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
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Read this before interpreting unexpected, missing, or truncated captured values,
## Scopes and the `this` Entry

- `Scope` is `Local`, `Parameter`, `InstanceField`, or `This`. `InstanceField` entries come from a reflection walk of the paused instance's declared type, not from the method's IL usage, so a field the method never reads can still appear — and `MaxCapturedVariableCount` still caps the total entry count across all scopes, so a field-heavy type can push some instance fields out of the snapshot. If a specific field you want is missing, read it directly from the live instance instead of waiting on the capped snapshot: while still paused, `UloopPausePoint.TryGetCapturedValue("this")` returns the live `this` reference, so `execute-dynamic-code` can read any field or property off it regardless of the cap.
- A `ref`, `out`, or `in` parameter, a pointer, and a `ref struct` value (`Span<T>` and any user-defined `ref struct`) can never be captured, because the snapshot boxes every value and none of these shapes can be boxed. The enable and `pause-point-status` responses list each such parameter with its reason in `NotCapturableVariables` (absent when there are none), so a missing name is explained rather than looking like a capture bug; to observe one of those values, copy the value it refers to into a plain local (dereference a pointer, `ToArray()` a span), or arm the line that consumes it with `--snapshot-timing post-line`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: --snapshot-timing post-line does not expose the excluded parameter itself, so arming its consuming line cannot generally observe a ref, pointer, or span value. Tell users to assign a boxable copy to a plain local and capture that local; reserve post-line for statements that produce such a local.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .agents/skills/uloop-pause-point/references/captured-variables.md, line 21:

<comment>`--snapshot-timing post-line` does not expose the excluded parameter itself, so arming its consuming line cannot generally observe a `ref`, pointer, or span value. Tell users to assign a boxable copy to a plain local and capture that local; reserve post-line for statements that produce such a local.</comment>

<file context>
@@ -18,7 +18,7 @@ Read this before interpreting unexpected, missing, or truncated captured values,
 
 - `Scope` is `Local`, `Parameter`, `InstanceField`, or `This`. `InstanceField` entries come from a reflection walk of the paused instance's declared type, not from the method's IL usage, so a field the method never reads can still appear — and `MaxCapturedVariableCount` still caps the total entry count across all scopes, so a field-heavy type can push some instance fields out of the snapshot. If a specific field you want is missing, read it directly from the live instance instead of waiting on the capped snapshot: while still paused, `UloopPausePoint.TryGetCapturedValue("this")` returns the live `this` reference, so `execute-dynamic-code` can read any field or property off it regardless of the cap.
-- A `ref`, `out`, or `in` parameter, a pointer, and a `ref struct` value (`Span<T>` and any user-defined `ref struct`) can never be captured, because the snapshot boxes every value and none of these shapes can be boxed. The enable and `pause-point-status` responses list each such parameter with its reason in `NotCapturableVariables` (absent when there are none), so a missing name is explained rather than looking like a capture bug; to observe one of those values, copy it into a local, or arm the line that consumes it with `--snapshot-timing post-line`.
+- A `ref`, `out`, or `in` parameter, a pointer, and a `ref struct` value (`Span<T>` and any user-defined `ref struct`) can never be captured, because the snapshot boxes every value and none of these shapes can be boxed. The enable and `pause-point-status` responses list each such parameter with its reason in `NotCapturableVariables` (absent when there are none), so a missing name is explained rather than looking like a capture bug; to observe one of those values, copy the value it refers to into a plain local (dereference a pointer, `ToArray()` a span), or arm the line that consumes it with `--snapshot-timing post-line`.
 - The snapshot also includes a synthetic `this` entry (Scope `This`) for the paused instance itself, so you can tell which instance or GameObject was hit via its `UnityObjectPath` and `UnityObjectInstanceId`. For an async or coroutine method it resolves to the original outer instance, not the compiler-generated state machine, and static methods emit no `this` entry. While Unity is still paused, `UloopPausePoint.TryGetCapturedValue("this")` returns the live instance reference (for example so a watch expression can read `transform.position`).
 - async and coroutine methods work: hoisted locals and the original `this` fields appear under their normal names.
</file context>
Suggested change
- A `ref`, `out`, or `in` parameter, a pointer, and a `ref struct` value (`Span<T>` and any user-defined `ref struct`) can never be captured, because the snapshot boxes every value and none of these shapes can be boxed. The enable and `pause-point-status` responses list each such parameter with its reason in `NotCapturableVariables` (absent when there are none), so a missing name is explained rather than looking like a capture bug; to observe one of those values, copy the value it refers to into a plain local (dereference a pointer, `ToArray()` a span), or arm the line that consumes it with `--snapshot-timing post-line`.
- A `ref`, `out`, or `in` parameter, a pointer, and a `ref struct` value (`Span<T>` and any user-defined `ref struct`) can never be captured, because the snapshot boxes every value and none of these shapes can be boxed. The enable and `pause-point-status` responses list each such parameter with its reason in `NotCapturableVariables` (absent when there are none), so a missing name is explained rather than looking like a capture bug; to observe one of those values, assign a boxable copy to a plain local (dereference a pointer, `ToArray()` a span) and capture that local; `--snapshot-timing post-line` only helps when the consuming statement produces such a local.

- The snapshot also includes a synthetic `this` entry (Scope `This`) for the paused instance itself, so you can tell which instance or GameObject was hit via its `UnityObjectPath` and `UnityObjectInstanceId`. For an async or coroutine method it resolves to the original outer instance, not the compiler-generated state machine, and static methods emit no `this` entry. While Unity is still paused, `UloopPausePoint.TryGetCapturedValue("this")` returns the live instance reference (for example so a watch expression can read `transform.position`).
- async and coroutine methods work: hoisted locals and the original `this` fields appear under their normal names.
- Auto-implemented properties are captured as instance fields under the property name (the compiler-generated backing field is un-mangled), so you do not need to rewrite them as explicit fields for verification.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Read this before interpreting unexpected, missing, or truncated captured values,
## Scopes and the `this` Entry

- `Scope` is `Local`, `Parameter`, `InstanceField`, or `This`. `InstanceField` entries come from a reflection walk of the paused instance's declared type, not from the method's IL usage, so a field the method never reads can still appear — and `MaxCapturedVariableCount` still caps the total entry count across all scopes, so a field-heavy type can push some instance fields out of the snapshot. If a specific field you want is missing, read it directly from the live instance instead of waiting on the capped snapshot: while still paused, `UloopPausePoint.TryGetCapturedValue("this")` returns the live `this` reference, so `execute-dynamic-code` can read any field or property off it regardless of the cap.
- A `ref`, `out`, or `in` parameter, a pointer, and a `ref struct` value (`Span<T>` and any user-defined `ref struct`) can never be captured, because the snapshot boxes every value and none of these shapes can be boxed. The enable and `pause-point-status` responses list each such parameter with its reason in `NotCapturableVariables` (absent when there are none), so a missing name is explained rather than looking like a capture bug; to observe one of those values, copy the value it refers to into a plain local (dereference a pointer, `ToArray()` a span), or arm the line that consumes it with `--snapshot-timing post-line`.
- The snapshot also includes a synthetic `this` entry (Scope `This`) for the paused instance itself, so you can tell which instance or GameObject was hit via its `UnityObjectPath` and `UnityObjectInstanceId`. For an async or coroutine method it resolves to the original outer instance, not the compiler-generated state machine, and static methods emit no `this` entry. While Unity is still paused, `UloopPausePoint.TryGetCapturedValue("this")` returns the live instance reference (for example so a watch expression can read `transform.position`).
- async and coroutine methods work: hoisted locals and the original `this` fields appear under their normal names.
- Auto-implemented properties are captured as instance fields under the property name (the compiler-generated backing field is un-mangled), so you do not need to rewrite them as explicit fields for verification.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -904,7 +904,8 @@ private static SourcePausePointResolution BuildSyntheticResolution(
compiledMethodStartLine,
compiledMethodEndLine,
Array.Empty<SourcePausePointLocalVariable>(),
Array.Empty<SourcePausePointParameter>());
Array.Empty<SourcePausePointParameter>(),
Array.Empty<string>());
}

private static string BuildEditedComputePlusHundred(string onDisk)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ private static UloopPausePointSnapshot CreateExpiredSnapshot(
false,
Array.Empty<string>(),
0,
Array.Empty<string>(),
string.Empty,
string.Empty,
false,
Expand Down
233 changes: 233 additions & 0 deletions Assets/Tests/Editor/PausePointNotCapturableVariablesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
using System;
using System.Collections.Generic;

using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;

using io.github.hatayama.UnityCliLoop.FirstPartyTools;
using io.github.hatayama.UnityCliLoop.Infrastructure;
using io.github.hatayama.UnityCliLoop.Runtime;
using io.github.hatayama.UnityCliLoop.ToolContracts;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
/// <summary>
/// Verifies the enable-time warning and the registry-to-response path for parameters that
/// capture cannot box.
/// </summary>
[TestFixture]
public sealed class PausePointNotCapturableVariablesTests
{
private const string ByRefEntry = "accumulator (ref/out/in parameter cannot be boxed)";
private const string RefStructEntry = "scratch (ref struct cannot be boxed)";

private const string ExpectedWarningForTwoEntries =
"Parameters not captured because they cannot be boxed: "
+ "accumulator (ref/out/in parameter cannot be boxed), scratch (ref struct cannot be boxed). "
+ "Copy the value it refers to into a plain local (dereference a pointer, ToArray() a span), "
+ "or use --snapshot-timing post-line on the line that consumes it.";

/// <summary>
/// What: a non-empty list produces the warning naming every entry with its reason.
/// </summary>
[Test]
public void BuildNotCapturableParametersWarningOrEmpty_WithEntries_NamesThemAndTheWorkaround()
{
string warning = PausePointNotCapturableWarnings.BuildNotCapturableParametersWarningOrEmpty(
new[] { ByRefEntry, RefStructEntry });

Assert.That(warning, Is.EqualTo(ExpectedWarningForTwoEntries));
}

/// <summary>
/// What: an empty list produces no warning, so a fully capturable method stays quiet.
/// </summary>
[Test]
public void BuildNotCapturableParametersWarningOrEmpty_WithEmptyList_ReturnsEmpty()
{
string warning = PausePointNotCapturableWarnings.BuildNotCapturableParametersWarningOrEmpty(
Array.Empty<string>());

Assert.That(warning, Is.Empty);
}

/// <summary>
/// What: a null list produces no warning instead of throwing.
/// </summary>
[Test]
public void BuildNotCapturableParametersWarningOrEmpty_WithNull_ReturnsEmpty()
{
string warning = PausePointNotCapturableWarnings.BuildNotCapturableParametersWarningOrEmpty(null);

Assert.That(warning, Is.Empty);
}

/// <summary>
/// What: SetNotCapturableVariables is visible on the next status snapshot.
/// </summary>
[Test]
public void SetNotCapturableVariables_WhenStored_AppearsInStatusSnapshot()
{
UloopPausePointRegistry.ConfigureForTests(new FakeNotCapturablePauseController(), () => DateTime.UtcNow);
try
{
const string id = "Assets/Scripts/Enemy.cs:42";
UloopPausePointRegistry.Enable(id, 30);
UloopPausePointRegistry.SetNotCapturableVariables(id, new[] { ByRefEntry });

UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus(id);

Assert.That(snapshot.NotCapturableVariables, Is.EqualTo(new[] { ByRefEntry }));
}
finally
{
UloopPausePointRegistry.ResetForTests();
}
}

/// <summary>
/// What: clearing with an empty list drops a previously stored exclusion list, so a
/// discarded resolution never leaves a stale list behind.
/// </summary>
[Test]
public void SetNotCapturableVariables_WhenClearedWithEmptyList_DropsPreviousEntries()
{
UloopPausePointRegistry.ConfigureForTests(new FakeNotCapturablePauseController(), () => DateTime.UtcNow);
try
{
const string id = "Assets/Scripts/Enemy.cs:42";
UloopPausePointRegistry.Enable(id, 30);
UloopPausePointRegistry.SetNotCapturableVariables(id, new[] { ByRefEntry });
UloopPausePointRegistry.SetNotCapturableVariables(id, Array.Empty<string>());

UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus(id);

Assert.That(snapshot.NotCapturableVariables, Is.Empty);
}
finally
{
UloopPausePointRegistry.ResetForTests();
}
}

/// <summary>
/// What: the status response carries the stored entries through FromSnapshot.
/// </summary>
[Test]
public void StatusResponseFromSnapshot_WithStoredEntries_CarriesThem()
{
UloopPausePointRegistry.ConfigureForTests(new FakeNotCapturablePauseController(), () => DateTime.UtcNow);
try
{
const string id = "Assets/Scripts/Enemy.cs:42";
UloopPausePointRegistry.Enable(id, 30);
UloopPausePointRegistry.SetNotCapturableVariables(id, new[] { ByRefEntry });

PausePointStatusResponse response =
PausePointStatusResponse.FromSnapshot(UloopPausePointRegistry.GetStatus(id));

Assert.That(response.NotCapturableVariables, Is.EqualTo(new[] { ByRefEntry }));
}
finally
{
UloopPausePointRegistry.ResetForTests();
}
}

/// <summary>
/// What: with nothing to report the status response omits the field from its JSON, so the
/// shared contract shape stays unchanged for fully capturable methods.
/// </summary>
[Test]
public void StatusResponseFromSnapshot_WithNoEntries_OmitsFieldFromJson()
{
UloopPausePointRegistry.ConfigureForTests(new FakeNotCapturablePauseController(), () => DateTime.UtcNow);
try
{
const string id = "Assets/Scripts/Enemy.cs:42";
UloopPausePointRegistry.Enable(id, 30);

PausePointStatusResponse response =
PausePointStatusResponse.FromSnapshot(UloopPausePointRegistry.GetStatus(id));
string json = JsonConvert.SerializeObject(
response,
Formatting.None,
UnityCliLoopJsonResponseSerializerSettings.Settings);

Assert.That(JObject.Parse(json).ContainsKey("NotCapturableVariables"), Is.False);
}
finally
{
UloopPausePointRegistry.ResetForTests();
}
}

/// <summary>
/// What: the enable response carries the stored entries through FromSnapshot.
/// </summary>
[Test]
public void EnableResponseFromSnapshot_WithStoredEntries_CarriesThem()
{
UloopPausePointRegistry.ConfigureForTests(new FakeNotCapturablePauseController(), () => DateTime.UtcNow);
try
{
const string id = "Assets/Scripts/Enemy.cs:42";
UloopPausePointRegistry.Enable(id, 30);
UloopPausePointRegistry.SetNotCapturableVariables(id, new[] { ByRefEntry, RefStructEntry });

PausePointResponse response =
PausePointResponse.FromSnapshot(UloopPausePointRegistry.GetStatus(id));

Assert.That(
response.NotCapturableVariables,
Is.EqualTo(new[] { ByRefEntry, RefStructEntry }));
}
finally
{
UloopPausePointRegistry.ResetForTests();
}
}

/// <summary>
/// What: with nothing to report the enable response omits the field from its JSON.
/// </summary>
[Test]
public void EnableResponseFromSnapshot_WithNoEntries_OmitsFieldFromJson()
{
UloopPausePointRegistry.ConfigureForTests(new FakeNotCapturablePauseController(), () => DateTime.UtcNow);
try
{
const string id = "Assets/Scripts/Enemy.cs:42";
UloopPausePointRegistry.Enable(id, 30);

PausePointResponse response =
PausePointResponse.FromSnapshot(UloopPausePointRegistry.GetStatus(id));
string json = JsonConvert.SerializeObject(
response,
Formatting.None,
UnityCliLoopJsonResponseSerializerSettings.Settings);

Assert.That(JObject.Parse(json).ContainsKey("NotCapturableVariables"), Is.False);
}
finally
{
UloopPausePointRegistry.ResetForTests();
}
}

private sealed class FakeNotCapturablePauseController : IUloopPausePointPauseController
{
public bool IsPlaying => true;
public bool IsPaused => false;

public void Pause()
{
}

public void Resume()
{
}
}
}
}
11 changes: 11 additions & 0 deletions Assets/Tests/Editor/PausePointNotCapturableVariablesTests.cs.meta

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 @@ -115,6 +115,7 @@ public void PausePointStatusResponse_WhenSerialized_MatchesSharedContractFieldSh
CapturedVariablesTruncated = true,
TruncatedVariableNames = new[] { "extraField" },
TruncatedVariableCount = 1,
NotCapturableVariables = new[] { "accumulator (ref/out/in parameter cannot be boxed)" },
ClearedReason = "",
StatusBeforeClear = "",
LateHitDiscardedAfterClear = false,
Expand Down
3 changes: 2 additions & 1 deletion Assets/Tests/Editor/PausePointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2222,7 +2222,8 @@ private static SourcePausePointResolution WithStaleMvid(SourcePausePointResoluti
resolution.CompiledMethodStartLine,
resolution.CompiledMethodEndLine,
resolution.Locals,
resolution.Parameters);
resolution.Parameters,
resolution.NotCapturableVariables);
}

private static async Task<PausePointResponse> EnablePausePointAsync(string id)
Expand Down
1 change: 1 addition & 0 deletions Assets/Tests/Editor/PausePointWarningsChannelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ private static UloopPausePointSnapshot CreateSnapshot(
false,
Array.Empty<string>(),
0,
Array.Empty<string>(),
string.Empty,
string.Empty,
false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
// Read back through Mono.Cecil from Library/ScriptAssemblies/UnityCLILoop.Tests.Editor.dll,
// so this fixture must stay in the UnityCLILoop.Tests.Editor assembly: a nested test asmdef
// compiles into a different dll that the Cecil test does not open.
internal sealed class SourcePausePointNotCapturableParameterFixture
{
// ref/out/in and ref-struct parameters exist here specifically to verify that capture
// reports them as not capturable instead of dropping them silently.
public int Combine(
int value,
ref int accumulator,
out int doubled,
in int multiplier,
System.Span<int> scratch)
{
doubled = value * 2;
accumulator += doubled;
scratch[0] = accumulator;
return scratch[0] * multiplier;
}

// The leading parameter is byref here specifically so skipFirstParameter is observable:
// skipping it changes the reported list, which a fixture with a capturable first
// parameter could never show.
public int CombineLeadingByRef(ref int accumulator, int value, in int multiplier)
{
accumulator += value;
return accumulator * multiplier;
}

// The capturable-only counterpart, so the empty not-capturable list is asserted against
// a method that really has nothing to report.
public int Add(int left, int right)
{
return left + right;
}
}
}

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

Loading
Loading