Auto-Update, a Live Inspector, and the Compiler That Halved Itself
This dated article records the Perry version and measurements available when it was published. Use the current limitations, platform guide, and benchmark artifact for decisions today.
The last post closed at v0.5.306 on the gen-GC + JSON + benchmarks story. Four days later, Perry is on v0.5.359 — that's 53 patch releases — and the story is different again. None of those releases are headline benchmark numbers. Almost all of them are issues from the tracker getting closed.
perry/updaterships — Sparkle/Tauri-shape auto-update for desktop apps (Ed25519 over a SHA-256 digest, sentinel-rollback, detached relaunch). Community PR from TheHypnoo (#224).- Geisterhand Phase D — a live inspector at
http://localhost:7676with widget tree, per-widget detail, click dispatch, and live style edit viaPOST /style/:h. - The compiler refactor. Across v0.5.329 → v0.5.343 the four most-cited files got carved up:
lower::lower_expr6,687 → 624 LOC (−91%),compile.rs9,391 → 3,783 LOC (−60%),lower.rs13,591 → 7,554 LOC (−44%),lower_call.rs7,000+ → 4,681 LOC (−33%). Newwalker.rsturns the_ =>catch-all bug class into a compile error. - UI styling Phase C closes out — inline
style: { ... }props on every widget across Apple, Android, GTK4, Windows, and Web. Windows gets 4 of 5 stubs wired (decoration / opacity / borders); onlywidget.shadowremains (DirectComposition follow-up). - A Scoop bucket for Windows:
scoop install perry-ts/perry. SHA-256 sidecars in the release workflow. - Wave of community issue fixes — about 30 issues closed across runtime, codegen, fetch, GTK4, Windows linker, async, and stdlib.
1. perry/updater — auto-update for desktop apps
Pre-fix Perry had no update path. Apps shipped, and shipped, and that was it. TheHypnoo opened #224 with the full story:
import { initUpdater, checkForUpdate, markHealthy } from "@perry/updater";
initUpdater(); // sentinel-rollback if the previous launch crashed
const update = await checkForUpdate({
manifestUrl: "https://example.com/updates/manifest.json",
publicKey: "<ed25519 raw 32-byte hex>",
currentVersion: "1.4.0",
});
if (update) {
await update.download((pct) => console.log(`${pct}%`));
await update.installAndRelaunch();
}
markHealthy(); // call after the new build successfully bootsTrust model: Ed25519 over the SHA-256 digest of the file (not the file bytes — keeps the verify cheap on big binaries). Manifest is JSON, schema-versioned, one entry per <os>-<arch> triple. Atomic install with <exe>.prev backup, detached relaunch (setsid on Unix, DETACHED_PROCESS on Windows). Mobile is excluded by design — App Store / Play Store own the install pipeline at the OS level.
Two Perry runtime quirks surfaced from writing the smoke test, and got fixed inline:
response.arrayBuffer()was returning a metadata-only stub. Fixed in #232 (also TheHypnoo) —js_response_array_buffernow allocates a realBufferHeaderandmemcpysresp.bodyinto it.fs.appendFileSyncwrote 0 bytes. Fixed in #226 — the namespace-import lowering path (import * as fs from "fs") had no arm forappendFileSync, and the LLVM codegen had no arm for the HIR variant either. Both wired.
Documentation lives at docs/src/updater/overview.md.
2. Geisterhand: a live inspector at localhost:7676
Geisterhand has been Perry's in-process UI test harness — HTTP API on port 7676 for snapshotting widget state and dispatching clicks. Phase D turns it into a devtools-style inspector you can browse from any browser.
- Step 1 (v0.5.349) —
GET /serves a single-page vanilla-JS UI with a widget tree, per-widget detail (frame, value, raw JSON), 1.5s auto-refresh with pause/resume, and a “fire onClick” action button. Codegen pinsINSPECTOR_HTMLagainst macOS lazy-load-dead_stripso it survives release builds. - Step 2 (v0.5.350) —
POST /style/:htakes a JSON prop bag and applies it live. 9 props (backgroundColor,color,borderColor,borderWidth,borderRadius,opacity,padding,hidden,enabled) flow HTTP-thread → main-thread via the existing pump-queue. Bad JSON → 400; bad handle → 400; unknown props are filtered server-side and the response lists which ones made it through.
perry compile main.ts -o app --enable-geisterhand
./app &
open http://localhost:7676
curl -X POST localhost:7676/style/3 \
-H 'content-type: application/json' \
-d '{"backgroundColor":"#1a1a1e","opacity":0.8}'
# => {"ok":true,"applied":["backgroundColor","opacity"]}macOS dispatcher is wired; Linux / Windows / iOS / tvOS / visionOS / Android follow the same shape and are next.
3. The compiler refactor — splitting the four biggest files
Five issues in the tracker (#167, #169, #212, #214, plus a long tail) had the same shape: a new Expr variant got added to ir.rs, but one of four ad-hoc walkers in lower.rs had a _ => catch-all and silently miscompiled the new variant. Catching this at runtime is expensive — sometimes invisible, sometimes a SIGSEGV under SSO.
v0.5.329 introduced crates/perry-hir/src/walker.rs with walk_expr_children / walk_expr_children_mut — exhaustive matches over all 178 Expr variants, no catch-all. Adding a new variant without listing it here is now a compile error. The four consumers (substitute_locals, find_max_local_id::check_expr, collect_local_refs_expr, remap_local_ids_in_expr) collapsed:
| Function | Before | After | Δ |
|---|---|---|---|
find_max_local_id::check_expr | 225 | 57 | −75% |
substitute_locals | 553 | 80 | −86% |
collect_local_refs_expr | 720 | 70 | −90% |
remap_local_ids_in_expr | 542 | 85 | −84% |
Total: −1,830 lines of duplicated descent, replaced by +1,840 lines of one centralized walker — net flat, but the bug class is gone.
That unblocked the rest. v0.5.331 → v0.5.343 carved up the four monoliths over 14 commits. The headline numbers:
| File | Before | After | Δ |
|---|---|---|---|
lower::lower_expr | 6,687 | 624 | −91% |
compile.rs | 9,391 | 3,783 | −60% |
lower.rs | 13,591 | 7,554 | −44% |
lower_call.rs | 7,000+ | 4,681 | −33% |
The split landed as 19 new focused sub-modules: compile/{parse_cache, strip_dedup, library_search, object_cache, resolve, collect_modules, optimized_libs, targets, link}.rs, lower/{expr_misc, expr_function, expr_object, expr_call, expr_member, expr_assign, expr_new}.rs, lower_call/{ui_styling, builtin, native}.rs, plus a new crates/perry-dispatch crate that became the single source of truth for UI / system / i18n method tables (the _ => "perry_ui_unknown" fan-out that drove issue #191's “compiles on macOS, breaks on web” surprises is now one lookup).
Tier 4 perf wins rode along (v0.5.335–v0.5.336):
- Fused two passes in
inline_functionsand three rayon passes incompile.rs— saves 5 module scans + 3 scheduler round-trips per compile. - Bounded the
perry devparse cache at 500 entries, FIFO eviction. Pre-fix a session walkingnode_modulescould hold 100+ MB of SWC AST. - Parallelized the post-codegen
.llwrite loop — 2–4× faster wall-time on SSDs with 50+ modules. Arc<I18nTable>instead of cloning the locale table per worker.
Workspace tests stayed at 434 passed / 0 failed / 5 ignored through every commit; gap tests at 25/28 baseline; doc-tests at 80/82 baseline.
4. UI styling Phase C, finished
Phase C was the inline style: { ... } rollout. Steps 1–7 closed in this window:
- v0.5.305 → v0.5.306 —
StylePropstype surface + inlinestyle:on Button. - v0.5.307 → v0.5.309 — color/padding/shadow inline destructure on every table widget, then VStack / HStack.
- v0.5.310 → v0.5.311 — hex strings + gradient + runtime
parseColorfor dynamic values. - v0.5.312 — styling docs + Windows tracking issue.
Then the cross-platform sweep:
- GTK4 (#202, #206) — 4 styling FFIs wired, plus 7 missing FFIs blocking the Linux doc-tests gate (v0.5.322).
- macOS (v0.5.324) —
CALayershadow plumbing forwidget.shadow+ visual_test infrastructure;set_colorclass-probe for non-NSTextFieldwidgets. - iOS / tvOS / visionOS (v0.5.346) — Button
color: ...was hittingsetTextColor:onUIButton, which doesn't implement that selector;objc2panic crossed anextern "C"boundary and the process aborted. Fixed via the same class-probe pattern as macOS — UIButton now routes throughsetTitleColor:forState:UIControlStateNormal. - Windows (v0.5.347) — 4 of 5 styling stubs wired (
text.decorationviaLOGFONTround-trip,widget.opacityviaWS_EX_LAYERED+SetLayeredWindowAttributes, borders viaSetWindowSubclass+WM_PAINT). Onlywidget.shadowremains (needs DirectComposition).
The styling matrix in docs/src/ui/styling-matrix.md ends the window with Web at 43/43 Wired, Windows at 42/43 Wired, the rest at full coverage.
5. The runtime correctness pass — issue by issue
A theme of the period: every miscompile that came in over the tracker either turned into a fix or a compile-time error. Highlights:
- #212 (v0.5.323) — class methods inside
fncouldn't capture enclosing-fn locals. Multi-module repros now match Node byte-for-byte. - #214 (v0.5.321 + v0.5.330) — SSO-safe string-handle unboxing across 7 string-operand sites:
arr.join,arr.toString,obj[stringKey]get/set/delete,string.match(re),process.env[dynKey], crypto digest input. Pre-fix every one of them either silently returned garbage or SIGSEGV'd on inline-string operands. - #221 (v0.5.351) — module-level empty
constarrays droppedarr[i]=writes from inside functions. Surfaced when Bloom-Engine/jump'sdiscoverLevels()populatedLEVEL_FILESat module level via index-assign and the level-select screen rendered empty. - #233 (v0.5.357) —
Array.pushfrom inside an async function silently capped at 16 elements when the array was passed in as a parameter. Async functions don't inline; reallocation returned a new pointer the caller never saw. Fix: install a forwarding pointer at the old location on every grow, reusing the GC's existingGC_FLAG_FORWARDEDmechanism. - #235 (v0.5.358) — method-default-param dispatch was passing garbage when callers skipped trailing args. Two contributing parts: cross-module method declares hardcoded 6 doubles instead of
arity + 1, andlower_class_methodhad nobuild_default_param_stmtscall at all. Surfaced in mongodb'sfindOne(filter, options = {})hanging silently; fix is uniform across local + cross-module dispatch. - #236 (v0.5.355) — three independent fetch + promise bugs from one repro: api.github.com 403'd anonymous (default User-Agent now set),
.then(console.log)hung forever (null callbacks weren't pushing TASK_QUEUE entries), every fetch rejection printedUncaught exception: [object Object](NaN-boxed bare*StringHeaderinstead of a realErrorHeader). - #234 (v0.5.359) — real
BlobwitharrayBuffer/text/bytes/sliceinstance methods. Pre-fixawait response.blob()returned a metadata-only{size, type}stub. Three-part fix landed across runtime + HIR + codegen.
Plus the small catch-ups:
- #181 — strip-dedup over-pruned generic monomorphizations on Linux + GTK4 link silent-fallback. Fix: replace name-pattern filtering with symbol-set comparison via
llvm-nm. Members with even one unique symbol are kept. Trimmedlibperry_ui_macos.a196 → 35 objects with no link errors. - #220 —
secur32.libadded to the Windows link line. - #198 — i18n
FormatNumberFP round-trip via Ryū. - #188 — codegen dispatch wired for
perry/i18nformat wrappers. - #189 / #203 —
perry/plugincodegen dispatch. - #190 — Canvas widget through LLVM codegen.
- #191 — CameraView through codegen.
- #192 — Table widget through codegen.
- #193 (partial) — 11 stdlib helper dispatch arms.
- #98 — background-receive notifications on iOS + Android (warm-path).
- #106 — watchOS weak fallbacks for game-loop FFI hooks.
- #154 —
using/await usingdispose hooks. - #167 —
js_native_call_methodargs alloca hoisted to entry block. - #169 —
substitute_localsUint8Array arms. - #226 —
fs.appendFileSyncwired end-to-end (community PR).
6. Windows + Scoop
The Windows toolchain story keeps simplifying. v0.5.353 pinned clang -target on host builds — non-MSVC clang on PATH (MinGW / MSYS2 / Anaconda / Rust GNU bundles) was silently rewriting Perry's x86_64-pc-windows-msvc IR to windows-gnu, and lld-link couldn't resolve the __main reference LLVM's mingw32 emitter inserted. New probe_clang_default_triple runs clang --version once per process and prints a single informational note when the host default is GNU but we're targeting MSVC. Suppress with PERRY_NO_CLANG_PROBE=1.
v0.5.345 aligned the Win64 perry-ui ABI with perry-dispatch — three runtime extern signatures had drifted (perry_ui_navstack_create, perry_ui_menu_add_item_with_shortcut, perry_ui_app_set_timer). On Win64 ABI integer and float positional args share slot indices, so a mismatch read garbage from uninitialized registers. SysV (macOS / Linux) uses separate int/float register pools and happened to land valid bits — Windows-only crash, fixed across all 8 perry-ui-* platform crates.
Then: scoop install perry-ts/perry. Manifest pinned to v0.5.345 (with depends: main/llvm to auto-pull official MSVC-default LLVM). Release workflow now emits <artifact>.sha256 sidecars next to every archive, in sha256sum-compatible format for every downstream package-manager bumper.
# Windows host
scoop bucket add perry-ts https://github.com/PerryTS/perry
scoop install perry-ts/perry
perry compile src\main.ts --target windows -o myapp.exe7. Wrapping up
The pattern of this stretch is community engagement plus internal hygiene. TheHypnoo shipped three significant PRs (#224 perry/updater, #231 fs.appendFileSync wiring, #232 response.arrayBuffer body bytes). The tracker emptied of about 30 issues. The compiler got 60% smaller on its biggest file and grew an exhaustive walker that turns “forgot to update one of four ad-hoc walkers” from a runtime miscompile into a cargo build error. UI styling reached parity across every desktop platform except shadows on Windows. Geisterhand grew a browser-based devtools surface. The Windows install path got one command shorter.
Try it:
# npm (any platform)
npm install @perryts/perry
npx perry compile src/main.ts -o myapp && ./myapp
# Homebrew (macOS)
brew install PerryTS/perry/perry
# Scoop (Windows)
scoop bucket add perry-ts https://github.com/PerryTS/perry
scoop install perry-ts/perry
# Auto-update for desktop apps
npm install @perry/updater
# Live inspector
perry compile main.ts -o app --enable-geisterhand
./app & # then open http://localhost:7676Source: github.com/PerryTS/perry — Issues: github.com/PerryTS/perry/issues — Changelog: CHANGELOG.md
— Ralph
Like this post? Get the next one.
Short notes on Perry releases and what we're building next.
Your email is sent to Resend for the Perry newsletter. You can request unsubscribe or deletion at any time. Privacy notice