Skip to content

FIX: Re-inject player loop hook after user-driven loop reset (UUM-140343)#2419

Draft
LeoUnity wants to merge 4 commits into
developfrom
fix/uum-140343-playerloop-reset
Draft

FIX: Re-inject player loop hook after user-driven loop reset (UUM-140343)#2419
LeoUnity wants to merge 4 commits into
developfrom
fix/uum-140343-playerloop-reset

Conversation

@LeoUnity
Copy link
Copy Markdown
Collaborator

Summary

Fixes UUM-140343PlayerLoop.SetPlayerLoop(PlayerLoop.GetDefaultPlayerLoop()) called from user code mid-play wipes the InputSystem's editor-only InputSystemPlayerLoopRunnerInitializationSystem, causing input state to freeze in FixedUpdate and other reads that occur between editor updates.

Root cause

The InputSystem injects an editor-only subsystem into PlayerLoop.Initialization to drive OnPlayerLoopInitialization, which switches input state buffers back to player mode after an editor update. PR #1424 introduced this as a self-described hot-fix (see comment at NativeInputRuntime.cs:147-148). When the user resets the loop to Unity's default, that injection is dropped — the default loop only contains Unity's native subsystems — and the buffer-switch never fires again.

Fix

Subscribe a watchdog to EditorApplication.update that re-injects the subsystem if it's missing. EditorApplication.update fires from SceneTracker::Update() (Editor/Src/Selection/SceneInspector.cpp:742), independently of the player loop, so a user SetPlayerLoop cannot wipe it. The watchdog is gated on EditorApplication.isPlaying:

  • Edit mode: zero work (early return).
  • Play mode: one PlayerLoopSystem-array scan per editor tick. Common case (injection present) returns immediately.

The original OnPlayerLoopInitialization mechanism is left untouched — the watchdog only re-establishes the injection when needed.

Why not other approaches

Investigated and ruled out (Unity source-confirmed):

  • Add to default player loop: no managed-side API. s_defaultLoop is built from C++ PLAYER_LOOP_INJECT(...) macros (Runtime/Misc/PlayerLoop.cpp:270-292).
  • Hook SetPlayerLoop event: no callback fires (PlayerLoop.cpp:368-389 — buffer swap, no events).
  • Move restore into OnUpdate: tried (commit 12a5921f7), preserved in branch history. Works but breaks the contract that manual InputSystem.Update(Editor) callers observe s_LatestUpdateType == Editor immediately after; required a m_ManualUpdateDepth counter to gate the restore. Reverted in favor of this approach.

Test plan

  • New [UnityTest] Integration_InputUpdatesContinue_AfterResettingPlayerLoopToDefault reproduces the bug pre-fix and passes post-fix.
  • Devices_CanHandleFocusChanges (parametrized variants) still pass — no manual-update contract changes.
  • Full Unity.InputSystem.IntegrationTests assembly via Yamato.
  • Editor functional tests via Yamato (Windows + macOS + Linux).
  • Manual walkthrough of the Jira repro scripts (LMB-in-FixedUpdate logger and RMB freeze script).

Branch history

The branch keeps Option B (OnUpdate-based restore) committed and reverted (12a5921f7 + 209331a5b) so reviewers can compare both approaches.

🤖 Generated with Claude Code

LeoUnity and others added 4 commits May 11, 2026 12:19
Adds a [UnityTest] integration test that resets the player loop via
PlayerLoop.SetPlayerLoop(PlayerLoop.GetDefaultPlayerLoop()) and asserts
that mouse button state is still observable in FixedUpdate.

Currently fails on develop, reproducing the user-reported bug: input
state freezes in FixedUpdate after a user-initiated player-loop reset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pdate

Fixes UUM-140343 with Option B from the design spec: relocate the
post-editor-update buffer restore (previously in OnPlayerLoopInitialization,
fired from an InputSystemPlayerLoopRunnerInitializationSystem we inject at
PlayerLoop.Initialization) into the InputManager.OnUpdate exit paths.
PlayerLoop.SetPlayerLoop(GetDefaultPlayerLoop()) wipes that injection;
NativeInputSystem.onUpdate (which drives OnUpdate) is fired by Unity's
built-in player-loop subsystems and survives a user-driven loop reset.

The restore is wired through three OnUpdate exit paths:
- FinalizeUpdate (normal exit)
- The eventCount==0 early-return (UNITY_INPUTSYSTEM_SUPPORTS_FOCUS_EVENTS)
- LegacyEarlyOutFromEventProcessing (older Unity versions)

A m_ManualUpdateDepth counter suppresses the auto-restore for manual
InputSystem.Update() calls, preserving the contract observed by tests
like Devices_CanHandleFocusChanges that read state immediately after a
manual editor update.

This commit preserves Option B in history. A subsequent commit replaces
this with Option 2 (EditorApplication.update watchdog scoped to play mode),
which is a smaller and cleaner fix once the no-default-loop-extension
constraint is understood from Unity source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes UUM-140343. When user code calls
  PlayerLoop.SetPlayerLoop(PlayerLoop.GetDefaultPlayerLoop())
mid-play, the InputSystem's editor-only InputSystemPlayerLoopRunnerInitializationSystem
is wiped from PlayerLoop.Initialization. Without it OnPlayerLoopInitialization
never fires, so InputStateBuffers are never switched back to player mode after an
editor update -- FixedUpdate (and other reads between editor updates) see stale
editor-buffer state.

Subscribe a watchdog to EditorApplication.update that re-injects when missing.
EditorApplication.update is invoked by SceneTracker (Editor/Src/Selection/SceneInspector.cpp)
and is not part of the player loop, so a user-initiated SetPlayerLoop call
cannot wipe it. The watchdog is gated on EditorApplication.isPlaying so it does
nothing in edit mode; while playing the cost is one PlayerLoopSystem-array scan
per editor tick, returning immediately when the injection is still in place
(the common case).

Unity exposes no callback for SetPlayerLoop, no engine API to add to the default
player loop, and no per-frame hook inside the player loop that could not be
wiped the same way; the watchdog is the cleanest workaround until the engine
gains one of those.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@codecov-github-com
Copy link
Copy Markdown

codecov-github-com Bot commented May 12, 2026

Codecov Report

Attention: Patch coverage is 91.37931% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...tsystem/Tests/IntegrationTests/IntegrationTests.cs 88.23% 4 Missing ⚠️
...utsystem/InputSystem/Runtime/NativeInputRuntime.cs 95.83% 1 Missing ⚠️
@@             Coverage Diff             @@
##           develop    #2419      +/-   ##
===========================================
+ Coverage    78.13%   78.93%   +0.80%     
===========================================
  Files          483      761     +278     
  Lines        98770   139018   +40248     
===========================================
+ Hits         77169   109732   +32563     
- Misses       21601    29286    +7685     
Flag Coverage Δ
inputsystem_MacOS_6000.0_project 77.27% <82.60%> (-0.03%) ⬇️
inputsystem_MacOS_6000.6_project 77.32% <82.60%> (+<0.01%) ⬆️
inputsystem_Windows_6000.0_project 77.41% <93.47%> (-0.02%) ⬇️
inputsystem_Windows_6000.6_project 77.45% <93.47%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...utsystem/InputSystem/Runtime/NativeInputRuntime.cs 68.30% <95.83%> (ø)
...tsystem/Tests/IntegrationTests/IntegrationTests.cs 61.53% <88.23%> (+6.85%) ⬆️

... and 191 files with indirect coverage changes

ℹ️ Need help interpreting these results?

EnsurePlayerLoopInjection();

// Watchdog for UUM-140343: user code calling
// PlayerLoop.SetPlayerLoop(GetDefaultPlayerLoop()) wipes our injection,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this only affecting the editor hook? We do a number of player loop hooks currently here: https://github.cds.internal.unity3d.com/unity/unity/blob/trunk/Modules/Input/Private/InputModuleRegistration.cpp#L26-L39

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We discussed this offline, but adding here just for context.
GetDefaultPlayerLoop only contains systems registered in Native, the problem is that we have one(?) system today that is registered in managed, so if users SetPlayerLoop using GetDefaultPlayerLoop, those managed registered systems gets removed.
If we were to somehow move all (one? InputSystemPlayerLoopRunnerInitializationSystem) systems registrations to native it would solve the problem.

// Watchdog tick. Fires from EditorApplication.update (independent of the player loop) and
// re-injects only when actually needed. In edit mode we don't care; OnPlayerLoopInitialization
// is gated on gameIsPlaying anyway, so a wiped injection while not playing is harmless.
private void EnsurePlayerLoopInjectionIfPlaying()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a lot of work to do on every tick and currently also triggering LINQ it seems - another idea - let me know what you think, do an active watchdog check instead, e.g. on each editor tick where the player hook is actually called we store the current timestamp realTImeSinceStartup, then this tick could just check e.g. WasPlayerloopHookCalledLastXXX or similar. That would imply that we never run anything heavier than a single if-statement for the happy case and the loop injection just triggers after XXX passes without an editor cycle executed?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Completely agree, I don't think this PR should move forward, it was just to illustrate the issue and a (bad) fix. I don't think we should invest on a watchdog at all, unless there is something I am missing, we should just aim to make GetDefaultPlayerLoop to contain all the hooks we need always.

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.

2 participants