From 1c38be7292d3b1e37cfaa24c32b9c082075f33c7 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 24 Aug 2026 03:14:15 +0900 Subject: [PATCH 1/3] Remap a failed --method/--line pause point onto the unique compiled span match When enable-pause-point is given both --method and --line and the compiled PDB has no sequence point on or after that line, testers already saw candidate compiled lines and retried by hand. Scan every line in the named method's compiled span and, if the edited line's text matches exactly once, re-resolve there and disclose the remap on the success warning. Zero or many matches, a missing --method, or a failed re-resolve leave the existing failure unchanged. Co-authored-by: Cursor --- .../Editor/PausePointEditedLineRemapTests.cs | 284 ++++++++++++++++++ .../PausePointEditedLineRemapTests.cs.meta | 11 + .../Fixtures/EditedLineRemapFixture.cs | 40 +++ .../Fixtures/EditedLineRemapFixture.cs.meta | 11 + .../SourcePausePointResolverTests.cs | 32 ++ .../PausePointCompiledSourceReader.cs | 46 +++ .../PausePointCompiledSourceReader.cs.meta | 11 + .../PausePoint/PausePointEditedLineRemap.cs | 143 +++++++++ .../PausePointEditedLineRemap.cs.meta | 11 + .../PausePoint/PausePointEnableWarnings.cs | 15 + .../PausePoint/PausePointUseCase.cs | 27 +- .../SourcePausePointCompiledMethodSpan.cs | 21 ++ ...SourcePausePointCompiledMethodSpan.cs.meta | 11 + .../PausePoint/SourcePausePointConstants.cs | 4 + .../PausePoint/SourcePausePointResolver.cs | 64 +++- 15 files changed, 713 insertions(+), 18 deletions(-) create mode 100644 Assets/Tests/Editor/PausePointEditedLineRemapTests.cs create mode 100644 Assets/Tests/Editor/PausePointEditedLineRemapTests.cs.meta create mode 100644 Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs create mode 100644 Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs create mode 100644 Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs create mode 100644 Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledMethodSpan.cs create mode 100644 Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledMethodSpan.cs.meta diff --git a/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs b/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs new file mode 100644 index 000000000..102e71ec8 --- /dev/null +++ b/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.Runtime; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies edited-line remap onto a named method's compiled span, including the UseCase route. + /// + [TestFixture] + public sealed class PausePointEditedLineRemapTests + { + private const string RemapFixtureFile = + "Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs"; + private const int UniqueTargetStatementLine = 10; + private const int UniqueOtherStatementLine = 16; + private const int DuplicateOtherStatementLine = 30; + private const int ZeroMatchOtherStatementLine = 36; + + private const string ExpectedRemapWarning = + "--line 16 did not resolve in method 'UniqueTarget' against the last compiled source; the edited line's text was found at line 10 inside that method's compiled span, so the marker was placed there. Verify ResolvedLocation, or run 'uloop compile' and re-enable to use edited-file line numbers."; + + [SetUp] + public void SetUp() + { + UloopPausePointRegistry.ConfigureForTests(new FakePausePointPauseController(), () => DateTime.UtcNow); + } + + [TearDown] + public void TearDown() + { + SourcePausePointPatcher.UnpatchAll(); + UloopPausePointRegistry.ResetForTests(); + } + + /// + /// What: a single trimmed match inside the named method span remaps to that compiled line. + /// + [Test] + public void FindUniqueMatchingCompiledLine_WhenOneMatchInSpan_ReturnsThatLine() + { + IReadOnlyList compiledSourceLines = new[] + { + "void Target()", + " int uniqueRemapProbe = value + 1;", + " return uniqueRemapProbe;", + "}", + " int uniqueRemapProbe = value + 1;" + }; + IReadOnlyList spans = new[] + { + new SourcePausePointCompiledMethodSpan(1, 4) + }; + + int remapped = PausePointEditedLineRemap.FindUniqueMatchingCompiledLineOrZero( + "Target", + " int uniqueRemapProbe = value + 1;", + compiledSourceLines, + spans); + + Assert.That(remapped, Is.EqualTo(2)); + } + + /// + /// What: a match that exists only outside the named method span does not remap. + /// + [Test] + public void FindUniqueMatchingCompiledLine_WhenMatchIsOutsideSpan_ReturnsZero() + { + IReadOnlyList compiledSourceLines = new[] + { + "void Target()", + " return value;", + "}", + " int uniqueRemapProbe = value + 1;" + }; + IReadOnlyList spans = new[] + { + new SourcePausePointCompiledMethodSpan(1, 3) + }; + + int remapped = PausePointEditedLineRemap.FindUniqueMatchingCompiledLineOrZero( + "Target", + "int uniqueRemapProbe = value + 1;", + compiledSourceLines, + spans); + + Assert.That(remapped, Is.EqualTo(0)); + } + + /// + /// What: two matches inside the named method span do not remap. + /// + [Test] + public void FindUniqueMatchingCompiledLine_WhenMultipleMatchesInSpan_ReturnsZero() + { + IReadOnlyList compiledSourceLines = new[] + { + "void Target()", + " _ = 12345;", + " int skip = 0;", + " _ = 12345;", + "}" + }; + IReadOnlyList spans = new[] + { + new SourcePausePointCompiledMethodSpan(1, 5) + }; + + int remapped = PausePointEditedLineRemap.FindUniqueMatchingCompiledLineOrZero( + "Target", + "_ = 12345;", + compiledSourceLines, + spans); + + Assert.That(remapped, Is.EqualTo(0)); + } + + /// + /// What: remap is skipped when --method is omitted even if the span has one match. + /// + [Test] + public void FindUniqueMatchingCompiledLine_WhenMethodFilterIsEmpty_ReturnsZero() + { + IReadOnlyList compiledSourceLines = new[] + { + " int uniqueRemapProbe = value + 1;" + }; + IReadOnlyList spans = new[] + { + new SourcePausePointCompiledMethodSpan(1, 1) + }; + + int remapped = PausePointEditedLineRemap.FindUniqueMatchingCompiledLineOrZero( + string.Empty, + "int uniqueRemapProbe = value + 1;", + compiledSourceLines, + spans); + + Assert.That(remapped, Is.EqualTo(0)); + } + + /// + /// What: the remap warning is the planned fixed literal. + /// + [Test] + public void BuildEditedLineRemapWarning_UsesFixedLiteral() + { + string warning = PausePointEnableWarnings.BuildEditedLineRemapWarning(16, "UniqueTarget", 10); + + Assert.That(warning, Is.EqualTo(ExpectedRemapWarning)); + } + + /// + /// What: UseCase resolve failure remaps onto the unique compiled span line and patches there. + /// + [Test] + public void Enable_WhenEditedLineMatchesOnceInNamedMethodSpan_RemapsAndPatches() + { + SourcePausePointResolveResult expected = SourcePausePointResolver.Resolve( + RemapFixtureFile, + UniqueTargetStatementLine, + "UniqueTarget"); + Assert.That(expected.Success, Is.True, expected.ErrorMessage); + + PausePointResponse response = new PausePointUseCase().Enable(new EnablePausePointSchema + { + File = RemapFixtureFile, + Line = UniqueOtherStatementLine, + Method = "UniqueTarget", + TimeoutSeconds = 30, + Mode = UloopPausePointCaptureMode.SingleShot + }); + + Assert.That(response.Success, Is.True, response.ErrorCode + " / " + response.Message); + Assert.That(response.ResolvedLine, Is.EqualTo(UniqueTargetStatementLine)); + Assert.That(response.ResolvedLineText, Is.EqualTo("int uniqueRemapProbe = value + 1;")); + Assert.That(response.ResolvedMethod, Is.EqualTo(expected.Resolution.MethodDisplayName)); + Assert.That( + response.Id, + Is.EqualTo(RemapFixtureFile + ":" + UniqueOtherStatementLine)); + Assert.That( + response.SnapshotTiming, + Is.EqualTo(SourcePausePointConstants.PreLineSnapshotTimingNote)); + string expectedWarning = PausePointEnableWarnings.MergeWarnings( + PausePointEnableWarnings.MergeWarnings( + PausePointEnableWarnings.CreateEnableWarning(), + ExpectedRemapWarning), + SourcePausePointConstants.SmallMethodInliningRiskWarning); + Assert.That(response.Warning, Is.EqualTo(expectedWarning)); + } + + /// + /// What: zero matches inside the named method span leave the existing resolve failure unchanged. + /// + [Test] + public void Enable_WhenEditedLineDoesNotMatchNamedMethodSpan_KeepsResolveFailure() + { + SourcePausePointResolveResult failed = SourcePausePointResolver.Resolve( + RemapFixtureFile, + ZeroMatchOtherStatementLine, + "UniqueTarget"); + Assert.That(failed.Success, Is.False, failed.ErrorMessage); + + PausePointResponse response = new PausePointUseCase().Enable(new EnablePausePointSchema + { + File = RemapFixtureFile, + Line = ZeroMatchOtherStatementLine, + Method = "UniqueTarget", + TimeoutSeconds = 30, + Mode = UloopPausePointCaptureMode.SingleShot + }); + + Assert.That(response.Success, Is.False); + Assert.That(response.ErrorCode, Is.EqualTo(SourcePausePointConstants.ErrorCodeResolveFailed)); + string expectedMessage = PausePointEnableWarnings.BuildResolveFailureMessage( + failed.ErrorMessage, + failed.NearbyCompiledMethods, + hasActiveHotReloadPatches: false, + ZeroMatchOtherStatementLine, + requestedLineReadOk: false, + requestedLineEditedText: string.Empty, + compiledSourceLinesOrNull: null); + Assert.That(response.Message, Is.EqualTo(expectedMessage)); + Assert.That(response.ResolvedLine, Is.EqualTo(0)); + Assert.That(response.ResolvedMethod, Is.EqualTo(string.Empty)); + } + + /// + /// What: multiple matches inside the named method span leave the existing resolve failure unchanged. + /// + [Test] + public void Enable_WhenEditedLineMatchesTwiceInNamedMethodSpan_KeepsResolveFailure() + { + SourcePausePointResolveResult failed = SourcePausePointResolver.Resolve( + RemapFixtureFile, + DuplicateOtherStatementLine, + "DuplicateTarget"); + Assert.That(failed.Success, Is.False, failed.ErrorMessage); + + PausePointResponse response = new PausePointUseCase().Enable(new EnablePausePointSchema + { + File = RemapFixtureFile, + Line = DuplicateOtherStatementLine, + Method = "DuplicateTarget", + TimeoutSeconds = 30, + Mode = UloopPausePointCaptureMode.SingleShot + }); + + Assert.That(response.Success, Is.False); + Assert.That(response.ErrorCode, Is.EqualTo(SourcePausePointConstants.ErrorCodeResolveFailed)); + string expectedMessage = PausePointEnableWarnings.BuildResolveFailureMessage( + failed.ErrorMessage, + failed.NearbyCompiledMethods, + hasActiveHotReloadPatches: false, + DuplicateOtherStatementLine, + requestedLineReadOk: false, + requestedLineEditedText: string.Empty, + compiledSourceLinesOrNull: null); + Assert.That(response.Message, Is.EqualTo(expectedMessage)); + } + + private sealed class FakePausePointPauseController : IUloopPausePointPauseController + { + public int PauseCount { get; private set; } + public bool IsPlaying => true; + public bool IsPaused => PauseCount > 0; + + public void Pause() + { + PauseCount++; + } + + public void Resume() + { + PauseCount = 0; + } + } + } +} diff --git a/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs.meta b/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs.meta new file mode 100644 index 000000000..074193e1e --- /dev/null +++ b/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bbbb27937cd284c2cb47b56b5e6f8bc1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs new file mode 100644 index 000000000..4da66bf7d --- /dev/null +++ b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs @@ -0,0 +1,40 @@ +// FROZEN FIXTURE: content and line numbers are asserted by PausePointEditedLineRemapTests +// and SourcePausePointResolverTests. +// Do not reformat or edit this file; add a new fixture file instead. +namespace io.github.hatayama.UnityCliLoop.Tests.SourcePausePointResolverFixtures +{ + internal sealed class EditedLineRemapFixture + { + public int UniqueTarget(int value) + { + int uniqueRemapProbe = value + 1; + return uniqueRemapProbe; + } + + public int UniqueOther(int value) + { + int uniqueRemapProbe = value + 1; + return uniqueRemapProbe; + } + + public int DuplicateTarget(int value) + { + _ = 12345; + int skip = 0; + _ = 12345; + return skip; + } + + public int DuplicateOther(int value) + { + _ = 12345; + return value; + } + + public int ZeroMatchOther(int value) + { + int zeroMatchOnlyHere = value; + return zeroMatchOnlyHere; + } + } +} diff --git a/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs.meta b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs.meta new file mode 100644 index 000000000..6cbfa3a80 --- /dev/null +++ b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6da72c4c915344fefa04892102fa0d81 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/SourcePausePointResolver/SourcePausePointResolverTests.cs b/Assets/Tests/Editor/SourcePausePointResolver/SourcePausePointResolverTests.cs index 39b833f42..8d45e6176 100644 --- a/Assets/Tests/Editor/SourcePausePointResolver/SourcePausePointResolverTests.cs +++ b/Assets/Tests/Editor/SourcePausePointResolver/SourcePausePointResolverTests.cs @@ -316,6 +316,38 @@ public void Resolve_WhenMethodFilterHasNoSequencePointOnOrAfterLine_FailsWithNam Is.EqualTo(otherMethod.Resolution.CompiledMethodEndLine)); } + /// + /// What: FindCompiledMethodSpans returns the named method's compiled span, not a neighbor. + /// + [Test] + public void FindCompiledMethodSpans_WhenMethodFilterMatches_ReturnsThatMethodSpan() + { + string file = FixturesDirectory + "CompiledMethodSpanFixture.cs"; + SourcePausePointResolveResult expected = SourcePausePointResolver.Resolve(file, 9, "Target"); + Assert.That(expected.Success, Is.True, expected.ErrorMessage); + + IReadOnlyList spans = + SourcePausePointResolver.FindCompiledMethodSpans(file, "Target"); + + Assert.That(spans.Count, Is.EqualTo(1)); + Assert.That(spans[0].StartLine, Is.EqualTo(expected.Resolution.CompiledMethodStartLine)); + Assert.That(spans[0].EndLine, Is.EqualTo(expected.Resolution.CompiledMethodEndLine)); + } + + /// + /// What: FindCompiledMethodSpans with no --method returns no spans. + /// + [Test] + public void FindCompiledMethodSpans_WhenMethodFilterIsEmpty_ReturnsNoSpans() + { + string file = FixturesDirectory + "CompiledMethodSpanFixture.cs"; + + IReadOnlyList spans = + SourcePausePointResolver.FindCompiledMethodSpans(file, string.Empty); + + Assert.That(spans, Is.Empty); + } + /// /// What: an empty method filter accepts every compiled method name. /// diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs new file mode 100644 index 000000000..d98dd9f1e --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs @@ -0,0 +1,46 @@ +using System.IO; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Loads last-compiled or on-disk source text for pause-point line comparison and remap. + /// + internal static class PausePointCompiledSourceReader + { + internal static string LoadSnapshotOrEmpty(string requestedFile) + { + string normalizedFile = SourcePausePointPathNormalizer.ToForwardSlashes(requestedFile); + string snapshotSource = + HotReloadPausePointCoordination.GetVerifiedSnapshotSourceForFile?.Invoke(normalizedFile); + return snapshotSource ?? string.Empty; + } + + // Why disk only after snapshot miss: a verified hot-reload snapshot is the last compiled + // source; without one the on-disk file is the only text the span scan can read, and + // re-resolve still fail-opens if those line numbers no longer match the PDB. + internal static string LoadSnapshotOrDiskOrEmpty(string requestedFile) + { + string snapshotSource = LoadSnapshotOrEmpty(requestedFile); + if (!string.IsNullOrEmpty(snapshotSource)) + { + return snapshotSource; + } + + if (string.IsNullOrEmpty(requestedFile)) + { + return string.Empty; + } + + string normalizedFile = SourcePausePointPathNormalizer.ToForwardSlashes(requestedFile); + string absoluteFilePath = Path.Combine(UnityCliLoopPathResolver.GetProjectRoot(), normalizedFile); + if (!File.Exists(absoluteFilePath)) + { + return string.Empty; + } + + return File.ReadAllText(absoluteFilePath); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs.meta b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs.meta new file mode 100644 index 000000000..52717ab2b --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e024249aebfd84757b173bd0c5c4e96a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs new file mode 100644 index 000000000..0cfef51c2 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Remaps a failed --method/--line resolve onto the unique matching line inside that + /// method's compiled span, or leaves the original failure unchanged. + /// + internal static class PausePointEditedLineRemap + { + internal static (SourcePausePointResolveResult resolveResult, string remapWarning) + ResolveWithEditedLineRemap(string file, int line, string method) + { + SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(file, line, method); + if (resolveResult.Success) + { + return (resolveResult, string.Empty); + } + + return TryRemapAfterResolveFailure(resolveResult, file, line, method); + } + + internal static (SourcePausePointResolveResult resolveResult, string remapWarning) + TryRemapAfterResolveFailure( + SourcePausePointResolveResult failedResult, + string file, + int line, + string method) + { + Debug.Assert(failedResult != null, "failedResult must not be null."); + Debug.Assert(!failedResult.Success, "TryRemapAfterResolveFailure requires a failed resolve."); + + if (string.IsNullOrEmpty(method) || line <= 0 || string.IsNullOrEmpty(file)) + { + return (failedResult, string.Empty); + } + + IReadOnlyList spans = + SourcePausePointResolver.FindCompiledMethodSpans(file, method); + if (spans.Count == 0) + { + return (failedResult, string.Empty); + } + + (bool readOk, string editedLineText) = + PausePointCompiledLineComparisonWarnings.ReadEditedLineText(file, line); + if (!readOk) + { + return (failedResult, string.Empty); + } + + string[] compiledSourceLines = SourcePausePointSourceLineReader.SplitSourceLines( + PausePointCompiledSourceReader.LoadSnapshotOrDiskOrEmpty(file)); + int remappedLine = FindUniqueMatchingCompiledLineOrZero( + method, + editedLineText, + compiledSourceLines, + spans); + if (remappedLine <= 0) + { + return (failedResult, string.Empty); + } + + SourcePausePointResolveResult retry = SourcePausePointResolver.Resolve(file, remappedLine, method); + if (!retry.Success) + { + return (failedResult, string.Empty); + } + + return ( + retry, + PausePointEnableWarnings.BuildEditedLineRemapWarning(line, method, remappedLine)); + } + + // Why every span line: file-wide candidate search stops at three hits and cannot prove + // uniqueness; a match outside the named method's compiled span must not count. + internal static int FindUniqueMatchingCompiledLineOrZero( + string methodFilter, + string editedLineText, + IReadOnlyList compiledSourceLines, + IReadOnlyList spans) + { + if (string.IsNullOrEmpty(methodFilter) + || string.IsNullOrEmpty(editedLineText) + || compiledSourceLines == null + || spans == null) + { + return 0; + } + + string editedTrimmed = editedLineText.Trim(); + if (editedTrimmed.Length == 0) + { + return 0; + } + + int matchingLine = 0; + int matchCount = 0; + for (int spanIndex = 0; spanIndex < spans.Count; spanIndex++) + { + SourcePausePointCompiledMethodSpan span = spans[spanIndex]; + if (span == null) + { + continue; + } + + for (int compiledLine = span.StartLine; compiledLine <= span.EndLine; compiledLine++) + { + if (compiledLine > compiledSourceLines.Count) + { + continue; + } + + string compiledText = compiledSourceLines[compiledLine - 1]; + if (compiledText == null) + { + continue; + } + + if (!string.Equals(compiledText.Trim(), editedTrimmed, StringComparison.Ordinal)) + { + continue; + } + + if (matchingLine != compiledLine) + { + matchCount++; + matchingLine = compiledLine; + } + } + } + + if (matchCount != 1) + { + return 0; + } + + return matchingLine; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs.meta b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs.meta new file mode 100644 index 000000000..eee20d31a --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bbfa30397a5384a678d63a25ca7d2f75 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEnableWarnings.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEnableWarnings.cs index 4afd9ed0a..a28e4485a 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEnableWarnings.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEnableWarnings.cs @@ -476,6 +476,21 @@ internal static string BuildCompiledLineMapResolveFailureWarningOrEmpty( SourcePausePointPathNormalizer.ToForwardSlashes(file)); } + internal static string BuildEditedLineRemapWarning( + int originalLine, + string methodName, + int remappedLine) + { + Debug.Assert(originalLine > 0, "originalLine must be a positive 1-based line number."); + Debug.Assert(!string.IsNullOrEmpty(methodName), "methodName must not be empty."); + Debug.Assert(remappedLine > 0, "remappedLine must be a positive 1-based line number."); + return string.Format( + SourcePausePointConstants.EditedLineRemapWarningFormat, + originalLine, + methodName, + remappedLine); + } + internal static string CreateEnableWarning() { if (EditorApplication.isPlaying) diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs index 10f39b3dc..916b9ec84 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs @@ -268,8 +268,9 @@ private static PausePointResponse EnableBySourceLocation(EnablePausePointSchema // map; only the warning text differs. } - SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve( - parameters.File, parameters.Line, parameters.Method); + (SourcePausePointResolveResult resolveResult, string editedLineRemapWarning) = + PausePointEditedLineRemap.ResolveWithEditedLineRemap( + parameters.File, parameters.Line, parameters.Method); if (!resolveResult.Success) { bool hasActiveHotReloadPatches = shimLookup != null; @@ -286,7 +287,7 @@ private static PausePointResponse EnableBySourceLocation(EnablePausePointSchema // path, so those reads would change the historical no-patch failure for no gain. if (hasActiveHotReloadPatches) { - string compiledSnapshotSource = LoadCompiledSnapshotSourceOrEmpty(parameters.File); + string compiledSnapshotSource = PausePointCompiledSourceReader.LoadSnapshotOrEmpty(parameters.File); compiledSourceLinesOrNull = string.IsNullOrEmpty(compiledSnapshotSource) ? null : SourcePausePointSourceLineReader.SplitSourceLines(compiledSnapshotSource); @@ -349,7 +350,8 @@ private static PausePointResponse EnableBySourceLocation(EnablePausePointSchema hasActiveHotReloadPatches: shimLookup != null, compiledMethodStartLine: resolveResult.Resolution.CompiledMethodStartLine, compiledMethodEndLine: resolveResult.Resolution.CompiledMethodEndLine, - patchedMethodPdbUnavailableWarning: patchedMethodPdbUnavailableWarning); + patchedMethodPdbUnavailableWarning: patchedMethodPdbUnavailableWarning, + editedLineRemapWarning: editedLineRemapWarning); } private static PausePointResponse FinishEnableBySourceLocation( @@ -365,7 +367,8 @@ private static PausePointResponse FinishEnableBySourceLocation( int editedMethodEndLine = 0, int compiledMethodStartLine = 0, int compiledMethodEndLine = 0, - string patchedMethodPdbUnavailableWarning = "") + string patchedMethodPdbUnavailableWarning = "", + string editedLineRemapWarning = "") { string rearmWarning = PausePointEnableWarnings.BuildRearmDiscardWarningOrEmpty( UloopPausePointRegistry.GetStatus(id)); @@ -384,7 +387,7 @@ private static PausePointResponse FinishEnableBySourceLocation( bool compareCompiledLineDrift = hasActiveHotReloadPatches && !retargetedToHotReloadPatch; string compiledSnapshotSource = compareCompiledLineDrift - ? LoadCompiledSnapshotSourceOrEmpty(parameters.File) + ? PausePointCompiledSourceReader.LoadSnapshotOrEmpty(parameters.File) : string.Empty; // Why snapshot over disk: the editor file may already include unpatched-line drift, so // reading disk at the compiled ResolvedLine shows the wrong statement (FB9 empty/mismatch). @@ -402,7 +405,9 @@ private static PausePointResponse FinishEnableBySourceLocation( response.ResolvedLineText = resolvedLineText; response.ResolvedMethod = resolvedMethod; response.SnapshotTiming = SourcePausePointConstants.PreLineSnapshotTimingNote; - string enableWarning = PausePointEnableWarnings.CreateEnableWarning(); + string enableWarning = PausePointEnableWarnings.MergeWarnings( + PausePointEnableWarnings.CreateEnableWarning(), + editedLineRemapWarning); enableWarning = PausePointEnableWarnings.MergeWarnings( enableWarning, PausePointEnableWarnings.BuildRetargetedToHotReloadPatchWarningOrEmpty( @@ -531,14 +536,6 @@ private static void LogPhysicsDispatchDiagnostics(string operation, string id, T }); } - private static string LoadCompiledSnapshotSourceOrEmpty(string requestedFile) - { - string normalizedFile = SourcePausePointPathNormalizer.ToForwardSlashes(requestedFile); - string snapshotSource = - HotReloadPausePointCoordination.GetVerifiedSnapshotSourceForFile?.Invoke(normalizedFile); - return snapshotSource ?? string.Empty; - } - // The derived id must use the originally requested file/line (not the resolved/rounded // line) so repeated calls at the same requested location stay idempotent. private static string BuildSourcePausePointId(string file, int line) diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledMethodSpan.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledMethodSpan.cs new file mode 100644 index 000000000..dc6d76718 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledMethodSpan.cs @@ -0,0 +1,21 @@ +using System.Diagnostics; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Inclusive compiled-source line range of one method that matches a --method filter. + /// + internal sealed class SourcePausePointCompiledMethodSpan + { + public int StartLine { get; } + public int EndLine { get; } + + public SourcePausePointCompiledMethodSpan(int startLine, int endLine) + { + Debug.Assert(startLine > 0, "startLine must be a positive 1-based line number."); + Debug.Assert(endLine >= startLine, "endLine must be on or after startLine."); + StartLine = startLine; + EndLine = endLine; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledMethodSpan.cs.meta b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledMethodSpan.cs.meta new file mode 100644 index 000000000..2a10e9879 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledMethodSpan.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d13319e661d0d44dcbbcb59e8189e342 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs index 45c7321c0..c9202e950 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs @@ -390,5 +390,9 @@ internal static class SourcePausePointConstants // Format: resolved line, resolved method display name. public const string ClosingBraceResolvedLineWarningFormat = "--line resolved to the method's closing brace at line {0}. Every return path through {1} reaches this line, including early returns, so captured variables can reflect a different path than the one you meant. To observe one specific path, target a statement line inside that path."; + + // Format: original --line, --method name, remapped compiled line. + public const string EditedLineRemapWarningFormat = + "--line {0} did not resolve in method '{1}' against the last compiled source; the edited line's text was found at line {2} inside that method's compiled span, so the marker was placed there. Verify ResolvedLocation, or run 'uloop compile' and re-enable to use edited-file line numbers."; } } diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointResolver.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointResolver.cs index c7ced427a..fd1994d01 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointResolver.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointResolver.cs @@ -294,6 +294,50 @@ private static (int startLine, int endLine) CollectCompiledMethodSpan( return (startLine, endLine); } + // Why a dedicated API: FindNearbyCompiledMethods does not take methodFilter and cannot + // promise the named method's compiled span. + internal static IReadOnlyList FindCompiledMethodSpans( + string projectRelativeFilePath, + string methodFilter) + { + Debug.Assert(!string.IsNullOrEmpty(projectRelativeFilePath), "projectRelativeFilePath must not be null or empty."); + if (string.IsNullOrEmpty(methodFilter)) + { + return Array.Empty(); + } + + return WithCompiledModuleOrDefault( + projectRelativeFilePath, + (module, normalizedInputPath) => + CollectCompiledMethodSpans(module, normalizedInputPath, methodFilter), + Array.Empty()); + } + + private static IReadOnlyList CollectCompiledMethodSpans( + ModuleDefinition module, + string normalizedInputPath, + string methodFilter) + { + List spans = new List(); + foreach (MethodDefinition method in EnumerateMethodsInModule(module)) + { + if (!method.HasBody || !CompiledMethodMatchesFilter(methodFilter, method)) + { + continue; + } + + (int startLine, int endLine) = CollectCompiledMethodSpan(method, normalizedInputPath); + if (startLine <= 0 || endLine <= 0) + { + continue; + } + + spans.Add(new SourcePausePointCompiledMethodSpan(startLine, endLine)); + } + + return spans; + } + // Why a file:line entry: the resolver test assembly cannot take a Cecil // ModuleDefinition dependency, but TakeAtMostTwo only runs on this walk. internal static IReadOnlyList FindNearbyCompiledMethodsInFile( @@ -303,11 +347,25 @@ internal static IReadOnlyList FindNearbyCo Debug.Assert(!string.IsNullOrEmpty(projectRelativeFilePath), "projectRelativeFilePath must not be null or empty."); Debug.Assert(line > 0, "line must be a positive 1-based line number."); + return WithCompiledModuleOrDefault( + projectRelativeFilePath, + (module, normalizedInputPath) => + FindNearbyCompiledMethods(module, normalizedInputPath, line), + Array.Empty()); + } + + private static TResult WithCompiledModuleOrDefault( + string projectRelativeFilePath, + Func read, + TResult fallback) + { + Debug.Assert(read != null, "read must not be null."); + string normalizedInputPath = SourcePausePointPathNormalizer.ToForwardSlashes(projectRelativeFilePath); string rawAssemblyName = CompilationPipeline.GetAssemblyNameFromScriptPath(normalizedInputPath); if (string.IsNullOrEmpty(rawAssemblyName)) { - return Array.Empty(); + return fallback; } string assemblyName = Path.GetFileNameWithoutExtension(rawAssemblyName); @@ -322,7 +380,7 @@ internal static IReadOnlyList FindNearbyCo assemblyName + SourcePausePointConstants.DebugSymbolsExtension); if (!File.Exists(dllPath) || !File.Exists(pdbPath)) { - return Array.Empty(); + return fallback; } using FileStream dllStream = File.Open(dllPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); @@ -336,7 +394,7 @@ internal static IReadOnlyList FindNearbyCo }; using AssemblyDefinition assemblyDefinition = AssemblyDefinition.ReadAssembly(dllStream, readerParameters); - return FindNearbyCompiledMethods(assemblyDefinition.MainModule, normalizedInputPath, line); + return read(assemblyDefinition.MainModule, normalizedInputPath); } // Why a separate walk from FindClosestSequencePointOnOrAfterLine: that search only From 129dcca8ca897f3a7e53ae673bb0f42e3ca8910a Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 24 Aug 2026 03:22:20 +0900 Subject: [PATCH 2/3] Keep a remapped pause point only when re-resolve lands on that exact line Resolve rounds comments and continuation lines forward, so accepting any successful retry would put the marker on a later statement while the warning still named the unique text match. Fail open unless the retry pins that line. Co-authored-by: Cursor --- .../Editor/PausePointEditedLineRemapTests.cs | 43 +++++++++++++++++++ .../EditedLineRemapRoundForwardFixture.cs | 20 +++++++++ ...EditedLineRemapRoundForwardFixture.cs.meta | 11 +++++ .../PausePoint/PausePointEditedLineRemap.cs | 4 +- 4 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs create mode 100644 Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs.meta diff --git a/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs b/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs index 102e71ec8..167baeda2 100644 --- a/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs +++ b/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs @@ -16,10 +16,13 @@ public sealed class PausePointEditedLineRemapTests { private const string RemapFixtureFile = "Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs"; + private const string RoundForwardFixtureFile = + "Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs"; private const int UniqueTargetStatementLine = 10; private const int UniqueOtherStatementLine = 16; private const int DuplicateOtherStatementLine = 30; private const int ZeroMatchOtherStatementLine = 36; + private const int CommentOtherCommentLine = 16; private const string ExpectedRemapWarning = "--line 16 did not resolve in method 'UniqueTarget' against the last compiled source; the edited line's text was found at line 10 inside that method's compiled span, so the marker was placed there. Verify ResolvedLocation, or run 'uloop compile' and re-enable to use edited-file line numbers."; @@ -264,6 +267,46 @@ public void Enable_WhenEditedLineMatchesTwiceInNamedMethodSpan_KeepsResolveFailu Assert.That(response.Message, Is.EqualTo(expectedMessage)); } + /// + /// What: a unique span match that only rounds forward on re-resolve keeps the original failure. + /// + [Test] + public void Enable_WhenRemappedLineRoundsForward_KeepsResolveFailure() + { + SourcePausePointResolveResult failed = SourcePausePointResolver.Resolve( + RoundForwardFixtureFile, + CommentOtherCommentLine, + "CommentTarget"); + Assert.That(failed.Success, Is.False, failed.ErrorMessage); + SourcePausePointResolveResult rounded = SourcePausePointResolver.Resolve( + RoundForwardFixtureFile, + 9, + "CommentTarget"); + Assert.That(rounded.Success, Is.True, rounded.ErrorMessage); + Assert.That(rounded.Resolution.ResolvedLine, Is.Not.EqualTo(9)); + + PausePointResponse response = new PausePointUseCase().Enable(new EnablePausePointSchema + { + File = RoundForwardFixtureFile, + Line = CommentOtherCommentLine, + Method = "CommentTarget", + TimeoutSeconds = 30, + Mode = UloopPausePointCaptureMode.SingleShot + }); + + Assert.That(response.Success, Is.False); + Assert.That(response.ErrorCode, Is.EqualTo(SourcePausePointConstants.ErrorCodeResolveFailed)); + string expectedMessage = PausePointEnableWarnings.BuildResolveFailureMessage( + failed.ErrorMessage, + failed.NearbyCompiledMethods, + hasActiveHotReloadPatches: false, + CommentOtherCommentLine, + requestedLineReadOk: false, + requestedLineEditedText: string.Empty, + compiledSourceLinesOrNull: null); + Assert.That(response.Message, Is.EqualTo(expectedMessage)); + } + private sealed class FakePausePointPauseController : IUloopPausePointPauseController { public int PauseCount { get; private set; } diff --git a/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs new file mode 100644 index 000000000..37cc7ac21 --- /dev/null +++ b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs @@ -0,0 +1,20 @@ +// FROZEN FIXTURE: content and line numbers are asserted by PausePointEditedLineRemapTests. +// Do not reformat or edit this file; add a new fixture file instead. +namespace io.github.hatayama.UnityCliLoop.Tests.SourcePausePointResolverFixtures +{ + internal sealed class EditedLineRemapRoundForwardFixture + { + public int CommentTarget(int value) + { + // uniqueRemapCommentProbe + int x = value; + return x; + } + + public int CommentOther(int value) + { + // uniqueRemapCommentProbe + return value; + } + } +} diff --git a/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs.meta b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs.meta new file mode 100644 index 000000000..b2c2c4ebe --- /dev/null +++ b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 19ff076ff2d1d4b81aa60ff977c675d7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs index 0cfef51c2..06afa21f8 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs @@ -64,7 +64,9 @@ internal static (SourcePausePointResolveResult resolveResult, string remapWarnin } SourcePausePointResolveResult retry = SourcePausePointResolver.Resolve(file, remappedLine, method); - if (!retry.Success) + // Why exact line: Resolve rounds a comment or continuation forward, and the + // remap warning claims the marker was placed at remappedLine. + if (!retry.Success || retry.Resolution.ResolvedLine != remappedLine) { return (failedResult, string.Empty); } From adc4bd5915dee72c86a41d43fd8b48b68dc17295 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 24 Aug 2026 03:46:17 +0900 Subject: [PATCH 3/3] Remap edited --line only against a verified compiled snapshot Scanning the on-disk file after an uncompiled edit can unique-match a later statement onto an old PDB line and still pass the exact-line pin. Count every span hit instead of collapsing a shared line, and lock UseCase contracts to fixed response literals so wording drift fails the tests. Co-authored-by: Cursor --- .../Editor/PausePointEditedLineRemapTests.cs | 153 +++++++++++------- .../PausePointCompiledSourceReader.cs | 30 +--- .../PausePoint/PausePointEditedLineRemap.cs | 20 ++- 3 files changed, 107 insertions(+), 96 deletions(-) diff --git a/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs b/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs index 167baeda2..b5ce36bd8 100644 --- a/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs +++ b/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; +using System.IO; using NUnit.Framework; using io.github.hatayama.UnityCliLoop.FirstPartyTools; using io.github.hatayama.UnityCliLoop.Runtime; +using io.github.hatayama.UnityCliLoop.ToolContracts; namespace io.github.hatayama.UnityCliLoop.Tests.Editor { @@ -24,18 +26,41 @@ public sealed class PausePointEditedLineRemapTests private const int ZeroMatchOtherStatementLine = 36; private const int CommentOtherCommentLine = 16; + private const string ExpectedUniqueTargetResolvedMethod = + "System.Int32 io.github.hatayama.UnityCliLoop.Tests.SourcePausePointResolverFixtures.EditedLineRemapFixture::UniqueTarget(System.Int32)"; + private const string ExpectedRemapWarning = "--line 16 did not resolve in method 'UniqueTarget' against the last compiled source; the edited line's text was found at line 10 inside that method's compiled span, so the marker was placed there. Verify ResolvedLocation, or run 'uloop compile' and re-enable to use edited-file line numbers."; + private const string ExpectedSuccessWarning = + "--line 16 did not resolve in method 'UniqueTarget' against the last compiled source; the edited line's text was found at line 10 inside that method's compiled span, so the marker was placed there. Verify ResolvedLocation, or run 'uloop compile' and re-enable to use edited-file line numbers. 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."; + + private const string ExpectedZeroMatchFailureMessage = + "No method named 'UniqueTarget' with a sequence point on or after line 36 was found. Nearby methods in the last compiled source: 'EditedLineRemapFixture.ZeroMatchOther' spans lines 35-38."; + + private const string ExpectedDuplicateMatchFailureMessage = + "No method named 'DuplicateTarget' with a sequence point on or after line 30 was found. Nearby methods in the last compiled source: 'EditedLineRemapFixture.DuplicateOther' spans lines 29-32."; + + private const string ExpectedRoundForwardFailureMessage = + "No method named 'CommentTarget' with a sequence point on or after line 16 was found. Nearby methods in the last compiled source: 'EditedLineRemapRoundForwardFixture.CommentOther' spans lines 15-18."; + + private const string ExpectedNoSnapshotFailureMessage = + "No method named 'UniqueTarget' with a sequence point on or after line 16 was found. Nearby methods in the last compiled source: 'EditedLineRemapFixture.UniqueOther' spans lines 15-18."; + + private Func _previousSnapshotLoader; + [SetUp] public void SetUp() { UloopPausePointRegistry.ConfigureForTests(new FakePausePointPauseController(), () => DateTime.UtcNow); + _previousSnapshotLoader = HotReloadPausePointCoordination.GetVerifiedSnapshotSourceForFile; + HotReloadPausePointCoordination.GetVerifiedSnapshotSourceForFile = null; } [TearDown] public void TearDown() { + HotReloadPausePointCoordination.GetVerifiedSnapshotSourceForFile = _previousSnapshotLoader; SourcePausePointPatcher.UnpatchAll(); UloopPausePointRegistry.ResetForTests(); } @@ -123,6 +148,34 @@ public void FindUniqueMatchingCompiledLine_WhenMultipleMatchesInSpan_ReturnsZero Assert.That(remapped, Is.EqualTo(0)); } + /// + /// What: overlapping spans that share one matching line count as two hits and do not remap. + /// + [Test] + public void FindUniqueMatchingCompiledLine_WhenOverlappingSpansShareTheMatch_ReturnsZero() + { + IReadOnlyList compiledSourceLines = new[] + { + "void Foo()", + " int sharedRemapProbe = 1;", + " return sharedRemapProbe;", + "}" + }; + IReadOnlyList spans = new[] + { + new SourcePausePointCompiledMethodSpan(1, 4), + new SourcePausePointCompiledMethodSpan(2, 4) + }; + + int remapped = PausePointEditedLineRemap.FindUniqueMatchingCompiledLineOrZero( + "Foo", + "int sharedRemapProbe = 1;", + compiledSourceLines, + spans); + + Assert.That(remapped, Is.EqualTo(0)); + } + /// /// What: remap is skipped when --method is omitted even if the span has one match. /// @@ -164,11 +217,7 @@ public void BuildEditedLineRemapWarning_UsesFixedLiteral() [Test] public void Enable_WhenEditedLineMatchesOnceInNamedMethodSpan_RemapsAndPatches() { - SourcePausePointResolveResult expected = SourcePausePointResolver.Resolve( - RemapFixtureFile, - UniqueTargetStatementLine, - "UniqueTarget"); - Assert.That(expected.Success, Is.True, expected.ErrorMessage); + InstallSnapshotFromFile(RemapFixtureFile); PausePointResponse response = new PausePointUseCase().Enable(new EnablePausePointSchema { @@ -182,19 +231,36 @@ public void Enable_WhenEditedLineMatchesOnceInNamedMethodSpan_RemapsAndPatches() Assert.That(response.Success, Is.True, response.ErrorCode + " / " + response.Message); Assert.That(response.ResolvedLine, Is.EqualTo(UniqueTargetStatementLine)); Assert.That(response.ResolvedLineText, Is.EqualTo("int uniqueRemapProbe = value + 1;")); - Assert.That(response.ResolvedMethod, Is.EqualTo(expected.Resolution.MethodDisplayName)); + Assert.That(response.ResolvedMethod, Is.EqualTo(ExpectedUniqueTargetResolvedMethod)); Assert.That( response.Id, Is.EqualTo(RemapFixtureFile + ":" + UniqueOtherStatementLine)); Assert.That( response.SnapshotTiming, Is.EqualTo(SourcePausePointConstants.PreLineSnapshotTimingNote)); - string expectedWarning = PausePointEnableWarnings.MergeWarnings( - PausePointEnableWarnings.MergeWarnings( - PausePointEnableWarnings.CreateEnableWarning(), - ExpectedRemapWarning), - SourcePausePointConstants.SmallMethodInliningRiskWarning); - Assert.That(response.Warning, Is.EqualTo(expectedWarning)); + Assert.That(response.Warning, Is.EqualTo(ExpectedSuccessWarning)); + } + + /// + /// What: without a verified compiled snapshot the UseCase keeps the existing resolve failure. + /// + [Test] + public void Enable_WhenVerifiedSnapshotIsMissing_KeepsResolveFailure() + { + PausePointResponse response = new PausePointUseCase().Enable(new EnablePausePointSchema + { + File = RemapFixtureFile, + Line = UniqueOtherStatementLine, + Method = "UniqueTarget", + TimeoutSeconds = 30, + Mode = UloopPausePointCaptureMode.SingleShot + }); + + Assert.That(response.Success, Is.False); + Assert.That(response.ErrorCode, Is.EqualTo(SourcePausePointConstants.ErrorCodeResolveFailed)); + Assert.That(response.Message, Is.EqualTo(ExpectedNoSnapshotFailureMessage)); + Assert.That(response.ResolvedLine, Is.EqualTo(0)); + Assert.That(response.ResolvedMethod, Is.EqualTo(string.Empty)); } /// @@ -203,11 +269,7 @@ public void Enable_WhenEditedLineMatchesOnceInNamedMethodSpan_RemapsAndPatches() [Test] public void Enable_WhenEditedLineDoesNotMatchNamedMethodSpan_KeepsResolveFailure() { - SourcePausePointResolveResult failed = SourcePausePointResolver.Resolve( - RemapFixtureFile, - ZeroMatchOtherStatementLine, - "UniqueTarget"); - Assert.That(failed.Success, Is.False, failed.ErrorMessage); + InstallSnapshotFromFile(RemapFixtureFile); PausePointResponse response = new PausePointUseCase().Enable(new EnablePausePointSchema { @@ -220,15 +282,7 @@ public void Enable_WhenEditedLineDoesNotMatchNamedMethodSpan_KeepsResolveFailure Assert.That(response.Success, Is.False); Assert.That(response.ErrorCode, Is.EqualTo(SourcePausePointConstants.ErrorCodeResolveFailed)); - string expectedMessage = PausePointEnableWarnings.BuildResolveFailureMessage( - failed.ErrorMessage, - failed.NearbyCompiledMethods, - hasActiveHotReloadPatches: false, - ZeroMatchOtherStatementLine, - requestedLineReadOk: false, - requestedLineEditedText: string.Empty, - compiledSourceLinesOrNull: null); - Assert.That(response.Message, Is.EqualTo(expectedMessage)); + Assert.That(response.Message, Is.EqualTo(ExpectedZeroMatchFailureMessage)); Assert.That(response.ResolvedLine, Is.EqualTo(0)); Assert.That(response.ResolvedMethod, Is.EqualTo(string.Empty)); } @@ -239,11 +293,7 @@ public void Enable_WhenEditedLineDoesNotMatchNamedMethodSpan_KeepsResolveFailure [Test] public void Enable_WhenEditedLineMatchesTwiceInNamedMethodSpan_KeepsResolveFailure() { - SourcePausePointResolveResult failed = SourcePausePointResolver.Resolve( - RemapFixtureFile, - DuplicateOtherStatementLine, - "DuplicateTarget"); - Assert.That(failed.Success, Is.False, failed.ErrorMessage); + InstallSnapshotFromFile(RemapFixtureFile); PausePointResponse response = new PausePointUseCase().Enable(new EnablePausePointSchema { @@ -256,15 +306,7 @@ public void Enable_WhenEditedLineMatchesTwiceInNamedMethodSpan_KeepsResolveFailu Assert.That(response.Success, Is.False); Assert.That(response.ErrorCode, Is.EqualTo(SourcePausePointConstants.ErrorCodeResolveFailed)); - string expectedMessage = PausePointEnableWarnings.BuildResolveFailureMessage( - failed.ErrorMessage, - failed.NearbyCompiledMethods, - hasActiveHotReloadPatches: false, - DuplicateOtherStatementLine, - requestedLineReadOk: false, - requestedLineEditedText: string.Empty, - compiledSourceLinesOrNull: null); - Assert.That(response.Message, Is.EqualTo(expectedMessage)); + Assert.That(response.Message, Is.EqualTo(ExpectedDuplicateMatchFailureMessage)); } /// @@ -273,17 +315,7 @@ public void Enable_WhenEditedLineMatchesTwiceInNamedMethodSpan_KeepsResolveFailu [Test] public void Enable_WhenRemappedLineRoundsForward_KeepsResolveFailure() { - SourcePausePointResolveResult failed = SourcePausePointResolver.Resolve( - RoundForwardFixtureFile, - CommentOtherCommentLine, - "CommentTarget"); - Assert.That(failed.Success, Is.False, failed.ErrorMessage); - SourcePausePointResolveResult rounded = SourcePausePointResolver.Resolve( - RoundForwardFixtureFile, - 9, - "CommentTarget"); - Assert.That(rounded.Success, Is.True, rounded.ErrorMessage); - Assert.That(rounded.Resolution.ResolvedLine, Is.Not.EqualTo(9)); + InstallSnapshotFromFile(RoundForwardFixtureFile); PausePointResponse response = new PausePointUseCase().Enable(new EnablePausePointSchema { @@ -296,15 +328,16 @@ public void Enable_WhenRemappedLineRoundsForward_KeepsResolveFailure() Assert.That(response.Success, Is.False); Assert.That(response.ErrorCode, Is.EqualTo(SourcePausePointConstants.ErrorCodeResolveFailed)); - string expectedMessage = PausePointEnableWarnings.BuildResolveFailureMessage( - failed.ErrorMessage, - failed.NearbyCompiledMethods, - hasActiveHotReloadPatches: false, - CommentOtherCommentLine, - requestedLineReadOk: false, - requestedLineEditedText: string.Empty, - compiledSourceLinesOrNull: null); - Assert.That(response.Message, Is.EqualTo(expectedMessage)); + Assert.That(response.Message, Is.EqualTo(ExpectedRoundForwardFailureMessage)); + } + + private static void InstallSnapshotFromFile(string projectRelativeFile) + { + string absoluteFilePath = Path.Combine( + UnityCliLoopPathResolver.GetProjectRoot(), + projectRelativeFile); + string snapshotSource = File.ReadAllText(absoluteFilePath); + HotReloadPausePointCoordination.GetVerifiedSnapshotSourceForFile = _ => snapshotSource; } private sealed class FakePausePointPauseController : IUloopPausePointPauseController diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs index d98dd9f1e..fd85254bf 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs @@ -1,11 +1,9 @@ -using System.IO; - using io.github.hatayama.UnityCliLoop.ToolContracts; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Loads last-compiled or on-disk source text for pause-point line comparison and remap. + /// Loads last-compiled snapshot source text for pause-point line comparison and remap. /// internal static class PausePointCompiledSourceReader { @@ -16,31 +14,5 @@ internal static string LoadSnapshotOrEmpty(string requestedFile) HotReloadPausePointCoordination.GetVerifiedSnapshotSourceForFile?.Invoke(normalizedFile); return snapshotSource ?? string.Empty; } - - // Why disk only after snapshot miss: a verified hot-reload snapshot is the last compiled - // source; without one the on-disk file is the only text the span scan can read, and - // re-resolve still fail-opens if those line numbers no longer match the PDB. - internal static string LoadSnapshotOrDiskOrEmpty(string requestedFile) - { - string snapshotSource = LoadSnapshotOrEmpty(requestedFile); - if (!string.IsNullOrEmpty(snapshotSource)) - { - return snapshotSource; - } - - if (string.IsNullOrEmpty(requestedFile)) - { - return string.Empty; - } - - string normalizedFile = SourcePausePointPathNormalizer.ToForwardSlashes(requestedFile); - string absoluteFilePath = Path.Combine(UnityCliLoopPathResolver.GetProjectRoot(), normalizedFile); - if (!File.Exists(absoluteFilePath)) - { - return string.Empty; - } - - return File.ReadAllText(absoluteFilePath); - } } } diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs index 06afa21f8..ed732eba6 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs @@ -37,6 +37,15 @@ internal static (SourcePausePointResolveResult resolveResult, string remapWarnin return (failedResult, string.Empty); } + // Why snapshot-only: the on-disk file can already include uncompiled edits, so + // scanning it against the last PDB span can unique-match a later statement onto + // an old sequence-point line and still pass the exact-line pin. + string compiledSnapshotSource = PausePointCompiledSourceReader.LoadSnapshotOrEmpty(file); + if (string.IsNullOrEmpty(compiledSnapshotSource)) + { + return (failedResult, string.Empty); + } + IReadOnlyList spans = SourcePausePointResolver.FindCompiledMethodSpans(file, method); if (spans.Count == 0) @@ -51,8 +60,8 @@ internal static (SourcePausePointResolveResult resolveResult, string remapWarnin return (failedResult, string.Empty); } - string[] compiledSourceLines = SourcePausePointSourceLineReader.SplitSourceLines( - PausePointCompiledSourceReader.LoadSnapshotOrDiskOrEmpty(file)); + string[] compiledSourceLines = + SourcePausePointSourceLineReader.SplitSourceLines(compiledSnapshotSource); int remappedLine = FindUniqueMatchingCompiledLineOrZero( method, editedLineText, @@ -126,11 +135,8 @@ internal static int FindUniqueMatchingCompiledLineOrZero( continue; } - if (matchingLine != compiledLine) - { - matchCount++; - matchingLine = compiledLine; - } + matchCount++; + matchingLine = compiledLine; } }