Skip to content

SIGSEGV at startup: geisterhand build dereferences an address-shaped pointer during module init (regression from #9708) #10019

Description

@steinybot

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:

  • SIGSEGVEXC_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.

Activity

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions