let-go v1.12.1
let-go v1.12.1
First release since v1.11.1 (2026-06-29). 126 commits in about three weeks,
centered on one theme: running real Clojure libraries unchanged. Stock HoneySQL
now works out of the box, backed by a round of JVM host-compat and exception-
semantics work. Beyond that: a compiler-free runtime binary, a WASI build, TCP
networking, a clearer Go-interop story, substantial AOT progress, and another
round of runtime performance improvements. There are no breaking API removals;
see the upgrade notes for observable behavior changes.
Run real Clojure libraries
The headline: stock HoneySQL runs on let-go unchanged (#494). Getting there
closed a long tail of JVM-compat gaps that benefit any library you try next:
- Mutable
java.util.HashMapandArrayDeque(#512), static method calls
(#514), and the collection interfaces libraries expect (#510). - The
java.langexception hierarchy (#472), typed catch dispatch (#476), and
finallyblocks that run on every exit path (#474). fnsupports:pre/:postconditions (#458),defprotocolaccepts standard
options (#488), and regex match results have Clojure's shape (#456).- Top-level
(do ...)forms evaluate subform-by-subform (#470), and require
aliases are honored before thecore/*shortcut (#548).
Ahead-of-time compilation
let-go has always bundled to bytecode (lg -c) and standalone binaries
(lg -b). This release hardens and extends that pipeline:
lg-runtimeis a compiler-free binary that executes precompiled.lgb
bundles: compile on your machine, then ship only a runtime and bundle (#424).- Bundles built against a different opcode set are rejected clearly instead of
misbehaving (#443), and lowered output is deterministic (#446). - More language features lower to native Go, including lambda-lifted functions
(#479), multi-arity direct calls (#371), and fused sequence pipelines (#440). - Parameters annotated with
^doubleor^longcan use unboxed Go numerics in
the native lowering pipeline (#530, #536). - The refreshed benchmark suite compares the VM with both native-lowered and
runtime-backed AOT code, and documents why those two paths perform differently
(#552, #578, #582).
New targets round out where compiled output can run: a WASI (wasip1)
module for any WASI host (#466), real terminal input on Plan 9 (#460, #461),
custom browser shell templates (#377), and an in-browser IR/bytecode/lowered-Go
inspector notebook (#379).
Interop between let-go and Go
- Go to let-go (embedding):
api.NewLetGoplusDefhands Go values,
functions, structs, and channels to the VM. - let-go to Go (wrapping):
lginteropgenerates smart wrappers around Go
packages. The guide now walks throughdatabase/sqlend to end, and an e2e
test keeps the wrapper path honest (#537, #532, #543, #542). - Native primitives: the
//lg:nativeannotation lets Go functions back
let-go builtins directly, including standard-library hot paths (#438).
Start with docs/guide/go-interop.md.
Networking, language, and library additions
- A
netnamespace with TCP client support plusbencodeencoding and framing
(#526, #533). - Ring-style HTTP requests now expose
request-methodas a keyword (#527). update-keys,update-vals,ns-publics,find-var,get-method, and a real
PersistentQueue(#447, #384, #417, #419).- Base64 and base64url encoding, raw byte-array writes through
spit/write!,
streamingos/exec*, and function metadata (#368, #367, #371, #383). - core.async now matches Clojure for closed channels: puts return
falseand
takes returnnil(#391).
See docs/guide/usage.md, docs/guide/net.md, and
docs/guide/clojure-compatibility.md for the newly expanded guidance (#550).
Performance
- Dynamic var lookup is O(1) per var (#462).
- Keyword map lookup and protocol dispatch avoid boxing (#396, #568), while
vector indexing takes a direct fast path (#398). - Inferred types moved to a positional side table, making type writes roughly
100x faster in the measured lowering workload (#558). - Realized lazy-seq reads gained a lock-free path (#560);
Keyword/Int
equality and string conversion gained typed fast paths (#567, #569). - Transient maps use their growth reserve (#559), checked integer multiply
avoids division on its fast path (#571), and all timeouts share one timer
daemon instead of one goroutine each (#442).
Upgrade notes
No hard-breaking API changes, but a few things behave differently:
- Namespace cleanup: let-go-specific functions that lived in
clojure.core
moved tolet-go.coreandlet-go.types(#410). Update requires for code that
reached let-go-specific functions throughclojure.core. - Closed channels do not panic: core.async operations on closed channels now
returnfalse/nil, matching Clojure (#391). - Recompile old bundles:
.lgbbundles built by older opcode sets are
rejected at load. Rebuild them with v1.12.0 (#443). - CLI exit codes:
lgexits non-zero when a script or-eexpression fails
(#409). - Ring-style request methods:
request-methodis a keyword such as:get,
not a string (#527). - Tracing: the old
traceopcode is replaced by the dynamically scoped
*lg-trace*facility; recompile bundles and migrate direct trace usage (#407).
Full change and contributor attribution follows.
What's Changed
- fix(bench): per-goroutine sink in Parallel VarDeref benchmarks by @nnunley in #365
- examples: host-eval browser demo (client-owned shell) by @mparrett in #359
- [codex] Derive lg metadata from Go build info by @mparrett in #366
- perf(ratchet): machine-profile baseline + machine-independent alloc gate + interactive multi-machine dashboard by @nnunley in #362
- fix(json,transit): serialize Float32 instead of panicking by @mparrett in #364
- chore: remove accidentally-committed perf-page binary + gitignore it by @nnunley in #369
- fix(ir): chained-comparison lowering + ec/callErr name collision in gogen by @nooga in #374
- feat(rt): spit/write! accept a byte-array (write raw bytes) by @mparrett in #367
- feat(wasm): -w-shell accepts a custom HTML template by @nnunley in #377
- feat(vm): carry metadata on functions (with-meta/meta) by @abogoyavlensky in #383
- feat(rt): add ns-publics by @abogoyavlensky in #384
- fix(vm): resolve empty LazySeq to nil so reduce/some don't see a phantom nil by @nnunley in #388
- refactor(vm): drop now-dead EmptyList guards after empty-LazySeq→nil fix by @nnunley in #389
- docs(clojure-test-suite): document make jank-stress native-lowering coverage gate by @nnunley in #393
- feat(ir,build): lowering-coverage ratchet gate (ITER-0021) by @nnunley in #370
- ci(perf): arm64 timeline leg + auto-backfill of release tags by @nnunley in #363
- chore(git): recompute generated.sums on merge (dedicated 'sums' driver) by @nnunley in #375
- Multi-arity direct calls + self-host coalesce fix, os/exec* streaming, VM Box fast-path by @nnunley in #371
- feat(wasm): neutral #app host mount by @mparrett in #378
- feat(rt): base64-encode / base64-decode builtins by @mparrett in #368
- docs(perf): document the perf label / PR-time A/B workflow by @mparrett in #373
- perf(ci): wasm A/B benchmark gate (perf-wasm label) by @mparrett in #386
- feat(rt): zero-arg (read-line) reads current in by @mparrett in #406
- feat(ir): type math intrinsic call results as :float in typeinfer by @mparrett in #372
- fix(cli): exit nonzero when script or -e fails by @mparrett in #409
- async: core.async parity — buffer policies, closed-channel puts, thread, alts!! by @nnunley in #391
- fix(vm): explicit :refer shadows the clojure.core auto-refer baseline by @nnunley in #408
- chore(perf): re-baseline ir-stress corpus after upstream drift (2347 -> 2366) by @nnunley in #395
- perf(vm): box-free KeywordLookup fast path (-4% native suite allocs) by @nnunley in #396
- feat(wasm): browser-inspector notebook + in-browser IR/bytecode/lowered-Go inspector by @nnunley in #379
- fix(rt): accept padded base64url input by @mparrett in #405
- feat(vm): real clojure.lang.PersistentQueue by @abogoyavlensky in #419
- feat(rt): add find-var and get-method by @abogoyavlensky in #417
- feat(ir): lowering strategy pattern + unified ir.coverage by @nnunley in #387
- feat(ir): combinator inlining + closure devirtualization (opt-in via enable-inline) by @nnunley in #394
- perf(vm): chunked ArrayVectorSeq (growing-from-1) + orderless maps + alloc-free symbol parsing (-34.8% suite allocs) by @nnunley in #397
- perf(rt): ArrayVector direct-index fast paths — eliminate the seq-protocol tax (-19.7% suite allocs) by @nnunley in #398
- Various bug fixes by @ingydotnet in #400
- fix(compiler): allow an empty catch body with a qualified class name by @abogoyavlensky in #413
- fix(core): scope record fields in defrecord protocol-method bodies by @abogoyavlensky in #415
- feat(rt): compile-only Clojure-compat classpath-scan stubs by @abogoyavlensky in #421
- feat(ir): reach typed natives from runtime values by @ingydotnet in #432
- perf(rt/ir): stop (nth string i) allocating the rune slice; O(1) registry emptiness check (−13% lowering allocs) by @nnunley in #402
- perf(ir): transducer-fusion foundation — data-driven pass list + remove arity-1 + transduce≡lazy proof by @nnunley in #434
- perf(ir/typeinfer): worklist membership via in-place transient-map flags (−1GB cloneAndSet) by @nnunley in #403
- chore(perf): refresh ir-stress and benchmark baselines after upstream drift by @nnunley in #437
- docs(vm/allocattr): correct the single-goroutine attribution assumption by @nnunley in #439
- feat(lgbgen): self-hosting lowering — Go overrides + check-selfhost target by @nnunley in #435
- feat(gogen): math.NaN()/math.Inf() emission + go-ast read/COW import bindings by @nnunley in #430
- perf(ir): transient current-locals-flat + reduce-based every? (−270MB lowering) by @nnunley in #433
- feat(ir-stress): tool-maintained coverage baseline — make ir-stress-rebaseline by @nnunley in #404
- refactor(rt,core): purify clojure.core — let-go.core / let-go.types for lg-isms by @nnunley in #410
- fix(core): format time macro elapsed duration as msecs by @nooga in #444
- feat(bytecode): reject .lgb bundles compiled against a different opcode set by @mparrett in #443
- perf(rt): close timeout channels from one shared timer daemon by @mparrett in #442
- test(vm): stabilize BenchmarkVectorAccess — sink result + hoist key boxing by @nnunley in #454
- fix(compiler): parse empty catch bodies with simple class names by @abogoyavlensky in #449
- fix(rt): return Clojure-compatible regex match results by @abogoyavlensky in #456
- fix(core): support the partition padding arity by @abogoyavlensky in #453
- fix(rt): add compile-only Exception constructor stub by @abogoyavlensky in #451
- fix(core): support fn pre and post conditions by @abogoyavlensky in #458
- feat(rt): java.lang exception class hierarchy and constructors by @abogoyavlensky in #472
- fix(vm): run finally on abnormal exits and propagate the exception by @abogoyavlensky in #474
- Typed catch dispatch by @abogoyavlensky in #476
- fix(rt): expose key-pending? on plan9 by @mparrett in #460
- feat(rt): real Plan 9 terminal input (raw mode, read-key, key-pending?) by @mparrett in #461
- bench: YamlStar real-corpus parse benchmark + ITER-0035 gate (#352) by @nnunley in #495
- Build let-go as a WASI (
GOOS=wasip1) module by @mparrett in #466 - test(ir): build-stable op baseline for the fold-unroll pipeline tests by @nnunley in #505
- feat(core): add update-keys/update-vals by @nooga in #447
- feat(native-bridge): //lg native-primitive generation — drain hot trampolines (IRCompile −13.6% allocs) by @nnunley in #438
- chore(hooks): reject committing compiled executables (pre-commit/prek) by @nnunley in #465
- chore(ir-stress): exclude scripts/lginterop.lg from corpus — stale gogen/parse-stmts by @nnunley in #467
- fix(vm): ArrayVectorSeq.ChunkedFirst must not mutate the shared seq node (data race) by @nnunley in #463
- fix(compiler): evaluate top-level (do ...) subform-by-subform like Clojure by @nnunley in #470
- Runtime-only build: execute precompiled bytecode, no compiler linked by @mparrett in #424
- perf(vm): per-var root binding chains — O(1) bound deref (Phase 2, stacks on #459) by @nnunley in #462
- feat(ir): loop-fusion (deforestation) — pass, enabled, comp-chains + into by @nnunley in #440
- fix(ir): accept defn attr-map position in AOT lowering by @nnunley in #469
- feat(ir): legalize cross-block refs and run lambda-lifted fns natively in gogen_ir by @nnunley in #479
- rt: JVM static methods & host-class markers by @abogoyavlensky in #514
- rt: add missing clojure.core fns (indexed?, class, uri?, monitor-enter/exit) by @abogoyavlensky in #508
- Host compat/compile stubs by @abogoyavlensky in #516
- rt: generic JVM collection-interface interop (.valAt/.iterator/.assoc/.cons/.nth/.count) on let-go collections by @abogoyavlensky in #510
- rt: add mutable java.util.HashMap and ArrayDeque host-compat types by @abogoyavlensky in #512
- feat(rt): unify core-boot onto LoadCoreBundle (#506) by @mparrett in #520
- perf(ci): interleaved repeat A/B as an informational shadow check by @mparrett in #517
- chore: gitignore .gomodcache and drop the committed toolchain lock by @nnunley in #518
- feat(vm): replace
tracewith dynamically-scoped lg-trace (signature-keyed opcode migration) by @nnunley in #407 - fix(core): parse defprotocol options eagerly by @nnunley in #488
- fix(core): accept return hints on arity vectors by @nnunley in #489
- fix(core): preserve dotdot member chains by @nnunley in #490
- perf(compiler): compile regex literals at read time (Clojure parity) by @nnunley in #496
- feat(ir): deterministic lowered tree — densify + work-unit typeinfer guard by @nnunley in #446
- fix(vm): recover top-level lazy realization errors by @nnunley in #491
- fix(errors): preserve namespace load diagnostics by @nnunley in #492
- feat(compat): run stock HoneySQL examples by @nnunley in #494
- fix(rt): request-method arrives as a keyword, Ring-style by @mparrett in #527
- ci: run the race detector over pkg/vm and pkg/rt by @mparrett in #524
- rt: add net and bencode namespaces (TCP client + bencode framing) by @abogoyavlensky in #526
- feat(rt): gate net/bencode off js/wasm; add unix bang-name aliases by @mparrett in #533
- fix(lginterop): rebuild the smart-wrapper emitter on gogen AST constructors by @nnunley in #537
- docs(guide): wrapping Go packages via lginterop — database/sql worked example by @nnunley in #532
- fix(ir): widen int operand to float64 in mixed int/float native lowering by @mparrett in #536
- feat(ir): accept numeric param hints (^double/^long) in AOT lowering (#357) by @mparrett in #530
- fix(vm): synchronize BoxedTypes cache and Boxed.Hash() lazy cache by @mparrett in #544
- test(lginterop): e2e leg for -smart output; defeat the test-cache blind spot by @mparrett in #543
- Update go-interop.md with human verification details by @nnunley in #542
- fix(lgbgen): stage --target=go output, install atomically, add completeness sentinel by @nnunley in #541
- fix(compat): resolve clojure-test-suite incompatibilities by @nnunley in #429
- fix(compiler): honor require aliases before the core/* resolution shortcut by @nnunley in #548
- fix(lgbgen): stage --target=go output and install atomically with a completeness sentinel by @nnunley in #549
- docs(guide): document lg-runtime, net/bencode; clarify numeric vs dispatch hints by @nnunley in #550
- bench: VM-vs-AOT comparison — lower fixtures to native Go, drop joker/go-joker/gloat/fennel by @nooga in #552
- ci(perf-timeline): per-OS concurrency groups, rebase-retry publish, skip docs-only commits by @mparrett in #565
- perf(vm): lock-free fast path for realized LazySeq access by @mparrett in #560
- ci(perf-timeline): revert -count to 3 — a count-4 ubuntu leg measures 104 min, past the 90-min timeout by @mparrett in #573
- perf(ir): move inferred types to a positional side table — ~100x faster type writes by @mparrett in #558
- perf(vm): divide-free fast path for checkedMulInt overflow guard by @mparrett in #571
- perf(vm): use the transient growth reserve in assocTransient inserts by @mparrett in #559
- perf(vm): gate lookup-stats noters on an atomic before the mutex by @mparrett in #563
- perf(vm): box-free keyword lookup in protocol method dispatch by @mparrett in #568
- perf(vm): typed Keyword/Int fast paths at the top of valueEquiv by @mparrett in #567
- perf(vm): strconv.Itoa and plain concat for Int/Keyword String() by @mparrett in #569
- bench: refresh results on post-#552 main; update README benchmark section by @nooga in #578
- bench: document the two AOT mechanisms behind the results table by @mparrett in #582
Full Changelog: v1.11.1...v1.12.0