Summary
A perry/ui app compiled with --enable-geisterhand SIGSEGVs during module
initialization, before the UI renders. git bisect points at
#9708 / commit
2016929c
("perf(ic): allocate inline caches per used site behind an 8-byte slot"). The
process dereferences an address-shaped value as a pointer during init and faults.
Environment
- perry
0.5.1523, main tip
0d62e051.
First bad commit 2016929c; present on every commit since.
- macOS 15, arm64 (Apple Silicon).
--enable-geisterhand (full stdlib + unwind runtime). A plain auto-optimized
build of the same app does not crash.
- Reproduces in both stripped-release and
--report-size / --debug-symbols
builds (identical __text layout under --report-size).
Steps to reproduce
A standalone 91-line program reproduces it:
perry compile single.ts --enable-geisterhand -o app
./app
Crashes 8/8 runs.
single.ts (91 lines)
// Single-file repro of a perry #9708 startup SIGSEGV (regression from commit
// 2016929c). A geisterhand build dereferences an address-shaped value as a
// pointer during module init and faults; ASLR-dependent. Root cause not yet
// found. See docs/perry-9708-github-issue.md.
// Load-bearing bulk: the node child_process/fs runtime linked by the wizard
// subgraph below. Stub those calls out and the layout-dependent crash vanishes.
import {
App,
appSetActivationPolicy,
Button,
Text,
VStack,
textSetColor,
textSetFontFamily,
textSetFontSize,
textSetFontWeight,
} from "perry/ui"
import type { Widget } from "perry/ui"
import { execFile } from "node:child_process"
import { mkdir, writeFile } from "node:fs/promises"
import { dirname } from "node:path"
interface Color { r: number; g: number; b: number; a: number }
interface TextStyle { size?: number; weight?: number; color?: Color; family?: string }
function rgb(hex: string): Color {
const n = parseInt(hex.slice(1), 16)
return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255, a: 1 }
}
function fade(c: Color, a: number): Color {
return { r: c.r, g: c.g, b: c.b, a }
}
const palette = { darkNavy: rgb("#010628"), white: rgb("#FFFFFF") } as const
const color = { textHeading: palette.darkNavy, textInverseMuted: fade(palette.white, 0.72) } as const
const font = { weight: { bold: 700 }, size: { h2: 32, body: 16 } } as const
function themeText(w: Widget, s: TextStyle): Widget {
if (s.size !== undefined) textSetFontSize(w, s.size)
if (s.weight !== undefined) textSetFontWeight(w, s.size ?? font.size.body, s.weight)
if (s.family !== undefined) textSetFontFamily(w, s.family)
if (s.color !== undefined) textSetColor(w, s.color.r, s.color.g, s.color.b, s.color.a)
return w
}
type Runner = (args: string[]) => Promise<string>
interface Config { beadsPoolDir: string }
interface WizardInput { poolDir: string; jira: { url: string; username: string; pullJql: string } }
interface WizardDeps { save(c: Config): Promise<void>; initPool(d: string): Promise<void>; poolRunner(d: string): Runner }
function bdRunner(poolDir: string): Runner {
return (args) =>
new Promise((resolve, reject) =>
execFile("bd", args, { env: { ...process.env, BEADS_DIR: poolDir } }, (err, stdout) =>
err ? reject(err) : resolve(stdout ?? ""),
),
)
}
async function saveConfig(c: Config): Promise<void> {
const p = `${process.env.HOME}/.config/uptask/config.json`
await mkdir(dirname(p), { recursive: true })
await writeFile(p, JSON.stringify(c), "utf8")
}
async function realInitPool(poolDir: string): Promise<void> {
const parent = dirname(poolDir)
await mkdir(parent, { recursive: true })
await new Promise<void>((resolve, reject) =>
execFile("bd", ["init"], { cwd: parent }, (err) => (err ? reject(err) : resolve())),
)
}
async function runWizard(input: WizardInput, deps: WizardDeps): Promise<void> {
await deps.save({ beadsPoolDir: input.poolDir })
await deps.initPool(input.poolDir)
const run = deps.poolRunner(input.poolDir)
if (input.jira.url !== "") await run(["config", "set", "jira.url", input.jira.url])
}
const realWizardDeps: WizardDeps = { save: saveConfig, initPool: realInitPool, poolRunner: bdRunner }
function runSetupApp(prefill: WizardInput): void {
appSetActivationPolicy("background")
const heading = themeText(Text("Setup"), { size: font.size.h2, weight: font.weight.bold, color: color.textHeading })
const save = Button("Save", () => {
runWizard(prefill, realWizardDeps).then(() => {}, () => {})
})
App({ title: "Setup", width: 760, height: 1040, body: VStack([heading, save]) })
}
runSetupApp({ poolDir: "~/.uptask/pool", jira: { url: "", username: "", pullJql: "" } })
Expected behaviour
The app launches and renders. Module init resolves its imports and returns.
Actual behaviour
Prints [geisterhand] listening on http://127.0.0.1:7676, then dies during module
init, before the UI comes up. Two outcomes, selected by address-space layout:
- SIGSEGV —
EXC_BAD_ACCESS, KERN_INVALID_ADDRESS at an address that varies
run to run (0x4a220020080, 0x2e390010080, 0x306e0020080 observed).
- Clean exit code 1 after the same
listening line, when the bad read lands on
mapped memory instead of faulting.
It reproduces under lldb only with ASLR left on
(settings set target.disable-aslr false); with lldb's default (ASLR off) it does
not. The varying fault address and the ASLR dependence both indicate a bad value
derived from an address, not a fixed constant.
Diagnosis (best so far)
Bisect. git bisect over
8c79324..a67e15ce
with a deterministic predicate (build the geisterhand binary, launch it, check
whether it serves before exiting). First bad commit:
2016929c.
Symbolicated backtrace (release layout, --report-size):
Thread 0 (main):
perry_runtime::object::native_module::callable_exports::bound_native_callable_export_value
perry_runtime::object::native_module::native_module_property_by_name
perry_runtime::object::native_module::native_module_export_value
perry_runtime::exception::arm_trap_and_run::invoke (module init)
perry_sjlj_try
js_run_module_init_catching
main
Fault. The faulting instruction reads a field off a corrupted, address-shaped
pointer: at crash the pointer register holds 0x4a220020000 (varies per run) and
the code reads field +0x80 off it. So an address-derived garbage value is used as
an object pointer and dereferenced during the runtime's module-export resolution at
init (the top frames of the backtrace above).
Origin of the change. #9708 moved each property-read inline cache behind a
lazily allocated 8-byte slot and gates the hit path on slot != null only. The slot
global is statically null, so any bad value in it is written at run time. The
change sites:
Confirmed vs. open. The root cause is not yet found.
- Confirmed: the bisect, and that the reproducible fault dereferences an
address-shaped value as a pointer during module init, ASLR-dependent.
- Open: what writes the address-shaped value, and whether it originates in the
#9708 inline-cache slot. The reproducible fault is inside the runtime's
module-export resolution code, not an emitted property-read hit path, so the bad
value may reach it from elsewhere.
- Note: an earlier analysis of the same regression on the full application saw the
fault in generated property-read code instead (reading colour fields off an
object during theme init). That backtrace no longer reproduces on 0.5.1523; the
reproducible fault is the one in the backtrace above. Both are the same signature
— an address-shaped value used as a pointer during init — consistent with one
cause surfacing at different code sites depending on layout.
We can reduce the repro further or run instrumented builds on request.
Summary
A
perry/uiapp compiled with--enable-geisterhandSIGSEGVs during moduleinitialization, before the UI renders.
git bisectpoints at#9708 / commit
2016929c("perf(ic): allocate inline caches per used site behind an 8-byte slot"). The
process dereferences an address-shaped value as a pointer during init and faults.
Environment
0.5.1523,maintip0d62e051.First bad commit
2016929c; present on every commit since.--enable-geisterhand(full stdlib + unwind runtime). A plain auto-optimizedbuild of the same app does not crash.
--report-size/--debug-symbolsbuilds (identical
__textlayout under--report-size).Steps to reproduce
A standalone 91-line program reproduces it:
Crashes 8/8 runs.
single.ts(91 lines)Expected behaviour
The app launches and renders. Module init resolves its imports and returns.
Actual behaviour
Prints
[geisterhand] listening on http://127.0.0.1:7676, then dies during moduleinit, before the UI comes up. Two outcomes, selected by address-space layout:
EXC_BAD_ACCESS,KERN_INVALID_ADDRESSat an address that variesrun to run (
0x4a220020080,0x2e390010080,0x306e0020080observed).listeningline, when the bad read lands onmapped memory instead of faulting.
It reproduces under
lldbonly with ASLR left on(
settings set target.disable-aslr false); with lldb's default (ASLR off) it doesnot. The varying fault address and the ASLR dependence both indicate a bad value
derived from an address, not a fixed constant.
Diagnosis (best so far)
Bisect.
git bisectover8c79324..a67e15cewith a deterministic predicate (build the geisterhand binary, launch it, check
whether it serves before exiting). First bad commit:
2016929c.Symbolicated backtrace (release layout,
--report-size):Fault. The faulting instruction reads a field off a corrupted, address-shaped
pointer: at crash the pointer register holds
0x4a220020000(varies per run) andthe code reads field
+0x80off it. So an address-derived garbage value is used asan object pointer and dereferenced during the runtime's module-export resolution at
init (the top frames of the backtrace above).
Origin of the change.
#9708moved each property-read inline cache behind alazily allocated 8-byte slot and gates the hit path on
slot != nullonly. The slotglobal is statically
null, so any bad value in it is written at run time. Thechange sites:
icmp ne slot, null):emit_inline_cache_slot, expr/mod.rs#L2273-L2277= private global ptr null): expr/mod.rs#L2242-L2253presentinto the receiver guard: generic_dispatch.rs#L467-L511pic_slot_resolve, ic_slot.rs#L102-L134,pic_arena_alloc, ic_slot.rs#L65-L86Confirmed vs. open. The root cause is not yet found.
address-shaped value as a pointer during module init, ASLR-dependent.
#9708inline-cache slot. The reproducible fault is inside the runtime'smodule-export resolution code, not an emitted property-read hit path, so the bad
value may reach it from elsewhere.
fault in generated property-read code instead (reading colour fields off an
object during theme init). That backtrace no longer reproduces on
0.5.1523; thereproducible fault is the one in the backtrace above. Both are the same signature
— an address-shaped value used as a pointer during init — consistent with one
cause surfacing at different code sites depending on layout.
We can reduce the repro further or run instrumented builds on request.