Skip to content

let-go v1.12.1

Choose a tag to compare

@nooga nooga released this 20 Jul 18:49
Immutable release. Only release title and notes can be modified.
c29b311

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.HashMap and ArrayDeque (#512), static method calls
    (#514), and the collection interfaces libraries expect (#510).
  • The java.lang exception hierarchy (#472), typed catch dispatch (#476), and
    finally blocks that run on every exit path (#474).
  • fn supports :pre/:post conditions (#458), defprotocol accepts 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 the core/* 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-runtime is 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 ^double or ^long can 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.NewLetGo plus Def hands Go values,
    functions, structs, and channels to the VM.
  • let-go to Go (wrapping): lginterop generates smart wrappers around Go
    packages. The guide now walks through database/sql end to end, and an e2e
    test keeps the wrapper path honest (#537, #532, #543, #542).
  • Native primitives: the //lg:native annotation 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 net namespace with TCP client support plus bencode encoding and framing
    (#526, #533).
  • Ring-style HTTP requests now expose request-method as 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!,
    streaming os/exec*, and function metadata (#368, #367, #371, #383).
  • core.async now matches Clojure for closed channels: puts return false and
    takes return nil (#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 to let-go.core and let-go.types (#410). Update requires for code that
    reached let-go-specific functions through clojure.core.
  • Closed channels do not panic: core.async operations on closed channels now
    return false/nil, matching Clojure (#391).
  • Recompile old bundles: .lgb bundles built by older opcode sets are
    rejected at load. Rebuild them with v1.12.0 (#443).
  • CLI exit codes: lg exits non-zero when a script or -e expression fails
    (#409).
  • Ring-style request methods: request-method is a keyword such as :get,
    not a string (#527).
  • Tracing: the old trace opcode 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 trace with 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