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
1 change: 1 addition & 0 deletions .agents/skills/uloop-status/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,4 @@ Play Mode does not change the state: a paused Play Mode session is `Ready`, with
- A sandboxed shell that cannot open the Unix socket gets an error, not NotRunning; run it outside the sandbox.
- uloop cannot detect Safe Mode directly; ServerUnavailable that does not clear is the signal.
- Before reporting MainThreadBlocked it wakes the Editor's main loop and asks again about a second later, so an Editor that is only idle in the background reads as Ready.
- A command sent while the main thread is blocked waits for it. Once the stall reaches 30 seconds before any of the command's code has run, Unity stops the wait and the command fails with `MAIN_THREAD_BLOCKED` (nothing ran, so running it again after the block clears is safe). A command whose own code blocks the main thread is not stopped; the CLI prints a stall notice on stderr every 60 seconds instead and gives up after 5 minutes with `UNITY_EDITOR_UNRESPONSIVE`.
1 change: 1 addition & 0 deletions .agents/skills/uloop-status/references/cancel.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Cancel the uloop command that holds the Editor's command slot, from another term
## When to Use

- Every command is rejected with `UNITY_SERVER_BUSY`, `uloop status` keeps reporting `Busy`, and the running command shows no sign of finishing.
`MAIN_THREAD_BLOCKED` means the command never ran and released its hold on the slot, so `uloop cancel` is not needed for it (an `execute-dynamic-code` run sharing the slot may still hold it).
- A command you started does not return, for example an `execute-dynamic-code` snippet that awaits something that never completes.

## Usage
Expand Down
1 change: 1 addition & 0 deletions .claude/skills/uloop-status/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,4 @@ Play Mode does not change the state: a paused Play Mode session is `Ready`, with
- A sandboxed shell that cannot open the Unix socket gets an error, not NotRunning; run it outside the sandbox.
- uloop cannot detect Safe Mode directly; ServerUnavailable that does not clear is the signal.
- Before reporting MainThreadBlocked it wakes the Editor's main loop and asks again about a second later, so an Editor that is only idle in the background reads as Ready.
- A command sent while the main thread is blocked waits for it. Once the stall reaches 30 seconds before any of the command's code has run, Unity stops the wait and the command fails with `MAIN_THREAD_BLOCKED` (nothing ran, so running it again after the block clears is safe). A command whose own code blocks the main thread is not stopped; the CLI prints a stall notice on stderr every 60 seconds instead and gives up after 5 minutes with `UNITY_EDITOR_UNRESPONSIVE`.
1 change: 1 addition & 0 deletions .claude/skills/uloop-status/references/cancel.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Cancel the uloop command that holds the Editor's command slot, from another term
## When to Use

- Every command is rejected with `UNITY_SERVER_BUSY`, `uloop status` keeps reporting `Busy`, and the running command shows no sign of finishing.
`MAIN_THREAD_BLOCKED` means the command never ran and released its hold on the slot, so `uloop cancel` is not needed for it (an `execute-dynamic-code` run sharing the slot may still hold it).
- A command you started does not return, for example an `execute-dynamic-code` snippet that awaits something that never completes.

## Usage
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/dead-code.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,4 @@ jobs:
--include-kept false
--format table
--fail-on high-confidence
--max-public-candidates 38
--max-public-candidates 39
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,23 @@ public void CreateErrorResponse_ForBusyException_IncludesRunningToolPhaseOnlyWhe
Assert.That(unknownPhaseData["runningToolPhase"], Is.Null);
}

/// <summary>
/// Verifies a blocked-main-thread failure reaches the wire as main_thread_blocked data with the tool name and stall seconds.
/// </summary>
[Test]
public void CreateErrorResponse_ForMainThreadBlockedException_ProducesMainThreadBlockedData()
{
UnityCliLoopMainThreadBlockedException blockedException = new("get-hierarchy", 45);

string response = JsonRpcResponseFactory.CreateErrorResponse(1, blockedException);

JToken dataToken = JObject.Parse(response)["error"]!["data"]!;
Assert.That(dataToken["type"]!.Value<string>(), Is.EqualTo("main_thread_blocked"));
Assert.That(dataToken["runningToolName"]!.Value<string>(), Is.EqualTo("get-hierarchy"));
Assert.That(dataToken["stallSeconds"]!.Value<double>(), Is.EqualTo(45));
Assert.That(dataToken["message"]!.Value<string>(), Is.Not.Empty);
}

[Test]
public async Task ProcessRequest_WhenProtocolVersionIsTooOld_ProducesFrozenMismatchJson()
{
Expand Down
175 changes: 175 additions & 0 deletions Assets/Tests/Editor/MainThreadEntryWatchdogTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;

using io.github.hatayama.UnityCliLoop.Application;
using io.github.hatayama.UnityCliLoop.Domain;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
/// <summary>
/// Tests the watchdog that fails a request still waiting for a blocked Editor main thread.
/// </summary>
public sealed class MainThreadEntryWatchdogTests
{
/// <summary>
/// Verifies a stall below the threshold leaves the waiting request alone.
/// </summary>
[Test]
public void Observe_WhenStallBelowThreshold_LeavesLeaseAlone()
{
ToolExecutionLease lease = new ToolExecutionSession().TryEnter("tool").Lease;
ManualPolls polls = new ManualPolls();
MainThreadEntryWatchdog watchdog = new MainThreadEntryWatchdog(new ScriptedLiveness(29.9), polls.WaitOnePoll);

bool cancelled = watchdog.Observe(lease);

Assert.That(cancelled, Is.False);
Assert.That(lease.ExecutionToken.IsCancellationRequested, Is.False);
lease.Dispose();
}

/// <summary>
/// Verifies a stall at the threshold cancels the waiting request and records the stall it saw.
/// </summary>
[Test]
public void Observe_WhenStallAtThreshold_CancelsLeaseWithStallSeconds()
{
ToolExecutionLease lease = new ToolExecutionSession().TryEnter("tool").Lease;
ManualPolls polls = new ManualPolls();
MainThreadEntryWatchdog watchdog = new MainThreadEntryWatchdog(new ScriptedLiveness(30.0), polls.WaitOnePoll);

bool cancelled = watchdog.Observe(lease);

Assert.That(cancelled, Is.True);
Assert.That(lease.WasCancelledForBlockedMainThread, Is.True);
Assert.That(lease.BlockedMainThreadStallSeconds, Is.EqualTo(30));
lease.Dispose();
}

/// <summary>
/// Verifies the first stall check waits for one poll, so an idle Editor that only slept is never judged.
/// </summary>
[Test]
public void GuardAsync_WhenStoppedBeforeFirstPoll_NeverReadsLiveness()
{
ToolExecutionLease lease = new ToolExecutionSession().TryEnter("tool").Lease;
ManualPolls polls = new ManualPolls();
ScriptedLiveness liveness = new ScriptedLiveness(45);
MainThreadEntryWatchdog watchdog = new MainThreadEntryWatchdog(liveness, polls.WaitOnePoll);
CancellationTokenSource stop = new CancellationTokenSource();
SynchronizationContext originalContext = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(null);

try
{
Task guardTask = watchdog.GuardAsync(lease, stop.Token);

Assert.That(polls.Count, Is.EqualTo(1));
Assert.That(liveness.Reads, Is.EqualTo(0));

stop.Cancel();

Assert.That(guardTask.Status, Is.EqualTo(TaskStatus.RanToCompletion));
Assert.That(liveness.Reads, Is.EqualTo(0));
Assert.That(lease.ExecutionToken.IsCancellationRequested, Is.False);
}
finally
{
SynchronizationContext.SetSynchronizationContext(originalContext);
stop.Dispose();
lease.Dispose();
}
}

/// <summary>
/// Verifies the watchdog keeps polling while the stall grows and cancels the request once it reaches the threshold.
/// </summary>
[Test]
public void GuardAsync_PollsUntilTheStallReachesTheThreshold()
{
ToolExecutionLease lease = new ToolExecutionSession().TryEnter("tool").Lease;
ManualPolls polls = new ManualPolls();
ScriptedLiveness liveness = new ScriptedLiveness(10, 20, 45);
MainThreadEntryWatchdog watchdog = new MainThreadEntryWatchdog(liveness, polls.WaitOnePoll);
CancellationTokenSource stop = new CancellationTokenSource();
SynchronizationContext originalContext = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(null);

try
{
Task guardTask = watchdog.GuardAsync(lease, stop.Token);

polls.CompleteLatest();
Assert.That(lease.ExecutionToken.IsCancellationRequested, Is.False);
Assert.That(polls.Count, Is.EqualTo(2));

polls.CompleteLatest();
Assert.That(lease.ExecutionToken.IsCancellationRequested, Is.False);
Assert.That(polls.Count, Is.EqualTo(3));

polls.CompleteLatest();
Assert.That(lease.WasCancelledForBlockedMainThread, Is.True);
Assert.That(guardTask.Status, Is.EqualTo(TaskStatus.RanToCompletion));
Assert.That(polls.Count, Is.EqualTo(3));
}
finally
{
SynchronizationContext.SetSynchronizationContext(originalContext);
stop.Cancel();
stop.Dispose();
lease.Dispose();
}
}
}

// Stands in for the one-second poll: each poll is a pending task the test completes by hand,
// and stopping the watchdog cancels the pending one so no background work is left behind.
internal sealed class ManualPolls
{
private readonly List<TaskCompletionSource<bool>> _polls = new List<TaskCompletionSource<bool>>();

internal int Count => _polls.Count;

internal Task Latest => _polls[_polls.Count - 1].Task;

internal Task WaitOnePoll(CancellationToken ct)
{
TaskCompletionSource<bool> poll = new TaskCompletionSource<bool>();
ct.Register(() => poll.TrySetCanceled());
_polls.Add(poll);
return poll.Task;
}

internal void CompleteLatest()
{
_polls[_polls.Count - 1].SetResult(true);
}
}

// Returns the scripted stall values in order (repeating the last one) and runs an optional hook
// at each read, so a test can let the main thread resume while the watchdog is deciding.
internal sealed class ScriptedLiveness : IMainThreadLivenessPort
{
private readonly double[] _values;

internal ScriptedLiveness(params double[] values)
{
_values = values;
}

internal int Reads { get; private set; }

internal Action OnRead { get; set; }

public double SecondsSinceLastMainThreadTick()
{
OnRead?.Invoke();
double value = _values[Math.Min(Reads, _values.Length - 1)];
Reads++;
return value;
}
}
}
11 changes: 11 additions & 0 deletions Assets/Tests/Editor/MainThreadEntryWatchdogTests.cs.meta

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

120 changes: 120 additions & 0 deletions Assets/Tests/Editor/ToolExecutionSessionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,126 @@ public void TryEnter_WhenDynamicCodeHolderIsCancelledByUserPastGrace_ShouldLetOt
holderLease.Dispose();
}

[Test]
public void TryCancelForBlockedMainThread_WhileWaiting_CancelsExecutionTokenAndRecordsStall()
{
// Tests that a blocked-main-thread cancel before tool entry cancels the token and is not reported as a user cancel.
ToolExecutionSession session = new ToolExecutionSession();
ToolExecutionLease lease = session.TryEnter("tool").Lease;

bool cancelled = lease.TryCancelForBlockedMainThread(45);

Assert.That(cancelled, Is.True);
Assert.That(lease.ExecutionToken.IsCancellationRequested, Is.True);
Assert.That(lease.WasCancelledForBlockedMainThread, Is.True);
Assert.That(lease.WasCancelledByUser, Is.False);
Assert.That(lease.BlockedMainThreadStallSeconds, Is.EqualTo(45));

lease.Dispose();
}

[Test]
public void TryCancelForBlockedMainThread_AfterToolEntered_ReturnsFalseAndLeavesTokenAlive()
{
// Tests that once the tool entered, the blocked-main-thread cancel loses and leaves the running tool alone.
ToolExecutionSession session = new ToolExecutionSession();
ToolExecutionLease lease = session.TryEnter("tool").Lease;

bool entered = lease.MarkToolEntered();
bool cancelled = lease.TryCancelForBlockedMainThread(45);

Assert.That(entered, Is.True);
Assert.That(cancelled, Is.False);
Assert.That(lease.ExecutionToken.IsCancellationRequested, Is.False);
Assert.That(lease.WasCancelledForBlockedMainThread, Is.False);

lease.Dispose();
}

[Test]
public void TryCancelForBlockedMainThread_AfterUserCancel_ReturnsFalseAndKeepsUserReason()
{
// Tests that a uloop cancel recorded first keeps its reason when the blocked-main-thread cancel comes later.
ToolExecutionSession session = new ToolExecutionSession();
ToolExecutionLease lease = session.TryEnter("tool").Lease;

lease.CancelByUser();
bool cancelled = lease.TryCancelForBlockedMainThread(45);

Assert.That(cancelled, Is.False);
Assert.That(lease.WasCancelledByUser, Is.True);
Assert.That(lease.WasCancelledForBlockedMainThread, Is.False);

lease.Dispose();
}

[Test]
public void CancelByUser_AfterBlockedCancel_KeepsBlockedReason()
{
// Tests that a uloop cancel arriving after the blocked-main-thread cancel does not overwrite the blocked reason.
ToolExecutionSession session = new ToolExecutionSession();
ToolExecutionLease lease = session.TryEnter("tool").Lease;

lease.TryCancelForBlockedMainThread(45);
lease.CancelByUser();

Assert.That(lease.WasCancelledByUser, Is.False);
Assert.That(lease.WasCancelledForBlockedMainThread, Is.True);

lease.Dispose();
}

[Test]
public void CancelByUser_AfterToolEntered_ReportsUserCancel()
{
// Tests that uloop cancel on a running tool is still reported as a user cancel.
ToolExecutionSession session = new ToolExecutionSession();
ToolExecutionLease lease = session.TryEnter("tool").Lease;

lease.MarkToolEntered();
lease.CancelByUser();

Assert.That(lease.WasCancelledByUser, Is.True);

lease.Dispose();
}

[Test]
public void MarkToolEntered_AfterBlockedClaim_ReturnsFalseBeforeTheTokenIsCancelled()
{
// Tests that a claimed lease refuses tool entry even in the moment before the claimer cancels the token.
ToolExecutionSession session = new ToolExecutionSession();
ToolExecutionLease lease = session.TryEnter("tool").Lease;

bool claimed = lease.TryClaimBlockedMainThread(45);
bool entered = lease.MarkToolEntered();

Assert.That(claimed, Is.True);
Assert.That(entered, Is.False);
Assert.That(lease.ExecutionToken.IsCancellationRequested, Is.False);
Assert.That(lease.WasCancelledForBlockedMainThread, Is.True);
Assert.That(lease.BlockedMainThreadStallSeconds, Is.EqualTo(45));

lease.Dispose();
}

[Test]
public void MarkToolEntered_Twice_ReturnsFalseTheSecondTime()
{
// Tests that tool entry is claimed once and a second claim does not change the entered state.
ToolExecutionSession session = new ToolExecutionSession();
ToolExecutionLease lease = session.TryEnter("tool").Lease;

bool first = lease.MarkToolEntered();
bool second = lease.MarkToolEntered();

Assert.That(first, Is.True);
Assert.That(second, Is.False);
Assert.That(lease.WasCancelledByUser, Is.False);

lease.Dispose();
}

private static long GraceTicks => ToolExecutionSession.CancelledLeaseGraceSeconds * Stopwatch.Frequency;

private static UnityCliLoopToolRegistry CreateRegistry(InMemoryToolSettingsPort settingsPort, IUnityCliLoopTool[] tools)
Expand Down
Loading
Loading