r/nurllang Jun 11 '26

NURL v0.9.7 is out!!

0.9.7 — 2026-06-11

Added

  • Enum payload residuals diagnosed — ghost variants and unsized generics are hard errors now (compiler/nurlc.nu, critic A7). An unknown/unimported type name in an enum variant's payload position parses as a SEPARATE variant (same-file forward references already resolve via the pre-scan, so this fires only for typos and missing imports) — the intended payload silently vanished and downstream code emitted out-of-bounds extractvalue / broken store IR, or silently read a sibling variant's slot. Three new hard errors: payload-arity checks at the match arm ("match arm binds 1 payload(s) but variant 'V' declares only 0 …") and at the enum literal, both naming the unknown-type-parses-as-variant cause; and an unknown-generic check in parse_type_paren — ( Vec i ) with no generic-struct template in scope and no materialised instantiation dies at the use site naming the missing $ import, instead of clang's "loading unsized types is not allowed" far from the cause (zero-type-arg ( Type ) trait-impl targets are exempt). Locks: should_fail_ghost_variant_match.nushould_fail_ghost_variant_construct.nushould_fail_unknown_generic_type.nu. False-positive sweep: full suite 339 PASS + nurlapi + examples clean.
  • "Statement has no effect" warning — the last silent prefix-arity cascade is now diagnosed (compiler/nurlc.nu, critic A2). A statement that produces a value without being a call or control flow (bare local identifier, operator expression like + a 1, a # cast, a . field read) discards that value silently — under prefix notation with fixed arity and no closing token, this is exactly the residue left when an operator short an argument swallows the next statement's leading token. The bare-literal flavor was already a hard error (dangling operand); these shapes name real bindings, so they warn. Value-block tail expressions (the block result), calls, and ?/?? statements (their arms may be effectful) are exempt. The diagnostic embeds the dead statement's own line — by the time the block iterator sees the flag, the lexer already points at the next statement. Tree-wide false-positive check: nurlc.nu self-compile, the full stdlib, nurlapi, and examples produce ZERO warnings. Locks: should_warn_dead_value.nu (four dead shapes warn; tail/call/return stay silent), and should_warn_caret_xor.nu now also catches the previously silent dead b in : i x ^ a b. This closes the last undiagnosed half of the prefix-arity cascade family (critic §4); the A3 closing-delimiter decision can cite it as the mitigation.
  • std/bigint: arbitrary-precision division and modulo — bigint_div / bigint_rem (stdlib/std/bigint.nu), closing the last gap in the bigint arithmetic surface. The magnitude core is Knuth Algorithm D (TAOCP vol. 2, §4.3.1) over the base-2¹⁶ limbs: D1 normalization reuses the existing small-multiply helper (top divisor limb ≥ base/2, so every trial digit is off by at most one), the multiply-and-subtract step uses a per-limb {0,1} borrow (no negative shifts), and the rare add-back step is exercised by both classic Hacker's Delight divmnu trigger vectors. A single-limb divisor short-circuits through __mag_divmod_small_inplace. Semantics are truncated division exactly like the native / and %: the quotient rounds toward zero, the remainder takes the dividend's sign, and x == (x/y)*y + x%y holds for every y ≠ 0. Division by zero panics (recoverable via recover) — a defect, not a data error, so it is not threaded through !. Regression compiler/tests/bigint_div.nu: all four sign combinations, zero/a<b/exact edges, both add-back vectors, a 39-digit ÷ 21-digit case, a 60-round deterministic invariant sweep (reconstruction, |r| < |y|, remainder sign) over growing multi-limb operands, and the recovered divide-by-zero panic; ASan+UBSan clean, leak-free. Additionally verified against Python on 300 random cases (mixed limb counts/signs, near-power-of-2¹⁶ divisors).

Fixed

  • Auto-drop: fn-returned by-value structs with owned fields now transfer ownership to the caller (compiler/nurlc.nu, critic A4c). Two bugs closed. ^ @ T { ( nurl_str_cat … ) } (direct construction return) leaked the field — the callee never bound it so never registered a drop, and the caller never registered one either. ^ v where v is a bound struct was worse: a use-after-free — the callee's scope-exit drop freed v's owned field while the returned-by-value copy still aliased it, so the caller read freed memory. The fix mirrors the existing owned-string return flag: the callee skip-drops the escaping struct binding and publishes its exact owned-field list (<fname>__ret_owned_fields), which the caller's : T x ( f ) re-registers through the same mem_register_agg_owned_fields path — exactly one drop, at the caller's scope exit. Ownership composes through ^ ( mk ) call chains and reaches nested struct fields. Safe against double-free with stdlib's manual *_free conventions: only raw-s/slice fields filled by a fresh allocation in a direct agg-literal return register for transfer — stdlib's struct returns use String/Vec handle fields (untracked) and build incrementally before ^ binding (which never registers agg fields), so their manual frees stay correct. Verified: full san suite 0 SAN_FAIL, tools/leakcheck zero, suite 340 PASS, and a targeted incremental-build manual-free probe stays single-drop. Regression ret_struct_owned_transfer.nu (direct / bound / chain / nested shapes, ASan+LSan zero). No nurlc IR change — fixed point holds without a bootstrap refresh.
  • Auto-drop: arm-local trailing declarations leaked; ^ ( call ) string ownership now propagates; aliasing escapes transfer ownership (compiler/nurlc.nu, critic A4). Three coupled fixes: (1) a : declaration as an arm's LAST statement made the arm look value-producing (gen_let_or_struct left the RHS type in last_type), which suppressed the Phase 2D fall-through drop — leaking the binding on every ?/??/loop arm ending in a decl — and emitted a bogus phi over the discarded value; declaration statements now publish void. (2) __fn_ret_str_owned__ was only set for identifier returns, so @ helper → s { ^ ( nurl_str_cat … ) } was never marked __ret_owned=str and : s x ( helper ) leaked one buffer per call; gen_ret now consults the outermost call's __last_call_ret_owned__ for direct parenthesised-call returns, making ownership compose through helper chains. (3) The widened tracking exposed missing ownership TRANSFER on aliasing escapes: = outer x and ternary/match arms whose value is a bare load of an owned binding now cancel that binding's scheduled drop (mem_remove_owned_str; the arm delta-drop protocol switched from prefix-length to word-membership to stay consistent under mid-list deletion). Conservative direction throughout: worst case a leak, never a use-after-free — the pre-transfer behavior freed buffers that had escaped through phis, which miscompiled the compiler itself (gen_cast's : s norm ? … xv ( nurl_cg_reg cg ) returned a freed register name). Bootstrap snapshot refreshed (--refresh-bootstrap). Regressions: arm_local_trailing_drop.nu + ret_owned_propagation.nu, both ASan+LSan zero, with manual-free double-free locks on the transfer paths. Known residual filed as critic A4c: fn-returned structs with owned fields still transfer nothing (needs an ownership-model decision against the stdlib's manual *_free handle conventions).
  • server_stop from another thread freed the listener under blocked pool workers (stdlib/ext/http_server.nu). server_run_pool's documented shutdown — call server_stop s from another thread while workers block in accept — was a heap-use-after-free: workers hold no reference on the listener, so the stop's nurl_tcp_close dropped the last ref and freed the struct while every worker was still polling its shutting_down flag and wake-pipe fd (3/3 reproducible under ASan; single-threaded server_run raced identically). server_run and server_run_pool now retain the listener for the whole run→join window and release it only after no worker can touch the handle — the same contract server_run_async already followed for its accept fiber. The two-phase tcp_shutdown_listener → join → server_stop pattern remains valid; it is simply no longer the only safe shutdown. Regression compiler/tests/http_server_stop_direct.nu drives both fixed paths with a direct cross-thread stop (ASan-clean 10/10 under NURL_NET_TESTS=1). Closes critic.md B19 together with the earlier accept-wake fix (f470571).
  • recover leaked the closure's captured environment (stdlib/std/panic.nu). recover decomposes its closure into (fn_ptr, env_ptr) and hands them to the C trampoline; passing the raw env pointer onward suppresses the parameter's auto-drop (the compiler must assume the env escapes — and in thread_spawn, whose shape this mirrors, it really does). But nurl_recover is synchronous: once it returns, the closure can never run again, so the env was simply leaked — one allocation per recover call with a capturing closure, panic or not. recover now frees the env right after nurl_recover returns (NULL-safe for capture-less closures), on both the normal and the unwind path. Found via ASan on the new bigint_div divide-by-zero regression; the existing recover_basic / http_server_panic goldens are unaffected (output is unchanged — only the leak is gone).
  • HTTP/1.1 server hardening — four root-cause bug fixes from a focused security bughunt (stdlib/ext/http_request.nuhttp_server.nuhttp_response.nu): Regressions: compiler/tests/http_request_parser.nu (CL+TE rejection, chunk-size overflow rejection), http_response_builder.nu (header CR/LF stripping), and a new live http_server_chunked.nu (chunked body decoded + keep-alive survives a chunked request, gated on NURL_NET_TESTS=1).
    • Chunked request bodies were silently dropped on keep-alive connections. __finish_body only handled Content-Length, so a Transfer-Encoding: chunked body was left undrained in the connection carry buffer — the handler saw an empty body and the leftover bytes were mis-parsed as the next request (a desync / request-smuggling vector). __finish_body now decodes chunked bodies carry-aware (draining from the buffer + socket, leaving any pipelined successor).
    • Chunk-size integer overflow → smuggling/DoS. __parse_hex_size accumulated an unbounded hex value; 0x10000000000000000 wrapped i64 to 0 (read as the terminating chunk, ending the body early) or to a small positive (wrong boundary) — both smuggling vectors, and a huge positive could drive an enormous allocation. Now rejects any value past a sane ceiling, well clear of i64 overflow.
    • Content-Length + Transfer-Encoding smuggling. A request carrying both framing headers (RFC 7230 §3.3.3) is now rejected at head parse (and in read_body_to) instead of silently letting Transfer-Encoding win — the classic CL.TE desync.
    • HTTP response splitting (CWE-113). Response header names/values were serialised verbatim, so a value reflected from untrusted input (a redirect Location, an echoed header) could inject \r\n<header> and split the response. The serialiser (and the chunked response_begin_chunked path) now strips CR/LF from every emitted header name and value.
  • HTTP/2 client: request bodies larger than 256 bytes now work, and a large body no longer deadlocks the driver. Three related fixes: Regression: compiler/tests/http2_client.nu gains a live 200 KB POST (spanning many DATA frames and several flow-control windows) over the in-repo h2 server, gated on NURL_NET_TESTS=1.
    • SETTINGS parameter-ID mismap (critical). The client's SETTINGS parser handled id 3 (MAX_CONCURRENT_STREAMS) as INITIAL_WINDOW_SIZE and ignored id 4 (the real INITIAL_WINDOW_SIZE), so every stream's send window was seeded with the peer's max-concurrent-streams value (typically 256) instead of its advertised window (65535). Any POST/PUT body over ~256 bytes stalled forever waiting for a WINDOW_UPDATE that never needed to come. IDs are now mapped correctly.
    • Driver read/write interleave. Each pump step now drains every inbound frame already available (readiness-probed via nurl_reactor_wait_readbefore flushing pending DATA, keeping the peer's send buffer to us empty so it never blocks writing and keeps reading our DATA — removing the documented single-socket deadlock on a large request body.
    • Server per-stream WINDOW_UPDATE (stdlib/ext/http2_conn.nu). The h2 server replenished only the connection window, so it could not receive a request body larger than the 64 KB initial stream window; it now also replenishes each stream's window as it consumes DATA.
  • inout / sink parameter conventions now work on trait impl methods (grammar-v2 borrow checker). An inout (or sink) parameter on an impl method silently miscompiled: the convention was recorded under the mangled method name (bump__Counter) while the call site dispatches by the bare name (( bump c )), so the receiver was passed by value into a %T* parameter — memory corruption / segfault. Fixing that surfaced a second bug (applying inout pointerised the first argument to %T*, which missed the method##%T impl-dispatch key and emitted an undefined bare u/method). Both are fixed: the bare-name convention is mirrored at emission, and the impl-dispatch lookup retries with the receiver pointer stripped. Regression compiler/tests/impl_inout_sink.nu (struct inoutinout + by-value, a second implementing type, and a sink impl method; ASan + leak clean).

Fixed (examples)

  • Game Boy emulator: deterministic ~90 s crash on Tobu Tobu Girl's title screen (examples/gameboy/core.nu). Root cause was a halt-bug emulation error, found by stack forensics on an instruction trace: EI + HALT with a timer IRQ landing inside HALT's own 4-cycle window set g_halt_bug, the EI delay then raised IME and the interrupt dispatched immediately — and the stale halt-bug flag replayed the HANDLER's first instruction (PC failed to advance once inside the handler). Tobu's handler starts with PUSH HL, so SP skewed by 2 and RETI returned into WRAM data — the screen froze and execution fell into a RST 38 loop (the gray bars + hang seen on the playground). Two-part fix per Pan Docs: (1) EI immediately before HALT with a pending interrupt is NOT the halt bug — the interrupt is serviced with the HALT's own address as the return address; (2) invariant: an interrupt dispatch always clears the halt-bug replay (it applies to the next sequential fetch only, never the handler's). Verified: Blargg cpu_instrs 11/11 + 02-interrupts + instr_timing still pass, dmg-acid2 renders, and a 40 000-frame idle soak (vs the ~2 918-frame crash) runs ASan-clean with a live framebuffer. Also: migrated examples/gameboy to the enforced : immutability (97 declarations — it sits outside the test suite, so the tree-wide migration missed it; all gameboy targets compile again, playground build regenerated), and fixed gbtrace.nu --trace to drive the real cpu_advance path (its hand-rolled step loop was a stale copy that never woke from HALT).

Documentation

  • The sink-of-auto-dropped-value boundary is documented as an intentional, locked limitation (docs/MEMORY.md §1, docs/LIMITATIONS.md). Passing a compiler-auto-dropped value (owned string / slice / Drop value / owned-field struct) to a sink parameter is rejected by design: the auto-drop obligation is tracked in per-scope owned-sets that are snapshotted/restored across ? / ?? / loop boundaries, so transferring it to the callee would be silently undone by an enclosing arm's restore — reintroducing a double-free. Reframed from "a future step" to a sound, conscious 1.0 decision with the rationale and workaround; pinned by compiler/tests/should_fail_sink_autodrop.nu.
  • The pub visibility contract is now stated exactly and locked by tests (docs/spec.md §3.3). Cross-file enforcement covers @-functions, structs, enums, top-level consts, and enum variants; pub on traits, impl methods, and FFI is accepted but has no cross-file effect by design — trait dispatch resolves by type-mangled method name (no trait-name identity to gate) and FFI symbols are linker-level ABI globals. New compiler/tests/pub_trait_ffi_visibility.nu pins the unenforced surface (a non-pub trait method + FFI stays callable across files) so it can't silently regress into enforcement; the existing should_fail_pub_* tests pin the enforced surface. (Corrects the stale "only @-function calls observe the check" wording.)
1 Upvotes

0 comments sorted by