Files
codex-app-server/cloud/tests/test_resident_website_loop.py
host b391fe9320 resident P0+P1: DNS-level SSRF pin, batch budget, daily cap/cooldown/retention, PG schema/migration, dual-connection deadlock isolation, retarget baseline reset, proxy and structured failures, metrics
- DNS 级 SSRF pin:解析后 pin IP 建连,防 DNS 重绑定
- 批量总预算与每日上限/抖动冷却/保留期
- PG schema 与迁移清单
- 双连接死锁隔离
- 改目标重置基线
- 显式代理配置与结构化失败原因
- /metrics resident 口径
2026-11-02 03:30:57 +00:00

417 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""常驻监听网站闭环端到端测试。
上一轮只验到注入假抓取的单测:真实抓取、真实 run_loop 轮次
边界、经服务端 API 的全链路(创建 -> 基线 -> 变化建任务 ->
暂停 -> 恢复 -> 失败退避 -> 结束)都没有跑过。本文件补这一环,
不改任何生产默认值、不改生产校验口径:
- 间隔仍是生产下限 60 秒;到期不是改间隔,而是给驱动传一个
更晚的 now(模拟时间流逝),与上一轮单测同款时间注入。
- 目标 URL 仍走生产校验:主机名 website-loop.test 不是 IP 字
面、不是 localhost,所以校验通过;抓取本身走真实
default_fetch(DNS 门、pin 连接、超时、1MB 截断),不注入
假 fetcher。注意:生产默认 DNS 门会把解析到 127.0.0.1 的
目标拦住,所以本文件用两段测试接缝:worker 的
resident_resolver 把该主机名解析成公网测试 IP(门正常放
行),测试进程内再在 socket 层把该 IP:内容端口重定向回
本地服务(传输层终点是测试替身,门与 pin 全程真实跑);
DNS 门本身由 test_resident_p0_fixes.py 的默认解析器用例
覆盖,不在本文件重复证明。
- 内容服务是线程内真实 http.server,可变内容、也可停服;
控制面是真实 ThreadingHTTPServer + 真实 SQLite 临时库;
变化建出的任务、轮询、审计都直接读库/接口核对。
不联网、不跑真实模型、不碰生产。
"""
import http.client
import http.server
import json
import os
import socket
import sqlite3
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from types import SimpleNamespace
try:
from cloud.auth import AuthStore
from cloud.resident import _content_hash
from cloud.server import create_server
from cloud.worker import Worker
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.resident import _content_hash
from cloud.server import create_server
from cloud.worker import Worker
HOST = "website-loop.test"
class WebsiteLoopTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
status, self.project = self._req(
"POST", "/projects", {"name": "demo"}, self.owner_key)
self.assertEqual(status, 201, self.project)
# 可变内容服务:state 字典跨重启保留,停服/重启可复现。
self.state = {"content": "version one"}
state = self.state
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
body = state["content"].encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
self._handler = Handler
self.content_server = http.server.ThreadingHTTPServer(
("127.0.0.1", 0), Handler)
self.content_port = self.content_server.server_address[1]
self.content_thread = threading.Thread(
target=self.content_server.serve_forever, daemon=True)
self.content_thread.start()
# 测试专用 DNS:只把 website-loop.test 指到本地服务,
# 其它主机一律走原始解析;生产校验与抓取代码一字不改。
self._orig_getaddrinfo = socket.getaddrinfo
def patched(host, port, family=0, type=0, proto=0, flags=0):
if host == HOST:
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "",
("127.0.0.1", port))]
return self._orig_getaddrinfo(
host, port, family, type, proto, flags)
socket.getaddrinfo = patched
# 测试环境自带 egress 代理:urllib 默认会把这个测试主机名
# 也送代理并被断开。只给本测试主机名加 no_proxy(测试
# 进程内生效),生产抓取代码与代理行为一字不改。
self._old_no_proxy = os.environ.get("no_proxy")
self._old_NO_PROXY = os.environ.get("NO_PROXY")
os.environ["no_proxy"] = (
(self._old_no_proxy + ",") if self._old_no_proxy
else "") + HOST
os.environ["NO_PROXY"] = (
(self._old_NO_PROXY + ",") if self._old_NO_PROXY
else "") + HOST
self.worker = Worker(
self.db_path, "/fake/host.py",
env={"XCM_API_KEY": "test-placeholder"},
runner=lambda cmd: SimpleNamespace(
returncode=0, stdout="{}", stderr=""))
# 两段测试接缝(见文件头说明):resolver 返回公网测试
# IP,socket 层把该 IP:内容端口重定向回本地服务。
try:
from cloud.resident import _default_resolver
except ImportError:
_default_resolver = None
def _loop_resolver(host, port, _default=_default_resolver):
if host == HOST:
return ["93.184.216.34"]
if _default is not None:
return _default(host, port)
return [socket.getaddrinfo(host, port)[0][4][0]]
self.worker.resident_resolver = _loop_resolver
self._orig_create_connection = socket.create_connection
def _patched_create_connection(address, *args, **kwargs):
if address[0] == "93.184.216.34" \
and address[1] == self.content_port:
address = ("127.0.0.1", self.content_port)
return self._orig_create_connection(address, *args,
**kwargs)
socket.create_connection = _patched_create_connection
def tearDown(self):
# setUp 中途失败时属性可能未建:getattr 防御,避免
# tearDown 的 AttributeError 掩盖原始失败。
if getattr(self, "_orig_getaddrinfo", None) is not None:
socket.getaddrinfo = self._orig_getaddrinfo
if getattr(self, "_orig_create_connection", None) is not None:
socket.create_connection = self._orig_create_connection
if getattr(self, "_old_no_proxy", None) is None:
if "_old_no_proxy" in self.__dict__:
os.environ.pop("no_proxy", None)
else:
os.environ["no_proxy"] = self._old_no_proxy
if getattr(self, "_old_NO_PROXY", None) is None:
if "_old_NO_PROXY" in self.__dict__:
os.environ.pop("NO_PROXY", None)
else:
os.environ["NO_PROXY"] = self._old_NO_PROXY
try:
self.content_server.shutdown()
self.content_server.server_close()
except Exception:
pass
try:
self.content_thread.join(timeout=5)
except Exception:
pass
try:
self.server.shutdown()
self.server.server_close()
except Exception:
pass
try:
self.thread.join(timeout=5)
except Exception:
pass
try:
self.auth.conn.close()
except Exception:
pass
try:
self.tmp.cleanup()
except Exception:
pass
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port,
timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
def _url(self, suffix=""):
return "/projects/%s/resident-watches%s" % (
self.project["id"], suffix)
def _create_watch(self):
body = {"name": "site1",
"target_url": "http://%s:%d/data" % (
HOST, self.content_port),
"prompt": "变化时汇报",
"interval_seconds": 60}
status, watch = self._req("POST", self._url(), body,
self.owner_key)
self.assertEqual(status, 201, watch)
return watch
def _get_watch(self, watch_id):
status, body = self._req(
"GET", self._url("/" + watch_id), None, self.owner_key)
self.assertEqual(status, 200, body)
return body
def _tasks(self):
conn = sqlite3.connect(self.db_path)
try:
return conn.execute(
"SELECT id, prompt, status FROM tasks"
" ORDER BY created_at, id").fetchall()
finally:
conn.close()
def _audit(self):
conn = sqlite3.connect(self.db_path)
try:
return [r[0] for r in conn.execute(
"SELECT action FROM audit_log WHERE project_id = ?",
(self.project["id"],)).fetchall()]
finally:
conn.close()
def test_full_website_loop_end_to_end(self):
# 1. 创建:经服务端 API,running、游标为空、下次轮询在位。
watch = self._create_watch()
wid = watch["id"]
self.assertEqual(watch["status"], "running")
self.assertEqual(watch["interval_seconds"], 60)
self.assertEqual(watch["fetch_timeout_seconds"], 10)
self.assertIsNone(watch["last_content_hash"])
self.assertIsNotNone(watch["next_poll_at"])
self.assertIn("resident_watch_created", self._audit())
t0 = time.time()
# 2. 基线:真实抓取 version one,只建游标,不建任务。
polls = self.worker.process_due_resident_watches(now=t0 + 120)
self.assertEqual(len(polls), 1, polls)
self.assertEqual(polls[0]["status"], "baseline")
self.assertEqual(
polls[0]["observed_hash"], _content_hash("version one"))
self.assertEqual(self._tasks(), [])
body = self._get_watch(wid)
self.assertEqual(body["last_content_hash"],
_content_hash("version one"))
self.assertEqual(len(body["polls"]), 1)
self.assertEqual(body["polls"][0]["status"], "baseline")
# 3. 变化:内容改成 version two,到期轮询建出 queued 任务。
self.state["content"] = "version two"
polls = self.worker.process_due_resident_watches(now=t0 + 240)
self.assertEqual(len(polls), 1, polls)
self.assertEqual(polls[0]["status"], "changed")
self.assertIsNotNone(polls[0]["task_id"])
tasks = self._tasks()
self.assertEqual(len(tasks), 1)
self.assertEqual(tasks[0][0], polls[0]["task_id"])
self.assertEqual(tasks[0][2], "queued")
self.assertIn("变化时汇报", tasks[0][1])
self.assertIn("version two", tasks[0][1])
self.assertIn("resident_watch_change_task_created",
self._audit())
body = self._get_watch(wid)
self.assertEqual(body["last_content_hash"],
_content_hash("version two"))
self.assertIn("version two", body["last_change_text"])
# 4. 暂停:暂停期间再改内容,驱动不轮询、游标不动。
status, body = self._req(
"POST", self._url("/%s/pause" % wid), {}, self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["status"], "paused")
self.assertIsNone(body["next_poll_at"])
self.state["content"] = "version three"
polls = self.worker.process_due_resident_watches(now=t0 + 400)
self.assertEqual(polls, [])
body = self._get_watch(wid)
self.assertEqual(len(body["polls"]), 2)
self.assertEqual(body["last_content_hash"],
_content_hash("version two"))
# 5. 恢复:用旧游标比对,报出暂停期间的变化并建任务,
# 不是从头建基线。轮询时刻取 t0+540:距上次建任务
# (t0+240)已过 300 秒抖动冷却,否则新冷却语义会
# 正确地记 changed_cooldown 而不是 changed(冷却期
# 内的变化不丢,冷却过后复查仍可报,见 P0 批单测)。
status, body = self._req(
"POST", self._url("/%s/resume" % wid), {}, self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["status"], "running")
polls = self.worker.process_due_resident_watches(now=t0 + 540)
self.assertEqual(len(polls), 1, polls)
self.assertEqual(polls[0]["status"], "changed")
tasks = self._tasks()
self.assertEqual(len(tasks), 2)
self.assertIn("version three", tasks[1][1])
body = self._get_watch(wid)
self.assertEqual(body["last_content_hash"],
_content_hash("version three"))
# 6. 失败:停服后轮询记 failed、退避、不结束、游标不动;
# 恢复服务后内容未变,轮询记 unchanged、失败清零。
self.content_server.shutdown()
self.content_server.server_close()
self.content_thread.join(timeout=5)
polls = self.worker.process_due_resident_watches(now=t0 + 600)
self.assertEqual(len(polls), 1, polls)
self.assertEqual(polls[0]["status"], "failed")
body = self._get_watch(wid)
self.assertEqual(body["status"], "running")
self.assertEqual(body["consecutive_failures"], 1)
self.assertEqual(body["last_content_hash"],
_content_hash("version three"))
self.assertGreater(body["next_poll_at"], t0 + 600 + 60)
self.content_server = http.server.ThreadingHTTPServer(
("127.0.0.1", self.content_port), self._handler)
self.content_thread = threading.Thread(
target=self.content_server.serve_forever, daemon=True)
self.content_thread.start()
polls = self.worker.process_due_resident_watches(now=t0 + 800)
self.assertEqual(len(polls), 1, polls)
self.assertEqual(polls[0]["status"], "unchanged")
body = self._get_watch(wid)
self.assertEqual(body["consecutive_failures"], 0)
self.assertEqual(len(self._tasks()), 2)
# 7. 结束:终态,驱动不再轮询,改/停/恢复一律 409。
status, body = self._req(
"POST", self._url("/%s/end" % wid), {}, self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["status"], "ended")
self.assertIsNone(body["next_poll_at"])
self.assertEqual(body["last_content_hash"],
_content_hash("version three"))
polls = self.worker.process_due_resident_watches(now=t0 + 1000)
self.assertEqual(polls, [])
for action in ("pause", "resume", "end"):
status, body = self._req(
"POST", self._url("/%s/%s" % (wid, action)), {},
self.owner_key)
self.assertEqual(status, 409, action)
status, _ = self._req(
"PATCH", self._url("/" + wid), {"interval_seconds": 120},
self.owner_key)
self.assertEqual(status, 409)
body = self._get_watch(wid)
self.assertEqual(body["status"], "ended")
self.assertEqual(len(body["polls"]), 5)
def test_run_loop_boundary_calls_resident_driver(self):
# 8. 驱动接线:run_loop 轮次边界确实调到常驻驱动,
# 不只是测试直接调 process_due。run_all 等无关边界
# 在本测试里打桩,只留 resident 驱动走真实抓取。
watch = self._create_watch()
wid = watch["id"]
worker = self.worker
calls = []
original = Worker.process_due_resident_watches
def recording():
# 实例属性上的函数不会自动绑定 self,必须闭包捕获
# worker,不能写成 def recording(self)。
calls.append(time.time())
return original(worker)
worker.process_due_resident_watches = recording
worker.run_all = lambda: []
worker.cleanup_stale_containers = lambda: None
worker._write_heartbeat = lambda: None
worker.register = lambda: None
worker._process_push_due = lambda: None
worker.process_auto_memories = lambda: None
worker._reap_kill_requests = lambda *a, **k: None
worker.auto_resume_interrupted = lambda: None
worker._heartbeat_running_tasks = lambda: None
worker._registry_heartbeat = lambda: None
worker._code_changed = lambda: False
worker._code_signature_valid = lambda: True
worker.run_loop(interval=0.05, max_seconds=0.4)
self.assertGreaterEqual(len(calls), 1)
body = self._get_watch(wid)
self.assertEqual(len(body["polls"]), 1)
self.assertEqual(body["polls"][0]["status"], "baseline")
self.assertEqual(body["last_content_hash"],
_content_hash("version one"))
if __name__ == "__main__":
unittest.main()