Skip to content

The Jac No-GC Profile: headerless correctness, receiver modes, container moves, borrow returns, views by inference, lin, and the [memory] flag clean break (#8984) - #8986

Merged
marsninja merged 25 commits into
jaseci-labs:mainfrom
marsninja:nogc-profile
Sep 6, 2026

Conversation

@marsninja

@marsninja marsninja commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Executes the No-GC Profile RFC (#8984) end to end, as one PR by request.

What lands

  • Correct headerless codegen for the everyday owned shapes (Part 1 A1–A5): named own locals into optional, inline and constructor-argument places; list[own T].pop(); subclasses in list[own Base]; borrow returns of list elements; generics over borrowed lists. Each shape is pinned by an identity test (tests/compiler/backends/native/test_nogc_identity.jac): nogc, rc, managed and jac run must print the same text.
  • Rule 3: container stores are moves (append, insert, dict stores, list literals take named own archetypes; dict overwrite and del drop the old value; pop hands the element out), plus take(place) and swap(&mut a, &mut b) builtins on every backend (E1316, E1317).
  • Rule 2: receiver modes inferred per method, explicit via def m(self: own T); E1303/E1309/E1318 for mutation through the wrong receiver.
  • Rule 4: borrow returns tied to the union of borrowed inputs.
  • Rule 5: views by inference; a type that transitively holds a borrow is stack-only and second-class (E1306/E1315).
  • Rule 6: errors without unwinding under enforcement: inferred raises effects, a hidden error slot, static drops on the error path, finally, and E1407 for an entry block that does not handle a raising call.
  • Rule 7: lin (must-consume) with E1305 from a must-analysis over the CFG.
  • Rule 8: the layout rule (inline only for a closed-world leaf archetype with no heap slots) decided once from the type.
  • Rule 1: under enforcement an unmarked local infers its state from its right-hand side (fresh values own, string literals imm, field and element reads borrow their root with the root's mutability decided by use, &place and reborrows tie to their owner, agreeing conditional arms take their state); contract positions stay explicit in every module. LiteralString results (p.upper() on a literal-narrowed receiver) count as str for the container fence.
  • Rule 9: inlay hints for inferred ownership states, view bindings, receiver modes and raises effects; jac explain memory | placement | ir; E1406 now names the value, why it cannot enter the owned world, the destination, and the idiom that fits (out.append(own p), take(place), iterate by value, or a fresh value). Builtin container mutators (append, insert, ...) are &mut self methods under the receiver discipline, so a shared borrow can no longer grow a container (E1318).
  • Rule 10 (hard clean break, no aliases): [memory] profile = managed | rc | nogc (+ enforce / exempt), [native] target / opt / debug / threads, [placement] default; jac build <file> --native [--lib] [--memory] [--target-triple] [--debug] replaces jac nacompile; --assert-no-rc and --strict are invariants; --scrub is jac clean --cache; no compile-time JAC_* variable sets behavior; a built binary reads only JAC_GC=off and JAC_THREADS; the cycle collector runs by default under managed.

Build fixes found on the way

  • The zig build script's launcher stub step invoked the removed nacompile verb; it builds through jac build --native now.
  • A --lib native build (the payload's compiler kernel) no longer refuses Python-only demotions in its closure: a shared library runs those in Python, and only a self-contained executable cannot.

Native codegen fixes found on the way

  • A borrow local (inferred from a field or element read, or written &place) is exempt from headerless scope-exit and overwrite drops; before, it = xs[0]; it.name = ... destroyed the element.
  • The owned-list layout (inline array-of-structs versus pointers) is decided once from the element type regardless of emission order; an entry-block global typed before the element archetype's body was registered used to get the inline layout while every later use chose pointers.

Runtime fix found on the way

  • With the cycle collector on by default, an object buffered as a possible cycle root that then reaches a zero count is now removed from the roots buffer and destroyed immediately (drop hook included); before, it waited for a collection a short program never runs. The identity tests caught it.

Dev-loop fixes found on the way

  • A dev checkout no longer parses through the shipped native compiler kernel (the kernel lookup stops at the checkout under the dev-source reroute).
  • A stub catalog shipped in the binary is validated against the checkout, so .pyi edits take effect.
  • Dependency adoption from another program exempts stub trees and never replaces the foreign program's live tree (the previous rule re-parsed typeshed unboundedly during a catalog build and broke the build-kit kernel step).
  • The structural loop-move rule is gone; the CFG dataflow already proves the moved-in-a-previous-iteration case.

Deviations from the RFC text

  • --as client stays (the RFC lists client as leaving the --as list; it has no replacement spelled out, so it is out of scope here).
  • examples/jaclang_org/jac.toml moves from [gc] default = "none" plus enforced modules to [memory] profile = "managed" with the same modules under enforce: a headerless build without enforcement is the unsound corner the RFC retires.
  • JAC_NA_DEBUG remains as a developer switch: it only prints, and jac explain placement is the user-facing surface.

Verification

Targeted suites (identity, ownership code table, container fences, local inference, raises, abort drops, ownbench differential, compile options, verb surface, docs parity, language server) pass locally; CI covers breadth. tests/support.native_memory_flags is the single place the test flags live.

… in the callee, take()/swap() move out of places

Under --gc none a named `own` local stored into an own field, an optional own
field, or an inline-flattened field left its binding live, so the last-use drop
freed the value the field now owned (jaseci-labs#8984 A1, A1b). Own-typed optional fields
were never torn down by the parent's drop, own parameters were never dropped
by the callee, and the structural loop-move rule in the checker rejected a
binding that the dataflow could see was revived before the back-edge.

- OwnershipCheckPass stamps `Module.own_move_sites`: the Name uses at which a
  verified-owned binding is consumed. The native backend consults it and
  `rc_move_in` through one primitive, `_nogc_consume_local`, which nulls the
  source slot at a move; the three inline copies in container element prep
  collapse onto it and field stores now use it.
- `__drop_<T>` tears down optional own fields; scope, loop-iteration, and
  last-use cleanups release optional slots in every gc mode; overwriting an
  own field drops the old value; own parameters are owned by the callee under
  headerless codegen and get last-use facts from RcFactsPass.
- `take(place)` and `swap(&mut a, &mut b)` builtins: type stubs, Python
  runtime helpers with compiler lowering for names, attributes and items,
  native lowering through a shared `_codegen_place_ptr`. E1316 rejects moving
  a heap value out of an owned slot into an owning destination without take().
- The structural loop-move rule and `_loop_depth` are removed; the CFG
  dataflow already proves the moved-in-a-previous-iteration case.
- Under the dev-source reroute the stub catalog locator validates a shipped
  catalog's key against the checkout, so a `.pyi` edit rebuilds the dev
  catalog instead of silently typing new builtins as Unknown.
- tests/support gains native_build/native_run/python_run/assert_native_identity
  and the nogc_identity fixtures pin headerless == rc == cycles == python.
…/insert/del/extend rebox or memmove by value; E1317 for index moves

`list[own T]` selected the inline (AoS) element layout from the annotation
alone, which the type system erases, so any seam that paired `list[own T]`
with `list[T]` (a `&list[T]` parameter, a generic instantiation, a borrow
return) ran pointer-layout accessors over a by-value buffer (jaseci-labs#8984 A4, A5),
and a base-typed list stored subclass payloads truncated to the base
(jaseci-labs#8984 A3). `pop()` on an AoS list handed out an interior pointer that the
receiver later freed (jaseci-labs#8984 A2).

- The layout is decided once, in `_lower_class_type`, from the element's
  semantic type: inline only for a closed-world leaf archetype (no subclass
  anywhere in the program) with no heap slots, in a nogc-enforced module.
  Type variables resolve through the monomorphization solution, so a generic
  body and its caller agree. The syntactic `list[own T]` shortcut is gone.
- AoS `pop` reboxes the element into a fresh shell before the tail moves;
  `insert`, `del xs[i]`, and `extend` move payloads with memmove/push;
  `remove` (identity equality) is not lowered for by-value elements.
- Every new static drop (optional own fields, optional slots at scope, loop
  and last-use cleanup, own parameters in the callee, drop-on-field-overwrite)
  applies only in the owned world: a nogc-enforced, non-kernel module.
  `CompileOptions.nogc_enforced_for` is the single predicate (it now covers
  the CLI target) and the checker delegates to it.
- Constructor arguments consume their move-site source, so an `own`
  parameter stored into a field is not dropped again by the callee.
- E1317 rejects moving a heap element out of an owned list into an owning
  destination; `pop()` and borrows are the two ways out.
- The nogc_identity suite grows an owned-list operations fixture and the
  generic fixture returns a borrow; 9/9 identity tests pass headerless.
…d written as a typed self

A method call was invisible to borrow discipline: nothing said whether a
method read, wrote, or consumed its receiver, so `c.inc()` while a shared
borrow of `c` was live compiled clean (jaseci-labs#8984 B2, rule 2).

- OwnershipCheckPass infers a receiver mode per method: `&mut self` when the
  body assigns a field of self, grows or mutates a container field, takes
  `&mut self`, or calls another mutating method (fixpoint over self-calls,
  cycles resolve to the least mode); `&self` otherwise. The mode is stamped
  as `Ability.receiver_mode`, the fact an editor hint reads.
- The explicit form reuses the typed-self parameter the grammar already
  parses: `self: own T` consumes the receiver, `self: &mut T` and `self: &T`
  pin the mode. E2015 now admits an explicit self in obj/node/edge/walker
  methods only when it carries a mode, and says so.
- A mutating call is a write: E1303 while a shared borrow is live, E1309 on
  an `imm` binding, and E1318 when reached through a shared borrow. A
  `self: own` call consumes the receiver in the dataflow (E1301 on a later
  read) and is a move site the native backend consumes; the callee owns and
  drops it under the owned world.
- The Python backend no longer emits a duplicate `self` when a method
  declares its receiver explicitly.
- Eight receiver-mode cases join the ownership code table and an identity
  fixture pins the own-self drop across backends.
…d containers of borrows are stack-scoped, second-class values

A type that transitively holds a borrow is a view (jaseci-labs#8984 rule 5, B1):
an archetype with a `&`-typed `has` field, a binding declared over a
borrow (`list[&Item]`), or a value built from either. The checker now
treats such bindings as borrows of everything they were built from, so the
existing E1302/E1303/E1304/E1306/E1308 rules police them: a view returned
over a local is E1306, stored into a field or global is E1306/E1315, sent
across `flow` is E1308, and mutating an owner while a view of it is live is
E1303. A view derived from the function's borrowed parameters may be
returned, and a return with several borrowed candidates ties the result to
all of them (rule 4) instead of being rejected.

- BorrowMeta carries extra owners; writes and moves are checked against
  every owner of a view. View bindings created from constructors reach in
  the borrow dataflow like `&x` bindings do.
- The native backend never drops through a borrow field, lowers a
  declared `list[&T]` with the pointer layout regardless of the element's
  owned layout, and stamps no element drops on a view container.
- Drop hooks are resolved through a forward declaration, so a list of
  hooked elements referenced from a struct field before the method is
  declared still runs the hook when the list drops.
- E1315 is the general view code; its text names the three view forms.
- Eight view cases join the ownership code table, a view fixture joins the
  identity suite, and the reference documents take/swap, receiver modes,
  the owned-list layout rule, and the new codes.
…er modes of foreign methods come from the stamped fact, never from walking another module's tree
…only when it carries type facts

A cold compiler shares the self-host program's module trees with the
program being analyzed. A tree the self-host built for bytecode alone has
no TypeOf facts, so a base class resolved through it was a hollow class and
every inherited method vanished (jac check on scale/plugin.jac after a
compiler edit). The analysis ledger already says whether a tree is typed;
load_dependency_module now consults it before adopting a foreign tree and
otherwise compiles its own analysis view, counting the skip as a cache
event.
…ing, inlay hints for inferred ownership

Rule 7: `lin` is a keyword. A `lin` binding is an `own` binding in every
respect (moves, borrows, drops, codegen) plus a must-consume check: a
forward must-analysis over the CFG (`solve_forward_must`) reports E1305 on
any path where the binding reaches the end of its scope unconsumed.
`SubTag.linear` and `Symbol.linear` carry the marker; the compiler's own C3
code no longer uses `lin` as a name.

Rule 3 (containers): a named `own` archetype binding appended or inserted
into a list, stored as a dict value, or placed in a list literal is a move.
The checker treats grow-method arguments of archetype type as consuming,
the E1406 fence admits fresh and moved archetype elements for lists and
dict values (sets and borrowed values stay fenced), dict overwrite and
`del` drop the old value through the element drop slot under headerless
codegen, inline-layout list literals use the inline helper set, and rc and
cycles transfer ownership at the call the way nogc does
(`NaIRGenCalls._transfer_own_args`, managed branch of `_nogc_elem_prep`),
so every profile prints the same drop order.

Rule 6: under enforcement `raise`, `try` and `except` lower to a hidden
error slot instead of setjmp/longjmp. Every ability carries an inferred
raises effect (`Ability.raises`, fixpoint in the ownership checker); a raise
stores into shared `__jac_err_*` globals and propagates; each call to a
raising function is followed by a check that dispatches to the enclosing
`except`, runs `finally`, or drops the frame's owned locals and returns; an
entry block that does not handle a raising call is E1407 at compile time.

Rule 9: `textDocument/inlayHint` in the language server, backed by one
`inferred_hints` producer in the compiler (ownership states of unmarked
locals, view bindings, receiver modes, raises effects).

Dev loop: under the dev-source reroute the native compiler kernel lookup
stops at the checkout (the shipped kernel was still lexing and parsing);
dependency adoption from another program exempts stub trees (the previous
rule re-parsed typeshed unboundedly during a catalog build) and never
replaces the foreign program's live tree.

Part of jaseci-labs#8984.
… --native, jac explain, and no compile-time JAC_* variables

Rule 10 of jaseci-labs#8984 as a hard clean break. One declaration of memory intent:
`[memory] profile = managed | rc | nogc` with `enforce` / `exempt` for the
incremental case, replacing `[gc] default` and `[gc.enforce]`;
`[native] target / opt / debug / threads` replaces `[build.native]`;
`[placement] default` replaces `[build] default_codespace`. Retired
sections are reported with their new home; both config loaders share one
parser (`_apply_memory_native_placement`, `_apply_build_section`).

`jac nacompile` is gone (tombstoned with the new spelling). `jac build
<file> --native` compiles one self-contained artifact, a binary when the
module has `with entry` and a C-ABI library otherwise (`--lib` forces it),
with `--memory`, `--target-triple` and `--debug` as its only knobs; `native`
leaves the `--as` list. The former flags became invariants: the RC-free IR
scan always runs under `nogc`, a demoted function always fails a native
artifact, and `--scrub` is `jac clean --cache`.

`CompileOptions` speaks `memory_profile` and derives the runtime mode; no
compile-time environment variable sets behavior (the target override is a
context variable beside the codespace override). A built binary reads only
`JAC_GC=off` and `JAC_THREADS`, and the cycle collector runs by default
under `managed`.

`jac explain memory | placement | ir` replaces the diagnostic variables and
`jac check --placements`, sharing the `inferred_hints` producer with the
language server.

Turning the collector on by default exposed two runtime faults that the
identity and differential tests caught: a release that drops a buffered
possible root to zero skipped its destructor (now it empties the roots
slot, clears the buffered bit, and reclaims at once, and the collector's
root passes skip emptied slots), and the driver's option carry-over must
copy only enforcement facts, never the runtime mode.

Tests, docs, example scripts and the desktop build paths are migrated;
`tests/support.native_memory_flags` is the single place the test flags
live.

Part of jaseci-labs#8984.
… and E1406 says what to write

Rule 1 of the No-GC Profile (jaseci-labs#8984): in an enforced module an unmarked
local takes its state from its right-hand side. A call, container
literal, f-string, concatenation or `own` copy is `own`; a string literal
is `imm` (or `own` once rebound); a field or element read borrows its
root, mutably when the root allows it and the local is written through;
`&place` and reborrows tie to their owner; a conditional whose arms agree
takes their state, and one whose arms disagree stays E1401. Contract
positions stay explicit in every module.

Rule 9: E1406 names the value, why it cannot enter the owned world, the
destination, and the idiom that fits the site (`out.append(own p)`,
`take(place)`, iterate by value, or a fresh value); `own <expr>` on a str
is the accepted copy-in idiom. The builtin container mutators join the
receiver discipline as `&mut self` methods, so a shared borrow can no
longer grow a container (E1318).

The container fence treats the type system's `LiteralString` as str, so
`p.upper()` on a literal-narrowed receiver no longer trips it.

Native backend: a borrow local is exempt from headerless scope-exit and
overwrite drops, and the owned-list layout is decided once from the
element type regardless of emission order (an entry-block global typed
before the element archetype's body was registered used to get the
inline layout while every later use chose pointers).

Build: the launcher stub builds through `jac build --native`, and a
`--lib` build no longer refuses Python-only demotions in its closure.
…memory takes --memory

The clean break removed compile-time reads of JAC_NATIVE_TARGET, so the
cross-target tests (wasm32, aarch64, windows, darwin) now set the target
through push_native_target / pop_native_target from the compile-options
module, and the two aarch64 subprocess tests pass --target-triple.
jac build --native --target-triple publishes the triple to every
compile-time consumer for the duration of the compile, the way the wasm
builder already did.

jac explain memory <file> --memory managed|rc|nogc explains a module under
a profile other than the project's and prints the RC coverage line on
stderr; the stack-promotion test reads promoted=N from it instead of the
retired JAC_RC_STATS variable. The wasm RC audit test probes for JAC_GC,
the one variable a built binary still reads.
# Conflicts:
#	jac/jaclang/compiler/backends/native/na_ir_gen/calls.jac
#	jac/jaclang/compiler/backends/native/na_ir_gen/container_helpers.jac
#	jac/jaclang/compiler/backends/native/na_ir_gen/exceptions.jac
#	jac/jaclang/compiler/backends/native/na_ir_gen/func.jac
#	jac/jaclang/compiler/backends/native/na_ir_gen/lists.jac
#	jac/jaclang/compiler/backends/native/na_ir_gen/objects.jac
#	jac/jaclang/compiler/backends/native/na_ir_gen/refcount.jac
#	jac/jaclang/compiler/backends/native/na_ir_gen/state.jac
#	jac/jaclang/compiler/backends/native/na_ir_gen/types.jac
#	jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/calls.impl.jac
#	jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/container_helpers.impl.jac
#	jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/exceptions.impl.jac
#	jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/func.impl.jac
#	jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/lists.impl.jac
#	jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/objects.impl.jac
#	jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/refcount.impl.jac
#	jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/types.impl.jac
#	jac/jaclang/compiler/backends/py/impl/jcir_gen_pass.impl.jac
#	jac/jaclang/compiler/backends/py/jcir_gen_pass.jac
@marsninja
marsninja marked this pull request as ready for review September 6, 2026 19:09
@greptile-apps

greptile-apps Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Too many files changed for review (376 files, 100 file limit).

Bypass the limit by tagging @greptile-apps to review.

@marsninja
marsninja merged commit f5001ba into jaseci-labs:main Sep 6, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant