33f5ad68bf
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m19s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m26s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m32s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m34s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m57s
Project CI / AI game creator shell Rust crates (push) Successful in 3m18s
Project CI / Native shell tests (push) Successful in 10m54s
Project CI / Backend tests (push) Successful in 12m42s
Project CI / Frontend tests (push) Successful in 13m4s
Project CI / AI game creator shell web tests (push) Successful in 5m1s
Project CI / Repository checks (push) Successful in 12m48s
AGC 原有插件系统无法直接操作已打开的 Unity Editor。本变更增加内置 `agc-unity-editor`,在 Windows x64 / Unity Mono 上支持当前项目探测、连接与 C# 执行,不向 Unity 工程安装 UPM 桥接包。 ## 主要变更 - 固定复用 DotCraft.Unity 0.4.3 的 Attach 核心,提供自包含 .NET helper,保留上游许可证、来源及修改记录。 - GUI、Runtime、DirectProject 共用 Runner 执行服务;补齐项目身份、并发、总期限、回执确认与持久不确定状态阻断。 - 现有打开项目入口支持 Unity,按项目类型及开关暴露插件和 Agent 工具。 - Windows 构建准备 helper 并随包分发;插件 JS/Rust 测试接入现有 CI 组,Jenkins 增加 .NET 10 工具链预检。 ## 验证 - .NET helper 27 项测试、自包含发布及最小环境协议 smoke 通过。 - Unity 6000.3.7f1 实机验证通过:连接、C# 执行、编译错误修复、断连重连、Domain Reload 后重新握手;真实 Runner 的 ACK、并发拒绝和跨重启阻断通过。 - 宿主 Unity、PluginHost、Cocos、MCP、工具目录与引擎识别定向回归通过;前端类型检查、插件 JS/Rust、CI 配置、格式、编码和文档门禁通过。 Linux CI 不代替 Windows helper/实机验证;发行安装包 UI smoke、其它 Unity 版本和 Unity CoreCLR 未验证。Unity 演示工程中的场景和组件已撤销,不在此 PR 范围内。 --------- Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/423
397 lines
20 KiB
C#
397 lines
20 KiB
C#
// Modified for AGC: bounded preparation, dispatch observation and local selection invalidation.
|
|
using System.Collections.Concurrent;
|
|
using System.Diagnostics;
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace DotCraft.Unity;
|
|
|
|
/// <summary>Local Mono Editor connection and target-side execution.</summary>
|
|
public sealed class UnityAttachService(string cacheRoot, string nativeBootstrapPath) : IAsyncDisposable
|
|
{
|
|
private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(60);
|
|
private readonly SemaphoreSlim gate = new(1, 1);
|
|
private readonly ConcurrentDictionary<TargetIdentity, byte> owned = new();
|
|
private readonly ConcurrentDictionary<string, TargetIdentity> selected = new(StringComparer.Ordinal);
|
|
private readonly ConcurrentDictionary<string, ExecutionHandle> executions = new(StringComparer.Ordinal);
|
|
private readonly string clientId = Guid.NewGuid().ToString("N");
|
|
private bool disposed;
|
|
private string? runtimeIdentity;
|
|
private string? extractedNative;
|
|
private string CacheRoot => resolvedCacheRoot ?? cacheRoot;
|
|
private string? resolvedCacheRoot;
|
|
private string NativeBootstrapPath => extractedNative ??=
|
|
string.IsNullOrEmpty(nativeBootstrapPath) ? AttachStorage.ExtractNative(CacheRoot) : nativeBootstrapPath;
|
|
private string RuntimeIdentity => runtimeIdentity ??= AttachStorage.RuntimeIdentity(NativeBootstrapPath);
|
|
|
|
/// <summary>Creates an Attach client using the shared per-user rendezvous.</summary>
|
|
public static UnityAttachService CreateDefault() => new(AttachStorage.DefaultRoot, "");
|
|
|
|
/// <summary>Lists running local Unity Editors without attaching.</summary>
|
|
public object List()
|
|
{
|
|
var targets = new List<object>();
|
|
foreach (var process in Process.GetProcessesByName("Unity"))
|
|
{
|
|
using (process)
|
|
{
|
|
try
|
|
{
|
|
targets.Add(new { pid = process.Id, startedUtc = process.StartTime.ToUniversalTime(),
|
|
editor = process.MainModule?.FileName,
|
|
project = AttachStorage.Project(Connection(process.Id)) ?? EditorDiscovery.Project(process) });
|
|
}
|
|
catch (Exception error) when (error is InvalidOperationException or ArgumentException or System.ComponentModel.Win32Exception) { }
|
|
}
|
|
}
|
|
return targets;
|
|
}
|
|
|
|
private string Connection(int pid) => AttachStorage.Connection(CacheRoot, pid);
|
|
|
|
/// <summary>Attaches or restores the bridge without replaying an execution.</summary>
|
|
public async Task<JsonObject> Connect(string threadId, int? requestedPid, CancellationToken cancellationToken = default)
|
|
{
|
|
await gate.WaitAsync(cancellationToken);
|
|
AttachAttempt? attempt = null;
|
|
try
|
|
{
|
|
ObjectDisposedException.ThrowIf(disposed, this);
|
|
var pid = requestedPid ?? RequireTarget(threadId).Pid;
|
|
using var target = GetUnityProcess(pid);
|
|
var identity = new TargetIdentity(pid, target.StartTime.ToUniversalTime());
|
|
attempt = new AttachAttempt(pid);
|
|
attempt.Step("resolve-paths");
|
|
resolvedCacheRoot ??= AttachStorage.ResolveRoot(cacheRoot, attempt);
|
|
var path = Connection(pid);
|
|
AttachStorage.ValidateMonoPath(path, "session and temporary state", ".bootstrap".Length + 33);
|
|
AttachStorage.ValidateMonoPath(Path.Combine(CacheRoot, "cache", "Snippet_" + new string('0', 64) + ".dll"), "compiled assembly");
|
|
var logicalConnection = AttachStorage.Connection(cacheRoot, pid);
|
|
AttachStorage.CheckStateAliases(logicalConnection, path, attempt);
|
|
using var processLock = await AttachStorage.Lock(path, cancellationToken);
|
|
var journal = path + ".bootstrap";
|
|
attempt.SetJournal(journal);
|
|
attempt.Step("connect");
|
|
if (File.Exists(path))
|
|
{
|
|
var recordedIdentity = JsonNode.Parse(File.ReadAllText(path))?["runtimeIdentity"]?.GetValue<string>();
|
|
if (recordedIdentity != RuntimeIdentity) throw new UnityTargetException("UnityRuntimeVersionMismatch", "The selected Editor has a different Attach runtime. Restart the Editor to upgrade; no injection or shutdown was sent.");
|
|
try
|
|
{
|
|
var current = await BridgeClient.Call(path, "metadata", cancellationToken: cancellationToken);
|
|
if (current["state"]?.GetValue<string>() != "completed")
|
|
throw new IOException("The bridge has not completed its main-thread metadata handshake.");
|
|
if (current["result"]?["asyncExecution"]?.GetValue<bool>() == true)
|
|
{
|
|
ValidateRuntime(current);
|
|
if (File.Exists(journal)) File.Delete(journal);
|
|
await RecordSelection(threadId, identity, current);
|
|
return current;
|
|
}
|
|
throw new UnityTargetException("UnityRuntimeVersionMismatch", "The existing Unity bridge does not support this runtime. Restart the Editor to upgrade; no bridge was stopped or injected.");
|
|
}
|
|
catch (Exception e) when (e is not UnityTargetException && (e is IOException or InvalidDataException or System.Net.Sockets.SocketException or InvalidOperationException or ArgumentException)) { }
|
|
}
|
|
if (File.Exists(path + ".lifecycle") && JsonNode.Parse(File.ReadAllText(path + ".lifecycle"))?["stage"]?.GetValue<int>() != 200)
|
|
{
|
|
attempt.Step("recovery-handshake");
|
|
return await WaitForHandshake(threadId, identity, target, path, journal, cancellationToken);
|
|
}
|
|
if (!File.Exists(NativeBootstrapPath)) throw new FileNotFoundException("Native bootstrap is not configured.", NativeBootstrapPath);
|
|
if (File.Exists(journal))
|
|
{
|
|
if (AttachAttempt.CanRetry(JsonNode.Parse(File.ReadAllText(journal)))) File.Delete(journal);
|
|
else throw new UnityTargetException("UnityAttachOutcomeUnknown", "Bootstrap outcome is unknown. No new bootstrap was sent; wait for its handshake or restart the isolated test session.");
|
|
}
|
|
attempt.Step("prepare-payload");
|
|
var data = Path.Combine(Path.GetDirectoryName(target.MainModule!.FileName)!, "Data");
|
|
var payloadSource = Path.Combine(CacheRoot, "payload", RuntimeIdentity);
|
|
Directory.CreateDirectory(payloadSource);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
|
var owner = typeof(UnityAttachService).Assembly;
|
|
foreach (var name in owner.GetManifestResourceNames().Where(n => n.EndsWith(".cs", StringComparison.Ordinal)))
|
|
{
|
|
using var reader = new StreamReader(owner.GetManifestResourceStream(name)!);
|
|
AttachStorage.WriteSource(Path.Combine(payloadSource, name), reader.ReadToEnd());
|
|
}
|
|
AttachStorage.WriteSource(Path.Combine(payloadSource, "RuntimeIdentity.cs"),
|
|
"namespace DotCraft.Unity { internal static class RuntimeIdentity { public const string Value = \"" + RuntimeIdentity
|
|
+ "\"; public const string Version = \"" + AttachStorage.ProductVersion + "\"; } }");
|
|
var references = Directory.GetFiles(Path.Combine(data, "Managed/UnityEngine"), "*.dll")
|
|
.Concat(new[] { "mscorlib.dll", "System.dll", "System.Core.dll" }.Select(n => Path.Combine(data, "MonoBleedingEdge/lib/mono/unityjit-win32", n)))
|
|
.Append(Path.Combine(data, "Managed/Newtonsoft.Json.dll"))
|
|
.Concat(Directory.GetFiles(Path.Combine(data, "MonoBleedingEdge/lib/mono/unityjit-win32/Facades"), "*.dll"));
|
|
var payload = TargetCompiler.Compile(payloadSource, references, Path.Combine(CacheRoot, "cache"));
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
NativeInjector.Inject(pid, NativeBootstrapPath, payload, path, attempt);
|
|
owned.TryAdd(identity, 0);
|
|
attempt.Step("handshake");
|
|
var connected = await WaitForHandshake(threadId, identity, target, path, journal, cancellationToken);
|
|
attempt.Step("connected");
|
|
return connected;
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
attempt?.Failed(error);
|
|
if (attempt != null && error is not OperationCanceledException)
|
|
throw attempt.DescribeFailure(error);
|
|
throw;
|
|
}
|
|
finally { gate.Release(); }
|
|
}
|
|
|
|
/// <summary>Reads live connection metadata without reconnecting.</summary>
|
|
public Task<JsonObject> Status(string threadId, CancellationToken cancellationToken = default)
|
|
{
|
|
var target = RequireTarget(threadId);
|
|
return BridgeClient.Call(Connection(target.Pid), "metadata", cancellationToken: cancellationToken);
|
|
}
|
|
|
|
/// <summary>Compiles for the target and starts one execution; failures are never replayed.</summary>
|
|
public async Task<JsonObject> Execute(
|
|
string threadId,
|
|
string code,
|
|
JsonObject? args,
|
|
bool runInBackground,
|
|
int yieldTimeMs,
|
|
CancellationToken cancellationToken = default,
|
|
Action? onDispatch = null)
|
|
{
|
|
var target = RequireTarget(threadId);
|
|
await Connect(threadId, target.Pid, cancellationToken);
|
|
var connection = Connection(target.Pid);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var prepared = await BridgeClient.Prepare(connection, code, CacheRoot, new AttachAttempt(target.Pid), cancellationToken);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var handle = new ExecutionHandle(threadId, target, prepared.Generation, connection);
|
|
PruneExecutions();
|
|
if (executions.Count >= 4096) throw new UnityTargetException("UnityExecutionCapacity", "This client has too many unresolved executions.");
|
|
executions[prepared.ExecutionId] = handle;
|
|
try
|
|
{
|
|
var started = await BridgeClient.Start(connection, prepared, args, cancellationToken, onDispatch);
|
|
if (IsTerminal(started)) return RecordResult(prepared.ExecutionId, started);
|
|
if (runInBackground)
|
|
{
|
|
try
|
|
{
|
|
return RecordResult(prepared.ExecutionId, await BridgeClient.Wait(connection, prepared.ExecutionId, prepared.Generation,
|
|
NormalizeWait(yieldTimeMs), terminate: false, cancellationToken: cancellationToken));
|
|
}
|
|
catch (Exception e) when (e is IOException or System.Net.Sockets.SocketException or TimeoutException)
|
|
{
|
|
return Unknown(prepared.ExecutionId, prepared.Generation);
|
|
}
|
|
}
|
|
|
|
while (true)
|
|
{
|
|
var result = await BridgeClient.Wait(connection, prepared.ExecutionId, prepared.Generation,
|
|
1000, terminate: false, cancellationToken: cancellationToken);
|
|
if (IsTerminal(result)) return RecordResult(prepared.ExecutionId, result);
|
|
}
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
try { await BridgeClient.Wait(connection, prepared.ExecutionId, prepared.Generation, 0, terminate: true, cancellationToken: CancellationToken.None); }
|
|
catch { }
|
|
throw;
|
|
}
|
|
catch (Exception e) when (e is IOException or System.Net.Sockets.SocketException or TimeoutException)
|
|
{
|
|
return Unknown(prepared.ExecutionId, prepared.Generation);
|
|
}
|
|
}
|
|
|
|
/// <summary>Waits for or cooperatively cancels an execution created by this task.</summary>
|
|
public async Task<JsonObject> Wait(
|
|
string threadId,
|
|
string executionId,
|
|
int yieldTimeMs,
|
|
bool terminate,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
PruneExecutions();
|
|
if (!executions.TryGetValue(executionId, out var handle)) return Lost(executionId, "");
|
|
if (!string.Equals(handle.ThreadId, threadId, StringComparison.Ordinal))
|
|
throw new UnityTargetException("UnityExecutionUnavailable", "The Unity execution does not belong to this task or is no longer available.");
|
|
|
|
try
|
|
{
|
|
using var process = GetUnityProcess(handle.Target.Pid);
|
|
if (process.StartTime.ToUniversalTime() != handle.Target.StartedUtc)
|
|
return RecordResult(executionId, Lost(executionId, handle.Generation));
|
|
if (!File.Exists(handle.ConnectionPath)
|
|
|| !string.Equals(BridgeClient.ReadGeneration(handle.ConnectionPath), handle.Generation, StringComparison.Ordinal))
|
|
return RecordResult(executionId, Lost(executionId, handle.Generation));
|
|
return RecordResult(executionId, await BridgeClient.Wait(handle.ConnectionPath, executionId, handle.Generation,
|
|
NormalizeWait(yieldTimeMs), terminate, cancellationToken));
|
|
}
|
|
catch (UnityTargetException) { return RecordResult(executionId, Lost(executionId, handle.Generation)); }
|
|
catch (InvalidDataException)
|
|
{
|
|
return RecordResult(executionId, Lost(executionId, handle.Generation));
|
|
}
|
|
catch (Exception e) when (e is IOException or System.Net.Sockets.SocketException or TimeoutException)
|
|
{
|
|
return Unknown(executionId, handle.Generation);
|
|
}
|
|
}
|
|
|
|
/// <summary>Releases this task selection without stopping other clients.</summary>
|
|
public async Task<JsonObject> Disconnect(string threadId, CancellationToken cancellationToken = default)
|
|
{
|
|
var target = RequireTarget(threadId);
|
|
selected.TryRemove(threadId, out _);
|
|
if (selected.Values.Contains(target))
|
|
return new JsonObject { ["state"] = "completed", ["result"] = "detached" };
|
|
var result = await BridgeClient.Call(Connection(target.Pid), "detach", clientId: clientId, cancellationToken: cancellationToken);
|
|
if (result["state"]?.GetValue<string>() == "completed") owned.TryRemove(target, out _);
|
|
return result;
|
|
}
|
|
|
|
public void ForgetSelection(string threadId) => selected.TryRemove(threadId, out _);
|
|
|
|
private static Process GetUnityProcess(int pid)
|
|
{
|
|
Process target;
|
|
try { target = Process.GetProcessById(pid); }
|
|
catch (ArgumentException) { throw new UnityTargetException("UnityTargetUnavailable", "The selected Unity Editor is no longer running. Call unity.list, then unity.connect with a PID."); }
|
|
try
|
|
{
|
|
if (target.HasExited || !target.ProcessName.Equals("Unity", StringComparison.OrdinalIgnoreCase))
|
|
throw new UnityTargetException("UnityTargetUnavailable", "The selected process is not a Unity Editor. Call unity.list, then unity.connect with a PID.");
|
|
return target;
|
|
}
|
|
catch
|
|
{
|
|
target.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private TargetIdentity RequireTarget(string threadId)
|
|
{
|
|
if (!selected.TryGetValue(threadId, out var selection))
|
|
throw new UnityTargetException("UnityTargetRequired", "No Unity Editor is connected for this task. Call unity.list, then unity.connect with a PID.");
|
|
try
|
|
{
|
|
using var process = GetUnityProcess(selection.Pid);
|
|
if (process.StartTime.ToUniversalTime() == selection.StartedUtc) return selection;
|
|
}
|
|
catch (UnityTargetException) { }
|
|
RemoveSelections(selection);
|
|
throw new UnityTargetException("UnityTargetUnavailable", "The selected Unity Editor is no longer running. Call unity.list, then unity.connect with a PID.");
|
|
}
|
|
|
|
private async Task RecordSelection(string threadId, TargetIdentity identity, JsonObject response)
|
|
{
|
|
if (response["state"]?.GetValue<string>() != "completed") return;
|
|
ValidateRuntime(response);
|
|
await BridgeClient.Call(Connection(identity.Pid), "lease", clientId: clientId);
|
|
selected[threadId] = identity;
|
|
owned.TryAdd(identity, 0);
|
|
}
|
|
|
|
private async Task<JsonObject> WaitForHandshake(
|
|
string threadId,
|
|
TargetIdentity identity,
|
|
Process target,
|
|
string path,
|
|
string journal,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var response = await AttachHandshake.WaitAsync(async token =>
|
|
{
|
|
target.Refresh();
|
|
if (target.HasExited)
|
|
throw new UnityTargetException("UnityTargetUnavailable", "The selected Unity Editor exited before completing its main-thread handshake.");
|
|
if (!File.Exists(path)) throw new IOException("Unity has not published its bridge metadata.");
|
|
var result = await BridgeClient.Call(path, "metadata", cancellationToken: token);
|
|
if (result["state"]?.GetValue<string>() != "completed")
|
|
throw new IOException("Unity has not completed its main-thread handshake.");
|
|
ValidateRuntime(result);
|
|
return result;
|
|
}, HandshakeTimeout, cancellationToken);
|
|
if (File.Exists(journal)) File.Delete(journal);
|
|
await RecordSelection(threadId, identity, response);
|
|
return response;
|
|
}
|
|
|
|
private void ValidateRuntime(JsonObject response)
|
|
{
|
|
if (response["result"]?["runtimeIdentity"]?.GetValue<string>() != RuntimeIdentity)
|
|
throw new UnityTargetException("UnityRuntimeVersionMismatch", "The running Unity bridge belongs to a different runtime build. Restart the Editor to upgrade; no injection or shutdown was sent.");
|
|
}
|
|
|
|
private void RemoveSelections(TargetIdentity identity)
|
|
{
|
|
foreach (var pair in selected.Where(pair => pair.Value == identity))
|
|
selected.TryRemove(pair);
|
|
}
|
|
|
|
/// <summary>Releases this client from its connected bridges without reconnecting during cleanup.</summary>
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await gate.WaitAsync();
|
|
try
|
|
{
|
|
if (disposed) return;
|
|
disposed = true;
|
|
await Task.WhenAll(owned.Keys.Select(async target =>
|
|
{
|
|
try { await BridgeClient.Call(Connection(target.Pid), "detach", clientId: clientId); }
|
|
catch (Exception e) when (e is IOException or InvalidDataException or InvalidOperationException
|
|
or ArgumentException or TimeoutException or System.Net.Sockets.SocketException) { }
|
|
}));
|
|
selected.Clear();
|
|
executions.Clear();
|
|
}
|
|
finally { gate.Release(); }
|
|
}
|
|
|
|
private readonly record struct TargetIdentity(int Pid, DateTime StartedUtc);
|
|
private sealed record ExecutionHandle(string ThreadId, TargetIdentity Target, string Generation, string ConnectionPath)
|
|
{
|
|
public DateTime? TerminalUtc { get; set; }
|
|
}
|
|
|
|
private JsonObject RecordResult(string executionId, JsonObject result)
|
|
{
|
|
if (IsTerminal(result) && executions.TryGetValue(executionId, out var handle)) handle.TerminalUtc ??= DateTime.UtcNow;
|
|
PruneExecutions();
|
|
return result;
|
|
}
|
|
|
|
private void PruneExecutions()
|
|
{
|
|
var terminal = executions.Where(p => p.Value.TerminalUtc != null).OrderBy(p => p.Value.TerminalUtc).ToArray();
|
|
foreach (var item in terminal.Take(Math.Max(0, terminal.Length - 1024)).Concat(terminal.Where(p => p.Value.TerminalUtc < DateTime.UtcNow.AddMinutes(-10))))
|
|
executions.TryRemove(item.Key, out _);
|
|
}
|
|
|
|
private static int NormalizeWait(int waitTimeMs) => Math.Clamp(waitTimeMs, 0, 30_000);
|
|
|
|
private static bool IsTerminal(JsonObject result) => result["state"]?.GetValue<string>() is "completed" or "failed" or "cancelled" or "lost";
|
|
|
|
private static JsonObject Lost(string executionId, string generation) => new()
|
|
{
|
|
["state"] = "lost",
|
|
["executionId"] = executionId,
|
|
["generation"] = generation
|
|
};
|
|
|
|
private static JsonObject Unknown(string executionId, string generation) => new()
|
|
{
|
|
["state"] = "unknown",
|
|
["executionId"] = executionId,
|
|
["generation"] = generation
|
|
};
|
|
}
|
|
|
|
/// <summary>A stable target or execution selection error.</summary>
|
|
public sealed class UnityTargetException(string code, string message) : InvalidOperationException(message)
|
|
{
|
|
/// <summary>The stable machine-readable error code.</summary>
|
|
public string Code { get; } = code;
|
|
}
|