Skip to content

Gather requirements for release after v1.12.2 #718

Closed

Description

@mparrett

Tracking issue for the next cut after v1.12.2 (2026-07-20), modeled on #275 / #545. Freeze scope here, draft the upgrade notes, run the pre-release checks, and list blockers before we tag.

Body rewritten 2026-09-18, refreshed 2026-09-20 against main @ 369e2a69. The merge wave the earlier snapshots were waiting on has drained: eleven PRs are open with none approved, against twenty open and four approved in early September. So the arcs below are consolidated from the whole range rather than layered as deltas. The comments underneath preserve the 2026-08-11, 09-06 and 09-09 states; nothing in them is lost by this rewrite, and where they disagree with this body, this body is current.

Scope snapshot

Range v1.12.2..main @ 369e2a69 (2026-09-20): 176 squash-merged PRs plus three direct commits, across 179 commits. 660 files, +88,494 / -10,655.

Excluding docs/perf/baseline.json, a regenerated data file, the range is +81,395 / -9,772 across 659 files. That is the figure for release-notes sizing.

369e2a69 is the current freeze candidate. Twelve PRs merged since the 2026-09-18 rewrite: #894, #795, #863, #871, #900, #901, #909, #906, #902, #910, #915, #916.

Concentration, by files touched: pkg/rt 139, pkg/vm 56, test/native-entry 19, pkg/ir 17, test/e2e 16, pkg/bytecode 14.


Capabilities

HTTP and networking. The namespace gains a handle you can hold. http/start, http/stop and http/wait, with serve now defined as start plus wait, and a bad address throwing at bind rather than from inside the loop (#898). Channel and lazy-seq response bodies written element by element with a flush after each, so a handler can pace or hold a stream (#851). Connect, request and stream_read timeout scopes on http/request, each naming itself in the error (#856). Clients inherit the caller's scope cancellation (#848). A first guide page, docs/guide/http.md. Alongside it, net/listen, net/accept and net/local-address give TCP the owned-listener surface unix already had (#896): @nnunley's patch, carried downstream in strange-lettractor until now.

Structured concurrency. scope-cancelled? lets Lisp code tell "woke up" from "was cancelled" after a blocking native returns (#850). bound-fn* now conveys only the binding stack and runs under the invoking scope (#852), and eval runs in the caller's execution context so binding and with-out-str reach the evaluated form (#853).

Clojure surface. Custom data readers via a dynamic *data-readers*, honored by read-string, load-string, ordinary compilation and child eval contexts, with a concurrency-safe registry for Go embedders (#770). *unchecked-math* stops being inert and rewrites +, -, *, inc and dec to their wrapping forms (#839). The Math/* namespace arrives with Long/bitCount, Integer/MAX_VALUE, Double/NaN and the rest, each pinned to JVM semantics rather than Go's: Java rounds -2.5 to -2 and Go does not (#840). Clojure's canonical (. obj member ...) forms compile (#841). Thread/currentThread resolves to the calling scope dressed as a java.lang.Thread, so the cooperative-cancellation idiom compiles and .isInterrupted means scope cancellation (#901). clojure.repl and clojure.pprint arrive with a Var-metadata bundle round trip (#668), clojure.data with structural projection (#711), and thrown?, thrown-with-msg?, run-test-var and run-test fill out clojure.test (#754, #671).

clojure.test, ported. #863 replaces the partial namespace with a real report, assert, fixture and runner contract. It merged ahead of its own conformance spec: the commit subject cites docs/specs/clojure-test-conformance.md, which is not on main — that file is #793, still open with changes requested. Worth landing #793 before the tag so the permanent commit message stops pointing at a missing file.

Host and OS. os/unzip (#688), os/rename and os/delete-tree (#698), os/absolute-path and os/canonical-path (#699), documented in a new docs/guide/os.md (#717). sha1 and sha256 join the hash namespace (#690).

Graphics, provisionally. glplat merged as a platform layer with two registry.Backend implementations, GLFW/OpenGL and Ebitengine, the second reaching the browser and Metal where the first is cgo-native-only (#744). The backends sit behind -tags glplat and -tags glplat_ebiten. The glplat namespace itself is present in a default build, with every native returning no backend registered and no graphics dependency linked; #789 gates untagged builds against the heavy subsystems, so x/image and x/text stay out (#788).

The package is marked provisional. The registry stays under internal/, and the planned rt.Display / *display* follow-up may rename natives and change Init's window-shaped signature, so nothing in it is a compatibility promise yet. The decision record is docs/design/glplat-backend-contract.md §5.1, where @nooga's call is that the release notes name the namespace experimental. #392 is @nnunley's re-scope of the seam and rebases onto main empty; leave it open. #874 carries the host-capability contract.

AOT and the compiler. The lowering driver is now the embedded lg.compiler namespace rather than a script on disk, so a shipped lg drives lowering from any directory (#735). That is the groundwork #596 asked for, with cmd/lgbgen's bundle-skip generalized so enrolling the driver does not drag the IR pipeline into core_compiled.lgb. --entry-frame emits a native-entry Go frame from main or -main (#628). unchecked-add, unchecked-subtract and unchecked-multiply become first-class IR and VM ops, so a hash or PRNG inner loop keeps its typed native path (#811). The lowerer types :int as Go int64 (#862), which is what makes the lowered tree compile under TinyGo.

TinyGo and WASM. The lane itself merged in #522, inside the range at every cut point under discussion. xxh3 is registered on TinyGo wasm builds through Wrap-based adapters, where the reflect-boxed generated binding could not go (#860). SPSC ring plus held-key coalesce for SAB key input (#595). RGBA frame sink as a host capability (#572). Plan 9 key reads wake on terminal resize (#743).

Bundles and footprint. Opt-in DEFLATE for .lgb (#501) and for the embedded core, default off (#502). lg -strip splits debug info into a digest-bound .lgb.debug companion instead of discarding it (#624), extended to -w bundles (#800). lg_no_http drops net/http from the runtime (#658). ir.data stops being compiled into the shared const pool before the bundle is written, taking core_compiled.lgb from 311,302 to 273,748 bytes (#838).

Embedder API. lg.IsIncomplete for REPL loops (#602), the lg.NS facade with Def and DefShadowing (#603), and api.LetGo.CompilerContext() so a host nREPL server evaluates against the running instance's compiler state rather than a second one (#803). #773 makes the interop packages self-contained and pkg/cli importable, so a third-party module can build a binary with lg's full feature set around its own generated interop.

Tooling. lgbdump for diffing bundles (#601), bootprobe and lgbstat for boot and bundle diagnostics (#623), lint.lg for breadcrumb and devlog comments (#836) with objective comment-abuse rules R1–R6 on top (#870), and spec-evidence, which makes fenced blocks in docs/specs/*.md executable (#861). A tracked AGENTS.md and contributor-workflow.md (#802).


Bugfixes and Clojure-divergence closures

Consumer-visible corrections, each of which changes the value existing code gets back:

Change
#898 [x & more] binds nil, not (). drop returned a truthy empty seq, so (if-let [[x & xs] s] ...) never terminated, and integrant's ig/init hung on any three-component chain
#843 clojure.string/split follows Pattern.split: trailing empty fields dropped, vectors not lists, a limit arity, and split-lines added
#854 clojure.edn/read-string reads data, not code forms: ^{:a 1} x attaches metadata instead of yielding (with-meta …), #{} is a set, interior #_ errors on an odd map, duplicates rejected
#820 json/write-json writes string map keys as text, not as their EDN reading. {"key" "value"} no longer emits {"\"key\"":"value"}
#901 format honors Java's %n. (format "Applying %s%n" "x") gave "Applying x%!n(MISSING)", since Go's Sprintf has no such verb
#855 line-seq surfaces read errors instead of ending the sequence silently
#764 Small maps preserve insertion order, matching array-map semantics
#762 reify accepts Object overrides: toString, hashCode, equals
#758 Symbols are invokable for collection lookup, as keywords already were
#760 Multi-arity dispatch routes an exactly-min-arg call to the variadic variant; an empty kwargs rest stays nil through the map destructure
#756 The var special form reaches private vars across namespaces; bare qualified symbols stay privacy-enforcing
#778 Collections cross the Go/let-go boundary as native values
#776 The reflect proxy boxes a multi-return func as a vector
#821 Typed-array backing slices survive interop
#611 *out* and *err* handles defer to the current stdout and stderr
#710, #713 Tab completion stops crashing on the cyclic refer graph
#734 warn-on-core-shadow fires for compiled defs
#798 test/run-tests restores the caller's namespace when called with explicit namespaces
#745 The bundle encoder no longer aliases chunks by scanning for identical bytecode, so two structurally identical functions cannot swap source metadata
#767, #779 Lowering: a disagreeing join gets per-branch closures; capture-free closure identity preserved

#771 catalogs the divergences that remain, narrowed for array-map by #826. Open and not closed by this range: #742, where nth accepts maps and sets and indexed? / counted? invert for seqs.


Compatibility and upgrade notes

Ordered by how likely each is to break someone.

Bundle compatibility, two independent breaks.

  1. The opcode set changed. feat(ir,vm): unchecked-add/subtract/multiply as first-class ops #811 appends OP_UNCHECKED_ADD, OP_UNCHECKED_SUB and OP_UNCHECKED_MUL. A .lgb built by an older lg is rejected by this one, the same trade perf(ir): thread typeinfer state + preserve [:dtype T]; complete numeric typing (contagion, :ratio, quot/div ops) #235 made when it added OP_DIV, and no migration entry was added on that precedent. feat(bytecode): actionable hint on capability-mask reject #608 and feat(bytecode): name capabilities in reject errors and lg -v #622 make the rejection name the capability rather than printing a bare mask.
  2. Compressed bundles are format version 3 (feat(bytecode): opt-in DEFLATE compression for .lgb bundles #501). Magic, version, flags, and the capability payload stay plaintext so compatibility checks run before decompression, but an older decoder rejects a version-3 bundle as an unsupported version, and version 2 now rejects the compression flag at the header. Uncompressed bundles stay version 2 with the pre-change encoding, and the committed core stays uncompressed (feat(lgbgen): compress the embedded core bundle (default off) #502), so the break reaches only someone who opts in with lg -c -z or lg -b -z.

Both rejections are the intended design. Both read as bundle corruption if the notes do not say so.

Int is 64 bits wide on every host (#859). On 64-bit targets nothing changes. On linux/386, linux/arm and TinyGo wasm, integers stop silently narrowing: (bit-shift-left 1 40) returned 0 and (* 1e9 1e9) trapped. The guarantee stops at the VM's arithmetic core. Collection indexing, the clojure.math *-exact family, range bounds and ToInt still convert through the platform int, so a value above MaxInt32 wraps at those boundaries; filed with reproductions as #867. Int.Unbox deliberately keeps returning the platform int because it feeds interop whose declared types are int, with ToInt64 the full-width read and MakeInt64 the full-width constructor. On a 32-bit host, values outside the small-int cache now allocate when boxed.

Go API removals and moves.

Toolchain floor: Go 1.27, with toolchain go1.27.1 in go.mod and golangci-lint pinned alongside it (#910), superseding the Go 1.26.5 floor #677 set earlier in this range. #916 then made the manifest generator take GOTOOLCHAIN from that toolchain line rather than GOTOOLCHAIN=local, so a contributor on an older Go can still run make generate. go 1.27 is the language floor. This changed on 2026-09-20 and is the single most likely thing to catch a downstream consumer by surprise.

Scope cancellation now propagates where it did not. #848 means an in-flight http/get, http/post or http/request aborts when the caller's scope closes, including one blocked reading an :as :stream body; before this, the request and its goroutine outlived the scope. #852 means work inside a bound-fn* is interruptible by the invoking scope rather than detached at root. Both are corrections, and both stop code that relied on the old outliving behavior.

#852's mechanism no longer ships; #863 replaced it. #852 rebuilt the call's context from the captured snapshot alone, which silently reverted any dynamic var the caller had bound but that was not captured — and since *file* is bound while loading a file the snapshot is essentially never empty, so that path was taken nearly always. #863 overlays the captured vars onto a private child of the caller's context instead, matching Clojure's push-thread-bindings. The property #852 fixed is kept. Describe the shipped behavior from #863, not #852.

That resolves #864, which is still open in the tracker because #863 never referenced it. Verified rather than assumed: a bound fn created under (binding [*x* :captured]) and called under (binding [*y* :caller-bound]) returns [:captured :root-y] on 9d32c1c4 (post-#852, pre-#863) and [:captured :caller-bound] on 827ef18b. Someone with the bit should close #864.

RunExecUnit load path (#628). Even without --entry-frame, RunExecUnit loads through LoadProgramNamespaces (NSOrder replay plus ApplyGoOverrides drain) and RunProgramMainChunk. Orchestrators that assumed the old MainChunk-skip loop should re-check. --entry-frame itself stays opt-in, and default lg-compile remains package-only for orchestrators owning their own frame. scripts/lg-compile keeps its path, argv, exit codes, and the EMIT-FAIL … pkg=… returned … stdout line after #735 moved the driver inside the binary.

WASM key input (#595). Single-slot SAB input became an SPSC ring: drop when full, held-key coalesce. Hosts that assumed oldest-pending-wins single-slot semantics should revalidate interactive and held-key paths. Measured against the xsofy shell, a synchronous burst of ten keys went from 1 accepted and 9 dropped to 7–8 accepted.

Default binary contents (#789). Untagged builds are gated against the GL and Ebitengine subsystems, so x/image and x/text no longer land in every default binary (#788). The glplat namespace is still registered in such a build; its natives return no backend registered until you build with -tags glplat or -tags glplat_ebiten.

Var metadata (#668). :arglists and source-location metadata round-trip through the bundle; the core bundle was regenerated. #781 then compacted the lazy metadata representation.


Performance

The direction is good and the measurement apparatus is still under repair.

What landed. reduce's ArrayVector and Range fast paths restored after the registrar shadowed them (#686). PreparedCall for per-element callback loops, adopted in some (#726) and in reduce's four fold loops (#727), made allocation-free in #794. Cold error arms moved out of the dispatch loop (#723), the runLoop hop removed (#730), defer scaffolding kept off the hot path (#719). Catalog-driven op and form-head dispatch (#612, #667). Direct-call natives for the hot clojure.core surface (#613), 222 primitives hoisted to //lg:native (#639), a per-package registrar (#640). Ordinary var loads re-emitted rather than copied (#579).

Where the earlier dispositions inverted. The 2026-08-11 comment proposed accepting #700's ~1.7× invoke cost and #663's startup regression with a note. #700's own analysis then found the cost is paid entering the VM from Go rather than being inherent to the explicit frame chain, since in-VM bytecode-to-bytecode invocation never regressed. #727 measured reduce at 50.5 ms against 58.7 ms on v1.12.2, below the release baseline rather than recovered toward it. #706 is closed because its content merged as #730, which is worth saying, or the closure reads as abandonment.

Why this section carries no number. The August probe table anchored "% vs v1.12.2" to a moving tip and went stale twice in four weeks. Since then the gate itself has been under repair: #780 landed deterministic rebaselining, a regenerated Go 1.26.5 baseline and a pre-push gate; #684 reseeded from a window; #740 freezes a per-release baseline on every tag. #791 then reported IRCompile and InitFromLGB regressed against that new baseline, attributing it to #727 allocations and #723 / #730 timing.

Repair is now visibly ahead of the defect list. #900 resolves #883: the deterministic bar is taken per benchmark from the entry with the newest provenance rather than the global minimum across profiles captured at different commits, seed-baseline stamps provenance so the null cannot recur, and the regression line names the commit its reference came from. That is #883's own options 1 and 3, and #900's write-up reproduces #883's exact evidence — the 8370C August row holding 7,041 allocs/op for InitFromLGB against 13,995–26,428 everywhere newer, gating current main at +69%. #883 stays open only because #900 refs #791 and #899 rather than it; it should be closed. #795 adds bench-baton, a machine-quiescence lease, which is the other half of getting a number anyone can trust.

Still open: #899 (timings gated on a point against a 5% budget rather than an interval from the stored samples), #884 (BenchmarkConsCreation measures an empty loop), #885 (the same size named two ways), #791 itself. #897 identifies readDefMetaPairs as 42% of InitFromLGB allocations. #891 recaptures the M3 profile, stacked on #900, changes requested.

#894 published the binary-size and boot-time audit behind #652. It does not satisfy the checklist item below: it compares v1.7.4 against ed4ecc2, a historical endpoint, not this release range. It is the method and the tooling for the audit, not the audit of this cut.

So the audit on the checklist stands unstarted, and it should run as one pass against a pinned SHA: startup, the invoke and reduce micros, WASM, and make bench-ratchet, measured once against a frozen commit, with the numbers dated in the notes.


Known open items that touch the cut

Decide accept, fix or defer before tagging.

Entry-frame, the feature the notes plan to advertise. #796 is fixed by #902; the rest are open:

Issue Why it matters
#796 Entry-frame binaries ran -main twice. Fixed by #902
#783 Lambda-lifted var (__lifted0) resolves to nil, panics in ExecContext.deref
#782 Native lowering: closures in loops capture the mutated slot, not the per-iteration value
#721 IR CSE merges var reads across set!; :mutated-vars only ever contains nil
#866 lg-compile emits non-compiling Go when a closure captures a typed param

#902 settles #796, and the shape it settled on matters for the notes. The first attempt set *compiling-aot* across the frame's replay, which @nooga rejected: the replay runs the whole top level including every required namespace, so that skips every when-not *compiling-aot* form in the program, not just the entry call — a library's runtime init silently dropped in an AOT binary. His framing is the one to keep: a slow binary versus a binary that quietly skips your setup code, and you can see a bad number in a benchmark but not an init that did not happen. The shipped fix has lg -c omit only the selected top-level entry call when it builds the bundle, so the fact lives in the artifact and *compiling-aot* keeps meaning one thing.

With that in, --entry-frame is advertisable. #783, #782, #721 and #866 remain open on the same surface; none is a correctness bug in what the flag emits. #724 frames what an AOT front door still needs.

AOT lowering frontier. #660 (silent fallback to bytecode), #678 (6/16 gold-aot fixtures Tier-2 under strict), #636 (a self tail call lowers to a Go call, so constant space on the VM becomes stack growth once lowered), #607 (lower-vm embedded fallback rejected by its own runtime). #607 did not reproduce when the encoder and embedded runtime share one LET-GO-SRC, which matches the dual-knob-skew diagnosis on the gloat side rather than a tip regression.

Measurement. #791, #899, #884, #885, #897, #705, #704, #663, #700, detailed above. #883 was resolved by #900 and is closed.

A gate is red on main going into the cut. #923: TestJankSuiteDirectABIGeneratedGo has failed since #863, bisected with a clean regenerate on each arm, and #915 does not cover it — #915 fixes the other half of the same fallout, the one-arity is self-reference behind 47 of 242 skipped jank-suite files. No workflow runs the failing gate: make native-entry-gate is invoked by none, and the test is in no -run list. The open question is whether deftest routing through clojure.test/test-var is correct by design, which decides whether the fix is a stale fixture or a lowering regression. Worth settling before the tag, since the test is what backs the direct-ABI claim.

Recent correctness, unresolved. #867 (wide Int narrowing at collection, math and interop boundaries on 32-bit hosts; #906 takes the pkg/rt leaf conversions and leaves the pkg/vm half until the ToInt question is settled), #865 (os/sh children outlive a killed parent), #875 (json/write-json drops an entry when two map keys coerce to the same JSON key), #742 (nth, indexed?, counted?), #873 (lint.lg R4 and the comment-churn gate). #864 is fixed on main by #863 and open only in the tracker.

Open PRs. Eleven as of 2026-09-20, none carrying an approval — every PR that held one has merged. #901, #902, #906, #909, #910, #915 and #916 all landed on 2026-09-19/20, which is what emptied the approved queue and settled the entry-frame question above.

What remains is @nnunley's reader, spec and bench-ratchet work in changes-requested or needs-review — #392, #736, #768, #793, #819, #891, #911, #917, #922, #924 — plus #895 of mine. The one with a claim on the cut is #793: #863's commit subject already cites the spec file it adds, and that file is still absent from main.


Pre-release checklist

Open questions

Downstream

Add other release-worthy items or blockers below.

Activity

  1. mparrett commented on Aug 11, 2026

    @mparrett
    CollaboratorAuthor

    @nooga Suggesting this one, too, depending on the cut (documentation freshening): #717

  2. mparrett commented on Aug 11, 2026

    @mparrett
    CollaboratorAuthor

    Probe results vs v1.12.2 + proposed dispositions

    Local tip @ f3ca5f9b (includes #628) vs tag v1.12.2, same machine (darwin/arm64). Cut-relevant subset only.

    Perf (hyperfine)

    Workload v1.12.2 tip tip/base
    startup lg -e '(+ 1 1)' (quiet, N=50) 9.4 ± 1.6 ms 11.8 ± 1.5 ms 1.25×
    (reduce + 0 (range 1e6)) N=20 37.8 ms 41.3 ms 1.09×
    (some pred (range 1e6)) N=20 72.4 ms 100.3 ms 1.39×
    map/filter/reduce (1e5) N=20 51.2 ms 66.4 ms 1.30×

    Registrar shadows

    TestNoNewShadowedHandRegistrations and the reduce fast-path tests PASS. reduce is no longer double-registered. 12 names remain in the accepted shadow set (conj, deref, get, int, name, namespace, nth, pop-binding!, push-binding!, some, str, subs). #704’s three soft guards are still open; adjacent drain work is #696 / #697.

    WASM key input (#595) × xsofy shell

    Same shipping shell; tip vs v1.12.2 host JS:

    Bundle Sync burst of 10 'l' via _lgKey
    v1.12.2 (single-slot) 1 accepted / 9 dropped
    tip (SPSC ring, cap 8) 7–8 accepted (3 runs); burst=20 caps at 8

    Neither path busy-spins here (both return false when full). The win is acceptance. Paced LetGoHost.sendInput movement (10 keys @ 80 ms): 10/10 accepted; COI + SAB OK.

    AOT / Gloat (#628, #607)

    • --entry-frame fib demo: OK (55 / 5702887; fib(34) ~0.11 s).
    • lg-compile without the flag: packages only, no main.go (summary still reports native entry).
    • rt / e2e / entry_frame tests: PASS.
    • Gloat -E let-go-lower-vm and -E let-go-vm with LET-GO-SRC = tip: OK (fib 10/34).
    • Gloat lower-vm with LET-GO-SRC = v1.12.2: OK.
    • Gloat does not pass --entry-frame; it keeps its skip-MainChunk + override-drain template. Ownership split holds.
    • AOT lower-vm: embedded VM-fallback bundle rejected by its own runtime — unsupported capability mask 0x1 (supported: 0x0) #607 not reproduced when encoder and embedded runtime share one LET-GO-SRC. That matches the existing diagnosis: dual-knob skew in gloat (pin vs fork checkout), not a tip #628 regression.

    Proposed dispositions for this cut

    Answering the open questions on this issue:

    1. Version: lean v1.13.0 (features + #604 Go API removal). Not a narrow patch.
    2. vm: entering bytecode from Go still costs more than on v1.12.2 (since PR #645) #700 invoke cost: accept for this tag, with the numbers above in the notes. Mitigation stays on vm: drop leaveFrame's unused *Frame parameter to unpin f from memory #706 / Design: constant-space tail calls on the explicit-frame VM (closures, multi-arity, apply) #620; don’t block the cut on reclaiming the ~1.3–1.4× callback tax unless someone wants that bar raised.
    3. AOT lower-vm: embedded VM-fallback bundle rejected by its own runtime — unsupported capability mask 0x1 (supported: 0x0) #607: known limitation / gloat-side, not a let-go tip blocker when sources agree. Link the dual-knob notes; don’t hold the tag on it.
    4. Frontier (~20 open PRs): freeze lightly — land gate/hygiene if ready (#681, #706 if cheap); keep IR/codegen and bundle-format stacks (#649, #675, #501/#624, …) for after unless already approved.
    5. Performance measurement status: ratchet is not consulted; startup regressed 5ms -> 9ms unnoticed #663 startup (~1.25×): accept with an explicit note; recovery remains under Performance measurement status: ratchet is not consulted; startup regressed 5ms -> 9ms unnoticed #663 / measurement discipline (Three gaps that let the perf gate report green through a regression #705).
    6. Three generated-primitive guards pass when their premise breaks #704 / Retire the twelve hand registrations the generated registrar shadows #696 / Drain the remaining hand registrations in pkg/rt onto //lg:native #697: not cut-blocking; drain/guard work before more //lg:native migration, not before the tag.
    7. Gather requirements for release v1.12.0 #545: still open for the already-shipped v1.12.0 cut — close or retarget so this issue is the live home.

    Upgrade-notes checklist from the probes: #604 embed API removal, toolchain go1.26.5, #595 ring contract, #628 RunExecUnit / --entry-frame split, plus the accepted #700 / #663 costs if we take (2) and (5).

    Happy to adjust any of these stances, especially (2) if #706 is close enough to fold in.

  3. mparrett commented on Sep 6, 2026

    @mparrett
    CollaboratorAuthor

    Notes update, 2026-09-06

    Updating my own snapshot above against main @ ee55803e (2026-09-06). Enough has merged since I wrote it that several things I said are no longer true, and a few sections describe work that did not exist yet. Recording the current state here rather than editing the body, so the history stays readable.

    Counts below have a short shelf life: eight PRs merged on 2026-09-06 alone, so read every number as of that SHA.

    Range

    v1.12.2..main is now 105 squash-merged PRs across 108 commits, 73,075 insertions / 9,429 deletions over 377 files, up from 82 PRs / 319 files at the 2026-09-02 snapshot. Twenty-three PRs merged in the four days since:

    #762 #675 #779 #734 #781 #794 #773 #792 #771 #624 #502 #745 #501 #730 #723 #727 #692 #758 #760 #789 #707 #780 #756

    Five of the six PRs listed as "still open, all now carrying an approval" have merged: #692, #501, #502, #624, and #675. Only #683 remains open, now with changes requested. Twenty PRs are open, four carrying an approval.

    The arcs above are still worth leaving until the wave settles, since rewriting them mid-drain buys a second rewrite. The corrections below are a different matter: they describe work that has already merged, so they do not go stale when the next PR does.

    Two things I got wrong about scoping

    My disposition (4) has not held up. It said "freeze lightly ... keep IR/codegen and bundle-format stacks (#649, #675, #501/#624, ...) for after unless already approved." I wrote that expecting the named stacks to sit out the cut. They were all approved, so every one of them merged, and the bundle-format work is now inside the range with the format version bump attached. The outcome looks fine to me; I just want to note that the "unless already approved" clause meant the disposition never had any effect, and it is still running. The range grows on its own while the timing stays open.

    My August perf numbers went stale twice in four weeks, which is a problem with how I measured rather than with the code. Anchoring a "% vs v1.12.2" table to a moving tip does not survive a merge wave. Whenever the size and perf audit on the checklist happens, I would pin a SHA first and measure against that.

    For whatever it is worth on timing: the interop set is already partly in (#773 merged, with #776, #778, and #787 approved and open), so a later tag would make it a headline arc with its own behavior-change section, and would likely pull the divergence PRs in behind it. An earlier one keeps the notes close to what is written here. I do not have a preference strong enough to argue for.

    Bundle format: version 3 and split debug info

    A .lgb arc merged after the snapshot and appears nowhere in the behavior-change section. It is the most consequential part of the cut for anyone who ships or embeds a bundle.

    • #501: opt-in DEFLATE, and a format version bump. Compressed bundles are format version 3; uncompressed stay version 2 with the pre-change byte encoding. Magic, version, flags, and capability payload stay plaintext so compatibility and opcode-set checks run before decompression. Old decoders reject a version-3 bundle as an unsupported version, and version 2 now rejects the compression flag at the header. lg -c -z and lg -b -z opt in; declared inflated size is capped at 256 MiB.
    • #502: lgbgen --compress, default off. The committed core stays uncompressed. The measurement is the reason: 297,359 B to 89,776 B (3.31x) on the core bundle and 214,656 B off the lg binary, against +1.17 ms decode and about +1.9 ms warm boot. Turning it on is a policy call, tracked under #503.
    • #624: lg -strip splits debug info rather than discarding it. Source maps and local-variable tables move to a digest-bound .lgb.debug companion; FlagDebugSplit marks the artifact, and a companion whose SHA-256 does not match the stripped payload is rejected instead of producing misleading tracebacks. lg, standalone bundles, and lg-runtime load a co-located companion automatically; LG_DEBUG_FILE overrides the path.
    • #745: the encoder no longer aliases chunks. Function constants were bound to serialized chunks by scanning for the first chunk with identical bytecode, so two structurally identical functions with different source metadata could swap chunks. The builder's live pointer-to-index mapping is now authoritative, including on the compressed path.

    The line I would want in the upgrade notes is the version-3 rejection rule: producing a compressed bundle with tip and reading it with any older runtime fails at the header. That is the intended design, and it is also the failure that reads as bundle corruption when nobody has written it down.

    #631 (strip debug from the embedded core and -w bundles) is now in flight as PR #800. Neither is referenced above, and #800 decides whether -strip reaches the -w path in this cut.

    My perf dispositions have inverted

    Disposition (2) accepts #700's invoke cost and (5) accepts the #663 startup regression with a note. Both rest on the 2026-08-11 probe table, which no longer describes tip.

    • #700's own analysis concluded the cost is an artifact of how transitions are built rather than inherent to the explicit frame chain: in-VM bytecode-to-bytecode invocation never regressed (1.00x), and the roughly 24 ns is paid entering the VM from Go, which native seq functions pay once per element.
    • #727 then adopted PreparedCall in reduce's four fold loops and measured 50.5 ms against 58.7 ms on v1.12.2, below the release baseline rather than only recovered toward it. #723 (cold error arms out of the dispatch loop, runLoopInner text down 21%) and #730 (removing the runLoop hop) merged alongside it.
    • #706 is listed as closed. Its content merged as #730; worth saying so, or the closure reads as work abandoned.

    I would now state it as "recovered on the shapes we measured" rather than "accept the tax". What stops me restating it cleanly is a second reversal. #780 merged the bench-ratchet infrastructure (deterministic forced rebaseline, regenerated Go 1.26.5 baseline, pre-push gate), which is progress on #663 and #705, both of which the table still shows as untouched. Then #791 (2026-09-04) reported IRCompile and InitFromLGB regressed against that new baseline, attributing it to #727 allocations and #723/#730 timing.

    So I would rather the perf section say the measurement is in flight than carry my August numbers, until #791 resolves and there is a pinned SHA to re-run the battery against.

    Entry-frame defects filed after the freeze

    The known-open-items table predates four issues on the #628 / --entry-frame feature the notes plan to advertise:

    Issue Why it touches the cut
    #796 Entry-frame binaries run -main twice — main-chunk replay, then the frame
    #783 Lambda-lifted var (__lifted0) resolves to nil and panics in ExecContext.deref
    #782 Native lowering: closures in loops capture the mutated slot, not the per-iteration value
    #721 IR CSE merges var reads across set!; :mutated-vars only ever contains nil

    #796 is a correctness bug in the binaries --entry-frame produces, which is the one I would want closed before the notes advertise the feature. #724 frames what an AOT front door still needs, for context.

    Behavior changes I had not recorded

    Three consumer-visible semantic changes merged after the snapshot:

    • #758: symbols are invokable for collection lookup, as keywords already are (resolves #757).
    • #760: multi-arity dispatch routes an exactly-min-arg call to the variadic variant, and an empty kwargs rest stays nil through the map destructure ({:as o} binds nil, not {}), matching Clojure. Resolves #759.
    • #756: the var special form reaches private vars across namespaces, matching Clojure, where privacy is enforced only on bare qualified symbol references. Bare-symbol resolution stays privacy-enforcing on both the bytecode and IR paths.

    #789 also gates untagged builds against the GL and Ebitengine subsystems via make check-default-deps and a CI job, motivated by #788.

    What I got wrong in the WASM and TinyGo arc

    The arc reads "TinyGo lane (#522) with CI follow-ups (#682, #685)". Two fixes.

    #682 was never a PR. It is an issue, closed as completed on 2026-08-25. #685 is the CI follow-up that merged.

    The arc is wider than one line. In range: #522 (the lane itself, merged 2026-07-23), #685, #629 (SameFile stub for js/wasm), #595 (SPSC ring for SAB key input), #572 (RGBA surface capability), #743 (wake Plan 9 key reads on terminal resize), and #792 (drop the tracked coi-serviceworker.js).

    For anyone tracking whether TinyGo makes this release: #522 merged on 2026-07-23 and is inside the range at every cut point under discussion, so it needs no action to be included. #552 is sometimes cited alongside it and is a different change, a bench-suite rework that shipped in v1.12.0 and is contained in v1.12.2.

    The limitation text we would ship with it is wrong. The #504 re-run on 2026-09-06 established that the guide's xxh3 bullet describes a substitution that does not occur: pkg/rt/interop_xxh3.go is !tinygo-gated, so a TinyGo build has no xxh3 namespace and a call fails at the site rather than returning a different digest. The 64-bit int boundary also binds more narrowly than documented, because hash_murmur3.go is untagged and masks to 31 bits; with the seed hash swapped, xsofy under TinyGo wasm produces a world byte-identical to the stock-Go wasm build at a fixed seed.

    PR #799 carries the correction and is open. If the notes end up advertising the TinyGo lane, I would want it in the cut alongside #800, so we are not describing a headline feature incorrectly.

    Two arcs I never gave a section

    Interop, now partly in the cut. #773 merged on 2026-09-06, putting self-contained interop packages and an importable pkg/cli inside the range with no section describing them. #776 (multi-return boxing), #778 (native collection crossing), and #787 (JVM string and keyword surface) are approved and open; #775, #777, and #790 track the rest. Whether this is a headline arc or a footnote now depends only on how long the tag waits.

    Clojure divergences. #761 (reify could not override Object methods) closed on 2026-09-06 when #762 merged, so that one is in the range. Still open: #763 (small maps don't preserve insertion order; PR #764 has changes requested), #742 (nth accepts maps and sets, indexed?/counted? invert for seqs), and #801 (reader metadata yields a with-meta list, map-interior #_ discard fails). #771, which catalogs the known divergences, merged on 2026-09-06 and is in the range.

    Checklist status

    Unchanged since 2026-09-02 apart from the #545 item.

    On the version: v1.13.0 still looks right to me for either candidate cut, since the range already carries the #604 Go API removal alongside features, and the bundle format version bump makes the case for a minor rather than a patch. That holds whichever SHA we freeze at.

    What I am carrying, none of which depends on the timing:

    • #796 and #721 are mine to fix. #796 is a correctness bug in what --entry-frame emits, so I would not want the notes advertising the feature ahead of it. #783 and #782 sit on the same surface.
    • #791 is open on the perf side; I will not write a perf paragraph until it resolves and I have a pinned SHA to measure against.
    • #799 carries the TinyGo guide correction, so it is worth having in before the notes describe that lane.
    • The bundle format entries above want folding into the behavior-change section whenever the body gets rewritten, since that work has already merged.

    Happy to take the body rewrite once the wave settles.

  4. mparrett commented on Sep 9, 2026

    @mparrett
    CollaboratorAuthor

    Delta, 2026-09-09

    main @ a9c183f9. Nine PRs merged since the note above, and one number in it needs reading differently.

    The items I said I wanted in have landed. #799 (TinyGo guide correction) and #800 (-strip composing with -w) merged on 09-07, #683 on 09-09, and the approved interop set #776 / #778 / #787 on 09-06. That last one settles the question I left open: interop is fully in the range now, not partly, so it wants its own behavior-change section rather than a footnote. The range is 114 squash-merged PRs, up from 105.

    My insertion count was misleading. I quoted 73,075 insertions, and roughly 40% of that was docs/perf/baseline.json, which #684 then reseeded from a window (30,726 lines down to 8,699). Excluding that file the range is +48,412 / -8,647 across 406 files, and that is the figure worth using for release-notes sizing. The clearest sign the raw number is not measuring code: it fell from 73,075 to 55,511 over three days in which nine PRs merged and the code grew.

    One behavior change to add to the list. #764 merged on 09-07 and closed #763: small maps now preserve insertion order, matching Clojure array-map semantics. #826 is open to narrow the divergence-catalog entry to construction. That is consumer-visible and belongs with #758 / #760 / #756 in the notes.

    Unchanged: #796, #791, #783, #782, and #721 are all still open, so the entry-frame set and the perf question stand where they did. Still no preference from me on timing.

  5. mparrett commented on Sep 19, 2026

    @mparrett
    CollaboratorAuthor

    Body rewritten, 2026-09-18

    main @ 7148f4ad. I said on 09-06 that I would take the body rewrite once the merge wave settled. It has: thirteen PRs are open with three approvals, against twenty open and four approved in early September. So the arcs can be written from the whole range instead of layered as another delta on a stale snapshot.

    The body is now a consolidated statement of capabilities, bugfixes, compatibility, performance and open items, rather than the 2026-08-11 text plus three correction comments. Those comments stay where they are; nothing in them is lost, and the corrections they carry are folded in. Where a comment disagrees with the body, the body is current.

    Fifty PRs merged since the 09-09 delta, taking the range to 164 squash-merged PRs plus three direct commits across 167 commits, +64,157 / -9,398 across 540 files excluding docs/perf/baseline.json.

    What those fifty add that the earlier notes had no section for:

    Three items for the cut specifically:

    1. Two independent bundle-compatibility breaks, not previously written down together. feat(ir,vm): unchecked-add/subtract/multiply as first-class ops #811 changes the opcode-set signature, so an older lg's .lgb is rejected; feat(bytecode): opt-in DEFLATE compression for .lgb bundles #501's compressed bundles are format version 3, which an older decoder rejects as unsupported. Both are the intended design, and both read as corruption if the notes do not say so.
    2. [x & more] now binds nil, not () (http: stoppable server, empty :headers fix; core: & rest binds nil #898). The old behavior hung integrant's ig/init on any three-component chain, so this is a fix, and it is also a visible semantic change that belongs in the notes with feat(vm): insertion-ordered small maps matching Clojure array-map semantics #764, feat(vm): symbols are invokable for collection lookup, like keywords #758, fix(vm): multi-arity dispatch routes exactly-min-arg calls to the variadic variant #760 and fix(compiler): var special form resolves private vars across namespaces #756.
    3. The perf section carries no numbers, deliberately. bench-ratchet: IRCompile and InitFromLGB regressed since the #780 baseline (#727 allocs, #723/#730 timing) #791 is open against the build: bench-ratchet infrastructure — deterministic rebaseline, Go 1.26.5 baseline, pre-push gate #780 baseline, and bench-ratchet: deterministic bars come from profiles captured a month apart #883, bench-ratchet: gate timings on an interval from the stored samples, not a point against a 5% budget #899, pkg/vm: BenchmarkConsCreation measures an empty loop #884 and pkg/vm benchmarks name the same size two ways: /1000 and /01000 #885 are four further gate defects filed since. The audit should be one pass against a pinned SHA, not a table anchored to main.

    The only item I would call a blocker is #796: entry-frame binaries run -main twice, and the notes plan to advertise --entry-frame. Either that lands or the flag stays out of the advertised surface. #783, #782, #721 and #866 sit on the same feature.

    Still no preference from me on timing.

  6. mparrett commented on Sep 19, 2026

    @mparrett
    CollaboratorAuthor

    Delta, 2026-09-19

    main @ 827ef18b. Five PRs merged since yesterday's rewrite — #894, #795, #863, #871, #900 — taking the range to 169 squash-merged PRs plus three direct commits across 172 commits. I have refreshed the body; this comment covers what changed in it and three items that need someone else's hand.

    #863 replaced #852's mechanism, so the body's compatibility note was describing code that no longer ships. #852 rebuilt a bound-fn* call's context from the captured snapshot alone, which silently reverted any dynamic var the caller had bound but that was not captured at creation. Since *file* is bound while loading a file, the snapshot is essentially never empty, so that was the path taken nearly always. #863 overlays the captured vars onto a private child of the caller's context instead, matching Clojure's push-thread-bindings, and keeps the property #852 fixed.

    That resolves #864, which is still open because #863 never referenced it. I verified rather than assumed, with a failing control:

    (def ^:dynamic *x* :root-x)
    (def ^:dynamic *y* :root-y)
    (let [f   (binding [*x* :captured]     (bound-fn [] [*x* *y*]))
          got (binding [*y* :caller-bound] (f))]
      got)

    9d32c1c4 (post-#852, pre-#863) returns [:captured :root-y]. 827ef18b returns [:captured :caller-bound]. My first attempt at this repro created the bound fn with an empty capture snapshot and passed on both builds, which is the reminder that a green check against one tree proves nothing without the control. #864 looks closeable.

    #900 resolves #883, also without referencing it. #900 takes the deterministic bar per benchmark from the entry with the newest provenance instead of the global minimum across profiles captured at different commits, stamps provenance in seed-baseline, and names the source commit in the regression line. That is #883's own options 1 and 3, and #900's write-up reproduces #883's exact evidence: the 8370C August row holding 7,041 allocs/op for InitFromLGB against 13,995–26,428 in every newer row, gating current main at +69%. #883 refs nothing and stays open; it should be closed. With #795's bench-baton lease alongside it, gate repair is now ahead of the defect list for the first time in this cycle.

    #863 merged ahead of its own spec. Its commit subject cites docs/specs/clojure-test-conformance.md, and that file is not on main — it is #793, still open with changes requested. docs/specs/ currently holds only executable-evidence.md. Worth landing #793 before the tag, since the squash subject is permanent.

    Also folded into the body: clojure.test is now a real port (report, assert, fixture, runner) rather than the handful of additions the Clojure-surface paragraph listed; #894 published the binary-size and boot-time audit behind #652, which is the method and tooling for the checklist's audit item but not the audit of this cut, since it compares v1.7.4 against the historical ed4ecc2; and #906 takes the pkg/rt leaf conversions from #867, leaving the pkg/vm half until the ToInt question is settled.

    Unchanged: #902 has not been reviewed, so #796 is still the one blocker, and nothing has frozen the scope — the range grew by five PRs in a day.

  7. mparrett commented on Sep 19, 2026

    @mparrett
    CollaboratorAuthor

    Release notes, Option A — the sober version

    Two drafts for the v1.13.0 notes, so nobody starts from a blank page. This is the straight one, for the GitHub release body. Option B in the next comment is the same release written as a Slack post in @nooga's voice.

    Both are starting points to cut and rewrite, not proposals. Measured at main @ 827ef18b, and written assuming #901 and #902 land.


    v1.13.0 is the largest release let-go has cut: 169 merged pull requests in the 61 days since v1.12.2, against 126 for the next biggest range in the project's history. Two changes in it move what you can build, and a third moves where you can run it.

    A let-go program now computes the same values on every host. Int was type Int int, so its width followed the platform. On linux/386, linux/arm and TinyGo wasm, (bit-shift-left 1 40) returned 0, (* 1e9 1e9) trapped, and a uint64 crossing an interop boundary was truncated. It is 64 bits wide everywhere now, the Go lowerer types :int to match, and xxh3 reaches TinyGo wasm for the first time. A 64-bit hash chain produces byte-identical results on a small-footprint wasm build and on amd64, which is the property that makes those targets usable for anything that has to agree with itself.

    The HTTP namespace became something you can run a service on. http/serve was ListenAndServe on the calling goroutine: no handle, no stop, a bind error the only way back. It is now http/start, http/stop and http/wait, with channel and lazy-seq response bodies flushed element by element, three named timeout scopes on http/request, and clients that inherit the caller's structured-concurrency scope so cancelling a scope aborts the requests under it. net/listen, net/accept and net/local-address give TCP the owned-listener surface unix already had. The forcing function was real code: an integrant and SQLite web app built under lgx, and the nREPL hub that had been carrying a downstream patch until now.

    Real Clojure libraries drove the compatibility work, and they name themselves in the diff. weavejester/dependency exposed [x & more] binding an empty seq instead of nil, which hung integrant's ig/init on any three-component chain. clojure.test.check wanted *unchecked-math* to stop being inert and the Math/* statics to exist. clojure.data.priority-map wanted Clojure's canonical (. obj member ...) forms to compile. weavejester/ragtime wanted Thread/currentThread, which now resolves to the calling scope, so the cooperative-cancellation idiom compiles and .isInterrupted means scope cancellation. Around them: small maps preserve insertion order, reify overrides Object methods, symbols are invokable for lookup, clojure.edn reads data rather than code forms, and clojure.string/split follows Pattern.split. The divergence catalog shrank by most of its consumer-visible entries. clojure.test was ported outright — a real report, assert, fixture and runner contract rather than the partial namespace that preceded it.

    AOT stopped needing a source checkout. The lowering driver is now the embedded lg.compiler namespace, so a shipped lg drives lowering from any directory, and lg-compile --entry-frame emits a native-entry Go frame from main or -main. unchecked-add, unchecked-subtract and unchecked-multiply became first-class IR and VM ops, so a hash or PRNG inner loop keeps its typed native path instead of boxing at every step. On footprint: opt-in DEFLATE bundles, lg -strip splitting debug info into a digest-bound companion rather than discarding it, lg_no_http to drop net/http from the runtime, and the embedded core bundle down from 311,302 to 273,748 bytes.

    What it costs. Two independent bundle-compatibility breaks — the opcode set changed, and compressed bundles are format version 3 — so bundles do not cross this boundary in either direction. Both rejections are by design, and both read as corruption if the notes do not say so. The toolchain floor is Go 1.26.5. Two Go API moves: 34 per-file core-embed symbols are gone, and the CLI moved out of the root package into an importable pkg/cli. Scope cancellation now propagates where it previously did not, a correction that will stop code relying on requests outliving their scope. On performance the direction is good — reduce measures below the v1.12.2 baseline rather than merely recovered toward it — but the measurement apparatus is under active repair, so the release should carry one audit against a frozen SHA rather than numbers anchored to a moving tip.


    One caveat for whoever finalizes this

    Written assuming #901 and #902 land. If either slips, pull its line: #901 is Thread/currentThread and the %n fix, #902 is what makes --entry-frame safe to advertise.

    glplat is experimental with an unstable contract, per @nooga's call recorded in docs/design/glplat-backend-contract.md §5.1. The package is marked provisional: the registry stays under internal/, and the planned rt.Display follow-up may rename natives and change Init's signature. The namespace is present in a default build with every native returning no backend registered, and no graphics dependency linked. Name it experimental or leave it out of the summary; do not describe it as a stable surface.

  8. mparrett commented on Sep 19, 2026

    @mparrett
    CollaboratorAuthor

    Release notes, Option B — the announcement, in @nooga's voice

    The same release shaped as a Slack post, matching the register of your v1.12.1 announcement: the #let-go opener, the bullet blocks, the ecosystem roundup, brew install, the star ask.

    Written in your first person because that is the form it ships in — a draft for you to take, cut, or rewrite, not something anyone else should post as-is. The notes at the bottom cover what I left out and why.


    let-go v1.13.0 is out 🎉

    #let-go is a Clojure implemented in Go, a bytecode compiler and VM, with an AOT compiler written in let-go itself. One ~14MB binary.

    This is the biggest release we've ever cut: 169 merged PRs in the two months since v1.12.2, more than any release in the project's history — the previous record was 126.

    The same program now computes the same numbers on every host.

    • Int used to be Go's int, so its width followed the platform. On 32-bit targets — linux/386, linux/arm, TinyGo wasm — (bit-shift-left 1 40) quietly returned 0 and (* 1e9 1e9) trapped. • It's 64 bits everywhere now. The AOT lowerer types to match, and xxh3 works on TinyGo wasm for the first time. • xsofy now generates a byte-identical world from the same seed on a TinyGo wasm build and on native — @matt Parrett chased that one down through its determinism layer. That's the property that makes the small targets worth shipping to.

    You can write a web service in let-go now.

    • http/serve used to be ListenAndServe on your goroutine: no handle, no stop. Now there's http/start, http/stop and http/wait. • Response bodies can be a channel or a lazy seq, flushed element by element — SSE and long-polling work. • Three named timeout scopes on http/request: connect, request, stream_read. • Clients inherit the calling scope, so closing a scope cancels the requests under it. • net/listen, net/accept and net/local-address — TCP finally has the listener side, from @norman Nunley's patch that had been living downstream.

    More real Clojure libraries run, unmodified.

    • integrant works. [x & more] was binding an empty seq instead of nil, which hung weavejester/dependency's reachability loop, so ig/init deadlocked on any three-component chain. That one took a while to find. 😅 • clojure.data.priority-map compiles — we accept Clojure's canonical (. obj member ...) forms now, not just the .member sugar. • clojure.test.check groundwork: *unchecked-math* is live instead of inert, and the Math/* statics exist, each pinned to JVM semantics rather than Go's. (Java rounds -2.5 to -2. Go doesn't. Now we do.) • weavejester/ragtime loads — Thread/currentThread resolves to the calling scope, so (.isInterrupted (Thread/currentThread)) compiles and actually means "was my scope cancelled". format honors %n too. • clojure.test is a real port now — report, assert, fixture and runner — not the handful of functions we had been getting by with. • Divergences closed: small maps keep insertion order, reify overrides toString/hashCode/equals, symbols are invokable for lookup, clojure.edn reads data rather than code forms, clojure.string/split follows Pattern.split, and custom *data-readers* work.

    The AOT compiler has a front door.

    • Driving lowering used to need a let-go checkout on disk. The driver is now the embedded lg.compiler namespace, so a shipped lg compiles from any directory. • unchecked-add/subtract/multiply became first-class IR and VM ops, so a hash or PRNG inner loop keeps its typed native path instead of boxing at every step. • lg-compile --entry-frame emits a real native entry point, so an AOT binary starts in Go rather than replaying a main chunk.

    Smaller, in several directions.

    • Opt-in DEFLATE for .lgb bundles. • lg -strip splits debug info into a digest-bound companion file instead of throwing it away, and it composes with -w now. • lg_no_http drops net/http from the runtime entirely. • The embedded core bundle went from 311,302 to 273,748 bytes.

    Graphics, experimentally. glplat landed: a platform layer with two backends, GLFW/OpenGL and Ebitengine — the second reaching the browser and Metal where the first is cgo-native-only. The contract is explicitly unstable and the namespace is experimental, so please play with it but don't build on it yet.

    Two things to know before you upgrade:

    • .lgb bundles don't cross this release in either direction. The opcode set grew and compressed bundles moved to format version 3, so rebuild your bundles. Both rejections are deliberate — if you see one, it isn't corruption. • The toolchain floor is Go 1.26.5, 34 unreferenced core-embed symbols are gone, and the CLI moved out of the root package into an importable pkg/cli — so you can now build your own lg with the full feature set around your own interop packages.

    The ecosystem keeps growing.

    • @Andrey Bogoyavlenskiy's lgx is the project and package manager, and a real integrant + SQLite web app built on it is what drove most of the HTTP work above. • @norman Nunley's strange-lettractor runs an nREPL hub and OAuth loopback on let-go. • xsofy runs in the browser, in a terminal, on Plan 9, and now under TinyGo. • legmacs got a better deal than I expected out of this one: eval now runs in the caller's execution context, so its built-in REPL can capture what your form prints, not just what it returns. • @jarkko Saltiola built a GTK time tracker that embeds let-go and hot-reloads its UI over nREPL — which is why api.LetGo exposes its compiler context now.

    Welcome to our two first-time contributors, @jarkko Saltiola and @dharmatech 👋 — dharmatech fixed Plan 9 key reads hanging on terminal resize, which is exactly the kind of bug that only gets found by someone actually running it there.

    Give it a try:

    brew install nooga/tap/let-go

    Release notes: https://github.com/nooga/let-go/releases/tag/v1.13.0 Repo: https://github.com/nooga/let-go (please ⭐ it, we live for ⭐s)

    Thank you to everyone who contributed, filed, reviewed and kicked the tires 🙏

    If you maintain a Clojure library: we'd still love to hear what breaks. Most of the compat work above started as someone trying to load something and telling us what happened.


    Notes for whoever finalizes this

    What's in this release for legmacs

    Checked against the working tree at 2b25d9b. Three findings, one of which is a trap rather than a win.

    legmacs is pinned behind all of it. .let-go-version is c879db76 (2026-07-01), which predates v1.12.2 by nearly three weeks and sits 294 commits behind main. None of what follows arrives until that pin moves, and the file's own comment says to bump it "when you want newer let-go fixes".

    #853 is the real win, and it lands on the feature legmacs is built around. legmacs/modes/letgo.lg is an in-editor REPL: eval-last-sexp and eval-print-last-sexp call (eval form) at line 229, and the namespace header reasons explicitly about what context eval runs in. Before #853, eval ran its frame against the root execution context, so dynamic bindings around it did not apply. After it, they do — which means wrapping that call in with-out-str captures what the user's form prints. Right now eval-last-sexp can only show a return value; (println …) output goes nowhere the editor can see. That is a small patch on their side and a visible feature for anyone using the REPL mode.

    #735 could take the source checkout out of their build. build.sh requires a let-go source tree (LETGO_DIR, defaulting to ../let-go), builds lg from it, then runs "$lg_bin" scripts/lg-compile. The pin comment gives the reason: the AOT tooling "lives on main". After #735 the driver is the embedded lg.compiler namespace, so a released lg drives lowering from any directory, and scripts/lg-compile survives as a shim with its path, argv and exit codes unchanged. Their existing invocation keeps working; the checkout requirement is what goes away.

    #843's split-lines is not a drop-in for theirs — don't let anyone swap it. legmacs/buffer.lg:10 hand-rolls split-lines with a documented editor contract: one trailing newline does not produce a phantom empty last line, but any other newline does. let-go's new clojure.string/split-lines is (split s #"\r?\n") at limit 0, which per Pattern.split drops all trailing empty fields. The two agree on "" and on a single trailing newline, which is exactly what test/buffer_test.lg pins, so a swap would pass their tests and silently eat the blank lines at the end of a file. The \r\n handling is worth borrowing; the function is not.

  9. nooga commented on Sep 20, 2026

    @nooga
    Owner

    Checked off three checklist items and a status update, following on from the 2026-09-19 refresh.

    Seven more PRs merged since 827ef18

    All reviewed, tested and landed 2026-09-19/20:

    Current main: 369e2a6900a4.

    Checklist

    Still open, unchanged

    New during this pass

  10. mparrett commented on Sep 20, 2026

    @mparrett
    CollaboratorAuthor

    Correction for the two release-note drafts above: the Go floor moved

    Heads-up before anything gets published — both Option A and Option B say Go 1.26.5, and that is now wrong. #910 bumped the module to Go 1.27 with toolchain go1.27.1 and pinned golangci-lint alongside it, several hours after I posted those drafts.

    The line to change, in both:

    The toolchain floor is Go 1.26.5

    The toolchain floor is Go 1.27

    Worth pairing it with #916 in the upgrade notes, since the two travel together: the manifest generator now takes GOTOOLCHAIN from go.mod's toolchain line instead of GOTOOLCHAIN=local, so a contributor on an older local Go can still run make generate. Without that, the bump would have been a harder break for anyone not already on 1.27.

    The toolchain floor is the most likely line in the whole set to be acted on by a downstream consumer, so it seemed worth flagging on its own rather than folding into the next refresh.

    The issue body is already updated: Go 1.27, and the range restated at 369e2a69 — 176 squash-merged PRs plus three direct commits across 179 commits, +81,395 / -9,772 over 659 files excluding docs/perf/baseline.json. The counts in the two drafts (169 PRs, 61 days) are one day stale for the same reason; they read fine as "the two months since v1.12.2" if you would rather not chase the number.

  11. mparrett commented on Sep 27, 2026

    @mparrett
    CollaboratorAuthor

    v1.13.0 shipped

    v1.13.0 was tagged on 2026-09-20 at 369e2a69, the freeze candidate the body names, so the scope described above is the scope that shipped. The release notes carry the upgrade notes, the bundle-compatibility breaks, the Go 1.27 floor, and glplat marked experimental.

    Checklist outcome

    Item Outcome
    Freeze at a named SHA 369e2a69, the tagged commit
    #545 Closed 2026-09-02
    Size and perf audit vs v1.12.2 Not run. The notes say so and ship no headline number. #894 is the method, not this range's audit
    #705, #883, #899 #883 closed by #900. #705 and #899 shipped undecided and are still open
    AOT matrix --entry-frame advertised after #902. #607 and #678 are still open, as are #783, #782, #721 and #866 on the entry-frame surface
    #796 Fixed by #902
    Release hook Green on 369e2a69 (@nooga, 2026-09-20)
    Upgrade notes Published in the release body
    Release and attribution Published, with contributor credits
    Tag v1.13.0

    Two items the body wanted settled before the tag are still open:

    Found after the tag

    Against the shipped release:

    The let-go-wiki refresh for this release is done (nnunley/let-go-wiki#28).

    Carried forward

    For whoever opens the next tracking issue: the size and perf audit at a pinned SHA, #705 and #899, the AOT matrix (#607, #678, and the four entry-frame issues above), #793, #923, #867's pkg/vm half, and #929's release-notes line.

    Closing, since everything left is tracked in its own issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    • Status
      Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions