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
2 changes: 1 addition & 1 deletion Assets/Tests/Editor/PausePointCaptureModeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ public void Clear_WhenContinuousMarkerHasHits_DisarmsAndPreservesHistory()
UloopPausePointRegistry.Enable("jump", 30, UloopPausePointCaptureMode.Continuous, 20);
UloopPausePointRegistry.Hit("jump");

UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Clear("jump");
(UloopPausePointSnapshot snapshot, _) = UloopPausePointRegistry.Clear("jump");

Assert.That(snapshot.IsEnabled, Is.False);
Assert.That(snapshot.CapturedVariableHistory, Has.Count.EqualTo(1));
Expand Down
124 changes: 114 additions & 10 deletions Assets/Tests/Editor/PausePointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ public void Clear_WhenPausePointIsEnabled_DisablesWithoutPause()
// Verifies explicit clear prevents later marker hits from pausing Unity.
UloopPausePointRegistry.Enable("jump", 30);

UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Clear("jump");
(UloopPausePointSnapshot snapshot, _) = UloopPausePointRegistry.Clear("jump");
UloopPausePoint.Pause("jump");

Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Cleared));
Expand All @@ -330,37 +330,90 @@ public void Clear_WhenPausePointWasHit_ReportsAlreadyHitMessage()
UloopPausePointRegistry.Enable("jump", 30);
UloopPausePoint.Pause("jump");

UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Clear("jump");
(UloopPausePointSnapshot snapshot, _) = UloopPausePointRegistry.Clear("jump");

Assert.That(snapshot.Message, Is.EqualTo("Pause point was already hit (auto-disarmed); nothing to clear."));
Assert.That(_pauseController.IsPaused, Is.False);
}

[Test]
public void Clear_AfterHit_ShouldResumeEditorPause()
public void Clear_AfterHit_ResumesPausePointOwnedPauseAndReportsResumed()
{
// Verifies Option B: Clear resumes even when the pause was left from a pause-point hit.
// Verifies Clear resumes and reports ResumedFromPause when a pause-point hit owns the pause.
UloopPausePointRegistry.Enable("jump", 30);
UloopPausePoint.Pause("jump");
Assert.That(_pauseController.IsPaused, Is.True);

UloopPausePointRegistry.Clear("jump");
(UloopPausePointSnapshot _, bool resumedFromPause) = UloopPausePointRegistry.Clear("jump");

Assert.That(resumedFromPause, Is.True);
Assert.That(_pauseController.IsPaused, Is.False);
}

[Test]
public void ClearAll_AfterHit_ShouldResumeEditorPause()
public void Clear_WhenEditorManuallyPaused_LeavesPauseUntouchedAndReportsNotResumed()
{
// Verifies Clear preserves a manual pause (no open pause window) instead of resuming it.
UloopPausePointRegistry.Enable("jump", 30);
_pauseController.PauseExternally();

(UloopPausePointSnapshot _, bool resumedFromPause) = UloopPausePointRegistry.Clear("jump");

Assert.That(resumedFromPause, Is.False);
Assert.That(_pauseController.IsPaused, Is.True);
Assert.That(_pauseController.ResumeCount, Is.EqualTo(0));
}

[Test]
public void ClearAll_AfterHit_ResumesPausePointOwnedPauseAndReportsResumed()
{
// Verifies ClearAll also resumes under Option B.
// Verifies ClearAll resumes and reports ResumedFromPause when a hit owns the pause.
UloopPausePointRegistry.Enable("jump", 30);
UloopPausePoint.Pause("jump");

UloopPausePointRegistry.ClearAll();
UloopPausePointClearAllResult result = UloopPausePointRegistry.ClearAll();

Assert.That(result.ResumedFromPause, Is.True);
Assert.That(_pauseController.IsPaused, Is.False);
}

[Test]
public void ClearAll_WhenEditorManuallyPaused_LeavesPauseUntouchedAndReportsNotResumed()
{
// Verifies ClearAll preserves a manual pause (no open pause window) instead of resuming it.
UloopPausePointRegistry.Enable("jump", 30);
_pauseController.PauseExternally();

UloopPausePointClearAllResult result = UloopPausePointRegistry.ClearAll();

Assert.That(result.ResumedFromPause, Is.False);
Assert.That(_pauseController.IsPaused, Is.True);
Assert.That(_pauseController.ResumeCount, Is.EqualTo(0));
}

[Test]
public void Clear_WhenWindowOpenButEditorAlreadyExternallyUnpaused_DoesNotReportResumeAndClosesStaleWindow()
{
// Verifies the still-open window is reconciled before deciding: a hit opened the
// window, then the Editor was unpaused externally before the update tick observed it.
// Clear must not claim it resumed Play Mode (Resume is a no-op on an unpaused Editor)
// and must close the stale window so it stops freezing expiry. ClearAll shares the same
// ResumeEditorPauseIfOwnedByPausePoint path, so this covers both entry points.
UloopPausePointRegistry.Enable("jump", 30);
UloopPausePoint.Pause("jump");
_pauseController.ResumeExternally();

(UloopPausePointSnapshot _, bool resumedFromPause) = UloopPausePointRegistry.Clear("jump");

Assert.That(resumedFromPause, Is.False);
Assert.That(_pauseController.ResumeCount, Is.EqualTo(0));
// The stale window is closed: an unrelated marker's countdown is no longer frozen.
UloopPausePointRegistry.Enable("dash", 1);
_nowUtc = _nowUtc.AddSeconds(2);
UloopPausePointRegistry.ApplyCaptureWindowExpirations();
Assert.That(UloopPausePointRegistry.GetStatus("dash").Status, Is.EqualTo(UloopPausePointStatus.Expired));
}

[Test]
public void ApplyCaptureWindowExpirations_WhenHitPastTimeoutWhilePaused_DoesNotExpireUntilResumed()
{
Expand Down Expand Up @@ -549,7 +602,7 @@ public void Clear_WhenPausePointExpired_ReportsAlreadyExpiredMessage()
UloopPausePointRegistry.Enable("jump", 1);
_nowUtc = _nowUtc.AddSeconds(2);

UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Clear("jump");
(UloopPausePointSnapshot snapshot, _) = UloopPausePointRegistry.Clear("jump");

Assert.That(snapshot.Message, Is.EqualTo("Pause point had already expired before being hit; nothing to clear."));
}
Expand All @@ -561,7 +614,7 @@ public void Clear_WhenPausePointAlreadyCleared_ReportsAlreadyClearedMessage()
UloopPausePointRegistry.Enable("jump", 30);
UloopPausePointRegistry.Clear("jump");

UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Clear("jump");
(UloopPausePointSnapshot snapshot, _) = UloopPausePointRegistry.Clear("jump");

Assert.That(snapshot.Message, Is.EqualTo("Pause point was already cleared."));
}
Expand All @@ -585,6 +638,48 @@ public async Task ClearAll_WhenNothingActive_ReportsNoActiveMessage()
Assert.That(response.Message, Is.EqualTo("No active pause points to clear."));
}

[Test]
public async Task Clear_WhenResumingPausePointOwnedPause_SetsResumedPlayModeWarning()
{
// Verifies the clear-pause-point tool warns when the clear resumed a pause-point-owned pause.
UloopPausePointRegistry.Enable("jump", 30);
UloopPausePoint.Pause("jump");

ClearPausePointTool tool = new();
JObject parameters = new() { ["id"] = "jump" };
PausePointResponse response = (PausePointResponse)await tool.ExecuteAsync(parameters, CancellationToken.None);

Assert.That(response.Warning, Is.EqualTo(SourcePausePointConstants.ClearResumedPlayModeWarning));
}

[Test]
public async Task Clear_WhenManualPausePreserved_SetsNoWarning()
{
// Verifies the clear-pause-point tool emits no resume warning when it preserves a manual pause.
UloopPausePointRegistry.Enable("jump", 30);
_pauseController.PauseExternally();

ClearPausePointTool tool = new();
JObject parameters = new() { ["id"] = "jump" };
PausePointResponse response = (PausePointResponse)await tool.ExecuteAsync(parameters, CancellationToken.None);

Assert.That(response.Warning, Is.Empty);
}

[Test]
public async Task ClearAll_WhenResumingPausePointOwnedPause_SetsResumedPlayModeWarning()
{
// Verifies clear-pause-point --all warns when the bulk clear resumed a pause-point-owned pause.
UloopPausePointRegistry.Enable("jump", 30);
UloopPausePoint.Pause("jump");

ClearPausePointTool tool = new();
JObject parameters = new() { ["all"] = true };
PausePointResponse response = (PausePointResponse)await tool.ExecuteAsync(parameters, CancellationToken.None);

Assert.That(response.Warning, Is.EqualTo(SourcePausePointConstants.ClearResumedPlayModeWarning));
}

[Test]
public async Task Enable_WhenMarkerCreated_ReturnsStateManagementFields()
{
Expand Down Expand Up @@ -1268,6 +1363,15 @@ public void ResumeExternally()
{
IsPaused = false;
}

// Simulates a manual pause set outside the pause-point workflow (control-play-mode
// --action Pause, or the Editor's own pause button). It never opens a pause window, so
// clear must leave it untouched. PauseCount is not incremented because no pause-point
// hit requested it.
public void PauseExternally()
{
IsPaused = true;
}
}
}
}
15 changes: 12 additions & 3 deletions Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,10 @@ internal static PausePointResponse FromClearAll(UloopPausePointClearAllResult re
EditorState = PausePointEditorState.FromSnapshot(result.EditorState),
Message = result.ClearedCount == 0
? "No active pause points to clear."
: "Pause points cleared."
: "Pause points cleared.",
Warning = result.ResumedFromPause
? SourcePausePointConstants.ClearResumedPlayModeWarning
: string.Empty
};
}
}
Expand Down Expand Up @@ -321,14 +324,20 @@ public PausePointResponse Clear(ClearPausePointSchema parameters)
return CreateValidationFailure(idError);
}

UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Clear(parameters.Id);
(UloopPausePointSnapshot snapshot, bool resumedFromPause) = UloopPausePointRegistry.Clear(parameters.Id);
LogCleared(snapshot.Id, snapshot.StatusBeforeClear);
if (snapshot.StatusBeforeClear == UloopPausePointStatus.Expired)
{
LogExpired(snapshot.Id, snapshot.ElapsedSinceEnabledMilliseconds);
}

return PausePointResponse.FromSnapshot(snapshot);
PausePointResponse response = PausePointResponse.FromSnapshot(snapshot);
if (resumedFromPause)
{
response.Warning = SourcePausePointConstants.ClearResumedPlayModeWarning;
}

return response;
}

// Why: PausePointStatusBridgeCommand duplicates this instead of sharing it, since that
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ internal static class SourcePausePointConstants
public const string PreLineSnapshotTimingNote =
"pre-line: variables are captured before ResolvedLine executes";

// Surfaced only when a clear actually resumed Play Mode, so the caller understands the
// clear had a side effect on run state. A manual pause (control-play-mode --action Pause
// or the Editor pause button) is left untouched by clear and never triggers this.
public const string ClearResumedPlayModeWarning =
"This clear resumed Play Mode because the pause was owned by a pause-point hit. "
+ "A manual pause set outside the pause-point workflow would have been left untouched.";

// 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 @@ -40,7 +40,9 @@ public static PausePointStatusResponse Clear(JToken paramsToken)
// SourcePausePointPatcher wires into it, so this bridge - which must not reference
// that Editor-only tool assembly directly - never leaves a Harmony injection attached
// after the marker itself reports Cleared.
UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Clear(id);
// The CLI polling bridge only reports marker status; the resumed-from-pause side
// effect is surfaced through the clear-pause-point tool response, not this path.
(UloopPausePointSnapshot snapshot, bool _) = UloopPausePointRegistry.Clear(id);
LogCleared(id, snapshot.StatusBeforeClear);
if (snapshot.StatusBeforeClear == UloopPausePointStatus.Expired)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ public UloopPausePointClearAllResult(
int clearedCount,
DateTime clearedAtUtc,
UloopPausePointEditorStateSnapshot editorState,
string[] clearedIds)
string[] clearedIds,
bool resumedFromPause)
{
Debug.Assert(editorState != null, "editorState must not be null");
Debug.Assert(clearedIds != null, "clearedIds must not be null");
Expand All @@ -22,12 +23,18 @@ public UloopPausePointClearAllResult(
ClearedAtUtc = clearedAtUtc;
EditorState = editorState;
ClearedIds = clearedIds;
ResumedFromPause = resumedFromPause;
}

public int ClearedCount { get; }
public DateTime ClearedAtUtc { get; }
public UloopPausePointEditorStateSnapshot EditorState { get; }
public string[] ClearedIds { get; }

// True when the bulk clear actually resumed a pause-point-owned Editor pause, so callers
// can warn that Play Mode was resumed as a side effect. False when no pause window was
// open (e.g. nothing was paused, or only a manual pause the clear intentionally preserved).
public bool ResumedFromPause { get; }
}
}
#endif
47 changes: 39 additions & 8 deletions Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public static UloopPausePointSnapshot Enable(
return entry.ToSnapshot(now, _pauseController);
}

public static UloopPausePointSnapshot Clear(string id)
public static (UloopPausePointSnapshot Snapshot, bool ResumedFromPause) Clear(string id)
{
Debug.Assert(!string.IsNullOrWhiteSpace(id), "id must not be null or empty");

Expand All @@ -84,7 +84,7 @@ public static UloopPausePointSnapshot Clear(string id)
DateTime now = NowUtc();
if (!Entries.ContainsKey(id))
{
return UloopPausePointSnapshot.NotEnabled(id, _pauseController);
return (UloopPausePointSnapshot.NotEnabled(id, _pauseController), false);
}

UloopPausePointEntry entry = Entries[id];
Expand All @@ -105,9 +105,14 @@ public static UloopPausePointSnapshot Clear(string id)
};
entry.MarkCleared(UloopPausePointClearedReason.ExplicitClear, message);
ClearHitSnapshotAndRawCaptureForId(id);
// Why always Resume: Option B does not distinguish manual vs pause-point pause.
ResumeEditorPause();
return entry.ToSnapshot(now, _pauseController);
// Why only when pause-point-owned: a clear must not steal ownership of a manual pause
// the user set outside the pause-point workflow (control-play-mode --action Pause or
// the Editor pause button). It resumes only while a pause window is open - i.e. while
// a pause-point hit is what is holding the Editor paused. Manual pauses leave no open
// window, so they are left untouched. (Client disconnect and expiry still resume
// unconditionally: those paths must guarantee release even for a manual pause.)
bool resumedFromPause = ResumeEditorPauseIfOwnedByPausePoint();
return (entry.ToSnapshot(now, _pauseController), resumedFromPause);
}

public static UloopPausePointClearAllResult ClearAll(
Expand All @@ -131,13 +136,17 @@ public static UloopPausePointClearAllResult ClearAll(
}
ClearLatestHitSnapshot();
UloopPausePointRawCaptureHolder.Clear();
// Why always Resume: Option B does not distinguish manual vs pause-point pause.
ResumeEditorPause();
// Why only when pause-point-owned: like Clear, a bulk clear must not resume a manual
// pause the user set outside the pause-point workflow. It resumes only while a pause
// window is open (a pause-point hit is holding the Editor paused). Client disconnect
// and expiry still resume unconditionally to guarantee release.
bool resumedFromPause = ResumeEditorPauseIfOwnedByPausePoint();

UloopPausePointEditorStateSnapshot editorState = UloopPausePointEditorStateSnapshot.FromController(
_pauseController,
UloopPausePointEditorStateCapturedAt.ClearAll);
return new UloopPausePointClearAllResult(clearedIds.Count, now, editorState, clearedIds.ToArray());
return new UloopPausePointClearAllResult(
clearedIds.Count, now, editorState, clearedIds.ToArray(), resumedFromPause);
}

public static UloopPausePointSnapshot GetStatus(string id)
Expand Down Expand Up @@ -396,6 +405,28 @@ private static void ResumeEditorPause()
_pauseController.Resume();
}

// Resumes the Editor only when a pause-point hit currently owns the pause (a pause window
// is open) and reports whether it did. Clear/ClearAll use this so they never resume a
// manual pause that no pause-point hit is responsible for. Disconnect and expiry paths
// deliberately call ResumeEditorPause directly instead, because they must release the
// Editor even when the pause was manual.
private static bool ResumeEditorPauseIfOwnedByPausePoint()
{
// A manual unpause (Editor pause button / control-play-mode) may not have been observed
// yet, because the external-resume sync runs on the Editor update tick. Reconcile first
// so a window left open by that unobserved unpause is credited and closed here instead
// of being misreported as a resume this clear performed (it would be a no-op resume of
// an already-unpaused Editor) and instead of leaving the stale window freezing expiry.
ClosePauseWindowIfEditorResumedExternally();
if (!_pauseWindowStartUtc.HasValue)
{
return false;
}

ResumeEditorPause();
return true;
}

/// <summary>
/// Closes an open pause window when the Editor was unpaused through a path that never
/// calls back into this registry - control-play-mode's Play/Stop
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ public void Pause()

public void Resume()
{
// Why unconditional: Option B clears any Editor pause on disconnect/clear/expiry,
// including a manual pause that happens to be active.
// This is the low-level unpause primitive and is intentionally unconditional; it does
// not know why the Editor is paused. The registry decides when to call it: clear only
// resumes a pause-point-owned pause, while disconnect and expiry resume unconditionally.
EditorApplication.isPaused = false;
}
}
Expand Down