Files
daluandou/.codely.packages/cn.tuanjie.codely.bridge@1.0.85-47f31100/Editor/Bridge/Helpers/StepJobRunner.cs
T
huadexuan 13d0c80335 ```
chore(bridge): 删除桥接包的提交记录和Git属性配置

删除了 .codely-bridge-commit 文件中存储的提交哈希值
移除了 .gitattributes 文件中的行结束符配置规则
```
2026-09-24 21:41:14 +08:00

592 lines
25 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Codely.Newtonsoft.Json;
using UnityEditor;
using UnityTcp.Editor.Native;
namespace UnityTcp.Editor.Helpers
{
/// <summary>
/// Drives <see cref="StepJob"/>s one step per editor frame, and — unlike
/// <see cref="AsyncTaskRunner"/> and <see cref="CoroutineRunner"/>, whose Tasks and iterators
/// die with the domain — carries its jobs across domain reloads. Before the reload each job is
/// serialized to SessionState (subclass type, field state, current step index, deadline); after
/// it, the job is rebuilt from that JSON and resumes at the step it was on. That is what lets a
/// single request span a recompile: "start a compile and answer when it has finished".
///
/// The native listener and its pending requests live in the native DLL, which is not reloaded,
/// so a job's request id stays valid across the reload and the finished job answers on the
/// original request — the caller makes one call and gets one result. Pass
/// <c>detached: true</c> for fire-and-poll instead, where the caller answers immediately with
/// <c>ctx.ToPendingResponse()</c> and the client collects the result by job id through
/// <see cref="DetachedJobs"/>.
///
/// Jobs compose: a step built with <see cref="JobStep.Nested"/> runs a child StepJob to
/// completion before the parent continues. The runner keeps a stack of frames per job — one per
/// nesting level — and persists the whole stack, so a child keeps its own progress across a
/// reload too. Exactly one step, at the deepest active frame, runs per editor frame.
///
/// Usage:
/// return StepJobRunner.Start(CommandContext.RequestId, CommandContext.CommandType,
/// new WaitForCompileJob { TimeoutSeconds = 180 });
/// Returning that <see cref="JobContext"/> from a HandleCommand tells the command loop to hold
/// the response until the runner delivers it.
///
/// Main-thread only; <see cref="Tick"/> is called once per editor frame from the top-level
/// command loop.
/// </summary>
[InitializeOnLoad]
public static class StepJobRunner
{
private const string SessionKeyJobs = "Codely_StepJobRunner_Jobs";
/// <summary>
/// Cap on nested sub-jobs, guarding against a factory that returns a job nesting itself.
/// </summary>
public const int MaxNestingDepth = 8;
// One nesting level of a job: the StepJob at that level and where it is in its steps.
// Frame 0 is the root job; a composite step pushes a frame and pops it when the child ends.
private sealed class Frame
{
public string TypeName; // StepJob subclass, AssemblyQualifiedName
public string StateJson; // serialized subclass fields, refreshed on save
public int StepIndex;
public bool SubJobStarted; // this frame's current step already pushed its child
[JsonIgnore] public StepJob Job;
}
// The persisted shape of one in-flight job.
private sealed class Entry
{
public string JobId;
public string Name;
public ulong RequestId; // native request to answer on; ignored when Detached
public bool Detached;
// Single-delivery gate: 1 once the original request has been answered (job launched
// detached, or a mid-flight detach ack went out). Interlocked — a detach may claim it
// from a non-main thread while Finish answers on the main thread. Persisted so a
// detach that already acked is not re-answered after a domain reload.
public int Answered;
public double Deadline; // EditorApplication.timeSinceStartup; 0 = no timeout
public List<Frame> Frames = new List<Frame>();
[JsonIgnore] public Frame Current => Frames[Frames.Count - 1];
[JsonIgnore] public bool Broken => Frames.Count == 0 || Frames.Any(f => f.Job == null);
[JsonIgnore]
public bool TimedOut => Deadline > 0 && EditorApplication.timeSinceStartup > Deadline;
}
// The JobRegistry face of a step job: detach goes through the entry's Answered gate and
// touches only thread-safe state, never the runner's entry list — that is what makes it
// callable from the background command executor while the main thread is busy.
private sealed class Handle : JobRegistry.IJobHandle
{
private readonly Entry _entry;
public Handle(Entry entry) => _entry = entry;
public string JobId => _entry.JobId;
public ulong RequestId => _entry.RequestId;
public string Name => _entry.Name;
public string Runner => "step";
public bool Detached => _entry.Detached;
public bool TryDetach(string reason, out string error)
{
if (Interlocked.CompareExchange(ref _entry.Answered, 1, 0) != 0)
{
error = _entry.Detached
? "Job is already detached."
: "Job already completed — its request has been answered.";
return false;
}
_entry.Detached = true;
DetachedJobs.MarkPending(_entry.JobId, _entry.Name);
// The Detached flag must survive a domain reload, but SessionState is main-thread
// only — ask the next Tick to persist (beforeAssemblyReload's Save is the backstop).
_saveRequested = true;
error = null;
if (_entry.RequestId == 0) return true; // no live native request to ack (tests)
try
{
CodelyLogger.Log($"[StepJobRunner] detaching job '{_entry.Name}' with '{reason}': {_entry.RequestId}");
NativeUnityTcpBridgeHost.EnqueueResponse(_entry.RequestId, JsonConvert.SerializeObject(
Response.Success(
$"Job detached. Use the manage_job tool (action 'status', job_id '{_entry.JobId}') to query the job's status and collect its result.",
new
{
job = _entry.Name,
id = _entry.JobId,
status = "detached",
reason = reason ?? "Detached by request.",
})));
}
catch (Exception ex)
{
CodelyLogger.LogWarning(
$"[StepJobRunner] Failed to ack detach for '{_entry.Name}': {ex.Message}");
}
return true;
}
}
private static readonly List<Entry> _entries = new List<Entry>();
// Set from any thread when state changed outside the tick (a cross-thread detach);
// consumed by Tick on the main thread, which owns persistence.
private static volatile bool _saveRequested;
private static bool _initialized;
static StepJobRunner()
{
if (EditorAutomationGuard.ShouldSkipBridgeAutoStart()) return;
EnsureInitialized();
}
// Main-thread only. Explicit bridge/job starts need restoration too, even
// when InitializeOnLoad deliberately skipped auto-start in this domain.
internal static void EnsureInitialized()
{
if (_initialized) return;
// Set before Load: a restored job's OnRestored may enroll another job.
_initialized = true;
Load();
// Pending markers persist but only step jobs survive the reload; retire the rest so no
// client is left polling a job that no longer exists.
DetachedJobs.ReconcilePending(_entries.Where(e => e.Detached).Select(e => e.JobId).ToList());
// Persist as late as possible so a job that keeps working right up to the reload saves
// the state it actually ended on.
AssemblyReloadEvents.beforeAssemblyReload -= Save;
AssemblyReloadEvents.beforeAssemblyReload += Save;
}
/// <summary>
/// Enrolls a step job and returns its context. Return that context from a HandleCommand to
/// hold the client's request open until the job finishes; with <paramref name="detached"/>
/// true, answer <see cref="JobContext.ToPendingResponse"/> instead and let the client poll
/// the job id via <see cref="DetachedJobs.Check"/>.
///
/// The timeout is wall-clock against the editor session clock, so time spent inside a
/// domain reload counts against it — budget for that on jobs that trigger a recompile.
/// Pass 0 for no timeout.
/// </summary>
public static JobContext Start(ulong requestId, string name, StepJob job,
int timeoutSeconds = JobRunnerBase.DefaultTimeoutSeconds,
bool detached = false)
{
if (job == null) throw new ArgumentNullException(nameof(job));
EnsureInitialized();
var ctx = new JobContext(requestId, name, detached);
var entry = new Entry
{
JobId = ctx.JobId,
Name = ctx.Name,
RequestId = requestId,
Detached = detached,
// A job launched detached is answered by the handler itself (ToPendingResponse),
// so its delivery gate starts claimed.
Answered = detached ? 1 : 0,
Deadline = timeoutSeconds > 0
? EditorApplication.timeSinceStartup + timeoutSeconds
: 0,
};
entry.Frames.Add(NewFrame(job));
_entries.Add(entry);
JobRegistry.Register(new Handle(entry));
if (detached) DetachedJobs.MarkPending(ctx);
Save();
return ctx;
}
/// <summary>Number of jobs currently in flight (nesting does not count extra).</summary>
public static int InFlightCount => _entries.Count;
/// <summary>
/// Advances every job by one step; called each editor frame. A job whose current step's
/// leave condition is still false stays on that step and its Run is invoked again next
/// frame. For a nested job, the step that runs is the one at the deepest active frame.
///
/// Steps whose skip condition holds are passed over for free — a run of them is consumed
/// and the first step that does not skip still runs — so "one step per frame" counts only
/// steps that actually execute.
/// </summary>
public static void Tick()
{
if (_entries.Count == 0) return;
// A cross-thread detach flipped an entry's Detached flag outside the tick; persist it
// now so the job comes back detached if a domain reload lands before it finishes.
bool dirty = _saveRequested;
_saveRequested = false;
for (int i = _entries.Count - 1; i >= 0; i--)
{
var entry = _entries[i];
// Cross-thread cancel requests (see JobControl) are applied here, on the tick, so
// entries are only ever touched from the main thread. (Detach needs no tick — it
// goes through the registry handle immediately.)
if (JobControl.TryTakeCancel(entry.JobId, out string cancelReason))
{
Finish(i, entry, Response.Error(
cancelReason ?? $"Step job '{entry.Name}' canceled."));
dirty = true;
continue;
}
if (entry.Broken)
{
// A frame failed to rebuild in this domain — the job can never progress again.
Finish(i, entry, Response.Error(
$"Step job '{entry.Name}' could not be restored after the domain reload."));
dirty = true;
continue;
}
if (entry.TimedOut)
{
Finish(i, entry, Response.Error(
$"Step job '{entry.Name}' timed out at step '{StepPath(entry)}'."));
dirty = true;
continue;
}
bool done;
try
{
dirty |= Advance(entry, out done);
}
catch (Exception ex)
{
// A throwing step (or BuildSteps) ends the job rather than stalling the loop —
// the other jobs in this frame must still get their tick.
Finish(i, entry, Response.Error(
$"Step job '{entry.Name}' failed at step '{StepPath(entry)}': {JobRunnerBase.SafeError(ex)}"));
dirty = true;
continue;
}
if (done)
{
Finish(i, entry, null);
dirty = true;
}
}
if (dirty) Save();
}
/// <summary>
/// Fails and answers every in-flight job, and clears the persisted state. Called on bridge
/// stop / editor quit — NOT on domain reload, which these jobs are built to survive.
/// </summary>
public static void CancelAll(string reason = "Operation canceled.")
{
for (int i = _entries.Count - 1; i >= 0; i--)
Finish(i, _entries[i], Response.Error(reason));
Save();
}
// Advances the deepest active frame by one step. Returns whether anything worth persisting
// changed; `done` reports that the whole job (root frame included) has finished.
private static bool Advance(Entry entry, out bool done)
{
done = false;
var frame = entry.Current;
var job = frame.Job;
var steps = job.Steps;
// ── pass over skipped steps ───────────────────────────────────────────
// Skipping is free: a whole run of skipped steps is consumed here and the first step
// that does not skip still runs in this same frame. The index only moves forward, so
// this cannot loop.
bool skipped = !job.IsFinished && SkipAhead(frame, steps);
// ── this frame's job is over ──────────────────────────────────────────
if (job.IsFinished || frame.StepIndex >= steps.Length)
{
if (entry.Frames.Count == 1)
{
done = true;
return true;
}
PopSubJob(entry);
return true;
}
var step = steps[frame.StepIndex];
// ── composite step: push the child and let it run from the next frame ─
if (step.SubJob != null && !frame.SubJobStarted)
{
if (entry.Frames.Count >= MaxNestingDepth)
throw new InvalidOperationException(
$"Sub-job nesting exceeded {MaxNestingDepth} levels at step '{step.Name}'.");
frame.SubJobStarted = true;
var child = step.SubJob();
if (child != null)
{
entry.Frames.Add(NewFrame(child));
return true;
}
// A null factory result means "skip the child" — fall through and treat this as a
// plain step so the conditional nesting costs the same single frame.
}
// ── plain step (and a composite step whose child has already finished) ─
step.Run?.Invoke();
if (job.IsFinished)
{
if (entry.Frames.Count == 1)
done = true;
return true;
}
if (step.CanLeave == null || step.CanLeave())
{
frame.StepIndex++;
frame.SubJobStarted = false;
return true;
}
return skipped;
}
// Advances past every step whose skip condition currently holds. Returns whether the index
// moved, which is what makes the skipping worth persisting.
private static bool SkipAhead(Frame frame, JobStep[] steps)
{
bool moved = false;
while (frame.StepIndex < steps.Length)
{
var step = steps[frame.StepIndex];
if (step.Skip == null || !step.Skip()) break;
frame.StepIndex++;
frame.SubJobStarted = false;
moved = true;
}
return moved;
}
// Ends a child frame: hands its response to the parent, runs the parent's onComplete hook,
// then evaluates the parent's leave condition — all within this one tick, so a composite
// step costs one frame to enter and one to leave.
private static void PopSubJob(Entry entry)
{
var child = entry.Current;
entry.Frames.RemoveAt(entry.Frames.Count - 1);
var frame = entry.Current;
var parent = frame.Job;
parent.SetSubJobResponse(child.Job.ToResponseJson(entry.Name, entry.JobId));
var steps = parent.Steps;
if (frame.StepIndex >= steps.Length) return; // parent finished underneath the child
var step = steps[frame.StepIndex];
step.OnSubJobComplete?.Invoke();
if (parent.IsFinished) return;
if (step.CanLeave == null || step.CanLeave())
{
frame.StepIndex++;
frame.SubJobStarted = false;
}
}
// Removes the job and delivers its outcome. A non-null errorResponse overrides whatever the
// job itself recorded. The entry's Answered gate decides where the outcome goes: if the
// original request is still unanswered this claims it and responds now; otherwise — the job
// launched detached, or a mid-flight detach (possibly from another thread) got there
// first — the outcome is memorized for polling. Exactly one of the two ever happens.
private static void Finish(int index, Entry entry, object errorResponse)
{
_entries.RemoveAt(index);
JobRegistry.Unregister(entry.JobId);
string json;
if (errorResponse != null)
json = JsonConvert.SerializeObject(errorResponse);
else
json = entry.Frames[0].Job.ToResponseJson(entry.Name, entry.JobId);
try
{
if (Interlocked.CompareExchange(ref entry.Answered, 1, 0) == 0)
NativeUnityTcpBridgeHost.EnqueueResponse(entry.RequestId, json);
else
DetachedJobs.StoreJson(entry.JobId, json);
}
catch (Exception ex)
{
// Delivery must never throw into the tick loop — the other jobs still need their
// frame, and this one is already out of the registry.
CodelyLogger.LogWarning(
$"[StepJobRunner] Failed to deliver '{entry.Name}' result: {ex.Message}");
}
}
private static Frame NewFrame(StepJob job) => new Frame
{
TypeName = job.GetType().AssemblyQualifiedName,
Job = job,
};
// Used to build error messages, including for a job whose BuildSteps is what threw — so it
// must never throw itself. Renders nested frames as "parent > child".
private static string StepPath(Entry entry)
{
try
{
return string.Join(" > ", entry.Frames.Select(StepNameOf).ToArray());
}
catch { return "(unknown)"; }
}
private static string StepNameOf(Frame frame)
{
try
{
var steps = frame.Job?.Steps;
return steps != null && frame.StepIndex >= 0 && frame.StepIndex < steps.Length
? steps[frame.StepIndex].Name
: $"#{frame.StepIndex}";
}
catch { return $"#{frame.StepIndex}"; }
}
// ---- persistence ---------------------------------------------------- //
private static void Save()
{
if (_entries.Count == 0)
{
SessionState.EraseString(SessionKeyJobs);
return;
}
try
{
foreach (var frame in _entries.SelectMany(e => e.Frames))
{
if (frame.Job != null)
frame.StateJson = JsonConvert.SerializeObject(frame.Job);
}
SessionState.SetString(SessionKeyJobs, JsonConvert.SerializeObject(_entries));
}
catch (Exception ex)
{
CodelyLogger.LogWarning($"[StepJobRunner] Failed to persist jobs: {ex.Message}");
}
}
/// <summary>
/// Test seam: persists every in-flight job, drops the live instances, and rebuilds them
/// from SessionState — the same round trip a domain reload puts them through, minus the
/// reload. Nothing in the editor calls this; jobs go through <see cref="Save"/> on
/// <c>beforeAssemblyReload</c> and <see cref="Load"/> on the next domain's initialization.
/// </summary>
internal static void SimulateDomainReload()
{
Save();
_entries.Clear();
_initialized = false;
EnsureInitialized();
}
private static void Load()
{
string json = SessionState.GetString(SessionKeyJobs, null);
if (string.IsNullOrEmpty(json)) return;
List<Entry> restored;
try { restored = JsonConvert.DeserializeObject<List<Entry>>(json); }
catch (Exception ex)
{
CodelyLogger.LogWarning($"[StepJobRunner] Failed to read persisted jobs: {ex.Message}");
SessionState.EraseString(SessionKeyJobs);
return;
}
if (restored == null) return;
foreach (var entry in restored)
{
if (entry.Frames == null) entry.Frames = new List<Frame>();
foreach (var frame in entry.Frames)
{
frame.Job = Rebuild(frame, entry.Name);
if (frame.Job == null) continue;
try { frame.Job.OnRestored(); }
catch (Exception ex)
{
CodelyLogger.LogWarning(
$"[StepJobRunner] OnRestored threw for '{entry.Name}': {ex.Message}");
}
}
// Entries with a broken frame are kept so Tick can report the failure to the client.
_entries.Add(entry);
// Re-register with the cross-thread registry — the old domain's handles died with
// it, but the job id (and its native request) are still live.
JobRegistry.Register(new Handle(entry));
}
}
private static StepJob Rebuild(Frame frame, string jobName)
{
try
{
var type = ResolveType(frame.TypeName);
if (type == null)
{
CodelyLogger.LogWarning(
$"[StepJobRunner] Cannot resolve job type '{frame.TypeName}' for '{jobName}'.");
return null;
}
return JsonConvert.DeserializeObject(frame.StateJson ?? "{}", type) as StepJob;
}
catch (Exception ex)
{
CodelyLogger.LogWarning(
$"[StepJobRunner] Failed to restore job '{jobName}': {ex.Message}");
return null;
}
}
// Type.GetType resolves the assembly-qualified name directly in the common case; the scan
// is the fallback for a job type whose assembly identity changed across the reload.
private static Type ResolveType(string assemblyQualifiedName)
{
if (string.IsNullOrEmpty(assemblyQualifiedName)) return null;
var type = Type.GetType(assemblyQualifiedName, throwOnError: false);
if (type != null) return type;
string fullName = assemblyQualifiedName.Split(',')[0].Trim();
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
try
{
type = assembly.GetType(fullName, throwOnError: false);
if (type != null) return type;
}
catch { /* dynamic or unloadable assembly — keep scanning */ }
}
return null;
}
}
}