Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Assets/Tests/Editor/PausePointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,8 @@ public async Task Enable_WhenMarkerCreated_ReturnsStateManagementFields()
Assert.That(response.RemainingMilliseconds, Is.EqualTo(30000));
Assert.That(response.Generation, Is.EqualTo(1));
Assert.That(response.Expired, Is.False);
// An id-only marker has no resolved source line, so no pre-line timing note applies.
Assert.That(response.SnapshotTiming, Is.Empty);
Assert.That(response.EditorState.CapturedAt, Is.EqualTo(UloopPausePointEditorStateCapturedAt.Current));
Assert.That(response.RecommendedNextAction, Is.Empty);
}
Expand Down Expand Up @@ -987,6 +989,7 @@ public async Task Enable_WhenFileAndLineResolveToRealMethod_PatchesAndCapturesVa
Assert.That(response.ResolvedLine, Is.EqualTo(FixtureLine));
Assert.That(response.ResolvedLineText, Is.EqualTo("return sum;"));
Assert.That(response.ResolvedMethod, Does.Contain("Add"));
Assert.That(response.SnapshotTiming, Is.EqualTo(SourcePausePointConstants.PreLineSnapshotTimingNote));

EnableBySourceLocationFixture fixture = new();
int sum = fixture.Add(2, 3);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// FROZEN FIXTURE: content and line numbers are asserted by SourcePausePointPatcherTests.
// Do not reformat or edit this file; add a new fixture file instead.
using System.Runtime.CompilerServices;

namespace io.github.hatayama.UnityCliLoop.Tests.SourcePausePointPatcherFixtures
{
internal static class PatcherAggressiveInliningMethodFixture
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int Classify(int value)
{
int result;
if (value > 100)
{
result = value * 2;
}
else if (value > 10)
{
result = value + 5;
}
else
{
result = value - 1;
}

return result;
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// FROZEN FIXTURE: content and line numbers are asserted by SourcePausePointPatcherTests.
// Do not reformat or edit this file; add a new fixture file instead.
namespace io.github.hatayama.UnityCliLoop.Tests.SourcePausePointPatcherFixtures
{
internal static class PatcherLargeMethodFixture
{
public static int Classify(int value)
{
int result;
if (value > 100)
{
result = value * 2;
}
else if (value > 10)
{
result = value + 5;
}
else
{
result = value - 1;
}

return result;
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,10 @@ public void Patch_PhysicsMessageMethodOnMonoBehaviour_ReturnsCachedDispatchWarni
// Verifies OnCollisionEnter2D on a MonoBehaviour-derived type reports the informational
// warning about Unity's physics message dispatch caching its call path independently of
// this patch (see To-Do 2 investigation: an already-existing GameObject's collision
// callback can miss the pause point even though the method body runs).
// callback can miss the pause point even though the method body runs). This fixture's
// body is also small enough to additionally trigger the inlining-risk warning (see
// Patch_PhysicsMessageMethodOnMonoBehaviour_AlsoTriggersInliningWarning for the exact
// joined string), so this test only pins the physical-callback warning's presence.
const string id = "patcher-physical-callback-method";
SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(
FixturesDirectory + "PatcherPhysicalCallbackMethodFixture.cs", 13);
Expand All @@ -155,14 +158,38 @@ public void Patch_PhysicsMessageMethodOnMonoBehaviour_ReturnsCachedDispatchWarni
SourcePausePointPatchResult patchResult = SourcePausePointPatcher.Patch(id, resolveResult.Resolution);

Assert.That(patchResult.Success, Is.True);
Assert.That(patchResult.Warning, Is.EqualTo(SourcePausePointConstants.PhysicalCallbackMayMissExistingInstanceWarning));
Assert.That(patchResult.Warning, Does.Contain(SourcePausePointConstants.PhysicalCallbackMayMissExistingInstanceWarning));
}

[Test]
public void Patch_PhysicsMessageMethodOnMonoBehaviour_AlsoTriggersInliningWarning()
{
// Verifies that when both the physical-callback warning and the small-body
// inlining-risk warning apply to the same method (OnCollisionEnter2D's body is only
// 16 IL bytes, well under SmallMethodInliningRiskThresholdBytes), BuildPatchWarning
// joins them in the same order as the checks appear in its source (physical-callback
// first, then inlining-risk), space-separated.
const string id = "patcher-physical-callback-method-dual-warning";
SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(
FixturesDirectory + "PatcherPhysicalCallbackMethodFixture.cs", 13);
Assert.That(resolveResult.Success, Is.True);

UloopPausePointRegistry.Enable(id, 30);
SourcePausePointPatchResult patchResult = SourcePausePointPatcher.Patch(id, resolveResult.Resolution);

Assert.That(patchResult.Success, Is.True);
Assert.That(patchResult.Warning, Is.EqualTo(
SourcePausePointConstants.PhysicalCallbackMayMissExistingInstanceWarning + " "
+ SourcePausePointConstants.SmallMethodInliningRiskWarning));
}

[Test]
public void Patch_OrdinaryMessageMethodOnSameMonoBehaviour_DoesNotReturnCachedDispatchWarning()
{
// Verifies Update() on the same MonoBehaviour fixture does not trigger the
// physics-message warning, since only physics message methods carry the caching risk.
// Update()'s body is also small (16 IL bytes), so the inlining-risk warning is still
// present; this test only asserts the physical-callback warning's absence.
const string id = "patcher-ordinary-message-method";
SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(
FixturesDirectory + "PatcherPhysicalCallbackMethodFixture.cs", 18);
Expand All @@ -172,15 +199,17 @@ public void Patch_OrdinaryMessageMethodOnSameMonoBehaviour_DoesNotReturnCachedDi
SourcePausePointPatchResult patchResult = SourcePausePointPatcher.Patch(id, resolveResult.Resolution);

Assert.That(patchResult.Success, Is.True);
Assert.That(patchResult.Warning, Is.Empty);
Assert.That(patchResult.Warning, Does.Not.Contain(SourcePausePointConstants.PhysicalCallbackMayMissExistingInstanceWarning));
}

[Test]
public void Patch_PhysicsNamedMethodOnNonMonoBehaviourType_DoesNotReturnCachedDispatchWarning()
{
// Verifies the warning requires both a matching method name AND a MonoBehaviour-derived
// declaring type, so an unrelated plain class that happens to name a method
// "OnTriggerEnter2D" does not produce a false positive.
// "OnTriggerEnter2D" does not produce a false positive. This fixture's body is also
// small (16 IL bytes), so the inlining-risk warning is still present; this test only
// asserts the physical-callback warning's absence.
const string id = "patcher-physics-named-method-plain-class";
SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(
FixturesDirectory + "PatcherPhysicsNamedMethodOnPlainClassFixture.cs", 9);
Expand All @@ -189,10 +218,80 @@ public void Patch_PhysicsNamedMethodOnNonMonoBehaviourType_DoesNotReturnCachedDi
UloopPausePointRegistry.Enable(id, 30);
SourcePausePointPatchResult patchResult = SourcePausePointPatcher.Patch(id, resolveResult.Resolution);

Assert.That(patchResult.Success, Is.True);
Assert.That(patchResult.Warning, Does.Not.Contain(SourcePausePointConstants.PhysicalCallbackMayMissExistingInstanceWarning));
}

[Test]
public void Patch_SmallMethodBody_ReturnsInliningRiskWarning()
{
// Verifies a method whose IL body is at or under SmallMethodInliningRiskThresholdBytes
// triggers the inlining-risk warning. The precondition assert guards against C# compiler
// version drift silently moving this fixture's IL size to the wrong side of the threshold.
byte[] ilBytes = typeof(PatcherStaticMethodFixture).GetMethod(nameof(PatcherStaticMethodFixture.Add))
.GetMethodBody().GetILAsByteArray();
Assert.That(ilBytes.Length, Is.LessThanOrEqualTo(SourcePausePointConstants.SmallMethodInliningRiskThresholdBytes),
"Fixture precondition failed: PatcherStaticMethodFixture.Add must stay at or under the inlining-risk threshold.");

const string id = "patcher-small-method-body";
SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(
FixturesDirectory + "PatcherStaticMethodFixture.cs", 10);
Assert.That(resolveResult.Success, Is.True);

UloopPausePointRegistry.Enable(id, 30);
SourcePausePointPatchResult patchResult = SourcePausePointPatcher.Patch(id, resolveResult.Resolution);

Assert.That(patchResult.Success, Is.True);
Assert.That(patchResult.Warning, Is.EqualTo(SourcePausePointConstants.SmallMethodInliningRiskWarning));
}

[Test]
public void Patch_LargeMethodBody_DoesNotReturnInliningRiskWarning()
{
// Verifies a method whose IL body clearly exceeds SmallMethodInliningRiskThresholdBytes
// (with a safety margin against compiler version drift, rather than sizing the fixture
// to land just past the boundary) does not trigger the inlining-risk warning.
byte[] ilBytes = typeof(PatcherLargeMethodFixture).GetMethod(nameof(PatcherLargeMethodFixture.Classify))
.GetMethodBody().GetILAsByteArray();
Assert.That(ilBytes.Length, Is.GreaterThan(SourcePausePointConstants.SmallMethodInliningRiskThresholdBytes + 8),
"Fixture precondition failed: PatcherLargeMethodFixture.Classify must clearly exceed the inlining-risk threshold.");

const string id = "patcher-large-method-body";
SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(
FixturesDirectory + "PatcherLargeMethodFixture.cs", 23);
Assert.That(resolveResult.Success, Is.True);

UloopPausePointRegistry.Enable(id, 30);
SourcePausePointPatchResult patchResult = SourcePausePointPatcher.Patch(id, resolveResult.Resolution);

Assert.That(patchResult.Success, Is.True);
Assert.That(patchResult.Warning, Is.Empty);
}

[Test]
public void Patch_AggressiveInliningMethod_ReturnsInliningRiskWarningRegardlessOfSize()
{
// Verifies [MethodImpl(MethodImplOptions.AggressiveInlining)] triggers the inlining-risk
// warning independent of IL body size: this fixture's body is identical in shape to
// PatcherLargeMethodFixture (clearly over the threshold), so the warning here can only
// come from the attribute check, not the size check.
byte[] ilBytes = typeof(PatcherAggressiveInliningMethodFixture).GetMethod(nameof(PatcherAggressiveInliningMethodFixture.Classify))
.GetMethodBody().GetILAsByteArray();
Assert.That(ilBytes.Length, Is.GreaterThan(SourcePausePointConstants.SmallMethodInliningRiskThresholdBytes + 8),
"Fixture precondition failed: PatcherAggressiveInliningMethodFixture.Classify must clearly exceed the inlining-risk threshold so this test isolates the attribute check.");

const string id = "patcher-aggressive-inlining-method";
SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(
FixturesDirectory + "PatcherAggressiveInliningMethodFixture.cs", 26);
Assert.That(resolveResult.Success, Is.True);

UloopPausePointRegistry.Enable(id, 30);
SourcePausePointPatchResult patchResult = SourcePausePointPatcher.Patch(id, resolveResult.Resolution);

Assert.That(patchResult.Success, Is.True);
Assert.That(patchResult.Warning, Is.EqualTo(SourcePausePointConstants.SmallMethodInliningRiskWarning));
}

[Test]
public void Patch_LoopMethod_PreservesBackEdgeBranchTargetAndCapturesFirstIterationState()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public class PausePointResponse : UnityCliLoopToolResponse
public int ResolvedLine { get; set; }
public string ResolvedLineText { get; set; } = string.Empty;
public string ResolvedMethod { get; set; } = string.Empty;
public string SnapshotTiming { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public bool IsEnabled { get; set; }
public bool IsHit { get; set; }
Expand Down Expand Up @@ -358,6 +359,7 @@ private static PausePointResponse EnableBySourceLocation(EnablePausePointSchema
response.ResolvedLine = resolveResult.Resolution.ResolvedLine;
response.ResolvedLineText = ReadResolvedLineText(parameters.File, resolveResult.Resolution.ResolvedLine);
response.ResolvedMethod = resolveResult.Resolution.MethodDisplayName;
response.SnapshotTiming = SourcePausePointConstants.PreLineSnapshotTimingNote;
response.Warning = MergeWarnings(CreateEnableWarning(), patchResult.Warning);
return response;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ internal static class SourcePausePointConstants
public const string HarmonyId = "io.github.hatayama.uloop.source-pause-point";
public const string BurstCompileAttributeFullName = "Unity.Burst.BurstCompileAttribute";

// A heuristic threshold, not a guarantee: Mono's JIT inlining decision depends on far more
// than IL byte count (call-site count, caller size, tiering), so this only flags methods
// small enough that inlining is plausible, to explain a HitCount=0 symptom after the fact.
public const int SmallMethodInliningRiskThresholdBytes = 32;

// The only escape hatch a caller has when a method cannot be patched by file:line: the
// hand-written marker path still works and does not depend on IL patching at all.
public const string ManualMarkerFallbackHint =
Expand Down Expand Up @@ -60,6 +65,18 @@ internal static class SourcePausePointConstants
+ "after enabling this pause point, or embed UloopPausePoint.Pause(\"id\") directly in the "
+ "method body and arm it with enable-pause-point --id instead.";

// Surfaces the same JIT-inlining risk documented under Requirements & Safety in the skill,
// but at enable time instead of only after a confusing HitCount=0 timeout.
public const string SmallMethodInliningRiskWarning =
"The target method body is very small and may be inlined by Mono's JIT into its callers; "
+ "if HitCount stays 0 while the line demonstrably runs, move the pause point into the calling method.";

// Callers have observed captured values that look like they belong to the line after
// ResolvedLine; this makes the pre-line snapshot timing explicit in the response itself
// instead of leaving it documented only in the skill.
public const string PreLineSnapshotTimingNote =
"pre-line: variables are captured before ResolvedLine executes";

// Release code optimization strips most sequence points and hoists/elides locals, so the
// Resolver's PDB-driven lookup cannot reliably find a patch location; rejecting up front
// avoids patching the wrong instruction instead of failing later in a confusing way.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,11 @@ private static string BuildPatchWarning(MethodBase method)
warnings.Add(SourcePausePointConstants.PhysicalCallbackMayMissExistingInstanceWarning);
}

if (IsLikelyJitInlined(method))
{
warnings.Add(SourcePausePointConstants.SmallMethodInliningRiskWarning);
}

return string.Join(" ", warnings);
}

Expand All @@ -282,6 +287,24 @@ private static bool IsByRefLikeType(Type type)
return false;
}

// Heuristic, not a guarantee: [AggressiveInlining] is only a hint the JIT may ignore, and
// IL body size alone cannot predict Mono's actual inlining decision (call-site count, caller
// size, and tiering all matter too). Both false positives (flagged but never inlined) and
// false negatives (inlined despite exceeding the threshold) are possible; this exists solely
// to explain a HitCount=0 symptom, not to predict it precisely.
// Harmony detours the JIT-compiled native code and never rewrites the metadata IL, so
// measuring after Patch still reads the original method body size.
private static bool IsLikelyJitInlined(MethodBase method)
{
if ((method.GetMethodImplementationFlags() & MethodImplAttributes.AggressiveInlining) != 0)
{
return true;
}

byte[] ilBytes = method.GetMethodBody()?.GetILAsByteArray();
return ilBytes != null && ilBytes.Length <= SourcePausePointConstants.SmallMethodInliningRiskThresholdBytes;
}

private static IEnumerable<CodeInstruction> Transpiler(
IEnumerable<CodeInstruction> instructions, ILGenerator generator, MethodBase original)
{
Expand Down
Loading