part 6 修复:worktree 根移出 /app——RETEST5 挂载点回归

RETEST5:part 5 的 --bind=<workspace>/.chv-worktrees:/app/work
(adc7bf6,排在 --bind-ro=/app 之后)让所有 cell 启动失败:
后挂的 bind 只有在挂载点已存在时才能盖住,挂载点不存在时
nspawn 在只读 /app 下连建都建不起来(Failed to create mount
point .../app/work: Read-only file system,全量 73 次,
CELL-RC=0 共 0 次),缺陷 1 的 15/15 回退为 0/15。

三选一取 (a) worktree 根移出 /app:
- chv-cell 不再发任何指向 /app 之下的 bind,删
  --bind=...:/app/work 与 worktrees_dir 参数;WORKTREE_DIR
  (/home/chv/workspace/.chv-worktrees)改经
  --setenv=CHV_WORKTREE_ROOT 传进单元——该路径在已可写绑进
  单元的 workspace 之下,挂载点问题按构造即不存在;backing
  仍在客机 workspace 下随磁盘持久化,隔离与落盘语义不变,
  只是单元内路径从 /app/work/host-worktrees 改为
  /home/chv/workspace/.chv-worktrees/host-worktrees。
- host.py 新增 worktree_base():CHV_WORKTREE_ROOT 优先;
  CHV_CELL=1 但变量未设时回落到同一单元内路径(旧版 chv-cell
  / 单元内手动跑 host.py 仍不出 /app);单元外沿用
  HERE/work/host-worktrees,行为不变。make_worktree 与
  dispatch_from_branch(第二个 HERE/work 调用方)同走该函数,
  goal/fork/依赖链一并修到。
- 不选 (b) 默认 --no-worktree:隔离丢了,且 goal/fork 同样
  调 make_worktree,照样撞 Errno 30;不选 (c) 镜像预建空
  目录 /app/work:修法耦合镜像构建,某次重建漏建即静默回归,
  且单测无法钉住镜像内容。
- 根因 1(39084ce)与根因 3(0203f32)代码未动。

单测:删 part 5 钉 argv 顺序的 4 项(该顺序本身已被真机证伪),
新增 CellPart6WorktreeTest 4 项 + HostWorktreeBaseTest 5 项:
argv 里无任何指向 /app 之下的 bind、CHV_WORKTREE_ROOT 经
setenv 进单元、worktree 根只经可写 workspace 绑定可达、
两处同值常量一致、worktree_base 三路选择、make_worktree
真 git worktree 落点、dispatch_from_branch 同根。

全量:cloud/ 下 discover -s tests,2067 项 OK(skipped=9)。
(注:仓库根跑 discover 会把 queue 解析成标准库、致
test_queue_sandbox 导入失败,须在 cloud/ 下跑。)
This commit is contained in:
host committed 2026-11-07 14:23:48 +00:00
1 parent 0203f32627
commit 50cae2a0af
3 files changed
+249 -55

No files matched your search

+168 -23
View File
@@ -36,6 +36,20 @@ BROWSER = _load_script("chv-browser.py")
BUILDINFO = _load_script("buildinfo.py")
def _load_host():
"""把仓库根的 host.py 当模块加载(只含定义,__main__ 才跑
CLI,导入无副作用),供 worktree 根选择逻辑单测。"""
path = ROOT / "host.py"
spec = importlib.util.spec_from_file_location(
"chv_host_py", str(path))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
HOST = _load_host()
class CellArgvTest(unittest.TestCase):
def test_private_users_maps_cell_root_to_chv(self):
argv = CELL.build_nspawn_argv(["true"])
@@ -854,37 +868,168 @@ class CellPart4ArgvTest(unittest.TestCase):
self.assertEqual(net.subnet, "10.200.0.0/24")
class CellPart5WorktreeTest(unittest.TestCase):
"""part 5 修复 2(RETEST4 新发现 2):/app/work 以可写 bind
盖住只读 /app——默认 dispatch 的 make_worktree 才写得进。"""
class CellPart6WorktreeTest(unittest.TestCase):
"""part 6 修复(RETEST5 新发现):worktree 根移出 /app。
def test_worktree_bind_is_writable_under_workspace(self):
part 5 的旧测试钉住的是 argv 顺序(/app/work 绑定排在
--bind-ro=/app 之后),RETEST5 真机证明该顺序本身不可行:
后挂的 bind 只有在挂载点已存在时才能盖住,挂载点不存在时
nspawn 在只读 /app 下连建都建不起来(全量 73 次同一错误,
CELL-RC=0 共 0 次)。本轮单测不再钉顺序,改钉语义:argv 里
不得出现任何指向只读 /app 之下的 bind(挂载点不存在时不发
致命 bind),worktree 根改经 CHV_WORKTREE_ROOT 环境变量传
进单元,落点在已可写绑进单元的 workspace 之下。"""
@staticmethod
def _bind_dests(argv):
"""argv 里全部 bind/bind-ro 的单元内目的路径。"""
dests = []
for token in argv:
for prefix in ("--bind=", "--bind-ro="):
if token.startswith(prefix):
spec = token[len(prefix):]
dests.append(spec.split(":", 1)[1]
if ":" in spec else spec)
return dests
def test_no_bind_targets_anything_under_readonly_app(self):
argv = CELL.build_nspawn_argv(["true"])
self.assertIn(
# RETEST5 的致命绑定本身必须消失。
self.assertNotIn(
"--bind=/home/chv/workspace/.chv-worktrees:/app/work",
argv)
# 必须是可写 bind,不是只读绑。
self.assertNotIn(
"--bind-ro=/home/chv/workspace/.chv-worktrees:/app/work",
# 更一般的语义:任何 bind 的目的路径都不得落在只读的
# /app 之下(/app 本身是 --bind-ro 的挂载点,允许;其
# 子路径挂载点在镜像里不存在,nspawn 建不了即 EROFS)。
for dest in self._bind_dests(argv):
self.assertFalse(
dest.startswith("/app/"),
"bind 目的路径在只读 /app 之下:%s" % dest)
def test_worktree_root_passed_via_setenv(self):
argv = CELL.build_nspawn_argv(["true"])
self.assertIn(
"--setenv=CHV_WORKTREE_ROOT="
"/home/chv/workspace/.chv-worktrees",
argv)
def test_worktree_bind_stacks_after_readonly_app(self):
# nspawn 按顺序挂载:work bind 必须在 --bind-ro=/app 之后,
# 才能盖住只读子树下的 /app/work。
argv = CELL.build_nspawn_argv(["true"])
self.assertLess(
argv.index("--bind-ro=/app"),
argv.index(
"--bind=/home/chv/workspace/.chv-worktrees:/app/work"))
def test_worktree_bind_custom_dir(self):
argv = CELL.build_nspawn_argv(["true"],
worktrees_dir="/tmp/wt")
self.assertIn("--bind=/tmp/wt:/app/work", argv)
def test_worktree_dir_constant_under_workspace(self):
def test_worktree_dir_reachable_via_writable_workspace_bind(self):
# worktree 根在 workspace 之下,且 workspace 本身已以
# 可写 bind(非 --bind-ro)进单元——根无需额外 bind,
# 挂载点问题按构造即不存在。
self.assertTrue(
CELL.WORKTREE_DIR.startswith(CELL.WORKSPACE + "/"))
argv = CELL.build_nspawn_argv(["true"])
self.assertIn("--bind=%s" % CELL.WORKSPACE, argv)
self.assertNotIn("--bind-ro=%s" % CELL.WORKSPACE, argv)
# .chv-worktrees 只应出现在 --setenv 里,不应出现在
# 任何 bind/bind-ro 参数里。
for token in argv:
if ".chv-worktrees" in token:
self.assertTrue(
token.startswith("--setenv=CHV_WORKTREE_ROOT="),
"worktree 目录不应再经 bind 进单元:%s" % token)
def test_worktree_dir_matches_host_cell_fallback(self):
# chv-cell 的 WORKTREE_DIR 与 host.py 在 CHV_CELL=1 且
# 环境变量未设时的回落值须是同一路径,否则两边各说各话。
self.assertEqual(CELL.WORKTREE_DIR, HOST.CELL_WORKTREE_ROOT)
class HostWorktreeBaseTest(unittest.TestCase):
"""part 6:host.py 的 worktree 根选择(worktree_base)与
make_worktree 落点——单元内永不在 /app 之下,单元外不变。"""
def test_env_override_wins_over_cell_fallback(self):
with mock.patch.dict(
os.environ,
{"CHV_WORKTREE_ROOT": "/tmp/wt-root",
"CHV_CELL": "1"},
clear=True):
self.assertEqual(
HOST.worktree_base(), "/tmp/wt-root/host-worktrees")
def test_cell_without_env_falls_back_to_workspace(self):
with mock.patch.dict(os.environ, {"CHV_CELL": "1"},
clear=True):
base = HOST.worktree_base()
self.assertEqual(
base,
"/home/chv/workspace/.chv-worktrees/host-worktrees")
self.assertFalse(base.startswith("/app/"))
def test_outside_cell_uses_here_work(self):
with mock.patch.dict(os.environ, {}, clear=True):
self.assertEqual(
HOST.worktree_base(),
os.path.join(HOST.HERE, "work", "host-worktrees"))
def _make_git_repo(self, tmp):
repo = os.path.join(tmp, "repo")
os.makedirs(repo)
for args in (["init", "-q", repo],
["-C", repo, "config", "user.email",
"test@example.invalid"],
["-C", repo, "config", "user.name", "test"]):
subprocess.run(["git"] + args, check=True,
capture_output=True)
with open(os.path.join(repo, "README.md"), "w") as fh:
fh.write("hi\n")
subprocess.run(["git", "-C", repo, "add", "."],
check=True, capture_output=True)
subprocess.run(
["git", "-C", repo, "commit", "-qm", "init"],
check=True, capture_output=True)
return repo
def test_make_worktree_uses_override_not_app(self):
with tempfile.TemporaryDirectory() as tmp:
repo = self._make_git_repo(tmp)
wt_root = os.path.join(tmp, "wt")
with mock.patch.dict(
os.environ,
{"CHV_WORKTREE_ROOT": wt_root, "CHV_CELL": ""},
clear=False):
path, branch = HOST.make_worktree(repo, "t1")
self.assertEqual(
path, os.path.join(wt_root, "host-worktrees", "t1"))
self.assertEqual(branch, "host/t1")
self.assertFalse(path.startswith("/app/"))
# 真是 git worktree,不是空目录拷贝。
proc = subprocess.run(
["git", "-C", path, "rev-parse", "--show-toplevel"],
capture_output=True, text=True)
self.assertEqual(proc.returncode, 0)
self.assertEqual(proc.stdout.strip(), path)
def test_dispatch_from_branch_uses_same_base(self):
# 第二个 HERE/work 调用方(依赖派发)须与 make_worktree
# 走同一根,否则 goal/fork 的依赖链仍撞只读 /app。
with tempfile.TemporaryDirectory() as tmp:
repo = self._make_git_repo(tmp)
wt_root = os.path.join(tmp, "wt")
with mock.patch.dict(
os.environ,
{"CHV_WORKTREE_ROOT": wt_root, "CHV_CELL": ""},
clear=False):
dep_path, _ = HOST.make_worktree(repo, "dep")
created = {}
class _FakeServer:
def create_thread(self, path, **kwargs):
created["path"] = path
return "th-1"
state = {"tasks": {}}
task, err = HOST.dispatch_from_branch(
_FakeServer(), state, "child", "prompt", repo,
"host/dep", dep_path, "m", "low",
"danger-full-access", "ask")
self.assertEqual(err, "")
self.assertEqual(
task["cwd"],
os.path.join(wt_root, "host-worktrees", "child"))
self.assertFalse(task["cwd"].startswith("/app/"))
class CellFilesTest(unittest.TestCase):