Skip to content

v0.9.7

Choose a tag to compare

@Hindurable Hindurable released this 11 Jun 07:18
· 160 commits to main since this release
0fa1947

[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.nu,
    should_fail_ghost_variant_construct.nu,
    should_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.nu, http_server.nu,
    http_response.nu):

    • 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.

    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).

  • HTTP/2 client: request bodies larger than 256 bytes now work, and a
    large body no longer deadlocks the driver.
    Three related fixes:

    • 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_read) before 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.

    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.

  • 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 @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 inout, inout + 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.)