From 3c265d4c2109b23f9dc9e36d6c3e11e00b552769 Mon Sep 17 00:00:00 2001 From: niqibiao Date: Fri, 24 Jul 2026 15:22:56 -0400 Subject: [PATCH] add protocol-v2 at-most-once operations --- Docs~/UnityCliPluginSync_zh.md | 191 ++ Docs~/UnityCliPluginSync_zh.md.meta | 7 + .../csharpconsole_core/response_parser.py | 43 +- .../csharpconsole_core/transport_http.py | 110 +- .../console-client/repl/direct_launch.py | 42 +- .../Commands/Core/CommandDispatcher.cs | 16 + .../Commands/Core/CommandResponseFactory.cs | 12 + .../Commands/Handlers/EditorCommandActions.cs | 27 +- .../Commands/Routing/BatchEndpointHandler.cs | 17 +- .../Service/Commands/Routing/CommandRouter.cs | 12 +- Runtime/Service/ConsoleHttpService.cs | 2636 +++++++++++++++-- Runtime/Service/ConsoleServiceConfig.cs | 2 +- .../Service/Contracts/EnvelopeContracts.cs | 1 + Runtime/Service/Contracts/HealthContracts.cs | 13 + .../Service/Contracts/InvocationContracts.cs | 44 + .../Contracts/InvocationContracts.cs.meta | 11 + .../ConsoleHttpServiceDependencies.cs | 86 +- .../Service/Internal/InvocationCoordinator.cs | 1433 +++++++++ .../Internal/InvocationCoordinator.cs.meta | 11 + .../Internal/MainThreadRequestRunner.cs | 156 +- 20 files changed, 4508 insertions(+), 362 deletions(-) create mode 100644 Docs~/UnityCliPluginSync_zh.md create mode 100644 Docs~/UnityCliPluginSync_zh.md.meta create mode 100644 Runtime/Service/Contracts/InvocationContracts.cs create mode 100644 Runtime/Service/Contracts/InvocationContracts.cs.meta create mode 100644 Runtime/Service/Internal/InvocationCoordinator.cs create mode 100644 Runtime/Service/Internal/InvocationCoordinator.cs.meta diff --git a/Docs~/UnityCliPluginSync_zh.md b/Docs~/UnityCliPluginSync_zh.md new file mode 100644 index 0000000..3db4d06 --- /dev/null +++ b/Docs~/UnityCliPluginSync_zh.md @@ -0,0 +1,191 @@ +# unity-cli-plugin 协议同步记录 + +## 2026-07-24:HTTP protocol v2 invocation 去重与诊断字段 + +本次将 `ConsoleServiceConfig.ProtocolVersion` 从 1 升为 2,package 版本不变。 + +### at-most-once 请求 + +以下 JSON mutation endpoint 支持同一 target 内、24 小时窗口的持久化 +at-most-once: + +- `/command` +- `/batch`(整批共用一个 invocation id) +- `/editor` +- `/compile` +- `/editor-compile` +- `/runtime-compile` +- `/refresh` +- `/execute` + +客户端通过通用 HTTP header 发送: + +- `X-CSharpConsole-Invocation-Id`: UUID +- `X-CSharpConsole-Target-Id`: 当前 `/health` 返回的 `targetId` + +两个 header 必须同时存在。都不存在时保持 protocol v1 的兼容执行,但 response +receipt 的 `guarantee` 为 `none`。只传一个 header、UUID 非法、target 不匹配或 +journal 不可写时,server 会在执行前 fail closed。 + +server 使用 `targetId + endpoint + 原始 UTF-8 body bytes 的 SHA-256` 作为 +invocation fingerprint。同一个 UUID: + +- fingerprint 相同且已完成:重放持久化 response,不重复执行; +- 正在执行:返回 `operation_in_progress`; +- 上次执行在完成结果落盘前中断:返回 `outcome_unknown`,不重复执行; +- fingerprint 不同:返回 `invocation_conflict`。 + +结果会先持久化,再写 HTTP socket。Editor ledger 位于 +`Library/CSharpConsole/InvocationLedger/v1`;development Player ledger 位于 +`Application.persistentDataPath/CSharpConsole/InvocationLedger/v1`。Player 使用 +按进程区分的 `identities/-.json`,并把 invocation record +写入 `targets//`;因此共享 `persistentDataPath` 的并发 Player 不会争用 +identity,也不会因使用相同 UUID 而互相冲突。Player target 的生命周期等于进程 +生命周期:新进程不会冒充旧 target 查询或重放旧结果;死亡进程的 identity 与 +target ledger 在 24 小时去重窗口结束后由存活 Player 做尽力清理。 + +Editor 的受保护 `/compile` 转发到 development Player `/execute` 时,第二跳也 +受 protocol v2 保护。Editor 先读取 Player `/health`,确认它是已初始化的 +Unity 2022 Player、journal 可写、主线程 heartbeat 正常并具有四个可靠性 +capability;然后由 parent invocation UUID、`player/execute` 和动作标签稳定派生 +child invocation UUID,并对实际 UTF-8 body 计算 digest。Player response 必须带 +匹配 child UUID、target、endpoint、digest、去重窗口和 `at-most-once` receipt。 +连接或读取失败、receipt 不一致,以及 child `outcome_unknown`、 +`operation_in_progress` 或 conflict 都会把 parent 返回为 `outcome_unknown`, +不会换 child id 重发。明确的 protected rejection 表示本次 Player 副作用确定未 +执行。 + +### response receipt + +所有 `HttpResponseEnvelope` 新增 `invocation`: + +- `invocationId` +- `targetId` +- `serviceEpoch` +- `endpoint` +- `requestDigest` +- `state` +- `guarantee`(`at-most-once` 或 `none`) +- `replayed` +- `dedupeWindowSeconds` +- `createdAtUtc` +- `updatedAtUtc` + +### invocation 状态查询 + +新增 `POST /CSharpConsole/invocation-status`,body: + +```json +{ + "invocationId": "UUID", + "targetId": "health 返回的 targetId" +} +``` + +`targetId` 也可以通过 `X-CSharpConsole-Target-Id` 发送;body 与 header 同时存在 +时必须一致。完成状态的 `dataJson.responseJson` 包含可恢复的原始 response +envelope。状态查询也会即时检查 24 小时窗口;过期 record 会在本次查询中删除并 +返回 `state=protection_expired`、`protectionExpired=true` 与 `previousState`, +不会在维护定时器尚未运行时继续声称受保护。 + +### health v2 + +`/health` 的 `dataJson` 新增: + +- `targetId`:Editor 使用规范化 project root 的 SHA-256 前 24 位, + 格式为 `editor-<24 hex>`;development Player 在同一进程内的 service 重启时 + 复用 target,新进程获得新 target;同一 `persistentDataPath` 下的并发 Player + 使用各自独立的 target ledger; +- `serviceEpoch`:每次 service 初始化生成;domain reload 后会变化; +- `capabilities`:`invocation_headers`、`invocation_receipts`、 + `invocation_status`、`at_most_once`; +- `journalWritable` +- `dedupeWindowSeconds` +- `isUpdating` +- `isPlaying` +- `mainThreadHeartbeatAgeMs` + +CLI 必须先读取 health target,再为 mutation 生成新的 invocation UUID;遇到连接 +超时或 reset 时只能使用同一个 UUID 重试或查询状态,不能用新 UUID 盲重试。 +Editor 的本地 `identity.json` 同时记录 `projectRoot`;CLI 只有在该路径解析后与 +当前项目根一致时才可用它处理 junction/symlink,不能信任从其他项目复制来的 +`Library` identity。 + +### refresh readiness 收紧 + +`RefreshOperationState` 新增 `triggerStarted`、`exitPlayModeRequested`、 +`waitingForEditMode`、内部恢复用的 `changedFiles` 和公开计数 +`changedFileCount`。`/refresh` 现在先持久化 invocation acceptance,再退出 +Play Mode 或把 refresh 排入主线程;若 acceptance 无法落盘,refresh 不会启动。 +已有 refresh 正在执行时,新请求返回 `ok=false`、`accepted=false`,其 +`changedFiles` 不会被假定已合并。请求 body 的读取、JSON 解析和路径校验都发生 +在 acceptance 之前;坏请求只会拒绝本次调用,不会改写已有 refresh operation。 + +refresh 状态文件也属于 acceptance 边界:状态的 durable write 成功后才会发布到 +`/health`、清空 session 或返回 `accepted=true`。因此 `accepted=true` 只表示 +refresh 意图已可靠接收,最终结果仍须按对应 `opId` / `generation` 等待。 + +主线程执行 refresh 时,会先可靠写入 `triggerStarted=true`,之后才允许退出 +Play Mode、调用 `AssetDatabase.Refresh` 或导入指定资源。该写入失败时不会触发 +任何上述操作,operation 会进入 `failed` 并说明持久化错误;如果失败标记本身也 +无法落盘,当前 service 仍报告内存中的 `failed`,下一 service epoch 会把最后 +落盘的 active 状态按“中断”处理,而不会恢复成 `ready`。 +`requested` 的超时基准会在状态发布前初始化;编译和 assembly reload 回调在 +`triggerStarted=false` 时不会推进该 operation,避免无关的 Unity 编译污染等待 +状态。排入主线程的 trigger 同时绑定 `opId`、`generation` 和本次文件列表;若等待 +期间 operation 已超时或被替换,旧 trigger 会直接跳过,不产生副作用。不支持 +`File.Replace` 的运行时通过 canonical/backup 同卷换名,并在启动时恢复中断的 +替换,不会直接截断唯一状态文件。 +所有 lifecycle transition 都按 `opId + generation` 做原子 compare-and-set; +旧 callback 不能推进或覆盖新 operation。 + +带 `--exit-playmode` 的请求会先持久化待续文件列表与等待阶段,设置 +`isPlaying=false` 后立即结束当前 trigger,不会在仍处于 Play Mode 的同一调用栈 +里刷新;`isPlayingOrWillChangePlaymode=true` 的 EnteringPlayMode 也会先进入 +durable waiting state,再用同一 setter 取消/退出。只有收到 `EnteredEditMode`、确认 +`isPlayingOrWillChangePlaymode=false`,且 Editor compile/update 空闲后,才恢复 +同一 `opId/generation`。预期中的 ExitingPlayMode service restart 会保留该 +waiting state;进程重启也从 Library 状态恢复,不创建第二个 intent。 + +refresh 状态 canonical path 改为 +`Library/CSharpConsole/RefreshState/v1/refresh_state.json`,不再把可清理的 +`Temp` 当作 durable acceptance。升级时会读取一次旧 +`Temp/CSharpConsole/refresh_state.json`:没有 operation 的旧 ready 记录迁移成 +pristine,旧 active operation 保守迁移成 `failed`,因为无法证明它已完成。空白、 +损坏、缺字段、未知 phase 或字段组合不一致的状态同样恢复为明确的 `failed`, +不会静默当作从未执行过 refresh。 + +完整 `changedFiles` 只保留在 Library 内部恢复状态;`/health` 与 `/refresh` +response 的 public operation snapshot 会清空该数组,只返回 +`changedFileCount`,避免 `wait-ready` 每次 poll 重复传输大量路径。 + +`wait-ready` 只应接受匹配 `opId` / `generation` 且 phase 为 `ready` 的状态: + +- `requested` 超时改为 `failed`,不再转成 `ready`; +- targeted/full refresh 都会在 Import/Refresh 前发布 + `compileRequested=true` 并显式请求 compilation;不再只靠 `.cs` 后缀推断, + 因此 `.asmdef`、`.asmref`、`.rsp`、预编译 DLL 或 package 配置变化不会提前 + ready; +- 编译必须绑定当前 `opId/generation` 并观察真实 lifecycle;30 秒内未开始或 + 300 秒内未结束会转成 `failed`; +- 如果至少一个 assembly 真正开始编译,成功后必须在 60 秒内观察到 assembly + reload,只有 `afterAssemblyReload` 才发布 `ready`;compile failure 直接 + `failed`; +- 如果 compilation pipeline 结束但没有 assembly 需要重编,则至少等待 2 秒和 + 3 个 Editor update 的稳定 idle 窗后才发布 `ready`; +- service reload 只有在 `triggerStarted=true` 且已进入 `reloading` 时才可恢复为 + `ready`; +- 其他被中断的 active phase 会恢复为 `failed`。 + +development Player 的 service 初始化强制 `Application.runInBackground=true`, +确保失焦时主线程仍能排空 mutation 队列。 + +### 主线程副作用提交边界 + +受 protocol v2 保护的 `editor/playmode.enter` 与 `editor/playmode.exit` 在 +durable invocation claim 之后,把校验与 `EditorApplication.isPlaying` setter +合并在同一次同步主线程执行中,再生成 terminal response。若 Play Mode reload 使 +response 来不及落盘,该 invocation 会恢复为 +`outcome_unknown` 而不是虚假的 completed;同一 UUID 不会再次投递。异步主线程 +工作若超时但仍在运行,会继续独占串行执行锁;后续异步请求最多等待一秒获取该锁, +未获取时明确在执行前失败,避免串行 HTTP 接收循环永久阻塞。 diff --git a/Docs~/UnityCliPluginSync_zh.md.meta b/Docs~/UnityCliPluginSync_zh.md.meta new file mode 100644 index 0000000..b347838 --- /dev/null +++ b/Docs~/UnityCliPluginSync_zh.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4befae6a708a4516b0970d14e900aa3d +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/ExternalTool~/console-client/csharpconsole_core/response_parser.py b/Editor/ExternalTool~/console-client/csharpconsole_core/response_parser.py index 1a27cf1..f026fdf 100644 --- a/Editor/ExternalTool~/console-client/csharpconsole_core/response_parser.py +++ b/Editor/ExternalTool~/console-client/csharpconsole_core/response_parser.py @@ -7,6 +7,8 @@ TYPE_RUNTIME_ERROR = "runtime_error" TYPE_VALIDATION_ERROR = "validation_error" TYPE_SYSTEM_ERROR = "system_error" +TYPE_OUTCOME_UNKNOWN = "outcome_unknown" +TYPE_OPERATION_IN_PROGRESS = "operation_in_progress" def _try_load_json_object(raw): @@ -46,6 +48,8 @@ def _decode_data_json(raw_data): def _exit_code_from_type(ok, result_type): if ok: return 0 + if result_type in {TYPE_OUTCOME_UNKNOWN, TYPE_OPERATION_IN_PROGRESS}: + return 4 if result_type in {TYPE_VALIDATION_ERROR, TYPE_COMPILE_ERROR}: return 1 if result_type == TYPE_RUNTIME_ERROR: @@ -57,6 +61,15 @@ def _is_response_envelope(data): return isinstance(data, dict) and "ok" in data and "summary" in data and "dataJson" in data +def _decode_invocation(raw_invocation): + if isinstance(raw_invocation, dict): + return raw_invocation + if isinstance(raw_invocation, str): + parsed = _try_load_json_object(raw_invocation) + return parsed if isinstance(parsed, dict) else None + return None + + def _build_envelope_result(envelope, default_stage, session_id, mode, run_id, duration_ms): ok = bool(envelope.get("ok")) stage = envelope.get("stage") or default_stage @@ -64,7 +77,7 @@ def _build_envelope_result(envelope, default_stage, session_id, mode, run_id, du summary = envelope.get("summary") or ("OK" if ok else "Request failed") resolved_session_id = envelope.get("sessionId", session_id) or session_id data = _decode_data_json(envelope.get("dataJson")) - return make_result( + result = make_result( ok, stage, result_type, @@ -76,6 +89,10 @@ def _build_envelope_result(envelope, default_stage, session_id, mode, run_id, du duration_ms, data, ) + invocation = _decode_invocation(envelope.get("invocation")) + if invocation is not None: + result["invocation"] = invocation + return result def _parse_envelope_result(raw, default_stage, session_id, mode, run_id, duration_ms): @@ -198,19 +215,21 @@ def parse_command_http_response(raw, session_id, mode, run_id, duration_ms): data = result.get("data", {}) command_payload = data if isinstance(data, dict) else {} - if "command" not in command_payload or "resultJson" not in command_payload: + if result["ok"] and ("command" not in command_payload or "resultJson" not in command_payload): raise ValueError("Invalid command response") - parsed_result_json = command_payload.get("resultJson") - if isinstance(parsed_result_json, str) and parsed_result_json.strip(): - try: - parsed_result_json = json.loads(parsed_result_json) - except Exception: - pass - result["data"] = { - "command": command_payload.get("command") or {}, - "resultJson": parsed_result_json, - } + normalized_data = dict(command_payload) + if "command" in normalized_data: + normalized_data["command"] = normalized_data.get("command") or {} + if "resultJson" in normalized_data: + parsed_result_json = normalized_data.get("resultJson") + if isinstance(parsed_result_json, str) and parsed_result_json.strip(): + try: + parsed_result_json = json.loads(parsed_result_json) + except Exception: + pass + normalized_data["resultJson"] = parsed_result_json + result["data"] = normalized_data return result diff --git a/Editor/ExternalTool~/console-client/csharpconsole_core/transport_http.py b/Editor/ExternalTool~/console-client/csharpconsole_core/transport_http.py index b8b70da..97b1322 100644 --- a/Editor/ExternalTool~/console-client/csharpconsole_core/transport_http.py +++ b/Editor/ExternalTool~/console-client/csharpconsole_core/transport_http.py @@ -7,26 +7,71 @@ class TransportError(Exception): """Raised when an HTTP request to the console service fails at the transport layer.""" -def _post(url, data=None, json=None, content_type="application/json", timeout_seconds=30): - if json is not None: - body = _json.dumps(json).encode("utf-8") +def encode_json_body(payload): + """Serialize a JSON payload to deterministic UTF-8 bytes.""" + return _json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + + +def _coerce_body_bytes(body): + if isinstance(body, bytes): + return body + if isinstance(body, (bytearray, memoryview)): + return bytes(body) + if isinstance(body, str): + return body.encode("utf-8") + raise TypeError("HTTP request body must be bytes or a string") + + +def _post( + url, + data=None, + json=None, + content_type="application/json", + timeout_seconds=30, + *, + headers=None, + body=None, +): + if body is not None: + request_body = _coerce_body_bytes(body) + elif json is not None: + request_body = encode_json_body(json) elif data is None: - body = b"" + request_body = b"" elif isinstance(data, (bytes, bytearray)): - body = bytes(data) + request_body = bytes(data) else: - body = str(data).encode("utf-8") + request_body = str(data).encode("utf-8") + + request_headers = {"Content-Type": content_type} + if headers: + request_headers.update(headers) request = urllib.request.Request( url, - data=body, - headers={"Content-Type": content_type}, + data=request_body, + headers=request_headers, method="POST", ) try: with urllib.request.urlopen(request, timeout=timeout_seconds) as response: - charset = response.headers.get_content_charset() or "utf-8" - return response.read().decode(charset) + try: + charset = response.headers.get_content_charset() or "utf-8" + return response.read().decode(charset) + except Exception as e: + # urlopen has already dispatched the request at this point. + # IncompleteRead/HTTPException, malformed charset metadata, and + # decode failures must all cross the reliable caller's + # post-send uncertainty boundary as TransportError. + raise TransportError( + f"Failed to read HTTP response: {e}" + ) from e except urllib.error.HTTPError as e: # urlopen raises for non-2xx, mirroring requests' raise_for_status(). raise TransportError(f"HTTP {e.code} {e.reason}") from e @@ -35,15 +80,48 @@ def _post(url, data=None, json=None, content_type="application/json", timeout_se raise TransportError(str(getattr(e, "reason", e))) from e -def post_json(server_base_url, endpoint, payload, timeout_seconds): +def post_json( + server_base_url, + endpoint, + payload=None, + timeout_seconds=30, + *, + headers=None, + body=None, +): url = f"{server_base_url}/{endpoint}" - return _post(url, json=payload, timeout_seconds=timeout_seconds) + return _post( + url, + json=payload if body is None else None, + timeout_seconds=timeout_seconds, + headers=headers, + body=body, + ) -def post_json_to_execute(execute_base_url, payload, timeout_seconds): +def post_json_to_execute( + execute_base_url, + payload=None, + timeout_seconds=30, + *, + headers=None, + body=None, +): url = f"{execute_base_url}/execute" - return _post(url, json=payload, timeout_seconds=timeout_seconds) + return _post( + url, + json=payload if body is None else None, + timeout_seconds=timeout_seconds, + headers=headers, + body=body, + ) -def post_binary(url, body, timeout_seconds): - return _post(url, data=body, content_type="application/octet-stream", timeout_seconds=timeout_seconds) +def post_binary(url, body, timeout_seconds, *, headers=None): + return _post( + url, + body=body, + content_type="application/octet-stream", + timeout_seconds=timeout_seconds, + headers=headers, + ) diff --git a/Editor/ExternalTool~/console-client/repl/direct_launch.py b/Editor/ExternalTool~/console-client/repl/direct_launch.py index 1c032f8..1299845 100644 --- a/Editor/ExternalTool~/console-client/repl/direct_launch.py +++ b/Editor/ExternalTool~/console-client/repl/direct_launch.py @@ -24,20 +24,38 @@ def extract_project_path_from_command_line(command_line): return None +def _read_project_refresh_state(project_path): + state_paths = ( + os.path.join( + project_path, + "Library", + "CSharpConsole", + "RefreshState", + "v1", + "refresh_state.json", + ), + # Compatibility with package versions that persisted discovery state + # under the rebuildable Temp directory. + os.path.join(project_path, "Temp", "CSharpConsole", "refresh_state.json"), + ) + for state_path in state_paths: + if not os.path.isfile(state_path): + continue + try: + with open(state_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + continue + return None + + def read_project_temp_state(project_path): - temp_dir = os.path.join(project_path, "Temp", "CSharpConsole") - if not os.path.isdir(temp_dir): - return None + """Compatibility name retained for callers that patch the old helper.""" + return _read_project_refresh_state(project_path) - state_path = os.path.join(temp_dir, "refresh_state.json") - if not os.path.isfile(state_path): - return None - try: - with open(state_path, "r", encoding="utf-8") as f: - return json.load(f) - except Exception: - return None +def read_project_refresh_state(project_path): + return read_project_temp_state(project_path) def is_batchmode_worker_command_line(command_line): @@ -181,7 +199,7 @@ def is_valid_console_port(port): def read_editor_port_from_project_state(project_path): - state = read_project_temp_state(project_path) + state = read_project_refresh_state(project_path) if not isinstance(state, dict): return None diff --git a/Runtime/Service/Commands/Core/CommandDispatcher.cs b/Runtime/Service/Commands/Core/CommandDispatcher.cs index 0039482..6d36451 100644 --- a/Runtime/Service/Commands/Core/CommandDispatcher.cs +++ b/Runtime/Service/Commands/Core/CommandDispatcher.cs @@ -2,6 +2,16 @@ namespace Zh1Zh1.CSharpConsole.Service.Commands.Core { + internal sealed class CommandOutcomeUnknownException : TimeoutException + { + internal CommandOutcomeUnknownException( + string message, + Exception innerException = null) + : base(message, innerException) + { + } + } + internal sealed class CommandDispatcher { private readonly Func, CommandResponse> _mainThreadRunner; @@ -37,6 +47,12 @@ public CommandResponse Dispatch(CommandRegistry registry, CommandInvocation invo return NormalizeResponse(route.handler(invocation), invocation); } + catch (CommandOutcomeUnknownException e) + { + return CommandResponseFactory.OutcomeUnknown( + invocation, + $"Command may have executed but its result is unknown: {invocation.commandNamespace}/{invocation.action}, {e.Message}"); + } catch (Exception e) { return CommandResponseFactory.SystemError(invocation, $"Failed to process command: {invocation.commandNamespace}/{invocation.action}, {e.Message}"); diff --git a/Runtime/Service/Commands/Core/CommandResponseFactory.cs b/Runtime/Service/Commands/Core/CommandResponseFactory.cs index 73077e3..84cca78 100644 --- a/Runtime/Service/Commands/Core/CommandResponseFactory.cs +++ b/Runtime/Service/Commands/Core/CommandResponseFactory.cs @@ -69,6 +69,18 @@ internal static CommandResponse SystemError(CommandInvocation invocation, string return response; } + internal static CommandResponse OutcomeUnknown(CommandInvocation invocation, string summary) + { + var response = new CommandResponse + { + ok = false, + type = "outcome_unknown", + summary = summary + }; + ApplyInvocation(response, invocation); + return response; + } + private static void ApplyInvocation(CommandResponse response, CommandInvocation invocation) { response.commandNamespace = invocation?.commandNamespace ?? ""; diff --git a/Runtime/Service/Commands/Handlers/EditorCommandActions.cs b/Runtime/Service/Commands/Handlers/EditorCommandActions.cs index ad5cec1..22f15ef 100644 --- a/Runtime/Service/Commands/Handlers/EditorCommandActions.cs +++ b/Runtime/Service/Commands/Handlers/EditorCommandActions.cs @@ -112,22 +112,33 @@ private static CommandResponse PlaymodeStatus() private static CommandResponse SetPlaymode(bool enter) { - var validationError = MainThreadRequestRunner.RunOnMainThread(() => ValidatePlaymodeTransition(enter)); - - if (string.IsNullOrEmpty(validationError)) + try { - MainThreadRequestRunner.Post(() => + return MainThreadRequestRunner.RunOnMainThread(() => { + var validationError = ValidatePlaymodeTransition(enter); + if (!string.IsNullOrEmpty(validationError)) + { + return CommandResponseFactory.ValidationError(validationError); + } + if (EditorApplication.isPlaying != enter) { EditorApplication.isPlaying = enter; } + + // If the setter initiates a reload before this response can be + // durably recorded, the invocation's started claim recovers as + // outcome_unknown and is never dispatched again. + return CommandResponseFactory.Ok( + $"Requested {(enter ? "enter" : "exit")} playmode", + "{}"); }); } - - return string.IsNullOrEmpty(validationError) - ? CommandResponseFactory.Ok($"Requested {(enter ? "enter" : "exit")} playmode", "{}") - : CommandResponseFactory.ValidationError(validationError); + catch (MainThreadOutcomeUnknownException e) + { + throw new CommandOutcomeUnknownException(e.Message, e); + } } [CommandAction("editor", "menu.open", editorOnly: true, summary: "Open a menu item by path")] diff --git a/Runtime/Service/Commands/Routing/BatchEndpointHandler.cs b/Runtime/Service/Commands/Routing/BatchEndpointHandler.cs index 3a213dd..987ebe5 100644 --- a/Runtime/Service/Commands/Routing/BatchEndpointHandler.cs +++ b/Runtime/Service/Commands/Routing/BatchEndpointHandler.cs @@ -45,6 +45,7 @@ private sealed class BatchResponse public int total; public int succeeded; public int failed; + public bool outcomeUnknown; public string resultsJson = "[]"; } @@ -82,7 +83,9 @@ public async Task Handle(HttpListenerContext context) var envelope = _dependencies.EnvelopeFactory.CreateEnvelope( batchResponse.ok, "command", - batchResponse.ok ? "ok" : "system_error", + batchResponse.outcomeUnknown + ? "outcome_unknown" + : (batchResponse.ok ? "ok" : "system_error"), batchResponse.ok ? $"Batch completed: {batchResponse.succeeded}/{batchResponse.total} succeeded" : $"Batch failed: {batchResponse.succeeded}/{batchResponse.total} succeeded", @@ -99,6 +102,7 @@ private static BatchResponse ExecuteBatch(BatchRequest request) var succeeded = 0; var failed = 0; var allOk = true; + var outcomeUnknown = false; foreach (var cmd in commands) { @@ -141,6 +145,15 @@ private static BatchResponse ExecuteBatch(BatchRequest request) { failed++; allOk = false; + if (string.Equals(response.type, "outcome_unknown", StringComparison.Ordinal)) + { + outcomeUnknown = true; + // The current item may have mutated Unity. Continuing + // would make the batch's partial state even harder to + // recover, regardless of the ordinary stopOnError flag. + break; + } + if (request.stopOnError) { break; @@ -154,6 +167,7 @@ private static BatchResponse ExecuteBatch(BatchRequest request) total = succeeded + failed, succeeded = succeeded, failed = failed, + outcomeUnknown = outcomeUnknown, resultsJson = "[" + string.Join(",", results) + "]" }; } @@ -172,6 +186,7 @@ private static BatchResponse CreateErrorResponse(string message) total = 0, succeeded = 0, failed = 1, + outcomeUnknown = false, resultsJson = "[" + errorItem + "]" }; } diff --git a/Runtime/Service/Commands/Routing/CommandRouter.cs b/Runtime/Service/Commands/Routing/CommandRouter.cs index 858468a..b90d976 100644 --- a/Runtime/Service/Commands/Routing/CommandRouter.cs +++ b/Runtime/Service/Commands/Routing/CommandRouter.cs @@ -323,7 +323,17 @@ private static string BuildId(string commandNamespace, string action) private static Func, CommandResponse> BuildMainThreadRunner() { - return work => MainThreadRequestRunner.RunOnMainThread(work); + return work => + { + try + { + return MainThreadRequestRunner.RunOnMainThread(work); + } + catch (MainThreadOutcomeUnknownException e) + { + throw new CommandOutcomeUnknownException(e.Message, e); + } + }; } private static bool ShouldRetryDiscovery() diff --git a/Runtime/Service/ConsoleHttpService.cs b/Runtime/Service/ConsoleHttpService.cs index da148ee..ada0662 100644 --- a/Runtime/Service/ConsoleHttpService.cs +++ b/Runtime/Service/ConsoleHttpService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.IO; using System.IO.Compression; using System.Net; @@ -37,10 +38,15 @@ public static class ConsoleHttpService private readonly static HttpClient s_HttpClient = new HttpClient() { Timeout = TimeSpan.FromMilliseconds(ConsoleServiceConfig.HttpClientTimeoutMs) }; private readonly static ReplServiceRegistry s_ReplServiceRegistry = new ReplServiceRegistry(); private readonly static HttpEnvelopeFactory s_EnvelopeFactory = new HttpEnvelopeFactory(); + private static InvocationCoordinator s_InvocationCoordinator; + private static bool s_StartNewInvocationEpoch; private static ConsoleHttpServiceDependencies s_Dependencies; private static HealthEndpointHandler s_HealthEndpointHandler; private static CommandEndpointHandler s_CommandEndpointHandler; private static BatchEndpointHandler s_BatchEndpointHandler; + private static long s_MainThreadHeartbeatUtcTicks; + private static volatile bool s_CachedIsUpdating; + private static volatile bool s_CachedIsPlaying; #if UNITY_EDITOR private static CompletionEndpointHandler s_CompletionEndpointHandler; #endif @@ -67,6 +73,7 @@ public static void InitializeForEditor( Func editorCompilerGenera { #if UNITY_EDITOR MainThreadRequestRunner.InitializeEditor(); + RecordMainThreadHeartbeat(); s_EditorREPLCompilerGenerator = editorCompilerGenerator ?? throw new ArgumentNullException(nameof(editorCompilerGenerator)); s_EditorREPLExecutorGenerator = editorExecutorGenerator ?? throw new ArgumentNullException(nameof(editorExecutorGenerator)); s_RuntimeREPLCompilerGenerator = runtimeCompilerGenerator ?? throw new ArgumentNullException(nameof(runtimeCompilerGenerator)); @@ -81,7 +88,10 @@ public static void InitializeForRuntime(Func runtimeExecutorGener #if UNITY_EDITOR throw new InvalidOperationException("InitializeForRuntime can only be called in the Unity Runtime."); #else + Application.runInBackground = true; MainThreadRequestRunner.InitializeRuntime(); + RecordMainThreadHeartbeat(); + EnsureRuntimeHealthHeartbeat(); s_RuntimeREPLExecutorGenerator = runtimeExecutorGenerator ?? throw new ArgumentNullException(nameof(runtimeExecutorGenerator)); InitializeInternal(); #endif @@ -96,6 +106,15 @@ private static void InitializeInternal() var sw = Stopwatch.StartNew(); + if (s_InvocationCoordinator == null) + { + s_InvocationCoordinator = new InvocationCoordinator(); + } + else if (s_StartNewInvocationEpoch) + { + s_InvocationCoordinator.RestartServiceEpoch(); + } + s_StartNewInvocationEpoch = false; BootstrapDependencies(); StartListener(); if (s_Listener?.IsListening != true) @@ -103,6 +122,7 @@ private static void InitializeInternal() // Listener failed — reset state so a future call can retry. s_Listener = null; Port = 0; + s_StartNewInvocationEpoch = true; ConsoleLog.Error("Service initialization failed: listener could not start"); return; } @@ -110,15 +130,43 @@ private static void InitializeInternal() s_Initialized = true; #if UNITY_EDITOR var state = GetRefreshStateSnapshot(); - if (state.PhaseValue == RefreshPhase.Reloading || state.PhaseValue == RefreshPhase.Compiling || state.PhaseValue == RefreshPhase.RefreshingAssets || state.PhaseValue == RefreshPhase.Requested) + var resumablePlayModeExit = + state.PhaseValue == RefreshPhase.Requested + && state.triggerStarted + && state.exitPlayModeRequested; + if (resumablePlayModeExit) + { + TrackRefreshOperation(state); + if (!EditorApplication.isPlaying + && !EditorApplication.isPlayingOrWillChangePlaymode) + { + state.waitingForEditMode = false; + state.message = + "Play Mode exited; waiting for Unity to become idle"; + } + } + else if (state.PhaseValue == RefreshPhase.Reloading && state.triggerStarted) { state.reloadObserved = true; SetPhase(state, RefreshPhase.Ready); state.message = "Service recovered after refresh"; } + else if (IsActiveRefreshPhase(state.PhaseValue)) + { + SetPhase(state, RefreshPhase.Failed); + state.message = state.triggerStarted + ? "Refresh was interrupted before completion could be confirmed" + : "Refresh was interrupted before its trigger started"; + } // Always persist so that direct-launch discovery can read the port // even before any refresh cycle has run. - SaveRefreshState(state); + if (!TrySaveRefreshState(state, out var initializationPersistenceError)) + { + RecordRefreshPersistenceFailure( + state, + $"Refresh state initialization could not be durably recorded: " + + initializationPersistenceError); + } #endif sw.Stop(); @@ -134,6 +182,9 @@ public static void Shutdown() s_Initialized = false; + s_InvocationCoordinator?.MarkOutstandingOutcomeUnknown( + "The Unity service stopped before the invocation outcome was durably recorded."); + s_StartNewInvocationEpoch = true; ClearSessionState(); s_Listener?.Stop(); @@ -288,13 +339,13 @@ private static async Task TryDispatchJsonRoute(HttpListenerContext context #if UNITY_EDITOR if (path.EndsWith("/editor")) { - await ProcessEditorREPL(context); + await DispatchInvocationProtected(context, "editor", ProcessEditorREPL); return true; } if (path.EndsWith("/compile")) { - await ProcessCompileRuntimeREPL(context); + await DispatchInvocationProtected(context, "compile", ProcessCompileRuntimeREPL); return true; } @@ -306,31 +357,36 @@ private static async Task TryDispatchJsonRoute(HttpListenerContext context if (path.EndsWith("/editor-compile")) { - await ProcessEditorCompileOnly(context); + await DispatchInvocationProtected(context, "editor-compile", ProcessEditorCompileOnly); return true; } if (path.EndsWith("/runtime-compile")) { - await ProcessRuntimeCompileOnly(context); + await DispatchInvocationProtected(context, "runtime-compile", ProcessRuntimeCompileOnly); return true; } if (path.EndsWith("/refresh")) { - await ProcessRefresh(context); + await DispatchInvocationProtected(context, "refresh", ProcessRefresh); return true; } #endif if (path.EndsWith("/command")) { - await s_CommandEndpointHandler.Handle(context); + await DispatchInvocationProtected(context, "command", s_CommandEndpointHandler.Handle); return true; } if (path.EndsWith("/batch")) { - await s_BatchEndpointHandler.Handle(context); + await DispatchInvocationProtected(context, "batch", s_BatchEndpointHandler.Handle); + return true; + } + if (path.EndsWith("/invocation-status")) + { + await ProcessInvocationStatus(context); return true; } if (path.EndsWith("/health")) @@ -341,13 +397,136 @@ private static async Task TryDispatchJsonRoute(HttpListenerContext context if (path.EndsWith("/execute")) { - await ProcessExecuteRuntimeREPL(context); + await DispatchInvocationProtected(context, "execute", ProcessExecuteRuntimeREPL); return true; } return false; } + private static async Task DispatchInvocationProtected( + HttpListenerContext context, + string endpoint, + Func handler) + { + InvocationClaim claim = null; + try + { + var cachedBody = await ConsoleHttpServiceDependencies.PrepareRequestBodyAsync(context); + claim = s_InvocationCoordinator.Claim( + context.Request.Headers[InvocationCoordinator.InvocationIdHeader], + context.Request.Headers[InvocationCoordinator.TargetIdHeader], + endpoint, + cachedBody.Bytes); + + if (claim.Disposition == InvocationClaimDisposition.Execute + || claim.Disposition == InvocationClaimDisposition.Unprotected) + { + ConsoleHttpServiceDependencies.AttachInvocationClaim(context, claim); + await handler(context); + return; + } + + if (claim.Disposition == InvocationClaimDisposition.Replay) + { + var replayEnvelope = string.IsNullOrWhiteSpace(claim.ResponseJson) + ? null + : JsonUtility.FromJson(claim.ResponseJson); + if (replayEnvelope == null) + { + claim.UpdatedAtUtc = DateTime.UtcNow.ToString("O"); + s_InvocationCoordinator.MarkOutcomeUnknown( + claim, + "The persisted invocation response could not be decoded."); + replayEnvelope = CreateInvocationStateEnvelope( + claim, + InvocationClaimDisposition.OutcomeUnknown, + "The persisted invocation response could not be decoded."); + } + else + { + replayEnvelope.invocation = + s_InvocationCoordinator.CreateReceipt(claim, "replayed", true); + } + + await WriteEnvelopeResponseAsync(context, replayEnvelope, endpoint); + return; + } + + await WriteEnvelopeResponseAsync( + context, + CreateInvocationStateEnvelope(claim, claim.Disposition, claim.Message), + endpoint); + } + catch (Exception e) + { + ConsoleLog.Error($"[{endpoint}] Invocation dispatch failed: {e}"); + HttpResponseEnvelope envelope; + if (claim != null + && claim.Disposition == InvocationClaimDisposition.Execute + && !string.IsNullOrEmpty(claim.InvocationId)) + { + claim.UpdatedAtUtc = DateTime.UtcNow.ToString("O"); + s_InvocationCoordinator.MarkOutcomeUnknown( + claim, + $"Invocation dispatch stopped unexpectedly: {e.Message}"); + envelope = CreateInvocationStateEnvelope( + claim, + InvocationClaimDisposition.OutcomeUnknown, + "Invocation outcome is unknown; the request will not be dispatched again."); + } + else + { + envelope = s_EnvelopeFactory.CreateEnvelope( + false, + "unknown", + "system_error", + $"Request dispatch failed: {e.Message}", + "", + "{}"); + envelope.invocation = s_InvocationCoordinator.CreateReceipt(claim, "none", false); + } + + await WriteEnvelopeResponseAsync(context, envelope, endpoint); + } + finally + { + ConsoleHttpServiceDependencies.ReleaseRequest(context); + } + } + + private static HttpResponseEnvelope CreateInvocationStateEnvelope( + InvocationClaim claim, + InvocationClaimDisposition disposition, + string message) + { + var state = disposition switch + { + InvocationClaimDisposition.InProgress => "in_progress", + InvocationClaimDisposition.Conflict => "conflict", + InvocationClaimDisposition.OutcomeUnknown => "outcome_unknown", + InvocationClaimDisposition.Rejected => "rejected", + _ => "outcome_unknown" + }; + var type = disposition switch + { + InvocationClaimDisposition.InProgress => "operation_in_progress", + InvocationClaimDisposition.Conflict => "invocation_conflict", + InvocationClaimDisposition.OutcomeUnknown => "outcome_unknown", + InvocationClaimDisposition.Rejected => "validation_error", + _ => "system_error" + }; + var envelope = s_EnvelopeFactory.CreateEnvelope( + false, + "bootstrap", + type, + string.IsNullOrEmpty(message) ? "Invocation was not dispatched." : message, + "", + "{}"); + envelope.invocation = s_InvocationCoordinator.CreateReceipt(claim, state, false); + return envelope; + } + private static Task TryDispatchBinaryRoute(HttpListenerContext context, string path) { #if UNITY_EDITOR @@ -412,6 +591,29 @@ private static string CombineCompilerNotice(string notice, string result) return $"{notice}\n\n{result}"; } + private sealed class RemoteInvocationOutcomeUnknownException : Exception + { + public RemoteInvocationOutcomeUnknownException(string message, Exception innerException = null) + : base(message, innerException) + { + } + } + + private static HttpResponseEnvelope CreateOutcomeUnknownEnvelope( + string stage, + Exception exception, + string sessionId) + { + var summary = exception?.Message ?? "Main-thread execution outcome is unknown."; + return s_EnvelopeFactory.CreateEnvelope( + false, + stage, + "outcome_unknown", + summary, + sessionId, + JsonUtility.ToJson(new TextResponseData { text = summary })); + } + #if UNITY_EDITOR private static async Task ProcessEditorREPL(HttpListenerContext context) { @@ -458,6 +660,10 @@ private static async Task ProcessEditorREPL(HttpListenerContext context) response = s_EnvelopeFactory.CreateTextEnvelope("execute", result, uuid); } + catch (MainThreadOutcomeUnknownException e) + { + response = CreateOutcomeUnknownEnvelope("execute", e, uuid); + } catch (Exception e) { response = s_EnvelopeFactory.CreateTextEnvelope("execute", $"C# Exception: {e}", uuid); @@ -486,6 +692,7 @@ private static async Task CompileAndRespond(HttpListenerContext context, IREPLCo { CompileOnlyResponse responseData; var compilerNotice = ""; + var failureType = "compile_error"; try { var (assemblyBytes, scriptClassName, errorMsg) = compiler.Compile(code, defines, defaultUsing); @@ -497,6 +704,11 @@ private static async Task CompileAndRespond(HttpListenerContext context, IREPLCo error = errorMsg ?? "" }; } + catch (MainThreadOutcomeUnknownException e) + { + failureType = "outcome_unknown"; + responseData = new CompileOnlyResponse { error = e.Message }; + } catch (Exception e) { responseData = new CompileOnlyResponse { error = e.ToString() }; @@ -506,14 +718,31 @@ private static async Task CompileAndRespond(HttpListenerContext context, IREPLCo var summary = ok ? (string.IsNullOrEmpty(compilerNotice) ? "Compile succeeded" : compilerNotice) : responseData.error; - var envelope = s_EnvelopeFactory.CreateEnvelope(ok, "compile", ok ? "ok" : "compile_error", summary, "", JsonUtility.ToJson(responseData)); + var envelope = s_EnvelopeFactory.CreateEnvelope(ok, "compile", ok ? "ok" : failureType, summary, "", JsonUtility.ToJson(responseData)); await WriteEnvelopeResponseAsync(context, envelope, "EditorCompile"); } #endif + private static RefreshOperationState CreatePublicRefreshState( + RefreshOperationState state) + { +#if UNITY_EDITOR + var publicState = CloneRefreshState(state); +#else + var publicState = state ?? new RefreshOperationState(); +#endif + publicState.changedFileCount = + state?.changedFiles?.Length + ?? state?.changedFileCount + ?? 0; + publicState.changedFiles = Array.Empty(); + return publicState; + } + internal static HealthResponse BuildHealthResponseSnapshot() { s_ReplServiceRegistry.EvictIdleSessions(); + s_InvocationCoordinator.Maintain(); var state = GetRefreshStateSnapshot(); return new HealthResponse { @@ -535,10 +764,125 @@ internal static HealthResponse BuildHealthResponseSnapshot() packageVersion = ConsoleServiceConfig.PackageVersion, protocolVersion = ConsoleServiceConfig.ProtocolVersion, unityVersion = Application.unityVersion, - operation = state + targetId = s_InvocationCoordinator.TargetId, + serviceEpoch = s_InvocationCoordinator.ServiceEpoch, + capabilities = new[] + { + "invocation_headers", + "invocation_receipts", + "invocation_status", + "at_most_once" + }, + journalWritable = s_InvocationCoordinator.JournalWritable, + dedupeWindowSeconds = InvocationCoordinator.DedupeWindowSeconds, + isUpdating = s_CachedIsUpdating, + isPlaying = s_CachedIsPlaying, + mainThreadHeartbeatAgeMs = GetMainThreadHeartbeatAgeMs(), + operation = CreatePublicRefreshState(state) }; } + private static async Task ProcessInvocationStatus(HttpListenerContext context) + { + HttpResponseEnvelope envelope; + try + { + var body = await ConsoleHttpServiceDependencies.ReadRequestBodyAsync(context); + var request = string.IsNullOrWhiteSpace(body) + ? null + : JsonUtility.FromJson(body); + if (request == null) + { + envelope = s_EnvelopeFactory.CreateEnvelope( + false, + "bootstrap", + "validation_error", + "Invocation status request body is empty or invalid.", + "", + "{}"); + } + else + { + var headerInvocationId = + context.Request.Headers[InvocationCoordinator.InvocationIdHeader]?.Trim() ?? ""; + var headerTargetId = + context.Request.Headers[InvocationCoordinator.TargetIdHeader]?.Trim() ?? ""; + var invocationId = request.invocationId?.Trim() ?? ""; + var targetId = request.targetId?.Trim() ?? ""; + + if (!string.IsNullOrEmpty(headerInvocationId) + && !string.Equals(headerInvocationId, invocationId, StringComparison.OrdinalIgnoreCase)) + { + envelope = s_EnvelopeFactory.CreateEnvelope( + false, + "bootstrap", + "validation_error", + "Invocation id header does not match the status request body.", + "", + "{}"); + } + else if (!string.IsNullOrEmpty(headerTargetId) + && !string.IsNullOrEmpty(targetId) + && !string.Equals(headerTargetId, targetId, StringComparison.Ordinal)) + { + envelope = s_EnvelopeFactory.CreateEnvelope( + false, + "bootstrap", + "validation_error", + "Target id header does not match the status request body.", + "", + "{}"); + } + else + { + if (string.IsNullOrEmpty(targetId)) + { + targetId = headerTargetId; + } + + if (s_InvocationCoordinator.TryGetStatus( + invocationId, + targetId, + out var status, + out var error)) + { + envelope = s_EnvelopeFactory.CreateEnvelope( + true, + "bootstrap", + "ok", + status.found + ? $"Invocation is {status.state}." + : "Invocation was not found in the active dedupe window.", + "", + JsonUtility.ToJson(status)); + } + else + { + envelope = s_EnvelopeFactory.CreateEnvelope( + false, + "bootstrap", + "validation_error", + error, + "", + "{}"); + } + } + } + } + catch (Exception e) + { + envelope = s_EnvelopeFactory.CreateEnvelope( + false, + "bootstrap", + "system_error", + $"Invocation status failed: {e.Message}", + "", + "{}"); + } + + await WriteEnvelopeResponseAsync(context, envelope, "InvocationStatus"); + } + private static async Task WriteJsonResponseAsync(HttpListenerContext context, string responseJson) { try @@ -555,32 +899,186 @@ private static async Task WriteJsonResponseAsync(HttpListenerContext context, st private static async Task WriteEnvelopeResponseAsync(HttpListenerContext context, HttpResponseEnvelope response, string endpoint) { + var claim = ConsoleHttpServiceDependencies.GetInvocationClaim(context); + var invocationTerminalRecorded = false; try { - await WriteJsonResponseAsync(context, JsonUtility.ToJson(response)); + response ??= s_EnvelopeFactory.CreateEnvelope( + false, + "unknown", + "system_error", + "Response envelope was empty.", + "", + "{}"); + string responseJson; + + if (claim != null && !string.IsNullOrEmpty(claim.InvocationId)) + { + claim.UpdatedAtUtc = DateTime.UtcNow.ToString("O"); + if (string.Equals(response.type, "outcome_unknown", StringComparison.Ordinal)) + { + response.invocation = s_InvocationCoordinator.MarkOutcomeUnknown( + claim, + string.IsNullOrEmpty(response.summary) + ? "Invocation outcome is unknown." + : response.summary); + invocationTerminalRecorded = true; + responseJson = JsonUtility.ToJson(response); + } + else + { + response.invocation = + s_InvocationCoordinator.CreateReceipt(claim, "completed", false); + responseJson = JsonUtility.ToJson(response); + if (s_InvocationCoordinator.TryComplete(claim, responseJson, out var persistenceError)) + { + invocationTerminalRecorded = true; + } + else + { + s_InvocationCoordinator.MarkOutcomeUnknown(claim, persistenceError); + invocationTerminalRecorded = true; + response = s_EnvelopeFactory.CreateEnvelope( + false, + "unknown", + "outcome_unknown", + string.IsNullOrEmpty(persistenceError) + ? "Invocation result could not be durably recorded." + : persistenceError, + response.sessionId, + "{}"); + response.invocation = + s_InvocationCoordinator.CreateReceipt(claim, "outcome_unknown", false); + responseJson = JsonUtility.ToJson(response); + } + } + } + else + { + if (claim != null) + { + response.invocation = + s_InvocationCoordinator.CreateReceipt(claim, "none", false); + } + + responseJson = JsonUtility.ToJson(response); + } + + await WriteJsonResponseAsync(context, responseJson); } catch (ObjectDisposedException) { + RecordUnknownInvocationIfNeeded( + claim, + ref invocationTerminalRecorded, + "Response handling stopped before a terminal invocation record was persisted."); ConsoleLog.Warning($"[{endpoint}] Response write skipped (client already disconnected)"); } catch (IOException e) { + RecordUnknownInvocationIfNeeded( + claim, + ref invocationTerminalRecorded, + $"Response handling failed before a terminal invocation record was persisted: {e.Message}"); ConsoleLog.Warning($"[{endpoint}] Response write failed (client disconnected): {e.Message}"); } catch (Exception e) { + RecordUnknownInvocationIfNeeded( + claim, + ref invocationTerminalRecorded, + $"Response persistence failed: {e.Message}"); ConsoleLog.Error($"[{endpoint}] Response write exception: {e}"); } + finally + { + ConsoleHttpServiceDependencies.ReleaseRequest(context); + } + } + + private static void RecordUnknownInvocationIfNeeded( + InvocationClaim claim, + ref bool invocationTerminalRecorded, + string message) + { + if (invocationTerminalRecorded + || claim == null + || string.IsNullOrEmpty(claim.InvocationId)) + { + return; + } + + s_InvocationCoordinator.MarkOutcomeUnknown(claim, message); + invocationTerminalRecorded = true; + } + + private static void RecordMainThreadHeartbeat() + { + Interlocked.Exchange(ref s_MainThreadHeartbeatUtcTicks, DateTime.UtcNow.Ticks); +#if UNITY_EDITOR + s_CachedIsUpdating = EditorApplication.isUpdating; + s_CachedIsPlaying = EditorApplication.isPlaying; +#else + s_CachedIsUpdating = false; + s_CachedIsPlaying = Application.isPlaying; +#endif + } + + private static int GetMainThreadHeartbeatAgeMs() + { + var lastTicks = Interlocked.Read(ref s_MainThreadHeartbeatUtcTicks); + if (lastTicks <= 0) + { + return -1; + } + + var elapsedTicks = Math.Max(0, DateTime.UtcNow.Ticks - lastTicks); + var elapsedMs = elapsedTicks / TimeSpan.TicksPerMillisecond; + return elapsedMs > int.MaxValue ? int.MaxValue : (int)elapsedMs; + } + +#if !UNITY_EDITOR + private sealed class RuntimeHealthHeartbeat : MonoBehaviour + { + private void Update() + { + RecordMainThreadHeartbeat(); + } + } + + private static void EnsureRuntimeHealthHeartbeat() + { + var heartbeatObject = new GameObject("CSharpConsoleHealthHeartbeat") + { + hideFlags = HideFlags.HideAndDontSave + }; + UnityEngine.Object.DontDestroyOnLoad(heartbeatObject); + heartbeatObject.AddComponent(); } +#endif #if UNITY_EDITOR private const string REFRESH_ACTION = "refresh_and_compile"; - private const double REFRESH_GRACE_SECONDS = 2.0; + private const string REFRESH_STATE_ERROR_ACTION = "refresh_state_unreadable"; + private const double REFRESH_COMPILE_START_TIMEOUT_SECONDS = 30.0; + private const double REFRESH_COMPILE_FINISH_TIMEOUT_SECONDS = 300.0; + private const double REFRESH_RELOAD_START_TIMEOUT_SECONDS = 60.0; + private const double REFRESH_POST_COMPILE_QUIET_SECONDS = 2.0; + private const int REFRESH_POST_COMPILE_QUIET_UPDATES = 3; private const double REFRESH_TRIGGER_TIMEOUT_SECONDS = 10.0; + private const double REFRESH_EXIT_PLAYMODE_TIMEOUT_SECONDS = 120.0; + private static readonly object s_RefreshStateGate = new object(); private static RefreshOperationState s_CachedRefreshState; private static long s_RefreshRequestedAtTicks; - private static double s_RefreshTriggeredAtEditorTime; - private static string[] s_PendingChangedFiles; + private static double s_CompileRequestedAtEditorTime; + private static double s_CompileStartedAtEditorTime; + private static double s_CompilationFinishedAtEditorTime; + private static bool s_CompilationStartedObserved; + private static bool s_AssemblyCompilationStartedObserved; + private static bool s_CompilationFinishedObserved; + private static int s_PostCompileQuietUpdateCount; + private static string s_TrackedRefreshOperationId = ""; + private static int s_TrackedRefreshGeneration; // Cached on the main thread so /health (background HTTP thread) can read // them safely. UnityEditor.EditorUtility.scriptCompilationFailed throws @@ -590,13 +1088,31 @@ private static async Task WriteEnvelopeResponseAsync(HttpListenerContext context private static void RefreshCompilationFlagCache() { + RecordMainThreadHeartbeat(); s_CachedIsCompiling = EditorApplication.isCompiling; s_CachedCompileFailed = EditorUtility.scriptCompilationFailed; } private static string GetRefreshStatePath() { - return Path.GetFullPath(Path.Combine(Application.dataPath, "..", "Temp", "CSharpConsole", "refresh_state.json")); + return Path.GetFullPath(Path.Combine( + Application.dataPath, + "..", + "Library", + "CSharpConsole", + "RefreshState", + "v1", + "refresh_state.json")); + } + + private static string GetLegacyRefreshStatePath() + { + return Path.GetFullPath(Path.Combine( + Application.dataPath, + "..", + "Temp", + "CSharpConsole", + "refresh_state.json")); } private static RefreshOperationState LoadRefreshState() @@ -604,134 +1120,733 @@ private static RefreshOperationState LoadRefreshState() try { var path = GetRefreshStatePath(); + var loadingLegacyState = false; + RecoverRefreshStateFile(path); if (!File.Exists(path)) { - return null; + // Read the previous Temp location once for compatibility. + // Initialization persists any valid legacy state into the + // Library-backed canonical path before advertising ready. + path = GetLegacyRefreshStatePath(); + RecoverRefreshStateFile(path); + if (!File.Exists(path)) + { + return null; + } + loadingLegacyState = true; } var json = File.ReadAllText(path); - return string.IsNullOrWhiteSpace(json) - ? null - : NormalizeRefreshState(JsonUtility.FromJson(json)); + if (string.IsNullOrWhiteSpace(json)) + { + return CreateUnreadableRefreshState( + "Refresh state file is empty; the previous refresh outcome is unknown"); + } + + var state = JsonUtility.FromJson(json); + if (state == null) + { + return CreateUnreadableRefreshState( + "Refresh state file could not be decoded; the previous refresh outcome is unknown"); + } + + string validationError; + var valid = loadingLegacyState + ? TryMigrateLegacyRefreshStateDocument( + json, + state, + out validationError) + : TryValidateRefreshStateDocument( + json, + state, + out validationError); + if (!valid) + { + return CreateUnreadableRefreshState( + "Refresh state file is structurally invalid; the previous " + + $"refresh outcome is unknown: {validationError}"); + } + + return NormalizeRefreshState(state); } catch (Exception e) { - ConsoleLog.Debug($"Failed to read refresh state: {e}"); - return null; + ConsoleLog.Warning($"Failed to read refresh state: {e}"); + return CreateUnreadableRefreshState( + $"Refresh state could not be read; the previous refresh outcome is unknown: {e.Message}"); } } - private static RefreshOperationState GetRefreshStateSnapshot() + private static bool TryMigrateLegacyRefreshStateDocument( + string json, + RefreshOperationState state, + out string error) { - if (s_CachedRefreshState != null) + error = ""; + var legacyFields = new[] { - return NormalizeRefreshState(s_CachedRefreshState); - } - - return NormalizeRefreshState(LoadRefreshState() ?? new RefreshOperationState()); - } - - private static void SaveRefreshState(RefreshOperationState state) - { - try + "opId", + "requestedAtUtc", + "action", + "phase", + "compileRequested", + "reloadObserved", + "generation", + "effectivePort", + "message" + }; + foreach (var field in legacyFields) { - state = NormalizeRefreshState(state); - s_CachedRefreshState = state; - var path = GetRefreshStatePath(); - var dir = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(dir)) + if (!ContainsJsonProperty(json, field)) { - Directory.CreateDirectory(dir); + error = $"legacy required field '{field}' is missing"; + return false; } + } - File.WriteAllText(path, JsonUtility.ToJson(state)); + state.triggerStarted = + !string.IsNullOrEmpty(state.opId) + && state.generation > 0; + state.exitPlayModeRequested = false; + state.waitingForEditMode = false; + state.changedFiles = Array.Empty(); + state.changedFileCount = 0; + state.SyncPhaseFromSerialized(); + if (string.IsNullOrEmpty(state.opId) + && state.generation == 0 + && state.PhaseValue == RefreshPhase.Ready) + { + // Older callbacks could persist a synthetic ready state even + // when no refresh operation had ever existed. Normalize that + // discovery-only record to the current pristine shape. + state.requestedAtUtc = ""; + state.action = ""; + state.triggerStarted = false; + state.compileRequested = false; + state.reloadObserved = false; + state.exitPlayModeRequested = false; + state.waitingForEditMode = false; + state.changedFiles = Array.Empty(); + state.changedFileCount = 0; + state.message = ""; + SetPhase(state, RefreshPhase.None); } - catch (Exception e) + if (IsActiveRefreshPhase(state.PhaseValue)) { - ConsoleLog.Warning($"Failed to write refresh state: {e}"); + SetPhase(state, RefreshPhase.Failed); + state.message = + "Legacy refresh was active during the reliability upgrade; " + + "its completion could not be confirmed"; } - } - - private static RefreshOperationState NormalizeRefreshState(RefreshOperationState state) - { - state ??= new RefreshOperationState(); - state.SyncPhaseFromSerialized(); - state.effectivePort = Port; - return state; - } - - private static void SetPhase(RefreshOperationState state, RefreshPhase phase) - { - state.PhaseValue = phase; - } - - private static bool IsActiveRefreshPhase(RefreshPhase phase) - { - return phase == RefreshPhase.Requested - || phase == RefreshPhase.RefreshingAssets - || phase == RefreshPhase.Compiling - || phase == RefreshPhase.Reloading; - } - - private static void UpdateRefreshState(Action update) - { - var state = GetRefreshStateSnapshot(); - update(state); - SaveRefreshState(state); - } - private static int GetCurrentGeneration() - { - return Mathf.Max(0, GetRefreshStateSnapshot().generation); + return TryValidateRefreshStateDocument( + JsonUtility.ToJson(state), + state, + out error); } - private static void MarkRefreshFailed(string message) + private static RefreshOperationState CreateUnreadableRefreshState(string message) { - UpdateRefreshState(state => + return new RefreshOperationState { - SetPhase(state, RefreshPhase.Failed); - state.message = message ?? "Refresh failed"; - }); + PhaseValue = RefreshPhase.Failed, + action = REFRESH_STATE_ERROR_ACTION, + effectivePort = Port, + message = message ?? "Refresh state is unreadable" + }; } - private static void MarkRefreshReady(string message = null) - { - UpdateRefreshState(state => + private static bool TryValidateRefreshStateDocument( + string json, + RefreshOperationState state, + out string error) + { + error = ""; + var requiredFields = new[] + { + "opId", + "requestedAtUtc", + "action", + "phase", + "triggerStarted", + "compileRequested", + "reloadObserved", + "exitPlayModeRequested", + "waitingForEditMode", + "changedFiles", + "changedFileCount", + "generation", + "effectivePort", + "message" + }; + foreach (var field in requiredFields) { - SetPhase(state, RefreshPhase.Ready); - state.message = string.IsNullOrEmpty(message) ? "Refresh completed" : message; - }); - } + if (!ContainsJsonProperty(json, field)) + { + error = $"required field '{field}' is missing"; + return false; + } + } - private static string GetEditorState(RefreshOperationState state) - { - if (!s_Initialized) + var serializedPhase = state.phase ?? ""; + var pristine = string.IsNullOrEmpty(state.opId) + && string.IsNullOrEmpty(state.requestedAtUtc) + && string.IsNullOrEmpty(state.action) + && string.IsNullOrEmpty(serializedPhase) + && !state.triggerStarted + && !state.compileRequested + && !state.reloadObserved + && !state.exitPlayModeRequested + && !state.waitingForEditMode + && (state.changedFiles == null || state.changedFiles.Length == 0) + && state.changedFileCount == 0 + && state.generation == 0 + && string.IsNullOrEmpty(state.message); + if (pristine) { - return "stopped"; + return true; } - state = NormalizeRefreshState(state); - if (state.PhaseValue == RefreshPhase.None) + var unreadableMarker = + string.Equals( + state.action, + REFRESH_STATE_ERROR_ACTION, + StringComparison.Ordinal) + && string.Equals(serializedPhase, "failed", StringComparison.Ordinal) + && string.IsNullOrEmpty(state.opId) + && string.IsNullOrEmpty(state.requestedAtUtc) + && state.generation == 0 + && !state.triggerStarted + && !state.compileRequested + && !state.reloadObserved + && !state.exitPlayModeRequested + && !state.waitingForEditMode + && (state.changedFiles == null || state.changedFiles.Length == 0) + && state.changedFileCount == 0 + && !string.IsNullOrEmpty(state.message); + if (unreadableMarker) { - return PhaseToString(RefreshPhase.Ready); + return true; } - if (state.PhaseValue == RefreshPhase.Failed) + if (!Guid.TryParseExact(state.opId, "N", out _)) { - return PhaseToString(RefreshPhase.Failed); + error = "opId is missing or is not a 32-character UUID"; + return false; } - - if (IsActiveRefreshPhase(state.PhaseValue)) + if (state.generation <= 0) { - return PhaseToString(state.PhaseValue); + error = "generation must be positive for an operation record"; + return false; + } + if (state.changedFileCount < 0 + || state.changedFileCount + != (state.changedFiles?.Length ?? 0)) + { + error = "changedFileCount does not match changedFiles"; + return false; + } + if (!string.Equals(state.action, REFRESH_ACTION, StringComparison.Ordinal)) + { + error = $"action must be '{REFRESH_ACTION}'"; + return false; + } + if (!DateTimeOffset.TryParseExact( + state.requestedAtUtc, + "O", + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out _)) + { + error = "requestedAtUtc is missing or is not an ISO-8601 round-trip timestamp"; + return false; } - return PhaseToString(RefreshPhase.Ready); - } -#else - private static RefreshOperationState GetRefreshStateSnapshot() - { - return new RefreshOperationState(); + var phase = ParsePhase(serializedPhase); + if (phase == RefreshPhase.None) + { + error = $"phase '{serializedPhase}' is not recognized"; + return false; + } + if (phase == RefreshPhase.Requested + && state.triggerStarted + && !state.exitPlayModeRequested) + { + error = "a started requested phase is only valid while exiting Play Mode"; + return false; + } + if ((phase == RefreshPhase.RefreshingAssets + || phase == RefreshPhase.Compiling + || phase == RefreshPhase.Reloading) + && !state.triggerStarted) + { + error = $"phase '{serializedPhase}' requires triggerStarted=true"; + return false; + } + if (state.waitingForEditMode + && ( + phase != RefreshPhase.Requested + || !state.triggerStarted + || !state.exitPlayModeRequested + )) + { + error = "waitingForEditMode is inconsistent with the requested phase"; + return false; + } + if (state.reloadObserved + && ( + phase != RefreshPhase.Ready + || !state.triggerStarted + )) + { + error = "reloadObserved is only valid for a completed started operation"; + return false; + } + + return true; + } + + private static bool ContainsJsonProperty(string json, string propertyName) + { + if (string.IsNullOrEmpty(json) || string.IsNullOrEmpty(propertyName)) + { + return false; + } + + var marker = $"\"{propertyName}\""; + var index = 0; + while ((index = json.IndexOf(marker, index, StringComparison.Ordinal)) >= 0) + { + var cursor = index + marker.Length; + while (cursor < json.Length && char.IsWhiteSpace(json[cursor])) + { + cursor++; + } + if (cursor < json.Length && json[cursor] == ':') + { + return true; + } + index = cursor; + } + + return false; + } + + private static void RecoverRefreshStateFile(string path) + { + var backupPath = path + ".backup"; + var directory = Path.GetDirectoryName(path); + if (!File.Exists(path) && File.Exists(backupPath)) + { + File.Move(backupPath, path); + } + + if (File.Exists(path) && File.Exists(backupPath)) + { + try + { + File.Delete(backupPath); + } + catch (Exception e) + { + ConsoleLog.Debug($"Failed to remove recovered refresh-state backup: {e.Message}"); + } + } + + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) + { + return; + } + + foreach (var staleTempPath in Directory.GetFiles( + directory, + Path.GetFileName(path) + ".tmp.*")) + { + try + { + File.Delete(staleTempPath); + } + catch (Exception e) + { + ConsoleLog.Debug($"Failed to remove stale refresh-state temp file: {e.Message}"); + } + } + } + + private static RefreshOperationState GetRefreshStateSnapshot() + { + lock (s_RefreshStateGate) + { + var state = s_CachedRefreshState ?? LoadRefreshState() ?? new RefreshOperationState(); + return CloneRefreshState(state); + } + } + + private static RefreshOperationState CloneRefreshState(RefreshOperationState state) + { + state ??= new RefreshOperationState(); + var clone = new RefreshOperationState + { + opId = state.opId ?? "", + requestedAtUtc = state.requestedAtUtc ?? "", + action = state.action ?? "", + phase = state.phase ?? "", + triggerStarted = state.triggerStarted, + compileRequested = state.compileRequested, + reloadObserved = state.reloadObserved, + exitPlayModeRequested = state.exitPlayModeRequested, + waitingForEditMode = state.waitingForEditMode, + changedFiles = state.changedFiles == null + ? Array.Empty() + : (string[])state.changedFiles.Clone(), + changedFileCount = state.changedFiles?.Length ?? 0, + generation = state.generation, + effectivePort = Port, + message = state.message ?? "" + }; + clone.SyncPhaseFromSerialized(); + return clone; + } + + private static bool TrySaveRefreshState(RefreshOperationState state, out string error) + { + error = ""; + try + { + state = NormalizeRefreshState(state); + var persistedState = CloneRefreshState(state); + var json = JsonUtility.ToJson(persistedState); + var path = GetRefreshStatePath(); + lock (s_RefreshStateGate) + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + WriteRefreshStateDurably(path, json); + // Never publish a transition to /health until the same + // transition is durably visible to the next service epoch. + s_CachedRefreshState = CloneRefreshState(persistedState); + } + + return true; + } + catch (Exception e) + { + error = e.Message; + ConsoleLog.Warning($"Failed to write refresh state: {e}"); + return false; + } + } + + private static void WriteRefreshStateDurably(string path, string json) + { + var tempPath = path + $".tmp.{Guid.NewGuid():N}"; + try + { + var bytes = new UTF8Encoding(false).GetBytes(json ?? ""); + using (var stream = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + stream.Write(bytes, 0, bytes.Length); + stream.Flush(true); + } + + if (File.Exists(path)) + { + try + { + File.Replace(tempPath, path, null); + } + catch (PlatformNotSupportedException) + { + ReplaceRefreshStateWithBackup(path, tempPath); + } + } + else + { + File.Move(tempPath, path); + } + } + finally + { + if (File.Exists(tempPath)) + { + try + { + File.Delete(tempPath); + } + catch + { + // Best-effort cleanup; the canonical file has already + // been durably written or the caller will fail closed. + } + } + } + } + + private static void ReplaceRefreshStateWithBackup(string path, string tempPath) + { + // Compatibility path for Unity/Mono profiles without File.Replace. + // Never truncate the canonical file. Both renames are same-volume, + // and startup restores the old canonical from .backup if the + // process stops between them. + var backupPath = path + ".backup"; + if (File.Exists(backupPath)) + { + File.Delete(backupPath); + } + + File.Move(path, backupPath); + try + { + File.Move(tempPath, path); + } + catch + { + if (!File.Exists(path) && File.Exists(backupPath)) + { + File.Move(backupPath, path); + } + throw; + } + + try + { + File.Delete(backupPath); + } + catch (Exception e) + { + // The new canonical is complete. Startup can safely discard + // the older backup if immediate cleanup is unavailable. + ConsoleLog.Debug($"Failed to remove refresh-state backup: {e.Message}"); + } + } + + private static RefreshOperationState RecordRefreshPersistenceFailure( + RefreshOperationState basis, + string message) + { + var failedState = + string.IsNullOrEmpty(basis?.opId) || (basis?.generation ?? 0) <= 0 + ? CreateUnreadableRefreshState( + string.IsNullOrEmpty(message) + ? "Refresh state could not be durably recorded" + : message) + : CloneRefreshState(basis); + if (!string.Equals( + failedState.action, + REFRESH_STATE_ERROR_ACTION, + StringComparison.Ordinal)) + { + SetPhase(failedState, RefreshPhase.Failed); + failedState.waitingForEditMode = false; + failedState.message = string.IsNullOrEmpty(message) + ? "Refresh state could not be durably recorded" + : message; + } + + if (!TrySaveRefreshState(failedState, out var failureError)) + { + // The durable marker could not be written, but the live + // service must still report an explicit failure rather than + // the transition that failed to persist. + lock (s_RefreshStateGate) + { + s_CachedRefreshState = CloneRefreshState(failedState); + } + ConsoleLog.Warning( + $"Refresh failure marker is not durable: {failureError}. " + + "A later service epoch will treat the last persisted active state as interrupted."); + } + + return failedState; + } + + private static RefreshOperationState NormalizeRefreshState(RefreshOperationState state) + { + state ??= new RefreshOperationState(); + state.SyncPhaseFromSerialized(); + state.effectivePort = Port; + return state; + } + + private static void SetPhase(RefreshOperationState state, RefreshPhase phase) + { + state.PhaseValue = phase; + } + + private static bool IsActiveRefreshPhase(RefreshPhase phase) + { + return phase == RefreshPhase.Requested + || phase == RefreshPhase.RefreshingAssets + || phase == RefreshPhase.Compiling + || phase == RefreshPhase.Reloading; + } + + private static bool TryUpdateRefreshState( + string expectedOperationId, + int expectedGeneration, + Action update) + { + lock (s_RefreshStateGate) + { + var state = CloneRefreshState( + s_CachedRefreshState + ?? LoadRefreshState() + ?? new RefreshOperationState()); + if (!string.Equals( + state.opId, + expectedOperationId, + StringComparison.Ordinal) + || state.generation != expectedGeneration) + { + return false; + } + + update(state); + if (TrySaveRefreshState(state, out var error)) + { + return true; + } + + RecordRefreshPersistenceFailure( + state, + $"Refresh state transition could not be durably recorded; " + + $"the operation outcome may be unknown: {error}"); + return false; + } + } + + private static bool MarkRefreshFailed( + string operationId, + int generation, + string message) + { + return TryUpdateRefreshState(operationId, generation, state => + { + SetPhase(state, RefreshPhase.Failed); + state.waitingForEditMode = false; + state.message = message ?? "Refresh failed"; + }); + } + + private static bool MarkRefreshReady( + string operationId, + int generation, + string message = null) + { + return TryUpdateRefreshState(operationId, generation, state => + { + SetPhase(state, RefreshPhase.Ready); + state.waitingForEditMode = false; + state.message = string.IsNullOrEmpty(message) ? "Refresh completed" : message; + }); + } + + private static bool IsTrackedRefreshOperation(RefreshOperationState state) + { + return state != null + && !string.IsNullOrEmpty(s_TrackedRefreshOperationId) + && string.Equals( + state.opId, + s_TrackedRefreshOperationId, + StringComparison.Ordinal) + && state.generation == s_TrackedRefreshGeneration; + } + + private static void TrackRefreshOperation(RefreshOperationState state) + { + if (state == null) + { + s_TrackedRefreshOperationId = ""; + s_TrackedRefreshGeneration = 0; + return; + } + + s_TrackedRefreshOperationId = state.opId ?? ""; + s_TrackedRefreshGeneration = state.generation; + s_CompilationStartedObserved = false; + s_AssemblyCompilationStartedObserved = false; + s_CompilationFinishedObserved = false; + s_CompileRequestedAtEditorTime = 0; + s_CompileStartedAtEditorTime = 0; + s_CompilationFinishedAtEditorTime = 0; + s_PostCompileQuietUpdateCount = 0; + if (DateTimeOffset.TryParseExact( + state.requestedAtUtc, + "O", + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out var requestedAt)) + { + s_RefreshRequestedAtTicks = requestedAt.UtcDateTime.Ticks; + } + } + + private static double GetRefreshElapsedSeconds(RefreshOperationState state) + { + var requestedTicks = s_RefreshRequestedAtTicks; + if (requestedTicks <= 0 + && DateTimeOffset.TryParseExact( + state?.requestedAtUtc, + "O", + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out var requestedAt)) + { + requestedTicks = requestedAt.UtcDateTime.Ticks; + } + + return requestedTicks <= 0 + ? double.MaxValue + : Math.Max( + 0, + (DateTime.UtcNow.Ticks - requestedTicks) + / (double)TimeSpan.TicksPerSecond); + } + + private static void BeginCompilationObservation() + { + s_CompilationStartedObserved = false; + s_AssemblyCompilationStartedObserved = false; + s_CompilationFinishedObserved = false; + s_CompileRequestedAtEditorTime = EditorApplication.timeSinceStartup; + s_CompileStartedAtEditorTime = 0; + s_CompilationFinishedAtEditorTime = 0; + s_PostCompileQuietUpdateCount = 0; + } + + private static string GetEditorState(RefreshOperationState state) + { + if (!s_Initialized) + { + return "stopped"; + } + + state = NormalizeRefreshState(state); + if (state.PhaseValue == RefreshPhase.None) + { + return PhaseToString(RefreshPhase.Ready); + } + + if (state.PhaseValue == RefreshPhase.Failed) + { + return PhaseToString(RefreshPhase.Failed); + } + + if (IsActiveRefreshPhase(state.PhaseValue)) + { + return PhaseToString(state.PhaseValue); + } + + return PhaseToString(RefreshPhase.Ready); + } +#else + private static RefreshOperationState GetRefreshStateSnapshot() + { + return new RefreshOperationState(); } private static bool IsActiveRefreshPhase(RefreshPhase phase) @@ -750,12 +1865,16 @@ public static void RegisterRefreshLifecycleCallbacks() { CompilationPipeline.compilationStarted -= OnCompilationStarted; CompilationPipeline.compilationStarted += OnCompilationStarted; + CompilationPipeline.assemblyCompilationStarted -= OnAssemblyCompilationStarted; + CompilationPipeline.assemblyCompilationStarted += OnAssemblyCompilationStarted; CompilationPipeline.compilationFinished -= OnCompilationFinished; CompilationPipeline.compilationFinished += OnCompilationFinished; AssemblyReloadEvents.beforeAssemblyReload -= OnBeforeAssemblyReload; AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload; AssemblyReloadEvents.afterAssemblyReload -= OnAfterAssemblyReload; AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload; + EditorApplication.playModeStateChanged -= OnRefreshPlayModeStateChanged; + EditorApplication.playModeStateChanged += OnRefreshPlayModeStateChanged; EditorApplication.update -= OnEditorUpdate; EditorApplication.update += OnEditorUpdate; RefreshCompilationFlagCache(); @@ -763,106 +1882,201 @@ public static void RegisterRefreshLifecycleCallbacks() private static async Task ProcessRefresh(HttpListenerContext context) { - RefreshResponse responseData; + RefreshResponse responseData = null; + var resultType = "system_error"; + var scheduleRefresh = false; + var exitPlayModeRequested = false; + var changedFiles = Array.Empty(); + var requestBodyRead = false; + var requestParsed = false; + var acceptedOperationId = ""; + var acceptedGeneration = 0; try { var body = await ConsoleHttpServiceDependencies.ReadRequestBodyAsync(context); - var request = !string.IsNullOrWhiteSpace(body) - ? JsonUtility.FromJson(body) - : null; - - var exitPlayModeRequested = false; - if (request != null && request.exitPlayModeIfNeeded) + requestBodyRead = true; + RefreshRequest request; + if (string.IsNullOrWhiteSpace(body)) { - MainThreadRequestRunner.Post(() => + request = new RefreshRequest(); + } + else + { + request = JsonUtility.FromJson(body); + if (request == null) { - if (EditorApplication.isPlaying) - { - EditorApplication.isPlaying = false; - } - }); - exitPlayModeRequested = true; + throw new FormatException( + "Refresh request body is not a JSON object"); + } + } + exitPlayModeRequested = request.exitPlayModeIfNeeded; + changedFiles = request.changedFiles ?? Array.Empty(); + foreach (var path in changedFiles) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new FormatException( + "Refresh changedFiles entries must be non-empty strings"); + } } + requestParsed = true; var current = GetRefreshStateSnapshot(); if (IsActiveRefreshPhase(current.PhaseValue)) { responseData = new RefreshResponse { - ok = true, + ok = false, accepted = false, sessionsCleared = false, refreshing = true, - exitPlayModeRequested = exitPlayModeRequested, + exitPlayModeRequested = false, + generation = current.generation, + message = "Refresh already in progress; this request was not accepted", + operation = CreatePublicRefreshState(current) + }; + } + else if (s_CachedIsCompiling || s_CachedIsUpdating) + { + responseData = new RefreshResponse + { + ok = false, + accepted = false, + sessionsCleared = false, + refreshing = false, + exitPlayModeRequested = false, generation = current.generation, - message = "Refresh already in progress", - operation = current + message = + "Refresh was not accepted because Unity is already " + + "compiling or updating; wait for ready first", + operation = CreatePublicRefreshState(current) }; } else { var nextGeneration = Mathf.Max(0, current.generation) + 1; - var requestedAtUtc = DateTime.UtcNow.ToString("O"); + var requestedAt = DateTimeOffset.UtcNow; + var requestedAtUtc = requestedAt.ToString("O"); var opId = Guid.NewGuid().ToString("N"); var nextState = new RefreshOperationState { opId = opId, requestedAtUtc = requestedAtUtc, action = REFRESH_ACTION, + triggerStarted = false, compileRequested = true, reloadObserved = false, + exitPlayModeRequested = exitPlayModeRequested, + waitingForEditMode = false, + changedFiles = (string[])changedFiles.Clone(), + changedFileCount = changedFiles.Length, generation = nextGeneration, message = "Refresh requested", PhaseValue = RefreshPhase.Requested }; - SaveRefreshState(nextState); - - ClearSessionState(); - - // Record request time (thread-safe) so OnEditorUpdate doesn't - // use a stale s_RefreshTriggeredAtEditorTime from a previous refresh. - s_RefreshRequestedAtTicks = DateTimeOffset.UtcNow.Ticks; - - // Pass explicit file list to TriggerRefresh (if provided). - s_PendingChangedFiles = request?.changedFiles; - - responseData = new RefreshResponse + // Initialize every in-memory dependency before publishing + // Requested to /health. Main-thread callbacks may observe + // the cached state immediately after this durable write. + s_RefreshRequestedAtTicks = requestedAt.Ticks; + if (!TrySaveRefreshState(nextState, out var acceptancePersistenceError)) { - ok = true, - accepted = true, - sessionsCleared = true, - refreshing = true, - exitPlayModeRequested = exitPlayModeRequested, - generation = nextState.generation, - message = "Refresh and script compilation scheduled. Existing compiler/executor sessions were cleared.", - operation = nextState - }; + var failureMessage = + "Refresh request was not accepted because its state could not be " + + $"durably recorded: {acceptancePersistenceError}"; + responseData = new RefreshResponse + { + ok = false, + accepted = false, + sessionsCleared = false, + refreshing = false, + exitPlayModeRequested = false, + generation = current.generation, + message = failureMessage, + operation = CreatePublicRefreshState(current) + }; + } + else + { + acceptedOperationId = nextState.opId; + acceptedGeneration = nextState.generation; + TrackRefreshOperation(nextState); + ClearSessionState(); - // Schedule on main thread via the thread-safe dispatcher. - // EditorApplication.delayCall is not reliable from HTTP threads. - MainThreadRequestRunner.Post(TriggerRefresh); + responseData = new RefreshResponse + { + ok = true, + accepted = true, + sessionsCleared = true, + refreshing = true, + exitPlayModeRequested = exitPlayModeRequested, + generation = nextState.generation, + message = "Refresh and script compilation scheduled. Existing compiler/executor sessions were cleared.", + operation = CreatePublicRefreshState(nextState) + }; + resultType = "ok"; + scheduleRefresh = true; + } } } catch (Exception e) { - MarkRefreshFailed(e.ToString()); + var accepted = !string.IsNullOrEmpty(acceptedOperationId); + if (accepted) + { + MarkRefreshFailed( + acceptedOperationId, + acceptedGeneration, + e.ToString()); + } + resultType = + requestBodyRead && !requestParsed + ? "validation_error" + : "system_error"; + var current = GetRefreshStateSnapshot(); responseData = new RefreshResponse { ok = false, - accepted = false, + accepted = accepted, sessionsCleared = false, - refreshing = false, - generation = GetCurrentGeneration(), + refreshing = IsActiveRefreshPhase(current.PhaseValue), + exitPlayModeRequested = accepted && exitPlayModeRequested, + generation = current.generation, message = e.ToString(), - operation = GetRefreshStateSnapshot() + operation = CreatePublicRefreshState(current) }; } var ok = responseData.ok; - var resultType = ok ? "ok" : "system_error"; var summary = responseData.message ?? (ok ? "Refresh accepted" : "Refresh failed"); var envelope = s_EnvelopeFactory.CreateEnvelope(ok, "bootstrap", resultType, summary, "", JsonUtility.ToJson(responseData)); + var invocationClaim = ConsoleHttpServiceDependencies.GetInvocationClaim(context); await WriteEnvelopeResponseAsync(context, envelope, "Refresh"); + + if (scheduleRefresh) + { + var acceptanceIsDurable = invocationClaim == null + || string.IsNullOrEmpty(invocationClaim.InvocationId) + || invocationClaim.TerminalResponsePersisted; + if (acceptanceIsDurable) + { + // Persist the acceptance before any operation that can + // trigger a domain reload. This prevents an accepted + // refresh from being downgraded to an unknown invocation + // solely because its own reload closed the HTTP response. + var scheduledOperationId = responseData.operation?.opId ?? ""; + var scheduledGeneration = responseData.operation?.generation ?? 0; + MainThreadRequestRunner.Post(() => TriggerRefresh( + scheduledOperationId, + scheduledGeneration)); + } + else + { + MarkRefreshFailed( + acceptedOperationId, + acceptedGeneration, + "Refresh acceptance could not be durably recorded; refresh was not started"); + } + } } [System.Runtime.InteropServices.DllImport("user32.dll")] @@ -884,24 +2098,137 @@ private static void ActivateEditorWindow() catch { /* best-effort, non-Windows platforms ignore this */ } } - private static void TriggerRefresh() - { + private static void TriggerRefresh( + string expectedOperationId, + int expectedGeneration) + { + var requestedState = GetRefreshStateSnapshot(); + if (!string.Equals( + requestedState.opId, + expectedOperationId, + StringComparison.Ordinal) + || requestedState.generation != expectedGeneration + || requestedState.PhaseValue != RefreshPhase.Requested + || requestedState.triggerStarted) + { + ConsoleLog.Warning( + $"Skipped stale refresh trigger for operation " + + $"{expectedOperationId}/{expectedGeneration}; current operation is " + + $"{requestedState.opId}/{requestedState.generation} " + + $"({requestedState.phase}, triggerStarted={requestedState.triggerStarted})."); + return; + } + + if (EditorApplication.isCompiling || EditorApplication.isUpdating) + { + MarkRefreshFailed( + expectedOperationId, + expectedGeneration, + "Refresh trigger did not start because Unity became busy " + + "with unrelated compilation or asset updates"); + return; + } + + var shouldExitPlayMode = + requestedState.exitPlayModeRequested + && ( + EditorApplication.isPlaying + || EditorApplication.isPlayingOrWillChangePlaymode + ); + if (!TryUpdateRefreshState( + expectedOperationId, + expectedGeneration, + state => + { + state.triggerStarted = true; + state.waitingForEditMode = shouldExitPlayMode; + state.message = shouldExitPlayMode + ? "Exiting Play Mode before refresh" + : "Refresh trigger started"; + })) + { + ConsoleLog.Warning( + $"Refresh trigger state for {expectedOperationId}/" + + $"{expectedGeneration} could not be durably recorded. " + + "No Play Mode exit or asset refresh was dispatched."); + return; + } + try { - s_RefreshTriggeredAtEditorTime = EditorApplication.timeSinceStartup; + if (shouldExitPlayMode) + { + EditorApplication.isPlaying = false; + return; + } + + ContinueRefresh(expectedOperationId, expectedGeneration); + } + catch (Exception e) + { + MarkRefreshFailed( + expectedOperationId, + expectedGeneration, + e.ToString()); + ConsoleLog.Warning($"Refresh failed: {e}"); + } + } - // Consume the pending file list (set by ProcessRefresh on the HTTP thread). - var explicitFiles = s_PendingChangedFiles; - s_PendingChangedFiles = null; + private static void ContinueRefresh( + string expectedOperationId, + int expectedGeneration) + { + var state = GetRefreshStateSnapshot(); + if (!string.Equals( + state.opId, + expectedOperationId, + StringComparison.Ordinal) + || state.generation != expectedGeneration + || state.PhaseValue != RefreshPhase.Requested + || !state.triggerStarted + || state.waitingForEditMode) + { + return; + } + if (state.exitPlayModeRequested + && ( + EditorApplication.isPlaying + || EditorApplication.isPlayingOrWillChangePlaymode + )) + { + return; + } + if (EditorApplication.isCompiling || EditorApplication.isUpdating) + { + // The expected Play Mode transition can briefly compile or + // update assets. OnEditorUpdate resumes this same operation + // once the Editor is idle; it never creates a second intent. + return; + } - if (explicitFiles != null && explicitFiles.Length > 0) - TriggerRefreshTargeted(explicitFiles); + try + { + var files = state.changedFiles ?? Array.Empty(); + if (files.Length > 0) + { + TriggerRefreshTargeted( + expectedOperationId, + expectedGeneration, + files); + } else - TriggerRefreshFull(); + { + TriggerRefreshFull( + expectedOperationId, + expectedGeneration); + } } catch (Exception e) { - MarkRefreshFailed(e.ToString()); + MarkRefreshFailed( + expectedOperationId, + expectedGeneration, + e.ToString()); ConsoleLog.Warning($"Refresh failed: {e}"); } } @@ -910,26 +2237,42 @@ private static void TriggerRefresh() /// Targeted refresh: caller provides exact file paths. /// Fast — no directory scanning, works for any path (Assets/, Packages/, etc.). /// - private static void TriggerRefreshTargeted(string[] files) + private static void TriggerRefreshTargeted( + string expectedOperationId, + int expectedGeneration, + string[] files) { - UpdateRefreshState(state => + var scriptCount = 0; + foreach (var path in files) { - SetPhase(state, RefreshPhase.RefreshingAssets); - state.message = $"Importing {files.Length} file(s)"; - }); + if (path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + { + scriptCount++; + } + } + var otherCount = files.Length - scriptCount; + + if (!TryUpdateRefreshState( + expectedOperationId, + expectedGeneration, + state => + { + state.compileRequested = true; + SetPhase(state, RefreshPhase.RefreshingAssets); + state.message = + $"Importing {files.Length} file(s) before compilation"; + })) + { + return; + } + BeginCompilationObservation(); - var scriptCount = 0; - var otherCount = 0; try { AssetDatabase.StartAssetEditing(); foreach (var path in files) { AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport); - if (path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) - scriptCount++; - else - otherCount++; } } finally @@ -937,25 +2280,68 @@ private static void TriggerRefreshTargeted(string[] files) AssetDatabase.StopAssetEditing(); } - if (scriptCount > 0) - CompilationPipeline.RequestScriptCompilation(); + var afterImport = GetRefreshStateSnapshot(); + if (!IsTrackedRefreshOperation(afterImport) + || !string.Equals( + afterImport.opId, + expectedOperationId, + StringComparison.Ordinal) + || afterImport.generation != expectedGeneration + || !IsActiveRefreshPhase(afterImport.PhaseValue)) + { + return; + } - UpdateRefreshState(state => + if (EditorApplication.isCompiling + && !s_CompilationStartedObserved) { - state.compileRequested = scriptCount > 0; - if (EditorApplication.isCompiling) - { - SetPhase(state, RefreshPhase.Compiling); - state.message = $"Compiling ({scriptCount} script(s), {otherCount} asset(s) updated)"; - } - else + s_CompilationStartedObserved = true; + s_CompilationFinishedObserved = false; + s_CompileStartedAtEditorTime = + EditorApplication.timeSinceStartup; + s_PostCompileQuietUpdateCount = 0; + TryUpdateRefreshState( + expectedOperationId, + expectedGeneration, + state => + { + SetPhase(state, RefreshPhase.Compiling); + state.message = "Script compilation is in progress"; + }); + } + + if (s_CompilationStartedObserved + || s_CompilationFinishedObserved + || afterImport.PhaseValue == RefreshPhase.Compiling + || EditorApplication.isCompiling) + { + TryUpdateRefreshState( + expectedOperationId, + expectedGeneration, + state => + { + if (state.PhaseValue == RefreshPhase.RefreshingAssets) + { + state.message = + $"Waiting for compilation ({scriptCount} script(s), " + + $"{otherCount} other asset(s) imported)"; + } + }); + return; + } + + TryUpdateRefreshState( + expectedOperationId, + expectedGeneration, + state => { - SetPhase(state, RefreshPhase.RefreshingAssets); - state.message = scriptCount > 0 - ? $"Waiting for compilation ({scriptCount} script(s), {otherCount} asset(s) updated)" - : $"{otherCount} non-script asset(s) refreshed"; - } - }); + state.message = + $"Requesting compilation ({scriptCount} script(s), " + + $"{otherCount} other asset(s) imported)"; + }); + s_CompileRequestedAtEditorTime = + EditorApplication.timeSinceStartup; + CompilationPipeline.RequestScriptCompilation(); } /// @@ -964,13 +2350,23 @@ private static void TriggerRefreshTargeted(string[] files) /// lets AssetDatabase.Refresh() handle everything — detection, import, /// compilation, and domain reload are all managed by Unity. /// - private static void TriggerRefreshFull() - { - UpdateRefreshState(state => + private static void TriggerRefreshFull( + string expectedOperationId, + int expectedGeneration) + { + if (!TryUpdateRefreshState( + expectedOperationId, + expectedGeneration, + state => + { + state.compileRequested = true; + SetPhase(state, RefreshPhase.RefreshingAssets); + state.message = "Activating editor and refreshing assets"; + })) { - SetPhase(state, RefreshPhase.RefreshingAssets); - state.message = "Activating editor and refreshing assets"; - }); + return; + } + BeginCompilationObservation(); // Bring editor to foreground so the OS file-watcher queue is flushed. // Without this, Refresh() misses external changes when Unity is in the background. @@ -979,20 +2375,68 @@ private static void TriggerRefreshFull() // Unity handles everything: detect changes, import, trigger compilation. AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); - UpdateRefreshState(state => + var afterRefresh = GetRefreshStateSnapshot(); + if (!IsTrackedRefreshOperation(afterRefresh) + || !string.Equals( + afterRefresh.opId, + expectedOperationId, + StringComparison.Ordinal) + || afterRefresh.generation != expectedGeneration + || !IsActiveRefreshPhase(afterRefresh.PhaseValue)) { - state.compileRequested = EditorApplication.isCompiling; - if (EditorApplication.isCompiling) - { - SetPhase(state, RefreshPhase.Compiling); - state.message = "Compiling after full asset refresh"; - } - else + return; + } + + if (EditorApplication.isCompiling + && !s_CompilationStartedObserved) + { + s_CompilationStartedObserved = true; + s_CompilationFinishedObserved = false; + s_CompileStartedAtEditorTime = + EditorApplication.timeSinceStartup; + s_PostCompileQuietUpdateCount = 0; + TryUpdateRefreshState( + expectedOperationId, + expectedGeneration, + state => + { + SetPhase(state, RefreshPhase.Compiling); + state.message = "Compiling after full asset refresh"; + }); + } + + if (s_CompilationStartedObserved + || s_CompilationFinishedObserved + || afterRefresh.PhaseValue == RefreshPhase.Compiling + || EditorApplication.isCompiling) + { + TryUpdateRefreshState( + expectedOperationId, + expectedGeneration, + state => + { + if (state.PhaseValue == RefreshPhase.RefreshingAssets) + { + state.message = + "Waiting for compilation after full asset refresh"; + } + }); + return; + } + + TryUpdateRefreshState( + expectedOperationId, + expectedGeneration, + state => { - SetPhase(state, RefreshPhase.RefreshingAssets); - state.message = "Asset refresh completed"; - } - }); + state.message = "Waiting for compilation after full asset refresh"; + }); + + // A full `refresh and compile` request must not infer "no compile" + // from one transient false isCompiling sample. + s_CompileRequestedAtEditorTime = + EditorApplication.timeSinceStartup; + CompilationPipeline.RequestScriptCompilation(); } // ImportChangedScripts and timestamp persistence removed — @@ -1002,53 +2446,186 @@ private static void TriggerRefreshFull() private static void OnCompilationStarted(object _) { - UpdateRefreshState(state => + var state = GetRefreshStateSnapshot(); + if (!IsTrackedRefreshOperation(state) + || !state.triggerStarted + || ( + state.PhaseValue != RefreshPhase.RefreshingAssets + && state.PhaseValue != RefreshPhase.Compiling + )) { - if (IsActiveRefreshPhase(state.PhaseValue)) + return; + } + + s_CompilationStartedObserved = true; + s_AssemblyCompilationStartedObserved = false; + s_CompilationFinishedObserved = false; + s_CompileStartedAtEditorTime = + EditorApplication.timeSinceStartup; + s_CompilationFinishedAtEditorTime = 0; + s_PostCompileQuietUpdateCount = 0; + TryUpdateRefreshState( + state.opId, + state.generation, + current => { - SetPhase(state, RefreshPhase.Compiling); - state.message = "Script compilation started"; - } - }); + if (current.triggerStarted + && ( + current.PhaseValue == RefreshPhase.RefreshingAssets + || current.PhaseValue == RefreshPhase.Compiling + )) + { + SetPhase(current, RefreshPhase.Compiling); + current.message = "Script compilation started"; + } + }); + } + + private static void OnAssemblyCompilationStarted(string _) + { + var state = GetRefreshStateSnapshot(); + if (IsTrackedRefreshOperation(state) + && state.triggerStarted + && ( + state.PhaseValue == RefreshPhase.RefreshingAssets + || state.PhaseValue == RefreshPhase.Compiling + )) + { + s_AssemblyCompilationStartedObserved = true; + } } private static void OnCompilationFinished(object _) { - UpdateRefreshState(state => + var state = GetRefreshStateSnapshot(); + if (!IsTrackedRefreshOperation(state) + || !state.triggerStarted + || ( + state.PhaseValue != RefreshPhase.RefreshingAssets + && state.PhaseValue != RefreshPhase.Compiling + )) { - if (state.PhaseValue == RefreshPhase.Compiling) - { - state.message = "Script compilation finished, waiting for reload or idle"; - } - }); + return; + } - EditorApplication.delayCall -= FinalizeRefreshAfterCompile; - EditorApplication.delayCall += FinalizeRefreshAfterCompile; + s_CompilationStartedObserved = true; + s_CompilationFinishedObserved = true; + s_CompilationFinishedAtEditorTime = + EditorApplication.timeSinceStartup; + s_PostCompileQuietUpdateCount = 0; + TryUpdateRefreshState( + state.opId, + state.generation, + current => + { + if (current.triggerStarted + && ( + current.PhaseValue == RefreshPhase.RefreshingAssets + || current.PhaseValue == RefreshPhase.Compiling + )) + { + SetPhase(current, RefreshPhase.Compiling); + current.message = + s_AssemblyCompilationStartedObserved + ? "Script compilation finished, waiting for assembly reload" + : "No assemblies required compilation; waiting for stable idle"; + } + }); } private static void OnBeforeAssemblyReload() { + var state = GetRefreshStateSnapshot(); + if (state.triggerStarted + && ( + state.PhaseValue == RefreshPhase.RefreshingAssets + || state.PhaseValue == RefreshPhase.Compiling + )) + { + TryUpdateRefreshState( + state.opId, + state.generation, + current => + { + if (current.triggerStarted + && ( + current.PhaseValue == RefreshPhase.RefreshingAssets + || current.PhaseValue == RefreshPhase.Compiling + )) + { + SetPhase(current, RefreshPhase.Reloading); + current.message = "Assembly reload started"; + } + }); + } + // Stop listener before domain unload to prevent port leak and drift. Shutdown(); + } - UpdateRefreshState(state => + private static void OnAfterAssemblyReload() + { + var state = GetRefreshStateSnapshot(); + if (state.PhaseValue == RefreshPhase.Reloading + && state.triggerStarted) { - if (IsActiveRefreshPhase(state.PhaseValue) || state.PhaseValue == RefreshPhase.Ready) - { - SetPhase(state, RefreshPhase.Reloading); - state.message = "Assembly reload started"; - } - }); + TryUpdateRefreshState( + state.opId, + state.generation, + current => + { + if (current.PhaseValue == RefreshPhase.Reloading + && current.triggerStarted) + { + current.reloadObserved = true; + SetPhase(current, RefreshPhase.Ready); + current.message = "Assembly reload finished"; + } + }); + } + else if (IsActiveRefreshPhase(state.PhaseValue) + && !( + state.PhaseValue == RefreshPhase.Requested + && state.triggerStarted + && state.exitPlayModeRequested + )) + { + MarkRefreshFailed( + state.opId, + state.generation, + "Assembly reload interrupted refresh before its reload phase"); + } } - private static void OnAfterAssemblyReload() + private static void OnRefreshPlayModeStateChanged( + PlayModeStateChange change) { - UpdateRefreshState(state => + if (change != PlayModeStateChange.EnteredEditMode) { - state.reloadObserved = true; - SetPhase(state, RefreshPhase.Ready); - state.message = "Assembly reload finished"; - }); + return; + } + + var state = GetRefreshStateSnapshot(); + if (state.PhaseValue == RefreshPhase.Requested + && state.triggerStarted + && state.exitPlayModeRequested + && state.waitingForEditMode) + { + TryUpdateRefreshState( + state.opId, + state.generation, + current => + { + if (current.PhaseValue == RefreshPhase.Requested + && current.triggerStarted + && current.exitPlayModeRequested) + { + current.waitingForEditMode = false; + current.message = + "Play Mode exited; waiting for Unity to become idle"; + } + }); + } } private static void OnEditorUpdate() @@ -1061,68 +2638,217 @@ private static void OnEditorUpdate() return; } - if (EditorApplication.isCompiling) + if (state.PhaseValue == RefreshPhase.Requested) { - if (state.PhaseValue != RefreshPhase.Compiling) + var elapsedSeconds = GetRefreshElapsedSeconds(state); + if (!state.triggerStarted) { - UpdateRefreshState(s => + if (elapsedSeconds >= REFRESH_TRIGGER_TIMEOUT_SECONDS) { - SetPhase(s, RefreshPhase.Compiling); - s.message = "Script compilation in progress"; - }); + MarkRefreshFailed( + state.opId, + state.generation, + "Refresh trigger did not start before timeout"); + } + return; + } + + if (!state.exitPlayModeRequested) + { + if (elapsedSeconds >= REFRESH_TRIGGER_TIMEOUT_SECONDS) + { + MarkRefreshFailed( + state.opId, + state.generation, + "Refresh trigger stopped before asset refresh began"); + } + return; + } + + if (elapsedSeconds >= REFRESH_EXIT_PLAYMODE_TIMEOUT_SECONDS) + { + MarkRefreshFailed( + state.opId, + state.generation, + "Timed out exiting Play Mode and waiting for an idle Editor"); + return; + } + + var editModeStable = + !EditorApplication.isPlaying + && !EditorApplication.isPlayingOrWillChangePlaymode; + if (state.waitingForEditMode) + { + if (editModeStable) + { + TryUpdateRefreshState( + state.opId, + state.generation, + current => + { + if (current.PhaseValue == RefreshPhase.Requested + && current.triggerStarted + && current.exitPlayModeRequested) + { + current.waitingForEditMode = false; + current.message = + "Play Mode exited; waiting for Unity to become idle"; + } + }); + } + return; + } + + if (!editModeStable + || EditorApplication.isCompiling + || EditorApplication.isUpdating) + { + return; } + + ContinueRefresh(state.opId, state.generation); return; } - if (EditorApplication.isUpdating) + if (!IsTrackedRefreshOperation(state)) { + MarkRefreshFailed( + state.opId, + state.generation, + "Refresh lifecycle lost its operation binding"); return; } - // Requested phase: TriggerRefresh hasn't run yet (scheduled via delayCall). - // Wait for it — only apply a safety timeout to prevent infinite hang. - if (state.PhaseValue == RefreshPhase.Requested) + if (EditorApplication.isCompiling) { - var elapsedSec = (DateTimeOffset.UtcNow.Ticks - s_RefreshRequestedAtTicks) / (double)TimeSpan.TicksPerSecond; - if (elapsedSec < REFRESH_TRIGGER_TIMEOUT_SECONDS) - return; - MarkRefreshReady("Refresh trigger timed out"); + if (!s_CompilationStartedObserved + || s_CompilationFinishedObserved) + { + s_CompilationStartedObserved = true; + s_AssemblyCompilationStartedObserved = false; + s_CompilationFinishedObserved = false; + s_CompileStartedAtEditorTime = + EditorApplication.timeSinceStartup; + s_CompilationFinishedAtEditorTime = 0; + s_PostCompileQuietUpdateCount = 0; + } + TryUpdateRefreshState( + state.opId, + state.generation, + current => + { + if (current.triggerStarted + && ( + current.PhaseValue == RefreshPhase.RefreshingAssets + || current.PhaseValue == RefreshPhase.Compiling + )) + { + SetPhase(current, RefreshPhase.Compiling); + current.message = "Script compilation in progress"; + } + }); + TryFailRefreshForCompilationTimeout(state); + return; + } + + if (EditorApplication.isUpdating) + { + s_PostCompileQuietUpdateCount = 0; return; } if (state.PhaseValue == RefreshPhase.RefreshingAssets) { - // No script changes — no compilation expected, done immediately. if (!state.compileRequested) { - MarkRefreshReady("Asset refresh completed without script compilation"); + MarkRefreshReady( + state.opId, + state.generation, + "Asset refresh completed without script compilation"); return; } - // Scripts were imported and compilation was requested. - // With ForceSynchronousImport, isCompiling usually becomes true - // immediately; this grace period is a safety net. - if (EditorApplication.timeSinceStartup - s_RefreshTriggeredAtEditorTime < REFRESH_GRACE_SECONDS) - return; - - MarkRefreshReady("Refresh completed without observable compilation work"); + if (EditorApplication.timeSinceStartup + - s_CompileRequestedAtEditorTime + >= REFRESH_COMPILE_START_TIMEOUT_SECONDS) + { + MarkRefreshFailed( + state.opId, + state.generation, + "Script compilation was requested but did not start before timeout"); + } + return; } - } - private static void FinalizeRefreshAfterCompile() - { - var state = GetRefreshStateSnapshot(); if (state.PhaseValue != RefreshPhase.Compiling) { return; } - if (EditorApplication.isCompiling || EditorApplication.isUpdating) + if (!s_CompilationFinishedObserved) + { + TryFailRefreshForCompilationTimeout(state); + return; + } + + if (s_CachedCompileFailed) { + MarkRefreshFailed( + state.opId, + state.generation, + "Script compilation failed"); return; } - MarkRefreshReady("Script compilation finished without assembly reload"); + var finishedElapsed = + EditorApplication.timeSinceStartup + - s_CompilationFinishedAtEditorTime; + if (s_AssemblyCompilationStartedObserved) + { + // Unity 2022 loads assemblies after a successful compilation. + // beforeAssemblyReload is the authoritative transition. A + // successful build that never reloads is not safe to call ready. + if (finishedElapsed >= REFRESH_RELOAD_START_TIMEOUT_SECONDS) + { + MarkRefreshFailed( + state.opId, + state.generation, + "Compilation finished but assembly reload did not start before timeout"); + } + return; + } + + s_PostCompileQuietUpdateCount++; + if (finishedElapsed >= REFRESH_POST_COMPILE_QUIET_SECONDS + && s_PostCompileQuietUpdateCount + >= REFRESH_POST_COMPILE_QUIET_UPDATES) + { + MarkRefreshReady( + state.opId, + state.generation, + "Compilation check completed; no assembly required rebuilding"); + } + } + + private static bool TryFailRefreshForCompilationTimeout( + RefreshOperationState state) + { + var compileStartedAt = + s_CompileStartedAtEditorTime > 0 + ? s_CompileStartedAtEditorTime + : s_CompileRequestedAtEditorTime; + if (compileStartedAt <= 0 + || EditorApplication.timeSinceStartup - compileStartedAt + < REFRESH_COMPILE_FINISH_TIMEOUT_SECONDS) + { + return false; + } + + MarkRefreshFailed( + state.opId, + state.generation, + "Script compilation did not finish before timeout"); + return true; } #endif @@ -1157,6 +2883,8 @@ internal static RefreshPhase ParsePhase(string phase) private static async Task ProcessCompileRuntimeREPL(HttpListenerContext context) { var message = await ConsoleHttpServiceDependencies.ReadRequestBodyAsync(context); + var parentInvocation = + ConsoleHttpServiceDependencies.GetInvocationClaim(context); var result = ""; string uuid = ""; @@ -1189,7 +2917,11 @@ private static async Task ProcessCompileRuntimeREPL(HttpListenerContext context) s_ReplServiceRegistry.RemoveCompilersForSession(uuid); - result = await ForwardReset(targetIP, targetPort, uuid); + result = await ForwardReset( + targetIP, + targetPort, + uuid, + parentInvocation); } else { @@ -1206,11 +2938,29 @@ private static async Task ProcessCompileRuntimeREPL(HttpListenerContext context) } else { - var executeResult = await ForwardDllToPlayer(targetIP, targetPort, uuid, compileBytes, compileScriptClsName); + var executeResult = await ForwardDllToPlayer( + targetIP, + targetPort, + uuid, + compileBytes, + compileScriptClsName, + parentInvocation); result = CombineCompilerNotice(compilerNotice, executeResult); } } } + catch (MainThreadOutcomeUnknownException e) + { + var unknownEnvelope = CreateOutcomeUnknownEnvelope("execute", e, uuid); + await WriteEnvelopeResponseAsync(context, unknownEnvelope, "RuntimeCompile"); + return; + } + catch (RemoteInvocationOutcomeUnknownException e) + { + var unknownEnvelope = CreateOutcomeUnknownEnvelope("execute", e, uuid); + await WriteEnvelopeResponseAsync(context, unknownEnvelope, "RuntimeCompile"); + return; + } catch (Exception e) { result = $"Compile failed, {e}"; @@ -1220,10 +2970,19 @@ private static async Task ProcessCompileRuntimeREPL(HttpListenerContext context) await WriteEnvelopeResponseAsync(context, envelope, "RuntimeCompile"); } - private static string ParseExecuteResponseText(string responseText) + private static string ParseExecuteResponseText( + string responseText, + string expectedInvocationId = "", + string expectedTargetId = "", + string expectedRequestDigest = "") { if (string.IsNullOrEmpty(responseText)) { + if (!string.IsNullOrEmpty(expectedInvocationId)) + { + throw new RemoteInvocationOutcomeUnknownException( + $"Player child invocation {expectedInvocationId} returned an empty response; its outcome is unknown."); + } return string.Empty; } @@ -1232,15 +2991,65 @@ private static string ParseExecuteResponseText(string responseText) var envelope = JsonUtility.FromJson(responseText); if (envelope != null && !string.IsNullOrEmpty(envelope.stage) && envelope.dataJson != null) { + if (!string.IsNullOrEmpty(expectedInvocationId)) + { + ValidatePlayerInvocationReceipt( + envelope, + expectedInvocationId, + expectedTargetId, + expectedRequestDigest); + } + + if (!envelope.ok + && ( + string.Equals(envelope.type, "outcome_unknown", StringComparison.Ordinal) + || string.Equals(envelope.type, "operation_in_progress", StringComparison.Ordinal) + || string.Equals(envelope.type, "invocation_conflict", StringComparison.Ordinal) + )) + { + throw new RemoteInvocationOutcomeUnknownException( + $"Player child invocation " + + $"{(string.IsNullOrEmpty(expectedInvocationId) ? "" : expectedInvocationId)} " + + (string.IsNullOrEmpty(envelope.summary) + ? "is unresolved." + : envelope.summary)); + } + + if (!envelope.ok) + { + return ConsoleLog.Format( + $"Forward failed: {envelope.summary ?? envelope.type ?? "Player request failed"}"); + } + var data = JsonUtility.FromJson(envelope.dataJson); - return data?.text ?? envelope.summary ?? string.Empty; + return string.IsNullOrEmpty(data?.text) + ? envelope.summary ?? string.Empty + : data.text; } } + catch (RemoteInvocationOutcomeUnknownException) + { + throw; + } catch (Exception e) { + if (!string.IsNullOrEmpty(expectedInvocationId)) + { + throw new RemoteInvocationOutcomeUnknownException( + $"Player child invocation {expectedInvocationId} returned " + + $"an unreadable protected response; its outcome is unknown: {e.Message}", + e); + } ConsoleLog.Warning($"Failed to parse execute response envelope JSON: {e}"); } + if (!string.IsNullOrEmpty(expectedInvocationId)) + { + throw new RemoteInvocationOutcomeUnknownException( + $"Player child invocation {expectedInvocationId} returned no " + + "protocol-v2 envelope; its outcome is unknown."); + } + try { var response = JsonUtility.FromJson(responseText); @@ -1262,7 +3071,59 @@ private static string ParseExecuteResponseText(string responseText) return responseText; } - private static async Task ForwardDllToPlayer(string ip, string port, string uuid, byte[] dllBytes, string className) + private static void ValidatePlayerInvocationReceipt( + HttpResponseEnvelope envelope, + string expectedInvocationId, + string expectedTargetId, + string expectedRequestDigest) + { + var receipt = envelope?.invocation; + if (receipt == null + || !Guid.TryParse(receipt.invocationId, out var parsedReceiptId) + || !Guid.TryParse(expectedInvocationId, out var parsedExpectedId) + || parsedReceiptId != parsedExpectedId + || !string.Equals(receipt.endpoint, "execute", StringComparison.Ordinal) + || !string.Equals( + receipt.requestDigest, + expectedRequestDigest, + StringComparison.OrdinalIgnoreCase) + || !string.Equals( + receipt.guarantee, + "at-most-once", + StringComparison.Ordinal) + || receipt.dedupeWindowSeconds <= 0) + { + throw new RemoteInvocationOutcomeUnknownException( + $"Player child invocation {expectedInvocationId} returned no " + + "matching durable receipt; its outcome is unknown."); + } + + if (string.Equals(receipt.state, "rejected", StringComparison.Ordinal)) + { + // A target replacement between health and execute is known not + // to have run this request. The rejection receipt identifies + // the new service target, so it need not match expectedTargetId. + return; + } + + if (!string.Equals( + receipt.targetId, + expectedTargetId, + StringComparison.Ordinal)) + { + throw new RemoteInvocationOutcomeUnknownException( + $"Player child invocation {expectedInvocationId} receipt " + + "belongs to a different target; its outcome is unknown."); + } + } + + private static async Task ForwardDllToPlayer( + string ip, + string port, + string uuid, + byte[] dllBytes, + string className, + InvocationClaim parentInvocation) { var request = new ExecuteREPLRequest { @@ -1271,43 +3132,244 @@ private static async Task ForwardDllToPlayer(string ip, string port, str uuid = uuid, reset = false }; - return await PostToPlayer(ip, port, request, "DLL"); + return await PostToPlayer( + ip, + port, + request, + "DLL", + parentInvocation); } - private static async Task ForwardReset(string ip, string port, string uuid) + private static async Task ForwardReset( + string ip, + string port, + string uuid, + InvocationClaim parentInvocation) { var request = new ForwardResetRequest { uuid = uuid, reset = true }; - return await PostToPlayer(ip, port, request, "reset"); + return await PostToPlayer( + ip, + port, + request, + "reset", + parentInvocation); + } + + private static async Task PostToPlayer( + string ip, + string port, + T request, + string debugLabel, + InvocationClaim parentInvocation) + { + var url = $"http://{ip}:{port}/CSharpConsole/execute"; + var jsonBytes = Encoding.UTF8.GetBytes(JsonUtility.ToJson(request)); + var protectedForward = parentInvocation != null + && !string.IsNullOrEmpty(parentInvocation.InvocationId); + var childInvocationId = ""; + var playerTargetId = ""; + var requestDigest = ""; + if (protectedForward) + { + var playerHealth = await ProbePlayerReliability(ip, port); + playerTargetId = playerHealth.targetId; + childInvocationId = DerivePlayerChildInvocationId( + parentInvocation.InvocationId, + debugLabel); + requestDigest = ComputeSha256Hex(jsonBytes); + } + + using var requestMessage = new HttpRequestMessage( + HttpMethod.Post, + url); + requestMessage.Content = new ByteArrayContent(jsonBytes); + requestMessage.Content.Headers.ContentType = + new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + if (protectedForward) + { + requestMessage.Headers.TryAddWithoutValidation( + InvocationCoordinator.InvocationIdHeader, + childInvocationId); + requestMessage.Headers.TryAddWithoutValidation( + InvocationCoordinator.TargetIdHeader, + playerTargetId); + } + + HttpResponseMessage response; + try + { + response = await s_HttpClient.SendAsync(requestMessage); + } + catch (Exception ex) + { + throw new RemoteInvocationOutcomeUnknownException( + protectedForward + ? $"Player child invocation {childInvocationId} may have " + + $"executed but no response was received: {ex.Message}" + : $"Player request may have executed but no response was received: {ex.Message}", + ex); + } + + using (response) + { + string responseText; + try + { + responseText = await response.Content.ReadAsStringAsync(); + } + catch (Exception ex) + { + throw new RemoteInvocationOutcomeUnknownException( + protectedForward + ? $"Player child invocation {childInvocationId} may " + + $"have executed but its response could not be read: {ex.Message}" + : $"Player request may have executed but its response could not be read: {ex.Message}", + ex); + } + + var executeText = ParseExecuteResponseText( + responseText, + childInvocationId, + playerTargetId, + requestDigest); + if (!response.IsSuccessStatusCode) + { + return ConsoleLog.Format($"Forward failed: {(int)response.StatusCode} {response.ReasonPhrase}: {executeText}"); + } + + ConsoleLog.Debug($"Forwarded {debugLabel} to {ip}:{port}, response={responseText}"); + return executeText; + } } - private static async Task PostToPlayer(string ip, string port, T request, string debugLabel) + private static async Task ProbePlayerReliability( + string ip, + string port) { + var url = $"http://{ip}:{port}/CSharpConsole/health"; + using var content = new ByteArrayContent(Encoding.UTF8.GetBytes("{}")); + content.Headers.ContentType = + new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + + HttpResponseMessage response; try { - var url = $"http://{ip}:{port}/CSharpConsole/execute"; - var jsonBytes = Encoding.UTF8.GetBytes(JsonUtility.ToJson(request)); - using var content = new ByteArrayContent(jsonBytes); - content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + response = await s_HttpClient.PostAsync(url, content); + } + catch (Exception e) + { + throw new InvalidOperationException( + $"Player reliability preflight failed before execute: {e.Message}", + e); + } - using var response = await s_HttpClient.PostAsync(url, content); + using (response) + { var responseText = await response.Content.ReadAsStringAsync(); - var executeText = ParseExecuteResponseText(responseText); if (!response.IsSuccessStatusCode) { - return ConsoleLog.Format($"Forward failed: {(int)response.StatusCode} {response.ReasonPhrase}: {executeText}"); + throw new InvalidOperationException( + $"Player reliability preflight returned HTTP " + + $"{(int)response.StatusCode}: {responseText}"); } - ConsoleLog.Debug($"Forwarded {debugLabel} to {ip}:{port}, response={responseText}"); - return executeText; + var envelope = JsonUtility.FromJson(responseText); + var health = envelope == null + || !envelope.ok + || string.IsNullOrWhiteSpace(envelope.dataJson) + ? null + : JsonUtility.FromJson(envelope.dataJson); + if (health == null + || !health.initialized + || health.isEditor + || health.protocolVersion < 2 + || string.IsNullOrEmpty(health.targetId) + || !health.targetId.StartsWith("player-", StringComparison.Ordinal) + || !health.journalWritable + || health.dedupeWindowSeconds <= 0 + || string.IsNullOrEmpty(health.unityVersion) + || !health.unityVersion.StartsWith("2022.", StringComparison.Ordinal) + || health.mainThreadHeartbeatAgeMs < 0 + || health.mainThreadHeartbeatAgeMs > 5000 + || !HasReliabilityCapabilities(health.capabilities)) + { + throw new InvalidOperationException( + "Player reliability preflight did not prove a ready " + + "Unity 2022 protocol-v2 target with a writable journal."); + } + + return health; } - catch (Exception ex) + } + + private static bool HasReliabilityCapabilities(string[] capabilities) + { + var required = new[] + { + "invocation_headers", + "invocation_receipts", + "invocation_status", + "at_most_once" + }; + foreach (var expected in required) + { + var found = false; + foreach (var capability in capabilities ?? Array.Empty()) + { + if (string.Equals( + capability, + expected, + StringComparison.Ordinal)) + { + found = true; + break; + } + } + + if (!found) + { + return false; + } + } + + return true; + } + + private static string DerivePlayerChildInvocationId( + string parentInvocationId, + string debugLabel) + { + if (!Guid.TryParse(parentInvocationId, out var parsedParent)) { - return ConsoleLog.Format($"Forward failed: {ex}"); + throw new InvalidOperationException( + "Protected runtime forwarding requires a valid parent invocation id."); } + + var material = Encoding.UTF8.GetBytes( + parsedParent.ToString("D") + + "\nplayer/execute\n" + + (debugLabel ?? "")); + using var sha256 = System.Security.Cryptography.SHA256.Create(); + var digest = sha256.ComputeHash(material); + var guidBytes = new byte[16]; + Array.Copy(digest, guidBytes, guidBytes.Length); + return new Guid(guidBytes).ToString("D"); + } + + private static string ComputeSha256Hex(byte[] bytes) + { + using var sha256 = System.Security.Cryptography.SHA256.Create(); + var digest = sha256.ComputeHash(bytes ?? Array.Empty()); + var builder = new StringBuilder(digest.Length * 2); + foreach (var value in digest) + { + builder.Append(value.ToString("x2")); + } + return builder.ToString(); } private static string ResolveRuntimeDefinesPath(string extractDir) @@ -1431,6 +3493,10 @@ private static async Task ProcessExecuteRuntimeREPL(HttpListenerContext context) response = s_EnvelopeFactory.CreateTextEnvelope("execute", result, uuid); } + catch (MainThreadOutcomeUnknownException e) + { + response = CreateOutcomeUnknownEnvelope("execute", e, uuid); + } catch (Exception e) { response = s_EnvelopeFactory.CreateTextEnvelope("execute", ConsoleLog.Format($"Execute exception: {e}"), uuid); diff --git a/Runtime/Service/ConsoleServiceConfig.cs b/Runtime/Service/ConsoleServiceConfig.cs index d545cb6..b897d3d 100644 --- a/Runtime/Service/ConsoleServiceConfig.cs +++ b/Runtime/Service/ConsoleServiceConfig.cs @@ -3,7 +3,7 @@ namespace Zh1Zh1.CSharpConsole.Service public static class ConsoleServiceConfig { public const string PackageVersion = "2.0.7"; - public const int ProtocolVersion = 1; + public const int ProtocolVersion = 2; public static int MainThreadTimeoutMs { get; set; } = 30_000; diff --git a/Runtime/Service/Contracts/EnvelopeContracts.cs b/Runtime/Service/Contracts/EnvelopeContracts.cs index 4486a0e..8be44e1 100644 --- a/Runtime/Service/Contracts/EnvelopeContracts.cs +++ b/Runtime/Service/Contracts/EnvelopeContracts.cs @@ -11,6 +11,7 @@ internal class HttpResponseEnvelope public string summary = ""; public string sessionId = ""; public string dataJson = "{}"; + public InvocationReceipt invocation = new InvocationReceipt(); } [Serializable] diff --git a/Runtime/Service/Contracts/HealthContracts.cs b/Runtime/Service/Contracts/HealthContracts.cs index 2489a1b..0edb606 100644 --- a/Runtime/Service/Contracts/HealthContracts.cs +++ b/Runtime/Service/Contracts/HealthContracts.cs @@ -20,8 +20,13 @@ internal class RefreshOperationState public string requestedAtUtc = ""; public string action = ""; public string phase = ""; + public bool triggerStarted; public bool compileRequested; public bool reloadObserved; + public bool exitPlayModeRequested; + public bool waitingForEditMode; + public string[] changedFiles = Array.Empty(); + public int changedFileCount; public int generation; public int effectivePort; public string message = ""; @@ -59,8 +64,16 @@ internal class HealthResponse public string packageVersion = ""; public int protocolVersion; public string unityVersion = ""; + public string targetId = ""; + public string serviceEpoch = ""; + public string[] capabilities = Array.Empty(); + public bool journalWritable; + public int dedupeWindowSeconds; public bool isCompiling; public bool compileFailed; + public bool isUpdating; + public bool isPlaying; + public int mainThreadHeartbeatAgeMs; public RefreshOperationState operation = new RefreshOperationState(); } diff --git a/Runtime/Service/Contracts/InvocationContracts.cs b/Runtime/Service/Contracts/InvocationContracts.cs new file mode 100644 index 0000000..46901d1 --- /dev/null +++ b/Runtime/Service/Contracts/InvocationContracts.cs @@ -0,0 +1,44 @@ +using System; + +namespace Zh1Zh1.CSharpConsole.Service +{ + [Serializable] + internal sealed class InvocationReceipt + { + public string invocationId = ""; + public string targetId = ""; + public string serviceEpoch = ""; + public string endpoint = ""; + public string requestDigest = ""; + public string state = "none"; + public string guarantee = "none"; + public bool replayed; + public int dedupeWindowSeconds; + public string createdAtUtc = ""; + public string updatedAtUtc = ""; + } + + [Serializable] + internal sealed class InvocationStatusRequest + { + public string invocationId = ""; + public string targetId = ""; + } + + [Serializable] + internal sealed class InvocationStatusResponse + { + public bool found; + public string invocationId = ""; + public string targetId = ""; + public string serviceEpoch = ""; + public string endpoint = ""; + public string requestDigest = ""; + public string state = ""; + public bool protectionExpired; + public string previousState = ""; + public string createdAtUtc = ""; + public string updatedAtUtc = ""; + public string responseJson = ""; + } +} diff --git a/Runtime/Service/Contracts/InvocationContracts.cs.meta b/Runtime/Service/Contracts/InvocationContracts.cs.meta new file mode 100644 index 0000000..97448c4 --- /dev/null +++ b/Runtime/Service/Contracts/InvocationContracts.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e9070b14ee6c48d19631148411697c2d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Service/Internal/ConsoleHttpServiceDependencies.cs b/Runtime/Service/Internal/ConsoleHttpServiceDependencies.cs index f59fa7c..5aeddc9 100644 --- a/Runtime/Service/Internal/ConsoleHttpServiceDependencies.cs +++ b/Runtime/Service/Internal/ConsoleHttpServiceDependencies.cs @@ -1,6 +1,8 @@ using System; using System.IO; using System.Net; +using System.Runtime.CompilerServices; +using System.Text; using System.Threading.Tasks; using Zh1Zh1.CSharpConsole.Interface; @@ -8,6 +10,16 @@ namespace Zh1Zh1.CSharpConsole.Service.Internal { internal sealed class ConsoleHttpServiceDependencies { + internal sealed class CachedRequestBody + { + public byte[] Bytes = Array.Empty(); + public string Text = ""; + public InvocationClaim InvocationClaim; + } + + private static readonly ConditionalWeakTable s_RequestBodies = + new ConditionalWeakTable(); + public ConsoleHttpServiceDependencies( HttpEnvelopeFactory envelopeFactory, Func buildHealthResponseSnapshot, @@ -34,8 +46,78 @@ public ConsoleHttpServiceDependencies( public static async Task ReadRequestBodyAsync(HttpListenerContext context) { - using var reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding); - return await reader.ReadToEndAsync(); + return (await PrepareRequestBodyAsync(context)).Text; + } + + public static async Task PrepareRequestBodyAsync(HttpListenerContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (s_RequestBodies.TryGetValue(context, out var cached)) + { + return cached; + } + + using var stream = new MemoryStream(); + await context.Request.InputStream.CopyToAsync(stream); + var bytes = stream.ToArray(); + var text = new UTF8Encoding(false, true).GetString(bytes); + if (text.Length > 0 && text[0] == '\uFEFF') + { + text = text.Substring(1); + } + + cached = new CachedRequestBody + { + Bytes = bytes, + Text = text + }; + try + { + s_RequestBodies.Add(context, cached); + } + catch (ArgumentException) + { + // A concurrent preparer won. This should not occur in the normal + // single-dispatch flow, but returning the canonical cache keeps + // downstream fingerprinting consistent if it does. + if (s_RequestBodies.TryGetValue(context, out var existing)) + { + return existing; + } + + throw; + } + + return cached; + } + + public static void AttachInvocationClaim(HttpListenerContext context, InvocationClaim claim) + { + if (!s_RequestBodies.TryGetValue(context, out var cached)) + { + throw new InvalidOperationException("Request body must be prepared before attaching an invocation claim."); + } + + cached.InvocationClaim = claim; + } + + public static InvocationClaim GetInvocationClaim(HttpListenerContext context) + { + return context != null && s_RequestBodies.TryGetValue(context, out var cached) + ? cached.InvocationClaim + : null; + } + + public static void ReleaseRequest(HttpListenerContext context) + { + if (context != null) + { + s_RequestBodies.Remove(context); + } } } } diff --git a/Runtime/Service/Internal/InvocationCoordinator.cs b/Runtime/Service/Internal/InvocationCoordinator.cs new file mode 100644 index 0000000..4c39b21 --- /dev/null +++ b/Runtime/Service/Internal/InvocationCoordinator.cs @@ -0,0 +1,1433 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using UnityEngine; + +namespace Zh1Zh1.CSharpConsole.Service.Internal +{ + internal enum InvocationClaimDisposition + { + Execute, + Unprotected, + Replay, + InProgress, + Conflict, + OutcomeUnknown, + Rejected + } + + internal sealed class InvocationClaim + { + public InvocationClaimDisposition Disposition; + public string InvocationId = ""; + public string TargetId = ""; + public string ServiceEpoch = ""; + public string Endpoint = ""; + public string RequestDigest = ""; + public string ResponseJson = ""; + public string Message = ""; + public string CreatedAtUtc = ""; + public string UpdatedAtUtc = ""; + public bool TerminalResponsePersisted; + } + + /// + /// Owns the durable at-most-once boundary for HTTP mutations. + /// + /// A started record is created before dispatch. Completed and outcome-unknown + /// records are append-only terminal markers, so a process or domain reload + /// cannot turn an ambiguous mutation back into an executable request. + /// + internal sealed class InvocationCoordinator + { + public const string InvocationIdHeader = "X-CSharpConsole-Invocation-Id"; + public const string TargetIdHeader = "X-CSharpConsole-Target-Id"; + public const int DedupeWindowSeconds = 86_400; + + private const int SchemaVersion = 1; + private const string StartedState = "started"; + private const string CompletedState = "completed"; + private const string OutcomeUnknownState = "outcome_unknown"; + + [Serializable] + private sealed class IdentityRecord + { + public int schemaVersion = SchemaVersion; + public string targetId = ""; + public int processId; + public long processStartUtcTicks; + public string serviceEpoch = ""; + public string projectRoot = ""; + } + + [Serializable] + private sealed class LedgerRecord + { + public int schemaVersion = SchemaVersion; + public string invocationId = ""; + public string targetId = ""; + public string serviceEpoch = ""; + public string endpoint = ""; + public string requestDigest = ""; + public string state = ""; + public string createdAtUtc = ""; + public string updatedAtUtc = ""; + public string responseJson = ""; + public string message = ""; + } + + private readonly object _gate = new object(); + private readonly HashSet _active = new HashSet(StringComparer.Ordinal); + private readonly string _ledgerBaseRoot; + private string _ledgerRoot; + private string _targetId = ""; + private string _serviceEpoch = ""; + private bool _journalWritable; + private DateTime _nextMaintenanceUtc = DateTime.MinValue; + + public InvocationCoordinator() + { + _ledgerBaseRoot = ResolveLedgerRoot(); + _ledgerRoot = _ledgerBaseRoot; + InitializeJournal(); + } + + public string TargetId + { + get + { + lock (_gate) + { + return _targetId; + } + } + } + + public string ServiceEpoch + { + get + { + lock (_gate) + { + return _serviceEpoch; + } + } + } + + public bool JournalWritable + { + get + { + lock (_gate) + { + return _journalWritable; + } + } + } + + public void RestartServiceEpoch() + { + MarkOutstandingOutcomeUnknown( + "The Unity service restarted before the invocation outcome was durably recorded."); + InitializeJournal(); + } + + public InvocationClaim Claim( + string invocationIdHeader, + string targetIdHeader, + string endpoint, + byte[] exactBody) + { + var rawInvocationId = invocationIdHeader?.Trim() ?? ""; + var rawTargetId = targetIdHeader?.Trim() ?? ""; + + if (string.IsNullOrEmpty(rawInvocationId) && string.IsNullOrEmpty(rawTargetId)) + { + return new InvocationClaim + { + Disposition = InvocationClaimDisposition.Unprotected, + TargetId = TargetId, + ServiceEpoch = ServiceEpoch, + Endpoint = endpoint ?? "" + }; + } + + if (string.IsNullOrEmpty(rawInvocationId) || string.IsNullOrEmpty(rawTargetId)) + { + return Reject( + rawInvocationId, + rawTargetId, + endpoint, + "Both invocation and target headers are required for at-most-once execution."); + } + + if (!Guid.TryParse(rawInvocationId, out var parsedInvocationId)) + { + return Reject( + rawInvocationId, + rawTargetId, + endpoint, + "Invocation id must be a UUID."); + } + + var invocationId = parsedInvocationId.ToString("D"); + var requestDigest = ComputeDigest(exactBody ?? Array.Empty()); + + lock (_gate) + { + if (!string.Equals(rawTargetId, _targetId, StringComparison.Ordinal)) + { + return new InvocationClaim + { + Disposition = InvocationClaimDisposition.Rejected, + InvocationId = invocationId, + TargetId = _targetId, + ServiceEpoch = _serviceEpoch, + Endpoint = endpoint ?? "", + RequestDigest = requestDigest, + Message = "Target id does not match this Unity service." + }; + } + + if (!_journalWritable) + { + return new InvocationClaim + { + Disposition = InvocationClaimDisposition.Rejected, + InvocationId = invocationId, + TargetId = _targetId, + ServiceEpoch = _serviceEpoch, + Endpoint = endpoint ?? "", + RequestDigest = requestDigest, + Message = "Invocation journal is not writable; mutation was not dispatched." + }; + } + + if (HasCorruptRecord(invocationId)) + { + _journalWritable = false; + return RejectLocked( + invocationId, + endpoint, + requestDigest, + "Invocation journal contains a corrupt record; mutation was not dispatched."); + } + + var existing = LoadExistingRecord(invocationId); + if (existing != null && !_active.Contains(invocationId)) + { + if (IsExpired(existing)) + { + if (DeleteInvocationFiles(invocationId)) + { + existing = null; + } + else + { + _journalWritable = false; + return RejectLocked( + invocationId, + endpoint, + requestDigest, + "Expired invocation records could not be removed safely; mutation was not dispatched."); + } + } + } + + if (existing != null) + { + if (!Matches(existing, _targetId, endpoint, requestDigest)) + { + return FromRecord( + existing, + InvocationClaimDisposition.Conflict, + "Invocation id was already used for a different target, endpoint, or exact request body."); + } + + if (string.Equals(existing.state, CompletedState, StringComparison.Ordinal)) + { + var replay = FromRecord(existing, InvocationClaimDisposition.Replay, "Replaying persisted response."); + replay.ResponseJson = existing.responseJson ?? ""; + return replay; + } + + if (string.Equals(existing.state, OutcomeUnknownState, StringComparison.Ordinal)) + { + return FromRecord( + existing, + InvocationClaimDisposition.OutcomeUnknown, + string.IsNullOrEmpty(existing.message) + ? "The original invocation outcome is unknown and will not be dispatched again." + : existing.message); + } + + if (_active.Contains(invocationId)) + { + return FromRecord( + existing, + InvocationClaimDisposition.InProgress, + "The original invocation is still in progress."); + } + + // A started record not owned by this live coordinator survived + // an interruption. Never guess that it did not mutate state. + var unknown = PersistOutcomeUnknown(existing, "The service restarted before the invocation outcome was durably recorded."); + return FromRecord( + unknown ?? existing, + InvocationClaimDisposition.OutcomeUnknown, + "The original invocation outcome is unknown and will not be dispatched again."); + } + + var now = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture); + var started = new LedgerRecord + { + invocationId = invocationId, + targetId = _targetId, + serviceEpoch = _serviceEpoch, + endpoint = endpoint ?? "", + requestDigest = requestDigest, + state = StartedState, + createdAtUtc = now, + updatedAtUtc = now + }; + + try + { + WriteNewRecord(GetRecordPath(invocationId, StartedState), started); + _active.Add(invocationId); + } + catch (IOException) + { + // Another request may have won the create-new race. + existing = LoadExistingRecord(invocationId); + if (existing != null) + { + if (!Matches(existing, _targetId, endpoint, requestDigest)) + { + return FromRecord( + existing, + InvocationClaimDisposition.Conflict, + "Invocation id was already used for a different target, endpoint, or exact request body."); + } + + if (string.Equals(existing.state, CompletedState, StringComparison.Ordinal)) + { + var replay = FromRecord(existing, InvocationClaimDisposition.Replay, "Replaying persisted response."); + replay.ResponseJson = existing.responseJson ?? ""; + return replay; + } + + return FromRecord( + existing, + string.Equals(existing.state, OutcomeUnknownState, StringComparison.Ordinal) + ? InvocationClaimDisposition.OutcomeUnknown + : InvocationClaimDisposition.InProgress, + "Invocation was already claimed."); + } + + _journalWritable = false; + return RejectLocked( + invocationId, + endpoint, + requestDigest, + "Invocation journal claim failed; mutation was not dispatched."); + } + catch (Exception e) + { + _journalWritable = false; + ConsoleLog.Warning($"Invocation journal claim failed: {e}"); + return RejectLocked( + invocationId, + endpoint, + requestDigest, + "Invocation journal claim failed; mutation was not dispatched."); + } + + return FromRecord(started, InvocationClaimDisposition.Execute, "Invocation claimed."); + } + } + + public void Maintain() + { + lock (_gate) + { + var now = DateTime.UtcNow; + if (now < _nextMaintenanceUtc) + { + return; + } + + _nextMaintenanceUtc = now.AddHours(1); + try + { + var recordsHealthy = CleanupExpiredRecords(); + _journalWritable = recordsHealthy && ProbeWritable(); +#if !UNITY_EDITOR + CleanupExpiredPlayerState(); +#endif + } + catch (Exception e) + { + _journalWritable = false; + ConsoleLog.Warning($"Invocation journal maintenance failed: {e}"); + } + } + } + + public InvocationReceipt CreateReceipt(InvocationClaim claim, string state, bool replayed) + { + claim ??= new InvocationClaim(); + var protectedInvocation = !string.IsNullOrEmpty(claim.InvocationId); + return new InvocationReceipt + { + invocationId = claim.InvocationId ?? "", + targetId = string.IsNullOrEmpty(claim.TargetId) ? TargetId : claim.TargetId, + serviceEpoch = string.IsNullOrEmpty(claim.ServiceEpoch) ? ServiceEpoch : claim.ServiceEpoch, + endpoint = claim.Endpoint ?? "", + requestDigest = claim.RequestDigest ?? "", + state = protectedInvocation ? (state ?? "") : "none", + guarantee = protectedInvocation ? "at-most-once" : "none", + replayed = replayed, + dedupeWindowSeconds = protectedInvocation ? DedupeWindowSeconds : 0, + createdAtUtc = claim.CreatedAtUtc ?? "", + updatedAtUtc = string.IsNullOrEmpty(claim.UpdatedAtUtc) + ? DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture) + : claim.UpdatedAtUtc + }; + } + + public bool TryComplete(InvocationClaim claim, string responseJson, out string error) + { + error = ""; + if (claim == null || string.IsNullOrEmpty(claim.InvocationId)) + { + return true; + } + + lock (_gate) + { + var started = LoadRecord(GetRecordPath(claim.InvocationId, StartedState)); + if (started == null || !Matches(started, claim.TargetId, claim.Endpoint, claim.RequestDigest)) + { + _active.Remove(claim.InvocationId); + var missingRecordTimestamp = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture); + var syntheticStarted = new LedgerRecord + { + invocationId = claim.InvocationId, + targetId = claim.TargetId, + serviceEpoch = claim.ServiceEpoch, + endpoint = claim.Endpoint, + requestDigest = claim.RequestDigest, + state = StartedState, + createdAtUtc = string.IsNullOrEmpty(claim.CreatedAtUtc) + ? missingRecordTimestamp + : claim.CreatedAtUtc, + updatedAtUtc = missingRecordTimestamp + }; + if (PersistOutcomeUnknown( + syntheticStarted, + "Invocation claim record disappeared after dispatch; outcome is unknown.") == null) + { + _journalWritable = false; + } + + error = "Invocation claim record is missing or no longer matches."; + return false; + } + + var now = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture); + var completed = CopyRecord(started); + completed.state = CompletedState; + completed.updatedAtUtc = now; + completed.responseJson = responseJson ?? ""; + + try + { + WriteNewRecord(GetRecordPath(claim.InvocationId, CompletedState), completed); + claim.TerminalResponsePersisted = true; + _active.Remove(claim.InvocationId); + return true; + } + catch (IOException) + { + var existing = LoadRecord(GetRecordPath(claim.InvocationId, CompletedState)); + if (existing != null && Matches(existing, claim.TargetId, claim.Endpoint, claim.RequestDigest)) + { + claim.TerminalResponsePersisted = true; + _active.Remove(claim.InvocationId); + return true; + } + + error = "Invocation result could not be durably recorded."; + } + catch (Exception e) + { + error = $"Invocation result could not be durably recorded: {e.Message}"; + ConsoleLog.Warning($"Invocation completion persistence failed: {e}"); + } + + _journalWritable = false; + PersistOutcomeUnknown(started, error); + _active.Remove(claim.InvocationId); + return false; + } + } + + public InvocationReceipt MarkOutcomeUnknown(InvocationClaim claim, string message) + { + if (claim == null || string.IsNullOrEmpty(claim.InvocationId)) + { + return CreateReceipt(claim, "none", false); + } + + lock (_gate) + { + var started = LoadRecord(GetRecordPath(claim.InvocationId, StartedState)); + if (started == null + || !Matches(started, claim.TargetId, claim.Endpoint, claim.RequestDigest)) + { + var now = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture); + started = new LedgerRecord + { + invocationId = claim.InvocationId, + targetId = claim.TargetId, + serviceEpoch = claim.ServiceEpoch, + endpoint = claim.Endpoint, + requestDigest = claim.RequestDigest, + state = StartedState, + createdAtUtc = string.IsNullOrEmpty(claim.CreatedAtUtc) + ? now + : claim.CreatedAtUtc, + updatedAtUtc = now + }; + } + + if (PersistOutcomeUnknown(started, message) == null) + { + // Keep the live service fail-closed if the terminal marker + // could not be made durable. A later request must not turn a + // possibly executed mutation back into an executable one. + _journalWritable = false; + } + + _active.Remove(claim.InvocationId); + } + + return CreateReceipt(claim, OutcomeUnknownState, false); + } + + public void MarkOutstandingOutcomeUnknown(string message) + { + lock (_gate) + { + foreach (var invocationId in _active.ToArray()) + { + var started = LoadRecord(GetRecordPath(invocationId, StartedState)); + if (started != null) + { + PersistOutcomeUnknown(started, message); + } + } + + _active.Clear(); + } + } + + public bool TryGetStatus( + string invocationIdText, + string targetId, + out InvocationStatusResponse status, + out string error) + { + status = null; + error = ""; + if (!Guid.TryParse(invocationIdText?.Trim(), out var parsedInvocationId)) + { + error = "Invocation id must be a UUID."; + return false; + } + + lock (_gate) + { + if (!string.Equals(targetId?.Trim(), _targetId, StringComparison.Ordinal)) + { + error = "Target id does not match this Unity service."; + return false; + } + + var invocationId = parsedInvocationId.ToString("D"); + if (HasCorruptRecord(invocationId)) + { + _journalWritable = false; + error = "Invocation journal contains a corrupt record."; + return false; + } + + var record = LoadExistingRecord(invocationId); + if (record != null + && !string.Equals(record.targetId, _targetId, StringComparison.Ordinal)) + { + error = "Invocation record belongs to a different target."; + return false; + } + + var protectionExpired = false; + var previousState = ""; + if (record != null + && !_active.Contains(invocationId) + && IsExpired(record)) + { + protectionExpired = true; + previousState = record.state ?? ""; + if (!DeleteInvocationFiles(invocationId)) + { + _journalWritable = false; + error = "Expired invocation records could not be removed safely."; + return false; + } + } + + if (!protectionExpired + && record != null + && string.Equals(record.state, StartedState, StringComparison.Ordinal) + && !_active.Contains(invocationId)) + { + record = PersistOutcomeUnknown( + record, + "The service restarted before the invocation outcome was durably recorded.") ?? record; + } + + status = new InvocationStatusResponse + { + found = record != null && !protectionExpired, + invocationId = invocationId, + targetId = _targetId, + serviceEpoch = record?.serviceEpoch ?? _serviceEpoch, + endpoint = record?.endpoint ?? "", + requestDigest = record?.requestDigest ?? "", + state = protectionExpired + ? "protection_expired" + : (record == null + ? "not_found" + : (string.Equals(record.state, StartedState, StringComparison.Ordinal) ? "in_progress" : record.state)), + protectionExpired = protectionExpired, + previousState = previousState, + createdAtUtc = record?.createdAtUtc ?? "", + updatedAtUtc = record?.updatedAtUtc ?? "", + responseJson = string.Equals(record?.state, CompletedState, StringComparison.Ordinal) + ? record.responseJson ?? "" + : "" + }; + return true; + } + } + + private void InitializeJournal() + { + lock (_gate) + { + try + { + Directory.CreateDirectory(_ledgerBaseRoot); + var processId = Process.GetCurrentProcess().Id; + var processStartUtcTicks = GetProcessStartUtcTicks(); +#if UNITY_EDITOR + var identityPath = Path.Combine(_ledgerBaseRoot, "identity.json"); +#else + // Development Players can share persistentDataPath. Give + // each live process its own durable identity slot and its + // own target-scoped ledger so concurrent Players never + // race on identity.json or deduplicate each other's UUIDs. + var identityDirectory = Path.Combine(_ledgerBaseRoot, "identities"); + Directory.CreateDirectory(identityDirectory); + var identityPath = Path.Combine( + identityDirectory, + $"{processId}-{processStartUtcTicks}.json"); +#endif + var identity = LoadIdentity(identityPath) ?? new IdentityRecord(); + var targetId = ResolveTargetId( + identity, + _targetId, + processId, + processStartUtcTicks); + // A coordinator instance is one service epoch. Domain reload + // constructs a new coordinator, which must be observable even + // when the native Unity process itself did not restart. + var serviceEpoch = Guid.NewGuid(); + + identity.schemaVersion = SchemaVersion; + identity.targetId = targetId; +#if UNITY_EDITOR + identity.projectRoot = ResolveEditorProjectRoot(); +#else + identity.projectRoot = ""; +#endif + identity.processId = processId; + identity.processStartUtcTicks = processStartUtcTicks; + identity.serviceEpoch = serviceEpoch.ToString("D"); + WriteReplace(identityPath, JsonUtility.ToJson(identity)); + + _targetId = identity.targetId; + _serviceEpoch = identity.serviceEpoch; +#if !UNITY_EDITOR + _ledgerRoot = Path.Combine(_ledgerBaseRoot, "targets", targetId); + Directory.CreateDirectory(_ledgerRoot); +#endif + _journalWritable = ProbeWritable(); + + _journalWritable = CleanupExpiredRecords() && _journalWritable; + MarkOrphanedStartedRecords(); +#if !UNITY_EDITOR + CleanupExpiredPlayerState(); +#endif + _nextMaintenanceUtc = DateTime.UtcNow.AddHours(1); + } + catch (Exception e) + { + try + { + _targetId = ResolveTargetId( + new IdentityRecord(), + _targetId, + Process.GetCurrentProcess().Id, + GetProcessStartUtcTicks()); + } + catch + { + _targetId = "unavailable"; + } + _serviceEpoch = Guid.NewGuid().ToString("D"); + _journalWritable = false; + ConsoleLog.Warning($"Invocation journal initialization failed: {e}"); + } + } + } + + private void MarkOrphanedStartedRecords() + { + foreach (var path in Directory.GetFiles(_ledgerRoot, "*.started.json")) + { + var started = LoadRecord(path); + if (started == null || string.IsNullOrEmpty(started.invocationId)) + { + continue; + } + + if (File.Exists(GetRecordPath(started.invocationId, CompletedState)) + || File.Exists(GetRecordPath(started.invocationId, OutcomeUnknownState))) + { + continue; + } + + if (!string.Equals(started.targetId, _targetId, StringComparison.Ordinal)) + { + // Multiple development Players can share persistentDataPath. + // A coordinator must never orphan another live target's work. + continue; + } + + PersistOutcomeUnknown( + started, + "The service restarted before the invocation outcome was durably recorded."); + } + } + + private bool CleanupExpiredRecords() + { + var cutoff = DateTime.UtcNow.AddSeconds(-DedupeWindowSeconds); + var invocationIds = new HashSet(StringComparer.Ordinal); + var recordsHealthy = true; + foreach (var path in Directory.GetFiles(_ledgerRoot, "*.json")) + { + var name = Path.GetFileName(path); + if (string.Equals(name, "identity.json", StringComparison.Ordinal)) + { + continue; + } + + var separator = name.IndexOf('.'); + if (separator > 0) + { + invocationIds.Add(name.Substring(0, separator)); + } + } + + foreach (var invocationId in invocationIds) + { + if (_active.Contains(invocationId)) + { + continue; + } + + if (!Guid.TryParse(invocationId, out _) || HasCorruptRecord(invocationId)) + { + recordsHealthy = false; + ConsoleLog.Warning( + $"Corrupt invocation record retained for fail-closed recovery: {invocationId}"); + continue; + } + + var record = LoadExistingRecord(invocationId); + if (record == null) + { + recordsHealthy = false; + ConsoleLog.Warning( + $"Unreadable invocation record retained for fail-closed recovery: {invocationId}"); + continue; + } + + if (TryParseUtc(record.updatedAtUtc, out var updatedAt) && updatedAt < cutoff) + { + if (!DeleteInvocationFiles(invocationId)) + { + recordsHealthy = false; + } + } + } + + return recordsHealthy; + } + + private bool HasCorruptRecord(string invocationId) + { + foreach (var state in new[] { StartedState, CompletedState, OutcomeUnknownState }) + { + var path = GetRecordPath(invocationId, state); + if (File.Exists(path) && LoadRecord(path) == null) + { + return true; + } + } + + return false; + } + + private LedgerRecord LoadExistingRecord(string invocationId) + { + var completed = LoadRecord(GetRecordPath(invocationId, CompletedState)); + var unknown = LoadRecord(GetRecordPath(invocationId, OutcomeUnknownState)); + if (completed != null && unknown != null) + { + // A durable completed response is stronger evidence than an + // observer's later conservative unknown marker. The same id + // cannot be dispatched again while either record exists. + return completed; + } + + return completed + ?? unknown + ?? LoadRecord(GetRecordPath(invocationId, StartedState)); + } + + private LedgerRecord PersistOutcomeUnknown(LedgerRecord started, string message) + { + if (started == null) + { + return null; + } + + var existing = LoadRecord(GetRecordPath(started.invocationId, OutcomeUnknownState)); + if (existing != null) + { + return existing; + } + + var unknown = CopyRecord(started); + unknown.state = OutcomeUnknownState; + unknown.updatedAtUtc = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture); + unknown.message = message ?? "Invocation outcome is unknown."; + + try + { + WriteNewRecord(GetRecordPath(started.invocationId, OutcomeUnknownState), unknown); + return unknown; + } + catch (IOException) + { + var raced = LoadRecord(GetRecordPath(started.invocationId, OutcomeUnknownState)); + if (raced == null) + { + _journalWritable = false; + } + + return raced; + } + catch (Exception e) + { + _journalWritable = false; + ConsoleLog.Warning($"Failed to persist unknown invocation outcome: {e}"); + return null; + } + } + + private static LedgerRecord CopyRecord(LedgerRecord source) + { + return new LedgerRecord + { + schemaVersion = source.schemaVersion, + invocationId = source.invocationId ?? "", + targetId = source.targetId ?? "", + serviceEpoch = source.serviceEpoch ?? "", + endpoint = source.endpoint ?? "", + requestDigest = source.requestDigest ?? "", + state = source.state ?? "", + createdAtUtc = source.createdAtUtc ?? "", + updatedAtUtc = source.updatedAtUtc ?? "", + responseJson = source.responseJson ?? "", + message = source.message ?? "" + }; + } + + private static bool Matches(LedgerRecord record, string targetId, string endpoint, string digest) + { + return record != null + && string.Equals(record.targetId, targetId ?? "", StringComparison.Ordinal) + && string.Equals(record.endpoint, endpoint ?? "", StringComparison.Ordinal) + && string.Equals(record.requestDigest, digest ?? "", StringComparison.Ordinal); + } + + private static InvocationClaim FromRecord( + LedgerRecord record, + InvocationClaimDisposition disposition, + string message) + { + return new InvocationClaim + { + Disposition = disposition, + InvocationId = record?.invocationId ?? "", + TargetId = record?.targetId ?? "", + ServiceEpoch = record?.serviceEpoch ?? "", + Endpoint = record?.endpoint ?? "", + RequestDigest = record?.requestDigest ?? "", + ResponseJson = record?.responseJson ?? "", + Message = message ?? "", + CreatedAtUtc = record?.createdAtUtc ?? "", + UpdatedAtUtc = record?.updatedAtUtc ?? "" + }; + } + + private InvocationClaim Reject(string invocationId, string targetId, string endpoint, string message) + { + lock (_gate) + { + return new InvocationClaim + { + Disposition = InvocationClaimDisposition.Rejected, + InvocationId = invocationId ?? "", + TargetId = string.IsNullOrEmpty(_targetId) ? targetId ?? "" : _targetId, + ServiceEpoch = _serviceEpoch, + Endpoint = endpoint ?? "", + Message = message ?? "" + }; + } + } + + private InvocationClaim RejectLocked(string invocationId, string endpoint, string digest, string message) + { + return new InvocationClaim + { + Disposition = InvocationClaimDisposition.Rejected, + InvocationId = invocationId ?? "", + TargetId = _targetId, + ServiceEpoch = _serviceEpoch, + Endpoint = endpoint ?? "", + RequestDigest = digest ?? "", + Message = message ?? "" + }; + } + + private bool IsExpired(LedgerRecord record) + { + return TryParseUtc(record?.updatedAtUtc, out var updatedAt) + && updatedAt < DateTime.UtcNow.AddSeconds(-DedupeWindowSeconds); + } + + private bool DeleteInvocationFiles(string invocationId) + { + var allDeleted = true; + foreach (var state in new[] { StartedState, CompletedState, OutcomeUnknownState }) + { + var path = GetRecordPath(invocationId, state); + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception e) + { + allDeleted = false; + ConsoleLog.Debug($"Failed to delete expired invocation record {path}: {e.Message}"); + } + } + + return allDeleted; + } + + private string GetRecordPath(string invocationId, string state) + { + return Path.Combine(_ledgerRoot, $"{invocationId}.{state}.json"); + } + + private static LedgerRecord LoadRecord(string path) + { + try + { + if (!File.Exists(path)) + { + return null; + } + + var json = File.ReadAllText(path); + var record = string.IsNullOrWhiteSpace(json) + ? null + : JsonUtility.FromJson(json); + if (record == null + || record.schemaVersion != SchemaVersion + || !Guid.TryParse(record.invocationId, out var parsedInvocationId)) + { + return null; + } + + record.invocationId = parsedInvocationId.ToString("D"); + var expectedPrefix = record.invocationId + "."; + var fileName = Path.GetFileName(path); + if (!fileName.StartsWith(expectedPrefix, StringComparison.Ordinal)) + { + ConsoleLog.Warning($"Invocation record filename does not match its id: {path}"); + return null; + } + + var expectedState = fileName.EndsWith($".{StartedState}.json", StringComparison.Ordinal) + ? StartedState + : (fileName.EndsWith($".{CompletedState}.json", StringComparison.Ordinal) + ? CompletedState + : (fileName.EndsWith($".{OutcomeUnknownState}.json", StringComparison.Ordinal) + ? OutcomeUnknownState + : "")); + if (string.IsNullOrEmpty(expectedState) + || !string.Equals(record.state, expectedState, StringComparison.Ordinal) + || string.IsNullOrEmpty(record.targetId) + || !Guid.TryParse(record.serviceEpoch, out _) + || string.IsNullOrEmpty(record.endpoint) + || !IsSha256Hex(record.requestDigest) + || !TryParseUtc(record.createdAtUtc, out _) + || !TryParseUtc(record.updatedAtUtc, out _)) + { + ConsoleLog.Warning($"Invocation record failed structural validation: {path}"); + return null; + } + + if (string.Equals(record.state, CompletedState, StringComparison.Ordinal) + && !IsResponseEnvelopeJson(record.responseJson)) + { + ConsoleLog.Warning($"Completed invocation response is unreadable: {path}"); + return null; + } + + return record; + } + catch (Exception e) + { + ConsoleLog.Warning($"Failed to read invocation record {path}: {e.Message}"); + return null; + } + } + + private static IdentityRecord LoadIdentity(string path) + { + try + { + if (!File.Exists(path)) + { + return null; + } + + var json = File.ReadAllText(path); + return string.IsNullOrWhiteSpace(json) ? null : JsonUtility.FromJson(json); + } + catch (Exception e) + { + ConsoleLog.Warning($"Failed to read invocation identity: {e.Message}"); + return null; + } + } + + private static void WriteNewRecord(string path, LedgerRecord record) + { + var bytes = new UTF8Encoding(false).GetBytes(JsonUtility.ToJson(record)); + using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.Read); + stream.Write(bytes, 0, bytes.Length); + stream.Flush(true); + } + + private static void WriteReplace(string path, string contents) + { + var tempPath = path + $".tmp.{Guid.NewGuid():N}"; + try + { + var bytes = new UTF8Encoding(false).GetBytes(contents ?? ""); + using (var stream = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + stream.Write(bytes, 0, bytes.Length); + stream.Flush(true); + } + + if (File.Exists(path)) + { + try + { + File.Replace(tempPath, path, null); + } + catch (PlatformNotSupportedException) + { + File.Delete(path); + File.Move(tempPath, path); + } + } + else + { + File.Move(tempPath, path); + } + } + finally + { + if (File.Exists(tempPath)) + { + try + { + File.Delete(tempPath); + } + catch + { + // Best effort cleanup. + } + } + } + } + + private bool ProbeWritable() + { + var path = Path.Combine(_ledgerRoot, $".probe.{Guid.NewGuid():N}"); + try + { + File.WriteAllText(path, "ok", new UTF8Encoding(false)); + File.Delete(path); + return true; + } + catch (Exception e) + { + ConsoleLog.Warning($"Invocation journal is not writable: {e.Message}"); + return false; + } + } + + private static string ResolveLedgerRoot() + { +#if UNITY_EDITOR + return Path.GetFullPath( + Path.Combine(Application.dataPath, "..", "Library", "CSharpConsole", "InvocationLedger", "v1")); +#else + return Path.GetFullPath( + Path.Combine(Application.persistentDataPath, "CSharpConsole", "InvocationLedger", "v1")); +#endif + } + + private static string ResolveTargetId( + IdentityRecord identity, + string currentTargetId, + int currentProcessId, + long currentProcessStartUtcTicks) + { +#if UNITY_EDITOR + var projectRoot = ResolveEditorProjectRoot(); + var bytes = new UTF8Encoding(false).GetBytes(projectRoot); + using var sha256 = SHA256.Create(); + var digest = sha256.ComputeHash(bytes); + var builder = new StringBuilder(24); + for (var index = 0; index < 12; index++) + { + builder.Append(digest[index].ToString("x2", CultureInfo.InvariantCulture)); + } + + return "editor-" + builder; +#else + if (IsPlayerTargetId(currentTargetId)) + { + // A service restart in the same process keeps its target. + return NormalizePlayerTargetId(currentTargetId); + } + + var persistedTargetId = identity?.targetId ?? ""; + var sameProcess = identity != null + && identity.processId == currentProcessId + && identity.processStartUtcTicks == currentProcessStartUtcTicks; + if (!string.IsNullOrEmpty(persistedTargetId) + && persistedTargetId.StartsWith("player-", StringComparison.Ordinal) + && Guid.TryParse(persistedTargetId.Substring("player-".Length), out var persistedPlayerId) + && sameProcess) + { + return "player-" + persistedPlayerId.ToString("D"); + } + + return "player-" + Guid.NewGuid().ToString("D"); +#endif + } + +#if UNITY_EDITOR + private static string ResolveEditorProjectRoot() + { + var projectRoot = Path + .GetFullPath(Path.Combine(Application.dataPath, "..")) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (Path.DirectorySeparatorChar == '\\') + { + projectRoot = projectRoot.ToLowerInvariant(); + } + + return projectRoot.Replace('\\', '/'); + } +#endif + +#if !UNITY_EDITOR + private static bool IsPlayerTargetId(string value) + { + return !string.IsNullOrEmpty(value) + && value.StartsWith("player-", StringComparison.Ordinal) + && Guid.TryParse(value.Substring("player-".Length), out _); + } + + private static string NormalizePlayerTargetId(string value) + { + return "player-" + Guid.Parse(value.Substring("player-".Length)).ToString("D"); + } + + private void CleanupExpiredPlayerState() + { + try + { + var cutoff = DateTime.UtcNow.AddSeconds(-DedupeWindowSeconds); + var identitiesRoot = Path.Combine(_ledgerBaseRoot, "identities"); + var targetsRoot = Path.Combine(_ledgerBaseRoot, "targets"); + var liveTargets = new HashSet(StringComparer.Ordinal); + + if (Directory.Exists(identitiesRoot)) + { + foreach (var identityPath in Directory.GetFiles(identitiesRoot, "*.json")) + { + var identity = LoadIdentity(identityPath); + if (identity == null || !IsPlayerTargetId(identity.targetId)) + { + // Retain unreadable identity state for manual recovery. + continue; + } + + var targetId = NormalizePlayerTargetId(identity.targetId); + if (string.Equals(targetId, _targetId, StringComparison.Ordinal) + || IsProcessInstanceAlive( + identity.processId, + identity.processStartUtcTicks)) + { + liveTargets.Add(targetId); + } + } + } + + if (Directory.Exists(targetsRoot)) + { + foreach (var targetDirectory in Directory.GetDirectories(targetsRoot)) + { + var targetId = Path.GetFileName(targetDirectory); + if (!IsPlayerTargetId(targetId) || liveTargets.Contains(targetId)) + { + continue; + } + + CleanupExpiredPlayerTargetDirectory(targetDirectory, cutoff); + } + } + + if (!Directory.Exists(identitiesRoot)) + { + return; + } + + foreach (var identityPath in Directory.GetFiles(identitiesRoot, "*.json")) + { + try + { + var identity = LoadIdentity(identityPath); + if (identity == null || !IsPlayerTargetId(identity.targetId)) + { + continue; + } + + var targetId = NormalizePlayerTargetId(identity.targetId); + if (liveTargets.Contains(targetId) + || File.GetLastWriteTimeUtc(identityPath) >= cutoff) + { + continue; + } + + var targetDirectory = Path.Combine(targetsRoot, targetId); + if (!Directory.Exists(targetDirectory)) + { + File.Delete(identityPath); + } + } + catch (Exception e) + { + ConsoleLog.Debug( + $"Failed to clean expired Player invocation identity {identityPath}: {e.Message}"); + } + } + } + catch (Exception e) + { + // Cross-target cleanup is never part of the current target's + // at-most-once write boundary. + ConsoleLog.Debug($"Failed to inspect expired Player invocation state: {e.Message}"); + } + } + + private static void CleanupExpiredPlayerTargetDirectory( + string targetDirectory, + DateTime cutoff) + { + try + { + foreach (var recordPath in Directory.GetFiles(targetDirectory, "*.json")) + { + var record = LoadRecord(recordPath); + if (record != null + && TryParseUtc(record.updatedAtUtc, out var updatedAt) + && updatedAt < cutoff) + { + File.Delete(recordPath); + } + } + + if (Directory.GetFileSystemEntries(targetDirectory).Length == 0) + { + Directory.Delete(targetDirectory, false); + } + } + catch (Exception e) + { + // Cross-target cleanup is best effort and must never make the + // current live target's otherwise healthy journal unavailable. + ConsoleLog.Debug( + $"Failed to clean expired Player invocation target {targetDirectory}: {e.Message}"); + } + } + + private static bool IsProcessInstanceAlive(int processId, long expectedStartUtcTicks) + { + if (processId <= 0) + { + return false; + } + + try + { + using var process = Process.GetProcessById(processId); + if (process.HasExited) + { + return false; + } + + if (expectedStartUtcTicks <= 0) + { + return true; + } + + return process.StartTime.ToUniversalTime().Ticks == expectedStartUtcTicks; + } + catch + { + return false; + } + } + +#endif + + private static long GetProcessStartUtcTicks() + { + try + { + return Process.GetCurrentProcess().StartTime.ToUniversalTime().Ticks; + } + catch + { + // The process id still prevents accidental identity reuse in the + // common case; this sentinel is stable across domain reloads. + return 0; + } + } + + private static string ComputeDigest(byte[] body) + { + using var sha256 = SHA256.Create(); + var digest = sha256.ComputeHash(body ?? Array.Empty()); + var builder = new StringBuilder(digest.Length * 2); + foreach (var value in digest) + { + builder.Append(value.ToString("x2", CultureInfo.InvariantCulture)); + } + + return builder.ToString(); + } + + private static bool TryParseUtc(string text, out DateTime value) + { + return DateTime.TryParse( + text, + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out value); + } + + private static bool IsSha256Hex(string value) + { + if (string.IsNullOrEmpty(value) || value.Length != 64) + { + return false; + } + + foreach (var character in value) + { + if (!Uri.IsHexDigit(character)) + { + return false; + } + } + + return true; + } + + private static bool IsResponseEnvelopeJson(string responseJson) + { + if (string.IsNullOrWhiteSpace(responseJson) + || responseJson.IndexOf("\"dataJson\"", StringComparison.Ordinal) < 0 + || responseJson.IndexOf("\"summary\"", StringComparison.Ordinal) < 0) + { + return false; + } + + try + { + return JsonUtility.FromJson(responseJson) != null; + } + catch + { + return false; + } + } + } +} diff --git a/Runtime/Service/Internal/InvocationCoordinator.cs.meta b/Runtime/Service/Internal/InvocationCoordinator.cs.meta new file mode 100644 index 0000000..9e64411 --- /dev/null +++ b/Runtime/Service/Internal/InvocationCoordinator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9e4f68cb228e4333b85c2599a00f0ab4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Service/Internal/MainThreadRequestRunner.cs b/Runtime/Service/Internal/MainThreadRequestRunner.cs index 8ec685e..3ee5a3b 100644 --- a/Runtime/Service/Internal/MainThreadRequestRunner.cs +++ b/Runtime/Service/Internal/MainThreadRequestRunner.cs @@ -10,9 +10,18 @@ namespace Zh1Zh1.CSharpConsole.Service.Internal { + internal sealed class MainThreadOutcomeUnknownException : TimeoutException + { + public MainThreadOutcomeUnknownException(string message) + : base(message) + { + } + } + internal sealed class MainThreadRequestRunner { private const string DISPATCHER_NOT_INITIALIZED_MESSAGE = "Main-thread dispatcher is not initialized. Ensure MainThreadRequestRunner.InitializeEditor() or InitializeRuntime() is called during startup."; + private const int ASYNC_RUN_LOCK_WAIT_MAX_MS = 1000; private readonly static Queue s_SharedQueue = new Queue(); private readonly static object s_SharedQueueLock = new object(); @@ -106,38 +115,40 @@ public static T RunOnMainThread(Func work, int timeoutMs) } var postToMainThread = GetPlatformPostToMainThreadOrThrow(); - - T result = default; - Exception exception = null; - using var done = new ManualResetEventSlim(false); + var lease = new ExecutionLease(); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); postToMainThread(() => { - try + if (!lease.TryStart()) { - result = work(); + completion.TrySetCanceled(); + return; } - catch (Exception e) + + try { - exception = e; + var result = work(); + lease.MarkCompleted(); + completion.TrySetResult(result); } - finally + catch (Exception e) { - done.Set(); + lease.MarkCompleted(); + completion.TrySetException(e); } }); - if (!done.Wait(timeoutMs)) - { - throw new TimeoutException("Timeout: main thread execution timed out"); - } - - if (exception != null) + using var timeoutCts = new CancellationTokenSource(); + var timeoutTask = Task.Delay(timeoutMs, timeoutCts.Token); + var completedTask = Task.WhenAny(completion.Task, timeoutTask).GetAwaiter().GetResult(); + if (completedTask != completion.Task) { - throw exception; + ThrowTimeoutForLease(lease); } - return result; + timeoutCts.Cancel(); + return completion.Task.GetAwaiter().GetResult(); } public static Task RunOnMainThreadAsync(Func> work) @@ -163,14 +174,37 @@ public static Task RunOnMainThreadAsync(Func> work, int timeoutMs) private static async Task RunOnMainThreadAsyncCore(Func> work, int timeoutMs) { - await s_AsyncRunLock.WaitAsync().ConfigureAwait(false); + var timeoutBudget = System.Diagnostics.Stopwatch.StartNew(); + var lockWaitMs = Math.Min(Math.Max(timeoutMs, 0), ASYNC_RUN_LOCK_WAIT_MAX_MS); + if (!await s_AsyncRunLock.WaitAsync(lockWaitMs).ConfigureAwait(false)) + { + throw new TimeoutException( + "Timeout: another asynchronous main-thread execution is still running; this request did not start"); + } + + var releaseLockNow = true; try { + var elapsedMs = Math.Min(timeoutBudget.ElapsedMilliseconds, int.MaxValue); + var remainingTimeoutMs = timeoutMs - (int)elapsedMs; + if (remainingTimeoutMs <= 0) + { + throw new TimeoutException( + "Timeout: the asynchronous main-thread execution did not start within its timeout budget"); + } + var postToMainThread = GetPlatformPostToMainThreadOrThrow(); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var lease = new ExecutionLease(); postToMainThread(() => { + if (!lease.TryStart()) + { + tcs.TrySetCanceled(); + return; + } + var previousContext = SynchronizationContext.Current; var bridgeContext = new MainThreadRequestRunnerSynchronizationContext(postToMainThread); SynchronizationContext.SetSynchronizationContext(bridgeContext); @@ -182,6 +216,7 @@ private static async Task RunOnMainThreadAsyncCore(Func> work, int } catch (Exception e) { + lease.MarkCompleted(); tcs.TrySetException(e); return; } @@ -192,14 +227,48 @@ private static async Task RunOnMainThreadAsyncCore(Func> work, int if (task == null) { + lease.MarkCompleted(); tcs.TrySetResult(default); return; } - _ = CompleteAsyncWork(task, tcs); + _ = CompleteAsyncWork(task, tcs, lease); }); - return await AwaitWithTimeoutAsync(tcs.Task, timeoutMs).ConfigureAwait(false); + try + { + return await AwaitWithTimeoutAsync(tcs.Task, remainingTimeoutMs, lease).ConfigureAwait(false); + } + catch (MainThreadOutcomeUnknownException) when (!tcs.Task.IsCompleted) + { + // The caller must receive the unknown outcome immediately, + // but the underlying async Unity work is still running. + // Keep the serialization lock until that work actually + // settles so a later request cannot overlap the same + // executor/session. + releaseLockNow = false; + _ = ReleaseAsyncRunLockWhenCompleted(tcs.Task); + throw; + } + } + finally + { + if (releaseLockNow) + { + s_AsyncRunLock.Release(); + } + } + } + + private static async Task ReleaseAsyncRunLockWhenCompleted(Task task) + { + try + { + await task.ConfigureAwait(false); + } + catch + { + // The original request owns result/error reporting. } finally { @@ -374,15 +443,17 @@ private static void DrainSharedQueue() } } - private static async Task CompleteAsyncWork(Task task, TaskCompletionSource tcs) + private static async Task CompleteAsyncWork(Task task, TaskCompletionSource tcs, ExecutionLease lease) { try { var result = await task; + lease.MarkCompleted(); tcs.TrySetResult(result); } catch (OperationCanceledException e) { + lease.MarkCompleted(); if (task.IsCanceled) { tcs.TrySetCanceled(); @@ -393,24 +464,61 @@ private static async Task CompleteAsyncWork(Task task, TaskCompletionSourc } catch (Exception e) { + lease.MarkCompleted(); tcs.TrySetException(e); } } - private static async Task AwaitWithTimeoutAsync(Task task, int timeoutMs) + private static async Task AwaitWithTimeoutAsync(Task task, int timeoutMs, ExecutionLease lease) { using var timeoutCts = new CancellationTokenSource(); var timeoutTask = Task.Delay(timeoutMs, timeoutCts.Token); var completedTask = await Task.WhenAny(task, timeoutTask).ConfigureAwait(false); if (completedTask != task) { - throw new TimeoutException("Timeout: main thread execution timed out"); + ThrowTimeoutForLease(lease); } timeoutCts.Cancel(); return await task.ConfigureAwait(false); } + private static void ThrowTimeoutForLease(ExecutionLease lease) + { + if (lease != null && lease.TryCancelBeforeStart()) + { + throw new TimeoutException("Timeout: main thread execution was canceled before it started"); + } + + throw new MainThreadOutcomeUnknownException( + "Main-thread execution exceeded the timeout after it may have started; the outcome is unknown"); + } + + private sealed class ExecutionLease + { + private const int Pending = 0; + private const int Started = 1; + private const int Completed = 2; + private const int Canceled = 3; + + private int _state = Pending; + + public bool TryStart() + { + return Interlocked.CompareExchange(ref _state, Started, Pending) == Pending; + } + + public bool TryCancelBeforeStart() + { + return Interlocked.CompareExchange(ref _state, Canceled, Pending) == Pending; + } + + public void MarkCompleted() + { + Interlocked.Exchange(ref _state, Completed); + } + } + private sealed class MainThreadRequestRunnerSynchronizationContext : SynchronizationContext { private readonly Action _postToMainThread;