Skip to content

Commit 591deec

Browse files
committed
fix(runtime): preserve ambiguity for response decode failures
Signed-off-by: aihop <4408344+hhyykk@users.noreply.github.com>
1 parent 973914e commit 591deec

4 files changed

Lines changed: 101 additions & 2 deletions

File tree

‎docs/architecture/rfcs/typescript-control-plane-migration-v0.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1518,6 +1518,10 @@ the transport neither truncates history nor creates a Python decision or new
15181518
authority. Missing, changed, non-private or over-64-MiB files fail closed.
15191519
After a handler may have committed, an unverifiable response stays ambiguous
15201520
and requires exact receipt readback, never automatic mutation retry.
1521+
Both inline and private-file responses apply this rule to invalid encoding,
1522+
JSON value limits and excessive nesting. A decoder failure cannot enter the
1523+
pre-send startup retry path or erase possible durable acceptance; it reports
1524+
`runtime_response_ambiguous` through the existing transport adapter.
15211525

15221526
Source read and read/check reduction now compose inside one TypeScript request.
15231527
Both context reading and commit preflight use one checkpoint request instead of

‎docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,6 +1066,9 @@ Goal 与重放;源码验证不代表已安装宿主采用或整 Goal 资格通
10661066
使用同 UID 私有文件及字节数、SHA-256 摘要绑定;其他 effect 的默认传输边界不变。
10671067
文件缺失、改变、权限不符或超过 64 MiB 时拒绝。Handler 可能已经提交后,无法验证
10681068
的响应仍按不明确结果处理,要求精确回执回读,不能自动重试写入。
1069+
内联响应和私有文件响应都将此规则用于编码无效、JSON 值超限和嵌套过深的情况。
1070+
解码失败不能进入发送前的启动重试路径,也不能抹掉可能已经持久化的事实;
1071+
既有传输 adapter 将其报告为 `runtime_response_ambiguous`。
10691072

10701073
来源读取和 read/check 归约现在在一次 TypeScript 请求内完成。上下文读取与提交前
10711074
检查各从两次 checkpoint 请求降为一次,完整权威事实不再先返回 Python 再传回 TS。

‎loopx/control_plane/effect_runtime.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -786,7 +786,8 @@ def _request_with_info(
786786
raise EffectRuntimeResponseAmbiguous(method, timeout=timeout) from exc
787787
try:
788788
response = json.loads(b"".join(chunks).split(b"\n", 1)[0])
789-
except (json.JSONDecodeError, IndexError):
789+
except (ValueError, RecursionError):
790+
# Decoder limits can fail after commit; they cannot authorize a retry.
790791
raise EffectRuntimeResponseAmbiguous(method, timeout=timeout) from None
791792
if isinstance(response, dict) and "result_ref" in response:
792793
response = _read_local_snapshot_response(
@@ -832,7 +833,7 @@ def _read_local_snapshot_response(
832833
if not isinstance(result, dict):
833834
raise ValueError("invalid local snapshot response")
834835
return result
835-
except (OSError, ValueError, json.JSONDecodeError) as exc:
836+
except (OSError, ValueError, RecursionError) as exc:
836837
raise EffectRuntimeResponseAmbiguous(method, timeout=timeout) from exc
837838

838839

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
from __future__ import annotations
2+
3+
import hashlib
4+
import json
5+
import os
6+
import socket
7+
from concurrent.futures import ThreadPoolExecutor
8+
from pathlib import Path
9+
from threading import Event
10+
11+
import pytest
12+
13+
from loopx.control_plane import effect_runtime
14+
15+
16+
@pytest.mark.parametrize("snapshot", [False, True], ids=["inline", "snapshot"])
17+
@pytest.mark.parametrize(
18+
"result_bytes",
19+
[b"[" * 100_000 + b"0" + b"]" * 100_000, b'"\xff"'],
20+
ids=["excessive-depth", "invalid-utf8"],
21+
)
22+
def test_undecodable_response_is_ambiguous_without_redispatch(
23+
tmp_path: Path, monkeypatch, snapshot: bool, result_bytes: bytes,
24+
) -> None:
25+
monkeypatch.setattr(effect_runtime, "_runtime_dir", lambda: tmp_path)
26+
fingerprint = effect_runtime._runtime_fingerprint_for_request()
27+
finished = Event()
28+
received_requests = []
29+
with socket.socket() as listener:
30+
listener.bind(("127.0.0.1", 0))
31+
listener.listen()
32+
listener.settimeout(0.05)
33+
info = {
34+
"schema_version": effect_runtime.EFFECT_RUNTIME_INFO_SCHEMA_VERSION,
35+
"fingerprint": fingerprint,
36+
"pid": os.getpid(),
37+
"host": "127.0.0.1",
38+
"port": listener.getsockname()[1],
39+
"token": "fixture-token",
40+
}
41+
locator = effect_runtime._runtime_info_path(fingerprint)
42+
locator.write_text(json.dumps(info), encoding="utf-8")
43+
44+
def serve_responses() -> None:
45+
while not finished.is_set():
46+
try:
47+
connection = listener.accept()[0]
48+
except TimeoutError:
49+
continue
50+
with connection:
51+
connection.settimeout(2)
52+
request_bytes = b""
53+
while b"\n" not in request_bytes:
54+
chunk = connection.recv(65536)
55+
if not chunk:
56+
raise AssertionError("request ended before its frame")
57+
request_bytes += chunk
58+
request = json.loads(request_bytes)
59+
received_requests.append(request)
60+
envelope = {
61+
"schema_version": effect_runtime.EFFECT_RUNTIME_RESPONSE_SCHEMA_VERSION,
62+
"request_id": request["request_id"],
63+
"ok": True,
64+
}
65+
response = json.dumps(envelope).encode()[:-1] + b',"result":' + result_bytes + b"}\n"
66+
if snapshot:
67+
Path(request["response_sink"]).write_bytes(response)
68+
envelope["result_ref"] = {
69+
"byte_count": len(response),
70+
"sha256": hashlib.sha256(response).hexdigest(),
71+
}
72+
response = json.dumps(envelope).encode() + b"\n"
73+
connection.sendall(response)
74+
75+
with ThreadPoolExecutor(max_workers=1) as executor:
76+
server = executor.submit(serve_responses)
77+
try:
78+
with pytest.raises(effect_runtime.EffectRuntimeResponseAmbiguous) as error:
79+
effect_runtime.effect_runtime_request(
80+
"goal.checkpoint_read_context.commit" if snapshot else "turn_journal.write",
81+
{"operation_id": "fixture-operation"},
82+
timeout=2,
83+
large_local_snapshot=snapshot,
84+
)
85+
finally:
86+
finished.set()
87+
server.result(timeout=3)
88+
89+
assert error.value.diagnostic_code == "runtime_response_ambiguous"
90+
assert len(received_requests) == 1
91+
assert json.loads(locator.read_text(encoding="utf-8")) == info

0 commit comments

Comments
 (0)