diff --git a/CHANGELOG.md b/CHANGELOG.md index b514538..809a32f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- Long-running commands now finish faster. Test runs, package operations, profiler sampling, recording and `qa run-sequence` all advance one step per Editor update tick, and an idle Editor runs that loop slowly — measured at roughly six ticks per second, whether or not its window is in front. While one of those commands is in flight the bridge now keeps the Editor ticking at full rate and lets it settle back the moment the work finishes, which measured about 1.5× faster end to end (60 ticks: 9.4–10.4 s → 6.0–6.1 s on Unity 6000.3 / macOS). Nothing to turn on, and on an Editor version that does not expose the internal API this relies on, commands simply run at the old speed. +- Component values may now be written as JSON arrays: `"m_Center": [1, 2, 3]` alongside the existing `{"x": 1, "y": 2, "z": 3}`. Vector2/3/4, Vector2Int/Vector3Int, Quaternion, Rect, RectInt and Color all accept the short form, and a Color array may leave off alpha. +- Structured values that arrive quoted — `"[1,2,3]"` or `"{\"x\":1,\"y\":2,\"z\":3}"` instead of the JSON value itself — are now parsed instead of rejected. AI agents produce this shape often, and the previous error read as though the value were wrong rather than the quoting. Only strings that open a JSON object or array are re-read, so asset paths, object-reference handles, enum names and plain text are untouched, and a string that fails to parse still produces the original validation error. + +### Fixed +- An Editor whose IPC listener died is no longer advertised as reachable. The listener could stop accepting connections for good — an unexpected socket failure, or a first bind that never succeeded — while the instance kept publishing itself, so the CLI would route to it and fail to connect over and over. The bridge now watches its own listener and re-binds it in place within a few seconds; if it cannot be revived, the instance removes itself from the registry with an Editor console message telling you to restart, instead of staying on the list as a target that never answers. +- The IPC auth token is now compared in fixed time, so the check cannot leak the expected token through timing to other processes on the same machine. Authentication behavior is otherwise unchanged. + ## [0.5.2] - 2026-08-13 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 5b45d32..d399e42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,8 +44,9 @@ The repo is a single solution (`UnityCliBridge.sln`) split across four projects: **Bridge runtime (`unity-package/com.yhc509.unity-cli-bridge/Editor/`)** — Hosted in the Unity Editor. - `BridgeHost.cs` is the bootstrap and dispatcher: it registers the project in the instance registry, starts the IPC listener (Named Pipe on Windows / Unix socket on macOS+Linux), and routes commands to one of the `*CommandHandler` classes (`Asset`, `AssetCreate`, `Scene`, `Prefab`, `Material`, `Package`, `Qa`, `Record`, `Profile`, `Screenshot`, `ExecuteCode`, `Custom`). It runs in any main editor process including `-batchmode`; secondary processes (MPE / `-adb2` AssetImportWorker) are excluded. - `ClientDisconnectMonitor.cs` is the `.NET`-testable stream watcher that lets `BridgeHost` cancel queued requests when the CLI connection closes before dispatch. +- `EditorTickPump.cs` wraps `EditorApplication.update` subscription for deferred flows and forces `EditorApplication.SignalTick` (reflection) while any of them is subscribed — see the auto-tick convention below. - Scene/prefab patch logic is deliberately split across `SceneCommandHandler.Patching.cs` and `PrefabCommandHandler.Patching.cs` (partial classes) so the entry-point file stays small and the op-application code lives next to its inspector. -- `SerializedValueApplier.cs` (+ `.ComplexTypes.cs` partial) is the most fragile layer — it translates JSON values into `SerializedProperty.propertyPath` mutations with friendly-key fallback. Run `*-inspect --with-values` before patching to verify paths. +- `SerializedValueApplier.cs` (+ `.ComplexTypes.cs` partial) is the most fragile layer — it translates JSON values into `SerializedProperty.propertyPath` mutations with friendly-key fallback. Run `*-inspect --with-values` before patching to verify paths. `ApplyToken` is the single funnel for every value (top-level, nested, array element), so agent-input coercion belongs there and nowhere else. - `ExecuteValueSerializer.cs` serializes values returned from `execute` and custom commands with a safe whitelist and round-trip float/double formatting. - `AssetBackupTransaction.cs` wraps `FileBackupTransaction` with an `AssetDatabase.Refresh` discipline (always in `finally`) and is the rollback core for scene patch / prefab patch / asset overwrite. Backups land in `Library/com.yhc509.unity-cli-bridge/backups/` to stay outside `Assets/` and `AssetDatabase` scanning. - `PackageCommandHandler.cs` polls Unity Package Manager from `EditorApplication.update` (deferred dispatch) with a single-flight guard and a 300 s `PACKAGE_TIMEOUT` — never re-introduce blocking polls here. @@ -70,6 +71,10 @@ Tests live in `tests/UnityCli.Cli.Tests/` (xUnit, `.NET`-testable surface only). - **macOS paths:** Use real paths (`pwd -P`), not symlinks, for hashing and registry lookups. - **Instance primary identity:** Registry and CLI routing use canonical `projectRoot` first. The 12-character hash is only for socket/pipe names and user-input fallback; if a hash matches multiple instances, require a project path. - **No-`--project` routing:** With no `--project`, routing resolves CWD → pinned `activeProjectRoot` (set via `instances use`) → the single live instance. If two or more live instances remain and none is pinned, the command fails with a `CLI_USAGE` error listing candidates instead of silently picking one. Auto-promoted `activeProjectRoot` (most-recent live Editor) is not trusted for this fallback; only an explicit `instances use` pin is. +- **Auto-tick pump:** deferred flows (test/package/profile/record/qa run-sequence) must subscribe their `EditorApplication.update` poll through `EditorTickPump.Add/Remove`, never directly — a direct subscription silently opts that flow out of the pump. While at least one is subscribed the pump forces `EditorApplication.SignalTick` (bound once by reflection, throttled to `DefaultIntervalMs` = 16) and disarms when the set empties, so the tick state reverts on its own. The subscriber set holds the very delegates on `EditorApplication.update`, so a pump leak would be a poll leak; `Remove` is idempotent to match the defensive double-unsubscribes in the poll bodies. Measured effect on Unity 6000.3 / macOS: ~160 ms → ~100 ms per tick (≈1.55×), **the same focused or unfocused** — on that version focus itself barely changes the edit-mode update rate, so the pump is a general speed-up rather than an unfocused-only fix. `SignalTick` is internal API: a bind failure degrades to a no-op with one warning, never a throw. `BridgeHost.OnEditorUpdate` stays a direct subscription — routing it through the pump would pin the editor at full rate for the whole session. +- **Listener watchdog:** the accept loops have no retry of their own (the Unix loop ends permanently on any unexpected `AcceptAsync` failure; both loops give up if the first acquire fails), so `BridgeHost` runs `ListenerWatchdogPolicy` (`Runtime/Protocol/`, Unity-free and unit-tested) on every editor tick. Both loops must clear `_isListenerReady` in their `finally`, and every bind outcome must clear `_isListenerStarting` — those two flags are the entire input. The watchdog skips while a bind is in flight or the editor is compiling/updating (domain-reload teardown is not a fault), rebinds in place up to 5 times at 5 s intervals, re-publishes registry + token sidecar on recovery (deleting the old sidecar if the rebind landed on a different hash), and on exhaustion unregisters the instance so it never advertises as live while unreachable. +- **Agent value coercion:** a value that is a string, targets a property that cannot hold a raw string, and starts with `{` or `[` after trimming is re-parsed as JSON at the `ApplyToken` entry. Keep the guard exactly that narrow — asset paths, object-reference handles, enum names and char values never open with those characters, so they keep the original path, and a re-parse failure falls back to the original token so the existing validation error is preserved. Vector-family readers additionally accept a flat numeric array (`[1,2,3]`) next to the canonical member object; `Bounds`/`BoundsInt` deliberately do not (their nested `center`/`size` accept arrays instead). +- **Auth token comparison:** `AuthTokenComparison.FixedTimeEquals` (no early exit, length folded into the same accumulator) is the only comparison allowed on the token path. Hand-rolled rather than `CryptographicOperations.FixedTimeEquals` so the shared source compiles on Unity's runtime profile and .NET 9. - **Auth token storage:** Live IPC auth tokens are stored in per-instance 0600 sidecars (`/tokens/.token`), not in the shared registry. The CLI reads the sidecar during resolve/load, and `InstanceRecord.token` is a non-serialized in-memory field. The token sidecar is written as soon as the listener key is acquired, before a client can connect; the registry entry is only written on the first editor tick after the listener is ready. A live socket with no registry entry is therefore a real (if brief) state, and the CLI must treat it as a retryable `LIVE_UNAVAILABLE` rather than connecting with an empty token — do not remove that guard. Both the entry and the sidecar survive domain reloads and are removed only when the Editor exits; during a reload only the socket/listener is closed. - **Token sidecar is overwrite-if-different:** `EnsureTokenSidecar` replaces a stale sidecar left by a killed/crashed session; never restore the old "skip when file exists" behavior — it makes every CLI call fail `UNAUTHORIZED` for the whole next session. - **Headless editors are first-class:** the bridge starts in any main editor process (GUI or `-batchmode`); only secondary Unity processes (MPE / `-adb2` AssetImportWorker) are excluded. Recommended headless launch is `-batchmode` *without* `-nographics` so the GPU stays available. Commands whose catalog entry sets `requiresGraphics` fail with `HEADLESS_NO_GRAPHICS` when `SystemInfo.graphicsDeviceType == Null`. diff --git a/README.md b/README.md index 5ca81b5..d8d0e13 100644 --- a/README.md +++ b/README.md @@ -406,6 +406,7 @@ docs/ Generated CLI reference, specs - **Scene paths:** `/Root[0]/Child[0]` format with sibling indices. `/` is the virtual scene root. - **Inspect before patch:** Always `scene inspect --with-values` or `prefab inspect --with-values` before writing patch specs. - **Friendly component keys:** Common Rigidbody, Collider, Renderer, Light, and Camera patch keys are resolved to Unity `SerializedProperty.propertyPath` values. +- **Component value shapes:** Vector2/3/4, Vector2Int/Vector3Int, Quaternion, Rect, RectInt, and Color accept both the member object (`{"x":1,"y":2,"z":3}`) and the array shorthand (`[1,2,3]`); a Color array may omit alpha. A structured value that arrives quoted (`"[1,2,3]"`) is re-parsed, but send real JSON values — the fallback never applies to genuine string fields such as asset paths or enum names. - **set-node warnings:** Unrecognized keys now return warnings instead of silent success. ## Documentation diff --git a/tests/UnityCli.Cli.Tests/AuthTokenComparisonTests.cs b/tests/UnityCli.Cli.Tests/AuthTokenComparisonTests.cs new file mode 100644 index 0000000..84639b8 --- /dev/null +++ b/tests/UnityCli.Cli.Tests/AuthTokenComparisonTests.cs @@ -0,0 +1,58 @@ +using UnityCli.Protocol; + +namespace UnityCli.Cli.Tests; + +public sealed class AuthTokenComparisonTests +{ + [Fact] + public void FixedTimeEquals_WhenIdentical_ReturnsTrue() + { + string token = new string('a', 64); + + Assert.True(AuthTokenComparison.FixedTimeEquals(token, new string('a', 64))); + } + + [Fact] + public void FixedTimeEquals_WhenLastCharacterDiffers_ReturnsFalse() + { + string expected = new string('a', 64); + string candidate = expected.Substring(0, 63) + "b"; + + Assert.False(AuthTokenComparison.FixedTimeEquals(expected, candidate)); + } + + [Fact] + public void FixedTimeEquals_WhenFirstCharacterDiffers_ReturnsFalse() + { + string expected = new string('a', 64); + string candidate = "b" + expected.Substring(1); + + Assert.False(AuthTokenComparison.FixedTimeEquals(expected, candidate)); + } + + [Fact] + public void FixedTimeEquals_IsCaseSensitive() + { + Assert.False(AuthTokenComparison.FixedTimeEquals("abcdef", "ABCDEF")); + } + + [Theory] + [InlineData("token", "")] + [InlineData("", "token")] + [InlineData("", "")] + [InlineData("token", null)] + [InlineData(null, "token")] + [InlineData(null, null)] + public void FixedTimeEquals_WhenEitherSideIsMissing_ReturnsFalse(string? expected, string? candidate) + { + Assert.False(AuthTokenComparison.FixedTimeEquals(expected!, candidate!)); + } + + [Theory] + [InlineData("abcdef", "abcde")] + [InlineData("abcde", "abcdef")] + public void FixedTimeEquals_WhenPrefixMatchesButLengthDiffers_ReturnsFalse(string expected, string candidate) + { + Assert.False(AuthTokenComparison.FixedTimeEquals(expected, candidate)); + } +} diff --git a/tests/UnityCli.Cli.Tests/EditorTickPumpPolicyTests.cs b/tests/UnityCli.Cli.Tests/EditorTickPumpPolicyTests.cs new file mode 100644 index 0000000..c76f858 --- /dev/null +++ b/tests/UnityCli.Cli.Tests/EditorTickPumpPolicyTests.cs @@ -0,0 +1,111 @@ +using UnityCli.DocGen; + +namespace UnityCli.Cli.Tests; + +/// +/// The tick pump only speeds up work that is subscribed through it. These are source-level guards +/// for the wiring — the pump itself needs a live editor, so it cannot be exercised here. +/// +public sealed class EditorTickPumpPolicyTests +{ + public static TheoryData DeferredHandlerFiles() + { + var files = new TheoryData(); + files.Add("TestCommandHandler.cs"); + files.Add("TestCommandHandler.PlayMode.cs"); + files.Add("PackageCommandHandler.cs"); + files.Add("ProfileCommandHandler.cs"); + files.Add("ProfileCommandHandler.Capture.cs"); + files.Add("ProfileCommandHandler.Memory.cs"); + files.Add("QaCommandHandler.cs"); + files.Add("QaCommandHandler.Sequence.cs"); + files.Add("RecordCommandHandler.cs"); + return files; + } + + private static string ReadEditorSource(string fileName) + { + string repoRoot = RepositoryPaths.FindRepoRoot(AppContext.BaseDirectory); + return File.ReadAllText(Path.Combine( + repoRoot, + "unity-package", + "com.yhc509.unity-cli-bridge", + "Editor", + fileName)); + } + + [Theory] + [MemberData(nameof(DeferredHandlerFiles))] + public void DeferredHandlers_SubscribeThroughTheTickPump(string fileName) + { + string source = ReadEditorSource(fileName); + + // A direct subscription still works, it just silently opts that flow out of the pump and + // leaves it running at the unfocused editor's throttled tick rate. + Assert.DoesNotContain("EditorApplication.update +=", source); + Assert.DoesNotContain("EditorApplication.update -=", source); + Assert.Contains("EditorTickPump.", source); + } + + [Theory] + [MemberData(nameof(DeferredHandlerFiles))] + public void DeferredHandlers_ReleaseThePumpOnEveryPathThatSubscribes(string fileName) + { + string source = ReadEditorSource(fileName); + int adds = CountOccurrences(source, "EditorTickPump.Add("); + int removes = CountOccurrences(source, "EditorTickPump.Remove("); + + // Removes are idempotent and the poll bodies exit on several paths, so the useful + // invariant is that nothing subscribes without a matching teardown somewhere. + Assert.True(adds > 0, fileName + " should drive at least one deferred poll."); + Assert.True( + removes >= adds, + fileName + " subscribes " + adds + " poll(s) but only unsubscribes " + removes + " time(s)."); + } + + [Fact] + public void TickPump_BindsSignalTickReflectivelyAndDegradesWhenMissing() + { + string source = ReadEditorSource("EditorTickPump.cs"); + + Assert.Contains("\"SignalTick\"", source); + Assert.Contains("BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public", source); + Assert.Contains("Delegate.CreateDelegate(typeof(Action), method)", source); + + // An internal API that disappears must cost speed, not availability. + Assert.DoesNotContain("throw new", source); + } + + [Fact] + public void TickPump_RunsOnlyWhileDeferredWorkIsSubscribed() + { + string source = ReadEditorSource("EditorTickPump.cs"); + + Assert.Contains("_subscribers.Count > 0", source); + Assert.Contains("EditorApplication.update -= Pump;", source); + } + + [Fact] + public void BridgeHost_KeepsItsOwnUpdateHookOutOfThePump() + { + string source = ReadEditorSource("BridgeHost.cs"); + + // The host update hook is permanent; routing it through the pump would pin the editor at + // full tick rate for the entire editor session. + Assert.Contains("EditorApplication.update += OnEditorUpdate;", source); + Assert.DoesNotContain("EditorTickPump.Add(OnEditorUpdate)", source); + } + + private static int CountOccurrences(string source, string value) + { + int count = 0; + int index = source.IndexOf(value, StringComparison.Ordinal); + while (index >= 0) + { + count++; + index = source.IndexOf(value, index + value.Length, StringComparison.Ordinal); + } + + return count; + } +} diff --git a/tests/UnityCli.Cli.Tests/ListenerWatchdogPolicyTests.cs b/tests/UnityCli.Cli.Tests/ListenerWatchdogPolicyTests.cs new file mode 100644 index 0000000..0dfd551 --- /dev/null +++ b/tests/UnityCli.Cli.Tests/ListenerWatchdogPolicyTests.cs @@ -0,0 +1,129 @@ +using UnityCli.Protocol; + +namespace UnityCli.Cli.Tests; + +public sealed class ListenerWatchdogPolicyTests +{ + private const double Interval = 5.0; + private const int MaxAttempts = 3; + + private static ListenerWatchdogPolicy CreatePolicy() + { + return new ListenerWatchdogPolicy(Interval, MaxAttempts, 0.0); + } + + private static ListenerWatchdogDecision Healthy(ListenerWatchdogPolicy policy, double now) + { + return policy.Evaluate(isListenerReady: true, isListenerStarting: false, isEditorBusy: false, nowSeconds: now); + } + + private static ListenerWatchdogDecision Dead(ListenerWatchdogPolicy policy, double now) + { + return policy.Evaluate(isListenerReady: false, isListenerStarting: false, isEditorBusy: false, nowSeconds: now); + } + + [Fact] + public void Evaluate_WhenListenerHealthy_StaysQuiet() + { + ListenerWatchdogPolicy policy = CreatePolicy(); + + Assert.Equal(ListenerWatchdogDecision.None, Healthy(policy, 1.0)); + Assert.Equal(ListenerWatchdogDecision.None, Healthy(policy, 100.0)); + Assert.Equal(0, policy.RecoveryAttempts); + } + + [Fact] + public void Evaluate_WhenListenerDiesButIntervalHasNotElapsed_Waits() + { + ListenerWatchdogPolicy policy = CreatePolicy(); + + Assert.Equal(ListenerWatchdogDecision.None, Dead(policy, 4.9)); + } + + [Fact] + public void Evaluate_WhenListenerStaysDeadPastInterval_RequestsRestart() + { + ListenerWatchdogPolicy policy = CreatePolicy(); + + Assert.Equal(ListenerWatchdogDecision.Restart, Dead(policy, 5.0)); + Assert.Equal(1, policy.RecoveryAttempts); + } + + [Fact] + public void Evaluate_WhileBindIsInFlight_DoesNotRaceIt() + { + ListenerWatchdogPolicy policy = CreatePolicy(); + + Assert.Equal(ListenerWatchdogDecision.Restart, Dead(policy, 5.0)); + + // The restart is running: not ready yet, but not dead either. + for (double now = 5.1; now <= 30.0; now += 1.0) + { + Assert.Equal( + ListenerWatchdogDecision.None, + policy.Evaluate(isListenerReady: false, isListenerStarting: true, isEditorBusy: false, nowSeconds: now)); + } + + Assert.Equal(1, policy.RecoveryAttempts); + } + + [Fact] + public void Evaluate_WhileEditorIsBusy_DoesNotFightDomainReload() + { + ListenerWatchdogPolicy policy = CreatePolicy(); + + for (double now = 1.0; now <= 60.0; now += 1.0) + { + Assert.Equal( + ListenerWatchdogDecision.None, + policy.Evaluate(isListenerReady: false, isListenerStarting: false, isEditorBusy: true, nowSeconds: now)); + } + + Assert.Equal(0, policy.RecoveryAttempts); + } + + [Fact] + public void Evaluate_WhenListenerComesBack_ReportsRecoveredOnceAndResetsAttempts() + { + ListenerWatchdogPolicy policy = CreatePolicy(); + + Assert.Equal(ListenerWatchdogDecision.Restart, Dead(policy, 5.0)); + Assert.Equal(ListenerWatchdogDecision.Recovered, Healthy(policy, 6.0)); + Assert.Equal(0, policy.RecoveryAttempts); + Assert.Equal(ListenerWatchdogDecision.None, Healthy(policy, 7.0)); + } + + [Fact] + public void Evaluate_AfterRecovery_AllowsTheFullAttemptBudgetAgain() + { + ListenerWatchdogPolicy policy = CreatePolicy(); + + Assert.Equal(ListenerWatchdogDecision.Restart, Dead(policy, 5.0)); + Assert.Equal(ListenerWatchdogDecision.Recovered, Healthy(policy, 6.0)); + + for (int attempt = 1; attempt <= MaxAttempts; attempt++) + { + Assert.Equal(ListenerWatchdogDecision.Restart, Dead(policy, 6.0 + (attempt * Interval))); + } + + Assert.False(policy.HasAbandoned); + } + + [Fact] + public void Evaluate_WhenRecoveryAttemptsAreExhausted_AbandonsExactlyOnce() + { + ListenerWatchdogPolicy policy = CreatePolicy(); + + for (int attempt = 1; attempt <= MaxAttempts; attempt++) + { + Assert.Equal(ListenerWatchdogDecision.Restart, Dead(policy, attempt * Interval)); + } + + Assert.Equal(ListenerWatchdogDecision.Abandon, Dead(policy, (MaxAttempts + 1) * Interval)); + Assert.True(policy.HasAbandoned); + + // Never advertises again, and never re-reports the abandon. + Assert.Equal(ListenerWatchdogDecision.None, Dead(policy, (MaxAttempts + 5) * Interval)); + Assert.Equal(ListenerWatchdogDecision.None, Healthy(policy, (MaxAttempts + 6) * Interval)); + } +} diff --git a/tests/UnityCli.Cli.Tests/ListenerWatchdogWiringTests.cs b/tests/UnityCli.Cli.Tests/ListenerWatchdogWiringTests.cs new file mode 100644 index 0000000..cd5b979 --- /dev/null +++ b/tests/UnityCli.Cli.Tests/ListenerWatchdogWiringTests.cs @@ -0,0 +1,100 @@ +using UnityCli.DocGen; + +namespace UnityCli.Cli.Tests; + +/// +/// Source-level guards for the listener watchdog wiring in BridgeHost. The policy itself is +/// covered by ; what cannot be unit-tested is that the +/// host actually reports listener death and stops advertising when recovery fails. +/// +public sealed class ListenerWatchdogWiringTests +{ + private static string ReadBridgeHost() + { + string repoRoot = RepositoryPaths.FindRepoRoot(AppContext.BaseDirectory); + return File.ReadAllText(Path.Combine( + repoRoot, + "unity-package", + "com.yhc509.unity-cli-bridge", + "Editor", + "BridgeHost.cs")); + } + + [Fact] + public void BridgeHost_RunsTheWatchdogFromTheEditorUpdateHook() + { + string source = ReadBridgeHost(); + + Assert.Contains("RunListenerWatchdog();", source); + Assert.Contains("new ListenerWatchdogPolicy(", source); + Assert.Contains("EditorApplication.isCompiling || EditorApplication.isUpdating", source); + } + + [Fact] + public void BridgeHost_ClearsListenerReadinessWhenAnAcceptLoopEnds() + { + string source = ReadBridgeHost(); + + // Both accept loops must report death, otherwise the heartbeat keeps advertising an + // instance the CLI can resolve but never connect to. + Assert.True( + CountOccurrences(source, "_isListenerReady = false;") >= 2, + "Each accept loop should clear listener readiness in its finally block."); + } + + [Fact] + public void BridgeHost_NeverRacesAnInFlightBind() + { + string source = ReadBridgeHost(); + + Assert.Contains("_isListenerStarting = true;", source); + Assert.True( + CountOccurrences(source, "_isListenerStarting = false;") >= 4, + "Every bind outcome — success, cancellation, failure — must clear the starting flag."); + } + + [Fact] + public void BridgeHost_StopsAdvertisingWhenRecoveryIsAbandoned() + { + string source = ReadBridgeHost(); + + Assert.Contains("case ListenerWatchdogDecision.Abandon:", source); + Assert.Contains("private void AbandonListener()", source); + + int abandonIndex = source.IndexOf("private void AbandonListener()", StringComparison.Ordinal); + string abandonBody = source.Substring(abandonIndex); + Assert.Contains("UnregisterInstance();", abandonBody); + + // The heartbeat branch has to honour the abandoned state. + Assert.Contains("if (_isListenerAbandoned)", source); + } + + [Fact] + public void BridgeHost_RepublishesRegistryStateAfterARebind() + { + string source = ReadBridgeHost(); + + int recoveredIndex = source.IndexOf("private void OnListenerRecovered()", StringComparison.Ordinal); + Assert.True(recoveredIndex >= 0, "The watchdog needs a recovery handler."); + + string recoveredBody = source.Substring(recoveredIndex); + Assert.Contains("WriteTokenSidecarSafely();", recoveredBody); + Assert.Contains("RegisterInstance();", recoveredBody); + + // A rebind can land on a different hash; the old sidecar would otherwise keep a live token. + Assert.Contains("DeleteTokenSidecar(_registryFilePath, _projectHashBeforeRestart)", recoveredBody); + } + + private static int CountOccurrences(string source, string value) + { + int count = 0; + int index = source.IndexOf(value, StringComparison.Ordinal); + while (index >= 0) + { + count++; + index = source.IndexOf(value, index + value.Length, StringComparison.Ordinal); + } + + return count; + } +} diff --git a/tests/UnityCli.Cli.Tests/PackageDeferredPolicyTests.cs b/tests/UnityCli.Cli.Tests/PackageDeferredPolicyTests.cs index 14697e6..69a5a7d 100644 --- a/tests/UnityCli.Cli.Tests/PackageDeferredPolicyTests.cs +++ b/tests/UnityCli.Cli.Tests/PackageDeferredPolicyTests.cs @@ -77,7 +77,7 @@ public void EditorPackageDispatch_UsesDeferredRouteWithoutSleepPolling() Assert.DoesNotContain("Thread.Sleep", packageHandler); Assert.DoesNotContain("WaitForRequest", packageHandler); - Assert.Contains("EditorApplication.update += Poll;", packageHandler); + Assert.Contains("EditorTickPump.Add(Poll);", packageHandler); Assert.Contains("ProtocolConstants.ErrorPackageTimeout", packageHandler); } @@ -124,7 +124,7 @@ public void EditorPackageDispatch_KeepsActiveRequestAfterTimeoutUntilUnityReques Assert.Contains("private static void StartBackgroundActiveRequestTracker(Request request)", packageHandler); Assert.Contains("new ActiveRequestCompletionTracker(", packageHandler); Assert.Contains("() => request.IsCompleted", packageHandler); - Assert.Contains("() => EditorApplication.update -= BackgroundPoll", packageHandler); + Assert.Contains("() => EditorTickPump.Remove(BackgroundPoll)", packageHandler); } private static string ReadPackageHandler() diff --git a/tools/skills/unity-cli-operator/SKILL.md b/tools/skills/unity-cli-operator/SKILL.md index ec356cd..d739da1 100644 --- a/tools/skills/unity-cli-operator/SKILL.md +++ b/tools/skills/unity-cli-operator/SKILL.md @@ -161,6 +161,8 @@ unity-cli does not have dedicated script create/delete commands. Use this combin **Friendly key mapping:** Rigidbody, Collider, Renderer, Light, and Camera values accept common keys like `mass`, `damping`, `isTrigger`, `materials[0]`, `shadowStrength`, and `fieldOfView`, which resolve to Unity `SerializedProperty.propertyPath` values. If a key is not found, use `list-components` then `inspect --with-values`. +**Value 형식:** Vector2/3/4, Vector2Int/Vector3Int, Quaternion, Rect, RectInt, Color는 member object(`{"x":1,"y":2,"z":3}`)와 배열 축약(`[1,2,3]`) 둘 다 받는다. Color 배열은 alpha를 생략하면 1이다. 값이 문자열로 감싸여 들어와도(`"[1,2,3]"`, `"{\"x\":1}"`) 브릿지가 다시 파싱하지만, **정상 JSON 값으로 보내는 것이 원칙**이다 — 문자열 감싸기는 asset path나 enum 이름 같은 진짜 문자열 필드에서는 풀리지 않는다. + ## Convenience Commands — 편의 명령 우선 사용 원칙 아래 작업에는 `scene patch --spec-json` 대신 전용 편의 명령을 우선 사용한다. 호출 횟수와 토큰을 절약할 수 있다. diff --git a/unity-package/com.yhc509.unity-cli-bridge/CHANGELOG.md b/unity-package/com.yhc509.unity-cli-bridge/CHANGELOG.md index b514538..809a32f 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/CHANGELOG.md +++ b/unity-package/com.yhc509.unity-cli-bridge/CHANGELOG.md @@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- Long-running commands now finish faster. Test runs, package operations, profiler sampling, recording and `qa run-sequence` all advance one step per Editor update tick, and an idle Editor runs that loop slowly — measured at roughly six ticks per second, whether or not its window is in front. While one of those commands is in flight the bridge now keeps the Editor ticking at full rate and lets it settle back the moment the work finishes, which measured about 1.5× faster end to end (60 ticks: 9.4–10.4 s → 6.0–6.1 s on Unity 6000.3 / macOS). Nothing to turn on, and on an Editor version that does not expose the internal API this relies on, commands simply run at the old speed. +- Component values may now be written as JSON arrays: `"m_Center": [1, 2, 3]` alongside the existing `{"x": 1, "y": 2, "z": 3}`. Vector2/3/4, Vector2Int/Vector3Int, Quaternion, Rect, RectInt and Color all accept the short form, and a Color array may leave off alpha. +- Structured values that arrive quoted — `"[1,2,3]"` or `"{\"x\":1,\"y\":2,\"z\":3}"` instead of the JSON value itself — are now parsed instead of rejected. AI agents produce this shape often, and the previous error read as though the value were wrong rather than the quoting. Only strings that open a JSON object or array are re-read, so asset paths, object-reference handles, enum names and plain text are untouched, and a string that fails to parse still produces the original validation error. + +### Fixed +- An Editor whose IPC listener died is no longer advertised as reachable. The listener could stop accepting connections for good — an unexpected socket failure, or a first bind that never succeeded — while the instance kept publishing itself, so the CLI would route to it and fail to connect over and over. The bridge now watches its own listener and re-binds it in place within a few seconds; if it cannot be revived, the instance removes itself from the registry with an Editor console message telling you to restart, instead of staying on the list as a target that never answers. +- The IPC auth token is now compared in fixed time, so the check cannot leak the expected token through timing to other processes on the same machine. Authentication behavior is otherwise unchanged. + ## [0.5.2] - 2026-08-13 ### Added diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/BridgeHost.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/BridgeHost.cs index d54cbf6..cd0e241 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/BridgeHost.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/BridgeHost.cs @@ -71,11 +71,17 @@ internal sealed class BridgeHost : IDisposable private bool _isDisposed; private bool _isInstanceRegistered; private volatile bool _isListenerReady; + private volatile bool _isListenerStarting; + private bool _isListenerAbandoned; + private ListenerWatchdogPolicy? _listenerWatchdog; + private string _projectHashBeforeRestart = string.Empty; private string _projectHash = string.Empty; private string _pipeName = string.Empty; private const string AuthTokenSessionStateKey = "UnityCliBridge.AuthToken"; private const int AuthTokenByteLength = 32; private const int ListenerAcquireMaxAttempts = 16; + private const double ListenerWatchdogIntervalSeconds = 5.0; + private const int ListenerWatchdogMaxRecoveryAttempts = 5; private const int NamedPipeMaxServerInstances = 2; private const int NamedPipeProbeTimeoutMilliseconds = 50; @@ -115,6 +121,10 @@ public void Start() _isStarted = true; ConsoleLogBuffer.Start(); _lastHeartbeatTime = EditorApplication.timeSinceStartup; + _listenerWatchdog = new ListenerWatchdogPolicy( + ListenerWatchdogIntervalSeconds, + ListenerWatchdogMaxRecoveryAttempts, + EditorApplication.timeSinceStartup); StartListener(); EditorApplication.update += OnEditorUpdate; @@ -191,6 +201,8 @@ private void Dispose(bool unregisterInstance) private void StartListener() { + // Read by the watchdog: a bind in flight must never be mistaken for a dead listener. + _isListenerStarting = true; #if !UNITY_5_3_OR_NEWER || UNITY_6000_0_OR_NEWER // Unity 6+ / non-Unity: use raw Unix domain sockets for non-Windows. if (Path.DirectorySeparatorChar != '\\') @@ -405,14 +417,18 @@ private async Task RunNamedPipeLoopAsync(CancellationToken cancellationToken) } catch (OperationCanceledException) { + _isListenerStarting = false; return; } catch (Exception exception) { + // Leaves the listener visibly dead; the watchdog retries the acquire. + _isListenerStarting = false; ReportBackgroundException("named pipe listener", exception); return; } + _isListenerStarting = false; NamedPipeServerStream? server = initialServer; try { @@ -445,6 +461,7 @@ private async Task RunNamedPipeLoopAsync(CancellationToken cancellationToken) } finally { + _isListenerReady = false; server?.Dispose(); DisposeNamedPipeOwnershipLock(); } @@ -538,16 +555,20 @@ private async Task RunUnixSocketLoopAsync(CancellationToken cancellationToken) } catch (OperationCanceledException) { + _isListenerStarting = false; return; } catch (Exception exception) { + // Leaves the listener visibly dead; the watchdog retries the acquire. + _isListenerStarting = false; ReportBackgroundException("unix socket listener", exception); return; } _unixListener = listener; _isListenerReady = true; + _isListenerStarting = false; try { @@ -566,6 +587,10 @@ private async Task RunUnixSocketLoopAsync(CancellationToken cancellationToken) } finally { + // The accept loop is gone: an unexpected AcceptAsync failure ends it for good, so + // the listener must stop reading as ready or the registry heartbeat would keep + // advertising an instance nothing can connect to. The watchdog rebinds from here. + _isListenerReady = false; if (ReferenceEquals(_unixListener, listener)) { _unixListener = null; @@ -729,8 +754,9 @@ private async Task HandleStreamClientAsync(Stream stream, CancellationToken canc return; } - if (string.IsNullOrEmpty(command.token) - || !string.Equals(command.token, _authToken, StringComparison.Ordinal)) + // Fixed-time compare: this is the bridge's only authentication gate, and an + // early-exit comparison is observable to other local processes. + if (!AuthTokenComparison.FixedTimeEquals(_authToken, command.token)) { var error = ResponseEnvelope.Failure( command.requestId, @@ -838,7 +864,13 @@ private void OnEditorUpdate() return; } - if (!_isInstanceRegistered && _isListenerReady) + RunListenerWatchdog(); + + if (_isListenerAbandoned) + { + // Nothing can connect any more; heartbeating would only re-advertise a zombie. + } + else if (!_isInstanceRegistered && _isListenerReady) { WriteTokenSidecarSafely(); RegisterInstance(); @@ -889,6 +921,85 @@ private void OnEditorUpdate() } } + // The accept loops have no retry of their own: the Unix loop ends permanently on any + // unexpected AcceptAsync failure, and both loops give up if the very first listener + // acquire fails. Without this the registry would keep advertising a live instance the + // CLI cannot connect to. + private void RunListenerWatchdog() + { + ListenerWatchdogPolicy? watchdog = _listenerWatchdog; + if (watchdog == null) + { + return; + } + + ListenerWatchdogDecision decision = watchdog.Evaluate( + _isListenerReady, + _isListenerStarting, + EditorApplication.isCompiling || EditorApplication.isUpdating, + EditorApplication.timeSinceStartup); + + switch (decision) + { + case ListenerWatchdogDecision.Restart: + _projectHashBeforeRestart = _projectHash; + UnityEngine.Debug.LogWarning(string.Format( + "Unity CLI bridge listener가 죽어 재바인딩을 시도합니다 ({0}/{1}).", + watchdog.RecoveryAttempts, + ListenerWatchdogMaxRecoveryAttempts)); + StartListener(); + break; + case ListenerWatchdogDecision.Recovered: + OnListenerRecovered(); + break; + case ListenerWatchdogDecision.Abandon: + AbandonListener(); + break; + } + } + + private void OnListenerRecovered() + { + // A rebind can settle on a different hash if the old socket name is still taken; the + // sidecar is keyed by hash, so the old one would linger with a live token in it. + if (!string.IsNullOrEmpty(_projectHashBeforeRestart) + && !string.Equals(_projectHashBeforeRestart, _projectHash, StringComparison.Ordinal)) + { + try + { + InstanceRegistryFile.DeleteTokenSidecar(_registryFilePath, _projectHashBeforeRestart); + } + catch (Exception exception) + { + UnityEngine.Debug.LogWarning(string.Format( + "Unity CLI bridge 이전 auth token 정리 실패: {0}", + exception.Message)); + } + } + + _projectHashBeforeRestart = string.Empty; + WriteTokenSidecarSafely(); + RegisterInstance(); + _isInstanceRegistered = true; + _lastHeartbeatTime = EditorApplication.timeSinceStartup; + UnityEngine.Debug.Log("Unity CLI bridge listener 재바인딩 완료: " + _pipeName); + } + + private void AbandonListener() + { + _isListenerAbandoned = true; + UnityEngine.Debug.LogError(string.Format( + "Unity CLI bridge listener를 {0}회 재바인딩했지만 복구하지 못했습니다. " + + "연결되지 않는 인스턴스를 광고하지 않도록 레지스트리에서 제거합니다. Unity Editor를 재시작하세요.", + ListenerWatchdogMaxRecoveryAttempts)); + + if (_isInstanceRegistered) + { + UnregisterInstance(); + _isInstanceRegistered = false; + } + } + private void StartDeferredTestRequest(PendingRequest pending) { CommandEnvelope command = pending.Command; diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/EditorTickPump.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/EditorTickPump.cs new file mode 100644 index 0000000..4d52c1d --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/EditorTickPump.cs @@ -0,0 +1,182 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Reflection; +using UnityEditor; + +namespace UnityCliBridge.Bridge.Editor +{ + /// + /// Keeps the editor ticking at full rate while deferred bridge work is in flight. + /// + /// An idle editor runs EditorApplication.update at roughly 6 ticks/second, and every + /// deferred command in the bridge — test runs, package operations, profiler sampling, + /// recording, qa run-sequence — advances one step per tick. Forcing the internal + /// EditorApplication.SignalTick() schedules the next tick immediately, which measured + /// ~1.55x faster on Unity 6000.3.10f1 / macOS (60 ticks: 9.4–10.4s → 6.0–6.1s), focused and + /// unfocused alike. + /// + /// Activation is automatic and scoped: deferred flows subscribe their poll through + /// instead of EditorApplication.update directly, and the pump runs + /// only while at least one of them is subscribed. Because the subscriber set holds the very + /// delegates that are on EditorApplication.update, the pump cannot outlive the work — + /// a leaked pump lease would mean a leaked poll subscription, which the flows already cannot + /// afford. is idempotent, matching the defensive double-unsubscribes the + /// poll bodies already do. + /// + /// SignalTick is an internal API. If reflection cannot bind it the pump degrades to a + /// no-op with a single warning: deferred work still completes, just at the throttled rate. + /// Statics do not survive a domain reload, but neither do the poll subscriptions — flows that + /// restore themselves after a reload re-subscribe through here and re-arm the pump. + /// + internal static class EditorTickPump + { + /// + /// Minimum milliseconds between forced ticks (~60Hz). 0 would signal on every update. + /// Measured on Unity 6000.3.10f1 / macOS the editor update loop settles at ~100ms per tick + /// under the pump either way, so the throttle costs nothing there and caps the forced rate + /// on setups whose loop can run faster. + /// + internal const int DefaultIntervalMs = 16; + + private static readonly HashSet _subscribers = + new HashSet(); + private static readonly Stopwatch _sinceLastSignal = new Stopwatch(); + private static Action? _signalTick; + private static bool _hasResolvedSignalTick; + private static bool _isPumping; + + /// Minimum spacing between forced ticks; see . + internal static int IntervalMs { get; set; } = DefaultIntervalMs; + + /// True while the pump is subscribed to the editor update loop. + internal static bool IsPumping + { + get { return _isPumping; } + } + + /// Deferred polls currently holding the pump open. + internal static int SubscriberCount + { + get { return _subscribers.Count; } + } + + /// + /// Subscribe a deferred poll to the editor update loop and arm the pump. + /// + internal static void Add(EditorApplication.CallbackFunction? callback) + { + if (callback == null) + { + return; + } + + EditorApplication.update += callback; + if (_subscribers.Add(callback)) + { + SyncPumpState(); + } + } + + /// + /// Unsubscribe a deferred poll and disarm the pump once nothing is left. Safe to call for + /// a callback that was never added, or twice for the same one. + /// + internal static void Remove(EditorApplication.CallbackFunction? callback) + { + if (callback == null) + { + return; + } + + EditorApplication.update -= callback; + if (_subscribers.Remove(callback)) + { + SyncPumpState(); + } + } + + private static void SyncPumpState() + { + bool shouldPump = _subscribers.Count > 0 && ResolveSignalTick() != null; + if (shouldPump == _isPumping) + { + return; + } + + _isPumping = shouldPump; + if (shouldPump) + { + _sinceLastSignal.Restart(); + EditorApplication.update += Pump; + return; + } + + _sinceLastSignal.Stop(); + EditorApplication.update -= Pump; + } + + private static void Pump() + { + Action? signalTick = _signalTick; + if (signalTick == null) + { + return; + } + + if (IntervalMs > 0 && _sinceLastSignal.ElapsedMilliseconds < IntervalMs) + { + return; + } + + _sinceLastSignal.Restart(); + try + { + signalTick(); + } + catch (Exception exception) + { + // Losing the pump only costs speed, so drop it rather than log once per tick. + _signalTick = null; + SyncPumpState(); + UnityEngine.Debug.LogWarning( + "Unity CLI bridge editor tick pump 중단 (EditorApplication.SignalTick 호출 실패): " + exception.Message); + } + } + + private static Action? ResolveSignalTick() + { + if (_hasResolvedSignalTick) + { + return _signalTick; + } + + _hasResolvedSignalTick = true; + try + { + MethodInfo? method = typeof(EditorApplication).GetMethod( + "SignalTick", + BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); + if (method == null) + { + UnityEngine.Debug.LogWarning( + "Unity CLI bridge editor tick pump 비활성화: EditorApplication.SignalTick을 찾지 못했습니다. " + + "unfocused 에디터에서 deferred 명령이 느리게 진행될 수 있습니다."); + return null; + } + + _signalTick = (Action)Delegate.CreateDelegate(typeof(Action), method); + } + catch (Exception exception) + { + UnityEngine.Debug.LogWarning( + "Unity CLI bridge editor tick pump 비활성화 (EditorApplication.SignalTick 바인딩 실패): " + + exception.Message); + _signalTick = null; + } + + return _signalTick; + } + } +} diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/EditorTickPump.cs.meta b/unity-package/com.yhc509.unity-cli-bridge/Editor/EditorTickPump.cs.meta new file mode 100644 index 0000000..36a4d20 --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/EditorTickPump.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e5410d7ba60654f37b1a0acb7d0ed306 \ No newline at end of file diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/PackageCommandHandler.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/PackageCommandHandler.cs index 96cb65f..9244136 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/PackageCommandHandler.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/PackageCommandHandler.cs @@ -253,7 +253,7 @@ void StopPolling() } isFinished = true; - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); stopwatch.Stop(); } @@ -327,7 +327,7 @@ void Poll() } } - EditorApplication.update += Poll; + EditorTickPump.Add(Poll); Poll(); } @@ -342,10 +342,10 @@ void BackgroundPoll() tracker = new ActiveRequestCompletionTracker( () => request.IsCompleted, - () => EditorApplication.update -= BackgroundPoll, + () => EditorTickPump.Remove(BackgroundPoll), EndActiveRequest); - EditorApplication.update += BackgroundPoll; + EditorTickPump.Add(BackgroundPoll); BackgroundPoll(); } diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.Capture.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.Capture.cs index 71b427b..2707e9d 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.Capture.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.Capture.cs @@ -218,7 +218,7 @@ void Poll() { if (_phase != CapturePhase.Capturing) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); return; } @@ -230,7 +230,7 @@ void Poll() bool playExited = !EditorApplication.isPlaying; if (framesHit || durationHit || safetyHit || playExited) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); try { BeginProcessing(); @@ -243,7 +243,7 @@ void Poll() } } - EditorApplication.update += Poll; + EditorTickPump.Add(Poll); } private static void BeginProcessing() @@ -274,7 +274,7 @@ private static void BeginProcessing() _walkMarkers = new Dictionary(512, StringComparer.Ordinal); _walkGpuMs = new List(); - EditorApplication.update += WalkStep; + EditorTickPump.Add(WalkStep); } private static void WalkStep() @@ -291,13 +291,13 @@ private static void WalkStep() if (_walkFrame > _walkLastFrame) { - EditorApplication.update -= WalkStep; + EditorTickPump.Remove(WalkStep); FinishProcessing("Completed"); } } catch (Exception ex) { - EditorApplication.update -= WalkStep; + EditorTickPump.Remove(WalkStep); Debug.LogError("[UCB] profile walk failed: " + ex); try { diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.Memory.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.Memory.cs index b9ebe9e..3d3c21a 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.Memory.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.Memory.cs @@ -78,7 +78,7 @@ void Poll() { if (completion.Task.IsCompleted) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); DisposeAll(); return; } @@ -88,7 +88,7 @@ void Poll() ticks++; if (stopwatch.Elapsed.TotalSeconds >= ProtocolConstants.ProfileStatsTimeoutSeconds) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); DisposeAll(); completion.TrySetResult(ResponseEnvelope.Failure( requestId, @@ -106,7 +106,7 @@ void Poll() return; } - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); var counters = new List(); var samples = new List(frames); foreach ((ProfileCounterSpec spec, ProfilerRecorder recorder) in recorders) @@ -156,13 +156,13 @@ void Poll() } catch (Exception exception) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); DisposeAll(); completion.TrySetResult(CreateFailureResponse(requestId, projectHash, exception, stopwatch.ElapsedMilliseconds)); } } - EditorApplication.update += Poll; + EditorTickPump.Add(Poll); } private static void WriteMemorySidecar(ProfileMemoryPayload report) @@ -262,7 +262,7 @@ void Watchdog() { if (finished) { - EditorApplication.update -= Watchdog; + EditorTickPump.Remove(Watchdog); return; } @@ -271,7 +271,7 @@ void Watchdog() return; } - EditorApplication.update -= Watchdog; + EditorTickPump.Remove(Watchdog); Finish(() => ResponseEnvelope.Failure( requestId, projectHash, @@ -282,7 +282,7 @@ void Watchdog() ProtocolConstants.TransportLive)); } - EditorApplication.update += Watchdog; + EditorTickPump.Add(Watchdog); try { @@ -291,7 +291,7 @@ void Watchdog() snapshotPath, (resultPath, success) => { - EditorApplication.update -= Watchdog; + EditorTickPump.Remove(Watchdog); if (!success) { Finish(() => ResponseEnvelope.Failure( @@ -330,7 +330,7 @@ void Watchdog() } catch (Exception exception) { - EditorApplication.update -= Watchdog; + EditorTickPump.Remove(Watchdog); Finish(() => CreateFailureResponse(requestId, projectHash, exception, stopwatch.ElapsedMilliseconds)); } } diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.cs index cd4c811..368e669 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/ProfileCommandHandler.cs @@ -206,7 +206,7 @@ void Poll() { if (completion.Task.IsCompleted) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); DisposeAll(); return; } @@ -216,7 +216,7 @@ void Poll() ticks++; if (stopwatch.Elapsed.TotalSeconds >= ProtocolConstants.ProfileStatsTimeoutSeconds) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); DisposeAll(); completion.TrySetResult(ResponseEnvelope.Failure( requestId, @@ -234,7 +234,7 @@ void Poll() return; } - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); var counters = new List(); var samples = new List(frames); foreach ((ProfileCounterSpec spec, ProfilerRecorder recorder) in recorders) @@ -287,13 +287,13 @@ void Poll() } catch (Exception exception) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); DisposeAll(); completion.TrySetResult(CreateFailureResponse(requestId, projectHash, exception, stopwatch.ElapsedMilliseconds)); } } - EditorApplication.update += Poll; + EditorTickPump.Add(Poll); } private static string GetRequestId(TaskCompletionSource completion) diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/QaCommandHandler.Sequence.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/QaCommandHandler.Sequence.cs index 2f4642c..eb47bec 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/QaCommandHandler.Sequence.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/QaCommandHandler.Sequence.cs @@ -65,7 +65,7 @@ void Poll() { if (completion.Task.IsCompleted) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); AbortActiveSwipe(); StopSequenceRecordingIfNeeded(); StopSequenceProfileIfNeeded(); @@ -152,7 +152,7 @@ void Poll() void CompleteSuccess() { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); AbortActiveSwipe(); stopwatch.Stop(); string finalRecordingPath = StopSequenceRecordingIfNeeded(); @@ -175,7 +175,7 @@ void FailTimeout( List unmet, List snapshot) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); AbortActiveSwipe(); stopwatch.Stop(); string finalRecordingPath = StopSequenceRecordingIfNeeded(); @@ -201,7 +201,7 @@ void FailTimeout( void CompleteFailure(Exception exception) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); AbortActiveSwipe(); stopwatch.Stop(); StopSequenceRecordingIfNeeded(); @@ -274,7 +274,7 @@ void AbortActiveSwipe() } } - EditorApplication.update += Poll; + EditorTickPump.Add(Poll); Poll(); } diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/QaCommandHandler.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/QaCommandHandler.cs index f4c3dc1..df50955 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/QaCommandHandler.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/QaCommandHandler.cs @@ -929,7 +929,7 @@ void Poll() { if (completion.Task.IsCompleted) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); return; } @@ -952,7 +952,7 @@ void Poll() if (elapsedMs >= timeoutMs) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); stopwatch.Stop(); completion.TrySetResult(ResponseEnvelope.Failure( requestId, @@ -974,19 +974,19 @@ void Poll() void CompleteSuccess(QaWaitUntilPayload payload) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); stopwatch.Stop(); completion.TrySetResult(CreateSuccessResponse(requestId, projectHash, payload, stopwatch.ElapsedMilliseconds)); } void CompleteFailure(Exception exception) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); stopwatch.Stop(); completion.TrySetResult(CreateFailureResponse(requestId, projectHash, exception, stopwatch.ElapsedMilliseconds)); } - EditorApplication.update += Poll; + EditorTickPump.Add(Poll); Poll(); } @@ -1010,7 +1010,7 @@ void Poll() { if (completion.Task.IsCompleted) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); swipe.Abort(); return; } @@ -1021,7 +1021,7 @@ void Poll() if (swipe.Advance()) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); stopwatch.Stop(); completion.TrySetResult(CreateSuccessResponse( requestId, @@ -1035,14 +1035,14 @@ void Poll() } catch (Exception exception) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); swipe.Abort(); stopwatch.Stop(); completion.TrySetResult(CreateFailureResponse(requestId, projectHash, exception, stopwatch.ElapsedMilliseconds)); } } - EditorApplication.update += Poll; + EditorTickPump.Add(Poll); Poll(); } #endif diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/RecordCommandHandler.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/RecordCommandHandler.cs index c67df9f..6eb8257 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/RecordCommandHandler.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/RecordCommandHandler.cs @@ -416,7 +416,7 @@ void Poll() { if (!HasActiveRecording()) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); return; } @@ -426,7 +426,7 @@ void Poll() bool playExited = !EditorApplication.isPlaying; if (durationHit || safetyHit || playExited) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); try { FinalizeAndBuildResult("Completed"); @@ -438,7 +438,7 @@ void Poll() } } - EditorApplication.update += Poll; + EditorTickPump.Add(Poll); } private static string SidecarPath(string recordingId) diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/SerializedValueApplier.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/SerializedValueApplier.cs index 839427b..e36c6d7 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/SerializedValueApplier.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/SerializedValueApplier.cs @@ -197,6 +197,8 @@ private static string BuildUnsupportedSerializedPropertyTypeMessage(SerializedPr private static void ApplyToken(SerializedProperty property, JToken token, string propertyPath) { + token = CoerceStringEncodedJson(property, token); + if (property.isArray && property.propertyType != SerializedPropertyType.String) { ApplyArray(property, token, propertyPath); @@ -289,6 +291,71 @@ private static void ApplyToken(SerializedProperty property, JToken token, string } } + /// + /// Accept a structured value that arrived as a JSON-encoded string. + /// + /// Agents routinely send "[1,2,3]" for a Vector3 or "{\"m_Mass\":0.17}" for a + /// nested object — the value is right, the quoting is not, and strict token typing rejects + /// it with an error that reads like the field itself was wrong. The guard is deliberately + /// narrow: only a string token, only where the target cannot hold a raw string, and only + /// when the trimmed text opens a JSON object or array. Asset paths, object-reference + /// handles, enum names and char values never start with '{' or '[' and so keep taking the + /// original path — as does any string that fails to parse, which preserves the existing + /// validation message. + /// + private static JToken CoerceStringEncodedJson(SerializedProperty property, JToken token) + { + if (token == null || token.Type != JTokenType.String || AcceptsRawStringToken(property)) + { + return token; + } + + string value = token.Value(); + if (string.IsNullOrWhiteSpace(value)) + { + return token; + } + + string trimmed = value.TrimStart(); + if (trimmed[0] != '{' && trimmed[0] != '[') + { + return token; + } + + try + { + return JToken.Parse(value); + } + catch (Newtonsoft.Json.JsonException) + { + return token; + } + } + + private static bool AcceptsRawStringToken(SerializedProperty property) + { + if (property.isArray && property.propertyType == SerializedPropertyType.String) + { + return true; + } + + switch (property.propertyType) + { + case SerializedPropertyType.String: + case SerializedPropertyType.Character: + case SerializedPropertyType.Enum: + case SerializedPropertyType.Hash128: + case SerializedPropertyType.Integer: + case SerializedPropertyType.Boolean: + case SerializedPropertyType.Float: + case SerializedPropertyType.LayerMask: + case SerializedPropertyType.ArraySize: + return true; + default: + return false; + } + } + private static void ApplyArray(SerializedProperty property, JToken token, string propertyPath) { if (token.Type != JTokenType.Array) @@ -777,6 +844,79 @@ private static JObject ReadObject(JToken token, string propertyPath) throw new CommandFailureException("PREFAB_FIELD_INVALID", "object 값이 필요합니다: " + propertyPath); } + /// + /// Read a fixed-arity numeric tuple written as a JSON array — [1,2,3] for a Vector3, + /// [1,0,0,1] for a Color. The member-object form stays the canonical one (it is what + /// inspect emits); this is the shorthand agents reach for, and it is unambiguous for the + /// component types below. Entries past are optional and + /// keep the caller's defaults. + /// + private static float[] ReadFloatTuple( + JArray array, + string propertyPath, + int requiredCount, + float[] defaults) + { + if (array.Count < requiredCount || array.Count > defaults.Length) + { + throw new CommandFailureException( + "PREFAB_FIELD_INVALID", + "배열 값은 숫자 " + + (requiredCount == defaults.Length + ? requiredCount.ToString() + : requiredCount + "~" + defaults.Length) + + "개여야 합니다: " + propertyPath); + } + + var values = (float[])defaults.Clone(); + for (int index = 0; index < array.Count; index++) + { + JToken element = array[index]; + if (element.Type != JTokenType.Integer && element.Type != JTokenType.Float) + { + throw new CommandFailureException( + "PREFAB_FIELD_INVALID", + "숫자 값이 필요합니다: " + propertyPath + "[" + index + "]"); + } + + values[index] = element.Value(); + } + + return values; + } + + private static int[] ReadIntTuple(JArray array, string propertyPath, int count) + { + if (array.Count != count) + { + throw new CommandFailureException( + "PREFAB_FIELD_INVALID", + "배열 값은 정수 " + count + "개여야 합니다: " + propertyPath); + } + + var values = new int[count]; + for (int index = 0; index < count; index++) + { + JToken element = array[index]; + if (element.Type != JTokenType.Integer) + { + throw new CommandFailureException( + "PREFAB_FIELD_INVALID", + "정수 값이 필요합니다: " + propertyPath + "[" + index + "]"); + } + + values[index] = element.Value(); + } + + return values; + } + + private static bool TryReadTupleArray(JToken token, out JArray array) + { + array = token as JArray; + return array != null; + } + private static float ReadFloatMember(JObject obj, string memberName, string propertyPath, float fallback = 0f) { JToken member = obj[memberName]; @@ -811,6 +951,12 @@ private static int ReadIntMember(JObject obj, string memberName, string property private static Color ReadColor(JToken token, string propertyPath) { + if (TryReadTupleArray(token, out JArray array)) + { + float[] values = ReadFloatTuple(array, propertyPath, 3, new[] { 0f, 0f, 0f, 1f }); + return new Color(values[0], values[1], values[2], values[3]); + } + JObject obj = ReadObject(token, propertyPath); return new Color( ReadFloatMember(obj, "r", propertyPath), @@ -821,6 +967,12 @@ private static Color ReadColor(JToken token, string propertyPath) private static Vector2 ReadVector2(JToken token, string propertyPath) { + if (TryReadTupleArray(token, out JArray array)) + { + float[] values = ReadFloatTuple(array, propertyPath, 2, new[] { 0f, 0f }); + return new Vector2(values[0], values[1]); + } + JObject obj = ReadObject(token, propertyPath); return new Vector2( ReadFloatMember(obj, "x", propertyPath), @@ -829,6 +981,12 @@ private static Vector2 ReadVector2(JToken token, string propertyPath) private static Vector3 ReadVector3(JToken token, string propertyPath) { + if (TryReadTupleArray(token, out JArray array)) + { + float[] values = ReadFloatTuple(array, propertyPath, 3, new[] { 0f, 0f, 0f }); + return new Vector3(values[0], values[1], values[2]); + } + JObject obj = ReadObject(token, propertyPath); return new Vector3( ReadFloatMember(obj, "x", propertyPath), @@ -838,6 +996,12 @@ private static Vector3 ReadVector3(JToken token, string propertyPath) private static Vector4 ReadVector4(JToken token, string propertyPath) { + if (TryReadTupleArray(token, out JArray array)) + { + float[] values = ReadFloatTuple(array, propertyPath, 4, new[] { 0f, 0f, 0f, 0f }); + return new Vector4(values[0], values[1], values[2], values[3]); + } + JObject obj = ReadObject(token, propertyPath); return new Vector4( ReadFloatMember(obj, "x", propertyPath), @@ -848,6 +1012,12 @@ private static Vector4 ReadVector4(JToken token, string propertyPath) private static Rect ReadRect(JToken token, string propertyPath) { + if (TryReadTupleArray(token, out JArray array)) + { + float[] values = ReadFloatTuple(array, propertyPath, 4, new[] { 0f, 0f, 0f, 0f }); + return new Rect(values[0], values[1], values[2], values[3]); + } + JObject obj = ReadObject(token, propertyPath); return new Rect( ReadFloatMember(obj, "x", propertyPath), @@ -866,6 +1036,12 @@ private static Bounds ReadBounds(JToken token, string propertyPath) private static Quaternion ReadQuaternion(JToken token, string propertyPath) { + if (TryReadTupleArray(token, out JArray array)) + { + float[] values = ReadFloatTuple(array, propertyPath, 4, new[] { 0f, 0f, 0f, 0f }); + return new Quaternion(values[0], values[1], values[2], values[3]); + } + JObject obj = ReadObject(token, propertyPath); return new Quaternion( ReadFloatMember(obj, "x", propertyPath), @@ -876,6 +1052,12 @@ private static Quaternion ReadQuaternion(JToken token, string propertyPath) private static Vector2Int ReadVector2Int(JToken token, string propertyPath) { + if (TryReadTupleArray(token, out JArray array)) + { + int[] values = ReadIntTuple(array, propertyPath, 2); + return new Vector2Int(values[0], values[1]); + } + JObject obj = ReadObject(token, propertyPath); return new Vector2Int( ReadIntMember(obj, "x", propertyPath), @@ -884,6 +1066,12 @@ private static Vector2Int ReadVector2Int(JToken token, string propertyPath) private static Vector3Int ReadVector3Int(JToken token, string propertyPath) { + if (TryReadTupleArray(token, out JArray array)) + { + int[] values = ReadIntTuple(array, propertyPath, 3); + return new Vector3Int(values[0], values[1], values[2]); + } + JObject obj = ReadObject(token, propertyPath); return new Vector3Int( ReadIntMember(obj, "x", propertyPath), @@ -893,6 +1081,12 @@ private static Vector3Int ReadVector3Int(JToken token, string propertyPath) private static RectInt ReadRectInt(JToken token, string propertyPath) { + if (TryReadTupleArray(token, out JArray array)) + { + int[] values = ReadIntTuple(array, propertyPath, 4); + return new RectInt(values[0], values[1], values[2], values[3]); + } + JObject obj = ReadObject(token, propertyPath); return new RectInt( ReadIntMember(obj, "x", propertyPath), diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/TestCommandHandler.PlayMode.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/TestCommandHandler.PlayMode.cs index 7f7e691..377ecc1 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/TestCommandHandler.PlayMode.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/TestCommandHandler.PlayMode.cs @@ -337,7 +337,7 @@ void RespondToPendingStart() _playModeWatchdog = Poll; _playModePendingStartResponder = completion == null ? null : (Action)RespondToPendingStart; - EditorApplication.update += _playModeWatchdog; + EditorTickPump.Add(_playModeWatchdog); Poll(); } @@ -421,7 +421,7 @@ private static void StopPlayModeWatchdog() return; } - EditorApplication.update -= _playModeWatchdog; + EditorTickPump.Remove(_playModeWatchdog); _playModeWatchdog = null; } diff --git a/unity-package/com.yhc509.unity-cli-bridge/Editor/TestCommandHandler.cs b/unity-package/com.yhc509.unity-cli-bridge/Editor/TestCommandHandler.cs index 5e65788..79dbba5 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/Editor/TestCommandHandler.cs +++ b/unity-package/com.yhc509.unity-cli-bridge/Editor/TestCommandHandler.cs @@ -169,7 +169,7 @@ void Cleanup() { if (pollRegistered) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); pollRegistered = false; } @@ -279,7 +279,7 @@ void RequestList(TestMode mode, string modeLabel, List targetEntr try { - EditorApplication.update += Poll; + EditorTickPump.Add(Poll); pollRegistered = true; if (includeEditMode) @@ -365,7 +365,7 @@ void Cleanup() { if (pollRegistered) { - EditorApplication.update -= Poll; + EditorTickPump.Remove(Poll); pollRegistered = false; } @@ -418,7 +418,7 @@ void Poll() try { api.RetrieveTestList(mode, adaptor => root = adaptor); - EditorApplication.update += Poll; + EditorTickPump.Add(Poll); pollRegistered = true; Poll(); } @@ -1020,7 +1020,7 @@ void Poll() } _editModeRestoreWatchdog = Poll; - EditorApplication.update += _editModeRestoreWatchdog; + EditorTickPump.Add(_editModeRestoreWatchdog); Poll(); } @@ -1031,7 +1031,7 @@ private static void StopRestoredEditModeWatchdog() return; } - EditorApplication.update -= _editModeRestoreWatchdog; + EditorTickPump.Remove(_editModeRestoreWatchdog); _editModeRestoreWatchdog = null; } diff --git a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/AuthTokenComparison.cs b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/AuthTokenComparison.cs new file mode 100644 index 0000000..268b970 --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/AuthTokenComparison.cs @@ -0,0 +1,36 @@ +namespace UnityCli.Protocol +{ + /// + /// Fixed-time equality for the IPC auth token. + /// + /// The transport is an owner-only local socket / named pipe, so the practical risk from a + /// timing side channel is low — but the bridge's token check is the only authentication + /// gate it has, and a length-prefixed early-exit comparison is observable to any other + /// local process. Hand-rolled instead of CryptographicOperations.FixedTimeEquals so the + /// same source compiles on Unity's runtime profile and on .NET 9. + /// + internal static class AuthTokenComparison + { + /// + /// Compare two tokens without an early exit. Length differences are folded into the + /// same accumulator instead of short-circuiting; an empty or missing token is always + /// a mismatch. + /// + internal static bool FixedTimeEquals(string expected, string candidate) + { + if (string.IsNullOrEmpty(expected) || string.IsNullOrEmpty(candidate)) + { + return false; + } + + int difference = expected.Length ^ candidate.Length; + int comparedLength = expected.Length < candidate.Length ? expected.Length : candidate.Length; + for (int index = 0; index < comparedLength; index++) + { + difference |= expected[index] ^ candidate[index]; + } + + return difference == 0; + } + } +} diff --git a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/AuthTokenComparison.cs.meta b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/AuthTokenComparison.cs.meta new file mode 100644 index 0000000..13e5e6b --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/AuthTokenComparison.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 63bb9918693f24ce19d04b78f3cfaee6 \ No newline at end of file diff --git a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ListenerWatchdogPolicy.cs b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ListenerWatchdogPolicy.cs new file mode 100644 index 0000000..19f9406 --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ListenerWatchdogPolicy.cs @@ -0,0 +1,108 @@ +namespace UnityCli.Protocol +{ + /// + /// What the listener watchdog wants the host to do on this tick. + /// + internal enum ListenerWatchdogDecision + { + /// Nothing to do — healthy, throttled, or already given up. + None, + + /// The listener came back after a recovery attempt; re-publish the registry entry. + Recovered, + + /// The listener is dead; try to bind it again in place. + Restart, + + /// Recovery attempts are exhausted; stop advertising this instance as live. + Abandon, + } + + /// + /// Decision logic for the bridge's listener watchdog, kept free of Unity types so it is + /// unit-testable. The accept loops can die on an unexpected exception without any retry, + /// while the registry heartbeat keeps advertising the instance — a zombie the CLI routes to + /// and then fails to connect to. This policy drives a bounded in-place rebind and, once the + /// attempts are spent, tells the host to stop advertising instead. + /// + internal sealed class ListenerWatchdogPolicy + { + private readonly double _checkIntervalSeconds; + private readonly int _maxRecoveryAttempts; + private double _lastCheckSeconds; + private int _recoveryAttempts; + private bool _hasAbandoned; + + internal ListenerWatchdogPolicy(double checkIntervalSeconds, int maxRecoveryAttempts, double startedAtSeconds) + { + _checkIntervalSeconds = checkIntervalSeconds; + _maxRecoveryAttempts = maxRecoveryAttempts; + _lastCheckSeconds = startedAtSeconds; + } + + /// Recovery attempts spent so far; reset once the listener is healthy again. + internal int RecoveryAttempts + { + get { return _recoveryAttempts; } + } + + /// True once the policy has stopped trying to recover. + internal bool HasAbandoned + { + get { return _hasAbandoned; } + } + + /// The listener is bound and accepting. + /// A bind attempt is still in flight — never race it. + /// Compiling/updating; the listener teardown is expected then. + /// Monotonic-ish clock, same source across calls. + internal ListenerWatchdogDecision Evaluate( + bool isListenerReady, + bool isListenerStarting, + bool isEditorBusy, + double nowSeconds) + { + if (_hasAbandoned) + { + // The host has already stopped advertising this instance; there is nothing left to + // report, and a listener cannot come back because nothing is retrying it. + return ListenerWatchdogDecision.None; + } + + if (isListenerReady) + { + _lastCheckSeconds = nowSeconds; + if (_recoveryAttempts == 0) + { + return ListenerWatchdogDecision.None; + } + + _recoveryAttempts = 0; + return ListenerWatchdogDecision.Recovered; + } + + if (isListenerStarting || isEditorBusy) + { + // A bind in flight or a domain reload in progress resets the clock: the interval + // measures time spent visibly dead, not time spent waiting for a legitimate start. + _lastCheckSeconds = nowSeconds; + return ListenerWatchdogDecision.None; + } + + if (nowSeconds - _lastCheckSeconds < _checkIntervalSeconds) + { + return ListenerWatchdogDecision.None; + } + + _lastCheckSeconds = nowSeconds; + if (_recoveryAttempts >= _maxRecoveryAttempts) + { + _hasAbandoned = true; + return ListenerWatchdogDecision.Abandon; + } + + _recoveryAttempts++; + return ListenerWatchdogDecision.Restart; + } + } +} diff --git a/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ListenerWatchdogPolicy.cs.meta b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ListenerWatchdogPolicy.cs.meta new file mode 100644 index 0000000..19354f6 --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Runtime/Protocol/ListenerWatchdogPolicy.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f8d04951e77c14298b3857684534ea2c \ No newline at end of file diff --git a/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/SKILL.md b/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/SKILL.md index b47c1a5..6e86588 100644 --- a/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/SKILL.md +++ b/unity-package/com.yhc509.unity-cli-bridge/SkillTemplates~/SKILL.md @@ -138,6 +138,8 @@ unity-cli does not have dedicated script create/delete commands. Use this combin **Friendly key mapping:** Rigidbody, Collider, Renderer, Light, and Camera values accept common keys like `mass`, `damping`, `isTrigger`, `materials[0]`, `shadowStrength`, and `fieldOfView`, which resolve to Unity `SerializedProperty.propertyPath` values. If a key is not found, use `list-components` then `inspect --with-values`. +**Value 형식:** Vector2/3/4, Vector2Int/Vector3Int, Quaternion, Rect, RectInt, Color는 member object(`{"x":1,"y":2,"z":3}`)와 배열 축약(`[1,2,3]`) 둘 다 받는다. Color 배열은 alpha를 생략하면 1이다. 값이 문자열로 감싸여 들어와도(`"[1,2,3]"`, `"{\"x\":1}"`) 브릿지가 다시 파싱하지만, **정상 JSON 값으로 보내는 것이 원칙**이다 — 문자열 감싸기는 asset path나 enum 이름 같은 진짜 문자열 필드에서는 풀리지 않는다. + ## Convenience Commands — 편의 명령 우선 사용 원칙 아래 작업에는 `scene patch --spec-json` 대신 전용 편의 명령을 우선 사용한다. 호출 횟수와 토큰을 절약할 수 있다. diff --git a/unity-package/com.yhc509.unity-cli-bridge/Tests~/Editor/SerializedValueApplierTests.cs b/unity-package/com.yhc509.unity-cli-bridge/Tests~/Editor/SerializedValueApplierTests.cs new file mode 100644 index 0000000..08e704a --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Tests~/Editor/SerializedValueApplierTests.cs @@ -0,0 +1,150 @@ +#nullable enable +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEngine; + +namespace UnityCliBridge.Bridge.Editor.Tests +{ + public sealed class SerializedValueApplierTests + { + private GameObject? _gameObject; + private SerializedValueProbe _probe = null!; + + [SetUp] + public void SetUp() + { + _gameObject = new GameObject("SerializedValueApplierProbe"); + _probe = _gameObject.AddComponent(); + } + + [TearDown] + public void TearDown() + { + if (_gameObject != null) + { + Object.DestroyImmediate(_gameObject); + _gameObject = null; + } + } + + private void Apply(string valuesJson) + { + SerializedValueApplier.Apply(_probe, JObject.Parse(valuesJson)); + } + + [Test] + public void Apply_WhenVector3IsAnArray_UsesComponentOrder() + { + Apply("{\"vector3Value\":[1,2,3]}"); + + Assert.That(_probe.vector3Value, Is.EqualTo(new Vector3(1f, 2f, 3f))); + } + + [Test] + public void Apply_WhenVector3IsAStringEncodedArray_ParsesItAnyway() + { + Apply("{\"vector3Value\":\"[1,2,3]\"}"); + + Assert.That(_probe.vector3Value, Is.EqualTo(new Vector3(1f, 2f, 3f))); + } + + [Test] + public void Apply_WhenVector3IsAStringEncodedObject_ParsesItAnyway() + { + Apply("{\"vector3Value\":\"{\\\"x\\\":1,\\\"y\\\":2,\\\"z\\\":3}\"}"); + + Assert.That(_probe.vector3Value, Is.EqualTo(new Vector3(1f, 2f, 3f))); + } + + [Test] + public void Apply_WhenVector3IsAnObject_StillWorks() + { + Apply("{\"vector3Value\":{\"x\":1,\"y\":2,\"z\":3}}"); + + Assert.That(_probe.vector3Value, Is.EqualTo(new Vector3(1f, 2f, 3f))); + } + + [Test] + public void Apply_WhenTupleShorthandIsUsed_CoversTheVectorFamily() + { + Apply("{\"vector2Value\":[1,2],\"vector4Value\":[1,2,3,4],\"vector3IntValue\":[1,2,3]," + + "\"quaternionValue\":[0,0,0,1],\"rectValue\":[1,2,3,4]}"); + + Assert.That(_probe.vector2Value, Is.EqualTo(new Vector2(1f, 2f))); + Assert.That(_probe.vector4Value, Is.EqualTo(new Vector4(1f, 2f, 3f, 4f))); + Assert.That(_probe.vector3IntValue, Is.EqualTo(new Vector3Int(1, 2, 3))); + Assert.That(_probe.quaternionValue, Is.EqualTo(new Quaternion(0f, 0f, 0f, 1f))); + Assert.That(_probe.rectValue, Is.EqualTo(new Rect(1f, 2f, 3f, 4f))); + } + + [Test] + public void Apply_WhenColorArrayOmitsAlpha_DefaultsToOpaque() + { + Apply("{\"colorValue\":[1,0,0]}"); + + Assert.That(_probe.colorValue, Is.EqualTo(new Color(1f, 0f, 0f, 1f))); + } + + [Test] + public void Apply_WhenNestedVectorsAreArrays_AppliesThroughTheParentObject() + { + Apply("{\"boundsValue\":{\"center\":[1,2,3],\"size\":[4,5,6]}}"); + + Assert.That(_probe.boundsValue.center, Is.EqualTo(new Vector3(1f, 2f, 3f))); + Assert.That(_probe.boundsValue.size, Is.EqualTo(new Vector3(4f, 5f, 6f))); + } + + [Test] + public void Apply_WhenTupleArityIsWrong_Fails() + { + CommandFailureException failure = Assert.Throws( + () => Apply("{\"vector3Value\":[1,2]}"))!; + + Assert.That(failure.Message, Does.Contain("vector3Value")); + } + + [Test] + public void Apply_WhenStringLooksLikeJsonButTargetIsAString_LeavesItAlone() + { + Apply("{\"stringValue\":\"[not,really,json]\"}"); + + Assert.That(_probe.stringValue, Is.EqualTo("[not,really,json]")); + } + + [Test] + public void Apply_WhenStringIsValidJsonButTargetIsAString_LeavesItAlone() + { + Apply("{\"stringValue\":\"{\\\"x\\\":1}\"}"); + + Assert.That(_probe.stringValue, Is.EqualTo("{\"x\":1}")); + } + + [Test] + public void Apply_WhenStringEncodedJsonIsMalformed_KeepsTheOriginalValidationError() + { + CommandFailureException failure = Assert.Throws( + () => Apply("{\"vector3Value\":\"[1,2,\"}"))!; + + Assert.That(failure.ErrorCode, Is.EqualTo("PREFAB_FIELD_INVALID")); + Assert.That(failure.Message, Does.Contain("object 값이 필요합니다")); + } + + [Test] + public void Apply_WhenArrayFieldIsAStringEncodedArray_ParsesItAnyway() + { + Apply("{\"intArrayValue\":\"[1,2,3]\"}"); + + Assert.That(_probe.intArrayValue, Is.EqualTo(new[] { 1, 2, 3 })); + } + + [Test] + public void Apply_WhenPrimitivesAndEnumsAreUsed_BehaviorIsUnchanged() + { + Apply("{\"floatValue\":1.5,\"intValue\":7,\"enumValue\":\"Running\"}"); + + Assert.That(_probe.floatValue, Is.EqualTo(1.5f)); + Assert.That(_probe.intValue, Is.EqualTo(7)); + Assert.That(_probe.enumValue, Is.EqualTo(SerializedValueProbe.ProbeMode.Running)); + } + } +} diff --git a/unity-package/com.yhc509.unity-cli-bridge/Tests~/Editor/SerializedValueProbe.cs b/unity-package/com.yhc509.unity-cli-bridge/Tests~/Editor/SerializedValueProbe.cs new file mode 100644 index 0000000..b66d4dc --- /dev/null +++ b/unity-package/com.yhc509.unity-cli-bridge/Tests~/Editor/SerializedValueProbe.cs @@ -0,0 +1,35 @@ +#nullable enable +using System; +using UnityEngine; + +namespace UnityCliBridge.Bridge.Editor.Tests +{ + /// + /// Component with one serialized field per shape has to + /// translate. Kept as a real MonoBehaviour (rather than borrowing a built-in component) so the + /// tests can cover every branch without depending on engine field names. + /// + public sealed class SerializedValueProbe : MonoBehaviour + { + public enum ProbeMode + { + Idle, + Running, + Stopped, + } + + public Vector2 vector2Value; + public Vector3 vector3Value; + public Vector4 vector4Value; + public Vector3Int vector3IntValue; + public Quaternion quaternionValue; + public Rect rectValue; + public Color colorValue = Color.black; + public Bounds boundsValue; + public string stringValue = string.Empty; + public float floatValue; + public int intValue; + public ProbeMode enumValue; + public int[] intArrayValue = Array.Empty(); + } +}