v0.9.7
[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-boundsextractvalue/ brokenstoreIR, 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 inparse_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), andshould_warn_caret_xor.nunow
also catches the previously silent deadbin: 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 Delightdivmnutrigger 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%yholds for everyy ≠ 0. Division by zero panics
(recoverable viarecover) — a defect, not a data error, so it is not
threaded through!. Regressioncompiler/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.
^ vwherevis 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_fieldspath — 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*_freeconventions: only raw-s/slice fields filled
by a fresh allocation in a direct agg-literal return register for
transfer — stdlib's struct returns useString/Vechandle 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/leakcheckzero, suite 340 PASS,
and a targeted incremental-build manual-free probe stays single-drop.
Regressionret_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 publishvoid.
(2)__fn_ret_str_owned__was only set for identifier returns, so
@ helper → s { ^ ( nurl_str_cat … ) }was never marked
__ret_owned=strand: 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 xand 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*_freehandle conventions). -
server_stopfrom another thread freed the listener under blocked
pool workers (stdlib/ext/http_server.nu).server_run_pool's
documented shutdown — callserver_stop sfrom another thread while
workers block in accept — was a heap-use-after-free: workers hold no
reference on the listener, so the stop'snurl_tcp_closedropped the
last ref and freed the struct while every worker was still polling
itsshutting_downflag and wake-pipe fd (3/3 reproducible under
ASan; single-threadedserver_runraced identically).server_run
andserver_run_poolnow retain the listener for the whole
run→join window and release it only after no worker can touch the
handle — the same contractserver_run_asyncalready followed for
its accept fiber. The two-phasetcp_shutdown_listener→ join →
server_stoppattern remains valid; it is simply no longer the only
safe shutdown. Regressioncompiler/tests/http_server_stop_direct.nu
drives both fixed paths with a direct cross-thread stop (ASan-clean
10/10 underNURL_NET_TESTS=1). Closes critic.md B19 together with
the earlier accept-wake fix (f470571). -
recoverleaked the closure's captured environment
(stdlib/std/panic.nu).recoverdecomposes 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 inthread_spawn, whose
shape this mirrors, it really does). Butnurl_recoveris
synchronous: once it returns, the closure can never run again, so the
env was simply leaked — one allocation perrecovercall with a
capturing closure, panic or not.recovernow frees the env right
afternurl_recoverreturns (NULL-safe for capture-less closures),
on both the normal and the unwind path. Found via ASan on the new
bigint_divdivide-by-zero regression; the existing
recover_basic/http_server_panicgoldens 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_bodyonly handledContent-Length, so a
Transfer-Encoding: chunkedbody 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_bodynow 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;0x10000000000000000wrapped i64
to0(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 inread_body_to) instead of silently lettingTransfer-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 redirectLocation, an echoed header) could inject
\r\n<header>and split the response. The serialiser (and the chunked
response_begin_chunkedpath) 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 livehttp_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
-
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 id3(MAX_CONCURRENT_STREAMS) as
INITIAL_WINDOW_SIZEand ignored id4(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.nugains a live 200 KB POST
(spanning many DATA frames and several flow-control windows) over the
in-repo h2 server, gated onNURL_NET_TESTS=1. - SETTINGS parameter-ID mismap (critical). The client's SETTINGS
-
inout/sinkparameter conventions now work on trait impl
methods (grammar-v2 borrow checker). Aninout(orsink) 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 (applyinginoutpointerised the
first argument to%T*, which missed themethod##%Timpl-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(structinout,inout+ by-value,
a second implementing type, and asinkimpl 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+HALTwith a timer IRQ landing inside
HALT's own 4-cycle window setg_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 andRETIreturned 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)EIimmediately beforeHALTwith 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: Blarggcpu_instrs11/11 +
02-interrupts+instr_timingstill 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: migratedexamples/gameboyto 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 --traceto drive the realcpu_advancepath (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 /Dropvalue / owned-field struct) to asink
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
pubvisibility 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. Newcompiler/tests/pub_trait_ffi_visibility.nupins the
unenforced surface (a non-pubtrait 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.)