Merge authoritative main into the task branch

Why: include the current integration baseline before verification.

Evidence: fetched origin/main=226a4af15791b8100528c9d9a31c68a32c4781d1.

Impact: integrate upstream changes without rewriting task history.

Refs: task-sync
This commit is contained in:
2026-10-12 09:48:02 +00:00
5 changed files with 255 additions and 3 deletions
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -1171,8 +1171,12 @@ env_config() {
ENV_LOG="$ENV_DIR/svc.log" ENV_LOG="$ENV_DIR/svc.log"
ENV_BIN_DIR="$ENV_DIR/bin" ENV_BIN_DIR="$ENV_DIR/bin"
ENV_BIN="$ENV_BIN_DIR/agent-system" ENV_BIN="$ENV_BIN_DIR/agent-system"
# 数据库配置解析态按实例重置——批量 update/restart 逐服务解析时不串用上一服务的 DB_*。 # 按实例重置内存态——批量 update/restart 逐服务解析时不串用上一服务。
# DB_*:防跨服务串库;SVC_PIDCHECK/EXEC/VERSION/SOURCE:防 pid_alive 与
# reclaim_own_port 用上一服务标记误判本服务进程为「已死/非本服务」(2026-10-12
# update --all:dev 成功后 genui/rdm1/rdmcand 端口仍被孤儿占用)。
SVC_DB_MODE=""; SVC_DB_SOURCE=""; SVC_DB_REF=""; SVC_DB_DSN_SAVE="" SVC_DB_MODE=""; SVC_DB_SOURCE=""; SVC_DB_REF=""; SVC_DB_DSN_SAVE=""
SVC_PIDCHECK=""; SVC_EXEC=""; SVC_VERSION=""; SVC_SOURCE=""
local registered="" local registered=""
[ -f "$ENV_DIR/svc.env" ] && registered="$(svc_kind "$name")" [ -f "$ENV_DIR/svc.env" ] && registered="$(svc_kind "$name")"
SVC_KIND="${registered:-agent}" SVC_KIND="${registered:-agent}"
+50
View File
@@ -602,6 +602,56 @@ class SvcDbRecoveryTests(unittest.TestCase):
self.svc("dev", "restart") self.svc("dev", "restart")
self.assertIn("AGENT_SYSTEM_DSN=%s" % dsn, self.serve_record()) self.assertIn("AGENT_SYSTEM_DSN=%s" % dsn, self.serve_record())
def test_batch_update_stops_second_service_despite_prior_pidcheck(self):
"""批量 update 不得用上一服务的 SVC_PIDCHECK 误判下一服务进程。
复现 2026-10-12:update --all 先成功部署 a,内存 SVC_PIDCHECK 残留 a 的
bin 路径;do_stop_quiet / reclaim_own_port 对 b 匹配失败 → 误清 pidfile、
旧进程占口 → 报「非本服务」。env_config 须按实例清空 SVC_PIDCHECK 等。
"""
old_sha, new_sha = self.make_src_repo()
ports = {"a": 18841, "b": 18842}
old_pids = {}
for name, port in ports.items():
d = self.runtime(name)
self.seed_agent(
name, port,
"--addr 127.0.0.1:%d --data %s --plugins %s --auth auto"
% (port, d / "data", d / "plugins"),
extra_rows=[("source", "dev@%s" % old_sha)])
self.start_ok(name)
pidf = d / "svc.pid"
self.assertTrue(pidf.exists(), name)
old_pids[name] = int(pidf.read_text().strip())
self.assertTrue(self.pid_alive(name), name)
result = self.svc("update", "a", "b")
self.assertNotIn("非本服务", result.stdout, result.stdout)
self.assertNotIn("更新失败", result.stdout, result.stdout)
for name in ("a", "b"):
self.assertEqual(self.kv(name, "source"), "dev@%s" % new_sha, name)
self.assertTrue(self.pid_alive(name), "%s 应在运行" % name)
new_pid = int((self.runtime(name) / "svc.pid").read_text().strip())
self.assertNotEqual(new_pid, old_pids[name],
"%s 应已换新进程" % name)
# 与 svc.sh / recover 夹具一致:僵尸按死处理(CI 容器 PID 1 不收割)。
old_still = False
try:
os.kill(old_pids[name], 0)
try:
state = Path("/proc/%d/stat" % old_pids[name]).read_text() \
.rsplit(")", 1)[1].split()[0]
old_still = state != "Z"
except (OSError, IndexError):
old_still = True
except (ProcessLookupError, PermissionError):
old_still = False
self.assertFalse(old_still, "%s 旧进程应已停止" % name)
hist = (self.runtime(name) / "history.jsonl").read_text()
self.assertIn('"event":"stop"', hist, hist)
self.assertIn('"result":"ok"', hist)
self.assertIn('"event":"update"', hist)
# ---- 12. 播种认证(2026-10-11 修复):认证开启自动铸临时 key,用后回收 ---- # ---- 12. 播种认证(2026-10-11 修复):认证开启自动铸临时 key,用后回收 ----
+9 -1
View File
@@ -218,7 +218,7 @@ class ResolveDbConfigFixtures(unittest.TestCase):
class ResolverStateIsolation(unittest.TestCase): class ResolverStateIsolation(unittest.TestCase):
"""解析态按实例隔离(env_config 重置,防 update --all 串库)。""" """解析态按实例隔离(env_config 重置,防 update --all 串库 / 串 pidcheck)。"""
def test_env_config_resets_resolver_state(self): def test_env_config_resets_resolver_state(self):
# 棘轮:env_config 必须重置 SVC_DB_*(删除即回归「同进程连续启动跨服务 # 棘轮:env_config 必须重置 SVC_DB_*(删除即回归「同进程连续启动跨服务
@@ -228,6 +228,14 @@ class ResolverStateIsolation(unittest.TestCase):
) )
self.assertEqual(out.strip(), "1") self.assertEqual(out.strip(), "1")
def test_env_config_resets_pidcheck_runtime_state(self):
# 棘轮:env_config 必须清空 SVC_PIDCHECK(删除即回归「批量 update 用上一
# 服务 mark 误判本服务进程 → 误清 pidfile / 无法 reclaim」)。
out = _bash(
'source "$1"; declare -f env_config | grep -c \'SVC_PIDCHECK=""\''
)
self.assertEqual(out.strip(), "1")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+20 -1
View File
@@ -453,8 +453,23 @@ class SvcAutoRecoverTests(unittest.TestCase):
s.close() s.close()
return False return False
def _ss_can_attribute_pid(self):
"""reclaim_own_port /「非本服务」归因依赖 ss -ltnp 的 pid= 字段。
CI 容器常无 iproute2 或 ss 不带进程信息——此时只能探测占用、不能归因,
相关用例跳过(拒绝启动的兜底路径仍由「未能识别占用者」覆盖)。
"""
try:
out = subprocess.run(
["ss", "-ltnp"], capture_output=True, text=True, timeout=5)
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return False
return out.returncode == 0 and "pid=" in out.stdout
def test_start_reclaims_own_orphan_without_pidfile(self): def test_start_reclaims_own_orphan_without_pidfile(self):
"""模拟 update 误清 pidfile 后本 bin 仍占口:裸 start 应回收并拉起新进程。""" """模拟 update 误清 pidfile 后本 bin 仍占口:裸 start 应回收并拉起新进程。"""
if not self._ss_can_attribute_pid():
self.skipTest("ss -ltnp 无法归因 PID(reclaim_own_port 需此能力)")
port = 18812 port = 18812
self.seed_agent("dev", port) self.seed_agent("dev", port)
self.start_ok("dev") self.start_ok("dev")
@@ -486,7 +501,11 @@ class SvcAutoRecoverTests(unittest.TestCase):
try: try:
result = self.svc("dev", "start", check=False) result = self.svc("dev", "start", check=False)
self.assertEqual(result.returncode, 1, result.stdout) self.assertEqual(result.returncode, 1, result.stdout)
self.assertIn("非本服务", result.stdout) # 有 ss 时归因到外部进程 →「非本服务」;无 ss 时仅能探测 →「未能识别占用者」。
# 两种路径都拒绝启动,不拉起本服务。
self.assertTrue(
("非本服务" in result.stdout) or ("未能识别占用者" in result.stdout),
result.stdout)
self.assertFalse(self.pid_alive(self.pid_of("dev"))) self.assertFalse(self.pid_alive(self.pid_of("dev")))
finally: finally:
thief.terminate() thief.terminate()