Skip to content

Fix the v0.3.0 launch crash: attach the input node before prepare() - #73

Merged
missingbulb merged 1 commit into
mainfrom
claude/standby-detection-counter-qt945o
Jul 29, 2026
Merged

Fix the v0.3.0 launch crash: attach the input node before prepare()#73
missingbulb merged 1 commit into
mainfrom
claude/standby-detection-counter-qt945o

Conversation

@missingbulb

Copy link
Copy Markdown
Owner

Closes #72.

v0.3.0 crashes about a second after every launch — menu-bar icon appears, then vanishes. Regression from #59. Reported from the owner's Mac mini, crash report LaughCounter-2026-07-29-111329.ips.

EXC_CRASH (SIGABRT)
required condition is false: inputNode != nullptr || outputNode != nullptr
  AVAudioEngineGraph::Initialize → -[AVAudioEngine prepare]
  AudioHub.prepareFormat() → AppDelegate.finishListening(generation:)

Cause

AVAudioEngine initializes its graph inside prepare() and asserts that at least one node is attached. A freshly constructed engine has none — inputNode is created lazily on first access, and touching the property is what attaches it.

#59's per-start rebuild replaced the engine and called prepare() with nothing in between. The single-engine code had satisfied the precondition by accident: requestListening() calls audio.stop(), which reaches engine.inputNode.removeTap(onBus: 0) and materialized the node on the long-lived engine well before any prepare() ran. Rebuilding the engine after that call removed the accident without replacing it with anything deliberate — so it failed on the very first start, every time.

Fix

  • makeEngine() materializes the input node as part of building an engine, and both init() and renewEngine() go through it. The precondition is satisfied by construction rather than by a call order somewhere else that a later edit can quietly break.
  • engine.prepare() is now trapped too. Fix the installTap crash on every audio device change, and reactivate the counter on return from standby #59 wrapped only installTapOnBus, which is why the trap was not in this path at all. This crash proves prepare() raises as well, and a raise there is equally fatal.
  • Version → 0.3.1 so the crashing build stays distinguishable.

The trap remains unexercised: nothing has yet demonstrated it catching an NSException that unwinds through the intervening Swift closure frame, which is not guaranteed to work. It is a hoped-for backstop, not a proven one — the two structural fixes (#61's fresh engine, this one's attached node) are what actually keep these paths off the exception route.

Testing

CI compiles it. That is not a meaningful gate for this file — compile-green passed two crash-on-launch builds in a row, because every raise-vs-throw bug in AudioHub is invisible to the compiler and reachable in the first second of a run. The real check is launching 0.3.1 on the Mac mini and seeing the icon stay up, then listening started in the activity log.

A unit-test target calling AudioHub().prepareFormat() would have caught this exact class of bug — it either throws or returns, and must never raise. Noted as follow-up in #72 rather than bundled here, since it needs a test target and a swift test step in both DMG workflows, and this fix should ship on its own.

dev/procedures/mac-audio-lifecycle.md records both the node-attachment rule and the "compile-green is not a gate for this file" lesson.


Generated by Claude Code

…launch

v0.3.0 died about a second after every launch: the menu-bar icon appeared and
vanished.

    required condition is false: inputNode != nullptr || outputNode != nullptr
    AVAudioEngineGraph::Initialize -> -[AVAudioEngine prepare]
    AudioHub.prepareFormat()

AVAudioEngine initializes its graph inside prepare() and asserts that at least
one node is attached. A freshly constructed engine has none — inputNode is
created lazily on first access, and touching the property is what attaches it.

The per-start engine rebuild from #59 replaced the engine and called prepare()
with nothing in between. The single-engine code had satisfied this by accident:
requestListening()'s stop() reaches engine.inputNode.removeTap(onBus: 0), which
materialized the node on the long-lived engine well before any prepare().
Rebuilding after that call removed the accident without replacing it.

So makeEngine() materializes the input node as part of building an engine, used
by both init() and renewEngine() — no call ordering elsewhere can get it wrong.

Also traps engine.prepare(). #59 wrapped only installTapOnBus, which is why the
trap was not in this path at all. The trap itself remains unexercised: nothing
has yet shown it catching a raise that unwinds through the intervening Swift
closure frame.

Bumps to 0.3.1 so the crashing build stays distinguishable.

Closes #72

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDYgSqFgDXf6BXWZFUTi8d
@missingbulb
missingbulb merged commit 61df205 into main Jul 29, 2026
1 check passed
@missingbulb
missingbulb deleted the claude/standby-detection-counter-qt945o branch July 29, 2026 09:33
missingbulb added a commit that referenced this pull request Aug 7, 2026
Three lessons from the 2026-07-26..08-02 window, into the laughcounter local
pack as prose.

#74/#75: diagnosing the installTap crash stalled on "which binary is
installed?" — the menu said only "LaughCounter", and several distinct builds
all reported 0.2.1, because the release workflow keys its Release on
v<version> from Info.plist, so a merge that leaves CFBundleShortVersionString
alone refreshes the same Release behind the latest/download link. Durable
part: show version and build from Bundle.main (not a source constant that
could disagree with the DMG), and bump per distinguishable build.

#56: the scheduler ran green nightly while silently skipping baselining ("no
vendored mount (no stamp)") because the vendored loadConfig dropped the
`claudinite` key it had just validated. Durable part: a job whose success and
whose no-op look identical from outside is telling you nothing — read the skip
line; and a bug inside the mechanism that updates itself has to be fixed out
of band.

#34: claudinite-isolation fired on CLAUDE.md's mount path, which carried
nothing a reader could act on. Durable part: before adding an `accept`, delete
the flagged text and see whether anything actionable went with it — an accept
is for a crossing that must exist.

Nothing new from the mac window (#59, #73, #77, #78, #87, #100, #101, #108):
dev/procedures/mac-audio-lifecycle.md already records the engine-per-start
rule, the inputFormat-vs-outputFormat trap, the aggregate churn, the
three-state health reporting, the witnessed-arrival settle rule and the
observation-gap rule in full. #55, #69, #84 and #99 are already carried by
this pack's existing prose and the on-device-privacy checks. #32's
"prove the check is live" is the see-it-fail discipline the canon owns.
Conversation-logs half: the 2026-08-01 logs are the #108 session (fully
covered above) and unattended task runs; no new friction lesson. No
retention_days configured, so no prune.

Refs #113.


Claude-Session: https://claude.ai/code/session_01G52dxZvLCxyZJJnvLrYcQs

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v0.3.0 crashes at launch: prepare() on a freshly built engine with no nodes attached

2 participants