Skip to content

A Core Haptics engine that stopped under a call raises an NSException: handle it as a failed engine instead of ending the app - #1694

Closed
oliverames wants to merge 1 commit into
seedds:mainfrom
oliverames:haptics-stopped-engine
Closed

oliverames wants to merge 1 commit into
seedds:mainfrom
oliverames:haptics-stopped-engine

Conversation

@oliverames

@oliverames oliverames commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

What and why

Mario Kart World 1.8.0 on a physical iPhone ended about 85 s into a race with *** Terminating app due to uncaught exception 'com.apple.CoreHaptics', reason: '-[CHHapticEngine(CHHapticEngineInternal) checkEngineRunning:]: self.running' and [dm] CRASH sig=6, a few seconds after the log's oe focus state changed twice (2, then 1). It was not memory: the footprint was flat at about 3.7 GiB.

Core Haptics reports a player or parameter call on an engine that is not running by raising an NSException, not by returning an NSError. The engine stops on its own (audio interruption, focus change, helper exit) and its stopped handler is only queued onto the adapter's serial queue, so a pulse renewal or a level change can reach the engine first. Every error: path was already handled; this is not an error return.

apply: and the pulse renewal now run in @try; a caught exception is handled as a failed engine (stop, drop the engine, back off like a failed start, clear the queued output, [RED] haptics: ... on a stopped engine), so the next effect builds a new engine. Reference entry: A haptic engine that stops under a call raises; game note line in dm/games/0400C3F00006E000/README.md.

Checked

  • Tests: t_haptics_apple gains a case that raises from a player's sendParameters: it passes with the change and ends in the same uncaught NSException without it (built and run on the Mac); dm/tools/tests/test_docs.py passes.
  • Games run, with title ID and version: Mario Kart World (0400C3F00006E000) 1.8.0, built from WIP: Mario Kart World 1.8.0 on iOS - rendering, memory and presentation #1383's tree, on the phone: two later runs of the build with this change (about 80 s and about 5 minutes) ended without the crash; neither forced an engine stop.
  • Devices and OS: iPhone 19,4 (2 Performance + 4 Efficiency cores), iOS 27.3; Apple-silicon Mac, macOS 27.3
  • scripts/regress.py check --games: not run: the change only adds an exception handler around Core Haptics calls on the Mac and iOS front ends; nothing runs differently unless Core Haptics raises.
  • Not checked: the guard itself has only been exercised by the unit test (no run forced an engine stop); no haptics were felt on a controller with the change.

Front ends

None: no front end has to act on this.

Docs

  • The finding is in docs/reference/services.md and the game note; python3 dm/tools/docs_symptoms.py was run
  • A chain tool changed: dmsh_chain.py --header was run and dm/gfx/nvn/dmsh_chain.h is part of the change. Not applicable: no chain tool changed.
  • No game files, keys or data extracted from a game in the change or the attachments

@github-actions github-actions Bot added host The Horizon host: SVCs, services, threads, memory (dm/host) docs Documentation game: 0400C3F00006E000 A game profile labels Nov 7, 2026
…: the rumble adapter handles it as a failed engine instead of ending the app

Mario Kart World 1.8.0 on a physical iPhone died after about 85 s of a race with
'CHHapticEngine checkEngineRunning: self.running' uncaught, a few seconds after two focus
changes. Core Haptics raises, not returns an NSError, for a player or parameter call on an engine
that is not running, and the engine stops on its own while its stopped handler is only queued.
apply: and the pulse renewal now run in @Try and handle a caught exception like a failed start
(stop, drop the engine, back off, clear the output, log a [RED] line); t_haptics_apple raises from a
player and ends in the same uncaught exception without the fix.
@oliverames
oliverames force-pushed the haptics-stopped-engine branch from 4cc18cb to ecf3ac0 Compare November 7, 2026 20:20
seedds added a commit that referenced this pull request Nov 9, 2026
…the rumble adapter drops it as a failed engine instead of ending the app (PR #1694)

Mario Kart World 1.8.0 on an iPhone ended after about 85 s of a race on an uncaught
'-[CHHapticEngine(CHHapticEngineInternal) checkEngineRunning:]: self.running', a few seconds after a
focus loss and its return. Core Haptics raises, rather than returning an NSError, for a player call on
an engine that is not running, and has no running state to ask first: the engine stops on its own while
its stopped handler is only queued, and the adapter's own stop is asynchronous. Which call raised is not
known (no backtrace).

Every entry into a rumble channel (apply:, stop, the queued pulse renewal and delayed engine stop) now
runs its Core Haptics calls through guarded:work:. A raise drops the engine and its players without
calling them again (a stop sends a plain player zero, which raises once more), backs off like a failed
start, clears the queued output and logs a rate-limited [RED] line; the next effect makes a new engine.
From PR #1694 (oliverames), which guarded apply: and the pulse renewal and recovered by stopping the
channel: on the plain-player route (Xbox controllers, one engine for both handles) that stop raised again
inside the catch. t_haptics_apple raises on the advanced and the plain-player route, from a level change
and from the idle release; the PR's version ends in an uncaught NSException on the plain-player case.
Each raise leaks what ARC held in the unwound frames (no -fobjc-arc-exceptions; about 300 bytes a raise
in the test), noted in the code.

Checked: t_haptics_apple passes; haptics_apple.m compiles for macOS and iOS arm64 with -Wall -Werror;
test_docs passes. Not checked: a forced engine stop on a device, haptics felt with the change.

Co-authored-by: Oliver Ames <7366664+oliverames@users.noreply.github.com>
@seedds

seedds commented Nov 9, 2026

Copy link
Copy Markdown
Owner

Thanks — landed on main as 7238bfd, with you as co-author. The diagnosis, the raising test fake and the docs are yours.

Two changes on top:

  • The recovery could raise again. stoppedEngine: called [self stop] inside the catch. On the plain-player route (Xbox controllers, one engine for both handles), stop sends zero to both players with sendParameters, which is the call that raises on a stopped engine, so the app still ended. The recovery now drops the engine and its players without calling them; nothing plays from an engine that isn't running.
  • Every entry into a channel is guarded, not only apply: and the pulse renewal. The idle release, the stop queued by the engine's stopped/reset handler, focus loss, reconfiguration with its fallback/merge routes, and the delayed engine stop all call players on the same queue and could lose the same race. They now go through one guarded:work: helper. The [RED] line names the target and is rate-limited like report().

t_haptics_apple gained plain-player cases (a raise from a level change and from the idle release). Your version ends in an uncaught NSException on the first of them. The reference entry now says which call raised in your crash is unknown, since the log has no backtrace. It may also have been the adapter's own asynchronous stop on the focus loss racing the restart on the focus return; the guard covers either. A raise leaks what ARC held in the unwound frames (the host has no -fobjc-arc-exceptions), about 300 bytes a raise, which the code comment notes.

Still untested: a forced engine stop on a device. If you hit the crash again, a log with the new [RED] haptics: … on a stopped engine line would show which stage raised.

@seedds seedds closed this Nov 9, 2026
Know-It-All-Brother pushed a commit to Know-It-All-Brother/nxnative that referenced this pull request Nov 9, 2026
…the rumble adapter drops it as a failed engine instead of ending the app (PR seedds#1694)

Mario Kart World 1.8.0 on an iPhone ended after about 85 s of a race on an uncaught
'-[CHHapticEngine(CHHapticEngineInternal) checkEngineRunning:]: self.running', a few seconds after a
focus loss and its return. Core Haptics raises, rather than returning an NSError, for a player call on
an engine that is not running, and has no running state to ask first: the engine stops on its own while
its stopped handler is only queued, and the adapter's own stop is asynchronous. Which call raised is not
known (no backtrace).

Every entry into a rumble channel (apply:, stop, the queued pulse renewal and delayed engine stop) now
runs its Core Haptics calls through guarded:work:. A raise drops the engine and its players without
calling them again (a stop sends a plain player zero, which raises once more), backs off like a failed
start, clears the queued output and logs a rate-limited [RED] line; the next effect makes a new engine.
From PR seedds#1694 (oliverames), which guarded apply: and the pulse renewal and recovered by stopping the
channel: on the plain-player route (Xbox controllers, one engine for both handles) that stop raised again
inside the catch. t_haptics_apple raises on the advanced and the plain-player route, from a level change
and from the idle release; the PR's version ends in an uncaught NSException on the plain-player case.
Each raise leaks what ARC held in the unwound frames (no -fobjc-arc-exceptions; about 300 bytes a raise
in the test), noted in the code.

Checked: t_haptics_apple passes; haptics_apple.m compiles for macOS and iOS arm64 with -Wall -Werror;
test_docs passes. Not checked: a forced engine stop on a device, haptics felt with the change.

Co-authored-by: Oliver Ames <7366664+oliverames@users.noreply.github.com>
john62036203-ops pushed a commit to john62036203-ops/NXNativeWindows that referenced this pull request Nov 10, 2026
…the rumble adapter drops it as a failed engine instead of ending the app (PR seedds#1694)

Mario Kart World 1.8.0 on an iPhone ended after about 85 s of a race on an uncaught
'-[CHHapticEngine(CHHapticEngineInternal) checkEngineRunning:]: self.running', a few seconds after a
focus loss and its return. Core Haptics raises, rather than returning an NSError, for a player call on
an engine that is not running, and has no running state to ask first: the engine stops on its own while
its stopped handler is only queued, and the adapter's own stop is asynchronous. Which call raised is not
known (no backtrace).

Every entry into a rumble channel (apply:, stop, the queued pulse renewal and delayed engine stop) now
runs its Core Haptics calls through guarded:work:. A raise drops the engine and its players without
calling them again (a stop sends a plain player zero, which raises once more), backs off like a failed
start, clears the queued output and logs a rate-limited [RED] line; the next effect makes a new engine.
From PR seedds#1694 (oliverames), which guarded apply: and the pulse renewal and recovered by stopping the
channel: on the plain-player route (Xbox controllers, one engine for both handles) that stop raised again
inside the catch. t_haptics_apple raises on the advanced and the plain-player route, from a level change
and from the idle release; the PR's version ends in an uncaught NSException on the plain-player case.
Each raise leaks what ARC held in the unwound frames (no -fobjc-arc-exceptions; about 300 bytes a raise
in the test), noted in the code.

Checked: t_haptics_apple passes; haptics_apple.m compiles for macOS and iOS arm64 with -Wall -Werror;
test_docs passes. Not checked: a forced engine stop on a device, haptics felt with the change.

Co-authored-by: Oliver Ames <7366664+oliverames@users.noreply.github.com>
(cherry picked from commit 7238bfd)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation game: 0400C3F00006E000 A game profile host The Horizon host: SVCs, services, threads, memory (dm/host)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants