From 15eb63b117498876f03f7ebbaecd1e1f205644d4 Mon Sep 17 00:00:00 2001 From: zhnt Date: Sun, 13 Sep 2026 06:30:44 +0000 Subject: [PATCH 01/44] feat(apphost): establish managed deployment storage baseline --- docs/internals/architecture/apphost/README.md | 8 + .../architecture/apphost/lmux-contract-m0.md | 287 +++++++++ docs/internals/architecture/drafts/README.md | 1 + ...rnesstui-named-mux-daemon-attach-design.md | 8 + .../drafts/lmux-managed-service-design.md | 600 ++++++++++++++++++ .../harness/machine-local-runtime-storage.md | 18 + .../hosted-application-support-boundary.md | 17 + .../hosted-product-runtime-v1-inventory.md | 2 + src/loushang/apphost/managed/__init__.py | 1 + src/loushang/apphost/managed/_files.py | 387 +++++++++++ src/loushang/apphost/managed/contracts.py | 206 ++++++ src/loushang/apphost/managed/paths.py | 94 +++ tests/apphost/test_managed_contracts.py | 150 +++++ tests/apphost/test_managed_files.py | 411 ++++++++++++ tests/apphost/test_managed_paths.py | 88 +++ ...test_hosted_product_runtime_v1_baseline.py | 8 + .../test_hosting_architecture_baseline.py | 4 + 17 files changed, 2290 insertions(+) create mode 100644 docs/internals/architecture/apphost/lmux-contract-m0.md create mode 100644 docs/internals/architecture/drafts/lmux-managed-service-design.md create mode 100644 src/loushang/apphost/managed/__init__.py create mode 100644 src/loushang/apphost/managed/_files.py create mode 100644 src/loushang/apphost/managed/contracts.py create mode 100644 src/loushang/apphost/managed/paths.py create mode 100644 tests/apphost/test_managed_contracts.py create mode 100644 tests/apphost/test_managed_files.py create mode 100644 tests/apphost/test_managed_paths.py diff --git a/docs/internals/architecture/apphost/README.md b/docs/internals/architecture/apphost/README.md index 1cddb52af..2bbf160e5 100644 --- a/docs/internals/architecture/apphost/README.md +++ b/docs/internals/architecture/apphost/README.md @@ -14,6 +14,7 @@ [G14 Foreground Stdio](../appserver/foreground-stdio-hosted-app-g14.md) · [G15 Foreground Hosted TUI Design](foreground-hosted-tui-g15.md) · [G17 Explicit Hosted Session Workflow](hosted-session-workflow-g17.md) · +[lmux Managed Contract M0](lmux-contract-m0.md) · [Hosted Product Runtime V1 Plan](../drafts/hosted-product-runtime-v1-plan.md) ## Status @@ -48,6 +49,13 @@ and a later packaging decision. ## Current +The accepted [lmux M0 contract](lmux-contract-m0.md) adds an optional Linux +managed-deployment Target and shared-client/view boundaries. Pure identity, +handoff-state, stop-evidence and layout values plus a Linux private-file owner +are implemented in `apphost.managed`; native service control, managed discovery, Session writer +admission and the installed `lmux` entry remain unactivated. This does not +change the existing foreground or explicit G16 lifecycle. + A0.1 supplies immutable standard-library contracts and exact validation for: - Product and profile descriptors; diff --git a/docs/internals/architecture/apphost/lmux-contract-m0.md b/docs/internals/architecture/apphost/lmux-contract-m0.md new file mode 100644 index 000000000..13880e4d1 --- /dev/null +++ b/docs/internals/architecture/apphost/lmux-contract-m0.md @@ -0,0 +1,287 @@ +# lmux M0:受管部署与共享会话接缝 + +[Proposal](../drafts/lmux-managed-service-design.md) · [AppHost](README.md) · +[Hosting boundary](../hosting/key-designs/hosted-application-support-boundary.md) + +## Status + +- ID: `LMUX-M0` +- Authority: normative — accepted limited Linux managed-profile contract +- Design status: accepted +- Review status: three-perspective contract, pure-value and private-file slice reviews passed +- Implementation status: partial — pure values and Linux private-file owner; no activation +- Owner: AppHost managed deployment; sibling changes remain sibling-owned +- Tracking objective: active Linux lmux goal, branch `harness/lmux-managed-service` + +## 1. 本地基线与推进记录 + +起点 `1286348e`,保留已评审 lmux 草案;独立任务分支,不推送/合并。 +基线命令为 `uv run --no-sync python scripts/dev/run_pytest.py`,目标包括 +`tests/coding/test_mux_command.py`、`tests/coding/test_hosted_catalog.py`、 +`tests/harnesstui/test_hosted_mux_profile.py`、`tests/harnesstui/test_hosted_mux_shell.py`, +使用 `-m 'not live' --skip-host-runtime -q`,沙箱外执行:30 passed。 +独立测试 runtime 为 `/var/tmp/loushang-lmux-goal-tests`,不是用户 lmux 状态根。 + +完整目标仍包含 M0–M4、真实安装/断连/性能/三视角代码评审;合同测试不替代 +这些验收。阶段交付记录只描述完成的切片,不缩小 goal。 + +## 2. 组件与候选准入 + +| 候选责任 | 决定 | 理由/约束 | +| --- | --- | --- | +| 用户级全局 Mux/服务目录及管理事务 | AppHost 可选 `managed` 组件组 | 跨 Product 部署编排;不放 Coding CLI/GUI;无需常驻 broker | +| 脱离终端并交接存活权 | Hosting 可选 service 机制 | 现有 ProcessLease/ChildSession 不具有交接后独立寿命,不修改其 close | +| 管理协议与认证 | AppServer 可选 managed local profile | 旧 G16 语义不变;只接 opaque 管理 port,不导入 AppHost/Hosting | +| Session scope/历史适配 | Coding trusted composition/catalog | Product 权威;不让部署索引成为 transcript 副本 | +| 唯一持久写入寿命 | Harness transcript lifecycle | Embedded/Hosted 同一写入口参与;只在 lmux 加锁不足 | +| 通用视图与 intent | Harnesstui conversation/bindings | UI 不持有 Product;保留 G16/G17/Embedded 各自终止语义 | + +本合同仅准入 Linux graceful-managed profile 的 Target,不将 Hosting 的 +候选 system-service/restart/跨平台自动后台全部提升为已接受能力。 +目标源模块显式导入,不从 AppHost/Hosting 根 facade 自动导出/初始化。 + +## 3. 不可变值与公共端口 + +首批 `apphost.managed.contracts` 仅标准库值与纯状态转换,无 filesystem/env/ +Product/GUI/asyncio 导入。验证值合法不等于赋予启动或连接 authority。 + +- `ManagedServiceKeyV1`:Product ID、规范化绝对 workspace、固定 managed profile; + SHA-256 编码绑定三字段,生成稳定 service ID,不包含 Mux 名或启动实例。 +- `ManagedNamespaceV1`:注入的规范化 platform home、用户 ID、机器身份; + namespace key 区分同机器不同 home,也区分共享 home 的不同机器。 +- `ManagedInstanceRefV1`:namespace key、service ID、instance ID;只供精确寻址, + 不含认证密钥、PID 或文件路径,不能独立作为启动/杀进程权限。 +- `ManagedHandoffV1`:ref、attempt ID、provisional/committed/aborting 及 stop fence; + commit 仅从未 stop 的 provisional 进入,已 committed 再 commit 为幂等观察, + 不是第二次交接;stop 后即使重试也拒绝 commit,abort 不可越过 committed。 +- `ManagedStopEvidenceV1`:精确 instance ref、进程退出、应用完整结算与进程 + scope/句柄结算三种事实;stopped 必须全部为真,不能用 exit code 或 + socket 消失推断其余结算。 + +公共 Discover/Resolve 只返回只读摘要与 reference。EnsureStarted/ManagedCreate/ +ManagedClose/Stop 必须显式 intent 与 operation ID。PrepareConnection 由 AppHost +连接协调器调用 AppServer client 重新认证,返回范围受限的 lease;不是 GUI +运行 CLI 的包装。所有 runtime error 为有界错误码,私有字段不进入 repr。 +TTY 前置验证只在 lmux CLI,不进入公共端口。 + +## 4. Linux service 机制合同 + +外部输入:已准入完整 executable/argv/environment、私有控制根、attempt/instance、 +单调启动预算与注入的中性 handoff/stop observation port。Hosting 不读 Mux 或 +AppServer record,不解释 Product ready,只处理 owner 给出的判定。 + +机制提供: + +1. shell=False、独立 POSIX session、stdin=/dev/null、close_fds 且仅保留白名单 + 握手 fd;默认 stdout/stderr 不直接持久化原始内容。 +2. 子端 provisional owner 检测启动握手 EOF;父端死亡时仍能自行清理。 +3. 交接提交通过 consumer 的持久 CAS port,唯一提交点在子端;ack 丢失只 + 进入 unknown。commit 后父进程退出不会向后台服务传播终止。 +4. Linux 精确身份由 boot identity + PID start time + 可验证 pidfd 绑定;无法 + 验证时不执行基于 PID 的终止。scope 退出清理只作用本次拥有的资源。 +5. rollback 需 provisional→aborting 成功且有精确 OS 身份;committed/unknown + 不能被启动器超时清理。用户 stop 为 graceful-only,无自动 kill escalation。 +6. 等待进程退出不证明应用成功收口;consumer 在 lease-last 成功后记录匹配 + 实例的结算证明。异常退出/证据丢失返回 unclean/unknown,不能伪报 0。 + +进程树/句柄也是独立结算维度,pidfd 只绑定 leader,不能为裸 killpg 授权。 +provisional 阶段由启动尝试持有 Hosting 的完整 scope/tree owner,服务内部 +Product 已创建的 worker/tool 子进程仍由对应 Product/Hosting owner 持有; +handoff 提交将服务寿命交给自持 owner,但不销毁已存在的子资源 owner。 +子端提交前 EOF 先通过应用 owner 收口子资源,再完成自身退出。rollback +必须验证当前实例 scope 身份,复用/扩展既有 `_posix_process` 的树/句柄 +结算机制,不把一个单独 pidfd 当作整个 scope 的替代实现。若后代脱离已有 +可验证 scope,必须保留债务并拒绝宣称已回收;支持该种后代需要先补受控 +子资源 owner,不在底层扫描全机器进程猜测归属。 + +leader 先退出、后代仍持有 pipe、应用清理失败时 fence/配额债务仍保留。 +干净 stop/rollback 的判定还要求 `process_scope_settled`,涵盖本次拥有的 +后代与句柄。启动器死亡且子端无法继续结算时记录 unknown,由未来精确 +reconcile 观察处理,不把超时或 leader 退出转换为 scope 回收成功。此点的 +真实父死/后代存活测试是 M2 激活前置,不可只用已有 ProcessLease 类型代替。 + +系统 logout cleanup、runtime 目录被系统回收和机器重启不承诺续存;不自动 +启用 linger/systemd。真实 Linux 安装+PTY 父死/EOF 是 M2/M4 必测,不以 +subprocess mock 成功激活此 profile。 + +## 5. 存储、事务与锁权威 + +采用草案的 `$LOUSHANG_HOME/lmux/machines//` 局部布局。平台纯路径 +默认不修改;路径分类仍按 state/log/tmp/cache,不复制 Session 内容。 +基础只读路径值不做 IO 准入:具体 private-directory owner 用 no-follow、 +owner/mode、inode 验证和稳定锁,拒绝不支持可靠本地锁的布局后才启动。 + +全局 registry 用一个受限 SQLite 数据库承载服务描述、名称保留、operation +和配额;journal/数据库计入 80 MiB 上限。采用 DELETE journal + FULL 同步, +不制造无限 WAL;本地文件权限私有,路径/父目录已由 private owner 准入。 +数据库 schema version 严格检查,不自动迁移未知版本,连接短寿命且有 deadline。 + +物理峰值准入:page_size=4096、max_page_count=8192(数据库最多 32 MiB); +rollback journal 预留 34 MiB(含页头/段开销),事务辅助文件总计最多 8 MiB, +控制紧急预留 6 MiB,总计不超过 80 MiB。禁用 ATTACH/扩展加载、WAL、隐式 +vacuum 与无界临时排序;temp_store=MEMORY 且查询结果/内存/操作批次有上限。 +单写事务只处理一个有界 operation 或一个额度批次,并在进入前检查新增数据、 +可能修改的现有页/索引页、journal 峰值与实际可分配空间,不能以 SQLite 的 +max_page_count 代替文件总额治理。正常新增停止于 28 MiB 数据库水位,保留 +控制记录更新余量;紧急预留禁止普通 create/log/tmp 消费。实际内核分配失败 +仍可发生:停止新准入,现存实例认证 stop 通道不依赖 registry/log 写成功。 +无法持久保存最终证明时报告 unknown/unclean,不伪造已确认的停止成功。 + +- UNIQUE(name) 与 UNIQUE(service key);pending/停止状态继续占用名字。 +- 长 RPC 不持有全局事务;intent 提交后释放事务,再调用服务,对账后 CAS。 +- 每服务稳定的 lifecycle lock 序列化 epoch/start/stop/reconcile;不在释放时 + unlink。跨 runtime 根仍绑定同一持久 service fence。 +- 子端 handoff commit、父端 abort/stop CAS 共用该序列。操作结果按 attempt/ + instance/operation 匹配,新代不能被旧回复更新。 +- runtime 内只存当前实例机制/认证记录;持久 PID/最后观察不是存活 authority。 + runtime 丢失时拒绝另起实例,保留 unknown;不通过删除记录恢复“可用”。 +- managed AppServer mutation 只接受 consumer-issued、实例与 operation 绑定 + 的管理 authority;legacy create/close 直连拒绝。未登记的手工 G16 部署保持 + 原语义,不自动接管。 +- namespace 配额事务先预留后写入,日志/trace 共计 200 MiB,tmp 512 MiB; + owner 确认退出后只可对账释放未使用预留。已落盘日志/tmp 跨退出/重启 + 持续计费,直到存储 owner 验证删除/轮转才释放;未知文件和清理债务不当作 + 空闲额度。控制收口保留额度,未知 pending 不删。 + +公共路径 resolver 接受可信平台根与临时覆盖,叶子不读环境;service/instance +ID 只接受固定小写十六进制,Mux 名不拼文件路径。显式 tmp 覆盖优先,默认 +集中于服务器实例 tmp。Session 根不得与 control/runtime 交叠。 + +## 6. 默认 Session 适配与唯一写入 + +当前根分离和 `coding.hosted` v1 元数据属于旧显式 profile 合同,不删校验。 +受管 profile 使用新 Product catalog/binding,CWD/user-home 为同库发现视图, +所有打开后身份归一到 canonical root + Product + conversation ID,不能因 +选择了不同 scope 就制造第二个活跃 runtime 或第二把 writer lock。 + +准入过程:读取候选 header → 校验 Product/runtime profile/工作区 → 获取 +规范 Session 的持久 writer lease → 重读身份/版本 → 打开现有 transcript。 +已有普通 Coding transcript 保留 conversation ID、正文与附件引用,使用 +**非破坏性 Product view adapter** 导出 hosted envelope:continuity 身份从 +规范 Session 身份确定,不强制将旧 header 改写成 v1 create-operation 身份。 +此选择细化草案的“受控适配”:不是跳过 Product 验证,也不在 registry +复制 Session 元数据。已有 v1 hosted 元数据需先验证其原合同再适配。 + +规范 binding identity 的冻结规则:已有合法 v1 hosted header 保留原 Product、 +`continuityId` 和 `sessionId`,不按新算法改写;原 v1 create identity 校验仍 +完整执行。普通 Coding 历史的 Session ID 保持 conversation ID,continuity +ID 为 `sha256(UTF-8(NUL.join(["coding.managed.continuity/v1", "coding", +canonical_session_root, conversation_id])))` 小写十六进制。路径经可信 native +resolver 规范化后注入;输入字段禁止 NUL/非法标量,不允许 scope/workspace/ +service/instance 或当前时间参与。两种 scope 和 embedded 显式文件路径最终 +必须映射到同一 root+conversation ID;root 被用户迁移是另一次显式存储 +权威迁移,不伪装成普通 attach。UI selection scope 与 canonical envelope +分开校验,不能把 canonical identity 的 scope 反向用来扩大 discovery 权限。 + +新会话 create-operation 幂等信息保存在 Product 的 canonical transcript +metadata 中;失败/丢回复按 operation 与完整身份对账,不按名称猜测。 +同工作区历史可恢复;跨工作区候选显示不可用原因,不能重绑定 cwd。 +scope fingerprint 是发现准入约束,不是 Session 的唯一写入锁身份。 + +writer lease 放在 canonical session root 下的私有 owner namespace,稳定 +身份由 Product+conversation ID 决定。读取/发现不取得 writer 权;持久创建/ +恢复从统一 Harness transcript lifecycle 取得并保留到完整 Product runtime +dispose 成功。构造失败、Graph ownership 转移、取消和异常必须同一 owner +转移;清理失败继续持有 lease。既有短期 journal lock 保持,不能在整个会话 +持有 journal 写锁阻塞合法 snapshot 读取。 + +所有规范 Session 的持久修改入口参加同一 writer owner:包括普通追加、 +header/元数据维护、迁移、`delete_agent_transcript_jsonl`/Product delete 与 +附件破坏性维护。删除另一进程的活跃 Session 必须 busy;同 owner 内部写入 +使用受限现有 lease projection,不二次加锁自死锁。Graph 刷新/移交转移同一 +owner,不先释放再竞争。纯读取不持 writer lease;fork 在 journal 读锁下 +取得一致源快照,为新目标取得独立 writer lease,再复制/发布附件。 + +Embedded 与受管 Hosted 同时打开同会话时第二写者明确 busy;不同 Tab 指向 +同一规范 Session 时不创建第二 runtime。测试必须跨两个真实进程,包括 +embedded↔hosted 竞争,而非只测同一 asyncio.Lock。该变化涉及 Harness +持久会话生命周期,未通过其回归不得激活默认共享根。 + +激活矩阵补充:同一会话由 CWD/user-home/Embedded 显式文件三入口取得 +相同规范身份与锁;Hosted 活跃期间 Embedded delete/维护拒绝;cleanup +失败第二 writer 仍 busy;释放后恢复成功。普通历史 discovery/失败 resume +不得改变 header/正文/附件引用;成功 resume 只追加正常会话记录。合法 v1 +恢复保留身份,非法 v1 元数据拒绝;日志写入→崩溃→重启→续写仍受总额度。 + +实施入口 inventory(对应 writer 能力,不能只修一个 hosted 调用点): + +| 现有入口 | 需要接入的合同 | +| --- | --- | +| `harness.transcript.session_factory.AgentTranscriptSessionFactory.restore_context` | 恢复前取得规范 writer 并重验;persist=False 保持只读 | +| `harness.transcript.lifecycle.AgentTranscriptLifecycle` | 持久 create/restore 的统一 owner acquisition 与失败补偿 | +| `AgentTranscriptLifecycleSession._dispose_owned` | Product runtime 成功释放后再释放 writer;Graph owner 连续转移 | +| `harness.transcript.lifecycle.delete_agent_transcript_jsonl` | 非本 owner 的活跃会话删除 busy,不能只比较 current_session_file | +| `harness.transcript.product_session.ProductTranscriptSession.delete_session` | transcript 与附件清理使用同一被准入维护 authority | +| `harness.journal.jsonl.journal_file_lock[_at]` | 保留原短期读写锁,不替代 runtime writer lease | +| `coding.hosted_catalog` 与新的 managed catalog | 旧 profile 不放宽;新 profile canonical identity/scope 与 writer binding | + +## 7. Harnesstui 与 wire 接缝 + +沿用已评审草案 §6.3–6.4。M3 共享完整 conversation view/presenter,分别 +注入 Embedded binding 与 Hosted binding。中性端口不反向依赖这两个适配器; +Mux 外壳只负责成员/连接组合。主题与终端能力在客户端组合边注入。 +输入 intent 不决定执行成功;请求投递、执行状态和 unknown 分别处理。 +审批键包括 attachment/controller/member/Session/interaction/实际呈现内容, +不能跨 Tab 重用;总额与控制预留保持。工具富卡片/图片的协议增量不隐式启用。 + +## 8. 交付审计(持续更新,不代表已完成) + +| 要求 | 当前证据 | 剩余工作 | +| --- | --- | --- | +| 独立分支与基线 | 分支已建立,30 个基线回归通过 | 后续增量相对基线验证 | +| M0 合同 | 三视角修订后通过;纯值/状态/路径 56 项测试、Ruff/mypy 通过 | native admission、持久 CAS、writer lease 等运行实现及验证 | +| M1 私有文件 owner | 文件准入、稳定锁、记录 CAS 与故障清理;三视角复审通过;文件测试 31 项通过 | SQLite、配额和公共发现端口尚未实现 | +| 一条命令/后台/全局名/多 Tab/stop | 仅 G16 既有显式能力 | M1–M3 全部接线 | +| Session 唯一写入与默认历史 | 缺运行期跨进程合同实现 | writer lease + canonical catalog + 双进程测试 | +| 完整共享 Harnesstui/Markdown | 草案与本文接缝 | M3 代码与 Embedded 非回退 | +| 真实安装/断连/性能/三视角代码评审 | 未执行本目标验收 | M4 完整矩阵与本地提交 | + +Goal 保持 active;没有推送、PR、合并或跨平台自动后台发布。 + +## 9. M0 具体合同与首个原子切片复审 + +三位独立 reviewer 对本文与首批未激活代码进行了只读评审: + +- `lmux_arch_review`:通过;职责、SQLite/lock 权威、非破坏 adapter 保留 + v1 身份无冲突。纯值不执行 IO 或赋权,路径验证不冒充 native admission。 +- `lmux_lifecycle_review`:修复原 2 项 P1(破坏性维护同 writer lease、leader + 与 scope 分离)和 2 项 P2(文件保留计费、SQLite 物理峰值)后通过。 +- `lmux_ux_review`:修复原 2 项 P2(同步草案的 adapter 手段、continuity + 映射与合法 v1 身份规则)后通过。普通历史恢复目标保持完整。 + +首批代码 `src/loushang/apphost/managed/` 不导出根 facade、不安装命令、不 +启动进程;`tests/apphost/test_managed_contracts.py` 与 `test_managed_paths.py` +共 56 passed。它们证明值/状态/布局,不证明并发数据库、真实进程、Session +唯一写入、终端或性能。本次后续运行阶段仍须三视角代码复审,不复用此结论 +冒称整目标通过。 + +## 10. M1 私有文件 owner 切片 + +`apphost.managed._files` 只负责注入目录的 Linux 原生准入与有界小记录 IO, +不加载 Product、不激活 CLI、不解释认证或进程状态。逐级保留 no-follow +父目录 fd,先准入再相对创建;稳定 `.lock` 文件禁止被记录 write 替换, +每次操作与发布前复核持有锁的身份。锁只序列化遵循此协议的写入者, +不是针对同 UID 任意恶意进程的完整安全边界。 + +临时文件从 exclusive create 成功起由 owner 跟踪。发布前失败仅回收本次 +可验证文件;发布后 fsync 失败报告 unknown,不能当作“没有写入”重放。 +清理异常保留 primary 和有界诊断;unlink 后仍保留目录同步债务,完成 +fsync 才结算。重试只同步目录,不删除后来出现的同名 replacement。 + +三位 reviewer 的原问题(父目录替换、锁替换后旧 owner 写入、锁/记录 +命名混用、创建阶段临时文件遗漏、清理覆盖原始异常、同步债务提前释放) +均已修复并通过只读复审。此结论只覆盖文件切片,不覆盖后台进程或登记库。 + +最新聚焦运行:`test_managed_files.py`、`test_managed_contracts.py`、 +`test_managed_paths.py`、`test_hosted_product_runtime_v1_baseline.py` 共 +98 passed;其中新增文件测试 31 项,包含真实双进程 flock 与故障注入。 +Ruff、managed 模块 mypy 通过。此前完整 AppHost 主测试 1431 passed / 12 skipped, +唯一失败是精确架构清单未登记新模块;清单已补齐并包含于上述 98 项复测。 +不将这次聚焦成功写作完整 `check-apphost` 成功;剩余选中门禁单独补跑。 +Hosting 主测试 384 passed / 48 skipped,唯一失败同样为另一份精确模块清单; +修复后该架构文件 8 passed。两处清单均只显式添加四个 managed 模块, +没有改成忽略新增模块。G8/G9/G10 pytest 与对应 manifest 验证分别为 +19 / 16 / 15 passed,零跳过;G10 安装 canary 另外通过,后端为 +`posix-process-group-v1`,不与上述计数混算。这是既有安装路径非回退证据, +不是 lmux 自动后台或 SSH 断连验收。文档 6 项、依赖图新鲜度与静态检查通过; +CI 计划提示的跨平台/host-runtime 验收仍不因这次本地切片而宣称完成。 diff --git a/docs/internals/architecture/drafts/README.md b/docs/internals/architecture/drafts/README.md index 6b5208bb2..7582ecde3 100644 --- a/docs/internals/architecture/drafts/README.md +++ b/docs/internals/architecture/drafts/README.md @@ -47,6 +47,7 @@ Standalone drafts: - [AppHost A0 Contract Baseline (promoted to canonical AppHost scope)](apphost-contract-baseline-a0.md) - [Hosted Product Runtime V1 Delivery Plan](hosted-product-runtime-v1-plan.md) - [Harnesstui Named Mux And Daemon Attach](harnesstui-named-mux-daemon-attach-design.md) +- [lmux:命名多 Tab 与自动后台服务设计](lmux-managed-service-design.md) - [Loushang Runtime Architecture](loushang-runtime-architecture.md) - [Loushang Work / Method / Channel / Harness Architecture](loushang-work-method-channel-harness-architecture.md) - [Method / Ontology Action Driven Multi-Agent Collaboration Requirements](method-ontology-action-multi-agent-collaboration-requirements.md) diff --git a/docs/internals/architecture/drafts/harnesstui-named-mux-daemon-attach-design.md b/docs/internals/architecture/drafts/harnesstui-named-mux-daemon-attach-design.md index dd2f471e5..24b5fbf42 100644 --- a/docs/internals/architecture/drafts/harnesstui-named-mux-daemon-attach-design.md +++ b/docs/internals/architecture/drafts/harnesstui-named-mux-daemon-attach-design.md @@ -30,6 +30,14 @@ - `src/loushang/harness/session/facade.py` - `src/loushang/harness/session/operations.py` +2026-09-13 reading note: the prerequisite snapshot and roadmap below are +historical. G16 detachable local deployment and G17 explicit hosted workflow +are now delivered; consult the canonical [AppHost status](../apphost/README.md). +The new [lmux managed-service proposal](lmux-managed-service-design.md) covers +the remaining short entrypoint, cross-workspace name discovery, automatic +background ownership and default directory policy. It does not claim these +new capabilities are implemented or replace this draft's member/window model. + This draft defines a small terminal-multiplexer-style hosted profile for Loushang. It adds multiple named attach targets, multiple concurrently live Sessions, a long-lived AppHost profile, and a Harnesstui window shell. It diff --git a/docs/internals/architecture/drafts/lmux-managed-service-design.md b/docs/internals/architecture/drafts/lmux-managed-service-design.md new file mode 100644 index 000000000..8f8ca41bc --- /dev/null +++ b/docs/internals/architecture/drafts/lmux-managed-service-design.md @@ -0,0 +1,600 @@ +# lmux:命名多 Tab 与自动后台服务设计 + +[Drafts](README.md) · [AppHost](../apphost/README.md) · +[G16 Local Deployment](../appserver/detachable-local-workspace-g16.md) · +[Named Mux Proposal](harnesstui-named-mux-daemon-attach-design.md) · +[Machine-Local Storage](../harness/machine-local-runtime-storage.md) + +## Status + +- ID: `LMUX-DP-MANAGED-SERVICE` +- Scope: Coding entry / AppHost deployment / Hosting service mechanism / Harnesstui +- Authority: proposed target; does not override accepted sibling contracts +- Design status: proposed +- Review status: base and shared-client/Harnesstui reuse revision both passed + three-perspective re-review as M0 design input, 2026-09-13 +- Implementation status: partial; M0 values and M1 private-file owner only, no activation +- Owner: Loushang application architecture +- Delivery objective: active local Linux lmux baseline; implementation tracked by LMUX-M0 + +本文收口短入口、全局命名、工作区隔离、默认目录和 SSH 重连讨论。 +按架构方法区分 Facts、Target 与 Delta;并非新建一个顶层 `loushang.lmux` +子系统,也不宣称现有命令已经支持自动后台启动。最初设计阶段为 docs-only; +后续合同准入与代码切片见 [M0 交付审计](../apphost/lmux-contract-m0.md), +本草案不替代其中更具体的已接受合同,不代表已发布。 + +## 1. 问题、目标与非目标 + +用户应能创建一个具名、多会话的终端工作界面,断开 SSH 后从任意目录 +重新进入,而不需要记住连接目录、端点、应用 ID、会话根和后台 PID。 + +核心不变量: + +1. 名字定位 Mux,不定位工作区或服务进程;一个 Mux 包含多个 Tab。 +2. 连接入口全局可见,执行与配置按服务绑定的工作区隔离。 +3. 客户端断开不等于应用停止;已接纳执行由应用持有。 +4. 服务实例、应用恢复状态、会话数据、客户端草稿是四种不同寿命。 +5. 方便启动不得绕过权限、认证、独占写入、版本或清理 fence。 + +第一版以 Linux 本机用户为范围,连接只走现有认证本地通道;SSH 只是 +用户进入该机器的方式,不增加 SSH/网络代理协议。不是 shell/PTY 通用复用器, +不提供任意进程重启恢复、机器重启后自动运行、热升级、跨用户共享、跨工作区 +Tab、跨 Product Mux、读写多控制器或自动重放工具调用。macOS/Windows 的 +现有显式入口不受影响;自动后台 profile 必须各自验收后才开启。 + +本机 GUI 是公共发现/连接能力的另一个预期消费者,不是 lmux CLI 的调用者。 +本轮明确可接入合同和无 UI 客户端验证;完整 GUI 界面与远程 GUI 传输仍不在 +第一版交付范围,不把“可复用接口”写成“已有 GUI 接入”。 + +## 2. Current:已有事实与必须补齐的缺口 + +| 事实 | 代码或合同依据 | 对本设计的约束 | +| --- | --- | --- | +| 显式 `loushang-mux serve/list/create/attach/close/stop` | `src/loushang/coding/cli/mux.py` | 当前不发现、不自动启动服务;stop 回复是 requested,不是退出证明 | +| G16 应用拥有执行,客户端 EOF 不停止应用 | `src/loushang/apphost/local.py`、`src/loushang/appservice/client_scope.py` | 复用此生命周期,不使用 G14 EOF-terminal profile | +| G13 持久协调记录和应用独占 lease | `src/loushang/apphost/continuity.py` | Mux 成员真相继续由 AppService/G13 管理 | +| 多成员、控制器、切换与终端 shell 已存在 | `src/loushang/harnesstui/mux/` | Tab 是成员视图,不是额外进程 | +| Hosted screen 复用共享会话组件,但未注入 transcript theme | `src/loushang/harnesstui/mux/_shell_screen.py`、`src/loushang/tui/transcript.py` | 当前助手消息走纯文本回退,不能声称已与普通 TUI Markdown 等价 | +| 前台客户端拥有子进程 | `src/loushang/coding/cli/hosted_client.py` | 不可把 A0.5 改名为 daemon 并忽略 close | +| 纯路径根解析 | `src/loushang/foundation/platform_paths.py` | 一次解析后注入,不让叶子重新读 home/cwd/env | +| 普通 Coding 默认全局会话根、cwd 筛选 | `src/loushang/coding/cli/multiagent.py`、Machine-Local Storage | `.loushang/sessions` 是兼容发现,不重新作为默认写根 | +| G16 launch 要求 application/cwd/home 三个根互异且不嵌套 | `src/loushang/coding/hosted_bootstrap.py` | 不能把同一默认会话根直接填两遍;必须先设计 scope/catalog 适配 | +| Hosting 通用服务控制仍是候选,M0 已接受有限 Linux managed Target | [Hosted Application Support Boundary](../hosting/key-designs/hosted-application-support-boundary.md) | Linux 自动后台仍待实现与验收,不能把合同准入当作已实现 | + +手工 Linux 验证已观察到 ready、新会话、真实模型回复、detach/reattach 和 +显式 stop 后服务返回 Shell。它不是 SSH 异常断连、自动后台、机器重启或 +性能验收证据。另有 `request_pending` 状态残留和退出终端残留待专项修复。 + +## 3. 术语与身份 + +```text +本机用户 + LOUSHANG_HOME(一个命名空间) + Mux 名称索引:dev ───────────────┐ + v + 服务 A(coding、workspace A、profile) + Mux dev:Tab 1 → Session X;Tab 2 → Session Y + Mux review:Tab 1 → Session Z + 服务 B(coding、workspace B、profile) + Mux docs:Tab 1 → Session W +``` + +- Mux 名 `dev` 在该本机用户命名空间内唯一,离线时也保留,不使用 `dev:main`。 + 第一版名字为 1–64 字符 ASCII `[A-Za-z0-9][A-Za-z0-9_-]*`,区分大小写; + 名称不直接拼文件路径,存储键是摘要或生成 ID。 +- Mux ID 是 AppService 身份。全局索引只存名称到 service ID / Mux ID 的引用, + 不复制成员列表、会话正文或执行状态。 +- 默认服务复用键为规范化工作区绝对路径 + Product ID + deployment profile, + 在用户命名空间内唯一。不自动上溯 Git 根,不因 attach 的 cwd 改变工作区。 +- service ID 为机器本地稳定 ID,instance ID 为每次启动新随机值;重启不换 + 服务 ID,但必须换实例与认证材料。可选服务别名用于管理,不是日常 attach 目标。 +- 相同工作区的多个 Mux 共用服务。第一版拒绝同工作区/相同 profile 的额外 + 服务实例;可在不同工作区新建多个服务。独立配置实例以后显式扩展。 +- 同一 Mux 的所有 Session 必须匹配服务工作区与 Product。全局发现会话不 + 赋予跨工作区执行权限。第一版同一 Session 只允许一个活跃宿主写入。 +- Tab 的选中位置属于客户端;服务持久保存成员顺序,不保存全局选中 Tab。 + 一个 Mux 同时只允许一个控制器;已有控制器时第二次 attach 明确返回 busy, + 不抢占。断连释放旧代 authority 后才接纳新控制器。 + +## 4. 用户命令合同(Target,非当前命令帮助) + +| 命令 | 行为 | 是否启动服务 | +| --- | --- | --- | +| `lmux` | 有 Mux 时显示全局选择器;无 Mux 时在 cwd 创建默认 Mux `main` 并进入 | 仅显式选择新建或第一次无目标时 | +| `lmux new -s dev` | 全局保留名称,在 cwd 对应服务创建空 Mux 并 attach;Tab 显式 `/new cwd [标题]` 创建 | 必要时启动/复用 | +| `lmux new -s docs --workspace ~/docs` | 同上,显式工作区 | 必要时 | +| `lmux attach -t dev` | 任意目录连接该名字;离线时提示 start,不隐式重启 | 否 | +| `lmux ls` | 全局名称、工作区、服务、Tab 数、可验证状态;离线字段标记 cached/unknown | 否 | +| `lmux start -t dev` | 显式重启所属服务,恢复该服务的全部持久 Mux;说明影响范围 | 是 | +| `lmux close -t dev` | 确认后关闭该 Mux 与其活跃成员,保留 Session 历史 | 否 | +| `lmux server start --name build --workspace ~/build` | 高级:显式创建/复用服务,不强制创建 Mux | 是 | +| `lmux stop --server build` | 请求并等待这个服务退出;显示受影响全部 Mux | 否 | +| `lmux stop --all` | 当前机器/用户/LOUSHANG_HOME 的已登记服务,冻结目标列表后确认 | 否 | +| `lmux status [--server build]` / `lmux logs --server build` | 诊断、实际路径、版本、受限日志读取 | 否 | + +`attach` 无 `-t`:唯一在线 Mux 直接进入,否则交互选择。所有进入终端的命令 +(裸 `lmux`、`new`、有/无目标的 `attach`)必须在任何写入/占名/spawn/连接 +IO 前检查 stdin/stdout TTY;非 TTY 直接拒绝,不会因为有 `-t` 就绕过。 +本版不提供 detached new;`start`/`server start`/`status`/`ls` 可脚本调用。 +`new` 同名报冲突,不静默 attach/覆盖。服务别名重名 +也拒绝;`stop --server` 可接受 status 输出的精确 service ID。 + +无目标 `stop` 报需要目标,不根据 cwd 猜测,更不全停。`close`/`stop` 交互 +确认,非交互必须 `--yes`。`--yes` 不允许绕过 busy/fence 或强制杀进程。 +第一版不提供隐式 force/kill。离线 `close` 不假装已执行 AppService close: +提示先 start 后 close;历史名称不会因超时或宕机自动释放。 + +用户使用 `lmux`;保留 `loushang-mux` 原有显式语法和参数兼容,新旧入口共用 +内部 client/bootstrap,而非两套 runtime。旧入口手工部署不自动登记为受管服务, +其服务内名称不冒充全局 lmux 名;导入/接管不在第一版范围。 + +空 Mux 显示服务绑定的工作区及可直接执行的 `/new cwd [标题]`、 +`/new user_home [标题]` 和 `/resume`,不把裸 `/new` 当现有有效语法。 +新 scope adapter 下两者仍共用 Session 权威存储;区别是显式选择/发现语义, +不改变服务工作区。后台入口不得等待 stdin 配置模型/凭证或弹信任确认。 +服务级前提缺失则启动失败并给安全诊断;仅会话级前提缺失则保留空 Mux, +首次 Tab 创建明确报错,提示通过现有配置入口完成准备后显式重试。 + +## 5. 默认目录与配置边界 + +采用用户提出的管理文件集中方案,根为 `$LOUSHANG_HOME/lmux`,默认 +`~/.loushang/lmux`,不是项目 `.loushang/lmux`。机器本地子键 `` +区分共享 home 中不同机器;从安全本地身份解析器注入,不用 hostname 充当 +进程权限。共享文件系统上的可靠锁与身份不满足时拒绝自动启动,不降级无锁。 + +```text +~/.loushang/lmux/ + machines// + registry/ # 有界服务描述、名称引用与未完成操作意图 + lifecycle/ # 每服务稳定 fence/lock,不在 release 时 unlink + servers// + descriptor.json # 版本、工作区、profile;不含密钥/完整环境 + state/application/ # G13 应用协调与 lease + state/control/ # 机制操作意图、最后观察;不证明当前存活 + logs/ # 有界日志与显式 trace + tmp// # 可丢弃且有 owner lease 的暂存 + cache/ # 需要时才创建,不复制现有插件缓存 + +/lmux/// + connection/ # 现有 AppServer 私有连接记录与认证材料 + control/ # 唯一当前实例机制 authority 与启动协调 + +<现有 Session 权威根>/ # 历史、持久附件;不迁移到 lmux +``` + +namespace key 绑定用户身份和规范化 LOUSHANG_HOME,避免同一 runtime 下 +两个隔离 home 冲突。临时目录显式设置 `LOUSHANG_TMPDIR` 时优先落到该根下 +`lmux///`;否则使用上面的集中 tmp。 +`LOUSHANG_RUNTIME_DIR` 仍有效。路径覆盖冲突、不同服务重用同一控制目录、 +连接目录与持久根交叠、符号链接替换或不安全所有者必须在启动前拒绝。 + +Hosting 机制 owner 同时维护两种不同记录:durable control 仅存操作意图、 +已完成交接的历史和最后观察,runtime control 是当前实例机制 authority。 +AppServer 独立拥有 connection 认证记录,不把它复制进 durable control。 +runtime 丢失时进入 unknown,历史 PID/epoch 或 durable 副本不能证明进程 +死亡、授权重建凭据或另起实例;必须由精确身份探测/显式维护完成协调。 + +跨启动互斥使用 AppHost 部署协调器拥有的持久 `lifecycle/` fence, +其锁文件身份稳定,不因 release、runtime 丢失或换根而 unlink/recreate。 +runtime 保存当前可达性/机制证据,持久 fence 决定是否允许新代启动,二者 +不互相冒充。换 runtime 覆盖值仍须取得同一持久 fence;若旧实例可能存活 +但不能认证,报告 `instance_unreachable`,保留名称和状态,拒绝自动启动。 +这不是可由 age/PID 判断绕过的锁。精确维护能力未提供时,只报告人工处理, +不暗中 kill 或删除;不同 runtime 根不能成为重复启动同一服务的后门。 + +这是相对现有 Machine-Local Storage 的**局部目标变更**:lmux 管理文件集中, +内部仍按 lifetime 分层;不修改 Foundation 全局 data/state/cache/tmp 缺省。 +实施前需要在该决策及 Hosting consumer storage 合同显式接纳此布局;本文 +不把 `$LOUSHANG_HOME/lmux` 谎称为已被接受的 `PlatformPaths.state`。 + +Session 遵循现有可信配置覆盖与默认 `$LOUSHANG_HOME/data/sessions`:cwd 和 +user-home 是发现/选择 scope,不是两份默认写库。旧项目会话目录只做兼容发现; +恢复与迁移继续由 Product owner 执行。新 scope/catalog adapter 必须保留规范 +Session 身份、过滤、准入与独占写入;不可简单删除 G16 三根校验绕过边界。 +同一全局会话被多个工作区服务或 embedded CLI 请求时,跨进程独占写入是 +启用共享默认存储的硬门禁,未证明支持时拒绝第二个打开者。 + +第一版目标包含同工作区既有普通 Coding 会话,但发现不等于可直接 hosted +resume:当前 `coding.hosted_catalog` 还要求 `coding.hosted` 元数据与 scope +fingerprint。[M0 合同](../apphost/lmux-contract-m0.md) 选择 Product-owned +非破坏性 view adapter:发现只读投影,实际打开先取得跨进程写入权并重验, +保留规范 Session ID/正文/附件引用;普通历史无需补写 hosted header,已有 +v1 hosted 身份通过原校验后保留。它替代此前“转换/补齐元数据”的实施手段, +不改变支持普通历史的目标,不修改工作区来迎合服务。未通过适配或不兼容的候选在 picker 明确禁用并 +说明原因,不能删除校验强行打开。该适配未交付前不得宣称普通历史已可恢复。 + +配置仍由 Product 使用现有全局/项目可信规则解析;lmux 不再新建配置文件体系。 +服务描述只记录获准绑定事实,不允许 registry 任意路径/argv/env 直接变成 +执行授权。启动使用当前可信安装入口;安装版本变化与当前服务不兼容则拒绝 +复用并要求显式停服升级。attach 不把客户端当前目录配置灌进旧服务。 +服务启动环境是快照;新会话的配置刷新只走既有 Product 规则,不宣称自动热载入。 + +私有目录 `0700`、文件 `0600`,Windows 用等价受限 ACL 后验收。 +已提交图片使用既有 Session 附件策略;客户端尚未发送的草稿/粘贴图不是 +持久会话,SSH 断线可能丢失。当前 Hosted shell 图片粘贴未支持,不能随本设计 +宣称已支持;服务不会为了保存图片接管客户端临时目录。 + +### 日志、trace 与清理 + +默认只记录脱敏生命周期/错误码/实例身份,不记录提示词、回复、工具正文、 +token 或原始环境。必须区分结构化安全日志与第三方原始 stderr:原始输出 +默认不能直接落盘;边界做限长和脱敏,不能保证安全的正文丢弃并记录计数。 +详细 trace 显式开启、有期限;不因此允许记录认证材料。 + +建议首版可配置上限:普通日志每服务合计 50 MiB(单份 10 MiB,含当前共 5 份), +trace 每服务 20 MiB,全部受管服务日志总计 200 MiB;跨重启仍共享额度。 +暂存每实例 128 MiB、命名空间总计 512 MiB;超额拒绝新暂存,不删活跃文件。 +满盘时不得把“日志写入成功”作为执行/清理前提;启动必要状态写入失败必须 +失败关闭,现有服务维持可达的停止路径。日志失败显示有界警告,禁止递归刷日志。 + +清理只由对应存储 owner 执行,必须检查 lease、实例、所有者和文件身份, +锁住后隔离再删除,不跟随符号链接;只凭时间、PID 或目录名字不能删除。 +未知/旧格式残留只报告,不自动回收。永远不把 Session 根纳入 lmux gc。 +活跃日志通过写入 owner 轮转/限流,汇总清理不得直接截断其他实例的打开文件。 + +全局 200 MiB 包含普通日志与 trace。命名空间额度协调由 AppHost consumer +侧存储管理组件负责(不由 Hosting/日志叶子自行推导),锁下写入前预留有界 +额度,实际落盘后结算,跨进程并发不能各自读余额后独立写满;可按小块预留 +减少争用,但未使用预留也计入总额。崩溃重启对账只回收确认为失效实例的 +预留,不把未知实例配额释放给新进程。无需新增常驻 broker。 +初始 registry 上限拟为 128 个服务、4096 个 Mux 名/意图、单记录 16 KiB, +含事务 journal 总计 80 MiB;已停止服务和 pending 也计入。满额拒绝新建, +停止与完成既有意图使用预留控制空间,不能靠删除未知 pending 腾出名字。 + +## 6. 核心组件与边界 + +| 组件 | 所属 | 拥有 | 不拥有 | +| --- | --- | --- | --- | +| Product entry/composition | Coding | CLI 语法、路径策略、可信安装/Product/workspace 绑定 | 通用进程控制实现、会话真相 | +| Managed deployment coordinator | AppHost 可选部署边 | 用户级服务发现、名称路由、协调意图、启动/复用编排 | Session 执行实现、终端渲染、OS 细节 | +| Service instance mechanism | Hosting 新的受限候选组件 | 脱离终端、精确实例证据、进程启动/退出观察、机制 lease | Mux 名称/列表、认证协议、日志保留、Product 配置 | +| Local connection | AppServer | 本地认证、record、协议、连接与背压 | 创建后台进程、发现全局工作区 | +| Application owner | AppHost/G13/G16 | 应用创建/恢复、admission、依赖顺序关闭和 lease-last | UI 生命周期、Session 内容复制 | +| Mux/Session coordination | AppService | Mux 成员、控制器、已接纳执行的生命周期 | OS 后台化、用户 home 推导 | +| Session/storage/policy | Product/Harness | 会话、资源、权限、工具和持久附件 | 用户级服务目录索引 | +| Client shell | Harnesstui/TUI | Tab、输入、选择器、状态、终端进入与恢复 | 服务 stop、全局 registry 写入 | + +CLI 将管理操作交给 AppHost 可选编排边;后者消费 Hosting 机制,调用 AppServer +client,或组合 AppService。Hosting 不导入 AppHost/AppServer/AppService; +AppServer 不导入 Hosting。全局 registry 不需要一个额外常驻 broker。 +UI 内创建/关闭 Mux 若影响全局名字,必须走同一管理编排入口;不能留下绕过 +全局保留的写路径。普通 Tab 创建仍走 AppService,不触碰全局名字。 + +此限制必须在**受管服务端**强制,而不是仅靠新 CLI 约定。managed profile +的 create/close Mux 只接受与名称保留和 operation ID 绑定的管理 authority, +现有不携带该 authority 的 legacy mutation 返回明确的 profile/upgrade 错误。 +即使用户用旧 `loushang-mux` 显式指定受管 connection root,也不能绕开索引。 +原有手工 G16 profile 不激活此限制,兼容语义保持不变。 + +### 6.1 公共发现与连接协调:终端和本机 GUI 共用 + +公共合同由 AppHost 的可选部署边提供,AppServer 继续拥有认证连接与协议; +不建 `lmux` 专属后端 API,不让 GUI 导入 `coding.cli` 或 Harnesstui,不通过 +执行 lmux 命令和解析 stdout 连接服务。具体 GUI 产品组合入口注入可信 +Product/工作区/路径策略,GUI 组件只消费通用端口。Hosting 不因此依赖 GUI。 + +公共操作面(逻辑职责,M0 冻结版本化类型与错误,不是已存在的 Python API): + +| 操作面 | 输入/结果 | 副作用与权限 | +| --- | --- | --- | +| Discover / Resolve | 不可变命名空间绑定、可选 Mux 名;返回有界摘要和 opaque target reference | 只读;不启动、不回收、不把 PID/缓存状态当 authority | +| EnsureStarted / Reconcile | 显式启动意图、可信 deployment binding / operation reference | 唯一协调器执行;GUI 打开选择器不等于授权启动 | +| PrepareConnection | opaque target reference;返回受限且实例绑定的连接 lease | 重新认证/校验,stale reference 不自动 retarget;无终端依赖 | +| ManagedCreate / ManagedClose / Stop | 精确目标、确认后的操作意图、operation ID | 走同一服务端管理 admission 与生命周期 fence,无 GUI 特权路径 | + +只读摘要可展示名称、工作区、Product、profile、兼容版本与有时效的状态, +不得暴露原始认证密钥、内部记录路径或可执行 argv。受限连接 lease 内部保留 +必要凭据,展示层不接触;引用仅是定位信息,不能扩大权限或绕过重新准入。 +取消发现或连接只清理本次读/连接 owner,不停止已存在或已交接的服务; +创建或启动丢回复仍返回可对账 operation,不由 GUI 自动重试 mutation。 + +TTY 检查属于 lmux 的交互入口,不属于公共服务 API;无终端的 GUI 可正常 +调用这些端口。GUI 不直接读写 registry/descriptor,也不复制目录算法。 +`~/.loushang/lmux/` 是此受管 deployment profile 的存储布局,不代表 TUI +所有权;TUI 与 GUI 通过注入的同一 namespace binding 找到同一服务,不因 +GUI 接入搬迁目录、创建另一套索引或另起 Session。该路径不强制成为所有 +未来 Hosted profile 的全局缺省。 + +一个 Mux 仍只有一个控制器:终端持有时 GUI attach 返回 busy;终端 detach +完成、旧代 authority 撤销后 GUI 可取得新代,反向同理。不同 Mux 可分别 +连接。只接管成员和会话状态,不承诺转移客户端未发送草稿、选中 Tab、 +旧审批 token 或未确认请求。GUI 关闭/断连也不隐式 stop。 +浏览器 GUI、远端桌面 GUI 与 SSH 隧道需要独立传输/认证边界,本文不扩展。 + +### 6.2 Mux 的会话渲染:复用,但不混淆 Current 与 Target + +Current 调用链:`HostedMuxScreenV1` 继承 `ScreenConversationApp`, +`project_active_conversation` 把 ASSISTANT 记录/流式草稿投影到共享会话状态, +最终交给 TUI transcript 组件。渲染不在后台服务执行,协议不传终端 ANSI +作为会话真相;控制字符净化作用于客户端显示副本。 + +但当前 Hosted screen 构造没有传 `transcript_theme`,共享默认值为 None; +`src/loushang/tui/transcript.py` 中助手记录仅在 theme 非空时调用 Markdown +渲染,否则是带 `* ` 前缀的纯文本。因此“复用组件”不等于当前已经启用完整 +Markdown。底层 Markdown renderer 已支持 CommonMark 与 table/strikethrough, +应复用其现有能力,不新建 Mux Markdown 解析器。 + +Target:M3 在客户端组合边注入共享主题和终端能力,接通已有 Markdown +渲染与流式路径,保留标题、列表、强调、引用、代码块和表格等共享能力; +颜色/代码高亮按实际终端能力降级,不将不支持颜色等同于丢掉 Markdown +结构。不同 Tab、重连 snapshot、未闭合代码围栏和窗口缩放需防止串缓存、 +错位或重复输出。GUI 独立渲染相同语义内容,不复用终端组件或接收终端样式。 + +能力边界:当前 Hosted transcript 协议的记录种类只有 USER/ASSISTANT/ +STATUS/ERROR,不能凭共享组件宣称已支持工具富卡片/diff 等完整结构化展示; +现有 Hosted 图片粘贴也明确 unavailable。工具结构化投影、图片、全部普通 +Coding 命令与 UI 扩展的等价性需要另列协议/交互增量,不搭本轮自动后台 +顺带承诺。本轮只增加 M3 Markdown 接线与不回退要求,不修改渲染代码。 + +### 6.3 复用单元:完整 Harnesstui 会话视图,不只是 TUI 控件 + +Target 明确为:**一个共享会话视图,两种会话接入方式,一个可选 Mux 外壳。** +“完整视图”指共享的展示与交互实现,不表示 Hosted 已有 Embedded 的全部 +数据/操作能力。不能仅因继承同一个 Screen 类就宣告功能等价。 + +```text +Embedded 产品外壳 Hosted Mux 外壳 + 单会话入口/产品退出 Tab/切换/新建关闭成员/attach/detach + \ / + Harnesstui 共享会话视图 + 消息/Markdown/输入/操作状态/能力可用时的工具与审批展示 + | + TUI 布局、主题、控件、终端渲染 + +会话视图需要的中性端口 ← Embedded binding ← 本进程 Product +会话视图需要的中性端口 ← Hosted binding ← AppClient / AppServer +``` + +该图不是把本地 Product 经远程协议传回 UI,也不新增顶层包。共享单元位于 +`harnesstui.conversation`,Mux 外壳位于 `harnesstui.mux`;在现有 +`ScreenConversationApp`、projection、action presentation 上渐进提炼组合 +端口,不复制整套 Hosted composer/transcript/approval 逻辑,也不大改 Embedded。 +现有 `conversation.agent_binding` 是可选的本地 Agent 适配,并非 Hosted 可直接 +复用的纯中性实现;Hosted 不为获得某个组件而引入 live Agent/Product 对象。 + +| 层 | 唯一职责 | 明确排除 | +| --- | --- | --- | +| 共享会话视图与交互 presenter | 记录/流式草稿/输入/历史视窗/详情/错误与 pending 显示;能力驱动的操作入口 | 服务发现、RPC 重试、Session 创建、权限决策、进程或应用关闭 | +| Embedded binding | 本地事件转为中性视图输入,用户 intent 映射到 Product 的受限操作端口 | 在共享视图注入完整 Session/工具执行器;改变原有退出/清理合同 | +| Hosted binding | snapshot/event/cursor 投影、动作请求和结果协调、连接/controller 代际隔离 | 通过本地 Product 补远端能力;合成不存在的工具事件;丢回复自动重发 | +| Mux 外壳 | 名称/成员切换、窗口级绑定选择、新建/恢复/关闭成员、连接提示与 detach | 另一套消息 renderer;持有服务关闭权作为 view.dispose 的副作用 | +| 客户端组合/终端 owner | 主题/终端能力/键位/操作与生命周期端口注入;一个终端循环 | 每 Tab 创建终端会话/事件循环/后台服务;共享视图自行探测 cwd/home | + +M0 须冻结的中性接缝(先复用现有结构,只有现有端口无法表达时才扩展): + +- **视图输入**:Session/member 身份、绑定 generation、不可变记录窗口、 + snapshot/cursor、流式草稿及权威运行状态;UI 不把渲染速度当作操作完成。 + 它们是客户端 view model,不直接成为 wire schema;Hosted adapter 转换 + 已校验的 AppServer 值,Embedded adapter 转换本地投影。 +- **操作端口**:提交、steer、follow-up、interrupt、审批等有类型 intent 与结果。 + 区分 submitting、accepted、running、completed、failed、unknown;不能套用 + 本地“await 返回表示 turn 完成”的假设。执行状态与请求投递状态分别显示, + `idle` 不足以把未知请求标为成功;terminal outcome 到达时按 operation/ + generation 清除对应 pending,不覆盖更新操作的状态。客户端 action owner + 必须保留既有总额上限与 interrupt/清理等控制操作的预留,不因多 Tab + 共用 presenter 放大每 Tab 独立队列到无界,也不能让普通提交饿死控制操作。 +- **能力描述**:只读且随绑定代际失效的支持矩阵,区分可用、不可用、只读及 + 原因;协议未提供的数据不能靠 UI 猜测。界面可用性不是授权,服务端仍须 + 对每次请求检查 scope/controller/policy;旧能力快照不赋予旧 token 新权限。 +- **呈现与交互 binding**:主题、文案、命令/补全来源、只读工具详情及审批 + 呈现回执。Product 提供受限展示数据,不让共享视图调用本地工具或加载任意 + 服务端可执行插件。Hosted 未协商的 Product 命令/图片入口明确禁用。 +- **视图生命周期**:view.dispose 仅释放视图缓存、草稿、订阅/显示资源; + detach/close-member/stop-app 是外壳不同的显式 intent,不能统一成一个 + 模糊 close 回调。Embedded 产品退出仍由原 Product owner 结算;G16 detach + 不结算应用;现有 G17 前台 Hosted 的退出结束应用合同仍独立保留。 + +Tab 状态按 `(attachment generation, member ID, Session ID)` 隔离,保存 +编辑草稿、历史锚点与有界渲染缓存;一个共享 renderer/view 可以重绑定当前 +Tab,无需每 Tab 克隆整套 Screen/终端循环。切换前保存本地视图状态,切换后 +恢复目标状态,晚到消息只更新匹配的后台成员,不能写到新 Tab 或复活已关闭 +成员。重连以新 attachment 为边界,不自动搬移旧审批回执或未确认操作;草稿 +迁移不在第一版保证中。缓存与订阅必须有总界限,关闭 Tab 完成后释放其资源。 + +输入路由只能有一个 owner:详情/选择器/补全等当前上下文优先,未消费的 +Tab/Shift+Tab 才由 Mux 切换成员;Ctrl+B 前缀由外壳处理,普通文本与会话 +intent 委派共享交互层。审批展示回执绑定 attachment ID、controller generation、 +member ID、Session ID、interaction ID 与实际呈现内容,不能因另一个 Tab +曾打开详情就允许当前审批。输入焦点、 +剪贴板暂存与终端恢复仍沿用各自 owner,不因视图复用扩大授权。 + +### 6.4 能力与交付范围,不把架构复用等同全功能迁移 + +| 能力 | 共享复用目标 | 本轮后续实施边界 | +| --- | --- | --- | +| 消息/Markdown/输入/流式显示/历史窗口 | 复用同一 Harnesstui 会话视图及 TUI renderer | M3 接线与回归,保留 Embedded 的既有行为 | +| Tab 切换/成员管理/连接与 detach | Mux 外壳组合视图 | 不进入单会话核心,不产生每 Tab 服务 | +| 运行状态/请求状态/interrupt/steer/follow-up | 共享呈现与 intent,binding 解释执行语义 | M3 覆盖已支持的 Hosted 操作,不修改旧协议含义 | +| 审批 | 能复用的详情/呈现组件统一;授权由各 binding/服务端处理 | 必须保留 G16 已呈现检查与旧 generation 撤销,不能为复用而削弱 | +| 工具富卡片/diff/用量/扩展详情 | 有中性数据时复用现有 Harnesstui presenter | 未覆盖 wire 字段的能力继续禁用;另立版本化协议增量 | +| 图片粘贴/全部 Product 命令与补全 | 用能力 binding 接入,不复制产品工作流 | Hosted 未支持项不随此设计自动启用;Embedded 不受其限制 | +| GUI | 公共发现/连接/应用语义可复用 | GUI 不依赖 Harnesstui 的终端视图,保持自己的呈现层 | + +§6.3 的逻辑复用目标不要求 M3 同时迁移全部 Product 功能;先接通已有 +Hosted 语义与共享 presentation/action 接缝,并逐项证明非回退。任何需要新 +wire 语义、持久草稿或新授权合同的能力列入独立后续增量,不借“完整视图” +名义绕过既有 M0/协议评审门禁。 + +## 7. 启动、命名事务与恢复 + +启动状态:`absent → starting → ready → stopping → stopped`;失败可进入 +`failed` 或 `cleanup_pending`。`starting`/`cleanup_pending` 不是可 attach 的 ready。 + +1. 只解析 CLI 必要模块,解析并准入不可变 deployment spec;检查私有根、 + 用户/机器身份、Product、工作区、安装/协议版本与权限。 +2. 全局名称锁下写入有界 pending 意图(operation ID、name、service ID), + 释放锁。名称保留必须排斥跨工作区同名创建。 +3. start/stop/reconcile 共用 service key 的持久 lifecycle fence 与 epoch CAS, + 不只是 start 单飞。状态更新在短锁下完成,长等待释放物理锁但保留操作 + 所有权/代际意图;锁顺序固定,禁止持有全局 registry 锁等待 ready/RPC/退出。 + stop 若遇到 starting,为该 attempt/epoch 登记停止意图,禁止其随后发布 + ready 或提交 create;服务与启动器均检查该 fence。竞争新 start 返回 + stopping/busy,不在同次调用中悄悄排队启动下一代。 +4. 先认证探测已有实例,再判断是否启动。活跃 lease、认证失败、版本不兼容、 + 状态未知都不是“旧垃圾”;禁止覆盖并另起进程。 +5. Hosting 机制启动完整可信服务入口,脱离控制终端/SSH 进程组,stdin 关闭, + 输出接有界诊断 owner;不得遗留连接 SSH 管道的描述符。 +6. service 自持有服务寿命的机制 owner;启动器仅持有此次启动尝试。 + 用实例绑定的就绪握手完成交接,不能“泄漏一个前台 ProcessLease”来续命。 + **唯一提交点**是子端在持久 lifecycle fence 下,将匹配 attempt/instance + 的交接从 provisional CAS 为 committed(且无 stop 意图),提交事实必须 + 可认证对账;其后才回父端 ack。子端提交前 EOF 自行收口,提交后 EOF + 不改变服务寿命。父端未收到 ack/超时只代表 unknown,查询该事实,不得 + 以本地未见 ack 推断未提交或直接 kill。M0 冻结序列化/耐久握手细节。 +7. ready 必须意味着认证通过、G13 恢复完成且 G16 accepting。随后按 operation + ID 创建 Mux,确认真实 Mux ID 后 CAS 提交索引;完成后才 attach。最终 + 返回成功前在同一生命周期序列复核 epoch/accepting/stop fence。若 stop + 先线性化则本次启动/创建不得报成功;若 create 先提交,随后 stop 必须 + 将它纳入收口,不留下“没有被停止的新 Mux”。 +8. RPC 丢回复时保留 pending/unknown,以认证查询对账,不盲目重发 create、 + close、start_turn。已有协议未提供足够对账信息时,新增版本化管理合同, + 不凭一个碰巧同名的 Mux 判断本次操作成功。 + +全局索引是路由引用,AppService 是成员真相。保留/提交/关闭采用可对账意图, +不得以两个不相关文件更新假装原子事务。索引写盘失败不得默默丢弃已创建 Mux; +启动恢复 pending 并只在证明确认未创建后释放名称。关闭只有 AppService +settlement 成功后才释放全局名称,旧 epoch 的响应不得覆盖新实例记录。 +停服不释放名字;重启从原 G13 记录恢复所有 Mux,不重新生成一套身份。 + +每次启动共享一个单调 deadline(拟议默认 30 秒,允许显式配置),排队、 +spawn、恢复、ready 均计入;每个步骤不得重新给满额。超时仅清理此次确切 +拥有的未交接实例,不能终止已复用或已交接服务。清理另有共享的有界预算, +超时保留可诊断 owner/证据和 busy fence,不宣称成功且不释放名称重新开跑。 +启动回滚的强制回收仅限持有精确 OS 实例权且 fence 下成功将 provisional +改为 aborting 的本次实例;CAS 失败/已 committed/状态未知均不得回收。 +无可靠 OS 身份终止能力时保留债务,不退化成按 PID 发信号。 + +## 8. 断连、停止与恢复保证 + +- SSH EOF/断线只关闭该 AppClient scope;已接纳 turn 继续由应用持有。 + 未确认接纳的请求显示 unknown,不自动重试。工具是否跨断连存活仍须受 + Product 生命周期保证约束,不承诺任意外部进程永久运行。 +- 待审批交互遵循现有 scope/controller 撤销策略,不自动授权,也不把旧审批 + token 转交新控制器;可以取消当前交互/相关执行,这不等于停止整个服务。 +- reattach 验证实例、profile、Mux/controller generation 后重新取得 snapshot + 与 cursor;Tab 列表和历史恢复不重放工具效果。 +- SSH 断开续存的前提是 Linux 用户进程仍获系统保留。systemd 注销清理、 + runtime 目录回收、管理员终止、机器重启均可能使其不可用;不暗中启用 + linger、不修改用户服务配置。需要这些保证时另行设计 supervisor profile。 +- `stop` 先 fence 新准入,再请求 G16 应用关闭,按依赖顺序收口,最后释放 + lease。首版用户 stop 为有界 graceful-only,超时不自动升级 terminate/kill; + 这是对 Hosting 候选默认 escalation 的显式 profile 裁剪,须 M0 单独接纳。 + 启动失败且确切未交接实例的 rollback 回收与用户 stop 是不同 authority。 + 客户端区分 stop_requested、stopping、stopped、cleanup_pending、exited_unclean。 + `process_exited` 和 `application_cleanup_completed` 是两个独立事实; + 必须同时具有精确实例退出证据与匹配实例的可信完整清理结算证明,才能打印 + stopped/返回 0。异常或强制退出、缺少结算证明,只能报告 exited_unclean/ + unknown 并保留诊断;PID/端口消失不足以授权杀进程或宣称清理成功。 + [M0](../apphost/lmux-contract-m0.md) 进一步把 native scope/后代/句柄结算 + 单独列为第三项必要证据:精确 leader 退出仍不等于整个 scope 已回收。 +- `stop --all` 仅作用确认时的实例快照,不追杀之后启动的新实例;逐个报告 + 结果,任何失败给非零总退出状态。默认没有空闲自动停服;最后一个 Mux + 关闭后服务可以保持 ready,避免意外改变后台生命周期。 +- 服务死亡后的 start 是当前安装重新准入的持久会话恢复,不是进程复活。 + 历史配置不提供启动任意旧 executable 的授权;不兼容状态应拒绝并给出诊断。 + +## 9. 性能假设与验收场景 + +常驻服务可能省进程启动、模块导入和公共初始化,但每次新会话仍须权限/注册/ +存储/上下文准备。这里不增加共享可变 Session、不删除安全扫描、不冻结新性能 +百分比。复用既有 Linux 采集器分别采集冷启、新 Tab、暖 attach、SSH 重连、 +长历史同步及首次命令/工具/补全;记录均值、分位数、样本数与首屏/ready 区别。 +自动后台不是先显示首屏的替代品;早屏可编辑属于后续独立优化,不自动发送草稿。 + +实施验收矩阵: + +| ID | 场景 | 必须证明 | +| --- | --- | --- | +| V1 | 并发两次 new 同名/不同名同工作区/不同工作区、旧入口直连受管端点 | 全局唯一、单服务单飞、无孤儿 Mux;legacy 名称 mutation 不能绕过管理 admission | +| V2 | reserve/spawn/ready/create/commit 各阶段崩溃或丢回复;交接 commit 后 ack 丢失 | 对账可收敛,不重复启动/创建/执行;unknown 不误杀 committed 实例 | +| V3 | SSH-like PTY 关闭与启动父进程死亡前/后交接,每个握手消息丢失或延迟 | 后台有精确 owner,已接纳任务续存,重连不重复执行 | +| V4 | 多 Tab、新控制器、旧 generation、pending approval | 成员正确、第二控制器 busy、旧授权不复活 | +| V5 | close/stop/stop all;stop 在 spawn 前、ready 后交接前、create/commit 中途 | 范围准确;无 ready-after-stop;退出不冒充清理;历史保留 | +| V6 | 两个 LOUSHANG_HOME、共享 home 两机器、runtime 活跃时删除/换根、不安全目录/符号链接 | 稳定持久 fence;不重复启动;命名隔离;无凭据泄露与越界删除 | +| V7 | cwd/user-home 同库、embedded/hosted 同 Session 竞争 | 规范身份与过滤、唯一写入者、旧根兼容、无静默迁移 | +| V8 | 满盘、并发日志/tmp 预留、重启保留用量、registry 满额、cleanup 超时、未知旧记录 | 全局有界、保持停止能力、活跃内容不被 gc | +| V9 | 真实安装、版本不兼容、不同 cwd attach、工作区已移动 | 重连不换工作区、不执行旧路径/配置、不隐式升级 | +| V10 | 暖新会话/连接性能、长历史、首次使用、终端退出 | 配对证据;无残留 pending/终端覆盖;不转移首次使用成本 | +| V11 | 无 TTY 的 lmux/new/attach、干净 home、无配置/凭证、首次空 Mux/Tab | 无 TTY 零副作用;后台不等待 stdin;空态命令可执行,失败明确 | +| V12 | 旧显式入口、旧客户端访问 managed profile、真实 wheel 双入口 | 旧帮助/参数/确认/JSON 与不自动启动语义兼容;managed mutation 不绕过 registry | +| V13 | 终端与无 UI 公共客户端交替 attach,同 Mux 并发 attach、stale reference、取消连接 | 无 coding.cli/Harnesstui/TTY 依赖;同 registry/服务;单控制器 busy;断连不 stop,不继承旧审批 | +| V14 | Mux Markdown 稳定消息/流式围栏/表格/窄屏/Tab 切换/重连 | 复用共享 renderer;主题与终端能力正确注入;无纯文本误回退、串缓存或重复输出 | +| V15 | 同一会话视图分别接 Embedded/Hosted binding,命令与能力缺失 | 同一 fixture 的呈现/intent 等价;Hosted 不持有本地 Product、不偷偷回退执行;Embedded 不被能力裁剪 | +| V16 | Tab 切换时流式/审批/操作回复晚到、补全占用 Tab、关闭/断连/前台退出 | generation 隔离;pending 按操作清理;审批不能串用;view.dispose 不等于 stop;缓存/订阅释放 | + +现有证据只复用其已证明的范围: + +| 现有测试 | 可复用 | 新增缺口 | +| --- | --- | --- | +| `tests/coding/test_mux_command.py` | 显式参数、TTY 前置拒绝、错误输出 | lmux 新语法及零副作用非 TTY 检查 | +| `tests/coding/test_mux_subprocess.py` | 显式服务 stdin EOF 后继续运行 | 真实进程组脱离、交接、父死与 SSH-like PTY 关闭 | +| `tests/coding/test_mux_terminal_process.py` | 真实 attach、建成员、detach | 全局跨 cwd 名称、自动后台、首次空态与终端残留 | +| `tests/coding/test_mux_installed_evidence.py` | 原有安装入口证据约束 | 真实隔离 wheel 同时安装/运行 lmux 与 loushang-mux | + +source/editable 环境成功不能替代 installed wheel 验收;不以旧 EOF 测试 +冒充 SSH 生命周期隔离或后台交接验证。 + +V13 使用无 UI 的独立客户端消费者验证公共合同,不要求本阶段实现 GUI; +完整 GUI 的“终端 detach → GUI attach → GUI detach → 终端重连”留给 GUI +接入时验收。V14 验证现有 Markdown 能力的接线,不作为完整工具/图片 UI +等价性的证据;静态架构测试需禁止公共发现/连接模块依赖终端/产品 CLI。 + +V15/V16 基于现有 Harnesstui projection/action、Hosted shell 与 fake-terminal +测试扩展:共享输入数据驱动同一 presenter,分别注入本地/远端适配器。 +同时保留 G16 客户端断开继续与 G17 前台退出结束的相反生命周期回归; +不以把两者改成相同退出行为换取“统一实现”。缺失协议能力的测试应断言 +明确不可用,而不是编造一份远端数据证明完整功能等价。 + +V3 必须使用真实 Linux 进程/PTY 隔离测试,测试清理只终止自身创建并验证身份的 +进程组,不能全局 pkill。真实 SSH 可补手工证据,但不作为默认联网测试。 +测试从首次就在 sandbox 外运行,保留 not-live 等选择器,限额 scratch 并清理。 + +## 10. Delta 与分步实施门禁 + +| 阶段 | 交付 | 进入下一阶段的条件 | +| --- | --- | --- | +| M0 合同 | 接纳局部存储变更;Hosting service context/requirements/组件发现;Session scope/跨进程写入适配设计;公共发现/连接、管理对账、Harnesstui 双 binding 接缝与能力合同 | 三视角设计无阻塞项;不得只删旧校验 | +| M1 发现与路径 | `lmux` 短入口、UI 无关公共端口、私有布局、用户级索引、全局唯一名称、只读 status/ls、兼容旧入口 | V1/V2/V6 的静态与确定性测试;V13 接口/依赖边界 | +| M2 Linux 后台 | service mechanism、单飞启动、交接、ready/stop、失败证据与诊断限额 | V2/V3/V5/V8,无 PID-only 杀进程 | +| M3 会话与交互 | scope 适配和独占写入、全局 new/attach/close、共享 Harnesstui 会话视图双 binding、Mux 外壳、Markdown 接线、终端问题收口 | V4/V7/V9/V11–V16,全局名字写路径受控;Embedded/Hosted 各自非回退 | +| M4 交付 | 安装环境测试、SSH 手工脚本、性能采集、实现三视角复审 | Linux 全矩阵通过;macOS/Windows 自动后台仍显式未验收 | + +M1–M3 中间能力保持 explicit/default-dark,不用尚未通过 V7 的默认共享库启动 +真实会话。实施前按高风险工作流建立追踪目标和独立任务分支、测基线;本轮仅 +文档设计,不创建远端 issue、不提交/推送,不把设计评审当运行验证。 + +## 11. 设计评审记录 + +2026-09-13:三位独立子 agent 先审初稿,主 agent 修订,再由原 reviewer +复核当时修订全文。该基线三视角均通过,无剩余 P1/P2 阻塞项。通过仅表示本方案可作为 +后续 M0 设计输入,不代表 Hosting service、局部存储变更或默认 Session +scope 适配已经被接受/实现。Design status 保持 proposed,M0 仍需独立接纳。 + +| Reviewer / 视角 | 初轮发现与修复位置 | 复审结论 | +| --- | --- | --- | +| `lmux_arch_review` / 架构边界 | §6 服务端封闭 legacy mutation;§5 control 单一权威分工;普通 Coding transcript 受控适配与稳定身份 | 通过,无新增阻塞项 | +| `lmux_lifecycle_review` / 生命周期与安全 | §7 start/stop/reconcile 线性化、子端交接 commit/丢 ack;§8 graceful-only 与退出/结算双证据;§5 runtime 丢失仍保留 fence、跨进程配额 | 通过,原 3 项 P1、2 项 P2 在设计层闭合 | +| `lmux_ux_review` / 交互、兼容与验收 | §6 legacy 直连负测;§4 非 TTY 零副作用、完整空态命令;§9 V11/V12、旧证据到新缺口映射与 wheel 双入口 | 通过,原 1 项 P1、3 项 P2 在设计层闭合 | + +验证:本轮相对 HEAD 的 change-aware plan 仅选 docs;文档轻量门禁 6 项 +通过,本文相对链接与引用的 source/test 路径存在性检查通过,`git diff --check` +通过。没有运行 Product/runtime/性能测试,reviewer 结论均为只读设计评审。 +原有性能分支已提交内容不属于本次改动,不因本轮文档重复执行其已通过门禁。 + +### GUI 公共能力与 Harnesstui 完整视图复用复审 + +同日按用户要求新增 §6.1–6.4、V13–V16 和 M0/M3 映射:公共端口不依赖 +CLI/TTY,客户端复用同一部署目录;共享完整 Harnesstui 会话视图,由 +Embedded/Hosted binding 接入,Mux 外壳只组合成员与连接职责。Hosted theme +未接入仍是 Current 缺口;工具/图片等 wire 未支持能力不宣称完成。 + +三位原 reviewer 独立核对本次新增章节、相关源码与全文一致性后均通过: + +| 视角 | 复审结论与收口 | +| --- | --- | +| 架构 `lmux_arch_review` | 无 P1/P2;GUI 公共接口、共享视图/双 binding/Mux 外壳边界成立;修复重复标题 | +| 生命周期 `lmux_lifecycle_review` | 无 P1/P2;view.dispose 不扩大清理权,G16/G17 退出不同,unknown/代际隔离保持;补明审批完整身份键与 action 总额/控制预留 | +| 交互与验收 `lmux_ux_review` | 无 P1/P2;Markdown 现状有源码依据,能力矩阵不冒称全功能等价,V13–V16 与实施阶段一致 | + +本轮通过范围仍为 M0 设计输入,不等于接受具体 API 或实现验收。只读源码 +核对与文档检查,不实现 GUI、公共 API、共享视图重构或 Markdown 修复; +不提交、不推送。后续 M0 冻结类型与 owner 后,实施仍需对应回归及实现评审。 diff --git a/docs/internals/architecture/harness/machine-local-runtime-storage.md b/docs/internals/architecture/harness/machine-local-runtime-storage.md index 45d24d32b..c5ed6858d 100644 --- a/docs/internals/architecture/harness/machine-local-runtime-storage.md +++ b/docs/internals/architecture/harness/machine-local-runtime-storage.md @@ -20,6 +20,24 @@ and performs no filesystem I/O. ## Composition rule +### Accepted lmux managed-profile layout exception + +The [lmux M0 contract](../apphost/lmux-contract-m0.md) accepts one explicitly +composed Linux deployment layout under `$LOUSHANG_HOME/lmux/machines/`. +Its registry, application/control state, logs, cache and disposable instance +scratch remain separately owned and bounded by lifetime. AppHost receives the +layout once from trusted Product composition; leaf consumers do not resolve +home/environment themselves. Explicit temporary overrides still win. Live +connection credentials remain under the platform runtime namespace; canonical +Sessions and attachments stay under their existing authority, not lmux. + +This is an optional-profile Target, not a change to `PlatformPaths` defaults, +the ordinary embedded CLI/TUI or existing Hosting consumers. Native private +directory admission, stable lifecycle fences, quotas and the shared Session +writer contract must be implemented and verified before activation. + +### Shared composition rule + The composition edge resolves `PlatformPaths` and `RuntimeScope` once and injects the immutable result. Leaf storage consumers do not reread process environment variables and do not infer cwd or user-home policy. This makes a diff --git a/docs/internals/architecture/hosting/key-designs/hosted-application-support-boundary.md b/docs/internals/architecture/hosting/key-designs/hosted-application-support-boundary.md index 0b22e59db..a8158cfad 100644 --- a/docs/internals/architecture/hosting/key-designs/hosted-application-support-boundary.md +++ b/docs/internals/architecture/hosting/key-designs/hosted-application-support-boundary.md @@ -80,6 +80,23 @@ graph and responsibility table. ## Future Service Instance Controller +The [accepted lmux M0 contract](../../apphost/lmux-contract-m0.md) now selects +one limited Linux managed-service Target from this candidate: independent +post-handoff lifetime, exact-instance/scope observations and explicit +graceful-only consumer stop. It supplies scope/context, responsibility +discovery, inputs, handoff and validation requirements; only consumer-side +pure values exist so far. Native mechanisms remain unimplemented and inactive. +The general controller (automatic restart, system installation and other +platforms) below remains a candidate, not implicitly promoted with lmux. + +For this profile, control paths are injected narrow leaves of the accepted +lmux layout rather than independently derived from `PlatformPaths.state`. +User stop does not automatically escalate to terminate/kill. Failed-start +rollback retains exact provisional ownership and separate scope settlement; +committed/unknown instances cannot be reclaimed on a missing acknowledgement. +The existing Process/ChildSession close contract is unchanged. Hosting still +owns no application registry, authentication, Session state or log retention. + `hosting.service` is a candidate namespace, not an accepted package or sixth baseline component. It becomes eligible only when a named local service must outlive its launching client and requires independent start, stop, restart, or diff --git a/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md b/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md index 7bc8b4ff3..34eeb4bf6 100644 --- a/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md +++ b/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md @@ -60,6 +60,8 @@ lease, or factory. | AppHost live binding owner | `src/loushang/apphost/runtime.py` | process-local single-flight Product/runtime binding keyed by canonical Session identity, exact retained catalog generation, per-profile attachment lifetime, admission fencing, retryable dependency-ordered close, and bounded phased shutdown; remains explicitly constructed and uncomposed | | optional AppHost/AppServer binder | `src/loushang/apphost/hosted.py` | validates one explicitly selected profile as an exact AppServer structural port bundle and preserves the canonical binding identity; owns no listener, protocol, transport, or service semantics | | optional AppHost foreground application owner | `src/loushang/apphost/application.py` | explicitly composes one AppService with one already admitted AppHost Runtime, fences service admission before runtime shutdown, and settles Product construction debt last; remains an uninstalled in-process library and owns no listener, IPC, Hosting, daemon, or Product semantics | +| optional managed-deployment values | `src/loushang/apphost/managed/__init__.py`, `src/loushang/apphost/managed/contracts.py` and `src/loushang/apphost/managed/paths.py` | closed pure namespace/service/instance identities, handoff transitions, three-fact stop evidence and injected layout; no facade/CLI activation or native authority | +| optional Linux managed-file owner | `src/loushang/apphost/managed/_files.py` | private canonical directory/regular-file admission, stable nonblocking locks and bounded identity-checked record publication; uncomposed, not a service registry or process owner | | AppServer Product ports | `src/loushang/appserver/ports.py` | immutable contract-only identity and Product-supplied Session/projection/work/interaction port bundle; no runtime, listener, protocol, or composition owner | | Session discovery composition | `src/loushang/harness/transcript/directory.py`, `src/loushang/harness/machine_resources/control_plane.py`, and `src/loushang/coding/cli/__main__.py` | canonical global plus cwd/home compatibility sources are selected at composition; cwd is a query/filter and compatibility roots are not writable authorities | | Linux/Windows Coding Product canary | `src/loushang/coding/_product_worker_canary.py` | sole explicit C5.4/C5.5c Product root; joins Coding Product/Session evidence to the real lifecycle coordinator, exact native-profile friend, Worker health, Capability publication, normal close, rollback, and recovery; omission remains Current and unlisted profiles remain closed | diff --git a/src/loushang/apphost/managed/__init__.py b/src/loushang/apphost/managed/__init__.py new file mode 100644 index 000000000..a0d8055a1 --- /dev/null +++ b/src/loushang/apphost/managed/__init__.py @@ -0,0 +1 @@ +"""Optional managed-deployment components; importing this edge activates nothing.""" diff --git a/src/loushang/apphost/managed/_files.py b/src/loushang/apphost/managed/_files.py new file mode 100644 index 000000000..ec29d0170 --- /dev/null +++ b/src/loushang/apphost/managed/_files.py @@ -0,0 +1,387 @@ +"""Linux private-file owner for managed deployment storage, with no domain policy. + +The caller injects a narrow canonical directory whose parent already exists. +Stable lock files are never unlinked. All IO is relative to a retained directory +descriptor, with path/descriptor identity checked around admitted operations. +""" + +from __future__ import annotations + +import os +import re +import stat +import sys +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass, field +from importlib import import_module +from pathlib import Path +from secrets import token_hex +from threading import RLock + +_NAME = re.compile(r"[a-z0-9][a-z0-9._-]{0,95}\Z") +MAX_RECORD_BYTES = 16 * 1024 +FileIdentity = tuple[int, int] + + +class ManagedStorageError(RuntimeError): + """Closed error codes, never native paths or file content.""" + + def __init__(self, code: str) -> None: + if code not in {"unsupported", "unavailable", "not_found", "conflict", + "busy", "closed", "invalid_record", "capacity"}: + raise ValueError("invalid managed storage error") + self.code = code + super().__init__("managed_storage_" + code) + + +@dataclass(frozen=True, slots=True) +class ManagedFileSnapshot: + content: bytes = field(repr=False) + identity: FileIdentity + + +class PrivateManagedDirectory: + """One retained directory; not an authority to recursively clean its parent.""" + + def __init__(self, root: Path, *, create: bool = False) -> None: + self._fd: int | None = None + self._parents: list[tuple[int, str, int, FileIdentity]] = [] + self._anchor: int | None = None + self._parent: int | None = None + self._locks: dict[str, tuple[int, FileIdentity]] = {} + self._pending: dict[str, FileIdentity | None] = {} + self._unlinked: set[str] = set() + self._mutex = RLock() + self._root = root + if sys.platform != "linux": + raise ManagedStorageError("unsupported") + if type(create) is not bool or not isinstance(root, Path): + raise ManagedStorageError("unavailable") + try: + if ( + not root.is_absolute() or root == root.parent + or ".." in root.parts or str(root).startswith("//") + or "\0" in str(root) + ): + raise ManagedStorageError("unavailable") + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC + self._anchor = os.open("/", flags) + parent = self._anchor + for part in root.parts[1:-1]: + fd = os.open(part, flags, dir_fd=parent) + try: + identity = self._validate_parent(os.fstat(fd)) + except BaseException: + _close_preserving_primary(fd) + raise + self._parents.append((parent, part, fd, identity)) + parent = fd + self._parent = parent + self._check_parents() + if create: + try: + os.mkdir(root.name, mode=0o700, dir_fd=parent) + except FileExistsError: + pass + else: + os.fsync(parent) + self._check_parents() + fd = os.open(root.name, flags, dir_fd=parent) + self._fd = fd + self._identity = self._validate(os.fstat(fd), directory=True) + self._check() + except BaseException as error: + try: + self.close() + except BaseException: + error.add_note("managed_directory_cleanup_incomplete") + if isinstance(error, FileNotFoundError): + raise _storage_error("not_found", error) from None + if isinstance(error, OSError): + raise _storage_error("unavailable", error) from None + raise + + @staticmethod + def _validate_parent(info: os.stat_result) -> FileIdentity: + # Trusted root-owned sticky ancestors such as /tmp are allowed, but + # the retained final root itself must still be owner-private. + if ( + not stat.S_ISDIR(info.st_mode) or info.st_uid not in {0, os.geteuid()} + or (info.st_mode & 0o022 and not (info.st_uid == 0 and info.st_mode & stat.S_ISVTX)) + ): + raise ManagedStorageError("unavailable") + return info.st_dev, info.st_ino + + def _check_parents(self) -> None: + for parent, name, fd, identity in self._parents: + current = os.stat(name, dir_fd=parent, follow_symlinks=False) + if ( + self._validate_parent(current) != identity + or self._validate_parent(os.fstat(fd)) != identity + ): + raise ManagedStorageError("conflict") + + @staticmethod + def _validate(info: os.stat_result, *, directory: bool = False) -> FileIdentity: + if ( + not (stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode)) + or info.st_uid != os.geteuid() + or info.st_mode & 0o077 + or (not directory and info.st_nlink != 1) + ): + raise ManagedStorageError("unavailable") + return info.st_dev, info.st_ino + + def _check(self) -> int: + if self._fd is None: + raise ManagedStorageError("closed") + self._check_parents() + if ( + self._validate(os.fstat(self._fd), directory=True) != self._identity + or self._validate(os.stat(self._root.name, dir_fd=self._parent, follow_symlinks=False), + directory=True) != self._identity + ): + raise ManagedStorageError("conflict") + for name, (fd, identity) in self._locks.items(): + info = os.stat(name, dir_fd=self._fd, follow_symlinks=False) + if (info.st_dev, info.st_ino) != identity: + raise ManagedStorageError("conflict") + self._validate(info) + self._validate(os.fstat(fd)) + return self._fd + + @contextmanager + def _operation(self) -> Iterator[int]: + with self._mutex: + try: + fd = self._check() + yield fd + self._check() + except FileNotFoundError as error: + raise _storage_error("not_found", error) from None + except OSError as error: + raise _storage_error("unavailable", error) from None + + def _open(self, name: str, flags: int, *, create: bool = False) -> int: + _name(name) + parent = self._check() + fd = os.open( + name, flags | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK, + 0o600, dir_fd=parent, + ) + try: + identity = self._validate(os.fstat(fd)) + self._check_named(name, identity) + if create: + os.fsync(parent) + return fd + except BaseException: + _close_preserving_primary(fd) + raise + + def _check_named(self, name: str, expected: FileIdentity) -> None: + parent = self._check() + info = os.stat(name, dir_fd=parent, follow_symlinks=False) + if self._validate(info) != expected: + raise ManagedStorageError("conflict") + + def read(self, name: str) -> ManagedFileSnapshot | None: + _name(name) + with self._operation(): + try: + fd = self._open(name, os.O_RDONLY) + except FileNotFoundError: + return None + try: + before = os.fstat(fd) + if before.st_size > MAX_RECORD_BYTES: + raise ManagedStorageError("invalid_record") + chunks = bytearray() + while len(chunks) <= MAX_RECORD_BYTES: + block = os.read(fd, MAX_RECORD_BYTES + 1 - len(chunks)) + if not block: + break + chunks.extend(block) + after = os.fstat(fd) + if len(chunks) > MAX_RECORD_BYTES: + raise ManagedStorageError("invalid_record") + identity = self._validate(after) + self._check_named(name, identity) + if (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != ( + after.st_size, after.st_mtime_ns, after.st_ctime_ns + ): + raise ManagedStorageError("conflict") + return ManagedFileSnapshot(bytes(chunks), identity) + finally: + _close_preserving_primary(fd) + + @contextmanager + def lock(self, name: str, *, create: bool = False) -> Iterator[None]: + """Nonblocking stable flock; the caller never gains an unlink right.""" + _name(name) + if type(create) is not bool or not name.endswith(".lock"): + raise ManagedStorageError("invalid_record") + with self._operation(): + if name in self._locks: + raise ManagedStorageError("busy") + fd = self._open(name, os.O_RDWR | (os.O_CREAT if create else 0), create=create) + fcntl = import_module("fcntl") + try: + identity = self._validate(os.fstat(fd)) + if os.fstat(fd).st_size != 0: + raise ManagedStorageError("invalid_record") + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise ManagedStorageError("busy") from None + self._check_named(name, identity) + self._locks[name] = (fd, identity) + yield + self._check_named(name, identity) + finally: + # Closing this exact descriptor releases only this flock. Never + # unlink a lock on release or retry close after an uncertain error. + self._locks.pop(name, None) + _close_preserving_primary(fd) + + def write(self, name: str, content: bytes, *, expected: ManagedFileSnapshot | None) -> None: + """Publish under a caller-held stable lock; fsync failure remains unknown. + + `expected=None` requires no previous record. Replacement needs both the + old file identity and its bounded bytes, preventing stale replacement. + The caller must serialize cooperating writers via lock(). + """ + _name(name) + if name.endswith(".lock") or type(content) is not bytes or len(content) > MAX_RECORD_BYTES: + raise ManagedStorageError("invalid_record") + if expected is not None and type(expected) is not ManagedFileSnapshot: + raise ManagedStorageError("invalid_record") + with self._operation() as parent: + if not self._locks or self._pending: + raise ManagedStorageError("busy") + if self.read(name) != expected: + raise ManagedStorageError("conflict") + temporary = "pending-" + token_hex(16) + fd = os.open( + temporary, os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW + | os.O_CLOEXEC | os.O_NONBLOCK, 0o600, dir_fd=parent, + ) + self._pending[temporary] = None + published = False + try: + identity = self._validate(os.fstat(fd)) + self._pending[temporary] = identity + self._check_named(temporary, identity) + os.fsync(parent) + remaining = memoryview(content) + while remaining: + written = os.write(fd, remaining) + if written <= 0: + raise ManagedStorageError("unavailable") + remaining = remaining[written:] + os.fsync(fd) + self._check_named(temporary, identity) + if self.read(name) != expected: + raise ManagedStorageError("conflict") + os.replace(temporary, name, src_dir_fd=parent, dst_dir_fd=parent) + published = True + self._pending.pop(temporary) + os.fsync(parent) + finally: + primary = sys.exception() + failures = [] + if not published and self._pending[temporary] is None: + try: + self._pending[temporary] = self._validate(os.fstat(fd)) + except BaseException as error: + failures.append(error) + try: + os.close(fd) + except OSError as error: + failures.append(error) + if not published: + try: + self._cleanup_pending() + except BaseException as error: + failures.append(error) + if failures: + if primary is not None: + primary.add_note("managed_record_cleanup_incomplete") + else: + raise ManagedStorageError("unavailable") from None + + @property + def cleanup_pending(self) -> bool: + return bool(self._pending) + + def _cleanup_pending(self) -> None: + parent = self._check() + for name, identity in tuple(self._pending.items()): + if identity is None: + raise ManagedStorageError("unavailable") + if name not in self._unlinked: + try: + self._check_named(name, identity) + except FileNotFoundError: + pass + else: + os.unlink(name, dir_fd=parent) + self._unlinked.add(name) + # A failed sync retains debt. Retrying only syncs the directory; + # it must never unlink a subsequently created same-name file. + os.fsync(parent) + del self._pending[name] + self._unlinked.discard(name) + + def close(self) -> None: + with self._mutex: + if self._locks: + raise ManagedStorageError("busy") + if self._pending: + with self._operation(): + self._cleanup_pending() + descriptors = [] + if self._fd is not None: + descriptors.append(self._fd) + self._fd = None + descriptors.extend(item[2] for item in reversed(self._parents)) + self._parents.clear() + if self._anchor is not None: + descriptors.append(self._anchor) + self._anchor = None + failed = False + for fd in descriptors: + try: + os.close(fd) + except OSError: + failed = True + if failed: + raise ManagedStorageError("unavailable") from None + + +def _name(name: str) -> None: + if type(name) is not str or _NAME.fullmatch(name) is None: + raise ManagedStorageError("invalid_record") + + +def _close_preserving_primary(fd: int) -> None: + primary = sys.exception() + try: + os.close(fd) + except OSError: + if primary is not None: + primary.add_note("managed_file_close_incomplete") + else: + raise ManagedStorageError("unavailable") from None + + +def _storage_error(code: str, original: BaseException) -> ManagedStorageError: + error = ManagedStorageError(code) + # Preserve only our bounded cleanup signal, never native text or notes. + if any(note in { + "managed_file_close_incomplete", "managed_record_cleanup_incomplete", + "managed_directory_cleanup_incomplete", + } for note in getattr(original, "__notes__", ())): + error.add_note("managed_storage_cleanup_incomplete") + return error diff --git a/src/loushang/apphost/managed/contracts.py b/src/loushang/apphost/managed/contracts.py new file mode 100644 index 000000000..ee92177ab --- /dev/null +++ b/src/loushang/apphost/managed/contracts.py @@ -0,0 +1,206 @@ +"""Pure values for the optional Linux managed-deployment boundary. + +These values neither admit filesystem paths nor authorize processes/connections. +An owner must validate native facts and CAS transitions under its lifecycle fence. +No facade import, environment read, native IO or Product construction occurs here. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field, replace +from enum import Enum +from hashlib import sha256 +from pathlib import PurePosixPath + +MANAGED_DEPLOYMENT_VERSION = "loushang.managed/v1" +MANAGED_LOCAL_PROFILE = "local-managed/v1" +_ID = re.compile(r"[a-z0-9][a-z0-9._-]{0,127}\Z") +_MUX_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}\Z") +_HEX32 = re.compile(r"[0-9a-f]{32}\Z") +_HEX64 = re.compile(r"[0-9a-f]{64}\Z") +_UNSAFE_TEXT = re.compile(r"[\x00-\x1f\x7f-\x9f\ud800-\udfff]") + + +class ManagedContractError(ValueError): + """A bounded error that never includes caller-controlled fields.""" + + def __init__(self) -> None: + super().__init__("invalid_managed_contract") + + +def require_mux_name(value: str) -> str: + """Validate a global display name; it must never become a raw path segment.""" + _match(value, _MUX_NAME) + return value + + +@dataclass(frozen=True, slots=True) +class ManagedNamespaceV1: + """Injected local identity; home is lexical, not yet filesystem-admitted.""" + + platform_home: str = field(repr=False) + user_id: int + machine_id: str + + def __post_init__(self) -> None: + _path(self.platform_home) + if self.platform_home == "/": + raise ManagedContractError() + if type(self.user_id) is not int or not 0 <= self.user_id < 2**32: + raise ManagedContractError() + _match(self.machine_id, _HEX32) + + @property + def namespace_key(self) -> str: + return _digest( + "namespace/v1", self.platform_home, str(self.user_id), self.machine_id + ) + + +@dataclass(frozen=True, slots=True) +class ManagedServiceKeyV1: + """One Product/workspace/profile reuse key, independent of Mux or instance.""" + + product_id: str + workspace: str = field(repr=False) + profile: str = MANAGED_LOCAL_PROFILE + + def __post_init__(self) -> None: + _match(self.product_id, _ID) + _path(self.workspace) + if type(self.profile) is not str or self.profile != MANAGED_LOCAL_PROFILE: + raise ManagedContractError() + + @property + def service_id(self) -> str: + return _digest("service/v1", self.product_id, self.workspace, self.profile) + + +@dataclass(frozen=True, slots=True) +class ManagedInstanceRefV1: + """A stale-detectable lookup reference, never an authority token.""" + + namespace_key: str + service_id: str + instance_id: str + + def __post_init__(self) -> None: + _match(self.namespace_key, _HEX64) + _match(self.service_id, _HEX64) + _match(self.instance_id, _HEX32) + + +class ManagedHandoffPhaseV1(str, Enum): + PROVISIONAL = "provisional" + COMMITTED = "committed" + ABORTING = "aborting" + + +@dataclass(frozen=True, slots=True) +class ManagedHandoffV1: + """An immutable transition model, not an in-memory replacement for CAS. + + Only the child may propose commit. An owner persists a returned next value if + the complete previous value still matches under the stable lifecycle fence. + Missing acknowledgement never changes this model to ABORTING automatically. + """ + + instance: ManagedInstanceRefV1 + attempt_id: str + phase: ManagedHandoffPhaseV1 = ManagedHandoffPhaseV1.PROVISIONAL + stop_requested: bool = False + + def __post_init__(self) -> None: + if type(self.instance) is not ManagedInstanceRefV1: + raise ManagedContractError() + _match(self.attempt_id, _HEX32) + if type(self.phase) is not ManagedHandoffPhaseV1: + raise ManagedContractError() + if type(self.stop_requested) is not bool: + raise ManagedContractError() + + def request_stop(self) -> ManagedHandoffV1: + return replace(self, stop_requested=True) + + def commit(self) -> ManagedHandoffV1: + if self.stop_requested or self.phase is ManagedHandoffPhaseV1.ABORTING: + raise ManagedContractError() + return replace(self, phase=ManagedHandoffPhaseV1.COMMITTED) + + def abort(self) -> ManagedHandoffV1: + if self.phase is ManagedHandoffPhaseV1.COMMITTED: + raise ManagedContractError() + return replace(self, phase=ManagedHandoffPhaseV1.ABORTING) + + +@dataclass(frozen=True, slots=True) +class ManagedStopEvidenceV1: + """Separate native exit and consumer cleanup facts for one exact instance. + + Constructed only by the observing owner, not decoded as trusted peer input. + No PID/socket absence inference or mixing facts from different epochs is allowed. + """ + + instance: ManagedInstanceRefV1 + process_exited: bool + application_cleanup_completed: bool + process_scope_settled: bool + + def __post_init__(self) -> None: + if type(self.instance) is not ManagedInstanceRefV1: + raise ManagedContractError() + if ( + type(self.process_exited) is not bool + or type(self.application_cleanup_completed) is not bool + or type(self.process_scope_settled) is not bool + ): + raise ManagedContractError() + + @property + def cleanly_stopped(self) -> bool: + return ( + self.process_exited + and self.application_cleanup_completed + and self.process_scope_settled + ) + + +def _match(value: str, pattern: re.Pattern[str]) -> None: + if type(value) is not str or pattern.fullmatch(value) is None: + raise ManagedContractError() + + +def _path(value: str) -> None: + if ( + type(value) is not str + or not 1 <= len(value) <= 4096 + or _UNSAFE_TEXT.search(value) is not None + ): + raise ManagedContractError() + path = PurePosixPath(value) + if ( + not path.is_absolute() + or value.startswith("//") + or ".." in path.parts + or str(path) != value + ): + raise ManagedContractError() + + +def _digest(*parts: str) -> str: + return sha256("\0".join((MANAGED_DEPLOYMENT_VERSION, *parts)).encode()).hexdigest() + + +__all__ = [ + "MANAGED_DEPLOYMENT_VERSION", + "MANAGED_LOCAL_PROFILE", + "ManagedContractError", + "ManagedHandoffPhaseV1", + "ManagedHandoffV1", + "ManagedInstanceRefV1", + "ManagedNamespaceV1", + "ManagedServiceKeyV1", + "ManagedStopEvidenceV1", + "require_mux_name", +] diff --git a/src/loushang/apphost/managed/paths.py b/src/loushang/apphost/managed/paths.py new file mode 100644 index 000000000..53f55d58d --- /dev/null +++ b/src/loushang/apphost/managed/paths.py @@ -0,0 +1,94 @@ +"""Pure layout for the optional lmux managed profile, not native admission. + +Composition supplies normalized roots once. Filesystem owners must separately +check permissions, links, stable identity, capacity and Session-root isolation. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import PurePosixPath + +from .contracts import ( + ManagedContractError, + ManagedInstanceRefV1, + ManagedNamespaceV1, + ManagedServiceKeyV1, + _path, +) + + +@dataclass(frozen=True, slots=True) +class ManagedDeploymentPathsV1: + """Resolved locations only: possessing this value grants no IO capability.""" + + registry: PurePosixPath = field(repr=False) + lifecycle: PurePosixPath = field(repr=False) + application: PurePosixPath = field(repr=False) + control: PurePosixPath = field(repr=False) + logs: PurePosixPath = field(repr=False) + temporary: PurePosixPath = field(repr=False) + cache: PurePosixPath = field(repr=False) + connection: PurePosixPath = field(repr=False) + runtime_control: PurePosixPath = field(repr=False) + + +def resolve_managed_paths( + namespace: ManagedNamespaceV1, + service: ManagedServiceKeyV1, + instance: ManagedInstanceRefV1, + *, + runtime_root: str, + temporary_override: str | None = None, +) -> ManagedDeploymentPathsV1: + """Bind all paths to the same namespace/service/instance without IO. + + An explicit temporary override wins over centralized scratch. The derived + runtime/temporary namespaces cannot overlap the durable root or one another; + Default centralized scratch is a separate instance leaf, never an application + record or Session directory. + """ + if ( + type(namespace) is not ManagedNamespaceV1 + or type(service) is not ManagedServiceKeyV1 + or type(instance) is not ManagedInstanceRefV1 + or instance.namespace_key != namespace.namespace_key + or instance.service_id != service.service_id + ): + raise ManagedContractError() + _path(runtime_root) + root = PurePosixPath(namespace.platform_home) / "lmux" + runtime_base = PurePosixPath(runtime_root) + runtime = runtime_base / "lmux" / namespace.namespace_key / service.service_id + if _overlaps(root, runtime): + raise ManagedContractError() + machine = root / "machines" / namespace.machine_id + server = machine / "servers" / service.service_id + temporary = server / "tmp" / instance.instance_id + if temporary_override is not None: + _path(temporary_override) + temporary = ( + PurePosixPath(temporary_override) + / "lmux" / namespace.namespace_key / service.service_id + / instance.instance_id + ) + if _overlaps(root, temporary) or _overlaps(runtime, temporary): + raise ManagedContractError() + return ManagedDeploymentPathsV1( + registry=machine / "registry", + lifecycle=machine / "lifecycle" / service.service_id, + application=server / "state" / "application", + control=server / "state" / "control", + logs=server / "logs", + temporary=temporary, + cache=machine / "cache", + connection=runtime / "connection", + runtime_control=runtime / "control", + ) + + +def _overlaps(left: PurePosixPath, right: PurePosixPath) -> bool: + return left.is_relative_to(right) or right.is_relative_to(left) + + +__all__ = ["ManagedDeploymentPathsV1", "resolve_managed_paths"] diff --git a/tests/apphost/test_managed_contracts.py b/tests/apphost/test_managed_contracts.py new file mode 100644 index 000000000..deb9bd639 --- /dev/null +++ b/tests/apphost/test_managed_contracts.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import ast +from dataclasses import FrozenInstanceError, replace +from pathlib import Path + +import pytest + +from loushang.apphost.managed import contracts +from loushang.apphost.managed.contracts import ( + ManagedContractError, + ManagedHandoffPhaseV1, + ManagedHandoffV1, + ManagedInstanceRefV1, + ManagedNamespaceV1, + ManagedServiceKeyV1, + ManagedStopEvidenceV1, + require_mux_name, +) + + +def _namespace() -> ManagedNamespaceV1: + return ManagedNamespaceV1("/private/user/.loushang", 1000, "a" * 32) + + +def _instance() -> ManagedInstanceRefV1: + return ManagedInstanceRefV1( + _namespace().namespace_key, + ManagedServiceKeyV1("coding", "/workspace").service_id, + "b" * 32, + ) + + +def test_managed_values_are_pure_private_and_immutable(monkeypatch): + def forbidden(*args, **kwargs): + raise AssertionError("contract must not resolve native paths") + + monkeypatch.setattr(Path, "resolve", forbidden) + namespace = _namespace() + key = ManagedServiceKeyV1("coding", "/workspace") + assert namespace.namespace_key == _namespace().namespace_key + assert key.service_id == ManagedServiceKeyV1("coding", "/workspace").service_id + assert "/private" not in repr(namespace) + assert "/workspace" not in repr(key) + with pytest.raises(FrozenInstanceError): + key.workspace = "/other" # type: ignore[misc] + + +def test_namespace_and_service_keys_cover_all_binding_dimensions(): + namespace = _namespace() + assert len({namespace.namespace_key, *( + replace(namespace, **change).namespace_key for change in ( + {"platform_home": "/another/home"}, {"user_id": 1001}, + {"machine_id": "b" * 32}, + ) + )}) == 4 + key = ManagedServiceKeyV1("coding", "/workspace") + assert len({key.service_id, replace(key, workspace="/other").service_id, + replace(key, product_id="work").service_id}) == 3 + assert len(key.service_id) == 64 + + +@pytest.mark.parametrize("name", ["dev", "Dev", "review-2", "a_b", "A" * 64]) +def test_mux_display_name(name): + assert require_mux_name(name) == name + + +@pytest.mark.parametrize("name", ["", "a" * 65, "../dev", "dev:main", "-dev", + "dev/main", "dev\n", "中文", "dev\0", 12, True]) +def test_mux_name_rejects_invalid_without_echo(name): + with pytest.raises(ManagedContractError, match="^invalid_managed_contract$"): + require_mux_name(name) + + +@pytest.mark.parametrize("path", ["relative", "/a/../b", "/a/./b", "/a//b", + "//host/a", "/trailing/", "/a\n", "/a\0", + "/a\ud800", "", None]) +def test_paths_are_lexically_normalized_without_native_admission(path): + with pytest.raises(ManagedContractError): + ManagedServiceKeyV1("coding", path) + + +@pytest.mark.parametrize("change", [{"user_id": True}, {"user_id": -1}, + {"user_id": 2**32}, {"machine_id": "host"}, + {"machine_id": "A" * 32}, {"platform_home": "/"}]) +def test_namespace_rejects_unbounded_or_ambiguous_identity(change): + with pytest.raises(ManagedContractError): + replace(_namespace(), **change) + + +def test_closed_profile_and_exact_reference_validation(): + with pytest.raises(ManagedContractError): + ManagedServiceKeyV1("coding", "/workspace", profile="legacy") + for change in ({"namespace_key": "a"}, {"service_id": "b" * 32}, + {"instance_id": "C" * 32}): + with pytest.raises(ManagedContractError): + replace(_instance(), **change) + + +def test_handoff_commit_cannot_be_aborted_or_reversed_by_lost_ack(): + provisional = ManagedHandoffV1(_instance(), "c" * 32) + committed = provisional.commit() + assert provisional.phase is ManagedHandoffPhaseV1.PROVISIONAL + assert committed.phase is ManagedHandoffPhaseV1.COMMITTED + assert committed.commit() == committed + with pytest.raises(ManagedContractError): + committed.abort() + assert committed.request_stop().phase is ManagedHandoffPhaseV1.COMMITTED + with pytest.raises(ManagedContractError): + committed.request_stop().commit() + + +def test_stop_or_abort_fences_later_commit(): + provisional = ManagedHandoffV1(_instance(), "c" * 32) + for value in (provisional.request_stop(), provisional.abort()): + with pytest.raises(ManagedContractError): + value.commit() + assert value.abort().phase is ManagedHandoffPhaseV1.ABORTING + assert provisional.abort().abort() == provisional.abort() + for change in ({"phase": "committed"}, {"stop_requested": 1}, + {"instance": object()}, {"attempt_id": "invalid"}): + with pytest.raises(ManagedContractError): + replace(provisional, **change) + + +@pytest.mark.parametrize("exited", [False, True]) +@pytest.mark.parametrize("cleaned", [False, True]) +@pytest.mark.parametrize("scope_settled", [False, True]) +def test_stop_needs_exact_exit_application_cleanup_and_scope(exited, cleaned, scope_settled): + evidence = ManagedStopEvidenceV1(_instance(), exited, cleaned, scope_settled) + assert evidence.cleanly_stopped is (exited and cleaned and scope_settled) + with pytest.raises(ManagedContractError): + replace(evidence, process_exited=1) + with pytest.raises(ManagedContractError): + replace(evidence, application_cleanup_completed=0) + with pytest.raises(ManagedContractError): + replace(evidence, process_scope_settled=1) + + +def test_contract_edge_has_only_closed_standard_library_dependencies(): + source = Path(contracts.__file__).read_text() + tree = ast.parse(source) + imports = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imports.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + assert node.level == 0 + imports.add(node.module) + assert imports <= {"__future__", "re", "dataclasses", "enum", "hashlib", "pathlib"} diff --git a/tests/apphost/test_managed_files.py b/tests/apphost/test_managed_files.py new file mode 100644 index 000000000..d209ae39a --- /dev/null +++ b/tests/apphost/test_managed_files.py @@ -0,0 +1,411 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from loushang.apphost.managed._files import ( + MAX_RECORD_BYTES, + ManagedStorageError, + PrivateManagedDirectory, +) + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed storage") + + +@pytest.fixture +def directory(tmp_path): + root = tmp_path / "private" + owner = PrivateManagedDirectory(root, create=True) + try: + yield root, owner + finally: + owner.close() + + +def test_lookup_missing_root_is_read_only(tmp_path): + with pytest.raises(ManagedStorageError, match="not_found"): + PrivateManagedDirectory(tmp_path / "missing") + assert tuple(tmp_path.iterdir()) == () + + +def test_private_modes_bounded_cas_and_stable_lock(directory): + root, owner = directory + assert root.stat().st_mode & 0o777 == 0o700 + with owner.lock("lifecycle.lock", create=True): + assert owner.read("record.json") is None + owner.write("record.json", b'{"phase":"provisional"}', expected=None) + before = owner.read("record.json") + assert before is not None + owner.write("record.json", b'{"phase":"committed"}', expected=before) + after = owner.read("record.json") + assert after is not None and after.identity != before.identity + assert b"committed" in after.content + with pytest.raises(ManagedStorageError, match="conflict"): + owner.write("record.json", b"stale", expected=before) + assert owner.read("record.json") == after + identity = (root / "lifecycle.lock").stat().st_ino + with owner.lock("lifecycle.lock"): + assert (root / "lifecycle.lock").stat().st_ino == identity + assert (root / "record.json").stat().st_mode & 0o777 == 0o600 + assert (root / "lifecycle.lock").stat().st_mode & 0o777 == 0o600 + assert sorted(p.name for p in root.iterdir()) == ["lifecycle.lock", "record.json"] + + +def test_stale_snapshot_detects_in_place_update(directory): + root, owner = directory + with owner.lock("control.lock", create=True): + owner.write("state.json", b"before", expected=None) + snapshot = owner.read("state.json") + (root / "state.json").write_bytes(b"changed") + with pytest.raises(ManagedStorageError, match="conflict"): + owner.write("state.json", b"bad", expected=snapshot) + + +@pytest.mark.parametrize("name", ["../escape", "/absolute", ".", "..", "x/y", + "x\0y", "x\ny", "a" * 97, 12]) +def test_invalid_names_are_rejected_without_side_effect(directory, name): + root, owner = directory + with pytest.raises(ManagedStorageError, match="invalid_record"): + owner.write(name, b"data", expected=None) + assert tuple(root.iterdir()) == () + + +def test_symlink_hardlink_and_nonprivate_file_rejected(directory, tmp_path): + root, owner = directory + target = tmp_path / "outside" + target.write_bytes(b"do not alter") + target.chmod(0o600) + (root / "link").symlink_to(target) + with pytest.raises(ManagedStorageError): + owner.read("link") + os.link(target, root / "hardlink") + with pytest.raises(ManagedStorageError): + owner.read("hardlink") + unsafe = root / "unsafe" + unsafe.write_bytes(b"unsafe") + unsafe.chmod(0o644) + with pytest.raises(ManagedStorageError): + owner.read("unsafe") + assert target.read_bytes() == b"do not alter" + + +def test_fifo_does_not_block_file_admission(directory): + root, owner = directory + os.mkfifo(root / "fifo", 0o600) + with pytest.raises(ManagedStorageError): + owner.read("fifo") + + +def test_insecure_or_symlink_root_is_not_repaired(tmp_path): + root = tmp_path / "unsafe" + root.mkdir(mode=0o755) + with pytest.raises(ManagedStorageError): + PrivateManagedDirectory(root, create=True) + assert root.stat().st_mode & 0o777 == 0o755 + link = tmp_path / "link" + link.symlink_to(root, target_is_directory=True) + with pytest.raises(ManagedStorageError): + PrivateManagedDirectory(link) + + +def test_root_replacement_fences_later_operations(directory, tmp_path): + root, owner = directory + root.rename(tmp_path / "old") + root.mkdir(mode=0o700) + with pytest.raises(ManagedStorageError, match="conflict"): + owner.write("state", b"bad", expected=None) + assert tuple(root.iterdir()) == () + + +def test_locked_file_replacement_is_not_silently_accepted(directory): + root, owner = directory + with pytest.raises(ManagedStorageError, match="conflict"): + with owner.lock("control.lock", create=True): + replacement = root / "replacement" + replacement.write_bytes(b"") + replacement.chmod(0o600) + os.replace(replacement, root / "control.lock") + assert (root / "control.lock").exists() + + +def test_oversize_write_or_read_is_bounded(directory): + root, owner = directory + with pytest.raises(ManagedStorageError, match="invalid_record"): + owner.write("state", b"x" * (MAX_RECORD_BYTES + 1), expected=None) + assert not (root / "state").exists() + large = root / "large" + large.write_bytes(b"x" * (MAX_RECORD_BYTES + 1)) + large.chmod(0o600) + with pytest.raises(ManagedStorageError, match="invalid_record"): + owner.read("large") + + +def test_write_failure_preserves_destination_and_removes_only_owned_temp(directory, monkeypatch): + root, owner = directory + with owner.lock("control.lock", create=True): + owner.write("record", b"original", expected=None) + before = owner.read("record") + original_write = os.write + + def fail_write(fd, data): + original_write(fd, data[:1]) + raise OSError("injected error contains private details") + + monkeypatch.setattr(os, "write", fail_write) + with pytest.raises(ManagedStorageError, match="^managed_storage_unavailable$"): + owner.write("record", b"new", expected=before) + assert owner.read("record") == before + assert sorted(p.name for p in root.iterdir()) == ["control.lock", "record"] + + +def test_close_is_idempotent_and_never_deletes_storage(directory): + root, owner = directory + with owner.lock("control.lock", create=True): + owner.write("state", b"retained", expected=None) + owner.close() + owner.close() + with pytest.raises(ManagedStorageError, match="closed"): + owner.read("state") + assert (root / "state").read_bytes() == b"retained" + assert (root / "control.lock").exists() + + +def test_real_second_process_cannot_acquire_stable_lock(directory): + root, owner = directory + script = """ +import sys +from pathlib import Path +from loushang.apphost.managed._files import PrivateManagedDirectory, ManagedStorageError +directory = PrivateManagedDirectory(Path(sys.argv[1])) +try: + with directory.lock('control.lock'): + print('acquired') +except ManagedStorageError as error: + print(error.code) +finally: + directory.close() +""" + env = dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[2] / "src")) + with owner.lock("control.lock", create=True): + first = subprocess.run([sys.executable, "-c", script, str(root)], env=env, + capture_output=True, text=True, timeout=10) + assert first.returncode == 0, first.stderr + assert first.stdout.strip() == "busy" + second = subprocess.run([sys.executable, "-c", script, str(root)], env=env, + capture_output=True, text=True, timeout=10) + assert second.returncode == 0, second.stderr + assert second.stdout.strip() == "acquired" + + +def test_ancestor_swap_before_mkdir_never_follows_replacement(tmp_path, monkeypatch): + parent = tmp_path / "parent" + parent.mkdir(mode=0o700) + outside = tmp_path / "outside" + outside.mkdir(mode=0o700) + original_mkdir = os.mkdir + + def swap_then_mkdir(path, mode=0o777, *, dir_fd=None): + if path == "private": + parent.rename(tmp_path / "retained-parent") + parent.symlink_to(outside, target_is_directory=True) + return original_mkdir(path, mode, dir_fd=dir_fd) + + monkeypatch.setattr(os, "mkdir", swap_then_mkdir) + with pytest.raises(ManagedStorageError): + PrivateManagedDirectory(parent / "private", create=True) + assert tuple(outside.iterdir()) == () + + +def test_writes_require_lock_and_cannot_replace_lock(directory): + root, owner = directory + with pytest.raises(ManagedStorageError, match="busy"): + owner.write("record", b"unlocked", expected=None) + with owner.lock("control.lock", create=True): + snapshot = owner.read("control.lock") + with pytest.raises(ManagedStorageError, match="invalid_record"): + owner.write("control.lock", b"", expected=snapshot) + assert owner.read("control.lock") == snapshot + assert not (root / "record").exists() + + +def test_replaced_lock_fences_write_before_publication(directory): + root, owner = directory + with pytest.raises(ManagedStorageError, match="conflict"): + with owner.lock("control.lock", create=True): + owner.write("record", b"original", expected=None) + snapshot = owner.read("record") + replacement = root / "replacement" + replacement.write_bytes(b"") + replacement.chmod(0o600) + os.replace(replacement, root / "control.lock") + script = """ +import sys +from pathlib import Path +from loushang.apphost.managed._files import PrivateManagedDirectory +owner = PrivateManagedDirectory(Path(sys.argv[1])) +with owner.lock('control.lock'): + print('acquired', flush=True) + sys.stdin.readline() +owner.close() +""" + env = dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[2] / "src")) + with subprocess.Popen([sys.executable, "-c", script, str(root)], env=env, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) as child: + try: + import select + + assert select.select([child.stdout], [], [], 10)[0] + assert child.stdout.readline().strip() == "acquired" + with pytest.raises(ManagedStorageError, match="conflict"): + owner.write("record", b"stale", expected=snapshot) + assert (root / "record").read_bytes() == b"original" + finally: + child.communicate("release\n", timeout=10) + assert child.returncode == 0 + + +@pytest.mark.parametrize("stage", [1, 2, 3]) +def test_fsync_failure_has_explicit_pre_or_post_publication_outcome(directory, monkeypatch, stage): + root, owner = directory + with owner.lock("control.lock", create=True): + owner.write("record", b"original", expected=None) + before = owner.read("record") + original_fsync = os.fsync + count = 0 + + def fail_stage(fd): + nonlocal count + count += 1 + if count == stage: + raise OSError("private native details") + original_fsync(fd) + + with monkeypatch.context() as patch: + patch.setattr(os, "fsync", fail_stage) + with pytest.raises(ManagedStorageError, match="^managed_storage_unavailable$"): + owner.write("record", b"new", expected=before) + assert (root / "record").read_bytes() == (b"new" if stage == 3 else b"original") + assert not owner.cleanup_pending + assert sorted(p.name for p in root.iterdir()) == ["control.lock", "record"] + + +def test_replace_failure_cleans_temporary_and_preserves_record(directory, monkeypatch): + root, owner = directory + with owner.lock("control.lock", create=True): + owner.write("record", b"original", expected=None) + before = owner.read("record") + + def fail_replace(*args, **kwargs): + raise OSError("replace failed") + + monkeypatch.setattr(os, "replace", fail_replace) + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.write("record", b"new", expected=before) + assert owner.read("record") == before + assert not owner.cleanup_pending + assert sorted(p.name for p in root.iterdir()) == ["control.lock", "record"] + + +def test_cancel_and_close_failure_preserve_cancel_and_attempt_unlink(directory, monkeypatch): + root, owner = directory + with owner.lock("control.lock", create=True): + original_close = os.close + temporary_fd = None + cancellation = KeyboardInterrupt("original cancellation") + + def cancel_write(fd, content): + nonlocal temporary_fd + temporary_fd = fd + raise cancellation + + def close_then_fail(fd): + original_close(fd) + if fd == temporary_fd: + raise OSError("uncertain close") + + with monkeypatch.context() as patch: + patch.setattr(os, "write", cancel_write) + patch.setattr(os, "close", close_then_fail) + with pytest.raises(KeyboardInterrupt) as caught: + owner.write("record", b"new", expected=None) + assert caught.value is cancellation + assert "managed_record_cleanup_incomplete" in caught.value.__notes__ + assert not owner.cleanup_pending + assert sorted(p.name for p in root.iterdir()) == ["control.lock"] + + +def test_failed_cleanup_retains_owned_debt_until_close(directory, monkeypatch): + root, owner = directory + with owner.lock("control.lock", create=True): + def fail_write(fd, content): + raise OSError("primary private failure") + + def fail_unlink(*args, **kwargs): + raise OSError("cleanup private failure") + + with monkeypatch.context() as patch: + patch.setattr(os, "write", fail_write) + patch.setattr(os, "unlink", fail_unlink) + with pytest.raises(ManagedStorageError) as caught: + owner.write("record", b"new", expected=None) + assert str(caught.value) == "managed_storage_unavailable" + assert caught.value.__notes__ == ["managed_storage_cleanup_incomplete"] + assert owner.cleanup_pending + with pytest.raises(ManagedStorageError, match="busy"): + owner.write("other", b"new", expected=None) + owner.close() + assert not owner.cleanup_pending + assert sorted(p.name for p in root.iterdir()) == ["control.lock"] + + +def test_cleanup_sync_debt_blocks_write_and_retry_never_unlinks_replacement(directory, monkeypatch): + root, owner = directory + with owner.lock("control.lock", create=True): + original_fsync = os.fsync + original_unlink = os.unlink + unlinked = None + + def fail_write(fd, content): + raise OSError("primary write failure") + + def record_unlink(name, **kwargs): + nonlocal unlinked + original_unlink(name, **kwargs) + unlinked = name + + def fail_cleanup_sync(fd): + if unlinked is not None: + raise OSError("cleanup sync failure") + original_fsync(fd) + + with monkeypatch.context() as patch: + patch.setattr(os, "write", fail_write) + patch.setattr(os, "unlink", record_unlink) + patch.setattr(os, "fsync", fail_cleanup_sync) + with pytest.raises(ManagedStorageError) as caught: + owner.write("record", b"new", expected=None) + assert str(caught.value) == "managed_storage_unavailable" + assert owner.cleanup_pending + assert sorted(p.name for p in root.iterdir()) == ["control.lock"] + with pytest.raises(ManagedStorageError, match="busy"): + owner.write("other", b"new", expected=None) + assert unlinked is not None + replacement = root / unlinked + replacement.write_bytes(b"not ours") + replacement.chmod(0o600) + synced = [] + + def track_sync(fd): + synced.append(fd) + original_fsync(fd) + + monkeypatch.setattr(os, "fsync", track_sync) + owner.close() + assert synced + assert not owner.cleanup_pending + assert replacement.read_bytes() == b"not ours" diff --git a/tests/apphost/test_managed_paths.py b/tests/apphost/test_managed_paths.py new file mode 100644 index 000000000..7b9c7a86d --- /dev/null +++ b/tests/apphost/test_managed_paths.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path, PurePosixPath + +import pytest + +from loushang.apphost.managed.contracts import ( + ManagedContractError, + ManagedInstanceRefV1, + ManagedNamespaceV1, + ManagedServiceKeyV1, +) +from loushang.apphost.managed.paths import resolve_managed_paths + + +def _values(home="/private/home", workspace="/workspace"): + namespace = ManagedNamespaceV1(home, 1000, "a" * 32) + service = ManagedServiceKeyV1("coding", workspace) + instance = ManagedInstanceRefV1(namespace.namespace_key, service.service_id, "b" * 32) + return namespace, service, instance + + +def test_managed_layout_is_pure_and_does_not_move_sessions(monkeypatch): + def no_io(*args, **kwargs): + raise AssertionError("pure layout may not inspect the filesystem") + + monkeypatch.setattr(Path, "resolve", no_io) + monkeypatch.setattr(Path, "stat", no_io) + namespace, service, instance = _values() + paths = resolve_managed_paths(namespace, service, instance, runtime_root="/run/user/1000") + root = PurePosixPath("/private/home/lmux/machines") / namespace.machine_id + server = root / "servers" / service.service_id + assert paths.registry == root / "registry" + assert paths.lifecycle == root / "lifecycle" / service.service_id + assert paths.application == server / "state/application" + assert paths.control == server / "state/control" + assert paths.logs == server / "logs" + assert paths.temporary == server / "tmp" / instance.instance_id + assert paths.cache == root / "cache" + runtime = PurePosixPath("/run/user/1000/lmux") / namespace.namespace_key / service.service_id + assert paths.connection == runtime / "connection" + assert paths.runtime_control == runtime / "control" + assert "private" not in repr(paths) + assert not hasattr(paths, "sessions") + + +def test_explicit_temporary_root_and_instance_are_separate(): + namespace, service, instance = _values() + kwargs = {"runtime_root": "/run/user/1000", "temporary_override": "/scratch"} + paths = resolve_managed_paths(namespace, service, instance, **kwargs) + newer = resolve_managed_paths( + namespace, service, replace(instance, instance_id="c" * 32), **kwargs + ) + assert paths.temporary == ( + PurePosixPath("/scratch/lmux") / namespace.namespace_key / service.service_id / instance.instance_id + ) + assert paths.temporary != newer.temporary + assert paths.lifecycle == newer.lifecycle + assert paths.connection == newer.connection + assert paths.application == newer.application + + +def test_isolated_home_and_workspace_do_not_share_runtime_records(): + original = resolve_managed_paths(*_values(), runtime_root="/run/user/1000") + other_home = resolve_managed_paths(*_values(home="/other"), runtime_root="/run/user/1000") + other_workspace = resolve_managed_paths(*_values(workspace="/another"), runtime_root="/run/user/1000") + assert len({original.connection, other_home.connection, other_workspace.connection}) == 3 + assert len({original.application, other_home.application, other_workspace.application}) == 3 + + +def test_reference_mismatch_never_produces_paths(): + namespace, service, instance = _values() + for changed in (replace(instance, namespace_key="c" * 64), + replace(instance, service_id="d" * 64)): + with pytest.raises(ManagedContractError): + resolve_managed_paths(namespace, service, changed, runtime_root="/run/user/1000") + + +@pytest.mark.parametrize("runtime,temporary", [ + ("relative", None), ("/private/home/lmux", None), + ("/run/user/1000", "/private/home/lmux"), + ("/run/user/1000", "/run/user/1000"), + ("/run/user/1000", "relative"), +]) +def test_reject_lexical_overlap_before_any_native_admission(runtime, temporary): + with pytest.raises(ManagedContractError): + resolve_managed_paths(*_values(), runtime_root=runtime, temporary_override=temporary) diff --git a/tests/architecture/test_hosted_product_runtime_v1_baseline.py b/tests/architecture/test_hosted_product_runtime_v1_baseline.py index 775440806..5eb828f7d 100644 --- a/tests/architecture/test_hosted_product_runtime_v1_baseline.py +++ b/tests/architecture/test_hosted_product_runtime_v1_baseline.py @@ -72,6 +72,10 @@ APPHOST_SOURCE / "runtime.py", APPHOST_SOURCE / "hosted.py", APPHOST_SOURCE / "application.py", + APPHOST_SOURCE / "managed/__init__.py", + APPHOST_SOURCE / "managed/contracts.py", + APPHOST_SOURCE / "managed/paths.py", + APPHOST_SOURCE / "managed/_files.py", APPSERVER_SOURCE / "ports.py", HARNESS_SOURCE / "machine_resources/control_plane.py", Path("src/loushang/coding/cli/__main__.py"), @@ -435,6 +439,10 @@ def test_current_inventory_matches_source_and_retained_absences() -> None: "foreground.py", "launcher.py", "local.py", + "managed/__init__.py", + "managed/contracts.py", + "managed/paths.py", + "managed/_files.py", } assert { path.relative_to(APPSERVER_SOURCE).as_posix() diff --git a/tests/architecture/test_hosting_architecture_baseline.py b/tests/architecture/test_hosting_architecture_baseline.py index 554a1c42d..bc4c3f8eb 100644 --- a/tests/architecture/test_hosting_architecture_baseline.py +++ b/tests/architecture/test_hosting_architecture_baseline.py @@ -711,6 +711,10 @@ def test_hosting_apphost_and_appserver_optional_modules_are_exact() -> None: "local.py", "integrations/__init__.py", "integrations/harness_session.py", + "managed/__init__.py", + "managed/contracts.py", + "managed/paths.py", + "managed/_files.py", "router.py", "runtime.py", "hosted.py", From 22ced962f7e6b0eee449b16f8a51009f61c0d25b Mon Sep 17 00:00:00 2001 From: zhnt Date: Sun, 13 Sep 2026 06:46:01 +0000 Subject: [PATCH 02/44] feat(apphost): persist managed mux name reservations --- docs/internals/architecture/apphost/README.md | 5 +- .../architecture/apphost/lmux-contract-m0.md | 47 ++- .../drafts/lmux-managed-service-design.md | 2 +- .../hosted-product-runtime-v1-inventory.md | 1 + src/loushang/apphost/managed/_database.py | 248 ++++++++++++ src/loushang/apphost/managed/registry.py | 127 ++++++ tests/apphost/test_managed_registry.py | 375 ++++++++++++++++++ ...test_hosted_product_runtime_v1_baseline.py | 4 + .../test_hosting_architecture_baseline.py | 2 + 9 files changed, 803 insertions(+), 8 deletions(-) create mode 100644 src/loushang/apphost/managed/_database.py create mode 100644 src/loushang/apphost/managed/registry.py create mode 100644 tests/apphost/test_managed_registry.py diff --git a/docs/internals/architecture/apphost/README.md b/docs/internals/architecture/apphost/README.md index 2bbf160e5..b57ecabd5 100644 --- a/docs/internals/architecture/apphost/README.md +++ b/docs/internals/architecture/apphost/README.md @@ -51,8 +51,9 @@ and a later packaging decision. The accepted [lmux M0 contract](lmux-contract-m0.md) adds an optional Linux managed-deployment Target and shared-client/view boundaries. Pure identity, -handoff-state, stop-evidence and layout values plus a Linux private-file owner -are implemented in `apphost.managed`; native service control, managed discovery, Session writer +handoff-state, stop-evidence and layout values, a Linux private-file owner and +SQLite name reservations are implemented in `apphost.managed`; native service +control, managed discovery/connection coordination, Session writer admission and the installed `lmux` entry remain unactivated. This does not change the existing foreground or explicit G16 lifecycle. diff --git a/docs/internals/architecture/apphost/lmux-contract-m0.md b/docs/internals/architecture/apphost/lmux-contract-m0.md index 13880e4d1..4e448817b 100644 --- a/docs/internals/architecture/apphost/lmux-contract-m0.md +++ b/docs/internals/architecture/apphost/lmux-contract-m0.md @@ -8,14 +8,18 @@ - ID: `LMUX-M0` - Authority: normative — accepted limited Linux managed-profile contract - Design status: accepted -- Review status: three-perspective contract, pure-value and private-file slice reviews passed -- Implementation status: partial — pure values and Linux private-file owner; no activation +- Review status: three-perspective contract, private-file and name-registry slice reviews passed +- Implementation status: partial — pure values, private-file owner and SQLite name intents; no activation - Owner: AppHost managed deployment; sibling changes remain sibling-owned - Tracking objective: active Linux lmux goal, branch `harness/lmux-managed-service` ## 1. 本地基线与推进记录 -起点 `1286348e`,保留已评审 lmux 草案;独立任务分支,不推送/合并。 +起点 `1286348e`,保留已评审 lmux 草案;独立任务分支。 +最初仅授权本地提交;用户后续明确授权:完整目标验收、三视角评审修复后, +提交并推送任务分支、创建 PR,通过门禁后合并,最后同步本地 main 与 +harness lane。此授权替代初始“不自动推送或合并”限制,不提前发布未完成切片, +不扩大 GUI、跨机器连接或非 Linux 自动后台范围。 基线命令为 `uv run --no-sync python scripts/dev/run_pytest.py`,目标包括 `tests/coding/test_mux_command.py`、`tests/coding/test_hosted_catalog.py`、 `tests/harnesstui/test_hosted_mux_profile.py`、`tests/harnesstui/test_hosted_mux_shell.py`, @@ -230,13 +234,15 @@ Mux 外壳只负责成员/连接组合。主题与终端能力在客户端组合 | --- | --- | --- | | 独立分支与基线 | 分支已建立,30 个基线回归通过 | 后续增量相对基线验证 | | M0 合同 | 三视角修订后通过;纯值/状态/路径 56 项测试、Ruff/mypy 通过 | native admission、持久 CAS、writer lease 等运行实现及验证 | -| M1 私有文件 owner | 文件准入、稳定锁、记录 CAS 与故障清理;三视角复审通过;文件测试 31 项通过 | SQLite、配额和公共发现端口尚未实现 | +| M1 私有文件 owner | 文件准入、稳定锁、记录 CAS 与故障清理;三视角复审通过;文件测试 31 项通过 | 配额和公共发现/连接协调仍待接线 | +| M1 持久名称预留 | SQLite schema/namespace、名称与操作唯一、服务复用键、分页查询及崩溃恢复;三视角复审通过 | 实例代际、操作结果对账与启动/停止编排尚未实现 | | 一条命令/后台/全局名/多 Tab/stop | 仅 G16 既有显式能力 | M1–M3 全部接线 | | Session 唯一写入与默认历史 | 缺运行期跨进程合同实现 | writer lease + canonical catalog + 双进程测试 | | 完整共享 Harnesstui/Markdown | 草案与本文接缝 | M3 代码与 Embedded 非回退 | | 真实安装/断连/性能/三视角代码评审 | 未执行本目标验收 | M4 完整矩阵与本地提交 | -Goal 保持 active;没有推送、PR、合并或跨平台自动后台发布。 +Goal 保持 active;当前尚未推送、创建 PR 或合并。最终交付包含用户后续 +授权的推送、PR、合并及本地同步;跨平台自动后台仍不在范围内。 ## 9. M0 具体合同与首个原子切片复审 @@ -285,3 +291,34 @@ Hosting 主测试 384 passed / 48 skipped,唯一失败同样为另一份精确 `posix-process-group-v1`,不与上述计数混算。这是既有安装路径非回退证据, 不是 lmux 自动后台或 SSH 断连验收。文档 6 项、依赖图新鲜度与静态检查通过; CI 计划提示的跨平台/host-runtime 验收仍不因这次本地切片而宣称完成。 + +## 11. M1 SQLite 名称预留切片 + +`ManagedRegistryV1` 接受已注入目录与 namespace,提供 `reserve_mux`、`resolve` +和有界 `list_muxes`。返回值是创建意图,不是已创建的 Mux 或运行实例, +不带 PID、连接凭据或 ready 状态。相同 operation 只可重查完整相同意图; +其他操作抢占同名或复用同 operation 则拒绝。服务键与名称在一个事务提交, +失败不留下孤儿服务记录。现有名称不因不可连接、进程退出或客户端 cwd 改变而删除。 + +实现采用固定 schema、namespace 身份和 DELETE/FULL SQLite,拒绝未知表、 +索引、版本、关联损坏和未知/不安全 sidecar。服务 128、名称/意图 4096、分页 +64 的边界独立于数据库物理容量检查;增长前保守预留现有全部页 journal、 +新页、辅助与控制空间。不使用 WAL,也不把 journal_size_limit 当作运行期 +总配额。日志/tmp 预留、实例表与停止控制紧急事务尚未实现。 + +普通观察以 `mode=ro` 打开数据库;需要 hot journal 恢复时失败且不改文件。 +显式可写 owner 在稳定锁内恢复。每次连接短寿命,SQL deadline 与内容/列/ +变量上限有界,不向公共客户端暴露 SQL。关闭失败保留 SQLite owner;检查 +债务、重试和目录关闭使用同一 mutex,支持串行化的跨线程重试。 + +三视角复审修复并通过:满盘下相同意图仍可重查;连接关闭失败保留债务; +并发 close 不跳过刚产生的债务。真实进程死亡测试强制缓存溢写,先验证 +主库确有未提交修改、只读观察不改 DB/journal,再证明显式恢复还原全部 +数据库字节和 21 个名称,不能用内存事务消失代替热日志恢复。 + +此切片保持未组合/未安装,不修改旧 G16/G17 入口或现有 Session 读写。 +验证以新增数据库/登记库与已有 managed 文件/合同、两份精确架构清单为范围; +后续完整运行接线仍须广泛门禁、真实 lmux 安装/断连与性能验收。 +本次聚焦共 125 passed(其中登记库 19 项),Ruff、managed 六个模块 mypy、 +依赖图新鲜度通过。未重复将未改动的 Product/终端路径广泛套件算作本切片 +新证据;完整远端门禁仍是最终 PR 合并前置。 diff --git a/docs/internals/architecture/drafts/lmux-managed-service-design.md b/docs/internals/architecture/drafts/lmux-managed-service-design.md index 8f8ca41bc..498d6b771 100644 --- a/docs/internals/architecture/drafts/lmux-managed-service-design.md +++ b/docs/internals/architecture/drafts/lmux-managed-service-design.md @@ -13,7 +13,7 @@ - Design status: proposed - Review status: base and shared-client/Harnesstui reuse revision both passed three-perspective re-review as M0 design input, 2026-09-13 -- Implementation status: partial; M0 values and M1 private-file owner only, no activation +- Implementation status: partial; M0 values, M1 private files and name intents, no activation - Owner: Loushang application architecture - Delivery objective: active local Linux lmux baseline; implementation tracked by LMUX-M0 diff --git a/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md b/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md index 34eeb4bf6..8462d66ea 100644 --- a/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md +++ b/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md @@ -62,6 +62,7 @@ lease, or factory. | optional AppHost foreground application owner | `src/loushang/apphost/application.py` | explicitly composes one AppService with one already admitted AppHost Runtime, fences service admission before runtime shutdown, and settles Product construction debt last; remains an uninstalled in-process library and owns no listener, IPC, Hosting, daemon, or Product semantics | | optional managed-deployment values | `src/loushang/apphost/managed/__init__.py`, `src/loushang/apphost/managed/contracts.py` and `src/loushang/apphost/managed/paths.py` | closed pure namespace/service/instance identities, handoff transitions, three-fact stop evidence and injected layout; no facade/CLI activation or native authority | | optional Linux managed-file owner | `src/loushang/apphost/managed/_files.py` | private canonical directory/regular-file admission, stable nonblocking locks and bounded identity-checked record publication; uncomposed, not a service registry or process owner | +| optional managed name registry | `src/loushang/apphost/managed/_database.py` and `src/loushang/apphost/managed/registry.py` | bounded SQLite name intents and Product/workspace/profile reuse index with exact operation matching; no native liveness authority, process activation or CLI composition | | AppServer Product ports | `src/loushang/appserver/ports.py` | immutable contract-only identity and Product-supplied Session/projection/work/interaction port bundle; no runtime, listener, protocol, or composition owner | | Session discovery composition | `src/loushang/harness/transcript/directory.py`, `src/loushang/harness/machine_resources/control_plane.py`, and `src/loushang/coding/cli/__main__.py` | canonical global plus cwd/home compatibility sources are selected at composition; cwd is a query/filter and compatibility roots are not writable authorities | | Linux/Windows Coding Product canary | `src/loushang/coding/_product_worker_canary.py` | sole explicit C5.4/C5.5c Product root; joins Coding Product/Session evidence to the real lifecycle coordinator, exact native-profile friend, Worker health, Capability publication, normal close, rollback, and recovery; omission remains Current and unlisted profiles remain closed | diff --git a/src/loushang/apphost/managed/_database.py b/src/loushang/apphost/managed/_database.py new file mode 100644 index 000000000..eec8f0856 --- /dev/null +++ b/src/loushang/apphost/managed/_database.py @@ -0,0 +1,248 @@ +"""Bounded Linux SQLite transactions for the optional managed registry. + +This owner is a same-component friend of the native file owner. SQL never +escapes this package as an application capability. The stable registry lock +serializes cooperating processes; it is not a same-UID adversary boundary. +""" + +from __future__ import annotations + +import os +import sqlite3 +import sys +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from time import monotonic + +from ._files import ( + ManagedStorageError, + PrivateManagedDirectory, + _close_preserving_primary, +) + +DATABASE_NAME = "registry.sqlite3" +DATABASE_LIMIT = 32 * 1024 * 1024 +NORMAL_WATERLINE = 28 * 1024 * 1024 +JOURNAL_LIMIT = 34 * 1024 * 1024 +AUXILIARY_RESERVE = 8 * 1024 * 1024 +CONTROL_RESERVE = 6 * 1024 * 1024 +PAGE_SIZE = 4096 +_LOCK = "registry.lock" +_APPLICATION_ID = 0x4C4D5558 +_VERSION = 1 +_SCHEMA = ( + "CREATE TABLE identity (namespace TEXT PRIMARY KEY NOT NULL) WITHOUT ROWID", + "CREATE TABLE services (service_id TEXT PRIMARY KEY NOT NULL, " + "product TEXT NOT NULL, workspace TEXT NOT NULL, profile TEXT NOT NULL, " + "UNIQUE(product, workspace, profile)) WITHOUT ROWID", + "CREATE TABLE muxes (name TEXT PRIMARY KEY NOT NULL, " + "service_id TEXT NOT NULL REFERENCES services(service_id), " + "operation_id TEXT NOT NULL UNIQUE) WITHOUT ROWID", +) +_SCHEMA_ROWS = tuple(sorted(( + ("table", "identity", "identity", _SCHEMA[0]), + ("table", "services", "services", _SCHEMA[1]), + ("table", "muxes", "muxes", _SCHEMA[2]), +))) + + +class ManagedDatabase: + """Short-lived connections, strict schema, no automatic format migration.""" + + def __init__(self, root: Path, namespace: str, *, create: bool = False) -> None: + if sqlite3.threadsafety == 0: + raise ManagedStorageError("unsupported") + self._directory = PrivateManagedDirectory(root, create=create) + self._namespace = namespace + self._cleanup_connection: sqlite3.Connection | None = None + try: + with self._connection(create=create, read_only=not create): + pass + except BaseException as error: + try: + self.close() + except BaseException: + error.add_note("managed_database_cleanup_incomplete") + raise + + def close(self) -> None: + with self._directory._mutex: + if self._cleanup_connection is not None: + try: + self._cleanup_connection.close() + except sqlite3.Error: + raise ManagedStorageError("unavailable") from None + self._cleanup_connection = None + self._directory.close() + + @property + def cleanup_pending(self) -> bool: + return self._cleanup_connection is not None + + def _admit_files(self, parent: int) -> None: + limits = {_LOCK: 0, DATABASE_NAME: DATABASE_LIMIT, + DATABASE_NAME + "-journal": JOURNAL_LIMIT} + # Enumerate with a bound before SQLite can open any sidecars. Unknown + # WAL/SHM/temp files are debt, not an invitation to clean or recover them. + with os.scandir(parent) as entries: + for index, entry in enumerate(entries): + if index >= len(limits) or entry.name not in limits: + raise ManagedStorageError("invalid_record") + info = entry.stat(follow_symlinks=False) + self._directory._validate(info) + if info.st_size > limits[entry.name]: + raise ManagedStorageError("capacity") + + @contextmanager + def _connection( + self, *, create: bool = False, read_only: bool = False, + ) -> Iterator[sqlite3.Connection]: + directory = self._directory + connection = None + guard = None + with directory.lock(_LOCK, create=create), directory._operation() as parent: + if self.cleanup_pending: + raise ManagedStorageError("busy") + try: + self._admit_files(parent) + try: + guard = directory._open(DATABASE_NAME, os.O_RDONLY if read_only else os.O_RDWR) + except FileNotFoundError: + if not create: + raise + guard = directory._open( + DATABASE_NAME, os.O_RDWR | os.O_CREAT | os.O_EXCL, create=True, + ) + identity = directory._validate(os.fstat(guard)) + # Root identity remains guarded while SQLite uses the retained + # directory path. mode=rw prohibits SQLite from inventing a DB. + mode = "ro" if read_only else "rw" + uri = f"file:/proc/self/fd/{parent}/{DATABASE_NAME}?mode={mode}" + # Every connection use/close, including debt retries on another + # worker thread, is serialized by the directory mutex. + connection = sqlite3.connect(uri, uri=True, timeout=0, isolation_level=None, + check_same_thread=False) + directory._check_named(DATABASE_NAME, identity) + deadline = monotonic() + 2.0 + connection.set_progress_handler(lambda: int(monotonic() >= deadline), 1000) + connection.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, 16 * 1024) + connection.setlimit(sqlite3.SQLITE_LIMIT_SQL_LENGTH, 16 * 1024) + connection.setlimit(sqlite3.SQLITE_LIMIT_ATTACHED, 0) + connection.setlimit(sqlite3.SQLITE_LIMIT_COLUMN, 32) + connection.setlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER, 64) + connection.enable_load_extension(False) + connection.execute("PRAGMA trusted_schema=OFF") + connection.execute("PRAGMA temp_store=MEMORY") + connection.execute("PRAGMA cache_size=-1024") + connection.execute("PRAGMA foreign_keys=ON") + connection.execute("PRAGMA synchronous=FULL") + if connection.execute("PRAGMA journal_mode").fetchone() != ("delete",): + raise ManagedStorageError("invalid_record") + connection.execute("PRAGMA page_size=4096") + if connection.execute("PRAGMA page_size").fetchone() != (PAGE_SIZE,): + raise ManagedStorageError("invalid_record") + if connection.execute("PRAGMA max_page_count=8192").fetchone() != (8192,): + raise ManagedStorageError("capacity") + connection.execute("PRAGMA journal_size_limit=35651584") + header = (connection.execute("PRAGMA application_id").fetchone()[0], + connection.execute("PRAGMA user_version").fetchone()[0]) + if header == (0, 0) and create: + if connection.execute("SELECT 1 FROM sqlite_schema LIMIT 1").fetchone(): + raise ManagedStorageError("invalid_record") + self._admit_capacity(connection, parent, growth=128 * 1024, normal=True) + connection.execute("BEGIN IMMEDIATE") + for statement in _SCHEMA: + connection.execute(statement) + connection.execute("INSERT INTO identity VALUES (?)", (self._namespace,)) + connection.execute(f"PRAGMA application_id={_APPLICATION_ID}") + connection.execute(f"PRAGMA user_version={_VERSION}") + connection.commit() + os.fsync(parent) + elif header != (_APPLICATION_ID, _VERSION): + raise ManagedStorageError("invalid_record") + self._validate_schema(connection) + directory._check_named(DATABASE_NAME, identity) + self._admit_files(parent) + yield connection + directory._check_named(DATABASE_NAME, identity) + self._admit_files(parent) + except sqlite3.Error as error: + code = "busy" if getattr(error, "sqlite_errorcode", 0) in { + sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED, + } else "unavailable" + raise ManagedStorageError(code) from None + finally: + primary = sys.exception() + failed = False + if connection is not None: + try: + # close rolls back a not-yet-committed transaction. + connection.close() + except sqlite3.Error: + failed = True + self._cleanup_connection = connection + if guard is not None: + try: + _close_preserving_primary(guard) + except ManagedStorageError: + failed = True + if failed: + if primary is not None: + primary.add_note("managed_database_cleanup_incomplete") + else: + raise ManagedStorageError("unavailable") from None + + def _validate_schema(self, connection: sqlite3.Connection) -> None: + rows = connection.execute( + "SELECT type, name, tbl_name, sql FROM sqlite_schema " + "WHERE sql IS NOT NULL ORDER BY name LIMIT 8" + ).fetchall() + if tuple(sorted(rows)) != _SCHEMA_ROWS: + raise ManagedStorageError("invalid_record") + if connection.execute("SELECT namespace FROM identity LIMIT 2").fetchall() != [ + (self._namespace,), + ]: + raise ManagedStorageError("conflict") + if ( + connection.execute("SELECT count(*) FROM services").fetchone()[0] > 128 + or connection.execute("SELECT count(*) FROM muxes").fetchone()[0] > 4096 + or connection.execute("PRAGMA foreign_key_check").fetchone() is not None + ): + raise ManagedStorageError("invalid_record") + + @staticmethod + def _admit_capacity( + connection: sqlite3.Connection, parent: int, *, growth: int, normal: bool, + ) -> None: + pages = connection.execute("PRAGMA page_count").fetchone()[0] + allocated = pages * PAGE_SIZE + if allocated + growth > (NORMAL_WATERLINE if normal else DATABASE_LIMIT): + raise ManagedStorageError("capacity") + # Conservatively budget every current page being journaled (including + # per-page headers), all possible DB growth, auxiliary and control space. + journal_peak = allocated + pages * 8 + PAGE_SIZE * 4 + if journal_peak > JOURNAL_LIMIT: + raise ManagedStorageError("capacity") + usage = os.fstatvfs(parent) + reserve = CONTROL_RESERVE if normal else 0 + needed = growth + journal_peak + AUXILIARY_RESERVE + reserve + if usage.f_bavail * usage.f_frsize < needed: + raise ManagedStorageError("capacity") + + @contextmanager + def transaction(self, *, write: bool = False) -> Iterator[sqlite3.Connection]: + with self._connection(read_only=not write) as connection: + if write: + connection.execute("BEGIN IMMEDIATE") + else: + connection.execute("PRAGMA query_only=ON") + connection.execute("BEGIN") + yield connection + self._directory._check() + connection.commit() + + def admit_growth(self, connection: sqlite3.Connection) -> None: + """Call after idempotent lookup but before a bounded actual mutation.""" + self._admit_capacity(connection, self._directory._check(), + growth=128 * 1024, normal=True) diff --git a/src/loushang/apphost/managed/registry.py b/src/loushang/apphost/managed/registry.py new file mode 100644 index 000000000..e11217f3f --- /dev/null +++ b/src/loushang/apphost/managed/registry.py @@ -0,0 +1,127 @@ +"""Product-neutral durable name reservation; no process or connection authority. + +The reservation is an intent, not proof of a running or created Mux. Downstream +coordination must reconcile the same operation, never replay an unknown create. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from ._database import ManagedDatabase +from ._files import ManagedStorageError +from .contracts import ( + _HEX32, + ManagedContractError, + ManagedNamespaceV1, + ManagedServiceKeyV1, + _match, + require_mux_name, +) + +MAX_SERVICES = 128 +MAX_MUXES = 4096 +MAX_PAGE = 64 + + +@dataclass(frozen=True, slots=True) +class ManagedMuxReservationV1: + """A persisted intent only; deliberately contains neither PID nor endpoint.""" + + name: str + service: ManagedServiceKeyV1 + operation_id: str + + def __post_init__(self) -> None: + require_mux_name(self.name) + if type(self.service) is not ManagedServiceKeyV1: + raise ManagedContractError() + _match(self.operation_id, _HEX32) + + +class ManagedRegistryV1: + """User/machine-scoped name index, safe to reopen from any client cwd.""" + + def __init__( + self, root: Path, namespace: ManagedNamespaceV1, *, create: bool = False, + ) -> None: + if type(namespace) is not ManagedNamespaceV1: + raise ManagedContractError() + self._database = ManagedDatabase(root, namespace.namespace_key, create=create) + + def close(self) -> None: + self._database.close() + + @property + def cleanup_pending(self) -> bool: + return self._database.cleanup_pending + + def reserve_mux(self, reservation: ManagedMuxReservationV1) -> ManagedMuxReservationV1: + if type(reservation) is not ManagedMuxReservationV1: + raise ManagedContractError() + with self._database.transaction(write=True) as connection: + # Retries are identified by the full intent, not just a reused name. + old = connection.execute( + "SELECT m.name, s.product, s.workspace, s.profile, m.operation_id, s.service_id " + "FROM muxes m JOIN services s USING(service_id) " + "WHERE m.name=? OR m.operation_id=? LIMIT 2", + (reservation.name, reservation.operation_id), + ).fetchall() + if old: + if len(old) == 1 and _decode(old[0]) == reservation: + return reservation + raise ManagedStorageError("conflict") + key = reservation.service + stored = connection.execute( + "SELECT product, workspace, profile FROM services WHERE service_id=?", + (key.service_id,), + ).fetchone() + if stored is not None and stored != (key.product_id, key.workspace, key.profile): + raise ManagedStorageError("conflict") + if connection.execute("SELECT count(*) FROM muxes").fetchone()[0] >= MAX_MUXES: + raise ManagedStorageError("capacity") + self._database.admit_growth(connection) + if stored is None: + if connection.execute("SELECT count(*) FROM services").fetchone()[0] >= MAX_SERVICES: + raise ManagedStorageError("capacity") + connection.execute("INSERT INTO services VALUES (?, ?, ?, ?)", + (key.service_id, key.product_id, key.workspace, key.profile)) + connection.execute("INSERT INTO muxes VALUES (?, ?, ?)", + (reservation.name, key.service_id, reservation.operation_id)) + return reservation + + def resolve(self, name: str) -> ManagedMuxReservationV1 | None: + require_mux_name(name) + with self._database.transaction() as connection: + row = connection.execute( + "SELECT m.name, s.product, s.workspace, s.profile, m.operation_id, s.service_id " + "FROM muxes m JOIN services s USING(service_id) WHERE m.name=?", + (name,), + ).fetchone() + return None if row is None else _decode(row) + + def list_muxes( + self, *, after: str | None = None, limit: int = MAX_PAGE, + ) -> tuple[ManagedMuxReservationV1, ...]: + if type(limit) is not int or not 1 <= limit <= MAX_PAGE: + raise ManagedContractError() + if after is not None: + require_mux_name(after) + with self._database.transaction() as connection: + return tuple(_decode(row) for row in connection.execute( + "SELECT m.name, s.product, s.workspace, s.profile, m.operation_id, s.service_id " + "FROM muxes m JOIN services s USING(service_id) " + "WHERE m.name>? ORDER BY m.name LIMIT ?", (after or "", limit), + ).fetchall()) + + +def _decode(row: tuple) -> ManagedMuxReservationV1: + try: + key = ManagedServiceKeyV1(row[1], row[2], row[3]) + reservation = ManagedMuxReservationV1(row[0], key, row[4]) + if key.service_id != row[5]: + raise ManagedContractError() + return reservation + except (ManagedContractError, IndexError, TypeError): + raise ManagedStorageError("invalid_record") from None diff --git a/tests/apphost/test_managed_registry.py b/tests/apphost/test_managed_registry.py new file mode 100644 index 000000000..4a94a2a96 --- /dev/null +++ b/tests/apphost/test_managed_registry.py @@ -0,0 +1,375 @@ +from __future__ import annotations + +import os +import sqlite3 +import subprocess +import sys +import threading +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from loushang.apphost.managed._database import DATABASE_NAME +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.contracts import ( + ManagedContractError, + ManagedNamespaceV1, + ManagedServiceKeyV1, +) +from loushang.apphost.managed.registry import ManagedMuxReservationV1, ManagedRegistryV1 + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed registry") + + +@pytest.fixture +def namespace(tmp_path): + return ManagedNamespaceV1(str(tmp_path / "platform"), os.geteuid(), "a" * 32) + + +@pytest.fixture +def registry(tmp_path, namespace): + owner = ManagedRegistryV1(tmp_path / "registry", namespace, create=True) + try: + yield owner + finally: + owner.close() + + +def intent(name="dev", operation="b" * 32, workspace="/workspace"): + return ManagedMuxReservationV1(name, ManagedServiceKeyV1("coding", workspace), operation) + + +def test_missing_lookup_does_not_create_root(tmp_path, namespace): + with pytest.raises(ManagedStorageError, match="not_found"): + ManagedRegistryV1(tmp_path / "missing", namespace) + assert not (tmp_path / "missing").exists() + + +def test_reopen_from_other_cwd_retains_global_intent(registry, tmp_path, namespace, monkeypatch): + request = intent() + assert registry.reserve_mux(request) == request + registry.close() + other = tmp_path / "other-workspace" + other.mkdir() + monkeypatch.chdir(other) + reopened = ManagedRegistryV1(tmp_path / "registry", namespace) + try: + assert reopened.resolve("dev") == request + assert reopened.list_muxes() == (request,) + assert not hasattr(reopened.resolve("dev"), "pid") + finally: + reopened.close() + + +def test_reservations_reuse_service_but_not_names_or_operations(registry, tmp_path): + first = intent() + assert registry.reserve_mux(first) == first + assert registry.reserve_mux(first) == first + second = intent("review", "c" * 32) + registry.reserve_mux(second) + with pytest.raises(ManagedStorageError, match="conflict"): + registry.reserve_mux(intent(operation="d" * 32)) + with pytest.raises(ManagedStorageError, match="conflict"): + registry.reserve_mux(intent("other")) + with pytest.raises(ManagedStorageError, match="conflict"): + registry.reserve_mux(intent(workspace="/different")) + with sqlite3.connect(tmp_path / "registry" / DATABASE_NAME) as connection: + assert connection.execute("SELECT count(*) FROM services").fetchone() == (1,) + assert connection.execute("SELECT count(*) FROM muxes").fetchone() == (2,) + + +def test_pagination_is_bounded_and_case_sensitive(registry): + for index, name in enumerate(("dev", "Dev", "review")): + registry.reserve_mux(intent(name, f"{index:032x}")) + assert [r.name for r in registry.list_muxes(limit=2)] == ["Dev", "dev"] + assert [r.name for r in registry.list_muxes(after="dev", limit=2)] == ["review"] + for invalid in (0, 65, True, "2"): + with pytest.raises(ManagedContractError): + registry.list_muxes(limit=invalid) + + +def test_private_files_and_read_only_queries(registry, tmp_path, namespace): + registry.reserve_mux(intent()) + root = tmp_path / "registry" + before = {p.name: (p.read_bytes(), p.stat().st_mtime_ns) for p in root.iterdir()} + observer = ManagedRegistryV1(root, namespace) + try: + assert observer.resolve("absent") is None + assert observer.resolve("dev") == intent() + observer.list_muxes() + finally: + observer.close() + assert before == {p.name: (p.read_bytes(), p.stat().st_mtime_ns) for p in root.iterdir()} + assert set(before) == {"registry.lock", DATABASE_NAME} + assert all(p.stat().st_mode & 0o777 == 0o600 for p in root.iterdir()) + + +def test_namespace_mismatch_and_unknown_schema_are_not_migrated(registry, tmp_path, namespace): + root = tmp_path / "registry" + other = ManagedNamespaceV1(namespace.platform_home, namespace.user_id, "c" * 32) + with pytest.raises(ManagedStorageError, match="conflict"): + ManagedRegistryV1(root, other) + with sqlite3.connect(root / DATABASE_NAME) as connection: + connection.execute("PRAGMA user_version=42") + before = (root / DATABASE_NAME).read_bytes() + with pytest.raises(ManagedStorageError, match="invalid_record"): + ManagedRegistryV1(root, namespace, create=True) + assert (root / DATABASE_NAME).read_bytes() == before + + +@pytest.mark.parametrize("change", ["CREATE TABLE unknown (x)", "CREATE INDEX extra ON muxes(service_id)"]) +def test_unexpected_schema_is_rejected(registry, tmp_path, change): + with sqlite3.connect(tmp_path / "registry" / DATABASE_NAME) as connection: + connection.execute(change) + with pytest.raises(ManagedStorageError, match="invalid_record"): + registry.list_muxes() + + +def test_unadmitted_sidecar_never_touched(registry, tmp_path): + outside = tmp_path / "outside" + outside.write_bytes(b"do not alter") + (tmp_path / "registry" / (DATABASE_NAME + "-journal")).symlink_to(outside) + with pytest.raises(ManagedStorageError): + registry.reserve_mux(intent()) + assert outside.read_bytes() == b"do not alter" + + +def test_capacity_failure_keeps_existing_names_resolvable(registry, monkeypatch): + registry.reserve_mux(intent()) + monkeypatch.setattr(os, "fstatvfs", lambda fd: SimpleNamespace(f_bavail=0, f_frsize=4096)) + assert registry.reserve_mux(intent()) == intent() + with pytest.raises(ManagedStorageError, match="capacity"): + registry.reserve_mux(intent("new", "c" * 32)) + assert registry.resolve("dev") == intent() + assert registry.resolve("new") is None + + +def test_service_capacity_is_atomic(registry, monkeypatch, tmp_path): + monkeypatch.setattr("loushang.apphost.managed.registry.MAX_SERVICES", 1) + registry.reserve_mux(intent()) + with pytest.raises(ManagedStorageError, match="capacity"): + registry.reserve_mux(intent("other", "c" * 32, "/other")) + registry.reserve_mux(intent("same-service", "d" * 32)) + assert [r.name for r in registry.list_muxes()] == ["dev", "same-service"] + + +def test_native_transaction_rollback_preserves_database(registry): + with pytest.raises(KeyboardInterrupt): + with registry._database.transaction(write=True) as connection: + connection.execute("INSERT INTO services VALUES (?, ?, ?, ?)", + ("orphan", "coding", "/orphan", "local-managed/v1")) + raise KeyboardInterrupt + with registry._database.transaction() as connection: + assert connection.execute("SELECT count(*) FROM services").fetchone() == (0,) + + +def test_real_process_death_rolls_back_without_releasing_committed_name(registry, tmp_path, namespace): + registry.reserve_mux(intent()) + for index in range(20): + registry.reserve_mux(intent(f"seed-{index}", f"{index:032x}", "/" + str(index) + "x" * 1000)) + database = tmp_path / "registry" / DATABASE_NAME + committed = database.read_bytes() + script = """ +import os, sys +from pathlib import Path +from loushang.apphost.managed.registry import ManagedRegistryV1 +from loushang.apphost.managed.contracts import ManagedNamespaceV1 +root = Path(sys.argv[1]) +owner = ManagedRegistryV1(root, ManagedNamespaceV1(sys.argv[2], os.geteuid(), 'a'*32)) +with owner._database.transaction(write=True) as connection: + connection.execute('PRAGMA cache_size=1') + connection.execute('PRAGMA cache_spill=1') + connection.execute('DELETE FROM muxes') + connection.execute('DELETE FROM services') + os._exit(23) +""" + env = dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[2] / "src")) + process = subprocess.run([sys.executable, "-c", script, str(tmp_path / "registry"), + namespace.platform_home], env=env, capture_output=True, timeout=10) + assert process.returncode == 23, process.stderr + journal = database.with_name(DATABASE_NAME + "-journal") + journal_bytes = journal.read_bytes() + assert len(journal_bytes) > 512 + assert journal_bytes[:8] != b"\0" * 8 + assert database.read_bytes() != committed # Uncommitted pages really spilled. + before = {p.name: (p.read_bytes(), p.stat().st_mtime_ns) for p in database.parent.iterdir()} + with pytest.raises(ManagedStorageError): + ManagedRegistryV1(database.parent, namespace) + with pytest.raises(ManagedStorageError): + registry.resolve("dev") + assert before == {p.name: (p.read_bytes(), p.stat().st_mtime_ns) for p in database.parent.iterdir()} + # An explicit writable owner can perform SQLite's journal recovery. A plain + # lookup must not silently mutate a database that requires crash recovery. + recovery = ManagedRegistryV1(tmp_path / "registry", namespace, create=True) + try: + assert recovery.resolve("dev") == intent() + assert len(recovery.list_muxes()) == 21 + assert database.read_bytes() == committed + assert not journal.exists() + finally: + recovery.close() + + +def test_real_process_contention_cannot_duplicate_name(registry, tmp_path, namespace): + script = """ +import os, sys +from pathlib import Path +from loushang.apphost.managed.registry import ManagedRegistryV1, ManagedMuxReservationV1 +from loushang.apphost.managed.contracts import ManagedNamespaceV1, ManagedServiceKeyV1 +from loushang.apphost.managed._files import ManagedStorageError +try: + owner = ManagedRegistryV1(Path(sys.argv[1]), ManagedNamespaceV1(sys.argv[2], os.geteuid(), 'a'*32)) + try: + owner.reserve_mux(ManagedMuxReservationV1('dev', ManagedServiceKeyV1('coding', '/workspace'), sys.argv[3])) + print('reserved') + finally: + owner.close() +except ManagedStorageError as error: + print(error.code) +""" + env = dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[2] / "src")) + with registry._database.transaction(write=True): + child = subprocess.run([sys.executable, "-c", script, str(tmp_path / "registry"), + namespace.platform_home, "c" * 32], env=env, + capture_output=True, text=True, timeout=10) + assert child.returncode == 0, child.stderr + assert child.stdout.strip() == "busy" + registry.reserve_mux(intent()) + child = subprocess.run([sys.executable, "-c", script, str(tmp_path / "registry"), + namespace.platform_home, "c" * 32], env=env, + capture_output=True, text=True, timeout=10) + assert child.returncode == 0, child.stderr + assert child.stdout.strip() == "conflict" + assert registry.list_muxes() == (intent(),) + + +def test_failed_connection_close_retains_cleanup_owner(registry, monkeypatch): + original_connect = sqlite3.connect + fail = True + + class FailingClose(sqlite3.Connection): + def close(self): + if fail: + raise sqlite3.OperationalError("private cleanup details") + super().close() + + def connect(*args, **kwargs): + return original_connect(*args, factory=FailingClose, **kwargs) + + monkeypatch.setattr(sqlite3, "connect", connect) + with pytest.raises(ManagedStorageError, match="unavailable"): + registry.resolve("dev") + assert registry.cleanup_pending + with pytest.raises(ManagedStorageError, match="busy"): + registry.resolve("dev") + with pytest.raises(ManagedStorageError, match="unavailable"): + registry.close() + assert registry.cleanup_pending + fail = False + registry.close() + assert not registry.cleanup_pending + + +def test_concurrent_close_cannot_lose_new_connection_debt(registry, monkeypatch): + original_connect = sqlite3.connect + directory = registry._database._directory + original_mutex = directory._mutex + holding = threading.Event() + release = threading.Event() + close_waiting = threading.Event() + fail_close = True + failures = [] + + class TrackedMutex: + def __enter__(self): + if threading.current_thread().name == "registry-closer": + close_waiting.set() + original_mutex.acquire() + return self + + def __exit__(self, *args): + original_mutex.release() + + class FailingClose(sqlite3.Connection): + def close(self): + if fail_close: + raise sqlite3.OperationalError("injected cleanup failure") + super().close() + + def connect(*args, **kwargs): + return original_connect(*args, factory=FailingClose, **kwargs) + + def transact(): + try: + with registry._database.transaction(): + holding.set() + assert release.wait(10) + except BaseException as error: + failures.append(error) + + def close(): + try: + registry.close() + except BaseException as error: + failures.append(error) + + monkeypatch.setattr(sqlite3, "connect", connect) + monkeypatch.setattr(directory, "_mutex", TrackedMutex()) + transaction = threading.Thread(target=transact) + closer = threading.Thread(target=close, name="registry-closer") + transaction.start() + try: + assert holding.wait(10) + closer.start() + assert close_waiting.wait(10) + finally: + release.set() + transaction.join(10) + if closer.ident is not None: + closer.join(10) + assert not transaction.is_alive() and not closer.is_alive() + assert len(failures) == 2 + assert all(isinstance(error, ManagedStorageError) for error in failures) + assert registry.cleanup_pending + assert directory._fd is not None + fail_close = False + registry.close() # Different thread from creation: still serialized and valid. + assert not registry.cleanup_pending + + +def test_name_insert_failure_rolls_back_new_service_too(registry, monkeypatch): + original_connect = sqlite3.connect + + class FailMuxInsert(sqlite3.Connection): + def execute(self, sql, parameters=()): + if sql.startswith("INSERT INTO muxes"): + raise sqlite3.OperationalError("injected insertion failure") + return super().execute(sql, parameters) + + def connect(*args, **kwargs): + return original_connect(*args, factory=FailMuxInsert, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(sqlite3, "connect", connect) + with pytest.raises(ManagedStorageError, match="unavailable"): + registry.reserve_mux(intent()) + with registry._database.transaction() as connection: + assert connection.execute("SELECT count(*) FROM services").fetchone() == (0,) + assert connection.execute("SELECT count(*) FROM muxes").fetchone() == (0,) + + +def test_corrupt_orphan_is_not_hidden_by_join(registry, tmp_path): + with sqlite3.connect(tmp_path / "registry" / DATABASE_NAME) as connection: + connection.execute("INSERT INTO muxes VALUES (?, ?, ?)", ("dev", "missing", "a" * 32)) + with pytest.raises(ManagedStorageError, match="invalid_record"): + registry.resolve("dev") + + +def test_mux_capacity_failure_does_not_allocate_service(registry, monkeypatch): + registry.reserve_mux(intent()) + monkeypatch.setattr("loushang.apphost.managed.registry.MAX_MUXES", 1) + with pytest.raises(ManagedStorageError, match="capacity"): + registry.reserve_mux(intent("other", "c" * 32, "/other")) + with registry._database.transaction() as connection: + assert connection.execute("SELECT count(*) FROM services").fetchone() == (1,) diff --git a/tests/architecture/test_hosted_product_runtime_v1_baseline.py b/tests/architecture/test_hosted_product_runtime_v1_baseline.py index 5eb828f7d..e71aac3d6 100644 --- a/tests/architecture/test_hosted_product_runtime_v1_baseline.py +++ b/tests/architecture/test_hosted_product_runtime_v1_baseline.py @@ -76,6 +76,8 @@ APPHOST_SOURCE / "managed/contracts.py", APPHOST_SOURCE / "managed/paths.py", APPHOST_SOURCE / "managed/_files.py", + APPHOST_SOURCE / "managed/_database.py", + APPHOST_SOURCE / "managed/registry.py", APPSERVER_SOURCE / "ports.py", HARNESS_SOURCE / "machine_resources/control_plane.py", Path("src/loushang/coding/cli/__main__.py"), @@ -443,6 +445,8 @@ def test_current_inventory_matches_source_and_retained_absences() -> None: "managed/contracts.py", "managed/paths.py", "managed/_files.py", + "managed/_database.py", + "managed/registry.py", } assert { path.relative_to(APPSERVER_SOURCE).as_posix() diff --git a/tests/architecture/test_hosting_architecture_baseline.py b/tests/architecture/test_hosting_architecture_baseline.py index bc4c3f8eb..4cdb97895 100644 --- a/tests/architecture/test_hosting_architecture_baseline.py +++ b/tests/architecture/test_hosting_architecture_baseline.py @@ -715,6 +715,8 @@ def test_hosting_apphost_and_appserver_optional_modules_are_exact() -> None: "managed/contracts.py", "managed/paths.py", "managed/_files.py", + "managed/_database.py", + "managed/registry.py", "router.py", "runtime.py", "hosted.py", From ce7ab184f9a37e3a48880eafed0ef1b9e733a3f1 Mon Sep 17 00:00:00 2001 From: zhnt Date: Sun, 13 Sep 2026 06:56:36 +0000 Subject: [PATCH 03/44] feat(apphost): fence managed service instance generations --- docs/internals/architecture/apphost/README.md | 3 +- .../architecture/apphost/lmux-contract-m0.md | 36 ++- .../drafts/lmux-managed-service-design.md | 2 +- .../hosted-product-runtime-v1-inventory.md | 1 + src/loushang/apphost/managed/_database.py | 13 +- src/loushang/apphost/managed/lifecycle.py | 251 ++++++++++++++++++ tests/apphost/test_managed_lifecycle.py | 210 +++++++++++++++ ...test_hosted_product_runtime_v1_baseline.py | 2 + .../test_hosting_architecture_baseline.py | 1 + 9 files changed, 513 insertions(+), 6 deletions(-) create mode 100644 src/loushang/apphost/managed/lifecycle.py create mode 100644 tests/apphost/test_managed_lifecycle.py diff --git a/docs/internals/architecture/apphost/README.md b/docs/internals/architecture/apphost/README.md index b57ecabd5..c903bb957 100644 --- a/docs/internals/architecture/apphost/README.md +++ b/docs/internals/architecture/apphost/README.md @@ -52,7 +52,8 @@ and a later packaging decision. The accepted [lmux M0 contract](lmux-contract-m0.md) adds an optional Linux managed-deployment Target and shared-client/view boundaries. Pure identity, handoff-state, stop-evidence and layout values, a Linux private-file owner and -SQLite name reservations are implemented in `apphost.managed`; native service +SQLite name reservations and clean-instance lifecycle coordination are implemented +in `apphost.managed`; native service control, managed discovery/connection coordination, Session writer admission and the installed `lmux` entry remain unactivated. This does not change the existing foreground or explicit G16 lifecycle. diff --git a/docs/internals/architecture/apphost/lmux-contract-m0.md b/docs/internals/architecture/apphost/lmux-contract-m0.md index 4e448817b..317fa3c65 100644 --- a/docs/internals/architecture/apphost/lmux-contract-m0.md +++ b/docs/internals/architecture/apphost/lmux-contract-m0.md @@ -8,8 +8,8 @@ - ID: `LMUX-M0` - Authority: normative — accepted limited Linux managed-profile contract - Design status: accepted -- Review status: three-perspective contract, private-file and name-registry slice reviews passed -- Implementation status: partial — pure values, private-file owner and SQLite name intents; no activation +- Review status: three-perspective storage/registry/lifecycle slice reviews passed; recovery admission pending +- Implementation status: partial — storage, name intents and clean instance generations; no activation - Owner: AppHost managed deployment; sibling changes remain sibling-owned - Tracking objective: active Linux lmux goal, branch `harness/lmux-managed-service` @@ -235,7 +235,8 @@ Mux 外壳只负责成员/连接组合。主题与终端能力在客户端组合 | 独立分支与基线 | 分支已建立,30 个基线回归通过 | 后续增量相对基线验证 | | M0 合同 | 三视角修订后通过;纯值/状态/路径 56 项测试、Ruff/mypy 通过 | native admission、持久 CAS、writer lease 等运行实现及验证 | | M1 私有文件 owner | 文件准入、稳定锁、记录 CAS 与故障清理;三视角复审通过;文件测试 31 项通过 | 配额和公共发现/连接协调仍待接线 | -| M1 持久名称预留 | SQLite schema/namespace、名称与操作唯一、服务复用键、分页查询及崩溃恢复;三视角复审通过 | 实例代际、操作结果对账与启动/停止编排尚未实现 | +| M1 持久名称预留 | SQLite schema/namespace、名称与操作唯一、服务复用键、分页查询及崩溃恢复;三视角复审通过 | 操作结果对账与启动/停止编排尚未实现 | +| M2 代际协调 | per-service fence、持久 prepare/commit/abort/stop、三项结算事实及干净停止后换代 | native 身份/启动交接、异常 retire/recovery admission、真实后台激活均未完成 | | 一条命令/后台/全局名/多 Tab/stop | 仅 G16 既有显式能力 | M1–M3 全部接线 | | Session 唯一写入与默认历史 | 缺运行期跨进程合同实现 | writer lease + canonical catalog + 双进程测试 | | 完整共享 Harnesstui/Markdown | 草案与本文接缝 | M3 代码与 Embedded 非回退 | @@ -322,3 +323,32 @@ CI 计划提示的跨平台/host-runtime 验收仍不因这次本地切片而宣 本次聚焦共 125 passed(其中登记库 19 项),Ruff、managed 六个模块 mypy、 依赖图新鲜度通过。未重复将未改动的 Product/终端路径广泛套件算作本切片 新证据;完整远端门禁仍是最终 PR 合并前置。 + +## 12. M2 持久代际协调切片 + +`ManagedServiceJournalV1` 借用 registry,拥有独立的每服务 lifecycle 目录; +锁顺序固定为服务 fence → 短 registry 事务,数据库提交前再次验证服务锁 +身份。不持锁等待 RPC 或进程。组合入口必须保证同 namespace/service +恒映射同一个稳定 lifecycle 根,不能由 runtime 覆盖改变此根。 + +实例记录进入私有 SQLite schema v2;尚未激活的 v1 测试库显式拒绝, +不静默迁移。格式版本与公共 `V1` API 版本分离。记录包含完整 instance/ +attempt、单调 revision、handoff phase、stop fence 和三项结算观察,不含 +PID 或连接 authority。prepare 需完整匹配旧观察;commit 回复丢失只能读取 +当前状态,不能因此重复 spawn 或 abort 已提交实例。迟到的旧实例请求不能 +修改新代;停止事实按同一实例单调汇合,控制更新可使用保留容量。 + +三视角确认此事务/竞态切片通过,但生命周期评审保留一个 **P2 激活前置**: +当前 prepare 只允许干净停止后的复用。真实崩溃后即使精确确认 leader/scope +退出,也不能伪造 `application_cleanup_completed=True`。异常实例的 +retire/recovery admission 必须另行实现:区分干净停止成功与安全恢复准入, +保留 unclean 诊断,取得旧资源及 Session 写入权安全回收证明后,才允许 +进入 G13 恢复。该工作属于原完整目标,不能因当前严格拒绝换代就删去。 + +本切片不声称实现了 native 后台、服务崩溃后 start、真实 SSH 续存或进程 +终止权限。下一阶段需将 native handoff 和异常恢复准入共同接入,再执行 +父死/子死/后代存活/断连矩阵。 + +本次全部 managed 与两份精确架构清单共 137 passed,其中 lifecycle 12 项; +Ruff、managed 七模块 mypy、文档 6 项与依赖图新鲜度通过。三视角允许当前 +未激活切片本地提交,异常恢复 P2 仍未关闭,不作为最终交付通过依据。 diff --git a/docs/internals/architecture/drafts/lmux-managed-service-design.md b/docs/internals/architecture/drafts/lmux-managed-service-design.md index 498d6b771..76aa3167f 100644 --- a/docs/internals/architecture/drafts/lmux-managed-service-design.md +++ b/docs/internals/architecture/drafts/lmux-managed-service-design.md @@ -13,7 +13,7 @@ - Design status: proposed - Review status: base and shared-client/Harnesstui reuse revision both passed three-perspective re-review as M0 design input, 2026-09-13 -- Implementation status: partial; M0 values, M1 private files and name intents, no activation +- Implementation status: partial; storage/name intents and clean-instance coordination, no activation - Owner: Loushang application architecture - Delivery objective: active local Linux lmux baseline; implementation tracked by LMUX-M0 diff --git a/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md b/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md index 8462d66ea..11ed25161 100644 --- a/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md +++ b/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md @@ -63,6 +63,7 @@ lease, or factory. | optional managed-deployment values | `src/loushang/apphost/managed/__init__.py`, `src/loushang/apphost/managed/contracts.py` and `src/loushang/apphost/managed/paths.py` | closed pure namespace/service/instance identities, handoff transitions, three-fact stop evidence and injected layout; no facade/CLI activation or native authority | | optional Linux managed-file owner | `src/loushang/apphost/managed/_files.py` | private canonical directory/regular-file admission, stable nonblocking locks and bounded identity-checked record publication; uncomposed, not a service registry or process owner | | optional managed name registry | `src/loushang/apphost/managed/_database.py` and `src/loushang/apphost/managed/registry.py` | bounded SQLite name intents and Product/workspace/profile reuse index with exact operation matching; no native liveness authority, process activation or CLI composition | +| optional managed instance journal | `src/loushang/apphost/managed/lifecycle.py` | stable per-service fence and durable instance generations, handoff/stop transitions and matching monotonic settlement observations; neither native process identity nor permission to terminate a process | | AppServer Product ports | `src/loushang/appserver/ports.py` | immutable contract-only identity and Product-supplied Session/projection/work/interaction port bundle; no runtime, listener, protocol, or composition owner | | Session discovery composition | `src/loushang/harness/transcript/directory.py`, `src/loushang/harness/machine_resources/control_plane.py`, and `src/loushang/coding/cli/__main__.py` | canonical global plus cwd/home compatibility sources are selected at composition; cwd is a query/filter and compatibility roots are not writable authorities | | Linux/Windows Coding Product canary | `src/loushang/coding/_product_worker_canary.py` | sole explicit C5.4/C5.5c Product root; joins Coding Product/Session evidence to the real lifecycle coordinator, exact native-profile friend, Worker health, Capability publication, normal close, rollback, and recovery; omission remains Current and unlisted profiles remain closed | diff --git a/src/loushang/apphost/managed/_database.py b/src/loushang/apphost/managed/_database.py index eec8f0856..ab3ed45b3 100644 --- a/src/loushang/apphost/managed/_database.py +++ b/src/loushang/apphost/managed/_database.py @@ -30,7 +30,7 @@ PAGE_SIZE = 4096 _LOCK = "registry.lock" _APPLICATION_ID = 0x4C4D5558 -_VERSION = 1 +_VERSION = 2 _SCHEMA = ( "CREATE TABLE identity (namespace TEXT PRIMARY KEY NOT NULL) WITHOUT ROWID", "CREATE TABLE services (service_id TEXT PRIMARY KEY NOT NULL, " @@ -39,11 +39,17 @@ "CREATE TABLE muxes (name TEXT PRIMARY KEY NOT NULL, " "service_id TEXT NOT NULL REFERENCES services(service_id), " "operation_id TEXT NOT NULL UNIQUE) WITHOUT ROWID", + "CREATE TABLE instances (service_id TEXT PRIMARY KEY NOT NULL REFERENCES services(service_id), " + "revision INTEGER NOT NULL CHECK(revision>0), instance_id TEXT NOT NULL, " + "attempt_id TEXT NOT NULL, phase TEXT NOT NULL, stop_requested INTEGER NOT NULL, " + "process_exited INTEGER NOT NULL, application_cleanup_completed INTEGER NOT NULL, " + "process_scope_settled INTEGER NOT NULL) WITHOUT ROWID", ) _SCHEMA_ROWS = tuple(sorted(( ("table", "identity", "identity", _SCHEMA[0]), ("table", "services", "services", _SCHEMA[1]), ("table", "muxes", "muxes", _SCHEMA[2]), + ("table", "instances", "instances", _SCHEMA[3]), ))) @@ -246,3 +252,8 @@ def admit_growth(self, connection: sqlite3.Connection) -> None: """Call after idempotent lookup but before a bounded actual mutation.""" self._admit_capacity(connection, self._directory._check(), growth=128 * 1024, normal=True) + + def admit_control(self, connection: sqlite3.Connection) -> None: + """Small existing-instance updates may consume the control headroom.""" + self._admit_capacity(connection, self._directory._check(), + growth=16 * 1024, normal=False) diff --git a/src/loushang/apphost/managed/lifecycle.py b/src/loushang/apphost/managed/lifecycle.py new file mode 100644 index 000000000..131516851 --- /dev/null +++ b/src/loushang/apphost/managed/lifecycle.py @@ -0,0 +1,251 @@ +"""Durable instance generations under one stable service lifecycle fence. + +These records coordinate trusted owners. They neither discover live processes +nor confer permission to kill one. A child-side owner alone proposes commit; +the eventual Hosting integration supplies native identity and settlement facts. +""" + +from __future__ import annotations + +import sqlite3 +from collections.abc import Callable +from dataclasses import dataclass, replace +from pathlib import Path +from secrets import token_hex + +from ._files import ManagedStorageError, PrivateManagedDirectory +from .contracts import ( + _HEX32, + ManagedContractError, + ManagedHandoffPhaseV1, + ManagedHandoffV1, + ManagedInstanceRefV1, + ManagedNamespaceV1, + ManagedServiceKeyV1, + ManagedStopEvidenceV1, + _match, +) +from .registry import ManagedRegistryV1 + +_MAX_REVISION = 2**63 - 1 + + +@dataclass(frozen=True, slots=True) +class ManagedServiceStateV1: + """Last durable observation; not an assertion that an instance is alive.""" + + revision: int + handoff: ManagedHandoffV1 + evidence: ManagedStopEvidenceV1 + + def __post_init__(self) -> None: + if ( + type(self.revision) is not int or not 1 <= self.revision <= _MAX_REVISION + or type(self.handoff) is not ManagedHandoffV1 + or type(self.evidence) is not ManagedStopEvidenceV1 + or self.handoff.instance != self.evidence.instance + ): + raise ManagedContractError() + if ( + not self.handoff.stop_requested + and self.handoff.phase is not ManagedHandoffPhaseV1.ABORTING + and (self.evidence.process_exited or self.evidence.application_cleanup_completed + or self.evidence.process_scope_settled) + ): + raise ManagedContractError() + + @property + def cleanly_stopped(self) -> bool: + return self.evidence.cleanly_stopped + + +class ManagedServiceJournalV1: + """Borrow a registry; own only the injected per-service fence directory. + + Lock order is service fence, then short registry transaction. No network IO, + process wait or caller callback is performed while either lock is held. + This slice permits only clean reuse. Unclean retirement/recovery admission + still needs a separate native proof; never invent application cleanup facts. + """ + + def __init__( + self, registry: ManagedRegistryV1, namespace: ManagedNamespaceV1, + service: ManagedServiceKeyV1, lifecycle_root: Path, *, create: bool = False, + ) -> None: + if (type(registry) is not ManagedRegistryV1 or type(namespace) is not ManagedNamespaceV1 + or type(service) is not ManagedServiceKeyV1): + raise ManagedContractError() + self._database = registry._database + if self._database._namespace != namespace.namespace_key: + raise ManagedContractError() + self._namespace, self._service = namespace, service + self._fence = PrivateManagedDirectory(lifecycle_root, create=create) + try: + if create: + with self._fence.lock("lifecycle.lock", create=True): + pass + except BaseException as error: + try: + self._fence.close() + except BaseException: + error.add_note("managed_lifecycle_cleanup_incomplete") + raise + + def close(self) -> None: + self._fence.close() + + def read(self) -> ManagedServiceStateV1 | None: + with self._fence.lock("lifecycle.lock"), self._database.transaction() as connection: + result = self._read(connection) + self._fence._check() + return result + + def prepare( + self, attempt_id: str, *, expected: ManagedServiceStateV1 | None, + ) -> ManagedServiceStateV1: + """Reserve a fresh generation, never infer absence from socket/PID loss. + + An uncertain result is resolved by read(), not by repeating process spawn. + Existing generations require all three matching stop facts before reuse. + """ + _match(attempt_id, _HEX32) + if expected is not None and type(expected) is not ManagedServiceStateV1: + raise ManagedContractError() + with self._fence.lock("lifecycle.lock"), self._database.transaction(write=True) as connection: + current = self._read(connection) + if current != expected: + raise ManagedStorageError("conflict") + if current is not None: + if not current.cleanly_stopped: + raise ManagedStorageError("busy") + if attempt_id == current.handoff.attempt_id: + raise ManagedStorageError("conflict") + self._database.admit_growth(connection) + instance_id = token_hex(16) + if current is not None and instance_id == current.handoff.instance.instance_id: + raise ManagedStorageError("conflict") + instance = ManagedInstanceRefV1(self._namespace.namespace_key, + self._service.service_id, instance_id) + state = ManagedServiceStateV1( + _next_revision(current), ManagedHandoffV1(instance, attempt_id), + ManagedStopEvidenceV1(instance, False, False, False), + ) + self._save(connection, state, insert=current is None) + self._fence._check() + return state + + def commit(self, instance: ManagedInstanceRefV1, attempt_id: str) -> ManagedServiceStateV1: + """Persist the child's proposal; a stop fence or abort wins by refusing it.""" + _match(attempt_id, _HEX32) + + def update(current: ManagedServiceStateV1) -> ManagedServiceStateV1: + if current.handoff.attempt_id != attempt_id: + raise ManagedStorageError("conflict") + return replace(current, handoff=current.handoff.commit()) + + return self._update(instance, update) + + def abort(self, instance: ManagedInstanceRefV1, attempt_id: str) -> ManagedServiceStateV1: + _match(attempt_id, _HEX32) + + def update(current: ManagedServiceStateV1) -> ManagedServiceStateV1: + if current.handoff.attempt_id != attempt_id: + raise ManagedStorageError("conflict") + return replace(current, handoff=current.handoff.abort()) + + return self._update(instance, update) + + def request_stop(self, instance: ManagedInstanceRefV1) -> ManagedServiceStateV1: + return self._update(instance, lambda current: replace( + current, handoff=current.handoff.request_stop(), + )) + + def record_stop_evidence(self, evidence: ManagedStopEvidenceV1) -> ManagedServiceStateV1: + """Join monotonic facts from the trusted native/application observers.""" + if type(evidence) is not ManagedStopEvidenceV1: + raise ManagedContractError() + + def update(current: ManagedServiceStateV1) -> ManagedServiceStateV1: + if not current.handoff.stop_requested and current.handoff.phase is not ManagedHandoffPhaseV1.ABORTING: + raise ManagedStorageError("conflict") + prior = current.evidence + joined = ManagedStopEvidenceV1( + evidence.instance, prior.process_exited or evidence.process_exited, + prior.application_cleanup_completed or evidence.application_cleanup_completed, + prior.process_scope_settled or evidence.process_scope_settled, + ) + return replace(current, evidence=joined) + + return self._update(evidence.instance, update) + + def _update( + self, instance: ManagedInstanceRefV1, + update: Callable[[ManagedServiceStateV1], ManagedServiceStateV1], + ) -> ManagedServiceStateV1: + self._require_instance(instance) + with self._fence.lock("lifecycle.lock"), self._database.transaction(write=True) as connection: + current = self._read(connection) + if current is None or current.handoff.instance != instance: + raise ManagedStorageError("conflict") + try: + proposed = update(current) + except ManagedContractError: + raise ManagedStorageError("conflict") from None + if proposed == current: + self._fence._check() + return current + self._database.admit_control(connection) + proposed = replace(proposed, revision=_next_revision(current)) + self._save(connection, proposed, insert=False) + self._fence._check() + return proposed + + def _require_instance(self, instance: ManagedInstanceRefV1) -> None: + if (type(instance) is not ManagedInstanceRefV1 + or instance.namespace_key != self._namespace.namespace_key + or instance.service_id != self._service.service_id): + raise ManagedContractError() + + def _read(self, connection: sqlite3.Connection) -> ManagedServiceStateV1 | None: + service = self._service + key = connection.execute("SELECT product, workspace, profile FROM services WHERE service_id=?", + (service.service_id,)).fetchone() + if key != (service.product_id, service.workspace, service.profile): + raise ManagedStorageError("conflict") + row = connection.execute("SELECT revision, instance_id, attempt_id, phase, stop_requested, " + "process_exited, application_cleanup_completed, process_scope_settled " + "FROM instances WHERE service_id=?", (service.service_id,)).fetchone() + if row is None: + return None + try: + if any(type(value) is not int or value not in (0, 1) for value in row[4:]): + raise ManagedContractError() + instance = ManagedInstanceRefV1(self._namespace.namespace_key, service.service_id, row[1]) + handoff = ManagedHandoffV1(instance, row[2], ManagedHandoffPhaseV1(row[3]), bool(row[4])) + return ManagedServiceStateV1(row[0], handoff, ManagedStopEvidenceV1( + instance, bool(row[5]), bool(row[6]), bool(row[7]), + )) + except (ManagedContractError, ValueError, TypeError): + raise ManagedStorageError("invalid_record") from None + + def _save(self, connection: sqlite3.Connection, state: ManagedServiceStateV1, *, insert: bool) -> None: + handoff, evidence = state.handoff, state.evidence + values = (state.revision, handoff.instance.instance_id, handoff.attempt_id, handoff.phase.value, + int(handoff.stop_requested), int(evidence.process_exited), + int(evidence.application_cleanup_completed), int(evidence.process_scope_settled), + self._service.service_id) + if insert: + connection.execute("INSERT INTO instances (revision, instance_id, attempt_id, phase, " + "stop_requested, process_exited, application_cleanup_completed, " + "process_scope_settled, service_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", values) + else: + connection.execute("UPDATE instances SET revision=?, instance_id=?, attempt_id=?, phase=?, " + "stop_requested=?, process_exited=?, application_cleanup_completed=?, " + "process_scope_settled=? WHERE service_id=?", values) + + +def _next_revision(current: ManagedServiceStateV1 | None) -> int: + revision = 1 if current is None else current.revision + 1 + if revision > _MAX_REVISION: + raise ManagedStorageError("capacity") + return revision diff --git a/tests/apphost/test_managed_lifecycle.py b/tests/apphost/test_managed_lifecycle.py new file mode 100644 index 000000000..74a3b4e98 --- /dev/null +++ b/tests/apphost/test_managed_lifecycle.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import os +import sqlite3 +import subprocess +import sys +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from loushang.apphost.managed._database import DATABASE_NAME +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.contracts import ( + ManagedContractError, + ManagedHandoffPhaseV1, + ManagedNamespaceV1, + ManagedServiceKeyV1, + ManagedStopEvidenceV1, +) +from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 +from loushang.apphost.managed.registry import ManagedMuxReservationV1, ManagedRegistryV1 + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed lifecycle") + + +@pytest.fixture +def owners(tmp_path): + namespace = ManagedNamespaceV1(str(tmp_path / "platform"), os.geteuid(), "a" * 32) + service = ManagedServiceKeyV1("coding", "/workspace") + registry = ManagedRegistryV1(tmp_path / "registry", namespace, create=True) + registry.reserve_mux(ManagedMuxReservationV1("dev", service, "b" * 32)) + journal = ManagedServiceJournalV1(registry, namespace, service, tmp_path / "fence", create=True) + try: + yield registry, journal, namespace, service + finally: + journal.close() + registry.close() + + +def test_prepare_is_durable_and_never_repeated_as_another_spawn(owners, tmp_path): + registry, journal, namespace, service = owners + assert journal.read() is None + prepared = journal.prepare("c" * 32, expected=None) + assert prepared.revision == 1 + assert prepared.handoff.phase is ManagedHandoffPhaseV1.PROVISIONAL + journal.close() + other = ManagedServiceJournalV1(registry, namespace, service, tmp_path / "fence") + try: + assert other.read() == prepared + with pytest.raises(ManagedStorageError, match="conflict"): + other.prepare("c" * 32, expected=None) + with pytest.raises(ManagedStorageError, match="busy"): + other.prepare("d" * 32, expected=prepared) + finally: + other.close() + + +def test_commit_ack_loss_is_observed_not_aborted(owners): + _, journal, _, _ = owners + prepared = journal.prepare("c" * 32, expected=None) + reference = prepared.handoff.instance + committed = journal.commit(reference, "c" * 32) + assert committed.handoff.phase is ManagedHandoffPhaseV1.COMMITTED + assert journal.read() == committed + assert journal.commit(reference, "c" * 32) == committed + with pytest.raises(ManagedStorageError, match="conflict"): + journal.abort(reference, "c" * 32) + with pytest.raises(ManagedStorageError, match="busy"): + journal.prepare("d" * 32, expected=committed) + + +@pytest.mark.parametrize("first", ["stop", "abort"]) +def test_stop_or_abort_before_commit_fences_child(owners, first): + _, journal, _, _ = owners + prepared = journal.prepare("c" * 32, expected=None) + reference = prepared.handoff.instance + if first == "stop": + stopped = journal.request_stop(reference) + assert journal.request_stop(reference) == stopped + else: + stopped = journal.abort(reference, "c" * 32) + assert journal.abort(reference, "c" * 32) == stopped + with pytest.raises(ManagedStorageError, match="conflict"): + journal.commit(reference, "c" * 32) + assert journal.read() == stopped + + +@pytest.mark.parametrize("partial", [(True, True, False), (True, False, True)]) +def test_restart_requires_all_three_facts_and_old_generation_cannot_mutate_new(owners, partial): + _, journal, _, _ = owners + first = journal.prepare("c" * 32, expected=None) + reference = first.handoff.instance + journal.commit(reference, "c" * 32) + with pytest.raises(ManagedStorageError, match="conflict"): + journal.record_stop_evidence(ManagedStopEvidenceV1(reference, True, True, True)) + journal.request_stop(reference) + incomplete = journal.record_stop_evidence(ManagedStopEvidenceV1(reference, *partial)) + assert not incomplete.cleanly_stopped + with pytest.raises(ManagedStorageError, match="busy"): + journal.prepare("d" * 32, expected=incomplete) + complete = journal.record_stop_evidence(ManagedStopEvidenceV1(reference, *(not item for item in partial))) + assert complete.cleanly_stopped + assert journal.record_stop_evidence(ManagedStopEvidenceV1(reference, False, False, False)) == complete + new = journal.prepare("d" * 32, expected=complete) + assert new.revision > complete.revision + assert new.handoff.instance != reference + for call in (lambda: journal.request_stop(reference), + lambda: journal.abort(reference, "c" * 32), + lambda: journal.commit(reference, "c" * 32), + lambda: journal.record_stop_evidence(complete.evidence)): + with pytest.raises(ManagedStorageError, match="conflict"): + call() + assert journal.read() == new + + +def test_attempt_and_namespace_mismatch_never_commit(owners): + _, journal, _, _ = owners + prepared = journal.prepare("c" * 32, expected=None) + with pytest.raises(ManagedStorageError, match="conflict"): + journal.commit(prepared.handoff.instance, "d" * 32) + with pytest.raises(ManagedContractError): + journal.request_stop(replace(prepared.handoff.instance, namespace_key="a" * 64)) + assert journal.read() == prepared + + +def test_control_headroom_remains_when_normal_admission_is_closed(owners, monkeypatch): + _, journal, _, _ = owners + prepared = journal.prepare("c" * 32, expected=None) + monkeypatch.setattr(os, "fstatvfs", lambda fd: SimpleNamespace(f_bavail=2560, f_frsize=4096)) + stopped = journal.request_stop(prepared.handoff.instance) + assert stopped.handoff.stop_requested + complete = journal.record_stop_evidence(ManagedStopEvidenceV1(prepared.handoff.instance, True, True, True)) + with pytest.raises(ManagedStorageError, match="capacity"): + journal.prepare("d" * 32, expected=complete) + assert journal.read() == complete + + +def test_corrupt_boolean_observation_is_not_accepted(owners, tmp_path): + _, journal, _, _ = owners + journal.prepare("c" * 32, expected=None) + with sqlite3.connect(tmp_path / "registry" / DATABASE_NAME) as connection: + connection.execute("UPDATE instances SET stop_requested=2") + with pytest.raises(ManagedStorageError, match="invalid_record"): + journal.read() + + +def test_replaced_service_fence_rolls_back_before_database_commit(owners, tmp_path, monkeypatch): + registry, journal, _, _ = owners + prepared = journal.prepare("c" * 32, expected=None) + original_save = journal._save + + def replace_after_update(connection, state, *, insert): + original_save(connection, state, insert=insert) + replacement = tmp_path / "fence" / "replacement" + replacement.write_bytes(b"") + replacement.chmod(0o600) + os.replace(replacement, tmp_path / "fence" / "lifecycle.lock") + + monkeypatch.setattr(journal, "_save", replace_after_update) + with pytest.raises(ManagedStorageError, match="conflict"): + journal.commit(prepared.handoff.instance, "c" * 32) + with registry._database.transaction() as connection: + assert connection.execute("SELECT phase FROM instances").fetchone() == ("provisional",) + + +def test_real_competing_process_only_one_generation_is_reserved(owners, tmp_path): + _, journal, namespace, _ = owners + script = """ +import os, sys +from pathlib import Path +from loushang.apphost.managed.registry import ManagedRegistryV1 +from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 +from loushang.apphost.managed.contracts import ManagedNamespaceV1, ManagedServiceKeyV1 +from loushang.apphost.managed._files import ManagedStorageError +namespace = ManagedNamespaceV1(sys.argv[2], os.geteuid(), 'a'*32) +registry = ManagedRegistryV1(Path(sys.argv[1])/'registry', namespace) +journal = ManagedServiceJournalV1(registry, namespace, ManagedServiceKeyV1('coding', '/workspace'), Path(sys.argv[1])/'fence') +try: + journal.prepare('d'*32, expected=None) + print('reserved') +except ManagedStorageError as error: + print(error.code) +finally: + journal.close() + registry.close() +""" + env = dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[2] / "src")) + with journal._fence.lock("lifecycle.lock"): + busy = subprocess.run([sys.executable, "-c", script, str(tmp_path), namespace.platform_home], + env=env, capture_output=True, text=True, timeout=10) + assert busy.returncode == 0, busy.stderr + assert busy.stdout.strip() == "busy" + prepared = journal.prepare("c" * 32, expected=None) + conflict = subprocess.run([sys.executable, "-c", script, str(tmp_path), namespace.platform_home], + env=env, capture_output=True, text=True, timeout=10) + assert conflict.returncode == 0, conflict.stderr + assert conflict.stdout.strip() == "conflict" + assert journal.read() == prepared + + +def test_unactivated_v1_registry_requires_explicit_upgrade_not_silent_migration(owners, tmp_path): + _, _, namespace, _ = owners + with sqlite3.connect(tmp_path / "registry" / DATABASE_NAME) as connection: + connection.execute("PRAGMA user_version=1") + before = (tmp_path / "registry" / DATABASE_NAME).read_bytes() + with pytest.raises(ManagedStorageError, match="invalid_record"): + ManagedRegistryV1(tmp_path / "registry", namespace, create=True) + assert (tmp_path / "registry" / DATABASE_NAME).read_bytes() == before diff --git a/tests/architecture/test_hosted_product_runtime_v1_baseline.py b/tests/architecture/test_hosted_product_runtime_v1_baseline.py index e71aac3d6..635fe6f94 100644 --- a/tests/architecture/test_hosted_product_runtime_v1_baseline.py +++ b/tests/architecture/test_hosted_product_runtime_v1_baseline.py @@ -78,6 +78,7 @@ APPHOST_SOURCE / "managed/_files.py", APPHOST_SOURCE / "managed/_database.py", APPHOST_SOURCE / "managed/registry.py", + APPHOST_SOURCE / "managed/lifecycle.py", APPSERVER_SOURCE / "ports.py", HARNESS_SOURCE / "machine_resources/control_plane.py", Path("src/loushang/coding/cli/__main__.py"), @@ -447,6 +448,7 @@ def test_current_inventory_matches_source_and_retained_absences() -> None: "managed/_files.py", "managed/_database.py", "managed/registry.py", + "managed/lifecycle.py", } assert { path.relative_to(APPSERVER_SOURCE).as_posix() diff --git a/tests/architecture/test_hosting_architecture_baseline.py b/tests/architecture/test_hosting_architecture_baseline.py index 4cdb97895..3e5ff2d52 100644 --- a/tests/architecture/test_hosting_architecture_baseline.py +++ b/tests/architecture/test_hosting_architecture_baseline.py @@ -717,6 +717,7 @@ def test_hosting_apphost_and_appserver_optional_modules_are_exact() -> None: "managed/_files.py", "managed/_database.py", "managed/registry.py", + "managed/lifecycle.py", "router.py", "runtime.py", "hosted.py", From 0b8f33d41217474942b24ad6606e666df16154ae Mon Sep 17 00:00:00 2001 From: zhnt Date: Sun, 13 Sep 2026 07:15:05 +0000 Subject: [PATCH 04/44] feat(hosting): retain exact Linux service exit observations --- .../architecture/apphost/lmux-contract-m0.md | 41 ++- .../hosted-application-support-boundary.md | 15 +- .../hosted-product-runtime-v1-inventory.md | 1 + src/loushang/hosting/service.py | 264 ++++++++++++++++ ...test_hosted_product_runtime_v1_baseline.py | 1 + .../architecture/test_hosting_h0_contract.py | 6 + tests/hosting/test_linux_service_observer.py | 291 ++++++++++++++++++ 7 files changed, 610 insertions(+), 9 deletions(-) create mode 100644 src/loushang/hosting/service.py create mode 100644 tests/hosting/test_linux_service_observer.py diff --git a/docs/internals/architecture/apphost/lmux-contract-m0.md b/docs/internals/architecture/apphost/lmux-contract-m0.md index 317fa3c65..5b16bb69d 100644 --- a/docs/internals/architecture/apphost/lmux-contract-m0.md +++ b/docs/internals/architecture/apphost/lmux-contract-m0.md @@ -8,8 +8,8 @@ - ID: `LMUX-M0` - Authority: normative — accepted limited Linux managed-profile contract - Design status: accepted -- Review status: three-perspective storage/registry/lifecycle slice reviews passed; recovery admission pending -- Implementation status: partial — storage, name intents and clean instance generations; no activation +- Review status: three-perspective storage/lifecycle/observer slice reviews passed; recovery admission pending +- Implementation status: partial — storage, instance coordination and Linux exit observations; no activation - Owner: AppHost managed deployment; sibling changes remain sibling-owned - Tracking objective: active Linux lmux goal, branch `harness/lmux-managed-service` @@ -236,7 +236,8 @@ Mux 外壳只负责成员/连接组合。主题与终端能力在客户端组合 | M0 合同 | 三视角修订后通过;纯值/状态/路径 56 项测试、Ruff/mypy 通过 | native admission、持久 CAS、writer lease 等运行实现及验证 | | M1 私有文件 owner | 文件准入、稳定锁、记录 CAS 与故障清理;三视角复审通过;文件测试 31 项通过 | 配额和公共发现/连接协调仍待接线 | | M1 持久名称预留 | SQLite schema/namespace、名称与操作唯一、服务复用键、分页查询及崩溃恢复;三视角复审通过 | 操作结果对账与启动/停止编排尚未实现 | -| M2 代际协调 | per-service fence、持久 prepare/commit/abort/stop、三项结算事实及干净停止后换代 | native 身份/启动交接、异常 retire/recovery admission、真实后台激活均未完成 | +| M2 代际协调 | per-service fence、持久 prepare/commit/abort/stop、三项结算事实及干净停止后换代 | native 启动交接、异常 retire/recovery admission、真实后台激活均未完成 | +| M2 Linux 退出观察 | boot/PID/start-time/实际 UID/PID namespace 与保留 pidfd;26 项原生回归、三视角复审通过 | 原生 spawn/handoff、进程树结算及异常恢复准入仍待接线 | | 一条命令/后台/全局名/多 Tab/stop | 仅 G16 既有显式能力 | M1–M3 全部接线 | | Session 唯一写入与默认历史 | 缺运行期跨进程合同实现 | writer lease + canonical catalog + 双进程测试 | | 完整共享 Harnesstui/Markdown | 草案与本文接缝 | M3 代码与 Embedded 非回退 | @@ -352,3 +353,37 @@ retire/recovery admission 必须另行实现:区分干净停止成功与安全 本次全部 managed 与两份精确架构清单共 137 passed,其中 lifecycle 12 项; Ruff、managed 七模块 mypy、文档 6 项与依赖图新鲜度通过。三视角允许当前 未激活切片本地提交,异常恢复 P2 仍未关闭,不作为最终交付通过依据。 + +## 13. M2 Linux 身份与退出观察 + +可选 `hosting.service` 新增 `LinuxServiceIdentityV1` 与 +`LinuxServiceObserverV1.capture/reopen/exited/close`。身份值只是定位事实; +reopen 必须重新验证 boot、PID/start-time、实际 UID、调用者 PID namespace, +在 pidfd 获取前后双观察并绑定 fdinfo PID。`/proc` 缺失或 PID 记录存在均 +不能推断当前退出;只以保留 pidfd 的退出事件给出 leader/thread-group 事实。 + +实际 UID 从保留 proc 目录 fd 下的 status 读取,不能由目录 owner 替代; +该 same-user profile 要求 real/effective/saved/fs UID 一致且匹配调用者。 +本机 standalone uv Python 没有 `os.pidfd_open`,但 libc 支持同一 API; +采用 Linux-only、lazy libc 适配,不猜系统调用号、不退回 PID 轮询。 + +等待预算包含 mutex 获取与 poll;关闭先设置 fence 阻止新等待,未及时取得 +锁时保留句柄供重试。close 只关观察句柄,不发任何信号。主进程退出、后代 +继续持有资源时不得宣称 scope 或应用已结算。此模块无 AppHost/AppServer/ +Product 依赖,不从 Hosting 根 facade 激活,不改变既有 ProcessLease.close。 + +三视角指出并修复:真实 UID 与目录 owner 的区别、超大整数 timeout 的 +封闭错误校验、mutex 等待预算及并发 close fence。26 项真实 Linux 测试 +通过,含独立新客户端 reopen、身份变化拒绝与 fd 回收、主进程退出但后代 +仍活、关闭不发信号、无效/巨大 timeout 和并发等待/关闭;Ruff/mypy 通过。 +这不是 SSH 断连验收、后台 spawn 或异常恢复 P2 的完成证据。 + +原生语义依据:[pidfd_open](https://man7.org/linux/man-pages/man2/pidfd_open.2.html)、 +[proc stat](https://man7.org/linux/man-pages/man5/proc_pid_stat.5.html) 与 +[proc 所有者](https://man7.org/linux/man-pages/man5/proc_pid.5.html)。 + +完整 Hosting 主测试 410 passed / 48 skipped,唯一失败为 H0 精确模块清单 +未登记可选 `service.py`。已显式补清单且新增不从根 facade 导入观察器的 +断言,H0 与交付清单聚焦复测 15 passed;Hosting Ruff/mypy 27 模块、 +文档 6 项与依赖图新鲜度通过。不将该聚焦修复标为完整 check-hosting 命令 +重跑成功。CI 计划选中的更广泛消费者/跨平台门禁仍是最终集成前置。 diff --git a/docs/internals/architecture/hosting/key-designs/hosted-application-support-boundary.md b/docs/internals/architecture/hosting/key-designs/hosted-application-support-boundary.md index a8158cfad..82609ff59 100644 --- a/docs/internals/architecture/hosting/key-designs/hosted-application-support-boundary.md +++ b/docs/internals/architecture/hosting/key-designs/hosted-application-support-boundary.md @@ -84,8 +84,10 @@ The [accepted lmux M0 contract](../../apphost/lmux-contract-m0.md) now selects one limited Linux managed-service Target from this candidate: independent post-handoff lifetime, exact-instance/scope observations and explicit graceful-only consumer stop. It supplies scope/context, responsibility -discovery, inputs, handoff and validation requirements; only consumer-side -pure values exist so far. Native mechanisms remain unimplemented and inactive. +discovery, inputs, handoff and validation requirements. Consumer-side storage, +name and clean-instance coordination plus the optional Linux identity/exit +observer are implemented. Native spawn/handoff/tree ownership and abnormal +recovery admission remain unimplemented; the managed profile is inactive. The general controller (automatic restart, system installation and other platforms) below remains a candidate, not implicitly promoted with lmux. @@ -97,10 +99,11 @@ committed/unknown instances cannot be reclaimed on a missing acknowledgement. The existing Process/ChildSession close contract is unchanged. Hosting still owns no application registry, authentication, Session state or log retention. -`hosting.service` is a candidate namespace, not an accepted package or sixth -baseline component. It becomes eligible only when a named local service must -outlive its launching client and requires independent start, stop, restart, or -reconcile operations. +`hosting.service` now contains only the accepted optional Linux identity and +pidfd exit observer. It is not a sixth baseline component or a general Service +Instance Controller. Closing an observer only releases its fd: it neither +signals a process nor asserts process-tree/application settlement. The broader +controller below remains a candidate beyond the limited lmux Target. The candidate Service Instance Controller would own: diff --git a/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md b/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md index 11ed25161..70f6504e5 100644 --- a/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md +++ b/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md @@ -64,6 +64,7 @@ lease, or factory. | optional Linux managed-file owner | `src/loushang/apphost/managed/_files.py` | private canonical directory/regular-file admission, stable nonblocking locks and bounded identity-checked record publication; uncomposed, not a service registry or process owner | | optional managed name registry | `src/loushang/apphost/managed/_database.py` and `src/loushang/apphost/managed/registry.py` | bounded SQLite name intents and Product/workspace/profile reuse index with exact operation matching; no native liveness authority, process activation or CLI composition | | optional managed instance journal | `src/loushang/apphost/managed/lifecycle.py` | stable per-service fence and durable instance generations, handoff/stop transitions and matching monotonic settlement observations; neither native process identity nor permission to terminate a process | +| optional Linux service observation | `src/loushang/hosting/service.py` | boot/PID/start-time/UID/namespace lookup facts with retained pidfd capture, re-admission and bounded exit observation; no process launch, signal, process-tree settlement or facade activation | | AppServer Product ports | `src/loushang/appserver/ports.py` | immutable contract-only identity and Product-supplied Session/projection/work/interaction port bundle; no runtime, listener, protocol, or composition owner | | Session discovery composition | `src/loushang/harness/transcript/directory.py`, `src/loushang/harness/machine_resources/control_plane.py`, and `src/loushang/coding/cli/__main__.py` | canonical global plus cwd/home compatibility sources are selected at composition; cwd is a query/filter and compatibility roots are not writable authorities | | Linux/Windows Coding Product canary | `src/loushang/coding/_product_worker_canary.py` | sole explicit C5.4/C5.5c Product root; joins Coding Product/Session evidence to the real lifecycle coordinator, exact native-profile friend, Worker health, Capability publication, normal close, rollback, and recovery; omission remains Current and unlisted profiles remain closed | diff --git a/src/loushang/hosting/service.py b/src/loushang/hosting/service.py new file mode 100644 index 000000000..9df612497 --- /dev/null +++ b/src/loushang/hosting/service.py @@ -0,0 +1,264 @@ +"""Optional Linux service identity and retained process-exit observations. + +No root-facade activation. These observations are not process-tree ownership, +application settlement, or permission to terminate a process. In particular, +closing an observer never signals the observed service. + +Kernel contracts: https://man7.org/linux/man-pages/man2/pidfd_open.2.html and +https://man7.org/linux/man-pages/man5/proc_pid_stat.5.html . +""" + +from __future__ import annotations + +import math +import os +import re +import select +import sys +from dataclasses import dataclass, field +from threading import Event, RLock +from time import monotonic + +from .errors import HostingError, HostingFailureCategory + +_UUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\Z") +_PROC_LIMIT = 4096 + + +@dataclass(frozen=True, slots=True) +class LinuxServiceIdentityV1: + """Native lookup facts; values alone do not establish current liveness.""" + + pid: int = field(repr=False) + start_ticks: int + boot_id: str + user_id: int + pid_namespace_device: int + pid_namespace_inode: int + + def __post_init__(self) -> None: + if ( + type(self.pid) is not int or not 1 <= self.pid < 2**31 + or type(self.boot_id) is not str or _UUID.fullmatch(self.boot_id) is None + or any(type(value) is not int or not 0 <= value < 2**64 for value in ( + self.start_ticks, self.user_id, self.pid_namespace_device, self.pid_namespace_inode, + )) + or self.pid_namespace_inode == 0 + ): + raise _error(HostingFailureCategory.INVALID_REQUEST) + + +class LinuxServiceObserverV1: + """One private pidfd. Cross-thread wait/close is bounded and serialized.""" + + _fd: int | None + _identity: LinuxServiceIdentityV1 + _mutex: RLock + _closing: Event + + def __init__(self) -> None: + raise TypeError("use capture or reopen") + + @classmethod + def capture(cls, pid: int) -> LinuxServiceObserverV1: + """Capture an explicitly supplied PID; no process enumeration or spawn.""" + return cls._open(pid, None) + + @classmethod + def reopen(cls, expected: LinuxServiceIdentityV1) -> LinuxServiceObserverV1: + """Re-admit a stored identity; missing/mismatched facts remain unknown.""" + if type(expected) is not LinuxServiceIdentityV1: + raise _error(HostingFailureCategory.INVALID_REQUEST) + return cls._open(expected.pid, expected) + + @classmethod + def _open(cls, pid: int, expected: LinuxServiceIdentityV1 | None) -> LinuxServiceObserverV1: + if sys.platform != "linux": + raise _error(HostingFailureCategory.PLATFORM_UNSUPPORTED) + if type(pid) is not int or not 1 <= pid < 2**31: + raise _error(HostingFailureCategory.INVALID_REQUEST) + descriptor = None + try: + before = _observe(pid) + if expected is not None and before != expected: + raise _error(HostingFailureCategory.PREPARATION_STALE) + descriptor = _open_pidfd(pid) + os.set_inheritable(descriptor, False) + after = _observe(pid) + if after != before or _pidfd_pid(descriptor) != pid: + raise _error(HostingFailureCategory.PREPARATION_STALE) + owner = object.__new__(cls) + owner._fd = descriptor + owner._identity = after + owner._mutex = RLock() + owner._closing = Event() + descriptor = None + return owner + except (OSError, UnicodeError): + raise _error(HostingFailureCategory.PREPARATION_FAILED) from None + finally: + if descriptor is not None: + _close_fd(descriptor) + + @property + def identity(self) -> LinuxServiceIdentityV1: + return self._identity + + def exited(self, *, timeout: float = 0.0) -> bool: + """Wait at most 30 seconds for leader/thread-group exit, not scope exit. + + False means only no exit event was observed during this call. Missing + proc files, timeout, and socket loss are never converted into True. + """ + if type(timeout) not in (int, float) or not 0 <= timeout <= 30: + raise _error(HostingFailureCategory.INVALID_REQUEST) + deadline = monotonic() + timeout + if self._closing.is_set(): + raise _error(HostingFailureCategory.HOST_CLOSED) + if not self._mutex.acquire(timeout=timeout): + raise _error(HostingFailureCategory.CAPACITY_EXHAUSTED) + try: + if self._closing.is_set() or self._fd is None: + raise _error(HostingFailureCategory.HOST_CLOSED) + try: + poller = select.poll() + poller.register(self._fd, select.POLLIN) + events = poller.poll(math.floor(max(0.0, deadline - monotonic()) * 1000)) + except OSError: + raise _error(HostingFailureCategory.PREPARATION_FAILED) from None + for _, event in events: + if event & (select.POLLERR | select.POLLNVAL): + raise _error(HostingFailureCategory.PREPARATION_FAILED) + if event & (select.POLLIN | select.POLLHUP): + return True + return False + finally: + self._mutex.release() + + def close(self) -> None: + self._closing.set() + if not self._mutex.acquire(timeout=30): + raise _error(HostingFailureCategory.CLEANUP_FAILED) + try: + descriptor, self._fd = self._fd, None + if descriptor is not None: + _close_fd(descriptor) + finally: + self._mutex.release() + + +def _observe(pid: int) -> LinuxServiceIdentityV1: + # Detect a procfs mounted from a different PID namespace before using its + # numeric process directories with pidfd_open in the caller's namespace. + self_stat = _read_file("/proc/self/stat") + _parse_start_ticks(self_stat, os.getpid()) + boot = _read_file("/proc/sys/kernel/random/boot_id", limit=64).strip().decode("ascii") + namespace = os.stat("/proc/self/ns/pid") + directory = os.open(f"/proc/{pid}", os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC) + try: + if os.fstat(directory).st_uid != os.geteuid(): + raise _error(HostingFailureCategory.PREPARATION_REJECTED) + uid = _parse_uid(_read_file("status", parent=directory, limit=16 * 1024)) + if uid != os.geteuid(): + raise _error(HostingFailureCategory.PREPARATION_REJECTED) + ticks = _parse_start_ticks(_read_file("stat", parent=directory), pid) + return LinuxServiceIdentityV1(pid, ticks, boot, uid, namespace.st_dev, namespace.st_ino) + finally: + _close_fd(directory) + + +def _parse_start_ticks(content: bytes, expected_pid: int) -> int: + # comm (field 2) can contain spaces and right parentheses. The final ')' + # ends comm; starttime (field 22) is offset 19 in the remaining fields. + try: + prefix, fields = content.rsplit(b")", 1) + pid, _comm = prefix.split(b" (", 1) + tokens = fields.split() + if int(pid) != expected_pid or len(tokens) < 20 or len(tokens[0]) != 1: + raise ValueError + ticks = int(tokens[19]) + if not 0 <= ticks < 2**64: + raise ValueError + return ticks + except (ValueError, IndexError): + raise _error(HostingFailureCategory.PREPARATION_REJECTED) from None + + +def _parse_uid(content: bytes) -> int: + # Non-dumpable proc directories may be root-owned. Use the actual four + # credentials, and reject set-ID/FSUID transitions for this same-user profile. + # https://man7.org/linux/man-pages/man5/proc_pid.5.html + try: + entries = [line.split(b":", 1)[1].split() for line in content.splitlines() + if line.startswith(b"Uid:")] + if len(entries) != 1 or len(entries[0]) != 4: + raise ValueError + values = [int(value) for value in entries[0]] + if len(set(values)) != 1 or not 0 <= values[0] < 2**32: + raise ValueError + return values[1] + except ValueError: + raise _error(HostingFailureCategory.PREPARATION_REJECTED) from None + + +def _pidfd_pid(descriptor: int) -> int: + try: + lines = _read_file(f"/proc/self/fdinfo/{descriptor}").splitlines() + values = [line.split(b":", 1)[1].strip() for line in lines if line.startswith(b"Pid:")] + if len(values) != 1: + raise ValueError + return int(values[0]) + except ValueError: + raise _error(HostingFailureCategory.PREPARATION_REJECTED) from None + + +def _open_pidfd(pid: int) -> int: + native = getattr(os, "pidfd_open", None) + if callable(native): + return native(pid, 0) + # Some standalone CPython builds omit the wrapper even on a capable + # kernel/libc. Use that exact native API, never guessed syscall numbers or + # weaker /proc polling. All symbol admission remains Linux-only and lazy. + from ctypes import CDLL, c_int, c_uint, get_errno + + library = CDLL(None, use_errno=True) + function = getattr(library, "pidfd_open", None) + if function is None: + raise _error(HostingFailureCategory.PLATFORM_UNSUPPORTED) + function.argtypes = [c_int, c_uint] + function.restype = c_int + descriptor = function(pid, 0) + if descriptor < 0: + raise OSError(get_errno(), "pidfd_open_failed") + return descriptor + + +def _read_file(path: str, *, parent: int | None = None, limit: int = _PROC_LIMIT) -> bytes: + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=parent) + try: + content = bytearray() + while len(content) <= limit: + chunk = os.read(descriptor, limit + 1 - len(content)) + if not chunk: + break + content.extend(chunk) + if len(content) > limit: + raise _error(HostingFailureCategory.READ_BOUND_EXCEEDED) + return bytes(content) + finally: + _close_fd(descriptor) + + +def _close_fd(descriptor: int) -> None: + primary = sys.exception() + try: + os.close(descriptor) + except OSError: + if primary is not None: + primary.add_note("service_observer_cleanup_incomplete") + else: + raise _error(HostingFailureCategory.CLEANUP_FAILED) from None + + +def _error(category: HostingFailureCategory) -> HostingError: + return HostingError(category, "linux_service_" + category.value) diff --git a/tests/architecture/test_hosted_product_runtime_v1_baseline.py b/tests/architecture/test_hosted_product_runtime_v1_baseline.py index 635fe6f94..2ac90003d 100644 --- a/tests/architecture/test_hosted_product_runtime_v1_baseline.py +++ b/tests/architecture/test_hosted_product_runtime_v1_baseline.py @@ -40,6 +40,7 @@ HOSTING_SOURCE / "_posix_launch_preparation.py", HOSTING_SOURCE / "_windows_launch_preparation.py", HOSTING_SOURCE / "_posix_process.py", + HOSTING_SOURCE / "service.py", HOSTING_SOURCE / "_windows_process.py", HOSTING_SOURCE / "_win32_process.py", HOSTING_SOURCE / "_endpoint_host.py", diff --git a/tests/architecture/test_hosting_h0_contract.py b/tests/architecture/test_hosting_h0_contract.py index a4d42965d..7956b6af9 100644 --- a/tests/architecture/test_hosting_h0_contract.py +++ b/tests/architecture/test_hosting_h0_contract.py @@ -36,6 +36,9 @@ HOSTING_ROOT / "_posix_launch_preparation.py", HOSTING_ROOT / "_windows_launch_preparation.py", } +OPTIONAL_MANAGED_SERVICE_MODULES = { + HOSTING_ROOT / "service.py", +} FORBIDDEN_PUBLIC_TERMS = { "Approval", "Authorization", @@ -66,6 +69,7 @@ def test_hosting_package_is_standard_library_only_and_product_neutral() -> None: | H3_PRIVATE_MODULES | H4_PRIVATE_MODULES | H6_PRIVATE_MODULES + | OPTIONAL_MANAGED_SERVICE_MODULES ) assert {path for path in HOSTING_ROOT.rglob("*.py")} == modules @@ -89,6 +93,8 @@ def test_h0_public_surface_exposes_no_platform_or_caller_authority_types() -> No assert forbidden not in public_surface assert all(path.name not in public_surface for path in H1_PRIVATE_MODULES) + assert "LinuxService" not in public_surface + assert "from .service import" not in public_surface def test_h0_observation_contract_has_no_arbitrary_payload_or_environment() -> None: diff --git a/tests/hosting/test_linux_service_observer.py b/tests/hosting/test_linux_service_observer.py new file mode 100644 index 000000000..8ebafa77f --- /dev/null +++ b/tests/hosting/test_linux_service_observer.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import threading +from dataclasses import asdict, replace +from pathlib import Path + +import pytest + +from loushang.hosting.errors import HostingError, HostingFailureCategory +from loushang.hosting.service import ( + LinuxServiceIdentityV1, + LinuxServiceObserverV1, + _parse_start_ticks, +) + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux pidfd service observation") + + +@pytest.fixture +def child(): + process = subprocess.Popen([sys.executable, "-c", "import sys; sys.stdin.buffer.read(1)"], + stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True) + try: + yield process + finally: + process.communicate(timeout=5) + + +def test_capture_reopen_and_exit_with_retained_native_identity(child): + observer = LinuxServiceObserverV1.capture(child.pid) + reopened = LinuxServiceObserverV1.reopen(observer.identity) + try: + assert type(observer.identity) is LinuxServiceIdentityV1 + assert observer.identity.pid == child.pid + assert observer.identity.user_id == os.geteuid() + assert observer.identity == reopened.identity + assert observer._fd != reopened._fd + assert not os.get_inheritable(observer._fd) + assert observer.exited() is False + child.communicate(b"x", timeout=5) + assert observer.exited(timeout=1) is True + assert reopened.exited() is True + finally: + reopened.close() + observer.close() + + +def test_observer_close_never_stops_or_signals_service(child, monkeypatch): + observer = LinuxServiceObserverV1.capture(child.pid) + + def forbidden(*args, **kwargs): + pytest.fail("observer must not send process signals") + + monkeypatch.setattr(os, "kill", forbidden) + monkeypatch.setattr(os, "killpg", forbidden) + observer.close() + observer.close() + assert child.poll() is None + with pytest.raises(HostingError) as caught: + observer.exited() + assert caught.value.category is HostingFailureCategory.HOST_CLOSED + + +@pytest.mark.parametrize("field,value", [ + ("start_ticks", 0), ("boot_id", "00000000-0000-0000-0000-000000000000"), + ("user_id", 2**32 - 1), ("pid_namespace_inode", 1), +]) +def test_stale_lookup_facts_do_not_create_observer(child, field, value): + observer = LinuxServiceObserverV1.capture(child.pid) + try: + with pytest.raises(HostingError) as caught: + LinuxServiceObserverV1.reopen(replace(observer.identity, **{field: value})) + assert caught.value.category is HostingFailureCategory.PREPARATION_STALE + assert child.poll() is None + finally: + observer.close() + + +def test_missing_process_is_unknown_not_proof_of_clean_exit(child): + observer = LinuxServiceObserverV1.capture(child.pid) + identity = observer.identity + child.communicate(b"x", timeout=5) + assert observer.exited(timeout=1) + observer.close() + with pytest.raises(HostingError) as caught: + LinuxServiceObserverV1.reopen(identity) + assert caught.value.category in { + HostingFailureCategory.PREPARATION_FAILED, HostingFailureCategory.PREPARATION_STALE, + } + + +def test_identity_change_around_pidfd_open_is_rejected_without_fd_leak(child, monkeypatch): + import loushang.hosting.service as module + + original_observe = module._observe + calls = 0 + descriptors = len(tuple(Path("/proc/self/fd").iterdir())) + + def changed(pid): + nonlocal calls + result = original_observe(pid) + calls += 1 + return replace(result, start_ticks=result.start_ticks + 1) if calls == 2 else result + + monkeypatch.setattr(module, "_observe", changed) + with pytest.raises(HostingError) as caught: + LinuxServiceObserverV1.capture(child.pid) + assert caught.value.category is HostingFailureCategory.PREPARATION_STALE + assert len(tuple(Path("/proc/self/fd").iterdir())) == descriptors + + +@pytest.mark.parametrize("timeout", [ + -1, 31, float("nan"), float("inf"), True, "1", + pytest.param(10**1000, id="huge-positive"), pytest.param(-(10**1000), id="huge-negative"), +]) +def test_observation_wait_is_bounded(child, timeout): + observer = LinuxServiceObserverV1.capture(child.pid) + try: + with pytest.raises(HostingError) as caught: + observer.exited(timeout=timeout) + assert caught.value.category is HostingFailureCategory.INVALID_REQUEST + finally: + observer.close() + + +@pytest.mark.parametrize("pid", [0, -1, True, "1", 2**31]) +def test_invalid_pid_is_rejected_before_io(pid): + with pytest.raises(HostingError) as caught: + LinuxServiceObserverV1.capture(pid) + assert caught.value.category is HostingFailureCategory.INVALID_REQUEST + + +def test_proc_stat_comm_delimiters_do_not_change_start_time(): + fields = [b"S"] + [b"0"] * 18 + [b"12345"] + assert _parse_start_ticks(b"123 (name with ) spaces\n) " + b" ".join(fields), 123) == 12345 + with pytest.raises(HostingError): + _parse_start_ticks(b"124 (name) " + b" ".join(fields), 123) + with pytest.raises(HostingError): + _parse_start_ticks(b"123 broken", 123) + + +def test_independent_client_reopens_same_running_service(child): + observer = LinuxServiceObserverV1.capture(child.pid) + identity = observer.identity + observer.close() + script = """ +import json, sys +from loushang.hosting.service import LinuxServiceIdentityV1, LinuxServiceObserverV1 +observer = LinuxServiceObserverV1.reopen(LinuxServiceIdentityV1(**json.loads(sys.argv[1]))) +try: + print('exited' if observer.exited() else 'running') +finally: + observer.close() +""" + env = dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[2] / "src")) + result = subprocess.run([sys.executable, "-c", script, json.dumps(asdict(identity))], env=env, + capture_output=True, text=True, timeout=5) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "running" + assert child.poll() is None + + +def test_exit_of_leader_does_not_assert_descendant_settlement(): + # The explicit pipe remains owned by a known descendant after the leader + # exits; a readable pidfd still only proves the leader's exit. + # Use two inherited pipes so each explicitly owned process has one release. + leader_read, leader_write = os.pipe() + descendant_read, descendant_write = os.pipe() + script = """ +import os, subprocess, sys +child = subprocess.Popen([sys.executable, '-c', 'import os,sys; os.read(int(sys.argv[1]),1)', sys.argv[2]], + pass_fds=(int(sys.argv[2]),), stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +print(child.pid, flush=True) +os.read(int(sys.argv[1]),1) +""" + leader = subprocess.Popen([sys.executable, "-c", script, str(leader_read), str(descendant_read)], + pass_fds=(leader_read, descendant_read), stdout=subprocess.PIPE, + stdin=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) + os.close(leader_read) + os.close(descendant_read) + parent_observer = descendant_observer = None + try: + import select + + assert select.select([leader.stdout], [], [], 5)[0] + descendant_pid = int(leader.stdout.readline()) + parent_observer = LinuxServiceObserverV1.capture(leader.pid) + descendant_observer = LinuxServiceObserverV1.capture(descendant_pid) + os.write(leader_write, b"x") + leader.wait(timeout=5) + assert parent_observer.exited(timeout=1) + assert not descendant_observer.exited() + assert not hasattr(parent_observer, "process_scope_settled") + finally: + os.close(leader_write) + os.close(descendant_write) + leader.communicate(timeout=5) + if descendant_observer is not None: + assert descendant_observer.exited(timeout=5) + descendant_observer.close() + if parent_observer is not None: + parent_observer.close() + + +def test_proc_directory_owner_does_not_substitute_for_actual_uid(child, monkeypatch): + import loushang.hosting.service as module + + original_read = module._read_file + + def different_uid(path, **kwargs): + if path == "status": + uid = str(os.geteuid() + 1).encode() + return b"Uid:\t" + b"\t".join([uid] * 4) + b"\n" + return original_read(path, **kwargs) + + monkeypatch.setattr(module, "_read_file", different_uid) + with pytest.raises(HostingError) as caught: + LinuxServiceObserverV1.capture(child.pid) + assert caught.value.category is HostingFailureCategory.PREPARATION_REJECTED + + +def test_contended_observe_is_bounded_and_close_fences_new_waits(child, monkeypatch): + import loushang.hosting.service as module + + observer = LinuxServiceObserverV1.capture(child.pid) + entered = threading.Event() + release = threading.Event() + short_finished = threading.Event() + results = [] + failures = [] + + class BlockedPoll: + def register(self, fd, mask): + pass + + def poll(self, timeout): + entered.set() + assert release.wait(5) + return [] + + def wait(): + try: + results.append(observer.exited(timeout=5)) + except BaseException as error: + failures.append(error) + + def immediate(): + try: + observer.exited(timeout=0) + except HostingError as error: + results.append(error.category) + finally: + short_finished.set() + + def close(): + try: + observer.close() + except BaseException as error: + failures.append(error) + + monkeypatch.setattr(module.select, "poll", BlockedPoll) + waiting = threading.Thread(target=wait) + short = threading.Thread(target=immediate) + closing = threading.Thread(target=close) + waiting.start() + try: + assert entered.wait(5) + short.start() + assert short_finished.wait(1) + assert HostingFailureCategory.CAPACITY_EXHAUSTED in results + closing.start() + assert observer._closing.wait(5) + with pytest.raises(HostingError) as caught: + observer.exited(timeout=0) + assert caught.value.category is HostingFailureCategory.HOST_CLOSED + finally: + release.set() + for worker in (waiting, short, closing): + if worker.ident is not None: + worker.join(5) + assert not worker.is_alive() + observer.close() + assert not failures + assert results.count(False) == 1 + assert child.poll() is None From 3bcc2f03ec03d3e2ec05178b8b057528960bc0e6 Mon Sep 17 00:00:00 2001 From: zhnt Date: Sun, 13 Sep 2026 07:56:34 +0000 Subject: [PATCH 05/44] feat(hosting): coordinate durable service startup handoff --- .../architecture/apphost/lmux-contract-m0.md | 41 ++- .../hosted-product-runtime-v1-inventory.md | 2 + src/loushang/apphost/managed/_database.py | 17 +- src/loushang/apphost/managed/_files.py | 28 +- src/loushang/apphost/managed/handoff.py | 54 +++ src/loushang/apphost/managed/lifecycle.py | 15 +- src/loushang/hosting/service_handoff.py | 190 +++++++++++ tests/apphost/test_managed_handoff.py | 319 ++++++++++++++++++ tests/apphost/test_managed_registry.py | 8 + ...test_hosted_product_runtime_v1_baseline.py | 3 + .../test_hosting_architecture_baseline.py | 1 + .../architecture/test_hosting_h0_contract.py | 4 + ...st_plugin_lifecycle_plc9c5_c50_baseline.py | 8 + tests/hosting/test_service_handoff.py | 271 +++++++++++++++ 14 files changed, 941 insertions(+), 20 deletions(-) create mode 100644 src/loushang/apphost/managed/handoff.py create mode 100644 src/loushang/hosting/service_handoff.py create mode 100644 tests/apphost/test_managed_handoff.py create mode 100644 tests/hosting/test_service_handoff.py diff --git a/docs/internals/architecture/apphost/lmux-contract-m0.md b/docs/internals/architecture/apphost/lmux-contract-m0.md index 5b16bb69d..65120edc7 100644 --- a/docs/internals/architecture/apphost/lmux-contract-m0.md +++ b/docs/internals/architecture/apphost/lmux-contract-m0.md @@ -8,8 +8,8 @@ - ID: `LMUX-M0` - Authority: normative — accepted limited Linux managed-profile contract - Design status: accepted -- Review status: three-perspective storage/lifecycle/observer slice reviews passed; recovery admission pending -- Implementation status: partial — storage, instance coordination and Linux exit observations; no activation +- Review status: three-perspective storage/lifecycle/observer/handoff slice reviews passed; recovery admission pending +- Implementation status: partial — storage, instance coordination, Linux exit observations and handoff binding; no activation - Owner: AppHost managed deployment; sibling changes remain sibling-owned - Tracking objective: active Linux lmux goal, branch `harness/lmux-managed-service` @@ -238,6 +238,7 @@ Mux 外壳只负责成员/连接组合。主题与终端能力在客户端组合 | M1 持久名称预留 | SQLite schema/namespace、名称与操作唯一、服务复用键、分页查询及崩溃恢复;三视角复审通过 | 操作结果对账与启动/停止编排尚未实现 | | M2 代际协调 | per-service fence、持久 prepare/commit/abort/stop、三项结算事实及干净停止后换代 | native 启动交接、异常 retire/recovery admission、真实后台激活均未完成 | | M2 Linux 退出观察 | boot/PID/start-time/实际 UID/PID namespace 与保留 pidfd;26 项原生回归、三视角复审通过 | 原生 spawn/handoff、进程树结算及异常恢复准入仍待接线 | +| M2 持久交接接线 | 继承通道 + exact instance/attempt journal port;共享协作 deadline、真实启动器退出和 EOF/CAS 竞争测试,三视角复审通过 | 生产 spawn/daemon 与应用 owner 清理尚未接入,不构成 SSH/后台交付 | | 一条命令/后台/全局名/多 Tab/stop | 仅 G16 既有显式能力 | M1–M3 全部接线 | | Session 唯一写入与默认历史 | 缺运行期跨进程合同实现 | writer lease + canonical catalog + 双进程测试 | | 完整共享 Harnesstui/Markdown | 草案与本文接缝 | M3 代码与 Embedded 非回退 | @@ -387,3 +388,39 @@ Product 依赖,不从 Hosting 根 facade 激活,不改变既有 ProcessLease 断言,H0 与交付清单聚焦复测 15 passed;Hosting Ruff/mypy 27 模块、 文档 6 项与依赖图新鲜度通过。不将该聚焦修复标为完整 check-hosting 命令 重跑成功。CI 计划选中的更广泛消费者/跨平台门禁仍是最终集成前置。 + +## 14. M2 继承通道与持久交接接线 + +可选 `hosting.service_handoff` 拥有继承的 Unix stream socket,借用中性 +handoff port;`apphost.managed.handoff` 将该端口绑定至精确 instance/attempt +的 journal。子端准备完成后提出 commit,父端只观察或提出 abort;消息字节 +只用于唤醒,既不是 ready 证明,也不产生停止权限。父端退出后,子端仅在 +持久结果为 ABORTING 时才能进行启动收口;UNKNOWN 保留所有权及待对账义务。 + +**竞争以持久 CAS 为准**:先看到 EOF 的子端提出 abort;存活检查后、CAS 前 +父端退出时,commit 与 abort 都可能先赢。不能将物理退出瞬间等同于已持久 +abort,不能因迟到 EOF 或丢失 ack 撤销 COMMITTED。stop fence 只阻止新的 +commit,不伪造 ABORTING;应用 owner 还需显式 abort 或处理已提交服务停止。 + +三视角要求修复共享预算:同一 absolute monotonic deadline 贯穿 channel +mutex、端口、service/registry mutex、SQL progress 与失败后重查,耗尽后不 +再开新 IO;timeout=0 返回 UNKNOWN 而不读库。已有同步 OS IO/fsync 不可由 +Python 抢占,这只是协作式预算,不承诺内核调用硬超时;不引入无人接管的 +后台线程,不因时间耗尽丢弃已接纳操作的结算责任。既有无 deadline 的存储 +API 保持原语义。应用/UI 接线必须由专属启动 owner 执行这些同步操作。 + +测试分别覆盖:真实启动器退出前后,子进程在同一持久库确认 ABORTING 或 +COMMITTED;独立测试控制通道负责释放子进程,不借此伪称应用清理完成。 +确定性插入 EOF 到存活检查与 CAS 之间,覆盖两个 CAS 获胜顺序;另测错代、 +ack 丢失、事务提交后错误对账、锁/SQL 预算耗尽、到期回滚与并发 close fence。 +本切片仍不提供生产 spawn/daemon、公共连接协调、异常 recovery admission, +更不是 SSH/完整产品交互的交付证据;§12 异常退役 P2 仍为激活前置。 + +三视角复审通过;聚焦交接与受影响存储回归 102 passed。加固后的事务到期 +回滚用例同步控制两个时钟,并要求确实执行 `_save`,避免在写入前就超时 +产生假通过。Hosting 主回归 434 passed / 48 skipped,唯一失败为旧 PLC +精确消费者清单未登记新 adapter;显式登记模块和唯一公共导入 symbol 后, +对应失败节点复测 1 passed。不扩大对 Hosting 私有 launch profile 的准入。 +Hosting Ruff/mypy 28 模块、AppHost Ruff/mypy 107 模块、文档 6 项和依赖图 +新鲜度通过。较大范围 AppHost 主回归仍在执行;最终 native/installed 和 +完整 change-aware/远端门禁仍在整体交付前完成,不将本切片检查冒称全通过。 diff --git a/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md b/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md index 70f6504e5..65f1f640b 100644 --- a/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md +++ b/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md @@ -65,6 +65,8 @@ lease, or factory. | optional managed name registry | `src/loushang/apphost/managed/_database.py` and `src/loushang/apphost/managed/registry.py` | bounded SQLite name intents and Product/workspace/profile reuse index with exact operation matching; no native liveness authority, process activation or CLI composition | | optional managed instance journal | `src/loushang/apphost/managed/lifecycle.py` | stable per-service fence and durable instance generations, handoff/stop transitions and matching monotonic settlement observations; neither native process identity nor permission to terminate a process | | optional Linux service observation | `src/loushang/hosting/service.py` | boot/PID/start-time/UID/namespace lookup facts with retained pidfd capture, re-admission and bounded exit observation; no process launch, signal, process-tree settlement or facade activation | +| optional inherited-channel handoff | `src/loushang/hosting/service_handoff.py` | child-side durable commit/abort proposals through a borrowed consumer port; EOF and acknowledgement hints never replace durable authority; no launch or application cleanup | +| optional managed handoff binding | `src/loushang/apphost/managed/handoff.py` | exact instance/attempt journal adapter for Hosting handoff; stale/uncertain reads never grant current authority; no process ownership | | AppServer Product ports | `src/loushang/appserver/ports.py` | immutable contract-only identity and Product-supplied Session/projection/work/interaction port bundle; no runtime, listener, protocol, or composition owner | | Session discovery composition | `src/loushang/harness/transcript/directory.py`, `src/loushang/harness/machine_resources/control_plane.py`, and `src/loushang/coding/cli/__main__.py` | canonical global plus cwd/home compatibility sources are selected at composition; cwd is a query/filter and compatibility roots are not writable authorities | | Linux/Windows Coding Product canary | `src/loushang/coding/_product_worker_canary.py` | sole explicit C5.4/C5.5c Product root; joins Coding Product/Session evidence to the real lifecycle coordinator, exact native-profile friend, Worker health, Capability publication, normal close, rollback, and recovery; omission remains Current and unlisted profiles remain closed | diff --git a/src/loushang/apphost/managed/_database.py b/src/loushang/apphost/managed/_database.py index ab3ed45b3..add2c6757 100644 --- a/src/loushang/apphost/managed/_database.py +++ b/src/loushang/apphost/managed/_database.py @@ -18,6 +18,7 @@ from ._files import ( ManagedStorageError, PrivateManagedDirectory, + _check_deadline, _close_preserving_primary, ) @@ -102,12 +103,12 @@ def _admit_files(self, parent: int) -> None: @contextmanager def _connection( - self, *, create: bool = False, read_only: bool = False, + self, *, create: bool = False, read_only: bool = False, deadline: float | None = None, ) -> Iterator[sqlite3.Connection]: directory = self._directory connection = None guard = None - with directory.lock(_LOCK, create=create), directory._operation() as parent: + with directory.lock(_LOCK, create=create, deadline=deadline), directory._operation(deadline=deadline) as parent: if self.cleanup_pending: raise ManagedStorageError("busy") try: @@ -130,8 +131,10 @@ def _connection( connection = sqlite3.connect(uri, uri=True, timeout=0, isolation_level=None, check_same_thread=False) directory._check_named(DATABASE_NAME, identity) - deadline = monotonic() + 2.0 - connection.set_progress_handler(lambda: int(monotonic() >= deadline), 1000) + sql_deadline = monotonic() + 2.0 + if deadline is not None: + sql_deadline = min(sql_deadline, deadline) + connection.set_progress_handler(lambda: int(monotonic() >= sql_deadline), 1000) connection.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, 16 * 1024) connection.setlimit(sqlite3.SQLITE_LIMIT_SQL_LENGTH, 16 * 1024) connection.setlimit(sqlite3.SQLITE_LIMIT_ATTACHED, 0) @@ -170,6 +173,7 @@ def _connection( self._validate_schema(connection) directory._check_named(DATABASE_NAME, identity) self._admit_files(parent) + _check_deadline(deadline) yield connection directory._check_named(DATABASE_NAME, identity) self._admit_files(parent) @@ -237,8 +241,8 @@ def _admit_capacity( raise ManagedStorageError("capacity") @contextmanager - def transaction(self, *, write: bool = False) -> Iterator[sqlite3.Connection]: - with self._connection(read_only=not write) as connection: + def transaction(self, *, write: bool = False, deadline: float | None = None) -> Iterator[sqlite3.Connection]: + with self._connection(read_only=not write, deadline=deadline) as connection: if write: connection.execute("BEGIN IMMEDIATE") else: @@ -246,6 +250,7 @@ def transaction(self, *, write: bool = False) -> Iterator[sqlite3.Connection]: connection.execute("BEGIN") yield connection self._directory._check() + _check_deadline(deadline) connection.commit() def admit_growth(self, connection: sqlite3.Connection) -> None: diff --git a/src/loushang/apphost/managed/_files.py b/src/loushang/apphost/managed/_files.py index ec29d0170..5a326225d 100644 --- a/src/loushang/apphost/managed/_files.py +++ b/src/loushang/apphost/managed/_files.py @@ -15,9 +15,11 @@ from contextlib import contextmanager from dataclasses import dataclass, field from importlib import import_module +from math import isfinite from pathlib import Path from secrets import token_hex from threading import RLock +from time import monotonic _NAME = re.compile(r"[a-z0-9][a-z0-9._-]{0,95}\Z") MAX_RECORD_BYTES = 16 * 1024 @@ -152,8 +154,14 @@ def _check(self) -> int: return self._fd @contextmanager - def _operation(self) -> Iterator[int]: - with self._mutex: + def _operation(self, *, deadline: float | None = None) -> Iterator[int]: + _check_deadline(deadline) + acquired = (self._mutex.acquire() if deadline is None else + self._mutex.acquire(timeout=max(0.0, min(30.0, deadline - monotonic())))) + if not acquired: + raise ManagedStorageError("busy") + try: + _check_deadline(deadline) try: fd = self._check() yield fd @@ -162,6 +170,8 @@ def _operation(self) -> Iterator[int]: raise _storage_error("not_found", error) from None except OSError as error: raise _storage_error("unavailable", error) from None + finally: + self._mutex.release() def _open(self, name: str, flags: int, *, create: bool = False) -> int: _name(name) @@ -217,12 +227,12 @@ def read(self, name: str) -> ManagedFileSnapshot | None: _close_preserving_primary(fd) @contextmanager - def lock(self, name: str, *, create: bool = False) -> Iterator[None]: + def lock(self, name: str, *, create: bool = False, deadline: float | None = None) -> Iterator[None]: """Nonblocking stable flock; the caller never gains an unlink right.""" _name(name) if type(create) is not bool or not name.endswith(".lock"): raise ManagedStorageError("invalid_record") - with self._operation(): + with self._operation(deadline=deadline): if name in self._locks: raise ManagedStorageError("busy") fd = self._open(name, os.O_RDWR | (os.O_CREAT if create else 0), create=create) @@ -244,7 +254,6 @@ def lock(self, name: str, *, create: bool = False) -> Iterator[None]: # unlink a lock on release or retry close after an uncertain error. self._locks.pop(name, None) _close_preserving_primary(fd) - def write(self, name: str, content: bytes, *, expected: ManagedFileSnapshot | None) -> None: """Publish under a caller-held stable lock; fsync failure remains unknown. @@ -365,6 +374,15 @@ def _name(name: str) -> None: raise ManagedStorageError("invalid_record") +def _check_deadline(deadline: float | None) -> None: + if deadline is None: + return + if type(deadline) not in (int, float) or not 0 <= deadline <= 1e12 or not isfinite(deadline): + raise ManagedStorageError("invalid_record") + if monotonic() >= deadline: + raise ManagedStorageError("busy") + + def _close_preserving_primary(fd: int) -> None: primary = sys.exception() try: diff --git a/src/loushang/apphost/managed/handoff.py b/src/loushang/apphost/managed/handoff.py new file mode 100644 index 000000000..4ff765d91 --- /dev/null +++ b/src/loushang/apphost/managed/handoff.py @@ -0,0 +1,54 @@ +"""Bind Hosting's inherited-channel protocol to one durable AppHost attempt.""" + +from __future__ import annotations + +from collections.abc import Callable + +from loushang.hosting.service_handoff import ServiceHandoffPhaseV1 + +from ._files import ManagedStorageError +from .contracts import _HEX32, ManagedContractError, ManagedInstanceRefV1, _match +from .lifecycle import ManagedServiceJournalV1, ManagedServiceStateV1 + + +class ManagedServiceHandoffPortV1: + """Borrow a journal; never turn a stale generation into current authority. + + Failed mutation is resolved by a fresh durable read, not by replaying spawn + or by assuming rollback. A provisional stop fence rejects commit but is not + itself ABORTING: the application owner must explicitly request abort before + performing startup cleanup. No process or application cleanup is done here. + """ + + def __init__( + self, journal: ManagedServiceJournalV1, instance: ManagedInstanceRefV1, + attempt_id: str, + ) -> None: + if type(journal) is not ManagedServiceJournalV1 or type(instance) is not ManagedInstanceRefV1: + raise ManagedContractError() + _match(attempt_id, _HEX32) + self._journal, self._instance, self._attempt = journal, instance, attempt_id + + def _phase(self, state: ManagedServiceStateV1 | None) -> ServiceHandoffPhaseV1: + if (state is None or state.handoff.instance != self._instance + or state.handoff.attempt_id != self._attempt): + return ServiceHandoffPhaseV1.UNKNOWN + return ServiceHandoffPhaseV1(state.handoff.phase.value) + + def observe(self, deadline: float | None = None) -> ServiceHandoffPhaseV1: + try: + return self._phase(self._journal.read(deadline=deadline)) + except (ManagedStorageError, ManagedContractError, OSError): + return ServiceHandoffPhaseV1.UNKNOWN + + def _mutate(self, operation: Callable[[], ManagedServiceStateV1], deadline: float | None) -> ServiceHandoffPhaseV1: + try: + return self._phase(operation()) + except (ManagedStorageError, ManagedContractError, OSError): + return self.observe(deadline) + + def commit(self, deadline: float | None = None) -> ServiceHandoffPhaseV1: + return self._mutate(lambda: self._journal.commit(self._instance, self._attempt, deadline=deadline), deadline) + + def abort(self, deadline: float | None = None) -> ServiceHandoffPhaseV1: + return self._mutate(lambda: self._journal.abort(self._instance, self._attempt, deadline=deadline), deadline) diff --git a/src/loushang/apphost/managed/lifecycle.py b/src/loushang/apphost/managed/lifecycle.py index 131516851..2bb260195 100644 --- a/src/loushang/apphost/managed/lifecycle.py +++ b/src/loushang/apphost/managed/lifecycle.py @@ -94,8 +94,8 @@ def __init__( def close(self) -> None: self._fence.close() - def read(self) -> ManagedServiceStateV1 | None: - with self._fence.lock("lifecycle.lock"), self._database.transaction() as connection: + def read(self, *, deadline: float | None = None) -> ManagedServiceStateV1 | None: + with self._fence.lock("lifecycle.lock", deadline=deadline), self._database.transaction(deadline=deadline) as connection: result = self._read(connection) self._fence._check() return result @@ -134,7 +134,7 @@ def prepare( self._fence._check() return state - def commit(self, instance: ManagedInstanceRefV1, attempt_id: str) -> ManagedServiceStateV1: + def commit(self, instance: ManagedInstanceRefV1, attempt_id: str, *, deadline: float | None = None) -> ManagedServiceStateV1: """Persist the child's proposal; a stop fence or abort wins by refusing it.""" _match(attempt_id, _HEX32) @@ -143,9 +143,9 @@ def update(current: ManagedServiceStateV1) -> ManagedServiceStateV1: raise ManagedStorageError("conflict") return replace(current, handoff=current.handoff.commit()) - return self._update(instance, update) + return self._update(instance, update, deadline=deadline) - def abort(self, instance: ManagedInstanceRefV1, attempt_id: str) -> ManagedServiceStateV1: + def abort(self, instance: ManagedInstanceRefV1, attempt_id: str, *, deadline: float | None = None) -> ManagedServiceStateV1: _match(attempt_id, _HEX32) def update(current: ManagedServiceStateV1) -> ManagedServiceStateV1: @@ -153,7 +153,7 @@ def update(current: ManagedServiceStateV1) -> ManagedServiceStateV1: raise ManagedStorageError("conflict") return replace(current, handoff=current.handoff.abort()) - return self._update(instance, update) + return self._update(instance, update, deadline=deadline) def request_stop(self, instance: ManagedInstanceRefV1) -> ManagedServiceStateV1: return self._update(instance, lambda current: replace( @@ -181,9 +181,10 @@ def update(current: ManagedServiceStateV1) -> ManagedServiceStateV1: def _update( self, instance: ManagedInstanceRefV1, update: Callable[[ManagedServiceStateV1], ManagedServiceStateV1], + *, deadline: float | None = None, ) -> ManagedServiceStateV1: self._require_instance(instance) - with self._fence.lock("lifecycle.lock"), self._database.transaction(write=True) as connection: + with self._fence.lock("lifecycle.lock", deadline=deadline), self._database.transaction(write=True, deadline=deadline) as connection: current = self._read(connection) if current is None or current.handoff.instance != instance: raise ManagedStorageError("conflict") diff --git a/src/loushang/hosting/service_handoff.py b/src/loushang/hosting/service_handoff.py new file mode 100644 index 000000000..6ff0e0ea8 --- /dev/null +++ b/src/loushang/hosting/service_handoff.py @@ -0,0 +1,190 @@ +"""Inherited-channel handoff coordination, independent of application semantics. + +The injected consumer port binds one exact attempt/instance and performs durable +CAS. Wire bytes are hints only. This module neither spawns nor signals processes; +the child application owner performs cleanup only after an ABORTING decision. +""" + +from __future__ import annotations + +import select +import socket +import sys +from collections.abc import Callable +from contextlib import suppress +from enum import Enum +from threading import Event, RLock +from time import monotonic +from typing import Protocol + +from .errors import HostingError, HostingFailureCategory + + +class ServiceHandoffPhaseV1(str, Enum): + PROVISIONAL = "provisional" + COMMITTED = "committed" + ABORTING = "aborting" + UNKNOWN = "unknown" + + +class ServiceHandoffPortV1(Protocol): + """Trusted, bounded synchronous CAS operations for one exact generation. + + observe never infers phase from process/socket absence. commit is proposed + only by the child after application readiness; abort cannot cross a durable + commit. Errors or inability to durably determine the outcome mean UNKNOWN. + The port consumes the supplied absolute monotonic deadline, including locks, + SQL and re-observation. It is borrowed and not closed by the channel owner. + This is a cooperative IO budget, not preemption of an in-flight OS fsync; + owners retain completion/cleanup responsibility even if native IO overruns. + """ + + def observe(self, deadline: float, /) -> ServiceHandoffPhaseV1: ... + + def commit(self, deadline: float, /) -> ServiceHandoffPhaseV1: ... + + def abort(self, deadline: float, /) -> ServiceHandoffPhaseV1: ... + + +class _HandoffChannel: + """Own a supplied stream socket; no timeout can erase a durable decision.""" + + def __init__(self, endpoint: socket.socket, port: ServiceHandoffPortV1) -> None: + if sys.platform != "linux": + raise _error(HostingFailureCategory.PLATFORM_UNSUPPORTED) + if not isinstance(endpoint, socket.socket) or any( + not callable(getattr(port, name, None)) for name in ("observe", "commit", "abort") + ): + raise _error(HostingFailureCategory.INVALID_REQUEST) + # Admission precedes ownership transfer: failed construction leaves the + # supplied socket with its caller, which must close it in a finally. + if endpoint.family != socket.AF_UNIX or endpoint.getsockopt(socket.SOL_SOCKET, socket.SO_TYPE) != socket.SOCK_STREAM: + raise _error(HostingFailureCategory.INVALID_REQUEST) + endpoint.setblocking(False) + endpoint.set_inheritable(False) + self._endpoint, self._port = endpoint, port + self._closing = Event() + self._mutex = RLock() + self._closed = False + + def _enter(self, timeout: float = 2.0) -> float: + if type(timeout) not in (int, float) or not 0 <= timeout <= 30: + raise _error(HostingFailureCategory.INVALID_REQUEST) + deadline = monotonic() + timeout + if self._closing.is_set(): + raise _error(HostingFailureCategory.HOST_CLOSED) + if not self._mutex.acquire(timeout=timeout): + raise _error(HostingFailureCategory.CAPACITY_EXHAUSTED) + if self._closing.is_set(): + self._mutex.release() + raise _error(HostingFailureCategory.HOST_CLOSED) + return deadline + + def close(self) -> None: + self._closing.set() + if not self._mutex.acquire(timeout=30): + raise _error(HostingFailureCategory.CLEANUP_FAILED) + try: + if not self._closed: + try: + self._endpoint.close() + except OSError: + raise _error(HostingFailureCategory.CLEANUP_FAILED) from None + self._closed = True + finally: + self._mutex.release() + + @staticmethod + def _call(operation: Callable[[float], ServiceHandoffPhaseV1], deadline: float) -> ServiceHandoffPhaseV1: + if monotonic() >= deadline: + return ServiceHandoffPhaseV1.UNKNOWN + try: + phase = operation(deadline) + except Exception: + return ServiceHandoffPhaseV1.UNKNOWN + return phase if type(phase) is ServiceHandoffPhaseV1 else ServiceHandoffPhaseV1.UNKNOWN + + +class ServiceChildHandoffV1(_HandoffChannel): + """A child checks parent loss while preparing, then proposes durable commit.""" + + def __init__(self, endpoint: socket.socket, port: ServiceHandoffPortV1) -> None: + super().__init__(endpoint, port) + self._ack_attempted = False + + def _parent_lost(self) -> bool: + try: + self._endpoint.recv(1, socket.MSG_PEEK) + except BlockingIOError: + return False + except OSError: + return True + # The parent never sends data. Unexpected input is not a commit or stop + # command; treat a broken startup channel like parent loss, then use CAS. + return True + + def poll_parent(self) -> ServiceHandoffPhaseV1: + """Only ABORTING authorizes caller cleanup; UNKNOWN remains owned debt.""" + deadline = self._enter() + try: + phase = self._call(self._port.observe, deadline) + if phase is ServiceHandoffPhaseV1.PROVISIONAL and self._parent_lost(): + return self._call(self._port.abort, deadline) + return phase + finally: + self._mutex.release() + + def commit(self) -> ServiceHandoffPhaseV1: + """After readiness, durable CAS wins even if EOF races the liveness peek. + + Parent death is not an instantaneous durable abort. Either CAS may win; + a later EOF/notification loss never undoes the committed decision. + """ + deadline = self._enter() + try: + phase = self._call(self._port.observe, deadline) + if phase is ServiceHandoffPhaseV1.PROVISIONAL: + phase = self._call(self._port.abort if self._parent_lost() else self._port.commit, deadline) + if phase is ServiceHandoffPhaseV1.COMMITTED and not self._ack_attempted: + self._ack_attempted = True + # Notification loss is not a lifecycle transition. + with suppress(OSError): + self._endpoint.send(b"C") + return phase + finally: + self._mutex.release() + + +class ServiceParentHandoffV1(_HandoffChannel): + """Starter-side bounded observation; notifications alone grant no authority.""" + + def wait(self, *, timeout: float) -> ServiceHandoffPhaseV1: + deadline = self._enter(timeout) + try: + phase = self._call(self._port.observe, deadline) + if phase in (ServiceHandoffPhaseV1.COMMITTED, ServiceHandoffPhaseV1.ABORTING): + return phase + try: + poller = select.poll() + poller.register(self._endpoint, select.POLLIN) + events = poller.poll(int(max(0, deadline - monotonic()) * 1000)) + if events: + self._endpoint.recv(1) # Consume a hint, never trust its value. + except OSError: + pass + phase = self._call(self._port.observe, deadline) + return phase if phase in (ServiceHandoffPhaseV1.COMMITTED, ServiceHandoffPhaseV1.ABORTING) else ServiceHandoffPhaseV1.UNKNOWN + finally: + self._mutex.release() + + def abort(self) -> ServiceHandoffPhaseV1: + """Request durable abort; a committed/unknown result forbids rollback.""" + deadline = self._enter() + try: + return self._call(self._port.abort, deadline) + finally: + self._mutex.release() + + +def _error(category: HostingFailureCategory) -> HostingError: + return HostingError(category, "service_handoff_" + category.value) diff --git a/tests/apphost/test_managed_handoff.py b/tests/apphost/test_managed_handoff.py new file mode 100644 index 000000000..648bd2f35 --- /dev/null +++ b/tests/apphost/test_managed_handoff.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +import json +import os +import select +import socket +import subprocess +import sys +import threading +from dataclasses import replace +from time import monotonic + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.contracts import ( + ManagedHandoffPhaseV1, + ManagedNamespaceV1, + ManagedServiceKeyV1, + ManagedStopEvidenceV1, +) +from loushang.apphost.managed.handoff import ManagedServiceHandoffPortV1 +from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 +from loushang.apphost.managed.registry import ManagedMuxReservationV1, ManagedRegistryV1 +from loushang.hosting.service import LinuxServiceObserverV1 +from loushang.hosting.service_handoff import ( + ServiceChildHandoffV1, + ServiceParentHandoffV1, +) +from loushang.hosting.service_handoff import ServiceHandoffPhaseV1 as Phase + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux durable handoff") + + +@pytest.fixture +def owners(tmp_path): + namespace = ManagedNamespaceV1(str(tmp_path / "platform"), os.geteuid(), "a" * 32) + service = ManagedServiceKeyV1("coding", "/workspace") + registry = ManagedRegistryV1(tmp_path / "registry", namespace, create=True) + registry.reserve_mux(ManagedMuxReservationV1("dev", service, "b" * 32)) + journal = ManagedServiceJournalV1(registry, namespace, service, tmp_path / "fence", create=True) + state = journal.prepare("c" * 32, expected=None) + port = ManagedServiceHandoffPortV1(journal, state.handoff.instance, state.handoff.attempt_id) + try: + yield journal, state, port + finally: + journal.close() + registry.close() + + +def test_port_observes_durable_commit_and_refuses_abort(owners): + _, _, port = owners + assert port.observe() is Phase.PROVISIONAL + assert port.commit() is Phase.COMMITTED + assert port.abort() is Phase.COMMITTED + + +def test_uncertain_commit_reobserves_without_replaying_mutation(owners, monkeypatch): + journal, _, port = owners + original = journal.commit + calls = 0 + + def commit_then_error(*args, **kwargs): + nonlocal calls + calls += 1 + original(*args, **kwargs) + raise ManagedStorageError("unavailable") + + monkeypatch.setattr(journal, "commit", commit_then_error) + assert port.commit() is Phase.COMMITTED + assert calls == 1 + + +def test_unavailable_journal_is_unknown_not_authority(owners, monkeypatch): + journal, _, port = owners + + def fail(*args, **kwargs): + raise ManagedStorageError("busy") + + monkeypatch.setattr(journal, "read", fail) + monkeypatch.setattr(journal, "commit", fail) + monkeypatch.setattr(journal, "abort", fail) + assert port.observe() is Phase.UNKNOWN + assert port.commit() is Phase.UNKNOWN + assert port.abort() is Phase.UNKNOWN + + +def test_stop_fence_is_not_a_fabricated_abort(owners): + journal, state, port = owners + journal.request_stop(state.handoff.instance) + assert port.commit() is Phase.PROVISIONAL + assert port.abort() is Phase.ABORTING + + +@pytest.mark.parametrize("mismatch", ["namespace", "instance", "attempt"]) +def test_binding_mismatch_cannot_observe_or_modify_current_attempt(owners, mismatch): + journal, state, _ = owners + reference, attempt = state.handoff.instance, state.handoff.attempt_id + if mismatch == "namespace": + reference = replace(reference, namespace_key="d" * 64) + elif mismatch == "instance": + reference = replace(reference, instance_id="d" * 32) + else: + attempt = "d" * 32 + port = ManagedServiceHandoffPortV1(journal, reference, attempt) + assert port.observe() is Phase.UNKNOWN + assert port.commit() is Phase.UNKNOWN + assert port.abort() is Phase.UNKNOWN + assert journal.read() == state + + +def test_delayed_old_port_never_controls_new_generation(owners): + journal, state, port = owners + assert port.abort() is Phase.ABORTING + stopped = journal.record_stop_evidence(ManagedStopEvidenceV1(state.handoff.instance, True, True, True)) + new = journal.prepare("d" * 32, expected=stopped) + assert port.observe() is Phase.UNKNOWN + assert port.commit() is Phase.UNKNOWN + assert port.abort() is Phase.UNKNOWN + assert journal.read() == new + + +@pytest.mark.parametrize("abort_wins", [False, True]) +def test_eof_racing_after_alive_peek_is_decided_by_durable_cas(owners, monkeypatch, abort_wins): + journal, _, port = owners + left, right = socket.socketpair() + parent, child = ServiceParentHandoffV1(left, port), ServiceChildHandoffV1(right, port) + original = child._parent_lost + + def close_after_alive_peek(): + assert original() is False + parent.close() + if abort_wins: + assert port.abort() is Phase.ABORTING + return False + + monkeypatch.setattr(child, "_parent_lost", close_after_alive_peek) + try: + expected = Phase.ABORTING if abort_wins else Phase.COMMITTED + assert child.commit() is expected + assert child.poll_parent() is expected + assert port.abort() is expected + assert journal.read().handoff.phase.value == expected.value + finally: + parent.close() + child.close() + + +@pytest.mark.parametrize("owner", ["fence", "database"]) +def test_port_deadline_includes_both_native_owner_mutexes(owners, owner): + journal, _, port = owners + mutex = journal._fence._mutex if owner == "fence" else journal._database._directory._mutex + locked, release = threading.Event(), threading.Event() + + def hold(): + with mutex: + locked.set() + assert release.wait(5) + + worker = threading.Thread(target=hold) + worker.start() + try: + assert locked.wait(5) + started = monotonic() + assert port.observe(started + 0.05) is Phase.UNKNOWN + assert monotonic() - started < 1 + finally: + release.set() + worker.join(5) + assert not worker.is_alive() + assert port.observe() is Phase.PROVISIONAL + + +def test_expired_transaction_rolls_back_without_fresh_reobservation_budget(owners, monkeypatch): + import loushang.apphost.managed._database as database + import loushang.apphost.managed._files as files + + journal, state, port = owners + now = [10.0] + monkeypatch.setattr(files, "monotonic", lambda: now[0]) + monkeypatch.setattr(database, "monotonic", lambda: now[0]) + original = journal._save + saved = [] + + def save_then_expire(*args, **kwargs): + original(*args, **kwargs) + saved.append(True) + now[0] = 11.0 + + monkeypatch.setattr(journal, "_save", save_then_expire) + assert port.commit(10.5) is Phase.UNKNOWN + assert saved == [True] + assert journal.read() == state + + +def test_sql_progress_uses_callers_deadline_not_a_fresh_two_seconds(owners, monkeypatch): + import loushang.apphost.managed._database as database + import loushang.apphost.managed._files as files + + journal, _, port = owners + now = [10.0] + monkeypatch.setattr(files, "monotonic", lambda: now[0]) + monkeypatch.setattr(database, "monotonic", lambda: now[0]) + + def long_query(connection): + now[0] = 11.0 # Caller budget is spent, the local two seconds are not. + connection.execute("WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 " + "FROM n WHERE x<10000) SELECT sum(x) FROM n").fetchone() + pytest.fail("expired SQL must be interrupted") + + monkeypatch.setattr(journal._database, "_validate_schema", long_query) + assert port.observe(10.5) is Phase.UNKNOWN + + +_CHILD = r''' +import os, socket, sys +from pathlib import Path +from loushang.apphost.managed.contracts import ManagedNamespaceV1, ManagedServiceKeyV1 +from loushang.apphost.managed.registry import ManagedRegistryV1 +from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 +from loushang.apphost.managed.handoff import ManagedServiceHandoffPortV1 +from loushang.hosting.service_handoff import ServiceChildHandoffV1 +root = Path(sys.argv[1]) +control = socket.socket(fileno=int(sys.argv[2])) +control.settimeout(8) +endpoint = socket.socket(fileno=int(sys.argv[3])) +namespace = ManagedNamespaceV1(str(root / 'platform'), os.geteuid(), 'a' * 32) +registry = ManagedRegistryV1(root / 'registry', namespace) +journal = ManagedServiceJournalV1(registry, namespace, ManagedServiceKeyV1('coding', '/workspace'), root / 'fence') +state = journal.read() +port = ManagedServiceHandoffPortV1(journal, state.handoff.instance, state.handoff.attempt_id) +channel = ServiceChildHandoffV1(endpoint, port) +try: + control.sendall(b'R') + while True: + command = control.recv(1) + if not command or command == b'Q': + break + phase = channel.commit() if command == b'C' else channel.poll_parent() + control.sendall(phase.value.encode() + b'\n') +finally: + channel.close() + journal.close() + registry.close() + control.close() +''' + +_STARTER = r''' +import json, os, socket, subprocess, sys +parent, child = socket.socketpair() +control_fd = int(sys.argv[2]) +command = json.loads(sys.argv[1]) + [str(child.fileno())] +process = subprocess.Popen(command, pass_fds=(child.fileno(), control_fd), + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True) +child.close() +os.close(control_fd) +print(process.pid, flush=True) +sys.stdin.buffer.read(1) +os._exit(0) +''' + + +@pytest.mark.parametrize("committed", [False, True]) +def test_real_starter_exit_obeys_durable_handoff(owners, tmp_path, committed): + journal, _, port = owners + control, inherited = socket.socketpair() + control.settimeout(8) + command = [sys.executable, "-c", _CHILD, str(tmp_path), str(inherited.fileno())] + starter = subprocess.Popen( + [sys.executable, "-c", _STARTER, json.dumps(command), str(inherited.fileno())], + pass_fds=(inherited.fileno(),), stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + inherited.close() + observer = None + try: + assert starter.stdout is not None + poller = select.poll() + poller.register(starter.stdout, select.POLLIN) + assert poller.poll(5000), "starter failed to report child" + observer = LinuxServiceObserverV1.capture(int(starter.stdout.readline())) + assert control.recv(1) == b"R" + if committed: + control.sendall(b"C") + assert _line(control) == b"committed\n" + _, errors = starter.communicate(b"Q", timeout=5) + assert starter.returncode == 0, errors.decode(errors="replace") + assert not observer.exited() + control.sendall(b"P") + expected = Phase.COMMITTED if committed else Phase.ABORTING + assert _line(control) == expected.value.encode() + b"\n" + assert port.observe() is expected + assert journal.read().handoff.phase is ManagedHandoffPhaseV1(expected.value) + # The handshake does not fabricate application/scope cleanup facts, nor + # stop the committed child. The test application owns its separate exit. + assert not journal.read().evidence.application_cleanup_completed + assert not journal.read().evidence.process_scope_settled + assert not observer.exited() + finally: + control.close() # EOF is the test child's own exit protocol, no signals. + starter.communicate(timeout=5) + if observer is not None: + try: + assert observer.exited(timeout=8) + finally: + observer.close() + + +def _line(endpoint): + result = bytearray() + while len(result) < 32: + value = endpoint.recv(1) + if not value: + break + result.extend(value) + if value == b"\n": + break + return bytes(result) diff --git a/tests/apphost/test_managed_registry.py b/tests/apphost/test_managed_registry.py index 4a94a2a96..553cd608e 100644 --- a/tests/apphost/test_managed_registry.py +++ b/tests/apphost/test_managed_registry.py @@ -282,6 +282,14 @@ def test_concurrent_close_cannot_lose_new_connection_debt(registry, monkeypatch) failures = [] class TrackedMutex: + def acquire(self, *args, **kwargs): + if threading.current_thread().name == "registry-closer": + close_waiting.set() + return original_mutex.acquire(*args, **kwargs) + + def release(self): + original_mutex.release() + def __enter__(self): if threading.current_thread().name == "registry-closer": close_waiting.set() diff --git a/tests/architecture/test_hosted_product_runtime_v1_baseline.py b/tests/architecture/test_hosted_product_runtime_v1_baseline.py index 2ac90003d..ad0edcbbf 100644 --- a/tests/architecture/test_hosted_product_runtime_v1_baseline.py +++ b/tests/architecture/test_hosted_product_runtime_v1_baseline.py @@ -41,6 +41,7 @@ HOSTING_SOURCE / "_windows_launch_preparation.py", HOSTING_SOURCE / "_posix_process.py", HOSTING_SOURCE / "service.py", + HOSTING_SOURCE / "service_handoff.py", HOSTING_SOURCE / "_windows_process.py", HOSTING_SOURCE / "_win32_process.py", HOSTING_SOURCE / "_endpoint_host.py", @@ -80,6 +81,7 @@ APPHOST_SOURCE / "managed/_database.py", APPHOST_SOURCE / "managed/registry.py", APPHOST_SOURCE / "managed/lifecycle.py", + APPHOST_SOURCE / "managed/handoff.py", APPSERVER_SOURCE / "ports.py", HARNESS_SOURCE / "machine_resources/control_plane.py", Path("src/loushang/coding/cli/__main__.py"), @@ -450,6 +452,7 @@ def test_current_inventory_matches_source_and_retained_absences() -> None: "managed/_database.py", "managed/registry.py", "managed/lifecycle.py", + "managed/handoff.py", } assert { path.relative_to(APPSERVER_SOURCE).as_posix() diff --git a/tests/architecture/test_hosting_architecture_baseline.py b/tests/architecture/test_hosting_architecture_baseline.py index 3e5ff2d52..d2da2bb9b 100644 --- a/tests/architecture/test_hosting_architecture_baseline.py +++ b/tests/architecture/test_hosting_architecture_baseline.py @@ -718,6 +718,7 @@ def test_hosting_apphost_and_appserver_optional_modules_are_exact() -> None: "managed/_database.py", "managed/registry.py", "managed/lifecycle.py", + "managed/handoff.py", "router.py", "runtime.py", "hosted.py", diff --git a/tests/architecture/test_hosting_h0_contract.py b/tests/architecture/test_hosting_h0_contract.py index 7956b6af9..271e356a4 100644 --- a/tests/architecture/test_hosting_h0_contract.py +++ b/tests/architecture/test_hosting_h0_contract.py @@ -38,6 +38,7 @@ } OPTIONAL_MANAGED_SERVICE_MODULES = { HOSTING_ROOT / "service.py", + HOSTING_ROOT / "service_handoff.py", } FORBIDDEN_PUBLIC_TERMS = { "Approval", @@ -95,6 +96,9 @@ def test_h0_public_surface_exposes_no_platform_or_caller_authority_types() -> No assert all(path.name not in public_surface for path in H1_PRIVATE_MODULES) assert "LinuxService" not in public_surface assert "from .service import" not in public_surface + assert "ServiceChildHandoff" not in public_surface + assert "ServiceParentHandoff" not in public_surface + assert "from .service_handoff import" not in public_surface def test_h0_observation_contract_has_no_arbitrary_payload_or_environment() -> None: diff --git a/tests/architecture/test_plugin_lifecycle_plc9c5_c50_baseline.py b/tests/architecture/test_plugin_lifecycle_plc9c5_c50_baseline.py index f7bcaa830..51d3232be 100644 --- a/tests/architecture/test_plugin_lifecycle_plc9c5_c50_baseline.py +++ b/tests/architecture/test_plugin_lifecycle_plc9c5_c50_baseline.py @@ -896,6 +896,14 @@ def test_c50_keeps_private_profiles_confined_and_product_layers_clean() -> None: CODING_APPHOST_CANARY, CODING_ROOT / "cli/hosted_client.py", APPHOST_ROOT / "launcher.py", + APPHOST_ROOT / "managed/handoff.py", + } + assert { + imported for imported in _imports(APPHOST_ROOT / "managed/handoff.py") + if imported.startswith("loushang.hosting") + } == { + "loushang.hosting.service_handoff", + "loushang.hosting.service_handoff.ServiceHandoffPhaseV1", } for consumer in hosting_consumers: assert not any( diff --git a/tests/hosting/test_service_handoff.py b/tests/hosting/test_service_handoff.py new file mode 100644 index 000000000..74d3bed3f --- /dev/null +++ b/tests/hosting/test_service_handoff.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import os +import socket +import sys +import threading + +import pytest + +from loushang.hosting.errors import HostingError, HostingFailureCategory +from loushang.hosting.service_handoff import ( + ServiceChildHandoffV1, + ServiceParentHandoffV1, +) +from loushang.hosting.service_handoff import ( + ServiceHandoffPhaseV1 as Phase, +) + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux inherited handoff") + + +class Port: + def __init__(self): + self.phase = Phase.PROVISIONAL + self.failure = False + + def observe(self, deadline=None): + if self.failure: + raise OSError("unavailable") + return self.phase + + def commit(self, deadline=None): + if self.phase is Phase.PROVISIONAL: + self.phase = Phase.COMMITTED + return self.observe() + + def abort(self, deadline=None): + if self.phase is Phase.PROVISIONAL: + self.phase = Phase.ABORTING + return self.observe() + + +@pytest.fixture +def channels(): + left, right = socket.socketpair() + port = Port() + parent, child = ServiceParentHandoffV1(left, port), ServiceChildHandoffV1(right, port) + try: + yield parent, child, port + finally: + parent.close() + child.close() + + +def test_timeout_never_aborts_or_commits(channels): + parent, child, port = channels + assert parent.wait(timeout=0) is Phase.UNKNOWN + assert port.phase is Phase.PROVISIONAL + assert child.poll_parent() is Phase.PROVISIONAL + + +def test_commit_survives_parent_close_and_late_abort(channels): + parent, child, port = channels + assert child.commit() is Phase.COMMITTED + assert parent.wait(timeout=1) is Phase.COMMITTED + assert parent.abort() is Phase.COMMITTED + parent.close() + assert child.poll_parent() is Phase.COMMITTED + assert child.commit() is Phase.COMMITTED + assert port.phase is Phase.COMMITTED + + +def test_parent_eof_before_readiness_requires_durable_abort(channels): + parent, child, port = channels + parent.close() + assert child.commit() is Phase.ABORTING + assert port.phase is Phase.ABORTING + + +def test_unknown_storage_after_parent_loss_is_not_cleanup_authority(channels): + parent, child, port = channels + port.failure = True + parent.close() + assert child.poll_parent() is Phase.UNKNOWN + assert port.phase is Phase.PROVISIONAL + port.failure = False + assert child.poll_parent() is Phase.ABORTING + + +def test_wire_byte_is_not_commit_proof(channels): + parent, child, port = channels + child._endpoint.send(b"C") + assert parent.wait(timeout=0.1) is Phase.UNKNOWN + assert port.phase is Phase.PROVISIONAL + + +def test_unexpected_parent_data_aborts_via_port(channels): + parent, child, port = channels + parent._endpoint.send(b"C") + assert child.poll_parent() is Phase.ABORTING + assert port.phase is Phase.ABORTING + + +def test_ack_loss_after_durable_commit_never_undoes_state(channels, monkeypatch): + parent, child, port = channels + original = port.commit + + def commit_and_disconnect(deadline): + result = original() + parent.close() + return result + + monkeypatch.setattr(port, "commit", commit_and_disconnect) + assert child.commit() is Phase.COMMITTED + assert child.poll_parent() is Phase.COMMITTED + + +def test_exception_after_commit_is_unknown_until_reobserved(channels, monkeypatch): + parent, child, port = channels + + def commit_then_error(deadline): + port.phase = Phase.COMMITTED + raise OSError("reply lost") + + monkeypatch.setattr(port, "commit", commit_then_error) + assert child.commit() is Phase.UNKNOWN + parent.close() + assert child.poll_parent() is Phase.COMMITTED + + +@pytest.mark.parametrize("timeout", [True, -1, 31, float("nan"), float("inf"), 10**1000, "1"]) +def test_invalid_timeouts_are_bounded_errors(channels, timeout): + with pytest.raises(HostingError) as caught: + channels[0].wait(timeout=timeout) + assert caught.value.category is HostingFailureCategory.INVALID_REQUEST + + +def test_adopted_channels_do_not_leak_to_exec(channels): + parent, child, _ = channels + for owner in (parent, child): + assert not owner._endpoint.getblocking() + assert not os.get_inheritable(owner._endpoint.fileno()) + + +def test_failed_admission_leaves_socket_with_caller(): + endpoint = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + try: + with pytest.raises(HostingError): + ServiceParentHandoffV1(endpoint, Port()) + assert endpoint.fileno() >= 0 + finally: + endpoint.close() + + +def test_closed_channel_rejects_new_calls(channels): + parent, child, _ = channels + parent.close() + child.close() + for operation in (lambda: parent.wait(timeout=0), parent.abort, child.commit, child.poll_parent): + with pytest.raises(HostingError) as caught: + operation() + assert caught.value.category is HostingFailureCategory.HOST_CLOSED + + +def test_contended_wait_spends_no_extra_lock_budget(channels): + parent, _, _ = channels + locked, release = threading.Event(), threading.Event() + + def hold(): + with parent._mutex: + locked.set() + assert release.wait(5) + + worker = threading.Thread(target=hold) + worker.start() + try: + assert locked.wait(5) + with pytest.raises(HostingError) as caught: + parent.wait(timeout=0) + assert caught.value.category is HostingFailureCategory.CAPACITY_EXHAUSTED + finally: + release.set() + worker.join(5) + assert not worker.is_alive() + + +def test_invalid_port_value_never_becomes_authority(channels): + parent, child, port = channels + port.phase = "committed" + assert parent.wait(timeout=0) is Phase.UNKNOWN + assert child.commit() is Phase.UNKNOWN + + +def test_budget_exhaustion_does_not_start_second_observation(channels, monkeypatch): + import loushang.hosting.service_handoff as module + + parent, _, port = channels + now = [10.0] + deadlines = [] + monkeypatch.setattr(module, "monotonic", lambda: now[0]) + + def observe(deadline): + deadlines.append(deadline) + now[0] = deadline + return Phase.PROVISIONAL + + monkeypatch.setattr(port, "observe", observe) + assert parent.wait(timeout=1) is Phase.UNKNOWN + assert deadlines == [11.0] + + +def test_zero_budget_does_not_enter_port(channels, monkeypatch): + parent, _, port = channels + + def forbidden(deadline): + pytest.fail("expired budget must not admit IO") + + monkeypatch.setattr(port, "observe", forbidden) + assert parent.wait(timeout=0) is Phase.UNKNOWN + + +def test_child_observation_and_cas_share_one_deadline(channels, monkeypatch): + import loushang.hosting.service_handoff as module + + _, child, port = channels + now, deadlines = [10.0], [] + monkeypatch.setattr(module, "monotonic", lambda: now[0]) + + def observe(deadline): + deadlines.append(deadline) + now[0] += 0.5 + return Phase.PROVISIONAL + + def commit(deadline): + deadlines.append(deadline) + return Phase.COMMITTED + + monkeypatch.setattr(port, "observe", observe) + monkeypatch.setattr(port, "commit", commit) + assert child.commit() is Phase.COMMITTED + assert deadlines == [12.0, 12.0] + + +def test_close_fences_new_calls_while_admitted_observation_finishes(channels, monkeypatch): + parent, _, port = channels + entered, release = threading.Event(), threading.Event() + results = [] + + def observe(deadline): + entered.set() + assert release.wait(5) + return Phase.COMMITTED + + monkeypatch.setattr(port, "observe", observe) + worker = threading.Thread(target=lambda: results.append(parent.wait(timeout=5))) + closer = threading.Thread(target=parent.close) + worker.start() + try: + assert entered.wait(5) + closer.start() + assert parent._closing.wait(5) + with pytest.raises(HostingError) as caught: + parent.wait(timeout=0) + assert caught.value.category is HostingFailureCategory.HOST_CLOSED + finally: + release.set() + worker.join(5) + if closer.ident is not None: + closer.join(5) + assert not worker.is_alive() and not closer.is_alive() + assert results == [Phase.COMMITTED] From 64ad7b0345dcbcb82efbcf0ae94c48bed1ef2d72 Mon Sep 17 00:00:00 2001 From: zhnt Date: Sun, 13 Sep 2026 08:14:45 +0000 Subject: [PATCH 06/44] feat(hosting): own detached Linux service creation --- .../architecture/apphost/lmux-contract-m0.md | 49 ++- .../hosted-product-runtime-v1-inventory.md | 1 + src/loushang/hosting/_posix_process.py | 28 +- src/loushang/hosting/service_process.py | 222 ++++++++++ tests/apphost/test_managed_handoff.py | 49 ++- ...test_hosted_product_g10_explicit_canary.py | 12 +- .../test_hosted_product_runtime_g9_closure.py | 7 +- ...test_hosted_product_runtime_v1_baseline.py | 1 + .../architecture/test_hosting_h0_contract.py | 3 + tests/hosting/test_service_process.py | 388 ++++++++++++++++++ 10 files changed, 728 insertions(+), 32 deletions(-) create mode 100644 src/loushang/hosting/service_process.py create mode 100644 tests/hosting/test_service_process.py diff --git a/docs/internals/architecture/apphost/lmux-contract-m0.md b/docs/internals/architecture/apphost/lmux-contract-m0.md index 65120edc7..fb7b70e00 100644 --- a/docs/internals/architecture/apphost/lmux-contract-m0.md +++ b/docs/internals/architecture/apphost/lmux-contract-m0.md @@ -8,8 +8,8 @@ - ID: `LMUX-M0` - Authority: normative — accepted limited Linux managed-profile contract - Design status: accepted -- Review status: three-perspective storage/lifecycle/observer/handoff slice reviews passed; recovery admission pending -- Implementation status: partial — storage, instance coordination, Linux exit observations and handoff binding; no activation +- Review status: three-perspective storage/lifecycle/observer/handoff/native-launch slice reviews passed; recovery admission pending +- Implementation status: partial — storage, instance coordination, Linux observations, handoff and native launch owner; no activation - Owner: AppHost managed deployment; sibling changes remain sibling-owned - Tracking objective: active Linux lmux goal, branch `harness/lmux-managed-service` @@ -239,6 +239,7 @@ Mux 外壳只负责成员/连接组合。主题与终端能力在客户端组合 | M2 代际协调 | per-service fence、持久 prepare/commit/abort/stop、三项结算事实及干净停止后换代 | native 启动交接、异常 retire/recovery admission、真实后台激活均未完成 | | M2 Linux 退出观察 | boot/PID/start-time/实际 UID/PID namespace 与保留 pidfd;26 项原生回归、三视角复审通过 | 原生 spawn/handoff、进程树结算及异常恢复准入仍待接线 | | M2 持久交接接线 | 继承通道 + exact instance/attempt journal port;共享协作 deadline、真实启动器退出和 EOF/CAS 竞争测试,三视角复审通过 | 生产 spawn/daemon 与应用 owner 清理尚未接入,不构成 SSH/后台交付 | +| M2 Linux 创建 owner | 单次生产 Popen 创建、受管 FD/环境/工作区、复用 POSIX group 观察;真实父端退出和故障矩阵、三视角复审通过 | 身份持久登记、应用自持生命周期、异常恢复和 CLI 激活仍待完成 | | 一条命令/后台/全局名/多 Tab/stop | 仅 G16 既有显式能力 | M1–M3 全部接线 | | Session 唯一写入与默认历史 | 缺运行期跨进程合同实现 | writer lease + canonical catalog + 双进程测试 | | 完整共享 Harnesstui/Markdown | 草案与本文接缝 | M3 代码与 Embedded 非回退 | @@ -422,5 +423,45 @@ ack 丢失、事务提交后错误对账、锁/SQL 预算耗尽、到期回滚 精确消费者清单未登记新 adapter;显式登记模块和唯一公共导入 symbol 后, 对应失败节点复测 1 passed。不扩大对 Hosting 私有 launch profile 的准入。 Hosting Ruff/mypy 28 模块、AppHost Ruff/mypy 107 模块、文档 6 项和依赖图 -新鲜度通过。较大范围 AppHost 主回归仍在执行;最终 native/installed 和 -完整 change-aware/远端门禁仍在整体交付前完成,不将本切片检查冒称全通过。 +新鲜度通过。较大范围 AppHost 主回归结束:1508 passed / 12 skipped, +两处失败均为旧 G9/G10 精确依赖清单只登记 foreground launcher;现为 +managed handoff 单独登记唯一公共 Hosting 依赖,对应两个节点已在 §15 +聚焦 69 项回归中通过。 +最终 native/installed 和完整 change-aware/远端门禁仍在整体交付前完成, +不将本切片检查冒称全通过。 + +## 15. M2 Linux 原生创建接线 + +`LinuxServiceProcessV1` 在 native effect 前先成为调用者持有的 owner;只允许 +一次创建,完整绝对 executable/argv/cwd/environment、独立 POSIX session、 +标准流 `/dev/null`、仅继承一个显式 startup socket。不接受 pipe/capture 的 +静默降级,不导入 Mux/AppServer,也不充当 H6 sealed plugin preparation +失败后的 fallback。该 local-managed profile 启动受信任的已安装服务入口。 + +采用同步 Popen 的专属 owner,避免父解释器关闭 asyncio transport 时终止 +服务。保留原 `_PosixProcess` 进程组机制,通过结构协议接入自己的 Popen; +只有实际 reap 后才提供 returncode,不把 pidfd 的 exited 等同于已 reap。 +创建错误不产生“证明未创建”的收据,禁止重复 spawn;身份捕获失败仍保留 +已经附着的 Popen 与 scope。close 只回收父端句柄并封闭创建,不发信号。 +进程组退出、父端句柄回收、应用 lease-last 成功仍为三个独立观察;逃逸出 +受控 scope 的子资源需要自己的 owner,不扫描全机器猜测归属。 + +真实 durable-handoff 测试改用该启动器,覆盖父端正常解释器退出与骤退; +标准流/控制终端、完整环境、FD 白名单、close/spawn 竞争和 leader 退出而 +后代仍存活单独测试。本机制尚未接入 CLI、Product 应用 owner、身份持久 +登记与异常恢复准入;不是一键启动、真实 SSH 或完整 lmux 交付完成证据。 + +机制依据:[Python Popen](https://docs.python.org/3/library/subprocess.html#subprocess.Popen) +和 [setsid](https://man7.org/linux/man-pages/man2/setsid.2.html)。原生创建与 OS IO +不能承诺被 Python timeout 抢占;外层启动预算到期不能抛弃仍持有的 owner。 + +三视角复审通过。修复了 observer 关闭失败时跳过独立 socket 回收的问题: +各自尝试关闭,保留失败 owner 与未完成状态,支持重试而不发信号。真实 +子进程测试补齐显式环境覆盖/空值/Unicode、父环境不继承及指定 cwd;取消 +发生于 capture 后时,也验证了关闭故障、socket 释放与随后重试。 + +聚焦 69 passed,包含新启动器、既有 POSIX process、四种正常/骤退父端的 +持久交接,以及 G9/G10/H0/current inventory。完整 `check-hosting` 通过: +457 passed / 48 skipped,Ruff 与 mypy 29 模块通过;文档 6 项与依赖图 +新鲜度通过。未重复未改动的整套 AppHost 1508 项通过用例;其两个旧清单 +失败已精准修复并复测。不将本机制结论扩展为完整 installed/SSH 验收。 diff --git a/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md b/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md index 65f1f640b..515b879a8 100644 --- a/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md +++ b/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md @@ -66,6 +66,7 @@ lease, or factory. | optional managed instance journal | `src/loushang/apphost/managed/lifecycle.py` | stable per-service fence and durable instance generations, handoff/stop transitions and matching monotonic settlement observations; neither native process identity nor permission to terminate a process | | optional Linux service observation | `src/loushang/hosting/service.py` | boot/PID/start-time/UID/namespace lookup facts with retained pidfd capture, re-admission and bounded exit observation; no process launch, signal, process-tree settlement or facade activation | | optional inherited-channel handoff | `src/loushang/hosting/service_handoff.py` | child-side durable commit/abort proposals through a borrowed consumer port; EOF and acknowledgement hints never replace durable authority; no launch or application cleanup | +| optional Linux service launch | `src/loushang/hosting/service_process.py` | one owned Popen effect, detached stdio/session and exact inherited endpoint; retained identity and existing POSIX group observation; no implicit signals, sealed-preparation fallback or application-success inference | | optional managed handoff binding | `src/loushang/apphost/managed/handoff.py` | exact instance/attempt journal adapter for Hosting handoff; stale/uncertain reads never grant current authority; no process ownership | | AppServer Product ports | `src/loushang/appserver/ports.py` | immutable contract-only identity and Product-supplied Session/projection/work/interaction port bundle; no runtime, listener, protocol, or composition owner | | Session discovery composition | `src/loushang/harness/transcript/directory.py`, `src/loushang/harness/machine_resources/control_plane.py`, and `src/loushang/coding/cli/__main__.py` | canonical global plus cwd/home compatibility sources are selected at composition; cwd is a query/filter and compatibility roots are not writable authorities | diff --git a/src/loushang/hosting/_posix_process.py b/src/loushang/hosting/_posix_process.py index ae240c67e..e828b5319 100644 --- a/src/loushang/hosting/_posix_process.py +++ b/src/loushang/hosting/_posix_process.py @@ -7,7 +7,7 @@ import signal import subprocess from collections.abc import Callable -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Protocol, cast from ._launch_preparation import _ManagedSpawnEffect from ._process_backend import _ProcessInheritance, _ProcessTransport @@ -28,8 +28,32 @@ _SIGKILL = cast(signal.Signals, getattr(signal, "SIGKILL", 9)) +class _PosixChildProcess(Protocol): + """Owned child identity; returncode is set only after this child is reaped. + + Both the asyncio transport and optional synchronous service launcher supply + this seam. A pidfd exit observation alone cannot implement returncode: an + exited but unreaped leader is not evidence that its numeric PID was reused. + """ + + @property + def pid(self) -> int: ... + + @property + def returncode(self) -> int | None: ... + + @property + def stdin(self) -> asyncio.StreamWriter | None: ... + + @property + def stdout(self) -> asyncio.StreamReader | None: ... + + @property + def stderr(self) -> asyncio.StreamReader | None: ... + + class _PosixProcess: - def __init__(self, process: asyncio.subprocess.Process) -> None: + def __init__(self, process: _PosixChildProcess) -> None: process_group_id = process.pid if type(process_group_id) is not int or process_group_id <= 0: raise RuntimeError("POSIX process has no valid process-group identity") diff --git a/src/loushang/hosting/service_process.py b/src/loushang/hosting/service_process.py new file mode 100644 index 000000000..569cad23f --- /dev/null +++ b/src/loushang/hosting/service_process.py @@ -0,0 +1,222 @@ +"""Optional Linux background launch owner, without implicit termination. + +The consumer adopts this owner before spawn and fences its durable attempt +before crossing the native effect. This local managed profile accepts complete +pathname launch material, not sealed plugin preparation; it is not a fallback +for H6 containment. No async transport destructor can terminate this service. +""" + +from __future__ import annotations + +import os +import socket +import subprocess +import sys +from threading import Event, RLock +from time import monotonic + +from ._posix_process import _PosixProcess +from .contracts import ( + ProcessLaunchRequest, + ProcessStderrMode, + ProcessStdinMode, + ProcessStdoutMode, +) +from .errors import HostingError, HostingFailureCategory +from .service import LinuxServiceIdentityV1, LinuxServiceObserverV1 + + +class _ServiceChild: + """Popen reaps only our direct child; no pidfd-to-returncode substitution.""" + + stdin = stdout = stderr = None + + def __init__(self, process: subprocess.Popen[bytes]) -> None: + self._process = process + + @property + def pid(self) -> int: + return self._process.pid + + @property + def returncode(self) -> int | None: + return self._process.poll() + + +class LinuxServiceProcessV1: + """Adopt the child endpoint at construction, then allow exactly one spawn. + + The caller owns the parent endpoint and durable handoff. close() releases + only parent-local descriptors and fences spawn; it never signals or waits + for the service. scope_exited() is separate from local handles_closed and + from application cleanup. Live committed services are expected after close. + + The child must adopt its control FD before preparing Product resources. + Synchronous native creation/IO cannot be preempted by a Python timeout; the + consumer retains this owner and the durable fence across uncertain returns. + """ + + def __init__(self, request: ProcessLaunchRequest, child_endpoint: socket.socket) -> None: + if sys.platform != "linux": + raise _error(HostingFailureCategory.PLATFORM_UNSUPPORTED) + if (type(request) is not ProcessLaunchRequest or not os.path.isabs(request.argv[0]) + or request.streams.stdin is not ProcessStdinMode.CLOSED + or request.streams.stdout is not ProcessStdoutMode.DISCARD + or request.streams.stderr is not ProcessStderrMode.DISCARD + or type(child_endpoint) is not socket.socket): + raise _error(HostingFailureCategory.INVALID_REQUEST) + try: + if (child_endpoint.family != socket.AF_UNIX or child_endpoint.fileno() < 3 + or child_endpoint.getsockopt(socket.SOL_SOCKET, socket.SO_TYPE) != socket.SOCK_STREAM): + raise _error(HostingFailureCategory.INVALID_REQUEST) + child_endpoint.getpeername() + child_endpoint.set_inheritable(False) + except OSError: + # Admission failure leaves ownership with the caller. + raise _error(HostingFailureCategory.INVALID_REQUEST) from None + self._request: ProcessLaunchRequest | None = request + self._endpoint = child_endpoint + self._process: subprocess.Popen[bytes] | None = None + self._scope: _PosixProcess | None = None + self._observer: LinuxServiceObserverV1 | None = None + self._identity: LinuxServiceIdentityV1 | None = None + self._attempted = False + self._handles_closed = False + self._closing = Event() + self._mutex = RLock() + + @property + def identity(self) -> LinuxServiceIdentityV1 | None: + """Captured lookup facts, never readiness or signal authority.""" + return self._identity + + @property + def handles_closed(self) -> bool: + return self._handles_closed + + @property + def creation_uncertain(self) -> bool: + """An attempted spawn without attached process must remain fenced.""" + return self._attempted and self._process is None + + def _enter(self, *, timeout: float = 0.0, allow_closed: bool = False) -> float: + if type(timeout) not in (int, float) or not 0 <= timeout <= 30: + raise _error(HostingFailureCategory.INVALID_REQUEST) + deadline = monotonic() + timeout + if self._closing.is_set() and not allow_closed: + raise _error(HostingFailureCategory.HOST_CLOSED) + if not self._mutex.acquire(timeout=timeout): + raise _error(HostingFailureCategory.CAPACITY_EXHAUSTED) + if self._closing.is_set() and not allow_closed: + self._mutex.release() + raise _error(HostingFailureCategory.HOST_CLOSED) + return deadline + + def spawn(self) -> LinuxServiceIdentityV1: + """One effect, no retry and no settled-without-process error receipt.""" + self._enter() + try: + if self._attempted or self._request is None: + raise _error(HostingFailureCategory.SPAWN_FAILED) + request = self._request + self._request = None + self._attempted = True + try: + self._process = subprocess.Popen( + request.argv, executable=request.argv[0], cwd=request.cwd, + env=dict(request.effective_environment), shell=False, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, close_fds=True, start_new_session=True, + pass_fds=(self._endpoint.fileno(),), + ) + self._scope = _PosixProcess(_ServiceChild(self._process)) + self._observer = LinuxServiceObserverV1.capture(self._process.pid) + self._identity = self._observer.identity + self._endpoint.close() # No parent copy of the child's socket. + return self._identity + except (OSError, subprocess.SubprocessError): + raise _error(HostingFailureCategory.SPAWN_FAILED) from None + # Other exceptions also retain this attempted owner. In particular, + # observer failure does not discard the attached Popen/scope. + finally: + self._mutex.release() + + def leader_exited(self) -> bool: + """Direct-child reaping fact, not scope/application settlement.""" + self._enter(allow_closed=True) + try: + return self._process is not None and self._process.poll() is not None + finally: + self._mutex.release() + + def scope_exited(self) -> bool: + """Original process group and leader are gone; escaped resources excluded. + + The admitted profile must keep descendants within owned groups or under + separate child-resource owners. This query does not scan the machine or + claim settlement of descendants that escaped that ownership model. + """ + self._enter(allow_closed=True) + try: + if not self._attempted: + return self._closing.is_set() + if self._process is None or self._process.poll() is None: + return False + # Retain attachment even if failure happened before scope creation. + if self._scope is None: + self._scope = _PosixProcess(_ServiceChild(self._process)) + try: + return not self._scope.group_exists() + except OSError: + raise _error(HostingFailureCategory.CLEANUP_FAILED) from None + finally: + self._mutex.release() + + def wait_scope(self, *, timeout: float) -> bool: + """Cooperatively wait on owned facts; no timeout triggers a signal.""" + deadline = self._enter(timeout=timeout, allow_closed=True) + try: + while True: + if self.scope_exited(): + return True + remaining = deadline - monotonic() + if remaining <= 0: + return False + # A local wait primitive, not a service control channel. + Event().wait(min(0.01, remaining)) + finally: + self._mutex.release() + + def close(self) -> None: + """Fence new creation and release only local handles, never terminate.""" + self._closing.set() + self._enter(timeout=30, allow_closed=True) + try: + self._request = None + if self._handles_closed: + return + primary: BaseException | None = None + if self._observer is not None: + try: + self._observer.close() + except BaseException as error: + primary = error + else: + self._observer = None + try: + self._endpoint.close() + except BaseException as error: + if primary is None: + primary = error + if primary is not None: + if isinstance(primary, Exception): + raise _error(HostingFailureCategory.CLEANUP_FAILED) from None + primary.add_note("service_process_cleanup_incomplete") + raise primary + self._handles_closed = True + finally: + self._mutex.release() + + +def _error(category: HostingFailureCategory) -> HostingError: + return HostingError(category, "service_process_" + category.value) diff --git a/tests/apphost/test_managed_handoff.py b/tests/apphost/test_managed_handoff.py index 648bd2f35..d925c911f 100644 --- a/tests/apphost/test_managed_handoff.py +++ b/tests/apphost/test_managed_handoff.py @@ -8,6 +8,7 @@ import sys import threading from dataclasses import replace +from secrets import token_hex from time import monotonic import pytest @@ -221,8 +222,9 @@ def long_query(connection): from loushang.apphost.managed.handoff import ManagedServiceHandoffPortV1 from loushang.hosting.service_handoff import ServiceChildHandoffV1 root = Path(sys.argv[1]) -control = socket.socket(fileno=int(sys.argv[2])) +control = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) control.settimeout(8) +control.connect('\0' + sys.argv[2]) endpoint = socket.socket(fileno=int(sys.argv[3])) namespace = ManagedNamespaceV1(str(root / 'platform'), os.geteuid(), 'a' * 32) registry = ManagedRegistryV1(root / 'registry', namespace) @@ -246,33 +248,40 @@ def long_query(connection): ''' _STARTER = r''' -import json, os, socket, subprocess, sys +import json, os, socket, sys +from loushang.hosting.contracts import ProcessLaunchRequest, ProcessStreamSpec, ProcessStdinMode, ProcessStdoutMode, ProcessStderrMode +from loushang.hosting.service_process import LinuxServiceProcessV1 parent, child = socket.socketpair() -control_fd = int(sys.argv[2]) command = json.loads(sys.argv[1]) + [str(child.fileno())] -process = subprocess.Popen(command, pass_fds=(child.fileno(), control_fd), - stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, start_new_session=True) -child.close() -os.close(control_fd) -print(process.pid, flush=True) +request = ProcessLaunchRequest(tuple(command), sys.argv[2], tuple(os.environ.items()), + ProcessStreamSpec(ProcessStdinMode.CLOSED, ProcessStdoutMode.DISCARD, ProcessStderrMode.DISCARD)) +owner = LinuxServiceProcessV1(request, child) +identity = owner.spawn() +print(identity.pid, flush=True) sys.stdin.buffer.read(1) -os._exit(0) +if sys.argv[3] == 'abrupt': + os._exit(0) +owner.close() +parent.close() ''' @pytest.mark.parametrize("committed", [False, True]) -def test_real_starter_exit_obeys_durable_handoff(owners, tmp_path, committed): +@pytest.mark.parametrize("exit_mode", ["normal", "abrupt"]) +def test_real_starter_exit_obeys_durable_handoff(owners, tmp_path, committed, exit_mode): journal, _, port = owners - control, inherited = socket.socketpair() - control.settimeout(8) - command = [sys.executable, "-c", _CHILD, str(tmp_path), str(inherited.fileno())] + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + address = "lmux-handoff-test-" + token_hex(16) + listener.bind("\0" + address) + listener.listen(1) + listener.settimeout(8) + control = None + command = [sys.executable, "-c", _CHILD, str(tmp_path), address] starter = subprocess.Popen( - [sys.executable, "-c", _STARTER, json.dumps(command), str(inherited.fileno())], - pass_fds=(inherited.fileno(),), stdin=subprocess.PIPE, stdout=subprocess.PIPE, + [sys.executable, "-c", _STARTER, json.dumps(command), str(tmp_path), exit_mode], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) - inherited.close() observer = None try: assert starter.stdout is not None @@ -280,6 +289,8 @@ def test_real_starter_exit_obeys_durable_handoff(owners, tmp_path, committed): poller.register(starter.stdout, select.POLLIN) assert poller.poll(5000), "starter failed to report child" observer = LinuxServiceObserverV1.capture(int(starter.stdout.readline())) + control, _ = listener.accept() + control.settimeout(8) assert control.recv(1) == b"R" if committed: control.sendall(b"C") @@ -298,7 +309,9 @@ def test_real_starter_exit_obeys_durable_handoff(owners, tmp_path, committed): assert not journal.read().evidence.process_scope_settled assert not observer.exited() finally: - control.close() # EOF is the test child's own exit protocol, no signals. + listener.close() + if control is not None: + control.close() # EOF is the test child's own exit protocol, no signals. starter.communicate(timeout=5) if observer is not None: try: diff --git a/tests/architecture/test_hosted_product_g10_explicit_canary.py b/tests/architecture/test_hosted_product_g10_explicit_canary.py index f1ec6e029..b0b707fbb 100644 --- a/tests/architecture/test_hosted_product_g10_explicit_canary.py +++ b/tests/architecture/test_hosted_product_g10_explicit_canary.py @@ -130,11 +130,13 @@ def test_dependency_graph_has_only_the_accepted_product_owned_edges( ): for path in package.rglob("*.py"): imports = _imports(path) - if path == Path("src/loushang/apphost/launcher.py"): - assert {name for name in imports if name.startswith("loushang.hosting")} == { - "loushang.hosting.contracts", - } - imports = imports - {"loushang.hosting.contracts"} + accepted_hosting = { + Path("src/loushang/apphost/launcher.py"): {"loushang.hosting.contracts"}, + Path("src/loushang/apphost/managed/handoff.py"): {"loushang.hosting.service_handoff"}, + }.get(path) + if accepted_hosting is not None: + assert {name for name in imports if name.startswith("loushang.hosting")} == accepted_hosting + imports = imports - accepted_hosting assert not any( name == prefix or name.startswith(f"{prefix}.") for prefix in forbidden diff --git a/tests/architecture/test_hosted_product_runtime_g9_closure.py b/tests/architecture/test_hosted_product_runtime_g9_closure.py index 81a233037..20dd94e62 100644 --- a/tests/architecture/test_hosted_product_runtime_g9_closure.py +++ b/tests/architecture/test_hosted_product_runtime_g9_closure.py @@ -546,9 +546,10 @@ def test_g9_4_retains_apphost_core_and_current_inventory_fences() -> None: name for name in imports if name == "loushang.hosting" or name.startswith("loushang.hosting.") } - assert hosting_imports == ( - {"loushang.hosting.contracts"} if path == APPHOST / "launcher.py" else set() - ) + assert hosting_imports == { + APPHOST / "launcher.py": {"loushang.hosting.contracts"}, + APPHOST / "managed/handoff.py": {"loushang.hosting.service_handoff"}, + }.get(path, set()) for path in APPHOST_CORE: source = _read(path) imports = _imports(path) diff --git a/tests/architecture/test_hosted_product_runtime_v1_baseline.py b/tests/architecture/test_hosted_product_runtime_v1_baseline.py index ad0edcbbf..b637dc0d5 100644 --- a/tests/architecture/test_hosted_product_runtime_v1_baseline.py +++ b/tests/architecture/test_hosted_product_runtime_v1_baseline.py @@ -42,6 +42,7 @@ HOSTING_SOURCE / "_posix_process.py", HOSTING_SOURCE / "service.py", HOSTING_SOURCE / "service_handoff.py", + HOSTING_SOURCE / "service_process.py", HOSTING_SOURCE / "_windows_process.py", HOSTING_SOURCE / "_win32_process.py", HOSTING_SOURCE / "_endpoint_host.py", diff --git a/tests/architecture/test_hosting_h0_contract.py b/tests/architecture/test_hosting_h0_contract.py index 271e356a4..ebe9f07e5 100644 --- a/tests/architecture/test_hosting_h0_contract.py +++ b/tests/architecture/test_hosting_h0_contract.py @@ -39,6 +39,7 @@ OPTIONAL_MANAGED_SERVICE_MODULES = { HOSTING_ROOT / "service.py", HOSTING_ROOT / "service_handoff.py", + HOSTING_ROOT / "service_process.py", } FORBIDDEN_PUBLIC_TERMS = { "Approval", @@ -99,6 +100,8 @@ def test_h0_public_surface_exposes_no_platform_or_caller_authority_types() -> No assert "ServiceChildHandoff" not in public_surface assert "ServiceParentHandoff" not in public_surface assert "from .service_handoff import" not in public_surface + assert "LinuxServiceProcess" not in public_surface + assert "from .service_process import" not in public_surface def test_h0_observation_contract_has_no_arbitrary_payload_or_environment() -> None: diff --git a/tests/hosting/test_service_process.py b/tests/hosting/test_service_process.py new file mode 100644 index 000000000..25240c59a --- /dev/null +++ b/tests/hosting/test_service_process.py @@ -0,0 +1,388 @@ +from __future__ import annotations + +import json +import os +import socket +import subprocess +import sys +import threading +from dataclasses import replace + +import pytest + +from loushang.hosting.contracts import ( + ProcessLaunchRequest, + ProcessStderrMode, + ProcessStdinMode, + ProcessStdoutMode, + ProcessStreamSpec, +) +from loushang.hosting.errors import HostingError, HostingFailureCategory +from loushang.hosting.service_process import LinuxServiceProcessV1 + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux service spawn") + +_CHILD = r''' +import json, os, socket, sys +endpoint = socket.socket(fileno=int(sys.argv[1])) +endpoint.settimeout(8) +endpoint.set_inheritable(False) +try: + os.open('/dev/tty', os.O_RDONLY) + tty = True +except OSError: + tty = False +facts = dict(pid=os.getpid(), sid=os.getsid(0), pgid=os.getpgrp(), tty=tty, cwd=os.getcwd(), + stdio=[os.readlink('/proc/self/fd/' + str(i)) for i in range(3)], + env=dict(os.environ)) +endpoint.sendall(json.dumps(facts).encode() + b'\n') +endpoint.recv(1) +endpoint.close() +''' + + +def _request(tmp_path, fd, *, code=_CHILD, environment=()): + return ProcessLaunchRequest( + (sys.executable, "-c", code, str(fd)), str(tmp_path), environment, + ProcessStreamSpec(ProcessStdinMode.CLOSED, ProcessStdoutMode.DISCARD, ProcessStderrMode.DISCARD), + ) + + +@pytest.fixture +def launched(tmp_path): + parent, child = socket.socketpair() + parent.settimeout(8) + owner = LinuxServiceProcessV1(_request(tmp_path, child.fileno()), child) + try: + identity = owner.spawn() + facts = json.loads(_line(parent)) + yield owner, parent, identity, facts + finally: + parent.close() # The test child handles EOF; no native termination. + owner.close() + assert owner.wait_scope(timeout=8) + + +def test_spawn_is_detached_with_no_stdio_and_complete_environment(launched): + owner, _, identity, facts = launched + assert facts["pid"] == facts["sid"] == facts["pgid"] == identity.pid + assert not facts["tty"] + assert facts["stdio"] == ["/dev/null"] * 3 + # CPython may coerce the empty C locale, but no parent secrets are inherited. + assert set(facts["env"]) <= {"LC_CTYPE"} + assert not owner.creation_uncertain + assert not owner.leader_exited() + assert not owner.scope_exited() + assert not owner.handles_closed + assert owner._endpoint.fileno() == -1 + + +def test_close_only_releases_parent_handles_and_does_not_signal(launched, monkeypatch): + owner, parent, _, _ = launched + + def forbidden(*args): + pytest.fail("close must not send signals") + + with monkeypatch.context() as patch: + patch.setattr(os, "kill", forbidden) + patch.setattr(os, "killpg", forbidden) + owner.close() + owner.close() + assert owner.handles_closed + assert not owner.leader_exited() + assert not owner.scope_exited() + parent.sendall(b"Q") + assert owner.wait_scope(timeout=5) + + +def test_zero_timeout_never_terminates_running_service(launched): + owner, _, _, _ = launched + assert not owner.wait_scope(timeout=0) + assert not owner.leader_exited() + + +def test_second_spawn_is_refused(launched): + with pytest.raises(HostingError) as caught: + launched[0].spawn() + assert caught.value.category is HostingFailureCategory.SPAWN_FAILED + + +def test_close_before_spawn_fences_native_creation(tmp_path, monkeypatch): + parent, child = socket.socketpair() + owner = LinuxServiceProcessV1(_request(tmp_path, child.fileno()), child) + try: + owner.close() + monkeypatch.setattr(subprocess, "Popen", lambda *args, **kwargs: pytest.fail("must not create")) + with pytest.raises(HostingError) as caught: + owner.spawn() + assert caught.value.category is HostingFailureCategory.HOST_CLOSED + assert owner.scope_exited() + assert owner.handles_closed + assert not owner.creation_uncertain + finally: + parent.close() + owner.close() + + +def test_failed_spawn_is_unknown_not_a_retry_or_settlement_receipt(tmp_path): + parent, child = socket.socketpair() + request = replace(_request(tmp_path, child.fileno()), argv=(str(tmp_path / "missing-executable"),)) + owner = LinuxServiceProcessV1(request, child) + try: + with pytest.raises(HostingError) as caught: + owner.spawn() + assert caught.value.category is HostingFailureCategory.SPAWN_FAILED + assert owner.creation_uncertain + assert not owner.scope_exited() + with pytest.raises(HostingError): + owner.spawn() + owner.close() + assert owner.handles_closed + assert not owner.scope_exited() + finally: + parent.close() + owner.close() + + +def test_failed_identity_capture_keeps_attached_process_owner(tmp_path, monkeypatch): + import loushang.hosting.service_process as module + + parent, child = socket.socketpair() + parent.settimeout(8) + owner = LinuxServiceProcessV1(_request(tmp_path, child.fileno()), child) + + def fail(pid): + raise HostingError(HostingFailureCategory.PREPARATION_FAILED, "injected") + + monkeypatch.setattr(module.LinuxServiceObserverV1, "capture", fail) + try: + with pytest.raises(HostingError): + owner.spawn() + assert json.loads(_line(parent))["pid"] == owner._process.pid + assert not owner.creation_uncertain + assert owner.identity is None + assert not owner.scope_exited() + finally: + parent.close() + owner.close() + assert owner.wait_scope(timeout=8) + + +@pytest.mark.parametrize("change", ["executable", "stdin", "stdout", "stderr"]) +def test_admission_rejects_ambient_executable_or_stream_downgrade(tmp_path, change): + parent, child = socket.socketpair() + request = _request(tmp_path, child.fileno()) + try: + with pytest.raises(HostingError): + if change == "executable": + request = replace(request, argv=("python", "-c", "pass")) + else: + value = {"stdin": ProcessStdinMode.PIPE, "stdout": ProcessStdoutMode.PIPE, + "stderr": ProcessStderrMode.CAPTURE_TAIL}[change] + request = replace(request, streams=replace(request.streams, **{change: value})) + LinuxServiceProcessV1(request, child) + assert child.fileno() >= 3 + finally: + parent.close() + child.close() + + +@pytest.mark.parametrize("timeout", [True, -1, 31, float("nan"), float("inf"), 10**1000]) +def test_invalid_wait_budget_never_reaches_native_io(launched, timeout): + with pytest.raises(HostingError) as caught: + launched[0].wait_scope(timeout=timeout) + assert caught.value.category is HostingFailureCategory.INVALID_REQUEST + + +def test_only_explicit_startup_socket_survives_exec(tmp_path): + parent, child = socket.socketpair() + extra_left, extra_right = socket.socketpair() + extra_left.set_inheritable(True) + target = os.readlink(f"/proc/self/fd/{extra_left.fileno()}") + code = r''' +import os, socket, sys +channel = socket.socket(fileno=int(sys.argv[1])) +try: + value = os.readlink('/proc/self/fd/' + sys.argv[2]) +except OSError: + value = 'closed' +channel.sendall(value.encode() + b'\n') +channel.recv(1) +''' + request = _request(tmp_path, child.fileno(), code=code) + request = replace(request, argv=(*request.argv, str(extra_left.fileno()))) + owner = LinuxServiceProcessV1(request, child) + parent.settimeout(8) + try: + owner.spawn() + assert _line(parent).strip().decode() != target + finally: + parent.close() + extra_left.close() + extra_right.close() + owner.close() + assert owner.wait_scope(timeout=8) + + +def test_close_during_native_creation_retains_the_single_spawn(tmp_path, monkeypatch): + import loushang.hosting.service_process as module + + parent, child = socket.socketpair() + parent.settimeout(8) + owner = LinuxServiceProcessV1(_request(tmp_path, child.fileno()), child) + entered, release = threading.Event(), threading.Event() + original = module.subprocess.Popen + results = [] + + def create(*args, **kwargs): + entered.set() + assert release.wait(5) + return original(*args, **kwargs) + + monkeypatch.setattr(module.subprocess, "Popen", create) + worker = threading.Thread(target=lambda: results.append(owner.spawn())) + closer = threading.Thread(target=owner.close) + worker.start() + try: + assert entered.wait(5) + closer.start() + assert owner._closing.wait(5) + with pytest.raises(HostingError) as caught: + owner.spawn() + assert caught.value.category is HostingFailureCategory.HOST_CLOSED + release.set() + worker.join(5) + closer.join(5) + assert not worker.is_alive() and not closer.is_alive() + assert len(results) == 1 + assert json.loads(_line(parent))["pid"] == results[0].pid + assert owner.handles_closed + assert not owner.leader_exited() + finally: + release.set() + worker.join(5) + if closer.ident is not None: + closer.join(5) + parent.close() + owner.close() + assert owner.wait_scope(timeout=8) + + +def test_leader_exit_is_not_settlement_of_a_living_descendant(tmp_path): + parent, child = socket.socketpair() + parent.settimeout(8) + code = r''' +import os, socket, sys +channel = socket.socket(fileno=int(sys.argv[1])) +channel.settimeout(8) +channel.sendall(b'R') +if channel.recv(1) != b'G': + sys.exit(0) +pid = os.fork() +if pid == 0: + channel.sendall(b'D') + channel.recv(1) + channel.close() + os._exit(0) +channel.close() +os._exit(0) +''' + owner = LinuxServiceProcessV1(_request(tmp_path, child.fileno(), code=code), child) + try: + owner.spawn() + assert parent.recv(1) == b"R" + parent.sendall(b"G") + assert parent.recv(1) == b"D" + assert owner._process.wait(timeout=5) == 0 + assert owner.leader_exited() + assert not owner.scope_exited() + owner.close() + assert owner.handles_closed + assert not owner.scope_exited() + finally: + parent.close() + owner.close() + assert owner.wait_scope(timeout=8) + + +def test_explicit_environment_and_workspace_reach_the_real_child(tmp_path, monkeypatch): + monkeypatch.setenv("LMUX_AMBIENT_ONLY", "must not inherit") + monkeypatch.setenv("LMUX_EXPLICIT", "parent value") + expected = {"LMUX_EXPLICIT": "显式 value with spaces", "LMUX_EMPTY": "", + "PYTHONCOERCECLOCALE": "0", "LC_ALL": "C"} + workspace = tmp_path / "workspace with spaces" + workspace.mkdir() + parent, child = socket.socketpair() + parent.settimeout(8) + owner = LinuxServiceProcessV1( + _request(workspace, child.fileno(), environment=tuple(expected.items())), child, + ) + try: + owner.spawn() + facts = json.loads(_line(parent)) + assert facts["env"] == expected + assert facts["cwd"] == str(workspace) + finally: + parent.close() + owner.close() + assert owner.wait_scope(timeout=8) + + +def test_cancelled_spawn_and_failed_observer_close_still_release_socket(tmp_path, monkeypatch): + import loushang.hosting.service_process as module + + parent, child = socket.socketpair() + parent.settimeout(8) + owner = LinuxServiceProcessV1(_request(tmp_path, child.fileno()), child) + original = module.LinuxServiceObserverV1.capture + + class CaptureThenCancel: + def __init__(self, pid): + self.observer = original(pid) + self.fail = True + + @property + def identity(self): + raise KeyboardInterrupt() + + def close(self): + if self.fail: + self.fail = False + raise OSError("injected observer close failure") + self.observer.close() + + monkeypatch.setattr(module.LinuxServiceObserverV1, "capture", CaptureThenCancel) + try: + with pytest.raises(KeyboardInterrupt): + owner.spawn() + assert json.loads(_line(parent))["pid"] == owner._process.pid + assert child.fileno() >= 3 + with monkeypatch.context() as patch: + patch.setattr(os, "kill", lambda *args: pytest.fail("no signals")) + patch.setattr(os, "killpg", lambda *args: pytest.fail("no signals")) + with pytest.raises(HostingError) as caught: + owner.close() + assert caught.value.category is HostingFailureCategory.CLEANUP_FAILED + assert child.fileno() == -1 + assert owner._observer is not None + assert not owner.handles_closed + owner.close() + assert owner._observer is None + assert owner.handles_closed + assert not owner.leader_exited() + finally: + parent.close() + owner.close() + assert owner.wait_scope(timeout=8) + + +def _line(endpoint): + result = bytearray() + while len(result) < 8192: + block = endpoint.recv(1) + if not block: + break + result.extend(block) + if block == b"\n": + return bytes(result) + pytest.fail("missing bounded child report") From d9be5f1014729d05de0bc1d886fbf7c8fe28e829 Mon Sep 17 00:00:00 2001 From: zhnt Date: Sun, 13 Sep 2026 08:34:50 +0000 Subject: [PATCH 07/44] feat(apphost): bind service handoff to durable native identity --- .../architecture/apphost/lmux-contract-m0.md | 37 +++++- .../hosted-product-runtime-v1-inventory.md | 2 +- src/loushang/apphost/managed/_database.py | 6 +- src/loushang/apphost/managed/handoff.py | 20 ++- src/loushang/apphost/managed/lifecycle.py | 96 ++++++++++++-- tests/apphost/test_managed_handoff.py | 79 ++++++++++- tests/apphost/test_managed_lifecycle.py | 125 ++++++++++++++++-- ...test_hosted_product_g10_explicit_canary.py | 3 +- .../test_hosted_product_runtime_g9_closure.py | 3 +- ...st_plugin_lifecycle_plc9c5_c50_baseline.py | 10 ++ 10 files changed, 344 insertions(+), 37 deletions(-) diff --git a/docs/internals/architecture/apphost/lmux-contract-m0.md b/docs/internals/architecture/apphost/lmux-contract-m0.md index fb7b70e00..f6b5c0ff0 100644 --- a/docs/internals/architecture/apphost/lmux-contract-m0.md +++ b/docs/internals/architecture/apphost/lmux-contract-m0.md @@ -8,8 +8,8 @@ - ID: `LMUX-M0` - Authority: normative — accepted limited Linux managed-profile contract - Design status: accepted -- Review status: three-perspective storage/lifecycle/observer/handoff/native-launch slice reviews passed; recovery admission pending -- Implementation status: partial — storage, instance coordination, Linux observations, handoff and native launch owner; no activation +- Review status: three-perspective storage/lifecycle/observer/handoff/native-launch/native-binding slice reviews passed; recovery admission pending +- Implementation status: partial — storage, instance coordination, Linux observations, native launch and durable birth binding; no activation - Owner: AppHost managed deployment; sibling changes remain sibling-owned - Tracking objective: active Linux lmux goal, branch `harness/lmux-managed-service` @@ -240,6 +240,7 @@ Mux 外壳只负责成员/连接组合。主题与终端能力在客户端组合 | M2 Linux 退出观察 | boot/PID/start-time/实际 UID/PID namespace 与保留 pidfd;26 项原生回归、三视角复审通过 | 原生 spawn/handoff、进程树结算及异常恢复准入仍待接线 | | M2 持久交接接线 | 继承通道 + exact instance/attempt journal port;共享协作 deadline、真实启动器退出和 EOF/CAS 竞争测试,三视角复审通过 | 生产 spawn/daemon 与应用 owner 清理尚未接入,不构成 SSH/后台交付 | | M2 Linux 创建 owner | 单次生产 Popen 创建、受管 FD/环境/工作区、复用 POSIX group 观察;真实父端退出和故障矩阵、三视角复审通过 | 身份持久登记、应用自持生命周期、异常恢复和 CLI 激活仍待完成 | +| M2 birth binding | schema v3 原生身份登记、精确匹配的 commit/abort;117 项聚焦验证、三视角复审通过 | 应用自持 owner、异常退役恢复、Session writer lease 与完整前端接线仍待完成 | | 一条命令/后台/全局名/多 Tab/stop | 仅 G16 既有显式能力 | M1–M3 全部接线 | | Session 唯一写入与默认历史 | 缺运行期跨进程合同实现 | writer lease + canonical catalog + 双进程测试 | | 完整共享 Harnesstui/Markdown | 草案与本文接缝 | M3 代码与 Embedded 非回退 | @@ -465,3 +466,35 @@ managed handoff 单独登记唯一公共 Hosting 依赖,对应两个节点已 457 passed / 48 skipped,Ruff 与 mypy 29 模块通过;文档 6 项与依赖图 新鲜度通过。未重复未改动的整套 AppHost 1508 项通过用例;其两个旧清单 失败已精准修复并复测。不将本机制结论扩展为完整 installed/SSH 验收。 + +## 16. M2 持久原生身份与提交准入 + +实例行升级为未激活 schema v3,新增有界、规范 JSON 编码的原生身份; +v1/v2 库显式拒绝,不自动迁移。直接复用 Hosting 的不可变 Linux 身份值, +不再复制一套 PID/start-time/boot/UID/namespace 字段协议。该依赖仅存在于 +可选 Linux managed lifecycle/handoff 两个模块,核心 contracts 与根 facade +不增加 Hosting 依赖;架构清单按模块及 symbol 精确登记。 + +可信启动 owner 捕获身份后,以同一 instance/attempt 登记。首次登记要求 +provisional 且没有 stop fence;同值重试幂等,不同值不得覆盖。commit 必须 +携带子端自己的 native identity,并与持久登记完全匹配;仅有实例号、PID +文本或 ready 通知不足以提交。读到身份也不是 liveness、scope 结算或 signal +权限,新客户端仍须用 Hosting 重新验证。干净换代在同一事务中清空旧身份, +不会把旧原生观察沿用到新实例。 + +未绑定 native 的 starter port 可观察和按精确 attempt 提出 abort,不能 +commit。绑定 native 的 child port 在登记出现后,observe/commit/abort 均 +要求匹配;其中 abort 前置检查与状态变更必须在同一 fence 事务中,不能 +先修改后再返回 UNKNOWN。登记前的 EOF 仍允许 provisional child 提出 +abort,之后迟到的登记被拒绝。正常/骤退父端的四种真实测试均使用生产 +launcher 的捕获值登记,子端独立捕获自身身份,验证两个观察与持久值一致。 + +这仅完成 birth binding 与交接准入,不代表应用自持 owner、异常退役恢复、 +Session writer lease、公共发现/连接或 CLI 激活已经完成。 + +三视角指出并修复了 wrong-native abort 的事后拒绝问题:原生匹配检查 +与修改在同一事务中完成,返回 UNKNOWN 不再掩盖已发生的状态修改。 +三视角复审通过;聚焦生命周期/交接/registry/file 及精确 G9/G10/C50/current +inventory 共 117 passed,Ruff、managed 八模块 mypy、文档 6 项、依赖图 +新鲜度通过。没有重复未改动的 Hosting 原生实现与 Product/UI 广泛套件, +完整 change-aware、真实安装/SSH/性能与最终三视角交付仍在整体目标内。 diff --git a/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md b/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md index 515b879a8..892f55396 100644 --- a/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md +++ b/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md @@ -63,7 +63,7 @@ lease, or factory. | optional managed-deployment values | `src/loushang/apphost/managed/__init__.py`, `src/loushang/apphost/managed/contracts.py` and `src/loushang/apphost/managed/paths.py` | closed pure namespace/service/instance identities, handoff transitions, three-fact stop evidence and injected layout; no facade/CLI activation or native authority | | optional Linux managed-file owner | `src/loushang/apphost/managed/_files.py` | private canonical directory/regular-file admission, stable nonblocking locks and bounded identity-checked record publication; uncomposed, not a service registry or process owner | | optional managed name registry | `src/loushang/apphost/managed/_database.py` and `src/loushang/apphost/managed/registry.py` | bounded SQLite name intents and Product/workspace/profile reuse index with exact operation matching; no native liveness authority, process activation or CLI composition | -| optional managed instance journal | `src/loushang/apphost/managed/lifecycle.py` | stable per-service fence and durable instance generations, handoff/stop transitions and matching monotonic settlement observations; neither native process identity nor permission to terminate a process | +| optional managed instance journal | `src/loushang/apphost/managed/lifecycle.py` | stable service fence, durable generations and immutable Linux native lookup registration; commit requires matching registration, stop/settlement facts stay separate; stored identity is neither liveness nor permission to terminate | | optional Linux service observation | `src/loushang/hosting/service.py` | boot/PID/start-time/UID/namespace lookup facts with retained pidfd capture, re-admission and bounded exit observation; no process launch, signal, process-tree settlement or facade activation | | optional inherited-channel handoff | `src/loushang/hosting/service_handoff.py` | child-side durable commit/abort proposals through a borrowed consumer port; EOF and acknowledgement hints never replace durable authority; no launch or application cleanup | | optional Linux service launch | `src/loushang/hosting/service_process.py` | one owned Popen effect, detached stdio/session and exact inherited endpoint; retained identity and existing POSIX group observation; no implicit signals, sealed-preparation fallback or application-success inference | diff --git a/src/loushang/apphost/managed/_database.py b/src/loushang/apphost/managed/_database.py index add2c6757..5ad2a4946 100644 --- a/src/loushang/apphost/managed/_database.py +++ b/src/loushang/apphost/managed/_database.py @@ -31,7 +31,7 @@ PAGE_SIZE = 4096 _LOCK = "registry.lock" _APPLICATION_ID = 0x4C4D5558 -_VERSION = 2 +_VERSION = 3 _SCHEMA = ( "CREATE TABLE identity (namespace TEXT PRIMARY KEY NOT NULL) WITHOUT ROWID", "CREATE TABLE services (service_id TEXT PRIMARY KEY NOT NULL, " @@ -44,7 +44,9 @@ "revision INTEGER NOT NULL CHECK(revision>0), instance_id TEXT NOT NULL, " "attempt_id TEXT NOT NULL, phase TEXT NOT NULL, stop_requested INTEGER NOT NULL, " "process_exited INTEGER NOT NULL, application_cleanup_completed INTEGER NOT NULL, " - "process_scope_settled INTEGER NOT NULL) WITHOUT ROWID", + "process_scope_settled INTEGER NOT NULL, native_identity TEXT " + "CHECK(native_identity IS NULL OR (typeof(native_identity)='text' " + "AND length(native_identity)<=1024))) WITHOUT ROWID", ) _SCHEMA_ROWS = tuple(sorted(( ("table", "identity", "identity", _SCHEMA[0]), diff --git a/src/loushang/apphost/managed/handoff.py b/src/loushang/apphost/managed/handoff.py index 4ff765d91..bd113e813 100644 --- a/src/loushang/apphost/managed/handoff.py +++ b/src/loushang/apphost/managed/handoff.py @@ -4,6 +4,7 @@ from collections.abc import Callable +from loushang.hosting.service import LinuxServiceIdentityV1 from loushang.hosting.service_handoff import ServiceHandoffPhaseV1 from ._files import ManagedStorageError @@ -22,17 +23,23 @@ class ManagedServiceHandoffPortV1: def __init__( self, journal: ManagedServiceJournalV1, instance: ManagedInstanceRefV1, - attempt_id: str, + attempt_id: str, *, native_identity: LinuxServiceIdentityV1 | None = None, ) -> None: if type(journal) is not ManagedServiceJournalV1 or type(instance) is not ManagedInstanceRefV1: raise ManagedContractError() _match(attempt_id, _HEX32) + if native_identity is not None and type(native_identity) is not LinuxServiceIdentityV1: + raise ManagedContractError() self._journal, self._instance, self._attempt = journal, instance, attempt_id + self._native = native_identity def _phase(self, state: ManagedServiceStateV1 | None) -> ServiceHandoffPhaseV1: if (state is None or state.handoff.instance != self._instance or state.handoff.attempt_id != self._attempt): return ServiceHandoffPhaseV1.UNKNOWN + if (self._native is not None and state.native_identity is not None + and self._native != state.native_identity): + return ServiceHandoffPhaseV1.UNKNOWN return ServiceHandoffPhaseV1(state.handoff.phase.value) def observe(self, deadline: float | None = None) -> ServiceHandoffPhaseV1: @@ -48,7 +55,14 @@ def _mutate(self, operation: Callable[[], ManagedServiceStateV1], deadline: floa return self.observe(deadline) def commit(self, deadline: float | None = None) -> ServiceHandoffPhaseV1: - return self._mutate(lambda: self._journal.commit(self._instance, self._attempt, deadline=deadline), deadline) + # Query/starter-side ports deliberately have no commit capability. + if self._native is None: + return ServiceHandoffPhaseV1.UNKNOWN + return self._mutate(lambda: self._journal.commit( + self._instance, self._attempt, native_identity=self._native, deadline=deadline, + ), deadline) def abort(self, deadline: float | None = None) -> ServiceHandoffPhaseV1: - return self._mutate(lambda: self._journal.abort(self._instance, self._attempt, deadline=deadline), deadline) + return self._mutate(lambda: self._journal.abort( + self._instance, self._attempt, native_identity=self._native, deadline=deadline, + ), deadline) diff --git a/src/loushang/apphost/managed/lifecycle.py b/src/loushang/apphost/managed/lifecycle.py index 2bb260195..e7f5c7c13 100644 --- a/src/loushang/apphost/managed/lifecycle.py +++ b/src/loushang/apphost/managed/lifecycle.py @@ -2,17 +2,21 @@ These records coordinate trusted owners. They neither discover live processes nor confer permission to kill one. A child-side owner alone proposes commit; -the eventual Hosting integration supplies native identity and settlement facts. +trusted Hosting callers supply native lookup values and settlement facts. """ from __future__ import annotations +import json import sqlite3 from collections.abc import Callable -from dataclasses import dataclass, replace +from dataclasses import asdict, dataclass, replace from pathlib import Path from secrets import token_hex +from loushang.hosting.errors import HostingError +from loushang.hosting.service import LinuxServiceIdentityV1 + from ._files import ManagedStorageError, PrivateManagedDirectory from .contracts import ( _HEX32, @@ -37,6 +41,7 @@ class ManagedServiceStateV1: revision: int handoff: ManagedHandoffV1 evidence: ManagedStopEvidenceV1 + native_identity: LinuxServiceIdentityV1 | None = None def __post_init__(self) -> None: if ( @@ -44,6 +49,8 @@ def __post_init__(self) -> None: or type(self.handoff) is not ManagedHandoffV1 or type(self.evidence) is not ManagedStopEvidenceV1 or self.handoff.instance != self.evidence.instance + or (self.native_identity is not None and type(self.native_identity) is not LinuxServiceIdentityV1) + or (self.handoff.phase is ManagedHandoffPhaseV1.COMMITTED and self.native_identity is None) ): raise ManagedContractError() if ( @@ -134,23 +141,71 @@ def prepare( self._fence._check() return state - def commit(self, instance: ManagedInstanceRefV1, attempt_id: str, *, deadline: float | None = None) -> ManagedServiceStateV1: - """Persist the child's proposal; a stop fence or abort wins by refusing it.""" + def register_native( + self, instance: ManagedInstanceRefV1, attempt_id: str, identity: LinuxServiceIdentityV1, + *, deadline: float | None = None, + ) -> ManagedServiceStateV1: + """Persist the trusted launch owner's capture, once per generation. + + This value is a lookup record, not current liveness or signal authority. + Same-value retries are idempotent even after commit; identity replacement + or new registration after stop/abort is never admitted. + """ _match(attempt_id, _HEX32) + self._require_native(identity) def update(current: ManagedServiceStateV1) -> ManagedServiceStateV1: if current.handoff.attempt_id != attempt_id: raise ManagedStorageError("conflict") + if current.native_identity == identity: + return current + if (current.native_identity is not None + or current.handoff.phase is not ManagedHandoffPhaseV1.PROVISIONAL + or current.handoff.stop_requested): + raise ManagedStorageError("conflict") + return replace(current, native_identity=identity) + + return self._update(instance, update, deadline=deadline) + + def _require_native(self, identity: LinuxServiceIdentityV1 | None) -> None: + if type(identity) is not LinuxServiceIdentityV1 or identity.user_id != self._namespace.user_id: + raise ManagedContractError() + + def commit( + self, instance: ManagedInstanceRefV1, attempt_id: str, *, + native_identity: LinuxServiceIdentityV1 | None = None, deadline: float | None = None, + ) -> ManagedServiceStateV1: + """Child proposal requires the durable native binding and no stop/abort.""" + _match(attempt_id, _HEX32) + self._require_native(native_identity) + + def update(current: ManagedServiceStateV1) -> ManagedServiceStateV1: + if current.handoff.attempt_id != attempt_id or current.native_identity != native_identity: + raise ManagedStorageError("conflict") return replace(current, handoff=current.handoff.commit()) return self._update(instance, update, deadline=deadline) - def abort(self, instance: ManagedInstanceRefV1, attempt_id: str, *, deadline: float | None = None) -> ManagedServiceStateV1: + def abort( + self, instance: ManagedInstanceRefV1, attempt_id: str, *, + native_identity: LinuxServiceIdentityV1 | None = None, deadline: float | None = None, + ) -> ManagedServiceStateV1: + """Starter may abort by exact attempt; bound children must match birth. + + Before birth registration, EOF still permits provisional child cleanup. + After registration, a child with a different native identity cannot + mutate the attempt. The check and transition share one transaction. + """ _match(attempt_id, _HEX32) + if native_identity is not None: + self._require_native(native_identity) def update(current: ManagedServiceStateV1) -> ManagedServiceStateV1: if current.handoff.attempt_id != attempt_id: raise ManagedStorageError("conflict") + if (native_identity is not None and current.native_identity is not None + and native_identity != current.native_identity): + raise ManagedStorageError("conflict") return replace(current, handoff=current.handoff.abort()) return self._update(instance, update, deadline=deadline) @@ -214,19 +269,22 @@ def _read(self, connection: sqlite3.Connection) -> ManagedServiceStateV1 | None: if key != (service.product_id, service.workspace, service.profile): raise ManagedStorageError("conflict") row = connection.execute("SELECT revision, instance_id, attempt_id, phase, stop_requested, " - "process_exited, application_cleanup_completed, process_scope_settled " + "process_exited, application_cleanup_completed, process_scope_settled, native_identity " "FROM instances WHERE service_id=?", (service.service_id,)).fetchone() if row is None: return None try: - if any(type(value) is not int or value not in (0, 1) for value in row[4:]): + if any(type(value) is not int or value not in (0, 1) for value in row[4:8]): raise ManagedContractError() instance = ManagedInstanceRefV1(self._namespace.namespace_key, service.service_id, row[1]) handoff = ManagedHandoffV1(instance, row[2], ManagedHandoffPhaseV1(row[3]), bool(row[4])) + native = _decode_native(row[8]) + if native is not None: + self._require_native(native) return ManagedServiceStateV1(row[0], handoff, ManagedStopEvidenceV1( instance, bool(row[5]), bool(row[6]), bool(row[7]), - )) - except (ManagedContractError, ValueError, TypeError): + ), native) + except (ManagedContractError, HostingError, ValueError, TypeError): raise ManagedStorageError("invalid_record") from None def _save(self, connection: sqlite3.Connection, state: ManagedServiceStateV1, *, insert: bool) -> None: @@ -234,15 +292,31 @@ def _save(self, connection: sqlite3.Connection, state: ManagedServiceStateV1, *, values = (state.revision, handoff.instance.instance_id, handoff.attempt_id, handoff.phase.value, int(handoff.stop_requested), int(evidence.process_exited), int(evidence.application_cleanup_completed), int(evidence.process_scope_settled), + _encode_native(state.native_identity), self._service.service_id) if insert: connection.execute("INSERT INTO instances (revision, instance_id, attempt_id, phase, " "stop_requested, process_exited, application_cleanup_completed, " - "process_scope_settled, service_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", values) + "process_scope_settled, native_identity, service_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", values) else: connection.execute("UPDATE instances SET revision=?, instance_id=?, attempt_id=?, phase=?, " "stop_requested=?, process_exited=?, application_cleanup_completed=?, " - "process_scope_settled=? WHERE service_id=?", values) + "process_scope_settled=?, native_identity=? WHERE service_id=?", values) + + +def _encode_native(identity: LinuxServiceIdentityV1 | None) -> str | None: + return None if identity is None else json.dumps(asdict(identity), sort_keys=True, separators=(",", ":")) + + +def _decode_native(value: object) -> LinuxServiceIdentityV1 | None: + if value is None: + return None + if type(value) is not str or len(value) > 1024: + raise ManagedContractError() + identity = LinuxServiceIdentityV1(**json.loads(value)) + if _encode_native(identity) != value: + raise ManagedContractError() + return identity def _next_revision(current: ManagedServiceStateV1 | None) -> int: diff --git a/tests/apphost/test_managed_handoff.py b/tests/apphost/test_managed_handoff.py index d925c911f..58efdbc1d 100644 --- a/tests/apphost/test_managed_handoff.py +++ b/tests/apphost/test_managed_handoff.py @@ -23,7 +23,7 @@ from loushang.apphost.managed.handoff import ManagedServiceHandoffPortV1 from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 from loushang.apphost.managed.registry import ManagedMuxReservationV1, ManagedRegistryV1 -from loushang.hosting.service import LinuxServiceObserverV1 +from loushang.hosting.service import LinuxServiceIdentityV1, LinuxServiceObserverV1 from loushang.hosting.service_handoff import ( ServiceChildHandoffV1, ServiceParentHandoffV1, @@ -34,14 +34,18 @@ @pytest.fixture -def owners(tmp_path): +def owners(tmp_path, request): namespace = ManagedNamespaceV1(str(tmp_path / "platform"), os.geteuid(), "a" * 32) service = ManagedServiceKeyV1("coding", "/workspace") registry = ManagedRegistryV1(tmp_path / "registry", namespace, create=True) registry.reserve_mux(ManagedMuxReservationV1("dev", service, "b" * 32)) journal = ManagedServiceJournalV1(registry, namespace, service, tmp_path / "fence", create=True) state = journal.prepare("c" * 32, expected=None) - port = ManagedServiceHandoffPortV1(journal, state.handoff.instance, state.handoff.attempt_id) + native = None + if getattr(request, "param", True): + native = LinuxServiceIdentityV1(431, 100, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", os.geteuid(), 1, 1) + state = journal.register_native(state.handoff.instance, state.handoff.attempt_id, native) + port = ManagedServiceHandoffPortV1(journal, state.handoff.instance, state.handoff.attempt_id, native_identity=native) try: yield journal, state, port finally: @@ -93,23 +97,65 @@ def test_stop_fence_is_not_a_fabricated_abort(owners): assert port.abort() is Phase.ABORTING -@pytest.mark.parametrize("mismatch", ["namespace", "instance", "attempt"]) +@pytest.mark.parametrize("mismatch", ["namespace", "instance", "attempt", "native"]) def test_binding_mismatch_cannot_observe_or_modify_current_attempt(owners, mismatch): journal, state, _ = owners reference, attempt = state.handoff.instance, state.handoff.attempt_id + native = state.native_identity if mismatch == "namespace": reference = replace(reference, namespace_key="d" * 64) elif mismatch == "instance": reference = replace(reference, instance_id="d" * 32) - else: + elif mismatch == "attempt": attempt = "d" * 32 - port = ManagedServiceHandoffPortV1(journal, reference, attempt) + else: + native = replace(native, start_ticks=native.start_ticks + 1) + port = ManagedServiceHandoffPortV1(journal, reference, attempt, native_identity=native) assert port.observe() is Phase.UNKNOWN assert port.commit() is Phase.UNKNOWN assert port.abort() is Phase.UNKNOWN assert journal.read() == state +def test_starter_port_cannot_commit_but_can_explicitly_abort(owners): + journal, state, _ = owners + starter = ManagedServiceHandoffPortV1(journal, state.handoff.instance, state.handoff.attempt_id) + assert starter.observe() is Phase.PROVISIONAL + assert starter.commit() is Phase.UNKNOWN + assert journal.read() == state + assert starter.abort() is Phase.ABORTING + + +@pytest.mark.parametrize("owners", [False], indirect=True) +def test_child_cannot_commit_until_starter_registers_matching_native(owners): + journal, state, _ = owners + native = LinuxServiceIdentityV1(431, 100, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", os.geteuid(), 1, 1) + child = ManagedServiceHandoffPortV1( + journal, state.handoff.instance, state.handoff.attempt_id, native_identity=native, + ) + assert child.commit() is Phase.PROVISIONAL + assert journal.read() == state + journal.register_native(state.handoff.instance, state.handoff.attempt_id, native) + assert child.commit() is Phase.COMMITTED + + +@pytest.mark.parametrize("owners", [False], indirect=True) +def test_parent_eof_before_registration_still_allows_child_abort(owners): + journal, state, _ = owners + native = LinuxServiceIdentityV1(431, 100, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", os.geteuid(), 1, 1) + port = ManagedServiceHandoffPortV1(journal, state.handoff.instance, state.handoff.attempt_id, native_identity=native) + parent, endpoint = socket.socketpair() + child = ServiceChildHandoffV1(endpoint, port) + parent.close() + try: + assert child.poll_parent() is Phase.ABORTING + assert journal.read().native_identity is None + with pytest.raises(ManagedStorageError, match="conflict"): + journal.register_native(state.handoff.instance, state.handoff.attempt_id, native) + finally: + child.close() + + def test_delayed_old_port_never_controls_new_generation(owners): journal, state, port = owners assert port.abort() is Phase.ABORTING @@ -221,16 +267,19 @@ def long_query(connection): from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 from loushang.apphost.managed.handoff import ManagedServiceHandoffPortV1 from loushang.hosting.service_handoff import ServiceChildHandoffV1 +from loushang.hosting.service import LinuxServiceObserverV1 root = Path(sys.argv[1]) control = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) control.settimeout(8) control.connect('\0' + sys.argv[2]) +assert control.recv(1) == b'S' # Test gate: starter has durably registered birth. endpoint = socket.socket(fileno=int(sys.argv[3])) namespace = ManagedNamespaceV1(str(root / 'platform'), os.geteuid(), 'a' * 32) registry = ManagedRegistryV1(root / 'registry', namespace) journal = ManagedServiceJournalV1(registry, namespace, ManagedServiceKeyV1('coding', '/workspace'), root / 'fence') state = journal.read() -port = ManagedServiceHandoffPortV1(journal, state.handoff.instance, state.handoff.attempt_id) +observer = LinuxServiceObserverV1.capture(os.getpid()) +port = ManagedServiceHandoffPortV1(journal, state.handoff.instance, state.handoff.attempt_id, native_identity=observer.identity) channel = ServiceChildHandoffV1(endpoint, port) try: control.sendall(b'R') @@ -242,6 +291,7 @@ def long_query(connection): control.sendall(phase.value.encode() + b'\n') finally: channel.close() + observer.close() journal.close() registry.close() control.close() @@ -251,23 +301,36 @@ def long_query(connection): import json, os, socket, sys from loushang.hosting.contracts import ProcessLaunchRequest, ProcessStreamSpec, ProcessStdinMode, ProcessStdoutMode, ProcessStderrMode from loushang.hosting.service_process import LinuxServiceProcessV1 +from pathlib import Path +from loushang.apphost.managed.contracts import ManagedNamespaceV1, ManagedServiceKeyV1 +from loushang.apphost.managed.registry import ManagedRegistryV1 +from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 +root = Path(sys.argv[2]) +namespace = ManagedNamespaceV1(str(root / 'platform'), os.geteuid(), 'a' * 32) +registry = ManagedRegistryV1(root / 'registry', namespace) +journal = ManagedServiceJournalV1(registry, namespace, ManagedServiceKeyV1('coding', '/workspace'), root / 'fence') +state = journal.read() parent, child = socket.socketpair() command = json.loads(sys.argv[1]) + [str(child.fileno())] request = ProcessLaunchRequest(tuple(command), sys.argv[2], tuple(os.environ.items()), ProcessStreamSpec(ProcessStdinMode.CLOSED, ProcessStdoutMode.DISCARD, ProcessStderrMode.DISCARD)) owner = LinuxServiceProcessV1(request, child) identity = owner.spawn() +journal.register_native(state.handoff.instance, state.handoff.attempt_id, identity) print(identity.pid, flush=True) sys.stdin.buffer.read(1) if sys.argv[3] == 'abrupt': os._exit(0) owner.close() parent.close() +journal.close() +registry.close() ''' @pytest.mark.parametrize("committed", [False, True]) @pytest.mark.parametrize("exit_mode", ["normal", "abrupt"]) +@pytest.mark.parametrize("owners", [False], indirect=True) def test_real_starter_exit_obeys_durable_handoff(owners, tmp_path, committed, exit_mode): journal, _, port = owners listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) @@ -291,6 +354,7 @@ def test_real_starter_exit_obeys_durable_handoff(owners, tmp_path, committed, ex observer = LinuxServiceObserverV1.capture(int(starter.stdout.readline())) control, _ = listener.accept() control.settimeout(8) + control.sendall(b"S") assert control.recv(1) == b"R" if committed: control.sendall(b"C") @@ -303,6 +367,7 @@ def test_real_starter_exit_obeys_durable_handoff(owners, tmp_path, committed, ex assert _line(control) == expected.value.encode() + b"\n" assert port.observe() is expected assert journal.read().handoff.phase is ManagedHandoffPhaseV1(expected.value) + assert journal.read().native_identity == observer.identity # The handshake does not fabricate application/scope cleanup facts, nor # stop the committed child. The test application owns its separate exit. assert not journal.read().evidence.application_cleanup_completed diff --git a/tests/apphost/test_managed_lifecycle.py b/tests/apphost/test_managed_lifecycle.py index 74a3b4e98..601ed6a3c 100644 --- a/tests/apphost/test_managed_lifecycle.py +++ b/tests/apphost/test_managed_lifecycle.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import os import sqlite3 import subprocess @@ -21,10 +22,15 @@ ) from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 from loushang.apphost.managed.registry import ManagedMuxReservationV1, ManagedRegistryV1 +from loushang.hosting.service import LinuxServiceIdentityV1 pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed lifecycle") +def _identity(): + return LinuxServiceIdentityV1(431, 100, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", os.geteuid(), 1, 1) + + @pytest.fixture def owners(tmp_path): namespace = ManagedNamespaceV1(str(tmp_path / "platform"), os.geteuid(), "a" * 32) @@ -61,10 +67,11 @@ def test_commit_ack_loss_is_observed_not_aborted(owners): _, journal, _, _ = owners prepared = journal.prepare("c" * 32, expected=None) reference = prepared.handoff.instance - committed = journal.commit(reference, "c" * 32) + journal.register_native(reference, "c" * 32, _identity()) + committed = journal.commit(reference, "c" * 32, native_identity=_identity()) assert committed.handoff.phase is ManagedHandoffPhaseV1.COMMITTED assert journal.read() == committed - assert journal.commit(reference, "c" * 32) == committed + assert journal.commit(reference, "c" * 32, native_identity=_identity()) == committed with pytest.raises(ManagedStorageError, match="conflict"): journal.abort(reference, "c" * 32) with pytest.raises(ManagedStorageError, match="busy"): @@ -76,6 +83,7 @@ def test_stop_or_abort_before_commit_fences_child(owners, first): _, journal, _, _ = owners prepared = journal.prepare("c" * 32, expected=None) reference = prepared.handoff.instance + journal.register_native(reference, "c" * 32, _identity()) if first == "stop": stopped = journal.request_stop(reference) assert journal.request_stop(reference) == stopped @@ -83,7 +91,7 @@ def test_stop_or_abort_before_commit_fences_child(owners, first): stopped = journal.abort(reference, "c" * 32) assert journal.abort(reference, "c" * 32) == stopped with pytest.raises(ManagedStorageError, match="conflict"): - journal.commit(reference, "c" * 32) + journal.commit(reference, "c" * 32, native_identity=_identity()) assert journal.read() == stopped @@ -92,7 +100,8 @@ def test_restart_requires_all_three_facts_and_old_generation_cannot_mutate_new(o _, journal, _, _ = owners first = journal.prepare("c" * 32, expected=None) reference = first.handoff.instance - journal.commit(reference, "c" * 32) + journal.register_native(reference, "c" * 32, _identity()) + journal.commit(reference, "c" * 32, native_identity=_identity()) with pytest.raises(ManagedStorageError, match="conflict"): journal.record_stop_evidence(ManagedStopEvidenceV1(reference, True, True, True)) journal.request_stop(reference) @@ -108,7 +117,7 @@ def test_restart_requires_all_three_facts_and_old_generation_cannot_mutate_new(o assert new.handoff.instance != reference for call in (lambda: journal.request_stop(reference), lambda: journal.abort(reference, "c" * 32), - lambda: journal.commit(reference, "c" * 32), + lambda: journal.commit(reference, "c" * 32, native_identity=_identity()), lambda: journal.record_stop_evidence(complete.evidence)): with pytest.raises(ManagedStorageError, match="conflict"): call() @@ -118,8 +127,9 @@ def test_restart_requires_all_three_facts_and_old_generation_cannot_mutate_new(o def test_attempt_and_namespace_mismatch_never_commit(owners): _, journal, _, _ = owners prepared = journal.prepare("c" * 32, expected=None) + prepared = journal.register_native(prepared.handoff.instance, "c" * 32, _identity()) with pytest.raises(ManagedStorageError, match="conflict"): - journal.commit(prepared.handoff.instance, "d" * 32) + journal.commit(prepared.handoff.instance, "d" * 32, native_identity=_identity()) with pytest.raises(ManagedContractError): journal.request_stop(replace(prepared.handoff.instance, namespace_key="a" * 64)) assert journal.read() == prepared @@ -149,6 +159,7 @@ def test_corrupt_boolean_observation_is_not_accepted(owners, tmp_path): def test_replaced_service_fence_rolls_back_before_database_commit(owners, tmp_path, monkeypatch): registry, journal, _, _ = owners prepared = journal.prepare("c" * 32, expected=None) + prepared = journal.register_native(prepared.handoff.instance, "c" * 32, _identity()) original_save = journal._save def replace_after_update(connection, state, *, insert): @@ -160,7 +171,7 @@ def replace_after_update(connection, state, *, insert): monkeypatch.setattr(journal, "_save", replace_after_update) with pytest.raises(ManagedStorageError, match="conflict"): - journal.commit(prepared.handoff.instance, "c" * 32) + journal.commit(prepared.handoff.instance, "c" * 32, native_identity=_identity()) with registry._database.transaction() as connection: assert connection.execute("SELECT phase FROM instances").fetchone() == ("provisional",) @@ -200,11 +211,107 @@ def test_real_competing_process_only_one_generation_is_reserved(owners, tmp_path assert journal.read() == prepared -def test_unactivated_v1_registry_requires_explicit_upgrade_not_silent_migration(owners, tmp_path): +@pytest.mark.parametrize("version", [1, 2]) +def test_unactivated_old_registry_requires_explicit_upgrade_not_silent_migration(owners, tmp_path, version): _, _, namespace, _ = owners with sqlite3.connect(tmp_path / "registry" / DATABASE_NAME) as connection: - connection.execute("PRAGMA user_version=1") + connection.execute(f"PRAGMA user_version={version}") before = (tmp_path / "registry" / DATABASE_NAME).read_bytes() with pytest.raises(ManagedStorageError, match="invalid_record"): ManagedRegistryV1(tmp_path / "registry", namespace, create=True) assert (tmp_path / "registry" / DATABASE_NAME).read_bytes() == before + + +def test_commit_requires_exact_durable_native_registration(owners): + _, journal, _, _ = owners + prepared = journal.prepare("c" * 32, expected=None) + reference = prepared.handoff.instance + with pytest.raises(ManagedContractError): + journal.commit(reference, "c" * 32) + with pytest.raises(ManagedStorageError, match="conflict"): + journal.commit(reference, "c" * 32, native_identity=_identity()) + assert journal.read() == prepared + bound = journal.register_native(reference, "c" * 32, _identity()) + assert bound.native_identity == _identity() + assert bound.revision == prepared.revision + 1 + assert journal.register_native(reference, "c" * 32, _identity()) == bound + wrong = replace(_identity(), start_ticks=101) + with pytest.raises(ManagedStorageError, match="conflict"): + journal.register_native(reference, "c" * 32, wrong) + with pytest.raises(ManagedStorageError, match="conflict"): + journal.commit(reference, "c" * 32, native_identity=wrong) + committed = journal.commit(reference, "c" * 32, native_identity=_identity()) + assert journal.register_native(reference, "c" * 32, _identity()) == committed + + +@pytest.mark.parametrize("transition", ["stop", "abort"]) +def test_new_registration_cannot_cross_stop_or_abort(owners, transition): + _, journal, _, _ = owners + prepared = journal.prepare("c" * 32, expected=None) + reference = prepared.handoff.instance + if transition == "stop": + stopped = journal.request_stop(reference) + else: + stopped = journal.abort(reference, "c" * 32) + with pytest.raises(ManagedStorageError, match="conflict"): + journal.register_native(reference, "c" * 32, _identity()) + assert journal.read() == stopped + + +def test_registration_is_bound_to_attempt_uid_and_generation(owners): + _, journal, _, _ = owners + first = journal.prepare("c" * 32, expected=None) + reference = first.handoff.instance + with pytest.raises(ManagedContractError): + journal.register_native(reference, "c" * 32, replace(_identity(), user_id=os.geteuid() + 1)) + with pytest.raises(ManagedStorageError, match="conflict"): + journal.register_native(reference, "d" * 32, _identity()) + journal.register_native(reference, "c" * 32, _identity()) + journal.abort(reference, "c" * 32) + stopped = journal.record_stop_evidence(ManagedStopEvidenceV1(reference, True, True, True)) + new = journal.prepare("d" * 32, expected=stopped) + assert new.native_identity is None + with pytest.raises(ManagedStorageError, match="conflict"): + journal.register_native(reference, "c" * 32, _identity()) + with pytest.raises(ManagedStorageError, match="conflict"): + journal.commit(new.handoff.instance, "d" * 32, native_identity=_identity()) + assert journal.read() == new + + +def test_native_identity_survives_reopen_without_conferring_liveness(owners, tmp_path): + registry, journal, namespace, service = owners + prepared = journal.prepare("c" * 32, expected=None) + bound = journal.register_native(prepared.handoff.instance, "c" * 32, _identity()) + other = ManagedServiceJournalV1(registry, namespace, service, tmp_path / "fence") + try: + assert other.read() == bound + assert not bound.cleanly_stopped + assert not bound.evidence.process_exited + finally: + other.close() + + +@pytest.mark.parametrize("corruption", ["syntax", "duplicate", "type", "uid", "missing_committed"]) +def test_corrupt_native_registration_is_rejected(owners, tmp_path, corruption): + _, journal, _, _ = owners + prepared = journal.prepare("c" * 32, expected=None) + journal.register_native(prepared.handoff.instance, "c" * 32, _identity()) + journal.commit(prepared.handoff.instance, "c" * 32, native_identity=_identity()) + with sqlite3.connect(tmp_path / "registry" / DATABASE_NAME) as connection: + encoded = connection.execute("SELECT native_identity FROM instances").fetchone()[0] + if corruption == "syntax": + broken = "{" + elif corruption == "duplicate": + broken = '{"pid":431,' + encoded[1:] + elif corruption == "missing_committed": + broken = None + else: + value = json.loads(encoded) + if corruption == "type": + value["pid"] = True + else: + value["user_id"] += 1 + broken = json.dumps(value, sort_keys=True, separators=(",", ":")) + connection.execute("UPDATE instances SET native_identity=?", (broken,)) + with pytest.raises(ManagedStorageError, match="invalid_record"): + journal.read() diff --git a/tests/architecture/test_hosted_product_g10_explicit_canary.py b/tests/architecture/test_hosted_product_g10_explicit_canary.py index b0b707fbb..1a1cec162 100644 --- a/tests/architecture/test_hosted_product_g10_explicit_canary.py +++ b/tests/architecture/test_hosted_product_g10_explicit_canary.py @@ -132,7 +132,8 @@ def test_dependency_graph_has_only_the_accepted_product_owned_edges( imports = _imports(path) accepted_hosting = { Path("src/loushang/apphost/launcher.py"): {"loushang.hosting.contracts"}, - Path("src/loushang/apphost/managed/handoff.py"): {"loushang.hosting.service_handoff"}, + Path("src/loushang/apphost/managed/handoff.py"): {"loushang.hosting.service", "loushang.hosting.service_handoff"}, + Path("src/loushang/apphost/managed/lifecycle.py"): {"loushang.hosting.errors", "loushang.hosting.service"}, }.get(path) if accepted_hosting is not None: assert {name for name in imports if name.startswith("loushang.hosting")} == accepted_hosting diff --git a/tests/architecture/test_hosted_product_runtime_g9_closure.py b/tests/architecture/test_hosted_product_runtime_g9_closure.py index 20dd94e62..d94fdc441 100644 --- a/tests/architecture/test_hosted_product_runtime_g9_closure.py +++ b/tests/architecture/test_hosted_product_runtime_g9_closure.py @@ -548,7 +548,8 @@ def test_g9_4_retains_apphost_core_and_current_inventory_fences() -> None: } assert hosting_imports == { APPHOST / "launcher.py": {"loushang.hosting.contracts"}, - APPHOST / "managed/handoff.py": {"loushang.hosting.service_handoff"}, + APPHOST / "managed/handoff.py": {"loushang.hosting.service", "loushang.hosting.service_handoff"}, + APPHOST / "managed/lifecycle.py": {"loushang.hosting.errors", "loushang.hosting.service"}, }.get(path, set()) for path in APPHOST_CORE: source = _read(path) diff --git a/tests/architecture/test_plugin_lifecycle_plc9c5_c50_baseline.py b/tests/architecture/test_plugin_lifecycle_plc9c5_c50_baseline.py index 51d3232be..9b1f64036 100644 --- a/tests/architecture/test_plugin_lifecycle_plc9c5_c50_baseline.py +++ b/tests/architecture/test_plugin_lifecycle_plc9c5_c50_baseline.py @@ -897,14 +897,24 @@ def test_c50_keeps_private_profiles_confined_and_product_layers_clean() -> None: CODING_ROOT / "cli/hosted_client.py", APPHOST_ROOT / "launcher.py", APPHOST_ROOT / "managed/handoff.py", + APPHOST_ROOT / "managed/lifecycle.py", } assert { imported for imported in _imports(APPHOST_ROOT / "managed/handoff.py") if imported.startswith("loushang.hosting") } == { + "loushang.hosting.service", + "loushang.hosting.service.LinuxServiceIdentityV1", "loushang.hosting.service_handoff", "loushang.hosting.service_handoff.ServiceHandoffPhaseV1", } + assert { + imported for imported in _imports(APPHOST_ROOT / "managed/lifecycle.py") + if imported.startswith("loushang.hosting") + } == { + "loushang.hosting.errors", "loushang.hosting.errors.HostingError", + "loushang.hosting.service", "loushang.hosting.service.LinuxServiceIdentityV1", + } for consumer in hosting_consumers: assert not any( imported.startswith("loushang.hosting._") From a2516f8c886f67c1d1e0aa0740ff1cc2ec20cfc4 Mon Sep 17 00:00:00 2001 From: zhnt Date: Sun, 13 Sep 2026 08:57:03 +0000 Subject: [PATCH 08/44] feat(apphost): separate application preparation from activation --- .../architecture/apphost/lmux-contract-m0.md | 80 ++++++- src/loushang/apphost/local.py | 110 +++++++-- src/loushang/appserver/local.py | 91 ++++++- src/loushang/coding/hosted_local.py | 89 ++++++- tests/apphost/test_local.py | 108 ++++++++- tests/appserver/test_local.py | 225 ++++++++++++++++++ .../test_detachable_local_workspace_g16.py | 25 +- tests/coding/test_hosted_local.py | 13 +- tests/coding/test_hosted_local_ownership.py | 169 ++++++++++++- 9 files changed, 863 insertions(+), 47 deletions(-) diff --git a/docs/internals/architecture/apphost/lmux-contract-m0.md b/docs/internals/architecture/apphost/lmux-contract-m0.md index f6b5c0ff0..c866e365c 100644 --- a/docs/internals/architecture/apphost/lmux-contract-m0.md +++ b/docs/internals/architecture/apphost/lmux-contract-m0.md @@ -8,8 +8,8 @@ - ID: `LMUX-M0` - Authority: normative — accepted limited Linux managed-profile contract - Design status: accepted -- Review status: three-perspective storage/lifecycle/observer/handoff/native-launch/native-binding slice reviews passed; recovery admission pending -- Implementation status: partial — storage, instance coordination, Linux observations, native launch and durable birth binding; no activation +- Review status: three-perspective storage/lifecycle/observer/handoff/native-launch/native-binding/application-staging slice reviews passed; recovery admission pending +- Implementation status: partial — storage, instance coordination, Linux observations, native launch, durable birth binding and staged application startup; no managed activation - Owner: AppHost managed deployment; sibling changes remain sibling-owned - Tracking objective: active Linux lmux goal, branch `harness/lmux-managed-service` @@ -241,6 +241,7 @@ Mux 外壳只负责成员/连接组合。主题与终端能力在客户端组合 | M2 持久交接接线 | 继承通道 + exact instance/attempt journal port;共享协作 deadline、真实启动器退出和 EOF/CAS 竞争测试,三视角复审通过 | 生产 spawn/daemon 与应用 owner 清理尚未接入,不构成 SSH/后台交付 | | M2 Linux 创建 owner | 单次生产 Popen 创建、受管 FD/环境/工作区、复用 POSIX group 观察;真实父端退出和故障矩阵、三视角复审通过 | 身份持久登记、应用自持生命周期、异常恢复和 CLI 激活仍待完成 | | M2 birth binding | schema v3 原生身份登记、精确匹配的 commit/abort;117 项聚焦验证、三视角复审通过 | 应用自持 owner、异常退役恢复、Session writer lease 与完整前端接线仍待完成 | +| M2 应用准备/激活 | 原 AppServer/AppHost/Coding owner 的两步启动、共享截止时间和同步 fence;真实 Product/认证回归,设计与代码三视角复审通过 | 受管 child owner 仍需在两步之间接入 durable commit;CLI 不自动启动 | | 一条命令/后台/全局名/多 Tab/stop | 仅 G16 既有显式能力 | M1–M3 全部接线 | | Session 唯一写入与默认历史 | 缺运行期跨进程合同实现 | writer lease + canonical catalog + 双进程测试 | | 完整共享 Harnesstui/Markdown | 草案与本文接缝 | M3 代码与 Embedded 非回退 | @@ -498,3 +499,78 @@ Session writer lease、公共发现/连接或 CLI 激活已经完成。 inventory 共 117 passed,Ruff、managed 八模块 mypy、文档 6 项、依赖图 新鲜度通过。没有重复未改动的 Hosting 原生实现与 Product/UI 广泛套件, 完整 change-aware、真实安装/SSH/性能与最终三视角交付仍在整体目标内。 + +## 17. M2 应用准备与接客分离 + +现有 `CodingLocalCommandV1.start()` 会恢复 Product、启用 client scopes 并发布 +可连接的服务,再由调用者宣告 ready。受管子端不能在这个已接客状态等待 +持久 handoff,否则交接前已经可能接受无法归属的请求。公共协调器只检查 +COMMITTED 也不够:旧显式连接入口仍可能直接读到 connection record。 + +采用同一部署 owner 的可选两步启动,而不是新建第二套 Coding runtime: + +- AppServer 增加 `prepare()` 与 `activate()`:prepare 保留 endpoint reservation、 + 绑定原生 listener,但不生成/发布当前实例 record、不开始 serving; + activate 才生成认证 record、发布并开始 serving。原 `start()` 依次执行两步, + 默认行为不变。准备期间可能存在旧崩溃实例的残留 record;不为“不可发现” + 删除未知旧文件,旧 record 也不构成当前实例 ready 证明。 +- AppHost `HostedLocalRuntimeV1.prepare()` 先准备 listener,保持 client scopes + 禁用;`activate()` 才启用 scopes 并激活 server。`accepting` 仅在 activate + 完成后为真。保留启动/关闭的单 owner、任务保留、取消不遗弃、lease-last + 和原有 G16 stop/EOF 语义。准备中的 stop 与 close 永远禁止迟到 activate。 +- Coding trusted composition 暴露相同 prepare/activate 接缝,仍由原 attempt + 构造实际应用;旧 `start()/run()` 路径不需要调用者变更。两步共用一次启动 + 预算,不能在 activate 重置 30 秒预算;显式 close 重试仍遵循原合同。 + Coding 在 Product 恢复前冻结 absolute monotonic deadline,原样向下传递为 + 共同上限(各层既有更紧 profile 上限可以缩短,不能延长)。prepare 与 + activate 之间的外部交接时间计入预算;启用 scopes、发布、serving 前及 + ready 返回前均检查 fence 与期限。OS IO 不能被该预算硬抢占。 + +保留的是底层 prepare/activate 阶段任务,不是会在异常路径调用自身 close + 的公共 start/prepare/activate waiter。close 等待前者并接管迟到资源,避免 + 环形等待;取消 waiter 不取消已拥有的阶段任务。API 顺序固定为一次 + prepare、随后一次 activate;prepare 重复、未完成 prepare 就 activate、 + 重复 activate、prepare 后再 start、并发争用均拒绝后来的不合法调用, + 不撤销先前合法阶段、不恢复第二份 Product、不重新发布。close 在任何 + 阶段均同步 fence;关闭后两步均拒绝,失败 owner 只能重试 cleanup。 + 一步 start 在首次 await 前保留两个阶段;外来 split activate 不能抢占 + prepare 完成到原 start 恢复之间的窗口。绝对 deadline 只允许精确 int/float + 且 `0 < deadline <= 1e12`,拒绝 bool、NaN、无穷和巨大整数,校验前无 IO。 + +随后受管 child owner 将按“准备应用 → 子端精确身份的持久 commit → activate” + 执行。COMMITTED 是寿命交接,尚不是可连接确认;协调器需要重新认证后才 + 返回连接 lease。提交与 activate 之间崩溃保留 committed/unclean,对账不得 + 重新 spawn。activate 失败是服务自己的启动故障,需 graceful close 和精确 + stop 记录,而不是启动器因超时撤销 committed。准备期间 EOF/abort 时由该 + 应用 owner 关闭已准备的 listener 与 Product,完整完成后才记录应用清理。 + +本接缝只实现两步应用启动,不宣称完整 child owner、公共协调器、writer lease + 或异常退役 P2 已完成。验收包括真实 loopback 的 prepare 无当前 record/不可用 + (同时测空目录和残留旧 record,旧地址/认证不能进入当前实例语义)、 + activate 后认证连接与旧 start 等价,以及 prepare/activate/close 的取消、 + 故障、晚到结果、重复调用与共享预算。AppServer 仍不知道 managed/Hosting/ + Mux 生命周期;它只实现通用显式发布时序。 + +设计与代码分别三视角复审通过。代码复审修复三处 P2:一步 start 的第二 + 阶段被外来 activate 抢占、Coding close 排队期间未同步 fence 已接管服务、 + 巨大整数 deadline 的 float 转换异常。AppHost 新增纯同步 `fence()`,由 + Coding 在建立 close task 前调用;它不转移 cleanup 所有权,不丢已有 stop + reply,也不自动创建任务。对应确定性竞争、task factory 失败和三层非法 + deadline 负测均已补齐。修复后本地阶段/认证/生命周期聚焦 76 passed。 + +G16 两个既有源码行数门禁按架构复审确认的精确增量更新:相对 `0351f8b0`, + AppServer local 从 392 到 463 行(旧上限 450 + 71),AppHost local 从 + 247 到 327 行(旧上限 300 + 80)。只计本次两阶段 owner/同步 fence 增量, + 保持原余量,不作文件豁免;额外锁定 prepare/activate/fence 的公开签名。 + 既有依赖方向、native IO confinement、连接容量与默认入口断言全部保留。 + +广泛协议、真实 Product/CLI、旧 Mux 外壳与架构回归结果为 406 passed / + 10 skipped,两个失败仅为上述旧 G16 行数上限;更新后该文件连同新增 + 签名检查复测 9 passed。没有将该聚焦复测写成整条广泛命令重跑通过。 + 当前三份源文件 mypy、全部修改代码/测试 Ruff、文档 6 项与依赖图新鲜度 + 均通过。首次残留 record 测试因 fixture 使用非法空 scope 而失败,补合法 + scope 后真实崩溃遗留/旧认证拒绝用例已包含在通过结果中。 + +本切片不发布自动后台入口,未运行整个完整目标的 installed/SSH/首用性能 + 矩阵,也不替代最终 change-aware/远端门禁。完整 child owner、异常退役、 + 唯一 writer、公共协调器、配额与 Harnesstui 接线仍为 active goal 的后续工作。 diff --git a/src/loushang/apphost/local.py b/src/loushang/apphost/local.py index c7c31648e..f58db384b 100644 --- a/src/loushang/apphost/local.py +++ b/src/loushang/apphost/local.py @@ -64,12 +64,16 @@ def __init__( ) self._startup_timeout, self._timeout = startup_timeout, settlement_timeout self._start_task: asyncio.Task[None] | None = None + self._activate_task: asyncio.Task[None] | None = None + self._startup_deadline: float | None = None self._close_task: asyncio.Task[None] | None = None self._phases: dict[str, asyncio.Task[None]] = {} self._reply: Awaitable[None] | None = None self._deadline: float | None = None self._stop_started = asyncio.Event() self._scopes_enabled = False + self._prepared = False + self._one_step = False self._closing = False self._settled = False @@ -80,7 +84,7 @@ def cleanup_pending(self) -> bool: @property def accepting(self) -> bool: """Current deployment readiness, revoked synchronously by stop.""" - task = self._start_task + task = self._activate_task return ( self._scopes_enabled and not self._closing @@ -90,30 +94,93 @@ def accepting(self) -> bool: ) async def start(self) -> None: + """Original one-step entrypoint over the same two owned phases.""" + if self._one_step or self._closing or self._start_task is not None: + raise HostedApplicationError("hosted_local_closed") + self._one_step = True + await self._prepare() + await self._activate() + + async def prepare(self, *, deadline: float | None = None) -> None: + """Prepare transport without enabling application client scopes.""" + if self._one_step: + raise HostedApplicationError("hosted_local_closed") + await self._prepare(deadline=deadline) + + async def _prepare(self, *, deadline: float | None = None) -> None: if self._start_task is not None or self._closing: raise HostedApplicationError("hosted_local_closed") - self._start_task = _spawn(self._start_once()) + if deadline is not None and ( + type(deadline) not in (int, float) or not 0 < deadline <= 1e12 + ): + raise ValueError("invalid local startup deadline") + self._startup_deadline = asyncio.get_running_loop().time() + self._startup_timeout + if deadline is not None: + self._startup_deadline = min(self._startup_deadline, deadline) try: + self._remaining_startup() + self._start_task = _spawn(self._start_once()) done, _ = await asyncio.wait( - {self._start_task}, timeout=self._startup_timeout + {self._start_task}, timeout=self._remaining_startup() ) if not done: raise HostedApplicationError("hosted_local_startup_timeout") await asyncio.shield(self._start_task) - if not self.accepting: + self._remaining_startup() + if not self._prepared: raise HostedApplicationError("hosted_local_closed") except BaseException: await self.close() raise async def _start_once(self) -> None: - if self._closing: + self._remaining_startup() + await self._server.prepare(deadline=self._startup_deadline) + self._remaining_startup() + self._prepared = True + + async def activate(self) -> None: + """Enable scopes and publish transport only after explicit admission.""" + if self._one_step: raise HostedApplicationError("hosted_local_closed") - self._application.enable_client_scopes() - self._scopes_enabled = True - await self._server.start() - if self._closing: + await self._activate() + + async def _activate(self) -> None: + if (self._closing or not self._prepared or self._activate_task is not None + or self._start_task is None or not self._start_task.done() or _failed(self._start_task)): raise HostedApplicationError("hosted_local_closed") + try: + self._remaining_startup() + self._activate_task = _spawn(self._activate_once()) + done, _ = await asyncio.wait( + {self._activate_task}, timeout=self._remaining_startup() + ) + if not done: + raise HostedApplicationError("hosted_local_startup_timeout") + await asyncio.shield(self._activate_task) + self._remaining_startup() + if not self.accepting: + raise HostedApplicationError("hosted_local_closed") + except BaseException: + await self.close() + raise + + def _remaining_startup(self) -> float: + if self._closing or self._startup_deadline is None: + raise HostedApplicationError("hosted_local_closed") + remaining = self._startup_deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise HostedApplicationError("hosted_local_startup_timeout") + return remaining + + async def _activate_once(self) -> None: + self._remaining_startup() + # Retain the scope fence even if enable partially fails. + self._scopes_enabled = True + self._application.enable_client_scopes() + self._remaining_startup() + await self._server.activate() + self._remaining_startup() async def wait_closed(self) -> None: """Wait for explicit stop or close, not for a client to disconnect.""" @@ -138,6 +205,23 @@ def _begin_close( reply: Awaitable[None] | None = None, retry_timeout: float | None = None, ) -> asyncio.Task[None]: + self._fence(reply) + task = self._close_task + if task is None or _failed(task): + if retry_timeout is not None: + self._deadline = asyncio.get_running_loop().time() + retry_timeout + self._close_task = task = _spawn(self._close_once()) + return task + + def fence(self) -> None: + """Synchronously revoke activation before an outer owner schedules close. + + This transfers no cleanup ownership and does not discard a stop reply. + The adopting caller must still close and retain incomplete settlement. + """ + self._fence(None) + + def _fence(self, reply: Awaitable[None] | None) -> None: if not self._closing: self._closing = True self._reply = reply @@ -146,12 +230,6 @@ def _begin_close( if self._scopes_enabled: self._application.fence_client_scopes() self._stop_started.set() - task = self._close_task - if task is None or _failed(task): - if retry_timeout is not None: - self._deadline = asyncio.get_running_loop().time() + retry_timeout - self._close_task = task = _spawn(self._close_once()) - return task async def close(self, *, retry_timeout: float | None = None) -> None: if retry_timeout is not None: @@ -166,6 +244,8 @@ async def _close_once(self) -> None: if self._start_task is not None: # A failed startup may still own a late listener/record handoff. await _wait(self._start_task, deadline, ignore_failure=True) + if self._activate_task is not None: + await _wait(self._activate_task, deadline, ignore_failure=True) await self._phase("reply", self._finish_reply, deadline) await self._phase("connection", self._server.close, deadline) await self._phase("directory", self._close_directory, deadline) diff --git a/src/loushang/appserver/local.py b/src/loushang/appserver/local.py index 0a353217e..d2ad2bf75 100644 --- a/src/loushang/appserver/local.py +++ b/src/loushang/appserver/local.py @@ -111,7 +111,11 @@ def __init__( self._server: asyncio.Server | None = None self._peers: set[_LocalPeer] = set() self._start_task: asyncio.Task[None] | None = None + self._activate_task: asyncio.Task[None] | None = None + self._startup_deadline: float | None = None self._close_task: asyncio.Task[None] | None = None + self._prepared = False + self._one_step = False self._ready = False self._closed = False self._stop_requested = False @@ -128,19 +132,78 @@ def connection_counts(self) -> tuple[int, int]: return len(self._peers) - admitted, admitted async def start(self) -> None: + """Compatibility convenience: prepare then activate the same owner.""" + if self._one_step or self._closed or self._start_task is not None: + raise AppConnectionClosedError() + self._one_step = True # Reserve both stages before the first suspension. + await self._prepare() + await self._activate() + + async def prepare(self, *, deadline: float | None = None) -> None: + """Reserve and bind, without publishing credentials or accepting peers. + + An old instance's record may remain readable; it is not this instance's + readiness. Only activate publishes a fresh record. A supplied absolute + loop-clock deadline can shorten, never extend, the profile's budget. + """ + if self._one_step: + raise AppConnectionClosedError() + await self._prepare(deadline=deadline) + + async def _prepare(self, *, deadline: float | None = None) -> None: if self._closed or self._start_task is not None: raise AppConnectionClosedError() - task = self._start_task = _spawn(self._start_once()) - task.add_done_callback(_observe) + if deadline is not None and ( + type(deadline) not in (int, float) or not 0 < deadline <= 1e12 + ): + raise ValueError("invalid local startup deadline") + self._startup_deadline = asyncio.get_running_loop().time() + self._timeout + if deadline is not None: + self._startup_deadline = min(self._startup_deadline, deadline) try: - await _join_close(task, self._timeout) - if self._closed or self._stop_requested or not self._ready: + self._remaining_startup() + task = self._start_task = _spawn(self._start_once()) + task.add_done_callback(_observe) + await _join_close(task, self._remaining_startup()) + self._remaining_startup() + if not self._prepared: raise AppConnectionClosedError() except BaseException: await self.close() raise + async def activate(self) -> None: + """Publish once after completed preparation; close fences late results.""" + if self._one_step: + raise AppConnectionClosedError() + await self._activate() + + async def _activate(self) -> None: + if (self._closed or not self._prepared or self._activate_task is not None + or self._start_task is None or not self._start_task.done() or _failed(self._start_task)): + raise AppConnectionClosedError() + try: + self._remaining_startup() + task = self._activate_task = _spawn(self._activate_once()) + task.add_done_callback(_observe) + await _join_close(task, self._remaining_startup()) + self._remaining_startup() + if not self._ready: + raise AppConnectionClosedError() + except BaseException: + await self.close() + raise + + def _remaining_startup(self) -> float: + if self._closed or self._stop_requested or self._startup_deadline is None: + raise AppConnectionClosedError() + remaining = self._startup_deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise AppConnectionClosedError() + return remaining + async def _start_once(self) -> None: + self._remaining_startup() self._reservation = self._directory.acquire(self._endpoint) self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._socket.set_inheritable(False) @@ -148,7 +211,6 @@ async def _start_once(self) -> None: if os.name == "nt": self._socket.setsockopt(socket.SOL_SOCKET, getattr(socket, "SO_EXCLUSIVEADDRUSE"), 1) self._socket.bind((_LOOPBACK, 0)) - port = self._socket.getsockname()[1] self._server = await asyncio.start_server( self._accepted, sock=self._socket, backlog=16, limit=_READ_LIMIT, start_serving=False, @@ -157,17 +219,23 @@ async def _start_once(self) -> None: if self._closed: self._server.close() raise AppConnectionClosedError() + self._remaining_startup() + self._prepared = True + + async def _activate_once(self) -> None: + self._remaining_startup() + assert self._reservation is not None and self._server is not None + port = self._server.sockets[0].getsockname()[1] self._record = self._reservation.publish( application_id=self._application_id, product_id=self._product_id, port=port, scopes=self._scopes, session_discovery=self._discovery_factory is not None, session_execution=self._execution_factory is not None, ) + self._remaining_startup() self._ready = True await self._server.start_serving() - if self._closed: - self._server.close() - raise AppConnectionClosedError() + self._remaining_startup() def _accepted(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: if (not self._ready or self._closed or self._stop_requested @@ -228,8 +296,11 @@ async def close(self) -> None: raise async def _close_once(self) -> None: - if self._start_task is not None: - await asyncio.gather(self._start_task, return_exceptions=True) + # Only join owned phases, not public waiters which themselves close on + # failure. A late listener/publication remains this owner's resource. + for task in (self._start_task, self._activate_task): + if task is not None: + await asyncio.gather(task, return_exceptions=True) if self._server is not None: self._server.close() peers = tuple(self._peers) diff --git a/src/loushang/coding/hosted_local.py b/src/loushang/coding/hosted_local.py index f61e1f1e0..e5a4565a3 100644 --- a/src/loushang/coding/hosted_local.py +++ b/src/loushang/coding/hosted_local.py @@ -111,6 +111,10 @@ def __init__( self._local: HostedLocalRuntimeV1 | None = None self._startup_timeout, self._timeout = startup_timeout, settlement_timeout self._start_task: asyncio.Task[None] | None = None + self._activate_task: asyncio.Task[None] | None = None + self._startup_deadline: float | None = None + self._prepared = False + self._one_step = False self._close_task: asyncio.Task[None] | None = None self._phases: dict[str, asyncio.Task[None]] = {} self._deadline: float | None = None @@ -122,28 +126,49 @@ def cleanup_pending(self) -> bool: return not self._settled async def start(self) -> None: + """Keep the original ready-on-return entrypoint.""" + if self._one_step or self._closing or self._start_task is not None: + raise HostedApplicationError("coding_local_closed") + self._one_step = True + await self._prepare() + await self._activate() + + async def prepare(self, *, deadline: float | None = None) -> None: + """Recover the actual Product and prepare transport without clients.""" + if self._one_step: + raise HostedApplicationError("coding_local_closed") + await self._prepare(deadline=deadline) + + async def _prepare(self, *, deadline: float | None = None) -> None: if self._closing or self._start_task is not None: raise HostedApplicationError("coding_local_closed") - self._start_task = _spawn(self._start_once()) + if deadline is not None and ( + type(deadline) not in (int, float) or not 0 < deadline <= 1e12 + ): + raise ValueError("invalid local startup deadline") + self._startup_deadline = asyncio.get_running_loop().time() + self._startup_timeout + if deadline is not None: + self._startup_deadline = min(self._startup_deadline, deadline) try: + self._remaining_startup() + self._start_task = _spawn(self._start_once()) done, _ = await asyncio.wait( - {self._start_task}, timeout=self._startup_timeout + {self._start_task}, timeout=self._remaining_startup() ) if not done: raise HostedApplicationError("coding_local_startup_timeout") await asyncio.shield(self._start_task) - if self._closing or self._local is None or not self._local.accepting: + self._remaining_startup() + if not self._prepared: raise HostedApplicationError("coding_local_closed") except BaseException: await self.close() raise async def _start_once(self) -> None: - if self._closing: - raise HostedApplicationError("coding_local_closed") + self._remaining_startup() self._application = await self._attempt.open() - if self._closing: - raise HostedApplicationError("coding_local_closed") + self._remaining_startup() self._local = HostedLocalRuntimeV1( self._application, self._directory, @@ -153,7 +178,49 @@ async def _start_once(self) -> None: session_discovery=self._launch.session_discovery, ) self._application = None # AppHost has adopted both application and directory. - await self._local.start() + await self._local.prepare(deadline=self._startup_deadline) + self._remaining_startup() + self._prepared = True + + async def activate(self) -> None: + """Activate a prepared deployment; does not renew its startup budget.""" + if self._one_step: + raise HostedApplicationError("coding_local_closed") + await self._activate() + + async def _activate(self) -> None: + if (self._closing or not self._prepared or self._activate_task is not None + or self._start_task is None or not self._start_task.done() or _failed(self._start_task)): + raise HostedApplicationError("coding_local_closed") + try: + self._remaining_startup() + self._activate_task = _spawn(self._activate_once()) + done, _ = await asyncio.wait( + {self._activate_task}, timeout=self._remaining_startup() + ) + if not done: + raise HostedApplicationError("coding_local_startup_timeout") + await asyncio.shield(self._activate_task) + self._remaining_startup() + if self._local is None or not self._local.accepting: + raise HostedApplicationError("coding_local_closed") + except BaseException: + await self.close() + raise + + async def _activate_once(self) -> None: + self._remaining_startup() + assert self._local is not None + await self._local.activate() + self._remaining_startup() + + def _remaining_startup(self) -> float: + if self._closing or self._startup_deadline is None: + raise HostedApplicationError("coding_local_closed") + remaining = self._startup_deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise HostedApplicationError("coding_local_startup_timeout") + return remaining async def wait_closed(self) -> None: if self._local is None: @@ -176,6 +243,10 @@ async def close(self, *, retry_timeout: float | None = None) -> None: if self._settled: return self._closing = True + if self._local is not None: + # Fence lower-level activation before scheduling our close task. + # An already queued activate must not publish in the intervening turn. + self._local.fence() if self._deadline is None: self._deadline = asyncio.get_running_loop().time() + self._timeout task = self._close_task @@ -193,6 +264,8 @@ async def _close_once(self, retry_timeout: float | None) -> None: await self._local.close(retry_timeout=retry_timeout) if self._start_task is not None: await _wait(self._start_task, self._deadline, ignore_failure=True) + if self._activate_task is not None: + await _wait(self._activate_task, self._deadline, ignore_failure=True) if self._local is None: self._directory.close() await self._phase( diff --git a/tests/apphost/test_local.py b/tests/apphost/test_local.py index d7b8c9fbb..2ebbaf676 100644 --- a/tests/apphost/test_local.py +++ b/tests/apphost/test_local.py @@ -71,8 +71,16 @@ def __init__(self, *args, **kwargs): self.request_stop = kwargs["request_stop"] self.start_gate = self.close_gate = None self.entered = asyncio.Event() + self.prepare_entered = asyncio.Event() + self.prepare_gate = None - async def start(self): + async def prepare(self, *, deadline=None): + self.deadline = deadline + self.prepare_entered.set() + if self.prepare_gate is not None: + await self.prepare_gate.wait() + + async def activate(self): events.append("listener.start") self.entered.set() if self.start_gate is not None: @@ -98,6 +106,98 @@ async def close(self): return owner, application, directory, events +def test_preparation_does_not_enable_scopes_or_renew_activation_budget(monkeypatch): + async def scenario(): + owner, _, _, events = _fake_runtime(monkeypatch) + deadline = asyncio.get_running_loop().time() + 0.1 + await owner.prepare(deadline=deadline) + assert not owner.accepting and events == [] + assert owner._startup_deadline == owner._server.deadline == deadline + await asyncio.sleep(max(0, deadline - asyncio.get_running_loop().time()) + 0.01) + with pytest.raises(HostedApplicationError, match="startup_timeout"): + await owner.activate() + assert owner._activate_task is None and not owner.accepting + assert "enable" not in events and "listener.start" not in events + assert events[-1] == "application.close" and not owner.cleanup_pending + asyncio.run(scenario()) + + +def test_repeated_or_out_of_order_stages_leave_valid_owner_intact(monkeypatch): + async def scenario(): + owner, _, _, events = _fake_runtime(monkeypatch) + with pytest.raises(HostedApplicationError): + await owner.activate() + await owner.prepare() + for call in (owner.prepare, owner.start): + with pytest.raises(HostedApplicationError): + await call() + assert events == [] and not owner._closing + await owner.activate() + with pytest.raises(HostedApplicationError): + await owner.activate() + assert events == ["enable", "listener.start"] and owner.accepting + await owner.close() + with pytest.raises(HostedApplicationError): + await owner.activate() + asyncio.run(scenario()) + + +def test_close_fences_late_preparation_without_enabling_scopes(monkeypatch): + async def scenario(): + owner, _, _, events = _fake_runtime(monkeypatch, timeout=0.02) + gate = owner._server.prepare_gate = asyncio.Event() + preparing = asyncio.create_task(owner.prepare()) + await owner._server.prepare_entered.wait() + owned = owner._start_task + for call in (owner.prepare, owner.activate, owner.start): + with pytest.raises(HostedApplicationError): + await call() + assert not owner._closing + with pytest.raises(HostedApplicationError, match="cleanup_incomplete"): + await owner.close() + assert owner._start_task is owned and not owned.done() + assert "application.close" not in events + gate.set() + with pytest.raises(HostedApplicationError): + await preparing + await owner.close(retry_timeout=1) + assert "enable" not in events and "listener.start" not in events + assert events.count("application.close") == 1 and not owner.cleanup_pending + asyncio.run(asyncio.wait_for(scenario(), 5)) + + +@pytest.mark.parametrize("deadline", [True, float("nan"), float("inf"), -float("inf"), 10**1000, -(10**1000), 0, -1]) +def test_invalid_deadline_never_starts_or_fences_application(monkeypatch, deadline): + async def scenario(): + owner, _, _, events = _fake_runtime(monkeypatch) + with pytest.raises(ValueError): + await owner.prepare(deadline=deadline) + assert owner._start_task is None and owner._startup_deadline is None + assert events == [] and not owner._closing + await owner.close() + asyncio.run(scenario()) + + +def test_one_step_start_reserves_both_phases(monkeypatch): + async def scenario(): + owner, _, _, events = _fake_runtime(monkeypatch) + original = owner._prepare + + async def competing(**kwargs): + await original(**kwargs) + assert owner._start_task.done() and owner._prepared + for call in (owner.prepare, owner.start, owner.activate): + with pytest.raises(HostedApplicationError): + await call() + assert owner._activate_task is None and not owner._closing + + monkeypatch.setattr(owner, "_prepare", competing) + await owner.start() + assert owner.accepting and events == ["enable", "listener.start"] + await owner.close() + asyncio.run(scenario()) + + def test_stop_publishes_owner_and_fences_before_reply_then_settles_dependencies( monkeypatch, ): @@ -124,7 +224,7 @@ async def scenario(): def test_stop_between_start_task_completion_and_delivery_rejects_ready(monkeypatch): async def scenario(): owner, _, _, events = _fake_runtime(monkeypatch) - original = owner._start_once + original = owner._activate_once reply = asyncio.get_running_loop().create_future() reply.set_result(None) @@ -133,7 +233,7 @@ async def start_then_stop(): # Dispatch stop before asyncio.wait delivers the finished task. asyncio.get_running_loop().call_soon(owner._server.request_stop, reply) - monkeypatch.setattr(owner, "_start_once", start_then_stop) + monkeypatch.setattr(owner, "_activate_once", start_then_stop) with pytest.raises(HostedApplicationError, match="hosted_local_closed"): await owner.start() assert not owner.cleanup_pending @@ -299,7 +399,7 @@ async def capture(task, deadline, **kwargs): monkeypatch.setattr(local, "_wait", capture) await owner.close() - assert len(deadlines) == 5 # startup join, reply, connection, directory, G13 + assert len(deadlines) == 6 # prepare/activate joins, reply, connection, directory, G13 assert set(deadlines) == {owner._deadline} asyncio.run(scenario()) diff --git a/tests/appserver/test_local.py b/tests/appserver/test_local.py index f8dda2df2..af9980589 100644 --- a/tests/appserver/test_local.py +++ b/tests/appserver/test_local.py @@ -2,6 +2,8 @@ import asyncio import inspect +import subprocess +import sys from contextlib import suppress from dataclasses import replace from typing import cast @@ -62,6 +64,229 @@ async def _until(predicate): await asyncio.sleep(0.001) +@pytest.mark.parametrize("stale", [False, True]) +def test_managed_prepare_binds_without_publication_or_acceptance(tmp_path, stale): + root = tmp_path / "runtime" + if stale: + # A real crashed writer leaves its record but releases its native lock. + subprocess.run([sys.executable, "-c", """ +import os, socket, sys +from pathlib import Path +from loushang.appserver.local_record import LocalConnectionDirectoryV1, LocalRecordScopeV1 +from loushang.appserver.protocol import SessionScopeV1 +directory = LocalConnectionDirectoryV1(Path(sys.argv[1])) +reservation = directory.acquire('workspace') +sock = socket.socket() +sock.bind(('127.0.0.1', 0)) +reservation.publish(application_id='old', product_id='coding', + port=sock.getsockname()[1], + scopes=(LocalRecordScopeV1(SessionScopeV1.CWD, 'a' * 64),)) +os._exit(0) +""", str(root)], check=True, timeout=10) + + async def scenario(): + directory = LocalConnectionDirectoryV1(root) + old = directory.read("workspace") if stale else None + server, scopes = _server(directory) + client = LocalAppClientConnectionV1(directory, "workspace") + try: + await server.prepare() + assert not server._server.is_serving() + assert server._record is None and scopes == [] + with pytest.raises(AppServiceError): + _ = server.record + if old is not None: + assert directory.read("workspace") == old + else: + with pytest.raises(LocalRecordError): + directory.read("workspace") + # Even knowing the prepared native address cannot enter semantics. + addresses = [server._server.sockets[0].getsockname()] + if old is not None: + addresses.append(("127.0.0.1", old.port)) + for address in addresses: + with pytest.raises(OSError): + await asyncio.wait_for(asyncio.open_connection(*address), 1) + assert scopes == [] + await server.activate() + if old is not None: + assert server.record.instance != old.instance + reader, writer = await asyncio.open_connection("127.0.0.1", server.record.port) + try: + with pytest.raises(LocalAuthenticationError): + await authenticate_local_client( + AsyncioStreamTransportV1(reader, writer), old.authentication, timeout=1, + ) + finally: + writer.close() + await writer.wait_closed() + assert scopes == [] + await client.start() + assert (await client.client.list_muxes()).mux_spaces == () + assert len(scopes) == 1 + finally: + await client.close() + await server.close() + directory.close() + + asyncio.run(asyncio.wait_for(scenario(), 5)) + + +def test_managed_prepare_activate_call_order_never_restarts_or_republishes(tmp_path): + async def scenario(): + directory = LocalConnectionDirectoryV1(tmp_path / "runtime") + server, _ = _server(directory) + try: + with pytest.raises(AppServiceError): + await server.activate() + assert server._start_task is None and not server._closed + await server.prepare() + original = server._server + for call in (server.prepare, server.start): + with pytest.raises(AppServiceError): + await call() + assert not server._closed + await server.activate() + record = server.record + with pytest.raises(AppServiceError): + await server.activate() + assert server._server is original and server.record == record + await server.close() + for call in (server.prepare, server.activate, server.start): + with pytest.raises(AppServiceError): + await call() + finally: + await server.close() + directory.close() + asyncio.run(scenario()) + + +def test_managed_activation_cannot_renew_expired_preparation_budget(tmp_path): + async def scenario(): + directory = LocalConnectionDirectoryV1(tmp_path / "runtime") + server, scopes = _server(directory) + deadline = asyncio.get_running_loop().time() + 0.1 + try: + await server.prepare(deadline=deadline) + assert server._startup_deadline == deadline + await asyncio.sleep(max(0, deadline - asyncio.get_running_loop().time()) + 0.01) + with pytest.raises(AppServiceError): + await server.activate() + assert server._startup_deadline == deadline + assert server._activate_task is None and scopes == [] + assert not directory._leases + with pytest.raises(LocalRecordError): + directory.read("workspace") + finally: + await server.close() + directory.close() + asyncio.run(scenario()) + + +def test_managed_cancelled_activation_retains_late_serving_task(tmp_path, monkeypatch): + async def scenario(): + directory = LocalConnectionDirectoryV1(tmp_path / "runtime") + server, scopes = _server(directory, close_timeout=0.05) + entered, release = asyncio.Event(), asyncio.Event() + try: + await server.prepare() + native = server._server + original = native.start_serving + + async def delayed(): + await original() + entered.set() + await release.wait() + + monkeypatch.setattr(native, "start_serving", delayed) + activating = asyncio.create_task(server.activate()) + await entered.wait() + owned = server._activate_task + with pytest.raises(AppServiceError): + await server.activate() + assert not server._closed # Invalid second waiter did not fence the first. + activating.cancel() + with pytest.raises(AppServiceError): + await activating + assert not owned.done() and not owned.cancelled() + assert not native.is_serving() and scopes == [] + release.set() + await server.close() + assert owned.done() and native.sockets == () + assert not directory._leases + finally: + release.set() + await server.close() + directory.close() + asyncio.run(asyncio.wait_for(scenario(), 5)) + + +def test_managed_failed_publication_cleans_its_late_record(tmp_path, monkeypatch): + async def scenario(): + directory = LocalConnectionDirectoryV1(tmp_path / "runtime") + server, scopes = _server(directory) + try: + await server.prepare() + original = server._reservation.publish + + def publish_then_fail(**kwargs): + original(**kwargs) + raise RuntimeError("injected failure after publication") + + monkeypatch.setattr(server._reservation, "publish", publish_then_fail) + with pytest.raises(RuntimeError, match="injected failure"): + await server.activate() + assert not server._server.is_serving() and scopes == [] + assert not directory._leases + with pytest.raises(LocalRecordError): + directory.read("workspace") + finally: + await server.close() + directory.close() + asyncio.run(scenario()) + + +@pytest.mark.parametrize("deadline", [True, float("nan"), float("inf"), -float("inf"), 10**1000, -(10**1000), 0, -1]) +def test_managed_invalid_deadline_has_no_startup_effect(tmp_path, deadline): + async def scenario(): + directory = LocalConnectionDirectoryV1(tmp_path / "runtime") + server, _ = _server(directory) + try: + with pytest.raises(ValueError): + await server.prepare(deadline=deadline) + assert server._start_task is None and server._startup_deadline is None + assert not (tmp_path / "runtime").exists() and not server._closed + finally: + await server.close() + directory.close() + asyncio.run(scenario()) + + +def test_one_step_start_reserves_activation_between_stages(tmp_path, monkeypatch): + async def scenario(): + directory = LocalConnectionDirectoryV1(tmp_path / "runtime") + server, _ = _server(directory) + original = server._prepare + + async def competing(**kwargs): + await original(**kwargs) + assert server._start_task.done() and server._prepared + for call in (server.start, server.prepare, server.activate): + with pytest.raises(AppServiceError): + await call() + assert server._activate_task is None and not server._closed + + monkeypatch.setattr(server, "_prepare", competing) + try: + await server.start() + assert server.record == directory.read("workspace") + assert server._server.is_serving() + finally: + await server.close() + directory.close() + asyncio.run(scenario()) + + def test_G16_LOCAL_AUTH_expected_product_is_checked_on_admitted_record_before_io(tmp_path): async def scenario(): directory = LocalConnectionDirectoryV1(tmp_path / "runtime") diff --git a/tests/architecture/test_detachable_local_workspace_g16.py b/tests/architecture/test_detachable_local_workspace_g16.py index a6608b2a8..041212c39 100644 --- a/tests/architecture/test_detachable_local_workspace_g16.py +++ b/tests/architecture/test_detachable_local_workspace_g16.py @@ -125,7 +125,9 @@ def test_G16_BOUNDARIES_native_io_is_confined_to_explicit_local_adapter() -> Non root = Path("src/loushang/appserver") source = (root / "local.py").read_text() - assert len(source.splitlines()) <= 450 + # LMUX-M0 §17 reviewed two-stage owner delta from 0351f8b0: 392 -> 463. + # Preserve the previous allowance; no other file or native boundary is exempt. + assert len(source.splitlines()) <= 450 + 71 assert len((root / "_local_peer.py").read_text().splitlines()) <= 240 assert '_LOOPBACK = "127.0.0.1"' in source assert "MAX_LOCAL_CONNECTIONS = 8" in source @@ -152,7 +154,8 @@ def test_G16_BOUNDARIES_native_io_is_confined_to_explicit_local_adapter() -> Non def test_G16_BOUNDARIES_local_apphost_edge_uses_public_application_capabilities() -> None: source = Path("src/loushang/apphost/local.py") - assert len(source.read_text().splitlines()) <= 300 + # LMUX-M0 §17 reviewed staged startup/synchronous fence: 247 -> 327. + assert len(source.read_text().splitlines()) <= 300 + 80 tree = ast.parse(source.read_text()) external = { node.module for node in ast.walk(tree) @@ -200,6 +203,24 @@ def test_G16_BOUNDARIES_product_bootstrap_is_shared_without_transport_or_default assert scripts["loushang-mux"] == "loushang.coding.cli.mux:main" +def test_managed_staging_keeps_exact_optional_signatures_and_sync_fence(): + from loushang.apphost.local import HostedLocalRuntimeV1 + from loushang.appserver.local import LocalAppServerV1 + from loushang.coding.hosted_local import CodingLocalCommandV1 + + for owner in (HostedLocalRuntimeV1, LocalAppServerV1, CodingLocalCommandV1): + parameters = inspect.signature(owner.prepare).parameters + assert set(parameters) == {"self", "deadline"} + assert parameters["deadline"].kind is inspect.Parameter.KEYWORD_ONLY + assert parameters["deadline"].default is None + assert set(inspect.signature(owner.activate).parameters) == {"self"} + assert set(inspect.signature(owner.start).parameters) == {"self"} + assert inspect.iscoroutinefunction(owner.prepare) + assert inspect.iscoroutinefunction(owner.activate) + assert set(inspect.signature(HostedLocalRuntimeV1.fence).parameters) == {"self"} + assert not inspect.iscoroutinefunction(HostedLocalRuntimeV1.fence) + + def test_G16_BOUNDARIES_shell_borrows_only_semantics_and_owns_no_native_connection(): root = Path("src/loushang/harnesstui/mux") paths = [root / name for name in ("shell.py", "terminal.py", "_shell_tasks.py", "_shell_screen.py")] diff --git a/tests/coding/test_hosted_local.py b/tests/coding/test_hosted_local.py index b51a312d1..29361bfa4 100644 --- a/tests/coding/test_hosted_local.py +++ b/tests/coding/test_hosted_local.py @@ -9,7 +9,7 @@ from loushang.agent import synthetic_model_transport from loushang.ai.types import TextPart from loushang.appserver.local import LocalAppClientConnectionV1, LocalConnectionModeV1 -from loushang.appserver.local_record import LocalConnectionDirectoryV1 +from loushang.appserver.local_record import LocalConnectionDirectoryV1, LocalRecordError from loushang.appserver.protocol import ( AppErrorCodeV1, AppServiceError, @@ -112,7 +112,16 @@ async def observed_stream(model, context, options=None): old_key = None try: mark("first_start") - await command.start() + if discovery_enabled: + await command.prepare() + assert not command._local.accepting + assert not command._local._scopes_enabled + assert not command._local._server._server.is_serving() + with pytest.raises(LocalRecordError): + directory.read(launch.endpoint) + await command.activate() + else: + await command.start() mark("first_ready_construct_and_interact") old_key = directory.read(launch.endpoint).key for connection, scope, name in zip( diff --git a/tests/coding/test_hosted_local_ownership.py b/tests/coding/test_hosted_local_ownership.py index 77e9c6fdf..8b9d5b552 100644 --- a/tests/coding/test_hosted_local_ownership.py +++ b/tests/coding/test_hosted_local_ownership.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import inspect import pytest @@ -42,21 +43,34 @@ def __init__(self, application, directory, *args, **kwargs): self.close_entered = asyncio.Event() self.fail_once = False self.budgets = [] - self.accepting = True + self.accepting = False self.settled = False + self.activations = 0 + self.fenced = False - async def start(self): + async def prepare(self, *, deadline=None): + self.deadline = deadline self.entered.set() if self.start_gate is not None: await self.start_gate.wait() + async def activate(self): + if self.fenced: + raise HostedApplicationError("hosted_local_closed") + self.activations += 1 + self.accepting = True + + def fence(self): + self.fenced = True + self.accepting = False + async def wait_closed(self): await self.close() async def close(self, *, retry_timeout=None): if self.settled: return - self.accepting = False + self.fence() self.budgets.append(retry_timeout) self.close_entered.set() if self.fail_once: @@ -81,6 +95,153 @@ async def close(self, *, retry_timeout=None): return command, attempt, events +def test_managed_preparation_reuses_product_and_rejects_invalid_stage_calls(tmp_path, monkeypatch): + async def scenario(): + command, _, events = _command(tmp_path, monkeypatch) + with pytest.raises(HostedApplicationError): + await command.activate() + assert command._start_task is None + deadline = asyncio.get_running_loop().time() + 1 + await command.prepare(deadline=deadline) + local = command._local + assert local.deadline == command._startup_deadline == deadline + assert not local.accepting and local.activations == 0 + for call in (command.prepare, command.start): + with pytest.raises(HostedApplicationError): + await call() + assert not command._closing and events == ["recovered", "adopt"] + await command.activate() + with pytest.raises(HostedApplicationError): + await command.activate() + assert local.accepting and local.activations == 1 + await command.close() + assert events.count("application.close") == 1 and not command.cleanup_pending + asyncio.run(scenario()) + + +@pytest.mark.parametrize("expired", [False, True]) +def test_managed_close_or_expiry_between_stages_never_activates(tmp_path, monkeypatch, expired): + async def scenario(): + command, _, events = _command(tmp_path, monkeypatch) + deadline = asyncio.get_running_loop().time() + 0.1 + await command.prepare(deadline=deadline) + local = command._local + if expired: + await asyncio.sleep(max(0, deadline - asyncio.get_running_loop().time()) + 0.01) + else: + await command.close() + with pytest.raises(HostedApplicationError): + await command.activate() + assert command._startup_deadline == deadline and local.activations == 0 + assert not local.accepting and not command.cleanup_pending + assert events.count("application.close") == 1 + asyncio.run(scenario()) + + +def test_managed_prepare_deadline_starts_before_product_recovery(tmp_path, monkeypatch): + async def scenario(): + command, attempt, events = _command(tmp_path, monkeypatch) + attempt.gate = asyncio.Event() + deadline = asyncio.get_running_loop().time() + 1 + preparing = asyncio.create_task(command.prepare(deadline=deadline)) + await attempt.entered.wait() + assert command._startup_deadline == deadline + for call in (command.prepare, command.activate, command.start): + with pytest.raises(HostedApplicationError): + await call() + assert not command._closing + attempt.gate.set() + await preparing + assert command._local.deadline == deadline + assert events == ["recovered", "adopt"] + await command.close() + asyncio.run(scenario()) + + +@pytest.mark.parametrize("deadline", [True, float("nan"), float("inf"), -float("inf"), 10**1000, -(10**1000), 0, -1]) +def test_invalid_deadline_never_starts_product(tmp_path, monkeypatch, deadline): + async def scenario(): + command, _, events = _command(tmp_path, monkeypatch) + with pytest.raises(ValueError): + await command.prepare(deadline=deadline) + assert command._start_task is None and command._startup_deadline is None + assert events == [] and not command._closing + await command.close() + asyncio.run(scenario()) + + +def test_one_step_start_cannot_lose_activation_to_later_waiter(tmp_path, monkeypatch): + async def scenario(): + command, _, events = _command(tmp_path, monkeypatch) + original = command._prepare + + async def competing(**kwargs): + await original(**kwargs) + assert command._start_task.done() and command._prepared + for call in (command.prepare, command.start, command.activate): + with pytest.raises(HostedApplicationError): + await call() + assert command._activate_task is None and not command._closing + + monkeypatch.setattr(command, "_prepare", competing) + await command.start() + assert command._local.activations == 1 and events == ["recovered", "adopt"] + await command.close() + asyncio.run(scenario()) + + +@pytest.mark.parametrize("active", [False, True]) +def test_close_factory_failure_still_fences_prepared_or_active_deployment(tmp_path, monkeypatch, active): + async def scenario(): + command, _, events = _command(tmp_path, monkeypatch) + await command.prepare() + if active: + await command.activate() + local = command._local + captured = [] + + def reject(work): + captured.append(work) + raise RuntimeError("injected task factory failure") + + with monkeypatch.context() as patch: + patch.setattr(asyncio, "create_task", reject) + with pytest.raises(RuntimeError, match="task factory"): + await command.close() + assert local.fenced and not local.accepting + assert not local.settled and command.cleanup_pending + assert all(inspect.getcoroutinestate(work) == inspect.CORO_CLOSED for work in captured) + assert "application.close" not in events + await command.close() + assert not command.cleanup_pending and events.count("application.close") == 1 + asyncio.run(scenario()) + + +def test_close_fences_already_queued_inner_activation(tmp_path, monkeypatch): + async def scenario(): + command, _, events = _command(tmp_path, monkeypatch) + await command.prepare() + local = command._local + entered, release = asyncio.Event(), asyncio.Event() + original = local.activate + + async def delayed(): + entered.set() + await release.wait() + await original() + + monkeypatch.setattr(local, "activate", delayed) + activating = asyncio.create_task(command.activate()) + await entered.wait() + release.set() # Activation is queued before the newly spawned close task. + await command.close() + with pytest.raises(HostedApplicationError): + await activating + assert local.activations == 0 and local.fenced and not local.accepting + assert events.count("application.close") == 1 and not command.cleanup_pending + asyncio.run(asyncio.wait_for(scenario(), 5)) + + def test_G16_PRODUCT_CANCEL_late_recovery_is_retained_without_publishing_local_owner( tmp_path, monkeypatch ): @@ -188,7 +349,7 @@ async def scenario(): async def stop(self): await self.close() - monkeypatch.setattr(local_module.HostedLocalRuntimeV1, "start", stop) + monkeypatch.setattr(local_module.HostedLocalRuntimeV1, "activate", stop) with pytest.raises(HostedApplicationError, match="coding_local_closed"): await command.run(ready=lambda: events.append("ready")) assert "ready" not in events From 415b6b90a0f44dad14326459890599437f51a346 Mon Sep 17 00:00:00 2001 From: zhnt Date: Sun, 13 Sep 2026 09:26:39 +0000 Subject: [PATCH 09/44] feat(apphost): retain managed child application lifetime --- .../architecture/apphost/lmux-contract-m0.md | 96 +++- .../hosted-product-runtime-v1-inventory.md | 3 +- src/loushang/apphost/managed/child.py | 353 ++++++++++++++ src/loushang/apphost/managed/handoff.py | 69 ++- src/loushang/apphost/managed/lifecycle.py | 44 ++ src/loushang/coding/hosted_local.py | 14 +- src/loushang/hosting/service_handoff.py | 17 +- tests/apphost/test_managed_child.py | 459 ++++++++++++++++++ tests/apphost/test_managed_lifecycle.py | 17 + .../test_detachable_local_workspace_g16.py | 5 +- ...test_hosted_product_g10_explicit_canary.py | 2 +- .../test_hosted_product_runtime_g9_closure.py | 2 +- ...test_hosted_product_runtime_v1_baseline.py | 2 + .../test_hosting_architecture_baseline.py | 1 + ...st_plugin_lifecycle_plc9c5_c50_baseline.py | 2 + tests/coding/test_managed_child.py | 171 +++++++ tests/hosting/test_service_handoff.py | 23 + 17 files changed, 1260 insertions(+), 20 deletions(-) create mode 100644 src/loushang/apphost/managed/child.py create mode 100644 tests/apphost/test_managed_child.py create mode 100644 tests/coding/test_managed_child.py diff --git a/docs/internals/architecture/apphost/lmux-contract-m0.md b/docs/internals/architecture/apphost/lmux-contract-m0.md index c866e365c..70927945c 100644 --- a/docs/internals/architecture/apphost/lmux-contract-m0.md +++ b/docs/internals/architecture/apphost/lmux-contract-m0.md @@ -8,8 +8,8 @@ - ID: `LMUX-M0` - Authority: normative — accepted limited Linux managed-profile contract - Design status: accepted -- Review status: three-perspective storage/lifecycle/observer/handoff/native-launch/native-binding/application-staging slice reviews passed; recovery admission pending -- Implementation status: partial — storage, instance coordination, Linux observations, native launch, durable birth binding and staged application startup; no managed activation +- Review status: three-perspective storage/lifecycle/observer/handoff/native-launch/native-binding/application-staging/child-owner slice reviews passed; recovery admission pending +- Implementation status: partial — storage, instance coordination, Linux observations, native launch, durable birth binding, staged application startup and child application owner; no managed CLI activation - Owner: AppHost managed deployment; sibling changes remain sibling-owned - Tracking objective: active Linux lmux goal, branch `harness/lmux-managed-service` @@ -242,6 +242,7 @@ Mux 外壳只负责成员/连接组合。主题与终端能力在客户端组合 | M2 Linux 创建 owner | 单次生产 Popen 创建、受管 FD/环境/工作区、复用 POSIX group 观察;真实父端退出和故障矩阵、三视角复审通过 | 身份持久登记、应用自持生命周期、异常恢复和 CLI 激活仍待完成 | | M2 birth binding | schema v3 原生身份登记、精确匹配的 commit/abort;117 项聚焦验证、三视角复审通过 | 应用自持 owner、异常退役恢复、Session writer lease 与完整前端接线仍待完成 | | M2 应用准备/激活 | 原 AppServer/AppHost/Coding owner 的两步启动、共享截止时间和同步 fence;真实 Product/认证回归,设计与代码三视角复审通过 | 受管 child owner 仍需在两步之间接入 durable commit;CLI 不自动启动 | +| M2 子端应用 owner | 同一实际 Coding 应用的 prepare/commit/activate 接线、独立 IO/stop/cleanup owner;三视角复审通过 | installed child composition/真正父进程退出与异常恢复仍需整体验证,尚无 CLI 自动后台 | | 一条命令/后台/全局名/多 Tab/stop | 仅 G16 既有显式能力 | M1–M3 全部接线 | | Session 唯一写入与默认历史 | 缺运行期跨进程合同实现 | writer lease + canonical catalog + 双进程测试 | | 完整共享 Harnesstui/Markdown | 草案与本文接缝 | M3 代码与 Embedded 非回退 | @@ -574,3 +575,94 @@ G16 两个既有源码行数门禁按架构复审确认的精确增量更新: 本切片不发布自动后台入口,未运行整个完整目标的 installed/SSH/首用性能 矩阵,也不替代最终 change-aware/远端门禁。完整 child owner、异常退役、 唯一 writer、公共协调器、配额与 Harnesstui 接线仍为 active goal 的后续工作。 + +## 18. M2 受管子端应用 owner + +受管子进程采用 AppHost 可选 `managed.child` owner,注入同一份实际应用 + 的 prepare/activate/fence/close/wait_closed/cleanup_pending 中性端口,不导入 + Coding、Harnesstui 或 CLI。Coding 原 command 增加同步 fence,复用 §17 + 同一 AppHost owner。原 foreground/G16 入口不选择本 owner。 + +owner 在准备前已被 child composition 持有。一次运行保留 Product prepare、 + activate、服务 wait_closed 和 cleanup 的精确任务;公开 run 的取消仅取消 + waiter,不终止该服务,不取消阶段任务。公开 close 是 child 自身的明确 + graceful-stop authority,不是 starter 取消或 client EOF 的传播入口。 + 该调用同步 fence 已拥有的应用,保留未完成任务及失败清理,显式有界 + retry_timeout 才能更新已结束的 cleanup attempt 的预算。 + +启动循环先验证绑定的 instance/attempt/native,再准备应用,同时轮询继承 + 通道。准备中确认 ABORTING 才因父端 EOF/启动取消回滚;UNKNOWN 时不另开 + 应用,不重新 spawn,也不丢弃已准备 owner。starter 等待/交接观察期限耗尽 + 只提出 abort,不能把超时变成已确认的 ABORTING。应用 prepare/activate + 自身失败(含其独立启动期限耗尽)属于下述 child 自主故障,可以自行关闭; + 不把原 §17 端口的自动清理错误解释成父端已获得 rollback authority。 + prepare 成功且应用启动 deadline 未耗尽后子端提出 durable commit; + 确认 COMMITTED 后才 activate,随后彻底忽略启动器通道 EOF。commit 后仍 + 观察该精确实例 stop fence 与应用的 wait_closed,不将未知观察当作停止。 + 应用 deadline 耗尽后不再新提 commit/activate;后续对账和 abort 使用单独 + 有界 IO 预算,但绝不续应用启动期限。commit 已落盘而确认到达太晚时, + 子端按自身启动失败停止,不改写成父端回滚成功。 + +应用内部失败和显式 child close 是服务自己的终止原因,与“启动器因为 + 未收到 ack 要回滚”分开。它们可同步 fence 自己的应用、执行 graceful + cleanup,同时提交该实例 stop/abort;数据库不可用不能阻塞实际已拥有 + 资源的安全关闭,但记录保持 unknown/unclean,不能返回已证明干净停止。 + cleanup_pending 为假且完整应用 close 成功后才能提交 application cleanup + 事实;子端绝不宣称自己已经 process_exited 或 scope_settled。记录失败 + 保留本地完成事实和精确 owner,重试只补交事实,不重跑已成功的清理。 + +同步 channel/journal IO 由该 owner 持有的单工作线程串行执行,全部 job/future + 被保留并 shield;一次最多一个任务,不建立无界队列。取消 waiter、到期 + 或 close 均不丢掉仍在 native IO 中的 future;仅在它完成后关闭 channel + 并回收 executor,借用的 journal/registry 由外层 composition 最后关闭。 + 单次 absolute monotonic IO deadline 贯穿 channel、journal 与失败重查; + 不承诺抢占 OS fsync。不存在 native IO 从 UI/应用事件循环同步执行的路径。 + close 同步 fence 并立即启动应用自身 cleanup,不等待在途 journal job; + 只有控制句柄/executor 回收与持久事实提交等待该 job 结算。应用超时且 + journal unknown、commit 确认晚于应用 deadline 必须有单独负测。 + +可选 managed control adapter 对 child 的 stop/cleanup 写入同样使用原子 + native 匹配,不能先读匹配再按 instance-only 修改。登记前的同 attempt + abort/cleanup 保持可结算,登记后的错 native 不能污染事实;父端/外部 + trusted stop observer 的旧 instance-only 方法保持原合同。 + +验收需覆盖实际 Coding prepare→commit→activate/认证、准备中父端 EOF、 + commit 后启动器退出与接受工作续行、stop/activate 竞争、未知提交后重查、 + 取消 run waiter、应用/记录清理失败及重试、原生 IO 挂起时关闭不丢 future。 + 本 owner 不等于公共 EnsureStarted、异常退役恢复或 CLI 激活;这些仍须 + 满足完整目标既定准入后再组合发布。 + +设计与代码三视角复审通过。复审修复了内部调度异常绕过 cleanup、挂起 + native IO 阻挡应用自主终止,以及清理失败阻挡 stop 水位提交的问题。 + runner 保留异常边界;独立 observation task 与 prepare/activate/wait_closed/ + deadline 并列观察,激活完成后立即建立应用终止任务,不等控制读取完成。 + stop task 与应用 cleanup 独立启动,通过同一工作线程串行进入控制 IO, + 不覆盖旧 job/future;明确的新预算仅在旧 stop job 完成后补交。成功应用 + cleanup 不重跑,channel/executor 最后回收;journal/registry 仍由外层保留。 + +`request_child_stop` 和 `record_child_cleanup` 是两个窄写入口:前者在 + provisional 原子 abort+stop,committed 只 stop;后者只可写应用完成位, + 原生与 attempt 检查和修改共用事务。登记前可以结算 abort,之后禁止迟到 + 登记;错 UID 在 control 接管 socket 前被拒绝。原 observer 的三事实接口 + 没有变成任意 child 可调用的进程退出声明。child channel 还接收原 absolute + deadline,防止计算 remaining 后的线程调度延长 commit 窗口;旧缺省调用 + 不变,过期不进行 port IO。 + +当前验证:111 项 child/lifecycle/handoff/旧 Coding ownership 聚焦回归通过; + 真实 Coding 认证及对应精确架构/inventory 回归 54 passed;补齐实际 Coding + 自身超时且 journal unknown,以及绝对 deadline 后,相关回归 49 passed。 + 三视角另确认上述 deadline 小修。Ruff、11 模块 mypy 与依赖图新鲜度通过。 + 第一版真实 Coding fixture 缺少 attach 前置、超时 fixture 对 frozen attempt + 实例 patch 方法而失败,均修正为合法协议/类方法故障注入后通过;不把 + fixture 错误作为产品失败或隐藏跳过。末次 prebirth/错 UID 与持久状态聚焦 + 回归 68 passed,文档 6 项通过。以上是不同范围的分次回归,不相加宣称 + 全新覆盖量,也不是完整 change-aware 或最终 installed 门禁已经通过。 + +真实 Coding 用受控 synthetic model 挂住已接纳请求,关闭继承 startup socket + 与客户端连接,再以新认证连接读取同一 Session 的完成结果;模型调用 + 计数为一次,最后通过认证 stop 完成应用清理。这里实际运行的是 Product、 + TCP 认证与 Session 工作 owner,但 application/controller 仍在测试解释器; + 不能把 socket EOF 测试称为已完成独立 daemon/SSH 验收。先前 Popen 父死 + 原生用例也不能替代下一步实际 child entry 的组合验证。完整默认目录、 + writer、异常退役、配额、公共协调器、CLI、完整 Harnesstui 与 installed/SSH/ + 性能矩阵仍在原 goal 内;本切片不提前 push/merge。 diff --git a/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md b/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md index 892f55396..6e8c735a7 100644 --- a/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md +++ b/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md @@ -67,7 +67,8 @@ lease, or factory. | optional Linux service observation | `src/loushang/hosting/service.py` | boot/PID/start-time/UID/namespace lookup facts with retained pidfd capture, re-admission and bounded exit observation; no process launch, signal, process-tree settlement or facade activation | | optional inherited-channel handoff | `src/loushang/hosting/service_handoff.py` | child-side durable commit/abort proposals through a borrowed consumer port; EOF and acknowledgement hints never replace durable authority; no launch or application cleanup | | optional Linux service launch | `src/loushang/hosting/service_process.py` | one owned Popen effect, detached stdio/session and exact inherited endpoint; retained identity and existing POSIX group observation; no implicit signals, sealed-preparation fallback or application-success inference | -| optional managed handoff binding | `src/loushang/apphost/managed/handoff.py` | exact instance/attempt journal adapter for Hosting handoff; stale/uncertain reads never grant current authority; no process ownership | +| optional managed handoff binding | `src/loushang/apphost/managed/handoff.py` | exact instance/attempt/native journal adapter and child control channel; bounded stop/cleanup proposals, stale/uncertain reads never grant current authority; no process ownership | +| optional managed child application owner | `src/loushang/apphost/managed/child.py` | staged application lifecycle around durable handoff, one retained control IO worker and independent application cleanup; no Product selection, spawn, CLI activation or process-exit claims | | AppServer Product ports | `src/loushang/appserver/ports.py` | immutable contract-only identity and Product-supplied Session/projection/work/interaction port bundle; no runtime, listener, protocol, or composition owner | | Session discovery composition | `src/loushang/harness/transcript/directory.py`, `src/loushang/harness/machine_resources/control_plane.py`, and `src/loushang/coding/cli/__main__.py` | canonical global plus cwd/home compatibility sources are selected at composition; cwd is a query/filter and compatibility roots are not writable authorities | | Linux/Windows Coding Product canary | `src/loushang/coding/_product_worker_canary.py` | sole explicit C5.4/C5.5c Product root; joins Coding Product/Session evidence to the real lifecycle coordinator, exact native-profile friend, Worker health, Capability publication, normal close, rollback, and recovery; omission remains Current and unlisted profiles remain closed | diff --git a/src/loushang/apphost/managed/child.py b/src/loushang/apphost/managed/child.py new file mode 100644 index 000000000..2499473d8 --- /dev/null +++ b/src/loushang/apphost/managed/child.py @@ -0,0 +1,353 @@ +"""Optional child-side application owner; no CLI, Product selection or spawn. + +The trusted child composition publishes this owner before run and retains it +through cleanup. Run waiters are not service-lifetime owners. A single retained +worker executes synchronous control IO without blocking the application loop. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Coroutine +from concurrent.futures import Future, ThreadPoolExecutor +from contextlib import suppress +from typing import Any, Literal, Protocol, TypeVar + +from .contracts import ManagedHandoffPhaseV1 +from .handoff import ManagedChildControlV1 +from .lifecycle import ManagedServiceStateV1 + +_T = TypeVar("_T") + + +class ManagedChildError(RuntimeError): + def __init__(self, code: str) -> None: + if code not in {"closed", "startup_failed", "activation_failed", "cleanup_incomplete"}: + raise ValueError("invalid child error") + super().__init__("managed_child_" + code) + + +class ManagedChildApplicationPortV1(Protocol): + """One already adopted application's complete, retryable lifecycle.""" + + @property + def cleanup_pending(self) -> bool: ... + + async def prepare(self, *, deadline: float | None = None) -> None: ... + + async def activate(self) -> None: ... + + def fence(self) -> None: ... + + async def close(self, *, retry_timeout: float | None = None) -> None: ... + + async def wait_closed(self) -> None: ... + + +class ManagedChildApplicationV1: + """Keep application ownership after durable handoff and starter exit. + + close is explicit child-local stop authority, never implicit run cancellation + or a client EOF. Cleanup failures retain phase tasks, the worker and the + borrowed journal dependency. No success here proves process/scope exit. + """ + + def __init__( + self, application: ManagedChildApplicationPortV1, control: ManagedChildControlV1, + *, startup_timeout: float = 30.0, settlement_timeout: float = 30.0, + ) -> None: + for value in (startup_timeout, settlement_timeout): + _budget(value) + if type(control) is not ManagedChildControlV1 or any( + not callable(getattr(application, name, None)) + for name in ("prepare", "activate", "fence", "close", "wait_closed") + ): + raise TypeError("invalid managed child owner") + self._application, self._control = application, control + self._startup_timeout, self._timeout = startup_timeout, settlement_timeout + self._worker: ThreadPoolExecutor | None = None + self._io_lock = asyncio.Lock() + self._pending_io: Future[Any] | None = None + self._observation_task: asyncio.Task[ManagedServiceStateV1 | None] | None = None + self._stop_task: asyncio.Task[ManagedServiceStateV1 | None] | None = None + self._stop_deadline: float | None = None + self._run_task: asyncio.Task[None] | None = None + self._prepare_task: asyncio.Task[None] | None = None + self._activate_task: asyncio.Task[None] | None = None + self._wait_task: asyncio.Task[None] | None = None + self._application_close: asyncio.Task[None] | None = None + self._close_task: asyncio.Task[None] | None = None + self._startup_deadline: float | None = None + self._close_deadline: float | None = None + self._application_settled = False + self._committed = False + self._closing = False + self._settled = False + self._failure: BaseException | None = None + self._wakeup = asyncio.Event() + + @property + def cleanup_pending(self) -> bool: + return not self._settled + + @property + def accepting(self) -> bool: + return (self._committed and not self._closing and self._activate_task is not None + and self._activate_task.done() and not _failed(self._activate_task)) + + async def run(self) -> None: + """Join the retained service runner; cancelling this waiter is nonterminal.""" + if self._run_task is None: + if self._closing: + raise ManagedChildError("closed") + self._startup_deadline = asyncio.get_running_loop().time() + self._startup_timeout + self._run_task = _spawn(self._run_once()) + await asyncio.shield(self._run_task) + + async def _run_once(self) -> None: + try: + await self._drive() + except BaseException as error: + self._failure = error + try: + await self.close() + except BaseException: + error.add_note("managed_child_cleanup_incomplete") + if isinstance(error, Exception) and not isinstance(error, ManagedChildError): + raise ManagedChildError("activation_failed" if self._committed else "startup_failed") from None + raise + else: + await self.close() + + async def _drive(self) -> None: + assert self._startup_deadline is not None + failure: str | None = None + while not self._closing: + if (not self.accepting and asyncio.get_running_loop().time() >= self._startup_deadline): + failure = "startup_failed" + break # Application's own startup budget, not a starter timeout. + state = await self._observe_running( + "read" if self._committed else "poll", + deadline=None if self.accepting else self._startup_deadline, + ) + if self._closing: + break + if state is not None: + if state.handoff.stop_requested or state.handoff.phase is ManagedHandoffPhaseV1.ABORTING: + break + if self._prepare_task is None: + if state.handoff.phase is not ManagedHandoffPhaseV1.PROVISIONAL: + failure = "startup_failed" + break + self._prepare_task = _spawn(self._application.prepare(deadline=self._startup_deadline)) + if state.handoff.phase is ManagedHandoffPhaseV1.COMMITTED: + self._committed = True + if self._prepare_task is not None and self._prepare_task.done(): + if _failed(self._prepare_task): + failure = "startup_failed" + break + if not self._committed: + # No fresh commit effect may begin after the app deadline. + if asyncio.get_running_loop().time() >= self._startup_deadline: + failure = "startup_failed" + break + state = await self._observe_running("commit", deadline=self._startup_deadline) + if self._closing: + break + if state is not None: + if state.handoff.stop_requested or state.handoff.phase is ManagedHandoffPhaseV1.ABORTING: + break + self._committed = state.handoff.phase is ManagedHandoffPhaseV1.COMMITTED + if self._committed and self._activate_task is None: + if asyncio.get_running_loop().time() >= self._startup_deadline: + failure = "startup_failed" + break + self._activate_task = _spawn(self._application.activate()) + if self._activate_task is not None and self._activate_task.done(): + if _failed(self._activate_task): + failure = "activation_failed" + break + if self._wait_task is None: + self._wait_task = _spawn(self._application.wait_closed()) + if self._wait_task.done(): + if _failed(self._wait_task): + failure = "activation_failed" + break + self._wakeup.clear() + with suppress(TimeoutError): + await asyncio.wait_for(self._wakeup.wait(), 0.25 if self.accepting else 0.05) + if failure is not None: + raise ManagedChildError(failure) + + async def _observe_running( + self, action: Literal["read", "poll", "commit"], *, deadline: float | None = None, + ) -> ManagedServiceStateV1 | None: + # Retain the observation independently. A blocked fsync cannot prevent + # noticing application failure/deadline or explicit local stop. + task = self._observation_task = _spawn(self._observe(action, deadline=deadline)) + while True: + if self._closing: + return None + if (self._activate_task is not None and self._activate_task.done() + and not _failed(self._activate_task) and self._wait_task is None): + self._wait_task = _spawn(self._application.wait_closed()) + if self._wait_task is not None and self._wait_task.done(): + return None # Application stop must not wait for hung control IO. + if any(phase is not None and _failed(phase) for phase in (self._prepare_task, self._activate_task)): + raise ManagedChildError("activation_failed" if self._committed else "startup_failed") + if (not self.accepting and self._startup_deadline is not None + and asyncio.get_running_loop().time() >= self._startup_deadline): + raise ManagedChildError("startup_failed") + if task.done(): + return await asyncio.shield(task) + pending: set[asyncio.Task[Any]] = {task} + pending.update(phase for phase in (self._prepare_task, self._activate_task, self._wait_task) + if phase is not None and not phase.done()) + await asyncio.wait(pending, timeout=0.01, return_when=asyncio.FIRST_COMPLETED) + + async def _observe( + self, action: Literal["read", "poll", "commit", "stop", "cleanup"], + *, deadline: float | None = None, + ) -> ManagedServiceStateV1 | None: + limit = asyncio.get_running_loop().time() + 2.0 + if deadline is not None: + limit = min(limit, deadline) + return await self._io(lambda: self._control.observe(action, limit)) + + async def _io(self, operation: Callable[[], _T]) -> _T: + async with self._io_lock: + # Even event-loop shutdown cancellation cannot make the previous + # native job disappear or admit a second queued job over it. + prior = self._pending_io + if prior is not None: + with suppress(Exception): + await asyncio.shield(asyncio.wrap_future(prior)) + self._pending_io = None + if self._worker is None: + self._worker = ThreadPoolExecutor(max_workers=1, thread_name_prefix="lmux-control") + future = self._worker.submit(operation) + self._pending_io = future + try: + return await asyncio.shield(asyncio.wrap_future(future)) + finally: + # Public waiters never cancel this retained coroutine. Native IO + # cannot be preempted; if shutdown cancels it, retain the future. + if future.done(): + self._pending_io = None + + async def close(self, *, retry_timeout: float | None = None) -> None: + if retry_timeout is not None: + _budget(retry_timeout) + if self._settled: + return + self._closing = True + self._application.fence() + self._wakeup.set() + if self._close_deadline is None: + self._close_deadline = asyncio.get_running_loop().time() + self._timeout + task = self._close_task + if task is None or _failed(task): + if retry_timeout is not None: + self._close_deadline = asyncio.get_running_loop().time() + retry_timeout + task = self._close_task = _spawn(self._close_once(retry_timeout)) + await _wait(task, self._close_deadline) + + async def _close_once(self, retry_timeout: float | None) -> None: + assert self._close_deadline is not None + deadline = self._close_deadline + # This phase runs before waiting for the IO worker, even if its current + # journal call is blocked. Local cleanup needs no successful DB write. + if not self._application_settled: + task = self._application_close + if task is None or _failed(task) or (task.done() and self._application.cleanup_pending): + if asyncio.get_running_loop().time() >= deadline: + raise ManagedChildError("cleanup_incomplete") + task = self._application_close = _spawn(self._application.close(retry_timeout=retry_timeout)) + self._start_stop(deadline) + await _wait(task, deadline) + if self._application.cleanup_pending: + raise ManagedChildError("cleanup_incomplete") + for owned in (self._prepare_task, self._activate_task, self._wait_task): + if owned is not None: + await _wait(owned, deadline, ignore_failure=True) + self._application_settled = True + self._start_stop(deadline) + if self._observation_task is not None: + await _wait(self._observation_task, deadline, ignore_failure=True) + assert self._stop_task is not None + await _wait(self._stop_task, deadline) + state = self._stop_task.result() + if (state is None and self._stop_deadline is not None and self._stop_deadline < deadline): + # A retained request from an older, expired attempt just settled. + # Use the explicitly granted retry budget only after that exact job + # completes; never replace it or enqueue a second native operation. + self._start_stop(deadline) + await _wait(self._stop_task, deadline) + state = self._stop_task.result() + if state is None or not state.handoff.stop_requested: + raise ManagedChildError("cleanup_incomplete") + state = await self._observe("cleanup", deadline=deadline) + if state is None or not state.evidence.application_cleanup_completed: + raise ManagedChildError("cleanup_incomplete") + if asyncio.get_running_loop().time() >= deadline: + raise ManagedChildError("cleanup_incomplete") + await self._io(self._control.close) + assert self._worker is not None and self._pending_io is None + # All jobs have finished; never join a native thread on the app loop. + self._worker.shutdown(wait=False) + self._settled = True + + def _start_stop(self, deadline: float) -> None: + task = self._stop_task + if task is not None and not _failed(task): + if not task.done(): + return + state = task.result() + if state is not None and state.handoff.stop_requested: + return + if asyncio.get_running_loop().time() >= deadline: + raise ManagedChildError("cleanup_incomplete") + # Independent of successful application cleanup, but serialized behind + # any exact in-flight native job by the same single-worker IO owner. + self._stop_task = _spawn(self._observe("stop", deadline=deadline)) + self._stop_deadline = deadline + + +def _budget(value: float) -> None: + if type(value) not in (int, float) or not 0 < value <= 30: + raise ValueError("invalid managed child budget") + + +def _failed(task: asyncio.Task[Any]) -> bool: + return task.done() and (task.cancelled() or task.exception() is not None) + + +async def _wait(task: asyncio.Task[Any], deadline: float, *, ignore_failure: bool = False) -> None: + if not task.done(): + await asyncio.wait({task}, timeout=max(0, deadline - asyncio.get_running_loop().time())) + if not task.done() or not ignore_failure and _failed(task): + raise ManagedChildError("cleanup_incomplete") + + +def _spawn(work: Coroutine[object, object, _T]) -> asyncio.Task[_T]: + published = asyncio.get_running_loop().create_future() + + async def invoke() -> _T: + await published + return await work + + def finished(task: asyncio.Task[_T]) -> None: + work.close() + if not task.cancelled(): + task.exception() + + invocation = invoke() + try: + task = asyncio.create_task(invocation) + except BaseException: + invocation.close() + work.close() + raise + task.add_done_callback(finished) + published.set_result(None) + return task diff --git a/src/loushang/apphost/managed/handoff.py b/src/loushang/apphost/managed/handoff.py index bd113e813..77be05d15 100644 --- a/src/loushang/apphost/managed/handoff.py +++ b/src/loushang/apphost/managed/handoff.py @@ -2,10 +2,17 @@ from __future__ import annotations +import socket from collections.abc import Callable +from time import monotonic +from typing import Literal +from loushang.hosting.errors import HostingError from loushang.hosting.service import LinuxServiceIdentityV1 -from loushang.hosting.service_handoff import ServiceHandoffPhaseV1 +from loushang.hosting.service_handoff import ( + ServiceChildHandoffV1, + ServiceHandoffPhaseV1, +) from ._files import ManagedStorageError from .contracts import _HEX32, ManagedContractError, ManagedInstanceRefV1, _match @@ -66,3 +73,63 @@ def abort(self, deadline: float | None = None) -> ServiceHandoffPhaseV1: return self._mutate(lambda: self._journal.abort( self._instance, self._attempt, native_identity=self._native, deadline=deadline, ), deadline) + + +class ManagedChildControlV1: + """Synchronous child control; exclusively used by one retained IO worker. + + Owns the supplied startup channel after successful construction, borrows the + journal. Never launches, signals or closes an application. Returned state + includes the matching stop fence; phase-only hints are insufficient here. + """ + + def __init__( + self, journal: ManagedServiceJournalV1, instance: ManagedInstanceRefV1, + attempt_id: str, native_identity: LinuxServiceIdentityV1, endpoint: socket.socket, + ) -> None: + if type(native_identity) is not LinuxServiceIdentityV1: + raise ManagedContractError() + self._port = ManagedServiceHandoffPortV1( + journal, instance, attempt_id, native_identity=native_identity, + ) + journal._require_native(native_identity) # Pure admission before taking the socket. + self._journal, self._instance, self._attempt = journal, instance, attempt_id + self._native = native_identity + self._channel = ServiceChildHandoffV1(endpoint, self._port) + + def observe( + self, action: Literal["read", "poll", "commit", "stop", "cleanup"], deadline: float, + ) -> ManagedServiceStateV1 | None: + """Mutate at most once, then reobserve; unknown never supplies authority.""" + if action not in {"read", "poll", "commit", "stop", "cleanup"}: + raise ManagedContractError() + if type(deadline) not in (int, float) or not 0 < deadline <= 1e12: + raise ManagedContractError() + try: + remaining = max(0.0, min(2.0, deadline - monotonic())) + if remaining <= 0: + return None + if action == "poll": + self._channel.poll_parent(timeout=remaining, deadline=deadline) + elif action == "commit": + self._channel.commit(timeout=remaining, deadline=deadline) + elif action == "stop": + self._journal.request_child_stop( + self._instance, self._attempt, self._native, deadline=deadline, + ) + elif action == "cleanup": + self._journal.record_child_cleanup( + self._instance, self._attempt, self._native, deadline=deadline, + ) + except (ManagedStorageError, ManagedContractError, HostingError, OSError): + pass # The write may already have committed; reobserve, never replay. + if monotonic() >= deadline: + return None + try: + state = self._journal.read(deadline=deadline) + return state if self._port._phase(state) is not ServiceHandoffPhaseV1.UNKNOWN else None + except (ManagedStorageError, ManagedContractError, OSError): + return None + + def close(self) -> None: + self._channel.close() diff --git a/src/loushang/apphost/managed/lifecycle.py b/src/loushang/apphost/managed/lifecycle.py index e7f5c7c13..a8e6d682c 100644 --- a/src/loushang/apphost/managed/lifecycle.py +++ b/src/loushang/apphost/managed/lifecycle.py @@ -215,6 +215,50 @@ def request_stop(self, instance: ManagedInstanceRefV1) -> ManagedServiceStateV1: current, handoff=current.handoff.request_stop(), )) + def request_child_stop( + self, instance: ManagedInstanceRefV1, attempt_id: str, native_identity: LinuxServiceIdentityV1, + *, deadline: float | None = None, + ) -> ManagedServiceStateV1: + """Child's explicit stop, atomically bound to its attempt and birth. + + Provisional stop includes abort, preventing a later handoff commit. + A committed service is stopped, never rewritten as an aborted startup. + Pre-registration stop remains possible if its starter died before birth. + """ + _match(attempt_id, _HEX32) + self._require_native(native_identity) + + def update(current: ManagedServiceStateV1) -> ManagedServiceStateV1: + self._match_child(current, attempt_id, native_identity) + handoff = current.handoff + if handoff.phase is ManagedHandoffPhaseV1.PROVISIONAL: + handoff = handoff.abort() + return replace(current, handoff=handoff.request_stop()) + + return self._update(instance, update, deadline=deadline) + + def record_child_cleanup( + self, instance: ManagedInstanceRefV1, attempt_id: str, native_identity: LinuxServiceIdentityV1, + *, deadline: float | None = None, + ) -> ManagedServiceStateV1: + """Record only successful application cleanup, never child/process exit.""" + _match(attempt_id, _HEX32) + self._require_native(native_identity) + + def update(current: ManagedServiceStateV1) -> ManagedServiceStateV1: + self._match_child(current, attempt_id, native_identity) + if not current.handoff.stop_requested and current.handoff.phase is not ManagedHandoffPhaseV1.ABORTING: + raise ManagedStorageError("conflict") + return replace(current, evidence=replace(current.evidence, application_cleanup_completed=True)) + + return self._update(instance, update, deadline=deadline) + + @staticmethod + def _match_child(current: ManagedServiceStateV1, attempt_id: str, native: LinuxServiceIdentityV1) -> None: + if (current.handoff.attempt_id != attempt_id + or current.native_identity is not None and current.native_identity != native): + raise ManagedStorageError("conflict") + def record_stop_evidence(self, evidence: ManagedStopEvidenceV1) -> ManagedServiceStateV1: """Join monotonic facts from the trusted native/application observers.""" if type(evidence) is not ManagedStopEvidenceV1: diff --git a/src/loushang/coding/hosted_local.py b/src/loushang/coding/hosted_local.py index e5a4565a3..5b95e3ac9 100644 --- a/src/loushang/coding/hosted_local.py +++ b/src/loushang/coding/hosted_local.py @@ -242,11 +242,7 @@ async def close(self, *, retry_timeout: float | None = None) -> None: _require_budget(retry_timeout) if self._settled: return - self._closing = True - if self._local is not None: - # Fence lower-level activation before scheduling our close task. - # An already queued activate must not publish in the intervening turn. - self._local.fence() + self.fence() if self._deadline is None: self._deadline = asyncio.get_running_loop().time() + self._timeout task = self._close_task @@ -256,6 +252,14 @@ async def close(self, *, retry_timeout: float | None = None) -> None: task = self._close_task = _spawn(self._close_once(retry_timeout)) await asyncio.shield(task) + def fence(self) -> None: + """Revoke startup and client admission without creating a cleanup task.""" + self._closing = True + if self._local is not None: + # Fence lower-level activation before scheduling our close task. + # An already queued activate must not publish in the intervening turn. + self._local.fence() + async def _close_once(self, retry_timeout: float | None) -> None: assert self._deadline is not None if self._local is not None: diff --git a/src/loushang/hosting/service_handoff.py b/src/loushang/hosting/service_handoff.py index 6ff0e0ea8..0c1622266 100644 --- a/src/loushang/hosting/service_handoff.py +++ b/src/loushang/hosting/service_handoff.py @@ -67,13 +67,16 @@ def __init__(self, endpoint: socket.socket, port: ServiceHandoffPortV1) -> None: self._mutex = RLock() self._closed = False - def _enter(self, timeout: float = 2.0) -> float: + def _enter(self, timeout: float = 2.0, deadline: float | None = None) -> float: if type(timeout) not in (int, float) or not 0 <= timeout <= 30: raise _error(HostingFailureCategory.INVALID_REQUEST) - deadline = monotonic() + timeout + if deadline is not None and (type(deadline) not in (int, float) or not 0 < deadline <= 1e12): + raise _error(HostingFailureCategory.INVALID_REQUEST) + now = monotonic() + deadline = min(now + timeout, deadline) if deadline is not None else now + timeout if self._closing.is_set(): raise _error(HostingFailureCategory.HOST_CLOSED) - if not self._mutex.acquire(timeout=timeout): + if not self._mutex.acquire(timeout=max(0.0, deadline - now)): raise _error(HostingFailureCategory.CAPACITY_EXHAUSTED) if self._closing.is_set(): self._mutex.release() @@ -123,9 +126,9 @@ def _parent_lost(self) -> bool: # command; treat a broken startup channel like parent loss, then use CAS. return True - def poll_parent(self) -> ServiceHandoffPhaseV1: + def poll_parent(self, *, timeout: float = 2.0, deadline: float | None = None) -> ServiceHandoffPhaseV1: """Only ABORTING authorizes caller cleanup; UNKNOWN remains owned debt.""" - deadline = self._enter() + deadline = self._enter(timeout, deadline) try: phase = self._call(self._port.observe, deadline) if phase is ServiceHandoffPhaseV1.PROVISIONAL and self._parent_lost(): @@ -134,13 +137,13 @@ def poll_parent(self) -> ServiceHandoffPhaseV1: finally: self._mutex.release() - def commit(self) -> ServiceHandoffPhaseV1: + def commit(self, *, timeout: float = 2.0, deadline: float | None = None) -> ServiceHandoffPhaseV1: """After readiness, durable CAS wins even if EOF races the liveness peek. Parent death is not an instantaneous durable abort. Either CAS may win; a later EOF/notification loss never undoes the committed decision. """ - deadline = self._enter() + deadline = self._enter(timeout, deadline) try: phase = self._call(self._port.observe, deadline) if phase is ServiceHandoffPhaseV1.PROVISIONAL: diff --git a/tests/apphost/test_managed_child.py b/tests/apphost/test_managed_child.py new file mode 100644 index 000000000..5de784db8 --- /dev/null +++ b/tests/apphost/test_managed_child.py @@ -0,0 +1,459 @@ +from __future__ import annotations + +import asyncio +import os +import socket +import sys +import threading +from dataclasses import replace +from time import monotonic + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.child import ManagedChildApplicationV1, ManagedChildError +from loushang.apphost.managed.contracts import ( + ManagedContractError, + ManagedNamespaceV1, + ManagedServiceKeyV1, +) +from loushang.apphost.managed.contracts import ManagedHandoffPhaseV1 as Phase +from loushang.apphost.managed.handoff import ManagedChildControlV1 +from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 +from loushang.apphost.managed.registry import ManagedMuxReservationV1, ManagedRegistryV1 +from loushang.hosting.service import LinuxServiceObserverV1 + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed child") + + +@pytest.fixture +def binding(tmp_path): + namespace = ManagedNamespaceV1(str(tmp_path / "platform"), os.geteuid(), "a" * 32) + service = ManagedServiceKeyV1("coding", str(tmp_path)) + registry = ManagedRegistryV1(tmp_path / "registry", namespace, create=True) + registry.reserve_mux(ManagedMuxReservationV1("dev", service, "b" * 32)) + journal = ManagedServiceJournalV1(registry, namespace, service, tmp_path / "fence", create=True) + state = journal.prepare("c" * 32, expected=None) + native = LinuxServiceObserverV1.capture(os.getpid()) + identity = native.identity + native.close() + journal.register_native(state.handoff.instance, "c" * 32, identity) + parent, endpoint = socket.socketpair() + control = ManagedChildControlV1(journal, state.handoff.instance, "c" * 32, identity, endpoint) + try: + yield journal, state.handoff.instance, identity, parent, control + finally: + parent.close() + control.close() + journal.close() + registry.close() + + +class Application: + def __init__(self): + self.prepared = asyncio.Event() + self.prepare_gate = asyncio.Event() + self.prepare_gate.set() + self.closed = asyncio.Event() + self.fenced = False + self.activations = 0 + self.prepares = 0 + self.closes = 0 + self.fail_close = False + + @property + def cleanup_pending(self): + return not self.closed.is_set() + + async def prepare(self, *, deadline=None): + self.prepares += 1 + self.prepared.set() + await self.prepare_gate.wait() + if self.fenced: + raise RuntimeError("closed during preparation") + + async def activate(self): + if self.fenced: + raise RuntimeError("closed during activation") + self.activations += 1 + + def fence(self): + self.fenced = True + self.prepare_gate.set() + + async def close(self, *, retry_timeout=None): + self.closes += 1 + if self.fail_close: + raise RuntimeError("injected application cleanup failure") + self.closed.set() + + async def wait_closed(self): + await self.closed.wait() + + +async def until(predicate): + async with asyncio.timeout(5): + while not predicate(): + await asyncio.sleep(0.005) + + +def test_committed_service_survives_parent_eof_and_cancelled_run_waiter(binding): + journal, reference, _, parent, control = binding + + async def scenario(): + application = Application() + owner = ManagedChildApplicationV1(application, control) + waiter = asyncio.create_task(owner.run()) + try: + await until(lambda: owner.accepting) + assert journal.read().handoff.phase is Phase.COMMITTED + parent.close() + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + await asyncio.sleep(0.1) + assert owner.accepting and application.activations == application.prepares == 1 + assert not application.fenced and application.closes == 0 + await owner.close() + await owner.run() # Join the same runner, not a second preparation. + evidence = journal.read().evidence + assert evidence.instance == reference and evidence.application_cleanup_completed + assert not evidence.process_exited and not evidence.process_scope_settled + assert not owner.cleanup_pending and application.closes == 1 + finally: + await owner.close(retry_timeout=2) + asyncio.run(asyncio.wait_for(scenario(), 10)) + + +def test_parent_eof_during_preparation_aborts_without_activation(binding): + journal, _, _, parent, control = binding + + async def scenario(): + application = Application() + application.prepare_gate.clear() + owner = ManagedChildApplicationV1(application, control) + waiter = asyncio.create_task(owner.run()) + await application.prepared.wait() + parent.close() + await waiter + assert application.activations == 0 and application.closes == 1 + assert journal.read().handoff.phase is Phase.ABORTING + assert journal.read().evidence.application_cleanup_completed + assert not owner.cleanup_pending + asyncio.run(asyncio.wait_for(scenario(), 10)) + + +def test_cleanup_failure_keeps_control_and_does_not_claim_application_success(binding): + journal, _, _, _, control = binding + + async def scenario(): + application = Application() + owner = ManagedChildApplicationV1(application, control) + waiter = asyncio.create_task(owner.run()) + await until(lambda: owner.accepting) + application.fail_close = True + with pytest.raises(ManagedChildError, match="cleanup_incomplete"): + await owner.close() + await asyncio.shield(owner._stop_task) + assert journal.read().handoff.stop_requested + assert owner.cleanup_pending and not journal.read().evidence.application_cleanup_completed + application.fail_close = False + await owner.close(retry_timeout=2) + await asyncio.gather(waiter, return_exceptions=True) + assert not owner.cleanup_pending and journal.read().evidence.application_cleanup_completed + asyncio.run(asyncio.wait_for(scenario(), 10)) + + +def test_failed_cleanup_record_retries_fact_not_successful_application_close(binding, monkeypatch): + journal, _, _, _, control = binding + + async def scenario(): + application = Application() + owner = ManagedChildApplicationV1(application, control) + waiter = asyncio.create_task(owner.run()) + await until(lambda: owner.accepting) + original = journal.record_child_cleanup + + def unavailable(*args, **kwargs): + raise ManagedStorageError("unavailable") + + monkeypatch.setattr(journal, "record_child_cleanup", unavailable) + with pytest.raises(ManagedChildError, match="cleanup_incomplete"): + await owner.close() + assert application.closes == 1 and owner.cleanup_pending + assert not journal.read().evidence.application_cleanup_completed + monkeypatch.setattr(journal, "record_child_cleanup", original) + await owner.close(retry_timeout=2) + await asyncio.gather(waiter, return_exceptions=True) + assert application.closes == 1 and not owner.cleanup_pending + asyncio.run(asyncio.wait_for(scenario(), 10)) + + +def test_hung_control_io_does_not_block_application_cleanup_or_lose_future(binding, monkeypatch): + journal, _, _, _, control = binding + + async def scenario(): + application = Application() + owner = ManagedChildApplicationV1(application, control, settlement_timeout=0.03) + entered, release = threading.Event(), threading.Event() + original = control.observe + + def blocked(action, deadline): + if action == "poll" and not entered.is_set(): + entered.set() + assert release.wait(5) + return original(action, deadline) + + monkeypatch.setattr(control, "observe", blocked) + waiter = asyncio.create_task(owner.run()) + try: + await until(entered.is_set) + pending = owner._pending_io + with pytest.raises(ManagedChildError, match="cleanup_incomplete"): + await owner.close() + assert application.fenced and application.closed.is_set() + assert owner._pending_io is pending and not pending.done() + assert owner.cleanup_pending and not control._channel._closed + release.set() + await until(lambda: owner._close_task.done()) + await owner.close(retry_timeout=2) + await asyncio.gather(waiter, return_exceptions=True) + assert application.closes == 1 and not owner.cleanup_pending + finally: + release.set() + await owner.close(retry_timeout=2) + asyncio.run(asyncio.wait_for(scenario(), 10)) + + +@pytest.mark.parametrize("method", ["request_child_stop", "record_child_cleanup"]) +@pytest.mark.parametrize("mismatch", ["attempt", "native", "instance"]) +def test_child_stop_and_fact_binding_is_atomic(binding, method, mismatch): + journal, reference, identity, _, _ = binding + journal.request_stop(reference) + before = journal.read() + attempt = "c" * 32 + if mismatch == "attempt": + attempt = "d" * 32 + elif mismatch == "native": + identity = replace(identity, start_ticks=identity.start_ticks + 1) + else: + reference = replace(reference, instance_id="d" * 32) + with pytest.raises(ManagedStorageError, match="conflict"): + getattr(journal, method)(reference, attempt, identity, deadline=monotonic() + 1) + assert journal.read() == before + + +def test_control_rejects_wrong_user_before_adopting_socket(binding): + journal, reference, identity, _, _ = binding + parent, endpoint = socket.socketpair() + try: + with pytest.raises(ManagedContractError): + ManagedChildControlV1( + journal, reference, "c" * 32, replace(identity, user_id=identity.user_id + 1), endpoint, + ) + assert endpoint.fileno() >= 0 + finally: + parent.close() + endpoint.close() + + +@pytest.mark.parametrize("cause", ["deadline", "prepare_failure", "application_stop", "application_failure"]) +def test_hung_io_does_not_block_autonomous_terminal_observation(binding, monkeypatch, cause): + _, _, _, _, control = binding + + async def scenario(): + application = Application() + owner = ManagedChildApplicationV1(application, control, startup_timeout=0.3, settlement_timeout=0.03) + entered, release = threading.Event(), threading.Event() + original = control.observe + target = "read" if cause.startswith("application_") else "commit" if cause == "deadline" else "poll" + if cause == "application_failure": + async def failed_wait(): + await application.closed.wait() + raise RuntimeError("private application failure") + monkeypatch.setattr(application, "wait_closed", failed_wait) + if cause == "prepare_failure": + application.prepare_gate.clear() + + async def failure(*, deadline=None): + application.prepared.set() + await application.prepare_gate.wait() + raise RuntimeError("private failure") + + monkeypatch.setattr(application, "prepare", failure) + + def blocked(action, deadline): + should_block = action == target and (cause != "prepare_failure" or application.prepared.is_set()) + if should_block and not entered.is_set(): + entered.set() + assert release.wait(5) + return original(action, deadline) + + monkeypatch.setattr(control, "observe", blocked) + waiter = asyncio.create_task(owner.run()) + try: + await until(entered.is_set) + pending = owner._pending_io + if cause == "prepare_failure": + application.prepare_gate.set() + elif cause.startswith("application_"): + application.closed.set() + await until(lambda: application.fenced) + await until(lambda: application.closed.is_set()) + assert owner._pending_io is pending and not pending.done() + assert owner.cleanup_pending and not control._channel._closed + release.set() + await asyncio.gather(waiter, return_exceptions=True) + await until(lambda: owner._close_task.done()) + await owner.close(retry_timeout=2) + assert not owner.cleanup_pending and application.closes == 1 + finally: + release.set() + await owner.close(retry_timeout=2) + asyncio.run(asyncio.wait_for(scenario(), 10)) + + +@pytest.mark.parametrize("failure", ["submit", "wait_task"]) +def test_internal_scheduling_failure_fences_and_settles_owned_application(binding, monkeypatch, failure): + _, _, _, _, control = binding + + async def scenario(): + from concurrent.futures import ThreadPoolExecutor + + import loushang.apphost.managed.child as module + + application = Application() + owner = ManagedChildApplicationV1(application, control) + injected = False + if failure == "wait_task": + original = module._spawn + + def fail_once(work): + nonlocal injected + if not injected and work.cr_code.co_name == "wait_closed": + injected = True + work.close() + raise RuntimeError("private factory failure") + return original(work) + + monkeypatch.setattr(module, "_spawn", fail_once) + else: + original_submit = ThreadPoolExecutor.submit + + def fail_submit(self, *args, **kwargs): + nonlocal injected + if not injected: + injected = True + raise RuntimeError("private submit failure") + return original_submit(self, *args, **kwargs) + + monkeypatch.setattr(ThreadPoolExecutor, "submit", fail_submit) + with pytest.raises(ManagedChildError): + await owner.run() + assert injected and application.fenced and application.closes == 1 + assert not owner.cleanup_pending and owner._failure is not None + asyncio.run(asyncio.wait_for(scenario(), 10)) + + +@pytest.mark.parametrize("phase", ["prepare", "activate"]) +def test_cancel_run_during_stage_keeps_exact_task_and_service(binding, monkeypatch, phase): + _, _, _, _, control = binding + + async def scenario(): + application = Application() + entered, release = asyncio.Event(), asyncio.Event() + original = getattr(application, phase) + + async def delayed(**kwargs): + entered.set() + await release.wait() + await original(**kwargs) + + monkeypatch.setattr(application, phase, delayed) + owner = ManagedChildApplicationV1(application, control) + waiter = asyncio.create_task(owner.run()) + await entered.wait() + owned = getattr(owner, f"_{phase}_task") + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + assert not owned.done() and not application.fenced + release.set() + await until(lambda: owner.accepting) + assert getattr(owner, f"_{phase}_task") is owned + await owner.close() + await owner.run() + assert application.closes == application.activations == application.prepares == 1 + asyncio.run(asyncio.wait_for(scenario(), 10)) + + +def test_stop_is_durable_while_application_cleanup_hangs_and_waiter_cancels(binding, monkeypatch): + journal, _, _, _, control = binding + + async def scenario(): + application = Application() + entered, release = asyncio.Event(), asyncio.Event() + original = application.close + + async def delayed(**kwargs): + entered.set() + await release.wait() + await original(**kwargs) + + monkeypatch.setattr(application, "close", delayed) + owner = ManagedChildApplicationV1(application, control) + runner = asyncio.create_task(owner.run()) + await until(lambda: owner.accepting) + waiter = asyncio.create_task(owner.close()) + await entered.wait() + owned, deadline = owner._close_task, owner._close_deadline + await asyncio.shield(owner._stop_task) + assert journal.read().handoff.stop_requested + assert not journal.read().evidence.application_cleanup_completed + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + again = asyncio.create_task(owner.close(retry_timeout=2)) + await asyncio.sleep(0) + assert owner._close_task is owned and owner._close_deadline == deadline + release.set() + await again + await runner + assert application.closes == 1 and not owner.cleanup_pending + asyncio.run(asyncio.wait_for(scenario(), 10)) + + +def test_durable_commit_confirmed_after_app_deadline_never_activates(binding, monkeypatch): + journal, _, _, _, control = binding + + async def scenario(): + application = Application() + owner = ManagedChildApplicationV1(application, control, startup_timeout=0.2, settlement_timeout=0.03) + entered, release = threading.Event(), threading.Event() + original = control._channel.commit + + def committed_then_held(**kwargs): + result = original(**kwargs) + entered.set() + assert release.wait(5) + return result + + monkeypatch.setattr(control._channel, "commit", committed_then_held) + runner = asyncio.create_task(owner.run()) + try: + await until(entered.is_set) + assert journal.read().handoff.phase is Phase.COMMITTED + await until(lambda: application.closed.is_set()) + assert application.fenced and application.activations == 0 + assert owner._pending_io is not None and not owner._pending_io.done() + release.set() + await asyncio.gather(runner, return_exceptions=True) + await until(lambda: owner._close_task.done()) + await owner.close(retry_timeout=2) + state = journal.read() + assert state.handoff.phase is Phase.COMMITTED and state.handoff.stop_requested + assert state.evidence.application_cleanup_completed and application.closes == 1 + finally: + release.set() + await owner.close(retry_timeout=2) + asyncio.run(asyncio.wait_for(scenario(), 10)) diff --git a/tests/apphost/test_managed_lifecycle.py b/tests/apphost/test_managed_lifecycle.py index 601ed6a3c..79b330147 100644 --- a/tests/apphost/test_managed_lifecycle.py +++ b/tests/apphost/test_managed_lifecycle.py @@ -63,6 +63,23 @@ def test_prepare_is_durable_and_never_repeated_as_another_spawn(owners, tmp_path other.close() +def test_child_can_settle_prebirth_abort_but_never_mint_process_exit(owners): + _, journal, _, _ = owners + prepared = journal.prepare("c" * 32, expected=None) + reference = prepared.handoff.instance + with pytest.raises(ManagedStorageError, match="conflict"): + journal.record_child_cleanup(reference, "c" * 32, _identity()) + stopped = journal.request_child_stop(reference, "c" * 32, _identity()) + assert stopped.handoff.phase is ManagedHandoffPhaseV1.ABORTING + assert stopped.handoff.stop_requested and stopped.native_identity is None + settled = journal.record_child_cleanup(reference, "c" * 32, _identity()) + assert settled.evidence.application_cleanup_completed + assert not settled.evidence.process_exited and not settled.evidence.process_scope_settled + assert journal.record_child_cleanup(reference, "c" * 32, _identity()) == settled + with pytest.raises(ManagedStorageError, match="conflict"): + journal.register_native(reference, "c" * 32, _identity()) + + def test_commit_ack_loss_is_observed_not_aborted(owners): _, journal, _, _ = owners prepared = journal.prepare("c" * 32, expected=None) diff --git a/tests/architecture/test_detachable_local_workspace_g16.py b/tests/architecture/test_detachable_local_workspace_g16.py index 041212c39..b3e717edd 100644 --- a/tests/architecture/test_detachable_local_workspace_g16.py +++ b/tests/architecture/test_detachable_local_workspace_g16.py @@ -217,8 +217,9 @@ def test_managed_staging_keeps_exact_optional_signatures_and_sync_fence(): assert set(inspect.signature(owner.start).parameters) == {"self"} assert inspect.iscoroutinefunction(owner.prepare) assert inspect.iscoroutinefunction(owner.activate) - assert set(inspect.signature(HostedLocalRuntimeV1.fence).parameters) == {"self"} - assert not inspect.iscoroutinefunction(HostedLocalRuntimeV1.fence) + for owner in (HostedLocalRuntimeV1, CodingLocalCommandV1): + assert set(inspect.signature(owner.fence).parameters) == {"self"} + assert not inspect.iscoroutinefunction(owner.fence) def test_G16_BOUNDARIES_shell_borrows_only_semantics_and_owns_no_native_connection(): diff --git a/tests/architecture/test_hosted_product_g10_explicit_canary.py b/tests/architecture/test_hosted_product_g10_explicit_canary.py index 1a1cec162..a8c7fcb65 100644 --- a/tests/architecture/test_hosted_product_g10_explicit_canary.py +++ b/tests/architecture/test_hosted_product_g10_explicit_canary.py @@ -132,7 +132,7 @@ def test_dependency_graph_has_only_the_accepted_product_owned_edges( imports = _imports(path) accepted_hosting = { Path("src/loushang/apphost/launcher.py"): {"loushang.hosting.contracts"}, - Path("src/loushang/apphost/managed/handoff.py"): {"loushang.hosting.service", "loushang.hosting.service_handoff"}, + Path("src/loushang/apphost/managed/handoff.py"): {"loushang.hosting.errors", "loushang.hosting.service", "loushang.hosting.service_handoff"}, Path("src/loushang/apphost/managed/lifecycle.py"): {"loushang.hosting.errors", "loushang.hosting.service"}, }.get(path) if accepted_hosting is not None: diff --git a/tests/architecture/test_hosted_product_runtime_g9_closure.py b/tests/architecture/test_hosted_product_runtime_g9_closure.py index d94fdc441..3fc213c4f 100644 --- a/tests/architecture/test_hosted_product_runtime_g9_closure.py +++ b/tests/architecture/test_hosted_product_runtime_g9_closure.py @@ -548,7 +548,7 @@ def test_g9_4_retains_apphost_core_and_current_inventory_fences() -> None: } assert hosting_imports == { APPHOST / "launcher.py": {"loushang.hosting.contracts"}, - APPHOST / "managed/handoff.py": {"loushang.hosting.service", "loushang.hosting.service_handoff"}, + APPHOST / "managed/handoff.py": {"loushang.hosting.errors", "loushang.hosting.service", "loushang.hosting.service_handoff"}, APPHOST / "managed/lifecycle.py": {"loushang.hosting.errors", "loushang.hosting.service"}, }.get(path, set()) for path in APPHOST_CORE: diff --git a/tests/architecture/test_hosted_product_runtime_v1_baseline.py b/tests/architecture/test_hosted_product_runtime_v1_baseline.py index b637dc0d5..52f9552d7 100644 --- a/tests/architecture/test_hosted_product_runtime_v1_baseline.py +++ b/tests/architecture/test_hosted_product_runtime_v1_baseline.py @@ -83,6 +83,7 @@ APPHOST_SOURCE / "managed/registry.py", APPHOST_SOURCE / "managed/lifecycle.py", APPHOST_SOURCE / "managed/handoff.py", + APPHOST_SOURCE / "managed/child.py", APPSERVER_SOURCE / "ports.py", HARNESS_SOURCE / "machine_resources/control_plane.py", Path("src/loushang/coding/cli/__main__.py"), @@ -454,6 +455,7 @@ def test_current_inventory_matches_source_and_retained_absences() -> None: "managed/registry.py", "managed/lifecycle.py", "managed/handoff.py", + "managed/child.py", } assert { path.relative_to(APPSERVER_SOURCE).as_posix() diff --git a/tests/architecture/test_hosting_architecture_baseline.py b/tests/architecture/test_hosting_architecture_baseline.py index d2da2bb9b..7c69ea82b 100644 --- a/tests/architecture/test_hosting_architecture_baseline.py +++ b/tests/architecture/test_hosting_architecture_baseline.py @@ -719,6 +719,7 @@ def test_hosting_apphost_and_appserver_optional_modules_are_exact() -> None: "managed/registry.py", "managed/lifecycle.py", "managed/handoff.py", + "managed/child.py", "router.py", "runtime.py", "hosted.py", diff --git a/tests/architecture/test_plugin_lifecycle_plc9c5_c50_baseline.py b/tests/architecture/test_plugin_lifecycle_plc9c5_c50_baseline.py index 9b1f64036..aafe48e48 100644 --- a/tests/architecture/test_plugin_lifecycle_plc9c5_c50_baseline.py +++ b/tests/architecture/test_plugin_lifecycle_plc9c5_c50_baseline.py @@ -903,10 +903,12 @@ def test_c50_keeps_private_profiles_confined_and_product_layers_clean() -> None: imported for imported in _imports(APPHOST_ROOT / "managed/handoff.py") if imported.startswith("loushang.hosting") } == { + "loushang.hosting.errors", "loushang.hosting.errors.HostingError", "loushang.hosting.service", "loushang.hosting.service.LinuxServiceIdentityV1", "loushang.hosting.service_handoff", "loushang.hosting.service_handoff.ServiceHandoffPhaseV1", + "loushang.hosting.service_handoff.ServiceChildHandoffV1", } assert { imported for imported in _imports(APPHOST_ROOT / "managed/lifecycle.py") diff --git a/tests/coding/test_managed_child.py b/tests/coding/test_managed_child.py new file mode 100644 index 000000000..a3dc9a579 --- /dev/null +++ b/tests/coding/test_managed_child.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from loushang.agent import synthetic_model_transport +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.child import ManagedChildApplicationV1 +from loushang.appserver.local import LocalAppClientConnectionV1, LocalConnectionModeV1 +from loushang.appserver.local_record import LocalConnectionDirectoryV1 +from loushang.appserver.protocol import ( + AppErrorCodeV1, + AppServiceError, + MuxAttachV1, + MuxCreateV1, + MuxMemberOpenV1, + MuxSelectorV1, + SessionOpenSpecV1, + SessionSnapshotRequestV1, + TurnTextV1, +) +from loushang.coding.hosted_local import CodingLocalCommandV1 + +from ..apphost.test_managed_child import binding as binding +from ..apphost.test_managed_child import until +from ._hosted_product_child import scripted_stream +from .test_hosted_local import _local_launch, _model + + +def test_real_coding_child_handoff_keeps_accepted_work_after_starter_and_client_eof( + binding, tmp_path, monkeypatch, +): + journal, _, _, parent, control = binding + + async def scenario(): + root = tmp_path.resolve() + monkeypatch.setenv("LOUSHANG_HOME", str(root / "platform")) + monkeypatch.setenv("LOUSHANG_RUNTIME_DIR", str(root / "runtime")) + launch = _local_launch(root) + entered, release = asyncio.Event(), asyncio.Event() + calls = 0 + + @synthetic_model_transport + async def held_stream(model, context, options=None): + nonlocal calls + calls += 1 + entered.set() + await release.wait() + return await scripted_stream(model, context, options) + + command = CodingLocalCommandV1(launch, model=_model(), stream_fn=held_stream, tools=[]) + owner = ManagedChildApplicationV1(command, control) + directory = LocalConnectionDirectoryV1(launch.connection_root) + client = LocalAppClientConnectionV1(directory, launch.endpoint) + fresh = LocalAppClientConnectionV1(directory, launch.endpoint) + stop = LocalAppClientConnectionV1(directory, launch.endpoint, mode=LocalConnectionModeV1.STOP) + runner = asyncio.create_task(owner.run()) + turn = None + try: + await until(lambda: owner.accepting) + assert journal.read().handoff.phase.value == "committed" + await client.start() + mux = await client.client.create_mux(MuxCreateV1("dev")) + selector = MuxSelectorV1(mux_space_id=mux.mux_space_id) + await client.client.attach_mux(MuxAttachV1(selector)) + scope = launch.application.scopes[0] + mux = await client.client.open_member(MuxMemberOpenV1( + selector, SessionOpenSpecV1("coding", "managed-child", scope.scope, scope.fingerprint, "dev"), + )) + attachment = await client.client.attach_mux(MuxAttachV1(selector)) + turn = asyncio.create_task(client.client.start_turn(TurnTextV1( + attachment.attachment_id, attachment.controller_generation, mux.members[0].member_id, "hello", + ))) + admitted = asyncio.create_task(entered.wait()) + try: + done, _ = await asyncio.wait({turn, admitted}, return_when=asyncio.FIRST_COMPLETED) + if turn in done: + await turn + pytest.fail("turn completed before controlled model release") + finally: + admitted.cancel() + await asyncio.gather(admitted, return_exceptions=True) + parent.close() # Actual inherited stream EOF, after durable handoff. + await client.close() + await asyncio.gather(turn, return_exceptions=True) + assert owner.accepting and not command._closing and calls == 1 + await fresh.start() + while True: + try: + current = await fresh.client.attach_mux(MuxAttachV1(selector)) + break + except AppServiceError as error: + if error.code is not AppErrorCodeV1.ALREADY_ATTACHED: + raise + await asyncio.sleep(0.01) + request = SessionSnapshotRequestV1( + current.attachment_id, current.controller_generation, mux.members[0].member_id, + ) + assert (await fresh.client.snapshot_session(request)).running + release.set() + while (snapshot := await fresh.client.snapshot_session(request)).running: + await asyncio.sleep(0.01) + assert calls == 1 and "真实跨进程回复" in str(snapshot) + await stop.start() + assert stop.stop_requested + await runner + assert not owner.cleanup_pending and not command.cleanup_pending + evidence = journal.read().evidence + assert evidence.application_cleanup_completed + assert not evidence.process_exited and not evidence.process_scope_settled + finally: + release.set() + await asyncio.gather(client.close(), fresh.close(), stop.close()) + await owner.close(retry_timeout=5) + await asyncio.gather(runner, return_exceptions=True) + if turn is not None: + await asyncio.gather(turn, return_exceptions=True) + directory.close() + asyncio.run(asyncio.wait_for(scenario(), 40)) + + +def test_real_coding_prepare_timeout_can_close_while_journal_observation_is_unknown( + binding, tmp_path, monkeypatch, +): + journal, _, _, _, control = binding + + async def scenario(): + root = tmp_path.resolve() + monkeypatch.setenv("LOUSHANG_HOME", str(root / "platform")) + monkeypatch.setenv("LOUSHANG_RUNTIME_DIR", str(root / "runtime")) + command = CodingLocalCommandV1( + _local_launch(root), model=_model(), stream_fn=scripted_stream, tools=[], settlement_timeout=5, + ) + owner = ManagedChildApplicationV1(command, control, startup_timeout=0.15, settlement_timeout=0.03) + entered, release = asyncio.Event(), asyncio.Event() + original_open, original_read = type(command._attempt).open, journal.read + + async def late_open(self): + entered.set() + await release.wait() + return await original_open(self) + + def unavailable(**kwargs): + raise ManagedStorageError("unavailable") + + monkeypatch.setattr(type(command._attempt), "open", late_open) + runner = asyncio.create_task(owner.run()) + try: + await entered.wait() + monkeypatch.setattr(journal, "read", unavailable) + await until(lambda: command._closing) + assert not owner.accepting and owner.cleanup_pending + release.set() + await asyncio.gather(runner, return_exceptions=True) + await until(lambda: not command.cleanup_pending) + await until(lambda: owner._close_task.done()) + assert owner.cleanup_pending # Product closed is not persisted proof. + monkeypatch.setattr(journal, "read", original_read) + assert not journal.read().evidence.application_cleanup_completed + await owner.close(retry_timeout=5) + assert not owner.cleanup_pending + assert journal.read().evidence.application_cleanup_completed + assert command._activate_task is None + finally: + release.set() + monkeypatch.setattr(journal, "read", original_read) + await until(lambda: owner._close_task is None or owner._close_task.done()) + await owner.close(retry_timeout=5) + await asyncio.gather(runner, return_exceptions=True) + asyncio.run(asyncio.wait_for(scenario(), 25)) diff --git a/tests/hosting/test_service_handoff.py b/tests/hosting/test_service_handoff.py index 74d3bed3f..8263eca97 100644 --- a/tests/hosting/test_service_handoff.py +++ b/tests/hosting/test_service_handoff.py @@ -59,6 +59,29 @@ def test_timeout_never_aborts_or_commits(channels): assert child.poll_parent() is Phase.PROVISIONAL +@pytest.mark.parametrize("action", ["poll_parent", "commit"]) +def test_child_respects_absolute_deadline_despite_delayed_entry(channels, monkeypatch, action): + import loushang.hosting.service_handoff as module + + _, child, port = channels + recorded = [] + monkeypatch.setattr(module, "monotonic", lambda: 11.0) + + def observe(deadline): + recorded.append(deadline) + return Phase.PROVISIONAL + + monkeypatch.setattr(port, "observe", observe) + if action == "commit": + monkeypatch.setattr(port, "commit", lambda deadline: recorded.append(deadline) or Phase.COMMITTED) + # The caller computed a two-second relative budget earlier, at t=10. + getattr(child, action)(timeout=2, deadline=12) + assert recorded and set(recorded) == {12} + recorded.clear() + assert getattr(child, action)(timeout=2, deadline=10) is Phase.UNKNOWN + assert recorded == [] + + def test_commit_survives_parent_close_and_late_abort(channels): parent, child, port = channels assert child.commit() is Phase.COMMITTED From 1e6383c0260c5d13c9ecdfa0cd5c6406256d305f Mon Sep 17 00:00:00 2001 From: zhnt Date: Sun, 20 Sep 2026 10:39:40 +0000 Subject: [PATCH 10/44] docs(lmux): record M0 slices 19-96, acceptance plans and ARD-004 Preserve the in-flight lmux M0 design and implementation record that so far existed only in the working tree. No product code changes. Contents: - lmux-contract-m0.md: slices 19-96 (4689 lines), including the shared data-root admission delta that is still candidate, not accepted - lmux-managed-output-capture.md: managed command output capture contract - lmux-capability-projection-plan.md, lmux-live-probe-plan.md, lmux-performance-acceptance-plan.md: remaining M3/M4 acceptance plans - ARD-004: AA stability gate vs interactive metric scale - user-guide lmux pages (en, zh-CN) and their README links Status remains partial: managed CLI preview, Linux launch/lifetime, owned transcripts, discovery, exact-instance connections, close and shared Markdown/action/input binding are implemented; full M3/M4 acceptance and the final goal-wide review are still pending. This commit records evidence only; it does not claim the lmux goal is complete. --- docs/en/user-guide/README.md | 3 + docs/en/user-guide/lmux.md | 162 + ...bility-gate-vs-interactive-metric-scale.md | 352 ++ .../lmux-capability-projection-plan.md | 117 + .../architecture/apphost/lmux-contract-m0.md | 4051 +++++++++++++++- .../apphost/lmux-live-probe-plan.md | 85 + .../apphost/lmux-managed-output-capture.md | 4149 +++++++++++++++++ .../lmux-performance-acceptance-plan.md | 416 ++ docs/zh-CN/user-guide/README.md | 2 + docs/zh-CN/user-guide/lmux.md | 142 + 10 files changed, 9464 insertions(+), 15 deletions(-) create mode 100644 docs/en/user-guide/lmux.md create mode 100644 docs/internals/architecture/apphost/decisions/accepted/ARD-004-aa-stability-gate-vs-interactive-metric-scale.md create mode 100644 docs/internals/architecture/apphost/lmux-capability-projection-plan.md create mode 100644 docs/internals/architecture/apphost/lmux-live-probe-plan.md create mode 100644 docs/internals/architecture/apphost/lmux-managed-output-capture.md create mode 100644 docs/internals/architecture/apphost/lmux-performance-acceptance-plan.md create mode 100644 docs/zh-CN/user-guide/lmux.md diff --git a/docs/en/user-guide/README.md b/docs/en/user-guide/README.md index cacee6434..8806eca6e 100644 --- a/docs/en/user-guide/README.md +++ b/docs/en/user-guide/README.md @@ -36,6 +36,9 @@ loushang -p "Summarize the current project." For building terminal UI applications with `loushang.tui`, see [Building TUI Apps](tui.md). +For the Linux background named-Mux development preview, see the [lmux guide](lmux.md), +including storage, reconnection, and upgrade limitations. + ### Explicit Hosted Application `loushang-hosted` is an opt-in foreground stdio server for an application diff --git a/docs/en/user-guide/lmux.md b/docs/en/user-guide/lmux.md new file mode 100644 index 000000000..f8bbf52fb --- /dev/null +++ b/docs/en/user-guide/lmux.md @@ -0,0 +1,162 @@ +# Linux lmux preview + +English | [中文](../../zh-CN/user-guide/lmux.md) + +This guide describes the current development branch, not completed delivery +acceptance. Check `lmux --help` against your installed version. Automatic +background services currently target Linux only; GUI and cross-machine access +are not included. Managed scratch quotas, full Harnesstui parity, and final +installed/disconnection/performance acceptance remain in progress. + +## Create and reconnect + +Run `lmux new -s dev` in your workspace. It starts or reuses the workspace +service, creates an empty named Mux, and opens the terminal. Use +`/new cwd Work` for the first Session Tab, `/resume` to discover existing +sessions, and `/help` for supported operations. An empty Mux does not call a model. + +`/detach` leaves the client without stopping the service. After reconnecting +over SSH to the same machine, run `lmux attach -t dev` from any directory. +Names are global within the machine/user namespace; no `server:mux` prefix +is needed. Multiple Muxes in one workspace share a service, and a Mux may +contain multiple Session Tabs. Another controller is not displaced; attach +may report busy until the previous connection releases its authority. + +Attach never restarts an offline service implicitly; use `lmux start -t dev`. +A new instance is allowed only after the previous instance is proven cleanly +stopped. Crashes or insufficient cleanup evidence are refused; start does not +bypass those checks. +Accepted work belongs to the background service rather than the SSH terminal. +This is not protection against reboot, crashes, or host policies that terminate +user processes. An admitted restart restores durable Mux/Session state, not running work or +requests whose replies were lost. + +Bare `lmux` creates `main` in cwd when no Mux name reservation exists; +untargeted `lmux attach` reports not_found in that case. When the entire namespace +has exactly one Mux reservation, and it belongs to Coding and is recorded as +committed with neither a stop request nor a clean stop, both commands directly +attempt an authenticated connection. For multiple candidates, a bounded, +read-only probe authenticates each eligible service and reads exact Mux IDs; +it never attaches or requests control. A sole confirmed Mux is selected only +when no candidate is unknown and the candidate set remains unchanged. +Otherwise, the selector shows the frozen observations: `n` advances a page, +`r` returns to the first page, and `f` explicitly refreshes and probes again. +Paging performs no new probe. Probe connections close before final attachment, +which authenticates again against the selected instance and Mux ID. +Recorded state does not prove availability: +a failed connection does not restart the service or choose another target. +Pending reservations are not treated as an empty list. +Terminal commands require TTY stdin and stdout; piped prompts are +not supported. + +### Recover an interrupted creation + +Before reserving a name, `new` (including bare `lmux`) prints a JSON +`planned_creation` with exact `serviceId` and `operationId`. This is **not** a +success receipt or proof that the reservation committed. `lmux ls` also reports +`creationOperationId` for retained reservations. + +```bash +lmux create-status --server SERVICE_ID --operation OPERATION_ID +lmux create --server SERVICE_ID --operation OPERATION_ID --continue --yes +``` + +Replace both IDs with the original values; service aliases are not accepted. +`create-status`, and `create` without `--continue`, only read recorded facts: +they do not connect, start, issue permission, or resend creation. `unknown` +(exit 1) does not mean the original request had no effect. `created` is a +historical receipt, not proof that the Mux is still open or online. + +Explicit `--continue` confirms the original name/workspace/operation and may +start or reuse that same service, then sends at most one idempotent create RPC. +It does not allocate a replacement operation, delete a reservation, or attach +automatically. Non-TTY continuation requires `--yes`. A known receipt needs no +RPC; otherwise, after successful continuation use `lmux attach -t NAME`. +The existing clean-stop and recovery checks still apply. A prior-instance +permission without sufficient durable creation history is refused, not guessed +safe to replay. Closed/released operations cannot reclaim a reused name. +Repeating `new -s NAME` remains a conflict, not a recovery action. + +## Start ahead of time and inspect + +```bash +lmux server start --name build --workspace /absolute/path/to/project +lmux status +lmux status --server build +lmux logs --server build --limit 20 +``` + +Replace the example path with an existing workspace. Server start is scriptable +and creates neither a Mux nor a Session. The optional alias is separate from +Mux names. Repeating the same alias/workspace reuses the service; a different +workspace or a second alias for the same service conflicts rather than rebinds. +Successful startup returns exact service and instance IDs. + +For an explicit, time-limited diagnostic request, use +`lmux server start --trace-for 60` (1–3600 seconds). The duration starts when +the command is prepared, not when startup finishes. Only a newly started +instance can apply this request; reusing a service never renews or replaces its +trace. Trace contains bounded timing aggregates and fixed problem codes, not +prompts, replies, tool bodies, or credentials. + +The JSON result separates `service_ready` from `trace.status`: `applied` records +historical configuration, not a guarantee of future writes; `expired` means +that configuration's deadline has passed; `not_applied_reused_instance` means +this request did not configure the reused instance; `not_confirmed` means no +matching fact was confirmed within the startup budget. +`observation_failed` reports a separate safe `errorCode` if observation fails; +the already authenticated service/instance result is preserved. `deadlineMs` +uses this machine's monotonic clock, not Unix time. With a trace request, +only `applied` returns exit code 0; other trace outcomes return 1 even if the +service is ready. Trace failure does not stop a ready service. + +`lmux ls` lists Mux reservations. Untargeted `status` also includes services +without Muxes. Status is explicitly `recorded_only` / `not_probed`, not a live +health check. Logs are bounded lifecycle tails, not complete history or +conversation content. Diagnostics never start a service. + +## Detach, close, or stop + +- `/detach`: leave this client; retain the service and sessions. +- `lmux close -t dev`: confirm closure of one Mux and its active members; + persistent Session history is retained. +- `lmux stop --server build`: confirm stopping the service and affecting all + Muxes it hosts. Exact service IDs are also accepted. +- `lmux stop --all`: confirm a frozen target set in this namespace, not all + Loushang processes on the machine. + +Noninteractive close/stop requires `--yes`; it does not force-kill or bypass +cleanup. Stop without a target does not guess from cwd. Preserve the operation +ID and follow-up command printed for incomplete closes. Historical close +reconciliation still requires an exact service ID, not an alias. + +## Storage and preview upgrades + +Management state and bounded lifecycle logs default to +`~/.loushang/lmux/machines//`, partitioned by service ID. Targeted +status reports paths. Credentials and runtime control use a separate private +platform runtime namespace; `LOUSHANG_RUNTIME_DIR` overrides that root. +Do not treat live runtime control as disposable cache. +Durable admission witnesses also live under `$LOUSHANG_HOME/state/managed-deployments/` +(defaulting beneath `~/.loushang/state/`); they retain deployment identity and initialization records +and are not disposable cache either. + +Instance scratch defaults to the service's `tmp/` directory, +with an explicit `LOUSHANG_TMPDIR` taking precedence. This does not yet impose +a disk quota on all tool output. Sessions retain their existing policy, +defaulting to `$LOUSHANG_HOME/data/sessions`; cwd and user_home are discovery +scopes, not separate default write stores. + +`LOUSHANG_HOME` defaults to `~/.loushang`. Reconnect using the same user, +machine, and root overrides. Switching roots is not a way to bypass cleanup +or restart an uncertain old instance. + +The current development Registry format is **14**. Older preview formats +are rejected without automatic migration. Do not delete state, locks, or +runtime records, or edit version numbers to bypass this refusal. Retain the +old state, use a matching old version to manage its service, and wait for an +explicit upgrade procedure. The legacy `loushang-mux` explicit-argument entry +remains separate and is not automatically imported or adopted. + +See the [lmux contract record](../../internals/architecture/apphost/lmux-contract-m0.md) +for development and acceptance status. diff --git a/docs/internals/architecture/apphost/decisions/accepted/ARD-004-aa-stability-gate-vs-interactive-metric-scale.md b/docs/internals/architecture/apphost/decisions/accepted/ARD-004-aa-stability-gate-vs-interactive-metric-scale.md new file mode 100644 index 000000000..e95560b28 --- /dev/null +++ b/docs/internals/architecture/apphost/decisions/accepted/ARD-004-aa-stability-gate-vs-interactive-metric-scale.md @@ -0,0 +1,352 @@ +# ARD-004: A/A 稳定性门禁与交互类指标量级的匹配性 + +## Status + +- ID: `apphost/ARD-004` +- Scope: `apphost` +- Parent: `loushang` +- Authority: normative — 已接受的 AppHost 判定合同变更 +- Design status: accepted +- Implementation status: implemented — `_g18_comparison.py` 已改用具名常量对, + 既有 campaign 的重评以独立文件发布 +- Owner: Loushang AppHost architecture +- Accepted: 2026-09-18,由本任务 owner 接受(用户明确指示“可以接受”) +- Accepted scope: 稳定性门禁 `N=25%`、回归阈值 `R=30%`,统一适用于 + `_compare` 的全部 case;端点定义、样本设计、既有 report 均不变 + +本记录只回答**一个问题**:现行的 A/A 稳定性门禁(`max(median/10, 1/100)`) +是否适用于量级在亚秒至十余秒的**交互类指标**?(触发实例为 +`history_completion_seconds`,110655 证据表明 managed-mux 八项同样受影响。) + +**本记录已被接受(2026-09-18):** 接受的是“成对重标定为 `N=25% / R=30%`”。 +接受**不等于**既有采集自动通过——既有 `inconclusive` 必须按新合同**重新评估** +并显式标注为“判据变更”,且不得改写原始 report。 + +## Background, Problem And Architectural Drivers + +### 触发事实 + +LMUX 长历史 warm 的正式 A/A 采集(`aa-history-warm-r1`,冻结源 +`82a936ce8cd1b35a3005048d5a77afbff8985c28`、候选 wheel `70d6d1bd…`, +2 块 × 10 对 × 2 侧 = 40 measured + 4 预热,无 `--resume`)在结构上完全成立: + +- 44/44 样本 `status=complete` / `valid=True`,并经冻结 runner 的 + `validate_observation` 逐样本复验通过; +- canonical 全史 40/40 一致:128 轮 / 256 记录 / 263680 字节 / + `00e1c01bb0a4603b94f5fbd70ea802f24a9389471893e5883310ba7c0c0fbb41`; +- 异 cwd attach viewport 40/40 精确匹配 Product 尾窗,含 Markdown + 二级标题、列表、代码块与末轮尾标记 `LMUX_HISTORY_0127_END`; +- 每样本 Session 五字段、target 六字段、detach/连接结算、精确 stop 三事实、 + 原 owner 与外层结算齐全。 + +但 `comparison.verdict` 为 **`inconclusive`**,原因是该 case 的两个受判指标中 +`history_completion_seconds` 未通过稳定性门禁(`history_frame_seconds` 判 +`pass`)。该指标中位数仅约 78 ms,而门禁在其量级下生效的是**绝对下限 +10 ms**(`max(median/10, 1/100)`,`median/10` 仅 7.4–8.8 ms)。 + +### 为什么现在必须决定 + +按现行合同,只要该指标未过门禁,warm 的正式 A/A 就无法产生 `pass`, +长历史验收被单一毫秒级指标阻塞;进一步发现 managed-mux 九项中八项同样越界。 +在决定"放宽、重采或降级"之前,必须先 +回答一个前提问题:**该指标在当前设计下,是否具备判别 10% 回归的能力?** +若不具备,则任何"让它通过"的处置都只会产出**表面通过**,而非真实结论。 + +### 驱动与优先级 + +1. **不得产生假通过**(最高):verdict 的 `pass` 必须真的意味着"无显著回归"。 +2. **结论可解释**:报告中出现的每个 verdict 都应能被其指标的分辨率支持。 +3. **不重复劳动**:避免在无分辨率的前提下反复重采。 +4. **保留既有语义**:`history_frame_seconds` 的判定与门禁不得被削弱。 + +### 事实 / 假设 / 未决 + +- **事实(本轮实测,均为只读分析)**: + - 该指标实测噪声 MAD = **12.09 ms**(side b,n=20), + 等效正态 σ ≈ 17.9 ms,估算 CV ≈ 21%; + - 它**必须探测的最小真实差异**是该 case 的回归阈值 + `max(baseline/10, 1/50)` = **7.64 ms**; + - 因此 **噪声/信号 ≈ 1.58×**,噪声大于信号; + - 按冻结设计(n=10/组 × 2 块)以该指标自身噪声做 3000 次/点模拟, + **10% 回归的检出率仅 0.5%**;20% 为 15.0%,30% 为 74.5%,50% 为 100%; + - 该指标相对 MAD 为 15.4%(分组 7.6% / 10.5% / 14.3% / 15.8%), + 在 10% 门禁阈值上下徘徊;把已测 40 值随机重排进 A/A 各槽, + 仅 **5.4%** 的排列能过门禁; + - 与并发 CPU 负载**无**相关(Pearson r = −0.163,Spearman ρ = −0.170; + 低负载三分位反而最慢),故该 inconclusive **不是环境噪声所致**; + - 单侧去掉 10 ms 绝对下限、改用纯相对规则后,四个组中仍有**三组越界**。 +- **假设(待验证)**:端点"输入 `/he` 前到完整补全帧"包含终端补全往返, + 这类交互往返天然带较高相对抖动;换端点或增加重复可降低噪声。 + 该假设**尚未**用实验证明。 +- **事实(110655 只读复算)**:同一门禁问题已影响 managed-mux。本仓库已有的完整 A/A + `.artifacts/lmux-current-freeze.aqp5u4ut/aa-warm-continuous/report.json` + (源 fd360f80…,44/44 valid、未分段、`eligible=true`)同样为 `inconclusive`: + **九项中八项越界**(相对 MAD 7%–19%);唯一通过的 `first_completion_seconds` + (0.058 s)恰是受绝对下限 `1/100` 保护的那一项。 + 说明不匹配是这套统计策略的普遍现象,非长历史个案。 +- **未决**:可接受的噪声/信号比目标(本提案建议 ≤ 1/3,理由见下)。 +- **未决**:换更安静/更多 vCPU 的主机能否使这些指标稳定下来,尚无独立实验; + managed-mux 文档已明确“不能仅凭不稳定判定归因于宿主噪声”。 + +### 非目标 + +- 不重新定义 `history_frame_seconds`(其噪声/信号比 0.26×,门禁正常)。 +- 不改动 native 41 指标、managed-mux 9 指标、first-use 8 指标的判定。 +- 不改写 `aa-history-warm-r1` 及任何既有 report;不通过重跑取得 pass。 + +## Options And Comparison + +五个可行选项,均对照上述驱动。**Option E 为建议;B 与 C 被排除**,须记录理由。 + +| 选项 | 对驱动 1(不得假通过) | 对驱动 2(结论可解释) | 对驱动 3(不重复劳动) | 对驱动 4(保留既有语义) | 成本 / 风险 | +| --- | --- | --- | --- | --- | --- | +| **A. 维持现行门禁,接受 `inconclusive`;需判回归的指标降级为描述性** | 满足:不再声称 pass | 满足:如实说明这些指标不足以判回归 | 满足:不再重采 | 满足:门禁常数不动 | 诚实但代价最大:V10 的长历史与 managed-mux 两个阻塞**长期无法解除**,且失去自动回归保护;仅在无法修改判据时作为兜底 | +| **B. 只放宽稳定性门禁 N**(抬高相对项或绝对下限,让现有数据 stable) | **不满足**:R/N 由 1.0 降至 0.4,比现状更糟;`pass` 仍不承载信息 | 不满足:verdict 无法被分辨率支持 | 表面满足 | **不满足**:若改 `median/10` 会连带放松 `:316` 的**回归阈值** | 零采集成本,但直接制造假通过——**必须排除** | +| **C. 提高样本量 / 多轮取稳健统计** | 潜在满足:噪声随 √n 下降,可能达到可判别 | 潜在满足 | **不满足**:需重采,且当前设计下所需样本量显著增加(见下) | 满足 | 需重新声明采样合同并重采;按 1.58× 噪声/信号比,要降到 1/3 需约 (1.58×3)² ≈ 22 倍样本量,远超现行 20 对设计 | +| **D. 提高该指标信噪比**(改端点:减少终端补全往返抖动,或改用更粗粒度但更稳的观测) | 满足:真正提升分辨率 | 满足 | 满足:一次性改善 | 满足 | 需产品侧设计与复核;改变端点即改变指标语义,必须重新声明合同并重采 | +| **E. 把 N 与 R 成对重新标定(建议)** | 满足:R/N 提到 1.2–1.6,`pass` 重新承载信息(30% 回归检出率由 0.5% 升到约 46%) | 满足:verdict 与其分辨率一致,且代价公开 | 满足:一次标定覆盖全部 case,无需重采 | 满足:端点语义、样本设计、既有报告均不变 | 判据变更需重新声明合同;既往 `inconclusive` 不得直接改判,须作为新合同下的重新评估 | + +**Option E 的关键区别**:B 只动 N,E 成对动 N 与 R。现行 R/N=1.0 是 +“无判别力”的根因(见 Decision 节),只抬 N 会加剧它。 + +### 关于"提高样本量"的定量说明(Options C) + +以当前 σ ≈ 17.9 ms、回归阈值 7.64 ms 计,欲使噪声降至阈值的 1/3 +(约 2.5 ms)需 σ 降为 1/7,即样本量约增大 **49 倍**;若只求噪声≈阈值 +(勉强可判)也需约 5.5 倍。这远超现行"2 块 × 10 对"的冻结设计, +故 C 在实践中等价于**重新设计采样合同**,而非简单重采。 + +## Decision And Rationale + +**建议(统一门禁评审):接受把 A/A 稳定性门禁与回归阈值一并重新标定为一对 +自洽常数,并按指标量级保留判定地位;不逐个 case 打补丁,也不放宽为“无判别力 +的通过”。** + +**为什么是“最小必要放宽”而不是“尽量宽容”**:`N=25%` 恰是能让**全部 22 组侧 +全稳的最小值**(实测:`N=24.7%` 仍只 21/22,`N=25%` 才 22/22)。再往上放宽 +不会带来任何推进收益,却会**放大 R、削弱检出功效**——实测 30% 回归的检出率: +`N=25%/R=30%` 为 52%,`N=30%/R=40%` 降到 10%,`N=40%/R=60%` 为 **0%**。 +即“越宽容越没用”,故建议值取在**刚好够用**处,而非尽量大。 + +具体建议参数(供评审,非既成事实): + +| 常数 | 现行 | 建议 | 作用 | +| --- | --- | --- | --- | +| 稳定性门禁 `N`(`_g18_comparison.py:300` 的相对项) | `median/10` = 10% | `median/4` = **25%** | “测量够不够准” | +| 回归阈值 `R`(同文件 `:316`) | `baseline/10` = 10% | `baseline/3.33` ≈ **30%** | “要探测多大的回归” | + +### 为什么必须“一并”改,而不是只抬 N + +这是本提案最关键的发现:**现行门禁本身不自洽。** + +- 稳定性门禁 N 与回归阈值 R 是**两个不同的语义**:N 是“我们接受多大噪声”, + R 是“我们声称能探测多大回归”。要让 `pass` 有意义,必须 **R 明显大于 N**。 +- 现行两者都是 10%,即 **R/N = 1.0**。这意味着“稳定之后再去比 R”, + 等于在噪声量级上比大小——这正是 110610 实测 **10% 回归检出率仅 0.5%** 的 + 结构性根因,与长历史无关,**对所有使用该策略的指标都成立**。 +- 因此**单独把 N 从 10% 抬到 25% 会让情况更糟**(R/N 降为 0.4)。 + 必须 N 与 R 成对调整。 + +### 建议参数的验证(只读复算 + 模拟) + +| 检查项 | 结果 | +| --- | --- | +| 稳定性覆盖(两 campaign、全部 11 指标、22 组侧) | **22/22 通过**(现行 N=10% 仅 5/22) | +| 真实 30% 回归的平均检出率(n=20/侧,1000 次/指标) | **≈46%**(现行 R=10% 时约 0.5%) | +| 各指标 MDE@80%(用实测噪声) | 11.8%–25.8%;R=30% 已覆盖多数指标 | + +分层对照(便于评审选择保守程度): + +| N | R | stable 覆盖 | 备注 | +| --- | --- | --- | --- | +| 10% | 10% | 5/22 | 现行;R/N=1.0,无判别力 | +| 15% | 20% | 16/22 | 仍不足以让现有 campaign 稳定 | +| 20% | 25% | 21/22 | 接近 | +| **25%** | **30%** | **22/22** | **建议**;R/N=1.2;**最小达标值**(24.7% 只 21/22) | +| 25% | 40% | 22/22 | R 更保守 → 30% 回归检出率更低(约 10% 量级) | + +### 反“自动 pass”验证(重标定不会让门禁失去意义) + +重标定必须证明它**仍能抓回归**,否则等于取消门禁。只读检验: + +| 检验 | 结果 | +| --- | --- | +| 注入 0% 回归(安慰剂) | 判 `regression` 比例 **0%**(不误报) | +| 注入 30% 回归 | **100%** 判 `regression` | +| 注入 80% / 150% 回归 | **100%** 判 `regression` | +| 真实噪声下、含 stability 前置,30% 回归(最噪指标 `detach_settlement`) | **48%** | +| 同上,50% 回归 | **95%** | + +对照现行 `N=10%/R=10%`:即便注入 200% 回归,检出率也仅 **23%**—— +即现行门禁**几乎抓不到任何回归**,这才是真正“失去意义”的那一档。 + +### 必须同时接受的代价(不得隐去) + +1. **回归灵敏度下降**:由“名义 10% 但实际 0.5% 检出”变为 + “名义 30% 且实际约 46% 检出”。这是**用灵敏度换真实可判别性**; + 净效果是**从“不可用”变为“可用但较粗”**,而非纯粹放宽。 +2. **30% 以下的回归基本无法发现**。若某条流水线需要保护 10–20% 级别的 + 性能回归,本方案**不能满足它**,必须走 Option D(提高信噪比)或 + Option C(加大样本量),而不是靠调门禁。 +3. **判据变更需重新声明合同**:本提案一旦接受,既往在旧门禁下的 + `inconclusive` 结论**不得**直接改判为 `pass`;应作为**新合同下的重新评估** + 单独记录,并明确其依据是门禁变更而非新数据。 + +### 关于“适当放开”与“基线一般也无所谓”的处理 + +- **“可以宽容一点、能往前推进”已采纳**,但形式是 Option E 的**成对重标定** + (只抬 N 会让 R/N 从 1.0 降到 0.4,比现状更糟)。建议值 **N=25% / R=30%**—— + 这是**能让全部 22 组侧稳定的最小值**;再放宽不增加推进收益,反而把 + 30% 回归检出率从 52% 拉到 10% 乃至 0%。 +- **“能往前推进”已具体化为可执行结果**:接受后,两个既有 campaign + (长历史 warm、managed-mux)在新合同下转为 `pass`,**无需重采**, + 从而解除 V10 三个阻塞中的两个(详见“接受后的推进路径”一节)。 +- **“基线一般也无所谓”**:必须与判据自洽性分开—— + - 基线的**绝对水平**(managed-mux 冷首帧约 11 s、stop 含结算约 7.9 s) + 可以按“先接受现状、后续再优化”处理,**不影响门禁是否自洽**; + - 而 **R/N=1.0 属判据本身的缺陷**,与基线好坏无关,必须修, + 否则任何 verdict(`pass` 或 `regression`)都不承载信息。 +- **不得**用本提案把既有 `inconclusive` 直接改判为 `pass`: + 判据变更后的结论须作为**新合同下的重新评估**单独记录, + 并明确依据是门禁变更而非新数据。 + +### 选定范围与排除项 + +**范围**:`_g18_comparison.py::_compare` 的 A/A 稳定性门禁与回归阈值这一对 +常数,及其对**所有**使用该策略的 case 的影响(native 41 指标、 +managed-mux 9、first-use 8、history 3)。**统一处理,不逐个 case 打补丁**。 + +- **排除**:不改各指标的**端点定义**(属 Option D);不改写任何既有 report; + 不通过重跑取得 pass;不改变样本量/配对设计(属 Option C)。 + +## 接受后的推进路径(回答“能否往前推进”) + +重标定一旦被接受,这些**既有**采集即可在**新合同下重新评估**——不需要重采: + +| campaign | 现行 verdict | N=25%/R=30% 下 | 备注 | +| --- | --- | --- | --- | +| `aa-history-warm-r1`(长历史 warm) | inconclusive | **pass** | 44/44 valid,两项硬验收事实已实测满足 | +| `aa-warm-continuous`(managed-mux) | inconclusive | **pass** | 44/44 valid,九指标全 pass | + +**这直接解除 V10 的三个阻塞中的两个**(长历史、managed-mux), +剩余阻塞:① restore 的 `startup_failed`(已交产品侧);② first-use 尚无正式 A/A。 + +**四条必须遵守的纪律:** + +1. 该改判必须**显式标注为“判据变更”**,而不是“新数据证明无回归”。 + 报告文字不得写成“性能通过”,应写成“在新门禁下未检出回归”。 +2. 既有 report 文件**不得改写**;重新评估结果写**新文件**并引用原 report 的 + sha256,保持原始证据不可变。 +3. 30% 以下回归**不可检出**这一点必须随结论一同呈现,否则读者会误以为 + “无回归”被证明了。 +4. first-use 建议在**新合同生效后**再启动正式 A/A,这样它能一次拿到有效结论, + 而不是先跑出一个注定 inconclusive 的长 campaign。 + +## Consequences, Risks And Trade-Offs + +### 收益 + +- `verdict` 重新**承载信息**:由“名义 10% 实则 0.5% 检出”变为“名义 30% 实则约 46%”; +- 一次标定覆盖**全部** case(native/managed-mux/first-use/history), + 不再逐个打补丁,V10 三个阻塞中的两个(长历史、managed-mux)同时解除; +- 端点语义、样本设计、既有报告**都不变**,历史证据链完整; +- 明确暴露并修掉的是一个**判据自洽性缺陷**,而不是掩盖它。 + +### 接受的代价 + +- **回归灵敏度下降**:30% 以下回归基本不可检出。需要保护 10–20% 级回归的 + 流水线**不能满足**,必须走 Option D 或 C。 +- 判据变更后,既往在新旧门禁下的 `inconclusive` **不得**直接改判 `pass`; + 须作为新合同下的重新评估单独记录。 + +### 残余风险与责任人 + +- **风险**:把 R 定在 30% 可能被误读为“允许 30% 性能退化”。须在实施时 + 明确:R 是**探测下限**,不是容忍上限。→ 责任人:验收 owner。 +- **风险**:`_g18_comparison.py:300`(绝对下限 `1/100`)与 `:316` + (`max(baseline/10, 1/50)`)形式相近但语义不同(“测量精度” vs “回归阈值”), + 且当前都不自洽。未来修改者可能只改一个。→ 建议实施时把两者合并为 + **具名常量对**并就近注释(属后续小改动)。 +- **风险**:现行 `1/100` 绝对下限对 78 ms 级指标反而是“宽松”来源 + (`median/10` 仅 7.8 ms),重标定时须一并明确它在新 N 下的取值。 + +### 兼容性 / 运维 + +- 不改变采集器行为、不改变已发布入口、不改变默认 case 集合、不需重采; +- 若本提案被接受,需同步更新长历史与 managed-mux 验收文档中的 + 指标表、判定说明,并声明新合同的生效边界(是否重评既有报告)。 + +## Validation Evidence And Reconsideration Conditions + +### 支撑证据(均为只读分析,未修改任何采集或报告) + +- 采集与复验:`/var/tmp/lmux-lazy-paired.JK8DMb/aa-history-warm-r1/report.json`; + 逐样本权威复验 44/44 通过(冻结 runner `validate_observation`)。 +- 噪声/信号:噪声 MAD 12.09 ms vs 回归阈值 7.64 ms = 1.58×。 +- 功效模拟:n=10/组 × 2 块,3000 次/点;10% 回归检出率 0.5%。 +- 负载相关性:Pearson −0.163 / Spearman −0.170;低负载组最慢。 +- 排列检验:40 值随机重排仅 5.4% 过门禁。 +- 阈值改写检验:去绝对下限后四组中三组仍越界。 +- 推进与取证记录(含失败/未达标细节): + [lmux-managed-output-capture.md](../../lmux-managed-output-capture.md) + 的 `110445`、`110512`、`110545`、`110610` 各节。 + +### 证据局限 + +- 功效模拟假设噪声近似正态且独立;真实抖动可能含有相关性, + 故检出率数字是**量级**而非精确值; +- 全部结论基于**单一**冻结 wheel 的 A/A 数据(n=40),未跨版本复验; +- 未做 Option D 的原型实验,故"改端点能否降噪"仍是**未验证假设**。 + +### 重新考虑条件 + +出现以下任一事实时应重开本决定: + +1. Option D 原型显示某端点可将噪声降到 R/3 以下(即 MDE ≲ 10%): + 则应改为“采用新端点 + 恢复更严门禁 + 重新声明合同并重采”; +2. Option C 证明用可接受的重采成本把 MDE 压到 10% 级: + 则应改为“加大样本量 + 保留严门禁”; +3. 宿主/环境显著改善(例如独占多 vCPU)使噪声结构性下降: + 现有 25%/30% 参数应重新收紧——**这是收紧而非放宽的触发条件**; +4. 出现跨版本 A/B 实测,显示 30% 的 R 会漏掉真实的、用户可感知的回归: + 应改为按指标分别标定 R,而非全局单一值。 + +## Review And Acceptance Records + +- 本记录由实施方根据用户指示起草:**统一门禁评审**,可适当放开。 + 起草过程据用户澄清采用 Option E(成对重标定),而非只抬 N。 +- 复核范围:仅基于既有只读证据与模拟重算,**未执行新采集**,未修改任何 + 产品代码、`_g18_comparison.py` 常数或历史报告。 +- 可独立复算的两项:①“现行 R/N=1.0 导致 10% 回归检出率约 0.5%”; + ②“N=25%/R=30% 下 22/22 组侧 stable 且 30% 回归检出率约 46%”。 +- **已接受(2026-09-18)**:接受人 = 本任务 owner(用户明确指示“可以接受”)。 + 接受范围 = 成对重标定 `N=25% / R=30%`,统一适用于 `_compare` 的全部 case; + 端点定义、样本设计与既有 report 不变。 +- **接受不免除以下纪律**: + 1. 既有 `inconclusive` 须**按新合同重新评估**并标注为“判据变更”, + 不得表述为“新数据证明无回归”,更不得表述为“性能通过”; + 2. 原始 report **不得改写**;重评结果写入新文件并引用原 report 的 sha256; + 3. “30% 以下回归不可检出”必须随结论一同呈现。 +- **实施状态:已完成。** + - `_g18_comparison.py` 现以具名常量 `STABILITY_RATIO = Fraction(1, 4)` 与 + `REGRESSION_RATIO = Fraction(3, 10)` 表达,`_compare` 的三处判定全部引用它们; + 文件头注释说明二者语义不同、必须成对标定,并记录实测结果与限制。 + - 新脚本 `scripts/dev/reevaluate_g18_comparison.py` 按本文件的纪律做 + **只读重评**:不改写原 report,记录其 sha256,输出到新文件且拒绝覆盖; + 拒绝未完成/已 resume 的 campaign;在输出里显式声明 + `is_new_measurement=false`、`is_performance_acceptance=false`。 + - 已发布重评(原始 report 均逐字节未变): + | campaign | 原 | 新 | 重评文件 | + | --- | --- | --- | --- | + | `aa-history-warm-r1` | inconclusive | **pass** | `reevaluation-ARD-004.json` | + | `aa-warm-continuous`(managed-mux) | inconclusive | **pass** | `reevaluation-ARD-004.json` | + - 测试:`tests/dev/test_reevaluate_g18_comparison.py`(16 例)覆盖契约记录、 + source 字节绑定、拒绝覆盖、拒绝未完成/resume/非冻结策略、源文件不变、 + 以及“超过接受比率的回归仍会被报出”。旧门禁边界测试已改为**由常量推导**, + 不再硬编码 10%。 +- 阻塞性发现:无(本提案不实施任何代码改动)。 +- 非阻塞后续事项(owner 待定): + 1. 把 `:300`/`:316` 的常数合并为具名常量对并就近注释作用域差异; + 2. 明确新合同的生效边界:既有报告是否重评、如何标注“判据变更”; + 3. Option D 的端点降噪原型(用于重新收紧门禁); + 4. 同步更新长历史与 managed-mux 验收文档的判定说明。 diff --git a/docs/internals/architecture/apphost/lmux-capability-projection-plan.md b/docs/internals/architecture/apphost/lmux-capability-projection-plan.md new file mode 100644 index 000000000..c2ede95c7 --- /dev/null +++ b/docs/internals/architecture/apphost/lmux-capability-projection-plan.md @@ -0,0 +1,117 @@ +# lmux shared conversation capability projection + +Status: accepted implementation slice after three-perspective design review; +implementation and its code review pending. +Authority: implementation plan under the accepted +[lmux design](../drafts/lmux-managed-service-design.md#63-复用单元完整-harnesstui-会话视图不只是-tui-控件). + +## Boundary + +The shared view needs immutable availability facts, not a new authority or wire +protocol. Preserve `ConversationInputCapabilities` and its existing Embedded +input behavior. Add a neutral presentation value beside the existing conversation +input policy/state; Hosted must not import a local Product to fill missing facts. +Do not introduce another task owner, RPC, permission check or terminal loop. + +Each presentation entry has a closed operation name, availability +(`available`, `read_only`, `unavailable`), and a closed reason code. The adapter +supplies a snapshot for its current opaque binding key. The key is equality-only +view identity, not a controller token. A retained snapshot for another key must +resolve as unavailable; it cannot authorize a request. + +Initial operations: transcript, submit, steer, follow_up, interrupt, +approval_details, approve, deny, image_paste and product_commands. Transcript +and available approval details are read-only. Distinguish protocol +support from current eligibility: an available operation means the UI can offer +an intent, not that policy will approve it or that execution will succeed. + +## Adapter behavior + +Hosted derives the key from the exact attachment, controller generation, member +and Session tuple already used by its action binding. No active member, required +snapshot, closing or membership transition disables member actions. Rebinding +recomputes the matrix; never transfer an old approval-presented receipt. + +The admitted v1 AppClient supplies text submission/steering/follow-up/interruption +protocol support. Existing runtime input policy still chooses running-submit +behavior. Approve eligibility needs the current pending interaction and the +existing exact-content presentation receipt; the matrix never substitutes for +that receipt. Deny requires a current eligible pending interaction but not a +presentation receipt. Details remain independently readable while approval is +disabled for lack of presentation, so the user can obtain that receipt. Never +hide deny or the details entry merely because approve is unavailable. +Unsupported image and Product commands remain unavailable +with explicit protocol-unavailable reasons, as required by the accepted scope. + +Embedded projects only facts supplied by its existing composition/input ports. +Keep older bindings working when they do not supply the optional richer matrix; +absence of the richer value is not evidence of additional capabilities. Do not +probe Product objects from shared view code or change clipboard ownership. + +For the current Coding composition, declaration-only input and operation groups +are available through the existing resolver metadata, without resolving a Session. +Interrupt requires lifecycle and queue groups. The standard clipboard profile +declares its entry point, not clipboard contents or model image support. Local +commands come from the bound Coding surface/dispatch, not its completion list. +The current local approval port has no pending/presented/outcome eligibility +snapshot. Mark these entries `unavailable/not_projected` (projection unavailable), +not unsupported or no-interaction, and do not use them to disable the existing +local approval surface. `not_supported` is reserved for an explicit negative +declaration. Only Hosted currently filters suggestions through its complete +protocol-derived projection; Embedded retains its existing input/surface routing. + +## Consumers and invalidation + +Expose the current matrix in shared screen state and use it in operation/help +presentation. Hosted command suggestions and unsupported notices consume the +same facts, avoiding a second independent availability table. Existing explicit +target capture, scope/controller checks, approval receipt checks, bounded action +admission and server-side authorization remain mandatory. + +Invalidation includes Tab selection, attachment/controller replacement, Session +replacement, snapshot-required state, pending membership mutation and close. +Recompute the small matrix synchronously from current state when presenting or +offering an operation, without a separate capability cache or asynchronous +publisher. Screen projection cache keys must include changing eligibility facts, +not just transcript revision. If later implementation introduces retained +publication, both the binding key and the current eligibility revision must +match; binding equality alone is insufficient. In particular, changes to approval +content, its presentation receipt, snapshot-required, membership or closing can +invalidate eligibility inside the same binding. An old result must not republish +availability in either the same binding or a new one. Local text editing remains possible where already supported even +when remote submission is unavailable. + +## Acceptance + +- Validate closed values, duplicate operations and immutable snapshots. +- Project exact member identity and explicit unsupported reasons. +- Show transcript read-only without granting any remote mutation. +- Reject retained snapshots after Tab/attachment/Session replacement. +- Invalidate availability without a transcript change when close, membership, + snapshot or pending approval facts change. +- Retain an old available snapshot, change approval content or closing with the + same binding and transcript revision, then offer the old value: current shared + presentation/help/suggestions must remain derived from the new state, and + must not restore old approve/submit eligibility. +- Keep approval receipt and stale action/result rejection regressions intact. +- With an unpresented pending interaction, offer deny and read-only details, + explain that approve requires presentation; enable approve after the exact + details were presented. Rebinding or changed content invalidates approval + eligibility. Verify both presentation and command suggestions. +- Preserve Embedded input/clipboard behavior; test bindings with and without the + richer presentation value. +- Test rendered help/suggestions as well as values, so a disconnected matrix + cannot satisfy acceptance. + +This slice does not add image transport, structured tool cards, usage transport, +GUI or cross-machine connections, and does not replace full PTY/performance +acceptance or the final goal-wide three-perspective review. + +## Design review resolution + +Architecture: preserve the distinction between no richer matrix (legacy adapter) +and an explicit unavailable entry; no additional authorization is inferred. +Interaction: split approval details, approve and deny, preserving their different +presentation-receipt requirements. Lifecycle: same-binding eligibility changes +invalidate prior observations, not merely attachment replacement. These local +design findings are resolved above; runtime and final goal acceptance remain open. diff --git a/docs/internals/architecture/apphost/lmux-contract-m0.md b/docs/internals/architecture/apphost/lmux-contract-m0.md index 70927945c..456548272 100644 --- a/docs/internals/architecture/apphost/lmux-contract-m0.md +++ b/docs/internals/architecture/apphost/lmux-contract-m0.md @@ -8,8 +8,8 @@ - ID: `LMUX-M0` - Authority: normative — accepted limited Linux managed-profile contract - Design status: accepted -- Review status: three-perspective storage/lifecycle/observer/handoff/native-launch/native-binding/application-staging/child-owner slice reviews passed; recovery admission pending -- Implementation status: partial — storage, instance coordination, Linux observations, native launch, durable birth binding, staged application startup and child application owner; no managed CLI activation +- Review status: three-perspective slice reviews passed through recovery/close and shared deferred input; final goal-wide review pending +- Implementation status: partial — managed CLI preview, Linux launch/lifetime, owned transcripts, discovery, exact-instance connections, close and shared Markdown/action/input binding; full M3/M4 acceptance pending - Owner: AppHost managed deployment; sibling changes remain sibling-owned - Tracking objective: active Linux lmux goal, branch `harness/lmux-managed-service` @@ -182,8 +182,8 @@ metadata 中;失败/丢回复按 operation 与完整身份对账,不按名 同工作区历史可恢复;跨工作区候选显示不可用原因,不能重绑定 cwd。 scope fingerprint 是发现准入约束,不是 Session 的唯一写入锁身份。 -writer lease 放在 canonical session root 下的私有 owner namespace,稳定 -身份由 Product+conversation ID 决定。读取/发现不取得 writer 权;持久创建/ +writer lease 放在 canonical session root 下的私有 owner namespace,逻辑授权 +绑定 Product+conversation ID,物理互斥域为 root+conversation ID(见 §24)。读取/发现不取得 writer 权;持久创建/ 恢复从统一 Harness transcript lifecycle 取得并保留到完整 Product runtime dispose 成功。构造失败、Graph ownership 转移、取消和异常必须同一 owner 转移;清理失败继续持有 lease。既有短期 journal lock 保持,不能在整个会话 @@ -233,18 +233,19 @@ Mux 外壳只负责成员/连接组合。主题与终端能力在客户端组合 | 要求 | 当前证据 | 剩余工作 | | --- | --- | --- | | 独立分支与基线 | 分支已建立,30 个基线回归通过 | 后续增量相对基线验证 | -| M0 合同 | 三视角修订后通过;纯值/状态/路径 56 项测试、Ruff/mypy 通过 | native admission、持久 CAS、writer lease 等运行实现及验证 | -| M1 私有文件 owner | 文件准入、稳定锁、记录 CAS 与故障清理;三视角复审通过;文件测试 31 项通过 | 配额和公共发现/连接协调仍待接线 | +| M0 合同 | 三视角修订后通过;纯值/状态/路径 56 项测试、Ruff/mypy 通过;后续实现见以下各行及 §20 | 完整 M1–M4 生产接线与最终验收 | +| M1 私有文件 owner | 文件准入、稳定锁、记录 CAS、deferred first admission 与不确定关闭债务;三视角复审通过 | 配额和公共发现/连接协调仍待接线 | | M1 持久名称预留 | SQLite schema/namespace、名称与操作唯一、服务复用键、分页查询及崩溃恢复;三视角复审通过 | 操作结果对账与启动/停止编排尚未实现 | -| M2 代际协调 | per-service fence、持久 prepare/commit/abort/stop、三项结算事实及干净停止后换代 | native 启动交接、异常 retire/recovery admission、真实后台激活均未完成 | -| M2 Linux 退出观察 | boot/PID/start-time/实际 UID/PID namespace 与保留 pidfd;26 项原生回归、三视角复审通过 | 原生 spawn/handoff、进程树结算及异常恢复准入仍待接线 | -| M2 持久交接接线 | 继承通道 + exact instance/attempt journal port;共享协作 deadline、真实启动器退出和 EOF/CAS 竞争测试,三视角复审通过 | 生产 spawn/daemon 与应用 owner 清理尚未接入,不构成 SSH/后台交付 | -| M2 Linux 创建 owner | 单次生产 Popen 创建、受管 FD/环境/工作区、复用 POSIX group 观察;真实父端退出和故障矩阵、三视角复审通过 | 身份持久登记、应用自持生命周期、异常恢复和 CLI 激活仍待完成 | -| M2 birth binding | schema v3 原生身份登记、精确匹配的 commit/abort;117 项聚焦验证、三视角复审通过 | 应用自持 owner、异常退役恢复、Session writer lease 与完整前端接线仍待完成 | -| M2 应用准备/激活 | 原 AppServer/AppHost/Coding owner 的两步启动、共享截止时间和同步 fence;真实 Product/认证回归,设计与代码三视角复审通过 | 受管 child owner 仍需在两步之间接入 durable commit;CLI 不自动启动 | -| M2 子端应用 owner | 同一实际 Coding 应用的 prepare/commit/activate 接线、独立 IO/stop/cleanup owner;三视角复审通过 | installed child composition/真正父进程退出与异常恢复仍需整体验证,尚无 CLI 自动后台 | +| M2 代际协调 | per-service fence、持久 prepare/commit/abort/stop、三项结算事实及干净停止后换代 | 异常 retire/recovery admission 与生产启动/停止编排未完成 | +| M2 Linux 退出观察 | boot/PID/start-time/实际 UID/PID namespace 与保留 pidfd;原生回归、三视角复审通过 | 退出、进程树结算及异常恢复准入的生产编排仍待接线 | +| M2 持久交接接线 | 继承通道 + exact instance/attempt journal port;真实启动器退出和 EOF/CAS 竞争测试;实际 Coding 后台组合见 §19/20 | 生产 process entry/EnsureStarted、异常恢复及 SSH 验收未完成 | +| M2 Linux 创建 owner | 单次生产 Popen 创建、受管 FD/环境/工作区、复用 POSIX group 观察;真实父端退出和故障矩阵、三视角复审通过 | installed 编排、异常恢复和 CLI 激活仍待完成 | +| M2 birth binding | schema v3 原生身份登记、精确匹配的 commit/abort;117 项聚焦验证、三视角复审通过 | 异常退役恢复、Session writer lease 与完整前端接线仍待完成 | +| M2 应用准备/激活 | 原 AppServer/AppHost/Coding owner 的两步启动、共享截止时间和同步 fence;child owner 已在两步间接入 durable commit | 统一生产入口未完成;CLI 不自动启动 | +| M2 子端应用 owner | 实际 Coding prepare/commit/activate、独立 IO/stop/cleanup;启动器正常/骤退后任务续行、重连、认证 stop 已有真实进程证据 | installed composition、异常恢复与完整 SSH 验收仍待完成 | +| M2 子端 bootstrap | 公共 layout/deferred 资源装配、精确自身份绑定与依赖关闭顺序;bootstrap/实际后台/通道回归 57 passed | 生产 process entry、公共协调器和默认资源策略仍待接线 | | 一条命令/后台/全局名/多 Tab/stop | 仅 G16 既有显式能力 | M1–M3 全部接线 | -| Session 唯一写入与默认历史 | 缺运行期跨进程合同实现 | writer lease + canonical catalog + 双进程测试 | +| Session 唯一写入与默认历史 | 单根 writer primitive、真实进程/fork/崩溃 20 项验证及三视角复审通过(§24);尚未接运行期 | 全写入口/附件联合占用 + canonical catalog + Embedded↔Hosted 验收 | | 完整共享 Harnesstui/Markdown | 草案与本文接缝 | M3 代码与 Embedded 非回退 | | 真实安装/断连/性能/三视角代码评审 | 未执行本目标验收 | M4 完整矩阵与本地提交 | @@ -270,7 +271,8 @@ Goal 保持 active;当前尚未推送、创建 PR 或合并。最终交付包 ## 10. M1 私有文件 owner 切片 -`apphost.managed._files` 只负责注入目录的 Linux 原生准入与有界小记录 IO, +`apphost.managed._files` 只负责注入目录的 Linux 原生准入、有界小记录 IO +及原 owner 上的有界数据追加(§83), 不加载 Product、不激活 CLI、不解释认证或进程状态。逐级保留 no-follow 父目录 fd,先准入再相对创建;稳定 `.lock` 文件禁止被记录 write 替换, 每次操作与发布前复核持有锁的身份。锁只序列化遵循此协议的写入者, @@ -666,3 +668,4022 @@ owner 在准备前已被 child composition 持有。一次运行保留 Product p 原生用例也不能替代下一步实际 child entry 的组合验证。完整默认目录、 writer、异常退役、配额、公共协调器、CLI、完整 Harnesstui 与 installed/SSH/ 性能矩阵仍在原 goal 内;本切片不提前 push/merge。 + +## 19. 实际 detached Product 组合证据 + +`tests/coding/test_managed_detached.py` 将实际 Coding command、managed child + owner、认证 listener 和 Session 放进独立解释器;启动器使用既有 + LinuxServiceProcessV1,仅继承 startup socket,标准流为 DEVNULL。 + 分别覆盖启动器正常关闭父端句柄后退出,以及直接 `os._exit(0)`。 + 请求进入受控 synthetic model 后才退出启动器,再断开客户端、重新认证 + attach 同一 Mux,读取仍在运行的 Session,释放模型并确认调用次数为一。 + 通过认证 stop 后观察 exact pidfd 退出,并检查持久 application cleanup + 位;不把该观察伪造成已持久登记 process/scope settled。 + +测试辅助子端 `_managed_product_child.py` 自行连接一个测试专用控制 socket, + 用于模型释放、计数及失败路径 graceful close,不增加启动器继承 fd。 + 初版使用的启动登记 barrier 已由 §20 的公共 bootstrap 接线移除;迟到 + 登记与受控真实锁竞争独立验证,测试观察字节不替代 durable commit。 + watchdog 强制退出只代表测试失败,绝非干净停止证明。此组合不是安装后 + CLI、真实 SSH/HUP、默认目录、writer、异常恢复或完整 Harnesstui 验收; + 上述完整 goal 的剩余项不变。 + +三视角复审通过,修正测试失败清理、投影唯一性和诊断证据缺口: + 重连前、运行中和完成后的 Session identity 相同,assistant records 精确 + 为单条目标回复;helper 全部依赖清理成功后才发送完成回执,正常路径 + 同时要求该回执、持久应用事实与实际进程退出。控制任务异常受到监督, + 专门负测要求 EOF 无完成回执、精确进程退出及私有诊断中的异常。 + 子端自行建立 0600 测试诊断文件,记录阶段、异常和 watchdog;不新增 + 继承 fd。测试失败时独立清理客户端和 observer,并等待子端 watchdog + 范围内的退出;测试自有 starter 的 terminate/kill/reap 兜底保留失败, + 不是生产 force-stop 能力,也不补写任何成功事实。 + +验证:最初正常/直接退出 2 passed;强化断言及已有实际 Coding child + 回归合跑 4 passed;最终新增 controller 故障负测后 detached 聚焦 + 3 passed。Ruff、diff whitespace 检查与文档 6 项通过。未宣称完整 + change-aware 门禁或最终 installed 验收通过;生产入口与公共协调器 + 等剩余项仍待实施。 + +## 20. 子端生产装配资源边界(bootstrap 已实施,生产入口待接线) + +接缝为 AppHost `managed/bootstrap.py`:Product-neutral 子端资源 owner, + 不是命令解析器、服务发现、starter 或 Product 工厂。Coding 的后续 process + entry 负责选择 Product 与 Session 配置,复用该 owner 打开公共控制资源。 + 对外入口与默认策略仍须满足 writer/recovery/配额等原有准入条件。 + +输入为已规范化的 namespace/service/instance/attempt/runtime_root,全部 + 路径只经现有 managed layout 推导,不接受调用者另填 registry/fence 路径。 + 这些值不携带凭据,不构成权限。构造前做纯值、UID 和 socket 形状验证; + 成功构造后接管唯一继承 startup socket,且禁止它再次被继承。调用者在 + open 前保留这个 owner;构造不打开数据库、不创建 Product 或任务。 + +首次资源准入前提:PrivateManagedDirectory、ManagedDatabase、Registry 与 + Journal 增加显式 deferred-open 接缝,由正常构造器建立可保留的纯资源 + 容器,不用外部 `__new__` 拼装。首次目录准入、锁、SQLite 检查及 journal + 读共享 bootstrap 冻结的 absolute deadline;原有 eager 构造调用兼容。 + deferred open 不在异常路径隐式销毁 owner;失败后必须显式结算已经 + 接纳的资源,再由 bootstrap 在同一原始期限内分配新的 unopened 容器。 + 无法确认已释放的句柄保持 cleanup debt,不能通过重复 close 返回成功 + 清掉证据,也不盲目关闭可能已被重用的原生 fd。 + +`open(deadline=...)` 是专用后台子进程 bootstrap 阶段的同步操作,不在 UI + 或应用事件循环中调用。它只打开现有 registry/fence,不创建命名意图或 + 代际;捕获自己的 native identity,核对 exact instance/attempt 及已登记 + native(如有),然后建立现有 child control。没有登记时仍可保留绑定, + 但只有后续 native 完全匹配且持久 commit 成功才能接客,不能靠 barrier + 或消息字节确认。失败保留已经接纳的资源,允许显式再次 open;不重复 + 接管已打开的资源,不放宽首个绝对 deadline,也不自动重建或换代。 + +`bind(application)` 仅接纳已构造且尚未运行的中立 application port,发布 + 一个既有 ManagedChildApplicationV1;成功一次后禁止替换。此后 socket + 的 close 归 child owner,通过它结算 control IO。bootstrap 不直接运行、 + 取消或关闭应用,也不推断其进程退出。bootstrap.close 在 child owner + 仍 cleanup_pending 时拒绝,不提前关闭其借用的 journal/registry;应用 + owner 完成后按 control、native observer、journal、registry 顺序释放。 + 未 bind 时允许直接回收控制资源。每项失败保留准确句柄供重试,独立 + observer 回收不阻挡其他安全的清理;journal 未关闭不得关闭 registry。 + 不发布虚假的 cleanup 事实,不发信号,不采用 destructor 做服务终止。 + +调用矩阵:首次 open 成功后再次 open、bind-before-open、bind 后 open、 + 重复 bind(包括同一 application)均拒绝,零新增 IO、零重复接管。 + 构造失败时 socket 仍归调用者;bind 失败时 application 仍归调用者。 + “尚未运行的 application”是可信 composition 前置条件,不检查 Product + 私有字段。child pending 时拒绝 close 不进入终止状态;一旦获准 close + 就永久 fence open/bind,即使某项 close 失败也只能重试 close。未 bind + 也遵守相同的终止规则,不能 close 后复活。 + +这不是重建第二套异步生命周期状态机:应用任务、首次启动期限、控制 + worker、stop 和 cleanup 重试继续由已有 child owner 持有。bootstrap + open 有独立的准备期限;产品 prepare/commit/activate 共用其原有期限。 + 最终 EnsureStarted 的端到端 caller wait 另有观察预算,不能把任一个 + 预算到期变成已交接服务的回滚权限。 + +验收覆盖默认 layout 的真实 registry 打开、迟到登记、不匹配身份/代际、 + 重复 open/bind、open 失败后重试和期限冻结、应用在途时关闭拒绝,以及 + control/observer/journal/registry 的故障保留与依赖顺序。随后将真实 + detached Coding 用例换用此公共 owner,删除辅助程序里的手工装配; + 私有模型控制仍仅属于测试,不进入公共模块。 + +三视角设计复审已通过。首次资源准入前置已实施:四层正常构造器增加 + `defer_open=True` 与显式 `open(deadline=...)`,容器在首次 IO 前可保留; + 旧 eager 用法不变。每个容器只准入一次,失败需先关闭再由外层建立新 + 容器;未成功准入不接受查询/修改,关闭后禁止复活。DB 与 journal 在 + 完整收尾后也复查原 deadline,不把迟到结果发布为准入成功。 + +代码三视角复审修复首次锁、校验失败文件、读取、临时写入及 DB guard + 的 native close 不确定性遗漏;全部记在文件 owner,后续 close 不盲目 + 重关原 fd 也不抹掉债务。SQLite connection 对象的已知未完成 close 则 + 保留对象按其接口重试。对外新操作在 closing/uncertain debt 后拒绝, + 内部 pending unlink/fsync 清理走窄内部通道,仍能重试。OS fd close + 结果未知不能靠本进程重复 close 伪造确定性,留待整体异常退役合同。 + +新准入负测和 storage/lifecycle/child/实际 detached Coding 合跑 114 passed, + 精确 Hosting/AppHost/G9/G10/C50 架构回归 44 passed;9 模块 mypy、Ruff、 + 文档 6 项、依赖图新鲜度与 diff whitespace 检查通过。以上是分次聚焦 + 验证,不宣称完整 change-aware 最终交付门禁。中途一项新注入误伤了锁的前置校验,已限定为目标 + record;旧取消用例的“不再有临时文件即无债务”断言已更新为保留原始 + 取消、临时文件删除且 unknown fd debt 不消失。复审结论仅覆盖此前置 + 前置切片;bootstrap 的后续进展见下节,生产 process entry 和公共协调器 + 仍未实现。 + +### 20.1 Bootstrap 接线证据 + +`ManagedChildBootstrapV1` 已实现本节装配合同;纯值路径推导后才接管 + 已连接的 AF_UNIX stream socket,立即撤销再次继承,不在构造中打开 + 数据库。同步 open/close 明确拒绝应用事件循环内调用,bind 仅发布 + 现有 child owner。前置容器失败重试保留首个 absolute deadline,成功 + 依赖不替换;首次准入观察到实例、attempt、已登记 native 任一不匹配 + 均拒绝完成 open。open 不是长期授权,bind 后仍由 child 的持久检查 + 决定能否 prepare/commit/activate。 + native capture 工厂失败无返回 owner 时保留 unknown,不重复捕获或 + 宣称全部资源已结算;未移交 socket 和 native observer 的未知关闭 + 同样保留债务,不重复关闭可能已复用的 fd。 + +复审补强 Hosting channel:只有取得 mutex、实际开始 socket close 后 + 才锁定一次关闭尝试;获取 mutex 失败仍可重试,原生关闭结果未知则 + 不能由第二次空 close 清掉债务。负测使用真实 socket 底层关闭后注入 + 异常,并检查新分配的 fd 仍可用。构造测试先把被接管 endpoint 设置 + 为 inheritable,再断言该端本身变为 non-inheritable;不是检查默认 + 已不可继承的另一端。未连接/datagram/TCP 与错误 UID 拒绝后仍由原 + 调用者持有 socket。 + +实际 Coding 子端已改用 bootstrap 及规范 application/connection layout, + 删除测试辅助程序的手工 registry/journal/native/control 装配和旧 S + barrier。B 只报告 bootstrap 打开,不表示 Product 已准备或有接客权限。 + 测试故意延迟登记,并跨进程持有 registry 锁直到启动器返回精确的 + `registration_busy` 测试回执才释放;等待受同一 gate deadline 限制, + 不靠固定 sleep 猜测竞争分支已经执行。确认同一原生创建 + 的身份登记在固定期限内可完成;应用任务和最终回复仍不重放。 + 子端 child.close、线程中的 bootstrap.close 全部完成后才返回测试 + 清理回执,再由外部 exact pidfd 观察退出。 + +首轮迟到登记曾发生一次间歇失败;补 starter stderr 后,通过受控锁竞争 + 复现同类提前退出并捕获 register_native 的 `managed_storage_busy`。 + 测试 starter 改为同一冻结期限内重查 exact instance/attempt/native 后 + 登记,不重 spawn;测试自身的观察也仅对 busy 做有界重查。未把先前 + 单独三次重跑通过当成故障已经解决。确定性竞争回执补齐后聚焦回归 + 57 passed;最终全部 managed 子系统、通道及实际 Coding 消费方合跑 + 248 passed;精确架构 44 passed。11 模块 mypy、Ruff、文档 6 项及 + 依赖图新鲜度通过,三视角复审通过。没有将这些分次聚焦结果宣称为 + 完整 change-aware 交付门禁。这不是生产 EnsureStarted 的实现 + 或完整 installed/SSH 验收;Session writer/default catalog、异常恢复、 + 配额、全局名称操作协调、CLI 与完整 Harnesstui 均仍在完整 goal 范围。 + +## 21. 标准子端调用与 Coding 装配 + +`ManagedChildInvocationV1` 是 AppHost 纯值启动寻址消息,wire version 为 + `loushang.managed-child/v1`。closed flat JSON 只含 version、platformHome、 + userId、machineId、productId、workspace、profile、instanceId、attemptId、 + runtimeRoot;namespace/service key 由相同值派生,不接收冗余可分叉的 key。 + 直接构造和 decode 共用既有 namespace/service/instance 与 layout 校验。 + 校验成功不授权启动、commit 或连接,原有 self-native 与 durable birth + 检查不减少。 + +输入在 JSON 解析前按 UTF-8 限制为 64 KiB(含边界);先限制字符数以免 + 为无界文本分配编码副本。拒绝非法 Unicode、深度解析失败、重复/未知/ + 缺少字段、未知版本、非标常量、bool 伪装整数、非规范值与路径。 + 所有拒绝仅返回 bounded contract error,不回显输入;repr 不包含路径。 + 输出使用规范排序与原生 Unicode,三个最长合法四字节字符路径也能 + round-trip。消息不含 fd、密钥、environment、模型或模块 factory。 + startup socket 作为独立继承参数,由既有 bootstrap 验证后接管;消息 + 解析不打开数据库、不接管 fd、不构造 Product。 + +Coding 的 `create_coding_managed_launch` 只接受 product=coding;workspace、 + application root、connection root 从同一 invocation/layout 取得,调用者 + 不能另传一份。application ID、endpoint、discovery 与显式 Session 两根 + 沿用 G16 校验;Session 根额外与 managed durable/runtime 控制及临时根 + 隔离。没有默认 Session 目录回退,也不借此激活默认共享库。可信调用者 + 先保留 bootstrap,准入后才构造实际 Coding command,bind 失败仍由该 + 调用者持有并结算 Product;模型测试注入仅留在可信库调用,不进入 wire。 + +真实 detached 辅助程序的 starter 负责编码,独立子端负责解码,替代旧 + instance/attempt 位置参数约定;继续测试正常/骤退启动器、迟到登记锁 + 竞争、断连后唯一回复、显式 stop 与 exact 退出证据,不新增安装命令。 + +### 21.1 生产 runner 的评审前置 + +三视角设计评审允许上述消息与装配接线先实施,但生产 runner 激活前 + 必须解决退出政策;禁止 `os._exit` 并不足够。`child.run` 可因失败返回, + 其 retained stage/native future 仍可能在清理,直接离开 `asyncio.run` + 同样会取消它们。进程顶层应保留同一事件循环与原 owner,普通 run waiter + 取消只脱离等待,不转换为服务 stop;显式 stop/服务内部故障才由既有 + child owner fence/close。正在运行的清理不替换,已结束且可重试的失败 + 才按明确新预算委托同一个 child.close;不重复 prepare/commit/worker。 + +child pending 时不能关闭 bootstrap 或宣称成功退出。永久 native-close + unknown 不靠第二次 close 消除,应保留 unclean 与 owner、不接客;允许 + 异常退役退出的合同仍需 recovery 准入,不在本节默默采用“非零即安全”。 + 顶层业务失败结果与资源完整结算是独立事实;子端不写 process/scope + exited。本节未将受控测试 watchdog 作为生产退出策略。完整 goal 中的 + 生产 runner、协调器、writer/default catalog、managed RPC、CLI、TUI 与 + installed/SSH/性能验证仍须完成,不能用本切片验收替代。 + +本节设计与代码三视角复审通过。标准消息、初版显式装配及四种真实 + detached 场景合跑 50 passed;扩展目录隔离与精确架构合跑 96 passed、 + 1 failed,唯一失败为 inventory 文档已列 Coding 新模块而精确源码表 + 漏项。补齐精确条目后该项单独重跑 1 passed,未重复其余通过项。 + 两个生产模块 mypy、所有本节文件 Ruff、文档 6 项、依赖图新鲜度和 + diff whitespace 检查通过。最终 change-aware 全分支门禁仍待完整交付; + 以上不是完整安装、SSH 或首次使用性能的证明。 + +## 22. 子端结算等待接口 + +`ManagedChildBootstrapV1.run() -> int` 在成功 bind 后发布唯一 retained + driver;实现细节放在可选 `managed/_lifetime.py`,不新增 facade/CLI + 激活。未 bind/已开始关闭而未建立 driver 的调用拒绝;并发调用加入 + 同一任务,完成后再次加入仍返回同一结果,跨事件循环调用明确拒绝。 + 首次任务发布沿用 child 的 publication gate,factory 失败时没有应用 + 副作用,保留 bootstrap 后按退避重试调度。 + +driver 仅调用既有 child.run,并在其返回后委托 child.close 结算未完成 + 部分,不复制准备、commit、激活、stop、worker 或阶段任务。重试初始 + 等待 1 秒,每次倍增至 30 秒封顶;child.close 获得 30 秒协作重试预算, + 由 child 判断哪些已失败的阶段可以重试。这个预算不能强行中断 native + IO,也不覆盖仍在运行任务的原期限。public waiter 取消只退出等待, + 不调用 stop、不记作服务错误;服务或清理出现的错误则保持为结果 1, + 即使后续结算成功也不会变成 0。 + +只有 child.cleanup_pending=False 才在线程中调用 bootstrap.close。 + offload task 保留在 driver,当前任务未结束不提交第二份;同步 callable + 完成后的正常关闭异常返回明确失败结果,再按同一 owner 合同重试。 + offload task 发布失败发生在 publication gate 前,可安全重试调度; + offload 任务本身异常/取消则不能证明同步 callable 已结束,保留原任务 + 与 unknown,不重发关闭。unknown 同时计入 bootstrap.cleanup_pending, + 即使原生资源后来为空,也不能靠该属性给出虚假的完整结算回执。 + +永久 native-close unknown 同样只进入有界频率维护等待,不再次关闭 + 可能已复用的 fd,不接客、不返回退出状态。0 仅表示本驱动观察到的 + 无错误完整结算;1 表示观察到错误但最终完整结算;有未结算资源或 + unknown 就不返回。两者都不证明 process/scope 已退出。进程外观察者 + 仍负责这些事实,异常退役准入仍须后续实现。 + +这是 §21.1 的库级等待接口,不是已完成的进程 main/signal 策略。 + shield 不能阻止调用者关闭整个事件循环;最终 process composition + 必须保持同一 loop 存活直到 run 真正返回。真实 detached 测试的正常 + 路径已改为等待此接口返回,再发 D 回执;不再由测试 finally 补 child + 与 bootstrap 清理。测试控制器故障仍有独立、明确的本地 stop 权限。 + +本节设计与代码三视角复审通过。9 项确定性 lifetime 回归覆盖调用矩阵、 + prepare/active/backoff/native-close 时取消 public waiter、清理迟到不替换、 + 两处 publication failure、业务失败保持非零、退避封顶、真实原生关闭 + 后 unknown 与复用 fd 保全,以及私有 offload task 取消后的回执债务。 + lifetime/bootstrap 合跑 33 passed,精确架构 44 passed;12 个 managed + 模块 mypy、Ruff、文档 6 项、依赖图新鲜度与 diff whitespace 通过。 + 真实 detached Coding 在首次接线后 4 场景通过;回执债务语义补齐后 + 重新执行其四种场景仍为 4 passed(52.20 秒)。无完整交付门禁或 + installed/signal/SSH 性能已完成的声明。 + +## 23. Linux 进程运行壳 + +`bootstrap.run_process()` 为已经 open、bind 的实例提供专用子进程的一次性同步进程 + 运行壳,实现在可选 `_process.py`。创建 Runner 或改变 signal handlers + 之前,在同一 mutex 内拒绝非 Linux、非主线程、已有 running loop、未 + bind、closing、已有 lifetime 或已有 process owner;先发布 process + owner,再进行原生初始化。不承担 open/bind 前置失败的资源所有权, + 这些仍由可信 composition 保留。它接管直到 OS 退出的信号策略,返回后 + 可信 entry 仅做最终诊断和退出,不继续承载其他应用。可嵌入调用使用 + 不改变信号的 async run()。没有默认路径发现或 CLI 激活。 + +分开两类结算:child+控制资源由原 lifetime 驱动,Runner、进程等待任务 + 与保护安装未知由 process 壳持有。lifetime 仅观察前者;对外 + cleanup_pending 同时包含二者,避免相互等待造成死锁,也不通过漏报 + 进程债务来规避。通过受控 loop factory,loop 在返回 Runner 前已经 + 登记到 process owner;初始化失败保留原 Runner/loop 与 unknown, + 不创建第二个 loop、不把 partial setup 当作已清理。 + +在 Runner 初始化之前逐项安装进程策略,不保存、不恢复旧 handlers。 + 正常返回后保留该策略是刻意行为,不是未清理资源。HUP 忽略;INT/TERM handler 只记 + sticky stop 并唤醒 loop,不做阻塞清理。loop 最多保留一个 signal-close + 任务,调用既有 child.close;重复信号不升级、不刷新在途预算。 + 此任务失败只记录失败,后续应用清理由既有 lifetime 重试。 + handler 在 loop 关闭窗口不再唤醒已关闭 loop。 + +首次 driver 前已收到停止信号,或 signal 安装部分失败时,先请求 child + stop 再启动等待,不短暂接客。本节把“启动前终止”定义为返回 1(未达 + ready),已接客后的正常优雅停止返回 0。partial install failure 保留 + 保护能力未知,先结算应用与 Runner,再进入维护,不返回退出回执; + 各项安装互相独立,不因其中一项失败跳过其余。 + +使用同一 retained loop 等待;不用 Runner.run 的缺省二次 SIGINT 升级 + 行为。普通等待取消后重新加入同一 lifetime,不取消应用或关闭 loop。 + 仅在 lifetime 返回且 signal-close 已结束之后,才关闭 Runner。 + 等待 signal-close 的外层任务取消时,重新加入原在途任务, + 不把取消吞成可关闭 Runner 的结果。Runner.close 可能部分完成,失败 + 不盲重试;初始化未知同样保持保护。进程策略持续到 OS 退出,没有 + 短暂恢复默认终止行为的窗口,也不依赖只能屏蔽调用线程的 + pthread_sigmask。如果操作系统 + 拒绝保护 setter 本身,不能承诺驻留保证,但仍不返回干净结果。 + 应用已结算而壳未知时进入同步低频维护等待,不 + 复活 loop、不返回成功;异常退役策略仍未获准。原生进程/scope 退出 + 仍须由外部观察者确认,不由本返回值推导。 + +真实 Coding 辅助程序使用此同步入口运行真正的主线程事件循环,模型 + 注入仍是可信库调用;测试控制独立在线程中,显式 join 后才发送 D。 + 正常路径不由 helper finally 补 Product 清理;控制器故障用明确的 + 自进程 TERM 请求停止,不新增生产测试 hook。测试用 exact pidfd 注入 + HUP、INT/TERM,不使用可能复用的数值 PID 对远端子进程发信号。 + 当前 uv Python 未暴露 pidfd_send_signal,测试使用 libc 同一 API, + 没有将这一环境适配加入生产终止权限。 + +专用进程策略的设计经三视角同意,替代“暂借再恢复 handlers”的草案。 + 确定性测试在挂起 close 内直接触发重复 handlers,验证同一任务、 + 原预算与一次清理,不依赖标准信号是否合并;独立真实子进程保留 + 未屏蔽信号的并存线程,在正常返回、初始化/关闭/安装故障后的状态 + 下逐次注入 HUP、TERM、INT。测试退出码 7/9 仅用于结束测试控制器, + 不是生产结算或异常退役证明。 + +本节设计与实现三视角复审通过,关闭外层取消误吞、重复信号证据不足、 + 多线程恢复默认 handlers 窗口三项问题。最终 process 确定性 12 项、 + 并存线程真实信号 4 项、实际 Coding detached 6 项合跑 22 passed + (60.15 秒);13 个 managed 模块 mypy、相关 Ruff、文档 6 项与 + diff whitespace 通过。精确架构 44 项及依赖图新鲜度此前已通过, + 本次信号策略调整未增加模块依赖,未重复运行该批架构测试。 + 随后 admission/bootstrap/child/files/invocation/lifetime/handoff 与 + Coding launch 相关回归合跑 183 passed(11.83 秒)。这些分组记录 + 不替代完整 change-aware、安装或性能验收。 + +本节是可复用进程运行壳及真实信号组合证据,不是完整 installed CLI + 或 SSH 验收。内部 Coding 参数入口、默认目录/catalog/writer、公共 + EnsureStarted、管理协议、完整 TUI 与最终安装/性能门禁仍需完成。 + +## 24. Session writer 准入与接线顺序 + +现有源码复核:lifecycle.create/restore 在 Product runtime bind 后才创建/ + 加载 UnitOfWork;_dispose_owned 在 Product dispose 成功后改变 owner + 状态。Graph construction/rollback 只转移该 lifecycle session,不需要 + 重建锁。factory 的 import_bundle、fork、_create 在 lifecycle.create + 之前发布附件,Product.delete_session 则在 JSONL 删除之后清理附件。 + 因而不能只在 create/restore 或 Hosted adapter 中加锁就启用共享默认库。 + +底层候选为 Harness transcript 可选 `writer_lease.py`,不导入 AppHost、 + Coding 或 Hosting;Linux first,其他平台显式 unsupported(现有非受管 + 持久入口此时不接线,不改变其兼容性)。逻辑授权绑定规范化 Session root + + Product ID + conversation ID,私有 `.transcript-writers` 下使用带版本的 + SHA-256 锁名;不使用 scope、Mux 名、文件 basename 或 PID 作为身份。 + 物理锁仅按 conversation ID 散列:现有 JSONL、tombstone、附件均未按 + Product 分根,所以同根同会话即使 Product 不同也必须竞争。Product + 仍是权限校验的一部分,不能由竞争成功替代 header/runtime 校验。 + 另一个物理冲突域是附件:resolve_session_blob_data_root 取 Session root + 的 parent,同父的兄弟 Session roots 可能共享 session-assets authority。 + 本底层 lease 仅提供单根 transcript 排他,不宣称跨根附件互斥;完整 + 接线须增加以实际附件 authority 为键的有序联合占用,或在明确受管 + profile 准入中拒绝未协调布局,不可静默改变旧入口合法布局。加入 + 兄弟根同 ID 并发 import/fork/delete 的负测,未覆盖前不启默认共享库。 + +lease 对象先构造、保留,再 acquire;构造只做纯参数检查,acquire 才解析 + 根并打开资源。不创建 Session root,不修改 transcript、header 或附件。 + 根可为用户所有且不可被组/其他用户写入的 0755 目录,不擅自 chmod; + 私有 owner 目录严格 0700、普通单链接 lock 严格 0600。保留 root/owner + dirfd,锁叶使用 no-follow/nonblocking/CLOEXEC;核对 UID、类型、模式、 + inode 及路径绑定。稳定锁文件不删除、不换名、不按 PID 清理。 + +Linux flock 为非阻塞独占,busy 不排队,最后一个继承/复制的原生 fd + 关闭才由 OS 释放锁,不能按父 PID 已退出推断;不占用 + journal 短期读写锁。acquire 原生效果失败时保留已取得的资源,只有明确 + 未取得锁的 busy 允许 caller 关闭该 owner 后另开一次准入。check 在当前 + PID、已取得且未开始关闭、路径/原生身份仍匹配时成功;fork 后继承的 + Python 对象不能被当成新的 writer 权限。acquire/check/close 串行, + 一次准入、关闭永久 fence;上层先结算所有写任务,再关闭 lease。 + fork 子端普通方法拒绝;另提供仅子端主线程可调用的 close_inherited, + 不获取可能由消失线程持有的继承 mutex,只关闭本进程继承 fd 副本, + 绝不 LOCK_UN,不影响父进程继续持锁。CLOEXEC 只保证 exec 后不继承。 + 该 disposal 仅支持已经成功 acquire 且尚未开始 close 的稳定 fork + 快照;acquire/close 在途时 fork 可能得到未登记 fd 或已复用的旧编号, + 子端保留 unknown 并拒绝盲关,不宣称清理完成。父端在途写任务也必须 + 按上层 fork 准入约束处理,本 primitive 不把 fork 变成安全的 runtime 克隆。 + close 是 owner 的显式动作, + 只关闭保留句柄、不发送信号、不解锁后继续持有旧 fd;原生 close 抛错 + 后保留 unknown,不盲重试可能已复用的 fd。持久改写只能使用同一已准入 + owner 的受限投影,不能把“知道锁路径”当作授权。 + +后续完整接线必须一起覆盖:恢复加锁后重读并校验 header;创建/导入/ + fork 在任何目标附件发布前持有目标 owner;runtime/Graph 全寿命连续 + 持有;失败/取消结算后最后释放;delete、migration、低层 Store 改写与 + 附件维护参与相同 writer 权限。只读快照不持独占锁,低层 repair load + 不能伪装为只读绕过准入。底层 lease 测试不替代这批全入口验收。 + +验收先覆盖同进程及两个真实进程竞争、不同身份并发、释放/进程退出后 + 重开、fork 子端无写权限且关闭副本不解父锁、父退出但子尚留 fd 仍 busy、 + 无 Session 文件修改、读锁不受影响、别名同锁、软/硬链接与目录 + 替换拒绝、原生关闭未知及 fd 复用不误关。后续 Embedded↔Hosted 与 + Graph/附件/默认 catalog 激活矩阵仍按 §6,不缩减目标。 + +本切片已经实施到可选 writer_lease 模块,设计与代码三视角复审通过。 + 纠正 Product 逻辑身份与物理互斥域混用、fork 继承描述符释放语义、 + 在途关闭快照可能误关复用 fd,以及 sole-holder crash 证据缺口。 + 15 项确定性回归加 5 项真实进程场景合跑 20 passed(1.06 秒), + Ruff/mypy 通过。实施前 lifecycle/factory/Product-session 基线 + 11 passed(1.59 秒);这些入口未修改、未重复执行基线。初版路径 + 替换测试因 fixture 默认目录权限含组写而提前被拒绝,改为显式 0700 + 后验证预期路径替换,不放宽生产权限校验。底层模块未导出到 facade, + 未接普通持久入口,不据此声明全入口 writer 或默认共享目录已启用。 + +## 25. writer 接线前的 Store I/O 结算 + +实际 FileConversationStore 的 create/load/scan/page 直接 await to_thread; + append/batch/delete 使用 shield,但取消 waiter 仍可先于 native callable + 结束返回。此时 lifecycle 的异常清理可能结束 runtime,未来再释放 writer + 就会与在途写入竞争。不能把 shield 本身当作线程结算证明。 + +先修 Store 的共同 I/O 等待边界:每次原生操作发布唯一受保留的任务, + publication gate 成功后才允许进入 to_thread。调用者取消不取消这个任务, + 重复取消仍加入同一任务,待其结束后才传播取消;原生操作本身失败优先 + 保留其错误。create/load/append/batch/delete/scan/page 走同一边界,不 + 更改同步 journal 锁、数据格式、幂等 operation ID 或线程池配置。 + 独立的原生完成回执不因内部 offload Task 被取消而失效;此时继续等 + 原 callable 的回执,不重发、不把 Task.cancelled 当作线程完成。 + 原生提交已开始但永远没有回执(包含内部提交结果未知)时保持 pending, + 不释放后续 writer。取消/timeout 均不是原生执行期限,不以忙循环等待。 + 这是公共等待者取消的结算合同,不承诺在 event loop 被外部销毁时还能 + 提供退出证明;上层仍必须保留 loop 直到所有受管调用结束。 + +聚焦验收以真实线程 barrier 覆盖上述七个入口,取消后调用尚未结束、 + 同一 callable 不重发,释放 barrier 后才得到取消结果;另覆盖业务错误 + 与取消竞争、publication 失败无原生效果,以及实际 JSONL 创建/追加 + 的末次写入发生在生命周期 disposer 之前。通过后再实施 writer 的 + 全寿命与全写入口接线,不将本修复当作默认共享库已经启用。 + +设计与实现三视角复审通过。原七入口 barrier 负测均复现失败,修改 + 共同等待边界后七项通过;增加取消/业务失败优先级、独立原生回执、 + publication 失败、contextvars 与真实 JSONL 创建排序后,与原 Store/ + lifecycle 47 项基线合跑 60 passed(18.10 秒)。补齐实际追加排序后, + 聚焦文件 14 passed(0.85 秒),未重复未改变的 47 项基线。实际创建 + 和追加均证明 write_complete → native_return → disposer,并保留已 + 提交磁盘内容。Ruff、该 Store mypy 与文档 6 项通过;不将局部证据 + 外推为整个生命周期取消安全或 writer 已全入口接线。 + 追加排序测试也经交互/验收视角确认;直接受影响的 transcript session/ + factory/Product/runtime profile、Coding Session manager 与 Hosted local + ownership 合跑 87 passed(6.41 秒),依赖图新鲜度通过。change-aware + 计划已重新生成,未把这些聚焦测试称为完整 check-changed 或最终交付 + 门禁。原目标下一步仍是持久 writer 生命周期与全入口接线。 + +## 26. 显式 writer lifecycle 构造 owner + +不能把 lease 仅挂到成功返回的 Session,也不能让同一已取得 lease 被两次 + create/restore 同时使用。新增可选 `lifecycle.prepare_writer(...)`:可信 + caller 传入已取得且精确绑定 root/Product/conversation 的 lease,先 + 构造并保留 preparation owner,再调用其 create/restore。准备阶段只 + 验证纯绑定和单一 claim,不创建 Product 或操作文件;claim 后普通 + lease.close 拒绝,释放权限归 preparation,失败资源不会只藏在异常里。 + preparation 先完整构造,claim 为末端提交;lease.claimed_owner 保留可 + 取回的同一 preparation,交付失败也不丢释放入口。此接缝仅支持 + persist=True、显式 session_file 直属已 claim 的规范根;key 一致仍 + 不能证明任意 Store 的物理映射,可信 binder 必须保证该 key 的全部 + 改写落在该根,不能通过反射 Store 私有字段冒充验证。 + 原 create/restore 和默认 factory 此时保持不变,不提前激活默认目录。 + +preparation 持有唯一构造任务、取得的 runtime binding、结果 Session 和 + 分阶段清理任务;并发或被取消的 public waiter 只加入原任务,不重建 + runtime。创建/恢复模式第一次选择后冻结。准备与关闭跨 loop 拒绝, + records、leaf_id、defer_materialization 在 prepare 时冻结,create/ + restore 不再接收可漂移参数;context 与纯 binding_input/records 复制为 + 私有稳定快照,不接受需转移资源句柄的 binding_input。模式改变拒绝。 + dispose 同步 fence 新构造,再等已在途构造结束,不取消原生 IO。 + 恢复在取得 writer 后重读 header,要求与原 Product 验证过的 context + 相同;load 后再核对实际 header。runtime key 必须精确匹配文件根和 + conversation,不把 Product header 校验转交给 neutral owner。 + +构造失败仍保留 runtime binding 与 lease,显式 dispose 按原阶段收口; + 若 binder 在开始后抛错却未返回可收口 binding,不能推断无副作用, + 保留 unknown 和 writer,不声称已清理。这一前置失败所有权仍须在 + 最终 factory 默认接线时与实际 binder 合同闭环。成功结果仍为原 + AgentTranscriptLifecycleSession,Graph 转移它时同时转移同一 writer + owner,不重新争锁;构造 root 不能绕过 Graph 对其进行释放。 + Graph 已持有结果时,root dispose 不得先 fence 再发现无权限;Graph + 接管与 dispose 共用 Session 的 ownership/fence。等待构造的 waiter + 返回结果前重验 closing,避免 dispose 已开始仍交付迟到 Session。 + +Session dispose 先等待同一 runtime disposer,成功阶段不重跑,已失败 + 阶段可按原 port 的清理合同重试;public 取消不替换在途任务。runtime + 清理成功后才关闭 writer;原生关闭 unknown 或内部清理任务取消仍保留 + 债务。关闭开始后拒绝再交给 Graph 或再次取得 Session。文件检查/关闭 + 复用 §25 的相同 settled I/O 机制,提取为 Journal 私有共同 helper, + 不复制另一套线程任务机制,不改变线程池或增加高层依赖。 + +验收覆盖真实持久创建/恢复竞争、同 lease 重复构造拒绝、错误 root/ + Product/ID、header 变化拒绝且不修改历史、Graph ownership 连续转移、 + runtime disposer 失败/迟到/取消时第二 writer 仍 busy、最后释放后可 + 恢复,以及构造失败 owner 保留和显式结算。后续附件联合锁、低层写入 +投影及 Embedded/Hosted 全入口激活仍按 §6/24,不以本显式接缝替代。 + +本显式接缝已实施并通过三视角代码复审。首次聚焦验证含新 lifecycle、 + writer 原语/真实进程、Store 原生结算和原 lifecycle,53 passed(3.36 + 秒)。复审发现内部 close Task 在原生关闭后被取消、fd 已清空时, + cleanup_pending 可能假阴性;现直接将取消/失败的清理任务计入债务, + 不等下一次 dispose 才暴露 unknown。补齐这一真实线程屏障、内部 + runtime Task 取消、原生关闭未知且 fd 复用不误关、实际 Store load + 后 header 二次核验,新增 lifecycle 文件 17 passed(0.88 秒)。 + 三视角确认修复闭环;Ruff、4 个源文件 mypy、文档 6 项与依赖图 + 新鲜度检查通过。Graph 证据仅为原 Session 内部 ownership 状态 + 转移,不称实际 Graph 组装已集成;低层独立 Store 引用和共享附件 + 尚不受本接缝保护,可信 binder disposer 必须结算其全部已接纳工作。 + 默认工厂、全入口 writer 与 lmux 开箱即用验收仍未完成,不能据此 + 执行最终发布或把完整 goal 标记完成。 + +## 27. 标准 runtime 投影前收养 + +标准 AgentTranscriptProfileRuntime 已取得 RuntimeProfileBinding 后, + selected_store/profile/key/snapshot 投影仍可能失败。显式 writer + preparation 必须在投影前取得该 binding 的清理入口,而不是仅在 + 异常里附上句柄,或尝试一次清理失败后丢弃资源。 + +在现有 lifecycle 增加可选 owned binder port:除原 context/input 外, + 接收同步的单次 retain_disposer 回调。标准 runtime 提供对应入口, + raw binding 返回后立即把其原 binder.dispose 闭包交给回调,再调用 + 共享的同步投影函数。§26 preparation 保留该 disposer;构造失败时 + 若已收养,仍按原 runtime→writer 次序清理,可重试失败阶段,不 + 重建 raw binding。正常返回仍为原 AgentTranscriptRuntimeBinding。 + 回调只接纳一次且只能在当前 binder 调用内使用,不能晚到替换 owner。 + binder finally 使回调失效,覆盖成功、异常与取消。标准入口只构造 + 一个 disposer,同步收养和最终返回共用该对象;漏掉收养或两者不 + 一致时拒绝交付,保留两个清理引用及 unknown,不盲选其一释放 writer。 + 旧 bind_lifecycle 和默认 create/restore 的返回合同不改变。 + +这个接缝解决“已返回 raw binding、投影尚未成功”的实际窗口;通用 + factory 未返回时的 partial acquisition 仍不能从异常推断无资源。 + 后续应复用原 RuntimeProfileBinding 的逐项清理账本,不能从 Harness + 引入 AppHost 私有资源栈。当前标准四种工厂只构造 Store 配置/布局、 + profile、compaction 等值,不启动外部任务或取得长期原生资源;此 + 源码事实不推广为任意替换工厂的 pure=True 承诺。 + +验收使用真实标准 runtime,注入投影错误和首次 disposer 失败,确认 + 同一 raw binding 始终可恢复、第二 writer 在完成清理前仍 busy、 + 重试完成后才释放 writer;覆盖取消 waiter 不重建 binding、非法或 + 迟到回调不能覆盖现有清理 owner。默认工厂全入口接线仍是后续工作。 + +已完成本 owned 接缝实施和三视角代码复审。改前标准 runtime/factory + 基线 8 passed(1.18 秒);新增接口负测先因 owned 方法不存在失败, + 实施后与 §26 和该基线合跑 26 passed(3.35 秒)。扩展缺失/冲突 + authority、重复/迟到回调和 public 取消后同 raw binding 重 join 后, + writer runtime/lifecycle 合跑 23 passed(0.99 秒)。投影失败保留 + 原错误且未创建 JSONL,首次 disposer 失败后的重试确实使用同一个 + RuntimeProfileBinding,未另造清理账本。Ruff、3 个源文件 mypy 与 + 依赖图检查通过。旧 bind_lifecycle 路径没有因此得到 owned 保证, + 通用 partial factory 和默认 Session 工厂激活仍待完成。 + +## 28. 先保留现有 binding,再逐项构造 + +RuntimeProfileBinder 的可选 prepare_binding 返回尚未发布 generation + 的空 RuntimeProfileBinding;caller 先保留它,再 bind_prepared。 + bind_prepared 只执行一次,每个 factory 返回后立即记入该 binding + 的原 _bound 账本,不等整个 slot 完成。全部成功后才发布可读值。 + 失败保留已返回 entries,显式 dispose 仍使用原逆序清理、失败项 + 重试与 retained disposal Task,不创建第二套清理状态机。 + +新接缝的 prepared/building/failed 不能读取或 rebind;building 时 + dispose 拒绝,构造 owner 必须先加入原构造任务。准备后即关闭则 + 不执行任何 factory;同一 binding 不接受第二次构造或其他 binder + 填充。此接缝只用 async dispose,避免混用同步/异步清理账本。 + 原 bind/bind_sync/rebind 的发布和兼容行为不改。 + +标准 transcript owned binder 改为先 prepare_binding,立即通过 §27 + 交出同一 disposer,再 bind_prepared 和投影。这样工厂后续失败也 + 不丢先前 entries。Factory 在返回前仍拥有自身尚未交付的资源: + 异常/取消前必须结算它们,不得让未受管线程或任务逃逸;这不是把 + 任意异常当作全局无资源证明。标准内置实现按实际源码没有长期原生 + 资源取得,泛型接缝的测试 factory 显式遵守该 port 责任,不能用 + 本机制为违反该合同的外部 factory 声明安全。已有独立 writer driver + 被私自取消仍保持 unknown,不据 canceled Task 推断底层工作完成。 + +验收:同 slot 第二个 factory 失败仍能逆序清理第一个;清理首次 + 失败后的重试不重复已成功项;未完成 generation 不可读、构造不能 + 重入/跨 binder、构造中不能清理。实际标准 transcript 在后续工厂 + 失败后仍保留 writer,完成原 binding 清理后才释放。标准成功路径 + 和旧 RuntimeProfileBinder 行为跑回归,不改 Product 恢复校验。 + +设计复审发现原 async disposer 先 fence closed 再创建任务,发布失败 + 未保存 pending,下一次 dispose 可误报成功。已将待清理 entries + 先登记原 pending 账本,再 fence 和通过 publication gate 发布任务; + 失败不运行 disposer,不丢重试依据。未另建清理状态机。 + +本接缝及上述修复已通过三视角代码复审。改前 runtime profile 基线 + 32 passed(0.33 秒);新 prepared/profile/owned transcript 回归 + 合跑 49 passed(2.60 秒)。实际标准 runtime 的后续 factory 失败、 + 首次清理失败仅重试失败项,以及内部清理任务发布失败保持 writer + busy 两项补测后,writer runtime/lifecycle 合跑 25 passed(2.52 + 秒)。Ruff、2 个源文件 mypy、依赖图检查通过。初次定向负测因 + 多值槽位 fixture 缺 variation semantic 而提前失败,修正 fixture + 后命中发布失败与重试断言,不把该初次失败称为原 bug 红测证据。 + 默认持久 Session 工厂和底层写入口尚未接入,不外推为完整 writer + 保护或 lmux 开箱即用交付已完成。 + +## 29. 单 Session 写权限与跨 Session 只读查询 + +FileConversationStore 的普通 load 可通过 transcript loader 重建缓存, + scan_page 又会读取多个 key;不能让整个 namespace 借用一个 Session + 的 writer。先将扫描的 snapshot loader 与普通 load 分开,保留同一 + Store 实现。Transcript 扫描和路径解析的 header 发现采用已有稳定 + no-follow 文件读取,不创建锁文件;分页使用同样只读的 snapshot。 + 其允许复用已验证索引,miss/tail 只严格重放,不修复尾部或回写缓存。 + 这是稳定字节快照,不承诺整个 namespace 的原子快照;并发变化仍 + 按原 Store discovery/load diagnostic 合同报告。 + +Catalog 的独立 Store 同样采用 read_only 投影,覆盖 scan、page 及 + Catalog 后续逐 key 的 load,并在路径回调前拒绝所有 mutation。 + 现有 index_writable 仍只决定 Catalog 自身聚合索引,不混同 Session + 内容/逐文件缓存写权限。普通单 key Session load 保留当前索引加速, + 后续在 offload 前通过中性 admission scope 借用精确 Session writer。 + dispose 顺序仍是 fence 新操作、join 构造、drain 已登记 IO、runtime + 清理、writer 关闭,不能只守 create/append 而漏缓存重建或删除。 + +只读验收要求 scan/page/load 不创建、改写或删除 Session JSONL、逐文件 + cache 或 lock,缺失/损坏索引均不自愈,有效索引仍复用;另一 Session + 处于写入生命周期时,跨 key 查询不取得它的 writer。readonly Store + mutation 无路径回调;严格格式校验、诊断及原非只读 Store 合同保留。 + 这一读投影是实际 writer 全入口接线的前置,不替代后续写入 admission。 + +本读投影已落到实际 Catalog/Directory 与 FileStore,并通过三视角 + 代码复审。缓存命中、tail 与 miss 都注入稳定 no-follow source/cache + reader 和不创建锁的策略,非单靠 write_index=False;只读 deferred + Model Input 保留本次已验证 raw bytes,后续仍懒解码但不再重开路径。 + 此处会将原始字节保留到该只读 snapshot 的 deferred source 释放, + 不改变普通可写 Session 的延迟读取行为。路径摘要/冲突发现也接入 + 有预算的只读读取,Catalog 自身聚合索引仍由原 index_writable 管理。 + +复审补齐三个遗漏:延迟 payload 重开路径、公开路径发现仍创建锁、 + leaf-open 失败后 parent-close 再失败覆盖主异常。稳定 prefix/full + 读取也改为独立尝试两个 fd 关闭、保留读取主异常且不重试不确定 fd; + leaf open 加 O_NONBLOCK,打开后身份复核拒绝 lstat 后的 FIFO 替换。 + +改前 Store/index 基线 50 passed(17.72 秒)。只读 Catalog 负测真实 + 复现生成两份 lock 与两份 cache;修复后初 10 项与该基线合跑 60 + passed(23.42 秒)。直接受影响的 catalog/discovery/file-store 与 + Coding 消费者 116 passed(35.72 秒)。复审修复后 readonly/index/ + catalog/directory 合跑 75 passed(2.60 秒);再补 FIFO 两项,聚焦 + readonly 文件 23 passed(2.52 秒)。验证包括有效缓存不严格重放、 + 新完整尾部可见且不回写、残缺尾部诊断及字节不变、活跃 writer 下 + 跨 key 读取、实际 Directory 查询全树不变、deferred load 后删除/ + symlink 替换仍无后续 IO,以及关闭双故障保留原异常。Ruff、受影响 + 源文件 mypy 和依赖图检查通过。单 Session 写入 admission、附件 + 联合权限及默认工厂激活仍未完成,不能据读投影声明 full goal 交付。 + +## 30. 实际 FileStore admission 与在途 IO 结算 + +Conversation 定义中性的 async operation_scope(target) port,target 为 + 单 key 或只读 namespace。FileStore 七个 public IO 入口在 offload 前 + 进入 scope,settled_io 真正返回或结算取消后才退出;read_only mutation + 仍在 scope/dispatch 前拒绝。Store 不导入 transcript writer、Product + 或 AppHost。namespace scan/page 沿 §29 的只读路径,不借单 Session + writer 改写其他 key。 + +§26 preparation 同一 owner 负责 scope:只接受原 loop、精确根/Session + key,namespace 只接受该根;先登记活动借用,再异步重验所持 writer, + 借用退出前不得释放 writer。关闭先 fence 新借用,join 原构造任务, + drain 已登记 IO,再原 runtime disposer、writer close。被取消的关闭 + waiter 可重 join,不能使在途操作减计数或替换其 native task;关闭后 + 晚到构造 IO 拒绝,不设置内部调用绕过 fence 的标志。runtime disposer + 不应在这个终止阶段新发 transcript 写入,最终内容保存须先完成。 + +owned binder 的第三个参数扩成一个可调用的中性绑定 owner 投影, + 保留原 retain_disposer 调用形状,同时明确携带 operation_scope;它 + 不另有生命周期状态。标准 runtime 将该 scope 放在私有构造 context + 中交给实际 FileStore factory,Product context/input 仍是纯配置,不 + 把 scope 混入 deepcopy。最终 transcript.store 与 Product binding + 中的 conversation.store 是同一受管 FileStore,不在外层包装副本, + 也不靠读取 Store 私有字段做权限认证。 + +验收使用真实标准 runtime 和 JSONL:普通主写、batch/delete、带缓存 + 副作用的 load,以及 namespace scan/page 均受 admission/寿命约束。 + native barrier 下同时取消操作 waiter 与关闭 waiter,第二 writer + 仍 busy,IO 完成才运行 runtime disposer;关闭后的原 Store 引用和 + Product binding 暴露的同一引用均不能再操作,错误 key/namespace 在 + path/native IO 前拒绝。Graph 持有时 root dispose 不 fence 其操作。 + 新接缝不等于独立未接线 Store、直接导出/维修/附件入口已被保护; + 完整默认激活仍需剩余入口与联合附件 authority 一起完成。 + +本节生命周期接线已实现。受管 writable load 的 deferred payload 也 + 保留本次已验证 raw bytes:可写索引仍可更新,但 scope 退出及 owner + 释放后不再重开路径;普通未接线 Store 的延迟读取策略未变。 + writer/runtime/native-settlement 改前基线 39 passed(1.22 秒); + 初次合跑 54 passed(3.92 秒),扩展后的独立 IO 文件 16 passed + (1.41 秒)。覆盖七入口真实 native IO、原 Product binding 同一 + Store、创建/关闭双方 waiter 取消后真实首次 JSONL materialization、 + late binder 返回后拒绝新 materialization、Graph root 不提前 fence、 + 重验成功/失败结算、runtime disposer 拒绝新 IO,以及 deferred source + 删除/symlink 替换后无重开。Ruff 和七个源文件 mypy 通过。 + +本节当时的三视角代码复审尚未整体通过(后续修复见 §31.2):架构边界通过;测试视角指出原 create + barrier 假调用不足,现已改为真实首次创建并补 owner.create 专项; + 生命周期视角另发现下面的物理 root 绑定 P2。这里的 scope 只已证明 + 精确逻辑 key 与生命周期,不等于原生文件打开已绑定同一物理目录。 + +## 31. 实际 IO 与 retained root 的物理绑定(受管 Store P2 已修复) + +writer.check 与 FileStore native IO 分属两次 offload。检查结束后若 + 原 Session root 被 rename 并在原位置新建目录,新 writer 可成功 + 锁住新 root;旧 Store 的 pathname 操作也会落入新 root,绕过原先 + 的物理互斥。再多一次脱离实际打开的 pathname check 不能消除竞态。 + +修复前新增 test_writer_root_binding.py 使用独立临时 Session 树、真实标准 + runtime 和第二个 writer,确定性暂停在 check 已结束/native IO 未 + 开始处,替换 root 后继续。append/delete 两项真实负测均失败 + (1.11 秒):新 root 的 JSONL 分别被修改/删除。测试未标 xfail, + 保留为修复验收;最初夹具目录权限不合要求的失败不计作竞态证据。 + +该 P2 是默认激活和发布前的必修项,而非额外扩大功能。修复须让实际 + JSONL、journal lock、Store head/create metadata、tombstone、Model + Input index 及其临时文件/replace/unlink/fsync 使用同一 retained root + authority,所有打开/写副作用均不得重新解析到替换 root。不能只保护 + JSONL 或用 /proc 路径字符串重定向冒充完整 fd-relative 合同。 + 既有逻辑 key/API 和 Session 默认策略保持不变,底层机制与 Product + 和 AppHost 解耦;§30 IO drain 保证这份物理 authority 的借用寿命。 + 修复后需重新执行两项负测、七入口 IO 回归及受影响的 journal/index + 检查,再完成三视角复审。当前分支不得据前述 54/16 项局部通过而 + 声明已可合并发布。 + +后续实现边界已按生命周期复审细化:从 writer 已持 root fd 派生中性 + descriptor-relative IO port,保留原 dev/ino,Store 仅借用、不关闭; + scope drain 后才由原 owner 释放。相对 child name 必须校验,固定 + 子目录逐级 no-follow 打开;Path 仅作逻辑映射和诊断,不再作受管 + 分支实际 open/replace/unlink/mkdir/fsync 的授权。已经接纳的操作 + 可在原 pinned root 完成,后续 admission 再拒绝失效 pathname。 + 不使用 chdir、/proc 字符串或第二套 Store,也不改变未受管旧分支。 + +优先复用 journal_file_lock_at/read_journal_file_at 的 dirfd 打开规则, + 补齐普通 Journal append/load/write/repair 与 FileStore unlocked + helpers 的显式传递。sidecar 原子写须在同一 retained parent 中以 + 随机名 O_EXCL/O_NOFOLLOW 创建、写入/fsync、同目录 fd replace、 + parent fsync;临时清理也使用同一 authority。tombstone 的首次与 + 幂等重试删除、index 命中/重建/尾部更新/删除、layout 发现与目录创建 + 都在覆盖清单内。JSONL、锁、head、tombstone、index、replace 和 + 临时清理前各自插入 root 替换 barrier,验证新 root 全树不变,而非 + 仅检查 JSONL 内容。这是实现前边界说明,尚非已通过的新设计验收。 + +### 31.1 Rooted IO 与 Journal 首切片 + +本首切片经三视角设计评审后实现。journal/_rooted_io.py 提供 Linux + RootedFileIO:借用现有 root fd,canonical Path 仅作词法映射,不 + close 借入 root、不 resolve/chdir,也不调用 pathname IO。一次 + bind 产生仅在该事务内有效的 RootedFile;锁、数据、rewrite 和 + partial-tail repair 共用同一个 pinned parent,不能各自再遍历 + 子目录。Journal 增加显式可选 file_io,拒绝同时使用仅支持 Path + 的旧 lock_factory,默认旧入口不激活此分支;rooted 类型只做静态 + 导入,避免普通启动多加载 native adapter。 + +原 operation ledger 记录自己打开的 fd、独占创建的临时名/原生身份、 + 临时 unlink/replace 后待同步目录。close 先记 unknown 再调用,失败 + 不重试数字 fd;每个独立 fd 均尝试关闭。临时清理失败保留 parent, + unlink 成功后只重试 fsync,不能重删后来同名文件。该 ledger 的 + cleanup_pending/cleanup 供原生命周期 owner 在 native drain 后 + 检查/结算,首切片时尚未接入 writer(后续见 §31.2),不能据单个原生异常推断已无清理债务。 + Port 没有后台任务、root 所有权或自动 admission;借用寿命仍必须 + 由 §30 原 owner 管理。所有 bound-file 方法和 cleanup 均拒绝 fork + 子进程,不能利用继承引用绕过原 owner 的进程边界。 + +首轮已有 Journal 基线 19 passed(0.41 秒);加入 rooted 首测合跑 + 47 passed(0.56 秒)。代码复审修复临时删除缺 parent fsync、嵌套 + parent 替换时锁/数据分裂、新 bound reference 缺 fork 拒绝,以及 + 测试树快照未覆盖 root 本身/mtime。补齐后 rooted+Journal 合跑 + 56 passed(2.30 秒);再加入真实 fork 负测,38 项 rooted 与直接 + FileStore/index/readonly/writer 消费者合跑 129 passed(3.50 秒)。 + 另一次消费者命令路径拼错导致 no tests ran,不计验证证据。Ruff + 和两个源文件 mypy、文档轻检查及依赖图检查通过。三视角代码复审 + 已确认本首切片 P2 闭环;这一结论不扩展到尚未接线的消费者。 + +验收覆盖真实 Journal load/append/batch/rewrite/repair;root 与加锁 + 后 nested parent 替换;实际 open/write/replace/unlink/fsync/flock + 边界替换后新目录全树 bytes/mode/inode/mtime 不变;symlink/FIFO/ + hardlink 与越界拒绝;临时名冲突不取得清理权;写入/文件同步/ + replace/目录同步失败;unlink 后重试仅同步与 replace 成功后不能 + 删除新同名临时文件;未知 close 保留主异常、独立回收且不重试; + 独立进程锁 busy→释放→acquired;真实 fork 拒绝继承引用而父继续 + 工作。首切片时原 FileStore 根替换两项失败尚未修复:其 layout、unlocked + helpers、head/tombstone/index 以及 writer 清理债务接线是下一步, + 本首切片通过不等于 §31 总 P2 已关闭或 lmux 已可发布。 + +### 31.2 实际 Store/Index/Layout 与 writer 清理接线 + +本次接线通过三视角代码复审,关闭 §31 所述受管 FileStore 的物理 + root 替换 P2。标准 owned binder 的实际 FileStore、layout 与索引 + 都接入同一 RootedFileIO;JSONL、锁、head/create metadata 在同一 + bound Journal 事务内操作,unlocked helpers 显式传递 bound_file, + 不重新 bind/加锁。新的受管 Store 仅接受 root 的直接 JSONL child, + 与原 prepare_writer 对 session_file 的限制一致;不收紧旧未受管 + layout。Journal factory 若未返回同一 root IO 会拒绝,不默默回退。 + +路径映射不再 resolve 或 mkdir 替换根;stat/exists、预算内 namespace + 发现和 header prefix 都从保留 fd 读取。只读 scan/page 不写逐文件 + cache/lock。可写 keyed load 的有效索引、strict replay、tail 扩展 + 和原子重建也共用同一 parent;deferred payload 仍保留已验证字节。 + cache 删除、tombstone 和各类临时清理不借助 Path 原生 IO。 + +delete 在整个操作内固定 tombstone parent。RootedFile.atomic_write + 成功返回前同步目录,确保新 tombstone 先持久化再删除正文;幂等 + 分支也显式同步匹配的已有 tombstone parent。后一项修复复审提出 + 的崩溃窗口:前进程可能停于 replace→fsync 之间,目录项可见不等于 + 已持久化。同步失败以 unknown 结果保留正文及同步债务,不能继续 + unlink。已 unlink 的临时文件只重试目录同步,不删除同名 replacement。 + +标准 owned preparation 在最终 claim 前构造借用原 writer fd 的 port + 投影(不新增 root fd);将它与 scope 一起传给原 runtime factory。 + 原 owner 在 IO drain 后按 runtime→port cleanup→writer 收口,保留 + _io_cleanup_task,同一 waiter 取消后重 join;失败保留原 port/账本, + 不释放 writer。owned restore 的前置 header 检查也使用 fd 只读路径; + 此标准受管路径目前拒绝定制 pathname-only header_loader,不能用 + 它绕开 root 授权。旧非 owned 工厂/定制路径未因此改变或自动激活。 + +新增预算目录扫描的 iterator 亦进入原 operation ledger:得到对象后 + 立即保留,关闭前记 unknown,一次 close 失败不盲重试对象;其他 + fd 独立关闭,遍历主异常保留。未知 iterator 关闭债务可见于原 writer + owner,重复 dispose 仍不能宣称释放。不是仅抛出异常后忘记资源。 + +修复初次原两项负测转绿,与 writer/rooted IO 合跑 56 passed + (4.54 秒)。旧 FileStore/index/readonly/writer/Journal 消费者 + 94 passed(2.35 秒)。三视角修复及增强负测后相关集合 167 passed + (4.00 秒);再补 owned restore 两项,root-binding 文件 21 passed + (1.78 秒)。Ruff 和九个受影响源文件 mypy 通过。主要证据: + +- 原两项不再忽略任意异常,严格校验成功回执、旧 root 记录/删除结果 + 和新 root 全树 bytes/mode/inode/mtime。 +- 七个实际 Store 入口在 native 前替换 root,禁用对应树的 Path + 原生 IO;返回值/diagnostics 及旧 root 内容准确,新 root 全树不变。 +- 四种索引状态 missing/corrupt/valid/tail,验证重建次数、记录和新树 + 不变;有效索引不重建,其他三种仅一次。 +- 原生临时清理失败时实际 writer busy;修复后取消/rejoin 同一清理 + task,只清理原债务,不重放业务,最终才释放 writer。 +- 首次/可见旧 tombstone × 同步成功/失败四组合证明先同步后删正文, + 同步失败正文保留;iterator-close unknown 多次 dispose 仍保锁。 +- 标准 owned restore 正常恢复;在 header IO 前替换 root 时 header + 仍读原 fd,新 root 不变,随后的 Store admission 拒绝失效路径。 + +此 P2 关闭限定于已接线的标准受管 Store/Journal/Index/Layout 与 + preparation;不等于所有默认 Session factory、导入/迁移/维修/ + 附件写入口都已有联合 writer authority,更不代表 lmux full goal + 已完成。默认激活、共享附件联合权限和完整交付门禁仍在剩余范围。 + +## 32. 图片发布的 Session 寿命与提交不确定性 + +调用链复核确认:ProductTranscriptSession 的 Agent/Application 图片 + 发布原先早于 Store admission,Session 已关闭后仍可构造 BlobStore、 + 读取 manifest 并发布图片。本轮复用原 writer preparation 的 scope, + 通过 LifecycleSession.operation_scope 从 BlobStore 构造前覆盖到 + 正文提交或失败回滚结束。没有新建 owner;旧 unowned binding 为 + no-op,Graph 的真实 owner 仍决定关闭。内部 Store scope 不绕过 + closing:关停先于正文准入时可拒绝,在外层 scope 内回滚后才 drain。 + +三视角设计/代码复审同时关闭一项数据完整性 P2:正文已落盘但回执 + unknown,随后重试被关闭拒绝,原 UOW 会改抛 closed;Product 据此 + 回滚图片可能破坏已持久化引用。append、batch、首次 materialize + 现在保留首次 unknown,直到恢复返回值完整校验通过。恢复抛错、 + 取消或返回非法 revision/record ID/batch size/snapshot 都不把它 + 降为未提交。未先收到 unknown 的非法提交回执同样表达 unknown, + 仍为 RuntimeError 子类。图片回滚遇到 unknown 或本地 transcript + revision 已变化时保留字节并记录异常 note;后者覆盖提交已完成而 + observer 抛错的情况。revision 变化仅用于保守保留,不是删除授权。 + +普通 public cancel 沿用 UOW 已有 cancellation-atomic commit;没有 + 再增加 Product task owner。确定未提交的等待取消/关停拒绝仍走原 + compare-and-rollback;其失败合同未改为自动强制清理。保留的图片 + 不是已完成回收的临时文件,后续对账/显式维护仍需判断真实引用。 + +验证先跑已有基线 32 passed(1.85 秒);新回归最初 5 failed,外层 + scope 后为 3 passed/2 failed,定位到 unknown 被 closing 覆盖。 + 修复并扩充后,本文件 24 项与直接消费者合跑 98 passed(4.09 秒), + 四个源文件 mypy 与 Ruff 通过。一次新 fixture 使用默认 mkdir + 权限遭当前 umask 影响被安全准入拒绝,已改为隔离的 0700 Session + 子目录,不计为产品回归。最终三视角复审通过本切片,证据包括: + +- Agent、Application 及转发入口关闭后,BlobStore 构造前即拒绝。 +- 图片已发布、等待 commit 锁时关闭/重复取消;原 writer 保持 busy + 直至回滚完成,关闭 waiter 取消后可重新 join。 +- 真实 native append 开始后取消,已接纳提交成功优先,持久图片可读。 +- 真实写入后 unknown,关停阻止重试;正文与图片保留,异常仍 unknown。 +- append/materialize 坏恢复回执六组合、batch 坏回执/拒绝四组合, + 验证原 unknown 对象、真实持久记录以及相关图片字节。 +- observer 失败不删已提交图片;Graph 接管后 root dispose 不提前 + fence,重复 Application message 仅一条正文,真实 Graph dispose + 后才拒绝新图片入口。 + +### 32.1 下一步联合权限(设计约束,尚未接线) + +本切片只证明附件操作的 fence/drain,不证明附件 IO 已 pinned, + 更不证明跨根互斥。SessionBlobStore 当前仍通过 pathname 访问; + 不能因此激活全部默认工厂。架构复审确定下一步两个物理锁域: + transcript-root + 原 conversation ID,以及 data-root/session-assets + + session_blob_authority_id(ID)。附件锁域不加入 Product 或 Session + root,以覆盖兄弟 Session roots 的共享附件。 + +附件 lifetime 锁须独立于现有每操作 shared/exclusive 锁且位于不会 + 被单 Session delete 删除的目录,避免自锁。原 preparation 保留 + 两份 lease,固定顺序非阻塞准入,失败保留并结算已取得的资源; + 原 scope/drain/cleanup 完成后释放。实际 object/manifest/import/ + rollback/delete 还须接入借用 retained root 的 IO,不能只加锁再 + 重复 §31 已修复的 pathname 替换窗口。导入/fork 在发布前取得联合 + 权限,delete 持有到附件处理结束,工具输出/模型输入图片入口同样 + 纳入后续接线;不从完整 lmux 目标中移除这些入口。 + +## 33. 共享附件 lifetime 锁与原 preparation 联合准入 + +本轮把 §32.1 的锁机制与原 owner 接线实现为显式可选路径,通过 + 三视角设计及代码复审。没有更换 Session 默认目录或激活入口。 + +原 TranscriptWriterLease 的 Linux fd/锁/身份检查/one-shot close + 机制抽为 Journal 私有 DirectoryWriterLease。Transcript 薄层保留 + 原参数名、错误类型/前缀、`.transcript-writers` 和完全相同的 hash + 输入字节;原生 flock 回归验证与旧锁互斥。Artifacts 的私有 + SessionBlobWriterLease 依赖同一中性机制,不反向依赖 Transcript。 + 它锁定 data_root 下 `.session-blob-writers` 中的规范化 Session + authority,不把 Product/owner 加入锁键;与现有短操作锁分离且 + 不随单 Session 附件删除而消失。constructor 不做文件 IO,缺少 + data root 时准入失败,不隐式 mkdir 或修改已有权限。 + +`prepare_writer(..., manage_blobs=True)` 在原 preparation 纯构造 + 阶段内部创建并独占保留第二 lease;不存在调用者共享的第二 owner。 + 正文已持有后,原 build driver 在 binder 前通过 settled IO 执行 + blob acquire→借用 root port→claim。任一步失败均保留同一对象, + 第二次 create 加入原失败 driver,不重试获取或重建资源。外部取消 + 不遗弃 native acquire;dispose join 原 driver 后再结算部分资源。 + +scope 通过同一次 settled IO 复核正文与附件两域。关停次序为 + drain→runtime→两个 root-port cleanup→blob lease→正文 lease。 + 两个新增清理阶段各保留原 task,waiter 取消可重 join;成功阶段 + 不重复,blob close unknown 不盲重试数字 fd,也不继续释放正文锁。 + 原 claim/recovery/fork 语义保留在共用机制中。 + +验证:提取前 native writer/process 基线 20 passed(0.92 秒)。 + 首次提取测试 53 passed/1 failed,失败是旧 fork helper 故障注入 + import 未同步到新 core,修正后该真实 fork 用例通过。机制、联合 + owner 和图片相关集合 83 passed(4.23 秒);补 borrow/claim + 中途失败和 blob-port cleanup 重试后,与真实 Store/root IO/旧 + BlobStore 消费者合跑 58 passed(2.46 秒)。两集合存在重叠,不 + 相加作为独立用例数。Ruff 与五个源文件 mypy 通过。 + +主要新增证据:实际 sibling Session roots 映射同一 data root, + 跨 Product、legacy/已规范化 ID、canonical 别名均互斥;不同 ID/ + data root 并存;独立进程 busy→close→held;持 lifetime 锁期间 + 实际 BlobStore 构造/读写/rollback/delete 不自锁且保留锁 inode; + 目录/锁替换拒绝;第二锁 busy 或 borrow/claim 失败不调用 binder、 + 不生成 JSONL;取消 acquire/close 后加入同一 driver;blob-port + cleanup 失败保留两域,重试成功才释放;blob close 成功不重复; + close receipt 丢失后的复用 fd 不被重关,正文 writer 保持 busy。 + +仍未完成:SessionBlobStore 的实际 manifest/object/rollback/delete + 尚未消费该 retained data-root port,`manage_blobs` 只提供联合 + lifetime 准入与结算,不能据此宣称 pathname 附件 IO 已安全。 + 下一步须将 port 接到实际附件 IO,再接导入/fork/delete/工具输出/ + 模型输入和默认 Session 工厂;完整 lmux 目标及最终交付门禁仍待 + 完成。本轮不以仅有互斥锁替代这些要求。 + +## 34. 实际附件 IO 与原 owner 恢复账本 + +本轮推进 §33 的可选路径:SessionBlobStore 接受原 preparation 的 + retained data-root port,实际 Product 的两条图片写入路径在已有 + operation scope 中传递同一 port。未切换默认工厂,未改变 Session + 路径、manifest 格式或旧 pathname BlobStore 行为。 + +Artifacts 私有 BlobTransactionIO 负责 Session/manifest/object 语义; + Journal 的 RootedDirectory/RootedFile 只提供 fd 相对操作及通用 + 恢复账本。每次事务固定 assets、短锁目录、Session 和 objects, + 加锁后复核各层命名身份。整体 data root 改名后继续访问原树, + 已固定的内部子目录被替换则拒绝,不转向新树。 + +对象 O_EXCL 创建、写入与同步完成前,由原 operation 保留确切 + inode 收据。初始 manifest 独占发布,后续 manifest 原子替换。 + 恢复先耐久还原旧 manifest,再删除本次未引用对象;恢复也失败 + 时保留旧内容、原目录、对象收据与原短锁,不遗弃孤儿对象。 + 批量对象及有界整树删除在第一次 unlink 前登记完整计划,成功 + unlink 后只重试父目录同步,不误删后来出现的同名对象。完成的 + manifest 恢复不因后续删除失败而重放。 + +恢复投影由每次回调独有 token 限制,只在原 cleanup 回调的同一 + 线程有效;后续重试不会使之前回调的投影重新有效。原借用在退出事务、 + 首次自动 cleanup 前即失效;drain 的 active 状态独立保留到原生 + 清理结束。恢复不会重新激活旧引用,也不另造 owner。 + +三视角复审修复了双重发布失败时恢复收据丢失、批量首删失败时 + 后续对象责任丢失,以及恢复期间释放短锁的问题。随后生命周期 + 复审发现:B 阻塞等待 A 保留的短锁,会让 drain 阻止 A cleanup。 + Rooted 短锁因此统一非阻塞准入,busy 明确失败且不等于提交成功; + `blocking` 仅保留通用调用形状,不承诺 rooted 无限等待。旧 + pathname Journal 锁语义不变。已有提交结果未知仍保持 sticky。 + +本轮验证覆盖实际 BlobStore 读写/检查/import/rollback/delete 的 + root 替换、加锁窗口子目录替换、符号链接与遍历预算拒绝、重复 + 故障不累积对象、双重恢复失败、整批删除、删除后同步失败、独立 + 进程短锁竞争,以及真实 Product 的两 lifetime lease 保留与清理。 + 额外双线程回归固定 A 持锁→B 尝试→A 留恢复债务→B busy 退出→ + 原 owner cleanup 完成的顺序。三视角局部复审通过,不扩大为 + 完整 lmux 或默认激活通过。 + +最终相关集合(rooted BlobStore、旧 BlobStore、rooted Journal、 + 实际 Product 图片/双 lease、writer IO/root binding、FileStore + settlement)157 passed(5.01 秒);补每回调 token 失效后同集合 + 再次 157 passed(11.94 秒),两次不相加,也不作为性能比较。 + 新增与修改文件 Ruff、四个 + 源文件 mypy、轻量文档检查、当前包依赖图检查和 diff 空白检查 + 通过。这是本切片证据,不替代最终 change-aware 全部适用门禁。 + +仍待完成:其它导入/fork/delete/工具输出/模型输入消费者与默认 + Session 工厂接线,生产服务入口/公共自动发现、完整 Harnesstui + 集成、真实安装/断连重连/首次使用性能及最终交付门禁。当前 + 可选 BlobStore 的原语测试不替代这些消费者端到端验收。 + +## 35. 同步上下文与 Model Input 图片消费者 + +实际 Product 的 `build_session_context` 与 Model Input binary codec + 构造现在使用原 preparation 的 blob port;读取不再回退 pathname。 + Codec 只接受通用同步 context-manager factory,不依赖 writer + preparation 类型,不保存 transaction-local 文件或目录引用。 + 每次 externalize/hydrate 都重新准入,空 mapping 和缓存 references + 不绕过检查;构造成功的 codec 不等于长期获准使用已关闭会话。 + +原 writer 提取共享 admission(同 loop、closing、target、active + 计数与 drain)。异步调用仍通过 settled IO 检查两域 writer, + 同步调用直接检查,沿用同一 owner,不新建生命周期。显式 owned + 同步消费者只允许在原运行 loop 调用;无 loop、其它 loop/线程 + 在触及文件 IO 前拒绝。旧 unowned 路径保留原行为。 + +准入覆盖 BlobStore 构造与完整 hydration,且在图片降级处理外部。 + 关闭/失效绑定不被吞成图片缺失;短锁 busy 直接传播,不再伪装 + 为缺失图片或完整性错误。普通损坏图片仍降级为占位文本。Graph + 接管后 root dispose 不 fence,真正 Graph close 才使旧 codec + 失效。同步接口仍可能占用 event loop,本切片没有性能提升承诺。 + +设计及代码三视角复审通过。评审修复了可选 scope factory 的 + falsey callable 被 `or nullcontext` 静默丢弃的问题,改为显式 + `is None` 并覆盖两个 codec 入口。测试另覆盖关闭前保存 codec、 + disposal 尚未完成、空 mapping、错误执行上下文、嵌套检查异常 + 计数回落、Graph 转移、损坏降级和 busy 传播;目录替换区分准入 + 前拒绝与准入后实际从原树恢复正确图片,验证投影确实替换一次。 + +基线 86 passed/1 skipped(7.62 秒)。首轮新增测试的两个失败是 + fixture 使用 `mime_type` 而非既有 wire 字段 `mimeType`,修正 + fixture 后 116 passed/1 skipped。补复审负测与联合 lease 回归 + 后最终 129 passed/1 skipped(8.92 秒);集合重叠,不相加。 + 修改文件 Ruff、五源文件 mypy 通过。既有序列化格式、图片预算与 + 默认策略未更改;这不是默认工厂或完整 lmux 验收完成。 + +下一步仍须接导入/fork/delete/工具输出与默认 Session 工厂,并 + 完成 §34 列出的生产入口、UI、进程与真实安装性能验收及交付。 + +## 36. 首次获取前的原 owner 与恢复健康检查 + +修复实际 owned restore 的末端遗漏:原 `_lifecycle_session` 的 + 附件健康检查也消费 retained blob port,并在原 operation scope + 内完成后才发布 Session。准入之后 data root 被替换仍检查原树, + 不重新按 pathname 打开附件。BlobStore 构造及逐对象 inspect 的 + busy 都直接传播;真实 missing/corrupt 仍生成健康状态,不重写 + JSONL。这是持 writer 的恢复检查,可能创建短锁元数据,不等同 + §29 的只读 catalog 无副作用保证。 + +新增显式 `prepare_owned_writer`:原 preparation 纯构造正文 lease, + 调用方在首次 await 前即可保留同一 owner。其原 build driver 执行 + acquire→borrow→claim→原 binding projection 更新,再进入已有 + blob acquire、binder、Store create/restore 链。失败不重新 acquire, + 不另建前置 owner;dispose 先 join 原 driver,再结算部分资源。 + 只有本 preparation 独占构造且未 claim 的 lease 可普通 close; + 已 claim 必须归属于自身才可 claimed-close。原外部已持锁的 + `prepare_writer` 合同不变。没有自动创建根目录或切换默认入口。 + +三视角设计与代码复审通过;修复 claim 在后台从 None→self 变化 + 时两次读值可能误判 closed 的竞态,统一使用一次快照。测试覆盖 + acquire/borrow/claim 前后六类失败、调用者取消时等待 native 获取、 + 不获取即可 dispose、实际 create/restore,以及 A 持锁→B busy→ + B 清理不解锁 A→A 关闭后 C 可获取。恢复测试覆盖原 port、健康 + 检查阶段禁止 pathname IO、真实缺失/损坏、实际附件短锁竞争与 + 未交付 Session 的两域资源保留。 + +基线 36 passed(2.37 秒)。新 restore fixture 首轮因未设置 + `defer_materialization=True` 而被既有 restore 合同拒绝,修正 + fixture 后与内部准入集合 65 passed(3.77 秒)。最终扩展集合 + 92 passed(3.94 秒);集合重叠不相加。Ruff、四源文件 mypy + 通过。另补未包含在该集合中的 writer 进程/root binding/图片/IO + 与实际 detached Coding 回归,72 passed(78.86 秒)。轻量文档、 + 包依赖图及 diff 检查通过。这是内部准入和恢复路径证据,不是 + 工厂/default 验收。 + +### 36.1 已复核的工厂接线方向 + +四条入口应汇到工厂私有构造接缝,但资源算法仍在同一 preparation: + new 执行 create;restore 执行加锁后 header 复核及 restore;import + 在两域获取后发布冻结 bundle 内容再 create;fork 在两域获取后 + 克隆并改写引用再 create。现工厂先发布附件、后调用 lifecycle 的 + 次序必须迁入原 driver;正文提交未知时不得回滚附件。 + +工厂仅保留尚未交付/待清理的原 preparation 句柄,不复制状态机。 + Session 已持有原 owner 后才能交付并移除待交付引用;失败/取消 + join 原 driver 后委托 dispose。清理债务须继续保留可恢复句柄, + 并接应用退出结算,不以异常 note 或全局集合替代责任。`fork_from` + 还须覆盖目标创建成功后源 dispose 失败/取消的交付窗口:源结算 + 完成前目标仍被保留。Product 选择、header 校验和路径策略继续 + 留在原工厂回调。该工厂集成仍待实施,未通过默认激活。 + +## 37. 工厂真实创建/恢复与未交付责任 + +§36.1 的首个工厂接线已实现:显式 `owned_product_id` 要求已有 + rooted runtime binder 与标准 header reader,公开 new、load/open、 + restore_context、continue_recent 接入同一 `_construct_owned`。 + 工厂先保留纯构造 preparation,再 await 原 create/restore driver。 + Session 已持原 owner 且工厂仍接受交付时,同 loop 内同步移除 + pending 并返回,中间没有 await;不复制资源算法或另建 owner。 + +失败或取消委托原 preparation.dispose。清理失败不替换原错误, + 原 typed handle 留在只读 `pending_preparations` 中,可由工厂 + close 重试。close 首次 await 前 fence 全部未交付 preparation, + 再逐个 dispose;单项普通失败不跳过其它项,失败项不移除。close + 不处置已交付 Session,它们继续归原 Session/Graph owner 所有。 + 工厂调用绑定原 event loop,错误 loop 不执行发现或 Product 回调。 + +设计复审修正公开 load 的预读边界:不再在 preparation 前使用 + 会创建短锁的普通 header loader。现用既有 stable read_only + 预读,保留叶 nofollow,context 绑定后核对叶路径;预读仅供寻址, + 原 driver 取得 writer 后仍重新核验 header。关闭检查在预读及 + recent 扫描之前;recent 使用 `index_writable=False`。原文件锁 + 与资源所有权检查未延后或删除。 + +阶段边界明确:显式 owned 模式目前只支持持久创建/恢复,transient、 + import_bundle、fork 与 fork_from 在最外层拒绝,不能先读取源或 + 发布附件后回退旧路径。旧 unowned 默认及内存模式保持原行为。 + 这些拒绝是尚未完成的接线,不是完整 lmux 的最终功能范围;导入/ + fork 必须按 §36.1 把冻结附件意图迁入原 driver 后再启用。 + +三视角设计及代码复审通过。基线 21 passed(1.85 秒),首轮新 + 工厂集合 23 passed(1.62 秒);补跨 loop、多个 pending 中首个 + 清理失败、真实 recent 缺/坏缓存后,与旧 factory/Product/catalog + 消费者合跑 57 passed(3.47 秒)。集合重叠,不相加。证据覆盖 + 真实图片 new→持久化→open→health、busy 预读不新建短锁/修改树、 + 关闭前后交付、两次取消、全部 pending 先 fence、失败句柄原位 + 恢复,以及已交付 Session 不受 factory.close 影响。Ruff、两个 + 源文件 mypy 与 diff 检查通过。 + +下一步:在同一 preparation 中冻结并保留 import/fork 附件发布 + 意图与回执,处理正文提交未知时的附件保留,以及 fork_from 的 + 源清理完成前目标交付窗口;再接应用退出的 factory.close、默认 + 工厂与生产 lmux 入口。当前不宣称四条入口或完整目标已完成。 + +## 38. Owned bundle 导入与提交尾部保护 + +显式 owned 工厂现已支持持久 `import_bundle`,不再属于 §37 的拒绝 + 入口。原 preparation 在首次获取资源前冻结 records 与附件字节, + 取得正文及附件两域 writer 后才通过原 rooted blob port 发布附件, + 再创建正文。发布回执保留在同一 preparation;不另建资源 owner。 + 旧默认工厂保持不变,owned transient 与 fork/fork_from 仍拒绝。 + +正文创建前失败,由原 preparation 回滚新附件;回滚身份冲突不删除 + 替换目录,两域 lease 与待清理句柄继续保留。委托给原 rooted IO + 的恢复使用该 operation 的 settlement Event,全部 native 清理完成 + 才置位。native 回滚完成不等于异步任务返回:dispose 重入必须等待 + 同一 rollback task 成功返回才清空 publication,不能绕过在途回执。 + +正文可能已提交时保留附件。FileStore 的提交未知分类现覆盖实际写入、 + 幂等成功、锁上下文退出及最终 snapshot 构造;UOW 的提交后本地投影 + 失败也分类为 unknown。BlobStore 最后 publication 回执构造失败纳入 + 同一恢复范围。失去 close 回执时继续 fail closed,不重试裸 fd。 + +三视角局部代码复审通过,修复提交尾部分类与回滚任务交付窗口两项 + 问题。扩展集合 142 passed(5.34 秒)。随后新增真实目录替换负测, + 首轮因 fixture 缺有效 manifest 而先触发 manifest 错误;改为复制相同 + manifest、仅替换根 inode 后,最终导入集合 15 passed(6.14 秒)。 + 集合重叠不相加;八源文件 mypy 通过。覆盖真实空/图片 bundle、busy + 无发布、冻结输入、重复取消、提交后重开、native close 丢回执、回滚 + 债务重试与替换目录保全。测试恢复原 inode 仅为故障注入清理,不是 + 生产恢复策略。 + +下一步仍是 owned fork/fork_from 的源生命周期与目标交付窗口,应用 + 退出接线、默认工厂及 lmux 生产入口。本节不是完整目标验收或默认 + 激活;完整验收后按用户最新授权提交、push、PR、merge 并同步本地。 + +## 39. Owned fork 与源先结算 + +显式 owned 工厂现接通 `fork` 和 `fork_from`。live-source fork 要求 + 原正文 writer 和 blob port,不能把 unowned 的 noop scope 当作 + 读取准入,也不能回退 pathname。选中记录的深拷贝、引用收集及严格 + 附件字节读取在源同一同步 scope 内完成;退出后仅携带冻结值。目标 + 规范化 authority 的引用改写是纯值操作,随后复用 §38 原 preparation + 的双域 publication/create/rollback。缺失或损坏源附件导致失败, + 不使用 restore 健康检查的降级语义。普通 fork 不关闭调用者的源。 + +设计复审简化 §36.1 的交付窗口:`fork_from` 改为先持久 owned restore + 源、冻结数据、完全结算源,再开始目标构造,而非先创建目标再 finally + 关闭源。私有源的原 preparation 从首次 await 前起一直在 pending; + 不先交付再收养,不新增资源 owner。源读取失败保留主错误;清理失败/ + 取消保留同一句柄,factory.close 可重试,但不自动重放目标创建。源 + 结算后再次检查 factory 接受状态。因此关闭竞争不会启动新目标。 + +该模式的 `fork_from(path)` 会获取源 writer,活动源返回 busy;已有 + live Session 应用 `fork(source)`。旧 unowned 两入口不变。owned + transient 仍拒绝;当前不是默认 Session 工厂激活或完整 lmux 验收。 + +三视角设计与局部代码复审通过。修正一个测试证据缺口:冻结验证的 + 屏障从目标 binder 移到真实双 lease 获取后、附件发布前;明确检查 + publication 为空、目标 authority 不存在,再关闭并移走源,放行后 + 验证原图片可用。覆盖选中路径、空附件、busy、源清理失败/取消、 + 无能力源、错误 loop、缺失/损坏、目标提交未知及工厂关闭竞争。 + Graph 测试仅覆盖内部 ownership 状态路径,不代表完整 Graph 集成。 + +基线 31 passed(3.40 秒);首轮两项 fixture 错把已绑定对象作为绑定 + 配置,修正后 42 passed(3.04 秒)。扩展目标故障测试曾误 patch 类 + 方法而非模块函数,修正后与 factory/import/hydration/blob 合跑最终 + 75 passed(4.39 秒)。集合重叠不相加;源文件 mypy、修改文件 Ruff + 与 diff 检查通过。 + +下一步仍需完成应用退出、默认工厂和剩余 Session 消费者接线,再进入 + 公共服务发现、一命令生产入口、完整 Harnesstui 与真实安装验收。 + +## 40. 真实 Hosted 工厂保管与应用退出 + +`create_coding_hosted_attempt(..., owned_transcripts=True)` 显式接入每目录 + 实例的 owned factory,默认仍为 False。目录直接消费 new/restore 的 + 原 LifecycleSession,返回后、任何 manager/Binding/Candidate 包装前 + 同步保管;包装、验证或关闭竞争失败时清理原 Session,失败句柄保留 + 在 pending_sessions,工厂自身的未交付 preparation 仍在原工厂。 + Candidate 完整包装且关闭检查通过才移交,不接管已交付 Candidate。 + +工厂新增同步 fence,目录 close 在等待自己的互斥锁前先 fence 目录 + 与工厂 pending。Coding 应用请求的可选 typed session_owner 接入 + 原 PRODUCT 关闭阶段,正常 shutdown、启动失败与未转交 continuity + attempt 都经过同一 Product owner。fence、Product 清理和独立目录 + 清理分别尝试,普通单项失败不跳过其它域;总体仍未完成,不释放 + continuity lease。目录清理失败可由原 shutdown owner 重试。 + +真实 Agent/Graph 接线暴露此前单独工厂测试没有触及的启动失败:命令 + 输出 adapter 构造旧 pathname BlobStore,journal 锁的 parents=True + 在 umask 002 下创建 0775 的 session-assets,随后 owned hydration + 拒绝该目录。修复两处 AgentProduct 组装,让输出 adapter 使用原 blob + port、同步初始化 scope 与异步 execution scope。delegate、输出发布 + 及 scratch 清理均在原 admission 内;取消时等待 delegate 协程退出, + 不替任意自定义 delegate 承诺其后台 native 工作结算。publication 失败 + 即使返回 retention error,原 port recovery 债务仍阻止释放两域 writer。 + 不 chmod 已有目录,不新增资源 owner。已包装 adapter 复用按规范化 + authority ID 比较,兼容 legacy:id。 + +临时边界仍明确:owned Hosted wrapper 不发布尚未受管的辅助聚合缓存, + Hosted 发现仍读取 canonical header;实例 fork 显式拒绝,不能回退 + 全局 unowned 工厂。Hosted 的 64 位 create identity/continuity envelope + 需要单独的 clone intent,不能冒用普通 32 位 fork 身份。该 opt-in + 要求调用方预备安全 Session 根;尚未接自动初始化、默认共享目录与 + 一命令 CLI。旧 Hosted/default CLI 行为不变。 + +三视角设计和局部代码复审通过。修复 PRODUCT 首域清理失败跳过独立 + 目录清理、规范化 ID 比较两项 P2。基线 18 passed(6.04 秒);纵向 + 首轮 38 passed/1 failed,mask 后的 session_unavailable 经原构造接缝 + 捕获得到上述 0775 根因,修复后真实 Hosted/输出集合 13 passed。 + 新输出测试的错误 ExecRequest 类型与非法 fixture 文件叶已修正。 + 最终扩展集合 56 passed(17.96 秒),追加 publication recovery 债务 + 后输出专项 4 passed(1.69 秒);集合重叠不相加。包含真实 Product + 创建、首轮、shutdown、continuity 重开、独立进程 busy→释放→重开、 + 包装失败保管、关闭竞争、取消期间双锁保留及连续性租约负测。 + +下一步继续受管服务的默认目录初始化与生产入口接线,并解决 Hosted + clone 身份和剩余 Session 消费者;之后才是完整 Harnesstui、真实安装、 + 断连与性能验收。此处不宣称默认激活或完整目标完成。 + +## 41. 新部署目录初始化与原句柄清理 + +新增显式 `ManagedLayoutPreparationV1`,从 namespace/service/instance 与 + 注入的 runtime root 推导固定九个目录。纯构造预先保管九个原 directory + owner,路径最多 64 层、4096 UTF-8 字节,聚合 fd 预算 512;open 消费 + 同一绝对 deadline,最后一次身份复核后再次检查期限才发布 initialized。 + 不读环境、不创建 Session 根、不登记服务或授予 recovery authority。 + +已有 PrivateManagedDirectory 增加显式 create_parents 选项,默认行为 + 不变。逐层 nofollow 打开并绑定身份,再同步 parent、复核绑定;EEXIST + 也同步,不能假设另一初始化者已完成落盘。新建目录 0700,既有安全 + 祖先权限不变;符号链接、文件及不安全可写祖先拒绝。同步失败保留 + 原 parent fd,close 只重试同步,不重新 mkdir 或删除残留。 + +关闭前将原 fd 收据移入持久账本,native close 前标记不确定状态。 + 即使真实 close 后被 KeyboardInterrupt 打断,其余独立 fd/目录仍尝试 + 清理;未知数字 fd 永不重试,不能误关系统复用的 fd,也不能误报 clean。 + 未完成初始化不允许原 owner 再 open;调用方负责保留并关闭原对象。 + 异步组合仍必须等待原 native 工作结算,不以取消等待者替代清理。 + +三视角局部复审通过,修复最终复核超时、同步证据早于 inode 绑定及 + 关闭中断丢失其余句柄三项问题。目录/路径/文件专项 57 passed(3.45 秒), + 直接消费者 registry/lifecycle/bootstrap/admission 82 passed(5.51 秒)。 + 包含不同 umask、首次创建、既有权限不变、同步债务、确定性替换、 + mkdir 后超时、真实 fd 复用及独立 owner 中断。双进程测试仅证明同一 + 布局的独立进程初始化兼容,不声称命中特定 mkdir/fsync 竞争窗口。 + 两个源文件 mypy 与修改文件 Ruff 通过。 + +该接口仅供确认的新部署初始化,禁止借它重建既有或未知服务丢失的 + runtime/lifecycle fence。尚未接生产 coordinator、默认 Session catalog + 或一命令 CLI;不得用虚构 instance 初始化临时目录以引导 registry。 + 完整目标及最终发布前置条件不变。 + +## 42. 同库 Session 发现视图与非破坏性历史适配 + +新增显式 `CodingManagedSessionCatalogV1(session_root=..., workspace=...)`。 + 两个 scope 注入同一规范 Session 根,强制使用原 owned factory、候选 + 保管与关闭路径;不另建 owner 或元数据索引。旧 Hosted 仍要求不同根。 + 基类仅提取目录校验、记录读取、视图和打开前后准入钩子;旧打开准入 + 保持 no-op,由原 Product validator 分类,managed 才增加严格检查。 + +普通 Coding 历史按 §6 算法导出稳定 continuity,保留 conversation ID、 + header、正文与附件。canonical identity 的 scope 固定 user_home;展示 + identity 使用当前已准入 scope,而 envelope/binding 不含选择视图。 + 已有 coding.hosted v1 则按原 header scope 与 cwd 重建 fingerprint, + 执行完整字段/schema/create identity 校验并保留原身份。字段存在但 + null、畸形或错误时拒绝,绝不回退普通历史算法。读取不解析 header + 中 cwd 的物理路径,不要求历史工作区当前存在。 + +cwd 只展示同工作区,user_home 可展示其它工作区但不可打开。真实 + runtime/capability validator 判定不兼容时展示 unsupported/unavailable; + open 在取得 writer 前拒绝,取得原双 writer 并恢复后再次校验完整 + header、revision、workspace 与 runtime。失败时原 Session 保管和清理 + 债务不丢失。两个 scope 保留不同 selection reference、相同 envelope; + 这不代表尚未接线的 resolver 已完成 runtime 去重。 + +规范身份冲突检查先于工作区过滤:同一文件跨两个视图合法;不同文件 + 声称相同 ID 必须拒绝,即便其中一个文件属于另一个工作区。新建仍 + 使用 canonical header 中的 v1 create-operation,落盘后丢回执由原 + operation/continuity 对账恢复,不另建正文或 fallback 到 unowned。 + +三视角局部复审通过。修正旧 Hosted 打开语义被无意加强的问题,补充 + 完整根/兄弟附件树的 mode/inode/mtime/bytes 只读证据、真实不兼容 + runtime header 与禁止 binder 调用、真实落盘后丢回执恢复,以及 + owned restore 返回后复核失败且首次清理失败的原 Session 保管测试。 + 初轮 30 passed(7.44 秒);最终 managed/旧 catalog/owned shutdown + 集合 37 passed(18.44 秒),包含两个视图顺序与隐藏冲突负测。 + 旧 Hosted discovery/application/bootstrap 另 23 passed(8.03 秒);两个 + 源文件 mypy、修改文件 Ruff、轻量文档与 diff 检查通过。 + 集合重叠不相加;本条只证明 catalog 切片,不是完整 lmux 验收。 + +下一步接 managed resolver/profile 的 selection-scope 与 canonical identity + 分离、默认 Session 根准备,以及普通 Embedded/维护写入入口的共同 + writer;生产 coordinator、一命令入口、完整 Harnesstui 与真实安装 + 断连/性能验收仍待完成。不改变默认入口,不自动收养手工 G16 服务。 + +## 43. 显式 managed 应用与选择 scope 接线 + +新增 `CodingManagedApplicationLaunchV1` 与 `create_coding_managed_attempt`。 + 使用独立 `coding.managed-mux` profile;managed_selection 必须显式开启, + 要求 exact canonical catalog、其完整 admitted scopes 和同一个 catalog + shutdown owner。旧 G16 launch 继续要求不同根。两种启动方式复用原 + Product/bootstrap/continuity 装配,纯构造不打开 Session 或启动后台服务。 + 新 launch 拒绝 application root 与 transcript 根、兄弟 session-assets、 + .session-blob-writers 的双向重叠,不 chmod 或迁移已有数据。 + +resolver 先验证 selection scope,并经原 catalog 的 workspace/runtime/ + canonical envelope 校验和原 AppHost 路由;再核对完整 Product、continuity、 + Session 核心身份,仅在返回 lease 的 wire view 上投影所选 scope 与 + fingerprint。Product binding、writer identity、header 不改,关闭仍委托 + 原 lease/runtime owner。旧 resolver 仍要求完整 identity 严格匹配。 + 可选 execution adapter 尚要求 owner 与 Product 的完整 identity 相同, + 因此本轮在 request 和直接 resolver 构造阶段拒绝 managed+execution, + 不在创建 Session 后才失败,也不删除 execution 的精确所有权校验。 + +保留现有应用规则:同 Session 不能重复加入多个 Mux。attach 已存在的 + Mux 直接复用已有 port,不调用 Session resolver;这与重复 open_member + 不同。当前 owned catalog 仍会在 AppHost live-runtime 去重前尝试 writer, + 本轮不宣称解决重复打开时的 runtime 复用,也不扩张多成员共享同一 + Session 的语义。失败不得新增成员或关闭正在执行工作的原 runtime。 + +三视角局部代码复审通过,修复 managed/execution 组合过晚失败与附件 + writer 锁域隔离遗漏。真实 ordinary(含图片)及两种 v1 scope 历史均 + 从另一 scope 恢复,旧正文不变;detach/attach 的 Product 工厂计数保持 + 一次。模型门闩确认已接纳工作运行中,跨 scope 重复 open 被拒,独立 + catalog 仍观察 writer busy;放行后唯一回复完成、再执行一轮成功。 + shutdown 与 fresh continuity 重开后再提交成功,验证回复文本及完整 + 历史中的完成次数;模型响应为本地 synthetic,不是网络或性能证据。 + +首轮 3 failed/16 passed:测试在生产静态方法绑定后才安装观察器,导致 + 工厂计数漏记;改为构造前 class 方法观察。增强测试曾误先等待整轮 + start_turn 再释放模型门闩,导致测试互等;已核实原进程并以 INT 中止, + exit 2,不算通过。修正为独立 submit task 后 7 passed(26.39 秒)。 + 最终 managed/旧 Hosted/application/bootstrap/shutdown/launch/execution + 合跑 56 passed(51.82 秒);增加锁域隔离与重启后首轮证据后专项 + 9 passed(29.07 秒)。集合重叠不相加。四个源文件 mypy 与修改文件 + Ruff 通过。 + +下一步继续默认 Session 根准备、Embedded/维护写入口共享 writer,及 + managed child/生产 coordinator 接线;一命令后台入口、完整 Harnesstui、 + 有界日志/tmp 和真实安装/断连/性能验收仍未完成。此处不是默认激活。 + +## 44. Canonical 应用接入后台 Local listener + +新增 `coding.managed_local` 的显式 launch/command 与 invocation 派生入口。 + `CodingManagedLocalCommandV1` 只覆盖 exact launch 校验和原 managed + attempt 选择;prepare/activate/start/close、超时、失败保管、应用移交 + 及重试全部继承 CodingLocalCommandV1。基类抽出两项钩子,仍拒绝新 + launch;新 command 也拒绝旧 launch,错误类型不构造目录或 attempt。 + +application/connection 根取自原 namespace/service/instance layout, + Session 根仍由 Coding composition 显式注入,connection scopes 直接 + 来自同一 canonical application 的两种视图。连接根与 application、 + Session、session-assets、.session-blob-writers 双向隔离;invocation + 派生入口另外拒绝 Session/附件/writer 域与持久或 runtime lmux 控制域 + 重叠。未新增路径扫描、目录初始化、进程 owner 或单独清理路径。 + +三视角局部复审通过。新增纯构造/未启动 close 的完整树不变、错误 + Product 在 Path.resolve 前拒绝、exact launch 交叉拒绝时禁止目录与 + attempt 构造、根/后代/祖先重叠、管理控制域与封闭 endpoint 参数负测。 + 局部准入与原 LocalCommand/ownership/launch 集合 57 passed(36.20 秒), + 三个源文件 mypy、修改文件 Ruff 通过。 + +真实 detached helper 新增 canonical 模式,走生产 managed LocalCommand、 + ManagedChildBootstrap.run_process 与原 LinuxServiceProcessV1。生产仍 + 仅继承原 handoff fd;模型释放和故障观察使用 child 内新建的测试 socket, + 不进入生产启动接口。canonical 三项 3 passed(33.02 秒):父正常退出、 + 父 os._exit、迟到 birth+TERM;均在真实模型已进入时注入 HUP、断开 + 原客户端并重连,身份不变、仅一次模型调用和唯一回复,最后明确 stop + 或 TERM 收口并获得原 owner 清理回执。只有 canonical Session 根出现 + 一个 JSONL,不创建 cwd-sessions/home-sessions。调整 helper 参数后旧 + 六项独立重跑 6 passed(51.07 秒),包括控制故障、迟到 birth、INT/TERM。 + +上述是本机真实进程与连接生命周期证据,不是实际 SSH 服务器登录配置 + 或安装性能验收。父端 pidfd 退出观察仍不冒充持久 process-scope 结算; + 测试保留三事实证据区分。下一步仍需默认 Session 根准备及共享 writer + 完整接线、生产 coordinator、自动启动/复用入口、有界产物和完整 TUI。 + +## 45. 首次 Session 根初始化与不可重授的显式权限 + +`DirectoryWriterLease(create_root=True)` 在原 writer 账本中准备缺失的 + Session 根与父目录;默认仍为 False,不选择路径,不引入 AppHost + 依赖。新目录请求 0700,既有安全 0755 根保持不变。逐级 no-follow + 打开、检查可信父目录并绑定 child inode;mkdir 前登记父同步债务, + 包括 EEXIST 的路径也先绑定后同步。原 root fd 不按路径重开;祖先 + fd 全部结算后才获取 writer 锁,成功仍只持有原三个 fd。 + +创建、同步或关闭失败均留在同一 owner;close 只推进原父 fd 的同步 + 与释放,不再次 mkdir、不删除部分创建的目录。未知 close 不重试 + 已可能复用的数字 fd,也不允许 held/borrow/claim。可选参数通过 + 原 transcript preparation/lifecycle/factory.new 传递,仅持久 owned + create 可用;restore 在 native IO 前拒绝,外部已持有 lease 不能 + 再补根创建权限。取消等待者不会取消原 native driver;dispose 等待 + 它完成并统一释放已构造的 runtime,而非承诺取消后从未开始构造。 + +Managed catalog 的 `initialize_session_root` 是可信组合显式授予的 + 一次性初始化权限,**新会话不等于新会话库**。默认、CLI、普通恢复 + 和重试都不自动获得它。创建前消费一次;任何既有根观察都会撤销。 + 三视角复审发现并修复一项 P2:仅在扫描前后检查路径存在性会漏掉 + “扫描曾见根、返回前根被移走”。现在目录 revision 与历史读取将 + 正向/未知观察单调保存在 Event,后续路径缺失不会抹去该事实。 + 跨进程的首次部署证明仍须由生产 coordinator 提供;本节不是缺失 + fence 的自动修复机制,也没有默认激活该权限。 + +本轮三视角局部代码复审通过。确定性覆盖新根与 umask、既有 0755、 + 只读发现、无授权失败、旧根移走、扫描中出现再消失、部分初始化 + 失败且原同步债务保留、symlink/替换、未知 close+fd 复用,以及 + native mkdir 门闩中的取消。真实独立子进程在 mkdir 后暂停,另一 + 进程走 EEXIST 取得 writer,原进程继续后 busy;最后关闭后可重获。 + +首次局部 31 passed(2.97 秒)。取消扩展首轮 1 failed/33 passed: + 测试错误要求 binder 不得开始,改为核实原 driver 完成及 runtime + 清理完毕。修复后 writer/root/factory/lifecycle/blob/新旧 catalog 与 + shutdown 联合集合 127 passed(20.67 秒);不重复累加重叠集合。 + 七个相关源文件 mypy 与修改文件 Ruff 通过,扫描观察修复后的两项 + catalog 类型检查另行复核。没有网络模型、安装或性能验收结论。 + +下一步仍为 Embedded/维护写入口统一 writer、生产根初始化授权与 + coordinator 接线,再落地一命令启动/复用、完整 Harnesstui 及有界 + 日志/tmp。全目标继续,不以本局部替代最终交付。 + +## 46. 原 writer 内的 transcript 删除与提交边界 + +新增 owned-only `AgentTranscriptSessionFactory.delete_transcript`。当前 + Session 检查和 factory closing 检查先于发现;缺文件返回 False 仅 + 表示本次未删除,不是此前不确定操作或清理债务已结算的证据。 + readonly header 发现后沿原 `_owned_source` 保留 preparation,恢复 + 取得同一 Session/blob writer,在原 operation scope 内调用 Store + delete,最后沿原 disposer 收口。内部 Session 从不交付,未新增 + maintenance owner,也不在删除成功时先释放 writer 再清理。 + +该入口只删除 transcript 及 Store 原有 tombstone/index 派生物,明确 + 保留附件。它不是 Product 完整 delete,不默认替换旧无锁入口;后续 + 附件破坏性维护仍须在同一权限下取得唯一引用证明。活动 Session + 删除 busy;已提交后 runtime 清理失败,原 preparation 留 pending, + 双 writer 仍占用,调用者可继续关闭原工厂,不能换 owner 假装清理。 + +三视角局部复审修复两项 P2: + +- Store 的 tombstone 写入、正文删除及整个 binding 退出由单调 + `may_have_committed` 状态覆盖;异常后不再读 tombstone 猜测是否 + 提交,原 `StoreCommitOutcomeUnknown` 不被清理错误替换。 +- factory 校验确切 DeletionReceipt 类型、revision 与本次稳定的 + operation_id 后才确认提交。非法回执按 unknown;确认回执后的 + disposer 失败抛 `TranscriptDeletionCleanupPending(receipt)`,区分 + “已提交但清理未结算”和普通删除失败,不自动重删。 + +扩展 Store conformance 发现此前 create 的一个回归:纯编码失败、 + 尚未进入 native 写边界,也被标为 unknown。Journal 原编码块新增 + 私有标记,仅 FileStore 写入启用,普通 Journal 调用仍抛原异常。 + FileStore 只在正文 `_write_unlocked` 编码失败时撤销提交推断;不 + 重复编码,侧车发布和后续 native/退出失败仍保留 unknown。 + +首四项删除测试 4 passed(1.37 秒)。首轮删除/Store/factory 联合 + 1 failed/74 passed(20.42 秒),失败为上述 codec 合同。修复后扩大 + 到 Journal 集合 7 failed/129 passed(28.35 秒):失败均为新增附件 + oracle 的测试根隔离问题,不同测试共用父级 data root 和 Session + ID,导致 manifest 累积。改为隔离整个 data root 后删除专项 + 10 passed(1.80 秒);正文编码捕获进一步收窄后的 conformance 复核 + 1 passed/40 deselected(0.53 秒)。成功、cleanup pending、unknown 均实际读取 + 原图片,成功还比较 manifest;missing False 后原 pending 与 busy + 不变。集合交叠不累加。三个源文件 mypy 与修改文件 Ruff 通过。 + +默认 Embedded 接线前的边界已经明确:不把模块全局 `_FACTORY` 改为 + loop-bound owned 工厂。实际应用 owner 应在自身 loop 内持有、fence + 和 close 持久工厂,瞬态/只读预览单独选择;上下文引用不充当清理 + owner。还需修复 ProductTranscriptSession 的工厂返回与 `cls(...)` + 包装之间交付缺口,实例 fork 沿用创建工厂。当前尚未实施这些默认 + 接线,也未完成一命令入口、完整 TUI 或安装性能验收。 + +## 47. Product 包装与原工厂交付收口 + +修复 factory 返回 LifecycleSession 与 ProductTranscriptSession `cls(...)` + 包装之间的清理责任缺口。owned 工厂支持私有同步 `_projection`:原 + `_construct_owned` 收到 Session 后先完成包装、绑定创建工厂引用并 + 再查 accepting,全部成功后才从原 pending 移出 preparation。失败 + 沿原 `_discard_failed_owned` 处理;disposer 暂时失败仍保留原对象和 + writer,不新建交付 owner、缓存或换工厂释放权限。 + +new/load/open/continue_recent/import_bundle/fork_from 和实例 fork 的 + Product 包装统一经过该接缝;内部 fork source 不执行目标投影。 + 裸 LifecycleSession 的原调用没有 callback 时保持原交付行为。旧 + unowned Product 分支不传私有参数,内存工厂的选择与实际应用持有 + 范围尚待后续接线。投影只允许同步包装,不得在 factory 交付前对外 + 发布、启动异步工作或转移 Graph;这是内部调用合同,不宣称能隔离 + 任意不合规构造器的副作用。 + +owned Product 实例保留创建工厂引用,fork 不重新读取变化后的类级 + 工厂选择。三视角复审发现并修复一项 P2:引用判断使用显式 `is not + None`,不把合法 falsey factory 当作缺失。其余局部复审通过。 + +首批 Product/原 factory 回归 26 passed(2.06 秒)。扩大到原 Coding + Session、owned 导入/分支与 Hosted catalog 后 110 passed(11.06 秒); + 集合交叠不累加。新增七入口单次投影、构造失败/工厂引用赋值失败/ + 同步 fence 且 disposer 失败时原 pending 与 writer 保留、falsey + factory 与类选择变更的 fork、legacy 严格无参数调用等证据。交付 + 后 Graph ownership 状态测试确认 factory.close 不再 fence 已交付 + Session,仍可追加,最终只能由 Graph disposer 释放;这是状态边界 + 测试,不替代完整 Graph 组装验收。两个源文件 mypy、修改文件 Ruff + 与 diff 检查通过。 + +接下来在实际应用 owner 内选择与持有持久 factory,瞬态预览保持 + 单独的只读来源路径,再统一默认 Embedded/维护入口。尚未改模块 + 全局 Coding 工厂,也未激活 managed 一命令入口;完整目标仍继续。 + +## 48. Product 构造失败与原生命周期关闭 + +`ProductTranscriptSessionLifecycleStore` 在 transcript 返回后立即保管 + 原对象,直到验证、Product builder 与 Session 交付成功。构造失败 + 且 disposer 失败时保留原异常、附加清理说明,并保留原 transcript + 供 close 重试;不通过重新打开文件或创建另一个 factory 回收写锁。 + close 先拒绝新构造、等待已接纳调用,再逐项清理未交付对象;一个 + 普通清理异常不阻断其他独立对象的清理。取消仍保留未完成引用。 + +`ProductSessionRuntime.dispose_session_runtime` 先 fence 该 store, + 由原生命周期清理已交付 Session,再关闭未交付 transcript;两处 + 失败均不丢失清理责任。已经开始的异步 builder 不在成功返回后 + 单独销毁 transcript,而由原 transition lock 接纳并处置迟到的 + Session,避免丢失完整 Product/Graph 的责任。绑定层通过公开 + `runtime_disposed` 判断真实处置完成,不把 Graph compatibility + disposer 的 no-op 误报为成功。 + +首轮含 Coding runtime 的回归 2 failed/115 passed(265.84 秒),两项 + 失败为新增 fixture 漏传必需的 dispose_session hook,未进入被测 + 路径。修复后构造清理专项 9 passed(2.08 秒);补异步 builder + 竞态后真实 writer 文件 3 passed(1.46 秒)。集合交叠不累加。 + 验证包含主异常保留、原 pending 与 writer busy、重试后锁可获取、 + 独立清理、关闭拒绝新接纳以及迟到 Session 在关闭返回前清理。 + Graph 用例仅验证 ownership 状态与实际 writer,不替代完整 Graph + 组装。三源文件 mypy、六修改文件 Ruff 通过。 + 三视角局部复审通过;生命周期与测试视角提出的迟到 builder 和 + 独立 pending 清理证据已补齐,并经对应评审者再次只读确认。 + +此 store 只负责已经返回的 transcript;更早的 Product 包装失败仍 + 由原 factory pending 保管,下一步需由实际应用同时持有并关闭该 + factory。尚未激活默认工厂、一命令启动或完整 Harnesstui。 + 用户已追加最终交付授权:完整目标验收后 commit、push、PR、merge + 并同步本地 main/harness;本节不作为提前发布未完成目标的依据。 + +## 49. 实际 Coding runtime 持有工厂与只读预览 + +`AgentSessionRuntime(owned_transcripts=True)` 现在持有应用级持久 + factory,而非改变模块全局 `_FACTORY`。初始 Session 必须由该 + runtime 构造;关闭先校验原 loop、fence factory,再分别处理原 + Session 生命周期、factory 未交付 preparation 和 Coding continuity。 + 首个失败保持为主异常,后续失败附注且原 owner 保留供重试。默认 + 选项仍为 False,不宣称普通 CLI/Embedded 已激活此模式。 + +Product facade 增加兼容的 `_factory_for_persistence(persist)` 接缝, + 默认继续调用旧 `_session_factory()`。应用绑定类只是原 factory + 的引用,不另设生命周期。持久实例 fork 沿用原创建工厂;瞬态 + 操作选择独立内存绑定,磁盘 header/snapshot 与 recent discovery + 均只读。factory 的 `index_writable` 选项保持旧默认,owned 工厂 + 强制禁写,瞬态应用工厂也明确禁写。 + +三视角评审发现并修复可达的旧实现旁路:删除委托原 factory 的 + writer-owned transcript 删除,明确保留附件给显式维护;重命名 + 在原 `_owned_source` 内完成包装、追加、summary 与清理,失败时 + 不丢失原 preparation;辅助索引发布暂不调用 pathname cache。 + 只返回路径、会丢失新 Session owner 的 `create_branched_session` + 在这个绑定类中明确拒绝,返回完整 Session 的 fork 路径保留。 + +含图片预览的构造健康检查曾仍创建 sibling 附件锁。修复后 + `SessionBlobStore(read_only=True)` 复用原稳定文件读取、完整性与 + manifest 检查,但 shared 操作不创建目录/锁,exclusive 操作拒绝 + 写入。非持久 lifecycle 的附件健康检查选此路径;这是 advisory + 检查,不宣称跨文件原子快照。默认持久和 rooted writer 路径不变。 + +首轮实际接线测试 2 failed/15 passed(5.04 秒),失败为测试工厂 + 将 keyword-only bootstrap 直接用作位置参数端口;修正测试适配 + 后 3 passed(7.41 秒)。加入图片全 data-root 快照后 3 passed + (6.65 秒)。维护与附件扩展回归 1 failed/37 passed(8.92 秒), + 失败为测试用空 import 验证 readonly,却先命中原空输入校验;改 + 为合法 blob 输入后,与真实 Graph fork、legacy SessionManager、 + lifecycle/rooted blobs 合跑 91 passed(11.61 秒)。另补实际绑定 + 进行中关闭、全局 factory 不被替换,2 passed/6 deselected(9.24 + 秒)。交叠集合不累加。七源文件 mypy、九修改文件 Ruff 通过。 + 三视角对各自所报问题修复后的局部只读复审均通过。 + 修改实际 runtime 关闭路径后的原 Coding runtime/构造保管回归 + 90 passed(297.17 秒);进程已正常退出。文档、架构依赖生成物 + 与 diff 检查通过。 + +当前实际 create/restore/fork 已验证 Graph 所有权、第二 runtime + writer busy 与清理后可恢复同 conversation;维护覆盖 busy/成功/ + 关闭拒绝、rename disposer 失败原 pending 保留。图片预览同时 + 验证 open/recent/in_memory、missing/corrupt 聚合索引和完整 data + root 不变,blob 独立测试检查无目录创建、拒绝写入与内容损坏。 + 仍需默认入口首次根授权、旧维护/导入入口的统一接线、生产服务 + 协调、一命令及完整 Harnesstui/真实安装验收,不能提前发布为完成。 + +## 50. 公共父端单次启动桥接 + +`ManagedServiceStarterV1` 将此前测试脚本中的父端启动流程接入公共 + AppHost 实现:借用已准入的原 journal,先 CAS 预留新代,再为该 + 真实 instance 准备目录,保管原 socket 和 LinuxServiceProcessV1, + 单次 spawn 后登记原生身份。Product/composition 注入纯 request + 构造函数,持久锁外调用;不把启动命令选择下沉到 Hosting。 + +新增 `resolve_managed_service_paths`,在尚未预留 instance 时解析 + registry/lifecycle 等稳定位置,不再需要伪造 instance 来取得控制 + 路径。原 Deployment 路径值与字段顺序保持兼容。新启动流程要求 + registry 与 lifecycle 根已经存在,丢失时不重建;实例目录只在 + journal.prepare 成功后创建。首次 namespace 初始化授权仍由后续 + 生产协调器解决,此类不根据目录或进程缺失推断旧服务已停止。 + +每个对象只允许一次 prepare/spawn。prepare 回执丢失时可观察本 + attempt,但不会据此重放 native effect;spawn 后报错仍保留原 + process,register_birth 只重试身份登记。登记回执丢失时读取原 + journal 对账,不重新启动。close 只关父端句柄与目录资源;它不 + kill、等待或把原代标记 clean,committed child 的生命周期仍由 + 既有子端协议负责。没有进程存活或 application ready 的返回承诺。 + +三视角发现并修复两类 P2:构造时拒绝异 UID namespace;加 mutex + 后与紧邻 spawn 的协作准入点重新检查原 deadline/closing。准入 + 后 native spawn 不可抢占,原对象保持保管。确定性线程屏障验证 + check 与加锁之间、最后一次 journal read 期间发生 close 时,不 + 再产生 prepare/register 或 Popen。全流程贯穿一个 absolute + deadline,journal.prepare 的新可选参数不改变旧调用合同。 + +首轮 starter/path/layout/lifecycle 回归 55 passed(5.72 秒)。 + 关闭竞态修复后 starter 专项 8 passed(1.62 秒);增加异 UID、 + 真实 spawn 后错误、独立资源清理、unknown socket close、callback + 失败/过期后最终 14 passed(2.16 秒)。先后竞争验证第二 starter + 不调用 request/spawn,不冒充同时并发压力验收。真实子进程只等 + EOF,不含 Product/模型或派生进程,均由测试显式回收。unknown + socket 用例在真实关闭后注入错误,验证债务不被重试或伪报清理; + 不遗留实际 fd。集合交叠不累加。四源文件 mypy 与修改文件 Ruff + 通过,三视角对修订后的 starter 局部复审通过。 + +本节不等于完整 EnsureStarted/Discover/PrepareConnection:生产 + child entry、ready 身份验证、初始化/异常恢复协调及一命令仍需 + 接线。原 registry 与 native/process 所有权未替换,Session、 + 完整 Harnesstui、日志/临时配额及安装验收仍在完整目标范围内。 + +## 51. 生产 Coding 子进程入口与精确连接实例 + +`coding.managed_process` 提供真实模块入口和纯 launch request 构造。 + 请求固定 `python -m loushang.coding.managed_process`,传递原 + invocation、显式 Session 根和唯一继承 fd,不接受任意模块/工厂 + 名。环境拷贝冻结 `LOUSHANG_HOME` 与 `LOUSHANG_RUNTIME_DIR`, + 默认 Session 根仍是该平台 home 下的 `data/sessions`。此处只选 + 路径,不创建或修复 Session/namespace 目录。 + +子端先严格解析 bounded invocation/参数,再收养原控制 fd,复用 + 同一 bootstrap 与固定 15 秒 admission deadline。busy 只重试 + 原 open;完成后绑定 CodingManagedLocalCommandV1 并进入既有 + run_process。绑定前失败非零退出、不宣称 clean stop;绑定后 + 的长期运行、HUP、INT/TERM 和未知清理债务仍由原 process owner + 负责。失败输出固定代码,不打印原 argv、路径或异常详情。 + +为后续公共 PrepareConnection 接线,原 invocation.instance_id + 经 Coding managed launch、AppHost local、LocalAppServer 传入 + 现有 connection record.instance,不增加 wire 字段。旧调用 + 缺省仍生成随机 instance;认证密钥独立随机生成。客户端新增 + expected_instance,在读取原 record 后、创建 socket 前匹配, + 后续认证仍绑定该 record 的 instance/digest/key。此项只校验 + 所选记录,不替代协调器对当前 journal/native/stop 状态的复核。 + +真实生产模块测试已验证:通过原 starter 启动 Coding 服务,认证 + 后 create/list Mux,关闭父端启动 fd 和旧 client 后,fresh client + 找回相同 Mux 元数据;协议 stop 后 native 退出且原 journal + application cleanup 成功,未伪造 process/scope stopped 事实。 + 未创建 Session 或调用模型;这是同一父测试进程中的 fresh client, + 不冒充父进程退出、SSH、installed wheel 或首次模型调用验收。 + +首次真实测试已到 create/list,但测试误用 muxes 字段,1 failed + (10.00 秒);改为协议真实 mux_spaces 后 1 passed(16.98 秒)。 + 扩大到入口纯值/错误脱敏、managed/legacy Coding local、AppServer + local/record 后 92 passed(45.52 秒)。新 instance publication、 + stale client 零 socket、非法 instance 零记录负测 6 passed/31 + deselected(0.79 秒)。六源文件 mypy、九修改文件 Ruff 通过。 + 三视角复审提出的测试清理 P2 已修复:setup 抛错时始终从原 + starter 收回 Popen/原 pidfd observer,真实丢失登记回复用例确认 + 子进程被精确、有界回收;没有 broad PID scan 或假定 EOF 会停止 + committed child。入口与 instance 接线局部复审均通过。 + +后续接入公共服务选择/EnsureStarted/PrepareConnection、首次目录 + 授权和异常恢复,然后完成 lmux 简短命令与完整 Harnesstui。 +当前默认 CLI 仍未激活,日志/临时配额与完整安装/性能验收未完成。 + +## 52. 公共精确实例连接接线 + +AppHost 可选 `ManagedConnectionLeaseV1` 接收已准入 journal、namespace、 + service 与精确 instance reference,不发现或创建目录、不启动或停止 + 服务。调用者先持有 lease,再 prepare,最后 close;journal 的寿命 + 覆盖 lease。UI 不接触认证记录,只得到 AppServer 语义 client、可选 + discovery/execution client 和不含路径的 scope。缺省 CLI 尚未接入。 + +准入先读取当前 journal,要求同一 COMMITTED 且未 stop 的实例,然后 + 通过 Hosting 重新验证原生身份并持有 pidfd observer。AppServer + 认证明确匹配 Product/instance;认证后再次检查相同 journal 状态、 + 原 observer 与 stop fence,回到事件循环后再检查原绝对 deadline。 + stale reference 不自动重选;这只是连接准入观察,不承诺其后不会 + 停止,服务端继续负责每次请求的权限与生命周期。 + +prepare/close 是同 loop 的原任务,公开 waiter 取消不取消这些任务。 + 内部 native 调用使用独立实际完成回执,内部任务取消也不能以 + Task.done 冒充 native 完成。复用本包 child 的任务 publication gate, + executor 另以准入 gate 保证提交回执失败时不执行未收养副作用。 + 连接未结算前保留其借用的 directory;observer 独立关闭,任何路径 + 都不 signal 后台。原生取得未返回 owner 或 close 回执未知时保债, + 不重新取得或重复 close,不将这些状态报告为 cleanup 完成。 + +真实 Coding 生产入口回归已改用此公共 lease 完成首次与 fresh client + 认证、Mux create/list、父端启动 fd 关闭后重新连接;停止仍使用原 + 显式 stop 协议。首轮连接/生产入口 15 passed(11.76 秒)。评审补测 + 后 20 passed、1 failed(12.44 秒),失败是迟到回执测试误用合同中 + 不存在的 timeout 码;改为既有 busy 后该专项 1 passed/18 deselected + (0.89 秒)。补齐前节未保留终态的旧 local/discovery/execution + 回归:34 passed(1.97 秒)。集合交叠不累加。新模块 mypy、修改 + 文件 Ruff 通过。 + +三视角提出的 native 无返回句柄、内部取消提前结算、迟到 ready 三项 + P2 已修复,局部复审通过;负测覆盖对应窗口,也覆盖 task/executor 发布失败、stop + 发生于认证期间和清理重试。此处不等于 Discover/EnsureStarted、 + 首次 namespace 授权、异常恢复、受管 Mux mutation 权限或完整 lmux + 交付;亦不将 source 环境同进程 fresh client 视作 installed/SSH、 + Session/模型或真实首次使用性能证据。后续仍按完整目标继续接线。 + +清单复核同时补回前序已记录的源码接缝:inventory 与精确 source 集合 + 对齐,并列出 layout/starter/connection 三个可选 AppHost 模块。两个 + 架构文件初跑 18 passed、1 failed(15.11 秒),唯一失败为反向 + consumer 清单遗漏已经落地的 managed_catalog/local/process;显式 + 补齐后原失败项 1 passed/10 deselected(15.12 秒),未改放宽依赖 + 断言。依赖图生成核对、文档轻门禁与 diff whitespace 检查通过。 + 已生成 change-aware 检查计划;全目标门禁与安装验收仍待交付前执行。 + +## 53. 公共只读名称发现与解析 + +新增可选 `ManagedDiscoveryV1`,借用同用户、同 namespace 且位于规范 + registry 根的原 owner,不创建第二套目录或后台 broker。独立纯函数 + `resolve_managed_registry_root` 给出 namespace 级路径,无需临时伪造 + workspace/service/instance;既有 service 路径仍使用同一布局。 + +resolve 与 list_muxes 在一次短只读事务中 JOIN 名称保留、服务键和实例, + 不逐条打开生命周期目录、连接记录或 native observer。列表最多 64 + 条,按大小写敏感全局名字分页,不按客户端 cwd 或 Product 过滤。 + 每页是独立快照,不承诺跨页枚举一致性。原绝对 deadline 贯穿读取; + busy/closed/corrupt 仍是错误,不能降级成“空列表/服务不存在”。 + +不可变 `ManagedMuxObservationV1` 保留名称意图、供显示/选择的服务信息、 + operation correlation、精确 instance 与原 recorded phase/revision/ + stop/cleanly-stopped 事实;不导出 PID、argv、密钥或连接记录路径。 + reservation 没有 instance 时不制造 ready;COMMITTED 也不是服务 + 正在运行或已完成 Mux 创建的证明。调用者仍须经公共 connection lease + 重新准入,发现引用不会自动重选。完整受管 Mux mutation 权限待接入。 + +原 journal 的严格 durable state 解码提取为同包纯函数,发现与写入 + owner 共用,保留 phase/位值/instance/native canonical JSON/UID + 等校验。journal 的读写仍受原生命周期 fence 约束;复用解码不转移 + owner 或 lifecycle authority。 + +新增回归覆盖任意 cwd、跨工作区/Product 分页、只读字节/mtime 不变、 + 无 instance、provisional/abort/committed-stop 的不可变旧观察、非法 + 状态、过期/closed、异用户/namespace/root 和缺失根不初始化。真实 + Coding 生产入口先 resolve 全局保留名再连接,并在关闭启动 fd 后 + 重新解析到同一实例;测试资源在 setup 失败时也由原 owner 清理。 + 发现/原 lifecycle/paths/生产入口联合回归 50 passed(12.33 秒), + 三源文件 mypy 与修改文件 Ruff 通过。仍不冒充 installed/SSH/Session + 或首次使用性能验收;自动启动/复用、初始化与异常恢复继续待接线。 + +三视角局部复审通过,无新增 P1/P2。按非阻断建议补测完整根/子项的 + bytes/mode/inode/mtime 只读快照、三事实齐全后的换代与 frozen 旧引用、 + 真实 65 条记录的 64 条截断及下一页:3 passed/14 deselected(1.18 + 秒)。换代用例是显式注入停止事实的纯状态测试,不是 native 重启 + 清理证据。两个相关精确架构门禁 2 passed/17 deselected(15.43 秒); + 依赖图生成核对、文档轻门禁和 diff whitespace 检查通过。 + +## 54. 已准入存储上的启动/复用协调 + +`ManagedServiceCoordinatorV1` 把原 starter、journal 与公共连接 lease + 接成一次显式操作。调用者先持有 coordinator;原 starter 生成唯一 + operation ID,同一对象重复等待沿用原任务与 deadline。它借用已准入 + 存储,不选择 Product、不初始化 namespace、不执行 Mux mutation, + 也不承诺跨进程/跨历史代次的持久操作去重或异常恢复。 + +没有实例或已有完整 clean stop 事实时最多调用一次 starter.start。 + CAS 竞争、busy 或未知启动回执后只读取 journal;有原 process/native + identity 时仅重试该原生出生登记,绝不再次 spawn。选定 instance 后 + 不自动换代;stop/abort、实例丢失或被替换均拒绝。已存在 COMMITTED + 服务经公共 lease 认证后才能返回。连接尚未就绪时,仅对 not-found/ + refused/closed 或短暂 busy 重试只读连接;先完整关闭前一 lease, + cleanup 失败保留它并终止替换,corrupt 等错误不当作启动中的缺失。 + +close 在首个 await 前同步 fence 原 starter,随后等待原操作/native + 回执,独立清理连接和父端启动资源,不停止已提交服务。取消公开 + waiter 不取消原操作;同 deadline 可重新等待同一 lease。借用者 + 显式关闭 lease 后再 join 会报告关闭,不隐式重连或交付失效 lease。 + +首轮 coordinator/starter 23 passed(2.82 秒);真实生产模块的两个 + 并发调用收敛到同一个 child,关闭客户端后第三个 warm 操作复用, + 专项 1 passed/2 deselected(10.72 秒)。三视角局部实现评审通过, + 随后补确定性冷态屏障,保证两方首次都读到 None 再竞争,并直接 + 断言实际 Popen 数为一;再注入前两次 birth registration busy, + 验证原 process 重试登记后收敛。另补取消 waiter 后原任务/lease/ + deadline 重 join、借用 lease 关闭拒绝、connection close 失败仍 + 清理 starter 且保留原 lease。补测及原生产入口合计 16 passed + (20.97 秒),集合交叠不累加;增量复审通过,无新增 P1/P2。 + +两源文件 mypy、修改文件 Ruff 通过。这些真实 source 环境测试使用 + 预先准入的 registry/lifecycle 和 Session 根,不冒充默认目录初次 + 安装验收;它们没有启动 Session、调用模型或验证 SSH。下一步完成 + namespace 首次初始化/重新打开与目录缺失的准入边界,再连接简短 + lmux CLI。异常恢复、完整 Harnesstui、配额、真实安装/首次使用性能 + 和完整交付仍在原 goal 范围中,不据本节将目标标为完成。 + +## 55. Namespace 首次准入:设计增量与实施约束 + +三视角对首次准入方向评审后按下列约束收紧设计。本文仍是 partial + implementation:本节记录准入合同,不宣称干净 home 已可自动启动。 + +### 55.1 独立持久见证与范围 + +AppHost 在现有 `/state/managed-deployments//` + 保存有界 namespace admission 记录与稳定锁;主体 registry、日志、 + tmp 仍留在既定 lmux 布局。路径由已冻结 namespace 推导,不重复读取 + 环境;不是 Hosting 的进程机制责任,也不改变 Foundation 全局目录 + 缺省。Discover/list 只读,不创建任何准入目录或记录。 + +新增 `state/managed-deployments` 整个见证域是受保护控制域,涵盖其他 + namespace 的兄弟记录。runtime/tmp 的实际部署路径、Session 根与 + 持久附件/写者域均不得与其相等、互为祖先或后代;在 native 准入前 + 拒绝重叠。不能只保护既有 lmux 子树而把新见证域留给普通数据写入。 + +记录分 initializing 与 initialized。initialized 仅代表存储初始化 + 完成,不叫 service ready。记录绑定版本、namespace、初始化 operation + ID、一次性 deployment nonce,以及 registry 根、数据库文件、稳定 + registry lock 的设备/inode 身份;不以随事务变化的大小/mtime 代替 + 部署身份。数据库需要同 nonce 的持久绑定,未知旧格式不自动迁移。 + +后续 §56 将记录升级为 v2,同时绑定 admission root 和稳定 admission + lock,共五个原生身份。marker 本身是允许原子更新的记录载体,不绑定 + 自身 inode:同字节副本可在五个身份/nonce 全部一致时只读重开,不能 + 据此改变 phase、授权不同部署或恢复丢失的锁。损坏 marker 的测试不 + 冒充“所有 marker inode 替换均拒绝”的证据。 + +外部删除 lmux 子树时,独立准入见证应阻止重新创建。必须明确边界: + **全部独立身份见证丢失**(例如同时删掉该 namespace 的 admission + 子树及 lmux 数据)后,纯文件算法无法区分首次使用与曾有活服务。 + 不把这种证据灭失判为旧服务已死亡,不承诺自动灾难恢复;仅保留 + 一个 state 父目录并不足以恢复被删掉的身份见证。此范围不同于 + 正常关闭/重启、单独 runtime 丢失或有见证的 registry 丢失。 + +### 55.2 首次、重开与未知的准入矩阵 + +- 真正首次:可信组合边明确发起初始化意图;没有 admission 记录, + 也无既有 registry/lifecycle/部署残留。新 identity root、稳定锁和 + 数据库必须排他创建,不能用现有接纳 EEXIST 的 create=True 冒充 + fresh。保留原 owner 的确切创建回执后才能继续。 +- 缺 marker/lock/identity root,但仍有任一 lmux/registry/fence 证据: + 拒绝自动接管,报告初始化不完整/不可用,不重建锁,不删除残留。 +- initializing:先完成意图文件及父目录 fsync,才允许创建 registry。 + 发布回执丢失、mkdir 后尚未记录 inode、数据库完成但 initialized + 未发布,均保留原意图和 unknown。原 owner 仅按已有阶段/回执继续; + 其他进程需要显式恢复,不能靠目录为空或实例表为空取得创建权。 +- initialized:在同一 admission lock 下读取记录,open-only 打开 + registry 并匹配完整身份/nonce,再交付原 retained registry owner。 + 根、数据库或任一稳定锁丢失/替换均失败,不补建,不刷新记录领养。 + +准入容器须在第一次 native IO 前保留。复用原 private directory 的 + descriptor 链、非阻塞锁、CAS 写、fsync/close 债务;不把其一次性 + open 改成失败后重新构造/重复 open。并发 initializer 遇 busy 有界 + 重读/等待;持久 initializing 不是可无限等待的“正在启动”。 + +### 55.3 每服务 fence 的首次事实 + +registry 需记录 service-control initialization 事实,独立于 instance + 是否存在。先在短 registry 事务中提交初始化意图并退出事务,再 + 排他创建/同步 lifecycle root 与稳定 lock,最后短事务发布完整身份。 + 此事实未 initialized 前不得 journal.prepare;曾有控制意图却缺失 + fence 时不重建。这样覆盖“fence 已创建、prepare 尚未发生就中断” + 的窗口,不能仅凭 instances 中没有行来重新授权。 + +固定顺序是 namespace admission lock → 短 registry transaction; + 不持 registry 事务等待 service fence。ready coordinator 只借用已 + 准入 journal,不复制这套初始化权威。旧手工 profile 不自动接管。 + +### 55.4 必需验收 + +干净 home、两个真实进程竞争、初始化意图/registry/最终发布各阶段 + 故障与进程退出;initialized 后 marker/锁/root/数据库分别丢失或 + 替换;符号链接、权限、fsync/close 失败和原债务保留;只读全树 + 不变;新服务第一次创建与旧 fence 丢失的区分。不得 chmod 用户 + 既有目录、用残留删除代替恢复,或建议用户直接删目录来消除未知。 + +本设计按三视角提出的排他创建、残留矩阵、完整身份、服务初始化事实 + 和锁顺序修订;不引入常驻 broker、全机 PID 扫描或另一套 Session。 + +### 55.5 本轮实施与验证边界 + +已实现有界、严格解码的 `ManagedNamespaceAdmissionRecordV1` 纯合同, + 初始化中不能声称拥有完整身份,初始化完成必须绑定三个不同的 + dev/inode 身份;路径推导无 IO。runtime/tmp 和两个 managed Coding + Session 入口共同保护整个见证域(含其他 namespace),不扩展旧 + 手工 G16 profile 的权限。 + +`PrivateManagedDirectory` 增加 opt-in `exclusive_create`:只对最终 + 身份叶进行真实排他 mkdir,稳定锁使用 O_CREAT|O_EXCL;已有对象 + 报 conflict,不接纳或删除。新建前登记父目录同步债务,成功同步 + 后核对原身份;失败后 close 只结算原 fsync/descriptor,不重放创建。 + 默认 `create=True` 仍保留原接纳已有对象语义,不能据此证明首次。 + +验证结果(有交叠,不累加): + +- admission record、路径隔离及两个 Coding 入口:89 passed(5.71 秒)。 +- files/layout/registry/lifecycle:101 passed(5.74 秒)。 +- 两个真实进程经同一 gate 竞争目录/锁,各只有一个成功;进程退出后 + 新排他申请仍 conflict,普通打开仍可用。另补 fsync 中替换目录/锁 + 的身份复核:4 passed(1.68 秒)。 +- 架构 exact module/current inventory:2 passed,17 deselected + (15.42 秒);相关五源文件 mypy、Ruff、依赖图生成校验通过。 +- 三视角局部复审通过;建议的真实竞争及同步后替换负测已补齐。 + +这只交付合同、路径隔离和排他创建机制:独立持久见证 owner、数据库 + deployment nonce/service-control 初始化事实及其真实首次接线仍待 + 实施,未将本节测试当作干净 home、完整 CLI 或安装验收。 + +## 56. Namespace 持久首次准入与数据库部署绑定 + +本节推进 §55 尚未实施的 namespace storage owner;service-control + 初始化、默认 Session 准入及 lmux CLI 仍为后续任务,不据此声称用户 + 已可一条命令启动完整服务。 + +`ManagedNamespaceAdmissionV1` 在 IO 前持有所有目录容器,显式 + `create_if_missing=True` 才可初始化。已有 witness 只读打开;没有 + witness 但当前 machine 的持久部署目录或当前 namespace runtime + 仍存在(包括空目录),拒绝创建新 witness。无残留时才排他创建 + admission root/lock,持久化 initializing,再排他创建 registry + root/DB/lock,最后 CAS 发布 initialized。每个 owner 只尝试 open + 一次;失败后只清理原持有资源,不重放初始化、不删除持久残留。 + +v2 witness 绑定 admission root/lock、registry root/DB/lock 五个身份 + 以及 deployment nonce;SQLite schema 4 持久存储同 nonce,旧 schema + 1/2/3 均不静默迁移。registry 后续每次事务也固定已打开的 DB/lock + 身份及 nonce,不能只在首次重开时比较一次。普通显式 registry 创建 + 仍可生成自己的 nonce;这不授予 namespace 首次初始化权限,也不会 + 自动激活旧手工 profile。 + +重新打开在原 admission lock 下匹配记录、全部原生身份和 DB nonce, + 完成后返回该 owner 保留的原 registry。初始化中、缺失、损坏或被 + 替换的控制对象均不补建。同字节 marker 原子替换允许重开,但不会 + 改变五个绑定身份、nonce 或状态。服务是否 ready 仍由原 coordinator + 和精确连接 lease 验证。 + +复审发现“新 admission.lock 已创建、首次 creator 尚未 flock”的 + 窗口:无 marker 的重开者可能抢锁,使双方都失败。因此重开者先做 + 只读 negative precheck,marker 不存在就拒绝,不争抢初始化锁; + marker 存在后仍必须在锁内重新读取并执行全部身份检查,锁外读取 + 不是准入凭证。真实双进程测试在双方均确认缺失之后、首次排他创建 + 之前设 gate,要求恰好一个 admitted、一个 conflict;另以确定性 + 回调验证上述创建锁尚未 flock 的窗口,重开者不能阻断原 creator。 + +记录发布与 schema 首次提交的父目录 fsync 失败现在保留原同步债务; + close 只结算原 fd,registry close 失败也继续独立清理 admission + 目录,保留失败的原 registry 供重试。全部独立证据灭失的灾难恢复 + 限制仍按 §55,不使用 PID 扫描或目录删除猜测旧服务死亡。 + +本轮验证(交叠集合不累加): + +- namespace/admission record/files/registry/lifecycle:160 passed + (9.77 秒),覆盖干净 home 存储初始化、含根目录的只读快照、五个 + 身份缺失/同字节替换、真实骤退、丢回执、关闭失败和同步债。 +- 锁抢占 P2 修复后,namespace 加原生产入口:37 passed(27.78 秒), + 包括严格双进程首次竞争和真实 start/reuse 子进程兼容回归。 +- 精确模块及 current inventory:2 passed,17 deselected(14.89 秒); + 五源文件 mypy、Ruff、依赖图生成检查、docs-light 和 diff-check 通过。 +- 三视角复审提出的 marker 合同/测试 P2 与首次锁抢占 P2 均已修复并 + 经对应视角复核关闭;局部通过不替代 service-control、完整 CLI、 + Session 首次使用、安装/SSH/性能或最终交付验收。 + +下一步沿原 goal 接入 service-control 初始化事实:先持久意图再排他 + 创建每服务 fence,匹配后才允许 journal.prepare;然后将已完成的 + namespace owner 接入自动启动组合,不能退回手工预建控制目录。 + +## 57. 每服务控制准入及真实启动组合 + +`ManagedServiceAdmissionV1` 借用已打开的原 NamespaceAdmission registry, + 保管本服务的路径探测、排他目录与原 journal,关闭不停止服务、不 + 删除 fence,也不关闭借用的 namespace。它只尝试一次 open;失败 + 不把“实例表为空”或“operation ID 相同”当作重新创建资格。 + +SQLite schema 5 在 identity 中持久绑定 `service_admission`,以及与 + instances 独立的 `service_controls` 有界记录。NamespaceAdmission + 创建和重开均显式要求该标志为 true,后续事务继续固定,不允许把 + managed namespace 降级为手工模式;旧显式手工入口新建为 false, + 不自动激活此路径。旧 managed schema 1/2/3/4 不静默迁移。 + +服务准入先检查原控制记录。缺记录却已有 fence 残留,或实例已存在, + 均拒绝补建。真正首次须在短事务中 CAS 提交 initializing 并获得 + 本次插入成功回执,退出事务后才排他创建 lifecycle root/lock; + 然后在 fence 下用短事务发布 initialized 与两个 inode 身份。 + 发布遇共享 registry 短暂 busy 时,在原绝对 deadline 内保留同一 + fence,重试精确的发布/观察;已落盘的相同 completed 记录可结算 + 丢失回执,绝不重做目录创建或延长预算。 + +managed journal 在任何目录 IO 前拒绝 `create=True`。每个 open、 + read、prepare、register、commit、abort、stop 和 evidence 更新都 + 经原 `_read` 接缝,在与读取/更新相同的事务中核对 initialized、 + service ID 及 fence 根/锁身份。open 先用短只读事务拒绝缺失或 + initializing 控制事实,释放事务后才获取 fence,锁内仍完整复核: + 这是防止读者在“锁已创建并同步、creator 尚未 flock”窗口抢锁, + 不是锁外授予准入,也不持 registry 事务等待 fence。 + +真实生产测试新增 admitted 组合:从缺失的 platform home 开始,通过 + NamespaceAdmission 和 ServiceAdmission 创建控制存储,再交给原 + Coordinator/Starter 启动真正的 Coding 子进程,验证竞争仅一个 + 子进程、warm reuse、精确 stop;原 manual 组合继续保留。该测试 + 仍显式准备 Session 根,不冒充 Session 首次创建、CLI、安装、SSH + 或性能验收。下一步是接好默认 Session 根准入和一条命令的组合。 + +本轮验证(交叠集合不累加): + +- schema/mode 增量下 namespace/record/registry/lifecycle:117 passed + (7.90 秒);随后 service/namespace/lifecycle/bootstrap:100 passed + (8.31 秒)。 +- service 自身最后一轮:37 passed(6.76 秒)。包含真正 intent 已提交、 + root 已创建、lock 已创建/fsync 且持锁、initialized 已提交四阶段 + `os._exit(23)`;前三级只读拒绝接管,最后一级只读重开;还包含 + namespace 可继续使用的独立清理、坏记录不改实例行、before-flock + 竞争以及提交前/后 busy 的原 fence 发布结算。 +- 最终 service 加真实 Coding process:43 passed(65.47 秒),包含 + manual/admitted 两种组合各自的普通及 birth-busy 并发启动与复用。 + 这里是正确性测试耗时,不作为 startup 性能结论。 +- 精确模块与 current inventory:2 passed,17 deselected(16.57 秒); + 六源文件 mypy、Ruff、依赖图生成检查、docs-light 与 diff-check 通过。 +- 三视角复审均通过;managed 降级防护、全部 mutation 同事务校验、 + raw journal 抢新锁的 P2 已关闭,要求的真实进程与清理负测已补齐。 + +仍未完成默认 Session 根首次准入、简短 CLI、完整 Harnesstui 接线、 + 有界日志/临时文件的最终组合及安装/SSH/首次使用性能验收;不据本节 + 将原 goal 标记完成,也不发布未完成的整体实现。 + +## 58. 共享 Session 存储的惰性首次写入准入 + +三视角设计复核确定:Session 根由多个工作区、Product 和服务共享, + 不能从“新服务”推导“新 Session 库”。启动、空 Mux、浏览和预览不 + 初始化 Session 库;真正的持久 new/fork/import 目标写入才允许首次 + 准入,restore 不创建缺失库。机制归 Harness transcript,Coding + 选择默认根和独立 state 根,AppHost 不拥有 Session 初始化政策。 + +`TranscriptStoreAdmission` 使用 UID 与 canonical Session 根的摘要 + 作为共享键;默认见证放在 `/state/session-stores/` + 下,不按工作区、machine 或服务分区。它复用原目录 writer 的 + native fd/同步债务与 RootedFileIO,没有新增 Session 数据库或运行 + 时 owner。排他首次 witness/root 创建及 open-only 已有锁是原目录 + 机制的可选参数;旧 writer 默认语义不变。runtime/tmp 不得进入 + platform home 的共享持久 `state` 域,Session/blob 写入域也不得 + 与准入见证交叠。 + +- 真正首次:缺见证、缺根且没有附件/附件 writer 残留,先排他创建 + 见证并同步稳定锁,再发布 initializing,随后排他创建 Session 根, + 最后发布 initialized 与 root/data-parent/witness/lock 四项身份。 +- Legacy:由原 root owner 在发布 intent **前** 打开并保管正文根和 + data parent,之后只复核同一对象;不改原历史、图片、权限或 mtime。 +- 已准入:只读打开原见证和锁,精确匹配身份。缺根、同路径替换、 + 缺锁、坏记录或 initializing 都不是重新创建许可;不自动接管。 +- 同一 catalog 已观察到存在或未知的根,单调观察事实传至原准入 + owner,在创建前再次禁止重新建库。独立持久证据全部灭失仍属于 + 无法区分全新状态的灾难恢复边界,不能声称自动恢复已有库。 + +原 `TranscriptWriterPreparation` 在其 retained driver 内完成上述 + 准入,关闭短 store lock 后,将 root/data-parent 身份约束交给原 + 正文/附件 writer,正文 writer 在创建其锁命名空间前检查身份。 + 不将结果降格为可复用的 `create_root=True`。原 preparation 继续 + 保管失败准入、取消、同步/关闭债务;清理失败仅附注原准入异常, + 不覆盖主错。两个不同 Session 可同时存活,初始化锁不延伸到运行 + 时构造或整个 Session 生命周期。 + +专用 `coding.managed_process` 已选择公共默认 state 根。生产测试从 + 干净 home 启动真正的子进程,空 Mux/warm reconnect 均不建 Session + 根或见证;首个 Tab 才创建存储,detach/reattach 后保留原 Session。 + 此测试不调用模型,不是完整 PTY、SSH 或性能验收。原显式 launch + 可不启用此可选接缝;普通 Embedded 入口尚未切换。 + +本轮已有证据(交叠集合不累加,后续新改动仍需针对性复核): + +- 原 expected-root/parent 接缝:83 passed;正文/附件共享父身份约束、 + 替换、缺失及构造前形状验证通过。 +- 新 store/factory/owned admission/catalog/launch 参数集合:118 passed + (7.75 秒);原 factory 实际图片写入和恢复、两个 Session 同时持有 + writer 的补充场景通过。 +- store/owned admission:41 passed(2.65 秒),包括真实双进程均在 + 排他创建前 gated 后恰一成功、四阶段真实 `os._exit(23)`、取消期间 + 等待原 native driver,以及 legacy root/parent 替换负测。 +- 原目录/附件 writer、namespace/path 隔离、factory 和真实生产入口 + 集合:145 passed(41.04 秒)。旧直接连接测试修正了“端点已发布 + 即短 journal fence 必须空闲”的假设:仅 busy 时结算原失败 lease, + 在同一 deadline 内重试冻结的同一 instance,不重启或重选服务。 +- 三视角已关闭 legacy 身份绑定时机和主错被 cleanup 覆盖两项 P2; + 局部通过不替代完整 CLI、Embedded、安装和最终交付评审。 +- 单调观察接线后的 store/原 preparation/factory/managed catalog/真实 + production 集合:85 passed(47.72 秒);随后补充同 inode witness + 符号链接替换拒绝,store 与精确模块检查通过。新增源模块同步加入 + 架构 inventory 的精确登记,不放宽原集合断言。 +- 十一源文件 mypy、相关 Ruff、依赖图生成检查、docs-light 和 + diff-check 通过。 + +下一步仍须让只读浏览识别持久已知库的缺失/替换,而非展示空库, + 完成 Embedded 共用准入和短 CLI 接线,再推进完整 Harnesstui、 + 有界日志/临时文件组合及真实安装、SSH 和首次使用性能验收。 + +## 59. 已知 Session 库的只读浏览与关闭结算 + +公共 `TranscriptStoreAdmission.inspect()` 与持久写入共用原身份检查, + 但未知见证分支直接返回 unknown,绝不创建根、锁或登记 legacy 库; + 即使构造时允许首次写入,inspect 也不能激活创建。存在的见证只开 + 原锁和原根,缺失、替换、损坏或 initializing 返回不可用。 + `check()` 核对同一保管对象,权限/IO 错误不当作不存在。 + +Managed catalog 在原目录 revision 采样中调用只读检查,且核对采样 + 与原 root/data-parent 身份;发现接口与路由列表都不把已知库丢失 + 表示为完整空列表。目录和已知 binding 的观察事实在同一 catalog + 内单调保留;持久见证后来消失也不会抹掉这份内存证据。新进程的 + 全部独立持久证据已灭失仍受 §58 的灾难恢复限制。 + +原 catalog 在 native IO 前保管 probe,最多八个活动浏览及八个 + probe;清理债务未结算时拒绝新增 probe。初始化锁只覆盖短目录 + 采样,不覆盖完整目录遍历。close 先 fence,活动 worker 的句柄 + 不由另一路提前释放;worker 结束后的清理债务继续由原 catalog + 重试。取消浏览仍等待原完成回执,不以取消 offload Future 冒充 + native 完成。旧 Hosted discovery 的 executor 提交新增 publication + gate:提交未成功返回时,晚到 worker 不获准开始真实读取。 + +本轮验证(交叠集合不累加): + +- 七项 inspect 新回归先确认在实现前失败;实现后 store/managed + discovery/原 managed catalog 集合 72 passed(6.45 秒)。 +- publication gate 修复与 inspect 后 root/parent 替换负测、旧 Hosted + discovery/owned catalog 回归:77 passed(15.03 秒)。 +- 真实专用生产子进程两种 admitted 启动:2 passed(26.79 秒); + 空 Mux 浏览不建库,首个 Tab 后可列出会话,测试根暂移走不被重建, + 恢复原根后可继续列出。补精确 `SESSION_UNAVAILABLE` 断言和八读 + 上限后的本地 Hosted/managed/生产集合:35 passed(58.00 秒)。 +- 三视角复审关闭 executor 已提交但丢回执的 P2;只读性、原 owner + 清理及检查后替换负测通过局部复核,不代表完整交付验收。 +- 最终 store/managed discovery/原 catalog/完整生产入口集合:86 passed + (53.81 秒),包含整份见证消失后的同 catalog 身份记忆;三源 + mypy、相关 Ruff、依赖图检查、docs-light 和 diff-check 通过。 + +本节完成 managed 浏览诊断,不切换普通 Embedded writer,也没有 + 安装简短 CLI。下一步仍是 Embedded 共用准入、`lmux` 自动启动与 + attach 接线,再完成 Harnesstui、日志/临时文件及安装/SSH/性能验收。 + +## 60. 受管 Mux 创建语义与持久回执 + +简短命令不能将 registry 的名字 intent 直接接到旧 `create_mux`。 +本节增加中性的 `ManagedMuxCreateV1` 与创建事实回执:请求包含 + service、当前 instance、operation、名字及有界 opaque authority; + 不包含 AppHost 类型。回执保存原提交 instance、operation、名字 + 与准确 Mux ID,不声明该 Mux 仍存活或在线,authority 不持久化。 + +部署 consumer 注入 `ManagedMuxServiceBindingV1`,其纯 prepare + 工厂先交出原 admission owner,再进行 acquire。consumer 必须 + 实际校验名字预留、实例及 stop fence,并在准入后让停止/换代 + 等待该操作结算;一次 `validate()->bool` 不能满足合同。 + AppService 在原 state lock 内保管 admission 直到原提交结算; + acquire/commit/close 的 Task 发布前均不可进入真实副作用。 + caller 取消仍 join 原任务,清理失败保留原 admission,禁止新 + 操作,service close 重试原对象并继续其他 Session 清理。 + +受管记录使用明确的 continuity/v2,新增 service ID 与最多 4096 + 个创建回执,仍受整份 1 MiB 上限。新增 Mux 与回执在原 continuity + lease 的一个提交内落盘;后续 Tab 变更必须保留全部回执。旧 v1 + 编码字节不变;无 managed binding 不恢复 v2,managed binding + 不自动接管 v1,service 不匹配在打开任何 Session 前拒绝。 + 相同 operation/相同名字返回原回执;不同意图冲突;同名不同 + operation 不隐式 attach。查询旧回执也必须重新授权,重启后的 + 请求需绑定新 instance,但不改写原回执的提交 instance。 + +回执不按 TTL/FIFO 淘汰。名字被逻辑关闭后的旧 create 回执仍保留, + 重放不能复活 Mux。容量满时拒绝新创建,旧回执仍可查;完整编码 + 的容量预检发生在 commit IO 前,不因超限误 fence 整个服务。 + 实际提交异常可能发生在 replace 之后,此时拒绝继续依据旧内存 + 写入,报告 unknown;不将一次 load 可见误报成持久提交成功。 + 后续恢复从持久记录读取准确回执,不重新生成身份。 + +AppClientScope 的可选 managed creation capability 复用原应用级 + accepted-work owner;delivery waiter 取消、client EOF 不取消 + 已接纳创建。受管 profile 的 legacy create/close 在发布任务、 + 改 controller 或分配身份前拒绝。旧手工 profile 保持原语义。 + +本节只完成 create 语义内核和 scoped capability;真实 consumer + admission、管理 wire profile、registry result CAS、ManagedClose + 及简短 CLI 尚未接通,不激活生产 profile,不代表可安装交付。 + 下一步将以上公共合同接到原 managed registry/lifecycle owner, + 验证真实 stop 与创建的并发,再接管理 wire 和简短命令。 + +本节验证(交叠集合不累加):初始测试先确认缺少 managed contract; + 实现后 create/legacy continuity 集合 34 passed(1.12 秒)。补 + publication gate、字节预检、lost-commit 与严格格式后,相关 + runtime/scoped/continuity 集合 82 passed(1.73 秒);再加入 + scoped EOF、4096 条历史、双 Tab 变更/恢复,86 passed(2.12 秒)。 + 最后加入 32 live Mux 边界后,完整 AppService 与架构基线集合 + 178 passed、仅源码清单漏登失败(4.56 秒);补齐两个新增模块 + 后单独原断言 1 passed(20.12 秒),不重复已通过 AppService。 + 三视角复审通过 create 语义与 scoped capability;六源 mypy、 + 相关 Ruff、docs-light、依赖图与 diff-check 通过。回归证明不含 + 真实部署 admission、管理 wire、安装或 SSH 整体验收。 + +## 61. 部署 consumer 的受管创建授权与停止串行化 + +`ManagedMuxManagerV1` 借用已准入的原 registry/journal 和精确 + namespace/service/instance,不自行发现、启动或停止服务。签发 + 前在原 service fence 与短事务中核对 COMMITTED、未 stop、真实 + 名称预留及 operation;持久随机 opaque token 后才返回请求。 + 相同实例/相同 operation 签发重试返回原 token,不因失回执旋转。 + token 只进私有 registry 与请求,不进入 discovery、repr 或日志。 + +SQLite schema 6 新增最多 4096 行 `mux_authorities`,每行绑定 + 一个真实 reservation。当前授权 instance/token 可随明确的新 + 实例换发,但首次签发的 origin instance 和已登记创建结果不变。 + 新行走普通增长额度;已有许可换发和结果收口走控制预留,普通 + 增长额度不足不应封死这些操作。旧 schema 不做静默迁移。 + +服务端 prepare 是纯工厂,原 admission acquire 后释放全局 DB + 事务,仅保留该 service 的原 lifecycle lock 到 AppService 本地 + commit 结算。因此其他工作目录/名称仍可办理 registry 操作; + 同服务 stop 若先持久生效则 create 拒绝,create 若先获准则 stop + 返回 busy,须在原请求预算内继续等待/重试,不能越过原提交。 + caller 取消不是释放锁的证明,仍须 join 原 commit。 + +admission 的 native 进入/退出在同一 event-loop OS 线程直接执行, + 绑定原 loop/thread,不通过两次 to_thread 移交 RLock,也不占住 + child 单 worker 等待释放。这些短同步 IO 的预算是协作式检查, + 不是可抢占执行或 UI 响应时限的承诺;未来 GUI 不应照搬到 UI loop。 + admission 不关闭借用的 journal/registry,未知 native close 仍是 + 原文件 owner 的债务,不能重新调用耗尽的 context generator 假报 + 已清理,也不能关闭后来复用同一数字的 fd。 + +中性 admission 新增 `check_creation(previous)`:AppService 在返回 + replay 或新提交前核对已知结果。本地历史与已登记结果冲突/缺失 + 时零提交拒绝;即使父端尚未登记结果,旧 origin 的请求在新实例 + 中没有历史也不能当作首次创建,因为此前副作用可能已经发生。 + 这类未知仍占名字,需显式恢复流程,不通过换 token 获得重做权。 + +`record_created` 消费原精确认证连接返回的可信结果,核对当前 + permit 与原 operation/name/origin,首次结果写入后不可变;同值 + 重试可查回,冲突拒绝。原创建 instance 可以与当前授权 instance + 不同。stop 后仍可收口同实例已完成结果,旧实例 permit 不能更新 + 新代。跨 registry 与 AppService 不制造伪原子事务:intent/permit + 落盘、RPC、精确结果 CAS 是三个步骤,未知不释放名字。 + +本节实现真实 SQLite/flock consumer 并与 AppService 创建内核组合 + 验证,但管理 wire、专用生产 bootstrap 的 binding 注入与简短 + CLI 尚未激活;ManagedClose、Embedded 默认接线、完整 Harnesstui + 和整体安装/SSH/性能验收仍待完成。 + +本节验证(交叠集合不累加):新测试先确认 consumer 模块尚缺; + 首批真实 registry + 原 lifecycle/registry/AppService 受管集合 + 80 passed(5.53 秒)。补独立进程 stop、取消保锁、已登记/未登记 + 结果换代与失回执,相关子集 39 passed(5.71 秒)。复审修复后 + 文件 owner/consumer/lifecycle/registry/AppService 集合 133 passed, + 两条新 close 注入测试因尚未捕获目标 fd 而失败(7.31 秒);将 + 注入点移至实际 flock 后的校验,再运行这两个参数、精确清单、 + namespace/service admission 与专用真实生产 child 入口,82 passed + (66.00 秒)。三视角复审关闭历史丢失重做与未知 close 两项 P2; + 五源 mypy、相关 Ruff、docs-light、依赖图和 diff-check 通过。 + +## 62. 可选管理创建协议与原本地连接接线 + +新增封闭 `loushang.managed-mux/v1` create 请求/结果编码,单帧最多 + 4 KiB,独立于旧应用 algebra 和 execution wire。只传递部署层已 + 签发的有界 authority,不把连接认证成功当作创建许可;响应和 + repr 不包含 authority,transport 不签发 token,也不重试操作。 + +本地 profile 明确支持 managed 与 discovery/execution 的四种组合。 + 原私有 connection record 只在显式启用时加入 capability,旧 + record/hello 字节不变;认证摘要覆盖完整 capability 选择。启用 + 管理能力后,原 LocalPeer 只获取一个 scope,先保管再借用全部 + 能力;任何已声明能力缺失或 getter 失败均关闭原 scope,无降级 + 或第二工厂回退。旧手工 profile 不隐式进入受管模式。 + +三类请求共用原连接的单调请求编号、16 个普通和 4 个控制槽位、 + request Task 与关闭流程。受管 create 占普通槽位;协议版本与 + request ID 必须共同匹配,包含同为 AppFailure 的响应。接收端 + 在删除 pending、释放槽位和交付前校验创建 operation/name, + 因而调用方取消等待也不能绕过检查;只保留两个有界意图值, + 不延长 authority 保留。历史回执可以来自原提交 instance, + 不将其强制改为当前实例。未协商协议、重复跨协议编号及受管 + profile 的 legacy create/close 在语义副作用前拒绝。 + +已接纳创建仍归原 AppService accepted-work owner 管理;客户端 + EOF 只结束交付,原提交结算后重连可用同一 operation 查询原 + 回执。真实 registry permit 经认证本地 wire、AppService 原 + admission、continuity 提交和父端精确 result CAS 的组合已有 + 回归,不再只用模拟许可验证 transport。 + +本节不激活生产 AppHost/Coding bootstrap binding 或短 CLI。 + ManagedClose、默认命令/目录、Embedded 接线、完整 Harnesstui、 + 日志/tmp 总量上限与安装/SSH/性能整体验收仍需后续实现。最新 + 用户已要求整体完成后提交、推送、PR、合并并同步本地 main 与 + harness lane;本节局部通过不是提前发布未完成分支的依据。 + +本节验证(交叠集合不累加):初始 wire/兼容集合 48 passed, + 四个新增 profile 测试缺少显式 capability 注入,补齐后扩展 + 集合 49 passed(1.58 秒)。评审所见取消后错回执用两参数 + 负测复现,28 passed、2 failed(1.42 秒);将意图校验移到 + receiver 后,全 AppServer、真实 consumer 与精确清单集合 + 414 passed、10 skipped(22.72 秒),剩余 36 个旧握手组合 + 同样缺少 managed capability 注入,另有一处 source inventory + 漏登。修正这些测试/清单并补原始帧服务端超额零 dispatch, + 相关集合 101 passed(16.55 秒)。三视角静态复审通过当前 + wire 范围;八源 mypy、相关 Ruff、docs-light、依赖图与 + diff-check 通过。未将这些本地测试当作真实安装或 SSH 验收。 + +## 63. 专用生产 child 的受管创建绑定 + +`ManagedChildBootstrapV1.managed_mux_binding` 只在原 open 成功且 + application bind 之前可用,借用原 registry/journal 生成并保留 + 一个 manager。纯绑定不签发许可、不进行新的 native admission, + 也不把 provisional 当作 COMMITTED。换 application ID 拒绝; + bind/关闭后不再提供新绑定。已有绑定的 acquire 仍核对原 journal + 的当前状态,依赖关闭后不能继续创建。 + +中性 AppHost application/continuity 请求显式携带管理 binding; + AppService recovery 使用原绑定和 continuity/v2。AppHost 与 + Coding 的 continuity 请求在构造时匹配 application ID,早于 + store acquire;两层非 continuity 工厂在构造任何 Product、 + catalog、runtime 前拒绝管理 binding,不因错误入口关闭调用者 + 的 Session catalog。Coding 还要求 managed Session selection, + invocation 的 service/instance/application 身份在路径 IO 前匹配。 + +HostedLocalRuntime 仅在显式 mux_management 且 connection instance + 等于已准入应用 binding 的 instance 时发布能力。专用 + `loushang.coding.managed_process` 接入该绑定;旧手工 Coding local + command 默认仍关闭。ManagedConnectionLease 从原精确认证连接 + 借用 managed capability,不改变发现、重定向、启动或停止语义。 + +没有新增生命周期 owner:bootstrap 保管控制资源直到原 child + application 清理结算。原 control 的 off-loop stop 与 AppService + commit 共用 service fence,锁序仍为 service fence 后短 DB。 + 确定性测试让提交持锁、control 到达原锁入口,再由同一 event loop + 释放提交并验证停止结算;不通过另一套 journal 绕开争用。 + +真实源码环境 child 覆盖管理能力发布、伪造 authority 零创建、 + 合法许可创建、registry 精确 result CAS、原客户端退出、新精确 + 连接重查同一回执以及 warm coordinator 复用同一实例。测试侧 + 对明确 native busy 只在原 deadline 内重试同一个控制操作; + 不重发 RPC,不分配新 operation/instance。短命令的管理请求 + 协调器尚待实现,不能据此声称命令层已经支持可靠重试。 + +本节接通专用生产入口,但尚无短 `lmux` CLI、ManagedClose 名字 + 释放、默认机器路径、完整 Harnesstui 复用或安装/SSH/性能整体验收。 + 完整目标保持不变;按当前 goal 边界不自动推送或合并。 + +本节验证(交叠集合不累加):真实生产入口先用负测确认旧记录 + mux_management=False(1 failed,9.02 秒)。接线后生产、managed + local 与 bootstrap 集合 53 passed(50.81 秒)。非 continuity + 入口的评审 P2 先负测复现(1 failed,5.71 秒),修复为前置 + 拒绝;扩大生产/旧手工入口/AppHost application/continuity 集合 + 110 passed(104.31 秒),一条测试将 provisional 错写为 prepared, + 另一条未处理已定义的短暂 native busy。修正断言后,纯绑定、 + 同 journal 竞态与前置 application ID 校验 3 passed(7.55 秒); + 固定预算重试同控制操作后,生产 child 集合 6 passed(47.05 秒)。 + 架构基线、旧 Coding application 和精确连接集合 36 passed + (38.31 秒)。三视角复审通过本轮接线;十二源 mypy、相关 Ruff、 + docs-light、依赖图及 diff-check 通过。源码环境验收不替代安装、 + SSH 断线与完整首次使用性能验收。 + +## 64. 创建操作的命令侧统一协调 + +`ManagedMuxCreateOperationV1` 借用已准入的原 registry/journal 和 + 完整 reservation,拥有一个原 ManagedServiceCoordinator 和一个 + 带 publication gate 的操作 Task。构造前置校验 namespace、 + service、registry/journal 借用关系;Mux operation ID 来自固定 + reservation,不混用服务启动 attempt ID。首次 run 必须提供有效 + 绝对 deadline,非法值在 Task/存储/启动前拒绝;重复 run 只 join + 原任务和原期限,不重新分配身份或续预算。 + +流程为:预留完整名字意图、原 coordinator 启动/复用、匹配原认证 + 连接的 application ID、签发许可、一次 managed create RPC、 + 原 manager 登记结果。LocalAppClientConnection 的 application_id + 来自同一次认证且完成 hello 的 record,未 ready/已 close 不可 + 借用;不为核对身份重新读取可变路径。ManagedConnectionLease + 原样借用该事实。应用 ID 不符在签发许可和 RPC 前拒绝。 + +控制 IO 复用本包 `_settled_native` 原生回执,只有明确 busy 可在 + 原期限内重试同一个控制操作;registry.reserve_mux 增加可选 + deadline,原调用保持兼容。全程不持全局事务跨 await/RPC。 + 创建 RPC 只发送一次,超时取消交付 waiter 而不取消服务已接纳 + 工作;断连、丢回执、重 join 均不自动重发,不释放名字或停止服务。 + +收到合法回执后立即保留 `created`,随后才做 result CAS;`result` + 只有对账成功后才赋值。两者均为历史创建事实,不声明 Mux 仍 + 开启或服务在线。对账前或提交后失回执仍保留已知 created,不能 + 伪装成尚未执行并重新创建;原登记提交后 busy 只重试相同事实。 + +close 先 fence 新工作,允许已返回回执在原预算内完成对账,再 + join 原操作并关闭 coordinator 的本地资源。借用 connection 不 + 转移所有权,关闭后不可再借;清理失败保留原 coordinator 重试, + 不关闭 registry/journal,不停止后台服务,也不释放未知名字。 + +真实生产 child 回归覆盖该操作完成冷启动创建、操作 owner 关闭 + 后服务仍存活、第二名字暖复用同一 child 且不产生第二次启动。 + 这是短 CLI 的可调用创建链路;默认 namespace/path、CLI 语法、 + ManagedClose/名字释放及安装/SSH/完整性能验收仍需后续工作, + 未将创建协调单项通过当作完整 lmux 交付。 + +本节验证(交叠集合不累加):初始负测确认缺少协调模块;首批 + 创建、原 coordinator 与 registry 集合 38 passed(3.90 秒)。 + 扩展故障测试先复现必填 deadline=None 与错误 application ID + 两项 P2,11 passed、2 failed(2.35 秒);前置必填期限校验与 + 原认证身份匹配修复后,创建操作、真实 child 冷/暖启动两种 + storage profile 和 LocalAppServer 集合 52 passed(22.82 秒)。 + 再补登记提交后丢回执、相同事实 busy 重查、Task 实际发布后 + 抛错,连同精确连接与源码清单,37 passed(19.78 秒)。三视角 + 复审关闭两项 P2;五源 mypy、相关 Ruff、docs-light、依赖图与 + diff-check 通过。真实进程测试使用当前源码环境,不作安装验收。 + +## 65. 默认机器命名空间与实例临时目录 + +AppHost 可选 `managed.defaults` 复用 Foundation 的平台路径选择, + Hosting 只负责读取 Linux OS 已配置的机器身份并生成域隔离键。 + 默认解析不创建部署目录或 Session;Path 规范化可能查询符号链接, + 不承诺零文件系统读取。默认模块不从 AppHost core facade 激活。 + +机器键使用固定版本域 `loushang.managed.machine/v1`;只接受可信 + `/etc/machine-id`,以 no-follow/nonblocking/cloexec 打开,验证 + root owner、目录/普通文件类型、不可 group/other 写及读取前后 + 身份/元数据稳定,最多读取 34 字节。拒绝空、未初始化、全零及 + 非法 ID,不使用 hostname、boot ID、随机值或其他路径回退。 + 原始 ID 不出现在返回值、异常或目录中,机器键仅作查找隔离, + 不授予进程权限。克隆机器仍需 OS 管理者提供不同 machine-id。 + 清理显式跟踪本次主异常,两个 fd 分别尝试一次;未知关闭结果 + 不重试数字 fd,也不以调用者外层 except 异常掩盖本次关闭失败。 + +默认 durable 管理根仍为 `$LOUSHANG_HOME/lmux/machines//`, + admission witness 仍在平台 state;Session 仍走 Coding 默认策略。 + runtime 优先 LOUSHANG_RUNTIME_DIR,其次 XDG_RUNTIME_DIR/loushang, + Linux managed fallback 明确为 `/tmp/loushang-`,不跟随各 + SSH shell 的 TMPDIR/TEMP,也不触发 tempfile 首次可写目录探测。 + 有效高优先级覆盖遮蔽低优先级值;只有实际参与选择的根才校验, + 相对根拒绝,防止跨 cwd 改变服务命名空间。 + +无显式 LOUSHANG_TMPDIR 时,暂存采用服务器实例 tmp 叶子。 + 显式覆盖保留为 `/lmux///`。 + 冲突按派生的管理子树判断,而非整个共同祖先目录;显式 `/tmp` + 可与默认 runtime 共存,真正与 durable/runtime 子树重叠仍拒绝。 + starter 构造时先做纯路径校验,早于 durable prepare/native spawn。 + 原 layout、invocation、bootstrap 传递同一冻结选择,不新增 owner。 + +无覆盖的 managed-child/v1 字节保持兼容(64 KiB);显式覆盖使用 + 闭合 v2,必须有字符串 temporaryRoot(96 KiB,以容纳第四个最大 + Unicode 路径),不容许 v1 偷加字段或 v2 缺字段降级。Coding + request 和 child entry 都以 invocation 派生的最终实例叶子覆盖 + LOUSHANG_TMPDIR;不将可变 inherited 环境当作路径权威。Session + catalog、附件和 writer 目录继续拒绝与显式管理 scratch 交叠。 + +本轮三视角复审修复关闭失败被外层异常掩盖、共同祖先目录误拒、 + 未生效低优先级配置误拒三项 P2。真实生产 child 回归增加默认与 + 显式 scratch,跨手工/准入两种存储 profile,验证首次创建布局、 + 父操作关闭后服务存活及第二名字复用原 child。此处没有激活短 + CLI;完整 Harnesstui、ManagedClose、日志/tmp 实际有界写入及 + 安装/SSH/首次使用性能验收仍未完成,不能据本节宣称整体交付。 + +本节验证(交叠集合不累加):默认/机器键/消息负测 74 passed、 + 1 failed(1.08 秒,缺少 scratch 字段);接线后扩大真实 child、 + coordinator、创建与架构集合 180 passed、1 failed(135.07 秒, + 另一架构清单漏登记此前 managed 模块,已补齐)。评审故障负测 + 1 passed、2 failed(0.97 秒);修复后默认/身份/bootstrap/ + Coding Session 隔离/架构集合 158 passed(11.09 秒)。补最大 + Unicode v2 payload、读取中变化及双 close 失败后 61 passed + (1.03 秒)。十源 mypy 与相关 Ruff、docs-light、依赖图和 + diff-check 通过;本机 native 默认解析无创建探测也通过。沙箱 + 中 `/etc` owner 映射为 65534 的同探测被安全拒绝,未放宽策略。 + +## 66. 短 CLI 创建、发现与重连预览 + +新增 `lmux` console script,保留 `loushang-mux` 显式旧语法。 + `coding.cli.lmux` 只解析参数及做 stdin/stdout TTY 前置检查, + help 不解析默认目录;之后延迟导入 Coding 命令组合。当前支持 + `new -s [--workspace ...]`、`attach [-t ]`、 + `ls [--after ]` 和裸命令,不将尚未实现的命令放入帮助。 + +同步 namespace/service/journal 准入和交互选择发生在 Runner 前, + 先保留 owner 再 open。所有准入在固定期限内;选择器等待用户 + 期间不持事务/服务 fence,选择后才建立连接操作预算。CLI 只做 + Product 选择和组合,公共目录、名称、启动/连接权威仍留在原 + AppHost owners,不复制 runtime。工作区规范化在首次创建前。 + +new 先预留完整名字意图,再准入所属 service;原创建操作只重查 + 这个相同 reservation,不另分配操作 ID。成功按创建回执中的 + Mux ID attach。同名冲突不静默 attach,也不创建另一工作区服务。 + attach 只用公共 discovery 选定 service/instance,再打开原 + journal 和精确连接,核对已认证 application ID,不调用 service + admission、不启动或重选实例。名称回收/ManagedClose 尚未激活, + 后续接入时仍须补完整 reservation→Mux ID 的历史结果绑定。 + +裸命令在没有任何登记名称时创建 main;pending/unavailable 名字 + 也保留在全局选择器中,不过滤后误判为空。当前选择器为有界文本 + 列表,可取消;跨页使用显式 attach -t。无目标 attach 的在线 + 唯一目标自动选择尚未实现,不能把此预览当作全部 CLI 合同。 + ls 使用只读分页,Tab 数为 null、状态 unknown,并单列 recorded + lifecycle facts;不以持久记录声称在线。工作区输出经 ASCII JSON + 转义,不将控制字符送入终端。目录/Session 没有隐式迁移。 + +收紧公共 namespace open 的 not_found 合同:只有独立 witness + 及原 registry-parent/runtime 两个探测均缺席,才作为空部署。 + 初始化 witness 已存在后依赖丢失报 unavailable;残留或未知 + 身份不回退为空。CLI 不通过私有 pathname/exists 猜此分类。 + read-only 查询不重建丢失 registry、DB、锁或 witness。 + +run 的 finally 先结算 shell 与原创建操作/连接,再关闭借用的 + journal/service/namespace。Runner 未进入协程便失败时,idle + pending 回调仍先释放同步准入句柄;async 活跃时不释放其借用。 + 未结算 owner 沿用 process-only 非零退出语义,不报成功或自动 + stop/replay;命令正常结束只 detach,后台进程仍由服务拥有。 + +当前仍暂用 HostedMuxShell,真实 child 回归替换终端 runner,仅 + 验证服务/协议 attach,不作 PTY/完整界面验收。完整 Harnesstui、 + ManagedClose、start/stop/status/logs、有界实际日志/tmp、并发 + 首次命令争用、安装/SSH/首次使用性能等仍是完整 goal 的必达项。 + 没有据此预览提前提交、推送或合并。 + +本节验证(交叠集合不累加):初始测试确认缺少短入口模块;新旧 + CLI 首批 15 passed(6.03 秒)。扩大验证暴露 pytest setup/call + 重建捕获流使 TTY 模拟失效,修正为调用时绑定,未放宽生产 TTY + 检查。三视角识别并复核关闭 ls 损坏状态误报空库 P2;同时补 + Runner 未进入协程便失败的原句柄结算。CLI、namespace admission + 及 G9/基线架构集合 76 passed(45.26 秒)。裸首次创建、显式 + 创建及 DB/锁丢失扩大集合 40 passed、3 failed(45.22 秒); + 三项为旧 A0/G16 门禁未登记已受审 managed seams,精确同步 + consumer/value imports 后 15 passed(18.55 秒)。记录值模块 + 已审 capability 扩展的单文件预算从 180 到 200,整体 1100 与 + stdlib/无 ambient IO 约束不变,经架构复核确认。损坏路径测试 + 比较根及子树 inode/mode/mtime/完整字节,不仅比较目录名字。 + 三源 mypy、相关 Ruff、docs-light、依赖图及 diff-check 通过。 + +## 67. 精确实例的停止观察与显式重启(实现复审待完成) + +Linux group observation 借用原 pidfd observer,在 leader 存活时核对 +PID/PGID/SID,查询与 close 串行化;仅以原进程退出及原进程组 +不存在的实际观察补写 native stop evidence。它不发送终止信号, +不以超时、权限错误或 socket 消失声称完成应用清理。应用清理事实 +仍由子进程通过原 journal 报告,三个事实齐备才返回 stopped。 + +ManagedServiceStopOperation 借用原 journal,保留原实例、deadline +及已接纳操作;取消调用者等待不会撤回停止意图。短 CLI 新增显式 +start -t NAME,以及 stop --server SERVICE_ID;非交互 stop 要求 +--yes,交互模式先预览受影响 mux 再确认。此切片暂不提供 --all +或服务别名。attach 不隐式重启已停止服务,显式 start 可恢复原 mux。 + +本轮修正 CLI 回归插入位置与 names 类型注解,停止确认前输出明确 +标记为 stop_preview。底层 group/process/stopper/lifecycle 集合 +64 passed(6.23 秒);CLI 集合 22 passed(40.07 秒),涵盖真实 +子进程停止、显式新实例重启及原 mux 恢复。六源 mypy、相关 Ruff +和 diff-check 通过。这不替代本切片实现三视角复审,也不代表完整 +Harnesstui、真实安装、SSH 或性能验收完成。完成全部目标后按用户 +最新授权提交、push、PR、合并并同步本地 main 与 harness lane。 + +## 68. 停止复审修复与原预算内的清理回执 + +三视角 §67 实现复审发现并关闭两个 P2:停止操作在 worker 实际入场 +与成功回执公开交付前都要复查原 deadline;交互确认不得把截断的 +`yes no` 误接受为 yes。新增负测先复现三个失败,修复后通过; +stopper 集合 11 passed。确认行必须完整换行,空行/EOF/否定/截断 +均不构造 stopper。原 native 事实不因迟到交付被抹掉,也不重跑 stop。 + +扩大真实 CLI 回归发现一次清理三事实齐备但子进程退出 1。随后以真实 +bootstrap/journal,分别在首次 request_child_stop/record_child_cleanup +注入一次 busy,确定性复现正常停止被提前记为失败的两个窗口。修复 +为原 close deadline 内,对已结算但未确认的控制观察有界重查;不换 +worker、不替换 pending job、不重跑成功的 Application.close。永久 +失败测试使用显式短预算,先 join 原 close task,再授予下一次预算, +继续验证真正超时/应用失败非零与已完成应用清理不重复。 + +该修复通过生命周期局部复审;没有把清理后退出码 1 一概清零。后续 +扩大集合仍观察到 CLI attach 的瞬时 busy,不能据此声称整条 CLI +稳定性已验收。实际 traceback/错误码仍需结合原调用阶段定位。 + +## 69. 共享 Markdown 与会话操作 binding 增量 + +将原 Coding 默认 transcript theme 原样提取至 +`harnesstui.conversation.theme`,Embedded 保留原工厂别名与内容; +三个 Hosted CLI composition 显式注入同一主题工厂。shell/screen +接受可选 theme,继续使用原 ScreenConversationApp、TranscriptRegion +及终端 owner 提供的 capabilities;无环境探测、无 Product 回退。 + +新增实际渲染对照覆盖宽/窄屏、流式/完成态、Markdown 标题/强调/ +代码/链接、切 Tab 草稿与 renderer 保持。另验证 OSC/CSI 控制序列 +过滤且原 transcript 不变;自定义 hyperlink 主题在终端能力关闭时 +确实不输出 OSC8。共享视图/终端集合 25 passed,安全/详情/编辑器 +集合 13 passed,不能把这些 fake-terminal 证据当成 SSH 验收。 + +`mux.conversation_binding` 实现已有 ConversationActionHost,接受 +ConversationTextAction,借 AppClient 而不创建新 task owner。submit、 +steer、follow-up、interrupt 均通过原 ShellActions;interrupt 仍用 +control 保留槽。每 window 仅持有最新投递展示与本地 request ID, +结果和失败均核对 attachment/generation/member/session/request ID; +refresh 不搬移旧请求状态。中性 Screen state/frame 独立显示 running +与 pending/acknowledged/unknown;Ack 不冒充运行完成,idle 不清除 +unknown,不自动重发。关闭后回执不再修改视图。 + +复审修复了输入验证被推迟至后台导致纯空白 prompt 清空/永久 pending, +以及 interrupt 遗留全局失败回调两项 P2:现在提交 task 前同步验证, +拒绝保持草稿,成功保管后才发布 pending;中断与文本统一隔离。 +对应七个负例先失败,修复后纳入扩大回归;三视角局部复审均通过。 +任务容量拒绝、附件前置拒绝、unknown 不重发等增量集合 23 passed。 + +独立评审确认 binding ≤150 行、中性 request presentation 与 theme +各 ≤60 行;新文件进入精确 inventory 及同一禁 native/Product 导入 +扫描,原 shell 四文件 ≤950、原语义 controller ≤600 不变。 +旧 G11 门禁另补齐 §60–63 已审的六个 managed 消费者。原 AppService +四个 core 文件继续全部计数;§60 ManagedCreate 净增 170 行后限额 +由 1500 调整为 1700,managed_mux.py 单独进入 ≤80 行预算组。经 +架构复核确认它们仍协调原 state lock/continuity 权威,不为压行数 +拆出新的 owner;原 Product/process 禁依赖扫描保持。 +这完成共享展示/操作端口接线的增量,不等于全部 M3:能力矩阵、审批 +展示整合、ManagedClose/名称回收、Session 缺省全入口接入及真实安装/ +SSH/首次使用性能、有界实际日志/tmp 仍待完成。 + +下一稳定性切口:不要通过重跑整个 CLI、namespace.open 或连接 prepare +掩盖瞬时 flock 竞争。锁等待必须显式选择,只允许同步准入或原 native +worker;不能仅因提供 deadline 就让所有 flock 同步等待。已有 Mux +admission 会在事件循环线程跨 await 持 service fence;同 loop 等待 +会堵住释放者。此约束已做两视角设计核对,等待实现与跨进程/超时/ +替换锁路径/事件循环 fail-fast 负例;尚未修改底层 flock 默认语义。 + +本轮末端复验:G11/G16 架构、全部请求隔离及 lifetime 集合 +43 passed(35.59 秒);前一轮 child/G11/G16 集合 37 passed、2 个 +旧 G11 清单/预算失败,已按上述精确同步修复。相关 Ruff、mypy +与 diff-check 通过。真实 CLI 扩大集合曾出现 attach 的 lmux_busy/ +local_operation_failed,因此未宣告完整稳定性通过,未提交/发布此 +未完成目标;保留测试侧原异常以继续定位,不放宽成功退出断言。 + +## 70. 同步准入显式等待与事件循环 fail-fast + +沿 §69 约束增加显式 `wait_for_lock`,默认 False;deadline 本身仍不 +启用等待。只有短 CLI 的同步准入/发现/预留及连接原 native worker +显式选择等待。数据库与 journal 透传原预算,fresh namespace/service +创建保持原单次准入与失败关闭合同;不重跑整个 CLI、open、prepare、 +业务事务或 RPC。竞争期间保留原 fd,逐次核对原命名身份;替换路径、 +预算耗尽或无效记录均拒绝,不重新打开锁也不取得 unlink 权利。 + +复审另发现同一目录 owner 的 worker 持有本地 RLock 等待 flock 时, +loop 的默认调用仍可能在进入 flock 前阻塞。新增有/无 deadline 两个 +负例先失败,再将 `_operation` 在运行中的事件循环上改为非阻塞获取; +worker 保留原期限和同一把锁。没有将跨 await 的 admission RLock 转交 +其他线程。显式等待在 loop 上即使锁空闲也前置拒绝;非法/缺失 deadline +同样在打开 fd 前拒绝。 + +覆盖真实跨进程竞争释放、同 fd 保持、超时、路径替换、异 owner 与同 +owner 事件循环竞争,并同步原服务崩溃注入 seam 的新增关键字参数。 +扩大 files/connection/namespace/service/discovery/CLI 集合最终 +194 passed(63.79 秒),包括原真实子进程启停、重启与跨 cwd attach。 +先前扩大集合 180 passed、7 failed:两个运行进程载入修正前的 mutex +入口,五个旧崩溃注入 seam 不接受新增关键字,均已修复后复验。 +三视角局部复审通过,九源 mypy 与相关 Ruff 通过。此证据关闭本次锁 +竞争切口,不等于并发首次初始化、真实安装/PTY/SSH 或完整目标验收。 + +追加真实 lifecycle flock 等待期间取消连接的回归:调用者取消后仍保留 +原 prepare/native worker,close 等待原回执,不提前关闭借用 journal, +不创建连接或重发准备。connection/lock-wait 集合 33 passed(3.95 秒); +文档轻门禁及 diff-check 通过。 + +后续仍按完整目标推进 ManagedClose/名称回收、Session 缺省全入口、 +完整会话能力绑定、有界实际日志/tmp 与安装/首次使用性能验证。完成 +全部实现和最终复审后,按用户授权提交、push、PR、合并、同步本地; +不提前发布当前未完成目标。 + +## 71. ManagedClose:实现前的关闭与名称回收合同 + +Target,三视角设计复核通过;纯值/schema 已实现,运行能力尚未激活, +不代表当前 CLI 已提供 close。 + +关闭沿已有 AppHost 准入、AppService state lock/continuity 与原 Session +owner 收口,不新建平行 Mux runtime。新增独立可选关闭合同;旧 creation +协议和 legacy close 语义不扩权。请求绑定当前 service/instance、独立 close +operation ID、原 creation operation ID、精确 mux ID 与 name;AppHost +发放单独用途的关闭 authority,不接受创建 token 充当关闭许可。确认预览 +冻结这组身份,不在确认之后按名字重新选择。Session 历史不删除。 + +AppService 在准入 fence 下先耐久提交 cleanup_pending 与原成员身份, +再撤销 attachment 和新增操作入口。原 Session owner 清理实际结算后, +才将同一关闭记录变为 closed,并从待关闭持久成员集合移除该 Mux。 +关闭记录的原 committing instance、close/create operation、name、mux ID +不可变;cleanup_pending → closed 是唯一状态进展。请求返回超时/取消、 +连接 EOF、停止服务或进程消失,都不自行生成 closed 事实。 + +持久记录必须区分活动与待关闭 Mux:cleanup_pending 仍保留准确的成员 +恢复描述,closed 必须无对应持久 Mux;一个 creation/mux 只能有一个关闭 +操作。已有历史 creation receipt 不删除、不重写为新 mux。新版本 continuity +闭集校验这组关系,保持旧 v1/v2 编码不变;旧消费者不得把新关闭状态当作 +普通可恢复 Mux。恢复时 pending 不能重新出现在 list/attach/Tab;由原 +AppHost 新实例准入提供旧实例清理/退出屏障,再沿既有 Session 恢复与关闭 +端口结算残余,未证明屏障或结算失败则保持不可服务/cleanup_pending。 + +清理期间不持全局 registry 事务等待 RPC 或 Session.close。AppService +保持已接纳关闭与原成员 owner,调用者取消只取消等待;失败重试只处理 +原未结算 owner,不重新关闭已完成成员。并发重复关闭按同一 operation +对账,另一个 operation 不接管原债务。服务停止必须 join 已接纳关闭, +成功关闭一个 Mux 不停止服务。 + +锁与任务结算分界:durable pending 后在原 state/mux lock 内同步摘除 +可服务索引、登记原清理 owner;退出这两把锁及准入物理 fence 后,才 +join Session.close。stop 也必须在 state lock 外 join 已接纳关闭,避免 +阻塞其最终提交。stop 赢得准入 fence 后拒绝新 close,但已耐久接纳的 +同实例关闭允许在 stop_requested 下提交结算;此结算重新取得原实例 +fence,不能要求服务仍 accepting,更不能允许跨实例替换写入。 + +AppHost 只消费原认证连接上匹配全部身份的 closed 结果,在当前实例 +fence 下 CAS 记录关闭事实并释放原名称引用;迟到旧 epoch 回复不得写入 +新实例,迟到旧 operation 不得影响同名新 mux。索引写失败/丢回复保留 +unknown 与原名称,通过只读关闭结果查询对账,不盲重发 close。历史关闭 +记录有界且先预留容量,容量不足在关闭副作用前拒绝,不自动删除未知债务。 + +名称引用与历史意图分开:当前 muxes.name 主键和 authority 外键不能直接 +删行冒充释放。后续 registry schema 必须保留不可重建的 creation/close +历史,仅 CAS 释放 active-name 引用;同名新建使用全新 creation operation。 +关闭结果的 committing instance 是原历史事实,查询的 serving instance +是当前连接身份,二者不得混为一谈。新实例的认证查询可交付原 closed +事实,当前 fence 下对账原 name/create/mux/close 引用;旧连接迟到回复 +仍拒绝。两次持久提交的未知结果均保留原 owner/名称,不退回 active。 + +本阶段恢复屏障只认现有 cleanly_stopped 三事实齐备的旧实例。异常退出 +但缺应用清理或 native scope 证据时仍 unavailable,不借“恢复”伪造清理 +完成,也不在本关闭功能内引入新的强制杀进程或异常接管权限。 + +实施顺序:闭集值与版本化持久校验 → 原 AppService 关闭/恢复收口 → +AppHost 用途隔离的许可与 CAS 名称释放 → 可选 wire/客户端/CLI 接线。 +每步仅在对应能力完整后激活,不让半实现 close 绕过 legacy 禁止路径。 +首个 schema 切片同步收紧 recovery 的版本白名单:无 managed binding +仅接纳 exact v1,当前仅支持 creation 的 binding 仅接纳 exact v2;新增 +v3 在任何 Session resolver 构造前拒绝,直到关闭恢复能力显式接入。 +验收覆盖有/无成员、活动 turn/审批、并发 close/stop、断线、取消、清理失败 +与重试、两次写盘的未知回执、进程恢复、同名 ABA、真实 CLI 确认与历史保留。 + +CLI 继续遵守[原草案 §4](../drafts/lmux-managed-service-design.md#4-用户命令合同target非当前命令帮助): +非 TTY 必须 --yes,交互确认必须完整行;离线 close 不启动服务。 + +本轮实现:新增独立 ManagedMuxClose 请求/phase/state 值,authority 不进入 +repr 或持久记录;精确 close/create/mux 身份与两个 phase 严格验证。 +continuity v3 纳入有界关闭历史,交叉核对 creation/name/mux 与 pending/ +closed 的持久 Mux 关系;v1/v2 字节不变。恢复当前只准入 exact v1/v2, +在 resolver 前拒绝未激活的 v3,防止待关闭成员被旧算法重新开启。 +摘要数值仍是保留状态计数(含清理债务),不冒充在线 Mux 数。 + +三视角设计修复补齐锁外 join、stop 后同代结算、origin/serving 分离、 +active-name/历史意图分离与严格恢复白名单。纯值/schema 实现复审通过; +测试复审另补双合法目标的独立 close-operation 重复/跨 creation 别名 +负测,避免重复目标提前拒绝而掩盖真正的 operation 唯一性规则。 + +架构复核接受 continuity 精确组 1250 → 1300(本次净增 59 行,当前 +1258 行),仍在原模块校验 schema 与恢复合同,不另拆 owner;新 close +纯值 69 行进入独立 ≤80 行预算和原依赖扫描。原 core/protocol 预算 +保持,三个旧精确源清单同步新增文件,无新 CLI 或 runtime 激活。 + +验证:原 creation/continuity/recovery 基线 51 passed;新增合同在实现前 +34 failed(缺少新模块/字段),实现后扩大集合 92 passed,仅旧 continuity +预算 1 failed,按上述独立复核同步。补齐唯一性/容量负例后的功能集合 +88 passed(1.96 秒)。真实 JsonFile store 的 pending、closed 分别写入/ +释放 lease/重开保留完全相同身份;包含该测试及架构集合 38 passed、 +2 个旧清单失败。补齐 close 值文档行和 §67 已审 stopper 遗漏后,两项 +清单复验 2 passed(25.60 秒)。Ruff、三源 mypy、依赖图检查、文档轻 +门禁及 diff-check 通过。此处没有调用实际 close,也没有验证其清理/名称 +释放;下一步继续原 AppService 接纳、两次提交及原成员 owner 的运行接线。 + +## 72. 原 AppService 的关闭运行接线(仍未接入 Host/wire/CLI) + +新增 default-None 的 managed closing binding;纯端口分别表示 ADMIT、 +OBSERVE、SETTLE,不沿用创建 token。它由消费者提供原 acquire/check/ +close admission,AppService 不负责发放授权。当前生产 AppHost 未提供 +该 binding,旧入口仍不具有管理关闭能力,v3 恢复仍按 §71 拒绝。 + +运行逻辑留在原 AppService:在 state/mux lock 与原 admission 下提交 +pending,同步隐藏 Mux/撤销 attachments,记录原成员引用和不可变 pending +snapshot;完整释放 admission 后才启动并保管 cleanup task。所有后续 +continuity 提交均合入 pending snapshots,部分成员清理完成也不提前 +释放其 Session ID 占用。原 SessionOwner 结算后,在 SETTLE 阶段提交 +closed 并移除 pending snapshot;同 operation 的多个等待者加入原任务。 +查询只鉴权读取已有记录,不开始清理或写入,不以无 Mux 推断 closed。 + +关闭服务先同步 fence execution/discovery/scopes 及新的 AppService +请求,再锁外 join 原关闭任务;预算耗尽不取消原任务,失败后显式收口 +只重试未结算的原成员。已成功的 port.close 不重复执行。任一次持久写 +回执未知都保持原 owner 和 unavailable/cleanup debt,不以成员已清理 +推断 closed,也不由 service.close 宣称完整成功。没有引入强制回收。 + +三视角实现复审修复三项 P2:ADMIT 成功释放阶段的取消要延迟到原清理 +任务发布后传播;部分清理成功的 Session 仍由 pending snapshot 占用; +已借出的发现/执行/scope 能力须在首个 drain await 前被同步撤销。三个 +负例先确定性失败,再修复通过。真正的 admission 释放失败仍保留原 +admission,禁止启动清理;不是把取消和释放失败混为一谈。 + +测试复审另纠正 stop 拒绝用例:原测试对已 pending 目标换 operation +会先触发冲突,不能证明 shutdown 拒绝。现预建另一合法目标,并在明确 +shutdown 屏障后验证拒绝且无新增 commit/closure。双目标并发、同 intent +双等待者验证原 task 恒等,各成员只关一次;第一个目标 closed 时另一 +目标的 pending record 与完整成员 snapshot 仍保持原值。 + +架构复核允许原 core 精确四文件预算 1700 → 1900(当前 1876 行), +两个 AppService managed 合同文件合计 103 行进入同组 ≤120;不拆平行 +runtime、不隐藏新增文件。旧 protocol/continuity 与其他组预算保持。 +这一增量尚不包含 v3 pending 恢复、AppHost 关闭许可与名称 CAS、管理 +wire/client scope 接线或 CLI;完整目标继续,不以运行单测替代真实安装。 + +验证:新增端口后的旧创建/恢复基线 63 passed;四个运行负例先因缺失 +close/read 方法失败,实现后相关集合 67 passed。两阶段丢回执、任务 +工厂拒绝、提交中取消与目标鉴权集合 13 passed。复审三项精确负例先 +3 failed(取消后清理未继续、Session 重开落到 ValueError、借出 discovery +未被 fence),修复后扩大集合 94 passed。补 admission 释放失败保持原 +owner、完整并发 snapshot 后,AppService 全套、AppHost 原创建许可/ +操作与架构集合 259 passed(80.72 秒);Ruff、四源 mypy、依赖图检查、 +文档轻门禁与 diff-check 通过。三视角局部复审已通过,没有据此宣布完整 +ManagedClose 或整个 lmux goal 交付完成。 + +## 73. 原 RecoveryAttempt 的关闭恢复(可选接线,Host 未激活) + +v3 恢复另需消费者显式提供 recovery admission factory;仅有 live-close +binding 仍拒绝 v3。许可分 ADMIT / SETTLE,输入是完整冻结 continuity +record,不伪造客户端关闭请求或 token。消费者必须在原实例 fence 内核对 +当前启动实例及前代 cleanly_stopped 三事实;SETTLE 只允许同代已接纳恢复 +结算,允许 stop_requested,但不允许跨实例。AppService 不判定 PID 存活。 + +沿用原 RecoveryAttempt、continuity lease 和 SessionOwner,不另设恢复 +runtime。在任何 resolver 调用前完成 ADMIT 并完整释放原许可;只对 pending +Mux 的原成员恢复清理 owner,锁外结算。已结算成员留在本次 attempt 的 +身份表,后续失败重试不重开、不重复关闭。原 admission 释放失败时保留 +同一 owner,先解决释放再进入 Session 清理;调用者取消仍 join 原 open task。 + +全部 pending 成员清理完成后,重新取得 SETTLE 短许可,在原 lease 上单次 +CAS 把 pending 历史改为 closed、删除相应 Mux snapshot,保留 active Mux +及不可变 origin/create/close 身份。持久提交成功并释放许可后才恢复 active +Sessions、发布服务;所以 pending 从不成为可见 Tab。closed-only 记录不 +重写、不恢复历史成员,但仍需要消费者恢复许可。v2 原接线保持不变。 + +attempt 保留冻结原记录、原成员 owner、未释放 admission 与已确认提交 +结果;重试必须匹配同一记录,不能悄悄改用新目标。提交回执未知则锁存 +unavailable/cleanup debt,即使重新 load 看见 closed 字节也不能据此宣称 +提交已确认。close 必须报告该债务;不关闭调用方借出的 continuity lease。 + +计划验证:旧消费者先拒绝、ADMIT 屏障前零 resolver、失败释放不启动清理、 +部分失败只重试原 owner、SETTLE 前零 active Session、两阶段代际变化、 +取消/超时仍保管原工作、未知提交不可转成功、真实文件 lease 重开、恢复 +后查询保留 origin instance。生产 Host 尚未提供此许可,本节不宣称已激活。 + +实施沿上述边界完成。v3 必须同时具备 live-close 和 recovery binding; +收养入口拒绝残余 pending 并保留完整关闭历史,后续普通 mutation 不得 +降级或丢失历史。原冻结记录与已确认 CAS 结果分开保存:CAS 确认后的 +许可释放/active 恢复失败可沿同结果重试,回执未知则永不通过 reload +转成功。已成功的 pending 成员只保留原 owner,不重开、不重复关闭。 + +三视角设计/实现复审同时修复原 Attempt 两个 P2:open/close 等待者 +同时取消时,已恢复成功的服务未被收养;以及关闭完成后的迟到 open +交付重新挂回已关闭对象。两个确定性负例均先失败。现在成功结果只由 +原 _open_once 返回前收养,公开 waiter 不重复收养;open/close 复用原 +publication gate,close 单任务串行清理,取消只影响等待者,不丢实际 +任务或借用 lease。任务工厂 schedule 后抛错时无许可、resolver 或写盘。 + +测试覆盖非空 closed-only、ADMIT/SETTLE 释放门闩、两阶段取消与双等待者、 +原成员部分清理失败、确认 CAS 后的释放失败和 active 恢复失败、未知 +写回执、冻结记录替换、origin/serving 分离及后续 mutation 历史保留。 +真实文件 lease 用三个不同 owner_epoch 重新获取,第一次恢复结算 pending, +再次恢复只开 active,closed-only 的文件 bytes/mtime 不变。 + +架构复核接受原 continuity 精确组预算 1300 → 1400(当前 1378 行), +两个 managed 合同 120 → 150(当前 134 行);不新增 runtime、不隐藏 +源文件,core 仍保持 1900。旧创建/关闭/恢复基线 59 passed;新增恢复 +七项在实现前因缺合同失败,接线后相关集合 68 passed。扩展测试曾发现 +一项测试 fixture 的 position 字段误写,已纠正,不计作产品缺陷。 +扩大 AppService 全套、AppHost 管理创建/许可与架构集合 277 passed +(64.58 秒);最后移除 close 的重复结果收养后,恢复集合复验 30 passed +(0.81 秒)。三视角局部复审通过;Ruff、三源 mypy、依赖图、文档轻门禁 +及 diff-check 通过。 + +后续仍须实现生产 AppHost 的恢复/关闭许可与 active-name CAS、wire/ +CLI 管理关闭,并继续完整 lmux 目标;本节不是生产恢复或最终交付声明。 + +## 74. AppHost 名称引用与管理关闭(设计及实施中) + +原 registry 的 muxes 保持当前名称引用(name 主键),新增 mux_intents +保存不可变 name/service/creation operation,operation 主键。reserve_mux +在同一原 SQLite 事务内同时登记历史意图与 active 引用;重试原 active +intent 幂等,已释放的历史 intent 不能重新激活,同名新建必须全新 operation。 +creation authority 外键改指历史意图;active 引用用复合外键保证其 name/ +service/operation 和历史一致。只删除 active 引用,不删历史、Session 或 +authority。历史仍受 4096 条和原数据库字节水位约束,不隐式 GC。 + +仅在管理关闭结果核对成功后由原 manager 执行 active-name CAS;registry +不增加一个任意按名删除的公开捷径。关闭许可与创建许可独立,close +operation 不得复用任何 creation operation;一个 creation 只接纳一个 +close intent。issuer、只读结果查询和 trusted-result 结算均绑定精确服务/ +当前实例,消费原 lifecycle fence 和短 registry 事务。RPC 仍在事务之外。 +pending/未知结果保留名称;closed 才能释放精确旧引用,已释放后的重放不能 +删除同名新引用。origin instance 是历史事实,serving instance 是当前鉴权。 + +本轮先落原数据库/registry 的历史分离基础,再接用途隔离的 close permit、 +原 AppService admission 与结果 CAS;生产 closing binding 必须等恢复许可 +同时具备后激活。恢复还需保留 journal.prepare 已验证的前代清理证据, +不能只看到新 PROVISIONAL 行就宣称前代清理完成;其证据传递单独复核。 + +格式采用新的严格 schema 7;这是尚未交付的 managed 原型格式,不自动 +迁移、覆盖或清理 schema 6。遇到旧或不完整格式仍 bounded invalid_record, +旧 CLI 入口不改。测试包括 atomic reserve、历史 operation 不复活、同名 +新 intent、复合外键、历史容量、回滚/重开与创建许可旧回归。 + +名称历史基础已落地:仍只有原数据库和 registry owner,发现/resolve/list +只读 active 表,无当前目录过滤变化。六个精确负例在实现前失败;修复后 +registry、创建许可、发现与创建协调 83 passed(9.16 秒)。新增双写异常 +分别在 history/active 插入成功后抛错,断言 service/history/active 全回滚; +真实子进程分别在这两个窗口退出,显式 writable reopen 后检查三表原计数。 +原 hot-journal 测试仍证明未提交页实际落盘后恢复完整历史与 active 引用。 +测试中的删 active 只是存储夹具,不是管理关闭或公开名称释放能力。 +包含最新骤退用例、生命周期/namespace/service admission 与架构的扩大 +集合 150 passed(52.43 秒);Ruff、两源 mypy 与 diff-check 通过。 + +三视角局部实现复审通过;历史容量按 mux_intents 计数,满额的原 active +重试仍幂等、已释放历史不释放容量。后续同一 manager 的可信 closed 记录 +与精确 active 删除必须在同一事务中,不得因这次历史分离而跳过鉴权。 + +恢复设计复核要求:原 journal.prepare 在同一 lifecycle fence/SQLite 事务 +内保存“旧完整 cleanly_stopped 证据 → 新 instance/attempt”的本代不可变 +换代凭证,再写新 PROVISIONAL;不需要平行 owner 或无限历史。首代不能 +用空前代凭证恢复已有 managed continuity。生产恢复 ADMIT 必须绑定原 +bootstrap 捕获的 self-native/attempt、未停止的 PROVISIONAL 及换代凭证, +不能复用只允许 COMMITTED 的 live 管理检查(application.prepare 先于 +handoff commit)。SETTLE 重查同代已接纳恢复,允许 stop/abort 时结算, +但不能重新授予新的工作权限或把历史 origin 强行等同于直接前代。 + +## 75. 原 Manager 的关闭许可和可信结果 CAS(显式本地接线) + +原 registry schema 8 新增有界 mux_close_authorities:一个 creation intent +只对应一个 close operation,保存当前授权实例、不可变 origin 实例、私有 +token 和 nullable pending/closed 结果。close operation 与全部 creation +operation 双向排他,reserve/issue_close 都在原 SQLite 事务检查,防止 +创建重用先前关闭 operation。关闭许可不接受创建 token。 + +issue_close 只针对已确认 creation receipt 的历史目标;首次签发要求原 +active 引用仍存在。重试保留原 operation/origin,跨实例只更新当前 token。 +ADMIT 拒绝停止和跨代未知意图的新接纳;OBSERVE 可读已有结果或 None, +不补写/不启动清理;SETTLE 只允许同实例已接纳 pending 的结算,包括 +stop_requested 后。该接纳证据来自原 AppService 传入的精确 durable +pending,而不是 registry 的 phase:正常首次关闭时父端尚未收到结果, +registry phase 可以仍为 NULL;token 或 NULL 本身均不构成接纳证据, +也不增加第三次跨库写入。原 Manager 借用同一 lifecycle fence,公共的短许可 +实现复用一个内部 fence 基类,不复制、搬移或重建 native lock owner。 + +record_close 只消费当前精确认证连接的可信 AppService 结果,核对完整 +close/create/name/mux/origin 后,在同一事务保存 CLOSED 并 CAS 删除精确 +active 引用;pending 保留引用。结果只能单调推进,重复 closed 只读原 +事实,不触碰同名新引用。查询历史按 operation,不以现在名称查询替代。 +任一步失败都不把未知结果解释为名称可用,数据库回滚与丢回执由同操作 +重查结算。此能力先通过显式 closing_binding 接本地 AppService 测试, +默认 binding 仍无 closing;生产恢复证据、wire/CLI 后续接通才激活。 + +实现保持原 Manager、SQLite 事务与 native fence 所有权;内部 fence 基类 +只复用原 acquire/exit 债务逻辑,既不新增 runtime,也不复制锁上下文。 +三视角复审发现并修复跨代 pending 的 P2:仅换发新 token 不能让旧代 +pending 获得 live ADMIT/SETTLE。两项确定性负例先失败,现两用途均要求 +pending origin 等于 current;OBSERVE 可观察旧事实,可信旧 closed 仍可 +由新实例对账,不要求历史 origin 等于 serving instance。 + +测试包括真实 AppService 清理和 registry NULL→closed、合法旧结果重放 +不影响同名新 intent、独立目标的双向 operation 排他、用途 token 隔离、 +无本地 pending 的 SETTLE 拒绝及换代缺历史拒绝。UPDATE closed 后和 +DELETE active 后分别注入异常与真实子进程退出,两项写入必须一起回滚; +COMMIT 成功丢回执则用只读查询找到原 closed 事实,重放不碰新引用。 +None/pending/closed 查询均断言 DB bytes/mtime 不变,pending 始终保名。 + +旧基线 51 passed(4.99 秒);四项最初因缺接口失败,接线后相关 55 passed +(6.56 秒)。修复跨代权限并扩大到原创建/发现及架构集合后 110 passed +(45.60 秒)。三视角局部复审通过,生产 closing/recovery/wire/CLI 仍未 +激活;本节不宣称完整命令交付完成。 +最后用另一真实子进程在原 Session 清理门闩期间取得服务 fence 并请求 +stop,确认不是同线程重入假象;独立合法目标的新 ADMIT 随后拒绝,原 +pending 仍能结算。该补强及 schema 6/7 无迁移拒绝的相关集合 45 passed +(5.57 秒)。Ruff、三源 mypy、文档轻门禁及 diff-check 通过。 +schema 8 的生命周期、namespace/service admission 消费者与当前清单 +另验 101 passed(51.89 秒);当前依赖图检查通过。 + +## 76. 原 Journal 的本代换代凭证(证据基础已实现) + +生产恢复前先补不可省略的证据:schema 9 的 service_transitions 每个 +service 仅保留本代一条凭证,绑定 successor instance/attempt、换代时 +revision 与完整 previous ManagedServiceState(含原 native 身份及三项 +清理事实)。真正首代显式 previous=NULL 且起始 revision=1,不能用作 +已有 managed continuity 的恢复许可。该记录不表示当前进程存活。 + +journal.prepare 沿原 lifecycle fence 和同一 SQLite 事务,先核验旧状态 +cleanly_stopped,随后原子写新 PROVISIONAL 和换代凭证。凭证本代不改, +普通 register/commit/stop/cleanup 不覆盖;下一次合法换代才原子替换。 +复合外键绑定当前 instances 的 service/instance/attempt,延迟到同一事务 +提交时核对,以允许上述成对换代,不允许半条凭证。无平行 owner/无限 +历史、额外文件、自动旧格式迁移或新 native 判活/杀进程权限。 + +原严格 state codec 同时用于 previous,规范 JSON 有界 4 KiB;解码核对 +previous 同 service/namespace、完整清理、不同实例/attempt、revision+1 +等于起始 revision,当前 revision 不早于起始值。所有 fenced journal +读取/更新先校验本代凭证;缺失、损坏、错代均 fail closed,不由当前 +PROVISIONAL 或内存补造。只读访问保持文件 bytes/mtime 不变。 + +本片先验证成对持久性、首次/多次换代、故障重试与原 journal 消费者。 +随后生产 recovery admission 才使用该凭证,加上 bootstrap 原 self-native +身份和当前 startup attempt,区分 ADMIT 与已接纳 SETTLE;当前仍不激活 +closing binding,也不把持久凭证单独当成完整恢复授权。 + +本轮实现保留原 Journal/数据库/codec,不新增 owner。previous 明确编码 +namespace/service 和原九个状态字段,避免从别的服务复制前代状态后被 +解码成当前服务。原 _read 包含凭证校验,read_transition 同样锁内读取 +成对快照;不存在旧状态被读取为新鲜空白的回退分支。 + +原换代/关闭基线 38 passed(6.21 秒);新增 11 项先因缺接口/表失败。 +接线后 65 passed,一项旧发现损坏夹具被新外键提前拦截,现改为绕过 +writer 的外部 SQLite 注入,继续验证真实磁盘损坏拒绝,不将其算作产品 +缺陷。随后换代、原创建/关闭/发现、namespace/service admission 的扩大 +集合 169 passed(22.89 秒)。三视角局部实现复审通过。 + +新增真实退出覆盖首代/后代的 instance 和 receipt 两次写入窗口,证明 +事务成对回滚,不额外宣称这些小事务已 spill。测试另显式在 COMMIT +验证延迟外键、校验普通更新不改变原凭证、第三代保存无 native 但完整 +清理的第二代状态,以及提交失回执后只读取得同一 successor。 + +补充深嵌套损坏 JSON 负测先复现 RecursionError,现归一为 invalid_record; +三项清理事实分别缺失、缺失凭证/错代/错服务/旧格式均拒绝。凭证基础不 +等于已接生产恢复;下一步仍须消费原 bootstrap self-native、启动 attempt +及本代凭证实现 ADMIT/SETTLE,之后再激活管理 wire/CLI。 +补强后的换代/registry/架构集合 70 passed(24.72 秒);Ruff、两源 mypy、 +文档轻门禁与 diff-check 通过。Linux 专属新增测试明确标注平台范围。 + +## 77. 生产恢复绑定(已接线并通过局部复审) + +沿用原 ManagedMuxManager、Journal fence 与 AppService recovery attempt。 +bootstrap 传入原 observer.identity 和 startup attempt 的只读值,配对启用 +closing/recovery binding;不重新捕获 native owner。无该配对的直接绑定 +保持创建专用兼容。Child 在 native 尚未登记时等待原控制循环,不提前 +启动 application.prepare,也不改变 stop/abort 优先处理和启动总期限。 + +ADMIT 在原短事务内同时核对本代 instance/attempt/native、PROVISIONAL +且未 stop、§76 凭证和完整前代清理。已有 v2/v3 continuity 都走恢复屏障, +首代 previous=NULL 不能恢复已有记录。SETTLE 只允许同 manager 已接纳的 +原记录与同一凭证,允许本代 stop/ABORTING,不允许换代或提前 COMMITTED。 +ADMIT 重试仍检查未 stop;AppService 始终提交原 recovery intent 给许可, +并独立核对加载值等于原记录或本 attempt 已确认的 closed 投影。 + +恢复交叉核验不可变创建 intent、已签发许可的 origin 与可选确认结果, +关闭许可的创建目标、origin 和单调阶段,以及每个保留 Mux 的精确 active +引用。Registry 已确认结果不得从 continuity 消失;仅签发但未确认的操作 +缺席仍为 unknown,不重放。Closed 历史不要求占有当前同名引用,不在 +恢复核验中释放名字。多代关闭 origin 不必等于紧邻前代 instance。 + +上述读取与 SETTLE 的本地 continuity CAS 使用原 fence,Session 恢复/清理 +在许可完全释放后执行。无第二恢复 owner、数据库版本变更或自动迁移。 +本节测试需覆盖真实 Manager/AppService 的 v2、pending/closed、多代、 +身份/历史篡改、stop、重试及 native 登记竞态,再检验 bootstrap 接线。 + +设计复审修订:生产配对模式连 load=None 也调用同一恢复许可(输入扩为 +record | None)。None 仅在 registry 没有该服务已确认创建/关闭历史时 +允许,不能因文件整体缺失发布空服务。仅签发未确认且缺席的 intent +继续保留 unknown 与名称占用,不释放、不重放。真正首代零历史 None +可冷启动;已有非空记录仍要求完整前代凭证。 + +并发准入补充:生产启动恢复会与 parent/control 的短只读观察竞争原锁。 +仅 recovery admission 在原 5 秒 acquire 期限内异步重试明确 busy 的读准入; +每轮先确认原 context/数据库临时资源已完整释放,再重读全部身份和历史。 +本轮读准入尚未产生 Session/CAS/ADMIT 记录效果才可重试;未知释放、非 busy、停止或 +换代直接失败。正常 create/close admission 仍 fail-fast,不自动重放请求。 + +实现保留原 manager、fence 和 attempt;已有 v2 不改写格式,pending 成功 +结算后的重试继续传原 intent,确认投影只用于避免重复清理/CAS。bootstrap +默认配对启用恢复,直接构造 manager 的旧路径仍 creation-only。首代 None +和整体索引缺失分别测试,后者还使用真实文件 lease 与移走保留副本验证。 + +设计复审的 None 绕过问题已修复;实现复审指出的外键提前拒绝夹具改成 +独立 creation-only 目标,未登记观察改成第二轮驱动屏障。临时移除 native +门槛时该测试明确失败(1 failed,1.15 秒),随后恢复生产代码。 + +原相关基线 74 passed(10.26 秒)。初始新增测试一项缺接口,其余九项 +先被夹具方法名错误拦截,未把它们冒充有效产品负测。修正接线与夹具后 +71 passed,两项旧 bootstrap 测试更新为 prepare-before-commit 与登记前 +不 prepare 的新约束。扩大集合 128 passed,其中一项新夹具被外键拦截, +另一项真实并发启动曾失败;修正夹具并单独复核后 49 passed(16.26 秒)。 + +确定性 fence/database 短 busy 两项随后复现提前中止(2 failed,1.50 秒), +现在只在原 recovery admission 内做有界异步读重试。退避期间 close/stop、 +释放失回执均有独立回归;close 成功后不得重入,未知释放不得重试。 +真实并发那次失败的具体内部点未被直接观测,不将单独重跑通过当作归因。 +架构与生命周期最终局部复审通过;完整 lmux 的 wire/CLI、日志/tmp 有界 +治理及真实安装/终端/性能验收仍按原目标推进。 + +最终扩大集合 154 passed(206.36 秒):包括原创建/关闭、恢复、bootstrap/ +child、真实生产子进程并发启动/重连,以及 Hosting/G11 架构约束;三视角 +最终局部复审通过。最后按测试评审建议补固定期限假时钟测试,确认持续 +busy 的五次尝试共享首次 5 秒期限,到期不再入场,公开 acquire 不可重用; +单测 1 passed(0.90 秒),未重复已通过的扩大集合。deadline 沿用原 busy +错误合同,不引入新错误分类。Ruff、五源 mypy、依赖图检查、文档轻门禁 +及 diff-check 通过。本节没有执行提交、PR、推送或合并。 + +## 78. 关闭管理协议与生产连接(已接线并通过局部复审) + +沿原 managed 创建连接增加显式可选关闭能力。关闭请求携带 §71 的完整 +service/instance/close-operation/create-operation/name/Mux/token;查询结果 +使用同一精确请求,但独立只读 operation,不重新调用关闭。返回仅为 +pending/closed 历史事实;查询可返回 None(未知),关闭不得以 None 成功。 +两者沿原 AppClientScope 的 accepted-work owner,EOF 只结束结果投递, +不取消已经接纳的清理。关闭用普通槽位,查询保留 control 槽位。 + +新增独立 loushang.managed-mux-close/v1 wire family,复用原管理 codec 的 +4 KiB/严格字段和 requestId 规则;原 creation v1 字节不变。原 Remote +pending 记录核对 family、result type 与完整 close/create/name/Mux tuple, +即使投递 waiter 已取消仍验证;origin instance 由原 manager 对照历史 +核验,不错误要求它等于当前 serving instance。无 token 回显或自动重放。 + +关闭需明确协商:本地记录追加 mux_closure 能力且必须同时 mux_management; +对应四种 managed semantic profile 使用 v2(discovery/execution 组合), +原 v1 profile/hello/record 保持不变。旧客户端拒绝未知能力,不能默默降级。 +原 LocalPeer 仅从同一已保管的 managed scope 借 creation/close/discovery/ +execution,不再创建第二 scope。AppHost 显式将已启用 closing binding +传到本地服务器;创建专用和手工旧入口仍不激活关闭。 + +完整生产连接需验证:错 profile/字段/响应目标在效果前拒绝;普通槽位满 +时仍可查结果;EOF 后原清理继续、重连只读取得结果,原 manager 结算 +精确名字引用;同名新 Mux 不受旧 closed 结果影响。CLI 随后使用该公共 +能力完成冻结目标确认与结果查询,不在 TUI 内另造协调器。 + +实现沿原 managed_mux_wire 增加独立 close family,新增纯 client protocol, +不增加运行时 owner 或旧 AppClient 方法。原 creation-only scope protocol +不变;仅显式关闭分支借用额外 pure protocol,其 getter 失败仍关闭原 scope。 +AppHost application/continuity 原边界暴露 closing 是否启用,由原 local +composition 选择 mux_closure;公共 ManagedConnectionLease 同样可借 close +client,CLI 后续不需要直接依赖传输细节。 + +原相关基线 101 passed(3.86 秒);11 项新 codec 负测先因缺接口失败。 +初步集合 124 passed,四项旧枚举全量夹具未传新增显式 close capability, +一项计数夹具试图覆写 slots 实例方法;现分别改成对应纯 protocol autospec +与测试范围内的类方法拦截,不把这些夹具失败当作产品缺陷。扩大到生产 +子进程/架构后 121 passed(106.23 秒)。 + +三视角局部实现复审通过。补强测试覆盖 close 与 app/execution/create +六向 failure 串线、取消后的错完整目标/close-None、合法历史 origin、 +getter None/抛错原 scope 回收,和真实服务端 16 个混合普通请求满槽时 +查询仍可用。原 Manager/AppService/认证连接集成证明 EOF 后真实 Session +清理继续、新连接仅查询、pending 保名、closed 后精确释放,并实际创建 +同名新 Mux 验证旧结果不误删;关停后的查询失败不折叠成 None。 + +最后补强集合 145 passed(13.76 秒),包括专用生产子进程:明确发布 +mux_closure,经公共 lease 关闭、查询 closed、由原 manager 释放精确名字, +不调用模型。Ruff、相关 mypy、当前依赖图与 diff-check 通过。本节完成 +公共协议/生产连接切片,未完成 CLI 关闭操作、日志/tmp 有界管理或整轮 +安装/SSH/终端/性能验收,未提交、推送或合并。 + +## 79. 关闭命令与可追溯结果(冻结身份前置完成,命令实现见 §80) + +`lmux close -t NAME [--yes]` 在原 namespace/manager 只读取得已确认 creation +与已有 close operation,打印 service/instance/create/Mux/name 的冻结预览; +确认前不发许可、不建连接、不关闭。确认后再核验原实例和精确 active 引用。 +不存在旧 close intent 才签发一次新 operation,并在唯一 RPC 前输出关联 ID。 +已有 intent 时只查询/对账该 operation,不重放 close(包括返回 None)。 + +`lmux close --server SERVICE --operation OP [--yes]` 精确重接历史操作,只查询 +并对账,可沿原 issue_close 续签本代许可,但不再发 close RPC。它不按名字 +重新选择目标,不自动启动服务。pending 保名;confirmed closed 才由原 +record_close 原子释放原引用。None/传输失败/超时均报告 unknown 或 unavailable, +不把缺失 Mux 当成功。输出不含 token、PID、底层路径;只关闭 Mux 的运行 +成员,不删除 Session 持久数据,也不停止其他 Mux 或服务。 + +`lmux close-status --server SERVICE --operation OP` 为只读查询,不签发或续签 +许可、不写对账、不启动服务。原 registry 已确认 closed 可以直接显示历史 +事实;未完成操作仅当原已签发许可属于当前实例时连接查询。跨代旧许可不 +偷偷续签,提示使用上面的显式 close 重接路径进行查询/对账;查询不承诺 +释放名字。无参数、格式错误在默认目录解析前拒绝。仅两个会签发/对账的 +close 形式要求交互确认或非 TTY 的 --yes;close-status 是可脚本调用的 +只读命令,不需要也不接受 --yes。 + +原 ManagedMuxManager 增加只读 inspection(creation、可选已签发 request、 +已确认 result 的冻结值),与原 fence/registry 共用,不增加生命周期 owner。 +原 ManagedMuxCommand 保管 inspection、manager、单个 connection 与请求; +短 native 调用仍 join 原 worker,唯一 RPC 受原绝对期限约束,finally 先关 +连接再关借用 journal/namespace。不为 RPC 失败重新分配 operation 或重试 +close;用户确认时间不计入随后启动的 30 秒动作期限。 + +本轮三视角设计复审指出两个联动问题: + +- 名称释放后,原 attach 按 name 连接会在 A→同名 B 的竞态中进入 B。 + 先在原 manager 增加 ManagedMuxInspectionV1 / inspect_mux / + inspect_close_operation;attach 使用 confirmed creation 的精确 Mux ID, + 无确认 receipt 时拒绝,不退回名字查找。交互选择保留原页的 reservation + 和 instance,后续查找只用于核验,不替换用户选中的目标。 +- close 许可已签发但 RPC 未发送时,两个只查询入口无法完成关闭。 + 不能将 None 描述为“重连后即可收口”。显式继续原 operation 的用户入口 + 尚需冻结合同:只在用户再次确认后向原完整目标提交,不自动重发、不换 + operation;同代使用原 AppService 的幂等关闭和原清理 owner。跨代且 + 服务从未接纳的 intent 仍受 origin 校验拒绝,不能以续签 token 绕过。 + 该恢复缺口未完成前不发布 close CLI,也不把本节记为关闭功能验收通过。 + +前置实现只增加冻结值与原 manager 的只读方法,无新表或生命周期 owner。 +active inspection 要求当前实例及精确引用;historical operation inspection +验证原 journal 配对事实和历史归属,但不要求 COMMITTED/存活,保留旧请求 +的 instance/token,不续签。许可字段不进入冻结值的 repr 或 CLI 输出。 + +用户最新交付授权为整轮完成后提交、推送、PR、合并并同步本地;它覆盖 +早期 goal 的“仅本地提交”交付限制,不改变实现与最终验收范围。本节的 +局部通过不触发提前合并。 + +前置验证:原管理器针对性基线 1 passed(0.70 秒)。新增三项 inspection +先因缺 API 失败;真实 CLI 竞态夹具先修正不支持的 resolve 参数,并仅对 +已知 native busy 有界等待,随后在未修复 attach 上确实复现误连:关闭 A、 +创建同名 B 后旧 attach 返回 0,负测失败(12.18 秒)。这些夹具错误不 +计为产品缺陷证据。 + +实现后 inspection、关闭管理及原 CLI 集合 44 passed(42.52 秒);另补 +人工选择后 reservation/instance 改变的两项回归,2 passed(31.60 秒)。 +真实 journal 换代回归证明新旧 manager 的历史只读查询保留原 token 与 +instance,零写事务;运行 loop 不允许阻塞等待锁,相关测试改为等待 +off-loop 只读调用完成,没有放松原合同。架构、生命周期、交互三视角 +均通过此前置实现。Ruff、两个源文件 mypy 通过。关闭 CLI 及整轮交付 +仍未完成;本节没有提交、推送、PR 或合并。 + +## 80. 显式继续未完成的关闭(设计通过,已接线并收口验证) + +`close --server ID --operation OP --continue [--yes]` 是用户重新确认后的 +显式继续,允许向同一个精确目标重送同一个幂等 operation;普通 close +历史重接和 close-status 仍只读查询,不自动重发。新 operation 的公开 +关联 ID 必须在签发许可前输出,以覆盖许可提交成功但本地丢回执的窗口。 +确认后 target、instance 或已有 operation 改变即冲突,不静默重新选择。 + +跨代未被 AppService 接纳的 intent,不能只删除旧 origin 校验。当前方案: +原 production manager 必须已经经过 startup recovery,原记录包含完整 +confirmed creation 与 active Mux,且没有该 creation 的任何关闭记录; +当前 AppService 的 live ADMIT 仍为 previous=None 时才可显式继续。 +permission 返回冻结的历史 origin,AppService pending 保留该 origin。 +原 manager 仅保留有界的本代 admitted-operation 数据,记录这一次精确 +ADMIT;跨 origin 的 SETTLE/重复 ADMIT 只对同一 manager 的已接纳记录 +放行。重启后数据为空,已有跨代 pending 仍走独立 startup recovery。 +不新增表、运行时 owner 或 wire 字段,也不改变 origin 的历史身份。 + +该方案经三视角设计收紧后通过;未知历史、缺失记录、stop、替换实例、 +不确定写入和原 permission 清理债务继续拒绝新效果。实现必须证明未发送 +失败可显式继续,同时不把已接纳任务变成重复清理或跨代 live 接管。 + +三视角要求现收紧如下:result.instance_id 是不可变的 operation origin +(首次签发实例),不是实际完成 pending 提交或清理的实例证明;当前 +执行权限来自原 manager 的 instance/native、原 fence 与本代精确 ADMIT +记录。原 startup record 必须非空且不存在该 creation/Mux 的任何关闭 +记录,registry phase 仍为 NULL。登记值保存完整 pending 身份,先验容量 +限制且不驱逐,不能替代 AppService 原 pending owner 或清除未知提交/释放 +债务。SETTLE 仍验证本代实例与 token;再次换代丢失本地登记,旧 pending +只能经恢复屏障。输出签发前的 ID 明确标为计划操作,不声称已经签发。 + +实际 CLI 使用原 Command/connection/manager,不增加 owner。确认前不签发 +或连接;确认后复核冻结 target/instance/已有 operation,单次 RPC 前同步 +检查原 deadline,响应、原只读验证及对账后的成功交付也复核期限。原 manager +提供 verify_close_result,与 record_close 共用 origin/单调结果核验;status +不能跳过验证直接显示服务端 phase。写入仍独立重新核验,不能拿只读检查 +代替事务内授权。所有短 native 操作等待原 worker 完成再关闭借用存储。 + +unknown 明确输出需重新确认的精确 --continue 命令,不自动重放;旧 token +的 reauthorization_required 只提示普通历史查询/对账,不直接建议重送。 +observedInstanceId 与 originInstanceId 分离;cached closed 是历史事实, +不证明服务仍可用。关闭后的 Session 持久文件不删除,同名新 Mux 不受旧 +operation 的查询或对账影响。 + +验证过程中发现并定位实际竞争故障:关闭后同名重建间歇返回 +operation_unavailable。单次重跑通过没有当作原因已消除;加固确认屏障 +的 race_completed/真实 Mux 与实例变化断言后故障再次出现。测试专用 +有界子进程探针(仅错误类型/代码/栈位置,无参数或密钥)在 8 轮序列 +中记录原 _ManagedMuxFence 获取文件 owner 锁时的 ManagedStorageError +busy,发生在业务准入检查/提交之前。具体竞争线程身份未由探针确定。 + +三视角同意将原 recovery 的等待循环上提原 _ManagedMuxFence:同一个 +公开 acquire、原始 5 秒绝对期限、原 loop/thread;仅重试 fence 和短 +只读事务。每轮确认 DB/fence 释放且无债务,清除尚未交付的 _permit 或 +_transition 后异步退让;关闭、stop、instance/token 每次重新核验。 +check_*、本地 ADMIT 登记、RPC、continuity/CAS 与 Session 清理均不进入 +循环,未知退出仍保留原 owner 并失败。这取代此前 live 准入对纯 busy +立即失败的行为,不改变用户明确继续与禁止自动重放的命令合同。 + +证据:原恢复/关闭基线 46 passed(6.37 秒);两个跨代继续回归先失败。 +首次扩大集合中的两项夹具问题为不存在的 Session.closed 字段及旧检查 +拦截器未转发新增 origin 返回值,修正后未当作产品缺陷。中间集合 +139 passed(89.32 秒)仍不足以覆盖间歇竞争;有界 8 轮探针复现为 +1 failed(14.90 秒),随后四项 create/close × fence/DB 的确定性 busy +回归也先全部失败(3.02 秒)。修复后关闭/创建准入、恢复、真实 CLI +和架构集合 119 passed(124.22 秒),包括真实生产重启后的未发送操作 +继续,以及 8 轮同名复用。门禁仅补记既有 manager 的 Hosting identity +与 stopper 的 service/group observation 精确依赖,没有放宽其他导入。 + +补充边界验证:recovery/create/close 三类准入的取消、stop、未知释放与 +原始超时边界 12 passed(36 deselected,1.98 秒);原管理器、关闭 wire +与 AppService 关闭运行时回归 58 passed(4.23 秒)。受影响源码 Ruff、 +mypy、文档轻量检查(6 passed)、依赖图一致性及 git diff --check 通过。 + +三视角局部实现复审的问题已修复:RPC 前同步 deadline、只读 origin/ +单调核验、明确恢复指引和确认竞态 oracle。整轮 lmux 的完整会话视图、 +有界日志/tmp、安装/SSH/首次使用性能最终验收仍未完成,未提交或发布。 + +## 81. 共享会话输入与模态优先(局部复审与扩大验证通过) + +本切片继续 M3 的完整会话视图复用,不新增 Hosted 输入控制器或任务 owner。 +原 HostedMuxShellV1 改用已有 ConversationInputRouter;编辑、选择、补全、 +换行和运行中提交策略与 Embedded 使用同一实现。原 router 的缺省 +optimistic 行为不变;显式 deferred 模式仅产生现有 typed input result, +不先插入消息、清稿、增加历史、转移附件或消费本地显示队列。 +deferred 是无附件端口:配置 image stager 或提交时 DraftStore 非空均拒绝; +不默默丢弃图片,也不将未确认远端队列变成本地可编辑队列。 + +Hosted 在每次输入前从当前 window 更新权威 running 展示事实,不依赖 +上一帧是否 render:idle Enter 提交,running Enter steer、Alt+Enter follow-up; +Shift+Enter 换行,// 保留字面 slash。共享静态 SlashCommandCompletionProvider +只列既有外壳命令,不加载 Product 命令、插件、文件扫描或远端执行能力。 +所有语义操作仍经过原 bounded ShellActions 和精确 attachment/generation/ +member/session request binding;Ack 只表示请求确认,不修改权威运行事实。 + +原 router 完成本地编辑/补全后,先同步并校验窗口草稿,再做命令验证与 +任务接纳。发布失败保留一致的本地草稿且不记历史;原同步接纳成功后才 +清空并记一次历史。该次序修复复审发现的 P2:/inter 补全后入队失败曾让 +composer 与 window.draft 不同,切 Tab 会回退。已有 task 的未知结果仍 +只改变匹配请求的呈现,不恢复草稿、不自动重送。 + +详情和 Session picker 先消费普通 Tab、Enter、Ctrl+C、Ctrl+D 等按键; +仅明确的 Ctrl+B 外壳前缀例外,避免看审批时意外切 Tab、interrupt 或 detach。 +审批仍使用原完整呈现回执/代际校验,不因共享路由绕过授权。按键 release +在原入口忽略,终端 EOF 与 view dispose 仍沿原关闭 owner,不停止应用。 + +保留原 G17 明确刷新时同 Mux/member/session 的光标、选择和 undo 合同; +这只是本地编辑状态,不能保留远端权限。每次绑定清补全与共享 router 的 +jump 上下文,晚到结果仍检查完整 request key。清理释放原 router 的私有 +DraftStore 并清 editor cache,不增加每 Tab 的事件循环或生命周期。 + +三视角设计收紧及实现复审通过。新增回归先复现模态穿透与未接入共享 +路由;新增夹具误用只读 running 和不存在的 Composer.cursor 已修正, +不当作产品缺陷。共享输入/旧 Hosted 基线 45 passed;首轮 64 passed、 +两项旧断言需按已接受 running Enter=steer 语义更新。扩大集合曾为 +135 passed、4 failed,其中一项上述 cursor 夹具、三项是 §78 已审关闭 +wire 接线遗漏的旧 G16 清单。架构复核后仅补 client_scope 的精确 close +值依赖和两个叶预算 201/244;原 aggregate 1100、shell 四文件 950 及 +其他依赖/ambient 禁止不变。P2 修复后的输入/请求/编辑器集合 36 passed。 +最终扩大集合 153 passed(46.13 秒),包括 Embedded 原剪贴板/启动、 +Hosted 审批/编辑器/Markdown/terminal/settlement、旧 mux 命令和 G11/G16 +架构边界。补充明确 action_queue_full 断言后,该项 1 passed(17 deselected, +1.46 秒)。三源 mypy、受影响 Ruff、文档轻量检查、依赖图一致性与 +diff-check 通过。 + +完整 goal 的能力呈现、实际有界日志/tmp、剩余管理命令及真实安装/SSH/ +首次使用性能验收仍待收口,本切片不作为最终交付完成证明。 + +## 82. 持久存储额度与文件身份(账本已实现,写入接线待完成) + +现有日志/tmp 路径不是配额。原 registry 增加有界 allocation 账本,不新建 +数据库、常驻 broker 或进程 owner;额度不是文件写权限。日志 slot 以 +service/kind/slot 为稳定键、原子预留完整段容量:普通最多 5×10 MiB, +trace 最多 2×10 MiB,namespace 两者合计 200 MiB。已写和未用预留均 +计费,跨实例退出/重启保留。临时分配另绑精确 instance,每实例 128 MiB、 +namespace 512 MiB,单实例最多 256 个 slot,整个账本最多 4096 行。 +临时容量按 4 KiB 单位预留,不能通过大量一字节分配绕过文件数量上限。 + +每个 allocation 冻结随机 ID、完整分配键、容量、实际根的路径摘要与 +device/inode;创建前先持久收费,原文件 owner 成功创建并同步后,再 +登记一次精确文件身份。不把 reserve 回执当写授权,不凭同名/大小认领 +未登记残留。未知预留只查询/重入同一分配键,不另起一个分配;身份登记 +丢回执仅对账原 ID/目标。同名不同容量/路径/inode 拒绝,不提供基于 +“文件没看到”“进程重启”的退款入口。 + +三视角明确后续实际 writer 的门禁:固定 inode 上有界 append/轮转, +不使用未计费的 old+temporary 双份替换;部分写入/fsync 未知不能整条 +重放或释放收费,暂停原 writer 并保留资源债务。未知目录条目阻止新的 +受管写入;不同文件系统各自校验物理余量。tmp 的解码、复制、发布前后 +同时存活的副本都必须预留,只有原 owner 确认精确删除并同步才可释放。 + +封闭生命周期事件不接任意文本或原始 stdout/stderr,原始流仍 DISCARD。 +日志错误不能递归记录,额度/锁忙时丢弃;控制流程不等待日志写入重试。 +这不承诺可抢占阻塞的 native IO,也不能在日志 IO/关闭债务未结算时 +宣称整个进程 clean。数据 plane 与控制预留隔离,日志分配不消费原 +registry control headroom。 + +本切片先验证账本;实际日志 writer、bootstrap 事件及 RuntimeScope 写入 +消费者仍需后续接线。仅传 LOUSHANG_TMPDIR 不限制任意第三方写入,不把 +受管 allocation 的上限宣称为操作系统级磁盘隔离。 + +账本实现采用 schema 10,旧 schema 9 与更早格式只读拒绝,不自动迁移或 +重建。原事务内校验 namespace 总量、临时 instance 总量和 4096 行上限; +SQL 闭合值约束同时限制每 service 的 5/2 日志 slot 与每 instance 的 +256 临时 slot。reserve 返回既有完整目标,否则冲突;bind_file 只登记 +原 allocation 的单一文件身份。二者未知提交均可原目标对账,不退款。 + +原 registry/file 基线 80 passed(5.80 秒);新 API 在实现前为缺少模块 +的收集失败。首批账本/registry 52 passed(3.97 秒),与文件、transition、 +namespace admission 扩大集合 162 passed(15.79 秒)。三视角实现复审 +未发现账本代码阻断;按复审补强双方 child 完成 open 的 ready 屏障, +独立 slot=256 负测,以及 16 MiB 的 4096 行合法 fixture,验证行满时 +新分配拒绝但原查询/幂等/绑定仍可用。补强与架构集合 43 passed、1 项 +inventory 尚未列出新模块,已同步精确源清单,待重验。 +单项清单重验 1 passed(10 deselected,17.82 秒);两源 mypy、受影响 +Ruff、文档轻量检查、依赖图一致性与 diff-check 通过。账本局部复审通过, +下一步是以这些收费/身份事实接通原文件 owner 的实际有界事件写入; +尚未启用日志 writer、trace 或 tmp 回收,整轮 goal 未完成或提交。 + +## 83. 原文件 owner 的有界追加与轮转 + +`PrivateManagedDirectory` 增加 `data_snapshot` / `append_data`,复用原目录 +描述符、稳定锁和清理账本,不新建运行时或后台 owner。只读快照最多读取 +16 KiB 尾部,记录 inode/device、大小、mtime/ctime;锁内比对完整快照后 +才允许追加。整条编码字节先受 16 KiB 限制,文件容量为调用者注入的 +已收费容量(最多 128 MiB),不能把容量参数或快照当实例写权限。 + +首次创建是 exclusive,日志消费者应先预留、创建空文件并同步、绑定精确 +身份,然后追加事件。轮转需明确 `truncate=True`,先完成值与容量检查, +再在同一 inode 截断并追加;无临时副本峰值,也无旧段完整保留承诺。 +本层不解释事件格式或认领未登记文件,上层仍须拒绝未知文件、不完整 +尾部与失效实例授权;§82 的账本不变,不提供退款。 + +写入或截断前,原 owner 登记数据 fd 同步债务;短写、中断、零字节写、 +同步或效果核验失败后封闭该 owner 的新追加。清理先同步原数据 fd, +再同步目录,最后关闭,不重发正文、不重做截断。同步再次失败继续保留 +原 fd。关闭结果未知后,绝不再次操作该数字 fd,以免伤及复用句柄。 +清理成功也不证明某事件完整写入;有限保留日志不是完整审计记录。 + +三视角复审发现并修复一项关闭错误传播 P2:原辅助函数使用环境 +`sys.exception()`,可能把调用者外层异常误当本次 IO 主异常,吞掉正常 +路径的关闭失败。现在各调用显式传递本次 primary,正常路径为 None。 +测试复审还补强清理的实际顺序:再次 fsync 失败后身份/债务不变,恢复 +后必须原 fd 同步成功再关闭;期间禁止 write 和 truncate。两项复核通过。 + +原文件/账本基线 66 passed(5.40 秒);新增 API 实现前 15 failed,均为 +缺少方法。首次实现与原文件/账本集合 81 passed(5.95 秒)。新增回归 +另覆盖外部追加/截断/路径替换、读取时变化、编码字节先验上限、无锁 +拒绝,以及外层 except 中真实 close 后 fd 复用。最终文件、账本、registry +和锁等待扩大集合 135 passed(10.53 秒);Ruff、单源 mypy、文档轻量检查、 +依赖图一致性与 diff-check 通过;原 AppHost baseline/G9 架构集合 +23 passed(18.85 秒)。本切片完成底层 IO,不代表事件 logger、bootstrap 或 trace/tmp +消费者已接通;整体 goal 仍需实际接线与安装验收。 + +## 84. 封闭生命周期日志消费者(实际文件写入,尚未激活 bootstrap) + +`ManagedLifecycleLogV1` 借用原目录 owner 与额度账本,无线程、任务、独立 +close 或新运行时。固定 `lifecycle.lock` 和五个 `lifecycle-0..4.jsonl` +文件;每段持久收费 10 MiB,普通日志合计最多 50 MiB。测试可注入更低 +物理段上限,但不能降低持久收费。满段选择下一段在原 inode 上截断轮转, +不产生临时双份副本,不另起 slot 绕过 namespace 配额。 + +事件字段闭集为版本、白名单 lifecycle event/code、instanceId 与 sequence, +不接任意 message、路径、token 或 stderr。编码整帧最多 256 字节;所有 +传入纯值验证先于 IO。每次写入在原 logdir 锁内扫描全部五段,核对实际 +根路径摘要/root identity、allocation 与 file identity,然后严格验证每段 +最后一帧的 canonical bytes、真实换行边界及非零尾序号不重复。序号以 +各段尾部最大值递增,满 2**63-1 拒绝而不回绕。这里只证明合法尾帧, +不声称整段历史无损或所有历史序号唯一;每段尾部读取至多 16 KiB。 + +首写遵循 exclusive reserve → exclusive 空文件创建/同步 → bind 精确 +identity → append。账本的 `exclusive=True` 在原事务内拒绝已有行, +因此历史 unbound 即使文件缺失,也不能变成本次创建回执。所有段先验, +任一未知条目、已绑定文件缺失、未绑定残留或不完整尾行均拒绝,不跳过、 +认领、修尾、退款或重放。已绑定空文件可以由后续已获授权的 consumer +写入新事件,但不证明前一调用成功;同一 consumer 的任何 IO 失败都会 +在原目录 mutex 内封闭,等待中的并发调用也必须看到封闭结果。 + +目录扫描器也纳入原目录 owner:关闭未知时保留原 iterator 和债务,不 +盲重试,不假报 clean。容量主异常不被关闭错误覆盖。日志 consumer +必须在 native worker 上调用;running event loop 在取 mutex 前直接 +busy 拒绝,无 IO、不封闭 consumer,以免磁盘/锁等待阻塞 stop 或输入。 +锁序保持 logdir → 短 registry 事务,禁止从服务/registry fence 反向取锁。 + +本切片尚未将日志接到 child/bootstrap;后续激活必须由原 journal/native +handoff 证明跨代资格,并让日志 IO 与原目录清理债务进入原生命周期, +不能以新任务逃避 join,也不能阻塞控制 worker。实际 trace/tmp 消费者、 +物理余量检查与安全回收仍待接线;Session 的缺省持久策略不变。 + +基线文件/额度 41 passed(4.92 秒);新事件模块实现前为缺模块收集失败。 +首次事件/文件/账本集合 53 passed(4.98 秒)。复审修复扫描器保管、并发 +封闭与 loop fail-fast,补充真实绑定/预留/创建丢回执、部分写入、空绑定 +重开、坏/耗尽/重复尾序号、200 MiB 满额后原五段持续轮转且 inode 不变。 +存储与原 baseline/G9 架构扩大集合 171 passed(37.53 秒);最后 loop +准入修复后的事件集合 27 passed(4.45 秒)。三源 mypy、受影响 Ruff、 +文档轻量检查、依赖图一致性与 diff-check 通过;三视角局部复审无剩余 +阻断。完整 goal 未完成,尚未提交或推送。 + +## 85. 生产后台服务生命周期日志接线 + +生产 `coding.managed_process` 明确开启 bootstrap diagnostics,默认 lmux +后台服务使用既有 layout 的 logs 目录。底层 bootstrap/child 仍默认关闭 +此选项,保留其他显式调用的原行为;不改变旧命令入口或 Session 存储。 +bootstrap 先保管 deferred 日志目录,原 journal 的 instance/attempt/native +核验成功且已释放 fence 后,才准入目录、借用账本并写封闭事件。身份 +读取在 native worker 上作有界锁等待;不持 service fence 跨越日志正文 +写入。原进程仍活着时,scope 结算门槛继续阻止后继实例激活。 + +不新建日志 executor 或运行时:原 child pool 在开启诊断时有两个槽位, +原 `_io_lock` 保持一个控制 job,独立保管最多一个日志 Future。loop 只 +入队五种 once 事件,依次异步投递。starting 发生于 native 已登记、准备 +开始之前;ready 在 activate 成功且尚未 closing 时入队,晚到成功不 +补记 ready。stopping 为关闭请求;stopped 仅表示应用及其阶段任务清理 +完成,不是进程/group 退出或可换代证明。failed 只有封闭错误码。 + +每个日志 job 先保管 publication gate,再调用原 pool.submit。只有取得 +Future 回执才能打开 gate。提交在入队前失败或入队后丢回执时,gate=False +禁止 callback 访问 journal/日志;不重发事件,也不等待可能根本不存在的 +done 回执。已接纳的 native Future 则必须保留,取消等待任务不能丢弃它。 +最终结算先同步关闭入队,等原日志任务,再 shutdown 原 pool;已完成的 +控制清理用原标记保留,日志结算重试不重复成功的控制阶段。 + +日志是有限保留的 best-effort 诊断:格式、容量、目录或 IO 错误会丢弃并 +封闭该 consumer,不向业务传播原异常。应用清理及 stop 发起不等待日志 +正文;共享 registry 的短事务仍可能争用,不能承诺控制路径完全不受 +共享存储影响。日志 Future/目录同步或关闭未知仍是资源债务,禁止整体 +假报 clean。bootstrap 必须在 child 日志结算后才关闭日志目录,再释放 +observer/journal/registry;日志缺失不能作为服务成功或失败的判断依据。 + +接线前基线 58 passed、1 项旧 lifetime 测试超时,定位为遗漏原 native +登记,已补夹具,不放松生产门槛。首轮 60 passed、2 failed:新日志 ready +时点竞争已移至 activate 完成包装内;旧测试直接读正在被控制任务持有的 +fence 改为有界 offloop 只读等待。下一轮 62 passed、2 failed:日志超时 +测试须先结算原 close task 再申请 retry budget;真实生产测试 registry +首次读取增加同样的有界只读等待,未重启或重发业务操作。 + +复审修复未入队日志的假债务,补入队前/后失回执、日志 waiter 取消、 +迟到 activate,以及真实 bootstrap→writer→日志 fd fsync 屏障测试。 +真实日志 fsync 挂起时,应用关闭与 stop/cleanup 事实仍推进,bootstrap +保留全部依赖;释放后原任务完成并收口。生产子进程启动、重连、停止与 +这些故障专项 10 passed(23.94 秒)。三视角局部复审无剩余阻断;扩大 +场景继续验证,完整 goal 的 trace/tmp、管理体验及安装性能验收仍待完成。 + +随后扩大到 child/bootstrap/lifetime、日志 writer、真实生产进程冷启动/ +暖复用/并发与 baseline/G9 架构:129 passed、1 failed(139.87 秒)。 +唯一失败为新 late-native 夹具与原控制轮询的合法 fence 竞争;改为原 +instance/attempt/native、固定五秒预算的幂等登记重试,不换进程或身份。 +只复验日志专项,已通过的其他集合不重复启动。三源 mypy、受影响 Ruff、 +文档轻量检查与依赖图一致性通过。 + +late-native 夹具修正后的日志专项 11 passed(3.88 秒),包含真实默认日志 +与 native gate、fsync 挂起、缺失/陌生目录、提交丢回执、取消和迟到激活。 +本轮相关运行均已结束;整体 goal 继续,未提交或推送。 + +## 86. 只读服务状态与目录诊断入口 + +新增 `lmux status -t dev` / `lmux status --server `,非交互 +可用,名称选择独立于调用 cwd。共享 `ManagedDiscoveryV1.inspect_service` +在原 registry 的一个只读事务中投影 service/instance,不要求该服务仍有 +active mux 名称,不创建 journal、连接、coordinator 或 native observer。 +缺失 namespace/目标只返回 not_found,不补目录、不启动或恢复服务。 + +输出明确标记 `observation=recorded_only`、`liveStatus=not_probed`; +recordedPhase、stopRequested 和 cleanlyStopped 是持久事实,不把 +COMMITTED 当作当前 ready,不用日志推导进程退出。这里只做记录诊断, +实时探测、服务别名/无目标汇总及日志读取仍须后续完成。 + +同时显示该服务的稳定 logs/application 路径。临时目录只显示受管默认 +base;实际 instance override 尚未持久记录,因此 actualRoot=null 并 +给出原因,不使用当前 shell 的 LOUSHANG_TMPDIR 冒充历史实例路径。 +路径输出经原 JSON 转义,观测不透出 native PID、认证材料或连接记录。 + +初测 23 passed、1 个“名称释放”夹具错误;改为删除 active mux 并验证 +名称列表为空后,公共发现/CLI/原 baseline/G9 架构 47 passed(26.63 秒)。 +新增端到端 journal 投影测试覆盖 PROVISIONAL、COMMITTED、stop,以及两项 +清理事实不报 clean、三项齐全才报 clean;每一步禁止连接/启动并比较 +整个测试目录树,名称释放后 exact ID 仍可查询。三源 mypy 与 Ruff 通过。 +本入口不代表整体管理体验或完整 goal 已交付。 + +完整 journal 投影夹具初次绕过 service admission 被生产门槛正确拒绝; +改为通过原 ManagedServiceAdmissionV1 建立控制目录,未放宽门槛。 +状态专项最终 7 passed(5.51 秒),包括全阶段投影及全树不变;三视角 +局部复审通过,文档轻量检查与 diff-check 通过。运行均已结束,完整 +goal 仍在进行,尚未提交或推送。 + +## 87. 有界只读日志入口 + +`lmux logs -t ` 或 `lmux logs --server ` 共用原公共发现, +`--limit` 缺省 50、范围 1–100。缺失 namespace、目录或锁不创建资源; +日志读取不连接服务、不启动后台、不补配额、不修复文件。 + +在原日志锁内检查五个固定段的预算绑定和文件身份,每段最多读取末尾 +16 KiB。丢弃被窗口截断的首帧,校验全部可见帧的 canonical 编码及 +序号后才应用 limit;损坏帧即使不在输出范围也导致零事件输出。 +完整释放锁后复验原绝对期限,再输出 JSON。输出明确标识 +`observation=bounded_tail`、`completeHistory=false` 和扫描上限, +不承诺完整历史、实时存活或任务成功事实。 + +三视角复审发现并修复三项 P2:读锁竞争不应永久禁用后续诊断;锁退出 +迟到不能交付成功结果;原 160 条夹具不足 16 KiB,未实际覆盖截断。 +writer 现在仅对尚未准入、明确 busy 且无清理债务的初始锁竞争返回 +None,丢弃当前事件、不重放;准入后的错误和未知释放仍封闭 writer。 +新增真实 reader/writer 竞争、释放锁后到期,以及 220 条记录实际截断 +断言。三视角局部复核通过。 + +初轮日志/状态/架构专项 75 passed(39.14 秒);修复后 reader、writer、 +child diagnostics 和 CLI logs 58 passed(28.41 秒)。三源 mypy 与 +相关 Ruff 通过。此节只交付有界日志读取;trace/tmp 实际消费者、完整 +CLI 体验和真实安装综合验收仍未完成,不代表整体 goal 可发布。 + +## 88. 全局选择器有界翻页 + +裸 `lmux` 和无目标 `attach` 的原选择器现在支持 `n` 下一页、`r` 回首页, +数字选择当前页,空输入取消。只保留一页,沿原公共发现的 name 游标读取; +不同页是独立快照,不承诺跨页枚举一致性。整页恰好为最后一页时,下一页 +为空会明确取消,不触发默认 main 创建。人类输入等待后为下一次只读操作 +建立新预算,不延长在途任务。选中后仍用原 reservation/instance 复核, +并经过原连接准入;记录字段继续显示 unknown,不暗示在线。 + +变更前命令基线 46 passed(120.26 秒)。新增下一页、回首页、空后继页、 +取消及非法输入回归;三视角局部复核通过。改后原命令与首批分页回归 +53 passed(116.77 秒),Ruff、mypy、文档轻量检查与 diff-check 通过。 +评审建议补充的 bare 满页翻到空页、无创建/连接且资源关闭的完整命令链 +回归另行补跑:最终分页专项 8 passed(5.64 秒),含此完整调用链验证。 +唯一在线 Mux 自动连接仍未实现,此处不能替代在线探测和连接协调合同。 + +## 89. 批量管理前的公共 namespace 快照 + +`ManagedDiscoveryV1.snapshot_namespace` 在原 registry 的单一只读事务中 +取得完整 services 和 muxes 两组持久观察,包括没有 Mux 名称及尚无 +instance 的服务。分别读取 MAX_SERVICES/MAX_MUXES 加一条检测越界, +超限整体报 capacity,不把截断结果称为全部。服务投影与 inspect_service +共用;冻结值检查重复 service ID、重复名称及两组生命周期字段的一致性。 +完整退出事务后复验原 deadline,不保留事务锁或增加资源 owner。 + +此快照仅用于冻结预览和后续协调,不证明在线、不授权停止。未来 +`stop --all` 必须沿原 namespace owner 对确认时的精确 instance 逐项 +准入;不得确认后重新发现目标集、追随换代或纳入新服务。 + +基线发现测试 18 passed(3.01 秒);实现后发现、快照、status 回归 +31 passed(10.09 秒)。覆盖无 Mux 服务、冻结/关联校验、两种上限独立 +拒绝、真实第二 SQLite 连接写入受原读事务阻挡、损坏/关闭 owner 拒绝及 +退出事务后的到期结果拒绝。Ruff、mypy 与 diff-check 通过。 +本切片尚未接入 CLI 批量停止,不能据此宣称 stop --all 已交付。 + +三视角设计与局部代码复核通过。按建议将容量夹具加强为上限 1、实际 +2 项(服务和 Mux 各自独立);首次补跑 5 passed、1 个夹具 operation ID +复用冲突。改用独立 ID,未修改生产冲突检查,最终专项 6 passed(1.45 秒)。 + +## 90. stop --all 冻结确认与逐项结算 + +CLI 新增 `stop --all`,与 `--server` 互斥;非 TTY 必须 `--yes`,缺失 +namespace 报 not_found、不创建目录。原公共快照一次性取得预览:namespace、 +Product/workspace、精确 instance、全部 Mux 名称及共享预算说明。确认后 +从 30 秒总预算开始按固定 service ID 顺序停止,不续期、不重新发现目标。 +无 instance 项报告 skipped/no_instance_at_snapshot;剩余预算不足报告 +not_attempted/deadline,不构造新停止操作。任何项失败,总退出状态非零。 + +每项继续原 ManagedServiceStopOperationV1 的精确实例检查和 graceful-only +语义。CLI 先保管原 journal/stopper 再进行 IO;用户取消不进入普通错误 +继续路径。成功项释放本地资源后输出 stopped,不把 stop 请求当完成。 +清理先发起并保管所有已有项的原 task,再作独立的 2 秒有界等待;此等待 +不追加停止预算,不取消原清理。未结算时保留 journal/namespace,沿既有 +cleanup_incomplete 退出路径处理。仅 terminal 失败/取消且仍有资源的 +清理 task 可重建,继续原 owner 的 close,不重放 stop 或替换 owner。 + +设计复审收紧清理等待、取消及 no_instance 命名;代码复审进一步修复 +failed close task 永久阻止原 owner 重试的问题。基线命令 46 passed +(117.78 秒);首轮批量/命令回归 53 passed(143.52 秒)。新增确认期间 +服务加入不纳入、同服务双 Mux 实际批量停止,以及原 journal 清理重试 +专项正在验证。本节尚不代表全部 lmux goal 或真实安装综合验收完成。 + +三视角代码复审通过。最终集成初跑 31 passed、2 failed:同服务双 Mux +夹具未承担原 Popen 父进程的 wait/reap 责任,以及架构反向依赖清单未 +登记新 CLI 组合模块。已沿既有单服务测试补原父进程回收,更新精确清单, +未弱化生产停止结算;另补取消 cleanup waiter 后仍保留原清理任务的回归。 + +补跑批量/G9/架构组合 33 passed(19.78 秒),剩余 1 项为文档源路径 +精确集合漏登记;补齐后原 baseline 架构 11 passed(17.79 秒)。因此 +本次最终 11 项批量功能回归均已通过,包含确认后新增服务排除、真实 +同服务双 Mux 停止、取消与原任务保留。三源 mypy、相关 Ruff、文档轻量 +检查及 diff-check 通过。本轮仍未提交或发布,完整 goal 保持进行中。 + +### 确认后换代的直接回归 + +补充真实 journal 状态转换的 CLI 验证:预览后、确认输入期间,将未启动 +native 的旧 provisional 实例合法 abort/settle,再 prepare 新实例。原 +batch 对旧 instance 返回 conflict;禁止 native admission/request_stop +的断言未触发,新实例完整状态保持不变,输出也不偷偷替换成新实例 ID。 +另补空行、拒绝、缺少换行及过长确认输入均不创建 batch owner。 +最终批量专项 16 passed(15.19 秒),相关 Ruff 通过。 + +## 91. 实际暂存消费者接线前的源码核对 + +当前 `coding/managed_process.py` 向子进程传入实例专属 LOUSHANG_TMPDIR, +但它不是强制文件系统配额,不能据此宣称全部暂存有界。已定位的关键路径: + +| 消费者 | 当前行为 | 后续约束 | +| --- | --- | --- | +| `harness/session/output_artifacts.py` | 在注入或平台 temporary root 下创建 session-output 目录,发布为 Session blob 后清理 | 需要在实际 stdout/stderr 写入前取得预算,而不是发布后检查大小 | +| `harness/workspace/exec/service.py` | 在 artifact_dir 下创建输出文件;preview/rolling 的内存上限不等于磁盘文件上限 | 配额必须覆盖持续写入、并发 stdout/stderr、超额与取消清理 | +| `coding/bootstrap.py` 的 `_ExplicitTemporaryDirectory` | 使用无 dir 的 mkdtemp,独立显式清理 | 不能假定 LOUSHANG_TMPDIR 会影响 Python tempfile;需通过原组合注入实例暂存能力 | +| Session 索引/arch cache 的原地临时替换 | 暂存与最终文件属于各自存储 owner | 不因为后缀或 tempfile 名称就挪到 lmux gc;保持原子替换和 Session 默认策略 | +| 插件 revision quarantine、工具解包、git 临时 index | 属于各自 cache/workspace owner | 逐项确定 lifetime,不能用全进程 TMPDIR 覆盖来冒充统一治理 | + +下一步应从 Session 命令输出这条实际字节写入链切入,以注入的中性预算/ +暂存接口连接原 AppHost 配额,而非让 Harness 反向依赖 AppHost。必须验证 +总磁盘上限与所有者清理,再接其余消费者;这项仍是完整 goal 的未完成项。 + +接线边界详见 [受管命令输出留存](lmux-managed-output-capture.md)。三视角 +已对架构、生命周期和超额体验完成设计复审,具体接口/数值仍待冻结。 +现有输出相关基线 26 passed(2.76 秒);尚未修改实际 capture 写入路径。 + +## 92. 无 Mux 的显式服务启动(局部复审与扩大验证通过) + +补齐草案 §4 高级服务入口的工作区启动部分: +`lmux server start [--workspace PATH]`,缺省工作区为 cwd,可非交互调用。 +服务别名仍待公共登记层实现;当前返回精确 service ID,供现有 status/logs/stop +使用,不把工作区名或 Mux 名冒充服务别名。 + +工作区规范化及目录校验早于 namespace IO;依次保管原 namespace admission、 +service admission 和 coordinator。无需预留 Mux,未进入 Harnesstui,也不创建 +Session。认证连接成功才返回 `service_ready`、`serviceId` 和 `instanceId`; +重复调用相同工作区复用原实例,之后 new 仍走原 Mux 创建协调器。 +关闭依次结算原 coordinator、service 和 namespace,不停止后台服务。 +保留已有 `start -t NAME` 语义,不通过失败回滚删除 durable intent。 + +旧 CLI 基线 46 passed(159.97 秒);新增非交互入口红测按预期在旧 parser +失败。接线后的新旧集合 52 passed(144.98 秒),三视角局部复审均未发现 +新增 P1/P2。按建议补强真实非 TTY/禁止 stdin 和终端调用、认证空 Mux 查询、 +默认 Session 根未创建,以及 coordinator 构造失败时原 owner 清理且保留 +durable 登记的回归,补强集合 7 passed(12.42 秒),进程已正常退出。 +Ruff、源文件 mypy、文档轻检通过。 +这个入口不替代临时文件消费者、服务别名和整体验收等剩余工作。 + +## 93. 公共服务别名(局部复审与验证通过) + +别名属于原机器 namespace 的 Registry,不属于 Coding/TUI 私有配置。 +单独的 `service_aliases` 表绑定 name、service ID 和原 reservation operation ID; +最多 128 项,一个 service 至多一个别名。与 Mux 名称分域,不复制实例或权限。 +第一版不提供重命名、删除、重绑定;stop 不释放别名。 + +名称使用现有 Mux 字符集和 64 字符上限,但排除完整 64 位十六进制串, +避免与精确 service ID 歧义。一次 reservation 在原写事务中同时登记缺失 +service 和别名;相同完整 intent 可重试,任何不同 intent 的重名、同服务 +第二别名、operation ID 复用均冲突。读取使用原短只读事务,不触发准入。 +未知提交结果不删登记,不另分配 operation 自动重试。 + +`server start --name build [--workspace PATH]` 在 service admission/spawn 前 +登记别名;已有同名且完整 service 相同则复用原 reservation。并发首次登记 +发生已结算 conflict 时,在原期限内只读核对一次胜出记录,同 service 才继续, +不换 operation 重试。未知提交或清理债务不走此对账分支。 +普通无 name 启动不要求别名。`stop/status/logs --server build` +先通过公共 Registry 解析为精确 service ID,后续使用原实例冻结和确认逻辑。 +历史 close/close-status 的 operation 继续要求精确 service ID,避免改变 +既有对账命令语义。GUI 可使用相同公共映射,无 CLI 专属解析数据库。 + +格式版本从 10 升到 11,继续既有严格拒绝旧版本、不自动迁移政策, +不删除或重建旧状态。旧 preview 状态需要明确后续迁移安排,不能将格式 +拒绝误报为丢失服务并自动启动。正式发布前须在用户文档明确此兼容边界。 + +Registry 基线 36 passed(2.83 秒);首轮别名与旧 Registry 52 passed +(3.51 秒);扩大到别名、无 Mux 启动、status/logs/stop-all 共 100 passed +(44.32 秒)。架构与生命周期局部复审通过;UX 复审发现同映射首次竞争 +未收敛,已按上述只读核对修复,新增确定性竞争插入与 CLI commit 失回执 +回归。新增用例曾因漏写参数在收集阶段失败,修正后最终 8 passed +(22.19 秒),进程正常退出;UX 复核已关闭 P2。Ruff、相关源文件 mypy、 +文档轻检和 diff 检查通过。此为别名增量证据,不是完整 goal 的验收结论。 + +## 94. 无目标服务状态汇总(局部复审与验证通过) + +`lmux status` 使用原公共 `snapshot_namespace` 的单事务有界快照,列出 +当前 namespace 全部登记服务,包括没有 Mux、没有实例的服务。附带的是 +`reservedMuxes` 名称引用,不称作在线 Mux、活动 Tab 或真实进程数量。 +顶层明确 `observation=recorded_only`、`liveStatus=not_probed`;完整快照 +校验后才输出一个 JSON,不逐服务输出半份成功结果。 + +没有 namespace 返回空服务数组,不创建目录。不调用 service admission、 +coordinator 或连接;不因调用者 cwd 改变服务范围。带 `-t` 或 `--server` +的原详细状态与路径诊断保持不变,logs 仍必须指定目标。别名已经能作选择器, +此增量汇总用精确 service ID 展示,不将额外事务的别名读取混成同一快照。 + +原详细状态基线 7 passed(6.65 秒);汇总、详细状态、logs 和公共快照 +集合 22 passed(8.96 秒)。三视角局部复审未发现新增 P1/P2,按建议补 +数据库缺失/版本不支持拒绝空结果、真实 journal 的 provisional/committed/ +stop/clean 序列投影,以及全数据树不变断言;补强集合 10 passed(5.86 秒), +测试进程正常退出。相关源文件 +mypy、Ruff、文档轻检和 diff 检查通过。 + +## 95. 跨边界门禁与使用说明(进行中) + +已运行 `make plan-checks`;完整变更仍需架构、相关子系统和安装等门禁, +不能用局部 CLI 通过替代。首次完整 `tests/architecture` 正在运行且已出现 +失败。只读架构复核定位 A0/A0.2 两份精确 AppHost consumer 集合漏列相同 +七个已批准可选 consumer:`coding/cli/lmux.py`、`lmux_command.py`、 +`lmux_stop_all.py` 以及 `coding/managed_bootstrap.py`、`managed_catalog.py`、 +`managed_local.py`、`managed_process.py`。已精确补入,不改为目录放行; +core/facade/adapter 禁入及 A0.3/A0.4 规则保持。原完整运行不会被这次编辑 +追溯变成通过,两项修复仍需后续针对性验证,其余失败尚待终态报告。 + +新增 [中文预览使用说明](../../../zh-CN/user-guide/lmux.md) 与 +[English preview guide](../../../en/user-guide/lmux.md),覆盖现有命令、 +目录、重连、detach/close/stop 区别、格式 11 拒绝旧版和未完成验收限制。 +它们不是正式发布或自动迁移承诺;不引导删除未知实例的控制状态。 + +静态计数与职责复核还定位到旧 Coding wave-A 行数预算漂移。保留原 core +文件归属,精确记录原 AgentSessionRuntime +42、SessionManager +77、共享 +theme 提取 −24,净许可 +95;不是按超限的 81 行反推许可,旧 14 行余量保留。 +G12 +84 属于原 Product/catalog 清理与 managed 激活;G14 +205 属于原 +catalog 的 factory/未交付会话保管、只读 hooks 和发现校验。对应旧分组预算 +分别成为 34181、884、1505,不把这些原 owner 移到豁免分组。 + +新增上述七个 lmux 文件以完整相对路径组成独立 `LMUX_PRODUCT_SLICE`, +本次受审上限为当前 1579 行,仍参与全部文件互斥分区/计数;补未审 +`managed_unreviewed.py` 和 `cli/lmux_unreviewed.py` 留在 core 的防扩散用例。 +架构职责复核认可这些精确增量,Ruff 通过;完整测试仍运行,修复后的 +针对性验证待原运行结束后执行。这些修改不用于宣布其他失败已解决。 + +完整架构运行还暴露本机内存压力:一次观测中测试约 599 MiB 常驻、 +596 MiB 已换出,物理读取累计约 57 GiB。已将 PR0、CLA0 与统一插件 +架构模块的源码缓存改为模块级自动 fixture 在 finally 中释放;模块内 +仍复用缓存,CLA0 先清派生调用索引、再清 AST。扫描根、测试和断言均 +不缩减。失败 traceback 仍可能持有 AST,此修改不承诺立即降低 RSS, +也不会改变已启动旧进程。该修改 Ruff、文档轻检和 diff 检查通过; +架构只读复核确认正常及失败 teardown 覆盖、无新增 P1/P2,运行验证 +尚待完成。A0/A0.2 与 Coding budget 的窄范围修复验证已单独申请执行, +未重复启动全套架构测试。 + +## 96. 单登记候选自动尝试连接(已实现,运行验证待完成) + +裸命令与无目标 attach 的首个公共列表页若恰好一条且未达分页容量, +该条属于 Coding、有 COMMITTED instance、无 stop/clean 标志,则省去 +数字选择;仍冻结该观察并走原 resolve 身份复核、Mux inspection、认证 +连接与 controller attachment。登记状态不是在线证明,失败不自动启动、 +创建、切换候选或重试。多条(即使只有一条 COMMITTED)、pending、其他 +Product 与停止状态继续选择器。此增量不代替多候选中唯一在线探测。 + +UX 设计复核无新增 P1/P2;新增两个真实服务回归要求裸命令及无目标 +attach 从不同 cwd 重连且禁止调用选择器。回归先行请求因权限审核超时 +未启动,未取得红灯证据。现已实现选择并更新预览说明,补资格矩阵及 +裸命令/attach 下 reservation、instance、消失、stop 的确定性变化验证。 +原 resolve/inspection/connection 和清理不变;架构与生命周期只读复核 +无新增 P1/P2。原 lmux CLI owner 净增 13 行,精确纳入既有分组预算 +1579 + 13,不添加路径豁免。扩大后的运行验证已申请,尚未通过验收。 + +A0/A0.2 与 Coding budget 窄范围运行已结束:19 passed、1 failed +(113.15 秒)。失败运行加载的是旧 1579 上限,但统计到了新 1592 行; +不是 consumer 边界失败。已申请只复验更新后的 Coding budget 模块, +不重复已经通过且未变更的 A0/A0.2 测试。 + +单候选增量最终 CLI/selector 集合正常结束:74 passed(369.64 秒), +包括两种入口跨 cwd 重连、资格矩阵、选择后的身份/停止变化及握手失败 +不重启、不改选和清理。源码 mypy、Ruff、文档轻检与 diff 检查通过。 +三视角局部复核无新增 P1/P2;UX 两处说明精度建议已修正:整个命名空间 +恰有一条登记才适用,空 namespace 的无目标 attach 报 not_found。 +这只证明单候选增量,不是完整 lmux goal 验收;全架构和预算复验仍待结果。 + +更新后的 Coding budget 独立复验正常结束:2 passed(0.57 秒)。 +全架构原运行尚未结束,其余失败仍待完整报告。 + +## Shared data-root admission delta — candidate, not accepted + +The default Embedded writer rollout exposes a compatibility defect: creating +`sessions/project-a` and then `sessions/project-b` is rejected because their +legitimate shared attachment directories are treated as orphaned residue. +Changing the layout or removing the residue check is not an acceptable fix. +The existing per-store v1 witness cannot distinguish a never-created sibling +from a sibling whose root and witness were both lost. + +Proposed owner boundary: + +- Harness owns a small data-root admission ledger, separate from Session data + and the application registry. Products select roots; CLI/discovery cannot + manufacture freshness from an absent pathname. +- Store the ledger under the already selected machine-local store-state root, + keyed by canonical data-root path and UID. Bind the physical parent identity, + ledger/lock identities, each admitted store key, and shared attachment-domain + identities. Do not move transcripts, attachments, or change Session defaults. +- Reuse the existing retained rooted IO and stable nonblocking lease machinery. + Acquire family admission before individual store admission. Release those + short initialization locks before normal transcript/blob lifetime operation; + never acquire them while already holding lifetime writer locks in reverse order. +- Publish a member intent durably before creating its root. A member remains + recorded after deletion or failure; missing member root/witness is not a new + admission. An incomplete member blocks that member, not unrelated initialized + members. Concurrent additions must serialize without losing ledger entries. +- Attachment creation is a separate retained initialization phase: persist its + intent before creation, then record identities before accepting sibling + membership. Existing attachment directories cannot be adopted merely because + their names or parent inode match. The original initializer must retain partial + resources and report incomplete/unknown outcomes without silently recreating. +- Fresh automatic initialization requires an unregistered data root with no + legacy store or attachment residue. Existing v1 stores continue their original + validation; their witnesses alone do not authorize adding new family members. + Enrolling a legacy family requires a separately specified explicit migration + operation. Do not silently require migration just to restore a valid v1 store. +- Discovery remains read-only: no ledger repair, membership enrollment, root + creation, or attachment initialization. Bound ledger decoding and member count; + exhaustion returns a capacity error rather than evicting historical members. + +Acceptance before implementation activation: real default bootstrap A→B succeeds +without layout changes; B root, B witness, and both missing remain rejected; +attachment injection/replacement is rejected; concurrent process additions keep +both members; crashes after intent remain incomplete; discovery creates nothing; +valid v1 restore remains usable without enrollment. Ledger loss/replacement must +also fail closed in the presence of old roots or attachment residue. + +Open design checks: exact legacy enrollment authority and command; retained +attachment-initializer handoff; missing-parent creation sequence; capacity values +and lock-order proof. This proposal is not evidence that the sibling regression +or the complete lmux goal is resolved. + +Review refinements (still candidate): + +- Freshness inspection covers the selected data root itself, the reserved shared + attachment/lock names, immediate child store directories and their bounded + transcript/tombstone/owner markers, and matching machine-state witnesses. + Use no-follow retained directory reads and existing discovery read budgets; + a truncated or unreadable inspection is unavailable, not proof of absence. + Do not recursively scan arbitrary workspace/home trees. Exact limits and the + recognized marker set remain implementation-review inputs, not unspecified + permission to skip unknown candidates. +- Family, member, and attachment initialization have separate phases. A member + becomes usable only after its own root binding and the shared attachment-domain + binding are confirmed. Unknown shared initialization blocks every writer in + that family; unknown member-only initialization blocks that member. Fresh + `new` performs all required phases automatically, with no setup command. +- Enrollment must durably upgrade the member witness to reference its family + before making that member available. Such a witness must never fall back to + standalone v1 if the family ledger is missing or invalid. An untouched valid + standalone v1 witness remains usable under its original contract. +- Losing every independent witness together with all roots and attachment + evidence is outside locally distinguishable recovery: this mechanism is not + an external backup or an anti-rollback authority. Do not claim recovery from + that state. Partial loss with surviving family/member evidence fails closed. +- Short-lock admission must hand its confirmed data-root, member-root, and + attachment-domain identities plus family/member generation to the original + lifetime acquisition owner. After acquisition and before any write, that + owner must bind and check the expected objects; it cannot re-adopt whatever + now occupies each pathname. Retain identity witnesses across the handoff where + needed to prevent inode reuse. On mismatch, keep the original acquisition + cleanup responsibility; never acquire the family lock in reverse order while + holding lifetime locks to obtain a replacement admission. +- Family membership intent takes precedence over a standalone v1 witness, + including a missing, not-yet-upgraded, or restored-old member witness. A valid + v1 header is not permission to ignore a surviving family intent. Enrollment + publication ordering must make this check possible after every crash point. +- Missing data-root creation belongs to the first retained family initializer: + durable intent precedes no-follow directory creation and parent fsync. Once + registered, a missing parent is unavailable and is never recreated implicitly. + Fix lock order as family → member, release both, then transcript → blob; + shared initialization must not wait on lifetime locks while holding admission + locks. Unknown shared initialization blocks new admissions, not a claim that + previously admitted writers have stopped; their original owners still settle. +- Split short admission-lock release from retained directory-witness cleanup. + The original preparation keeps the confirmed root pins through lifetime + acquisition and final cleanup. Reuse the original directory/descriptor ledger; + do not create a second owner or close all admission resources before handoff. +- Every later attachment operation must consume the registered shared-domain + identity, including the `session-assets` and `.locks` components. Existing + `create=True` authority-lock paths cannot recreate an enrolled missing domain. + Acquisition-time checks alone do not protect later deletion or replacement. +- Legacy v1 records contain hashed paths and physical identities, not enough + information to reconstruct a vanished parent and its child names. Preserve + v1's exact member-key/root/parent protection; do not retroactively interpret an + unrelated valid v1 witness as a global prohibition on new workspaces. Ignoring + an unrelated witness requires strict closed-schema and physical witness/lock + validation, not a version-label check. Malformed, incomplete, unknown-version, + or truncated evidence remains unavailable. A v1 parent identity matching the + originally retained current data-root still forbids implicit enrollment, even + if its old member disappeared and that parent is empty. A missing parent and + a different member key cannot be globally attributed using v1 alone: this is + an explicit format limit, not a recovered historical fact. All v2 family/path/ + member loss and replacement checks remain unchanged. Explicit enrollment is + still required to migrate an existing legacy shared domain. +- Freeze publication order as family/shared intent → confirmed shared identity + → member intent → v2 member witness → member root → member completion. + Family incarnation and member admission ID are immutable identity components; + the mutable ledger revision only serializes updates. Adding sibling B must + not invalidate A's existing lease by changing its identity generation. + +## LMUX default-owned and presentation budget supplement + +Independent architecture review compared the current owners with the validated +`da820585` wheel. The following deltas supersede the earlier unchanged-cap claims +for these exact groups; file inventories and dependency prohibitions remain in +force. This is not acceptance of the overall LMUX implementation. + +| Existing group | Reviewed delta | Updated cap | +| --- | --- | --- | +| Coding core | `bootstrap.py` +7, `runtime/agent_session_runtime.py` +5, `session_manager.py` +1 | 34248 → 34261 | +| G16 shell four files | `shell.py` +70, `_shell_screen.py` +22; other two unchanged | 950 → 1042 | +| G11 semantic mux six files | `projection.py` +41; other five unchanged | 600 → 641 | + +Coding adds Linux default-owned composition and state-root/maintenance identity +wiring, not storage implementation. The shell adds capability-driven help and +completion, approval receipt revocation and stale-request result isolation, using +the same action and task owners. The semantic projection maps Hosted state into +neutral capabilities; it neither grants authority nor imports native or Product +implementations. No file moves or exclusions are used to reduce counted lines. +The Coding and semantic groups retain their previous six- and twenty-one-line +margins respectively; previously counted capability allowances are not repeated. + +The subsequent Coding LMUX seven-file assertion exposed one further unrecorded +creation-receipt recovery composition delta: parser +14 and command +75, raising +1721 to 1810. Relative to the `da820585` wheel, parser is 102 → 116 and command +764 → 810; the command's net +46 includes the old selector's -29 already deducted +in the existing allowance. Do not deduct it twice. The other five files are +unchanged from that wheel. Independent review confirms that CLI only presents +and inspects the original operation, obtains confirmation and revalidates exact +identity through the existing creation owner. Durable history and authorization +remain in AppHost. Retain the exact seven-file group and boundary scans. diff --git a/docs/internals/architecture/apphost/lmux-live-probe-plan.md b/docs/internals/architecture/apphost/lmux-live-probe-plan.md new file mode 100644 index 000000000..1ed3ec89d --- /dev/null +++ b/docs/internals/architecture/apphost/lmux-live-probe-plan.md @@ -0,0 +1,85 @@ +# lmux 无目标连接:公共只读探测接线 + +状态:公共 operation 首版及 CLI 接线已实现,定向验证/复核中,尚未验收。补足既有 managed-service 设计中的 +“多候选中唯一在线 Mux 自动进入”,不替代完整交付验收。 + +## 边界 + +`ManagedDiscoveryV1` 仍只读登记事实,不把 COMMITTED 改称 ONLINE。 +新增 AppHost 公共探测 operation,Coding CLI 只负责交互和 Product 过滤; +未来 GUI 可调用相同探测,不依赖 Coding 或 Harnesstui。 + +输入为已打开的 namespace/registry、Product、原绝对期限,以及由 composition +冻结的 runtime_root、endpoint 和 expected_application_id,不重新读取环境。 +公共探测不硬编码 Coding +端点;认证后精确匹配 lease.application_id,read_mux 响应也须匹配冻结 +Mux ID,Product 相同不等于任意 Application 均可信。使用现有 +`snapshot_namespace()` 在一次事务内冻结完整有界候选,不跨分页推断唯一。 +输出只携带冻结 reservation、instance、creation Mux ID 和封闭观察结果: + +- `authenticated_present`:原实例连接认证成功,原 client 的 `read_mux` + 确认精确 Mux ID;不调用 attach,不取得 Tab 写入权。 +- `recorded_ineligible`:登记事实证明不参与本次选择,例如干净停止。 +- `not_present`:原实例给出精确 Mux 不存在的协议结果。 +- `unknown`:争用、超时、鉴权失败、损坏、实例变化或证据不足。 + +无 instance 的 reservation、PROVISIONAL、未结算 ABORTING 均为 unknown, +不得据它们排除候选后判断唯一。Product 过滤在初次冻结和最终复核使用 +同一冻结规则;同服务多个 Mux 共用一次探测认证,逐个精确 read_mux。 + +不得将任意连接失败统称 offline。探测不 spawn、start、stop、create、 +恢复 Session 或修改名称登记;状态结论不是未来在线保证。 + +## 所有权和 CLI 接线 + +采用值返回的两阶段接线,避免同步选择器跨 loop 保管已认证客户端: + +1. CLI 同步准备阶段保管原 probe operation;probe 在专属 Runner 内执行。 +2. probe 在 open/prepare 前保管原 journal 和 connection,使用只读准入, + 全部依赖借用同一 registry。按服务顺序探测并及时结算,避免候选数扩大 + 导致同时打开大量 fd。每次 IO 使用原总期限的剩余预算,不续期。 +3. 返回同步选择器前,必须结算全部连接和 journal;只允许值跨 loop。 + 取消、提交回执丢失或 close 失败仍由原 operation 保管,未结算不得 + 关闭 namespace 或启动下一阶段。禁止用第二个 owner 掩盖未知清理。 + 取消公开 waiter 后须在原 Runner 重新加入原 operation;原任务/原生 + 回执未结算时禁止退出 Runner 上下文或调用 Runner.close,不能依靠其 + 自动取消收尾。永久 unknown 沿已有专用 CLI cleanup_incomplete 进程 + 终止策略处理,不带着未结算 owner 返回可复用的嵌入式调用方。 +4. 唯一 present、其余候选无 unknown,且最终候选集合复核未变化时可 + 自动选择;否则展示选择器。复核比较完整 reservation、instance、phase、 + stop_requested 和 cleanly_stopped,不因无关 trace + 回执版本变化误判集合变化。选择器 `n` 下一页、`r` 首页只遍历本次 + 完整快照,不查询新页或追加探测;`f` 显式刷新才重新冻结并探测完整 + 候选。刷新完成前旧 present 标签不可用于自动选择。 +5. 最终原 CLI Runner 创建新的精确连接并重新认证,复核 reservation、 + instance 和 Mux ID,再启动 Harnesstui。失败不改选、不重启。 + +两阶段会增加一次认证,但能保留现有同步选择器,并明确清理边界。 +后续复用连接必须将整个选择流程迁入同一 Runner,不允许跨 loop 转移。 +探测和最终认证分别计时。总预算耗尽后未探测项标 unknown,不冒充已探测。 +探测期限到达后不再开连接、发请求或复核。清理另用原 owner 首次关闭时 +冻结的独立结算预算,超时保债;该预算不延长探测窗口。原生 IO 不可 +抢占,因此不承诺无条件硬墙钟上限。公共 operation 本身不退出宿主进程, +宿主负责保管未结算任务;只有专用 CLI 外层可应用上述终止策略。 +界面将 present 描述为“已认证确认 Mux 存在,尚未申请控制权”,不保证 +最终可进入或取得写入权。 + +## 认证状态例外 + +只允许一次合法 trace 事实发布跨过认证:原 trace 为空、新 trace 非空、 +revision 恰增加一,其余完整 state 不变。停止、身份变化、其他 revision +变化仍拒绝。该窄修已实现,相关回归待执行,不能据此认定探测完成。 + +## 验收 + +- 两真实服务一干净停止,异目录 attach 自动进入唯一在线 Mux,不读选择器。 +- 两在线 Mux(包括同服务的两个 Mux)仍选择;pending/unknown 不误判唯一。 +- 一 present 加一 pending/超时必须选择;同服务双 Mux 只认证一次, + 不影响另一个客户端的控制权;翻页无 IO,刷新不混入旧标签。 +- Mux 不存在、认证失败、超时、候选增删/换代、分页容量边界均安全处理。 +- 探测不 attach,不释放或取得其他客户端写入权,不改变 Session 数。 +- 取消、原 prepare 未返回、close 失败、原生回执丢失:依赖保管与退出正确。 +- 首项耗尽期限后后续服务零原生准入;取消 prepare/close waiter 不丢原 + 任务;全部值已产生但末项 close 失败仍禁止进入选择器或最终 Runner。 +- 最终认证失败不换目标;新旧入口、单候选流程和非 TTY 前置拒绝不回退。 +- 设计/实现复核后,再以真实安装环境验证额外认证的首用成本。 diff --git a/docs/internals/architecture/apphost/lmux-managed-output-capture.md b/docs/internals/architecture/apphost/lmux-managed-output-capture.md new file mode 100644 index 000000000..6ec976291 --- /dev/null +++ b/docs/internals/architecture/apphost/lmux-managed-output-capture.md @@ -0,0 +1,4149 @@ +# lmux 受管命令输出留存 + +状态:执行侧实现与验证中,完整受管接线未完成;补充 lmux M0 的实际暂存消费者。 + +## 目标与边界 + +把命令 stdout/stderr 的实际暂存写入接入既有实例 128 MiB、namespace +512 MiB 配额。不是任意工具的磁盘/内存沙箱,不涵盖工具自行写入 workspace +或绕过接口写入 TMPDIR。Session 持久根与旧非 managed 执行语义不变。 + +端口属于 `harness/workspace/exec`,Session adapter 负责组合和 blob 发布, +AppHost 实现配额与受管文件 owner。Harness 不反向导入 AppHost 类型。 +捕获句柄仅由可信本地组合注入,不进入 wire、工具参数、可复制配置或 repr。 +不支持该端口的 delegate 必须在 spawn 前拒绝,不回退旧暂存路径。 + +### 执行端口(设计复审通过,接线验证中) + +在 `harness.workspace.exec` 定义中性 `ExecCaptureSink`,提供 +`async append(chunk: ExecOutputChunk) -> None` 与同步、幂等、无 IO 的 +`stop_accepting() -> None`(只封闭新增写入,不关闭文件或退额)。另设显式 +`execute_captured(request, *, capture, signal=None, on_update=None)` 路径; +不把 capture 塞进 `ExecRequest`,也不复用可选 `on_update` 作为能力证明。 +原 `execute` 和两套后端的普通调用语义不变。受管组合只接受明确实现 +captured 路径的 delegate;不支持时在任何 reserve/create/spawn 之前拒绝。 +方法存在只表达可信本地实现的合同承诺,不宣称能约束任意恶意插件。 + +候选的职责划分如下: + +- Session adapter 在 prepare 前把新 capture 收进原 pending 生命周期, + 负责 prepare、调用 captured 执行、双流 seal、发布和最终清理;后端 + 只借用 append 与封闭写入准入,不拥有 reserve、seal、退款、Session blob + 或目录权限。 +- 本地与授权执行后端都将 stdout/stderr 的增量送入同一 sink,等待每次 + append 结算后再继续该流读取;最多两路在途,不另建无界队列。sink + 串行化原 native 写入并保管任务,调用者取消不能遗失实际写入的结果。 + 同一流按读取顺序提交;跨流不承诺操作系统级全序。正常 append 等待 + 自身写入结算;取消则先 stop_accepting,允许取消中的 reader 返回, + 但原 capture 继续强引用保管 native 任务及未知回执。decoder finally + flush 不得重新打开写入准入。进程终止必须先发起,不能等 native 写入 + 结算才终止;adapter 之后仍负责独立检查 native 债务,不以 reader + 已返回或已取消冒充 capture 完成。 +- captured 路径强制三种输出缓冲 rolling、禁用旧文件留存及 artifact_dir。 + 候选每个缓冲最多 100 KiB;preview 上限不大于该值;读取与文本分块 + 最多 16 KiB 字节/字符,编码最坏单块 64 KiB。授权 handle 必须遵守 + `read_stdout/read_stderr(max_bytes)` 合同;不能把请求里的更大上限带入。 + sink 将最多 64 KiB 的编码块有界拆成 native append 允许的 ≤16 KiB + 写入,不把编码块大小与底层 record 上限混为一谈。 +- 日常留存失败由 sink 记录双流共享 sticky 状态,后续 append 为有界 + 无写入操作,仍让后端完成 drain。合同错误或无法结算的 native IO + 不是普通 quota failure,不用吞异常冒充留存失败;保留原执行清理路径。 +- captured 运行期必须同时监督原 stdout/stderr reader、进程退出及 + abort/timeout 任务。不能仅在 `_publish` 插入 await 后继续先等进程 + 退出:reader 因 sink 合同错误结束时,子进程可能堵满管道而永不退出。 + 任一 reader 异常立即进入原进程终止、排水和 join 清理;正常 EOF 不 + 等于进程退出。复用原任务和 owner,不增加第二个进程管理者;普通 + quota sticky 返回仍属于正常 drain,不触发该异常分支。 + 监督从取得原进程 handle 后、开始写 stdin 前就生效:stdin 写入和 + close_stdin 也是原执行拥有的任务,不能先无限等待 stdin drain 再开始 + 监督。覆盖子进程不读 stdin、持续输出且 sink 首次失败的交叉管道场景。 +- 后端结束仅证明原读取任务已结算,不证明磁盘完整。adapter 只有在 + sink seal 返回双流完整回执、`stdio_complete=True` 且无留存失败时 + 才有发布资格。普通 preview 的 stdout/stderr_truncated 不取消资格。 + 等待者取消、执行异常或正常返回的 `cancelled=True` 均跳过发布,继续 + 保管 capture 清理。`timed_out=True` 但非 cancelled、stdio 完整且 seal + 成功时允许保留截止终止前的完整输出;结果仍明确超时,不能改成成功。 + +能力获取候选签名为 `ExecService.capture_executor() -> CapturedExecExecutor +| None`;`CapturedExecExecutor.execute(request, *, capture, signal=None, +on_update=None) -> Awaitable[ExecResult]` 绑定获取时的原后端,不在调用时 +重新挑选后端。获取为无 IO 操作,在 Session capture prepare 前完成。 +执行能力负责 materialize 原请求、钳制 captured 参数并校验返回结果。 +两套后端实现独立 `execute_captured`,不让调用者直接用普通 execute +加 on_update 伪造该能力。 + +默认获取器必须核对实际后端是否实现 captured 合同,不能只检查 +ExecService 上继承来的方法。另有自定义 execute 的包装器,若未显式 +实现 captured 策略则返回 None,不返回内部后端能力绕过包装层。 +现有 SessionOutputPersistingExecService 也不能因基类默认建立了一个 +未使用 LocalExecBackend 而被误认支持。显式受管 Session 分支会调用 +其原 delegate 的获取器,不调用自身继承的默认获取器。 + +Session prepare/seal 回执与可重复 cleanup 的具体类型仍待冻结; +此执行侧能力并不宣称整个 capture 生命周期接口已完成。 + +这些数值限定捕获层的新增缓冲,不是整个 Python 进程的 RSS 上限; +已有回调、工具、Session blob 发布和并发执行另按各自合同计量。 +先评审并实现两后端的真实 captured 路径及无旁路回归,再接 AppHost +capture/精确删除退额;仅增加 Protocol 或给后端打支持标记不算完成。 + +执行侧修订经三视角复审可作为实现输入;上述 reader/stdin 监督、 +取消准入和结果资格问题已在设计中关闭。首个实现为私有 +`exec/_capture_supervision.py`:借用原 stdin、双 reader、exit 和 abort +任务,返回 exit/abort/timeout 或传播 IO 异常;正常 EOF 仅移出观察集合。 +不创建第二个进程 owner,也不取消借用任务。已补 8 个确定性 asyncio +用例;仅该辅助函数通过静态检查,不足以证明真实子进程接线正确, +还需以下后端验证。 + +后续已实现中性 capture 接口、绑定原后端的获取器,以及两后端显式 +captured 路径。两后端使用上述监督,并禁止旧 artifact 文件路径。 +代码复审发现并修复:内置后端子类隐式继承绕过普通策略、封闭回调抛错 +阻止授权进程清理、本地清理二次取消跳过后续结算。新增本地借用准入 +保护仅记录封闭错误、阻止后续 append,不取得存储清理或退款权限。 + +首轮监督测试因误用仓库未安装的 pytest-asyncio 失败(8 failed),已 +改用现有 asyncio.run 风格。首轮两后端与旧 execute 集合 50 passed +(6.78 秒),但运行与最后修复重叠,不作为最终修复验收。已追加后端 +策略包装、封闭双异常和二次取消/native 仍 pending 回归并申请最终验证。 +Session prepare/seal/发布、AppHost capture 和精确退额尚未接线。 + +复核进一步要求清理任务不能经外部 task factory 发布。captured 本地 +强制清理改用可信内置 asyncio.Task、绑定当前 loop 并立即保管引用, +之后 cancellation-atomic join;不新增资源 owner,普通 execute 不经过 +此新任务。生命周期复核认可该边界,补 before/after-schedule 外部工厂 +故障均不得命中 cleanup 的定向验证。内置 Task 构造器被猴补或 OOM +不在该外部工厂合同内;新增验证仍待实际运行结果。 + +修复后的监督、两后端和普通执行集合正常结束:62 passed(6.89 秒)。 +后续内置 Task 的最终修改及新增工厂故障用例另行针对性验证,不计入 +这次 62 项结论;源码类型检查通过。整体受管 capture 仍未交付。 + +最终本地清理的外部工厂隔离与二次取消定向验证正常结束:3 passed、 +6 deselected(0.83 秒),不重复此前未变更的普通执行检查。 + +## 捕获与发布合同 + +### Session 租约(设计已复核,适配器待接线) + +实现进度:`SessionOutputPersistingExecService` 已新增显式 +`capture_factory` 分支,内部 `_output_capture.SessionOutputCapture` 保管 +8 个 pending 租约、封存后顺序读取/导入双流、独立清理诊断与 close。 +原 scratch 分支保持兼容;已有包装器不允许静默更换 capture authority, +非持久 Session 显式传 factory 会拒绝。首次异步使用绑定原 event loop。 +并发清理等待者只能清除自己加入的原 task 引用,不覆盖后续阶段。 +新增发布与清理交叉、迟到 prepare、容量债务、factory/loop 隔离和 +多等待者清理回归。`test_output_capture.py` 与旧 +`test_output_artifacts.py` 配对验证共 **29 passed in 1.74s**;包括 +发布/清理双失败、限额预检、超时与取消及后继清理回执竞态。 +AgentProduct 已增加可信 +`output_capture_factory` 注入,并在进入 Graph/runtime profile 处置前按 +对象身份去重,先同步 fence 两个执行适配器,再取消并 join 原 +side-question 生产者,之后逐个 close;不持有 model-call bind lock +等待执行;任一清理失败则保留原 Session +authority 供重试。该生命周期接线待复审及实际 Product 全链路验证。 +`tests/coding/test_owned_capture_shutdown.py` 已新增实际 Coding runtime、 +Graph-owned transcript writer 与假 capture/backend 的交叉回归:blob +已发布但 close 失败时原 writer 必须仍 busy,原 capture close 重试成功 +后才能重新获取 writer。该测试运行待返回,不等于实际 AppHost native +删除/退额验收。Graph 准备失败的独立回滚路径仍在复核,正常退出 +前移 close 不足以单独证明所有失败路径的 authority 释放顺序。 +底层 Session provider disposer 已加入同步 retirement guard:捕获仍有 +pending/active 时在任何释放动作前拒绝,由原 Graph 保留 retirement。 +外层 prepare except 此时仅 fence、保留原局部 generations/components、 +标记必须 retire 后抛回原错,不持锁等待;正常 dispose 在锁外完成 +capture 后重接原 Graph/catalog 回滚。此增量仍待复审及故障运行验证。 +生命周期局部复核已关闭所报问题;排队 prepare 在获得原 model lock +后再次检查 retirement latch,防止早期失败尚无 Graph pending 时继续 +构造。实际 Coding writer、capture 与 shutdown 三模块回归合计 +**24 passed in 10.65s**,包括该锁屏障和原 provider retirement guard。 +此验证仍使用假 capture/native backend,不证明 AppHost 删除/退额。 +此前退出 +helper 与清理诊断/命令/协议投影组合 **26 passed in 5.24s**,不用于 +证明本次新增 guard 和排队检查已运行通过。 +真实 AppHost factory 尚未接线,因此尚未激活实际 lmux 受管输出。 +本次三视角局部代码评审报告的 factory 忽略、跨 loop 操作及迟到 +cleanup waiter 竞态已修复;架构与生命周期修复复核通过。成功移除 +pending 同样要求当前 cleanup task 身份匹配,不能仅依据 lease 的 +pending=False 提前放弃仍未返回的后继回执。新增回归同时覆盖后继 +回执成功/失败;尚不能以静态复核代替这些运行结果。 + +可信组合注入 `ExecCaptureFactory.new_capture() -> ExecCaptureLease`,此步骤 +无 IO。adapter 在构造时先获取并保存原 delegate 的 captured executor; +不支持则拒绝,不让继承的备用后端或旧 TemporaryDirectory 接管。 +受管分支不预建旧 scratch root。每次执行把新 lease 加入原 pending +集合后才调用 `prepare()`;失败或取消也不丢失这份原对象。 + +lease 除执行侧 sink 两个方法外,候选接口为: + +- `async prepare() -> CapturePreparation`:只有 READY 或 + RETENTION_UNAVAILABLE 两种已结算结果。后者仅由原双槽零效果容量 + 拒绝产生,明确使用不写盘的 sink;未知 commit/create/close 回执抛错 + 并留在 pending,不能变成该降级结果。不自动换 lease 重试。 +- `async seal() -> SealedExecCapture | None`:封闭新增 append,核对原 + 双流及全部 native 债务。完整才返回同一 lease 绑定的两个只读 source; + 已知 sticky 留存失败返回 None,未知 native 债务不伪装成完整或普通 + 配额不足。source 不携带可传输凭据,不是清理/退款许可。 +- source 提供 `size_bytes` 与 `async read_bytes(max_bytes=...) -> bytes`, + 必须核对 seal 时的原文件身份、长度、变更戳;读取由 lease 保管 native + 任务,不暴露可由 caller 改写的路径。adapter 在读取前核对双流总量 + 和 Session 每 blob 限额,继续既有 hash/blob import,不引入第二份 + Session manifest。两个 source 必须同属本次 lease,不接受后端结果里 + 的旧 artifact path/ref 代替这份封存结果。 +- `async close() -> None` 与 `cleanup_pending: bool`:继续结算原任务、 + 精确删除、父目录同步、原文件句柄关闭及原 allocation ID 退额。 + 允许在同一 lease 上继续未结算阶段,不重新执行已完成或未知的删除; + 仅 close 成功且 pending=False 才从 adapter 集合移除。 + +正常执行但无法完整留存时,清除两路物理路径/ref 并设置原 +artifact_retention_error;process exit_code、timeout、cancelled 不改写。 +发布失败和清理失败分开记录:暂存已清理不能证明 blob 未发布,blob +已发布也不能证明暂存已释放。Session/runtime 的退出路径必须等待原 +adapter pending 集合结算,不能只结束 execute 局部 finally。 + +以下规则已纳入设计复核;它不代表精确删除回执或退款实现已经具备。 +中立接口已落在 Harness 的 `workspace/exec/capture_lease.py`: +`ExecCaptureFactory`、`ExecCaptureLease`、`CapturePreparation` 与只读 +`SealedExecCapture`/`SealedExecSource`。双流均为必填 source,空流使用 +长度为零的 source,不以缺席表示完整。接口本身不执行 IO、不验证具体 +实现的所有权,也不代表 Session adapter 或 AppHost capture 已接线。 +接口落地后架构局部复核通过,无新增 P1/P2;Ruff 与该模块 mypy 通过。 +这不替代实现阶段的并发、取消和删除/退额验证。 + +接线位置已核对:`AgentProductSession` 当前分别组合命令与工具执行 +适配器,两者可能为同一对象;退出走 `_dispose_session_runtime_profile` +及原 `base_dispose`。实现时需对适配器按对象身份去重收尾,并在释放 +Session 原 blob/writer 权限前结算其 pending;构造失败也须保留已创建 +适配器,不能只在正常 dispose 中添加 close。 + +复审收紧的阶段与容量规则:adapter 同时最多保管 8 个 pending lease, +满额在新 lease/prepare 前明确拒绝,清理债务同样占位。首次 close 同步 +fence 新执行,join 已接纳执行及原 prepare,再逐 lease 收尾;prepare +返回后、进入 executor 前复核 closing,迟到 READY 不得启动进程。 +execute.finally 与 runtime close 必须 join 同一 lease cleanup 任务, +不得并行启动两份清理。重复 prepare/seal 只重接原阶段;重复 close +只 join pending 原任务,原任务已失败结束时才续未完成阶段。 + +source 的大小是 seal 时冻结值。read 在 lease 同一准入域登记后才 +启动 native,单 source 至多一份在途 read,额外并发请求明确 busy; +取消 waiter 不取消原 native。close 同步禁止新增 append/read,并等待 +已准入读取结算后才删除。closing/closed 后旧 source 一律拒绝,不重开 +路径。adapter 顺序读取 stdout、stderr;读取前核对两流合计不超过 +128 MiB 和各自 Session blob 限额,读取中仍检查上限及最终长度。 +这限定本次双流 payload 合计,不宣称是整个进程 RSS 上限;现有 blob +import 所需副本、native 分块及其他并发活动不能从测量中排除。 + +结果交付候选:blob 已确认发布但临时清理/退额失败时,保留真实 +exit_code 和已确认 refs,lease 继续占 pending;不把它写成"输出未留存", +不因这项收尾错误重跑命令。拟新增兼容的 keyword-only +`ExecResult.artifact_cleanup_error` 区分清理债务与 retention error,相关 +工具结果投影同步传达该诊断。发布回执未知但暂存清理成功时,仍由原 +Session blob owner 保管未知发布,不由暂存状态推断成功或重新发布。 +该兼容字段及工具/协议/命令结果投影已实现,默认 None 时不增加工具 +输出字段;当前只允许 temporary_cleanup_pending 固定代码,不接受原始 +异常、路径或凭据。三条投影复用同一封闭校验,两个别名冲突一律拒绝。 +值表示结果交付时的清理诊断,不是随后重试后的实时状态;pending owner +仍是清理依据。已补字段兼容、refs/exit 保留、双错误及 raw/别名冲突 +回归,运行结果待返回。尚未连接真实 lease 发布/清理交叉故障流程。 + +修订后的 Session 租约阶段与容量设计经架构、生命周期复核可进入实现; +精确 native 删除/退额不在此次设计通过范围内。 + +- managed 分支先收养 capture,再做任何 reserve/create,不先创建原 + TemporaryDirectory。原 adapter/runtime 保管 pending capture,不能只 + 依靠 execute 局部变量和 finally。 +- 一个 capture 覆盖 stdout、stderr 和合并输出的全部状态。两套执行后端 + 都使用硬钳制的 rolling 内存上限;用户参数不能重新开启 full capture。 + 禁止 `_build_preview` 再走 `_write_output_artifact` 形成旁路。 + 纯内存降级需要显式无文件 capture 路径:现有 capture_full_output=False + 单独设置仍创建文件,不能仅翻转此布尔值就宣称无暂存效果。基础修复后 + 与 retain_output_artifacts=False 组合可真正纯内存捕获,后续受管组合 + 必须同时强制两者,并明确记录配额导致的留存失败。 +- 追加按 UTF-8 + surrogateescape 实际字节计费,编码 chunk 和排队容量 + 均有界。双流共用总预算,不各自领取整份实例额度。磁盘 IO 放在原有 + 生命周期保管的 offloop 执行中,等待者取消不取消原 IO 或丢弃回执。 +- quota/写入失败是双流共享的 sticky retention failure。运行中失败后 + 停止两路落盘,继续有限内存捕获和 drain;原 timeout、取消、stop 仍有效。 + 真实 exit_code 不变,使用既有 artifact_retention_error 明确说明完整 + 输出未保留,不将进程成功冒充留存成功。 +- 发布前必须取得双流 seal 且全部 append 结算、没有 retention failure 的 + 完整回执。任何一路失败,跳过整个双流发布并清除物理路径投影;不得把 + partial 文件发布成完整 blob。blob 发布未知回执仍由原 Session blob owner + 保管,不以暂存清理推断未发布。 +- 发布期间暂存与最终 blob 共存。暂存额度直到真实释放前持续收费;blob + 计入 Session 存储策略,不能声称 512 MiB 限制包含全部持久历史。复制峰值 + 和两路 payload 内存必须另行有界;不能无限 read/join 后再检查。 + +### 当前发布路径的已有界限与剩余成本 + +源码复核:现有 `SessionBlobPolicy.max_blob_bytes` 默认 128 MiB。 +`read_stable_artifact_source` 在读取前检查文件大小,读取中按最多 1 MiB +分块并检测超限,之后才 join;因此它并非无限读完后才检查。但 Session +adapter 的 `prepared` 会同时保留两路完整 payload(默认上限合计 256 MiB), +单路 join 期间还会暂存分块与新连续 bytes。这不是整个过程的峰值上界, +后续 blob import、已有引用校验和并发执行也需计入。 + +受管接线应继续使用现有稳定读取/Session 发布身份校验,并显式限定本次 +capture 双流发布预算;不能把现有每 blob 限额当作实例级内存或磁盘配额, +也不因 managed 需求修改旧非 managed 的 Session 默认存储策略。 + +## 启动前失败的区分 + +双流必须使用原子预算准入,避免第一路成功、第二路不足造成半准入。 +接口必须返回与本次准入绑定的明确"零效果容量拒绝"结果,不能仅捕获 +通用 capacity 异常后推断无效果。该结果允许退化为纯 rolling 捕获并运行命令,报告 +留存失败。若已有 create/预留未知回执或清理债务,则不得在未结算状态下 +把它当成零效果失败继续 spawn;先保管、结算或失败退出。端口不支持、 +身份错误等合同失败同样不得静默降级。预算 COMMIT 未知不属于零效果拒绝。 + +## 精确释放与复用 + +现有 ManagedStorageBudgetV1 只有 reserve/bind,不能直接用于可循环暂存。 +新增 release 只允许 temporary,并遵循: + +1. 封闭追加,join 双流原 native 任务与发布读取。 +2. 校验精确文件/目录身份,unlink,父目录 fsync,原句柄全部关闭成功。 +3. 原 registry 短事务按完整 allocation/reservation ID CAS 退额。 + +Linux unlink 后仍打开的文件继续占盘;未知 close/delete/sync 一律保留 +收费。旧 allocation ID 的重复 release 不得碰同 slot 新 allocation。 +COMMIT 回执丢失只能核对原 ID 收费状态,不重复删除。历史 unbound、路径 +不存在、PID 消失不是退款证据;只有原 owner 能证明 create 从未准入时, +才可释放本次未绑定预留。slot 复用必须产生新 allocation ID。 + +## 实施与验收顺序 + +1. 冻结中性 capture/完整回执接口、可信注入与 capability 检查;覆盖两后端 + 和三种缓存,不启用 managed fallback 旁路。 +2. 实现精确 temporary 退额和 slot 复用,补 COMMIT 丢回执、旧 ID 重试及 + unknown-close 不退款的确定性验证。 +3. 实现受管 capture 和 Session adapter 接线,纳入原 pending 生命周期; + 同时验证 spawn/取消/发布/清理失败。 +4. 真实子进程双流超额后仍完成可验证最终动作并返回指定 exit_code;并发 + 最后一份额度竞争、长行/无换行/分块 UTF-8、磁盘与内存峰值、满盘和短写。 +5. 验证正常命令重复执行不会累计耗尽已释放配额,异常原件不被误删;旧 + 非 managed 路径兼容。再接插件等其他实际消费者。 + +三视角初审提出的关键缺口已纳入本稿:实际写入旁路、未支持端口的拒绝、 +双流失败一致性、发布资格、强引用保管及精确退额。接口与启动前降级细节 +仍待具体类型/数值冻结;三视角边界复审通过,可作为接口实施输入。 +现有 Session 输出、owned 输出与 exec service 基线 26 passed(2.76 秒)。 +本稿不是功能完成或整体 goal 验收记录。 + +## 已验证的前置实现:禁用留存不写盘 + +共享 `_StreamCapture.append` 现在仅在 retain_output_artifact=True 时创建 +和追加文件。rolling 模式关闭留存后只更新原计数和有界缓冲;full-capture +关闭留存的预览路径原本就不写文件,但仍保留完整内存,不能作为 managed +有界模式使用。没有新增配置、资源 owner 或 AppHost 依赖。 + +先加强原测试禁止 mkstemp,复现 rolling 模式 1 failed、full-capture +1 passed;修复后本地/授权执行、Session 输出及 owned 输出等 59 passed +(6.10 秒)。新授权后端测试验证双流多字节、真实退出码、无临时文件和 +三种 rolling 结果的留存大小,不作为整个执行过程内存峰值证明。三视角 +局部复核、Ruff、源码 mypy 通过。此处仅为无文件降级基础, +尚未完成配额准入、原子双流预留、完整 capture 回执及安全退额接线。 + +## 原子双流账务准入 + +原 `ManagedStorageBudgetV1.reserve_temporary_pair` 已实现排他双槽事务。 +输入必须是同 service/instance/root 的两个 temporary allocation、不同 +slot,并要求调用方在事务前保管两个不同 allocation ID。任一 slot 或 +ID 已存在均先报 conflict,不认领旧记录、不把冲突包装成容量不足。 + +事务统一检查行数加二、namespace 与实例的两路容量合计,成功一起插入; +逻辑额度不足且完整事务/锁退出成功、期限仍有效时,才返回独立的 +`ManagedTemporaryPairCapacityRefusedV1`,绑定 namespace、原有序 pair 和 +原 ID。物理增长准入、COMMIT、关闭或其他 capacity 异常均不转换成此值。 + +该值只证明本次账务未新增两槽,不是 native 零效果或文件创建权限。 +提交回执未知时仍收费,后续必须在原 capture 保管下按原 pair/ID 原子 +对账;不能仅看两个目的相同就收养另一调用者的记录,不能重新选择 slots +或补插半对。原子查询、native capture 与退额尚未接线。 + +原账务基线 21 passed(2.77 秒);首轮新旧回归 33 passed(4.44 秒)。 +三视角局部复核通过;按建议继续补 namespace 最后额度竞争、已占用 ID +用于新 slots、非法 ID 零 IO,以及事务退出后到期不交付成功/拒绝结果。 +最终双流账务专项 20 passed(1.09 秒),相关 Ruff、源码 mypy、文档轻量 +检查及 diff-check 通过。实际命令输出 capture 的配额接线仍未完成。 + +## 原 pair 的只读精确对账 + +新增 `lookup_temporary_pair`,按原有序目的与调用方保管的两个 allocation +ID,在同一只读事务中查询。两个槽位均无对应记录且两个 ID 也未被占用时 +返回 None;完整原 pair 返回当前 reservation/binding;半对、其他 ID、 +原 ID 被移到其他 slot/root 等情况均报 conflict。不写入、不补插、不退款。 + +绑定状态只是观察值,不是文件创建资格。None 同样不能证明此前没有 +native 效果;未知 COMMIT 后仅可由原 capture 使用其既有身份继续对账。 +完整退出事务后复验 deadline,成功和空结果均不得在期限后交付。 + +三视角局部复核通过;新旧账务回归 49 passed(3.73 秒),涵盖提交回执 +丢失、部分存在、ID 替换、目标重定向、已有 binding 与退出后超时。 +此处补齐了账务对账,实际 capture、原 owner 结算和安全退额仍待实现。 + +## 删除完成凭据:实现前约束 + +源码复核发现,`PrivateManagedDirectory._cleanup_pending` 允许原文件已 +missing 时继续清理。这符合一般临时残留清理语义,但不能证明原文件空间 +已经释放,因此不能直接把 `_pending` 清空当作 temporary 退款资格。 + +保留原 directory owner,新增其内部小型 removal tracker,而非新 runtime +或清理控制器。capture 在首次 IO 前保管 tracker;tracker 冻结目录身份、 +完整 expected snapshot、文件名和本次操作身份,拒绝 lock 文件,未结算 +数量有界。它必须独立于允许 missing 的普通 pending 清理。 + +开始删除前,原 capture 封闭追加与新借用,并 join 原双流 IO 和发布读取。 +以原 inode 的验证 fd 贯穿隔离、删除和同步,禁止用重新打开的同名文件 +替换原句柄。沿既定"先隔离后删除"原则,在原私有目录中隔离后再次核验 +身份;隔离或身份回执未知时保留原件/隔离件,不销毁可能的替代文件。 + +阶段单调推进: + +1. 尚未删除:核验原路径/隔离路径、原 fd 身份及单链接文件条件。 +2. 删除已准入但回执未知:保持 unknown,不以当前 missing 补成功,不能 + 再删同名替代文件。 +3. unlink 明确返回:通过原验证 fd 核验 st_nlink == 0。正常 unlink 返回 + 不足以证明删除的是原 inode;核验失败不能退款。 +4. 仅待目录同步/关闭:重试只续原 fsync 和尚未进入 close 的原句柄,不再 + 按名字 unlink。未知 close 沿原 `_uncertain_closes` 保债,不重关 fd 数字。 +5. complete:父目录同步、所有受管文件句柄与借用均已结算,才形成绑定 + 原 tracker 的完成凭据。不是可任意构造的 complete=True 配置值。 + +该完成事实只覆盖原受管资源域,不声称能发现任意外部进程持有的 fd。 +capture 还须核对原 allocation ID、root/file identity,才能做 temporary +专用退款 CAS。重启后无原 tracker 的残留继续收费,不由路径不存在恢复 +为成功;退款回执未知只对账原 ID,不重新删除。两路可分别结算,但失败 +一路的原 tracker 和费用必须保留。 + +三视角设计复审要求的故障矩阵:删除前失败;隔离后身份替换;unlink +成功后丢回执;unlink 后目录 fsync 失败且同名新文件出现;close 未知后 +fd 复用;预先 missing/symlink/hardlink;原 append/发布读取未结算;旧 +退款 ID 遇新 slot 分配;同一完成回执重复使用零新 native 效果。 + +删除 tracker 初始实现已加入原 `PrivateManagedDirectory`,退款接口尚未 +实现。`prepare_data_removal` 无 IO 登记最多 8 个原对象;`remove_data` +保管同一 fd 经过隔离、unlink、目录同步和关闭。未知 rename/unlink/close +不重放;isolated 阶段保存并复核完整快照。可恢复 tracker 未结算时 +directory.close 拒绝改变关闭状态;只有未知阶段时可回收尚未尝试关闭 +的原 fd,但保留 removal 债务并报 unavailable,绝不据此形成 complete。 +新入口在事件循环内前置拒绝,供原 offloop owner 调用。该初始实现仍在 +局部评审/测试中,不能作为退款凭据或宣称实际 capture 已完成接线。 +初始删除/隔离组合 **15 passed in 0.61s**。之后新增单调 +`abandon_data_removal`:保留原 phase、封闭进一步删除,允许目录回收 +已知句柄,但保留 tracker 债务并报 unavailable。new/opened 永久冲突 +和 isolated 内容变化不再迫使原句柄永远打开;该修复静态复核通过, +新增放弃、完整快照变化、事件循环拒绝、unknown-close 加 fd 复用 +交叉回归已补齐并运行;删除与隔离组合 **20 passed in 0.81s**。 +该结果不包含尚未实现的退款或实际 capture factory。 + +### 下一接缝:删除完成与原账务绑定(待评审) + +退款不能只接收公开 snapshot 或 caller 可改的 tracker.phase。计划由 +原 native owner 在所有阶段成功后铸造只读完成对象,原 tracker 保管 +该对象身份;重复取得只返回同一对象,不再做 native IO。 + +删除前的可信组合必须绑定原 budget/database 实例及完整的已绑定 +reservation(allocation ID、root key/identity、file identity、slot、 +capacity)。该绑定作为本次原操作的不可变上下文进入 tracker,不在 +完成后才按 inode 推导:同 inode 数字被新文件复用也不能匹配新账务。 +native 层只保存不解释这个不透明绑定,不导入 storage_budget。 +仅保存不透明 reservation 不足以证明目标一致:budget 侧必须在绑定及 +退款时,把原 native owner 自身冻结的 root key/identity、expected file +identity、capacity 与 reservation 双向核对。native 目标不能从该 +reservation 回填,需来自原目录/文件 owner;应以两个合法文件和两份 +合法 reservation 的交叉绑定负测,证明"删除 A,释放仍存在的 B"被拒绝。 + +temporary 专用 release 在任何事务前验证原绑定和原完成对象身份, +仅按原 allocation ID 及完整 reservation CAS 删除收费行。已无原 ID +可返回已释放,但不按 slot 删除新行;原 ID 仍存在却内容不符则冲突。 +未知 COMMIT 后,只重复同一 CAS/原 ID 对账,不调用 native 删除。 +log/trace、unbound、abandoned/unknown 或仅路径 missing 全部拒绝。 +allocation ID 不能在退款后复用。永久保存每次退款 tombstone 会随正常 +采集无限增长,不适合可循环暂存。候选采用 namespace 单行持久高水位: +temporary ID 由递增序号编码成现有 32 位 hex;原账本提供只读的下一对 +候选 ID,reserve 在原双槽事务中核验它们恰为当前下一对,并同时推进 +高水位。并发旧观察明确 conflict,不重新选择 ID/slot 或自动重跑命令。 +逻辑零效果容量拒绝不推进高水位;COMMIT 未知仍按原 pair 对账。退款 +仅删 allocation,不回退高水位;序号耗尽拒绝而不回绕。单槽 temporary +reserve 也须使用同一序号源,不能保留可绕过的不受约束 ID 入口。 +此候选需要严格存储格式升级及相应旧格式拒绝测试;不会自动迁移数据。 +设计复核通过后,该 ID 准入已实现为格式 12:固定前缀 +`74656d7000000000` 加 63 位正序号的 16 位 hex,高水位存在原 identity +单行;单/双槽统一来源,active 行还须通过域及高水位一致性校验。 +原双槽调用者需先读取候选 ID,尚无生产调用方。新增序号及竞争回归、 +旧格式 11 拒绝测试;结果待返回。删除完成凭据与真正退款 API 仍未实现。 +原未创建文件的零效果退款需另一种由创建 owner 证明的完成事实, +不借用本删除凭据;这一分支也必须在启用实际 capture 前实现。 + +初始实现进度:native owner 已提供冻结 `ManagedRemovalTarget`、删除前 +不透明绑定及完成校验入口;完成时由原 owner 创建内部 marker,不能仅 +传 phase 字符串取得完成目标。budget 的 `prepare_temporary_release` +双向核对原目标并绑定原 database,`release_temporary` 复核完成/绑定 +后只删除原 ID 对应且完整一致的收费行。已有原 ID 缺失则不改新 slot。 +DELETE 仍需要原 `admit_growth` 的物理峰值准入,不消费 stop/control +保留区;空间不足保留收费和原完成事实供重试。相关跨绑定、重复释放、 +未知 COMMIT 和物理准入失败回归已补,运行结果待返回。该原型还未接 +实际 capture,也未实现未创建文件的零效果退额。 +退款与删除组合 **12 passed in 1.39s**。随后补齐同一 absolute deadline +覆盖 native 完成校验 mutex:进入前、取得锁后均复验,超时不进入数据库。 +新增到期/锁忙负测与 format 12 原始行 fixture 修正的定向验证合计 +**9 passed in 1.48s**;完成 marker、原ID、物理准入不因超时而消费。 + +### 未创建文件的退额接缝(待评审) + +不能以 unbound row 或当前 missing 证明没有创建。计划在原 directory +owner 中增加轻量 creation tracker,capture 在 reserve/create 的首次 +native IO 前保管它;它冻结原根、文件名、capacity 和不可变账务绑定。 +真实创建只能通过该 tracker:在进入 open/create 前单调标记 admitted。 +任意已 admitted 的错误均不发"未创建"凭据,交给原 native debt 结算。 + +只有仍未 admitted 的原 tracker 能同步 fence 创建并铸造"从未准入" +完成事实;之后任何创建调用都拒绝。budget 侧独立校验原 database、 +完整 unbound reservation 以及 native 冻结目标,再走同一原ID精确退额。 +已绑定文件的 reservation 不接受此凭据。原 create 任务尚未开始但已经 +排队也要受同一 fence:不能先退额、随后排队任务迟到创建文件。 +该分支只退明确的零效果预留,不从未知 COMMIT/close 或文件缺失恢复 +成成功;正常 created 路径仍须走原删除完成凭据。 + +生命周期评审补充的强制身份合同:unbound reservation 尚无 inode, +因此仅比较 root 与 capacity 不足以证明同一分配。必须在首次创建前, +将原 allocation ID、slot、严格由 allocation ID 派生的 native filename +与原 tracker 建立不可换绑的一对一关系。原 owner 不得为同一目标重建 +第二个未准入 tracker;capture 只使用该次 reserve 所对应的原 tracker。 +创建准入检查及 admitted 置位与 fence 必须持同一原 mutex 完成,禁止 +检查后释放锁、在原操作真正准入前允许 fence 退额。已排队任务也不例外。 + +实现验收必须包含同根同额的交叉负测:B 已创建但尚未绑定 inode(包括 +bind 回执丢失),不能借未准入 A 的完成事实退还 B;重复注册同 target +不能重新获得零效果凭据;fence 先赢时排队创建无 native effect,创建 +先赢时 fence 不生成退款资格。这些要求目前仅为设计约束,尚未实现或 +通过测试,不作为实际 capture 已有安全退额能力的证据。 + +生命周期补充复审确认以上身份约束可作为实现输入。避免长期服务保存 +无界 tombstone 的实现约束:原 owner 保留单调 high-water,原 mutex 下 +仅接受更大的 format 12 序号;完成、放弃或退款均不得回退。两流 tracker +必须成对原子注册(容量不足不留半对),或对整个 owner 的注册串行化, +不能仅对单个 capture 串行。high-water 与有界在途 tracker 独立保存, +结算后移除 tracker 不恢复旧 ID 的注册资格。该 high-water 只防同一 +owner 生命周期内重注册,不是重启恢复凭证:新 owner 不得从历史 unbound +行重新生成 never-admitted 事实,零效果退额始终要求原 reserve 尝试与 +原 tracker 的来源绑定。未知 reservation 提交须先查询原 ID 的精确结果, +不能通过新 owner、新 tracker 或新 ID 把未知状态转为零效果。 + +当前实现增量:`PrivateManagedDirectory` 已加入原 owner 创建 tracker, +两流原子登记、八个在途上限与不回退 high-water;native 文件名由传入 +allocation ID 严格派生。原 binding 身份与 owner 双重检查,fence 和 +admitted 置位共用原 mutex。创建回执丢失保留 unknown,不重放创建, +close 释放已知资源后仍报告未结清;未准入 tracker 须先 fence 才能关闭 +owner。这是 native 原型,不是退额实现:尚需 budget 绑定原 reserve +尝试、检查完整 reservation 与冻结 native 根身份,禁止新 owner 根据 +历史行生成凭据,之后才能接入 capture。创建/删除定向测试已发起待收取; +源码 Ruff、mypy 和 diff-check 已通过。新增原 owner/原 binding 交叉负测 +在测试启动请求之后加入,须核对实际采集数量再计入通过证据。 + +创建/删除组合已返回 **12 passed in 0.69s**(四项创建、八项删除)。 +随后修复局部评审 P2:`cleanup_pending` 计入所有在途 creation,而 +内部 append 准入只检查实际 IO/unknown/removal 债务,避免隐藏尚需 +fence 的 new tracker。生命周期静态复审确认该 P2 关闭。新增 Event +控制的双线程竞争覆盖 fence 先赢与 admitted 先赢;另外补创建 fd 真实 +关闭后失回执、立刻复用数字 fd 的测试,要求原 close 不伤替代句柄。 +这些新增验证尚待运行结果,不能计入上述十二项通过证据。 + +随后创建专项返回 **7 passed in 0.51s**,含双线程两种先赢和创建 close +回执丢失后的 fd 复用。下一增量为 native 冻结 `ManagedCreationTarget` +(原根 hash/identity、allocation ID、派生名称、capacity),由原 owner +生成,不从 unbound 行复制。`creation_target(require_fenced=True)` 仅在 +同原 binding、原完成 marker 和 fenced 状态下返回该目标;完整 deadline +覆盖原 mutex 等待及取得后的复验。创建/fence 前也复验当前 tracker 与 +冻结目标一致。该增量及其新增负测尚待专项结果;budget 的原 reserve +来源绑定与精确零效果退额仍未实现,不能仅凭此 target 释放收费。 + +native 创建目标专项已返回 **8 passed in 0.48s**。当前新增 budget 接线 +原型:`prepare_temporary_creation` 先选原候选 ID 并登记双流 tracker, +冻结原 database、allocations 与 owner 上下文;`reserve_temporary_creation` +只允许原尝试调用一次,不接收历史 reservation。成功回执后才进入 reserved, +逻辑拒绝为 refused,其余未知保持 unknown。`release_uncreated_temporary` +同时检查该原成功尝试、指定流的 native 冻结目标与 fenced marker,再按 +原 ID 及完整 unbound reservation 删除原收费行,仍经过物理峰值准入。 +原 ID 已缺失时重复调用不触碰新 slot;已创建但未 bind 的另一流没有 +fenced 凭据,不能借用第一流资格。退款提交失回执仅重试原账务,不重放 +native 操作。相关跨流与未知提交测试待返回,局部评审已发起。 + +此原型尚不完整:未知 reserve 提交目前保留债务,尚未接原事务来源证明 +与只读核对;不能将该状态冒充容量拒绝或生成退款资格。实际 capture 还 +必须保持 prepare/reserve/create 的先后次序与原任务生命周期,不得在 +预留成功前调用 native create。本次接线尚未激活任何 Product 路径。 + +原未创建/删除退额组合已返回 **9 passed in 1.33s**。局部复审发现并修正 +两项 P2:容量拒绝后 native high-water 已前进而 DB 未前进,下一次候选 +供给会停滞;现在候选取两端 high-water 的较大值之后一对,reserve 允许 +安全跳号,要求首序号严格大于 DB high-water、第二为首加一,仍校验固定 +prefix 与 63 位范围,成功后 DB high-water 置为第二序号。这里更新此前 +"恰为 DB 下一对"的合同:要求单调不复用,不要求无空洞。拒绝仍不收取 +配额、不推进 DB;原 owner 不回退、不重用旧 tracker。新测试使用原 +删除/退额释放其他占用后,验证同 owner 可再次预留。 + +另一项修正贯穿原 absolute deadline 至 native 注册及 high-water 读取 +mutex,锁前和取得后复验;超时不登记半对、不消费 native high-water。 +这些修正的专项与 pair 合同回归已发起,源码 Ruff/mypy 已通过。 +未知 reserve 核对仍待实现:仅保存"事务曾 INSERT"的内存 marker 不足, +因为 rollback 后别的 owner 可能成功使用相同候选;不能据此认领后来的行。 + +跳号/同 owner 恢复/锁期限/pair 合同组合返回 **46 passed in 2.40s**,两项 +P2 静态复审关闭。下一增量采用经生命周期设计复核的来源表:Registry +**format 13** 增加 `storage_creation_origins`,allocation ID 为主键并以 +`ON DELETE CASCADE` 引用收费行,origin 为原 attempt 在首次 reserve 前 +生成并保管的 32 hex 随机 ID。两个来源 INSERT 与两个收费 INSERT 和 +high-water 更新处于同一原事务,经过原物理峰值准入;来源行上限 4096, +禁止孤儿及非 temporary 来源,没有永久 tombstone。不自动迁移旧格式。 + +`reconcile_temporary_creation` 持原 attempt 锁,在同一只读事务核对两条 +完整 unbound 收费与原 origin;事务完整退出、deadline 复验后才更新本地 +状态。原提交成功但失回执可恢复 reserved;回滚后其他 origin 用相同候选 +则冲突并保留 unknown;两 ID 都缺失只置 unreserved,不允许再次 reserve, +不生成 native 完成资格。半对、已绑定或错来源仍拒绝。来源记录不是重启 +恢复权限,调用方必须继续保管原 attempt,不能从历史行重建它。 + +该 format13 增量与新测试(含第二来源 INSERT 失败整体回滚、删除级联) +已发起验证及局部复审,尚不能计入此前 46 项通过证据;实际 capture +仍待接入,不能把账户状态核对当作 native 文件生命周期已结算。 + +format13 局部静态复审通过,无新增 P1/P2。Registry/配额/来源组合返回 +64 passed、3 failed(并有三项派生 teardown error),失败均为新增事务内 +OSError 注入测试误期待原异常,实际按既有 native 边界转换成 +`managed_storage_unavailable`;已只修正这三处异常类型断言,定向重跑 +来源模块。尚未取得重跑结果,不把此次失败集合计为通过。 + +为实际 capture 补入 `PrivateManagedDirectory.read_data`:调用前验证 +封存长度不超过 max_bytes/capacity,再持原 fd 以不超过 16 KiB 的 native +读取块读取全文,前后核对原完整 snapshot。原 close 失回执仍沿用既有 +句柄债务,不重新打开路径或收取替代文件。相关空文件/多块/超限预拒绝/ +读取中改写和路径替换测试已发起;native worker 的取消保管与源的并发 +准入仍由下一步实际 capture lease 实现,不能由该同步方法替代。 + +有界读取/创建组合已返回 **16 passed in 0.66s**。新增私有同步组合 +`_capture_native.NativeOutputCapture`,借用原目录和 budget,不拥有共享 +根目录的关闭权:原 pair 预留后逐流创建/绑定,追加使用原 snapshot;容量 +溢出置双流 sticky loss,封存不再交付残缺源。close 将未创建流 fence 并 +精确退额,已创建流保管原 removal/release 完成删除后退额,重复 close +不选择新路径。bind 回执未知时依据原创建身份重复核对绑定;写入效果 +未知仍保留未结清状态,不凭较旧 snapshot 删除退款。 + +该同步 owner 必须由后续 lease 的单个原生 worker 串行调用;本身不承担 +async waiter 取消、任务工厂故障及并发读写准入。其端到端存储测试与局部 +评审已发起,Ruff/mypy 通过,尚未接 Product。先前来源断言修正的测试 +审批超时,已按工具允许一次重试,与该同步 owner 专项一同收取结果。 + +该重试返回 9 passed、1 failed(及一项派生 teardown error):来源模块 +七项通过,同步 owner 的两项普通流程通过;bind 故障注入同样应期待既有 +边界转换后的 unavailable,已修正测试预期。同步 owner 局部复审新增 +两项 P2 已修:helper 在首次 native 登记前通过 retain 回调把原 attempt +交给 capture;登记后核验和 fence 双重失败不再误报 clean。原登记返回 +丢失时,只在原 owner 的有界在途列表按原 binding 找回 pair,不读历史 +记录、不重建 tracker。另保存完整退出删除锁后的 deleted 阶段,之后 +仅退款回执未知的重试不再打开 native lock。对应双重失败、禁止再次 +native lock 的回归已发起,尚待结果及局部复核。异步 lease 未实现。 + +同步 owner 修复专项 **5 passed in 1.22s**,两项 P2 静态复核关闭。 +下一增量 `managed.output_capture.ManagedOutputCapture` 实现异步 lease: +原 loop 内部 Task 保管每个 offload,公共等待使用 shield;有界八项登记, +原 async lock 串行 native 操作。close 同步关闭新读写准入,等待已接纳 +任务后才执行 native close;内部任务被取消不是 native 完成证明,保留 +unknown,不重新调度原操作。逐源只允许一个在途读取,prepare/seal 保管 +原任务供重复等待,成功关闭后释放读结果任务引用。明确容量拒绝仍允许 +命令预览,不再追加保留输出,seal 返回 None。 + +异步双流完整交付及 prepare/read 公共等待取消后的原任务保管测试已发起, +局部复审待结果;源码 Ruff/mypy 通过。该增量尚缺 factory/实际 Product +接线、进一步并发及退出故障验证,不表示 Linux 整体验收通过。 + +异步首轮局部评审未通过:P1 为 to_thread awaiter 不能单独证明 native +完成,P2 为 prepare/seal 等待后可能迟到交付。源码已改为复用现有 +`connection._settled_native` 的 executor publication gate 与独立 callable +receipt,串行锁取得后再查 unknown;prepare/seal 在 await 后、返回或 +发布 source 前再查 closing。该修复尚待提交后丢回执、内部任务取消及 +迟到 waiter 的确定性回归和复审,不得当作 P1 已验收关闭。 + +上述异步故障专项已返回 **7 passed in 2.44s**,生命周期静态复核确认 +本次 P1/P2 修复关闭。下一增量 `ManagedOutputCaptureFactory` 借用同一 +instance 的打开目录与 budget,无 IO 分配原 lease,最多保管八个槽位; +未启动 lease 也必须明确 close 后才可回收槽位。factory.close 先 fence +全部 lease,再逐个等待清理;不关闭借用根目录,不丢弃失败 lease。 +其无 IO 分配/槽位复用专项已发起,源码 Ruff/mypy 通过。真实 Product +仍未接线,安装环境和 SSH 整体验收仍未进行。 + +factory 无 IO 分配与槽位复用专项 **1 passed in 1.21s**。当前继续打通 +显式 Product 接线:`create_coding_managed_attempt` → 共享 hosted 构造 → +`CodingRealHostedSessionFactoryV1` → `create_agent_session` → `AgentSession` +→ Harness AgentProduct,以可选 `ExecCaptureFactory` 参数传递。Coding +构造只引用 Harness 中立 port,不引用 AppHost 的实际存储实现;旧入口 +缺省 None 保持兼容。新增真实 Coding owned Session + 本机 Python 命令 +验证,要求 stdout 进入原 Session blob、临时文件与配额清理后 blob 仍可 +读取。该测试及原 writer shutdown 回归已发起,结果尚待返回。 + +当前这是显式注入接线,尚未在 managed child 默认 composition 创建并 +持有 factory;共享目录/registry 的关闭须晚于所有 Session 及 capture +结算,下一步完成该顶层生命周期后才能称为 lmux 实际激活。 + +真实 Coding Session + 本机命令以及原 writer 关闭回归 **2 passed in +6.70s**。当前新增默认顶层接线:managed process 在 bind 前向 bootstrap +请求原 capture factory;bootstrap 保管已准入实例 scratch 目录,Coding +managed command 注入该 factory,应用及 Session 关闭后再关闭 factory。 +command.cleanup_pending 包含 factory,bootstrap 仅在 child 与 factory +均结算后关闭 scratch;scratch 关闭失败保留 journal、registry 和 observer。 +尚未进入任何 loop 的未用 factory 有显式 close_unstarted 零 IO 清理, +已入 loop 者必须等待原 async close 的 settled 回执。 + +bootstrap/managed process 真入口/managed local 组合已发起验证,顶层 +接线三视角局部复审已发起;源码 Ruff/mypy 通过。此处已改默认构造代码, +但测试与复审结果未收齐,尚不能宣称默认激活验收通过。 + +顶层组合返回 **62 passed、1 failed in 85.34s**;失败为旧测试要求 close +与父类函数 identity 相同,新增 factory 清理使该约束不再适用。保留 +prepare/activate/start 原继承约束,将 close 改为实际行为回归:先完成 +父应用关闭,factory 首次清理失败保留 cleanup_pending,重试同 owner +结算;另补 factory 已构造、bind 前失败的无 loop 早退清理。定向运行待 +结果,生命周期静态复核未发现新增关闭顺序或循环依赖阻断。 + +行为/架构复审发现并修正非 UTF-8 P2:sink 以 `surrogateescape` 对称 +编码后端增量解码的文本,保留原字节而不抛 UnicodeEncodeError 中止命令。 +实际 Coding 命令测试增加双流二进制、容量拒绝和运行中溢出,检查命令 +执行到末尾 marker、指定 exitcode、有限预览及降级无双流残缺 refs,正常 +保留的 blob 在临时清理后按原 bytes 回读。这组测试已发起,尚待结果。 + +真实 Coding 命令四场景返回 **4 passed in 8.24s**,已向行为评审提交 +两项 P2 的闭环复核。两个 capture 模块已加入 hosted-product 与 hosting +精确模块清单,inventory 更新为 schema13 及已接线的命令输出生命周期, +明确一般 trace 和其他 tmp 消费者仍待治理;文档轻量检查通过,两项 +精确架构验证已发起。关闭顺序三项定向测试审批超时,按工具允许重试 +一次,尚未拿到结果,不计为已验证。 + +行为复核已确认非 UTF-8 与真实降级两项 P2 关闭。关闭顺序三项定向 +验证的允许重试也发生审批超时,尚未实际执行,不能继续重复提交同一 +请求或记录为通过。精确架构验证仍在等待原工具句柄。已重新运行 +`make plan-checks`,更新 `.artifacts/check-plan.json`;由于本分支包含 +共享构建/依赖及跨模块改动,最终门禁范围仍广,不能以采集专项替代。 +原文件 owner 基线 45 passed(2.83 秒),文档轻量检查与 diff-check 通过。 + +精确 hosted-product inventory 与 hosting optional-module 验证随后返回 +**2 passed in 16.00s**;仅证明模块清单一致,不替代其余架构门禁。 + +### 默认写入路径复核 + +受管 Hosted catalog 构造 `persist=True` 的 Session;Coding bootstrap +的三处 `_ExplicitTemporaryDirectory` 分支均要求非持久化 Session, +不是该默认路径上的暂存消费者。命令输出注入 capture factory 后绕过 +`SessionOutputPersistingExecService._execute` 的旧 `TemporaryDirectory`。 +真实命令四场景新增 consumer 级禁止旧临时目录分配的断言,尤其约束容量 +拒绝和溢出不得回退到无配额 spool;验证结果仍须单独收集。 + +新增禁止回退断言后的真实命令四场景返回 **4 passed in 8.17s**。 +这证明上述持久化构造与命令执行路径未分配旧暂存目录,不覆盖任意第三方 +插件自行写入文件的行为,也不替代真实安装与 SSH 断连验收。 + +默认 managed process 未接入 CLI 的 startup/session observability context; +Foundation router 默认 debug/trace sink 为 None。现有生命周期日志已单独 +接入,但不能把"默认未启用 trace"计作显式 trace 能力交付。现有通用 +TraceJSONLSink 的单文件轮转也不提供 lmux 命名空间共享额度与期限;后续 +显式 trace 应通过受管 consumer 接入,而非仅设置日志路径或环境变量。 + +### 显式 trace 接线候选(设计复审中,未实现) + +- 配置是部署诊断请求,不进入 Session storage 或 Product identity。 + 必须给出有限持续时间;复用已有服务不得静默声称新请求已生效。 + 启用入口及冻结/复用语义由本次三视角设计复审确定。 +- Foundation 同步 sink 仅投递有限大小、白名单字段的结构化记录;不允许 + 原始异常、提示词、回复、工具正文、环境或认证字段进入队列。队列按条数 + 和字节双限,丢弃仅累加有界计数,禁止递归写诊断。 +- 一个 AppHost 原 owner 持有队列、到期 fence、native worker 及 borrowed + directory/budget;event loop 不执行同步文件 IO。到期/关闭禁止新投递, + 已接纳工作必须按原 worker 结算后才能释放目录与 registry。 +- trace 使用既有两个 10 MiB trace 槽,与普通日志共用 namespace 200 MiB + 额度,重启不清零;未知写入不重放。只读读取和失败降级不得取得写权限。 +- 不能直接把 trace 文件塞进当前 lifecycle logs 目录:现有 writer 严格 + 拒绝其六个已知名称之外的条目。需评审共享目录协议或独立受管子目录, + 保留未知条目拒绝、原目录身份与并发写入互斥,不能放宽为接受任意文件。 + +验收至少包括到期与关闭竞态、队列满、满盘、跨实例共享额度、写入回执丢失、 +关闭重试、字段脱敏、与 lifecycle logs 共存,以及旧启动消息/入口兼容。 + +三视角局部设计复审已返回,按意见冻结以下实现约束(非整目标验收): + +1. 首个入口为 `lmux server start --trace-for `,范围 1-3600, + 不增加环境变量默认值。只适用于本请求实际启动的新实例;既有实例复用 + 明确报告 `trace_not_applied_existing_instance`,不重启、不静默忽略。 + 服务成功与 trace 应用结果独立投影;请求未满足返回非零并说明服务状态。 + 无 trace 请求保留旧 invocation 字节,带请求使用版本化启动消息。 +2. 原启动请求冻结有限截止时间与可信实例关联,锁等待、重查、连接重试均 + 不续期。到期关闭队列准入,也禁止开始新的 native 写入;尚未写入的队列 + 内容丢弃并计数。已开始的 native IO 不抢占,必须收齐原回执。这消解了 + "到期后继续排空"与"到期停止写入"的歧义:入队不是持久化承诺。 +3. 同步入口只读取封闭字段,不调用 record.to_dict/asdict,不遍历任意嵌套 + data/details。单帧、条数、总字节以及唤醒通知均有上限;跨线程最多保留 + 一个待处理通知。复用原诊断执行通道,lifecycle/control 优先,trace 不 + 新建 executor 或平行应用 owner。 +4. 保留同一个原日志目录 owner,将已知名称精确扩展为原六项加 + `trace.lock`、`trace-0.jsonl`、`trace-1.jsonl`;两种格式仅操作自身固定 + 槽,各自核验账本与 inode。仍拒绝任何其他名称,不接受 trace* 通配符。 +5. Foundation 通过公开 runtime context 的显式 sink 注入接缝组合,不让 + AppHost 依赖私有 router;不复用会同步写完整正文的 TraceJSONLSink。 + 原 child 管理在途诊断工作,bootstrap 保管借用目录和 registry。 +6. 关闭先 fence sink,应用停止先行,再结算有限诊断工作和真实 native + 回执,随后关闭目录及账本依赖。普通 trace 失败不得阻断应用 stop; + 未知 IO/close 仍保留原债务,不能把"允许停止"表述为保证安全 clean exit。 + +实现前尚需把上述期限/结果投影接入现有 coordinator 的原启动回执,不能 +仅回显 CLI 参数作为已启用证据。运行中动态启用不在该首个入口内,后续 +若提供需走版本化管理协议,不能借启动参数假装控制既有实例。 + +首个公共接缝已实现:Foundation `observability_runtime_context` 增加 +keyword-only `trace_sink=None`,与 trace_path 在配置变更前互斥;注入时 +不构造文件 sink,正常/异常退出恢复旧配置,不关闭借用 sink。明确这是 +进程级绑定,不能在并发 Session 内用作隔离。源码 Ruff/mypy 通过,架构 +局部复核无新增 P1/P2;包含旧 debug 路径和三项新增行为的定向测试仍待 +原执行句柄结果。该接缝尚未安装到 managed process,不表示 trace 已启用。 + +公共接缝定向验证已返回 **4 passed in 0.30s**。新增 `managed/trace_buffer.py` +作为原诊断 owner 的借用 sink:仅固定 turn aggregate 数值和封闭错误码投影, +单帧 512 bytes、最多 128 条/64 KiB;原 owner 轮询取单帧,不由生产者调度 +回调。生产者非阻塞锁争用直接丢弃,丢弃计数明确为下界;到期不再交付队列 +内容,fence 后已准入帧仅可在期限内取出。该缓冲还未接 native writer/CLI, +不得宣称服务 trace 已生效。源码 Ruff/mypy 检查及局部生命周期复核/测试 +进行中;精确模块 inventory 已纳入该模块,仍待新增后的门禁结果。 + +缓冲局部生命周期静态复核无 P1/P2。已继续落实共存布局:paths 提供九个 +固定名称,lifecycle writer 的有界目录扫描改用该精确集合,不开放通配符, +不读取/修改 trace 格式内容。新增所有九项共存读写及 trace 额外名称拒绝 +回归,原 event-log 测试组合与缓冲测试均仍等待各自执行结果;没有据此 +宣称 native trace writer 已实现。更新了人工 inventory 的 uncomposed 状态。 + +缓冲运行验证返回 **10 passed in 0.40s**。新增同步 `managed/trace_log.py`: +借用原 directory 与 budget,两个固定 10 MiB trace 槽,同 inode 轮转, +重新校验封闭 payload schema、序号及原绑定;写入前复核期限,已准入失败 +封闭当前 writer,不重放、不退款、不接管 unbound 残留。Ruff/mypy 通过; +轮转/共存/期限/配额拒绝/失回执测试与局部生命周期复核已发起,待结果。 +原 lifecycle-log 共存组合审批超时,已按提示重试一次。native writer 已有 +实现但尚未接原 child 诊断工作与 CLI,不算显式 trace 激活交付。 + +写入器局部评审 P2(原 mutex 无界等待)已改为按剩余期限 acquire,取得后 +复验、finally 释放;准入前超时不封闭无债务 writer。真实持锁线程负测与 +其余写入器回归返回 **5 passed in 3.04s**,静态复核关闭该 P2。 +lifecycle-log 共存组合的允许重试再次审批超时,未实际执行,不继续重复提交。 + +原 child 新增可选成对 trace_buffer/trace_write 接线,复用原诊断 task、 +publication gate、Future 与独立诊断执行槽;原循环轮询,不新增 executor +或应用 owner。每条记录后重新考虑 lifecycle 优先级,trace 失败单独停用, +close 先 fence 生产者,应用停止先行,随后结算已有 native 及有限尾部。 +trace-only 配置也显式安排最后一次排空。源码静态检查通过;新增 trace +线程/失败/阻塞关闭场景与旧 child logging 组合、局部复核待结果。bootstrap +生产 composition 和 CLI 仍未安装该可选接线,显式 trace 激活尚未交付。 + +child 接线原组合返回 **14 passed in 6.41s**,不包含随后新增三个关闭边界 +用例。局部评审新增 P2:最终排空任务尚未放行 native 的发布失败不应污染 +正常 close;已改为停用 trace、丢弃队列尾部并继续结算既有 Future/实际回执, +不吞掉在途 native 债务。before/after-schedule task factory 与最后一次 +poll 后入队立即 close 的三项补充验证单独发起,待结果。 + +bootstrap 现有 `prepare_trace` 可选接缝冻结原期限,bind 借出 buffer 与 +写入回调;同一个原日志目录通过共享 once-only admission 供两种格式使用, +任一格式 open 失败后另一格式不得重新 open。每次 trace 写入仍核验原实例、 +attempt 与 native identity,再释放 journal fence 后进日志 IO。bootstrap +close 仍晚于 child 结算,丢弃尚在队列的内容,保留目录失败债务。Ruff/mypy +通过;先 trace/先 lifecycle 共存、期限不可续期、open 失败不跨格式重试三项 +bootstrap 回归已发起。生产 CLI/invocation 尚未接入,不宣称显式启用完成。 + +bootstrap 复核 P2(由目录存在误推 lifecycle 启用)已修复:独立保留显式 +diagnostics bool,bind 仅据此安装 lifecycle callback。包含真实 trace-only +child、无 lifecycle 文件及仅 trace 收费的组合返回 **4 passed in 1.92s**, +静态复核关闭该 P2。最终 drain 三项补充回归首次审批超时,已按提示重试 +一次,尚无结果。 + +启动消息新增闭合 v3 `traceDeadlineMs`(同机单调时钟的绝对毫秒期限), +可与显式 temporaryRoot 同时使用;未请求 trace 时仍编码原 v1/v2 字节。 +严格拒绝 bool/float/空值/缺字段及旧版本夹带 trace 字段;只传冻结期限, +不在反序列化时重新计算时长。当前仅完成值协议与静态检查,兼容性回归 +进行中;starter/coordinator、生产 process 和 CLI 尚未安装该选项。 + +消息兼容性与 trace-only 组合返回 **51 passed in 3.45s**。starter/coordinator +新增可选冻结 trace_deadline_ms,原启动请求仅透传,不在等待或重查时续期; +专用 managed process 在 bind 前准备尚未过期的 buffer,再以公共 runtime +context 安装借用 sink,run_process 结算后恢复配置。无请求路径不安装 sink; +到达时已过期则不启用,不因此重启或延长。当前没有 CLI 生效回执,不能 +把服务 ready 或回显请求当作 trace 已开启。 + +启动参数与原真实 starter 的期限传递回归已发起,静态检查通过。最终 drain +三个补充用例的允许重试再次审批超时,未实际运行,不继续重复提交同一请求。 +CLI 与实际启用/拒绝状态投影仍待实现,整体 goal 未完成。 + +启动链路组合返回 **18 passed in 7.00s**。回执落点经局部架构复核选定为 +原 ManagedServiceJournal/instances 的可选版本化 trace_application fact, +不新增 AppServer RPC、独立状态文件或 Hosting 应用协议。事实需绑定精确 +instance/attempt、原始 traceDeadlineMs 和固定配置版本,并由原 native +identity 与 stop fence 核验后同事务发布;同值幂等,不同值冲突,旧状态 +更新及 predecessor 编码不得丢弃该字段。此 schema 扩展尚未实现。 + +发布晚于真实 sink 安装、child 消费/结算路径绑定,以及 trace 双槽账务和 +文件实际准入;发布前再次验期限。父端只读匹配回执后报告"曾成功应用", +不承诺持续写入成功;到期单独标记,别的 attempt 复用报告本次未应用, +自己的启动缺回执报告 not_confirmed,而非推断确定未生效。 + +为此新增 writer.prepare 真实准入两个收费槽,不生成虚假 trace 事件; +旧 write 与 prepare 共用原 slot 创建逻辑。首次 bind 前失败/提交后丢回执 +补充回归,以及双槽准入后的完整 writer 回归均已发起,待结果。该 prepare +不单独构成 applied 回执,尚未连接发布切点。 + +包含双槽准入、首次 bind 两种失败及轮转的 writer 组合返回 **8 passed in +2.43s**。原实例新增 trace_application 可选事实,Registry 升为 schema14, +与当前状态/只读发现/predecessor 共用严格 codec。首次发布核对 instance、 +attempt、native、stop/abort 与期限,同值幂等、异值冲突;生命周期 replace +保留事实,新实例不继承旧事实。首次写入使用普通 admit_growth,不消耗 +停止/清理专用 control headroom。文档标明旧 schema13 仍拒绝、不自动迁移。 +状态/发现/Registry 与新增事实回归组合已发起,局部架构复核待结果;原 +bootstrap 发布切点和 CLI 读取反馈尚未安装,不据值/存储接缝声称 trace +已获得实际 applied 确证。 + +状态/Registry/发现组合返回 **95 passed in 9.87s**。局部架构复核 P2: +首次发布只在 update 回调内验 trace 期限,后续容量准入/SQL/COMMIT 可能 +越过期限。已改为先只读识别历史同值,首次写事务整体使用 caller/trace +较早截止时间,并在原写 fence 内重新核对实例。历史同值允许到期后重查, +不存在事实时过期不能首次提交。新增容量检查后到期、SQL 保存后到期的 +回滚负测及过期同值重查,定向组合待结果;不使用提交后报错冒充未持久化。 + +回执期限定向组合返回 **13 passed in 1.33s**。现已接实际发布切点:专用 +process 在公共 observability context 内向原 bootstrap 确认同一 buffer 已 +安装;此确认不生成回执。原 child 在 committed 后通过同一诊断执行槽先 +执行 trace initializer,再消费帧;initializer 核验原身份/stop、真实准入 +两个 trace 槽,退出目录锁后经原 journal 发布事实。失败只停 trace,原 +native 债务仍由共享目录/child 结算保管,不占应用控制工作槽。 + +安装确认缺失/存在的真实 bootstrap 测试分别要求无事实/无槽与精确事实/ +双槽收费,联合 child/专用进程回归已发起;Ruff/mypy 通过,局部生命周期 +复核待结果。父端只读分类和 CLI 尚未接入,不把内部发布代码当整目标完成。 + +后续 bootstrap/child/专用进程组合返回 **27 passed in 7.19s**,覆盖实际 +sink 安装确认、双槽准入发布和最终排空故障用例。现父端已接原 journal +只读观察,CLI 新增 `server start --trace-for 1..3600`;原命令准备时冻结 +期限,不续期、不重启复用实例。JSON 独立报告服务就绪与 trace 状态, +显式 trace 未应用/未确认/过期均返回非零。中英文使用说明已补充。 +新增真实 CLI 首启应用、再次请求复用和非法期限回归,执行尚待返回; +新接线 Ruff 与 diff-check 通过,用户语义局部复核进行中。不据此声明 +完整 trace 交付或整目标验收完成。 + +真实 CLI 首轮返回 **12 passed / 1 failed(45.36s)**:首次请求能得到 +service_ready,但 trace 在原 30 秒启动预算内仅为 not_confirmed。这是 +未解决的真实进程集成失败,不能用底层通过覆盖。已补 diagnostics=True +与 trace 同时运行的 bootstrap 场景;与更新后的 CLI 回归组合待返回。 + +体验复核另报 P2:ready 后观察异常会丢失尚未输出的启动事实。现保留 +精确服务/实例 JSON,以 observation_failed 和独立闭集 errorCode 返回1; +非领域异常只输出 unavailable,不泄漏正文、不重启/停止服务。新增过期、 +未确认和观察抛错的六参数真实服务测试;文档注明 deadlineMs 为本机单调 +时钟。Ruff/diff-check 通过,执行与局部复核尚待返回。 + +下一轮组合返回 **24 passed / 1 failed(86.77s)**,包括六种观察结果与 +diagnostics+trace 组合,但真实 CLI 首启仍未确认。失败现场只有 +lifecycle.lock;只读查询确认 storage_allocations 为空、trace_application +为空,定位到诊断早期准入路径,而非双槽创建后的发布阶段。 + +发现生命周期日志在原 child/父端并发观察 Registry 时仍 fail-fast,一次 +busy 会封闭共享诊断消费。现 bootstrap 为单条日志冻结2秒预算,writer +在同一原期限内等待 mutex、文件锁及 lookup/reserve/bind;trace 同样为 +lookup/reserve/bind 与最后 journal 发布使用原期限等待。无事务重放、 +无期限续期,未知副作用仍封闭保债。新增两格式全链路期限传递回归; +局部生命周期复核确认无新增 P1/P2,Ruff/diff-check 通过。组合验证已 +发起但尚待返回,不提前声称真实首启故障已解决。 + +修复后组合返回 **75 passed / 4 failed(111.50s)**。真实 CLI 首次应用、 +复用不续期、六种观察结果以及 trace/budget/journal 组合通过,首次请求 +不再耗尽预算返回 not_confirmed。六个相关源码文件 mypy 通过。 +四项失败均是尚未运行过的外来文件夹具直接调用 append_data:该受管接口 +要求持有锁,且不允许拿 data API 创建 trace.lock。现改为测试自行构造 +权限0600的外来文件,以验证生命周期消费者拒绝/忽略对应名称;没有修改 +生产校验。生命周期专项重新验证中,并纳入新加的 mutex 期限负测。 +这仍不是完整 lmux 真实安装/SSH/性能验收或整目标完成。 + +生命周期专项返回 **33 passed in 5.05s**,覆盖修正后的外来文件夹具和 +mutex 截止负测;原四项夹具失败已关闭。下一项恢复多服务选择工作:新增 +真实双服务、一干净停止、异目录无目标 attach 的验收用例,要求不进入 +选择器、不重新启动任一服务。当前实现多登记项总走选择器,验证待返回; +不能把这项新增测试当作功能已经实现。公共只读探测的所有权/同 loop +连接保管方案正在局部架构复核,仍需真实认证和 Mux 存在性验证。 + +新增双服务验收返回 **1 failed(46.14s)**,失败发生在 stop 步骤,尚未 +到达自动选择断言;不能宣称已得到该选择行为的完整红灯证据。已补原 +command.failure 诊断,保留该验收,不通过跳过 stop 掩盖集成问题。 + +另修复探测前的连接假阴性:允许唯一一次合法 trace 发布跨过认证,其余 +完整状态不变且 revision 恰增加一;原停止/身份/存活检查保持。连接组合 +**26 passed in 4.78s**,局部架构复核通过。多候选公共探测方案见 +[接线计划](lmux-live-probe-plan.md),三视角复核反馈修正中,尚未实现。 + +停止失败的进一步源码对照发现测试缺少父进程回收:该测试本身持有原 +Popen,子进程退出后若不 wait,进程组消失判定仍为否。现沿用既有 +test_stop_and_explicit_restart_restore_mux_without_recreating_it 的模式, +原父进程并发 wait 自己启动的目标,stop CLI 保持原进程组/清理判定。 +未改生产 stopper,也未把 pidfd exited 冒充进程组结算。新用例复验待返回。 + +复验 **1 failed(21.58s)** 已走过真实正常停止,失败确实位于唯一在线 +Mux 仍进入选择器的断言。现新增公共 mux_probe operation 首版:完整快照、 +按服务认证复用、精确 read_mux、unknown 保守分类、最终集合复核、原任务 +取消保管及先清理再返回值。尚未接 CLI,不能据此声明红灯关闭。新增五项 +定向测试等待执行,局部生命周期复核中;初版 mypy 通过,后续值验证仍需 +复查。新模块架构 inventory 和最终完整验收尚未更新/完成。 + +公共 probe 初始五项 **5 passed in 1.68s**,主要使用替身认证,不能替代 +真实验证;局部生命周期静态复核未发现明确 P1/P2。已接 CLI 多候选路径, +专属 Runner 关闭全部探测资源后只带值返回;最终重新认证并比较冻结 Mux +ID。选择器翻页遍历冻结结果,f 显式刷新;pending 取消不新建 main。 +新旧两个源码 mypy 通过,真实双服务回归与冻结选择器专项待返回。后续 +仍需期限耗尽/发布故障/候选变化等回归及完整门禁,不能宣称选择功能已验收。 + +真实双服务验收 **1 passed in 16.87s**:正常停止并由原测试父进程回收 +一个服务后,异目录无目标 attach 自动进入唯一在线 Mux,不打开选择器、 +不重启服务。冻结选择器专项审批发生终止性超时,按规则仅重试一次,尚 +待结果;新增候选增删、首服务耗尽期限、关闭超时保管专项也在验证中。 +两处精确架构模块 inventory 已显式加入 mux_probe,文本 inventory 补公共 +探测并修正 trace 已接线状态;门禁仍待执行。实现的架构/体验局部复审 +已发起,不用单个真实场景替代整体验收。 + +公共 probe 扩展组合 **8 passed in 2.87s**,覆盖候选变化、首服务预算耗尽 +后不准入第二服务、关闭等待超时保管原任务/依赖。该运行早于随后交付 +期限 P2 的修复,不用它证明最新改动。架构复核发现结果交付/rejoin 时 +需复验期限:现过期值仅展示,candidates_unchanged=False 禁止自动选择; +closing 后不交付。体验 P2 的末页 n 误报也已修复并补回归,两项静态 +复核均关闭。新增负测与两处精确架构 inventory 检查已发起。 + +冻结分页/刷新/pending 专项原审批及唯一一次重试均终止性超时,未执行, +仍是缺失证据,不继续重复同一请求;新增末页用例作为新改动单独验证。 + +最新负测及两项精确模块架构检查 **6 passed in 29.11s**,覆盖结果到期 +交付/rejoin、closing 后不交付、末页 n 及新增模块 inventory。已刷新 +make plan-checks:整变更仍要求较广门禁,不能用这六项替代。 + +进入真实安装准备:专用 .artifacts/lmux-installed.6hVazq/venv 已创建, +不修改原开发 venv/用户工具安装。离线 wheel 构建缺 setuptools;两个缓存 +的锁定依赖安装分别缺 joserfc/mypy,尚未构建/安装成功。已申请下载声明 +依赖;构建审批首轮终止性超时后仅重试一次,依赖同步另待执行。空间检查 +为根分区约3.1GiB、/tmp约207MiB,安装产物留在工作区而非扩大tmpfs占用。 + +旧插件聚合架构门禁静态诊断确认 extra58/missing0/changed0,相关四源码 +根均无本轮改动。评审定位其聚合inventory停留PLC8而源码已有后续PLC9B +边界;正在逐组件归属核对,尚未修改该门禁的准入名单或排除目录。 + +### 已实现:同 owner 的不覆盖隔离 + +`PrivateManagedDirectory.isolate_data` 在原稳定锁下验证完整 snapshot, +通过 Linux renameat2/RENAME_NOREPLACE 隔离到 removed- 名称;无原子 +不覆盖能力时拒绝,不降级成存在性检查加 rename。目标已有文件不覆盖。 +rename 后始终用原 fd 核验目标身份、大小、mtime 与尾部内容;目录同步 +后再比较 rename 后的精确 snapshot,包括 ctime,避免交付过期结果。 + +调用方在进入前保管原/隔离名称。未知 rename、同步或关闭会封闭该 owner +的 data 写入;原 close 只结算原同步和 fd,不重放 rename、不删除源路径 +上的 replacement。此接口只隔离,不删除、不生成退款凭据,完整 removal +tracker 与 capture 仍待实现。 + +首轮隔离及原文件回归 53 passed(4.78 秒)。三视角复审发现并修复同步后 +缺少目标复核的 P2;补 fsync 期间目标替换/改写、rename 前源替换负测。 +最终隔离专项 11 passed(0.78 秒),Ruff、源码 mypy、文档轻量检查和 +diff-check 通过。目录隔离成功仍不是删除完成或可退额证明。 + +补强验证:真实 close 原验证 fd 后,立即打开另一个测试文件复用其数字, +再抛失回执。两次 owner.close 均保留 unknown,不再次关闭该 fd;替代文件 +仍可写、隔离原件保持完整。测试自行关闭替代 fd,不修改原 owner 的未知 +债务。生命周期局部复审通过,隔离集合 12 passed(0.71 秒),Ruff 通过。 +这只验证隔离的关闭故障边界,不把 unknown 转为清理完成或退款资格。 + +### 验收续接:安装工件与 PLC9B 聚合清单 + +隔离 wheel 已构建完成,路径为 +`.artifacts/lmux-installed.6hVazq/dist/loushang-0.1.0-py3-none-any.whl`, +SHA256 为 `da82058508cfa922a3e938a3c993953c9b0cf939ddc285eb868ad24c0043f9f2`。 +现有 G17 wheel/source verifier 已验证包模块集合和文件字节一致;包含 +`lmux` 及旧入口。此证据不是安装运行通过:隔离依赖同步的首次审批已 +终止性超时,目前只提交一次重试,未替换用户工具或开发环境。 + +架构评审逐组件核对后,将 PLC9B 遗漏的 58 个 function/operation 项 +(48 个函数、64 次调用)加入静态期望清单,说明见 +[边界清单归属](../harness/plugin/plugin-boundary-sinks-plc9b.md)。不从扫描 +结果生成期望,不排除源码目录,保留精确计数和 synthetic 绕过负测。 +修改后的 Ruff 检查通过;专项 pytest 已提交执行,尚无通过结果。 + +后续安装结果:一次重试后锁定的 40 项依赖同步成功,wheel 已离线安装到 +上述独立 venv。复用 G17 verifier 验证安装来源、SHA256、所有安装包文件 +与 wheel 字节一致;补验 lmux/mux_probe 导入均来自独立 venv。从 `/tmp` +调用安装后的 `lmux --help` 返回成功。未改动用户工具安装。冷/暖服务及 +跨 cwd 重连专项已在该安装环境提交执行,禁止 `src` 进入 pytest pythonpath; +结果未回收前不声称运行验收通过。 + +mux_probe/lmux_command 两源码 mypy 通过;文档轻量检查 6 项通过。 +新增 journal 关闭失败回归:连接已结算时仍保管原 journal,再次 run 只 +重接原失败 task,不重新认证、不交付结果;显式 close 结算后才解除清理 +责任。Ruff 通过,更新后的探测故障矩阵执行结果待回收。 + +探测故障矩阵已完成:`test_managed_mux_probe.py` 共 11 passed(3.10 秒), +包含新增 journal-close 故障、取消重接、超时清理和过期结果禁止自动选择。 +聚合架构专项首次和唯一一次重试均在审批阶段终止性超时,未运行,不能 +记作通过。安装启动/复用/跨 cwd 专项仍待结果。 + +另提交已安装包的 canonical-parent-exit / canonical-parent-crash 两项: +通过真实子进程、精确 pidfd SIGHUP、合成模型闸门验证已接纳任务持续运行 +及同 Session 重连。测试 helper 是测试组合入口,不是远端 SSH 客户端; +不会将其结果称为跨机器 SSH 实测,亦不代替完整 PTY 交互验收。 + +### 交互复核:剩余 M3/M4 必须分开举证 + +原交互评审员只读复核确认:短命令 CLI 场景替换了 +`run_hosted_mux_shell`;现有真实终端场景仍是旧 `loushang-mux` +serve/create/attach。因此,安装 CLI 回归不能替代短命令真实 PTY 验收。 +下一项须复用 `terminal_process_support`,覆盖 `lmux new -s dev`、两个 +Tab、切换草稿、detach 后跨 cwd 重连,以及正常/EOF/取消的终端恢复。 +Markdown/resize 还需真实终端证据,不能只凭替身 client 投影用例通过。 + +M3 仍需按绑定代际投影 available/read-only/unavailable 与原因;切 Tab +和重连不能沿用旧能力或审批资格。当前 Hosted 的固定命令补全、图片禁用 +和 unsupported 分支不等于该矩阵已交付。工具富卡片、diff、用量、Product +命令及图片流程也不能由共享文字 renderer 推断等价;须按原设计逐项实现 +或核实原有明确延期边界,不因当前测试容易通过而自行缩小目标。 + +PLC9B 清单局部复审通过,无 P1/P2;不是整目标评审通过。 + +已安装 wheel 的 canonical-parent-exit / canonical-parent-crash 实测通过: +2 passed(32.37 秒)。两场景都在合成模型已进入时发送精确 SIGHUP, +启动器退出后重新连接同一运行中 Session,放行后只生成一次预期回复。 +这证明上述 Linux 子进程/连接场景,不证明真实 SSH 登录策略或完整 PTY。 + +新增 `tests/coding/test_lmux_terminal_process.py`,复用现有 native PTY driver, +直接调用安装后的短 lmux 入口,覆盖两 Tab/各自草稿、resize、detach 与 +跨 cwd 重连。清理仅对测试私有 namespace 发 graceful stop,不按裸 PID +杀后台服务。Ruff 通过,已提交安装环境执行;尚未取得结果,也尚未覆盖 +真实 Markdown 回复与 EOF/取消终端恢复。 + +边界复核:草案 §6.2 已明确将工具富卡片/diff/图片/全部 Coding 命令等价 +排除于本轮;"完整视图"指共享实现,不是协议能力等价。本轮继续完成 +既定能力投影及代际失效,不新增工具/图片远程协议。安装 CLI 非 PTY +专项第二次审批仍超时,未执行,不再重复同一请求。 + +PTY 首轮实际执行 1 failed(70.02 秒):第二成员已成功创建,footer 为 +`*1 2`,用例却假设自动选择 `*2`;清理 stop 成功后的多行 JSON 又被单值 +decoder 错读。已修正为显式切换第二 Tab、按 JSON 行读取 batch stop。 +没有为满足测试改变产品选择语义。 + +局部复审指出两处假阳性,已补强:草稿断言复用 G18 ANSI→FakeScreen +回放,只检查完整同步帧的当前 viewport、精确 composer 行且排除另一份 +草稿;resize 等待其后的完成重绘。重连前后比较 `status --server` 的非空 +instanceId,而非由不含实例字段的 ls 推断无重启。修订后专项已提交, +结果待回收;原失败不计通过。 + +修订后的真实安装 PTY 专项通过:1 passed(53.76 秒)。短入口直接启动, +两成员真实创建;当前 viewport 的两份完整草稿隔离、resize 后重绘、detach +与另一 cwd 重连通过;前后精确 service 的非空 instanceId 一致,测试私有 +namespace graceful batch stop 成功。此结果不覆盖 Markdown 回复或异常 +终端恢复,也不充当统计性能数据。 + +共享能力投影实施方案见 [能力投影计划](lmux-capability-projection-plan.md)。 +已提交三视角设计评审,架构视角通过,其余待回收;文档轻量检查 6 项通过。 +实施时保留旧绑定未提供矩阵与明确 unavailable 的差别,不以展示矩阵替换 +原有 controller/scope/approval receipt 授权检查。 + +能力设计三视角已完成,修复 approve/deny/details 资格差异,以及同一绑定 +内资格改变不能接受旧投影的问题。选择同步重算,不新增异步 owner/cache。 +首批中性不可变能力值已加入既有 input_policy,封闭 operation/status/reason +集合并检查完整矩阵、重复项及绑定不匹配;值测试已提交。尚未接入 Hosted +与 Embedded 呈现消费者,不能视为该能力功能完成。 + +此前安装 wheel 仍是记录的冻结 SHA256;本次源码新增能力值后,它不再代表 +当前全部源码。正在回收的终端恢复两参数用例只验证该冻结安装,最终源码 +需重新构建并核验。终端恢复新增断言比较打开 PTY 前保存的完整 termios, +以及最后 cursor/paste 模式关闭;空 Ctrl+D 是逻辑退出键,不称作物理 EOF。 + +冻结安装的终端恢复两参数测试通过:2 passed(127.14 秒),含完整 termios +恢复与最后 cursor/paste 模式断言。能力值验证 4 passed(0.87 秒)。 + +当前源码已将同步能力投影接入 Hosted shell、共享 screen state 的可选字段 +和帮助页;资格变化独立于 transcript 缓存刷新,批准/拒绝/详情分别表达。 +新增同绑定内 closing/snapshot/membership/content 变化的实际帮助渲染回归, +以及跨 Tab 快照失效验证,已提交执行。命令补全及 Embedded richer adapter +尚未接线;默认 None 保持原输入能力路径,不能将这一增量标为全矩阵交付。 + +后续源码将 Hosted 补全接入同一当前矩阵:输入分发和渲染前同步检查, +矩阵改变时替换补全 provider 并取消旧候选;切 editor 强制绑定当前 provider。 +详情/拒绝不依赖批准回执,批准仅在当前准确详情展示后进入建议。手工命令 +仍经过原 target/receipt/server 校验。补确定性 complete() 与帮助渲染断言。 +前版四源 mypy 通过;本版 Ruff/diff-check 通过,运行结果仍待回收,已请求 +局部生命周期/交互代码复审。Embedded richer adapter 仍是下一项。 + +前版 Hosted 能力/原 shell 集合 13 passed(1.63 秒)。局部复审另发现 +A→B→A 会复活已关闭详情的旧批准回执,不能以该集合通过宣告安全闭环。 +修复:显式 rebind/refresh 与已接纳 membership 撤销回执;普通 Esc 仅关闭 +详情。资格观察即使详情已关闭也会撤销不匹配回执,poll 后同步观察失效。 +新增真实 Ctrl+B 往返、直接 /approve 不发送以及 snapshot True→False +不恢复资格断言。修复后能力/请求安全集合已提交执行,结果待回收。 + +审批修复后能力/请求集合通过:20 passed(1.39 秒),包含真实快捷键 +A→B→A、snapshot 恢复不复活资格及原请求投递隔离回归。 + +Embedded 中性 prepared-screen 接缝增加可选 capability_provider;共享 +screen 在 render 时同步读取精确不可变值,不从 Product 反射推断能力。 +provider 与当前投影只借用到本次 interaction 结束,正常/失败退出均恢复 +原值;缺省 None 不改变原输入能力。正常/异常退出及原 host 集合已提交 +验证,尚无结果。实际 Coding/Agent composition 的事实供应仍需接线, +不能仅凭新增可选接缝宣告完整 Embedded 适配完成。 + +Prepared host/screen app 两源 mypy 通过。AgentScreenConversationApplicationBinding +增加 keyword-only 可选 capability_provider,原样传入 prepared run,不在 +prepare 阶段读取动态资格,既有位置参数不变;新增透传断言,Ruff 通过。 +实际 Coding 供应矩阵前,正在复核既有 typed operations/审批 surface 的 +资格事实来源,不以对象存在性推断可批准或开放原本未声明的能力。 + +Embedded 接缝及原 prepared host 回归 10 passed(2.30 秒)。实际 Coding +组合现在提供声明投影:输入组及 steer/follow-up 元数据、中断所需 lifecycle +和 queue、标准 clipboard profile 与原 Coding 命令 dispatch。读取元数据 +不调用 operations resolver;绑定 Session 改变时使用新的局部投影代次。 +原本地审批 port 没有资格快照,显式 not_projected,不虚构无 pending 或 +不支持,也不以该值禁用原审批 surface。自定义 clipboard profile 同样不 +猜测支持。新增声明负测与 Agent 透传验证已提交,运行结果待回收。 + +Coding 声明投影/Agent 透传两项通过:2 passed(4.21 秒);Coding/Agent +三源 mypy 通过。生命周期及交互局部代码复审均通过,原审批 ABA P2 已 +闭环。按建议扩展 provider 恢复测试,包含已有非空 provider、正常/异常/ +取消退出,修订集合待执行。当前 Embedded 是声明接线与旧行为兼容,不 +据此声称新增完整能力帮助界面。最新 plan-checks 仍要求广门禁与原生/ +安装平台门禁,不能用这些局部结果替代全目标验收。 + +能力投影增量的架构局部复审也通过;三视角局部均无未关闭 P1/P2,不是 +完整 goal 复审。生成依赖图 --check 成功,与当前源码一致。 + +当前 wheel 构建目录为 `.artifacts/lmux-current.g5DrJs/dist`;离线构建缺 +setuptools,已提交声明构建依赖下载/构建,尚未成功产出。旧 wheel 保留。 +扩展 provider 恢复测试首次审批超时,已仅重试一次,结果待回收。 + +新增真实 Product/IPC/终端 Markdown 回归,复用既有 synthetic model helper +与 G18 屏幕回放,检查当前 viewport 无原始强调/代码围栏标记且包含完整 +回复、idle footer,再 detach/重连验证持久 snapshot 渲染。不使用外部模型, +不等同 managed 短命令启动证明;后者已有单独 PTY 场景。新用例 Ruff 通过, +待更新安装包验证。 + +更新 wheel 已构建并核验源码集合/字节,SHA256 +`e53fb545f3ed09562b8db0f13529647b934c86ef3463bc04d0fde07b1ec08e63`。 +已安装到原任务专用 venv(未改用户工具),G17 安装来源/digest/全部文件 +字节核验通过。新包的 Markdown 与短 lmux PTY/终端恢复集合已提交执行。 +旧 wheel 文件仍保留用于证据对照,原 venv 当前已更新,不能再称为旧包。 +扩展 provider 取消恢复集合两次审批均超时未执行;不记为通过、不继续重复 +同一请求。Coding/G12 规模门禁亦待执行结果。 + +Coding/G12 规模检查结果 1 failed、2 passed(0.48 秒):G12 与精确文件 +分区通过,Coding core 为34242,高于旧34181。与旧已验证 wheel 逐文件 +比较,能力组合仅 mode.py +17、screen_input.py +50,共67行。架构复审 +确认均为 Product 组合责任;单列17+50专项预算,两文件仍计core,原扫描 +与其他阈值不变,新上限34248保留6行余量。修订预算测试已提交验证; +不是按超额61行倒推扩容,也不将原失败记成通过。 + +修订 Coding 预算复验结果为 1 failed、1 passed(0.42 秒):core 检查已 +通过,继续暴露 lmux 专项实计1750、预算1592的差异。尚未复核该158行 +差额的责任归属,不直接扩大预算,也不宣告规模门禁通过。 +当前 wheel 的 Markdown/短命令 PTY 集合首次及唯一重试均因执行审批 +超时未运行,不能记为产品测试失败或通过,不再重复提交同一执行请求。 +用户再次确认完整验收后提交、push、PR、merge并同步本地;发布顺序授权 +不替代尚未完成的安装验收、首次使用性能验证与全目标三视角复审。 + +继续复核规模差额:架构 reviewer 确认 process +23、local +20、bootstrap ++3、parser +4、command +108 均为 capture/trace/probe 的 Product/CLI 组合, +但 command 仍保留无生产调用的旧 `_select`。已删除该29行,旧分页/非法 +输入/取消测试迁移到实际 `_select_probe`,单候选测试也改为拦截实际选择器。 +专项记录经评审净增129行(1592+129=1721),不排除文件或额外增加余量。 +Ruff 与 diff-check 通过;规模/选择器/命令回归已提交,尚未回收执行结果。 +因产品源码删除了旧函数,e53fb545 wheel 现在仅代表该删除前基线,后续 +最终安装证据须重新构建并核验,不宣称它与当前源码逐字节相同。 + +新增 `lmux-performance-acceptance-plan.md`,完成三视角初审并修订:status +只证明登记事实,活体复用另走原公共认证只读查询;成员 ready 须本次操作 +成功及精确身份当前帧;三事实 clean stop 与采集 owner 结算缺一不可。 +正式 managed 指标闭集/比较器分支、长历史种子、可信受管测试 Product +仍是采集前门禁。未将"有效配对"或旧七项 comparison 冒充 V10 已通过。 + +性能首个 managed-mux 九指标候选经架构与交互复核可实施;生命周期指出 +stop终点须由外层采集 owner 物理结算后补记,已修订,等待原 reviewer +确认。补全限定面板候选行而非常驻 footer;累计冷启为连续墙钟不扣中间 +开销。文档轻量检查6项通过。完整首次模型/工具和长历史仍为独立缺口。 + +生命周期 reviewer 已确认 stop 外层结算及累计墙钟修订,首个managed-mux +采集设计三视角局部通过。开始实现纯比较接缝:独立九指标清单与 +compare_managed,原七项 compare_native 保持原清单,复用同一统计实现。 +新增25项独立清单、逐指标回退、失败/缺失/债务拒绝测试;Ruff通过,测试 +待执行。交互局部代码审查通过,不代表真实采集或性能验收。 + +规模/选择器/命令集合结果78 passed、1 failed(202.31秒);规模门禁通过。 +唯一失败为旧pending测试将 `_execute` 模拟持续到后续bare调用,截断新增 +真实probe及其清理回调。已把模拟限定于首次创建,补probe结果/无债/原生 +关闭断言;单项复验待执行,不改变产品清理策略或将原失败记为通过。 + +比较器新旧集合115 passed(4.07秒),仅证明纯统计合同,不是实际性能 +数字。pending名称单项首次审批超时,已提交唯一重试,结果待回收。 +原 G18 probe 新增 managed_completion_frame,真实短命令PTY场景在空 +composer输入/he,要求新完整当前帧有独立/help建议行,再以退格清空, +不提交该文本。新增旧帧、未完成帧、常驻footer、历史清屏误命中的拒绝 +测试;Ruff通过,执行结果待回收。未将该接线宣称为已完成九指标采集。 + +为后续真实安装验证重建当前wheel: +`.artifacts/lmux-current.5V7UFs/dist/loushang-0.1.0-py3-none-any.whl`,SHA256 +`58d140e2d17e6fa4d1d08c4dd93ef12176d49329187a0db257aee192ce741158`。 +原G17源码模块/字节核验和隔离解释器/来源/digest/全部安装字节核验均通过; +任务专用venv更新至该wheel,用户安装未变。包含首次补全的新PTY集合与 +Markdown安装验证已提交执行,结果尚未返回。pending单项复验首次及唯一 +重试均审批超时未执行,不继续重复同一请求,也不记为通过。 + +补全当前帧见证拒绝测试2 passed(5.29秒),证明其拒绝旧帧/未完成帧/ +footer误命中;实际终端首次补全能否呈现仍以安装PTY结果为准。 + +原采集器新增外层 complete_managed_stop 接缝:只在原owner.run_python +成功返回后补停止耗时,输入须为observed/valid=False,stop结果为与认证 +instance匹配的stopped,起点处于原观察器寿命内。拒绝子端预填stop耗时; +取消/原owner失败不走补记分支。新增16项纯转换/原owner失败测试,Ruff +通过,执行结果待回收。该函数不替代来源/活体/成员校验;完整managed +probe与receipt validator尚未启用,原CASES默认集合不变,未开放正式采集。 + +当前58d140e2 wheel的真实PTY集合结果2 passed、1 failed(206.33秒)。 +两项短命令流程通过,包含新首次补全面板、两Tab、草稿切换/resize、不同 +cwd重连、同登记实例、detach/空CtrlD及完整termios恢复。Markdown失败 +是测试错误要求围栏消失;实际输出已含样式标题、正文和高亮代码,而原 +共享renderer._render_code_block明确保留styled围栏。已改为检查原始标题/ +强调标记消除、标题bold/颜色及围栏/代码颜色,保留当前帧与idle要求。 +仅测试修订,产品源码/wheel未变;单项安装复验待执行。原失败不记为通过。 + +外层停止计时接缝及原采集owner成功边界回归21 passed(0.58秒),包含 +原owner失败/取消不补记终点。局部生命周期静态复审无新P1/P2;完整 +managed回执校验与真实九指标采集仍未完成。 + +新增managed值回执校验:四次认证只读观察值固定为first-member、second- +member、detached、reattached,同instance/service/Mux,成员/Session唯一且 +原成员前缀不变;前台spawn为精确new/attach argv与不同cwd。外层补stop后 +才进入完整验证并保存这些观察值。局部复审发现时长可脱离实际查询时序, +已补五项managed_actions起终点与严格时长对应、冷启至最终stop连续顺序; +修复后生命周期复核通过。新增巨大attach时长、late首帧、缺动作、时长 +不符、late detach等拒绝测试;Ruff/diff-check通过,集合执行待回收。 +这里仍是trusted observer回执的结构/关系校验,不自行认证。真实managed +probe及显式case开关未接入,原默认CASES不变,不据合成回执宣称实测。 +Markdown单项首次审批超时,已仅重试一次,结果待回收。 + +时序修订后的managed回执/外层结算/原owner集合47 passed(0.58秒)。 +新增真实managed_read_observation:原namespace/journal只读准入、冻结 +历史Mux ID、原connection lease精确实例认证后read_mux;不attach或夺 +控制器,原lease/native全部结算后返回纯值。接入短命令PTY首/第二成员、 +detach后、重连后四阶段,比较实例/service/Mux/成员前缀身份,Ruff通过; +生命周期局部复审与新增PTY运行尚待完成。未改变产品源码或当前wheel。 +Markdown修订单项首次及唯一重试都审批超时未执行,不继续重复同一请求。 + +只读观察器新增7类故障回归:成功、prepare/read取消、connection.close +超时、journal/namespace关闭失败、Runner启动失败;拦截硬退出但保留原 +Runner供测试finally收口,断言连接未结算时不关闭借用依赖。Ruff通过, +集合执行待回收。当前真实认证短PTY集合正在运行,不能提前记整组通过。 +原probe新增managed_mux九指标真实body,复用现有PTY/当前帧/认证观察/ +精确service停止,失败清理仅私有namespace且不补正常stop指标。产品源码 +与wheel不变;body暂未加入dispatch/collector选项,局部复审与实测待完成。 + +当前wheel的四阶段真实认证短PTY集合2 passed(126.50秒),只读查询未 +夺控制器,成员与实例在detach/跨cwd重连后相同。采集body局部复审指出 +兜底失败覆盖主错、后续观察未即时核对first身份;已保留原异常并另记有界 +cleanup类型,逐次核对first实例/service/Mux与second完整双成员,修复 +后生命周期/交互复核通过。新增7项body失败/替换/第九指标边界负测,待 +结果;read observer故障集合首次审批超时,已仅重试一次。 +已接probe dispatch与collector显式 `--cases managed-mux`,禁止混入旧 +场景/旧诊断模式,默认七项不变;固定槽20对时走独立managed比较器。 +单安装共享observer的单样本链路smoke已提交,仅诊断接线,不是正式 +配对/隔离observer性能证据;结果待回收。产品源码及58d140e2 wheel未变。 + +采集body的7项故障/身份替换/第九指标边界测试通过(6.97秒),仍不替代 +真实链路smoke或正式配对采集。 + +新增9项collector选项/比较路由测试:旧默认不变、managed显式选择、混合 +campaign在source/installation IO前拒绝、完整各走原策略、小样本/非固定 +槽/子集不进入比较;回归集合待执行。统计结果增加描述性均值,使用原 +精确Fraction样本计算,不参与或改变中位数/稳定性/退化判定。Ruff与 +diff-check通过。观察器故障集合首次及唯一重试均审批超时未执行;smoke +首次审批超时,已仅重试一次,结果待回收。不能把未执行计作通过或产品失败。 + +选择/比较回归124 passed(5.21秒),含描述性均值。单安装smoke的唯一 +重试同样审批超时未执行,不再重复同一请求。 +新建任务专用`.artifacts/lmux-paired-envs.dakNYx/{observer,reference-b}`, +均为CPython3.11.15,离线按原uv.lock安装40项dev/runtime依赖和58d140e2 +wheel;两者原G17来源/digest/全部安装字节核验通过。原用户环境未改变。 +正式采集仍需原source_pair的clean Product与不可变Git来源校验;当前lane +dirty,不能用HEAD冒充来源。已请架构reviewer评估独立任务证据快照repo +冻结当前源码/辅助文件并运行原门禁的方案;尚未创建快照、未绕过该检查, +也未将这些环境准备当作正式性能结果。 + +证据快照现已冻结于 `.artifacts/lmux-source-snapshot.GdsdbX/repo`;独立 +证据提交 `f25a4189f955694b692e7dda237747a106f56192`,不是任务分支交付 +提交。原 lane 的 2778 个选定文件(35050213 bytes)复制前后路径、内容 +SHA256、完整权限一致;副本一致,提交后的 Git archive 字节及执行权限 +逐项一致。未改写原 lane HEAD,也未放松原 clean Product 校验。 +从该冻结 repo 离线构建 wheel,原 `verify_wheel_at_commit` 与 clean +Product 检查通过;wheel SHA256 为 +`ea66a3cb4228173383d7c31e12a2f7fb4404f570f55d628a539b3ae48ad7cba0`。 +`origin-before.json` 和 `verified-source.json` 保留原来源、快照 tree、 +辅助文件清单及 setuptools 84.0.0 构建标识。原锁导出的带哈希依赖清单 +`requirements.txt` SHA256 为 +`4ef76f53bd65b228247a08341571eb393937c0b5f1f109aed1cf1b16a17f34c0`。 +快照仍 clean;后续采集必须运行快照内 collector,并重新安装/核验该 +wheel,不能沿用旧 wheel 的安装通过记录。正式交付前必须核对最终源码 +与测量来源差异;相关修改需要重验。尚无真实九指标或正式 A/A 结果。 + +冻结 wheel 已分别安装到任务专用 observer、reference-b 与原 reference-a +venv;三个环境均通过原 G17 隔离解释器的 origins/direct_url/digest/全部 +安装包字节核验。它们当前指向上述 ea66a3cb wheel,不再是 58d140e2; +该安装验证不替代新 wheel 的运行场景结果,也未改变用户 tool 环境。 + +源码回归 `test_lmux_read_observer.py`、`test_lmux_selector.py` 与 +`test_lmux_command.py` 合计 84 passed(230.72 秒),包含此前因审批超时 +未执行的观察器故障覆盖与 pending-name 测试修订。未运行 live/host-runtime +测试;本集合不替代 installed Markdown、首次模型/工具或正式性能验收。 + +首次 Product 使用测试组合已补入性能计划,并经架构/生命周期/交互局部 +复核修订:复用原 request_factory 与真实新准入,不预置 journal;保留 +生产子端存储、capture、diagnostics 和原进程清理链。补充被测解释器/ +固定 child 来源、取消不重发、真实工具效果、原任务清理及下一调用完成 +见证;回复完成须有 turn 完成事实,审批拆待办提示/详情展示/批准后结果。 +此复核只允许继续实现可信测试组合,未冻结正式字段或长历史规模,不是 +完整 M4/goal 评审通过。 + +冻结 repo 的三安装 `managed-mux` 单块单对诊断已启动,报告位于 +`.artifacts/lmux-source-snapshot.GdsdbX/diagnostic-aa/report.json`。 +这是非固定槽小样本,比较必须保持 not-evaluated,尚未完成采集,不据此 +宣称性能提升。新 wheel 的 installed Markdown 单项审批超时未执行, +未将超时算作产品失败;为减少采集噪声,待本次诊断结束后再唯一重试。 + +上述诊断已终止失败:首个 B 侧预热 `lmux new` 输出 lmux_unavailable, +退出1,无首帧指标;原 owner 同样返回失败,保留完整 report/scratch, +comparison 仍 not-evaluated。目录检查发现 workspace 虽为0700,sample +父目录及 workspace lane/.artifacts 为0775;原 managed `_validate_parent` +明确拒绝此祖先,不能通过放宽产品权限解决。collector 现先显式创建 +managed sample root 为0700,避免 `mkdir(parents=True)` 仅对叶子应用 +mode 的陷阱;新增 umask002 下原 owner 启动前检查两级0700的回归。 +Ruff/diff-check通过,回归执行待结果。后续需使用安全磁盘临时父目录 +(如原测试用 /var/tmp 下任务私有根),不能继续将运行状态放在该 lane +树下;安装包与报告仍可留在 workspace。新 helper 尚未重新冻结,旧 +证据提交保留不改写;此失败不产生任何有效性能值。 + +目录修复经生命周期局部复核通过:仅新 sample 目录受0700保障, +exist_ok 不会修复已有目录,必须换安全的新 scratch,不能 chmod 旧树 +或放松生产准入。修订后的 collector 和回归已追加冻结为证据提交 +`0baba3bc7db39bdf8aaf918bf2aea6e72de16680`(非 lane 交付提交)。 +旧失败对应 f25a418 提交保持可追溯。原 ea66a3cb wheel 在新提交的 +`verify_wheel_at_commit` / clean Product 检查通过,完整 helper manifest +与当前 lane 一致;证据保存在 `verified-source-private-samples.json`。 +产品未变,无需重建 wheel;不得将旧失败样本改记为成功。 + +目录修复及 managed 停止/回执回归 43 passed(0.63秒),包含 umask002 +新增覆盖。安全 `/var/tmp` 任务根创建首次审批超时,已唯一重试,尚待 +结果;没有开始第二次采集。首次 Product 子端组合经架构进一步复核, +选择固定测试进程内 partial 原构造名称并调用生产 main,保留原 launch/ +capture 和异常清理;明确禁止直接导入含顶层 install() 的旧 hosted +fixture。该小节设计已同步,实际组合与端点回归仍待实现。 + +安全磁盘临时根 `/var/tmp/loushang-lmux-measure.aUibmj` 已由 mktemp 创建; +第二次单块单对诊断使用该根与0baba3bc冻结 helper,报告目标为 +`diagnostic-private-aa/report.json`,已提交执行,尚无结果。未覆盖旧失败。 +新增无导入副作用的 `_lmux_synthetic_product.py`:固定模型与真实 +AuthorizedExecution工具定义,仅handler发工具效果见证,hold使用原流 +producer/task及finally结算见证;见证不是授权或native清理权威。 +三项局部回归验证请求不执行、模型回显不算效果、producer取消结算; +Ruff/diff-check通过,执行与局部复审待结果。尚未接专用child、有界独立 +见证sink或真实审批链路。新fixture不在现有0baba3bc快照中,不参与本次 +短入口诊断;首次Product采集前须另行冻结其完整来源。 + +fixture局部复审发现可插拔 task factory 在调度后抛错可使producer失去 +收养句柄;已改为内置 asyncio.Task 显式保管再同步 attach_task,不经过 +外部factory。hold测试扩为无factory/调度前抛错/调度后抛错三种条件, +断言factory不被调用,并保留测试故障兜底取消。共五项fixture用例, +Ruff通过,运行仍待结果;尚未宣称P2运行验证完成。 + +第二次诊断已终止失败:安全目录修复后首屏、补全、两Tab、detach和 +跨cwd重连均经过真实PTY,四次认证的实例/Mux/成员一致,取得八项 +局部时长;最终 stop 输出 preview 后 local_operation_failed,因此全部 +样本仍无效,comparison not-evaluated。只读数据库显示 +process_exited=1/application_cleanup_completed=1/process_scope_settled=0。 +生命周期复核定位旧 `_guarded` 包装器为 subreaper,却只在 operation +结束后的 finally 才回收 adopted leader;operation 内 stop 正在等待其 +进程组消失,形成循环。日志 stopped 只代表应用侧,不是完整三事实。 +拟保留原包装器,在 stop 等待窗口用原精确pidfd回收已收养leader, +不 waitpid(-1)、不抢PTY/Popen状态、不写journal,不将强制回收算成功; +具体实现、非零退出/其他Popen隔离回归及重新冻结均尚待完成。 + +fixture新增 run_product:仅测试进程构造名称 partial 原类并调用原 +production main,finally恢复;增加三项原launch/capture传参、失败和 +中断恢复覆盖,当前共八项。首次审批超时未执行,已唯一重试;Ruff与 +diff-check通过。此处mock main测试不证明真实服务构造失败清理。 + +新增测试侧 `_lmux_adopted_process.AdoptedLeader`:原 observer.reopen +绑定精确pidfd,收养前后检查存活和PPid;仅借用原锁/句柄执行 +waitid(P_PIDFD,WEXITED|WNOHANG),只接受该leader正常0退出,缓存失败 +不能重试成成功。不发信号、不写journal、不抢其他Popen状态。两项真实 +子进程测试验证0/7退出和另一Popen的23退出码;Ruff/diff-check通过, +执行及局部复核待结果。尚未接stop等待窗口,也未重新冻结/采集。 +原失败进程组4037344的沙箱外只读ps检查已完成,无匹配进程;这是失败 +包装器结束后的现状,不补成先前正常stop成功证据。 +synthetic Product八项回归唯一重试仍审批超时未执行,不继续重复相同请求。 + +AdoptedLeader局部复核指出关闭未知债务及测试清理跳过后续资源两项P2; +已保留close-unknown、拒绝重试假成功,构造失败清理保留primary;测试 +finally独立尝试owner/两个Popen/stdin清理,新增相应负测。首次执行 +结果4 skipped(0.65秒),不是通过:当前standalone CPython缺少 +os.P_PIDFD(也缺少pidfd_open,后者原Hosting已有libc适配)。已核对本机 +Linux UAPI头文件P_PIDFD=3;测试helper仍用原os.waitid精确pidfd等待, +仅为缺失的常量名称选Linux ABI值,不退化P_PID或扫描。移除Linux上 +该名称缺失的skip,内核不支持须失败。新四项回归已提交,结果待回收; +stop接线和完整采集仍未完成。 + +精确回收四项回归4 passed(0.62秒),确认当前standalone Python可执行 +Linux P_PIDFD等待。随后已接入probe:reattached认证后读取同实例native +identity,原connection/native清理完成才交付;持有AdoptedLeader,在 +stop前台Popen的communicate轮询间履行精确wait职责,仍要求原stopper +返回精确stopped。关闭pidfd与前台管道失败均保留失败,不信号服务。 +局部复核指出sys.exception可能误取外层旧异常、原生read不应阻塞事件 +循环;已改本次显式primary,并通过原_settled_native/原deadline/ +wait_for_lock=True读取和结算。新增外层except关闭故障、缺失/替换身份 +及工作线程断言,静态检查通过,接线集合运行待结果。 +精确回收测试另加stop前台等待原进程组消失的正常流程、管道关闭失败 +负测;先前4 passed不覆盖这些后加断言。新helper仍未冻结,不重跑旧 +快照来冒充本次修复验收。 + +上述接线集合17 passed(5.72秒),包括外层异常下的leader.close失败 +传播及native身份缺失/替换/工作线程读取。前次两项P2经生命周期复核 +确认关闭,无新局部阻断。完整当前helpers已追加冻结为证据提交 +`7b5a5a44ee0c8985a97e5ddfc20c65a985456b0e`;与lane完整helper manifest +一致,原ea66a3cb wheel对该提交的来源/clean Product核验通过,记录于 +`verified-source-adopted-wait.json`。这仍不是任务分支交付提交。 +新增真实stop/group等待及外层异常下管道关闭回归已单独提交执行,尚待 +结果;未开始第三次采集。既有安全scratch父目录仍为0700,后续采集器 +在其下创建全新样本根,不复用失败样本的运行状态。 + +新增停止等待集合5 passed(0.80秒),覆盖原进程组消失等待与管道关闭 +失败不被外层异常掩盖。第三次诊断已提交,使用7b5a5a44冻结版本,报告 +目标 `diagnostic-adopted-aa/report.json`;结果待回收,仍为非固定槽小样本。 +新 `_lmux_product_child.py` 提供固定测试request factory(只替换argv入口, +保留原invocation/session-root/descriptor/cwd/env/streams),子端核验被测 +安装来源与预建私有观察目录,再调用固定run_product。复用原BoundaryTrace +类而不调用其install,避免修改生产类。五项child局部测试及八项synthetic +依赖测试已提交,尚待结果;Ruff/diff-check通过。未接真实首次Product +父端准入或正式指标;这些新文件不在本次短入口冻结快照中。 + +第三次诊断已完整成功退出0:`diagnostic-adopted-aa/report.json` 为 +complete-record-only,2预热+1对诊断样本全为valid,九项指标齐全、精确 +实例stop与原外层owner结算成功,helpers_before/after一致。非固定槽、 +仅一对,comparison按原规则not-evaluated;耗时波动明显,不声明改善 +百分比、稳定性或正式性能通过。前两次失败证据未删改。 + +首次Product父端按架构复核进一步收敛为 `_lmux_product_entry.py`:只接受 +固定new -s perf,校验安装来源,暂换固定child request factory并调用 +原lmux.main,finally恢复,不复制准入/认证/清理。新增五项参数/来源/ +失败/中断恢复局部回归。此前child与synthetic集合审批超时未执行;结合 +新父端的18项依赖集合已唯一重试,待结果。父端仍未在真实PTY验收, +新文件尚未冻结,不与上述短入口诊断结果混用。 + +固定Product父/子/模型组合18项回归通过(4.53秒)。首次回复终点经 +交互局部复核:保留PTY的用户可见完成帧,显式detach后再由原控制器 +attach/snapshot核验正式记录与非running,不为观察抢占活动终端。 +当前协议snapshot_session需要attachment/generation,不能当匿名只读 +观察口。计划已补nonce、错误/unknown拒绝、后验不倒填时间与不宣称 +native结算的边界。synthetic fixture新增唯一回复和delayed完整文本但 +无final的负例,静态检查通过,新增回归待执行;18项旧通过不覆盖本次 +增补。真实首次Product PTY驱动、审批和长历史仍未完成。 + +唯一回复与delayed负例增补后的synthetic集合10 passed(4.30秒)。新增 +当前完整帧判定:唯一回复、空输入、精确目标Tab和正常idle状态须同帧 +出现;运行、审批、错误、未知、旧回复及composer回显不计完成。11项 +帧回归已提交执行,结果待回收;Ruff与diff-check通过。这不替代真实 +PTY或detach后的认证snapshot核验。完整验收后再按已授权顺序提交、 +推送、PR、合并及同步本地分支,当前尚未发布。 + +新增测试侧 `_lmux_product_snapshot.confirm_reply`:调用方须先完成终端 +detach并持有原认证connection;使用公共attach/snapshot核验精确Mux、 +唯一member/Session、FirstUse标题、非running和恰好一条预期正式回复, +拒绝ERROR记录,finally释放此次attachment。借用connection的原owner +仍负责异常/超时后清理;不新增匿名snapshot接口,不回填PTY时间戳。 +八项局部负测已提交执行(5615),生命周期局部复核已请求;Ruff与 +diff-check通过。完成帧集合5606仍在等待审批,未重启。两组均不能 +代替尚未接入的真实受管Product首次使用场景。 + +完成帧集合11 passed(7.06秒)。snapshot局部复核发现deadline P2: +asyncio.timeout(0)不能阻止无挂起点方法同步完成。已改有限期限校验、 +操作工厂延迟构造、派发前及返回后复验绝对期限,增加过期/NaN/inf与 +迟到snapshot负测。生命周期复核确认该P2关闭;外层失attach回执或 +取消后的原connection关闭接线仍待实现和验收,不算关闭。5615仍待 +审批,文件已增至12项,最终以实际collection结果为准。已安装Markdown +PTY在原ea66a3cb安装上提交唯一重试5620,尚无运行结果;不声明渲染 +验收通过。静态Ruff与diff-check通过。 + +5615首次审批超时未执行,当前12项snapshot集合已唯一重试5622。 +新增 `managed_reply_observation` 接入原采集器connection owner:原只读 +入口委托同一私有body且不传verify,行为不变;新入口仅在终端已结算 +后使用,精确比较既有instance/service/mux/member目标后调用公共 +attach/snapshot确认。异常仍由原connection finally close及原process-only +runner/pending结算;没有第二套生命周期。read-observer新增正常核验、 +失attach回执、snapshot取消三项路径,共12项提交5626待结果;局部复核 +已请求。真实PTY调用端仍待接入,不能把mock故障覆盖当成端到端证明。 +Ruff及diff-check通过;5620已安装Markdown与5622尚在审批等待。 + +5620已安装Markdown集合实际通过:1 passed(24.93秒),包括真实样式 +渲染、detach后snapshot显示与原服务退出;使用ea66a3cb隔离安装,不是 +新受管首次Product场景证明。5622唯一重试仍审批超时未执行,不重复 +该12项请求。5626读观察接线集合仍待结果;局部生命周期评审确认 +原connection/pending路径无新增阻断,模拟attach-lost不替代真实回执丢失。 + +新增 `_lmux_product_probe.first_reply`:固定安装入口启动真实PTY,唯一 +nonce回复由完整当前帧计时,显式detach并离开原terminal owner上下文 +后才进行公共记录核验;原身份/native owner精确stop后交付场景记录。 +复用原观察器与精确回收helper,没有新增采集框架或生产配置开关。 +当前仅静态Ruff通过,局部评审待结果;尚未接原guarded入口、冻结或 +实际执行,也未加入正式性能比较。审批、延迟结束负例及长历史场景 +仍未完成。后续必须保留来源校验及外层物理结算,不单独调用来冒充验收。 + +首次回复驱动局部生命周期复核无阻断;按建议新增snapshot失败、 +leader.close失败、stop失回执及fallback失败测试(连同正常路径共5项)。 +fallback失败类型写入有界字段,防止外层仅序列化主异常时遗漏note; +不写异常敏感详情。probe主入口增加诊断case managed-product-first-reply, +沿用原measured_entries和_guarded,不自行发布valid=True;正式协调器 +尚不选择或比较此case,仍待严格receipt校验及冻结。5626首次审批超时 +未执行,与新增驱动5项合并唯一重试5636(预计17项)待结果。Ruff通过。 + +本轮14个helper/test差异已冻结到独立证据仓库提交 +`46bb4ab50e798633eaee5ad2b6cb833cca8f479a`。原require_clean_product与 +verify_wheel_at_commit通过,仍匹配ea66a3cb wheel、1392个包文件, +完整helper manifest与当前lane一致;此提交不是lane交付提交。首次 +回复诊断尚未运行,5636仍待结果。下一步使用该冻结入口和原外层 +evidence owner,在安全私有runtime下运行;未增加正式有效性声明。 + +5636集合实际17 passed(5.18秒):原连接观察器12项、首次回复编排 +5项,覆盖失attach回执/取消清理及失败不发布成功。冻结46bb4ab的 +真实首次回复诊断已提交5646,复用原owner.run_python及probe _guarded, +新建安全runtime与独立报告目录,执行前重验wheel与helper清单,执行后 +保留原始receipt和helper清单。当前等待审批/启动;即使正常退出仍仅 +标记owner-settled-diagnostic、valid=False,不跳过后续严格验收。 + +继续轮询5646确认仍为同一活跃请求,未重复启动。等待期间已运行 +make plan-checks,更新.artifacts/check-plan.json;当前跨包源码与 +pyproject改动仍选择广泛门禁,部分新tests/dev路径为未分类,不擅自 +缩减检查范围。make check-docs-light实际6项通过;这不是源码门禁或 +首次回复验收。正式跨包门禁及完整三视角复审仍未完成。 + +当前全部312个变更/新增Python文件Ruff通过。5646已获准实际运行, +原进程session3459仍活跃,报告为 +`.artifacts/lmux-source-snapshot.GdsdbX/diagnostic-first-reply-z6fhwaaf/report.json`, +runtime为`/var/tmp/loushang-lmux-measure.aUibmj/first-reply-vlur4rls`。 +已观察到原native receipt的running状态及正确隔离安装来源;尚无终态, +不重启、不过早判定服务失败或完成。 + +3459实际失败退出1,helper前后相同。PTY尾部已出现本次唯一回复与 +idle/*1,但状态为`submit: request_acknowledged; cwd / user_home; /help`, +原精确完成帧漏掉该正常文案导致40秒误超时。仅测试predicate新增这个 +精确idle组合,并保持running/pending/unknown/failed拒绝;12项回归5668 +待结果,不把原失败样本改判成功。 +fallback普通stop随后超时;只读registry显示实例 +f6a4e844b7a2ad6c1e1c5cc3246aa8b5三事实为1/1/0,native PID4054573, +当前ps无该PID或原终端4054552。与此前正常stop已修复的subreaper回收 +等待环路一致,失败分支尚未接精确回收;需补齐后再冻结重跑。原报告、 +清理失败字段和外层leftovers失败保留,不据当前进程消失声称原停止通过。 + +完成帧修复集合12 passed(4.88秒)。失败清理已按局部评审方向改为 +首次认证read冻结native identity;终端上下文退出后,正常/失败路径 +共用一次exact_stop/AdoptedLeader,stop_attempted在构造前置位,原owner +已消费或close-unknown时不重新reopen。认证前失败/收养前原leader已 +退出交外层原owner结算,不放宽身份准入,不重新选实例。普通阻塞 +subprocess stop fallback已移除;清理失败有界字段保留首因,正常路径 +再次比对冻结身份。八项正常/故障回归5678及局部复核待结果;Ruff与 +diff-check通过。新修复尚未冻结或再次真实运行,旧失败证据保留。 + +失败清理修复局部复审通过,无新增阻断。四个变更helper/test已冻结为 +证据提交`2104c81ab2062473b34be9233df4893186d7acbc`,原wheel来源、 +clean Product与完整helper一致性重新核验通过;仍使用ea66a3cb安装, +未更改生产包。修复版真实诊断5683已提交,使用新建私有样本/报告目录, +等待审批/启动;八项故障回归5678仍在等待,不重复请求。没有把上一轮 +屏幕回复或当前冻结校验当成完整成功证据。 + +失败清理八项实际8 passed(5.45秒)。Hosting Makefile所列源码及适配层 +类型检查通过(31 source files),使用--no-incremental和/dev/null缓存, +未扩大mypy缓存。5683审批超时未执行,冻结2104c81真实诊断唯一重试 +5691已提交,尚待结果;仍不把测试耗时当性能指标。 + +按Makefile typecheck-apphost原源码范围执行静态类型检查,通过137个 +source files;同样使用--no-incremental与/dev/null缓存。5691仍是同一 +等待请求,尚无启动/终态结果;暂不叠加其他重型检查,保持诊断噪声较低。 + +5691已实际运行(session45209)并失败退出1,报告 +`diagnostic-first-reply-cxmrcu8v`,runtime `first-reply-qyew5iq9`。 +失败发生在初始perf界面前:前台退出1,输出operation_unavailable; +后台同实例c61da1350c6ac1647b69245a293fc218有starting/ready日志及 +fixed_product_selected见证。尚未到首次认证读取,不能选择新实例做 +fallback;原外层报告leftovers失败。helpers前后一致。不能把ready日志 +当完整启动成功,也不能据此断言超时根因;下一步定位前台创建/连接 +失败边界,前次回复完成帧与失败回收修复尚未获真实成功样本证明。 + +原失败registry只读检查:instance rev3/committed、stop_requested=0, +perf reservation及对应create permit已有,但created_instance_id与 +mux_space_id均NULL。范围缩小到受管create RPC阶段,尚无完整创建事实。 +AppServer _execute_managed将内部Exception统一映射OPERATION_UNAVAILABLE, +AppService的binding.prepare/check_creation也隐藏内部原因;现有记录 +不能区分权限准备、acquire、校验或commit失败,不能直接断言期限原因。 +已请求局部评审最小测试侧有界admission诊断代理,要求只委托原owner/ +权限/时限,不新增重试、不输出authority或异常文本,诊断不纳入perf。 +此观测尚未实现;不通过重新启动旧样本猜测根因。 + +局部评审认可固定child构造点的admission代理方向,强调预建代理再 +取得原owner、每次close原样委托、日志失败不得破坏原生命周期。 +新增测试侧 `_lmux_admission_diagnostic`:只替换binding.prepare,原 +字段/closing不变;代理逐次委托prepare/acquire/check/close,记录固定 +阶段及白名单错误类型/code,无消息/路径/authority。sink失败禁用后续 +诊断而不替换原异常或丢失owner;缺失阶段不能当成功证据。12项日志 +故障/原异常/取消回归5721待执行,Ruff通过。尚未接固定child或冻结, +不影响当前2104c81快照;诊断同步IO会扰动耗时,必须排除性能样本。 + +诊断已接固定测试入口:专用managed-product-admission-diagnostic case +向固定父/子脚本传固定标志,子脚本移除标志后仍交原三个production +arguments;原Product构造点仅替换launch.managed_mux.prepare,保留其余 +事实及output capture。普通first-reply默认不启用代理,报告另标 +admission-proxy-not-for-performance。父/子参数与恢复矩阵已扩充,连同 +synthetic基础回归5727待执行;代理集合5721仍待结果。Ruff通过,尚未 +冻结或实际运行诊断,新构造代理还需补集成断言与局部复核。 + +代理12项实际通过(0.55秒),包括sink写失败、取消、原异常与每次close +委托。新增构造集成三项:仅替换binding.prepare,保留launch其他事实和 +capture identity,原异常/中断不被替换且恢复原构造入口。父子/模型组合 +5727仍待结果,最终以实际collection为准;局部实现复核已请求。Ruff与 +diff-check通过,尚未将本轮诊断接线冻结或运行到真实服务。 + +诊断接线局部复核通过;父子/模型组合实际31 passed(8.32秒),包括 +新增三项构造保留/异常恢复。十个helper/test差异已冻结证据提交 +`40c9dd36dfeddfec78fbe1cea58325abc176ab06`,原wheel来源/clean Product/ +完整helper一致性校验通过。专用admission诊断5734已提交,独立新建 +runtime与报告目录,仍用原owner.run_python/_guarded,固定标为 +admission-proxy-not-for-performance。当前待审批/启动,不宣称原因已 +定位;阶段缺失只能视作诊断不完整,不能据此判定commit失败。 + +5734实际运行(session99119)后失败,报告diagnostic-admission-0pf_p_pp, +runtime admission-diagnostic-0wj82gy8。本次prepare/acquire/check/close +阶段均有return,continuity中已存在perf及FirstUse成员;前次创建错误 +未复现,不能据此判定已解决。本次已通过可见回复和终端退出,失败 +发生在后验managed_reply_observation,原runner将内部异常折成 +local_operation_failed,外层只收到一般RuntimeError。测试observer现 +保留原operation_failure作显式cause,原清理与失败判定不变;尚未验证 +该增补或重跑。不把fixed_product_returned=0或已有创建记录当样本成功。 +生成包依赖文档的--check检查实际退出0。 + +后验核验发现测试混淆了两个标题合同:Mux 成员标题为 FirstUse, +Coding Product 未设置 session_name 时快照标题默认为 Coding。 +现将 FirstUse 断言移至成员,快照仍严格验证完整 Session identity、 +唯一正式 assistant 回复、无 ERROR、running=False;没有放宽完成判定。 +新增错误成员标题负例,观察器测试也使用独立的 Coding 快照标题, +并验证 attach 响应丢失与 native identity 错误保留原始 cause。 +局部 Ruff 通过;快照与观察器回归执行请求 5760 尚待结果。 +尚未冻结这些 helper 或完成真实重跑,不能据此认定此前失败已解决。 + +UX 原评审视角完成本次标题修正的局部只读复核:协议与 Product 源码 +确认两种标题独立,未发现新增 P1/P2;身份、正式回复及清理核验均 +保留。评审未运行测试,不替代请求 5760 的实际结果或全目标复审。 + +5760 实际启动 session80342,终态退出0:25 passed in 5.76s。 +四个 helper/test 差异冻结为独立证据提交 +`82fd3b66e191b61dcae5974d528b50331c9db19f`,helper 全量一致, +原 ea66a3cb wheel 的源码/清洁 Product 校验通过;不是 lane 交付提交。 +普通 managed-product-first-reply 真实诊断请求 5773 已提交,沿用 +原 owner/_guarded 与新私有 runtime,不启用 admission 代理。 +目前尚待结果,报告保持 valid=False,不当正式配对性能验收。 + +继续验收时复核 5773,工具仍报告 running,未重新启动诊断。 +PLC9B 架构 inventory 定向检查另提交请求 5779,尚无执行结果。 +这两项均不得计为通过;首次 Product 的延迟完成负例、真实审批/工具、 +中断再调用、长历史,以及正式配对和全目标三视角复审仍未完成。 + +5773 实际运行 session6613 并退出0,报告 +`diagnostic-first-reply-title-bxng36an/report.json`,runtime +`first-reply-title-prh7prwp`。原 owner 返回 owner-settled-diagnostic, +helper 前后一致;receipt 的正式回复后验确认成功,同实例 +`0d7fa22cba3a43cdf0d6416b9a1333ea` 精确 stop 返回 stopped。 +可见回复 1.3374 秒,固定测试父端 spawn 至可见回复累计 16.8381 秒。 +该单次普通诊断未启用 admission 代理;保留 valid=False,既不是正式 +配对比较,也不说明此前间歇性 create 错误已经根治。快照标题修正后 +首次回复/后验/正常 stop/外层物理结算闭环已有实际成功证据。 +5779 审批终止性超时,测试未运行,不能写作架构门禁通过。 +成功样本 registry 经 mode=ro 只读核验:上述同一 instance 的 +process_exited/application_cleanup_completed/process_scope_settled 均为1, +与原 stop receipt 及外层 owner 成功返回一致。 + +新增尚待验证的 managed-product-delayed-final 测试 case:沿用原固定 +Product 的 delayed nonce(完整 delta 后不发送 done)、原 PTY/owner/ +精确 stop。当前帧必须呈现本轮全文与 FirstUse running,采样时若 +reply_completed 提前为真即失败。detach 后借原 public attach/snapshot +确认同身份仍 running、唯一 delayed nonce user 且没有正式 assistant; +随后 detach,stop 前后核对同实例同 call 的 producer_started/settled。 +生成任务观察文件有大小/条数/顺序边界,不作为服务权限或停止凭据; +仍以原 stop 与外层物理结算决定通过。负例不发布首次回复性能指标。 +当前 Ruff 通过,回归请求5799已提交,三视角局部复核已请求;尚未 +冻结 helper、未实际运行负例,也未纳入正式比较或声明验收成功。 + +三视角局部静态复核中,生命周期视角发现验收 P2:首个正确 running +帧之后至终端退出之间可能出现迟到错误 completed 帧。已修复为 PTY +context 完全结算后扫描输入后的所有完整帧,任何 completed 都失败; +原始输出前缀不匹配则拒绝截断窗口。新增 running→completed→running +及截断负测,生命周期复核确认关闭;架构/UX 局部无其他 P1/P2。 +增补 pending observer 返回 pendingConfirmed 而非 replyConfirmed 的回归。 +最新 Ruff 通过,5799 仍待实际结果;不以替身测试或局部评审代替真实 +安装负例与全目标复审。 + +5799 实际运行 session83648,退出0:73 passed in 8.26s,包含本轮 +迟到完成帧与 pending observer 增补。八个 helper/test 差异冻结为 +`6c00cfd79fe409f4d5069a97f31683603c7ef0b8`,与 lane 全量 helper +一致且原 wheel 来源校验通过。真实 delayed-final 请求5808已提交, +仅使用该冻结版本和新私有 runtime;尚待结果,不发布性能指标。 +最新 make plan-checks 实际退出0,仍选择广泛门禁以及 Actions 的平台/ +安装/真实 LSP 检查;本轮定向73项不能替代这些交付要求。 + +5808 复核仍为 running,保持冻结 helper 不变。并行执行 Makefile 原 +typecheck-harnesstui 的完整源码范围(原 follow-imports=silent,额外 +no-incremental 与 cache-dir=/dev/null,使用现有 uv 环境),session66622 +尚待结果。该静态检查不是首次工具/中断的真实验收,也不影响正式性能 +结论;本次 delayed-final 本身不采集性能指标。 + +Harnesstui 类型检查 session66622 终态退出0:160 source files 无问题。 +5808 已启动真实诊断 session90548,当前仍运行,未重启该样本。 + +5808/session90548 最终退出1,报告 diagnostic-delayed-final-c7k0t1rz, +runtime delayed-final-fd7og57u。失败为 running frame predicate 超时: +原始终端尾帧已包含完整 nonce、Hosted FirstUse running、perf *1~, +状态为 submit: request_pending; cwd / user_home; /help。此状态未被 +streaming 白名单接受;不是观察到了 completed,也未走到后验快照。 +已仅为 streaming 见证增补该精确 running+pending 状态,completed 的 +pending 拒绝保持不变;新增 idle pending/unknown/failed 拒绝测试。 +Ruff 通过,5826定向回归待结果、UX局部复核已请求;未重新标记旧样本, +未冻结该修正或运行新负例,原外层 leftovers 失败仍保留。 + +UX局部复核确认 running+pending 增补未弱化完成判定。该两文件修正已 +冻结独立证据提交 `6c22acac49b53b4bc1322796126869806c99f611`,冻结时 +helper 与 lane 一致、原 wheel 来源校验通过。5826 回归仍待结果;新 +独立安装诊断请求5830已提交,使用该精确冻结源码,不修改旧失败样本。 +本次 coordinator 仅消费冻结 repo(其前后 helper 摘要仍核验),不从 +后续可编辑的 lane 导入 Product/helper;证据冻结不是功能交付或验收。 + +5826 实际启动 session54216,终态退出0:20 passed in 8.86s。 +5830 仍待执行结果,未重复启动。中断后再调用的后续核验需注意: +Agent 流式 message_start/update 只更新 streaming_message,message_end +才进入正式消息列表;被中断的运行可留下空的 aborted assistant, +因此不能直接复用"新 Session 恰一 assistant"的完成断言。 + +5830 审批终止性超时未执行,仅重试一次为5838,仍使用冻结6c22aca, +不改其 repo。并行在 lane 实现下一增量的快照合同:中断后新 nonce B +必须与 A 不同,完整记录顺序严格为 delayed A user、一个空 assistant、 +reply B user、唯一 B assistant;仍核验完整身份、running=False 和 detach。 +仅接受 Agent 当前固定 abort 路径的一个空记录,不把它自身当中断原因 +证明;原 producer 结算与真实中断/下一轮完成接线仍待实施。新增九项 +顺序/重复/空记录/身份/运行态负测,Ruff通过,5840回归待结果。 + +5838 实际运行 session86152,退出0。报告 +`diagnostic-delayed-pending-y1zd5757/report.json`,runtime +`delayed-pending-vtzuvam4`,冻结6c22aca helper前后一致。完整流出文字 +未触发 completed,detach 后同实例/会话仍 running 且没有正式回复, +同 producer 于 stop 后 settled;原 owner 成功返回。registry mode=ro +核验实例 `1d9cbec0bfc53f217df24e6ddc6ef156` 的 process_exited、 +application_cleanup_completed、process_scope_settled 均为1。本项真实 +负例已有成功证据,仍标 valid=False(不是正式配对性能采集)。 + +lane 已接显式 managed-product-interrupt-next-turn case:复用原 delayed +与精确 stop,elsewhere 重连后核对身份/native、单次 Ctrl+C,再确认 +idle及原 producer settled,单次提交不同nonce B,完整回复后 detach +再核验精确四条记录。原终端/连接/stop owner不变,不发布fresh首次 +回复指标;工具子进程/输出捕获的中断清理不在此模型负例的声明范围。 +架构与UX局部通过;生命周期发现重连可能先取消任务的因果P2,已在 +身份核验后、Ctrl+C紧前复核 producer 尚未settled,并要求既有trace +settled monotonic_ns不早于此次中断动作。补提前结算与时间边界负测, +局部复核待返回。5846接线组合仍待结果;5840审批超时未运行,仅重试 +一次为5850。最新Ruff通过,尚未冻结或真实运行中断case。 + +5846 实际运行 session57577,终态退出0:74 passed in 7.68s,包含 +中断因果补强后的接线、当前帧与原观察器回归。5850严格快照测试仍待 +结果,生命周期P2最终复核亦待返回;不据此宣称真实中断已验收。 + +生命周期静态复核确认中断因果P2关闭。七个helper/test差异冻结为 +`528734d70226caac404d21d4572a571f5450e47a`,冻结时lane/helper一致, +原wheel来源核验通过;真实中断诊断请求5858已提交,仍待结果。 +5840及唯一重试5850均审批终止性超时,严格快照测试未执行;不再重复 +同一请求,仍记作缺失证据。该冻结与诊断提交不等于快照回归通过。 +Makefile原typecheck-harness完整范围使用现有uv、no-incremental、 +cache-dir=/dev/null实际运行session33468并退出0:694 source files +无问题;原follow-imports=silent不变,不替代运行时验收或全量门禁。 + +5858 已启动真实中断诊断 session33880,目前仍运行,冻结528734d不变。 +lane 并行准备首次工具验收:复用有界固定Product trace读取,新增工具 +effect见证只接受真实handler写出的同实例 lmux-call-1,审批前计数0、 +成功后恰1;回显、重复、错误call/instance与提前效果均拒绝。新增六项 +负测,Ruff通过,5866回归待结果。尚未接真实审批界面或声明工具验收。 + +5858/session33880 最终退出1,报告 diagnostic-interrupt-next-turn-kzbxmolz, +runtime interrupt-next-turn-sxxayuz7。初始delayed/detach后验已完成, +重连当前帧有FirstUse running与perf *1~,notice为原shell.py明确提供的 +"running; earlier partial output is not in the v1 snapshot"。测试白名单 +遗漏该合法提示而超时,尚未发送Ctrl+C。仅target_state_visible的running +分支增补精确提示,仍拒绝错目标/idle、不当完成(负测包含完整nonce)。 +UX局部复核通过,Ruff通过,5871回归待结果;旧失败与leftovers结论保留, +修正未冻结,真实中断尚未通过。 + +四个helper/test差异(重连提示及共用有界trace/tool效果检查)冻结为 +`ee36b9494c24fa7f08564448e79230ca7b271677`,lane一致性与原wheel +来源校验通过。新真实中断诊断请求5875已提交,独立新目录、原owner, +尚待结果。5866实际运行session29382,退出0:44 passed in 8.02s, +覆盖工具效果见证和共享producer/清理编排;5871重连帧回归仍待结果。 +本证据提交不是lane功能提交,工具真实审批与中断实际成功仍未证明。 + +5888/session3240 已退出0:141 passed in 6.65s,覆盖工具审批接线、 +当前完整帧、严格快照及原只读观察器,含最新审批因果与详情目标负测。 +审批紧前记录 monotonic_ns,完成及 exact stop 后均核验真实工具效果 +恰好一次且不早于批准;详情帧必须同时呈现正确 Mux/活动 Tab footer。 +生命周期、架构、UX 三个局部复核均确认对应 P2 关闭,Ruff通过;不等于 +完整目标复审或真实工具调用已验收。审批到回复的时间包含工具后的模型 +回复及显示,不解释为纯工具执行时间。 + +5871、5875 及真实中断诊断的唯一重试5889均审批终止性超时、未启动。 +其中重连帧回归已由上述141项覆盖;真实中断成功仍缺证据,不重复提交 +相同请求。七个工具审批helper/test冻结为 +`4d858444c3e495a0435f3c640f71703cdf08f5ea`,冻结时helper一致、Product +和原wheel来源校验通过。新工具审批诊断5899已提交,使用固定本地模型、 +独立私有目录和原owner,结果待确认;该证据提交仍不是lane功能提交。 + +5899 实际启动 session74027,最终退出1。报告 +`diagnostic-tool-approval-3yg52yh2/report.json`,runtime +`tool-approval-_fpcw4w3`。已进入同目标审批界面,但当前状态行同时带 +`submit: request_pending; Approval pending: F2 details; /approve /deny`, +footer为`perf | *1! | /help /detach`。采集器只允许无submit前缀,故 +等待超时,尚未发送批准;原失败及leftovers分类保留,不计验收通过。 +仅审批待处理帧增加精确submit pending/ack前缀,仍要求正确目标和审批 +footer,未知/失败回执不接受,完成帧规则不变。新增四项组合负测,Ruff +通过,5923回归及UX局部复核待结果,尚未冻结新修复。 + +UX局部复核已通过该实际帧修正:审批待办与submit回执可并存,但不扩大 +完成条件。5923回归仍待结果;修复后真实工具验收尚未运行。 + +5923/session88898 实际退出1:36 passed、1 failed。新增ack状态行超过 +固定100列夹具,不能作为完整单行展示证据;修正为仅接纳实际观察到的 +pending组合,无前缀正常审批帧继续支持,超宽ack负例保持拒绝。该修正 +回归5940待结果;不以放宽到截断文本或子串匹配来掩盖失败。 + +拒绝工具快照新增独立入口,仅接受固定真实策略错误回显 +`Tool lmux_evidence requires approval`,严格核对同会话、idle、原user、 +空tool-call assistant及唯一最终回显;成功与拒绝回显不能互换,错误 +期望在attach前拒绝。该消息来自原policy engine、原异常转tool result +和固定模型回显,不修改Product。新增十项快照分支/输入回归5928待结果, +Ruff通过;仅为后验记录校验,真实拒绝UI接线及停服后零效果检查仍待实现。 + +lane已接`managed-product-tool-denial`独立诊断:固定新Session、原ask +策略,等待同目标审批帧后单次`/deny`,不打开详情、不发送approve;仅在 +拒绝回显且idle后detach,原后验快照/身份检查/exact stop完成后再核对 +实际handler效果为0。拒绝回显不是零执行证明,仍依赖有界真实效果trace; +不发布成功工具耗时。新增拒绝帧及五项交互故障测试,并扩展八项原停服 +失败编排;Ruff通过,生命周期局部评审与5946接线/快照/观察器回归待结果。 +5928单独快照请求审批终止性超时、未运行;5946覆盖新增接线后的快照。 +5940帧回归仍待结果。未冻结上述拒绝接线,未宣称真实拒绝验收通过。 + +5940/session40658已退出0:38 passed in 11.15s,含最新拒绝帧负测。 +生命周期评审发现拒绝因果P2仍开放:若`/deny`丢失而pending因其他原因 +结束,相同policy回显与零效果不足以证明本次拒绝生效。须补精确interaction +的原DENY接纳回执见证及丢弃deny负测,再冻结/真实运行拒绝case。原停服 +与最终零效果顺序未发现新增阻断;5946回归仍待结果,不能代替此P2关闭。 + +拒绝因果修复已接固定父进程的显式`--interaction-diagnostic`:仅包装原 +RemoteAppClientV1.respond_interaction,一次调用原方法,收到原AckV1才写 +accepted,退出恢复原方法;独立私有有界trace,不改Product。原AppService +要求session port返回True才允许Ack,否则报operation_unavailable。停服后 +要求sent/accepted精确两条、同attachment/generation/member/interaction、 +DENY及不早于本次按键的时间下界,再核对零效果。没有回执或回显相同均不 +足以通过。新增八项回执/丢失deny负测,Ruff通过,5956回归及生命周期复核 +待结果。5946已审批终止性超时、未执行;严格拒绝快照运行证据仍缺失。 +该观察为诊断开销,不用于宣称未经插桩的首次工具性能。 + +生命周期复核确认DENY因果P2关闭:原RPC仅一次、原服务实际接纳才有 +Ack、精确请求配对及本次时间下界、stop后回执和零效果共同验证。5956 +回归仍待结果。新增诊断入口成功/异常/中断恢复测试,5962待结果;5946 +超时请求的唯一重试5958(接线/快照/只读观察器)亦待结果。上述等待均 +不计通过。最新make plan-checks退出0,因跨包源代码、pyproject及未分类 +测试仍选广泛门禁,Actions平台/安装/真实LSP检查也仍需执行;未缩减交付 +范围,不因本地窄回归而宣称全门禁已完成。 + +5956/session96115退出0:81 passed in 8.54s,覆盖原DENY回执观察与工具 +接线、失败清理。当前全部改动/新增Python文件Ruff通过,git diff --check +通过。5958唯一重试也审批终止性超时,严格拒绝快照仍未实测;5962入口 +恢复测试与5967当前架构门禁仍待结果。 + +11个helper/test冻结为`3ee561e91717a3e4773a9ac6510236091a915be1`, +冻结时helper一致、Product及原wheel来源验证通过。冻结仅固定输入,不 +代表所有回归通过。新真实审批诊断5971已提交,验证实际pending帧修正, +使用原批准case、不启用拒绝RPC观察器,新私有目录/原owner,结果待确认。 +本次仍为证据快照提交而非lane功能交付。 + +5962入口恢复与5967架构门禁均审批终止性超时、未启动;各自唯一重试 +为5974与5973。5971真实审批诊断原调用仍待返回,不重复启动。已启动 +完整目标的架构、生命周期/安全、UX/覆盖三视角只读代码复审,覆盖当前 +Product与设计边界,不限于最新采集helper。复审不能替代尚缺的真实 +运行、长历史、正式性能及广泛门禁证据,当前仍不可交付。 + +完整三视角复审不通过,当前合并去重问题为:P1默认Embedded未启用同库 +writer、P1公开delete仍绕过保留型维护owner、P1进程启动后的辅助task发布 +失败可跳过清理;P2 owned import准入前staging、P2失败创建保名无精确继续 +入口、P2审批晚失败污染其他Tab。均需修复,不以旧局部评审覆盖。 + +已为真实Embedded bootstrap补同库restore/rename/delete busy及释放后恢复 +回归(5981待结果),并在Linux新建应用runtime组合点启用原owned factory、 +注入原platform state/session-stores;非Linux与低层借用构造默认不变, +不增加unowned fallback。Ruff通过。此接线尚不能关闭公开delete/import +问题,也不能替代真实两进程默认入口验收。生命周期worker负责原exec后端 +task发布P1及窄回归,与本入口修复文件分离。 + +5973架构及5974入口的唯一重试也审批终止性超时,均未执行。5971已实际 +启动session42599,仍使用冻结3ee561e和原wheel;它只验证该冻结诊断, +不代表最新Embedded产品改动已经安装验收,最终须重建冻结并补验。 + +5971/session42599随后退出0,报告`diagnostic-tool-pending-4x93cm1n/report.json`, +helpers_unchanged=True、owner-settled-diagnostic。首次待审批1.1439s、详情 +0.1513s、批准到工具后最终回复0.3480s;同instance/member/session后验确认, +真实handler lmux-call-1恰一次且不早于批准,exact stop返回stopped,原owner +结算。单次固定模型诊断仍valid=False,不代表正式性能配对或最新Product +通过,也不覆盖拒绝/工具子进程中断。 + +exec任务发布P1实施完成:仅captured路径全部进程辅助任务采用可信Task, +普通路径保留原task factory;原缺陷四项确定性失败,修复后94项相关回归、 +23项原Local回归通过,Ruff/mypy通过,独立架构复核待返回。 + +5981默认Embedded回归审批超时未执行;新增平台选择/无目录创建检查, +5997实际bootstrap/owned集合待结果。公开delete入口新增窄 +TranscriptDeletionOwner参数,复用原retained factory.delete_transcript; +Linux未传owner在任何路径访问前拒绝,Product默认delete前置同样处理, +不再先读缓存或按pathname回收附件。ApplicationSessionManager坚持原factory。 +这是Linux底层公开API兼容收紧:调用方须持有并结算owned维护factory, +正常应用runtime沿既有override;非Linux暂保持旧行为。六项前置拒绝/原错误 +保留测试6004待结果,旧公开API调用点和测试仍需迁移、实际busy验证仍需补齐。 +owned import尚未修复,须沿原prepared restore candidate直接消费冻结源意图, +不能先staging再恢复。UX worker另负责审批晚失败代际门控。 + +5997/session17333退出1:16 passed、1 failed、80 deselected。新同库夹具 +将platform state放入会话root.parent下,违反已有state/data互斥规则,实际 +先报invalid而非busy;改夹具为data/sessions与platform/state兄弟域,未放宽 +Product校验。6008复验真实默认入口及平台选择,尚待结果。 + +审批晚失败P2已修:原control owner内捕获回复,完整目标/interaction内容和 +请求代际匹配才发布错误,Tab ABA/refresh撤销旧交付。78项相邻及32项补强 +窄回归通过,Ruff/mypy/diff-check通过;独立复核仍需安排。 +公开delete新增真实owned factory竞争、释放后删除及附件保留两项测试, +6004仍待结果;独立生命周期复核已请求。import由架构worker实施,保留主侧 +delete更改。尚未迁移完旧无owner删除测试,不能宣称全门禁通过。 + +6004及6008审批超时未执行,唯一重试分别6016、6019待结果。公开删除 +边界文档已写明Linux无owner即使missing也拒绝、调用方保管原factory清理 +债务及附件保留语义;原SessionManager稳定锁删除测试开始显式owner迁移, +Ruff通过,尚未执行此迁移测试,其余旧调用迁移未完成。 +审批代际修复通过独立生命周期复核。创建恢复P2按显式create-status与 +create --continue原operation设计推进:只读查询不启动/重放,继续不更换 +ID、不自动attach,历史unknown仍保守;UX worker负责实现和窄验证。 + +旧SessionManager删除相关测试进一步迁移:Linux显式持有并结算原owned +维护factory,保留锁/索引断言;附件删除预期调整为可恢复保留,重复authority +的删除应拒绝(该预期待实测核验),非Linux保留原语义。6026针对性回归 +已提交,Ruff通过,不以跳过Linux测试替代迁移。6019唯一重试也审批超时、 +未启动;Embedded同库修复仍缺新夹具的运行结果。6016删除集合原调用待返回。 +owned import修复已请求独立生命周期复核,实现agent继续扩展原prepared/ +外部恢复及冻结指纹、失败保管测试;尚不计完整import验收通过。 + +6016/session34059退出0:8 passed in 2.50s,公开删除的前置owner要求、 +原错误保留、真实busy/释放后删除/附件保留已有运行证据。Harness目录与 +生命周期旧删除调用也迁移到测试范围显式retained owner,6032回归待结果。 +6026审批终止性超时未执行,Coding删除迁移仍缺运行结果。 + +UX测试确认并发pytest共享临时根可在执行中被其他运行清理;后续每次命令 +使用独立mktemp --basetemp,不修改Product超时、不重启已有live handle。 +6032使用独立根。owned import复核未发现新增生命周期阻断,但JSONL正文 +81MiB与ZIP正文64MiB边界须明确,构造失败债务新测试尚有失败待实现agent +核对;创建恢复已开始独立生命周期复核,真实启动间歇失败仍保留为未闭环。 + +6026未运行请求的唯一重试6034使用独立basetemp提交;6032仍待结果。 +默认bootstrap/SessionManager及共享delete相关五源mypy session70090仍运行, +不计通过。当前内存available约338MiB、swap使用约3474MiB、根分区余2.1GiB; +暂停增加大型并发测试,已有运行不重启,不以压力指标解释startup_failed。 + +导入兼容合集session96159已退出:107 passed/10 failed。两项新增夹具未 +materialize源正文,实施agent已修夹具待复验;其余包括五项默认目录/index +行为、一个discovery源替换、两个import兼容/缺cwd案例。原失败保留:目录 +索引由主侧跟进,导入agent处理源绑定与cwd准入前校验,不能只调整旧预期。 +创建恢复独立生命周期静态复核未发现新P1/P2,建议追加确认后更晚ABA及 +旧代permit无回执换代后继续拒绝两负测,UX已接手。 + +五源mypy session70090退出0。6032在pytest启动前因外加--basetemp被原 +run_pytest包装器拒绝(exit4),不计测试失败或通过;包装器本身已有带租约 +独立scratch,修正命令6042不再外加basetemp。直接pytest才需显式独立根。 +6034审批超时未执行,6040仍待返回(也带错误basetemp,须等终态后修正)。 + +创建恢复新增28项分批通过、独立复核及两晚边界负测通过;兼容3例现场已 +分开:两例child提交前startup_failed,一例COMMITTED后许可持久化前失败, +三者无mux_authorities,三事实0/1/0。既有stopped日志不足以证明结算,需 +利用原异常/测试probe补诊断,不能统一归因内存或创建RPC。 + +owned import额外8项窄回归通过,缺cwd前置和源替换已覆盖。但prepared +abort保留可见已导入会话不满足旧取消语义,此项仍开放:拟在原writer +preparation/Graph disposer内对本次已确认create执行精确撤销,未知结果 +保留双lease/附件债务,绝不pathname unlink或重开factory。原prepared +abort过早closed也须联修,取消只rejoin原task。设计已请求独立生命周期 +评审,尚未实施该撤销,不把已有import测试当完整P2关闭。 + +续验:6040 handle 已不存在,不据此声称测试执行。6042返回原session68992, +该session已退出:目录/生命周期28 passed、2 failed。两例墓碑测试在 +writer准入处报unsafe;夹具canonical/compatibility目录创建改为显式0700, +保留产品权限校验及墓碑断言,窄复验6048待回收。五项目录/index复现6046 +已去掉外置basetemp,使用原包装器独立scratch,待取结果。 + +导入撤销独立复审确认还须联修consume异常路径的无条件closed:未交付 +且rollback未结算须保留同owner重试入口;已成为current后不得因activate +或after_commit异常撤销正文。精确key/revision/op、确认delete receipt后 +才rollback附件、取消只退出等待等约束继续有效,尚未实施或宣称关闭。 + +6046返回session96392,已退出:五项均失败,82 deselected,41.61s。 +前三项并非索引查询失败,而是在第二个同父目录store创建时被 +TranscriptStoreAdmission._reject_residue拦截:第一个store的共享附件目录 +被误判为新store残留。需兼顾独立store共存与缺失已知store不得重建,不可 +直接移除残留校验。第四项dispose后索引preview仍为first而不是hi;第五项 +旧低层无owner删除被拒。第五项测试改Linux owned runtime并finally关闭, +静态检查通过,尚待运行。前四项产品问题未修复。 + +新增prepared abort失败重试及取消等待者重接清理两项确定性回归;6055 +待执行结果,未据静态预判记为红测。6048审批超时未执行,唯一重试6056 +待返回。prepared abort/consume产品实现仍未改动。 + +后续6056唯一重试亦审批超时未执行,墓碑复验不可记通过。6055仍待返回; +其提交后产品abort已修改,故无论后续结果如何不能称为旧版本红测。 +PreparedSessionLifecycleOperation.abort现持有原候选清理task,以shield +隔离等待者取消,失败保留aborting以便重试,成功才closed;并发close +重接同task,异常回收不丢失保留task的结果。Ruff通过,运行验证待回收。 +consume异常路径及导入精确删除撤销未修改,此基础修复不代表P2全关闭。 + +6055审批超时未执行。consume后续已联修:给原协调器的候选rollback +接回prepared持有的abort task,未交付失败保留aborting;通过原slot +赋值后的activate入口、先于产品hook单调标记delivered,不根据异常后 +瞬时current推测撤销资格。新增替换失败且cleanup首错重试、activate及 +after_commit失败不撤销回归。完整tests/harness/session/test_lifecycle.py +session68641退出0:22 passed/0.68s,Ruff通过。旧mypy session90989退出0, +覆盖abort基础版本,不当作后续consume修改的类型证据。导入原Store精确 +删除、附件rollback及receipt未知保留双lease仍未实现,本P2仍开放。 + +当前consume版本mypy session25143已退出0。迁移后的rename/delete索引 +回归session72681退出0:1 passed、86 deselected/6.88s。真实cold new[main] +session44269亦确认退出0:1 passed/16.04s;不据本次通过解释历史3项 +间歇失败或宣称全部门禁通过。其余restart与name-reuse窄复验session67232 +已启动待回收。原import实施agent续接精确撤销,生命周期评审agent只读 +复核prepared任务与transition重入边界,主侧保留lifecycle.py接线所有权。 + +剩余两项启动复验session67232退出0:2 passed/37.19s,历史间歇原因 +未归因。新增同步mark_candidate_delivered接线先于Product activate,普通 +及prepared路径两例覆盖,session76075退出0:24 passed。独立生命周期 +复核指出consume持transition锁等待新abort task会使重入锁的disposer +死锁;改为锁内保留consuming责任,锁外consume except转aborting并join。 +不能在failure observer之前标记aborting,否则observer.close会复现同类 +死锁。前一版本session47419为25 passed,最终增加observer.close参数 +后26例待回收;不得将前版本通过当最终验证。import原Store撤销仍由 +实施agent推进,未宣称完整关闭。 + +session73896最终26例退出0:26 passed/1.02s,Ruff通过。进一步修正 +legacy prepare_import_file rollback:原disposer未成功时不再finally删除 +staged文件;已成功的disposer阶段不因后续文件cleanup重试而重复。 +新增源不变、第一次失败暂存文件仍在、第二次close成功后删除的回归。 +session28061退出0:完整生命周期27 passed/0.66s,Ruff通过。此项只覆盖 +prepared legacy取消链,普通import异常路径及owned精确撤销不借此宣称关闭。 + +默认Embedded互斥及owned runtime文件session34925已确认退出0:12 passed/ +15.39s,覆盖同应用writer持有期间restore/rename/delete busy及释放后恢复。 +SessionManager删除子集session11605为7 passed/1 failed/47 deselected: +唯一失败是legacy夹具data/sessions隐式权限不安全。将两级目录显式0700, +并将用例命名改为platform blob cleanup policy以反映Linux保留附件语义; +窄复验session93196退出0:1 passed/54 deselected/2.63s,Ruff通过。 +以上是分批证据,不代表全部Coding或最终安装验收通过。owned import +agent仍在补唯一create operation与同ID异路径防领养校验,未提交。 + +最新lifecycle类型检查session44869退出0。make plan-checks已生成 +.artifacts/check-plan.json:公共包、pyproject及未分类测试触发广泛门禁, +不以近期窄回归代替最终跨包/安装验收。原check_docs.py --plan执行成功, +6项文档治理检查通过。此为当前快照证据,后续相关改动须按影响重验。 + +sibling准入独立复核:v1仅证明A root与parent inode,不足以区分B从未 +存在和B正文+witness均丢失,两者可能具有相同可见附件/兄弟根状态。 +故不能靠任一合法sibling witness放行,也不能仅移除_reject_residue。 +最小候选是独立parent/family持久准入记录,intent先于创建,记录所有 +曾准入root key及附件域绑定;迁移已有未知family需明确授权,不能把 +create_if_missing误当freshness证明。该扩展尚在设计,未实施/验收。 + +owned import原prep/实际Graph取消与正常delivery首集合16 passed,证据 +/tmp/lmux-import-retract-check.bHmB8A/results.xml。实施继续补原FileStore +最终unlink的inode绑定:只增加file-specific可选expected_file_identity, +不扩中性Store协议、不改变普通delete默认。须覆盖tombstone fsync期间 +replacement ABA,外层stat不足以证明精确撤销。仍不宣称P2全部关闭。 + +shared data-root候选已写入contract末尾并按独立复核补充:fresh有界 +检查范围、family/attachment未知阻断整个family、member未知仅阻自身、 +enrolled witness禁止丢ledger后fallback v1及全证据丢失的灾难边界。 +普通fresh new自动完成初始化,不新增用户setup步骤;仍未批准激活。 +6项文档治理检查再次通过。跨包架构session57736已实际运行且出现F, +仍无终态/完整报告;持续poll原session,不重启或记通过。 + +owned import撤销实现窄集合已退出:29 passed/45.97s,XML +/tmp/lmux-import-retract-final.785Sy7/results.xml已核实tests29/errors0/failures0。 +覆盖原prep撤销、真实Graph import/abort/delivery和原inode/墓碑fsync期间 +替换防误删;实施agent报告8源mypy/Ruff通过。生命周期独立复核已启动, +冻结revision冲突及旧delete兼容仍在追加,不能仅据该集合关闭整个P2。 +架构session57736仍持续产出进度,当前已见至少两处F,等待完整报告。 + +独立生命周期复核确认prepared锁互等及legacy暂存清理顺序已关闭;补充 +stale previous+observer.close+disposer重入锁回归session6037退出0: +1 passed/27 deselected/1.07s,Ruff通过。owned撤销仍有创建身份窗口P2: +UOW.create返回后stat可收养replacement;须从原native发布持有身份凭据, +并保留原inode见证避免纯dev/ino数值复用。实施agent负责原_rooted_io/ +FileStore窄接线,不新owner或扩中性协议,尚未验证完成。 +架构session57736现进度超过33%,出现多项F,仍继续poll原进程等完整报告。 + +创建身份窗口原负测已确认1 failed,XML +/tmp/lmux-import-native-receipt-red.grWACk/results.xml核实tests1/failures1。 +原port单一publication witness实现已落地:仅armed import,原临时fd +发布前dup进原账本,原native成功后才提供精确key/op收据;不再后置 +pathname stat铸造创建证明。发布失败、unlink后inode仍pin、重复写不增pin、 +close_unknown不重关负测随agent session46446运行中,尚无终态。 +3源mypy/Ruff由实施agent报告通过,独立生命周期复核已续接。 +架构session57736已超过55%,持续产出进度但尚未返回完整失败报告。 + +原发布pin版集合已结束:36 passed/36.55s,XML +/tmp/lmux-import-pinned-receipt.eGD2KA/results.xml已核实36/0/0。 +独立生命周期复核确认后置stat收养replacement及inode复用P2可关闭: +原临时fd在发布前保管、成功才确认、原key/op新写入限定、原port最后 +关闭pin且未知关闭保留债务、不重关。未发现新增P1/P2;复核为只读。 +新增native_return窗口及实际Session pin-close债务、原IO/FileStore兼容 +集合由agent session36113继续运行,最终整体import兼容尚未全确认。 +架构57736仍存活且持续输出,未出现完整终态报告,不重启。 + +原publication pin及FileStore兼容集合已完成:131 passed/52.46s, +/tmp/lmux-import-ledger-compat.HknukL/results.xml核实131/0/0。包含原 +rooted IO、Store conformance/settlement、本rollback文件16例(不另加到 +131)、原删除/root-binding回归。结合独立生命周期无新P1/P2,创建身份 +窗口局部闭环;不代表完整lmux集成与性能门禁通过。实施agent现转为只读 +架构视角补审shared data-root candidate,尚不激活或改变legacy注册策略。 +原架构session57736已超过77%,仍有此前失败待完整报告。 + +架构session57736已退出1:639 passed/8 failed,1610.18s。失败完整清单: +test_apphost_a03_a04_architecture hosted binder consumers(managed/registry); +test_coding_wave_a_budget总行数;test_detachable_local_workspace_g16 shell预算; +test_hosted_application_g11 adapter consumers(managed/mux_probe)及mux预算; +test_hosted_product_g10_explicit_canary inventoryVersion与依赖边; +test_plugin_lifecycle_plc9c5_c50_baseline hosting consumers。须逐项核实新边 +和预算归属,不能机械提高上限或笼统扩大allowlist。此运行横跨实施变更, +后续修复需重验受影响门禁,不能把639通过当最终冻结快照证据。 + +主侧已开始退出索引底层:JsonConversationIndex.upsert_rooted复用原codec/ +revision/tombstone格式,在原RootedFile固定短锁下RMW,不重建缺失/损坏 +cache,不pathname preserve-corrupt。初集合2失败来自legacy夹具组可写 +权限,4 passed/1 skipped;明确私有夹具并补组可写拒绝后session26673 +退出0:4 passed/0.53s。尚未接Catalog/Session所有者,也未宣称跨所有旧 +index写入口互斥(旧upsert/delete/replace仍需统一锁与安全创建权限)。 + +架构失败首修:G10 inventory测试仍断言v6,但当前v7明确新增lmux preview, +G14设计测试也已要求v7。改为精确v7并增加coding.lmux.command完整row和 +project.scripts.lmux绑定断言,保留原canary及default omission断言。 +session2825退出0:1 passed/3 deselected/0.51s。其他consumer漂移已交 +独立复核逐文件/module/symbol核实,不机械扩allowlist;三个规模预算 +失败仍保留,未提高上限。owned index仍为未激活底层,不算退出回归已修。 + +2026-09-15 后续架构依赖收口:按已复核的具体文件/module/symbol更新 +A0.4、G11、G10与C50 consumer guards,保留private Hosting禁用与全部 +预算。session9283为3 passed/1 failed,失败是新A0.4精确断言遗漏解析器 +同时返回的symbol;修正后session40993为1 passed/19.86s。不是完整 +architecture复跑,也未修完三个规模预算。 + +退出索引前置修复:去掉夹具chmod掩盖后,session87900真实复现2失败: +legacy JsonConversationIndex创建为0664,且固定index.json.tmp符号链接 +会覆盖无关文件。该adapter现用独占创建的0600唯一临时文件发布;不改 +Session布局,不接管/删除旧.tmp。session75199为8 passed/1 skipped, +覆盖rooted index和原JSON index;新增发布失败保留原缓存、只清理本次 +临时文件后session84954为6 passed/0.85s。Ruff及单源mypy通过。 +这不等同完整受控索引接线:旧upsert/delete/replace尚未统一跨进程RMW锁, +Catalog/Session退出摘要仍未接入原writer port,相关交付项继续未完成。 + +索引RMW协调后续:session28635以原rooted事务持锁复现旧upsert/delete/ +replace全部绕锁,3 failed。三个旧入口现复用journal_file_lock相同 +index.json.lock,覆盖整段read/merge/write,非阻塞并先拒绝readonly。 +不是新持久所有者;owned入口仍走原RootedFile清理账本。session87152为 +12 passed/1 skipped(包含原JSON index),新增反向锁竞争与readonly不 +创建父目录/锁后session44147为13 passed/1.03s;Ruff、单源mypy通过。 +这些是独立文件描述符的本机事务互斥测试,不冒充双进程最终集成验收。 +Catalog旧失效处理的pathname unlink/损坏保全尚不属于该协调闭环;退出 +摘要必须使用新受控窄分支,不能直接删除ApplicationSessionManager的 +no-op去调用旧Catalog.upsert_summary。最终摘要接线、取消结算、双进程 +及完整退出回归仍未完成。 + +受控退出摘要已接线:LifecycleSession只读投影原transcript_file_io, +ProductTranscriptSession在原operation_scope内用settled_io调用Catalog +窄分支,冻结header/records/leaf,对比原port读取的磁盘记录及稳定stat, +再发布带原fingerprint的摘要。缺失cache不创建;不扫描其他Session,不 +走旧upsert_summary。Coding取消原no-op,普通unowned路径保留。 +session44996原runtime.dispose最新摘要回归1 passed/13.02s;四源mypy +通过。新测最初44504因夹具错误调用不存在的manager.dispose而3 failed, +改用原session.dispose后5710为3 passed,补目录替换后55744为4 passed。 + +独立生命周期复核确认原scope/native取消结算与摘要指纹局部链路,但发现 +P2旧Catalog异常unlink及普通read的quarantine旁路。已删除upsert异常 +无锁unlink,JsonConversationIndex只在mutation scope允许损坏保全; +read/get/query只报告stale。补真实Catalog持锁busy不删缓存及corrupt读 +不改名测试。session33948为19 passed/2.51s(owned摘要+rooted index), +Ruff通过。尚需Graph unknown-close负测、真实双进程/并发退出与旧Catalog +其他invalidate路径复核;性能影响未冻结,不以本轮局部通过宣称全部完成。 + +真实双进程索引互斥已补:父owned事务成功写入并保留锁,独立Python子 +进程旧upsert报告busy;父释放后子重试,最终同时保留one最新值和two。 +session24422为1 passed/1.13s;不是两个完整Runtime并发退出验收。 +四源mypy与新增子进程夹具Ruff通过。 + +扩大旧Catalog兼容集合session86801为28 passed/1 failed/1 skipped: +test_catalog_upsert_refuses_duplicate_physical_identity_and_invalidates_index +原要求重复身份时缓存失效,而本轮移除异常unlink后仍保留。暂不修改该 +断言掩盖原契约。独立复核进一步确认refresh/repair/bounded refresh的 +三处无锁unlink也需修;简单删除会产生scan漏B、index发布时间晚于B而 +被判fresh的风险。下一步需要原mutation内的publication版本回执和同锁 +精确CAS失效;不能无锁删,也不能拿锁后盲删别人的新版本。busy/未知 +发布/changed=False不具备本次publication回执,不得失效其他版本。 + +CAS原型已落地:JsonIndexPublication记录原临时fd的dev/ino及成功发布的 +generation/sequence,replace_with_receipt不破坏原replace返回值, +invalidate_if_current取同固定锁且拒绝后继版本;有界刷新已暂接该原型。 +session71679为24 passed/1 skipped,87976有界后验及后继保护3 passed。 +两源mypy初有显式None返回遗漏,修后54004通过;不是最终CAS安全验收。 + +独立复核报两项P2:两次open之间同inode改写,及receipt未绑定原publication +parent。第一项改为最终同fd读取/解析/状态复验到unlink,69048七项通过, +包括原地改写负测。第二项尚未修:原父目录替换后把原index inode移入 +新目录、重建同名lock,原型仍可能认可旧receipt。下一步需原mutation +固定parent、descriptor-relative临时创建/发布,receipt携带原parent身份, +CAS同parent执行journal_file_lock_at/read/unlink。禁止宣称CAS闭环或扩大 +接线;其余refresh/repair及duplicate身份旧回归仍未收口。 + +parent P2已复现:62473旧receipt对新目录内原inode返回True并误删,1 failed。 +现POSIX原mutation固定directory fd,journal_file_lock_at/read/quarantine/ +私有临时创建/replace均相对该fd;receipt从原fd提取parent identity,CAS +同parent验身份再读/删。63570相关31 passed/1 skipped;39662 parent +定向5 passed(含读取后换目录仍写原parent);单源mypy、Ruff通过。 +已交独立生命周期复核,不能仅凭绿测宣称完整native cleanup安全闭环。 + +完整refresh_index也改为scan后取得本次replace回执,再进行后验;有界/ +完整刷新共用后继writer保护测试。7419为22 passed/1 deselected/1.89s, +明确排除尚未修的upsert重复身份失效旧回归。repair路径和该回归仍待接线, +总体lmux安装/性能/最终门禁与交付状态不变。 + +repair和upsert接线完成局部回归:repair只对changed后的成功receipt做CAS, +未知发布/后验冲突不再自动refresh;初始missing/stale保留重建。 +upsert使用同稳定fd观察旧版本,仅已确认duplicate身份可CAS该版本; +发布后失败只CAS本次upsert receipt。88215 repair/后继保护7 passed; +95791相关集合55 passed/3.87s,含原duplicate旧回归;62459检测duplicate +期间后继发布保护1 passed。两源mypy、Ruff通过。独立生命周期复核认为 +两个底层P2及全部Catalog接线未见新增P1/P2,建议补upsert no-change +后验与后继版本两个窄反例;不代表全部goal验收。 + +真实runtime扩大回归揭示两类问题:61729为4 fail/4 pass,4822单测 +确认fresh family被无关旧v1全局阻止(_store_family.py:117),是真实 +新增兼容P2;不能用测试隔离掩盖。该修订已交架构/生命周期/UX三视角: +保留v1精确key/root/parent保证,严格合法不匹配v1不全局封禁新family, +同现存旧parent仍不隐式enroll,v2孤儿/失踪/替换保护保留;合同原全局 +追溯保证需要撤回。尚未修改family实现。 + +另测试原来使用真实platform home,且多个tmp_path Session共享pytest +basetemp附件父目录,不能作为独立运行环境。先隔离home但放在同data域 +导致40213四失败;移到独立TemporaryDirectory后92118余3个共享附件 +残留失败。现每测独立Session parent加独立platform home,不改行为断言, +56751真实runtime索引/摘要集合8 passed/41.21s。无删除用户state;此绿 +只证明隔离场景链路,无关v1的产品兼容P2仍明确未完成。 + +v1兼容P2已按三视角设计修订实施:严格校验闭字段/store-key/initialized/ +operation/身份,通过原state port的固定witness目录重读并校验物理witness/ +lock。合法非匹配v1不全局封禁;精确目标仍不落fresh,同已保留data parent +身份仍拒绝隐式family,坏记录与v2约束不放宽。M0撤回无依据的v1全局跨 +未知父路径保证。原missing-parent sibling测试改为精确旧A不可重建+明确 +不同key的v1不可定位边界,不再假称其为v2保证。 +67274 legacy/v1定向7 passed;2619完整shared/store admission71 passed/ +6.87s,含坏v1七参数与空但已知旧parent拒绝;73164真实default runtime +同state无关v1新workspace成功、旧tree/witness不变且恢复1 passed/13.01s。 +Ruff通过,已交限定实现独立复核;整体goal与最终安装/性能状态不变。 + +后续故障验证补齐:v1 校验后替换 data-root 不得采用替代目录;旧 witness +关闭回执未知时保留原 parent、不得重关已复用 fd;Graph 索引关闭回执未知 +时保留 transcript writer,直到原清理真正结算。默认 Embedded writer 互斥 +测试使用同一真实 platform registry,避免把无登记的低层 helper 当作 managed +Application。原测试句柄已消失,重新运行默认 Embedded 定向为 1 passed; +随后 owned runtime、owned index publication、shared store admission 三文件 +完整集合 58 passed/16.11s,Ruff 通过。未据此宣称整体生命周期验收完成。 + +已按独立架构逐文件增量在合同与门禁记录 core +13、shell +92、semantic +projection +41,保留原文件清单和余量。定向架构结果为 4 passed/1 failed: +此前 core 失败遮住的后续 lmux 分组断言现暴露 1810 > 1721。该分组尚未 +放宽,已交独立架构复核实际净增及职责归属;最终架构门禁仍未通过。 + +独立架构复核完成:lmux parser +14、创建回执恢复 command +75 未登记; +旧 selector -29 已在原预算扣除,不能重复扣减。其余五文件与 da820585 +wheel 一致,创建恢复仍组合原 AppHost operation,无新增状态 owner。 +精确补充 allowance=14+75,合同保留完整账目与七文件边界。 +同时补齐 upsert 后验失败的两项实证:成功发布后出现后继版本不得删除; +较新 revision 已胜出、本次返回 False/None 时不得失效缓存。均通过真实 +JSON index publication 路径,不以伪造成功回执替代。定向 7 passed, +随后三个相关架构文件与 Catalog 完整集合 48 passed/26.41s;Ruff 通过。 +这是相关门禁通过,不代表全架构、真实安装或正式性能验收完成。 + +终端与 Product 接线六文件为 179 passed/133.75s。其中两个 terminal 参数 +实际运行已安装短入口,验证两 Tab、草稿/resize、主动 detach/空 Ctrl-D、 +跨 cwd 精确实例/成员及终端恢复;其余 Product helper 测试包含替身,不能 +当作真实模型/审批/中断场景已运行,也不是最新冻结 wheel 的最终验收。 + +主侧与独立生命周期复核同时发现诊断入口 P2:tool-denial 虽有 dispatch, +却遗漏在 main 前置白名单。新增实际 main 分派窄测复现 5 passed/1 failed, +唯一失败为 denial 提前抛 unsupported native measurement case。改为前置 +校验及 dispatch 共用 PRODUCT_DIAGNOSTIC_CASES,保留原 guarded owner +以及 valid=False,未给诊断授予正式验收资格;Ruff 通过,修复后六入口与 +异常发布保护 7 passed/7.81s。实际 denial 场景仍待冻结安装运行。 + +剩余最小真实运行证据明确为:当前冻结安装的已接纳任务遭遇突然 PTY/EOF +断连,另一 cwd 重连同实例仍运行;interrupt-next-turn;DENY Ack 与停服后 +零工具效果;每项精确实例 stopped、三事实及外层 owner 结算。现有下层 +test_managed_detached 跨进程 HUP/EOF 与旧冻结诊断不代替这些最终证据。 + +原采集器 test_measure_g18_native 完整回归为 243 passed/4 skipped/40.56s, +涵盖本次诊断场景清单修复;未将 skip 计作通过。突然 PTY 断连的最小 +原 driver 扩展经生命周期专项讨论,已记录到性能验收计划,仍待实现与 +真实运行;未修改正常 detach 的终端恢复判断,未使用裸 PID kill 代测。 + +原 PosixPtyDriver 已增加 hangup_transport,使用同一 master/Popen/reader, +单次关闭原 master 后等待原客户端自主退出,无信号刺激。真实 EOF/EIO、 +重复关闭与 close 失回执复用 fd 首批 2 passed,reader 查询在途/超时增补 +后 4 passed。独立复核报两个 P2:原 close 会在 reader join 前关闭 fd 并 +丢债;挂断无界等锁及过期后仍关闭。现共用单次关闭 unknown 状态,先 +fence/join 再关原 fd,绝对期限约束锁/关闭/返回,非有限 timeout 前置拒绝。 +补齐 close-retry、双锁期限和非法 timeout 后 10 passed/1.96s,Ruff 通过, +已交限定复审。Product 突然断连接线与冻结安装验收尚未完成。 + +限定复审关闭原 driver 两项 P2;补上获锁后期限复验。现新增诊断入口 +managed-product-hangup-interrupt-next-turn,原 observed_terminal 不改, +窄 abrupt_terminal 使用原 foreground/driver owner,要求真实挂断成功、 +客户端/reader 退出且无 fallback。关闭最后 master 后 termios 明确不可 +观察,不能报告已恢复。Product 在流式 pending 后挂断,沿原认证快照/ +producer见证及异 cwd 重连中断/next-turn/exact-stop 路径继续。 +分派与失败清理等定向 65 passed/10.89s;新增真实 abrupt context 后 +PTY 全文件 11 passed/7.84s,diff-check 通过。已交接线独立复核,完整 +Product 突然断连及 denial 仍待当前冻结安装实际执行,未产生性能结论。 + +接线复核新增窄 P2:若客户端早已退出0,关闭遗留master可能误记主动 +挂断成功。原driver首次open准入及probe刺激前均检查原客户端仍活;已 +退出客户端/master仍开负测补齐,PTY与Product probe完整93 passed/7.84s。 + +当前证据输入已重新冻结到 `.artifacts/lmux-current-freeze.aqp5u4ut/repo`, +证据提交 `2853fcf1a80a882d653b5b93cd61d1b566f7a80e`,2810文件/35392594字节。 +逐文件复制前后与副本SHA256、权限一致,原lane HEAD未改变;不是功能 +交付提交。离线wheel通过原verify_wheel_at_commit,包含1393个包文件, +wheel SHA256 `866c2310c8771fd2beac8a988da505e28d5749f23f5098eff5dc2eb53a49a947`。 +仅把本次构建生成的build/egg-info移到证据目录外层保留,冻结Product clean。 +第一次安装因/tmp任务uv缓存解包配额失败;改用本证据目录下独立uv-cache +后安装成功,无清理其他任务文件。任务专用observer安装已更新到新wheel, +原verify_pinned_install的隔离解释器/来源/安装字节核验通过,回执在该 +目录 `verified-installation.json`。用户全局tool环境未改;旧reference-b +仍是旧wheel,不能冒充已同步A/A。当前冻结真实场景尚待执行。 + +当前冻结真实 tool-denial 已完成,证据在 +`.artifacts/lmux-current-freeze.aqp5u4ut/managed-product-tool-denial-cc2e029y/report.json`。 +原 observer 运行固定安装与冻结 helper,DENY 原 Ack 见证、精确请求关联及 +stop 后零工具效果检查通过,tool_execution_count=0;同 instance/member/ +Session 后验确认,精确 stop 返回 stopped,原外层 owner 结算,helpers +前后未变。status=owner-settled-diagnostic,valid=False;这是当前版本 +功能诊断,不是正式性能配对。随后串行启动同版本 +managed-product-hangup-interrupt-next-turn,原工具句柄73820尚待结果, +未把启动或测试Product选中视为完整断连/中断验收成功。 + +73820原句柄最终失败:真实客户端在PTY销毁后exit120,探针在挂断处的 +exit0断言阻止后续重连。原_evidence_process的leftovers输出是实际残留 +或wrapper非零的合并失败位,不可据此断言存在孤儿。生命周期只读核对 +原精确instance三事实均1、producer settled/Product returned,未见清理 +失败;原owner已终止,无另行发信号或重建清理owner。 +最小固定安装 TerminalSession 诊断在 `terminal-loss-27f9266w` 复现120, +异常链为termios error/EIO与两项OSError/EIO,均在销毁PTY后的恢复路径。 +UX复核要求分离正常detach和强制传输破坏:前者仍要求exit0及模式恢复; +后者记录原客户端实际非负exit_status/exit_clean,而非强迫恢复不存在的 +终端。仅客户端自主结算可进入后续后台连续性验证,signal/fallback/超时 +不通过。已据此更新窄探针/合同,93项相关回归通过/8.49s;旧失败样本保留, +不追认通过。还需补"原任务断线后完成且唯一结果可重连取得"的证据, +不能仅以仍running后马上中断替代完整持续执行承诺。 + +探针退出记录冻结为证据提交f5f958d后,真实样本alzvi32n已越过挂断、 +同实例pending快照、producer未结束及异cwd重连;Ctrl-C后输出已有idle, +但target_state_visible仅允许带help/receipt提示的status,漏掉合法的 +`Hosted | FirstUse | idle`,因此40秒超时,整体仍失败。补确切bare状态 +而非宽泛子串,保留当前完整帧/身份/footer/unknown拒绝;frames完整 +40 passed/7.01s,Ruff通过。仅这两个helper更新为证据bb04aaa,原wheel +不变,已串行重跑,结果待确认。 + +同时开始自然完成场景的测试侧最小增量:固定synthetic components新增 +显式completion_gate与gated nonce,仍由原attach_task producer释放done, +无gate拒绝,不在取消finally推done。原synthetic完整16 passed/5.70s, +包含未释放/取消与原唯一producer结算。尚未接跨进程控制门闩,也未同步 +到正在运行的冻结探针;不能算自然完成场景已验收。 + +83043样本8pddajh3已完成中断并显示下一轮精确nonce回复与idle;但回复完成 +判定_completed_lines也漏掉相同bare idle,因而在下一轮等待超时,整体 +仍失败。补齐回复/tool/denial共用完成状态的精确bare行,拒绝stale、unknown、 +错误成员/状态;frames完整43 passed/7.36s。未追认旧样本通过,待更新冻结 +helper并继续真实运行。 + +自然完成门闩测试侧新增CompletionGate/release:绑定instance+nonce、同 +nonce只消费一次、独占创建release、固定期限异步等候,错误身份不释放, +未完成写入也不能提前释放;超时/取消不推done。synthetic的gate_released、 +final_emitted、producer_settled分开见证,不能只用finally的settled证明 +自然成功。门闩及synthetic相关19 passed/6.11s(随后补未完成写入分支待 +追加回归);跨进程Product接线尚未完成,仍不构成完整自然完成证据。 + +完成帧helper冻结fe26c217后,真实断连/重连/中断/下一轮样本673crff_通过, +回执status=owner-settled-diagnostic,helpers_unchanged=True,valid=False。 +PTY原客户端自主exit120(client_exit_clean=False,不伪造模式恢复);随后 +认证同instance/Mux/member/Session pending且producer未结束,异cwd重连 +Ctrl-C后原producer settled,B唯一正式回复及A/B记录顺序通过,exact stop +返回原instance6603e114cf74ca1881fca0f5db865ab8 stopped,原外层owner结算。 +本次诊断中断到idle+producer约0.178秒、下一回复约0.581秒,仅单次描述, +不构成性能对比。证据在当前freeze目录下 +`managed-product-hangup-interrupt-next-turn-673crff_/report.json`。 + +门闩未完成写入回归与原synthetic/child集合30 passed/6.16s。固定child只在 +预创建completion-gates测试目录存在时选择固定门闩helper,绑定本次instance, +原生产启动参数不改;新增该可选分支参数化覆盖18 passed/5.78s,Ruff通过。自然完成完整探针 +尚未接线/实测,不用已通过的中断场景替代它。 + +自然完成完整接线新增managed-product-hangup-natural-completion:同原 +first_reply owner内先验证挂断后的pending,再精确release测试instance+ +nonce;另一cwd attach见原final及原native身份,验证producer_started→ +gate_released→final_emitted→producer_settled四阶段,B只提交一次,最后 +原认证snapshot要求gated A/user+assistant、reply B/user+assistant恰四条 +且无error,原exact stop仍是必要门禁。74项相关定向通过/7.63s,Ruff通过, +已交独立复核。12个测试helper/回归冻结证据3240fad,原Product wheel未改。 +真实自然完成样本已提交原owner启动,尚待结果,不计验收成功。 + +43598自然样本orbuehh9已记录A的自然完成四阶段、重连显示A完整结果并收到 +B回复,但_completed_lines漏掉acknowledged+长help组合,等待B完成帧超时, +整体失败。现在用同一精确状态组合函数穷举bare/两种hint/acknowledged, +不接受pending/unknown/failed;frames完整45 passed/6.65s,Ruff通过。 +同步3个helper为证据a24e02b,原wheel未变,已串行重跑自然完成样本。 + +架构只读盘点确认managed-mux九指标及compare_managed已接线,无需新框架。 +正式A/A仍需三个相同当前wheel的独立安装、原collector固定槽及锁定依赖 +离线缓存;当前reference-b旧版不能直接当A/A。旧新A/B因包路径1392/1393 +差_store_family.py被原source_pair拒绝,不能补空文件或改标签绕过。首次 +Product/长历史正式case与指标闭集仍待补齐,不以单次功能诊断关闭M4。 + +自然完成9729/ypei8ch9通过:原A断线后pending、释放精确测试gate后四阶段 +自然完成,异cwd重连取回A并只发送一次B,最终同instance/Session恰四记录 +且无error,原请求未重发。exact stop原instance80f78d12568377416ea3865ee9f6d964, +外层owner结算,helpers未变。客户端exit120如实单列,状态为功能诊断, +valid=False;不把gate等待计作性能。 + +两个reference现已安装当前866c2310 wheel,三个独立安装的原字节核验均 +通过,python/dependencies/entries一致,回执verified-aa-installations.json。 +冻结repo离线cache入口复用本任务g18-design/uv-cache,未复制335M缓存。 +原collector A/A warm 2×10对、checkpoint/pause-after=1尝试88068在固定槽 +安装核验阶段失败,未产生有效样本:长冻结路径下生成的console wrapper +不符合原核验器模板,report保留于aa-warm,slot标failed。下一步使用较短 +任务专用冻结路径重试,不放宽wrapper验证或改写已有失败报告。 + +短路径副本 `/var/tmp/lmux-aa.VzIjiw/repo` 已核验仍为 a24e02b,复用原离线 +cache。43826 的 `aa-warm-short` 已越过安装槽 wrapper 核验,但第一预热 +样本短入口 `lmux new -s perf` 自主退出1、输出 `lmux_unavailable`,首帧 +等待超时;采集器终态 exit1,checkpoint.phase=failed,比较 not-evaluated。 +原报告与 `/var/tmp/loushang-g18-native-5ie36q0i` 保留,不续传失败检查点, +不过滤失败样本。具体启动异常尚待定位,不能归因为单纯测量噪声。 + +使用该固定槽解释器只读调用相同根的 defaults 解析成功。另在独立私有 +`/var/tmp/lmux-prepare-diagnostic.6zr0j24t` 调用原同步 prepare 成功、返回 +planned_creation;未调用 run/启动后台服务,finally 原 close_native 结束, +55568 exit0。该诊断尚未复现采集器的完整隔离环境,不能证明真实失败已修复。 +性能方案已同步两项断连实测及 managed 九指标接线状态;正式验收仍未完成。 + +已定位首个预热的 unavailable:原固定槽 collector 在 run_sample 前调用 +control.mkdir(parents=True),以本机 umask002 创建了 sample-1=775;随后 +run_sample 的 mkdir(mode=700, exist_ok=True) 不改变已有权限。原 Product +_validate_parent 拒绝组可写祖先。独立隔离诊断 ma8dhlry(755)prepare 成功, +34p24wr0(775)在相同祖先准入路径失败;均未调用后台 run,原资源关闭。 + +新增 fixed collector 路径回归先红(实际775而非700),随后只修改原 +collect_fixed_native:managed-mux 在创建 control 前独占创建700样本父目录, +不 chmod 既有目录、不放宽 Product 安全检查、不改旧场景。采集器、stop +measurement 与 checkpoint 三文件回归输出310 passed/4 skipped/62.60s, +Ruff通过。修复尚未同步到冻结测量副本,真实安装重跑仍待完成,旧失败报告保留。 + +目录顺序修复已冻结到短路径证据仓库 fd360f804b0feb3ae9a6afab4b425fa65a000bce +(仅采集器四行,非 harness lane 交付提交;Product wheel 不变)。生命周期 +专项只读复核通过,无新增P1/P2:原resume跳过完成样本后使用新sample-N, +既有目录不chmod或接管,恢复种子与checkpoint身份检查不变。 + +72064 `aa-warm-private` 首个预热(block0/pair-1/side b)complete且valid=True; +外层退出0,checkpoint.phase=paused。原scratch为 +`/var/tmp/loushang-g18-native-0a0xtew9`。这证明真实安装已越过775准入错误, +且完成短入口、多成员、异cwd重连、精确stop及原采集owner结算,不构成 +20对性能结论。49739已使用原版本/安装/计划从此检查点resume,pause-after=2, +验证下一新样本及分段续采,结果待确认。按原合同,恢复后的分段证据不自动 +获得正式验收资格,仍须独立校准与环境审计,不绕过这一限制。 + +49739续采已退出0:原检查点消费后segment1跳过旧sample-1,新sample-2 +完成side a预热,两个样本均complete/valid=True;原checkpoint再次paused, +未重跑第一样本。分段接受标记保持false,comparison仍not-evaluated。 +真实安装的"安全暂停→恢复→新样本→再次暂停"链路现有证据;正式20对、 +首次Product和长历史指标以及整体交付门禁仍未完成。 + +独立连续A/A已启动19664,输出 `aa-warm-continuous`,源仍fd360f8、原wheel +不变,计划两块各十对,保留四个预热,无自动pause;原scratch为 +`/var/tmp/loushang-g18-native-j19gdns0`。截至本次核验,首块两个预热及首对 +正式a/b均complete/valid=True,下一样本running;报告无failure。尚未获得 +完整比较结果,后续须轮询原19664,不因观察超时重启。 + +采集期间仅开展文档/源码只读评审,未并行运行测试。首次Product正式接线 +增补经架构、生命周期、UX三视角评审:三fresh子场景八指标、原始动作端点、 +完整身份/效果回执及外层结算资格可实施。修复评审发现的"三个PTY"遗漏, +改为逐个实际terminal(包括interrupt初始与重连端,至少四次)验证退出0、 +恢复、reader结算、无fallback及子场景先停后开的顺序。设计通过不代表 +该正式case已实现;其实现与负测应在本轮连续测量之外验证,以免增加噪声。 + +首次使用接线开始实施测试侧证据增量,未改冻结副本或Product: +_lmux_product_probe 现为首次回复、审批三窗口及中断/下一轮保留原始 +started_at/finished_at;中断新增CtrlC至B完成累计,使用共享原端点, +不将两个分段相加。新增确定性时钟用例特意保留中间8秒间隙,期望累计15秒 +而非分段和7秒。旧诊断duration键保留,正式case仍未开放、valid语义未变。 + +普通首次回复snapshot收紧为恰USER(reply nonce)/ASSISTANT(expected)两条, +补缺USER、错nonce、额外USER、错序负测,保留原连接detach/finally。四个 +修改文件Ruff通过;为避免19664测量噪声,pytest尚未运行,不宣称回归通过。 +19664最新原进程输出已完成首块pair0..3共4对正式样本及两个预热,无失败, +仍运行中。后续继续轮询原会话,不重启;完整结果未出前不作性能结论。 + +首次使用原始spawn接线继续:复用原_g18_native_probe.observe_spawn包围 +固定测试入口及interrupt/natural的异cwd重连,保留实际executable/argv/cwd/ +pid/start。首次回复累计起点绑定真实Popen前的原观察时间,不再使用调用 +terminal context之前的近似起点;未新增进程owner。编排测试同步加入 +spawn替身和共享起点/重连argv断言,Ruff通过;pytest仍待连续采集结束。 +该增量尚未提供全部终端恢复回执/完整正式validator,不能宣布正式接线完成。 +19664原进程已输出首块pair0..5共6对正式样本complete,仍在连续运行, +无已知失败;冻结测量副本未改动。 + +首次使用证据增量新增原observed_terminal的可选settlements输出:先检查 +原模式恢复与无fallback,再退出原foreground context并验证reader结算、 +exit0,最后才append pid/argv/cwd/恢复及结算时间。原普通调用不要求此输出, +abrupt不伪造正常恢复。首次使用正常初始端、interrupt与natural重连端均接入, +避免仅按子场景数量漏掉第四个终端。新增8种正常/故障receipt编排测试, +包含body、仍alive、模式未恢复、fallback、reader未结算、非零exit和晚close; +尚未运行,Ruff通过。 + +exact_stop的原调用增加started/observed/local_owner_settled时间与原service/ +instance结果,仅原leader.close成功后写入;此字段不是外层evidence进程 +退出证明。失败路径不能写成功stop证据,outer终点仍待collector接线。 +相应顺序和失败无证据断言已补,生命周期专项只读复核已请求,行为回归 +待19664结束。原19664最新已完成首块pair0..7共8对正式样本,仍在运行; +本轮只改工作分支,未修改冻结helpers或启动并行测试。 + +原始证据增量生命周期静态复核通过,无新增P1/P2。评审提醒termios恢复 +不是光标/括号粘贴恢复证明;现settlements可选输出复用既有真实终端测试 +的最终转义顺序校验(最后show晚于hide、paste-disable晚于enable),分别 +记录termios_restored_at、cursor_restored及bracketed_paste_disabled。补 +恢复后再次hide/enable两负测,receipt测试现10种分支;Ruff通过,pytest +尚未运行。没有通过改写布尔值替代真实输出检查,旧无settlements调用不变。 +19664第一块十对正式样本全部complete,报告formal_complete=20(单侧计数), +已进入第二块预热;无failure,磁盘仍余约1.2G。全程仍为同一连续进程。 + +原collector新增validate_managed_product_timings,落实已评审八指标的三场景 +精确字段集合、有限非负端点/耗时、duration=end-start、审批窗口顺序及回复/ +中断累计共享端点。仅验证时间子集,不修改valid、不接受缺少身份/终端/ +stop/outer证明的完整样本,正式case入口仍未开放。新增30项纯校验用例, +包含相同duration仍端点错位和误用分段和丢掉间隙的反例;Ruff通过,pytest +继续等待原连续采集结束。19664已完成第二块pair0,累计11/20对正式样本, +仍运行、无已知失败;冻结目录未改。 + +时间子集校验补超大整数预先拒绝,避免math.isfinite转换溢出;新增非有限、 +布尔、字符串、负值及未知scenario负测,仍仅纯校验、未开放正式入口。 +测试侧first_use组合已接三个独立700私有子根,顺序调用原first_reply的 +reply/approval/interrupt路径;前一调用返回后才进入下一子场景,异常立即 +传播,所有子报告valid仍false。规范化八指标名称但不把缺少其他证据的 +子报告当完整通过,原外层guard/owner不变。新增4项顺序/中途失败编排 +用例,待原测量结束后运行;Ruff通过。 +19664第二块pair0..2已complete,累计13/20对正式样本,原进程仍运行, +无已知失败。没有启动并行测试或更改冻结采集输入。 + +三场景组合与时间校验窄实现经架构只读复核,无P1/P2;确认原环境投影 +为各workspace派生独立platform/runtime,不只是换cwd。按建议新增无spawn +投影回归:覆盖环境override原有ambient值、三个根互异及投影不创建状态。 +Ruff通过,行为测试仍未运行。时间子集允许零符合nonnegative合同,不能 +据此省略正式comparison的正值门槛。19664目前formal_complete=30, +即15/20对正式样本已complete,第二块pair5运行中、无failure;等待原进程。 + +19664连续暖A/A已退出0。`aa-warm-continuous/report.json`为 +complete-record-only:44/44 valid(40正式、4预热),helpers_before/after +完全一致,slot.busy=false/failed=false,未分段恢复。最终原统计判定为 +inconclusive:九项仅first_completion通过,另外八项未满足稳定性条件。 +不删慢样本、不放宽阈值、不把有效功能样本或退出0称为性能验收通过。 + +两侧各20个正式样本的描述均值(秒,A/B为相同wheel,不是新旧版本): +冷首帧10.823/11.879;首成员3.713/4.268;冷启动至首成员14.595/16.214; +第二成员2.058/2.042;暖attach5.584/5.607;首次补全0.058/0.066; +stop含外层结算7.878/7.969。这些是本次环境下的观察,不推断代码优化幅度; +波动原因尚需独立诊断,不能仅凭不稳定判定归因为宿主噪声。 + +确认原采集终态后才启动71465回归,覆盖新terminal receipt/timing/composition、 +Product probe/snapshot与原native collector/checkpoint,共八个测试文件。 +结果待确认;当前未启动新的性能采集。 + +71465回归已退出0:521 passed、4 skipped,71.71s。此次覆盖了采集期间 +新增的原始动作/真实spawn接线、精确USER/ASSISTANT后验、terminal成功 +receipt发布顺序及十类分支、数值/累计端点校验、三fresh组合与环境投影, +同时保留原native collector/checkpoint回归。此为行为回归结果,不将窄 +mock用例称为正式Product组合真实安装验收。后续仍需完整身份/效果及 +全样本时序validator、正式case/统计接线、真实组合采集及长历史验收。 + +原managed_reply_observation现在保留经confirm核验的snapshot纯值:完整 +product/continuity/session/scope/fingerprint身份、running、精确records、 +expected reply和确认完成时间。仍复用原临时attach/snapshot/detach及 +原认证连接关闭,外层_managed_observation完成后才返回,不新增观察通道。 +依赖的test_lmux_read_observer成功fixture同步为精确USER/ASSISTANT两条, +避免旧单assistant替身与已收紧合同不一致。50964相关三文件161 passed/7.42s。 + +原_tool_effect_witness增加可选纯值evidence,仅原trace调用身份/次数/ +批准后时序检查全部通过才输出instance/call/monotonic_ns/sequence,首次 +审批在exact stop后保存该原始效果;不是另读或重建authority。提前、重复、 +错调用不发布,新增4负/正分支。95449 probe完整93 passed/7.31s,四个修改 +文件Ruff通过。正式完整validator仍待实现,诊断valid语义不变。 + +原collector新增validate_managed_product_terminals,按reply/approval各1个、 +interrupt两个实际terminal闭合原spawn及settlement列表;核对固定入口或 +异cwd attach argv、cwd、pid、exit0、三类模式/reader/no-fallback及时序, +所有terminal结束后才允许原精确service/instance stop三事实及local owner +结算。拒绝将outer字段塞入local stop回执;不把该子校验当全部身份或外层 +物理结算证明。新增63项正/负分支,65786连同原timing/receipt共121 passed/ +7.12s。Product报告同时保存首次认证target与detach后原身份观察,不仅 +保存最终snapshot;71178 probe/composition共98 passed/7.42s。Ruff通过。 +完整validator整合、正式入口及真实首次使用组合采集仍待完成。 + +中断证据增量:原_producer_witness可选返回经过原阶段/调用/中断后时序 +验证的instance/call/phase/monotonic_ns/sequence;_interrupt_next_turn保留 +前后producer记录、两轮nonce、中断发送ns及重连/最终detach原认证观察。 +首次普通/延迟回复也保存原request_nonce,便于正式validator从请求推导 +唯一期待记录,而非反向相信结果。提前、重复、错call不发布证据;18066 +probe/composition共102 passed/7.98s。 + +原collector新增validate_managed_product_confirmation,闭合原target和 +后验字段、完整成员/Session身份与user_home范围,要求观察晚于传入的 +terminal settlement下界且确认不晚于停止上界;精确比较由调用者按nonce +构造的记录,拒绝额外字段/轮次/错误用户输入、提前snapshot、晚确认、 +错实例/成员/scope或双确认旗标。4912连同terminal/timing共137 passed/ +1.82s;Ruff通过。仍是组合validator所需子校验,正式整合入口尚未开放。 + +原collector新增validate_managed_product_scenario,逐一整合reply/approval/ +interrupt的时间、全部terminal、首次目标、detach观察、精确后验及stop副本。 +回复累计绑定真实spawn;审批要求唯一原效果位于approve发送与最终可见 +完成之间;中断核对前后同producer、发送/结束窗口、不同nonce、同一完整 +Session身份和精确A/B记录。原始duration与规范化值必须一致,各层字段 +闭集拒绝额外failure/未知字段;仍不修改valid。 + +91420三场景整合及既有子校验171 passed/1.99s;随后补原始result/next-turn +字段闭集负测,55080场景完整40 passed/1.40s,Ruff通过。尚未接跨三个 +fresh子场景的总样本闭集/外层物理结算与正式采集入口,不称正式验证完成。 + +总样本纯值校验已补:三个子场景依次闭合、独立service/instance/完整Session +身份、四个实际spawn和八指标汇总必须一致;complete_managed_product仅接受 +未提升valid的原观察,添加调用者提供的outer结算边界并返回新对象。正式 +run_sample尚未调用它,因此纯值测试不证明外层进程真的完成,更不构成真实 +安装验收。10043组合及编排19 passed/8.00s;新增三项"子场景单独合法但 +跨场景身份复用"负测后,63616总样本测试17 passed/1.47s,Ruff通过。 +合成时钟平移需从新端点重新计算耗时,已修正fixture而未放宽精确时长校验。 +下一步仍为正式case/原owner返回后的接线、统计接线和真实组合采集。 + +正式接线增量:native probe接受显式managed-product-first-use,仍在原 +measured_entries/_guarded内顺序调用first_use;run_sample仅在原 +owner.run_python成功返回后调用complete_managed_product,再走原安装来源 +与完整receipt校验,保留子报告及outer结算。三个fresh场景的外层总上限 +为3×240秒,内部操作期限不变。采集前的700父目录策略覆盖新场景,避免 +control隐式创建775祖先。原七场景缺省和managed-mux九指标保持不变。 + +首次使用独立八指标复用_compare_native_inventory的统计规则,不与旧场景 +混采;正式比较仍需fixed-slot、两个block各十对。19738入口/receipt/ +comparison/总样本/原collector回归324 passed、4 skipped/37.39s;随后补 +八项单独回退拒绝及不完整统计样本、两类私有父目录回归,8966共57 passed/ +2.63s,Ruff通过。尚需新case的原owner失败/取消专项接线回归、真实安装 +组合采集及复审;上述纯值与mock回归不证明真实首次使用验收通过。 + +owner专项接线新增成功、deferred、失败、取消、错误observer来源五分支, +完整使用原validator:即便失败前写出完整回执也不补outer或提升valid, +82162共22 passed/1.82s。三视角复审架构/统计无新增P1/P2;生命周期指出 +原native identity仅内部比较、未落报告的P2。已补fixed_product_native: +复用原first-member/首次detached、interrupt reattached/final-detached及 +AdoptedLeader所借stop身份,序列化六字段,严格阶段闭集/类型/相等校验。 +4191相关159 passed/8.28s,新增身份负测后43132共80 passed/2.32s。 + +生命周期再审发现stop身份记录位于leader打开后的try外,已移入原try首句。 +七类probe场景补OSError/KeyboardInterrupt注入:原close恰一次、原异常 +保留、stop不发送且成功receipt不发布。43505完整111 passed/8.48s,Ruff +通过;生命周期最终只读确认窗口已关闭。三视角本轮问题已修复,尚未进行 +新组合真实安装采集或全变更最终交付审计,不作整轮验收通过声明。 + +新组合真实安装预检已完成:23034原进程退出0,报告 +`.artifacts/lmux-current-freeze.aqp5u4ut/managed-first-use-preflight-055c6ep2/report.json`, +scratch `/var/tmp/lmux-first-use-t1407e8d`。使用原冻结wheel866c2310的独立 +measured/observer安装,当前checkout helper前后manifest相同,两套安装 +前后原verify_pinned_install收据相同。原run_sample完整处理reply/approval/ +interrupt三fresh场景、四PTY、native身份及精确stop,外层结算后validator +通过。report为owner-settled-preflight,report/sample valid仍false:这是 +真实安装接线预检,不是fixed-slot配对性能验收或代码提速证据。 + +单次描述值(秒):entry至回复22.879,发送至回复1.135,审批pending +1.003、details0.103、批准至回复0.407;中断至idle/producer结束0.228, +下一轮回复0.507,中断至下一轮可见回复累计0.735。此轮无模型网络调用, +固定测试Product;正式配对、长历史、全变更门禁及最终提交仍待完成。 + +交付门禁推进:make plan-checks重新生成当前计划,因pyproject/public跨包 +改动及新测试路径选择广泛门禁;未以窄测代替。全tests/architecture已启动 +原session61180,尚在运行(最后约11%),未启动另一份或判定通过。 +长历史实施草案补128轮×2048ASCII配方、warm/restore两显式场景后做三视角 +设计复核:发现公开snapshot是16384字符/128记录尾窗,不能验证全文; +已修订为尾窗新轮见证+公开只读load_agent_transcript_file及profile replay +的canonical全史摘要,不扩大生产限额、不解析私有JSONL兜底。 +同时明确旧/新两代独立owner、种子失回执/关闭失败不可继续、Markdown +完整帧而非仅尾标记。架构确认start后attach可走既有continuity恢复,固定 +测试入口需补start白名单。配方逐字节向量、seed预算和实现仍待冻结, +没有开放长历史正式case,也未宣称设计全部验收完成。 + +长历史配方实现候选已落测试侧_lmux_history_recipe:128轮,每个USER12 +ASCII字节/ASSISTANT2048字节,完整文本263680字节,带Markdown尾部和 +每轮唯一样式标记,规范全文摘要00e1c01b...。固定synthetic Product仅增加 +history NNNN请求的确定性回复,无生产代码或新启动入口。2111配方与 +原synthetic Product共30 passed/12.30s,Ruff导入排序已修正。 +原全architecture session61180仍运行,最新超过44%且已有一个F,尚无 +完整失败栈;保持原进程,不重启或宣称通过。下一轮需继续poll61180, +取得原终态后修复;seed公开调用、canonical读取及PTY历史见证仍未接线。 + +尾窗校验增量:validate_history_window严格比较当前轮次的原始kind/text +尾窗,不承担running/Session身份/持久化证明。固定2060字节一轮在16384 +字符生产预算下保留七整轮;第8轮起须有准确STATUS省略提示。回归用真实 +CodingRealHostedSessionV1.project_snapshot(无Session/store构造)验证 +第1/7/8/128轮,另拒绝旧轮、缺最新、重复、改正文及漏省略标记。82981 +配方/尾窗共23 passed/11.28s,Ruff通过;仍未称为真实PTY历史验收。 +原architecture session61180继续运行,最后超过66%,已有失败但尚无 +终态/完整错误栈,后续必须继续同句柄而非启动新全量。 + +canonical只读核验已接公开load_agent_transcript_file(read_only=True, +max_bytes=2097152)及AgentTranscriptProfile.default().replay:明确私有根 +下按hosted-SessionID文件定位,核对header/continuity/scope/fingerprint/cwd +及兼容版本,再对精确USER/ASSISTANT全文配方计算摘要;不按标题/mtime选取, +不写文件,不承担stop完成证明。真实typed export仅作为单测fixture(不是 +公开seed工作流),覆盖成功、错continuity/scope/cwd及早期正文改动,文件 +内容/mtime/inode/mode前后不变。83413为5 passed/6.15s;加严格readonly/ +固定上限/单次原读取断言后75522为5 passed/6.24s,Ruff通过。 +全architecture原61180仍未结束,最新89%;此前一个F尚待完整报告。 + +canonical helper只读复审发现P2:profile replay按输入顺序投影,不验证 +parent链,正文相同而parent被改会误过全文摘要。已在原typed records上 +先验证id唯一、首parentNone、后续严格链接上一记录(包括元数据),并 +拒绝branch summary/compaction checkpoint;保留固定无分支无压缩配方。 +正文不变的断parent/跳祖先/重复ID负测通过,46632完整8 passed/6.94s, +Ruff通过,架构复核确认P2关闭。原全architecture61180仍有增量输出且 +未终态;继续同句柄等待其完整失败信息,不作通过声明。 + +原architecture61180现已终态exit1:646 passed/1 failed,1516.87s。 +唯一失败test_agent_session_catalog_uses_bound_store_discovery禁止Catalog +直接依赖journal。当前session_catalog.publish_owned_summary虽仅用 +RootedFileIO作类型注解,方法体确实承担stat/bind/read/decode/二次stat及 +rooted index事务;架构只读复核认定真实职责越界,不应扩大allowlist或 +仅挪TYPE_CHECKING隐藏依赖。下一步保留断言,将完整"冻结source核验→保持 +原bound source→rooted index upsert"同步片段封装回jsonl_file存储适配层, +Catalog保留summary/locator投影和原index选择。当前尚未实施该修复。 +61180已结束,不再poll或重复启动整套;修复后先跑唯一失败断言及owned +index publication/取消结算相关回归,再按影响决定更广验证。 + +边界修复已实施:jsonl_file.publish_owned_transcript_projection接管完整 +同步IO临界区,source绑定保持到index upsert返回;Catalog仅选择index、 +构造locator/summary纯投影并调用publication。Product在原operation_scope +内冻结header/records、用原file_io构造partial,仍交原settled_io,无新owner。 +Journal禁入断言未改,32667原失败断言及owned publication共6 passed/ +2.67s;31140目录索引及rooted发布完整38 passed/3.20s,Ruff通过,架构 +只读复审确认无新增P1/P2。三文件mypy发现catalog.session_dir可选类型, +已增加local catalog显式检查,93735复跑类型检查中,待取原终态。 +这次涉及生产文件,旧866c2310 wheel的预检不视为修复后安装验收;最终 +必须按新源码重新冻结安装验证,不能沿用旧wheel身份声明完成。 + +93735已退出0:mypy三个改动源文件全部通过(关闭增量并使用/dev/null +缓存,未复用此前大WAL缓存)。全架构唯一失败的定点修复及相关行为/类型 +验证已完成;长历史播种/PTY接线、其余交付门禁与新安装验证仍待继续。 + +公开seed借用循环已实现于_lmux_history_seed:初始必须fresh idle,128轮 +start_turn恰一次,合法Ack后同绝对期限内检查准确本轮尾窗和idle,旧轮 +idle只可继续只读观察,不准进入下一轮。失回执/错误/取消直接传播。候选 +全seed600s、每轮40s、每轮最多80次snapshot及0.1s间隔,sleep也受同deadline +约束,不改生产期限。16147首批11 passed/0.78s,92466补deadline非法值 +后17 passed/0.65s,Ruff通过。该函数不拥有连接/attachment,不负责close, +外层原owner的attach/detach/close及总deadline尚未接入,不能称真实seed +采集完成;已请求生命周期只读评审,结论待收。 + +生命周期复核提出校验跨deadline仍可成功的P2,已补本轮validate之后 +settled`),全程 `--offline`,**均退出0**; + 并以 `verify_pinned_install` 实探针比对,得到 python= + 3.11.15 (main, Mar 10 2026, 18:16:52) [Clang 21.1.4]、41 依赖、 + 7 入口,与原 report.installations.a **逐字段一致**。 + 因此无需联网补回,也**不需要**重建 cache;采集前只做一次离线 dry-run 即可。 +5. 明确冻结分段策略:若确实需要暂停,必须在同一 segment 内一路采完同一 + A/A 的 2×10 对,避免跨越 A/B 面或改变 seed 年龄;并把 + `eligible_for_automatic_acceptance` 的说明与正式 verdict 分开报告。 +6. 每条样本仍须独立满足:真实 PTY、attach/detach、canonical history、 + 精确 stop 三事实、原 owner 结算,以及 warm 的 128 轮/256 消息/263680 字节/ + 固定 SHA256 与异 cwd viewport 校验、restore 的 instance/native identity + 确实换代。任何身份错配、摘要错配、历史截断、旧回执串用或晚到清理失败, + 一律使样本无效并保留证据。 + +### 110445 长历史正式采集结果:warm 结构完整但 comparison=inconclusive;restore 在样本27失败 + +审计后按下文修正案重开两次**单段不暂停**采集(新 output 目录、无 +`--checkpoint`/`--pause-after`),仍用同一冻结源 +82a936ce8cd1b35a3005048d5a77afbff8985c28 与候选 wheel +70d6d1bdda17a57814b9c67f546ef784997fd1425ec446cfecf05e06cbfa5ea3。 +两者都不含 `segments`/`segmented_acceptance`,即**确认没有发生任何 --resume**, +不受"续采需另行校准审计"限制。 + +**warm:`/var/tmp/lmux-lazy-paired.JK8DMb/aa-history-warm-r1`** + +采集完整:44 行 = 2块×(1预热+10对)×2侧,40 measured + 4 预热, +全部 status=complete、valid=True,0 失败。用冻结 runner 的 +`validate_observation`(先按 `complete_managed_history` 语义补记原 owner 的 +`outer_settlement` 与派生 milestones)逐样本复验:**44/44 通过**。 + +已满足的正式事实: + +- canonical 全史:全部 40 条 measured 样本一致为 `lmux-history-128x2048/v1`、 + 128 轮、256 记录、263680 字节、SHA256 + `00e1c01bb0a4603b94f5fbd70ea802f24a9389471893e5883310ba7c0c0fbb41`。 +- 异 cwd attach 当前 viewport:40/40 精确通过 `validate_history_window(...,127)`, + 即与 Product 尾窗逐字节一致;末轮含 Markdown 二级标题 `## History 0127`、 + 列表项、`text` 代码块及唯一尾标记 `LMUX_HISTORY_0127_END`。不是空壳 footer, + 也不是 Markdown 退化原文。有界尾窗(15 条)是设计行为,非历史截断。 +- 每样本含完整 Session 五字段 scope 身份、target 六字段、detach/connection + 结算、精确 stop 三事实、原 owner 结算与外层结算。 +- 采集期间无其他采集器;load_before 均值 1.56(1 vCPU)。 + +**但 comparison verdict = `inconclusive`,不是 pass**(`history_frame_seconds` +单独为 `pass`,improvement -2.4%、回归上限 0.17s,两侧 stable)。判为 +inconclusive 的是 `history_completion_seconds`: + +| 组 | median | MAD | 上限 max(median/10, 1/100) | 结论 | +| --- | --- | --- | --- | --- | +| a block0 | 89.53 ms | 14.17 ms | 10.00 ms | 超 4.17 ms | +| a block1 | 83.96 ms | 6.41 ms | 10.00 ms | 通过 | +| b block0 | 77.73 ms | 8.19 ms | 10.00 ms | 通过 | +| b block1 | 73.63 ms | 10.51 ms | 10.00 ms | 超 0.51 ms | + +该指标量级仅 74-90 ms,而规则对它的绝对下限是 **10 ms**(`median/10` 只在 +median > 100 ms 时才超过该下限),因此两组在其量级的自然抖动下越界 +(0.51-4.17 ms)。对照同一批数据的 `history_frame_seconds` 量级 1.7-1.8 s, +上限为 173-180 ms,四组全部稳定通过。**判定按原规则原样执行,未放宽阈值、 +未剔除样本、未改写 report**;本 campaign 的结论就是 inconclusive。 + +**必须避免一个错误的"修复":把 10 ms 下限拿掉并不能让它变 pass。**实测 +将规则换成纯相对上限(`mad ≤ median/10`)后: + +| 组 | MAD | 现上限 | 纯相对上限 | 纯相对下结论 | +| --- | --- | --- | --- | --- | +| a block0 | 14.17 ms | 10.00 ms | 8.95 ms | **仍越界** | +| b block1 | 10.51 ms | 10.00 ms | 7.36 ms | **仍越界** | + +即四个组里有三个在纯相对规则下同样越界。因此该不稳定是**指标本身的真实 +抖动**,不是绝对下限造成的假阴性;"降低/取消下限"不构成合格修复。 +是否需要为该毫秒级指标另行评审校准(而不是在本次结果上放宽),超出本轮范围, +且不得用"环境噪声"作为宣布 pass 的理由。 + +**restore:`/var/tmp/lmux-lazy-paired.JK8DMb/aa-history-restore-r1`** + +前 26 个样本(含 4 预热)全部 complete/valid=True,随后在**样本27**(block1 +pair1 side a)失败并终止:report.status=failed、该行 valid=False、 +comparison=not-evaluated。失败按原合同**原样保留、未改写、未替换**。 + +根因取证(只读): + +- 失败点在第一代 `probe.first_reply` 的 `_see(driver, "perf |")` 首屏等待 + 超时(30s 内未出现);前台 `lmux new -s perf` 随后 exit 1,stderr 为 + `local_operation_failed`(`coding/cli/mux.py` 的兜底分支)。 +- 生命周期日志只有 `starting → failed(startup_failed) → stopping → stopped`, + 即应用自身启动预算内未提交,属 **`startup_failed`**。 +- registry 该实例 `phase=aborting`、`stop_requested=1`、 + `process_exited=0`、`application_cleanup_completed=1`、 + `process_scope_settled=0`:stop 三事实**不完整(2/3 不成立)**, + 故该样本即便想用也不合格。 +- observer 边界记录 `fixed_product_selected → fixed_product_returned(status=1)`, + fallback 清理抛 RuntimeError、`leftovers=True`、`force_cleanup=False`。 +- 失败实例的原生 pid 173367 已不存在,无人持有该 sample-27 的任何 fd, + 槽 receipt 为 `busy=false`/`failed=true`。未见活动残留进程。 + +必须如实记录、不得掩盖的两点: + +1. 该 `startup_failed` 是本仓库**已有记录、且此前明确"仍未解释"**的签名 + (见上文 admission 诊断与"旧 startup_failed 仍未解释"各条)。 + 本轮**不能**声称已定位根因,也不能把它记成关闭修复之后的回归。 +2. 采集期间机器上存在**非本任务启动**的并发负载:`tmux new -s loushang` + 会话于 10:58:25 起、其 `loushang --resume` 于 10:58:34 起持续占用约 + 48-58% CPU(1 vCPU 机器)。失败样本27 落在 11:00:06-11:00:44, + 与该负载时间重叠。该进程非本任务所有,**未触碰、未停止**。 + 但必须如实指出这与"纯负载致因"相抵触的一面:**样本25、26 在该负载已 + 启用的情况下分别于 10:58:53(+19s)与 11:00:00(+86s)成功完成**, + 只有样本27(+130s)失败。因此不能声称"有该负载就必然失败",也不能把 + 时间重叠当作已证实的根因;但仅 2 个样本也不足以排除负载贡献。 + 该负载不能作为宣布样本有效或无效的依据,不能用来解释或推诿 + `startup_failed` 本身。 + +warm 侧无此负载(09:41-10:08 期间不存在该会话),其 44/44 通过不受影响。 + +结论分层(不得混淆):采集完成 warm=是/restore=否;样本 valid warm=44/44、 +restore=26/44 且 1 条如实失败;comparison verdict warm=inconclusive、 +restore=not-evaluated;**性能是否达标:无结论,不得宣布通过**。 +`managed-product-history-warm` 的两个硬验收事实(128×2048 canonical 与 +异 cwd viewport 尾标记)已实测满足,但"结构完整"不等于"性能 pass"。 + +后续所需(待人工决定,本轮未做):restore 需在确认无外部并发负载、 +并独立复核 `startup_failed` 是否为环境相关的条件下重开新 output 重采; +不得在本 campaign 上续采或改写。warm 的 inconclusive 不得通过放宽阈值解决。 + +#### warm 逐样本验收明细(110445,实测,非推断) + +对 40 条 measured 样本逐项复验,全部满足: + +- **canonical 全史**:40/40 为 128 轮 / 256 记录 / 263680 字节 / + `00e1c01bb0a4603b94f5fbd70ea802f24a9389471893e5883310ba7c0c0fbb41`, + recipe 恒为 `lmux-history-128x2048/v1`。 +- **异 cwd attach viewport**:40/40 通过 `validate_history_window(...,127)` + 逐字节比对;末轮含 `## History 0127`、列表项、```text``` 代码块与 + `LMUX_HISTORY_0127_END`。attach 确实来自另一 cwd(`.../workspace/elsewhere`)。 +- **每个真实终端独立验证**:每样本恰 2 个终端结算,全部 + `exit_status=0`、`fallback=False`、`reader_settled=True`、 + `termios_restored_at` 非空、`bracketed_paste_disabled=True`、 + `cursor_restored=True`,并各自带精确 pid/argv/cwd。0 例违反。 +- **Session 五字段**:`product_id`/`continuity_id`/`session_id`/`scope`/ + `scope_fingerprint` 齐全,scope=user_home、fingerprint 为 64 位十六进制。 +- **target 六字段**:`stage`/`observed_at`/`instanceId`/`serviceId`/`muxId`/ + `members` 齐全。 +- **精确 stop 三事实**:40/40 满足 `service_id` 与 target 一致、 + `result={status:stopped, instanceId:target}` 且 + `started_at ≤ observed_at ≤ local_owner_settled_at`。 +- **外层结算**:每样本 `outer_settlement` 有界且 started8 ms 的空隙),所以不是“两种状态切换”, + 而是单峰且相对离散偏大。 + +**(五)结论与后续方向。** 该 inconclusive **不得**解释为“环境不合格、 +换机器重采即可通过”,也**不得**靠调整阈值取得 pass(实测:去掉 10 ms 绝对 +下限、改用纯相对规则后,四个组里仍有三个越界,见前文表格)。正确结论是 +**门禁与该毫秒级指标不匹配,应作为设计问题评审**: + +- 该指标端点是“输入 `/he` 前到完整补全帧”,量级由终端补全往返决定, + 天然带约 15% 相对抖动,而 10% 相对门禁对它偏严; +- 可选方向(需设计评审,不得在本次结果上放宽):改为多轮取稳健统计、 + 为该指标单独设定与其量级相称的门禁、或把它降级为描述性指标 + (与 `history_frame_seconds` 的判定分开报告); +- **无论选哪个方向,都不是“重跑到通过”**,也不是修改既有 report。 + +### 110610 关于“放宽门禁”的决定性分析:放宽后得到的是假通过,不是真结论 + +用户指示“放宽一点门禁”。在动手前先做了只读功效分析,结论是**不该放**, +理由如下(若强行放宽,必须连同本节一起记录,否则等于制造假通过)。 + +**(一)要放宽的旋钮不是 `median/10`,而是绝对下限 `1/100`。** +`_g18_comparison.py` 里三个常数作用域不同,改错会伤及真实回归检测: + +| 行 | 用途 | 表达式 | +| --- | --- | --- | +| 298 | block 间中位数跨度 | `max(min(medians)/10, 1/50)` | +| 300 | **MAD 稳定性门禁** | `max(median/10, 1/100)` | +| 316 | **回归阈值**(探测真实性能回归) | `max(baseline/10, 1/50)` | + +若把 `median/10` 改成 `median/6.3`,会**连带把第316行的回归阈值放松1.6倍**, +即更难发现真实回归——那是错的旋钮。对 78 ms 量级指标,第300行实际生效的是 +**绝对下限 10 ms**(`median/10` 仅 7.4–8.8 ms),所以要动的是它。 +实测:下限抬到 **15 ms** 可让四组全过(最差组 MAD = 14.17 ms)。 + +**(二)但放宽会直接制造假通过。** 该指标**必须探测的最小真实回归**是 +10%(=7.64 ms),而它的**测量噪声 MAD 是 12.09 ms**: + +| 量 | 值 | +| --- | --- | +| 必须探测的信号(10% 回归阈值) | 7.64 ms | +| 测量噪声(MAD) | 12.09 ms | +| 噪声/信号 | **1.58x** | + +噪声**大于**要探测的信号。也就是说该指标在当前设计下对 10% 回归 +**没有分辨率**——无论稳定性门禁放宽与否。 + +**(三)模拟验证:放宽门禁 ≠ 得到结论,而是得到“看起来通过”。** +按冻结设计(n=10/组 × 2块)用该指标自身的噪声做功效模拟(3000 次/点): + +| 真实回归 | 正确判 regression(功效) | +| --- | --- | +| 0% | 0.0%(无回归时不误报,正确) | +| 10%(阈值本身) | **0.5%** | +| 20% | 15.0% | +| 30% | 74.5% | +| 50% | 100.0% | + +即:**在阈值处的检出率只有 0.5%**,要有 30% 的真实回归才有约 3/4 概率被 +发现。把 MAD 门禁放宽到能让本次数据 stable,随后的回归判定仍淹没在噪声里, +**结果是 verdict=pass,但该 pass 不代表“没有性能回归”**。 + +**(四)决定与正确方向。** 按“不得通过放宽阈值、剔除样本取得 pass”的既有 +合同,本轮**不放宽 `_g18_comparison.py` 的任何常数**,也不重跑: +该指标的 inconclusive 是**真实的测量能力不足**信号,正确处置是把 +`history_completion_seconds` 的**设计问题**交产品侧评审,选项包括: + +1. 提高该指标的信噪比(例如把端点改为更粗粒度但更稳定的观测,或减少 + 终端渲染往返抖动); +2. 增大样本量 / 采用多轮重复取稳健统计,直到噪声降到回归阈值的 + ~1/3 以下; +3. 若二者都不可行,则**明确把它降级为描述性指标**,只报中位数与分位数, + **不参与 verdict**,并在验收文档中写明“不以此指标判定回归”。 + +**无论选哪条,都必须先声明新的判定合同,再采集**;不得用既有数据补算。 +本轮的 warm A/A 因此**保持 inconclusive**,维持前述四层结论不变。 + +### 110655 重要发现:同一门禁问题不止长历史,managed-mux 九指标已中招八项 + +在梳理 V10 其余子验收时发现:本仓库**已有**一份 managed-mux 的完整 A/A +正式采集 `.artifacts/lmux-current-freeze.aqp5u4ut/aa-warm-continuous/report.json` +(源 fd360f80…,44/44 valid、40 正式 + 4 预热、未分段、`eligible=true`、 +load_before 均值 2.24),其 `comparison` 为 **inconclusive**,且文档已如实 +记录“九项仅 `first_completion` 通过,另外八项未满足稳定性条件”。 + +该结论与 110545 对长历史的分析是**同一个机制**。逐指标复算相对 MAD +(门禁要求 ≤10%): + +| 指标 | 中位数量级 | 越界组数/4 | 相对 MAD 范围 | +| --- | --- | --- | --- | +| `cold_frame_seconds` | ≈11 s | 3 | 8.6%–13.3% | +| `first_member_ready_seconds` | ≈3.6 s | 2 | 2.2%–15.0% | +| `cold_through_first_member_seconds` | ≈15 s | 2 | 3.7%–12.2% | +| `warm_member_ready_seconds` | ≈2.0 s | 2 | 9.1%–11.5% | +| `detach_settlement_seconds` | ≈0.8 s | 3 | 9.5%–19.2% | +| `warm_attach_frame_seconds` | ≈5.5 s | 2 | 6.3%–15.4% | +| `reattach_detach_settlement_seconds` | ≈0.8 s | 2 | 3.7%–12.6% | +| `stop_settlement_seconds` | ≈7.7 s | 2 | 4.5%–11.4% | +| `first_completion_seconds` | ≈0.058 s | 0(**通过**) | 7.0%–14.8%(但受 10 ms 下限保护) | + +**关键含义(必须修正 110610 的范围限定):** + +1. 门禁与指标不匹配**不是长历史个案,而是这套 A/A 统计策略的普遍现象**: + 在 1 vCPU 主机上,凡量级在 0.8–15 s 的交互类指标,其相对 MAD 常落在 + 7%–19%,与 10% 阈值高度重叠。八项同时越界很难用“某几组运气差”解释。 +2. 唯一通过的 `first_completion_seconds` 恰恰是**受绝对下限 `1/100` 保护**的 + 0.058 s 指标——它的 `median/10` 仅 5.8 ms,实际生效的是 10 ms 下限。 + 这从正面印证了 110610 的判断:**生效杠杆是绝对下限,不是 10% 相对项。** +3. 因此若只针对长历史改判定合同,managed-mux(以及 first-use)会**继续 + 卡在同一处**,V10 仍无法关闭。正确的处置层级是**统一评审这套 A/A 稳定性 + 门禁本身**,而不是逐个 case 打补丁。 +4. 也**不支持**“换更安静机器就能过”的乐观解释:该 campaign 由当时进程独占 + 发起、文档亦已明确“不能仅凭不稳定判定归因为宿主噪声”。是否换环境可解, + 需要**独立实验**证明,不能假设。 + +**对 ARD-004 的影响(需在其接受前补充):** ARD-004 目前把范围限定为 +`history_completion_seconds`。上述证据表明该限定**过窄**。建议评审时把 +范围提升为“A/A 稳定性门禁与交互类指标量级的匹配性”,长历史只是其中一例; +否则会出现“长历史按新合同降级通过、managed-mux 仍 inconclusive”的 +不一致状态,且 V10 依然无法关闭。ARD-004 的重新考虑条件已覆盖此情形 +(“出现该指标能稳定通过…可恢复其判定地位”),但**范围**需显式放宽。 + +**本轮未做任何改动**:未重算既有 report、未改门禁常数、未重采。 + +### 110720 统一门禁重标定分析:N=25%/R=30% 是最小必要放宽 + +按用户“统一门禁评审、可适当放开、能往前推进”的指示,完成只读分析并写入 +[ARD-004(当时为 proposed,现已 accepted)](decisions/accepted/ARD-004-aa-stability-gate-vs-interactive-metric-scale.md)。 +本节只记录结论与关键数字。 + +**(一)根因是 N 与 R 不自洽。** `_g18_comparison.py` 有两个语义不同的常数: +`:300` 稳定性门禁 `N=max(median/10, 1/100)`(“测量够不够准”)与 `:316` 回归阈值 +`R=max(baseline/10, 1/50)`(“要探测多大的回归”)。现行**两者都是 10%,R/N=1.0**, +即“稳定后比 R”等于在噪声量级上比大小——这才是 10% 回归检出率仅 0.5% 的**结构性** +根因,**与长历史无关**。故**只抬 N 会让 R/N 降到 0.4,比现状更糟**;必须成对调。 + +**(二)N=25% 是“最小必要放宽”,不是“尽量宽容”。** 实测:`N=24.7%` 仍有 21/22 +组侧 unstable,**`N=25%` 才 22/22**。再往上放宽无推进收益,反而放大 R、削弱检出: +注入 30% 真实回归的检出率在 `N=25%/R=30%` 为 **52%**,`N=30%/R=40%` 降到 **10%**, +`N=40%/R=60%` 为 **0%**。故建议值取在刚好够用处。 + +**(三)反“自动 pass”验证。** 重标定仍需能抓回归:注入 0% 回归时判 `regression` +比例 **0%**(不误报);注入 30%/80%/150% 时 **100%** 抓到;真实噪声下含 stability +前置,30% 回归 48%、50% 回归 95%。对照现行 `N=10%/R=10%`:即便注入 **200%** 回归 +检出率也仅 **23%** —— 真正“失去意义”的是现行档位。 + +**(四)接受后的推进路径(直接回答“能否往前推进”)。** 新合同下**无需重采**, +两个既有 campaign 转为 `pass`: + +| campaign | 现行 | 新合同下 | +| --- | --- | --- | +| `aa-history-warm-r1` | inconclusive | **pass** | +| `aa-warm-continuous`(managed-mux) | inconclusive | **pass** | + +由此解除 V10 三个阻塞中的两个;剩余:restore 的 `startup_failed`(已交产品侧)、 +first-use 尚无正式 A/A(建议新合同生效后再启动,避免白跑一次注定 inconclusive 的长采集)。 + +**纪律(已写入 ARD):** 改判须显式标注为“判据变更”而非“新数据证明无回归”; +既有 report **不改写**,重评结果写新文件并引用原 sha256;“30% 以下不可检出” +须随结论呈现。 + +**本轮未改任何代码或常数**(`:300`/`:316` 原样)、未改写任何 report、未重采。 + +### 110815 ARD-004 已接受并实施:门禁成对重标定 + 只读重评 + +用户确认“可以接受”。ARD-004 由 `proposed/` 移入 `accepted/`,并按生命周期 +要求同步更新了状态、接受记录与入口链接。 + +**实施内容(代码):** + +- `scripts/dev/_g18_comparison.py` 引入具名常量对 + `STABILITY_RATIO = Fraction(1, 4)`、`REGRESSION_RATIO = Fraction(3, 10)`, + `_compare` 的三处判定(MAD 稳定性、块间跨度、回归阈值与 calibration) + 全部改为引用它们。文件头注释说明:二者语义不同(“测量精度” vs “可探测回归”), + **必须成对标定**——只抬前者会让 R/N 反转、verdict 更无意义。 +- 新增 `scripts/dev/reevaluate_g18_comparison.py`:按 ARD 纪律做**只读重评**。 + 不改写原 report;记录其 sha256 作为绑定;输出到新文件并**拒绝覆盖**; + 拒绝未完成或已 resume 的 campaign;输出中显式声明 + `is_new_measurement=false`、`is_performance_acceptance=false`, + 并列出“判据变更而非新数据”等声明。 + +**已发布重评(原始 report 均逐字节未变):** + +| campaign | 原 verdict | 新 verdict | 重评文件 | +| --- | --- | --- | --- | +| `aa-history-warm-r1`(长历史 warm) | inconclusive | **pass** | `reevaluation-ARD-004.json` | +| `aa-warm-continuous`(managed-mux) | inconclusive | **pass** | `reevaluation-ARD-004.json` | + +重评文件中的 `source_report_sha256` 已复核与实际 report 一致 +(446af8f1… / d6e243ef…)。 + +**测试与回归:** + +- 新增 `tests/dev/test_reevaluate_g18_comparison.py`(16 例):契约记录、 + source 字节绑定、拒绝覆盖、拒绝未完成/已 resume/非冻结策略、源文件不变、 + 以及“超过接受比率的回归仍会被报出”。 +- 旧门禁边界测试改为**由常量推导**(不再硬编码 10%): + `test_g18_comparison.py`、`test_lmux_comparison.py`、`test_measure_g18_native.py`、 + `test_measure_g18_startup.py`。其中 `test_measure_g18_startup` 原先注入固定 + 20% 回归(已低于新 30% 阈值),现改为按常量推导。 +- `ruff` 通过;`tests/dev` **1818 passed / 5 skipped / 1 failed**; + 该唯一失败仍是**未跟踪**的 `test_lmux_native_receipt.py`(断言 `OPTIONAL_CASES` + 旧值),属既有过时断言,非本轮引入。 + +**重要限定(不得误读):** 上述 `pass` 的含义是“**在新门禁下未检出超过接受 +比率的回归**”,**不是**“证明无回归”,也**不是**性能验收通过。30% 以下的回归 +在本设计下不可检出,该限制已随结论写入重评文件与 ARD。 + +**V10 影响:** 三个阻塞中的两个(长历史、managed-mux)在新合同下有有效结论; +剩余 ① restore 的 `startup_failed`(已交产品侧)② first-use 尚无正式 A/A +(建议在新合同生效后启动,避免再跑一次注定 inconclusive 的采集)。 diff --git a/docs/internals/architecture/apphost/lmux-performance-acceptance-plan.md b/docs/internals/architecture/apphost/lmux-performance-acceptance-plan.md new file mode 100644 index 000000000..6b24e3421 --- /dev/null +++ b/docs/internals/architecture/apphost/lmux-performance-acceptance-plan.md @@ -0,0 +1,416 @@ +# LMUX M4 Linux 性能验收增量 + +状态:部分子项已局部评审并开展诊断采集;正式配对验收和完整三视角 +评审尚未完成,不构成性能结论。实际通过项、失败样本和待办见 +[推进记录](lmux-managed-output-capture.md),下文候选指标不等于验收通过。 + +## 已取得冻结安装功能诊断:运行中真实 PTY 突然断连 + +生命周期专项复核建议在原测试 `PosixPtyDriver` 增加窄接口 +`hangup_transport(timeout)`,不另建进程 owner,不使用 `terminate_tree` +作为断连刺激。原 driver 仍保管前台 Popen 与 reader,并负责最终结算。 +此为真实 PTY transport hangup(slave EOF/EIO)而不是对 SSH 协议本身的 +测试;当前启动方式没有建立 controlling terminal,不能声称一定发 SIGHUP。 + +实现约束:与 close 串行,先 fence write/resize、通知原 reader 停止,再 +在期限内 join;不得持 writer lock 等待可能回应终端查询的 reader。reader +停止后单次关闭原 master,不保留 dup master。关闭失回执记录 unknown, +后续 close 不得再次关闭可能复用的 fd,也不得声称已干净结算。hangup +不提前设置整个 driver closed;前台退出和其余资源仍由原 driver 处理。 + +保持正常 `observed_terminal` 的终端模式/光标恢复门禁不变。突然断连使用 +窄专用 context,要求原客户端自主退出、reader 结算、无 fallback termination; +最后 master 关闭后 termios 标记为不可观察,不伪造恢复成功。重新连接仍走 +正常终端门禁。失败或超时可由原 close 回收,但样本必须保留为失败。 + +真实破坏传输与正常 detach 的退出码分开验收:master 关闭后客户端必须 +在期限内自主退出/被原 owner 回收,但不强求退出0;记录实际非负退出码及 +`client_exit_clean`。输出flush或termios对已销毁终端失败可能导致非零, +不能为满足测试吞异常。超时、信号终止、fallback及未知清理不能因此通过。 +这只记录客户端结算,不表示客户端正常退出或后台任务成功;后续原任务/ +实例连续性、中断/下一轮及精确stop事实必须独立满足。正常detach仍要求0。 + +验收顺序:delayed 任务已接纳且 producer 未结束 → 关闭原 PTY transport → +原客户端结算 → 认证查询同实例/成员/Session 仍 running,producer 未结束 → +另一 cwd 正常重连 → 中断原调用并验证 producer 结算 → 下一轮唯一回复 → +精确实例 stop、三事实与外层 owner 结算。另补单次关闭、fd 复用失回执、 +reader 查询在途和 join 超时反例。原 driver 与独立 abrupt context 已实现, +入口为 `managed-product-hangup-interrupt-next-turn`,仅诊断、保持 valid=False。 +driver/context 真实 EOF 及接线负测已通过。当前冻结安装的 +`managed-product-hangup-interrupt-next-turn-673crff_` 已验证断连后重连、 +中断与唯一下一轮回复;`managed-product-hangup-natural-completion-ypei8ch9` +已验证原任务断连后自然完成、异 cwd 取回结果且不重发原请求。两者均完成 +精确实例 stop 和外层 owner 结算;原客户端自主退出120,如实标记 +client_exit_clean=False,不宣称终端恢复成功。结果均为功能诊断、valid=False, +不能充当正式性能样本或完整 SSH 协议测试。证据位置见推进记录。 + +## 边界与复用 + +落实主设计 §9/V10,不以 pytest 总耗时或旧 G18 数字替代 lmux 数据。 +沿用 `scripts/dev/measure_g18_native.py`、`tests/coding/_g18_native_probe.py` +及原安装槽、配对顺序、断点恢复、来源/字节核验和 PTY owner。 +增加显式选择的 managed 场景;原 CASES 默认集合与已有报告含义不变。 +观察器固定安装,与被测安装分离;不在 Product 加计时开关或跳过权限检查。 + +## 分开回答的问题 + +1. `managed-mux`:实际安装的短入口 `lmux new -s perf` 冷启空 Mux; + 首个完整可用空态帧、首次命令补全、`/new user_home` 成员可交互帧、 + 第二个 Tab 建立、detach、从另一 cwd 暖 attach、重连首帧及最终停止。 + 首帧不是 Session ready;本次 membership 成功完成,且输入后的完整当前 + 帧显示预期新 member/session 才记 ready。pending 消失单独不足以证明成功。 +2. `managed-product-first-use`:可信测试 Product 经真实受管启动/IPC 路径, + 分别记录首次合成模型回复、审批、工具效果与中断,不调用外部模型。 + 必须确认现有启动合同能注入该测试 Product;不能偷换成 legacy serve。 + 若现有合同不支持,明确缺口并评审最小测试组合,不能为测量开放任意启动路径。 +3. 长历史:通过公开会话行为预置相同历史,记录输入规模与内容摘要;计时前 + 完成种子准备。暖 attach 同时验证当前 viewport 历史可见、成员身份正确, + 不以空壳 footer 冒充历史同步完成。不编辑受管 registry 或伪造 receipt。 + +补全采用实际键盘输入与当前屏幕建议项的完整帧见证;不能仅测 provider +调用耗时。模型/工具采用确定性测试实现,报告明确不含网络服务延迟。 + +## 证据合同 + +- 所有用户动作使用观察器单调时钟,保留冷启动到首个成员 ready 的端到端值, + 同时拆分阶段;服务内部 ready 不由终端出现推断。 +- 前台进程逐次记录精确 executable/argv/cwd;status 仅为 recorded_only + 登记补充,不证明存活。冻结原 service/instance,通过公共 probe 的精确 + 实例认证只读查询核对 application/Mux 身份;成员另由原精确连接只读 + 查询核验,不 attach/夺控制器。原 lease/journal 结算后才交付纯值证据。 + 不将前台 spawn 数当作全部后台进程数。 +- 跨 cwd 重连用实际不同目录并核验同一实例/成员;为新场景增加明确 cwd + 校验分支,不放松其他场景的固定 cwd 规则。 +- 首次补全、成员、模型、工具必须基于输入后完整当前帧或确定性效果见证, + 排除历史输出命中。失败、超时、清理债务与预热均保留,不筛掉慢样本。 +- detach 后认证确认服务存活;最终仅 stop 本样本私有命名空间,结算失败 + 则样本无效,保留有界诊断。记录完整 termios/光标/粘贴模式恢复。 +- 延续原 owner 的异常清理和总期限;不新增裸 PID kill 或无主后台进程。 +- 最终成功要求原实例的退出、应用清理、native scope 结算三事实,以及原 + 采集 owner 的物理结算。外层强制回收不能补成正常 stop;带债时不得换 + 安装槽、删样本根或写可恢复检查点。首次 Product 场景必须先冻结可信 + 受管测试组合;旧 legacy `_product/_serve` 不能作为它的替代证明。 + +## 比较与判定 + +先在同一冻结 wheel 做 A/A 验证采集稳定性,再做冻结版本的 A/B;两侧均须 +支持被选 managed case,不对不含 lmux 的旧版本强行补入口。冷启与暖复用 +是不同场景,可并列报告,但不能直接宣称为代码优化百分比。 +实施前须补齐 managed 独立里程碑闭集、每项起终点、第二 Tab 暖 new、 +spawn 至首次模型的累计值以及长历史规模/种子,作为本设计的评审增补。 +原 comparison 现已接入 managed-mux 九指标的独立比较分支与拒绝测试, +旧 NATIVE_METRICS 七项保持不变;首次 Product 八指标闭集及组合校验已接入 +(见下方增补),最新安装组合预验仍在进行。长历史正式比较接线及其限定 +三视角复审已完成;首轮冻结 A/A 在首次预热失败,正式比较尚未取得有效结果。 +不能直接把新 case 的诊断采集成功记为比较通过。 +正式采集沿用原20对样本、稳定性与回归判定规则;小样本只作诊断,任一 +失败/缺失/清理债务不允许通过过滤变成 pass。指标增补通过前不得关闭 V10。 +沿用现有采集器预热、配对与恢复策略,报告均值、分位数、样本数、 +失败率及逐样本原始里程碑。未冻结正式指标前不承诺提升百分比。 +短入口、首次使用、长历史均有有效配对证据,且无首次使用成本迁移与终端/ +生命周期回归,才可关闭 V10;单独完成 managed-mux 不等于 M4 完成。 + +## 实施门禁 + +三视角设计通过后修改原采集器,先补里程碑缺失、错误安装/cwd/实例、陈旧帧、 +未结算与恢复配置漂移的拒绝测试,再跑真实安装采集。默认旧场景回归保持。 +测量结果与本设计分离存放;三视角实现复审必须同时检查原始证据及采集代码。 + +## 第一个可实施增量:managed-mux 指标候选(待复核) + +该增量只关闭真实短入口的测量接线缺口,不关闭整个 V10。单独的比较入口 +仅接受本场景精确指标集合,调用原 `_g18_comparison._compare` 统计策略; +不扩充旧 `NATIVE_METRICS`,不改变原七项全量判断,也不另写统计实现。 + +| 指标(均为 seconds) | 起点 | 终点见证 | +| --- | --- | --- | +| cold_frame | spawn 短入口前 | 首次完整空态帧,perf Mux 名与可执行 /new 提示 | +| first_completion | 输入 /he 前 | 输入后的完整当前帧,补全面板候选行显示 /help;不匹配常驻 footer | +| first_member_ready | 提交 /new user_home First 前 | 成功新增的精确成员/Session 与当前帧一致 | +| cold_through_first_member | 同 cold_frame 起点 | 同 first_member_ready 终点,含首个补全操作 | +| warm_member_ready | 提交 /new user_home Second 前 | 第二个精确成员出现,第一成员身份不变 | +| detach_settlement | 输入 Ctrl+B d 前 | 原前台退出0且原 PTY 完整恢复 | +| warm_attach_frame | 不同 cwd spawn attach 前 | 同实例/Mux的两成员完整当前帧 | +| reattach_detach_settlement | 重连端 Ctrl+B d 前 | 重连前台退出0且其 PTY 完整恢复 | +| stop_settlement | 提交私有命名空间 stop 前 | 三事实 stopped 与样本采集 owner 物理结算 | + +表中指标实际键统一添加 `_seconds`。两次会话命令均通过原作用域目录查询 +取得其 scopeFingerprint;不能凭 First/Second 标题作为唯一身份见证。 +认证探测安排在首成员终点之后;原终端收到并呈现成功结果才是终点, +额外探测用于判定样本真实性,不倒填时间。cold_through_first_member 是 +连续墙钟窗口,不扣除中间探测、清空补全或其他操作的任何开销。 +stop_settlement 由外层 collector 完成:probe 发布 stop 起点及三事实 +观察,原 owner.run_python 成功返回并物理结算后,collector 补记终点, +再验证完整指标集合。probe 内不能声称自身已物理退出;异常/强制回收/ +债务不能产生成功终点,也不得为取时间提前释放 owner。 +补全后通过真实键盘取消建议并清空输入,再提交 /new;无固定 sleep。 +每侧每块一对预热、十对正式样本,共两块20对;warm/absent 字节码条件 +独立比较。原稳定性、回归阈值原样沿用,无额外提升目标;均值为描述统计, +不改变以配对完整性及原中位数规则作结论的合同。 + +首次模型/工具和长历史指标仍须独立补齐并评审;不以此表覆盖它们。 +已检查 `_managed_product_child.py`:它能组合真实 ManagedChildBootstrap +与 CodingManagedLocalCommand,但 `_managed_starter.py` 消费预置 journal +与固定测试机器身份,且 Product tools=[]。因此不能直接拿该旧 fixture +证明短入口自动创建/发现或首次工具;后续复用组合点,不能复用伪造准入种子。 + +## 首次 Product 使用:可信测试组合候选 + +状态:固定测试组合与若干功能诊断已实施、局部评审并在冻结安装运行; +正式性能 case、指标闭集与完整配对仍未完成。具体实测项见推进记录, +不能以诊断 valid=False 的报告关闭本节。与 `managed-mux` 分开报告; +不能用本节测试组合替代安装短入口验收,也不能把合成模型耗时解释为在线 +供应商延迟。生产入口不增加任意 executable、模型注入或测试环境开关。 +本节结果标为“真实受管基础设施+固定测试 Product”;测试父端启动到 +回复的累计值不是生产 CLI 启动耗时。观察器、测试 helper 与被测安装分别 +冻结来源,禁止从活跃工作树导入 Product。 + +测试父端从原 `resolve_managed_defaults` 得到真实机器与默认 namespace, +按 CLI 原有顺序执行 namespace admission、精确命名意图 reservation、 +service admission 与 `ManagedMuxCreateOperationV1`,由原 Coordinator / +Starter 完成新服务登记和进程交接。使用已有 +`ManagedLaunchRequestFactoryV1` 组合固定且冻结的测试 child,不预置 +journal、实例 ID、native identity 或认证 receipt。 +父端在首次 IO 前保管 namespace/journal/create operation;取消或丢失 +回执仍结算原任务,不重发 create,也不提前关闭被借用的存储。 +request_factory 使用被测安装的 Python,保留原 invocation、继承 descriptor、 +cwd 和环境覆盖规则;固定 child 的摘要进入 helper 证据,不使用观察器 +安装的 Coding 执行被测 Product。 + +父端实现同样复用原流程:固定测试入口只接受 `new -s perf`,核验被测 +安装后,在专用测试进程内替换 `lmux_command` 的启动请求工厂名称并调用 +原 `lmux.main`,finally 恢复。固定工厂先生成原生产 request,仅把 argv +入口换为同一被测 Python `-I` 和冻结 child 脚本;不复制准入、连接、退出 +或清理代码。观察目录由驱动在计时前明确准备,不作为登记或权限事实。 + +child 复用 `ManagedChildBootstrapV1`,仅在 +`CodingManagedLocalCommandV1(model=..., stream_fn=..., tools=...)` 选择 +确定性模型与测试工具。保留生产配置的 store_state_root、managed Mux +binding、output capture factory 与 diagnostics;模型替换不能顺便删去 +会话存储、输出管理或生命周期成本。测试工具走真实授权与执行接口, +拒绝审批时不得产生工具效果;成功效果必须与本次调用 ID 对应且只发生一次。 +child 保留原 socket 收养及 `open → bind → run_process → close` 顺序, +保留原失败退出与永久 pending 处理,不另建简化的事件循环或清理 owner。 +拒绝审批同时核验实际执行后端未进入、无附件及其他工具效果。 + +子端实现进一步收敛为固定测试脚本调用被测安装的 +`coding.managed_process.main(argv)`,只在该测试进程内将模块中的 +`CodingManagedLocalCommandV1` 构造名称替换为原类的固定 partial。 +保存原类,原 launch/output_capture_factory 参数不变,返回真实原类实例; +不替换 bootstrap、journal、认证、授权或清理方法。以此复用生产全部 +构造和异常出口,不复制子端生命周期。测试须覆盖正常及构造失败路径。 +不能直接导入 `_hosted_product_child.py`:其顶层 `install()` 会修改生产 +类方法。确定性模型/工具须独立且无导入副作用,固定 helper 摘要纳入来源。 + +候选观测窗口: + +- 提交首条输入到当前帧完整预期回复可见且对应 turn 已完成;同时保留 + 新服务启动到该终点的累计值。字符串命中或流式 draft 含全文均不足。 + 本指标仅称“用户可见回复完成”:新Session、本轮唯一nonce,无额外 + 输入或中断;同一完整当前帧核对回复与精确目标Tab非running,且无 + 快照失效、错误或unknown。随后显式detach再用原public attach/snapshot + 核验同instance/Mux/member/Session、恰一正式预期assistant记录及 + running=False。快照需要控制器,不能在终端仍连接时偷偷attach观察。 + 后验只能接受/否决,不修改先前时间戳,不证明该时刻native物理结算。 + 必须用“完整文本已流出、最终消息仍阻塞”的负例验证不会提前记完成; + 无须强求快速请求曾实际渲染过running帧。 +- 提交工具请求到待办提示可见、真实 F2 或 `/question` 打开详情到完整 + 详情展示、确认审批到确定性工具效果和完成结果可见,三段分别记录。 + 保留动作起终点,不把用户或观察器操作等待隐藏成模型/工具执行耗时。 +- 提交可控长任务到接纳见证,再中断到执行槽可再次使用;接纳和清理必须 + 有协议/效果证据并确认原 producer/task 结算,不能仅靠 idle 文案或 Ack。 + 终点还须同一 Session 完成下一次唯一 ID 的调用,而不只是再次被接纳; + 下一次调用成功本身也不能替代原执行及 native IO 的清理见证。 + +观测均复用原 collector 时钟、PTY 当前帧与精确实例只读核对。先冻结 +字段闭集、每项终点及负测,再扩展显式 case;不得放松原默认七项和 +managed-mux 九指标合同。原理验证不直接进入正式性能比较。 + +长历史另作种子方案:通过公开会话调用生成固定规模与内容摘要,完成 +种子后再计暖 attach;核对持久化的原 Session 身份与当前 viewport 中的 +指定历史内容。诊断配方的条数、字节数、轮次与摘要已实现并取得旧安装单次 +通过证据(见下);正式配对及最新安装重验仍未完成。 +暖重连与停止后重新加载分开测量;不通过直接填 JSONL 或复制 registry +替代公开种子流程。工具实际 handler 产生独立效果见证,模型回显相同 +字符串不算执行成功;审批须经过真实待办、详情展示与回应。 + +### 长历史实施合同(已有局部复核与安装诊断,未完成正式验收) + +采用固定测试Product,不连接网络模型;沿用受管启动、公开AppServer请求、 +原evidence owner及PTY采集器,不新增存储写入接口。历史配方 +`lmux-history-128x2048/v1`:128轮串行调用,每轮USER文本为 +`history NNNN`(0000至0127,12个ASCII字节),ASSISTANT文本恰2048个 +ASCII字节,含轮次、Markdown标题/列表/代码块及唯一尾标记,其余为确定性 +正文。配方实现必须给出逐字节规范和测试向量后才冻结,不能采集后修改。 +256条USER/ASSISTANT文本合计263680字节;不把序列化JSON或元数据计入该 +文本规模。摘要为按顺序的`[kind,text]`数组,kind严格为`user`或`assistant`, +以UTF-8、`separators=(',', ':')`、`ensure_ascii=False`的JSON编码计算SHA-256, +编码末尾不加换行,文本内换行固定LF。配方须带独立已知摘要向量;记录 +配方版本、轮次、记录数、文本字节数和摘要。此规模只代表约258KiB文本 +及有界尾窗呈现,不声称全历史同时进入终端。 + +配方实现候选为`tests/coding/_lmux_history_recipe.py`:每轮正文重复 +`history-NNNN deterministic evidence. `并按字节截到固定尾部之前;尾部为 +空行、二级标题、带轮次列表项、text代码块和`LMUX_HISTORY_NNNN_END`。 +换行及空行以该纯函数和独立固定向量测试为准。完整256条规范JSON的候选 +SHA-256为`00e1c01bb0a4603b94f5fbd70ea802f24a9389471893e5883310ba7c0c0fbb41`。 +已测精确规模/摘要及早期记录删改而尾窗不变的拒绝分支;公开 seed 接线、 +真实终端尾窗与 Markdown、原服务干净停止后重新加载均已在 wheel +`79c3deda35aed72100c6658d52a725d581cae67ff3045a5e1c7f24bfff518990` +取得单次安装诊断通过。记录位于 `.artifacts/lmux-history-install.LWJpGM/` +下 `history-warm-gtf_wyxv` 与 `history-warm-1_8nc_ot`。它们仍是 +valid=False 功能诊断,不是正式性能样本,也不覆盖后续生产修复。 + +种子准备不计入暖attach耗时,但单独记录真实耗时和终态。通过现有认证 +连接attach取得controller generation,逐轮start_turn仅发送一次,必须先 +await合法Ack,但Ack只代表请求应答。随后在同一controller和同一本轮 +绝对期限内只读轮询snapshot,要求running=false、同一完整Session身份, +尾窗中本轮USER/ASSISTANT精确且唯一,才发下一轮;旧轮idle不能满足本轮。 +实际Coding snapshot限制总文本16384字符、至多128条并可包含省略标记, +不能要求每轮返回完整前缀,不能为测试调大限额。完整历史摘要使用独立 +公开`loushang.harness.transcript.load_agent_transcript_file(path, +read_only=True, max_bytes=2097152)`,通过原typed codec及 +`AgentTranscriptProfile.default().replay(records).messages`核对完整消息。 +从本样本明确私有canonical Session根定位,header与已认证完整Session +身份必须匹配,不按mtime最新文件或标题猜选。精确stop和本地owner结算 +后才只读持久文件;超2MiB失败而不是临时放大上限。禁止默认repository +加载引入锁写入,不用bundle export,也不以测试私有JSONL解析兜底。 +这是canonical持久化证据,不伪称AppServer全文导出;合法尾窗省略不等于 +持久历史丢失。 +丢回执、超时、重复/缺失/额外本轮记录直接 +使样本失败,不重发,不修复JSONL,不复制registry或伪造运行状态。准备 +完成必须detach并关闭原连接,才启动计时的真实PTY。原helper从attach前 +即保管连接;失去attach/detach回执不能凭无attachment ID称clean。任何 +seed/读取/关闭失败停止后续轮次及计时,保留失败并交原精确stop/outer结算。 +候选实现预算:全seed最多600秒,每轮最多40秒并受全局剩余期限约束, +每轮最多80次snapshot,间隔0.1秒;初始fresh检查单独一次且计入全局预算。 +每次轮询先沿同一attachment/generation读取事件,最多64次请求、每次请求 +上限64条;协议可能每帧只返回1条,只有空批次才完成消费并读取snapshot, +短批次不能视为空队列,次数耗尽直接失败; +二者共用本轮绝对期限。事件只用于消费正常客户端mailbox及校验当前身份/ +错误,不代替snapshot成功判据。失回执、取消、lagged或身份错误直接失败, +不重新attach、不重发turn、不扩大默认mailbox,且不新增后台读取任务。 +所有操作含sleep均使用同一本轮绝对期限,poll不刷新期限。读取条数和文本 +受现有snapshot上限约束。这些仅为测试侧seed预算,不放宽生产操作期限。 +外层连接观察器的既有短deadline尚未接入该长流程,不能绕过原owner直接 +宣称真实播种可用;需显式整合并复审总预算/关闭语义后再开启采集。 + +分开两个显式场景,均使用独立新根、同配方公开准备,不混合统计: + +- `managed-history-warm`:服务保持运行,异cwd实际`lmux attach -t perf`。 + 末轮结尾布局须冻结在100×30终端的指定viewport内,同一当前帧必须验证 + Markdown标题及列表/代码块的预期呈现、末轮尾标记、正确成员与idle; + 尾标记存在但Markdown退化原文不算完成。从实际spawn到该完整帧为 + `history_frame_seconds`;随后固定键盘补全到可见建议项为 + `history_completion_seconds`。正常detach、四类终端结算、同一原生进程 + 身份及有界尾窗后验、独立canonical全史摘要、精确stop/原owner结束均是 + 有效性前提。 +- `managed-history-restore`:公开准备后先精确stop且原owner本地结算; + 保留原成员,使用`lmux start -t perf`后`lmux attach -t perf`,通过现有 + Hosted continuity/Catalog恢复原Session,不额外`/resume`已恢复会话。 + 本场景恢复原continuity持久Mux/member引用,而非新建替代引用: + `appservice/continuity.py`的原编码/解码保存`muxSpaceId`、`memberId`及 + Session完整身份,恢复收养后必须通过新代认证重新核对它们一致;不得 + 跨代复用旧attachment/controller generation或旧服务连接。要求新 + service instance/native identity确实不同。从start实际spawn到attach历史 + 完整可见帧记`restored_history_frame_seconds`,覆盖两进程及中间等待的 + 连续墙钟时间,保留两个原owner;不得只测attach而称为重启恢复耗时。 + 不把进程重启描述成原进程仍在。固定测试父入口白名单支持`new -s perf` + 与`start -t perf`,仍只替换原request factory, + 不加生产启动开关,不用私有存储注入代替恢复。 + old/new两代各自保留不可变身份和原stop/adopted owner;同一namespace、 + service配置及Session数据根不得切换。old停止三事实与全部连接本地结算 + 后才spawn新代,不重置旧stop_attempted或复用旧owner。PID可复用,但 + 完整native身份须变化;new代最终精确stop和外层结束不能由old成功替代。 + +两个场景复用原统计规则,独立A/A再A/B;完整单次预检不等于配对通过。 +必须补负测:错轮次/摘要/字节规模、仅footer或旧帧可见、历史截断、Ack后 +仍运行、身份替换、seed连接未关闭、终端未结算、重启后沿用旧进程身份。 +在完整合同及实现评审通过前,不把这些case加入正式CLI可选集合。 +另补正确尾标记但Markdown原文/布局错误,以及尾窗正确但早期canonical +记录被删改;必须分别由当前帧和独立全史摘要拒绝。 + +## 正式首次使用组合:字段闭集增补(三视角设计复核后修订) + +架构、生命周期、UX 三视角确认该闭集可实施;复核指出的终端计数遗漏 +已改为逐个实际终端验证。此处为设计结论,不表示实现或正式配对已完成。 + +沿用一个原 collector 样本及其外层 evidence owner,显式 case 为 +`managed-product-first-use`。顺序执行 reply、approval、interrupt 三个独立 +fresh 子场景,各自使用私有根、新服务及新 Session;前一场景精确停止且原 +本地资源结算后才能进入下一场景。不把一次普通回复后的暖 Session 当作 +首次工具,不将不同服务的分段耗时相加伪称连续墙钟时间。每个子场景保留 +原 first_reply / observed_terminal / AdoptedLeader / exact_stop 生命周期。 +既有诊断入口和 valid=False 含义不变,长历史另行验收。 + +候选精确八指标(键均加 `_seconds`): + +| 键 | 起点 → 终点 | +| --- | --- | +| visible_reply | 首条输入写入前 → 唯一回复及 idle 的完整当前帧 | +| fixed_entry_through_visible_reply | 固定测试入口 spawn 前 → 同一回复终点,包含成员创建和认证观察开销 | +| approval_pending | 工具请求写入前 → 同目标待办完整当前帧 | +| approval_details | 打开详情前 → 完整详情当前帧 | +| approved_tool_reply | 批准写入前 → 工具结果及 idle 完整当前帧 | +| interrupt_through_idle_and_producer | Ctrl+C 写入前 → idle 且原 producer 结算见证核验完成 | +| next_reply | 下一轮唯一输入写入前 → 该轮唯一回复完成当前帧 | +| interrupt_through_next_reply | 同 Ctrl+C 起点 → 同下一轮回复终点,包含中间核验和输入准备开销 | + +八指标不包含在线模型延迟;固定入口累计不是未经测试替换的 CLI 启动 +指标,中断终点也不是纯 idle 渲染时间。原始动作保留同一观察器单调时钟 +的 started_at/finished_at,要求有限、非负、有序且 duration=end-start; +累计指标共享原始端点,禁止通过相加分段遗漏中间开销。 + +报告按三个命名子场景闭合,不接受任意诊断字段集合。每个子场景必须保存 +实际 spawn 的 executable/argv/cwd/start、member/认证/输入/当前帧完成、 +detach 结算、后验 snapshot、stop 观察的顺序和证据。身份闭集包含 +instance/service/Mux/member/Session、请求 nonce 与原 native identity。 +普通回复必须恰两条 USER(reply nonce)/ASSISTANT(expected),不只数 assistant。 +工具保存同一 call 的原效果证据和批准时刻,批准前零效果、stop 后恰一次, +且效果不早于批准;中断保存原 producer 的前未结算/后已结算与唯一 B。 + +所有实际创建的前台终端,包括 interrupt 初始端与异 cwd 重连端,均须 +退出0、完整PTY恢复、reader结算且无fallback;按完整spawn/terminal列表 +逐个验证,不按三个子场景只计三次终端(当前组合至少四次)。前一子场景 +最终stop及本地资源结算终点不得晚于下一子场景首个spawn。精确实例stop +三事实及原外层owner物理结算也是整个样本的资格门槛,而非仅有duration +即通过。stop 起点及三事实观察时刻 +需保留;只有 collector 的原 run_python 成功返回才能追加外层结算终点, +probe 不宣称自身已退出。后验只接受/否决,不能倒填可见帧时间。任何 +子场景、后验或最终清理失败均使整样本无效,不换槽、不写可恢复检查点。 + +复用原精确 inventory 比较入口与统计策略,保持 managed-mux 九指标和 +旧七场景不变。正式晋级负测至少覆盖缺失/额外/非有限字段、乱序/错duration、 +早于detach的snapshot、错身份/nonce或额外记录、提前/重复工具效果、 +提前producer结算、假stop、外层失败及已有成功字段后的晚清理失败。 +先完成本增补复核,再实施接线与真实冻结安装 A/A;不得据此关闭全部M4。 + +## 长历史正式晋级增补(接线与限定复审完成,正式采集待通过) + +沿用原两个诊断名称作为显式 optional case,不修改默认七场景、managed-mux +九指标或首次 Product 八指标。warm 比较 `history_frame_seconds`(真实 attach +spawn 到完整历史帧)和 `history_completion_seconds`(输入 `/he` 前到完整 +补全帧);seed 不计入两者。restore 只比较 `restored_history_frame_seconds`, +直接采用行式 start 的真实 spawn 与新代历史帧两个原端点,包含启动、认证和 +attach 准备间隙,不相加分段;恢复补全仍必须成功,只是不进入该指标。 + +正式 validator 独立核对以下证据,而非信任 helper 内的断言: + +- 原配方128轮、256消息、263680文本字节及固定摘要;近期15条尾窗严格是 + 14条 USER/ASSISTANT 加1条 omitted STATUS,不将尾窗冒充完整历史。 +- seed最后一轮完成 → detach Ack → 原连接/journal/namespace结算 → warm + attach;旧代stop/local-owner结算 → canonical读取完成 → 新代spawn。 + 新代也必须stop、canonical及原outer结算,不能用旧代回执顶替。 +- 每代认证target、ready、snapshot与完整五字段Session身份相互绑定;同服务 + 与Session,不同instance/native。canonical绑定实际读取的根和Session选择, + 不以另一次事后pathname stat冒充原读取身份。 +- warm两个TUI;restore旧代两个TUI、新代一个行式start和一个TUI。每个真实 + 终端独立核验PID/argv/cwd、退出0、termios、reader及无fallback。行式命令 + 不要求也不伪造cursor/paste标记,TUI仍保留完整恢复门禁。 + +outer预算必须按串行阶段上界核算,覆盖原600秒seed、认证、全部终端、两代 +停止与后验读取,不延长内部期限。原run_python返回后追加outer结算,再经 +完整校验和固定槽来源复验才能valid=True;helper始终False。负测包括迟到 +seed/连接结算、旧canonical未完成即重启、start关闭失败、错误服务/同代身份、 +新旧摘要不一致、跨代回执串用、错误累计起点及任意stop/outer失败,均不得 +晋级正式样本或checkpoint。仍沿用原20对与失败保留规则。 diff --git a/docs/zh-CN/user-guide/README.md b/docs/zh-CN/user-guide/README.md index 8887f7bd6..c0b9a5f06 100644 --- a/docs/zh-CN/user-guide/README.md +++ b/docs/zh-CN/user-guide/README.md @@ -34,6 +34,8 @@ loushang -p "Summarize the current project." 如果要用 `loushang.tui` 构建终端 UI 应用,见 [构建 TUI 应用](tui.md)。 +Linux 后台命名 Mux 开发预览见 [lmux 使用说明](lmux.md),包含目录、重连及升级限制。 + ### 显式 Hosted Application `loushang-hosted` 是供应用启动器连接的前台 stdio 服务,不是新的交互式 prompt loop; diff --git a/docs/zh-CN/user-guide/lmux.md b/docs/zh-CN/user-guide/lmux.md new file mode 100644 index 000000000..33a0e0cef --- /dev/null +++ b/docs/zh-CN/user-guide/lmux.md @@ -0,0 +1,142 @@ +# Linux lmux 预览 + +[English](../../en/user-guide/lmux.md) | 中文 + +本文对应当前开发分支的 `lmux`,不代表完整交付验收已经通过。运行 +`lmux --help` 确认安装版本提供哪些命令;旧安装可能尚无本文中的入口。 +当前自动后台仅支持 Linux,不提供跨机器连接或 GUI。实际暂存配额接线、 +完整 Harnesstui 能力对齐及最终安装/断连/性能验收仍在开发中。 + +重连时界面只呈现有界的近期历史尾窗,不会一次加载全部历史;未显示的早期 +内容并未删除,持久化 Session 历史仍按原存储策略保留。 + +## 创建与重连 + +在要执行工作的目录运行: + +```bash +lmux new -s dev +``` + +命令会启动或复用该工作区服务,创建名为 `dev` 的空 Mux 并进入终端。 +在界面中用 `/new cwd 工作会话` 创建第一个 Tab;`/help` 显示当前支持的操作, +`/resume` 用于发现已有会话。空 Mux 不会自动调用模型。 + +输入 `/detach` 只离开客户端,不停止服务。重新 SSH 登录同一台机器后, +在任意目录运行: + +```bash +lmux attach -t dev +``` + +Mux 名是当前机器和用户命名空间内的全局名字,不需要 `server:mux` 前缀。 +同工作区的多个 Mux 共用服务,一个 Mux 可有多个 Session Tab。已有控制器 +时不会抢占;断连权限释放前可能返回 busy。`attach` 不隐式重启离线服务, +需要重启时显式运行 `lmux start -t dev`;只有确认前代已干净停止,才允许 +启动新实例。异常退出或清理证据不足时会拒绝,`start` 不绕过这些检查。 + +后台服务不依赖原 SSH 终端,已接纳任务按服务寿命继续执行;这不意味着 +机器重启、进程崩溃或主机策略清理用户进程后仍能保住执行中的任务。 +允许的服务重启恢复持久 Mux/Session 状态,不自动重放丢失回执的请求。 + +裸 `lmux` 在无 Mux 名称登记时于 cwd 创建默认 `main`,无目标 +`lmux attach` 此时报告 not_found。整个命名空间恰有一条 Mux 登记,且 +该条属于 Coding、已提交、未请求停止或干净停止时,两条命令直接尝试 +认证连接;多个候选使用有界只读探测,按服务认证、逐个读取精确 Mux ID, +不 attach 或申请控制权。仅一项确认存在、无未知候选且集合未变化时自动 +选择,否则显示冻结快照的选择器:`n` 下一页、`r` 首页、`f` 显式刷新并 +重新探测;翻页不追加探测。探测连接先关闭,最终连接再次认证并核对实例 +和 Mux ID。登记状态不保证在线,最终连接失败 +不会自动重启或改选;pending 名称登记也不会被当作空列表。 +进入终端的命令要求 stdin/stdout 都是 TTY,不支持把 prompt 管道送给 `lmux`。 + +### 恢复中断的创建 + +`new`(包括裸 `lmux`)在预留名称前输出 JSON `planned_creation`,包含原始 +`serviceId` 和 `operationId`。它**不是成功回执**,也不证明名称预留已提交。 +`lmux ls` 同时显示已保留名称的 `creationOperationId`,便于找回原操作。 + +```bash +lmux create-status --server SERVICE_ID --operation OPERATION_ID +lmux create --server SERVICE_ID --operation OPERATION_ID --continue --yes +``` + +请替换为原始精确 ID;这里不接受服务别名。`create-status` 和不带 +`--continue` 的 `create` 只读取持久事实,不连接、不启动、不签发许可,也不重发创建。 +`unknown`(退出码 1)不等于原请求未生效;`created` 只是历史回执,不证明 Mux +仍打开或服务在线。 + +显式 `--continue` 确认原名称、工作区和操作,可启动或复用同一服务,最多发送一次 +同 ID 的幂等创建 RPC,不换新操作、不删除预留、不自动进入终端。非 TTY 继续须 +加 `--yes`。已有回执时无需 RPC;否则继续成功后可用 `lmux attach -t NAME` 重连。 +原有干净停止和恢复检查仍生效:前代许可缺少充分持久创建历史时拒绝继续,不猜测 +可以重放;已关闭释放的操作不能重新占用复用名称。再次 `new -s NAME` 仍是冲突, +不是恢复动作。 + +## 提前启动服务 + +```bash +lmux server start --name build --workspace /absolute/path/to/project +lmux status +lmux status --server build +lmux logs --server build --limit 20 +``` + +将示例路径替换为已有工作区。`server start` 不创建 Mux 或 Session,可在 +非 TTY 中调用;`--name` 可省略。成功结果包含精确 `serviceId` 和 `instanceId`。 +同别名、同工作区重复启动会复用;别名绑定其他工作区,或同服务使用第二个 +别名时会报冲突,不自动改绑。服务别名和 Mux 名是两个独立名称空间。 + +显式限时诊断使用 `lmux server start --trace-for 60`,范围为 1–3600 秒。 +期限从命令准备时计算,不从服务就绪时重新计算;仅本次新启实例能够应用, +复用服务不会替换或续期原 trace。内容限于有界耗时聚合和固定问题码,不含 +提示词、回复、工具正文或凭据。 + +JSON 将服务 `service_ready` 与 `trace.status` 分开:`applied` 仅证明历史 +配置成功,不保证持续写入;`expired` 表示该配置已到期; +`not_applied_reused_instance` 表示本次请求未配置被复用的实例; +`not_confirmed` 表示启动预算内未确认匹配事实;`observation_failed` 使用 +独立安全 `errorCode` 报告观察失败,同时保留已认证的服务和实例结果。 +`deadlineMs` 使用本机单调时钟,不是 Unix 时间。显式请求 trace 时,只有 +`applied` 返回退出码 0,其余 trace 结果返回 1,即使服务已经就绪。 +trace 失败不会停止已就绪服务。 + +`lmux ls` 列出 Mux 名称登记;`lmux status` 也能列出没有 Mux 的服务。 +状态标注 `recorded_only` / `not_probed`,不是在线检测。日志读取只提供有界 +生命周期尾部,不是完整历史,也不包含对话正文。诊断命令不会启动服务。 + +## 三种结束方式 + +| 操作 | 影响 | +| --- | --- | +| 界面 `/detach` | 只离开客户端,服务与会话继续存在 | +| `lmux close -t dev` | 确认后关闭该 Mux 及其活跃成员,保留 Session 历史 | +| `lmux stop --server build` | 确认后停止该服务,影响它承载的全部 Mux | + +`lmux stop --all` 会先冻结本命名空间的目标并确认,不是停止机器上所有 +Loushang 进程。非交互 close/stop 必须显式加 `--yes`;它不代表强杀或绕过 +清理。无目标 stop 不会按 cwd 猜测。未完成关闭返回的 operation ID 和后续 +命令应原样保留,不自行换 ID 重试。历史 close 对账命令仍要求精确 service ID。 + +## 默认目录与升级注意 + +- 管理状态及有界生命周期日志:默认 `~/.loushang/lmux/machines//`, + 服务文件按精确 service ID 隔离;`lmux status --server build` 可查看实际路径。 +- 认证连接与运行控制:平台 runtime 下的私有 `lmux` 命名空间。 + `LOUSHANG_RUNTIME_DIR` 可显式覆盖,不要把这里当作可随手清理的缓存。 +- 持久准入见证:`$LOUSHANG_HOME/state/managed-deployments/` 中保留原部署 + 身份与初始化记录(默认在 `~/.loushang/state/` 下),同样不是可清理缓存。 +- 实例临时根:默认位于服务目录的 `tmp/`;显式 + `LOUSHANG_TMPDIR` 优先。当前这不等于所有工具输出都已受强制磁盘配额管理。 +- Session:沿用原 Session 存储策略,默认 `$LOUSHANG_HOME/data/sessions`; + `cwd` 和 `user_home` 是发现范围,不是迁移会话到 lmux 目录。 + +`LOUSHANG_HOME` 默认 `~/.loushang`。重连时须使用相同用户、机器及目录覆盖; +切换这些设置会选择不同命名空间,不能用于绕过旧实例的停止或清理。 + +当前开发版受管 Registry 格式为 **14**。旧预览格式会明确拒绝,当前没有 +自动迁移命令。不要删除 registry、锁、runtime 或改版本号来“修复”此错误。 +保留原状态,使用匹配旧格式的版本处理旧服务,并等待明确的升级方案。 +这不影响旧 `loushang-mux` 显式参数入口;旧入口不会自动登记或接管为受管服务。 + +开发与验收状态见 [lmux 合同记录](../../internals/architecture/apphost/lmux-contract-m0.md)。 From 714789430e77d81ffb309baebe8c0b46278ec1a9 Mon Sep 17 00:00:00 2001 From: zhnt Date: Sun, 20 Sep 2026 10:55:13 +0000 Subject: [PATCH 11/44] fix(harness): create private directory ancestors at 0700 Path.mkdir(mode=0o700, parents=True) and os.makedirs(mode=0o700) apply mode to the leaf only: each intermediate level is created through an internal self.parent.mkdir(parents=True, exist_ok=True) call that drops it, so ancestors inherit the process default (0o777 & ~umask). Under a permissive umask such as 0o002 -- the Debian/Ubuntu system-user default in use here -- those ancestors become group-writable 0o775. Private-directory admission rejects a group-writable ancestor as unsafe, so a caller can build a tree and then be refused by its own admission layer. Any commit that introduces such an admission layer must not inherit 0o775 ancestors from this creation path. Add create_private_directory_chain() as the single implementation of the creation rule, shared by the journal owner and the machine-resource control plane instead of two copies of security-relevant directory logic. The journal lock path uses it directly; prepare_private_directory_chain() remains a thin Product-facing wrapper over it that only fixes the entry contract. The primitive also closes two ways the naive form failed: - A directory symlink satisfies is_dir(), so the previous is_dir()/is_symlink() pair accepted it and continued into the link target. Levels are now classified with lstat(), and the FileExistsError race branch reclassifies the same way rather than trusting a following is_dir(). Symlinks, dangling symlinks and non-directory entries are refused and never followed. - mkdir copies an inherited setgid bit, so under a setgid parent mkdir(mode=0o700) yields 0o2700, which admission rejects for not being exactly 0o700. A level this call created is now tightened to 0o700. Only levels created by this call are touched; an existing directory is never rewritten, since it may belong to another owner or hold data this call did not create. Scope is deliberately narrow: creating the chain is not an admission or ownership guarantee, and the module documents that. Callers that own a specific leaf still tighten and validate that leaf through their own authority. Covered by tests/harness/test_private_directory.py for the rule itself (creation mode, setgid, directory/dangling/leaf symlinks, regular file, both concurrent-creator branches, relative target, home expansion), wrapper-only tests in tests/harness/machine_resources/test_control_plane.py, and the existing integration coverage that journal_file_lock routes through the primitive. Verified on a worktree containing only this commit: the mode regression fails before and passes after, and the new suite passes. Not verified here, and not claimed: this commit does not by itself fix any startup failure, because the writer-admission layer that rejects these ancestors is not yet committed. Not addressed: a user whose own HOME is 0o775 is still refused, because the Product deliberately does not rewrite directories it did not create. Relaxing that is a security-contract decision and remains open. --- src/loushang/coding/cli/application.py | 7 +- src/loushang/coding/cli/multiagent.py | 3 +- src/loushang/harness/journal/jsonl.py | 9 +- .../harness/machine_resources/__init__.py | 2 + .../machine_resources/control_plane.py | 20 ++ src/loushang/harness/private_directory.py | 115 +++++++++++ tests/harness/journal/test_jsonl.py | 62 ++++++ .../machine_resources/test_control_plane.py | 26 +++ tests/harness/test_private_directory.py | 183 ++++++++++++++++++ 9 files changed, 420 insertions(+), 7 deletions(-) create mode 100644 src/loushang/harness/private_directory.py create mode 100644 tests/harness/test_private_directory.py diff --git a/src/loushang/coding/cli/application.py b/src/loushang/coding/cli/application.py index 8b7e12909..3914c1034 100644 --- a/src/loushang/coding/cli/application.py +++ b/src/loushang/coding/cli/application.py @@ -138,7 +138,10 @@ ) from loushang.harness.host.product_host import ProductHostLifecycle, stream_is_tty from loushang.harness.host.rpc import run_rpc_host -from loushang.harness.machine_resources import resolve_machine_resource_layout +from loushang.harness.machine_resources import ( + prepare_private_directory_chain, + resolve_machine_resource_layout, +) from loushang.harness.policy_engine import PolicyEngine from loushang.harness.resources.packages import ( record_package_source_policy_denial, @@ -312,7 +315,7 @@ def default_runtime_builder( resource_layout = resolve_machine_resource_layout(cwd=cwd) platform_sessions = resource_layout.sessions if session_dir.expanduser().resolve(strict=False) == platform_sessions: - platform_sessions.mkdir(mode=0o700, parents=True, exist_ok=True) + prepare_private_directory_chain(platform_sessions) platform_sessions.chmod(0o700) set_authority = getattr(runtime, "set_session_authority_source", None) if callable(set_authority): diff --git a/src/loushang/coding/cli/multiagent.py b/src/loushang/coding/cli/multiagent.py index 5cdec1dbf..68b4cf81d 100644 --- a/src/loushang/coding/cli/multiagent.py +++ b/src/loushang/coding/cli/multiagent.py @@ -31,6 +31,7 @@ write_multiagent_recipe_result, ) from loushang.harness.environment import resolve_platform_paths +from loushang.harness.machine_resources import prepare_private_directory_chain from loushang.harness.multiagent import ( ImmediateRecipeExecutor, RecipeRunRequest, @@ -149,7 +150,7 @@ async def run_coding_multiagent_command( ) platform_sessions = resolve_platform_paths().data / "sessions" if session_dir == platform_sessions: - platform_sessions.mkdir(mode=0o700, parents=True, exist_ok=True) + prepare_private_directory_chain(platform_sessions) platform_sessions.chmod(0o700) tool_registry = build_tool_registry( diagnostics_service=getattr( diff --git a/src/loushang/harness/journal/jsonl.py b/src/loushang/harness/journal/jsonl.py index d8c1bc09f..4d693a963 100644 --- a/src/loushang/harness/journal/jsonl.py +++ b/src/loushang/harness/journal/jsonl.py @@ -31,6 +31,7 @@ JournalLoadPolicy, JsonlSnapshot, ) +from loushang.harness.private_directory import create_private_directory_chain H = TypeVar("H") R = TypeVar("R") @@ -95,7 +96,7 @@ def journal_file_lock( raise TypeError("Journal lock creation mode must be a built-in bool") lock_path = path.with_name(f"{path.name}{lock_suffix}") if create: - lock_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + create_private_directory_chain(lock_path.parent) with _open_lock_file(lock_path, create=create) as handle: if create: _fchmod_private(handle.fileno()) @@ -283,7 +284,7 @@ def append_jsonl_record( durability=durability, lock_factory=lock_factory, ): - target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + create_private_directory_chain(target.parent) existed = target.exists() with target.open("a", encoding=format_profile.encoding) as handle: _fchmod_private(handle.fileno()) @@ -320,7 +321,7 @@ def append_jsonl_records( durability=durability, lock_factory=lock_factory, ): - target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + create_private_directory_chain(target.parent) existed = target.exists() with target.open("a", encoding=format_profile.encoding) as handle: _fchmod_private(handle.fileno()) @@ -791,7 +792,7 @@ def _replace_text_unlocked( encoding: str, durability: JournalDurabilityProfile, ) -> None: - target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + create_private_directory_chain(target.parent) temp_path = target.with_name(f".{target.name}.{os.getpid()}.tmp") try: with temp_path.open("w", encoding=encoding) as handle: diff --git a/src/loushang/harness/machine_resources/__init__.py b/src/loushang/harness/machine_resources/__init__.py index 10a011a4b..dc61711ca 100644 --- a/src/loushang/harness/machine_resources/__init__.py +++ b/src/loushang/harness/machine_resources/__init__.py @@ -27,6 +27,7 @@ inspect_machine_resources, migrate_machine_resources, plan_machine_resource_migration, + prepare_private_directory_chain, resolve_machine_resource_layout, ) @@ -55,6 +56,7 @@ "MACHINE_RESOURCE_SCHEMA_VERSION", "clean_machine_resources", "inspect_machine_resources", + "prepare_private_directory_chain", "migrate_machine_resources", "plan_machine_resource_migration", "resolve_machine_resource_layout", diff --git a/src/loushang/harness/machine_resources/control_plane.py b/src/loushang/harness/machine_resources/control_plane.py index e0c770863..750ae065a 100644 --- a/src/loushang/harness/machine_resources/control_plane.py +++ b/src/loushang/harness/machine_resources/control_plane.py @@ -40,6 +40,7 @@ StoreDataError, load_conversation_deletion_receipt, ) +from loushang.harness.private_directory import create_private_directory_chain from loushang.harness.transcript.jsonl_file import ( AgentTranscriptFileLayout, create_agent_transcript_file_store, @@ -804,6 +805,25 @@ def _inspect_resource_path( ) +def prepare_private_directory_chain(path: str | Path) -> Path: + """Create ``path`` and every missing ancestor as a private directory. + + Thin Product-facing wrapper over :func:`create_private_directory_chain`, + which owns the creation rule and the symlink and race-handling contract. This + wrapper only fixes the entry contract -- an absolute, ``expanduser``-resolved + target -- and returns it. + + Creating the chain is not an admission guarantee: an existing level is never + rewritten or validated here. Callers that own a specific leaf remain + responsible for tightening and validating that leaf. + """ + + target = Path(path).expanduser() + if not target.is_absolute(): + raise ValueError("private directory target must be absolute") + return create_private_directory_chain(target) + + def _authority_path(path: Path) -> Path: """Resolve an authority's parent without hiding a link at the authority.""" diff --git a/src/loushang/harness/private_directory.py b/src/loushang/harness/private_directory.py new file mode 100644 index 000000000..f9e29920d --- /dev/null +++ b/src/loushang/harness/private_directory.py @@ -0,0 +1,115 @@ +"""Product-neutral creation of private directory chains. + +``Path.mkdir(mode=0o700, parents=True)`` and ``os.makedirs(mode=0o700)`` apply +``mode`` to the leaf only. Each intermediate level is created through an internal +``self.parent.mkdir(parents=True, exist_ok=True)`` call that drops ``mode``, so +ancestors inherit the process default (``0o777 & ~umask``). Under a permissive +umask such as ``0o002`` -- the Debian/Ubuntu system-user default -- those +ancestors become group-writable ``0o775``, which private-directory admission +rejects as unsafe. A caller could therefore build a tree and then be refused by +its own admission layer. + +This module owns the single implementation of that creation rule so journal +locking, the machine-resource control plane and any other writer share one +reviewed primitive instead of repeating security-relevant directory logic. + +Scope is deliberately narrow: create the missing levels at ``0o700``. This is +not an admission or ownership guarantee. It does not validate that an existing +level is private, does not claim the chain, and does not serialise concurrent +creators beyond what ``mkdir`` itself provides. Callers that own a specific leaf +still tighten and validate that leaf through their own authority. +""" + +from __future__ import annotations + +import errno +import os +import stat +from pathlib import Path + +__all__ = ["create_private_directory_chain"] + +_PRIVATE_MODE = 0o700 + + +def create_private_directory_chain(path: str | Path) -> Path: + """Create every missing ancestor of ``path`` plus ``path`` at ``0o700``. + + ``path`` must be absolute. Missing levels are created explicitly at ``0o700`` + so a permissive umask cannot widen them. + + Levels that already exist are never rewritten, even when their mode is not + private: an existing directory may belong to another owner or hold data this + call did not create. + + Symlinks are never followed. A directory symlink is a valid directory to + ``is_dir()``, so every level is classified with ``lstat`` and any symlink, + dangling symlink or non-directory entry is rejected instead of resolved. The + returned path is the ``path`` argument, not a resolved target. + + Raises ``ValueError`` for a relative ``path`` and ``NotADirectoryError`` for a + symlink, a non-directory entry, or a filesystem root that cannot be reached. + """ + + target = Path(path).expanduser() + if not target.is_absolute(): + raise ValueError("private directory target must be absolute") + + missing: list[Path] = [] + current = target + while True: + try: + metadata = current.lstat() + except FileNotFoundError: + missing.append(current) + except OSError as error: + if error.errno == errno.ENOTDIR: + raise NotADirectoryError(str(current)) from None + raise + else: + _require_direct_directory(current, metadata) + break + parent = current.parent + if parent == current: + raise NotADirectoryError(str(target)) + current = parent + + for level in reversed(missing): + try: + os.mkdir(level, mode=_PRIVATE_MODE) + except FileExistsError: + # A concurrent creator won this level. It is not this call's to + # rewrite, and a following ``is_dir()`` would accept a symlink, so + # reclassify with lstat and refuse anything that is not a direct + # directory. + try: + metadata = level.lstat() + except OSError: + raise NotADirectoryError(str(level)) from None + _require_direct_directory(level, metadata) + else: + _tighten_created_level(level) + return target + + +def _tighten_created_level(path: Path) -> None: + """Fix the mode of a level this call just created. + + ``mkdir`` masks ``mode`` with the umask, so an inherited bit such as setgid on + the parent is copied onto the new directory: under a setgid parent, + ``mkdir(mode=0o700)`` yields ``0o2700``, which private-directory admission + rejects because it requires exactly ``0o700``. Only levels created by this + call are corrected; an existing directory is never rewritten. + """ + + if os.name != "posix": + return + metadata = path.lstat() + if stat.S_IMODE(metadata.st_mode) == _PRIVATE_MODE: + return + os.chmod(path, _PRIVATE_MODE) + + +def _require_direct_directory(path: Path, metadata: os.stat_result) -> None: + if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode): + raise NotADirectoryError(str(path)) diff --git a/tests/harness/journal/test_jsonl.py b/tests/harness/journal/test_jsonl.py index ad48ba760..23f55852f 100644 --- a/tests/harness/journal/test_jsonl.py +++ b/tests/harness/journal/test_jsonl.py @@ -551,3 +551,65 @@ def test_existing_journal_lock_rejects_fifo_without_blocking(tmp_path: Path) -> with pytest.raises(OSError): with journal_file_lock(path, "exclusive", create=False): pytest.fail("a FIFO lock must never be acquired") + + +def test_journal_lock_creates_private_ancestors_under_group_writable_umask( + tmp_path: Path, +) -> None: + """Ancestors must be 0o700 even when the process umask is permissive. + + ``Path.mkdir(mode=0o700, parents=True)`` applies that mode to the leaf only, + so intermediate levels used to inherit 0o777 & ~umask. Under umask 0o002 + that produced group-writable 0o775 ancestors, which private-directory + admission rejects as unsafe -- a Product could create a tree and then fail + against it. + """ + + import os + import stat + + import pytest + + from loushang.harness.journal import journal_file_lock + + if os.name != "posix": + pytest.skip("POSIX directory permission regression") + + target = tmp_path / "one" / "two" / "three" / "state.jsonl" + previous = os.umask(0o002) + try: + with journal_file_lock(target, "exclusive"): + pass + finally: + os.umask(previous) + + created = [tmp_path / "one", tmp_path / "one" / "two", target.parent] + for directory in created: + assert directory.is_dir(), directory + assert stat.S_IMODE(directory.stat().st_mode) == 0o700, directory + assert not stat.S_IMODE(directory.stat().st_mode) & 0o022, directory + + +def test_journal_lock_never_rewrites_existing_group_writable_ancestor( + tmp_path: Path, +) -> None: + """An existing directory is not this call's to tighten.""" + + import os + import stat + + import pytest + + from loushang.harness.journal import journal_file_lock + + if os.name != "posix": + pytest.skip("POSIX directory permission regression") + + existing = tmp_path / "shared" + existing.mkdir() + os.chmod(existing, 0o2775) + + with journal_file_lock(existing / "state.jsonl", "exclusive"): + pass + + assert stat.S_IMODE(existing.stat().st_mode) == 0o2775 diff --git a/tests/harness/machine_resources/test_control_plane.py b/tests/harness/machine_resources/test_control_plane.py index 80ca734bc..3f0d095a7 100644 --- a/tests/harness/machine_resources/test_control_plane.py +++ b/tests/harness/machine_resources/test_control_plane.py @@ -21,6 +21,7 @@ inspect_machine_resources, migrate_machine_resources, plan_machine_resource_migration, + prepare_private_directory_chain, resolve_machine_resource_layout, ) from loushang.harness.machine_resources import control_plane as control_plane_module @@ -441,3 +442,28 @@ async def create(self, _key, header, records, *, operation_id): assert plan.candidates[0].destination.exists() asyncio.run(scenario()) + + +def test_prepare_private_directory_chain_fixes_entry_contract(tmp_path: Path) -> None: + """The wrapper owns only its entry contract; the primitive owns the rule. + + Creation mode, symlink refusal and race handling are covered by + tests/harness/test_private_directory.py. Here the wrapper must reject a + relative target, expand ``~`` and return the expanded target unchanged. + """ + + with pytest.raises(ValueError): + prepare_private_directory_chain("relative/child") + + target = tmp_path / "one" / "two" + assert prepare_private_directory_chain(target) == target + assert target.is_dir() + + +def test_prepare_private_directory_chain_expands_user_home( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + result = prepare_private_directory_chain("~/expanded/child") + assert result == tmp_path / "expanded" / "child" + assert result.is_dir() diff --git a/tests/harness/test_private_directory.py b/tests/harness/test_private_directory.py new file mode 100644 index 000000000..599ab839c --- /dev/null +++ b/tests/harness/test_private_directory.py @@ -0,0 +1,183 @@ +"""Contract tests for the shared private-directory chain primitive. + +These cover the creation rule itself: mode, symlink refusal, non-directory +entries, the concurrent-creator branch and the absence of symlink following. +Callers that wrap this primitive test only their own entry contract. +""" + +from __future__ import annotations + +import os +import stat +from pathlib import Path + +import pytest + +from loushang.harness.private_directory import create_private_directory_chain + +_POSIX_ONLY = pytest.mark.skipif( + os.name != "posix", reason="POSIX directory permission contract" +) + + +def _mode(path: Path) -> int: + return stat.S_IMODE(path.lstat().st_mode) + + +@_POSIX_ONLY +def test_creates_every_missing_level_private_under_group_writable_umask( + tmp_path: Path, +) -> None: + target = tmp_path / "one" / "two" / "three" + previous = os.umask(0o002) + try: + result = create_private_directory_chain(target) + finally: + os.umask(previous) + + assert result == target + for level in (tmp_path / "one", tmp_path / "one" / "two", target): + assert level.is_dir(), level + assert _mode(level) == 0o700, level + + +def test_returns_the_given_path_without_resolving_symlinks(tmp_path: Path) -> None: + target = tmp_path / "one" / "two" + assert create_private_directory_chain(target) == target + assert create_private_directory_chain(target) == target + + +@_POSIX_ONLY +def test_never_rewrites_an_existing_level(tmp_path: Path) -> None: + existing = tmp_path / "shared" + existing.mkdir() + os.chmod(existing, 0o2775) + + create_private_directory_chain(existing / "child") + + assert _mode(existing) == 0o2775 + assert _mode(existing / "child") == 0o700 + + +@_POSIX_ONLY +def test_rejects_directory_symlink_without_following_it(tmp_path: Path) -> None: + outside = tmp_path / "outside" + outside.mkdir() + link = tmp_path / "link" + link.symlink_to(outside, target_is_directory=True) + assert link.is_dir(), "a directory symlink satisfies is_dir(); this is the trap" + + with pytest.raises(NotADirectoryError): + create_private_directory_chain(link / "child") + + assert not (outside / "child").exists(), "a symlink must never be followed" + + +@_POSIX_ONLY +def test_rejects_symlink_when_it_is_the_final_target(tmp_path: Path) -> None: + outside = tmp_path / "outside" + outside.mkdir() + link = tmp_path / "leaflink" + link.symlink_to(outside, target_is_directory=True) + + with pytest.raises(NotADirectoryError): + create_private_directory_chain(link) + + +@_POSIX_ONLY +def test_rejects_dangling_symlink(tmp_path: Path) -> None: + link = tmp_path / "dangling" + link.symlink_to(tmp_path / "missing", target_is_directory=True) + + with pytest.raises(NotADirectoryError): + create_private_directory_chain(link / "child") + + +@_POSIX_ONLY +def test_rejects_regular_file_at_any_level(tmp_path: Path) -> None: + entry = tmp_path / "file" + entry.write_text("occupied", encoding="utf-8") + + with pytest.raises(NotADirectoryError): + create_private_directory_chain(entry / "child") + with pytest.raises(NotADirectoryError): + create_private_directory_chain(entry) + + assert entry.read_text(encoding="utf-8") == "occupied" + + +@_POSIX_ONLY +def test_rejects_symlink_created_by_a_concurrent_creator( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The FileExistsError branch must reclassify instead of trusting is_dir().""" + + outside = tmp_path / "outside" + outside.mkdir() + level = tmp_path / "raced" + real_mkdir = os.mkdir + + def racing_mkdir(path, mode=0o777, **kwargs): + # A concurrent creator wins the race with a symlink. + Path(path).symlink_to(outside, target_is_directory=True) + raise FileExistsError(17, "File exists", str(path)) + + monkeypatch.setattr(os, "mkdir", racing_mkdir) + try: + with pytest.raises(NotADirectoryError): + create_private_directory_chain(level) + finally: + monkeypatch.setattr(os, "mkdir", real_mkdir) + + assert not (outside / "child").exists() + + +@_POSIX_ONLY +def test_accepts_level_created_by_a_concurrent_creator( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + level = tmp_path / "raced" + real_mkdir = os.mkdir + + def racing_mkdir(path, mode=0o777, **kwargs): + real_mkdir(path, 0o700) + raise FileExistsError(17, "File exists", str(path)) + + monkeypatch.setattr(os, "mkdir", racing_mkdir) + try: + assert create_private_directory_chain(level) == level + finally: + monkeypatch.setattr(os, "mkdir", real_mkdir) + + assert _mode(level) == 0o700 + + +@_POSIX_ONLY +def test_created_level_drops_inherited_setgid_bit(tmp_path: Path) -> None: + """mkdir copies an inherited setgid bit, which admission then rejects. + + Under a setgid parent ``mkdir(mode=0o700)`` yields ``0o2700``; a directory that + must be exactly ``0o700`` would refuse the level this call just created. + """ + + parent = tmp_path / "storage" + parent.mkdir() + os.chmod(parent, 0o2775) + + target = parent / "sessions" + create_private_directory_chain(target) + + assert _mode(target) == 0o700, "a created level must end up exactly 0o700" + assert not stat.S_IMODE(target.lstat().st_mode) & stat.S_ISGID + + +def test_rejects_relative_target() -> None: + with pytest.raises(ValueError): + create_private_directory_chain("relative/child") + + +def test_expands_user_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + result = create_private_directory_chain("~/expanded/child") + assert result == tmp_path / "expanded" / "child" + assert result.is_dir() From b8d63305d5e4637e3c280a44d150a4ea195bf0cd Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 01:33:05 +0000 Subject: [PATCH 12/44] docs(lmux): record v2 baseline split in the M0 contract The M0 contract named 1286348e as its starting point. That commit was the tip of harness/linux-interactive-startup-v2, a G18 phase-two line whose own document records that its two 20% primary targets were not met and that it is local delivery only. It is not part of the lmux goal and has been split off with git rebase --onto main 1286348e so the v2 line decides its own destination. Record the split, the range-diff evidence that the eleven retained commits are mechanically equivalent (11/11 "="), and the resulting main baseline, so the contract no longer states a stale starting point. --- docs/internals/architecture/apphost/lmux-contract-m0.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/internals/architecture/apphost/lmux-contract-m0.md b/docs/internals/architecture/apphost/lmux-contract-m0.md index 456548272..01010c0f6 100644 --- a/docs/internals/architecture/apphost/lmux-contract-m0.md +++ b/docs/internals/architecture/apphost/lmux-contract-m0.md @@ -16,6 +16,11 @@ ## 1. 本地基线与推进记录 起点 `1286348e`,保留已评审 lmux 草案;独立任务分支。 +该起点原为 `harness/linux-interactive-startup-v2` 的顶端。该 G18 phase-two +工作在 v2 文档中自述两个 20% 主目标未达成、仅本地交付,不属于 lmux 目标; +已由 `git rebase --onto main 1286348e` 从本分支剥离,交由 v2 单独决定去向。 +剥离后本分支保留的 11 个提交经 `git range-diff` 逐项确认为机械等价(11/11 为 `=`), +基线随之改为剥离后的 `main`。 最初仅授权本地提交;用户后续明确授权:完整目标验收、三视角评审修复后, 提交并推送任务分支、创建 PR,通过门禁后合并,最后同步本地 main 与 harness lane。此授权替代初始“不自动推送或合并”限制,不提前发布未完成切片, From efc205d7062abdb13fdcf27dcccfc0eb7caaf1da Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 01:54:31 +0000 Subject: [PATCH 13/44] docs(lmux): mark the M0 record as a working-tree snapshot The M0 contract, its output-capture supplement, ARD-004 and the lmux user guides were committed as a snapshot while the implementation they describe was still uncommitted. Several "implemented / wired / passed" records therefore cannot be reproduced from the commit that carries them: apphost.managed.*, transcript/writer_lease.py, journal/_rooted_io.py, coding/cli/lmux*.py, appserver/managed_mux.py and scripts/dev/reevaluate_g18_comparison.py are all absent from this tree, and the guides document a `lmux` entrypoint that does not exist here. Keep the records, but state the scope so a reader cannot mistake authoring working-tree observations for committed evidence: - lmux-contract-m0.md: add an Evidence scope status field naming the uncommitted implementation and requiring per-slice claims to be read as working-tree observations until the matching code slice lands - ARD-004: change "implemented" to "accepted decision; implementation observed only in the authoring working tree" and name the uncommitted reevaluation tool - user guides (en, zh-CN): state that a build from this commit has no `lmux` entrypoint yet and that the commands are the intended interface, not a shipped one The partial-goal framing was already correct and is unchanged; only the evidence scope was over-claimed. --- docs/en/user-guide/lmux.md | 4 +++- ...D-004-aa-stability-gate-vs-interactive-metric-scale.md | 6 ++++-- docs/internals/architecture/apphost/lmux-contract-m0.md | 8 ++++++++ docs/zh-CN/user-guide/lmux.md | 4 +++- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/en/user-guide/lmux.md b/docs/en/user-guide/lmux.md index f8bbf52fb..fb3594da1 100644 --- a/docs/en/user-guide/lmux.md +++ b/docs/en/user-guide/lmux.md @@ -3,7 +3,9 @@ English | [中文](../../zh-CN/user-guide/lmux.md) This guide describes the current development branch, not completed delivery -acceptance. Check `lmux --help` against your installed version. Automatic +acceptance. The `lmux` command it documents was still uncommitted when this +guide landed, so a build from this commit has no `lmux` entrypoint yet; treat +the commands below as the intended interface, not a shipped one. Check `lmux --help` against your installed version. Automatic background services currently target Linux only; GUI and cross-machine access are not included. Managed scratch quotas, full Harnesstui parity, and final installed/disconnection/performance acceptance remain in progress. diff --git a/docs/internals/architecture/apphost/decisions/accepted/ARD-004-aa-stability-gate-vs-interactive-metric-scale.md b/docs/internals/architecture/apphost/decisions/accepted/ARD-004-aa-stability-gate-vs-interactive-metric-scale.md index e95560b28..8c2779c53 100644 --- a/docs/internals/architecture/apphost/decisions/accepted/ARD-004-aa-stability-gate-vs-interactive-metric-scale.md +++ b/docs/internals/architecture/apphost/decisions/accepted/ARD-004-aa-stability-gate-vs-interactive-metric-scale.md @@ -7,8 +7,10 @@ - Parent: `loushang` - Authority: normative — 已接受的 AppHost 判定合同变更 - Design status: accepted -- Implementation status: implemented — `_g18_comparison.py` 已改用具名常量对, - 既有 campaign 的重评以独立文件发布 +- Implementation status: accepted decision; implementation observed only in the + authoring working tree — `_g18_comparison.py` 的具名常量对与重评工具 + (`scripts/dev/reevaluate_g18_comparison.py`)在本文档入库时仍未提交, + 无法从本提交复现。以「已实现」描述时请视为 working-tree observation。 - Owner: Loushang AppHost architecture - Accepted: 2026-09-18,由本任务 owner 接受(用户明确指示“可以接受”) - Accepted scope: 稳定性门禁 `N=25%`、回归阈值 `R=30%`,统一适用于 diff --git a/docs/internals/architecture/apphost/lmux-contract-m0.md b/docs/internals/architecture/apphost/lmux-contract-m0.md index 01010c0f6..7e0840c50 100644 --- a/docs/internals/architecture/apphost/lmux-contract-m0.md +++ b/docs/internals/architecture/apphost/lmux-contract-m0.md @@ -12,6 +12,14 @@ - Implementation status: partial — managed CLI preview, Linux launch/lifetime, owned transcripts, discovery, exact-instance connections, close and shared Markdown/action/input binding; full M3/M4 acceptance pending - Owner: AppHost managed deployment; sibling changes remain sibling-owned - Tracking objective: active Linux lmux goal, branch `harness/lmux-managed-service` +- Evidence scope: this document is a working-tree snapshot of an in-flight goal. + Its per-slice implementation and pass records describe the authoring working + tree, not this commit's tree. Most referenced implementation + (`apphost.managed.*`, `transcript/writer_lease.py`, `journal/_rooted_io.py`, + `coding/cli/lmux*.py`, `appserver/managed_mux.py`) and the G18 reevaluation + tooling were still uncommitted when this snapshot was taken, so those claims + cannot be reproduced from this commit alone. Treat `已实现`/`已接线`/`passed` + below as working-tree observations until the matching code slice lands. ## 1. 本地基线与推进记录 diff --git a/docs/zh-CN/user-guide/lmux.md b/docs/zh-CN/user-guide/lmux.md index 33a0e0cef..fe57b4c38 100644 --- a/docs/zh-CN/user-guide/lmux.md +++ b/docs/zh-CN/user-guide/lmux.md @@ -2,7 +2,9 @@ [English](../../en/user-guide/lmux.md) | 中文 -本文对应当前开发分支的 `lmux`,不代表完整交付验收已经通过。运行 +本文对应当前开发分支的 `lmux`,不代表完整交付验收已经通过。本文入库时 +`lmux` 命令本身仍未提交,从本提交构建的版本尚无该入口;下文命令应按 +目标接口理解,而非已发布能力。运行 `lmux --help` 确认安装版本提供哪些命令;旧安装可能尚无本文中的入口。 当前自动后台仅支持 Linux,不提供跨机器连接或 GUI。实际暂存配额接线、 完整 Harnesstui 能力对齐及最终安装/断连/性能验收仍在开发中。 From 0cc6a112ba7aa937d97eed0e585f3284e1f8ce7b Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 05:19:38 +0000 Subject: [PATCH 14/44] feat(harness): retain descriptor-relative journal IO Add a Linux rooted-file adapter that borrows an already-owned directory descriptor, keeps cleanup and publication receipts explicit, and never falls back to pathname authority after admission. Wire JSONL reads, appends, rewrites, repair and unlink through the borrowed adapter while preserving the existing pathname mode for callers without a retained root. Cover replacement-root attacks, native boundary races, cancellation and cleanup debt, directory bindings, durable deletion, and single-publication inode witnesses. --- src/loushang/harness/journal/_rooted_io.py | 779 ++++++++++++++++++ src/loushang/harness/journal/jsonl.py | 124 ++- tests/harness/journal/test_rooted_io.py | 446 ++++++++++ .../journal/test_rooted_publication.py | 100 +++ 4 files changed, 1429 insertions(+), 20 deletions(-) create mode 100644 src/loushang/harness/journal/_rooted_io.py create mode 100644 tests/harness/journal/test_rooted_io.py create mode 100644 tests/harness/journal/test_rooted_publication.py diff --git a/src/loushang/harness/journal/_rooted_io.py b/src/loushang/harness/journal/_rooted_io.py new file mode 100644 index 000000000..09b61789f --- /dev/null +++ b/src/loushang/harness/journal/_rooted_io.py @@ -0,0 +1,779 @@ +"""Linux descriptor-relative file operations borrowing an existing root owner. + +The caller retains the root and this adapter before admitting any operation, +drains native calls, then settles this adapter's cleanup ledger before closing +the root. Path values are lexical labels, never native path authorities. +""" + +from __future__ import annotations + +import os +import secrets +import stat +import sys +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path +from threading import Event, RLock, get_ident +from typing import Protocol + +DirectoryBinding = tuple[tuple[int, int], str, tuple[int, int]] + + +class _DirectoryEntries(Protocol): + def __iter__(self) -> Iterator[os.DirEntry[str]]: ... + def close(self) -> None: ... + + +@dataclass(eq=False) +class _Operation: + pid: int = field(default_factory=os.getpid) + descriptors: dict[int, bool] = field(default_factory=dict) # True: close outcome unknown. + temporaries: dict[tuple[int, str], tuple[int, int] | None] = field(default_factory=dict) + sync_pending: set[int] = field(default_factory=set) + iterators: list[tuple[_DirectoryEntries, bool]] = field(default_factory=list) + deletions: list[tuple[int, str, tuple[int, int], bool]] = field(default_factory=list) + deletion_registered: bool = False + recoveries: list[tuple[int, Callable[[RootedDirectory], None]]] = field(default_factory=list) + directory_fds: set[int] = field(default_factory=set) + lock_fds: set[int] = field(default_factory=set) + cleanup_thread: int | None = None + cleanup_token: object | None = None + durable: bool = True + active: bool = True + borrow_active: bool = True + settlements: list[Event] = field(default_factory=list) + expected_directories: dict[tuple[int, int, str], tuple[int, int]] = field(default_factory=dict) + + def opened(self, fd: int) -> int: + self.descriptors[fd] = False + return fd + + def finish_deletions(self) -> None: + if os.getpid() != self.pid: + raise OSError("rooted IO cannot be used after fork") + for parent in tuple(self.sync_pending): + os.fsync(parent) + self.sync_pending.remove(parent) + while self.deletions: + parent, name, identity, is_directory = self.deletions[0] + try: + current = os.stat(name, dir_fd=parent, follow_symlinks=False) + except FileNotFoundError: + pass + else: + if (current.st_dev, current.st_ino) != identity: + raise OSError("rooted deletion identity changed") + if is_directory: + _directory(current) + os.rmdir(name, dir_fd=parent) + else: + _regular(current) + os.unlink(name, dir_fd=parent) + self.deletions.pop(0) + self.sync_pending.add(parent) + os.fsync(parent) + self.sync_pending.remove(parent) + + def cleanup(self) -> None: + if os.getpid() != self.pid: + raise OSError("rooted IO cannot be used after fork") + failures: list[BaseException] = [] + if self.recoveries and not any(self.descriptors.values()): + self.cleanup_thread = get_ident() + try: + while self.recoveries: + parent, recover = self.recoveries[0] + self.cleanup_token = object() + try: + recover(_CleanupDirectory(self, parent, self.cleanup_token)) + finally: + self.cleanup_token = None + self.recoveries.pop(0) + except BaseException as exc: + failures.append(exc) + finally: + self.cleanup_thread = None + if self.deletions and any(self.descriptors.values()): + failures.append(OSError("rooted deletion descriptor outcome unknown")) + else: + try: + self.finish_deletions() + except BaseException as exc: + failures.append(exc) + for iterator, unknown in tuple(self.iterators): + if unknown: + continue + self.iterators.remove((iterator, False)) + self.iterators.append((iterator, True)) + try: + iterator.close() + except BaseException as exc: + failures.append(exc) + else: + self.iterators.remove((iterator, True)) + for (parent, name), identity in tuple(self.temporaries.items()): + try: + if identity is None: + raise OSError("rooted IO temporary identity is unknown") + try: + current = os.stat(name, dir_fd=parent, follow_symlinks=False) + except FileNotFoundError: + pass + else: + if (current.st_dev, current.st_ino) != identity: + raise OSError("rooted IO temporary identity changed") + os.unlink(name, dir_fd=parent) + if self.durable: + self.sync_pending.add(parent) + del self.temporaries[parent, name] + except BaseException as exc: + failures.append(exc) + for parent in tuple(self.sync_pending): + try: + os.fsync(parent) + except BaseException as exc: + failures.append(exc) + else: + self.sync_pending.remove(parent) + retained_parents = ({parent for parent, _ in self.temporaries} | self.sync_pending + | {parent for parent, _, _, _ in self.deletions}) + if self.recoveries: + retained_parents |= self.directory_fds + for fd, unknown in tuple(reversed(self.descriptors.items())): + if fd in self.lock_fds and (failures or self.recoveries or self.deletions + or self.temporaries or self.sync_pending or any(self.descriptors.values())): + continue + if unknown or fd in retained_parents: + continue + # Publish the fence before close; even interruption cannot retry a + # number whose native close may already have allowed descriptor reuse. + self.descriptors[fd] = True + try: + os.close(fd) + except BaseException as exc: + failures.append(exc) + else: + del self.descriptors[fd] + if failures: + primary = failures[0] + for _ in failures[1:]: + primary.add_note("additional rooted IO cleanup failure") + raise primary + if self.descriptors or self.iterators: + raise OSError("rooted IO descriptor cleanup outcome unknown") + while self.settlements: + self.settlements[0].set() + self.settlements.pop(0) + + +@dataclass(eq=False) +class _PublicationWitness: + """One bounded data receipt; its descriptors belong to the original port.""" + + operation: _Operation = field(default_factory=lambda: _Operation(active=False, borrow_active=False)) + attempted: bool = False + published: bool = False + _identity: tuple[int, int] | None = None + + @property + def identity(self) -> tuple[int, int]: + if os.getpid() != self.operation.pid: + raise OSError("rooted publication cannot be used after fork") + if (not self.published or self._identity is None or len(self.operation.descriptors) != 1 + or any(self.operation.descriptors.values())): + raise OSError("rooted publication has no retained completion witness") + return self._identity + + +class RootedFileIO: + """Borrowed Linux root with an explicit ledger for native cleanup debt. + + No destructor, root close, automatic admission, or pathname fallback. The + owning operation scope is the authority for borrowing, not dev/ino alone. + cleanup() is called by that existing owner only after all calls have drained. + """ + + def __init__(self, root: Path, directory_fd: int, *, directory_bindings: tuple[DirectoryBinding, ...] = ()) -> None: + root = Path(root) + if sys.platform != "linux": + raise OSError("rooted IO requires Linux") + if not root.is_absolute() or ".." in root.parts: + raise ValueError("rooted IO requires an absolute lexical root") + opened = os.fstat(directory_fd) + _directory(opened) + self.root, self._root_fd = root, directory_fd + self._identity = (opened.st_dev, opened.st_ino) + if type(directory_bindings) is not tuple or len(directory_bindings) > 16: + raise ValueError("rooted directory bindings must be a bounded tuple") + self._expected_directories: dict[tuple[int, int, str], tuple[int, int]] = {} + for parent, name, child in directory_bindings: + if (type(name) is not str or not name or name in {".", ".."} + or any(char in name for char in ("/", "\\", "\0")) + or any(type(identity) is not tuple or len(identity) != 2 + or any(type(value) is not int or value < 0 for value in identity) + for identity in (parent, child))): + raise ValueError("invalid rooted directory binding") + key = (*parent, name) + if key in self._expected_directories: + raise ValueError("duplicate rooted directory binding") + self._expected_directories[key] = child + self._pid = os.getpid() + self._operations: list[_Operation] = [] + self._publication: tuple[tuple[str, ...], _PublicationWitness] | None = None + self._mutex = RLock() + + @property + def cleanup_pending(self) -> bool: + self._same_process() + with self._mutex: + return bool(self._operations or (self._publication is not None + and self._publication[1].operation.descriptors)) + + def retain_next_publication(self, path: Path) -> _PublicationWitness: + """Purely register at most one inode pin, released by this port's cleanup. + + This is not a write authority. The original caller must admit the write + and retain this port throughout the receipt's entire useful lifetime. + """ + self._same_process() + parts = self._parts(path) + with self._mutex: + if self._publication is not None: + raise OSError("rooted publication witness is already registered") + witness = _PublicationWitness() + self._publication = parts, witness + return witness + + def cleanup(self) -> None: + """Retry known temporary debt; never retry an uncertain descriptor close.""" + self._same_process() + with self._mutex: + if any(operation.active for operation in self._operations): + raise OSError("rooted IO has active operations") + failures = [] + for operation in tuple(self._operations): + try: + operation.cleanup() + except BaseException as exc: + failures.append(exc) + else: + self._operations.remove(operation) + if self._publication is not None and not failures: + try: + self._publication[1].operation.cleanup() + except BaseException as exc: + failures.append(exc) + if failures: + raise failures[0] + + def _same_process(self) -> None: + if os.getpid() != self._pid: + raise OSError("rooted IO cannot be used after fork") + + def _parts(self, path: Path) -> tuple[str, ...]: + path = Path(path) + try: + parts = path.relative_to(self.root).parts + except ValueError: + raise ValueError("rooted IO path is outside the borrowed root") from None + if not parts or any(part in {"", ".", ".."} or "\0" in part or "\\" in part for part in parts): + raise ValueError("rooted IO requires safe relative child components") + return parts + + @contextmanager + def _operation( + self, path: Path, *, create_parent: bool = False, durable: bool = True, + ) -> Iterator[tuple[_Operation, int, str]]: + parts = self._parts(path) + self._same_process() + operation = _Operation(durable=durable, expected_directories=dict(self._expected_directories)) + with self._mutex: + if (any(not item.active for item in self._operations) + or (self._publication is not None and any(self._publication[1].operation.descriptors.values()))): + raise OSError("rooted IO has unsettled cleanup debt") + self._operations.append(operation) + primary: BaseException | None = None + try: + opened = os.fstat(self._root_fd) + _directory(opened) + if (opened.st_dev, opened.st_ino) != self._identity: + raise OSError("rooted IO borrowed root identity changed") + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC | os.O_NOFOLLOW + parent = operation.opened(os.open(".", flags, dir_fd=self._root_fd)) + operation.directory_fds.add(parent) + for part in parts[:-1]: + status = os.fstat(parent) + expected = operation.expected_directories.get((status.st_dev, status.st_ino, part)) + if create_parent and expected is None: + try: + os.mkdir(part, mode=0o700, dir_fd=parent) + except FileExistsError: + pass + else: + os.fsync(parent) + parent = operation.opened(os.open(part, flags, dir_fd=parent)) + operation.directory_fds.add(parent) + _directory(os.fstat(parent)) + if expected is not None and (os.fstat(parent).st_dev, os.fstat(parent).st_ino) != expected: + raise OSError("rooted enrolled directory identity changed") + yield operation, parent, parts[-1] + except BaseException as exc: + primary = exc + raise + finally: + operation.borrow_active = False + if os.getpid() != operation.pid: + # Do not touch inherited locks or the parent's temporary names. + raise OSError("rooted IO cannot be used after fork") + try: + operation.cleanup() + except BaseException: + if primary is None: + raise + primary.add_note("rooted IO cleanup debt retained") + finally: + with self._mutex: + operation.active = False + if (not operation.descriptors and not operation.temporaries + and not operation.sync_pending and not operation.iterators + and not operation.deletions and not operation.recoveries + and not operation.settlements): + self._operations.remove(operation) + + @staticmethod + def _open(operation: _Operation, parent: int, name: str, flags: int) -> int: + fd = operation.opened(os.open( + name, flags | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC, 0o600, dir_fd=parent, + )) + _regular(os.fstat(fd)) + return fd + + @contextmanager + def bind( + self, path: Path, *, create_parent: bool = False, durable: bool = True, + ) -> Iterator[RootedFile]: + """Borrow one pinned parent for an entire synchronous transaction. + + The yielded reference is valid only within this context. In particular, + Journal locks, JSONL and repair must all use this one reference. + """ + with self._operation(path, create_parent=create_parent, durable=durable) as values: + witness = (self._publication[1] if self._publication is not None + and self._parts(path) == self._publication[0] else None) + yield RootedFile(*values, _publication=witness) + + def read_bytes(self, path: Path, *, max_bytes: int | None = None) -> bytes: + with self.bind(path) as target: + return target.read_bytes(max_bytes=max_bytes) + + @contextmanager + def directory(self) -> Iterator[RootedDirectory]: + """Borrow the root and any child directories for one transaction.""" + with self.bind(self.root / ".directory") as target: + yield RootedDirectory(target._operation, target._parent) + + def read_prefix(self, path: Path, limit: int) -> bytes: + with self.bind(path) as target: + return target.read_bytes(prefix_bytes=limit) + + def stat(self, path: Path) -> os.stat_result: + with self.bind(path) as target: + return target.stat() + + def scan_names(self, *, limit: int) -> tuple[tuple[str, ...], bool]: + """Bounded direct-child names from the borrowed root, without Path IO.""" + if type(limit) is not int or limit < 1: + raise ValueError("rooted IO scan limit must be positive") + with self.bind(self.root / ".scan") as target: + names: list[str] = [] + entries = os.scandir(target._parent) + target._operation.iterators.append((entries, False)) + for entry in entries: + if len(names) == limit: + return tuple(names), False + names.append(entry.name) + return tuple(names), True + + def append_bytes(self, path: Path, data: bytes, *, fsync: bool = True) -> None: + with self.bind(path, create_parent=True, durable=fsync) as target: + target.append_bytes(data, fsync=fsync) + + def atomic_write(self, path: Path, data: bytes, *, fsync: bool = True) -> None: + with self.bind(path, create_parent=True, durable=fsync) as target: + target.atomic_write(data, fsync=fsync) + + def unlink(self, path: Path, *, missing_ok: bool = False, fsync: bool = True) -> None: + with self.bind(path, durable=fsync) as target: + target.unlink(missing_ok=missing_ok, fsync=fsync) + + @contextmanager + def lock(self, path: Path, *, exclusive: bool, blocking: bool = True) -> Iterator[None]: + with self.bind(path, create_parent=True) as target: + target.acquire_lock(exclusive=exclusive, blocking=blocking) + yield + + +@dataclass(frozen=True) +class RootedFile: + """One transaction's borrowed parent and lexical leaf, never retained.""" + + _operation: _Operation + _parent: int + _name: str + _publication: _PublicationWitness | None = field(default=None, kw_only=True) + + def _require_active(self) -> None: + if os.getpid() != self._operation.pid: + raise OSError("rooted IO cannot be used after fork") + if not self._operation.borrow_active: + raise OSError("rooted IO file borrow has ended") + + def sibling(self, name: str) -> RootedFile: + self._require_active() + if not name or name in {".", ".."} or any(char in name for char in ("/", "\\", "\0")): + raise ValueError("rooted IO requires one direct child name") + return self._project(name) + + def _project(self, name: str) -> RootedFile: + return RootedFile(self._operation, self._parent, name) + + def stat(self) -> os.stat_result: + self._require_active() + value = os.stat(self._name, dir_fd=self._parent, follow_symlinks=False) + _regular(value) + return value + + def read_bytes(self, *, max_bytes: int | None = None, prefix_bytes: int | None = None) -> bytes: + self._require_active() + if max_bytes is not None and (type(max_bytes) is not int or max_bytes < 1): + raise ValueError("rooted IO read limit must be positive") + if prefix_bytes is not None and (type(prefix_bytes) is not int or prefix_bytes < 1): + raise ValueError("rooted IO prefix limit must be positive") + fd = RootedFileIO._open(self._operation, self._parent, self._name, os.O_RDONLY) + before = os.fstat(fd) + if max_bytes is not None and before.st_size > max_bytes: + raise OSError("rooted IO read limit exceeded") + chunks = [] + remaining = before.st_size if prefix_bytes is None else min(before.st_size, prefix_bytes) + while remaining: + data = os.read(fd, min(remaining, 1024 * 1024)) + if not data: + raise OSError("rooted IO file truncated during read") + chunks.append(data) + remaining -= len(data) + after = os.fstat(fd) + named = os.stat(self._name, dir_fd=self._parent, follow_symlinks=False) + if _version(before) != _version(after) or _version(after) != _version(named): + raise OSError("rooted IO file changed during read") + return b"".join(chunks) + + def append_bytes(self, data: bytes, *, fsync: bool = True) -> None: + self._require_active() + fd = RootedFileIO._open(self._operation, self._parent, self._name, os.O_WRONLY | os.O_APPEND | os.O_CREAT) + os.fchmod(fd, 0o600) + _write_all(fd, data) + if fsync: + os.fsync(fd) + self.sync_directory() + + def create_new(self, data: bytes) -> tuple[int, int]: + """Create an unpublished object exclusively; failures retain cleanup.""" + self._require_active() + operation, parent, name = self._operation, self._parent, self._name + fd = operation.opened(os.open( + name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, + 0o600, dir_fd=parent, + )) + operation.temporaries[parent, name] = None + metadata = os.fstat(fd) + identity = metadata.st_dev, metadata.st_ino + operation.temporaries[parent, name] = identity + _regular(metadata) + _write_all(fd, data) + os.fsync(fd) + self.sync_directory() + del operation.temporaries[parent, name] + return identity + + def atomic_write(self, data: bytes, *, fsync: bool = True, exclusive: bool = False) -> None: + self._require_active() + operation, parent, name = self._operation, self._parent, self._name + temporary = f".{name}.{secrets.token_hex(12)}.tmp" + # Only a successful exclusive creation acquires cleanup authority. + fd = operation.opened(os.open( + temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, + 0o600, dir_fd=parent, + )) + operation.temporaries[parent, temporary] = None + opened = os.fstat(fd) + operation.temporaries[parent, temporary] = (opened.st_dev, opened.st_ino) + _regular(opened) + witness = self._publication + capturing = witness is not None and not witness.attempted + if capturing: + assert witness is not None + witness.attempted = True + # Duplicate the actual new inode before publication, never reopen + # its pathname. This separate ledger is not this call's finally + # ledger; the original port alone releases the bounded pin. + witness.operation.opened(os.dup(fd)) + witness._identity = opened.st_dev, opened.st_ino + _write_all(fd, data) + if fsync: + os.fsync(fd) + current = os.stat(temporary, dir_fd=parent, follow_symlinks=False) + if (current.st_dev, current.st_ino) != (opened.st_dev, opened.st_ino): + raise OSError("rooted IO temporary identity changed") + if exclusive: + os.link(temporary, name, src_dir_fd=parent, dst_dir_fd=parent, follow_symlinks=False) + os.unlink(temporary, dir_fd=parent) + else: + os.replace(temporary, name, src_dir_fd=parent, dst_dir_fd=parent) + del operation.temporaries[parent, temporary] # Consumed, even if fsync fails. + if fsync: + self.sync_directory() + if capturing: + assert witness is not None + witness.published = True + + def sync_directory(self) -> None: + self._require_active() + self._operation.sync_pending.add(self._parent) + os.fsync(self._parent) + self._operation.sync_pending.remove(self._parent) + + def unlink(self, *, missing_ok: bool = False, fsync: bool = True) -> None: + self._require_active() + try: + _regular(os.stat(self._name, dir_fd=self._parent, follow_symlinks=False)) + os.unlink(self._name, dir_fd=self._parent) + except FileNotFoundError: + if not missing_ok: + raise + else: + if fsync: + self.sync_directory() + + def unlink_owned(self, identity: tuple[int, int]) -> None: + """Retain an authorized cleanup until its exact name and sync settle.""" + self._require_active() + self._operation.deletions.append((self._parent, self._name, identity, False)) + self._operation.finish_deletions() + + def acquire_lock(self, *, exclusive: bool, blocking: bool = True, suffix: str = "") -> None: + import fcntl + + self._require_active() + if any(char in suffix for char in ("/", "\\", "\0")): + raise ValueError("rooted IO lock suffix must be one child component") + fd = RootedFileIO._open(self._operation, self._parent, self._name + suffix, os.O_RDWR | os.O_CREAT) + self._operation.lock_fds.add(fd) + os.fchmod(fd, 0o600) + mode = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH + # Retained recovery may keep this lock after the call exits. Waiting + # here would keep another operation active and prevent owner cleanup + # from releasing that lock. Rooted admission is always fail-fast; + # blocking remains accepted for the generic Journal lock interface. + fcntl.flock(fd, mode | fcntl.LOCK_NB) + # Closing this independent OFD releases the lock, never LOCK_UN. + + +@dataclass(frozen=True) +class RootedDirectory: + """A directory projection borrowing the same operation, never an owner.""" + + _operation: _Operation + _fd: int + + def file(self, name: str) -> RootedFile: + return RootedFile(self._operation, self._fd, ".directory").sibling(name) + + def stat(self) -> os.stat_result: + self.file(".directory")._require_active() + result = os.fstat(self._fd) + _directory(result) + return result + + def child(self, name: str, *, create: bool = False) -> RootedDirectory: + target = self.file(name) + status = self.stat() + expected = self._operation.expected_directories.get((status.st_dev, status.st_ino, name)) + if create and expected is None: + try: + os.mkdir(name, mode=0o700, dir_fd=self._fd) + except FileExistsError: + pass + else: + target.sync_directory() + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC | os.O_NOFOLLOW + fd = self._operation.opened(os.open(name, flags, dir_fd=self._fd)) + self._operation.directory_fds.add(fd) + result = self._project(fd) + current = result.stat() + if expected is not None and (current.st_dev, current.st_ino) != expected: + raise OSError("rooted enrolled directory identity changed") + return result + + def _project(self, fd: int) -> RootedDirectory: + return RootedDirectory(self._operation, fd) + + def retain_cleanup(self, action: Callable[[RootedDirectory], None]) -> None: + """Retain a domain recovery in this operation, with its original locks.""" + self.stat() + self._operation.recoveries.append((self._fd, action)) + + def track_settlement(self) -> Event: + """Receipt for full settlement of this original operation, including close.""" + self.stat() + receipt = Event() + self._operation.settlements.append(receipt) + return receipt + + def reborrow(self, retained: RootedDirectory) -> RootedDirectory: + self.stat() + if retained._operation is not self._operation or retained._fd not in self._operation.directory_fds: + raise ValueError("directory reborrow requires the same retained operation") + return self._project(retained._fd) + + @property + def deletion_registered(self) -> bool: + self.stat() + return self._operation.deletion_registered + + def queue_files(self, files: list[tuple[str, tuple[int, int]]]) -> None: + plan = [] + for name, identity in files: + self.file(name) + plan.append((self._fd, name, identity, False)) + self._operation.deletions.extend(plan) + self._operation.deletion_registered |= bool(plan) + + def remove_files(self, files: list[tuple[str, tuple[int, int]]]) -> None: + self.queue_files(files) + self._operation.finish_deletions() + + def check_child(self, name: str, expected: RootedDirectory) -> None: + self.file(name) + if expected._operation is not self._operation: + raise ValueError("directory check requires the same transaction") + named = os.stat(name, dir_fd=self._fd, follow_symlinks=False) + if not os.path.samestat(named, expected.stat()): + raise OSError("rooted child directory identity changed") + + def names(self, *, limit: int) -> tuple[str, ...]: + self.stat() + if type(limit) is not int or limit < 1: + raise ValueError("rooted directory scan limit must be positive") + entries = os.scandir(self._fd) + self._operation.iterators.append((entries, False)) + names: list[str] = [] + for entry in entries: + if len(names) == limit: + raise OSError("rooted directory traversal budget exceeded") + names.append(entry.name) + return tuple(names) + + def remove_tree(self, name: str, *, expected: RootedDirectory, limit: int, defer: bool = False) -> None: + """Preflight a bounded owned tree, then delete only its pinned members.""" + if expected._operation is not self._operation: + raise ValueError("directory deletion requires the same transaction") + self.file(name) + if type(limit) is not int or limit < 1: + raise ValueError("rooted directory traversal limit must be positive") + root = expected.stat() + named = os.stat(name, dir_fd=self._fd, follow_symlinks=False) + if not os.path.samestat(root, named): + raise OSError("rooted directory deletion identity changed") + remaining = limit + files: list[tuple[RootedFile, os.stat_result]] = [] + directories: list[tuple[RootedDirectory, str, os.stat_result]] = [] + + def scan(directory: RootedDirectory, depth: int) -> None: + nonlocal remaining + if depth > 16: + raise OSError("rooted directory traversal depth exceeded") + for child in directory.names(limit=max(1, remaining)): + remaining -= 1 + if remaining < 0: + raise OSError("rooted directory traversal budget exceeded") + metadata = os.stat(child, dir_fd=directory._fd, follow_symlinks=False) + if stat.S_ISDIR(metadata.st_mode): + opened = directory.child(child) + if not os.path.samestat(metadata, opened.stat()): + raise OSError("rooted child directory identity changed") + scan(opened, depth + 1) + directories.append((directory, child, metadata)) + else: + _regular(metadata) + files.append((directory.file(child), metadata)) + + scan(expected, 0) + for leaf, metadata in files: + if not os.path.samestat(metadata, leaf.stat()): + raise OSError("rooted file deletion identity changed") + plan = [ + (leaf._parent, leaf._name, (metadata.st_dev, metadata.st_ino), False) + for leaf, metadata in files + ] + for parent, child, metadata in (*directories, (self, name, root)): + current = os.stat(child, dir_fd=parent._fd, follow_symlinks=False) + if not os.path.samestat(metadata, current): + raise OSError("rooted directory deletion identity changed") + plan.append((parent._fd, child, (metadata.st_dev, metadata.st_ino), True)) + # Keep the exact preflight plan in the original IO ledger before the + # first unlink. A partial deletion must remain recoverable without a + # still-readable manifest or a new pathname traversal. + self._operation.deletions.extend(plan) + self._operation.deletion_registered = True + if not defer: + self._operation.finish_deletions() + + +@dataclass(frozen=True) +class _CleanupFile(RootedFile): + _token: object + + def _require_active(self) -> None: + if (os.getpid() != self._operation.pid or self._operation.cleanup_thread != get_ident() + or self._operation.cleanup_token is not self._token): + raise OSError("rooted cleanup borrow has ended") + + def _project(self, name: str) -> RootedFile: + return _CleanupFile(self._operation, self._parent, name, self._token) + + +@dataclass(frozen=True) +class _CleanupDirectory(RootedDirectory): + _token: object + + def file(self, name: str) -> RootedFile: + return _CleanupFile(self._operation, self._fd, ".directory", self._token).sibling(name) + + def _project(self, fd: int) -> RootedDirectory: + return _CleanupDirectory(self._operation, fd, self._token) + + +def _directory(value: os.stat_result) -> None: + if (not stat.S_ISDIR(value.st_mode) or value.st_uid != os.geteuid() or value.st_mode & 0o022): + raise OSError("rooted IO requires an owned non-writable directory") + + +def _regular(value: os.stat_result) -> None: + if (not stat.S_ISREG(value.st_mode) or value.st_uid != os.geteuid() + or value.st_mode & 0o022 or value.st_nlink != 1): + raise OSError("rooted IO requires an owned single-link regular file") + + +def _version(value: os.stat_result) -> tuple[int, int, int, int, int]: + return value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns + + +def _write_all(fd: int, data: bytes) -> None: + remaining = memoryview(data) + while remaining: + written = os.write(fd, remaining) + if written <= 0: + raise OSError("rooted IO write made no progress") + remaining = remaining[written:] diff --git a/src/loushang/harness/journal/jsonl.py b/src/loushang/harness/journal/jsonl.py index 4d693a963..2f2a7aaf1 100644 --- a/src/loushang/harness/journal/jsonl.py +++ b/src/loushang/harness/journal/jsonl.py @@ -10,7 +10,7 @@ from contextlib import AbstractContextManager, contextmanager, nullcontext, suppress from dataclasses import dataclass from pathlib import Path -from typing import Any, Generic, Literal, TypeVar, cast +from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast from loushang.foundation.json import ( JsonValueError, @@ -33,6 +33,9 @@ ) from loushang.harness.private_directory import create_private_directory_chain +if TYPE_CHECKING: + from loushang.harness.journal._rooted_io import RootedFile, RootedFileIO + H = TypeVar("H") R = TypeVar("R") LockMode = Literal["exclusive", "shared"] @@ -275,6 +278,8 @@ def append_jsonl_record( format_profile: JournalFormatProfile = DEFAULT_JSONL_FORMAT, durability: JournalDurabilityProfile = DURABLE_LOCKED_JOURNAL, lock_factory: LockFactory | None = None, + file_io: RootedFileIO | None = None, + bound_file: RootedFile | None = None, ) -> None: target = Path(path) line = _dump_mapping(record_codec.encode_record(record), format_profile) @@ -283,7 +288,15 @@ def append_jsonl_record( "exclusive", durability=durability, lock_factory=lock_factory, - ): + file_io=file_io, + bound_file=bound_file, + ) as rooted: + if rooted is not None: + rooted.append_bytes( + (line + format_profile.newline).encode(format_profile.encoding), + fsync=durability.fsync, + ) + return create_private_directory_chain(target.parent) existed = target.exists() with target.open("a", encoding=format_profile.encoding) as handle: @@ -303,6 +316,8 @@ def append_jsonl_records( format_profile: JournalFormatProfile = DEFAULT_JSONL_FORMAT, durability: JournalDurabilityProfile = DURABLE_LOCKED_JOURNAL, lock_factory: LockFactory | None = None, + file_io: RootedFileIO | None = None, + bound_file: RootedFile | None = None, ) -> None: """Append an ordered record batch with one lock, open, write, and sync.""" @@ -320,7 +335,12 @@ def append_jsonl_records( "exclusive", durability=durability, lock_factory=lock_factory, - ): + file_io=file_io, + bound_file=bound_file, + ) as rooted: + if rooted is not None: + rooted.append_bytes(payload.encode(format_profile.encoding), fsync=durability.fsync) + return create_private_directory_chain(target.parent) existed = target.exists() with target.open("a", encoding=format_profile.encoding) as handle: @@ -331,6 +351,10 @@ def append_jsonl_records( _sync_parent_directory(target, durability) +class _JournalWriteEncodingError(ValueError): + """Encoding failed before write_jsonl entered its native write boundary.""" + + def write_jsonl( path: str | Path, records: Sequence[R], @@ -341,34 +365,45 @@ def write_jsonl( format_profile: JournalFormatProfile = DEFAULT_JSONL_FORMAT, durability: JournalDurabilityProfile = DURABLE_LOCKED_JOURNAL, lock_factory: LockFactory | None = None, + file_io: RootedFileIO | None = None, + bound_file: RootedFile | None = None, + _report_encoding_failure: bool = False, ) -> None: target = Path(path) - encoded: list[str] = [] - if header is not None: - if header_codec is None: - raise ValueError("header_codec is required when writing a header") - encoded.append( - _dump_mapping(header_codec.encode_header(header), format_profile) + try: + encoded: list[str] = [] + if header is not None: + if header_codec is None: + raise ValueError("header_codec is required when writing a header") + encoded.append( + _dump_mapping(header_codec.encode_header(header), format_profile) + ) + encoded.extend( + _dump_mapping(record_codec.encode_record(record), format_profile) + for record in records ) - encoded.extend( - _dump_mapping(record_codec.encode_record(record), format_profile) - for record in records - ) - data = format_profile.newline.join(encoded) - if encoded: - data += format_profile.newline + data = format_profile.newline.join(encoded) + if encoded: + data += format_profile.newline + except Exception as error: + if _report_encoding_failure: + raise _JournalWriteEncodingError(str(error)) from error + raise with _lock_context( target, "exclusive", durability=durability, lock_factory=lock_factory, - ): + file_io=file_io, + bound_file=bound_file, + ) as rooted: _replace_text_unlocked( target, data, encoding=format_profile.encoding, durability=durability, + rooted=rooted, ) @@ -381,6 +416,8 @@ def load_jsonl( durability: JournalDurabilityProfile = DURABLE_LOCKED_JOURNAL, load_policy: JournalLoadPolicy = JournalLoadPolicy(), lock_factory: LockFactory | None = None, + file_io: RootedFileIO | None = None, + bound_file: RootedFile | None = None, ) -> JsonlSnapshot[H, R]: target = Path(path) lock_mode: LockMode = ( @@ -391,8 +428,13 @@ def load_jsonl( lock_mode, durability=durability, lock_factory=lock_factory, - ): - raw = target.read_text(encoding=format_profile.encoding) + file_io=file_io, + bound_file=bound_file, + ) as rooted: + raw = ( + rooted.read_bytes().decode(format_profile.encoding) if rooted is not None + else target.read_text(encoding=format_profile.encoding) + ) snapshot = _decode_jsonl( raw, target=target, @@ -417,6 +459,7 @@ def load_jsonl( repaired, encoding=format_profile.encoding, durability=durability, + rooted=rooted, ) return snapshot @@ -613,6 +656,8 @@ def __init__( durability: JournalDurabilityProfile = DURABLE_LOCKED_JOURNAL, load_policy: JournalLoadPolicy = JournalLoadPolicy(), lock_factory: LockFactory | None = None, + file_io: RootedFileIO | None = None, + bound_file: RootedFile | None = None, ) -> None: self.path = Path(path) self.record_codec = record_codec @@ -621,6 +666,12 @@ def __init__( self.durability = durability self.load_policy = load_policy self.lock_factory = lock_factory + if file_io is not None and lock_factory is not None: + raise ValueError("rooted Journal cannot use a pathname lock factory") + self.file_io = file_io + if bound_file is not None and (file_io is not None or lock_factory is not None or durability.locking): + raise ValueError("bound Journal requires its existing unlocked transaction") + self.bound_file = bound_file def append(self, record: R) -> None: append_jsonl_record( @@ -630,6 +681,8 @@ def append(self, record: R) -> None: format_profile=self.format_profile, durability=self.durability, lock_factory=self.lock_factory, + file_io=self.file_io, + bound_file=self.bound_file, ) def append_batch(self, records: Sequence[R]) -> None: @@ -640,6 +693,8 @@ def append_batch(self, records: Sequence[R]) -> None: format_profile=self.format_profile, durability=self.durability, lock_factory=self.lock_factory, + file_io=self.file_io, + bound_file=self.bound_file, ) def rewrite(self, records: Sequence[R], *, header: H | None = None) -> None: @@ -652,6 +707,8 @@ def rewrite(self, records: Sequence[R], *, header: H | None = None) -> None: format_profile=self.format_profile, durability=self.durability, lock_factory=self.lock_factory, + file_io=self.file_io, + bound_file=self.bound_file, ) def load(self) -> JsonlSnapshot[H, R]: @@ -663,6 +720,8 @@ def load(self) -> JsonlSnapshot[H, R]: durability=self.durability, load_policy=self.load_policy, lock_factory=self.lock_factory, + file_io=self.file_io, + bound_file=self.bound_file, ) @@ -672,7 +731,18 @@ def _lock_context( *, durability: JournalDurabilityProfile, lock_factory: LockFactory | None, -) -> AbstractContextManager[None]: + file_io: RootedFileIO | None = None, + bound_file: RootedFile | None = None, +) -> AbstractContextManager[RootedFile | None]: + if bound_file is not None: + if file_io is not None or lock_factory is not None or durability.locking: + raise ValueError("bound Journal requires its existing unlocked transaction") + bound_file._require_active() + return nullcontext(bound_file) + if file_io is not None and lock_factory is not None: + raise ValueError("rooted Journal cannot use a pathname lock factory") + if file_io is not None: + return _rooted_context(file_io, path, mode, durability) if not durability.locking: return nullcontext() if lock_factory is not None: @@ -680,6 +750,16 @@ def _lock_context( return journal_file_lock(path, mode, lock_suffix=durability.lock_suffix) +@contextmanager +def _rooted_context( + file_io: RootedFileIO, path: Path, mode: LockMode, durability: JournalDurabilityProfile, +) -> Iterator[RootedFile]: + with file_io.bind(path, create_parent=durability.locking or mode == "exclusive", durable=durability.fsync) as rooted: + if durability.locking: + rooted.acquire_lock(exclusive=mode == "exclusive", suffix=durability.lock_suffix) + yield rooted + + def _dump_mapping( value: Mapping[str, object], profile: JournalFormatProfile, @@ -791,7 +871,11 @@ def _replace_text_unlocked( *, encoding: str, durability: JournalDurabilityProfile, + rooted: RootedFile | None = None, ) -> None: + if rooted is not None: + rooted.atomic_write(data.encode(encoding), fsync=durability.fsync) + return create_private_directory_chain(target.parent) temp_path = target.with_name(f".{target.name}.{os.getpid()}.tmp") try: diff --git a/tests/harness/journal/test_rooted_io.py b/tests/harness/journal/test_rooted_io.py new file mode 100644 index 000000000..641ad6e07 --- /dev/null +++ b/tests/harness/journal/test_rooted_io.py @@ -0,0 +1,446 @@ +from __future__ import annotations + +import os +import stat +import subprocess +import sys +from contextlib import contextmanager +from pathlib import Path + +import pytest + +from loushang.harness.journal import JournalLoadPolicy, JsonlJournal +from loushang.harness.journal import _rooted_io as module +from loushang.harness.journal._rooted_io import RootedFileIO + +from .test_jsonl import _Header, _HeaderCodec, _Record, _RecordCodec + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux retained-root IO") + + +@contextmanager +def borrowed(root): + root.mkdir(mode=0o700) + fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC) + io = RootedFileIO(root, fd) + try: + yield io + assert not io.cleanup_pending + assert stat.S_ISDIR(os.fstat(fd).st_mode) # Never consumes the borrowed fd. + finally: + os.close(fd) + + +def journal(io, *, repair=False): + return JsonlJournal( + io.root / "session.jsonl", file_io=io, + record_codec=_RecordCodec(), header_codec=_HeaderCodec(), + load_policy=JournalLoadPolicy(header="required", partial_tail="repair" if repair else "raise"), + ) + + +def tree(root): + return { + str(path.relative_to(root)): (path.lstat().st_mode, path.lstat().st_ino, + path.lstat().st_mtime_ns, + path.read_bytes() if path.is_file() else None) + for path in (root, *root.rglob("*")) + } + + +def replace_root(root): + moved = root.with_name("moved") + root.rename(moved) + root.mkdir(mode=0o700) + (root / "session.jsonl").write_bytes(b"replacement must stay untouched\n") + (root / "sentinel").mkdir(mode=0o700) + return moved + + +@pytest.mark.parametrize("action", ["load", "append", "batch", "rewrite", "repair", "unlink"]) +def test_rooted_journal_actions_never_follow_replacement_root(tmp_path, monkeypatch, action): + with borrowed(tmp_path / "root") as io: + store = journal(io, repair=action == "repair") + store.rewrite([_Record("one", "old")], header=_Header("id")) + if action == "repair": + io.append_bytes(store.path, b'{"unterminated":') + moved = replace_root(io.root) + untouched = tree(io.root) + + def forbidden(*_, **__): + raise AssertionError("rooted Journal fell back to pathname IO") + + with monkeypatch.context() as patch: + for name in ("open", "read_text", "read_bytes", "write_text", "write_bytes", "mkdir", "replace", "unlink"): + patch.setattr(Path, name, forbidden) + if action == "append": + store.append(_Record("two", "new")) + elif action == "batch": + store.append_batch([_Record("two", "new"), _Record("three", "last")]) + elif action == "rewrite": + store.rewrite([_Record("rewritten", "new")], header=_Header("id")) + elif action == "unlink": + io.unlink(store.path) + else: + assert store.load().records == (_Record("one", "old"),) + assert tree(io.root) == untouched + if action == "unlink": + assert not (moved / "session.jsonl").exists() + elif action in {"append", "batch", "rewrite"}: + assert b"new" in (moved / "session.jsonl").read_bytes() + elif action == "repair": + assert b"unterminated" not in (moved / "session.jsonl").read_bytes() + + +@pytest.mark.parametrize("stage", ["open", "write", "replace", "unlink", "fsync", "lock"]) +def test_replacement_at_native_boundaries_stays_in_original_parent(tmp_path, monkeypatch, stage): + import fcntl + + with borrowed(tmp_path / "root") as io: + path = io.root / "session.jsonl" + io.atomic_write(path, b"old") + replacement = [] + namespace = fcntl if stage == "lock" else module.os + name = "flock" if stage == "lock" else stage + original = getattr(namespace, name) + + def swap(*args, **kwargs): + if not replacement: + replacement.append(replace_root(io.root)) + replacement.append(tree(io.root)) + return original(*args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(namespace, name, swap) + if stage == "unlink": + io.unlink(path) + elif stage == "lock": + with io.lock(path.with_suffix(".lock"), exclusive=True): + pass + elif stage == "write": + io.append_bytes(path, b"appended") + else: + io.atomic_write(path, b"rewritten") + assert replacement and tree(io.root) == replacement[1] + + +@pytest.mark.parametrize("name", ["../outside", "/outside", "bad\\name", "bad\0name"]) +def test_lexical_escape_rejected_before_native_io(tmp_path, monkeypatch, name): + with borrowed(tmp_path / "root") as io: + with monkeypatch.context() as patch: + patch.setattr(module.os, "open", lambda *_, **__: pytest.fail("opened invalid path")) + with pytest.raises(ValueError): + io.read_bytes(io.root / name) + + +@pytest.mark.parametrize("kind", ["parent_symlink", "leaf_symlink", "fifo", "hardlink"]) +def test_unsafe_parent_or_leaf_cannot_write(tmp_path, kind): + with borrowed(tmp_path / "root") as io: + outside = tmp_path / "outside" + outside.mkdir(mode=0o700) + original = outside / "value" + original.write_bytes(b"safe") + path = io.root / "value" + if kind == "parent_symlink": + path.symlink_to(outside, target_is_directory=True) + path = path / "value" + elif kind == "leaf_symlink": + path.symlink_to(original) + elif kind == "fifo": + os.mkfifo(path, 0o600) + else: + os.link(original, path) + with pytest.raises(OSError): + io.append_bytes(path, b"unsafe") + assert original.read_bytes() == b"safe" + + +def test_temporary_collision_never_acquires_cleanup_authority(tmp_path, monkeypatch): + with borrowed(tmp_path / "root") as io: + collision = io.root / ".value.fixed.tmp" + collision.write_bytes(b"not ours") + monkeypatch.setattr(module.secrets, "token_hex", lambda _: "fixed") + with pytest.raises(FileExistsError): + io.atomic_write(io.root / "value", b"new") + assert collision.read_bytes() == b"not ours" + + +@pytest.mark.parametrize("stage", ["write", "file_fsync", "replace", "directory_fsync"]) +def test_atomic_failure_preserves_commit_boundary_and_removes_only_own_temp(tmp_path, monkeypatch, stage): + with borrowed(tmp_path / "root") as io: + path = io.root / "value" + io.atomic_write(path, b"old") + failure = OSError("test native failure") + original_fsync = module.os.fsync + + def fail(*_, **__): + raise failure + + def sync(fd): + directory = stat.S_ISDIR(os.fstat(fd).st_mode) + if directory == (stage == "directory_fsync"): + raise failure + original_fsync(fd) + + with monkeypatch.context() as patch: + patch.setattr(module.os, "fsync" if "fsync" in stage else stage, sync if "fsync" in stage else fail) + with pytest.raises(OSError) as raised: + io.atomic_write(path, b"new") + assert raised.value is failure + assert path.read_bytes() == (b"new" if stage == "directory_fsync" else b"old") + assert not tuple(io.root.glob("*.tmp")) + assert io.cleanup_pending == (stage == "directory_fsync") + io.cleanup() + + +def test_unlink_cleanup_debt_retains_parent_and_retries_after_root_replacement(tmp_path, monkeypatch): + with borrowed(tmp_path / "root") as io: + failure = OSError("test write failure") + with monkeypatch.context() as patch: + patch.setattr(module.os, "write", lambda *_: (_ for _ in ()).throw(failure)) + patch.setattr(module.os, "unlink", lambda *_, **__: (_ for _ in ()).throw(OSError("test cleanup"))) + with pytest.raises(OSError) as raised: + io.atomic_write(io.root / "value", b"new") + assert raised.value is failure and io.cleanup_pending + moved = replace_root(io.root) + untouched = tree(io.root) + io.cleanup() + assert not tuple(moved.glob("*.tmp")) and tree(io.root) == untouched + + +def test_unknown_close_preserves_primary_closes_other_fds_and_never_retries_number(tmp_path, monkeypatch): + root = tmp_path / "root" + root.mkdir(mode=0o700) + fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY) + io = RootedFileIO(root, fd) + closed = [] + original_close = os.close + primary = ValueError("test body") + + def close(descriptor): + closed.append(descriptor) + original_close(descriptor) + if len(closed) == 1: + raise OSError("test close result lost") + + try: + with monkeypatch.context() as patch: + patch.setattr(module.os, "close", close) + with pytest.raises(ValueError) as raised: + with io.lock(root / "guard", exclusive=True): + raise primary + assert raised.value is primary + assert len(closed) == 2 and fd not in closed and io.cleanup_pending + with monkeypatch.context() as patch: + patch.setattr(module.os, "close", lambda *_: pytest.fail("retried unknown close")) + with pytest.raises(OSError, match="unknown"): + io.cleanup() + assert stat.S_ISDIR(os.fstat(fd).st_mode) + finally: + original_close(fd) # Fixture knows both injected closes actually succeeded. + + +def test_rooted_journal_refuses_path_only_lock_factory(tmp_path): + with borrowed(tmp_path / "root") as io: + with pytest.raises(ValueError, match="pathname lock factory"): + JsonlJournal(io.root / "value", record_codec=_RecordCodec(), file_io=io, lock_factory=lambda *_: None) + + +@pytest.mark.parametrize("action", ["load", "append", "batch", "rewrite", "repair"]) +def test_journal_lock_and_data_keep_same_parent_after_nested_directory_replacement(tmp_path, monkeypatch, action): + import fcntl + + with borrowed(tmp_path / "root") as io: + parent = io.root / "sub" + parent.mkdir(mode=0o700) + store = JsonlJournal( + parent / "session.jsonl", file_io=io, + record_codec=_RecordCodec(), header_codec=_HeaderCodec(), + load_policy=JournalLoadPolicy(header="required", partial_tail="repair"), + ) + store.rewrite([_Record("one", "old")], header=_Header("id")) + if action == "repair": + io.append_bytes(store.path, b"partial") + original = fcntl.flock + snapshots = [] + + def flock(fd, mode): + result = original(fd, mode) + if not snapshots: + parent.rename(io.root / "previous-sub") + parent.mkdir(mode=0o700) + (parent / "session.jsonl").write_bytes(b"must stay untouched") + snapshots.append(tree(parent)) + return result + + with monkeypatch.context() as patch: + patch.setattr(fcntl, "flock", flock) + if action == "append": + store.append(_Record("two", "new")) + elif action == "batch": + store.append_batch([_Record("two", "new")]) + elif action == "rewrite": + store.rewrite([_Record("two", "new")], header=_Header("id")) + else: + assert store.load().records == (_Record("one", "old"),) + assert snapshots and tree(parent) == snapshots[0] + original_bytes = (io.root / "previous-sub" / "session.jsonl").read_bytes() + if action in {"append", "batch", "rewrite"}: + assert b"new" in original_bytes + if action == "repair": + assert b"partial" not in original_bytes + + +def test_unlinked_temporary_sync_debt_never_redeletes_replacement(tmp_path, monkeypatch): + with borrowed(tmp_path / "root") as io: + primary = ValueError("test write failed") + monkeypatch.setattr(module.secrets, "token_hex", lambda _: "fixed") + with monkeypatch.context() as patch: + patch.setattr(module.os, "write", lambda *_: (_ for _ in ()).throw(primary)) + patch.setattr(module.os, "fsync", lambda *_: (_ for _ in ()).throw(OSError("test sync failed"))) + with pytest.raises(ValueError) as raised: + io.atomic_write(io.root / "value", b"new") + assert raised.value is primary and io.cleanup_pending + assert not (io.root / ".value.fixed.tmp").exists() + (io.root / ".value.fixed.tmp").write_bytes(b"not our old temp") + moved = replace_root(io.root) + untouched = tree(io.root) + with monkeypatch.context() as patch: + patch.setattr(module.os, "unlink", lambda *_, **__: pytest.fail("retried settled unlink")) + io.cleanup() + assert (moved / ".value.fixed.tmp").read_bytes() == b"not our old temp" + assert tree(io.root) == untouched + + +def test_committed_replace_sync_debt_never_deletes_new_same_named_temporary(tmp_path, monkeypatch): + with borrowed(tmp_path / "root") as io: + monkeypatch.setattr(module.secrets, "token_hex", lambda _: "fixed") + original = os.fsync + + def fsync(fd): + if stat.S_ISDIR(os.fstat(fd).st_mode): + (io.root / ".value.fixed.tmp").write_bytes(b"new unrelated file") + raise OSError("test commit durability unknown") + original(fd) + + with monkeypatch.context() as patch: + patch.setattr(module.os, "fsync", fsync) + with pytest.raises(OSError, match="durability unknown"): + io.atomic_write(io.root / "value", b"committed") + assert (io.root / "value").read_bytes() == b"committed" and io.cleanup_pending + io.cleanup() + assert (io.root / ".value.fixed.tmp").read_bytes() == b"new unrelated file" + + +def test_rooted_lock_excludes_independent_process_and_releases_after_context(tmp_path): + script = """ +import os, sys +from pathlib import Path +from loushang.harness.journal._rooted_io import RootedFileIO +root = Path(sys.argv[1]) +fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY) +io = RootedFileIO(root, fd) +try: + try: + with io.lock(root / 'guard', exclusive=True, blocking=False): + print('acquired') + except BlockingIOError: + print('busy') + assert not io.cleanup_pending +finally: + os.close(fd) +""" + with borrowed(tmp_path / "root") as io: + def child(): + result = subprocess.run( + [sys.executable, "-c", script, str(io.root)], capture_output=True, text=True, + timeout=5, env={**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parents[3] / "src")}, + ) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + with io.lock(io.root / "guard", exclusive=True): + assert child() == "busy" + assert child() == "acquired" + + +def test_file_reference_expires_with_its_parent_borrow(tmp_path): + with borrowed(tmp_path / "root") as io: + with io.bind(io.root / "value") as target: + target.atomic_write(b"value") + with pytest.raises(OSError, match="borrow has ended"): + target.append_bytes(b"late") + + +def test_recovery_borrows_do_not_revive_expired_references(tmp_path): + with borrowed(tmp_path / "root") as io: + recovered = [] + attempts = [] + with pytest.raises(OSError, match="test retained recovery"): + with io.directory() as original: + leaf = original.file("payload") + leaf.atomic_write(b"before") + + def recover(current): + for previous in recovered: + with pytest.raises(OSError, match="cleanup borrow has ended"): + previous.stat() + with pytest.raises(OSError, match="borrow has ended"): + original.stat() + with pytest.raises(OSError, match="borrow has ended"): + leaf.read_bytes() + rebound = current.reborrow(original) + recovered.extend((current, rebound, rebound.file("payload"))) + attempts.append(True) + if len(attempts) == 1: + raise OSError("test retained recovery") + rebound.file("payload").atomic_write(b"after") + + original.retain_cleanup(recover) + assert io.cleanup_pending + for reference in recovered: + with pytest.raises(OSError, match="cleanup borrow has ended"): + reference.stat() + io.cleanup() + assert len(attempts) == 2 and not io.cleanup_pending + for reference in recovered: + with pytest.raises(OSError, match="cleanup borrow has ended"): + reference.stat() + assert io.read_bytes(io.root / "payload") == b"after" + + +def test_inherited_bound_file_refuses_all_operations_before_native_io(tmp_path, monkeypatch): + with borrowed(tmp_path / "root") as io: + with io.bind(io.root / "value") as target: + target.atomic_write(b"parent") + pid = os.fork() + if pid == 0: + try: + def forbidden(*_, **__): + raise AssertionError("fork child reached native IO") + + monkeypatch.setattr(module.os, "open", forbidden) + monkeypatch.setattr(module.os, "unlink", forbidden) + operations = ( + target.read_bytes, + lambda: target.append_bytes(b"child"), + lambda: target.atomic_write(b"child"), + target.unlink, + lambda: target.acquire_lock(exclusive=True), + target._operation.cleanup, + ) + for operation in operations: + try: + operation() + except OSError as exc: + assert "after fork" in str(exc) + else: + raise AssertionError("inherited borrow was accepted") + except BaseException: + os._exit(1) + os._exit(0) + waited, status = os.waitpid(pid, 0) + assert waited == pid and os.waitstatus_to_exitcode(status) == 0 + assert target.read_bytes() == b"parent" + target.append_bytes(b"-continued") + assert io.read_bytes(io.root / "value") == b"parent-continued" diff --git a/tests/harness/journal/test_rooted_publication.py b/tests/harness/journal/test_rooted_publication.py new file mode 100644 index 000000000..5f7a72f37 --- /dev/null +++ b/tests/harness/journal/test_rooted_publication.py @@ -0,0 +1,100 @@ +"""A single opt-in publication witness stays in the original root ledger.""" + +from __future__ import annotations + +import os +import sys + +import pytest + +from loushang.harness.journal import _rooted_io as native + +from .test_rooted_io import borrowed + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux pinned publication") + + +def test_publication_pin_survives_unlink_and_is_not_a_per_write_fd_leak(tmp_path): + with borrowed(tmp_path / "root") as io: + path = io.root / "data" + witness = io.retain_next_publication(path) + assert not io.cleanup_pending and not witness.operation.descriptors + with pytest.raises(OSError, match="already registered"): + io.retain_next_publication(path) + io.atomic_write(path, b"original") + fd, = witness.operation.descriptors + identity = witness.identity + assert (os.fstat(fd).st_dev, os.fstat(fd).st_ino) == identity + path.unlink() + assert os.fstat(fd).st_nlink == 0 + for index in range(8): + io.atomic_write(path, f"replacement-{index}".encode()) + assert witness.identity == identity + assert tuple(witness.operation.descriptors) == (fd,) + assert path.stat().st_ino != identity[1] + assert os.fstat(fd).st_nlink == 0 and io.cleanup_pending + io.cleanup() + with pytest.raises(OSError): + os.fstat(fd) + with pytest.raises(OSError, match="no retained completion"): + _ = witness.identity + assert path.read_bytes() == b"replacement-7" + + +def test_normal_atomic_writes_never_retain_publication_descriptors(tmp_path): + with borrowed(tmp_path / "root") as io: + for _ in range(8): + io.atomic_write(io.root / "data", b"ordinary") + assert io._publication is None and not io.cleanup_pending + + +def test_failed_publication_cannot_mint_completed_identity(tmp_path, monkeypatch): + with borrowed(tmp_path / "root") as io: + witness = io.retain_next_publication(io.root / "data") + + def refused(*args, **kwargs): + raise OSError("test refused publication") + + with monkeypatch.context() as patch: + patch.setattr(native.os, "replace", refused) + with pytest.raises(OSError, match="refused publication"): + io.atomic_write(io.root / "data", b"not published") + assert not (io.root / "data").exists() + fd, = witness.operation.descriptors + assert os.fstat(fd).st_nlink == 0 and not witness.published + with pytest.raises(OSError, match="no retained completion"): + _ = witness.identity + io.cleanup() + + +def test_publication_unknown_close_keeps_original_debt_without_reclosing_fd(tmp_path, monkeypatch): + with borrowed(tmp_path / "root") as io: + witness = io.retain_next_publication(io.root / "data") + io.atomic_write(io.root / "data", b"original") + fd, = witness.operation.descriptors + close = native.os.close + calls = [] + + def lost(value): + assert value == fd + calls.append(value) + close(value) + raise OSError("test lost actual close receipt") + + try: + with monkeypatch.context() as patch: + patch.setattr(native.os, "close", lost) + with pytest.raises(OSError, match="lost actual close"): + io.cleanup() + with pytest.raises(OSError, match="cleanup outcome unknown"): + io.cleanup() + assert calls == [fd] and io.cleanup_pending + with pytest.raises(OSError, match="unsettled cleanup debt"): + io.read_bytes(io.root / "data") + with pytest.raises(OSError, match="no retained completion"): + _ = witness.identity + finally: + # Only this injector observed the real close complete. It removes + # its synthetic unknown marker; production cannot do this. + witness.operation.descriptors.clear() + io.cleanup() From a22eab92e5521a29898ac3c867d69c7ece8944f8 Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 05:21:00 +0000 Subject: [PATCH 15/44] feat(harness): admit retained transcript writers Introduce the shared retained directory lease, transcript writer lease and store-admission witness used to bind one logical writer to a validated Linux root. Keep creation, binding, process inheritance and cleanup outcomes explicit and fail closed on replacement, unsafe modes, duplicate authority or unknown close results. Cover private root creation, cross-process exclusion, store witnesses, identity replacement, cancellation boundaries and retryable cleanup without importing transcript lifecycle consumers. --- .../harness/journal/_directory_lease.py | 380 +++++++++++++++ .../harness/transcript/store_admission.py | 389 +++++++++++++++ .../harness/transcript/writer_lease.py | 43 ++ .../transcript/test_store_admission.py | 450 ++++++++++++++++++ tests/harness/transcript/test_writer_lease.py | 204 ++++++++ .../harness/transcript/test_writer_process.py | 129 +++++ .../transcript/test_writer_root_creation.py | 240 ++++++++++ 7 files changed, 1835 insertions(+) create mode 100644 src/loushang/harness/journal/_directory_lease.py create mode 100644 src/loushang/harness/transcript/store_admission.py create mode 100644 src/loushang/harness/transcript/writer_lease.py create mode 100644 tests/harness/transcript/test_store_admission.py create mode 100644 tests/harness/transcript/test_writer_lease.py create mode 100644 tests/harness/transcript/test_writer_process.py create mode 100644 tests/harness/transcript/test_writer_root_creation.py diff --git a/src/loushang/harness/journal/_directory_lease.py b/src/loushang/harness/journal/_directory_lease.py new file mode 100644 index 000000000..cca6504f1 --- /dev/null +++ b/src/loushang/harness/journal/_directory_lease.py @@ -0,0 +1,380 @@ +"""Private Linux retained directory writer mechanism shared by storage owners.""" + +from __future__ import annotations + +import hashlib +import json +import os +import stat +import sys +from contextlib import suppress +from pathlib import Path +from threading import RLock, current_thread, main_thread +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ._rooted_io import RootedFileIO + + +class DirectoryWriterError(RuntimeError): + """Bounded failure without logical identities or paths in its message.""" + + prefix = "directory_writer" + + def __init__(self, code: str) -> None: + self.code = code + super().__init__(f"{self.prefix}:{code}") + + +class DirectoryWriterLease: + """Retained one-shot writer admission; no destructor or automatic release. + + The owner identity validates borrowing, but does not partition the physical + lock. Subsystems select a fixed directory/hash domain and error projection. + Root creation is explicit and opt-in; no destructor or task ownership. + """ + + _error_type = DirectoryWriterError + _lock_directory = ".directory-writers" + _hash_domain = "directory-writer/v1" + + def __init__(self, root: Path, owner_id: str, authority_id: str, *, create_root: bool = False, + expected_root_identity: tuple[int, int] | None = None, + expected_parent_identity: tuple[int, int] | None = None, + exclusive_root: bool = False, create_lock: bool = True) -> None: + root = Path(root) + if not root.is_absolute() or len(str(root)) > 4096 or "\0" in str(root): + raise self._error_type("invalid") + if type(create_root) is not bool: + raise self._error_type("invalid") + if (type(exclusive_root) is not bool or type(create_lock) is not bool + or (exclusive_root and not create_root)): + raise self._error_type("invalid") + for identity in (expected_root_identity, expected_parent_identity): + if identity is not None and ( + type(identity) is not tuple or len(identity) != 2 + or any(type(item) is not int or not 0 <= item < 2**64 for item in identity) + or identity[1] == 0 + ): + raise self._error_type("invalid") + if ((expected_parent_identity is not None and expected_root_identity is None) + or (create_root and expected_root_identity is not None)): + raise self._error_type("invalid") + if create_root: + try: + if (root == root.parent or ".." in root.parts or str(root).startswith("//") + or len(root.parts) > 64 or len(str(root).encode("utf-8")) > 4096): + raise self._error_type("invalid") + except UnicodeError: + raise self._error_type("invalid") from None + for value in (owner_id, authority_id): + if type(value) is not str or not value.strip() or len(value) > 65536: + raise self._error_type("invalid") + try: + if len(value.encode("utf-8")) > 65536: + raise self._error_type("invalid") + except UnicodeError: + raise self._error_type("invalid") from None + self._root, self._owner_id, self._authority_id = root, owner_id, authority_id + payload = json.dumps([self._hash_domain, authority_id], ensure_ascii=True).encode() + self._name = hashlib.sha256(payload).hexdigest() + ".lock" + self._canonical: Path | None = None + self._fds: dict[str, int] = {} + self._unknown: set[str] = set() + self._create_root = create_root + self._exclusive_root = exclusive_root + self._create_lock = create_lock + self._expected_root_identity = expected_root_identity + self._expected_parent_identity = expected_parent_identity + self._expected_directory_identity: tuple[int, int] | None = None + self._sync_pending: set[str] = set() + self._root_edges: list[tuple[str, str, str, tuple[int, int]]] = [] + self._mutex = RLock() + self._pid = os.getpid() + self._inherited_disposal_pid: int | None = None + self._claim_owner: object | None = None + self._attempted = self._held = self._closing = False + + @property + def cleanup_pending(self) -> bool: + return bool(self._fds or self._unknown or self._sync_pending) + + @property + def claimed_owner(self) -> object | None: + """Recover the exact owner after a claimed preparation's lost delivery.""" + return self._claim_owner + + def _claim_binding(self, owner: object, *, root: Path, owner_id: str, authority_id: str) -> None: + self._enter() + try: + if self._closing or not self._held or self._claim_owner is not None: + raise self._error_type("closed") + if (self._canonical != root or self._owner_id != owner_id + or self._authority_id != authority_id): + raise self._error_type("conflict") + self._claim_owner = owner # Final commit; recovery reference retained. + finally: + self._mutex.release() + + def _borrow_root_io(self, *, root: Path, owner_id: str, authority_id: str, + directory_bindings: tuple[tuple[tuple[int, int], str, tuple[int, int]], ...] = ()) -> RootedFileIO: + """Construct a no-open projection before the preparation's final claim.""" + from loushang.harness.journal._rooted_io import RootedFileIO + + self._enter() + try: + if self._closing or not self._held or self._claim_owner is not None: + raise self._error_type("closed") + if (self._canonical != root or self._owner_id != owner_id + or self._authority_id != authority_id): + raise self._error_type("conflict") + return RootedFileIO(root, self._fds["root"], directory_bindings=directory_bindings) + finally: + self._mutex.release() + + def _close_claimed(self, owner: object) -> None: + self._enter() + try: + if self._claim_owner is not owner: + raise self._error_type("conflict") + self._close_fds() + finally: + self._mutex.release() + + def acquire(self) -> None: + """Attempt once without queuing; caller closes even failed admissions.""" + self._enter() + try: + if self._attempted or self._closing: + raise self._error_type("closed") + self._attempted = True + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC | os.O_NOFOLLOW + if self._create_root: + self._prepare_root(flags) + else: + self._canonical = self._resolve_root() + if self._expected_root_identity is not None and self._canonical != self._root: + raise self._error_type("conflict") + if self._expected_parent_identity is not None: + self._fds["parent"] = os.open(self._canonical.parent, flags) + self._check_expected_parent() + self._fds["root"] = os.open(self._canonical.name, flags, dir_fd=self._fds["parent"]) + else: + self._fds["root"] = os.open(self._canonical, flags) + self._check_expected_root() + self._validate_directory(self._fds["root"], private=False) + if self._create_lock and self._expected_directory_identity is None: + with suppress(FileExistsError): + os.mkdir(self._lock_directory, mode=0o700, dir_fd=self._fds["root"]) + self._fds["directory"] = os.open(self._lock_directory, flags, dir_fd=self._fds["root"]) + if self._expected_directory_identity is not None: + status = os.fstat(self._fds["directory"]) + if (status.st_dev, status.st_ino) != self._expected_directory_identity: + raise self._error_type("conflict") + self._validate_directory(self._fds["directory"], private=True) + self._fds["lock"] = os.open( + self._name, os.O_RDWR | (os.O_CREAT if self._create_lock else 0) + | os.O_CLOEXEC | os.O_NOFOLLOW | os.O_NONBLOCK, + 0o600, dir_fd=self._fds["directory"], + ) + self._validate_binding() + import fcntl + + try: + fcntl.flock(self._fds["lock"], fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise self._error_type("busy") from None + self._validate_binding() + self._held = True + except OSError: + raise self._error_type("unavailable") from None + finally: + self._mutex.release() + + def check_binding(self, *, owner_id: str, authority_id: str) -> None: + """Revalidate a held owner's binding; this never acquires another lock.""" + self._enter() + try: + if self._closing or not self._held or self._unknown: + raise self._error_type("closed") + if (type(owner_id) is not str or type(authority_id) is not str + or owner_id != self._owner_id or authority_id != self._authority_id): + raise self._error_type("conflict") + self._validate_binding() + except OSError: + raise self._error_type("unavailable") from None + finally: + self._mutex.release() + + def close(self) -> None: + """Permanently fence this owner, then close each retained fd once.""" + self._enter() + try: + if self._claim_owner is not None: + raise self._error_type("busy") + self._close_fds() + finally: + self._mutex.release() + + def close_inherited(self) -> None: + """Fork-child main-thread disposal, never LOCK_UN on a shared OFD. + + Normal methods refuse the fork child. Do not enter an inherited mutex: + another parent thread may have held it at fork and no longer exists here. + Only this child's main thread may dispose its inherited copies. + """ + pid = os.getpid() + if pid == self._pid or current_thread() is not main_thread(): + raise self._error_type("unsupported") + if self._inherited_disposal_pid != pid: + if not self._held or self._closing: + # Fork may have captured an open before publication, or a close + # after fd reuse but before removal. No inherited-number retry. + self._unknown.add("fork_snapshot") + self._closing = True + raise self._error_type("unavailable") + self._inherited_disposal_pid = pid + self._close_fds() + + def _enter(self) -> None: + if sys.platform != "linux" or os.getpid() != self._pid: + raise self._error_type("unsupported") + if not self._mutex.acquire(blocking=False): + raise self._error_type("busy") + + def _close_fds(self) -> None: + self._closing = True + self._sync_root_parents() + self._release_fds(tuple(dict.fromkeys(("lock", "directory", "root", *self._fds)))) + + def _release_fds(self, keys: tuple[str, ...]) -> None: + for key in keys: + if key not in self._fds or key in self._unknown: + continue + self._unknown.add(key) + try: + os.close(self._fds[key]) + except BaseException: + pass # fd may already be closed/reused; never retry. + else: + del self._fds[key] + self._unknown.remove(key) + if self._unknown: + raise self._error_type("unavailable") + + def _sync_root_parents(self) -> None: + for key in tuple(self._sync_pending): + try: + os.fsync(self._fds[key]) + except OSError: + raise self._error_type("unavailable") from None + self._sync_pending.remove(key) + + def _prepare_root(self, flags: int) -> None: + """Bind each edge before sync; retain failures in the original ledger.""" + self._canonical = self._root + self._fds["ancestor:0"] = os.open("/", flags) + parent = "ancestor:0" + parts = self._root.parts[1:] + for index, part in enumerate(parts): + self._check_root_edges() + self._validate_root_parent(self._fds[parent]) + self._sync_pending.add(parent) # mkdir may succeed without a receipt. + try: + os.mkdir(part, mode=0o700, dir_fd=self._fds[parent]) + except FileExistsError: + if self._exclusive_root and index == len(parts) - 1: + raise self._error_type("conflict") from None + key = "root" if index == len(parts) - 1 else f"ancestor:{index + 1}" + self._fds[key] = os.open(part, flags, dir_fd=self._fds[parent]) + if key == "root": + self._validate_directory(self._fds[key], private=False) + else: + self._validate_root_parent(self._fds[key]) + info = os.fstat(self._fds[key]) + self._root_edges.append((parent, part, key, (info.st_dev, info.st_ino))) + self._check_root_edges() + self._sync_root_parents() + self._check_root_edges() + parent = key + # Root is never reopened by pathname. Settle temporary ancestors before + # lock acquisition; successful admission retains only the original 3 fds. + self._release_fds(tuple(key for key in self._fds if key != "root")) + self._root_edges.clear() + if self._resolve_root() != self._canonical or not os.path.samestat( + os.stat(self._canonical, follow_symlinks=False), os.fstat(self._fds["root"]), + ): + raise self._error_type("conflict") + + def _check_root_edges(self) -> None: + for parent, name, key, identity in self._root_edges: + self._validate_root_parent(self._fds[parent]) + current = os.stat(name, dir_fd=self._fds[parent], follow_symlinks=False) + opened = os.fstat(self._fds[key]) + if (not stat.S_ISDIR(current.st_mode) or (current.st_dev, current.st_ino) != identity + or not os.path.samestat(current, opened)): + raise self._error_type("conflict") + if key == "root": + self._validate_directory(self._fds[key], private=False) + else: + self._validate_root_parent(self._fds[key]) + + def _validate_root_parent(self, fd: int) -> None: + info = os.fstat(fd) + if (not stat.S_ISDIR(info.st_mode) or info.st_uid not in {0, os.geteuid()} + or (info.st_mode & 0o022 and not (info.st_uid == 0 and info.st_mode & stat.S_ISVTX))): + raise self._error_type("unsafe") + + @classmethod + def _validate_directory(cls, fd: int, *, private: bool) -> None: + opened = os.fstat(fd) + mode = stat.S_IMODE(opened.st_mode) + if (not stat.S_ISDIR(opened.st_mode) or opened.st_uid != os.geteuid() + or (mode != 0o700 if private else bool(mode & 0o022))): + raise cls._error_type("unsafe") + + def _validate_binding(self) -> None: + assert self._canonical is not None + if self._resolve_root() != self._canonical: + raise self._error_type("conflict") + self._check_expected_parent() + self._check_expected_root() + self._validate_directory(self._fds["root"], private=False) + self._validate_directory(self._fds["directory"], private=True) + paths = ( + ("root", os.stat(self._canonical, follow_symlinks=False)), + ("directory", os.stat(self._lock_directory, dir_fd=self._fds["root"], follow_symlinks=False)), + ("lock", os.stat(self._name, dir_fd=self._fds["directory"], follow_symlinks=False)), + ) + for key, path_stat in paths: + if not os.path.samestat(path_stat, os.fstat(self._fds[key])): + raise self._error_type("conflict") + opened = os.fstat(self._fds["lock"]) + if (not stat.S_ISREG(opened.st_mode) or opened.st_uid != os.geteuid() + or stat.S_IMODE(opened.st_mode) != 0o600 or opened.st_nlink != 1): + raise self._error_type("unsafe") + + def _check_expected_root(self) -> None: + if self._expected_root_identity is None: + return + opened = os.fstat(self._fds["root"]) + if (opened.st_dev, opened.st_ino) != self._expected_root_identity: + raise self._error_type("conflict") + self._check_expected_parent() + + def _check_expected_parent(self) -> None: + if self._expected_parent_identity is None: + return + assert self._canonical is not None + self._validate_root_parent(self._fds["parent"]) + opened = os.fstat(self._fds["parent"]) + named = os.stat(self._canonical.parent, follow_symlinks=False) + if ((opened.st_dev, opened.st_ino) != self._expected_parent_identity + or not os.path.samestat(opened, named)): + raise self._error_type("conflict") + + def _resolve_root(self) -> Path: + try: + return self._root.resolve(strict=True) + except (OSError, RuntimeError): + raise self._error_type("unavailable") from None diff --git a/src/loushang/harness/transcript/store_admission.py b/src/loushang/harness/transcript/store_admission.py new file mode 100644 index 000000000..d378b3544 --- /dev/null +++ b/src/loushang/harness/transcript/store_admission.py @@ -0,0 +1,389 @@ +"""Lazy, process-independent admission of one shared transcript store on Linux. + +Products select the store and state roots. Discovery uses only ``inspect``; +it cannot register or initialize storage. A writer retains this owner before native work, then transfers +the returned physical identities to its original writer preparation. Closing +does not delete histories, attachments, witnesses or stable lock files. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from dataclasses import dataclass +from pathlib import Path +from secrets import token_hex +from threading import Event + +from loushang.harness.journal._directory_lease import DirectoryWriterLease +from loushang.harness.journal._rooted_io import RootedFileIO + +from .writer_lease import TranscriptWriterError + +Identity = tuple[int, int] +_VERSION = "transcript-store-admission/v1" + + +@dataclass(frozen=True) +class TranscriptStoreBinding: + root_identity: Identity + parent_identity: Identity + family_id: str | None = None + member_id: str | None = None + shared_identities: tuple[Identity, Identity, Identity] | None = None + + +class _WitnessLease(DirectoryWriterLease): + _error_type = TranscriptWriterError + _lock_directory = ".store-admission-locks" + _hash_domain = _VERSION + + +class _StoreRoot(DirectoryWriterLease): + """Only a directory handle; reuse the native creation and cleanup ledger.""" + + _error_type = TranscriptWriterError + + def acquire_child(self, parent: _StoreRoot, *, create: bool = False) -> None: + """Bind/create one child through the already retained original parent.""" + self._enter() + try: + if self._attempted or self._closing or self._root.parent != parent._root: + raise self._error_type("closed") + self._attempted = True + parent.binding() + self._canonical = self._root + self._fds["parent"] = os.dup(parent._fds["root"]) + if create: + self._sync_pending.add("parent") + os.mkdir(self._root.name, mode=0o700, dir_fd=self._fds["parent"]) + self._fds["root"] = os.open(self._root.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC, + dir_fd=self._fds["parent"]) + self._sync_root_parents() + self.binding() + parent.binding() + except FileExistsError: + raise self._error_type("conflict") from None + except OSError: + raise self._error_type("unavailable") from None + finally: + self._mutex.release() + + def acquire(self) -> None: + self._enter() + try: + if self._attempted or self._closing: + raise self._error_type("closed") + self._attempted = True + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC + if self._create_root: + self._prepare_root(flags) + else: + self._canonical = self._resolve_root() + if self._canonical != self._root: + raise self._error_type("conflict") + self._fds["root"] = os.open(self._canonical, flags) + self._fds["parent"] = os.open(self._root.parent, flags) + self.binding() + self._release_fds(tuple(name for name in self._fds if name.startswith("ancestor:"))) + except OSError: + raise self._error_type("unavailable") from None + finally: + self._mutex.release() + + def binding(self) -> TranscriptStoreBinding: + self._validate_directory(self._fds["root"], private=False) + self._validate_root_parent(self._fds["parent"]) + root, parent = os.fstat(self._fds["root"]), os.fstat(self._fds["parent"]) + if (self._resolve_root() != self._root + or not os.path.samestat(parent, os.stat(self._root.parent, follow_symlinks=False)) + or not os.path.samestat(root, os.stat(self._root.name, dir_fd=self._fds["parent"], + follow_symlinks=False))): + raise self._error_type("conflict") + return TranscriptStoreBinding((root.st_dev, root.st_ino), (parent.st_dev, parent.st_ino)) + + +class TranscriptStoreAdmission: + """One-shot, short initialization lock, independent of Sessions/Products. + + ``create_if_missing`` belongs to an actual new persistent write, never + service startup or restore. Existing legacy stores are registered without + modifying their contents. Missing or incomplete durable facts are not + permission to recreate an already admitted store. + """ + + def __init__(self, root: Path, *, state_root: Path, create_if_missing: bool = False, + root_observed: Event | None = None) -> None: + root, state_root = Path(root), Path(state_root) + if type(create_if_missing) is not bool or (root_observed is not None and type(root_observed) is not Event): + raise TranscriptWriterError("invalid") + for path in (root, state_root): + if (not path.is_absolute() or path == path.parent or ".." in path.parts + or str(path).startswith("//")): + raise TranscriptWriterError("invalid") + # All store/attachment writers and temporary cleaners must keep out of + # the selected state domain; callers also isolate it from runtime roots. + if state_root.is_relative_to(root.parent) or root.parent.is_relative_to(state_root): + raise TranscriptWriterError("invalid") + key = hashlib.sha256(f"{_VERSION}\0{os.geteuid()}\0{root}".encode()).hexdigest() + self.root, self.witness_root = root, state_root / key + self._key, self._create = key, create_if_missing + self._root_observed = root_observed + self._existing = _WitnessLease(self.witness_root, _VERSION, key, create_lock=False) + self._fresh = _WitnessLease(self.witness_root, _VERSION, key, + create_root=True, exclusive_root=True) + self._root_existing = _StoreRoot(root, _VERSION, key) + self._root_fresh = _StoreRoot(root, _VERSION, key, create_root=True, exclusive_root=True) + self._witness: _WitnessLease | None = None + self._store: _StoreRoot | None = None + self._io: RootedFileIO | None = None + self._binding: TranscriptStoreBinding | None = None + self._attempted = self._closed = False + from ._store_family import _SharedFamily + + self._family = _SharedFamily(self, state_root) + + @property + def cleanup_pending(self) -> bool: + return (any(owner.cleanup_pending for owner in self._owners) + or (self._io is not None and self._io.cleanup_pending) or self._family.cleanup_pending) + + @property + def _owners(self) -> tuple[DirectoryWriterLease, ...]: + return self._root_existing, self._root_fresh, self._existing, self._fresh + + def open(self) -> TranscriptStoreBinding: + self._existing._enter() + try: + binding = self._open() + assert binding is not None + return binding + finally: + self._existing._mutex.release() + + def inspect(self) -> TranscriptStoreBinding | None: + """Open only known durable facts; unknown stores remain unregistered. + + The caller retains this owner before entering native IO, closes it + after the short observation, and keeps any failed cleanup for retry. + ``None`` is an unknown store, not permission to initialize one. + """ + self._existing._enter() + try: + return self._open(inspect_only=True) + finally: + self._existing._mutex.release() + + def _open(self, *, inspect_only: bool = False) -> TranscriptStoreBinding | None: + if self._attempted or self._closed: + raise TranscriptWriterError("closed") + self._attempted = True + try: + existing = not _missing(self.witness_root) + root_missing = _missing(self.root) + if self._family.present or (not existing and root_missing and not inspect_only and self._may_create()): + if not self._family.present: + self._reject_residue() + binding = self._family.open(inspect_only=inspect_only) + self._binding = binding + return binding + if existing: + # Negative precheck only: do not steal a creator's new lock + # before its first flock. Positive data is re-read under lock. + if _missing(self.witness_root / "admission.json"): + raise TranscriptWriterError("incomplete") + self._witness = self._existing + else: + if inspect_only: + if root_missing: + if self._root_observed is not None and self._root_observed.is_set(): + raise TranscriptWriterError("unavailable") + self._reject_residue() + return None + if root_missing: + if not self._may_create(): + raise TranscriptWriterError("unavailable") + self._reject_residue() + else: + # An unrecorded root beside shared writer/artifact residue + # is not evidence for silently adopting a lost family as v1. + self._reject_residue() + if not _missing(self.root / ".transcript-writers"): + raise TranscriptWriterError("conflict") + self._family.inspect_state_evidence(allow_legacy=True) + self._witness = self._fresh + self._witness.acquire() + if self._witness._canonical != self.witness_root: + raise TranscriptWriterError("conflict") + self._io = RootedFileIO(self.witness_root, self._witness._fds["root"]) + if existing: + record = self._read() + if record["phase"] != "initialized": + raise TranscriptWriterError("incomplete") + self._open_store(create=False) + assert self._store is not None + binding = self._store.binding() + if (record["root"] != list(binding.root_identity) + or record["parent"] != list(binding.parent_identity)): + raise TranscriptWriterError("conflict") + else: + if not root_missing: + # Pin legacy identity before publishing intent. A root or + # data-parent replacement is not a new legacy adoption. + self._open_store(create=False) + # Durable lock and intent precede every Session-root creation. + self._witness._sync_pending.update(("root", "directory", "lock")) + self._witness._sync_root_parents() + record = self._record() + self._write(record) + # Freeze the initial observation: a legacy root disappearing + # while intent is published never becomes a creation grant. + create = root_missing + if create: + if not self._may_create(): + raise TranscriptWriterError("unavailable") + self._reject_residue() + if create: + self._open_store(create=True) + assert self._store is not None + binding = self._store.binding() + record.update(phase="initialized", root=list(binding.root_identity), + parent=list(binding.parent_identity)) + self._write(record) + if self._read() != record: + raise TranscriptWriterError("conflict") + self._binding = binding + return binding + except (OSError, ValueError, RecursionError): + raise TranscriptWriterError("unavailable") from None + + def _reject_residue(self) -> None: + if any(os.path.lexists(self.root.parent / name) for name in ("session-assets", ".session-blob-writers")): + raise TranscriptWriterError("conflict") + + def check(self) -> None: + """Revalidate the original known observation, granting no write rights.""" + self._existing._enter() + try: + if self._closed or self._binding is None or self._store is None: + raise TranscriptWriterError("closed") + if self._binding.family_id is not None: + self._family.check() + return + record = self._read() + if (record["phase"] != "initialized" or self._store.binding() != self._binding + or record["root"] != list(self._binding.root_identity) + or record["parent"] != list(self._binding.parent_identity)): + raise TranscriptWriterError("conflict") + except (OSError, ValueError, RecursionError): + raise TranscriptWriterError("unavailable") from None + finally: + self._existing._mutex.release() + + def _may_create(self) -> bool: + return self._create and not (self._root_observed is not None and self._root_observed.is_set()) + + def _open_store(self, *, create: bool) -> None: + self._store = self._root_fresh if create else self._root_existing + if self._family._data is not None: + self._store.acquire_child(self._family._data, create=create) + else: + self._store.acquire() + + def _record(self) -> dict[str, object]: + assert self._witness is not None + return dict(version=_VERSION, store=self._key, operation=token_hex(16), phase="initializing", + witness=list(_identity(self._witness._fds["root"])), + lock=list(_identity(self._witness._fds["lock"])), root=None, parent=None) + + def _write(self, record: dict[str, object]) -> None: + assert self._io is not None and self._witness is not None + self._witness.check_binding(owner_id=_VERSION, authority_id=self._key) + self._io.atomic_write(self.witness_root / "admission.json", json.dumps(record).encode()) + self._witness.check_binding(owner_id=_VERSION, authority_id=self._key) + + def _read(self) -> dict[str, object]: + assert self._io is not None and self._witness is not None + self._witness.check_binding(owner_id=_VERSION, authority_id=self._key) + record = json.loads(self._io.read_bytes(self.witness_root / "admission.json", max_bytes=4096), + object_pairs_hook=_unique) + if type(record) is dict and record.get("version") == "transcript-store-admission/v2": + return self._family.validate_member(record) + if (type(record) is not dict + or set(record) != {"version", "store", "operation", "phase", "witness", "lock", "root", "parent"} + or record["version"] != _VERSION or record["store"] != self._key + or type(record["operation"]) is not str or len(record["operation"]) != 32 + or any(c not in "0123456789abcdef" for c in record["operation"]) + or record["phase"] not in ("initializing", "initialized") + or record["witness"] != list(_identity(self._witness._fds["root"])) + or record["lock"] != list(_identity(self._witness._fds["lock"]))): + raise TranscriptWriterError("conflict") + for name in ("witness", "lock", "root", "parent"): + value = record[name] + if record["phase"] == "initializing" and name in ("root", "parent"): + if value is not None: + raise TranscriptWriterError("conflict") + elif (type(value) is not list or len(value) != 2 + or any(type(item) is not int or not 0 <= item < 2**64 for item in value) or value[1] == 0): + raise TranscriptWriterError("conflict") + return record + + def close(self) -> None: + self._existing._enter() + try: + self._close() + finally: + self._existing._mutex.release() + + def release_locks(self) -> None: + """Release short admission locks but keep root pins with this owner.""" + if self._io is not None: + self._io.cleanup() + for owner in (self._existing, self._fresh): + owner.close() + self._family.release_locks() + + def _close(self) -> None: + self._closed = True + failures: list[Exception] = [] + if self._io is not None: + try: + self._io.cleanup() + except Exception as error: + failures.append(error) + try: + self._family.close() + except Exception as error: + failures.append(error) + for owner in self._owners: + if owner in (self._existing, self._fresh) and self._io is not None and self._io.cleanup_pending: + continue # Rooted IO must settle before releasing its borrowed fd. + try: + owner.close() + except Exception as error: + failures.append(error) + if failures: + raise failures[0] + + +def _identity(fd: int) -> Identity: + info = os.fstat(fd) + return info.st_dev, info.st_ino + + +def _missing(path: Path) -> bool: + try: + path.lstat() + except FileNotFoundError: + return True + return False # Permission/IO failures never become evidence of absence. + + +def _unique(pairs: list[tuple[str, object]]) -> dict[str, object]: + record: dict[str, object] = {} + for key, value in pairs: + if key in record: + raise ValueError("duplicate field") + record[key] = value + return record diff --git a/src/loushang/harness/transcript/writer_lease.py b/src/loushang/harness/transcript/writer_lease.py new file mode 100644 index 000000000..29334d58e --- /dev/null +++ b/src/loushang/harness/transcript/writer_lease.py @@ -0,0 +1,43 @@ +"""Optional transcript writer projection over the shared directory mechanism.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from loushang.harness.journal._directory_lease import ( + DirectoryWriterError, + DirectoryWriterLease, +) + +if TYPE_CHECKING: + from loushang.harness.journal._rooted_io import RootedFileIO + + +class TranscriptWriterError(DirectoryWriterError): + """Bounded transcript admission failure; preserves the existing error API.""" + + prefix = "transcript_writer" + + +class TranscriptWriterLease(DirectoryWriterLease): + """Root + conversation exclusion, independent of Product identity.""" + + _error_type = TranscriptWriterError + _lock_directory = ".transcript-writers" + _hash_domain = "transcript-writer/v1" + + def __init__(self, root: Path, product_id: str, conversation_id: str, *, create_root: bool = False, + expected_root_identity: tuple[int, int] | None = None, + expected_parent_identity: tuple[int, int] | None = None) -> None: + super().__init__(root, product_id, conversation_id, create_root=create_root, + expected_root_identity=expected_root_identity, expected_parent_identity=expected_parent_identity) + + def check(self, *, product_id: str, conversation_id: str) -> None: + self.check_binding(owner_id=product_id, authority_id=conversation_id) + + def _claim(self, owner: object, *, root: Path, product_id: str, conversation_id: str) -> None: + self._claim_binding(owner, root=root, owner_id=product_id, authority_id=conversation_id) + + def _borrow_file_io(self, *, root: Path, product_id: str, conversation_id: str) -> RootedFileIO: + return self._borrow_root_io(root=root, owner_id=product_id, authority_id=conversation_id) diff --git a/tests/harness/transcript/test_store_admission.py b/tests/harness/transcript/test_store_admission.py new file mode 100644 index 000000000..78b2ded00 --- /dev/null +++ b/tests/harness/transcript/test_store_admission.py @@ -0,0 +1,450 @@ +from __future__ import annotations + +import json +import os +import selectors +import stat +import subprocess +import sys +from threading import Event + +import pytest + +from loushang.harness.transcript.store_admission import TranscriptStoreAdmission +from loushang.harness.transcript.writer_lease import ( + TranscriptWriterError, + TranscriptWriterLease, +) + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux retained storage") + + +def owner(tmp_path, *, create=False): + return TranscriptStoreAdmission(tmp_path / "data/sessions", state_root=tmp_path / "state/session-stores", + create_if_missing=create) + + +def snapshot(path): + return {str(p): (p.lstat().st_dev, p.lstat().st_ino, p.lstat().st_mode, p.lstat().st_mtime_ns, + p.read_bytes() if p.is_file() else None) + for p in (path, *path.rglob("*"))} + + +def test_constructor_and_missing_restore_create_nothing(tmp_path): + admission = owner(tmp_path) + assert not tuple(tmp_path.iterdir()) + try: + with pytest.raises(TranscriptWriterError, match="unavailable"): + admission.open() + finally: + admission.close() + assert not tuple(tmp_path.iterdir()) and not admission.cleanup_pending + + +@pytest.mark.parametrize("legacy", [False, True]) +def test_inspect_never_registers_or_initializes_even_with_creation_enabled(tmp_path, legacy): + admission = owner(tmp_path, create=True) + if legacy: + admission.root.mkdir(parents=True, mode=0o700) + (admission.root / "old.jsonl").write_bytes(b"history") + before = snapshot(tmp_path) + try: + assert admission.inspect() is None + finally: + admission.close() + assert snapshot(tmp_path) == before + + +@pytest.mark.parametrize("loss", ["none", "root", "replacement", "parent", "marker"]) +def test_readonly_inspection_distinguishes_known_store_from_missing_or_replaced(tmp_path, loss): + first = owner(tmp_path, create=True) + try: + binding = first.open() + finally: + first.close() + if loss in ("root", "replacement"): + first.root.rename(tmp_path / "original") + if loss == "replacement": + first.root.mkdir(mode=0o700) + elif loss == "parent": + first.root.parent.rename(tmp_path / "original") + first.root.parent.mkdir(mode=0o700) + (tmp_path / "original/sessions").rename(first.root) + elif loss == "marker": + (first.witness_root / "admission.json").rename(tmp_path / "old-marker") + before = snapshot(tmp_path) + selected = owner(tmp_path) + try: + if loss == "none": + assert selected.inspect() == binding + selected.check() + else: + with pytest.raises(TranscriptWriterError, match="unavailable|incomplete|conflict"): + selected.inspect() + finally: + selected.close() + assert snapshot(tmp_path) == before + + +@pytest.mark.parametrize("observed_at", ["before_open", "intent"]) +def test_prior_positive_or_unknown_root_evidence_prevents_creation(tmp_path, monkeypatch, observed_at): + observed = Event() + admission = TranscriptStoreAdmission(tmp_path / "data/sessions", state_root=tmp_path / "state/session-stores", + create_if_missing=True, root_observed=observed) + if observed_at == "before_open": + observed.set() + else: + write = admission._write + + def signal(record): + write(record) + observed.set() + + monkeypatch.setattr(admission, "_write", signal) + try: + with pytest.raises(TranscriptWriterError, match="unavailable"): + admission.open() + finally: + admission.close() + assert not admission.root.exists() + if observed_at == "before_open": + assert not tuple(tmp_path.iterdir()) + + +def test_first_new_creates_private_root_then_reopen_is_readonly(tmp_path): + admission = owner(tmp_path, create=True) + try: + binding = admission.open() + assert stat.S_IMODE(admission.root.stat().st_mode) == 0o700 + assert list(admission.root.iterdir()) == [] + finally: + admission.close() + before = snapshot(tmp_path) + second = owner(tmp_path) + try: + assert second.open() == binding + finally: + second.close() + assert snapshot(tmp_path) == before + writer = TranscriptWriterLease(admission.root, "coding", "one", + expected_root_identity=binding.root_identity, + expected_parent_identity=binding.parent_identity) + try: + writer.acquire() + finally: + writer.close() + + +def test_legacy_store_and_images_are_never_modified(tmp_path): + root = tmp_path / "data/sessions" + root.mkdir(parents=True, mode=0o755) + root.parent.chmod(0o755) + root.chmod(0o755) + (root / "old.jsonl").write_bytes(b"legacy transcript\n") + # Existing standalone v1 admission remains usable; it does not enroll an + # unknown shared family merely because attachment names are present. + previous = owner(tmp_path) + try: + previous.open() + finally: + previous.close() + assets = root.parent / "session-assets" + assets.mkdir(mode=0o755) + (assets / "old.png").write_bytes(b"image bytes") + before = snapshot(root.parent) + admission = owner(tmp_path) + try: + admission.open() + finally: + admission.close() + assert snapshot(root.parent) == before + + +@pytest.mark.parametrize("lost", ["root", "parent", "marker", "lock", "witness", "witness_symlink"]) +def test_known_store_loss_or_replacement_is_not_new_empty_store(tmp_path, lost): + admission = owner(tmp_path, create=True) + try: + admission.open() + finally: + admission.close() + if lost == "root": + admission.root.rename(tmp_path / "old-sessions") + elif lost == "parent": + admission.root.parent.rename(tmp_path / "old-data") + admission.root.parent.mkdir(mode=0o700) + (tmp_path / "old-data/sessions").rename(admission.root) + elif lost == "marker": + (admission.witness_root / "admission.json").rename(tmp_path / "old-marker") + elif lost == "lock": + next((admission.witness_root / ".store-admission-locks").iterdir()).rename(tmp_path / "old-lock") + else: + admission.witness_root.rename(tmp_path / "old-witness") + if lost == "witness_symlink": + admission.witness_root.symlink_to(tmp_path / "old-witness", target_is_directory=True) + else: + admission.witness_root.mkdir() + before = snapshot(tmp_path) + second = owner(tmp_path, create=True) + try: + with pytest.raises(TranscriptWriterError, match="unavailable|conflict|incomplete"): + second.open() + finally: + second.close() + assert before == snapshot(tmp_path) + + +@pytest.mark.parametrize("residue", ["session-assets", ".session-blob-writers"]) +def test_missing_root_with_attachment_residue_cannot_initialize(tmp_path, residue): + (tmp_path / "data" / residue).mkdir(parents=True) + before = snapshot(tmp_path) + admission = owner(tmp_path, create=True) + try: + with pytest.raises(TranscriptWriterError, match="conflict"): + admission.open() + finally: + admission.close() + assert snapshot(tmp_path) == before + + +@pytest.mark.parametrize("stage", ["intent", "root", "publication"]) +def test_lost_receipt_never_replays_initialization(tmp_path, monkeypatch, stage): + admission = owner(tmp_path, create=True) + write, acquire = admission._write, admission._open_store + + def failed_write(record): + write(record) + if (record["phase"] == "initializing") == (stage == "intent"): + raise OSError("lost publication receipt") + + def failed_acquire(*, create): + acquire(create=create) + raise OSError("lost root creation receipt") + + family_write = admission._family._write + + def failed_completion(): + family_write() + member = admission._family._record["members"].get(admission._key) + if member is not None and member["phase"] == "initialized": + raise OSError("lost family completion receipt") + + with monkeypatch.context() as patch: + if stage == "root": + patch.setattr(admission, "_open_store", failed_acquire) + elif stage == "publication": + patch.setattr(admission._family, "_write", failed_completion) + else: + patch.setattr(admission, "_write", failed_write) + try: + with pytest.raises(TranscriptWriterError, match="unavailable"): + admission.open() + finally: + admission.close() + before = snapshot(tmp_path) + second = owner(tmp_path, create=True) + try: + if stage == "publication": + second.open() + else: + with pytest.raises(TranscriptWriterError, match="incomplete"): + second.open() + finally: + second.close() + assert before == snapshot(tmp_path) + + +def test_shared_key_has_no_product_workspace_or_machine_partition(tmp_path): + first, second = owner(tmp_path, create=True), owner(tmp_path, create=True) + assert first.witness_root == second.witness_root + try: + binding = first.open() + with pytest.raises(TranscriptWriterError, match="busy"): + second.open() + finally: + first.close() + second.close() + third = owner(tmp_path, create=True) + try: + assert third.open() == binding + finally: + third.close() + + +def test_malformed_known_record_is_not_overwritten(tmp_path): + admission = owner(tmp_path, create=True) + try: + admission.open() + finally: + admission.close() + marker = admission.witness_root / "admission.json" + record = json.loads(marker.read_bytes()) + record["root"][0] = True + marker.write_text(json.dumps(record)) + before = snapshot(tmp_path) + second = owner(tmp_path, create=True) + try: + with pytest.raises(TranscriptWriterError): + second.open() + finally: + second.close() + assert before == snapshot(tmp_path) + + +def test_exclusive_native_root_does_not_adopt_a_racing_directory(tmp_path, monkeypatch): + admission = owner(tmp_path, create=True) + original = os.mkdir + + def race(path, *args, **kwargs): + if path == "sessions": + original(path, *args, **kwargs) + return original(path, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(os, "mkdir", race) + try: + with pytest.raises(TranscriptWriterError, match="conflict"): + admission.open() + finally: + admission.close() + assert list(admission.root.iterdir()) == [] + assert json.loads((admission.witness_root / "admission.json").read_bytes())["phase"] == "initializing" + + +def test_observed_legacy_root_disappearance_cannot_become_creation(tmp_path, monkeypatch): + admission = owner(tmp_path, create=True) + admission.root.mkdir(parents=True, mode=0o700) + admission.root.parent.chmod(0o700) + write = admission._write + + def disappear(record): + write(record) + admission.root.rename(tmp_path / "original-sessions") + + monkeypatch.setattr(admission, "_write", disappear) + try: + with pytest.raises(TranscriptWriterError, match="unavailable"): + admission.open() + finally: + admission.close() + assert not admission.root.exists() + + +@pytest.mark.parametrize("replacement", ["root", "parent"]) +def test_legacy_identity_is_retained_across_intent_publication(tmp_path, monkeypatch, replacement): + admission = owner(tmp_path, create=True) + admission.root.mkdir(parents=True, mode=0o700) + admission.root.parent.chmod(0o700) + (admission.root / "original.jsonl").write_bytes(b"history") + write = admission._write + snapshots = [] + + def replace(record): + write(record) + source = admission.root if replacement == "root" else admission.root.parent + source.rename(tmp_path / "original") + source.mkdir(mode=0o700) + if replacement == "parent": + (tmp_path / "original/sessions").rename(admission.root) + snapshots.append(snapshot(tmp_path)) + + monkeypatch.setattr(admission, "_write", replace) + try: + with pytest.raises(TranscriptWriterError, match="conflict"): + admission.open() + finally: + admission.close() + assert snapshot(tmp_path) == snapshots[0] + + +_CHILD = """ +import os, sys +from pathlib import Path +from loushang.harness.transcript.store_admission import TranscriptStoreAdmission +from loushang.harness.transcript.writer_lease import TranscriptWriterError +base, stage = Path(sys.argv[1]), sys.argv[2] +owner = TranscriptStoreAdmission(base / 'data/sessions', state_root=base / 'state/session-stores', + create_if_missing=True) +write, fresh, root = owner._write, owner._family._fresh.acquire, owner._open_store +family_write = owner._family._write +def publish(record): + write(record) + if stage == 'intent' and record['phase'] == 'initializing': + os._exit(23) +def completed(): + family_write() + member = owner._family._record['members'].get(owner._key) + if stage == 'publication' and member is not None and member['phase'] == 'initialized': + os._exit(23) +def create_witness(): + if stage == 'race': + print('ready', flush=True) + assert sys.stdin.readline().strip() == 'go' + fresh() + if stage == 'witness': + os._exit(23) +def create_root(*, create): + root(create=create) + if stage == 'root': + os._exit(23) +owner._write, owner._family._fresh.acquire, owner._open_store = publish, create_witness, create_root +owner._family._write = completed +try: + owner.open() + print('admitted', flush=True) +except TranscriptWriterError as error: + print(error.code, flush=True) +finally: + owner.close() +""" + + +@pytest.mark.parametrize("stage", ["witness", "intent", "root", "publication"]) +def test_actual_process_crash_preserves_incomplete_or_initialized_fact(tmp_path, stage): + result = subprocess.run([sys.executable, "-c", _CHILD, str(tmp_path), stage], + capture_output=True, text=True, timeout=15) + assert result.returncode == 23, result.stderr + before = snapshot(tmp_path) + admission = owner(tmp_path, create=True) + try: + if stage == "publication": + admission.open() + else: + with pytest.raises(TranscriptWriterError, match="incomplete"): + admission.open() + finally: + admission.close() + assert snapshot(tmp_path) == before + + +def test_two_actual_initializers_have_one_exclusive_creator(tmp_path): + children = [] + try: + for _ in range(2): + child = subprocess.Popen([sys.executable, "-c", _CHILD, str(tmp_path), "race"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + children.append(child) + with selectors.DefaultSelector() as selected: + selected.register(child.stdout, selectors.EVENT_READ) + assert selected.select(15), "initializer did not reach the pre-create gate" + assert child.stdout.readline().strip() == "ready" + for child in children: + child.stdin.write("go\n") + child.stdin.flush() + outcomes = [] + for child in children: + stdout, stderr = child.communicate(timeout=15) + assert child.returncode == 0, stderr + outcomes.append(stdout.strip()) + assert sorted(outcomes) == ["admitted", "conflict"] + finally: + for child in children: + if child.poll() is None: + child.kill() + child.communicate(timeout=5) + before = snapshot(tmp_path) + admission = owner(tmp_path) + try: + admission.open() + finally: + admission.close() + assert snapshot(tmp_path) == before diff --git a/tests/harness/transcript/test_writer_lease.py b/tests/harness/transcript/test_writer_lease.py new file mode 100644 index 000000000..9c00dc1b1 --- /dev/null +++ b/tests/harness/transcript/test_writer_lease.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import os +import stat +import sys + +import pytest + +from loushang.harness.journal import _directory_lease as module +from loushang.harness.journal import journal_file_lock +from loushang.harness.transcript.writer_lease import ( + TranscriptWriterError, + TranscriptWriterLease, +) + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux optional writer lease") + + +def lease(root, product="coding", conversation="one"): + return TranscriptWriterLease(root, product, conversation) + + +def busy(root, product="coding", conversation="one"): + owner = lease(root, product, conversation) + try: + with pytest.raises(TranscriptWriterError, match="busy"): + owner.acquire() + with pytest.raises(TranscriptWriterError, match="closed"): + owner.acquire() + finally: + owner.close() + + +def test_identity_aliases_and_other_products_share_physical_lock(tmp_path): + root = tmp_path / "sessions" + root.mkdir(mode=0o755) + alias = tmp_path / "alias" + alias.symlink_to(root, target_is_directory=True) + owner, other = lease(root), lease(root, conversation="two") + assert not owner.cleanup_pending and not (root / ".transcript-writers").exists() + try: + owner.acquire() + owner.check(product_id="coding", conversation_id="one") + assert stat.S_IMODE(root.stat().st_mode) == 0o755 + assert all(not os.get_inheritable(fd) for fd in owner._fds.values()) + for selected, product in ((root, "coding"), (alias, "coding"), (root, "work")): + busy(selected, product) + other.acquire() + with pytest.raises(TranscriptWriterError, match="conflict"): + owner.check(product_id="work", conversation_id="one") + # Runtime writer ownership is independent of the journal snapshot lock. + with journal_file_lock(root / "unmodified.jsonl", "shared", blocking=False): + assert not (root / "unmodified.jsonl").exists() + finally: + owner.close() + other.close() + stable = root / ".transcript-writers" / owner._name + inode = stable.stat().st_ino + reopened = lease(root) + try: + reopened.acquire() + assert stable.stat().st_ino == inode and stat.S_IMODE(stable.stat().st_mode) == 0o600 + assert stat.S_IMODE(stable.parent.stat().st_mode) == 0o700 + finally: + reopened.close() + owner.close() + assert not owner.cleanup_pending + with pytest.raises(TranscriptWriterError, match="closed"): + owner.check(product_id="coding", conversation_id="one") + + +@pytest.mark.parametrize("kind", ["root_mode", "directory_mode", "directory_link", "lock_mode", "lock_link", "lock_hardlink", "lock_fifo"]) +def test_unsafe_files_rejected_without_repair_or_native_block(tmp_path, kind): + root = tmp_path / "root" + root.mkdir(mode=0o700) + owner = lease(root) + directory = root / ".transcript-writers" + if kind == "directory_link": + directory.symlink_to(tmp_path, target_is_directory=True) + else: + directory.mkdir(mode=0o700) + target = directory / owner._name + if kind == "root_mode": + root.chmod(0o777) + elif kind == "directory_mode": + directory.chmod(0o755) + elif kind == "lock_mode": + target.touch(mode=0o644) + elif kind in {"lock_link", "lock_hardlink"}: + source = tmp_path / "private" + source.write_bytes(b"untouched") + source.chmod(0o600) + if kind == "lock_link": + target.symlink_to(source) + else: + os.link(source, target) + elif kind == "lock_fifo": + os.mkfifo(target, 0o600) + before = target.stat().st_mode if target.exists() else None + try: + with pytest.raises(TranscriptWriterError, match="unsafe|unavailable"): + owner.acquire() + with pytest.raises(TranscriptWriterError, match="closed"): + owner.check(product_id="coding", conversation_id="one") + if before is not None: + assert target.stat().st_mode == before + finally: + owner.close() + + +@pytest.mark.parametrize("kind", ["root", "directory", "lock", "alias"]) +def test_replaced_path_invalidates_retained_binding(tmp_path, kind): + root, alternate = tmp_path / "root", tmp_path / "alternate" + root.mkdir(mode=0o700) + alternate.mkdir(mode=0o700) + alias = tmp_path / "alias" + alias.symlink_to(root, target_is_directory=True) + owner = lease(alias if kind == "alias" else root) + try: + owner.acquire() + path = {"root": root, "directory": root / ".transcript-writers", + "lock": root / ".transcript-writers" / owner._name, "alias": alias}[kind] + path.rename(path.with_name(path.name + ".saved")) + if kind == "alias": + path.symlink_to(alternate, target_is_directory=True) + elif kind == "lock": + path.touch(mode=0o600) + else: + path.mkdir(mode=0o700) + with pytest.raises(TranscriptWriterError, match="conflict"): + owner.check(product_id="coding", conversation_id="one") + finally: + owner.close() + + +def test_unknown_close_never_closes_reused_descriptor(tmp_path, monkeypatch): + owner = lease(tmp_path) + owner.acquire() + fd = owner._fds["lock"] + original, replacement, calls = os.close, [], [] + + def fail_after_close(descriptor): + calls.append(descriptor) + original(descriptor) + if descriptor == fd: + new = os.open("/dev/null", os.O_RDONLY) + assert new == fd + replacement.append(new) + raise OSError("native effect completed but receipt failed") + + try: + with monkeypatch.context() as patch: + patch.setattr(module.os, "close", fail_after_close) + for _ in range(2): + with pytest.raises(TranscriptWriterError, match="unavailable"): + owner.close() + assert calls.count(fd) == 1 and owner.cleanup_pending + os.fstat(replacement[0]) + with pytest.raises(TranscriptWriterError, match="closed"): + owner.check(product_id="coding", conversation_id="one") + finally: + for descriptor in replacement: + original(descriptor) + + +def test_missing_root_and_non_linux_reject_without_creating_storage(tmp_path, monkeypatch): + missing = tmp_path / "missing" + owner = lease(missing) + with pytest.raises(TranscriptWriterError, match="unavailable"): + owner.acquire() + owner.close() + assert not missing.exists() + other = lease(tmp_path) + monkeypatch.setattr(module.sys, "platform", "win32") + with pytest.raises(TranscriptWriterError, match="unsupported"): + other.acquire() + assert not other.cleanup_pending + + +def test_post_flock_validation_failure_keeps_native_ownership_until_close(tmp_path, monkeypatch): + owner = lease(tmp_path) + original, calls = owner._validate_binding, [] + + def validate(): + calls.append(1) + original() + if len(calls) == 2: + raise TranscriptWriterError("conflict") + + monkeypatch.setattr(owner, "_validate_binding", validate) + try: + with pytest.raises(TranscriptWriterError, match="conflict"): + owner.acquire() + assert owner.cleanup_pending + with pytest.raises(TranscriptWriterError, match="closed"): + owner.check(product_id="coding", conversation_id="one") + busy(tmp_path) + finally: + owner.close() + fresh = lease(tmp_path) + try: + fresh.acquire() + finally: + fresh.close() diff --git a/tests/harness/transcript/test_writer_process.py b/tests/harness/transcript/test_writer_process.py new file mode 100644 index 000000000..c3c08a5e7 --- /dev/null +++ b/tests/harness/transcript/test_writer_process.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import json +import os +import select +import subprocess +import sys +from pathlib import Path +from time import monotonic + +import pytest + +from loushang.harness.transcript.writer_lease import TranscriptWriterLease +from loushang.hosting.service import LinuxServiceObserverV1 + +from .test_writer_lease import busy, lease + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux writer process ownership") + + +def test_real_process_concurrent_root_creation_has_one_writer(tmp_path): + root = tmp_path / "new" / "sessions" + child = subprocess.Popen( + [sys.executable, str(Path(__file__).with_name("_writer_child.py")), str(root), "create_pause"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + owner = TranscriptWriterLease(root, "coding", "one", create_root=True) + try: + assert select.select([child.stdout], [], [], 10)[0] + assert json.loads(child.stdout.readline()) == {"stage": "created"} + assert root.is_dir() and not (root / ".transcript-writers").exists() + owner.acquire() # Independent process encounters real EEXIST before A's fsync. + child.stdin.write(b"continue\n") + child.stdin.flush() + output, errors = child.communicate(timeout=10) + assert child.returncode == 0 and not errors + assert json.loads(output) == {"stage": "busy"} + assert set(owner._fds) == {"root", "directory", "lock"} + owner.check(product_id="coding", conversation_id="one") + busy(root) + finally: + owner.close() + if child.poll() is None: + child.kill() + child.communicate(timeout=5) + fresh = lease(root) + try: + fresh.acquire() + finally: + fresh.close() + + +@pytest.mark.parametrize("mode", ["normal", "fork_close", "orphan", "crash"]) +def test_real_process_and_inherited_open_description_ownership(tmp_path, mode): + child = subprocess.Popen( + [sys.executable, str(Path(__file__).with_name("_writer_child.py")), str(tmp_path), mode], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + observer = None + + def receipt(): + data = bytearray() + deadline = monotonic() + 10 + while not data.endswith(b"\n"): + remaining = deadline - monotonic() + assert remaining > 0 and select.select([child.stdout], [], [], remaining)[0] + value = os.read(child.stdout.fileno(), 1) + assert value, "writer fixture exited before receipt" + data.extend(value) + assert len(data) < 1024 + return json.loads(data) + + def send(command): + child.stdin.write(command + b"\n") + child.stdin.flush() + + try: + ready = receipt() + if mode == "orphan": + assert ready["stage"] == "orphan" + observer = LinuxServiceObserverV1.capture(ready["pid"]) + assert child.wait(timeout=5) == 0 + assert not observer.exited() # Father exited, inherited writer still held. + else: + assert ready["stage"] == "held" + busy(tmp_path) + busy(tmp_path, product="work") + stable = next((tmp_path / ".transcript-writers").glob("*.lock")) + inode = stable.stat().st_ino + if mode == "crash": + send(b"crash") + assert child.wait(timeout=8) == 3 + else: + send(b"release") + assert receipt()["stage"] == "released" + fresh = lease(tmp_path) + try: + fresh.acquire() + assert stable.stat().st_ino == inode + finally: + fresh.close() + if mode == "crash": + return + send(b"quit") + if observer is not None: + assert observer.exited(timeout=8) + else: + assert child.wait(timeout=8) == 0 + finally: + # EOF asks the exact fixture (including orphan) to close; no production + # unlock/termination authority is introduced by test failure hygiene. + child.stdin.close() + child.stdin = None + if child.poll() is None: + child.kill() + _, errors = child.communicate(timeout=25) # Orphan watchdog is 20 seconds. + if observer is not None: + assert observer.exited(timeout=1) + observer.close() + assert not errors, errors.decode(errors="replace") + + +def test_fork_during_native_close_cannot_close_reused_inherited_fd(tmp_path): + result = subprocess.run( + [sys.executable, str(Path(__file__).with_name("_writer_child.py")), str(tmp_path), "unstable_fork"], + capture_output=True, timeout=20, + ) + assert result.returncode == 0, result.stderr.decode(errors="replace") + assert json.loads(result.stdout) == {"stage": "verified"} diff --git a/tests/harness/transcript/test_writer_root_creation.py b/tests/harness/transcript/test_writer_root_creation.py new file mode 100644 index 000000000..417ca00fa --- /dev/null +++ b/tests/harness/transcript/test_writer_root_creation.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import os +import stat +import sys + +import pytest + +from loushang.harness.journal import _directory_lease as module +from loushang.harness.transcript.writer_lease import ( + TranscriptWriterError, + TranscriptWriterLease, +) + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux retained writer") + + +def identity(path): + info = path.stat() + return info.st_dev, info.st_ino + + +def test_expected_root_and_parent_are_bound_before_writer_namespace_creation(tmp_path): + root = tmp_path / "sessions" + root.mkdir(mode=0o755) + root.chmod(0o755) + owner = TranscriptWriterLease(root, "coding", "one", expected_root_identity=identity(root), + expected_parent_identity=identity(tmp_path)) + try: + owner.acquire() + assert set(owner._fds) == {"parent", "root", "directory", "lock"} + owner.check(product_id="coding", conversation_id="one") + assert stat.S_IMODE(root.stat().st_mode) == 0o755 + finally: + owner.close() + assert not owner.cleanup_pending + + +@pytest.mark.parametrize("replacement", ["root", "parent", "symlink", "missing"]) +def test_expected_identity_rejects_replacement_without_creating_writer_files(tmp_path, replacement): + data = tmp_path / "data" + data.mkdir(mode=0o700) + root = data / "sessions" + root.mkdir(mode=0o700) + owner = TranscriptWriterLease(root, "coding", "one", expected_root_identity=identity(root), + expected_parent_identity=identity(data)) + moved = tmp_path / "original" + if replacement == "parent": + data.rename(moved) + data.mkdir(mode=0o700) + (moved / "sessions").rename(root) + else: + root.rename(moved) + if replacement == "root": + root.mkdir(mode=0o700) + elif replacement == "symlink": + root.symlink_to(moved, target_is_directory=True) + before = {str(p): (p.lstat().st_ino, p.lstat().st_mode) for p in tmp_path.rglob("*")} + try: + with pytest.raises(TranscriptWriterError, match="conflict|unavailable"): + owner.acquire() + finally: + owner.close() + assert before == {str(p): (p.lstat().st_ino, p.lstat().st_mode) for p in tmp_path.rglob("*")} + + +def test_expected_parent_is_checked_during_held_lifetime(tmp_path): + data = tmp_path / "data" + data.mkdir(mode=0o700) + root = data / "sessions" + root.mkdir(mode=0o700) + owner = TranscriptWriterLease(root, "coding", "one", expected_root_identity=identity(root), + expected_parent_identity=identity(data)) + try: + owner.acquire() + moved = tmp_path / "original" + data.rename(moved) + data.mkdir(mode=0o700) + (moved / "sessions").rename(root) + with pytest.raises(TranscriptWriterError, match="conflict"): + owner.check(product_id="coding", conversation_id="one") + finally: + owner.close() + + +@pytest.mark.parametrize("options", [ + {"expected_root_identity": (True, 1)}, {"expected_root_identity": (1, 0)}, + {"expected_root_identity": [1, 2]}, {"expected_root_identity": (1, 2**64)}, + {"expected_parent_identity": (1, 2)}, + {"create_root": True, "expected_root_identity": (1, 2)}, +]) +def test_expected_identity_cannot_grant_root_creation_or_accept_invalid_shape(tmp_path, options): + with pytest.raises(TranscriptWriterError, match="invalid"): + TranscriptWriterLease(tmp_path / "missing", "coding", "one", **options) + assert not tuple(tmp_path.iterdir()) + + +@pytest.mark.parametrize("mask", [0, 0o022, 0o077]) +def test_explicit_create_private_parents_and_original_three_fds(tmp_path, mask): + root = tmp_path / "new" / "sessions" + owner = TranscriptWriterLease(root, "coding", "one", create_root=True) + old_mask = os.umask(mask) + try: + owner.acquire() + assert set(owner._fds) == {"root", "directory", "lock"} + assert not owner._sync_pending and not owner._unknown + assert stat.S_IMODE(root.stat().st_mode) == 0o700 + assert stat.S_IMODE(root.parent.stat().st_mode) == 0o700 + owner.check(product_id="coding", conversation_id="one") + finally: + os.umask(old_mask) + owner.close() + assert not owner.cleanup_pending + assert root.is_dir() # Closing a preparation never removes created storage. + + +def test_existing_safe_root_mode_unchanged(tmp_path): + root = tmp_path / "sessions" + root.mkdir(mode=0o755) + root.chmod(0o755) + owner = TranscriptWriterLease(root, "coding", "one", create_root=True) + try: + owner.acquire() + assert stat.S_IMODE(root.stat().st_mode) == 0o755 + finally: + owner.close() + + +@pytest.mark.parametrize("position", ["parent", "root"]) +def test_creation_never_follows_symlinks(tmp_path, position): + outside = tmp_path / "outside" + outside.mkdir(mode=0o700) + link = tmp_path / "link" + link.symlink_to(outside, target_is_directory=True) + root = link / "sessions" if position == "parent" else link + owner = TranscriptWriterLease(root, "coding", "one", create_root=True) + try: + with pytest.raises(TranscriptWriterError, match="unavailable|conflict"): + owner.acquire() + finally: + owner.close() + assert list(outside.iterdir()) == [] + + +@pytest.mark.parametrize("exists", [False, True]) +def test_failed_parent_sync_retained_and_close_only_retries_sync(tmp_path, monkeypatch, exists): + root = tmp_path / "sessions" + if exists: + root.mkdir(mode=0o700) + owner = TranscriptWriterLease(root, "coding", "one", create_root=True) + sync, mkdir = os.fsync, os.mkdir + calls, failed = [], [] + parent_identity = tmp_path.stat() + + def fail_sync(fd): + if os.path.samestat(os.fstat(fd), parent_identity): + failed.append(fd) + raise OSError("injected sync failure") + return sync(fd) + + def record_mkdir(*args, **kwargs): + calls.append(args[0]) + return mkdir(*args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(module.os, "fsync", fail_sync) + patch.setattr(module.os, "mkdir", record_mkdir) + with pytest.raises(TranscriptWriterError, match="unavailable"): + owner.acquire() + count = len(calls) + assert root.is_dir() and not (root / ".transcript-writers").exists() + with pytest.raises(TranscriptWriterError, match="unavailable"): + owner.close() + assert owner.cleanup_pending and len(calls) == count + assert len(failed) == 2 and failed[0] == failed[1] + assert os.path.samestat(os.fstat(failed[0]), parent_identity) + patch.setattr(module.os, "fsync", sync) + owner.close() + assert len(calls) == count and not owner.cleanup_pending + assert root.is_dir() + + +def test_replaced_child_at_sync_rejected_before_writer_admission(tmp_path, monkeypatch): + root = tmp_path / "sessions" + saved = tmp_path / "saved" + owner = TranscriptWriterLease(root, "coding", "one", create_root=True) + sync = os.fsync + parent_identity = tmp_path.stat() + + def replace_at_sync(fd): + if os.path.samestat(os.fstat(fd), parent_identity) and not saved.exists(): + root.rename(saved) + root.mkdir(mode=0o700) + return sync(fd) + + try: + with monkeypatch.context() as patch: + patch.setattr(module.os, "fsync", replace_at_sync) + with pytest.raises(TranscriptWriterError, match="conflict"): + owner.acquire() + with pytest.raises(TranscriptWriterError, match="closed"): + owner._borrow_file_io(root=root, product_id="coding", conversation_id="one") + assert list(root.iterdir()) == list(saved.iterdir()) == [] + finally: + owner.close() + + +@pytest.mark.parametrize("error", [OSError, KeyboardInterrupt]) +def test_ancestor_unknown_close_never_admits_or_retries_reused_fd(tmp_path, monkeypatch, error): + root = tmp_path / "sessions" + owner = TranscriptWriterLease(root, "coding", "one", create_root=True) + close = os.close + replacement, calls = [], [] + + def fail_after_close(fd): + calls.append(fd) + close(fd) + if not replacement: + reused = os.open("/dev/null", os.O_RDONLY) + assert reused == fd + replacement.append(fd) + raise error("lost close receipt") + + try: + with monkeypatch.context() as patch: + patch.setattr(module.os, "close", fail_after_close) + with pytest.raises(TranscriptWriterError, match="unavailable"): + owner.acquire() + assert not owner._held and not (root / ".transcript-writers").exists() + with pytest.raises(TranscriptWriterError, match="closed"): + owner._borrow_file_io(root=root, product_id="coding", conversation_id="one") + for _ in range(2): + with pytest.raises(TranscriptWriterError, match="unavailable"): + owner.close() + assert calls.count(replacement[0]) == 1 + assert len(owner._fds) == len(owner._unknown) == 1 + os.fstat(replacement[0]) + finally: + for fd in replacement: + close(fd) From f80f5c16b5be6daf37e0f9fae1a1a2a0997a7e5e Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 05:22:33 +0000 Subject: [PATCH 16/44] feat(harness): bind session blobs to retained roots Add descriptor-relative blob transaction IO and an explicit blob writer lease on top of the shared directory authority. SessionBlobStore can now borrow a retained data root or operate read-only without reopening mutable pathname authority. Cover root replacement, manifest/object publication, rollback and deletion, read-only rejection, writer exclusion, cleanup debt and identity-preserving recovery. --- src/loushang/harness/artifacts/_blob_io.py | 120 +++++ .../harness/artifacts/_writer_lease.py | 53 ++ .../harness/artifacts/session_blobs.py | 232 +++++++-- .../artifacts/test_readonly_session_blobs.py | 46 ++ tests/harness/artifacts/test_rooted_blobs.py | 451 ++++++++++++++++++ tests/harness/artifacts/test_writer_lease.py | 189 ++++++++ 6 files changed, 1045 insertions(+), 46 deletions(-) create mode 100644 src/loushang/harness/artifacts/_blob_io.py create mode 100644 src/loushang/harness/artifacts/_writer_lease.py create mode 100644 tests/harness/artifacts/test_readonly_session_blobs.py create mode 100644 tests/harness/artifacts/test_rooted_blobs.py create mode 100644 tests/harness/artifacts/test_writer_lease.py diff --git a/src/loushang/harness/artifacts/_blob_io.py b/src/loushang/harness/artifacts/_blob_io.py new file mode 100644 index 000000000..21dfadf4d --- /dev/null +++ b/src/loushang/harness/artifacts/_blob_io.py @@ -0,0 +1,120 @@ +"""Transaction-local blob layout over an existing rooted IO operation.""" + +from __future__ import annotations + +import os +from contextlib import suppress +from pathlib import Path +from threading import Event + +from loushang.harness.journal._rooted_io import RootedDirectory, RootedFile + + +class BlobTransactionIO: + """Map only this authority's manifest/objects; retain no independent owner.""" + + def __init__(self, data_root: Path, assets: RootedDirectory, session_id: str) -> None: + self.root = data_root / "session-assets" / session_id + self._assets, self._session_id = assets, session_id + self._session: RootedDirectory | None = None + self._objects: RootedDirectory | None = None + self.created_objects: dict[Path, tuple[int, int]] = {} + + def pin_existing(self) -> None: + with suppress(FileNotFoundError): + self.objects() + + def track_settlement(self) -> Event: + return self._assets.track_settlement() + + def check_binding(self) -> None: + if self._session is not None: + self._assets.check_child(self._session_id, self._session) + if self._objects is not None: + self._session.check_child("objects", self._objects) + + def directory(self, *, create: bool = False) -> RootedDirectory: + if self._session is None: + self._session = self._assets.child(self._session_id, create=create) + return self._session + + def objects(self, *, create: bool = False) -> RootedDirectory: + if self._objects is None: + self._objects = self.directory(create=create).child("objects", create=create) + return self._objects + + def prepare(self) -> None: + self.objects(create=True) + + def file(self, path: Path) -> RootedFile: + parts = path.relative_to(self.root).parts + if parts == ("manifest.json",): + return self.directory().file(parts[0]) + if len(parts) == 2 and parts[0] == "objects": + return self.objects().file(parts[1]) + raise ValueError("blob IO path is outside its manifest/object authority") + + def stat(self, path: Path) -> os.stat_result: + return self.directory().stat() if path == self.root else self.file(path).stat() + + def exists(self, path: Path) -> bool: + try: + self.stat(path) + except FileNotFoundError: + return False + return True + + def unlink(self, path: Path, identity: tuple[int, int]) -> None: + target = self.file(path) + metadata = target.stat() + if (metadata.st_dev, metadata.st_ino) != identity: + raise OSError("blob file deletion identity changed") + target.unlink_owned(identity) + + def remove_objects(self, files: list[tuple[str, tuple[int, int]]]) -> None: + self.objects().remove_files(files) + + def retain_restore(self, payload: bytes, retained_ids: set[str], removed_ids: set[str], *, + root_preexisting: bool, limit: int) -> None: + if self._assets.deletion_registered: + return # The complete deletion plan already owns recovery. + directory = self.directory() + objects = self._objects + created = dict(self.created_objects) + + def recover(assets: RootedDirectory) -> None: + root = assets.reborrow(directory) + assets.check_child(self._session_id, root) + if not root_preexisting: + assets.remove_tree(self._session_id, expected=root, limit=limit, defer=True) + return + selected = root.reborrow(objects) if objects is not None else root.child("objects") + root.check_child("objects", selected) + root.file("manifest.json").atomic_write(payload) + plan = [] + for name in removed_ids | {path.name for path in created}: + if name in retained_ids: + continue + identity = created.get(self.root / "objects" / name) + if identity is None: + try: + status = selected.file(name).stat() + except FileNotFoundError: + continue + identity = status.st_dev, status.st_ino + plan.append((name, identity)) + selected.queue_files(plan) + + self._assets.retain_cleanup(recover) + + def delete(self, *, expected_identity: tuple[int, int] | None, limit: int) -> bool: + try: + directory = self.directory() + except FileNotFoundError: + return False + metadata = directory.stat() + if expected_identity is not None and (metadata.st_dev, metadata.st_ino) != expected_identity: + raise OSError("blob directory deletion identity changed") + self._assets.remove_tree(self._session_id, expected=directory, limit=limit) + self._session = self._objects = None + return True diff --git a/src/loushang/harness/artifacts/_writer_lease.py b/src/loushang/harness/artifacts/_writer_lease.py new file mode 100644 index 000000000..269b7efbc --- /dev/null +++ b/src/loushang/harness/artifacts/_writer_lease.py @@ -0,0 +1,53 @@ +"""Optional Session blob lifetime writer; separate from each blob operation lock. + +The data root must already exist. This lease neither creates an attachment tree +nor authorizes pathname IO; callers retain it through their IO and cleanup. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from loushang.harness.journal._directory_lease import ( + DirectoryWriterError, + DirectoryWriterLease, +) + +from .references import session_blob_authority_id + +if TYPE_CHECKING: + from loushang.harness.journal._rooted_io import RootedFileIO + + +class SessionBlobWriterError(DirectoryWriterError): + """Bounded Session attachment admission failure.""" + + prefix = "session_blob_writer" + + +class SessionBlobWriterLease(DirectoryWriterLease): + """One physical data-root + normalized Session attachment authority. + + Product/owner identity does not partition exclusion. The stable lifetime + lock is outside session-assets and cannot disappear on Session deletion. + """ + + _error_type = SessionBlobWriterError + _lock_directory = ".session-blob-writers" + _hash_domain = "session-blob-writer/v1" + + def __init__(self, data_root: Path, owner_id: str, session_id: str, *, + expected_root_identity: tuple[int, int] | None = None) -> None: + super().__init__(data_root, owner_id, session_blob_authority_id(session_id), + expected_root_identity=expected_root_identity) + + def check(self, *, owner_id: str, session_id: str) -> None: + self.check_binding(owner_id=owner_id, authority_id=session_blob_authority_id(session_id)) + + def borrow_file_io(self, *, data_root: Path, owner_id: str, session_id: str, + directory_bindings: tuple[tuple[tuple[int, int], str, tuple[int, int]], ...] = ()) -> RootedFileIO: + return self._borrow_root_io( + root=data_root, owner_id=owner_id, authority_id=session_blob_authority_id(session_id), + directory_bindings=directory_bindings, + ) diff --git a/src/loushang/harness/artifacts/session_blobs.py b/src/loushang/harness/artifacts/session_blobs.py index 77853f30e..8cccce4bd 100644 --- a/src/loushang/harness/artifacts/session_blobs.py +++ b/src/loushang/harness/artifacts/session_blobs.py @@ -10,10 +10,10 @@ import time from collections.abc import Callable, Iterable, Sequence from contextlib import contextmanager, suppress -from dataclasses import dataclass, replace +from dataclasses import dataclass, field, replace from pathlib import Path -from threading import RLock -from typing import Literal, Protocol +from threading import Event, RLock +from typing import TYPE_CHECKING, Literal, Protocol from uuid import uuid4 from loushang.harness.journal import journal_file_lock @@ -34,6 +34,11 @@ _write_new_private_file, ) +if TYPE_CHECKING: + from loushang.harness.journal._rooted_io import RootedFileIO + + from ._blob_io import BlobTransactionIO + class SessionBlobError(ArtifactStoreError): """Base class for durable session-blob failures.""" @@ -72,6 +77,11 @@ class SessionBlobHealth: detail: str | None = None +@dataclass +class _RollbackSettlement: + receipt: Event | None = None + + @dataclass(frozen=True, slots=True) class SessionBlobPublication: """Rollback authority for exactly one unchanged publication.""" @@ -82,6 +92,16 @@ class SessionBlobPublication: _expected_records: tuple[SessionBlobRef, ...] _root_identity: tuple[int, int] _root_preexisting: bool + _rollback_settlement: _RollbackSettlement = field(default_factory=_RollbackSettlement, compare=False, repr=False) + + @property + def rollback_delegated(self) -> bool: + return self._rollback_settlement.receipt is not None + + @property + def rollback_complete(self) -> bool: + receipt = self._rollback_settlement.receipt + return receipt is not None and receipt.is_set() def rollback(self) -> bool: return self._store._rollback_publication( @@ -89,6 +109,7 @@ def rollback(self) -> bool: self._expected_records, self._root_identity, self._root_preexisting, + settlement=self._rollback_settlement, ) @@ -123,11 +144,23 @@ def __init__( *, policy: SessionBlobPolicy = DEFAULT_SESSION_BLOB_POLICY, now: Callable[[], float] = time.time, + file_io: RootedFileIO | None = None, + read_only: bool = False, ) -> None: + if type(read_only) is not bool or (read_only and file_io is not None): + raise ValueError("invalid read-only blob selection") + self._read_only = read_only # Logical conversation ids historically allowed non-portable text. The # store exposes and persists only their safe physical authority key. session_id = session_blob_authority_id(session_id) - self.data_root = Path(data_root).expanduser().resolve(strict=False) + if file_io is not None: + if Path(data_root) != file_io.root: + raise ValueError("blob IO must borrow the selected data root") + self.data_root = file_io.root + else: + self.data_root = Path(data_root).expanduser().resolve(strict=False) + self._file_io = file_io + self._rooted: BlobTransactionIO | None = None self.session_id = session_id self.policy = policy self._now = now @@ -218,9 +251,9 @@ def import_blobs( if not prepared: raise ValueError("session blob import requires at least one item") with self._lock, self._authority_lock("exclusive"): - root_preexisting = self.root.exists() + root_preexisting = self._exists(self.root) self._load_manifest_if_present() - if require_new_authority and self.root.exists(): + if require_new_authority and self._exists(self.root): raise SessionBlobError("target session blob authority already exists") previous_records = tuple(self._records) try: @@ -232,9 +265,16 @@ def import_blobs( ) for reference, payload in prepared ) + root_metadata = self._stat(self.root) + return SessionBlobPublication( + _store=self, references=references, _previous_records=previous_records, + _expected_records=tuple(self._records), + _root_identity=(root_metadata.st_dev, root_metadata.st_ino), + _root_preexisting=root_preexisting, + ) except BaseException as error: try: - self._restore_records_locked( + self._restore_or_retain( previous_records, root_preexisting=root_preexisting, ) @@ -244,15 +284,6 @@ def import_blobs( f"{cleanup_error.__class__.__name__}: {cleanup_error}" ) raise - root_metadata = self.root.lstat() - return SessionBlobPublication( - _store=self, - references=references, - _previous_records=previous_records, - _expected_records=tuple(self._records), - _root_identity=(root_metadata.st_dev, root_metadata.st_ino), - _root_preexisting=root_preexisting, - ) def _import_blob_locked( self, @@ -279,26 +310,34 @@ def _import_blob_locked( existing = next((item for item in self._records if item == target), None) if existing is not None: - _read_blob_object(self.objects_root / existing.blob_id, existing) + self._read_object(existing) return existing self._check_capacity(target) self._prepare_tree() object_path = self.objects_root / target.blob_id object_identity: tuple[int, int] | None = None object_created = False - if object_path.exists(): - _read_blob_object(object_path, target) + if self._exists(object_path): + self._read_object(target) else: try: - object_identity = _write_new_private_file(object_path, payload) + object_identity = ( + self._rooted.file(object_path).create_new(payload) + if self._rooted is not None else _write_new_private_file(object_path, payload) + ) object_created = True - _sync_directory(self.objects_root) + if self._rooted is not None: + self._rooted.created_objects[object_path] = object_identity + else: + self._sync_objects() except FileExistsError: - _read_blob_object(object_path, target) + self._read_object(target) try: self._write_manifest((*self._records, target)) except BaseException: - if object_created and object_identity is not None: + # On rooted IO an atomic manifest publication may have completed + # before its sync failed. Never delete objects it may now reference. + if self._rooted is None and object_created and object_identity is not None: with suppress(OSError): _unlink_owned_file(object_path, object_identity) _sync_directory(self.objects_root) @@ -312,7 +351,7 @@ def read_bytes(self, blob: SessionBlobRef) -> bytes: self._load_manifest_if_present() if blob.session_id != self.session_id or blob not in self._records: raise ArtifactSourceRejected("blob is not owned by this session") - return _read_blob_object(self.objects_root / blob.blob_id, blob) + return self._read_object(blob) def inspect( self, blobs: Iterable[SessionBlobRef] | None = None @@ -330,10 +369,12 @@ def inspect( state: SessionBlobHealthState = ( "missing" if blob not in self.records - and not (self.objects_root / blob.blob_id).exists() + and not self._object_exists(blob) else "corrupt" ) health.append(SessionBlobHealth(blob, state, str(error))) + except BlockingIOError: + raise # Busy is not evidence that the retained object is corrupt. except OSError as error: health.append(SessionBlobHealth(blob, "corrupt", str(error))) else: @@ -360,7 +401,7 @@ def inspect_metadata( object_path = self.objects_root / blob.blob_id if blob.session_id != self.session_id or blob not in owned: try: - object_path.lstat() + self._stat(object_path) except FileNotFoundError: object_exists = False except OSError: @@ -376,7 +417,7 @@ def inspect_metadata( ) continue try: - metadata = object_path.lstat() + metadata = self._stat(object_path) except FileNotFoundError: health.append( SessionBlobHealth(blob, "missing", "object is missing") @@ -432,20 +473,42 @@ def _rollback_publication( expected_records: tuple[SessionBlobRef, ...], root_identity: tuple[int, int], root_preexisting: bool, + *, settlement: _RollbackSettlement | None = None, ) -> bool: with self._lock, self._authority_lock("exclusive"): self._load_manifest_if_present() - if not self.root.exists() or tuple(self._records) != expected_records: + if not self._exists(self.root) or tuple(self._records) != expected_records: return False - metadata = self.root.lstat() + metadata = self._stat(self.root) if (metadata.st_dev, metadata.st_ino) != root_identity: return False - self._restore_records_locked( + self._restore_or_retain( previous_records, root_preexisting=root_preexisting, + settlement=settlement, ) return True + def _restore_or_retain( + self, previous_records: tuple[SessionBlobRef, ...], *, root_preexisting: bool, + settlement: _RollbackSettlement | None = None, + ) -> None: + current_ids = set(_unique_objects(self._records)) + try: + self._restore_records_locked(previous_records, root_preexisting=root_preexisting) + except BaseException: + if self._rooted is not None: + self._rooted.retain_restore( + self._manifest_payload(previous_records), set(_unique_objects(previous_records)), current_ids, + root_preexisting=root_preexisting, limit=self.policy.max_blobs * 2 + 16, + ) + if settlement is not None: + settlement.receipt = self._rooted.track_settlement() + raise + else: + if settlement is not None and self._rooted is not None: + settlement.receipt = self._rooted.track_settlement() + def _restore_records_locked( self, previous_records: tuple[SessionBlobRef, ...], @@ -454,7 +517,7 @@ def _restore_records_locked( ) -> None: """Restore one interrupted publication without deleting older records.""" - if not self.root.exists(): + if not self._exists(self.root): self._records = list(previous_records) self._initialized = bool(previous_records) or root_preexisting return @@ -466,19 +529,27 @@ def _restore_records_locked( self._write_manifest(previous_records) retained_ids = set(_unique_objects(previous_records)) removed_object = False - for blob_id in set(_unique_objects(current_records)) - retained_ids: + rooted_plan: list[tuple[str, tuple[int, int]]] = [] + removed_ids = set(_unique_objects(current_records)) - retained_ids + if self._rooted is not None: + removed_ids.update(path.name for path in self._rooted.created_objects if path.name not in retained_ids) + for blob_id in removed_ids: object_path = self.objects_root / blob_id try: - metadata = object_path.lstat() + metadata = self._stat(object_path) except FileNotFoundError: continue - _unlink_owned_file( - object_path, - (metadata.st_dev, metadata.st_ino), - ) + identity = metadata.st_dev, metadata.st_ino + if self._rooted is not None: + identity = self._rooted.created_objects.get(object_path, identity) + rooted_plan.append((blob_id, identity)) + else: + _unlink_owned_file(object_path, identity) removed_object = True + if self._rooted is not None and rooted_plan: + self._rooted.remove_objects(rooted_plan) if removed_object: - _sync_directory(self.objects_root) + self._sync_objects() self._records = list(previous_records) self._initialized = True @@ -487,6 +558,13 @@ def _delete_locked( *, expected_root_identity: tuple[int, int] | None = None, ) -> bool: + if self._rooted is not None: + deleted = self._rooted.delete( + expected_identity=expected_root_identity, limit=self.policy.max_blobs * 2 + 16, + ) + self._records.clear() + self._initialized = False + return deleted if not self.root.exists(): self._records.clear() self._initialized = False @@ -528,6 +606,9 @@ def _check_capacity(self, reference: SessionBlobRef) -> None: ) def _prepare_tree(self) -> None: + if self._rooted is not None: + self._rooted.prepare() + return _prepare_private_directory(self.data_root) _prepare_private_directory(self.assets_root) _prepare_private_directory(self.root) @@ -538,20 +619,72 @@ def _authority_lock( self, mode: Literal["shared", "exclusive"], ): + if self._read_only: + if mode != "shared": + raise ValueError("read-only blob store cannot mutate storage") + # Advisory inspection uses existing stable-file readers. No lock + # file/directory is created, and no cross-file transaction is claimed. + yield + return + if self._file_io is not None: + from ._blob_io import BlobTransactionIO + + with self._file_io.directory() as data: + assets = data.child("session-assets", create=True) + locks = assets.child(".locks", create=True) + rooted = BlobTransactionIO(self.data_root, assets, self.session_id) + rooted.pin_existing() + locks.file(self.session_id + ".lock").acquire_lock(exclusive=mode == "exclusive") + data.check_child("session-assets", assets) + assets.check_child(".locks", locks) + rooted.check_binding() + self._rooted = rooted + try: + yield + finally: + self._rooted = None + return lock_target = self.assets_root / ".locks" / self.session_id with journal_file_lock(lock_target, mode): yield + def _stat(self, path: Path) -> os.stat_result: + return self._rooted.stat(path) if self._rooted is not None else path.lstat() + + def _exists(self, path: Path) -> bool: + return self._rooted.exists(path) if self._rooted is not None else path.exists() + + def _sync_objects(self) -> None: + if self._rooted is not None: + self._rooted.objects().file(".sync").sync_directory() + else: + _sync_directory(self.objects_root) + + def _read_object(self, reference: SessionBlobRef) -> bytes: + if self._rooted is None: + return _read_blob_object(self.objects_root / reference.blob_id, reference) + payload = self._rooted.file(self.objects_root / reference.blob_id).read_bytes( + max_bytes=max(1, reference.size_bytes), + ) + if len(payload) != reference.size_bytes or hashlib.sha256(payload).hexdigest() != reference.sha256: + raise ArtifactSourceRejected("session blob object content changed") + return payload + + def _object_exists(self, reference: SessionBlobRef) -> bool: + with self._lock, self._authority_lock("shared"): + return self._exists(self.objects_root / reference.blob_id) + def _load_manifest_if_present(self) -> None: self._records = [] self._initialized = False - if not self.root.exists(): + if not self._exists(self.root): return - _validate_private_directory(self.root) - if not self.manifest_path.exists(): + if self._rooted is None: + _validate_private_directory(self.root) + if not self._exists(self.manifest_path): raise SessionBlobManifestError("session blob root has no manifest") try: - metadata = self.manifest_path.lstat() + metadata = self._stat(self.manifest_path) if ( not stat.S_ISREG(metadata.st_mode) or _is_reparse_point(metadata) @@ -563,11 +696,12 @@ def _load_manifest_if_present(self) -> None: if metadata.st_size > 8 * 1024 * 1024: raise SessionBlobManifestError("session blob manifest is too large") value = json.loads( - _read_stable_private_file( + (self._rooted.file(self.manifest_path).read_bytes(max_bytes=8 * 1024 * 1024) + if self._rooted is not None else _read_stable_private_file( self.manifest_path, metadata, max_bytes=8 * 1024 * 1024, - ).decode("utf-8") + )).decode("utf-8") ) except SessionBlobManifestError: raise @@ -617,13 +751,13 @@ def _load_manifest_if_present(self) -> None: self._records = records self._initialized = True - def _write_manifest(self, records: Sequence[SessionBlobRef]) -> None: + def _manifest_payload(self, records: Sequence[SessionBlobRef]) -> bytes: manifest = { "schemaVersion": 1, "sessionId": self.session_id, "blobs": [record.manifest_entry() for record in records], } - payload = ( + return ( json.dumps( manifest, ensure_ascii=False, @@ -633,6 +767,12 @@ def _write_manifest(self, records: Sequence[SessionBlobRef]) -> None: ) + "\n" ).encode("utf-8") + + def _write_manifest(self, records: Sequence[SessionBlobRef]) -> None: + payload = self._manifest_payload(records) + if self._rooted is not None: + self._rooted.file(self.manifest_path).atomic_write(payload, exclusive=not self._initialized) + return temporary = self.root / f".manifest-{uuid4().hex}.tmp" identity: tuple[int, int] | None = None try: diff --git a/tests/harness/artifacts/test_readonly_session_blobs.py b/tests/harness/artifacts/test_readonly_session_blobs.py new file mode 100644 index 000000000..6be9336ec --- /dev/null +++ b/tests/harness/artifacts/test_readonly_session_blobs.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import hashlib + +import pytest + +from loushang.harness.artifacts import SessionBlobRef, SessionBlobStore +from tests.harness.transcript.test_readonly_store import snapshot + + +def test_readonly_missing_store_never_creates_directories_and_rejects_writes(tmp_path): + before = snapshot(tmp_path) + reader = SessionBlobStore(tmp_path / "missing", "one", read_only=True) + digest = hashlib.sha256(b"bytes").hexdigest() + reference = SessionBlobRef( + session_id="one", blob_id=digest, logical_name="output", kind="command-output", + media_type="text/plain", disclosure="private", size_bytes=5, sha256=digest, created_at=1.0, + ) + assert reader.records == () and reader.inspect() == () + for operation in ( + lambda: reader.put_bytes(b"bytes", logical_name="output", kind="command-output", media_type="text/plain"), + lambda: reader.import_blobs(((reference, b"bytes"),)), + reader.delete, + ): + with pytest.raises(ValueError, match="read-only"): + operation() + assert snapshot(tmp_path) == before + + +def test_readonly_existing_store_reuses_integrity_checks_without_locks(tmp_path): + writer = SessionBlobStore(tmp_path, "one") + reference = writer.put_bytes(b"hello", logical_name="output", kind="command-output", media_type="text/plain") + # Remove only the known test lock, so accidental recreation is observable. + lock = tmp_path / "session-assets" / ".locks" / "one.lock" + lock.unlink() + lock.parent.rmdir() + before = snapshot(tmp_path) + reader = SessionBlobStore(tmp_path, "one", read_only=True) + assert reader.read_bytes(reference) == b"hello" + assert reader.inspect((reference,))[0].state == "available" + assert reader.inspect_metadata((reference,))[0].state == "available" + assert snapshot(tmp_path) == before + writer.objects_root.joinpath(reference.blob_id).write_bytes(b"wrong") + corrupt = snapshot(tmp_path) + assert reader.inspect((reference,))[0].state == "corrupt" + assert snapshot(tmp_path) == corrupt diff --git a/tests/harness/artifacts/test_rooted_blobs.py b/tests/harness/artifacts/test_rooted_blobs.py new file mode 100644 index 000000000..eb44220ca --- /dev/null +++ b/tests/harness/artifacts/test_rooted_blobs.py @@ -0,0 +1,451 @@ +from __future__ import annotations + +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from threading import Event, current_thread + +import pytest + +from loushang.harness.artifacts import SessionBlobStore +from loushang.harness.artifacts._writer_lease import SessionBlobWriterLease +from loushang.harness.journal import _rooted_io as native + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux rooted attachments") + + +@pytest.fixture +def owned(tmp_path): + root = tmp_path / "data" + root.mkdir(mode=0o700) + owner = SessionBlobWriterLease(root, "coding", "session") + owner.acquire() + io = owner.borrow_file_io(data_root=root, owner_id="coding", session_id="session") + try: + yield root, io + finally: + io.cleanup() + owner.close() + + +def put(store, payload=b"image", name="image.png"): + return store.put_bytes(payload, logical_name=name, kind="image", media_type="image/png") + + +def tree(root): + return { + str(path.relative_to(root)): (path.lstat().st_mode, path.lstat().st_ino, + path.lstat().st_mtime_ns, path.read_bytes() if path.is_file() else None) + for path in (root, *root.rglob("*")) + } + + +@pytest.mark.parametrize("action", ["read", "inspect", "metadata", "put", "import", "rollback", "delete"]) +def test_actual_blob_operations_stay_in_original_data_root(owned, monkeypatch, action): + root, io = owned + store = SessionBlobStore(root, "session", file_io=io) + first = put(store) + source = SessionBlobStore(root, "source", file_io=io) + added = put(source, b"other", "other.png") + publication = store.import_blobs([(added, b"other")]) if action == "rollback" else None + moved = root.with_name("saved") + root.rename(moved) + root.mkdir(mode=0o700) + (root / "sentinel").write_bytes(b"untouched") + before = tree(root) + + def forbidden(*args, **kwargs): + raise AssertionError("rooted BlobStore fell back to pathname IO") + + with monkeypatch.context() as patch: + for method in ("open", "read_bytes", "write_bytes", "exists", "lstat", "stat", "resolve", "mkdir", "unlink"): + patch.setattr(Path, method, forbidden) + if action == "read": + assert store.read_bytes(first) == b"image" + elif action == "inspect": + assert store.inspect([first])[0].state == "available" + elif action == "metadata": + assert store.inspect_metadata([first])[0].state == "available" + elif action == "put": + assert store.read_bytes(put(store, b"new")) == b"new" + elif action == "import": + assert store.read_bytes(store.import_blobs([(added, b"other")]).references[0]) == b"other" + elif action == "rollback": + assert publication.rollback() + assert store.records == (first,) + else: + assert store.delete() + assert tree(root) == before + actual = SessionBlobStore(moved, "session") + if action == "delete": + assert actual.records == () + else: + assert actual.read_bytes(first) == b"image" + + +@pytest.mark.parametrize("level", ["session-assets", "session-assets/session", "session-assets/session/objects"]) +def test_swap_nested_directory_after_lock_is_rejected_without_touching_replacement(owned, monkeypatch, level): + import fcntl + + root, io = owned + store = SessionBlobStore(root, "session", file_io=io) + reference = put(store) + selected = root / level + original = fcntl.flock + snapshots = [] + + def replace_after_lock(*args, **kwargs): + result = original(*args, **kwargs) + if not snapshots: + selected.rename(selected.with_name(selected.name + ".saved")) + selected.mkdir(mode=0o700) + (selected / "sentinel").write_bytes(b"untouched") + snapshots.append(tree(selected)) + return result + + with monkeypatch.context() as patch: + patch.setattr(fcntl, "flock", replace_after_lock) + with pytest.raises(OSError, match="identity changed"): + store.read_bytes(reference) + assert tree(selected) == snapshots[0] + + +def test_rollback_after_session_root_replacement_does_not_delete_new_authority(owned): + root, io = owned + source = SessionBlobStore(root, "source", file_io=io) + reference = put(source) + target = SessionBlobStore(root, "session", file_io=io) + publication = target.import_blobs([(reference, b"image")]) + path = root / "session-assets/session" + path.rename(path.with_name("saved")) + replacement = SessionBlobStore(root, "session", file_io=io) + put(replacement) + before = tree(path) + assert publication.rollback() is False + assert tree(path) == before + + +def test_manifest_published_sync_and_restore_failures_preserve_referenced_objects(owned, monkeypatch): + root, io = owned + store = SessionBlobStore(root, "session", file_io=io) + first = put(store) + replace = native.os.replace + fsync = native.os.fsync + replaced = [] + + def track_replace(*args, **kwargs): + if args[1] == "manifest.json": + if replaced: + raise OSError("test restore failed") + result = replace(*args, **kwargs) + replaced.append(True) + return result + return replace(*args, **kwargs) + + def fail_sync(fd): + if replaced: + raise OSError("test manifest sync failed") + return fsync(fd) + + with monkeypatch.context() as patch: + patch.setattr(native.os, "replace", track_replace) + patch.setattr(native.os, "fsync", fail_sync) + with pytest.raises(OSError, match="manifest sync"): + put(store, b"second", "second.png") + assert io.cleanup_pending + # Reconcile only known native cleanup debt, not a replay of publication. + io.cleanup() + restored = SessionBlobStore(root, "session", file_io=io) + assert restored.records == (first,) + assert restored.read_bytes(first) == b"image" + assert {path.name for path in restored.objects_root.iterdir()} == {first.blob_id} + + +def test_delete_budget_and_symlink_fail_before_deleting_existing_objects(owned): + root, io = owned + store = SessionBlobStore(root, "session", file_io=io) + reference = put(store) + authority = root / "session-assets/session" + (authority / "link").symlink_to(root.parent) + with pytest.raises(OSError): + store.delete() + assert store.read_bytes(reference) == b"image" + (authority / "link").unlink() + with io.directory() as data: + assets = data.child("session-assets") + selected = assets.child("session") + with pytest.raises(OSError, match="budget"): + assets.remove_tree("session", expected=selected, limit=1) + assert store.read_bytes(reference) == b"image" + + +def test_exclusive_manifest_publish_never_overwrites_existing_target(owned): + root, io = owned + with io.directory() as data: + target = data.file("manifest") + target.atomic_write(b"original", exclusive=True) + with pytest.raises(FileExistsError): + target.atomic_write(b"replacement", exclusive=True) + assert target.read_bytes() == b"original" + assert not io.cleanup_pending + + +@pytest.mark.parametrize("stage", ["replace", "sync"]) +def test_repeated_manifest_failures_restore_without_unaccounted_objects(owned, monkeypatch, stage): + root, io = owned + store = SessionBlobStore(root, "session", file_io=io) + first = put(store) + replacement = native.os.replace + fsync = native.os.fsync + failed = [] + published = [] + + def fail_publication(*args, **kwargs): + if args[1] == "manifest.json" and not failed: + if stage == "replace": + failed.append(True) + raise OSError("test manifest publication failed") + result = replacement(*args, **kwargs) + published.append(True) + return result + return replacement(*args, **kwargs) + + def fail_sync(fd): + if published and not failed: + failed.append(True) + raise OSError("test manifest publication failed") + return fsync(fd) + + with monkeypatch.context() as patch: + patch.setattr(native.os, "replace", fail_publication) + patch.setattr(native.os, "fsync", fail_sync) + for number in range(8): + failed.clear() + published.clear() + with pytest.raises(OSError, match="publication failed"): + put(store, f"new object {number}".encode()) + assert not io.cleanup_pending + assert store.read_bytes(first) == b"image" + assert store.records == (first,) + assert {path.name for path in (root / "session-assets/session/objects").iterdir()} == {first.blob_id} + + +def test_partial_delete_retains_exact_plan_without_needing_manifest(owned, monkeypatch): + root, io = owned + store = SessionBlobStore(root, "session", file_io=io) + put(store) + rmdir = native.os.rmdir + + def unavailable(name, *args, **kwargs): + if name == "objects": + raise OSError("test partial delete") + return rmdir(name, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(native.os, "rmdir", unavailable) + with pytest.raises(OSError, match="partial delete"): + store.delete() + assert io.cleanup_pending + assert not (root / "session-assets/session/manifest.json").exists() + with pytest.raises(OSError, match="partial delete"): + io.cleanup() + io.cleanup() + assert not io.cleanup_pending + assert not (root / "session-assets/session").exists() + assert SessionBlobStore(root, "session", file_io=io).delete() is False + + +def test_delete_sync_retry_does_not_remove_replacement_authority(owned, monkeypatch): + root, io = owned + store = SessionBlobStore(root, "session", file_io=io) + put(store) + rmdir, fsync = native.os.rmdir, native.os.fsync + removed = [] + + def track_remove(name, *args, **kwargs): + result = rmdir(name, *args, **kwargs) + if name == "session": + removed.append(True) + return result + + def unavailable(fd): + if removed: + raise OSError("test retired directory sync") + return fsync(fd) + + with monkeypatch.context() as patch: + patch.setattr(native.os, "rmdir", track_remove) + patch.setattr(native.os, "fsync", unavailable) + with pytest.raises(OSError, match="directory sync"): + store.delete() + authority = root / "session-assets/session" + authority.mkdir(mode=0o700) + (authority / "sentinel").write_bytes(b"replacement") + before = tree(authority) + assert io.cleanup_pending + io.cleanup() + assert tree(authority) == before and not io.cleanup_pending + + +def short_lock_state(root): + script = """ +import sys +from pathlib import Path +from loushang.harness.journal import JournalLockUnavailable, journal_file_lock +try: + with journal_file_lock(Path(sys.argv[1]), 'shared', blocking=False): + print('free') +except JournalLockUnavailable: + print('busy') +""" + result = subprocess.run( + [sys.executable, "-c", script, str(root / "session-assets/.locks/session")], + capture_output=True, text=True, timeout=10, + ) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + +def test_double_prepublish_failure_retains_recovery_and_original_short_lock(owned, monkeypatch): + root, io = owned + store = SessionBlobStore(root, "session", file_io=io) + first = put(store) + replace = native.os.replace + + def unavailable(*args, **kwargs): + if args[1] == "manifest.json": + raise OSError("test both publications fail") + return replace(*args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(native.os, "replace", unavailable) + with pytest.raises(OSError, match="both publications"): + put(store, b"new unreferenced object") + assert io.cleanup_pending + assert short_lock_state(root) == "busy" + for _ in range(2): + with pytest.raises(OSError, match="both publications"): + io.cleanup() + assert io.cleanup_pending + io.cleanup() + assert not io.cleanup_pending and short_lock_state(root) == "free" + assert store.read_bytes(first) == b"image" + assert {path.name for path in store.objects_root.iterdir()} == {first.blob_id} + + +def test_contender_exits_before_retained_recovery_cleanup(owned, monkeypatch): + import fcntl + + root, io = owned + store = SessionBlobStore(root, "session", file_io=io) + first = put(store) + holder_ready, contender_ready, holder_finished = Event(), Event(), Event() + flock, replace = fcntl.flock, native.os.replace + + def barrier(fd, mode): + if current_thread().name.endswith("_0"): + result = flock(fd, mode) + holder_ready.set() + assert contender_ready.wait(5) + return result + contender_ready.set() + assert holder_finished.wait(5) + # Check the flag before issuing the syscall, so a regression fails + # without leaving the test runner stuck in a native blocking flock. + assert mode & fcntl.LOCK_NB + return flock(fd, mode) + + def unavailable(*args, **kwargs): + if args[1] == "manifest.json": + raise OSError("test retained publication recovery") + return replace(*args, **kwargs) + + def holder(): + try: + with pytest.raises(OSError, match="retained publication recovery"): + put(store, b"unreferenced") + finally: + holder_finished.set() + + def contender(): + with pytest.raises(BlockingIOError): + put(SessionBlobStore(root, "session", file_io=io), b"contender") + + with monkeypatch.context() as patch: + patch.setattr(fcntl, "flock", barrier) + patch.setattr(native.os, "replace", unavailable) + with ThreadPoolExecutor(max_workers=2, thread_name_prefix="blob-contention") as executor: + first_call = executor.submit(holder) + assert holder_ready.wait(5) + second_call = executor.submit(contender) + first_call.result(timeout=10) + second_call.result(timeout=10) + assert io.cleanup_pending + assert not any(operation.active for operation in io._operations) + io.cleanup() + assert not io.cleanup_pending and short_lock_state(root) == "free" + assert store.read_bytes(first) == b"image" + assert {path.name for path in store.objects_root.iterdir()} == {first.blob_id} + + +def test_batch_rollback_first_unlink_failure_retains_all_remaining_objects(owned, monkeypatch): + root, io = owned + store = SessionBlobStore(root, "session", file_io=io) + first = put(store) + source = SessionBlobStore(root, "source", file_io=io) + blobs = [(put(source, str(number).encode()), str(number).encode()) for number in range(3)] + replace, unlink = native.os.replace, native.os.unlink + published = [] + + def fail_third(*args, **kwargs): + if args[1] == "manifest.json": + published.append(True) + if len(published) == 3: + raise OSError("test third publication fails") + return replace(*args, **kwargs) + + def unavailable(name, *args, **kwargs): + if name in {reference.blob_id for reference, _ in blobs}: + raise OSError("test object deletion fails") + return unlink(name, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(native.os, "replace", fail_third) + patch.setattr(native.os, "unlink", unavailable) + with pytest.raises(OSError, match="third publication"): + store.import_blobs(blobs) + assert io.cleanup_pending and short_lock_state(root) == "busy" + assert sum(len(operation.deletions) for operation in io._operations) == 3 + io.cleanup() + assert not io.cleanup_pending and short_lock_state(root) == "free" + assert {path.name for path in store.objects_root.iterdir()} == {first.blob_id} + + +def test_publication_final_receipt_failure_retains_native_recovery(owned, monkeypatch): + root, io = owned + source = SessionBlobStore(root, "source", file_io=io) + reference = put(source) + target = SessionBlobStore(root, "session", file_io=io) + native_stat, rmdir = target._stat, native.os.rmdir + + def final_stat(path): + if path == target.root: + raise OSError("test final publication receipt fails") + return native_stat(path) + + def unavailable(name, *args, **kwargs): + if name == "session": + raise OSError("test rollback deletion fails") + return rmdir(name, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(target, "_stat", final_stat) + patch.setattr(native.os, "rmdir", unavailable) + with pytest.raises(OSError, match="final publication receipt fails"): + target.import_blobs([(reference, b"image")], require_new_authority=True) + assert io.cleanup_pending + assert short_lock_state(root) == "busy" + io.cleanup() + assert not io.cleanup_pending and not target.root.exists() + assert short_lock_state(root) == "free" diff --git a/tests/harness/artifacts/test_writer_lease.py b/tests/harness/artifacts/test_writer_lease.py new file mode 100644 index 000000000..b5e58588d --- /dev/null +++ b/tests/harness/artifacts/test_writer_lease.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from loushang.harness.artifacts import SessionBlobStore, resolve_session_blob_data_root +from loushang.harness.artifacts._writer_lease import ( + SessionBlobWriterError, + SessionBlobWriterLease, +) +from loushang.harness.artifacts.references import session_blob_authority_id +from loushang.harness.transcript.writer_lease import ( + TranscriptWriterError, + TranscriptWriterLease, +) + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux lifetime writer") + + +def busy(root, product, session_id): + writer = SessionBlobWriterLease(root, product, session_id) + try: + with pytest.raises(SessionBlobWriterError, match="busy"): + writer.acquire() + with pytest.raises(SessionBlobWriterError, match="closed"): + writer.acquire() + finally: + writer.close() + assert not writer.cleanup_pending + + +def test_sibling_roots_products_and_normalized_aliases_share_blob_writer(tmp_path): + roots = [tmp_path / "sessions", tmp_path / "other-sessions"] + for root in roots: + root.mkdir(mode=0o700) + logical = "historical/session" + normalized = session_blob_authority_id(logical) + data = resolve_session_blob_data_root(roots[0]) + alias = tmp_path / "alias" + alias.symlink_to(data, target_is_directory=True) + writer = SessionBlobWriterLease(data, "coding", logical) + other = SessionBlobWriterLease(data, "coding", "another") + try: + writer.acquire() + writer.check(owner_id="coding", session_id=logical) + writer.check(owner_id="coding", session_id=normalized) + for root in (resolve_session_blob_data_root(roots[1]), alias): + busy(root, "work", normalized) + with pytest.raises(SessionBlobWriterError, match="conflict"): + writer.check(owner_id="work", session_id=logical) + other.acquire() + finally: + writer.close() + other.close() + again = SessionBlobWriterLease(data, "work", normalized) + try: + again.acquire() + finally: + again.close() + + +def test_different_data_roots_do_not_share_authority(tmp_path): + leases = [] + try: + for name in ("left", "right"): + root = tmp_path / name + root.mkdir(mode=0o700) + lease = SessionBlobWriterLease(root, "coding", "same") + leases.append(lease) + lease.acquire() + finally: + for lease in leases: + lease.close() + + +def test_constructor_is_pure_and_missing_data_root_is_not_created(tmp_path, monkeypatch): + missing = tmp_path / "missing" + + def forbidden(*args, **kwargs): + raise AssertionError("constructor performed filesystem IO") + + with monkeypatch.context() as patch: + patch.setattr(os, "open", forbidden) + patch.setattr(Path, "resolve", forbidden) + writer = SessionBlobWriterLease(missing, "coding", "session") + assert not writer.cleanup_pending and not missing.exists() + try: + with pytest.raises(SessionBlobWriterError, match="unavailable"): + writer.acquire() + finally: + writer.close() + assert not missing.exists() and not writer.cleanup_pending + + +def test_blob_operations_and_delete_do_not_remove_or_reacquire_lifetime_lock(tmp_path): + writer = SessionBlobWriterLease(tmp_path, "coding", "session") + try: + writer.acquire() + lock = tmp_path / ".session-blob-writers" / writer._name + identity = lock.stat().st_ino + store = SessionBlobStore(tmp_path, "session") + reference = store.put_bytes(b"image", logical_name="one.png", kind="image", media_type="image/png") + assert store.read_bytes(reference) == b"image" + publication = store.import_blobs([(reference, b"image")]) + assert publication.rollback() + assert store.delete() + assert lock.stat().st_ino == identity + busy(tmp_path, "work", "session") + finally: + writer.close() + + +@pytest.mark.parametrize("replacement", ["root", "directory", "lock"]) +def test_blob_lease_rejects_replaced_binding(tmp_path, replacement): + root = tmp_path / "data" + root.mkdir(mode=0o700) + writer = SessionBlobWriterLease(root, "coding", "session") + try: + writer.acquire() + selected = {"root": root, "directory": root / ".session-blob-writers", + "lock": root / ".session-blob-writers" / writer._name}[replacement] + selected.rename(selected.with_name(selected.name + ".saved")) + if replacement == "lock": + selected.touch(mode=0o600) + else: + selected.mkdir(mode=0o700) + with pytest.raises(SessionBlobWriterError, match="conflict|unavailable"): + writer.check(owner_id="coding", session_id="session") + finally: + writer.close() + + +def test_extracted_transcript_lease_conflicts_with_original_hash_lock(tmp_path): + import fcntl + + directory = tmp_path / ".transcript-writers" + directory.mkdir(mode=0o700) + name = hashlib.sha256(json.dumps(["transcript-writer/v1", "one"], ensure_ascii=True).encode()).hexdigest() + ".lock" + fd = os.open(directory / name, os.O_CREAT | os.O_RDWR, 0o600) + writer = TranscriptWriterLease(tmp_path, "coding", "one") + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + assert writer._name == name + with pytest.raises(TranscriptWriterError, match="transcript_writer:busy"): + writer.acquire() + finally: + writer.close() + os.close(fd) + + +def test_independent_process_contends_for_normalized_blob_authority(tmp_path): + script = """ +import sys +from pathlib import Path +from loushang.harness.artifacts._writer_lease import SessionBlobWriterLease, SessionBlobWriterError +writer = SessionBlobWriterLease(Path(sys.argv[1]), 'work', sys.argv[2]) +try: + try: + writer.acquire() + except SessionBlobWriterError as error: + print(error.code) + else: + print('held') +finally: + writer.close() +""" + logical = "historical/session" + writer = SessionBlobWriterLease(tmp_path, "coding", logical) + + def probe(): + result = subprocess.run( + [sys.executable, "-c", script, str(tmp_path), session_blob_authority_id(logical)], + capture_output=True, text=True, timeout=10, + ) + assert result.returncode == 0 and not result.stderr + return result.stdout.strip() + + try: + writer.acquire() + assert probe() == "busy" + finally: + writer.close() + assert probe() == "held" From 5d99473a36e22622bdab9d4e05aaaaf58f6adff3 Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 05:23:15 +0000 Subject: [PATCH 17/44] feat(harness): root conversation store IO Route file-backed conversation records and JSON indexes through borrowed rooted handles, and add a shared native-settlement helper so cancellation never turns an accepted filesystem effect into an implicit retry. Preserve the existing pathname mode for unowned callers. Cover replacement roots, index publication and recovery, subprocess exclusion, cancellation, cleanup failure and unknown native outcomes. --- .../harness/conversation/indexes/json_file.py | 311 ++++++++++++-- src/loushang/harness/conversation/store.py | 9 +- .../harness/conversation/stores/file.py | 384 ++++++++++++++---- src/loushang/harness/journal/_owned_io.py | 69 ++++ tests/harness/conversation/_index_child.py | 40 ++ .../test_file_store_settlement.py | 264 ++++++++++++ .../harness/conversation/test_rooted_index.py | 327 +++++++++++++++ 7 files changed, 1294 insertions(+), 110 deletions(-) create mode 100644 src/loushang/harness/journal/_owned_io.py create mode 100644 tests/harness/conversation/_index_child.py create mode 100644 tests/harness/conversation/test_file_store_settlement.py create mode 100644 tests/harness/conversation/test_rooted_index.py diff --git a/src/loushang/harness/conversation/indexes/json_file.py b/src/loushang/harness/conversation/indexes/json_file.py index 12a3734f5..7514b4790 100644 --- a/src/loushang/harness/conversation/indexes/json_file.py +++ b/src/loushang/harness/conversation/indexes/json_file.py @@ -6,11 +6,12 @@ import os import secrets import stat -from collections.abc import Callable, Mapping, Sequence -from contextlib import suppress +from collections.abc import Callable, Iterator, Mapping, Sequence +from contextlib import contextmanager, suppress from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path +from tempfile import NamedTemporaryFile from threading import Lock from time import time_ns from typing import Any, Generic, Protocol, TypeVar, cast @@ -26,6 +27,8 @@ ConversationLocator, require_revision, ) +from loushang.harness.journal._rooted_io import RootedFile +from loushang.harness.journal.jsonl import journal_file_lock, journal_file_lock_at P = TypeVar("P") Q = TypeVar("Q") @@ -56,6 +59,17 @@ class ProjectionIndexSnapshot(Generic[P]): stale: bool = False +@dataclass(frozen=True) +class JsonIndexPublication: + """Exact successful local publication, not permission to delete by name.""" + + path: Path + generation: str + sequence: int + identity: tuple[int, int] + parent_identity: tuple[int, int] | None = None + + class JsonProjectionIndex(Generic[P]): def __init__( self, @@ -187,6 +201,40 @@ def __init__( async def upsert(self, item: IndexedProjection[P]) -> bool: return await asyncio.to_thread(self._upsert_sync, item) + def upsert_rooted(self, item: IndexedProjection[P], target: RootedFile) -> bool: + """Update an existing cache using the caller's retained transaction. + + The caller owns admission and native settlement. This synchronous path + neither creates the index nor repairs/renames corrupt pathname caches. + Its stable short lock serializes read/modify/write across processes. + """ + require_revision(item.source_revision, name="source revision") + if not self._writable: + raise RuntimeError("read-only conversation index cannot be modified") + try: + target.read_bytes(max_bytes=_MAX_CONVERSATION_INDEX_BYTES) + except FileNotFoundError: + return False + target.acquire_lock(exclusive=True, blocking=False, suffix=".lock") + try: + content = target.read_bytes(max_bytes=_MAX_CONVERSATION_INDEX_BYTES) + except FileNotFoundError: + return False + state = self._decode_state(content, preserve_corrupt=False) + if state.index_state != "fresh": + return False + if item.source_revision <= state.tombstones.get(item.locator, -1): + return False + current = state.items.get(item.locator) + if current is not None and item.source_revision < current.source_revision: + return False + state.items[item.locator] = item + self._write_state( + state.items, state.tombstones, generation=_writable_generation(state), + sequence=state.sequence + 1, target=target, + ) + return True + async def delete( self, locator: ConversationLocator, @@ -218,23 +266,118 @@ async def replace( ) -> tuple[IndexedProjection[P], ...]: return await asyncio.to_thread(self._replace_sync, tuple(items)) + async def replace_with_receipt( + self, items: Sequence[IndexedProjection[P]], + ) -> tuple[tuple[IndexedProjection[P], ...], JsonIndexPublication]: + return await asyncio.to_thread(self._replace_with_receipt_sync, tuple(items)) + + async def invalidate_if_current(self, receipt: JsonIndexPublication) -> bool: + """Invalidate only this exact version, serialized with all writers.""" + return await asyncio.to_thread(self._invalidate_if_current_sync, receipt) + + async def observe_publication(self) -> JsonIndexPublication | None: + """Capture the exact existing version for a subsequent conditional check.""" + return await asyncio.to_thread(self._observe_publication_sync) + + def _observe_publication_sync(self) -> JsonIndexPublication | None: + with self._mutation_scope() as directory: + try: + content, opened = _read_stable_regular_snapshot( + self.path, max_bytes=_MAX_CONVERSATION_INDEX_BYTES, directory=directory, + ) + except FileNotFoundError: + return None + state = self._decode_state(content, preserve_corrupt=False) + if state.index_state != "fresh": + return None + parent = os.fstat(directory) if directory is not None else None + return JsonIndexPublication( + self.path, state.generation, state.sequence, (opened.st_dev, opened.st_ino), + (parent.st_dev, parent.st_ino) if parent is not None else None, + ) + + async def upsert_with_receipt( + self, item: IndexedProjection[P], + ) -> tuple[bool, JsonIndexPublication | None]: + return await asyncio.to_thread(self._upsert_with_receipt_sync, item) + + def _invalidate_if_current_sync(self, receipt: JsonIndexPublication) -> bool: + if receipt.path != self.path: + raise ValueError("index publication belongs to another path") + with self._mutation_scope(create_lock=False) as directory: + if directory is not None: + status = os.fstat(directory) + if (status.st_dev, status.st_ino) != receipt.parent_identity: + return False + try: + if directory is None: + descriptor, parent = _open_file_no_follow(self.path) + else: + descriptor = os.open( + self.path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC, + dir_fd=directory, + ) + parent = directory + except FileNotFoundError: + return False + try: + opened = os.fstat(descriptor) + if (not _regular_file_status_no_follow(opened) + or (opened.st_dev, opened.st_ino) != receipt.identity + or opened.st_size > _MAX_CONVERSATION_INDEX_BYTES): + return False + chunks: list[bytes] = [] + remaining = opened.st_size + while remaining: + chunk = os.read(descriptor, min(remaining, 1024 * 1024)) + if not chunk: + return False + chunks.append(chunk) + remaining -= len(chunk) + state = self._decode_state(b"".join(chunks), preserve_corrupt=False) + if (state.index_state != "fresh" or state.generation != receipt.generation + or state.sequence != receipt.sequence + or not _same_file_status(opened, os.fstat(descriptor))): + return False + current = (os.stat(self.path.name, dir_fd=parent, follow_symlinks=False) + if parent >= 0 else self.path.lstat()) + if not _same_file_status(opened, current): + return False + if parent >= 0: + os.unlink(self.path.name, dir_fd=parent) + else: + self.path.unlink() + return True + finally: + try: + os.close(descriptor) + finally: + if parent >= 0 and directory is None: + os.close(parent) + def _upsert_sync(self, item: IndexedProjection[P]) -> bool: + return self._upsert_with_receipt_sync(item)[0] + + def _upsert_with_receipt_sync( + self, item: IndexedProjection[P], + ) -> tuple[bool, JsonIndexPublication | None]: require_revision(item.source_revision, name="source revision") - with self._lock: - state = self._read_state() + with self._mutation_scope() as directory: + state = self._read_state(preserve_corrupt=True, directory=directory) if item.source_revision <= state.tombstones.get(item.locator, -1): - return False + return False, None current = state.items.get(item.locator) if current is not None and item.source_revision < current.source_revision: - return False + return False, None state.items[item.locator] = item - self._write_state( + receipt = self._write_state( state.items, state.tombstones, generation=_writable_generation(state), sequence=state.sequence + 1, + directory=directory, ) - return True + return True, receipt def _delete_sync( self, @@ -242,8 +385,8 @@ def _delete_sync( through_revision: int, ) -> bool: revision = require_revision(through_revision, name="deletion revision") - with self._lock: - state = self._read_state() + with self._mutation_scope() as directory: + state = self._read_state(preserve_corrupt=True, directory=directory) previous = state.tombstones.get(locator, -1) if revision < previous: return False @@ -256,6 +399,7 @@ def _delete_sync( state.tombstones, generation=_writable_generation(state), sequence=state.sequence + 1, + directory=directory, ) return revision > previous @@ -285,28 +429,66 @@ def _replace_sync( self, replacement: tuple[IndexedProjection[P], ...], ) -> tuple[IndexedProjection[P], ...]: - with self._lock: - state = self._read_state() + return self._replace_with_receipt_sync(replacement)[0] + + def _replace_with_receipt_sync( + self, replacement: tuple[IndexedProjection[P], ...], + ) -> tuple[tuple[IndexedProjection[P], ...], JsonIndexPublication]: + with self._mutation_scope() as directory: + state = self._read_state(preserve_corrupt=True, directory=directory) items = { item.locator: item for item in replacement if item.source_revision > state.tombstones.get(item.locator, -1) } - self._write_state( + receipt = self._write_state( items, state.tombstones, generation=_new_generation(), sequence=0, + directory=directory, ) - return tuple(items.values()) + assert receipt is not None + return tuple(items.values()), receipt + + @contextmanager + def _mutation_scope(self, *, create_lock: bool = True) -> Iterator[int | None]: + """Serialize legacy writers with the retained-root cache transaction. + + Contention is reported before reading or publishing the index. Do not + wait on a lock that an owned transaction may retain for cleanup. + """ + if not self._writable: + raise RuntimeError("read-only conversation index cannot be modified") + with self._lock: + if os.name == "posix" and hasattr(os, "O_DIRECTORY"): + if create_lock: + self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + directory = os.open( + self.path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC, + ) + try: + with journal_file_lock_at( + directory, self.path.name + ".lock", "exclusive", + blocking=False, create=create_lock, + ): + yield directory + finally: + os.close(directory) + else: + with journal_file_lock(self.path, "exclusive", blocking=False, create=create_lock): + yield None def _read_state( self, + *, preserve_corrupt: bool = False, + directory: int | None = None, ) -> _JsonConversationIndexState[P]: try: content = _read_stable_regular_file( self.path, max_bytes=_MAX_CONVERSATION_INDEX_BYTES, + directory=directory, ) except FileNotFoundError: return _JsonConversationIndexState( @@ -324,6 +506,14 @@ def _read_state( sequence=0, index_state="stale", ) + # Read paths must not rename an index concurrently owned by a writer. + # Quarantine is permitted only inside the mutation scope above. + return self._decode_state(content, preserve_corrupt=preserve_corrupt, directory=directory) + + def _decode_state( + self, content: bytes, *, preserve_corrupt: bool = True, + directory: int | None = None, + ) -> _JsonConversationIndexState[P]: try: payload = json.loads(content.decode("utf-8")) if ( @@ -343,8 +533,8 @@ def _read_state( if type(raw_sequence) is not int or raw_sequence < 0: raise ValueError("conversation index sequence is invalid") except Exception: - if self._writable: - self._preserve_corrupt() + if self._writable and preserve_corrupt: + self._preserve_corrupt(directory=directory) return _JsonConversationIndexState( items={}, tombstones={}, @@ -407,7 +597,9 @@ def _write_state( *, generation: str, sequence: int, - ) -> None: + target: RootedFile | None = None, + directory: int | None = None, + ) -> JsonIndexPublication | None: if not self._writable: raise RuntimeError("read-only conversation index cannot be modified") payload = { @@ -431,25 +623,64 @@ def _write_state( for locator, revision in sorted(tombstones.items()) ], } + if target is not None: + encoded = (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8") + if len(encoded) > _MAX_CONVERSATION_INDEX_BYTES: + raise ValueError("conversation index exceeds the bounded cache size") + target.atomic_write(encoded) + return None + if directory is not None: + name = f".{self.path.name}.{secrets.token_hex(16)}.tmp" + descriptor = os.open( + name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, + 0o600, dir_fd=directory, + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n") + stream.flush() + opened, parent = os.fstat(stream.fileno()), os.fstat(directory) + receipt = JsonIndexPublication( + self.path, generation, sequence, (opened.st_dev, opened.st_ino), + (parent.st_dev, parent.st_ino), + ) + os.replace(name, self.path.name, src_dir_fd=directory, dst_dir_fd=directory) + return receipt + except BaseException: + with suppress(FileNotFoundError): + os.unlink(name, dir_fd=directory) + raise self.path.parent.mkdir(parents=True, exist_ok=True) - temp_path = self.path.with_suffix(self.path.suffix + ".tmp") + temp_path: Path | None = None try: - temp_path.write_text( - json.dumps(payload, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) + # A private exclusive temporary file avoids following a pre-existing + # predictable .tmp link and keeps the published cache owner-only. + with NamedTemporaryFile( + mode="w", encoding="utf-8", dir=self.path.parent, + prefix=f".{self.path.name}.", suffix=".tmp", delete=False, + ) as stream: + temp_path = Path(stream.name) + stream.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n") + stream.flush() + opened = os.fstat(stream.fileno()) + receipt = JsonIndexPublication( + self.path, generation, sequence, (opened.st_dev, opened.st_ino), + ) temp_path.replace(self.path) + return receipt except BaseException: - with suppress(FileNotFoundError): - temp_path.unlink() + if temp_path is not None: + with suppress(FileNotFoundError): + temp_path.unlink() raise - def _preserve_corrupt(self) -> Path | None: - if not self.path.exists(): - return None + def _preserve_corrupt(self, *, directory: int | None = None) -> Path | None: target = self.path.with_name(f"{self.path.name}.corrupt-{time_ns()}") try: - self.path.replace(target) + if directory is None: + self.path.replace(target) + else: + os.replace(self.path.name, target.name, src_dir_fd=directory, dst_dir_fd=directory) except Exception: return None return target @@ -464,8 +695,15 @@ class _JsonConversationIndexState(Generic[P]): index_state: ConversationIndexState -def _read_stable_regular_file(path: Path, *, max_bytes: int) -> bytes: - before = path.lstat() +def _read_stable_regular_file(path: Path, *, max_bytes: int, directory: int | None = None) -> bytes: + return _read_stable_regular_snapshot(path, max_bytes=max_bytes, directory=directory)[0] + + +def _read_stable_regular_snapshot( + path: Path, *, max_bytes: int, directory: int | None = None, +) -> tuple[bytes, os.stat_result]: + before = (path.lstat() if directory is None else + os.stat(path.name, dir_fd=directory, follow_symlinks=False)) if not _regular_file_status_no_follow(before): raise OSError("conversation index must be a direct regular file") if before.st_size > max_bytes: @@ -473,7 +711,13 @@ def _read_stable_regular_file(path: Path, *, max_bytes: int) -> bytes: descriptor = -1 parent_descriptor = -1 try: - descriptor, parent_descriptor = _open_file_no_follow(path) + if directory is None: + descriptor, parent_descriptor = _open_file_no_follow(path) + else: + descriptor = os.open( + path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC, + dir_fd=directory, + ) opened = os.fstat(descriptor) if not _same_file_status(before, opened): raise OSError("conversation index identity changed") @@ -486,12 +730,13 @@ def _read_stable_regular_file(path: Path, *, max_bytes: int) -> bytes: chunks.append(chunk) remaining -= len(chunk) after = os.fstat(descriptor) - current = path.lstat() + current = (path.lstat() if directory is None else + os.stat(path.name, dir_fd=directory, follow_symlinks=False)) if not _same_file_status(before, after) or not _same_file_status( before, current ): raise OSError("conversation index changed while reading") - return b"".join(chunks) + return b"".join(chunks), opened finally: if descriptor >= 0: os.close(descriptor) diff --git a/src/loushang/harness/conversation/store.py b/src/loushang/harness/conversation/store.py index 5d4dab42b..e3b8653ee 100644 --- a/src/loushang/harness/conversation/store.py +++ b/src/loushang/harness/conversation/store.py @@ -1,6 +1,7 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Callable, Sequence +from contextlib import AbstractAsyncContextManager from dataclasses import dataclass, field from datetime import UTC, datetime from pathlib import Path @@ -41,6 +42,12 @@ def __post_init__(self) -> None: _require_text(self.conversation_id, name="conversation id") +# A namespace target grants query lifetime only, never another key's writes. +ConversationOperationScope = Callable[ + [ConversationKey | str], AbstractAsyncContextManager[None] +] + + @dataclass(frozen=True) class ConversationSnapshot(Generic[HeaderT, RecordT]): """One authoritative conversation snapshot and its concurrency token.""" diff --git a/src/loushang/harness/conversation/stores/file.py b/src/loushang/harness/conversation/stores/file.py index 7d3c26d6f..9ea0083de 100644 --- a/src/loushang/harness/conversation/stores/file.py +++ b/src/loushang/harness/conversation/stores/file.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import base64 import binascii import hashlib @@ -9,12 +8,12 @@ import math import os import stat -from collections.abc import Callable, Iterable, Sequence -from contextlib import AbstractContextManager, suppress +from collections.abc import Callable, Iterable, Iterator, Sequence +from contextlib import contextmanager, nullcontext, suppress from dataclasses import dataclass, replace from datetime import datetime, timezone from pathlib import Path -from typing import Generic, TypeVar +from typing import TYPE_CHECKING, Any, Generic, TypeVar from loushang.harness.conversation.store import ( CommitReceipt, @@ -23,6 +22,7 @@ ConversationHead, ConversationKey, ConversationLoadResult, + ConversationOperationScope, ConversationPage, ConversationSnapshot, ConversationSourceDiagnostic, @@ -49,9 +49,19 @@ load_jsonl, write_jsonl, ) +from loushang.harness.journal._owned_io import settled_io as _settled_io +from loushang.harness.journal.jsonl import _JournalWriteEncodingError + +if TYPE_CHECKING: + from loushang.harness.journal._rooted_io import ( + RootedFile, + RootedFileIO, + _PublicationWitness, + ) HeaderT = TypeVar("HeaderT") RecordT = TypeVar("RecordT") +ResultT = TypeVar("ResultT") CreatePath = Callable[[ConversationKey], Path] ResolvePath = Callable[[ConversationKey], Path | None] ScanPaths = Callable[[str], Iterable[Path]] @@ -82,6 +92,11 @@ class _JournalIdentity: ctime_ns: int +@dataclass +class _DeletionProgress: + may_have_committed: bool = False + + @dataclass(frozen=True) class _OperationFilterSegment: payload: bytes @@ -202,6 +217,14 @@ class _StoreHead: class FileConversationStore(Generic[HeaderT, RecordT]): """File-backed Store whose layout and codecs are Product supplied. + Once submitted, native IO settles before caller cancellation propagates. + Cancellation is not rollback or a native deadline; committed writes remain. + + read_only rejects mutations before dispatch. The adapter must also supply + non-mutating path/discovery/read callbacks to provide a physically read-only + projection. scan_snapshot_loader separates namespace reads from a keyed + loader that may maintain caches; no high-level writer identity lives here. + Persistent append acceleration is opt-in. Products must provide a stable ``head_compatibility_token`` and bump it whenever writable codec, load-policy, or record-id projection semantics change. Without a token the journal is @@ -222,8 +245,19 @@ def __init__( tombstone_path: TombstonePath | None = None, head_compatibility_token: str | None = None, snapshot_loader: SnapshotLoader[HeaderT, RecordT] | None = None, + scan_snapshot_loader: SnapshotLoader[HeaderT, RecordT] | None = None, delete_artifacts: DeleteArtifacts | None = None, + read_only: bool = False, + operation_scope: ConversationOperationScope | None = None, + file_io: RootedFileIO | None = None, ) -> None: + if type(read_only) is not bool: + raise TypeError("read_only must be a built-in bool") + self._read_only = read_only + self._operation_scope = operation_scope + self._file_io = file_io + self._creation_witness: tuple[ConversationKey, str, _PublicationWitness] | None = None + self._creation_witness_written = False self._create_path = create_path self._resolve_path = resolve_path self._scan_paths = scan_paths @@ -237,8 +271,42 @@ def __init__( head_compatibility_token ) self._snapshot_loader = snapshot_loader + self._scan_snapshot_loader = scan_snapshot_loader or snapshot_loader self._delete_artifacts = delete_artifacts + def _require_writable(self) -> None: + if self._read_only: + raise StoreConflictError("conversation store is read-only") + + def retain_creation_identity(self, key: ConversationKey, operation_id: str) -> None: + """Arm one original native publication receipt without creating a file.""" + self._require_writable() + operation = require_operation_id(operation_id) + if self._file_io is None or self._creation_witness is not None: + raise StoreDataError("creation identity requires one unused rooted FileStore") + path = self._checked_path(self._create_path(key)) + witness = self._file_io.retain_next_publication(path) + self._creation_witness = key, operation, witness + + def created_file_identity(self, key: ConversationKey, operation_id: str) -> tuple[int, int]: + """Read only the exact creation's still-pinned native receipt.""" + retained = self._creation_witness + if retained is None or retained[:2] != (key, operation_id) or not self._creation_witness_written: + raise StoreCommitOutcomeUnknown("create has no original file identity receipt") + try: + return retained[2].identity + except OSError as exc: + raise StoreCommitOutcomeUnknown("create file identity witness is unavailable") from exc + + async def _scoped_io( + self, target: ConversationKey | str, operation: Callable[..., ResultT], + *args: Any, **kwargs: Any, + ) -> ResultT: + if self._operation_scope is None: + return await _settled_io(operation, *args, **kwargs) + async with self._operation_scope(target): + return await _settled_io(operation, *args, **kwargs) + async def create( self, key: ConversationKey, @@ -247,7 +315,9 @@ async def create( *, operation_id: str, ) -> ConversationSnapshot[HeaderT, RecordT]: - return await asyncio.to_thread( + self._require_writable() + return await self._scoped_io( + key, self._create_sync, key, header, @@ -259,7 +329,7 @@ async def load( self, key: ConversationKey, ) -> ConversationLoadResult[HeaderT, RecordT]: - return await asyncio.to_thread(self._load_sync, key) + return await self._scoped_io(key, self._load_sync, key) async def append( self, @@ -269,14 +339,15 @@ async def append( expected_revision: int, operation_id: str, ) -> ConversationCommitResult: - operation = asyncio.to_thread( + self._require_writable() + return await self._scoped_io( + key, self._append_sync, key, record, expected_revision=expected_revision, operation_id=operation_id, ) - return await asyncio.shield(operation) async def append_batch( self, @@ -286,14 +357,15 @@ async def append_batch( expected_revision: int, operation_ids: Sequence[str], ) -> ConversationBatchCommitResult: - operation = asyncio.to_thread( + self._require_writable() + return await self._scoped_io( + key, self._append_batch_sync, key, tuple(records), expected_revision=expected_revision, operation_ids=tuple(operation_ids), ) - return await asyncio.shield(operation) async def delete( self, @@ -301,17 +373,26 @@ async def delete( *, expected_revision: int, operation_id: str, + expected_file_identity: tuple[int, int] | None = None, ) -> DeletionReceipt: - operation = asyncio.to_thread( + self._require_writable() + if expected_file_identity is not None: + if (type(expected_file_identity) is not tuple or len(expected_file_identity) != 2 + or any(type(value) is not int or value < 0 for value in expected_file_identity)): + raise ValueError("expected file identity must be a device/inode pair") + if self._file_io is None: + raise StoreDataError("identity-bound deletion requires rooted file IO") + return await self._scoped_io( + key, self._delete_sync, key, expected_revision=expected_revision, operation_id=operation_id, + expected_file_identity=expected_file_identity, ) - return await asyncio.shield(operation) async def scan(self, namespace: str) -> tuple[ConversationKey, ...]: - return await asyncio.to_thread(self._scan_sync, namespace) + return await self._scoped_io(namespace, self._scan_sync, namespace) async def scan_page( self, @@ -320,7 +401,8 @@ async def scan_page( cursor: str | None = None, limit: int = 100, ) -> ConversationPage: - return await asyncio.to_thread( + return await self._scoped_io( + namespace, self._scan_page_sync, namespace, cursor=cursor, @@ -337,24 +419,26 @@ def _create_sync( ) -> ConversationSnapshot[HeaderT, RecordT]: operation = require_operation_id(operation_id) durable_records = tuple(records) + may_have_created = False try: - path = Path(self._create_path(key)) - journal = self._write_journal_factory(path) - with _exclusive_lock(journal): - tombstone = _load_tombstone(self._tombstone_for(key, path)) + path = self._checked_path(self._create_path(key)) + journal = self._new_journal(path, write=True) + with _exclusive_lock(journal) as journal: + tombstone = self._read_tombstone(self._tombstone_for(key, path)) if tombstone is not None: raise StoreAlreadyExistsError( f"conversation {key!r} has a retired identity" ) - if path.exists(): + if self._is_file(path): current = _load_unlocked(journal) - recorded_operation = _load_create_operation(path) + recorded_operation = _load_create_operation(path, rooted=journal.bound_file) if ( current.header == header and current.records == durable_records and operation == (recorded_operation or _create_operation_id(key)) ): + may_have_created = True return ConversationSnapshot( header=header, records=durable_records, @@ -371,11 +455,19 @@ def _create_sync( raise StoreAlreadyExistsError( f"conversation {key!r} already exists" ) - _write_unlocked(journal, header=header, records=durable_records) + may_have_created = True try: + try: + _write_unlocked(journal, header=header, records=durable_records) + except _JournalWriteEncodingError: + may_have_created = False + raise + if self._creation_witness is not None and self._creation_witness[:2] == (key, operation): + self._creation_witness_written = True _write_create_operation( path, operation, + rooted=journal.bound_file, head=( _build_store_head( journal, @@ -387,30 +479,39 @@ def _create_sync( else None ), ) + except _JournalWriteEncodingError: + raise except Exception as exc: raise StoreCommitOutcomeUnknown( f"create outcome for conversation {key!r} is unknown" ) from exc + return ConversationSnapshot( + header=header, + records=durable_records, + revision=len(durable_records), + ) except (StoreAlreadyExistsError, StoreCommitOutcomeUnknown): raise except Exception as exc: + if may_have_created: + raise StoreCommitOutcomeUnknown( + f"create outcome for conversation {key!r} is unknown" + ) from exc raise _data_error("create", key, exc) from exc - return ConversationSnapshot( - header=header, - records=durable_records, - revision=len(durable_records), - ) def _load_sync( self, key: ConversationKey, + *, + scan: bool = False, ) -> ConversationLoadResult[HeaderT, RecordT]: path = self._required_path(key) + loader = self._scan_snapshot_loader if scan else self._snapshot_loader try: snapshot = ( - self._snapshot_loader(path) - if self._snapshot_loader is not None - else self._journal_factory(path).load() + loader(path) + if loader is not None + else self._new_journal(path).load() ) except FileNotFoundError as exc: raise StoreNotFoundError(f"conversation {key!r} was not found") from exc @@ -440,12 +541,12 @@ def _append_sync( operation = require_operation_id(operation_id) expected = require_revision(expected_revision, name="expected revision") path = self._required_path(key) - journal = self._write_journal_factory(path) + journal = self._new_journal(path, write=True) receipt: CommitReceipt source_diagnostics: tuple[ConversationSourceDiagnostic, ...] = () try: - with _exclusive_lock(journal): - if not path.is_file(): + with _exclusive_lock(journal) as journal: + if not self._is_file(path): raise StoreNotFoundError(f"conversation {key!r} was not found") projected_id = ( self._record_id(record) if self._record_id is not None else None @@ -459,6 +560,7 @@ def _append_sync( _try_load_store_head( path, compatibility_token=self._head_compatibility_token, + rooted=journal.bound_file, ) if self._head_compatibility_token is not None else None @@ -514,6 +616,7 @@ def _append_sync( record_id=self._record_id, compatibility_token=self._head_compatibility_token, ), + rooted=journal.bound_file, ) return reconciled revision = len(snapshot.records) @@ -555,7 +658,7 @@ def _append_sync( f"append outcome for conversation {key!r} is unknown" ) from exc if advanced_head is not None: - _try_write_store_head(path, advanced_head, refresh_identity=True) + _try_write_store_head(path, advanced_head, refresh_identity=True, rooted=journal.bound_file) except ( StoreCommitOutcomeUnknown, StoreConflictError, @@ -599,11 +702,11 @@ def _append_batch_sync( ) expected = require_revision(expected_revision, name="expected revision") path = self._required_path(key) - journal = self._write_journal_factory(path) + journal = self._new_journal(path, write=True) source_diagnostics: tuple[ConversationSourceDiagnostic, ...] = () try: - with _exclusive_lock(journal): - if not path.is_file(): + with _exclusive_lock(journal) as journal: + if not self._is_file(path): raise StoreNotFoundError(f"conversation {key!r} was not found") record_digests = tuple( _record_digest(journal, record) for record in durable_records @@ -612,6 +715,7 @@ def _append_batch_sync( _try_load_store_head( path, compatibility_token=self._head_compatibility_token, + rooted=journal.bound_file, ) if self._head_compatibility_token is not None else None @@ -682,9 +786,10 @@ def _append_batch_sync( path, advanced_head, refresh_identity=True, + rooted=journal.bound_file, ) elif replayed_authority and head is not None: - _try_write_store_head(path, head) + _try_write_store_head(path, head, rooted=journal.bound_file) except ( StoreCommitOutcomeUnknown, StoreConflictError, @@ -834,24 +939,66 @@ def _delete_sync( *, expected_revision: int, operation_id: str, + expected_file_identity: tuple[int, int] | None = None, ) -> DeletionReceipt: expected = require_revision(expected_revision, name="expected revision") operation = require_operation_id(operation_id) resolved = self._resolve_path(key) - path = Path(resolved) if resolved is not None else Path(self._create_path(key)) + path = self._checked_path(resolved if resolved is not None else self._create_path(key)) tombstone_path = self._tombstone_for(key, path) - prior_tombstone = _load_tombstone(tombstone_path) + binding = self._file_io.bind(tombstone_path, create_parent=True) if self._file_io is not None else nullcontext(None) + progress = _DeletionProgress() + try: + with binding as tombstone: + return self._delete_bound( + key, path, tombstone_path, tombstone, expected=expected, operation=operation, + progress=progress, + expected_file_identity=expected_file_identity, + ) + except StoreCommitOutcomeUnknown: + raise + except Exception as exc: + if progress.may_have_committed: + raise StoreCommitOutcomeUnknown( + f"delete outcome for conversation {key!r} is unknown" + ) from exc + raise + + def _delete_bound( + self, key: ConversationKey, path: Path, tombstone_path: Path, + tombstone: RootedFile | None, *, expected: int, operation: str, + progress: _DeletionProgress, + expected_file_identity: tuple[int, int] | None, + ) -> DeletionReceipt: + prior_tombstone = _load_tombstone(tombstone_path, rooted=tombstone) if prior_tombstone is not None: if ( prior_tombstone.get("operation_id") == operation and prior_tombstone.get("revision") == expected ): receipt = _decode_deletion_receipt(prior_tombstone) - if path.is_file(): + progress.may_have_committed = True + if tombstone is not None: + try: + # A prior process may have stopped after replace but + # before directory fsync; visibility is not durability. + tombstone.sync_directory() + except Exception as exc: + raise StoreCommitOutcomeUnknown( + f"delete outcome for conversation {key!r} is unknown" + ) from exc + if self._is_file(path): try: - journal = self._write_journal_factory(path) - with _exclusive_lock(journal): - path.unlink(missing_ok=True) + journal = self._new_journal(path, write=True) + with _exclusive_lock(journal) as journal: + if expected_file_identity is not None: + self._require_delete_identity(journal.bound_file, expected_file_identity) + assert journal.bound_file is not None + journal.bound_file.unlink_owned(expected_file_identity) + elif journal.bound_file is None: + path.unlink(missing_ok=True) + else: + journal.bound_file.unlink(missing_ok=True) except Exception as exc: raise StoreCommitOutcomeUnknown( f"delete outcome for conversation {key!r} is unknown" @@ -859,11 +1006,13 @@ def _delete_sync( self._try_delete_artifacts(path) return receipt raise StoreNotFoundError(f"conversation {key!r} was not found") - journal = self._write_journal_factory(path) + journal = self._new_journal(path, write=True) try: - with _exclusive_lock(journal): - if not path.is_file(): + with _exclusive_lock(journal) as journal: + if not self._is_file(path): raise StoreNotFoundError(f"conversation {key!r} was not found") + if expected_file_identity is not None: + self._require_delete_identity(journal.bound_file, expected_file_identity) snapshot = _load_unlocked(journal) revision = len(snapshot.records) if revision != expected: @@ -876,21 +1025,37 @@ def _delete_sync( deleted_at=self._clock(), operation_id=operation, ) - _write_tombstone(tombstone_path, receipt) - path.unlink() + progress.may_have_committed = True + _write_tombstone(tombstone_path, receipt, rooted=tombstone) + if expected_file_identity is not None: + self._require_delete_identity(journal.bound_file, expected_file_identity) + assert journal.bound_file is not None + journal.bound_file.unlink_owned(expected_file_identity) + elif journal.bound_file is None: + path.unlink() + else: + journal.bound_file.unlink() self._try_delete_artifacts(path) except (StoreCommitOutcomeUnknown, StoreConflictError, StoreNotFoundError): raise except FileNotFoundError as exc: raise StoreNotFoundError(f"conversation {key!r} was not found") from exc except Exception as exc: - if _load_tombstone(tombstone_path) is not None: + if progress.may_have_committed: raise StoreCommitOutcomeUnknown( f"delete outcome for conversation {key!r} is unknown" ) from exc raise _data_error("delete", key, exc) from exc return receipt + @staticmethod + def _require_delete_identity(rooted: RootedFile | None, identity: tuple[int, int]) -> None: + if rooted is None: + raise StoreDataError("identity-bound deletion requires a rooted Journal") + status = rooted.stat() + if (status.st_dev, status.st_ino) != identity: + raise StoreConflictError("conversation file identity changed before deletion") + def _try_delete_artifacts(self, path: Path) -> None: if self._delete_artifacts is None: return @@ -953,7 +1118,7 @@ def _scan_page_sync( heads = [] for key in selected: try: - snapshot = self._load_sync(key).snapshot + snapshot = self._load_sync(key, scan=True).snapshot except Exception as exc: resolved = self._resolve_path(key) diagnostics += ( @@ -988,11 +1153,41 @@ def _required_path(self, key: ConversationKey) -> Path: raise _data_error("resolve", key, exc) from exc if resolved is None: raise StoreNotFoundError(f"conversation {key!r} was not found") - path = Path(resolved) - if not path.is_file(): + path = self._checked_path(resolved) + if not self._is_file(path): raise StoreNotFoundError(f"conversation {key!r} was not found") return path + def _checked_path(self, path: Path) -> Path: + path = Path(path) + if self._file_io is not None and path.parent != self._file_io.root: + raise StoreDataError("managed conversation must be a direct child of its root") + return path + + def _new_journal(self, path: Path, *, write: bool = False) -> JsonlJournal[HeaderT, RecordT]: + result = (self._write_journal_factory if write else self._journal_factory)(path) + if self._file_io is not None and result.file_io is not self._file_io: + raise StoreDataError("managed Journal must borrow the Store's root IO") + return result + + def _is_file(self, path: Path) -> bool: + if self._file_io is None: + return path.is_file() + try: + self._file_io.stat(path) + except FileNotFoundError: + return False + return True + + def _read_tombstone(self, path: Path) -> dict[str, object] | None: + if self._file_io is None: + return _load_tombstone(path) + try: + with self._file_io.bind(path) as rooted: + return _load_tombstone(path, rooted=rooted) + except FileNotFoundError: + return None + def _reconcile_append( self, key: ConversationKey, @@ -1032,16 +1227,25 @@ def _tombstone_for(self, key: ConversationKey, path: Path) -> Path: return _default_tombstone_path(path) +@contextmanager def _exclusive_lock( journal: JsonlJournal[HeaderT, RecordT], -) -> AbstractContextManager[None]: +) -> Iterator[JsonlJournal[HeaderT, RecordT]]: + if journal.file_io is not None: + with journal.file_io.bind(journal.path, create_parent=True, durable=journal.durability.fsync) as rooted: + rooted.acquire_lock(exclusive=True, suffix=journal.durability.lock_suffix) + yield JsonlJournal( + journal.path, record_codec=journal.record_codec, header_codec=journal.header_codec, + format_profile=journal.format_profile, durability=_unlocked_durability(journal), + load_policy=journal.load_policy, bound_file=rooted, + ) + return if journal.lock_factory is not None: - return journal.lock_factory(journal.path, "exclusive") - return journal_file_lock( - journal.path, - "exclusive", - lock_suffix=journal.durability.lock_suffix, - ) + with journal.lock_factory(journal.path, "exclusive"): + yield journal + else: + with journal_file_lock(journal.path, "exclusive", lock_suffix=journal.durability.lock_suffix): + yield journal def _unlocked_durability(journal: JsonlJournal[HeaderT, RecordT]): @@ -1056,6 +1260,7 @@ def _load_unlocked(journal: JsonlJournal[HeaderT, RecordT]): format_profile=journal.format_profile, durability=_unlocked_durability(journal), load_policy=journal.load_policy, + bound_file=journal.bound_file, ) @@ -1069,6 +1274,7 @@ def _append_unlocked( record_codec=journal.record_codec, format_profile=journal.format_profile, durability=_unlocked_durability(journal), + bound_file=journal.bound_file, ) @@ -1082,6 +1288,7 @@ def _append_many_unlocked( record_codec=journal.record_codec, format_profile=journal.format_profile, durability=_unlocked_durability(journal), + bound_file=journal.bound_file, ) @@ -1110,6 +1317,8 @@ def _write_unlocked( header_codec=journal.header_codec, format_profile=journal.format_profile, durability=_unlocked_durability(journal), + bound_file=journal.bound_file, + _report_encoding_failure=True, ) @@ -1161,8 +1370,8 @@ def _record_digest( return hashlib.sha256(payload).hexdigest() -def _journal_identity(path: Path) -> _JournalIdentity: - stat = path.stat() +def _journal_identity(path: Path, *, rooted: RootedFile | None = None) -> _JournalIdentity: + stat = path.stat() if rooted is None else rooted.stat() return _JournalIdentity( device=stat.st_dev, inode=stat.st_ino, @@ -1205,7 +1414,7 @@ def _build_store_head( return _StoreHead( compatibility_token=compatibility_token, revision=len(records), - identity=_journal_identity(journal.path), + identity=_journal_identity(journal.path, rooted=journal.bound_file), operation_filter=operation_filter.freeze(), recent_records=tuple( _RecentRecord( @@ -1265,8 +1474,9 @@ def _create_operation_id(key: ConversationKey) -> str: def _load_create_operation( path: Path, + *, rooted: RootedFile | None = None, ) -> str | None: - value = _load_store_metadata(path) + value = _load_store_metadata(path, rooted=rooted) if value is None: return None if "create_operation_id" not in value: @@ -1280,16 +1490,22 @@ def _write_create_operation( operation_id: str, *, head: _StoreHead | None, + rooted: RootedFile | None = None, ) -> None: metadata: dict[str, object] = {"create_operation_id": operation_id} if head is not None: metadata["head"] = _encode_store_head(head) - _write_json_sidecar(_metadata_path(path), metadata) + _write_json_sidecar(_metadata_path(path), metadata, rooted=_metadata_ref(path, rooted)) + +def _metadata_ref(path: Path, rooted: RootedFile | None) -> RootedFile | None: + return None if rooted is None else rooted.sibling(_metadata_path(path).name) -def _load_store_metadata(path: Path) -> dict[str, object] | None: + +def _load_store_metadata(path: Path, *, rooted: RootedFile | None = None) -> dict[str, object] | None: try: - value = json.loads(_metadata_path(path).read_text(encoding="utf-8")) + metadata = _metadata_ref(path, rooted) + value = json.loads(metadata.read_bytes() if metadata is not None else _metadata_path(path).read_text(encoding="utf-8")) except FileNotFoundError: return None except OSError: @@ -1305,16 +1521,17 @@ def _try_load_store_head( path: Path, *, compatibility_token: str, + rooted: RootedFile | None = None, ) -> _StoreHead | None: try: - metadata = _load_store_metadata(path) + metadata = _load_store_metadata(path, rooted=rooted) if metadata is None: return None head = _decode_store_head( metadata.get("head"), compatibility_token=compatibility_token, ) - if head.identity != _journal_identity(path): + if head.identity != _journal_identity(path, rooted=rooted): return None return head except (OSError, StoreDataError, TypeError, ValueError): @@ -1326,20 +1543,21 @@ def _try_write_store_head( head: _StoreHead, *, refresh_identity: bool = False, + rooted: RootedFile | None = None, ) -> None: """Best-effort cache update that never changes a durable journal outcome.""" try: try: - metadata = _load_store_metadata(path) + metadata = _load_store_metadata(path, rooted=rooted) except StoreDataError: metadata = {} if metadata is None: metadata = {} if refresh_identity: - head = replace(head, identity=_journal_identity(path)) + head = replace(head, identity=_journal_identity(path, rooted=rooted)) metadata["head"] = _encode_store_head(head) - _write_json_sidecar(_metadata_path(path), metadata) + _write_json_sidecar(_metadata_path(path), metadata, rooted=_metadata_ref(path, rooted)) except Exception: return @@ -1524,8 +1742,8 @@ def _store_head_checksum(value: dict[str, object]) -> str: return hashlib.sha256(payload).hexdigest() -def _load_tombstone(target: Path) -> dict[str, object] | None: - value = _read_tombstone_json(target) +def _load_tombstone(target: Path, *, rooted: RootedFile | None = None) -> dict[str, object] | None: + value = _read_tombstone_json(target, rooted=rooted) if value is None: return None _validated_deletion_receipt(value) @@ -1548,7 +1766,17 @@ def _validated_deletion_receipt(value: dict[str, object]) -> DeletionReceipt: raise StoreDataError("conversation deletion tombstone is invalid") from exc -def _read_tombstone_json(target: Path) -> dict[str, object] | None: +def _read_tombstone_json(target: Path, *, rooted: RootedFile | None = None) -> dict[str, object] | None: + if rooted is not None: + try: + value = json.loads(rooted.read_bytes(max_bytes=_TOMBSTONE_MAX_BYTES)) + except FileNotFoundError: + return None + except Exception as exc: + raise StoreDataError("conversation deletion tombstone is invalid") from exc + if not isinstance(value, dict): + raise StoreDataError("conversation deletion tombstone is invalid") + return value try: before = target.lstat() except FileNotFoundError: @@ -1637,7 +1865,7 @@ def _same_file_status(left: os.stat_result, right: os.stat_result) -> bool: ) -def _write_tombstone(path: Path, receipt: DeletionReceipt) -> None: +def _write_tombstone(path: Path, receipt: DeletionReceipt, *, rooted: RootedFile | None = None) -> None: _write_json_sidecar( path, { @@ -1645,6 +1873,7 @@ def _write_tombstone(path: Path, receipt: DeletionReceipt) -> None: "deleted_at": receipt.deleted_at.isoformat(), "operation_id": receipt.operation_id, }, + rooted=rooted, ) @@ -1665,7 +1894,10 @@ def _decode_deletion_receipt(value: dict[str, object]) -> DeletionReceipt: ) -def _write_json_sidecar(path: Path, value: dict[str, object]) -> None: +def _write_json_sidecar(path: Path, value: dict[str, object], *, rooted: RootedFile | None = None) -> None: + if rooted is not None: + rooted.atomic_write((json.dumps(value, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8")) + return path.parent.mkdir(parents=True, exist_ok=True) temp = path.with_suffix(path.suffix + ".tmp") try: diff --git a/src/loushang/harness/journal/_owned_io.py b/src/loushang/harness/journal/_owned_io.py new file mode 100644 index 000000000..7a0d0ac04 --- /dev/null +++ b/src/loushang/harness/journal/_owned_io.py @@ -0,0 +1,69 @@ +"""Private native IO settlement shared by durable stores and their owners.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from typing import Any, TypeVar, cast + +ResultT = TypeVar("ResultT") + + +async def settled_io(operation: Callable[..., ResultT], *args: Any, **kwargs: Any) -> ResultT: + """Join one native operation before returning, including on cancellation. + + The independent receipt outlives cancellation of the private offload task. + A submitted operation without a receipt remains pending, never resubmitted. + The owning application must keep its event loop alive through settlement. + """ + loop = asyncio.get_running_loop() + published: asyncio.Future[None] = loop.create_future() + receipt: asyncio.Future[tuple[bool, object]] = loop.create_future() + dispatched = False + + def native() -> None: + try: + outcome: tuple[bool, object] = (True, operation(*args, **kwargs)) + except BaseException as error: + outcome = (False, error) + loop.call_soon_threadsafe(receipt.set_result, outcome) + + async def run() -> None: + nonlocal dispatched + await published + dispatched = True + await asyncio.to_thread(native) + + work = run() + try: + task = loop.create_task(work) + except BaseException: + published.cancel() + work.close() + raise + published.set_result(None) + cancellation: asyncio.CancelledError | None = None + while not task.done(): + try: + await asyncio.shield(task) + except asyncio.CancelledError as error: + cancellation = error + except BaseException: + break # Preserve the task error after native settlement, if dispatched. + if not dispatched: + task.result() # Failed/cancelled before native submission: no native effect. + raise RuntimeError("file IO task ended before dispatch") + task_error = asyncio.CancelledError() if task.cancelled() else task.exception() + while not receipt.done(): + try: + await asyncio.shield(receipt) + except asyncio.CancelledError as error: + cancellation = error + succeeded, result = receipt.result() + if not succeeded: + raise cast(BaseException, result) + if task_error is not None: + raise task_error + if cancellation is not None: + raise cancellation + return cast(ResultT, result) diff --git a/tests/harness/conversation/_index_child.py b/tests/harness/conversation/_index_child.py new file mode 100644 index 000000000..8c7d3221e --- /dev/null +++ b/tests/harness/conversation/_index_child.py @@ -0,0 +1,40 @@ +"""Independent index writer used only by the process-lock regression.""" + +import asyncio +import sys + +from loushang.harness.conversation import ( + ConversationKey, + ConversationLocator, + FunctionalProjectionCodec, + IndexedProjection, + JsonConversationIndex, +) +from loushang.harness.journal.jsonl import JournalLockUnavailable + + +def main(): + index = JsonConversationIndex( + sys.argv[1], version=1, + codec=FunctionalProjectionCodec( + encoder=lambda value: {"value": value}, decoder=lambda data: data["value"], + ), + query_items=lambda _query, items: tuple(items), + ) + projection = IndexedProjection( + ConversationLocator("local", ConversationKey("root", "two")), 1, "second", + ) + try: + asyncio.run(index.upsert(projection)) + except JournalLockUnavailable: + print("busy", flush=True) + else: + raise AssertionError("child bypassed parent index transaction") + if sys.stdin.readline() != "continue\n": + raise AssertionError("missing parent release receipt") + assert asyncio.run(index.upsert(projection)) + print("published", flush=True) + + +if __name__ == "__main__": + main() diff --git a/tests/harness/conversation/test_file_store_settlement.py b/tests/harness/conversation/test_file_store_settlement.py new file mode 100644 index 000000000..c10e816f9 --- /dev/null +++ b/tests/harness/conversation/test_file_store_settlement.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import asyncio +import contextvars +import threading +from time import monotonic + +import pytest + +from loushang.harness.conversation import ConversationKey +from loushang.harness.journal import _owned_io as module + +from .test_store_conformance import _file_factory, _Header, _Record + + +async def entered(event): + deadline = monotonic() + 5 + while not event.is_set(): + assert monotonic() < deadline, "native operation did not start" + await asyncio.sleep(0.001) + + +def operation(store, name): + key = ConversationKey("sessions", "one") + calls = { + "create": lambda: store.create(key, _Header("one"), operation_id="create"), + "load": lambda: store.load(key), + "append": lambda: store.append(key, _Record("r1", "text"), expected_revision=0, operation_id="r1"), + "append_batch": lambda: store.append_batch(key, [_Record("r1", "text")], expected_revision=0, operation_ids=["r1"]), + "delete": lambda: store.delete(key, expected_revision=0, operation_id="delete"), + "scan": lambda: store.scan("sessions"), + "scan_page": lambda: store.scan_page("sessions"), + } + return calls[name]() + + +@pytest.mark.parametrize("name", ["create", "load", "append", "append_batch", "delete", "scan", "scan_page"]) +def test_repeated_waiter_cancellation_waits_for_exact_native_operation(tmp_path, monkeypatch, name): + store = _file_factory(tmp_path)() + ready, release, completed = threading.Event(), threading.Event(), threading.Event() + calls = [] + + def native(*args, **kwargs): + calls.append(1) + ready.set() + assert release.wait(5) + completed.set() + return object() + + monkeypatch.setattr(store, "_" + name + "_sync", native) + + async def scenario(): + task = asyncio.create_task(operation(store, name)) + try: + await entered(ready) + for _ in range(2): + task.cancel() + await asyncio.sleep(0) + await asyncio.sleep(0) + assert not task.done(), "caller returned before native IO settled" + assert calls == [1] and not completed.is_set() + release.set() + with pytest.raises(asyncio.CancelledError): + await task + assert completed.is_set() and calls == [1] + finally: + release.set() + await asyncio.gather(task, return_exceptions=True) + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("cancel", [False, True]) +def test_native_failure_has_priority_over_waiter_cancellation(tmp_path, monkeypatch, cancel): + store = _file_factory(tmp_path)() + ready, release = threading.Event(), threading.Event() + failure = ValueError("actual native failure") + + def native(*args): + ready.set() + assert release.wait(5) + raise failure + + monkeypatch.setattr(store, "_load_sync", native) + + async def scenario(): + task = asyncio.create_task(operation(store, "load")) + try: + await entered(ready) + if cancel: + task.cancel() + await asyncio.sleep(0) + assert not task.done() + release.set() + with pytest.raises(ValueError) as caught: + await task + assert caught.value is failure + finally: + release.set() + await asyncio.gather(task, return_exceptions=True) + + asyncio.run(scenario()) + + +def test_private_offload_cancellation_still_waits_for_independent_native_receipt(tmp_path, monkeypatch): + store = _file_factory(tmp_path)() + ready, release, finished = threading.Event(), threading.Event(), threading.Event() + calls, offloads = [], [] + original = asyncio.to_thread + + def native(*args): + calls.append(1) + ready.set() + assert release.wait(5) + finished.set() + return "result" + + async def observe(*args, **kwargs): + offloads.append(asyncio.current_task()) + return await original(*args, **kwargs) + + monkeypatch.setattr(store, "_load_sync", native) + monkeypatch.setattr(module.asyncio, "to_thread", observe) + + async def scenario(): + task = asyncio.create_task(operation(store, "load")) + try: + await entered(ready) + offloads[0].cancel() + for _ in range(5): + await asyncio.sleep(0) + assert offloads[0].cancelled() and not task.done() + task.cancel() + await asyncio.sleep(0) + assert not task.done() and calls == [1] + release.set() + with pytest.raises(asyncio.CancelledError): + await task + assert finished.is_set() and calls == [1] + finally: + release.set() + await asyncio.gather(task, return_exceptions=True) + + asyncio.run(scenario()) + + +def test_publication_failure_has_no_native_effect(tmp_path, monkeypatch): + store = _file_factory(tmp_path)() + native_calls = [] + monkeypatch.setattr(store, "_load_sync", lambda *args: native_calls.append(1)) + + async def scenario(): + def reject(work, **kwargs): + raise RuntimeError("task publication failed") + + with monkeypatch.context() as patch: + patch.setattr(asyncio.get_running_loop(), "create_task", reject) + with pytest.raises(RuntimeError, match="publication"): + await operation(store, "load") + await asyncio.sleep(0) + assert native_calls == [] + + asyncio.run(scenario()) + + +def test_success_preserves_result_identity_and_contextvars(tmp_path, monkeypatch): + store = _file_factory(tmp_path)() + context = contextvars.ContextVar("file-store-test-context", default="missing") + result = object() + + def native(*args): + assert context.get() == "caller" + return result + + monkeypatch.setattr(store, "_load_sync", native) + + async def scenario(): + context.set("caller") + assert await operation(store, "load") is result + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("name", ["create", "append"]) +def test_real_jsonl_write_settles_before_cancelled_lifecycle_disposes(tmp_path, monkeypatch, name): + from loushang.harness.conversation import ConversationHeader + from loushang.harness.transcript import ( + AgentTranscriptFileLayout, + AgentTranscriptLifecycle, + AgentTranscriptProfile, + AgentTranscriptRuntimeBinding, + create_agent_transcript_file_store, + load_agent_transcript_file, + load_agent_transcript_header, + ) + + from ..transcript.test_lifecycle import _record + + header = ConversationHeader("actual-one", 1, "2026-09-13T00:00:00Z") + path = tmp_path / "actual.jsonl" + ready, release = threading.Event(), threading.Event() + events = [] + + async def bind(context, unused): + layout = AgentTranscriptFileLayout(context.session_dir) + key = layout.key(header.conversation_id) + layout.bind_create_path(key, path) + store = create_agent_transcript_file_store(layout) + target = "_create_sync" if name == "create" else "_append_sync" + original = getattr(store, target) + + def native(*args, **kwargs): + value = original(*args, **kwargs) # Actual JSONL committed before cancel. + events.append("write_complete") + ready.set() + assert release.wait(5) # The native call has not delivered its receipt. + events.append("native_return") + return value + + monkeypatch.setattr(store, target, native) + + async def dispose(): + events.append("dispose") + + return AgentTranscriptRuntimeBinding(store, key, AgentTranscriptProfile.default(), None, dispose) + + lifecycle = AgentTranscriptLifecycle(bind_runtime=bind) + context = lifecycle.new_context(session_dir=tmp_path, cwd=tmp_path, persist=True, + header=header, session_file=path) + + async def scenario(): + if name == "create": + work = lifecycle.create(context, None, defer_materialization=False) + else: + session = await lifecycle.create(context, None, defer_materialization=False) + + async def append(): + try: + return await session.runtime_binding.store.append( + session.runtime_binding.key, _record("r1"), expected_revision=0, operation_id="r1", + ) + finally: + await session.dispose() + + work = append() + task = asyncio.create_task(work) + try: + await entered(ready) + assert load_agent_transcript_header(path) == header + task.cancel() + for _ in range(3): + await asyncio.sleep(0) + assert events == ["write_complete"] and not task.done() + release.set() + with pytest.raises(asyncio.CancelledError): + await task + assert events == ["write_complete", "native_return", "dispose"] + assert load_agent_transcript_header(path) == header # Cancellation is not rollback. + assert len(load_agent_transcript_file(path)[1]) == (0 if name == "create" else 1) + finally: + release.set() + await asyncio.gather(task, return_exceptions=True) + + asyncio.run(scenario()) diff --git a/tests/harness/conversation/test_rooted_index.py b/tests/harness/conversation/test_rooted_index.py new file mode 100644 index 000000000..d8d01193e --- /dev/null +++ b/tests/harness/conversation/test_rooted_index.py @@ -0,0 +1,327 @@ +"""Owned cache publication reuses the existing index format and root ledger.""" + +import asyncio +import json +import os +import select +import stat +import subprocess +import sys +from pathlib import Path + +import pytest + +from loushang.harness.conversation import ( + ConversationKey, + ConversationLocator, + FunctionalProjectionCodec, + IndexedProjection, + JsonConversationIndex, +) + +from ..journal.test_rooted_io import borrowed + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux rooted cache") + + +def index_for(path): + return JsonConversationIndex( + path, version=1, + codec=FunctionalProjectionCodec(encoder=lambda value: {"value": value}, + decoder=lambda data: data["value"]), + query_items=lambda _query, items: tuple(items), + ) + + +def item(revision, value): + return IndexedProjection(ConversationLocator("local", ConversationKey("root", "one")), revision, value) + + +def test_rooted_upsert_preserves_format_revision_and_tombstones(tmp_path): + with borrowed(tmp_path / "root") as io: + index = index_for(io.root / "index.json") + asyncio.run(index.upsert(item(1, "first"))) + assert stat.S_IMODE(index.path.stat().st_mode) == 0o600 + with io.bind(index.path) as target: + assert index.upsert_rooted(item(2, "latest"), target) + assert asyncio.run(index.get(item(2, "latest").locator)) == item(2, "latest") + with io.bind(index.path) as target: + assert not index.upsert_rooted(item(1, "stale"), target) + asyncio.run(index.delete(item(2, "latest").locator, through_revision=2)) + assert stat.S_IMODE(index.path.stat().st_mode) == 0o600 + with io.bind(index.path) as target: + assert not index.upsert_rooted(item(2, "latest"), target) + + +@pytest.mark.parametrize("content", [None, b"{corrupt"]) +def test_rooted_upsert_does_not_create_or_repair_cache(tmp_path, content): + with borrowed(tmp_path / "root") as io: + index = index_for(io.root / "index.json") + if content is not None: + index.path.write_bytes(content) + index.path.chmod(0o600) + with io.bind(index.path) as target: + assert not index.upsert_rooted(item(1, "first"), target) + if content is None: + assert not index.path.exists() + else: + assert index.path.read_bytes() == content + assert not list(io.root.glob("*.corrupt-*")) + + +def test_rooted_upsert_rejects_group_writable_cache_without_modifying_it(tmp_path): + with borrowed(tmp_path / "root") as io: + index = index_for(io.root / "index.json") + index.path.write_bytes(b"unsafe cache") + index.path.chmod(0o660) + with io.bind(index.path) as target: + with pytest.raises(OSError, match="owned single-link"): + index.upsert_rooted(item(1, "first"), target) + assert index.path.read_bytes() == b"unsafe cache" + assert not (io.root / "index.json.lock").exists() + + +def test_index_creation_does_not_follow_predictable_temporary_symlink(tmp_path): + index = index_for(tmp_path / "index.json") + unrelated = tmp_path / "unrelated.txt" + unrelated.write_bytes(b"must stay unchanged") + legacy_temp = index.path.with_suffix(".json.tmp") + legacy_temp.symlink_to(unrelated) + + asyncio.run(index.upsert(item(1, "first"))) + + assert unrelated.read_bytes() == b"must stay unchanged" + assert legacy_temp.is_symlink() + assert stat.S_IMODE(index.path.stat().st_mode) == 0o600 + assert asyncio.run(index.get(item(1, "first").locator)) == item(1, "first") + + +def test_index_failed_publication_preserves_cache_and_cleans_own_temp(tmp_path, monkeypatch): + index = index_for(tmp_path / "index.json") + asyncio.run(index.upsert(item(1, "first"))) + before = index.path.read_bytes() + unrelated = tmp_path / "index.json.tmp" + unrelated.write_bytes(b"not ours") + + def fail_replace(source, target, *, src_dir_fd=None, dst_dir_fd=None): + assert target == index.path.name + assert source != unrelated.name + assert src_dir_fd == dst_dir_fd and src_dir_fd is not None + raise OSError("injected publication failure") + + monkeypatch.setattr(os, "replace", fail_replace) + with pytest.raises(OSError, match="injected publication failure"): + asyncio.run(index.upsert(item(2, "latest"))) + assert index.path.read_bytes() == before + assert unrelated.read_bytes() == b"not ours" + assert not list(tmp_path.glob(".index.json.*.tmp")) + + +@pytest.mark.parametrize("operation", ["upsert", "delete", "replace"]) +def test_legacy_mutations_respect_owned_index_transaction(tmp_path, operation): + from loushang.harness.journal.jsonl import JournalLockUnavailable + + with borrowed(tmp_path / "root") as io: + index = index_for(io.root / "index.json") + original = item(1, "first") + asyncio.run(index.upsert(original)) + before = index.path.read_bytes() + + def mutate(): + if operation == "upsert": + return asyncio.run(index.upsert(item(2, "latest"))) + if operation == "delete": + return asyncio.run(index.delete(original.locator, through_revision=2)) + return asyncio.run(index.replace((item(2, "latest"),))) + + with io.bind(index.path) as target: + target.acquire_lock(exclusive=True, blocking=False, suffix=".lock") + with pytest.raises(JournalLockUnavailable): + mutate() + assert index.path.read_bytes() == before + mutate() + + +def test_owned_mutation_respects_legacy_transaction(tmp_path): + from loushang.harness.journal.jsonl import journal_file_lock + + with borrowed(tmp_path / "root") as io: + index = index_for(io.root / "index.json") + asyncio.run(index.upsert(item(1, "first"))) + before = index.path.read_bytes() + with journal_file_lock(index.path, "exclusive", blocking=False): + with io.bind(index.path) as target: + with pytest.raises(BlockingIOError): + index.upsert_rooted(item(2, "latest"), target) + assert index.path.read_bytes() == before + with io.bind(index.path) as target: + assert index.upsert_rooted(item(2, "latest"), target) + + +@pytest.mark.parametrize("operation", ["upsert", "delete", "replace"]) +def test_readonly_mutation_rejects_before_creating_lock_or_parent(tmp_path, operation): + index = index_for(tmp_path / "absent" / "index.json") + readonly = JsonConversationIndex( + index.path, version=1, codec=index.codec, + query_items=lambda _query, items: tuple(items), writable=False, + ) + original = item(1, "first") + with pytest.raises(RuntimeError, match="read-only"): + if operation == "upsert": + asyncio.run(readonly.upsert(original)) + elif operation == "delete": + asyncio.run(readonly.delete(original.locator, through_revision=1)) + else: + asyncio.run(readonly.replace((original,))) + assert not index.path.parent.exists() + + +def test_corrupt_index_read_does_not_quarantine_under_owned_writer(tmp_path): + with borrowed(tmp_path / "root") as io: + index = index_for(io.root / "index.json") + index.path.write_bytes(b"{corrupt") + index.path.chmod(0o600) + with io.bind(index.path) as target: + target.acquire_lock(exclusive=True, blocking=False, suffix=".lock") + assert asyncio.run(index.get(item(1, "first").locator)) is None + assert asyncio.run(index.query_snapshot(None)).index_state == "stale" + assert index.path.read_bytes() == b"{corrupt" + assert not list(io.root.glob("*.corrupt-*")) + # Explicit mutation, not read, still preserves the damaged cache. + asyncio.run(index.upsert(item(1, "first"))) + assert len(list(io.root.glob("*.corrupt-*"))) == 1 + + +def test_independent_process_respects_rooted_transaction_and_preserves_both_items(tmp_path): + with borrowed(tmp_path / "root") as io: + index = index_for(io.root / "index.json") + asyncio.run(index.upsert(item(1, "first"))) + child = None + try: + with io.bind(index.path) as target: + assert index.upsert_rooted(item(2, "latest"), target) + child = subprocess.Popen( + [sys.executable, str(Path(__file__).with_name("_index_child.py")), str(index.path)], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + assert select.select([child.stdout], [], [], 15)[0], "child did not report lock contention" + assert child.stdout.readline() == b"busy\n" + assert asyncio.run(index.get(item(2, "latest").locator)) == item(2, "latest") + output, errors = child.communicate(b"continue\n", timeout=15) + assert child.returncode == 0, errors.decode(errors="replace") + assert output == b"published\n" + assert not errors + rows = asyncio.run(index.query(None)) + assert {row.locator.key.conversation_id: row.projection for row in rows} == { + "one": "latest", "two": "second", + } + finally: + if child is not None: + if child.poll() is None: + child.kill() + child.communicate(timeout=5) + + +def test_publication_receipt_invalidates_only_its_exact_version(tmp_path): + index = index_for(tmp_path / "index.json") + rows, receipt = asyncio.run(index.replace_with_receipt((item(1, "first"),))) + assert rows == (item(1, "first"),) + assert asyncio.run(index.invalidate_if_current(receipt)) + assert not index.path.exists() + assert not asyncio.run(index.invalidate_if_current(receipt)) + + +@pytest.mark.parametrize("successor", ["upsert", "replace", "rooted", "physical_copy"]) +def test_publication_receipt_cannot_delete_a_successor(tmp_path, successor): + with borrowed(tmp_path / "root") as io: + index = index_for(io.root / "index.json") + _, receipt = asyncio.run(index.replace_with_receipt((item(1, "first"),))) + if successor == "upsert": + asyncio.run(index.upsert(item(2, "newer"))) + elif successor == "replace": + asyncio.run(index.replace((item(2, "newer"),))) + elif successor == "rooted": + with io.bind(index.path) as target: + assert index.upsert_rooted(item(2, "newer"), target) + else: + original = index.path.with_name("retained-old-index") + index.path.rename(original) + index.path.write_bytes(original.read_bytes()) + index.path.chmod(0o600) + before = index.path.read_bytes() + assert not asyncio.run(index.invalidate_if_current(receipt)) + assert index.path.read_bytes() == before + + +def test_publication_invalidation_reports_busy_without_touching_cache(tmp_path): + from loushang.harness.journal.jsonl import JournalLockUnavailable + + with borrowed(tmp_path / "root") as io: + index = index_for(io.root / "index.json") + _, receipt = asyncio.run(index.replace_with_receipt((item(1, "first"),))) + before = index.path.read_bytes() + with io.bind(index.path) as target: + target.acquire_lock(exclusive=True, blocking=False, suffix=".lock") + with pytest.raises(JournalLockUnavailable): + asyncio.run(index.invalidate_if_current(receipt)) + assert index.path.read_bytes() == before + + +def test_invalidation_rechecks_same_open_file_after_in_place_version_change(tmp_path, monkeypatch): + index = index_for(tmp_path / "index.json") + _, receipt = asyncio.run(index.replace_with_receipt((item(1, "first"),))) + original = index._decode_state + successor = json.loads(index.path.read_bytes()) + successor["index_generation"] = "independent-successor" + encoded = json.dumps(successor).encode() + + def decode_then_change(content, **kwargs): + state = original(content, **kwargs) + # Deliberately bypass the cooperative lock to exercise the final + # descriptor/status check, preserving this exact inode. + index.path.write_bytes(encoded) + assert (index.path.stat().st_dev, index.path.stat().st_ino) == receipt.identity + return state + + monkeypatch.setattr(index, "_decode_state", decode_then_change) + assert not asyncio.run(index.invalidate_if_current(receipt)) + assert index.path.read_bytes() == encoded + + +def test_old_receipt_cannot_delete_original_inode_moved_into_replacement_parent(tmp_path): + root = tmp_path / "root" + root.mkdir(mode=0o700) + index = index_for(root / "index.json") + _, receipt = asyncio.run(index.replace_with_receipt((item(1, "first"),))) + moved = tmp_path / "retained-root" + root.rename(moved) + root.mkdir(mode=0o700) + (moved / "index.json").rename(index.path) + (root / "index.json.lock").touch(mode=0o600) + before = index.path.read_bytes() + assert not asyncio.run(index.invalidate_if_current(receipt)) + assert index.path.read_bytes() == before + + +def test_publication_keeps_original_parent_if_path_changes_after_read(tmp_path, monkeypatch): + root = tmp_path / "root" + root.mkdir(mode=0o700) + index = index_for(root / "index.json") + asyncio.run(index.upsert(item(1, "first"))) + original = index._read_state + moved = tmp_path / "retained-root" + + def read_then_replace_parent(**kwargs): + state = original(**kwargs) + root.rename(moved) + root.mkdir(mode=0o700) + index.path.write_bytes(b"replacement remains untouched") + index.path.chmod(0o600) + return state + + monkeypatch.setattr(index, "_read_state", read_then_replace_parent) + _, receipt = asyncio.run(index.replace_with_receipt((item(2, "latest"),))) + assert receipt.parent_identity == (moved.stat().st_dev, moved.stat().st_ino) + assert index.path.read_bytes() == b"replacement remains untouched" + assert {path.name for path in root.iterdir()} == {"index.json"} + assert asyncio.run(index_for(moved / "index.json").get(item(2, "latest").locator)) == item(2, "latest") From b9d79399f01a232b7d633af7954c0b2353dcd1a2 Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 05:25:38 +0000 Subject: [PATCH 18/44] feat(harness): own transcript writer lifecycles Compose retained transcript and blob writers with rooted conversation storage, explicit store-family admission, Product runtime binding and one owner for construction, delivery, import, fork, delete and cleanup. Linux maintenance deletion now requires the caller's retained authority instead of constructing an implicit writer. Carry the authority through catalogs, model-input indexes, images, bundles and session factories. The complete transcript suite covers read-only discovery, process exclusion, replacement races, rollback, cancellation, unknown outcomes and cleanup retry. --- src/loushang/harness/transcript/__init__.py | 2 + .../harness/transcript/_store_family.py | 411 +++++++++++ .../harness/transcript/export/bundle.py | 20 +- src/loushang/harness/transcript/jsonl_file.py | 265 +++++-- src/loushang/harness/transcript/lifecycle.py | 192 +++++- .../harness/transcript/model_input_blobs.py | 29 +- .../transcript/model_input_v2_index_file.py | 74 +- .../harness/transcript/product_session.py | 228 +++++-- .../harness/transcript/runtime_profile.py | 61 +- .../harness/transcript/session_artifacts.py | 13 +- .../harness/transcript/session_catalog.py | 143 ++-- .../harness/transcript/session_factory.py | 459 ++++++++++++- .../harness/transcript/session_images.py | 12 + .../harness/transcript/unit_of_work.py | 107 +-- .../harness/transcript/writer_lifecycle.py | 646 ++++++++++++++++++ tests/harness/transcript/_maintenance.py | 19 + tests/harness/transcript/_writer_child.py | 183 +++++ tests/harness/transcript/test_catalog.py | 179 ++++- .../test_delete_maintenance_owner.py | 73 ++ tests/harness/transcript/test_directory.py | 21 +- tests/harness/transcript/test_lifecycle.py | 8 +- .../transcript/test_owned_bundle_import.py | 423 ++++++++++++ tests/harness/transcript/test_owned_delete.py | 282 ++++++++ .../transcript/test_owned_delete_identity.py | 96 +++ tests/harness/transcript/test_owned_fork.py | 351 ++++++++++ .../transcript/test_owned_import_rollback.py | 380 +++++++++++ .../transcript/test_owned_import_source.py | 104 +++ .../test_owned_index_publication.py | 167 +++++ .../transcript/test_owned_product_delivery.py | 212 ++++++ .../transcript/test_owned_session_factory.py | 377 ++++++++++ .../harness/transcript/test_readonly_store.py | 356 ++++++++++ .../transcript/test_shared_store_admission.py | 615 +++++++++++++++++ tests/harness/transcript/test_writer_blobs.py | 344 ++++++++++ .../transcript/test_writer_hydration.py | 304 +++++++++ .../harness/transcript/test_writer_images.py | 360 ++++++++++ tests/harness/transcript/test_writer_io.py | 293 ++++++++ .../transcript/test_writer_lifecycle.py | 422 ++++++++++++ .../transcript/test_writer_owned_admission.py | 378 ++++++++++ .../transcript/test_writer_restore_blobs.py | 168 +++++ .../transcript/test_writer_root_binding.py | 402 +++++++++++ .../harness/transcript/test_writer_runtime.py | 271 ++++++++ 41 files changed, 9207 insertions(+), 243 deletions(-) create mode 100644 src/loushang/harness/transcript/_store_family.py create mode 100644 src/loushang/harness/transcript/writer_lifecycle.py create mode 100644 tests/harness/transcript/_maintenance.py create mode 100644 tests/harness/transcript/_writer_child.py create mode 100644 tests/harness/transcript/test_delete_maintenance_owner.py create mode 100644 tests/harness/transcript/test_owned_bundle_import.py create mode 100644 tests/harness/transcript/test_owned_delete.py create mode 100644 tests/harness/transcript/test_owned_delete_identity.py create mode 100644 tests/harness/transcript/test_owned_fork.py create mode 100644 tests/harness/transcript/test_owned_import_rollback.py create mode 100644 tests/harness/transcript/test_owned_import_source.py create mode 100644 tests/harness/transcript/test_owned_index_publication.py create mode 100644 tests/harness/transcript/test_owned_product_delivery.py create mode 100644 tests/harness/transcript/test_owned_session_factory.py create mode 100644 tests/harness/transcript/test_readonly_store.py create mode 100644 tests/harness/transcript/test_shared_store_admission.py create mode 100644 tests/harness/transcript/test_writer_blobs.py create mode 100644 tests/harness/transcript/test_writer_hydration.py create mode 100644 tests/harness/transcript/test_writer_images.py create mode 100644 tests/harness/transcript/test_writer_io.py create mode 100644 tests/harness/transcript/test_writer_lifecycle.py create mode 100644 tests/harness/transcript/test_writer_owned_admission.py create mode 100644 tests/harness/transcript/test_writer_restore_blobs.py create mode 100644 tests/harness/transcript/test_writer_root_binding.py create mode 100644 tests/harness/transcript/test_writer_runtime.py diff --git a/src/loushang/harness/transcript/__init__.py b/src/loushang/harness/transcript/__init__.py index 9cb2ccb2e..8bd6a980f 100644 --- a/src/loushang/harness/transcript/__init__.py +++ b/src/loushang/harness/transcript/__init__.py @@ -138,6 +138,7 @@ AgentTranscriptLifecycleContext, AgentTranscriptLifecycleSession, AgentTranscriptRuntimeBinding, + TranscriptDeletionOwner, delete_agent_transcript_jsonl, ) from loushang.harness.transcript.maintenance import ( @@ -512,6 +513,7 @@ "create_agent_transcript_repository": "loushang.harness.transcript.jsonl_file", "decode_agent_transcript_bytes": "loushang.harness.transcript.jsonl_file", "delete_agent_transcript_jsonl": "loushang.harness.transcript.lifecycle", + "TranscriptDeletionOwner": "loushang.harness.transcript.lifecycle", "build_context_usage_snapshot": "loushang.harness.transcript.context_usage", "build_threshold_compaction_decision": "loushang.harness.transcript.maintenance", "calculate_context_tokens": "loushang.harness.transcript.context_usage", diff --git a/src/loushang/harness/transcript/_store_family.py b/src/loushang/harness/transcript/_store_family.py new file mode 100644 index 000000000..8cfdb760b --- /dev/null +++ b/src/loushang/harness/transcript/_store_family.py @@ -0,0 +1,411 @@ +"""Private family membership facts retained by TranscriptStoreAdmission. + +No Session database, migration authority, background work, or independent close +owner. Every descriptor below belongs to the calling admission's lifecycle. +""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +from secrets import token_hex +from typing import TYPE_CHECKING, Any + +from loushang.harness.journal._rooted_io import RootedFileIO + +from .store_admission import ( + TranscriptStoreBinding, + _identity, + _missing, + _StoreRoot, + _unique, + _WitnessLease, +) +from .writer_lease import TranscriptWriterError + +if TYPE_CHECKING: + from .store_admission import TranscriptStoreAdmission + +_VERSION = "transcript-store-family/v1" +_MEMBER = "transcript-store-admission/v2" +_MAX_MEMBERS = 128 +_MAX_STATE = 512 +_MAX_BYTES = 131072 + + +class _SharedFamily: + def __init__(self, admission: TranscriptStoreAdmission, state_root: Path) -> None: + self.admission = admission + self.data_root = admission.root.parent + self.key = hashlib.sha256(f"{_VERSION}\0{os.geteuid()}\0{self.data_root}".encode()).hexdigest() + self.root = state_root / f"family-{self.key}" + self._existing = _WitnessLease(self.root, _VERSION, self.key, create_lock=False) + self._fresh = _WitnessLease(self.root, _VERSION, self.key, create_root=True, exclusive_root=True) + self._data_existing = _StoreRoot(self.data_root, _VERSION, self.key) + self._data_fresh = _StoreRoot(self.data_root, _VERSION, self.key, create_root=True, exclusive_root=True) + self._state = _StoreRoot(state_root, _VERSION, self.key) + self._assets = _StoreRoot(self.data_root / "session-assets", _VERSION, self.key) + self._locks = _StoreRoot(self.data_root / "session-assets/.locks", _VERSION, self.key) + self._writers = _StoreRoot(self.data_root / ".session-blob-writers", _VERSION, self.key) + self._data: _StoreRoot | None = None + self._lease: _WitnessLease | None = None + self._io: RootedFileIO | None = None + self._record: dict[str, Any] | None = None + self._probes: list[tuple[RootedFileIO, _StoreRoot]] = [] + + @property + def present(self) -> bool: + return not _missing(self.root) + + @property + def _owners(self): + return (self._assets, self._locks, self._writers, self._data_existing, self._data_fresh, + self._state, self._existing, self._fresh) + + @property + def cleanup_pending(self) -> bool: + return (any(item.cleanup_pending for item in self._owners) or bool(self._io and self._io.cleanup_pending) + or any(port.cleanup_pending for port, _ in self._probes)) + + def _freshness(self) -> None: + """No unbounded inference or adoption of an unrecognized old family.""" + if not _missing(self.data_root): + self._data = self._data_existing + self._data.acquire() + port = RootedFileIO(self.data_root, self._data._fds["root"]) + self._probes.append((port, self._data)) + try: + names, complete = port.scan_names(limit=_MAX_MEMBERS + 3) + if names or not complete: + raise TranscriptWriterError("conflict") + finally: + port.cleanup() + self.inspect_state_evidence(allow_legacy=False) + + def inspect_state_evidence(self, *, allow_legacy: bool) -> None: + """Never discard a surviving v2 member merely because its family vanished.""" + if not _missing(self._state._root): + self._state.acquire() + port = RootedFileIO(self._state._root, self._state._fds["root"]) + self._probes.append((port, self._state)) + try: + names, complete = port.scan_names(limit=_MAX_STATE) + if not complete: + raise TranscriptWriterError("unavailable") + families, members = {}, [] + for name in names: + # v1 protects its exact member key, not an inferred global + # mapping of every future path to a vanished parent. + raw = json.loads(port.read_bytes(self._state._root / name / "admission.json", max_bytes=_MAX_BYTES), + object_pairs_hook=_unique) + if name.startswith("family-"): + self._validate_record(raw) + if name != "family-" + raw["family"]: + raise TranscriptWriterError("conflict") + if raw["incarnation"] in families: + raise TranscriptWriterError("conflict") + families[raw["incarnation"]] = {key: value["id"] for key, value in raw["members"].items()} + if raw["data_path"] == str(self.data_root): + raise TranscriptWriterError("conflict") + elif type(raw) is dict and raw.get("version") == _MEMBER: + members.append((name, raw)) + elif type(raw) is dict and raw.get("version") == "transcript-store-admission/v1": + self._validate_legacy_evidence(port, name, raw) + if name == self.admission._key: + raise TranscriptWriterError("conflict") + if (not allow_legacy and self._data is not None + and raw["parent"] == list(self._data.binding().root_identity)): + raise TranscriptWriterError("conflict") + # This neither enrolls the old store nor grants access + # to its data. All v2 evidence still must pair below. + else: + raise TranscriptWriterError("conflict") + for name, record in members: + family = families.get(record.get("family")) + member = family.get(name) if family is not None else None + if member is None or member != record.get("member") or record.get("store") != name: + raise TranscriptWriterError("conflict") + finally: + port.cleanup() + + @staticmethod + def _validate_legacy_evidence(port: RootedFileIO, name: str, record: dict[str, Any]) -> None: + version = "transcript-store-admission/v1" + if (set(record) != {"version", "store", "operation", "phase", "witness", "lock", "root", "parent"} + or record["version"] != version or not _hex(name, 64) or record["store"] != name + or not _hex(record["operation"], 32) or record["phase"] != "initialized"): + raise TranscriptWriterError("conflict") + for field in ("witness", "lock", "root", "parent"): + _require_identity(record[field]) + lock_name = hashlib.sha256(json.dumps([version, name], ensure_ascii=True).encode()).hexdigest() + ".lock" + with port.directory() as state: + witness = state.child(name) + status = witness.stat() + lock = witness.child(".store-admission-locks").file(lock_name).stat() + reread = json.loads(witness.file("admission.json").read_bytes(max_bytes=4096), object_pairs_hook=_unique) + if (reread != record or record["witness"] != [status.st_dev, status.st_ino] + or record["lock"] != [lock.st_dev, lock.st_ino]): + raise TranscriptWriterError("conflict") + + def _read(self) -> dict[str, Any]: + assert self._io is not None and self._lease is not None + self._lease.check_binding(owner_id=_VERSION, authority_id=self.key) + record = json.loads(self._io.read_bytes(self.root / "admission.json", max_bytes=_MAX_BYTES), + object_pairs_hook=_unique) + self._validate_record(record) + if (record["family"] != self.key or record["data_path"] != str(self.data_root) + or record["witness"] != list(_identity(self._lease._fds["root"])) + or record["lock"] != list(_identity(self._lease._fds["lock"]))): + raise TranscriptWriterError("conflict") + return record + + @staticmethod + def _validate_record(record: Any) -> None: + if (type(record) is not dict or set(record) != {"version", "family", "incarnation", "phase", "revision", + "data_path", "witness", "lock", "data", "shared", "members"} + or record["version"] != _VERSION or not _hex(record["family"], 64) + or not _hex(record["incarnation"], 32) or type(record["phase"]) is not str + or record["phase"] not in {"initializing", "initialized"} + or type(record["revision"]) is not int or not 0 <= record["revision"] <= 2**63 - 1 + or type(record["data_path"]) is not str or len(record["data_path"]) > 4096 + or not Path(record["data_path"]).is_absolute() + or ".." in Path(record["data_path"]).parts or str(Path(record["data_path"])) != record["data_path"] + or type(record["members"]) is not dict or len(record["members"]) > _MAX_MEMBERS): + raise TranscriptWriterError("conflict") + expected_key = hashlib.sha256(f"{_VERSION}\0{os.geteuid()}\0{record['data_path']}".encode()).hexdigest() + if expected_key != record["family"]: + raise TranscriptWriterError("conflict") + for name in ("witness", "lock"): + _require_identity(record[name]) + if record["phase"] == "initializing": + if record["data"] is not None or record["shared"] is not None or record["members"]: + raise TranscriptWriterError("conflict") + return + _require_identity(record["data"]) + if type(record["shared"]) is not list or len(record["shared"]) != 3: + raise TranscriptWriterError("conflict") + for identity in record["shared"]: + _require_identity(identity) + if len({tuple(value) for value in [record["data"], *record["shared"]]}) != 4: + raise TranscriptWriterError("conflict") + names, identifiers = set(), set() + roots = {tuple(value) for value in [record["data"], *record["shared"]]} + for key, member in record["members"].items(): + if (not _hex(key, 64) or type(member) is not dict + or set(member) != {"name", "id", "phase", "root", "witness", "lock"} + or not _hex(member["id"], 32) or type(member["phase"]) is not str + or member["phase"] not in {"initializing", "initialized"} + or type(member["name"]) is not str or not member["name"] or len(member["name"].encode()) > 255 + or member["name"] in {".", "..", "session-assets", ".session-blob-writers"} + or any(c in member["name"] for c in ("/", "\\", "\0")) + or member["name"] in names or member["id"] in identifiers): + raise TranscriptWriterError("conflict") + member_key = hashlib.sha256( + f"transcript-store-admission/v1\0{os.geteuid()}\0{Path(record['data_path']) / member['name']}".encode(), + ).hexdigest() + if key != member_key: + raise TranscriptWriterError("conflict") + names.add(member["name"]) + identifiers.add(member["id"]) + for name in ("root", "witness", "lock"): + if member["phase"] == "initializing": + if member[name] is not None: + raise TranscriptWriterError("conflict") + else: + _require_identity(member[name]) + if member["phase"] == "initialized": + identity = tuple(member["root"]) + if identity in roots: + raise TranscriptWriterError("conflict") + roots.add(identity) + + def _write(self) -> None: + assert self._io is not None and self._record is not None and self._lease is not None + self._validate_record(self._record) + payload = json.dumps(self._record, separators=(",", ":")).encode() + if len(payload) > _MAX_BYTES: + raise TranscriptWriterError("capacity") + self._lease.check_binding(owner_id=_VERSION, authority_id=self.key) + self._io.atomic_write(self.root / "admission.json", payload) + if self._read() != self._record: + raise TranscriptWriterError("conflict") + + def open(self, *, inspect_only: bool) -> TranscriptStoreBinding | None: + admission = self.admission + existing = self.present + if not existing: + if inspect_only or not admission._may_create(): + raise TranscriptWriterError("unavailable") + self._freshness() + elif _missing(self.root / "admission.json"): + raise TranscriptWriterError("incomplete") + self._lease = self._existing if existing else self._fresh + self._lease.acquire() + if self._lease._canonical != self.root: + raise TranscriptWriterError("conflict") + self._io = RootedFileIO(self.root, self._lease._fds["root"]) + if existing: + self._record = self._read() + if self._record["phase"] != "initialized": + raise TranscriptWriterError("incomplete") + self._data = self._data_existing + self._data.acquire() + else: + self._record = dict(version=_VERSION, family=self.key, incarnation=token_hex(16), phase="initializing", + revision=0, data_path=str(self.data_root), witness=list(_identity(self._lease._fds["root"])), + lock=list(_identity(self._lease._fds["lock"])), data=None, shared=None, members={}) + self._write() + if self._data is None: + self._data = self._data_fresh + self._data.acquire() + assert self._data is not None + self._assets.acquire_child(self._data, create=not existing) + self._locks.acquire_child(self._assets, create=not existing) + self._writers.acquire_child(self._data, create=not existing) + for item in (self._assets, self._locks, self._writers): + item._validate_directory(item._fds["root"], private=True) + data_id = self._data.binding().root_identity + shared = (self._assets.binding().root_identity, self._locks.binding().root_identity, + self._writers.binding().root_identity) + if existing: + if self._record["data"] != list(data_id) or self._record["shared"] != [list(value) for value in shared]: + raise TranscriptWriterError("conflict") + else: + self._record.update(phase="initialized", data=list(data_id), shared=[list(value) for value in shared]) + self._write() + member = self._record["members"].get(admission._key) + if member is None: + if not _missing(admission.root) or not _missing(admission.witness_root): + raise TranscriptWriterError("conflict") + if inspect_only: + return None + if not admission._may_create(): + raise TranscriptWriterError("unavailable") + if len(self._record["members"]) >= _MAX_MEMBERS or self._record["revision"] > 2**63 - 3: + raise TranscriptWriterError("capacity") + member = dict(name=admission.root.name, id=token_hex(16), phase="initializing", root=None, witness=None, lock=None) + self._record["members"][admission._key] = member + self._record["revision"] += 1 + self._write() + admission._witness = admission._fresh + admission._witness.acquire() + if admission._witness._canonical != admission.witness_root: + raise TranscriptWriterError("conflict") + admission._io = RootedFileIO(admission.witness_root, admission._witness._fds["root"]) + record = admission._record() + record.update(version=_MEMBER, family=self._record["incarnation"], member=member["id"]) + admission._write(record) + if not admission._may_create(): + raise TranscriptWriterError("unavailable") + admission._open_store(create=True) + assert admission._store is not None + physical = admission._store.binding() + record.update(phase="initialized", root=list(physical.root_identity), parent=list(data_id)) + admission._write(record) + member.update(phase="initialized", root=record["root"], witness=record["witness"], lock=record["lock"]) + self._record["revision"] += 1 + self._write() + else: + if member["phase"] != "initialized" or member["name"] != admission.root.name: + raise TranscriptWriterError("incomplete") + if _missing(admission.witness_root / "admission.json"): + raise TranscriptWriterError("incomplete") + admission._witness = admission._existing + admission._witness.acquire() + if admission._witness._canonical != admission.witness_root: + raise TranscriptWriterError("conflict") + admission._io = RootedFileIO(admission.witness_root, admission._witness._fds["root"]) + record = admission._read() + admission._open_store(create=False) + assert admission._store is not None + physical = admission._store.binding() + if (record["phase"] != "initialized" or member["root"] != list(physical.root_identity) + or record["root"] != member["root"] or record["parent"] != list(data_id) + or record["witness"] != member["witness"] or record["lock"] != member["lock"]): + raise TranscriptWriterError("conflict") + return TranscriptStoreBinding(physical.root_identity, data_id, self._record["incarnation"], member["id"], shared) + + def validate_member(self, record: dict[str, Any]) -> dict[str, Any]: + admission = self.admission + if self._record is None or admission._witness is None: + raise TranscriptWriterError("conflict") + member = self._record["members"].get(admission._key) + if (set(record) != {"version", "store", "operation", "phase", "witness", "lock", "root", "parent", "family", "member"} + or record["version"] != _MEMBER or record["store"] != admission._key or not _hex(record["operation"], 32) + or member is None or record["family"] != self._record["incarnation"] or record["member"] != member["id"] + or type(record["phase"]) is not str or record["phase"] not in {"initializing", "initialized"} + or record["witness"] != list(_identity(admission._witness._fds["root"])) + or record["lock"] != list(_identity(admission._witness._fds["lock"]))): + raise TranscriptWriterError("conflict") + for name in ("root", "parent"): + if record["phase"] == "initializing": + if record[name] is not None: + raise TranscriptWriterError("conflict") + else: + _require_identity(record[name]) + return record + + def check(self) -> None: + assert self._data is not None and self.admission._store is not None + binding = self.admission._binding + record = self._read() + if self._record is None or record["incarnation"] != self._record["incarnation"]: + raise TranscriptWriterError("conflict") + self._record = record + for item in (self._assets, self._locks, self._writers): + item._validate_directory(item._fds["root"], private=True) + own = self.admission._read() + member = record["members"].get(self.admission._key) + if (binding is None or member is None or member["phase"] != "initialized" + or member["id"] != binding.member_id or member["root"] != list(binding.root_identity) + or own["phase"] != "initialized" or own["root"] != member["root"] + or own["witness"] != member["witness"] or own["lock"] != member["lock"] + or self.admission._store.binding().root_identity != binding.root_identity + or self._data.binding().root_identity != binding.parent_identity + or record["data"] != list(binding.parent_identity) + or record["shared"] != [list(value) for value in binding.shared_identities or ()] + or tuple(item.binding().root_identity for item in (self._assets, self._locks, self._writers)) != binding.shared_identities): + raise TranscriptWriterError("conflict") + + def release_locks(self) -> None: + if self._io is not None: + self._io.cleanup() + self._existing.close() + self._fresh.close() + self._state.close() # Freshness scanning is complete; this is not a handoff pin. + + def close(self) -> None: + failures = [] + for port, _ in self._probes: + try: + port.cleanup() + except Exception as error: + failures.append(error) + if self._io is not None: + try: + self._io.cleanup() + except Exception as error: + failures.append(error) + for owner in self._owners: + if any(parent is owner and port.cleanup_pending for port, parent in self._probes): + continue + if owner in (self._existing, self._fresh) and self._io is not None and self._io.cleanup_pending: + continue + try: + owner.close() + except Exception as error: + failures.append(error) + if failures: + raise failures[0] + + +def _hex(value: Any, length: int) -> bool: + return type(value) is str and len(value) == length and all(c in "0123456789abcdef" for c in value) + + +def _require_identity(value: Any) -> None: + if (type(value) is not list or len(value) != 2 + or any(type(item) is not int or not 0 <= item < 2**64 for item in value) or value[1] == 0): + raise TranscriptWriterError("conflict") diff --git a/src/loushang/harness/transcript/export/bundle.py b/src/loushang/harness/transcript/export/bundle.py index 7f433b555..abe4a8e31 100644 --- a/src/loushang/harness/transcript/export/bundle.py +++ b/src/loushang/harness/transcript/export/bundle.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import io import json import os import re @@ -194,8 +195,25 @@ def read_agent_transcript_bundle( ) if metadata.st_size > max_archive_bytes: raise ArtifactStoreQuotaExceeded("bundle archive exceeds its size limit") + return _read_bundle_archive(target, policy=policy) + + +def decode_agent_transcript_bundle( + content: bytes, + *, + policy: SessionBlobPolicy = DEFAULT_TRANSCRIPT_BUNDLE_POLICY, +) -> AgentTranscriptBundle: + """Decode an already frozen source without reopening its pathname.""" + if len(content) > policy.max_total_bytes + 16 * 1024 * 1024 + policy.max_blobs * 1024: + raise ArtifactStoreQuotaExceeded("bundle archive exceeds its size limit") + return _read_bundle_archive(io.BytesIO(content), policy=policy) + + +def _read_bundle_archive( + source: Path | io.BytesIO, *, policy: SessionBlobPolicy, +) -> AgentTranscriptBundle: try: - with zipfile.ZipFile(target, "r") as archive: + with zipfile.ZipFile(source, "r") as archive: members = archive.infolist() if len(members) > policy.max_blobs + 2: raise ArtifactStoreQuotaExceeded("bundle member count exceeds its limit") diff --git a/src/loushang/harness/transcript/jsonl_file.py b/src/loushang/harness/transcript/jsonl_file.py index 5578c9d4b..c9a86d64e 100644 --- a/src/loushang/harness/transcript/jsonl_file.py +++ b/src/loushang/harness/transcript/jsonl_file.py @@ -12,11 +12,12 @@ import os import stat as stat_module from collections.abc import Callable, Iterator -from contextlib import contextmanager -from dataclasses import dataclass, field +from contextlib import AbstractContextManager, contextmanager, nullcontext +from dataclasses import dataclass, field, replace from datetime import UTC, datetime +from functools import partial from pathlib import Path -from typing import Any, NoReturn, cast +from typing import TYPE_CHECKING, Any, NoReturn, TypeVar, cast from loushang.foundation.json import validate_json_value from loushang.harness.conversation import ( @@ -26,7 +27,10 @@ ConversationKey, ConversationRepository, FileConversationStore, + IndexedProjection, ) +from loushang.harness.conversation.indexes.json_file import JsonConversationIndex +from loushang.harness.conversation.store import ConversationOperationScope from loushang.harness.journal import ( DEFAULT_JSONL_FORMAT, DURABLE_LOCKED_JOURNAL, @@ -38,6 +42,7 @@ LockMode, decode_jsonl, journal_file_lock, + load_jsonl, ) from loushang.harness.transcript.model_input_v2_index_file import ( delete_agent_transcript_index, @@ -50,6 +55,49 @@ from loushang.harness.transcript.profile import AgentTranscriptProfile from loushang.harness.transcript.types import AgentTranscriptRecord +if TYPE_CHECKING: + from loushang.harness.journal._rooted_io import RootedFileIO + +_Projection = TypeVar("_Projection") +_Query = TypeVar("_Query") + + +def publish_owned_transcript_projection( + index_path: Path, + index: JsonConversationIndex[_Projection, _Query], + project: Callable[[str], IndexedProjection[_Projection]], + *, file_io: RootedFileIO, root: Path, key: ConversationKey, + source_path: Path, header: ConversationHeader, + records: tuple[AgentTranscriptRecord, ...], max_bytes: int, + fingerprint: Callable[[os.stat_result], str], +) -> bool: + """Borrow the original writer IO for one synchronous publication. + + The source stays bound through index publication. This creates no owner, + scans no directory and never creates an absent projection cache. + """ + if root != file_io.root or key.namespace != str(file_io.root): + raise ValueError("owned summary belongs to another transcript root") + if header.conversation_id != key.conversation_id: + raise ValueError("owned summary identity differs from its writer") + if source_path.parent != file_io.root or index_path.parent != file_io.root: + raise ValueError("owned summary must select direct transcript children") + try: + file_io.stat(index_path) + except FileNotFoundError: + return False + with file_io.bind(source_path) as source: + before = fingerprint(source.stat()) + content = source.read_bytes(max_bytes=max_bytes) + disk_header, disk_records = decode_agent_transcript_bytes(content, source_path=source_path) + if disk_header != header or tuple(disk_records) != records: + return False + if fingerprint(source.stat()) != before: + return False + item = project(before) + with file_io.bind(index_path) as target: + return index.upsert_rooted(item, target) + class AgentTranscriptFileError(ValueError): """An Agent transcript JSONL file could not be read safely.""" @@ -113,6 +161,7 @@ def agent_transcript_journal( path: Path, *, repair_partial_tail: bool = False, + file_io: RootedFileIO | None = None, ) -> JsonlJournal[ConversationHeader, AgentTranscriptRecord]: """Open one Conversation JSONL transcript journal.""" @@ -125,7 +174,8 @@ def agent_transcript_journal( load_policy=( _WRITABLE_LOAD_POLICY if repair_partial_tail else _READ_LOAD_POLICY ), - lock_factory=agent_transcript_file_lock, + lock_factory=agent_transcript_file_lock if file_io is None else None, + file_io=file_io, ) @@ -178,12 +228,15 @@ def load_agent_transcript_file( path: Path, *, max_bytes: int | None = None, + read_only: bool = False, ) -> tuple[ConversationHeader, list[AgentTranscriptRecord]]: target = Path(path) + if type(read_only) is not bool: + raise TypeError("read_only must be a built-in bool") if max_bytes is not None and (type(max_bytes) is not int or max_bytes < 1): raise ValueError("transcript read limit must be positive") try: - with agent_transcript_file_lock(target, "shared"): + with nullcontext() if read_only else agent_transcript_file_lock(target, "shared"): content = _read_stable_regular_file(target, max_bytes=max_bytes) except OSError as exc: raise AgentTranscriptFileError( @@ -231,23 +284,29 @@ def decode_agent_transcript_bytes( def load_agent_transcript_header( path: Path, *, blocking: bool = True, create_lock: bool = True, + read_only: bool = False, + file_io: RootedFileIO | None = None, ) -> ConversationHeader: - """Read only the Conversation JSONL header without scanning the transcript.""" + """Read a header; read_only uses a stable snapshot without creating locks.""" + if type(read_only) is not bool: + raise TypeError("read_only must be a built-in bool") + if file_io is not None and not read_only: + raise ValueError("managed header reads require the read-only projection") target = Path(path) try: - lock = ( - agent_transcript_file_lock(target, "shared") - if blocking is True and create_lock is True - else agent_transcript_file_lock( + lock: AbstractContextManager[None] + if read_only: + lock = nullcontext() + elif blocking is True and create_lock is True: + lock = agent_transcript_file_lock(target, "shared") + else: + lock = agent_transcript_file_lock( target, "shared", blocking=blocking, create_lock=create_lock, ) - ) with lock: - prefix = _read_stable_regular_prefix( - target, - max_bytes=_MAX_HEADER_BYTES, - ) + prefix = (file_io.read_prefix(target, _MAX_HEADER_BYTES) if file_io is not None + else _read_stable_regular_prefix(target, max_bytes=_MAX_HEADER_BYTES)) line_bytes = next( (line for line in prefix.splitlines() if line.strip()), b"", @@ -318,9 +377,13 @@ class AgentTranscriptFileLayout: root: Path filename_for_key: FilenameForKey | None = None _known_paths: dict[ConversationKey, Path] = field(default_factory=dict) + file_io: RootedFileIO | None = None def __post_init__(self) -> None: - self.root = _absolute_path_preserving_leaf(self.root) + if self.file_io is None: + self.root = _absolute_path_preserving_leaf(self.root) + elif self.root != self.file_io.root: + raise ValueError("managed transcript layout must use its borrowed root") @property def namespace(self) -> str: @@ -334,11 +397,29 @@ def key(self, conversation_id: str) -> ConversationKey: def bind_path(self, key: ConversationKey, path: str | Path) -> None: self._require_namespace(key) - self._known_paths[key] = _absolute_path_preserving_leaf(path) + self._known_paths[key] = self._layout_path(path) + + def _layout_path(self, path: str | Path) -> Path: + if self.file_io is None: + return _absolute_path_preserving_leaf(path) + result = Path(path) + if result.parent != self.root or result.name in {".", ".."}: + raise ValueError("managed transcript path must be a direct child") + return result + + def _is_file(self, path: Path) -> bool: + if self.file_io is None: + return _is_regular_file_no_follow(path) + try: + self.file_io.stat(path) + except OSError: + return False + return True def create_path(self, key: ConversationKey) -> Path: self._require_namespace(key) - self.root.mkdir(parents=True, exist_ok=True) + if self.file_io is None: + self.root.mkdir(parents=True, exist_ok=True) known = self._known_paths.get(key) if known is not None: return known @@ -347,14 +428,14 @@ def create_path(self, key: ConversationKey) -> Path: if self.filename_for_key is not None else _default_filename(key) ) - path = self.root / filename + path = self._layout_path(self.root / filename) self._known_paths[key] = path return path def resolve_path(self, key: ConversationKey) -> Path | None: self._require_namespace(key) known = self._known_paths.get(key) - if known is not None and _is_regular_file_no_follow(known): + if known is not None and self._is_file(known): return known for path in self.scan_paths(key.namespace): try: @@ -369,7 +450,7 @@ def scan_paths(self, namespace: str) -> tuple[Path, ...]: return tuple( path for path in self.scan_candidate_paths(namespace) - if _is_conversation_jsonl_candidate(path) + if _is_conversation_jsonl_candidate(path, file_io=self.file_io) ) def scan_candidate_paths(self, namespace: str) -> tuple[Path, ...]: @@ -389,7 +470,7 @@ def scan_candidate_path_snapshot( if should_stop is not None and should_stop(): return AgentTranscriptCandidateScan((), complete=False) - if namespace != self.namespace or not _is_directory_no_follow(self.root): + if namespace != self.namespace or (self.file_io is None and not _is_directory_no_follow(self.root)): return AgentTranscriptCandidateScan((), complete=True) if max_candidates is not None and ( type(max_candidates) is not int or max_candidates < 0 @@ -405,6 +486,19 @@ def scan_candidate_path_snapshot( return AgentTranscriptCandidateScan((), complete=False) candidates: list[Path] = [] complete = True + if self.file_io is not None: + names, complete = self.file_io.scan_names(limit=_MAX_DISCOVERY_DIRECTORY_ENTRIES) + for name in names: + if should_stop is not None and should_stop(): + complete = False + break + path = self.root / name + if path.suffix == ".jsonl" and not path.name.endswith("-export.jsonl") and self._is_file(path): + if len(candidates) == candidate_limit: + complete = False + break + candidates.append(path) + return AgentTranscriptCandidateScan(tuple(sorted(candidates)), complete) try: with os.scandir(self.root) as entries: for inspected, entry in enumerate(entries, start=1): @@ -443,7 +537,7 @@ def transcript_paths_modified_after( scan = self.scan_candidate_path_snapshot(self.namespace) for path in scan.paths: try: - status = path.lstat() + status = path.lstat() if self.file_io is None else self.file_io.stat(path) if ( _status_is_regular_no_follow(status) and ( @@ -459,12 +553,12 @@ def transcript_paths_modified_after( def key_for_path(self, namespace: str, path: Path) -> ConversationKey: if namespace != self.namespace: raise ValueError("conversation key does not belong to this layout") - key = self.key(load_agent_transcript_header(path).conversation_id) + key = self.key(load_agent_transcript_header(path, read_only=True, file_io=self.file_io).conversation_id) self.bind_path(key, path) return key def bind_existing_path(self, path: str | Path) -> ConversationKey: - resolved = _absolute_path_preserving_leaf(path) + resolved = self._layout_path(path) return self.key_for_path(self.namespace, resolved) def bind_create_path(self, key: ConversationKey, path: str | Path) -> None: @@ -486,37 +580,95 @@ def _require_namespace(self, key: ConversationKey) -> None: def create_agent_transcript_file_store( layout: AgentTranscriptFileLayout, + *, + read_only: bool = False, + operation_scope: ConversationOperationScope | None = None, ) -> FileConversationStore[ConversationHeader, AgentTranscriptRecord]: """Build the Conversation JSONL provider for an Agent transcript profile.""" + snapshot_loader: Callable[[Path], JsonlSnapshot[ConversationHeader, AgentTranscriptRecord]] + snapshot_loader = partial(_load_agent_transcript_store_snapshot, file_io=layout.file_io) + if read_only: + snapshot_loader = partial(_load_agent_transcript_readonly_snapshot, file_io=layout.file_io) + elif operation_scope is not None: + snapshot_loader = partial(_load_agent_transcript_store_snapshot, retain_raw=True, file_io=layout.file_io) + return FileConversationStore( create_path=layout.create_path, resolve_path=layout.resolve_path, scan_paths=layout.scan_paths, key_for_path=layout.key_for_path, - journal_factory=agent_transcript_journal, + journal_factory=partial(agent_transcript_journal, file_io=layout.file_io), write_journal_factory=lambda path: agent_transcript_journal( path, repair_partial_tail=True, + file_io=layout.file_io, ), record_id=lambda record: record.record_id, tombstone_path=layout.tombstone_path, head_compatibility_token=_STORE_HEAD_COMPATIBILITY_TOKEN, - snapshot_loader=_load_agent_transcript_store_snapshot, - delete_artifacts=delete_agent_transcript_index, + snapshot_loader=snapshot_loader, + scan_snapshot_loader=partial(_load_agent_transcript_readonly_snapshot, file_io=layout.file_io), + delete_artifacts=partial(delete_agent_transcript_index, file_io=layout.file_io), + read_only=read_only, + operation_scope=operation_scope, + file_io=layout.file_io, ) def _load_agent_transcript_store_snapshot( path: Path, + *, + retain_raw: bool = False, + file_io: RootedFileIO | None = None, ) -> JsonlSnapshot[ConversationHeader, AgentTranscriptRecord]: + if file_io is not None: + with file_io.bind(path) as rooted: + rooted.acquire_lock(exclusive=False, suffix=DURABLE_LOCKED_JOURNAL.lock_suffix) + return load_agent_transcript_snapshot_with_index( + path, + strict_loader=lambda: load_jsonl( + path, record_codec=_RECORD_CODEC, header_codec=_HEADER_CODEC, + durability=replace(DURABLE_LOCKED_JOURNAL, locking=False), + load_policy=_READ_LOAD_POLICY, bound_file=rooted, + ), + header_codec=_HEADER_CODEC, + record_codec=cast(ConversationJsonlRecordCodec, _RECORD_CODEC), + lock_factory=lambda _path, _mode: nullcontext(), + compatibility_token=_LOAD_INDEX_COMPATIBILITY_TOKEN, + retain_raw=True, read_bytes=rooted.read_bytes, + read_cache=lambda cache: rooted.sibling(cache.name).read_bytes(), + write_cache=lambda cache, payload: rooted.sibling(cache.name).atomic_write(payload), + ) return load_agent_transcript_snapshot_with_index( path, - strict_loader=lambda: agent_transcript_journal(path).load(), + strict_loader=lambda: agent_transcript_journal(path, file_io=file_io).load(), header_codec=_HEADER_CODEC, record_codec=cast(ConversationJsonlRecordCodec, _RECORD_CODEC), lock_factory=agent_transcript_file_lock, compatibility_token=_LOAD_INDEX_COMPATIBILITY_TOKEN, + retain_raw=retain_raw, + ) + + +def _load_agent_transcript_readonly_snapshot( + path: Path, *, file_io: RootedFileIO | None = None, +) -> JsonlSnapshot[ConversationHeader, AgentTranscriptRecord]: + reader = _read_stable_regular_file if file_io is None else file_io.read_bytes + def strict() -> JsonlSnapshot[ConversationHeader, AgentTranscriptRecord]: + raw = reader(path).decode(DEFAULT_JSONL_FORMAT.encoding) + return decode_jsonl( + raw, target=path, record_codec=_RECORD_CODEC, + header_codec=_HEADER_CODEC, load_policy=_READ_LOAD_POLICY, + ) + + return load_agent_transcript_snapshot_with_index( + path, strict_loader=strict, header_codec=_HEADER_CODEC, + record_codec=cast(ConversationJsonlRecordCodec, _RECORD_CODEC), + lock_factory=lambda _path, _mode: nullcontext(), + compatibility_token=_LOAD_INDEX_COMPATIBILITY_TOKEN, + write_index=False, read_bytes=lambda: reader(path), + read_cache=reader, ) @@ -554,13 +706,14 @@ def _agent_transcript_file_error(error: JournalFileError) -> AgentTranscriptFile return AgentTranscriptFileError(message, path=error.path, code=code) -def _is_conversation_jsonl_candidate(path: Path) -> bool: +def _is_conversation_jsonl_candidate(path: Path, *, file_io: RootedFileIO | None = None) -> bool: """Exclude other JSONL families; malformed Conversation files stay visible.""" - if not _is_regular_file_no_follow(path): + if file_io is None and not _is_regular_file_no_follow(path): return False try: - prefix = _read_stable_regular_prefix(path, max_bytes=_MAX_HEADER_BYTES) + prefix = (file_io.read_prefix(path, _MAX_HEADER_BYTES) if file_io is not None + else _read_stable_regular_prefix(path, max_bytes=_MAX_HEADER_BYTES)) line = next((line for line in prefix.splitlines() if line.strip()), b"") value = json.loads(line.decode(DEFAULT_JSONL_FORMAT.encoding)) except Exception: @@ -608,8 +761,15 @@ def _read_stable_regular_file( path: Path, *, max_bytes: int | None = None, + expected_source_fingerprint: str | None = None, ) -> bytes: before = path.lstat() + fingerprint = ( + f"stat-v1:{before.st_dev}:{before.st_ino}:{before.st_size}:" + f"{before.st_mtime_ns}:{before.st_ctime_ns}" + ) + if expected_source_fingerprint is not None and fingerprint != expected_source_fingerprint: + raise OSError("transcript source identity changed before import") if not _status_is_regular_no_follow(before): raise OSError("transcript source must be a regular file") if max_bytes is not None and before.st_size > max_bytes: @@ -621,6 +781,7 @@ def _read_stable_regular_file( flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) descriptor, parent_descriptor = _open_file_no_follow(path, flags=flags) + read_error: BaseException | None = None try: opened = os.fstat(descriptor) if not _same_file_status(before, opened): @@ -634,10 +795,11 @@ def _read_stable_regular_file( chunks.append(chunk) remaining -= len(chunk) after = os.fstat(descriptor) + except BaseException as exc: + read_error = exc + raise finally: - os.close(descriptor) - if parent_descriptor >= 0: - os.close(parent_descriptor) + _close_read_descriptors(descriptor, parent_descriptor, primary=read_error) current = path.lstat() if not _same_file_status(before, after) or not _same_file_status(before, current): raise OSError("transcript source changed while reading") @@ -651,6 +813,7 @@ def _read_stable_regular_prefix(path: Path, *, max_bytes: int) -> bytes: flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) descriptor, parent_descriptor = _open_file_no_follow(path, flags=flags) + read_error: BaseException | None = None try: opened = os.fstat(descriptor) if not _same_file_status(before, opened): @@ -680,10 +843,30 @@ def _read_stable_regular_prefix(path: Path, *, max_bytes: int) -> bytes: ): raise OSError("transcript source changed while reading") return content + except BaseException as exc: + read_error = exc + raise finally: - os.close(descriptor) - if parent_descriptor >= 0: - os.close(parent_descriptor) + _close_read_descriptors(descriptor, parent_descriptor, primary=read_error) + + +def _close_read_descriptors(descriptor: int, parent_descriptor: int, *, primary: BaseException | None = None) -> None: + """Attempt both closes once, preserving a pre-existing read exception.""" + errors: list[BaseException] = [] + for candidate in (descriptor, parent_descriptor): + if candidate < 0: + continue + try: + os.close(candidate) + except BaseException as exc: + errors.append(exc) + if errors: + if primary is not None: + primary.add_note("transcript reader descriptor cleanup failed") + else: + if len(errors) > 1: + errors[0].add_note("another transcript reader descriptor cleanup failed") + raise errors[0] def _has_complete_nonblank_line(content: bytes) -> bool: @@ -694,6 +877,8 @@ def _has_complete_nonblank_line(content: bytes) -> bool: def _open_file_no_follow(path: Path, *, flags: int) -> tuple[int, int]: + # A regular-file lstat can race a FIFO replacement before open. + flags |= getattr(os, "O_NONBLOCK", 0) directory_flag = getattr(os, "O_DIRECTORY", 0) if os.name != "nt" and directory_flag: parent_flags = os.O_RDONLY | directory_flag @@ -701,8 +886,8 @@ def _open_file_no_follow(path: Path, *, flags: int) -> tuple[int, int]: parent = os.open(path.parent, parent_flags) try: return os.open(path.name, flags, dir_fd=parent), parent - except BaseException: - os.close(parent) + except BaseException as exc: + _close_read_descriptors(-1, parent, primary=exc) raise return os.open(path, flags), -1 diff --git a/src/loushang/harness/transcript/lifecycle.py b/src/loushang/harness/transcript/lifecycle.py index 9a502712f..2d3b66d30 100644 --- a/src/loushang/harness/transcript/lifecycle.py +++ b/src/loushang/harness/transcript/lifecycle.py @@ -9,19 +9,32 @@ import asyncio import os -from collections.abc import Awaitable, Callable, Sequence +import sys +from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Sequence +from contextlib import ( + AbstractAsyncContextManager, + AbstractContextManager, + asynccontextmanager, + contextmanager, +) from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Generic, TypeVar +from threading import Event +from typing import TYPE_CHECKING, Generic, Protocol, TypeVar from uuid import uuid4 -from loushang.harness.artifacts import SessionBlobHealth, session_blob_authority_id +from loushang.harness.artifacts import ( + SessionBlobHealth, + SessionBlobRef, + session_blob_authority_id, +) from loushang.harness.conversation import ( ConversationHeader, ConversationKey, ConversationStore, StoreNotFoundError, ) +from loushang.harness.conversation.store import ConversationOperationScope from loushang.harness.transcript.jsonl_file import ( AgentTranscriptFileLayout, create_agent_transcript_file_store, @@ -41,10 +54,13 @@ from loushang.harness.transcript.unit_of_work import AgentTranscriptUnitOfWork if TYPE_CHECKING: + from loushang.harness.journal._rooted_io import RootedFileIO from loushang.harness.runtime import RuntimeProfileSnapshot from loushang.harness.transcript.compaction import ( AgentTranscriptCompactionCapability, ) + from loushang.harness.transcript.writer_lease import TranscriptWriterLease + from loushang.harness.transcript.writer_lifecycle import TranscriptWriterPreparation BindingInputT = TypeVar("BindingInputT") ProductBindingT = TypeVar("ProductBindingT") @@ -53,6 +69,41 @@ AsyncDisposer = Callable[[], Awaitable[None]] +@dataclass(frozen=True) +class AgentTranscriptBindingOwner: + """Binding-time projections of an existing owner, not another lifetime.""" + + retain_disposer: Callable[[AsyncDisposer], None] + operation_scope: ConversationOperationScope + file_io: RootedFileIO | None = None + + def __call__(self, disposer: AsyncDisposer) -> None: + self.retain_disposer(disposer) + + +class _WriterLifecycleOwner(Protocol): + def _mark_import_delivered(self) -> None: ... + + @property + def blob_file_io(self) -> RootedFileIO | None: ... + + @property + def transcript_file_io(self) -> RootedFileIO | None: ... + + @property + def closing(self) -> bool: ... + + def _operation_scope( + self, target: ConversationKey | str, + ) -> AbstractAsyncContextManager[None]: ... + + def _sync_operation_scope( + self, target: ConversationKey | str, + ) -> AbstractContextManager[None]: ... + + async def _dispose_bound_resources(self) -> None: ... + + @dataclass(frozen=True) class AgentTranscriptLifecycleContext: """One Product-selected transcript location and persistence mode.""" @@ -101,6 +152,7 @@ class AgentTranscriptLifecycleSession(Generic[ProductBindingT]): label_timestamps_by_target_id: dict[str, str] session_blob_health: tuple[SessionBlobHealth, ...] = () _disposed: bool = field(default=False, init=False, repr=False) + _writer_owner: _WriterLifecycleOwner | None = field(default=None, init=False, repr=False) _ownership_state: str = field(default="root_owned", init=False, repr=False) _dispose_lock: asyncio.Lock = field( default_factory=asyncio.Lock, init=False, repr=False @@ -110,12 +162,49 @@ class AgentTranscriptLifecycleSession(Generic[ProductBindingT]): def product_binding(self) -> ProductBindingT: return self.runtime_binding.product_binding + def _mark_import_delivered(self) -> None: + if self._writer_owner is not None: + self._writer_owner._mark_import_delivered() + @property def ownership_state(self) -> str: return self._ownership_state + @property + def blob_file_io(self) -> RootedFileIO | None: + """Borrowed storage projection; callers must enter operation_scope.""" + return self._writer_owner.blob_file_io if self._writer_owner is not None else None + + @property + def transcript_file_io(self) -> RootedFileIO | None: + """Borrow the original transcript port inside operation_scope only.""" + return self._writer_owner.transcript_file_io if self._writer_owner is not None else None + + @asynccontextmanager + async def operation_scope(self) -> AsyncIterator[None]: + """Keep Product-side effects inside the existing writer lifetime. + + This is admission/drain, not authorization of additional storage roots. + Legacy unowned bindings retain their existing behavior. Nested Store + calls must still acquire their own admission and respect closing. + """ + if self._writer_owner is None: + yield + else: + async with self._writer_owner._operation_scope(self.runtime_binding.key): + yield + + @contextmanager + def sync_operation_scope(self) -> Iterator[None]: + """Borrow the same admission for synchronous, owning-loop consumers.""" + if self._writer_owner is None: + yield + else: + with self._writer_owner._sync_operation_scope(self.runtime_binding.key): + yield + def _begin_graph_construction(self) -> None: - if self._ownership_state != "root_owned": + if self._ownership_state != "root_owned" or (self._writer_owner is not None and self._writer_owner.closing): raise RuntimeError("transcript candidate is not root-owned") self._ownership_state = "graph_constructing" @@ -156,7 +245,10 @@ async def _dispose_owned(self, expected_state: str) -> None: raise RuntimeError( "transcript candidate ownership changed during disposal" ) - await self.runtime_binding.dispose() + if self._writer_owner is None: + await self.runtime_binding.dispose() + else: + await self._writer_owner._dispose_bound_resources() self._ownership_state = "disposed" self._disposed = True @@ -165,6 +257,10 @@ async def _dispose_owned(self, expected_state: str) -> None: [AgentTranscriptLifecycleContext, BindingInputT], Awaitable[AgentTranscriptRuntimeBinding[ProductBindingT]], ] +OwnedRuntimeBinder = Callable[ + [AgentTranscriptLifecycleContext, BindingInputT, AgentTranscriptBindingOwner], + Awaitable[AgentTranscriptRuntimeBinding[ProductBindingT]], +] HeaderLoader = Callable[[Path], ConversationHeader] SnapshotLoader = Callable[ [Path], tuple[ConversationHeader, list[AgentTranscriptRecord]] @@ -184,15 +280,79 @@ def __init__( self, *, bind_runtime: RuntimeBinder[BindingInputT, ProductBindingT], + bind_runtime_owned: OwnedRuntimeBinder[BindingInputT, ProductBindingT] | None = None, header_loader: HeaderLoader = load_agent_transcript_header, snapshot_loader: SnapshotLoader = load_agent_transcript_file, id_factory: IdFactory | None = None, ) -> None: self._bind_runtime = bind_runtime + self._bind_runtime_owned = bind_runtime_owned self._header_loader = header_loader self._snapshot_loader = snapshot_loader self._id_factory = id_factory or _default_id + def prepare_owned_writer( + self, context: AgentTranscriptLifecycleContext, binding_input: BindingInputT, *, + product_id: str, records: Sequence[AgentTranscriptRecord] = (), + leaf_id: str | None = None, defer_materialization: bool = True, + manage_blobs: bool = False, + initial_blobs: Sequence[tuple[SessionBlobRef, bytes]] = (), + create_root: bool = False, + expected_root_identity: tuple[int, int] | None = None, + expected_parent_identity: tuple[int, int] | None = None, + store_state_root: Path | None = None, + initialize_store: bool = False, + store_root_observed: Event | None = None, + ) -> TranscriptWriterPreparation[BindingInputT, ProductBindingT]: + """Purely prepare acquisition; callers retain this before the first await. + + The same preparation owns even a failed acquire before claim. Its + retained driver, not the factory, performs acquire/borrow/claim. + Optional root creation is create-only and chooses no default paths. + """ + from loushang.harness.transcript.writer_lifecycle import ( + TranscriptWriterPreparation, + ) + + return TranscriptWriterPreparation( + self, context, binding_input, writer=None, product_id=product_id, + records=records, leaf_id=leaf_id, defer_materialization=defer_materialization, + manage_blobs=manage_blobs, + initial_blobs=initial_blobs, + create_root=create_root, + expected_root_identity=expected_root_identity, expected_parent_identity=expected_parent_identity, + store_state_root=store_state_root, initialize_store=initialize_store, + store_root_observed=store_root_observed, + ) + + def prepare_writer( + self, context: AgentTranscriptLifecycleContext, binding_input: BindingInputT, *, + writer: TranscriptWriterLease, product_id: str, + records: Sequence[AgentTranscriptRecord] = (), leaf_id: str | None = None, + defer_materialization: bool = True, + manage_blobs: bool = False, + ) -> TranscriptWriterPreparation[BindingInputT, ProductBindingT]: + """Claim an already held writer for an explicit, retained construction. + + Only direct-root persistent contexts and pure binding inputs are valid. + The trusted binder must map this key to the claimed physical root. + ``manage_blobs`` adds a second lifetime writer for the shared data root; + it does not automatically wire existing pathname-based blob consumers. + Default create/restore and Product factories are not activated here. + """ + from loushang.harness.transcript.writer_lifecycle import ( + TranscriptWriterPreparation, + ) + + owner = TranscriptWriterPreparation( + self, context, binding_input, writer=writer, product_id=product_id, + records=records, leaf_id=leaf_id, defer_materialization=defer_materialization, + manage_blobs=manage_blobs, + ) + writer._claim(owner, root=context.session_dir, product_id=product_id, + conversation_id=context.header.conversation_id) + return owner + def new_context( self, *, @@ -342,12 +502,28 @@ async def fork( ) +class TranscriptDeletionOwner(Protocol): + """Caller-retained maintenance authority, including failed cleanup debt.""" + + async def delete_transcript( + self, session_file: str | Path, *, current_session_file: str | Path | None = None, + ) -> bool: ... + + async def delete_agent_transcript_jsonl( session_file: str | Path, *, current_session_file: str | Path | None = None, + maintenance_owner: TranscriptDeletionOwner | None = None, ) -> bool: - """Delete one Conversation JSONL file after protecting the active transcript.""" + """Delete through retained maintenance; Linux requires explicit ownership.""" + + if maintenance_owner is not None: + return await maintenance_owner.delete_transcript( + session_file, current_session_file=current_session_file, + ) + if sys.platform == "linux": + raise ValueError("Linux transcript deletion requires a retained maintenance_owner") target = Path(session_file).expanduser() if current_session_file is not None and same_agent_transcript_session_path( @@ -375,6 +551,8 @@ def _lifecycle_session( context: AgentTranscriptLifecycleContext, transcript: AgentTranscriptUnitOfWork, runtime_binding: AgentTranscriptRuntimeBinding[ProductBindingT], + *, + blob_file_io: RootedFileIO | None = None, ) -> AgentTranscriptLifecycleSession[ProductBindingT]: labels_by_target_id, label_timestamps_by_target_id = ( build_agent_transcript_label_indexes(transcript.records) @@ -389,6 +567,8 @@ def _lifecycle_session( session_dir=context.session_dir, session_id=context.header.conversation_id, records=transcript.records, + file_io=blob_file_io, + read_only=not context.persist, ), ) diff --git a/src/loushang/harness/transcript/model_input_blobs.py b/src/loushang/harness/transcript/model_input_blobs.py index a5aa0b01b..34015a12c 100644 --- a/src/loushang/harness/transcript/model_input_blobs.py +++ b/src/loushang/harness/transcript/model_input_blobs.py @@ -5,7 +5,8 @@ import base64 import binascii import hashlib -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence +from contextlib import AbstractContextManager, nullcontext from dataclasses import dataclass from typing import cast @@ -43,17 +44,28 @@ def __init__( store: SessionBlobStore, *, references: Sequence[SessionBlobRef] | None = None, + operation_scope: Callable[[], AbstractContextManager[None]] | None = None, ) -> None: if not isinstance(store, SessionBlobStore): raise TypeError("Model Input blob codec requires SessionBlobStore") self._store = store self._references = tuple(references) if references is not None else None + self._operation_scope = nullcontext if operation_scope is None else operation_scope def externalize_mapping( self, value: Mapping[str, JSONValue], *, binary_fields: Sequence[PreparedRequestBinaryField] | None = None, + ) -> ProjectedModelInputMapping: + with self._operation_scope(): + return self._externalize_mapping(value, binary_fields=binary_fields) + + def _externalize_mapping( + self, + value: Mapping[str, JSONValue], + *, + binary_fields: Sequence[PreparedRequestBinaryField] | None, ) -> ProjectedModelInputMapping: references = self._image_references() prepared_fields = ( @@ -82,12 +94,13 @@ def hydrate_mapping( self, value: Mapping[str, JSONValue], ) -> dict[str, JSONValue]: - hydrated = _hydrate_value(dict(value), self._store) - if not isinstance(hydrated, dict): # pragma: no cover - root is a mapping - raise ModelInputBinaryProjectionError( - "projected Model Input root changed shape" - ) - return cast(dict[str, JSONValue], hydrated) + with self._operation_scope(): + hydrated = _hydrate_value(dict(value), self._store) + if not isinstance(hydrated, dict): # pragma: no cover - root is a mapping + raise ModelInputBinaryProjectionError( + "projected Model Input root changed shape" + ) + return cast(dict[str, JSONValue], hydrated) def _image_references(self) -> dict[str, tuple[SessionBlobRef, ...]]: """Index image metadata by digest without materializing their bytes.""" @@ -354,6 +367,8 @@ def _hydrate_blob_marker(value: JSONValue, store: SessionBlobStore) -> str: ) try: payload = store.read_bytes(matches[0]) + except BlockingIOError: + raise # Contention is not an integrity failure. except (OSError, ValueError) as error: raise ModelInputBinaryProjectionError( "Model Input image blob failed integrity verification" diff --git a/src/loushang/harness/transcript/model_input_v2_index_file.py b/src/loushang/harness/transcript/model_input_v2_index_file.py index e44318a49..c08f059aa 100644 --- a/src/loushang/harness/transcript/model_input_v2_index_file.py +++ b/src/loushang/harness/transcript/model_input_v2_index_file.py @@ -20,9 +20,12 @@ from contextlib import suppress from dataclasses import dataclass, replace from pathlib import Path -from typing import NoReturn, cast +from typing import TYPE_CHECKING, NoReturn, cast from loushang.foundation.json import JSONValue, validate_json_value + +if TYPE_CHECKING: + from loushang.harness.journal._rooted_io import RootedFileIO from loushang.harness.conversation.jsonl_codec import ( ConversationJsonlHeaderCodec, ConversationJsonlRecordCodec, @@ -93,8 +96,9 @@ class _CacheMiss(Exception): class _DeferredNodeSource: - def __init__(self, path: Path) -> None: + def __init__(self, path: Path, *, raw: bytes | None = None) -> None: self._path = path + self._raw = raw self._entries: dict[str, _DeferredBundleEntry] = {} self._bundles: dict[str, tuple[ModelInputNode, ...]] = {} self._sequence_links: dict[ @@ -167,9 +171,12 @@ def _read_entry_line(self, record_id: str) -> tuple[_DeferredBundleEntry, bytes] if entry is None: raise ValueError("deferred Model Input record is outside the index") try: - with self._path.open("rb") as handle: - handle.seek(entry.start) - line = handle.read(entry.end - entry.start) + if self._raw is not None: + line = self._raw[entry.start:entry.end] + else: + with self._path.open("rb") as handle: + handle.seek(entry.start) + line = handle.read(entry.end - entry.start) except OSError as exc: raise ValueError( "deferred Model Input journal line is unavailable" @@ -189,16 +196,26 @@ def load_agent_transcript_snapshot_with_index( record_codec: ConversationJsonlRecordCodec, lock_factory: LockFactory, compatibility_token: str, + write_index: bool = True, + retain_raw: bool = False, + read_bytes: Callable[[], bytes] | None = None, + read_cache: Callable[[Path], bytes] | None = None, + write_cache: Callable[[Path, bytes], None] | None = None, ) -> JsonlSnapshot[ConversationHeader, AgentTranscriptRecord]: - """Load through a verified index, or replay strictly and self-heal it.""" + """Load a verified index, optionally rebuilding it after strict replay. + + Read-only projections supply stable no-follow readers and a non-mutating + lock strategy as well as write_index=False; this flag alone is not enough. + """ started = time.perf_counter_ns() try: with lock_factory(path, "shared"): - raw = path.read_bytes() + raw = read_bytes() if read_bytes is not None else path.read_bytes() manifest = _read_manifest( _projection_cache_path(path), compatibility_token=compatibility_token, + read_cache=read_cache, ) verify_started = time.perf_counter_ns() indexed_size = _integer(manifest, "projectedSize") @@ -220,6 +237,7 @@ def load_agent_transcript_snapshot_with_index( manifest, header_codec=header_codec, record_codec=record_codec, + retain_raw=retain_raw or not write_index, ) except JournalFileError: # A strictly decoded appended tail is authoritative and must @@ -231,16 +249,17 @@ def load_agent_transcript_snapshot_with_index( # Cache-derived construction must be fail-open. The strict # loader below remains the sole authority for the transcript. raise _CacheMiss("node index projection is invalid") from exc - if len(raw) != indexed_size: + if write_index and len(raw) != indexed_size: _try_rebuild_manifest( path, raw, snapshot, compatibility_token=compatibility_token, + write_cache=write_cache, ) _log_stats( AgentTranscriptIndexLoadStats( - status="extended" if len(raw) != indexed_size else "hit", + status=("extended" if write_index else "tail_read") if len(raw) != indexed_size else "hit", indexed_bytes=indexed_size, tail_bytes=len(raw) - indexed_size, record_count=len(snapshot.records), @@ -253,14 +272,22 @@ def load_agent_transcript_snapshot_with_index( pass snapshot = strict_loader() + if not write_index: + _log_stats(AgentTranscriptIndexLoadStats( + status="replayed", indexed_bytes=0, tail_bytes=0, + record_count=len(snapshot.records), verify_ms=0.0, + load_ms=_milliseconds(time.perf_counter_ns() - started), + )) + return snapshot try: with lock_factory(path, "shared"): - raw = path.read_bytes() + raw = read_bytes() if read_bytes is not None else path.read_bytes() _try_rebuild_manifest( path, raw, snapshot, compatibility_token=compatibility_token, + write_cache=write_cache, ) except OSError: # The journal result is still authoritative; a disposable cache failure @@ -279,10 +306,13 @@ def load_agent_transcript_snapshot_with_index( return snapshot -def delete_agent_transcript_index(path: Path) -> None: +def delete_agent_transcript_index(path: Path, *, file_io: RootedFileIO | None = None) -> None: """Remove the disposable projection associated with one transcript.""" - _projection_cache_path(path).unlink(missing_ok=True) + if file_io is not None: + file_io.unlink(_projection_cache_path(path), missing_ok=True) + else: + _projection_cache_path(path).unlink(missing_ok=True) def _load_verified_manifest( @@ -292,6 +322,7 @@ def _load_verified_manifest( *, header_codec: ConversationJsonlHeaderCodec, record_codec: ConversationJsonlRecordCodec, + retain_raw: bool = False, ) -> JsonlSnapshot[ConversationHeader, AgentTranscriptRecord]: indexed_size = _integer(manifest, "projectedSize") ends_at_line_boundary = _boolean(manifest, "endsAtLineBoundary") @@ -330,7 +361,7 @@ def _load_verified_manifest( except Exception as exc: raise _CacheMiss("indexed header no longer decodes") from exc - source = _DeferredNodeSource(path) + source = _DeferredNodeSource(path, raw=bytes(raw) if retain_raw else None) prepared: list[ tuple[ Mapping[str, JSONValue], @@ -534,6 +565,7 @@ def _try_rebuild_manifest( snapshot: JsonlSnapshot[ConversationHeader, AgentTranscriptRecord], *, compatibility_token: str, + write_cache: Callable[[Path, bytes], None] | None = None, ) -> None: try: manifest = _build_manifest( @@ -541,7 +573,7 @@ def _try_rebuild_manifest( snapshot, compatibility_token=compatibility_token, ) - _write_manifest(_projection_cache_path(path), manifest) + _write_manifest(_projection_cache_path(path), manifest, write_cache=write_cache) except Exception as exc: _LOGGER.debug("failed to rebuild transcript node index", exc_info=exc) @@ -703,9 +735,11 @@ def _read_manifest( path: Path, *, compatibility_token: str, + read_cache: Callable[[Path], bytes] | None = None, ) -> dict[str, JSONValue]: try: - value = json.loads(path.read_text(encoding="utf-8"), parse_constant=_reject) + content = read_cache(path) if read_cache is not None else path.read_text(encoding="utf-8") + value = json.loads(content, parse_constant=_reject) if not isinstance(value, dict): raise TypeError("node index must be an object") manifest = cast(dict[str, JSONValue], value) @@ -723,8 +757,10 @@ def _read_manifest( raise _CacheMiss("node index is unavailable or invalid") from exc -def _write_manifest(path: Path, manifest: Mapping[str, JSONValue]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) +def _write_manifest( + path: Path, manifest: Mapping[str, JSONValue], *, + write_cache: Callable[[Path, bytes], None] | None = None, +) -> None: payload = json.dumps( manifest, ensure_ascii=False, @@ -732,6 +768,10 @@ def _write_manifest(path: Path, manifest: Mapping[str, JSONValue]) -> None: separators=(",", ":"), allow_nan=False, ).encode("utf-8") + if write_cache is not None: + write_cache(path, payload) + return + path.parent.mkdir(parents=True, exist_ok=True) temp_name: str | None = None try: with tempfile.NamedTemporaryFile( diff --git a/src/loushang/harness/transcript/product_session.py b/src/loushang/harness/transcript/product_session.py index 8dfc5e615..54ea4f33a 100644 --- a/src/loushang/harness/transcript/product_session.py +++ b/src/loushang/harness/transcript/product_session.py @@ -10,10 +10,12 @@ from __future__ import annotations import builtins -from collections.abc import Mapping +import sys +from collections.abc import Awaitable, Callable, Mapping from contextlib import suppress +from functools import partial from pathlib import Path -from typing import Generic, Self, TypeVar, cast +from typing import TYPE_CHECKING, Generic, Self, TypeVar, cast from loushang.agent.types import AgentMessage from loushang.ai.types import AssistantMessage, ToolResultMessage, UserMessage @@ -22,6 +24,7 @@ SessionBlobStore, resolve_session_blob_data_root, ) +from loushang.harness.journal._owned_io import settled_io from loushang.harness.runtime import RuntimeProfileSnapshot from loushang.harness.transcript.capability_candidate import ( AgentTranscriptCapabilityCandidate, @@ -36,9 +39,11 @@ from loushang.harness.transcript.jsonl_file import ( load_agent_transcript_file, load_agent_transcript_header, + publish_owned_transcript_projection, ) from loushang.harness.transcript.lifecycle import ( AgentTranscriptLifecycleSession, + TranscriptDeletionOwner, delete_agent_transcript_jsonl, ) from loushang.harness.transcript.model_input_blobs import SessionModelInputBlobCodec @@ -48,12 +53,14 @@ delete_agent_transcript_session_blobs, ) from loushang.harness.transcript.session_catalog import ( + _MAX_PATH_SUMMARY_TOTAL_BYTES, AgentTranscriptSessionCatalog, SessionMetadata, SessionQuery, SessionRecord, SessionSummary, SessionTreeNode, + _status_fingerprint, agent_transcript_header_parent_session, build_agent_transcript_session_context, build_agent_transcript_session_tree, @@ -84,6 +91,9 @@ BindingInputT = TypeVar("BindingInputT") ProductBindingT = TypeVar("ProductBindingT") +if TYPE_CHECKING: + from loushang.harness.journal._rooted_io import RootedFileIO + class ProductTranscriptSession( AgentTranscriptSession, @@ -103,6 +113,7 @@ def __init__( lifecycle_session: AgentTranscriptLifecycleSession[ProductBindingT], ) -> None: self._lifecycle_session = lifecycle_session + self._creation_factory: AgentTranscriptSessionFactory[BindingInputT, ProductBindingT] | None = None self.session_dir = lifecycle_session.context.session_dir self.cwd = lifecycle_session.context.cwd self.persist = lifecycle_session.context.persist @@ -129,18 +140,23 @@ async def append_message( message, UserMessage | AssistantMessage | ToolResultMessage ): return await super().append_message(message, metadata=metadata) - externalized = externalize_session_message_images( - message, - self._session_blob_store(), - ) - try: - return await super().append_message( - externalized.message, - metadata=metadata, + async with self._lifecycle_session.operation_scope(): + externalized = externalize_session_message_images( + message, + self._session_blob_store(file_io=self._lifecycle_session.blob_file_io), ) - except BaseException as error: - rollback_externalized_session_images(externalized, error) - raise + revision = self._transcript.revision + try: + return await super().append_message( + externalized.message, + metadata=metadata, + ) + except BaseException as error: + rollback_externalized_session_images( + externalized, error, + transcript_changed=self._transcript.revision != revision, + ) + raise async def commit_application_message( self, @@ -150,18 +166,23 @@ async def commit_application_message( if not self.persist: return await super().commit_application_message(message) - externalized = externalize_session_message_images( - message, - self._session_blob_store(), - now=message.timestamp, - ) - try: - return await super().commit_application_message( - cast(ApplicationMessage, externalized.message) + async with self._lifecycle_session.operation_scope(): + externalized = externalize_session_message_images( + message, + self._session_blob_store(file_io=self._lifecycle_session.blob_file_io), + now=message.timestamp, ) - except BaseException as error: - rollback_externalized_session_images(externalized, error) - raise + revision = self._transcript.revision + try: + return await super().commit_application_message( + cast(ApplicationMessage, externalized.message) + ) + except BaseException as error: + rollback_externalized_session_images( + externalized, error, + transcript_changed=self._transcript.revision != revision, + ) + raise @classmethod def _session_factory( @@ -169,9 +190,37 @@ def _session_factory( ) -> AgentTranscriptSessionFactory[BindingInputT, ProductBindingT]: raise NotImplementedError("Product transcript sessions must bind a factory") + @classmethod + def _factory_for_persistence( + cls, persist: bool, + ) -> AgentTranscriptSessionFactory[BindingInputT, ProductBindingT]: + """Select an application binding without changing legacy factory hooks.""" + return cls._session_factory() + def _fork_binding_input(self) -> BindingInputT: raise NotImplementedError("Product transcript sessions must bind fork input") + @classmethod + async def _construct_product( + cls, factory: AgentTranscriptSessionFactory[BindingInputT, ProductBindingT], + operation: Callable[..., Awaitable[AgentTranscriptLifecycleSession[ProductBindingT]]], + ) -> Self: + if not factory.owns_persistent_sessions: + return cls(lifecycle_session=await operation()) + projected: Self | None = None + + def project(session: AgentTranscriptLifecycleSession[ProductBindingT]) -> None: + nonlocal projected + # Synchronous construction only: do not publish or transfer Graph + # ownership before the factory completes its original handoff. + value = cls(lifecycle_session=session) + value._creation_factory = factory + projected = value + + await operation(_projection=project) + assert projected is not None + return projected + async def dispose_runtime_profile(self) -> None: """Release the Product-owned runtime binding for this session.""" @@ -186,6 +235,11 @@ async def dispose_runtime_profile(self) -> None: finally: await self._lifecycle_session.dispose() + @property + def runtime_disposed(self) -> bool: + """True only after the actual Session/Graph disposal owner has settled.""" + return self._lifecycle_session.ownership_state == "disposed" + def transcript_capability_candidate(self) -> AgentTranscriptCapabilityCandidate: """Project the already-bound transcript trio for Session graph adoption.""" @@ -249,6 +303,35 @@ async def publish_index_summary(self) -> None: if revision == self._published_index_revision: return catalog = AgentTranscriptSessionCatalog(self.session_dir) + file_io = self._lifecycle_session.transcript_file_io + if file_io is not None: + try: + async with self._lifecycle_session.operation_scope(): + if catalog.session_dir is None: + raise ValueError("owned publication requires a local catalog") + key = self._lifecycle_session.runtime_binding.key + header, records = self.header, tuple(self.entries) + publication = partial( + publish_owned_transcript_projection, + file_io=file_io, root=catalog.session_dir, key=key, + source_path=self.session_file, header=header, records=records, + max_bytes=_MAX_PATH_SUMMARY_TOTAL_BYTES, fingerprint=_status_fingerprint, + ) + changed = await settled_io( + catalog.publish_owned_summary, + publication=publication, + key=key, + source_path=self.session_file, + header=header, records=records, + leaf_id=self.leaf_id, + ) + if changed: + self._published_index_revision = revision + except Exception: + # Cache failure does not roll back committed transcript data. + # Any unsettled native resources remain on the original port. + pass + return if not catalog.index_path.exists(): return try: @@ -272,7 +355,8 @@ async def new( additional_header_metadata: Mapping[str, JSONValue] | None = None, defer_materialization: bool = True, ) -> Self: - lifecycle_session = await cls._session_factory().new( + factory = cls._factory_for_persistence(persist) + return await cls._construct_product(factory, partial(factory.new, session_dir=session_dir, cwd=cwd, persist=persist, @@ -280,16 +364,15 @@ async def new( session_id=session_id, additional_header_metadata=additional_header_metadata, defer_materialization=defer_materialization, - ) - return cls(lifecycle_session=lifecycle_session) + )) @classmethod async def load(cls, session_file: Path, persist: bool = True) -> Self: - lifecycle_session = await cls._session_factory().load( + factory = cls._factory_for_persistence(persist) + return await cls._construct_product(factory, partial(factory.load, session_file, persist=persist, - ) - return cls(lifecycle_session=lifecycle_session) + )) @classmethod async def open( @@ -299,13 +382,13 @@ async def open( cwd_override: str | Path | None = None, persist: bool = True, ) -> Self: - lifecycle_session = await cls._session_factory().open( + factory = cls._factory_for_persistence(persist) + return await cls._construct_product(factory, partial(factory.open, session_file, session_dir=session_dir, cwd_override=cwd_override, persist=persist, - ) - return cls(lifecycle_session=lifecycle_session) + )) @classmethod async def continue_recent( @@ -314,12 +397,12 @@ async def continue_recent( cwd: str | Path, persist: bool = True, ) -> Self: - lifecycle_session = await cls._session_factory().continue_recent( + factory = cls._factory_for_persistence(persist) + return await cls._construct_product(factory, partial(factory.continue_recent, session_dir=session_dir, cwd=cwd, persist=persist, - ) - return cls(lifecycle_session=lifecycle_session) + )) @classmethod async def in_memory( @@ -327,11 +410,34 @@ async def in_memory( cwd: str | Path = ".", session_id: str | None = None, ) -> Self: - lifecycle_session = await cls._session_factory().in_memory( + factory = cls._factory_for_persistence(False) + return await cls._construct_product(factory, partial(factory.in_memory, cwd=cwd, session_id=session_id, - ) - return cls(lifecycle_session=lifecycle_session) + )) + + @classmethod + async def import_transcript( + cls, + source_file: str | Path, + *, + session_dir: str | Path, + cwd_override: str | Path | None = None, + expected_source_fingerprint: str | None = None, + _validate_cwd: Callable[[str], None] | None = None, + _unpublished: bool = False, + ) -> Self: + factory = cls._factory_for_persistence(True) + return await cls._construct_product(factory, partial( + factory.import_transcript, source_file, session_dir=session_dir, + cwd_override=cwd_override, + expected_source_fingerprint=expected_source_fingerprint, + _validate_cwd=_validate_cwd, + _unpublished=_unpublished, + )) + + def _mark_import_delivered(self) -> None: + self._lifecycle_session._mark_import_delivered() @classmethod async def import_bundle( @@ -342,13 +448,13 @@ async def import_bundle( cwd_override: str | Path | None = None, persist: bool = True, ) -> Self: - lifecycle_session = await cls._session_factory().import_bundle( + factory = cls._factory_for_persistence(persist) + return await cls._construct_product(factory, partial(factory.import_bundle, source_file, session_dir=session_dir, cwd_override=cwd_override, persist=persist, - ) - return cls(lifecycle_session=lifecycle_session) + )) @classmethod async def fork_from( @@ -358,13 +464,13 @@ async def fork_from( session_dir: str | Path, persist: bool = True, ) -> Self: - lifecycle_session = await cls._session_factory().fork_from( + factory = cls._factory_for_persistence(persist) + return await cls._construct_product(factory, partial(factory.fork_from, source_file, target_cwd=target_cwd, session_dir=session_dir, persist=persist, - ) - return cls(lifecycle_session=lifecycle_session) + )) def get_session_dir(self) -> Path: return self.session_dir @@ -444,21 +550,25 @@ async def append_session_info(self, name: str | None) -> str: return record_id async def fork(self, leaf_id: str) -> Self: - lifecycle_session = await self._session_factory().fork( + factory = self._creation_factory if self._creation_factory is not None else self._factory_for_persistence(self.persist) + return await type(self)._construct_product(factory, partial(factory.fork, self._lifecycle_session, leaf_id=leaf_id, binding_input=self._fork_binding_input(), - ) - return type(self)(lifecycle_session=lifecycle_session) + )) async def create_branched_session(self, leaf_id: str) -> Path | None: return (await self.fork(leaf_id)).session_file def build_session_context(self) -> AgentTranscriptContext: + with self._lifecycle_session.sync_operation_scope(): + return self._build_session_context() + + def _build_session_context(self) -> AgentTranscriptContext: context = build_agent_transcript_session_context(self.entries, self.leaf_id) if not self.persist: return context - store = self._session_blob_store() + store = self._session_blob_store(file_io=self._lifecycle_session.blob_file_io) hydration = SessionImageHydrationContext() return AgentTranscriptContext( messages=tuple( @@ -475,16 +585,23 @@ def build_session_context(self) -> AgentTranscriptContext: state=context.state, ) - def _session_blob_store(self) -> SessionBlobStore: + def _session_blob_store(self, *, file_io: RootedFileIO | None = None) -> SessionBlobStore: return SessionBlobStore( - resolve_session_blob_data_root(self.session_dir), + self.session_dir.parent if file_io is not None else resolve_session_blob_data_root(self.session_dir), self.header.conversation_id, + file_io=file_io, ) def _model_input_binary_codec( self, *, active_only: bool, + ) -> SessionModelInputBlobCodec | None: + with self._lifecycle_session.sync_operation_scope(): + return self._build_model_input_binary_codec(active_only=active_only) + + def _build_model_input_binary_codec( + self, *, active_only: bool, ) -> SessionModelInputBlobCodec | None: if not self.persist: return None @@ -494,7 +611,8 @@ def _model_input_binary_codec( else self._transcript.records ) return SessionModelInputBlobCodec( - self._session_blob_store(), + self._session_blob_store(file_io=self._lifecycle_session.blob_file_io), + operation_scope=self._lifecycle_session.sync_operation_scope, references=collect_agent_transcript_session_blobs( records, expected_session_id=self.header.conversation_id, @@ -521,7 +639,13 @@ async def delete_session( session_file: str | Path, *, current_session_file: str | Path | None = None, + maintenance_owner: TranscriptDeletionOwner | None = None, ) -> bool: + if maintenance_owner is not None or sys.platform == "linux": + return await delete_agent_transcript_jsonl( + session_file, current_session_file=current_session_file, + maintenance_owner=maintenance_owner, + ) target = Path(session_file).expanduser() if current_session_file is not None and same_agent_transcript_session_path( target, diff --git a/src/loushang/harness/transcript/runtime_profile.py b/src/loushang/harness/transcript/runtime_profile.py index 3914b72b8..37e94d891 100644 --- a/src/loushang/harness/transcript/runtime_profile.py +++ b/src/loushang/harness/transcript/runtime_profile.py @@ -10,7 +10,7 @@ from collections.abc import Mapping from dataclasses import dataclass, field, replace -from typing import cast +from typing import TYPE_CHECKING, cast from loushang.foundation.json import JSONValue from loushang.harness.conversation import ( @@ -20,6 +20,7 @@ ConversationStore, MemoryConversationStore, ) +from loushang.harness.conversation.store import ConversationOperationScope from loushang.harness.runtime import ( ProductRuntimePlan, ResolvedRuntimeProfile, @@ -43,17 +44,29 @@ create_agent_transcript_file_store, ) from loushang.harness.transcript.lifecycle import ( + AgentTranscriptBindingOwner, AgentTranscriptLifecycleContext, AgentTranscriptRuntimeBinding, + AsyncDisposer, ) from loushang.harness.transcript.profile import AgentTranscriptProfile from loushang.harness.transcript.types import AgentTranscriptRecord +if TYPE_CHECKING: + from loushang.harness.journal._rooted_io import RootedFileIO + _STORE_SLOT = "conversation.store" _TRANSCRIPT_SLOT = "agent.transcript_profile" _COMPACTION_SLOT = "context.compaction" +@dataclass(frozen=True) +class _ScopedTranscriptContext: + context: AgentTranscriptLifecycleContext + operation_scope: ConversationOperationScope + file_io: RootedFileIO | None + + @dataclass(frozen=True) class AgentTranscriptRuntimeSpec: """Product selections for the standard Agent transcript runtime.""" @@ -247,12 +260,44 @@ async def bind_lifecycle( profile: ResolvedRuntimeProfile, ) -> AgentTranscriptRuntimeBinding[RuntimeProfileBinding]: binding = await self._binder.bind(profile, context=context) + return self._project_lifecycle(context, profile, binding, lambda: self._binder.dispose(binding)) + + async def bind_lifecycle_owned( + self, + context: AgentTranscriptLifecycleContext, + profile: ResolvedRuntimeProfile, + retain_disposer: AgentTranscriptBindingOwner, + ) -> AgentTranscriptRuntimeBinding[RuntimeProfileBinding]: + """Transfer binding cleanup before factories or transcript projection. + + The trusted construction owner supplies a synchronous, non-throwing + retention callback and keeps it alive for this invocation. The same + disposer is used by the projected binding; no competing cleanup owner + is created. Each factory still owns resources it has not returned. + """ + scoped_context = _ScopedTranscriptContext(context, retain_disposer.operation_scope, retain_disposer.file_io) + binding = self._binder.prepare_binding(profile, context=scoped_context) + + async def dispose() -> None: + await self._binder.dispose(binding) + + retain_disposer(dispose) + await self._binder.bind_prepared(binding) + return self._project_lifecycle(context, profile, binding, dispose) + + def _project_lifecycle( + self, + context: AgentTranscriptLifecycleContext, + profile: ResolvedRuntimeProfile, + binding: RuntimeProfileBinding, + dispose: AsyncDisposer, + ) -> AgentTranscriptRuntimeBinding[RuntimeProfileBinding]: return AgentTranscriptRuntimeBinding( store=self.selected_store(binding), key=self.conversation_key(context), profile=self.selected_transcript_profile(binding), product_binding=binding, - dispose=lambda: self._binder.dispose(binding), + dispose=dispose, runtime_profile_snapshot=profile.snapshot(), get_compaction_capability=lambda: self.selected_compaction_capability( binding @@ -363,12 +408,18 @@ def _create_file_store( f"the {self.spec.product_name} file store requires a persistent " "session context" ) - layout = AgentTranscriptFileLayout(lifecycle_context.session_dir) + layout = AgentTranscriptFileLayout( + lifecycle_context.session_dir, + file_io=context.file_io if isinstance(context, _ScopedTranscriptContext) else None, + ) layout.bind_create_path( self.conversation_key(lifecycle_context), lifecycle_context.session_file, ) - return create_agent_transcript_file_store(layout) + return create_agent_transcript_file_store( + layout, + operation_scope=context.operation_scope if isinstance(context, _ScopedTranscriptContext) else None, + ) @staticmethod def _create_transcript_profile( @@ -394,6 +445,8 @@ def _create_compaction_capability( def _require_context( context: object | None, ) -> AgentTranscriptLifecycleContext: + if isinstance(context, _ScopedTranscriptContext): + return context.context if not isinstance(context, AgentTranscriptLifecycleContext): raise TypeError( "Agent transcript runtime factories require " diff --git a/src/loushang/harness/transcript/session_artifacts.py b/src/loushang/harness/transcript/session_artifacts.py index a8d0fa535..b842615de 100644 --- a/src/loushang/harness/transcript/session_artifacts.py +++ b/src/loushang/harness/transcript/session_artifacts.py @@ -5,7 +5,7 @@ from collections.abc import Mapping, Sequence from dataclasses import replace from pathlib import Path -from typing import cast +from typing import TYPE_CHECKING, cast from loushang.ai.types import AssistantMessage, ToolResultMessage, UserMessage from loushang.harness.artifacts import ( @@ -24,6 +24,9 @@ SessionImagePart, ) +if TYPE_CHECKING: + from loushang.harness.journal._rooted_io import RootedFileIO + class SessionBlobOwnershipError(ValueError): """A transcript tried to exercise another Session's blob authority.""" @@ -61,6 +64,8 @@ def inspect_agent_transcript_session_blobs( records: Sequence[AgentTranscriptRecord], verify_content: bool = True, max_references: int | None = None, + file_io: RootedFileIO | None = None, + read_only: bool = False, ) -> tuple[SessionBlobHealth, ...]: """Return availability diagnostics while leaving transcript resume usable.""" @@ -73,14 +78,16 @@ def inspect_agent_transcript_session_blobs( raise ValueError("session blob preview reference limit exceeded") if not references: return () - data_root = resolve_session_blob_data_root(session_dir) + data_root = Path(session_dir).parent if file_io is not None else resolve_session_blob_data_root(session_dir) try: - store = SessionBlobStore(data_root, authority_id) + store = SessionBlobStore(data_root, authority_id, file_io=file_io, read_only=read_only) return ( store.inspect(references) if verify_content else store.inspect_metadata(references) ) + except BlockingIOError: + raise except (OSError, ValueError) as error: return tuple( SessionBlobHealth(blob, "corrupt", str(error)) for blob in references diff --git a/src/loushang/harness/transcript/session_catalog.py b/src/loushang/harness/transcript/session_catalog.py index 0f1310fab..f5d96fb78 100644 --- a/src/loushang/harness/transcript/session_catalog.py +++ b/src/loushang/harness/transcript/session_catalog.py @@ -29,6 +29,7 @@ ConversationIndexSnapshot, ConversationJsonlHeaderCodec, ConversationJsonlRecordCodec, + ConversationKey, ConversationLocator, ConversationProviderBinding, ConversationRepository, @@ -85,6 +86,10 @@ log = get_log(__name__).bind(component="AgentTranscriptSessionCatalog") +class _SessionIdentityCollisionError(RuntimeError): + """A complete authority check established duplicate logical identities.""" + + @dataclass(frozen=True) class SessionMetadata: created_at: str @@ -493,7 +498,7 @@ def __init__( self._provider = ConversationProviderBinding( provider_id=(f"agent-conversation-jsonl:{resolved_session_dir.as_posix()}"), namespace=layout.namespace, - store=create_agent_transcript_file_store(layout), + store=create_agent_transcript_file_store(layout, read_only=True), ) self._external_index: ConversationIndex[SessionSummary, SessionQuery] | None = ( None @@ -578,7 +583,7 @@ def list_path_summaries( if not budget.reserve(candidates=1, bytes_=header_charge): break try: - header = load_agent_transcript_header(candidate.path) + header = load_agent_transcript_header(candidate.path, read_only=True) if session_id_prefix is not None and not ( header.conversation_id.startswith(session_id_prefix) or candidate.path.name == session_id_prefix @@ -593,6 +598,7 @@ def list_path_summaries( loaded_header, records = load_agent_transcript_file( candidate.path, max_bytes=_MAX_PATH_SUMMARY_FILE_BYTES, + read_only=True, ) if ( loaded_header.conversation_id != header.conversation_id @@ -665,7 +671,7 @@ def list_path_collision_summaries( ): break try: - header = load_agent_transcript_header(candidate.path) + header = load_agent_transcript_header(candidate.path, read_only=True) key = self._layout.key(header.conversation_id) locator = ConversationLocator(self._provider.provider_id, key) summary = _header_only_session_summary( @@ -707,13 +713,21 @@ def refresh_index(self) -> list[SessionSummary]: before = self._validated_unique_authority_snapshot() if self.session_dir is not None: self.session_dir.mkdir(parents=True, exist_ok=True) - result = _run_catalog(self._catalog(indexed=True).refresh()) + # Keep projection semantics, but retain this publication's receipt for + # post-scan validation instead of deleting an arbitrary pathname cache. + result = _run_catalog(self._catalog(indexed=True).scan()) + index = self._projection_index() + receipt = None + if isinstance(index, JsonConversationIndex): + _, receipt = _run_catalog(index.replace_with_receipt(result.items)) + else: + _run_catalog(index.replace(result.items)) after, after_complete = self._bounded_candidates_with_completeness() if not after_complete or _bounded_candidate_identities( before ) != _bounded_candidate_identities(after): - if self.session_dir is not None: - self.index_path.unlink(missing_ok=True) + if receipt is not None and isinstance(index, JsonConversationIndex): + _run_catalog(index.invalidate_if_current(receipt)) raise RuntimeError("session authority changed during index refresh") return _sort_summaries(item.projection for item in result.items) @@ -735,33 +749,26 @@ def repair_index(self) -> list[SessionSummary]: snapshot = _run_catalog(query_snapshot(SessionQuery())) if snapshot.index_state != "fresh": return self.refresh_index() - try: - index_modified = self.index_path.stat().st_mtime_ns - changed_paths = self._layout.transcript_paths_modified_after(index_modified) - replacement, changed = _run_catalog( - self._repair_local_index( - snapshot.items, - changed_paths, - ) - ) - before_publish, before_publish_complete = ( - self._bounded_candidates_with_completeness() - ) - if not before_publish_complete or _bounded_candidate_identities( - before - ) != _bounded_candidate_identities(before_publish): - raise RuntimeError("session authority changed during index repair") - repaired = ( - _run_catalog(index.replace(replacement)) if changed else replacement - ) - after, after_complete = self._bounded_candidates_with_completeness() - if not after_complete or _bounded_candidate_identities( - before - ) != _bounded_candidate_identities(after): - self.index_path.unlink(missing_ok=True) - raise RuntimeError("session authority changed during index repair") - except Exception: - return self.refresh_index() + index_modified = self.index_path.stat().st_mtime_ns + changed_paths = self._layout.transcript_paths_modified_after(index_modified) + replacement, changed = _run_catalog( + self._repair_local_index(snapshot.items, changed_paths) + ) + before_publish = self._validated_unique_authority_snapshot() + if _bounded_candidate_identities(before) != _bounded_candidate_identities(before_publish): + raise RuntimeError("session authority changed during index repair") + receipt = None + repaired = replacement + if changed: + if isinstance(index, JsonConversationIndex): + repaired, receipt = _run_catalog(index.replace_with_receipt(replacement)) + else: + repaired = _run_catalog(index.replace(replacement)) + after, after_complete = self._bounded_candidates_with_completeness() + if not after_complete or _bounded_candidate_identities(before) != _bounded_candidate_identities(after): + if receipt is not None and isinstance(index, JsonConversationIndex): + _run_catalog(index.invalidate_if_current(receipt)) + raise RuntimeError("session authority changed during index repair") return _sort_summaries(item.projection for item in repaired) def load_index(self) -> list[SessionSummary]: @@ -952,14 +959,20 @@ def refresh_bounded_index( after ): raise RuntimeError("session authority changed during bounded index refresh") - published = _run_catalog(self._projection_index().replace(projected)) + index = self._projection_index() + receipt = None + if isinstance(index, JsonConversationIndex): + published, receipt = _run_catalog(index.replace_with_receipt(projected)) + else: + published = _run_catalog(index.replace(projected)) after_publish, after_publish_complete = ( self._bounded_candidates_with_completeness() ) if not after_publish_complete or _bounded_candidate_identities( before ) != _bounded_candidate_identities(after_publish): - self.index_path.unlink(missing_ok=True) + if receipt is not None and isinstance(index, JsonConversationIndex): + _run_catalog(index.invalidate_if_current(receipt)) raise RuntimeError( "session authority changed during bounded index refresh" ) @@ -1027,9 +1040,40 @@ def _validated_unique_authority_snapshot( if collision_budget.truncated: raise RuntimeError("session authority scan was truncated") if collisions: - raise RuntimeError("session authority contains duplicate identities") + raise _SessionIdentityCollisionError("session authority contains duplicate identities") return candidates + def publish_owned_summary( + self, *, publication: Callable[ + [Path, JsonConversationIndex[SessionSummary, SessionQuery], Callable[[str], IndexedProjection[SessionSummary]]], bool + ], key: ConversationKey, + source_path: Path, header: ConversationHeader, + records: tuple[AgentTranscriptRecord, ...], leaf_id: str | None, + ) -> bool: + """Publish a frozen writer snapshot through its existing storage port. + + The caller retains writer admission until native settlement. No scan, + repair, pathname invalidation or creation of an absent cache occurs. + """ + if self.session_dir is None or key.namespace != str(self.session_dir): + raise ValueError("owned summary belongs to another transcript root") + if header.conversation_id != key.conversation_id: + raise ValueError("owned summary identity differs from its writer") + if source_path.parent != self.session_dir: + raise ValueError("owned summary must select a direct transcript child") + def project(before: str) -> IndexedProjection[SessionSummary]: + locator = ConversationLocator(self._provider.provider_id, key) + summary = project_agent_transcript_session_summary( + header, records, leaf_id, source_path, locator=locator, + include_all_messages_text=False, + ) + return IndexedProjection(locator, len(records), replace(summary, authority_fingerprint=before)) + + index = self._projection_index() + if not isinstance(index, JsonConversationIndex): + raise ValueError("owned summary requires the local JSON cache") + return publication(self.index_path, index, project) + async def upsert_summary( self, summary: SessionSummary, @@ -1044,8 +1088,15 @@ async def upsert_summary( raise ValueError("local session summary has no transcript path") if source_revision != summary.entry_count: raise ValueError("session summary revision must equal its entry count") + index = self._projection_index() + observed = await index.observe_publication() if isinstance(index, JsonConversationIndex) else None try: before = self._validated_unique_authority_snapshot() + except _SessionIdentityCollisionError: + if observed is not None and isinstance(index, JsonConversationIndex): + await index.invalidate_if_current(observed) + raise + try: key = self._layout.bind_existing_path(summary.session_file) if key.conversation_id != summary.session_id: raise ValueError( @@ -1053,21 +1104,27 @@ async def upsert_summary( ) locator = ConversationLocator(self._provider.provider_id, key) indexed_summary = _summary_with_authority_fingerprint(summary) - changed = await self._projection_index().upsert( - IndexedProjection( - locator=locator, - source_revision=source_revision, - projection=replace(indexed_summary, locator=locator), - ) + item = IndexedProjection( + locator=locator, + source_revision=source_revision, + projection=replace(indexed_summary, locator=locator), ) + receipt = None + if isinstance(index, JsonConversationIndex): + changed, receipt = await index.upsert_with_receipt(item) + else: + changed = await index.upsert(item) after, after_complete = self._bounded_candidates_with_completeness() if not after_complete or _bounded_candidate_identities( before ) != _bounded_candidate_identities(after): + if receipt is not None and isinstance(index, JsonConversationIndex): + await index.invalidate_if_current(receipt) raise RuntimeError("session authority changed during index upsert") return changed except Exception: - self.index_path.unlink(missing_ok=True) + # Failure (including lock contention) grants no authority to delete + # another writer's cache. Readers revalidate source fingerprints. raise def load_authoritative_revision(self, locator: ConversationLocator) -> int: diff --git a/src/loushang/harness/transcript/session_factory.py b/src/loushang/harness/transcript/session_factory.py index 7fe448698..9f47babae 100644 --- a/src/loushang/harness/transcript/session_factory.py +++ b/src/loushang/harness/transcript/session_factory.py @@ -8,23 +8,42 @@ from __future__ import annotations -from collections.abc import Callable, Mapping, Sequence +import asyncio +from collections.abc import AsyncIterator, Callable, Mapping, Sequence +from contextlib import asynccontextmanager +from copy import deepcopy +from dataclasses import replace from datetime import UTC, datetime from pathlib import Path -from typing import Generic, TypeVar +from threading import Event +from typing import TYPE_CHECKING, Generic, TypeVar from uuid import uuid4 from loushang.foundation.json import JSONValue from loushang.harness.artifacts import ( SessionBlobPublication, + SessionBlobRef, SessionBlobStore, resolve_session_blob_data_root, + session_blob_authority_id, ) from loushang.harness.conversation import ( CURRENT_CONVERSATION_FORMAT_VERSION, ConversationHeader, + DeletionReceipt, + StoreCommitOutcomeUnknown, +) +from loushang.harness.transcript.export.bundle import ( + DEFAULT_TRANSCRIPT_BUNDLE_POLICY, + AgentTranscriptBundle, + decode_agent_transcript_bundle, + read_agent_transcript_bundle, +) +from loushang.harness.transcript.jsonl_file import ( + _read_stable_regular_file, + decode_agent_transcript_bytes, + load_agent_transcript_header, ) -from loushang.harness.transcript.export.bundle import read_agent_transcript_bundle from loushang.harness.transcript.lifecycle import ( AgentTranscriptLifecycle, AgentTranscriptLifecycleContext, @@ -32,12 +51,19 @@ ) from loushang.harness.transcript.session_artifacts import ( clone_agent_transcript_session_blobs, + collect_agent_transcript_session_blobs, + replace_agent_transcript_session_blobs, ) from loushang.harness.transcript.session_catalog import ( AgentTranscriptSessionCatalog, + agent_transcript_header_cwd, + same_agent_transcript_session_path, ) from loushang.harness.transcript.types import AgentTranscriptRecord +if TYPE_CHECKING: + from loushang.harness.transcript.writer_lifecycle import TranscriptWriterPreparation + BindingInputT = TypeVar("BindingInputT") ProductBindingT = TypeVar("ProductBindingT") SourceProductBindingT = TypeVar("SourceProductBindingT") @@ -50,6 +76,14 @@ IdFactory = Callable[[], str] +class TranscriptDeletionCleanupPending(RuntimeError): + """Deletion committed, but its original preparation has not settled.""" + + def __init__(self, receipt: DeletionReceipt) -> None: + self.receipt = receipt + super().__init__("transcript deletion committed; original cleanup remains pending") + + class AgentTranscriptSessionFactory(Generic[BindingInputT, ProductBindingT]): """Compose one Product's standard Agent transcript session lifecycle. @@ -71,9 +105,22 @@ def __init__( conversation_version: int = CURRENT_CONVERSATION_FORMAT_VERSION, clock: Clock | None = None, conversation_id_factory: IdFactory | None = None, + owned_product_id: str | None = None, + index_writable: bool = True, + store_state_root: Path | None = None, + store_root_observed: Event | None = None, ) -> None: + if type(index_writable) is not bool: + raise TypeError("index_writable must be a built-in bool") + self._index_writable = index_writable and owned_product_id is None if type(conversation_version) is not int or conversation_version < 1: raise ValueError("conversation version must be a positive integer") + if owned_product_id is not None and ( + type(owned_product_id) is not str or not owned_product_id.strip() + or lifecycle._bind_runtime_owned is None + or lifecycle._header_loader is not load_agent_transcript_header + ): + raise ValueError("owned factory requires an explicit Product and rooted runtime binder") self._lifecycle = lifecycle self._resolve_binding_input = resolve_binding_input self._header_metadata = header_metadata @@ -82,6 +129,181 @@ def __init__( self._conversation_version = conversation_version self._clock = clock or _utc_now self._conversation_id_factory = conversation_id_factory or _default_id + self._owned_product_id = owned_product_id + if store_state_root is not None and owned_product_id is None: + raise ValueError("store admission requires an owned persistent factory") + self._store_state_root = store_state_root + self._store_root_observed = store_root_observed + self._pending: dict[TranscriptWriterPreparation[BindingInputT, ProductBindingT], None] = {} + self._loop: asyncio.AbstractEventLoop | None = None + self._closing = False + + @property + def pending_preparations(self) -> tuple[TranscriptWriterPreparation[BindingInputT, ProductBindingT], ...]: + """Original undelivered/cleanup handles; never a second resource owner.""" + return tuple(self._pending) + + @property + def owns_persistent_sessions(self) -> bool: + return self._owned_product_id is not None + + def _on_loop(self) -> None: + loop = asyncio.get_running_loop() + if self._loop is None: + self._loop = loop + elif self._loop is not loop: + raise RuntimeError("owned factory belongs to another event loop") + + def _accepting(self, *, persist: bool = True) -> None: + if self._owned_product_id is None: + return + self._on_loop() + if self._closing: + raise RuntimeError("owned factory is closed") + if not persist: + raise ValueError("owned factory transient construction is not yet supported") + + def _reject_unwired(self) -> None: + self._accepting() + if self._owned_product_id is not None: + raise ValueError("owned factory attachment transfer is not yet supported") + + def fence(self) -> None: + """Stop unpublished construction synchronously before dependent cleanup.""" + if self._owned_product_id is None: + return + self._on_loop() + self._closing = True + failures: list[Exception] = [] + for owner in tuple(self._pending): + try: + owner._fence_unpublished() + except Exception as error: + failures.append(error) + if failures: + raise failures[0] + + async def close(self) -> None: + """Fence and settle undelivered constructions, not delivered Sessions.""" + if self._owned_product_id is None: + return + self._on_loop() + failures: list[Exception] = [] + try: + self.fence() + except Exception as error: + failures.append(error) + for owner in tuple(self._pending): + try: + await owner.dispose() + if owner.cleanup_pending: + raise RuntimeError("owned factory cleanup remains pending") + except Exception as error: + failures.append(error) + else: + self._pending.pop(owner, None) + if failures: + raise failures[0] + + def _prepare_owned( + self, context: AgentTranscriptLifecycleContext, binding_input: BindingInputT, *, + records: Sequence[AgentTranscriptRecord] = (), + leaf_id: str | None = None, defer_materialization: bool = True, + initial_blobs: Sequence[tuple[SessionBlobRef, bytes]] = (), + create_root: bool = False, + initialize_store: bool = False, + ) -> TranscriptWriterPreparation[BindingInputT, ProductBindingT]: + self._accepting(persist=context.persist) + assert self._owned_product_id is not None + owner = self._lifecycle.prepare_owned_writer( + context, binding_input, product_id=self._owned_product_id, records=records, + leaf_id=leaf_id, defer_materialization=defer_materialization, manage_blobs=True, + initial_blobs=initial_blobs, + create_root=create_root, + store_state_root=self._store_state_root, + initialize_store=initialize_store and self._store_state_root is not None, + store_root_observed=self._store_root_observed, + ) + self._pending[owner] = None + return owner + + async def _discard_owned(self, owner: TranscriptWriterPreparation[BindingInputT, ProductBindingT]) -> None: + await owner.dispose() + if owner.cleanup_pending: + raise RuntimeError("owned factory cleanup remains pending") + self._pending.pop(owner, None) + + async def _discard_failed_owned( + self, owner: TranscriptWriterPreparation[BindingInputT, ProductBindingT], error: BaseException, + ) -> None: + try: + await self._discard_owned(owner) + except BaseException as cleanup_error: + error.add_note(f"owned factory cleanup retained: {type(cleanup_error).__name__}") + + async def _construct_owned( + self, context: AgentTranscriptLifecycleContext, binding_input: BindingInputT, *, + restore: bool = False, records: Sequence[AgentTranscriptRecord] = (), + leaf_id: str | None = None, defer_materialization: bool = True, + initial_blobs: Sequence[tuple[SessionBlobRef, bytes]] = (), + create_root: bool = False, + unpublished_import: bool = False, + _projection: Callable[[AgentTranscriptLifecycleSession[ProductBindingT]], None] | None = None, + ) -> AgentTranscriptLifecycleSession[ProductBindingT]: + owner = self._prepare_owned( + context, binding_input, records=records, leaf_id=leaf_id, + defer_materialization=defer_materialization, initial_blobs=initial_blobs, + create_root=create_root, + initialize_store=not restore, + ) + try: + if unpublished_import: + owner._prepare_import_rollback() + session = await (owner.restore() if restore else owner.create()) + self._accepting() + if _projection is not None: + _projection(session) + self._accepting() + self._pending.pop(owner) + return session + except BaseException as error: + await self._discard_failed_owned(owner, error) + raise + + @asynccontextmanager + async def _owned_source( + self, context: AgentTranscriptLifecycleContext, + ) -> AsyncIterator[AgentTranscriptLifecycleSession[ProductBindingT]]: + binding_input = self._resolve_binding_input(True) + self._validate_restored_header(context.header, binding_input, True) + owner = self._prepare_owned(context, binding_input) + try: + source = await owner.restore() + self._accepting() + yield source + except BaseException as error: + await self._discard_failed_owned(owner, error) + raise + else: + # Never deliver this internal source or create a target until its + # original owner has settled. Failure leaves that owner pending. + await self._discard_owned(owner) + + def _discover_owned_context( + self, session_file: str | Path, *, session_dir: str | Path | None = None, + cwd_override: str | Path | None = None, + ) -> AgentTranscriptLifecycleContext: + path = Path(session_file).expanduser().absolute() + path = path.parent.resolve(strict=False) / path.name + header = load_agent_transcript_header(path, read_only=True) + context = self._lifecycle.new_context( + session_dir=session_dir if session_dir is not None else path.parent, + cwd=cwd_override if cwd_override is not None else agent_transcript_header_cwd(header), + persist=True, header=header, session_file=path, + ) + if context.session_file != path: + raise ValueError("owned discovery leaf changed during context binding") + return context async def new( self, @@ -93,9 +315,13 @@ async def new( session_id: str | None = None, additional_header_metadata: Mapping[str, JSONValue] | None = None, defer_materialization: bool = True, + create_root: bool = False, + _projection: Callable[[AgentTranscriptLifecycleSession[ProductBindingT]], None] | None = None, ) -> AgentTranscriptLifecycleSession[ProductBindingT]: """Create one empty transcript with Product-selected runtime metadata.""" - + self._accepting(persist=persist) + if type(create_root) is not bool or (create_root and (not persist or self._owned_product_id is None)): + raise ValueError("root initialization requires an explicit owned persistent create") resolved_session_id = self._resolve_conversation_id(session_id) binding_input = self._resolve_binding_input(persist) return await self._create( @@ -107,6 +333,8 @@ async def new( parent_session=parent_session, additional_header_metadata=additional_header_metadata, defer_materialization=defer_materialization, + create_root=create_root, + _projection=_projection, ) async def load( @@ -116,9 +344,15 @@ async def load( persist: bool = True, session_dir: str | Path | None = None, cwd_override: str | Path | None = None, + _projection: Callable[[AgentTranscriptLifecycleSession[ProductBindingT]], None] | None = None, ) -> AgentTranscriptLifecycleSession[ProductBindingT]: """Restore one Conversation JSONL transcript through the selected binding.""" - + self._accepting(persist=persist) + if self._owned_product_id is not None: + context = self._discover_owned_context( + session_file, session_dir=session_dir, cwd_override=cwd_override, + ) + return await self.restore_context(context, _projection=_projection) context = self._lifecycle.conversation_jsonl_context( session_file, persist=persist, @@ -130,6 +364,7 @@ async def load( async def restore_context( self, context: AgentTranscriptLifecycleContext, + *, _projection: Callable[[AgentTranscriptLifecycleSession[ProductBindingT]], None] | None = None, ) -> AgentTranscriptLifecycleSession[ProductBindingT]: """Restore an already-bound source without resolving its selected leaf. @@ -138,8 +373,11 @@ async def restore_context( """ if type(context) is not AgentTranscriptLifecycleContext: raise TypeError("invalid transcript lifecycle context") + self._accepting(persist=context.persist) binding_input = self._resolve_binding_input(context.persist) self._validate_restored_header(context.header, binding_input, context.persist) + if self._owned_product_id is not None: + return await self._construct_owned(context, binding_input, restore=True, _projection=_projection) return await self._lifecycle.restore(context, binding_input) async def open( @@ -149,6 +387,7 @@ async def open( session_dir: str | Path | None = None, cwd_override: str | Path | None = None, persist: bool = True, + _projection: Callable[[AgentTranscriptLifecycleSession[ProductBindingT]], None] | None = None, ) -> AgentTranscriptLifecycleSession[ProductBindingT]: """Restore a transcript while applying Product-selected path overrides.""" @@ -157,20 +396,68 @@ async def open( persist=persist, session_dir=session_dir, cwd_override=cwd_override, + _projection=_projection, ) + async def delete_transcript( + self, session_file: str | Path, *, current_session_file: str | Path | None = None, + ) -> bool: + """Delete through the original retained writer, without deleting blobs. + + The internal source is never delivered: its original preparation stays + pending through deletion and disposal. Store deletion owns its journal, + tombstone and projection-index effects. Attachment reclamation requires + separate uniqueness evidence and is deliberately not implied here. + """ + self._accepting() + if self._owned_product_id is None: + raise ValueError("transcript deletion requires an owned factory") + target = Path(session_file).expanduser() + if current_session_file is not None and same_agent_transcript_session_path( + target, Path(current_session_file).expanduser(), + ): + raise ValueError("Cannot delete the currently active session") + try: + target.lstat() + except FileNotFoundError: + return False + context = self._discover_owned_context(target) + receipt = None + try: + async with self._owned_source(context) as source: + async with source.operation_scope(): + runtime = source.runtime_binding + revision = source.transcript.revision + operation = f"delete:{runtime.key.namespace}:{runtime.key.conversation_id}:{revision}" + result = await runtime.store.delete( + runtime.key, expected_revision=revision, + operation_id=operation, + ) + if (type(result) is not DeletionReceipt or result.revision != revision + or result.operation_id != operation): + raise StoreCommitOutcomeUnknown("transcript deletion receipt does not match the request") + receipt = result + except StoreCommitOutcomeUnknown: + raise # Preserve the original uncertain commit and its cleanup notes. + except Exception as error: + if receipt is not None: + raise TranscriptDeletionCleanupPending(receipt) from error + raise + return True + async def continue_recent( self, *, session_dir: str | Path, cwd: str | Path, persist: bool = True, + _projection: Callable[[AgentTranscriptLifecycleSession[ProductBindingT]], None] | None = None, ) -> AgentTranscriptLifecycleSession[ProductBindingT]: """Resume the most recent Conversation JSONL transcript or create a new one.""" - + self._accepting(persist=persist) resolved_session_dir = Path(session_dir) for summary in AgentTranscriptSessionCatalog( - resolved_session_dir + resolved_session_dir, index_writable=self._index_writable, ).list_summaries(): if summary.session_file is not None: return await self.open( @@ -178,11 +465,13 @@ async def continue_recent( session_dir=resolved_session_dir, cwd_override=cwd, persist=persist, + _projection=_projection, ) return await self.new( session_dir=resolved_session_dir, cwd=cwd, persist=persist, + _projection=_projection, ) async def in_memory( @@ -190,6 +479,7 @@ async def in_memory( *, cwd: str | Path = ".", session_id: str | None = None, + _projection: Callable[[AgentTranscriptLifecycleSession[ProductBindingT]], None] | None = None, ) -> AgentTranscriptLifecycleSession[ProductBindingT]: """Create a transient transcript without choosing a Product root.""" @@ -198,6 +488,7 @@ async def in_memory( cwd=cwd, persist=False, session_id=session_id, + _projection=_projection, ) async def import_bundle( @@ -207,10 +498,78 @@ async def import_bundle( session_dir: str | Path, cwd_override: str | Path | None = None, persist: bool = True, + _projection: Callable[[AgentTranscriptLifecycleSession[ProductBindingT]], None] | None = None, ) -> AgentTranscriptLifecycleSession[ProductBindingT]: """Import one portable transcript-and-blob bundle transactionally.""" + self._accepting(persist=persist) + if self.owns_persistent_sessions: + raw = _read_stable_regular_file(Path(source_file).expanduser().absolute(), max_bytes=81 * 1024 * 1024) + bundle = decode_agent_transcript_bundle(raw) + else: + bundle = read_agent_transcript_bundle(source_file) + return await self._import_frozen( + bundle, session_dir=session_dir, cwd_override=cwd_override, + persist=persist, _projection=_projection, + ) - bundle = read_agent_transcript_bundle(source_file) + async def import_transcript( + self, + source_file: str | Path, + *, + session_dir: str | Path, + cwd_override: str | Path | None = None, + expected_source_fingerprint: str | None = None, + _validate_cwd: Callable[[str], None] | None = None, + _unpublished: bool = False, + _projection: Callable[[AgentTranscriptLifecycleSession[ProductBindingT]], None] | None = None, + ) -> AgentTranscriptLifecycleSession[ProductBindingT]: + """Import frozen external JSONL/bundle through the retained writer owner.""" + self._accepting() + if type(_unpublished) is not bool: + raise ValueError("invalid unpublished import policy") + if not self.owns_persistent_sessions: + raise ValueError("direct transcript import requires an owned factory") + source = Path(source_file).expanduser().absolute() + raw = _read_stable_regular_file( + source, max_bytes=81 * 1024 * 1024, + expected_source_fingerprint=expected_source_fingerprint, + ) + if source.suffix.lower() == ".zip": + bundle = decode_agent_transcript_bundle(raw) + else: + header, records = decode_agent_transcript_bytes(raw, source_path=source) + references = collect_agent_transcript_session_blobs( + records, expected_session_id=session_blob_authority_id(header.conversation_id), + ) + policy = DEFAULT_TRANSCRIPT_BUNDLE_POLICY + if (len(references) > policy.max_blobs + or any(ref.size_bytes > policy.max_blob_bytes for ref in references) + or sum(ref.size_bytes for ref in references) > policy.max_total_bytes): + raise ValueError("transcript import exceeds the portable blob budget") + # Read-only blob readers do not create Session directories or locks. + blob_store = SessionBlobStore( + resolve_session_blob_data_root(source.parent), header.conversation_id, + read_only=True, policy=policy, + ) if references else None + blobs = tuple((reference, blob_store.read_bytes(reference)) for reference in references) if blob_store is not None else () + bundle = AgentTranscriptBundle(header=header, records=tuple(records), blobs=blobs) + if _validate_cwd is not None: + _validate_cwd(str(cwd_override) if cwd_override is not None else str(bundle.header.metadata.get("cwd", "."))) + return await self._import_frozen( + bundle, session_dir=session_dir, cwd_override=cwd_override, + persist=True, _projection=_projection, unpublished_import=_unpublished, + ) + + async def _import_frozen( + self, + bundle: AgentTranscriptBundle, + *, + session_dir: str | Path, + cwd_override: str | Path | None, + persist: bool, + _projection: Callable[[AgentTranscriptLifecycleSession[ProductBindingT]], None] | None, + unpublished_import: bool = False, + ) -> AgentTranscriptLifecycleSession[ProductBindingT]: if not persist and bundle.blobs: raise ValueError( "bundle import with blobs requires persistent session storage" @@ -229,6 +588,13 @@ async def import_bundle( header=bundle.header, ) publication: SessionBlobPublication | None = None + if self._owned_product_id is not None: + return await self._construct_owned( + context, binding_input, records=bundle.records, initial_blobs=bundle.blobs, + defer_materialization=False, + unpublished_import=unpublished_import, + _projection=_projection, + ) if persist and bundle.blobs: blob_store = SessionBlobStore( resolve_session_blob_data_root(context.session_dir), @@ -255,9 +621,22 @@ async def fork_from( target_cwd: str | Path, session_dir: str | Path, persist: bool = True, + _projection: Callable[[AgentTranscriptLifecycleSession[ProductBindingT]], None] | None = None, ) -> AgentTranscriptLifecycleSession[ProductBindingT]: """Copy a Conversation JSONL transcript into a new Product-selected session.""" - + self._accepting(persist=persist) + if self._owned_product_id is not None: + context = self._discover_owned_context(source_file) + async with self._owned_source(context) as source: + records, blobs = _freeze_owned_source(source) + self._accepting(persist=persist) + return await self._create( + session_dir=session_dir, cwd=target_cwd, persist=persist, + binding_input=self._resolve_binding_input(persist), + parent_conversation_id=context.header.conversation_id, + parent_session=str(Path(source_file)), records=records, initial_blobs=blobs, + _projection=_projection, + ) source = await self.load(source_file, persist=False) try: return await self._create( @@ -280,9 +659,14 @@ async def fork( *, leaf_id: str, binding_input: BindingInputT, + _projection: Callable[[AgentTranscriptLifecycleSession[ProductBindingT]], None] | None = None, ) -> AgentTranscriptLifecycleSession[ProductBindingT]: """Fork one selected source path using an already selected Product binding.""" - + self._accepting() + frozen = None + if self._owned_product_id is not None: + self._accepting(persist=source.context.persist) + frozen = _freeze_owned_source(source, leaf_id=leaf_id) source_context = source.context header = self._new_header( conversation_id=None, @@ -301,6 +685,12 @@ async def fork( persist=source_context.persist, header=header, ) + if frozen is not None: + records, blobs = _retarget_frozen_blobs(*frozen, target_session_id=header.conversation_id) + return await self._construct_owned( + context, binding_input, records=records, leaf_id=leaf_id, initial_blobs=blobs, + _projection=_projection, + ) records = source.transcript.records_for_fork(leaf_id) prepared_records, rollback_store = ( clone_agent_transcript_session_blobs( @@ -336,11 +726,15 @@ async def _create( parent_conversation_id: str | None = None, parent_session: str | None = None, records: Sequence[AgentTranscriptRecord] = (), + initial_blobs: Sequence[tuple[SessionBlobRef, bytes]] = (), source_session_dir: str | Path | None = None, source_session_id: str | None = None, additional_header_metadata: Mapping[str, JSONValue] | None = None, defer_materialization: bool = True, + create_root: bool = False, + _projection: Callable[[AgentTranscriptLifecycleSession[ProductBindingT]], None] | None = None, ) -> AgentTranscriptLifecycleSession[ProductBindingT]: + self._accepting(persist=persist) header = self._new_header( conversation_id=conversation_id, cwd=cwd, @@ -356,6 +750,18 @@ async def _create( header=header, ) prepared_records: Sequence[AgentTranscriptRecord] = records + if self._owned_product_id is not None: + if source_session_dir is not None: + self._reject_unwired() + records, initial_blobs = _retarget_frozen_blobs( + records, initial_blobs, target_session_id=header.conversation_id, + ) + return await self._construct_owned( + context, binding_input, records=records, defer_materialization=defer_materialization, + initial_blobs=initial_blobs, + create_root=create_root, + _projection=_projection, + ) rollback_store = None if persist and records and source_session_dir is not None: if source_session_id is None: @@ -443,6 +849,38 @@ def _resolve_conversation_id(self, conversation_id: str | None) -> str: return conversation_id +def _freeze_owned_source( + source: AgentTranscriptLifecycleSession[SourceProductBindingT], *, leaf_id: str | None = None, +) -> tuple[tuple[AgentTranscriptRecord, ...], tuple[tuple[SessionBlobRef, bytes], ...]]: + if source._writer_owner is None or source.blob_file_io is None: + raise ValueError("owned fork requires a source with retained transcript and blob ports") + with source.sync_operation_scope(): + records = deepcopy(tuple( + source.transcript.records if leaf_id is None else source.transcript.records_for_fork(leaf_id) + )) + references = collect_agent_transcript_session_blobs( + records, expected_session_id=source.context.header.conversation_id, + ) + if not references: + return records, () + store = SessionBlobStore( + source.context.session_dir.parent, source.context.header.conversation_id, + file_io=source.blob_file_io, + ) + return records, tuple((ref, store.read_bytes(ref)) for ref in references) + + +def _retarget_frozen_blobs( + records: Sequence[AgentTranscriptRecord], blobs: Sequence[tuple[SessionBlobRef, bytes]], *, + target_session_id: str, +) -> tuple[tuple[AgentTranscriptRecord, ...], tuple[tuple[SessionBlobRef, bytes], ...]]: + replacements = {ref: replace(ref, session_id=session_blob_authority_id(target_session_id)) for ref, _ in blobs} + return ( + replace_agent_transcript_session_blobs(records, replacements), + tuple((replacements[ref], data) for ref, data in blobs), + ) + + def _validate_nothing( header: ConversationHeader, binding_input: object, @@ -492,4 +930,5 @@ def _encode_timestamp(value: datetime) -> str: "IdFactory", "RestoredHeaderValidator", "SessionFileFactory", + "TranscriptDeletionCleanupPending", ] diff --git a/src/loushang/harness/transcript/session_images.py b/src/loushang/harness/transcript/session_images.py index d00eec75a..ce62c6cfb 100644 --- a/src/loushang/harness/transcript/session_images.py +++ b/src/loushang/harness/transcript/session_images.py @@ -22,6 +22,7 @@ SessionBlobRef, SessionBlobStore, ) +from loushang.harness.conversation import StoreCommitOutcomeUnknown from loushang.harness.transcript.types import ApplicationMessage, SessionImagePart DEFAULT_SESSION_IMAGE_CONTEXT_BYTES = 64 * 1024 * 1024 @@ -172,6 +173,9 @@ def hydrate_session_message_images( text=f"[Image omitted: context budget exceeded: {part.blob.logical_name}]", ) ) + except BlockingIOError: + # A contended retained authority is not a missing/corrupt image. + raise except (OSError, ValueError): hydrated.append( TextPart( @@ -187,10 +191,18 @@ def hydrate_session_message_images( def rollback_externalized_session_images( externalized: ExternalizedSessionImages, error: BaseException, + *, + transcript_changed: bool = False, ) -> None: publication = externalized.publication if publication is None: return + if transcript_changed or isinstance(error, StoreCommitOutcomeUnknown): + # Retaining possibly referenced bytes is safer than making a durable + # transcript unreadable. A failed observer is not a failed commit, and a + # failed retry cannot establish that the original write did not happen. + error.add_note("session images retained: transcript commit may have completed") + return try: publication.rollback() except BaseException as cleanup_error: diff --git a/src/loushang/harness/transcript/unit_of_work.py b/src/loushang/harness/transcript/unit_of_work.py index 7438aab5d..229059338 100644 --- a/src/loushang/harness/transcript/unit_of_work.py +++ b/src/loushang/harness/transcript/unit_of_work.py @@ -151,6 +151,7 @@ async def create( profile: AgentTranscriptProfile | None = None, defer_materialization: bool = False, materialization_policy: MaterializationPolicy | None = None, + create_operation_id: str | None = None, ) -> AgentTranscriptUnitOfWork: _require_matching_identity(key, header) initial_records = tuple(records) @@ -162,6 +163,8 @@ async def create( ) resolved_clock = clock or _utc_now if defer_materialization: + if create_operation_id is not None: + raise ValueError("a specific create operation requires immediate materialization") if initial_records: raise ValueError( "deferred transcript materialization requires no initial records" @@ -187,29 +190,32 @@ async def create( key, header, initial_records, - operation_id=_create_operation_id(key), + operation_id=_create_operation_id(key) if create_operation_id is None else create_operation_id, ) - repository = ConversationRepository.from_snapshot( - snapshot, - record_id=lambda record: record.record_id, - parent_id=lambda record: record.parent_id, - leaf_id=leaf_id, - ) - return cls( - backend=backend, - key=key, - repository=repository, - revision=snapshot.revision, - record_factory=record_factory - or AgentTranscriptRecordFactory( + try: + repository = ConversationRepository.from_snapshot( + snapshot, + record_id=lambda record: record.record_id, + parent_id=lambda record: record.parent_id, + leaf_id=leaf_id, + ) + return cls( + backend=backend, + key=key, + repository=repository, + revision=snapshot.revision, + record_factory=record_factory + or AgentTranscriptRecordFactory( + clock=resolved_clock, + id_factory=id_factory, + ), + profile=resolved_profile, + diagnostics=repository.diagnostics, + materialization_policy=materialization_policy, clock=resolved_clock, - id_factory=id_factory, - ), - profile=resolved_profile, - diagnostics=repository.diagnostics, - materialization_policy=materialization_policy, - clock=resolved_clock, - ) + ) + except BaseException as error: + raise StoreCommitOutcomeUnknown("transcript create returned but local construction failed") from error @classmethod async def load( @@ -860,6 +866,7 @@ async def _commit_locked( return AgentTranscriptCommit(record=record, receipt=None) return await self._materialize_locked(record, candidate) expected_revision = self._revision + commit_uncertainty: StoreCommitOutcomeUnknown | None = None try: commit_result = await self._backend.append( self._key, @@ -867,22 +874,27 @@ async def _commit_locked( expected_revision=expected_revision, operation_id=record.record_id, ) - except StoreCommitOutcomeUnknown: - commit_result = await self._backend.append( - self._key, - record, - expected_revision=expected_revision, - operation_id=record.record_id, - ) + except StoreCommitOutcomeUnknown as uncertain: + commit_uncertainty = uncertain + try: + commit_result = await self._backend.append( + self._key, + record, + expected_revision=expected_revision, + operation_id=record.record_id, + ) + except (Exception, asyncio.CancelledError) as recovery_error: + # A rejected retry cannot prove the first attempt did not write. + raise uncertain from recovery_error receipt = commit_result.receipt next_revision = expected_revision + 1 if receipt.revision != next_revision: - raise RuntimeError( + raise commit_uncertainty or StoreCommitOutcomeUnknown( "conversation backend returned an invalid append revision: " f"expected {next_revision}, got {receipt.revision}" ) if receipt.record_id not in {None, record.record_id}: - raise RuntimeError( + raise commit_uncertainty or StoreCommitOutcomeUnknown( "conversation backend returned a different committed record id" ) self._repository = candidate @@ -923,6 +935,7 @@ async def _commit_batch_locked( ) expected_revision = self._revision operation_ids = tuple(record.record_id for record in records) + commit_uncertainty: StoreCommitOutcomeUnknown | None = None try: result = await self._backend.append_batch( self._key, @@ -930,15 +943,21 @@ async def _commit_batch_locked( expected_revision=expected_revision, operation_ids=operation_ids, ) - except StoreCommitOutcomeUnknown: - result = await self._backend.append_batch( - self._key, - records, - expected_revision=expected_revision, - operation_ids=operation_ids, - ) + except StoreCommitOutcomeUnknown as uncertain: + commit_uncertainty = uncertain + try: + result = await self._backend.append_batch( + self._key, + records, + expected_revision=expected_revision, + operation_ids=operation_ids, + ) + except (Exception, asyncio.CancelledError) as recovery_error: + raise uncertain from recovery_error if len(result.receipts) != len(records): - raise RuntimeError("conversation backend returned an invalid batch size") + raise commit_uncertainty or StoreCommitOutcomeUnknown( + "conversation backend returned an invalid batch size" + ) batch_commits: list[AgentTranscriptCommit] = [] for index, (record, receipt) in enumerate( zip(records, result.receipts, strict=True), @@ -946,11 +965,11 @@ async def _commit_batch_locked( ): expected_receipt_revision = expected_revision + index if receipt.revision != expected_receipt_revision: - raise RuntimeError( + raise commit_uncertainty or StoreCommitOutcomeUnknown( "conversation backend returned an invalid batch revision" ) if receipt.record_id not in {None, record.record_id}: - raise RuntimeError( + raise commit_uncertainty or StoreCommitOutcomeUnknown( "conversation backend returned a different batch record id" ) batch_commits.append( @@ -1043,6 +1062,7 @@ async def _materialize_locked( ], ) -> AgentTranscriptCommit: diagnostics: tuple[ConversationSourceDiagnostic, ...] = () + commit_uncertainty: StoreCommitOutcomeUnknown | None = None try: snapshot = await self._backend.create( self._key, @@ -1051,10 +1071,11 @@ async def _materialize_locked( operation_id=_create_operation_id(self._key), ) except StoreCommitOutcomeUnknown as error: + commit_uncertainty = error try: loaded = await self._backend.load(self._key) - except Exception: - raise error + except (Exception, asyncio.CancelledError) as recovery_error: + raise error from recovery_error snapshot = loaded.snapshot diagnostics = loaded.diagnostics if snapshot.header != self.header or snapshot.records != candidate.records: @@ -1064,7 +1085,7 @@ async def _materialize_locked( or snapshot.records != candidate.records or snapshot.revision != len(candidate.records) ): - raise RuntimeError( + raise commit_uncertainty or StoreCommitOutcomeUnknown( "conversation backend returned an invalid materialized snapshot" ) receipt = CommitReceipt( diff --git a/src/loushang/harness/transcript/writer_lifecycle.py b/src/loushang/harness/transcript/writer_lifecycle.py new file mode 100644 index 000000000..452443c58 --- /dev/null +++ b/src/loushang/harness/transcript/writer_lifecycle.py @@ -0,0 +1,646 @@ +"""Explicit retained construction and lease-last disposal for writer Sessions.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Coroutine, Iterator, Sequence +from contextlib import asynccontextmanager, contextmanager +from copy import deepcopy +from pathlib import Path +from threading import Event +from typing import Generic, TypeVar +from uuid import uuid4 + +from loushang.harness.artifacts import ( + SessionBlobPublication, + SessionBlobRef, + SessionBlobStore, +) +from loushang.harness.artifacts._writer_lease import SessionBlobWriterLease +from loushang.harness.conversation import ( + ConversationKey, + DeletionReceipt, + StoreAlreadyExistsError, + StoreCommitOutcomeUnknown, + StoreDataError, +) +from loushang.harness.conversation.stores.file import FileConversationStore +from loushang.harness.journal._owned_io import settled_io +from loushang.harness.journal._rooted_io import RootedFileIO + +from .jsonl_file import AgentTranscriptFileLayout, load_agent_transcript_header +from .lifecycle import ( + AgentTranscriptBindingOwner, + AgentTranscriptLifecycle, + AgentTranscriptLifecycleContext, + AgentTranscriptLifecycleSession, + AgentTranscriptRuntimeBinding, + AsyncDisposer, + _lifecycle_session, +) +from .store_admission import TranscriptStoreAdmission +from .types import AgentTranscriptRecord +from .unit_of_work import AgentTranscriptUnitOfWork +from .writer_lease import TranscriptWriterError, TranscriptWriterLease + +InputT = TypeVar("InputT") +ProductT = TypeVar("ProductT") +TaskT = TypeVar("TaskT") + + +class TranscriptWriterPreparation(Generic[InputT, ProductT]): + """Retain one construction intent and release its writer after its runtime. + + Only trusted binders whose disposer settles all their admitted work may use + this seam. Scoped Store references drain before disposal; independently + constructed unscoped Stores require separate authority. ``manage_blobs`` + adds attachment lifetime exclusion and retains its physical root, but does + not retrofit descriptor-relative IO into independently created BlobStores. + Callers retain the preparation before awaiting create/restore or disposal. + """ + + def __init__( + self, lifecycle: AgentTranscriptLifecycle[InputT, ProductT], + context: AgentTranscriptLifecycleContext, binding_input: InputT, *, + writer: TranscriptWriterLease | None, product_id: str, + records: Sequence[AgentTranscriptRecord], leaf_id: str | None, defer_materialization: bool, + manage_blobs: bool = False, + initial_blobs: Sequence[tuple[SessionBlobRef, bytes]] = (), + create_root: bool = False, + expected_root_identity: tuple[int, int] | None = None, + expected_parent_identity: tuple[int, int] | None = None, + store_state_root: Path | None = None, + initialize_store: bool = False, + store_root_observed: Event | None = None, + ) -> None: + if (type(context) is not AgentTranscriptLifecycleContext or not context.persist + or context.session_file is None or context.session_file.parent != context.session_dir + or (writer is not None and type(writer) is not TranscriptWriterLease) + or type(product_id) is not str or not product_id.strip() + or type(defer_materialization) is not bool or type(manage_blobs) is not bool + or type(create_root) is not bool or (create_root and writer is not None)): + raise TranscriptWriterError("invalid") + if writer is not None and (expected_root_identity is not None or expected_parent_identity is not None): + raise TranscriptWriterError("invalid") + if (type(initialize_store) is not bool or (initialize_store and store_state_root is None) + or (store_state_root is not None and (writer is not None or create_root + or expected_root_identity is not None or expected_parent_identity is not None))): + raise TranscriptWriterError("invalid") + self._store_admission = (TranscriptStoreAdmission( + context.session_dir, state_root=store_state_root, create_if_missing=initialize_store, + root_observed=store_root_observed, + ) if store_state_root is not None else None) + self._admission_cleanup_task: asyncio.Task[None] | None = None + self._context = deepcopy(context) + self._input, self._records = deepcopy(binding_input), deepcopy(tuple(records)) + self._initial_blobs = deepcopy(tuple((reference, bytes(payload)) for reference, payload in initial_blobs)) + if self._initial_blobs and (not manage_blobs or lifecycle._bind_runtime_owned is None): + raise TranscriptWriterError("invalid") + self._publication: SessionBlobPublication | None = None + self._preserve_publication = False + self._publication_rollback_task: asyncio.Task[None] | None = None + self._import_create_operation: str | None = None + self._import_delete_operation: str | None = None + self._import_revision: int | None = None + self._import_identity: tuple[int, int] | None = None + self._import_delivered = self._import_unknown = False + self._import_delete_task: asyncio.Task[None] | None = None + self._import_delete_receipt: DeletionReceipt | None = None + self._internal_writer = writer is None + self._create_root = create_root + self._writer = writer if writer is not None else TranscriptWriterLease( + context.session_dir, product_id, context.header.conversation_id, create_root=create_root, + expected_root_identity=expected_root_identity, expected_parent_identity=expected_parent_identity, + ) + self._lifecycle, self._product = lifecycle, product_id + self._leaf, self._defer = leaf_id, defer_materialization + self._loop: asyncio.AbstractEventLoop | None = None + self._driver: asyncio.Task[AgentTranscriptLifecycleSession[ProductT]] | None = None + self._runtime_task: asyncio.Task[None] | None = None + self._io_cleanup_task: asyncio.Task[None] | None = None + self._blob_io_cleanup_task: asyncio.Task[None] | None = None + self._blob_close_task: asyncio.Task[None] | None = None + self._close_task: asyncio.Task[None] | None = None + self._runtime: AgentTranscriptRuntimeBinding[ProductT] | None = None + self._retained_disposer: AsyncDisposer | None = None + self._retain_open = False + self._session: AgentTranscriptLifecycleSession[ProductT] | None = None + self._mode: str | None = None + self._cleanup_lock = asyncio.Lock() + self._active_io = 0 + self._io_drained = asyncio.Event() + self._io_drained.set() + self._file_io = None + # Pure construction: the existing preparation exclusively retains this + # second lease before any native acquire, including failed admissions. + self._blob_writer = ( + SessionBlobWriterLease(context.session_dir.parent, product_id, context.header.conversation_id, + expected_root_identity=self._writer._expected_parent_identity) + if manage_blobs else None + ) + self._blob_file_io = None + if lifecycle._bind_runtime_owned is not None: + if lifecycle._header_loader is not load_agent_transcript_header: + raise TranscriptWriterError("invalid") + if writer is not None: + self._file_io = writer._borrow_file_io( + root=context.session_dir, product_id=product_id, conversation_id=context.header.conversation_id, + ) + self._binding_owner = AgentTranscriptBindingOwner(self._retain_disposer, self._operation_scope, self._file_io) + self._binding_started = self._runtime_done = self._closing = self._unknown = False + + @property + def closing(self) -> bool: + return self._closing + + @property + def blob_file_io(self) -> RootedFileIO | None: + return self._blob_file_io + + @property + def transcript_file_io(self) -> RootedFileIO | None: + return self._file_io + + @property + def cleanup_pending(self) -> bool: + return (self._writer.cleanup_pending or self._unknown or self._import_unknown or self._active_io > 0 + or (self._import_create_operation is not None and not self._import_delivered + and self._import_revision is not None and self._import_delete_receipt is None) + or (self._store_admission is not None and self._store_admission.cleanup_pending) + or (self._publication is not None and not self._preserve_publication) + or (self._blob_writer is not None and self._blob_writer.cleanup_pending) + or (self._blob_file_io is not None and self._blob_file_io.cleanup_pending) + or (self._file_io is not None and self._file_io.cleanup_pending) + or any(task is not None and not task.done() for task in (self._driver, self._runtime_task, self._close_task)) + or (self._driver is not None and self._driver.cancelled()) + or (self._io_cleanup_task is not None and not self._io_cleanup_task.done()) + or any(task is not None and not task.done() + for task in (self._blob_io_cleanup_task, self._blob_close_task, self._publication_rollback_task)) + or any(task is not None and task.done() and (task.cancelled() or task.exception() is not None) + for task in (self._runtime_task, self._io_cleanup_task, self._blob_io_cleanup_task, + self._blob_close_task, self._close_task, self._publication_rollback_task)) + or ((self._runtime is not None or self._retained_disposer is not None) and not self._runtime_done)) + + def _prepare_import_rollback(self) -> None: + """Arm only this fresh preparation before any binding or create effect.""" + if self._driver is not None or self._import_create_operation is not None or self._defer: + raise TranscriptWriterError("invalid") + nonce = uuid4().hex + self._import_create_operation = f"import-create:{nonce}" + self._import_delete_operation = f"import-abort:{nonce}" + + def _mark_import_delivered(self) -> None: + # A conservative, monotonic disarm at the original lifecycle's delivery + # cut. It must never throw after that lifecycle has installed the slot. + self._import_delivered = True + + def _on_loop(self) -> None: + loop = asyncio.get_running_loop() + if self._loop is None: + self._loop = loop + elif self._loop is not loop: + raise TranscriptWriterError("conflict") + claimed = self._writer.claimed_owner + if claimed is not self and not (self._internal_writer and claimed is None): + raise TranscriptWriterError("closed") + + async def create(self) -> AgentTranscriptLifecycleSession[ProductT]: + return await self._join("create") + + async def restore(self) -> AgentTranscriptLifecycleSession[ProductT]: + if (self._create_root or self._records or self._leaf is not None or not self._defer or self._initial_blobs + or (self._store_admission is not None and self._store_admission._create)): + raise TranscriptWriterError("invalid") + return await self._join("restore") + + async def _join(self, mode: str) -> AgentTranscriptLifecycleSession[ProductT]: + self._on_loop() + if self._closing: + raise TranscriptWriterError("closed") + if self._mode is not None and self._mode != mode: + raise TranscriptWriterError("conflict") + if self._driver is None: + self._mode = mode + self._driver = _task(self._build(mode)) + result = await asyncio.shield(self._driver) + if self._closing or result.ownership_state != "root_owned": + raise TranscriptWriterError("closed") + return result + + async def _build(self, mode: str) -> AgentTranscriptLifecycleSession[ProductT]: + context = self._context + assert context.session_file is not None + if self._internal_writer: + await settled_io(self._acquire_writer) + await settled_io(self._writer.check, product_id=self._product, conversation_id=context.header.conversation_id) + if self._blob_writer is not None: + await settled_io(self._acquire_blob_writer) + if self._import_create_operation is not None: + await settled_io(self._require_fresh_import_path) + if self._initial_blobs: + async with self._operation_scope(str(context.session_dir)): + self._publication = SessionBlobStore( + context.session_dir.parent, context.header.conversation_id, file_io=self._blob_file_io, + ).import_blobs(self._initial_blobs, require_new_authority=True) + if mode == "restore": + header = ( + await settled_io(load_agent_transcript_header, context.session_file, read_only=True, file_io=self._file_io) + if self._file_io is not None else + await settled_io(self._lifecycle._header_loader, context.session_file) + ) + if header != context.header: + raise TranscriptWriterError("conflict") + self._binding_started = True + if self._lifecycle._bind_runtime_owned is None: + self._runtime = await self._lifecycle._bind_runtime(context, self._input) + else: + self._retain_open = True + try: + self._runtime = await self._lifecycle._bind_runtime_owned(context, self._input, self._binding_owner) + finally: + self._retain_open = False + if self._retained_disposer is not self._runtime.dispose: + # Keep both receipts; conflicting authorities cannot prove release. + self._unknown = True + raise TranscriptWriterError("conflict") + expected = ConversationKey(str(context.session_dir), context.header.conversation_id) + if self._runtime.key != expected: + raise TranscriptWriterError("conflict") + if self._import_create_operation is not None: + if not isinstance(self._runtime.store, FileConversationStore): + raise StoreDataError("import rollback requires the original rooted FileStore") + self._runtime.store.retain_creation_identity(self._runtime.key, self._import_create_operation) + if mode == "create": + try: + transcript = await AgentTranscriptUnitOfWork.create( + self._runtime.store, self._runtime.key, context.header, records=self._records, + leaf_id=self._leaf, id_factory=self._lifecycle._id_factory, profile=self._runtime.profile, + defer_materialization=self._defer and not self._records and not self._initial_blobs, + create_operation_id=self._import_create_operation, + ) + except StoreCommitOutcomeUnknown: + self._preserve_publication = True + if self._import_create_operation is not None: + self._import_unknown = True + raise + else: + self._preserve_publication = True + if self._import_create_operation is not None: + self._import_revision = transcript.revision + try: + assert isinstance(self._runtime.store, FileConversationStore) + self._import_identity = self._runtime.store.created_file_identity( + self._runtime.key, self._import_create_operation, + ) + except BaseException: + self._import_unknown = True + raise + else: + transcript = await AgentTranscriptUnitOfWork.load( + self._runtime.store, self._runtime.key, id_factory=self._lifecycle._id_factory, + profile=self._runtime.profile, + ) + if transcript.header != context.header: + raise TranscriptWriterError("conflict") + async with self._operation_scope(expected): + session = _lifecycle_session(context, transcript, self._runtime, blob_file_io=self._blob_file_io) + session._writer_owner = self + self._session = session + return session + + def _acquire_writer(self) -> None: + """Acquire only the lease exclusively constructed by this preparation.""" + assert self._internal_writer + if self._store_admission is not None: + try: + binding = self._store_admission.open() + except BaseException as error: + try: + self._store_admission.close() + except BaseException as cleanup_error: + error.add_note(f"store admission cleanup retained: {type(cleanup_error).__name__}") + raise + else: + self._writer._expected_root_identity = binding.root_identity + self._writer._expected_parent_identity = binding.parent_identity + if self._blob_writer is not None: + self._blob_writer._expected_root_identity = binding.parent_identity + if binding.shared_identities is not None: + self._blob_writer._expected_directory_identity = binding.shared_identities[2] + # The store lock never covers Session runtime construction or + # lifetime; all later IO is guarded by the original writers. + self._store_admission.release_locks() + self._writer.acquire() + if self._lifecycle._bind_runtime_owned is not None: + self._file_io = self._writer._borrow_file_io( + root=self._context.session_dir, product_id=self._product, + conversation_id=self._context.header.conversation_id, + ) + self._writer._claim( + self, root=self._context.session_dir, product_id=self._product, + conversation_id=self._context.header.conversation_id, + ) + self._binding_owner = AgentTranscriptBindingOwner(self._retain_disposer, self._operation_scope, self._file_io) + + def _close_writer(self) -> None: + if self._store_admission is not None: + self._store_admission.close() + claimed = self._writer.claimed_owner + if claimed is self: + self._writer._close_claimed(self) + elif self._internal_writer and claimed is None: + self._writer.close() + else: + raise TranscriptWriterError("conflict") + + def _acquire_blob_writer(self) -> None: + assert self._blob_writer is not None + self._blob_writer.acquire() + directories: tuple[tuple[tuple[int, int], str, tuple[int, int]], ...] = () + if self._store_admission is not None: + binding = self._store_admission._binding + if binding is not None and binding.shared_identities is not None: + assets, locks, _ = binding.shared_identities + directories = ((binding.parent_identity, "session-assets", assets), (assets, ".locks", locks)) + self._blob_file_io = self._blob_writer.borrow_file_io( + data_root=self._context.session_dir.parent, + owner_id=self._product, session_id=self._context.header.conversation_id, + directory_bindings=directories, + ) + self._blob_writer._claim_binding( + self, root=self._context.session_dir.parent, + owner_id=self._product, authority_id=self._blob_writer._authority_id, + ) + + def _check_writers(self) -> None: + self._writer.check(product_id=self._product, conversation_id=self._context.header.conversation_id) + if self._blob_writer is not None: + self._blob_writer.check(owner_id=self._product, session_id=self._context.header.conversation_id) + + def _close_blob_writer(self) -> None: + assert self._blob_writer is not None + if self._blob_writer.claimed_owner is self: + self._blob_writer._close_claimed(self) + else: + # A failed acquisition may precede claim; this is still the same + # exclusively constructed lease, never an externally supplied one. + self._blob_writer.close() + + def _retain_disposer(self, disposer: AsyncDisposer) -> None: + self._on_loop() + if asyncio.current_task() is not self._driver: + raise TranscriptWriterError("closed") + if not self._retain_open or self._retained_disposer is not None: + raise TranscriptWriterError("closed") + if not callable(disposer): + raise TranscriptWriterError("invalid") + self._retained_disposer = disposer + + @contextmanager + def _admission_scope(self, target: ConversationKey | str) -> Iterator[None]: + self._on_loop() + cleanup_admission = ( + type(target) is ConversationKey and self._import_delete_task is not None + and asyncio.current_task() is self._import_delete_task + ) + if self._closing and not cleanup_admission: + raise TranscriptWriterError("closed") + expected = ConversationKey(str(self._context.session_dir), self._context.header.conversation_id) + if ((type(target) is ConversationKey and target != expected) + or (type(target) is str and target != expected.namespace) + or type(target) not in (ConversationKey, str)): + raise TranscriptWriterError("conflict") + self._active_io += 1 + self._io_drained.clear() + try: + yield + finally: + self._active_io -= 1 + if self._active_io == 0: + self._io_drained.set() + + @asynccontextmanager + async def _operation_scope(self, target: ConversationKey | str) -> AsyncIterator[None]: + with self._admission_scope(target): + await settled_io(self._check_writers) + yield + + @contextmanager + def _sync_operation_scope(self, target: ConversationKey | str) -> Iterator[None]: + """Admit existing synchronous consumers on the owning event loop only.""" + with self._admission_scope(target): + self._check_writers() + yield + + def _fence_unpublished(self) -> None: + """Let the retaining factory stop every undelivered construction first.""" + self._on_loop() + if self._session is not None and self._session.ownership_state != "root_owned": + raise TranscriptWriterError("conflict") + self._closing = True + + async def dispose(self) -> None: + self._on_loop() + if self._session is not None: + await self._session.dispose() # Graph ownership is authoritative; no root fence first. + return + self._closing = True + if self._driver is not None: + try: + await asyncio.shield(self._driver) + except asyncio.CancelledError: + if not self._driver.cancelled(): + raise + self._unknown = True + except Exception: + pass # Binding, if returned, is retained below for explicit cleanup. + if self._session is not None: + await self._session.dispose() + else: + await self._dispose_bound_resources() + + async def _dispose_bound_resources(self) -> None: + self._on_loop() + self._closing = True + async with self._cleanup_lock: + await self._io_drained.wait() + await self._dispose_stages() + + async def _dispose_runtime(self) -> None: + if self._retained_disposer is not None: + await self._retained_disposer() + else: + assert self._runtime is not None + await self._runtime.dispose() + + def _rollback_import(self) -> None: + publication = self._publication + assert publication is not None and not self._preserve_publication + if publication.rollback_delegated: + assert self._blob_file_io is not None + self._blob_file_io.cleanup() + if not publication.rollback_complete: + raise TranscriptWriterError("unavailable") + elif not publication.rollback(): + raise TranscriptWriterError("conflict") + + def _check_import_target(self) -> None: + self._check_writers() + assert self._file_io is not None and self._context.session_file is not None + status = self._file_io.stat(self._context.session_file) + if (status.st_dev, status.st_ino) != self._import_identity: + raise TranscriptWriterError("conflict") + + def _require_fresh_import_path(self) -> None: + assert self._file_io is not None and self._context.session_file is not None + try: + self._file_io.stat(self._context.session_file) + except FileNotFoundError: + pass + else: + raise StoreAlreadyExistsError("import destination already exists") + layout = AgentTranscriptFileLayout(self._context.session_dir, file_io=self._file_io) + scan = layout.scan_candidate_path_snapshot(layout.namespace, raise_on_error=True) + if not scan.complete: + raise TranscriptWriterError("conflict") + for path in scan.paths: + header = load_agent_transcript_header(path, read_only=True, file_io=self._file_io) + if header.conversation_id == self._context.header.conversation_id: + raise StoreAlreadyExistsError("import conversation identity already exists") + + async def _delete_unpublished_import(self) -> None: + assert self._runtime is not None and self._import_revision is not None + assert self._import_delete_operation is not None + assert self._import_identity is not None + try: + store = self._runtime.store + if not isinstance(store, FileConversationStore): + raise StoreDataError("import rollback requires the original rooted FileStore") + await settled_io(self._check_import_target) + receipt = await store.delete( + self._runtime.key, expected_revision=self._import_revision, + operation_id=self._import_delete_operation, + expected_file_identity=self._import_identity, + ) + if (type(receipt) is not DeletionReceipt + or receipt.operation_id != self._import_delete_operation + or receipt.revision != self._import_revision): + raise StoreCommitOutcomeUnknown("import rollback deletion receipt is invalid") + except StoreDataError: + # The original Store classifies pre-commit data failures separately + # from unknown commits. Only this known failure can retry its op. + raise + except BaseException: + self._import_unknown = True + raise + self._import_delete_receipt = receipt + self._preserve_publication = False + + async def _settle_import_rollback(self) -> None: + if self._import_create_operation is None or self._import_delivered: + return + if self._import_unknown: + raise TranscriptWriterError("unavailable") + if self._import_revision is None or self._import_delete_receipt is not None: + return + task = self._import_delete_task + if task is not None and task.cancelled(): + self._import_unknown = True + raise TranscriptWriterError("unavailable") + if task is None or (task.done() and task.exception() is not None): + self._import_delete_task = _task(self._delete_unpublished_import()) + assert self._import_delete_task is not None + await asyncio.shield(self._import_delete_task) + + async def _dispose_stages(self) -> None: + if self._binding_started and self._runtime is None and self._retained_disposer is None: + self._unknown = True + if self._unknown: + raise TranscriptWriterError("unavailable") + if (self._runtime is not None or self._retained_disposer is not None) and not self._runtime_done: + if self._runtime_task is not None and self._runtime_task.cancelled(): + self._unknown = True + raise TranscriptWriterError("unavailable") + if self._runtime_task is None or (self._runtime_task.done() and self._runtime_task.exception() is not None): + self._runtime_task = _task(self._dispose_runtime()) + await asyncio.shield(self._runtime_task) + self._runtime_done = True + await self._settle_import_rollback() + if ((self._publication is not None and not self._preserve_publication) + or self._publication_rollback_task is not None): + if self._publication_rollback_task is not None and self._publication_rollback_task.cancelled(): + self._unknown = True + raise TranscriptWriterError("unavailable") + if (self._publication_rollback_task is None + or (self._publication_rollback_task.done() and self._publication_rollback_task.exception() is not None)): + self._publication_rollback_task = _task(settled_io(self._rollback_import)) + await asyncio.shield(self._publication_rollback_task) + self._publication = None + if self._file_io is not None: + if self._io_cleanup_task is not None and self._io_cleanup_task.cancelled(): + self._unknown = True + raise TranscriptWriterError("unavailable") + if (self._io_cleanup_task is None + or (self._io_cleanup_task.done() and self._io_cleanup_task.exception() is not None)): + self._io_cleanup_task = _task(settled_io(self._file_io.cleanup)) + await asyncio.shield(self._io_cleanup_task) + if self._file_io.cleanup_pending: + raise TranscriptWriterError("unavailable") + if self._blob_file_io is not None: + if self._blob_io_cleanup_task is not None and self._blob_io_cleanup_task.cancelled(): + self._unknown = True + raise TranscriptWriterError("unavailable") + if (self._blob_io_cleanup_task is None or + (self._blob_io_cleanup_task.done() and self._blob_io_cleanup_task.exception() is not None)): + self._blob_io_cleanup_task = _task(settled_io(self._blob_file_io.cleanup)) + await asyncio.shield(self._blob_io_cleanup_task) + if self._blob_file_io.cleanup_pending: + raise TranscriptWriterError("unavailable") + if self._blob_writer is not None: + if self._store_admission is not None: + if self._admission_cleanup_task is not None and self._admission_cleanup_task.cancelled(): + self._unknown = True + raise TranscriptWriterError("unavailable") + if (self._admission_cleanup_task is None or + (self._admission_cleanup_task.done() and self._admission_cleanup_task.exception() is not None)): + self._admission_cleanup_task = _task(settled_io(self._store_admission.close)) + await asyncio.shield(self._admission_cleanup_task) + if self._blob_close_task is not None and self._blob_close_task.cancelled(): + self._unknown = True + raise TranscriptWriterError("unavailable") + if (self._blob_close_task is None or + (self._blob_close_task.done() and self._blob_close_task.exception() is not None)): + self._blob_close_task = _task(settled_io(self._close_blob_writer)) + await asyncio.shield(self._blob_close_task) + if self._close_task is not None and self._close_task.cancelled(): + self._unknown = True + raise TranscriptWriterError("unavailable") + if self._close_task is None or (self._close_task.done() and self._close_task.exception() is not None): + self._close_task = _task(settled_io(self._close_writer)) + await asyncio.shield(self._close_task) + + +def _task(work: Coroutine[object, object, TaskT]) -> asyncio.Task[TaskT]: + gate = asyncio.get_running_loop().create_future() + + async def invoke() -> TaskT: + await gate + return await work + + invocation = invoke() + try: + task = asyncio.create_task(invocation) + except BaseException: + gate.cancel() + invocation.close() + work.close() + raise + + def finished(task: asyncio.Task[TaskT]) -> None: + work.close() + if not task.cancelled(): + task.exception() + + task.add_done_callback(finished) + gate.set_result(None) + return task diff --git a/tests/harness/transcript/_maintenance.py b/tests/harness/transcript/_maintenance.py new file mode 100644 index 000000000..13e9b0a02 --- /dev/null +++ b/tests/harness/transcript/_maintenance.py @@ -0,0 +1,19 @@ +"""Test-scoped retained maintenance for public transcript deletion tests.""" + +import sys + +from loushang.harness.transcript import delete_agent_transcript_jsonl + + +async def delete_with_maintenance(path, *, current_session_file=None): + from .test_owned_session_factory import factory + + if sys.platform != "linux": + return await delete_agent_transcript_jsonl(path, current_session_file=current_session_file) + owner = factory() + try: + return await delete_agent_transcript_jsonl( + path, current_session_file=current_session_file, maintenance_owner=owner, + ) + finally: + await owner.close() diff --git a/tests/harness/transcript/_writer_child.py b/tests/harness/transcript/_writer_child.py new file mode 100644 index 000000000..df56daa2e --- /dev/null +++ b/tests/harness/transcript/_writer_child.py @@ -0,0 +1,183 @@ +"""Controlled real-process/fork writer fixture; never a production entry.""" + +import json +import os +import signal +import sys +import threading +from pathlib import Path + +from loushang.harness.journal import _directory_lease as module +from loushang.harness.transcript.writer_lease import ( + TranscriptWriterError, + TranscriptWriterLease, +) + + +def output(stage, **facts): + print(json.dumps({"stage": stage, **facts}), flush=True) + + +def main(): + mode = sys.argv[2] + if mode == "create_pause": + create_pause(Path(sys.argv[1])) + return + owner = TranscriptWriterLease(Path(sys.argv[1]), "coding", "one") + owner.acquire() + if mode == "unstable_fork": + unstable_fork(owner) + output("verified") + return + if mode == "orphan": + pid = os.fork() + if pid: + output("orphan", pid=pid) + os._exit(0) # Test abrupt parent exit, without closing child copies. + signal.alarm(20) # Failure-only watchdog for an otherwise orphan fixture. + elif mode == "fork_close": + entered, release = threading.Event(), threading.Event() + + def hold(): + with owner._mutex: + entered.set() + release.wait(15) + + thread = threading.Thread(target=hold) + thread.start() + assert entered.wait(5) + pid = os.fork() + if pid == 0: + signal.alarm(8) + try: + try: + owner.check(product_id="coding", conversation_id="one") + except TranscriptWriterError as error: + assert error.code == "unsupported" + else: + raise AssertionError("fork child gained writer authority") + owner.close_inherited() # Must not enter the inherited held mutex. + assert not owner.cleanup_pending + except BaseException: + os._exit(2) + os._exit(0) + waited, status = os.waitpid(pid, 0) + release.set() + thread.join(2) + assert waited == pid and status == 0 and not thread.is_alive() + owner.check(product_id="coding", conversation_id="one") + output("held") + else: + output("held") + try: + for command in sys.stdin: + command = command.strip() + if command == "release": + if mode == "orphan": + try: + owner.check(product_id="coding", conversation_id="one") + except TranscriptWriterError as error: + assert error.code == "unsupported" + else: + raise AssertionError("fork child gained writer authority") + owner.close_inherited() + else: + owner.close() + output("released") + elif command == "crash": + os._exit(3) # No finally or owner.close: sole-holder crash evidence. + elif command == "quit": + break + else: + raise AssertionError("unknown test command") + finally: + if mode == "orphan": + owner.close_inherited() + else: + owner.close() + + +def create_pause(root): + """Pause after real mkdir, before binding/sync, to expose the EEXIST race.""" + signal.alarm(15) + owner = TranscriptWriterLease(root, "coding", "one", create_root=True) + mkdir = os.mkdir + + def paused(path, *args, **kwargs): + result = mkdir(path, *args, **kwargs) + if path == root.name: + output("created") + assert sys.stdin.readline().strip() == "continue" + return result + + module.os.mkdir = paused + try: + try: + owner.acquire() + except TranscriptWriterError as error: + assert error.code == "busy" + assert not owner._held and owner.claimed_owner is None + output("busy") + else: + raise AssertionError("both creators admitted the same writer") + finally: + module.os.mkdir = mkdir + owner.close() + assert not owner.cleanup_pending + + +def unstable_fork(owner): + original = os.close + descriptor = owner._fds["lock"] + entered, release = threading.Event(), threading.Event() + replacements, failures = [], [] + + def close(fd): + original(fd) + if fd == descriptor: + replacement = os.open("/dev/null", os.O_RDONLY) + assert replacement == descriptor + replacements.append(replacement) + entered.set() + assert release.wait(12) + + def dispose(): + try: + owner.close() + except BaseException as error: + failures.append(error) + + module.os.close = close + thread = threading.Thread(target=dispose) + thread.start() + try: + assert entered.wait(5) + pid = os.fork() + if pid == 0: + signal.alarm(8) + try: + try: + owner.close_inherited() + except TranscriptWriterError as error: + assert error.code == "unavailable" + else: + raise AssertionError("unstable fork snapshot was closed") + assert owner.cleanup_pending + os.fstat(descriptor) # Unrelated inherited fd was not closed. + except BaseException: + os._exit(2) + os._exit(0) + waited, status = os.waitpid(pid, 0) + assert waited == pid and status == 0 + finally: + release.set() + thread.join(3) + module.os.close = original + assert not thread.is_alive() + for fd in replacements: + original(fd) + assert not failures and not owner.cleanup_pending + + +if __name__ == "__main__": + main() diff --git a/tests/harness/transcript/test_catalog.py b/tests/harness/transcript/test_catalog.py index 1a7316ac7..9cdba32ef 100644 --- a/tests/harness/transcript/test_catalog.py +++ b/tests/harness/transcript/test_catalog.py @@ -3,6 +3,7 @@ import asyncio import json import os +from dataclasses import replace from pathlib import Path import pytest @@ -285,6 +286,36 @@ def test_catalog_upsert_refuses_duplicate_physical_identity_and_invalidates_inde assert catalog.index_path.exists() is False +def test_duplicate_identity_rejection_preserves_a_newer_index_version(tmp_path, monkeypatch): + first = tmp_path / "first.jsonl" + write_agent_transcript_export( + first, _header("shared", cwd="/workspace"), [_record("record", "first")], + ) + catalog = AgentTranscriptSessionCatalog(tmp_path) + catalog.refresh_index() + summary = catalog.load_index()[0] + write_agent_transcript_export( + tmp_path / "duplicate.jsonl", _header("shared", cwd="/workspace"), + [_record("duplicate", "different")], + ) + original = catalog._validated_unique_authority_snapshot + successor = None + + def publish_successor_then_detect_collision(): + nonlocal successor + index = catalog._projection_index() + rows = catalog_module._run_catalog(index.query(SessionQuery())) + catalog_module._run_catalog(index.replace(rows)) + successor = catalog.index_path.read_bytes() + return original() + + monkeypatch.setattr(catalog, "_validated_unique_authority_snapshot", publish_successor_then_detect_collision) + with pytest.raises(RuntimeError, match="duplicate identities"): + asyncio.run(catalog.upsert_summary(summary, source_revision=summary.entry_count)) + assert successor is not None + assert catalog.index_path.read_bytes() == successor + + def test_catalog_repair_revalidates_authority_before_publish( tmp_path: Path, monkeypatch, @@ -510,7 +541,7 @@ def test_bounded_index_refresh_revalidates_authority_after_publish( [_record("record", "prompt")], ) catalog = AgentTranscriptSessionCatalog(tmp_path) - original_replace = catalog_module.JsonConversationIndex.replace + original_replace = catalog_module.JsonConversationIndex.replace_with_receipt async def replace_then_add_duplicate(index, items): published = await original_replace(index, items) @@ -523,7 +554,7 @@ async def replace_then_add_duplicate(index, items): monkeypatch.setattr( catalog_module.JsonConversationIndex, - "replace", + "replace_with_receipt", replace_then_add_duplicate, ) @@ -532,6 +563,145 @@ async def replace_then_add_duplicate(index, items): assert catalog.index_path.exists() is False +@pytest.mark.parametrize("refresh", ["refresh_bounded_index", "refresh_index", "repair_index"]) +def test_refresh_failure_preserves_another_writers_publication(tmp_path, monkeypatch, refresh): + write_agent_transcript_export( + tmp_path / "session.jsonl", _header("session", cwd="/workspace"), + [_record("record", "prompt")], + ) + catalog = AgentTranscriptSessionCatalog(tmp_path) + if refresh == "repair_index": + catalog.refresh_index() + write_agent_transcript_export( + tmp_path / "session.jsonl", _header("session", cwd="/workspace"), + [_record("record", "prompt"), _record("next", "updated", parent_id="record")], + ) + original = catalog_module.JsonConversationIndex.replace_with_receipt + successor = None + + async def publish_then_race(index, items): + nonlocal successor + result = await original(index, items) + write_agent_transcript_export( + tmp_path / "new.jsonl", _header("new", cwd="/workspace"), + [_record("new-record", "new session")], + ) + await original(index, items) # Independent successor generation. + successor = index.path.read_bytes() + return result # The failed refresh owns only the older receipt. + + monkeypatch.setattr(catalog_module.JsonConversationIndex, "replace_with_receipt", publish_then_race) + with pytest.raises(RuntimeError, match="changed"): + getattr(catalog, refresh)() + assert successor is not None + assert catalog.index_path.read_bytes() == successor + + +@pytest.mark.parametrize("no_change", [False, True]) +def test_upsert_postcheck_failure_preserves_other_publication(tmp_path, monkeypatch, no_change): + write_agent_transcript_export( + tmp_path / "session.jsonl", _header("session", cwd="/workspace"), + [_record("record", "prompt")], + ) + catalog = AgentTranscriptSessionCatalog(tmp_path) + catalog.refresh_index() + summary = catalog.load_index()[0] + original = catalog_module.JsonConversationIndex.upsert_with_receipt + retained = None + + async def publish_then_race(index, item): + nonlocal retained + if no_change: + # A newer revision wins first; the attempted stale upsert owns no receipt. + await original(index, replace(item, source_revision=item.source_revision + 1)) + result = await original(index, item) + assert result == (False, None) + else: + result = await original(index, item) + assert result[0] is True and result[1] is not None + await original(index, item) # A successor publication, not our receipt. + retained = index.path.read_bytes() + write_agent_transcript_export( + tmp_path / "new.jsonl", _header("new", cwd="/workspace"), + [_record("new-record", "new session")], + ) + return result + + monkeypatch.setattr(catalog_module.JsonConversationIndex, "upsert_with_receipt", publish_then_race) + with pytest.raises(RuntimeError, match="authority changed during index upsert"): + asyncio.run(catalog.upsert_summary(summary, source_revision=summary.entry_count)) + assert retained is not None + assert catalog.index_path.read_bytes() == retained + assert catalog.try_query_index_snapshot().index_state == "stale" + + +def test_unchanged_repair_postcheck_failure_does_not_invalidate_cache(tmp_path, monkeypatch): + write_agent_transcript_export( + tmp_path / "session.jsonl", _header("session", cwd="/workspace"), + [_record("record", "prompt")], + ) + catalog = AgentTranscriptSessionCatalog(tmp_path) + catalog.refresh_index() + before = catalog.index_path.read_bytes() + original = catalog._validated_unique_authority_snapshot + calls = 0 + + def race_after_validation(): + nonlocal calls + result = original() + calls += 1 + if calls == 2: + write_agent_transcript_export( + tmp_path / "new.jsonl", _header("new", cwd="/workspace"), + [_record("new-record", "new session")], + ) + return result + + def forbidden_refresh(): + raise AssertionError("postcheck failure must not trigger another publication") + + monkeypatch.setattr(catalog, "_validated_unique_authority_snapshot", race_after_validation) + monkeypatch.setattr(catalog, "refresh_index", forbidden_refresh) + with pytest.raises(RuntimeError, match="changed"): + catalog.repair_index() + assert catalog.index_path.read_bytes() == before + assert catalog.try_query_index_snapshot().index_state == "stale" + + +def test_repair_lost_publication_receipt_does_not_retry_or_delete(tmp_path, monkeypatch): + write_agent_transcript_export( + tmp_path / "session.jsonl", _header("session", cwd="/workspace"), + [_record("record", "prompt")], + ) + catalog = AgentTranscriptSessionCatalog(tmp_path) + catalog.refresh_index() + write_agent_transcript_export( + tmp_path / "session.jsonl", _header("session", cwd="/workspace"), + [_record("record", "prompt"), _record("next", "updated", parent_id="record")], + ) + original = catalog_module.JsonConversationIndex.replace_with_receipt + published = None + calls = 0 + + async def lose_receipt(index, items): + nonlocal published, calls + calls += 1 + await original(index, items) + published = index.path.read_bytes() + raise RuntimeError("injected lost publication receipt") + + def forbidden_refresh(): + raise AssertionError("unknown publication must not trigger retry") + + monkeypatch.setattr(catalog_module.JsonConversationIndex, "replace_with_receipt", lose_receipt) + monkeypatch.setattr(catalog, "refresh_index", forbidden_refresh) + with pytest.raises(RuntimeError, match="lost publication receipt"): + catalog.repair_index() + assert calls == 1 + assert published is not None + assert catalog.index_path.read_bytes() == published + + def test_index_freshness_can_ignore_only_the_active_transcript( tmp_path: Path, ) -> None: @@ -691,9 +861,10 @@ def test_targeted_path_projection_never_replays_unrelated_large_transcript( original = catalog_module.load_agent_transcript_file loaded: list[Path] = [] - def record_load(path: Path, *, max_bytes: int | None = None): + def record_load(path: Path, *, max_bytes: int | None = None, read_only: bool = False): loaded.append(path) - return original(path, max_bytes=max_bytes) + assert read_only + return original(path, max_bytes=max_bytes, read_only=read_only) monkeypatch.setattr(catalog_module, "load_agent_transcript_file", record_load) diff --git a/tests/harness/transcript/test_delete_maintenance_owner.py b/tests/harness/transcript/test_delete_maintenance_owner.py new file mode 100644 index 000000000..e6c05a6bb --- /dev/null +++ b/tests/harness/transcript/test_delete_maintenance_owner.py @@ -0,0 +1,73 @@ +import asyncio +import sys +from pathlib import Path + +import pytest + +from loushang.harness.transcript import delete_agent_transcript_jsonl +from loushang.harness.transcript.product_session import ProductTranscriptSession +from loushang.harness.transcript.writer_lease import TranscriptWriterError + +from .test_owned_session_factory import factory, new +from .test_writer_images import message + + +@pytest.mark.skipif(sys.platform != "linux", reason="Linux shared writer admission") +@pytest.mark.parametrize("entry", [delete_agent_transcript_jsonl, ProductTranscriptSession.delete_session]) +def test_unowned_delete_rejects_before_any_path_access(monkeypatch, entry): + monkeypatch.setattr(Path, "expanduser", lambda *_: pytest.fail("unowned path access")) + with pytest.raises(ValueError, match="retained maintenance_owner"): + asyncio.run(entry("missing.jsonl")) + + +@pytest.mark.parametrize("entry", [delete_agent_transcript_jsonl, ProductTranscriptSession.delete_session]) +@pytest.mark.parametrize("failure", [False, True]) +def test_public_delete_delegates_to_original_retained_owner(entry, failure, monkeypatch): + calls = [] + error = OSError("cleanup pending") + + class Owner: + async def delete_transcript(self, path, *, current_session_file): + calls.append((path, current_session_file)) + if failure: + raise error + return True + + monkeypatch.setattr(Path, "expanduser", lambda *_: pytest.fail("facade must not access paths")) + operation = entry("session.jsonl", current_session_file="active.jsonl", maintenance_owner=Owner()) + if failure: + with pytest.raises(OSError) as caught: + asyncio.run(operation) + assert caught.value is error + else: + assert asyncio.run(operation) is True + assert calls == [("session.jsonl", "active.jsonl")] + + +@pytest.mark.skipif(sys.platform != "linux", reason="Linux shared writer admission") +@pytest.mark.parametrize("entry", [delete_agent_transcript_jsonl, ProductTranscriptSession.delete_session]) +def test_public_delete_obeys_live_writer_and_preserves_recoverable_blobs(tmp_path, entry): + async def scenario(): + root = tmp_path / "sessions" + root.mkdir(mode=0o700) + writer, maintenance = factory(), factory() + session = await new(writer, root) + try: + await ProductTranscriptSession(lifecycle_session=session).append_message(message()) + path = session.context.session_file + assets = tmp_path / "session-assets" / session.context.header.conversation_id + before = {item.relative_to(assets): item.read_bytes() for item in assets.rglob("*") if item.is_file()} + assert before + with pytest.raises(TranscriptWriterError, match="busy"): + await entry(path, maintenance_owner=maintenance) + assert path.is_file() + await session.dispose() + assert await entry(path, maintenance_owner=maintenance) + assert not path.exists() + assert before == {item.relative_to(assets): item.read_bytes() for item in assets.rglob("*") if item.is_file()} + finally: + await session.dispose() + await writer.close() + await maintenance.close() + + asyncio.run(scenario()) diff --git a/tests/harness/transcript/test_directory.py b/tests/harness/transcript/test_directory.py index d6b1dd610..3a05c3286 100644 --- a/tests/harness/transcript/test_directory.py +++ b/tests/harness/transcript/test_directory.py @@ -21,7 +21,6 @@ SessionDiscoverySource, SessionQuery, SessionSummary, - delete_agent_transcript_jsonl, write_agent_transcript_export, ) @@ -614,8 +613,8 @@ def test_canonical_delete_tombstone_prevents_compatibility_resurrection( ) -> None: canonical_dir = tmp_path / "canonical" compatibility_dir = tmp_path / "compatibility" - canonical_dir.mkdir() - compatibility_dir.mkdir() + canonical_dir.mkdir(mode=0o700) + compatibility_dir.mkdir(mode=0o700) canonical = canonical_dir / "canonical.jsonl" compatibility = compatibility_dir / "legacy.jsonl" write_agent_transcript_export( @@ -630,7 +629,8 @@ def test_canonical_delete_tombstone_prevents_compatibility_resurrection( summary.session_id for summary in runtime.list_discovered_session_summaries() ] == ["deleted"] - assert asyncio.run(delete_agent_transcript_jsonl(canonical)) is True + from ._maintenance import delete_with_maintenance + assert asyncio.run(delete_with_maintenance(canonical)) is True assert runtime.session_catalog.is_tombstoned("deleted") is True assert runtime.list_discovered_session_summaries() == [] @@ -642,8 +642,8 @@ def test_invalid_canonical_tombstone_fails_closed_with_discovery_issue( ) -> None: canonical_dir = tmp_path / "canonical" compatibility_dir = tmp_path / "compatibility" - canonical_dir.mkdir() - compatibility_dir.mkdir() + canonical_dir.mkdir(mode=0o700) + compatibility_dir.mkdir(mode=0o700) canonical = canonical_dir / "canonical.jsonl" write_agent_transcript_export( canonical, @@ -653,7 +653,8 @@ def test_invalid_canonical_tombstone_fails_closed_with_discovery_issue( (compatibility_dir / "legacy.jsonl").write_bytes(canonical.read_bytes()) runtime = AgentTranscriptDirectoryRuntime(session_dir=canonical_dir) runtime.add_session_discovery_dir(compatibility_dir) - assert asyncio.run(delete_agent_transcript_jsonl(canonical)) is True + from ._maintenance import delete_with_maintenance + assert asyncio.run(delete_with_maintenance(canonical)) is True runtime.session_catalog.tombstone_path("deleted").write_text( "not-json\n", encoding="utf-8", @@ -729,7 +730,8 @@ def test_duplicate_canonical_identity_is_conflicted_and_tombstone_hides_residual assert page.items[0].item.projection.discovery is not None assert page.items[0].item.projection.discovery.health == "conflict" - assert asyncio.run(delete_agent_transcript_jsonl(first)) is True + from ._maintenance import delete_with_maintenance + assert asyncio.run(delete_with_maintenance(first)) is True assert second.exists() assert runtime.list_discovered_session_summaries() == [] @@ -761,7 +763,8 @@ def test_canonical_only_index_applies_provenance_and_tombstones_before_filtering _header("canonical", cwd="/workspace/project"), [_record("removed", "removed", timestamp=2.0)], ) - assert asyncio.run(delete_agent_transcript_jsonl(removed_duplicate)) is True + from ._maintenance import delete_with_maintenance + assert asyncio.run(delete_with_maintenance(removed_duplicate)) is True assert runtime.try_query_session_index_page(limit=10).items == () diff --git a/tests/harness/transcript/test_lifecycle.py b/tests/harness/transcript/test_lifecycle.py index dfa0c3cb2..ce7751257 100644 --- a/tests/harness/transcript/test_lifecycle.py +++ b/tests/harness/transcript/test_lifecycle.py @@ -20,7 +20,6 @@ AgentTranscriptRuntimeBinding, ModelSelectionSnapshot, create_agent_transcript_file_store, - delete_agent_transcript_jsonl, write_agent_transcript_export, ) @@ -345,14 +344,15 @@ async def dispose() -> None: assert disposed == ["failed"] source = tmp_path / "deletable.jsonl" + from ._maintenance import delete_with_maintenance write_agent_transcript_export(source, _header(), [_record("record-1")]) with pytest.raises(ValueError, match="currently active"): - await delete_agent_transcript_jsonl( + await delete_with_maintenance( source, current_session_file=source, ) - assert await delete_agent_transcript_jsonl(source) + assert await delete_with_maintenance(source) assert not source.exists() - assert not await delete_agent_transcript_jsonl(source) + assert not await delete_with_maintenance(source) asyncio.run(scenario()) diff --git a/tests/harness/transcript/test_owned_bundle_import.py b/tests/harness/transcript/test_owned_bundle_import.py new file mode 100644 index 000000000..8bdaac1a9 --- /dev/null +++ b/tests/harness/transcript/test_owned_bundle_import.py @@ -0,0 +1,423 @@ +from __future__ import annotations + +import asyncio +import sys +import threading + +import pytest + +from loushang.harness.artifacts import SessionBlobPublication, SessionBlobStore +from loushang.harness.conversation import StoreCommitOutcomeUnknown +from loushang.harness.conversation.stores import file as file_module +from loushang.harness.journal import _rooted_io as native +from loushang.harness.transcript import ( + ProductTranscriptSession, + unit_of_work, + writer_lifecycle, +) +from loushang.harness.transcript.export import ( + export_agent_transcript_bundle, + read_agent_transcript_bundle, +) +from loushang.harness.transcript.writer_lease import TranscriptWriterError + +from .test_owned_session_factory import factory, new +from .test_runtime_profile import _runtime +from .test_writer_blobs import blob_busy +from .test_writer_images import message +from .test_writer_io import wait_until +from .test_writer_lifecycle import assert_available, assert_busy +from .test_writer_root_binding import tree + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux owned import") + + +async def bundle(tmp_path, *, images=True): + source = tmp_path / "source" / "sessions" + source.parent.mkdir(mode=0o700) + source.mkdir(mode=0o700) + selected = factory() + session = await new(selected, source) + try: + if images: + await ProductTranscriptSession(lifecycle_session=session).append_message(message()) + finally: + await session.dispose() + await selected.close() + archive = tmp_path / "session.zip" + export_agent_transcript_bundle( + session.context.header, session.transcript.records, + session_dir=source, output_path=archive, allow_private=True, + ) + target = tmp_path / "target" / "sessions" + target.parent.mkdir(mode=0o700) + target.mkdir(mode=0o700) + return archive, target + + +@pytest.mark.parametrize("images", [False, True]) +def test_real_owned_bundle_import_roundtrip(tmp_path, monkeypatch, images): + async def scenario(): + archive, target = await bundle(tmp_path, images=images) + selected = factory() + actual = writer_lifecycle.SessionBlobStore + ports = [] + + def rooted(*args, **kwargs): + retained, = selected.pending_preparations + assert kwargs["file_io"] is retained.blob_file_io + assert retained._writer.claimed_owner is retained + assert retained._blob_writer.claimed_owner is retained + ports.append(kwargs["file_io"]) + return actual(*args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(writer_lifecycle, "SessionBlobStore", rooted) + session = await selected.import_bundle(archive, session_dir=target) + try: + assert session.context.session_file.exists() + assert len(ports) == int(images) + assert not selected.pending_preparations + if images: + assert session.session_blob_health[0].state == "available" + context = ProductTranscriptSession(lifecycle_session=session).build_session_context() + assert context.messages[0].content[0].data == "aGVsbG8=" + finally: + await session.dispose() + await selected.close() + + asyncio.run(scenario()) + + +def test_busy_import_does_not_publish_attachments(tmp_path, monkeypatch): + async def scenario(): + archive, target = await bundle(tmp_path) + first, selected = factory(), factory() + held = await new(first, target) + before = tree(target.parent) + + def forbidden(*args, **kwargs): + raise AssertionError("busy import attempted publication") + + try: + with monkeypatch.context() as patch: + patch.setattr(writer_lifecycle, "SessionBlobStore", forbidden) + with pytest.raises(TranscriptWriterError, match="busy"): + await selected.import_bundle(archive, session_dir=target) + assert tree(target.parent) == before + assert not selected.pending_preparations + finally: + await held.dispose() + await first.close() + await selected.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("failure", ["binder", "post_write", "snapshot", "projection", "health"]) +def test_import_distinguishes_precommit_and_committed_failures(tmp_path, monkeypatch, failure): + async def scenario(): + archive, target = await bundle(tmp_path) + runtime = _runtime("coding") + + async def fail_binding(context, profile, owner): + await runtime.bind_lifecycle_owned(context, profile, owner) + raise ValueError("test before transcript creation") + + selected = factory(runtime=runtime, bind=fail_binding if failure == "binder" else None) + write = file_module._write_unlocked + + def after_write(*args, **kwargs): + write(*args, **kwargs) + raise OSError("test write completed but reply failed") + + def projection(*args, **kwargs): + raise ValueError("test local projection failed") + + with monkeypatch.context() as patch: + if failure == "post_write": + patch.setattr(file_module, "_write_unlocked", after_write) + elif failure == "projection": + patch.setattr(unit_of_work.ConversationRepository, "from_snapshot", projection) + elif failure == "snapshot": + patch.setattr(file_module, "ConversationSnapshot", projection) + elif failure == "health": + patch.setattr(writer_lifecycle, "_lifecycle_session", projection) + with pytest.raises(StoreCommitOutcomeUnknown if failure in {"post_write", "snapshot", "projection"} else ValueError): + await selected.import_bundle(archive, session_dir=target) + assert not selected.pending_preparations + await selected.close() + if failure == "binder": + assert not (target.parent / "session-assets/conversation-1").exists() + assert not (target / "conversation-1.jsonl").exists() + else: + reader = factory() + restored = await reader.open(target / "conversation-1.jsonl") + try: + assert restored.session_blob_health[0].state == "available" + finally: + await restored.dispose() + await reader.close() + + asyncio.run(scenario()) + + +def test_create_native_close_loss_does_not_rollback_referenced_attachments(tmp_path, monkeypatch): + async def scenario(): + archive, target = await bundle(tmp_path) + selected = factory() + write, close = file_module._write_unlocked, native.os.close + written, lost = [], [] + + def published(*args, **kwargs): + result = write(*args, **kwargs) + written.append(True) + return result + + def lost_close(fd): + if written and not lost: + name = native.os.readlink(f"/proc/self/fd/{fd}") + if name == str(target / "conversation-1.jsonl.lock"): + close(fd) + lost.append(fd) + raise OSError("test native close reply lost") + return close(fd) + + with monkeypatch.context() as patch: + patch.setattr(file_module, "_write_unlocked", published) + patch.setattr(native.os, "close", lost_close) + with pytest.raises(StoreCommitOutcomeUnknown): + await selected.import_bundle(archive, session_dir=target) + retained, = selected.pending_preparations + assert len(lost) == 1 and retained._preserve_publication + assert_busy(target) + store = SessionBlobStore(target.parent, "conversation-1") + assert store.read_bytes(store.records[0]) == b"hello" + with pytest.raises(OSError, match="unknown"): + await selected.close() + # Test-only reconciliation: this injector knows close() returned before + # it fabricated a lost reply. Remove only that known receipt; never + # retry its numeric fd. Production remains fail-closed without proof. + removed = [] + for operation in retained._file_io._operations: + for fd in lost: + if fd in operation.descriptors: + assert operation.descriptors.pop(fd) is True + removed.append(fd) + assert removed == lost + await selected.close() + assert not selected.pending_preparations + + asyncio.run(scenario()) + + +def test_import_rollback_debt_resumes_original_settlement_without_replaying(tmp_path, monkeypatch): + async def scenario(): + archive, target = await bundle(tmp_path) + runtime = _runtime("coding") + + async def fail_binding(context, profile, owner): + await runtime.bind_lifecycle_owned(context, profile, owner) + raise ValueError("test binder rejects after attachment publication") + + selected = factory(runtime=runtime, bind=fail_binding) + rmdir = native.os.rmdir + + def unavailable(name, *args, **kwargs): + if name == "objects": + raise OSError("test retained deletion failure") + return rmdir(name, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(native.os, "rmdir", unavailable) + with pytest.raises(ValueError, match="binder rejects"): + await selected.import_bundle(archive, session_dir=target) + retained, = selected.pending_preparations + receipt = retained._publication + assert receipt.rollback_delegated and not receipt.rollback_complete + assert_busy(target) + with pytest.raises(OSError, match="retained deletion failure"): + await selected.close() + assert selected.pending_preparations == (retained,) + await selected.close() + assert receipt.rollback_complete and not selected.pending_preparations + assert not (target.parent / "session-assets/conversation-1").exists() + assert_available(target) + + asyncio.run(scenario()) + + +def test_initial_attachment_intent_is_frozen_before_acquire(tmp_path): + async def scenario(): + archive, target = await bundle(tmp_path) + contents = read_agent_transcript_bundle(archive) + selected, runtime = factory(), _runtime("coding") + context = selected._new_context(session_dir=target, cwd="/workspace", persist=True, header=contents.header) + payload = bytearray(contents.blobs[0][1]) + items, records = [(contents.blobs[0][0], payload)], list(contents.records) + owner = selected._lifecycle.prepare_owned_writer( + context, runtime.resolve(persist=True), product_id="coding", manage_blobs=True, + initial_blobs=items, records=records, + ) + items.clear() + records.clear() + payload[:] = b"changed after preparation" + try: + session = await owner.create() + assert session.session_blob_health[0].state == "available" + assert session.transcript.records == contents.records + assert session.context.session_file.exists() + finally: + await owner.dispose() + await selected.close() + + asyncio.run(scenario()) + + +def test_cancel_after_publication_keeps_original_cleanup_owner(tmp_path): + async def scenario(): + archive, target = await bundle(tmp_path) + runtime = _runtime("coding") + entered, release = asyncio.Event(), asyncio.Event() + + async def delayed(context, profile, owner): + result = await runtime.bind_lifecycle_owned(context, profile, owner) + entered.set() + await release.wait() + return result + + selected = factory(runtime=runtime, bind=delayed) + caller = asyncio.create_task(selected.import_bundle(archive, session_dir=target)) + try: + await asyncio.wait_for(entered.wait(), 5) + retained, = selected.pending_preparations + assert retained._publication is not None + caller.cancel() + await wait_until(lambda: retained.closing) + caller.cancel() + with pytest.raises(asyncio.CancelledError): + await caller + assert selected.pending_preparations == (retained,) + assert_busy(target) + finally: + release.set() + await asyncio.gather(caller, return_exceptions=True) + await selected.close() + assert not selected.pending_preparations + assert not (target.parent / "session-assets/conversation-1").exists() + assert_available(target) + + asyncio.run(scenario()) + + +def test_rollback_refusal_does_not_report_cleanup_success(tmp_path, monkeypatch): + async def scenario(): + archive, target = await bundle(tmp_path) + runtime = _runtime("coding") + + async def fail_binding(context, profile, owner): + await runtime.bind_lifecycle_owned(context, profile, owner) + raise ValueError("test import rejected") + + selected = factory(runtime=runtime, bind=fail_binding) + with monkeypatch.context() as patch: + patch.setattr(SessionBlobPublication, "rollback", lambda self: False) + with pytest.raises(ValueError, match="import rejected"): + await selected.import_bundle(archive, session_dir=target) + retained, = selected.pending_preparations + assert retained._publication is not None and retained.cleanup_pending + with pytest.raises(TranscriptWriterError, match="conflict"): + await selected.close() + assert_busy(target) + await selected.close() + assert not selected.pending_preparations + + asyncio.run(scenario()) + + +def test_completed_native_rollback_still_waits_for_original_task_receipt(tmp_path): + async def scenario(): + archive, target = await bundle(tmp_path) + runtime = _runtime("coding") + completed, release = threading.Event(), threading.Event() + calls = [] + + async def fail_binding(context, profile, binding_owner): + await runtime.bind_lifecycle_owned(context, profile, binding_owner) + retained, = selected.pending_preparations + rollback = retained._rollback_import + + def delayed_receipt(): + rollback() + calls.append(True) + completed.set() + assert release.wait(5) + + retained._rollback_import = delayed_receipt + raise ValueError("test failure before create") + + selected = factory(runtime=runtime, bind=fail_binding) + caller = asyncio.create_task(selected.import_bundle(archive, session_dir=target)) + closing = None + try: + await wait_until(completed.is_set) + retained, = selected.pending_preparations + original_task = retained._publication_rollback_task + assert retained._publication.rollback_complete and not original_task.done() + caller.cancel() + with pytest.raises(ValueError, match="failure before create"): + await caller + closing = asyncio.create_task(selected.close()) + await asyncio.sleep(0) + assert not closing.done() and retained.cleanup_pending + assert retained._publication_rollback_task is original_task + assert_busy(target) + release.set() + await asyncio.wait_for(closing, 5) + assert calls == [True] and not selected.pending_preparations + finally: + release.set() + await asyncio.gather(caller, *([closing] if closing else []), return_exceptions=True) + await selected.close() + + asyncio.run(scenario()) + + +def test_real_rollback_identity_conflict_preserves_replacement_and_both_leases(tmp_path): + async def scenario(): + archive, target = await bundle(tmp_path) + runtime = _runtime("coding") + authority = target.parent / "session-assets/conversation-1" + original = authority.with_name("original-import") + + async def swap_before_failure(context, profile, binding_owner): + await runtime.bind_lifecycle_owned(context, profile, binding_owner) + authority.rename(original) + authority.mkdir(mode=0o700) + manifest = authority / "manifest.json" + manifest.write_bytes((original / "manifest.json").read_bytes()) + manifest.chmod(0o600) + (authority / "sentinel").write_bytes(b"replacement") + raise ValueError("test replacement before failed create") + + selected = factory(runtime=runtime, bind=swap_before_failure) + with pytest.raises(ValueError, match="replacement before failed create"): + await selected.import_bundle(archive, session_dir=target) + retained, = selected.pending_preparations + before = tree(authority) + with pytest.raises(TranscriptWriterError, match="conflict"): + await selected.close() + assert selected.pending_preparations == (retained,) + assert tree(authority) == before + assert_busy(target) + blob_busy(target) + # Restore only the test's exact original authority, leaving the + # replacement separately intact; production must not do this itself. + replacement = authority.with_name("replacement-saved") + authority.rename(replacement) + original.rename(authority) + await selected.close() + assert tree(replacement) == before + assert not authority.exists() and not selected.pending_preparations + + asyncio.run(scenario()) diff --git a/tests/harness/transcript/test_owned_delete.py b/tests/harness/transcript/test_owned_delete.py new file mode 100644 index 000000000..8b63eab85 --- /dev/null +++ b/tests/harness/transcript/test_owned_delete.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +import asyncio +import sys +import threading +from contextlib import contextmanager +from dataclasses import replace + +import pytest + +from loushang.harness.artifacts import SessionBlobStore +from loushang.harness.conversation import StoreCommitOutcomeUnknown +from loushang.harness.conversation.stores import file as file_module +from loushang.harness.journal._rooted_io import RootedFileIO +from loushang.harness.transcript import ProductTranscriptSession +from loushang.harness.transcript.session_factory import TranscriptDeletionCleanupPending +from loushang.harness.transcript.writer_lease import TranscriptWriterError + +from .test_owned_session_factory import factory, new +from .test_runtime_profile import _runtime +from .test_writer_images import message +from .test_writer_io import wait_until +from .test_writer_lifecycle import assert_available, assert_busy +from .test_writer_root_binding import tree + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux retained deletion") + + +@pytest.fixture +def tmp_path(tmp_path_factory): + # Blobs are siblings of Session roots. Isolate the full data root, not just + # transcripts, so equal logical Session IDs in separate tests do not share a manifest. + root = tmp_path_factory.mktemp("owned-delete") / "sessions" + root.mkdir(mode=0o700) + return root + + +async def saved(root): + creator = factory() + session = await new(creator, root, "conversation-1") + await ProductTranscriptSession(lifecycle_session=session).append_message(message()) + return creator, session + + +def retained_image(root): + store = SessionBlobStore(root.parent, "conversation-1") + reference, = store.records + assert store.read_bytes(reference) == b"hello" + return reference, store.manifest_path.read_bytes() + + +def test_active_writer_blocks_delete_then_close_allows_delete(tmp_path): + async def scenario(): + creator, active = await saved(tmp_path) + owner = factory() + path = active.context.session_file + try: + before = tree(tmp_path) + with pytest.raises(TranscriptWriterError, match="busy"): + await owner.delete_transcript(path) + assert tree(tmp_path) == before and not owner.pending_preparations + await active.dispose() + reference = retained_image(tmp_path) + assert await owner.delete_transcript(path) + assert retained_image(tmp_path) == reference + assert not path.exists() and not owner.pending_preparations + assert not await owner.delete_transcript(path) + assert_available(tmp_path) + finally: + await active.dispose() + await creator.close() + await owner.close() + + asyncio.run(scenario()) + + +def test_active_path_guard_and_missing_target_do_not_create_storage(tmp_path): + async def scenario(): + owner = factory() + path = tmp_path / "absent" / "session.jsonl" + before = tree(tmp_path) + try: + with pytest.raises(ValueError, match="currently active"): + await owner.delete_transcript(path, current_session_file=path) + assert not await owner.delete_transcript(path) + assert tree(tmp_path) == before and not owner.pending_preparations + finally: + await owner.close() + + asyncio.run(scenario()) + + +def test_delete_cleanup_failure_retains_original_writer_after_file_is_gone(tmp_path, monkeypatch): + async def scenario(): + creator, active = await saved(tmp_path) + path = active.context.session_file + await active.dispose() + await creator.close() + runtime = _runtime("coding") + owner = factory(runtime=runtime) + + async def fail(binding): + raise OSError("runtime cleanup unavailable") + + try: + with monkeypatch.context() as patch: + patch.setattr(runtime._binder, "dispose", fail) + with pytest.raises(TranscriptDeletionCleanupPending) as failure: + await owner.delete_transcript(path) + assert failure.value.receipt.revision == 1 + assert isinstance(failure.value.__cause__, OSError) + assert not path.exists() + retained_image(tmp_path) + retained, = owner.pending_preparations + assert retained.cleanup_pending + assert_busy(tmp_path) + assert not await owner.delete_transcript(path) + assert owner.pending_preparations == (retained,) + assert retained.cleanup_pending + assert_busy(tmp_path) + with pytest.raises(OSError, match="runtime cleanup unavailable"): + await owner.close() + assert owner.pending_preparations == (retained,) + assert_busy(tmp_path) + await owner.close() + assert not owner.pending_preparations + assert_available(tmp_path) + finally: + await owner.close() + + asyncio.run(scenario()) + + +def test_cancel_during_native_delete_and_factory_close_join_original_owner(tmp_path, monkeypatch): + async def scenario(): + creator, active = await saved(tmp_path) + path = active.context.session_file + await active.dispose() + await creator.close() + owner = factory() + entered, release = threading.Event(), threading.Event() + delete = file_module.FileConversationStore._delete_sync + + def paused(*args, **kwargs): + entered.set() + assert release.wait(10) + return delete(*args, **kwargs) + + monkeypatch.setattr(file_module.FileConversationStore, "_delete_sync", paused) + caller = asyncio.create_task(owner.delete_transcript(path)) + closing = None + try: + await wait_until(entered.is_set) + retained, = owner.pending_preparations + caller.cancel() + closing = asyncio.create_task(owner.close()) + await wait_until(lambda: owner._closing) + assert not closing.done() and retained.cleanup_pending + assert_busy(tmp_path) + release.set() + results = await asyncio.wait_for(asyncio.gather(caller, closing, return_exceptions=True), 10) + assert isinstance(results[0], asyncio.CancelledError) and results[1] is None + assert not owner.pending_preparations and not retained.cleanup_pending + assert not path.exists() + assert_available(tmp_path) + finally: + release.set() + await asyncio.gather(caller, *([closing] if closing else []), return_exceptions=True) + await owner.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("failure_point", ["binding_exit", "tombstone_receipt"]) +def test_actual_delete_commit_boundary_preserves_unknown(tmp_path, monkeypatch, failure_point): + async def scenario(): + creator, active = await saved(tmp_path) + path = active.context.session_file + await active.dispose() + await creator.close() + owner = factory() + bind = RootedFileIO.bind + write = file_module._write_tombstone + failure = OSError("lost deletion receipt") + + @contextmanager + def fail_exit(io, selected, **kwargs): + with bind(io, selected, **kwargs) as value: + yield value + if selected.name.endswith(".deleted.json") and kwargs.get("create_parent"): + raise failure + + def fail_tombstone(*args, **kwargs): + write(*args, **kwargs) + raise failure + + try: + with monkeypatch.context() as patch: + if failure_point == "binding_exit": + patch.setattr(RootedFileIO, "bind", fail_exit) + else: + patch.setattr(file_module, "_write_tombstone", fail_tombstone) + with pytest.raises(StoreCommitOutcomeUnknown) as result: + await owner.delete_transcript(path) + assert result.value.__cause__ is failure + assert bool(tuple(tmp_path.glob(".conversation-identities/*.deleted.json"))) + assert path.exists() is (failure_point == "tombstone_receipt") + retained_image(tmp_path) + assert not owner.pending_preparations + assert_available(tmp_path) + finally: + await owner.close() + + asyncio.run(scenario()) + + +def test_original_unknown_survives_disposal_failure(tmp_path, monkeypatch): + async def scenario(): + creator, active = await saved(tmp_path) + path = active.context.session_file + await active.dispose() + await creator.close() + runtime = _runtime("coding") + owner = factory(runtime=runtime) + delete = file_module.FileConversationStore._delete_sync + failure = StoreCommitOutcomeUnknown("test unknown deletion") + + def uncertain(*args, **kwargs): + delete(*args, **kwargs) + raise failure + + async def dispose(binding): + raise OSError("cleanup pending") + + try: + with monkeypatch.context() as patch: + patch.setattr(file_module.FileConversationStore, "_delete_sync", uncertain) + patch.setattr(runtime._binder, "dispose", dispose) + with pytest.raises(StoreCommitOutcomeUnknown) as result: + await owner.delete_transcript(path) + assert result.value is failure + assert "cleanup retained" in failure.__notes__[0] + assert not path.exists() and owner.pending_preparations + retained_image(tmp_path) + assert_busy(tmp_path) + await owner.close() + assert_available(tmp_path) + finally: + await owner.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("invalid", ["none", "revision", "operation"]) +def test_actual_delete_with_mismatched_receipt_is_not_claimed_committed(tmp_path, monkeypatch, invalid): + async def scenario(): + creator, active = await saved(tmp_path) + path = active.context.session_file + await active.dispose() + await creator.close() + owner = factory() + delete = file_module.FileConversationStore._delete_sync + + def corrupt_receipt(*args, **kwargs): + receipt = delete(*args, **kwargs) + return { + "none": None, + "revision": replace(receipt, revision=receipt.revision + 1), + "operation": replace(receipt, operation_id="another-operation"), + }[invalid] + + try: + monkeypatch.setattr(file_module.FileConversationStore, "_delete_sync", corrupt_receipt) + with pytest.raises(StoreCommitOutcomeUnknown, match="receipt does not match"): + await owner.delete_transcript(path) + assert not path.exists() and not owner.pending_preparations + retained_image(tmp_path) + assert_available(tmp_path) + finally: + await owner.close() + + asyncio.run(scenario()) diff --git a/tests/harness/transcript/test_owned_delete_identity.py b/tests/harness/transcript/test_owned_delete_identity.py new file mode 100644 index 000000000..547ed924b --- /dev/null +++ b/tests/harness/transcript/test_owned_delete_identity.py @@ -0,0 +1,96 @@ +"""The original file-specific delete cannot retire an inode replacement.""" + +from __future__ import annotations + +import asyncio +import os +import sys + +import pytest + +from loushang.harness.conversation import StoreCommitOutcomeUnknown +from loushang.harness.journal import _rooted_io as native + +from .test_runtime_profile import _runtime +from .test_writer_runtime import prepare + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux rooted delete") + + +@pytest.mark.parametrize("prior_retirement", [False, True]) +@pytest.mark.parametrize("replace_during_sync", [False, True]) +def test_delete_keeps_frozen_inode_through_retirement_sync( + tmp_path, monkeypatch, prior_retirement, replace_during_sync, +): + async def scenario(): + runtime = _runtime("coding") + owner, _ = prepare(tmp_path, runtime, runtime.bind_lifecycle_owned) + session = await owner.create() + store, key = session.runtime_binding.store, session.runtime_binding.key + path = tmp_path / "session.jsonl" + saved = tmp_path / "original-held" + original = path.read_bytes() + status = path.stat() + identity = (status.st_dev, status.st_ino) + identities = tmp_path / ".conversation-identities" + identities.mkdir(mode=0o700) + parent = identities.stat() + tombstone = store._tombstone_for(key, path) + if prior_retirement: + tombstone.write_text( + '{"revision":0,"deleted_at":"2026-09-13T00:00:00+00:00","operation_id":"abort"}', + encoding="utf-8", + ) + tombstone.chmod(0o600) + sync = native.os.fsync + replaced = [] + + def fsync(fd): + current = os.fstat(fd) + if (replace_during_sync and not replaced and tombstone.exists() + and (current.st_dev, current.st_ino) == (parent.st_dev, parent.st_ino)): + path.rename(saved) + path.write_bytes(original) # Same key and revision is not authority. + path.chmod(0o600) + replaced.append(path.stat().st_ino) + assert replaced[0] != identity[1] + return sync(fd) + + async def delete(): + return await store.delete( + key, expected_revision=0, operation_id="abort", expected_file_identity=identity, + ) + + try: + with monkeypatch.context() as patch: + patch.setattr(native.os, "fsync", fsync) + if replace_during_sync: + with pytest.raises(StoreCommitOutcomeUnknown): + await delete() + assert replaced and path.stat().st_ino == replaced[0] + assert path.read_bytes() == original + # Reconciliation is the same original op and must still + # reject the replacement; it cannot authorize a new inode. + with pytest.raises(StoreCommitOutcomeUnknown): + await delete() + assert path.stat().st_ino == replaced[0] + else: + receipt = await delete() + assert receipt.operation_id == "abort" and receipt.revision == 0 + assert not path.exists() + assert await delete() == receipt + if replace_during_sync: + # Test-only restoration of the retained original inode lets + # the exact original operation settle without touching the + # foreign replacement (which is kept under another name). + path.rename(tmp_path / "foreign-kept") + saved.rename(path) + receipt = await delete() + assert receipt.operation_id == "abort" + assert not path.exists() + assert (tmp_path / "foreign-kept").stat().st_ino == replaced[0] + assert await delete() == receipt + finally: + await owner.dispose() + + asyncio.run(scenario()) diff --git a/tests/harness/transcript/test_owned_fork.py b/tests/harness/transcript/test_owned_fork.py new file mode 100644 index 000000000..50c79b640 --- /dev/null +++ b/tests/harness/transcript/test_owned_fork.py @@ -0,0 +1,351 @@ +from __future__ import annotations + +import asyncio +import sys +import threading + +import pytest + +from loushang.ai.types import UserMessage +from loushang.harness.conversation import StoreCommitOutcomeUnknown +from loushang.harness.conversation.stores import file as file_module +from loushang.harness.transcript import ProductTranscriptSession +from loushang.harness.transcript.writer_lease import TranscriptWriterError + +from .test_owned_session_factory import factory, new +from .test_runtime_profile import _runtime +from .test_writer_images import message +from .test_writer_io import wait_until +from .test_writer_root_binding import tree + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux owned fork") + + +async def source_session(tmp_path, *, images=True): + root = tmp_path / "sessions" + root.mkdir(mode=0o700) + selected = factory() + source = await new(selected, root) + await ProductTranscriptSession(lifecycle_session=source).append_message( + message() if images else UserMessage(role="user", content="hello", timestamp=1.0) + ) + return selected, source + + +@pytest.mark.parametrize("images", [False, True]) +def test_live_owned_fork_selected_path_and_source_remains_usable(tmp_path, images): + async def scenario(): + selected, source = await source_session(tmp_path, images=images) + leaf = source.transcript.leaf_id + before = source.transcript.records + await ProductTranscriptSession(lifecycle_session=source).append_message( + UserMessage(role="user", content="not selected", timestamp=2.0) + ) + target = await selected.fork(source, leaf_id=leaf, binding_input=selected._resolve_binding_input(True)) + try: + assert target.transcript.leaf_id == leaf + assert [r.record_id for r in target.transcript.records] == [r.record_id for r in before] + assert len(source.transcript.records) == len(before) + 1 + assert source.ownership_state == "root_owned" and not source._writer_owner.closing + assert target.context.header.parent_conversation_id == source.context.header.conversation_id + assert not selected.pending_preparations + if images: + ref = target.session_blob_health[0].reference + assert ref.session_id == target.context.header.conversation_id + assert target.session_blob_health[0].state == "available" + assert ProductTranscriptSession(lifecycle_session=target).build_session_context().messages[0].content[0].data == "aGVsbG8=" + assert source.transcript.records[0].payload.content[0].blob.session_id == "conversation-1" + finally: + await target.dispose() + await source.dispose() + await selected.close() + + asyncio.run(scenario()) + + +def test_fork_from_busy_source_has_no_target_effects_then_copies_after_close(tmp_path): + async def scenario(): + first, source = await source_session(tmp_path) + selected = factory() + target_root = tmp_path / "target" + target_root.mkdir(mode=0o700) + before = tree(target_root) + with pytest.raises(TranscriptWriterError, match="busy"): + await selected.fork_from(source.context.session_file, session_dir=target_root, target_cwd="/other") + assert tree(target_root) == before and not selected.pending_preparations + await source.dispose() + target = await selected.fork_from(source.context.session_file, session_dir=target_root, target_cwd="/other") + try: + assert target.context.cwd == "/other" + assert target.session_blob_health[0].state == "available" + assert not selected.pending_preparations + reopened = await first.open(source.context.session_file) + await reopened.dispose() + finally: + await target.dispose() + await first.close() + await selected.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("failure", ["cleanup", "cancel"]) +def test_internal_source_cleanup_debt_prevents_any_target_creation(tmp_path, monkeypatch, failure): + async def scenario(): + first, source = await source_session(tmp_path) + await source.dispose() + await first.close() + runtime = _runtime("coding") + selected = factory(runtime=runtime) + entered, release = asyncio.Event(), asyncio.Event() + dispose = runtime._binder.dispose + targets = [] + + async def faulty(binding): + entered.set() + if failure == "cleanup": + raise OSError("source cleanup unavailable") + await release.wait() + await dispose(binding) + + def forbidden(*args, **kwargs): + targets.append(True) + raise AssertionError("target must not start before source cleanup") + + with monkeypatch.context() as patch: + patch.setattr(runtime._binder, "dispose", faulty) + patch.setattr(selected, "_create", forbidden) + caller = asyncio.create_task(selected.fork_from( + source.context.session_file, session_dir=source.context.session_dir, target_cwd="/other", + )) + await asyncio.wait_for(entered.wait(), 5) + owner, = selected.pending_preparations + if failure == "cancel": + caller.cancel() + with pytest.raises(OSError if failure == "cleanup" else asyncio.CancelledError): + await caller + assert selected.pending_preparations == (owner,) and owner.cleanup_pending + assert not targets + release.set() + await selected.close() + assert not selected.pending_preparations and not targets + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("failure", ["closed", "unowned", "no_blobs", "missing", "corrupt", "bad_leaf"]) +def test_invalid_source_never_acquires_target(tmp_path, monkeypatch, failure): + async def scenario(): + selected, source = await source_session(tmp_path) + owner = source._writer_owner + port = owner._blob_file_io + leaf = source.transcript.leaf_id + if failure == "closed": + await source.dispose() + elif failure == "unowned": + source._writer_owner = None + elif failure == "no_blobs": + owner._blob_file_io = None + elif failure == "bad_leaf": + leaf = "does-not-exist" + elif failure in {"missing", "corrupt"}: + ref = source.transcript.records[0].payload.content[0].blob + path = tmp_path / "session-assets/conversation-1/objects" / ref.blob_id + if failure == "missing": + path.unlink() + else: + path.write_bytes(b"wrong") + + def forbidden(*args, **kwargs): + raise AssertionError("invalid source started target construction") + + try: + with monkeypatch.context() as patch: + patch.setattr(selected, "_construct_owned", forbidden) + with pytest.raises((ValueError, OSError, TranscriptWriterError)): + await selected.fork(source, leaf_id=leaf, binding_input=selected._resolve_binding_input(True)) + assert not selected.pending_preparations + finally: + source._writer_owner = owner + owner._blob_file_io = port + await source.dispose() + await selected.close() + + asyncio.run(scenario()) + + +def test_graph_owned_source_fork_does_not_transfer_or_dispose_source(tmp_path): + async def scenario(): + selected, source = await source_session(tmp_path) + source._begin_graph_construction() + source._commit_graph_ownership() + target = await selected.fork( + source, leaf_id=source.transcript.leaf_id, binding_input=selected._resolve_binding_input(True), + ) + try: + assert source.ownership_state == "graph_owned" + await selected.close() + assert not source._writer_owner.closing and not target._writer_owner.closing + await ProductTranscriptSession(lifecycle_session=source).append_message(message()) + finally: + await target.dispose() + await source._dispose_graph_owned() + + asyncio.run(scenario()) + + +def test_foreign_loop_source_rejected_before_target(tmp_path, monkeypatch): + async def scenario(): + selected, source = await source_session(tmp_path) + + def other_loop(): + async def attempt(): + other = factory() + + def forbidden(*args, **kwargs): + raise AssertionError("wrong-loop source started target") + + monkeypatch.setattr(other, "_construct_owned", forbidden) + try: + with pytest.raises(TranscriptWriterError): + await other.fork(source, leaf_id=source.transcript.leaf_id, binding_input=None) + finally: + await other.close() + asyncio.run(attempt()) + + try: + await asyncio.to_thread(other_loop) + assert not source._writer_owner.closing + finally: + await source.dispose() + await selected.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("failure", ["before_create", "unknown_commit"]) +def test_target_failure_preserves_source_and_uses_existing_publication_rules(tmp_path, monkeypatch, failure): + async def scenario(): + first, source = await source_session(tmp_path) + runtime = _runtime("coding") + + async def binding(context, profile, owner): + result = await runtime.bind_lifecycle_owned(context, profile, owner) + if failure == "before_create": + raise ValueError("target binding failed") + return result + + selected = factory(runtime=runtime, bind=binding) + selected._conversation_id_factory = lambda: "fork-target" + write = file_module._write_unlocked + + def committed_then_failed(*args, **kwargs): + write(*args, **kwargs) + raise OSError("target commit receipt lost") + + try: + with monkeypatch.context() as patch: + if failure == "unknown_commit": + patch.setattr(file_module, "_write_unlocked", committed_then_failed) + with pytest.raises(StoreCommitOutcomeUnknown if failure == "unknown_commit" else ValueError): + await selected.fork(source, leaf_id=source.transcript.leaf_id, binding_input=runtime.resolve(persist=True)) + assert not source._writer_owner.closing and not selected.pending_preparations + assert (tmp_path / "session-assets/fork-target").exists() == (failure == "unknown_commit") + if failure == "unknown_commit": + restored = await selected.open(source.context.session_dir / "fork-target.jsonl") + assert restored.session_blob_health[0].state == "available" + await restored.dispose() + await ProductTranscriptSession(lifecycle_session=source).append_message(message()) + finally: + await source.dispose() + await first.close() + await selected.close() + + asyncio.run(scenario()) + + +def test_factory_close_during_source_settlement_never_starts_target(tmp_path, monkeypatch): + async def scenario(): + first, source = await source_session(tmp_path) + await source.dispose() + await first.close() + runtime = _runtime("coding") + selected = factory(runtime=runtime) + entered, release = asyncio.Event(), asyncio.Event() + dispose = runtime._binder.dispose + + async def delayed(binding): + entered.set() + await release.wait() + await dispose(binding) + + monkeypatch.setattr(runtime._binder, "dispose", delayed) + caller = asyncio.create_task(selected.fork_from( + source.context.session_file, session_dir=tmp_path / "must-not-exist", target_cwd="/other", + )) + closer = None + try: + await asyncio.wait_for(entered.wait(), 5) + closer = asyncio.create_task(selected.close()) + await asyncio.sleep(0) + assert selected._closing + release.set() + with pytest.raises(RuntimeError, match="closed"): + await caller + await closer + assert not (tmp_path / "must-not-exist").exists() and not selected.pending_preparations + finally: + release.set() + await asyncio.gather(caller, *([closer] if closer else []), return_exceptions=True) + await selected.close() + + asyncio.run(scenario()) + + +def test_frozen_source_not_reread_after_target_admission(tmp_path, monkeypatch): + async def scenario(): + selected, source = await source_session(tmp_path) + runtime = _runtime("coding") + entered, release = threading.Event(), threading.Event() + target_factory = factory(runtime=runtime) + prepare = target_factory._prepare_owned + + def paused_prepare(*args, **kwargs): + owner = prepare(*args, **kwargs) + acquire = owner._acquire_blob_writer + + def delayed_acquisition_receipt(): + acquire() + entered.set() + assert release.wait(5) + + owner._acquire_blob_writer = delayed_acquisition_receipt + return owner + + monkeypatch.setattr(target_factory, "_prepare_owned", paused_prepare) + task = asyncio.create_task(target_factory.fork( + source, leaf_id=source.transcript.leaf_id, binding_input=runtime.resolve(persist=True), + )) + target = None + try: + await wait_until(entered.is_set) + owner, = target_factory.pending_preparations + assert owner._publication is None + assert not (tmp_path / "session-assets" / owner._context.header.conversation_id).exists() + await source.dispose() + authority = tmp_path / "session-assets/conversation-1" + authority.rename(authority.with_name("source-moved")) + release.set() + target = await task + assert target.session_blob_health[0].state == "available" + assert ProductTranscriptSession(lifecycle_session=target).build_session_context().messages[0].content[0].data == "aGVsbG8=" + finally: + release.set() + await asyncio.gather(task, return_exceptions=True) + if target is not None: + await target.dispose() + await source.dispose() + await target_factory.close() + await selected.close() + + asyncio.run(scenario()) diff --git a/tests/harness/transcript/test_owned_import_rollback.py b/tests/harness/transcript/test_owned_import_rollback.py new file mode 100644 index 000000000..cb438ea9a --- /dev/null +++ b/tests/harness/transcript/test_owned_import_rollback.py @@ -0,0 +1,380 @@ +from __future__ import annotations + +import asyncio +import sys +from dataclasses import replace + +import pytest + +from loushang.harness.conversation import ( + MemoryConversationStore, + StoreAlreadyExistsError, + StoreCommitOutcomeUnknown, + StoreConflictError, + StoreDataError, +) +from loushang.harness.journal import _rooted_io as native +from loushang.harness.transcript import ProductTranscriptSession +from loushang.harness.transcript.unit_of_work import AgentTranscriptUnitOfWork + +from .test_owned_bundle_import import bundle +from .test_owned_session_factory import factory +from .test_runtime_profile import _runtime +from .test_writer_images import message +from .test_writer_lease import TranscriptWriterError +from .test_writer_lifecycle import assert_busy +from .test_writer_root_binding import tree + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux owned import") + + +def test_unpublished_import_rejects_non_file_store_before_create(tmp_path, monkeypatch): + async def scenario(): + source, target = await bundle(tmp_path) + runtime = _runtime("coding") + memory = MemoryConversationStore() + calls = [] + + async def forbidden(*args, **kwargs): + calls.append(1) + raise AssertionError("unsupported Store reached create") + + async def bind(context, binding_input, retained): + actual = await runtime.bind_lifecycle_owned(context, binding_input, retained) + return replace(actual, store=memory) + + monkeypatch.setattr(memory, "create", forbidden) + selected = factory(runtime=runtime, bind=bind) + try: + with pytest.raises(StoreDataError, match="original rooted FileStore"): + await selected.import_transcript(source, session_dir=target, _unpublished=True) + assert calls == [] and not tuple(target.glob("*.jsonl")) + assert not (target.parent / "session-assets/conversation-1").exists() + assert not selected.pending_preparations + finally: + await selected.close() + + asyncio.run(scenario()) + + +def test_import_witness_unknown_close_holds_both_original_writers(tmp_path, monkeypatch): + async def scenario(): + source, target = await bundle(tmp_path) + selected = factory() + session = await selected.import_transcript(source, session_dir=target, _unpublished=True) + owner = session._writer_owner + witness = owner._file_io._publication[1] + fd, = witness.operation.descriptors + close = native.os.close + calls = [] + + def lost(value): + close(value) + if value == fd: + calls.append(value) + raise OSError("test lost publication witness close") + + try: + with monkeypatch.context() as patch: + patch.setattr(native.os, "close", lost) + with pytest.raises(OSError, match="lost publication witness close"): + await session.dispose() + assert not session.context.session_file.exists() + assert owner._import_delete_receipt is not None + assert owner.cleanup_pending and owner._file_io.cleanup_pending + assert_busy(target) + with pytest.raises(OSError, match="cleanup outcome unknown"): + await session.dispose() + assert calls == [fd] + finally: + # This injector alone knows the close syscall actually completed. + witness.operation.descriptors.clear() + await session.dispose() + await selected.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("cut", ["native_return", "uow_return"]) +def test_uow_create_return_cannot_adopt_replacement_inode_for_retraction(tmp_path, monkeypatch, cut): + async def scenario(): + source, target = await bundle(tmp_path) + selected = factory() + path = target / "conversation-1.jsonl" + saved = target / "original-held" + create = AgentTranscriptUnitOfWork.create + atomic_write = native.RootedFile.atomic_write + original_identity = [] + + def change(): + value = path.read_bytes() + status = path.stat() + original_identity.append((status.st_dev, status.st_ino)) + path.rename(saved) + path.write_bytes(value) + path.chmod(0o600) + + async def replaced(cls, *args, **kwargs): + result = await create(*args, **kwargs) + change() + return result + + def after_native(rooted, *args, **kwargs): + result = atomic_write(rooted, *args, **kwargs) + if rooted._name == path.name: + change() + return result + + with monkeypatch.context() as patch: + if cut == "uow_return": + patch.setattr(AgentTranscriptUnitOfWork, "create", classmethod(replaced)) + else: + patch.setattr(native.RootedFile, "atomic_write", after_native) + session = await selected.import_transcript(source, session_dir=target, _unpublished=True) + owner = session._writer_owner + replacement = path.stat().st_ino + try: + assert owner._import_identity == original_identity[0] + with pytest.raises(TranscriptWriterError, match="conflict"): + await session.dispose() + assert path.stat().st_ino == replacement and saved.exists() + assert owner.cleanup_pending and owner._preserve_publication + assert (target.parent / "session-assets/conversation-1").exists() + assert_busy(target) + finally: + # Restore only the known original inode in this adversarial test; + # production leaves the conflict pending and cannot adopt either. + if saved.exists(): + path.rename(target / "foreign-kept") + saved.rename(path) + owner._import_identity = original_identity[0] + owner._import_unknown = False + await session.dispose() + await selected.close() + + asyncio.run(scenario()) + + +def test_retraction_never_widens_frozen_revision_to_later_history(tmp_path, monkeypatch): + async def scenario(): + source, target = await bundle(tmp_path) + selected = factory() + session = await selected.import_transcript(source, session_dir=target, _unpublished=True) + owner = session._writer_owner + frozen = owner._import_revision + await ProductTranscriptSession(lifecycle_session=session).append_message(message()) + assert session.transcript.revision > frozen + path = session.context.session_file + changed = path.read_bytes() + calls = [] + delete = session.runtime_binding.store.delete + + async def observed(*args, **kwargs): + calls.append((kwargs["expected_revision"], kwargs["operation_id"])) + return await delete(*args, **kwargs) + + try: + with monkeypatch.context() as patch: + patch.setattr(session.runtime_binding.store, "delete", observed) + with pytest.raises(StoreConflictError): + await session.dispose() + assert calls == [(frozen, owner._import_delete_operation)] + assert path.read_bytes() == changed and owner.cleanup_pending + assert (target.parent / "session-assets/conversation-1").is_dir() + assert_busy(target) + with pytest.raises(TranscriptWriterError, match="unavailable"): + await session.dispose() + assert len(calls) == 1 + finally: + # This fixture intentionally authored additional history. Preserve + # that now-persistent authority; only test teardown waives the + # observed conflict debt, never production reconciliation. + owner._import_delivered = True + owner._import_unknown = False + await session.dispose() + await selected.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("archive", [False, True]) +@pytest.mark.parametrize("delivered", [False, True]) +def test_original_import_owner_retracts_only_undelivered_creation(tmp_path, archive, delivered): + async def scenario(): + zipped, target = await bundle(tmp_path) + source = zipped if archive else next((tmp_path / "source/sessions").glob("*.jsonl")) + source_before = tree(tmp_path / "source") + original_bytes = source.read_bytes() + selected = factory() + session = await selected.import_transcript(source, session_dir=target, _unpublished=True) + path = session.context.session_file + assert path.exists() + if delivered: + session._mark_import_delivered() + try: + await session.dispose() + assert path.exists() is delivered + assert (target.parent / "session-assets/conversation-1").exists() is delivered + assert tree(tmp_path / "source") == source_before + assert source.read_bytes() == original_bytes + finally: + await session.dispose() + await selected.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("fault", ["runtime", "before_delete"]) +def test_retraction_retries_only_original_cleanup_and_frozen_delete_operation(tmp_path, monkeypatch, fault): + async def scenario(): + source, target = await bundle(tmp_path) + runtime = _runtime("coding") + selected = factory(runtime=runtime) + session = await selected.import_transcript(source, session_dir=target, _unpublished=True) + owner = session._writer_owner + path = session.context.session_file + operations = [] + delete = session.runtime_binding.store.delete + + async def delete_once(*args, **kwargs): + operations.append(kwargs["operation_id"]) + if fault == "before_delete" and len(operations) == 1: + raise StoreDataError("known pre-commit test failure") + return await delete(*args, **kwargs) + + async def failed_runtime(*args): + raise OSError("original runtime cleanup failed") + + try: + with monkeypatch.context() as patch: + patch.setattr(session.runtime_binding.store, "delete", delete_once) + with patch.context() as phase: + if fault == "runtime": + phase.setattr(runtime._binder, "dispose", failed_runtime) + with pytest.raises(OSError if fault == "runtime" else StoreDataError): + await session.dispose() + assert path.exists() and owner.cleanup_pending + assert_busy(target) + if fault == "runtime": + assert operations == [] + await session.dispose() + assert not path.exists() + assert len(operations) == (1 if fault == "runtime" else 2) + assert set(operations) == {owner._import_delete_operation} + assert owner._import_delete_receipt.operation_id == owner._import_delete_operation + finally: + await session.dispose() + await selected.close() + + asyncio.run(scenario()) + + +def test_retraction_cancelled_waiter_joins_original_delete_and_fences_public_io(tmp_path, monkeypatch): + async def scenario(): + source, target = await bundle(tmp_path) + selected = factory() + session = await selected.import_transcript(source, session_dir=target, _unpublished=True) + entered, release = asyncio.Event(), asyncio.Event() + calls = [] + delete = session.runtime_binding.store.delete + + async def blocked(*args, **kwargs): + calls.append(kwargs["operation_id"]) + entered.set() + await release.wait() + return await delete(*args, **kwargs) + + monkeypatch.setattr(session.runtime_binding.store, "delete", blocked) + waiter = asyncio.create_task(session.dispose()) + retry = None + try: + await entered.wait() + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + with pytest.raises(TranscriptWriterError, match="closed"): + async with session.operation_scope(): + raise AssertionError("closed import admitted public IO") + retry = asyncio.create_task(session.dispose()) + await asyncio.sleep(0) + assert not retry.done() and len(calls) == 1 + release.set() + await retry + assert not session.context.session_file.exists() + assert len(calls) == 1 + finally: + release.set() + await asyncio.gather(waiter, *(() if retry is None else (retry,)), return_exceptions=True) + await session.dispose() + await selected.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("fault", ["unknown_commit", "wrong_receipt"]) +def test_unknown_retraction_keeps_original_debt_and_never_replays_delete(tmp_path, monkeypatch, fault): + async def scenario(): + source, target = await bundle(tmp_path) + selected = factory() + session = await selected.import_transcript(source, session_dir=target, _unpublished=True) + owner = session._writer_owner + path = session.context.session_file + delete = session.runtime_binding.store.delete + actual_receipts = [] + + async def lost(*args, **kwargs): + receipt = await delete(*args, **kwargs) + actual_receipts.append(receipt) + if fault == "unknown_commit": + raise StoreCommitOutcomeUnknown("actual delete committed; test lost reply") + return replace(receipt, operation_id="foreign-receipt") + + try: + with monkeypatch.context() as patch: + patch.setattr(session.runtime_binding.store, "delete", lost) + with pytest.raises(StoreCommitOutcomeUnknown): + await session.dispose() + assert not path.exists() + assert owner._import_delete_receipt is None and owner._preserve_publication + assert (target.parent / "session-assets/conversation-1").exists() + assert_busy(target) + path.write_bytes(b"replacement must remain") + with pytest.raises(TranscriptWriterError, match="unavailable"): + await session.dispose() + assert len(actual_receipts) == 1 + assert path.read_bytes() == b"replacement must remain" + finally: + # Test-only delivery of the exact receipt captured before this + # injector hid/corrupted it. Production does not infer it from EOF + # or absence and cannot discard unknown debt this way. + if actual_receipts: + owner._import_delete_receipt = actual_receipts[0] + owner._import_unknown = False + owner._preserve_publication = False + await session.dispose() + assert path.read_bytes() == b"replacement must remain" + await selected.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("different_path", [False, True]) +def test_equal_preexisting_creation_cannot_become_retractable(tmp_path, different_path): + async def scenario(): + source, target = await bundle(tmp_path, images=False) + selected = factory() + previous = await selected.import_bundle(source, session_dir=target) + await previous.dispose() + path = previous.context.session_file + if different_path: + path = path.rename(path.with_name("older-authority.jsonl")) + original = path.read_bytes() + try: + with pytest.raises(StoreAlreadyExistsError): + await selected.import_transcript(source, session_dir=target, _unpublished=True) + assert path.read_bytes() == original + assert not selected.pending_preparations + finally: + await selected.close() + + asyncio.run(scenario()) diff --git a/tests/harness/transcript/test_owned_import_source.py b/tests/harness/transcript/test_owned_import_source.py new file mode 100644 index 000000000..f91ab8cb4 --- /dev/null +++ b/tests/harness/transcript/test_owned_import_source.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import asyncio +import os +import sys + +import pytest + +from loushang.harness.transcript import ProductTranscriptSession +from loushang.harness.transcript import session_factory as module +from loushang.harness.transcript.session_catalog import ( + session_file_authority_fingerprint, +) + +from .test_owned_bundle_import import bundle +from .test_owned_session_factory import factory, new +from .test_writer_lease import TranscriptWriterError +from .test_writer_root_binding import tree + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux owned import") + + +@pytest.mark.parametrize("archive", [False, True]) +@pytest.mark.parametrize("fault", ["stale", "during_read", "after_freeze"]) +def test_import_source_fingerprint_binds_actual_frozen_bytes(tmp_path, monkeypatch, archive, fault): + async def scenario(): + zipped, target = await bundle(tmp_path) + source = zipped if archive else next((tmp_path / "source/sessions").glob("*.jsonl")) + fingerprint = session_file_authority_fingerprint(source) + selected = factory() + before = tree(target.parent) + read = module._read_stable_regular_file + native_read = os.read + reads = [] + changed = [] + + def replace_source(): + source.rename(source.with_suffix(".original")) + source.symlink_to(tmp_path / "untrusted-missing-source") + changed.append(True) + + def frozen(*args, **kwargs): + reads.append(True) + result = read(*args, **kwargs) + if fault == "after_freeze": + replace_source() + return result + + def changing_read(fd, count): + result = native_read(fd, count) + if fault == "during_read" and not changed: + replace_source() + return result + + session = None + try: + if fault == "stale": + replace_source() + with monkeypatch.context() as patch: + patch.setattr(module, "_read_stable_regular_file", frozen) + if fault == "during_read": + patch.setattr(os, "read", changing_read) + if fault == "after_freeze": + session = await selected.import_transcript( + source, session_dir=target, expected_source_fingerprint=fingerprint, + ) + else: + with pytest.raises(OSError): + await selected.import_transcript( + source, session_dir=target, expected_source_fingerprint=fingerprint, + ) + assert len(reads) == 1 + assert changed + if session is None: + assert tree(target.parent) == before + assert not selected.pending_preparations + else: + context = ProductTranscriptSession(lifecycle_session=session).build_session_context() + assert context.messages[0].content[0].data == "aGVsbG8=" + assert session.session_blob_health[0].state == "available" + finally: + if session is not None: + await session.dispose() + await selected.close() + + asyncio.run(scenario()) + + +def test_import_cannot_recreate_a_known_missing_store_root(tmp_path): + async def scenario(): + source, target = await bundle(tmp_path, images=False) + selected = factory(store_state_root=tmp_path / "state/session-stores") + seed = await new(selected, target, "existing-store") + await seed.dispose() + target.rename(target.with_name("retained-old-store")) + try: + with pytest.raises(TranscriptWriterError): + await selected.import_transcript(source, session_dir=target) + assert not target.exists() + assert not selected.pending_preparations + finally: + await selected.close() + + asyncio.run(scenario()) diff --git a/tests/harness/transcript/test_owned_index_publication.py b/tests/harness/transcript/test_owned_index_publication.py new file mode 100644 index 000000000..bcd68bcce --- /dev/null +++ b/tests/harness/transcript/test_owned_index_publication.py @@ -0,0 +1,167 @@ +"""Summary publication borrows the original writer lifetime and frozen state.""" + +import asyncio +import sys +import threading +from functools import partial + +import pytest + +from loushang.ai.types import UserMessage +from loushang.harness.transcript import ProductTranscriptSession +from loushang.harness.transcript.jsonl_file import publish_owned_transcript_projection +from loushang.harness.transcript.session_catalog import ( + _MAX_PATH_SUMMARY_TOTAL_BYTES, + AgentTranscriptSessionCatalog, + _status_fingerprint, +) + +from .test_owned_session_factory import factory, new +from .test_writer_io import wait_until + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux owned index") + + +def test_owned_index_rejects_old_snapshot_then_publishes_latest(tmp_path): + async def scenario(): + selected = factory(store_state_root=tmp_path / "state") + session = await new(selected, tmp_path / "data/sessions") + manager = ProductTranscriptSession(lifecycle_session=session) + try: + await manager.append_message(UserMessage(role="user", content="first", timestamp=1)) + catalog = AgentTranscriptSessionCatalog(manager.session_dir) + catalog.refresh_index() + frozen = tuple(manager.entries) + await manager.append_message(UserMessage(role="user", content="latest", timestamp=2)) + before = catalog.index_path.read_bytes() + async with session.operation_scope(): + assert not catalog.publish_owned_summary( + publication=partial( + publish_owned_transcript_projection, + file_io=session.transcript_file_io, root=catalog.session_dir, + key=session.runtime_binding.key, source_path=manager.session_file, + header=manager.header, records=frozen, + max_bytes=_MAX_PATH_SUMMARY_TOTAL_BYTES, fingerprint=_status_fingerprint, + ), + key=session.runtime_binding.key, source_path=manager.session_file, + header=manager.header, records=frozen, leaf_id=manager.leaf_id, + ) + assert catalog.index_path.read_bytes() == before + await manager.publish_index_summary() + summaries = catalog.load_index() + assert len(summaries) == 1 + assert summaries[0].last_message_preview == "latest" + assert summaries[0].entry_count == 2 + finally: + await session.dispose() + await selected.close() + + asyncio.run(scenario()) + + +def test_owned_publication_does_not_create_absent_index(tmp_path): + async def scenario(): + selected = factory(store_state_root=tmp_path / "state") + session = await new(selected, tmp_path / "data/sessions") + manager = ProductTranscriptSession(lifecycle_session=session) + try: + await manager.append_message(UserMessage(role="user", content="first", timestamp=1)) + await manager.publish_index_summary() + assert not (manager.session_dir / ".session-index.json").exists() + assert not (manager.session_dir / ".session-index.json.lock").exists() + finally: + await session.dispose() + await selected.close() + + asyncio.run(scenario()) + + +def test_cancelled_publication_waits_for_original_native_operation(tmp_path, monkeypatch): + async def scenario(): + selected = factory(store_state_root=tmp_path / "state") + session = await new(selected, tmp_path / "data/sessions") + manager = ProductTranscriptSession(lifecycle_session=session) + entered, release = threading.Event(), threading.Event() + original = AgentTranscriptSessionCatalog.publish_owned_summary + + def held(catalog, **kwargs): + entered.set() + assert release.wait(10), "test did not release publication" + return original(catalog, **kwargs) + + task = None + try: + await manager.append_message(UserMessage(role="user", content="first", timestamp=1)) + AgentTranscriptSessionCatalog(manager.session_dir).refresh_index() + monkeypatch.setattr(AgentTranscriptSessionCatalog, "publish_owned_summary", held) + task = asyncio.create_task(manager.publish_index_summary()) + await wait_until(entered.is_set) + task.cancel() + await asyncio.sleep(0) + assert not task.done() + assert session.ownership_state != "disposed" + release.set() + with pytest.raises(asyncio.CancelledError): + await task + assert not session.transcript_file_io.cleanup_pending + finally: + release.set() + if task is not None and not task.done(): + await asyncio.gather(task, return_exceptions=True) + monkeypatch.setattr(AgentTranscriptSessionCatalog, "publish_owned_summary", original) + await session.dispose() + await selected.close() + + asyncio.run(scenario()) + + +def test_owned_publication_never_updates_replacement_directory(tmp_path): + async def scenario(): + selected = factory(store_state_root=tmp_path / "state") + root = tmp_path / "data/sessions" + session = await new(selected, root) + manager = ProductTranscriptSession(lifecycle_session=session) + try: + await manager.append_message(UserMessage(role="user", content="first", timestamp=1)) + AgentTranscriptSessionCatalog(root).refresh_index() + await manager.append_message(UserMessage(role="user", content="latest", timestamp=2)) + root.rename(root.with_name("retained-sessions")) + root.mkdir(mode=0o700) + replacement = root / ".session-index.json" + replacement.write_bytes(b"replacement directory must remain untouched") + replacement.chmod(0o600) + await manager.publish_index_summary() + assert replacement.read_bytes() == b"replacement directory must remain untouched" + assert {path.name for path in root.iterdir()} == {".session-index.json"} + finally: + await session.dispose() + await selected.close() + + asyncio.run(scenario()) + + +def test_legacy_catalog_busy_does_not_delete_owned_cache(tmp_path): + from loushang.harness.journal.jsonl import JournalLockUnavailable + + async def scenario(): + selected = factory(store_state_root=tmp_path / "state") + session = await new(selected, tmp_path / "data/sessions") + manager = ProductTranscriptSession(lifecycle_session=session) + try: + await manager.append_message(UserMessage(role="user", content="first", timestamp=1)) + catalog = AgentTranscriptSessionCatalog(manager.session_dir) + catalog.refresh_index() + before = catalog.index_path.read_bytes() + async with session.operation_scope(): + with session.transcript_file_io.bind(catalog.index_path) as target: + target.acquire_lock(exclusive=True, blocking=False, suffix=".lock") + with pytest.raises(JournalLockUnavailable): + await catalog.upsert_summary( + manager._get_session_index_summary(), source_revision=1, + ) + assert catalog.index_path.read_bytes() == before + finally: + await session.dispose() + await selected.close() + + asyncio.run(scenario()) diff --git a/tests/harness/transcript/test_owned_product_delivery.py b/tests/harness/transcript/test_owned_product_delivery.py new file mode 100644 index 000000000..d7829f79e --- /dev/null +++ b/tests/harness/transcript/test_owned_product_delivery.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import asyncio +import sys + +import pytest + +from loushang.ai.types import UserMessage +from loushang.harness.transcript import ( + AgentTranscriptSessionFactory, + ProductTranscriptSession, +) + +from .test_owned_bundle_import import bundle +from .test_owned_session_factory import factory +from .test_runtime_profile import _runtime +from .test_writer_lease import busy, lease + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux owned Product delivery") + + +def product(selected, calls, *, action=None): + class Bound(ProductTranscriptSession): + @classmethod + def _session_factory(cls): + return selected + + def _fork_binding_input(self): + return selected._resolve_binding_input(True) + + def __init__(self, *, lifecycle_session): + calls.append(lifecycle_session) + assert lifecycle_session._writer_owner in selected.pending_preparations + super().__init__(lifecycle_session=lifecycle_session) + if action: + action(self) + + return Bound + + +@pytest.mark.parametrize("method", ["new", "open", "load", "continue_recent", "import_bundle", "fork_from", "fork"]) +def test_each_product_construction_projects_once_before_delivery(tmp_path, method): + async def scenario(): + root = tmp_path / "sessions" + root.mkdir(mode=0o700) + selected, calls = factory(), [] + Bound = product(selected, calls) + source = await Bound.new(root, "/workspace", session_id="conversation-1") + await source.append_message(UserMessage(role="user", content="hello", timestamp=1.0)) + path, leaf = source.session_file, source.leaf_id + if method != "fork": + await source.dispose_runtime_profile() + calls.clear() + result = None + try: + if method == "new": + result = await Bound.new(root, "/workspace", session_id="new") + elif method in {"open", "load"}: + result = await getattr(Bound, method)(path) + elif method == "continue_recent": + result = await Bound.continue_recent(root, "/workspace") + elif method == "import_bundle": + archive, target = await bundle(tmp_path, images=False) + result = await Bound.import_bundle(archive, session_dir=target) + elif method == "fork_from": + result = await Bound.fork_from(path, "/workspace", root) + else: + result = await source.fork(leaf) + assert calls == [result._lifecycle_session] + assert result._creation_factory is selected and not selected.pending_preparations + await selected.close() # Delivered runtime remains Session-owned. + busy(result.session_dir, conversation=result.header.conversation_id) + finally: + if result: + await result.dispose_runtime_profile() + await source.dispose_runtime_profile() + await selected.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("failure", ["constructor", "factory_binding", "fence"]) +def test_failed_wrapper_retains_original_preparation_until_cleanup(tmp_path, monkeypatch, failure): + async def scenario(): + runtime = _runtime("coding") + selected, calls = factory(runtime=runtime), [] + + def action(value): + if failure == "constructor": + raise ValueError("wrapper failed") + if failure == "fence": + selected.fence() + + Bound = product(selected, calls, action=action) + if failure == "factory_binding": + original = Bound.__setattr__ + + def fail_setattr(self, name, value): + if name == "_creation_factory" and value is selected: + raise ValueError("factory binding failed") + original(self, name, value) + + monkeypatch.setattr(Bound, "__setattr__", fail_setattr) + + async def fail_dispose(binding): + raise OSError("cleanup temporarily unavailable") + + try: + with monkeypatch.context() as patch: + patch.setattr(runtime._binder, "dispose", fail_dispose) + with pytest.raises((ValueError, RuntimeError)) as result: + await Bound.new(tmp_path, "/workspace", session_id="conversation-1") + assert "cleanup retained" in result.value.__notes__[0] + retained, = selected.pending_preparations + assert calls == [retained._session] and retained.cleanup_pending + busy(tmp_path, conversation="conversation-1") + with pytest.raises(OSError, match="cleanup temporarily unavailable"): + await selected.close() + assert selected.pending_preparations == (retained,) + await selected.close() + assert not selected.pending_preparations + fresh = lease(tmp_path, conversation="conversation-1") + try: + fresh.acquire() + finally: + fresh.close() + finally: + await selected.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("falsey", [False, True]) +def test_instance_fork_uses_creating_factory_not_later_class_selection(tmp_path, monkeypatch, falsey): + async def scenario(): + class FalseyFactory(AgentTranscriptSessionFactory): + def __bool__(self): + return False + + selected = factory(factory_type=FalseyFactory if falsey else AgentTranscriptSessionFactory) + calls = [] + Bound = product(selected, calls) + source = await Bound.new(tmp_path, "/workspace", session_id="conversation-1") + await source.append_message(UserMessage(role="user", content="hello", timestamp=1.0)) + + def forbidden(cls): + raise AssertionError("instance fork looked up a replacement factory") + + monkeypatch.setattr(Bound, "_session_factory", classmethod(forbidden)) + target = None + try: + target = await source.fork(source.leaf_id) + assert target._creation_factory is selected + assert len(calls) == 2 and not selected.pending_preparations + finally: + if target: + await target.dispose_runtime_profile() + await source.dispose_runtime_profile() + await selected.close() + + asyncio.run(scenario()) + + +def test_delivered_graph_state_remains_owned_after_factory_close(tmp_path): + async def scenario(): + selected, calls = factory(), [] + Bound = product(selected, calls) + value = await Bound.new(tmp_path, "/workspace", session_id="conversation-1") + session = value._lifecycle_session + session._begin_graph_construction() + session._commit_graph_ownership() + try: + await selected.close() + assert session.ownership_state == "graph_owned" + assert not session._writer_owner.closing + assert not selected.pending_preparations + busy(tmp_path, conversation="conversation-1") + await value.append_message(UserMessage(role="user", content="after delivery", timestamp=1.0)) + await value.dispose_runtime_profile() # Compatibility close cannot retire the Graph owner. + busy(tmp_path, conversation="conversation-1") + finally: + await session._dispose_graph_owned() + await selected.close() + assert session.ownership_state == "disposed" + + asyncio.run(scenario()) + + +def test_legacy_factory_operation_does_not_receive_projection_keyword(tmp_path): + from .test_product_session import _ExampleProductSession + + async def scenario(): + selected = factory() + session = await selected.new(session_dir=tmp_path, cwd="/workspace", session_id="conversation-1") + calls = [] + + async def strict_operation(): + calls.append("operation") + return session + + class LegacyFactory: + owns_persistent_sessions = False + + try: + value = await _ExampleProductSession._construct_product(LegacyFactory(), strict_operation) + assert calls == ["operation"] and value._creation_factory is None + assert value._lifecycle_session is session + finally: + await session.dispose() + await selected.close() + + asyncio.run(scenario()) diff --git a/tests/harness/transcript/test_owned_session_factory.py b/tests/harness/transcript/test_owned_session_factory.py new file mode 100644 index 000000000..fbb27cd81 --- /dev/null +++ b/tests/harness/transcript/test_owned_session_factory.py @@ -0,0 +1,377 @@ +from __future__ import annotations + +import asyncio +import sys + +import pytest + +from loushang.harness.transcript import ( + AgentTranscriptLifecycle, + AgentTranscriptSessionFactory, + ProductTranscriptSession, +) +from loushang.harness.transcript import session_factory as module +from loushang.harness.transcript.writer_lease import TranscriptWriterError + +from .test_runtime_profile import _runtime +from .test_writer_images import message +from .test_writer_io import wait_until +from .test_writer_lifecycle import assert_available, assert_busy +from .test_writer_root_binding import tree + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux owned factory") + + +def factory(*, runtime=None, bind=None, factory_type=AgentTranscriptSessionFactory, **kwargs): + runtime = runtime or _runtime("coding") + lifecycle = AgentTranscriptLifecycle( + bind_runtime=runtime.bind_lifecycle, bind_runtime_owned=bind or runtime.bind_lifecycle_owned, + ) + return factory_type( + lifecycle=lifecycle, resolve_binding_input=lambda persist: runtime.resolve(persist=persist), + header_metadata=lambda _: {}, owned_product_id="coding", + session_file_factory=lambda root, header: root / f"{header.conversation_id}.jsonl", + **kwargs, + ) + + +async def new(selected, root, session_id="conversation-1"): + return await selected.new(session_dir=root, cwd="/workspace", session_id=session_id) + + +def test_factory_lazy_store_admission_new_second_session_and_restore(tmp_path): + async def scenario(): + root, state = tmp_path / "data/sessions", tmp_path / "state/session-stores" + selected = factory(store_state_root=state) + assert not root.exists() and not state.exists() + first = await new(selected, root) + second = None + try: + binding = first._writer_owner._store_admission._binding + assert first._writer_owner._writer._expected_root_identity == binding.root_identity + assert first._writer_owner._blob_writer._expected_root_identity == binding.parent_identity + admission = first._writer_owner._store_admission + assert not admission._witness.cleanup_pending + assert not admission._family._lease.cleanup_pending + assert admission.cleanup_pending # Only the handed-off directory pins remain. + # The first Session remains live; the shared initialization lock + # must not serialize independent Session lifetimes. + second = await new(selected, root, "conversation-2") + await ProductTranscriptSession(lifecycle_session=first).append_message(message()) + finally: + if second is not None: + await second.dispose() + await first.dispose() + await selected.close() + restored_factory = factory(store_state_root=state) + restored = await restored_factory.open(first.context.session_file) + try: + assert restored.session_blob_health[0].state == "available" + assert restored.transcript.records == first.transcript.records + finally: + await restored.dispose() + await restored_factory.close() + + asyncio.run(scenario()) + + +def test_factory_public_create_open_and_delivered_session_ownership(tmp_path): + async def scenario(): + selected = factory() + session = await new(selected, tmp_path) + assert not selected.pending_preparations + await ProductTranscriptSession(lifecycle_session=session).append_message(message()) + await selected.close() + assert not session._writer_owner.closing + assert_busy(tmp_path) + await session.dispose() + reopened_factory = factory() + restored = await reopened_factory.open(session.context.session_file) + try: + assert restored.session_blob_health[0].state == "available" + assert restored.transcript.records == session.transcript.records + assert not reopened_factory.pending_preparations + finally: + await restored.dispose() + await reopened_factory.close() + + asyncio.run(scenario()) + + +def test_busy_public_open_uses_readonly_discovery(tmp_path, monkeypatch): + async def scenario(): + first_factory = factory() + first = await new(first_factory, tmp_path) + await ProductTranscriptSession(lifecycle_session=first).append_message(message()) + second = factory() + reads = [] + read = module.load_agent_transcript_header + + def readonly(path, **kwargs): + assert kwargs == {"read_only": True} + reads.append(path) + return read(path, **kwargs) + + first.context.session_file.with_name(first.context.session_file.name + ".lock").unlink(missing_ok=True) + before = tree(tmp_path) + try: + with monkeypatch.context() as patch: + patch.setattr(module, "load_agent_transcript_header", readonly) + with pytest.raises(TranscriptWriterError, match="busy"): + await second.open(first.context.session_file) + assert reads == [first.context.session_file] + assert tree(tmp_path) == before + assert not second.pending_preparations + assert_busy(tmp_path) + finally: + await second.close() + await first.dispose() + await first_factory.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("method", ["new", "open", "continue_recent"]) +def test_closed_factory_rejects_before_discovery_and_callbacks(tmp_path, monkeypatch, method): + async def scenario(): + selected = factory() + await selected.close() + + def forbidden(*args, **kwargs): + raise AssertionError("closed factory performed discovery or callbacks") + + monkeypatch.setattr(module, "load_agent_transcript_header", forbidden) + monkeypatch.setattr(module, "AgentTranscriptSessionCatalog", forbidden) + monkeypatch.setattr(selected, "_resolve_binding_input", forbidden) + with pytest.raises(RuntimeError, match="closed"): + if method == "new": + await new(selected, tmp_path) + elif method == "open": + await selected.open(tmp_path / "absent.jsonl") + else: + await selected.continue_recent(session_dir=tmp_path, cwd="/workspace") + assert not tuple(tmp_path.iterdir()) + + asyncio.run(scenario()) + + +def test_failed_cleanup_keeps_original_typed_preparation(tmp_path, monkeypatch): + async def scenario(): + runtime = _runtime("coding") + + async def fail_binding(context, profile, owner): + await runtime.bind_lifecycle_owned(context, profile, owner) + raise ValueError("test primary construction failure") + + selected = factory(runtime=runtime, bind=fail_binding) + dispose = runtime._binder.dispose + + async def unavailable(binding): + raise OSError("test cleanup temporarily unavailable") + + with monkeypatch.context() as patch: + patch.setattr(runtime._binder, "dispose", unavailable) + with pytest.raises(ValueError, match="primary construction failure") as failure: + await new(selected, tmp_path) + assert "cleanup retained" in failure.value.__notes__[0] + retained, = selected.pending_preparations + assert retained.cleanup_pending + assert_busy(tmp_path) + with pytest.raises(OSError, match="cleanup temporarily unavailable"): + await selected.close() + assert selected.pending_preparations == (retained,) + assert runtime._binder.dispose == dispose + await selected.close() + assert not selected.pending_preparations + assert_available(tmp_path) + + asyncio.run(scenario()) + + +def test_cancelled_caller_keeps_pending_until_factory_close(tmp_path): + async def scenario(): + runtime = _runtime("coding") + entered, release = asyncio.Event(), asyncio.Event() + + async def delayed(context, profile, owner): + result = await runtime.bind_lifecycle_owned(context, profile, owner) + entered.set() + await release.wait() + return result + + selected = factory(runtime=runtime, bind=delayed) + caller = asyncio.create_task(new(selected, tmp_path)) + try: + await asyncio.wait_for(entered.wait(), 5) + retained, = selected.pending_preparations + caller.cancel() + await wait_until(lambda: retained.closing) + caller.cancel() + with pytest.raises(asyncio.CancelledError): + await caller + assert selected.pending_preparations == (retained,) + assert_busy(tmp_path) + finally: + release.set() + await asyncio.gather(caller, return_exceptions=True) + await selected.close() + assert not selected.pending_preparations + assert_available(tmp_path) + + asyncio.run(scenario()) + + +def test_close_fences_all_pending_before_waiting(tmp_path): + async def scenario(): + runtime = _runtime("coding") + entered, release = [], asyncio.Event() + + async def delayed(context, profile, owner): + result = await runtime.bind_lifecycle_owned(context, profile, owner) + entered.append(True) + await release.wait() + return result + + selected = factory(runtime=runtime, bind=delayed) + callers = [asyncio.create_task(new(selected, tmp_path, name)) for name in ("one", "two")] + closer = None + try: + await wait_until(lambda: len(entered) == 2) + owners = selected.pending_preparations + closer = asyncio.create_task(selected.close()) + await wait_until(lambda: all(owner.closing for owner in owners)) + assert not closer.done() + release.set() + results = await asyncio.gather(*callers, return_exceptions=True) + assert all(isinstance(result, RuntimeError) for result in results) + await closer + assert not selected.pending_preparations + finally: + release.set() + await asyncio.gather(*callers, *([closer] if closer else []), return_exceptions=True) + await selected.close() + + asyncio.run(scenario()) + + +def test_unwired_owned_paths_reject_before_effects(tmp_path, monkeypatch): + async def scenario(): + selected = factory() + + def forbidden(*args, **kwargs): + raise AssertionError("unwired operation accessed inputs") + + monkeypatch.setattr(module, "read_agent_transcript_bundle", forbidden) + monkeypatch.setattr(selected, "load", forbidden) + with pytest.raises(ValueError, match="transient"): + await selected.import_bundle(tmp_path / "absent", session_dir=tmp_path, persist=False) + with pytest.raises(ValueError, match="transient"): + await selected.fork_from(tmp_path / "absent", target_cwd="/workspace", session_dir=tmp_path, persist=False) + with pytest.raises(ValueError, match="transient"): + await selected.in_memory() + assert not tuple(tmp_path.iterdir()) + await selected.close() + + asyncio.run(scenario()) + + +def test_wrong_loop_rejects_before_effects_and_original_loop_can_close(tmp_path, monkeypatch): + selected = factory() + original = asyncio.new_event_loop() + + async def establish(): + session = await new(selected, tmp_path) + await session.dispose() + + original.run_until_complete(establish()) + + def forbidden(*args, **kwargs): + raise AssertionError("wrong-loop factory accessed inputs") + + async def wrong_loop(): + for operation in ( + lambda: new(selected, tmp_path), + lambda: selected.open(tmp_path / "absent.jsonl"), + lambda: selected.continue_recent(session_dir=tmp_path, cwd="/workspace"), + selected.close, + ): + with pytest.raises(RuntimeError, match="another event loop"): + await operation() + + try: + with monkeypatch.context() as patch: + patch.setattr(module, "load_agent_transcript_header", forbidden) + patch.setattr(module, "AgentTranscriptSessionCatalog", forbidden) + patch.setattr(selected, "_resolve_binding_input", forbidden) + asyncio.run(wrong_loop()) + original.run_until_complete(selected.close()) + assert not selected.pending_preparations + finally: + original.close() + + +def test_close_continues_after_first_cleanup_failure(tmp_path, monkeypatch): + async def scenario(): + runtime = _runtime("coding") + + async def fail_binding(context, profile, owner): + await runtime.bind_lifecycle_owned(context, profile, owner) + raise ValueError("test failed construction") + + selected = factory(runtime=runtime, bind=fail_binding) + dispose = runtime._binder.dispose + bindings, closed = [], [] + fail_all = True + fail_first = True + + async def sometimes(binding): + if binding not in bindings: + bindings.append(binding) + if fail_all or (fail_first and binding is bindings[0]): + raise OSError("test selected cleanup failure") + await dispose(binding) + closed.append(binding) + + with monkeypatch.context() as patch: + patch.setattr(runtime._binder, "dispose", sometimes) + for name in ("one", "two"): + with pytest.raises(ValueError, match="failed construction"): + await new(selected, tmp_path, name) + first, second = selected.pending_preparations + fail_all = False + with pytest.raises(OSError, match="selected cleanup failure"): + await selected.close() + assert selected.pending_preparations == (first,) + assert first.cleanup_pending and not second.cleanup_pending + assert closed == [bindings[1]] + fail_first = False + await selected.close() + assert closed == [bindings[1], bindings[0]] + assert not selected.pending_preparations + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("cache", ["missing", "corrupt"]) +def test_recent_discovery_does_not_rebuild_cache_before_busy_admission(tmp_path, cache): + async def scenario(): + first_factory, second = factory(), factory() + first = await new(first_factory, tmp_path) + await ProductTranscriptSession(lifecycle_session=first).append_message(message()) + index = tmp_path / ".session-index.json" + if cache == "missing": + index.unlink(missing_ok=True) + else: + index.write_bytes(b"broken index") + before = tree(tmp_path) + try: + with pytest.raises(TranscriptWriterError, match="busy"): + await second.continue_recent(session_dir=tmp_path, cwd="/workspace") + assert tree(tmp_path) == before + assert not second.pending_preparations + finally: + await first.dispose() + await first_factory.close() + await second.close() + + asyncio.run(scenario()) diff --git a/tests/harness/transcript/test_readonly_store.py b/tests/harness/transcript/test_readonly_store.py new file mode 100644 index 000000000..148a595c9 --- /dev/null +++ b/tests/harness/transcript/test_readonly_store.py @@ -0,0 +1,356 @@ +from __future__ import annotations + +import asyncio +import stat +import sys +from pathlib import Path + +import pytest + +from loushang.harness.conversation import StoreConflictError +from loushang.harness.transcript import ( + AgentTranscriptDirectoryRuntime, + AgentTranscriptFileLayout, + AgentTranscriptSessionCatalog, + create_agent_transcript_file_store, + write_agent_transcript_export, +) +from loushang.harness.transcript import jsonl_file as module +from loushang.harness.transcript import model_input_v2_index_file as index_module +from loushang.harness.transcript.model_input_v2_index_file import _projection_cache_path +from loushang.harness.transcript.model_input_v2_types import ( + DeferredModelInputNodeBundle, +) +from loushang.harness.transcript.writer_lease import TranscriptWriterLease + +from .test_lifecycle import _header, _record +from .test_model_input_v2_index_file import _model_input_records + + +def snapshot(root): + return { + str(path.relative_to(root)): ( + path.read_bytes() if path.is_file() else None, + path.stat().st_mtime_ns, stat.S_IMODE(path.stat().st_mode), path.stat().st_ino, + ) + for path in (root, *root.rglob("*")) + } + + +def seed(root): + paths = [] + for number in (1, 2): + path = root / f"session-{number}.jsonl" + write_agent_transcript_export(path, _header(f"session-{number}"), [_record(f"record-{number}")]) + # Fixture models a historical file without any adjacent lock/cache. + path.with_name(path.name + ".lock").unlink(missing_ok=True) + paths.append(path) + return paths + + +def test_readonly_catalog_does_not_create_session_lock_or_projection_cache(tmp_path): + seed(tmp_path) + before = snapshot(tmp_path) + summaries = AgentTranscriptSessionCatalog(tmp_path, index_writable=False).list_summaries() + assert {summary.session_id for summary in summaries} == {"session-1", "session-2"} + assert snapshot(tmp_path) == before + + +@pytest.mark.parametrize("cache", ["missing", "corrupt", "valid", "tail"]) +def test_readonly_scan_page_and_load_never_rewrite_files(tmp_path, monkeypatch, cache): + async def scenario(): + paths = seed(tmp_path) + layout = AgentTranscriptFileLayout(tmp_path) + writable = create_agent_transcript_file_store(layout) + if cache in {"valid", "tail"}: + for number in (1, 2): + await writable.load(layout.key(f"session-{number}")) + elif cache == "corrupt": + for path in paths: + _projection_cache_path(path).write_text("invalid cache", encoding="utf-8") + if cache == "tail": + await writable.append( + layout.key("session-1"), _record("tail", "record-1"), + expected_revision=1, operation_id="test-tail", + ) + before = snapshot(tmp_path) + calls = [] + original = index_module._try_rebuild_manifest + + def rebuild(*args, **kwargs): + calls.append(1) + return original(*args, **kwargs) + + monkeypatch.setattr(index_module, "_try_rebuild_manifest", rebuild) + strict_calls = [] + original_decode = module.decode_jsonl + + def decode(*args, **kwargs): + strict_calls.append(1) + return original_decode(*args, **kwargs) + + monkeypatch.setattr(module, "decode_jsonl", decode) + store = create_agent_transcript_file_store(AgentTranscriptFileLayout(tmp_path), read_only=True) + keys = await store.scan(layout.namespace) + assert len(keys) == 2 + page = await store.scan_page(layout.namespace) + assert len(page.heads) == 2 and not page.diagnostics + for key in keys: + result = await store.load(key) + assert result.snapshot.header.conversation_id == key.conversation_id + if cache == "tail" and key.conversation_id == "session-1": + assert result.snapshot.records[-1].record_id == "tail" + assert calls == [] + if cache == "valid": + assert strict_calls == [] + assert snapshot(tmp_path) == before + asyncio.run(scenario()) + + +@pytest.mark.parametrize("method", ["create", "append", "append_batch", "delete"]) +def test_readonly_mutation_rejected_before_path_callbacks(tmp_path, monkeypatch, method): + async def scenario(): + seed(tmp_path) + layout = AgentTranscriptFileLayout(tmp_path) + store = create_agent_transcript_file_store(layout, read_only=True) + before = snapshot(tmp_path) + calls = [] + + def path_callback(*_): + calls.append(1) + raise AssertionError("readonly mutation reached filesystem mapping") + + monkeypatch.setattr(store, "_create_path", path_callback) + monkeypatch.setattr(store, "_resolve_path", path_callback) + monkeypatch.setattr(store, "_journal_factory", path_callback) + monkeypatch.setattr(store, "_write_journal_factory", path_callback) + key = layout.key("session-1") + with pytest.raises(StoreConflictError, match="read.only"): + if method == "create": + await store.create(key, _header("new"), operation_id="new") + elif method == "append": + await store.append(key, _record("next"), expected_revision=1, operation_id="append") + elif method == "append_batch": + await store.append_batch(key, [_record("next")], expected_revision=1, operation_ids=["batch"]) + else: + await store.delete(key, expected_revision=1, operation_id="delete") + assert not calls and snapshot(tmp_path) == before + asyncio.run(scenario()) + + +def test_readonly_partial_tail_preserves_bytes_and_diagnostic(tmp_path): + async def scenario(): + paths = seed(tmp_path) + with paths[0].open("ab") as handle: + handle.write(b'{"partial":') + before = snapshot(tmp_path) + layout = AgentTranscriptFileLayout(tmp_path) + store = create_agent_transcript_file_store(layout, read_only=True) + result = await store.load(layout.key("session-1")) + assert len(result.snapshot.records) == 1 + assert result.diagnostics + assert snapshot(tmp_path) == before + asyncio.run(scenario()) + + +@pytest.mark.skipif(sys.platform != "linux", reason="Linux writer") +def test_active_session_writers_do_not_block_readonly_namespace_queries(tmp_path): + paths = seed(tmp_path) + writers = [TranscriptWriterLease(tmp_path, "coding", f"session-{number}") for number in (1, 2)] + try: + for writer in writers: + writer.acquire() + before = snapshot(tmp_path) + + async def scenario(): + layout = AgentTranscriptFileLayout(tmp_path) + store = create_agent_transcript_file_store(layout) + assert len(await store.scan(layout.namespace)) == 2 + assert len((await store.scan_page(layout.namespace)).heads) == 2 + asyncio.run(scenario()) + catalog = AgentTranscriptSessionCatalog(tmp_path, index_writable=False) + assert len(catalog.list_summaries()) == 2 + assert snapshot(tmp_path) == before + assert all(writer.cleanup_pending for writer in writers) + assert all(not _projection_cache_path(path).exists() for path in paths) + finally: + for writer in writers: + writer.close() + + +def test_readonly_source_and_cache_symlinks_are_not_followed(tmp_path, monkeypatch): + async def scenario(): + paths = seed(tmp_path) + private = tmp_path / "private.txt" + private.write_text("not a cache", encoding="utf-8") + cache = _projection_cache_path(paths[0]) + cache.symlink_to(private) + link = tmp_path / "linked.jsonl" + link.symlink_to(paths[0]) + opened = [] + original = module._open_file_no_follow + + def open_file(path, **kwargs): + opened.append(path) + return original(path, **kwargs) + + monkeypatch.setattr(module, "_open_file_no_follow", open_file) + before = snapshot(tmp_path) + layout = AgentTranscriptFileLayout(tmp_path) + store = create_agent_transcript_file_store(layout, read_only=True) + assert len(await store.scan(layout.namespace)) == 2 + assert (await store.load(layout.key("session-1"))).snapshot.header.conversation_id == "session-1" + with pytest.raises(OSError, match="regular file"): + module._load_agent_transcript_readonly_snapshot(link) + assert cache not in opened and link not in opened and private not in opened + assert snapshot(tmp_path) == before + asyncio.run(scenario()) + + +def test_readonly_replacement_during_read_is_rejected(tmp_path, monkeypatch): + paths = seed(tmp_path) + replacement = tmp_path / "replacement" + replacement.write_bytes(paths[0].read_bytes()) + original = module.os.read + replaced = False + + def read(fd, count): + nonlocal replaced + result = original(fd, count) + if not replaced: + replacement.replace(paths[0]) + replaced = True + return result + + monkeypatch.setattr(module.os, "read", read) + with pytest.raises(OSError, match="changed"): + module._load_agent_transcript_readonly_snapshot(paths[0]) + assert replaced + + +@pytest.mark.parametrize("primary_error", [False, True]) +def test_read_descriptor_cleanup_attempts_parent_and_keeps_primary(monkeypatch, primary_error): + calls = [] + error = ValueError("test read failure") if primary_error else None + + def close(fd): + calls.append(fd) + if fd == 101: + raise OSError("test first close failure") + + monkeypatch.setattr(module.os, "close", close) + if error is None: + with pytest.raises(OSError, match="first close failure"): + module._close_read_descriptors(101, 102) + else: + module._close_read_descriptors(101, 102, primary=error) + assert error.__notes__ == ["transcript reader descriptor cleanup failed"] + assert calls == [101, 102] + + +def test_path_discovery_directory_and_collision_queries_leave_files_unchanged(tmp_path): + paths = seed(tmp_path) + before = snapshot(tmp_path) + catalog = AgentTranscriptSessionCatalog(tmp_path, index_writable=False) + assert len(catalog.list_path_summaries()) == 2 + directory = AgentTranscriptDirectoryRuntime(session_dir=tmp_path) + assert len(directory.list_discovered_session_summaries()) == 2 + assert snapshot(tmp_path) == before + duplicate = tmp_path / "duplicate.jsonl" + duplicate.write_bytes(paths[0].read_bytes()) + before = snapshot(tmp_path) + assert len(catalog.list_path_collision_summaries()) == 2 + assert snapshot(tmp_path) == before + + +@pytest.mark.parametrize("change", ["delete", "symlink"]) +def test_readonly_deferred_payload_uses_verified_bytes_after_source_changes(tmp_path, monkeypatch, change): + async def scenario(): + path = tmp_path / "session.jsonl" + records = _model_input_records() + write_agent_transcript_export(path, _header(), list(records)) + layout = AgentTranscriptFileLayout(tmp_path) + key = layout.key(_header().conversation_id) + await create_agent_transcript_file_store(layout).load(key) + snapshot = (await create_agent_transcript_file_store(layout, read_only=True).load(key)).snapshot + assert isinstance(snapshot.records[0].payload, DeferredModelInputNodeBundle) + path.unlink() + if change == "symlink": + other = tmp_path / "unrelated.txt" + other.write_text("must not be read", encoding="utf-8") + path.symlink_to(other) + + def forbid_open(*_, **__): + raise AssertionError("readonly deferred payload reopened a path") + + with monkeypatch.context() as patch: + patch.setattr(Path, "open", forbid_open) + assert snapshot.records[0].payload.nodes == records[0].payload.nodes + assert snapshot.records[1].payload.nodes == records[1].payload.nodes + asyncio.run(scenario()) + + +@pytest.mark.skipif(sys.platform != "linux", reason="POSIX parent-dir open") +@pytest.mark.parametrize("error_type", [OSError, KeyboardInterrupt]) +def test_leaf_open_failure_survives_parent_close_failure(tmp_path, monkeypatch, error_type): + failure = error_type("test leaf open") + opened, closed = [], [] + + def open_file(*args, **kwargs): + opened.append(args) + if len(opened) == 1: + return 101 + raise failure + + def close(fd): + closed.append(fd) + raise OSError("test parent close") + + with monkeypatch.context() as patch: + patch.setattr(module.os, "open", open_file) + patch.setattr(module.os, "close", close) + with pytest.raises(error_type) as raised: + module._open_file_no_follow(tmp_path / "leaf", flags=0) + assert raised.value is failure + assert closed == [101] + assert failure.__notes__ == ["transcript reader descriptor cleanup failed"] + + +@pytest.mark.skipif(sys.platform != "linux", reason="POSIX FIFO replacement") +@pytest.mark.parametrize("prefix", [False, True]) +def test_readonly_fifo_replacement_cannot_block_open(tmp_path, monkeypatch, prefix): + path = tmp_path / "source.jsonl" + path.write_bytes(b"fixture\n") + original = module.os.open + swapped = False + + def open_file(name, flags, *args, **kwargs): + nonlocal swapped + if name == path.name and kwargs.get("dir_fd") is not None: + assert flags & module.os.O_NONBLOCK # Guard the negative test from hanging. + path.unlink() + module.os.mkfifo(path) + swapped = True + return original(name, flags, *args, **kwargs) + + monkeypatch.setattr(module.os, "open", open_file) + with pytest.raises(OSError, match="identity changed"): + if prefix: + module._read_stable_regular_prefix(path, max_bytes=100) + else: + module._read_stable_regular_file(path) + assert swapped + + +def test_writable_store_namespace_page_is_readonly_but_key_load_can_build_cache(tmp_path): + async def scenario(): + paths = seed(tmp_path) + layout = AgentTranscriptFileLayout(tmp_path) + store = create_agent_transcript_file_store(layout) + before = snapshot(tmp_path) + assert len((await store.scan_page(layout.namespace)).heads) == 2 + assert snapshot(tmp_path) == before + await store.load(layout.key("session-1")) + assert _projection_cache_path(paths[0]).exists() + assert not _projection_cache_path(paths[1]).exists() + asyncio.run(scenario()) diff --git a/tests/harness/transcript/test_shared_store_admission.py b/tests/harness/transcript/test_shared_store_admission.py new file mode 100644 index 000000000..f477b025c --- /dev/null +++ b/tests/harness/transcript/test_shared_store_admission.py @@ -0,0 +1,615 @@ +from __future__ import annotations + +import asyncio +import json +import os +import selectors +import subprocess +import sys + +import pytest + +from loushang.harness.transcript import ProductTranscriptSession +from loushang.harness.transcript.store_admission import TranscriptStoreAdmission +from loushang.harness.transcript.writer_lease import TranscriptWriterError + +from .test_owned_session_factory import factory, new +from .test_writer_images import message +from .test_writer_lifecycle import assert_busy + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux shared store family") + + +def test_fresh_sibling_stores_share_original_attachment_domain(tmp_path): + async def scenario(): + state = tmp_path / "state/session-stores" + first, second = factory(store_state_root=state), factory(store_state_root=state) + a = b = None + try: + a = await new(first, tmp_path / "data/project-a", "a") + b = await new(second, tmp_path / "data/project-b", "b") + assert a.context.session_dir != b.context.session_dir + assert a._writer_owner._blob_writer._expected_root_identity == b._writer_owner._blob_writer._expected_root_identity + assert (tmp_path / "data/session-assets/.locks").is_dir() + assert a._writer_owner._store_admission._binding.family_id == b._writer_owner._store_admission._binding.family_id + finally: + if b is not None: + await b.dispose() + if a is not None: + await a.dispose() + await second.close() + await first.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("loss", ["root", "witness", "both"]) +def test_known_sibling_loss_never_becomes_a_new_member(tmp_path, loss): + state = tmp_path / "state/session-stores" + first = TranscriptStoreAdmission(tmp_path / "data/project-a", state_root=state, create_if_missing=True) + second = TranscriptStoreAdmission(tmp_path / "data/project-b", state_root=state, create_if_missing=True) + try: + first.open() + first.close() + second.open() + finally: + second.close() + first.close() + if loss in {"root", "both"}: + second.root.rename(tmp_path / "saved-root") + if loss in {"witness", "both"}: + second.witness_root.rename(tmp_path / "saved-witness") + attempted = TranscriptStoreAdmission(second.root, state_root=state, create_if_missing=True) + try: + with pytest.raises(TranscriptWriterError, match="conflict|incomplete|unavailable"): + attempted.open() + if loss in {"root", "both"}: + assert not second.root.exists() + finally: + attempted.close() + + +@pytest.mark.parametrize("also_member", [False, True]) +def test_missing_family_never_falls_back_to_v1_adoption(tmp_path, also_member): + root, state = tmp_path / "data/project-a", tmp_path / "state/stores" + admission = TranscriptStoreAdmission(root, state_root=state, create_if_missing=True) + try: + admission.open() + finally: + admission.close() + admission._family.root.rename(tmp_path / "saved-family") + if also_member: + admission.witness_root.rename(tmp_path / "saved-member") + retry = TranscriptStoreAdmission(root, state_root=state, create_if_missing=True) + try: + with pytest.raises(TranscriptWriterError, match="conflict|unavailable|incomplete"): + retry.open() + assert not admission._family.root.exists() + if also_member: + assert not admission.witness_root.exists() + finally: + retry.close() + + +@pytest.mark.parametrize("domain", ["session-assets", "session-assets/.locks", ".session-blob-writers"]) +@pytest.mark.parametrize("replace", [False, True]) +def test_registered_shared_domain_cannot_be_recreated_or_adopted(tmp_path, domain, replace): + root, state = tmp_path / "data/project-a", tmp_path / "state/stores" + admission = TranscriptStoreAdmission(root, state_root=state, create_if_missing=True) + try: + admission.open() + finally: + admission.close() + selected = root.parent / domain + selected.rename(tmp_path / "saved-domain") + if replace: + selected.mkdir(mode=0o700) + retry = TranscriptStoreAdmission(root.parent / "project-b", state_root=state, create_if_missing=True) + try: + with pytest.raises(TranscriptWriterError, match="conflict|unavailable"): + retry.open() + assert selected.exists() is replace + assert not retry.root.exists() + finally: + retry.close() + + +@pytest.mark.parametrize("domain", ["session-assets", "session-assets/.locks"]) +@pytest.mark.parametrize("replace", [False, True]) +def test_live_blob_operations_check_original_shared_domains(tmp_path, domain, replace): + async def scenario(): + root, state = tmp_path / "data/project-a", tmp_path / "state/stores" + selected = factory(store_state_root=state) + session = await new(selected, root) + moved = root.parent / domain + moved.rename(tmp_path / "saved-domain") + if replace: + moved.mkdir(mode=0o700) + try: + with pytest.raises(OSError): + await ProductTranscriptSession(lifecycle_session=session).append_message(message()) + assert moved.exists() is replace + if replace: + assert not tuple(moved.iterdir()) + finally: + await session.dispose() + await selected.close() + + asyncio.run(scenario()) + + +def test_short_lock_handoff_keeps_root_pin_and_rejects_replacement(tmp_path, monkeypatch): + async def scenario(): + root, state = tmp_path / "data/project-a", tmp_path / "state/stores" + selected = factory(store_state_root=state) + release = TranscriptStoreAdmission.release_locks + observed = [] + + def replaced(admission): + release(admission) + assert not admission._witness.cleanup_pending + fd = admission._store._fds["root"] + original = os.fstat(fd) + root.rename(tmp_path / "old-store") + root.mkdir(mode=0o700) + assert os.fstat(fd).st_ino == original.st_ino + observed.append(1) + + try: + monkeypatch.setattr(TranscriptStoreAdmission, "release_locks", replaced) + with pytest.raises(TranscriptWriterError, match="conflict"): + await new(selected, root) + assert observed == [1] and not tuple(root.iterdir()) + finally: + await selected.close() + + asyncio.run(scenario()) + + +def test_valid_standalone_v1_restores_without_enrollment_but_cannot_add_sibling(tmp_path): + root, state = tmp_path / "data/project-a", tmp_path / "state/stores" + root.parent.mkdir(mode=0o700) + root.mkdir(mode=0o700) + legacy = TranscriptStoreAdmission(root, state_root=state) + try: + expected = legacy.open() + finally: + legacy.close() + assert json.loads((legacy.witness_root / "admission.json").read_bytes())["version"].endswith("/v1") + (root.parent / "session-assets").mkdir(mode=0o700) + restored = TranscriptStoreAdmission(root, state_root=state) + try: + assert restored.open() == expected + assert not restored._family.present + finally: + restored.close() + sibling = TranscriptStoreAdmission(root.parent / "project-b", state_root=state, create_if_missing=True) + try: + with pytest.raises(TranscriptWriterError, match="conflict"): + sibling.open() + assert not sibling.root.exists() and not sibling._family.present + finally: + sibling.close() + + +def test_v1_exact_member_stays_protected_without_global_cross_path_inference(tmp_path): + root, state = tmp_path / "data/project-a", tmp_path / "state/stores" + root.parent.mkdir(mode=0o700) + root.mkdir(mode=0o700) + legacy = TranscriptStoreAdmission(root, state_root=state) + try: + legacy.open() + finally: + legacy.close() + root.parent.rename(tmp_path / "saved-data") + exact = TranscriptStoreAdmission(root, state_root=state, create_if_missing=True) + try: + with pytest.raises(TranscriptWriterError): + exact.open() + assert not root.parent.exists() + finally: + exact.close() + sibling = TranscriptStoreAdmission(root.parent / "project-b", state_root=state, create_if_missing=True) + try: + # v1 has no parent path mapping: it cannot identify an unknown sibling + # after that parent disappears. Do not pretend this is a v2 guarantee. + assert sibling.open().family_id is not None + finally: + sibling.close() + + +def test_valid_unrelated_v1_does_not_block_fresh_family_or_change_old_evidence(tmp_path): + root, state = tmp_path / "old/project", tmp_path / "state/stores" + root.parent.mkdir(mode=0o700) + root.mkdir(mode=0o700) + old = TranscriptStoreAdmission(root, state_root=state) + try: + binding = old.open() + witness = old.witness_root / "admission.json" + before = witness.read_bytes() + finally: + old.close() + fresh = TranscriptStoreAdmission(tmp_path / "new/project", state_root=state, create_if_missing=True) + try: + assert fresh.open().family_id is not None + assert witness.read_bytes() == before + finally: + fresh.close() + restored = TranscriptStoreAdmission(root, state_root=state) + try: + assert restored.open() == binding + finally: + restored.close() + + +@pytest.mark.parametrize("field,value", [ + ("extra", True), ("store", "0" * 64), ("operation", "invalid"), + ("phase", "initializing"), ("parent", [True, 1]), + ("witness", [0, 1]), ("lock", [0, 1]), +]) +def test_unrelated_bad_v1_is_not_ignored(tmp_path, field, value): + root, state = tmp_path / "old/project", tmp_path / "state/stores" + root.parent.mkdir(mode=0o700) + root.mkdir(mode=0o700) + old = TranscriptStoreAdmission(root, state_root=state) + try: + old.open() + witness = old.witness_root / "admission.json" + finally: + old.close() + record = json.loads(witness.read_bytes()) + record[field] = value + witness.write_text(json.dumps(record)) + before = witness.read_bytes() + fresh = TranscriptStoreAdmission(tmp_path / "new/project", state_root=state, create_if_missing=True) + try: + with pytest.raises(TranscriptWriterError): + fresh.open() + assert not fresh.root.parent.exists() + assert witness.read_bytes() == before + finally: + fresh.close() + + +def test_empty_but_known_v1_parent_does_not_grant_new_family(tmp_path): + root, state = tmp_path / "old/project", tmp_path / "state/stores" + root.parent.mkdir(mode=0o700) + root.mkdir(mode=0o700) + old = TranscriptStoreAdmission(root, state_root=state) + try: + old.open() + finally: + old.close() + root.rmdir() + sibling = TranscriptStoreAdmission(root.parent / "sibling", state_root=state, create_if_missing=True) + try: + with pytest.raises(TranscriptWriterError, match="conflict"): + sibling.open() + assert not sibling.root.exists() + assert not sibling._family.present + finally: + sibling.close() + + +def test_data_root_replaced_after_legacy_validation_is_not_adopted(tmp_path, monkeypatch): + root, state = tmp_path / "old/project", tmp_path / "state/stores" + root.parent.mkdir(mode=0o700) + root.mkdir(mode=0o700) + old = TranscriptStoreAdmission(root, state_root=state) + try: + old.open() + finally: + old.close() + data = tmp_path / "new" + data.mkdir(mode=0o700) + fresh = TranscriptStoreAdmission(data / "project", state_root=state, create_if_missing=True) + original = fresh._family._validate_legacy_evidence + + def validate_then_replace(*args): + original(*args) + data.rename(tmp_path / "retained-new") + data.mkdir(mode=0o700) + (data / "sentinel").write_bytes(b"do not adopt") + + monkeypatch.setattr(fresh._family, "_validate_legacy_evidence", validate_then_replace) + try: + with pytest.raises(TranscriptWriterError): + fresh.open() + assert {path.name for path in data.iterdir()} == {"sentinel"} + assert (data / "sentinel").read_bytes() == b"do not adopt" + finally: + fresh.close() + + +def test_legacy_evidence_unknown_close_retains_parent_and_never_recloses_reused_fd(tmp_path, monkeypatch): + from loushang.harness.journal._rooted_io import RootedDirectory + + root, state = tmp_path / "old/project", tmp_path / "state/stores" + root.parent.mkdir(mode=0o700) + root.mkdir(mode=0o700) + old = TranscriptStoreAdmission(root, state_root=state) + try: + old.open() + key = old.witness_root.name + finally: + old.close() + fresh = TranscriptStoreAdmission(tmp_path / "new/project", state_root=state, create_if_missing=True) + original_child, original_close = RootedDirectory.child, os.close + retained = {} + calls = [] + + def capture(directory, name, **kwargs): + child = original_child(directory, name, **kwargs) + if name == key and not retained: + retained.update(fd=child._fd, operation=child._operation) + return child + + def lose_close(fd): + if fd == retained.get("fd"): + assert not calls, "retried an unknown close against a reused descriptor" + original_close(fd) + replacement = os.open(os.devnull, os.O_RDONLY) + if replacement != fd: + os.dup2(replacement, fd) + original_close(replacement) + calls.append(fd) + raise OSError("test legacy evidence close receipt lost") + original_close(fd) + + try: + with monkeypatch.context() as patch: + patch.setattr(RootedDirectory, "child", capture) + patch.setattr(os, "close", lose_close) + with pytest.raises(TranscriptWriterError): + fresh.open() + assert calls + for _ in range(2): + with pytest.raises(OSError, match="unknown"): + fresh.close() + assert fresh.cleanup_pending + os.fstat(fresh._family._state._fds["root"]) + assert os.fstat(calls[0]).st_rdev == os.stat(os.devnull).st_rdev + assert calls == [retained["fd"]] + finally: + if calls: + # Only this injector knows the original close completed. Production + # retains unknown debt; the test removes that witnessed tombstone. + operation = retained["operation"] + operation.descriptors.pop(calls[0], None) + operation.directory_fds.discard(calls[0]) + original_close(calls[0]) + fresh.close() + + +def test_unregistered_legacy_shared_assets_are_not_implicitly_migrated(tmp_path): + root, state = tmp_path / "data/project-a", tmp_path / "state/stores" + root.parent.mkdir(mode=0o700) + root.mkdir(mode=0o700) + (root.parent / "session-assets").mkdir(mode=0o700) + source = root / "legacy.jsonl" + source.write_bytes(b"legacy transcript") + admission = TranscriptStoreAdmission(root, state_root=state) + try: + with pytest.raises(TranscriptWriterError, match="conflict"): + admission.open() + assert source.read_bytes() == b"legacy transcript" + assert not state.exists() + finally: + admission.close() + + +def test_surviving_v2_state_prevents_v1_fallback_after_family_and_shared_loss(tmp_path): + state = tmp_path / "state/stores" + first = TranscriptStoreAdmission(tmp_path / "data/project-a", state_root=state, create_if_missing=True) + try: + first.open() + finally: + first.close() + first._family.root.rename(tmp_path / "saved-family") + (first.root.parent / "session-assets").rename(tmp_path / "saved-assets") + (first.root.parent / ".session-blob-writers").rename(tmp_path / "saved-writers") + candidate = first.root.parent / "empty-legacy-candidate" + candidate.mkdir(mode=0o700) + admission = TranscriptStoreAdmission(candidate, state_root=state) + try: + with pytest.raises(TranscriptWriterError, match="conflict"): + admission.open() + assert not admission.witness_root.exists() + finally: + admission.close() + + +@pytest.mark.parametrize("phase", ["shared", "member"]) +def test_incomplete_shared_vs_member_facts_have_distinct_scope(tmp_path, monkeypatch, phase): + state = tmp_path / "state/stores" + first = TranscriptStoreAdmission(tmp_path / "data/project-a", state_root=state, create_if_missing=True) + if phase == "shared": + original = first._family._assets.acquire_child + + def fail(*args, **kwargs): + original(*args, **kwargs) + raise OSError("test shared publication not settled") + + monkeypatch.setattr(first._family._assets, "acquire_child", fail) + else: + def fail(*args, **kwargs): + raise OSError("test member publication not settled") + + monkeypatch.setattr(first, "_open_store", fail) + try: + with pytest.raises(TranscriptWriterError, match="unavailable"): + first.open() + finally: + first.close() + retry = TranscriptStoreAdmission(first.root, state_root=state, create_if_missing=True) + sibling = TranscriptStoreAdmission(first.root.parent / "project-b", state_root=state, create_if_missing=True) + try: + with pytest.raises(TranscriptWriterError, match="incomplete"): + retry.open() + retry.close() + if phase == "shared": + with pytest.raises(TranscriptWriterError, match="incomplete"): + sibling.open() + else: + assert sibling.open().family_id is not None + assert not first.root.exists() + finally: + retry.close() + sibling.close() + + +def test_family_discovery_does_not_enroll_an_unseen_sibling(tmp_path): + from .test_store_admission import snapshot + + state = tmp_path / "state/stores" + first = TranscriptStoreAdmission(tmp_path / "data/project-a", state_root=state, create_if_missing=True) + try: + first.open() + finally: + first.close() + before = snapshot(tmp_path) + sibling = TranscriptStoreAdmission(first.root.parent / "project-b", state_root=state, create_if_missing=True) + try: + assert sibling.inspect() is None + finally: + sibling.close() + assert snapshot(tmp_path) == before + + +def test_distinct_fresh_data_roots_share_state_without_reclassifying_members(tmp_path): + state = tmp_path / "state/stores" + bindings = [] + for name in ("one", "two"): + admission = TranscriptStoreAdmission(tmp_path / name / "sessions", state_root=state, create_if_missing=True) + try: + bindings.append(admission.open()) + finally: + admission.close() + assert bindings[0].family_id != bindings[1].family_id + assert bindings[0].parent_identity != bindings[1].parent_identity + + +def test_family_member_capacity_is_no_effect_and_does_not_evict_old_identity(tmp_path, monkeypatch): + from loushang.harness.transcript import _store_family + + from .test_store_admission import snapshot + + state = tmp_path / "state/stores" + first = TranscriptStoreAdmission(tmp_path / "data/project-a", state_root=state, create_if_missing=True) + try: + binding = first.open() + finally: + first.close() + before = snapshot(tmp_path) + monkeypatch.setattr(_store_family, "_MAX_MEMBERS", 1) + sibling = TranscriptStoreAdmission(first.root.parent / "project-b", state_root=state, create_if_missing=True) + try: + with pytest.raises(TranscriptWriterError, match="capacity"): + sibling.open() + finally: + sibling.close() + restored = TranscriptStoreAdmission(first.root, state_root=state) + try: + assert restored.open() == binding + finally: + restored.close() + assert snapshot(tmp_path) == before + + +_MEMBER_CHILD = """ +import sys, time +from pathlib import Path +from loushang.harness.transcript.store_admission import TranscriptStoreAdmission +from loushang.harness.transcript.writer_lease import TranscriptWriterError +base, name = Path(sys.argv[1]), sys.argv[2] +print('ready', flush=True) +assert sys.stdin.readline().strip() == 'go' +deadline = time.monotonic() + 5 +while True: + owner = TranscriptStoreAdmission(base / 'data' / name, state_root=base / 'state/stores', create_if_missing=True) + try: + result = owner.open() + except TranscriptWriterError as error: + owner.close() + if error.code != 'busy' or owner.cleanup_pending or time.monotonic() >= deadline: + raise + time.sleep(.01) + else: + owner.close() + print(result.member_id, flush=True) + break +""" + + +def test_two_process_member_additions_preserve_both_entries(tmp_path): + state = tmp_path / "state/stores" + first = TranscriptStoreAdmission(tmp_path / "data/project-a", state_root=state, create_if_missing=True) + try: + binding = first.open() + finally: + first.close() + children = [] + try: + for name in ("project-b", "project-c"): + child = subprocess.Popen([sys.executable, "-c", _MEMBER_CHILD, str(tmp_path), name], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + children.append(child) + with selectors.DefaultSelector() as selected: + selected.register(child.stdout, selectors.EVENT_READ) + assert selected.select(15), "member child did not reach gate" + assert child.stdout.readline().strip() == "ready" + for child in children: + child.stdin.write("go\n") + child.stdin.flush() + for child in children: + output, error = child.communicate(timeout=15) + assert child.returncode == 0, error + assert len(output.strip()) == 32 + finally: + for child in children: + if child.poll() is None: + child.kill() + child.communicate(timeout=5) + raw = json.loads((first._family.root / "admission.json").read_bytes()) + assert {member["name"] for member in raw["members"].values()} == {"project-a", "project-b", "project-c"} + reopened = TranscriptStoreAdmission(first.root, state_root=state) + try: + assert reopened.open() == binding # Ledger revision did not invalidate A. + reopened.check() + finally: + reopened.close() + + +def test_handoff_pin_close_unknown_does_not_release_lifetime_writers(tmp_path, monkeypatch): + async def scenario(): + root, state = tmp_path / "data/project-a", tmp_path / "state/stores" + selected = factory(store_state_root=state) + session = await new(selected, root) + admission = session._writer_owner._store_admission + original = admission._family._assets + fd = original._fds["root"] + close, calls = os.close, [] + + def lost(value): + close(value) + if value == fd and not calls: + calls.append(value) + raise OSError("test lost pin close receipt") + + try: + with monkeypatch.context() as patch: + patch.setattr(os, "close", lost) + with pytest.raises(TranscriptWriterError, match="unavailable"): + await session.dispose() + assert_busy(root) + with pytest.raises(TranscriptWriterError, match="unavailable"): + await session.dispose() + assert calls == [fd] + finally: + # Only the test injector witnessed the completed close syscall. + original._unknown.remove("root") + del original._fds["root"] + await session.dispose() + await selected.close() + + asyncio.run(scenario()) diff --git a/tests/harness/transcript/test_writer_blobs.py b/tests/harness/transcript/test_writer_blobs.py new file mode 100644 index 000000000..7c167caec --- /dev/null +++ b/tests/harness/transcript/test_writer_blobs.py @@ -0,0 +1,344 @@ +from __future__ import annotations + +import asyncio +import os +import sys +import threading + +import pytest + +from loushang.harness.artifacts._writer_lease import ( + SessionBlobWriterError, + SessionBlobWriterLease, +) +from loushang.harness.journal import _directory_lease as lease_module +from loushang.harness.transcript import AgentTranscriptLifecycle +from loushang.harness.transcript.writer_lease import TranscriptWriterLease + +from .test_lifecycle import _header +from .test_runtime_profile import _runtime +from .test_writer_io import wait_until +from .test_writer_lifecycle import assert_available, assert_busy + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux joint writer") + + +def prepare(root, bind=None): + runtime = _runtime("coding") + lifecycle = AgentTranscriptLifecycle( + bind_runtime=runtime.bind_lifecycle, + bind_runtime_owned=bind or runtime.bind_lifecycle_owned, + ) + context = lifecycle.new_context( + session_dir=root, cwd="/workspace", persist=True, + header=_header(), session_file=root / "session.jsonl", + ) + writer = TranscriptWriterLease(root, "coding", context.header.conversation_id) + writer.acquire() + owner = lifecycle.prepare_writer( + context, runtime.resolve(persist=True), writer=writer, product_id="coding", + defer_materialization=False, manage_blobs=True, + ) + return owner, writer + + +def blob_busy(root): + other = SessionBlobWriterLease(root.parent, "work", _header().conversation_id) + try: + with pytest.raises(SessionBlobWriterError, match="busy"): + other.acquire() + finally: + other.close() + + +@pytest.fixture +def root(tmp_path): + value = tmp_path / "sessions" + value.mkdir(mode=0o700) + return value + + +def test_sibling_transcripts_contend_before_second_runtime_binding(root): + async def scenario(): + first, _ = prepare(root) + assert first._blob_writer is not None and not first._blob_writer.cleanup_pending + assert not (root.parent / ".session-blob-writers").exists() + await first.create() + sibling = root.parent / "other-sessions" + sibling.mkdir(mode=0o700) + calls = [] + + async def forbidden(*args): + calls.append(args) + raise AssertionError("busy blob authority reached runtime binding") + + second, _ = prepare(sibling, forbidden) + try: + with pytest.raises(SessionBlobWriterError, match="busy"): + await second.create() + driver = second._driver + with pytest.raises(SessionBlobWriterError, match="busy"): + await second.create() + assert second._driver is driver and not calls + assert not (sibling / "session.jsonl").exists() + assert_busy(root) + assert_busy(sibling) + blob_busy(root) + await second.dispose() + assert_available(sibling) + blob_busy(root) + finally: + await first.dispose() + assert_available(root) + third, _ = prepare(sibling) + await third.create() + await third.dispose() + + asyncio.run(scenario()) + + +def test_cancel_during_second_native_acquire_retains_original_driver(root, monkeypatch): + async def scenario(): + owner, _ = prepare(root) + entered, release = threading.Event(), threading.Event() + acquire = owner._blob_writer.acquire + + def blocked(): + entered.set() + assert release.wait(5) + acquire() + + monkeypatch.setattr(owner._blob_writer, "acquire", blocked) + creating = asyncio.create_task(owner.create()) + try: + await wait_until(entered.is_set) + driver = owner._driver + creating.cancel() + with pytest.raises(asyncio.CancelledError): + await creating + closing = asyncio.create_task(owner.dispose()) + await wait_until(lambda: owner.closing) + assert not driver.done() and owner.cleanup_pending + assert_busy(root) + closing.cancel() + with pytest.raises(asyncio.CancelledError): + await closing + rejoin = asyncio.create_task(owner.dispose()) + release.set() + await rejoin + assert owner._driver is driver and not owner.cleanup_pending + assert_available(root) + other = SessionBlobWriterLease(root.parent, "work", _header().conversation_id) + try: + other.acquire() + finally: + other.close() + finally: + release.set() + + asyncio.run(scenario()) + + +def test_scope_checks_blob_binding_before_actual_store_io(root, monkeypatch): + async def scenario(): + owner, _ = prepare(root) + session = await owner.create() + directory = root.parent / ".session-blob-writers" + directory.rename(directory.with_name(directory.name + ".saved")) + directory.mkdir(mode=0o700) + + def forbidden(*args, **kwargs): + raise AssertionError("invalid blob binding reached Store IO") + + monkeypatch.setattr(session.runtime_binding.store, "_load_sync", forbidden) + with pytest.raises(SessionBlobWriterError, match="conflict"): + await session.runtime_binding.store.load(session.runtime_binding.key) + assert owner._active_io == 0 + await owner.dispose() + assert_available(root) + + asyncio.run(scenario()) + + +def test_successful_blob_close_is_not_repeated_when_transcript_close_retries(root, monkeypatch): + async def scenario(): + owner, writer = prepare(root) + await owner.create() + events = [] + blob_close = owner._close_blob_writer + transcript_close = writer._close_claimed + + def close_blob(): + events.append("blob") + blob_close() + + def close_transcript(value): + events.append("transcript") + if events.count("transcript") == 1: + raise RuntimeError("test before native close") + transcript_close(value) + + monkeypatch.setattr(owner, "_close_blob_writer", close_blob) + monkeypatch.setattr(writer, "_close_claimed", close_transcript) + with pytest.raises(RuntimeError, match="before native"): + await owner.dispose() + assert_busy(root) + await owner.dispose() + await owner.dispose() + assert events == ["blob", "transcript", "transcript"] + assert not owner.cleanup_pending + assert_available(root) + + asyncio.run(scenario()) + + +def test_unknown_blob_close_keeps_transcript_lock_and_never_closes_reused_fd(root, monkeypatch): + async def scenario(): + owner, writer = prepare(root) + await owner.create() + fd = owner._blob_writer._fds["lock"] + native_close = os.close + replacements, calls = [], [] + + def failed_close(descriptor): + calls.append(descriptor) + native_close(descriptor) + if descriptor == fd: + replacement = os.open("/dev/null", os.O_RDONLY) + assert replacement == fd + replacements.append(replacement) + raise OSError("test close receipt lost") + + try: + with monkeypatch.context() as patch: + patch.setattr(lease_module.os, "close", failed_close) + for _ in range(2): + with pytest.raises(SessionBlobWriterError, match="unavailable"): + await owner.dispose() + assert calls.count(fd) == 1 + assert owner.cleanup_pending and owner._close_task is None + assert_busy(root) + assert os.fstat(replacements[0]) + finally: + # The test knows which injected replacement it owns. Production + # must retain the uncertainty and cannot perform this test cleanup. + for replacement in replacements: + native_close(replacement) + writer._close_claimed(owner) + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("stage", ["borrow_file_io", "_claim_binding"]) +def test_partial_second_admission_retained_until_explicit_dispose(root, monkeypatch, stage): + async def scenario(): + calls = [] + + async def forbidden(*args): + calls.append(args) + raise AssertionError("failed blob admission reached binder") + + owner, _ = prepare(root, forbidden) + + def failure(*args, **kwargs): + raise RuntimeError("test after blob acquire") + + monkeypatch.setattr(owner._blob_writer, stage, failure) + with pytest.raises(RuntimeError, match="after blob acquire"): + await owner.create() + driver = owner._driver + with pytest.raises(RuntimeError, match="after blob acquire"): + await owner.create() + assert owner._driver is driver and not calls + assert owner._blob_writer.claimed_owner is None + assert_busy(root) + blob_busy(root) + assert not (root / "session.jsonl").exists() + await owner.dispose() + assert not owner.cleanup_pending + assert_available(root) + other = SessionBlobWriterLease(root.parent, "work", _header().conversation_id) + try: + other.acquire() + finally: + other.close() + + asyncio.run(scenario()) + + +def test_blob_io_cleanup_failure_retains_both_writers_until_retry(root, monkeypatch): + async def scenario(): + owner, _ = prepare(root) + await owner.create() + cleanup = owner._blob_file_io.cleanup + calls = 0 + + def retryable(): + nonlocal calls + calls += 1 + if calls == 1: + raise OSError("test blob cleanup unavailable") + cleanup() + + monkeypatch.setattr(owner._blob_file_io, "cleanup", retryable) + with pytest.raises(OSError, match="blob cleanup"): + await owner.dispose() + assert_busy(root) + blob_busy(root) + assert owner.cleanup_pending and owner._blob_close_task is None + await owner.dispose() + assert calls == 2 and not owner.cleanup_pending + assert_available(root) + + asyncio.run(scenario()) + + +def test_actual_product_blob_recovery_keeps_both_leases_until_settlement(root, monkeypatch): + from loushang.ai.types import ImagePart, UserMessage + from loushang.harness.journal import _rooted_io as native + from loushang.harness.transcript import ProductTranscriptSession + from loushang.harness.transcript import product_session as product_module + + async def scenario(): + owner, _ = prepare(root) + lifecycle = await owner.create() + session = ProductTranscriptSession(lifecycle_session=lifecycle) + construct = product_module.SessionBlobStore + seen = [] + + def selected(*args, **kwargs): + assert kwargs["file_io"] is owner.blob_file_io + seen.append(True) + return construct(*args, **kwargs) + + monkeypatch.setattr(product_module, "SessionBlobStore", selected) + first = UserMessage(role="user", content=[ImagePart(type="image", data="aGVsbG8=", mime_type="image/png")], timestamp=1.0) + second = UserMessage(role="user", content=[ImagePart(type="image", data="c2Vjb25k", mime_type="image/png")], timestamp=2.0) + await session.append_message(first) + replace = native.os.replace + + def unavailable(*args, **kwargs): + if args[1] == "manifest.json": + raise OSError("test both manifests unavailable") + return replace(*args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(native.os, "replace", unavailable) + with pytest.raises(OSError, match="manifests unavailable"): + await session.append_message(second) + assert len(seen) == 2 and owner.cleanup_pending + assert_busy(root) + blob_busy(root) + with pytest.raises(OSError, match="manifests unavailable"): + await owner.dispose() + assert_busy(root) + blob_busy(root) + await owner.dispose() + assert not owner.cleanup_pending + assert_available(root) + store = construct(root.parent, _header().conversation_id) + assert len(store.records) == 1 + assert store.read_bytes(store.records[0]) == b"hello" + assert {path.name for path in store.objects_root.iterdir()} == {store.records[0].blob_id} + + asyncio.run(scenario()) diff --git a/tests/harness/transcript/test_writer_hydration.py b/tests/harness/transcript/test_writer_hydration.py new file mode 100644 index 000000000..fd223b66c --- /dev/null +++ b/tests/harness/transcript/test_writer_hydration.py @@ -0,0 +1,304 @@ +from __future__ import annotations + +import asyncio +import sys +from contextlib import contextmanager + +import pytest + +from loushang.ai.types import ImagePart +from loushang.harness.artifacts import SessionBlobStore +from loushang.harness.transcript import ProductTranscriptSession +from loushang.harness.transcript import product_session as product_module +from loushang.harness.transcript.model_input_blobs import SessionModelInputBlobCodec +from loushang.harness.transcript.writer_lease import TranscriptWriterError + +from .test_runtime_profile import _runtime +from .test_writer_images import message, prepare + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux owned hydration") + + +def setup(root): + root.mkdir(mode=0o700) + runtime = _runtime("coding") + return prepare(root, runtime, runtime.bind_lifecycle_owned)[0] + + +def logical_image(): + return {"type": "image", "data": "aGVsbG8=", "mimeType": "image/png"} + + +def invoke(session, codec, action): + if action == "context": + return session.build_session_context() + if action == "new_codec": + return session._model_input_binary_codec(active_only=True) + if action == "hydrate": + return codec.hydrate_mapping({}) + return codec.externalize_mapping({}) + + +@pytest.mark.parametrize("action", ["context", "new_codec", "hydrate", "externalize"]) +def test_closed_owned_consumers_reject_even_without_images(tmp_path, monkeypatch, action): + async def scenario(): + owner = setup(tmp_path / "sessions") + session = ProductTranscriptSession(lifecycle_session=await owner.create()) + codec = session._model_input_binary_codec(active_only=True) + await owner.dispose() + + def forbidden(*args, **kwargs): + raise AssertionError("closed consumer accessed native storage") + + monkeypatch.setattr(product_module, "SessionBlobStore", forbidden) + monkeypatch.setattr(owner, "_check_writers", forbidden) + with pytest.raises(TranscriptWriterError, match="closed"): + invoke(session, codec, action) + assert owner._active_io == 0 and owner._io_drained.is_set() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("action", ["context", "codec"]) +def test_hydration_uses_original_tree_after_admission(tmp_path, monkeypatch, action): + async def scenario(): + data = tmp_path / "data" + data.mkdir(mode=0o700) + owner = setup(data / "sessions") + session = ProductTranscriptSession(lifecycle_session=await owner.create()) + try: + await session.append_message(message()) + codec = session._model_input_binary_codec(active_only=True) + projected = codec.externalize_mapping(logical_image()) + assert projected.replacement_count == 1 + check = owner._check_writers + + def swap_after_check(): + check() + data.rename(tmp_path / "original") + data.mkdir(mode=0o700) + (data / "sentinel").write_bytes(b"replacement") + + with monkeypatch.context() as patch: + patch.setattr(owner, "_check_writers", swap_after_check) + if action == "context": + context = session.build_session_context() + assert context.messages[-1].content == [ + ImagePart(type="image", data="aGVsbG8=", mime_type="image/png") + ] + else: + assert codec.hydrate_mapping(projected.value) == logical_image() + assert tuple(path.name for path in data.iterdir()) == ("sentinel",) + assert (data / "sentinel").read_bytes() == b"replacement" + assert owner._active_io == 0 + finally: + await owner.dispose() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("where", ["thread", "other_loop", "no_loop"]) +def test_owned_sync_consumers_reject_wrong_execution_context(tmp_path, monkeypatch, where): + owner_loop = asyncio.new_event_loop() + owner = setup(tmp_path / "sessions") + + async def create(): + session = ProductTranscriptSession(lifecycle_session=await owner.create()) + return session, session._model_input_binary_codec(active_only=True) + + session, codec = owner_loop.run_until_complete(create()) + + def reject(): + for action in ("context", "new_codec", "hydrate", "externalize"): + with pytest.raises(RuntimeError): + invoke(session, codec, action) + assert owner._active_io == 0 + + async def call(): + reject() + + async def in_thread(): + await asyncio.to_thread(reject) + + def forbidden(*args, **kwargs): + raise AssertionError("wrong execution context reached storage") + + try: + with monkeypatch.context() as patch: + patch.setattr(owner, "_check_writers", forbidden) + patch.setattr(product_module, "SessionBlobStore", forbidden) + if where == "no_loop": + reject() + elif where == "other_loop": + asyncio.run(call()) + else: + owner_loop.run_until_complete(in_thread()) + finally: + owner_loop.run_until_complete(owner.dispose()) + owner_loop.close() + + +def test_sync_scope_checks_and_nested_errors_drain(tmp_path, monkeypatch): + async def scenario(): + owner = setup(tmp_path / "sessions") + lifecycle = await owner.create() + session = ProductTranscriptSession(lifecycle_session=lifecycle) + codec = session._model_input_binary_codec(active_only=True) + try: + async with lifecycle.operation_scope(): + assert owner._active_io == 1 + + def unavailable(): + raise OSError("test writer check failed") + + with monkeypatch.context() as patch: + patch.setattr(owner, "_check_writers", unavailable) + with pytest.raises(OSError, match="writer check failed"): + session.build_session_context() + assert owner._active_io == 1 + with pytest.raises(ValueError): + codec.hydrate_mapping({"$loushang.sessionBlob": {"version": -1}}) + assert owner._active_io == 1 + assert owner._active_io == 0 and owner._io_drained.is_set() + finally: + await owner.dispose() + + asyncio.run(scenario()) + + +def test_graph_transfer_preserves_consumers_until_graph_close(tmp_path): + async def scenario(): + owner = setup(tmp_path / "sessions") + lifecycle = await owner.create() + session = ProductTranscriptSession(lifecycle_session=lifecycle) + try: + await session.append_message(message()) + codec = session._model_input_binary_codec(active_only=True) + projected = codec.externalize_mapping(logical_image()) + lifecycle._begin_graph_construction() + lifecycle._commit_graph_ownership() + await owner.dispose() + assert not owner.closing + assert session.build_session_context().messages + assert codec.hydrate_mapping(projected.value) == logical_image() + finally: + await lifecycle._dispose_graph_owned() + with pytest.raises(TranscriptWriterError, match="closed"): + codec.hydrate_mapping(projected.value) + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("action", ["context", "codec"]) +def test_owned_blob_contention_is_not_missing_image(tmp_path, monkeypatch, action): + async def scenario(): + owner = setup(tmp_path / "sessions") + session = ProductTranscriptSession(lifecycle_session=await owner.create()) + try: + await session.append_message(message()) + codec = session._model_input_binary_codec(active_only=True) + projected = codec.externalize_mapping(logical_image()) + + def busy(*args, **kwargs): + raise BlockingIOError("test retained blob authority busy") + + with monkeypatch.context() as patch: + patch.setattr(SessionBlobStore, "read_bytes", busy) + with pytest.raises(BlockingIOError, match="retained blob authority busy"): + if action == "context": + session.build_session_context() + else: + codec.hydrate_mapping(projected.value) + assert owner._active_io == 0 and owner._io_drained.is_set() + finally: + await owner.dispose() + + asyncio.run(scenario()) + + +def test_root_replaced_before_admission_rejects_before_blob_store(tmp_path, monkeypatch): + async def scenario(): + root = tmp_path / "sessions" + owner = setup(root) + session = ProductTranscriptSession(lifecycle_session=await owner.create()) + try: + root.rename(tmp_path / "original") + root.mkdir(mode=0o700) + + def forbidden(*args, **kwargs): + raise AssertionError("changed root reached blob storage") + + with monkeypatch.context() as patch: + patch.setattr(product_module, "SessionBlobStore", forbidden) + with pytest.raises(TranscriptWriterError): + session.build_session_context() + assert owner._active_io == 0 and not tuple(root.iterdir()) + finally: + await owner.dispose() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("method", ["externalize_mapping", "hydrate_mapping"]) +def test_falsey_scope_factory_is_not_discarded(tmp_path, method): + class Scope: + def __bool__(self): + return False + + @contextmanager + def __call__(self): + raise RuntimeError("test scope admission refused") + yield # pragma: no cover + + codec = SessionModelInputBlobCodec(SessionBlobStore(tmp_path, "session"), operation_scope=Scope()) + with pytest.raises(RuntimeError, match="scope admission refused"): + getattr(codec, method)({}) + + +def test_sync_consumers_reject_while_disposal_is_pending(tmp_path, monkeypatch): + async def scenario(): + owner = setup(tmp_path / "sessions") + lifecycle = await owner.create() + session = ProductTranscriptSession(lifecycle_session=lifecycle) + codec = session._model_input_binary_codec(active_only=True) + started, release = asyncio.Event(), asyncio.Event() + dispose = owner._dispose_runtime + + async def waiting_dispose(): + started.set() + await release.wait() + await dispose() + + monkeypatch.setattr(owner, "_dispose_runtime", waiting_dispose) + closing = asyncio.create_task(owner.dispose()) + try: + await asyncio.wait_for(started.wait(), 5) + assert not closing.done() + for action in ("context", "new_codec", "hydrate", "externalize"): + with pytest.raises(TranscriptWriterError, match="closed"): + invoke(session, codec, action) + assert owner._active_io == 0 + finally: + release.set() + await closing + + asyncio.run(scenario()) + + +def test_corrupt_image_still_degrades_without_leaking_admission(tmp_path): + async def scenario(): + owner = setup(tmp_path / "sessions") + session = ProductTranscriptSession(lifecycle_session=await owner.create()) + try: + await session.append_message(message()) + with session._lifecycle_session.sync_operation_scope(): + store = session._session_blob_store(file_io=owner.blob_file_io) + reference = store.records[0] + (store.objects_root / reference.blob_id).write_bytes(b"corrupt") + context = session.build_session_context() + assert "Image unavailable" in context.messages[-1].content[0].text + assert owner._active_io == 0 and owner._io_drained.is_set() + finally: + await owner.dispose() + + asyncio.run(scenario()) diff --git a/tests/harness/transcript/test_writer_images.py b/tests/harness/transcript/test_writer_images.py new file mode 100644 index 000000000..6e6580f17 --- /dev/null +++ b/tests/harness/transcript/test_writer_images.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +import asyncio +import sys +import threading +from dataclasses import replace + +import pytest + +from loushang.ai.types import ImagePart, UserMessage +from loushang.harness.artifacts import SessionBlobStore +from loushang.harness.conversation import StoreCommitOutcomeUnknown +from loushang.harness.transcript import ApplicationMessage, ProductTranscriptSession +from loushang.harness.transcript import product_session as product_module +from loushang.harness.transcript.jsonl_file import load_agent_transcript_file +from loushang.harness.transcript.session_artifacts import ( + collect_agent_transcript_session_blobs, +) +from loushang.harness.transcript.writer_lease import TranscriptWriterError + +from .test_lifecycle import _record +from .test_runtime_profile import _runtime +from .test_writer_io import wait_until +from .test_writer_lifecycle import assert_available, assert_busy +from .test_writer_runtime import prepare as prepare_runtime + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux writer") + + +def prepare(*args, **kwargs): + return prepare_runtime(*args, manage_blobs=True, **kwargs) + + +@pytest.fixture +def session_root(tmp_path): + root = tmp_path / "sessions" + root.mkdir(mode=0o700) + return root + + +def message(application=False): + content = [ImagePart(type="image", data="aGVsbG8=", mime_type="image/png")] + if application: + return ApplicationMessage( + application_message_id="image-one", custom_type="test", content=content, + timestamp=1.0, + ) + return UserMessage(role="user", content=content, timestamp=1.0) + + +def submit(session, entry): + if entry == "application": + return session.commit_application_message(message(True)) + return session.append_message(message(entry == "forwarded")) + + +@pytest.mark.parametrize("entry", ["user", "application", "forwarded"]) +def test_closed_session_rejects_before_blob_store_construction(session_root, monkeypatch, entry): + tmp_path = session_root + async def scenario(): + runtime = _runtime("coding") + owner, _ = prepare(tmp_path, runtime, runtime.bind_lifecycle_owned) + session = ProductTranscriptSession(lifecycle_session=await owner.create()) + await owner.dispose() + + def forbidden(*args, **kwargs): + raise AssertionError("closed session reached blob storage") + + monkeypatch.setattr(product_module, "SessionBlobStore", forbidden) + with pytest.raises(TranscriptWriterError, match="closed"): + await submit(session, entry) + assert_available(tmp_path) + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("entry", ["user", "application"]) +def test_unknown_commit_then_close_preserves_published_images(session_root, monkeypatch, entry): + tmp_path = session_root + async def scenario(): + runtime = _runtime("coding") + owner, _ = prepare(tmp_path, runtime, runtime.bind_lifecycle_owned) + lifecycle = await owner.create() + session = ProductTranscriptSession(lifecycle_session=lifecycle) + store = lifecycle.runtime_binding.store + append = store.append + committed, release = asyncio.Event(), asyncio.Event() + calls = 0 + + async def uncertain(*args, **kwargs): + nonlocal calls + calls += 1 + result = await append(*args, **kwargs) + if calls == 1: + committed.set() + await release.wait() + raise StoreCommitOutcomeUnknown("test reply lost after durable append") + return result + + monkeypatch.setattr(store, "append", uncertain) + operation = asyncio.create_task(submit(session, entry)) + await wait_until(committed.is_set) + closing = asyncio.create_task(owner.dispose()) + await wait_until(lambda: owner.closing) + assert_busy(tmp_path) + release.set() + with pytest.raises(StoreCommitOutcomeUnknown): + await operation + await closing + header, records = load_agent_transcript_file(tmp_path / "session.jsonl") + references = collect_agent_transcript_session_blobs( + records, expected_session_id=header.conversation_id, + ) + assert len(references) == 1 and calls == 2 + blobs = SessionBlobStore(tmp_path.parent, header.conversation_id) + assert blobs.read_bytes(references[0]) == b"hello" + assert_available(tmp_path) + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("entry", ["user", "application"]) +@pytest.mark.parametrize("cancel", [False, True]) +def test_image_publication_waiting_for_commit_drains_rollback(session_root, monkeypatch, entry, cancel): + async def scenario(): + runtime = _runtime("coding") + owner, _ = prepare(session_root, runtime, runtime.bind_lifecycle_owned) + lifecycle = await owner.create() + session = ProductTranscriptSession(lifecycle_session=lifecycle) + lock = lifecycle.transcript._commit_lock + await lock.acquire() + published = asyncio.Event() + externalize = product_module.externalize_session_message_images + rollback = product_module.rollback_externalized_session_images + publications = [] + rolled_back = [] + + def publish(*args, **kwargs): + result = externalize(*args, **kwargs) + publications.append(result.publication) + published.set() + return result + + def undo(*args, **kwargs): + assert owner._active_io == 1 + assert_busy(session_root) + rollback(*args, **kwargs) + rolled_back.append(True) + + monkeypatch.setattr(product_module, "externalize_session_message_images", publish) + monkeypatch.setattr(product_module, "rollback_externalized_session_images", undo) + operation = asyncio.create_task(submit(session, entry)) + try: + await wait_until(published.is_set) + closing = asyncio.create_task(owner.dispose()) + await wait_until(lambda: owner.closing) + assert_busy(session_root) + closing.cancel() + with pytest.raises(asyncio.CancelledError): + await closing + rejoin = asyncio.create_task(owner.dispose()) + if cancel: + operation.cancel() + operation.cancel() + else: + lock.release() + with pytest.raises(asyncio.CancelledError if cancel else TranscriptWriterError): + await operation + await rejoin + assert len(publications) == 1 and publications[0] is not None + assert rolled_back == [True] + _, records = load_agent_transcript_file(session_root / "session.jsonl") + assert records == [] + assert not (session_root.parent / "session-assets" / "conversation-1").exists() + assert_available(session_root) + finally: + if lock.locked(): + lock.release() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("entry", ["user", "application"]) +def test_native_commit_wins_cancellation_without_deleting_images(session_root, monkeypatch, entry): + async def scenario(): + runtime = _runtime("coding") + owner, _ = prepare(session_root, runtime, runtime.bind_lifecycle_owned) + lifecycle = await owner.create() + session = ProductTranscriptSession(lifecycle_session=lifecycle) + store = lifecycle.runtime_binding.store + entered, release = threading.Event(), threading.Event() + append = store._append_sync + + def blocked(*args, **kwargs): + entered.set() + assert release.wait(5) + return append(*args, **kwargs) + + monkeypatch.setattr(store, "_append_sync", blocked) + operation = asyncio.create_task(submit(session, entry)) + try: + await wait_until(entered.is_set) + closing = asyncio.create_task(owner.dispose()) + await wait_until(lambda: owner.closing) + operation.cancel() + operation.cancel() + assert_busy(session_root) + release.set() + assert await operation + await closing + assert_durable_image(session_root) + assert_available(session_root) + finally: + release.set() + + asyncio.run(scenario()) + + +def assert_durable_image(root): + header, records = load_agent_transcript_file(root / "session.jsonl") + references = collect_agent_transcript_session_blobs( + records, expected_session_id=header.conversation_id, + ) + assert len(records) == len(references) == 1 + assert SessionBlobStore(root.parent, header.conversation_id).read_bytes(references[0]) == b"hello" + + +@pytest.mark.parametrize("entry", ["user", "application"]) +def test_observer_failure_does_not_rollback_committed_images(session_root, entry): + async def scenario(): + runtime = _runtime("coding") + owner, _ = prepare(session_root, runtime, runtime.bind_lifecycle_owned) + session = ProductTranscriptSession(lifecycle_session=await owner.create()) + + def failed_observer(result): + raise ValueError("test observer failed after commit") + + session.set_commit_observer(failed_observer) + with pytest.raises(ValueError, match="test observer") as caught: + await submit(session, entry) + assert any("images retained" in note for note in caught.value.__notes__) + assert_durable_image(session_root) + await owner.dispose() + assert_available(session_root) + + asyncio.run(scenario()) + + +def test_graph_owner_allows_image_commit_and_application_replay(session_root): + async def scenario(): + runtime = _runtime("coding") + owner, _ = prepare(session_root, runtime, runtime.bind_lifecycle_owned) + lifecycle = await owner.create() + session = ProductTranscriptSession(lifecycle_session=lifecycle) + lifecycle._begin_graph_construction() + lifecycle._commit_graph_ownership() + await owner.dispose() + assert not owner.closing + first = await submit(session, "application") + repeated = await submit(session, "application") + assert first.record_id == repeated.record_id + assert repeated.disposition == "already_committed" + assert_durable_image(session_root) + assert_busy(session_root) + await lifecycle._dispose_graph_owned() + assert_available(session_root) + with pytest.raises(TranscriptWriterError, match="closed"): + await submit(session, "application") + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("entry", ["user", "application"]) +@pytest.mark.parametrize("recovery", ["revision", "record_id", "materialize"]) +def test_unknown_remains_until_recovery_receipt_is_valid(session_root, monkeypatch, entry, recovery): + async def scenario(): + runtime = _runtime("coding") + materialize = recovery == "materialize" + owner, _ = prepare( + session_root, runtime, runtime.bind_lifecycle_owned, + defer_materialization=materialize, + ) + lifecycle = await owner.create() + session = ProductTranscriptSession(lifecycle_session=lifecycle) + store = lifecycle.runtime_binding.store + original = store.create if materialize else store.append + unknown = StoreCommitOutcomeUnknown("test durable result lost") + calls = 0 + + async def uncertain(*args, **kwargs): + nonlocal calls + calls += 1 + result = await original(*args, **kwargs) + if calls == 1: + raise unknown + receipt = replace( + result.receipt, + **({"revision": 99} if recovery == "revision" else {"record_id": "wrong"}), + ) + return replace(result, receipt=receipt) + + if materialize: + load = store.load + + async def invalid_snapshot(*args, **kwargs): + result = await load(*args, **kwargs) + return replace(result, snapshot=replace(result.snapshot, revision=99)) + + monkeypatch.setattr(store, "create", uncertain) + monkeypatch.setattr(store, "load", invalid_snapshot) + else: + monkeypatch.setattr(store, "append", uncertain) + with pytest.raises(StoreCommitOutcomeUnknown) as caught: + await submit(session, entry) + assert caught.value is unknown + assert_durable_image(session_root) + await owner.dispose() + assert_available(session_root) + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("recovery", ["size", "revision", "record_id", "rejected"]) +def test_batch_uncertainty_survives_invalid_recovery(session_root, monkeypatch, recovery): + async def scenario(): + runtime = _runtime("coding") + owner, _ = prepare(session_root, runtime, runtime.bind_lifecycle_owned) + lifecycle = await owner.create() + store = lifecycle.runtime_binding.store + append = store.append_batch + unknown = StoreCommitOutcomeUnknown("test batch result lost") + calls = 0 + + async def uncertain(*args, **kwargs): + nonlocal calls + calls += 1 + if calls == 2 and recovery == "rejected": + raise TranscriptWriterError("closed") + result = await append(*args, **kwargs) + if calls == 1: + raise unknown + receipts = result.receipts + if recovery == "size": + receipts = () + else: + change = {"revision": 99} if recovery == "revision" else {"record_id": "wrong"} + receipts = (replace(receipts[0], **change), *receipts[1:]) + return replace(result, receipts=receipts) + + monkeypatch.setattr(store, "append_batch", uncertain) + records = [_record("one"), _record("two", parent_id="one")] + with pytest.raises(StoreCommitOutcomeUnknown) as caught: + await lifecycle.transcript.commit_batch(records) + assert caught.value is unknown and calls == 2 + _, durable = load_agent_transcript_file(session_root / "session.jsonl") + assert durable == records + await owner.dispose() + assert_available(session_root) + + asyncio.run(scenario()) diff --git a/tests/harness/transcript/test_writer_io.py b/tests/harness/transcript/test_writer_io.py new file mode 100644 index 000000000..b50a7608d --- /dev/null +++ b/tests/harness/transcript/test_writer_io.py @@ -0,0 +1,293 @@ +from __future__ import annotations + +import asyncio +import sys +import threading +from pathlib import Path + +import pytest + +from loushang.harness.conversation import ConversationKey +from loushang.harness.transcript.model_input_v2_types import ( + DeferredModelInputNodeBundle, +) +from loushang.harness.transcript.writer_lease import TranscriptWriterError + +from .test_lifecycle import _header, _record +from .test_model_input_v2_index_file import _model_input_records +from .test_runtime_profile import _runtime +from .test_writer_lifecycle import assert_available, assert_busy +from .test_writer_runtime import prepare + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux writer") + + +async def wait_until(predicate): + async with asyncio.timeout(5): + while not predicate(): + await asyncio.sleep(0.001) + + +def invoke(store, key, method): + if method == "create": + return store.create(key, _header(), operation_id="test-create") + if method == "append": + return store.append(key, _record("one"), expected_revision=0, operation_id="one") + if method == "append_batch": + return store.append_batch(key, [_record("one")], expected_revision=0, operation_ids=["one"]) + if method == "delete": + return store.delete(key, expected_revision=0, operation_id="delete") + return getattr(store, method)(key.namespace if method.startswith("scan") else key) + + +@pytest.mark.parametrize("method", ["create", "load", "append", "append_batch", "delete", "scan", "scan_page"]) +def test_all_store_entries_hold_writer_until_native_settlement(tmp_path, monkeypatch, method): + async def scenario(): + runtime = _runtime("coding") + owner, _ = prepare( + tmp_path, runtime, runtime.bind_lifecycle_owned, + defer_materialization=method == "create", + ) + session = await owner.create() + store, key = session.runtime_binding.store, session.runtime_binding.key + assert session.product_binding.value("conversation.store") is store + entered, release = threading.Event(), threading.Event() + events = [] + original = getattr(store, f"_{method}_sync") + dispose = runtime._binder.dispose + + def blocked(*args, **kwargs): + entered.set() + assert release.wait(5) + result = original(*args, **kwargs) + events.append("io") + return result + + async def cleanup(binding): + events.append("dispose") + await dispose(binding) + + monkeypatch.setattr(store, f"_{method}_sync", blocked) + monkeypatch.setattr(runtime._binder, "dispose", cleanup) + operation = asyncio.create_task(invoke(store, key, method)) + try: + await wait_until(entered.is_set) + closing = asyncio.create_task(owner.dispose()) + await wait_until(lambda: owner.closing) + operation.cancel() + operation.cancel() + closing.cancel() + with pytest.raises(asyncio.CancelledError): + await closing + assert not operation.done() and events == [] and owner.cleanup_pending + assert_busy(tmp_path) + with pytest.raises(TranscriptWriterError, match="closed"): + await invoke(store, key, method) + rejoin = asyncio.create_task(owner.dispose()) + release.set() + with pytest.raises(asyncio.CancelledError): + await operation + await rejoin + assert events == ["io", "dispose"] + assert not owner.cleanup_pending + assert_available(tmp_path) + with pytest.raises(TranscriptWriterError, match="closed"): + await store.load(key) + finally: + release.set() + asyncio.run(scenario()) + + +@pytest.mark.parametrize("target", ["key", "namespace"]) +def test_wrong_target_rejected_before_validation_or_native_io(tmp_path, monkeypatch, target): + async def scenario(): + runtime = _runtime("coding") + owner, writer = prepare(tmp_path, runtime, runtime.bind_lifecycle_owned) + session = await owner.create() + store = session.runtime_binding.store + + def forbidden(*_, **__): + raise AssertionError("wrong target reached validation/native IO") + + with monkeypatch.context() as patch: + patch.setattr(writer, "check", forbidden) + patch.setattr(store, "_load_sync", forbidden) + patch.setattr(store, "_scan_sync", forbidden) + with pytest.raises(TranscriptWriterError, match="conflict"): + if target == "key": + await store.load(ConversationKey(str(tmp_path), "other")) + else: + await store.scan(str(tmp_path / "other")) + assert owner._active_io == 0 + await owner.dispose() + asyncio.run(scenario()) + + +@pytest.mark.parametrize("fail_check", [False, True]) +def test_validation_is_admitted_and_drained_before_writer_release(tmp_path, monkeypatch, fail_check): + async def scenario(): + runtime = _runtime("coding") + owner, writer = prepare(tmp_path, runtime, runtime.bind_lifecycle_owned) + session = await owner.create() + entered, release = threading.Event(), threading.Event() + original = writer.check + + def check(**kwargs): + entered.set() + assert release.wait(5) + if fail_check: + raise TranscriptWriterError("conflict") + original(**kwargs) + + monkeypatch.setattr(writer, "check", check) + operation = asyncio.create_task(session.runtime_binding.store.load(session.runtime_binding.key)) + try: + await wait_until(entered.is_set) + closing = asyncio.create_task(owner.dispose()) + await wait_until(lambda: owner.closing) + assert owner._active_io == 1 + assert_busy(tmp_path) + operation.cancel() + release.set() + with pytest.raises(TranscriptWriterError if fail_check else asyncio.CancelledError): + await operation + await closing + assert owner._active_io == 0 and not owner.cleanup_pending + assert_available(tmp_path) + finally: + release.set() + asyncio.run(scenario()) + + +def test_graph_retains_admission_and_runtime_disposer_cannot_start_io(tmp_path, monkeypatch): + async def scenario(): + runtime = _runtime("coding") + owner, _ = prepare(tmp_path, runtime, runtime.bind_lifecycle_owned) + session = await owner.create() + store, key = session.runtime_binding.store, session.runtime_binding.key + session._begin_graph_construction() + session._commit_graph_ownership() + await owner.dispose() + assert not owner.closing + await store.append(key, _record("one"), expected_revision=0, operation_id="one") + original = runtime._binder.dispose + + async def dispose(binding): + with pytest.raises(TranscriptWriterError, match="closed"): + await store.load(key) + await original(binding) + + monkeypatch.setattr(runtime._binder, "dispose", dispose) + await asyncio.wait_for(session._dispose_graph_owned(), 5) + assert_available(tmp_path) + asyncio.run(scenario()) + + +@pytest.mark.parametrize("change", ["delete", "symlink"]) +def test_owned_deferred_snapshot_never_reopens_path_after_disposal(tmp_path, monkeypatch, change): + async def scenario(): + runtime = _runtime("coding") + owner, _ = prepare(tmp_path, runtime, runtime.bind_lifecycle_owned) + session = await owner.create() + store, key = session.runtime_binding.store, session.runtime_binding.key + records = _model_input_records() + await store.append_batch(key, records, expected_revision=0, operation_ids=[r.record_id for r in records]) + await store.load(key) # Build the writable index. + snapshot = (await store.load(key)).snapshot + assert isinstance(snapshot.records[0].payload, DeferredModelInputNodeBundle) + await owner.dispose() + path = tmp_path / "session.jsonl" + path.unlink() + if change == "symlink": + path.symlink_to(tmp_path / "missing") + + def forbidden(*_, **__): + raise AssertionError("owned deferred payload reopened source") + + with monkeypatch.context() as patch: + patch.setattr(Path, "open", forbidden) + assert snapshot.records[0].payload.nodes == records[0].payload.nodes + assert snapshot.records[1].payload.nodes == records[1].payload.nodes + asyncio.run(scenario()) + + +def test_closing_during_binding_rejects_late_materialization_and_cleans_raw_binding(tmp_path): + async def scenario(): + runtime = _runtime("coding") + entered, release = asyncio.Event(), asyncio.Event() + raw = [] + + async def bind(context, profile, owner): + result = await runtime.bind_lifecycle_owned(context, profile, owner) + raw.append(result) + entered.set() + await release.wait() + return result + + owner, _ = prepare(tmp_path, runtime, bind) + creating = asyncio.create_task(owner.create()) + await asyncio.wait_for(entered.wait(), 5) + closing = asyncio.create_task(owner.dispose()) + await wait_until(lambda: owner.closing) + assert_busy(tmp_path) + release.set() + with pytest.raises(TranscriptWriterError, match="closed"): + await creating + await closing + assert raw[0].product_binding.is_closed + assert not (tmp_path / "session.jsonl").exists() + assert not owner.cleanup_pending + assert_available(tmp_path) + asyncio.run(scenario()) + + +def test_first_materialization_settles_after_create_and_dispose_waiters_cancel(tmp_path, monkeypatch): + async def scenario(): + runtime = _runtime("coding") + entered, release = threading.Event(), threading.Event() + events = [] + original_dispose = runtime._binder.dispose + + async def bind(context, profile, owner): + binding = await runtime.bind_lifecycle_owned(context, profile, owner) + original_create = binding.store._create_sync + + def create(*args, **kwargs): + entered.set() + assert release.wait(5) + result = original_create(*args, **kwargs) + events.append("created") + return result + + monkeypatch.setattr(binding.store, "_create_sync", create) + return binding + + async def dispose(binding): + events.append("disposed") + await original_dispose(binding) + + monkeypatch.setattr(runtime._binder, "dispose", dispose) + owner, _ = prepare(tmp_path, runtime, bind) + creating = asyncio.create_task(owner.create()) + try: + await wait_until(entered.is_set) + creating.cancel() + with pytest.raises(asyncio.CancelledError): + await creating + closing = asyncio.create_task(owner.dispose()) + await wait_until(lambda: owner.closing) + closing.cancel() + with pytest.raises(asyncio.CancelledError): + await closing + assert_busy(tmp_path) + assert events == [] and not (tmp_path / "session.jsonl").exists() + rejoin = asyncio.create_task(owner.dispose()) + release.set() + await asyncio.wait_for(rejoin, 5) + assert events == ["created", "disposed"] + assert (tmp_path / "session.jsonl").is_file() + assert not owner.cleanup_pending + assert_available(tmp_path) + finally: + release.set() + asyncio.run(scenario()) diff --git a/tests/harness/transcript/test_writer_lifecycle.py b/tests/harness/transcript/test_writer_lifecycle.py new file mode 100644 index 000000000..6ea682a95 --- /dev/null +++ b/tests/harness/transcript/test_writer_lifecycle.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +import asyncio +import os +import sys +import threading +from dataclasses import replace +from pathlib import Path + +import pytest + +from loushang.harness.conversation import ConversationKey +from loushang.harness.transcript import ( + AgentTranscriptFileLayout, + AgentTranscriptLifecycle, + AgentTranscriptProfile, + AgentTranscriptRuntimeBinding, + create_agent_transcript_file_store, + load_agent_transcript_file, + write_agent_transcript_export, +) +from loushang.harness.transcript.writer_lease import ( + TranscriptWriterError, + TranscriptWriterLease, +) + +from .test_lifecycle import _header, _record + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux writer") + + +class Binding: + def __init__(self) -> None: + self.binds = 0 + self.disposals = 0 + self.inputs = [] + self.bind_entered = asyncio.Event() + self.bind_release = asyncio.Event() + self.bind_release.set() + self.dispose_entered = asyncio.Event() + self.dispose_release = asyncio.Event() + self.dispose_release.set() + self.fail_dispose = False + self.fail_bind = False + self.wrong_key = False + + async def bind(self, context, value): + self.binds += 1 + self.inputs.append(value) + self.bind_entered.set() + await self.bind_release.wait() + if self.fail_bind: + raise RuntimeError("binding failed") + layout = AgentTranscriptFileLayout(context.session_dir) + key = layout.key(context.header.conversation_id) + layout.bind_create_path(key, context.session_file) + return AgentTranscriptRuntimeBinding( + store=create_agent_transcript_file_store(layout), + key=ConversationKey("wrong", key.conversation_id) if self.wrong_key else key, + profile=AgentTranscriptProfile.default(), + product_binding=value, + dispose=self.dispose, + ) + + async def dispose(self): + self.disposals += 1 + self.dispose_entered.set() + await self.dispose_release.wait() + if self.fail_dispose: + self.fail_dispose = False + raise RuntimeError("retry cleanup") + + +def setup(root, binding): + lifecycle = AgentTranscriptLifecycle(bind_runtime=binding.bind) + header = _header() + context = lifecycle.new_context( + session_dir=root, cwd="/workspace", persist=True, header=header, + session_file=lifecycle.default_jsonl_session_file(root, header), + ) + writer = TranscriptWriterLease(root, "coding", header.conversation_id) + writer.acquire() + return lifecycle, context, writer + + +def assert_busy(root): + other = TranscriptWriterLease(root, "coding", _header().conversation_id) + try: + with pytest.raises(TranscriptWriterError, match="busy"): + other.acquire() + finally: + other.close() + + +def assert_available(root): + other = TranscriptWriterLease(root, "coding", _header().conversation_id) + try: + other.acquire() + finally: + other.close() + + +def test_create_restore_and_frozen_claim(tmp_path: Path): + async def scenario(): + binding = Binding() + lifecycle, context, writer = setup(tmp_path, binding) + value = {"options": ["original"]} + records = [_record("first")] + owner = lifecycle.prepare_writer( + context, value, writer=writer, product_id="coding", records=records, + ) + assert writer.claimed_owner is owner + value["options"].append("changed") + records.clear() + with pytest.raises(TranscriptWriterError, match="closed"): + lifecycle.prepare_writer(context, {}, writer=writer, product_id="coding") + with pytest.raises(TranscriptWriterError, match="busy"): + writer.close() + one, two = await asyncio.gather(owner.create(), owner.create()) + assert one is two and binding.binds == 1 + assert binding.inputs == [{"options": ["original"]}] + assert [r.record_id for r in one.transcript.records] == ["first"] + assert_busy(tmp_path) + await owner.dispose() + assert not owner.cleanup_pending and binding.disposals == 1 + assert_available(tmp_path) + + writer2 = TranscriptWriterLease(tmp_path, "coding", context.header.conversation_id) + writer2.acquire() + restored = lifecycle.prepare_writer(context, {}, writer=writer2, product_id="coding") + session = await restored.restore() + assert [r.record_id for r in session.transcript.records] == ["first"] + with pytest.raises(TranscriptWriterError, match="conflict"): + await restored.create() + await session.dispose() + assert not restored.cleanup_pending + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("change", ["path", "root", "product", "conversation", "persist"]) +def test_invalid_preparation_does_not_claim(tmp_path: Path, change): + binding = Binding() + lifecycle, context, writer = setup(tmp_path, binding) + kwargs = {"product_id": "coding"} + if change == "path": + context = replace(context, session_file=tmp_path.parent / "outside.jsonl") + elif change == "root": + context = replace(context, session_dir=tmp_path / "other", session_file=tmp_path / "other" / "one.jsonl") + elif change == "product": + kwargs["product_id"] = "work" + elif change == "conversation": + context = replace(context, header=_header("another")) + else: + context = replace(context, persist=False) + try: + with pytest.raises(TranscriptWriterError): + lifecycle.prepare_writer(context, {}, writer=writer, **kwargs) + assert writer.claimed_owner is None and binding.binds == 0 + finally: + writer.close() + + +def test_cancelled_create_rejoins_original_construction(tmp_path: Path): + async def scenario(): + binding = Binding() + binding.bind_release.clear() + lifecycle, context, writer = setup(tmp_path, binding) + owner = lifecycle.prepare_writer(context, {}, writer=writer, product_id="coding") + waiter = asyncio.create_task(owner.create()) + await asyncio.wait_for(binding.bind_entered.wait(), 5) + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + assert_busy(tmp_path) + binding.bind_release.set() + session = await owner.create() + assert binding.binds == 1 + await session.dispose() + assert_available(tmp_path) + asyncio.run(scenario()) + + +def test_dispose_during_construction_fences_late_delivery(tmp_path: Path): + async def scenario(): + binding = Binding() + binding.bind_release.clear() + lifecycle, context, writer = setup(tmp_path, binding) + owner = lifecycle.prepare_writer(context, {}, writer=writer, product_id="coding") + waiter = asyncio.create_task(owner.create()) + await asyncio.wait_for(binding.bind_entered.wait(), 5) + closing = asyncio.create_task(owner.dispose()) + await asyncio.sleep(0) + assert owner.closing and not closing.done() + assert_busy(tmp_path) + binding.bind_release.set() + with pytest.raises(TranscriptWriterError, match="closed"): + await waiter + await closing + assert binding.disposals == 1 and not owner.cleanup_pending + assert_available(tmp_path) + asyncio.run(scenario()) + + +def test_graph_retains_writer_and_cleanup_retry_survives_cancellation(tmp_path: Path): + async def scenario(): + binding = Binding() + lifecycle, context, writer = setup(tmp_path, binding) + owner = lifecycle.prepare_writer(context, {}, writer=writer, product_id="coding") + session = await owner.create() + session._begin_graph_construction() + session._commit_graph_ownership() + await owner.dispose() + assert not owner.closing and binding.disposals == 0 + assert_busy(tmp_path) + binding.fail_dispose = True + with pytest.raises(RuntimeError, match="retry cleanup"): + await session._dispose_graph_owned() + assert_busy(tmp_path) + assert owner.cleanup_pending + binding.dispose_entered.clear() + binding.dispose_release.clear() + closing = asyncio.create_task(session._dispose_graph_owned()) + await asyncio.wait_for(binding.dispose_entered.wait(), 5) + closing.cancel() + with pytest.raises(asyncio.CancelledError): + await closing + assert_busy(tmp_path) + rejoin = asyncio.create_task(session._dispose_graph_owned()) + await asyncio.sleep(0) + assert not rejoin.done() and binding.disposals == 2 + binding.dispose_release.set() + await rejoin + assert session.ownership_state == "disposed" + assert not owner.cleanup_pending and binding.disposals == 2 + assert_available(tmp_path) + asyncio.run(scenario()) + + +def test_closing_root_cannot_transfer_to_graph(tmp_path: Path): + async def scenario(): + binding = Binding() + binding.dispose_release.clear() + lifecycle, context, writer = setup(tmp_path, binding) + owner = lifecycle.prepare_writer(context, {}, writer=writer, product_id="coding") + session = await owner.create() + closing = asyncio.create_task(session.dispose()) + await asyncio.wait_for(binding.dispose_entered.wait(), 5) + with pytest.raises(RuntimeError, match="not root-owned"): + session._begin_graph_construction() + binding.dispose_release.set() + await closing + asyncio.run(scenario()) + + +def test_stale_restore_header_preserves_history_and_releases_on_dispose(tmp_path: Path): + async def scenario(): + binding = Binding() + lifecycle, context, writer = setup(tmp_path, binding) + changed = replace(context.header, metadata={"cwd": "/changed"}) + write_agent_transcript_export(context.session_file, changed, [_record("history")]) + before = context.session_file.read_bytes() + owner = lifecycle.prepare_writer(context, {}, writer=writer, product_id="coding") + with pytest.raises(TranscriptWriterError, match="conflict"): + await owner.restore() + assert binding.binds == 0 + assert_busy(tmp_path) + await owner.dispose() + assert_available(tmp_path) + assert context.session_file.read_bytes() == before + assert load_agent_transcript_file(context.session_file)[0] == changed + asyncio.run(scenario()) + + +def test_failed_returned_binding_is_retained_and_disposed_once(tmp_path: Path): + async def scenario(): + binding = Binding() + binding.wrong_key = True + lifecycle, context, writer = setup(tmp_path, binding) + owner = lifecycle.prepare_writer(context, {}, writer=writer, product_id="coding") + with pytest.raises(TranscriptWriterError, match="conflict"): + await owner.create() + assert_busy(tmp_path) + await asyncio.gather(owner.dispose(), owner.dispose()) + assert binding.disposals == 1 and not owner.cleanup_pending + assert_available(tmp_path) + asyncio.run(scenario()) + + +def test_unreturned_binding_stays_unknown(tmp_path: Path): + async def scenario(): + binding = Binding() + binding.fail_bind = True + lifecycle, context, writer = setup(tmp_path, binding) + owner = lifecycle.prepare_writer(context, {}, writer=writer, product_id="coding") + with pytest.raises(RuntimeError, match="binding failed"): + await owner.create() + with pytest.raises(TranscriptWriterError, match="unavailable"): + await owner.dispose() + assert owner.cleanup_pending and binding.disposals == 0 + assert_busy(tmp_path) + # Test-only: this injected binder has no native effects or resources. + # Production cannot infer that from the generic exception. + writer._close_claimed(owner) + asyncio.run(scenario()) + + +def test_loaded_header_is_rechecked_without_rewriting_history(tmp_path: Path): + async def scenario(): + binding = Binding() + lifecycle, context, writer = setup(tmp_path, binding) + changed = replace(context.header, metadata={"cwd": "/changed"}) + write_agent_transcript_export(context.session_file, changed, [_record("history")]) + before = context.session_file.read_bytes() + # Simulate a stale first read; the real Store still loads the disk header. + lifecycle._header_loader = lambda _: context.header + owner = lifecycle.prepare_writer(context, {}, writer=writer, product_id="coding") + with pytest.raises(TranscriptWriterError, match="conflict"): + await owner.restore() + assert binding.binds == 1 and binding.disposals == 0 + assert_busy(tmp_path) + await owner.dispose() + assert binding.disposals == 1 and not owner.cleanup_pending + assert context.session_file.read_bytes() == before + assert_available(tmp_path) + asyncio.run(scenario()) + + +def test_private_runtime_task_cancel_keeps_writer_unknown(tmp_path: Path): + async def scenario(): + binding = Binding() + binding.dispose_release.clear() + lifecycle, context, writer = setup(tmp_path, binding) + owner = lifecycle.prepare_writer(context, {}, writer=writer, product_id="coding") + session = await owner.create() + closing = asyncio.create_task(session.dispose()) + await asyncio.wait_for(binding.dispose_entered.wait(), 5) + owner._runtime_task.cancel() + with pytest.raises(asyncio.CancelledError): + await closing + assert owner.cleanup_pending and session.ownership_state == "root_owned" + assert_busy(tmp_path) + with pytest.raises(TranscriptWriterError, match="unavailable"): + await session.dispose() + assert binding.disposals == 1 + # This test disposer owns no external effects; production cannot infer it. + writer._close_claimed(owner) + asyncio.run(scenario()) + + +def test_private_close_cancel_reports_debt_after_native_release(tmp_path: Path, monkeypatch): + async def scenario(): + binding = Binding() + lifecycle, context, writer = setup(tmp_path, binding) + owner = lifecycle.prepare_writer(context, {}, writer=writer, product_id="coding") + session = await owner.create() + entered = threading.Event() + release = threading.Event() + calls = [] + original = writer._close_claimed + + def close(claimant): + original(claimant) + calls.append(1) + entered.set() + if not release.wait(5): + raise TimeoutError("test release") + + monkeypatch.setattr(writer, "_close_claimed", close) + closing = asyncio.create_task(session.dispose()) + try: + async with asyncio.timeout(5): + while not entered.is_set(): + await asyncio.sleep(0.001) + owner._close_task.cancel() + release.set() + with pytest.raises(asyncio.CancelledError): + await closing + assert not writer.cleanup_pending + assert owner.cleanup_pending # Debt survives even when all fds closed. + assert session.ownership_state == "root_owned" + with pytest.raises(TranscriptWriterError, match="unavailable"): + await session.dispose() + assert calls == [1] and binding.disposals == 1 + finally: + release.set() + await asyncio.gather(closing, return_exceptions=True) + asyncio.run(scenario()) + + +def test_unknown_native_close_never_repeats_runtime_or_closes_reused_fd(tmp_path: Path, monkeypatch): + async def scenario(): + binding = Binding() + lifecycle, context, writer = setup(tmp_path, binding) + owner = lifecycle.prepare_writer(context, {}, writer=writer, product_id="coding") + session = await owner.create() + descriptor = writer._fds["lock"] + original, replacements, calls = os.close, [], [] + + def close(fd): + original(fd) + if fd == descriptor: + calls.append(fd) + replacement = os.open("/dev/null", os.O_RDONLY) + assert replacement == fd + replacements.append(replacement) + raise OSError("test lost native close receipt") + + try: + with monkeypatch.context() as patch: + patch.setattr(os, "close", close) + for _ in range(2): + with pytest.raises(TranscriptWriterError, match="unavailable"): + await session.dispose() + assert owner.cleanup_pending + assert session.ownership_state == "root_owned" + assert binding.disposals == 1 and calls == [descriptor] + os.fstat(replacements[0]) + finally: + for fd in replacements: + original(fd) + asyncio.run(scenario()) diff --git a/tests/harness/transcript/test_writer_owned_admission.py b/tests/harness/transcript/test_writer_owned_admission.py new file mode 100644 index 000000000..354ff33f5 --- /dev/null +++ b/tests/harness/transcript/test_writer_owned_admission.py @@ -0,0 +1,378 @@ +from __future__ import annotations + +import asyncio +import sys +import threading + +import pytest + +from loushang.harness.transcript import AgentTranscriptLifecycle +from loushang.harness.transcript.writer_lease import ( + TranscriptWriterError, + TranscriptWriterLease, +) + +from .test_lifecycle import _header, _record +from .test_runtime_profile import _runtime +from .test_writer_io import wait_until +from .test_writer_lifecycle import assert_available, assert_busy + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux owned acquisition") + + +def preparation(root, *, bind=None, create_root=False, **kwargs): + runtime = _runtime("coding") + lifecycle = AgentTranscriptLifecycle( + bind_runtime=runtime.bind_lifecycle, bind_runtime_owned=bind or runtime.bind_lifecycle_owned, + ) + context = lifecycle.new_context( + session_dir=root, cwd="/workspace", persist=True, + header=_header(), session_file=root / "session.jsonl", + ) + return lifecycle.prepare_owned_writer( + context, runtime.resolve(persist=True), product_id="coding", manage_blobs=True, + create_root=create_root, + **kwargs, + ) + + +def test_preparation_pins_session_and_attachment_roots_before_runtime_construction(tmp_path): + async def scenario(): + root = tmp_path / "sessions" + root.mkdir(mode=0o700) + info, parent = root.stat(), tmp_path.stat() + owner = preparation(root, expected_root_identity=(info.st_dev, info.st_ino), + expected_parent_identity=(parent.st_dev, parent.st_ino)) + assert owner._blob_writer._expected_root_identity == (parent.st_dev, parent.st_ino) + moved = tmp_path / "original" + root.rename(moved) + root.mkdir(mode=0o700) + try: + with pytest.raises(TranscriptWriterError, match="conflict"): + await owner.create() + finally: + await owner.dispose() + assert not tuple(root.iterdir()) and not tuple(moved.iterdir()) + assert not owner.cleanup_pending + + asyncio.run(scenario()) + + +def test_admitted_root_identity_constructs_original_owned_session(tmp_path): + async def scenario(): + root = tmp_path / "sessions" + root.mkdir(mode=0o700) + info, parent = root.stat(), tmp_path.stat() + owner = preparation(root, expected_root_identity=(info.st_dev, info.st_ino), + expected_parent_identity=(parent.st_dev, parent.st_ino)) + try: + session = await owner.create() + assert session._writer_owner is owner and owner._writer._held + assert owner._blob_writer._held + finally: + await owner.dispose() + assert not owner.cleanup_pending + + asyncio.run(scenario()) + + +def test_store_admission_primary_failure_survives_cleanup_failure(tmp_path, monkeypatch): + async def scenario(): + root = tmp_path / "data/sessions" + owner = preparation(root, store_state_root=tmp_path / "state/session-stores", initialize_store=True) + admission = owner._store_admission + original_close = admission.close + failure = TranscriptWriterError("incomplete") + calls = [] + + def fail_open(): + calls.append("open") + raise failure + + def fail_close(): + calls.append("close") + raise OSError("cleanup incomplete") + + monkeypatch.setattr(admission, "open", fail_open) + monkeypatch.setattr(admission, "close", fail_close) + try: + with pytest.raises(TranscriptWriterError) as caught: + await owner.create() + assert caught.value is failure + assert "store admission cleanup retained" in failure.__notes__[0] + assert calls == ["open", "close"] + assert not owner._writer._attempted and not owner._binding_started + assert owner._store_admission is admission + finally: + monkeypatch.setattr(admission, "close", original_close) + await owner.dispose() + assert not owner.cleanup_pending and not root.exists() + + asyncio.run(scenario()) + + +def test_cancel_during_store_intent_retains_original_driver_and_cleanup(tmp_path, monkeypatch): + async def scenario(): + root = tmp_path / "data/sessions" + owner = preparation(root, store_state_root=tmp_path / "state/session-stores", initialize_store=True) + entered, release = threading.Event(), threading.Event() + admission = owner._store_admission + write = admission._write + publications = [] + + def pause(record): + write(record) + publications.append(record["phase"]) + if record["phase"] == "initializing": + entered.set() + assert release.wait(10) + + monkeypatch.setattr(admission, "_write", pause) + caller = asyncio.create_task(owner.create()) + closing = None + try: + await wait_until(entered.is_set) + assert owner.cleanup_pending and admission.cleanup_pending + caller.cancel() + with pytest.raises(asyncio.CancelledError): + await caller + closing = asyncio.create_task(owner.dispose()) + await wait_until(lambda: owner.closing) + assert not closing.done() and not root.exists() + release.set() + await asyncio.wait_for(closing, 10) + assert publications == ["initializing", "initialized"] + assert owner._store_admission is admission and not owner.cleanup_pending + assert root.is_dir() and not tuple(root.glob("*.jsonl")) + finally: + release.set() + await asyncio.gather(caller, *([closing] if closing is not None else []), return_exceptions=True) + await owner.dispose() + + asyncio.run(scenario()) + + +def test_root_creation_grant_cannot_be_used_for_restore(tmp_path): + async def scenario(): + root = tmp_path / "new" / "sessions" + owner = preparation(root, create_root=True) + try: + with pytest.raises(TranscriptWriterError, match="invalid"): + await owner.restore() + assert not owner.cleanup_pending and owner._driver is None + assert not root.parent.exists() + finally: + await owner.dispose() + + asyncio.run(scenario()) + + +def test_cancel_during_mkdir_keeps_original_native_preparation(tmp_path, monkeypatch): + from loushang.harness.journal import _directory_lease as directory_module + + async def scenario(): + root = tmp_path / "new" / "sessions" + owner = preparation(root, create_root=True) + entered, release = threading.Event(), threading.Event() + mkdir = directory_module.os.mkdir + calls = [] + + def paused_mkdir(path, *args, **kwargs): + result = mkdir(path, *args, **kwargs) + if path == "new": + calls.append(path) + entered.set() + assert release.wait(10) + return result + + monkeypatch.setattr(directory_module.os, "mkdir", paused_mkdir) + caller = asyncio.create_task(owner.create()) + closing = None + try: + await wait_until(entered.is_set) + assert owner.cleanup_pending and owner._writer._sync_pending + caller.cancel() + with pytest.raises(asyncio.CancelledError): + await caller + closing = asyncio.create_task(owner.dispose()) + await wait_until(lambda: owner.closing) + assert not closing.done() and not root.exists() + release.set() + await asyncio.wait_for(closing, 10) + assert calls == ["new"] and not owner.cleanup_pending + assert owner._driver.done() and owner._runtime_done + assert root.is_dir() and not tuple(root.glob("*.jsonl")) + finally: + release.set() + await asyncio.gather(caller, *([closing] if closing is not None else []), return_exceptions=True) + await owner.dispose() + + asyncio.run(scenario()) + + +def test_pure_preparation_can_be_disposed_without_acquiring(tmp_path, monkeypatch): + async def scenario(): + def forbidden(*args, **kwargs): + raise AssertionError("pure preparation acquired a writer") + + monkeypatch.setattr(TranscriptWriterLease, "acquire", forbidden) + owner = preparation(tmp_path) + assert not owner.cleanup_pending + assert owner._writer.claimed_owner is None + assert owner._file_io is None and owner.blob_file_io is None + assert not tuple(tmp_path.iterdir()) + await owner.dispose() + with pytest.raises(TranscriptWriterError, match="closed"): + await owner.create() + assert not tuple(tmp_path.iterdir()) + + asyncio.run(scenario()) + + +def test_internal_admission_create_then_restore_uses_same_owner(tmp_path): + async def scenario(): + first = preparation(tmp_path) + try: + session = await first.create() + assert session._writer_owner is first + assert first._writer.claimed_owner is first + assert first._binding_owner.file_io is first._file_io + assert first.blob_file_io is not None + assert not (tmp_path / "session.jsonl").exists() + await session.transcript.append_agent_message(_record("one").payload) + records = session.transcript.records + assert_busy(tmp_path) + finally: + await first.dispose() + second = preparation(tmp_path) + try: + restored = await second.restore() + assert restored.transcript.records == records + assert restored._writer_owner is second + finally: + await second.dispose() + assert_available(tmp_path) + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("step", ["acquire", "_borrow_file_io", "_claim"]) +@pytest.mark.parametrize("after", [False, True]) +def test_partial_admission_is_retained_and_never_reacquired(tmp_path, monkeypatch, step, after): + async def scenario(): + calls = [] + + async def forbidden(*args): + raise AssertionError("partial writer admission reached binder") + + owner = preparation(tmp_path, bind=forbidden) + original = getattr(owner._writer, step) + + def fail(*args, **kwargs): + calls.append(True) + if after: + original(*args, **kwargs) + raise OSError("test native admission failure") + + monkeypatch.setattr(owner._writer, step, fail) + try: + for _ in range(2): + with pytest.raises(OSError, match="native admission failure"): + await owner.create() + assert len(calls) == 1 and not owner._binding_started + assert not (tmp_path / "session.jsonl").exists() + if step != "acquire" or after: + assert owner.cleanup_pending + assert_busy(tmp_path) + finally: + await owner.dispose() + assert not owner.cleanup_pending + assert_available(tmp_path) + + asyncio.run(scenario()) + + +def test_cancelled_waiter_does_not_abandon_inflight_acquire(tmp_path, monkeypatch): + async def scenario(): + owner = preparation(tmp_path) + entered, release = threading.Event(), threading.Event() + original = owner._writer.acquire + calls = [] + + def acquire(): + calls.append(True) + original() + entered.set() + assert release.wait(5) + + monkeypatch.setattr(owner._writer, "acquire", acquire) + caller = asyncio.create_task(owner.create()) + closing = None + try: + await wait_until(entered.is_set) + caller.cancel() + with pytest.raises(asyncio.CancelledError): + await caller + assert_busy(tmp_path) + closing = asyncio.create_task(owner.dispose()) + await wait_until(lambda: owner.closing) + assert not closing.done() + release.set() + await asyncio.wait_for(closing, 5) + assert len(calls) == 1 and not owner.cleanup_pending + assert_available(tmp_path) + finally: + release.set() + await asyncio.gather(caller, *([closing] if closing is not None else []), return_exceptions=True) + await owner.dispose() + + asyncio.run(scenario()) + + +def test_admission_samples_claim_transition_once(tmp_path, monkeypatch): + async def scenario(): + owner = preparation(tmp_path) + reads = [] + + def claim_transition(writer): + reads.append(writer) + return None if len(reads) == 1 else owner + + with monkeypatch.context() as patch: + patch.setattr(TranscriptWriterLease, "claimed_owner", property(claim_transition)) + owner._on_loop() + assert reads == [owner._writer] + await owner.dispose() + + asyncio.run(scenario()) + + +def test_busy_internal_admission_cleanup_does_not_release_other_owner(tmp_path): + async def scenario(): + first = preparation(tmp_path) + await first.create() + calls = [] + + async def forbidden(*args): + calls.append(True) + raise AssertionError("busy writer reached binding") + + second = preparation(tmp_path, bind=forbidden) + try: + with pytest.raises(TranscriptWriterError, match="busy"): + await second.create() + assert not calls and second.cleanup_pending + await second.dispose() + assert not second.cleanup_pending + assert_busy(tmp_path) + finally: + await second.dispose() + await first.dispose() + third = preparation(tmp_path) + try: + await third.create() + finally: + await third.dispose() + assert_available(tmp_path) + + asyncio.run(scenario()) diff --git a/tests/harness/transcript/test_writer_restore_blobs.py b/tests/harness/transcript/test_writer_restore_blobs.py new file mode 100644 index 000000000..6b534d2fd --- /dev/null +++ b/tests/harness/transcript/test_writer_restore_blobs.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +import pytest + +from loushang.harness.artifacts import SessionBlobStore +from loushang.harness.journal import journal_file_lock +from loushang.harness.transcript import ( + ProductTranscriptSession, + session_artifacts, + writer_lifecycle, +) + +from .test_lifecycle import _header +from .test_runtime_profile import _runtime +from .test_writer_blobs import blob_busy +from .test_writer_images import message, prepare +from .test_writer_lifecycle import assert_available, assert_busy +from .test_writer_root_binding import tree + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux retained restore") + + +async def saved(root): + runtime = _runtime("coding") + owner, _ = prepare(root, runtime, runtime.bind_lifecycle_owned) + session = ProductTranscriptSession(lifecycle_session=await owner.create()) + try: + await session.append_message(message()) + finally: + await owner.dispose() + return runtime + + +@pytest.mark.parametrize("swap", [False, True]) +def test_actual_owned_restore_health_uses_original_port(tmp_path, monkeypatch, swap): + async def scenario(): + data = tmp_path / "data" + data.mkdir(mode=0o700) + root = data / "sessions" + root.mkdir(mode=0o700) + runtime = await saved(root) + owner, _ = prepare(root, runtime, runtime.bind_lifecycle_owned, defer_materialization=True) + construct = writer_lifecycle._lifecycle_session + blob_store = session_artifacts.SessionBlobStore + calls = [] + untouched = None + + def before_health(*args, **kwargs): + nonlocal untouched + assert owner._active_io == 1 + if swap: + data.rename(tmp_path / "original") + data.mkdir(mode=0o700) + (data / "sentinel").write_bytes(b"replacement") + untouched = tree(data) + def forbidden(*args, **kwargs): + raise AssertionError("owned health used pathname IO") + + with monkeypatch.context() as patch: + for method in ("open", "read_bytes", "write_bytes", "stat", "lstat", "exists", "mkdir", "resolve"): + patch.setattr(Path, method, forbidden) + return construct(*args, **kwargs) + + def retained_store(*args, **kwargs): + calls.append(kwargs.get("file_io")) + assert kwargs.get("file_io") is owner.blob_file_io + return blob_store(*args, **kwargs) + + try: + with monkeypatch.context() as patch: + patch.setattr(writer_lifecycle, "_lifecycle_session", before_health) + patch.setattr(session_artifacts, "SessionBlobStore", retained_store) + result = await owner.restore() + assert len(calls) == 1 + assert len(result.session_blob_health) == 1 + assert result.session_blob_health[0].state == "available" + assert owner._active_io == 0 + if swap: + assert tree(data) == untouched + finally: + await owner.dispose() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("state", ["missing", "corrupt"]) +def test_restore_health_degrades_real_damage_without_rewriting_history(tmp_path, state): + async def scenario(): + root = tmp_path / "sessions" + root.mkdir(mode=0o700) + runtime = await saved(root) + history = (root / "session.jsonl").read_bytes() + store = SessionBlobStore(tmp_path, _header().conversation_id) + target = store.objects_root / store.records[0].blob_id + if state == "missing": + target.unlink() + else: + target.write_bytes(b"corrupt") + owner, _ = prepare(root, runtime, runtime.bind_lifecycle_owned, defer_materialization=True) + try: + result = await owner.restore() + assert result.session_blob_health[0].state == state + assert (root / "session.jsonl").read_bytes() == history + finally: + await owner.dispose() + + asyncio.run(scenario()) + + +def test_restore_health_real_short_lock_contention_retains_both_leases(tmp_path, monkeypatch): + async def scenario(): + root = tmp_path / "sessions" + root.mkdir(mode=0o700) + runtime = await saved(root) + owner, _ = prepare(root, runtime, runtime.bind_lifecycle_owned, defer_materialization=True) + construct = writer_lifecycle._lifecycle_session + + def contended(*args, **kwargs): + lock = tmp_path / "session-assets/.locks" / _header().conversation_id + with journal_file_lock(lock, "exclusive"): + return construct(*args, **kwargs) + + try: + with monkeypatch.context() as patch: + patch.setattr(writer_lifecycle, "_lifecycle_session", contended) + with pytest.raises(BlockingIOError): + await owner.restore() + assert owner._session is None and owner._runtime is not None + assert_busy(root) + blob_busy(root) + finally: + await owner.dispose() + assert_available(root) + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("where", ["construct", "read"]) +def test_health_contention_retains_unpublished_session_owner(tmp_path, monkeypatch, where): + async def scenario(): + root = tmp_path / "sessions" + root.mkdir(mode=0o700) + runtime = await saved(root) + owner, _ = prepare(root, runtime, runtime.bind_lifecycle_owned, defer_materialization=True) + + def busy(*args, **kwargs): + raise BlockingIOError("test restore attachment busy") + + try: + with monkeypatch.context() as patch: + if where == "construct": + patch.setattr(session_artifacts, "SessionBlobStore", busy) + else: + patch.setattr(SessionBlobStore, "read_bytes", busy) + with pytest.raises(BlockingIOError, match="restore attachment busy"): + await owner.restore() + assert owner._session is None and owner._runtime is not None + assert owner._active_io == 0 and owner.cleanup_pending + assert_busy(root) + finally: + await owner.dispose() + assert_available(root) + + asyncio.run(scenario()) diff --git a/tests/harness/transcript/test_writer_root_binding.py b/tests/harness/transcript/test_writer_root_binding.py new file mode 100644 index 000000000..a79831282 --- /dev/null +++ b/tests/harness/transcript/test_writer_root_binding.py @@ -0,0 +1,402 @@ +"""End-to-end regressions for retained-root Store IO and cleanup.""" + +from __future__ import annotations + +import asyncio +import os +import sys +import threading +from pathlib import Path + +import pytest + +from loushang.harness.journal import _rooted_io as native +from loushang.harness.transcript import load_agent_transcript_file +from loushang.harness.transcript import model_input_v2_index_file as index +from loushang.harness.transcript.model_input_v2_index_file import ( + _projection_cache_path, +) +from loushang.harness.transcript.writer_lease import TranscriptWriterLease + +from .test_lifecycle import _header, _record +from .test_runtime_profile import _runtime +from .test_writer_io import invoke, wait_until +from .test_writer_lifecycle import assert_available, assert_busy +from .test_writer_runtime import prepare + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux writer") + + +def tree(root): + return { + str(path.relative_to(root)): ( + path.lstat().st_mode, path.lstat().st_ino, path.lstat().st_mtime_ns, + path.read_bytes() if path.is_file() else None, + ) + for path in (root, *root.rglob("*")) + } + + +@pytest.mark.parametrize("method", ["append", "delete"]) +def test_admitted_io_cannot_mutate_replacement_root(tmp_path, monkeypatch, method): + async def scenario(): + root, moved = tmp_path / "sessions", tmp_path / "original" + root.mkdir(mode=0o700) + runtime = _runtime("coding") + owner, _ = prepare(root, runtime, runtime.bind_lifecycle_owned) + session = await owner.create() + store, key = session.runtime_binding.store, session.runtime_binding.key + before = (root / "session.jsonl").read_bytes() + entered, release = threading.Event(), threading.Event() + original = getattr(store, f"_{method}_sync") + replacement_writer = None + + def blocked(*args, **kwargs): + entered.set() # The independent writer.check has already settled. + assert release.wait(5) + return original(*args, **kwargs) + + monkeypatch.setattr(store, f"_{method}_sync", blocked) + operation = asyncio.create_task( + store.append(key, _record("one"), expected_revision=0, operation_id="one") + if method == "append" else + store.delete(key, expected_revision=0, operation_id="delete") + ) + try: + await wait_until(entered.is_set) + root.rename(moved) + root.mkdir(mode=0o700) + replacement = root / "session.jsonl" + replacement.write_bytes(before) + replacement_writer = TranscriptWriterLease(root, "coding", _header().conversation_id) + replacement_writer.acquire() + untouched = tree(root) + release.set() + result = await operation + assert tree(root) == untouched + if method == "append": + assert result.receipt.revision == 1 + _, records = load_agent_transcript_file(moved / "session.jsonl", read_only=True) + assert records == [_record("one")] + else: + assert result.revision == 0 and not (moved / "session.jsonl").exists() + finally: + release.set() + await asyncio.gather(operation, return_exceptions=True) + await owner.dispose() + if replacement_writer is not None: + replacement_writer.close() + asyncio.run(scenario()) + + +@pytest.mark.parametrize("method", ["create", "load", "append", "append_batch", "delete", "scan", "scan_page"]) +def test_all_owned_entries_use_original_tree_without_pathname_fallback(tmp_path, monkeypatch, method): + async def scenario(): + root, moved = tmp_path / "sessions", tmp_path / "original" + root.mkdir(mode=0o700) + runtime = _runtime("coding") + owner, _ = prepare(root, runtime, runtime.bind_lifecycle_owned, defer_materialization=method == "create") + session = await owner.create() + store, key = session.runtime_binding.store, session.runtime_binding.key + entered, release = threading.Event(), threading.Event() + original = getattr(store, f"_{method}_sync") + replacement_writer = None + + def blocked(*args, **kwargs): + entered.set() + assert release.wait(5) + return original(*args, **kwargs) + + monkeypatch.setattr(store, f"_{method}_sync", blocked) + operation = asyncio.create_task(invoke(store, key, method)) + try: + await wait_until(entered.is_set) + root.rename(moved) + root.mkdir(mode=0o700) + (root / "session.jsonl").write_bytes(b"this replacement must not be parsed or changed") + replacement_writer = TranscriptWriterLease(root, "coding", _header().conversation_id) + replacement_writer.acquire() + before = tree(root) + + def forbid(original_method): + def guarded(path, *args, **kwargs): + if path.is_relative_to(root) or path.is_relative_to(moved): + raise AssertionError("owned Store fell back to pathname IO") + return original_method(path, *args, **kwargs) + return guarded + + with monkeypatch.context() as patch: + for name in ("open", "stat", "lstat", "mkdir", "unlink", "replace", "resolve"): + patch.setattr(Path, name, forbid(getattr(Path, name))) + release.set() + result = await operation + assert tree(root) == before + if method == "load": + assert result.snapshot.header == _header() + elif method == "scan": + assert result == (key,) + elif method == "scan_page": + assert len(result.heads) == 1 and not result.diagnostics + elif method == "delete": + assert result.revision == 0 and not (moved / "session.jsonl").exists() + elif method == "create": + assert result.revision == 0 + header, records = load_agent_transcript_file(moved / "session.jsonl", read_only=True) + assert header == _header() and records == [] + else: + assert (result.receipt.revision if method == "append" else result.receipts[0].revision) == 1 + _, records = load_agent_transcript_file(moved / "session.jsonl", read_only=True) + assert records == [_record("one")] + finally: + release.set() + await asyncio.gather(operation, return_exceptions=True) + await owner.dispose() + if replacement_writer is not None: + replacement_writer.close() + asyncio.run(scenario()) + + +@pytest.mark.parametrize("cache", ["missing", "corrupt", "valid", "tail"]) +def test_owned_index_paths_remain_in_original_root(tmp_path, monkeypatch, cache): + async def scenario(): + root, moved = tmp_path / "sessions", tmp_path / "original" + root.mkdir(mode=0o700) + runtime = _runtime("coding") + owner, _ = prepare(root, runtime, runtime.bind_lifecycle_owned) + session = await owner.create() + store, key = session.runtime_binding.store, session.runtime_binding.key + if cache in {"valid", "tail"}: + await store.load(key) + elif cache == "corrupt": + _projection_cache_path(root / "session.jsonl").write_bytes(b"corrupt") + if cache == "tail": + await store.append(key, _record("one"), expected_revision=0, operation_id="one") + original = store._load_sync + untouched, rebuilds = [], [] + rebuild = index._try_rebuild_manifest + + def rebuilding(*args, **kwargs): + rebuilds.append(1) + return rebuild(*args, **kwargs) + + monkeypatch.setattr(index, "_try_rebuild_manifest", rebuilding) + + def load(*args, **kwargs): + root.rename(moved) + root.mkdir(mode=0o700) + (root / "session.jsonl").write_bytes(b"unrelated replacement") + untouched.append(tree(root)) + return original(*args, **kwargs) + + monkeypatch.setattr(store, "_load_sync", load) + result = await store.load(key) + assert result.snapshot.header == _header() + assert len(result.snapshot.records) == (1 if cache == "tail" else 0) + assert result.snapshot.records == ((_record("one"),) if cache == "tail" else ()) + assert tree(root) == untouched[0] + assert rebuilds == ([] if cache == "valid" else [1]) + assert _projection_cache_path(moved / "session.jsonl").is_file() + await owner.dispose() + asyncio.run(scenario()) + + +@pytest.mark.parametrize("fail_sync", [False, True]) +@pytest.mark.parametrize("prior_retirement", [False, True]) +def test_tombstone_is_durable_before_jsonl_unlink(tmp_path, monkeypatch, fail_sync, prior_retirement): + async def scenario(): + runtime = _runtime("coding") + owner, _ = prepare(tmp_path, runtime, runtime.bind_lifecycle_owned) + session = await owner.create() + store, key = session.runtime_binding.store, session.runtime_binding.key + identities = tmp_path / ".conversation-identities" + identities.mkdir(mode=0o700) + identity = identities.stat() + if prior_retirement: + # Visible retirement + remaining JSONL is the observable state after + # a previous process stopped between replace and directory fsync. + tombstone = store._tombstone_for(key, tmp_path / "session.jsonl") + tombstone.write_text( + '{"revision":0,"deleted_at":"2026-09-13T00:00:00+00:00","operation_id":"delete"}', + encoding="utf-8", + ) + tombstone.chmod(0o600) + events = [] + sync, unlink = native.os.fsync, native.os.unlink + + def fsync(fd): + current = os.fstat(fd) + if (current.st_dev, current.st_ino) == (identity.st_dev, identity.st_ino): + if fail_sync: + raise OSError("test retirement sync failed") + events.append("retirement-durable") + return sync(fd) + + def remove(name, *args, **kwargs): + if name == "session.jsonl": + assert "retirement-durable" in events + events.append("content-unlink") + return unlink(name, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(native.os, "fsync", fsync) + patch.setattr(native.os, "unlink", remove) + if fail_sync: + from loushang.harness.conversation import StoreCommitOutcomeUnknown + + with pytest.raises(StoreCommitOutcomeUnknown): + await store.delete(key, expected_revision=0, operation_id="delete") + assert events == [] and (tmp_path / "session.jsonl").is_file() + assert owner._file_io.cleanup_pending + assert_busy(tmp_path) + else: + result = await store.delete(key, expected_revision=0, operation_id="delete") + assert result.revision == 0 + assert events.index("retirement-durable") < events.index("content-unlink") + assert await store.delete(key, expected_revision=0, operation_id="delete") == result + await owner.dispose() + assert_available(tmp_path) + asyncio.run(scenario()) + + +def test_scandir_unknown_close_is_retained_and_prevents_writer_release(tmp_path, monkeypatch): + async def scenario(): + runtime = _runtime("coding") + owner, writer = prepare(tmp_path, runtime, runtime.bind_lifecycle_owned) + session = await owner.create() + primary = ValueError("test traversal failed") + close_calls = [] + + class Entries: + def __iter__(self): + raise primary + + def close(self): + close_calls.append(1) + raise OSError("test iterator close unknown") + + iterator = Entries() + with monkeypatch.context() as patch: + patch.setattr(native.os, "scandir", lambda *_: iterator) + from loushang.harness.conversation import StoreDataError + + with pytest.raises(StoreDataError) as raised: + await session.runtime_binding.store.scan(session.runtime_binding.key.namespace) + assert raised.value.__cause__ is primary + assert primary.__notes__ == ["rooted IO cleanup debt retained"] + assert close_calls == [1] and owner._file_io.cleanup_pending + for _ in range(2): + with pytest.raises(OSError, match="unknown"): + await owner.dispose() + assert_busy(tmp_path) + assert close_calls == [1] + # The test owns the fake iterator, which has no native descriptor. + for operation in owner._file_io._operations: + assert operation.iterators == [(iterator, True)] and not operation.descriptors + operation.iterators.clear() + await owner.dispose() + assert not owner.cleanup_pending and not writer.cleanup_pending + asyncio.run(scenario()) + + +@pytest.mark.parametrize("replace_during_header", [False, True]) +def test_standard_owned_restore_reads_header_from_retained_root(tmp_path, monkeypatch, replace_during_header): + from loushang.harness.transcript import writer_lifecycle as lifecycle_module + from loushang.harness.transcript.writer_lease import TranscriptWriterError + + async def scenario(): + root, moved = tmp_path / "sessions", tmp_path / "original" + root.mkdir(mode=0o700) + runtime = _runtime("coding") + first, _ = prepare(root, runtime, runtime.bind_lifecycle_owned) + session = await first.create() + await session.runtime_binding.store.append( + session.runtime_binding.key, _record("one"), expected_revision=0, operation_id="one", + ) + await first.dispose() + owner, _ = prepare(root, runtime, runtime.bind_lifecycle_owned, defer_materialization=True) + original_header = lifecycle_module.load_agent_transcript_header + snapshots = [] + + def header(path, **kwargs): + assert kwargs["read_only"] and kwargs["file_io"] is owner._file_io + if replace_during_header: + root.rename(moved) + root.mkdir(mode=0o700) + (root / "session.jsonl").write_bytes(b"unrelated replacement") + snapshots.append(tree(root)) + result = original_header(path, **kwargs) + assert result == _header() + return result + + monkeypatch.setattr(lifecycle_module, "load_agent_transcript_header", header) + if replace_during_header: + # Header IO stays pinned. Subsequent Store admission detects the + # invalidated logical path and refuses to continue the restoration. + with pytest.raises(TranscriptWriterError, match="conflict"): + await owner.restore() + assert tree(root) == snapshots[0] + assert_busy(moved) + else: + restored = await owner.restore() + snapshot = await restored.runtime_binding.store.load(restored.runtime_binding.key) + assert snapshot.snapshot.records == (_record("one"),) + await owner.dispose() + assert_available(moved if replace_during_header else root) + if replace_during_header: + assert tree(root) == snapshots[0] + asyncio.run(scenario()) + + +def test_owned_cleanup_debt_prevents_writer_release_and_rejoins_same_port(tmp_path, monkeypatch): + async def scenario(): + runtime = _runtime("coding") + owner, _ = prepare(tmp_path, runtime, runtime.bind_lifecycle_owned) + session = await owner.create() + store, key = session.runtime_binding.store, session.runtime_binding.key + port = owner._file_io + assert port is not None + + def fail_write(*_): + raise ValueError("test index write failed") + + def fail_unlink(*_, **__): + raise OSError("test temporary unlink failed") + + with monkeypatch.context() as patch: + patch.setattr(native, "_write_all", fail_write) + patch.setattr(native.os, "unlink", fail_unlink) + with pytest.raises(Exception): + await store.load(key) + assert port.cleanup_pending + with pytest.raises(OSError, match="unlink failed"): + await owner.dispose() + assert owner.cleanup_pending and owner._file_io is port + assert_busy(tmp_path) + entered, release = threading.Event(), threading.Event() + original = port.cleanup + + def cleanup(): + entered.set() + assert release.wait(5) + original() + + monkeypatch.setattr(port, "cleanup", cleanup) + closing = asyncio.create_task(owner.dispose()) + try: + await wait_until(entered.is_set) + retained_task = owner._io_cleanup_task + closing.cancel() + with pytest.raises(asyncio.CancelledError): + await closing + assert_busy(tmp_path) + rejoin = asyncio.create_task(owner.dispose()) + await asyncio.sleep(0) + assert owner._io_cleanup_task is retained_task and not rejoin.done() + release.set() + await rejoin + assert not port.cleanup_pending and not owner.cleanup_pending + assert not tuple(tmp_path.glob("*.tmp")) + assert_available(tmp_path) + finally: + release.set() + asyncio.run(scenario()) diff --git a/tests/harness/transcript/test_writer_runtime.py b/tests/harness/transcript/test_writer_runtime.py new file mode 100644 index 000000000..641193ecb --- /dev/null +++ b/tests/harness/transcript/test_writer_runtime.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +import asyncio +import sys +from dataclasses import replace + +import pytest + +from loushang.harness.runtime import RuntimeCapabilityBindingError +from loushang.harness.transcript import AgentTranscriptLifecycle +from loushang.harness.transcript.writer_lease import ( + TranscriptWriterError, + TranscriptWriterLease, +) + +from .test_lifecycle import _header +from .test_runtime_profile import _runtime +from .test_writer_lifecycle import assert_available, assert_busy + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux writer") + + +def test_raw_runtime_is_retained_before_projection_and_cleanup_can_retry(tmp_path, monkeypatch): + async def scenario(): + runtime = _runtime("coding") + lifecycle = AgentTranscriptLifecycle( + bind_runtime=runtime.bind_lifecycle, + bind_runtime_owned=runtime.bind_lifecycle_owned, + ) + context = lifecycle.new_context( + session_dir=tmp_path, cwd="/workspace", persist=True, + header=_header(), session_file=tmp_path / "session.jsonl", + ) + writer = TranscriptWriterLease(tmp_path, "coding", context.header.conversation_id) + writer.acquire() + owner = lifecycle.prepare_writer( + context, runtime.resolve(persist=True), writer=writer, product_id="coding", + ) + raw = [] + calls = [] + original = runtime._binder.dispose + + def fail_projection(binding): + raw.append(binding) + raise ValueError("test projection") + + async def dispose(binding): + calls.append(binding) + if len(calls) == 1: + raise RuntimeError("test retry cleanup") + await original(binding) + + monkeypatch.setattr(runtime, "selected_store", fail_projection) + monkeypatch.setattr(runtime._binder, "dispose", dispose) + with pytest.raises(ValueError, match="test projection"): + await owner.create() + assert not (tmp_path / "session.jsonl").exists() + assert len(raw) == 1 and not raw[0].is_closed + assert_busy(tmp_path) + with pytest.raises(RuntimeError, match="test retry cleanup"): + await owner.dispose() + assert owner.cleanup_pending + assert_busy(tmp_path) + await owner.dispose() + assert calls == [raw[0], raw[0]] and raw[0].is_closed + assert not owner.cleanup_pending + assert_available(tmp_path) + asyncio.run(scenario()) + + +def prepare(root, runtime, bind_owned, *, defer_materialization=False, manage_blobs=False): + lifecycle = AgentTranscriptLifecycle( + bind_runtime=runtime.bind_lifecycle, bind_runtime_owned=bind_owned, + ) + context = lifecycle.new_context( + session_dir=root, cwd="/workspace", persist=True, + header=_header(), session_file=root / "session.jsonl", + ) + writer = TranscriptWriterLease(root, "coding", context.header.conversation_id) + writer.acquire() + owner = lifecycle.prepare_writer( + context, runtime.resolve(persist=True), writer=writer, product_id="coding", + defer_materialization=defer_materialization, + manage_blobs=manage_blobs, + ) + return owner, writer + + +def test_owned_success_rejects_duplicate_and_late_retention(tmp_path): + async def scenario(): + runtime = _runtime("coding") + callbacks = [] + raw = [] + + async def bind(context, profile, retain): + result = await runtime.bind_lifecycle_owned(context, profile, retain) + callbacks.append(retain) + raw.append(result) + with pytest.raises(TranscriptWriterError, match="closed"): + retain(result.dispose) + return result + + owner, writer = prepare(tmp_path, runtime, bind) + session = await owner.create() + assert (tmp_path / "session.jsonl").is_file() + assert session.runtime_binding is raw[0] + with pytest.raises(TranscriptWriterError, match="closed"): + callbacks[0](raw[0].dispose) + assert_busy(tmp_path) + await owner.dispose() + assert not owner.cleanup_pending and not writer.cleanup_pending + assert raw[0].product_binding.is_closed + asyncio.run(scenario()) + + +def test_cancelled_waiter_rejoins_same_raw_binding(tmp_path): + async def scenario(): + runtime = _runtime("coding") + entered, release = asyncio.Event(), asyncio.Event() + raw = [] + + async def bind(context, profile, retain): + result = await runtime.bind_lifecycle_owned(context, profile, retain) + raw.append(result) + entered.set() + await release.wait() + return result + + owner, _ = prepare(tmp_path, runtime, bind) + waiter = asyncio.create_task(owner.create()) + await asyncio.wait_for(entered.wait(), 5) + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + assert len(raw) == 1 + assert_busy(tmp_path) + release.set() + session = await owner.create() + assert session.runtime_binding is raw[0] and len(raw) == 1 + await owner.dispose() + assert_available(tmp_path) + asyncio.run(scenario()) + + +def test_late_callback_after_failed_projection_cannot_replace_cleanup(tmp_path): + async def scenario(): + runtime = _runtime("coding") + raw, callbacks = [], [] + + async def bind(context, profile, retain): + result = await runtime.bind_lifecycle_owned(context, profile, retain) + raw.append(result) + callbacks.append(retain) + raise ValueError("after retain") + + owner, _ = prepare(tmp_path, runtime, bind) + with pytest.raises(ValueError, match="after retain"): + await owner.create() + with pytest.raises(TranscriptWriterError, match="closed"): + callbacks[0](raw[0].dispose) + await owner.dispose() + assert raw[0].product_binding.is_closed + assert not owner.cleanup_pending + assert_available(tmp_path) + asyncio.run(scenario()) + + +@pytest.mark.parametrize("mode", ["conflict", "missing"]) +def test_conflicting_returned_disposer_does_not_release_writer(tmp_path, mode): + async def scenario(): + runtime = _runtime("coding") + raw = [] + + async def noop(): + pass + + async def bind(context, profile, retain): + if mode == "missing": + result = await runtime.bind_lifecycle(context, profile) + else: + result = await runtime.bind_lifecycle_owned(context, profile, retain) + raw.append(result) + return replace(result, dispose=noop) if mode == "conflict" else result + + owner, writer = prepare(tmp_path, runtime, bind) + with pytest.raises(TranscriptWriterError, match="conflict"): + await owner.create() + with pytest.raises(TranscriptWriterError, match="unavailable"): + await owner.dispose() + assert owner.cleanup_pending and not raw[0].product_binding.is_closed + assert_busy(tmp_path) + # The test owns both injected authorities and knows noop is effect-free. + await raw[0].dispose() + writer._close_claimed(owner) + asyncio.run(scenario()) + + +def test_standard_partial_factory_failure_keeps_cleanup_ledger_and_writer(tmp_path, monkeypatch): + async def scenario(): + runtime = _runtime("coding") + disposed, raw = [], [] + implementations = runtime._binder._registry._implementations + for key, implementation in tuple(implementations.items()): + if implementation.slot == "context.compaction": + def fail_factory(*_): + raise ValueError("test later factory") + implementations[key] = replace(implementation, create=fail_factory) + else: + def dispose(_value, _context, slot=implementation.slot): + disposed.append(slot) + if slot == "conversation.store" and disposed.count(slot) == 1: + raise RuntimeError("test store cleanup retry") + implementations[key] = replace(implementation, dispose=dispose) + original = runtime._binder.prepare_binding + + def capture(*args, **kwargs): + binding = original(*args, **kwargs) + raw.append(binding) + return binding + + monkeypatch.setattr(runtime._binder, "prepare_binding", capture) + owner, _ = prepare(tmp_path, runtime, runtime.bind_lifecycle_owned) + with pytest.raises(RuntimeCapabilityBindingError) as failure: + await owner.create() + assert failure.value.slot == "context.compaction" + assert isinstance(failure.value.__cause__, ValueError) + assert not (tmp_path / "session.jsonl").exists() + assert len(raw) == 1 and disposed == [] + assert_busy(tmp_path) + with pytest.raises(RuntimeCapabilityBindingError): + await owner.dispose() + assert disposed == ["agent.transcript_profile", "conversation.store"] + assert_busy(tmp_path) + await owner.dispose() + assert disposed == ["agent.transcript_profile", "conversation.store", "conversation.store"] + assert raw[0].is_closed and not owner.cleanup_pending + assert_available(tmp_path) + asyncio.run(scenario()) + + +def test_runtime_disposal_publication_failure_does_not_release_writer(tmp_path): + async def scenario(): + runtime = _runtime("coding") + disposed = [] + implementations = runtime._binder._registry._implementations + for key, implementation in tuple(implementations.items()): + def dispose(_value, _context, slot=implementation.slot): + disposed.append(slot) + implementations[key] = replace(implementation, dispose=dispose) + owner, _ = prepare(tmp_path, runtime, runtime.bind_lifecycle_owned) + await owner.create() + loop = asyncio.get_running_loop() + + def task_factory(loop, coro, **kwargs): + if coro.cr_code.co_name == "dispose_entries": + raise RuntimeError("test disposal publication") + return asyncio.Task(coro, loop=loop, **kwargs) + + loop.set_task_factory(task_factory) + try: + with pytest.raises(RuntimeError, match="test disposal publication"): + await owner.dispose() + finally: + loop.set_task_factory(None) + assert disposed == [] and owner.cleanup_pending + assert_busy(tmp_path) + await owner.dispose() + assert disposed == ["context.compaction", "agent.transcript_profile", "conversation.store"] + assert not owner.cleanup_pending + assert_available(tmp_path) + asyncio.run(scenario()) From 3df6f63f0120d70dfe1fe11bfe03a44cf62a3534 Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 05:29:20 +0000 Subject: [PATCH 19/44] feat(harness): supervise retained exec capture --- src/loushang/harness/tools/workspace/bash.py | 6 + .../harness/tools/workspace/protocol.py | 16 + .../harness/workspace/exec/__init__.py | 15 + .../workspace/exec/_capture_supervision.py | 49 +++ .../harness/workspace/exec/capture.py | 74 ++++ .../harness/workspace/exec/capture_lease.py | 74 ++++ .../harness/workspace/exec/service.py | 297 ++++++++++--- src/loushang/harness/workspace/exec/types.py | 5 + .../workspace/test_capture_supervision.py | 102 +++++ tests/harness/workspace/test_exec.py | 7 + tests/harness/workspace/test_exec_capture.py | 409 ++++++++++++++++++ .../test_exec_cleanup_diagnostics.py | 73 ++++ 12 files changed, 1068 insertions(+), 59 deletions(-) create mode 100644 src/loushang/harness/workspace/exec/_capture_supervision.py create mode 100644 src/loushang/harness/workspace/exec/capture.py create mode 100644 src/loushang/harness/workspace/exec/capture_lease.py create mode 100644 tests/harness/workspace/test_capture_supervision.py create mode 100644 tests/harness/workspace/test_exec_capture.py create mode 100644 tests/harness/workspace/test_exec_cleanup_diagnostics.py diff --git a/src/loushang/harness/tools/workspace/bash.py b/src/loushang/harness/tools/workspace/bash.py index c850a223b..173731bf3 100644 --- a/src/loushang/harness/tools/workspace/bash.py +++ b/src/loushang/harness/tools/workspace/bash.py @@ -109,6 +109,7 @@ class BashToolDetails(TypedDict, total=False): stdout_blob: dict[str, object] stderr_blob: dict[str, object] artifact_retention_error: str + artifact_cleanup_error: str class BashOperations(Protocol): @@ -727,6 +728,11 @@ def _exec_result_to_tool_result(result: ExecResult) -> AgentToolResult[dict[str, if result.artifact_retention_error is not None else {} ), + **( + {"artifact_cleanup_error": result.artifact_cleanup_error} + if result.artifact_cleanup_error is not None + else {} + ), "timed_out": result.timed_out, "cancelled": result.cancelled, "stdio_complete": result.stdio_complete, diff --git a/src/loushang/harness/tools/workspace/protocol.py b/src/loushang/harness/tools/workspace/protocol.py index 4adb0af6c..594a75540 100644 --- a/src/loushang/harness/tools/workspace/protocol.py +++ b/src/loushang/harness/tools/workspace/protocol.py @@ -12,6 +12,8 @@ def project_tool_details_for_protocol(details: object | None) -> dict[str, Any]: _copy_alias(projected, details, "full_output_path", "fullOutputPath") _copy_alias(projected, details, "stdout_blob", "stdoutBlob") _copy_alias(projected, details, "stderr_blob", "stderrBlob") + artifact_cleanup_diagnostic(details) + _copy_alias(projected, details, "artifact_cleanup_error", "artifactCleanupError") _copy_alias( projected, details, @@ -63,9 +65,22 @@ def normalize_bash_result_from_protocol( value = result.get(snake_key, result.get(protocol_key)) if value is not None: normalized[snake_key] = value + cleanup = artifact_cleanup_diagnostic(result) + if cleanup is not None: + normalized["artifact_cleanup_error"] = cleanup return normalized +def artifact_cleanup_diagnostic(details: Mapping[str, object]) -> str | None: + """Read the closed diagnostic without hiding invalid or conflicting aliases.""" + values = [details[key] for key in ("artifact_cleanup_error", "artifactCleanupError") if key in details] + if any(value not in (None, "temporary_cleanup_pending") for value in values): + raise ValueError("invalid artifact cleanup diagnostic") + if len(values) == 2 and values[0] != values[1]: + raise ValueError("conflicting artifact cleanup diagnostics") + return "temporary_cleanup_pending" if values and values[0] is not None else None + + def _copy_alias( projected: dict[str, Any], details: Mapping[str, object], @@ -97,6 +112,7 @@ def _int_or_none(value: object) -> int | None: __all__ = [ + "artifact_cleanup_diagnostic", "normalize_bash_result_from_protocol", "project_tool_details_for_protocol", "tool_artifact_paths_for_protocol", diff --git a/src/loushang/harness/workspace/exec/__init__.py b/src/loushang/harness/workspace/exec/__init__.py index 559c9211d..46dade29b 100644 --- a/src/loushang/harness/workspace/exec/__init__.py +++ b/src/loushang/harness/workspace/exec/__init__.py @@ -1,5 +1,13 @@ from __future__ import annotations +from .capture import CapturedExecExecutor, ExecCaptureSink +from .capture_lease import ( + CapturePreparation, + ExecCaptureFactory, + ExecCaptureLease, + SealedExecCapture, + SealedExecSource, +) from .errors import ExecLaunchError, ExecLaunchErrorKind from .service import ( AuthorizedProcessExecBackend, @@ -17,6 +25,13 @@ ) __all__ = [ + "CapturePreparation", + "ExecCaptureFactory", + "ExecCaptureLease", + "SealedExecCapture", + "SealedExecSource", + "CapturedExecExecutor", + "ExecCaptureSink", "ExecBackend", "AuthorizedProcessExecBackend", "ExecLaunchError", diff --git a/src/loushang/harness/workspace/exec/_capture_supervision.py b/src/loushang/harness/workspace/exec/_capture_supervision.py new file mode 100644 index 000000000..133b0f8e6 --- /dev/null +++ b/src/loushang/harness/workspace/exec/_capture_supervision.py @@ -0,0 +1,49 @@ +"""Observe borrowed execution tasks without cancelling or owning them.""" + +from __future__ import annotations + +import asyncio +from typing import Literal, TypeVar + +CaptureWakeReason = Literal["exit", "abort", "timeout"] +_Exit = TypeVar("_Exit") + + +async def wait_for_captured_process( + *, + exit_task: asyncio.Task[_Exit], + stdin_task: asyncio.Task[None], + readers: tuple[asyncio.Task[None], asyncio.Task[None]], + abort_task: asyncio.Task[None] | None, + timeout: float | None, +) -> CaptureWakeReason: + """Surface IO failure even while stdin or process exit is still pending. + + The caller retains all tasks and must terminate/join its original process + on failure or cancellation. A normal reader EOF is not process completion. + No task is shielded into a new owner or cancelled by this observer. + """ + loop = asyncio.get_running_loop() + deadline = None if timeout is None else loop.time() + timeout + io_tasks = {stdin_task, *readers} + while True: + # Failure wins over a simultaneous successful exit: output may be lost. + for task in tuple(io_tasks): + if task.done(): + task.result() + io_tasks.remove(task) + if exit_task.done(): + exit_task.result() + return "exit" + if abort_task is not None and abort_task.done(): + abort_task.result() + return "abort" + remaining = None if deadline is None else deadline - loop.time() + if remaining is not None and remaining <= 0: + return "timeout" + waiters = {exit_task, *io_tasks} + if abort_task is not None: + waiters.add(abort_task) + await asyncio.wait( + waiters, timeout=remaining, return_when=asyncio.FIRST_COMPLETED, + ) diff --git a/src/loushang/harness/workspace/exec/capture.py b/src/loushang/harness/workspace/exec/capture.py new file mode 100644 index 000000000..863858bc3 --- /dev/null +++ b/src/loushang/harness/workspace/exec/capture.py @@ -0,0 +1,74 @@ +"""Trusted local capture ports; no paths, storage authority or wire fields.""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Protocol + +from .types import ExecOutputChunk, ExecRequest, ExecResult, ExecUpdateCallback + +CAPTURE_BUFFER_BYTES = 100 * 1024 +CAPTURE_READ_BYTES = 16 * 1024 + + +class ExecCaptureSink(Protocol): + async def append(self, chunk: ExecOutputChunk) -> None: + """Accept ordered per-stream chunks, retaining native work on cancellation.""" + ... + + def stop_accepting(self) -> None: + """Synchronously close write admission; do not close or refund storage.""" + ... + + +class CapturedExecExecutor(Protocol): + async def execute( + self, request: ExecRequest, *, capture: ExecCaptureSink, + signal: object | None = None, on_update: ExecUpdateCallback | None = None, + ) -> ExecResult: ... + + +class _CaptureSinkBorrow: + """Close local admission even if the borrowed sink's callback is broken.""" + + def __init__(self, sink: ExecCaptureSink) -> None: + self._sink = sink + self._stopped = False + self.stop_error: BaseException | None = None + + async def append(self, chunk: ExecOutputChunk) -> None: + if self._stopped: + return + try: + await self._sink.append(chunk) + except BaseException as error: + self.stop_accepting() + self.annotate(error) + raise + + def stop_accepting(self) -> None: + if self._stopped: + return + self._stopped = True + try: + self._sink.stop_accepting() + except BaseException as error: + self.stop_error = error + + def annotate(self, primary: BaseException) -> None: + if self.stop_error is not None and self.stop_error is not primary: + primary.add_note(f"Capture admission close also failed: {self.stop_error!r}") + + +def bounded_capture_request(request: ExecRequest) -> ExecRequest: + return replace( + request, capture_full_output=False, retain_output_artifacts=False, + artifact_dir=None, + rolling_max_bytes=min(request.rolling_max_bytes, CAPTURE_BUFFER_BYTES), + preview_max_bytes=max(1, min(request.preview_max_bytes, CAPTURE_BUFFER_BYTES)), + ) + + +def validate_capture(capture: ExecCaptureSink) -> None: + if not callable(getattr(capture, "append", None)) or not callable(getattr(capture, "stop_accepting", None)): + raise TypeError("execution requires an explicit capture sink") diff --git a/src/loushang/harness/workspace/exec/capture_lease.py b/src/loushang/harness/workspace/exec/capture_lease.py new file mode 100644 index 000000000..bbed764e0 --- /dev/null +++ b/src/loushang/harness/workspace/exec/capture_lease.py @@ -0,0 +1,74 @@ +"""Local capture ownership ports, implemented by the composing storage owner. + +These objects are never wire credentials or authority to delete a path. The +original lease owns all native work, including work whose waiter was cancelled. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Protocol + +from .capture import ExecCaptureSink + + +class CapturePreparation(Enum): + READY = "ready" + # Only a settled, zero-effect logical capacity refusal permits this result. + RETENTION_UNAVAILABLE = "retention_unavailable" + + +class SealedExecSource(Protocol): + @property + def size_bytes(self) -> int: + """Immutable byte length recorded when the original lease was sealed.""" + ... + + async def read_bytes(self, *, max_bytes: int) -> bytes: + """Read the original identity within the limit, verifying final length. + + Admit at most one read at a time per source. The lease retains native + work on cancellation and rejects new reads once closing has started. + """ + ... + + +@dataclass(frozen=True, slots=True) +class SealedExecCapture: + """Both complete streams, including empty streams, from the same lease.""" + + stdout: SealedExecSource + stderr: SealedExecSource + + +class ExecCaptureLease(ExecCaptureSink, Protocol): + async def prepare(self) -> CapturePreparation: + """Join original preparation; unknown effects raise, never downgrade.""" + ... + + async def seal(self) -> SealedExecCapture | None: + """Fence writes and settle native work; None means known retention loss. + + Repeated calls join the original seal, not another scan or publication. + """ + ... + + @property + def cleanup_pending(self) -> bool: + """Whether original native work, deletion or reservation debt remains.""" + ... + + async def close(self) -> None: + """Fence reads/writes, join admitted work and settle original cleanup. + + Never replay completed or unknown deletion. Success alone is not release: + the caller must also observe cleanup_pending=False before dropping us. + """ + ... + + +class ExecCaptureFactory(Protocol): + def new_capture(self) -> ExecCaptureLease: + """Allocate a fresh owner without IO, before the caller enrolls it.""" + ... diff --git a/src/loushang/harness/workspace/exec/service.py b/src/loushang/harness/workspace/exec/service.py index 388b4fea9..864bf9d3d 100644 --- a/src/loushang/harness/workspace/exec/service.py +++ b/src/loushang/harness/workspace/exec/service.py @@ -5,11 +5,11 @@ import inspect import os import tempfile -from collections.abc import Awaitable +from collections.abc import Awaitable, Coroutine from contextlib import suppress from dataclasses import dataclass, field, replace from pathlib import Path -from typing import Any, Literal, Protocol, TextIO +from typing import Any, Literal, Protocol, TextIO, TypeVar from uuid import uuid4 from loushang.harness.runtime._owned_tasks import _await_cancellation_atomic @@ -25,6 +25,15 @@ ) from loushang.harness.workspace.truncation import truncate_tail +from ._capture_supervision import wait_for_captured_process +from .capture import ( + CAPTURE_READ_BYTES, + CapturedExecExecutor, + ExecCaptureSink, + _CaptureSinkBorrow, + bounded_capture_request, + validate_capture, +) from .errors import ExecLaunchError, ExecLaunchErrorKind from .types import ( ExecOutputChunk, @@ -35,6 +44,22 @@ materialize_exec_request, ) +_T = TypeVar("_T") + + +def _process_task( + coroutine: Coroutine[Any, Any, _T], *, captured: bool, name: str | None = None, +) -> asyncio.Task[_T]: + """Publish captured process support only through the trusted Task primitive. + + The process is already owned: an external factory must not detach a reader + or cleanup task by scheduling it and then losing its publication receipt. + Ordinary execution retains its existing task-factory behavior. + """ + if captured: + return asyncio.Task(coroutine, loop=asyncio.get_running_loop(), name=name) + return asyncio.create_task(coroutine, name=name) + class ExecBackend(Protocol): """Execute a materialized request without rereading cwd or environment.""" @@ -58,6 +83,19 @@ def __init__( self._backend = backend if backend is not None else LocalExecBackend() self.execution_profile = execution_profile + def capture_executor(self) -> CapturedExecExecutor | None: + # An execute wrapper must explicitly preserve its own policy. Its + # unused inherited backend is not a capture capability. + if getattr(self.execute, "__func__", None) is not ExecService.execute: + return None + for base in (LocalExecBackend, AuthorizedProcessExecBackend): + if (isinstance(self._backend, base) + and type(self._backend).__call__ is not base.__call__ + and type(self._backend).execute_captured is base.execute_captured): + return None + operation = getattr(self._backend, "execute_captured", None) + return _BoundCapturedExecutor(operation) if callable(operation) else None + async def execute( self, request: ExecRequest, @@ -74,6 +112,31 @@ async def execute( return result +class _CapturedOperation(Protocol): + def __call__( + self, request: ExecRequest, *, capture: ExecCaptureSink, + signal: object | None = None, on_update: ExecUpdateCallback | None = None, + ) -> Awaitable[ExecResult]: ... + + +class _BoundCapturedExecutor: + def __init__(self, operation: _CapturedOperation) -> None: + self._operation = operation + + async def execute( + self, request: ExecRequest, *, capture: ExecCaptureSink, + signal: object | None = None, on_update: ExecUpdateCallback | None = None, + ) -> ExecResult: + validate_capture(capture) + result = await self._operation( + materialize_exec_request(bounded_capture_request(request)), + capture=capture, signal=signal, on_update=on_update, + ) + if not isinstance(result, ExecResult): + raise TypeError("captured exec backend must return ExecResult") + return result + + class LocalExecBackend: """Run one already-materialized request as a local child process.""" @@ -101,15 +164,34 @@ def __init__( hard_timeout_seconds=post_exit_stdio_hard_timeout_seconds, ) + async def execute_captured( + self, request: ExecRequest, *, capture: ExecCaptureSink, + signal: object | None = None, on_update: ExecUpdateCallback | None = None, + ) -> ExecResult: + validate_capture(capture) + return await self._execute( + bounded_capture_request(request), signal=signal, on_update=on_update, + capture=capture, + ) + async def __call__( + self, request: ExecRequest, *, signal: object | None = None, + on_update: ExecUpdateCallback | None = None, + ) -> ExecResult: + return await self._execute(request, signal=signal, on_update=on_update) + + async def _execute( self, request: ExecRequest, *, signal: object | None = None, on_update: ExecUpdateCallback | None = None, + capture: ExecCaptureSink | None = None, ) -> ExecResult: assert request.effective_environment is not None assert request.cwd is not None + capture_guard = None if capture is None else _CaptureSinkBorrow(capture) + capture = capture_guard env = dict(request.effective_environment) _validate_local_launch(request.command, request.cwd) @@ -165,6 +247,8 @@ async def _publish( sink.append(text) output_chunk = ExecOutputChunk(stream=stream_name, text=text) output_capture.append(output_chunk) + if capture is not None: + await capture.append(output_chunk) if on_update is not None: update = on_update(output_chunk) if inspect.isawaitable(update): @@ -175,39 +259,62 @@ async def _read_stream( stream, sink: _StreamCapture, ) -> None: - decoder = _IncrementalTextChunks(max_chunk_chars=self._read_chunk_bytes) + chunk_bytes = self._read_chunk_bytes if capture is None else min(self._read_chunk_bytes, CAPTURE_READ_BYTES) + decoder = _IncrementalTextChunks(max_chunk_chars=chunk_bytes) try: while True: - chunk = await stream.read(self._read_chunk_bytes) + chunk = await stream.read(chunk_bytes) if not chunk: break activity.mark() for text in decoder.feed(chunk): await _publish(stream_name, sink, text) + except BaseException: + if capture is not None: + capture.stop_accepting() + raise finally: for text in decoder.finish(): await _publish(stream_name, sink, text) - stdout_task = asyncio.create_task( - _read_stream("stdout", process.stdout, stdout_capture) + stdout_task = _process_task( + _read_stream("stdout", process.stdout, stdout_capture), captured=capture is not None, ) - stderr_task = asyncio.create_task( - _read_stream("stderr", process.stderr, stderr_capture) + stderr_task = _process_task( + _read_stream("stderr", process.stderr, stderr_capture), captured=capture is not None, ) - root_exit_task = asyncio.create_task(_wait_for_root_process_exit(process)) - settlement_task = asyncio.create_task(process.wait()) + root_exit_task = _process_task(_wait_for_root_process_exit(process), captured=capture is not None) + settlement_task = _process_task(process.wait(), captured=capture is not None) abort_task = ( - asyncio.create_task(_wait_for_abort(signal)) if signal is not None else None + _process_task(_wait_for_abort(signal), captured=capture is not None) if signal is not None else None ) timed_out = False cancelled = False force_terminated = False drain_outcome = _OutputDrainOutcome.complete() + stdin_task: asyncio.Task[None] | None = None try: - await _write_process_stdin(process, request.stdin) - if request.timeout_seconds is None and abort_task is None: + if capture is not None: + stdin_task = _process_task(_write_process_stdin(process, request.stdin), captured=True) + reason = await wait_for_captured_process( + exit_task=root_exit_task, stdin_task=stdin_task, + readers=(stdout_task, stderr_task), abort_task=abort_task, + timeout=request.timeout_seconds, + ) + if reason != "exit": + cancelled, timed_out = reason == "abort", reason == "timeout" + force_terminated = True + if cancelled: + capture.stop_accepting() + await _kill_process(process) + await asyncio.shield(root_exit_task) + else: + await _write_process_stdin(process, request.stdin) + if capture is not None: + pass + elif request.timeout_seconds is None and abort_task is None: await asyncio.shield(root_exit_task) else: waiters: set[asyncio.Task[int] | asyncio.Task[None]] = {root_exit_task} @@ -233,41 +340,61 @@ async def _read_stream( for task in pending: task.cancel() await asyncio.gather(task, return_exceptions=True) - except asyncio.CancelledError: + except asyncio.CancelledError as error: + if capture is not None: + capture.stop_accepting() + capture.annotate(error) force_terminated = True await _kill_process(process) if not root_exit_task.done(): await asyncio.shield(root_exit_task) raise + except BaseException as error: + if capture is not None: + capture.stop_accepting() + capture.annotate(error) + raise finally: - if abort_task is not None and not abort_task.done(): - abort_task.cancel() - await asyncio.gather(abort_task, return_exceptions=True) - if not root_exit_task.done(): - force_terminated = True - await _kill_process(process) - await asyncio.shield(root_exit_task) - activity.mark() - try: - drain_outcome = await _drain_output_tasks( - process, - (stdout_task, stderr_task), - activity=activity, - policy=( - _FORCED_OUTPUT_DRAIN_POLICY - if force_terminated - else self._normal_output_drain_policy - ), - ) - finally: - _close_reader_transport(process.stdout) - _close_reader_transport(process.stderr) + async def finish_process() -> None: + nonlocal force_terminated, drain_outcome + if abort_task is not None and not abort_task.done(): + abort_task.cancel() + await asyncio.gather(abort_task, return_exceptions=True) + if not root_exit_task.done(): + force_terminated = True + await _kill_process(process) + await asyncio.shield(root_exit_task) try: - await settlement_task + if stdin_task is not None: + await _cancel_tasks({stdin_task}) + activity.mark() + drain_outcome = await _drain_output_tasks( + process, (stdout_task, stderr_task), activity=activity, + policy=(_FORCED_OUTPUT_DRAIN_POLICY if force_terminated + else self._normal_output_drain_policy), + ) finally: - stdout_capture.close() - stderr_capture.close() + _close_reader_transport(process.stdout) + _close_reader_transport(process.stderr) + try: + await settlement_task + finally: + stdout_capture.close() + stderr_capture.close() + + if capture is None: + await finish_process() + else: + # This mandatory cleanup must not be published through an + # external task factory after the process is already owned. + cleanup_task = _process_task( + finish_process(), captured=True, + name="harness-captured-local-cleanup", + ) + await _await_cancellation_atomic(cleanup_task) + if capture_guard is not None and capture_guard.stop_error is not None: + raise capture_guard.stop_error stdout = stdout_capture.content stderr = stderr_capture.content stdout_preview, stdout_artifact_path = _build_preview_from_capture( @@ -333,17 +460,36 @@ def __post_init__(self) -> None: if value <= 0: raise ValueError(f"Authorized process {name} must be positive") + async def execute_captured( + self, request: ExecRequest, *, capture: ExecCaptureSink, + signal: object | None = None, on_update: ExecUpdateCallback | None = None, + ) -> ExecResult: + validate_capture(capture) + return await self._execute( + bounded_capture_request(request), signal=signal, on_update=on_update, + capture=capture, + ) + async def __call__( + self, request: ExecRequest, *, signal: object | None = None, + on_update: ExecUpdateCallback | None = None, + ) -> ExecResult: + return await self._execute(request, signal=signal, on_update=on_update) + + async def _execute( self, request: ExecRequest, *, signal: object | None = None, on_update: ExecUpdateCallback | None = None, + capture: ExecCaptureSink | None = None, ) -> ExecResult: if not isinstance(request, ExecRequest): raise TypeError("Authorized process execution requires an ExecRequest") if request.cwd is None or request.effective_environment is None: raise ValueError("Authorized process execution requires a frozen request") + capture_guard = None if capture is None else _CaptureSinkBorrow(capture) + capture = capture_guard handle = await self.launcher.start( ProcessLaunchRequest( command=request.command, @@ -375,12 +521,14 @@ async def __call__( async def publish( chunk: ExecOutputChunk, - capture: _StreamCapture, + stream_capture: _StreamCapture, ) -> None: if not chunk.text: return - capture.append(chunk.text) + stream_capture.append(chunk.text) output_capture.append(chunk) + if capture is not None: + await capture.append(chunk) if on_update is not None: update = on_update(chunk) if inspect.isawaitable(update): @@ -389,14 +537,11 @@ async def publish( async def drain_stream( stream_name: Literal["stdout", "stderr"], ) -> None: - decoder = _IncrementalTextChunks(max_chunk_chars=64 * 1024) + decoder = _IncrementalTextChunks(max_chunk_chars=64 * 1024 if capture is None else CAPTURE_READ_BYTES) try: while True: - content = ( - await handle.read_stdout() - if stream_name == "stdout" - else await handle.read_stderr() - ) + read = handle.read_stdout if stream_name == "stdout" else handle.read_stderr + content = await read() if capture is None else await read(CAPTURE_READ_BYTES) if not content: return for text in decoder.feed(content): @@ -406,6 +551,10 @@ async def drain_stream( if stream_name == "stdout" else stderr_capture, ) + except BaseException: + if capture is not None: + capture.stop_accepting() + raise finally: for text in decoder.finish(): await publish( @@ -416,12 +565,12 @@ async def drain_stream( ) drain_tasks = ( - asyncio.create_task(drain_stream("stdout")), - asyncio.create_task(drain_stream("stderr")), + _process_task(drain_stream("stdout"), captured=capture is not None), + _process_task(drain_stream("stderr"), captured=capture is not None), ) wait_task: asyncio.Task[ProcessExit] | None = None abort_task = ( - asyncio.create_task(_wait_for_abort(signal)) if signal is not None else None + _process_task(_wait_for_abort(signal), captured=capture is not None) if signal is not None else None ) timed_out = False cancelled = False @@ -429,14 +578,35 @@ async def drain_stream( exit_status: ProcessExit | None = None result: ExecResult | None = None operation_error: BaseException | None = None - try: + stdin_task: asyncio.Task[None] | None = None + + async def write_input() -> None: if request.stdin is not None: - await handle.write_stdin( - request.stdin.encode("utf-8", errors="surrogateescape") - ) + await handle.write_stdin(request.stdin.encode("utf-8", errors="surrogateescape")) await handle.close_stdin() - wait_task = asyncio.create_task(handle.wait()) - if request.timeout_seconds is None and abort_task is None: + + try: + if capture is not None: + wait_task = _process_task(handle.wait(), captured=True) + stdin_task = _process_task(write_input(), captured=True) + reason = await wait_for_captured_process( + exit_task=wait_task, stdin_task=stdin_task, readers=drain_tasks, + abort_task=abort_task, timeout=request.timeout_seconds, + ) + if reason == "exit": + exit_status = wait_task.result() + else: + cancelled, timed_out = reason == "abort", reason == "timeout" + if cancelled: + capture.stop_accepting() + exit_status = await handle.terminate() + await _cancel_tasks({stdin_task}) + else: + await write_input() + wait_task = asyncio.create_task(handle.wait()) + if capture is not None: + pass + elif request.timeout_seconds is None and abort_task is None: exit_status = await wait_task else: waiters: set[asyncio.Task[object]] = { @@ -473,6 +643,8 @@ async def drain_stream( stdio_complete = False await asyncio.gather(*drain_tasks, return_exceptions=True) assert exit_status is not None + if capture_guard is not None and capture_guard.stop_error is not None: + raise capture_guard.stop_error stdout_capture.close() stderr_capture.close() stdout = stdout_capture.content @@ -513,13 +685,17 @@ async def drain_stream( ) except BaseException as error: operation_error = error + if capture is not None: + capture.stop_accepting() + capture.annotate(error) - cleanup_task = asyncio.create_task( + cleanup_task = _process_task( _cleanup_authorized_process( handle, - (wait_task, abort_task, *drain_tasks), + (wait_task, abort_task, *drain_tasks, stdin_task), terminate=operation_error is not None, ), + captured=capture is not None, name="harness-authorized-process-cleanup", ) cleanup_error: BaseException | None = None @@ -818,7 +994,10 @@ def append(self, text: str) -> None: self.chunks.append(text) return - self._ensure_artifact_handle().write(text) + # Rolling previews do not require a disk spool when retention is off. + # This also keeps the no-retention fallback free of temporary-file IO. + if self.retain_output_artifact: + self._ensure_artifact_handle().write(text) self.chunks.append(text) self._chunk_bytes += len(_output_bytes(text)) self._trim_rolling_chunks() diff --git a/src/loushang/harness/workspace/exec/types.py b/src/loushang/harness/workspace/exec/types.py index a15c536cc..1517e8c43 100644 --- a/src/loushang/harness/workspace/exec/types.py +++ b/src/loushang/harness/workspace/exec/types.py @@ -203,6 +203,9 @@ class ExecResult: stdout_artifact_ref: RunArtifactRef | SessionBlobRef | None = None stderr_artifact_ref: RunArtifactRef | SessionBlobRef | None = None artifact_retention_error: str | None = None + artifact_cleanup_error: Literal["temporary_cleanup_pending"] | None = field( + default=None, kw_only=True, + ) def __post_init__(self) -> None: object.__setattr__( @@ -236,6 +239,8 @@ def __post_init__(self) -> None: self.artifact_retention_error, str ): raise TypeError("artifact retention error must be a string or None") + if self.artifact_cleanup_error not in (None, "temporary_cleanup_pending"): + raise ValueError("invalid artifact cleanup diagnostic") __all__ = [ diff --git a/tests/harness/workspace/test_capture_supervision.py b/tests/harness/workspace/test_capture_supervision.py new file mode 100644 index 000000000..d41d2906d --- /dev/null +++ b/tests/harness/workspace/test_capture_supervision.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from loushang.harness.workspace.exec._capture_supervision import ( + wait_for_captured_process, +) + + +def test_completed_reader_failure_wins_over_simultaneous_process_exit(): + asyncio.run(_completed_reader_failure_wins_over_simultaneous_process_exit()) + + +async def _completed_reader_failure_wins_over_simultaneous_process_exit(): + async def complete(): + return None + + async def fail(): + raise ValueError("reader failed before observation") + + stdin, stdout, stderr, exited = [asyncio.create_task(operation()) for operation in + (complete, fail, complete, complete)] + await asyncio.gather(stdin, stdout, stderr, exited, return_exceptions=True) + with pytest.raises(ValueError, match="reader failed before observation"): + await wait_for_captured_process( + exit_task=exited, stdin_task=stdin, readers=(stdout, stderr), + abort_task=None, timeout=None, + ) + + +@pytest.mark.parametrize("failed", ["stdout", "stderr", "stdin"]) +def test_io_failure_wakes_before_blocked_stdin_and_process_exit(failed): + asyncio.run(_io_failure_wakes_before_blocked_stdin_and_process_exit(failed)) + + +async def _io_failure_wakes_before_blocked_stdin_and_process_exit(failed): + gates = {name: asyncio.Event() for name in ("stdout", "stderr", "stdin", "exit")} + + async def operation(name): + await gates[name].wait() + if name == failed: + raise ValueError("capture contract failure") + + tasks = {name: asyncio.create_task(operation(name)) for name in gates} + observer = asyncio.create_task(wait_for_captured_process( + exit_task=tasks["exit"], stdin_task=tasks["stdin"], + readers=(tasks["stdout"], tasks["stderr"]), abort_task=None, timeout=None, + )) + try: + gates[failed].set() + with pytest.raises(ValueError, match="capture contract failure"): + await asyncio.wait_for(observer, 1) + assert not tasks["exit"].done() + assert all(not task.cancelled() for task in tasks.values()) + finally: + observer.cancel() + for task in tasks.values(): + task.cancel() + await asyncio.gather(observer, *tasks.values(), return_exceptions=True) + + +@pytest.mark.parametrize("wake", ["exit", "abort", "timeout", "cancel"]) +def test_normal_eof_does_not_end_execution_or_transfer_task_ownership(wake): + asyncio.run(_normal_eof_does_not_end_execution_or_transfer_task_ownership(wake)) + + +async def _normal_eof_does_not_end_execution_or_transfer_task_ownership(wake): + exit_gate, abort_gate = asyncio.Event(), asyncio.Event() + + async def complete(): + return None + + io_tasks = [asyncio.create_task(complete()) for _ in range(3)] + exit_task = asyncio.create_task(exit_gate.wait()) + abort_task = asyncio.create_task(abort_gate.wait()) + observer = asyncio.create_task(wait_for_captured_process( + exit_task=exit_task, stdin_task=io_tasks[0], readers=tuple(io_tasks[1:]), + abort_task=abort_task, timeout=0 if wake == "timeout" else None, + )) + try: + await asyncio.gather(*io_tasks) + if wake != "timeout": + assert not observer.done() + if wake == "exit": + exit_gate.set() + elif wake == "abort": + abort_gate.set() + elif wake == "cancel": + observer.cancel() + if wake == "cancel": + with pytest.raises(asyncio.CancelledError): + await observer + else: + assert await asyncio.wait_for(observer, 1) == wake + assert not exit_task.cancelled() and not abort_task.cancelled() + finally: + observer.cancel() + exit_task.cancel() + abort_task.cancel() + await asyncio.gather(observer, exit_task, abort_task, return_exceptions=True) diff --git a/tests/harness/workspace/test_exec.py b/tests/harness/workspace/test_exec.py index 1dbfec21e..bf9e904ea 100644 --- a/tests/harness/workspace/test_exec.py +++ b/tests/harness/workspace/test_exec.py @@ -533,7 +533,14 @@ async def scenario() -> None: def test_exec_service_discards_unretained_output_artifacts( tmp_path: Path, capture_full_output: bool, + monkeypatch: pytest.MonkeyPatch, ) -> None: + from loushang.harness.workspace.exec import service as service_module + + def forbidden(*args, **kwargs): + pytest.fail("disabled output retention created a temporary file") + + monkeypatch.setattr(service_module.tempfile, "mkstemp", forbidden) artifact_dir = tmp_path / "artifacts" artifact_dir.mkdir() diff --git a/tests/harness/workspace/test_exec_capture.py b/tests/harness/workspace/test_exec_capture.py new file mode 100644 index 000000000..37c4a985b --- /dev/null +++ b/tests/harness/workspace/test_exec_capture.py @@ -0,0 +1,409 @@ +from __future__ import annotations + +import asyncio +import sys +from types import SimpleNamespace + +import pytest + +from loushang.harness.workspace.exec import ExecRequest, ExecResult, ExecService +from loushang.harness.workspace.exec import service as module + + +class Sink: + def __init__(self): + self.stopped = False + self.chunks = [] + + async def append(self, chunk): + if not self.stopped: + assert len(chunk.text) <= 16 * 1024 + self.chunks.append(chunk) + + def stop_accepting(self): + self.stopped = True + + +@pytest.mark.parametrize("base", [module.LocalExecBackend, module.AuthorizedProcessExecBackend]) +def test_backend_call_wrapper_requires_explicit_capture_support(base): + class Guard(base): + async def __call__(self, *args, **kwargs): + pytest.fail("ordinary policy must not be bypassed implicitly") + + class Explicit(Guard): + async def execute_captured(self, *args, **kwargs): + return ExecResult(exit_code=9) + + options = {} if base is module.LocalExecBackend else {"launcher": SimpleNamespace(start=lambda: None)} + assert ExecService(backend=Guard(**options)).capture_executor() is None + capability = ExecService(backend=Explicit(**options)).capture_executor() + assert capability is not None + assert asyncio.run(capability.execute(ExecRequest(("unused",)), capture=Sink())).exit_code == 9 + + +def test_unsupported_backend_and_execute_wrapper_have_no_capture_capability(): + def ordinary(*args, **kwargs): + pytest.fail("capability lookup executed backend") + + class Wrapper(ExecService): + async def execute(self, *args, **kwargs): + pytest.fail("capability lookup executed wrapper") + + assert ExecService(backend=ordinary).capture_executor() is None + assert Wrapper().capture_executor() is None + + +def test_capability_remains_bound_to_original_backend(): + asyncio.run(_capability_remains_bound_to_original_backend()) + + +async def _capability_remains_bound_to_original_backend(): + calls = [] + + class Backend: + async def execute_captured(self, request, **kwargs): + calls.append(self) + return ExecResult(exit_code=7) + + original = Backend() + service = ExecService(backend=original) + capability = service.capture_executor() + service._backend = Backend() + assert capability is not None + assert (await capability.execute(ExecRequest(("unused",)), capture=Sink())).exit_code == 7 + assert calls == [original] + + +def test_local_capture_clamps_all_buffers_and_never_uses_old_file_path(tmp_path, monkeypatch): + asyncio.run(_local_capture_clamps_all_buffers_and_never_uses_old_file_path(tmp_path, monkeypatch)) + + +async def _local_capture_clamps_all_buffers_and_never_uses_old_file_path(tmp_path, monkeypatch): + monkeypatch.setattr(module.tempfile, "mkstemp", lambda *a, **k: pytest.fail("old artifact path")) + sink = Sink() + capability = ExecService().capture_executor() + assert capability is not None + result = await capability.execute(ExecRequest( + (sys.executable, "-c", "import os; os.write(1,b'x'*300000); os.write(2,b'y'*300000); raise SystemExit(7)"), + cwd=str(tmp_path), artifact_dir=str(tmp_path), rolling_max_bytes=10**9, + preview_max_bytes=10**9, + ), capture=sink) + assert result.exit_code == 7 and result.stdio_complete + assert result.stdout_artifact_path is result.stderr_artifact_path is None + assert len(result.stdout.encode()) <= 100 * 1024 + assert len(result.stderr.encode()) <= 100 * 1024 + assert sum(len(chunk.text.encode()) for chunk in result.output_chunks) <= 100 * 1024 + assert sum(len(chunk.text.encode()) for chunk in sink.chunks) == 600000 + assert not list(tmp_path.iterdir()) + + +def test_local_sink_failure_terminates_process_while_stdin_is_blocked(tmp_path, monkeypatch): + asyncio.run(_local_sink_failure_terminates_process_while_stdin_is_blocked(tmp_path, monkeypatch)) + + +async def _local_sink_failure_terminates_process_while_stdin_is_blocked(tmp_path, monkeypatch): + processes = [] + spawn = module.spawn_local_process + + async def record_spawn(**kwargs): + process = await spawn(**kwargs) + processes.append(process) + return process + + class FailingSink(Sink): + async def append(self, chunk): + if not self.stopped: + raise ValueError("capture contract failure") + + monkeypatch.setattr(module, "spawn_local_process", record_spawn) + sink = FailingSink() + capability = ExecService().capture_executor() + assert capability is not None + with pytest.raises(ValueError, match="capture contract failure"): + await asyncio.wait_for(capability.execute(ExecRequest( + (sys.executable, "-c", "import os\nwhile True: os.write(1,b'x'*16384)"), + cwd=str(tmp_path), stdin="i" * 1024 * 1024, + ), capture=sink), 5) + assert sink.stopped and len(processes) == 1 and processes[0].returncode is not None + + +def test_second_cancel_cannot_abandon_local_cleanup_or_native_capture(tmp_path, monkeypatch): + asyncio.run(_second_cancel_cannot_abandon_local_cleanup_or_native_capture(tmp_path, monkeypatch)) + + +async def _second_cancel_cannot_abandon_local_cleanup_or_native_capture(tmp_path, monkeypatch): + entered, stdin_closing, release_stdin, release_native = [asyncio.Event() for _ in range(4)] + processes = [] + spawn = module.spawn_local_process + + async def record_spawn(**kwargs): + process = await spawn(**kwargs) + processes.append(process) + return process + + async def blocked_stdin(*args): + try: + await asyncio.Event().wait() + finally: + stdin_closing.set() + await release_stdin.wait() + + native = asyncio.create_task(release_native.wait()) + + class PendingSink(Sink): + async def append(self, chunk): + if self.stopped: + pytest.fail("write after admission closed") + self.chunks.append(chunk) + entered.set() + await asyncio.shield(native) + + monkeypatch.setattr(module, "spawn_local_process", record_spawn) + monkeypatch.setattr(module, "_write_process_stdin", blocked_stdin) + sink = PendingSink() + capability = ExecService().capture_executor() + assert capability is not None + operation = asyncio.create_task(capability.execute(ExecRequest( + (sys.executable, "-c", "import os\nwhile True: os.write(1,b'x'*16384)"), + cwd=str(tmp_path), stdin="blocked", + ), capture=sink)) + try: + await asyncio.wait_for(entered.wait(), 5) + operation.cancel() + await asyncio.wait_for(stdin_closing.wait(), 5) + operation.cancel() + await asyncio.sleep(0) + assert not operation.done() + assert processes[0].returncode is not None and not native.done() + release_stdin.set() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(operation, 5) + assert sink.stopped and not native.done() + assert processes[0].stdout._transport.is_closing() + assert processes[0].stderr._transport.is_closing() + finally: + release_stdin.set() + release_native.set() + operation.cancel() + await asyncio.gather(operation, native, return_exceptions=True) + + +@pytest.mark.parametrize("fault", ["before", "after"]) +def test_local_cleanup_bypasses_external_task_factory(tmp_path, monkeypatch, fault): + asyncio.run(_local_cleanup_bypasses_external_task_factory(tmp_path, monkeypatch, fault)) + + +async def _local_cleanup_bypasses_external_task_factory(tmp_path, monkeypatch, fault): + loop = asyncio.get_running_loop() + previous = loop.get_task_factory() + attempts, hits, processes = [], [], [] + spawn = module.spawn_local_process + + async def record_spawn(**kwargs): + process = await spawn(**kwargs) + processes.append(process) + return process + + def factory(loop, coroutine, context=None): + name = coroutine.cr_code.co_name + attempts.append(name) + if name == "finish_process": + hits.append(name) + if fault == "after": + asyncio.Task(coroutine, loop=loop, context=context) + raise RuntimeError("external cleanup factory failure") + return asyncio.Task(coroutine, loop=loop, context=context) + + monkeypatch.setattr(module, "spawn_local_process", record_spawn) + loop.set_task_factory(factory) + try: + capability = ExecService().capture_executor() + assert capability is not None + result = await capability.execute(ExecRequest( + (sys.executable, "-c", "print('complete')"), cwd=str(tmp_path), + ), capture=Sink()) + assert result.exit_code == 0 and result.stdio_complete + assert attempts and not hits + assert processes[0].stdout._transport.is_closing() + assert processes[0].stderr._transport.is_closing() + finally: + loop.set_task_factory(previous) + + +class _RejectProcessTaskFactory: + """Fault only after the backend has received its original process owner.""" + + def __init__(self, point, fault): + self.point, self.fault = point, fault + self.hits = [] + self.published = [] + + def __call__(self, loop, coroutine, context=None): + name = coroutine.cr_code.co_name + values = coroutine.cr_frame.f_locals + matches = ( + (self.point in {"stdout", "stderr"} + and name in {"_read_stream", "drain_stream"} + and values.get("stream_name") == self.point) + or name == { + "root_exit": "_wait_for_root_process_exit", + "settlement": "wait", + "abort": "_wait_for_abort", + "stdin": "_write_process_stdin", + "local_cleanup": "finish_process", + "authorized_stdin": "write_input", + "authorized_cleanup": "_cleanup_authorized_process", + }.get(self.point) + ) + if matches: + self.hits.append(name) + if self.fault == "after": + self.published.append(asyncio.Task(coroutine, loop=loop, context=context)) + else: + coroutine.close() + raise RuntimeError("external process task publication failed") + return asyncio.Task(coroutine, loop=loop, context=context) + + +@pytest.mark.parametrize("point", ["stdout", "stderr", "root_exit", "settlement", "abort", "stdin", "local_cleanup"]) +@pytest.mark.parametrize("fault", ["before", "after"]) +@pytest.mark.parametrize("reader_failure", [False, True]) +def test_local_captured_process_tasks_bypass_factory_and_settle(tmp_path, monkeypatch, point, fault, reader_failure): + asyncio.run(_local_captured_process_tasks_bypass_factory_and_settle( + tmp_path, monkeypatch, point, fault, reader_failure, + )) + + +async def _local_captured_process_tasks_bypass_factory_and_settle(tmp_path, monkeypatch, point, fault, reader_failure): + loop = asyncio.get_running_loop() + previous = loop.get_task_factory() + factory = _RejectProcessTaskFactory(point, fault) + processes = [] + spawn = module.spawn_local_process + + async def record_spawn(**kwargs): + process = await spawn(**kwargs) + processes.append(process) + loop.set_task_factory(factory) + return process + + class Capture(Sink): + async def append(self, chunk): + if reader_failure and not self.stopped: + raise ValueError("original reader failure") + await super().append(chunk) + + monkeypatch.setattr(module, "spawn_local_process", record_spawn) + code = ("import os,time; os.write(1,b'output'); time.sleep(30)" if reader_failure + else "import sys; sys.stdin.read(); print('output'); raise SystemExit(7)") + capability = ExecService().capture_executor() + assert capability is not None + try: + operation = capability.execute(ExecRequest( + (sys.executable, "-c", code), cwd=str(tmp_path), stdin="input", timeout_seconds=3, + ), capture=Capture(), signal=SimpleNamespace(aborted=False)) + if reader_failure: + with pytest.raises(ValueError, match="original reader failure"): + await operation + else: + result = await operation + assert result.exit_code == 7 and result.stdio_complete + assert not factory.hits + assert len(processes) == 1 and processes[0].returncode is not None + assert processes[0].stdout._transport.is_closing() + assert processes[0].stderr._transport.is_closing() + finally: + loop.set_task_factory(previous) + # The fixture, not a later Product destructor, must contain the broken + # pre-fix path while the assertions still require backend settlement. + for process in processes: + if process.returncode is None: + await module._kill_process(process) + module._close_reader_transport(process.stdout) + module._close_reader_transport(process.stderr) + await process.wait() + for task in factory.published: + task.cancel() + await asyncio.gather(*factory.published, return_exceptions=True) + + +@pytest.mark.parametrize("point", ["stdout", "stderr", "settlement", "abort", "authorized_stdin", "authorized_cleanup"]) +@pytest.mark.parametrize("fault", ["before", "after"]) +@pytest.mark.parametrize("reader_failure", [False, True]) +def test_authorized_captured_process_tasks_bypass_factory_and_settle(point, fault, reader_failure): + asyncio.run(_authorized_captured_process_tasks_bypass_factory_and_settle(point, fault, reader_failure)) + + +async def _authorized_captured_process_tasks_bypass_factory_and_settle(point, fault, reader_failure): + loop = asyncio.get_running_loop() + previous = loop.get_task_factory() + factory = _RejectProcessTaskFactory(point, fault) + exited = asyncio.Event() + calls = [] + + class Handle: + def __init__(self): + self.stdout = [b"output", b""] + + async def read_stdout(self, max_bytes): + return self.stdout.pop(0) + + async def read_stderr(self, max_bytes): + return b"" + + async def write_stdin(self, content): + assert content == b"input" + + async def close_stdin(self): + calls.append("stdin-closed") + if not reader_failure: + exited.set() + + async def wait(self): + await exited.wait() + return module.ProcessExit(return_code=7) + + async def terminate(self): + calls.append("terminated") + exited.set() + return module.ProcessExit(return_code=-1) + + async def close(self): + assert exited.is_set() + calls.append("closed") + + handle = Handle() + + class Launcher: + async def start(self, *args, **kwargs): + calls.append("started") + loop.set_task_factory(factory) + return handle + + class Capture(Sink): + async def append(self, chunk): + if reader_failure and not self.stopped: + raise ValueError("original reader failure") + await super().append(chunk) + + capability = ExecService(backend=module.AuthorizedProcessExecBackend(Launcher())).capture_executor() + assert capability is not None + try: + operation = capability.execute(ExecRequest( + ("/bin/unused",), cwd="/workspace", stdin="input", timeout_seconds=3, + ), capture=Capture(), signal=SimpleNamespace(aborted=False)) + if reader_failure: + with pytest.raises(ValueError, match="original reader failure"): + await operation + else: + result = await operation + assert result.exit_code == 7 and result.stdio_complete + assert not factory.hits + assert calls.count("started") == calls.count("closed") == 1 + assert calls.count("terminated") == int(reader_failure) + finally: + loop.set_task_factory(previous) + exited.set() + for task in factory.published: + task.cancel() + await asyncio.gather(*factory.published, return_exceptions=True) diff --git a/tests/harness/workspace/test_exec_cleanup_diagnostics.py b/tests/harness/workspace/test_exec_cleanup_diagnostics.py new file mode 100644 index 000000000..f0aeab7ce --- /dev/null +++ b/tests/harness/workspace/test_exec_cleanup_diagnostics.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import inspect +from dataclasses import fields + +import pytest + +from loushang.agent.types import AgentToolResult +from loushang.harness.artifacts import SessionBlobRef +from loushang.harness.session.bash import command_result_from_tool_result +from loushang.harness.tools.workspace.bash import _exec_result_to_tool_result +from loushang.harness.tools.workspace.protocol import ( + normalize_bash_result_from_protocol, + project_tool_details_for_protocol, +) +from loushang.harness.workspace.exec import ExecResult + + +def test_cleanup_diagnostic_is_keyword_only_and_absent_from_default_tool_details(): + parameter = inspect.signature(ExecResult).parameters["artifact_cleanup_error"] + assert parameter.kind is inspect.Parameter.KEYWORD_ONLY and parameter.default is None + assert fields(ExecResult)[-1].kw_only + result = ExecResult(7, "out", "err") + details = _exec_result_to_tool_result(result).details + assert result.artifact_cleanup_error is None + assert "artifact_cleanup_error" not in details + assert "artifactCleanupError" not in project_tool_details_for_protocol(details) + + +@pytest.mark.parametrize("retention", [None, "publication outcome unknown"]) +def test_cleanup_diagnostic_preserves_exit_refs_and_independent_retention_error(retention): + reference = SessionBlobRef( + session_id="session", blob_id="a" * 64, logical_name="command.log", + kind="command-stdout", media_type="text/plain", disclosure="private", + size_bytes=3, sha256="a" * 64, created_at=1.0, + ) + result = ExecResult(7, "out", stdout_artifact_ref=reference, + artifact_retention_error=retention, + artifact_cleanup_error="temporary_cleanup_pending") + tool = _exec_result_to_tool_result(result) + projected = project_tool_details_for_protocol(tool.details) + normalized = normalize_bash_result_from_protocol(projected) + command = command_result_from_tool_result(tool) + for value in (normalized, command): + assert value["exit_code"] == 7 + assert value["stdout_blob"] == reference.manifest_entry() + assert value["artifact_cleanup_error"] == "temporary_cleanup_pending" + assert value.get("artifact_retention_error") == retention + assert projected["artifactCleanupError"] == "temporary_cleanup_pending" + + +@pytest.mark.parametrize("value", ["/private/path", "secret-token", "", True, 1]) +def test_cleanup_diagnostic_never_projects_raw_error_text(value): + with pytest.raises(ValueError, match="invalid artifact cleanup diagnostic"): + ExecResult(0, artifact_cleanup_error=value) + for key in ("artifact_cleanup_error", "artifactCleanupError"): + with pytest.raises(ValueError, match="invalid artifact cleanup diagnostic"): + project_tool_details_for_protocol({key: value}) + with pytest.raises(ValueError, match="invalid artifact cleanup diagnostic"): + normalize_bash_result_from_protocol({key: value}) + with pytest.raises(ValueError, match="invalid artifact cleanup diagnostic"): + command_result_from_tool_result(AgentToolResult(content=[], details={key: value})) + + +@pytest.mark.parametrize("values", [(None, "temporary_cleanup_pending"), ("temporary_cleanup_pending", None), + (None, "raw secret"), ("raw secret", None)]) +def test_every_projection_rejects_conflicting_or_hidden_invalid_alias(values): + details = dict(zip(("artifact_cleanup_error", "artifactCleanupError"), values, strict=True)) + for projection in (project_tool_details_for_protocol, normalize_bash_result_from_protocol): + with pytest.raises(ValueError): + projection(details) + with pytest.raises(ValueError): + command_result_from_tool_result(AgentToolResult(content=[], details=details)) From ffb321edbeb5a95e00e928e283125857cb7958f1 Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 05:30:22 +0000 Subject: [PATCH 20/44] feat(harness): own captured session output --- .../harness/runtime/_profile_binding.py | 103 ++++- .../harness/session/_output_capture.py | 181 ++++++++ src/loushang/harness/session/agent_product.py | 72 +++- .../harness/session/agent_product_runtime.py | 2 + src/loushang/harness/session/bash.py | 4 + src/loushang/harness/session/lifecycle.py | 128 +++++- .../harness/session/output_artifacts.py | 70 +++- .../harness/session/product_runtime.py | 30 +- .../session/session_capability_provider.py | 5 + .../harness/session/transcript_lifecycle.py | 179 ++++++-- .../harness/runtime/test_prepared_profile.py | 169 ++++++++ tests/harness/session/test_lifecycle.py | 228 ++++++++++- tests/harness/session/test_output_capture.py | 387 ++++++++++++++++++ .../session/test_output_capture_shutdown.py | 121 ++++++ .../test_owned_construction_cleanup.py | 162 ++++++++ .../session/test_owned_output_artifacts.py | 147 +++++++ .../test_transcript_construction_cleanup.py | 184 +++++++++ .../session/test_transcript_lifecycle.py | 2 + 18 files changed, 2115 insertions(+), 59 deletions(-) create mode 100644 src/loushang/harness/session/_output_capture.py create mode 100644 tests/harness/runtime/test_prepared_profile.py create mode 100644 tests/harness/session/test_output_capture.py create mode 100644 tests/harness/session/test_output_capture_shutdown.py create mode 100644 tests/harness/session/test_owned_construction_cleanup.py create mode 100644 tests/harness/session/test_owned_output_artifacts.py create mode 100644 tests/harness/session/test_transcript_construction_cleanup.py diff --git a/src/loushang/harness/runtime/_profile_binding.py b/src/loushang/harness/runtime/_profile_binding.py index 3da0b26b3..30d2d8104 100644 --- a/src/loushang/harness/runtime/_profile_binding.py +++ b/src/loushang/harness/runtime/_profile_binding.py @@ -28,7 +28,12 @@ @dataclass(frozen=True) class RuntimeCapabilityImplementation: - """One registered factory for an exact slot, key, and wire version.""" + """One registered factory for an exact slot, key, and wire version. + + A factory owns its effects until it returns a value. On failure or + cancellation it must settle unreturned resources itself; a binder can + dispose only values actually handed to it, not escaped tasks or threads. + """ slot: str implementation: str @@ -173,6 +178,9 @@ def __init__( self._async_disposal_pending: tuple[_BoundRuntimeCapability, ...] | None = None self._async_dispose_lock = asyncio.Lock() self._sync_disposal_pending: tuple[_BoundRuntimeCapability, ...] | None = None + self._prepared_by: RuntimeProfileBinder | None = None + self._construction_state = "ready" + self._construction_loop: asyncio.AbstractEventLoop | None = None @property def profile(self) -> ResolvedRuntimeProfile: @@ -212,6 +220,8 @@ def _replace( def _require_open(self) -> None: if self._closed: raise RuntimeError("runtime profile binding is closed") + if self._construction_state != "ready": + raise RuntimeError("runtime profile binding is not ready") class RuntimeProfileBinder: @@ -220,6 +230,69 @@ class RuntimeProfileBinder: def __init__(self, registry: RuntimeCapabilityRegistry) -> None: self._registry = registry + def prepare_binding( + self, profile: ResolvedRuntimeProfile, *, context: object | None = None, + ) -> RuntimeProfileBinding: + """Return an unpopulated cleanup owner before any factory executes. + + Retain it before awaiting bind_prepared. Failed construction does not + dispose implicitly: the owner explicitly disposes the same binding. + """ + state = RuntimeBindingState[RuntimeProfileBindings]( + unbound_message="runtime profile binding has not been initialized", + stale_message="runtime profile binding was refreshed", + ) + binding = RuntimeProfileBinding(profile=profile, context=context, state=state, bound={}) + binding._prepared_by = self + binding._construction_state = "prepared" + return binding + + def _check_prepared_binding(self, binding: RuntimeProfileBinding) -> None: + if binding._prepared_by is None: + return + if binding._prepared_by is not self: + raise RuntimeError("prepared binding belongs to another binder") + loop = asyncio.get_running_loop() + if binding._construction_loop is None: + binding._construction_loop = loop + elif binding._construction_loop is not loop: + raise RuntimeError("prepared binding belongs to another event loop") + + async def bind_prepared(self, binding: RuntimeProfileBinding) -> RuntimeProfileBinding: + """Populate once, retaining each returned value before the next factory. + + No generation is published until all factories and projection succeed. + The caller must join construction before disposing this binding. + """ + self._check_prepared_binding(binding) + if binding._prepared_by is not self or binding._closed or binding._construction_state != "prepared": + raise RuntimeError("runtime profile binding cannot be constructed again") + binding._construction_state = "building" + try: + for capability in binding.profile.capabilities: + for resolved in capability.selections: + selection = resolved.selection + implementation = self._registry.resolve(selection) + try: + value = await _await_result(implementation.create(selection, binding._context)) + except (asyncio.CancelledError, RuntimeCapabilityBindingError): + raise + except Exception as exc: + raise RuntimeCapabilityBindingError( + "capability factory failed", slot=selection.slot, + implementation=selection.implementation, + implementation_version=selection.implementation_version, + ) from exc + entry = _BoundRuntimeCapability(resolved=resolved, implementation=implementation, value=value) + key = capability.slot.key + binding._bound[key] = (*binding._bound.get(key, ()), entry) + binding._state.bind(_live_bindings(binding.profile, binding._bound)) + except BaseException: + binding._construction_state = "failed" + raise + binding._construction_state = "ready" + return binding + async def bind( self, profile: ResolvedRuntimeProfile, @@ -272,6 +345,7 @@ async def rebind( *, boundary: Literal["turn"] = "turn", ) -> None: + self._check_prepared_binding(binding) if boundary != "turn": raise ValueError( "runtime profile rebind is only supported at a turn boundary" @@ -356,6 +430,9 @@ async def rebind( ) async def dispose(self, binding: RuntimeProfileBinding) -> None: + self._check_prepared_binding(binding) + if binding._construction_state == "building": + raise RuntimeError("runtime profile binding is still building") async with binding._async_dispose_lock: task = binding._dispose_task if task is None: @@ -365,16 +442,26 @@ async def dispose(self, binding: RuntimeProfileBinding) -> None: entries = pending or tuple( entry for bound in binding._bound.values() for entry in bound ) + # A failed task publication must not turn closed into settled. + binding._async_disposal_pending = entries if not binding._closed: binding._closed = True binding._state.invalidate("runtime profile binding was disposed") - task = asyncio.create_task( - self._dispose_entries_collecting_retryable( - entries, - context=binding._context, - ) - ) + published = asyncio.get_running_loop().create_future() + + async def dispose_entries(): + await published + return await self._dispose_entries_collecting_retryable(entries, context=binding._context) + + work = dispose_entries() + try: + task = asyncio.create_task(work) + except BaseException: + published.cancel() + work.close() + raise binding._dispose_task = task + published.set_result(None) try: errors, failed = await _await_cancellation_atomic(task) except asyncio.CancelledError as exc: @@ -390,6 +477,8 @@ async def dispose(self, binding: RuntimeProfileBinding) -> None: def dispose_sync(self, binding: RuntimeProfileBinding) -> None: """Dispose a binding created from synchronous factories.""" + if binding._prepared_by is not None: + raise RuntimeError("prepared bindings require asynchronous disposal") task = binding._dispose_task if task is not None and task.done(): return diff --git a/src/loushang/harness/session/_output_capture.py b/src/loushang/harness/session/_output_capture.py new file mode 100644 index 000000000..e52a7312b --- /dev/null +++ b/src/loushang/harness/session/_output_capture.py @@ -0,0 +1,181 @@ +"""Session-owned bounded capture admission and durable publication.""" + +from __future__ import annotations + +import asyncio +import hashlib +import time +from dataclasses import dataclass, replace + +from loushang.harness.artifacts import ( + ArtifactStoreError, + SessionBlobRef, + SessionBlobStore, +) +from loushang.harness.runtime._owned_tasks import _await_cancellation_atomic +from loushang.harness.workspace.exec import ( + CapturedExecExecutor, + CapturePreparation, + ExecCaptureFactory, + ExecCaptureLease, + ExecRequest, + ExecResult, + ExecUpdateCallback, + SealedExecCapture, +) + +MAX_PENDING_CAPTURES = 8 +MAX_PUBLICATION_BYTES = 128 * 1024 * 1024 + + +@dataclass(eq=False) +class _PendingCapture: + lease: ExecCaptureLease + cleanup: asyncio.Task[None] | None = None + + +class SessionOutputCapture: + def __init__( + self, executor: CapturedExecExecutor, factory: ExecCaptureFactory, + store: SessionBlobStore, + ) -> None: + self._executor = executor + self._factory = factory + self._store = store + self._pending: list[_PendingCapture] = [] + self._active: set[asyncio.Task[object]] = set() + self._idle = asyncio.Event() + self._idle.set() + self._closing = False + self._loop: asyncio.AbstractEventLoop | None = None + + def _require_loop(self) -> None: + current = asyncio.get_running_loop() + if self._loop is None: + self._loop = current + elif self._loop is not current: + raise RuntimeError("Session output capture belongs to another event loop") + + async def _cleanup(self, pending: _PendingCapture) -> None: + task = pending.cleanup + if task is None or (task.done() and (task.cancelled() or task.exception() is not None)): + # Keep the original task even when a caller is cancelled. Bypass + # external task factories at this resource-ownership boundary. + task = asyncio.Task(pending.lease.close(), loop=asyncio.get_running_loop()) + pending.cleanup = task + await _await_cancellation_atomic(task) + if pending not in self._pending: + return + if pending.cleanup is not task: + # A late waiter has no authority over another cleanup phase, even + # if that phase has cleared its debt flag before returning a receipt. + raise RuntimeError("temporary cleanup remains pending") + if pending.lease.cleanup_pending: + # A successful phase can still leave debt; a later close may + # continue only through the original lease. + if pending.cleanup is task: + pending.cleanup = None + raise RuntimeError("temporary cleanup remains pending") + if pending in self._pending: + self._pending.remove(pending) + + def fence(self) -> None: + self._require_loop() + self._closing = True + + @property + def cleanup_pending(self) -> bool: + self._require_loop() + return bool(self._pending or self._active) + + async def close(self) -> None: + if asyncio.current_task() in self._active: + raise RuntimeError("capture cannot close from its own execution") + self.fence() + await self._idle.wait() + failures: list[Exception] = [] + for pending in tuple(self._pending): + try: + await self._cleanup(pending) + except Exception as error: + failures.append(error) + if failures: + raise failures[0] + + async def execute( + self, request: ExecRequest, *, signal: object | None, + on_update: ExecUpdateCallback | None, + ) -> ExecResult: + self._require_loop() + if self._closing: + raise RuntimeError("Session output capture is closing") + if len(self._pending) >= MAX_PENDING_CAPTURES: + raise RuntimeError("Session output capture capacity exhausted") + task = asyncio.current_task() + assert task is not None + if task in self._active: + raise RuntimeError("recursive Session output capture is unavailable") + pending = _PendingCapture(self._factory.new_capture()) + self._pending.append(pending) + self._active.add(task) + self._idle.clear() + result: ExecResult | None = None + try: + prepared = await pending.lease.prepare() + if not isinstance(prepared, CapturePreparation): + raise TypeError("invalid capture preparation result") + if self._closing: + raise RuntimeError("Session output capture is closing") + result = await self._executor.execute( + request, capture=pending.lease, signal=signal, on_update=on_update, + ) + result = replace(result, stdout_artifact_path=None, stderr_artifact_path=None, + stdout_artifact_ref=None, stderr_artifact_ref=None) + sealed = await pending.lease.seal() + if (prepared is CapturePreparation.READY and sealed is not None + and result.stdio_complete and not result.cancelled + and result.artifact_retention_error is None): + try: + stdout, stderr = await self._publish(sealed) + except (ArtifactStoreError, OSError, ValueError): + result = replace(result, artifact_retention_error="command output was not retained") + else: + result = replace(result, stdout_artifact_ref=stdout, stderr_artifact_ref=stderr) + else: + result = replace(result, artifact_retention_error="command output was not retained") + finally: + try: + await self._cleanup(pending) + except Exception: + if result is not None: + result = replace(result, artifact_cleanup_error="temporary_cleanup_pending") + finally: + self._active.remove(task) + if not self._active: + self._idle.set() + assert result is not None + return result + + async def _publish(self, sealed: SealedExecCapture) -> tuple[SessionBlobRef, SessionBlobRef]: + sources = (sealed.stdout, sealed.stderr) + sizes = tuple(source.size_bytes for source in sources) + limit = self._store.policy.max_blob_bytes + if (any(type(size) is not int or size < 0 or size > limit for size in sizes) + or sum(sizes) > MAX_PUBLICATION_BYTES): + raise ArtifactStoreError("sealed output exceeds publication limit") + blobs: list[tuple[SessionBlobRef, bytes]] = [] + for role, source, size in zip(("stdout", "stderr"), sources, sizes, strict=True): + payload = await source.read_bytes(max_bytes=min(size, limit)) + if not isinstance(payload, bytes) or len(payload) != size: + raise ArtifactStoreError("sealed output size changed") + digest = hashlib.sha256(payload).hexdigest() + blobs.append((SessionBlobRef( + session_id=self._store.session_id, blob_id=digest, + logical_name=f"command-output/{role}-{digest[:16]}.log", + kind=f"command-{role}", media_type="text/plain; charset=utf-8", + disclosure="private", size_bytes=size, sha256=digest, + created_at=time.time(), source=f"command-output:{role}", + ), payload)) + publication = self._store.import_blobs(blobs) + stdout, stderr = publication.references + return stdout, stderr diff --git a/src/loushang/harness/session/agent_product.py b/src/loushang/harness/session/agent_product.py index 043801352..8c2579711 100644 --- a/src/loushang/harness/session/agent_product.py +++ b/src/loushang/harness/session/agent_product.py @@ -178,6 +178,7 @@ ) from loushang.harness.session.operations_runtime import SessionOperationsPorts from loushang.harness.session.output_artifacts import ( + SessionOutputPersistingExecService, persist_session_command_outputs, ) from loushang.harness.session.request_evidence import ( @@ -220,7 +221,7 @@ CompactionResult, ProductTranscriptSession, ) -from loushang.harness.workspace.exec import ExecService +from loushang.harness.workspace.exec import ExecCaptureFactory, ExecService from loushang.harness.workspace.process import AuthorizedProcessLauncher ResourceCatalogRefreshBootstrapFactory = Callable[ @@ -383,6 +384,7 @@ def __init__( api_registry: APIRegistry | None = None, exec_service: ExecService | None = None, tool_exec_service: ExecService | None = None, + output_capture_factory: ExecCaptureFactory | None = None, approval_resolver: InteractiveApprovalResolver | None = None, tool_policy_evaluator: PolicyEvaluator | None = None, workspace_capability_binding: CapabilityBundleProviderBinding | None = None, @@ -597,6 +599,7 @@ def __init__( staged_side_question=self._staged_side_question_candidate, staged_transcript=self._staged_transcript_candidate, bind_provider=self._bind_selected_side_question_provider, + check_transcript_retirement=self._check_output_capture_retirement, ) self._model_call_capability_binding = ( build_session_model_call_capability_binding( @@ -767,6 +770,10 @@ def __init__( session_id=session_manager.get_header().conversation_id, persist=session_manager.persist, temporary_root=session_temporary_root, + file_io=session_manager._lifecycle_session.blob_file_io, + operation_scope=session_manager._lifecycle_session.operation_scope, + initialization_scope=session_manager._lifecycle_session.sync_operation_scope, + capture_factory=output_capture_factory, ) if not session_manager.persist: self._tool_exec_service = tool_exec_service @@ -779,6 +786,10 @@ def __init__( session_id=session_manager.get_header().conversation_id, persist=True, temporary_root=session_temporary_root, + file_io=session_manager._lifecycle_session.blob_file_io, + operation_scope=session_manager._lifecycle_session.operation_scope, + initialization_scope=session_manager._lifecycle_session.sync_operation_scope, + capture_factory=output_capture_factory, ) self.footer_data_provider = footer_data_provider self._base_prompt = ( @@ -1286,11 +1297,54 @@ async def _dispose_session_runtime_profile(self) -> None: ) await _await_cancellation_atomic(task) + async def _close_output_captures(self) -> None: + """Settle original output owners before releasing Session blob authority.""" + seen: set[int] = set() + failures: list[Exception] = [] + owners: list[SessionOutputPersistingExecService] = [] + for executor in (getattr(self, "_exec_service", None), getattr(self, "_tool_exec_service", None)): + if not isinstance(executor, SessionOutputPersistingExecService) or id(executor) in seen: + continue + seen.add(id(executor)) + owners.append(executor) + for executor in owners: + executor.fence() + for executor in owners: + try: + await executor.close() + except Exception as error: + failures.append(error) + if failures: + raise failures[0] + + def _fence_output_captures(self) -> None: + for executor in (getattr(self, "_exec_service", None), getattr(self, "_tool_exec_service", None)): + if isinstance(executor, SessionOutputPersistingExecService): + executor.fence() + + def _check_output_capture_retirement(self) -> None: + for executor in (getattr(self, "_exec_service", None), getattr(self, "_tool_exec_service", None)): + if isinstance(executor, SessionOutputPersistingExecService) and executor.cleanup_pending: + raise RuntimeError("Session output capture cleanup remains pending") + + def _defer_output_capture_rollback(self) -> bool: + owners = tuple(executor for executor in ( + getattr(self, "_exec_service", None), getattr(self, "_tool_exec_service", None), + ) if isinstance(executor, SessionOutputPersistingExecService)) + if not any(owner.cleanup_pending for owner in owners): + return False + for owner in owners: + owner.fence() + self._execution_preparation_failed = True + return True + async def _dispose_owned_model_call_runtime( self, *, base_dispose: Callable[[], Awaitable[None]], ) -> None: + # Stop new work, then cancel producers before waiting for output idle. + self._fence_output_captures() errors: list[BaseException] = [] side_question_consumer = self._side_question_consumer if side_question_consumer is not None: @@ -1298,6 +1352,9 @@ async def _dispose_owned_model_call_runtime( await side_question_consumer.cancel_and_wait() except BaseException as exc: errors.append(exc) + # Graph disposal can release the transcript writer too. Settle output + # before entering that path, without holding the model-call bind lock. + await self._close_output_captures() self._restore_agent_model_call_boundary() owner_cleanup_failed = False async with self._model_call_bind_lock: @@ -1464,10 +1521,14 @@ async def prepare_model_call_runtime(self) -> None: async def _ensure_session_graph_prepared( self, ) -> SessionModelCallCapabilityConsumer: + if not self.execution_available: + raise RuntimeError("failed execution preparation requires Session retirement") consumer = self._model_call_consumer if consumer is not None: return consumer async with self._model_call_bind_lock: + if not self.execution_available: + raise RuntimeError("failed execution preparation requires Session retirement") consumer = self._model_call_consumer if consumer is not None: return consumer @@ -1630,6 +1691,15 @@ async def _ensure_session_graph_prepared( commit_session_capability_owner_generations(owner_generations) self._commit_session_owner_generation_evidence() except BaseException as error: + if self._defer_output_capture_rollback(): + # Keep the original staged owners for disposal outside the + # bind lock. The Graph retains its own failed retirement. + self._capability_owner_generations = owner_generations + self._pending_capability_components = ( + *self._pending_capability_components, *prepared_components, + ) + error.add_note("Session rollback retained for output capture cleanup") + raise owner_cleanup_failed = False if owner_generations: try: diff --git a/src/loushang/harness/session/agent_product_runtime.py b/src/loushang/harness/session/agent_product_runtime.py index 6366fe201..0e8c53d6b 100644 --- a/src/loushang/harness/session/agent_product_runtime.py +++ b/src/loushang/harness/session/agent_product_runtime.py @@ -243,6 +243,8 @@ async def composed_before_release( persist=persist, create_transcript=transcript.create, restore_transcript=transcript.restore, + prepare_import_transcript=transcript.prepare_import, + mark_import_delivered=transcript.mark_import_delivered, fork_transcript=transcript.fork, dispose_transcript=transcript.dispose, transcript_for_session=lambda session: cast( diff --git a/src/loushang/harness/session/bash.py b/src/loushang/harness/session/bash.py index 803550e91..8ad939b32 100644 --- a/src/loushang/harness/session/bash.py +++ b/src/loushang/harness/session/bash.py @@ -18,6 +18,7 @@ from loushang.harness.artifacts import SessionBlobRef from loushang.harness.conversation import CommandExecutionRecord from loushang.harness.tools.core import ToolDefinition +from loushang.harness.tools.workspace.protocol import artifact_cleanup_diagnostic from loushang.harness.workspace.exec import ExecOutputChunk CommandOutputCallback = Callable[[ExecOutputChunk], Awaitable[None] | None] @@ -290,6 +291,9 @@ def bash_result_from_tool_result(tool_result: object) -> dict[str, object]: for key in ("stdout_blob", "stderr_blob", "artifact_retention_error"): if details.get(key) is not None: result[key] = details[key] + cleanup = artifact_cleanup_diagnostic(details) + if cleanup is not None: + result["artifact_cleanup_error"] = cleanup return result diff --git a/src/loushang/harness/session/lifecycle.py b/src/loushang/harness/session/lifecycle.py index 2bb37d332..96f98123c 100644 --- a/src/loushang/harness/session/lifecycle.py +++ b/src/loushang/harness/session/lifecycle.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import errno import inspect import os @@ -56,6 +57,14 @@ class SessionLifecycleTransition: metadata: Mapping[str, object] = field(default_factory=dict) +ImportCandidate = Callable[ + [SessionT | None, SessionLifecycleTransition], Awaitable[SessionT] +] +ImportCandidateFactory = Callable[ + [Path, Path, str | None, str | None], ImportCandidate[SessionT] | None +] + + @dataclass(frozen=True) class SessionLifecycleDecision: """A hook result that can cancel a pending lifecycle transition.""" @@ -104,7 +113,8 @@ def __init__( self._transition = transition self._previous = previous self._candidate = candidate - self._state: Literal["prepared", "consuming", "consumed", "closed"] = "prepared" + self._state: Literal["prepared", "consuming", "consumed", "aborting", "closed"] = "prepared" + self._abort_task: asyncio.Task[None] | None = None @property def consumed(self) -> bool: @@ -120,20 +130,55 @@ async def consume(self) -> SessionOperationResult[SessionT, PayloadT | None]: result = await self._runtime._consume_prepared( transition=self._transition, previous=self._previous, - candidate=self._candidate, + candidate=replace(self._candidate, rollback=self._rollback_unpublished), + on_delivered=self._mark_delivered, ) except BaseException: - self._state = "closed" + if self._state == "consuming": + self._state = "aborting" + if self._state == "aborting": + # The coordinator has released its transition lock. Product + # cleanup may acquire that lock from the retained abort task. + await self.abort() raise self._state = "consumed" return result + def _mark_delivered(self) -> None: + # Called before product activation, while the transition owns the slot. + # Delivery remains true even if a later hook replaces the current slot. + self._state = "consumed" + + async def _rollback_unpublished(self) -> None: + # Called under the coordinator's transition lock: retain responsibility + # in consuming (including during failure observers), then switch to + # aborting and join cleanup after _consume_prepared has unwound. + return None + async def abort(self) -> None: - if self._state != "prepared": + if self._state not in {"prepared", "aborting"}: return - self._state = "closed" + self._state = "aborting" + task = self._abort_task + if task is None or (task.done() and (task.cancelled() or task.exception() is not None)): + # Retain the cleanup independently of any individual close waiter. + # A failed attempt stays aborting and retries the same candidate. + task = asyncio.Task(self._abort_candidate(), loop=asyncio.get_running_loop()) + self._abort_task = task + task.add_done_callback(self._observe_abort) + await asyncio.shield(task) + + async def _abort_candidate(self) -> None: if self._candidate.rollback is not None: await _maybe_await(self._candidate.rollback()) + self._state = "closed" + + @staticmethod + def _observe_abort(task: asyncio.Task[None]) -> None: + # Retrieve failures even if every waiter was cancelled; the retained + # task still carries the error for the next explicit close/retry. + if not task.cancelled(): + task.exception() async def close(self) -> None: await self.abort() @@ -273,16 +318,20 @@ def __init__( fork_target_resolver: ForkTargetResolver[SessionT, PayloadT] | None = None, copy_file: FileCopy = copy_file_exclusive, verified_copy_file: VerifiedFileCopy | None = None, + prepare_import_candidate: ImportCandidateFactory[SessionT] | None = None, + mark_candidate_delivered: Callable[[SessionT], None] | None = None, ) -> None: if hooks.dispose_session is None: raise ValueError("Session lifecycle hooks require dispose_session.") self.store = store + self._mark_candidate_delivered = mark_candidate_delivered self.hooks = hooks self._dispose_session = hooks.dispose_session self.fork_profile = fork_profile self._fork_target_resolver = fork_target_resolver or _default_fork_target self._copy_file = copy_file self._verified_copy_file = verified_copy_file + self._prepare_import_candidate = prepare_import_candidate self._host = SessionTransitionHost( current_session, dispose=self._dispose_session, @@ -390,20 +439,26 @@ async def prepare_restore( target_session_ref=str(session_ref), metadata=metadata or {}, ) + return await self._prepare_candidate( + transition, + lambda previous: self._restore_candidate( + previous, transition=transition, session_ref=session_ref, + fallback_cwd=fallback_cwd, missing_cwd=missing_cwd, + ), + ) + async def _prepare_candidate( + self, + transition: SessionLifecycleTransition, + build: Callable[[SessionT | None], Awaitable[SessionT]], + ) -> PreparedSessionLifecycleOperation[SessionT, PayloadT]: async with self._host.transition(): previous = self._host.current if await self._transition_cancelled(previous, transition): raise SessionLifecyclePreparationCancelledError( "session lifecycle preparation was cancelled" ) - session = await self._restore_candidate( - previous, - transition=transition, - session_ref=session_ref, - fallback_cwd=fallback_cwd, - missing_cwd=missing_cwd, - ) + session = await build(previous) async def _rollback() -> None: await _maybe_await(self._dispose_session(session)) @@ -435,6 +490,17 @@ async def prepare_import_file( """Stage an external transcript as an abortable authority restore.""" source = Path(input_path).expanduser() + if self._prepare_import_candidate is not None: + build = self._prepare_import_candidate( + source, destination_dir, cwd_override, expected_source_fingerprint, + ) + if build is not None: + transition = SessionLifecycleTransition( + reason="resume", target_session_ref=str(source), metadata=metadata or {}, + ) + return await self._prepare_candidate( + transition, lambda previous: build(previous, transition), + ) staged = stage_file_import( source, destination_dir, @@ -453,13 +519,15 @@ async def prepare_import_file( staged.cleanup() raise original_rollback = prepared._candidate.rollback + original_rollback_complete = False async def rollback() -> None: - try: + nonlocal original_rollback_complete + if not original_rollback_complete: if original_rollback is not None: await _maybe_await(original_rollback()) - finally: - staged.cleanup() + original_rollback_complete = True + staged.cleanup() prepared._candidate = replace(prepared._candidate, rollback=rollback) return prepared @@ -511,6 +579,12 @@ async def import_file( target_session_ref=str(source), metadata=metadata or {}, ) + if self._prepare_import_candidate is not None: + build = self._prepare_import_candidate( + source, destination_dir, cwd_override, expected_source_fingerprint, + ) + if build is not None: + return await self._run(preflight, lambda current: build(current, preflight)) try: if not source.exists(): raise FileNotFoundError( @@ -633,6 +707,7 @@ async def _consume_prepared( transition: SessionLifecycleTransition, previous: SessionT | None, candidate: SessionOperationCandidate[SessionT, PayloadT | None], + on_delivered: Callable[[], None] | None = None, ) -> SessionOperationResult[SessionT, PayloadT | None]: async def _prepared( current: SessionT | None, @@ -645,7 +720,7 @@ async def _prepared( ) return candidate - return await self._coordinate(transition, _prepared) + return await self._coordinate(transition, _prepared, on_delivered=on_delivered) async def _coordinate( self, @@ -657,6 +732,8 @@ async def _coordinate( | CancelledSessionOperation[PayloadT | None] ], ], + *, + on_delivered: Callable[[], None] | None = None, ) -> SessionOperationResult[SessionT, PayloadT | None]: async def _after_commit( result: SessionOperationResult[SessionT, PayloadT | None], @@ -670,6 +747,18 @@ async def _on_failure(failure: SessionOperationFailure[SessionT]) -> None: prepare_session = self.hooks.prepare_session activate_session = self.hooks.activate_session + + async def _activate( + candidate: SessionOperationCandidate[SessionT, PayloadT | None], + previous: SessionT | None, + ) -> None: + if on_delivered is not None: + on_delivered() + if self._mark_candidate_delivered is not None: + self._mark_candidate_delivered(candidate.session) + if activate_session is not None: + await _maybe_await(activate_session(candidate.session, previous, transition)) + return await self._operations.run( prepare, prepare_session=( @@ -686,10 +775,9 @@ async def _on_failure(failure: SessionOperationFailure[SessionT]) -> None: ), activate=( None - if activate_session is None - else lambda candidate, previous: activate_session( - candidate.session, previous, transition - ) + if (activate_session is None and on_delivered is None + and self._mark_candidate_delivered is None) + else _activate ), after_commit=_after_commit, on_failure=_on_failure, diff --git a/src/loushang/harness/session/output_artifacts.py b/src/loushang/harness/session/output_artifacts.py index 099c73c3a..e47bf5001 100644 --- a/src/loushang/harness/session/output_artifacts.py +++ b/src/loushang/harness/session/output_artifacts.py @@ -4,9 +4,12 @@ import hashlib import time +from collections.abc import Callable +from contextlib import AbstractAsyncContextManager, AbstractContextManager, nullcontext from dataclasses import replace from pathlib import Path from tempfile import TemporaryDirectory +from typing import TYPE_CHECKING from loushang.foundation.platform_paths import resolve_platform_paths from loushang.harness.artifacts import ( @@ -16,14 +19,21 @@ prepare_private_artifact_directory, read_stable_artifact_source, resolve_session_blob_data_root, + session_blob_authority_id, ) from loushang.harness.workspace.exec import ( + ExecCaptureFactory, ExecRequest, ExecResult, ExecService, ExecUpdateCallback, ) +from ._output_capture import SessionOutputCapture + +if TYPE_CHECKING: + from loushang.harness.journal._rooted_io import RootedFileIO + class SessionOutputPersistingExecService(ExecService): """Capture full stream output into one durable Session authority. @@ -41,25 +51,62 @@ def __init__( session_dir: str | Path, session_id: str, temporary_root: str | Path | None = None, + file_io: RootedFileIO | None = None, + operation_scope: Callable[[], AbstractAsyncContextManager[None]] | None = None, + initialization_scope: Callable[[], AbstractContextManager[None]] | None = None, + capture_factory: ExecCaptureFactory | None = None, ) -> None: if isinstance(delegate, SessionOutputPersistingExecService): raise TypeError("session output persistence cannot wrap itself") + if file_io is not None and (operation_scope is None or initialization_scope is None): + raise ValueError("owned output persistence requires original operation scopes") super().__init__(execution_profile=getattr(delegate, "execution_profile", None)) self._delegate = delegate - self._store = SessionBlobStore( - resolve_session_blob_data_root(session_dir), - session_id, + self._capture_factory = capture_factory + captured = delegate.capture_executor() if capture_factory is not None else None + if capture_factory is not None and captured is None: + raise TypeError("managed output persistence requires captured execution") + self._file_io = file_io + self._operation_scope = nullcontext if operation_scope is None else operation_scope + with nullcontext() if initialization_scope is None else initialization_scope(): + self._store = SessionBlobStore( + Path(session_dir).parent if file_io is not None else resolve_session_blob_data_root(session_dir), + session_id, file_io=file_io, + ) + self._capture = ( + SessionOutputCapture(captured, capture_factory, self._store) + if captured is not None and capture_factory is not None else None ) - self._temporary_root = prepare_private_artifact_directory( + self._temporary_root = None if self._capture is not None else prepare_private_artifact_directory( temporary_root or resolve_platform_paths().temporary ) + def fence(self) -> None: + if self._capture is not None: + self._capture.fence() + + @property + def cleanup_pending(self) -> bool: + return self._capture is not None and self._capture.cleanup_pending + + async def close(self) -> None: + if self._capture is not None: + await self._capture.close() + async def execute( self, request: ExecRequest, *, signal: object | None = None, on_update: ExecUpdateCallback | None = None, + ) -> ExecResult: + async with self._operation_scope(): + if self._capture is not None: + return await self._capture.execute(request, signal=signal, on_update=on_update) + return await self._execute(request, signal=signal, on_update=on_update) + + async def _execute( + self, request: ExecRequest, *, signal: object | None, on_update: ExecUpdateCallback | None, ) -> ExecResult: with TemporaryDirectory( prefix="session-output-", @@ -167,9 +214,22 @@ def persist_session_command_outputs( session_id: str, persist: bool, temporary_root: str | Path | None = None, + file_io: RootedFileIO | None = None, + operation_scope: Callable[[], AbstractAsyncContextManager[None]] | None = None, + initialization_scope: Callable[[], AbstractContextManager[None]] | None = None, + capture_factory: ExecCaptureFactory | None = None, ) -> ExecService: """Bind output persistence only for durable Product sessions.""" + if capture_factory is not None and not persist: + raise ValueError("managed output persistence requires a durable Session") + if (isinstance(delegate, SessionOutputPersistingExecService) + and capture_factory is not None and delegate._capture_factory is not capture_factory): + raise ValueError("command output adapter belongs to another capture authority") + if (isinstance(delegate, SessionOutputPersistingExecService) and file_io is not None + and (delegate._file_io is not file_io + or delegate._store.session_id != session_blob_authority_id(session_id))): + raise ValueError("command output adapter belongs to another Session authority") if not persist or isinstance(delegate, SessionOutputPersistingExecService): return delegate return SessionOutputPersistingExecService( @@ -177,6 +237,8 @@ def persist_session_command_outputs( session_dir=session_dir, session_id=session_id, temporary_root=temporary_root, + file_io=file_io, operation_scope=operation_scope, initialization_scope=initialization_scope, + capture_factory=capture_factory, ) diff --git a/src/loushang/harness/session/product_runtime.py b/src/loushang/harness/session/product_runtime.py index 1d211ddc9..9f45c200d 100644 --- a/src/loushang/harness/session/product_runtime.py +++ b/src/loushang/harness/session/product_runtime.py @@ -31,7 +31,6 @@ MissingSessionCwdError, SessionLifecycleHooks, SessionLifecycleRuntime, - SessionLifecycleStore, SessionLifecycleTransition, resolve_fork_target, ) @@ -41,6 +40,7 @@ from loushang.harness.session.transcript_lifecycle import ( ProductTranscriptSessionLifecyclePorts, ProductTranscriptSessionLifecycleStore, + TranscriptImportFactory, require_session_operation_session, ) from loushang.harness.transcript import ( @@ -127,6 +127,8 @@ class ProductSessionRuntimePorts(Generic[SessionT, TranscriptT, PayloadT]): record_replacement_callback_failure: ReplacementFailureRecorder | None = None resolve_import_cwd: Callable[[str | Path], str] | None = None translate_missing_cwd_error: Callable[[MissingSessionCwdError], Exception] | None = None + prepare_import_transcript: TranscriptImportFactory[TranscriptT] | None = None + mark_import_delivered: Callable[[TranscriptT], None] | None = None class ProductSessionRuntime( @@ -155,7 +157,7 @@ def __init__( self._product_lifecycle_hooks = ports.hooks self.session_factory = ports.session_factory self.persist = ports.persist - lifecycle_store: SessionLifecycleStore[SessionT] = ( + lifecycle_store = ( ProductTranscriptSessionLifecycleStore( ports=ProductTranscriptSessionLifecyclePorts( create_transcript=ports.create_transcript, @@ -166,11 +168,14 @@ def __init__( transcript_cwd=ports.transcript_cwd, transcript_session_ref=ports.transcript_session_ref, transcript_leaf_entry_id=ports.transcript_leaf_entry_id, + prepare_import_transcript=ports.prepare_import_transcript, + mark_import_delivered=ports.mark_import_delivered, ), build_session=ports.build_session, validate_restored_transcript=ports.validate_restored_transcript, ) ) + self._transcript_construction_store = lifecycle_store lifecycle = SessionLifecycleRuntime( store=lifecycle_store, current_session=current_session, @@ -178,6 +183,8 @@ def __init__( fork_target_resolver=ports.fork_target_resolver, copy_file=ports.copy_file, verified_copy_file=ports.verified_copy_file, + prepare_import_candidate=lifecycle_store.prepare_import, + mark_candidate_delivered=lifecycle_store.mark_import_delivered, hooks=SessionLifecycleHooks( before_transition=ports.hooks.before_transition, prepare_session=ports.hooks.prepare_session, @@ -214,6 +221,25 @@ async def create_session( ) -> SessionT: return await self.new_session(cwd=cwd, parent_session=parent_session) + async def dispose_session_runtime( + self, *, metadata: dict[str, object] | None = None, + ) -> None: + self._transcript_construction_store.fence() + failure: BaseException | None = None + try: + await super().dispose_session_runtime(metadata=metadata) + except BaseException as error: + failure = error + try: + await self._transcript_construction_store.close() + except BaseException as error: + if failure is None: + failure = error + else: + failure.add_note(f"transcript construction cleanup retained: {type(error).__name__}") + if failure is not None: + raise failure + async def rename_session( self, session_id: str | Path, diff --git a/src/loushang/harness/session/session_capability_provider.py b/src/loushang/harness/session/session_capability_provider.py index 6c3ce7a14..314b74484 100644 --- a/src/loushang/harness/session/session_capability_provider.py +++ b/src/loushang/harness/session/session_capability_provider.py @@ -298,6 +298,7 @@ def session_capability_provider_binding( staged_side_question: LegacySideQuestionBinding, staged_transcript: AgentTranscriptCapabilityCandidate, bind_provider: Callable[[SideQuestionProviderFactory], SideQuestionProvider], + check_transcript_retirement: Callable[[], None] | None = None, provider_id: str = "harness.session.standard", source_id: str = "builtin", ) -> CapabilityBundleProviderBinding: @@ -415,6 +416,10 @@ def create(context: CapabilityProviderContext) -> CapabilityBundleValue: return value async def dispose(value: CapabilityBundleValue) -> None: + # Binding failure may reach this disposer before the Product's outer + # rollback. Never wait under Graph locks for an active output capture. + if check_transcript_retirement is not None: + check_transcript_retirement() facet = value.require(SIDE_QUESTION_FACET) if not isinstance(facet, _SideQuestionFacet): raise TypeError("Session Provider received an alien Bundle value") diff --git a/src/loushang/harness/session/transcript_lifecycle.py b/src/loushang/harness/session/transcript_lifecycle.py index fe45edd9a..cd899115b 100644 --- a/src/loushang/harness/session/transcript_lifecycle.py +++ b/src/loushang/harness/session/transcript_lifecycle.py @@ -8,8 +8,10 @@ from __future__ import annotations +import asyncio import errno -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Iterator +from contextlib import contextmanager from dataclasses import dataclass from inspect import isawaitable from pathlib import Path @@ -24,6 +26,7 @@ from loushang.harness.runtime import SessionOperationResult from loushang.harness.session.diagnostics import SessionDiagnosticsRuntime from loushang.harness.session.lifecycle import ( + ImportCandidate, MissingCwdPolicy, MissingSessionCwdError, PreparedSessionLifecycleOperation, @@ -59,6 +62,9 @@ SessionT | Awaitable[SessionT], ] TranscriptSessionValidator = Callable[[TranscriptSessionT], None | Awaitable[None]] +TranscriptImportFactory = Callable[ + [Path, Path, str | None, str | None], Callable[[], Awaitable[TranscriptSessionT]] | None +] @dataclass(frozen=True) @@ -92,6 +98,8 @@ class ProductTranscriptSessionLifecyclePorts(Generic[TranscriptSessionT, Session transcript_cwd: Callable[[TranscriptSessionT], str] transcript_session_ref: Callable[[TranscriptSessionT], str | None] transcript_leaf_entry_id: Callable[[TranscriptSessionT], str | None] + prepare_import_transcript: TranscriptImportFactory[TranscriptSessionT] | None = None + mark_import_delivered: Callable[[TranscriptSessionT], None] | None = None @dataclass(frozen=True) @@ -103,6 +111,33 @@ class ProductTranscriptSessionBinding(Generic[ProductTranscriptSessionT]): persist: bool resolve_cwd_override: Callable[[str | Path], str] + def prepare_import( + self, source: Path, destination: Path, cwd_override: str | None, fingerprint: str | None, + ) -> Callable[[], Awaitable[ProductTranscriptSessionT]] | None: + # Resolve a capability before any destination IO, without constructing + # an unawaited coroutine when the original transition hook cancels. + factory = self.session_type._factory_for_persistence(self.persist) + if not factory.owns_persistent_sessions: + return None + if destination.expanduser().absolute() != self.session_dir.expanduser().absolute(): + raise ValueError("owned import destination differs from the bound Session root") + + async def import_transcript() -> ProductTranscriptSessionT: + return await self.session_type.import_transcript( + source, session_dir=self.session_dir, + cwd_override=(self.resolve_cwd_override(cwd_override) + if cwd_override is not None else None), + expected_source_fingerprint=fingerprint, + _validate_cwd=lambda cwd: self._validate_cwd(cwd, str(source)), + _unpublished=True, + ) + + return import_transcript + + @staticmethod + def mark_import_delivered(transcript: ProductTranscriptSessionT) -> None: + transcript._mark_import_delivered() + async def create( self, cwd: str, @@ -159,6 +194,8 @@ async def fork( @staticmethod async def dispose(transcript: ProductTranscriptSessionT) -> None: await transcript.dispose_runtime_profile() + if not transcript.runtime_disposed: + raise RuntimeError("transcript runtime cleanup remains pending") async def rename( self, @@ -180,14 +217,20 @@ async def delete( @staticmethod def validate_available_cwd(transcript: ProductTranscriptSessionT) -> None: session_cwd = transcript.get_cwd() + session_file = transcript.get_session_file() + ProductTranscriptSessionBinding._validate_cwd( + session_cwd, str(session_file) if session_file is not None else None, + ) + + @staticmethod + def _validate_cwd(session_cwd: str, session_ref: str | None) -> None: candidate = Path(session_cwd).expanduser() if candidate.exists() and candidate.is_dir(): return - session_file = transcript.get_session_file() raise MissingSessionCwdError( SessionCwdIssue( session_cwd=session_cwd, - session_ref=str(session_file) if session_file is not None else None, + session_ref=session_ref, ) ) @@ -213,6 +256,58 @@ def __init__( self._build_session = build_session self._validate_restored_transcript = validate_restored_transcript self._transcripts_by_session_id: dict[int, TranscriptSessionT] = {} + self._pending_transcripts: dict[int, TranscriptSessionT] = {} + self._closing = False + self._active = 0 + self._idle = asyncio.Event() + self._idle.set() + self._cleanup_lock = asyncio.Lock() + + @property + def pending_transcripts(self) -> tuple[TranscriptSessionT, ...]: + return tuple(self._pending_transcripts.values()) + + def fence(self) -> None: + self._closing = True + + @contextmanager + def _admission(self) -> Iterator[None]: + if self._closing: + raise RuntimeError("transcript lifecycle store is closed") + self._active += 1 + self._idle.clear() + try: + yield + finally: + self._active -= 1 + if not self._active: + self._idle.set() + + async def _discard(self, transcript: TranscriptSessionT) -> None: + async with self._cleanup_lock: + if id(transcript) not in self._pending_transcripts: + return + await _maybe_await(self._ports.dispose_transcript(transcript)) + self._pending_transcripts.pop(id(transcript)) + + async def _discard_failed(self, transcript: TranscriptSessionT, error: BaseException) -> None: + try: + await self._discard(transcript) + except BaseException as cleanup: + error.add_note(f"transcript lifecycle cleanup retained: {type(cleanup).__name__}") + + async def close(self) -> None: + """Join admitted construction and settle only undelivered transcripts.""" + self.fence() + await self._idle.wait() + failures: list[Exception] = [] + for transcript in self.pending_transcripts: + try: + await self._discard(transcript) + except Exception as error: + failures.append(error) + if failures: + raise failures[0] async def create( self, @@ -222,10 +317,35 @@ async def create( cwd: str, parent_session_ref: str | None, ) -> SessionT: - transcript = await _maybe_await( - self._ports.create_transcript(cwd, parent_session_ref) - ) - return await self._build_or_dispose(transcript, current_session, transition) + with self._admission(): + transcript = await _maybe_await( + self._ports.create_transcript(cwd, parent_session_ref) + ) + return await self._build_or_dispose(transcript, current_session, transition) + + def prepare_import( + self, source: Path, destination: Path, cwd_override: str | None, fingerprint: str | None, + ) -> ImportCandidate[SessionT] | None: + factory = self._ports.prepare_import_transcript + if factory is None: + return None + operation = factory(source, destination, cwd_override, fingerprint) + if operation is None: + return None + + async def build(current: SessionT | None, transition: SessionLifecycleTransition) -> SessionT: + with self._admission(): + transcript = await operation() + self._pending_transcripts[id(transcript)] = transcript + try: + if self._validate_restored_transcript is not None: + await _maybe_await(self._validate_restored_transcript(transcript)) + except BaseException as error: + await self._discard_failed(transcript, error) + raise + return await self._build_or_dispose(transcript, current, transition) + + return build async def restore( self, @@ -235,16 +355,18 @@ async def restore( *, cwd_override: str | None = None, ) -> SessionT: - transcript = await _maybe_await( - self._ports.restore_transcript(session_ref, cwd_override) - ) - try: - if self._validate_restored_transcript is not None: - await _maybe_await(self._validate_restored_transcript(transcript)) - except BaseException: - await _maybe_await(self._ports.dispose_transcript(transcript)) - raise - return await self._build_or_dispose(transcript, current_session, transition) + with self._admission(): + transcript = await _maybe_await( + self._ports.restore_transcript(session_ref, cwd_override) + ) + self._pending_transcripts[id(transcript)] = transcript + try: + if self._validate_restored_transcript is not None: + await _maybe_await(self._validate_restored_transcript(transcript)) + except BaseException as error: + await self._discard_failed(transcript, error) + raise + return await self._build_or_dispose(transcript, current_session, transition) async def fork( self, @@ -252,16 +374,21 @@ async def fork( transition: SessionLifecycleTransition, target_entry_id: str | None, ) -> SessionT: - transcript = await _maybe_await( - self._ports.fork_transcript( - self._transcript_for_session(session), target_entry_id + with self._admission(): + transcript = await _maybe_await( + self._ports.fork_transcript( + self._transcript_for_session(session), target_entry_id + ) ) - ) - return await self._build_or_dispose(transcript, session, transition) + return await self._build_or_dispose(transcript, session, transition) def get_cwd(self, session: SessionT) -> str: return self._ports.transcript_cwd(self._transcript_for_session(session)) + def mark_import_delivered(self, session: SessionT) -> None: + if self._ports.mark_import_delivered is not None: + self._ports.mark_import_delivered(self._transcript_for_session(session)) + def get_session_ref(self, session: SessionT) -> str | None: return self._ports.transcript_session_ref(self._transcript_for_session(session)) @@ -276,14 +403,18 @@ async def _build_or_dispose( current_session: SessionT | None, transition: SessionLifecycleTransition, ) -> SessionT: + self._pending_transcripts[id(transcript)] = transcript try: + if self._closing: + raise RuntimeError("transcript lifecycle store is closed") session = await _maybe_await( self._build_session(transcript, current_session, transition) ) - except BaseException: - await _maybe_await(self._ports.dispose_transcript(transcript)) + except BaseException as error: + await self._discard_failed(transcript, error) raise self._transcripts_by_session_id[id(session)] = transcript + self._pending_transcripts.pop(id(transcript)) return session def _transcript_for_session(self, session: SessionT) -> TranscriptSessionT: diff --git a/tests/harness/runtime/test_prepared_profile.py b/tests/harness/runtime/test_prepared_profile.py new file mode 100644 index 000000000..5d55bbca4 --- /dev/null +++ b/tests/harness/runtime/test_prepared_profile.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from loushang.harness.runtime import ( + ProductRuntimePlan, + RuntimeCapabilityBindingError, + RuntimeCapabilityImplementation, + RuntimeCapabilityRegistry, + RuntimeCapabilitySelection, + RuntimeCapabilitySlot, + RuntimeProfileBinder, + RuntimeProfileResolver, +) + + +def setup(create, dispose): + selections = tuple( + RuntimeCapabilitySelection(slot=slot, implementation=name, implementation_version=1) + for slot, name in (("first", "one"), ("first", "two"), ("last", "three")) + ) + profile = RuntimeProfileResolver().resolve(ProductRuntimePlan( + product_id="test", + slots=tuple(RuntimeCapabilitySlot( + key=key, shape="ordered", scope="session", refresh_boundary="turn", + allowed_sources=frozenset({"product"}), + variation_semantic="aggregate_contribution", + ) for key in ("first", "last")), + defaults=selections, + )) + binder = RuntimeProfileBinder(RuntimeCapabilityRegistry( + RuntimeCapabilityImplementation( + slot=s.slot, implementation=s.implementation, implementation_version=1, + create=create, dispose=dispose, + ) for s in selections + )) + return binder, profile + + +def test_disposal_publication_failure_keeps_entries_for_retry(monkeypatch): + async def scenario(): + disposed = [] + binder, profile = setup(lambda s, _: s.implementation, lambda value, _: disposed.append(value)) + binding = await binder.bind(profile) + loop = asyncio.get_running_loop() + + def reject_task(_loop, _coro, **kwargs): + raise RuntimeError("test task publication") + + loop.set_task_factory(reject_task) + try: + with pytest.raises(RuntimeError, match="test task publication"): + await binder.dispose(binding) + finally: + loop.set_task_factory(None) + assert disposed == [] + await binder.dispose(binding) + assert disposed == ["three", "two", "one"] + asyncio.run(scenario()) + + +@pytest.mark.parametrize("cancel", [False, True]) +def test_partial_same_slot_failure_retains_earlier_value(cancel): + async def scenario(): + created, disposed = [], [] + + async def create(selection, _): + name = selection.implementation + created.append(name) + if name == "two": + if cancel: + raise asyncio.CancelledError() + raise ValueError("second factory") + return name + + binder, profile = setup(create, lambda value, _: disposed.append(value)) + binding = binder.prepare_binding(profile) + with pytest.raises(RuntimeError, match="not ready"): + binding.values() + if cancel: + with pytest.raises(asyncio.CancelledError): + await binder.bind_prepared(binding) + else: + with pytest.raises(RuntimeCapabilityBindingError) as raised: + await binder.bind_prepared(binding) + assert raised.value.slot == "first" + assert raised.value.implementation == "two" + assert raised.value.implementation_version == 1 + assert isinstance(raised.value.__cause__, ValueError) + assert created == ["one", "two"] and disposed == [] + with pytest.raises(RuntimeError, match="not ready"): + binding.capture() + with pytest.raises(RuntimeError): + await binder.rebind(binding, profile) + with pytest.raises(RuntimeError): + await binder.bind_prepared(binding) + await binder.dispose(binding) + assert disposed == ["one"] + asyncio.run(scenario()) + + +def test_partial_disposal_retries_only_failed_entries_in_reverse_order(): + async def scenario(): + disposed = [] + + def dispose(value, _): + disposed.append(value) + if value == "two" and disposed.count(value) == 1: + raise RuntimeError("cleanup once") + + binder, profile = setup(lambda s, _: s.implementation, dispose) + binding = binder.prepare_binding(profile) + assert await binder.bind_prepared(binding) is binding + assert binding.value("first") == ("one", "two") + with pytest.raises(RuntimeCapabilityBindingError): + await binder.dispose(binding) + assert disposed == ["three", "two", "one"] + await binder.dispose(binding) + assert disposed == ["three", "two", "one", "two"] + asyncio.run(scenario()) + + +def test_prepared_close_has_no_factory_effects_and_disallows_sync_dispose(): + async def scenario(): + created = [] + binder, profile = setup(lambda s, _: created.append(s.implementation), lambda *_: None) + binding = binder.prepare_binding(profile) + with pytest.raises(RuntimeError, match="asynchronous"): + binder.dispose_sync(binding) + assert not binding.is_closed + await binder.dispose(binding) + with pytest.raises(RuntimeError): + await binder.bind_prepared(binding) + assert created == [] + asyncio.run(scenario()) + + +def test_building_rejects_dispose_reentry_and_wrong_binder(): + async def scenario(): + entered, release = asyncio.Event(), asyncio.Event() + + async def create(selection, _): + entered.set() + await release.wait() + return selection.implementation + + binder, profile = setup(create, lambda *_: None) + other, _ = setup(create, lambda *_: None) + binding = binder.prepare_binding(profile) + with pytest.raises(RuntimeError): + await other.bind_prepared(binding) + task = asyncio.create_task(binder.bind_prepared(binding)) + await asyncio.wait_for(entered.wait(), 5) + with pytest.raises(RuntimeError, match="building"): + await binder.dispose(binding) + assert not binding.is_closed + with pytest.raises(RuntimeError): + await binder.bind_prepared(binding) + with pytest.raises(RuntimeError, match="not ready"): + binding.values() + release.set() + assert await task is binding + with pytest.raises(RuntimeError, match="asynchronous"): + binder.dispose_sync(binding) + assert not binding.is_closed + await binder.dispose(binding) + asyncio.run(scenario()) diff --git a/tests/harness/session/test_lifecycle.py b/tests/harness/session/test_lifecycle.py index 581856f46..259f3d608 100644 --- a/tests/harness/session/test_lifecycle.py +++ b/tests/harness/session/test_lifecycle.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path import pytest @@ -85,6 +85,232 @@ def get_leaf_entry_id(self, session: _Session) -> str: return session.leaf_id +def test_prepared_abort_retries_failed_original_cleanup(tmp_path: Path) -> None: + async def scenario() -> None: + calls = 0 + + async def dispose(session: _Session) -> None: + nonlocal calls + assert session.ref == "saved.jsonl" + calls += 1 + if calls == 1: + raise RuntimeError("cleanup failed") + + lifecycle = SessionLifecycleRuntime[_Session, str]( + store=_Store(restored_cwd=str(tmp_path)), + hooks=SessionLifecycleHooks(dispose_session=dispose), + ) + prepared = await lifecycle.prepare_restore("saved.jsonl") + with pytest.raises(RuntimeError, match="cleanup failed"): + await prepared.abort() + await prepared.close() + await prepared.close() + assert calls == 2 + + asyncio.run(scenario()) + + +def test_prepared_abort_cancelled_waiter_rejoins_cleanup(tmp_path: Path) -> None: + async def scenario() -> None: + entered = asyncio.Event() + release = asyncio.Event() + calls = 0 + finished = False + + async def dispose(_session: _Session) -> None: + nonlocal calls, finished + calls += 1 + entered.set() + await release.wait() + finished = True + + lifecycle = SessionLifecycleRuntime[_Session, str]( + store=_Store(restored_cwd=str(tmp_path)), + hooks=SessionLifecycleHooks(dispose_session=dispose), + ) + prepared = await lifecycle.prepare_restore("saved.jsonl") + waiter = asyncio.create_task(prepared.abort()) + await entered.wait() + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + release.set() + await asyncio.gather(prepared.close(), prepared.close()) + assert finished + assert calls == 1 + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("close_on_failure", [False, True]) +def test_prepared_consume_cleanup_can_acquire_transition_lock( + tmp_path: Path, close_on_failure: bool, +) -> None: + async def scenario() -> None: + cleaned = False + + def reject(*_args) -> None: + raise RuntimeError("replacement failed") + + async def dispose(_session: _Session) -> None: + nonlocal cleaned + async with lifecycle.transition_host.transition(): + cleaned = True + + async def failure(*_args) -> None: + if close_on_failure: + await prepared.close() + + lifecycle = SessionLifecycleRuntime[_Session, str]( + store=_Store(restored_cwd=str(tmp_path)), + hooks=SessionLifecycleHooks( + dispose_session=dispose, prepare_session=reject, on_failure=failure, + ), + ) + prepared = await lifecycle.prepare_restore("saved.jsonl") + with pytest.raises(RuntimeError, match="replacement failed"): + await asyncio.wait_for(prepared.consume(), timeout=2) + assert cleaned + + asyncio.run(scenario()) + + +def test_stale_prepared_cleanup_runs_after_transition_unlock(tmp_path: Path) -> None: + async def scenario() -> None: + disposed: list[str] = [] + + async def dispose(session: _Session) -> None: + async with lifecycle.transition_host.transition(): + disposed.append(session.ref) + + async def failure(*_args) -> None: + await prepared.close() + + lifecycle = SessionLifecycleRuntime[_Session, str]( + store=_Store(restored_cwd=str(tmp_path)), + hooks=SessionLifecycleHooks(dispose_session=dispose, on_failure=failure), + ) + prepared = await lifecycle.prepare_restore("saved.jsonl") + replacement = _Session("replacement", str(tmp_path)) + await lifecycle.replace(replacement) + with pytest.raises(PreparedSessionOperationStateError, match="active session changed"): + await asyncio.wait_for(prepared.consume(), timeout=2) + assert lifecycle.current_session is replacement + assert disposed == ["saved.jsonl"] + await prepared.close() + assert disposed == ["saved.jsonl"] + + asyncio.run(scenario()) + + +def test_prepared_consume_failure_keeps_cleanup_retry(tmp_path: Path) -> None: + async def scenario() -> None: + calls = 0 + + def reject(*_args) -> None: + raise RuntimeError("replacement failed") + + async def dispose(_session: _Session) -> None: + nonlocal calls + calls += 1 + if calls == 1: + raise RuntimeError("cleanup failed") + + lifecycle = SessionLifecycleRuntime[_Session, str]( + store=_Store(restored_cwd=str(tmp_path)), + hooks=SessionLifecycleHooks(dispose_session=dispose, prepare_session=reject), + ) + prepared = await lifecycle.prepare_restore("saved.jsonl") + with pytest.raises(RuntimeError, match="cleanup failed"): + await prepared.consume() + await prepared.close() + await prepared.close() + assert calls == 2 + assert not prepared.consumed + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("phase", ["activate_session", "after_commit"]) +def test_prepared_delivered_hook_failure_never_rolls_back(tmp_path: Path, phase: str) -> None: + async def scenario() -> None: + disposed: list[str] = [] + + def reject(*_args) -> None: + raise RuntimeError("delivered hook failed") + + hooks = SessionLifecycleHooks(dispose_session=lambda session: disposed.append(session.ref)) + hooks = replace(hooks, **{phase: reject}) + lifecycle = SessionLifecycleRuntime[_Session, str]( + store=_Store(restored_cwd=str(tmp_path)), hooks=hooks, + ) + prepared = await lifecycle.prepare_restore("saved.jsonl") + with pytest.raises(RuntimeError, match="delivered hook failed"): + await prepared.consume() + assert prepared.consumed + await prepared.close() + assert disposed == [] + assert lifecycle.current_session is not None + assert lifecycle.current_session.ref == "saved.jsonl" + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("prepared_mode", [False, True]) +def test_delivery_marker_precedes_product_activation(tmp_path: Path, prepared_mode: bool) -> None: + async def scenario() -> None: + events: list[str] = [] + lifecycle = SessionLifecycleRuntime[_Session, str]( + store=_Store(restored_cwd=str(tmp_path)), + hooks=SessionLifecycleHooks( + dispose_session=lambda _session: None, + activate_session=lambda *_args: events.append("activate"), + ), + mark_candidate_delivered=lambda session: events.append(f"delivered:{session.ref}"), + ) + if prepared_mode: + prepared = await lifecycle.prepare_restore("saved.jsonl") + assert events == [] + await prepared.consume() + else: + await lifecycle.restore("saved.jsonl") + assert events == ["delivered:saved.jsonl", "activate"] + + asyncio.run(scenario()) + + +def test_prepared_legacy_import_retains_file_until_cleanup_settles(tmp_path: Path) -> None: + async def scenario() -> None: + source = tmp_path / "source.jsonl" + source.write_text("original transcript", encoding="utf-8") + destination = tmp_path / "imported" + attempts = 0 + + async def dispose(session: _Session) -> None: + nonlocal attempts + assert Path(session.ref).read_text(encoding="utf-8") == "original transcript" + attempts += 1 + if attempts == 1: + raise RuntimeError("cleanup failed") + + lifecycle = SessionLifecycleRuntime[_Session, str]( + store=_Store(restored_cwd=str(tmp_path)), + hooks=SessionLifecycleHooks(dispose_session=dispose), + ) + prepared = await lifecycle.prepare_import_file(source, destination_dir=destination) + imported = destination / source.name + assert imported.exists() + with pytest.raises(RuntimeError, match="cleanup failed"): + await prepared.abort() + assert imported.read_bytes() == source.read_bytes() + await prepared.close() + assert not imported.exists() + assert source.read_text(encoding="utf-8") == "original transcript" + assert attempts == 2 + + asyncio.run(scenario()) + + def test_lifecycle_default_profile_forks_at_selected_entry( tmp_path: Path, ) -> None: diff --git a/tests/harness/session/test_output_capture.py b/tests/harness/session/test_output_capture.py new file mode 100644 index 000000000..5d8d3efbc --- /dev/null +++ b/tests/harness/session/test_output_capture.py @@ -0,0 +1,387 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from loushang.harness.session.output_artifacts import ( + SessionOutputPersistingExecService, + persist_session_command_outputs, +) +from loushang.harness.workspace.exec import ( + CapturePreparation, + ExecRequest, + ExecResult, + ExecService, + SealedExecCapture, +) + + +class Source: + size_bytes = 4 + + async def read_bytes(self, *, max_bytes): + assert max_bytes >= 4 + return b"data" + + +class Lease: + def __init__(self, *, fail_close=False, preparation=CapturePreparation.READY): + self.cleanup_pending = True + self.fail_close = fail_close + self.preparation = preparation + self.closes = 0 + + async def prepare(self): + return self.preparation + + async def append(self, chunk): + pass + + def stop_accepting(self): + pass + + async def seal(self): + return SealedExecCapture(Source(), Source()) + + async def close(self): + self.closes += 1 + if self.fail_close: + raise OSError("private path must not escape") + self.cleanup_pending = False + + +class Factory: + def __init__(self, lease): + self.lease = lease + self.calls = 0 + + def new_capture(self): + self.calls += 1 + return self.lease + + +class Backend: + def __init__(self, result=None): + self.result = result or ExecResult(exit_code=7, stdout_truncated=True) + self.calls = 0 + + async def __call__(self, request, *, signal=None, on_update=None): + raise AssertionError("legacy execution must not run") + + async def execute_captured(self, request, *, capture, signal=None, on_update=None): + self.calls += 1 + assert not request.retain_output_artifacts + assert not request.capture_full_output + return self.result + + +def adapter(tmp_path, lease, backend=None): + return SessionOutputPersistingExecService( + ExecService(backend=backend or Backend()), session_dir=tmp_path / "sessions", + session_id="test", temporary_root=tmp_path / "unused-scratch", + capture_factory=Factory(lease), + ) + + +def test_published_refs_survive_cleanup_failure_then_close_retries(tmp_path: Path): + async def run(): + lease = Lease(fail_close=True) + service = adapter(tmp_path, lease) + result = await service.execute(ExecRequest(("unused",))) + assert result.exit_code == 7 + assert result.stdout_artifact_ref is not None + assert result.stderr_artifact_ref is not None + assert result.artifact_retention_error is None + assert result.artifact_cleanup_error == "temporary_cleanup_pending" + assert not (tmp_path / "unused-scratch").exists() + lease.fail_close = False + await service.close() + await service.close() + assert lease.closes == 2 + with pytest.raises(RuntimeError, match="closing"): + await service.execute(ExecRequest(("unused",))) + asyncio.run(run()) + + +@pytest.mark.parametrize("result", [ + ExecResult(exit_code=7, cancelled=True), + ExecResult(exit_code=7, stdio_complete=False, stdio_drain_reason="hard_timeout"), +]) +def test_ineligible_output_is_not_published(tmp_path: Path, result): + async def run(): + service = adapter(tmp_path, Lease(), Backend(result)) + actual = await service.execute(ExecRequest(("unused",))) + assert actual.stdout_artifact_ref is None + assert actual.stderr_artifact_ref is None + assert actual.artifact_retention_error is not None + assert actual.cancelled == result.cancelled + await service.close() + asyncio.run(run()) + + +def test_late_prepare_does_not_launch_after_close(tmp_path: Path): + async def run(): + started, release = asyncio.Event(), asyncio.Event() + class SlowLease(Lease): + async def prepare(self): + started.set() + await release.wait() + return CapturePreparation.READY + backend = Backend() + service = adapter(tmp_path, SlowLease(), backend) + execution = asyncio.create_task(service.execute(ExecRequest(("unused",)))) + await started.wait() + closing = asyncio.create_task(service.close()) + await asyncio.sleep(0) + assert not closing.done() + release.set() + with pytest.raises(RuntimeError, match="closing"): + await execution + await closing + assert backend.calls == 0 + asyncio.run(run()) + + +@pytest.mark.parametrize("managed", [False, True]) +def test_existing_adapter_cannot_change_capture_authority(tmp_path: Path, managed): + factory = Factory(Lease()) + service = SessionOutputPersistingExecService( + ExecService(backend=Backend()), session_dir=tmp_path / "sessions", + session_id="test", temporary_root=tmp_path / "scratch", + capture_factory=factory if managed else None, + ) + with pytest.raises(ValueError, match="capture authority"): + persist_session_command_outputs( + service, session_dir=tmp_path / "sessions", session_id="test", persist=True, + capture_factory=Factory(Lease()), + ) + if managed: + assert persist_session_command_outputs( + service, session_dir=tmp_path / "sessions", session_id="test", persist=True, + capture_factory=factory, + ) is service + + +def test_nonpersistent_capture_is_not_silently_ignored(tmp_path: Path): + with pytest.raises(ValueError, match="durable Session"): + persist_session_command_outputs( + ExecService(), session_dir=tmp_path / "sessions", session_id="test", + persist=False, capture_factory=Factory(Lease()), + ) + + +def test_capture_rejects_another_loop_before_factory(tmp_path: Path): + service = adapter(tmp_path, Lease()) + asyncio.run(service.execute(ExecRequest(("unused",)))) + with pytest.raises(RuntimeError, match="another event loop"): + asyncio.run(service.execute(ExecRequest(("unused",)))) + with pytest.raises(RuntimeError, match="another event loop"): + asyncio.run(service.close()) + + +def test_cleanup_debt_occupies_all_eight_slots(tmp_path: Path): + async def run(): + class FreshFactory: + calls = 0 + + def new_capture(self): + self.calls += 1 + return Lease(fail_close=True) + factory = FreshFactory() + service = SessionOutputPersistingExecService( + ExecService(backend=Backend()), session_dir=tmp_path / "sessions", + session_id="test", capture_factory=factory, + ) + for _ in range(8): + result = await service.execute(ExecRequest(("unused",))) + assert result.artifact_cleanup_error == "temporary_cleanup_pending" + with pytest.raises(RuntimeError, match="capacity exhausted"): + await service.execute(ExecRequest(("unused",))) + assert factory.calls == 8 + asyncio.run(run()) + + +@pytest.mark.parametrize("unknown", [False, True]) +def test_preparation_refusal_and_unknown_have_distinct_execution_effects(tmp_path: Path, unknown): + async def run(): + class PreparedLease(Lease): + async def prepare(self): + if unknown: + raise OSError("unknown original commit") + return CapturePreparation.RETENTION_UNAVAILABLE + backend = Backend() + service = adapter(tmp_path, PreparedLease(), backend) + if unknown: + with pytest.raises(OSError, match="unknown original commit"): + await service.execute(ExecRequest(("unused",))) + assert backend.calls == 0 + else: + result = await service.execute(ExecRequest(("unused",))) + assert backend.calls == 1 + assert result.stdout_artifact_ref is None + assert result.artifact_retention_error is not None + await service.close() + asyncio.run(run()) + + +@pytest.mark.parametrize("receipt_fails", [False, True]) +def test_late_cleanup_waiter_cannot_erase_next_phase(tmp_path: Path, monkeypatch, receipt_fails): + from loushang.harness.session import _output_capture as capture_module + + async def run(): + lease = Lease(fail_close=True) + service = adapter(tmp_path, lease) + await service.execute(ExecRequest(("unused",))) + owner = service._capture + assert owner is not None + pending = owner._pending[0] + lease.fail_close = False + first_release, second_release = asyncio.Event(), asyncio.Event() + second_started = asyncio.Event() + late_joined, late_release = asyncio.Event(), asyncio.Event() + original_join = capture_module._await_cancellation_atomic + late = None + + async def phased_close(): + lease.closes += 1 + if lease.closes == 2: + await first_release.wait() + # First successful phase still has native debt. + else: + lease.cleanup_pending = False + second_started.set() + await second_release.wait() + if receipt_fails: + raise OSError("cleanup receipt unknown") + + async def delayed_join(task): + result = await original_join(task) + if asyncio.current_task() is late: + late_joined.set() + await late_release.wait() + return result + + monkeypatch.setattr(lease, "close", phased_close) + monkeypatch.setattr(capture_module, "_await_cancellation_atomic", delayed_join) + first = asyncio.create_task(owner.close()) + late = asyncio.create_task(owner.close()) + await asyncio.sleep(0) + first_release.set() + await late_joined.wait() + with pytest.raises(RuntimeError, match="cleanup remains pending"): + await first + next_phase = asyncio.create_task(owner.close()) + await second_started.wait() + original_next = pending.cleanup + assert original_next is not None and not original_next.done() + late_release.set() + with pytest.raises(RuntimeError, match="cleanup remains pending"): + await late + assert pending.cleanup is original_next + assert pending in owner._pending + final_waiter = asyncio.create_task(owner.close()) + await asyncio.sleep(0) + assert pending.cleanup is original_next + second_release.set() + outcomes = await asyncio.gather(next_phase, final_waiter, return_exceptions=True) + if receipt_fails: + assert all(isinstance(value, OSError) for value in outcomes) + assert pending in owner._pending + else: + assert outcomes == [None, None] + assert pending not in owner._pending + assert lease.closes == 3 + asyncio.run(run()) + + +@pytest.mark.parametrize("fault", ["aggregate_limit", "changed_length", "publication"]) +def test_publication_failure_and_cleanup_failure_remain_independent(tmp_path: Path, monkeypatch, fault): + from loushang.harness.session import _output_capture as capture_module + + async def run(): + class FaultySource(Source): + async def read_bytes(self, *, max_bytes): + if fault == "aggregate_limit": + raise AssertionError("limit must be checked before reads") + return b"changed" if fault == "changed_length" else b"data" + + class FaultyLease(Lease): + async def seal(self): + return SealedExecCapture(FaultySource(), FaultySource()) + + service = adapter(tmp_path, FaultyLease(fail_close=True)) + imports = [] + + def failed_import(values): + imports.append(values) + raise OSError("publication outcome unknown") + + monkeypatch.setattr(service._store, "import_blobs", failed_import) + if fault == "aggregate_limit": + monkeypatch.setattr(capture_module, "MAX_PUBLICATION_BYTES", 7) + result = await service.execute(ExecRequest(("unused",))) + assert result.exit_code == 7 + assert result.stdout_artifact_ref is None + assert result.stderr_artifact_ref is None + assert result.artifact_retention_error == "command output was not retained" + assert result.artifact_cleanup_error == "temporary_cleanup_pending" + assert len(imports) == (1 if fault == "publication" else 0) + assert len(service._capture._pending) == 1 + asyncio.run(run()) + + +def test_complete_timeout_keeps_real_status_and_publishes(tmp_path: Path): + async def run(): + service = adapter(tmp_path, Lease(), Backend(ExecResult(exit_code=-15, timed_out=True))) + result = await service.execute(ExecRequest(("unused",))) + assert result.timed_out + assert result.exit_code == -15 + assert result.stdout_artifact_ref is not None + await service.close() + asyncio.run(run()) + + +def test_repeated_cancel_joins_original_cleanup_without_publication(tmp_path: Path, monkeypatch): + async def run(): + reading, cleanup_started, cleanup_release = (asyncio.Event() for _ in range(3)) + + class WaitingSource(Source): + async def read_bytes(self, *, max_bytes): + reading.set() + await asyncio.Event().wait() + + class WaitingLease(Lease): + async def seal(self): + return SealedExecCapture(WaitingSource(), Source()) + + async def close(self): + self.closes += 1 + cleanup_started.set() + await cleanup_release.wait() + self.cleanup_pending = False + + lease = WaitingLease() + service = adapter(tmp_path, lease) + + def no_import(values): + raise AssertionError("cancelled read must not publish") + + monkeypatch.setattr(service._store, "import_blobs", no_import) + execution = asyncio.create_task(service.execute(ExecRequest(("unused",)))) + await reading.wait() + execution.cancel() + await cleanup_started.wait() + execution.cancel() + closing = asyncio.create_task(service.close()) + await asyncio.sleep(0) + assert not execution.done() + assert not closing.done() + cleanup_release.set() + with pytest.raises(asyncio.CancelledError): + await execution + await closing + assert lease.closes == 1 + assert not service._capture._pending + asyncio.run(run()) diff --git a/tests/harness/session/test_output_capture_shutdown.py b/tests/harness/session/test_output_capture_shutdown.py new file mode 100644 index 000000000..472742d8b --- /dev/null +++ b/tests/harness/session/test_output_capture_shutdown.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +import pytest + +from loushang.harness.session.agent_product import AgentProductSession +from loushang.harness.session.output_artifacts import SessionOutputPersistingExecService + + +class OutputOwner(SessionOutputPersistingExecService): + def __init__(self, *, fail=False): + self.fail = fail + self.calls = 0 + self.fenced = False + + def fence(self): + self.fenced = True + + async def close(self): + assert self.fenced + self.calls += 1 + if self.fail: + raise OSError("original cleanup debt") + + +def test_product_shutdown_closes_shared_output_adapter_only_once(): + async def run(): + owner = OutputOwner() + product = SimpleNamespace(_exec_service=owner, _tool_exec_service=owner) + await AgentProductSession._close_output_captures(product) + assert owner.calls == 1 + asyncio.run(run()) + + +def test_product_shutdown_tries_both_original_owners_and_can_retry(): + async def run(): + command, tool = OutputOwner(fail=True), OutputOwner() + product = SimpleNamespace(_exec_service=command, _tool_exec_service=tool) + with pytest.raises(OSError, match="original cleanup debt"): + await AgentProductSession._close_output_captures(product) + assert command.calls == tool.calls == 1 + assert product._exec_service is command + assert product._tool_exec_service is tool + command.fail = False + await AgentProductSession._close_output_captures(product) + assert command.calls == tool.calls == 2 + asyncio.run(run()) + + +def test_output_debt_stops_disposal_before_graph_or_transcript_access(): + async def run(): + calls = [] + + async def failed_close(): + calls.append("capture") + raise OSError("capture cleanup pending") + + async def base_dispose(): + raise AssertionError("writer must remain owned") + + # Deliberately has no graph, side-question or model-call state: capture + # must settle before disposal can even access those downstream owners. + product = SimpleNamespace( + _close_output_captures=failed_close, + _fence_output_captures=lambda: None, + _side_question_consumer=None, + ) + with pytest.raises(OSError, match="capture cleanup pending"): + await AgentProductSession._dispose_owned_model_call_runtime( + product, base_dispose=base_dispose, + ) + assert calls == ["capture"] + asyncio.run(run()) + + +def test_side_question_is_cancelled_before_capture_drain(): + async def run(): + calls = [] + stopped = asyncio.Event() + + async def cancel_and_wait(): + calls.append("cancel producer") + stopped.set() + + async def close_outputs(): + assert stopped.is_set(), "capture drain must not precede producer cancellation" + calls.append("capture drain") + raise OSError("stop before graph") + + async def base_dispose(): + raise AssertionError("not reached") + + product = SimpleNamespace( + _fence_output_captures=lambda: calls.append("fence"), + _side_question_consumer=SimpleNamespace(cancel_and_wait=cancel_and_wait), + _close_output_captures=close_outputs, + ) + with pytest.raises(OSError, match="stop before graph"): + await AgentProductSession._dispose_owned_model_call_runtime(product, base_dispose=base_dispose) + assert calls == ["fence", "cancel producer", "capture drain"] + asyncio.run(run()) + + +def test_queued_prepare_rechecks_retirement_after_lock_acquisition(): + async def run(): + product = object.__new__(AgentProductSession) + product._model_call_consumer = None + product._model_call_bind_lock = asyncio.Lock() + await product._model_call_bind_lock.acquire() + queued = asyncio.create_task(product._ensure_session_graph_prepared()) + await asyncio.sleep(0) + assert not queued.done() + # The predecessor failed before it obtained any Graph/components; the + # retirement latch, not those later fields, must reject the queued call. + product._execution_preparation_failed = True + product._model_call_bind_lock.release() + with pytest.raises(RuntimeError, match="requires Session retirement"): + await queued + asyncio.run(run()) diff --git a/tests/harness/session/test_owned_construction_cleanup.py b/tests/harness/session/test_owned_construction_cleanup.py new file mode 100644 index 000000000..294ccf0f0 --- /dev/null +++ b/tests/harness/session/test_owned_construction_cleanup.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import asyncio +import sys + +import pytest + +from loushang.harness.session import ( + ForkProfile, + ForkSelection, + ProductSessionRuntime, + ProductSessionRuntimePorts, + ProductTranscriptSessionBinding, + SessionLifecycleHooks, +) +from tests.harness.transcript.test_owned_product_delivery import product +from tests.harness.transcript.test_owned_session_factory import factory +from tests.harness.transcript.test_runtime_profile import _runtime +from tests.harness.transcript.test_writer_lease import busy, lease + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux owned construction") + + +@pytest.mark.parametrize("graph", [False, True]) +def test_actual_runtime_shutdown_retains_failed_product_construction(tmp_path, monkeypatch, graph): + async def scenario(): + profile = _runtime("coding") + selected, calls = factory(runtime=profile), [] + Bound = product(selected, calls) + binding = ProductTranscriptSessionBinding( + session_type=Bound, session_dir=tmp_path, persist=True, resolve_cwd_override=str, + ) + failure = ValueError("Product construction failed") + + def build(transcript, current, transition): + if graph: + transcript._lifecycle_session._begin_graph_construction() + transcript._lifecycle_session._commit_graph_ownership() + raise failure + + runtime = ProductSessionRuntime( + session_dir=tmp_path, + ports=ProductSessionRuntimePorts( + session_factory=lambda manager: manager, persist=True, + create_transcript=lambda cwd, parent: Bound.new(tmp_path, cwd, session_id="conversation-1"), + restore_transcript=binding.restore, fork_transcript=binding.fork, + dispose_transcript=binding.dispose, transcript_for_session=lambda value: value, + transcript_cwd=lambda value: value.cwd, transcript_session_ref=lambda value: str(value.session_file), + transcript_leaf_entry_id=lambda value: value.leaf_id, build_session=build, + validate_restored_transcript=None, + fork_profile=ForkProfile(default_position="at", supported_positions=frozenset({"at"})), + fork_target_resolver=lambda session, entry, position: ForkSelection(target_entry_id=entry), + hooks=SessionLifecycleHooks(dispose_session=binding.dispose), + ), + ) + + async def unavailable(value): + raise OSError("runtime disposal unavailable") + + try: + with monkeypatch.context() as patch: + if not graph: + patch.setattr(profile._binder, "dispose", unavailable) + with pytest.raises(ValueError) as result: + await runtime.create_session(cwd=str(tmp_path)) + assert result.value is failure and "cleanup retained" in failure.__notes__[0] + retained, = runtime._transcript_construction_store.pending_transcripts + assert retained._lifecycle_session is calls[0] + assert not retained.runtime_disposed and not selected.pending_preparations + busy(tmp_path, conversation="conversation-1") + with pytest.raises((OSError, RuntimeError)): + await runtime.dispose_session_runtime() + assert runtime._transcript_construction_store.pending_transcripts == (retained,) + busy(tmp_path, conversation="conversation-1") + if graph: + await calls[0]._dispose_graph_owned() + await runtime.dispose_session_runtime() + assert retained.runtime_disposed + assert not runtime._transcript_construction_store.pending_transcripts + reopened = lease(tmp_path, conversation="conversation-1") + try: + reopened.acquire() + finally: + reopened.close() + finally: + if graph and calls and calls[0].ownership_state == "graph_owned": + await calls[0]._dispose_graph_owned() + await runtime.dispose_session_runtime() + await selected.close() + + asyncio.run(scenario()) + + +def test_actual_runtime_close_waits_for_started_builder_and_disposes_late_session(tmp_path): + async def scenario(): + selected, calls = factory(runtime=_runtime("coding")), [] + Bound = product(selected, calls) + binding = ProductTranscriptSessionBinding( + session_type=Bound, session_dir=tmp_path, persist=True, resolve_cwd_override=str, + ) + entered, release, closing = asyncio.Event(), asyncio.Event(), asyncio.Event() + disposed = [] + + async def build(transcript, current, transition): + transcript._lifecycle_session._begin_graph_construction() + transcript._lifecycle_session._commit_graph_ownership() + entered.set() + await release.wait() + return transcript + + async def dispose(transcript): + await transcript._lifecycle_session._dispose_graph_owned() + disposed.append(transcript) + + runtime = ProductSessionRuntime( + session_dir=tmp_path, + ports=ProductSessionRuntimePorts( + session_factory=lambda manager: manager, persist=True, + create_transcript=lambda cwd, parent: Bound.new(tmp_path, cwd, session_id="conversation-1"), + restore_transcript=binding.restore, fork_transcript=binding.fork, + dispose_transcript=binding.dispose, transcript_for_session=lambda value: value, + transcript_cwd=lambda value: value.cwd, transcript_session_ref=lambda value: str(value.session_file), + transcript_leaf_entry_id=lambda value: value.leaf_id, build_session=build, + validate_restored_transcript=None, + fork_profile=ForkProfile(default_position="at", supported_positions=frozenset({"at"})), + fork_target_resolver=lambda session, entry, position: ForkSelection(target_entry_id=entry), + hooks=SessionLifecycleHooks(dispose_session=dispose), + ), + ) + + async def close(): + closing.set() + await runtime.dispose_session_runtime() + + creation = asyncio.create_task(runtime.create_session(cwd=str(tmp_path))) + shutdown = None + try: + await asyncio.wait_for(entered.wait(), 5) + shutdown = asyncio.create_task(close()) + await asyncio.wait_for(closing.wait(), 5) + assert not shutdown.done() + assert runtime._transcript_construction_store.pending_transcripts + busy(tmp_path, conversation="conversation-1") + release.set() + result = await asyncio.wait_for(creation, 5) + await asyncio.wait_for(shutdown, 5) + assert disposed == [result] and result.runtime_disposed + assert runtime.current_session is None + assert not runtime._transcript_construction_store.pending_transcripts + assert not selected.pending_preparations + reopened = lease(tmp_path, conversation="conversation-1") + try: + reopened.acquire() + finally: + reopened.close() + finally: + release.set() + await asyncio.gather(creation, *([shutdown] if shutdown else []), return_exceptions=True) + await runtime.dispose_session_runtime() + await selected.close() + + asyncio.run(scenario()) diff --git a/tests/harness/session/test_owned_output_artifacts.py b/tests/harness/session/test_owned_output_artifacts.py new file mode 100644 index 000000000..bdabb852f --- /dev/null +++ b/tests/harness/session/test_owned_output_artifacts.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import asyncio +import hashlib +import stat +import sys + +import pytest + +from loushang.harness.artifacts import SessionBlobStore +from loushang.harness.journal import _rooted_io as native +from loushang.harness.session.output_artifacts import persist_session_command_outputs +from loushang.harness.transcript.writer_lease import TranscriptWriterError +from loushang.harness.workspace.exec import ExecRequest + +from ..transcript.test_owned_session_factory import factory, new +from ..transcript.test_writer_blobs import blob_busy +from ..transcript.test_writer_lifecycle import assert_busy +from .test_output_artifacts import _CapturedOutputService + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux owned output IO") + + +def adapter(session, delegate, scratch): + return persist_session_command_outputs( + delegate, session_dir=session.context.session_dir, + session_id=session.context.header.conversation_id, persist=True, temporary_root=scratch, + file_io=session.blob_file_io, operation_scope=session.operation_scope, + initialization_scope=session.sync_operation_scope, + ) + + +def test_owned_output_initialization_publication_and_legacy_id_reuse(tmp_path): + async def scenario(): + selected = factory() + root = tmp_path / "sessions" + root.mkdir(mode=0o700) + session = await new(selected, root, "legacy:id") + try: + service = adapter(session, _CapturedOutputService(), tmp_path / "scratch") + assert service._store._file_io is session.blob_file_io + assert stat.S_IMODE((tmp_path / "session-assets").stat().st_mode) == 0o700 + assert adapter(session, service, tmp_path / "scratch") is service + result = await service.execute(ExecRequest(command=("unused",), cwd=str(tmp_path))) + assert result.artifact_retention_error is None + assert result.stdout_artifact_path is None + with session.sync_operation_scope(): + assert service._store.read_bytes(result.stdout_artifact_ref) == b"complete stdout\n" + assert not list((tmp_path / "scratch").iterdir()) + finally: + await session.dispose() + await selected.close() + with pytest.raises(TranscriptWriterError): + await service.execute(ExecRequest(command=("unused",), cwd=str(tmp_path))) + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("cancel", [False, True]) +def test_owned_output_execution_and_cancel_cleanup_hold_both_writers(tmp_path, cancel): + async def scenario(): + selected = factory() + root = tmp_path / "sessions" + root.mkdir(mode=0o700) + session = await new(selected, root) + entered, release, cleaning = asyncio.Event(), asyncio.Event(), asyncio.Event() + + class Delayed(_CapturedOutputService): + async def execute(self, request, *, signal=None, on_update=None): + entered.set() + try: + await release.wait() + return await super().execute(request, signal=signal, on_update=on_update) + finally: + cleaning.set() + await release.wait() + + service = adapter(session, Delayed(), tmp_path / "scratch") + task = asyncio.create_task(service.execute(ExecRequest(command=("unused",), cwd=str(tmp_path)))) + closer = None + try: + await asyncio.wait_for(entered.wait(), 5) + if cancel: + task.cancel() + await asyncio.wait_for(cleaning.wait(), 5) + closer = asyncio.create_task(session.dispose()) + await asyncio.sleep(0) + assert not closer.done() + assert_busy(root) + blob_busy(root) + release.set() + if cancel: + with pytest.raises(asyncio.CancelledError): + await task + else: + result = await task + assert result.artifact_retention_error is None + assert SessionBlobStore(tmp_path, "conversation-1").read_bytes(result.stdout_artifact_ref) == b"complete stdout\n" + await closer + finally: + release.set() + await asyncio.gather(task, *([closer] if closer else []), return_exceptions=True) + await session.dispose() + await selected.close() + + asyncio.run(scenario()) + + +def test_output_retention_error_does_not_discard_native_recovery_debt(tmp_path, monkeypatch): + async def scenario(): + selected = factory() + root = tmp_path / "sessions" + root.mkdir(mode=0o700) + session = await new(selected, root) + service = adapter(session, _CapturedOutputService(), tmp_path / "scratch") + original_stat, original_unlink = service._store._stat, native.os.unlink + digest = hashlib.sha256(b"complete stdout\n").hexdigest() + + def lost_receipt(path): + if path == service._store.root: + raise OSError("publication receipt unavailable") + return original_stat(path) + + def failed_cleanup(name, *args, **kwargs): + if name == digest: + raise OSError("output rollback unavailable") + return original_unlink(name, *args, **kwargs) + + try: + with monkeypatch.context() as patch: + patch.setattr(service._store, "_stat", lost_receipt) + patch.setattr(native.os, "unlink", failed_cleanup) + result = await service.execute(ExecRequest(command=("unused",), cwd=str(tmp_path))) + assert result.artifact_retention_error is not None and result.stdout_artifact_ref is None + assert session.blob_file_io.cleanup_pending + with pytest.raises(OSError): + await session.dispose() + assert_busy(root) + blob_busy(root) + await session.dispose() + assert not service._file_io.cleanup_pending + assert not service._store.root.exists() + finally: + await session.dispose() + await selected.close() + + asyncio.run(scenario()) diff --git a/tests/harness/session/test_transcript_construction_cleanup.py b/tests/harness/session/test_transcript_construction_cleanup.py new file mode 100644 index 000000000..558314bb4 --- /dev/null +++ b/tests/harness/session/test_transcript_construction_cleanup.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import asyncio +from dataclasses import replace + +import pytest + +from loushang.harness.session import ( + ProductTranscriptSessionLifecycleStore, + SessionLifecycleTransition, +) + +from .test_transcript_lifecycle import _ProductPorts, _Session + + +@pytest.mark.parametrize("operation", ["create", "restore", "fork", "validate"]) +def test_failed_construction_keeps_exact_transcript_and_primary_error(operation): + async def scenario(): + ports = _ProductPorts() + pending = [] + failed = True + primary = ValueError("construction failed") + + async def dispose(transcript): + pending.append(transcript) + if failed: + raise OSError("cleanup pending") + + def build(*args): + raise primary + + def validate(transcript): + raise primary + + store = ProductTranscriptSessionLifecycleStore( + ports=replace(ports.lifecycle_ports(), dispose_transcript=dispose), + build_session=build, + validate_restored_transcript=validate if operation == "validate" else None, + ) + transition = SessionLifecycleTransition(reason="new") + try: + with pytest.raises(ValueError) as result: + if operation == "create": + await store.create(None, transition, cwd="/project", parent_session_ref=None) + elif operation in {"restore", "validate"}: + await store.restore(None, transition, "saved.jsonl") + else: + await store.fork(_Session("source.jsonl", "/project"), transition, "leaf") + assert result.value is primary and "cleanup retained" in primary.__notes__[0] + retained, = store.pending_transcripts + assert pending == [retained] + with pytest.raises(OSError, match="cleanup pending"): + await store.close() + assert store.pending_transcripts == (retained,) + before = tuple(ports.actions) + with pytest.raises(RuntimeError, match="closed"): + await store.create(None, transition, cwd="/project", parent_session_ref=None) + assert tuple(ports.actions) == before + failed = False + await store.close() + assert not store.pending_transcripts and pending == [retained] * 3 + await store.close() + assert pending == [retained] * 3 + finally: + failed = False + await store.close() + + asyncio.run(scenario()) + + +def test_close_waits_for_late_transcript_then_disposes_without_building(): + async def scenario(): + ports = _ProductPorts() + entered, release = asyncio.Event(), asyncio.Event() + built = [] + + async def create(cwd, parent): + entered.set() + await release.wait() + return await ports.create(cwd, parent) + + store = ProductTranscriptSessionLifecycleStore( + ports=replace(ports.lifecycle_ports(), create_transcript=create), + build_session=lambda *args: built.append(args), + ) + caller = asyncio.create_task(store.create( + None, SessionLifecycleTransition(reason="new"), cwd="/project", parent_session_ref=None, + )) + closer = None + try: + await asyncio.wait_for(entered.wait(), 5) + closer = asyncio.create_task(store.close()) + await asyncio.sleep(0) + assert not closer.done() and not ports.disposed + release.set() + with pytest.raises(RuntimeError, match="closed"): + await caller + await asyncio.wait_for(closer, 5) + assert ports.disposed == ["new.jsonl"] and not built + assert not store.pending_transcripts + finally: + release.set() + await asyncio.gather(caller, *([closer] if closer else []), return_exceptions=True) + await store.close() + + asyncio.run(scenario()) + + +def test_cancelled_cleanup_waiter_keeps_original_transcript_for_retry(): + async def scenario(): + ports = _ProductPorts() + entered, release = asyncio.Event(), asyncio.Event() + count = 0 + + async def dispose(transcript): + nonlocal count + count += 1 + entered.set() + await release.wait() + + def build(*args): + raise ValueError("construction failed") + + store = ProductTranscriptSessionLifecycleStore( + ports=replace(ports.lifecycle_ports(), dispose_transcript=dispose), build_session=build, + ) + caller = asyncio.create_task(store.create( + None, SessionLifecycleTransition(reason="new"), cwd="/project", parent_session_ref=None, + )) + try: + await asyncio.wait_for(entered.wait(), 5) + caller.cancel() + with pytest.raises(ValueError) as result: + await caller + assert "CancelledError" in result.value.__notes__[0] + retained, = store.pending_transcripts + release.set() + await store.close() + assert not store.pending_transcripts and count == 2 + assert retained.ref == "new.jsonl" + finally: + release.set() + await asyncio.gather(caller, return_exceptions=True) + await store.close() + + asyncio.run(scenario()) + + +def test_close_attempts_independent_pending_transcripts_after_one_failure(): + async def scenario(): + ports = _ProductPorts() + failing = {"one.jsonl", "two.jsonl"} + disposed = [] + + async def dispose(transcript): + if transcript.ref in failing: + raise OSError("cleanup pending") + disposed.append(transcript.ref) + + def build(*args): + raise ValueError("construction failed") + + store = ProductTranscriptSessionLifecycleStore( + ports=replace(ports.lifecycle_ports(), dispose_transcript=dispose), build_session=build, + ) + transition = SessionLifecycleTransition(reason="restore") + try: + for name in ("one.jsonl", "two.jsonl"): + with pytest.raises(ValueError): + await store.restore(None, transition, name) + assert len(store.pending_transcripts) == 2 + failing.remove("two.jsonl") + with pytest.raises(OSError): + await store.close() + assert disposed == ["two.jsonl"] + assert [item.ref for item in store.pending_transcripts] == ["one.jsonl"] + failing.clear() + await store.close() + assert disposed == ["two.jsonl", "one.jsonl"] + finally: + failing.clear() + await store.close() + + asyncio.run(scenario()) diff --git a/tests/harness/session/test_transcript_lifecycle.py b/tests/harness/session/test_transcript_lifecycle.py index 6bd2da937..03a579a73 100644 --- a/tests/harness/session/test_transcript_lifecycle.py +++ b/tests/harness/session/test_transcript_lifecycle.py @@ -290,6 +290,7 @@ class BoundTranscript: def __init__(self, cwd: str, session_file: Path | None) -> None: self.cwd = cwd self.session_file = session_file + self.runtime_disposed = False @classmethod async def new( @@ -325,6 +326,7 @@ async def fork(self, leaf_id: str) -> BoundTranscript: async def dispose_runtime_profile(self) -> None: self.actions.append(("dispose", self.session_file)) + self.runtime_disposed = True def get_cwd(self) -> str: return self.cwd From 6393964442242e4e95a5b6063a77da502d38c847 Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 05:49:02 +0000 Subject: [PATCH 21/44] feat(coding): adopt owned session stores --- src/loushang/coding/bootstrap.py | 14 +- .../coding/runtime/agent_session_runtime.py | 53 ++- src/loushang/coding/session/agent_session.py | 3 + src/loushang/coding/session_manager.py | 78 ++++ tests/coding/test_agent_session_runtime.py | 92 ++-- tests/coding/test_base_plugin_owners.py | 84 ++++ tests/coding/test_owned_capture_shutdown.py | 69 +++ tests/coding/test_owned_runtime_import.py | 188 ++++++++ tests/coding/test_owned_session_runtime.py | 441 ++++++++++++++++++ tests/coding/test_session_manager.py | 75 ++- tests/coding/test_shared_store_bootstrap.py | 39 ++ .../machine_resources/test_control_plane.py | 7 +- 12 files changed, 1080 insertions(+), 63 deletions(-) create mode 100644 tests/coding/test_owned_capture_shutdown.py create mode 100644 tests/coding/test_owned_runtime_import.py create mode 100644 tests/coding/test_owned_session_runtime.py create mode 100644 tests/coding/test_shared_store_bootstrap.py diff --git a/src/loushang/coding/bootstrap.py b/src/loushang/coding/bootstrap.py index 2e54e978b..8e02bfc95 100644 --- a/src/loushang/coding/bootstrap.py +++ b/src/loushang/coding/bootstrap.py @@ -2,6 +2,7 @@ import hashlib import secrets +import sys import threading import time from collections.abc import Callable, Iterable, Mapping @@ -111,6 +112,7 @@ from loushang.coding.session_manager import SessionManager from loushang.coding.tool_pack import coding_default_active_tool_names from loushang.coding.workspace_operations import CodingWorkspaceOperations +from loushang.foundation.platform_paths import resolve_platform_home from loushang.harness.approval import ( InteractiveApprovalResolver, approval_actor_id, @@ -187,6 +189,7 @@ from loushang.harness.tools.workspace.registry import WorkspaceToolRegistry from loushang.harness.transcript import context_items_to_model_messages from loushang.harness.workspace.exec import ExecService +from loushang.harness.workspace.exec.capture_lease import ExecCaptureFactory from loushang.harness.workspace.operations import LOCAL_TOOL_OPERATIONS _SESSION_MANAGER_PLUGIN_OWNER_LOCK = threading.Lock() @@ -493,6 +496,7 @@ def _create_agent_session( active_tool_names: list[str] | None = None, no_tools: NoToolsMode | bool | None = None, services: BootstrapServices | None = None, + output_capture_factory: ExecCaptureFactory | None = None, agent_factory: AgentFactory = Agent, session_start_event: SessionStartEvent | None = None, package_materializer: PackageMaterializer | None = None, @@ -1390,6 +1394,7 @@ def construct_child_session( package_product_inventory=package_product_inventory, package_product_lifecycle_mode=package_product_lifecycle_mode, exec_service=sandbox_runtime.exec_service, + output_capture_factory=output_capture_factory, approval_resolver=approval_resolver, tool_policy_evaluator=tool_policy_evaluator, capability_runtime=capability_runtime, @@ -1634,6 +1639,7 @@ def create_agent_session( no_tools: NoToolsMode | bool | None = None, composition_set: CodingCompositionSetId | None = None, services: BootstrapServices | None = None, + output_capture_factory: ExecCaptureFactory | None = None, agent_factory: AgentFactory = Agent, session_start_event: SessionStartEvent | None = None, package_materializer: PackageMaterializer | None = None, @@ -1662,6 +1668,7 @@ def create_agent_session( "coding-standard" if composition_set is None else composition_set ), services=services, + output_capture_factory=output_capture_factory, agent_factory=agent_factory, session_start_event=session_start_event, package_materializer=package_materializer, @@ -1913,7 +1920,12 @@ def _create_agent_session_runtime( ) return build_agent_product_session_runtime( session_dir=Path(session_dir), - runtime_factory=AgentSessionRuntime, + runtime_factory=partial( + AgentSessionRuntime, + owned_transcripts=sys.platform == "linux", + store_state_root=(resolve_platform_home() / "state/session-stores" + if sys.platform == "linux" else None), + ), fixed_services=fixed_services, build_session=lambda session_manager, session_services, start_event: ( _create_agent_session( diff --git a/src/loushang/coding/runtime/agent_session_runtime.py b/src/loushang/coding/runtime/agent_session_runtime.py index c6f76c96e..9ef712041 100644 --- a/src/loushang/coding/runtime/agent_session_runtime.py +++ b/src/loushang/coding/runtime/agent_session_runtime.py @@ -4,7 +4,11 @@ from pathlib import Path from loushang.coding.session import AgentSession -from loushang.coding.session_manager import SessionManager +from loushang.coding.session_manager import ( + SessionManager, + _bind_owned_session_manager, + _create_owned_session_factory, +) from loushang.harness.diagnostics.service import DiagnosticsService from loushang.harness.runtime import copy_file_exclusive from loushang.harness.session import AgentProductSessionRuntime @@ -46,9 +50,24 @@ def __init__( auto_refresh_session_index: bool = False, session_index_refresh_interval: float = 0.5, session_index_flush_delay: float = 0.25, + owned_transcripts: bool = False, + store_state_root: Path | None = None, ) -> None: + if type(owned_transcripts) is not bool: + raise TypeError("invalid owned transcript activation") + if owned_transcripts and current_session is not None: + raise ValueError("owned runtime must construct its own initial Session") + if store_state_root is not None and not owned_transcripts: + raise ValueError("store admission requires owned transcripts") + self._owned_transcript_factory = ( + _create_owned_session_factory(store_state_root=store_state_root) if owned_transcripts else None + ) + transcript_type = ( + _bind_owned_session_manager(self._owned_transcript_factory) + if self._owned_transcript_factory is not None else SessionManager + ) super().__init__( - transcript_session_type=SessionManager, + transcript_session_type=transcript_type, session_dir=session_dir, session_factory=session_factory, persist=persist, @@ -75,7 +94,35 @@ async def dispose_session_runtime( ) -> None: from loushang.coding.continuity import shutdown_coding_continuity + failure: BaseException | None = None + factory = self._owned_transcript_factory + if factory is not None: + factory._on_loop() + try: + factory.fence() + except BaseException as error: + failure = error try: await super().dispose_session_runtime(metadata=metadata) - finally: + except BaseException as error: + if failure is None: + failure = error + else: + failure.add_note(f"Session cleanup retained: {type(error).__name__}") + if factory is not None: + try: + await factory.close() + except BaseException as error: + if failure is None: + failure = error + else: + failure.add_note(f"transcript factory cleanup retained: {type(error).__name__}") + try: await shutdown_coding_continuity(self) + except BaseException as error: + if failure is None: + failure = error + else: + failure.add_note(f"continuity cleanup retained: {type(error).__name__}") + if failure is not None: + raise failure diff --git a/src/loushang/coding/session/agent_session.py b/src/loushang/coding/session/agent_session.py index 8e6b88f47..3aca01278 100644 --- a/src/loushang/coding/session/agent_session.py +++ b/src/loushang/coding/session/agent_session.py @@ -119,6 +119,7 @@ CompactionResult, ) from loushang.harness.workspace.exec import ExecService +from loushang.harness.workspace.exec.capture_lease import ExecCaptureFactory SessionEventListener = Callable[[AgentSessionEvent], Awaitable[None] | None] # project_runtime_event_to_session_event remains an external migration label. @@ -199,6 +200,7 @@ def __init__( api_registry: APIRegistry | None = None, footer_data_provider: FooterDataProvider | None = None, exec_service: ExecService | None = None, + output_capture_factory: ExecCaptureFactory | None = None, approval_resolver: InteractiveApprovalResolver | None = None, tool_policy_evaluator: PolicyEvaluator | None = None, capability_runtime: StagedResourceCompositionCandidate | None = None, @@ -491,6 +493,7 @@ def __init__( session_start_event=session_start_event, api_registry=api_registry, exec_service=exec_service, + output_capture_factory=output_capture_factory, tool_exec_service=( None if coding_base_plugin_session_assembly is not None diff --git a/src/loushang/coding/session_manager.py b/src/loushang/coding/session_manager.py index 9f20937b3..a59f4a0a2 100644 --- a/src/loushang/coding/session_manager.py +++ b/src/loushang/coding/session_manager.py @@ -1,6 +1,8 @@ from __future__ import annotations from dataclasses import replace +from functools import partial +from pathlib import Path from loushang.coding.product_plan import ( CODING_CAPABILITY_PROFILE, @@ -21,6 +23,12 @@ AgentTranscriptLifecycle, AgentTranscriptSessionFactory, ProductTranscriptSession, + SessionSummary, + TranscriptDeletionOwner, +) +from loushang.harness.transcript.jsonl_file import ( + load_agent_transcript_file, + load_agent_transcript_header, ) _LIFECYCLE = AgentTranscriptLifecycle( @@ -146,4 +154,74 @@ def get_runtime_capability(self, slot: str) -> object | tuple[object, ...]: return self._lifecycle_session.product_binding.value(slot) +def _create_owned_session_factory(*, store_state_root: Path | None = None) -> AgentTranscriptSessionFactory[ + ResolvedRuntimeProfile, RuntimeProfileBinding, +]: + """Create an application-owned factory; never replace the legacy singleton.""" + lifecycle = AgentTranscriptLifecycle( + bind_runtime=CODING_TRANSCRIPT_RUNTIME.bind_lifecycle, + bind_runtime_owned=CODING_TRANSCRIPT_RUNTIME.bind_lifecycle_owned, + ) + return AgentTranscriptSessionFactory( + lifecycle=lifecycle, + resolve_binding_input=_resolve_coding_binding_input, + header_metadata=_coding_header_metadata, + validate_restored_header=_validate_coding_restored_header, + session_file_factory=lifecycle.default_jsonl_session_file, + owned_product_id=CODING_PRODUCT_ID, + store_state_root=store_state_root, + ) + + +def _bind_owned_session_manager( + factory: AgentTranscriptSessionFactory[ResolvedRuntimeProfile, RuntimeProfileBinding], +) -> type[SessionManager]: + """Reference the runtime's factory, with non-mutating disk preview loaders.""" + transient = AgentTranscriptSessionFactory( + lifecycle=AgentTranscriptLifecycle( + bind_runtime=CODING_TRANSCRIPT_RUNTIME.bind_lifecycle, + header_loader=partial(load_agent_transcript_header, read_only=True), + snapshot_loader=partial(load_agent_transcript_file, read_only=True), + ), + index_writable=False, + resolve_binding_input=_resolve_coding_binding_input, + header_metadata=_coding_header_metadata, + validate_restored_header=_validate_coding_restored_header, + ) + + class ApplicationSessionManager(SessionManager): + @classmethod + def _factory_for_persistence( + cls, persist: bool, + ) -> AgentTranscriptSessionFactory[ResolvedRuntimeProfile, RuntimeProfileBinding]: + # The runtime owns admission/cleanup; this class is only a binding. + factory._accepting() + return factory if persist else transient + + @classmethod + async def rename_session(cls, session_file: str | Path, name: str | None) -> SessionSummary: + factory._accepting() + context = factory._discover_owned_context(session_file) + async with factory._owned_source(context) as source: + manager = cls(lifecycle_session=source) + await manager.append_session_info(name) + return manager.get_session_summary() + + @classmethod + async def delete_session( + cls, session_file: str | Path, *, current_session_file: str | Path | None = None, + maintenance_owner: TranscriptDeletionOwner | None = None, + ) -> bool: + # Transcript deletion is writer-owned. Attachments remain recoverable + # for explicit maintenance, never deleted through a pathname fallback. + if maintenance_owner is not None and maintenance_owner is not factory: + raise ValueError("application transcript maintenance belongs to its original factory") + return await factory.delete_transcript(session_file, current_session_file=current_session_file) + + async def create_branched_session(self, leaf_id: str) -> Path | None: + raise ValueError("owned branching requires the Session-returning fork API") + + return ApplicationSessionManager + + __all__ = ["SessionManager"] diff --git a/tests/coding/test_agent_session_runtime.py b/tests/coding/test_agent_session_runtime.py index 695a6ddec..12cd547b3 100644 --- a/tests/coding/test_agent_session_runtime.py +++ b/tests/coding/test_agent_session_runtime.py @@ -3,14 +3,36 @@ import asyncio import os import stat +import sys from datetime import date from functools import wraps from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest from loushang.ai.model import Capabilities, Model from loushang.ai.types import AssistantMessage, TextPart, Usage, UserMessage +@pytest.fixture +def tmp_path(tmp_path): + """Keep each test's Session parent/attachment domain independent too.""" + case = tmp_path / "case" + case.mkdir(mode=0o700) + return case + + +@pytest.fixture(autouse=True) +def isolated_runtime_platform(monkeypatch): + """Default bootstrap must not read or register stores in the user's home.""" + # State must also be outside the transcript data-root, including tests that + # deliberately use pytest's tmp_path itself as their Session directory. + with TemporaryDirectory(prefix="loushang-runtime-platform-") as platform_home: + monkeypatch.setenv("LOUSHANG_HOME", platform_home) + yield + + def _async_test(test): @wraps(test) def run(*args, **kwargs): @@ -23,6 +45,19 @@ def _runtime_footer(cwd: Path) -> str: return f"Current date: {date.today().isoformat()}\nCurrent working directory: {cwd.as_posix()}" +def _legacy_import_runtime(session_dir: Path): + """Keep copy-protocol regressions on the explicit legacy runtime contract.""" + from loushang.coding.bootstrap import create_agent_session + from loushang.coding.runtime import AgentSessionRuntime + + return AgentSessionRuntime( + session_dir=session_dir, + session_factory=lambda manager, **kwargs: create_agent_session( + session_manager=manager, model=_model(), **kwargs, + ), + ) + + def _model() -> Model: return Model( id="faux-model", @@ -683,9 +718,7 @@ async def test_runtime_dispose_publishes_latest_session_summary(tmp_path) -> Non project = tmp_path / "project" project.mkdir() runtime = create_agent_session_runtime( - session_dir=tmp_path, - model=_model(), - persist=True, + session_dir=tmp_path, model=_model(), persist=True ) session = await runtime.create_session(cwd=str(project)) await session.session_manager.append_message( @@ -752,6 +785,7 @@ def __init__(self, manager: SessionManager) -> None: persist=True, auto_refresh_session_index=True, session_index_flush_delay=60.0, + owned_transcripts=sys.platform == "linux", ) project = tmp_path / "project" project.mkdir() @@ -780,11 +814,14 @@ async def scenario() -> None: await runtime.delete_session(second.get_header().conversation_id) await runtime.drain_session_index_flush() - await scenario() + try: + await scenario() - assert {summary.session_id for summary in SessionManager.load_index(tmp_path)} == { - first.get_header().conversation_id - } + assert {summary.session_id for summary in SessionManager.load_index(tmp_path)} == { + first.get_header().conversation_id + } + finally: + await runtime.dispose_session_runtime() @_async_test @@ -1132,7 +1169,6 @@ async def test_runtime_restore_emits_one_aggregate_performance_event( tmp_path, monkeypatch, ) -> None: - from loushang.coding.bootstrap import create_agent_session_runtime from loushang.coding.session_manager import SessionManager from loushang.harness.session import lifecycle_adapter as lifecycle_adapter_module @@ -1157,11 +1193,7 @@ def debug_event(self, scope: str, name: str, **data: object) -> None: "_SESSION_RESUME_PERFORMANCE_LOG", _TimingLog(), ) - runtime = create_agent_session_runtime( - session_dir=tmp_path, - model=_model(), - persist=True, - ) + runtime = _legacy_import_runtime(tmp_path) await runtime.restore_session_operation(target_file) assert len(events) == 1 @@ -1227,7 +1259,6 @@ async def test_runtime_rejects_compatibility_source_replaced_after_discovery( ) -> None: import pytest - from loushang.coding.bootstrap import create_agent_session_runtime from loushang.coding.runtime import agent_session_runtime as runtime_module from loushang.coding.session_manager import SessionManager @@ -1246,11 +1277,7 @@ async def test_runtime_rejects_compatibility_source_replaced_after_discovery( legacy_id = legacy.get_session_record().session_id await legacy.dispose_runtime_profile() - runtime = create_agent_session_runtime( - session_dir=authority_dir, - model=_model(), - persist=True, - ) + runtime = _legacy_import_runtime(authority_dir) runtime.add_session_discovery_dir(legacy_dir) selected = runtime.resolve_discovered_session_source(legacy_id) monkeypatch.setattr( @@ -1413,7 +1440,6 @@ async def test_runtime_refuses_same_id_with_different_discovery_content( async def test_runtime_switches_from_provisional_session_without_persisting_it( tmp_path, ) -> None: - from loushang.coding.bootstrap import create_agent_session_runtime from loushang.coding.session_manager import SessionManager project_root = tmp_path / "project" @@ -1427,11 +1453,7 @@ async def test_runtime_switches_from_provisional_session_without_persisting_it( historical_file = historical.get_session_file() assert historical_file is not None - runtime = create_agent_session_runtime( - session_dir=tmp_path, - model=_model(), - persist=True, - ) + runtime = _legacy_import_runtime(tmp_path) provisional = await runtime.create_session(cwd=str(project_root)) provisional_file = provisional.get_session_file() assert provisional_file is not None @@ -1541,16 +1563,13 @@ async def _with_session(ctx) -> None: @_async_test async def test_runtime_import_from_jsonl_copies_and_switches_session(tmp_path) -> None: - from loushang.coding.bootstrap import create_agent_session_runtime from loushang.coding.session_manager import SessionManager project_root = tmp_path / "project" project_root.mkdir() import_dir = tmp_path / "imports" import_dir.mkdir() - runtime = create_agent_session_runtime( - session_dir=tmp_path / "sessions", model=_model(), persist=True - ) + runtime = _legacy_import_runtime(tmp_path / "sessions") original = await runtime.create_session(cwd=str(project_root)) await original.session_manager.append_message(_user_message("original")) @@ -1581,7 +1600,6 @@ async def test_runtime_import_from_jsonl_copies_and_switches_session(tmp_path) - async def test_runtime_import_from_jsonl_does_not_overwrite_existing_same_name_session( tmp_path, ) -> None: - from loushang.coding.bootstrap import create_agent_session_runtime from loushang.coding.session_manager import SessionManager project_root = tmp_path / "project" @@ -1589,9 +1607,7 @@ async def test_runtime_import_from_jsonl_does_not_overwrite_existing_same_name_s session_dir = tmp_path / "sessions" project_root.mkdir() import_dir.mkdir() - runtime = create_agent_session_runtime( - session_dir=session_dir, model=_model(), persist=True - ) + runtime = _legacy_import_runtime(session_dir) existing = await runtime.create_session(cwd=str(project_root)) await existing.session_manager.append_message(_user_message("existing session")) existing_file = existing.session_manager.session_file @@ -1632,7 +1648,6 @@ async def test_runtime_import_from_jsonl_retries_when_unique_destination_is_clai ) -> None: import errno - from loushang.coding.bootstrap import create_agent_session_runtime from loushang.coding.runtime import agent_session_runtime as runtime_module from loushang.coding.session_manager import SessionManager @@ -1641,9 +1656,7 @@ async def test_runtime_import_from_jsonl_retries_when_unique_destination_is_clai session_dir = tmp_path / "sessions" project_root.mkdir() import_dir.mkdir() - runtime = create_agent_session_runtime( - session_dir=session_dir, model=_model(), persist=True - ) + runtime = _legacy_import_runtime(session_dir) existing = await runtime.create_session(cwd=str(project_root)) await existing.session_manager.append_message(_user_message("existing session")) existing_file = existing.session_manager.session_file @@ -1785,7 +1798,6 @@ async def test_runtime_import_from_jsonl_cleans_copied_file_when_stored_cwd_is_m ) -> None: import pytest - from loushang.coding.bootstrap import create_agent_session_runtime from loushang.coding.session_manager import SessionManager from loushang.harness.session import MissingSessionCwdError @@ -1795,9 +1807,7 @@ async def test_runtime_import_from_jsonl_cleans_copied_file_when_stored_cwd_is_m session_dir = tmp_path / "sessions" project_root.mkdir() import_dir.mkdir() - runtime = create_agent_session_runtime( - session_dir=session_dir, model=_model(), persist=True - ) + runtime = _legacy_import_runtime(session_dir) current = await runtime.create_session(cwd=str(project_root)) imported_manager = await SessionManager.new( session_dir=import_dir, cwd=str(missing_cwd), persist=True diff --git a/tests/coding/test_base_plugin_owners.py b/tests/coding/test_base_plugin_owners.py index 16b637cf3..8aa698413 100644 --- a/tests/coding/test_base_plugin_owners.py +++ b/tests/coding/test_base_plugin_owners.py @@ -110,6 +110,90 @@ def test_base_process_operations_execute_only_through_captured_launcher() -> Non asyncio.run(_base_process_operations_execute_only_through_captured_launcher()) +@pytest.mark.parametrize("fail", [False, True, "stop_callback"]) +def test_explicit_authorized_capture_is_bounded_and_supervises_failure(monkeypatch, fail): + from loushang.harness.workspace.exec import service as module + + monkeypatch.setattr(module.tempfile, "mkstemp", lambda *a, **k: pytest.fail("old artifact path")) + handle = _ProcessHandle(stdout=[b"x" * 16384] * 20 + [b""], + stderr=[b"y" * 16384] * 20 + [b""], wait_forever=fail) + reads = [] + for name in ("read_stdout", "read_stderr"): + original = getattr(_ProcessHandle, name) + + async def read(self, max_bytes=65536, original=original): + reads.append(max_bytes) + assert max_bytes == 16384 + return await original(self, max_bytes) + + monkeypatch.setattr(_ProcessHandle, name, read) + + class Sink: + stopped = False + total = 0 + + async def append(self, chunk): + if not self.stopped: + if fail: + raise ValueError("capture contract failure") + assert len(chunk.text) <= 16384 + self.total += len(chunk.text.encode()) + + def stop_accepting(self): + self.stopped = True + if fail == "stop_callback": + raise RuntimeError("broken stop callback") + + async def scenario(): + sink = Sink() + capability = ExecService(backend=AuthorizedProcessExecBackend(_CapturedLauncher(handle))).capture_executor() + assert capability is not None + operation = capability.execute(ExecRequest(("/bin/example",), cwd="/workspace", + rolling_max_bytes=10**9, preview_max_bytes=10**9), capture=sink) + if fail: + with pytest.raises(ValueError, match="capture contract failure"): + await asyncio.wait_for(operation, 1) + assert sink.stopped and handle.terminated + else: + result = await operation + assert result.exit_code == 7 and result.stdio_complete and not handle.terminated + assert sink.total == 40 * 16384 + assert len(result.stdout.encode()) <= 100 * 1024 + assert len(result.stderr.encode()) <= 100 * 1024 + assert sum(len(chunk.text.encode()) for chunk in result.output_chunks) <= 100 * 1024 + assert result.stdout_artifact_path is result.stderr_artifact_path is None + assert reads and handle.closed + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("capture_full_output", [True, False]) +def test_authorized_output_without_retention_never_creates_files(monkeypatch, capture_full_output): + from loushang.harness.workspace.exec import service as service_module + + def forbidden(*args, **kwargs): + pytest.fail("disabled retention created an output file") + + monkeypatch.setattr(service_module.tempfile, "mkstemp", forbidden) + payload = ("字" * 2000 + "\nlast\n").encode() + handle = _ProcessHandle(stdout=[payload, b""], stderr=[payload, b""]) + operations = ExecService(backend=AuthorizedProcessExecBackend(_CapturedLauncher(handle))) + + async def scenario(): + result = await operations.execute(ExecRequest(command=("/bin/example",), cwd="/workspace", + capture_full_output=capture_full_output, retain_output_artifacts=False, rolling_max_bytes=512, + preview_max_lines=1, preview_max_bytes=128)) + assert result.exit_code == 7 and handle.closed and not handle.terminated + assert result.stdout_artifact_path is result.stderr_artifact_path is None + assert result.stdout_preview == result.stderr_preview == "last\n" + assert result.stdout_truncated and result.stderr_truncated + if not capture_full_output: + assert len(result.stdout.encode()) <= 512 and len(result.stderr.encode()) <= 512 + assert sum(len(chunk.text.encode()) for chunk in result.output_chunks) <= 512 + + asyncio.run(scenario()) + + async def _base_process_operations_execute_only_through_captured_launcher() -> None: handle = _ProcessHandle() launcher = _CapturedLauncher(handle) diff --git a/tests/coding/test_owned_capture_shutdown.py b/tests/coding/test_owned_capture_shutdown.py new file mode 100644 index 000000000..aa3e4fbe1 --- /dev/null +++ b/tests/coding/test_owned_capture_shutdown.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import asyncio +import sys + +import pytest + +from loushang.harness.session.output_artifacts import SessionOutputPersistingExecService +from loushang.harness.workspace.exec import ExecRequest, ExecService +from tests.harness.session.test_output_capture import Backend, Factory, Lease +from tests.harness.transcript.test_writer_lease import busy, lease + +from .test_owned_session_runtime import runtime + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux owned writer") + + +def test_graph_owned_writer_is_retained_until_output_cleanup_succeeds(tmp_path): + async def run(): + root = tmp_path / "sessions" + root.mkdir(mode=0o700) + owner = runtime(root) + capture = Lease(fail_close=True) + try: + session = await owner.create_session(cwd=str(tmp_path)) + manager = session.session_manager + original = manager._lifecycle_session + assert original.ownership_state == "graph_owned" + identity = manager.header.conversation_id + service = SessionOutputPersistingExecService( + ExecService(backend=Backend()), session_dir=manager.session_dir, + session_id=identity, file_io=original.blob_file_io, + operation_scope=original.operation_scope, + initialization_scope=original.sync_operation_scope, + capture_factory=Factory(capture), + ) + # Inject only the fake storage/execution boundary; exercise the real + # Product shutdown, capability graph and rooted Session writer. + session._exec_service = service + session._tool_exec_service = service + result = await service.execute(ExecRequest(("unused",))) + assert result.stdout_artifact_ref is not None + assert result.artifact_cleanup_error == "temporary_cleanup_pending" + # Automatic Graph rollback reaches this same disposer before the + # Product's outer except. The guard must reject before inspecting + # or mutating any bundle/transcript, without awaiting capture. + with pytest.raises(RuntimeError, match="capture cleanup remains pending"): + await session._session_capability_binding.dispose(None) + assert original.ownership_state == "graph_owned" + busy(root, conversation=identity) + with pytest.raises(OSError, match="private path"): + await session.dispose() + assert original.ownership_state == "graph_owned" + assert not manager.runtime_disposed + busy(root, conversation=identity) + with original.sync_operation_scope(): + assert service._store.read_bytes(result.stdout_artifact_ref) == b"data" + capture.fail_close = False + await session.dispose() + assert manager.runtime_disposed + reopened = lease(root, conversation=identity) + try: + reopened.acquire() + finally: + reopened.close() + finally: + capture.fail_close = False + await owner.dispose_session_runtime() + asyncio.run(run()) diff --git a/tests/coding/test_owned_runtime_import.py b/tests/coding/test_owned_runtime_import.py new file mode 100644 index 000000000..2e510c9c8 --- /dev/null +++ b/tests/coding/test_owned_runtime_import.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import asyncio +import sys + +import pytest + +from loushang.ai.types import UserMessage +from loushang.coding import session_manager as managers +from loushang.harness.session import lifecycle as lifecycle_module +from loushang.harness.transcript import session_factory as factory_module +from loushang.harness.transcript.export import export_agent_transcript_bundle +from loushang.harness.transcript.session_catalog import ( + session_file_authority_fingerprint, +) +from tests.harness.transcript.test_writer_lease import busy, lease + +from .test_owned_session_runtime import runtime + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux owned import") + + +@pytest.mark.parametrize("archive", [False, True]) +@pytest.mark.parametrize("entry", ["import", "prepared_import", "restore", "prepared_restore", "abort_import"]) +def test_owned_import_uses_original_candidate_without_path_staging(tmp_path, monkeypatch, archive, entry): + async def scenario(): + source_root, target_root = tmp_path / "source" / "sessions", tmp_path / "target" / "sessions" + source_root.parent.mkdir(mode=0o700) + target_root.parent.mkdir(mode=0o700) + source_root.mkdir(mode=0o700) + target_root.mkdir(mode=0o700) + source_owner, target_owner = runtime(source_root), runtime(target_root) + candidate = None + try: + source = await source_owner.create_session(cwd=str(tmp_path)) + manager = source.session_manager + await manager.append_message(UserMessage(role="user", content="frozen import", timestamp=1.0)) + path = manager.session_file + identity = manager.header.conversation_id + if archive: + path = tmp_path / "portable.zip" + export_agent_transcript_bundle( + manager.header, manager.entries, session_dir=source_root, + output_path=path, allow_private=True, + ) + await source_owner.dispose_session_runtime() + fingerprint = session_file_authority_fingerprint(path) + + def forbidden(*args, **kwargs): + raise AssertionError("owned import must not stage a pathname copy") + + monkeypatch.setattr(lifecycle_module, "stage_file_import", forbidden) + if entry.startswith("prepared") or entry == "abort_import": + if entry in {"prepared_import", "abort_import"}: + candidate = await target_owner.lifecycle.prepare_import_file( + path, destination_dir=target_root, expected_source_fingerprint=fingerprint, + ) + else: + candidate = await target_owner.prepare_restore_session_operation(path) + assert target_owner.current_session is None + if entry == "abort_import": + assert len(list(target_root.glob("*.jsonl"))) == 1 + await candidate.abort() + assert not list(target_root.glob("*.jsonl")) + assert target_owner.current_session is None + return + result = await candidate.consume() + elif entry == "restore": + result = await target_owner.restore_session_operation(path) + else: + result = await target_owner.import_session_operation( + path, expected_source_fingerprint=fingerprint, + ) + assert not result.cancelled + imported = target_owner.current_session.session_manager + assert imported.header.conversation_id == identity + assert imported.session_file.parent == target_root + assert imported._creation_factory is target_owner._owned_transcript_factory + assert imported._lifecycle_session.ownership_state == "graph_owned" + assert len(list(target_root.glob("*.jsonl"))) == 1 + assert not list(target_root.glob("*.zip")) + finally: + if candidate is not None: + await candidate.close() + await source_owner.dispose_session_runtime() + await target_owner.dispose_session_runtime() + + asyncio.run(scenario()) + + +def test_owned_restore_checks_discovery_fingerprint_at_real_source_read(tmp_path, monkeypatch): + async def scenario(): + source_root, target_root = tmp_path / "source", tmp_path / "target" + source_root.mkdir(mode=0o700) + target_root.mkdir(mode=0o700) + source_owner, target_owner = runtime(source_root), runtime(target_root) + try: + source = await source_owner.create_session(cwd=str(tmp_path)) + await source.session_manager.append_message(UserMessage(role="user", content="selected source", timestamp=1.0)) + path = source.session_manager.session_file + await source_owner.dispose_session_runtime() + selected = target_owner.resolve_discovered_session_source(path) + monkeypatch.setattr(target_owner, "resolve_discovered_session_source", lambda _: selected) + read = factory_module._read_stable_regular_file + calls = [] + + def replaced(path, **kwargs): + assert kwargs["expected_source_fingerprint"] == selected.authority_fingerprint + calls.append(True) + with path.open("ab") as handle: + handle.write(b" \n") + return read(path, **kwargs) + + monkeypatch.setattr(factory_module, "_read_stable_regular_file", replaced) + with pytest.raises(OSError, match="identity changed"): + await target_owner.restore_session_operation(path) + assert calls == [True] + assert target_owner.current_session is None + assert not target_owner._owned_transcript_factory.pending_preparations + assert tuple(target_root.iterdir()) == () + finally: + await source_owner.dispose_session_runtime() + await target_owner.dispose_session_runtime() + + asyncio.run(scenario()) + + +def test_owned_import_rejects_foreign_destination_before_source_io(tmp_path): + async def scenario(): + root, other = tmp_path / "sessions", tmp_path / "other" + owner = runtime(root) + try: + with pytest.raises(ValueError, match="bound Session root"): + await owner.lifecycle.import_file(tmp_path / "missing.jsonl", destination_dir=other) + assert not root.exists() and not other.exists() + finally: + await owner.dispose_session_runtime() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("failure", ["wrapper", "product"]) +def test_owned_import_retains_original_cleanup_owner_before_product_delivery(tmp_path, monkeypatch, failure): + async def scenario(): + source_root, target_root = tmp_path / "source", tmp_path / "target" + source_root.mkdir(mode=0o700) + target_root.mkdir(mode=0o700) + source_owner, target_owner = runtime(source_root), runtime(target_root) + try: + source = await source_owner.create_session(cwd=str(tmp_path)) + await source.session_manager.append_message(UserMessage(role="user", content="persist import source", timestamp=1.0)) + path = source.session_manager.session_file + identity = source.session_manager.header.conversation_id + await source_owner.dispose_session_runtime() + + def fail_delivery(*args, **kwargs): + raise ValueError("original imported delivery failed") + + async def fail_cleanup(*args, **kwargs): + raise OSError("imported cleanup unavailable") + + with monkeypatch.context() as patch: + if failure == "wrapper": + patch.setattr(managers.SessionManager, "__init__", fail_delivery) + else: + patch.setattr(target_owner._transcript_construction_store, "_build_session", fail_delivery) + patch.setattr(managers.CODING_TRANSCRIPT_RUNTIME._binder, "dispose", fail_cleanup) + with pytest.raises(ValueError, match="original imported delivery"): + await target_owner.import_session_operation(path) + assert target_owner.current_session is None + factory_pending = target_owner._owned_transcript_factory.pending_preparations + store_pending = target_owner._transcript_construction_store.pending_transcripts + assert (len(factory_pending), len(store_pending)) == ((1, 0) if failure == "wrapper" else (0, 1)) + busy(target_root, conversation=identity) + with pytest.raises(OSError, match="cleanup unavailable"): + await target_owner.dispose_session_runtime() + busy(target_root, conversation=identity) + await target_owner.dispose_session_runtime() + reopened = lease(target_root, conversation=identity) + try: + reopened.acquire() + finally: + reopened.close() + finally: + await source_owner.dispose_session_runtime() + await target_owner.dispose_session_runtime() + + asyncio.run(scenario()) diff --git a/tests/coding/test_owned_session_runtime.py b/tests/coding/test_owned_session_runtime.py new file mode 100644 index 000000000..2814082f4 --- /dev/null +++ b/tests/coding/test_owned_session_runtime.py @@ -0,0 +1,441 @@ +from __future__ import annotations + +import asyncio +import os +import shutil +import sys +from types import SimpleNamespace + +import pytest + +from loushang.ai.types import UserMessage +from loushang.coding import session_manager as managers +from loushang.coding.bootstrap import create_agent_session, create_agent_session_runtime +from loushang.coding.runtime.agent_session_runtime import AgentSessionRuntime +from loushang.harness.transcript.writer_lease import TranscriptWriterError +from tests.harness.transcript.test_readonly_store import snapshot +from tests.harness.transcript.test_writer_images import message +from tests.harness.transcript.test_writer_lease import busy, lease + +from .test_agent_session_runtime import _model + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux owned Coding runtime") + + +@pytest.mark.parametrize("platform", ["linux", "darwin", "win32"]) +def test_default_bootstrap_selects_owned_store_only_on_supported_platform(tmp_path, monkeypatch, platform): + from loushang.coding import bootstrap + + monkeypatch.setattr(bootstrap, "sys", SimpleNamespace(platform=platform)) + monkeypatch.setenv("LOUSHANG_HOME", str(tmp_path / "platform")) + selected = create_agent_session_runtime(session_dir=tmp_path / "sessions", model=_model(), no_tools=True) + factory = selected._owned_transcript_factory + if platform == "linux": + assert factory is not None + assert factory._store_state_root == tmp_path / "platform/state/session-stores" + else: + assert factory is None + assert not (tmp_path / "platform").exists() + assert not (tmp_path / "sessions").exists() + + +def runtime(root, **kwargs): + def build(manager, *, session_start_event): + return create_agent_session( + session_manager=manager, model=_model(), no_tools=True, + session_start_event=session_start_event, + ) + + return AgentSessionRuntime( + session_dir=root, + session_factory=build, + owned_transcripts=True, + **kwargs, + ) + + +def test_default_runtime_creates_new_workspace_beside_unrelated_v1(tmp_path, monkeypatch): + from loushang.harness.transcript.store_admission import TranscriptStoreAdmission + + platform = tmp_path / "platform" + monkeypatch.setenv("LOUSHANG_HOME", str(platform)) + state = platform / "state/session-stores" + old_root = tmp_path / "old-data/sessions" + old_root.parent.mkdir(mode=0o700) + old_root.mkdir(mode=0o700) + old = TranscriptStoreAdmission(old_root, state_root=state) + try: + binding = old.open() + witness = old.witness_root + finally: + old.close() + old_tree, old_evidence = snapshot(old_root.parent), snapshot(witness) + + async def scenario(): + selected = create_agent_session_runtime( + session_dir=tmp_path / "new-data/sessions", model=_model(), + persist=True, no_tools=True, + ) + try: + session = await selected.create_session(cwd=str(tmp_path)) + await session.session_manager.append_message( + UserMessage(role="user", content="new workspace", timestamp=1), + ) + assert snapshot(old_root.parent) == old_tree + assert snapshot(witness) == old_evidence + finally: + await selected.dispose_session_runtime() + + asyncio.run(scenario()) + restored = TranscriptStoreAdmission(old_root, state_root=state) + try: + assert restored.open() == binding + finally: + restored.close() + + +def test_graph_index_unknown_close_keeps_writer_until_original_cleanup_settles(tmp_path, monkeypatch): + from loushang.harness.journal._rooted_io import RootedFile + + async def scenario(): + root = tmp_path / "data/sessions" + owner = runtime(root, store_state_root=tmp_path / "state/stores") + session = await owner.create_session(cwd=str(tmp_path)) + manager = session.session_manager + await manager.append_message(UserMessage(role="user", content="first", timestamp=1)) + owner.refresh_session_index() + await manager.append_message(UserMessage(role="user", content="latest", timestamp=2)) + retained, calls = {}, [] + original_lock, original_close = RootedFile.acquire_lock, os.close + + def capture(target, **kwargs): + before = set(target._operation.lock_fds) + original_lock(target, **kwargs) + if target._name == ".session-index.json" and not retained: + fd, = target._operation.lock_fds - before + retained.update(fd=fd, operation=target._operation) + + def lose_close(fd): + if fd == retained.get("fd"): + assert not calls, "reclosed an unknown index descriptor" + original_close(fd) + replacement = os.open(os.devnull, os.O_RDONLY) + if replacement != fd: + os.dup2(replacement, fd) + original_close(replacement) + calls.append(fd) + raise OSError("test index close receipt lost") + original_close(fd) + + try: + with monkeypatch.context() as patch: + patch.setattr(RootedFile, "acquire_lock", capture) + patch.setattr(os, "close", lose_close) + for _ in range(2): + with pytest.raises(RuntimeError, match="cleanup remains pending"): + await owner.dispose_session_runtime() + busy(root, conversation=manager.header.conversation_id) + assert manager._lifecycle_session.transcript_file_io.cleanup_pending + assert not manager.runtime_disposed + assert calls == [retained["fd"]] + os.fstat(calls[0]) + finally: + if calls: + # Only the injector knows close completed; production must keep + # the unknown debt rather than guess ownership of a reused fd. + operation = retained["operation"] + operation.descriptors.pop(calls[0], None) + operation.lock_fds.discard(calls[0]) + original_close(calls[0]) + await owner.dispose_session_runtime() + assert manager.runtime_disposed + reopened = lease(root, conversation=manager.header.conversation_id) + try: + reopened.acquire() + finally: + reopened.close() + + asyncio.run(scenario()) + + +def test_actual_coding_runtime_create_restore_and_application_writer(tmp_path): + async def scenario(): + root = tmp_path / "sessions" + root.mkdir(mode=0o700) + first, second = runtime(root), runtime(root) + try: + session = await first.create_session(cwd=str(tmp_path)) + manager = session.session_manager + assert manager._creation_factory is first._owned_transcript_factory + assert manager._lifecycle_session.ownership_state == "graph_owned" + await manager.append_message(UserMessage(role="user", content="retained", timestamp=1.0)) + path, identity = manager.session_file, manager.header.conversation_id + busy(root, conversation=identity) + with pytest.raises(TranscriptWriterError, match="busy"): + await second.restore_session(path) + await first.dispose_session_runtime() + assert manager.runtime_disposed + restored = await second.restore_session(path) + assert restored.session_manager.header.conversation_id == identity + assert restored.session_manager.entries + forked = await second.fork_session(restored.session_manager.leaf_id) + assert forked.session_manager._creation_factory is second._owned_transcript_factory + assert forked.session_manager._lifecycle_session.ownership_state == "graph_owned" + assert forked.session_manager.header.conversation_id != identity + assert restored.session_manager.runtime_disposed + await second.dispose_session_runtime() + assert forked.session_manager.runtime_disposed + reopened = lease(root, conversation=identity) + try: + reopened.acquire() + finally: + reopened.close() + finally: + await first.dispose_session_runtime() + await second.dispose_session_runtime() + + asyncio.run(scenario()) + + +def test_default_embedded_bootstrap_respects_existing_application_writer(tmp_path, monkeypatch): + monkeypatch.setenv("LOUSHANG_HOME", str(tmp_path / "platform")) + async def scenario(): + data = tmp_path / "data" + data.mkdir(mode=0o700) + root = data / "sessions" + root.mkdir(mode=0o700) + first = runtime(root, store_state_root=tmp_path / "platform/state/session-stores") + embedded = create_agent_session_runtime(session_dir=root, model=_model(), no_tools=True) + try: + session = await first.create_session(cwd=str(tmp_path)) + manager = session.session_manager + await manager.append_message(UserMessage(role="user", content="retained", timestamp=1.0)) + path = manager.session_file + assert embedded._owned_transcript_factory is not None + for operation in ( + lambda: embedded.restore_session(path), + lambda: embedded.rename_session(path, "renamed"), + lambda: embedded.delete_session(path), + ): + with pytest.raises(TranscriptWriterError, match="busy"): + await operation() + await first.dispose_session_runtime() + restored = await embedded.restore_session(path) + assert restored.session_manager.header.conversation_id == manager.header.conversation_id + busy(root, conversation=manager.header.conversation_id) + finally: + await first.dispose_session_runtime() + await embedded.dispose_session_runtime() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("index", ["missing", "corrupt"]) +def test_preview_uses_readonly_source_and_never_inherits_writer_factory(tmp_path, index): + async def scenario(): + root = tmp_path / "sessions" + root.mkdir(mode=0o700) + owner = runtime(root) + previews = [] + try: + current = await owner.create_session(cwd=str(tmp_path)) + manager = current.session_manager + await manager.append_message(message()) + Bound = type(manager) + # Copy only the transcript into a source without any sidecars. + preview_data = tmp_path / "preview-data" + source = preview_data / "sessions" + source.mkdir(parents=True) + path = source / manager.session_file.name + path.write_bytes(manager.session_file.read_bytes()) + authority = manager.header.conversation_id + shutil.copytree(tmp_path / "session-assets" / authority, preview_data / "session-assets" / authority) + if index == "corrupt": + Bound.index_file(source).write_bytes(b"invalid index") + before = snapshot(preview_data) + preview = await Bound.open(path, persist=False) + previews.append(preview) + memory = await Bound.in_memory(cwd=tmp_path) + previews.append(memory) + recent = await Bound.continue_recent(source, tmp_path, persist=False) + previews.append(recent) + assert recent.header.conversation_id == manager.header.conversation_id + assert recent.entries + assert not preview.persist and preview._creation_factory is None + assert preview.entries and not memory.entries + assert preview._lifecycle_session.session_blob_health + assert all(item.state == "available" for item in preview._lifecycle_session.session_blob_health) + await preview.append_message(UserMessage(role="user", content="local only", timestamp=2.0)) + assert snapshot(preview_data) == before + assert manager._creation_factory is owner._owned_transcript_factory + busy(root, conversation=manager.header.conversation_id) + await owner.dispose_session_runtime() + with pytest.raises(RuntimeError, match="closed"): + await Bound.in_memory(cwd=tmp_path) + finally: + for preview in previews: + await preview.dispose_runtime_profile() + await owner.dispose_session_runtime() + + asyncio.run(scenario()) + + +def test_owned_runtime_maintenance_is_fenced_and_preserves_attachments(tmp_path): + async def scenario(): + root = tmp_path / "sessions" + root.mkdir(mode=0o700) + first, second = runtime(root), runtime(root) + try: + session = await first.create_session(cwd=str(tmp_path)) + manager = session.session_manager + await manager.append_message(message()) + path = manager.session_file + for operation in ( + lambda: second.rename_session(path, "renamed"), + lambda: second.delete_session(path), + ): + with pytest.raises(TranscriptWriterError, match="busy"): + await operation() + assert path.is_file() + await first.dispose_session_runtime() + summary = await second.rename_session(path, "renamed") + assert summary.name == "renamed" + assets = tmp_path / "session-assets" / manager.header.conversation_id + before = snapshot(assets) + assert await second.delete_session(path) + assert not path.exists() and snapshot(assets) == before + await second.dispose_session_runtime() + with pytest.raises(RuntimeError, match="closed"): + await second._product_runtime_ports.delete_transcript(path, None) + finally: + await first.dispose_session_runtime() + await second.dispose_session_runtime() + + asyncio.run(scenario()) + + +def test_owned_runtime_retains_rename_cleanup_debt(tmp_path, monkeypatch): + async def scenario(): + root = tmp_path / "sessions" + root.mkdir(mode=0o700) + first, second = runtime(root), runtime(root) + + async def unavailable(binding): + raise OSError("rename cleanup unavailable") + + try: + session = await first.create_session(cwd=str(tmp_path)) + manager = session.session_manager + await manager.append_message(UserMessage(role="user", content="history", timestamp=1.0)) + path, identity = manager.session_file, manager.header.conversation_id + await first.dispose_session_runtime() + with monkeypatch.context() as patch: + patch.setattr(managers.CODING_TRANSCRIPT_RUNTIME._binder, "dispose", unavailable) + with pytest.raises(OSError, match="rename cleanup"): + await second.rename_session(path, "renamed") + retained, = second._owned_transcript_factory.pending_preparations + busy(root, conversation=identity) + with pytest.raises(OSError, match="rename cleanup"): + await second.dispose_session_runtime() + assert second._owned_transcript_factory.pending_preparations == (retained,) + await second.dispose_session_runtime() + assert not second._owned_transcript_factory.pending_preparations + finally: + await first.dispose_session_runtime() + await second.dispose_session_runtime() + + asyncio.run(scenario()) + + +def test_actual_runtime_retains_factory_debt_before_wrapper_delivery(tmp_path, monkeypatch): + async def scenario(): + root = tmp_path / "sessions" + root.mkdir(mode=0o700) + owner = runtime(root) + captured = [] + + def fail_wrapper(self, *, lifecycle_session): + captured.append(lifecycle_session) + raise ValueError("wrapper construction failed") + + async def unavailable(binding): + raise OSError("runtime cleanup unavailable") + + try: + with monkeypatch.context() as patch: + patch.setattr(managers.SessionManager, "__init__", fail_wrapper) + patch.setattr(managers.CODING_TRANSCRIPT_RUNTIME._binder, "dispose", unavailable) + with pytest.raises(ValueError, match="wrapper construction"): + await owner.create_session(cwd=str(tmp_path)) + retained, = owner._owned_transcript_factory.pending_preparations + assert retained._session is captured[0] + assert not owner._transcript_construction_store.pending_transcripts + identity = captured[0].context.header.conversation_id + busy(root, conversation=identity) + with pytest.raises(OSError, match="cleanup unavailable"): + await owner.dispose_session_runtime() + assert owner._owned_transcript_factory.pending_preparations == (retained,) + await owner.dispose_session_runtime() + assert not owner._owned_transcript_factory.pending_preparations + reopened = lease(root, conversation=identity) + try: + reopened.acquire() + finally: + reopened.close() + finally: + await owner.dispose_session_runtime() + + asyncio.run(scenario()) + + +def test_actual_runtime_close_fences_inflight_binding_before_join(tmp_path, monkeypatch): + async def scenario(): + root = tmp_path / "sessions" + root.mkdir(mode=0o700) + owner = runtime(root) + selected = owner._owned_transcript_factory + lifecycle = selected._lifecycle + original = lifecycle._bind_runtime_owned + entered, release, closing = asyncio.Event(), asyncio.Event(), asyncio.Event() + + async def delayed(*args): + result = await original(*args) + entered.set() + await release.wait() + return result + + async def close(): + closing.set() + await owner.dispose_session_runtime() + + monkeypatch.setattr(lifecycle, "_bind_runtime_owned", delayed) + creation = asyncio.create_task(owner.create_session(cwd=str(tmp_path))) + shutdown = None + try: + await asyncio.wait_for(entered.wait(), 5) + retained, = selected.pending_preparations + shutdown = asyncio.create_task(close()) + await asyncio.wait_for(closing.wait(), 5) + assert selected._closing and retained.closing and not shutdown.done() + release.set() + with pytest.raises((RuntimeError, TranscriptWriterError)): + await asyncio.wait_for(creation, 5) + await asyncio.wait_for(shutdown, 5) + assert not selected.pending_preparations + assert not owner._transcript_construction_store.pending_transcripts + assert owner.current_session is None + finally: + release.set() + await asyncio.gather(creation, *([shutdown] if shutdown else []), return_exceptions=True) + await owner.dispose_session_runtime() + + asyncio.run(scenario()) + + +def test_owned_runtime_does_not_replace_legacy_global_factory(tmp_path): + original = managers.SessionManager._session_factory() + owned = runtime(tmp_path) + legacy = AgentSessionRuntime(session_dir=tmp_path, session_factory=lambda manager: manager) + assert legacy._owned_transcript_factory is None + assert owned._owned_transcript_factory is not original + assert managers.SessionManager._session_factory() is original is managers._FACTORY diff --git a/tests/coding/test_session_manager.py b/tests/coding/test_session_manager.py index ce8279f8f..433b661c1 100644 --- a/tests/coding/test_session_manager.py +++ b/tests/coding/test_session_manager.py @@ -2,9 +2,28 @@ import asyncio import json +import sys from functools import wraps +async def _delete_session_with_owner(session_file, *, current_session_file=None): + """Keep the Linux maintenance authority for the entire test operation.""" + from loushang.coding.session_manager import ( + SessionManager, + _create_owned_session_factory, + ) + + if sys.platform != "linux": + return await SessionManager.delete_session(session_file, current_session_file=current_session_file) + maintenance = _create_owned_session_factory() + try: + return await SessionManager.delete_session( + session_file, current_session_file=current_session_file, maintenance_owner=maintenance, + ) + finally: + await maintenance.close() + + def _async_test(test): @wraps(test) def run(*args, **kwargs): @@ -766,14 +785,26 @@ async def test_session_manager_delete_session_file_preserves_stable_lock( await SessionManager.load(session_file) assert lock_file.exists() - assert await SessionManager.delete_session(session_file) is True - assert session_file.exists() is False - assert lock_file.exists() is True - assert await SessionManager.delete_session(session_file) is False + if sys.platform == "linux": + from loushang.coding.session_manager import _create_owned_session_factory + + maintenance = _create_owned_session_factory() + try: + assert await SessionManager.delete_session(session_file, maintenance_owner=maintenance) is True + assert session_file.exists() is False + assert lock_file.exists() is True + assert await SessionManager.delete_session(session_file, maintenance_owner=maintenance) is False + finally: + await maintenance.close() + else: + assert await SessionManager.delete_session(session_file) is True + assert session_file.exists() is False + assert lock_file.exists() is True + assert await SessionManager.delete_session(session_file) is False @_async_test -async def test_session_manager_delete_removes_owned_blobs_after_transcript( +async def test_session_manager_delete_preserves_platform_blob_cleanup_policy( tmp_path, ) -> None: from loushang.ai.types import UserMessage @@ -782,6 +813,8 @@ async def test_session_manager_delete_removes_owned_blobs_after_transcript( from loushang.harness.conversation import CommandExecutionRecord session_dir = tmp_path / "data" / "sessions" + session_dir.parent.mkdir(mode=0o700) + session_dir.mkdir(mode=0o700) manager = await SessionManager.new( session_dir=session_dir, cwd="/tmp/project", @@ -810,9 +843,12 @@ async def test_session_manager_delete_removes_owned_blobs_after_transcript( session_file = manager.get_session_file() assert session_file is not None - assert await SessionManager.delete_session(session_file) is True + assert await _delete_session_with_owner(session_file) is True assert not session_file.exists() - assert not blobs.root.exists() + if sys.platform == "linux": + assert blobs.read_bytes(reference) == b"complete output" + else: + assert not blobs.root.exists() @_async_test @@ -856,7 +892,16 @@ async def test_session_delete_does_not_accept_a_duplicate_transcript_as_blob_own duplicate = session_dir / "forged-duplicate.jsonl" shutil.copyfile(session_file, duplicate) - assert await SessionManager.delete_session(duplicate) is True + if sys.platform == "linux": + import pytest + + from loushang.harness.transcript.writer_lease import TranscriptWriterError + + with pytest.raises(TranscriptWriterError): + await _delete_session_with_owner(duplicate) + assert duplicate.exists() + else: + assert await _delete_session_with_owner(duplicate) is True assert session_file.exists() assert blobs.read_bytes(reference) == b"private output" @@ -1060,7 +1105,7 @@ def fail_cleanup(**_kwargs): fail_cleanup, ) - assert await SessionManager.delete_session(session_file) is True + assert await _delete_session_with_owner(session_file) is True assert not session_file.exists() @@ -1198,7 +1243,7 @@ async def test_session_manager_rename_and_delete_refresh_existing_index( await SessionManager.rename_session(first_file, "Indexed Name") renamed_index = SessionManager.list_indexed_summaries(tmp_path) - await SessionManager.delete_session(second_file) + await _delete_session_with_owner(second_file) deleted_index = SessionManager.list_indexed_summaries(tmp_path) assert ( @@ -1327,7 +1372,7 @@ def _fail_refresh_index(cls, session_dir): ) renamed = await SessionManager.rename_session(first_file, "Renamed Anyway") - deleted = await SessionManager.delete_session(second_file) + deleted = await _delete_session_with_owner(second_file) assert renamed.name == "Renamed Anyway" assert deleted is True @@ -1360,7 +1405,7 @@ async def test_session_manager_delete_session_file_refuses_current_session_alias aliased_file = alias_dir / session_file.name with pytest.raises(ValueError, match="currently active session"): - await SessionManager.delete_session( + await _delete_session_with_owner( aliased_file, current_session_file=session_file ) @@ -1763,7 +1808,7 @@ async def test_session_manager_falls_back_before_bounded_invalid_index_rebuild( @_async_test -async def test_session_manager_preserves_corrupt_index_for_diagnostics( +async def test_session_manager_read_fallback_preserves_corrupt_index_in_place( tmp_path, ) -> None: from loushang.ai.types import TextPart, UserMessage @@ -1788,8 +1833,8 @@ async def test_session_manager_preserves_corrupt_index_for_diagnostics( session.get_header().conversation_id ] corrupt_files = sorted(tmp_path.glob(".session-index.json.corrupt-*")) - assert len(corrupt_files) == 1 - assert corrupt_files[0].read_text(encoding="utf-8") == "not-json\n" + assert corrupt_files == [] + assert index_file.read_text(encoding="utf-8") == "not-json\n" @_async_test diff --git a/tests/coding/test_shared_store_bootstrap.py b/tests/coding/test_shared_store_bootstrap.py new file mode 100644 index 000000000..6aae83dee --- /dev/null +++ b/tests/coding/test_shared_store_bootstrap.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import asyncio +import sys + +import pytest + +from loushang.ai.types import UserMessage +from loushang.coding.bootstrap import create_agent_session_runtime + +from .test_agent_session_runtime import _model + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux default owned bootstrap") + + +def test_default_bootstrap_fresh_sibling_projects_share_family_without_layout_change(tmp_path, monkeypatch): + monkeypatch.setenv("LOUSHANG_HOME", str(tmp_path / "platform")) + + async def scenario(): + a_root, b_root = tmp_path / "sessions/project-a", tmp_path / "sessions/project-b" + a = create_agent_session_runtime(session_dir=a_root, model=_model(), no_tools=True) + b = create_agent_session_runtime(session_dir=b_root, model=_model(), no_tools=True) + try: + first = await a.create_session(cwd=str(tmp_path)) + await first.session_manager.append_message(UserMessage(role="user", content="A", timestamp=1.0)) + second = await b.create_session(cwd=str(tmp_path)) + await second.session_manager.append_message(UserMessage(role="user", content="B", timestamp=2.0)) + assert first.session_manager.session_file.parent == a_root + assert second.session_manager.session_file.parent == b_root + assert (a_root.parent / "session-assets/.locks").is_dir() + first_owner = first.session_manager._lifecycle_session._writer_owner + second_owner = second.session_manager._lifecycle_session._writer_owner + assert first_owner._store_admission._binding.family_id == second_owner._store_admission._binding.family_id + assert first.session_manager._lifecycle_session.ownership_state == "graph_owned" + finally: + await b.dispose_session_runtime() + await a.dispose_session_runtime() + + asyncio.run(scenario()) diff --git a/tests/harness/machine_resources/test_control_plane.py b/tests/harness/machine_resources/test_control_plane.py index 3f0d095a7..804bb5a4a 100644 --- a/tests/harness/machine_resources/test_control_plane.py +++ b/tests/harness/machine_resources/test_control_plane.py @@ -27,7 +27,6 @@ from loushang.harness.machine_resources import control_plane as control_plane_module from loushang.harness.transcript import AGENT_MESSAGE_KIND, SessionImagePart from loushang.harness.transcript.jsonl_file import write_agent_transcript_export -from loushang.harness.transcript.lifecycle import delete_agent_transcript_jsonl def _paths(tmp_path: Path) -> PlatformPaths: @@ -157,9 +156,11 @@ def test_clean_preview_is_non_mutating_and_apply_removes_only_managed_archives( def test_orphan_asset_cleanup_preserves_every_transcript_claimed_authority( tmp_path: Path, ) -> None: + from tests.harness.transcript._maintenance import delete_with_maintenance + paths = _paths(tmp_path) sessions = paths.data / "sessions" - sessions.mkdir(parents=True) + prepare_private_directory_chain(sessions) write_agent_transcript_export( sessions / "live.jsonl", _header("live"), @@ -185,7 +186,7 @@ def test_orphan_asset_cleanup_preserves_every_transcript_claimed_authority( _header("orphan"), [], ) - assert asyncio.run(delete_agent_transcript_jsonl(orphan_transcript)) is True + assert asyncio.run(delete_with_maintenance(orphan_transcript)) is True layout = resolve_machine_resource_layout(platform_paths=paths, cwd=tmp_path) result = clean_machine_resources( From 40484eaec4a1305686ded7e35163a3f0890c66fe Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 05:50:04 +0000 Subject: [PATCH 22/44] feat(hosting): retain Linux service observations --- src/loushang/hosting/machine_identity.py | 94 ++++++++++++ src/loushang/hosting/service.py | 36 ++++- src/loushang/hosting/service_group.py | 78 ++++++++++ src/loushang/hosting/service_handoff.py | 6 + src/loushang/hosting/service_process.py | 25 ++- tests/hosting/_pidfd_signal.py | 17 +++ tests/hosting/test_linux_service_observer.py | 55 +++++++ tests/hosting/test_machine_identity.py | 152 +++++++++++++++++++ tests/hosting/test_service_group.py | 73 +++++++++ tests/hosting/test_service_handoff.py | 47 ++++++ tests/hosting/test_service_process.py | 68 ++++++++- 11 files changed, 633 insertions(+), 18 deletions(-) create mode 100644 src/loushang/hosting/machine_identity.py create mode 100644 src/loushang/hosting/service_group.py create mode 100644 tests/hosting/_pidfd_signal.py create mode 100644 tests/hosting/test_machine_identity.py create mode 100644 tests/hosting/test_service_group.py diff --git a/src/loushang/hosting/machine_identity.py b/src/loushang/hosting/machine_identity.py new file mode 100644 index 000000000..5f0002629 --- /dev/null +++ b/src/loushang/hosting/machine_identity.py @@ -0,0 +1,94 @@ +"""Read an application-scoped Linux machine lookup key, never process authority. + +The raw machine ID is confidential: do not return, log or persist it. Read only +the OS-provisioned identity; no hostname, boot-ID, random or DBus-path fallback. +The system administrator remains responsible for unique IDs on cloned hosts. +""" + +from __future__ import annotations + +import os +import re +import stat +import sys +from hashlib import blake2b, sha256 + +from .errors import HostingError, HostingFailureCategory + +_ETC = "/etc" +_ROOT_UID = 0 +_ID = re.compile(rb"[0-9a-f]{32}\n?\Z") +_DOMAIN = re.compile(r"[a-z0-9][a-z0-9./_-]{0,127}\Z") + + +def linux_machine_key(*, domain: str) -> str: + """Return a stable 128-bit domain-separated key without creating files.""" + if type(domain) is not str or _DOMAIN.fullmatch(domain) is None: + raise _error(HostingFailureCategory.INVALID_REQUEST) + if sys.platform != "linux": + raise _error(HostingFailureCategory.PLATFORM_UNSUPPORTED) + flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK + parent = descriptor = None + primary: BaseException | None = None + try: + parent = os.open(_ETC, flags | os.O_DIRECTORY) + _trusted(os.fstat(parent), directory=True) + descriptor = os.open("machine-id", flags, dir_fd=parent) + before = os.fstat(descriptor) + _trusted(before, directory=False) + content = bytearray() + while len(content) < 34: + chunk = os.read(descriptor, 34 - len(content)) + if not chunk: + break + content.extend(chunk) + after = os.fstat(descriptor) + if (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns) != ( + after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns, + ): + raise _error(HostingFailureCategory.PREPARATION_STALE) + raw = bytes(content) + if _ID.fullmatch(raw) is None or raw.rstrip(b"\n") == b"0" * 32: + raise _error(HostingFailureCategory.PREPARATION_REJECTED) + # Fixed domain-derived key, raw 128-bit machine identity input. + return blake2b(bytes.fromhex(raw.decode("ascii")), + key=sha256(domain.encode("ascii")).digest(), digest_size=16).hexdigest() + except OSError: + primary = _error(HostingFailureCategory.PREPARATION_FAILED) + raise primary from None + except BaseException as error: + primary = error + raise + finally: + _close_owned((descriptor, parent), primary=primary) + + +def _trusted(value: os.stat_result, *, directory: bool) -> None: + if (value.st_uid != _ROOT_UID or value.st_mode & 0o022 + or not (stat.S_ISDIR(value.st_mode) if directory else stat.S_ISREG(value.st_mode))): + raise _error(HostingFailureCategory.PREPARATION_REJECTED) + + +def _close_owned(descriptors: tuple[int | None, ...], *, primary: BaseException | None) -> None: + cleanup: BaseException | None = None + for descriptor in descriptors: + if descriptor is None: + continue + try: + os.close(descriptor) + except BaseException as error: + # Attempt each owned fd once even after a lost close receipt. Never + # infer this call's primary exception from the caller's except block. + if primary is not None: + primary.add_note("machine_identity_cleanup_failed") + elif cleanup is None: + cleanup = _error(HostingFailureCategory.CLEANUP_FAILED) if isinstance(error, OSError) else error + if cleanup is not None: + raise cleanup from None + + +def _error(category: HostingFailureCategory) -> HostingError: + return HostingError(category, "machine_identity_" + category.value) + + +__all__ = ["linux_machine_key"] diff --git a/src/loushang/hosting/service.py b/src/loushang/hosting/service.py index 9df612497..c10f0329b 100644 --- a/src/loushang/hosting/service.py +++ b/src/loushang/hosting/service.py @@ -55,6 +55,7 @@ class LinuxServiceObserverV1: _identity: LinuxServiceIdentityV1 _mutex: RLock _closing: Event + _close_unknown: bool def __init__(self) -> None: raise TypeError("use capture or reopen") @@ -78,6 +79,7 @@ def _open(cls, pid: int, expected: LinuxServiceIdentityV1 | None) -> LinuxServic if type(pid) is not int or not 1 <= pid < 2**31: raise _error(HostingFailureCategory.INVALID_REQUEST) descriptor = None + primary: BaseException | None = None try: before = _observe(pid) if expected is not None and before != expected: @@ -92,13 +94,18 @@ def _open(cls, pid: int, expected: LinuxServiceIdentityV1 | None) -> LinuxServic owner._identity = after owner._mutex = RLock() owner._closing = Event() + owner._close_unknown = False descriptor = None return owner except (OSError, UnicodeError): - raise _error(HostingFailureCategory.PREPARATION_FAILED) from None + primary = _error(HostingFailureCategory.PREPARATION_FAILED) + raise primary from None + except BaseException as error: + primary = error + raise finally: if descriptor is not None: - _close_fd(descriptor) + _close_fd(descriptor, primary=primary) @property def identity(self) -> LinuxServiceIdentityV1: @@ -140,9 +147,13 @@ def close(self) -> None: if not self._mutex.acquire(timeout=30): raise _error(HostingFailureCategory.CLEANUP_FAILED) try: + if self._close_unknown: + raise _error(HostingFailureCategory.CLEANUP_FAILED) descriptor, self._fd = self._fd, None if descriptor is not None: + self._close_unknown = True _close_fd(descriptor) + self._close_unknown = False finally: self._mutex.release() @@ -155,6 +166,7 @@ def _observe(pid: int) -> LinuxServiceIdentityV1: boot = _read_file("/proc/sys/kernel/random/boot_id", limit=64).strip().decode("ascii") namespace = os.stat("/proc/self/ns/pid") directory = os.open(f"/proc/{pid}", os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC) + primary: BaseException | None = None try: if os.fstat(directory).st_uid != os.geteuid(): raise _error(HostingFailureCategory.PREPARATION_REJECTED) @@ -163,8 +175,11 @@ def _observe(pid: int) -> LinuxServiceIdentityV1: raise _error(HostingFailureCategory.PREPARATION_REJECTED) ticks = _parse_start_ticks(_read_file("stat", parent=directory), pid) return LinuxServiceIdentityV1(pid, ticks, boot, uid, namespace.st_dev, namespace.st_ino) + except BaseException as error: + primary = error + raise finally: - _close_fd(directory) + _close_fd(directory, primary=primary) def _parse_start_ticks(content: bytes, expected_pid: int) -> int: @@ -235,6 +250,7 @@ def _open_pidfd(pid: int) -> int: def _read_file(path: str, *, parent: int | None = None, limit: int = _PROC_LIMIT) -> bytes: descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=parent) + primary: BaseException | None = None try: content = bytearray() while len(content) <= limit: @@ -245,19 +261,23 @@ def _read_file(path: str, *, parent: int | None = None, limit: int = _PROC_LIMIT if len(content) > limit: raise _error(HostingFailureCategory.READ_BOUND_EXCEEDED) return bytes(content) + except BaseException as error: + primary = error + raise finally: - _close_fd(descriptor) + _close_fd(descriptor, primary=primary) -def _close_fd(descriptor: int) -> None: - primary = sys.exception() +def _close_fd(descriptor: int, *, primary: BaseException | None = None) -> None: try: os.close(descriptor) - except OSError: + except BaseException as error: if primary is not None: primary.add_note("service_observer_cleanup_incomplete") - else: + elif isinstance(error, Exception): raise _error(HostingFailureCategory.CLEANUP_FAILED) from None + else: + raise def _error(category: HostingFailureCategory) -> HostingError: diff --git a/src/loushang/hosting/service_group.py b/src/loushang/hosting/service_group.py new file mode 100644 index 000000000..abf8b1885 --- /dev/null +++ b/src/loushang/hosting/service_group.py @@ -0,0 +1,78 @@ +"""Read-only original Linux service-group observations, never signal authority. + +The borrowed pidfd must be retained through every call. Escaped processes are +excluded: the application must settle its separately owned child resources. +Kernel semantics: https://man7.org/linux/man-pages/man2/kill.2.html and +https://man7.org/linux/man-pages/man2/setsid.2.html . +""" + +from __future__ import annotations + +import os + +from .errors import HostingError, HostingFailureCategory +from .service import LinuxServiceObserverV1 + + +class LinuxServiceGroupObservationV1: + """Borrow one observer and its mutex; allocate no descriptor or close duty.""" + + def __init__(self, observer: LinuxServiceObserverV1) -> None: + if type(observer) is not LinuxServiceObserverV1 or observer.identity.pid <= 1: + raise _error(HostingFailureCategory.INVALID_REQUEST) + self._observer = observer + self._admitted = False + + def _context(self) -> None: + identity = self._observer.identity + namespace = os.stat("/proc/self/ns/pid") + if (os.geteuid() != identity.user_id + or (namespace.st_dev, namespace.st_ino) != ( + identity.pid_namespace_device, identity.pid_namespace_inode)): + raise _error(HostingFailureCategory.PREPARATION_STALE) + + def admit(self) -> None: + observer = self._observer + if not observer._mutex.acquire(blocking=False): + raise _error(HostingFailureCategory.CAPACITY_EXHAUSTED) + try: + if self._admitted: + raise _error(HostingFailureCategory.INVALID_REQUEST) + self._context() + pid = observer.identity.pid + if observer.exited() or os.getpgid(pid) != pid or os.getsid(pid) != pid or observer.exited(): + raise _error(HostingFailureCategory.PREPARATION_STALE) + self._admitted = True + except OSError: + raise _error(HostingFailureCategory.PREPARATION_FAILED) from None + finally: + observer._mutex.release() + + def exited(self) -> bool: + observer = self._observer + if not observer._mutex.acquire(blocking=False): + raise _error(HostingFailureCategory.CAPACITY_EXHAUSTED) + try: + if not self._admitted: + raise _error(HostingFailureCategory.INVALID_REQUEST) + self._context() + if not observer.exited(): + return False + try: + os.killpg(observer.identity.pid, 0) + except ProcessLookupError: + return True + except PermissionError: + return False + return False + except OSError: + raise _error(HostingFailureCategory.PREPARATION_FAILED) from None + finally: + observer._mutex.release() + + +def _error(category: HostingFailureCategory) -> HostingError: + return HostingError(category, "service_group_" + category.value) + + +__all__ = ["LinuxServiceGroupObservationV1"] diff --git a/src/loushang/hosting/service_handoff.py b/src/loushang/hosting/service_handoff.py index 0c1622266..fb2ba90b5 100644 --- a/src/loushang/hosting/service_handoff.py +++ b/src/loushang/hosting/service_handoff.py @@ -66,6 +66,7 @@ def __init__(self, endpoint: socket.socket, port: ServiceHandoffPortV1) -> None: self._closing = Event() self._mutex = RLock() self._closed = False + self._close_started = False def _enter(self, timeout: float = 2.0, deadline: float | None = None) -> float: if type(timeout) not in (int, float) or not 0 <= timeout <= 30: @@ -89,6 +90,11 @@ def close(self) -> None: raise _error(HostingFailureCategory.CLEANUP_FAILED) try: if not self._closed: + if self._close_started: + # The native close may already have relinquished the fd. + # A later socket.close no-op is not cleanup evidence. + raise _error(HostingFailureCategory.CLEANUP_FAILED) + self._close_started = True try: self._endpoint.close() except OSError: diff --git a/src/loushang/hosting/service_process.py b/src/loushang/hosting/service_process.py index 569cad23f..271e8ee64 100644 --- a/src/loushang/hosting/service_process.py +++ b/src/loushang/hosting/service_process.py @@ -82,6 +82,10 @@ def __init__(self, request: ProcessLaunchRequest, child_endpoint: socket.socket) self._identity: LinuxServiceIdentityV1 | None = None self._attempted = False self._handles_closed = False + self._observer_close_unknown = False + self._observer_adoption_unknown = False + self._endpoint_close_unknown = False + self._endpoint_closed = False self._closing = Event() self._mutex = RLock() @@ -130,9 +134,11 @@ def spawn(self) -> LinuxServiceIdentityV1: pass_fds=(self._endpoint.fileno(),), ) self._scope = _PosixProcess(_ServiceChild(self._process)) + self._observer_adoption_unknown = True self._observer = LinuxServiceObserverV1.capture(self._process.pid) + self._observer_adoption_unknown = False self._identity = self._observer.identity - self._endpoint.close() # No parent copy of the child's socket. + self._close_endpoint() # No parent copy of the child's socket. return self._identity except (OSError, subprocess.SubprocessError): raise _error(HostingFailureCategory.SPAWN_FAILED) from None @@ -196,15 +202,19 @@ def close(self) -> None: if self._handles_closed: return primary: BaseException | None = None - if self._observer is not None: + if self._observer_close_unknown or self._observer_adoption_unknown: + primary = _error(HostingFailureCategory.CLEANUP_FAILED) + elif self._observer is not None: + self._observer_close_unknown = True try: self._observer.close() except BaseException as error: primary = error else: self._observer = None + self._observer_close_unknown = False try: - self._endpoint.close() + self._close_endpoint() except BaseException as error: if primary is None: primary = error @@ -217,6 +227,15 @@ def close(self) -> None: finally: self._mutex.release() + def _close_endpoint(self) -> None: + if self._endpoint_close_unknown: + raise _error(HostingFailureCategory.CLEANUP_FAILED) + if not self._endpoint_closed: + self._endpoint_close_unknown = True + self._endpoint.close() + self._endpoint_closed = True + self._endpoint_close_unknown = False + def _error(category: HostingFailureCategory) -> HostingError: return HostingError(category, "service_process_" + category.value) diff --git a/tests/hosting/_pidfd_signal.py b/tests/hosting/_pidfd_signal.py new file mode 100644 index 000000000..e62f894da --- /dev/null +++ b/tests/hosting/_pidfd_signal.py @@ -0,0 +1,17 @@ +"""Test-only exact Linux signal injection; never a numeric-PID fallback.""" + +import ctypes +import signal + + +def send_signal(observer, kind): + send = getattr(signal, "pidfd_send_signal", None) + if send is not None: + send(observer._fd, kind) + return + # Some uv Python builds omit the wrapper on supporting Linux hosts. + native = ctypes.CDLL(None, use_errno=True).pidfd_send_signal + native.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_void_p, ctypes.c_uint] + native.restype = ctypes.c_int + if native(observer._fd, kind, None, 0) != 0: + raise OSError(ctypes.get_errno(), "test pidfd signal failed") diff --git a/tests/hosting/test_linux_service_observer.py b/tests/hosting/test_linux_service_observer.py index 8ebafa77f..de3758a04 100644 --- a/tests/hosting/test_linux_service_observer.py +++ b/tests/hosting/test_linux_service_observer.py @@ -31,6 +31,61 @@ def child(): process.communicate(timeout=5) +def test_close_lost_receipt_inside_outer_exception_retains_unknown(monkeypatch): + observer = LinuxServiceObserverV1.capture(os.getpid()) + descriptor = observer._fd + native_close = os.close + calls = [] + replacement = None + + def close_then_raise(fd): + nonlocal replacement + assert fd == descriptor + calls.append(fd) + native_close(fd) + replacement = os.open("/dev/null", os.O_RDONLY) + if replacement != fd: + os.dup2(replacement, fd) + native_close(replacement) + replacement = fd + raise OSError("lost close receipt") + + try: + with monkeypatch.context() as patch: + patch.setattr(os, "close", close_then_raise) + try: + raise ValueError("unrelated outer exception") + except ValueError: + with pytest.raises(HostingError) as caught: + observer.close() + assert caught.value.category is HostingFailureCategory.CLEANUP_FAILED + with pytest.raises(HostingError): + observer.close() + assert calls == [descriptor] + assert replacement == descriptor + os.fstat(replacement) + finally: + if replacement is not None: + native_close(replacement) + + +def test_close_preserves_own_primary_when_native_cleanup_is_interrupted(monkeypatch): + from loushang.hosting.service import _close_fd + + descriptor = os.open("/dev/null", os.O_RDONLY) + native = os.close + primary = ValueError("original operation") + + def interrupted(fd): + native(fd) + raise KeyboardInterrupt() + + with monkeypatch.context() as patch: + patch.setattr(os, "close", interrupted) + _close_fd(descriptor, primary=primary) + assert primary.__notes__ == ["service_observer_cleanup_incomplete"] + + def test_capture_reopen_and_exit_with_retained_native_identity(child): observer = LinuxServiceObserverV1.capture(child.pid) reopened = LinuxServiceObserverV1.reopen(observer.identity) diff --git a/tests/hosting/test_machine_identity.py b/tests/hosting/test_machine_identity.py new file mode 100644 index 000000000..bb0a865e5 --- /dev/null +++ b/tests/hosting/test_machine_identity.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import os +import sys + +import pytest + +from loushang.hosting import machine_identity as module +from loushang.hosting.errors import HostingError, HostingFailureCategory + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux OS identity") +RAW = b"0123456789abcdef0123456789abcdef" + + +@pytest.fixture +def identity(tmp_path, monkeypatch): + root = tmp_path / "etc" + root.mkdir(mode=0o755) + path = root / "machine-id" + path.write_bytes(RAW + b"\n") + path.chmod(0o444) + monkeypatch.setattr(module, "_ETC", str(root)) + monkeypatch.setattr(module, "_ROOT_UID", os.geteuid()) + return root, path + + +def test_stable_domain_separated_private_key(identity, monkeypatch): + root, path = identity + before = path.read_bytes() + value = module.linux_machine_key(domain="loushang.managed.machine/v1") + monkeypatch.chdir(root) + assert module.linux_machine_key(domain="loushang.managed.machine/v1") == value + assert module.linux_machine_key(domain="another.application/v1") != value + assert len(value) == 32 and int(value, 16) > 0 + assert value != RAW.decode() and path.read_bytes() == before + assert list(root.iterdir()) == [path] + + +@pytest.mark.parametrize("content", [b"", b"uninitialized\n", b"0" * 32, RAW.upper(), RAW + b"\n\n", RAW + b"x", b"a" * 10000]) +def test_rejects_invalid_or_oversized_identity(identity, content): + _, path = identity + path.chmod(0o644) + path.write_bytes(content) + with pytest.raises(HostingError) as caught: + module.linux_machine_key(domain="test/v1") + assert caught.value.category == HostingFailureCategory.PREPARATION_REJECTED + assert RAW.decode() not in str(caught.value) + + +@pytest.mark.parametrize("kind", ["link", "fifo", "directory", "writable_file", "writable_parent", "owner", "missing"]) +def test_rejects_untrusted_native_source(identity, monkeypatch, kind): + root, path = identity + if kind in ("link", "fifo", "directory", "missing"): + path.unlink() + if kind == "link": + target = root / "target" + target.write_bytes(RAW) + path.symlink_to(target) + elif kind == "fifo": + os.mkfifo(path) + elif kind == "directory": + path.mkdir() + elif kind == "writable_file": + path.chmod(0o666) + elif kind == "writable_parent": + root.chmod(0o777) + else: + monkeypatch.setattr(module, "_ROOT_UID", os.geteuid() + 1) + with pytest.raises(HostingError): + module.linux_machine_key(domain="test/v1") + + +def test_invalid_domain_and_platform_fail_before_io(monkeypatch): + monkeypatch.setattr(module.os, "open", lambda *a, **k: pytest.fail("opened identity")) + for domain in (None, "", "a" * 129, "secret\n", "中文"): + with pytest.raises(HostingError) as caught: + module.linux_machine_key(domain=domain) + assert caught.value.category == HostingFailureCategory.INVALID_REQUEST + monkeypatch.setattr(module.sys, "platform", "win32") + with pytest.raises(HostingError) as caught: + module.linux_machine_key(domain="test/v1") + assert caught.value.category == HostingFailureCategory.PLATFORM_UNSUPPORTED + + +def test_short_native_reads_and_metadata_change(identity, monkeypatch): + original_read = os.read + _, path = identity + changed = False + + def read(fd, size): + nonlocal changed + value = original_read(fd, min(size, 3)) + if not changed: + changed = True + path.chmod(0o600) + return value + + monkeypatch.setattr(module.os, "read", read) + with pytest.raises(HostingError) as caught: + module.linux_machine_key(domain="test/v1") + assert caught.value.category == HostingFailureCategory.PREPARATION_STALE + + +def test_primary_read_error_survives_both_close_errors(identity, monkeypatch): + original = os.close + closed = [] + + def close(fd): + closed.append(fd) + original(fd) + raise OSError("private close detail") + + def read(fd, size): + raise OSError("private read detail") + + monkeypatch.setattr(module.os, "close", close) + monkeypatch.setattr(module.os, "read", read) + with pytest.raises(HostingError) as caught: + module.linux_machine_key(domain="test/v1") + assert caught.value.category == HostingFailureCategory.PREPARATION_FAILED + assert len(set(closed)) == len(closed) == 2 + assert caught.value.__notes__ == ["machine_identity_cleanup_failed"] * 2 + + +@pytest.mark.parametrize("outer_exception", [False, True]) +def test_close_error_is_bounded_and_descriptor_not_retried(identity, monkeypatch, outer_exception): + original = os.close + closed = [] + + def close(fd): + closed.append(fd) + original(fd) + if len(closed) == 1: + raise OSError("private native close details") + + monkeypatch.setattr(module.os, "close", close) + def read(): + with pytest.raises(HostingError) as caught: + module.linux_machine_key(domain="test/v1") + return caught + + if outer_exception: + try: + raise ValueError("unrelated caller exception") + except ValueError as outer: + caught = read() + assert not hasattr(outer, "__notes__") + else: + caught = read() + assert caught.value.category == HostingFailureCategory.CLEANUP_FAILED + assert len(closed) == len(set(closed)) == 2 + assert "private" not in str(caught.value) diff --git a/tests/hosting/test_service_group.py b/tests/hosting/test_service_group.py new file mode 100644 index 000000000..c4e5dee35 --- /dev/null +++ b/tests/hosting/test_service_group.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import os +import sys + +import pytest + +from loushang.hosting.errors import HostingError +from loushang.hosting.service_group import LinuxServiceGroupObservationV1 + +from .test_service_process import launched as launched + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux service group observations") + + +def test_original_live_group_then_reaped_exit(launched, monkeypatch): + owner, endpoint, _, _ = launched + group = LinuxServiceGroupObservationV1(owner._observer) + group.admit() + original = os.killpg + probes = [] + + def probe(pid, sig): + assert sig == 0 and pid == owner.identity.pid + probes.append((pid, sig)) + return original(pid, sig) + + monkeypatch.setattr(os, "killpg", probe) + assert not group.exited() and not probes + endpoint.sendall(b"Q") + owner._process.wait(timeout=5) + assert group.exited() and len(probes) == 1 + assert not owner.handles_closed + + +@pytest.mark.parametrize("field", ["getpgid", "getsid"]) +def test_not_a_detached_session_leader_is_rejected(launched, monkeypatch, field): + owner, _, _, _ = launched + monkeypatch.setattr(os, field, lambda pid: pid + 1) + with pytest.raises(HostingError): + LinuxServiceGroupObservationV1(owner._observer).admit() + + +def test_unadmitted_or_closed_borrow_never_probes_numeric_group(launched, monkeypatch): + owner, _, _, _ = launched + group = LinuxServiceGroupObservationV1(owner._observer) + monkeypatch.setattr(os, "killpg", lambda *a: pytest.fail("unadmitted probe")) + with pytest.raises(HostingError): + group.exited() + group.admit() + owner.close() + with pytest.raises(HostingError): + group.exited() + + +@pytest.mark.parametrize("error", [PermissionError, OSError]) +def test_failed_group_probe_never_proves_exit(launched, monkeypatch, error): + owner, endpoint, _, _ = launched + group = LinuxServiceGroupObservationV1(owner._observer) + group.admit() + endpoint.sendall(b"Q") + owner._process.wait(timeout=5) + + def probe(*a): + raise error("private native details") + + with monkeypatch.context() as patch: + patch.setattr(os, "killpg", probe) + if error is PermissionError: + assert not group.exited() + else: + with pytest.raises(HostingError): + group.exited() diff --git a/tests/hosting/test_service_handoff.py b/tests/hosting/test_service_handoff.py index 8263eca97..8d48b06a3 100644 --- a/tests/hosting/test_service_handoff.py +++ b/tests/hosting/test_service_handoff.py @@ -165,6 +165,53 @@ def test_adopted_channels_do_not_leak_to_exec(channels): assert not os.get_inheritable(owner._endpoint.fileno()) +@pytest.mark.parametrize("channel_type", [ServiceParentHandoffV1, ServiceChildHandoffV1]) +def test_native_close_uncertainty_never_retries_reused_descriptor(tmp_path, monkeypatch, channel_type): + peer, endpoint = socket.socketpair() + owner = channel_type(endpoint, Port()) + original = socket.socket._real_close + calls, replacements = [], [] + + def fail_after_release(self): + original(self) + if self is endpoint: + calls.append(1) + replacements.append(os.open(tmp_path, os.O_RDONLY | os.O_DIRECTORY)) + raise OSError("uncertain native socket close") + + try: + with monkeypatch.context() as patch: + patch.setattr(socket.socket, "_real_close", fail_after_release) + for _ in range(2): + with pytest.raises(HostingError) as caught: + owner.close() + assert caught.value.category is HostingFailureCategory.CLEANUP_FAILED + assert calls == [1] and not owner._closed and endpoint.fileno() == -1 + os.fstat(replacements[0]) + finally: + peer.close() + for fd in replacements: + os.close(fd) + + +def test_close_mutex_failure_can_retry_before_native_close(channels, monkeypatch): + owner = channels[0] + original = owner._mutex + + class Denied: + def acquire(self, **kwargs): + return False + + with monkeypatch.context() as patch: + patch.setattr(owner, "_mutex", Denied()) + with pytest.raises(HostingError): + owner.close() + assert not owner._close_started and owner._endpoint.fileno() >= 0 + assert owner._mutex is original + owner.close() + assert owner._closed + + def test_failed_admission_leaves_socket_with_caller(): endpoint = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) try: diff --git a/tests/hosting/test_service_process.py b/tests/hosting/test_service_process.py index 25240c59a..e85c6a56e 100644 --- a/tests/hosting/test_service_process.py +++ b/tests/hosting/test_service_process.py @@ -107,6 +107,31 @@ def test_second_spawn_is_refused(launched): assert caught.value.category is HostingFailureCategory.SPAWN_FAILED +def test_endpoint_close_lost_receipt_never_becomes_clean(tmp_path, monkeypatch): + parent, child = socket.socketpair() + owner = LinuxServiceProcessV1(_request(tmp_path, child.fileno()), child) + native = socket.socket.close + calls = [] + + def lose_receipt(endpoint): + native(endpoint) + if endpoint is child: + calls.append(endpoint) + raise OSError("lost endpoint close receipt") + + try: + with monkeypatch.context() as patch: + patch.setattr(socket.socket, "close", lose_receipt) + for _ in range(2): + with pytest.raises(HostingError): + owner.close() + assert not owner.handles_closed + assert calls == [child] and child.fileno() == -1 + finally: + parent.close() + native(child) + + def test_close_before_spawn_fences_native_creation(tmp_path, monkeypatch): parent, child = socket.socketpair() owner = LinuxServiceProcessV1(_request(tmp_path, child.fileno()), child) @@ -144,7 +169,9 @@ def test_failed_spawn_is_unknown_not_a_retry_or_settlement_receipt(tmp_path): owner.close() -def test_failed_identity_capture_keeps_attached_process_owner(tmp_path, monkeypatch): +@pytest.mark.parametrize("lost_cleanup", [False, True]) +def test_failed_identity_capture_keeps_attached_process_owner(tmp_path, monkeypatch, lost_cleanup): + import loushang.hosting.service as service import loushang.hosting.service_process as module parent, child = socket.socketpair() @@ -154,7 +181,25 @@ def test_failed_identity_capture_keeps_attached_process_owner(tmp_path, monkeypa def fail(pid): raise HostingError(HostingFailureCategory.PREPARATION_FAILED, "injected") - monkeypatch.setattr(module.LinuxServiceObserverV1, "capture", fail) + if lost_cleanup: + original_set = os.set_inheritable + original_close = os.close + captured = [] + + def fail_after_allocation(fd, inheritable): + captured.append(fd) + original_set(fd, inheritable) + raise OSError("capture failed after allocation") + + def lose_close(fd): + original_close(fd) + if fd in captured: + raise OSError("temporary pidfd close receipt lost") + + monkeypatch.setattr(service.os, "set_inheritable", fail_after_allocation) + monkeypatch.setattr(service.os, "close", lose_close) + else: + monkeypatch.setattr(module.LinuxServiceObserverV1, "capture", fail) try: with pytest.raises(HostingError): owner.spawn() @@ -164,7 +209,9 @@ def fail(pid): assert not owner.scope_exited() finally: parent.close() - owner.close() + with pytest.raises(HostingError): + owner.close() + assert child.fileno() == -1 and not owner.handles_closed assert owner.wait_scope(timeout=8) @@ -269,6 +316,7 @@ def create(*args, **kwargs): def test_leader_exit_is_not_settlement_of_a_living_descendant(tmp_path): + from loushang.hosting.service_group import LinuxServiceGroupObservationV1 parent, child = socket.socketpair() parent.settimeout(8) code = r''' @@ -291,10 +339,13 @@ def test_leader_exit_is_not_settlement_of_a_living_descendant(tmp_path): try: owner.spawn() assert parent.recv(1) == b"R" + group = LinuxServiceGroupObservationV1(owner._observer) + group.admit() parent.sendall(b"G") assert parent.recv(1) == b"D" assert owner._process.wait(timeout=5) == 0 assert owner.leader_exited() + assert not group.exited() assert not owner.scope_exited() owner.close() assert owner.handles_closed @@ -348,6 +399,7 @@ def identity(self): def close(self): if self.fail: self.fail = False + self.observer.close() raise OSError("injected observer close failure") self.observer.close() @@ -366,13 +418,15 @@ def close(self): assert child.fileno() == -1 assert owner._observer is not None assert not owner.handles_closed - owner.close() - assert owner._observer is None - assert owner.handles_closed + with pytest.raises(HostingError): + owner.close() + assert owner._observer is not None + assert not owner.handles_closed assert not owner.leader_exited() finally: parent.close() - owner.close() + with pytest.raises(HostingError): + owner.close() assert owner.wait_scope(timeout=8) From a707b53403b240fdd644f43456b83d154a93165c Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 05:52:24 +0000 Subject: [PATCH 23/44] feat(apphost): own managed service state --- src/loushang/apphost/managed/_database.py | 229 ++++- src/loushang/apphost/managed/_files.py | 945 ++++++++++++++++-- .../apphost/managed/admission_record.py | 165 +++ src/loushang/apphost/managed/connection.py | 297 ++++++ src/loushang/apphost/managed/contracts.py | 9 + src/loushang/apphost/managed/defaults.py | 69 ++ src/loushang/apphost/managed/discovery.py | 259 +++++ src/loushang/apphost/managed/invocation.py | 138 +++ src/loushang/apphost/managed/layout.py | 107 ++ src/loushang/apphost/managed/lifecycle.py | 352 ++++++- .../apphost/managed/namespace_admission.py | 236 +++++ src/loushang/apphost/managed/paths.py | 82 +- src/loushang/apphost/managed/registry.py | 200 +++- .../apphost/managed/service_admission.py | 193 ++++ src/loushang/apphost/managed/starter.py | 211 ++++ src/loushang/appserver/managed_mux.py | 60 ++ src/loushang/appserver/managed_mux_close.py | 78 ++ tests/apphost/test_managed_admission.py | 350 +++++++ .../apphost/test_managed_admission_record.py | 107 ++ tests/apphost/test_managed_connection.py | 522 ++++++++++ .../test_managed_creation_inspection.py | 79 ++ tests/apphost/test_managed_data_creation.py | 268 +++++ tests/apphost/test_managed_data_isolation.py | 185 ++++ tests/apphost/test_managed_data_read.py | 68 ++ tests/apphost/test_managed_data_removal.py | 226 +++++ tests/apphost/test_managed_defaults.py | 92 ++ tests/apphost/test_managed_discovery.py | 246 +++++ .../test_managed_discovery_snapshot.py | 122 +++ tests/apphost/test_managed_files.py | 252 ++++- tests/apphost/test_managed_invocation.py | 155 +++ tests/apphost/test_managed_layout.py | 344 +++++++ tests/apphost/test_managed_lifecycle.py | 2 +- tests/apphost/test_managed_lock_wait.py | 197 ++++ .../test_managed_namespace_admission.py | 455 +++++++++ tests/apphost/test_managed_paths.py | 25 +- tests/apphost/test_managed_registry.py | 171 +++- .../apphost/test_managed_service_admission.py | 502 ++++++++++ tests/apphost/test_managed_service_aliases.py | 175 ++++ tests/apphost/test_managed_starter.py | 368 +++++++ tests/apphost/test_managed_transitions.py | 232 +++++ .../test_managed_mux_close_values.py | 48 + 41 files changed, 8659 insertions(+), 162 deletions(-) create mode 100644 src/loushang/apphost/managed/admission_record.py create mode 100644 src/loushang/apphost/managed/connection.py create mode 100644 src/loushang/apphost/managed/defaults.py create mode 100644 src/loushang/apphost/managed/discovery.py create mode 100644 src/loushang/apphost/managed/invocation.py create mode 100644 src/loushang/apphost/managed/layout.py create mode 100644 src/loushang/apphost/managed/namespace_admission.py create mode 100644 src/loushang/apphost/managed/service_admission.py create mode 100644 src/loushang/apphost/managed/starter.py create mode 100644 src/loushang/appserver/managed_mux.py create mode 100644 src/loushang/appserver/managed_mux_close.py create mode 100644 tests/apphost/test_managed_admission.py create mode 100644 tests/apphost/test_managed_admission_record.py create mode 100644 tests/apphost/test_managed_connection.py create mode 100644 tests/apphost/test_managed_creation_inspection.py create mode 100644 tests/apphost/test_managed_data_creation.py create mode 100644 tests/apphost/test_managed_data_isolation.py create mode 100644 tests/apphost/test_managed_data_read.py create mode 100644 tests/apphost/test_managed_data_removal.py create mode 100644 tests/apphost/test_managed_defaults.py create mode 100644 tests/apphost/test_managed_discovery.py create mode 100644 tests/apphost/test_managed_discovery_snapshot.py create mode 100644 tests/apphost/test_managed_invocation.py create mode 100644 tests/apphost/test_managed_layout.py create mode 100644 tests/apphost/test_managed_lock_wait.py create mode 100644 tests/apphost/test_managed_namespace_admission.py create mode 100644 tests/apphost/test_managed_service_admission.py create mode 100644 tests/apphost/test_managed_service_aliases.py create mode 100644 tests/apphost/test_managed_starter.py create mode 100644 tests/apphost/test_managed_transitions.py create mode 100644 tests/appserver/test_managed_mux_close_values.py diff --git a/src/loushang/apphost/managed/_database.py b/src/loushang/apphost/managed/_database.py index 5ad2a4946..c483bb83a 100644 --- a/src/loushang/apphost/managed/_database.py +++ b/src/loushang/apphost/managed/_database.py @@ -13,14 +13,16 @@ from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path +from secrets import token_hex from time import monotonic from ._files import ( ManagedStorageError, PrivateManagedDirectory, _check_deadline, - _close_preserving_primary, + _check_lock_wait, ) +from .contracts import _HEX32, ManagedContractError, _match DATABASE_NAME = "registry.sqlite3" DATABASE_LIMIT = 32 * 1024 * 1024 @@ -31,43 +33,129 @@ PAGE_SIZE = 4096 _LOCK = "registry.lock" _APPLICATION_ID = 0x4C4D5558 -_VERSION = 3 +_VERSION = 14 +_TEMPORARY_ID_PREFIX = "74656d7000000000" +_MAX_TEMPORARY_SEQUENCE = 2**63 - 1 _SCHEMA = ( - "CREATE TABLE identity (namespace TEXT PRIMARY KEY NOT NULL) WITHOUT ROWID", + "CREATE TABLE identity (namespace TEXT PRIMARY KEY NOT NULL, deployment TEXT NOT NULL, " + "service_admission INTEGER NOT NULL CHECK(service_admission IN (0, 1)), " + "temporary_high_water INTEGER NOT NULL CHECK(typeof(temporary_high_water)='integer' " + "AND temporary_high_water BETWEEN 0 AND 9223372036854775807)) WITHOUT ROWID", "CREATE TABLE services (service_id TEXT PRIMARY KEY NOT NULL, " "product TEXT NOT NULL, workspace TEXT NOT NULL, profile TEXT NOT NULL, " "UNIQUE(product, workspace, profile)) WITHOUT ROWID", "CREATE TABLE muxes (name TEXT PRIMARY KEY NOT NULL, " "service_id TEXT NOT NULL REFERENCES services(service_id), " - "operation_id TEXT NOT NULL UNIQUE) WITHOUT ROWID", + "operation_id TEXT NOT NULL UNIQUE, " + "FOREIGN KEY(name, service_id, operation_id) " + "REFERENCES mux_intents(name, service_id, operation_id)) WITHOUT ROWID", "CREATE TABLE instances (service_id TEXT PRIMARY KEY NOT NULL REFERENCES services(service_id), " "revision INTEGER NOT NULL CHECK(revision>0), instance_id TEXT NOT NULL, " "attempt_id TEXT NOT NULL, phase TEXT NOT NULL, stop_requested INTEGER NOT NULL, " "process_exited INTEGER NOT NULL, application_cleanup_completed INTEGER NOT NULL, " "process_scope_settled INTEGER NOT NULL, native_identity TEXT " "CHECK(native_identity IS NULL OR (typeof(native_identity)='text' " - "AND length(native_identity)<=1024))) WITHOUT ROWID", + "AND length(native_identity)<=1024)), trace_application TEXT " + "CHECK(trace_application IS NULL OR (typeof(trace_application)='text' AND length(trace_application)<=512)), " + "UNIQUE(service_id, instance_id, attempt_id)) WITHOUT ROWID", + "CREATE TABLE service_controls (service_id TEXT PRIMARY KEY NOT NULL REFERENCES services(service_id), " + "record TEXT NOT NULL CHECK(typeof(record)='text' AND length(record)<=4096)) WITHOUT ROWID", + "CREATE TABLE mux_authorities (operation_id TEXT PRIMARY KEY NOT NULL REFERENCES mux_intents(operation_id), " + "instance_id TEXT NOT NULL CHECK(typeof(instance_id)='text' AND length(instance_id)=32), " + "origin_instance_id TEXT NOT NULL CHECK(typeof(origin_instance_id)='text' AND length(origin_instance_id)=32), " + "authority TEXT NOT NULL CHECK(typeof(authority)='text' AND length(authority)=64), " + "created_instance_id TEXT, mux_space_id TEXT, " + "CHECK((created_instance_id IS NULL AND mux_space_id IS NULL) OR " + "(typeof(created_instance_id)='text' AND length(created_instance_id)=32 " + "AND typeof(mux_space_id)='text' AND length(mux_space_id) BETWEEN 1 AND 512))) WITHOUT ROWID", + "CREATE TABLE mux_intents (name TEXT NOT NULL, " + "service_id TEXT NOT NULL REFERENCES services(service_id), " + "operation_id TEXT PRIMARY KEY NOT NULL, " + "UNIQUE(name, service_id, operation_id)) WITHOUT ROWID", + "CREATE TABLE mux_close_authorities (operation_id TEXT PRIMARY KEY NOT NULL, " + "creation_operation_id TEXT NOT NULL UNIQUE REFERENCES mux_authorities(operation_id), " + "instance_id TEXT NOT NULL CHECK(typeof(instance_id)='text' AND length(instance_id)=32), " + "origin_instance_id TEXT NOT NULL CHECK(typeof(origin_instance_id)='text' AND length(origin_instance_id)=32), " + "authority TEXT NOT NULL CHECK(typeof(authority)='text' AND length(authority)=64), " + "phase TEXT CHECK(phase IS NULL OR phase IN ('cleanup_pending', 'closed'))) WITHOUT ROWID", + "CREATE TABLE service_transitions (service_id TEXT PRIMARY KEY NOT NULL, " + "instance_id TEXT NOT NULL, attempt_id TEXT NOT NULL, " + "started_revision INTEGER NOT NULL CHECK(started_revision>0), " + "previous TEXT CHECK(previous IS NULL OR (typeof(previous)='text' AND length(previous)<=4096)), " + "FOREIGN KEY(service_id, instance_id, attempt_id) REFERENCES instances(service_id, instance_id, attempt_id) " + "DEFERRABLE INITIALLY DEFERRED) WITHOUT ROWID", + "CREATE TABLE storage_allocations (allocation_id TEXT PRIMARY KEY NOT NULL, " + "service_id TEXT NOT NULL REFERENCES services(service_id), kind TEXT NOT NULL, " + "scope TEXT NOT NULL, slot INTEGER NOT NULL, capacity INTEGER NOT NULL, " + "root_key TEXT NOT NULL CHECK(typeof(root_key)='text' AND length(root_key)=64), " + "root_device INTEGER NOT NULL CHECK(typeof(root_device)='integer' AND root_device>=0), " + "root_inode INTEGER NOT NULL CHECK(typeof(root_inode)='integer' AND root_inode>0), " + "file_device INTEGER, file_inode INTEGER, UNIQUE(service_id, kind, scope, slot), " + "CHECK(typeof(allocation_id)='text' AND length(allocation_id)=32), " + "CHECK(typeof(slot)='integer' AND typeof(capacity)='integer' AND " + "((kind='log' AND scope='' AND slot BETWEEN 0 AND 4 AND capacity=10485760) OR " + "(kind='trace' AND scope='' AND slot BETWEEN 0 AND 1 AND capacity=10485760) OR " + "(kind='temporary' AND typeof(scope)='text' AND length(scope)=32 AND slot BETWEEN 0 AND 255 " + "AND capacity BETWEEN 4096 AND 134217728 AND capacity%4096=0))), " + "CHECK((file_device IS NULL AND file_inode IS NULL) OR " + "(typeof(file_device)='integer' AND file_device>=0 AND typeof(file_inode)='integer' AND file_inode>0))) WITHOUT ROWID", + "CREATE TABLE service_aliases (name TEXT PRIMARY KEY NOT NULL, " + "service_id TEXT NOT NULL UNIQUE REFERENCES services(service_id), " + "operation_id TEXT NOT NULL UNIQUE CHECK(typeof(operation_id)='text' AND length(operation_id)=32), " + "CHECK(typeof(name)='text' AND length(name) BETWEEN 1 AND 64)) WITHOUT ROWID", + "CREATE TABLE storage_creation_origins (allocation_id TEXT PRIMARY KEY NOT NULL " + "REFERENCES storage_allocations(allocation_id) ON DELETE CASCADE, " + "origin_id TEXT NOT NULL CHECK(typeof(origin_id)='text' AND length(origin_id)=32 " + "AND origin_id NOT GLOB '*[^0-9a-f]*')) WITHOUT ROWID", ) _SCHEMA_ROWS = tuple(sorted(( ("table", "identity", "identity", _SCHEMA[0]), ("table", "services", "services", _SCHEMA[1]), ("table", "muxes", "muxes", _SCHEMA[2]), ("table", "instances", "instances", _SCHEMA[3]), + ("table", "service_controls", "service_controls", _SCHEMA[4]), + ("table", "mux_authorities", "mux_authorities", _SCHEMA[5]), + ("table", "mux_intents", "mux_intents", _SCHEMA[6]), + ("table", "mux_close_authorities", "mux_close_authorities", _SCHEMA[7]), + ("table", "service_transitions", "service_transitions", _SCHEMA[8]), + ("table", "storage_allocations", "storage_allocations", _SCHEMA[9]), + ("table", "service_aliases", "service_aliases", _SCHEMA[10]), + ("table", "storage_creation_origins", "storage_creation_origins", _SCHEMA[11]), ))) class ManagedDatabase: """Short-lived connections, strict schema, no automatic format migration.""" - def __init__(self, root: Path, namespace: str, *, create: bool = False) -> None: + def __init__(self, root: Path, namespace: str, *, create: bool = False, defer_open: bool = False, + exclusive_create: bool = False, create_parents: bool = False, + deployment_id: str | None = None, service_admission_required: bool | None = None) -> None: if sqlite3.threadsafety == 0: raise ManagedStorageError("unsupported") - self._directory = PrivateManagedDirectory(root, create=create) + if type(defer_open) is not bool: + raise ManagedStorageError("invalid_record") + if deployment_id is not None: + _match(deployment_id, _HEX32) + if service_admission_required is not None and type(service_admission_required) is not bool: + raise ManagedContractError() + self._directory = PrivateManagedDirectory(root, create=create, defer_open=True, + exclusive_create=exclusive_create, + create_parents=create_parents) self._namespace = namespace + self._create = create + self._exclusive_create = exclusive_create + self._deployment_id = deployment_id + self._new_deployment_id = deployment_id or token_hex(16) + self._service_admission_required = service_admission_required + self._new_service_admission_required = bool(service_admission_required) + self._file_identity: tuple[int, int] | None = None + self._lock_identity: tuple[int, int] | None = None + self._attempted = self._opened = self._closing = False self._cleanup_connection: sqlite3.Connection | None = None + if defer_open: + return try: - with self._connection(create=create, read_only=not create): - pass + self.open() except BaseException as error: try: self.close() @@ -75,8 +163,23 @@ def __init__(self, root: Path, namespace: str, *, create: bool = False) -> None: error.add_note("managed_database_cleanup_incomplete") raise + def open(self, *, deadline: float | None = None, wait_for_lock: bool = False) -> None: + """Admit once after the caller has retained this container.""" + _check_deadline(deadline) + _check_lock_wait(wait_for_lock, deadline) + if self._attempted or self._closing: + raise ManagedStorageError("closed") + self._attempted = True + self._directory.open(deadline=deadline) + with self._connection(create=self._create, read_only=not self._create, deadline=deadline, + wait_for_lock=wait_for_lock): + pass + _check_deadline(deadline) + self._opened = True + def close(self) -> None: with self._directory._mutex: + self._closing = True if self._cleanup_connection is not None: try: self._cleanup_connection.close() @@ -87,7 +190,19 @@ def close(self) -> None: @property def cleanup_pending(self) -> bool: - return self._cleanup_connection is not None + return self._cleanup_connection is not None or self._directory.cleanup_pending + + @property + def deployment_id(self) -> str: + if not self._opened or self._closing or self._deployment_id is None: + raise ManagedStorageError("closed") + return self._deployment_id + + @property + def service_admission_required(self) -> bool: + if not self._opened or self._closing or self._service_admission_required is None: + raise ManagedStorageError("closed") + return self._service_admission_required def _admit_files(self, parent: int) -> None: limits = {_LOCK: 0, DATABASE_NAME: DATABASE_LIMIT, @@ -106,17 +221,28 @@ def _admit_files(self, parent: int) -> None: @contextmanager def _connection( self, *, create: bool = False, read_only: bool = False, deadline: float | None = None, + wait_for_lock: bool = False, ) -> Iterator[sqlite3.Connection]: directory = self._directory connection = None guard = None - with directory.lock(_LOCK, create=create, deadline=deadline), directory._operation(deadline=deadline) as parent: + with directory.lock(_LOCK, create=create, deadline=deadline, + exclusive_create=create and self._exclusive_create, + wait_for_lock=wait_for_lock), directory._operation(deadline=deadline) as parent: if self.cleanup_pending: raise ManagedStorageError("busy") try: + lock_identity = directory._locks[_LOCK][1] + if self._lock_identity is not None and self._lock_identity != lock_identity: + raise ManagedStorageError("conflict") self._admit_files(parent) try: - guard = directory._open(DATABASE_NAME, os.O_RDONLY if read_only else os.O_RDWR) + if create and self._exclusive_create: + guard = directory._open(DATABASE_NAME, os.O_RDWR | os.O_CREAT | os.O_EXCL, create=True) + else: + guard = directory._open(DATABASE_NAME, os.O_RDONLY if read_only else os.O_RDWR) + except FileExistsError: + raise ManagedStorageError("conflict") from None except FileNotFoundError: if not create: raise @@ -124,12 +250,15 @@ def _connection( DATABASE_NAME, os.O_RDWR | os.O_CREAT | os.O_EXCL, create=True, ) identity = directory._validate(os.fstat(guard)) + if self._file_identity is not None and self._file_identity != identity: + raise ManagedStorageError("conflict") # Root identity remains guarded while SQLite uses the retained # directory path. mode=rw prohibits SQLite from inventing a DB. mode = "ro" if read_only else "rw" uri = f"file:/proc/self/fd/{parent}/{DATABASE_NAME}?mode={mode}" # Every connection use/close, including debt retries on another # worker thread, is serialized by the directory mutex. + _check_deadline(deadline) connection = sqlite3.connect(uri, uri=True, timeout=0, isolation_level=None, check_same_thread=False) directory._check_named(DATABASE_NAME, identity) @@ -165,19 +294,27 @@ def _connection( connection.execute("BEGIN IMMEDIATE") for statement in _SCHEMA: connection.execute(statement) - connection.execute("INSERT INTO identity VALUES (?)", (self._namespace,)) + connection.execute("INSERT INTO identity VALUES (?, ?, ?, 0)", + (self._namespace, self._new_deployment_id, + int(self._new_service_admission_required))) connection.execute(f"PRAGMA application_id={_APPLICATION_ID}") connection.execute(f"PRAGMA user_version={_VERSION}") + directory._sync_pending.add(parent) connection.commit() os.fsync(parent) + directory._sync_pending.discard(parent) elif header != (_APPLICATION_ID, _VERSION): raise ManagedStorageError("invalid_record") self._validate_schema(connection) directory._check_named(DATABASE_NAME, identity) + directory._check_named(_LOCK, lock_identity) self._admit_files(parent) _check_deadline(deadline) + self._file_identity = identity + self._lock_identity = lock_identity yield connection directory._check_named(DATABASE_NAME, identity) + directory._check_named(_LOCK, lock_identity) self._admit_files(parent) except sqlite3.Error as error: code = "busy" if getattr(error, "sqlite_errorcode", 0) in { @@ -196,8 +333,9 @@ def _connection( self._cleanup_connection = connection if guard is not None: try: - _close_preserving_primary(guard) - except ManagedStorageError: + os.close(guard) + except OSError: + directory._uncertain_closes.add(guard) failed = True if failed: if primary is not None: @@ -208,17 +346,63 @@ def _connection( def _validate_schema(self, connection: sqlite3.Connection) -> None: rows = connection.execute( "SELECT type, name, tbl_name, sql FROM sqlite_schema " - "WHERE sql IS NOT NULL ORDER BY name LIMIT 8" + "WHERE sql IS NOT NULL ORDER BY name LIMIT 13" ).fetchall() if tuple(sorted(rows)) != _SCHEMA_ROWS: raise ManagedStorageError("invalid_record") - if connection.execute("SELECT namespace FROM identity LIMIT 2").fetchall() != [ - (self._namespace,), - ]: + if (connection.execute("SELECT count(*) FROM storage_creation_origins").fetchone()[0] > 4096 + or connection.execute("SELECT 1 FROM storage_creation_origins AS o LEFT JOIN storage_allocations AS a " + "ON a.allocation_id=o.allocation_id WHERE a.allocation_id IS NULL OR a.kind!='temporary' " + "OR typeof(o.origin_id)!='text' OR length(o.origin_id)!=32 " + "OR o.origin_id GLOB '*[^0-9a-f]*' LIMIT 1").fetchone() is not None): + raise ManagedStorageError("invalid_record") + identities = connection.execute("SELECT namespace, deployment, service_admission, temporary_high_water " + "FROM identity LIMIT 2").fetchall() + if len(identities) != 1 or identities[0][0] != self._namespace: + raise ManagedStorageError("conflict") + high_water = identities[0][3] + if type(high_water) is not int or not 0 <= high_water <= _MAX_TEMPORARY_SEQUENCE: + raise ManagedStorageError("invalid_record") + if connection.execute( + "SELECT 1 FROM storage_allocations WHERE " + "allocation_id GLOB '*[^0-9a-f]*' OR " + "(kind='temporary' AND (substr(allocation_id,1,16)!=? OR allocation_id<=? OR allocation_id>?)) " + "OR (kind!='temporary' AND substr(allocation_id,1,16)=?) LIMIT 1", + (_TEMPORARY_ID_PREFIX, _TEMPORARY_ID_PREFIX + "0" * 16, + _TEMPORARY_ID_PREFIX + f"{high_water:016x}", _TEMPORARY_ID_PREFIX), + ).fetchone() is not None: + raise ManagedStorageError("invalid_record") + deployment = identities[0][1] + try: + _match(deployment, _HEX32) + except ManagedContractError: + raise ManagedStorageError("invalid_record") from None + if self._deployment_id is not None and self._deployment_id != deployment: + raise ManagedStorageError("conflict") + self._deployment_id = deployment + required = identities[0][2] + if type(required) is not int or required not in (0, 1): + raise ManagedStorageError("invalid_record") + if (self._service_admission_required is not None + and self._service_admission_required != bool(required)): raise ManagedStorageError("conflict") + self._service_admission_required = bool(required) if ( connection.execute("SELECT count(*) FROM services").fetchone()[0] > 128 + or connection.execute("SELECT count(*) FROM service_aliases").fetchone()[0] > 128 + or connection.execute("SELECT count(*) FROM service_transitions").fetchone()[0] > 128 or connection.execute("SELECT count(*) FROM muxes").fetchone()[0] > 4096 + or connection.execute("SELECT count(*) FROM mux_intents").fetchone()[0] > 4096 + or connection.execute("SELECT count(*) FROM mux_authorities").fetchone()[0] > 4096 + or connection.execute("SELECT count(*) FROM mux_close_authorities").fetchone()[0] > 4096 + or connection.execute("SELECT count(*) FROM storage_allocations").fetchone()[0] > 4096 + or connection.execute("SELECT coalesce(sum(capacity),0) FROM storage_allocations " + "WHERE kind IN ('log','trace')").fetchone()[0] > 200 * 1024 * 1024 + or connection.execute("SELECT coalesce(sum(capacity),0) FROM storage_allocations " + "WHERE kind='temporary'").fetchone()[0] > 512 * 1024 * 1024 + or connection.execute("SELECT 1 FROM storage_allocations WHERE kind='temporary' " + "GROUP BY service_id,scope HAVING sum(capacity)>134217728 LIMIT 1").fetchone() is not None + or connection.execute("SELECT 1 FROM mux_intents JOIN mux_close_authorities USING(operation_id) LIMIT 1").fetchone() is not None or connection.execute("PRAGMA foreign_key_check").fetchone() is not None ): raise ManagedStorageError("invalid_record") @@ -243,8 +427,11 @@ def _admit_capacity( raise ManagedStorageError("capacity") @contextmanager - def transaction(self, *, write: bool = False, deadline: float | None = None) -> Iterator[sqlite3.Connection]: - with self._connection(read_only=not write, deadline=deadline) as connection: + def transaction(self, *, write: bool = False, deadline: float | None = None, + wait_for_lock: bool = False) -> Iterator[sqlite3.Connection]: + if not self._opened or self._closing: + raise ManagedStorageError("closed") + with self._connection(read_only=not write, deadline=deadline, wait_for_lock=wait_for_lock) as connection: if write: connection.execute("BEGIN IMMEDIATE") else: diff --git a/src/loushang/apphost/managed/_files.py b/src/loushang/apphost/managed/_files.py index 5a326225d..c9a7b46cc 100644 --- a/src/loushang/apphost/managed/_files.py +++ b/src/loushang/apphost/managed/_files.py @@ -1,31 +1,40 @@ """Linux private-file owner for managed deployment storage, with no domain policy. -The caller injects a narrow canonical directory whose parent already exists. +The caller injects a narrow canonical directory. Parents must exist unless +explicit parent creation is requested for new deployment initialization. Stable lock files are never unlinked. All IO is relative to a retained directory descriptor, with path/descriptor identity checked around admitted operations. """ from __future__ import annotations +import errno import os import re import stat import sys from collections.abc import Iterator -from contextlib import contextmanager +from contextlib import contextmanager, suppress from dataclasses import dataclass, field +from hashlib import sha256 from importlib import import_module from math import isfinite from pathlib import Path from secrets import token_hex from threading import RLock -from time import monotonic +from time import monotonic, sleep +from typing import Protocol _NAME = re.compile(r"[a-z0-9][a-z0-9._-]{0,95}\Z") MAX_RECORD_BYTES = 16 * 1024 FileIdentity = tuple[int, int] +class _DirectoryScan(Protocol): + def __iter__(self) -> Iterator[os.DirEntry[str]]: ... + def close(self) -> None: ... + + class ManagedStorageError(RuntimeError): """Closed error codes, never native paths or file content.""" @@ -43,10 +52,73 @@ class ManagedFileSnapshot: identity: FileIdentity +@dataclass(frozen=True, slots=True) +class ManagedDataFileSnapshot: + """Bounded tail and change stamp; neither allocation nor write authority.""" + + identity: FileIdentity + size: int + mtime_ns: int + ctime_ns: int + tail: bytes = field(repr=False) + + +@dataclass(eq=False, slots=True) +class _DataRemoval: + owner: PrivateManagedDirectory = field(repr=False) + name: str + isolated_name: str + expected: ManagedDataFileSnapshot + capacity: int + root_identity: FileIdentity + target: ManagedRemovalTarget + phase: str = "new" + fd: int | None = None + isolated: ManagedDataFileSnapshot | None = None + abandoned: bool = False + binding: object | None = field(default=None, repr=False) + _completion: object | None = field(default=None, repr=False) + + +@dataclass(frozen=True, slots=True) +class ManagedRemovalTarget: + """Native owner's frozen facts; not a caller-supplied accounting permit.""" + + root_key: str + root_identity: FileIdentity + file_identity: FileIdentity + capacity: int + + +@dataclass(eq=False, slots=True) +class _DataCreation: + owner: PrivateManagedDirectory = field(repr=False) + allocation_id: str + name: str + capacity: int + binding: object = field(repr=False) + target: ManagedCreationTarget + phase: str = "new" + snapshot: ManagedDataFileSnapshot | None = None + _completion: object | None = field(default=None, repr=False) + + +@dataclass(frozen=True, slots=True) +class ManagedCreationTarget: + """Original native destination, independent of unbound accounting rows.""" + + root_key: str + root_identity: FileIdentity + allocation_id: str + name: str + capacity: int + + class PrivateManagedDirectory: """One retained directory; not an authority to recursively clean its parent.""" - def __init__(self, root: Path, *, create: bool = False) -> None: + def __init__(self, root: Path, *, create: bool = False, defer_open: bool = False, + create_parents: bool = False, exclusive_create: bool = False) -> None: self._fd: int | None = None self._parents: list[tuple[int, str, int, FileIdentity]] = [] self._anchor: int | None = None @@ -56,53 +128,141 @@ def __init__(self, root: Path, *, create: bool = False) -> None: self._unlinked: set[str] = set() self._mutex = RLock() self._root = root + self._create = create + self._opening_fd: int | None = None + self._uncertain_closes: set[int] = set() + self._close_pending: set[int] = set() + self._sync_pending: set[int] = set() + self._data_sync_pending: set[int] = set() + self._data_failed = False + self._removals: list[_DataRemoval] = [] + self._creations: list[_DataCreation] = [] + self._creation_high_water = "" + self._scan: _DirectoryScan | None = None + self._scan_close_unknown = False + self._create_parents = create_parents + self._exclusive_create = exclusive_create + self._attempted = self._opened = self._closing = False if sys.platform != "linux": raise ManagedStorageError("unsupported") - if type(create) is not bool or not isinstance(root, Path): + if (type(create) is not bool or type(defer_open) is not bool or not isinstance(root, Path) + or type(create_parents) is not bool or (create_parents and not create) + or type(exclusive_create) is not bool or (exclusive_create and not create)): raise ManagedStorageError("unavailable") + if create_parents: + try: + if len(root.parts) > 64 or len(str(root).encode("utf-8")) > 4096: + raise ManagedStorageError("capacity") + except UnicodeError: + raise ManagedStorageError("unavailable") from None + if ( + not root.is_absolute() or root == root.parent + or ".." in root.parts or str(root).startswith("//") or "\0" in str(root) + ): + raise ManagedStorageError("unavailable") + if defer_open: + return try: - if ( - not root.is_absolute() or root == root.parent - or ".." in root.parts or str(root).startswith("//") - or "\0" in str(root) - ): - raise ManagedStorageError("unavailable") + self.open() + except BaseException as error: + try: + self.close() + except BaseException: + error.add_note("managed_directory_cleanup_incomplete") + raise + + def open(self, *, deadline: float | None = None) -> None: + """One admission attempt on a previously retained container. + + Failure retains all acquired descriptors. Explicit close is required; + neither a failed admission nor a partial close permits reopening. + """ + _check_deadline(deadline) + acquired = (self._mutex.acquire() if deadline is None else + self._mutex.acquire(timeout=max(0.0, min(30.0, deadline - monotonic())))) + if not acquired: + raise ManagedStorageError("busy") + try: + if self._attempted or self._closing: + raise ManagedStorageError("closed") + _check_deadline(deadline) + self._attempted = True + root = self._root flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC self._anchor = os.open("/", flags) parent = self._anchor for part in root.parts[1:-1]: + _check_deadline(deadline) + if self._create_parents: + self._prepare_entry(parent, part, deadline=deadline) fd = os.open(part, flags, dir_fd=parent) - try: - identity = self._validate_parent(os.fstat(fd)) - except BaseException: - _close_preserving_primary(fd) - raise + self._opening_fd = fd + identity = self._validate_parent(os.fstat(fd)) self._parents.append((parent, part, fd, identity)) + self._opening_fd = None + if self._create_parents: + self._check_parents() + _check_deadline(deadline) + self._sync_parent(parent) + self._check_parents() parent = fd self._parent = parent self._check_parents() - if create: - try: + _check_deadline(deadline) + if self._create: + if self._exclusive_create: + # Record possible publication debt before mkdir. A lost + # receipt must not authorize adopting or deleting the leaf. + self._sync_pending.add(parent) os.mkdir(root.name, mode=0o700, dir_fd=parent) - except FileExistsError: - pass + elif self._create_parents: + self._prepare_entry(parent, root.name, deadline=deadline) else: - os.fsync(parent) + try: + os.mkdir(root.name, mode=0o700, dir_fd=parent) + except FileExistsError: + pass + else: + self._sync_pending.add(parent) + os.fsync(parent) + self._sync_pending.discard(parent) self._check_parents() + _check_deadline(deadline) fd = os.open(root.name, flags, dir_fd=parent) self._fd = fd self._identity = self._validate(os.fstat(fd), directory=True) self._check() - except BaseException as error: - try: - self.close() - except BaseException: - error.add_note("managed_directory_cleanup_incomplete") - if isinstance(error, FileNotFoundError): - raise _storage_error("not_found", error) from None - if isinstance(error, OSError): - raise _storage_error("unavailable", error) from None - raise + if self._create_parents or self._exclusive_create: + _check_deadline(deadline) + self._sync_parent(parent) + self._check() + _check_deadline(deadline) + self._opened = True + except FileExistsError as error: + raise _storage_error("conflict", error) from None + except FileNotFoundError as error: + raise _storage_error("not_found", error) from None + except OSError as error: + raise _storage_error("unavailable", error) from None + finally: + self._mutex.release() + + def _prepare_entry(self, parent: int, name: str, *, deadline: float | None) -> None: + self._check_parents() + _check_deadline(deadline) + # EEXIST may be another initializer's not-yet-synced mkdir. Each + # successful admission obtains its own durability evidence. + self._sync_pending.add(parent) + with suppress(FileExistsError): + os.mkdir(name, mode=0o700, dir_fd=parent) + self._check_parents() + _check_deadline(deadline) + + def _sync_parent(self, parent: int) -> None: + # Bind the child inode before collecting durability evidence, and + # recheck it afterwards. Pre-open fsync could cover a replaced edge. + os.fsync(parent) + self._sync_pending.discard(parent) @staticmethod def _validate_parent(info: os.stat_result) -> FileIdentity: @@ -154,14 +314,24 @@ def _check(self) -> int: return self._fd @contextmanager - def _operation(self, *, deadline: float | None = None) -> Iterator[int]: + def _operation(self, *, deadline: float | None = None, _cleanup: bool = False) -> Iterator[int]: _check_deadline(deadline) - acquired = (self._mutex.acquire() if deadline is None else - self._mutex.acquire(timeout=max(0.0, min(30.0, deadline - monotonic())))) + if _on_event_loop(): + acquired = self._mutex.acquire(blocking=False) + else: + acquired = (self._mutex.acquire() if deadline is None else + self._mutex.acquire(timeout=max(0.0, min(30.0, deadline - monotonic())))) if not acquired: raise ManagedStorageError("busy") try: _check_deadline(deadline) + if not self._opened: + raise ManagedStorageError("closed") + if not _cleanup: + if self._closing: + raise ManagedStorageError("closed") + if self._uncertain_closes or self._scan_close_unknown: + raise ManagedStorageError("busy") try: fd = self._check() yield fd @@ -173,21 +343,27 @@ def _operation(self, *, deadline: float | None = None) -> Iterator[int]: finally: self._mutex.release() - def _open(self, name: str, flags: int, *, create: bool = False) -> int: + def _open(self, name: str, flags: int, *, create: bool = False, retain: bool = False) -> int: _name(name) parent = self._check() + if create: + self._sync_pending.add(parent) fd = os.open( name, flags | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK, 0o600, dir_fd=parent, ) + if retain: + self._close_pending.add(fd) try: identity = self._validate(os.fstat(fd)) self._check_named(name, identity) if create: os.fsync(parent) + self._sync_pending.discard(parent) + self._check_named(name, identity) return fd - except BaseException: - _close_preserving_primary(fd) + except BaseException as error: + self._close_preserving_primary(fd, primary=error) raise def _check_named(self, name: str, expected: FileIdentity) -> None: @@ -203,6 +379,7 @@ def read(self, name: str) -> ManagedFileSnapshot | None: fd = self._open(name, os.O_RDONLY) except FileNotFoundError: return None + primary = None try: before = os.fstat(fd) if before.st_size > MAX_RECORD_BYTES: @@ -223,37 +400,573 @@ def read(self, name: str) -> ManagedFileSnapshot | None: ): raise ManagedStorageError("conflict") return ManagedFileSnapshot(bytes(chunks), identity) + except BaseException as error: + primary = error + raise + finally: + self._close_preserving_primary(fd, primary=primary) + + def _data_snapshot(self, name: str, fd: int, capacity: int) -> ManagedDataFileSnapshot: + before = os.fstat(fd) + identity = self._validate(before) + if before.st_size > capacity: + raise ManagedStorageError("capacity") + start = max(0, before.st_size - MAX_RECORD_BYTES) + tail = bytearray() + while len(tail) < before.st_size - start: + block = os.pread(fd, before.st_size - start - len(tail), start + len(tail)) + if not block: + raise ManagedStorageError("conflict") + tail.extend(block) + after = os.fstat(fd) + self._check_named(name, identity) + if (self._validate(after) != identity or + (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != + (after.st_size, after.st_mtime_ns, after.st_ctime_ns)): + raise ManagedStorageError("conflict") + return ManagedDataFileSnapshot(identity, after.st_size, after.st_mtime_ns, + after.st_ctime_ns, bytes(tail)) + + def data_snapshot(self, name: str, *, capacity: int) -> ManagedDataFileSnapshot | None: + """Read at most one record-sized tail, without creating or repairing.""" + _data_arguments(name, capacity) + with self._operation(): + try: + fd = self._open(name, os.O_RDONLY, retain=True) + except FileNotFoundError: + return None + primary = None + try: + return self._data_snapshot(name, fd, capacity) + except BaseException as error: + primary = error + raise finally: - _close_preserving_primary(fd) + self._close_preserving_primary(fd, primary=primary) + + def read_data(self, name: str, *, expected: ManagedDataFileSnapshot, + capacity: int, max_bytes: int) -> bytes: + """Read one sealed identity within a hard limit, without adopting a path. + + The original worker owns this entire operation, including fd close. + A cancelled async waiter must retain that worker before cleanup begins. + """ + _data_arguments(name, capacity) + if (type(expected) is not ManagedDataFileSnapshot or type(max_bytes) is not int + or max_bytes < 0 or type(expected.size) is not int or expected.size < 0): + raise ManagedStorageError("invalid_record") + if expected.size > min(capacity, max_bytes): + raise ManagedStorageError("capacity") + with self._operation(): + if self._data_cleanup_pending or self._data_failed: + raise ManagedStorageError("busy") + fd = self._open(name, os.O_RDONLY, retain=True) + primary = None + try: + if self._data_snapshot(name, fd, capacity) != expected: + raise ManagedStorageError("conflict") + result = bytearray() + while len(result) < expected.size: + block = os.pread(fd, min(MAX_RECORD_BYTES, expected.size - len(result)), len(result)) + if not block: + raise ManagedStorageError("conflict") + result.extend(block) + if self._data_snapshot(name, fd, capacity) != expected: + raise ManagedStorageError("conflict") + return bytes(result) + except BaseException as error: + primary = error + raise + finally: + self._close_preserving_primary(fd, primary=primary) + + def isolate_data(self, name: str, isolated_name: str, *, expected: ManagedDataFileSnapshot, + capacity: int) -> ManagedDataFileSnapshot: + """Move an exact data file without overwriting a quarantine destination. + + Caller retains both names before entry. This is not deletion or refund + proof. Unknown rename/sync/close seals data writes; close settles only + original descriptors and sync debt, never repeats the rename. + """ + _data_arguments(name, capacity) + _data_arguments(isolated_name, capacity) + if (type(expected) is not ManagedDataFileSnapshot or name == isolated_name + or not isolated_name.startswith("removed-")): + raise ManagedStorageError("invalid_record") + effect = False + try: + with self._operation() as parent: + if not self._locks or self.cleanup_pending or self._data_failed: + raise ManagedStorageError("busy") + fd = self._open(name, os.O_RDONLY, retain=True) + primary = None + try: + if self._data_snapshot(name, fd, capacity) != expected: + raise ManagedStorageError("conflict") + effect = True + self._sync_pending.add(parent) + _rename_data_noreplace(parent, name, isolated_name) + after = self._data_snapshot(isolated_name, fd, capacity) + if (after.identity, after.size, after.mtime_ns, after.tail) != ( + expected.identity, expected.size, expected.mtime_ns, expected.tail): + raise ManagedStorageError("conflict") + self._sync_parent(parent) + if self._data_snapshot(isolated_name, fd, capacity) != after: + raise ManagedStorageError("conflict") + return after + except BaseException as error: + primary = error + raise + finally: + self._close_preserving_primary(fd, primary=primary) + except BaseException: + if effect: + self._data_failed = True + raise + + def prepare_data_removal(self, name: str, isolated_name: str, *, + expected: ManagedDataFileSnapshot, capacity: int) -> _DataRemoval: + """Enroll the original removal before native effects; perform no IO.""" + if _on_event_loop(): + raise ManagedStorageError("busy") + _data_arguments(name, capacity) + _data_arguments(isolated_name, capacity) + if (type(expected) is not ManagedDataFileSnapshot or name == isolated_name + or not isolated_name.startswith("removed-") or name.endswith(".lock")): + raise ManagedStorageError("invalid_record") + with self._mutex: + if not self._opened or self._closing: + raise ManagedStorageError("closed") + if len(self._removals) >= 8: + raise ManagedStorageError("capacity") + if any({name, isolated_name} & {item.name, item.isolated_name} for item in self._removals): + raise ManagedStorageError("conflict") + target = ManagedRemovalTarget(sha256(os.fsencode(self._root)).hexdigest(), + self._identity, expected.identity, capacity) + removal = _DataRemoval(self, name, isolated_name, expected, capacity, self._identity, target) + self._removals.append(removal) + return removal + + def removal_target(self, removal: _DataRemoval) -> ManagedRemovalTarget: + if _on_event_loop(): + raise ManagedStorageError("busy") + with self._mutex: + if (type(removal) is not _DataRemoval or removal.owner is not self + or removal not in self._removals or removal.phase != "new" or removal.abandoned): + raise ManagedStorageError("conflict") + return removal.target + + def bind_data_removal(self, removal: _DataRemoval, binding: object) -> None: + """Attach opaque original accounting context before native deletion.""" + self.removal_target(removal) + with self._mutex: + if removal.phase != "new" or removal.abandoned or binding is None: + raise ManagedStorageError("conflict") + if removal.binding is not None and removal.binding is not binding: + raise ManagedStorageError("conflict") + removal.binding = binding + + def completed_removal_target(self, removal: _DataRemoval, binding: object, *, + deadline: float | None = None) -> ManagedRemovalTarget: + """Verify the original completion marker, not merely a phase string.""" + _check_deadline(deadline) + if _on_event_loop(): + raise ManagedStorageError("busy") + acquired = (self._mutex.acquire() if deadline is None else + self._mutex.acquire(timeout=max(0.0, min(30.0, deadline - monotonic())))) + if not acquired: + raise ManagedStorageError("busy") + try: + _check_deadline(deadline) + if (type(removal) is not _DataRemoval or removal.owner is not self + or removal._completion is None or removal.phase != "complete" + or removal.abandoned or binding is None or removal.binding is not binding): + raise ManagedStorageError("unavailable") + return removal.target + finally: + self._mutex.release() + + def remove_data(self, removal: _DataRemoval) -> None: + """Continue original removal; unknown rename/unlink/close is never replayed. + + Caller fences all writers/readers before entry. Completion covers this + owner's file descriptors only and is not itself an allocation refund. + """ + if _on_event_loop(): + raise ManagedStorageError("busy") + with self._mutex: + self._remove_data(removal) + + def abandon_data_removal(self, removal: _DataRemoval) -> None: + """Fence further removal, without claiming deletion or refund. + + The original directory close may now settle known descriptors/sync debt. + Keep the original phase and object as evidence that deletion was not + proven. No file is opened, removed or adopted by this operation. + """ + if _on_event_loop(): + raise ManagedStorageError("busy") + with self._mutex: + if (type(removal) is not _DataRemoval or removal.owner is not self + or removal not in self._removals): + raise ManagedStorageError("conflict") + removal.abandoned = True + + def _remove_data(self, removal: _DataRemoval) -> None: + if type(removal) is not _DataRemoval or removal.owner is not self: + raise ManagedStorageError("conflict") + if removal.abandoned: + raise ManagedStorageError("unavailable") + if removal.phase == "complete": + return + with self._operation() as parent: + if (removal not in self._removals or not self._locks + or removal.root_identity != self._identity): + raise ManagedStorageError("conflict") + if removal.target != ManagedRemovalTarget( + sha256(os.fsencode(self._root)).hexdigest(), self._identity, + removal.expected.identity, removal.capacity, + ): + raise ManagedStorageError("conflict") + if removal.phase in {"rename_unknown", "unlink_unknown", "close_unknown"}: + raise ManagedStorageError("unavailable") + if removal.phase == "new": + if self._io_cleanup_pending or self._data_failed: + raise ManagedStorageError("busy") + removal.fd = self._open(removal.name, os.O_RDONLY, retain=True) + removal.phase = "opened" + fd = removal.fd + if fd is None: + raise ManagedStorageError("unavailable") + if removal.phase == "opened": + if self._data_snapshot(removal.name, fd, removal.capacity) != removal.expected: + raise ManagedStorageError("conflict") + removal.phase = "rename_unknown" + self._sync_pending.add(parent) + _rename_data_noreplace(parent, removal.name, removal.isolated_name) + after = self._data_snapshot(removal.isolated_name, fd, removal.capacity) + if (after.identity, after.size, after.mtime_ns, after.tail) != ( + removal.expected.identity, removal.expected.size, + removal.expected.mtime_ns, removal.expected.tail): + raise ManagedStorageError("conflict") + removal.isolated = after + removal.phase = "isolated" + if removal.phase == "isolated": + if self._data_snapshot(removal.isolated_name, fd, removal.capacity) != removal.isolated: + raise ManagedStorageError("conflict") + self._check_named(removal.isolated_name, removal.expected.identity) + if self._validate(os.fstat(fd)) != removal.expected.identity: + raise ManagedStorageError("conflict") + removal.phase = "unlink_unknown" + os.unlink(removal.isolated_name, dir_fd=parent) + after_unlink = os.fstat(fd) + if ((after_unlink.st_dev, after_unlink.st_ino) != removal.expected.identity + or after_unlink.st_nlink != 0): + raise ManagedStorageError("conflict") + removal.phase = "unlinked" + if removal.phase == "unlinked": + self._sync_parent(parent) + removal.phase = "synced" + if removal.phase == "synced": + removal.phase = "close_unknown" + self._close_preserving_primary(fd, primary=None) + removal.phase = "closed" + if self._io_cleanup_pending: + raise ManagedStorageError("busy") + removal.phase = "complete" + removal._completion = object() + self._removals.remove(removal) + + def names(self, *, limit: int) -> tuple[str, ...]: + """Bounded directory discovery, never following entries or reading data.""" + if type(limit) is not int or not 0 < limit <= 32: + raise ManagedStorageError("invalid_record") + with self._operation() as parent: + if self._scan is not None: + raise ManagedStorageError("busy") + names: list[str] = [] + self._scan = os.scandir(parent) + primary = None + try: + for entry in self._scan: + if len(names) == limit: + raise ManagedStorageError("capacity") + names.append(entry.name) + except BaseException as error: + primary = error + raise + finally: + self._scan_close_unknown = True + try: + self._scan.close() + except BaseException: + if primary is not None: + primary.add_note("managed_directory_cleanup_incomplete") + else: + raise ManagedStorageError("unavailable") from None + else: + self._scan = None + self._scan_close_unknown = False + return tuple(names) + + def prepare_data_creation_pair(self, allocation_ids: tuple[str, str], *, + capacity: int, binding: object, deadline: float | None = None + ) -> tuple[_DataCreation, _DataCreation]: + """Register a fresh pair atomically, before enqueueing any native create. + + The caller binds the original reservation attempt, not recovered rows. + A lifetime high-water prevents reissuing zero-effect facts after removal + of settled trackers, without retaining unbounded per-file tombstones. + """ + if _on_event_loop(): + raise ManagedStorageError("busy") + if (type(allocation_ids) is not tuple or len(allocation_ids) != 2 + or any(type(value) is not str or re.fullmatch(r"[0-9a-f]{32}", value) is None + for value in allocation_ids) or binding is None): + raise ManagedStorageError("invalid_record") + first, second = allocation_ids + _data_arguments("capture-" + first, capacity) + with self._creation_lock(deadline=deadline): + if not self._opened or self._closing: + raise ManagedStorageError("closed") + if not self._creation_high_water < first < second: + raise ManagedStorageError("conflict") + if len(self._creations) > 6: + raise ManagedStorageError("capacity") + root_key = sha256(os.fsencode(self._root)).hexdigest() + def tracker(allocation_id: str) -> _DataCreation: + name = "capture-" + allocation_id + target = ManagedCreationTarget(root_key, self._identity, allocation_id, name, capacity) + return _DataCreation(self, allocation_id, name, capacity, binding, target) + pair = (tracker(first), tracker(second)) + self._creations.extend(pair) + self._creation_high_water = second + return pair + + @contextmanager + def _creation_lock(self, *, deadline: float | None = None) -> Iterator[None]: + _check_deadline(deadline) + if _on_event_loop(): + raise ManagedStorageError("busy") + acquired = (self._mutex.acquire() if deadline is None else + self._mutex.acquire(timeout=max(0.0, min(30.0, deadline - monotonic())))) + if not acquired: + raise ManagedStorageError("busy") + try: + _check_deadline(deadline) + yield + finally: + self._mutex.release() + + def creation_high_water(self, *, deadline: float | None = None) -> str | None: + with self._creation_lock(deadline=deadline): + return self._creation_high_water or None + + def pending_creation_pair(self, *, binding: object) -> tuple[_DataCreation, _DataCreation] | None: + """Recover only original in-memory enrollment after a lost return.""" + with self._creation_lock(): + found = [creation for creation in self._creations if creation.binding is binding] + if not found: + return None + if len(found) != 2: + raise ManagedStorageError("unavailable") + return found[0], found[1] + + def creation_target(self, creation: _DataCreation, *, binding: object, + require_fenced: bool = False, deadline: float | None = None + ) -> ManagedCreationTarget: + """Read original native facts, optionally requiring zero-effect completion.""" + _check_deadline(deadline) + if _on_event_loop(): + raise ManagedStorageError("busy") + acquired = (self._mutex.acquire() if deadline is None else + self._mutex.acquire(timeout=max(0.0, min(30.0, deadline - monotonic())))) + if not acquired: + raise ManagedStorageError("busy") + try: + _check_deadline(deadline) + if (type(creation) is not _DataCreation or creation.owner is not self + or binding is None or creation.binding is not binding + or type(require_fenced) is not bool): + raise ManagedStorageError("conflict") + if require_fenced: + if creation.phase != "fenced" or creation._completion is None: + raise ManagedStorageError("unavailable") + elif creation not in self._creations or creation.phase != "new": + raise ManagedStorageError("conflict") + target = creation.target + if (target.root_key != sha256(os.fsencode(self._root)).hexdigest() + or target.root_identity != self._identity + or (target.allocation_id, target.name, target.capacity) + != (creation.allocation_id, creation.name, creation.capacity)): + raise ManagedStorageError("conflict") + return target + finally: + self._mutex.release() + + def fence_data_creation(self, creation: _DataCreation, *, binding: object) -> bool: + """Fence queued work; only the original never-admitted attempt succeeds.""" + if _on_event_loop(): + raise ManagedStorageError("busy") + with self._mutex: + if (type(creation) is not _DataCreation or creation.owner is not self + or creation.binding is not binding): + raise ManagedStorageError("conflict") + if creation.phase == "fenced" and creation._completion is not None: + return True + if creation not in self._creations or creation.phase != "new": + return False + self.creation_target(creation, binding=binding) + creation.phase = "fenced" + creation._completion = object() + self._creations.remove(creation) + return True + + def create_data(self, creation: _DataCreation, *, binding: object) -> ManagedDataFileSnapshot: + """Admit once under the fence mutex; an unknown result is never replayed.""" + if _on_event_loop(): + raise ManagedStorageError("busy") + with self._mutex: + if (type(creation) is not _DataCreation or creation.owner is not self + or creation.binding is not binding or creation not in self._creations + or creation.phase != "new"): + raise ManagedStorageError("conflict") + self.creation_target(creation, binding=binding) + creation.phase = "admitted" + try: + snapshot = self.append_data(creation.name, b"", expected=None, + capacity=creation.capacity) + except BaseException: + creation.phase = "unknown" + raise + creation.snapshot = snapshot + creation.phase = "created" + self._creations.remove(creation) + return snapshot + + def append_data(self, name: str, content: bytes, *, expected: ManagedDataFileSnapshot | None, + capacity: int, truncate: bool = False) -> ManagedDataFileSnapshot: + """Bounded append or explicit same-inode rotation under the caller's lock. + + Caller proves charged capacity and instance authorization separately. + None means exclusive creation. Rotation discards old bytes, with no + temporary copy. Any uncertain effect seals this owner's data writer; + close only syncs/closes original fds, never replays or repairs a record. + """ + _data_arguments(name, capacity) + if (type(content) is not bytes or len(content) > MAX_RECORD_BYTES + or type(truncate) is not bool + or (expected is not None and type(expected) is not ManagedDataFileSnapshot) + or (truncate and expected is None)): + raise ManagedStorageError("invalid_record") + if len(content) > capacity: + raise ManagedStorageError("capacity") + effect = False + try: + with self._operation() as parent: + if (not self._locks or self._data_cleanup_pending or self._data_failed): + raise ManagedStorageError("busy") + flags = os.O_RDWR | os.O_APPEND + if expected is None: + flags |= os.O_CREAT | os.O_EXCL + effect = True + try: + fd = self._open(name, flags, create=expected is None, retain=True) + except FileExistsError: + raise ManagedStorageError("conflict") from None + primary = None + try: + before = self._data_snapshot(name, fd, capacity) + if expected is not None and before != expected: + raise ManagedStorageError("conflict") + size = 0 if truncate else before.size + if size + len(content) > capacity: + raise ManagedStorageError("capacity") + effect = True + self._data_sync_pending.add(fd) + if expected is None: + self._sync_pending.add(parent) + if truncate: + os.ftruncate(fd, 0) + remaining = memoryview(content) + while remaining: + written = os.write(fd, remaining) + if written <= 0 or written > len(remaining): + raise ManagedStorageError("unavailable") + remaining = remaining[written:] + after = self._data_snapshot(name, fd, capacity) + tail = ((b"" if truncate else before.tail) + content)[-MAX_RECORD_BYTES:] + if after.size != size + len(content) or after.tail != tail: + raise ManagedStorageError("conflict") + os.fsync(fd) + self._data_sync_pending.remove(fd) + if expected is None: + self._sync_parent(parent) + if self._data_snapshot(name, fd, capacity) != after: + raise ManagedStorageError("conflict") + return after + except BaseException as error: + primary = error + raise + finally: + if fd not in self._data_sync_pending: + self._close_preserving_primary(fd, primary=primary) + except BaseException: + if effect: + self._data_failed = True + raise @contextmanager - def lock(self, name: str, *, create: bool = False, deadline: float | None = None) -> Iterator[None]: - """Nonblocking stable flock; the caller never gains an unlink right.""" + def lock(self, name: str, *, create: bool = False, deadline: float | None = None, + exclusive_create: bool = False, wait_for_lock: bool = False) -> Iterator[None]: + """Stable flock, optionally waiting on the original fd off the event loop. + + A deadline alone never enables blocking. No caller gains an unlink right. + """ + _check_lock_wait(wait_for_lock, deadline) _name(name) - if type(create) is not bool or not name.endswith(".lock"): + if (type(create) is not bool or not name.endswith(".lock") + or type(exclusive_create) is not bool or (exclusive_create and not create)): raise ManagedStorageError("invalid_record") with self._operation(deadline=deadline): if name in self._locks: raise ManagedStorageError("busy") - fd = self._open(name, os.O_RDWR | (os.O_CREAT if create else 0), create=create) + try: + fd = self._open(name, os.O_RDWR | (os.O_CREAT if create else 0) + | (os.O_EXCL if exclusive_create else 0), create=create) + except FileExistsError as error: + raise _storage_error("conflict", error) from None fcntl = import_module("fcntl") + primary = None try: identity = self._validate(os.fstat(fd)) if os.fstat(fd).st_size != 0: raise ManagedStorageError("invalid_record") - try: - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError: - raise ManagedStorageError("busy") from None + while True: + _check_deadline(deadline) + self._check_named(name, identity) + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + if not wait_for_lock: + raise ManagedStorageError("busy") from None + assert deadline is not None + _check_deadline(deadline) + sleep(max(0.0, min(0.01, deadline - monotonic()))) + _check_deadline(deadline) self._check_named(name, identity) self._locks[name] = (fd, identity) yield self._check_named(name, identity) + except BaseException as error: + primary = error + raise finally: # Closing this exact descriptor releases only this flock. Never # unlink a lock on release or retry close after an uncertain error. self._locks.pop(name, None) - _close_preserving_primary(fd) + self._close_preserving_primary(fd, primary=primary) def write(self, name: str, content: bytes, *, expected: ManagedFileSnapshot | None) -> None: """Publish under a caller-held stable lock; fsync failure remains unknown. @@ -293,10 +1006,12 @@ def write(self, name: str, content: bytes, *, expected: ManagedFileSnapshot | No self._check_named(temporary, identity) if self.read(name) != expected: raise ManagedStorageError("conflict") + self._sync_pending.add(parent) os.replace(temporary, name, src_dir_fd=parent, dst_dir_fd=parent) published = True self._pending.pop(temporary) os.fsync(parent) + self._sync_pending.discard(parent) finally: primary = sys.exception() failures = [] @@ -308,6 +1023,7 @@ def write(self, name: str, content: bytes, *, expected: ManagedFileSnapshot | No try: os.close(fd) except OSError as error: + self._uncertain_closes.add(fd) failures.append(error) if not published: try: @@ -322,7 +1038,30 @@ def write(self, name: str, content: bytes, *, expected: ManagedFileSnapshot | No @property def cleanup_pending(self) -> bool: - return bool(self._pending) + return bool(self._creations) or self._data_cleanup_pending + + @property + def _data_cleanup_pending(self) -> bool: + return (bool(self._removals) or any(item.phase == "unknown" for item in self._creations) + or self._io_cleanup_pending) + + @property + def _io_cleanup_pending(self) -> bool: + return bool(self._pending or self._uncertain_closes or self._sync_pending + or self._data_sync_pending or self._close_pending or self._scan is not None) + + def _close_preserving_primary(self, fd: int, *, primary: BaseException | None) -> None: + self._uncertain_closes.add(fd) + try: + os.close(fd) + except BaseException: + if primary is not None: + primary.add_note("managed_file_close_incomplete") + else: + raise ManagedStorageError("unavailable") from None + else: + self._uncertain_closes.discard(fd) + self._close_pending.discard(fd) def _cleanup_pending(self) -> None: parent = self._check() @@ -340,6 +1079,7 @@ def _cleanup_pending(self) -> None: # A failed sync retains debt. Retrying only syncs the directory; # it must never unlink a subsequently created same-name file. os.fsync(parent) + self._sync_pending.discard(parent) del self._pending[name] self._unlinked.discard(name) @@ -347,33 +1087,105 @@ def close(self) -> None: with self._mutex: if self._locks: raise ManagedStorageError("busy") + if any(not item.abandoned and item.phase not in {"rename_unknown", "unlink_unknown", "close_unknown"} + for item in self._removals): + raise ManagedStorageError("busy") + if any(item.phase == "new" for item in self._creations): + raise ManagedStorageError("busy") + self._closing = True + try: + # Data durability precedes directory publication durability. + # These fds have never entered a close attempt. + for fd in tuple(self._data_sync_pending): + os.fsync(fd) + self._data_sync_pending.remove(fd) + for parent in tuple(self._sync_pending): + os.fsync(parent) + self._sync_pending.remove(parent) + except OSError: + # Keep the original parent fd alive; retry sync, never mkdir + # or deletion. Unknown close still follows the existing ledger. + raise ManagedStorageError("unavailable") from None if self._pending: - with self._operation(): + with self._operation(_cleanup=True): self._cleanup_pending() - descriptors = [] + # Stage all receipts before clearing any old references. A signal + # cannot strand the remaining fds in a local-only list. + if self._opening_fd is not None: + self._close_pending.add(self._opening_fd) if self._fd is not None: - descriptors.append(self._fd) - self._fd = None - descriptors.extend(item[2] for item in reversed(self._parents)) - self._parents.clear() + self._close_pending.add(self._fd) + self._close_pending.update(item[2] for item in self._parents) if self._anchor is not None: - descriptors.append(self._anchor) - self._anchor = None - failed = False - for fd in descriptors: + self._close_pending.add(self._anchor) + self._opening_fd = self._fd = self._anchor = None + self._parents.clear() + failures: list[BaseException] = [] + for fd in tuple(self._close_pending): + if fd in self._uncertain_closes: + continue + self._uncertain_closes.add(fd) try: os.close(fd) - except OSError: - failed = True - if failed: + except BaseException as error: + failures.append(error) + else: + self._close_pending.remove(fd) + self._uncertain_closes.discard(fd) + if failures and not isinstance(failures[0], OSError): + raise failures[0] + if self._uncertain_closes or self._scan is not None or self._removals or self._creations: raise ManagedStorageError("unavailable") from None +def _rename_data_noreplace(parent: int, source: str, target: str) -> None: + # Same Linux primitive already used by package materialization. No fallback + # to rename/replace: absence checks cannot prevent destination overwrite. + import ctypes + + function = getattr(ctypes.CDLL(None, use_errno=True), "renameat2", None) + if function is None: + raise ManagedStorageError("unsupported") + function.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] + function.restype = ctypes.c_int + ctypes.set_errno(0) + if function(parent, os.fsencode(source), parent, os.fsencode(target), 1) != 0: + raise ManagedStorageError("conflict" if ctypes.get_errno() == errno.EEXIST else "unavailable") + + +def _data_arguments(name: str, capacity: int) -> None: + _name(name) + if name.endswith(".lock") or type(capacity) is not int or not 0 < capacity <= 128 * 1024**2: + raise ManagedStorageError("invalid_record") + + def _name(name: str) -> None: if type(name) is not str or _NAME.fullmatch(name) is None: raise ManagedStorageError("invalid_record") +def _check_lock_wait(wait_for_lock: bool, deadline: float | None) -> None: + if type(wait_for_lock) is not bool or (wait_for_lock and deadline is None): + raise ManagedStorageError("invalid_record") + if not wait_for_lock: + return + _check_deadline(deadline) + # A different task on this loop may own the fence across an await. Never + # block the thread which must resume that task to release its original lock. + if _on_event_loop(): + raise ManagedStorageError("busy") + + +def _on_event_loop() -> bool: + from asyncio import get_running_loop + + try: + get_running_loop() + except RuntimeError: + return False + return True + + def _check_deadline(deadline: float | None) -> None: if deadline is None: return @@ -383,17 +1195,6 @@ def _check_deadline(deadline: float | None) -> None: raise ManagedStorageError("busy") -def _close_preserving_primary(fd: int) -> None: - primary = sys.exception() - try: - os.close(fd) - except OSError: - if primary is not None: - primary.add_note("managed_file_close_incomplete") - else: - raise ManagedStorageError("unavailable") from None - - def _storage_error(code: str, original: BaseException) -> ManagedStorageError: error = ManagedStorageError(code) # Preserve only our bounded cleanup signal, never native text or notes. diff --git a/src/loushang/apphost/managed/admission_record.py b/src/loushang/apphost/managed/admission_record.py new file mode 100644 index 000000000..aa88b9e19 --- /dev/null +++ b/src/loushang/apphost/managed/admission_record.py @@ -0,0 +1,165 @@ +"""Pure bounded namespace initialization witnesses, not IO admission authority. + +Only the retained initializer may supply native identity evidence. No +record, including initialized, claims that a service is running or stopped. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from enum import Enum + +from .contracts import _HEX32, _HEX64, ManagedContractError, _match + +MAX_ADMISSION_RECORD_BYTES = 4096 +_VERSION = "loushang.managed-namespace/v2" +_FIELDS = {"version", "namespace", "operation", "deployment", "phase", "registryRoot", "database", "registryLock", + "admissionRoot", "admissionLock"} +FileIdentity = tuple[int, int] + + +class ManagedInitializationPhaseV1(str, Enum): + INITIALIZING = "initializing" + INITIALIZED = "initialized" + + +@dataclass(frozen=True, slots=True) +class ManagedNamespaceAdmissionRecordV1: + namespace_key: str + operation_id: str + deployment_id: str + phase: ManagedInitializationPhaseV1 + registry_root_identity: FileIdentity | None = field(default=None, repr=False) + database_identity: FileIdentity | None = field(default=None, repr=False) + registry_lock_identity: FileIdentity | None = field(default=None, repr=False) + admission_root_identity: FileIdentity | None = field(default=None, repr=False) + admission_lock_identity: FileIdentity | None = field(default=None, repr=False) + + def __post_init__(self) -> None: + _match(self.namespace_key, _HEX64) + _match(self.operation_id, _HEX32) + _match(self.deployment_id, _HEX32) + if type(self.phase) is not ManagedInitializationPhaseV1: + raise ManagedContractError() + identities = (self.registry_root_identity, self.database_identity, self.registry_lock_identity) + admission = (self.admission_root_identity, self.admission_lock_identity) + if self.phase is ManagedInitializationPhaseV1.INITIALIZING: + if any(value is not None for value in identities): + raise ManagedContractError() + if any(value is not None for value in admission): + for value in admission: + _identity(value) + if len(set(admission)) != 2: + raise ManagedContractError() + else: + for value in (*identities, *admission): + _identity(value) + if len(set((*identities, *admission))) != 5: + raise ManagedContractError() + + def to_json(self) -> str: + return json.dumps({ + "version": _VERSION, "namespace": self.namespace_key, + "operation": self.operation_id, "deployment": self.deployment_id, + "phase": self.phase.value, "registryRoot": self.registry_root_identity, + "database": self.database_identity, "registryLock": self.registry_lock_identity, + "admissionRoot": self.admission_root_identity, "admissionLock": self.admission_lock_identity, + }, sort_keys=True, separators=(",", ":")) + + @classmethod + def from_json(cls, value: str) -> ManagedNamespaceAdmissionRecordV1: + if type(value) is not str or not 1 <= len(value) <= MAX_ADMISSION_RECORD_BYTES: + raise ManagedContractError() + try: + if len(value.encode("utf-8")) > MAX_ADMISSION_RECORD_BYTES: + raise ManagedContractError() + data = json.loads(value, object_pairs_hook=_unique) + if type(data) is not dict or set(data) != _FIELDS or data["version"] != _VERSION: + raise ManagedContractError() + return cls( + data["namespace"], data["operation"], data["deployment"], + ManagedInitializationPhaseV1(data["phase"]), + _decoded_identity(data["registryRoot"]), _decoded_identity(data["database"]), + _decoded_identity(data["registryLock"]), + _decoded_identity(data["admissionRoot"]), _decoded_identity(data["admissionLock"]), + ) + except (ValueError, TypeError, KeyError, UnicodeError, RecursionError): + raise ManagedContractError() from None + + +def _identity(value: object) -> None: + if (type(value) is not tuple or len(value) != 2 + or any(type(item) is not int or not 0 <= item < 2**64 for item in value) + or value[1] == 0): + raise ManagedContractError() + + +@dataclass(frozen=True, slots=True) +class ManagedServiceAdmissionRecordV1: + """Durable fence initialization fact, independent of service generations.""" + + service_id: str + operation_id: str + phase: ManagedInitializationPhaseV1 + root_identity: FileIdentity | None = field(default=None, repr=False) + lock_identity: FileIdentity | None = field(default=None, repr=False) + + def __post_init__(self) -> None: + _match(self.service_id, _HEX64) + _match(self.operation_id, _HEX32) + if type(self.phase) is not ManagedInitializationPhaseV1: + raise ManagedContractError() + if self.phase is ManagedInitializationPhaseV1.INITIALIZING: + if self.root_identity is not None or self.lock_identity is not None: + raise ManagedContractError() + else: + _identity(self.root_identity) + _identity(self.lock_identity) + if self.root_identity == self.lock_identity: + raise ManagedContractError() + + def to_json(self) -> str: + return json.dumps({"version": "loushang.managed-service-control/v1", "service": self.service_id, + "operation": self.operation_id, "phase": self.phase.value, + "root": self.root_identity, "lock": self.lock_identity}, + sort_keys=True, separators=(",", ":")) + + @classmethod + def from_json(cls, value: str) -> ManagedServiceAdmissionRecordV1: + if type(value) is not str or not 1 <= len(value) <= MAX_ADMISSION_RECORD_BYTES: + raise ManagedContractError() + try: + if len(value.encode("utf-8")) > MAX_ADMISSION_RECORD_BYTES: + raise ManagedContractError() + data = json.loads(value, object_pairs_hook=_unique) + if (type(data) is not dict or set(data) != {"version", "service", "operation", "phase", "root", "lock"} + or data["version"] != "loushang.managed-service-control/v1"): + raise ManagedContractError() + return cls(data["service"], data["operation"], ManagedInitializationPhaseV1(data["phase"]), + _decoded_identity(data["root"]), _decoded_identity(data["lock"])) + except (ValueError, TypeError, KeyError, UnicodeError, RecursionError): + raise ManagedContractError() from None + + +def _decoded_identity(value: object) -> FileIdentity | None: + if value is None: + return None + if type(value) is not list or len(value) != 2: + raise ManagedContractError() + result = (value[0], value[1]) + _identity(result) + return result + + +def _unique(items: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in items: + if key in result: + raise ManagedContractError() + result[key] = value + return result + + +__all__ = ["ManagedInitializationPhaseV1", "ManagedNamespaceAdmissionRecordV1", + "ManagedServiceAdmissionRecordV1", "MAX_ADMISSION_RECORD_BYTES"] diff --git a/src/loushang/apphost/managed/connection.py b/src/loushang/apphost/managed/connection.py new file mode 100644 index 000000000..6ae311acd --- /dev/null +++ b/src/loushang/apphost/managed/connection.py @@ -0,0 +1,297 @@ +"""An explicitly selected managed instance, authenticated without retargeting. + +Composition adopts this lease before prepare and closes it before the borrowed +journal. Cancellation of a waiter never abandons the original preparation or +cleanup task. No discovery, spawn, stop, directory creation or mutation replay. +""" + +from __future__ import annotations + +import asyncio +import os +from collections.abc import Callable +from dataclasses import replace +from pathlib import Path +from threading import Event +from time import monotonic +from typing import Any, TypeVar, cast + +from loushang.appserver.client import ( + AppClientV1, + AppConnectionClosedError, + SessionDiscoveryClientV1, +) +from loushang.appserver.execution.client import ExecutionClientV1 +from loushang.appserver.local import LocalAppClientConnectionV1 +from loushang.appserver.local_record import ( + LocalConnectionDirectoryV1, + LocalRecordScopeV1, + require_endpoint, +) +from loushang.appserver.managed_mux import ManagedMuxCreationClientV1 +from loushang.appserver.managed_mux_close import ManagedMuxCloseClientV1 +from loushang.hosting.service import LinuxServiceObserverV1 + +from ._files import ManagedStorageError, _check_deadline +from .child import _spawn +from .contracts import ( + ManagedContractError, + ManagedHandoffPhaseV1, + ManagedInstanceRefV1, + ManagedNamespaceV1, + ManagedServiceKeyV1, +) +from .lifecycle import ManagedServiceJournalV1, ManagedServiceStateV1 +from .paths import resolve_managed_service_paths + + +def _same_connection_state(original: ManagedServiceStateV1, current: ManagedServiceStateV1) -> bool: + if current == original: + return True + # The optional, write-once trace receipt is not connection authority. Only + # its single publication may cross authentication; every lifecycle field, + # native identity and other revision change retains the exact fence. + return ( + original.trace_application is None and current.trace_application is not None + and current.revision == original.revision + 1 + and replace(current, revision=original.revision, trace_application=None) == original + ) + + +class ManagedConnectionLeaseV1: + """One same-loop connection admission; close never signals the service. + + The post-authentication check is an admission observation, not a guarantee + against a subsequent service stop. The server retains per-request authority. + Failed preparation is not retried; an explicit fresh selection needs a new + lease. Failed cleanup retains the original resources for close retries. + """ + + def __init__( + self, journal: ManagedServiceJournalV1, namespace: ManagedNamespaceV1, + service: ManagedServiceKeyV1, instance: ManagedInstanceRefV1, *, + runtime_root: str, endpoint: str, + ) -> None: + paths = resolve_managed_service_paths(namespace, service, runtime_root=runtime_root) + require_endpoint(endpoint) + if ( + namespace.user_id != os.geteuid() + or type(journal) is not ManagedServiceJournalV1 + or type(instance) is not ManagedInstanceRefV1 + or instance.namespace_key != namespace.namespace_key + or instance.service_id != service.service_id + or journal._namespace != namespace or journal._service != service + or str(journal._fence._root) != str(paths.lifecycle) + or str(journal._database._directory._root) != str(paths.registry) + ): + raise ManagedContractError() + self._journal, self._service, self._instance = journal, service, instance + self._root, self._endpoint = Path(paths.connection), endpoint + self._observer: LinuxServiceObserverV1 | None = None + self._native_uncertain = False + self._observer_close_unknown = False + self._directory: LocalConnectionDirectoryV1 | None = None + self._connection: LocalAppClientConnectionV1 | None = None + self._prepare_task: asyncio.Task[None] | None = None + self._close_task: asyncio.Task[None] | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._ready = False + self._closing = False + self._settled = False + + @property + def instance(self) -> ManagedInstanceRefV1: + return self._instance + + @property + def cleanup_pending(self) -> bool: + return not self._settled + + def _require_connection(self) -> LocalAppClientConnectionV1: + self._bind_loop() + if self._closing or not self._ready or self._connection is None: + raise AppConnectionClosedError() + return self._connection + + @property + def client(self) -> AppClientV1: + return self._require_connection().client + + @property + def discovery_client(self) -> SessionDiscoveryClientV1 | None: + return self._require_connection().discovery_client + + @property + def execution_client(self) -> ExecutionClientV1 | None: + return self._require_connection().execution_client + + @property + def managed_mux_client(self) -> ManagedMuxCreationClientV1 | None: + return self._require_connection().managed_mux_client + + @property + def managed_mux_close_client(self) -> ManagedMuxCloseClientV1 | None: + return self._require_connection().managed_mux_close_client + + @property + def application_id(self) -> str: + return self._require_connection().application_id + + @property + def scopes(self) -> tuple[LocalRecordScopeV1, ...]: + return self._require_connection().scopes + + def _bind_loop(self) -> None: + loop = asyncio.get_running_loop() + if self._loop is None: + self._loop = loop + elif self._loop is not loop: + raise ManagedStorageError("conflict") + + async def prepare(self, *, deadline: float) -> None: + self._bind_loop() + _check_deadline(deadline) + if self._closing or self._prepare_task is not None: + raise ManagedStorageError("conflict") + self._prepare_task = _spawn(self._prepare_once(deadline)) + await asyncio.shield(self._prepare_task) + if self._closing or not self._ready: + raise AppConnectionClosedError() + + def _current(self, deadline: float) -> ManagedServiceStateV1: + state = self._journal.read(deadline=deadline, wait_for_lock=True) + if (state is None or state.handoff.instance != self._instance + or state.handoff.phase is not ManagedHandoffPhaseV1.COMMITTED + or state.handoff.stop_requested or state.native_identity is None): + raise ManagedStorageError("unavailable") + return state + + def _admit_native(self, deadline: float) -> ManagedServiceStateV1: + state = self._current(deadline) + assert state.native_identity is not None + self._native_uncertain = True + self._observer = LinuxServiceObserverV1.reopen(state.native_identity) + self._native_uncertain = False + if self._observer.exited(): + raise ManagedStorageError("unavailable") + _check_deadline(deadline) + return state + + def _recheck(self, original: ManagedServiceStateV1, deadline: float) -> None: + current = self._current(deadline) + if (not _same_connection_state(original, current) or self._observer is None + or self._observer.identity != current.native_identity or self._observer.exited()): + raise ManagedStorageError("unavailable") + _check_deadline(deadline) + + async def _prepare_once(self, deadline: float) -> None: + original = await _settled_native(self._admit_native, deadline) + if self._closing: + raise AppConnectionClosedError() + _check_deadline(deadline) + self._directory = LocalConnectionDirectoryV1(self._root) + self._connection = LocalAppClientConnectionV1( + self._directory, self._endpoint, timeout=min(30.0, deadline - monotonic()), + expected_product_id=self._service.product_id, + expected_instance=self._instance.instance_id, + ) + await self._connection.start() + await _settled_native(self._recheck, original, deadline) + if self._closing: + raise AppConnectionClosedError() + _check_deadline(deadline) + self._ready = True + + async def close(self) -> None: + self._bind_loop() + self._closing = True + self._ready = False + task = self._close_task + if task is None or (task.done() and (task.cancelled() or task.exception() is not None)): + task = self._close_task = _spawn(self._close_once()) + await asyncio.shield(task) + + async def _close_once(self) -> None: + if self._prepare_task is not None: + await asyncio.gather(self._prepare_task, return_exceptions=True) + errors: list[BaseException] = [] + if self._connection is not None: + try: + await self._connection.close() + except BaseException as error: + errors.append(error) + else: + self._connection = None + # The directory is borrowed by the connection until it fully settles. + if self._connection is None and self._directory is not None: + try: + await _settled_native(self._directory.close) + except BaseException as error: + errors.append(error) + else: + self._directory = None + if self._observer is not None: + if self._observer_close_unknown: + errors.append(ManagedStorageError("unavailable")) + else: + self._observer_close_unknown = True + try: + await _settled_native(self._observer.close) + except BaseException as error: + errors.append(error) + else: + self._observer_close_unknown = False + self._observer = None + if self._native_uncertain: + errors.append(ManagedStorageError("unavailable")) + if errors: + raise errors[0] + self._settled = True + + +_T = TypeVar("_T") + + +async def _settled_native(operation: Callable[..., _T], *args: Any) -> _T: + """Actual callable receipt, independent of cancellation of executor futures. + + Publication gates submission faults before native effects, using the same + receipt pattern as existing IO owners without importing Harness internals. + The caller must keep this loop alive until its lease finishes closing. + """ + loop = asyncio.get_running_loop() + receipt: asyncio.Future[tuple[bool, object]] = loop.create_future() + published = Event() + admitted = False + + def native() -> None: + published.wait() + if not admitted: + return + try: + outcome: tuple[bool, object] = (True, operation(*args)) + except BaseException as error: + outcome = (False, error) + loop.call_soon_threadsafe(receipt.set_result, outcome) + + try: + offload = loop.run_in_executor(None, native) + admitted = True + finally: + published.set() + offload.add_done_callback(lambda future: None if future.cancelled() else future.exception()) + cancellation = None + while not receipt.done(): + try: + await asyncio.shield(receipt) + except asyncio.CancelledError as error: + cancellation = error + succeeded, result = receipt.result() + if not succeeded: + raise cast(BaseException, result) + if cancellation is not None: + raise cancellation + return cast(_T, result) + + +__all__ = ["ManagedConnectionLeaseV1"] diff --git a/src/loushang/apphost/managed/contracts.py b/src/loushang/apphost/managed/contracts.py index ee92177ab..55de039f5 100644 --- a/src/loushang/apphost/managed/contracts.py +++ b/src/loushang/apphost/managed/contracts.py @@ -35,6 +35,14 @@ def require_mux_name(value: str) -> str: return value +def require_service_alias(value: str) -> str: + """A separate namespace of display names, never an ambiguous service ID.""" + require_mux_name(value) + if _HEX64.fullmatch(value.lower()) is not None: + raise ManagedContractError() + return value + + @dataclass(frozen=True, slots=True) class ManagedNamespaceV1: """Injected local identity; home is lexical, not yet filesystem-admitted.""" @@ -203,4 +211,5 @@ def _digest(*parts: str) -> str: "ManagedServiceKeyV1", "ManagedStopEvidenceV1", "require_mux_name", + "require_service_alias", ] diff --git a/src/loushang/apphost/managed/defaults.py b/src/loushang/apphost/managed/defaults.py new file mode 100644 index 000000000..557d7886d --- /dev/null +++ b/src/loushang/apphost/managed/defaults.py @@ -0,0 +1,69 @@ +"""Shared managed default selection, separate from native deployment admission. + +No deployment directories or Session stores are created. Platform path +normalization may inspect symlinks; OS machine identity is explicitly read. +""" + +from __future__ import annotations + +import os +import sys +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path + +from loushang.foundation.platform_paths import PlatformPaths, resolve_platform_paths +from loushang.hosting.errors import HostingError, HostingFailureCategory +from loushang.hosting.machine_identity import linux_machine_key + +from .contracts import ManagedContractError, ManagedNamespaceV1 + +MACHINE_DOMAIN = "loushang.managed.machine/v1" + + +@dataclass(frozen=True, slots=True) +class ManagedDefaultsV1: + namespace: ManagedNamespaceV1 + platform: PlatformPaths = field(repr=False) + temporary_override: str | None = field(default=None, repr=False) + + +def resolve_managed_defaults( + *, environ: Mapping[str, str] | None = None, home: str | Path | None = None, +) -> ManagedDefaultsV1: + """Resolve one user/home/machine context, independent of the attach cwd. + + Relative root overrides are rejected: otherwise reconnecting from another + cwd would silently select a different namespace. Existing Foundation path + overrides keep Foundation precedence. Linux managed fallback is explicitly + /tmp (not TMPDIR/TEMP): reconnect must not depend on a shell's scratch path, + and read-only listing must not trigger tempfile's writable-directory probe. + """ + values = dict(os.environ if environ is None else environ) + if sys.platform != "linux": + raise HostingError(HostingFailureCategory.PLATFORM_UNSUPPORTED, "managed_defaults_platform_unsupported") + for key in ("LOUSHANG_HOME", "LOUSHANG_RUNTIME_DIR", "LOUSHANG_TMPDIR", "XDG_RUNTIME_DIR"): + if key == "XDG_RUNTIME_DIR" and values.get("LOUSHANG_RUNTIME_DIR"): + continue + value = values.get(key) + if value and (not value.strip() or not Path(value).expanduser().is_absolute()): + raise ManagedContractError() + if not values.get("LOUSHANG_HOME") and home is not None and not Path(home).expanduser().is_absolute(): + raise ManagedContractError() + paths = resolve_platform_paths(environ=values, home=home, temporary_root="/tmp") + namespace = ManagedNamespaceV1(str(paths.home), os.geteuid(), linux_machine_key(domain=MACHINE_DOMAIN)) + runtime = paths.runtime / "lmux" / namespace.namespace_key + # Reject conflicting default roots before granting any creation intent. + for durable in (paths.home / "lmux", paths.state, paths.data): + if runtime.is_relative_to(durable) or durable.is_relative_to(runtime): + raise ManagedContractError() + temporary_override = str(paths.temporary) if values.get("LOUSHANG_TMPDIR") else None + if temporary_override is not None: + temporary = Path(temporary_override) / "lmux" / namespace.namespace_key + for other in (paths.home / "lmux", paths.state, paths.data, runtime): + if temporary.is_relative_to(other) or other.is_relative_to(temporary): + raise ManagedContractError() + return ManagedDefaultsV1(namespace, paths, temporary_override) + + +__all__ = ["ManagedDefaultsV1", "resolve_managed_defaults"] diff --git a/src/loushang/apphost/managed/discovery.py b/src/loushang/apphost/managed/discovery.py new file mode 100644 index 000000000..9b1c64c34 --- /dev/null +++ b/src/loushang/apphost/managed/discovery.py @@ -0,0 +1,259 @@ +"""Read-only global name selection; durable observations never imply readiness. + +One borrowed registry transaction snapshots a bounded page of reservations and +their current instance references. No endpoint records, native observation, +directory creation, recovery or mutation. Consumers still PrepareConnection. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + +from ._files import ManagedStorageError, _check_deadline +from .contracts import ( + _HEX64, + ManagedContractError, + ManagedHandoffPhaseV1, + ManagedInstanceRefV1, + ManagedNamespaceV1, + ManagedServiceKeyV1, + _match, + require_mux_name, +) +from .lifecycle import _decode_state +from .paths import resolve_managed_registry_root +from .registry import ( + MAX_MUXES, + MAX_PAGE, + MAX_SERVICES, + ManagedMuxReservationV1, + ManagedRegistryV1, + _decode, +) + +_SELECT = ( + "SELECT m.name, s.product, s.workspace, s.profile, m.operation_id, s.service_id, " + "i.revision, i.instance_id, i.attempt_id, i.phase, i.stop_requested, " + "i.process_exited, i.application_cleanup_completed, i.process_scope_settled, i.native_identity, i.trace_application " + "FROM muxes m JOIN services s USING(service_id) " + "LEFT JOIN instances i USING(service_id) " +) + +_SERVICE_SELECT = ( + "SELECT s.service_id,s.product,s.workspace,s.profile,i.revision,i.instance_id,i.attempt_id,i.phase," + "i.stop_requested,i.process_exited,i.application_cleanup_completed,i.process_scope_settled," + "i.native_identity,i.trace_application FROM services s LEFT JOIN instances i USING(service_id) " +) + + +@dataclass(frozen=True, slots=True) +class ManagedMuxObservationV1: + """Reserved name and recorded lifecycle, not proof of an existing live Mux. + + Workspace is deliberate display/selection metadata; PID, argv, credential + and record paths are never exposed. The operation ID is correlation only. + """ + + reservation: ManagedMuxReservationV1 + instance: ManagedInstanceRefV1 | None + revision: int | None + recorded_phase: ManagedHandoffPhaseV1 | None + stop_requested: bool + cleanly_stopped: bool + + def __post_init__(self) -> None: + if (type(self.reservation) is not ManagedMuxReservationV1 + or type(self.stop_requested) is not bool or type(self.cleanly_stopped) is not bool): + raise ManagedContractError() + if self.instance is None: + if self.revision is not None or self.recorded_phase is not None or self.stop_requested or self.cleanly_stopped: + raise ManagedContractError() + elif (type(self.instance) is not ManagedInstanceRefV1 + or self.instance.service_id != self.reservation.service.service_id + or type(self.revision) is not int or not 1 <= self.revision < 2**63 + or type(self.recorded_phase) is not ManagedHandoffPhaseV1 + or self.cleanly_stopped and not (self.stop_requested or self.recorded_phase is ManagedHandoffPhaseV1.ABORTING)): + raise ManagedContractError() + + @property + def name(self) -> str: + return self.reservation.name + + @property + def service(self) -> ManagedServiceKeyV1: + return self.reservation.service + + +class ManagedDiscoveryV1: + """Borrow an already open namespace registry; no additional owner to close. + + Native callers can invoke these synchronous reads directly. Async consumers + must join the actual read before closing the borrowed registry. Pages are + ordered by case-sensitive global name, not filtered by the client's cwd. + Separate pages are independent snapshots, not a stable enumeration epoch. + """ + + def __init__(self, registry: ManagedRegistryV1, namespace: ManagedNamespaceV1) -> None: + root = resolve_managed_registry_root(namespace) + if (type(registry) is not ManagedRegistryV1 or namespace.user_id != os.geteuid() + or registry._database._namespace != namespace.namespace_key + or str(registry._database._directory._root) != str(root)): + raise ManagedContractError() + self._registry, self._namespace = registry, namespace + + def resolve(self, name: str, *, deadline: float, wait_for_lock: bool = False) -> ManagedMuxObservationV1 | None: + require_mux_name(name) + with self._registry._database.transaction(deadline=deadline, wait_for_lock=wait_for_lock) as connection: + row = connection.execute(_SELECT + "WHERE m.name=?", (name,)).fetchone() + result = None if row is None else self._project(row) + _check_deadline(deadline) + return result + + def resolve_service(self, service_id: str, *, deadline: float, wait_for_lock: bool = False) -> ManagedServiceKeyV1 | None: + """Exact service lookup, including services without a registered Mux.""" + _match(service_id, _HEX64) + with self._registry._database.transaction(deadline=deadline, wait_for_lock=wait_for_lock) as connection: + row = connection.execute("SELECT product, workspace, profile FROM services WHERE service_id=?", + (service_id,)).fetchone() + _check_deadline(deadline) + if row is None: + return None + try: + service = ManagedServiceKeyV1(*row) + if service.service_id != service_id: + raise ManagedContractError() + return service + except (ManagedContractError, TypeError): + raise ManagedStorageError("invalid_record") from None + + def inspect_service(self, service_id: str, *, deadline: float, + wait_for_lock: bool = False) -> ManagedServiceObservationV1 | None: + """One durable snapshot, including services with no remaining mux names.""" + _match(service_id, _HEX64) + with self._registry._database.transaction(deadline=deadline, wait_for_lock=wait_for_lock) as connection: + row = connection.execute( + _SERVICE_SELECT + "WHERE s.service_id=?", + (service_id,), + ).fetchone() + result = None if row is None else self._project_service(row) + _check_deadline(deadline) + return result + + def snapshot_namespace(self, *, deadline: float, + wait_for_lock: bool = False) -> ManagedDiscoverySnapshotV1: + """Freeze bounded recorded facts in one transaction, never stop authority. + + Include services with no Mux names. Consumers must not replace confirmed + instance references with successors, or add later services to this set. + """ + with self._registry._database.transaction(deadline=deadline, wait_for_lock=wait_for_lock) as connection: + service_rows = connection.execute(_SERVICE_SELECT + "ORDER BY s.service_id LIMIT ?", + (MAX_SERVICES + 1,)).fetchall() + mux_rows = connection.execute(_SELECT + "ORDER BY m.name LIMIT ?", (MAX_MUXES + 1,)).fetchall() + if len(service_rows) > MAX_SERVICES or len(mux_rows) > MAX_MUXES: + raise ManagedStorageError("capacity") + try: + result = ManagedDiscoverySnapshotV1( + tuple(self._project_service(row) for row in service_rows), + tuple(self._project(row) for row in mux_rows), + ) + except ManagedContractError: + raise ManagedStorageError("invalid_record") from None + _check_deadline(deadline) + return result + + def _project_service(self, row: tuple) -> ManagedServiceObservationV1: + try: + service = ManagedServiceKeyV1(*row[1:4]) + if service.service_id != row[0]: + raise ManagedContractError() + if row[4] is None: + if any(value is not None for value in row[5:]): + raise ManagedContractError() + return ManagedServiceObservationV1(service, None, None, None, False, False) + state = _decode_state(self._namespace, service, row[4:]) + return ManagedServiceObservationV1(service, state.handoff.instance, state.revision, + state.handoff.phase, state.handoff.stop_requested, state.cleanly_stopped) + except (ManagedContractError, TypeError): + raise ManagedStorageError("invalid_record") from None + + def list_muxes( + self, *, deadline: float, after: str | None = None, limit: int = MAX_PAGE, + wait_for_lock: bool = False, + ) -> tuple[ManagedMuxObservationV1, ...]: + if type(limit) is not int or not 1 <= limit <= MAX_PAGE: + raise ManagedContractError() + if after is not None: + require_mux_name(after) + with self._registry._database.transaction(deadline=deadline, wait_for_lock=wait_for_lock) as connection: + rows = connection.execute( + _SELECT + "WHERE m.name>? ORDER BY m.name LIMIT ?", (after or "", limit), + ).fetchall() + result = tuple(self._project(row) for row in rows) + _check_deadline(deadline) + return result + + def _project(self, row: tuple) -> ManagedMuxObservationV1: + reservation = _decode(row[:6]) + if row[6] is None: + if any(value is not None for value in row[7:]): + raise ManagedStorageError("invalid_record") + return ManagedMuxObservationV1(reservation, None, None, None, False, False) + state = _decode_state(self._namespace, reservation.service, row[6:]) + return ManagedMuxObservationV1( + reservation, state.handoff.instance, state.revision, state.handoff.phase, + state.handoff.stop_requested, state.cleanly_stopped, + ) + + +@dataclass(frozen=True, slots=True) +class ManagedServiceObservationV1: + """Recorded service facts only; no PID, credential, endpoint or readiness.""" + + service: ManagedServiceKeyV1 + instance: ManagedInstanceRefV1 | None + revision: int | None + recorded_phase: ManagedHandoffPhaseV1 | None + stop_requested: bool + cleanly_stopped: bool + + def __post_init__(self) -> None: + if (type(self.service) is not ManagedServiceKeyV1 or type(self.stop_requested) is not bool + or type(self.cleanly_stopped) is not bool): + raise ManagedContractError() + if self.instance is None: + if self.revision is not None or self.recorded_phase is not None or self.stop_requested or self.cleanly_stopped: + raise ManagedContractError() + elif (type(self.instance) is not ManagedInstanceRefV1 or self.instance.service_id != self.service.service_id + or type(self.revision) is not int or not 1 <= self.revision < 2**63 + or type(self.recorded_phase) is not ManagedHandoffPhaseV1 + or self.cleanly_stopped and not (self.stop_requested or self.recorded_phase is ManagedHandoffPhaseV1.ABORTING)): + raise ManagedContractError() + + +@dataclass(frozen=True, slots=True) +class ManagedDiscoverySnapshotV1: + """One namespace's recorded set, not liveness or permission to stop it.""" + + services: tuple[ManagedServiceObservationV1, ...] + muxes: tuple[ManagedMuxObservationV1, ...] + + def __post_init__(self) -> None: + if (type(self.services) is not tuple or type(self.muxes) is not tuple + or len(self.services) > MAX_SERVICES or len(self.muxes) > MAX_MUXES + or any(type(item) is not ManagedServiceObservationV1 for item in self.services) + or any(type(item) is not ManagedMuxObservationV1 for item in self.muxes)): + raise ManagedContractError() + services = {item.service.service_id: item for item in self.services} + if len(services) != len(self.services) or len({item.name for item in self.muxes}) != len(self.muxes): + raise ManagedContractError() + for mux in self.muxes: + expected = ManagedServiceObservationV1(mux.service, mux.instance, mux.revision, + mux.recorded_phase, mux.stop_requested, mux.cleanly_stopped) + if services.get(mux.service.service_id) != expected: + raise ManagedContractError() + + +__all__ = ["ManagedDiscoveryV1", "ManagedMuxObservationV1", "ManagedServiceObservationV1", + "ManagedDiscoverySnapshotV1"] diff --git a/src/loushang/apphost/managed/invocation.py b/src/loushang/apphost/managed/invocation.py new file mode 100644 index 000000000..f751f0412 --- /dev/null +++ b/src/loushang/apphost/managed/invocation.py @@ -0,0 +1,138 @@ +"""Bounded child-start lookup message, not launch or connection authority. + +The inherited socket is passed separately and admitted by the bootstrap owner. +No credentials, file descriptors, Product factories or environment are decoded. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any + +from .contracts import ( + _HEX32, + ManagedContractError, + ManagedInstanceRefV1, + ManagedNamespaceV1, + ManagedServiceKeyV1, + _match, +) +from .paths import resolve_managed_paths + +_VERSION = "loushang.managed-child/v1" +_VERSION_SCRATCH = "loushang.managed-child/v2" +_VERSION_TRACE = "loushang.managed-child/v3" +_MAX_BYTES = 64 * 1024 +_MAX_SCRATCH_BYTES = 96 * 1024 +_FIELDS = frozenset({ + "version", "platformHome", "userId", "machineId", "productId", "workspace", + "profile", "instanceId", "attemptId", "runtimeRoot", +}) +_SCRATCH_FIELDS = _FIELDS | {"temporaryRoot"} +_TRACE_FIELDS = _FIELDS | {"traceDeadlineMs"} +_ALL_FIELDS = _SCRATCH_FIELDS | _TRACE_FIELDS + + +def _validate_trace_deadline(value: int | None) -> None: + if value is not None and (type(value) is not int or not 0 < value <= 10**15): + raise ManagedContractError() + + +@dataclass(frozen=True, slots=True) +class ManagedChildInvocationV1: + """Immutable addressing facts; the child must still verify durable birth.""" + + namespace: ManagedNamespaceV1 + service: ManagedServiceKeyV1 + instance: ManagedInstanceRefV1 + attempt_id: str = field(repr=False) + runtime_root: str = field(repr=False) + temporary_override: str | None = field(default=None, repr=False) + trace_deadline_ms: int | None = field(default=None, repr=False) + + def __post_init__(self) -> None: + # Reuse the exact identity and lexical layout checks, without native IO. + resolve_managed_paths(self.namespace, self.service, self.instance, runtime_root=self.runtime_root, + temporary_override=self.temporary_override) + _match(self.attempt_id, _HEX32) + _validate_trace_deadline(self.trace_deadline_ms) + + def to_json(self) -> str: + record: dict[str, str | int] = { + "version": _VERSION if self.temporary_override is None else _VERSION_SCRATCH, + "platformHome": self.namespace.platform_home, + "userId": self.namespace.user_id, + "machineId": self.namespace.machine_id, + "productId": self.service.product_id, + "workspace": self.service.workspace, + "profile": self.service.profile, + "instanceId": self.instance.instance_id, + "attemptId": self.attempt_id, + "runtimeRoot": self.runtime_root, + } + if self.temporary_override is not None: + record["temporaryRoot"] = self.temporary_override + if self.trace_deadline_ms is not None: + record["version"] = _VERSION_TRACE + record["traceDeadlineMs"] = self.trace_deadline_ms + payload = json.dumps(record, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + _bounded(payload, maximum=_MAX_BYTES if self.temporary_override is None else _MAX_SCRATCH_BYTES) + return payload + + @classmethod + def from_json(cls, payload: str) -> ManagedChildInvocationV1: + """Reject malformed data before any filesystem or descriptor admission.""" + _bounded(payload, maximum=_MAX_SCRATCH_BYTES) + try: + record = json.loads(payload, object_pairs_hook=_object, parse_constant=_constant) + if type(record) is not dict: + raise ManagedContractError() + trace_deadline = None + if record.get("version") == _VERSION and record.keys() == _FIELDS: + _bounded(payload, maximum=_MAX_BYTES) + temporary = None + elif record.get("version") == _VERSION_SCRATCH and record.keys() == _SCRATCH_FIELDS: + temporary = record["temporaryRoot"] + elif record.get("version") == _VERSION_TRACE and ( + record.keys() == _TRACE_FIELDS or record.keys() == _ALL_FIELDS + ): + temporary = record.get("temporaryRoot") + trace_deadline = record["traceDeadlineMs"] + if temporary is None: + _bounded(payload, maximum=_MAX_BYTES) + else: + raise ManagedContractError() + namespace = ManagedNamespaceV1(record["platformHome"], record["userId"], record["machineId"]) + service = ManagedServiceKeyV1(record["productId"], record["workspace"], record["profile"]) + instance = ManagedInstanceRefV1(namespace.namespace_key, service.service_id, record["instanceId"]) + return cls(namespace, service, instance, record["attemptId"], record["runtimeRoot"], temporary, trace_deadline) + except (ValueError, TypeError, RecursionError): + raise ManagedContractError() from None + + +def _bounded(payload: str, *, maximum: int) -> None: + # Character count first avoids allocating a huge UTF-8 copy of invalid input. + if type(payload) is not str or len(payload) > maximum: + raise ManagedContractError() + try: + if len(payload.encode("utf-8")) > maximum: + raise ManagedContractError() + except UnicodeError: + raise ManagedContractError() from None + + +def _object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + record: dict[str, Any] = {} + for key, value in pairs: + if key in record or key not in _ALL_FIELDS or type(value) not in (str, int): + raise ManagedContractError() + record[key] = value + return record + + +def _constant(value: str) -> Any: + raise ManagedContractError() + + +__all__ = ["ManagedChildInvocationV1"] diff --git a/src/loushang/apphost/managed/layout.py b/src/loushang/apphost/managed/layout.py new file mode 100644 index 000000000..05b97f959 --- /dev/null +++ b/src/loushang/apphost/managed/layout.py @@ -0,0 +1,107 @@ +"""Explicit new-deployment directory preparation using existing native owners. + +This is not service recovery: callers must not use it to recreate a lost +runtime or lifecycle fence for an existing/unknown live instance. It creates +neither authority records nor Session roots, and reads no environment. +""" + +from __future__ import annotations + +from pathlib import Path +from threading import RLock +from time import monotonic + +from ._files import ManagedStorageError, PrivateManagedDirectory, _check_deadline +from .contracts import ManagedInstanceRefV1, ManagedNamespaceV1, ManagedServiceKeyV1 +from .paths import ManagedDeploymentPathsV1, resolve_managed_paths + + +class ManagedLayoutPreparationV1: + """Retain all fixed directory owners before the first native operation. + + Synchronous composition only. Async callers must retain and join their + original native task; cancelling a waiter does not settle this object. + Failed initialization leaves directories in place, never recursively + removes them, and does not become a ready service or a recovery proof. + """ + + def __init__( + self, namespace: ManagedNamespaceV1, service: ManagedServiceKeyV1, + instance: ManagedInstanceRefV1, *, runtime_root: str, + temporary_override: str | None = None, + require_control_roots: bool = False, + ) -> None: + if type(require_control_roots) is not bool: + raise ManagedStorageError("invalid_record") + self._paths = resolve_managed_paths( + namespace, service, instance, runtime_root=runtime_root, + temporary_override=temporary_override, + ) + paths = tuple(Path(path) for path in ( + self.paths.registry, self.paths.lifecycle, self.paths.application, + self.paths.control, self.paths.logs, self.paths.temporary, + self.paths.cache, self.paths.connection, self.paths.runtime_control, + )) + # Include the anchor, all ancestor fds, final fd and opening slot. + if sum(len(path.parts) + 2 for path in paths) > 512: + raise ManagedStorageError("capacity") + self._directories = tuple( + PrivateManagedDirectory( + path, create=not (require_control_roots and index < 2), + create_parents=not (require_control_roots and index < 2), defer_open=True, + ) + for index, path in enumerate(paths) + ) + self._closed: set[int] = set() + self._mutex = RLock() + self._attempted = self._opened = self._closing = False + + @property + def paths(self) -> ManagedDeploymentPathsV1: + return self._paths + + @property + def cleanup_pending(self) -> bool: + return len(self._closed) != len(self._directories) + + @property + def initialized(self) -> bool: + return self._opened and not self._closing + + def open(self, *, deadline: float) -> None: + _check_deadline(deadline) + if not self._mutex.acquire(timeout=max(0.0, min(30.0, deadline - monotonic()))): + raise ManagedStorageError("busy") + try: + if self._attempted or self._closing: + raise ManagedStorageError("closed") + self._attempted = True + for directory in self._directories: + directory.open(deadline=deadline) + # No earlier owner can have been replaced while later leaves open. + for directory in self._directories: + _check_deadline(deadline) + directory._check() + _check_deadline(deadline) + self._opened = True + finally: + self._mutex.release() + + def close(self) -> None: + with self._mutex: + self._closing = True + failures: list[BaseException] = [] + for index, directory in enumerate(self._directories): + if index in self._closed: + continue + try: + directory.close() + except BaseException as error: + failures.append(error) + else: + self._closed.add(index) + if failures: + raise failures[0] + + +__all__ = ["ManagedLayoutPreparationV1"] diff --git a/src/loushang/apphost/managed/lifecycle.py b/src/loushang/apphost/managed/lifecycle.py index a8e6d682c..d3b9aefa3 100644 --- a/src/loushang/apphost/managed/lifecycle.py +++ b/src/loushang/apphost/managed/lifecycle.py @@ -17,7 +17,16 @@ from loushang.hosting.errors import HostingError from loushang.hosting.service import LinuxServiceIdentityV1 -from ._files import ManagedStorageError, PrivateManagedDirectory +from ._files import ( + ManagedStorageError, + PrivateManagedDirectory, + _check_deadline, + _check_lock_wait, +) +from .admission_record import ( + ManagedInitializationPhaseV1, + ManagedServiceAdmissionRecordV1, +) from .contracts import ( _HEX32, ManagedContractError, @@ -34,6 +43,23 @@ _MAX_REVISION = 2**63 - 1 +@dataclass(frozen=True, slots=True) +class ManagedTraceApplicationV1: + """Historical configuration fact, never a promise of continuing writes.""" + + instance_id: str + attempt_id: str + deadline_ms: int + configuration: str = "aggregate/v1" + + def __post_init__(self) -> None: + _match(self.instance_id, _HEX32) + _match(self.attempt_id, _HEX32) + if (type(self.deadline_ms) is not int or not 0 < self.deadline_ms <= 10**15 + or type(self.configuration) is not str or self.configuration != "aggregate/v1"): + raise ManagedContractError() + + @dataclass(frozen=True, slots=True) class ManagedServiceStateV1: """Last durable observation; not an assertion that an instance is alive.""" @@ -42,6 +68,7 @@ class ManagedServiceStateV1: handoff: ManagedHandoffV1 evidence: ManagedStopEvidenceV1 native_identity: LinuxServiceIdentityV1 | None = None + trace_application: ManagedTraceApplicationV1 | None = None def __post_init__(self) -> None: if ( @@ -53,6 +80,13 @@ def __post_init__(self) -> None: or (self.handoff.phase is ManagedHandoffPhaseV1.COMMITTED and self.native_identity is None) ): raise ManagedContractError() + trace = self.trace_application + if trace is not None and ( + type(trace) is not ManagedTraceApplicationV1 or self.native_identity is None + or trace.instance_id != self.handoff.instance.instance_id + or trace.attempt_id != self.handoff.attempt_id + ): + raise ManagedContractError() if ( not self.handoff.stop_requested and self.handoff.phase is not ManagedHandoffPhaseV1.ABORTING @@ -66,6 +100,33 @@ def cleanly_stopped(self) -> bool: return self.evidence.cleanly_stopped +@dataclass(frozen=True, slots=True) +class ManagedServiceTransitionV1: + """This generation's immutable predecessor evidence, not recovery authority.""" + + instance: ManagedInstanceRefV1 + attempt_id: str + started_revision: int + previous: ManagedServiceStateV1 | None + + def __post_init__(self) -> None: + if (type(self.instance) is not ManagedInstanceRefV1 or type(self.started_revision) is not int + or not 1 <= self.started_revision <= _MAX_REVISION): + raise ManagedContractError() + _match(self.attempt_id, _HEX32) + previous = self.previous + if previous is None: + if self.started_revision != 1: + raise ManagedContractError() + elif (type(previous) is not ManagedServiceStateV1 or not previous.cleanly_stopped + or previous.handoff.instance.namespace_key != self.instance.namespace_key + or previous.handoff.instance.service_id != self.instance.service_id + or previous.handoff.instance == self.instance + or previous.handoff.attempt_id == self.attempt_id + or previous.revision + 1 != self.started_revision): + raise ManagedContractError() + + class ManagedServiceJournalV1: """Borrow a registry; own only the injected per-service fence directory. @@ -77,7 +138,7 @@ class ManagedServiceJournalV1: def __init__( self, registry: ManagedRegistryV1, namespace: ManagedNamespaceV1, - service: ManagedServiceKeyV1, lifecycle_root: Path, *, create: bool = False, + service: ManagedServiceKeyV1, lifecycle_root: Path, *, create: bool = False, defer_open: bool = False, ) -> None: if (type(registry) is not ManagedRegistryV1 or type(namespace) is not ManagedNamespaceV1 or type(service) is not ManagedServiceKeyV1): @@ -86,11 +147,15 @@ def __init__( if self._database._namespace != namespace.namespace_key: raise ManagedContractError() self._namespace, self._service = namespace, service - self._fence = PrivateManagedDirectory(lifecycle_root, create=create) + if type(defer_open) is not bool: + raise ManagedContractError() + self._create = create + self._opened = False + self._fence = PrivateManagedDirectory(lifecycle_root, create=create, defer_open=True) + if defer_open: + return try: - if create: - with self._fence.lock("lifecycle.lock", create=True): - pass + self.open() except BaseException as error: try: self._fence.close() @@ -98,27 +163,57 @@ def __init__( error.add_note("managed_lifecycle_cleanup_incomplete") raise + def open(self, *, deadline: float | None = None, wait_for_lock: bool = False) -> None: + _check_lock_wait(wait_for_lock, deadline) + if self._database.service_admission_required and self._create: + # Managed control creation belongs to the durable admission owner, + # never to an opportunistic journal open or a missing instance. + raise ManagedStorageError("conflict") + if self._database.service_admission_required: + # A fresh lifecycle.lock exists before its creator can flock it. + # Do not steal that lock while its control fact is missing or + # initializing. This negative check grants no fence authority. + with self._database.transaction(deadline=deadline, wait_for_lock=wait_for_lock) as connection: + self._read_control(connection) + self._fence.open(deadline=deadline) + if self._create: + with self._fence.lock("lifecycle.lock", create=True, deadline=deadline, wait_for_lock=wait_for_lock): + pass + if self._database.service_admission_required: + with self._fence.lock("lifecycle.lock", deadline=deadline, wait_for_lock=wait_for_lock), self._database.transaction( + deadline=deadline, wait_for_lock=wait_for_lock, + ) as connection: + self._read(connection) + _check_deadline(deadline) + self._opened = True + def close(self) -> None: + self._opened = False self._fence.close() - def read(self, *, deadline: float | None = None) -> ManagedServiceStateV1 | None: - with self._fence.lock("lifecycle.lock", deadline=deadline), self._database.transaction(deadline=deadline) as connection: + def read(self, *, deadline: float | None = None, wait_for_lock: bool = False) -> ManagedServiceStateV1 | None: + self._require_open() + with self._fence.lock("lifecycle.lock", deadline=deadline, wait_for_lock=wait_for_lock), self._database.transaction( + deadline=deadline, wait_for_lock=wait_for_lock, + ) as connection: result = self._read(connection) self._fence._check() return result def prepare( self, attempt_id: str, *, expected: ManagedServiceStateV1 | None, + deadline: float | None = None, ) -> ManagedServiceStateV1: """Reserve a fresh generation, never infer absence from socket/PID loss. An uncertain result is resolved by read(), not by repeating process spawn. Existing generations require all three matching stop facts before reuse. """ + self._require_open() _match(attempt_id, _HEX32) if expected is not None and type(expected) is not ManagedServiceStateV1: raise ManagedContractError() - with self._fence.lock("lifecycle.lock"), self._database.transaction(write=True) as connection: + with self._fence.lock("lifecycle.lock", deadline=deadline), self._database.transaction(write=True, deadline=deadline) as connection: current = self._read(connection) if current != expected: raise ManagedStorageError("conflict") @@ -138,9 +233,25 @@ def prepare( ManagedStopEvidenceV1(instance, False, False, False), ) self._save(connection, state, insert=current is None) + transition = ManagedServiceTransitionV1(instance, attempt_id, state.revision, current) + connection.execute( + "INSERT INTO service_transitions VALUES (?, ?, ?, ?, ?) " + "ON CONFLICT(service_id) DO UPDATE SET instance_id=excluded.instance_id, " + "attempt_id=excluded.attempt_id, started_revision=excluded.started_revision, previous=excluded.previous", + (self._service.service_id, instance_id, attempt_id, transition.started_revision, _encode_previous(current)), + ) self._fence._check() return state + def read_transition(self, *, deadline: float | None = None) -> ManagedServiceTransitionV1 | None: + """Read a paired snapshot under the original fence, without repairing it.""" + self._require_open() + with self._fence.lock("lifecycle.lock", deadline=deadline), self._database.transaction(deadline=deadline) as connection: + state = self._read_state(connection) + result = self._read_transition(connection, state) + self._fence._check() + return result + def register_native( self, instance: ManagedInstanceRefV1, attempt_id: str, identity: LinuxServiceIdentityV1, *, deadline: float | None = None, @@ -186,6 +297,44 @@ def update(current: ManagedServiceStateV1) -> ManagedServiceStateV1: return self._update(instance, update, deadline=deadline) + def record_trace_application( + self, instance: ManagedInstanceRefV1, attempt_id: str, *, + native_identity: LinuxServiceIdentityV1, trace_deadline_ms: int, + deadline: float | None = None, + ) -> ManagedServiceStateV1: + """Child publishes after sink/consumer/storage admission, not before. + + This trusted composition seam does not itself install or inspect sinks. + The same fact is idempotent; stopped/replaced attempts cannot first + publish it, and unknown transaction results require original reobserve. + """ + self._require_instance(instance) + self._require_native(native_identity) + receipt = ManagedTraceApplicationV1(instance.instance_id, attempt_id, trace_deadline_ms) + def update(current: ManagedServiceStateV1) -> ManagedServiceStateV1: + if current.handoff.attempt_id != attempt_id or current.native_identity != native_identity: + raise ManagedStorageError("conflict") + if current.trace_application is not None: + if current.trace_application != receipt: + raise ManagedStorageError("conflict") + return current + if current.handoff.stop_requested or current.handoff.phase is ManagedHandoffPhaseV1.ABORTING: + raise ManagedStorageError("conflict") + _check_deadline(trace_deadline_ms / 1000) + return replace(current, trace_application=receipt) + # Historical same-value reads remain valid after expiry. A new write + # uses the trace window for the entire transaction, including its + # pre-COMMIT check, and rechecks the instance under the write fence. + observed = self.read(deadline=deadline, wait_for_lock=deadline is not None) + if observed is None or observed.handoff.instance != instance: + raise ManagedStorageError("conflict") + if observed.trace_application is not None: + return update(observed) + publication_deadline = trace_deadline_ms / 1000 + if deadline is not None: + publication_deadline = min(deadline, publication_deadline) + return self._update(instance, update, deadline=publication_deadline, normal_growth=True, wait_for_lock=True) + def abort( self, instance: ManagedInstanceRefV1, attempt_id: str, *, native_identity: LinuxServiceIdentityV1 | None = None, deadline: float | None = None, @@ -210,10 +359,10 @@ def update(current: ManagedServiceStateV1) -> ManagedServiceStateV1: return self._update(instance, update, deadline=deadline) - def request_stop(self, instance: ManagedInstanceRefV1) -> ManagedServiceStateV1: + def request_stop(self, instance: ManagedInstanceRefV1, *, deadline: float | None = None) -> ManagedServiceStateV1: return self._update(instance, lambda current: replace( current, handoff=current.handoff.request_stop(), - )) + ), deadline=deadline) def request_child_stop( self, instance: ManagedInstanceRefV1, attempt_id: str, native_identity: LinuxServiceIdentityV1, @@ -259,6 +408,30 @@ def _match_child(current: ManagedServiceStateV1, attempt_id: str, native: LinuxS or current.native_identity is not None and current.native_identity != native): raise ManagedStorageError("conflict") + def record_native_stop( + self, instance: ManagedInstanceRefV1, native_identity: LinuxServiceIdentityV1, *, + process_exited: bool, process_scope_settled: bool, deadline: float, + ) -> ManagedServiceStateV1: + """Native-only monotonic facts; never accept an application-cleanup bit.""" + self._require_native(native_identity) + if type(deadline) not in (int, float) or not 0 < deadline <= 1e12: + raise ManagedContractError() + if (type(process_exited) is not bool or type(process_scope_settled) is not bool + or process_scope_settled and not process_exited): + raise ManagedContractError() + + def update(current: ManagedServiceStateV1) -> ManagedServiceStateV1: + if (current.native_identity != native_identity or not current.handoff.stop_requested + and current.handoff.phase is not ManagedHandoffPhaseV1.ABORTING): + raise ManagedStorageError("conflict") + prior = current.evidence + return replace(current, evidence=replace( + prior, process_exited=prior.process_exited or process_exited, + process_scope_settled=prior.process_scope_settled or process_scope_settled, + )) + + return self._update(instance, update, deadline=deadline) + def record_stop_evidence(self, evidence: ManagedStopEvidenceV1) -> ManagedServiceStateV1: """Join monotonic facts from the trusted native/application observers.""" if type(evidence) is not ManagedStopEvidenceV1: @@ -280,10 +453,13 @@ def update(current: ManagedServiceStateV1) -> ManagedServiceStateV1: def _update( self, instance: ManagedInstanceRefV1, update: Callable[[ManagedServiceStateV1], ManagedServiceStateV1], - *, deadline: float | None = None, + *, deadline: float | None = None, normal_growth: bool = False, wait_for_lock: bool = False, ) -> ManagedServiceStateV1: + self._require_open() self._require_instance(instance) - with self._fence.lock("lifecycle.lock", deadline=deadline), self._database.transaction(write=True, deadline=deadline) as connection: + with self._fence.lock("lifecycle.lock", deadline=deadline, wait_for_lock=wait_for_lock), self._database.transaction( + write=True, deadline=deadline, wait_for_lock=wait_for_lock, + ) as connection: current = self._read(connection) if current is None or current.handoff.instance != instance: raise ManagedStorageError("conflict") @@ -294,12 +470,19 @@ def _update( if proposed == current: self._fence._check() return current - self._database.admit_control(connection) + if normal_growth: + self._database.admit_growth(connection) + else: + self._database.admit_control(connection) proposed = replace(proposed, revision=_next_revision(current)) self._save(connection, proposed, insert=False) self._fence._check() return proposed + def _require_open(self) -> None: + if not self._opened: + raise ManagedStorageError("closed") + def _require_instance(self, instance: ManagedInstanceRefV1) -> None: if (type(instance) is not ManagedInstanceRefV1 or instance.namespace_key != self._namespace.namespace_key @@ -307,45 +490,148 @@ def _require_instance(self, instance: ManagedInstanceRefV1) -> None: raise ManagedContractError() def _read(self, connection: sqlite3.Connection) -> ManagedServiceStateV1 | None: + state = self._read_state(connection) + self._read_transition(connection, state) + return state + + def _read_state(self, connection: sqlite3.Connection) -> ManagedServiceStateV1 | None: service = self._service key = connection.execute("SELECT product, workspace, profile FROM services WHERE service_id=?", (service.service_id,)).fetchone() if key != (service.product_id, service.workspace, service.profile): raise ManagedStorageError("conflict") + if self._database.service_admission_required: + control = self._read_control(connection) + self._fence._check() + lock_identity = self._fence._locks["lifecycle.lock"][1] + self._fence._check_named("lifecycle.lock", lock_identity) + if (control.service_id != service.service_id or control.root_identity != self._fence._identity + or control.lock_identity != lock_identity): + raise ManagedStorageError("conflict") row = connection.execute("SELECT revision, instance_id, attempt_id, phase, stop_requested, " - "process_exited, application_cleanup_completed, process_scope_settled, native_identity " + "process_exited, application_cleanup_completed, process_scope_settled, native_identity, trace_application " "FROM instances WHERE service_id=?", (service.service_id,)).fetchone() if row is None: return None + return _decode_state(self._namespace, service, row) + + def _read_transition( + self, connection: sqlite3.Connection, state: ManagedServiceStateV1 | None, + ) -> ManagedServiceTransitionV1 | None: + row = connection.execute( + "SELECT instance_id, attempt_id, started_revision, previous FROM service_transitions WHERE service_id=?", + (self._service.service_id,), + ).fetchone() + if state is None and row is None: + return None + if state is None or row is None: + raise ManagedStorageError("invalid_record") try: - if any(type(value) is not int or value not in (0, 1) for value in row[4:8]): + previous = None + if row[3] is not None: + if type(row[3]) is not str or len(row[3]) > 4096: + raise ManagedContractError() + raw = json.loads(row[3]) + if (type(raw) is not list or len(raw) != 12 + or raw[:2] != [self._namespace.namespace_key, self._service.service_id]): + raise ManagedContractError() + previous = _decode_state(self._namespace, self._service, tuple(raw[2:])) + if _encode_previous(previous) != row[3]: + raise ManagedContractError() + transition = ManagedServiceTransitionV1( + ManagedInstanceRefV1(self._namespace.namespace_key, self._service.service_id, row[0]), + row[1], row[2], previous, + ) + if (transition.instance != state.handoff.instance or transition.attempt_id != state.handoff.attempt_id + or transition.started_revision > state.revision): raise ManagedContractError() - instance = ManagedInstanceRefV1(self._namespace.namespace_key, service.service_id, row[1]) - handoff = ManagedHandoffV1(instance, row[2], ManagedHandoffPhaseV1(row[3]), bool(row[4])) - native = _decode_native(row[8]) - if native is not None: - self._require_native(native) - return ManagedServiceStateV1(row[0], handoff, ManagedStopEvidenceV1( - instance, bool(row[5]), bool(row[6]), bool(row[7]), - ), native) - except (ManagedContractError, HostingError, ValueError, TypeError): + return transition + except (ManagedContractError, ValueError, TypeError, RecursionError): + raise ManagedStorageError("invalid_record") from None + + def _read_control(self, connection: sqlite3.Connection) -> ManagedServiceAdmissionRecordV1: + row = connection.execute("SELECT record FROM service_controls WHERE service_id=?", + (self._service.service_id,)).fetchone() + if row is None: + raise ManagedStorageError("unavailable") + try: + control = ManagedServiceAdmissionRecordV1.from_json(row[0]) + except ManagedContractError: raise ManagedStorageError("invalid_record") from None + if control.service_id != self._service.service_id: + raise ManagedStorageError("conflict") + if control.phase is not ManagedInitializationPhaseV1.INITIALIZED: + raise ManagedStorageError("unavailable") + return control def _save(self, connection: sqlite3.Connection, state: ManagedServiceStateV1, *, insert: bool) -> None: - handoff, evidence = state.handoff, state.evidence - values = (state.revision, handoff.instance.instance_id, handoff.attempt_id, handoff.phase.value, - int(handoff.stop_requested), int(evidence.process_exited), - int(evidence.application_cleanup_completed), int(evidence.process_scope_settled), - _encode_native(state.native_identity), - self._service.service_id) + values = (*_state_row(state), self._service.service_id) if insert: connection.execute("INSERT INTO instances (revision, instance_id, attempt_id, phase, " "stop_requested, process_exited, application_cleanup_completed, " - "process_scope_settled, native_identity, service_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", values) + "process_scope_settled, native_identity, trace_application, service_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", values) else: connection.execute("UPDATE instances SET revision=?, instance_id=?, attempt_id=?, phase=?, " "stop_requested=?, process_exited=?, application_cleanup_completed=?, " - "process_scope_settled=?, native_identity=? WHERE service_id=?", values) + "process_scope_settled=?, native_identity=?, trace_application=? WHERE service_id=?", values) + + +def _state_row(state: ManagedServiceStateV1) -> tuple: + handoff, evidence = state.handoff, state.evidence + return (state.revision, handoff.instance.instance_id, handoff.attempt_id, handoff.phase.value, + int(handoff.stop_requested), int(evidence.process_exited), int(evidence.application_cleanup_completed), + int(evidence.process_scope_settled), _encode_native(state.native_identity), _encode_trace(state.trace_application)) + + +def _encode_previous(state: ManagedServiceStateV1 | None) -> str | None: + if state is None: + return None + instance = state.handoff.instance + return json.dumps((instance.namespace_key, instance.service_id, *_state_row(state)), separators=(",", ":")) + + +def _decode_state( + namespace: ManagedNamespaceV1, service: ManagedServiceKeyV1, row: tuple, +) -> ManagedServiceStateV1: + """Same strict durable decoder for fenced owners and read-only discovery. + + Decoding conveys no lifecycle authority and performs no native observation. + """ + try: + if len(row) != 10 or any(type(value) is not int or value not in (0, 1) for value in row[4:8]): + raise ManagedContractError() + instance = ManagedInstanceRefV1(namespace.namespace_key, service.service_id, row[1]) + handoff = ManagedHandoffV1(instance, row[2], ManagedHandoffPhaseV1(row[3]), bool(row[4])) + native = _decode_native(row[8]) + if native is not None and native.user_id != namespace.user_id: + raise ManagedContractError() + return ManagedServiceStateV1(row[0], handoff, ManagedStopEvidenceV1( + instance, bool(row[5]), bool(row[6]), bool(row[7]), + ), native, _decode_trace(row[9])) + except (ManagedContractError, HostingError, ValueError, TypeError): + raise ManagedStorageError("invalid_record") from None + + +def _encode_trace(receipt: ManagedTraceApplicationV1 | None) -> str | None: + return None if receipt is None else json.dumps({"v": 1, **asdict(receipt)}, sort_keys=True, separators=(",", ":")) + + +def _decode_trace(value: object) -> ManagedTraceApplicationV1 | None: + if value is None: + return None + if type(value) is not str or len(value) > 512: + raise ManagedContractError() + try: + row = json.loads(value) + if (type(row) is not dict or row.keys() != {"v", "instance_id", "attempt_id", "deadline_ms", "configuration"} + or type(row["v"]) is not int or row["v"] != 1): + raise ManagedContractError() + receipt = ManagedTraceApplicationV1(row["instance_id"], row["attempt_id"], row["deadline_ms"], row["configuration"]) + if _encode_trace(receipt) != value: + raise ManagedContractError() + return receipt + except (ValueError, TypeError, RecursionError): + raise ManagedContractError() from None def _encode_native(identity: LinuxServiceIdentityV1 | None) -> str | None: diff --git a/src/loushang/apphost/managed/namespace_admission.py b/src/loushang/apphost/managed/namespace_admission.py new file mode 100644 index 000000000..2843fd48f --- /dev/null +++ b/src/loushang/apphost/managed/namespace_admission.py @@ -0,0 +1,236 @@ +"""Retained Linux namespace storage admission, independent of service readiness. + +An explicit first-use request may initialize absent storage. Existing witnesses +are always opened without creation. Failed attempts never delete or adopt their +residue; callers retain this owner until its local cleanup has settled. +""" + +from __future__ import annotations + +import os +from dataclasses import replace +from pathlib import Path, PurePosixPath +from secrets import token_hex +from threading import RLock +from time import monotonic + +from ._files import ( + ManagedStorageError, + PrivateManagedDirectory, + _check_deadline, + _check_lock_wait, +) +from .admission_record import ( + ManagedInitializationPhaseV1, + ManagedNamespaceAdmissionRecordV1, +) +from .contracts import ManagedContractError, ManagedNamespaceV1, _path +from .paths import ( + _overlaps, + resolve_managed_admission_root, + resolve_managed_registry_root, +) +from .registry import ManagedRegistryV1 + +_LOCK = "admission.lock" +_RECORD = "admission.json" + + +class ManagedNamespaceAdmissionV1: + """Own the registry and admission descriptors; create no Session or service. + + Construct before native IO and call open once. Concurrent first-use losers + may retry with a fresh owner only after closing this one; no failed open is + replayed on the original container. Persistent initializing requires explicit + recovery, not an unbounded wait or automatic takeover. + """ + + def __init__(self, namespace: ManagedNamespaceV1, *, runtime_root: str, + create_if_missing: bool = False) -> None: + if (type(namespace) is not ManagedNamespaceV1 or namespace.user_id != os.geteuid() + or type(create_if_missing) is not bool): + raise ManagedContractError() + _path(runtime_root) + admission = Path(resolve_managed_admission_root(namespace)) + registry = Path(resolve_managed_registry_root(namespace)) + runtime = Path(runtime_root) / "lmux" / namespace.namespace_key + if (_overlaps(PurePosixPath(runtime), PurePosixPath(namespace.platform_home) / "lmux") + or _overlaps(PurePosixPath(runtime), PurePosixPath(namespace.platform_home) / "state")): + raise ManagedContractError() + self._namespace = namespace + self._runtime_root = runtime_root + self._registry_root = registry + self._create = create_if_missing + self._record = ManagedNamespaceAdmissionRecordV1( + namespace.namespace_key, token_hex(16), token_hex(16), + ManagedInitializationPhaseV1.INITIALIZING, + ) + self._existing = PrivateManagedDirectory(admission, defer_open=True) + self._fresh = PrivateManagedDirectory(admission, create=True, create_parents=True, + exclusive_create=True, defer_open=True) + self._probes = tuple(PrivateManagedDirectory(root, defer_open=True) + for root in (registry.parent, runtime)) + self._directories = (self._existing, self._fresh, *self._probes) + self._registry: ManagedRegistryV1 | None = None + self._registry_closed = False + self._closed: set[int] = set() + self._attempted = self._opened = self._closing = False + self._mutex = RLock() + + @property + def registry(self) -> ManagedRegistryV1: + if not self._opened or self._closing or self._registry is None: + raise ManagedStorageError("closed") + return self._registry + + @property + def cleanup_pending(self) -> bool: + return (len(self._closed) != len(self._directories) + or (self._registry is not None and not self._registry_closed)) + + def open(self, *, deadline: float, wait_for_lock: bool = False) -> ManagedRegistryV1: + """Open original storage; not_found means witness and residue absent. + + Missing dependencies of an initialized witness are unavailable, not a + fresh namespace. This classification never authorizes creation itself. + """ + _check_deadline(deadline) + _check_lock_wait(wait_for_lock, deadline) + if not self._mutex.acquire(timeout=max(0.0, min(30.0, deadline - monotonic()))): + raise ManagedStorageError("busy") + try: + if self._attempted or self._closing: + raise ManagedStorageError("closed") + self._attempted = True + _check_deadline(deadline) + try: + self._existing.open(deadline=deadline) + except ManagedStorageError as error: + if error.code != "not_found": + raise + if not self._create: + self._require_no_residue(deadline) + raise + # Settle the failed path walk before claiming any creation. + self._existing.close() + self._closed.add(0) + self._initialize(deadline) + else: + try: + self._reopen(deadline, wait_for_lock=wait_for_lock) + except ManagedStorageError as error: + if error.code == "not_found": + raise ManagedStorageError("unavailable") from None + raise + _check_deadline(deadline) + self._opened = True + return self.registry + finally: + self._mutex.release() + + def _require_no_residue(self, deadline: float) -> None: + for probe in self._probes: + try: + probe.open(deadline=deadline) + except ManagedStorageError as error: + if error.code != "not_found": + raise + else: + # Even an empty surviving machine/runtime directory is residue, + # not evidence that a lost admission witness can be recreated. + raise ManagedStorageError("conflict") + + def _initialize(self, deadline: float) -> None: + self._require_no_residue(deadline) + self._fresh.open(deadline=deadline) + with self._fresh.lock(_LOCK, create=True, exclusive_create=True, deadline=deadline): + self._record = replace( + self._record, admission_root_identity=self._fresh._identity, + admission_lock_identity=self._fresh._locks[_LOCK][1], + ) + self._fresh.write(_RECORD, self._record.to_json().encode(), expected=None) + intent = self._fresh.read(_RECORD) + if intent is None or intent.content != self._record.to_json().encode(): + raise ManagedStorageError("conflict") + _check_deadline(deadline) + self._registry = ManagedRegistryV1( + self._registry_root, self._namespace, create=True, exclusive_create=True, + create_parents=True, deployment_id=self._record.deployment_id, defer_open=True, + service_admission_required=True, + ) + self._registry.open(deadline=deadline) + database = self._registry._database + complete = replace( + self._record, phase=ManagedInitializationPhaseV1.INITIALIZED, + registry_root_identity=database._directory._identity, + database_identity=database._file_identity, + registry_lock_identity=database._lock_identity, + ) + _check_deadline(deadline) + self._fresh.write(_RECORD, complete.to_json().encode(), expected=intent) + published = self._fresh.read(_RECORD) + if published is None or published.content != complete.to_json().encode(): + raise ManagedStorageError("conflict") + self._record = complete + + def _reopen(self, deadline: float, *, wait_for_lock: bool = False) -> None: + # A new stable lock is visible before its creator can flock it. A + # reader without any marker must not steal that first lock and make + # both attempts fail. This negative precheck grants no authority; + # the actual record is always read again under the admitted lock. + if self._existing.read(_RECORD) is None: + raise ManagedStorageError("invalid_record") + with self._existing.lock(_LOCK, deadline=deadline, wait_for_lock=wait_for_lock): + snapshot = self._existing.read(_RECORD) + if snapshot is None: + raise ManagedStorageError("invalid_record") + try: + record = ManagedNamespaceAdmissionRecordV1.from_json(snapshot.content.decode("utf-8")) + except (ManagedContractError, UnicodeError): + raise ManagedStorageError("invalid_record") from None + if record.namespace_key != self._namespace.namespace_key: + raise ManagedStorageError("conflict") + if (record.admission_root_identity != self._existing._identity + or record.admission_lock_identity != self._existing._locks[_LOCK][1]): + raise ManagedStorageError("conflict") + if record.phase is not ManagedInitializationPhaseV1.INITIALIZED: + raise ManagedStorageError("unavailable") + self._registry = ManagedRegistryV1( + self._registry_root, self._namespace, deployment_id=record.deployment_id, defer_open=True, + service_admission_required=True, + ) + self._registry.open(deadline=deadline, wait_for_lock=wait_for_lock) + database = self._registry._database + if (database._directory._identity != record.registry_root_identity + or database._file_identity != record.database_identity + or database._lock_identity != record.registry_lock_identity + or self._existing.read(_RECORD) != snapshot): + raise ManagedStorageError("conflict") + self._record = record + + def close(self) -> None: + with self._mutex: + self._closing = True + self._opened = False + failures: list[BaseException] = [] + if self._registry is not None and not self._registry_closed: + try: + self._registry.close() + except BaseException as error: + failures.append(error) + else: + self._registry_closed = True + for index, directory in enumerate(self._directories): + if index in self._closed: + continue + try: + directory.close() + except BaseException as error: + failures.append(error) + else: + self._closed.add(index) + if failures: + raise failures[0] + + +__all__ = ["ManagedNamespaceAdmissionV1"] diff --git a/src/loushang/apphost/managed/paths.py b/src/loushang/apphost/managed/paths.py index 53f55d58d..191f8abb2 100644 --- a/src/loushang/apphost/managed/paths.py +++ b/src/loushang/apphost/managed/paths.py @@ -17,6 +17,28 @@ _path, ) +# Names shared by the two diagnostic formats in one retained directory. These +# are layout facts only; each consumer must validate its own files and charges. +LIFECYCLE_LOG_NAMES = tuple(f"lifecycle-{slot}.jsonl" for slot in range(5)) +TRACE_LOG_NAMES = ("trace-0.jsonl", "trace-1.jsonl") +MANAGED_LOG_DIRECTORY_NAMES = frozenset({ + "lifecycle.lock", *LIFECYCLE_LOG_NAMES, "trace.lock", *TRACE_LOG_NAMES, +}) + + +@dataclass(frozen=True, slots=True) +class ManagedServicePathsV1: + """Stable service paths, available before an instance is reserved.""" + + registry: PurePosixPath = field(repr=False) + lifecycle: PurePosixPath = field(repr=False) + application: PurePosixPath = field(repr=False) + control: PurePosixPath = field(repr=False) + logs: PurePosixPath = field(repr=False) + cache: PurePosixPath = field(repr=False) + connection: PurePosixPath = field(repr=False) + runtime_control: PurePosixPath = field(repr=False) + @dataclass(frozen=True, slots=True) class ManagedDeploymentPathsV1: @@ -33,6 +55,43 @@ class ManagedDeploymentPathsV1: runtime_control: PurePosixPath = field(repr=False) +def resolve_managed_registry_root(namespace: ManagedNamespaceV1) -> PurePosixPath: + """Namespace-level index location, independent of service and client cwd.""" + if type(namespace) is not ManagedNamespaceV1: + raise ManagedContractError() + return PurePosixPath(namespace.platform_home) / "lmux" / "machines" / namespace.machine_id / "registry" + + +def resolve_managed_admission_root(namespace: ManagedNamespaceV1) -> PurePosixPath: + """Independent durable initialization witness; resolving grants no IO.""" + if type(namespace) is not ManagedNamespaceV1: + raise ManagedContractError() + return PurePosixPath(namespace.platform_home) / "state" / "managed-deployments" / namespace.namespace_key + + +def resolve_managed_service_paths( + namespace: ManagedNamespaceV1, service: ManagedServiceKeyV1, *, runtime_root: str, +) -> ManagedServicePathsV1: + if type(namespace) is not ManagedNamespaceV1 or type(service) is not ManagedServiceKeyV1: + raise ManagedContractError() + _path(runtime_root) + root = PurePosixPath(namespace.platform_home) / "lmux" + runtime = PurePosixPath(runtime_root) / "lmux" / namespace.namespace_key / service.service_id + # Durable shared state is not deployment scratch, regardless of which + # subsystem owns a witness beneath it. + admission = PurePosixPath(namespace.platform_home) / "state" + if _overlaps(root, runtime) or _overlaps(admission, runtime): + raise ManagedContractError() + machine = root / "machines" / namespace.machine_id + server = machine / "servers" / service.service_id + return ManagedServicePathsV1( + registry=resolve_managed_registry_root(namespace), lifecycle=machine / "lifecycle" / service.service_id, + application=server / "state" / "application", control=server / "state" / "control", + logs=server / "logs", cache=machine / "cache", + connection=runtime / "connection", runtime_control=runtime / "control", + ) + + def resolve_managed_paths( namespace: ManagedNamespaceV1, service: ManagedServiceKeyV1, @@ -56,7 +115,7 @@ def resolve_managed_paths( or instance.service_id != service.service_id ): raise ManagedContractError() - _path(runtime_root) + stable = resolve_managed_service_paths(namespace, service, runtime_root=runtime_root) root = PurePosixPath(namespace.platform_home) / "lmux" runtime_base = PurePosixPath(runtime_root) runtime = runtime_base / "lmux" / namespace.namespace_key / service.service_id @@ -72,18 +131,19 @@ def resolve_managed_paths( / "lmux" / namespace.namespace_key / service.service_id / instance.instance_id ) - if _overlaps(root, temporary) or _overlaps(runtime, temporary): + if (_overlaps(root, temporary) or _overlaps(runtime, temporary) + or _overlaps(PurePosixPath(namespace.platform_home) / "state", temporary)): raise ManagedContractError() return ManagedDeploymentPathsV1( - registry=machine / "registry", - lifecycle=machine / "lifecycle" / service.service_id, - application=server / "state" / "application", - control=server / "state" / "control", - logs=server / "logs", + registry=stable.registry, + lifecycle=stable.lifecycle, + application=stable.application, + control=stable.control, + logs=stable.logs, temporary=temporary, - cache=machine / "cache", - connection=runtime / "connection", - runtime_control=runtime / "control", + cache=stable.cache, + connection=stable.connection, + runtime_control=stable.runtime_control, ) @@ -91,4 +151,4 @@ def _overlaps(left: PurePosixPath, right: PurePosixPath) -> bool: return left.is_relative_to(right) or right.is_relative_to(left) -__all__ = ["ManagedDeploymentPathsV1", "resolve_managed_paths"] +__all__ = ["ManagedDeploymentPathsV1", "ManagedServicePathsV1", "resolve_managed_paths", "resolve_managed_service_paths", "resolve_managed_registry_root", "resolve_managed_admission_root"] diff --git a/src/loushang/apphost/managed/registry.py b/src/loushang/apphost/managed/registry.py index e11217f3f..27f0e205c 100644 --- a/src/loushang/apphost/managed/registry.py +++ b/src/loushang/apphost/managed/registry.py @@ -9,15 +9,19 @@ from dataclasses import dataclass from pathlib import Path +from loushang.appserver.managed_mux import ManagedMuxCreatedV1 + from ._database import ManagedDatabase -from ._files import ManagedStorageError +from ._files import ManagedStorageError, _check_deadline from .contracts import ( _HEX32, + _HEX64, ManagedContractError, ManagedNamespaceV1, ManagedServiceKeyV1, _match, require_mux_name, + require_service_alias, ) MAX_SERVICES = 128 @@ -40,15 +44,65 @@ def __post_init__(self) -> None: _match(self.operation_id, _HEX32) +@dataclass(frozen=True, slots=True) +class ManagedServiceAliasReservationV1: + """Immutable service label intent, not a live instance or startup permit.""" + + name: str + service: ManagedServiceKeyV1 + operation_id: str + + def __post_init__(self) -> None: + require_service_alias(self.name) + if type(self.service) is not ManagedServiceKeyV1: + raise ManagedContractError() + _match(self.operation_id, _HEX32) + + +@dataclass(frozen=True, slots=True) +class ManagedMuxCreationInspectionV1: + """Read-only creation history, not authority or evidence of a live Mux.""" + + reservation: ManagedMuxReservationV1 + active: bool + created: ManagedMuxCreatedV1 | None + + def __post_init__(self) -> None: + if type(self.reservation) is not ManagedMuxReservationV1 or type(self.active) is not bool: + raise ManagedContractError() + if self.created is not None and (type(self.created) is not ManagedMuxCreatedV1 + or self.created.operation_id != self.reservation.operation_id + or self.created.name != self.reservation.name): + raise ManagedContractError() + + class ManagedRegistryV1: """User/machine-scoped name index, safe to reopen from any client cwd.""" def __init__( - self, root: Path, namespace: ManagedNamespaceV1, *, create: bool = False, + self, root: Path, namespace: ManagedNamespaceV1, *, create: bool = False, defer_open: bool = False, + exclusive_create: bool = False, create_parents: bool = False, deployment_id: str | None = None, + service_admission_required: bool | None = None, ) -> None: - if type(namespace) is not ManagedNamespaceV1: + if type(namespace) is not ManagedNamespaceV1 or type(defer_open) is not bool: raise ManagedContractError() - self._database = ManagedDatabase(root, namespace.namespace_key, create=create) + self._database = ManagedDatabase(root, namespace.namespace_key, create=create, defer_open=True, + exclusive_create=exclusive_create, create_parents=create_parents, + deployment_id=deployment_id, + service_admission_required=service_admission_required) + if defer_open: + return + try: + self.open() + except BaseException as error: + try: + self.close() + except BaseException: + error.add_note("managed_registry_cleanup_incomplete") + raise + + def open(self, *, deadline: float | None = None, wait_for_lock: bool = False) -> None: + self._database.open(deadline=deadline, wait_for_lock=wait_for_lock) def close(self) -> None: self._database.close() @@ -57,21 +111,31 @@ def close(self) -> None: def cleanup_pending(self) -> bool: return self._database.cleanup_pending - def reserve_mux(self, reservation: ManagedMuxReservationV1) -> ManagedMuxReservationV1: + def reserve_mux( + self, reservation: ManagedMuxReservationV1, *, deadline: float | None = None, + wait_for_lock: bool = False, + ) -> ManagedMuxReservationV1: if type(reservation) is not ManagedMuxReservationV1: raise ManagedContractError() - with self._database.transaction(write=True) as connection: - # Retries are identified by the full intent, not just a reused name. + with self._database.transaction(write=True, deadline=deadline, wait_for_lock=wait_for_lock) as connection: + if connection.execute("SELECT 1 FROM mux_close_authorities WHERE operation_id=?", + (reservation.operation_id,)).fetchone() is not None: + raise ManagedStorageError("conflict") + # History never reactivates a released operation, even under the + # same name. Only the exact still-active intent is an idempotent retry. old = connection.execute( "SELECT m.name, s.product, s.workspace, s.profile, m.operation_id, s.service_id " - "FROM muxes m JOIN services s USING(service_id) " - "WHERE m.name=? OR m.operation_id=? LIMIT 2", - (reservation.name, reservation.operation_id), - ).fetchall() - if old: - if len(old) == 1 and _decode(old[0]) == reservation: + "FROM mux_intents m JOIN services s USING(service_id) WHERE m.operation_id=?", + (reservation.operation_id,), + ).fetchone() + active = connection.execute("SELECT operation_id FROM muxes WHERE name=?", + (reservation.name,)).fetchone() + if old is not None: + if _decode(old) == reservation and active == (reservation.operation_id,): return reservation raise ManagedStorageError("conflict") + if active is not None: + raise ManagedStorageError("conflict") key = reservation.service stored = connection.execute( "SELECT product, workspace, profile FROM services WHERE service_id=?", @@ -79,7 +143,7 @@ def reserve_mux(self, reservation: ManagedMuxReservationV1) -> ManagedMuxReserva ).fetchone() if stored is not None and stored != (key.product_id, key.workspace, key.profile): raise ManagedStorageError("conflict") - if connection.execute("SELECT count(*) FROM muxes").fetchone()[0] >= MAX_MUXES: + if connection.execute("SELECT count(*) FROM mux_intents").fetchone()[0] >= MAX_MUXES: raise ManagedStorageError("capacity") self._database.admit_growth(connection) if stored is None: @@ -87,10 +151,59 @@ def reserve_mux(self, reservation: ManagedMuxReservationV1) -> ManagedMuxReserva raise ManagedStorageError("capacity") connection.execute("INSERT INTO services VALUES (?, ?, ?, ?)", (key.service_id, key.product_id, key.workspace, key.profile)) - connection.execute("INSERT INTO muxes VALUES (?, ?, ?)", - (reservation.name, key.service_id, reservation.operation_id)) + values = (reservation.name, key.service_id, reservation.operation_id) + connection.execute("INSERT INTO mux_intents VALUES (?, ?, ?)", values) + connection.execute("INSERT INTO muxes VALUES (?, ?, ?)", values) + return reservation + + def reserve_service_alias( + self, reservation: ManagedServiceAliasReservationV1, *, deadline: float | None = None, + wait_for_lock: bool = False, + ) -> ManagedServiceAliasReservationV1: + if type(reservation) is not ManagedServiceAliasReservationV1: + raise ManagedContractError() + key = reservation.service + with self._database.transaction(write=True, deadline=deadline, wait_for_lock=wait_for_lock) as connection: + rows = connection.execute( + "SELECT a.name, s.product, s.workspace, s.profile, a.operation_id, s.service_id " + "FROM service_aliases a JOIN services s USING(service_id) " + "WHERE a.name=? OR a.service_id=? OR a.operation_id=?", + (reservation.name, key.service_id, reservation.operation_id), + ).fetchall() + if rows: + if len(rows) != 1 or _decode_alias(rows[0]) != reservation: + raise ManagedStorageError("conflict") + else: + stored = connection.execute( + "SELECT product, workspace, profile FROM services WHERE service_id=?", (key.service_id,), + ).fetchone() + if stored is not None and stored != (key.product_id, key.workspace, key.profile): + raise ManagedStorageError("conflict") + if (connection.execute("SELECT count(*) FROM service_aliases").fetchone()[0] >= MAX_SERVICES + or stored is None and connection.execute("SELECT count(*) FROM services").fetchone()[0] >= MAX_SERVICES): + raise ManagedStorageError("capacity") + self._database.admit_growth(connection) + if stored is None: + connection.execute("INSERT INTO services VALUES (?, ?, ?, ?)", + (key.service_id, key.product_id, key.workspace, key.profile)) + connection.execute("INSERT INTO service_aliases VALUES (?, ?, ?)", + (reservation.name, key.service_id, reservation.operation_id)) + _check_deadline(deadline) return reservation + def resolve_service_alias( + self, name: str, *, deadline: float | None = None, wait_for_lock: bool = False, + ) -> ManagedServiceAliasReservationV1 | None: + require_service_alias(name) + with self._database.transaction(deadline=deadline, wait_for_lock=wait_for_lock) as connection: + row = connection.execute( + "SELECT a.name, s.product, s.workspace, s.profile, a.operation_id, s.service_id " + "FROM service_aliases a JOIN services s USING(service_id) WHERE a.name=?", (name,), + ).fetchone() + result = None if row is None else _decode_alias(row) + _check_deadline(deadline) + return result + def resolve(self, name: str) -> ManagedMuxReservationV1 | None: require_mux_name(name) with self._database.transaction() as connection: @@ -101,6 +214,50 @@ def resolve(self, name: str) -> ManagedMuxReservationV1 | None: ).fetchone() return None if row is None else _decode(row) + def inspect_mux_creation( + self, operation_id: str, *, deadline: float | None = None, wait_for_lock: bool = False, + ) -> ManagedMuxCreationInspectionV1 | None: + """Read the exact intent even before service admission or after release. + + No permit is issued and no name is adopted. A missing creation receipt + is unknown, never proof that an earlier RPC had no effect. + """ + _match(operation_id, _HEX32) + with self._database.transaction(deadline=deadline, wait_for_lock=wait_for_lock) as connection: + row = connection.execute( + "SELECT m.name, s.product, s.workspace, s.profile, m.operation_id, s.service_id " + "FROM mux_intents m JOIN services s USING(service_id) WHERE m.operation_id=?", + (operation_id,), + ).fetchone() + result = None + if row is not None: + reservation = _decode(row) + active = connection.execute("SELECT name, service_id FROM muxes WHERE operation_id=?", + (operation_id,)).fetchone() + if active is not None and active != (reservation.name, reservation.service.service_id): + raise ManagedStorageError("invalid_record") + permit = connection.execute( + "SELECT instance_id, origin_instance_id, authority, created_instance_id, mux_space_id " + "FROM mux_authorities WHERE operation_id=?", (operation_id,), + ).fetchone() + created = None + if permit is not None: + try: + _match(permit[0], _HEX32) + _match(permit[1], _HEX32) + _match(permit[2], _HEX64) + if (permit[3] is None) != (permit[4] is None): + raise ManagedContractError() + if permit[3] is not None: + created = ManagedMuxCreatedV1(operation_id, permit[3], reservation.name, permit[4]) + if created.instance_id != permit[1]: + raise ManagedContractError() + except (ValueError, TypeError): + raise ManagedStorageError("invalid_record") from None + result = ManagedMuxCreationInspectionV1(reservation, active is not None, created) + _check_deadline(deadline) + return result + def list_muxes( self, *, after: str | None = None, limit: int = MAX_PAGE, ) -> tuple[ManagedMuxReservationV1, ...]: @@ -125,3 +282,14 @@ def _decode(row: tuple) -> ManagedMuxReservationV1: return reservation except (ManagedContractError, IndexError, TypeError): raise ManagedStorageError("invalid_record") from None + + +def _decode_alias(row: tuple) -> ManagedServiceAliasReservationV1: + try: + key = ManagedServiceKeyV1(row[1], row[2], row[3]) + result = ManagedServiceAliasReservationV1(row[0], key, row[4]) + if key.service_id != row[5]: + raise ManagedContractError() + return result + except (ManagedContractError, IndexError, TypeError): + raise ManagedStorageError("invalid_record") from None diff --git a/src/loushang/apphost/managed/service_admission.py b/src/loushang/apphost/managed/service_admission.py new file mode 100644 index 000000000..974272cf0 --- /dev/null +++ b/src/loushang/apphost/managed/service_admission.py @@ -0,0 +1,193 @@ +"""Service-control intent before native fence creation, without process authority.""" + +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path +from secrets import token_hex +from threading import RLock +from time import monotonic, sleep + +from ._files import ( + ManagedStorageError, + PrivateManagedDirectory, + _check_deadline, + _check_lock_wait, +) +from .admission_record import ( + ManagedInitializationPhaseV1, + ManagedServiceAdmissionRecordV1, +) +from .contracts import ManagedContractError, ManagedServiceKeyV1 +from .lifecycle import ManagedServiceJournalV1 +from .namespace_admission import ManagedNamespaceAdmissionV1 +from .paths import resolve_managed_service_paths +from .registry import MAX_SERVICES + + +class ManagedServiceAdmissionV1: + """Borrow the original namespace; own only this service's fence and journal. + + Call open once after retaining this object. Creation requires a newly + committed control intent, not absence of an instance. close never stops a + service, removes its fence, or closes the borrowed namespace registry. + """ + + def __init__(self, namespace: ManagedNamespaceAdmissionV1, service: ManagedServiceKeyV1) -> None: + if type(namespace) is not ManagedNamespaceAdmissionV1 or type(service) is not ManagedServiceKeyV1: + raise ManagedContractError() + registry = namespace.registry + if not registry._database.service_admission_required: + raise ManagedContractError() + paths = resolve_managed_service_paths(namespace._namespace, service, runtime_root=namespace._runtime_root) + self._namespace = namespace + self._database = registry._database + self._service = service + self._intent = ManagedServiceAdmissionRecordV1(service.service_id, token_hex(16), + ManagedInitializationPhaseV1.INITIALIZING) + self._fresh = PrivateManagedDirectory(Path(paths.lifecycle), create=True, create_parents=True, + exclusive_create=True, defer_open=True) + self._probe = PrivateManagedDirectory(Path(paths.lifecycle), defer_open=True) + self._journal = ManagedServiceJournalV1(registry, namespace._namespace, service, + Path(paths.lifecycle), defer_open=True) + self._attempted = self._opened = self._closing = False + self._closed: set[str] = set() + self._mutex = RLock() + + @property + def journal(self) -> ManagedServiceJournalV1: + if not self._opened or self._closing: + raise ManagedStorageError("closed") + return self._journal + + @property + def cleanup_pending(self) -> bool: + return len(self._closed) != 3 + + def open(self, *, deadline: float, wait_for_lock: bool = False) -> ManagedServiceJournalV1: + _check_deadline(deadline) + _check_lock_wait(wait_for_lock, deadline) + if not self._mutex.acquire(timeout=max(0.0, min(30.0, deadline - monotonic()))): + raise ManagedStorageError("busy") + try: + if self._attempted or self._closing: + raise ManagedStorageError("closed") + self._attempted = True + _check_deadline(deadline) + with self._database.transaction(deadline=deadline, wait_for_lock=wait_for_lock) as connection: + row = connection.execute("SELECT record FROM service_controls WHERE service_id=?", + (self._service.service_id,)).fetchone() + if row is None: + try: + self._probe.open(deadline=deadline) + except ManagedStorageError as error: + if error.code != "not_found": + raise + else: + raise ManagedStorageError("conflict") + record, claimed = self._claim(deadline, wait_for_lock=wait_for_lock) + if claimed: + self._initialize(deadline) + else: + record = self._decode(row) + claimed = False + if not claimed and record.phase is not ManagedInitializationPhaseV1.INITIALIZED: + raise ManagedStorageError("unavailable") + self._journal.open(deadline=deadline, wait_for_lock=wait_for_lock) + _check_deadline(deadline) + self._opened = True + return self.journal + finally: + self._mutex.release() + + def _decode(self, row: tuple) -> ManagedServiceAdmissionRecordV1: + try: + record = ManagedServiceAdmissionRecordV1.from_json(row[0]) + except (ManagedContractError, IndexError): + raise ManagedStorageError("invalid_record") from None + if record.service_id != self._service.service_id: + raise ManagedStorageError("conflict") + return record + + def _claim(self, deadline: float, *, wait_for_lock: bool = False) -> tuple[ManagedServiceAdmissionRecordV1, bool]: + service = self._service + with self._database.transaction(write=True, deadline=deadline, wait_for_lock=wait_for_lock) as connection: + row = connection.execute("SELECT record FROM service_controls WHERE service_id=?", + (service.service_id,)).fetchone() + if row is not None: + return self._decode(row), False + key = connection.execute("SELECT product, workspace, profile FROM services WHERE service_id=?", + (service.service_id,)).fetchone() + if key is not None and key != (service.product_id, service.workspace, service.profile): + raise ManagedStorageError("conflict") + if connection.execute("SELECT 1 FROM instances WHERE service_id=?", (service.service_id,)).fetchone(): + raise ManagedStorageError("conflict") + self._database.admit_growth(connection) + if key is None: + if connection.execute("SELECT count(*) FROM services").fetchone()[0] >= MAX_SERVICES: + raise ManagedStorageError("capacity") + connection.execute("INSERT INTO services VALUES (?, ?, ?, ?)", + (service.service_id, service.product_id, service.workspace, service.profile)) + connection.execute("INSERT INTO service_controls VALUES (?, ?)", + (service.service_id, self._intent.to_json())) + return self._intent, True + + def _initialize(self, deadline: float) -> None: + # No registry transaction is held while admitting or locking the fence. + self._fresh.open(deadline=deadline) + with self._fresh.lock("lifecycle.lock", create=True, exclusive_create=True, deadline=deadline): + lock_identity = self._fresh._locks["lifecycle.lock"][1] + complete = replace(self._intent, phase=ManagedInitializationPhaseV1.INITIALIZED, + root_identity=self._fresh._identity, + lock_identity=lock_identity) + self._publish(complete, lock_identity, deadline) + + def _publish(self, complete: ManagedServiceAdmissionRecordV1, lock_identity: tuple[int, int], + deadline: float) -> None: + # Other services/readers may briefly hold the shared registry lock. + # Retain the original fence and settle only this publication; never + # turn transient contention into a replay of native creation. + while True: + _check_deadline(deadline) + try: + with self._database.transaction(write=True, deadline=deadline) as connection: + row = connection.execute("SELECT record FROM service_controls WHERE service_id=?", + (self._service.service_id,)).fetchone() + if row is None: + raise ManagedStorageError("conflict") + current = self._decode(row) + self._fresh._check() + self._fresh._check_named("lifecycle.lock", lock_identity) + if current == complete: + return + if current != self._intent: + raise ManagedStorageError("conflict") + self._database.admit_control(connection) + connection.execute("UPDATE service_controls SET record=? WHERE service_id=?", + (complete.to_json(), self._service.service_id)) + return + except ManagedStorageError as error: + if error.code != "busy": + raise + _check_deadline(deadline) + sleep(min(0.01, max(0.0, deadline - monotonic()))) + + def close(self) -> None: + with self._mutex: + self._closing = True + self._opened = False + failures: list[BaseException] = [] + for name, resource in (("journal", self._journal), ("fresh", self._fresh), ("probe", self._probe)): + if name in self._closed: + continue + try: + resource.close() + except BaseException as error: + failures.append(error) + else: + self._closed.add(name) + if failures: + raise failures[0] + + +__all__ = ["ManagedServiceAdmissionV1"] diff --git a/src/loushang/apphost/managed/starter.py b/src/loushang/apphost/managed/starter.py new file mode 100644 index 000000000..bb8c08492 --- /dev/null +++ b/src/loushang/apphost/managed/starter.py @@ -0,0 +1,211 @@ +"""Parent-side launch composition; durable birth is not application readiness. + +The caller adopts this object before calling start and retains it after errors. +It borrows an already admitted journal and never creates/replaces its registry +or lifecycle fence. Async consumers must join the original synchronous worker. +Closing releases parent-local resources, not a committed background service. +""" + +from __future__ import annotations + +import os +import socket +from collections.abc import Callable +from secrets import token_hex +from threading import Event, RLock + +from loushang.hosting.contracts import ProcessLaunchRequest +from loushang.hosting.service_process import LinuxServiceProcessV1 + +from ._files import ManagedStorageError, _check_deadline +from .contracts import ( + ManagedContractError, + ManagedHandoffPhaseV1, + ManagedInstanceRefV1, + ManagedNamespaceV1, + ManagedServiceKeyV1, +) +from .invocation import ManagedChildInvocationV1, _validate_trace_deadline +from .layout import ManagedLayoutPreparationV1 +from .lifecycle import ManagedServiceJournalV1, ManagedServiceStateV1 +from .paths import resolve_managed_paths, resolve_managed_service_paths + +ManagedLaunchRequestFactoryV1 = Callable[[ManagedChildInvocationV1, int], ProcessLaunchRequest] + + +class ManagedServiceStarterV1: + """One original prepare/spawn attempt, with repeatable birth observation. + + Request construction is a pure Product callback outside durable locks. + Neither a callback error, timeout nor an uncertain spawn permits replay. + A fresh starter must win a new journal generation, not reuse this attempt. + """ + + def __init__( + self, journal: ManagedServiceJournalV1, namespace: ManagedNamespaceV1, + service: ManagedServiceKeyV1, *, runtime_root: str, + request_factory: ManagedLaunchRequestFactoryV1, + temporary_override: str | None = None, + trace_deadline_ms: int | None = None, + ) -> None: + _validate_trace_deadline(trace_deadline_ms) + paths = resolve_managed_service_paths(namespace, service, runtime_root=runtime_root) + # Validate scratch before durable prepare; this placeholder is used only + # for pure lexical validation, never as a native or persisted instance. + resolve_managed_paths(namespace, service, ManagedInstanceRefV1( + namespace.namespace_key, service.service_id, "0" * 32, + ), runtime_root=runtime_root, temporary_override=temporary_override) + if (namespace.user_id != os.geteuid() + or type(journal) is not ManagedServiceJournalV1 or not callable(request_factory) + or journal._namespace != namespace or journal._service != service + or str(journal._fence._root) != str(paths.lifecycle) + or str(journal._database._directory._root) != str(paths.registry)): + raise ManagedContractError() + self._journal, self._namespace, self._service = journal, namespace, service + self._runtime_root, self._request_factory = runtime_root, request_factory + self._temporary_override = temporary_override + self._trace_deadline_ms = trace_deadline_ms + self._attempt = token_hex(16) + self._state: ManagedServiceStateV1 | None = None + self._layout: ManagedLayoutPreparationV1 | None = None + self._process: LinuxServiceProcessV1 | None = None + self._endpoints: tuple[socket.socket, socket.socket] | None = None + self._closed_endpoints: set[int] = set() + self._unknown_endpoints: set[int] = set() + self._started = False + self._closing = Event() + self._mutex = RLock() + + @property + def cleanup_pending(self) -> bool: + return bool( + self._layout is not None and self._layout.cleanup_pending + or self._process is not None and not self._process.handles_closed + or self._endpoints is not None and len(self._closed_endpoints) != 2 + ) + + def _check(self, deadline: float) -> None: + _check_deadline(deadline) + if self._closing.is_set(): + raise ManagedStorageError("closed") + + def start( + self, *, expected: ManagedServiceStateV1 | None, deadline: float, + ) -> ManagedServiceStateV1: + """Reserve once, prepare real-instance layout, spawn once and bind birth.""" + self._check(deadline) + if not self._mutex.acquire(blocking=False): + raise ManagedStorageError("busy") + try: + self._check(deadline) + if self._started: + raise ManagedStorageError("conflict") + self._started = True + self._state = self._journal.prepare(self._attempt, expected=expected, deadline=deadline) + self._check(deadline) + self._layout = ManagedLayoutPreparationV1( + self._namespace, self._service, self._state.handoff.instance, + runtime_root=self._runtime_root, require_control_roots=True, + temporary_override=self._temporary_override, + ) + self._layout.open(deadline=deadline) + self._check(deadline) + self._require_provisional(deadline) + self._endpoints = socket.socketpair() + invocation = ManagedChildInvocationV1( + self._namespace, self._service, self._state.handoff.instance, + self._attempt, self._runtime_root, self._temporary_override, self._trace_deadline_ms, + ) + request = self._request_factory(invocation, self._endpoints[1].fileno()) + self._check(deadline) + self._require_provisional(deadline) + self._process = LinuxServiceProcessV1(request, self._endpoints[1]) + # Admission linearizes here. Native spawn cannot be preempted after + # this check; its original owner remains retained through the effect. + self._check(deadline) + self._process.spawn() + return self.register_birth(deadline=deadline) + finally: + self._mutex.release() + + def _require_current(self, deadline: float) -> ManagedServiceStateV1: + observed = self._journal.read(deadline=deadline) + if (observed is None or observed.handoff.attempt_id != self._attempt + or self._state is not None and observed.handoff.instance != self._state.handoff.instance): + raise ManagedStorageError("conflict") + return observed + + def _require_provisional(self, deadline: float) -> None: + observed = self._require_current(deadline) + if (observed.handoff.phase is not ManagedHandoffPhaseV1.PROVISIONAL + or observed.handoff.stop_requested or observed.native_identity is not None): + raise ManagedStorageError("conflict") + + def register_birth(self, *, deadline: float) -> ManagedServiceStateV1: + """Retry only durable identity binding; never repeat native creation.""" + self._check(deadline) + if not self._mutex.acquire(blocking=False): + raise ManagedStorageError("busy") + try: + self._check(deadline) + if self._process is None or self._process.identity is None: + raise ManagedStorageError("unavailable") + observed = self._require_current(deadline) + identity = self._process.identity + if observed.native_identity == identity: + return observed + return self._journal.register_native( + observed.handoff.instance, self._attempt, identity, deadline=deadline, + ) + finally: + self._mutex.release() + + def observe(self, *, deadline: float) -> ManagedServiceStateV1: + """Read this exact attempt even after local close; no liveness claim.""" + return self._require_current(deadline) + + def fence(self) -> None: + """Revoke new local effects without waiting for a native worker.""" + self._closing.set() + + def close(self) -> None: + self.fence() + if not self._mutex.acquire(blocking=False): + raise ManagedStorageError("busy") + try: + errors: list[BaseException] = [] + if self._process is not None: + try: + self._process.close() + except BaseException as error: + errors.append(error) + else: + self._closed_endpoints.add(1) + if self._endpoints is not None: + for index, endpoint in enumerate(self._endpoints): + if index in self._closed_endpoints or index in self._unknown_endpoints: + continue + if index == 1 and self._process is not None: + continue # Only the original process owner can close it. + self._unknown_endpoints.add(index) + try: + endpoint.close() + except BaseException as error: + errors.append(error) + else: + self._unknown_endpoints.remove(index) + self._closed_endpoints.add(index) + if self._layout is not None: + try: + self._layout.close() + except BaseException as error: + errors.append(error) + if errors: + raise errors[0] + if self.cleanup_pending: + raise ManagedStorageError("unavailable") + finally: + self._mutex.release() + + +__all__ = ["ManagedServiceStarterV1", "ManagedLaunchRequestFactoryV1"] diff --git a/src/loushang/appserver/managed_mux.py b/src/loushang/appserver/managed_mux.py new file mode 100644 index 000000000..e3f7d1cbc --- /dev/null +++ b/src/loushang/appserver/managed_mux.py @@ -0,0 +1,60 @@ +"""Opt-in, transport-neutral managed Mux values; no deployment authority here.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Protocol + +_HEX32 = re.compile(r"[0-9a-f]{32}\Z") +_HEX64 = re.compile(r"[0-9a-f]{64}\Z") +_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}\Z") +_MUX_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._~-]{0,511}\Z") +_AUTHORITY = re.compile(r"[A-Za-z0-9._~-]{1,1024}\Z") + + +def _match(value: str, pattern: re.Pattern[str]) -> None: + if type(value) is not str or pattern.fullmatch(value) is None: + raise ValueError("invalid managed Mux value") + + +@dataclass(frozen=True, slots=True) +class ManagedMuxCreateV1: + service_id: str + instance_id: str + operation_id: str + name: str + authority: str = field(repr=False) + + def __post_init__(self) -> None: + _match(self.service_id, _HEX64) + _match(self.instance_id, _HEX32) + _match(self.operation_id, _HEX32) + _match(self.name, _NAME) + _match(self.authority, _AUTHORITY) + + +@dataclass(frozen=True, slots=True) +class ManagedMuxCreatedV1: + """Immutable creation fact, not a claim the Mux is still open or online. + + The instance is the original committing instance. Reconciliation after + restart needs fresh authority but returns this same historical result. + """ + + operation_id: str + instance_id: str + name: str + mux_space_id: str + + def __post_init__(self) -> None: + _match(self.operation_id, _HEX32) + _match(self.instance_id, _HEX32) + _match(self.name, _NAME) + _match(self.mux_space_id, _MUX_ID) + + +class ManagedMuxCreationClientV1(Protocol): + """Optional capability; does not extend the legacy AppClient algebra.""" + + async def create_managed_mux(self, request: ManagedMuxCreateV1) -> ManagedMuxCreatedV1: ... diff --git a/src/loushang/appserver/managed_mux_close.py b/src/loushang/appserver/managed_mux_close.py new file mode 100644 index 000000000..497e994b9 --- /dev/null +++ b/src/loushang/appserver/managed_mux_close.py @@ -0,0 +1,78 @@ +"""Optional exact-target close values, separate from creation permission. + +A state is a historical fact supplied by an authenticated application, not an +authority token or permission to release a name. The registry consumer must +check the serving instance and CAS the original reference independently. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Protocol + +from .managed_mux import _AUTHORITY, _HEX32, _HEX64, _MUX_ID, _NAME, _match + + +class ManagedMuxClosePhaseV1(str, Enum): + CLEANUP_PENDING = "cleanup_pending" + CLOSED = "closed" + + +def _target(operation: str, creation: str, name: str, mux_id: str) -> None: + _match(operation, _HEX32) + _match(creation, _HEX32) + _match(name, _NAME) + _match(mux_id, _MUX_ID) + if operation == creation: + raise ValueError("invalid managed Mux close identity") + + +@dataclass(frozen=True, slots=True) +class ManagedMuxCloseV1: + """Current-instance permission request with a frozen, non-name-only target.""" + + service_id: str + instance_id: str + operation_id: str + creation_operation_id: str + name: str + mux_space_id: str + authority: str = field(repr=False) + + def __post_init__(self) -> None: + _match(self.service_id, _HEX64) + _match(self.instance_id, _HEX32) + _target(self.operation_id, self.creation_operation_id, self.name, self.mux_space_id) + _match(self.authority, _AUTHORITY) + + +@dataclass(frozen=True, slots=True) +class ManagedMuxCloseStateV1: + """Immutable origin identity and one of two durable settlement phases. + +instance_id is the operation origin (first issuing instance), not evidence of +which instance executed it. Only actual cleanup permits pending to become closed; neither +an absent Mux nor transport EOF constitutes this result. No authority is stored. +""" + + operation_id: str + instance_id: str + creation_operation_id: str + name: str + mux_space_id: str + phase: ManagedMuxClosePhaseV1 + + def __post_init__(self) -> None: + _match(self.instance_id, _HEX32) + _target(self.operation_id, self.creation_operation_id, self.name, self.mux_space_id) + if type(self.phase) is not ManagedMuxClosePhaseV1: + raise ValueError("invalid managed Mux close phase") + + +class ManagedMuxCloseClientV1(Protocol): + """Optional close and read-only result access, never a permit issuer.""" + + async def close_managed_mux(self, request: ManagedMuxCloseV1) -> ManagedMuxCloseStateV1: ... + + async def read_managed_mux_close(self, request: ManagedMuxCloseV1) -> ManagedMuxCloseStateV1 | None: ... diff --git a/tests/apphost/test_managed_admission.py b/tests/apphost/test_managed_admission.py new file mode 100644 index 000000000..e806edbf8 --- /dev/null +++ b/tests/apphost/test_managed_admission.py @@ -0,0 +1,350 @@ +from __future__ import annotations + +import os +import sys +from time import monotonic + +import pytest + +from loushang.apphost.managed import _database, _files +from loushang.apphost.managed._files import ManagedStorageError, PrivateManagedDirectory +from loushang.apphost.managed.contracts import ManagedNamespaceV1, ManagedServiceKeyV1 +from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 +from loushang.apphost.managed.registry import ManagedMuxReservationV1, ManagedRegistryV1 + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed storage admission") + + +def test_deferred_containers_do_no_io_and_cannot_reopen_or_revive(tmp_path, monkeypatch): + namespace = ManagedNamespaceV1(str(tmp_path / "platform"), os.geteuid(), "a" * 32) + service = ManagedServiceKeyV1("coding", str(tmp_path)) + with monkeypatch.context() as patch: + patch.setattr(os, "open", lambda *a, **kw: pytest.fail("constructor performed IO")) + registry = ManagedRegistryV1(tmp_path / "registry", namespace, create=True, defer_open=True) + journal = ManagedServiceJournalV1( + registry, namespace, service, tmp_path / "fence", create=True, defer_open=True, + ) + try: + with pytest.raises(ManagedStorageError, match="closed"): + registry.resolve("dev") + with pytest.raises(ManagedStorageError, match="closed"): + journal.read() + deadline = monotonic() + 5 + registry.open(deadline=deadline) + registry.reserve_mux(ManagedMuxReservationV1("dev", service, "b" * 32)) + journal.open(deadline=deadline) + state = journal.prepare("c" * 32, expected=None) + for owner in (registry, journal): + with pytest.raises(ManagedStorageError, match="closed"): + owner.open(deadline=deadline) + assert journal.read() == state # Rejected duplicate open did not close valid owners. + journal.close() + registry.close() + for owner in (registry, journal): + with pytest.raises(ManagedStorageError, match="closed"): + owner.open(deadline=deadline) + finally: + journal.close() + registry.close() + + +def test_unopened_close_permanently_fences_admission(tmp_path): + directory = PrivateManagedDirectory(tmp_path / "private", create=True, defer_open=True) + directory.close() + with pytest.raises(ManagedStorageError, match="closed"): + directory.open(deadline=monotonic() + 1) + assert not (tmp_path / "private").exists() + + +def test_failed_parent_validation_retains_unvalidated_descriptor_until_explicit_close(tmp_path, monkeypatch): + directory = PrivateManagedDirectory(tmp_path / "private", defer_open=True) + + def fail(info): + raise ManagedStorageError("unavailable") + + monkeypatch.setattr(directory, "_validate_parent", fail) + with pytest.raises(ManagedStorageError): + directory.open(deadline=monotonic() + 1) + fd, anchor = directory._opening_fd, directory._anchor + assert fd is not None and anchor is not None + os.fstat(fd) + os.fstat(anchor) + directory.close() + for descriptor in (fd, anchor): + with pytest.raises(OSError): + os.fstat(descriptor) + + +def test_directory_admission_deadline_retains_acquired_anchor(tmp_path, monkeypatch): + directory = PrivateManagedDirectory(tmp_path / "private", defer_open=True) + now = [1.0] + original = os.open + + def slow_open(*args, **kwargs): + fd = original(*args, **kwargs) + now[0] = 3.0 + return fd + + monkeypatch.setattr(_files, "monotonic", lambda: now[0]) + monkeypatch.setattr(os, "open", slow_open) + with pytest.raises(ManagedStorageError, match="busy"): + directory.open(deadline=2.0) + assert directory._anchor is not None and directory._opening_fd is None + directory.close() + + +def test_database_initial_inspection_receives_callers_absolute_deadline(tmp_path, monkeypatch): + from contextlib import contextmanager + + namespace = ManagedNamespaceV1(str(tmp_path / "platform"), os.geteuid(), "a" * 32) + registry = ManagedRegistryV1(tmp_path / "registry", namespace, create=True, defer_open=True) + original = registry._database._connection + deadlines = [] + + @contextmanager + def inspect(**kwargs): + deadlines.append(kwargs["deadline"]) + with original(**kwargs) as connection: + yield connection + + monkeypatch.setattr(registry._database, "_connection", inspect) + deadline = monotonic() + 5 + try: + registry.open(deadline=deadline) + assert deadlines == [deadline] + finally: + registry.close() + + +def test_failed_initial_database_inspection_and_close_keep_retryable_connection(tmp_path, monkeypatch): + namespace = ManagedNamespaceV1(str(tmp_path / "platform"), os.geteuid(), "a" * 32) + registry = ManagedRegistryV1(tmp_path / "registry", namespace, create=True, defer_open=True) + original = _database.sqlite3.connect + close_calls = [] + + class Connection: + def __init__(self, *args, **kwargs): + self.inner = original(*args, **kwargs) + + def __getattr__(self, name): + return getattr(self.inner, name) + + def close(self): + close_calls.append(1) + if len(close_calls) < 3: + raise _database.sqlite3.OperationalError("injected close failure") + self.inner.close() + + def reject(connection): + raise ManagedStorageError("invalid_record") + + monkeypatch.setattr(_database.sqlite3, "connect", Connection) + monkeypatch.setattr(registry._database, "_validate_schema", reject) + try: + with pytest.raises(ManagedStorageError, match="invalid_record"): + registry.open(deadline=monotonic() + 5) + retained = registry._database._cleanup_connection + assert retained is not None and registry.cleanup_pending + with pytest.raises(ManagedStorageError, match="unavailable"): + registry.close() + assert registry._database._cleanup_connection is retained + assert registry._database._directory._fd is not None + with pytest.raises(ManagedStorageError, match="closed"): + registry.open(deadline=monotonic() + 5) + registry.close() + assert not registry.cleanup_pending and len(close_calls) == 3 + finally: + registry.close() + + +def test_uncertain_native_close_is_debt_not_permission_to_close_reused_fd(tmp_path, monkeypatch): + root = tmp_path / "private" + directory = PrivateManagedDirectory(root, create=True) + target = directory._fd + original = os.close + replacements = [] + attempts = [] + + def uncertain_close(fd): + if fd == target: + attempts.append(fd) + original(fd) + replacements.append(os.open(root, os.O_RDONLY | os.O_DIRECTORY)) + raise OSError("close reported failure after releasing the descriptor") + original(fd) + + try: + with monkeypatch.context() as patch: + patch.setattr(os, "close", uncertain_close) + with pytest.raises(ManagedStorageError, match="unavailable"): + directory.close() + with pytest.raises(ManagedStorageError, match="unavailable"): + directory.close() + assert attempts == [target] and directory.cleanup_pending + os.fstat(replacements[0]) # Retry did not close the unrelated replacement. + with pytest.raises(ManagedStorageError, match="closed"): + directory.open(deadline=monotonic() + 1) + finally: + for fd in replacements: + original(fd) + + +@pytest.mark.parametrize("kind", ["registry", "journal"]) +def test_first_lock_release_failure_retains_debt_without_closing_replacement(tmp_path, monkeypatch, kind): + namespace = ManagedNamespaceV1(str(tmp_path / "platform"), os.geteuid(), "a" * 32) + service = ManagedServiceKeyV1("coding", str(tmp_path)) + registry = ManagedRegistryV1(tmp_path / "registry", namespace, create=True, defer_open=True) + if kind == "journal": + registry.open() + owner = ManagedServiceJournalV1(registry, namespace, service, tmp_path / "fence", create=True, defer_open=True) + directory = owner._fence + else: + owner, directory = registry, registry._database._directory + open_file, close_fd = directory._open, os.close + target, replacement = [], [] + + def capture(name, *args, **kwargs): + fd = open_file(name, *args, **kwargs) + if name.endswith(".lock"): + target.append(fd) + return fd + + def fail(fd): + close_fd(fd) + if target and fd == target[0] and not replacement: + replacement.append(os.open(tmp_path, os.O_RDONLY | os.O_DIRECTORY)) + raise OSError("uncertain lock release") + + try: + with monkeypatch.context() as patch: + patch.setattr(directory, "_open", capture) + patch.setattr(os, "close", fail) + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.open(deadline=monotonic() + 5) + assert directory.cleanup_pending + for _ in range(2): + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.close() + os.fstat(replacement[0]) + finally: + for fd in replacement: + close_fd(fd) + if kind == "journal": + registry.close() + + +@pytest.mark.parametrize("kind", ["open", "read", "write"]) +def test_file_operation_close_failure_is_retained_by_owner(tmp_path, monkeypatch, kind): + directory = PrivateManagedDirectory(tmp_path / "private", create=True) + open_fd, close_fd = os.open, os.close + target = [] + + def capture(name, *args, **kwargs): + fd = open_fd(name, *args, **kwargs) + if (kind == "write" and str(name).startswith("pending-")) or (kind != "write" and name == "record"): + target.append(fd) + return fd + + def fail(fd): + close_fd(fd) + if target and fd == target[0]: + raise OSError("uncertain file release") + + with directory.lock("control.lock", create=True): + if kind != "write": + directory.write("record", b"text", expected=None) + with monkeypatch.context() as patch: + patch.setattr(os, "open", capture) + patch.setattr(os, "close", fail) + if kind == "open": + original = directory._validate + rejected_inode = (tmp_path / "private" / "record").stat().st_ino + + def reject(info, *, directory=False): + if not directory and info.st_ino == rejected_inode: + raise ManagedStorageError("invalid_record") + return original(info, directory=directory) + + patch.setattr(directory, "_validate", reject) + with pytest.raises(ManagedStorageError, match="invalid_record"): + directory._open("record", os.O_RDONLY) + elif kind == "read": + with pytest.raises(ManagedStorageError, match="unavailable"): + directory.read("record") + else: + with pytest.raises(ManagedStorageError, match="unavailable"): + directory.write("record", b"new", expected=None) + assert directory.cleanup_pending and target + with pytest.raises(ManagedStorageError, match="busy"): + directory.read("record") + for _ in range(2): + with pytest.raises(ManagedStorageError, match="unavailable"): + directory.close() + + +@pytest.mark.parametrize("kind", ["registry", "journal"]) +def test_late_admission_cleanup_does_not_publish_open_success(tmp_path, monkeypatch, kind): + from contextlib import contextmanager + + from loushang.apphost.managed import lifecycle + + namespace = ManagedNamespaceV1(str(tmp_path / "platform"), os.geteuid(), "a" * 32) + service = ManagedServiceKeyV1("coding", str(tmp_path)) + registry = ManagedRegistryV1(tmp_path / "registry", namespace, create=True, defer_open=True) + now = [1.0] + if kind == "registry": + owner = registry + target, attribute = registry._database, "_connection" + else: + registry.open() + owner = ManagedServiceJournalV1(registry, namespace, service, tmp_path / "fence", create=True, defer_open=True) + target, attribute = owner._fence, "lock" + original = getattr(target, attribute) + + @contextmanager + def late(*args, **kwargs): + with original(*args, **kwargs) as value: + yield value + now[0] = 3.0 + + monkeypatch.setattr(_files, "monotonic", lambda: now[0]) + monkeypatch.setattr(_database, "monotonic", lambda: now[0]) + monkeypatch.setattr(lifecycle, "_check_deadline", _files._check_deadline) + monkeypatch.setattr(target, attribute, late) + try: + with pytest.raises(ManagedStorageError, match="busy"): + owner.open(deadline=2.0) + with pytest.raises(ManagedStorageError, match="closed"): + owner.resolve("dev") if kind == "registry" else owner.read() + finally: + owner.close() + registry.close() + + +def test_partial_close_fences_new_operations_but_allows_cleanup_retry(tmp_path, monkeypatch): + directory = PrivateManagedDirectory(tmp_path / "private", create=True) + original_write = os.write + + def fail_write(fd, content): + original_write(fd, content[:1]) + raise OSError("write failed") + + def fail_unlink(*args, **kwargs): + raise OSError("cleanup failed") + + with monkeypatch.context() as patch: + patch.setattr(os, "unlink", fail_unlink) + with directory.lock("control.lock", create=True): + patch.setattr(os, "write", fail_write) + with pytest.raises(ManagedStorageError): + directory.write("record", b"new", expected=None) + with pytest.raises(ManagedStorageError): + directory.close() + assert directory.cleanup_pending + with pytest.raises(ManagedStorageError, match="closed"): + directory.read("record") + with pytest.raises(ManagedStorageError, match="closed"): + with directory.lock("control.lock"): + pytest.fail("closed directory admitted a new lock") + directory.close() + assert not directory.cleanup_pending diff --git a/tests/apphost/test_managed_admission_record.py b/tests/apphost/test_managed_admission_record.py new file mode 100644 index 000000000..6d441b942 --- /dev/null +++ b/tests/apphost/test_managed_admission_record.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import json +from dataclasses import FrozenInstanceError, replace +from pathlib import Path + +import pytest + +from loushang.apphost.managed.admission_record import ( + MAX_ADMISSION_RECORD_BYTES, + ManagedInitializationPhaseV1, + ManagedNamespaceAdmissionRecordV1, +) +from loushang.apphost.managed.contracts import ManagedContractError, ManagedNamespaceV1 +from loushang.apphost.managed.paths import ( + resolve_managed_admission_root, + resolve_managed_registry_root, +) + + +def record(initialized=False): + return ManagedNamespaceAdmissionRecordV1( + "a" * 64, "b" * 32, "c" * 32, + ManagedInitializationPhaseV1.INITIALIZED if initialized else ManagedInitializationPhaseV1.INITIALIZING, + *((12, 345), (12, 346), (12, 347), (12, 348), (12, 349)) if initialized else (None,) * 5, + ) + + +@pytest.mark.parametrize("initialized", [False, True]) +def test_record_roundtrip_is_bounded_immutable_and_not_service_ready(initialized): + value = record(initialized) + encoded = value.to_json() + assert len(encoded.encode()) <= MAX_ADMISSION_RECORD_BYTES + assert ManagedNamespaceAdmissionRecordV1.from_json(encoded) == value + assert ManagedNamespaceAdmissionRecordV1.from_json(encoded).to_json() == encoded + assert not hasattr(value, "ready") and not hasattr(value, "pid") + assert "345" not in repr(value) + with pytest.raises(FrozenInstanceError): + value.operation_id = "d" * 32 + + +@pytest.mark.parametrize("field,value", [ + ("namespace_key", "a" * 63), ("operation_id", "B" * 32), ("deployment_id", ""), + ("phase", "initialized"), ("registry_root_identity", (12, 0)), + ("database_identity", (True, 346)), ("registry_lock_identity", (12, 2**64)), + ("registry_root_identity", [12, 345]), ("database_identity", (12, 345)), + ("registry_lock_identity", None), + ("admission_root_identity", (12, 345)), ("admission_lock_identity", None), +]) +def test_constructor_rejects_incomplete_or_non_native_shapes(field, value): + with pytest.raises(ManagedContractError, match="^invalid_managed_contract$"): + replace(record(True), **{field: value}) + + +def test_initializing_cannot_claim_partial_or_completed_identity(): + with pytest.raises(ManagedContractError): + replace(record(), registry_root_identity=(12, 345)) + with pytest.raises(ManagedContractError): + replace(record(), phase=ManagedInitializationPhaseV1.INITIALIZED) + + +@pytest.mark.parametrize("change", [ + lambda data: {**data, "version": "unknown/v2"}, + lambda data: {**data, "extra": True}, + lambda data: {key: value for key, value in data.items() if key != "operation"}, + lambda data: {**data, "phase": "ready"}, + lambda data: {**data, "database": [12, 0]}, + lambda data: {**data, "database": [12, False]}, + lambda data: {**data, "database": {"device": 12, "inode": 346}}, + lambda data: {**data, "namespace": "../secret/path"}, +]) +def test_decoder_rejects_unknown_schema_and_bad_values_without_echo(change): + data = change(json.loads(record(True).to_json())) + with pytest.raises(ManagedContractError, match="^invalid_managed_contract$"): + ManagedNamespaceAdmissionRecordV1.from_json(json.dumps(data)) + + +@pytest.mark.parametrize("raw", [ + "", "[]", "null", "{}", "{", "x" * (MAX_ADMISSION_RECORD_BYTES + 1), + '"' + "汉" * 2000 + '"', "\ud800", "[" * 2000 + "]" * 2000, +]) +def test_bad_or_oversized_encoding_is_bounded_error(raw): + with pytest.raises(ManagedContractError, match="^invalid_managed_contract$"): + ManagedNamespaceAdmissionRecordV1.from_json(raw) + + +def test_duplicate_keys_are_rejected(): + value = record().to_json() + duplicated = '{"phase":"initialized",' + value[1:] + with pytest.raises(ManagedContractError): + ManagedNamespaceAdmissionRecordV1.from_json(duplicated) + + +def test_admission_path_is_pure_separate_from_lmux_and_namespace_bound(tmp_path, monkeypatch): + namespace = ManagedNamespaceV1(str(tmp_path / "missing-platform"), 123, "a" * 32) + monkeypatch.setenv("LOUSHANG_HOME", str(tmp_path / "unrelated")) + monkeypatch.chdir(tmp_path) + admission = resolve_managed_admission_root(namespace) + registry = resolve_managed_registry_root(namespace) + assert admission == Path(namespace.platform_home) / "state/managed-deployments" / namespace.namespace_key + assert not admission.is_relative_to(Path(namespace.platform_home) / "lmux") + assert not registry.is_relative_to(admission) and not admission.is_relative_to(registry) + assert resolve_managed_admission_root(replace(namespace, machine_id="b" * 32)) != admission + assert resolve_managed_admission_root(replace(namespace, user_id=124)) != admission + assert not tuple(tmp_path.iterdir()) + with pytest.raises(ManagedContractError): + resolve_managed_admission_root(None) diff --git a/tests/apphost/test_managed_connection.py b/tests/apphost/test_managed_connection.py new file mode 100644 index 000000000..8105c8de2 --- /dev/null +++ b/tests/apphost/test_managed_connection.py @@ -0,0 +1,522 @@ +from __future__ import annotations + +import asyncio +import os +import sys +from dataclasses import replace +from threading import Event +from time import monotonic + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError, PrivateManagedDirectory +from loushang.apphost.managed.connection import ManagedConnectionLeaseV1 +from loushang.apphost.managed.contracts import ManagedContractError +from loushang.appserver.client import AppConnectionClosedError +from loushang.hosting.service import LinuxServiceObserverV1 +from tests.apphost.test_managed_starter import owners as owners + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed connection") + + +def committed(owners): + journal = owners[0] + provisional = journal.prepare("c" * 32, expected=None) + observer = LinuxServiceObserverV1.capture(os.getpid()) + try: + identity = observer.identity + journal.register_native(provisional.handoff.instance, "c" * 32, identity) + return journal.commit(provisional.handoff.instance, "c" * 32, native_identity=identity) + finally: + observer.close() + + +def lease(owners, instance): + journal, namespace, service, runtime = owners + return ManagedConnectionLeaseV1(journal, namespace, service, instance, runtime_root=runtime, endpoint="workspace") + + +def fake_connection(monkeypatch): + connections = [] + + class Connection: + def __init__(self, directory, endpoint, **kwargs): + self.directory, self.endpoint, self.selection = directory, endpoint, kwargs + self.client = object() + self.discovery_client = self.execution_client = None + self.scopes = () + self.started = False + self.closed = False + self.start_hook = None + self.close_error = False + connections.append(self) + + async def start(self): + self.started = True + if start_hook is not None: + await start_hook(self) + + async def close(self): + if self.close_error: + raise RuntimeError("injected_cleanup") + self.closed = True + + start_hook = None + + def on_start(callback): + nonlocal start_hook + start_hook = callback + + monkeypatch.setattr("loushang.apphost.managed.connection.LocalAppClientConnectionV1", Connection) + return connections, on_start + + +def test_exact_instance_prepare_is_readonly_and_close_does_not_stop(owners, monkeypatch): + state = committed(owners) + connections, _ = fake_connection(monkeypatch) + owner = lease(owners, state.handoff.instance) + + async def scenario(): + with pytest.raises(AppConnectionClosedError): + _ = owner.client + await owner.prepare(deadline=monotonic() + 5) + assert owner.client is connections[0].client + assert owner.instance == state.handoff.instance + assert connections[0].selection["expected_instance"] == state.handoff.instance.instance_id + assert connections[0].selection["expected_product_id"] == "coding" + assert owner.scopes == () and owner.discovery_client is owner.execution_client is None + with pytest.raises(ManagedStorageError, match="conflict"): + await owner.prepare(deadline=monotonic() + 5) + await owner.close() + await owner.close() + assert connections[0].closed and not owner.cleanup_pending + with pytest.raises(AppConnectionClosedError): + _ = owner.client + + asyncio.run(scenario()) + assert owners[0].read() == state + assert not owner._root.exists() + + +@pytest.mark.parametrize("stop", [False, True]) +def test_trace_publication_during_authentication_preserves_lifecycle_fence(owners, monkeypatch, stop): + from loushang.apphost.managed.connection import _settled_native + + state = committed(owners) + connections, on_start = fake_connection(monkeypatch) + owner = lease(owners, state.handoff.instance) + def publish(): + owners[0].record_trace_application(state.handoff.instance, state.handoff.attempt_id, + native_identity=state.native_identity, trace_deadline_ms=int((monotonic() + 30) * 1000), + deadline=monotonic() + 5) + if stop: + owners[0].request_stop(state.handoff.instance) + async def during_authentication(connection): + await _settled_native(publish) + on_start(during_authentication) + async def scenario(): + try: + if stop: + with pytest.raises(ManagedStorageError, match="unavailable"): + await owner.prepare(deadline=monotonic() + 5) + else: + await owner.prepare(deadline=monotonic() + 5) + assert owner.client is connections[0].client + finally: + await owner.close() + asyncio.run(scenario()) + assert connections[0].closed and not owner.cleanup_pending + assert owners[0].read().handoff.stop_requested == stop + + +@pytest.mark.parametrize("change", ["revision_only", "skipped_revision", "removed_trace", "changed_trace"]) +def test_connection_fence_does_not_ignore_other_revision_or_trace_changes(owners, change): + from loushang.apphost.managed.connection import _same_connection_state + from loushang.apphost.managed.lifecycle import ManagedTraceApplicationV1 + + original = committed(owners) + trace = ManagedTraceApplicationV1(original.handoff.instance.instance_id, original.handoff.attempt_id, 1000) + current = replace(original, revision=original.revision + 1) + if change == "skipped_revision": + current = replace(current, revision=original.revision + 2, trace_application=trace) + elif change in {"removed_trace", "changed_trace"}: + original = replace(original, trace_application=trace) + if change == "changed_trace": + current = replace(current, trace_application=replace(trace, deadline_ms=2000)) + assert not _same_connection_state(original, current) + + +@pytest.mark.parametrize("kind", ["stale", "stop", "provisional", "absent"]) +def test_unavailable_state_never_opens_connection(owners, monkeypatch, kind): + state = committed(owners) + instance = state.handoff.instance + if kind == "stale": + instance = replace(instance, instance_id="d" * 32) + elif kind == "stop": + owners[0].request_stop(instance) + elif kind == "provisional": + from loushang.apphost.managed.contracts import ManagedHandoffPhaseV1 + state = replace(state, handoff=replace(state.handoff, phase=ManagedHandoffPhaseV1.PROVISIONAL)) + monkeypatch.setattr(owners[0], "read", lambda **kwargs: state) + else: + monkeypatch.setattr(owners[0], "read", lambda **kwargs: None) + connections, _ = fake_connection(monkeypatch) + owner = lease(owners, instance) + + async def scenario(): + try: + with pytest.raises(ManagedStorageError, match="unavailable"): + await owner.prepare(deadline=monotonic() + 5) + assert not connections + finally: + await owner.close() + assert not owner.cleanup_pending + + asyncio.run(scenario()) + + +def test_stop_during_authentication_cannot_publish_client(owners, monkeypatch): + state = committed(owners) + connections, on_start = fake_connection(monkeypatch) + owner = lease(owners, state.handoff.instance) + + async def stop(_): + owners[0].request_stop(state.handoff.instance) + + on_start(stop) + + async def scenario(): + try: + with pytest.raises(ManagedStorageError, match="unavailable"): + await owner.prepare(deadline=monotonic() + 5) + with pytest.raises(AppConnectionClosedError): + _ = owner.client + finally: + await owner.close() + assert connections[0].closed and not owner.cleanup_pending + + asyncio.run(scenario()) + assert owners[0].read().handoff.stop_requested + + +def test_cancelled_prepare_and_close_join_original_native_worker(owners, monkeypatch): + state = committed(owners) + owner = lease(owners, state.handoff.instance) + connections, _ = fake_connection(monkeypatch) + entered, release = Event(), Event() + original = owner._admit_native + + def held(deadline): + value = original(deadline) + entered.set() + assert release.wait(5) + return value + + monkeypatch.setattr(owner, "_admit_native", held) + + async def scenario(): + preparation = asyncio.create_task(owner.prepare(deadline=monotonic() + 10)) + closing = None + try: + assert await asyncio.to_thread(entered.wait, 5) + preparation.cancel() + with pytest.raises(asyncio.CancelledError): + await preparation + closing = asyncio.create_task(owner.close()) + await asyncio.sleep(0) + assert not closing.done() and owner.cleanup_pending + closing.cancel() + with pytest.raises(asyncio.CancelledError): + await closing + assert owner._observer is not None and not owner._observer.exited() + finally: + release.set() + await owner.close() + await asyncio.gather(preparation, *([closing] if closing is not None else []), return_exceptions=True) + assert not connections and not owner.cleanup_pending + + asyncio.run(scenario()) + assert owners[0].read() == state + + +def test_cancelled_connection_joins_worker_waiting_on_original_flock(owners, monkeypatch): + import fcntl + + state = committed(owners) + owner = lease(owners, state.handoff.instance) + connections, _ = fake_connection(monkeypatch) + holder = PrivateManagedDirectory(owners[0]._fence._root) + blocked = Event() + native = fcntl.flock + + def probe(fd, flags): + try: + return native(fd, flags) + except BlockingIOError: + blocked.set() + raise + + monkeypatch.setattr(fcntl, "flock", probe) + + async def scenario(): + preparation = closing = None + try: + with holder.lock("lifecycle.lock"): + preparation = asyncio.create_task(owner.prepare(deadline=monotonic() + 10)) + assert await asyncio.to_thread(blocked.wait, 3) + original = owner._prepare_task + preparation.cancel() + with pytest.raises(asyncio.CancelledError): + await preparation + closing = asyncio.create_task(owner.close()) + await asyncio.sleep(0) + assert not closing.done() and owner.cleanup_pending + assert owner._prepare_task is original and not original.done() + assert owner._observer is None and not connections + assert not owners[0]._fence._closing + await owner.close() + finally: + await owner.close() + await asyncio.gather(*(task for task in (preparation, closing) if task is not None), + return_exceptions=True) + assert not owner.cleanup_pending and not connections + + try: + asyncio.run(scenario()) + finally: + holder.close() + assert owners[0].read() == state + + +def test_failed_client_close_retains_directory_and_retries_original(owners, monkeypatch): + state = committed(owners) + connections, _ = fake_connection(monkeypatch) + owner = lease(owners, state.handoff.instance) + + async def scenario(): + await owner.prepare(deadline=monotonic() + 5) + original_directory = owner._directory + connections[0].close_error = True + try: + with pytest.raises(RuntimeError, match="injected_cleanup"): + await owner.close() + assert owner.cleanup_pending and owner._directory is original_directory + assert owner._observer is None + finally: + connections[0].close_error = False + await owner.close() + assert not owner.cleanup_pending and len(connections) == 1 + + asyncio.run(scenario()) + + +def test_unknown_native_close_is_not_replayed_or_reported_settled(owners, monkeypatch): + state = committed(owners) + fake_connection(monkeypatch) + owner = lease(owners, state.handoff.instance) + + async def scenario(): + await owner.prepare(deadline=monotonic() + 5) + original = owner._observer.close + calls = [] + + def lost(): + original() # No actual fd leak; only the receipt is lost. + calls.append(1) + raise RuntimeError("injected_close_receipt") + + monkeypatch.setattr(owner._observer, "close", lost) + with pytest.raises(RuntimeError, match="injected_close_receipt"): + await owner.close() + with pytest.raises(ManagedStorageError, match="unavailable"): + await owner.close() + assert calls == [1] and owner.cleanup_pending + assert owner._connection is owner._directory is None + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("kind", ["namespace", "service", "user"]) +def test_mismatched_binding_rejected_without_io(owners, kind): + state = committed(owners) + journal, namespace, service, runtime = owners + instance = state.handoff.instance + if kind == "namespace": + instance = replace(instance, namespace_key="f" * 64) + elif kind == "service": + instance = replace(instance, service_id="f" * 64) + else: + namespace = replace(namespace, user_id=namespace.user_id + 1) + with pytest.raises(ManagedContractError): + ManagedConnectionLeaseV1(journal, namespace, service, instance, runtime_root=runtime, endpoint="workspace") + + +def test_close_before_prepare_never_opens_connection(owners, monkeypatch): + state = committed(owners) + connections, _ = fake_connection(monkeypatch) + owner = lease(owners, state.handoff.instance) + + async def scenario(): + await owner.close() + with pytest.raises(ManagedStorageError, match="conflict"): + await owner.prepare(deadline=monotonic() + 5) + assert not connections and not owner.cleanup_pending + + asyncio.run(scenario()) + + +def test_native_acquisition_without_returned_owner_keeps_unknown_debt(owners, monkeypatch): + state = committed(owners) + owner = lease(owners, state.handoff.instance) + connections, _ = fake_connection(monkeypatch) + original = LinuxServiceObserverV1.reopen + calls = [] + + def lost(identity): + native = original(identity) + native.close() # Actual test fd is closed; acquisition receipt is unknown. + calls.append(1) + raise RuntimeError("injected_native_receipt") + + monkeypatch.setattr(LinuxServiceObserverV1, "reopen", lost) + + async def scenario(): + with pytest.raises(RuntimeError, match="injected_native_receipt"): + await owner.prepare(deadline=monotonic() + 5) + for _ in range(2): + with pytest.raises(ManagedStorageError, match="unavailable"): + await owner.close() + assert owner.cleanup_pending and owner._observer is None + assert calls == [1] and not connections + + asyncio.run(scenario()) + + +def test_internal_task_cancellation_cannot_outlive_native_receipt(owners, monkeypatch): + state = committed(owners) + owner = lease(owners, state.handoff.instance) + connections, _ = fake_connection(monkeypatch) + entered, release = Event(), Event() + original = LinuxServiceObserverV1.reopen + + def held(identity): + native = original(identity) + entered.set() + assert release.wait(5) + return native + + monkeypatch.setattr(LinuxServiceObserverV1, "reopen", held) + + async def scenario(): + preparation = asyncio.create_task(owner.prepare(deadline=monotonic() + 10)) + closing = None + try: + assert await asyncio.to_thread(entered.wait, 5) + owner._prepare_task.cancel() + await asyncio.sleep(0) + closing = asyncio.create_task(owner.close()) + await asyncio.sleep(0) + assert not closing.done() and owner.cleanup_pending + assert owner._observer is None # Native holds it, not yet returned. + finally: + release.set() + await owner.close() + await asyncio.gather(preparation, *([closing] if closing is not None else []), return_exceptions=True) + assert not connections and not owner.cleanup_pending and owner._observer is None + + asyncio.run(scenario()) + + +def test_late_post_auth_receipt_does_not_publish_ready(owners, monkeypatch): + import loushang.apphost.managed.connection as module + + state = committed(owners) + owner = lease(owners, state.handoff.instance) + connections, _ = fake_connection(monkeypatch) + original_check, original_recheck = module._check_deadline, owner._recheck + returned = Event() + + def recheck(*args): + original_recheck(*args) + returned.set() + + def check(deadline): + if returned.is_set(): + raise ManagedStorageError("busy") + original_check(deadline) + + monkeypatch.setattr(owner, "_recheck", recheck) + monkeypatch.setattr(module, "_check_deadline", check) + + async def scenario(): + try: + with pytest.raises(ManagedStorageError, match="busy"): + await owner.prepare(deadline=monotonic() + 5) + assert connections[0].started + with pytest.raises(AppConnectionClosedError): + _ = owner.client + finally: + await owner.close() + assert not owner.cleanup_pending + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("phase", ["prepare", "close"]) +def test_task_publication_fault_cannot_start_unowned_phase(owners, monkeypatch, phase): + state = committed(owners) + owner = lease(owners, state.handoff.instance) + connections, _ = fake_connection(monkeypatch) + + async def scenario(): + if phase == "close": + await owner.prepare(deadline=monotonic() + 5) + original = asyncio.create_task + unpublished = [] + + def lost(work, **kwargs): + unpublished.append(original(work, **kwargs)) + raise RuntimeError("injected_task_publication") + + with monkeypatch.context() as scoped: + scoped.setattr(asyncio, "create_task", lost) + with pytest.raises(RuntimeError, match="injected_task_publication"): + if phase == "prepare": + await owner.prepare(deadline=monotonic() + 5) + else: + await owner.close() + await asyncio.gather(*unpublished, return_exceptions=True) + if phase == "prepare": + assert not connections and owner._observer is None + else: + assert not connections[0].closed and owner.cleanup_pending + await owner.close() + assert not owner.cleanup_pending + + asyncio.run(scenario()) + + +def test_executor_publication_fault_runs_no_native_effect(owners, monkeypatch): + state = committed(owners) + owner = lease(owners, state.handoff.instance) + connections, _ = fake_connection(monkeypatch) + + async def scenario(): + loop = asyncio.get_running_loop() + original = loop.run_in_executor + unpublished = [] + + def lost(executor, callback, *args): + unpublished.append(original(executor, callback, *args)) + raise RuntimeError("injected_executor_publication") + + with monkeypatch.context() as scoped: + scoped.setattr(loop, "run_in_executor", lost) + with pytest.raises(RuntimeError, match="injected_executor_publication"): + await owner.prepare(deadline=monotonic() + 5) + await asyncio.gather(*unpublished) + assert not connections and owner._observer is None and not owner._native_uncertain + await owner.close() + assert not owner.cleanup_pending + + asyncio.run(scenario()) diff --git a/tests/apphost/test_managed_creation_inspection.py b/tests/apphost/test_managed_creation_inspection.py new file mode 100644 index 000000000..86d2c0e54 --- /dev/null +++ b/tests/apphost/test_managed_creation_inspection.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.registry import ManagedMuxCreationInspectionV1 + +from .test_managed_registry import intent +from .test_managed_registry import namespace as namespace +from .test_managed_registry import registry as registry + + +def snapshot(root): + return {str(path.relative_to(root)): (path.lstat().st_mode, path.lstat().st_ino, + path.lstat().st_mtime_ns, + path.read_bytes() if path.is_file() else None) + for path in (root, *sorted(root.rglob("*")))} + + +def test_exact_creation_inspection_is_readonly_before_service_and_after_name_reuse(registry, tmp_path): + first = intent() + registry.reserve_mux(first) + original = snapshot(tmp_path) + assert registry.inspect_mux_creation("f" * 32) is None + result = registry.inspect_mux_creation(first.operation_id) + assert result == ManagedMuxCreationInspectionV1(first, True, None) + with pytest.raises(FrozenInstanceError): + result.active = False + assert snapshot(tmp_path) == original + with registry._database.transaction(write=True) as connection: + connection.execute("INSERT INTO mux_authorities VALUES (?, ?, ?, ?, ?, ?)", + (first.operation_id, "c" * 32, "c" * 32, "d" * 64, "c" * 32, "old-mux")) + connection.execute("DELETE FROM muxes WHERE operation_id=?", (first.operation_id,)) + second = intent(operation="e" * 32) + registry.reserve_mux(second) + original = snapshot(tmp_path) + old = registry.inspect_mux_creation(first.operation_id) + assert old.reservation == first and not old.active + assert old.created.operation_id == first.operation_id and old.created.mux_space_id == "old-mux" + assert registry.inspect_mux_creation(second.operation_id) == ManagedMuxCreationInspectionV1(second, True, None) + assert snapshot(tmp_path) == original + assert "authority" not in repr(old) + + +@pytest.mark.parametrize("column,value", [("origin_instance_id", "e" * 32), ("created_instance_id", "d" * 32), + ("mux_space_id", "invalid id")]) +def test_inspection_rejects_corrupt_creation_without_writes(registry, tmp_path, column, value): + first = intent() + registry.reserve_mux(first) + with registry._database.transaction(write=True) as connection: + connection.execute("INSERT INTO mux_authorities VALUES (?, ?, ?, ?, ?, ?)", + (first.operation_id, "c" * 32, "c" * 32, "d" * 64, "c" * 32, "mux")) + connection.execute(f"UPDATE mux_authorities SET {column}=?", (value,)) + original = snapshot(tmp_path) + with pytest.raises(ManagedStorageError, match="invalid_record"): + registry.inspect_mux_creation(first.operation_id) + assert snapshot(tmp_path) == original + + +@pytest.mark.parametrize("present", [False, True]) +def test_final_deadline_applies_to_empty_and_present_results(registry, tmp_path, monkeypatch, present): + from loushang.apphost.managed import registry as module + + first = intent() + if present: + registry.reserve_mux(first) + original = snapshot(tmp_path) + checked = [] + + def expired(deadline): + checked.append(deadline) + raise ManagedStorageError("unavailable") + + monkeypatch.setattr(module, "_check_deadline", expired) + with pytest.raises(ManagedStorageError, match="unavailable"): + registry.inspect_mux_creation(first.operation_id) + assert checked == [None] and snapshot(tmp_path) == original diff --git a/tests/apphost/test_managed_data_creation.py b/tests/apphost/test_managed_data_creation.py new file mode 100644 index 000000000..6bad2bf46 --- /dev/null +++ b/tests/apphost/test_managed_data_creation.py @@ -0,0 +1,268 @@ +import os +from concurrent.futures import ThreadPoolExecutor +from contextlib import suppress +from dataclasses import FrozenInstanceError +from threading import Event +from time import monotonic + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError, PrivateManagedDirectory + +from .test_managed_files import pytestmark as pytestmark + + +def ids(first): + return tuple(f"74656d7000000000{value:016x}" for value in (first, first + 1)) + + +def test_fence_prevents_late_create_and_reissuing_same_pair(tmp_path): + owner = PrivateManagedDirectory(tmp_path / "private", create=True) + binding = object() + try: + first, second = owner.prepare_data_creation_pair(ids(1), capacity=4096, binding=binding) + assert owner.cleanup_pending + assert owner.fence_data_creation(first, binding=binding) + assert owner.cleanup_pending + with owner.lock("data.lock", create=True): + with pytest.raises(ManagedStorageError, match="conflict"): + owner.create_data(first, binding=binding) + snapshot = owner.create_data(second, binding=binding) + assert snapshot.size == 0 + assert not owner.cleanup_pending + assert not owner.fence_data_creation(second, binding=binding) + assert not (tmp_path / "private" / first.name).exists() + assert (tmp_path / "private" / second.name).exists() + with pytest.raises(ManagedStorageError, match="conflict"): + owner.prepare_data_creation_pair(ids(1), capacity=4096, binding=binding) + finally: + owner.close() + + +def test_pair_capacity_refusal_has_no_partial_registration(tmp_path): + owner = PrivateManagedDirectory(tmp_path / "private", create=True) + binding = object() + creations = [] + try: + for first in (1, 3, 5, 7): + creations.extend(owner.prepare_data_creation_pair(ids(first), capacity=4096, binding=binding)) + with pytest.raises(ManagedStorageError, match="capacity"): + owner.prepare_data_creation_pair(ids(9), capacity=4096, binding=binding) + assert len(owner._creations) == 8 and owner._creation_high_water == ids(7)[1] + for creation in creations: + owner.fence_data_creation(creation, binding=binding) + assert not owner.cleanup_pending + pair = owner.prepare_data_creation_pair(ids(9), capacity=4096, binding=binding) + for creation in pair: + owner.fence_data_creation(creation, binding=binding) + finally: + owner.close() + + +def test_creation_requires_original_owner_and_binding_before_native_io(tmp_path): + owner = PrivateManagedDirectory(tmp_path / "private", create=True) + other = PrivateManagedDirectory(tmp_path / "other", create=True) + binding = object() + pair = owner.prepare_data_creation_pair(ids(1), capacity=4096, binding=binding) + try: + for destination, credential in ((owner, object()), (other, binding)): + with pytest.raises(ManagedStorageError, match="conflict"): + destination.create_data(pair[0], binding=credential) + with pytest.raises(ManagedStorageError, match="conflict"): + destination.fence_data_creation(pair[0], binding=credential) + assert pair[0].phase == "new" + with pytest.raises(ManagedStorageError, match="busy"): + owner.close() + assert not owner._closing + finally: + for creation in pair: + owner.fence_data_creation(creation, binding=binding) + owner.close() + other.close() + + +def test_frozen_native_target_requires_original_fence_completion(tmp_path): + owner = PrivateManagedDirectory(tmp_path / "private", create=True) + binding = object() + first, second = owner.prepare_data_creation_pair(ids(1), capacity=4096, binding=binding) + try: + target = owner.creation_target(first, binding=binding) + assert target.allocation_id == ids(1)[0] and target.name == first.name + assert target.root_identity == owner._identity and target.capacity == 4096 + with pytest.raises(FrozenInstanceError): + target.capacity = 8192 + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.creation_target(first, binding=binding, require_fenced=True) + owner.fence_data_creation(first, binding=binding) + assert owner.creation_target(first, binding=binding, require_fenced=True) is target + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.creation_target(second, binding=binding, require_fenced=True) + with pytest.raises(ManagedStorageError, match="conflict"): + owner.creation_target(first, binding=object(), require_fenced=True) + finally: + owner.fence_data_creation(first, binding=binding) + owner.fence_data_creation(second, binding=binding) + owner.close() + + +@pytest.mark.parametrize("expired", [True, False]) +def test_creation_registration_deadline_covers_mutex(tmp_path, monkeypatch, expired): + owner = PrivateManagedDirectory(tmp_path / "private", create=True) + calls = [] + + class HeldMutex: + def acquire(self, *, timeout): + calls.append(timeout) + assert 0 <= timeout <= 1 + return False + + try: + with monkeypatch.context() as patch: + patch.setattr(owner, "_mutex", HeldMutex()) + with pytest.raises(ManagedStorageError, match="busy"): + owner.prepare_data_creation_pair(ids(1), capacity=4096, binding=object(), + deadline=monotonic() + (-1 if expired else 1)) + assert len(calls) == (0 if expired else 1) + assert not owner._creations and not owner._creation_high_water + finally: + owner.close() + + +def test_unknown_creation_cannot_be_fenced_or_replayed(tmp_path, monkeypatch): + owner = PrivateManagedDirectory(tmp_path / "private", create=True) + binding = object() + first, second = owner.prepare_data_creation_pair(ids(1), capacity=4096, binding=binding) + original = owner.append_data + + def lost_receipt(*args, **kwargs): + original(*args, **kwargs) + raise OSError("lost creation receipt") + + try: + monkeypatch.setattr(owner, "append_data", lost_receipt) + with owner.lock("data.lock", create=True): + with pytest.raises(OSError): + owner.create_data(first, binding=binding) + with pytest.raises(ManagedStorageError, match="conflict"): + owner.create_data(first, binding=binding) + assert not owner.fence_data_creation(first, binding=binding) + assert owner.fence_data_creation(second, binding=binding) + assert owner.cleanup_pending and (tmp_path / "private" / first.name).exists() + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.close() + assert owner._fd is None and owner.cleanup_pending + finally: + with suppress(ManagedStorageError): + owner.close() + + +def test_fence_wins_against_queued_native_worker(tmp_path, monkeypatch): + owner = PrivateManagedDirectory(tmp_path / "private", create=True) + binding = object() + first, second = owner.prepare_data_creation_pair(ids(1), capacity=4096, binding=binding) + entered = Event() + + def create(): + entered.set() + return owner.create_data(first, binding=binding) + + def unexpected(*args, **kwargs): + raise AssertionError("fenced worker reached native creation") + + try: + monkeypatch.setattr(owner, "append_data", unexpected) + with ThreadPoolExecutor(max_workers=1) as pool: + with owner._mutex: + future = pool.submit(create) + assert entered.wait(5) + assert owner.fence_data_creation(first, binding=binding) + with pytest.raises(ManagedStorageError, match="conflict"): + future.result(timeout=5) + assert not (tmp_path / "private" / first.name).exists() + finally: + owner.fence_data_creation(first, binding=binding) + owner.fence_data_creation(second, binding=binding) + owner.close() + + +def test_native_admission_wins_against_concurrent_fence(tmp_path, monkeypatch): + owner = PrivateManagedDirectory(tmp_path / "private", create=True) + binding = object() + first, second = owner.prepare_data_creation_pair(ids(1), capacity=4096, binding=binding) + admitted, fence_entered, resume = Event(), Event(), Event() + original = owner.append_data + + def paused_append(*args, **kwargs): + admitted.set() + assert resume.wait(5) + return original(*args, **kwargs) + + def create(): + with owner.lock("data.lock", create=True): + return owner.create_data(first, binding=binding) + + def fence(): + fence_entered.set() + return owner.fence_data_creation(first, binding=binding) + + try: + monkeypatch.setattr(owner, "append_data", paused_append) + with ThreadPoolExecutor(max_workers=2) as pool: + creating = pool.submit(create) + try: + assert admitted.wait(5) + fencing = pool.submit(fence) + assert fence_entered.wait(5) + assert not fencing.done() + finally: + resume.set() + assert creating.result(timeout=5).size == 0 + assert fencing.result(timeout=5) is False + assert first._completion is None + assert owner.cleanup_pending # The other stream still needs settlement. + finally: + resume.set() + owner.fence_data_creation(second, binding=binding) + owner.close() + + +def test_creation_close_receipt_loss_never_closes_reused_descriptor(tmp_path, monkeypatch): + root = tmp_path / "private" + owner = PrivateManagedDirectory(root, create=True) + binding = object() + first, second = owner.prepare_data_creation_pair(ids(1), capacity=4096, binding=binding) + original_close = os.close + reused, attempts = [], [] + + def lost_close(fd): + attempts.append(fd) + original_close(fd) + if first.phase == "admitted" and not reused: + replacement = os.open(root / "unrelated", os.O_CREAT | os.O_RDWR, 0o600) + if replacement != fd: + os.dup2(replacement, fd) + original_close(replacement) + reused.append(fd) + raise OSError("close succeeded but receipt was lost") + + try: + with monkeypatch.context() as patch: + patch.setattr(os, "close", lost_close) + with owner.lock("data.lock", create=True): + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.create_data(first, binding=binding) + assert first.phase == "unknown" and not owner.fence_data_creation(first, binding=binding) + owner.fence_data_creation(second, binding=binding) + for _ in range(2): + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.close() + assert len(reused) == 1 and attempts.count(reused[0]) == 1 + os.write(reused[0], b"still test-owned") + assert (root / "unrelated").read_bytes() == b"still test-owned" + assert (root / first.name).exists() and owner.cleanup_pending + finally: + for fd in reused: + original_close(fd) + owner.fence_data_creation(second, binding=binding) + with suppress(ManagedStorageError): + owner.close() diff --git a/tests/apphost/test_managed_data_isolation.py b/tests/apphost/test_managed_data_isolation.py new file mode 100644 index 000000000..07510bd99 --- /dev/null +++ b/tests/apphost/test_managed_data_isolation.py @@ -0,0 +1,185 @@ +import os +from contextlib import suppress +from dataclasses import replace + +import pytest + +from loushang.apphost.managed import _files +from loushang.apphost.managed._files import ManagedStorageError + +from .test_managed_files import directory as directory +from .test_managed_files import pytestmark as pytestmark + + +def create(owner): + with owner.lock("data.lock", create=True): + return owner.append_data("stdout", b"original", expected=None, capacity=4096) + + +def test_isolation_keeps_original_inode_and_bytes(directory): + root, owner = directory + snapshot = create(owner) + with owner.lock("data.lock"): + isolated = owner.isolate_data("stdout", "removed-first", expected=snapshot, capacity=4096) + assert isolated.identity == snapshot.identity and isolated.tail == b"original" + assert not (root / "stdout").exists() + assert (root / "removed-first").read_bytes() == b"original" + assert not owner.cleanup_pending + + +def test_isolation_never_overwrites_existing_target(directory): + root, owner = directory + snapshot = create(owner) + target = root / "removed-first" + target.write_bytes(b"keep") + target.chmod(0o600) + with owner.lock("data.lock"): + with pytest.raises(ManagedStorageError, match="conflict"): + owner.isolate_data("stdout", "removed-first", expected=snapshot, capacity=4096) + assert target.read_bytes() == b"keep" and (root / "stdout").read_bytes() == b"original" + + +@pytest.mark.parametrize("change", ["missing", "snapshot", "symlink", "hardlink"]) +def test_bad_identity_does_not_move_file(directory, change): + root, owner = directory + snapshot = create(owner) + if change == "missing": + (root / "stdout").unlink() + elif change == "snapshot": + snapshot = replace(snapshot, size=snapshot.size + 1) + elif change == "symlink": + (root / "stdout").rename(root / "held") + (root / "stdout").symlink_to(root / "held") + else: + os.link(root / "stdout", root / "held") + with owner.lock("data.lock"): + with pytest.raises(ManagedStorageError): + owner.isolate_data("stdout", "removed-first", expected=snapshot, capacity=4096) + assert not (root / "removed-first").exists() + + +def test_unknown_rename_is_not_replayed_by_close(directory, monkeypatch): + root, owner = directory + snapshot = create(owner) + original = _files._rename_data_noreplace + calls = [] + + def rename(*args): + calls.append(args) + original(*args) + raise ManagedStorageError("unavailable") + + monkeypatch.setattr(_files, "_rename_data_noreplace", rename) + with owner.lock("data.lock"): + with pytest.raises(ManagedStorageError): + owner.isolate_data("stdout", "removed-first", expected=snapshot, capacity=4096) + (root / "stdout").write_bytes(b"replacement") + owner.close() + assert len(calls) == 1 + assert (root / "stdout").read_bytes() == b"replacement" + assert (root / "removed-first").read_bytes() == b"original" + + +def test_sync_failure_close_only_retries_sync(directory, monkeypatch): + root, owner = directory + snapshot = create(owner) + with owner.lock("data.lock"): + with monkeypatch.context() as patch: + patch.setattr(os, "fsync", lambda fd: (_ for _ in ()).throw(OSError("injected sync"))) + with pytest.raises(ManagedStorageError): + owner.isolate_data("stdout", "removed-first", expected=snapshot, capacity=4096) + assert owner.cleanup_pending + (root / "stdout").write_bytes(b"replacement") + owner.close() + assert (root / "stdout").read_bytes() == b"replacement" + assert (root / "removed-first").read_bytes() == b"original" + + +@pytest.mark.parametrize("change", ["replace", "modify"]) +def test_sync_boundary_mutation_cannot_return_success(directory, monkeypatch, change): + root, owner = directory + snapshot = create(owner) + original = os.fsync + target = root / "removed-first" + + def sync(fd): + original(fd) + if change == "replace": + target.rename(root / "held-original") + target.write_bytes(b"changed") + target.chmod(0o600) + + with owner.lock("data.lock"): + with monkeypatch.context() as patch: + patch.setattr(os, "fsync", sync) + with pytest.raises(ManagedStorageError, match="conflict"): + owner.isolate_data("stdout", "removed-first", expected=snapshot, capacity=4096) + assert owner._data_failed + owner.close() + assert target.read_bytes() == b"changed" + if change == "replace": + assert (root / "held-original").read_bytes() == b"original" + + +def test_source_replacement_before_rename_preserves_both_files(directory, monkeypatch): + root, owner = directory + snapshot = create(owner) + original = _files._rename_data_noreplace + + def rename(*args): + (root / "stdout").rename(root / "held-original") + (root / "stdout").write_bytes(b"replacement") + (root / "stdout").chmod(0o600) + original(*args) + + with owner.lock("data.lock"): + with monkeypatch.context() as patch: + patch.setattr(_files, "_rename_data_noreplace", rename) + with pytest.raises(ManagedStorageError, match="conflict"): + owner.isolate_data("stdout", "removed-first", expected=snapshot, capacity=4096) + owner.close() + assert (root / "held-original").read_bytes() == b"original" + assert (root / "removed-first").read_bytes() == b"replacement" + + +def test_unknown_data_close_does_not_close_a_reused_descriptor(tmp_path, monkeypatch): + root = tmp_path / "private" + owner = _files.PrivateManagedDirectory(root, create=True) + snapshot = create(owner) + original_close = os.close + reused = [] + attempted = [] + + def close_then_lose_receipt(fd): + metadata = os.fstat(fd) + attempted.append(fd) + original_close(fd) + if not reused and (metadata.st_dev, metadata.st_ino) == snapshot.identity: + # The native close really happened; its numeric descriptor now + # belongs to a different, test-owned file before the error returns. + replacement = os.open(root / "unrelated", os.O_CREAT | os.O_RDWR, 0o600) + reused.append(replacement) + assert replacement == fd + raise OSError("injected lost close receipt") + + try: + with monkeypatch.context() as patch: + patch.setattr(os, "close", close_then_lose_receipt) + with owner.lock("data.lock"): + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.isolate_data("stdout", "removed-first", expected=snapshot, capacity=4096) + assert owner.cleanup_pending and owner._data_failed + for _ in range(2): + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.close() + assert attempted.count(reused[0]) == 1 + os.write(reused[0], b"still open") + assert (root / "unrelated").read_bytes() == b"still open" + assert (root / "removed-first").read_bytes() == b"original" + assert not (root / "stdout").exists() + finally: + for fd in reused: + original_close(fd) + # Unknown receipts remain unknown; only settle the other original fds. + with suppress(ManagedStorageError): + owner.close() diff --git a/tests/apphost/test_managed_data_read.py b/tests/apphost/test_managed_data_read.py new file mode 100644 index 000000000..1e76f862d --- /dev/null +++ b/tests/apphost/test_managed_data_read.py @@ -0,0 +1,68 @@ +import os + +import pytest + +from loushang.apphost.managed import _files +from loushang.apphost.managed._files import MAX_RECORD_BYTES, ManagedStorageError + +from .test_managed_files import directory as directory +from .test_managed_files import pytestmark as pytestmark + + +def create(owner, content): + snapshot = None + with owner.lock("data.lock", create=True): + for start in range(0, max(1, len(content)), MAX_RECORD_BYTES): + snapshot = owner.append_data("stdout", content[start:start + MAX_RECORD_BYTES], + expected=snapshot, capacity=128 * 1024) + return snapshot + + +@pytest.mark.parametrize("content", [b"", b"hello", b"x" * (MAX_RECORD_BYTES * 3 + 7)]) +def test_sealed_read_checks_exact_length_with_bounded_native_reads(directory, monkeypatch, content): + _, owner = directory + snapshot = create(owner, content) + original = os.pread + + def bounded(fd, length, offset): + assert length <= MAX_RECORD_BYTES + return original(fd, length, offset) + + monkeypatch.setattr(_files.os, "pread", bounded) + assert owner.read_data("stdout", expected=snapshot, capacity=128 * 1024, max_bytes=len(content)) == content + + +def test_oversize_sealed_read_fails_before_open(directory, monkeypatch): + _, owner = directory + snapshot = create(owner, b"hello") + + def unexpected(*args, **kwargs): + raise AssertionError("oversize read must not open a descriptor") + + monkeypatch.setattr(owner, "_open", unexpected) + with pytest.raises(ManagedStorageError, match="capacity"): + owner.read_data("stdout", expected=snapshot, capacity=128 * 1024, max_bytes=4) + + +@pytest.mark.parametrize("replace_path", [False, True]) +def test_sealed_read_rejects_mid_read_change(directory, monkeypatch, replace_path): + root, owner = directory + snapshot = create(owner, b"x" * (MAX_RECORD_BYTES * 2)) + original = os.pread + changed = False + + def mutate(fd, length, offset): + nonlocal changed + block = original(fd, length, offset) + # Initial snapshot reads the tail; offset zero marks the actual body read. + if offset == 0 and not changed: + changed = True + if replace_path: + os.rename(root / "stdout", root / "old-stdout") + (root / "stdout").write_bytes(b"y" * snapshot.size) + return block + + monkeypatch.setattr(_files.os, "pread", mutate) + with pytest.raises(ManagedStorageError): + owner.read_data("stdout", expected=snapshot, capacity=128 * 1024, max_bytes=snapshot.size) + assert changed and (root / "stdout").read_bytes() == b"y" * snapshot.size diff --git a/tests/apphost/test_managed_data_removal.py b/tests/apphost/test_managed_data_removal.py new file mode 100644 index 000000000..1f8f62264 --- /dev/null +++ b/tests/apphost/test_managed_data_removal.py @@ -0,0 +1,226 @@ +import asyncio +import os +from contextlib import suppress +from dataclasses import replace + +import pytest + +from loushang.apphost.managed import _files +from loushang.apphost.managed._files import ManagedStorageError, PrivateManagedDirectory + +from .test_managed_data_isolation import create +from .test_managed_files import pytestmark as pytestmark + + +@pytest.fixture +def directory(tmp_path): + root = tmp_path / "private" + owner = PrivateManagedDirectory(root, create=True) + try: + yield root, owner + finally: + try: + owner.close() + except ManagedStorageError: + # Unknown deletion stays charged, but these tests never inject an + # unknown close: all originally owned descriptors must be released. + assert owner._removals and owner._fd is None + assert not owner._close_pending and not owner._uncertain_closes + + +def test_original_removal_completes_and_repeated_use_has_no_native_effect(directory, monkeypatch): + root, owner = directory + snapshot = create(owner) + removal = owner.prepare_data_removal("stdout", "removed-first", expected=snapshot, capacity=4096) + assert owner.cleanup_pending and (root / "stdout").exists() + with owner.lock("data.lock"): + owner.remove_data(removal) + assert removal.phase == "complete" and not owner.cleanup_pending + assert not (root / "stdout").exists() and not (root / "removed-first").exists() + + def unexpected(*args, **kwargs): + raise AssertionError("completed removal must not replay native work") + monkeypatch.setattr(_files.os, "unlink", unexpected) + owner.remove_data(removal) + + +def test_sync_retry_never_unlinks_replacement(directory, monkeypatch): + root, owner = directory + snapshot = create(owner) + removal = owner.prepare_data_removal("stdout", "removed-first", expected=snapshot, capacity=4096) + original_sync = owner._sync_parent + failed = False + + def fail_once(parent): + nonlocal failed + if not failed: + failed = True + raise OSError("sync unavailable") + return original_sync(parent) + + monkeypatch.setattr(owner, "_sync_parent", fail_once) + with owner.lock("data.lock"): + with pytest.raises(ManagedStorageError): + owner.remove_data(removal) + assert removal.phase == "unlinked" + # Even after releasing the lock, close must preserve retry resources. + with pytest.raises(ManagedStorageError, match="busy"): + owner.close() + assert not owner._closing and removal.fd in owner._close_pending + with owner.lock("data.lock"): + (root / "removed-first").write_bytes(b"replacement") + owner.remove_data(removal) + assert (root / "removed-first").read_bytes() == b"replacement" + assert removal.phase == "complete" + + +def test_unknown_unlink_retains_debt_and_never_replays(directory, monkeypatch): + root, owner = directory + snapshot = create(owner) + removal = owner.prepare_data_removal("stdout", "removed-first", expected=snapshot, capacity=4096) + original = os.unlink + calls = [] + + def lost_receipt(name, *args, **kwargs): + calls.append(name) + original(name, *args, **kwargs) + raise OSError("lost unlink receipt") + + with owner.lock("data.lock"): + with monkeypatch.context() as patch: + patch.setattr(_files.os, "unlink", lost_receipt) + with pytest.raises(ManagedStorageError): + owner.remove_data(removal) + (root / "removed-first").write_bytes(b"replacement") + with pytest.raises(ManagedStorageError): + owner.remove_data(removal) + assert calls == ["removed-first"] + assert removal.phase == "unlink_unknown" and owner.cleanup_pending + assert (root / "removed-first").read_bytes() == b"replacement" + + +def test_event_loop_rejection_precedes_mutex_and_native_effects(directory, monkeypatch): + root, owner = directory + snapshot = create(owner) + removal = owner.prepare_data_removal("stdout", "removed-first", expected=snapshot, capacity=4096) + + class UnavailableMutex: + def __enter__(self): + raise AssertionError("event loop must not wait for the native mutex") + + def __exit__(self, *args): + pass + + async def run(): + with pytest.raises(ManagedStorageError, match="busy"): + owner.prepare_data_removal("stderr", "removed-second", expected=snapshot, capacity=4096) + with pytest.raises(ManagedStorageError, match="busy"): + owner.remove_data(removal) + + with monkeypatch.context() as patch: + patch.setattr(owner, "_mutex", UnavailableMutex()) + asyncio.run(run()) + assert removal.phase == "new" and (root / "stdout").read_bytes() == b"original" + assert len(owner._removals) == 1 + with owner.lock("data.lock"): + owner.remove_data(removal) + + +@pytest.mark.parametrize("opened", [False, True]) +def test_abandon_releases_handles_but_never_completes_removal(directory, opened): + root, owner = directory + snapshot = create(owner) + if opened: + snapshot = replace(snapshot, size=snapshot.size + 1) + removal = owner.prepare_data_removal("stdout", "removed-first", expected=snapshot, capacity=4096) + if opened: + with owner.lock("data.lock"): + with pytest.raises(ManagedStorageError, match="conflict"): + owner.remove_data(removal) + phase = removal.phase + owner.abandon_data_removal(removal) + assert removal.phase == phase and removal.abandoned + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.remove_data(removal) + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.close() + assert owner._fd is None and not owner._close_pending + assert owner.cleanup_pending and removal.phase != "complete" + assert (root / "stdout").read_bytes() == b"original" + + +def test_isolated_content_change_is_not_deleted_on_retry(directory, monkeypatch): + root, owner = directory + snapshot = create(owner) + removal = owner.prepare_data_removal("stdout", "removed-first", expected=snapshot, capacity=4096) + original_snapshot = owner._data_snapshot + calls = 0 + + def transient(*args): + nonlocal calls + calls += 1 + if calls == 3: + raise OSError("read temporarily unavailable") + return original_snapshot(*args) + + with owner.lock("data.lock"): + with monkeypatch.context() as patch: + patch.setattr(owner, "_data_snapshot", transient) + with pytest.raises(ManagedStorageError): + owner.remove_data(removal) + assert removal.phase == "isolated" + (root / "removed-first").write_bytes(b"changed original inode") + with pytest.raises(ManagedStorageError, match="conflict"): + owner.remove_data(removal) + owner.abandon_data_removal(removal) + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.close() + assert (root / "removed-first").read_bytes() == b"changed original inode" + assert removal.phase == "isolated" and owner.cleanup_pending + + +def test_abandoned_unknown_close_never_closes_reused_fd(tmp_path, monkeypatch): + root = tmp_path / "private" + owner = PrivateManagedDirectory(root, create=True) + snapshot = create(owner) + removal = owner.prepare_data_removal( + "stdout", "removed-first", expected=replace(snapshot, size=snapshot.size + 1), capacity=4096, + ) + with owner.lock("data.lock"): + with pytest.raises(ManagedStorageError, match="conflict"): + owner.remove_data(removal) + original_fd = removal.fd + assert original_fd is not None + owner.abandon_data_removal(removal) + original_close = os.close + reused = [] + attempts = [] + + def lost_close(fd): + attempts.append(fd) + original_close(fd) + if fd == original_fd and not reused: + replacement = os.open(root / "unrelated", os.O_CREAT | os.O_RDWR, 0o600) + if replacement != fd: + os.dup2(replacement, fd) + original_close(replacement) + reused.append(fd) + raise OSError("original close succeeded but receipt was lost") + + try: + with monkeypatch.context() as patch: + patch.setattr(os, "close", lost_close) + for _ in range(2): + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.close() + assert attempts.count(original_fd) == 1 + assert removal.phase == "opened" and removal.abandoned + assert owner.cleanup_pending and original_fd in owner._uncertain_closes + os.write(reused[0], b"test-owned descriptor remains open") + assert (root / "unrelated").read_bytes() == b"test-owned descriptor remains open" + assert (root / "stdout").read_bytes() == b"original" + finally: + for fd in reused: + original_close(fd) + with suppress(ManagedStorageError): + owner.close() diff --git a/tests/apphost/test_managed_defaults.py b/tests/apphost/test_managed_defaults.py new file mode 100644 index 000000000..2aa254e69 --- /dev/null +++ b/tests/apphost/test_managed_defaults.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest + +from loushang.apphost.managed import defaults as module +from loushang.apphost.managed.contracts import ManagedContractError +from loushang.hosting.errors import HostingError, HostingFailureCategory + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed defaults") + + +@pytest.fixture(autouse=True) +def machine(monkeypatch): + monkeypatch.setattr(module, "linux_machine_key", lambda *, domain: "a" * 32) + + +def test_defaults_same_from_any_cwd_without_creating_directories(tmp_path, monkeypatch): + first = module.resolve_managed_defaults(environ={}, home=tmp_path / "home") + monkeypatch.chdir(tmp_path) + second = module.resolve_managed_defaults(environ={}, home=tmp_path / "home") + assert first == second + assert first.namespace.platform_home == str(tmp_path / "home/.loushang") + assert first.platform.runtime == Path("/tmp") / f"loushang-{os.getuid()}" + assert first.temporary_override is None + assert not list(tmp_path.iterdir()) + + +def test_explicit_root_precedence_and_environment_snapshot(tmp_path): + env = {"LOUSHANG_HOME": str(tmp_path / "platform"), "LOUSHANG_RUNTIME_DIR": str(tmp_path / "run"), + "XDG_RUNTIME_DIR": str(tmp_path / "xdg"), "LOUSHANG_TMPDIR": str(tmp_path / "scratch")} + value = module.resolve_managed_defaults(environ=env, home=tmp_path / "ignored") + env["LOUSHANG_HOME"] = "/changed" + assert value.namespace.platform_home == str(tmp_path / "platform") + assert value.platform.runtime == tmp_path / "run" + assert value.temporary_override == str(tmp_path / "scratch") + assert not list(tmp_path.iterdir()) + + +def test_xdg_runtime_precedence(tmp_path): + result = module.resolve_managed_defaults(environ={"XDG_RUNTIME_DIR": str(tmp_path / "xdg")}, home=tmp_path) + assert result.platform.runtime == tmp_path / "xdg/loushang" + + +def test_platform_rejection_before_uid_or_path_lookup(monkeypatch): + monkeypatch.setattr(module.sys, "platform", "win32") + monkeypatch.setattr(module, "resolve_platform_paths", lambda **k: pytest.fail("unsupported lookup")) + with pytest.raises(HostingError) as caught: + module.resolve_managed_defaults(environ={}) + assert caught.value.category == HostingFailureCategory.PLATFORM_UNSUPPORTED + + +def test_shell_tmpdir_does_not_change_reconnect_namespace(tmp_path): + before = module.resolve_managed_defaults(environ={}, home=tmp_path) + after = module.resolve_managed_defaults(environ={"TMPDIR": str(tmp_path / "scratch")}, home=tmp_path) + assert before == after + + +def test_explicit_tmp_common_ancestor_is_allowed_when_leaves_are_disjoint(tmp_path): + value = module.resolve_managed_defaults(environ={"LOUSHANG_TMPDIR": "/tmp"}, home=tmp_path) + assert value.temporary_override == "/tmp" + assert value.platform.runtime == Path("/tmp") / f"loushang-{os.getuid()}" + + +def test_unused_lower_priority_roots_do_not_override_valid_selection(tmp_path): + env = {"LOUSHANG_HOME": str(tmp_path / "platform"), "LOUSHANG_RUNTIME_DIR": str(tmp_path / "run"), + "XDG_RUNTIME_DIR": "relative-unused"} + result = module.resolve_managed_defaults(environ=env, home="relative-unused") + assert result.namespace.platform_home == str(tmp_path / "platform") + assert result.platform.runtime == tmp_path / "run" + + +@pytest.mark.parametrize("key", ["LOUSHANG_HOME", "LOUSHANG_RUNTIME_DIR", "LOUSHANG_TMPDIR", "XDG_RUNTIME_DIR"]) +@pytest.mark.parametrize("value", ["relative", " "]) +def test_rejects_cwd_dependent_root_overrides(tmp_path, key, value, monkeypatch): + monkeypatch.setattr(module, "linux_machine_key", lambda **k: pytest.fail("invalid root read machine ID")) + with pytest.raises(ManagedContractError): + module.resolve_managed_defaults(environ={key: value}, home=tmp_path) + assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize("key,suffix", [("LOUSHANG_RUNTIME_DIR", "lmux"), ("LOUSHANG_RUNTIME_DIR", "state"), + ("LOUSHANG_RUNTIME_DIR", "data"), ("LOUSHANG_TMPDIR", "lmux"), ("LOUSHANG_TMPDIR", "state"), + ("LOUSHANG_TMPDIR", "data"), ("LOUSHANG_TMPDIR", "run")]) +def test_overlapping_roots_rejected_before_creation(tmp_path, key, suffix): + env = {"LOUSHANG_HOME": str(tmp_path), "LOUSHANG_RUNTIME_DIR": str(tmp_path / "run"), key: str(tmp_path / suffix)} + with pytest.raises(ManagedContractError): + module.resolve_managed_defaults(environ=env) + assert not list(tmp_path.iterdir()) diff --git a/tests/apphost/test_managed_discovery.py b/tests/apphost/test_managed_discovery.py new file mode 100644 index 000000000..6ab359faf --- /dev/null +++ b/tests/apphost/test_managed_discovery.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import os +import sqlite3 +import sys +from dataclasses import FrozenInstanceError, asdict, replace +from pathlib import Path +from time import monotonic + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.contracts import ( + ManagedContractError, + ManagedHandoffPhaseV1, + ManagedServiceKeyV1, + ManagedStopEvidenceV1, +) +from loushang.apphost.managed.discovery import ( + ManagedDiscoveryV1, + ManagedMuxObservationV1, +) +from loushang.apphost.managed.paths import resolve_managed_registry_root +from loushang.apphost.managed.registry import ManagedMuxReservationV1, ManagedRegistryV1 +from loushang.hosting.service import LinuxServiceObserverV1 +from tests.apphost.test_managed_connection import committed +from tests.apphost.test_managed_starter import owners as owners + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed discovery") + + +@pytest.fixture +def discovery(owners): + namespace = owners[1] + registry = ManagedRegistryV1(Path(resolve_managed_registry_root(namespace)), namespace) + try: + yield ManagedDiscoveryV1(registry, namespace), registry + finally: + registry.close() + + +def test_global_lookup_from_other_cwd_is_readonly_and_contains_no_native_facts(owners, discovery, tmp_path, monkeypatch): + state = committed(owners) + reader, registry = discovery + other = tmp_path / "other" + other.mkdir() + root = Path(resolve_managed_registry_root(owners[1])) + def snapshot(): + result = {} + for path in (root, *root.rglob("*")): + info = path.lstat() + result[path.relative_to(root)] = ( + info.st_dev, info.st_ino, info.st_mode, info.st_mtime_ns, + path.read_bytes() if path.is_file() else None, + ) + return result + + before = snapshot() + monkeypatch.chdir(other) + + def forbidden(*args, **kwargs): + raise AssertionError("discovery must not perform a native liveness check") + + monkeypatch.setattr(LinuxServiceObserverV1, "reopen", forbidden) + result = reader.resolve("main", deadline=monotonic() + 5) + assert result.instance == state.handoff.instance + assert result.recorded_phase is ManagedHandoffPhaseV1.COMMITTED + assert result.service.workspace == str(tmp_path) + assert not result.stop_requested and not result.cleanly_stopped + assert result.reservation == registry.resolve("main") + assert reader.list_muxes(deadline=monotonic() + 5) == (result,) + assert reader.resolve("missing", deadline=monotonic() + 5) is None + assert set(asdict(result)) == { + "reservation", "instance", "revision", "recorded_phase", "stop_requested", "cleanly_stopped", + } + assert not any(hasattr(result, name) for name in ("pid", "native_identity", "endpoint", "record_path", "argv", "key")) + with pytest.raises(FrozenInstanceError): + result.revision = 99 + assert before == snapshot() + assert owners[0].read() == state + + +def test_reservation_without_instance_does_not_invent_ready_or_start(owners, discovery): + reader, registry = discovery + result = reader.resolve("main", deadline=monotonic() + 5) + assert result.reservation == registry.resolve("main") + assert result.instance is result.revision is result.recorded_phase is None + assert not result.stop_requested and not result.cleanly_stopped + assert owners[0].read() is None + + +def test_service_snapshot_survives_mux_name_release_without_native_authority(owners, discovery): + reader, registry = discovery + service = owners[2] + initial = reader.inspect_service(service.service_id, deadline=monotonic() + 5) + assert initial.service == service and initial.instance is None + state = committed(owners) + observed = reader.inspect_service(service.service_id, deadline=monotonic() + 5) + assert observed.instance == state.handoff.instance and observed.recorded_phase is ManagedHandoffPhaseV1.COMMITTED + assert not hasattr(observed, "native_identity") and not hasattr(observed, "pid") + assert reader.inspect_service("f" * 64, deadline=monotonic() + 5) is None + # Storage fixture only: read by service must not require the mux join. + with registry._database.transaction(write=True) as connection: + connection.execute("DELETE FROM muxes WHERE service_id=?", (service.service_id,)) + assert reader.list_muxes(deadline=monotonic() + 5) == () + assert reader.inspect_service(service.service_id, deadline=monotonic() + 5) == observed + + +def test_durable_start_stop_observations_do_not_retarget_prior_reference(owners, discovery): + journal = owners[0] + reader, _ = discovery + state = journal.prepare("c" * 32, expected=None) + first = reader.resolve("main", deadline=monotonic() + 5) + assert first.recorded_phase is ManagedHandoffPhaseV1.PROVISIONAL + assert first.instance == state.handoff.instance + journal.abort(state.handoff.instance, "c" * 32) + second = reader.resolve("main", deadline=monotonic() + 5) + assert second.recorded_phase is ManagedHandoffPhaseV1.ABORTING + assert second.instance == first.instance and second.revision > first.revision + assert not second.cleanly_stopped + assert first.recorded_phase is ManagedHandoffPhaseV1.PROVISIONAL + + +def test_committed_stop_flag_is_visible_without_claiming_exit(owners, discovery): + state = committed(owners) + owners[0].request_stop(state.handoff.instance) + result = discovery[0].resolve("main", deadline=monotonic() + 5) + assert result.recorded_phase is ManagedHandoffPhaseV1.COMMITTED and result.stop_requested + assert not result.cleanly_stopped + + +def test_fully_settled_record_and_new_generation_keep_old_reference_frozen(owners, discovery): + journal = owners[0] + reader, _ = discovery + # Pure durable state transition: no actual service is launched in this test. + first = journal.prepare("c" * 32, expected=None) + journal.abort(first.handoff.instance, "c" * 32) + settled = journal.record_stop_evidence(ManagedStopEvidenceV1(first.handoff.instance, True, True, True)) + old = reader.resolve("main", deadline=monotonic() + 5) + assert old.cleanly_stopped and old.instance == first.handoff.instance + second = journal.prepare("d" * 32, expected=settled) + new = reader.resolve("main", deadline=monotonic() + 5) + assert new.instance == second.handoff.instance != old.instance + assert new.revision > old.revision and new.recorded_phase is ManagedHandoffPhaseV1.PROVISIONAL + assert not new.cleanly_stopped and not new.stop_requested + assert old.cleanly_stopped and old.instance == first.handoff.instance + + +def test_default_page_really_stops_at_64_rows(discovery, owners): + reader, registry = discovery + for index in range(64): + registry.reserve_mux(ManagedMuxReservationV1(f"mux-{index:02}", owners[2], f"{index:032x}")) + first = reader.list_muxes(deadline=monotonic() + 5) + assert len(first) == 64 and first[0].name == "main" and first[-1].name == "mux-62" + second = reader.list_muxes(deadline=monotonic() + 5, after=first[-1].name) + assert [item.name for item in second] == ["mux-63"] + + +def test_bounded_pagination_includes_other_workspaces_and_products(owners, discovery): + reader, registry = discovery + for index, (name, product, workspace) in enumerate(( + ("Alpha", "work", "/other"), ("alpha", "coding", "/third"), ("zzz", "design", "/fourth"), + )): + registry.reserve_mux(ManagedMuxReservationV1(name, ManagedServiceKeyV1(product, workspace), f"{index:032x}")) + first = reader.list_muxes(deadline=monotonic() + 5, limit=2) + second = reader.list_muxes(deadline=monotonic() + 5, after=first[-1].name, limit=2) + assert [r.name for r in first + second] == ["Alpha", "alpha", "main", "zzz"] + assert first[0].service.product_id == "work" + assert reader.list_muxes(deadline=monotonic() + 5, after="zzz") == () + for limit in (0, 65, True, "2"): + with pytest.raises(ManagedContractError): + reader.list_muxes(deadline=monotonic() + 5, limit=limit) + for name in ("../main", "", "a\x1bb"): + with pytest.raises(ManagedContractError): + reader.resolve(name, deadline=monotonic() + 5) + with pytest.raises(ManagedContractError): + reader.list_muxes(deadline=monotonic() + 5, after=name) + + +@pytest.mark.parametrize("change", [ + "phase='invalid'", "stop_requested=2", "native_identity='{}'", "instance_id='invalid'", + "native_identity=NULL", "application_cleanup_completed=1", +]) +def test_corrupt_state_is_not_partially_projected(owners, discovery, change): + committed(owners) + reader, registry = discovery + # Deliberate on-disk corruption bypasses the writer's deferred paired FK. + with sqlite3.connect(registry._database._directory._root / "registry.sqlite3") as connection: + connection.execute("UPDATE instances SET " + change) + for operation in (lambda: reader.resolve("main", deadline=monotonic() + 5), + lambda: reader.list_muxes(deadline=monotonic() + 5), owners[0].read): + with pytest.raises(ManagedStorageError, match="invalid_record"): + operation() + + +def test_expired_deadline_and_closed_registry_are_not_empty_results(owners, discovery): + reader, registry = discovery + for operation in (lambda: reader.resolve("main", deadline=monotonic() - 1), + lambda: reader.list_muxes(deadline=monotonic() - 1)): + with pytest.raises(ManagedStorageError, match="busy"): + operation() + registry.close() + with pytest.raises(ManagedStorageError, match="closed"): + reader.resolve("main", deadline=monotonic() + 5) + + +def test_discovery_requires_same_namespace_user_and_canonical_registry(owners, discovery, tmp_path): + namespace = owners[1] + for other in (replace(namespace, user_id=os.geteuid() + 1), replace(namespace, machine_id="d" * 32)): + with pytest.raises(ManagedContractError): + ManagedDiscoveryV1(discovery[1], other) + other_registry = ManagedRegistryV1(tmp_path / "other-registry", namespace, create=True) + try: + with pytest.raises(ManagedContractError): + ManagedDiscoveryV1(other_registry, namespace) + finally: + other_registry.close() + + +def test_missing_registry_is_not_initialized_by_discovery(tmp_path, owners): + namespace = replace(owners[1], platform_home=str(tmp_path / "missing-home")) + root = Path(resolve_managed_registry_root(namespace)) + registry = ManagedRegistryV1(root, namespace, defer_open=True) + try: + reader = ManagedDiscoveryV1(registry, namespace) + with pytest.raises(ManagedStorageError): + reader.resolve("main", deadline=monotonic() + 5) + assert not root.exists() and not Path(namespace.platform_home).exists() + finally: + registry.close() + + +def test_observation_rejects_incoherent_values(owners, discovery): + value = discovery[0].resolve("main", deadline=monotonic() + 5) + with pytest.raises(ManagedContractError): + replace(value, revision=1) + with pytest.raises(ManagedContractError): + replace(value, stop_requested=True) + state = committed(owners) + value = discovery[0].resolve("main", deadline=monotonic() + 5) + for invalid in (replace(state.handoff.instance, service_id="f" * 64), None): + with pytest.raises(ManagedContractError): + replace(value, instance=invalid) + with pytest.raises(ManagedContractError): + replace(value, cleanly_stopped=True) + assert isinstance(value, ManagedMuxObservationV1) diff --git a/tests/apphost/test_managed_discovery_snapshot.py b/tests/apphost/test_managed_discovery_snapshot.py new file mode 100644 index 000000000..3925846e5 --- /dev/null +++ b/tests/apphost/test_managed_discovery_snapshot.py @@ -0,0 +1,122 @@ +import sqlite3 +from contextlib import contextmanager +from dataclasses import FrozenInstanceError, replace +from time import monotonic + +import pytest + +from loushang.apphost.managed import discovery as module +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.contracts import ManagedContractError, ManagedServiceKeyV1 +from tests.apphost.test_managed_connection import committed +from tests.apphost.test_managed_namespace_admission import tree + +from .test_managed_discovery import discovery as discovery +from .test_managed_discovery import owners as owners +from .test_managed_discovery import pytestmark as pytestmark + + +def test_snapshot_includes_unnamed_services_and_is_readonly(owners, discovery): + reader, registry = discovery + state = committed(owners) + extra = ManagedServiceKeyV1("coding", "/another-workspace") + with registry._database.transaction(write=True) as connection: + connection.execute("INSERT INTO services VALUES (?,?,?,?)", + (extra.service_id, extra.product_id, extra.workspace, extra.profile)) + before = tree(registry._database._directory._root) + result = reader.snapshot_namespace(deadline=monotonic() + 5) + assert len(result.services) == 2 and len(result.muxes) == 1 + assert next(item for item in result.services if item.service == extra).instance is None + assert result.muxes[0].instance == state.handoff.instance + assert tree(registry._database._directory._root) == before + with pytest.raises(FrozenInstanceError): + result.services = () + with pytest.raises(ManagedContractError): + replace(result, services=()) + with pytest.raises(ManagedContractError): + replace(result, muxes=result.muxes * 2) + with registry._database.transaction(write=True) as connection: + connection.execute("DELETE FROM muxes") + assert len(result.muxes) == 1 + assert reader.snapshot_namespace(deadline=monotonic() + 5).muxes == () + + +@pytest.mark.parametrize("limit", ["MAX_SERVICES", "MAX_MUXES"]) +def test_snapshot_limits_reject_instead_of_truncate(discovery, monkeypatch, limit): + from loushang.apphost.managed.registry import ManagedMuxReservationV1 + + reader, registry = discovery + monkeypatch.setattr(module, limit, 1) + initial = reader.snapshot_namespace(deadline=monotonic() + 5) + assert len(initial.services) == len(initial.muxes) == 1 + if limit == "MAX_SERVICES": + extra = ManagedServiceKeyV1("coding", "/second-workspace") + with registry._database.transaction(write=True) as connection: + connection.execute("INSERT INTO services VALUES (?,?,?,?)", + (extra.service_id, extra.product_id, extra.workspace, extra.profile)) + else: + registry.reserve_mux(ManagedMuxReservationV1("second", initial.services[0].service, "e" * 32)) + with pytest.raises(ManagedStorageError, match="capacity"): + reader.snapshot_namespace(deadline=monotonic() + 5) + + +def test_snapshot_uses_one_read_transaction_and_excludes_concurrent_change(discovery, monkeypatch): + reader, registry = discovery + original = registry._database.transaction + calls = [] + attempts = [] + database = registry._database._directory._root / "registry.sqlite3" + + @contextmanager + def transaction(**kwargs): + calls.append(kwargs) + with original(**kwargs) as connection: + class Proxy: + def execute(self, sql, parameters): + cursor = connection.execute(sql, parameters) + if "ORDER BY m.name" in sql: + with sqlite3.connect(database, timeout=0) as other: + with pytest.raises(sqlite3.OperationalError, match="locked"): + other.execute("DELETE FROM muxes") + other.commit() + other.rollback() + attempts.append(True) + return cursor + yield Proxy() + + monkeypatch.setattr(registry._database, "transaction", transaction) + result = reader.snapshot_namespace(deadline=monotonic() + 5) + assert len(calls) == 1 and not calls[0].get("write", False) + assert attempts == [True] and len(result.muxes) == 1 + + +def test_snapshot_rejects_corruption_and_closed_owner(discovery): + reader, registry = discovery + with registry._database.transaction(write=True) as connection: + connection.execute("UPDATE services SET workspace='/tampered'") + with pytest.raises(ManagedStorageError, match="invalid_record"): + reader.snapshot_namespace(deadline=monotonic() + 5) + registry.close() + with pytest.raises(ManagedStorageError, match="closed"): + reader.snapshot_namespace(deadline=monotonic() + 5) + + +def test_snapshot_rechecks_deadline_after_transaction_exit(discovery, monkeypatch): + from loushang.apphost.managed import _files + + reader, registry = discovery + original = registry._database.transaction + expired = False + + @contextmanager + def transaction(**kwargs): + nonlocal expired + with original(**kwargs) as connection: + yield connection + expired = True + + monkeypatch.setattr(registry._database, "transaction", transaction) + monkeypatch.setattr(_files, "monotonic", lambda: 11.0 if expired else 1.0) + with pytest.raises(ManagedStorageError, match="busy"): + reader.snapshot_namespace(deadline=10.0) + assert expired diff --git a/tests/apphost/test_managed_files.py b/tests/apphost/test_managed_files.py index d209ae39a..f9ddfee0e 100644 --- a/tests/apphost/test_managed_files.py +++ b/tests/apphost/test_managed_files.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import selectors import subprocess import sys from pathlib import Path @@ -32,6 +33,242 @@ def test_lookup_missing_root_is_read_only(tmp_path): assert tuple(tmp_path.iterdir()) == () +@pytest.mark.parametrize("parents", [False, True]) +def test_exclusive_directory_creation_never_adopts_existing_leaf(tmp_path, parents): + root = tmp_path / "new" + first = PrivateManagedDirectory(root, create=True, create_parents=parents, exclusive_create=True) + first.close() + before = root.stat() + contender = PrivateManagedDirectory(root, create=True, create_parents=parents, + exclusive_create=True, defer_open=True) + try: + with pytest.raises(ManagedStorageError, match="conflict"): + contender.open() + assert not contender._opened + with pytest.raises(ManagedStorageError, match="closed"): + contender.open() + finally: + contender.close() + assert root.stat() == before and not tuple(root.iterdir()) + + +@pytest.mark.parametrize("value", [True, 1, "yes"]) +def test_exclusive_creation_requires_explicit_create_without_io(tmp_path, value): + with pytest.raises(ManagedStorageError): + PrivateManagedDirectory(tmp_path / "new", exclusive_create=value) + assert not tuple(tmp_path.iterdir()) + + +def test_exclusive_lock_creation_preserves_existing_lock(directory): + root, owner = directory + with owner.lock("first.lock", create=True, exclusive_create=True): + original = (root / "first.lock").stat() + with owner.lock("first.lock"): + assert (root / "first.lock").stat() == original + with pytest.raises(ManagedStorageError, match="conflict"): + with owner.lock("first.lock", create=True, exclusive_create=True): + pytest.fail("existing lock was adopted") + assert (root / "first.lock").stat() == original + assert owner.cleanup_pending + owner.close() + assert not owner.cleanup_pending and (root / "first.lock").exists() + + +@pytest.mark.parametrize("kind", ["directory", "lock"]) +def test_exclusive_creation_lost_mkdir_or_open_receipt_never_replays(tmp_path, monkeypatch, kind): + root = tmp_path / "private" + owner = PrivateManagedDirectory(root, create=True, exclusive_create=True, defer_open=True) + if kind == "lock": + owner.open() + native_call = os.mkdir if kind == "directory" else os.open + calls = [] + + def lose_receipt(name, *args, **kwargs): + result = native_call(name, *args, **kwargs) + if name == (root.name if kind == "directory" else "first.lock"): + calls.append(name) + if kind == "lock": + # Model a syscall wrapper that settles its descriptor but + # loses publication of the successful file creation. + os.close(result) + raise OSError("lost creation receipt") + return result + + try: + with monkeypatch.context() as patch: + patch.setattr(os, "mkdir" if kind == "directory" else "open", lose_receipt) + with pytest.raises(ManagedStorageError, match="unavailable"): + if kind == "directory": + owner.open() + else: + with owner.lock("first.lock", create=True, exclusive_create=True): + pytest.fail("lost receipt was delivered") + assert owner.cleanup_pending + target = root if kind == "directory" else root / "first.lock" + original = target.stat() + owner.close() + assert not owner.cleanup_pending and target.stat() == original + assert calls == [target.name] + finally: + owner.close() + + +@pytest.mark.parametrize("kind", ["directory", "lock"]) +def test_exclusive_creation_failed_sync_retains_original_parent(tmp_path, monkeypatch, kind): + root = tmp_path / "private" + owner = PrivateManagedDirectory(root, create=True, exclusive_create=True, defer_open=True) + if kind == "lock": + owner.open() + fsync = os.fsync + retained = [] + + def fail(fd): + retained.append(fd) + raise OSError("sync unavailable") + + try: + with monkeypatch.context() as patch: + patch.setattr(os, "fsync", fail) + with pytest.raises(ManagedStorageError, match="unavailable"): + if kind == "directory": + owner.open() + else: + with owner.lock("first.lock", create=True, exclusive_create=True): + pytest.fail("unsynced lock delivered") + assert owner.cleanup_pending + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.close() + assert retained[0] == retained[-1] + synced = [] + + def sync(fd): + synced.append(fd) + return fsync(fd) + + with monkeypatch.context() as patch: + patch.setattr(os, "fsync", sync) + owner.close() + assert synced == [retained[0]] and not owner.cleanup_pending + assert root.exists() + if kind == "lock": + assert (root / "first.lock").exists() + finally: + owner.close() + + +@pytest.mark.parametrize("kind", ["directory", "lock"]) +def test_two_real_processes_cannot_both_claim_exclusive_creation(tmp_path, kind): + root = tmp_path / "private" + if kind == "lock": + root.mkdir(mode=0o700) + script = """ +import os +import sys +from pathlib import Path +from loushang.apphost.managed._files import PrivateManagedDirectory, ManagedStorageError +root, kind, gate = Path(sys.argv[1]), sys.argv[2], int(sys.argv[3]) +owner = PrivateManagedDirectory(root, create=kind == 'directory', + exclusive_create=kind == 'directory', defer_open=True) +print('ready', flush=True) +try: + assert os.read(gate, 1) == b'x' + os.close(gate) + owner.open() + if kind == 'lock': + with owner.lock('first.lock', create=True, exclusive_create=True): + pass + print('created', flush=True) +except ManagedStorageError as error: + print(error.code, flush=True) +finally: + owner.close() +""" + env = dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[2] / "src")) + read_gate, write_gate = os.pipe() + children = [] + try: + for _ in range(2): + children.append(subprocess.Popen( + [sys.executable, "-c", script, str(root), kind, str(read_gate)], + env=env, pass_fds=(read_gate,), stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, + )) + for child in children: + with selectors.DefaultSelector() as selector: + selector.register(child.stdout, selectors.EVENT_READ) + assert selector.select(timeout=10), "child did not reach creation gate" + assert child.stdout.readline().strip() == "ready" + assert os.write(write_gate, b"xx") == 2 + results = [] + for child in children: + stdout, stderr = child.communicate(timeout=10) + assert child.returncode == 0, stderr + results.append(stdout.strip()) + assert sorted(results) == ["conflict", "created"] + target = root if kind == "directory" else root / "first.lock" + assert target.exists() + # Even after both contenders exit, a new exclusive claimant cannot + # adopt the durable object; ordinary reopening remains compatible. + if kind == "directory": + with pytest.raises(ManagedStorageError, match="conflict"): + PrivateManagedDirectory(root, create=True, exclusive_create=True) + reopened = PrivateManagedDirectory(root) + try: + if kind == "lock": + with reopened.lock("first.lock"): + pass + with pytest.raises(ManagedStorageError, match="conflict"): + with reopened.lock("first.lock", create=True, exclusive_create=True): + pytest.fail("post-exit exclusive claimant adopted lock") + finally: + reopened.close() + finally: + os.close(read_gate) + os.close(write_gate) + for child in children: + if child.poll() is None: + child.kill() + child.communicate(timeout=10) + + +@pytest.mark.parametrize("kind", ["directory", "lock"]) +def test_exclusive_creation_rechecks_identity_after_sync(tmp_path, monkeypatch, kind): + root = tmp_path / "private" + owner = PrivateManagedDirectory(root, create=True, exclusive_create=True, defer_open=True) + if kind == "lock": + owner.open() + target = root if kind == "directory" else root / "first.lock" + moved = target.with_name("displaced") + fsync = os.fsync + swapped = False + + def replace_after_sync(fd): + nonlocal swapped + fsync(fd) + if not swapped: + swapped = True + target.rename(moved) + if kind == "directory": + target.mkdir(mode=0o700) + else: + target.touch(mode=0o600) + + try: + with monkeypatch.context() as patch: + patch.setattr(os, "fsync", replace_after_sync) + with pytest.raises(ManagedStorageError, match="conflict"): + if kind == "directory": + owner.open() + else: + with owner.lock("first.lock", create=True, exclusive_create=True): + pytest.fail("replacement lock was delivered") + assert swapped + finally: + owner.close() + assert target.exists() and moved.exists() + assert target.stat().st_ino != moved.stat().st_ino + + def test_private_modes_bounded_cas_and_stable_lock(directory): root, owner = directory assert root.stat().st_mode & 0o777 == 0o700 @@ -290,8 +527,11 @@ def fail_stage(fd): with pytest.raises(ManagedStorageError, match="^managed_storage_unavailable$"): owner.write("record", b"new", expected=before) assert (root / "record").read_bytes() == (b"new" if stage == 3 else b"original") - assert not owner.cleanup_pending + assert owner.cleanup_pending is (stage == 3) assert sorted(p.name for p in root.iterdir()) == ["control.lock", "record"] + owner.close() + assert not owner.cleanup_pending + assert (root / "record").read_bytes() == (b"new" if stage == 3 else b"original") def test_replace_failure_cleans_temporary_and_preserves_record(directory, monkeypatch): @@ -311,8 +551,9 @@ def fail_replace(*args, **kwargs): assert sorted(p.name for p in root.iterdir()) == ["control.lock", "record"] -def test_cancel_and_close_failure_preserve_cancel_and_attempt_unlink(directory, monkeypatch): - root, owner = directory +def test_cancel_and_close_failure_preserve_cancel_and_attempt_unlink(tmp_path, monkeypatch): + root = tmp_path / "private" + owner = PrivateManagedDirectory(root, create=True) with owner.lock("control.lock", create=True): original_close = os.close temporary_fd = None @@ -335,8 +576,11 @@ def close_then_fail(fd): owner.write("record", b"new", expected=None) assert caught.value is cancellation assert "managed_record_cleanup_incomplete" in caught.value.__notes__ - assert not owner.cleanup_pending + assert owner.cleanup_pending and not owner._pending assert sorted(p.name for p in root.iterdir()) == ["control.lock"] + for _ in range(2): + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.close() def test_failed_cleanup_retains_owned_debt_until_close(directory, monkeypatch): diff --git a/tests/apphost/test_managed_invocation.py b/tests/apphost/test_managed_invocation.py new file mode 100644 index 000000000..1d641c231 --- /dev/null +++ b/tests/apphost/test_managed_invocation.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import json +from dataclasses import FrozenInstanceError, replace +from pathlib import Path + +import pytest + +from loushang.apphost.managed.contracts import ( + ManagedContractError, + ManagedInstanceRefV1, + ManagedNamespaceV1, + ManagedServiceKeyV1, +) +from loushang.apphost.managed.invocation import ManagedChildInvocationV1 + + +def invocation(): + namespace = ManagedNamespaceV1("/private/平台", 1000, "a" * 32) + service = ManagedServiceKeyV1("coding", "/工作区") + return ManagedChildInvocationV1( + namespace, service, + ManagedInstanceRefV1(namespace.namespace_key, service.service_id, "b" * 32), + "c" * 32, "/private/runtime", + ) + + +@pytest.mark.parametrize("scratch", [None, "/private/scratch"]) +def test_trace_window_uses_v3_and_preserves_disabled_wire_bytes(scratch): + original = replace(invocation(), temporary_override=scratch) + before = original.to_json() + traced = replace(original, trace_deadline_ms=123456789) + record = json.loads(traced.to_json()) + assert record["version"] == "loushang.managed-child/v3" + assert record["traceDeadlineMs"] == 123456789 + assert ManagedChildInvocationV1.from_json(traced.to_json()) == traced + assert replace(traced, trace_deadline_ms=None).to_json() == before + for old_version in ("loushang.managed-child/v1", "loushang.managed-child/v2"): + with pytest.raises(ManagedContractError): + ManagedChildInvocationV1.from_json(json.dumps({**record, "version": old_version})) + del record["traceDeadlineMs"] + with pytest.raises(ManagedContractError): + ManagedChildInvocationV1.from_json(json.dumps(record)) + + +@pytest.mark.parametrize("deadline", [True, 0, -1, 10**15 + 1, 1.5, "100", None]) +def test_trace_wire_rejects_invalid_deadlines(deadline): + record = json.loads(invocation().to_json()) + record.update(version="loushang.managed-child/v3", traceDeadlineMs=deadline) + with pytest.raises(ManagedContractError): + ManagedChildInvocationV1.from_json(json.dumps(record)) + + +def test_roundtrip_is_pure_immutable_and_does_not_echo_paths(monkeypatch): + def forbidden(*args, **kwargs): + raise AssertionError("invocation must not admit native resources") + + monkeypatch.setattr(Path, "resolve", forbidden) + value = invocation() + payload = value.to_json() + assert ManagedChildInvocationV1.from_json(payload) == value + assert ManagedChildInvocationV1.from_json(payload).to_json() == payload + assert "平台" in payload + assert "/private" not in repr(value) and "/工作区" not in repr(value) + with pytest.raises(FrozenInstanceError): + value.attempt_id = "d" * 32 + + +@pytest.mark.parametrize("key,value", [ + ("version", "loushang.managed-child/v2"), ("userId", True), + ("userId", 1.0), ("userId", -1), ("userId", 2**32), + ("workspace", "/a/../b"), ("platformHome", "/"), + ("instanceId", "x"), ("attemptId", "c" * 31), + ("runtimeRoot", "/private/平台/lmux"), ("profile", "local/v1"), + ("productId", None), ("machineId", []), ("workspace", "/x\ud800"), + ("factory", "attacker.module:factory"), ("fd", 4), ("key", "secret"), + ("namespaceKey", "0" * 64), ("serviceId", "0" * 64), +]) +def test_closed_fields_and_value_contracts(key, value): + record = json.loads(invocation().to_json()) + record[key] = value + with pytest.raises(ManagedContractError, match="^invalid_managed_contract$"): + ManagedChildInvocationV1.from_json(json.dumps(record)) + + +def test_every_field_is_required_and_duplicates_never_overwrite(): + record = json.loads(invocation().to_json()) + for key, value in record.items(): + missing = dict(record) + del missing[key] + with pytest.raises(ManagedContractError): + ManagedChildInvocationV1.from_json(json.dumps(missing)) + duplicate = json.dumps(record)[:-1] + "," + json.dumps(key) + ":" + json.dumps(value) + "}" + with pytest.raises(ManagedContractError): + ManagedChildInvocationV1.from_json(duplicate) + + +@pytest.mark.parametrize("payload", [ + None, b"{}", "", "null", "true", "[]", "{}", "{", "\ud800", + "[" * 2000 + "]" * 2000, " " * 65537, "\"" + "界" * 24000 + "\"", + '{"userId":NaN}', '{"userId":Infinity}', '{"userId":-Infinity}', +]) +def test_malformed_oversized_or_nonobject_input_is_bounded(payload): + with pytest.raises(ManagedContractError, match="^invalid_managed_contract$"): + ManagedChildInvocationV1.from_json(payload) + + +def test_byte_limit_is_inclusive_before_json_decode(): + payload = invocation().to_json() + padded = payload + " " * (65536 - len(payload.encode("utf-8"))) + assert ManagedChildInvocationV1.from_json(padded) == invocation() + with pytest.raises(ManagedContractError): + ManagedChildInvocationV1.from_json(padded + " ") + + +@pytest.mark.parametrize("scratch", [False, True]) +def test_all_maximum_unicode_paths_fit_wire_budget(scratch): + namespace = ManagedNamespaceV1("/" + "\U0001f600" * 4095, 2**32 - 1, "a" * 32) + service = ManagedServiceKeyV1("x" * 128, "/" + "\U0001f601" * 4095) + value = ManagedChildInvocationV1(namespace, service, ManagedInstanceRefV1( + namespace.namespace_key, service.service_id, "b" * 32, + ), "c" * 32, "/" + "\U0001f602" * 4095) + if scratch: + value = replace(value, temporary_override="/" + "\U0001f603" * 4095) + assert 65536 < len(value.to_json().encode("utf-8")) <= 96 * 1024 + assert ManagedChildInvocationV1.from_json(value.to_json()) == value + + +def test_constructor_requires_exact_namespace_service_and_instance_binding(): + value = invocation() + for change in ( + {"instance": replace(value.instance, namespace_key="0" * 64)}, + {"instance": replace(value.instance, service_id="0" * 64)}, + {"namespace": None}, {"service": None}, {"instance": None}, + {"attempt_id": False}, {"runtime_root": "relative"}, + ): + with pytest.raises(ManagedContractError): + replace(value, **change) + + +def test_explicit_scratch_uses_closed_v2_without_changing_v1(): + original = invocation() + assert json.loads(original.to_json())["version"] == "loushang.managed-child/v1" + value = replace(original, temporary_override="/private/scratch") + record = json.loads(value.to_json()) + assert record["version"] == "loushang.managed-child/v2" + assert record["temporaryRoot"] == "/private/scratch" + assert ManagedChildInvocationV1.from_json(value.to_json()) == value + for change in ({"version": "loushang.managed-child/v1"}, {"temporaryRoot": None}, + {"temporaryRoot": "relative"}, {"temporaryRoot": "/private/平台/state"}): + with pytest.raises(ManagedContractError): + ManagedChildInvocationV1.from_json(json.dumps({**record, **change})) + del record["temporaryRoot"] + with pytest.raises(ManagedContractError): + ManagedChildInvocationV1.from_json(json.dumps(record)) diff --git a/tests/apphost/test_managed_layout.py b/tests/apphost/test_managed_layout.py new file mode 100644 index 000000000..8cc3b1e76 --- /dev/null +++ b/tests/apphost/test_managed_layout.py @@ -0,0 +1,344 @@ +from __future__ import annotations + +import os +import stat +import subprocess +import sys +from time import monotonic + +import pytest + +from loushang.apphost.managed import _files as native +from loushang.apphost.managed import layout +from loushang.apphost.managed._files import ManagedStorageError, PrivateManagedDirectory +from loushang.apphost.managed.contracts import ( + ManagedInstanceRefV1, + ManagedNamespaceV1, + ManagedServiceKeyV1, +) +from loushang.apphost.managed.layout import ManagedLayoutPreparationV1 + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed initialization") + + +def preparation(root, **kwargs): + namespace = ManagedNamespaceV1(str(root / "platform"), os.geteuid(), "a" * 32) + service = ManagedServiceKeyV1("coding", str(root)) + instance = ManagedInstanceRefV1(namespace.namespace_key, service.service_id, "b" * 32) + return ManagedLayoutPreparationV1(namespace, service, instance, runtime_root=str(root / "runtime"), **kwargs) + + +@pytest.mark.parametrize("mask", [0o002, 0o022, 0o077]) +def test_fresh_layout_is_pure_until_open_and_creates_private_ancestors(tmp_path, mask): + owner = preparation(tmp_path) + assert not tuple(tmp_path.iterdir()) + assert len(owner._directories) == 9 and not owner.initialized + old_mask = os.umask(mask) + try: + owner.open(deadline=monotonic() + 5) + assert owner.initialized + assert not (tmp_path / "platform/data/sessions").exists() + before = {path: (path.stat().st_ino, stat.S_IMODE(path.stat().st_mode)) for path in tmp_path.rglob("*")} + assert before and all(mode == 0o700 for _, mode in before.values()) + finally: + os.umask(old_mask) + owner.close() + assert not owner.cleanup_pending and not owner.initialized + assert {path: (path.stat().st_ino, stat.S_IMODE(path.stat().st_mode)) for path in before} == before + + +def test_parent_creation_is_explicit_and_existing_permissions_are_not_changed(tmp_path): + root = tmp_path / "ancestor" / "leaf" + with pytest.raises(ManagedStorageError): + PrivateManagedDirectory(root, create=True) + assert not root.parent.exists() + with pytest.raises(ManagedStorageError): + PrivateManagedDirectory(root, create_parents=True) + assert not root.parent.exists() + root.parent.mkdir(mode=0o755) + root.parent.chmod(0o755) + before = root.parent.stat() + owner = PrivateManagedDirectory(root, create=True, create_parents=True) + owner.close() + assert root.parent.stat().st_mode == before.st_mode + assert root.parent.stat().st_ino == before.st_ino + + +@pytest.mark.parametrize("kind", ["symlink", "file", "writable"]) +def test_bad_parent_never_creates_descendants(tmp_path, kind): + parent = tmp_path / "bad" + outside = tmp_path / "outside" + outside.mkdir(mode=0o700) + if kind == "symlink": + parent.symlink_to(outside, target_is_directory=True) + elif kind == "file": + parent.write_bytes(b"unchanged") + else: + parent.mkdir(mode=0o700) + parent.chmod(0o777) + owner = PrivateManagedDirectory(parent / "more/leaf", create=True, create_parents=True, defer_open=True) + try: + with pytest.raises(ManagedStorageError): + owner.open() + finally: + owner.close() + assert not tuple(outside.iterdir()) + if kind == "writable": + assert parent.stat().st_mode & 0o777 == 0o777 + assert not tuple(parent.iterdir()) + + +def test_mkdir_sync_debt_retains_original_parent_and_new_owner_syncs_eexist(tmp_path, monkeypatch): + target = tmp_path / "first" / "second" / "leaf" + owner = PrivateManagedDirectory(target, create=True, create_parents=True, defer_open=True) + fsync = native.os.fsync + parent_identity = (tmp_path.stat().st_dev, tmp_path.stat().st_ino) + synced = [] + + def observe(fd): + info = os.fstat(fd) + if (info.st_dev, info.st_ino) == parent_identity: + synced.append(fd) + return fsync(fd) + + def fail(fd): + info = os.fstat(fd) + if (info.st_dev, info.st_ino) == parent_identity and target.parents[1].exists(): + raise OSError("parent sync unavailable") + return fsync(fd) + + with monkeypatch.context() as patch: + patch.setattr(native.os, "fsync", fail) + with pytest.raises(ManagedStorageError): + owner.open() + retained, = owner._sync_pending + assert owner.cleanup_pending and os.fstat(retained).st_ino == parent_identity[1] + with pytest.raises(ManagedStorageError): + owner.close() + assert owner._sync_pending == {retained} + with monkeypatch.context() as patch: + patch.setattr(native.os, "fsync", observe) + other = PrivateManagedDirectory(target, create=True, create_parents=True) + assert synced, "EEXIST did not bypass the parent's durability check" + other.close() + synced.clear() + owner.close() + assert synced == [retained] + assert not owner.cleanup_pending and target.is_dir() + + +def test_layout_partial_failure_closes_all_original_owners_and_retries_only_debt(tmp_path, monkeypatch): + owner = preparation(tmp_path) + directories = owner._directories + open_third, close_first = directories[2].open, directories[0].close + calls = [] + + def fail_open(**kwargs): + raise ManagedStorageError("unavailable") + + def fail_close(): + calls.append("failed") + raise ManagedStorageError("unavailable") + + with monkeypatch.context() as patch: + patch.setattr(directories[2], "open", fail_open) + with pytest.raises(ManagedStorageError): + owner.open(deadline=monotonic() + 5) + assert not owner.initialized and owner._directories == directories + patch.setattr(directories[0], "close", fail_close) + with pytest.raises(ManagedStorageError): + owner.close() + assert owner._closed == set(range(1, 9)) and owner.cleanup_pending + assert directories[2].open == open_third and directories[0].close == close_first + owner.close() + assert not owner.cleanup_pending and calls == ["failed"] + with pytest.raises(ManagedStorageError, match="closed"): + owner.open(deadline=monotonic() + 5) + + +def test_layout_capacity_and_expired_deadline_do_no_native_io(tmp_path, monkeypatch): + def forbidden(*args, **kwargs): + raise AssertionError("preflight performed IO") + + with monkeypatch.context() as patch: + patch.setattr(native.os, "open", forbidden) + with pytest.raises(ManagedStorageError, match="capacity"): + preparation(tmp_path.joinpath(*(["deep"] * 64))) + owner = preparation(tmp_path) + with pytest.raises(ManagedStorageError, match="busy"): + owner.open(deadline=monotonic() - 1) + owner.close() + assert not tuple(tmp_path.iterdir()) + + +def test_layout_deadline_expiring_during_final_check_cannot_report_initialized(tmp_path, monkeypatch): + owner = preparation(tmp_path) + clock = [100.0] + final = owner._directories[-1] + original_check = final._check + checks = 0 + + def expire_on_layout_check(): + nonlocal checks + result = original_check() + checks += 1 + # Two checks in the leaf open, then the final layout-wide check. + if checks == 3: + clock[0] = 102.0 + return result + + try: + with monkeypatch.context() as patch: + patch.setattr(native, "monotonic", lambda: clock[0]) + patch.setattr(layout, "monotonic", lambda: clock[0]) + patch.setattr(final, "_check", expire_on_layout_check) + with pytest.raises(ManagedStorageError, match="busy"): + owner.open(deadline=101.0) + assert checks == 3 and not owner.initialized + finally: + owner.close() + + +@pytest.mark.parametrize("replace_leaf", [False, True]) +def test_entry_replacement_during_parent_sync_is_rejected(tmp_path, monkeypatch, replace_leaf): + target = tmp_path / "ancestor" / "leaf" + entry = target if replace_leaf else target.parent + displaced = tmp_path / "displaced" + owner = PrivateManagedDirectory(target, create=True, create_parents=True, defer_open=True) + original_sync = os.fsync + replaced = False + + def replace_during_sync(fd): + nonlocal replaced + if not replaced and entry.exists() and os.fstat(fd).st_ino == entry.parent.stat().st_ino: + entry.rename(displaced) + entry.mkdir(mode=0o700) + replaced = True + return original_sync(fd) + + try: + with monkeypatch.context() as patch: + patch.setattr(native.os, "fsync", replace_during_sync) + with pytest.raises(ManagedStorageError, match="conflict"): + owner.open() + assert replaced and not owner._opened + assert not tuple(entry.iterdir()) + finally: + owner.close() + assert displaced.is_dir() and entry.is_dir() + + +def test_deadline_after_mkdir_retains_sync_debt_without_creating_descendants(tmp_path, monkeypatch): + target = tmp_path / "first" / "leaf" + owner = PrivateManagedDirectory(target, create=True, create_parents=True, defer_open=True) + clock = [100.0] + original_mkdir = os.mkdir + + def expire_after_mkdir(name, mode=0o777, *, dir_fd=None): + result = original_mkdir(name, mode=mode, dir_fd=dir_fd) + if name == "first": + clock[0] = 102.0 + return result + + try: + with monkeypatch.context() as patch: + patch.setattr(native, "monotonic", lambda: clock[0]) + patch.setattr(native.os, "mkdir", expire_after_mkdir) + with pytest.raises(ManagedStorageError, match="busy"): + owner.open(deadline=101.0) + retained, = owner._sync_pending + assert os.fstat(retained).st_ino == tmp_path.stat().st_ino + assert target.parent.is_dir() and not target.exists() + finally: + owner.close() + assert not owner.cleanup_pending and target.parent.is_dir() + + +def test_interrupted_close_retains_unknown_fd_and_closes_other_originals(tmp_path, monkeypatch): + owner = PrivateManagedDirectory(tmp_path / "leaf", create=True, create_parents=True) + original_fds = {owner._anchor, owner._fd, *(item[2] for item in owner._parents)} + original_close = os.close + attempted = [] + replacement = None + + def close_then_interrupt(fd): + nonlocal replacement + attempted.append(fd) + original_close(fd) + if replacement is None: + replacement = os.open(tmp_path, os.O_RDONLY | os.O_DIRECTORY) + assert replacement == fd, "fault must reproduce actual fd reuse" + raise KeyboardInterrupt("close receipt interrupted") + + try: + with monkeypatch.context() as patch: + patch.setattr(native.os, "close", close_then_interrupt) + with pytest.raises(KeyboardInterrupt): + owner.close() + assert set(attempted) == original_fds + assert owner._close_pending == owner._uncertain_closes == {replacement} + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.close() + assert len(attempted) == len(original_fds) + assert os.fstat(replacement).st_ino == tmp_path.stat().st_ino + for fd in original_fds - {replacement}: + with pytest.raises(OSError): + os.fstat(fd) + finally: + # Only the injector knows the replacement's receipt; production must + # never retry the unknown numeric descriptor. + if replacement is not None: + original_close(replacement) + + +def test_layout_interrupt_does_not_skip_independent_directory_cleanup(tmp_path, monkeypatch): + owner = preparation(tmp_path) + owner.open(deadline=monotonic() + 5) + + def interrupt(): + raise KeyboardInterrupt("injected close interruption") + + with monkeypatch.context() as patch: + patch.setattr(owner._directories[0], "close", interrupt) + with pytest.raises(KeyboardInterrupt): + owner.close() + assert owner._closed == set(range(1, 9)) + owner.close() + assert not owner.cleanup_pending + + +def test_two_native_processes_initialize_the_same_fixed_layout(tmp_path): + script = """ +import os, sys +from pathlib import Path +from time import monotonic +from loushang.apphost.managed.contracts import ManagedNamespaceV1, ManagedServiceKeyV1, ManagedInstanceRefV1 +from loushang.apphost.managed.layout import ManagedLayoutPreparationV1 +root = Path(sys.argv[1]) +ns = ManagedNamespaceV1(str(root / 'platform'), os.geteuid(), 'a' * 32) +service = ManagedServiceKeyV1('coding', str(root)) +instance = ManagedInstanceRefV1(ns.namespace_key, service.service_id, 'b' * 32) +owner = ManagedLayoutPreparationV1(ns, service, instance, runtime_root=str(root / 'runtime')) +try: + sys.stdin.buffer.read(1) + owner.open(deadline=monotonic() + 10) + print(Path(owner.paths.registry).stat().st_ino) +finally: + owner.close() +""" + children = [subprocess.Popen( + [sys.executable, "-c", script, str(tmp_path)], stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) for _ in range(2)] + try: + for child in children: + child.stdin.write(b"G") + child.stdin.flush() + outputs = [child.communicate(timeout=20) for child in children] + assert all(child.returncode == 0 for child in children), outputs + assert outputs[0][0] == outputs[1][0] and outputs[0][0].strip().isdigit() + finally: + for child in children: + if child.poll() is None: + child.terminate() + child.communicate(timeout=5) diff --git a/tests/apphost/test_managed_lifecycle.py b/tests/apphost/test_managed_lifecycle.py index 79b330147..09d6ac785 100644 --- a/tests/apphost/test_managed_lifecycle.py +++ b/tests/apphost/test_managed_lifecycle.py @@ -228,7 +228,7 @@ def test_real_competing_process_only_one_generation_is_reserved(owners, tmp_path assert journal.read() == prepared -@pytest.mark.parametrize("version", [1, 2]) +@pytest.mark.parametrize("version", [1, 2, 3, 4, 5]) def test_unactivated_old_registry_requires_explicit_upgrade_not_silent_migration(owners, tmp_path, version): _, _, namespace, _ = owners with sqlite3.connect(tmp_path / "registry" / DATABASE_NAME) as connection: diff --git a/tests/apphost/test_managed_lock_wait.py b/tests/apphost/test_managed_lock_wait.py new file mode 100644 index 000000000..710a15778 --- /dev/null +++ b/tests/apphost/test_managed_lock_wait.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import asyncio +import fcntl +import selectors +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from threading import Event +from time import monotonic + +import pytest + +from loushang.apphost.managed import _files +from loushang.apphost.managed._files import ManagedStorageError, PrivateManagedDirectory + +from .test_managed_files import directory as directory + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed flock") + + +def test_wait_uses_original_fd_until_other_process_releases(directory, monkeypatch): + root, owner = directory + with owner.lock("control.lock", create=True): + pass + script = """ +import fcntl, os, sys +fd = os.open(sys.argv[1], os.O_RDWR) +try: + fcntl.flock(fd, fcntl.LOCK_EX) + print('locked', flush=True) + sys.stdin.readline() +finally: + os.close(fd) +""" + child = subprocess.Popen([sys.executable, "-c", script, str(root / "control.lock")], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) + blocked = Event() + descriptors = [] + native = fcntl.flock + + def probe(fd, flags): + descriptors.append(fd) + try: + return native(fd, flags) + except BlockingIOError: + blocked.set() + raise + + try: + with selectors.DefaultSelector() as selector: + selector.register(child.stdout, selectors.EVENT_READ) + assert selector.select(timeout=3), "child did not acquire original lock" + assert child.stdout.readline().strip() == "locked" + monkeypatch.setattr(fcntl, "flock", probe) + + def release(): + assert blocked.wait(3) + child.stdin.write("release\n") + child.stdin.flush() + + with ThreadPoolExecutor(max_workers=1) as pool: + releasing = pool.submit(release) + with owner.lock("control.lock", deadline=monotonic() + 5, wait_for_lock=True): + assert len(descriptors) >= 2 and len(set(descriptors)) == 1 + releasing.result(timeout=3) + assert child.wait(timeout=3) == 0 + finally: + child.communicate("release\n" if child.poll() is None else None, timeout=5) + + +@pytest.mark.parametrize("replacement", [False, True]) +def test_wait_timeout_or_replacement_never_enters_body_or_reopens(directory, monkeypatch, replacement): + root, owner = directory + with owner.lock("control.lock", create=True): + pass + clock = [100.0] + opens = [] + original = owner._open + + def opened(*args, **kwargs): + value = original(*args, **kwargs) + opens.append(value) + return value + + def busy(*args): + raise BlockingIOError() + + def pause(delay): + clock[0] += delay + if replacement: + (root / "control.lock").rename(root / "held.lock") + (root / "control.lock").touch(mode=0o600) + + monkeypatch.setattr(owner, "_open", opened) + monkeypatch.setattr(_files, "monotonic", lambda: clock[0]) + monkeypatch.setattr(_files, "sleep", pause, raising=False) + monkeypatch.setattr(fcntl, "flock", busy) + with pytest.raises(ManagedStorageError, match="conflict" if replacement else "busy"): + with owner.lock("control.lock", deadline=100.02, wait_for_lock=True): + pytest.fail("unadmitted body") + assert len(opens) == 1 and not owner._locks + assert (root / "control.lock").exists() + + +def test_wait_is_explicit_and_forbidden_on_event_loop_thread(directory): + root, owner = directory + other = PrivateManagedDirectory(root) + + async def scenario(): + with owner.lock("control.lock", create=True): + for wait in (False, True): + with pytest.raises(ManagedStorageError, match="busy"): + with other.lock("control.lock", deadline=monotonic() + 5, wait_for_lock=wait): + pytest.fail("event-loop waiter stole held lock") + await asyncio.sleep(0) # Original holder can resume and release. + + try: + asyncio.run(scenario()) + finally: + other.close() + + +@pytest.mark.parametrize("deadline", [None, True, "soon", -1, float("nan"), float("inf")]) +def test_wait_requires_valid_deadline_before_open(directory, monkeypatch, deadline): + _, owner = directory + monkeypatch.setattr(owner, "_open", lambda *args, **kwargs: pytest.fail("opened invalid wait")) + with pytest.raises(ManagedStorageError, match="invalid_record"): + with owner.lock("control.lock", create=True, deadline=deadline, wait_for_lock=True): + pytest.fail("invalid wait admitted") + + +def test_event_loop_rejects_explicit_wait_even_without_contention(directory, monkeypatch): + _, owner = directory + monkeypatch.setattr(owner, "_open", lambda *args, **kwargs: pytest.fail("opened loop wait")) + + async def scenario(): + with pytest.raises(ManagedStorageError, match="busy"): + with owner.lock("control.lock", create=True, deadline=monotonic() + 5, + wait_for_lock=True): + pytest.fail("loop wait admitted") + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("bounded", [False, True]) +def test_same_owner_local_mutex_never_blocks_event_loop(directory, monkeypatch, bounded): + root, owner = directory + other = PrivateManagedDirectory(root) + blocked = Event() + native = fcntl.flock + mutex = owner._mutex + + class CheckedMutex: + def acquire(self, *args, **kwargs): + try: + asyncio.get_running_loop() + except RuntimeError: + pass + else: + assert kwargs.get("blocking") is False, "blocking local mutex on event loop" + return mutex.acquire(*args, **kwargs) + + def release(self): + mutex.release() + + def probe(fd, flags): + try: + return native(fd, flags) + except BlockingIOError: + blocked.set() + raise + + def waiting(): + with owner.lock("control.lock", deadline=monotonic() + 5, wait_for_lock=True): + return "settled" + + async def scenario(): + assert await asyncio.to_thread(blocked.wait, 3) + deadline = monotonic() + 1 if bounded else None + with pytest.raises(ManagedStorageError, match="busy"): + with owner.lock("control.lock", deadline=deadline): + pytest.fail("same-owner loop acquired worker's mutex") + await asyncio.sleep(0) + assert not worker.done() + + monkeypatch.setattr(owner, "_mutex", CheckedMutex()) + monkeypatch.setattr(fcntl, "flock", probe) + try: + with ThreadPoolExecutor(max_workers=1) as pool: + with other.lock("control.lock", create=True): + worker = pool.submit(waiting) + asyncio.run(scenario()) + assert worker.result(timeout=3) == "settled" + finally: + monkeypatch.setattr(owner, "_mutex", mutex) + other.close() diff --git a/tests/apphost/test_managed_namespace_admission.py b/tests/apphost/test_managed_namespace_admission.py new file mode 100644 index 000000000..3f77355ee --- /dev/null +++ b/tests/apphost/test_managed_namespace_admission.py @@ -0,0 +1,455 @@ +from __future__ import annotations + +import os +import selectors +import shutil +import sqlite3 +import subprocess +import sys +from pathlib import Path +from time import monotonic + +import pytest + +from loushang.apphost.managed._database import DATABASE_NAME +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.admission_record import ( + ManagedInitializationPhaseV1, + ManagedNamespaceAdmissionRecordV1, +) +from loushang.apphost.managed.contracts import ManagedNamespaceV1 +from loushang.apphost.managed.namespace_admission import ManagedNamespaceAdmissionV1 +from loushang.apphost.managed.paths import ( + resolve_managed_admission_root, + resolve_managed_registry_root, +) +from loushang.apphost.managed.registry import ManagedRegistryV1 + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux namespace admission") + + +def namespace(root): + return ManagedNamespaceV1(str(root / "home"), os.geteuid(), "a" * 32) + + +def owner(root, *, create=False): + return ManagedNamespaceAdmissionV1(namespace(root), runtime_root=str(root / "runtime"), + create_if_missing=create) + + +def tree(root): + return {str(path.relative_to(root)): (path.stat().st_dev, path.stat().st_ino, + path.stat().st_mode, path.stat().st_mtime_ns, + path.read_bytes() if path.is_file() else None) for path in (root, *root.rglob("*"))} + + +def initialize(root): + admission = owner(root, create=True) + try: + registry = admission.open(deadline=monotonic() + 10) + assert registry is admission.registry + assert registry.list_muxes() == () + finally: + admission.close() + assert not admission.cleanup_pending + + +@pytest.mark.parametrize("suffix", ["state", "state/session-stores", "state/other-product"]) +def test_namespace_runtime_cannot_enter_shared_durable_state(tmp_path, suffix): + from loushang.apphost.managed.contracts import ManagedContractError + + value = namespace(tmp_path) + before = tree(tmp_path) + with pytest.raises(ManagedContractError): + ManagedNamespaceAdmissionV1(value, runtime_root=str(Path(value.platform_home) / suffix), + create_if_missing=True) + assert tree(tmp_path) == before + + +def test_first_home_creates_bound_registry_but_no_sessions_or_service(tmp_path): + admission = owner(tmp_path, create=True) + assert set(tree(tmp_path)) == {"."} + try: + registry = admission.open(deadline=monotonic() + 10) + marker = Path(resolve_managed_admission_root(namespace(tmp_path))) / "admission.json" + record = ManagedNamespaceAdmissionRecordV1.from_json(marker.read_text()) + assert record.phase is ManagedInitializationPhaseV1.INITIALIZED + assert record.deployment_id == registry._database.deployment_id + assert record.database_identity == registry._database._file_identity + assert record.registry_lock_identity == registry._database._lock_identity + assert not (tmp_path / "home/data").exists() + assert not (tmp_path / "home/lmux/machines" / ("a" * 32) / "lifecycle").exists() + with pytest.raises(ManagedStorageError, match="closed"): + admission.open(deadline=monotonic() + 10) + finally: + admission.close() + assert not admission.cleanup_pending + + +@pytest.mark.parametrize("create", [False, True]) +def test_reopen_is_full_tree_read_only_and_retains_same_deployment(tmp_path, create): + initialize(tmp_path) + before = tree(tmp_path) + admission = owner(tmp_path, create=create) + try: + assert admission.open(deadline=monotonic() + 10).list_muxes() == () + finally: + admission.close() + assert tree(tmp_path) == before + + +def test_missing_read_only_namespace_never_creates_anything(tmp_path): + before = tree(tmp_path) + admission = owner(tmp_path) + try: + with pytest.raises(ManagedStorageError, match="not_found"): + admission.open(deadline=monotonic() + 10) + finally: + admission.close() + assert tree(tmp_path) == before and not admission.cleanup_pending + + +@pytest.mark.parametrize("residue", ["machine", "runtime"]) +def test_missing_admission_with_control_residue_never_creates_witness(tmp_path, residue): + ns = namespace(tmp_path) + path = (Path(resolve_managed_registry_root(ns)).parent if residue == "machine" + else tmp_path / "runtime/lmux" / ns.namespace_key) + current = tmp_path + for part in path.relative_to(tmp_path).parts: + current /= part + current.mkdir(mode=0o700) + before = tree(tmp_path) + admission = owner(tmp_path, create=True) + try: + with pytest.raises(ManagedStorageError, match="conflict"): + admission.open(deadline=monotonic() + 10) + finally: + admission.close() + assert tree(tmp_path) == before + assert not Path(resolve_managed_admission_root(ns)).exists() + + +@pytest.mark.parametrize("target", ["witness", "marker", "admission_lock", "registry", "database", "registry_lock"]) +@pytest.mark.parametrize("replace", [False, True]) +def test_missing_control_replaced_bound_identity_or_corrupt_marker_is_rejected(tmp_path, target, replace): + initialize(tmp_path) + witness = Path(resolve_managed_admission_root(namespace(tmp_path))) + registry = Path(resolve_managed_registry_root(namespace(tmp_path))) + paths = {"witness": witness, "marker": witness / "admission.json", + "admission_lock": witness / "admission.lock", "registry": registry, + "database": registry / DATABASE_NAME, "registry_lock": registry / "registry.lock"} + selected = paths[target] + displaced = tmp_path / "displaced" + selected.rename(displaced) + if replace: + if displaced.is_dir(): + shutil.copytree(displaced, selected) + elif target == "marker": + selected.write_text("{}") + selected.chmod(0o600) + else: + shutil.copy2(displaced, selected) + before = tree(tmp_path) + admission = owner(tmp_path, create=True) + try: + with pytest.raises(ManagedStorageError): + admission.open(deadline=monotonic() + 10) + with pytest.raises(ManagedStorageError, match="closed"): + _ = admission.registry + finally: + admission.close() + assert tree(tmp_path) == before + + +def test_equivalent_atomic_marker_copy_preserves_authority_without_rewriting(tmp_path): + initialize(tmp_path) + marker = Path(resolve_managed_admission_root(namespace(tmp_path))) / "admission.json" + old = marker.stat().st_ino + copy = tmp_path / "marker-copy" + shutil.copy2(marker, copy) + copy.replace(marker) + assert marker.stat().st_ino != old + before = tree(tmp_path) + admission = owner(tmp_path) + try: + assert admission.open(deadline=monotonic() + 10).list_muxes() == () + finally: + admission.close() + assert tree(tmp_path) == before + + +@pytest.mark.parametrize("phase", ["intent", "database", "publish"]) +def test_initialization_failures_leave_unavailable_intent_without_replay(tmp_path, monkeypatch, phase): + admission = owner(tmp_path, create=True) + write = admission._fresh.write + calls = [] + + def fail_write(name, content, **kwargs): + calls.append(content) + if phase == "intent" or (phase == "publish" and len(calls) == 2): + raise ManagedStorageError("unavailable") + return write(name, content, **kwargs) + + def fail_database(self, **kwargs): + raise ManagedStorageError("unavailable") + + try: + with monkeypatch.context() as patch: + patch.setattr(admission._fresh, "write", fail_write) + if phase == "database": + patch.setattr(ManagedRegistryV1, "open", fail_database) + with pytest.raises(ManagedStorageError, match="unavailable"): + admission.open(deadline=monotonic() + 10) + with pytest.raises(ManagedStorageError, match="closed"): + admission.open(deadline=monotonic() + 10) + finally: + admission.close() + before = tree(tmp_path) + other = owner(tmp_path, create=True) + try: + with pytest.raises(ManagedStorageError): + other.open(deadline=monotonic() + 10) + finally: + other.close() + assert tree(tmp_path) == before + + +def test_database_nonce_cannot_be_adopted(tmp_path): + initialize(tmp_path) + database = Path(resolve_managed_registry_root(namespace(tmp_path))) / DATABASE_NAME + with sqlite3.connect(database) as connection: + connection.execute("UPDATE identity SET deployment=?", ("f" * 32,)) + before = tree(tmp_path) + admission = owner(tmp_path, create=True) + try: + with pytest.raises(ManagedStorageError, match="conflict"): + admission.open(deadline=monotonic() + 10) + finally: + admission.close() + assert tree(tmp_path) == before + + +@pytest.mark.parametrize("stage", ["intent", "database", "published"]) +def test_lost_success_receipt_retains_original_owner_and_never_replays(tmp_path, monkeypatch, stage): + admission = owner(tmp_path, create=True) + write, opened = admission._fresh.write, ManagedRegistryV1.open + calls = [] + + def lost_write(*args, **kwargs): + result = write(*args, **kwargs) + calls.append("write") + if (stage == "intent" and len(calls) == 1) or (stage == "published" and len(calls) == 2): + raise ManagedStorageError("unavailable") + return result + + def lost_open(self, **kwargs): + result = opened(self, **kwargs) + if stage == "database": + raise ManagedStorageError("unavailable") + return result + + try: + with monkeypatch.context() as patch: + patch.setattr(admission._fresh, "write", lost_write) + patch.setattr(ManagedRegistryV1, "open", lost_open) + with pytest.raises(ManagedStorageError, match="unavailable"): + admission.open(deadline=monotonic() + 10) + if stage != "intent": + assert admission._registry is not None and admission._registry._database._opened + else: + assert admission._registry is None + with pytest.raises(ManagedStorageError, match="closed"): + admission.open(deadline=monotonic() + 10) + finally: + admission.close() + before = tree(tmp_path) + reopened = owner(tmp_path, create=True) + try: + if stage == "published": + reopened.open(deadline=monotonic() + 10) + else: + with pytest.raises(ManagedStorageError, match="unavailable"): + reopened.open(deadline=monotonic() + 10) + finally: + reopened.close() + assert tree(tmp_path) == before + + +def test_registry_close_failure_does_not_skip_independent_directory_cleanup(tmp_path, monkeypatch): + admission = owner(tmp_path, create=True) + registry = admission.open(deadline=monotonic() + 10) + closed = registry.close + calls = [] + + def fail(): + calls.append("failed") + raise ManagedStorageError("unavailable") + + try: + with monkeypatch.context() as patch: + patch.setattr(registry, "close", fail) + with pytest.raises(ManagedStorageError, match="unavailable"): + admission.close() + assert admission._registry is registry and not admission._registry_closed + assert admission._closed == set(range(len(admission._directories))) + assert admission.cleanup_pending + assert registry.close == closed + finally: + admission.close() + assert not admission.cleanup_pending and calls == ["failed"] + + +_CHILD = """ +import os +import sys +from pathlib import Path +from time import monotonic +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.contracts import ManagedNamespaceV1 +from loushang.apphost.managed.namespace_admission import ManagedNamespaceAdmissionV1 +from loushang.apphost.managed.registry import ManagedRegistryV1 +root, stage, gate = Path(sys.argv[1]), sys.argv[2], int(sys.argv[3]) +ns = ManagedNamespaceV1(str(root / 'home'), os.geteuid(), 'a' * 32) +owner = ManagedNamespaceAdmissionV1(ns, runtime_root=str(root / 'runtime'), create_if_missing=True) +original_write, original_open = owner._fresh.write, ManagedRegistryV1.open +original_fresh_open = owner._fresh.open +writes = 0 +def write(*args, **kwargs): + global writes + writes += 1 + if stage == 'before_intent': + os._exit(23) + result = original_write(*args, **kwargs) + if (stage == 'intent' and writes == 1) or (stage == 'published' and writes == 2): + os._exit(23) + return result +def opened(self, **kwargs): + result = original_open(self, **kwargs) + if stage == 'database': + os._exit(23) + return result +owner._fresh.write = write +ManagedRegistryV1.open = opened +def fresh_open(**kwargs): + print('ready', flush=True) + assert os.read(gate, 1) == b'x' + os.close(gate) + return original_fresh_open(**kwargs) +if stage == 'compete': + owner._fresh.open = fresh_open +elif gate >= 0: + print('ready', flush=True) + assert os.read(gate, 1) == b'x' + os.close(gate) +try: + owner.open(deadline=monotonic() + 10) + print('admitted', flush=True) +except ManagedStorageError as error: + print(error.code, flush=True) +finally: + owner.close() +""" + + +def child_env(): + return dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[2] / "src")) + + +@pytest.mark.parametrize("stage", ["before_intent", "intent", "database", "published"]) +def test_real_process_exit_preserves_unknown_or_durable_completion(tmp_path, stage): + child = subprocess.run([sys.executable, "-c", _CHILD, str(tmp_path), stage, "-1"], + env=child_env(), capture_output=True, text=True, timeout=15) + assert child.returncode == 23, child.stderr + before = tree(tmp_path) + admission = owner(tmp_path, create=True) + try: + if stage == "published": + assert admission.open(deadline=monotonic() + 10).list_muxes() == () + else: + with pytest.raises(ManagedStorageError): + admission.open(deadline=monotonic() + 10) + finally: + admission.close() + assert tree(tmp_path) == before + registry = Path(resolve_managed_registry_root(namespace(tmp_path))) + if stage in {"database", "published"}: + assert (registry / DATABASE_NAME).exists() + else: + assert not registry.exists() + + +def test_two_real_initializers_settle_to_one_reopenable_namespace(tmp_path): + gate_read, gate_write = os.pipe() + children = [] + try: + for _ in range(2): + children.append(subprocess.Popen( + [sys.executable, "-c", _CHILD, str(tmp_path), "compete", str(gate_read)], + env=child_env(), pass_fds=(gate_read,), stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, + )) + for child in children: + with selectors.DefaultSelector() as selector: + selector.register(child.stdout, selectors.EVENT_READ) + assert selector.select(timeout=10), "initializer did not reach gate" + assert child.stdout.readline().strip() == "ready" + assert os.write(gate_write, b"xx") == 2 + results = [] + for child in children: + stdout, stderr = child.communicate(timeout=15) + assert child.returncode == 0, stderr + results.append(stdout.strip()) + assert sorted(results) == ["admitted", "conflict"] + finally: + os.close(gate_read) + os.close(gate_write) + for child in children: + if child.poll() is None: + child.kill() + child.communicate(timeout=10) + before = tree(tmp_path) + admission = owner(tmp_path) + try: + admission.open(deadline=monotonic() + 10) + finally: + admission.close() + assert tree(tmp_path) == before + + +def test_markerless_reopener_cannot_steal_new_initializers_lock(tmp_path, monkeypatch): + creator = owner(tmp_path, create=True) + contender = owner(tmp_path, create=True) + opening = creator._fresh._open + observed = [] + + def forbidden(*args, **kwargs): + raise AssertionError("markerless reader attempted to take initializer lock") + + def at_created_lock(name, *args, **kwargs): + result = opening(name, *args, **kwargs) + if name == "admission.lock": + # The lock file really exists and has synced, but its creator has + # not flocked it yet. An unfixed reopener can steal it right here. + observed.append(name) + with pytest.raises(ManagedStorageError, match="invalid_record"): + contender.open(deadline=monotonic() + 10) + return result + + try: + with monkeypatch.context() as patch: + patch.setattr(creator._fresh, "_open", at_created_lock) + patch.setattr(contender._existing, "lock", forbidden) + creator.open(deadline=monotonic() + 10) + assert observed == ["admission.lock"] + assert creator.registry.list_muxes() == () + assert contender._registry is None + finally: + contender.close() + creator.close() + before = tree(tmp_path) + reopened = owner(tmp_path) + try: + reopened.open(deadline=monotonic() + 10) + finally: + reopened.close() + assert tree(tmp_path) == before diff --git a/tests/apphost/test_managed_paths.py b/tests/apphost/test_managed_paths.py index 7b9c7a86d..13f39a900 100644 --- a/tests/apphost/test_managed_paths.py +++ b/tests/apphost/test_managed_paths.py @@ -11,7 +11,10 @@ ManagedNamespaceV1, ManagedServiceKeyV1, ) -from loushang.apphost.managed.paths import resolve_managed_paths +from loushang.apphost.managed.paths import ( + resolve_managed_admission_root, + resolve_managed_paths, +) def _values(home="/private/home", workspace="/workspace"): @@ -82,7 +85,27 @@ def test_reference_mismatch_never_produces_paths(): ("/run/user/1000", "/private/home/lmux"), ("/run/user/1000", "/run/user/1000"), ("/run/user/1000", "relative"), + ("/private/home/state/session-stores", None), + ("/private/home/state", None), + ("/run/user/1000", "/private/home/state/session-stores"), + ("/run/user/1000", "/private/home/state"), ]) def test_reject_lexical_overlap_before_any_native_admission(runtime, temporary): with pytest.raises(ManagedContractError): resolve_managed_paths(*_values(), runtime_root=runtime, temporary_override=temporary) + + +@pytest.mark.parametrize("kind", ["runtime", "temporary"]) +@pytest.mark.parametrize("location", ["namespace", "child", "shared_root", "other_namespace"]) +def test_admission_witness_domain_is_not_runtime_or_scratch(kind, location): + values = _values() + root = resolve_managed_admission_root(values[0]) + selected = { + "namespace": root, "child": root / "child", "shared_root": root.parent, + "other_namespace": root.parent / ("c" * 64), + }[location] + with pytest.raises(ManagedContractError): + resolve_managed_paths( + *values, runtime_root=str(selected) if kind == "runtime" else "/run/user/1000", + temporary_override=str(selected) if kind == "temporary" else None, + ) diff --git a/tests/apphost/test_managed_registry.py b/tests/apphost/test_managed_registry.py index 553cd608e..96e6c9d13 100644 --- a/tests/apphost/test_managed_registry.py +++ b/tests/apphost/test_managed_registry.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import shutil import sqlite3 import subprocess import sys @@ -79,6 +80,74 @@ def test_reservations_reuse_service_but_not_names_or_operations(registry, tmp_pa assert connection.execute("SELECT count(*) FROM muxes").fetchone() == (2,) +def test_released_intent_is_retained_but_never_reactivated(registry): + first = intent() + registry.reserve_mux(first) + # Storage-level fixture only: actual release requires manager closed CAS. + with registry._database.transaction(write=True) as connection: + connection.execute("INSERT INTO mux_authorities VALUES (?, ?, ?, ?, ?, ?)", + (first.operation_id, "c" * 32, "c" * 32, "d" * 64, "c" * 32, "mux-old")) + connection.execute("DELETE FROM muxes WHERE operation_id=?", (first.operation_id,)) + assert registry.resolve(first.name) is None and not registry.list_muxes() + for request in (first, intent("other")): + with pytest.raises(ManagedStorageError, match="conflict"): + registry.reserve_mux(request) + second = intent(operation="e" * 32, workspace="/new-workspace") + registry.reserve_mux(second) + with pytest.raises(ManagedStorageError, match="conflict"): + registry.reserve_mux(first) + assert registry.resolve("dev") == second and registry.list_muxes() == (second,) + with registry._database.transaction() as connection: + assert connection.execute("SELECT count(*) FROM mux_intents").fetchone() == (2,) + assert connection.execute("SELECT mux_space_id FROM mux_authorities WHERE operation_id=?", + (first.operation_id,)).fetchone() == ("mux-old",) + + +def test_history_capacity_does_not_reopen_after_active_reference_is_released(registry, monkeypatch): + first = intent() + registry.reserve_mux(first) + monkeypatch.setattr("loushang.apphost.managed.registry.MAX_MUXES", 1) + assert registry.reserve_mux(first) == first # Exact active retry at capacity. + with registry._database.transaction(write=True) as connection: + connection.execute("DELETE FROM muxes WHERE operation_id=?", (first.operation_id,)) + with pytest.raises(ManagedStorageError, match="capacity"): + registry.reserve_mux(intent(operation="e" * 32, workspace="/new-workspace")) + assert registry.list_muxes() == () + with registry._database.transaction() as connection: + assert connection.execute("SELECT count(*) FROM mux_intents").fetchone() == (1,) + assert connection.execute("SELECT count(*) FROM services").fetchone() == (1,) + + +@pytest.mark.parametrize("field", ["name", "service_id", "operation_id"]) +def test_active_reference_must_match_full_historical_intent(registry, tmp_path, field): + first, other = intent(), intent("other", "e" * 32, "/other") + registry.reserve_mux(first) + registry.reserve_mux(other) + replacement = {"name": "renamed", "service_id": other.service.service_id, "operation_id": "f" * 32}[field] + with sqlite3.connect(tmp_path / "registry" / DATABASE_NAME) as connection: + connection.execute("PRAGMA foreign_keys=ON") + with pytest.raises(sqlite3.IntegrityError): + connection.execute(f"UPDATE muxes SET {field}=? WHERE name=?", (replacement, first.name)) + # Deliberate disk corruption must be detected by public reads, not hidden by joins. + with sqlite3.connect(tmp_path / "registry" / DATABASE_NAME) as connection: + connection.execute(f"UPDATE muxes SET {field}=? WHERE name=?", (replacement, first.name)) + with pytest.raises(ManagedStorageError, match="invalid_record"): + registry.list_muxes() + + +@pytest.mark.parametrize("version", [6, 7, 8, 9, 10, 11, 12, 13]) +def test_previous_schema_is_rejected_without_migration(registry, tmp_path, namespace, version): + registry.reserve_mux(intent()) + registry.close() + path = tmp_path / "registry" / DATABASE_NAME + with sqlite3.connect(path) as connection: + connection.execute(f"PRAGMA user_version={version}") + before = (path.read_bytes(), path.stat().st_mtime_ns) + with pytest.raises(ManagedStorageError, match="invalid_record"): + ManagedRegistryV1(path.parent, namespace, create=True) + assert (path.read_bytes(), path.stat().st_mtime_ns) == before + + def test_pagination_is_bounded_and_case_sensitive(registry): for index, name in enumerate(("dev", "Dev", "review")): registry.reserve_mux(intent(name, f"{index:032x}")) @@ -118,6 +187,53 @@ def test_namespace_mismatch_and_unknown_schema_are_not_migrated(registry, tmp_pa assert (root / DATABASE_NAME).read_bytes() == before +def test_deployment_nonce_is_persistent_and_cannot_be_rebound(tmp_path, namespace): + root = tmp_path / "registry" + owner = ManagedRegistryV1(root, namespace, create=True, exclusive_create=True, + deployment_id="d" * 32) + try: + assert owner._database.deployment_id == "d" * 32 + owner.reserve_mux(intent()) + finally: + owner.close() + before = {path.name: path.read_bytes() for path in root.iterdir()} + reopened = ManagedRegistryV1(root, namespace, deployment_id="d" * 32) + try: + assert reopened.resolve("dev") == intent() + finally: + reopened.close() + with pytest.raises(ManagedStorageError, match="conflict"): + ManagedRegistryV1(root, namespace, deployment_id="e" * 32) + assert before == {path.name: path.read_bytes() for path in root.iterdir()} + + +def test_exclusive_registry_creation_does_not_adopt_empty_directory(tmp_path, namespace): + root = tmp_path / "registry" + root.mkdir(mode=0o700) + with pytest.raises(ManagedStorageError, match="conflict"): + ManagedRegistryV1(root, namespace, create=True, exclusive_create=True) + assert not tuple(root.iterdir()) + + +def test_nonce_mutation_is_rejected_by_original_registry(registry, tmp_path): + with sqlite3.connect(tmp_path / "registry" / DATABASE_NAME) as connection: + connection.execute("UPDATE identity SET deployment=?", ("f" * 32,)) + with pytest.raises(ManagedStorageError, match="conflict"): + registry.list_muxes() + + +@pytest.mark.parametrize("name", [DATABASE_NAME, "registry.lock"]) +def test_open_registry_rejects_identical_bytes_with_new_file_identity(registry, tmp_path, name): + target = tmp_path / "registry" / name + replacement = tmp_path / "replacement" + shutil.copy2(target, replacement) + replacement.replace(target) + before = target.read_bytes() + with pytest.raises(ManagedStorageError, match="conflict"): + registry.list_muxes() + assert target.read_bytes() == before + + @pytest.mark.parametrize("change", ["CREATE TABLE unknown (x)", "CREATE INDEX extra ON muxes(service_id)"]) def test_unexpected_schema_is_rejected(registry, tmp_path, change): with sqlite3.connect(tmp_path / "registry" / DATABASE_NAME) as connection: @@ -164,6 +280,49 @@ def test_native_transaction_rollback_preserves_database(registry): assert connection.execute("SELECT count(*) FROM services").fetchone() == (0,) +@pytest.mark.parametrize("fail_after", ["mux_intents", "muxes"]) +def test_process_exit_between_reservation_writes_does_not_leave_partial_intent( + registry, tmp_path, namespace, fail_after, +): + registry.reserve_mux(intent()) + script = """ +import os, sqlite3, sys +from pathlib import Path +from loushang.apphost.managed.registry import ManagedRegistryV1, ManagedMuxReservationV1 +from loushang.apphost.managed.contracts import ManagedNamespaceV1, ManagedServiceKeyV1 +root, platform, stage = sys.argv[1:] +owner = ManagedRegistryV1(Path(root), ManagedNamespaceV1(platform, os.geteuid(), 'a'*32)) +original = sqlite3.connect +class CrashAfterInsert(sqlite3.Connection): + def execute(self, sql, parameters=()): + result = super().execute(sql, parameters) + if sql.startswith('INSERT INTO ' + stage + ' '): + os._exit(23) + return result +def connect(*args, **kwargs): + return original(*args, factory=CrashAfterInsert, **kwargs) +sqlite3.connect = connect +owner.reserve_mux(ManagedMuxReservationV1('new', ManagedServiceKeyV1('coding', '/new'), 'c'*32)) +os._exit(24) +""" + env = dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[2] / "src")) + root = tmp_path / "registry" + process = subprocess.run([sys.executable, "-c", script, str(root), namespace.platform_home, fail_after], + env=env, capture_output=True, timeout=10) + assert process.returncode == 23, process.stderr + registry.close() + # Explicit writable recovery, not an implicit mutation by discovery. + recovered = ManagedRegistryV1(root, namespace, create=True) + try: + assert recovered.list_muxes() == (intent(),) + with recovered._database.transaction() as connection: + assert connection.execute("SELECT count(*) FROM services").fetchone() == (1,) + assert connection.execute("SELECT count(*) FROM mux_intents").fetchone() == (1,) + assert connection.execute("SELECT count(*) FROM muxes").fetchone() == (1,) + finally: + recovered.close() + + def test_real_process_death_rolls_back_without_releasing_committed_name(registry, tmp_path, namespace): registry.reserve_mux(intent()) for index in range(20): @@ -181,6 +340,7 @@ def test_real_process_death_rolls_back_without_releasing_committed_name(registry connection.execute('PRAGMA cache_size=1') connection.execute('PRAGMA cache_spill=1') connection.execute('DELETE FROM muxes') + connection.execute('DELETE FROM mux_intents') connection.execute('DELETE FROM services') os._exit(23) """ @@ -205,6 +365,8 @@ def test_real_process_death_rolls_back_without_releasing_committed_name(registry try: assert recovery.resolve("dev") == intent() assert len(recovery.list_muxes()) == 21 + with recovery._database.transaction() as connection: + assert connection.execute("SELECT count(*) FROM mux_intents").fetchone() == (21,) assert database.read_bytes() == committed assert not journal.exists() finally: @@ -346,14 +508,16 @@ def close(): assert not registry.cleanup_pending -def test_name_insert_failure_rolls_back_new_service_too(registry, monkeypatch): +@pytest.mark.parametrize("fail_after", ["mux_intents", "muxes"]) +def test_name_insert_failure_rolls_back_new_service_too(registry, monkeypatch, fail_after): original_connect = sqlite3.connect class FailMuxInsert(sqlite3.Connection): def execute(self, sql, parameters=()): - if sql.startswith("INSERT INTO muxes"): + result = super().execute(sql, parameters) + if sql.startswith(f"INSERT INTO {fail_after} "): raise sqlite3.OperationalError("injected insertion failure") - return super().execute(sql, parameters) + return result def connect(*args, **kwargs): return original_connect(*args, factory=FailMuxInsert, **kwargs) @@ -365,6 +529,7 @@ def connect(*args, **kwargs): with registry._database.transaction() as connection: assert connection.execute("SELECT count(*) FROM services").fetchone() == (0,) assert connection.execute("SELECT count(*) FROM muxes").fetchone() == (0,) + assert connection.execute("SELECT count(*) FROM mux_intents").fetchone() == (0,) def test_corrupt_orphan_is_not_hidden_by_join(registry, tmp_path): diff --git a/tests/apphost/test_managed_service_admission.py b/tests/apphost/test_managed_service_admission.py new file mode 100644 index 000000000..894f3dac0 --- /dev/null +++ b/tests/apphost/test_managed_service_admission.py @@ -0,0 +1,502 @@ +from __future__ import annotations + +import os +import selectors +import shutil +import sqlite3 +import subprocess +import sys +from contextlib import contextmanager +from dataclasses import replace +from pathlib import Path +from time import monotonic + +import pytest + +from loushang.apphost.managed._database import DATABASE_NAME +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.admission_record import ( + ManagedInitializationPhaseV1, + ManagedServiceAdmissionRecordV1, +) +from loushang.apphost.managed.contracts import ( + ManagedContractError, + ManagedNamespaceV1, + ManagedServiceKeyV1, + ManagedStopEvidenceV1, +) +from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 +from loushang.apphost.managed.namespace_admission import ManagedNamespaceAdmissionV1 +from loushang.apphost.managed.paths import ( + resolve_managed_registry_root, + resolve_managed_service_paths, +) +from loushang.apphost.managed.service_admission import ManagedServiceAdmissionV1 +from loushang.hosting.service import LinuxServiceIdentityV1 + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux service-control admission") + + +@pytest.fixture +def namespace(tmp_path): + identity = ManagedNamespaceV1(str(tmp_path / "home"), os.geteuid(), "a" * 32) + owner = ManagedNamespaceAdmissionV1(identity, runtime_root=str(tmp_path / "runtime"), create_if_missing=True) + try: + owner.open(deadline=monotonic() + 10) + yield owner + finally: + owner.close() + + +def service(namespace, name="coding"): + return ManagedServiceKeyV1(name, str(Path(namespace._namespace.platform_home).parent)) + + +def paths(namespace, key): + return resolve_managed_service_paths(namespace._namespace, key, runtime_root=namespace._runtime_root) + + +def tree(root): + return {str(path.relative_to(root)): (path.stat().st_ino, path.stat().st_mode, path.stat().st_mtime_ns, + path.read_bytes() if path.is_file() else None) for path in (root, *root.rglob("*"))} + + +def stored(namespace, key): + with namespace.registry._database.transaction() as connection: + row = connection.execute("SELECT record FROM service_controls WHERE service_id=?", (key.service_id,)).fetchone() + return None if row is None else ManagedServiceAdmissionRecordV1.from_json(row[0]) + + +def test_new_service_has_control_fact_before_any_instance_and_reopens_read_only(namespace, tmp_path): + key = service(namespace) + first = ManagedServiceAdmissionV1(namespace, key) + try: + journal = first.open(deadline=monotonic() + 10) + assert journal is first.journal and journal.read() is None + control = stored(namespace, key) + assert control.phase is ManagedInitializationPhaseV1.INITIALIZED + assert control.root_identity == journal._fence._identity + assert not (tmp_path / "home/data").exists() + finally: + first.close() + before = tree(tmp_path) + second = ManagedServiceAdmissionV1(namespace, key) + try: + assert second.open(deadline=monotonic() + 10).read() is None + assert stored(namespace, key) == control + finally: + second.close() + assert tree(tmp_path) == before and not first.cleanup_pending and not second.cleanup_pending + + +def test_managed_journal_cannot_create_fence_before_control_fact(namespace): + key = service(namespace) + root = Path(paths(namespace, key).lifecycle) + journal = ManagedServiceJournalV1(namespace.registry, namespace._namespace, key, root, create=True, defer_open=True) + try: + with pytest.raises(ManagedStorageError, match="conflict"): + journal.open(deadline=monotonic() + 10) + finally: + journal.close() + assert not root.exists() and stored(namespace, key) is None + + +@pytest.mark.parametrize("stage", ["intent", "root", "lock", "publish"]) +def test_failure_after_control_intent_cannot_reinitialize_even_without_instances(namespace, monkeypatch, stage): + key = service(namespace) + owner = ManagedServiceAdmissionV1(namespace, key) + initialize, opening, locking = owner._initialize, owner._fresh.open, owner._fresh.lock + + def fail(*args, **kwargs): + raise ManagedStorageError("unavailable") + + def opened(**kwargs): + opening(**kwargs) + raise ManagedStorageError("unavailable") + + def after_publish(*args): + initialize(*args) + raise ManagedStorageError("unavailable") + + try: + with monkeypatch.context() as patch: + if stage == "intent": + patch.setattr(owner, "_initialize", fail) + elif stage == "root": + patch.setattr(owner._fresh, "open", opened) + elif stage == "lock": + patch.setattr(owner._fresh, "lock", fail) + else: + patch.setattr(owner, "_initialize", after_publish) + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.open(deadline=monotonic() + 10) + assert owner._fresh.lock == locking + with pytest.raises(ManagedStorageError, match="closed"): + owner.open(deadline=monotonic() + 10) + finally: + owner.close() + before = tree(Path(namespace._namespace.platform_home)) + other = ManagedServiceAdmissionV1(namespace, key) + try: + if stage == "publish": + assert other.open(deadline=monotonic() + 10).read() is None + else: + with pytest.raises(ManagedStorageError, match="unavailable"): + other.open(deadline=monotonic() + 10) + finally: + other.close() + assert tree(Path(namespace._namespace.platform_home)) == before + + +@pytest.mark.parametrize("target", ["root", "lock", "record"]) +def test_lost_control_never_recreated_when_instance_table_empty(namespace, tmp_path, target): + key = service(namespace) + first = ManagedServiceAdmissionV1(namespace, key) + first.open(deadline=monotonic() + 10) + first.close() + root = Path(paths(namespace, key).lifecycle) + if target == "record": + with namespace.registry._database.transaction(write=True) as connection: + connection.execute("DELETE FROM service_controls WHERE service_id=?", (key.service_id,)) + else: + selected = root if target == "root" else root / "lifecycle.lock" + selected.rename(tmp_path / "displaced") + before = tree(tmp_path) + other = ManagedServiceAdmissionV1(namespace, key) + try: + with pytest.raises(ManagedStorageError): + other.open(deadline=monotonic() + 10) + finally: + other.close() + assert tree(tmp_path) == before + + +@pytest.mark.parametrize("target", ["root", "lock"]) +def test_byte_identical_replacement_fence_is_not_adopted(namespace, tmp_path, target): + key = service(namespace) + first = ManagedServiceAdmissionV1(namespace, key) + first.open(deadline=monotonic() + 10) + first.close() + root = Path(paths(namespace, key).lifecycle) + selected = root if target == "root" else root / "lifecycle.lock" + displaced = tmp_path / "displaced" + selected.rename(displaced) + if target == "root": + shutil.copytree(displaced, selected) + else: + shutil.copy2(displaced, selected) + before = tree(tmp_path) + other = ManagedServiceAdmissionV1(namespace, key) + try: + with pytest.raises(ManagedStorageError, match="conflict"): + other.open(deadline=monotonic() + 10) + finally: + other.close() + assert tree(tmp_path) == before + + +@pytest.mark.parametrize("operation", ["read", "prepare", "abort", "stop", "register", "commit", + "child_stop", "child_cleanup", "evidence"]) +def test_original_journal_rechecks_control_in_each_transaction(namespace, operation): + key = service(namespace) + owner = ManagedServiceAdmissionV1(namespace, key) + try: + journal = owner.open(deadline=monotonic() + 10) + state = journal.prepare("d" * 32, expected=None) + with namespace.registry._database.transaction(write=True) as connection: + connection.execute("DELETE FROM service_controls WHERE service_id=?", (key.service_id,)) + identity = LinuxServiceIdentityV1(12345, 100, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", os.geteuid(), 1, 2) + with pytest.raises(ManagedStorageError, match="unavailable"): + if operation == "read": + journal.read() + elif operation == "prepare": + journal.prepare("e" * 32, expected=state) + elif operation == "abort": + journal.abort(state.handoff.instance, "d" * 32) + elif operation == "stop": + journal.request_stop(state.handoff.instance) + elif operation == "register": + journal.register_native(state.handoff.instance, "d" * 32, identity) + elif operation == "commit": + journal.commit(state.handoff.instance, "d" * 32, native_identity=identity) + elif operation == "child_stop": + journal.request_child_stop(state.handoff.instance, "d" * 32, identity) + elif operation == "child_cleanup": + journal.record_child_cleanup(state.handoff.instance, "d" * 32, identity) + else: + journal.record_stop_evidence(ManagedStopEvidenceV1(state.handoff.instance, False, False, False)) + with namespace.registry._database.transaction() as connection: + assert connection.execute("SELECT revision FROM instances").fetchone() == (1,) + finally: + owner.close() + + +def test_namespace_cannot_downgrade_to_manual_service_admission(namespace): + root = Path(resolve_managed_registry_root(namespace._namespace)) + with sqlite3.connect(root / DATABASE_NAME) as connection: + connection.execute("UPDATE identity SET service_admission=0") + with pytest.raises(ManagedStorageError, match="conflict"): + namespace.registry.list_muxes() + other = ManagedNamespaceAdmissionV1(namespace._namespace, runtime_root=namespace._runtime_root) + try: + with pytest.raises(ManagedStorageError, match="conflict"): + other.open(deadline=monotonic() + 10) + finally: + other.close() + + +def test_service_record_roundtrip_and_partial_identity_rejected(namespace): + value = ManagedServiceAdmissionRecordV1(service(namespace).service_id, "d" * 32, + ManagedInitializationPhaseV1.INITIALIZING) + assert ManagedServiceAdmissionRecordV1.from_json(value.to_json()) == value + with pytest.raises(ManagedContractError): + replace(value, root_identity=(1, 2)) + complete = replace(value, phase=ManagedInitializationPhaseV1.INITIALIZED, + root_identity=(1, 2), lock_identity=(1, 3)) + assert ManagedServiceAdmissionRecordV1.from_json(complete.to_json()) == complete + + +def test_initializing_raw_journal_never_steals_new_service_lock(namespace, monkeypatch): + key = service(namespace) + creator = ManagedServiceAdmissionV1(namespace, key) + contender = ManagedServiceJournalV1(namespace.registry, namespace._namespace, key, + Path(paths(namespace, key).lifecycle), defer_open=True) + opening = creator._fresh._open + observations = [] + + def forbidden(*args, **kwargs): + raise AssertionError("initializing journal attempted to steal fresh lock") + + def before_flock(name, *args, **kwargs): + result = opening(name, *args, **kwargs) + if name == "lifecycle.lock": + observations.append(name) + with pytest.raises(ManagedStorageError, match="unavailable"): + contender.open(deadline=monotonic() + 10) + assert not contender._fence._attempted + return result + + try: + with monkeypatch.context() as patch: + patch.setattr(creator._fresh, "_open", before_flock) + patch.setattr(contender._fence, "lock", forbidden) + assert creator.open(deadline=monotonic() + 10).read() is None + assert observations == ["lifecycle.lock"] + finally: + contender.close() + creator.close() + + +def test_failed_journal_close_keeps_original_and_closes_independent_resources(namespace, monkeypatch): + owner = ManagedServiceAdmissionV1(namespace, service(namespace)) + journal = owner.open(deadline=monotonic() + 10) + calls = [] + + def failed(): + calls.append("close") + raise ManagedStorageError("unavailable") + + try: + with monkeypatch.context() as patch: + patch.setattr(journal, "close", failed) + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.close() + assert owner._journal is journal and owner.cleanup_pending + assert owner._closed == {"fresh", "probe"} + assert namespace.registry.list_muxes() == () + finally: + owner.close() + assert calls == ["close"] and not owner.cleanup_pending + other = ManagedServiceAdmissionV1(namespace, service(namespace, "work")) + try: + assert other.open(deadline=monotonic() + 10).read() is None + finally: + other.close() + + +@pytest.mark.parametrize("broken", ["{}", "[]", "{", '{"phase":"initialized","phase":"initializing"}', + '{"version":"unknown"}', "[" * 1100 + "]" * 1100]) +def test_corrupt_control_is_fixed_error_and_cannot_mutate_instance(namespace, broken): + key = service(namespace) + owner = ManagedServiceAdmissionV1(namespace, key) + try: + journal = owner.open(deadline=monotonic() + 10) + state = journal.prepare("d" * 32, expected=None) + with namespace.registry._database.transaction(write=True) as connection: + before = connection.execute("SELECT * FROM instances").fetchall() + connection.execute("UPDATE service_controls SET record=? WHERE service_id=?", (broken, key.service_id)) + with pytest.raises(ManagedStorageError, match="^managed_storage_invalid_record$"): + journal.request_stop(state.handoff.instance) + with namespace.registry._database.transaction() as connection: + assert connection.execute("SELECT * FROM instances").fetchall() == before + finally: + owner.close() + + +_CHILD = """ +import os +import sys +from contextlib import contextmanager +from pathlib import Path +from time import monotonic +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.contracts import ManagedNamespaceV1, ManagedServiceKeyV1 +from loushang.apphost.managed.namespace_admission import ManagedNamespaceAdmissionV1 +from loushang.apphost.managed.service_admission import ManagedServiceAdmissionV1 +root, stage, gate = Path(sys.argv[1]), sys.argv[2], int(sys.argv[3]) +ns = ManagedNamespaceV1(str(root / 'home'), os.geteuid(), 'a' * 32) +namespace = ManagedNamespaceAdmissionV1(ns, runtime_root=str(root / 'runtime')) +namespace.open(deadline=monotonic() + 10) +owner = ManagedServiceAdmissionV1(namespace, ManagedServiceKeyV1('coding', str(root))) +claim, initialize, opening, locking = owner._claim, owner._initialize, owner._fresh.open, owner._fresh.lock +created = 0 +def claimed(deadline, *, wait_for_lock=False): + if gate >= 0: + print('ready', flush=True) + assert os.read(gate, 1) == b'x' + os.close(gate) + result = claim(deadline, wait_for_lock=wait_for_lock) + if stage == 'intent': + os._exit(23) + return result +def opened(**kwargs): + global created + created += 1 + result = opening(**kwargs) + if stage == 'root': + os._exit(23) + return result +@contextmanager +def locked(*args, **kwargs): + with locking(*args, **kwargs): + if stage == 'lock': + os._exit(23) + yield +def initialized(deadline): + result = initialize(deadline) + if stage == 'published': + os._exit(23) + return result +owner._claim, owner._fresh.open, owner._fresh.lock, owner._initialize = claimed, opened, locked, initialized +try: + owner.open(deadline=monotonic() + 10) + print('admitted', created, flush=True) +except ManagedStorageError as error: + print(error.code, created, flush=True) +finally: + try: + owner.close() + finally: + namespace.close() +""" + + +def child_env(): + return dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[2] / "src")) + + +@pytest.mark.parametrize("stage", ["intent", "root", "lock", "published"]) +def test_real_service_initializer_exit_never_uses_empty_instances_as_creation_proof(namespace, tmp_path, stage): + child = subprocess.run([sys.executable, "-c", _CHILD, str(tmp_path), stage, "-1"], + env=child_env(), capture_output=True, text=True, timeout=15) + assert child.returncode == 23, child.stderr + key = service(namespace) + control = stored(namespace, key) + assert control is not None + root = Path(paths(namespace, key).lifecycle) + assert root.exists() is (stage != "intent") + assert (root / "lifecycle.lock").exists() is (stage in {"lock", "published"}) + before = tree(tmp_path) + other = ManagedServiceAdmissionV1(namespace, key) + try: + if stage == "published": + assert other.open(deadline=monotonic() + 10).read() is None + else: + with pytest.raises(ManagedStorageError, match="unavailable"): + other.open(deadline=monotonic() + 10) + finally: + other.close() + assert stored(namespace, key) == control and tree(tmp_path) == before + + +def test_two_real_service_initializers_publish_only_one_fence(namespace, tmp_path): + gate_read, gate_write = os.pipe() + children = [] + try: + for _ in range(2): + children.append(subprocess.Popen( + [sys.executable, "-c", _CHILD, str(tmp_path), "compete", str(gate_read)], + env=child_env(), pass_fds=(gate_read,), stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, + )) + child = children[-1] + with selectors.DefaultSelector() as selector: + selector.register(child.stdout, selectors.EVENT_READ) + assert selector.select(timeout=10), "service initializer did not reach intent gate" + assert child.stdout.readline().strip() == "ready" + assert os.write(gate_write, b"xx") == 2 + results = [] + for child in children: + stdout, stderr = child.communicate(timeout=15) + assert child.returncode == 0, stderr + result, count = stdout.strip().split() + results.append((result, int(count))) + assert sum(count for _, count in results) == 1 + assert any(result == "admitted" for result, _ in results) + assert {result for result, _ in results} <= {"admitted", "busy", "unavailable"} + finally: + os.close(gate_read) + os.close(gate_write) + for child in children: + if child.poll() is None: + child.kill() + child.communicate(timeout=10) + key = service(namespace) + control = stored(namespace, key) + before = tree(tmp_path) + reopeners = [ManagedServiceAdmissionV1(namespace, key) for _ in range(2)] + try: + for owner in reopeners: + journal = owner.open(deadline=monotonic() + 10) + assert journal.read() is None + assert journal._fence._identity == control.root_identity + assert stored(namespace, key) == control + finally: + for owner in reopeners: + owner.close() + assert tree(tmp_path) == before + + +@pytest.mark.parametrize("when", ["before", "after"]) +def test_publication_contention_settles_original_fence_without_recreation(namespace, monkeypatch, when): + owner = ManagedServiceAdmissionV1(namespace, service(namespace)) + transaction = namespace.registry._database.transaction + opening = owner._fresh.open + attempts, opens = [], [] + + def opened(**kwargs): + opens.append(1) + return opening(**kwargs) + + @contextmanager + def busy(**kwargs): + publishing = bool(owner._fresh._locks) + if publishing: + attempts.append(1) + fail = publishing and len(attempts) <= 2 + if fail and when == "before": + raise ManagedStorageError("busy") + with transaction(**kwargs) as connection: + yield connection + if fail and when == "after": + raise ManagedStorageError("busy") + + try: + with monkeypatch.context() as patch: + patch.setattr(owner._fresh, "open", opened) + patch.setattr(namespace.registry._database, "transaction", busy) + assert owner.open(deadline=monotonic() + 10).read() is None + assert len(attempts) == 3 and len(opens) == 1 + assert stored(namespace, service(namespace)).phase is ManagedInitializationPhaseV1.INITIALIZED + finally: + owner.close() diff --git a/tests/apphost/test_managed_service_aliases.py b/tests/apphost/test_managed_service_aliases.py new file mode 100644 index 000000000..b5f52e230 --- /dev/null +++ b/tests/apphost/test_managed_service_aliases.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from dataclasses import replace +from time import monotonic + +import pytest + +from loushang.apphost.managed import registry as module +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.contracts import ManagedContractError, ManagedServiceKeyV1 + +from .test_managed_registry import namespace as namespace +from .test_managed_registry import pytestmark as pytestmark +from .test_managed_registry import registry as registry + + +def intent(name="build", workspace="/build", operation="a" * 32): + return module.ManagedServiceAliasReservationV1(name, ManagedServiceKeyV1("coding", workspace), operation) + + +def test_alias_reopens_independently_of_cwd_without_mux(registry, namespace, tmp_path, monkeypatch): + request = intent() + assert registry.reserve_service_alias(request) == request + assert registry.reserve_service_alias(request) == request + registry.close() + other = tmp_path / "other" + other.mkdir() + monkeypatch.chdir(other) + reopened = module.ManagedRegistryV1(tmp_path / "registry", namespace) + try: + assert reopened.resolve_service_alias("build") == request + assert reopened.resolve_service_alias("missing") is None + assert reopened.list_muxes() == () + with reopened._database.transaction() as connection: + for table in ("muxes", "mux_intents", "mux_authorities", "instances", "service_controls"): + assert connection.execute(f"SELECT count(*) FROM {table}").fetchone() == (0,) + finally: + reopened.close() + + +@pytest.mark.parametrize("change", [dict(name="other"), dict(operation_id="b" * 32), + dict(service=ManagedServiceKeyV1("coding", "/other")), + dict(name="other", service=ManagedServiceKeyV1("coding", "/other"))]) +def test_alias_cannot_rebind_name_service_or_operation(registry, change): + original = intent() + registry.reserve_service_alias(original) + with pytest.raises(ManagedStorageError, match="conflict"): + registry.reserve_service_alias(replace(original, **change)) + assert registry.resolve_service_alias("build") == original + with registry._database.transaction() as connection: + assert connection.execute("SELECT count(*) FROM services").fetchone() == (1,) + + +@pytest.mark.parametrize("name", ["", "bad:name", "../escape", "a" * 65, "a" * 64, "0" * 64, "A" * 64]) +def test_invalid_alias_rejects_without_io(name): + with pytest.raises(ManagedContractError): + intent(name=name) + + +def test_alias_capacity_is_atomic_but_exact_retry_still_works(registry, monkeypatch): + original = intent() + registry.reserve_service_alias(original) + monkeypatch.setattr(module, "MAX_SERVICES", 1) + assert registry.reserve_service_alias(original) == original + with pytest.raises(ManagedStorageError, match="capacity"): + registry.reserve_service_alias(intent("second", "/second", "b" * 32)) + assert registry.resolve_service_alias("second") is None + with registry._database.transaction() as connection: + assert connection.execute("SELECT count(*) FROM services").fetchone() == (1,) + + +def test_alias_and_mux_names_are_independent(registry): + original = intent() + registry.reserve_service_alias(original) + mux = module.ManagedMuxReservationV1("build", ManagedServiceKeyV1("coding", "/other"), "c" * 32) + registry.reserve_mux(mux) + assert registry.resolve("build") == mux + assert registry.resolve_service_alias("build") == original + + +def test_expired_alias_calls_do_not_commit(registry): + with pytest.raises(ManagedStorageError): + registry.reserve_service_alias(intent(), deadline=monotonic() - 1) + assert registry.resolve_service_alias("build") is None + with pytest.raises(ManagedStorageError): + registry.resolve_service_alias("build", deadline=monotonic() - 1) + + +@pytest.mark.parametrize("write", [True, False]) +def test_alias_post_transaction_deadline_preserves_exact_intent(registry, monkeypatch, write): + from contextlib import contextmanager + + request = intent() + if not write: + registry.reserve_service_alias(request) + transaction = registry._database.transaction + expired = [] + + @contextmanager + def late(**kwargs): + with transaction(**kwargs) as connection: + yield connection + expired.append(True) + + def check(deadline): + if expired: + raise ManagedStorageError("unavailable") + + with monkeypatch.context() as fault: + fault.setattr(registry._database, "transaction", late) + fault.setattr(module, "_check_deadline", check) + with pytest.raises(ManagedStorageError, match="unavailable"): + if write: + registry.reserve_service_alias(request) + else: + registry.resolve_service_alias(request.name) + assert registry.resolve_service_alias(request.name) == request + assert registry.reserve_service_alias(request) == request + + +def test_alias_insert_failure_rolls_back_service(registry, monkeypatch): + from contextlib import contextmanager + + transaction = registry._database.transaction + + class Connection: + def __init__(self, original): + self.original = original + + def execute(self, sql, *args): + if sql.startswith("INSERT INTO service_aliases"): + raise RuntimeError("injected alias insert failure") + return self.original.execute(sql, *args) + + @contextmanager + def failed(**kwargs): + with transaction(**kwargs) as connection: + yield Connection(connection) + + with monkeypatch.context() as fault: + fault.setattr(registry._database, "transaction", failed) + with pytest.raises(RuntimeError): + registry.reserve_service_alias(intent()) + assert registry.resolve_service_alias("build") is None + with transaction() as connection: + assert connection.execute("SELECT count(*) FROM services").fetchone() == (0,) + + +@pytest.mark.parametrize("same_service", [True, False]) +def test_competing_alias_reservations_have_one_winner(registry, namespace, tmp_path, same_service): + from concurrent.futures import ThreadPoolExecutor + from threading import Barrier + + other = module.ManagedRegistryV1(tmp_path / "registry", namespace) + barrier = Barrier(2) + first = intent() + second = intent("other" if same_service else "build", "/build" if same_service else "/other", "b" * 32) + + def reserve(owner, request): + barrier.wait(timeout=5) + try: + return owner.reserve_service_alias(request, deadline=monotonic() + 5, wait_for_lock=True) + except ManagedStorageError as error: + return error.code + + try: + with ThreadPoolExecutor(max_workers=2) as pool: + a, b = pool.submit(reserve, registry, first), pool.submit(reserve, other, second) + results = [a.result(timeout=10), b.result(timeout=10)] + assert results.count("conflict") == 1 + with registry._database.transaction() as connection: + assert connection.execute("SELECT count(*) FROM service_aliases").fetchone() == (1,) + assert connection.execute("SELECT count(*) FROM services").fetchone() == (1,) + finally: + other.close() diff --git a/tests/apphost/test_managed_starter.py b/tests/apphost/test_managed_starter.py new file mode 100644 index 000000000..fff2124c5 --- /dev/null +++ b/tests/apphost/test_managed_starter.py @@ -0,0 +1,368 @@ +from __future__ import annotations + +import os +import sys +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from threading import Event +from time import monotonic + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError, PrivateManagedDirectory +from loushang.apphost.managed.contracts import ( + ManagedContractError, + ManagedNamespaceV1, + ManagedServiceKeyV1, +) +from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 +from loushang.apphost.managed.paths import resolve_managed_service_paths +from loushang.apphost.managed.registry import ManagedMuxReservationV1, ManagedRegistryV1 +from loushang.apphost.managed.starter import ManagedServiceStarterV1 +from loushang.hosting.contracts import ( + ProcessLaunchRequest, + ProcessStderrMode, + ProcessStdinMode, + ProcessStdoutMode, + ProcessStreamSpec, +) + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed starter") + + +@pytest.fixture +def owners(tmp_path, request): + namespace = ManagedNamespaceV1(str(tmp_path / "platform"), os.geteuid() + getattr(request, "param", 0), "a" * 32) + service = ManagedServiceKeyV1("coding", str(tmp_path)) + runtime = str(tmp_path / "runtime") + paths = resolve_managed_service_paths(namespace, service, runtime_root=runtime) + for root in (paths.registry, paths.lifecycle): + directory = PrivateManagedDirectory(Path(root), create=True, create_parents=True) + directory.close() + registry = ManagedRegistryV1(Path(paths.registry), namespace, create=True) + registry.reserve_mux(ManagedMuxReservationV1("main", service, "b" * 32)) + journal = ManagedServiceJournalV1(registry, namespace, service, Path(paths.lifecycle), create=True) + try: + yield journal, namespace, service, runtime + finally: + journal.close() + registry.close() + + +def request(invocation, fd): + # The child creates no descendants/Product. EOF from starter close ends it. + code = "import socket,sys; s=socket.socket(fileno=int(sys.argv[1])); s.recv(1); s.close()" + return ProcessLaunchRequest( + (sys.executable, "-c", code, str(fd)), invocation.service.workspace, + tuple(os.environ.items()), + ProcessStreamSpec(ProcessStdinMode.CLOSED, ProcessStdoutMode.DISCARD, ProcessStderrMode.DISCARD), + ) + + +def starter(owners, callback=request): + journal, namespace, service, runtime = owners + return ManagedServiceStarterV1(journal, namespace, service, runtime_root=runtime, request_factory=callback) + + +def close_and_reap(owner): + process = owner._process + owner.close() + if process is not None and process._process is not None: + assert process._process.wait(timeout=5) == 0 + + +def test_invalid_scratch_rejected_before_durable_prepare(owners): + journal, namespace, service, runtime = owners + before = journal.read() + with pytest.raises(ManagedContractError): + ManagedServiceStarterV1(journal, namespace, service, runtime_root=runtime, request_factory=request, + temporary_override="relative") + assert journal.read() == before + + +@pytest.mark.parametrize("trace_deadline", [None, 123456789]) +def test_real_spawn_registers_once_and_close_does_not_fabricate_stop(owners, trace_deadline): + calls = [] + + def build(invocation, fd): + state = owners[0].read() + assert state.handoff.instance == invocation.instance + calls.append(invocation) + return request(invocation, fd) + + journal, namespace, service, runtime = owners + owner = ManagedServiceStarterV1(journal, namespace, service, runtime_root=runtime, + request_factory=build, trace_deadline_ms=trace_deadline) + competing = starter(owners, build) + try: + state = owner.start(expected=None, deadline=monotonic() + 10) + assert len(calls) == 1 and state.native_identity is not None + assert not state.cleanly_stopped and state.handoff.phase.value == "provisional" + assert owner.register_birth(deadline=monotonic() + 5) == state + with pytest.raises(ManagedStorageError, match="conflict"): + owner.start(expected=None, deadline=monotonic() + 5) + with pytest.raises(ManagedStorageError, match="conflict"): + competing.start(expected=None, deadline=monotonic() + 5) + assert len(calls) == 1 and competing._process is None + assert calls[0].instance.instance_id in str(owner._layout.paths.temporary) + assert calls[0].trace_deadline_ms == trace_deadline + finally: + close_and_reap(owner) + competing.close() + assert not owner.cleanup_pending + assert owner.observe(deadline=monotonic() + 5) == state + + +def test_lost_birth_reply_reconciles_without_spawning_again(owners, monkeypatch): + owner = starter(owners) + journal = owners[0] + original = journal.register_native + calls = [] + + def lost(*args, **kwargs): + calls.append(1) + original(*args, **kwargs) + raise ManagedStorageError("unavailable") + + monkeypatch.setattr(journal, "register_native", lost) + try: + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.start(expected=None, deadline=monotonic() + 10) + identity = owner._process.identity + assert owner.register_birth(deadline=monotonic() + 5).native_identity == identity + assert calls == [1] + finally: + close_and_reap(owner) + + +def test_callback_stop_fences_native_spawn(owners): + def stop(invocation, fd): + owners[0].request_stop(invocation.instance) + return request(invocation, fd) + + owner = starter(owners, stop) + try: + with pytest.raises(ManagedStorageError, match="conflict"): + owner.start(expected=None, deadline=monotonic() + 10) + assert owner._process is None and owner.observe(deadline=monotonic() + 5).handoff.stop_requested + finally: + owner.close() + assert not owner.cleanup_pending + + +def test_lost_prepare_reply_never_authorizes_spawn_or_replay(owners, monkeypatch): + journal = owners[0] + original = journal.prepare + calls = [] + + def lost(*args, **kwargs): + original(*args, **kwargs) + raise ManagedStorageError("unavailable") + + monkeypatch.setattr(journal, "prepare", lost) + owner = starter(owners, lambda *args: calls.append(1)) + try: + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.start(expected=None, deadline=monotonic() + 5) + assert owner.observe(deadline=monotonic() + 5).native_identity is None + with pytest.raises(ManagedStorageError, match="conflict"): + owner.start(expected=None, deadline=monotonic() + 5) + assert not calls and not owner.cleanup_pending + finally: + owner.close() + + +def test_lost_control_root_is_not_recreated(owners, monkeypatch): + journal = owners[0] + original = journal.prepare + fence = journal._fence._root + + def move(*args, **kwargs): + state = original(*args, **kwargs) + fence.rename(fence.with_name(fence.name + "-moved")) + return state + + monkeypatch.setattr(journal, "prepare", move) + owner = starter(owners) + try: + with pytest.raises(ManagedStorageError): + owner.start(expected=None, deadline=monotonic() + 5) + assert not fence.exists() and owner._process is None + finally: + owner.close() + + +@pytest.mark.parametrize("operation", ["start", "register_birth"]) +def test_close_between_initial_check_and_mutex_never_mutates(owners, monkeypatch, operation): + owner = starter(owners) + entered, release = Event(), Event() + original = owner._check + calls = [] + + def delayed(deadline): + original(deadline) + if not calls: + calls.append(1) + entered.set() + assert release.wait(5) + + monkeypatch.setattr(owner, "_check", delayed) + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit( + owner.start if operation == "start" else owner.register_birth, + **({"expected": None} if operation == "start" else {}), deadline=monotonic() + 10, + ) + try: + assert entered.wait(5) + owner.close() + release.set() + with pytest.raises(ManagedStorageError, match="closed"): + future.result(timeout=5) + assert owners[0].read() is None and not owner.cleanup_pending + finally: + release.set() + owner.close() + + +def test_close_during_final_provisional_read_rejects_native_spawn(owners, monkeypatch): + owner = starter(owners) + original = owner._require_provisional + entered, release = Event(), Event() + calls = [] + + def delayed(deadline): + original(deadline) + calls.append(1) + if len(calls) == 2: + entered.set() + assert release.wait(5) + + monkeypatch.setattr(owner, "_require_provisional", delayed) + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(owner.start, expected=None, deadline=monotonic() + 10) + try: + assert entered.wait(5) + with pytest.raises(ManagedStorageError, match="busy"): + owner.close() + release.set() + with pytest.raises(ManagedStorageError, match="closed"): + future.result(timeout=5) + assert owner._process.identity is None and owner._process._process is None + finally: + release.set() + future.result(timeout=5) if not future.done() else None + owner.close() + assert not owner.cleanup_pending + + +@pytest.mark.parametrize("owners", [1], indirect=True) +def test_foreign_uid_namespace_rejected_before_start(owners): + with pytest.raises(ManagedContractError): + starter(owners) + assert owners[0].read() is None + + +def test_failure_after_real_spawn_retains_process_for_registration(owners, monkeypatch): + from loushang.hosting.service_process import LinuxServiceProcessV1 + + original = LinuxServiceProcessV1.spawn + calls = [] + + def lost(process): + calls.append(original(process)) + raise OSError("lost native return") + + monkeypatch.setattr(LinuxServiceProcessV1, "spawn", lost) + owner = starter(owners) + try: + with pytest.raises(OSError, match="lost native return"): + owner.start(expected=None, deadline=monotonic() + 10) + assert owner.cleanup_pending and owner._process.identity == calls[0] + assert owner.register_birth(deadline=monotonic() + 5).native_identity == calls[0] + with pytest.raises(ManagedStorageError, match="conflict"): + owner.start(expected=None, deadline=monotonic() + 5) + assert len(calls) == 1 + finally: + close_and_reap(owner) + + +def test_close_failure_still_closes_parent_and_layout_then_retries_original_process(owners, monkeypatch): + from loushang.hosting.service_process import LinuxServiceProcessV1 + + owner = starter(owners) + owner.start(expected=None, deadline=monotonic() + 10) + process = owner._process + original = LinuxServiceProcessV1.close + calls = [] + + def unavailable(selected): + calls.append(selected) + if len(calls) == 1: + raise OSError("process close unavailable") + original(selected) + + try: + with monkeypatch.context() as patch: + patch.setattr(LinuxServiceProcessV1, "close", unavailable) + with pytest.raises(OSError, match="process close unavailable"): + owner.close() + assert owner._endpoints[0].fileno() == -1 + assert not owner._layout.cleanup_pending and owner.cleanup_pending + owner.close() + assert calls == [process, process] and not owner.cleanup_pending + finally: + close_and_reap(owner) + + +def test_unknown_socket_close_is_retained_without_retry(owners, monkeypatch): + import socket + + owner = starter(owners) + owner.start(expected=None, deadline=monotonic() + 10) + parent = owner._endpoints[0] + original = socket.socket.close + calls = [] + + def unknown(endpoint): + original(endpoint) + if endpoint is parent: + calls.append(1) + raise OSError("close outcome unknown") + + try: + with monkeypatch.context() as patch: + patch.setattr(socket.socket, "close", unknown) + with pytest.raises(OSError, match="close outcome unknown"): + owner.close() + assert owner._process.handles_closed and not owner._layout.cleanup_pending + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.close() + assert calls == [1] and owner.cleanup_pending + finally: + # The injected failure happened after actual close, so no native fd is + # leaked. Unknown authority remains unknown, even after child exit. + assert owner._process._process.wait(timeout=5) == 0 + + +@pytest.mark.parametrize("failure", ["callback", "deadline"]) +def test_callback_failure_or_expired_budget_never_spawns(owners, monkeypatch, failure): + from loushang.apphost.managed import _files + + deadline = monotonic() + 10 + + def failed(invocation, fd): + if failure == "callback": + raise ValueError("request construction failed") + value = request(invocation, fd) + monkeypatch.setattr(_files, "monotonic", lambda: deadline + 1) + return value + + owner = starter(owners, failed) + try: + with pytest.raises((ValueError, ManagedStorageError)): + owner.start(expected=None, deadline=deadline) + assert owner._process is None + monkeypatch.undo() + assert owner.observe(deadline=monotonic() + 5).native_identity is None + finally: + owner.close() diff --git a/tests/apphost/test_managed_transitions.py b/tests/apphost/test_managed_transitions.py new file mode 100644 index 000000000..92be74e19 --- /dev/null +++ b/tests/apphost/test_managed_transitions.py @@ -0,0 +1,232 @@ +"""A bounded original-journal witness, not independent recovery authority.""" + +from __future__ import annotations + +import json +import os +import sqlite3 +import subprocess +import sys +from pathlib import Path +from time import monotonic + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.contracts import ManagedStopEvidenceV1 +from tests.apphost.test_managed_lifecycle import _identity +from tests.apphost.test_managed_lifecycle import owners as _base_owners + +owners = _base_owners +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed transitions") + + +def stopped(owners): + journal = owners[1] + state = journal.prepare("c" * 32, expected=None) + journal.register_native(state.handoff.instance, "c" * 32, _identity()) + journal.commit(state.handoff.instance, "c" * 32, native_identity=_identity()) + journal.request_stop(state.handoff.instance) + return journal.record_stop_evidence(ManagedStopEvidenceV1(state.handoff.instance, True, True, True)) + + +def test_transition_retains_exact_previous_and_is_unchanged_by_current_updates(owners): + registry, journal, _, _ = owners + assert journal.read_transition() is None + previous = stopped(owners) + first = journal.read_transition() + assert first.previous is None and first.started_revision == 1 + successor = journal.prepare("d" * 32, expected=previous) + receipt = journal.read_transition() + assert receipt.previous == previous + assert receipt.instance == successor.handoff.instance + assert receipt.attempt_id == successor.handoff.attempt_id + assert receipt.started_revision == previous.revision + 1 + with registry._database.transaction() as connection: + before = connection.execute("SELECT * FROM service_transitions").fetchone() + journal.register_native(successor.handoff.instance, "d" * 32, _identity()) + journal.commit(successor.handoff.instance, "d" * 32, native_identity=_identity()) + journal.request_stop(successor.handoff.instance) + assert journal.read_transition() == receipt + with registry._database.transaction() as connection: + assert connection.execute("SELECT * FROM service_transitions").fetchone() == before + path = registry._database._directory._root / "registry.sqlite3" + before_file = (path.read_bytes(), path.stat().st_mtime_ns) + assert journal.read_transition() == receipt + assert journal.read().revision > receipt.started_revision + assert (path.read_bytes(), path.stat().st_mtime_ns) == before_file + + +@pytest.mark.parametrize("change", ["missing", "instance", "attempt", "revision", "unclean", "namespace", "service", + "previous_instance", "previous_attempt", "null_previous", "noncanonical", "nested", + "exit_unsettled", "scope_unsettled"]) +def test_missing_mismatched_or_unclean_predecessor_is_rejected_before_journal_update(owners, change): + registry, journal, _, _ = owners + previous = stopped(owners) + current = journal.prepare("d" * 32, expected=previous) + path = registry._database._directory._root / "registry.sqlite3" + with sqlite3.connect(path) as connection: + if change == "missing": + connection.execute("DELETE FROM service_transitions") + elif change in ("instance", "attempt", "revision"): + column, value = {"instance": ("instance_id", "e" * 32), "attempt": ("attempt_id", "e" * 32), + "revision": ("started_revision", previous.revision + 2)}[change] + connection.execute(f"UPDATE service_transitions SET {column}=?", (value,)) + elif change == "null_previous": + connection.execute("UPDATE service_transitions SET previous=NULL") + elif change == "nested": + connection.execute("UPDATE service_transitions SET previous=?", ("[" * 1200 + "0" + "]" * 1200,)) + else: + raw = json.loads(connection.execute("SELECT previous FROM service_transitions").fetchone()[0]) + if change != "noncanonical": + index, value = {"namespace": (0, "e" * 64), "service": (1, "e" * 64), "unclean": (8, 0), + "exit_unsettled": (7, 0), "scope_unsettled": (9, 0), + "previous_instance": (3, current.handoff.instance.instance_id), + "previous_attempt": (4, current.handoff.attempt_id)}[change] + raw[index] = value + encoded = json.dumps(raw) if change == "noncanonical" else json.dumps(raw, separators=(",", ":")) + connection.execute("UPDATE service_transitions SET previous=?", (encoded,)) + before = path.read_bytes() + for action in (journal.read, journal.read_transition, + lambda: journal.register_native(current.handoff.instance, "d" * 32, _identity())): + with pytest.raises(ManagedStorageError): + action() + assert path.read_bytes() == before + + +@pytest.mark.parametrize("initial", [False, True]) +@pytest.mark.parametrize("table", ["instances", "service_transitions"]) +def test_prepare_failure_rolls_back_both_successor_and_transition(owners, monkeypatch, table, initial): + registry, journal, _, _ = owners + previous = None if initial else stopped(owners) + receipt = journal.read_transition() + stage = ("INSERT INTO " if initial or table == "service_transitions" else "UPDATE ") + table + original_connect = sqlite3.connect + + class Failure(sqlite3.Connection): + def execute(self, sql, parameters=()): + result = super().execute(sql, parameters) + if sql.startswith(stage): + raise sqlite3.OperationalError("write receipt failed") + return result + + def connect(*args, **kwargs): + return original_connect(*args, factory=Failure, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(sqlite3, "connect", connect) + with pytest.raises(ManagedStorageError): + journal.prepare("d" * 32, expected=previous) + assert journal.read() == previous + assert journal.read_transition() == receipt + with registry._database.transaction() as connection: + assert connection.execute("SELECT count(*) FROM service_transitions").fetchone() == (0 if initial else 1,) + + +def test_prepare_lost_commit_receipt_is_resolved_by_reading_same_successor(owners, monkeypatch): + journal = owners[1] + previous = stopped(owners) + original_connect = sqlite3.connect + + class Failure(sqlite3.Connection): + def commit(self): + super().commit() + raise sqlite3.OperationalError("committed without receipt") + + def connect(*args, **kwargs): + return original_connect(*args, factory=Failure, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(sqlite3, "connect", connect) + with pytest.raises(ManagedStorageError): + journal.prepare("d" * 32, expected=previous, deadline=monotonic() + 5) + current = journal.read() + receipt = journal.read_transition() + assert current.handoff.attempt_id == "d" * 32 + assert receipt.instance == current.handoff.instance and receipt.previous == previous + with pytest.raises(ManagedStorageError): + journal.prepare("d" * 32, expected=previous) + assert journal.read_transition() == receipt + + +def test_clean_failed_generation_without_native_can_precede_another_generation(owners): + registry, journal, _, _ = owners + first = stopped(owners) + second = journal.prepare("d" * 32, expected=first) + journal.request_stop(second.handoff.instance) + second = journal.record_stop_evidence(ManagedStopEvidenceV1(second.handoff.instance, True, True, True)) + assert second.native_identity is None + assert journal.read_transition().previous == first + third = journal.prepare("e" * 32, expected=second) + receipt = journal.read_transition() + assert receipt.previous == second and receipt.previous != first + assert receipt.instance == third.handoff.instance + with registry._database.transaction() as connection: + assert connection.execute("SELECT count(*) FROM service_transitions").fetchone() == (1,) + + +def test_deferred_foreign_key_rejects_one_sided_successor_change_at_commit(owners): + registry, journal, _, _ = owners + state = journal.prepare("c" * 32, expected=None) + receipt = journal.read_transition() + path = registry._database._directory._root / "registry.sqlite3" + with sqlite3.connect(path) as connection: + connection.execute("PRAGMA foreign_keys=ON") + connection.execute("BEGIN IMMEDIATE") + connection.execute("UPDATE instances SET instance_id=?", ("e" * 32,)) + # A statement can succeed while its deferred constraint remains owed. + with pytest.raises(sqlite3.IntegrityError): + connection.commit() + connection.rollback() + assert journal.read() == state and journal.read_transition() == receipt + + +@pytest.mark.parametrize("initial", [False, True]) +@pytest.mark.parametrize("table", ["instances", "service_transitions"]) +def test_real_exit_between_prepare_writes_keeps_original_pair(owners, initial, table): + from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 + from loushang.apphost.managed.registry import ManagedRegistryV1 + + registry, journal, namespace, service = owners + previous = None if initial else stopped(owners) + receipt = journal.read_transition() + root, fence = registry._database._directory._root, journal._fence._root + stage = ("INSERT INTO " if initial or table == "service_transitions" else "UPDATE ") + table + script = """ +import os, sqlite3, sys +from pathlib import Path +from loushang.apphost.managed.registry import ManagedRegistryV1 +from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 +from loushang.apphost.managed.contracts import ManagedNamespaceV1, ManagedServiceKeyV1 +root, fence, platform, stage = sys.argv[1:] +namespace = ManagedNamespaceV1(platform, os.geteuid(), 'a'*32) +key = ManagedServiceKeyV1('coding', '/workspace') +registry = ManagedRegistryV1(Path(root), namespace) +journal = ManagedServiceJournalV1(registry, namespace, key, Path(fence)) +expected = journal.read() +original = sqlite3.connect +class Crash(sqlite3.Connection): + def execute(self, sql, parameters=()): + result = super().execute(sql, parameters) + if sql.startswith(stage): + os._exit(23) + return result +def connect(*args, **kwargs): + return original(*args, factory=Crash, **kwargs) +sqlite3.connect = connect +journal.prepare('d'*32, expected=expected) +os._exit(24) +""" + env = dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[2] / "src")) + process = subprocess.run([sys.executable, "-c", script, str(root), str(fence), namespace.platform_home, stage], + env=env, capture_output=True, timeout=10) + assert process.returncode == 23, process.stderr + journal.close() + registry.close() + recovered = ManagedRegistryV1(root, namespace, create=True) + reopened = ManagedServiceJournalV1(recovered, namespace, service, fence) + try: + assert reopened.read() == previous and reopened.read_transition() == receipt + finally: + reopened.close() + recovered.close() diff --git a/tests/appserver/test_managed_mux_close_values.py b/tests/appserver/test_managed_mux_close_values.py new file mode 100644 index 000000000..2809607bf --- /dev/null +++ b/tests/appserver/test_managed_mux_close_values.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from dataclasses import replace + +import pytest + + +def request(): + from loushang.appserver.managed_mux_close import ManagedMuxCloseV1 + + return ManagedMuxCloseV1("a" * 64, "b" * 32, "c" * 32, "d" * 32, + "dev", "mux-1", "private-close-authority") + + +@pytest.mark.parametrize("field,value", [ + ("service_id", "a" * 63), ("instance_id", True), + ("operation_id", "bad"), ("creation_operation_id", "c" * 32), + ("name", "../dev"), ("mux_space_id", "mux\n"), ("authority", ""), +]) +def test_close_request_rejects_invalid_or_aliased_identity(field, value): + with pytest.raises(ValueError): + replace(request(), **{field: value}) + + +def test_close_request_does_not_expose_authority_and_is_not_creation_request(): + from loushang.appserver.managed_mux import ManagedMuxCreateV1 + + value = request() + assert "private-close-authority" not in repr(value) + assert not isinstance(value, ManagedMuxCreateV1) + + +def test_close_state_has_closed_phase_and_no_authority(): + from loushang.appserver.managed_mux_close import ( + ManagedMuxClosePhaseV1, + ManagedMuxCloseStateV1, + ) + + pending = ManagedMuxCloseStateV1("c" * 32, "b" * 32, "d" * 32, "dev", "mux-1", + ManagedMuxClosePhaseV1.CLEANUP_PENDING) + closed = replace(pending, phase=ManagedMuxClosePhaseV1.CLOSED) + assert closed.operation_id == pending.operation_id + assert not hasattr(closed, "authority") + for phase in ("closed", "stopped", None, True): + with pytest.raises(ValueError): + replace(pending, phase=phase) + with pytest.raises(ValueError): + replace(pending, creation_operation_id=pending.operation_id) From 5e7f3277b95bb2bae8dedd802bc53ea59d5776dd Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 05:57:36 +0000 Subject: [PATCH 24/44] feat(apphost): budget managed runtime storage --- .../apphost/managed/_capture_native.py | 166 ++++++ src/loushang/apphost/managed/event_log.py | 250 ++++++++ .../apphost/managed/output_capture.py | 271 +++++++++ .../apphost/managed/storage_budget.py | 550 ++++++++++++++++++ src/loushang/apphost/managed/trace_buffer.py | 135 +++++ src/loushang/apphost/managed/trace_log.py | 220 +++++++ tests/apphost/_storage_budget_child.py | 41 ++ tests/apphost/test_managed_event_log.py | 387 ++++++++++++ tests/apphost/test_managed_log_files.py | 231 ++++++++ tests/apphost/test_managed_log_reader.py | 165 ++++++ tests/apphost/test_managed_output_capture.py | 231 ++++++++ tests/apphost/test_managed_storage_budget.py | 197 +++++++ tests/apphost/test_managed_temporary_pair.py | 287 +++++++++ .../apphost/test_managed_temporary_release.py | 147 +++++ .../apphost/test_managed_trace_application.py | 118 ++++ tests/apphost/test_managed_trace_buffer.py | 79 +++ tests/apphost/test_managed_trace_log.py | 181 ++++++ .../apphost/test_managed_uncreated_release.py | 207 +++++++ tests/apphost/test_native_output_capture.py | 141 +++++ 19 files changed, 4004 insertions(+) create mode 100644 src/loushang/apphost/managed/_capture_native.py create mode 100644 src/loushang/apphost/managed/event_log.py create mode 100644 src/loushang/apphost/managed/output_capture.py create mode 100644 src/loushang/apphost/managed/storage_budget.py create mode 100644 src/loushang/apphost/managed/trace_buffer.py create mode 100644 src/loushang/apphost/managed/trace_log.py create mode 100644 tests/apphost/_storage_budget_child.py create mode 100644 tests/apphost/test_managed_event_log.py create mode 100644 tests/apphost/test_managed_log_files.py create mode 100644 tests/apphost/test_managed_log_reader.py create mode 100644 tests/apphost/test_managed_output_capture.py create mode 100644 tests/apphost/test_managed_storage_budget.py create mode 100644 tests/apphost/test_managed_temporary_pair.py create mode 100644 tests/apphost/test_managed_temporary_release.py create mode 100644 tests/apphost/test_managed_trace_application.py create mode 100644 tests/apphost/test_managed_trace_buffer.py create mode 100644 tests/apphost/test_managed_trace_log.py create mode 100644 tests/apphost/test_managed_uncreated_release.py create mode 100644 tests/apphost/test_native_output_capture.py diff --git a/src/loushang/apphost/managed/_capture_native.py b/src/loushang/apphost/managed/_capture_native.py new file mode 100644 index 000000000..561d10a81 --- /dev/null +++ b/src/loushang/apphost/managed/_capture_native.py @@ -0,0 +1,166 @@ +"""Original two-stream storage lifecycle, called by one retained native worker. + +The composing lease serializes calls and retains this owner on cancellation. +Directory lifetime is borrowed; closing a capture never closes a shared root. +""" + +from __future__ import annotations + +from ._files import ( + MAX_RECORD_BYTES, + ManagedDataFileSnapshot, + ManagedStorageError, + PrivateManagedDirectory, + _DataRemoval, +) +from .storage_budget import ( + ManagedStorageAllocationV1, + ManagedStorageBudgetV1, + ManagedStorageReservationV1, + ManagedTemporaryPairCapacityRefusedV1, + _TemporaryCreationAttempt, + _TemporaryRelease, +) + + +class NativeOutputCapture: + def __init__(self, owner: PrivateManagedDirectory, budget: ManagedStorageBudgetV1, + allocations: tuple[ManagedStorageAllocationV1, ManagedStorageAllocationV1]) -> None: + self.owner, self.budget, self.allocations = owner, budget, allocations + self.attempt: _TemporaryCreationAttempt | None = None + self.snapshots: list[ManagedDataFileSnapshot | None] = [None, None] + self.releases: list[_TemporaryRelease | None] = [None, None] + self.removals: list[_DataRemoval | None] = [None, None] + self.released = [False, False] + self.deleted = [False, False] + self.started = self.ready = self.lost = self.sealed = self.closed = False + self.write_unknown = False + + @property + def cleanup_pending(self) -> bool: + return self.started and not self.closed + + def prepare(self) -> bool: + """True means both charged files; False only a known capacity refusal.""" + if self.started or self.closed: + raise ManagedStorageError("conflict") + self.started = True + self.budget.prepare_temporary_creation(self.allocations, owner=self.owner, _retain_attempt=self._retain_attempt) + assert self.attempt is not None + result = self.budget.reserve_temporary_creation(self.attempt) + if isinstance(result, ManagedTemporaryPairCapacityRefusedV1): + self.lost = True + self.close() + return False + assert self.attempt.creations is not None + with self.owner.lock("capture.lock", create=True): + for index, creation in enumerate(self.attempt.creations): + snapshot = self.owner.create_data(creation, binding=self.attempt) + self.snapshots[index] = snapshot + self.budget.bind_file(result[index], snapshot.identity) + self.ready = True + return True + + def _retain_attempt(self, attempt: _TemporaryCreationAttempt) -> None: + self.attempt = attempt + + def append(self, index: int, content: bytes) -> None: + if type(index) is not int or index not in (0, 1) or type(content) is not bytes or len(content) > MAX_RECORD_BYTES: + raise ManagedStorageError("invalid_record") + if self.closed or self.sealed or not self.ready: + raise ManagedStorageError("closed") + if self.lost: + return + snapshot = self.snapshots[index] + assert snapshot is not None and self.attempt is not None and self.attempt.creations is not None + if snapshot.size + len(content) > self.allocations[index].capacity: + self.lost = True # Sticky across both streams; discard neither file here. + return + try: + with self.owner.lock("capture.lock"): + self.snapshots[index] = self.owner.append_data( + self.attempt.creations[index].name, content, expected=snapshot, + capacity=self.allocations[index].capacity, + ) + except BaseException: + self.lost = self.write_unknown = True + raise + + def seal(self) -> tuple[ManagedDataFileSnapshot, ManagedDataFileSnapshot] | None: + if self.closed or not self.ready: + raise ManagedStorageError("closed") + self.sealed = True + if self.lost: + return None + first, second = self.snapshots + assert first is not None and second is not None + return first, second + + def read(self, index: int, *, max_bytes: int) -> bytes: + if type(index) is not int or index not in (0, 1): + raise ManagedStorageError("invalid_record") + if self.closed or not self.sealed or self.lost: + raise ManagedStorageError("closed") + assert self.attempt is not None and self.attempt.creations is not None + snapshot = self.snapshots[index] + assert snapshot is not None + with self.owner.lock("capture.lock"): + return self.owner.read_data(self.attempt.creations[index].name, expected=snapshot, + capacity=self.allocations[index].capacity, max_bytes=max_bytes) + + def close(self) -> None: + if self.closed: + return + self.sealed = True + attempt = self.attempt + if attempt is None: + self.closed = True # No native registration or reservation was returned. + return + if attempt.phase == "unknown": + self.budget.reconcile_temporary_creation(attempt) + if attempt.creations is None: + if attempt.phase != "prepared": + raise ManagedStorageError("unavailable") + attempt.creations = self.owner.pending_creation_pair(binding=attempt) + if attempt.creations is None: + self.closed = True # Original owner confirms no enrollment; reserve never began. + return + for creation in attempt.creations: + self.owner.fence_data_creation(creation, binding=attempt) + if self.write_unknown: + raise ManagedStorageError("unavailable") + if attempt.phase in {"prepared", "refused", "unreserved"}: + if any(creation.phase != "fenced" for creation in attempt.creations): + raise ManagedStorageError("unavailable") + self.closed = True + return + if attempt.phase != "reserved": + raise ManagedStorageError("unavailable") + for index, creation in enumerate(attempt.creations): + if self.released[index]: + continue + if creation.phase == "fenced": + self.budget.release_uncreated_temporary(attempt, index) + elif creation.phase == "created": + release = self.releases[index] + if release is None: + snapshot = self.snapshots[index] or creation.snapshot + assert snapshot is not None + reserved = ManagedStorageReservationV1(attempt.allocation_ids[index], self.allocations[index]) + bound = self.budget.bind_file(reserved, snapshot.identity) + removal = self.removals[index] + if removal is None: + removal = self.owner.prepare_data_removal(creation.name, "removed-" + creation.allocation_id, + expected=snapshot, capacity=creation.capacity) + self.removals[index] = removal + release = self.budget.prepare_temporary_release(bound, owner=self.owner, removal=removal) + self.releases[index] = release + if not self.deleted[index]: + with self.owner.lock("capture.lock"): + self.owner.remove_data(release.removal) + self.deleted[index] = True + self.budget.release_temporary(release) + else: + raise ManagedStorageError("unavailable") + self.released[index] = True + self.closed = True diff --git a/src/loushang/apphost/managed/event_log.py b/src/loushang/apphost/managed/event_log.py new file mode 100644 index 000000000..839857957 --- /dev/null +++ b/src/loushang/apphost/managed/event_log.py @@ -0,0 +1,250 @@ +"""Finite lifecycle diagnostics on borrowed storage, never an audit or permit. + +The process composition must establish instance lifetime before using this +consumer. It retains the original directory owner through all native settlement. +Lock order is log directory then short registry transaction, never the reverse. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from hashlib import sha256 +from threading import TIMEOUT_MAX +from time import monotonic + +from ._files import ( + ManagedDataFileSnapshot, + ManagedStorageError, + PrivateManagedDirectory, + _check_deadline, + _on_event_loop, +) +from .contracts import _HEX32, _HEX64, ManagedContractError, _match +from .paths import LIFECYCLE_LOG_NAMES, MANAGED_LOG_DIRECTORY_NAMES +from .storage_budget import ManagedStorageAllocationV1, ManagedStorageBudgetV1 + +_EVENTS = frozenset({"starting", "ready", "stopping", "stopped", "failed"}) +_CODES = frozenset({"startup_failed", "application_failed", "cleanup_incomplete", "stop_requested"}) +_CAPACITY = 10 * 1024**2 +_FRAME_BYTES = 256 +_MAX_SEQUENCE = 2**63 - 1 +_NAMES = LIFECYCLE_LOG_NAMES +_LOCK = "lifecycle.lock" + + +@dataclass(frozen=True, slots=True) +class ManagedLifecycleEventV1: + event: str + instance_id: str + code: str | None = None + + def __post_init__(self) -> None: + if (type(self.event) is not str or self.event not in _EVENTS + or (self.code is not None and (type(self.code) is not str or self.code not in _CODES))): + raise ManagedContractError() + _match(self.instance_id, _HEX32) + + +def _encode(event: ManagedLifecycleEventV1, sequence: int) -> bytes: + if type(event) is not ManagedLifecycleEventV1: + raise ManagedContractError() + event.__post_init__() + if type(sequence) is not int or not 1 <= sequence <= _MAX_SEQUENCE: + raise ManagedStorageError("capacity") + value = {"v": 1, "event": event.event, "instanceId": event.instance_id, + "code": event.code, "sequence": sequence} + result = json.dumps(value, separators=(",", ":"), ensure_ascii=True).encode("ascii") + b"\n" + if len(result) > _FRAME_BYTES: + raise ManagedStorageError("capacity") + return result + + +def _sequence(snapshot: ManagedDataFileSnapshot) -> int: + if snapshot.size == 0: + return 0 + tail = snapshot.tail + if not tail.endswith(b"\n"): + raise ManagedStorageError("invalid_record") + boundary = tail.rfind(b"\n", 0, len(tail) - 1) + if boundary == -1 and snapshot.size != len(tail): + raise ManagedStorageError("invalid_record") + frame = tail[boundary + 1:] + return _decode_frame(frame).sequence + + +@dataclass(frozen=True, slots=True) +class ManagedLifecycleRecordV1: + sequence: int + event: ManagedLifecycleEventV1 + + def __post_init__(self) -> None: + _encode(self.event, self.sequence) + + +def _decode_frame(frame: bytes) -> ManagedLifecycleRecordV1: + if not 0 < len(frame) <= _FRAME_BYTES: + raise ManagedStorageError("invalid_record") + try: + row = json.loads(frame) + if (type(row) is not dict or set(row) != {"v", "event", "instanceId", "code", "sequence"} + or type(row["v"]) is not int or row["v"] != 1): + raise ValueError() + event = ManagedLifecycleEventV1(row["event"], row["instanceId"], row["code"]) + # Exact canonical bytes also reject duplicate keys, extra whitespace, + # alternate encodings and bool/huge sequence values without text leaks. + if _encode(event, row["sequence"]) != frame: + raise ValueError() + return ManagedLifecycleRecordV1(row["sequence"], event) + except (ValueError, TypeError, KeyError, ManagedStorageError): + raise ManagedStorageError("invalid_record") from None + + +class ManagedLifecycleLogV1: + """Five fixed charged segments. Failure seals this consumer, never retries. + + segment_bytes may lower the physical ceiling for deterministic validation; + durable reservations always charge the full production segment capacity. + No resources or closing rights are transferred from the injected owners. + Synchronous native-worker API: running event loops are rejected before IO. + """ + + def __init__(self, directory: PrivateManagedDirectory, budget: ManagedStorageBudgetV1, + service_id: str, *, segment_bytes: int = _CAPACITY) -> None: + if (type(directory) is not PrivateManagedDirectory or type(budget) is not ManagedStorageBudgetV1 + or type(segment_bytes) is not int or not _FRAME_BYTES <= segment_bytes <= _CAPACITY): + raise ManagedContractError() + _match(service_id, _HEX64) + self._directory, self._budget = directory, budget + self._service_id, self._segment_bytes = service_id, segment_bytes + self._failed = False + + def _allocation(self, slot: int) -> ManagedStorageAllocationV1: + directory = self._directory + directory._check() + return ManagedStorageAllocationV1(self._service_id, "log", None, slot, _CAPACITY, + sha256(os.fsencode(directory._root)).hexdigest(), directory._identity) + + def write(self, event: ManagedLifecycleEventV1, *, deadline: float | None = None) -> int | None: + """Return the sequence, or None when initial lock contention drops this event. + + A dropped event is never replayed. Failures after admission still seal + the writer, including uncertain append and lock-release effects. + """ + _encode(event, _MAX_SEQUENCE) # All event validation precedes IO. + if _on_event_loop(): + raise ManagedStorageError("busy") + # Reuse the native owner's serialization: a waiting call must observe + # failure before admission, including errors during flock release. + _check_deadline(deadline) + timeout = -1 if deadline is None else min(TIMEOUT_MAX, max(0, deadline - monotonic())) + if not self._directory._mutex.acquire(timeout=timeout): + raise ManagedStorageError("busy") + try: + return self._write(event, deadline=deadline) + finally: + self._directory._mutex.release() + + def _write(self, event: ManagedLifecycleEventV1, *, deadline: float | None = None) -> int | None: + _check_deadline(deadline) + if self._failed: + raise ManagedStorageError("closed") + directory = self._directory + admitted = False + try: + with directory.lock(_LOCK, create=True, deadline=deadline, wait_for_lock=deadline is not None): + admitted = True + snapshots = self._snapshots(deadline=deadline) + sequences = [0 if snapshot is None else _sequence(snapshot) for snapshot in snapshots] + nonzero = [value for value in sequences if value] + if len(set(nonzero)) != len(nonzero): + raise ManagedStorageError("invalid_record") + latest = max(sequences) + content = _encode(event, latest + 1) + slot = sequences.index(latest) + snapshot = snapshots[slot] + rotate = snapshot is not None and snapshot.size + len(content) > self._segment_bytes + if rotate: + slot = (slot + 1) % len(_NAMES) + snapshot = snapshots[slot] + if snapshot is None: + allocation = self._allocation(slot) + # Exclusive insert is the current attempt's receipt, not + # an idempotent lookup of an abandoned unbound reservation. + reservation = self._budget.reserve(allocation, exclusive=True, + deadline=deadline, wait_for_lock=deadline is not None) + _check_deadline(deadline) + snapshot = directory.append_data(_NAMES[slot], b"", expected=None, + capacity=self._segment_bytes) + self._budget.bind_file(reservation, snapshot.identity, + deadline=deadline, wait_for_lock=deadline is not None) + _check_deadline(deadline) + directory.append_data(_NAMES[slot], content, expected=snapshot, + capacity=self._segment_bytes, truncate=rotate) + return latest + 1 + except BaseException as error: + if (not admitted and isinstance(error, ManagedStorageError) + and error.code == "busy" and not directory.cleanup_pending): + return None + self._failed = True + raise + + def _snapshots(self, *, deadline: float | None = None) -> list[ManagedDataFileSnapshot | None]: + directory = self._directory + if set(directory.names(limit=len(MANAGED_LOG_DIRECTORY_NAMES))) - MANAGED_LOG_DIRECTORY_NAMES: + raise ManagedStorageError("invalid_record") + result = [] + for slot, name in enumerate(_NAMES): + _check_deadline(deadline) + reservation = self._budget.lookup(self._allocation(slot), deadline=deadline, + wait_for_lock=deadline is not None) + snapshot = directory.data_snapshot(name, capacity=self._segment_bytes) + if reservation is None: + if snapshot is not None: + raise ManagedStorageError("conflict") + elif (reservation.file_identity is None or snapshot is None + or snapshot.identity != reservation.file_identity): + raise ManagedStorageError("conflict") + result.append(snapshot) + _check_deadline(deadline) + return result + + def read_tail(self, *, limit: int = 50, deadline: float | None = None) -> tuple[ManagedLifecycleRecordV1, ...]: + """Read up to limit visible records, not a complete history or audit. + + At most five 16 KiB tails are read. A cut first frame is omitted; every + remaining visible frame is validated before returning any records. + This neither creates a missing lock nor binds, repairs or rotates files. + """ + if type(limit) is not int or not 1 <= limit <= 100: + raise ManagedContractError() + _check_deadline(deadline) + if _on_event_loop(): + raise ManagedStorageError("busy") + with self._directory.lock(_LOCK, deadline=deadline, wait_for_lock=deadline is not None): + records = [] + for snapshot in self._snapshots(deadline=deadline): + if snapshot is None or snapshot.size == 0: + continue + _sequence(snapshot) # Require the final frame to be complete. + frames = snapshot.tail.split(b"\n")[:-1] + if snapshot.size > len(snapshot.tail): + frames = frames[1:] + previous = 0 + for frame in frames: + record = _decode_frame(frame + b"\n") + if record.sequence <= previous: + raise ManagedStorageError("invalid_record") + previous = record.sequence + records.append(record) + sequences = [record.sequence for record in records] + if len(sequences) != len(set(sequences)): + raise ManagedStorageError("invalid_record") + _check_deadline(deadline) + result = tuple(sorted(records, key=lambda record: record.sequence)[-limit:]) + _check_deadline(deadline) + return result + + +__all__ = ["ManagedLifecycleEventV1", "ManagedLifecycleRecordV1", "ManagedLifecycleLogV1"] diff --git a/src/loushang/apphost/managed/output_capture.py b/src/loushang/apphost/managed/output_capture.py new file mode 100644 index 000000000..fe08b160c --- /dev/null +++ b/src/loushang/apphost/managed/output_capture.py @@ -0,0 +1,271 @@ +"""Loop-owned capture lease; native work outlives cancelled public waiters.""" + +from __future__ import annotations + +import asyncio +import os +from collections.abc import Callable +from dataclasses import dataclass +from hashlib import sha256 +from typing import Any, TypeVar + +from loushang.harness.workspace.exec.capture_lease import ( + CapturePreparation, + SealedExecCapture, +) +from loushang.harness.workspace.exec.types import ExecOutputChunk + +from ._capture_native import NativeOutputCapture +from ._files import MAX_RECORD_BYTES, ManagedStorageError, PrivateManagedDirectory +from .connection import _settled_native +from .contracts import ManagedContractError +from .storage_budget import ManagedStorageAllocationV1, ManagedStorageBudgetV1 + +_T = TypeVar("_T") + + +class ManagedOutputCaptureFactory: + """Borrow one instance's opened root; keep slots until leases truly close. + + The composition owns the directory and keeps it alive through factory close. + Constructing a lease performs no filesystem or database IO. + """ + + def __init__(self, owner: PrivateManagedDirectory, budget: ManagedStorageBudgetV1, *, + service_id: str, instance_id: str, capacity: int = 64 * 1024**2) -> None: + if type(owner) is not PrivateManagedDirectory or type(budget) is not ManagedStorageBudgetV1: + raise ManagedContractError() + self._owner, self._budget = owner, budget + self._template = ManagedStorageAllocationV1(service_id, "temporary", instance_id, 0, capacity, + sha256(os.fsencode(owner._root)).hexdigest(), owner._identity) + self._leases: dict[int, ManagedOutputCapture] = {} + self._loop: asyncio.AbstractEventLoop | None = None + self._closing = False + self._settled = False + + @property + def settled(self) -> bool: + """Terminal receipt readable by the outer native dependency owner.""" + return self._settled + + def close_unstarted(self) -> None: + """No-loop failure cleanup; never settle a factory that entered a loop.""" + if self._settled: + return + if self._loop is not None or self._leases: + raise ManagedStorageError("busy") + self._closing = self._settled = True + + def _bind(self) -> None: + loop = asyncio.get_running_loop() + if self._loop is not None and self._loop is not loop: + raise ManagedStorageError("conflict") + self._loop = loop + + def new_capture(self) -> ManagedOutputCapture: + self._bind() + if self._closing: + raise ManagedStorageError("closed") + for existing_slot, lease in tuple(self._leases.items()): + if lease._closed and not lease.cleanup_pending: + del self._leases[existing_slot] + slot = next((value for value in range(8) if value not in self._leases), None) + if slot is None: + raise ManagedStorageError("capacity") + template = self._template + allocations = tuple(ManagedStorageAllocationV1(template.service_id, "temporary", template.instance_id, + slot * 2 + index, template.capacity, template.root_key, template.root_identity) for index in (0, 1)) + lease = ManagedOutputCapture(NativeOutputCapture(self._owner, self._budget, (allocations[0], allocations[1]))) + self._leases[slot] = lease + return lease + + @property + def cleanup_pending(self) -> bool: + self._bind() + return any(not lease._closed or lease.cleanup_pending for lease in self._leases.values()) + + async def close(self) -> None: + self._bind() + self._closing = True + for lease in self._leases.values(): + lease._fence() + failures = [] + for lease in tuple(self._leases.values()): + try: + await lease.close() + except Exception as error: + failures.append(error) + if failures: + raise failures[0] + self._settled = True + + +@dataclass(frozen=True, slots=True) +class _SealedSource: + lease: ManagedOutputCapture + index: int + size_bytes: int + + async def read_bytes(self, *, max_bytes: int) -> bytes: + return await self.lease._read(self.index, max_bytes=max_bytes) + + +class ManagedOutputCapture: + """One original storage owner, bounded admissions, one serialized worker.""" + + def __init__(self, native: NativeOutputCapture) -> None: + self._native = native + self._loop: asyncio.AbstractEventLoop | None = None + self._serial = asyncio.Lock() + self._pending: set[asyncio.Task[Any]] = set() + self._prepare_task: asyncio.Task[bool] | None = None + self._seal_task: asyncio.Task[Any] | None = None + self._close_task: asyncio.Task[None] | None = None + self._reads: list[asyncio.Task[bytes] | None] = [None, None] + self._sealed: SealedExecCapture | None = None + self._accepting = True + self._refused = False + self._closing = self._closed = self._unknown = False + + def _bind(self) -> asyncio.AbstractEventLoop: + loop = asyncio.get_running_loop() + if self._loop is not None and self._loop is not loop: + raise ManagedStorageError("conflict") + self._loop = loop + return loop + + def _submit(self, function: Callable[[], _T]) -> asyncio.Task[_T]: + loop = self._bind() + if self._unknown: + raise ManagedStorageError("unavailable") + if len(self._pending) >= 8: + raise ManagedStorageError("capacity") + + async def run() -> _T: + async with self._serial: + if self._unknown: + raise ManagedStorageError("unavailable") + try: + return await _settled_native(function) + except asyncio.CancelledError: + self._unknown = True # Not a native completion receipt. + raise + + coroutine = run() + try: + # Trusted internal owner: public loop task factories cannot detach + # the native callable from the task retained by this lease. + task = asyncio.Task(coroutine, loop=loop) + except BaseException: + coroutine.close() + raise + self._pending.add(task) + + def completed(done: asyncio.Task[_T]) -> None: + if done.cancelled(): + self._unknown = True + return + done.exception() # Observe failure even if every waiter departed. + self._pending.discard(done) + + task.add_done_callback(completed) + return task + + async def prepare(self) -> CapturePreparation: + self._bind() + if self._closing: + raise ManagedStorageError("closed") + if self._prepare_task is None: + self._prepare_task = self._submit(self._native.prepare) + ready = await asyncio.shield(self._prepare_task) + if self._closing: + raise ManagedStorageError("closed") + if not ready: + self._refused = True + self._accepting = False + return CapturePreparation.READY if ready else CapturePreparation.RETENTION_UNAVAILABLE + + def stop_accepting(self) -> None: + self._bind() + self._accepting = False + + async def append(self, chunk: ExecOutputChunk) -> None: + self._bind() + if not self._accepting or self._closing: + return + if (type(chunk) is not ExecOutputChunk or chunk.stream not in ("stdout", "stderr") + or type(chunk.text) is not str or len(chunk.text) > MAX_RECORD_BYTES): + raise ManagedStorageError("invalid_record") + content = chunk.text.encode("utf-8", errors="surrogateescape") + index = 0 if chunk.stream == "stdout" else 1 + + def append() -> None: + for start in range(0, len(content), MAX_RECORD_BYTES): + self._native.append(index, content[start:start + MAX_RECORD_BYTES]) + + await asyncio.shield(self._submit(append)) + + async def seal(self) -> SealedExecCapture | None: + self._bind() + if self._closing: + raise ManagedStorageError("closed") + self._accepting = False + if self._refused: + return None + if self._seal_task is None: + self._seal_task = self._submit(self._native.seal) + snapshots = await asyncio.shield(self._seal_task) + if self._closing: + raise ManagedStorageError("closed") + if snapshots is None: + return None + if self._sealed is None: + self._sealed = SealedExecCapture(_SealedSource(self, 0, snapshots[0].size), + _SealedSource(self, 1, snapshots[1].size)) + return self._sealed + + async def _read(self, index: int, *, max_bytes: int) -> bytes: + self._bind() + if self._closing or self._sealed is None: + raise ManagedStorageError("closed") + prior = self._reads[index] + if prior is not None and not prior.done(): + raise ManagedStorageError("busy") + task = self._submit(lambda: self._native.read(index, max_bytes=max_bytes)) + self._reads[index] = task + return await asyncio.shield(task) + + @property + def cleanup_pending(self) -> bool: + self._bind() + return not self._closed and (bool(self._pending) or self._unknown or self._close_task is not None + or self._native.cleanup_pending) + + async def close(self) -> None: + loop = self._bind() + self._fence() + if self._closed: + return + task = self._close_task + if task is None or (task.done() and not task.cancelled() and task.exception() is not None): + task = asyncio.Task(self._close(), loop=loop) + self._close_task = task + await asyncio.shield(task) + + def _fence(self) -> None: + self._bind() + self._closing = True + self._accepting = False + + async def _close(self) -> None: + pending = tuple(self._pending) + if pending: + await asyncio.gather(*(asyncio.shield(task) for task in pending), return_exceptions=True) + if self._unknown: + raise ManagedStorageError("unavailable") + await asyncio.shield(self._submit(self._native.close)) + if self._native.cleanup_pending: + raise ManagedStorageError("unavailable") + self._reads = [None, None] + self._sealed = None + self._closed = True diff --git a/src/loushang/apphost/managed/storage_budget.py b/src/loushang/apphost/managed/storage_budget.py new file mode 100644 index 000000000..0dee3e96d --- /dev/null +++ b/src/loushang/apphost/managed/storage_budget.py @@ -0,0 +1,550 @@ +"""Durable storage accounting on the original registry; never a write permit. + +The native file owner must separately establish exclusive lifetime, path/file +identity, capacity and complete IO settlement. This borrowed component neither +creates files nor releases reservations based on absence or process identity. +""" + +from __future__ import annotations + +from _thread import LockType +from collections.abc import Callable +from dataclasses import dataclass, field +from secrets import token_hex +from threading import Lock +from typing import Literal + +from ._database import _MAX_TEMPORARY_SEQUENCE, _TEMPORARY_ID_PREFIX +from ._files import ( + ManagedRemovalTarget, + ManagedStorageError, + PrivateManagedDirectory, + _check_deadline, + _DataCreation, + _DataRemoval, + _on_event_loop, +) +from .contracts import _HEX32, _HEX64, ManagedContractError, _match +from .registry import ManagedRegistryV1 + +MIB = 1024 * 1024 +MAX_ALLOCATIONS = 4096 +LOG_NAMESPACE_BYTES = 200 * MIB +TEMPORARY_NAMESPACE_BYTES = 512 * MIB +TEMPORARY_INSTANCE_BYTES = 128 * MIB +StorageKind = Literal["log", "trace", "temporary"] +_COLUMNS = "allocation_id,service_id,kind,scope,slot,capacity,root_key,root_device,root_inode,file_device,file_inode" + + +def _identity(value: tuple[int, int]) -> None: + if (type(value) is not tuple or len(value) != 2 + or any(type(part) is not int or not 0 <= part < 2**63 for part in value) + or value[1] == 0): + raise ManagedContractError() + + +@dataclass(frozen=True, slots=True) +class ManagedStorageAllocationV1: + """Exact accounting destination, not permission to adopt its directory.""" + + service_id: str + kind: StorageKind + instance_id: str | None + slot: int + capacity: int + root_key: str = field(repr=False) + root_identity: tuple[int, int] = field(repr=False) + + def __post_init__(self) -> None: + _match(self.service_id, _HEX64) + _match(self.root_key, _HEX64) + _identity(self.root_identity) + if type(self.slot) is not int or type(self.capacity) is not int: + raise ManagedContractError() + if self.kind == "temporary": + if not isinstance(self.instance_id, str): + raise ManagedContractError() + _match(self.instance_id, _HEX32) + if not 0 <= self.slot < 256 or not 4096 <= self.capacity <= TEMPORARY_INSTANCE_BYTES or self.capacity % 4096: + raise ManagedContractError() + elif self.kind in ("log", "trace"): + if self.instance_id is not None or self.capacity != 10 * MIB or not 0 <= self.slot < (5 if self.kind == "log" else 2): + raise ManagedContractError() + else: + raise ManagedContractError() + + def _key(self) -> tuple[str, str, str, int]: + return self.service_id, self.kind, self.instance_id or "", self.slot + + +@dataclass(frozen=True, slots=True) +class ManagedStorageReservationV1: + """Charged capacity, with an optional one-time native identity receipt.""" + + allocation_id: str + allocation: ManagedStorageAllocationV1 + file_identity: tuple[int, int] | None = field(default=None, repr=False) + + def __post_init__(self) -> None: + _match(self.allocation_id, _HEX32) + if type(self.allocation) is not ManagedStorageAllocationV1: + raise ManagedContractError() + if self.file_identity is not None: + _identity(self.file_identity) + + +@dataclass(frozen=True, slots=True) +class ManagedTemporaryPairCapacityRefusedV1: + """This budget attempt allocated neither slot; not native no-effect proof.""" + + allocations: tuple[ManagedStorageAllocationV1, ManagedStorageAllocationV1] + namespace_key: str + allocation_ids: tuple[str, str] + + def __post_init__(self) -> None: + _temporary_pair(self.allocations) + _match(self.namespace_key, _HEX64) + _pair_ids(self.allocation_ids) + + +@dataclass(frozen=True, slots=True) +class _TemporaryRelease: + database: object = field(repr=False) + reservation: ManagedStorageReservationV1 + owner: PrivateManagedDirectory = field(repr=False) + removal: _DataRemoval = field(repr=False) + target: ManagedRemovalTarget + + +@dataclass(eq=False, slots=True) +class _TemporaryCreationAttempt: + database: object = field(repr=False) + owner: PrivateManagedDirectory = field(repr=False) + allocations: tuple[ManagedStorageAllocationV1, ManagedStorageAllocationV1] + allocation_ids: tuple[str, str] + origin_id: str = field(default_factory=lambda: token_hex(16), repr=False) + creations: tuple[_DataCreation, _DataCreation] | None = field(default=None, repr=False) + phase: str = "prepared" + lock: LockType = field(default_factory=Lock, repr=False) + + +class ManagedStorageBudgetV1: + """Borrow the original bounded transactions, without IO/task ownership. + + reserve is idempotent for the *entire* destination, including root identity + and size. Unknown receipts remain charged and can only be looked up using + that same destination; a returned row alone never authorizes file creation. + exclusive=True instead requires a new row in this transaction; an existing + unbound allocation is never a fresh-creation receipt. + """ + + def __init__(self, registry: ManagedRegistryV1) -> None: + if type(registry) is not ManagedRegistryV1: + raise ManagedContractError() + self._database = registry._database + + def next_temporary_pair_ids(self, *, deadline: float | None = None, + wait_for_lock: bool = False, after_id: str | None = None) -> tuple[str, str]: + """Read candidates, not a reservation; concurrent observations can stale.""" + after = 0 if after_id is None else _temporary_sequence(after_id) + with self._database.transaction(deadline=deadline, wait_for_lock=wait_for_lock) as connection: + high_water = connection.execute("SELECT temporary_high_water FROM identity").fetchone()[0] + result = _next_temporary_ids(max(high_water, after), 2) + _check_deadline(deadline) + return result[0], result[1] + + def prepare_temporary_creation(self, allocations: tuple[ManagedStorageAllocationV1, ManagedStorageAllocationV1], *, + owner: PrivateManagedDirectory, deadline: float | None = None, + wait_for_lock: bool = False, + _retain_attempt: Callable[[_TemporaryCreationAttempt], None] | None = None + ) -> _TemporaryCreationAttempt: + """Retain this original attempt before reserve IO or native creation. + + This does not accept recovered reservations. Unknown reserve receipts + remain debt; they are not converted to fresh creation/refund authority. + """ + _temporary_pair(allocations) + if type(owner) is not PrivateManagedDirectory or allocations[0].capacity != allocations[1].capacity: + raise ManagedContractError() + ids = self.next_temporary_pair_ids(deadline=deadline, wait_for_lock=wait_for_lock, + after_id=owner.creation_high_water(deadline=deadline)) + attempt = _TemporaryCreationAttempt(self._database, owner, allocations, ids) + if _retain_attempt is not None: + _retain_attempt(attempt) + pair = owner.prepare_data_creation_pair(ids, capacity=allocations[0].capacity, binding=attempt, deadline=deadline) + attempt.creations = pair + try: + for index in (0, 1): + self._creation_attempt_target(attempt, index, deadline=deadline) + except BaseException: + for creation in pair: + owner.fence_data_creation(creation, binding=attempt) + raise + return attempt + + def _creation_attempt_target(self, attempt: _TemporaryCreationAttempt, index: int, *, + fenced: bool = False, deadline: float | None = None) -> None: + if (type(attempt) is not _TemporaryCreationAttempt or attempt.database is not self._database + or attempt.creations is None or type(index) is not int or index not in (0, 1)): + raise ManagedContractError() + target = attempt.owner.creation_target(attempt.creations[index], binding=attempt, + require_fenced=fenced, deadline=deadline) + allocation = attempt.allocations[index] + if (target.root_key, target.root_identity, target.allocation_id, target.capacity) != ( + allocation.root_key, allocation.root_identity, attempt.allocation_ids[index], allocation.capacity): + raise ManagedStorageError("conflict") + + def reserve_temporary_creation(self, attempt: _TemporaryCreationAttempt, *, deadline: float | None = None, + wait_for_lock: bool = False + ) -> tuple[ManagedStorageReservationV1, ManagedStorageReservationV1] | ManagedTemporaryPairCapacityRefusedV1: + if type(attempt) is not _TemporaryCreationAttempt or attempt.database is not self._database: + raise ManagedContractError() + _check_deadline(deadline) + if _on_event_loop() or not attempt.lock.acquire(blocking=False): + raise ManagedStorageError("busy") + try: + if attempt.phase != "prepared": + raise ManagedStorageError("conflict") + for index in (0, 1): + self._creation_attempt_target(attempt, index, deadline=deadline) + attempt.phase = "unknown" + result = self.reserve_temporary_pair(attempt.allocations, allocation_ids=attempt.allocation_ids, + deadline=deadline, wait_for_lock=wait_for_lock, + _creation_origin=attempt.origin_id) + attempt.phase = "refused" if type(result) is ManagedTemporaryPairCapacityRefusedV1 else "reserved" + return result + finally: + attempt.lock.release() + + def reconcile_temporary_creation(self, attempt: _TemporaryCreationAttempt, *, deadline: float | None = None, + wait_for_lock: bool = False + ) -> tuple[ManagedStorageReservationV1, ManagedStorageReservationV1] | None: + """Read original transaction provenance; never reconstruct an attempt.""" + if type(attempt) is not _TemporaryCreationAttempt or attempt.database is not self._database: + raise ManagedContractError() + _check_deadline(deadline) + if _on_event_loop() or not attempt.lock.acquire(blocking=False): + raise ManagedStorageError("busy") + try: + if attempt.phase != "unknown": + raise ManagedStorageError("conflict") + found: list[ManagedStorageReservationV1 | None] = [] + with self._database.transaction(deadline=deadline, wait_for_lock=wait_for_lock) as connection: + for index in (0, 1): + allocation_id = attempt.allocation_ids[index] + row = connection.execute(f"SELECT {_COLUMNS} FROM storage_allocations WHERE allocation_id=?", + (allocation_id,)).fetchone() + if row is None: + found.append(None) + continue + origin = connection.execute("SELECT origin_id FROM storage_creation_origins WHERE allocation_id=?", + (allocation_id,)).fetchone() + expected = ManagedStorageReservationV1(allocation_id, attempt.allocations[index]) + if origin != (attempt.origin_id,) or _decode(row, expected.allocation) != expected: + raise ManagedStorageError("conflict") + found.append(expected) + _check_deadline(deadline) + if found[0] is None and found[1] is None: + attempt.phase = "unreserved" + return None + if found[0] is None or found[1] is None: + raise ManagedStorageError("conflict") + attempt.phase = "reserved" + return found[0], found[1] + finally: + attempt.lock.release() + + def release_uncreated_temporary(self, attempt: _TemporaryCreationAttempt, index: int, *, + deadline: float | None = None, wait_for_lock: bool = False) -> None: + """Refund an exact successful original reserve, never a supplied row.""" + if type(attempt) is not _TemporaryCreationAttempt or attempt.database is not self._database: + raise ManagedContractError() + _check_deadline(deadline) + if _on_event_loop() or not attempt.lock.acquire(blocking=False): + raise ManagedStorageError("busy") + try: + if attempt.phase != "reserved": + raise ManagedStorageError("unavailable") + self._creation_attempt_target(attempt, index, fenced=True, deadline=deadline) + expected = ManagedStorageReservationV1(attempt.allocation_ids[index], attempt.allocations[index]) + with self._database.transaction(write=True, deadline=deadline, wait_for_lock=wait_for_lock) as connection: + row = connection.execute(f"SELECT {_COLUMNS} FROM storage_allocations WHERE allocation_id=?", + (expected.allocation_id,)).fetchone() + if row is not None: + if _decode(row, expected.allocation) != expected: + raise ManagedStorageError("conflict") + self._database.admit_growth(connection) + connection.execute("DELETE FROM storage_allocations WHERE allocation_id=?", (expected.allocation_id,)) + _check_deadline(deadline) + finally: + attempt.lock.release() + + def prepare_temporary_release(self, reservation: ManagedStorageReservationV1, *, + owner: PrivateManagedDirectory, removal: _DataRemoval) -> _TemporaryRelease: + """Bind the exact native target before removal, without database IO.""" + if (type(reservation) is not ManagedStorageReservationV1 + or reservation.allocation.kind != "temporary" or reservation.file_identity is None + or type(owner) is not PrivateManagedDirectory): + raise ManagedContractError() + target = owner.removal_target(removal) + _release_target(reservation, target) + binding = _TemporaryRelease(self._database, reservation, owner, removal, target) + owner.bind_data_removal(removal, binding) + return binding + + def release_temporary(self, binding: _TemporaryRelease, *, deadline: float | None = None, + wait_for_lock: bool = False) -> None: + """Release only the original charge; never delete files or choose a slot.""" + if type(binding) is not _TemporaryRelease or binding.database is not self._database: + raise ManagedContractError() + _check_deadline(deadline) + target = binding.owner.completed_removal_target(binding.removal, binding, deadline=deadline) + if target != binding.target: + raise ManagedStorageError("conflict") + reservation = binding.reservation + _release_target(reservation, target) + with self._database.transaction(write=True, deadline=deadline, wait_for_lock=wait_for_lock) as connection: + row = connection.execute(f"SELECT {_COLUMNS} FROM storage_allocations WHERE allocation_id=?", + (reservation.allocation_id,)).fetchone() + if row is not None: + previous = _decode(row, reservation.allocation) + if previous != reservation: + raise ManagedStorageError("conflict") + # Deletion still dirties pages and needs rollback-journal space. + # Preserve the reserved stop/control headroom. + self._database.admit_growth(connection) + connection.execute("DELETE FROM storage_allocations WHERE allocation_id=?", (reservation.allocation_id,)) + _check_deadline(deadline) + + def lookup(self, allocation: ManagedStorageAllocationV1, *, deadline: float | None = None, + wait_for_lock: bool = False) -> ManagedStorageReservationV1 | None: + _allocation(allocation) + with self._database.transaction(deadline=deadline, wait_for_lock=wait_for_lock) as connection: + row = connection.execute(f"SELECT {_COLUMNS} FROM storage_allocations " + "WHERE service_id=? AND kind=? AND scope=? AND slot=?", allocation._key()).fetchone() + return None if row is None else _decode(row, allocation) + + def lookup_temporary_pair(self, allocations: tuple[ManagedStorageAllocationV1, ManagedStorageAllocationV1], *, + allocation_ids: tuple[str, str], deadline: float | None = None, + wait_for_lock: bool = False, + ) -> tuple[ManagedStorageReservationV1, ManagedStorageReservationV1] | None: + """Observe both original IDs atomically; absence is not native proof. + + Partial, substituted or retargeted pairs conflict. This does not reserve, + repair, bind or authorize creation, even when both original rows exist. + """ + _temporary_pair(allocations) + _pair_ids(allocation_ids) + with self._database.transaction(deadline=deadline, wait_for_lock=wait_for_lock) as connection: + found: list[ManagedStorageReservationV1 | None] = [] + for allocation, allocation_id in zip(allocations, allocation_ids, strict=True): + rows = connection.execute(f"SELECT {_COLUMNS} FROM storage_allocations WHERE " + "(service_id=? AND kind=? AND scope=? AND slot=?) OR allocation_id=?", + (*allocation._key(), allocation_id)).fetchall() + if not rows: + found.append(None) + continue + if len(rows) != 1: + raise ManagedStorageError("conflict") + reservation = _decode(rows[0], allocation) + if reservation.allocation_id != allocation_id: + raise ManagedStorageError("conflict") + found.append(reservation) + if found[0] is None and found[1] is None: + result = None + elif found[0] is None or found[1] is None: + raise ManagedStorageError("conflict") + else: + result = (found[0], found[1]) + _check_deadline(deadline) + return result + + def reserve(self, allocation: ManagedStorageAllocationV1, *, deadline: float | None = None, + wait_for_lock: bool = False, exclusive: bool = False) -> ManagedStorageReservationV1: + _allocation(allocation) + if type(exclusive) is not bool: + raise ManagedContractError() + with self._database.transaction(write=True, deadline=deadline, wait_for_lock=wait_for_lock) as connection: + row = connection.execute(f"SELECT {_COLUMNS} FROM storage_allocations " + "WHERE service_id=? AND kind=? AND scope=? AND slot=?", allocation._key()).fetchone() + if row is not None: + if exclusive: + raise ManagedStorageError("conflict") + return _decode(row, allocation) + if connection.execute("SELECT 1 FROM services WHERE service_id=?", (allocation.service_id,)).fetchone() is None: + raise ManagedStorageError("not_found") + if connection.execute("SELECT count(*) FROM storage_allocations").fetchone()[0] >= MAX_ALLOCATIONS: + raise ManagedStorageError("capacity") + temporary = allocation.kind == "temporary" + used = connection.execute("SELECT coalesce(sum(capacity),0) FROM storage_allocations " + + ("WHERE kind='temporary'" if temporary else "WHERE kind IN ('log','trace')")).fetchone()[0] + limit = TEMPORARY_NAMESPACE_BYTES if temporary else LOG_NAMESPACE_BYTES + if used + allocation.capacity > limit: + raise ManagedStorageError("capacity") + if temporary: + used = connection.execute("SELECT coalesce(sum(capacity),0) FROM storage_allocations " + "WHERE service_id=? AND kind='temporary' AND scope=?", + (allocation.service_id, allocation.instance_id)).fetchone()[0] + if used + allocation.capacity > TEMPORARY_INSTANCE_BYTES: + raise ManagedStorageError("capacity") + self._database.admit_growth(connection) # Never consume stop/control headroom. + if temporary: + high_water = connection.execute("SELECT temporary_high_water FROM identity").fetchone()[0] + allocation_id = _next_temporary_ids(high_water, 1)[0] + connection.execute("UPDATE identity SET temporary_high_water=?", (high_water + 1,)) + else: + allocation_id = token_hex(16) + if allocation_id.startswith(_TEMPORARY_ID_PREFIX): + raise ManagedStorageError("conflict") + result = ManagedStorageReservationV1(allocation_id, allocation) + connection.execute("INSERT INTO storage_allocations VALUES (?,?,?,?,?,?,?,?,?,NULL,NULL)", + (result.allocation_id, *allocation._key(), allocation.capacity, allocation.root_key, *allocation.root_identity)) + return result + + def reserve_temporary_pair(self, allocations: tuple[ManagedStorageAllocationV1, ManagedStorageAllocationV1], *, + allocation_ids: tuple[str, str], + deadline: float | None = None, wait_for_lock: bool = False, + _creation_origin: str | None = None, + ) -> tuple[ManagedStorageReservationV1, ManagedStorageReservationV1] | ManagedTemporaryPairCapacityRefusedV1: + """Exclusive atomic pair, with explicit logical-capacity refusal only. + + Existing slots conflict before capacity checks. Unknown commit/close or + native growth failures never become refusal. Reconcile the original pair + after uncertainty; do not adopt one slot or retry with new destinations. + """ + _temporary_pair(allocations) + _pair_ids(allocation_ids) + if _creation_origin is not None: + _match(_creation_origin, _HEX32) + first, second = allocations + with self._database.transaction(write=True, deadline=deadline, wait_for_lock=wait_for_lock) as connection: + high_water = connection.execute("SELECT temporary_high_water FROM identity").fetchone()[0] + first_sequence, second_sequence = map(_temporary_sequence, allocation_ids) + if first_sequence <= high_water or second_sequence != first_sequence + 1: + raise ManagedStorageError("conflict") + for allocation in allocations: + if connection.execute("SELECT 1 FROM storage_allocations WHERE service_id=? AND kind=? AND scope=? AND slot=?", + allocation._key()).fetchone() is not None: + raise ManagedStorageError("conflict") + if connection.execute("SELECT 1 FROM storage_allocations WHERE allocation_id IN (?,?)", + allocation_ids).fetchone() is not None: + raise ManagedStorageError("conflict") + if connection.execute("SELECT 1 FROM services WHERE service_id=?", (first.service_id,)).fetchone() is None: + raise ManagedStorageError("not_found") + rows = connection.execute("SELECT count(*) FROM storage_allocations").fetchone()[0] + total = connection.execute("SELECT coalesce(sum(capacity),0) FROM storage_allocations " + "WHERE kind='temporary'").fetchone()[0] + instance = connection.execute("SELECT coalesce(sum(capacity),0) FROM storage_allocations " + "WHERE service_id=? AND kind='temporary' AND scope=?", (first.service_id, first.instance_id)).fetchone()[0] + capacity = first.capacity + second.capacity + result: tuple[ManagedStorageReservationV1, ManagedStorageReservationV1] | ManagedTemporaryPairCapacityRefusedV1 + if (rows + 2 > MAX_ALLOCATIONS or total + capacity > TEMPORARY_NAMESPACE_BYTES + or instance + capacity > TEMPORARY_INSTANCE_BYTES): + result = ManagedTemporaryPairCapacityRefusedV1(allocations, self._database._namespace, allocation_ids) + else: + self._database.admit_growth(connection) + result = (ManagedStorageReservationV1(allocation_ids[0], first), + ManagedStorageReservationV1(allocation_ids[1], second)) + for reservation in result: + allocation = reservation.allocation + connection.execute("INSERT INTO storage_allocations VALUES (?,?,?,?,?,?,?,?,?,NULL,NULL)", + (reservation.allocation_id, *allocation._key(), allocation.capacity, + allocation.root_key, *allocation.root_identity)) + if _creation_origin is not None: + connection.execute("INSERT INTO storage_creation_origins VALUES (?,?)", + (reservation.allocation_id, _creation_origin)) + connection.execute("UPDATE identity SET temporary_high_water=?", (second_sequence,)) + _check_deadline(deadline) + return result + + def bind_file(self, reservation: ManagedStorageReservationV1, identity: tuple[int, int], *, + deadline: float | None = None, wait_for_lock: bool = False) -> ManagedStorageReservationV1: + """Record the native owner's completed create, never inspect/adopt a file. + + The caller must retain its original creation owner across unknown IO. + This cannot turn an unbound row or a missing filename into clean proof. + """ + if type(reservation) is not ManagedStorageReservationV1: + raise ManagedContractError() + _identity(identity) + with self._database.transaction(write=True, deadline=deadline, wait_for_lock=wait_for_lock) as connection: + row = connection.execute(f"SELECT {_COLUMNS} FROM storage_allocations WHERE allocation_id=?", + (reservation.allocation_id,)).fetchone() + if row is None: + raise ManagedStorageError("not_found") + previous = _decode(row, reservation.allocation) + if previous.file_identity is not None and previous.file_identity != identity: + raise ManagedStorageError("conflict") + if reservation.file_identity is not None and reservation.file_identity != identity: + raise ManagedStorageError("conflict") + if previous.file_identity is None: + self._database.admit_growth(connection) + connection.execute("UPDATE storage_allocations SET file_device=?,file_inode=? WHERE allocation_id=?", + (*identity, reservation.allocation_id)) + result = ManagedStorageReservationV1(reservation.allocation_id, reservation.allocation, identity) + return result + + +def _release_target(reservation: ManagedStorageReservationV1, target: ManagedRemovalTarget) -> None: + allocation = reservation.allocation + if (allocation.kind != "temporary" or reservation.file_identity is None + or (allocation.root_key, allocation.root_identity, reservation.file_identity, allocation.capacity) + != (target.root_key, target.root_identity, target.file_identity, target.capacity)): + raise ManagedStorageError("conflict") + + +def _temporary_sequence(allocation_id: str) -> int: + _match(allocation_id, _HEX32) + if not allocation_id.startswith(_TEMPORARY_ID_PREFIX): + raise ManagedStorageError("conflict") + sequence = int(allocation_id[16:], 16) + if not 1 <= sequence <= _MAX_TEMPORARY_SEQUENCE: + raise ManagedStorageError("conflict") + return sequence + + +def _next_temporary_ids(high_water: int, count: int) -> tuple[str, ...]: + if type(high_water) is not int or not 0 <= high_water <= _MAX_TEMPORARY_SEQUENCE: + raise ManagedStorageError("invalid_record") + if high_water > _MAX_TEMPORARY_SEQUENCE - count: + raise ManagedStorageError("capacity") + return tuple(_TEMPORARY_ID_PREFIX + f"{value:016x}" + for value in range(high_water + 1, high_water + count + 1)) + + +def _allocation(value: ManagedStorageAllocationV1) -> None: + if type(value) is not ManagedStorageAllocationV1: + raise ManagedContractError() + + +def _temporary_pair(values: tuple[ManagedStorageAllocationV1, ManagedStorageAllocationV1]) -> None: + if type(values) is not tuple or len(values) != 2: + raise ManagedContractError() + for value in values: + _allocation(value) + first, second = values + if (first.kind != "temporary" or second.kind != "temporary" or first.slot == second.slot + or (first.service_id, first.instance_id, first.root_key, first.root_identity) + != (second.service_id, second.instance_id, second.root_key, second.root_identity)): + raise ManagedContractError() + + +def _pair_ids(values: tuple[str, str]) -> None: + if type(values) is not tuple or len(values) != 2: + raise ManagedContractError() + for value in values: + _match(value, _HEX32) + if values[0] == values[1]: + raise ManagedContractError() + + +def _decode(row: tuple, expected: ManagedStorageAllocationV1) -> ManagedStorageReservationV1: + try: + allocation = ManagedStorageAllocationV1(row[1], row[2], row[3] or None, row[4], row[5], row[6], (row[7], row[8])) + file_identity = None if row[9] is None and row[10] is None else (row[9], row[10]) + result = ManagedStorageReservationV1(row[0], allocation, file_identity) + except (ValueError, TypeError, IndexError): + raise ManagedStorageError("invalid_record") from None + if allocation != expected: + raise ManagedStorageError("conflict") + return result diff --git a/src/loushang/apphost/managed/trace_buffer.py b/src/loushang/apphost/managed/trace_buffer.py new file mode 100644 index 000000000..4f74d0b48 --- /dev/null +++ b/src/loushang/apphost/managed/trace_buffer.py @@ -0,0 +1,135 @@ +"""Bounded, content-free trace ingress; no IO, scheduling or resource ownership. + +The original child diagnostics owner polls this buffer. Producers never enqueue +loop callbacks, so contention cannot build an unbounded notification backlog. +Dequeuing is not a persistence receipt; the writer must recheck the deadline. +""" + +from __future__ import annotations + +import json +import math +from collections import deque +from collections.abc import Callable +from threading import Lock +from time import monotonic + +from loushang.foundation.observability.records import DebugEventRecord, ProblemRecord + +from .contracts import _HEX32, ManagedContractError, _match + +_CODES = frozenset({"startup_failed", "application_failed", "cleanup_incomplete"}) +_MAX_COUNT = 2**31 - 1 + + +class ManagedTraceBuffer: + """Borrowed Foundation sink with bounded, nonblocking producer admission. + + Only aggregate turn timing and closed managed error codes are projected. + Arbitrary record fields are neither copied nor recursively sanitized. + Dropped counts exclude lock contention and are therefore lower bounds. + """ + + def __init__(self, instance_id: str, deadline: float, *, + clock: Callable[[], float] = monotonic, + max_records: int = 128, max_bytes: int = 65536) -> None: + _match(instance_id, _HEX32) + if (type(deadline) not in (int, float) or not 0 <= deadline <= 2**53 + or not math.isfinite(deadline) + or type(max_records) is not int or not 1 <= max_records <= 128 + or type(max_bytes) is not int or not 512 <= max_bytes <= 65536): + raise ManagedContractError() + self._instance_id, self._deadline = instance_id, deadline + self._clock = clock + self._max_records, self._max_bytes = max_records, max_bytes + self._lock = Lock() + self._queue: deque[bytes] = deque() + self._bytes = self._dropped = 0 + self._closed = False + + @property + def deadline(self) -> float: + return self._deadline + + def write_problem(self, record: ProblemRecord) -> None: + if type(record) is not ProblemRecord: + return + code = record.code + if type(code) is str and len(code) <= 32 and code in _CODES: + self._offer({"event": "problem", "code": code}) + + def write_debug_event(self, record: DebugEventRecord) -> None: + if (type(record) is not DebugEventRecord + or type(record.scope) is not str or record.scope != "turn.start.performance" + or type(record.name) is not str or record.name != "turn" + or type(record.data) is not dict): + return + # Fixed lookups only: no iteration/copy of provider metadata, arbitrary + # nested milestones, Session IDs, prompt content or mutable objects. + value: dict[str, str | int | float] = {"event": "turn"} + for key in ("total_ms", "startup_ms", "local_ready_ms"): + duration = record.data.get(key) + if ((type(duration) is int or type(duration) is float) and 0 <= duration <= 86400000 + and math.isfinite(duration)): + value[key] = duration + if len(value) > 1: + self._offer(value) + + def _offer(self, value: dict[str, str | int | float]) -> None: + # Projection above is a fixed amount of pure work. Never block a caller + # on a native worker, file lock, queue capacity or another producer. + if not self._lock.acquire(blocking=False): + return + try: + if self._closed or self._clock() >= self.deadline: + self._drop() + return + frame = (json.dumps({"v": 1, "instanceId": self._instance_id, **value}, + separators=(",", ":"), allow_nan=False) + "\n").encode("ascii") + if (len(frame) > 512 or len(self._queue) >= self._max_records + or self._bytes + len(frame) > self._max_bytes): + self._drop() + return + self._queue.append(frame) + self._bytes += len(frame) + finally: + self._lock.release() + + def _drop(self, count: int = 1) -> None: + self._dropped = min(_MAX_COUNT, self._dropped + count) + + def take(self) -> bytes | None: + """Take at most one frame; the original writer checks expiry again.""" + if not self._lock.acquire(blocking=False): + return None + try: + if self._clock() >= self.deadline: + self._closed = True + self._drop(len(self._queue)) + self._queue.clear() + self._bytes = 0 + if not self._queue: + return None + frame = self._queue.popleft() + self._bytes -= len(frame) + return frame + finally: + self._lock.release() + + def fence(self) -> None: + """Stop producers; admitted frames remain available until expiry.""" + with self._lock: + self._closed = True + + def snapshot(self) -> tuple[int, int, int]: + """Bounded diagnostic counts: queued records, bytes, dropped lower bound.""" + with self._lock: + return len(self._queue), self._bytes, self._dropped + + def discard(self) -> None: + """Fence ingress and discard only frames still owned by this queue.""" + with self._lock: + self._closed = True + self._drop(len(self._queue)) + self._queue.clear() + self._bytes = 0 diff --git a/src/loushang/apphost/managed/trace_log.py b/src/loushang/apphost/managed/trace_log.py new file mode 100644 index 000000000..e609a0574 --- /dev/null +++ b/src/loushang/apphost/managed/trace_log.py @@ -0,0 +1,220 @@ +"""Two charged trace segments on a borrowed native directory and registry. + +Only the original diagnostics worker may call write. This consumer neither +owns the directory nor retries uncertain writes, releases charges, or schedules +work. Its caller retains it and the dependencies through native settlement. +""" + +from __future__ import annotations + +import json +import os +from collections.abc import Iterator +from contextlib import contextmanager +from hashlib import sha256 +from threading import TIMEOUT_MAX +from time import monotonic +from typing import Any + +from ._files import ( + ManagedDataFileSnapshot, + ManagedStorageError, + PrivateManagedDirectory, + _check_deadline, + _on_event_loop, +) +from .contracts import _HEX32, _HEX64, ManagedContractError, _match +from .paths import MANAGED_LOG_DIRECTORY_NAMES, TRACE_LOG_NAMES +from .storage_budget import ManagedStorageAllocationV1, ManagedStorageBudgetV1 + +_CAPACITY = 10 * 1024**2 +_MAX_SEQUENCE = 2**63 - 1 + + +@contextmanager +def _writer_lock(directory: PrivateManagedDirectory, deadline: float) -> Iterator[None]: + _check_deadline(deadline) + if not directory._mutex.acquire(timeout=min(TIMEOUT_MAX, max(0, deadline - monotonic()))): + raise ManagedStorageError("busy") + try: + _check_deadline(deadline) + yield + finally: + directory._mutex.release() + + +def _json(value: object) -> bytes: + return (json.dumps(value, separators=(",", ":"), allow_nan=False) + "\n").encode("ascii") + + +def _payload(frame: bytes) -> dict[str, Any]: + if type(frame) is not bytes or not 0 < len(frame) <= 512: + raise ManagedStorageError("invalid_record") + try: + row = json.loads(frame) + if type(row) is not dict or type(row.get("v")) is not int or row["v"] != 1: + raise ValueError() + identity = row.get("instanceId") + if type(identity) is not str: + raise ValueError() + _match(identity, _HEX32) + base = {"v", "instanceId", "event"} + if row.get("event") == "problem": + if (row.keys() != base | {"code"} or type(row["code"]) is not str + or row["code"] not in {"startup_failed", "application_failed", "cleanup_incomplete"}): + raise ValueError() + elif row.get("event") == "turn": + fields = row.keys() - base + if (not base <= row.keys() or not fields + or not fields <= {"total_ms", "startup_ms", "local_ready_ms"}): + raise ValueError() + for key in fields: + number = row[key] + if type(number) not in (int, float) or not 0 <= number <= 86400000: + raise ValueError() + else: + raise ValueError() + if _json(row) != frame: + raise ValueError() # Reject duplicates, trailing data and alternate encodings. + return row + except (ValueError, TypeError, RecursionError, UnicodeError): + raise ManagedStorageError("invalid_record") from None + + +def _encode(frame: bytes, sequence: int) -> bytes: + if type(sequence) is not int or not 1 <= sequence <= _MAX_SEQUENCE: + raise ManagedStorageError("capacity") + return _json({"sequence": sequence, "record": _payload(frame)}) + + +def _sequence(snapshot: ManagedDataFileSnapshot | None) -> int: + if snapshot is None or snapshot.size == 0: + return 0 + tail = snapshot.tail + if not tail.endswith(b"\n"): + raise ManagedStorageError("invalid_record") + boundary = tail.rfind(b"\n", 0, len(tail) - 1) + if boundary == -1 and snapshot.size != len(tail): + raise ManagedStorageError("invalid_record") + frame = tail[boundary + 1:] + if len(frame) > 640: + raise ManagedStorageError("invalid_record") + try: + row = json.loads(frame) + if type(row) is not dict or row.keys() != {"sequence", "record"}: + raise ValueError() + if _encode(_json(row["record"]), row["sequence"]) != frame: + raise ValueError() + return row["sequence"] + except (ValueError, TypeError, RecursionError, UnicodeError): + raise ManagedStorageError("invalid_record") from None + + +class ManagedTraceLog: + def __init__(self, directory: PrivateManagedDirectory, budget: ManagedStorageBudgetV1, + service_id: str, *, segment_bytes: int = _CAPACITY) -> None: + if (type(directory) is not PrivateManagedDirectory or type(budget) is not ManagedStorageBudgetV1 + or type(segment_bytes) is not int or not 1024 <= segment_bytes <= _CAPACITY): + raise ManagedContractError() + _match(service_id, _HEX64) + self._directory, self._budget = directory, budget + self._service_id, self._segment_bytes = service_id, segment_bytes + self._failed = False + + def _allocation(self, slot: int) -> ManagedStorageAllocationV1: + directory = self._directory + directory._check() + return ManagedStorageAllocationV1(self._service_id, "trace", None, slot, _CAPACITY, + sha256(os.fsencode(directory._root)).hexdigest(), directory._identity) + + def _create_slot(self, slot: int, deadline: float) -> ManagedDataFileSnapshot: + _check_deadline(deadline) + reservation = self._budget.reserve(self._allocation(slot), exclusive=True, deadline=deadline, wait_for_lock=True) + _check_deadline(deadline) + snapshot = self._directory.append_data(TRACE_LOG_NAMES[slot], b"", expected=None, + capacity=self._segment_bytes) + self._budget.bind_file(reservation, snapshot.identity, deadline=deadline, wait_for_lock=True) + return snapshot + + def prepare(self, *, deadline: float) -> None: + """Admit both charged slots before an outer application receipt. + + This alone is not evidence that a sink or consumer has been installed. + Partial/unknown admission seals this writer and retains all charges. + """ + _check_deadline(deadline) + if _on_event_loop(): + raise ManagedStorageError("busy") + with _writer_lock(self._directory, deadline): + if self._failed: + raise ManagedStorageError("closed") + try: + with self._directory.lock("trace.lock", create=True, deadline=deadline): + snapshots = self._snapshots(deadline) + sequences = [_sequence(snapshot) for snapshot in snapshots] + if sequences[0] != 0 and sequences[0] == sequences[1]: + raise ManagedStorageError("invalid_record") + for slot, snapshot in enumerate(snapshots): + if snapshot is None: + self._create_slot(slot, deadline) + _check_deadline(deadline) + except BaseException: + self._failed = True + raise + + def write(self, frame: bytes, *, deadline: float) -> int | None: + _payload(frame) # Reject non-projected data before native admission. + _check_deadline(deadline) + if _on_event_loop(): + raise ManagedStorageError("busy") + with _writer_lock(self._directory, deadline): + _check_deadline(deadline) + if self._failed: + raise ManagedStorageError("closed") + admitted = False + try: + with self._directory.lock("trace.lock", create=True, deadline=deadline): + admitted = True + snapshots = self._snapshots(deadline) + sequences = [_sequence(item) for item in snapshots] + if sequences[0] != 0 and sequences[0] == sequences[1]: + raise ManagedStorageError("invalid_record") + sequence = max(sequences) + 1 + content = _encode(frame, sequence) + slot = sequences.index(max(sequences)) + snapshot = snapshots[slot] + rotate = snapshot is not None and snapshot.size + len(content) > self._segment_bytes + if rotate: + slot = 1 - slot + snapshot = snapshots[slot] + _check_deadline(deadline) + if snapshot is None: + snapshot = self._create_slot(slot, deadline) + _check_deadline(deadline) + self._directory.append_data(TRACE_LOG_NAMES[slot], content, expected=snapshot, + capacity=self._segment_bytes, truncate=rotate) + return sequence + except BaseException as error: + if (not admitted and isinstance(error, ManagedStorageError) and error.code == "busy" + and not self._directory.cleanup_pending): + return None + self._failed = True + raise + + def _snapshots(self, deadline: float) -> list[ManagedDataFileSnapshot | None]: + directory = self._directory + if set(directory.names(limit=len(MANAGED_LOG_DIRECTORY_NAMES))) - MANAGED_LOG_DIRECTORY_NAMES: + raise ManagedStorageError("invalid_record") + result = [] + for slot, name in enumerate(TRACE_LOG_NAMES): + _check_deadline(deadline) + reservation = self._budget.lookup(self._allocation(slot), deadline=deadline, wait_for_lock=True) + snapshot = directory.data_snapshot(name, capacity=self._segment_bytes) + if reservation is None: + if snapshot is not None: + raise ManagedStorageError("conflict") + elif (snapshot is None or reservation.file_identity is None + or reservation.file_identity != snapshot.identity): + raise ManagedStorageError("conflict") + result.append(snapshot) + return result diff --git a/tests/apphost/_storage_budget_child.py b/tests/apphost/_storage_budget_child.py new file mode 100644 index 000000000..721566fd8 --- /dev/null +++ b/tests/apphost/_storage_budget_child.py @@ -0,0 +1,41 @@ +"""Local subprocess accounting probe; no application, network or model calls.""" + +import json +import os +import signal +import sys +from pathlib import Path +from time import monotonic + +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.contracts import ManagedNamespaceV1 +from loushang.apphost.managed.registry import ManagedRegistryV1 +from loushang.apphost.managed.storage_budget import ( + ManagedStorageAllocationV1, + ManagedStorageBudgetV1, +) + +if __name__ == "__main__": + signal.alarm(15) # Bound even a parent-side failed barrier in this test child. + root, home_path, encoded, mode = sys.argv[1:] + values = json.loads(encoded) + values["root_identity"] = tuple(values["root_identity"]) + request = ManagedStorageAllocationV1(**values) + owner = ManagedRegistryV1(Path(root), ManagedNamespaceV1(home_path, os.geteuid(), "a" * 32), defer_open=True) + try: + deadline = monotonic() + 10 + owner.open(deadline=deadline, wait_for_lock=True) + print("ready", flush=True) + sys.stdin.readline() + try: + ManagedStorageBudgetV1(owner).reserve(request, deadline=deadline, wait_for_lock=True) + except ManagedStorageError as error: + if error.code != "capacity": + raise + print("capacity", flush=True) + else: + if mode == "crash": + os._exit(23) + print("reserved", flush=True) + finally: + owner.close() diff --git a/tests/apphost/test_managed_event_log.py b/tests/apphost/test_managed_event_log.py new file mode 100644 index 000000000..e262a0505 --- /dev/null +++ b/tests/apphost/test_managed_event_log.py @@ -0,0 +1,387 @@ +from __future__ import annotations + +import asyncio +import json +import os +import sys +from concurrent.futures import ThreadPoolExecutor +from threading import Event, Thread +from time import monotonic + +import pytest + +from loushang.apphost.managed import event_log as event_module +from loushang.apphost.managed._files import ManagedStorageError, PrivateManagedDirectory +from loushang.apphost.managed.event_log import ( + ManagedLifecycleEventV1, + ManagedLifecycleLogV1, +) +from loushang.apphost.managed.storage_budget import ManagedStorageBudgetV1 + +from . import test_managed_registry as fixtures +from . import test_managed_storage_budget as budget_fixtures + +registry = fixtures.registry +namespace = fixtures.namespace +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed logs") + + +@pytest.fixture +def log(registry, tmp_path): + request = fixtures.intent() + registry.reserve_mux(request) + directory = PrivateManagedDirectory(tmp_path / "logs", create=True) + writer = ManagedLifecycleLogV1(directory, ManagedStorageBudgetV1(registry), + request.service.service_id, segment_bytes=256) + try: + yield writer, directory, request.service.service_id + finally: + directory.close() + + +def event(instance="a" * 32): + return ManagedLifecycleEventV1("ready", instance) + + +def test_explicit_deadline_reaches_every_registry_admission(log, monkeypatch): + writer, _, _ = log + deadline = monotonic() + 5 + calls = [] + for name in ("lookup", "reserve", "bind_file"): + original = getattr(writer._budget, name) + def checked(*args, _name=name, _original=original, **kwargs): + assert kwargs["deadline"] == deadline + assert kwargs["wait_for_lock"] is True + calls.append(_name) + return _original(*args, **kwargs) + monkeypatch.setattr(writer._budget, name, checked) + assert writer.write(event(), deadline=deadline) == 1 + assert calls == ["lookup"] * 5 + ["reserve", "bind_file"] + + +def test_mutex_timeout_has_no_native_effect_and_does_not_seal(log): + writer, directory, _ = log + held, release = Event(), Event() + def hold(): + with directory._mutex: + held.set() + release.wait(10) + holder = Thread(target=hold) + holder.start() + try: + assert held.wait(5) + with pytest.raises(ManagedStorageError, match="busy"): + writer.write(event(), deadline=monotonic() + 0.05) + assert holder.is_alive() and not release.is_set() + assert not writer._failed + assert not tuple(directory._root.iterdir()) + finally: + release.set() + holder.join(5) + assert not holder.is_alive() + assert writer.write(event(), deadline=monotonic() + 5) == 1 + + +def test_events_rotate_reopen_and_remain_charged(log, registry): + writer, directory, service_id = log + identities = {} + for index in range(25): + assert writer.write(event()) == index + 1 + for path in directory._root.glob("*.jsonl"): + if path.name in identities: + assert identities[path.name] == path.stat().st_ino + identities[path.name] = path.stat().st_ino + assert path.stat().st_size <= 256 + assert len(identities) == 5 + with registry._database.transaction() as connection: + assert connection.execute("SELECT count(*),sum(capacity) FROM storage_allocations").fetchone() == (5, 50 * 1024**2) + for service in (1, 2, 3): + for slot in range(5): + writer._budget.reserve(budget_fixtures.allocation(registry, service=service, slot=slot)) + with registry._database.transaction() as connection: + assert connection.execute("SELECT sum(capacity) FROM storage_allocations").fetchone() == (200 * 1024**2,) + reopened = ManagedLifecycleLogV1(directory, ManagedStorageBudgetV1(registry), service_id, + segment_bytes=256) + assert reopened.write(event("b" * 32)) == 26 + values = [json.loads(line) for path in directory._root.glob("*.jsonl") + for line in path.read_bytes().splitlines()] + assert max(row["sequence"] for row in values) == 26 + assert {path.name for path in directory._root.iterdir()} == {"lifecycle.lock", *identities} + for index in range(26, 40): + assert reopened.write(event()) == index + 1 + assert {path.name: path.stat().st_ino for path in directory._root.glob("*.jsonl")} == identities + + +@pytest.mark.parametrize("name,instance,code", [("secret text", "a" * 32, None), + ("ready", "token-value", None), ("ready", "a" * 32, "raw exception"), + (True, "a" * 32, None)]) +def test_invalid_event_has_no_io(log, name, instance, code): + _, directory, _ = log + with pytest.raises(ValueError): + ManagedLifecycleEventV1(name, instance, code) + assert not tuple(directory._root.iterdir()) + + +@pytest.mark.parametrize("damage", ["unknown", "partial", "missing", "unbound", "extra-field"]) +def test_unknown_entries_or_bad_bound_state_never_repaired(log, registry, damage): + writer, directory, service_id = log + writer.write(event()) + path = directory._root / "lifecycle-0.jsonl" + if damage == "unknown": + (directory._root / "stranger").touch(mode=0o600) + elif damage == "partial": + with path.open("ab") as stream: + stream.write(b"partial") + elif damage == "missing": + path.unlink() + elif damage == "unbound": + with registry._database.transaction(write=True) as connection: + connection.execute("UPDATE storage_allocations SET file_device=NULL,file_inode=NULL") + else: + row = json.loads(path.read_bytes()) + row["message"] = "private arbitrary text" + path.write_bytes(json.dumps(row).encode() + b"\n") + before = {item.name: item.read_bytes() for item in directory._root.iterdir()} + reopened = ManagedLifecycleLogV1(directory, ManagedStorageBudgetV1(registry), service_id, + segment_bytes=256) + with pytest.raises(ManagedStorageError): + reopened.write(event()) + assert {item.name: item.read_bytes() for item in directory._root.iterdir()} == before + with pytest.raises(ManagedStorageError, match="closed"): + reopened.write(event()) + + +def test_bind_receipt_loss_does_not_replay_event(log, registry, monkeypatch): + writer, directory, service_id = log + original = writer._budget.bind_file + + def lost(*args, **kwargs): + original(*args, **kwargs) + raise ManagedStorageError("unavailable") + + with monkeypatch.context() as patch: + patch.setattr(writer._budget, "bind_file", lost) + with pytest.raises(ManagedStorageError): + writer.write(event()) + assert (directory._root / "lifecycle-0.jsonl").read_bytes() == b"" + with pytest.raises(ManagedStorageError, match="closed"): + writer.write(event()) + # A new admitted writer can use a bound empty file, but this is a new event. + reopened = ManagedLifecycleLogV1(directory, ManagedStorageBudgetV1(registry), service_id, + segment_bytes=256) + assert reopened.write(event("b" * 32)) == 1 + + +def test_lifecycle_preserves_fixed_trace_entries_in_shared_directory(log): + writer, directory, _ = log + for _ in range(25): + writer.write(event()) + # Foreign-format contents belong to the trace consumer. Lifecycle must + # neither parse, modify nor adopt their accounting, even if malformed. + trace = {name: ("trace-owned:" + name).encode() for name in + ("trace.lock", "trace-0.jsonl", "trace-1.jsonl")} + for name, content in trace.items(): + path = directory._root / name + path.write_bytes(content) + path.chmod(0o600) + assert len(tuple(directory._root.iterdir())) == 9 + assert writer.write(event()) == 26 + assert writer.read_tail()[-1].sequence == 26 + assert {name: (directory._root / name).read_bytes() for name in trace} == trace + + +@pytest.mark.parametrize("name", ["trace-2.jsonl", "trace-secret.jsonl", "trace.lock.backup"]) +def test_trace_coexistence_does_not_allow_extra_names(log, name): + writer, directory, _ = log + writer.write(event()) + path = directory._root / name + path.write_bytes(b"foreign") + path.chmod(0o600) + before = (directory._root / "lifecycle-0.jsonl").read_bytes() + with pytest.raises(ManagedStorageError, match="invalid_record"): + writer.write(event()) + assert (directory._root / "lifecycle-0.jsonl").read_bytes() == before + + +def test_partial_event_seals_writer_without_retrying(log, monkeypatch): + writer, directory, _ = log + writer.write(event()) + native = os.write + calls = [] + + def short(fd, data): + calls.append(1) + if len(calls) == 1: + return native(fd, data[:3]) + raise OSError("private error") + + with monkeypatch.context() as patch: + patch.setattr(os, "write", short) + with pytest.raises(ManagedStorageError): + writer.write(event()) + before = (directory._root / "lifecycle-0.jsonl").read_bytes() + with pytest.raises(ManagedStorageError, match="closed"): + writer.write(event()) + directory.close() + assert (directory._root / "lifecycle-0.jsonl").read_bytes() == before + + +@pytest.mark.parametrize("stage", ["reserve", "create"]) +def test_unbound_reservation_never_authorizes_recreation(log, registry, monkeypatch, stage): + writer, directory, service_id = log + target = writer._budget if stage == "reserve" else directory + name = "reserve" if stage == "reserve" else "append_data" + original = getattr(target, name) + + def lost(*args, **kwargs): + original(*args, **kwargs) + raise ManagedStorageError("unavailable") + + with monkeypatch.context() as patch: + patch.setattr(target, name, lost) + with pytest.raises(ManagedStorageError): + writer.write(event()) + before = {path.name: path.read_bytes() for path in directory._root.iterdir()} + reopened = ManagedLifecycleLogV1(directory, ManagedStorageBudgetV1(registry), service_id, + segment_bytes=256) + with pytest.raises(ManagedStorageError, match="conflict"): + reopened.write(event()) + assert {path.name: path.read_bytes() for path in directory._root.iterdir()} == before + with registry._database.transaction() as connection: + assert connection.execute("SELECT count(*),sum(capacity) FROM storage_allocations").fetchone() == (1, 10 * 1024**2) + + +@pytest.mark.parametrize("value", [True, 0, 2**63 - 1, 2**63]) +def test_bad_or_exhausted_tail_sequence_never_writes(log, value): + writer, directory, _ = log + writer.write(event()) + path = directory._root / "lifecycle-0.jsonl" + row = json.loads(path.read_bytes()) + row["sequence"] = value + path.write_bytes(json.dumps(row, separators=(",", ":")).encode() + b"\n") + before = path.read_bytes() + with pytest.raises(ManagedStorageError): + writer.write(event()) + assert path.read_bytes() == before + + +def test_duplicate_tail_sequence_never_selects_a_winner(log): + writer, directory, _ = log + for _ in range(3): + writer.write(event()) + first = directory._root / "lifecycle-0.jsonl" + second = directory._root / "lifecycle-1.jsonl" + second.write_bytes(first.read_bytes().splitlines(keepends=True)[-1]) + before = {path.name: path.read_bytes() for path in directory._root.iterdir()} + with pytest.raises(ManagedStorageError, match="invalid_record"): + writer.write(event()) + assert {path.name: path.read_bytes() for path in directory._root.iterdir()} == before + + +def test_exclusive_reservation_cannot_claim_existing_unbound_row(log): + writer, directory, _ = log + allocation = writer._allocation(0) + first = writer._budget.reserve(allocation, exclusive=True) + with pytest.raises(ManagedStorageError, match="conflict"): + writer._budget.reserve(allocation, exclusive=True) + assert writer._budget.reserve(allocation) == first + assert not tuple(directory._root.iterdir()) + + +def test_concurrent_waiter_observes_writer_sealed_by_lost_bind(log, monkeypatch): + writer, directory, _ = log + entered, second_validated = Event(), Event() + original_bind, original_encode = writer._budget.bind_file, event_module._encode + + def encode(value, sequence): + result = original_encode(value, sequence) + if value.instance_id == "b" * 32: + second_validated.set() + return result + + def bind(*args, **kwargs): + original_bind(*args, **kwargs) + entered.set() + assert second_validated.wait(5) + raise ManagedStorageError("unavailable") + + with monkeypatch.context() as patch: + patch.setattr(event_module, "_encode", encode) + patch.setattr(writer._budget, "bind_file", bind) + with ThreadPoolExecutor(max_workers=2) as executor: + first = executor.submit(writer.write, event()) + assert entered.wait(5) + second = executor.submit(writer.write, event("b" * 32)) + with pytest.raises(ManagedStorageError, match="unavailable"): + first.result(timeout=5) + with pytest.raises(ManagedStorageError, match="closed"): + second.result(timeout=5) + assert (directory._root / "lifecycle-0.jsonl").read_bytes() == b"" + + +@pytest.mark.parametrize("value", [None, "private text", {"event": "ready"}]) +def test_write_invalid_type_has_zero_io(log, value): + writer, directory, _ = log + with pytest.raises(ValueError): + writer.write(value) + assert not tuple(directory._root.iterdir()) + + +@pytest.mark.parametrize("overfull", [False, True]) +def test_scan_close_unknown_stays_with_original_directory(tmp_path, monkeypatch, overfull): + root = tmp_path / "scan" + owner = PrivateManagedDirectory(root, create=True) + if overfull: + for index in range(2): + (root / str(index)).touch(mode=0o600) + original = os.scandir + closes = [] + + class LostClose: + def __init__(self, fd): + self.native = original(fd) + + def __iter__(self): + return iter(self.native) + + def close(self): + closes.append(1) + self.native.close() + raise OSError("private lost close receipt") + + with monkeypatch.context() as patch: + patch.setattr(os, "scandir", LostClose) + with pytest.raises(ManagedStorageError, match="capacity" if overfull else "unavailable"): + owner.names(limit=1) + assert owner.cleanup_pending + with pytest.raises(ManagedStorageError, match="busy"): + owner.names(limit=1) + for _ in range(2): + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.close() + assert owner.cleanup_pending + assert closes == [1] + + +def test_running_loop_is_rejected_before_native_mutex(log): + writer, directory, _ = log + entered, release = Event(), Event() + + def holder(): + with directory._mutex: + entered.set() + assert release.wait(5) + + async def call(): + try: + with pytest.raises(ManagedStorageError, match="busy"): + writer.write(event()) + finally: + release.set() + + with ThreadPoolExecutor(max_workers=1) as executor: + held = executor.submit(holder) + assert entered.wait(5) + asyncio.run(call()) + held.result(timeout=5) + assert not writer._failed and not tuple(directory._root.iterdir()) diff --git a/tests/apphost/test_managed_log_files.py b/tests/apphost/test_managed_log_files.py new file mode 100644 index 000000000..6eaa43c42 --- /dev/null +++ b/tests/apphost/test_managed_log_files.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import os +import stat +import sys + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError, PrivateManagedDirectory + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed storage") + + +@pytest.fixture +def directory(tmp_path): + root = tmp_path / "private" + owner = PrivateManagedDirectory(root, create=True) + try: + yield root, owner + finally: + owner.close() + + +def test_append_and_same_inode_rotation_have_no_copy_peak(directory): + root, owner = directory + with owner.lock("events.lock", create=True): + empty = owner.append_data("events", b"", expected=None, capacity=64) + full = owner.append_data("events", b"x" * 64, expected=empty, capacity=64) + with pytest.raises(ManagedStorageError, match="capacity"): + owner.append_data("events", b"y", expected=full, capacity=64) + rotated = owner.append_data("events", b"next\n", expected=full, capacity=64, truncate=True) + assert empty.identity == full.identity == rotated.identity + assert rotated.size == 5 and rotated.tail == b"next\n" + assert {p.name for p in root.iterdir()} == {"events.lock", "events"} + assert (root / "events").read_bytes() == b"next\n" + assert not owner.cleanup_pending + + +@pytest.mark.parametrize("change", ["append", "truncate", "replace", "symlink"]) +def test_stale_snapshot_never_changes_replaced_or_modified_target(directory, change): + root, owner = directory + with owner.lock("events.lock", create=True): + original = owner.append_data("events", b"old\n", expected=None, capacity=64) + target = root / "events" + if change == "append": + with target.open("ab") as stream: + stream.write(b"external\n") + elif change == "truncate": + with target.open("wb"): + pass + else: + target.rename(root / "old") + if change == "replace": + target.touch(mode=0o600) + else: + target.symlink_to(root / "old") + before = target.read_bytes() + with pytest.raises(ManagedStorageError): + owner.append_data("events", b"new\n", expected=original, capacity=64) + assert target.read_bytes() == before + + +@pytest.mark.parametrize("fault", ["partial", "zero", "interrupt", "truncate", "sync"]) +def test_unknown_effect_is_not_replayed_and_close_only_syncs(directory, monkeypatch, fault): + root, owner = directory + with owner.lock("events.lock", create=True): + original = owner.append_data("events", b"old\n", expected=None, capacity=64) + native_write, native_sync, native_truncate = os.write, os.fsync, os.ftruncate + writes = [] + + def write(fd, value): + writes.append(bytes(value)) + if fault == "partial": + if len(writes) == 1: + return native_write(fd, value[:3]) + raise OSError("private native failure") + if fault == "zero": + return 0 + if fault == "interrupt": + raise InterruptedError("private native failure") + return native_write(fd, value) + + def sync(fd): + if fault == "sync" and stat.S_ISREG(os.fstat(fd).st_mode): + raise OSError("private native failure") + return native_sync(fd) + + def truncate(fd, size): + native_truncate(fd, size) + if fault == "truncate": + raise OSError("lost truncate receipt") + + with monkeypatch.context() as patch: + patch.setattr(os, "write", write) + patch.setattr(os, "fsync", sync) + patch.setattr(os, "ftruncate", truncate) + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.append_data("events", b"new-event\n", expected=original, + capacity=64, truncate=fault == "truncate") + assert owner.cleanup_pending and len(owner._data_sync_pending) == 1 + with pytest.raises(ManagedStorageError, match="busy"): + owner.append_data("events", b"new-event\n", expected=original, capacity=64) + before = (root / "events").read_bytes() + count = len(writes) + failed_fd, = owner._data_sync_pending + settled = [] + native_close = os.close + + def settle_sync(fd): + native_sync(fd) + if fd == failed_fd: + settled.append("sync") + + def settle_close(fd): + if fd == failed_fd: + assert settled == ["sync"] + settled.append("close") + native_close(fd) + + with monkeypatch.context() as patch: + patch.setattr(os, "write", lambda *args: pytest.fail("cleanup replayed body")) + patch.setattr(os, "ftruncate", lambda *args: pytest.fail("cleanup replayed truncate")) + patch.setattr(os, "fsync", lambda fd: (_ for _ in ()).throw(OSError("retry sync"))) + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.close() + assert owner._data_sync_pending == {failed_fd} and owner.cleanup_pending + assert os.fstat(failed_fd).st_ino == original.identity[1] + patch.setattr(os, "fsync", settle_sync) + patch.setattr(os, "close", settle_close) + owner.close() + assert settled == ["sync", "close"] + assert len(writes) == count and not owner.cleanup_pending + assert (root / "events").read_bytes() == before + assert before == {"partial": b"old\nnew", "zero": b"old\n", "interrupt": b"old\n", + "truncate": b"", "sync": b"old\nnew-event\n"}[fault] + + +def test_snapshot_reads_only_bounded_tail(directory): + root, owner = directory + with owner.lock("events.lock", create=True): + current = owner.append_data("events", b"a" * 16384, expected=None, capacity=65536) + current = owner.append_data("events", b"b" * 16384, expected=current, capacity=65536) + assert current.size == 32768 and current.tail == b"b" * 16384 + assert owner.data_snapshot("events", capacity=65536) == current + + +@pytest.mark.parametrize("capacity,payload", [(True, b"x"), (0, b"x"), + (128 * 1024**2 + 1, b"x"), + (65536, b"x" * 16385)], + ids=["bool", "zero", "too-large-cap", "too-large-record"]) +def test_invalid_values_rejected_before_file_creation(directory, capacity, payload): + root, owner = directory + with owner.lock("events.lock", create=True): + with pytest.raises(ManagedStorageError): + owner.append_data("events", payload, expected=None, capacity=capacity) + assert not (root / "events").exists() + + +def test_rotation_validates_encoded_bytes_before_truncating(directory): + root, owner = directory + with owner.lock("events.lock", create=True): + original = owner.append_data("events", b"old\n", expected=None, capacity=8) + with pytest.raises(ManagedStorageError, match="capacity"): + owner.append_data("events", "你好啊".encode(), expected=original, + capacity=8, truncate=True) + assert (root / "events").read_bytes() == b"old\n" + + +def test_snapshot_rejects_change_during_bounded_read(directory, monkeypatch): + root, owner = directory + with owner.lock("events.lock", create=True): + owner.append_data("events", b"old\n", expected=None, capacity=64) + native = os.pread + + def changed(fd, size, offset): + result = native(fd, size, offset) + with (root / "events").open("ab") as stream: + stream.write(b"external\n") + return result + + with monkeypatch.context() as patch: + patch.setattr(os, "pread", changed) + with pytest.raises(ManagedStorageError, match="conflict"): + owner.data_snapshot("events", capacity=64) + assert (root / "events").read_bytes() == b"old\nexternal\n" + + +@pytest.mark.parametrize("operation", ["append", "snapshot"]) +def test_unknown_close_never_touches_reused_fd(tmp_path, monkeypatch, operation): + owner = PrivateManagedDirectory(tmp_path / "private", create=True) + reused = [] + native_close = os.close + + def close(fd): + if fd in owner._close_pending and fd != owner._fd and not reused: + native_close(fd) + replacement = os.open("/dev/null", os.O_RDONLY) + reused.append(replacement) + assert replacement == fd + raise OSError("lost close receipt") + assert fd not in reused, "retried close on reused fd" + return native_close(fd) + + try: + with owner.lock("events.lock", create=True): + original = owner.append_data("events", b"old\n", expected=None, capacity=64) + with monkeypatch.context() as patch: + patch.setattr(os, "close", close) + try: + raise RuntimeError("unrelated caller exception") + except RuntimeError: + with pytest.raises(ManagedStorageError, match="unavailable"): + if operation == "append": + owner.append_data("events", b"event\n", expected=original, capacity=64) + else: + owner.data_snapshot("events", capacity=64) + assert reused and owner.cleanup_pending and not owner._data_sync_pending + for _ in range(2): + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.close() + assert stat.S_ISCHR(os.fstat(reused[0]).st_mode) + finally: + for fd in reused: + native_close(fd) + + +def test_no_lock_means_no_data_creation(directory): + root, owner = directory + with pytest.raises(ManagedStorageError, match="busy"): + owner.append_data("events", b"event\n", expected=None, capacity=64) + assert not tuple(root.iterdir()) diff --git a/tests/apphost/test_managed_log_reader.py b/tests/apphost/test_managed_log_reader.py new file mode 100644 index 000000000..fdda6730e --- /dev/null +++ b/tests/apphost/test_managed_log_reader.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import os +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from threading import Event +from time import monotonic + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError, PrivateManagedDirectory +from loushang.apphost.managed.event_log import ManagedLifecycleLogV1 +from tests.apphost.test_managed_namespace_admission import tree + +from . import test_managed_event_log as fixtures + +log = fixtures.log +registry = fixtures.registry +namespace = fixtures.namespace +pytestmark = fixtures.pytestmark + + +def test_rotated_tail_is_ordered_bounded_and_readonly(log): + writer, directory, service_id = log + for _ in range(25): + writer.write(fixtures.event()) + before = tree(directory._root) + reader = ManagedLifecycleLogV1(directory, writer._budget, service_id, segment_bytes=256) + result = reader.read_tail(limit=5) + assert [item.sequence for item in result] == [21, 22, 23, 24, 25] + assert all(item.event == fixtures.event() for item in result) + assert tree(directory._root) == before + + +def test_missing_log_lock_is_not_created(log): + writer, directory, _ = log + before = tree(directory._root) + with pytest.raises(ManagedStorageError, match="not_found"): + writer.read_tail(limit=5) + assert tree(directory._root) == before + + +@pytest.mark.parametrize("limit", [True, 0, 101, -1, "5"]) +def test_bad_read_limit_has_no_io(log, limit): + writer, directory, _ = log + with pytest.raises(ValueError): + writer.read_tail(limit=limit) + assert not tuple(directory._root.iterdir()) + + +def test_bad_earlier_visible_frame_is_not_printed_or_repaired(log): + writer, directory, _ = log + writer.write(fixtures.event()) + writer.write(fixtures.event()) + path = directory._root / "lifecycle-0.jsonl" + last = path.read_bytes().splitlines(keepends=True)[-1] + path.write_bytes(b'{"message":"secret arbitrary text"}\n' + last) + before = tree(directory._root) + with pytest.raises(ManagedStorageError, match="invalid_record") as error: + writer.read_tail(limit=1) + assert "secret" not in str(error.value) + assert tree(directory._root) == before + + +def test_reader_limits_actual_pread_and_handles_cut_first_frame(log, monkeypatch): + writer, directory, service_id = log + # Use the production cap for this same charged slot. + writer = ManagedLifecycleLogV1(directory, writer._budget, service_id) + for _ in range(220): + writer.write(fixtures.event()) + content = (directory._root / "lifecycle-0.jsonl").read_bytes() + assert len(content) > 16384 + assert content[-16385:-16384] != b"\n" + native = os.pread + requested = [] + + def read(fd, size, offset): + requested.append(size) + return native(fd, size, offset) + + with monkeypatch.context() as patch: + patch.setattr(os, "pread", read) + result = writer.read_tail(limit=100) + assert [item.sequence for item in result] == list(range(121, 221)) + assert sum(requested) <= 5 * 16384 and max(requested) <= 16384 + + +def test_writer_lock_contention_is_bounded_and_readonly(log): + writer, directory, service_id = log + writer.write(fixtures.event()) + other = PrivateManagedDirectory(directory._root) + reader = ManagedLifecycleLogV1(other, writer._budget, service_id, segment_bytes=256) + entered, release = Event(), Event() + + def hold(): + with directory.lock("lifecycle.lock"): + entered.set() + assert release.wait(5) + + try: + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(hold) + assert entered.wait(5) + before = tree(directory._root) + try: + with pytest.raises(ManagedStorageError, match="busy"): + reader.read_tail(limit=1, deadline=monotonic() + 0.05) + assert tree(directory._root) == before + finally: + release.set() + future.result(timeout=5) + assert reader.read_tail(limit=1, deadline=monotonic() + 2)[0].sequence == 1 + finally: + other.close() + + +def test_reader_contention_drops_only_current_writer_event(log, monkeypatch): + writer, directory, service_id = log + assert writer.write(fixtures.event()) == 1 + other = PrivateManagedDirectory(directory._root) + reader = ManagedLifecycleLogV1(other, writer._budget, service_id, segment_bytes=256) + entered, release = Event(), Event() + native = reader._snapshots + + def snapshots(**kwargs): + entered.set() + assert release.wait(5) + return native(**kwargs) + + monkeypatch.setattr(reader, "_snapshots", snapshots) + try: + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(reader.read_tail) + assert entered.wait(5) + try: + assert writer.write(fixtures.event()) is None + assert not writer._failed + finally: + release.set() + assert future.result(timeout=5)[0].sequence == 1 + assert writer.write(fixtures.event()) == 2 + finally: + other.close() + + +def test_reader_checks_deadline_after_lock_release(log, monkeypatch): + from loushang.apphost.managed import _files + + writer, directory, _ = log + writer.write(fixtures.event()) + native = directory.lock + expired = False + + @contextmanager + def lock(*args, **kwargs): + nonlocal expired + with native(*args, **kwargs): + yield + expired = True + + monkeypatch.setattr(directory, "lock", lock) + monkeypatch.setattr(_files, "monotonic", lambda: 11.0 if expired else 1.0) + with pytest.raises(ManagedStorageError, match="busy"): + writer.read_tail(deadline=10.0) + assert expired and not directory._locks diff --git a/tests/apphost/test_managed_output_capture.py b/tests/apphost/test_managed_output_capture.py new file mode 100644 index 000000000..b223b0b62 --- /dev/null +++ b/tests/apphost/test_managed_output_capture.py @@ -0,0 +1,231 @@ +import asyncio +from threading import Event + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.output_capture import ( + ManagedOutputCapture, + ManagedOutputCaptureFactory, +) +from loushang.harness.workspace.exec.capture_lease import CapturePreparation +from loushang.harness.workspace.exec.types import ExecOutputChunk + +from .test_managed_files import directory as directory +from .test_managed_storage_budget import namespace as namespace +from .test_managed_storage_budget import pytestmark as pytestmark +from .test_managed_storage_budget import registry as registry +from .test_native_output_capture import capture + + +def test_async_capture_delivers_both_sources_and_releases_storage(registry, directory): + native = capture(registry, directory) + lease = ManagedOutputCapture(native) + + async def run(): + assert await lease.prepare() is CapturePreparation.READY + await lease.append(ExecOutputChunk("stdout", "你好")) + await lease.append(ExecOutputChunk("stderr", "warning")) + sources = await lease.seal() + assert sources is await lease.seal() + assert await sources.stdout.read_bytes(max_bytes=6) == "你好".encode() + assert await sources.stderr.read_bytes(max_bytes=7) == b"warning" + await lease.close() + assert not lease.cleanup_pending + with pytest.raises(ManagedStorageError, match="closed"): + await sources.stdout.read_bytes(max_bytes=6) + + asyncio.run(run()) + assert all(native.budget.lookup(request) is None for request in native.allocations) + + +@pytest.mark.parametrize("operation", ["prepare", "read"]) +def test_cancelled_waiter_keeps_native_work_until_close(registry, directory, monkeypatch, operation): + native = capture(registry, directory) + lease = ManagedOutputCapture(native) + entered, resume, completed = Event(), Event(), Event() + original = getattr(native, operation) + original_close = native.close + + def paused(*args, **kwargs): + entered.set() + assert resume.wait(5) + try: + return original(*args, **kwargs) + finally: + completed.set() + + def close_after_completion(): + assert completed.is_set() + original_close() + + async def run(): + if operation == "read": + await lease.prepare() + await lease.append(ExecOutputChunk("stdout", "hello")) + sources = await lease.seal() + monkeypatch.setattr(native, operation, paused) + monkeypatch.setattr(native, "close", close_after_completion) + waiting = asyncio.create_task(lease.prepare() if operation == "prepare" else sources.stdout.read_bytes(max_bytes=5)) + closing = None + try: + assert await asyncio.to_thread(entered.wait, 5) + waiting.cancel() + with pytest.raises(asyncio.CancelledError): + await waiting + assert lease.cleanup_pending + if operation == "read": + with pytest.raises(ManagedStorageError, match="busy"): + await sources.stdout.read_bytes(max_bytes=5) + closing = asyncio.create_task(lease.close()) + await asyncio.sleep(0) + assert not closing.done() + finally: + resume.set() + if closing is not None: + await closing + else: + await lease.close() + assert not lease.cleanup_pending + + asyncio.run(run()) + assert all(native.budget.lookup(request) is None for request in native.allocations) + + +def test_executor_submission_receipt_loss_never_admits_native(registry, directory, monkeypatch): + native = capture(registry, directory) + lease = ManagedOutputCapture(native) + submitted = [] + + async def run(): + loop = asyncio.get_running_loop() + original = loop.run_in_executor + + def lost_receipt(*args, **kwargs): + submitted.append(original(*args, **kwargs)) + raise OSError("executor submitted but lost return") + + with monkeypatch.context() as patch: + patch.setattr(loop, "run_in_executor", lost_receipt) + with pytest.raises(OSError): + await lease.prepare() + await asyncio.gather(*submitted) + assert not native.started and native.attempt is None + await lease.close() + assert not lease.cleanup_pending + + asyncio.run(run()) + + +@pytest.mark.parametrize("operation", ["prepare", "seal"]) +def test_late_public_waiter_cannot_deliver_after_close(registry, directory, monkeypatch, operation): + native = capture(registry, directory) + lease = ManagedOutputCapture(native) + + async def run(): + if operation == "seal": + await lease.prepare() + reached, resume = asyncio.Event(), asyncio.Event() + original_shield = asyncio.shield + waiter = None + + def delayed_shield(awaitable): + if asyncio.current_task() is waiter: + async def delayed(): + result = await original_shield(awaitable) + reached.set() + await resume.wait() + return result + return delayed() + return original_shield(awaitable) + + with monkeypatch.context() as patch: + patch.setattr(asyncio, "shield", delayed_shield) + waiter = asyncio.create_task(getattr(lease, operation)()) + try: + await asyncio.wait_for(reached.wait(), timeout=5) + await lease.close() + finally: + resume.set() + with pytest.raises(ManagedStorageError, match="closed"): + await waiter + assert lease._sealed is None and not lease.cleanup_pending + + asyncio.run(run()) + + +def test_internal_cancellation_cannot_overlap_or_admit_queued_native(registry, directory, monkeypatch): + native = capture(registry, directory) + lease = ManagedOutputCapture(native) + entered, resume, finished = Event(), Event(), Event() + original_append = native.append + calls = [] + + def paused(index, content): + calls.append(content) + entered.set() + assert resume.wait(5) + try: + original_append(index, content) + finally: + finished.set() + + async def run(): + await lease.prepare() + monkeypatch.setattr(native, "append", paused) + first = asyncio.create_task(lease.append(ExecOutputChunk("stdout", "first"))) + try: + assert await asyncio.to_thread(entered.wait, 5) + original_task = next(task for task in lease._pending if not task.done()) + second = asyncio.create_task(lease.append(ExecOutputChunk("stderr", "second"))) + await asyncio.sleep(0) + original_task.cancel() + await asyncio.sleep(0) + assert not original_task.done() and calls == [b"first"] + finally: + resume.set() + with pytest.raises(asyncio.CancelledError): + await first + with pytest.raises(ManagedStorageError, match="unavailable"): + await second + assert finished.is_set() and calls == [b"first"] + with pytest.raises(ManagedStorageError, match="unavailable"): + await lease.close() + assert lease.cleanup_pending + + try: + asyncio.run(run()) + finally: + resume.set() + # Test-owned native cleanup only after asyncio.run joined its executor; + # the deliberately cancelled lease retains its unknown state. + native.close() + + +def test_factory_keeps_unstarted_leases_until_explicit_close_without_storage_io(registry, directory, monkeypatch): + native = capture(registry, directory) + request = native.allocations[0] + factory = ManagedOutputCaptureFactory(native.owner, native.budget, service_id=request.service_id, + instance_id=request.instance_id, capacity=4096) + + def unexpected(*args, **kwargs): + raise AssertionError("lease allocation must not perform storage IO") + + async def run(): + with monkeypatch.context() as patch: + patch.setattr(native.owner, "_open", unexpected) + patch.setattr(native.budget._database, "transaction", unexpected) + leases = [factory.new_capture() for _ in range(8)] + assert factory.cleanup_pending + with pytest.raises(ManagedStorageError, match="capacity"): + factory.new_capture() + await leases[0].close() + replacement = factory.new_capture() + assert replacement is not leases[0] and replacement._native.allocations[0].slot == 0 + await factory.close() + assert not factory.cleanup_pending + with pytest.raises(ManagedStorageError, match="closed"): + factory.new_capture() + + asyncio.run(run()) + assert native.owner._fd is not None # Factory only borrowed the shared root. diff --git a/tests/apphost/test_managed_storage_budget.py b/tests/apphost/test_managed_storage_budget.py new file mode 100644 index 000000000..26c434427 --- /dev/null +++ b/tests/apphost/test_managed_storage_budget.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from dataclasses import asdict, replace +from hashlib import sha256 +from pathlib import Path + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.storage_budget import ( + ManagedStorageAllocationV1, + ManagedStorageBudgetV1, +) + +from . import test_managed_registry as registry_fixtures + +namespace = registry_fixtures.namespace +registry = registry_fixtures.registry +intent = registry_fixtures.intent + +MIB = 1024 * 1024 +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed storage") + + +def allocation(registry, *, kind="log", slot=0, size=10 * MIB, instance=None, service=0): + request = intent(f"mux{service}", f"{service + 1:032x}", f"/workspace/{service}") + registry.reserve_mux(request) + return ManagedStorageAllocationV1(request.service.service_id, kind, instance, slot, size, + sha256(f"root{service}".encode()).hexdigest(), (1, service + 1)) + + +def test_exact_reservation_and_file_binding_are_durable_not_write_authority(registry): + budget = ManagedStorageBudgetV1(registry) + request = allocation(registry) + first = budget.reserve(request) + assert first.allocation == request and first.file_identity is None + assert budget.reserve(request) == first + bound = budget.bind_file(first, (1, 1024)) + assert bound.file_identity == (1, 1024) + assert ManagedStorageBudgetV1(registry).reserve(request) == bound + assert budget.bind_file(first, (1, 1024)) == bound + with pytest.raises(ManagedStorageError, match="conflict"): + budget.bind_file(first, (1, 2048)) + assert not hasattr(budget, "release") and not hasattr(bound, "write") + + +@pytest.mark.parametrize("field,value", [("capacity", MIB), ("root_key", "f" * 64), + ("root_identity", (2, 1))]) +def test_slot_cannot_be_retargeted_or_refunded(registry, field, value): + budget = ManagedStorageBudgetV1(registry) + request = allocation(registry, kind="temporary", size=2 * MIB, instance="a" * 32) + budget.reserve(request) + with pytest.raises(ManagedStorageError, match="conflict"): + budget.reserve(replace(request, **{field: value})) + assert budget.lookup(request) is not None + + +def test_log_and_trace_share_namespace_budget_across_services_and_reopen(registry): + budget = ManagedStorageBudgetV1(registry) + for service in range(4): + for slot in range(5): + budget.reserve(allocation(registry, slot=slot, service=service)) + extra = allocation(registry, kind="trace", service=4) + with pytest.raises(ManagedStorageError, match="capacity"): + ManagedStorageBudgetV1(registry).reserve(extra) + assert budget.reserve(allocation(registry)).allocation.slot == 0 + with registry._database.transaction() as connection: + assert connection.execute("SELECT sum(capacity) FROM storage_allocations").fetchone() == (200 * MIB,) + + +def test_temporary_limits_count_all_instances_and_unbound_reservations(registry): + budget = ManagedStorageBudgetV1(registry) + for index in range(4): + budget.reserve(allocation(registry, kind="temporary", size=128 * MIB, instance=f"{index:032x}")) + for instance in ("0" * 32, "f" * 32): + with pytest.raises(ManagedStorageError, match="capacity"): + budget.reserve(allocation(registry, kind="temporary", size=4096, slot=1, instance=instance)) + # A different kind has a separate byte budget. + assert budget.reserve(allocation(registry)).file_identity is None + + +def test_temporary_instance_cannot_borrow_another_instances_unused_budget(registry): + budget = ManagedStorageBudgetV1(registry) + budget.reserve(allocation(registry, kind="temporary", size=128 * MIB, instance="a" * 32)) + with pytest.raises(ManagedStorageError, match="capacity"): + budget.reserve(allocation(registry, kind="temporary", size=4096, slot=1, instance="a" * 32)) + budget.reserve(allocation(registry, kind="temporary", size=4096, instance="b" * 32)) + + +@pytest.mark.parametrize("changes", [ + {"kind": "raw_stderr"}, {"slot": True}, {"slot": 5}, {"capacity": 1}, + {"kind": "trace", "slot": 2}, {"instance_id": "a" * 32}, + {"kind": "temporary"}, {"root_identity": (True, 1)}, {"root_key": "private/path"}, + {"kind": "temporary", "instance_id": "a" * 32, "slot": 256, "capacity": 4096}, +]) +def test_invalid_allocations_are_rejected_before_transactions(registry, changes): + with pytest.raises(ValueError): + replace(allocation(registry), **changes) + + +def test_committed_but_lost_receipt_remains_charged_once(registry, monkeypatch): + budget = ManagedStorageBudgetV1(registry) + request = allocation(registry) + original = registry._database.transaction + @contextmanager + def lost(**kwargs): + with original(**kwargs) as connection: + yield connection + if kwargs.get("write"): + raise ManagedStorageError("unavailable") + monkeypatch.setattr(registry._database, "transaction", lost) + with pytest.raises(ManagedStorageError, match="unavailable"): + budget.reserve(request) + monkeypatch.setattr(registry._database, "transaction", original) + recorded = budget.lookup(request) + assert recorded is not None and budget.reserve(request) == recorded + with original() as connection: + assert connection.execute("SELECT count(*),sum(capacity) FROM storage_allocations").fetchone() == (1, 10 * MIB) + monkeypatch.setattr(registry._database, "transaction", lost) + with pytest.raises(ManagedStorageError, match="unavailable"): + budget.bind_file(recorded, (1, 2048)) + monkeypatch.setattr(registry._database, "transaction", original) + assert budget.lookup(request).file_identity == (1, 2048) + assert budget.bind_file(recorded, (1, 2048)) == budget.lookup(request) + + +def child(root, namespace, request, *, mode="reserve"): + return subprocess.Popen( + [sys.executable, str(Path(__file__).with_name("_storage_budget_child.py")), str(root), + namespace.platform_home, json.dumps(asdict(request)), mode], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + env={**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parents[2] / "src")}, + ) + + +def test_two_processes_compete_for_last_namespace_segment(registry, namespace, tmp_path): + budget = ManagedStorageBudgetV1(registry) + for service in range(4): + for slot in range(5 if service < 3 else 4): + budget.reserve(allocation(registry, service=service, slot=slot)) + requests = [allocation(registry, service=service) for service in (4, 5)] + children = [child(tmp_path / "registry", namespace, request) for request in requests] + try: + assert [process.stdout.readline().strip() for process in children] == ["ready", "ready"] + with ThreadPoolExecutor(max_workers=2) as executor: + outputs = list(executor.map(lambda process: process.communicate("go\n", timeout=15), children)) + assert all(process.returncode == 0 for process in children) + assert sorted(output[0].strip() for output in outputs) == ["capacity", "reserved"] + with registry._database.transaction() as connection: + assert connection.execute("SELECT count(*),sum(capacity) FROM storage_allocations").fetchone() == (20, 200 * MIB) + finally: + for process in children: + if process.poll() is None: + process.terminate() + process.wait(timeout=5) + + +def test_process_exit_after_reservation_does_not_refund_or_duplicate(registry, namespace, tmp_path): + request = allocation(registry) + process = child(tmp_path / "registry", namespace, request, mode="crash") + try: + assert process.stdout.readline().strip() == "ready" + process.communicate("go\n", timeout=15) + assert process.returncode == 23 + recorded = ManagedStorageBudgetV1(registry).lookup(request) + assert recorded is not None and recorded.file_identity is None + assert ManagedStorageBudgetV1(registry).reserve(request) == recorded + finally: + if process.poll() is None: + process.terminate() + process.wait(timeout=5) + + +def test_row_capacity_is_independent_of_bytes_and_does_not_block_existing_receipts(registry): + budget = ManagedStorageBudgetV1(registry) + first = allocation(registry, kind="temporary", size=4096, instance="0" * 32) + # Exact schema fixture: 16 MiB, well below both byte ceilings; no files exist. + with registry._database.transaction(write=True) as connection: + connection.executemany("INSERT INTO storage_allocations VALUES (?,?,?,?,?,?,?,?,?,NULL,NULL)", [ + ("74656d7000000000" + f"{index + 1:016x}", first.service_id, "temporary", f"{index // 256:032x}", + index % 256, 4096, first.root_key, *first.root_identity) for index in range(4096) + ]) + connection.execute("UPDATE identity SET temporary_high_water=4096") + existing = budget.lookup(first) + assert existing is not None and budget.reserve(first) == existing + assert budget.bind_file(existing, (1, 2048)).file_identity == (1, 2048) + assert budget.reserve(first).file_identity == (1, 2048) + with pytest.raises(ManagedStorageError, match="capacity"): + budget.reserve(replace(first, instance_id=f"{16:032x}")) + with registry._database.transaction() as connection: + assert connection.execute("SELECT count(*),sum(capacity) FROM storage_allocations").fetchone() == (4096, 16 * MIB) diff --git a/tests/apphost/test_managed_temporary_pair.py b/tests/apphost/test_managed_temporary_pair.py new file mode 100644 index 000000000..5319e19a0 --- /dev/null +++ b/tests/apphost/test_managed_temporary_pair.py @@ -0,0 +1,287 @@ +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from dataclasses import replace +from threading import Barrier +from time import monotonic + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.contracts import ManagedContractError +from loushang.apphost.managed.storage_budget import ( + ManagedStorageBudgetV1, + ManagedTemporaryPairCapacityRefusedV1, +) + +from .test_managed_storage_budget import allocation +from .test_managed_storage_budget import namespace as namespace +from .test_managed_storage_budget import pytestmark as pytestmark +from .test_managed_storage_budget import registry as registry + +IDS = ("74656d70000000000000000000000001", "74656d70000000000000000000000002") +MIB = 1024 * 1024 + + +def pair(registry, *, size=4096): + first = allocation(registry, kind="temporary", size=size, instance="a" * 32) + return first, replace(first, slot=1) + + +def test_pair_success_preserves_order_and_original_ids(registry): + budget, request = ManagedStorageBudgetV1(registry), pair(registry) + result = budget.reserve_temporary_pair(request, allocation_ids=IDS) + assert tuple(item.allocation for item in result) == request + assert tuple(item.allocation_id for item in result) == IDS + assert tuple(budget.lookup(item) for item in request) == result + assert budget.lookup_temporary_pair(request, allocation_ids=IDS) == result + + +def test_released_rows_do_not_allow_old_ids_to_be_admitted_again(registry): + budget, request = ManagedStorageBudgetV1(registry), pair(registry) + assert budget.next_temporary_pair_ids() == IDS + budget.reserve_temporary_pair(request, allocation_ids=IDS) + # Simulate settled accounting only: the real native-proof release API is + # intentionally not under test here and is not implemented yet. + with budget._database.transaction(write=True) as connection: + connection.execute("DELETE FROM storage_allocations WHERE allocation_id IN (?,?)", IDS) + next_ids = budget.next_temporary_pair_ids() + assert next_ids == tuple("74656d7000000000" + f"{value:016x}" for value in (3, 4)) + with pytest.raises(ManagedStorageError, match="conflict"): + budget.reserve_temporary_pair(request, allocation_ids=IDS) + assert budget.lookup_temporary_pair(request, allocation_ids=IDS) is None + assert budget.reserve_temporary_pair(request, allocation_ids=next_ids)[0].allocation_id == next_ids[0] + + +def test_single_temporary_reservation_uses_the_same_sequence(registry): + budget, request = ManagedStorageBudgetV1(registry), pair(registry) + first = budget.reserve(request[0]) + assert first.allocation_id == IDS[0] + assert budget.next_temporary_pair_ids() == ( + IDS[1], "74656d70000000000000000000000003", + ) + + +def test_temporary_sequence_exhaustion_never_wraps(registry): + budget = ManagedStorageBudgetV1(registry) + with budget._database.transaction(write=True) as connection: + connection.execute("UPDATE identity SET temporary_high_water=?", (2**63 - 1,)) + with pytest.raises(ManagedStorageError, match="capacity"): + budget.next_temporary_pair_ids() + + +def test_noncanonical_temporary_id_rejected_before_read_or_candidate(registry): + budget, request = ManagedStorageBudgetV1(registry), pair(registry) + budget.reserve_temporary_pair(request, allocation_ids=IDS) + with budget._database.transaction(write=True) as connection: + connection.execute("UPDATE identity SET temporary_high_water=16") + connection.execute("UPDATE storage_allocations SET allocation_id=? WHERE allocation_id=?", + ("74656d7000000000000000000000000A", IDS[0])) + with pytest.raises(ManagedStorageError, match="invalid_record"): + budget.next_temporary_pair_ids() + with pytest.raises(ManagedStorageError, match="invalid_record"): + registry.list_muxes() + + +def test_combined_capacity_refusal_has_no_partial_charge(registry): + budget, request = ManagedStorageBudgetV1(registry), pair(registry, size=68 * MIB) + result = budget.reserve_temporary_pair(request, allocation_ids=IDS) + assert isinstance(result, ManagedTemporaryPairCapacityRefusedV1) + assert result.allocations == request and result.namespace_key == registry._database._namespace + assert result.allocation_ids == IDS + assert all(budget.lookup(item) is None for item in request) + + +@pytest.mark.parametrize("slot", [0, 1]) +def test_existing_slot_is_conflict_not_refusal_or_adoption(registry, slot): + budget, request = ManagedStorageBudgetV1(registry), pair(registry, size=68 * MIB) + existing = budget.reserve(request[slot]) + with pytest.raises(ManagedStorageError, match="conflict"): + budget.reserve_temporary_pair(request, allocation_ids=IDS) + assert budget.lookup(request[slot]) == existing and budget.lookup(request[1 - slot]) is None + + +def test_second_insert_failure_rolls_back_entire_pair(registry, monkeypatch): + budget, request = ManagedStorageBudgetV1(registry), pair(registry) + original = budget._database.transaction + + @contextmanager + def transaction(**kwargs): + with original(**kwargs) as connection: + class Proxy: + inserts = 0 + + def execute(self, sql, args=()): + if sql.startswith("INSERT INTO storage_allocations"): + self.inserts += 1 + if self.inserts == 2: + raise ManagedStorageError("unavailable") + return connection.execute(sql, args) + + def __getattr__(self, name): + return getattr(connection, name) + yield Proxy() + + with monkeypatch.context() as patch: + patch.setattr(budget._database, "transaction", transaction) + with pytest.raises(ManagedStorageError, match="unavailable"): + budget.reserve_temporary_pair(request, allocation_ids=IDS) + assert all(budget.lookup(item) is None for item in request) + + +@pytest.mark.parametrize("refused", [True, False]) +def test_transaction_exit_error_never_returns_capacity_refusal(registry, monkeypatch, refused): + budget, request = ManagedStorageBudgetV1(registry), pair(registry, size=68 * MIB if refused else 4096) + original = budget._database.transaction + + @contextmanager + def transaction(**kwargs): + with original(**kwargs) as connection: + yield connection + raise ManagedStorageError("capacity") # Includes a lost commit receipt. + + with monkeypatch.context() as patch: + patch.setattr(budget._database, "transaction", transaction) + with pytest.raises(ManagedStorageError, match="capacity"): + budget.reserve_temporary_pair(request, allocation_ids=IDS) + rows = tuple(budget.lookup(item) for item in request) + if refused: + assert rows == (None, None) + else: + assert tuple(item.allocation_id for item in rows) == IDS + assert budget.lookup_temporary_pair(request, allocation_ids=IDS) == rows + + +def test_pair_lookup_is_one_readonly_transaction(registry, monkeypatch): + budget, request = ManagedStorageBudgetV1(registry), pair(registry) + original = budget._database.transaction + calls = [] + + @contextmanager + def transaction(**kwargs): + calls.append(kwargs) + with original(**kwargs) as connection: + yield connection + + monkeypatch.setattr(budget._database, "transaction", transaction) + assert budget.lookup_temporary_pair(request, allocation_ids=IDS) is None + assert len(calls) == 1 and not calls[0].get("write", False) + + +@pytest.mark.parametrize("change", ["half", "other_ids", "other_slots", "other_root", "bound"]) +def test_pair_lookup_never_adopts_another_attempt(registry, change): + budget, request = ManagedStorageBudgetV1(registry), pair(registry) + if change == "half": + with registry._database.transaction(write=True) as connection: + value = request[0] + connection.execute("INSERT INTO storage_allocations VALUES (?,?,?,?,?,?,?,?,?,NULL,NULL)", + (IDS[0], *value._key(), value.capacity, value.root_key, *value.root_identity)) + connection.execute("UPDATE identity SET temporary_high_water=2") + else: + original = budget.reserve_temporary_pair(request, allocation_ids=IDS) + if change == "bound": + bound = budget.bind_file(original[0], (1, 999)) + assert budget.lookup_temporary_pair(request, allocation_ids=IDS) == (bound, original[1]) + return + if change == "other_ids": + ids = ("e" * 32, "f" * 32) + else: + ids = IDS + if change == "other_slots": + request = tuple(replace(item, slot=item.slot + 2) for item in request) + elif change == "other_root": + request = tuple(replace(item, root_identity=(1, 999)) for item in request) + with pytest.raises(ManagedStorageError, match="conflict"): + budget.lookup_temporary_pair(request, allocation_ids=ids) + + +@pytest.mark.parametrize("scope", ["instance", "namespace"]) +def test_two_contenders_only_one_pair_fits_budget(registry, scope): + budget, request = ManagedStorageBudgetV1(registry), pair(registry, size=48 * MIB) + if scope == "namespace": + for index in range(1, 4): + budget.reserve(allocation(registry, kind="temporary", size=128 * MIB, + instance="b" * 32, service=index)) + other = tuple(replace(item, slot=item.slot + 2, + instance_id="b" * 32 if scope == "namespace" else item.instance_id) for item in request) + ready = Barrier(2) + candidates = budget.next_temporary_pair_ids() + + def reserve(values, ids): + ready.wait(timeout=5) + try: + return budget.reserve_temporary_pair(values, allocation_ids=ids) + except ManagedStorageError as error: + return error + + with ThreadPoolExecutor(max_workers=2) as executor: + first = executor.submit(reserve, request, candidates) + second = executor.submit(reserve, other, candidates) + results = [first.result(timeout=10), second.result(timeout=10)] + assert sum(isinstance(result, ManagedStorageError) for result in results) == 1 + assert all(str(result) == "managed_storage_conflict" for result in results if isinstance(result, ManagedStorageError)) + assert sum(budget.lookup(item) is not None for item in (*request, *other)) == 2 + loser = request if isinstance(results[0], ManagedStorageError) else other + refused = budget.reserve_temporary_pair(loser, allocation_ids=budget.next_temporary_pair_ids()) + assert isinstance(refused, ManagedTemporaryPairCapacityRefusedV1) + + +@pytest.mark.parametrize("change", ["duplicate", "instance", "root", "length"]) +def test_bad_pair_rejected_before_transaction(registry, monkeypatch, change): + budget, request = ManagedStorageBudgetV1(registry), pair(registry) + if change == "duplicate": + request = (request[0], request[0]) + elif change == "instance": + request = (request[0], replace(request[1], instance_id="b" * 32)) + elif change == "root": + request = (request[0], replace(request[1], root_identity=(1, 999))) + else: + request = request[:1] + monkeypatch.setattr(budget._database, "transaction", lambda **kw: pytest.fail("invalid pair reached IO")) + with pytest.raises(ManagedContractError): + budget.reserve_temporary_pair(request, allocation_ids=IDS) + + +def test_existing_allocation_id_cannot_be_reused_for_new_slots(registry): + budget, request = ManagedStorageBudgetV1(registry), pair(registry) + budget.reserve_temporary_pair(request, allocation_ids=IDS) + other = tuple(replace(item, slot=item.slot + 2) for item in request) + with pytest.raises(ManagedStorageError, match="conflict"): + budget.reserve_temporary_pair(other, allocation_ids=IDS) + assert all(budget.lookup(item) is None for item in other) + + +@pytest.mark.parametrize("ids", [("c" * 32, "c" * 32), ("bad", "d" * 32), ("c" * 32,), list(IDS)]) +def test_bad_ids_never_enter_transaction(registry, monkeypatch, ids): + budget, request = ManagedStorageBudgetV1(registry), pair(registry) + monkeypatch.setattr(budget._database, "transaction", lambda **kw: pytest.fail("invalid IDs reached IO")) + with pytest.raises(ManagedContractError): + budget.reserve_temporary_pair(request, allocation_ids=ids) + + +@pytest.mark.parametrize("refused", [True, False]) +@pytest.mark.parametrize("operation", ["reserve", "lookup"]) +def test_final_deadline_prevents_pair_or_refusal_delivery(registry, monkeypatch, refused, operation): + from loushang.apphost.managed import _files + + budget, request = ManagedStorageBudgetV1(registry), pair(registry, size=68 * MIB if refused else 4096) + original = budget._database.transaction + if operation == "lookup" and not refused: + budget.reserve_temporary_pair(request, allocation_ids=IDS) + expired = False + deadline = monotonic() + 5 + + @contextmanager + def transaction(**kwargs): + nonlocal expired + with original(**kwargs) as connection: + yield connection + expired = True + + with monkeypatch.context() as patch: + patch.setattr(budget._database, "transaction", transaction) + patch.setattr(_files, "monotonic", lambda: deadline + 1 if expired else deadline - 1) + with pytest.raises(ManagedStorageError, match="busy"): + method = budget.reserve_temporary_pair if operation == "reserve" else budget.lookup_temporary_pair + method(request, allocation_ids=IDS, deadline=deadline) + assert expired + assert all((budget.lookup(item) is None) == refused for item in request) diff --git a/tests/apphost/test_managed_temporary_release.py b/tests/apphost/test_managed_temporary_release.py new file mode 100644 index 000000000..4ab942851 --- /dev/null +++ b/tests/apphost/test_managed_temporary_release.py @@ -0,0 +1,147 @@ +import os +from contextlib import contextmanager +from dataclasses import replace +from hashlib import sha256 +from time import monotonic + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.storage_budget import ManagedStorageBudgetV1 + +from .test_managed_files import directory as directory +from .test_managed_storage_budget import allocation +from .test_managed_storage_budget import namespace as namespace +from .test_managed_storage_budget import pytestmark as pytestmark +from .test_managed_storage_budget import registry as registry + + +def prepare(registry, directory, *, name="stdout", slot=0, snapshot=None): + root, owner = directory + if snapshot is None: + with owner.lock("data.lock", create=True): + snapshot = owner.append_data(name, b"original", expected=None, capacity=4096) + request = replace(allocation(registry, kind="temporary", slot=slot, size=4096, instance="a" * 32), + root_key=sha256(os.fsencode(root)).hexdigest(), root_identity=owner._identity) + budget = ManagedStorageBudgetV1(registry) + reserved = budget.bind_file(budget.reserve(request), snapshot.identity) + removal = owner.prepare_data_removal(name, "removed-" + name, expected=snapshot, capacity=4096) + return budget, reserved, removal + + +def test_release_requires_completion_and_does_not_touch_reused_slot(registry, directory): + budget, reserved, removal = prepare(registry, directory) + _, owner = directory + binding = budget.prepare_temporary_release(reserved, owner=owner, removal=removal) + with pytest.raises(ManagedStorageError, match="unavailable"): + budget.release_temporary(binding) + assert budget.lookup(reserved.allocation) == reserved + with owner.lock("data.lock"): + owner.remove_data(removal) + budget.release_temporary(binding) + assert budget.lookup(reserved.allocation) is None + replacement = budget.reserve(reserved.allocation) + assert replacement.allocation_id != reserved.allocation_id + budget.release_temporary(binding) + assert budget.lookup(reserved.allocation) == replacement + + +def test_cross_binding_two_valid_files_cannot_release_the_wrong_charge(registry, directory): + root, owner = directory + with owner.lock("data.lock", create=True): + first_snapshot = owner.append_data("stdout", b"original", expected=None, capacity=4096) + second_snapshot = owner.append_data("stderr", b"original", expected=None, capacity=4096) + budget, first, first_removal = prepare(registry, directory, snapshot=first_snapshot) + second_budget, second, second_removal = prepare( + registry, directory, name="stderr", slot=1, snapshot=second_snapshot, + ) + binding = budget.prepare_temporary_release(first, owner=owner, removal=first_removal) + with pytest.raises(ManagedStorageError, match="conflict"): + second_budget.prepare_temporary_release(first, owner=owner, removal=second_removal) + assert (root / "stderr").read_bytes() == b"original" + assert budget.lookup(first.allocation) == first + assert budget.lookup(second.allocation) == second + valid = second_budget.prepare_temporary_release(second, owner=owner, removal=second_removal) + with owner.lock("data.lock"): + owner.remove_data(first_removal) + owner.remove_data(second_removal) + budget.release_temporary(binding) + assert budget.lookup(second.allocation) == second + second_budget.release_temporary(valid) + + +def test_unknown_release_commit_retries_accounting_only(registry, directory, monkeypatch): + budget, reserved, removal = prepare(registry, directory) + _, owner = directory + binding = budget.prepare_temporary_release(reserved, owner=owner, removal=removal) + with owner.lock("data.lock"): + owner.remove_data(removal) + original = budget._database.transaction + + @contextmanager + def lost_receipt(**kwargs): + with original(**kwargs) as connection: + yield connection + raise OSError("commit receipt lost") + + with monkeypatch.context() as patch: + patch.setattr(budget._database, "transaction", lost_receipt) + with pytest.raises(OSError, match="commit receipt lost"): + budget.release_temporary(binding) + assert budget.lookup(reserved.allocation) is None + + def no_delete(*args, **kwargs): + raise AssertionError("accounting retry must not delete files") + monkeypatch.setattr(os, "unlink", no_delete) + budget.release_temporary(binding) + + +def test_release_capacity_failure_keeps_original_charge_and_completion(registry, directory, monkeypatch): + budget, reserved, removal = prepare(registry, directory) + _, owner = directory + binding = budget.prepare_temporary_release(reserved, owner=owner, removal=removal) + with owner.lock("data.lock"): + owner.remove_data(removal) + + def reject(connection): + raise ManagedStorageError("capacity") + + with monkeypatch.context() as patch: + patch.setattr(budget._database, "admit_growth", reject) + with pytest.raises(ManagedStorageError, match="capacity"): + budget.release_temporary(binding) + assert budget.lookup(reserved.allocation) == reserved + assert removal.phase == "complete" + budget.release_temporary(binding) + with monkeypatch.context() as patch: + patch.setattr(budget._database, "admit_growth", reject) + budget.release_temporary(binding) # Already missing: no mutation admission. + + +@pytest.mark.parametrize("expired", [False, True]) +def test_release_deadline_covers_native_completion_mutex(registry, directory, monkeypatch, expired): + budget, reserved, removal = prepare(registry, directory) + _, owner = directory + binding = budget.prepare_temporary_release(reserved, owner=owner, removal=removal) + with owner.lock("data.lock"): + owner.remove_data(removal) + waits = [] + + class HeldMutex: + def acquire(self, *, timeout): + assert not expired, "expired calls must not enter the mutex" + waits.append(timeout) + assert 0 <= timeout <= 1 + return False + + def no_database(**kwargs): + raise AssertionError("completion deadline must fail before database entry") + + with monkeypatch.context() as patch: + patch.setattr(owner, "_mutex", HeldMutex()) + patch.setattr(budget._database, "transaction", no_database) + with pytest.raises(ManagedStorageError, match="busy"): + budget.release_temporary(binding, deadline=monotonic() + (-1 if expired else 1)) + assert len(waits) == (0 if expired else 1) + assert budget.lookup(reserved.allocation) == reserved + budget.release_temporary(binding) diff --git a/tests/apphost/test_managed_trace_application.py b/tests/apphost/test_managed_trace_application.py new file mode 100644 index 000000000..438deb13f --- /dev/null +++ b/tests/apphost/test_managed_trace_application.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import json +from time import monotonic + +import pytest + +from loushang.apphost.managed import _files +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.contracts import ManagedStopEvidenceV1 + +from .test_managed_lifecycle import _identity +from .test_managed_lifecycle import owners as owners +from .test_managed_lifecycle import pytestmark as pytestmark + + +def started(journal): + first = journal.prepare("c" * 32, expected=None) + journal.register_native(first.handoff.instance, "c" * 32, _identity()) + return first.handoff.instance + + +def publish(journal, instance, deadline_ms): + return journal.record_trace_application(instance, "c" * 32, + native_identity=_identity(), trace_deadline_ms=deadline_ms) + + +def test_trace_fact_is_idempotent_preserved_and_not_inherited_by_successor(owners): + _, journal, _, _ = owners + instance = started(journal) + deadline = int((monotonic() + 60) * 1000) + applied = publish(journal, instance, deadline) + assert applied.trace_application.deadline_ms == deadline + assert publish(journal, instance, deadline) == applied + with pytest.raises(ManagedStorageError, match="conflict"): + publish(journal, instance, deadline + 1) + committed = journal.commit(instance, "c" * 32, native_identity=_identity()) + assert committed.trace_application == applied.trace_application + journal.request_stop(instance) + complete = journal.record_stop_evidence(ManagedStopEvidenceV1(instance, True, True, True)) + assert publish(journal, instance, deadline) == complete + fresh = journal.prepare("d" * 32, expected=complete) + assert fresh.trace_application is None + assert journal.read_transition().previous.trace_application == applied.trace_application + with pytest.raises(ManagedStorageError, match="conflict"): + publish(journal, instance, deadline) + + +def test_trace_receipt_cannot_consume_control_headroom(owners, monkeypatch): + registry, journal, _, _ = owners + instance = started(journal) + def full(connection): + raise ManagedStorageError("capacity") + monkeypatch.setattr(registry._database, "admit_growth", full) + with pytest.raises(ManagedStorageError, match="capacity"): + publish(journal, instance, int((monotonic() + 60) * 1000)) + assert journal.read().trace_application is None + assert journal.request_stop(instance).handoff.stop_requested + + +@pytest.mark.parametrize("phase", ["growth", "save"]) +def test_expiry_during_first_publication_rolls_back_fact_and_revision(owners, monkeypatch, phase): + registry, journal, _, _ = owners + instance = started(journal) + before = journal.read() + now = [monotonic()] + deadline = int((now[0] + 60) * 1000) + monkeypatch.setattr(_files, "monotonic", lambda: now[0]) + target, name = (registry._database, "admit_growth") if phase == "growth" else (journal, "_save") + original = getattr(target, name) + def expires(*args, **kwargs): + result = original(*args, **kwargs) + now[0] = deadline / 1000 + 1 + return result + with monkeypatch.context() as patch: + patch.setattr(target, name, expires) + with pytest.raises(ManagedStorageError, match="busy"): + publish(journal, instance, deadline) + assert journal.read() == before + + +def test_same_trace_fact_can_be_reobserved_after_expiry(owners, monkeypatch): + _, journal, _, _ = owners + instance = started(journal) + deadline = int((monotonic() + 60) * 1000) + applied = publish(journal, instance, deadline) + monkeypatch.setattr(_files, "monotonic", lambda: deadline / 1000 + 1) + assert publish(journal, instance, deadline) == applied + + +@pytest.mark.parametrize("reason", ["stopped", "expired", "wrong_attempt", "unbound"]) +def test_trace_cannot_first_publish_without_current_eligible_binding(owners, reason): + _, journal, _, _ = owners + if reason == "unbound": + instance = journal.prepare("c" * 32, expected=None).handoff.instance + else: + instance = started(journal) + if reason == "stopped": + journal.request_stop(instance) + deadline = 1 if reason == "expired" else int((monotonic() + 60) * 1000) + before = journal.read() + with pytest.raises(ManagedStorageError): + journal.record_trace_application(instance, "d" * 32 if reason == "wrong_attempt" else "c" * 32, + native_identity=_identity(), trace_deadline_ms=deadline) + assert journal.read() == before + + +@pytest.mark.parametrize("damage", ["instance_id", "attempt_id", "configuration", "v"]) +def test_corrupt_trace_fact_is_rejected_by_state_decoder(owners, damage): + registry, journal, _, _ = owners + instance = started(journal) + publish(journal, instance, int((monotonic() + 60) * 1000)) + with registry._database.transaction(write=True) as connection: + row = json.loads(connection.execute("SELECT trace_application FROM instances").fetchone()[0]) + row[damage] = 2 if damage == "v" else "f" * 32 + connection.execute("UPDATE instances SET trace_application=?", (json.dumps(row, sort_keys=True, separators=(",", ":")),)) + with pytest.raises(ManagedStorageError, match="invalid_record"): + journal.read() diff --git a/tests/apphost/test_managed_trace_buffer.py b/tests/apphost/test_managed_trace_buffer.py new file mode 100644 index 000000000..8e42da79e --- /dev/null +++ b/tests/apphost/test_managed_trace_buffer.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import json +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from loushang.apphost.managed.trace_buffer import ManagedTraceBuffer +from loushang.foundation.observability.records import DebugEventRecord, ProblemRecord + + +def event(**data): + return DebugEventRecord("turn.start.performance", "turn", {"total_ms": 12.5, **data}) + + +def test_projection_never_copies_sensitive_or_nested_fields(): + class Forbidden: + def __deepcopy__(self, memo): + pytest.fail("must not copy arbitrary trace data") + + buffer = ManagedTraceBuffer("a" * 32, 10, clock=lambda: 0) + record = event(prompt="secret", milestones=Forbidden(), model_id="private", startup_ms=3) + buffer.write_debug_event(record) + record.data["total_ms"] = 900 + assert json.loads(buffer.take()) == { + "v": 1, "instanceId": "a" * 32, "event": "turn", "total_ms": 12.5, "startup_ms": 3, + } + buffer.write_problem(ProblemRecord("application_failed", message="secret", exception_message="token")) + assert json.loads(buffer.take()) == { + "v": 1, "instanceId": "a" * 32, "event": "problem", "code": "application_failed", + } + buffer.write_problem(ProblemRecord("arbitrary secret code")) + assert buffer.take() is None + + +@pytest.mark.parametrize("invalid", [True, float("nan"), float("inf"), -1, 86400001, "secret", {}]) +def test_invalid_numeric_fields_are_not_serialized(invalid): + buffer = ManagedTraceBuffer("a" * 32, 10, clock=lambda: 0) + buffer.write_debug_event(event(total_ms=invalid)) + assert buffer.take() is None + + +def test_count_byte_limits_fence_and_expiry(): + now = [0] + buffer = ManagedTraceBuffer("a" * 32, 10, clock=lambda: now[0], max_records=2) + for _ in range(4): + buffer.write_debug_event(event()) + assert buffer.snapshot()[::2] == (2, 2) + buffer.fence() + buffer.write_debug_event(event()) + assert buffer.take() is not None + now[0] = 10 + assert buffer.take() is None + assert buffer.snapshot() == (0, 0, 4) + limited = ManagedTraceBuffer("b" * 32, 10, clock=lambda: 0, max_bytes=512) + for _ in range(128): + limited.write_debug_event(event()) + count, size, dropped = limited.snapshot() + assert 0 < count < 128 and size <= 512 and count + dropped == 128 + + +def test_multithreaded_producers_are_bounded_and_contention_never_waits(): + buffer = ManagedTraceBuffer("a" * 32, 10, clock=lambda: 0) + with buffer._lock: + # A blocking producer lock would deadlock this same-thread call. + buffer.write_debug_event(event()) + assert buffer.snapshot() == (0, 0, 0) + def produce(): + for _ in range(500): + buffer.write_debug_event(event()) + with ThreadPoolExecutor(max_workers=4) as pool: + list(pool.map(lambda _: produce(), range(4))) + count, size, dropped = buffer.snapshot() + assert count <= 128 and size <= 65536 and dropped <= 2000 + buffer.fence() + frames = [] + while (frame := buffer.take()) is not None: + frames.append(frame) + assert len(frames) == count and sum(map(len, frames)) == size diff --git a/tests/apphost/test_managed_trace_log.py b/tests/apphost/test_managed_trace_log.py new file mode 100644 index 000000000..adb98d1ad --- /dev/null +++ b/tests/apphost/test_managed_trace_log.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import json +from threading import Event, Thread +from time import monotonic + +import pytest + +from loushang.apphost.managed import storage_budget +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.trace_buffer import ManagedTraceBuffer +from loushang.apphost.managed.trace_log import ManagedTraceLog +from loushang.foundation.observability.records import DebugEventRecord + +from .test_managed_event_log import event +from .test_managed_event_log import log as log +from .test_managed_event_log import namespace as namespace +from .test_managed_event_log import pytestmark as pytestmark +from .test_managed_event_log import registry as registry + + +def frame(): + buffer = ManagedTraceBuffer("a" * 32, monotonic() + 60) + buffer.write_debug_event(DebugEventRecord("turn.start.performance", "turn", {"total_ms": 12})) + result = buffer.take() + assert result is not None + return result + + +def test_prepare_waits_with_original_deadline_for_all_registry_steps(log, monkeypatch): + lifecycle, directory, service = log + writer = ManagedTraceLog(directory, lifecycle._budget, service) + deadline = monotonic() + 5 + calls = [] + for name in ("lookup", "reserve", "bind_file"): + original = getattr(lifecycle._budget, name) + def checked(*args, _name=name, _original=original, **kwargs): + assert kwargs["deadline"] == deadline + assert kwargs["wait_for_lock"] is True + calls.append(_name) + return _original(*args, **kwargs) + monkeypatch.setattr(lifecycle._budget, name, checked) + writer.prepare(deadline=deadline) + assert calls == ["lookup", "lookup", "reserve", "bind_file", "reserve", "bind_file"] + + +def test_trace_rotates_reopens_and_coexists_with_lifecycle(log): + lifecycle, directory, service = log + for _ in range(25): + lifecycle.write(event()) + writer = ManagedTraceLog(directory, lifecycle._budget, service, segment_bytes=1024) + payload = frame() + identities = {} + for index in range(30): + assert writer.write(payload, deadline=monotonic() + 30) == index + 1 + for path in directory._root.glob("trace-*.jsonl"): + assert path.stat().st_size <= 1024 + if path.name in identities: + assert path.stat().st_ino == identities[path.name] + identities[path.name] = path.stat().st_ino + assert len(identities) == 2 + reopened = ManagedTraceLog(directory, lifecycle._budget, service, segment_bytes=1024) + assert reopened.write(payload, deadline=monotonic() + 30) == 31 + assert lifecycle.write(event()) == 26 + assert lifecycle.read_tail()[-1].sequence == 26 + assert sum(lifecycle._budget.lookup(writer._allocation(slot)).allocation.capacity for slot in range(2)) == 20 * 1024**2 + + +def test_trace_prepare_admits_both_slots_without_emitting_an_event(log): + lifecycle, directory, service = log + writer = ManagedTraceLog(directory, lifecycle._budget, service) + writer.prepare(deadline=monotonic() + 30) + identities = {} + for slot in range(2): + path = directory._root / f"trace-{slot}.jsonl" + assert path.read_bytes() == b"" + reservation = lifecycle._budget.lookup(writer._allocation(slot)) + assert reservation.file_identity == (path.stat().st_dev, path.stat().st_ino) + identities[slot] = reservation.allocation_id + writer.prepare(deadline=monotonic() + 30) + assert {slot: lifecycle._budget.lookup(writer._allocation(slot)).allocation_id for slot in range(2)} == identities + assert writer.write(frame(), deadline=monotonic() + 30) == 1 + + +def test_invalid_or_expired_trace_has_no_io(log): + lifecycle, directory, service = log + writer = ManagedTraceLog(directory, lifecycle._budget, service) + value = json.loads(frame()) + value["prompt"] = "secret" + with pytest.raises(ManagedStorageError, match="invalid_record"): + writer.write((json.dumps(value) + "\n").encode(), deadline=monotonic() + 30) + with pytest.raises(ManagedStorageError, match="busy"): + writer.write(frame(), deadline=0) + assert not tuple(directory._root.iterdir()) + + +def test_original_mutex_wait_obeys_deadline_before_io(log): + lifecycle, directory, service = log + writer = ManagedTraceLog(directory, lifecycle._budget, service) + held, release = Event(), Event() + def hold(): + with directory._mutex: + held.set() + release.wait(10) + holder = Thread(target=hold) + holder.start() + try: + assert held.wait(5) + with pytest.raises(ManagedStorageError, match="busy"): + writer.write(frame(), deadline=monotonic() + 0.05) + assert holder.is_alive() and not release.is_set() + assert not writer._failed + assert not tuple(directory._root.iterdir()) + finally: + release.set() + holder.join(5) + assert not holder.is_alive() + assert writer.write(frame(), deadline=monotonic() + 30) == 1 + + +def test_trace_capacity_failure_seals_without_replay(log, monkeypatch): + lifecycle, directory, service = log + monkeypatch.setattr(storage_budget, "LOG_NAMESPACE_BYTES", 10 * 1024**2) + writer = ManagedTraceLog(directory, lifecycle._budget, service, segment_bytes=1024) + payload = frame() + with pytest.raises(ManagedStorageError, match="capacity"): + for _ in range(30): + writer.write(payload, deadline=monotonic() + 30) + before = (directory._root / "trace-0.jsonl").read_bytes() + with pytest.raises(ManagedStorageError, match="closed"): + writer.write(payload, deadline=monotonic() + 30) + assert (directory._root / "trace-0.jsonl").read_bytes() == before + + +def test_trace_lost_append_receipt_never_replays(log, monkeypatch): + lifecycle, directory, service = log + writer = ManagedTraceLog(directory, lifecycle._budget, service) + payload = frame() + writer.write(payload, deadline=monotonic() + 30) + original = directory.append_data + def lost(*args, **kwargs): + original(*args, **kwargs) + raise ManagedStorageError("unavailable") + with monkeypatch.context() as patch: + patch.setattr(directory, "append_data", lost) + with pytest.raises(ManagedStorageError, match="unavailable"): + writer.write(payload, deadline=monotonic() + 30) + before = (directory._root / "trace-0.jsonl").read_bytes() + with pytest.raises(ManagedStorageError, match="closed"): + writer.write(payload, deadline=monotonic() + 30) + assert (directory._root / "trace-0.jsonl").read_bytes() == before + + +@pytest.mark.parametrize("committed", [False, True]) +def test_trace_first_bind_failure_retains_charge_and_never_adopts_unbound(log, monkeypatch, committed): + lifecycle, directory, service = log + writer = ManagedTraceLog(directory, lifecycle._budget, service) + original = lifecycle._budget.bind_file + def lost(*args, **kwargs): + if committed: + original(*args, **kwargs) + raise ManagedStorageError("unavailable") + with monkeypatch.context() as patch: + patch.setattr(lifecycle._budget, "bind_file", lost) + with pytest.raises(ManagedStorageError, match="unavailable"): + writer.write(frame(), deadline=monotonic() + 30) + assert (directory._root / "trace-0.jsonl").read_bytes() == b"" + reservation = lifecycle._budget.lookup(writer._allocation(0)) + assert reservation is not None and reservation.allocation.capacity == 10 * 1024**2 + assert (reservation.file_identity is not None) == committed + with pytest.raises(ManagedStorageError, match="closed"): + writer.write(frame(), deadline=monotonic() + 30) + reopened = ManagedTraceLog(directory, lifecycle._budget, service) + if committed: + # A new explicitly admitted write may use a bound empty file. This is + # not retry of the old writer or adoption of an unbound reservation. + assert reopened.write(frame(), deadline=monotonic() + 30) == 1 + else: + with pytest.raises(ManagedStorageError, match="conflict"): + reopened.write(frame(), deadline=monotonic() + 30) + assert (directory._root / "trace-0.jsonl").read_bytes() == b"" diff --git a/tests/apphost/test_managed_uncreated_release.py b/tests/apphost/test_managed_uncreated_release.py new file mode 100644 index 000000000..7fbed26fc --- /dev/null +++ b/tests/apphost/test_managed_uncreated_release.py @@ -0,0 +1,207 @@ +import os +from contextlib import contextmanager +from dataclasses import replace +from hashlib import sha256 + +import pytest + +from loushang.apphost.managed import storage_budget +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.storage_budget import ManagedStorageBudgetV1 + +from .test_managed_files import directory as directory +from .test_managed_storage_budget import allocation +from .test_managed_storage_budget import namespace as namespace +from .test_managed_storage_budget import pytestmark as pytestmark +from .test_managed_storage_budget import registry as registry + + +def prepare(registry, directory): + root, owner = directory + allocations = tuple(replace( + allocation(registry, kind="temporary", slot=slot, size=4096, instance="a" * 32), + root_key=sha256(os.fsencode(root)).hexdigest(), root_identity=owner._identity, + ) for slot in (0, 1)) + budget = ManagedStorageBudgetV1(registry) + attempt = budget.prepare_temporary_creation(allocations, owner=owner) + return budget, attempt + + +def test_uncreated_release_never_uses_other_stream_proof(registry, directory): + root, owner = directory + budget, attempt = prepare(registry, directory) + first, second = attempt.creations + reservations = budget.reserve_temporary_creation(attempt) + with owner.lock("data.lock", create=True): + owner.create_data(second, binding=attempt) # File exists; inode is not bound yet. + owner.fence_data_creation(first, binding=attempt) + budget.release_uncreated_temporary(attempt, 0) + with pytest.raises(ManagedStorageError, match="unavailable"): + budget.release_uncreated_temporary(attempt, 1) + assert budget.lookup(attempt.allocations[0]) is None + assert budget.lookup(attempt.allocations[1]) == reservations[1] + assert (root / second.name).exists() + replacement = budget.reserve(attempt.allocations[0]) + budget.release_uncreated_temporary(attempt, 0) + assert budget.lookup(attempt.allocations[0]) == replacement + + +def test_unknown_reserve_cannot_mint_uncreated_refund(registry, directory, monkeypatch): + _, owner = directory + budget, attempt = prepare(registry, directory) + original = budget._database.transaction + + @contextmanager + def lost_receipt(**kwargs): + with original(**kwargs) as connection: + yield connection + raise OSError("lost reserve commit receipt") + + with monkeypatch.context() as patch: + patch.setattr(budget._database, "transaction", lost_receipt) + with pytest.raises(OSError): + budget.reserve_temporary_creation(attempt) + for creation in attempt.creations: + owner.fence_data_creation(creation, binding=attempt) + with pytest.raises(ManagedStorageError, match="unavailable"): + budget.release_uncreated_temporary(attempt, 0) + with pytest.raises(ManagedStorageError, match="conflict"): + budget.reserve_temporary_creation(attempt) + assert all(budget.lookup(allocation) is not None for allocation in attempt.allocations) + result = budget.reconcile_temporary_creation(attempt) + assert isinstance(result, tuple) and attempt.phase == "reserved" + for index in (0, 1): + budget.release_uncreated_temporary(attempt, index) + with budget._database.transaction() as connection: + assert connection.execute("SELECT count(*) FROM storage_creation_origins").fetchone()[0] == 0 + + +def test_unknown_refund_commit_retries_original_accounting_only(registry, directory, monkeypatch): + _, owner = directory + budget, attempt = prepare(registry, directory) + budget.reserve_temporary_creation(attempt) + for creation in attempt.creations: + owner.fence_data_creation(creation, binding=attempt) + original = budget._database.transaction + + @contextmanager + def lost_receipt(**kwargs): + with original(**kwargs) as connection: + yield connection + raise OSError("lost refund commit receipt") + + with monkeypatch.context() as patch: + patch.setattr(budget._database, "transaction", lost_receipt) + with pytest.raises(OSError): + budget.release_uncreated_temporary(attempt, 0) + budget.release_uncreated_temporary(attempt, 0) + budget.release_uncreated_temporary(attempt, 1) + assert all(budget.lookup(allocation) is None for allocation in attempt.allocations) + + +def test_capacity_refusal_then_released_space_allows_same_owner_retry(registry, directory, monkeypatch): + from .test_managed_temporary_release import prepare as prepare_blocker + + _, owner = directory + monkeypatch.setattr(storage_budget, "TEMPORARY_INSTANCE_BYTES", 8192) + budget, blocker, removal = prepare_blocker(registry, directory, slot=2) + release = budget.prepare_temporary_release(blocker, owner=owner, removal=removal) + budget, attempt = prepare(registry, directory) + refused = budget.reserve_temporary_creation(attempt) + assert isinstance(refused, storage_budget.ManagedTemporaryPairCapacityRefusedV1) + for creation in attempt.creations: + owner.fence_data_creation(creation, binding=attempt) + with owner.lock("data.lock"): + owner.remove_data(removal) + budget.release_temporary(release) + budget, fresh = prepare(registry, directory) + assert fresh.allocation_ids[0] > attempt.allocation_ids[1] + result = budget.reserve_temporary_creation(fresh) + assert isinstance(result, tuple) + for index, creation in enumerate(fresh.creations): + owner.fence_data_creation(creation, binding=fresh) + budget.release_uncreated_temporary(fresh, index) + + +def test_rolled_back_attempt_cannot_adopt_other_origin_with_same_ids(registry, directory, monkeypatch): + _, owner = directory + budget, attempt = prepare(registry, directory) + original = budget._database.transaction + + @contextmanager + def rollback(**kwargs): + with original(**kwargs) as connection: + yield connection + raise OSError("rollback original transaction") + + with monkeypatch.context() as patch: + patch.setattr(budget._database, "transaction", rollback) + with pytest.raises(ManagedStorageError, match="unavailable"): + budget.reserve_temporary_creation(attempt) + for creation in attempt.creations: + owner.fence_data_creation(creation, binding=attempt) + budget.reserve_temporary_pair(attempt.allocations, allocation_ids=attempt.allocation_ids, + _creation_origin="b" * 32 if attempt.origin_id != "b" * 32 else "c" * 32) + with pytest.raises(ManagedStorageError, match="conflict"): + budget.reconcile_temporary_creation(attempt) + assert attempt.phase == "unknown" + assert all(budget.lookup(allocation) is not None for allocation in attempt.allocations) + + +def test_rolled_back_original_attempt_reconciles_absence_without_creation_authority(registry, directory, monkeypatch): + _, owner = directory + budget, attempt = prepare(registry, directory) + original = budget._database.transaction + + @contextmanager + def rollback(**kwargs): + with original(**kwargs) as connection: + yield connection + raise OSError("rollback original transaction") + + with monkeypatch.context() as patch: + patch.setattr(budget._database, "transaction", rollback) + with pytest.raises(ManagedStorageError, match="unavailable"): + budget.reserve_temporary_creation(attempt) + assert budget.reconcile_temporary_creation(attempt) is None + assert attempt.phase == "unreserved" + assert all(creation._completion is None for creation in attempt.creations) + with pytest.raises(ManagedStorageError, match="conflict"): + budget.reserve_temporary_creation(attempt) + for creation in attempt.creations: + owner.fence_data_creation(creation, binding=attempt) + + +def test_second_origin_insert_failure_rolls_back_pair_and_sequence(registry, directory, monkeypatch): + _, owner = directory + budget, attempt = prepare(registry, directory) + original = budget._database.transaction + + class Connection: + def __init__(self, connection): + self.connection = connection + + def __getattr__(self, name): + return getattr(self.connection, name) + + def execute(self, sql, parameters=()): + if sql.startswith("INSERT INTO storage_creation_origins") and parameters[0] == attempt.allocation_ids[1]: + raise OSError("second origin insert failed") + return self.connection.execute(sql, parameters) + + @contextmanager + def fail_second(**kwargs): + with original(**kwargs) as connection: + yield Connection(connection) + + with monkeypatch.context() as patch: + patch.setattr(budget._database, "transaction", fail_second) + with pytest.raises(ManagedStorageError, match="unavailable"): + budget.reserve_temporary_creation(attempt) + with original() as connection: + assert connection.execute("SELECT count(*) FROM storage_creation_origins").fetchone()[0] == 0 + assert connection.execute("SELECT count(*) FROM storage_allocations").fetchone()[0] == 0 + assert connection.execute("SELECT temporary_high_water FROM identity").fetchone()[0] == 0 + assert budget.reconcile_temporary_creation(attempt) is None + for creation in attempt.creations: + owner.fence_data_creation(creation, binding=attempt) diff --git a/tests/apphost/test_native_output_capture.py b/tests/apphost/test_native_output_capture.py new file mode 100644 index 000000000..8f162ea7f --- /dev/null +++ b/tests/apphost/test_native_output_capture.py @@ -0,0 +1,141 @@ +import os +from contextlib import contextmanager +from dataclasses import replace +from hashlib import sha256 + +import pytest + +from loushang.apphost.managed._capture_native import NativeOutputCapture +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.storage_budget import ManagedStorageBudgetV1 + +from .test_managed_files import directory as directory +from .test_managed_storage_budget import allocation +from .test_managed_storage_budget import namespace as namespace +from .test_managed_storage_budget import pytestmark as pytestmark +from .test_managed_storage_budget import registry as registry + + +def capture(registry, directory): + root, owner = directory + requests = tuple(replace( + allocation(registry, kind="temporary", slot=slot, size=4096, instance="a" * 32), + root_key=sha256(os.fsencode(root)).hexdigest(), root_identity=owner._identity, + ) for slot in (0, 1)) + return NativeOutputCapture(owner, ManagedStorageBudgetV1(registry), requests) + + +def test_two_stream_capture_reads_sealed_output_then_deletes_and_refunds(registry, directory): + root, owner = directory + storage = capture(registry, directory) + assert not storage.cleanup_pending + assert storage.prepare() + storage.append(0, b"hello") + storage.append(1, b"warning") + first, second = storage.seal() + assert (first.size, second.size) == (5, 7) + assert storage.read(0, max_bytes=5) == b"hello" + assert storage.read(1, max_bytes=7) == b"warning" + storage.close() + storage.close() + assert not storage.cleanup_pending and not owner.cleanup_pending + assert all(storage.budget.lookup(request) is None for request in storage.allocations) + assert list(root.iterdir()) == [root / "capture.lock"] + + +def test_retention_overflow_is_sticky_for_both_streams_but_cleanup_refunds(registry, directory): + storage = capture(registry, directory) + assert storage.prepare() + storage.append(0, b"x" * 4096) + storage.append(0, b"overflow") + storage.append(1, b"must not be retained") + assert storage.snapshots[1].size == 0 + assert storage.seal() is None + with pytest.raises(ManagedStorageError, match="closed"): + storage.read(0, max_bytes=4096) + storage.close() + assert not storage.cleanup_pending + assert all(storage.budget.lookup(request) is None for request in storage.allocations) + + +def test_bind_failure_keeps_original_create_for_close_retry(registry, directory, monkeypatch): + storage = capture(registry, directory) + original = storage.budget.bind_file + calls = 0 + + def fail_once(*args, **kwargs): + nonlocal calls + calls += 1 + result = original(*args, **kwargs) + if calls == 1: + raise OSError("bind committed but receipt lost") + return result + + monkeypatch.setattr(storage.budget, "bind_file", fail_once) + with pytest.raises(ManagedStorageError, match="unavailable"): + storage.prepare() + assert storage.cleanup_pending + storage.close() + assert not storage.cleanup_pending + assert all(storage.budget.lookup(request) is None for request in storage.allocations) + + +def test_registration_validation_and_fence_failure_keep_original_attempt(registry, directory, monkeypatch): + _, owner = directory + storage = capture(registry, directory) + original_fence = owner.fence_data_creation + + def validation_failure(*args, **kwargs): + raise ManagedStorageError("conflict") + + def fence_failure(*args, **kwargs): + raise ManagedStorageError("busy") + + with monkeypatch.context() as patch: + patch.setattr(storage.budget, "_creation_attempt_target", validation_failure) + patch.setattr(owner, "fence_data_creation", fence_failure) + with pytest.raises(ManagedStorageError): + storage.prepare() + assert storage.attempt is not None and storage.cleanup_pending + with pytest.raises(ManagedStorageError, match="busy"): + storage.close() + assert storage.cleanup_pending and not storage.closed + original_pair = storage.attempt.creations + storage.close() + assert storage.attempt.creations is original_pair and not storage.cleanup_pending + assert all(original_fence(creation, binding=storage.attempt) for creation in original_pair) + + +def test_last_refund_receipt_loss_retries_without_native_lock(registry, directory, monkeypatch): + storage = capture(registry, directory) + storage.prepare() + original = storage.budget._database.transaction + original_release = storage.budget.release_temporary + calls = 0 + + @contextmanager + def lost_receipt(**kwargs): + with original(**kwargs) as connection: + yield connection + raise OSError("refund committed but receipt lost") + + def lose_second(binding, **kwargs): + nonlocal calls + calls += 1 + if calls == 2: + with monkeypatch.context() as patch: + patch.setattr(storage.budget._database, "transaction", lost_receipt) + return original_release(binding, **kwargs) + return original_release(binding, **kwargs) + + monkeypatch.setattr(storage.budget, "release_temporary", lose_second) + with pytest.raises(OSError): + storage.close() + assert storage.deleted == [True, True] and storage.cleanup_pending + + def unexpected(*args, **kwargs): + raise AssertionError("only original accounting may be retried") + + monkeypatch.setattr(storage.owner, "lock", unexpected) + storage.close() + assert not storage.cleanup_pending From a807f52238b2c4f529a8729a32df0c988eea9b81 Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 05:59:27 +0000 Subject: [PATCH 25/44] feat(apphost): supervise managed child lifetimes --- src/loushang/apphost/managed/_lifetime.py | 106 +++ src/loushang/apphost/managed/_process.py | 162 ++++ src/loushang/apphost/managed/bootstrap.py | 528 ++++++++++++ src/loushang/apphost/managed/child.py | 273 +++++- src/loushang/apphost/managed/coordinator.py | 250 ++++++ src/loushang/apphost/managed/mux_creation.py | 181 ++++ .../apphost/managed/mux_management.py | 795 ++++++++++++++++++ src/loushang/apphost/managed/mux_probe.py | 256 ++++++ src/loushang/apphost/managed/stopper.py | 180 ++++ src/loushang/appserver/_local_peer.py | 42 +- .../appserver/_local_record_values.py | 40 +- src/loushang/appserver/connection.py | 81 +- src/loushang/appserver/local.py | 48 +- src/loushang/appserver/local_record.py | 7 +- src/loushang/appserver/managed_mux_wire.py | 221 +++++ .../appserver/protocol/connection_profile.py | 52 ++ src/loushang/appserver/remote_client.py | 108 ++- src/loushang/appservice/client_scope.py | 50 +- src/loushang/appservice/continuity.py | 127 ++- src/loushang/appservice/continuity_runtime.py | 178 +++- src/loushang/appservice/managed_mux.py | 57 ++ src/loushang/appservice/managed_mux_close.py | 86 ++ src/loushang/appservice/runtime.py | 415 ++++++++- .../foundation/observability/runtime.py | 13 +- tests/apphost/_managed_process_fault.py | 82 ++ tests/apphost/test_managed_bootstrap.py | 589 +++++++++++++ tests/apphost/test_managed_bootstrap_trace.py | 130 +++ tests/apphost/test_managed_child.py | 86 +- tests/apphost/test_managed_child_logging.py | 307 +++++++ tests/apphost/test_managed_child_trace.py | 123 +++ tests/apphost/test_managed_coordinator.py | 318 +++++++ tests/apphost/test_managed_lifetime.py | 446 ++++++++++ tests/apphost/test_managed_mux_closure.py | 638 ++++++++++++++ tests/apphost/test_managed_mux_creation.py | 365 ++++++++ tests/apphost/test_managed_mux_management.py | 462 ++++++++++ tests/apphost/test_managed_mux_probe.py | 266 ++++++ tests/apphost/test_managed_mux_recovery.py | 712 ++++++++++++++++ tests/apphost/test_managed_process.py | 335 ++++++++ tests/apphost/test_managed_process_signals.py | 69 ++ tests/apphost/test_managed_stopper.py | 192 +++++ tests/appserver/test_connection_profiles.py | 10 +- tests/appserver/test_local.py | 51 +- .../appserver/test_managed_mux_close_wire.py | 282 +++++++ tests/appserver/test_managed_mux_wire.py | 545 ++++++++++++ .../test_session_discovery_connection.py | 11 + tests/appservice/test_continuity_runtime.py | 102 +++ tests/appservice/test_managed_mux.py | 564 +++++++++++++ .../test_managed_mux_close_continuity.py | 246 ++++++ .../test_managed_mux_close_recovery.py | 406 +++++++++ .../test_managed_mux_close_runtime.py | 515 ++++++++++++ .../foundation/observability/test_runtime.py | 68 +- 51 files changed, 12076 insertions(+), 100 deletions(-) create mode 100644 src/loushang/apphost/managed/_lifetime.py create mode 100644 src/loushang/apphost/managed/_process.py create mode 100644 src/loushang/apphost/managed/bootstrap.py create mode 100644 src/loushang/apphost/managed/coordinator.py create mode 100644 src/loushang/apphost/managed/mux_creation.py create mode 100644 src/loushang/apphost/managed/mux_management.py create mode 100644 src/loushang/apphost/managed/mux_probe.py create mode 100644 src/loushang/apphost/managed/stopper.py create mode 100644 src/loushang/appserver/managed_mux_wire.py create mode 100644 src/loushang/appservice/managed_mux.py create mode 100644 src/loushang/appservice/managed_mux_close.py create mode 100644 tests/apphost/_managed_process_fault.py create mode 100644 tests/apphost/test_managed_bootstrap.py create mode 100644 tests/apphost/test_managed_bootstrap_trace.py create mode 100644 tests/apphost/test_managed_child_logging.py create mode 100644 tests/apphost/test_managed_child_trace.py create mode 100644 tests/apphost/test_managed_coordinator.py create mode 100644 tests/apphost/test_managed_lifetime.py create mode 100644 tests/apphost/test_managed_mux_closure.py create mode 100644 tests/apphost/test_managed_mux_creation.py create mode 100644 tests/apphost/test_managed_mux_management.py create mode 100644 tests/apphost/test_managed_mux_probe.py create mode 100644 tests/apphost/test_managed_mux_recovery.py create mode 100644 tests/apphost/test_managed_process.py create mode 100644 tests/apphost/test_managed_process_signals.py create mode 100644 tests/apphost/test_managed_stopper.py create mode 100644 tests/appserver/test_managed_mux_close_wire.py create mode 100644 tests/appserver/test_managed_mux_wire.py create mode 100644 tests/appservice/test_managed_mux.py create mode 100644 tests/appservice/test_managed_mux_close_continuity.py create mode 100644 tests/appservice/test_managed_mux_close_recovery.py create mode 100644 tests/appservice/test_managed_mux_close_runtime.py diff --git a/src/loushang/apphost/managed/_lifetime.py b/src/loushang/apphost/managed/_lifetime.py new file mode 100644 index 000000000..bea9b390a --- /dev/null +++ b/src/loushang/apphost/managed/_lifetime.py @@ -0,0 +1,106 @@ +"""Private loop-lifetime settlement policy; application stages stay child-owned. + +The process composition must keep the event loop alive while run is pending. +Shielding a public waiter cannot prevent its caller from closing the whole loop. +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +from ._files import ManagedStorageError +from .child import ManagedChildApplicationV1, _spawn + +if TYPE_CHECKING: + from .bootstrap import ManagedChildBootstrapV1 + + +class _ChildLifetime: + def __init__(self, child: ManagedChildApplicationV1, dependencies: ManagedChildBootstrapV1) -> None: + self._child, self._dependencies = child, dependencies + self._loop: asyncio.AbstractEventLoop | None = None + self._task: asyncio.Task[int] | None = None + self._close_task: asyncio.Task[bool] | None = None + self._close_unknown = False + self._status = 0 + + @property + def cleanup_pending(self) -> bool: + return self._close_unknown or self._close_task is not None + + @property + def close_unknown(self) -> bool: + return self._close_unknown + + async def run(self) -> int: + loop = asyncio.get_running_loop() + if self._loop is not None and self._loop is not loop: + raise ManagedStorageError("conflict") + self._loop = loop + delay = 1 + while self._task is None: + try: + # Publication gate guarantees task-factory failure starts no + # application work. Keep this owner and retry only scheduling. + self._task = _spawn(self._drive()) + except Exception: + self._status = 1 + await _pause(delay) + delay = min(30, delay * 2) + return await asyncio.shield(self._task) + + async def _drive(self) -> int: + try: + await self._child.run() + except BaseException: + self._status = 1 + delay = 1 + while self._child.cleanup_pending: + await _pause(delay) + try: + # This is a retry grant, not authority to replace any in-flight + # phase or extend its original budget. The child decides that. + await self._child.close(retry_timeout=30) + except BaseException: + self._status = 1 + delay = min(30, delay * 2) + + delay = 1 + while self._dependencies._application_cleanup_pending() or self._close_unknown: + if self._close_task is None and not self._close_unknown: + try: + self._close_task = _spawn(asyncio.to_thread(self._close_dependencies)) + except Exception: + self._status = 1 # Publication failed before offload began. + task = self._close_task + if task is not None and not self._close_unknown: + try: + succeeded = await asyncio.shield(task) + except BaseException: + # Offload cancellation/failure is not a native completion + # receipt. Retain that exact task; never enqueue a replacement. + self._close_unknown = True + self._status = 1 + else: + self._close_task = None + if not succeeded: + self._status = 1 + if not self._dependencies._application_cleanup_pending() and not self._close_unknown: + break + await _pause(delay) + delay = min(30, delay * 2) + return self._status + + def _close_dependencies(self) -> bool: + # An ordinary resource-close failure is returned only after the native + # callable has finished. Its owner retains exact resources/unknown debt. + try: + self._dependencies.close() + except BaseException: + return False + return True + + +async def _pause(delay: float) -> None: + await asyncio.sleep(delay) diff --git a/src/loushang/apphost/managed/_process.py b/src/loushang/apphost/managed/_process.py new file mode 100644 index 000000000..d2ad09bad --- /dev/null +++ b/src/loushang/apphost/managed/_process.py @@ -0,0 +1,162 @@ +"""Private Linux process shell; no Product stages or native termination policy.""" + +from __future__ import annotations + +import asyncio +import signal +from contextlib import suppress +from time import sleep +from typing import TYPE_CHECKING, Any + +from .child import ManagedChildApplicationV1, _spawn + +if TYPE_CHECKING: + from .bootstrap import ManagedChildBootstrapV1 + + +class _ChildProcess: + def __init__(self, bootstrap: ManagedChildBootstrapV1, child: ManagedChildApplicationV1) -> None: + self._bootstrap, self._child = bootstrap, child + self._runner: asyncio.Runner | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._serve_task: asyncio.Task[int] | None = None + self._waiter: asyncio.Task[int] | None = None + self._signal_waiter: asyncio.Task[bool] | None = None + self._stop_task: asyncio.Task[None] | None = None + self._wakeup = asyncio.Event() + self._protection_unknown = False + self._stop_requested = self._failed = self._ending = False + self._unknown = self._settled = False + + @property + def cleanup_pending(self) -> bool: + return not self._settled + + @property + def unknown(self) -> bool: + return self._unknown + + def accepts_loop(self, loop: asyncio.AbstractEventLoop) -> bool: + return self._loop is loop and not self._ending + + def _new_loop(self) -> asyncio.AbstractEventLoop: + loop = asyncio.new_event_loop() + self._loop = loop # Retain before Runner performs its remaining setup. + return loop + + def run(self) -> int: + # Dedicated process policy, not borrowed handlers. It stays installed + # through final diagnostics and OS exit, including with other threads. + self._protect_maintenance() + if self._protection_unknown: + self._failed = self._stop_requested = True + try: + self._runner = asyncio.Runner(loop_factory=self._new_loop) + loop = self._runner.get_loop() + except BaseException: + # No second Runner or native loop creation after uncertain setup. + self._unknown = self._ending = True + self._protect_maintenance() + _park() + raise AssertionError("unreachable") + + while True: + if self._serve_task is None: + work = self._serve() + try: + self._serve_task = loop.create_task(work) + except BaseException: + work.close() + self._failed = self._stop_requested = True + sleep(1) + continue + try: + # Unlike Runner.run, this never installs the default two-INT + # cancellation/escalation handler over a partial installation. + status = loop.run_until_complete(self._serve_task) + break + except asyncio.CancelledError: + self._serve_task = None # Join the same retained application driver. + except BaseException: + self._failed = self._stop_requested = True + if self._serve_task.done(): + self._serve_task = None + sleep(1) + + self._ending = True # Signal callbacks must not wake a closing/closed loop. + try: + self._runner.close() + except BaseException: + self._unknown = True # Partial close is not retryable native authority. + self._unknown = self._unknown or self._protection_unknown + if self._unknown: + self._protect_maintenance() + if self._unknown: + _park() + self._settled = True + return int(bool(status or self._failed)) + + def _signal(self, signum: int, frame: object) -> None: + self._stop_requested = True + loop = self._loop + if not self._ending and loop is not None and not loop.is_closed(): + # The sticky flag survives the closing-loop window. + with suppress(RuntimeError): + loop.call_soon_threadsafe(self._wakeup.set) + + def _request_stop(self) -> None: + if self._stop_task is None: + self._stop_task = _spawn(self._child.close(retry_timeout=30)) + + async def _serve(self) -> int: + if self._stop_requested: + self._request_stop() + if (self._waiter is None or self._waiter.cancelled() + or self._waiter.done() and self._waiter.exception() is not None): + self._waiter = _spawn(self._bootstrap.run()) + if self._signal_waiter is None or self._signal_waiter.cancelled(): + self._signal_waiter = _spawn(self._wakeup.wait()) + while not self._waiter.done(): + if self._signal_waiter.done() and not self._stop_requested: + self._failed = self._stop_requested = True + if self._stop_requested: + self._request_stop() + pending: set[asyncio.Task[Any]] = {self._waiter} + if not self._stop_requested: + pending.add(self._signal_waiter) + if self._stop_task is not None and not self._stop_task.done(): + pending.add(self._stop_task) + await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED) + status = await asyncio.shield(self._waiter) + if self._stop_task is not None: + try: + await asyncio.shield(self._stop_task) + except asyncio.CancelledError: + if not self._stop_task.cancelled(): + raise # Rejoin this exact still-running (or successful) stop waiter. + self._failed = True + except BaseException: + self._failed = True + # Only this pure event waiter is cancelled, never an application/close job. + self._signal_waiter.cancel() + await asyncio.gather(self._signal_waiter, return_exceptions=True) + return status + + def _protect_maintenance(self) -> None: + for kind in _SIGNALS: + try: + signal.signal(kind, signal.SIG_IGN if kind == signal.SIGHUP else self._signal) + except BaseException: + # No unconditional residency guarantee if the OS refuses the + # protective disposition itself. Still never return a clean code. + self._protection_unknown = True + + +_SIGNALS = (signal.SIGINT, signal.SIGTERM, signal.SIGHUP) + + +def _park() -> None: + """No implicit exit or native retry after an uncertain process-shell close.""" + while True: + with suppress(BaseException): + sleep(30) diff --git a/src/loushang/apphost/managed/bootstrap.py b/src/loushang/apphost/managed/bootstrap.py new file mode 100644 index 000000000..97535957b --- /dev/null +++ b/src/loushang/apphost/managed/bootstrap.py @@ -0,0 +1,528 @@ +"""Optional child-process control composition; no Product selection or CLI. + +Open/close are synchronous bootstrap operations, outside an application event +loop. A trusted process entry retains this owner before opening resources and +retains its bound child owner until application cleanup has completed. +""" + +from __future__ import annotations + +import asyncio +import os +import socket +import sys +from pathlib import Path +from threading import RLock, current_thread, main_thread +from time import monotonic + +from loushang.appservice.managed_mux import ManagedMuxServiceBindingV1 +from loushang.hosting.service import LinuxServiceObserverV1 + +from ._files import ManagedStorageError, PrivateManagedDirectory, _check_deadline +from ._lifetime import _ChildLifetime +from ._process import _ChildProcess +from .child import ManagedChildApplicationPortV1, ManagedChildApplicationV1 +from .contracts import ( + _HEX32, + ManagedContractError, + ManagedInstanceRefV1, + ManagedNamespaceV1, + ManagedServiceKeyV1, + _match, +) +from .event_log import ManagedLifecycleEventV1, ManagedLifecycleLogV1 +from .handoff import ManagedChildControlV1 +from .lifecycle import ManagedServiceJournalV1 +from .mux_management import ManagedMuxManagerV1 +from .output_capture import ManagedOutputCaptureFactory +from .paths import ManagedDeploymentPathsV1, resolve_managed_paths +from .registry import ManagedRegistryV1 +from .storage_budget import ManagedStorageBudgetV1 +from .trace_buffer import ManagedTraceBuffer +from .trace_log import ManagedTraceLog + + +class ManagedChildBootstrapV1: + """Own one child's control dependencies, without owning its run waiter. + + Namespace and instance values are lookups, not authority. Only the child's + existing durable/native handoff may permit application activation. Failed + admissions keep their containers; successful dependencies are never swapped. + """ + + def __init__( + self, namespace: ManagedNamespaceV1, service: ManagedServiceKeyV1, + instance: ManagedInstanceRefV1, attempt_id: str, endpoint: socket.socket, + *, runtime_root: str, temporary_override: str | None = None, + diagnostics: bool = False, + ) -> None: + paths = resolve_managed_paths(namespace, service, instance, runtime_root=runtime_root, + temporary_override=temporary_override) + _match(attempt_id, _HEX32) + if namespace.user_id != os.geteuid() or type(endpoint) is not socket.socket or type(diagnostics) is not bool: + raise ManagedContractError() + try: + if (endpoint.family != socket.AF_UNIX or endpoint.fileno() < 3 + or endpoint.getsockopt(socket.SOL_SOCKET, socket.SO_TYPE) != socket.SOCK_STREAM): + raise ManagedContractError() + endpoint.getpeername() + endpoint.set_inheritable(False) + except OSError: + raise ManagedContractError() from None + self._namespace, self._service = namespace, service + self._instance, self._attempt = instance, attempt_id + self._paths = paths + self._diagnostics_enabled = diagnostics + self._log_directory = PrivateManagedDirectory(Path(paths.logs), defer_open=True) if diagnostics else None + self._log_writer: ManagedLifecycleLogV1 | None = None + self._log_attempted = self._log_failed = False + self._log_open_attempted = self._log_opened = False + self._trace_buffer: ManagedTraceBuffer | None = None + self._trace_writer: ManagedTraceLog | None = None + self._trace_failed = False + self._trace_sink_installed = False + self._endpoint: socket.socket | None = endpoint + self._registry: ManagedRegistryV1 | None = None + self._capture_directory: PrivateManagedDirectory | None = None + self._capture_factory: ManagedOutputCaptureFactory | None = None + self._journal: ManagedServiceJournalV1 | None = None + self._observer: LinuxServiceObserverV1 | None = None + self._control: ManagedChildControlV1 | None = None + self._child: ManagedChildApplicationV1 | None = None + self._lifetime: _ChildLifetime | None = None + self._process: _ChildProcess | None = None + self._mux_manager: ManagedMuxManagerV1 | None = None + self._registry_ready = self._journal_ready = False + self._native_attempted = self._native_uncertain = False + self._observer_close_uncertain = self._endpoint_close_uncertain = False + self._opened = self._closing = False + self._deadline: float | None = None + self._mutex = RLock() + + @property + def paths(self) -> ManagedDeploymentPathsV1: + return self._paths + + @property + def cleanup_pending(self) -> bool: + return self._application_cleanup_pending() or bool(self._process is not None and self._process.cleanup_pending) + + def _application_cleanup_pending(self) -> bool: + return self._resources_pending() or bool(self._lifetime is not None and self._lifetime.cleanup_pending) + + def _resources_pending(self) -> bool: + return bool( + self._endpoint is not None or self._registry is not None or self._journal is not None + or self._log_directory is not None + or self._capture_directory is not None + or self._observer is not None or self._control is not None or self._native_uncertain + or (self._child is not None and self._child.cleanup_pending) + ) + + def open(self, *, deadline: float) -> None: + """Admit existing paths with one frozen absolute bootstrap deadline. + + A retry may finish an earlier failed admission, never extend its budget, + replace successful dependencies, recapture uncertain native resources or + change the durable service generation. Caller owns scheduling retries. + """ + if type(deadline) not in (int, float) or not 0 < deadline <= 1e12: + raise ManagedContractError() + _outside_loop() + if not self._mutex.acquire(blocking=False): + raise ManagedStorageError("busy") + try: + if self._closing or self._opened or self._child is not None: + raise ManagedStorageError("closed") + _check_deadline(deadline) + self._deadline = deadline if self._deadline is None else min(deadline, self._deadline) + deadline = self._deadline + _check_deadline(deadline) + if self._native_uncertain: + raise ManagedStorageError("unavailable") + if not self._registry_ready: + if self._registry is not None: + self._registry.close() + self._registry = None + _check_deadline(deadline) + self._registry = ManagedRegistryV1(Path(self._paths.registry), self._namespace, defer_open=True) + self._registry.open(deadline=deadline) + self._registry_ready = True + assert self._registry is not None + if not self._journal_ready: + if self._journal is not None: + self._journal.close() + self._journal = None + _check_deadline(deadline) + self._journal = ManagedServiceJournalV1( + self._registry, self._namespace, self._service, Path(self._paths.lifecycle), defer_open=True, + ) + self._journal.open(deadline=deadline) + self._journal_ready = True + assert self._journal is not None + _check_deadline(deadline) + if not self._native_attempted: + self._native_attempted = True + try: + self._observer = LinuxServiceObserverV1.capture(os.getpid()) + except BaseException: + # The capture factory may have acquired native descriptors + # before failing; absent a returned owner, do not invent a + # clean receipt or repeat the native effect in this process. + self._native_uncertain = True + raise + assert self._observer is not None + state = self._journal.read(deadline=deadline) + if (state is None or state.handoff.instance != self._instance + or state.handoff.attempt_id != self._attempt + or (state.native_identity is not None and state.native_identity != self._observer.identity)): + raise ManagedStorageError("conflict") + _check_deadline(deadline) + if self._control is None: + assert self._endpoint is not None + self._control = ManagedChildControlV1( + self._journal, self._instance, self._attempt, self._observer.identity, self._endpoint, + ) + self._endpoint = None # The control now owns the same socket. + _check_deadline(deadline) + self._opened = True + finally: + self._mutex.release() + + def managed_mux_binding(self, *, application_id: str) -> ManagedMuxServiceBindingV1: + """Borrow admitted control storage before binding the application. + + This pure binding neither issues a permit nor claims COMMITTED. The + original manager rechecks durable authority at each creation admission; + its dependencies remain owned here through child application cleanup. + """ + if not self._mutex.acquire(blocking=False): + raise ManagedStorageError("busy") + try: + if self._closing or not self._opened or self._child is not None: + raise ManagedStorageError("closed") + assert self._registry is not None and self._journal is not None and self._observer is not None + if self._mux_manager is None: + self._mux_manager = ManagedMuxManagerV1( + self._registry, self._journal, self._namespace, self._service, + self._instance, application_id=application_id, + startup_attempt_id=self._attempt, startup_native_identity=self._observer.identity, + ) + binding = self._mux_manager.binding() + if binding.application_id != application_id: + raise ManagedStorageError("conflict") + return binding + finally: + self._mutex.release() + + def output_capture_factory(self, *, deadline: float) -> ManagedOutputCaptureFactory: + """Open the admitted instance scratch root before binding its Product.""" + _outside_loop() + if not self._mutex.acquire(blocking=False): + raise ManagedStorageError("busy") + try: + if self._closing or not self._opened or self._child is not None: + raise ManagedStorageError("closed") + if self._capture_factory is None: + if self._capture_directory is None: + self._capture_directory = PrivateManagedDirectory(Path(self._paths.temporary), defer_open=True) + self._capture_directory.open(deadline=deadline) + assert self._registry is not None + self._capture_factory = ManagedOutputCaptureFactory( + self._capture_directory, ManagedStorageBudgetV1(self._registry), + service_id=self._service.service_id, instance_id=self._instance.instance_id, + ) + return self._capture_factory + finally: + self._mutex.release() + + def prepare_trace(self, *, deadline: float) -> ManagedTraceBuffer: + """Freeze one explicit trace window before binding; no filesystem IO.""" + _outside_loop() + _check_deadline(deadline) + if not self._mutex.acquire(blocking=False): + raise ManagedStorageError("busy") + try: + if self._closing or not self._opened or self._child is not None: + raise ManagedStorageError("closed") + if self._trace_buffer is not None: + if self._trace_buffer.deadline != deadline: + raise ManagedStorageError("conflict") + return self._trace_buffer + self._trace_buffer = ManagedTraceBuffer(self._instance.instance_id, deadline) + if self._log_directory is None: + self._log_directory = PrivateManagedDirectory(Path(self._paths.logs), defer_open=True) + return self._trace_buffer + finally: + self._mutex.release() + + def trace_sink_installed(self, buffer: ManagedTraceBuffer) -> None: + """Trusted dedicated-process composition confirms its installed sink. + + Call inside the public observability context, after bind and before run. + This is not an applied receipt: native storage and publication happen + later on the original child diagnostics worker. + """ + _outside_loop() + if not self._mutex.acquire(blocking=False): + raise ManagedStorageError("busy") + try: + if (self._closing or self._child is None or buffer is not self._trace_buffer + or self._process is not None or self._lifetime is not None): + raise ManagedStorageError("conflict") + self._trace_sink_installed = True + finally: + self._mutex.release() + + def bind( + self, application: ManagedChildApplicationPortV1, *, startup_timeout: float = 30.0, + settlement_timeout: float = 30.0, + ) -> ManagedChildApplicationV1: + """Adopt an unstarted application once; this operation does no native IO.""" + if not self._mutex.acquire(blocking=False): + raise ManagedStorageError("busy") + try: + if self._closing or not self._opened or self._child is not None: + raise ManagedStorageError("closed") + assert self._control is not None + child = ManagedChildApplicationV1( + application, self._control, startup_timeout=startup_timeout, settlement_timeout=settlement_timeout, + diagnostic=self._record_lifecycle if self._diagnostics_enabled else None, + trace_buffer=self._trace_buffer, + trace_write=self._record_trace if self._trace_buffer is not None else None, + trace_initialize=self._apply_trace if self._trace_buffer is not None else None, + ) + self._child = child + return child + finally: + self._mutex.release() + + def _record_lifecycle(self, event: str, code: str | None) -> None: + """One original diagnostic job; identity check releases before log IO. + + Native process/scope lifetime still prevents successor activation while + this job runs. Log records themselves never establish that authority. + """ + _outside_loop() + if self._log_failed: + raise ManagedStorageError("closed") + deadline = monotonic() + 2 + try: + assert self._journal is not None and self._observer is not None and self._registry is not None + state = self._journal.read(deadline=deadline, wait_for_lock=True) + if (state is None or state.handoff.instance != self._instance + or state.handoff.attempt_id != self._attempt + or state.native_identity != self._observer.identity): + raise ManagedStorageError("conflict") + # No journal/service fence crosses directory admission or writing. + directory = self._log_directory + assert directory is not None + if not self._log_attempted: + self._log_attempted = True + self._open_log_directory(deadline) + self._log_writer = ManagedLifecycleLogV1(directory, ManagedStorageBudgetV1(self._registry), + self._service.service_id) + assert self._log_writer is not None + self._log_writer.write(ManagedLifecycleEventV1(event, self._instance.instance_id, code), deadline=deadline) + except BaseException: + self._log_failed = True + raise + + def _open_log_directory(self, deadline: float) -> None: + # Both formats run on the same diagnostic slot and borrow this single + # one-shot native admission. A failed open is never retried by its peer. + if self._log_opened: + return + if self._log_open_attempted: + raise ManagedStorageError("closed") + self._log_open_attempted = True + assert self._log_directory is not None + self._log_directory.open(deadline=deadline) + self._log_opened = True + + def _apply_trace(self) -> None: + """Admit storage, then publish an exact historical application fact.""" + _outside_loop() + if self._trace_failed: + raise ManagedStorageError("closed") + try: + if not self._trace_sink_installed or self._trace_buffer is None or self._child is None: + raise ManagedStorageError("conflict") + deadline = self._trace_buffer.deadline + _check_deadline(deadline) + assert self._journal is not None and self._observer is not None and self._registry is not None + state = self._journal.read(deadline=min(deadline, monotonic() + 2), wait_for_lock=True) + if (state is None or state.handoff.instance != self._instance + or state.handoff.attempt_id != self._attempt or state.handoff.stop_requested + or state.native_identity != self._observer.identity): + raise ManagedStorageError("conflict") + self._open_log_directory(deadline) + assert self._log_directory is not None + if self._trace_writer is None: + self._trace_writer = ManagedTraceLog(self._log_directory, ManagedStorageBudgetV1(self._registry), + self._service.service_id) + self._trace_writer.prepare(deadline=deadline) + # Directory locks have left scope before the original service fence. + self._journal.record_trace_application(self._instance, self._attempt, + native_identity=self._observer.identity, trace_deadline_ms=round(deadline * 1000), + deadline=deadline) + except BaseException: + self._trace_failed = True + raise + + def _record_trace(self, frame: bytes, deadline: float) -> None: + _outside_loop() + if self._trace_failed: + raise ManagedStorageError("closed") + try: + if self._trace_buffer is None or deadline != self._trace_buffer.deadline: + raise ManagedStorageError("conflict") + _check_deadline(deadline) + assert self._journal is not None and self._observer is not None and self._registry is not None + state = self._journal.read(deadline=min(deadline, monotonic() + 2), wait_for_lock=True) + if (state is None or state.handoff.instance != self._instance + or state.handoff.attempt_id != self._attempt + or state.native_identity != self._observer.identity): + raise ManagedStorageError("conflict") + self._open_log_directory(deadline) + assert self._log_directory is not None + if self._trace_writer is None: + self._trace_writer = ManagedTraceLog(self._log_directory, ManagedStorageBudgetV1(self._registry), + self._service.service_id) + self._trace_writer.write(frame, deadline=deadline) + except BaseException: + self._trace_failed = True + raise + + async def run(self) -> int: + """Run the bound child and await complete settlement on the same loop. + + Waiter cancellation does not stop the service. The process composition + must keep this loop alive and rejoin; it may exit only after a result. + Returns 0 for clean success, 1 for a failure that ultimately settled. + Permanent unknown cleanup debt intentionally has no exit result. + """ + if not self._mutex.acquire(blocking=False): + raise ManagedStorageError("busy") + try: + if self._process is not None and not self._process.accepts_loop(asyncio.get_running_loop()): + raise ManagedStorageError("conflict") + if self._lifetime is None: + if self._closing or not self._opened or self._child is None: + raise ManagedStorageError("closed") + self._lifetime = _ChildLifetime(self._child, self) + lifetime = self._lifetime + finally: + self._mutex.release() + return await lifetime.run() + + def run_process(self) -> int: + """Run once in a dedicated Linux child, owning signals until OS exit. + + Must be called after bind and before any run waiter. The existing + application driver settles first, then this process shell settles its + Runner. INT/TERM remain graceful-only and HUP remains ignored after + return; the caller may finalize diagnostics and exit, not reuse this + process for another application. Embedded callers must use run(), which + does not alter signal policy. Neither cleanup domain may hide the other. + """ + _outside_loop() + if sys.platform != "linux" or current_thread() is not main_thread(): + raise ManagedStorageError("unsupported") + if not self._mutex.acquire(blocking=False): + raise ManagedStorageError("busy") + try: + if self._closing or not self._opened or self._child is None or self._lifetime is not None or self._process is not None: + raise ManagedStorageError("closed") + self._process = _ChildProcess(self, self._child) + finally: + self._mutex.release() + return self._process.run() + + def close(self) -> None: + """Release borrowed dependencies only after the bound child has settled. + + Partial close permanently fences open/bind. Failed resource objects stay + retained, and a failed journal close keeps its registry dependency live. + Native observer/socket close ambiguity is debt, not a retryable fd right. + """ + _outside_loop() + if not self._mutex.acquire(blocking=False): + raise ManagedStorageError("busy") + try: + if self._child is not None and self._child.cleanup_pending: + raise ManagedStorageError("busy") + if self._capture_factory is not None and not self._capture_factory.settled: + self._capture_factory.close_unstarted() + self._closing = True + if self._trace_buffer is not None: + self._trace_buffer.discard() + errors: list[BaseException] = [] + if self._capture_directory is not None: + try: + self._capture_directory.close() + self._capture_directory = None + self._capture_factory = None + except BaseException as error: + errors.append(error) + if self._log_directory is not None: + try: + self._log_directory.close() + self._log_directory = None + self._log_writer = None + self._trace_writer = None + except BaseException as error: + errors.append(error) + if self._control is not None: + try: + self._control.close() + self._control = None + except BaseException as error: + errors.append(error) + if self._endpoint is not None and not self._endpoint_close_uncertain: + try: + self._endpoint.close() + self._endpoint = None + except BaseException as error: + self._endpoint_close_uncertain = True + errors.append(error) + if (self._log_directory is None and self._capture_directory is None + and self._observer is not None and not self._observer_close_uncertain): + try: + self._observer.close() + self._observer = None + except BaseException as error: + self._observer_close_uncertain = True + errors.append(error) + if self._log_directory is None and self._capture_directory is None and self._journal is not None: + try: + self._journal.close() + self._journal = None + except BaseException as error: + errors.append(error) + if self._journal is None and self._capture_directory is None and self._registry is not None: + try: + self._registry.close() + self._registry = None + except BaseException as error: + errors.append(error) + for failure in errors: + if not isinstance(failure, Exception): + failure.add_note("managed_bootstrap_cleanup_incomplete") + raise failure + if (self._resources_pending() or (self._lifetime is not None and self._lifetime.close_unknown) + or (self._process is not None and self._process.unknown)): + raise ManagedStorageError("unavailable") from None + finally: + self._mutex.release() + + +def _outside_loop() -> None: + try: + asyncio.get_running_loop() + except RuntimeError: + return + raise ManagedStorageError("busy") + + +__all__ = ["ManagedChildBootstrapV1"] diff --git a/src/loushang/apphost/managed/child.py b/src/loushang/apphost/managed/child.py index 2499473d8..24a0bac77 100644 --- a/src/loushang/apphost/managed/child.py +++ b/src/loushang/apphost/managed/child.py @@ -1,23 +1,29 @@ """Optional child-side application owner; no CLI, Product selection or spawn. The trusted child composition publishes this owner before run and retains it -through cleanup. Run waiters are not service-lifetime owners. A single retained -worker executes synchronous control IO without blocking the application loop. +through cleanup. Run waiters are not service-lifetime owners. One retained pool +serializes control IO; optional diagnostics get one independent execution slot. """ from __future__ import annotations import asyncio +from collections import deque from collections.abc import Callable, Coroutine from concurrent.futures import Future, ThreadPoolExecutor from contextlib import suppress +from functools import partial +from threading import Event from typing import Any, Literal, Protocol, TypeVar from .contracts import ManagedHandoffPhaseV1 from .handoff import ManagedChildControlV1 from .lifecycle import ManagedServiceStateV1 +from .trace_buffer import ManagedTraceBuffer _T = TypeVar("_T") +_DiagnosticEvent = Literal["starting", "ready", "stopping", "stopped", "failed"] +_DiagnosticCode = Literal["startup_failed", "application_failed", "cleanup_incomplete", "stop_requested"] class ManagedChildError(RuntimeError): @@ -55,6 +61,10 @@ class ManagedChildApplicationV1: def __init__( self, application: ManagedChildApplicationPortV1, control: ManagedChildControlV1, *, startup_timeout: float = 30.0, settlement_timeout: float = 30.0, + diagnostic: Callable[[_DiagnosticEvent, _DiagnosticCode | None], None] | None = None, + trace_buffer: ManagedTraceBuffer | None = None, + trace_write: Callable[[bytes, float], None] | None = None, + trace_initialize: Callable[[], None] | None = None, ) -> None: for value in (startup_timeout, settlement_timeout): _budget(value) @@ -63,14 +73,33 @@ def __init__( for name in ("prepare", "activate", "fence", "close", "wait_closed") ): raise TypeError("invalid managed child owner") + if diagnostic is not None and not callable(diagnostic): + raise TypeError("invalid managed diagnostic") + if ((trace_buffer is None) != (trace_write is None) + or trace_buffer is not None and type(trace_buffer) is not ManagedTraceBuffer + or trace_write is not None and not callable(trace_write)): + raise TypeError("invalid managed trace binding") + if trace_initialize is not None and (trace_buffer is None or not callable(trace_initialize)): + raise TypeError("invalid managed trace initializer") self._application, self._control = application, control + self._diagnostic = diagnostic + self._trace_buffer, self._trace_write = trace_buffer, trace_write + self._trace_disabled = False + self._trace_initialize = trace_initialize + self._trace_initialized = trace_initialize is None + self._diagnostic_queue: deque[tuple[_DiagnosticEvent, _DiagnosticCode | None]] = deque() + self._diagnostic_seen: set[_DiagnosticEvent] = set() + self._diagnostic_task: asyncio.Task[None] | None = None + self._pending_log: Future[bool] | None = None + self._log_gate: Future[bool] | None = None + self._log_done: Event | None = None + self._diagnostics_disabled = self._diagnostics_closed = False self._startup_timeout, self._timeout = startup_timeout, settlement_timeout self._worker: ThreadPoolExecutor | None = None self._io_lock = asyncio.Lock() self._pending_io: Future[Any] | None = None self._observation_task: asyncio.Task[ManagedServiceStateV1 | None] | None = None self._stop_task: asyncio.Task[ManagedServiceStateV1 | None] | None = None - self._stop_deadline: float | None = None self._run_task: asyncio.Task[None] | None = None self._prepare_task: asyncio.Task[None] | None = None self._activate_task: asyncio.Task[None] | None = None @@ -80,6 +109,7 @@ def __init__( self._startup_deadline: float | None = None self._close_deadline: float | None = None self._application_settled = False + self._control_settled = False self._committed = False self._closing = False self._settled = False @@ -109,6 +139,7 @@ async def _run_once(self) -> None: await self._drive() except BaseException as error: self._failure = error + self._emit("failed", "application_failed" if self._committed else "startup_failed") try: await self.close() except BaseException: @@ -123,6 +154,7 @@ async def _drive(self) -> None: assert self._startup_deadline is not None failure: str | None = None while not self._closing: + self._poll_trace() if (not self.accepting and asyncio.get_running_loop().time() >= self._startup_deadline): failure = "startup_failed" break # Application's own startup budget, not a starter timeout. @@ -135,10 +167,11 @@ async def _drive(self) -> None: if state is not None: if state.handoff.stop_requested or state.handoff.phase is ManagedHandoffPhaseV1.ABORTING: break - if self._prepare_task is None: + if self._prepare_task is None and state.native_identity is not None: if state.handoff.phase is not ManagedHandoffPhaseV1.PROVISIONAL: failure = "startup_failed" break + self._emit("starting") self._prepare_task = _spawn(self._application.prepare(deadline=self._startup_deadline)) if state.handoff.phase is ManagedHandoffPhaseV1.COMMITTED: self._committed = True @@ -162,7 +195,7 @@ async def _drive(self) -> None: if asyncio.get_running_loop().time() >= self._startup_deadline: failure = "startup_failed" break - self._activate_task = _spawn(self._application.activate()) + self._activate_task = _spawn(self._activate()) if self._activate_task is not None and self._activate_task.done(): if _failed(self._activate_task): failure = "activation_failed" @@ -179,6 +212,11 @@ async def _drive(self) -> None: if failure is not None: raise ManagedChildError(failure) + async def _activate(self) -> None: + await self._application.activate() + if self._committed and not self._closing: + self._emit("ready") + async def _observe_running( self, action: Literal["read", "poll", "commit"], *, deadline: float | None = None, ) -> ManagedServiceStateV1 | None: @@ -186,6 +224,7 @@ async def _observe_running( # noticing application failure/deadline or explicit local stop. task = self._observation_task = _spawn(self._observe(action, deadline=deadline)) while True: + self._poll_trace() if self._closing: return None if (self._activate_task is not None and self._activate_task.done() @@ -220,13 +259,38 @@ async def _io(self, operation: Callable[[], _T]) -> _T: # native job disappear or admit a second queued job over it. prior = self._pending_io if prior is not None: - with suppress(Exception): - await asyncio.shield(asyncio.wrap_future(prior)) - self._pending_io = None - if self._worker is None: - self._worker = ThreadPoolExecutor(max_workers=1, thread_name_prefix="lmux-control") - future = self._worker.submit(operation) + try: + with suppress(Exception): + await asyncio.shield(asyncio.wrap_future(prior)) + finally: + if prior.done(): + self._pending_io = None + gate: Future[bool] = Future() + # This receipt belongs to the callable, not to the executor or an + # asyncio waiter. Cancellation cannot turn admitted native IO into + # an apparently finished job. + future: Future[_T] = Future() + future.set_running_or_notify_cancel() + + def deliver_control() -> None: + if not gate.result(): + return + try: + result = operation() + except BaseException as error: + future.set_exception(error) + else: + future.set_result(result) + + try: + self._executor().submit(deliver_control) + except BaseException: + # submit may enqueue before losing its return receipt. Such a + # wrapper must never touch the borrowed control dependencies. + gate.set_result(False) + raise self._pending_io = future + gate.set_result(True) try: return await asyncio.shield(asyncio.wrap_future(future)) finally: @@ -235,12 +299,154 @@ async def _io(self, operation: Callable[[], _T]) -> _T: if future.done(): self._pending_io = None + def _executor(self) -> ThreadPoolExecutor: + if self._worker is None: + self._worker = ThreadPoolExecutor(max_workers=2 if self._diagnostic is not None or self._trace_buffer is not None else 1, + thread_name_prefix="lmux-control") + return self._worker + + def _emit(self, event: _DiagnosticEvent, code: _DiagnosticCode | None = None) -> None: + if (self._diagnostic is None or self._diagnostics_disabled or self._diagnostics_closed + or event in self._diagnostic_seen): + return + self._diagnostic_seen.add(event) + self._diagnostic_queue.append((event, code)) + if self._diagnostic_task is None or self._diagnostic_task.done(): + try: + self._diagnostic_task = _spawn(self._drain_diagnostics()) + except BaseException: + self._diagnostics_disabled = True + self._diagnostic_queue.clear() + + async def _drain_diagnostics(self) -> None: + operation: Callable[[], None] + while not self._diagnostics_disabled: + is_trace = False + initializing = False + if self._diagnostic_queue: + event, code = self._diagnostic_queue.popleft() + assert self._diagnostic is not None + operation = partial(self._diagnostic, event, code) + elif self._trace_buffer is not None and not self._trace_disabled: + is_trace = True + if not self._trace_initialized: + if not self._committed: + break # Native birth/commit must precede application facts. + assert self._trace_initialize is not None + operation = self._trace_initialize + initializing = True + else: + frame = self._trace_buffer.take() + if frame is None: + break + assert self._trace_write is not None + operation = partial(self._trace_write, frame, self._trace_buffer.deadline) + else: + break + gate: Future[bool] = Future() + done = Event() + self._log_gate, self._log_done = gate, done + + def deliver(operation: Callable[[], None] = operation, + gate: Future[bool] = gate, done: Event = done) -> bool: + try: + if not gate.result(): + return False + operation() + return True + except BaseException: + return False # No arbitrary exception enters the control plane. + finally: + done.set() + + try: + future = self._executor().submit(deliver) + except BaseException: + # submit may have queued a wrapper before raising. Without a + # receipt that wrapper may not enter journal or log IO. + gate.set_result(False) + # No native work was authorized. A queued wrapper can only + # return without touching dependencies; a never-queued one + # cannot supply a done receipt. Both belong to pool shutdown. + self._log_gate = self._log_done = None + self._disable_diagnostic(is_trace) + if is_trace: + continue + return + self._pending_log = future + gate.set_result(True) + try: + succeeded = await asyncio.shield(asyncio.wrap_future(future)) + except BaseException: + self._diagnostics_disabled = True + self._diagnostic_queue.clear() + raise + finally: + if future.done(): + self._pending_log = None + self._log_gate = self._log_done = None + if not succeeded: + self._disable_diagnostic(is_trace) + elif initializing: + self._trace_initialized = True + await asyncio.sleep(0) # Reconsider lifecycle priority between frames. + + def _disable_diagnostic(self, is_trace: bool) -> None: + if is_trace: + self._trace_disabled = True + assert self._trace_buffer is not None + self._trace_buffer.discard() + else: + self._diagnostics_disabled = True + self._diagnostic_queue.clear() + + def _poll_trace(self) -> None: + if (self._trace_buffer is None or self._trace_disabled or self._closing + or self._diagnostics_closed or self._diagnostics_disabled): + return + if self._diagnostic_task is None or self._diagnostic_task.done(): + try: + self._diagnostic_task = _spawn(self._drain_diagnostics()) + except BaseException: + self._disable_diagnostic(True) + + async def _settle_diagnostics(self, deadline: float) -> None: + # With trace-only composition, no lifecycle event schedules the final + # drain. Fence has already stopped producers; settle this finite tail. + if (self._trace_buffer is not None and not self._trace_disabled + and not self._diagnostics_disabled + and (self._diagnostic_task is None or self._diagnostic_task.done())): + try: + self._diagnostic_task = _spawn(self._drain_diagnostics()) + except BaseException: + # _spawn's publication gate prevents this new drain from + # entering native IO. Existing receipts below still settle. + self._disable_diagnostic(True) + self._diagnostics_closed = True # No enqueue can race final settlement. + if self._diagnostic_task is not None: + await _wait(self._diagnostic_task, deadline, ignore_failure=True) + future = self._pending_log + if future is not None: + wrapped = asyncio.wrap_future(future) + await asyncio.wait({wrapped}, timeout=max(0, deadline - asyncio.get_running_loop().time())) + if not wrapped.done(): + raise ManagedChildError("cleanup_incomplete") + with suppress(BaseException): + wrapped.result() + self._pending_log = None + if self._log_done is not None and not self._log_done.is_set(): + raise ManagedChildError("cleanup_incomplete") + self._log_gate = self._log_done = None + async def close(self, *, retry_timeout: float | None = None) -> None: if retry_timeout is not None: _budget(retry_timeout) if self._settled: return self._closing = True + if self._trace_buffer is not None: + self._trace_buffer.fence() + self._emit("stopping") self._application.fence() self._wakeup.set() if self._close_deadline is None: @@ -271,27 +477,43 @@ async def _close_once(self, retry_timeout: float | None) -> None: if owned is not None: await _wait(owned, deadline, ignore_failure=True) self._application_settled = True + self._emit("stopped") # Application stopped, not process/group exit. + if self._control_settled: + await self._finish_settlement(deadline) + return self._start_stop(deadline) if self._observation_task is not None: await _wait(self._observation_task, deadline, ignore_failure=True) - assert self._stop_task is not None - await _wait(self._stop_task, deadline) - state = self._stop_task.result() - if (state is None and self._stop_deadline is not None and self._stop_deadline < deadline): - # A retained request from an older, expired attempt just settled. - # Use the explicitly granted retry budget only after that exact job - # completes; never replace it or enqueue a second native operation. - self._start_stop(deadline) + while True: + assert self._stop_task is not None await _wait(self._stop_task, deadline) state = self._stop_task.result() - if state is None or not state.handoff.stop_requested: - raise ManagedChildError("cleanup_incomplete") - state = await self._observe("cleanup", deadline=deadline) - if state is None or not state.evidence.application_cleanup_completed: - raise ManagedChildError("cleanup_incomplete") + if state is not None and state.handoff.stop_requested: + break + # A completed observation may have met only transient contention. + # Retain the same worker and budget; never replace an in-flight job + # or repeat successful application cleanup for a missing receipt. + if asyncio.get_running_loop().time() >= deadline: + raise ManagedChildError("cleanup_incomplete") + await asyncio.sleep(0.01) + self._start_stop(deadline) + while True: + if asyncio.get_running_loop().time() >= deadline: + raise ManagedChildError("cleanup_incomplete") + state = await self._observe("cleanup", deadline=deadline) + if state is not None and state.evidence.application_cleanup_completed: + break + if asyncio.get_running_loop().time() >= deadline: + raise ManagedChildError("cleanup_incomplete") + await asyncio.sleep(0.01) if asyncio.get_running_loop().time() >= deadline: raise ManagedChildError("cleanup_incomplete") await self._io(self._control.close) + self._control_settled = True + await self._finish_settlement(deadline) + + async def _finish_settlement(self, deadline: float) -> None: + await self._settle_diagnostics(deadline) assert self._worker is not None and self._pending_io is None # All jobs have finished; never join a native thread on the app loop. self._worker.shutdown(wait=False) @@ -308,9 +530,8 @@ def _start_stop(self, deadline: float) -> None: if asyncio.get_running_loop().time() >= deadline: raise ManagedChildError("cleanup_incomplete") # Independent of successful application cleanup, but serialized behind - # any exact in-flight native job by the same single-worker IO owner. + # any exact in-flight control job by the original single-flight IO owner. self._stop_task = _spawn(self._observe("stop", deadline=deadline)) - self._stop_deadline = deadline def _budget(value: float) -> None: diff --git a/src/loushang/apphost/managed/coordinator.py b/src/loushang/apphost/managed/coordinator.py new file mode 100644 index 000000000..195af9651 --- /dev/null +++ b/src/loushang/apphost/managed/coordinator.py @@ -0,0 +1,250 @@ +"""One explicit service-start/reuse operation over already admitted storage. + +No Product selection, directory initialization, Mux mutation or unclean restart. +The caller retains this owner before ensure_started and keeps its journal and +event loop alive through close. A cancelled waiter does not stop the service. +""" + +from __future__ import annotations + +import asyncio +from time import monotonic +from typing import Literal + +from loushang.appserver.client import AppConnectionClosedError +from loushang.appserver.local_record import ( + LocalRecordError, + LocalRecordErrorCodeV1, + require_endpoint, +) + +from ._files import ManagedStorageError, _check_deadline +from .child import _spawn +from .connection import ManagedConnectionLeaseV1, _settled_native +from .contracts import ( + ManagedHandoffPhaseV1, + ManagedInstanceRefV1, + ManagedNamespaceV1, + ManagedServiceKeyV1, +) +from .lifecycle import ManagedServiceJournalV1, ManagedServiceStateV1 +from .starter import ManagedLaunchRequestFactoryV1, ManagedServiceStarterV1 + + +class ManagedServiceCoordinatorV1: + """Own one generated operation ID and at most one native start attempt. + + Competing callers may converge on one instance. Once selected, replacement + is refused; neither absent endpoints nor failed authentication starts another + child. Only read/connection attempts and the original birth registration are + retried. This is not durable historical operation deduplication or recovery. + """ + + def __init__( + self, journal: ManagedServiceJournalV1, namespace: ManagedNamespaceV1, + service: ManagedServiceKeyV1, *, runtime_root: str, endpoint: str, + request_factory: ManagedLaunchRequestFactoryV1, + temporary_override: str | None = None, + trace_deadline_ms: int | None = None, + ) -> None: + require_endpoint(endpoint) + self._starter = ManagedServiceStarterV1( + journal, namespace, service, runtime_root=runtime_root, request_factory=request_factory, + temporary_override=temporary_override, + trace_deadline_ms=trace_deadline_ms, + ) + self._journal, self._namespace, self._service = journal, namespace, service + self._runtime_root, self._endpoint = runtime_root, endpoint + self._trace_deadline_ms = trace_deadline_ms + self._instance: ManagedInstanceRefV1 | None = None + self._connection: ManagedConnectionLeaseV1 | None = None + self._task: asyncio.Task[None] | None = None + self._close_task: asyncio.Task[None] | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._deadline: float | None = None + self._closing = self._ready = self._settled = False + + @property + def operation_id(self) -> str: + return self._starter._attempt + + @property + def instance(self) -> ManagedInstanceRefV1 | None: + return self._instance + + @property + def cleanup_pending(self) -> bool: + return not self._settled + + def _bind_loop(self) -> None: + loop = asyncio.get_running_loop() + if self._loop is None: + self._loop = loop + elif self._loop is not loop: + raise ManagedStorageError("conflict") + + def _check(self, deadline: float) -> None: + _check_deadline(deadline) + if self._closing: + raise ManagedStorageError("closed") + + async def ensure_started(self, *, deadline: float) -> ManagedConnectionLeaseV1: + """Start/reuse, then return a borrowed exact-instance connection lease. + + Rejoining this operation uses its original deadline, never a new budget. + Closing the returned lease does not transfer ownership from this object. + """ + self._bind_loop() + self._check(deadline) + if self._task is None: + self._deadline = deadline + self._task = _spawn(self._ensure_once(deadline)) + elif deadline != self._deadline: + raise ManagedStorageError("conflict") + await asyncio.shield(self._task) + self._check(deadline) + if not self._ready or self._connection is None: + raise ManagedStorageError("unavailable") + # A borrower may have explicitly closed its lease between joins. + # Rejoining observes that closure; it never silently reconnects. + _ = self._connection.client + return self._connection + + async def _read(self, deadline: float) -> ManagedServiceStateV1 | None: + while True: + self._check(deadline) + try: + return await _settled_native(lambda: self._journal.read(deadline=deadline)) + except ManagedStorageError as error: + if error.code != "busy": + raise + await self._pause(deadline) + + async def observe_requested_trace( + self, *, deadline: float, + ) -> Literal["applied", "expired", "not_applied_reused_instance", "not_confirmed"]: + """Read the exact startup fact; never install, restart or extend trace. + + Uses the original startup budget. Missing/late evidence is unknown, + not proof of non-application. Applied describes a historical fact. + """ + self._bind_loop() + if self._trace_deadline_ms is None or not self._ready or self._instance is None or self._closing: + raise ManagedStorageError("conflict") + if self._deadline is None or deadline > self._deadline: + raise ManagedStorageError("conflict") + while True: + try: + state = await self._read(deadline) + except ManagedStorageError as error: + if error.code == "busy" and monotonic() >= deadline: + return "not_confirmed" + raise + if state is None or state.handoff.instance != self._instance: + raise ManagedStorageError("conflict") + if state.handoff.attempt_id != self.operation_id: + return "not_applied_reused_instance" + receipt = state.trace_application + if receipt is not None: + if receipt.deadline_ms != self._trace_deadline_ms: + raise ManagedStorageError("conflict") + return "expired" if monotonic() * 1000 >= receipt.deadline_ms else "applied" + if state.handoff.stop_requested or monotonic() * 1000 >= self._trace_deadline_ms: + return "not_confirmed" + if monotonic() >= deadline: + return "not_confirmed" + await asyncio.sleep(min(0.05, max(0, deadline - monotonic()))) + + async def _pause(self, deadline: float) -> None: + self._check(deadline) + await asyncio.sleep(0.05) + self._check(deadline) + + async def _ensure_once(self, deadline: float) -> None: + state = await self._read(deadline) + if state is None or state.cleanly_stopped: + self._check(deadline) + try: + state = await _settled_native(lambda: self._starter.start(expected=state, deadline=deadline)) + except ManagedStorageError as error: + if error.code not in {"busy", "conflict", "unavailable"}: + raise + # Observe after unknown/CAS contention; never replay start. + state = await self._read(deadline) + if state is None or state.cleanly_stopped: + raise error + assert state is not None + self._instance = state.handoff.instance + while True: + self._check(deadline) + if (state.handoff.instance != self._instance or state.handoff.stop_requested + or state.handoff.phase is ManagedHandoffPhaseV1.ABORTING or state.cleanly_stopped): + raise ManagedStorageError("conflict") + process = self._starter._process + if (state.handoff.attempt_id == self.operation_id and state.native_identity is None + and process is not None and process.identity is not None): + try: + state = await _settled_native(lambda: self._starter.register_birth(deadline=deadline)) + except ManagedStorageError as error: + if error.code != "busy": + raise + if state.handoff.phase is ManagedHandoffPhaseV1.COMMITTED: + self._connection = ManagedConnectionLeaseV1( + self._journal, self._namespace, self._service, self._instance, + runtime_root=self._runtime_root, endpoint=self._endpoint, + ) + try: + await self._connection.prepare(deadline=deadline) + except (LocalRecordError, ConnectionRefusedError, AppConnectionClosedError, ManagedStorageError) as error: + if isinstance(error, LocalRecordError) and error.code is not LocalRecordErrorCodeV1.NOT_FOUND: + raise + if isinstance(error, ManagedStorageError) and error.code != "busy": + raise + # Read-only readiness retry, only after original cleanup. + await self._connection.close() + self._connection = None + else: + self._check(deadline) + self._ready = True + return + await self._pause(deadline) + current = await self._read(deadline) + if current is None: + raise ManagedStorageError("unavailable") + state = current + + def fence(self) -> None: + """Prevent further startup/reuse work without closing borrowed delivery.""" + self._bind_loop() + self._closing = True + self._ready = False + self._starter.fence() + + async def close(self) -> None: + self.fence() + task = self._close_task + if task is None or (task.done() and (task.cancelled() or task.exception() is not None)): + task = self._close_task = _spawn(self._close_once()) + await asyncio.shield(task) + + async def _close_once(self) -> None: + if self._task is not None: + await asyncio.gather(self._task, return_exceptions=True) + errors: list[BaseException] = [] + if self._connection is not None: + try: + await self._connection.close() + except BaseException as error: + errors.append(error) + else: + self._connection = None + try: + await _settled_native(self._starter.close) + except BaseException as error: + errors.append(error) + if errors: + raise errors[0] + self._settled = True + + +__all__ = ["ManagedServiceCoordinatorV1"] diff --git a/src/loushang/apphost/managed/mux_creation.py b/src/loushang/apphost/managed/mux_creation.py new file mode 100644 index 000000000..9fb7114b4 --- /dev/null +++ b/src/loushang/apphost/managed/mux_creation.py @@ -0,0 +1,181 @@ +"""One explicit managed Mux creation over already admitted deployment storage. + +Own the original service coordinator and one creation task, not the namespace, +journal, application or Session. Unknown results retain their name reservation; +neither close nor caller cancellation stops a service or repeats a create RPC. +""" + +from __future__ import annotations + +import asyncio +import os +from collections.abc import Callable +from time import monotonic +from typing import TypeVar + +from loushang.appserver.managed_mux import ManagedMuxCreatedV1 +from loushang.appservice.continuity import require_application_id + +from ._files import ManagedStorageError, _check_deadline +from .child import _failed, _spawn +from .connection import ManagedConnectionLeaseV1, _settled_native +from .contracts import ManagedContractError, ManagedNamespaceV1 +from .coordinator import ManagedServiceCoordinatorV1 +from .lifecycle import ManagedServiceJournalV1 +from .mux_management import ManagedMuxManagerV1 +from .registry import ManagedMuxReservationV1, ManagedRegistryV1 +from .starter import ManagedLaunchRequestFactoryV1 + +_T = TypeVar("_T") + + +class ManagedMuxCreateOperationV1: + """Retain before run; keep borrowed storage and event loop alive until close. + + The reservation's operation ID is supplied once by composition and differs + from the service-start attempt ID. Rejoin uses exactly the original deadline. + ``created`` is a known immutable creation fact, even when result registration + fails; ``result`` additionally proves registry reconciliation succeeded. + Neither property is a liveness assertion or a claim that the Mux is open. + """ + + def __init__( + self, registry: ManagedRegistryV1, journal: ManagedServiceJournalV1, + namespace: ManagedNamespaceV1, reservation: ManagedMuxReservationV1, *, + runtime_root: str, endpoint: str, application_id: str, + request_factory: ManagedLaunchRequestFactoryV1, + temporary_override: str | None = None, + ) -> None: + require_application_id(application_id) + if ( + type(registry) is not ManagedRegistryV1 or type(journal) is not ManagedServiceJournalV1 + or type(namespace) is not ManagedNamespaceV1 or type(reservation) is not ManagedMuxReservationV1 + or namespace.user_id != os.geteuid() or journal._database is not registry._database + or journal._namespace != namespace or journal._service != reservation.service + or registry._database._namespace != namespace.namespace_key + ): + raise ManagedContractError() + self._registry, self._journal, self._namespace = registry, journal, namespace + self._reservation, self._application_id = reservation, application_id + self._coordinator = ManagedServiceCoordinatorV1( + journal, namespace, reservation.service, runtime_root=runtime_root, + endpoint=endpoint, request_factory=request_factory, + temporary_override=temporary_override, + ) + self._manager: ManagedMuxManagerV1 | None = None + self._connection: ManagedConnectionLeaseV1 | None = None + self._created: ManagedMuxCreatedV1 | None = None + self._result: ManagedMuxCreatedV1 | None = None + self._task: asyncio.Task[None] | None = None + self._close_task: asyncio.Task[None] | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._deadline: float | None = None + self._closing = self._settled = False + + @property + def created(self) -> ManagedMuxCreatedV1 | None: + return self._created + + @property + def result(self) -> ManagedMuxCreatedV1 | None: + return self._result + + @property + def connection(self) -> ManagedConnectionLeaseV1: + self._bind_loop() + if self._closing or self._result is None or self._connection is None: + raise ManagedStorageError("closed") + _ = self._connection.client + return self._connection + + @property + def cleanup_pending(self) -> bool: + return not self._settled + + def _bind_loop(self) -> None: + loop = asyncio.get_running_loop() + if self._loop is None: + self._loop = loop + elif self._loop is not loop: + raise ManagedStorageError("conflict") + + def _check(self, deadline: float, *, settling: bool = False) -> None: + if type(deadline) not in (int, float): + raise ManagedStorageError("invalid_record") + _check_deadline(deadline) + if self._closing and not settling: + raise ManagedStorageError("closed") + + async def run(self, *, deadline: float) -> ManagedMuxCreatedV1: + self._bind_loop() + self._check(deadline) + if self._task is None: + self._deadline = deadline + self._task = _spawn(self._run_once(deadline)) + elif deadline != self._deadline: + raise ManagedStorageError("conflict") + await asyncio.shield(self._task) + self._check(deadline) + if self._result is None: + raise ManagedStorageError("unavailable") + return self._result + + async def _control(self, operation: Callable[[], _T], deadline: float, *, settling: bool = False) -> _T: + while True: + self._check(deadline, settling=settling) + try: + return await _settled_native(operation) + except ManagedStorageError as error: + if error.code != "busy": + raise + self._check(deadline, settling=settling) + await asyncio.sleep(min(0.01, max(0, deadline - monotonic()))) + + async def _run_once(self, deadline: float) -> None: + reservation = self._reservation + await self._control(lambda: self._registry.reserve_mux(reservation, deadline=deadline), deadline) + self._check(deadline) + connection = self._connection = await self._coordinator.ensure_started(deadline=deadline) + self._check(deadline) + if connection.application_id != self._application_id: + raise ManagedStorageError("conflict") + capability = connection.managed_mux_client + if capability is None: + raise ManagedStorageError("unavailable") + manager = self._manager = ManagedMuxManagerV1( + self._registry, self._journal, self._namespace, reservation.service, + connection.instance, application_id=self._application_id, + ) + permit = await self._control(lambda: manager.issue_create(reservation, deadline=deadline), deadline) + self._check(deadline) + # One RPC only. Timeout cancels its delivery waiter, not accepted remote + # work. Explicit close later settles the original local connection. + try: + async with asyncio.timeout(max(0, deadline - monotonic())): + created = await capability.create_managed_mux(permit) + except TimeoutError: + raise ManagedStorageError("unavailable") from None + if (type(created) is not ManagedMuxCreatedV1 or created.operation_id != reservation.operation_id + or created.name != reservation.name): + raise ManagedStorageError("invalid_record") + self._created = created # Preserve known fact before fallible reconciliation. + self._result = await self._control( + lambda: manager.record_created(permit, created, deadline=deadline), deadline, settling=True, + ) + + async def close(self) -> None: + self._bind_loop() + self._closing = True + self._coordinator.fence() + if self._close_task is None or _failed(self._close_task): + self._close_task = _spawn(self._close_once()) + await asyncio.shield(self._close_task) + + async def _close_once(self) -> None: + if self._task is not None: + await asyncio.gather(self._task, return_exceptions=True) + await self._coordinator.close() + self._settled = True + + +__all__ = ["ManagedMuxCreateOperationV1"] diff --git a/src/loushang/apphost/managed/mux_management.py b/src/loushang/apphost/managed/mux_management.py new file mode 100644 index 000000000..3950112bd --- /dev/null +++ b/src/loushang/apphost/managed/mux_management.py @@ -0,0 +1,795 @@ +"""Purpose-specific managed Mux permits and the original service fence. + +Registry transactions end before RPC. Only the server-side admission retains +the service lock across the *local* AppService continuity commit. All native +entry/exit runs on the same event-loop thread; never split this RLock context +across to_thread jobs. This module neither launches nor stops a service. +""" + +from __future__ import annotations + +import asyncio +import os +import sqlite3 +from contextlib import AbstractContextManager +from dataclasses import dataclass, field +from hmac import compare_digest +from secrets import token_hex +from threading import get_ident +from time import monotonic + +from loushang.appserver.managed_mux import ManagedMuxCreatedV1, ManagedMuxCreateV1 +from loushang.appserver.managed_mux_close import ( + ManagedMuxClosePhaseV1, + ManagedMuxCloseStateV1, + ManagedMuxCloseV1, +) +from loushang.appservice.continuity import ( + MANAGED_CLOSE_CONTINUITY_VERSION, + MANAGED_CONTINUITY_VERSION, + ApplicationContinuityRecordV1, + require_application_id, +) +from loushang.appservice.managed_mux import ManagedMuxServiceBindingV1 +from loushang.appservice.managed_mux_close import ( + ManagedMuxCloseBindingV1, + ManagedMuxCloseRecoveryBindingV1, + ManagedMuxCloseUseV1, +) +from loushang.hosting.service import LinuxServiceIdentityV1 + +from ._files import ManagedStorageError, _check_deadline +from .contracts import ( + _HEX32, + _HEX64, + ManagedContractError, + ManagedHandoffPhaseV1, + ManagedInstanceRefV1, + ManagedNamespaceV1, + ManagedServiceKeyV1, + _match, +) +from .lifecycle import ManagedServiceJournalV1, ManagedServiceTransitionV1 +from .registry import MAX_MUXES, ManagedMuxReservationV1, ManagedRegistryV1, _decode + + +@dataclass(frozen=True, slots=True) +class _Permit: + instance_id: str + origin_instance_id: str + authority: str = field(repr=False) + created: ManagedMuxCreatedV1 | None = None + + +@dataclass(frozen=True, slots=True) +class _ClosePermit: + instance_id: str + origin_instance_id: str + authority: str = field(repr=False) + creation: ManagedMuxCreatedV1 + result: ManagedMuxCloseStateV1 | None + + +@dataclass(frozen=True, slots=True) +class ManagedMuxInspectionV1: + """Frozen historical facts, not liveness or authority to retarget a name. + + The optional request retains its originally issued instance and token. + Inspection never renews permission; consumers must not serialize it into + diagnostics. A closed result does not describe today's same-name Mux. + """ + + creation: ManagedMuxCreatedV1 + close_request: ManagedMuxCloseV1 | None = field(default=None, repr=False) + close_result: ManagedMuxCloseStateV1 | None = None + + def __post_init__(self) -> None: + creation, request, result = self.creation, self.close_request, self.close_result + if type(creation) is not ManagedMuxCreatedV1: + raise ManagedContractError() + target = (creation.operation_id, creation.name, creation.mux_space_id) + if request is not None and (type(request) is not ManagedMuxCloseV1 or + (request.creation_operation_id, request.name, request.mux_space_id) != target): + raise ManagedContractError() + if result is not None and (type(result) is not ManagedMuxCloseStateV1 or request is None or + (result.operation_id, result.creation_operation_id, result.name, result.mux_space_id) + != (request.operation_id, *target)): + raise ManagedContractError() + + +class ManagedMuxManagerV1: + """Borrow admitted storage, issue permits and reconcile trusted responses. + + record_created consumes a result from the caller's exact authenticated + service connection, not arbitrary peer input. A result's original instance + can differ after recovery; the current permit still fences old callers. + Token rows are private registry state, never discovery/diagnostic output. + """ + + def __init__( + self, registry: ManagedRegistryV1, journal: ManagedServiceJournalV1, + namespace: ManagedNamespaceV1, service: ManagedServiceKeyV1, + instance: ManagedInstanceRefV1, *, application_id: str, + startup_attempt_id: str | None = None, + startup_native_identity: LinuxServiceIdentityV1 | None = None, + ) -> None: + require_application_id(application_id) + if ( + type(registry) is not ManagedRegistryV1 + or type(journal) is not ManagedServiceJournalV1 + or type(namespace) is not ManagedNamespaceV1 + or type(service) is not ManagedServiceKeyV1 + or type(instance) is not ManagedInstanceRefV1 + or namespace.user_id != os.geteuid() + or journal._database is not registry._database + or journal._namespace != namespace or journal._service != service + or instance.namespace_key != namespace.namespace_key + or instance.service_id != service.service_id + ): + raise ManagedContractError() + self._registry, self._journal = registry, journal + self._service, self._instance = service, instance + self._application_id = application_id + if (startup_attempt_id is None) != (startup_native_identity is None): + raise ManagedContractError() + if startup_attempt_id is not None: + _match(startup_attempt_id, _HEX32) + if (type(startup_native_identity) is not LinuxServiceIdentityV1 + or startup_native_identity.user_id != namespace.user_id): + raise ManagedContractError() + self._startup_attempt, self._startup_native = startup_attempt_id, startup_native_identity + self._recovery_admitted = False + self._recovery_record: ApplicationContinuityRecordV1 | None = None + self._recovery_transition: ManagedServiceTransitionV1 | None = None + # Data on this original manager, not another cleanup owner. Never + # evict: after restart old pending must use startup recovery instead. + self._continued_closures: dict[str, ManagedMuxCloseStateV1] = {} + + def binding(self) -> ManagedMuxServiceBindingV1: + return ManagedMuxServiceBindingV1( + self._application_id, self._service.service_id, self._instance.instance_id, + self.prepare, closing=None if self._startup_attempt is None else self.closing_binding(), + ) + + def closing_binding(self) -> ManagedMuxCloseBindingV1: + """Recovery activates only with the original bootstrap startup identity.""" + return ManagedMuxCloseBindingV1(self.prepare_close, recovery=( + None if self._startup_attempt is None else ManagedMuxCloseRecoveryBindingV1(self.prepare_recovery) + )) + + def prepare_recovery( + self, record: ApplicationContinuityRecordV1 | None, use: ManagedMuxCloseUseV1, + ) -> ManagedMuxRecoveryAdmission: + if (self._startup_attempt is None or type(use) is not ManagedMuxCloseUseV1 + or use not in (ManagedMuxCloseUseV1.ADMIT, ManagedMuxCloseUseV1.SETTLE)): + raise ManagedStorageError("conflict") + if record is not None and (type(record) is not ApplicationContinuityRecordV1 + or record.application_id != self._application_id or record.product_id != self._service.product_id + or record.managed_service_id != self._service.service_id + or record.contract_version not in (MANAGED_CONTINUITY_VERSION, MANAGED_CLOSE_CONTINUITY_VERSION)): + raise ManagedStorageError("conflict") + return ManagedMuxRecoveryAdmission(self, record, use) + + def _recovery_history(self, connection: sqlite3.Connection, record: ApplicationContinuityRecordV1 | None) -> None: + creations = {} if record is None else {item.operation_id: item for item in record.managed_creations} + closures = {} if record is None else {item.operation_id: item for item in record.managed_closures} + confirmed = connection.execute( + "SELECT a.operation_id FROM mux_authorities a JOIN mux_intents m USING(operation_id) " + "WHERE m.service_id=? AND a.created_instance_id IS NOT NULL", (self._service.service_id,), + ) + if any(row[0] not in creations for row in confirmed): + raise ManagedStorageError("conflict") + confirmed = connection.execute( + "SELECT c.operation_id FROM mux_close_authorities c JOIN mux_intents m " + "ON c.creation_operation_id=m.operation_id WHERE m.service_id=? AND c.phase IS NOT NULL", + (self._service.service_id,), + ) + if any(row[0] not in closures for row in confirmed): + raise ManagedStorageError("conflict") + for item in creations.values(): + row = connection.execute( + "SELECT name, service_id FROM mux_intents WHERE operation_id=?", (item.operation_id,), + ).fetchone() + permit = self._permit(connection, item.name, item.operation_id) + if (row != (item.name, self._service.service_id) or permit is None + or permit.origin_instance_id != item.instance_id + or permit.created is not None and permit.created != item): + raise ManagedStorageError("conflict") + for closure in closures.values(): + close = self._close_permit(connection, closure.operation_id) + if (close is None or close.origin_instance_id != closure.instance_id + or close.creation != creations[closure.creation_operation_id] + or close.result is not None and close.result.phase is ManagedMuxClosePhaseV1.CLOSED + and close.result != closure): + raise ManagedStorageError("conflict") + if record is not None: + by_mux = {item.mux_space_id: item for item in creations.values()} + for mux in record.mux_spaces: + self._reservation(connection, mux.name, by_mux[mux.mux_space_id].operation_id) + + def _current(self, connection: sqlite3.Connection, *, stopped: bool = False) -> None: + journal = self._journal + journal._require_open() + state = journal._read(connection) + if ( + state is None or state.handoff.instance != self._instance + or state.handoff.phase is not ManagedHandoffPhaseV1.COMMITTED + or (state.handoff.stop_requested and not stopped) + ): + raise ManagedStorageError("conflict") + + def _reservation(self, connection: sqlite3.Connection, name: str, operation: str) -> None: + row = connection.execute( + "SELECT m.name, s.product, s.workspace, s.profile, m.operation_id, s.service_id " + "FROM muxes m JOIN services s USING(service_id) WHERE m.name=?", (name,), + ).fetchone() + if row is None or _decode(row) != ManagedMuxReservationV1(name, self._service, operation): + raise ManagedStorageError("conflict") + + @staticmethod + def _permit(connection: sqlite3.Connection, name: str, operation: str) -> _Permit | None: + row = connection.execute( + "SELECT instance_id, origin_instance_id, authority, created_instance_id, mux_space_id " + "FROM mux_authorities WHERE operation_id=?", (operation,), + ).fetchone() + if row is None: + return None + try: + _match(row[0], _HEX32) + _match(row[1], _HEX32) + _match(row[2], _HEX64) + if (row[3] is None) != (row[4] is None): + raise ManagedContractError() + created = None if row[3] is None else ManagedMuxCreatedV1(operation, row[3], name, row[4]) + if created is not None and created.instance_id != row[1]: + raise ManagedContractError() + return _Permit(row[0], row[1], row[2], created) + except (ValueError, TypeError): + raise ManagedStorageError("invalid_record") from None + + def _request(self, request: ManagedMuxCreateV1) -> None: + if type(request) is not ManagedMuxCreateV1 or ( + request.service_id != self._service.service_id + or request.instance_id != self._instance.instance_id + ): + raise ManagedStorageError("conflict") + + def _authorized(self, connection: sqlite3.Connection, request: ManagedMuxCreateV1) -> _Permit: + self._request(request) + self._reservation(connection, request.name, request.operation_id) + permit = self._permit(connection, request.name, request.operation_id) + if (permit is None or permit.instance_id != request.instance_id + or not compare_digest(permit.authority, request.authority)): + raise ManagedStorageError("conflict") + return permit + + def issue_create(self, reservation: ManagedMuxReservationV1, *, deadline: float) -> ManagedMuxCreateV1: + """Reserve one opaque current-instance permit, idempotent on lost reply. + + Does not reserve a name or start the service implicitly. Reauthorization + replaces only current permission, never the immutable historical result. + """ + if type(reservation) is not ManagedMuxReservationV1 or reservation.service != self._service: + raise ManagedContractError() + journal = self._journal + journal._require_open() + with journal._fence.lock("lifecycle.lock", deadline=deadline): + with journal._database.transaction(write=True, deadline=deadline) as connection: + self._current(connection) + self._reservation(connection, reservation.name, reservation.operation_id) + previous = self._permit(connection, reservation.name, reservation.operation_id) + if previous is not None and previous.instance_id == self._instance.instance_id: + authority = previous.authority + else: + if previous is None and connection.execute( + "SELECT count(*) FROM mux_authorities", + ).fetchone()[0] >= MAX_MUXES: + raise ManagedStorageError("capacity") + if previous is None: + journal._database.admit_growth(connection) + else: + journal._database.admit_control(connection) + authority = token_hex(32) + connection.execute( + "INSERT INTO mux_authorities VALUES (?, ?, ?, ?, NULL, NULL) " + "ON CONFLICT(operation_id) DO UPDATE SET instance_id=excluded.instance_id, " + "authority=excluded.authority", + (reservation.operation_id, self._instance.instance_id, + self._instance.instance_id, authority), + ) + result = ManagedMuxCreateV1(self._service.service_id, self._instance.instance_id, + reservation.operation_id, reservation.name, authority) + journal._fence._check() + return result + + def prepare(self, request: ManagedMuxCreateV1) -> ManagedMuxAdmission: + """Pure factory: AppService retains this exact owner before acquire.""" + self._request(request) + return ManagedMuxAdmission(self, request) + + def read_created(self, request: ManagedMuxCreateV1, *, deadline: float) -> ManagedMuxCreatedV1 | None: + self._request(request) + journal = self._journal + journal._require_open() + with journal._fence.lock("lifecycle.lock", deadline=deadline): + with journal._database.transaction(deadline=deadline) as connection: + self._current(connection, stopped=True) + result = self._authorized(connection, request).created + journal._fence._check() + return result + + def record_created( + self, request: ManagedMuxCreateV1, result: ManagedMuxCreatedV1, *, deadline: float, + ) -> ManagedMuxCreatedV1: + self._request(request) + if type(result) is not ManagedMuxCreatedV1 or ( + result.name != request.name or result.operation_id != request.operation_id + ): + raise ManagedStorageError("conflict") + journal = self._journal + journal._require_open() + with journal._fence.lock("lifecycle.lock", deadline=deadline): + with journal._database.transaction(write=True, deadline=deadline) as connection: + self._current(connection, stopped=True) + permit = self._authorized(connection, request) + if result.instance_id != permit.origin_instance_id: + raise ManagedStorageError("conflict") + previous = permit.created + if previous is not None: + if previous != result: + raise ManagedStorageError("conflict") + else: + journal._database.admit_control(connection) + connection.execute( + "UPDATE mux_authorities SET created_instance_id=?, mux_space_id=? " + "WHERE operation_id=?", (result.instance_id, result.mux_space_id, result.operation_id), + ) + journal._fence._check() + return result + + + def _close_request(self, request: ManagedMuxCloseV1) -> None: + if type(request) is not ManagedMuxCloseV1 or ( + request.service_id != self._service.service_id + or request.instance_id != self._instance.instance_id + ): + raise ManagedStorageError("conflict") + + def _admit_unaccepted_closure(self, creation: ManagedMuxCreatedV1, pending: ManagedMuxCloseStateV1) -> None: + """Called only under the original live ADMIT fence, after full checks. + + The immutable operation origin can precede its first actual execution. + Only the original startup snapshot can prove this older intent was + still an active, unclosed target; a later load or rotated token cannot. + """ + record = self._recovery_record + if (not self._recovery_admitted or record is None + or creation not in record.managed_creations + or not any(mux.mux_space_id == creation.mux_space_id and mux.name == creation.name + for mux in record.mux_spaces) + or any(item.creation_operation_id == creation.operation_id or item.mux_space_id == creation.mux_space_id + for item in record.managed_closures)): + raise ManagedStorageError("conflict") + previous = self._continued_closures.get(pending.operation_id) + if previous is not None and previous != pending: + raise ManagedStorageError("conflict") + if previous is None and len(self._continued_closures) >= MAX_MUXES: + raise ManagedStorageError("capacity") + self._continued_closures[pending.operation_id] = pending + + def _historical_creation(self, connection: sqlite3.Connection, reservation: ManagedMuxReservationV1) -> ManagedMuxCreatedV1: + row = connection.execute( + "SELECT m.name, s.product, s.workspace, s.profile, m.operation_id, s.service_id " + "FROM mux_intents m JOIN services s USING(service_id) WHERE m.operation_id=?", + (reservation.operation_id,), + ).fetchone() + if row is None or _decode(row) != reservation: + raise ManagedStorageError("conflict") + permit = self._permit(connection, reservation.name, reservation.operation_id) + if permit is None or permit.created is None: + raise ManagedStorageError("conflict") + return permit.created + + def _close_permit(self, connection: sqlite3.Connection, operation: str) -> _ClosePermit | None: + row = connection.execute( + "SELECT c.instance_id, c.origin_instance_id, c.authority, c.creation_operation_id, " + "c.phase, m.name, m.service_id FROM mux_close_authorities c " + "JOIN mux_intents m ON m.operation_id=c.creation_operation_id WHERE c.operation_id=?", + (operation,), + ).fetchone() + if row is None: + return None + if row[6] != self._service.service_id: + raise ManagedStorageError("conflict") + try: + _match(row[0], _HEX32) + _match(row[1], _HEX32) + _match(row[2], _HEX64) + creation = self._historical_creation(connection, ManagedMuxReservationV1(row[5], self._service, row[3])) + result = None if row[4] is None else ManagedMuxCloseStateV1( + operation, row[1], creation.operation_id, creation.name, creation.mux_space_id, + ManagedMuxClosePhaseV1(row[4]), + ) + return _ClosePermit(row[0], row[1], row[2], creation, result) + except (ValueError, TypeError): + raise ManagedStorageError("invalid_record") from None + + def _close_authorized(self, connection: sqlite3.Connection, request: ManagedMuxCloseV1) -> _ClosePermit: + self._close_request(request) + permit = self._close_permit(connection, request.operation_id) + if (permit is None or permit.instance_id != request.instance_id + or not compare_digest(permit.authority, request.authority) + or (permit.creation.operation_id, permit.creation.name, permit.creation.mux_space_id) + != (request.creation_operation_id, request.name, request.mux_space_id)): + raise ManagedStorageError("conflict") + if permit.result is None or permit.result.phase is not ManagedMuxClosePhaseV1.CLOSED: + self._reservation(connection, request.name, request.creation_operation_id) + return permit + + def _inspect_close(self, connection: sqlite3.Connection, operation: str) -> ManagedMuxInspectionV1: + permit = self._close_permit(connection, operation) + if permit is None: + raise ManagedStorageError("not_found") + creation = permit.creation + request = ManagedMuxCloseV1(self._service.service_id, permit.instance_id, operation, + creation.operation_id, creation.name, creation.mux_space_id, permit.authority) + return ManagedMuxInspectionV1(creation, request, permit.result) + + def inspect_mux( + self, reservation: ManagedMuxReservationV1, *, deadline: float, wait_for_lock: bool = False, + ) -> ManagedMuxInspectionV1: + """Freeze a confirmed active identity without issuing any permission. + + Default fail-fast; synchronous/off-loop callers may opt into bounded + waiting. The original journal and registry remain borrowed throughout. + """ + if type(reservation) is not ManagedMuxReservationV1 or reservation.service != self._service: + raise ManagedContractError() + journal = self._journal + journal._require_open() + with journal._fence.lock("lifecycle.lock", deadline=deadline, wait_for_lock=wait_for_lock): + with journal._database.transaction(deadline=deadline, wait_for_lock=wait_for_lock) as connection: + self._current(connection, stopped=True) + self._reservation(connection, reservation.name, reservation.operation_id) + creation = self._historical_creation(connection, reservation) + row = connection.execute("SELECT operation_id FROM mux_close_authorities WHERE creation_operation_id=?", + (reservation.operation_id,)).fetchone() + result = ManagedMuxInspectionV1(creation) if row is None else self._inspect_close(connection, row[0]) + journal._fence._check() + return result + + def inspect_close_operation( + self, operation_id: str, *, deadline: float, wait_for_lock: bool = False, + ) -> ManagedMuxInspectionV1: + """Observe exact history, including after stop or same-name reuse. + + Validate the original paired journal but do not require current serving + authority. A request from an older instance is returned unchanged. + """ + _match(operation_id, _HEX32) + journal = self._journal + journal._require_open() + with journal._fence.lock("lifecycle.lock", deadline=deadline, wait_for_lock=wait_for_lock): + with journal._database.transaction(deadline=deadline, wait_for_lock=wait_for_lock) as connection: + journal._read(connection) + result = self._inspect_close(connection, operation_id) + journal._fence._check() + return result + + def issue_close( + self, reservation: ManagedMuxReservationV1, *, operation_id: str, deadline: float, wait_for_lock: bool = False, + ) -> ManagedMuxCloseV1: + if type(reservation) is not ManagedMuxReservationV1 or reservation.service != self._service: + raise ManagedContractError() + _match(operation_id, _HEX32) + journal = self._journal + journal._require_open() + with journal._fence.lock("lifecycle.lock", deadline=deadline, wait_for_lock=wait_for_lock): + with journal._database.transaction(write=True, deadline=deadline, wait_for_lock=wait_for_lock) as connection: + self._current(connection) + if connection.execute("SELECT 1 FROM mux_intents WHERE operation_id=?", (operation_id,)).fetchone(): + raise ManagedStorageError("conflict") + creation = self._historical_creation(connection, reservation) + previous = self._close_permit(connection, operation_id) + if previous is not None and previous.creation != creation: + raise ManagedStorageError("conflict") + if previous is None and connection.execute( + "SELECT 1 FROM mux_close_authorities WHERE creation_operation_id=?", (creation.operation_id,), + ).fetchone(): + raise ManagedStorageError("conflict") + if previous is None or previous.result is None or previous.result.phase is not ManagedMuxClosePhaseV1.CLOSED: + self._reservation(connection, reservation.name, reservation.operation_id) + if previous is not None and previous.instance_id == self._instance.instance_id: + authority = previous.authority + else: + journal._database.admit_control(connection) + authority = token_hex(32) + connection.execute( + "INSERT INTO mux_close_authorities VALUES (?, ?, ?, ?, ?, NULL) " + "ON CONFLICT(operation_id) DO UPDATE SET instance_id=excluded.instance_id, authority=excluded.authority", + (operation_id, creation.operation_id, self._instance.instance_id, self._instance.instance_id, authority), + ) + result = ManagedMuxCloseV1(self._service.service_id, self._instance.instance_id, operation_id, + creation.operation_id, creation.name, creation.mux_space_id, authority) + journal._fence._check() + return result + + def prepare_close(self, request: ManagedMuxCloseV1, use: ManagedMuxCloseUseV1) -> ManagedMuxCloseAdmission: + self._close_request(request) + if type(use) is not ManagedMuxCloseUseV1: + raise ManagedContractError() + return ManagedMuxCloseAdmission(self, request, use) + + def read_close(self, request: ManagedMuxCloseV1, *, deadline: float) -> ManagedMuxCloseStateV1 | None: + self._close_request(request) + journal = self._journal + journal._require_open() + with journal._fence.lock("lifecycle.lock", deadline=deadline): + with journal._database.transaction(deadline=deadline) as connection: + self._current(connection, stopped=True) + result = self._close_authorized(connection, request).result + journal._fence._check() + return result + + def record_close( + self, request: ManagedMuxCloseV1, result: ManagedMuxCloseStateV1, *, deadline: float, wait_for_lock: bool = False, + ) -> ManagedMuxCloseStateV1: + """Consume only an exact authenticated service result, never raw peer input.""" + self._close_request(request) + if type(result) is not ManagedMuxCloseStateV1: + raise ManagedStorageError("conflict") + journal = self._journal + journal._require_open() + with journal._fence.lock("lifecycle.lock", deadline=deadline, wait_for_lock=wait_for_lock): + with journal._database.transaction(write=True, deadline=deadline, wait_for_lock=wait_for_lock) as connection: + self._current(connection, stopped=True) + permit = self._close_authorized(connection, request) + self._check_close_result(permit, request, result) + if result != permit.result: + journal._database.admit_control(connection) + connection.execute("UPDATE mux_close_authorities SET phase=? WHERE operation_id=?", + (result.phase.value, request.operation_id)) + if result.phase is ManagedMuxClosePhaseV1.CLOSED: + deleted = connection.execute( + "DELETE FROM muxes WHERE name=? AND service_id=? AND operation_id=?", + (request.name, self._service.service_id, request.creation_operation_id), + ) + if deleted.rowcount != 1: + raise ManagedStorageError("conflict") + journal._fence._check() + return result + + @staticmethod + def _check_close_result(permit: _ClosePermit, request: ManagedMuxCloseV1, result: ManagedMuxCloseStateV1) -> None: + expected = ManagedMuxCloseStateV1(request.operation_id, permit.origin_instance_id, + request.creation_operation_id, request.name, request.mux_space_id, result.phase) + if result != expected or (permit.result is not None and permit.result.phase is ManagedMuxClosePhaseV1.CLOSED + and result != permit.result): + raise ManagedStorageError("conflict") + + def verify_close_result( + self, request: ManagedMuxCloseV1, result: ManagedMuxCloseStateV1, *, deadline: float, wait_for_lock: bool = False, + ) -> ManagedMuxCloseStateV1: + """Read-only historical origin/monotonicity check before presenting a reply. + + Transport validates correlation and target, not the registry's origin. + Verification grants no reconciliation, token renewal or name release. + """ + self._close_request(request) + if type(result) is not ManagedMuxCloseStateV1: + raise ManagedStorageError("conflict") + journal = self._journal + journal._require_open() + with journal._fence.lock("lifecycle.lock", deadline=deadline, wait_for_lock=wait_for_lock): + with journal._database.transaction(deadline=deadline, wait_for_lock=wait_for_lock) as connection: + self._current(connection, stopped=True) + permit = self._close_authorized(connection, request) + self._check_close_result(permit, request, result) + journal._fence._check() + return result + + +class _ManagedMuxFence: + """One loop/thread-bound original native context, never a copied lock. + + An uncertain __exit__ is not retried on an exhausted generator, nor counted + as release success. The borrowed native owner keeps its original close debt. + """ + + def __init__(self, manager: ManagedMuxManagerV1) -> None: + self._manager = manager + self._context: AbstractContextManager[None] | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._thread: int | None = None + self._attempted = self._entered = self._exit_attempted = self._closed = False + + def _bind(self) -> None: + loop = asyncio.get_running_loop() + thread = get_ident() + if self._loop is None: + self._loop, self._thread = loop, thread + elif self._loop is not loop or self._thread != thread: + raise ManagedStorageError("conflict") + + async def acquire(self) -> None: + self._bind() + if self._closed or self._attempted: + raise ManagedStorageError("conflict") + self._attempted = True + await self._acquire_permission(monotonic() + 5.0) + + async def _acquire_permission(self, deadline: float) -> None: + journal = self._manager._journal + while True: + if self._closed or self._exit_attempted: + raise ManagedStorageError("closed") + _check_deadline(deadline) + try: + await self._read_once(deadline) + return + except ManagedStorageError as error: + if (error.code != "busy" or self._exit_attempted + or journal._fence.cleanup_pending or journal._database.cleanup_pending): + raise + # No check_*/ADMIT/CAS/Session effect has occurred. Release + # this exact read context; never retry an uncertain release. + if self._entered: + assert self._context is not None + self._exit_attempted = True + self._context.__exit__(None, None, None) + self._entered = False + if journal._fence.cleanup_pending or journal._database.cleanup_pending: + raise ManagedStorageError("unavailable") from None + self._context = None + self._discard_permission() + self._exit_attempted = False # Prior read's release is confirmed. + _check_deadline(deadline) + await asyncio.sleep(min(0.01, max(0.0, deadline - monotonic()))) + + async def _read_once(self, deadline: float) -> None: + journal = self._manager._journal + journal._require_open() + self._context = journal._fence.lock("lifecycle.lock", deadline=deadline) + self._context.__enter__() + self._entered = True + with journal._database.transaction(deadline=deadline) as connection: + self._read_permission(connection) + journal._fence._check() + _check_deadline(deadline) + + def _read_permission(self, connection: sqlite3.Connection) -> None: + raise NotImplementedError + + def _discard_permission(self) -> None: + raise NotImplementedError + + async def close(self) -> None: + self._bind() + if self._closed: + return + if self._exit_attempted: + raise ManagedStorageError("unavailable") + if self._entered: + assert self._context is not None + self._exit_attempted = True + self._context.__exit__(None, None, None) + self._entered = False + if self._manager._journal._fence.cleanup_pending: + raise ManagedStorageError("unavailable") + self._context = None + self._closed = True + + +class ManagedMuxRecoveryAdmission(_ManagedMuxFence): + """Borrow the startup fence; no Session IO or native recapture occurs here.""" + + def __init__( + self, manager: ManagedMuxManagerV1, record: ApplicationContinuityRecordV1 | None, + use: ManagedMuxCloseUseV1, + ) -> None: + super().__init__(manager) + self._record, self._use = record, use + self._transition: ManagedServiceTransitionV1 | None = None + + def _discard_permission(self) -> None: + self._transition = None + + def _read_permission(self, connection: sqlite3.Connection) -> None: + manager = self._manager + state = manager._journal._read_state(connection) + transition = manager._journal._read_transition(connection, state) + if (state is None or transition is None or state.handoff.instance != manager._instance + or state.handoff.attempt_id != manager._startup_attempt + or state.native_identity != manager._startup_native + or state.handoff.phase not in (ManagedHandoffPhaseV1.PROVISIONAL, ManagedHandoffPhaseV1.ABORTING) + or self._record is not None and transition.previous is None): + raise ManagedStorageError("conflict") + if self._use is ManagedMuxCloseUseV1.ADMIT: + if state.handoff.stop_requested or state.handoff.phase is not ManagedHandoffPhaseV1.PROVISIONAL: + raise ManagedStorageError("conflict") + elif not manager._recovery_admitted: + raise ManagedStorageError("conflict") + if manager._recovery_admitted and ( + manager._recovery_record != self._record or manager._recovery_transition != transition + ): + raise ManagedStorageError("conflict") + manager._recovery_history(connection, self._record) + self._transition = transition + + def check_record(self, record: ApplicationContinuityRecordV1 | None) -> None: + self._bind() + if (not self._entered or self._exit_attempted or self._transition is None or record != self._record): + raise ManagedStorageError("conflict") + if self._use is ManagedMuxCloseUseV1.ADMIT: + manager = self._manager + manager._recovery_record, manager._recovery_transition = record, self._transition + manager._recovery_admitted = True + + +class ManagedMuxAdmission(_ManagedMuxFence): + def __init__(self, manager: ManagedMuxManagerV1, request: ManagedMuxCreateV1) -> None: + super().__init__(manager) + self._request = request + self._permit: _Permit | None = None + + def _discard_permission(self) -> None: + self._permit = None + + def _read_permission(self, connection: sqlite3.Connection) -> None: + self._manager._current(connection) + self._permit = self._manager._authorized(connection, self._request) + + def check_creation(self, previous: ManagedMuxCreatedV1 | None) -> None: + self._bind() + permit = self._permit + if not self._entered or self._exit_attempted or permit is None: + raise ManagedStorageError("conflict") + if ( + (permit.created is not None and permit.created != previous) + or (previous is not None and previous.instance_id != permit.origin_instance_id) + or (previous is None and permit.origin_instance_id != self._request.instance_id) + ): + # Missing history of an old issued operation is not freshness. + raise ManagedStorageError("conflict") + + +class ManagedMuxCloseAdmission(_ManagedMuxFence): + def __init__(self, manager: ManagedMuxManagerV1, request: ManagedMuxCloseV1, use: ManagedMuxCloseUseV1) -> None: + super().__init__(manager) + self._request, self._use = request, use + self._permit: _ClosePermit | None = None + + def _discard_permission(self) -> None: + self._permit = None + + def _read_permission(self, connection: sqlite3.Connection) -> None: + self._manager._current(connection, stopped=self._use is not ManagedMuxCloseUseV1.ADMIT) + self._permit = self._manager._close_authorized(connection, self._request) + + def check_closure(self, creation: ManagedMuxCreatedV1, previous: ManagedMuxCloseStateV1 | None) -> str: + self._bind() + permit, request = self._permit, self._request + if (not self._entered or self._exit_attempted or permit is None or creation != permit.creation): + raise ManagedStorageError("conflict") + if previous is None: + if permit.result is not None or self._use is ManagedMuxCloseUseV1.SETTLE: + raise ManagedStorageError("conflict") + if self._use is ManagedMuxCloseUseV1.ADMIT and permit.origin_instance_id != request.instance_id: + pending = ManagedMuxCloseStateV1(request.operation_id, permit.origin_instance_id, + creation.operation_id, creation.name, creation.mux_space_id, ManagedMuxClosePhaseV1.CLEANUP_PENDING) + self._manager._admit_unaccepted_closure(creation, pending) + return permit.origin_instance_id + if type(previous) is not ManagedMuxCloseStateV1 or previous != ManagedMuxCloseStateV1( + request.operation_id, permit.origin_instance_id, creation.operation_id, + creation.name, creation.mux_space_id, previous.phase, + ): + raise ManagedStorageError("conflict") + if (permit.result is not None and permit.result.phase is ManagedMuxClosePhaseV1.CLOSED and previous != permit.result + or self._use is ManagedMuxCloseUseV1.SETTLE and previous.phase is not ManagedMuxClosePhaseV1.CLEANUP_PENDING + or previous.phase is ManagedMuxClosePhaseV1.CLEANUP_PENDING + and self._use is not ManagedMuxCloseUseV1.OBSERVE and previous.instance_id != request.instance_id + and self._manager._continued_closures.get(request.operation_id) != previous): + # Cross-generation pending must pass the separate recovery barrier, + # not obtain live ADMIT/SETTLE just by rotating its private token. + raise ManagedStorageError("conflict") + return permit.origin_instance_id diff --git a/src/loushang/apphost/managed/mux_probe.py b/src/loushang/apphost/managed/mux_probe.py new file mode 100644 index 000000000..07cfff7a7 --- /dev/null +++ b/src/loushang/apphost/managed/mux_probe.py @@ -0,0 +1,256 @@ +"""Bounded read-only Mux discovery with owned, same-loop authentication. + +No attach, spawn, stop or Session recovery. Only immutable observations leave +the operation; connections and journals settle before a result is returned. +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, replace +from functools import partial +from pathlib import Path +from time import monotonic +from typing import Literal + +from loushang.appserver.local_record import require_endpoint +from loushang.appserver.protocol import ( + AppErrorCodeV1, + AppServiceError, + MuxReadV1, + MuxSelectorV1, + MuxSpaceV1, +) +from loushang.appservice.continuity import require_application_id + +from ._files import ManagedStorageError, _check_deadline +from .child import _spawn +from .connection import ManagedConnectionLeaseV1, _settled_native +from .contracts import ( + _ID, + ManagedContractError, + ManagedHandoffPhaseV1, + ManagedNamespaceV1, + _match, + _path, +) +from .discovery import ManagedDiscoveryV1, ManagedMuxObservationV1 +from .lifecycle import ManagedServiceJournalV1 +from .mux_management import ManagedMuxManagerV1 +from .paths import resolve_managed_service_paths +from .registry import MAX_MUXES, ManagedRegistryV1 + +ProbeStatus = Literal["authenticated_present", "recorded_ineligible", "not_present", "unknown"] + + +@dataclass(frozen=True, slots=True) +class ManagedMuxProbeResultV1: + observation: ManagedMuxObservationV1 + status: ProbeStatus + mux_space_id: str | None = None + + def __post_init__(self) -> None: + if (type(self.observation) is not ManagedMuxObservationV1 or type(self.status) is not str + or self.status not in {"authenticated_present", "recorded_ineligible", "not_present", "unknown"} + or (self.status in {"authenticated_present", "not_present"}) != (self.mux_space_id is not None)): + raise ManagedContractError() + if self.mux_space_id is not None: + MuxSelectorV1(mux_space_id=self.mux_space_id) + + +@dataclass(frozen=True, slots=True) +class ManagedMuxProbeSnapshotV1: + results: tuple[ManagedMuxProbeResultV1, ...] + candidates_unchanged: bool + + def __post_init__(self) -> None: + if (type(self.results) is not tuple or len(self.results) > MAX_MUXES + or any(type(row) is not ManagedMuxProbeResultV1 for row in self.results) + or type(self.candidates_unchanged) is not bool + or len({row.observation.name for row in self.results}) != len(self.results)): + raise ManagedContractError() + + @property + def unique_present(self) -> ManagedMuxProbeResultV1 | None: + if not self.candidates_unchanged or any(row.status == "unknown" for row in self.results): + return None + present = [row for row in self.results if row.status == "authenticated_present"] + return present[0] if len(present) == 1 else None + + +def _candidate_key(item: ManagedMuxObservationV1) -> tuple[object, ...]: + return (item.reservation, item.instance, item.recorded_phase, item.stop_requested, item.cleanly_stopped) + + +class ManagedMuxProbeOperationV1: + """Borrow registry until close settles; public cancellation never drops IO. + + Construction performs no IO. The caller retains this owner before run and + must keep its loop alive through close, including a timed-out close waiter. + The operation never terminates its host process. + """ + + def __init__(self, registry: ManagedRegistryV1, namespace: ManagedNamespaceV1, *, + product_id: str, runtime_root: str, endpoint: str, + expected_application_id: str, deadline: float) -> None: + self._discovery = ManagedDiscoveryV1(registry, namespace) + _match(product_id, _ID) + _path(runtime_root) + require_endpoint(endpoint) + require_application_id(expected_application_id) + if deadline is None: + raise ManagedContractError() + _check_deadline(deadline) + self._registry, self._namespace = registry, namespace + self._product, self._runtime = product_id, runtime_root + self._endpoint, self._application = endpoint, expected_application_id + self._deadline = deadline + self._loop: asyncio.AbstractEventLoop | None = None + self._task: asyncio.Task[ManagedMuxProbeSnapshotV1] | None = None + self._close_task: asyncio.Task[None] | None = None + self._close_deadline: float | None = None + self._journal: ManagedServiceJournalV1 | None = None + self._connection: ManagedConnectionLeaseV1 | None = None + self._closing = self._settled = False + + @property + def cleanup_pending(self) -> bool: + return not self._settled + + def _bind_loop(self) -> None: + current = asyncio.get_running_loop() + if self._loop is None: + self._loop = current + elif current is not self._loop: + raise ManagedStorageError("conflict") + + async def run(self) -> ManagedMuxProbeSnapshotV1: + self._bind_loop() + if self._closing: + raise ManagedStorageError("closed") + if self._task is None: + self._task = _spawn(self._run_once()) + result = await asyncio.shield(self._task) + if self._closing: + raise ManagedStorageError("closed") + # Native completion and loop delivery are different observations. A + # late/rejoined result may be displayed, but cannot auto-select. + if monotonic() >= self._deadline: + return replace(result, candidates_unchanged=False) + return result + + def _snapshot(self) -> tuple[ManagedMuxObservationV1, ...]: + snapshot = self._discovery.snapshot_namespace(deadline=self._deadline, wait_for_lock=True) + return tuple(item for item in snapshot.muxes if item.service.product_id == self._product) + + async def _run_once(self) -> ManagedMuxProbeSnapshotV1: + original = await _settled_native(self._snapshot) + results = {item.name: ManagedMuxProbeResultV1(item, "unknown") for item in original} + groups: dict[str, list[ManagedMuxObservationV1]] = {} + for item in original: + if item.cleanly_stopped: + results[item.name] = ManagedMuxProbeResultV1(item, "recorded_ineligible") + elif (item.instance is not None and item.recorded_phase is ManagedHandoffPhaseV1.COMMITTED + and not item.stop_requested): + groups.setdefault(item.service.service_id, []).append(item) + for group in groups.values(): + if self._closing or monotonic() >= self._deadline: + break + try: + await self._probe_group(group, results) + except Exception: + # A failed service admission proves neither absence nor offline. + for item in group: + results[item.name] = ManagedMuxProbeResultV1(item, "unknown") + finally: + # Cleanup errors escape; no values can authorize selection while + # a native owner remains unsettled. Original fields retain debt. + await self._settle_group() + unchanged = False + if not self._closing and monotonic() < self._deadline: + try: + current = await _settled_native(self._snapshot) + unchanged = tuple(map(_candidate_key, current)) == tuple(map(_candidate_key, original)) + except Exception: + pass + return ManagedMuxProbeSnapshotV1(tuple(results[item.name] for item in original), unchanged) + + async def _probe_group(self, group: list[ManagedMuxObservationV1], + results: dict[str, ManagedMuxProbeResultV1]) -> None: + first = group[0] + assert first.instance is not None + paths = resolve_managed_service_paths(self._namespace, first.service, runtime_root=self._runtime) + self._journal = ManagedServiceJournalV1(self._registry, self._namespace, first.service, + Path(paths.lifecycle), defer_open=True) + await _settled_native(partial(self._journal.open, deadline=self._deadline, wait_for_lock=True)) + if self._closing: + return + _check_deadline(self._deadline) + self._connection = ManagedConnectionLeaseV1(self._journal, self._namespace, first.service, + first.instance, runtime_root=self._runtime, endpoint=self._endpoint) + await self._connection.prepare(deadline=self._deadline) + if self._connection.application_id != self._application: + raise ManagedStorageError("conflict") + manager = ManagedMuxManagerV1(self._registry, self._journal, self._namespace, + first.service, first.instance, application_id=self._application) + for item in group: + if self._closing or monotonic() >= self._deadline: + break + try: + inspection = await _settled_native(partial(manager.inspect_mux, item.reservation, + deadline=self._deadline, wait_for_lock=True)) + if self._closing: + break + mux_id = inspection.creation.mux_space_id + _check_deadline(self._deadline) + async with asyncio.timeout(max(0, self._deadline - monotonic())): + try: + mux = await self._connection.client.read_mux(MuxReadV1(MuxSelectorV1(mux_space_id=mux_id))) + except AppServiceError as error: + if error.code is not AppErrorCodeV1.NOT_FOUND: + raise + results[item.name] = ManagedMuxProbeResultV1(item, "not_present", mux_id) + else: + if type(mux) is not MuxSpaceV1 or mux.mux_space_id != mux_id or mux.name != item.name: + raise ManagedStorageError("conflict") + results[item.name] = ManagedMuxProbeResultV1(item, "authenticated_present", mux_id) + except Exception: + results[item.name] = ManagedMuxProbeResultV1(item, "unknown") + + async def _settle_group(self) -> None: + if self._connection is not None: + await self._connection.close() + self._connection = None + if self._journal is not None: + await _settled_native(self._journal.close) + self._journal = None + + async def close(self) -> None: + self._bind_loop() + self._closing = True + if self._settled: + return + if self._close_deadline is None: + self._close_deadline = monotonic() + 5 + if self._close_task is None or (self._close_task.done() and ( + self._close_task.cancelled() or self._close_task.exception() is not None + )): + _check_deadline(self._close_deadline) + self._close_task = _spawn(self._close_once()) + if self._close_task.done(): + await asyncio.shield(self._close_task) + return + async with asyncio.timeout(max(0, self._close_deadline - monotonic())): + await asyncio.shield(self._close_task) + + async def _close_once(self) -> None: + if self._task is not None: + await asyncio.gather(self._task, return_exceptions=True) + await self._settle_group() + self._settled = True + + def close_unstarted(self) -> None: + """Settle a never-entered operation when Runner admission itself failed.""" + if self._loop is not None or self._task is not None or self._journal is not None or self._connection is not None: + raise ManagedStorageError("busy") + self._closing = self._settled = True diff --git a/src/loushang/apphost/managed/stopper.py b/src/loushang/apphost/managed/stopper.py new file mode 100644 index 000000000..4d55475ca --- /dev/null +++ b/src/loushang/apphost/managed/stopper.py @@ -0,0 +1,180 @@ +"""One graceful stop over an exact recorded service; no kill or restart policy.""" + +from __future__ import annotations + +import asyncio +import os +from collections.abc import Callable +from functools import partial +from typing import TypeVar + +from loushang.hosting.service import LinuxServiceObserverV1 +from loushang.hosting.service_group import LinuxServiceGroupObservationV1 + +from ._files import ManagedStorageError, _check_deadline +from .child import _spawn +from .connection import _settled_native +from .contracts import ( + ManagedContractError, + ManagedInstanceRefV1, + ManagedNamespaceV1, + ManagedServiceKeyV1, +) +from .lifecycle import ManagedServiceJournalV1, ManagedServiceStateV1 +from .paths import resolve_managed_service_paths + +_T = TypeVar("_T") + + +class ManagedServiceStopOperationV1: + """Own observations, borrow the journal; closing never revokes accepted stop. + + Retain before run, and keep the original event loop/journal through close. + Already-dead leaders without a prior group admission remain unavailable; + numeric PID disappearance is not clean-stop recovery evidence. + """ + + def __init__(self, journal: ManagedServiceJournalV1, namespace: ManagedNamespaceV1, + service: ManagedServiceKeyV1, instance: ManagedInstanceRefV1, *, runtime_root: str) -> None: + paths = resolve_managed_service_paths(namespace, service, runtime_root=runtime_root) + if (type(journal) is not ManagedServiceJournalV1 or type(instance) is not ManagedInstanceRefV1 + or namespace.user_id != os.geteuid() or instance.namespace_key != namespace.namespace_key + or instance.service_id != service.service_id or journal._namespace != namespace + or journal._service != service or str(journal._fence._root) != str(paths.lifecycle) + or str(journal._database._directory._root) != str(paths.registry)): + raise ManagedContractError() + self._journal, self._instance = journal, instance + self._observer: LinuxServiceObserverV1 | None = None + self._group: LinuxServiceGroupObservationV1 | None = None + self._native_uncertain = self._close_unknown = False + self._state: ManagedServiceStateV1 | None = None + self._task: asyncio.Task[ManagedServiceStateV1] | None = None + self._close_task: asyncio.Task[None] | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._deadline: float | None = None + self._closing = self._settled = False + + @property + def state(self) -> ManagedServiceStateV1 | None: + return self._state + + @property + def cleanup_pending(self) -> bool: + return not self._settled + + def _bind_loop(self) -> None: + loop = asyncio.get_running_loop() + if self._loop is None: + self._loop = loop + elif self._loop is not loop: + raise ManagedStorageError("conflict") + + async def run(self, *, deadline: float) -> ManagedServiceStateV1: + self._bind_loop() + if type(deadline) not in (int, float) or not 0 < deadline <= 1e12: + raise ManagedContractError() + _check_deadline(deadline) + if self._closing: + raise ManagedStorageError("closed") + if self._task is None: + self._deadline = deadline + self._task = _spawn(self._run_once(deadline)) + elif self._deadline != deadline: + raise ManagedStorageError("conflict") + result = await asyncio.shield(self._task) + _check_deadline(deadline) + return result + + async def _control(self, operation: Callable[[], _T], deadline: float) -> _T: + def enter() -> _T: + # Executor admission can lag behind submission. Once entered, keep + # the original receipt even if it arrives after the deadline. + _check_deadline(deadline) + return operation() + + while True: + _check_deadline(deadline) + try: + return await _settled_native(enter) + except ManagedStorageError as error: + if error.code != "busy": + raise + await asyncio.sleep(0.01) + + def _read(self, deadline: float) -> ManagedServiceStateV1: + state = self._journal.read(deadline=deadline) + if state is None or state.handoff.instance != self._instance: + raise ManagedStorageError("conflict") + self._state = state + return state + + def _admit(self, state: ManagedServiceStateV1) -> None: + assert state.native_identity is not None + self._native_uncertain = True + self._observer = LinuxServiceObserverV1.reopen(state.native_identity) + self._native_uncertain = False + self._group = LinuxServiceGroupObservationV1(self._observer) + self._group.admit() + + def _observe(self) -> tuple[bool, bool]: + assert self._observer is not None and self._group is not None + if not self._observer.exited(): + return False, False + return True, self._group.exited() + + async def _run_once(self, deadline: float) -> ManagedServiceStateV1: + if self._closing: + raise ManagedStorageError("closed") + state = await self._control(lambda: self._read(deadline), deadline) + if state.cleanly_stopped: + return state + if state.native_identity is not None: + await self._control(lambda: self._admit(state), deadline) + self._state = await self._control(lambda: self._journal.request_stop(self._instance, deadline=deadline), deadline) + while True: + state = await self._control(lambda: self._read(deadline), deadline) + if state.cleanly_stopped: + return state + if state.native_identity is not None: + if self._observer is None: + await self._control(partial(self._admit, state), deadline) + assert self._observer is not None + if self._observer.identity != state.native_identity: + raise ManagedStorageError("conflict") + leader, group = await self._control(self._observe, deadline) + if leader: + native = self._observer.identity + self._state = await self._control(partial(self._journal.record_native_stop, + self._instance, native, process_exited=leader, + process_scope_settled=group, deadline=deadline, + ), deadline) + if self._state.cleanly_stopped: + return self._state + await asyncio.sleep(0.01) + + async def close(self) -> None: + self._bind_loop() + self._closing = True + if self._close_task is None or (self._close_task.done() and ( + self._close_task.cancelled() or self._close_task.exception() is not None + )): + self._close_task = _spawn(self._close_once()) + await asyncio.shield(self._close_task) + + async def _close_once(self) -> None: + if self._task is not None: + await asyncio.gather(self._task, return_exceptions=True) + if self._observer is not None: + if self._close_unknown: + raise ManagedStorageError("unavailable") + self._close_unknown = True + await _settled_native(self._observer.close) + self._close_unknown = False + self._observer = None + self._group = None + if self._native_uncertain: + raise ManagedStorageError("unavailable") + self._settled = True + + +__all__ = ["ManagedServiceStopOperationV1"] diff --git a/src/loushang/appserver/_local_peer.py b/src/loushang/appserver/_local_peer.py index 8bf493054..8aaf2a298 100644 --- a/src/loushang/appserver/_local_peer.py +++ b/src/loushang/appserver/_local_peer.py @@ -5,13 +5,15 @@ import asyncio from collections.abc import Awaitable, Callable, Coroutine from contextlib import suppress -from typing import Protocol +from typing import Protocol, cast from .client import AppClientV1, SessionDiscoveryClientV1 from .connection import AppServerConnectionV1 from .execution.client import ExecutionClientV1 from .framing import AppByteTransportV1, AppConnectionClosedError from .local_auth import LocalAuthenticationV1, authenticate_local_server +from .managed_mux import ManagedMuxCreationClientV1 +from .managed_mux_close import ManagedMuxCloseClientV1 from .protocol import AppErrorCodeV1, AppServiceError from .protocol.connection_profile import AppConnectionProfileV1 @@ -38,6 +40,16 @@ class OwnedLocalExecutionScopeV1(OwnedLocalDiscoveryScopeV1, Protocol): def execution_client(self) -> ExecutionClientV1 | None: ... +class OwnedLocalManagedMuxScopeV1(OwnedLocalExecutionScopeV1, Protocol): + @property + def managed_mux_client(self) -> ManagedMuxCreationClientV1 | None: ... + + +class OwnedLocalManagedMuxCloseScopeV1(OwnedLocalManagedMuxScopeV1, Protocol): + @property + def managed_mux_close_client(self) -> ManagedMuxCloseClientV1 | None: ... + + def _observe(task: asyncio.Task[None]) -> None: if not task.cancelled(): task.exception() @@ -66,6 +78,8 @@ def __init__( profile: AppConnectionProfileV1 = AppConnectionProfileV1.LOCAL, discovery_scope_factory: Callable[[], OwnedLocalDiscoveryScopeV1] | None = None, execution_scope_factory: Callable[[], OwnedLocalExecutionScopeV1] | None = None, + managed_mux_scope_factory: Callable[[], OwnedLocalManagedMuxScopeV1] | None = None, + mux_closure: bool = False, ) -> None: self.mode: bytes | None = None self.task: asyncio.Task[None] | None = None @@ -75,6 +89,8 @@ def __init__( self._auth_timeout, self._timeout = auth_timeout, close_timeout self._profile, self._discovery_factory = profile, discovery_scope_factory self._execution_factory = execution_scope_factory + self._managed_mux_factory = managed_mux_scope_factory + self._mux_closure = mux_closure self._published = asyncio.get_running_loop().create_future() self._scope: OwnedLocalClientScopeV1 | None = None self._connection: AppServerConnectionV1 | None = None @@ -128,7 +144,27 @@ async def _serve(self) -> None: if mode == LOCAL_APP_MODE: discovery = None execution = None - if self._execution_factory is not None: + managed_mux = None + managed_mux_close = None + if self._managed_mux_factory is not None: + managed_scope = self._managed_mux_factory() + self._scope = managed_scope + managed_mux = managed_scope.managed_mux_client + if managed_mux is None: + raise AppConnectionClosedError() + if self._mux_closure: + managed_mux_close = cast(OwnedLocalManagedMuxCloseScopeV1, managed_scope).managed_mux_close_client + if managed_mux_close is None: + raise AppConnectionClosedError() + if self._execution_factory is not None: + execution = managed_scope.execution_client + if execution is None: + raise AppConnectionClosedError() + if self._discovery_factory is not None: + discovery = managed_scope.discovery_client + if discovery is None: + raise AppConnectionClosedError() + elif self._execution_factory is not None: execution_scope = self._execution_factory() self._scope = execution_scope execution = execution_scope.execution_client @@ -150,6 +186,8 @@ async def _serve(self) -> None: self._scope, frames, profile=self._profile, phase_timeout=self._timeout, discovery=discovery, execution=execution, + managed_mux=managed_mux, + managed_mux_close=managed_mux_close, ) await self._connection.serve() else: diff --git a/src/loushang/appserver/_local_record_values.py b/src/loushang/appserver/_local_record_values.py index 8c26a56d5..ea7c16bb4 100644 --- a/src/loushang/appserver/_local_record_values.py +++ b/src/loushang/appserver/_local_record_values.py @@ -18,6 +18,15 @@ _DISCOVERY_CAPABILITIES = (*_CAPABILITIES, "session_discovery") _EXECUTION_CAPABILITIES = (*_CAPABILITIES, "session_execution") _COMBINED_CAPABILITIES = (*_DISCOVERY_CAPABILITIES, "session_execution") +_CAPABILITY_VARIANTS = ( + _CAPABILITIES, _DISCOVERY_CAPABILITIES, _EXECUTION_CAPABILITIES, _COMBINED_CAPABILITIES, + (*_CAPABILITIES, "mux_management"), (*_DISCOVERY_CAPABILITIES, "mux_management"), + (*_EXECUTION_CAPABILITIES, "mux_management"), (*_COMBINED_CAPABILITIES, "mux_management"), + (*_CAPABILITIES, "mux_management", "mux_closure"), + (*_DISCOVERY_CAPABILITIES, "mux_management", "mux_closure"), + (*_EXECUTION_CAPABILITIES, "mux_management", "mux_closure"), + (*_COMBINED_CAPABILITIES, "mux_management", "mux_closure"), +) _ENDPOINT = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}\Z") _STABLE_ID = re.compile(r"[a-z0-9][a-z0-9._-]{0,127}\Z") _HEX = re.compile(r"[0-9a-f]+\Z") @@ -71,10 +80,15 @@ class LocalConnectionRecordV1: key: bytes = field(repr=False) session_discovery: bool = False session_execution: bool = False + mux_management: bool = False + mux_closure: bool = False def __post_init__(self) -> None: - if type(self.session_discovery) is not bool or type(self.session_execution) is not bool: + if (type(self.session_discovery) is not bool or type(self.session_execution) is not bool + or type(self.mux_management) is not bool or type(self.mux_closure) is not bool): raise TypeError("invalid discovery activation") + if self.mux_closure and not self.mux_management: + raise ValueError("close requires managed Mux activation") require_endpoint(self.endpoint) if any(type(value) is not str or _STABLE_ID.fullmatch(value) is None for value in (self.application_id, self.product_id)): @@ -92,6 +106,14 @@ def __post_init__(self) -> None: @property def semantic_profile(self) -> AppConnectionProfileV1: + if self.mux_management: + if self.session_execution: + profile = (AppConnectionProfileV1.LOCAL_DISCOVERY_EXECUTION_MANAGED if self.session_discovery + else AppConnectionProfileV1.LOCAL_EXECUTION_MANAGED) + else: + profile = (AppConnectionProfileV1.LOCAL_DISCOVERY_MANAGED if self.session_discovery + else AppConnectionProfileV1.LOCAL_MANAGED) + return AppConnectionProfileV1(profile.value.removesuffix("/v1") + "/v2") if self.mux_closure else profile if self.session_execution: return (AppConnectionProfileV1.LOCAL_DISCOVERY_EXECUTION if self.session_discovery else AppConnectionProfileV1.LOCAL_EXECUTION) @@ -113,11 +135,12 @@ def _public(record: LocalConnectionRecordV1) -> dict[str, object]: "instance": record.instance, "port": record.port, "scopes": [{"scope": item.scope.value, "fingerprint": item.fingerprint} for item in record.scopes], - "capabilities": list( + "capabilities": list(( (_COMBINED_CAPABILITIES if record.session_discovery else _EXECUTION_CAPABILITIES) if record.session_execution else (_DISCOVERY_CAPABILITIES if record.session_discovery else _CAPABILITIES) - ), + ) + (("mux_management",) if record.mux_management else ()) + + (("mux_closure",) if record.mux_closure else ())), } @@ -153,10 +176,7 @@ def decode_connection_record(payload: bytes) -> LocalConnectionRecordV1: if ( raw["schemaVersion"] != _VERSION or raw["profile"] != LOCAL_PROFILE_V1 or raw["protocolVersion"] != APP_PROTOCOL_VERSION - or raw["capabilities"] not in ( - list(_CAPABILITIES), list(_DISCOVERY_CAPABILITIES), - list(_EXECUTION_CAPABILITIES), list(_COMBINED_CAPABILITIES), - ) + or raw["capabilities"] not in tuple(list(item) for item in _CAPABILITY_VARIANTS) or not _hex(raw["key"], 64) ): raise ValueError @@ -170,8 +190,10 @@ def decode_connection_record(payload: bytes) -> LocalConnectionRecordV1: endpoint=raw["endpoint"], application_id=raw["applicationId"], product_id=raw["productId"], instance=raw["instance"], port=raw["port"], key=bytes.fromhex(raw["key"]), - session_discovery=raw["capabilities"] in (list(_DISCOVERY_CAPABILITIES), list(_COMBINED_CAPABILITIES)), - session_execution=raw["capabilities"] in (list(_EXECUTION_CAPABILITIES), list(_COMBINED_CAPABILITIES)), + session_discovery="session_discovery" in raw["capabilities"], + session_execution="session_execution" in raw["capabilities"], + mux_management="mux_management" in raw["capabilities"], + mux_closure="mux_closure" in raw["capabilities"], scopes=tuple(LocalRecordScopeV1(SessionScopeV1(item["scope"]), item["fingerprint"]) for item in scopes), ) diff --git a/src/loushang/appserver/connection.py b/src/loushang/appserver/connection.py index 21a71a63f..41583dc97 100644 --- a/src/loushang/appserver/connection.py +++ b/src/loushang/appserver/connection.py @@ -7,7 +7,7 @@ from .client import AppClientV1, SessionDiscoveryClientV1 from .dispatch import dispatch_request from .execution.client import ExecutionClientV1 -from .execution.codec import decode_call, is_execution_frame +from .execution.codec import EXECUTION_PROTOCOL_VERSION, decode_call from .execution.codec import encode_response as encode_execution_response from .execution.dispatch import dispatch_execution from .execution.model import ( @@ -24,6 +24,20 @@ AppMessageStreamV1, require_timeout, ) +from .managed_mux import ManagedMuxCreationClientV1 +from .managed_mux_close import ManagedMuxCloseClientV1 +from .managed_mux_wire import ( + MANAGED_MUX_CLOSE_PROTOCOL_VERSION, + MANAGED_MUX_PROTOCOL_VERSION, + ManagedMuxCallV1, + ManagedMuxCloseCallV1, + ManagedMuxCloseResponseV1, + ManagedMuxResponseV1, + decode_close_call, + encode_close_response, +) +from .managed_mux_wire import decode_call as decode_managed_call +from .managed_mux_wire import encode_response as encode_managed_response from .protocol import ( AppErrorCodeV1, AppFailureV1, @@ -35,11 +49,14 @@ decode_request, encode_response, ) +from .protocol.codec import _loads from .protocol.connection_profile import ( AppConnectionProfileV1, connection_hello, require_profile_operation, supports_execution, + supports_managed_mux, + supports_managed_mux_close, ) from .protocol.stdio_profile import ( CONTROL_OPERATIONS, @@ -61,6 +78,8 @@ def __init__( profile: AppConnectionProfileV1 = AppConnectionProfileV1.STDIO, discovery: SessionDiscoveryClientV1 | None = None, execution: ExecutionClientV1 | None = None, + managed_mux: ManagedMuxCreationClientV1 | None = None, + managed_mux_close: ManagedMuxCloseClientV1 | None = None, ) -> None: require_timeout(phase_timeout) if supports_execution(profile): @@ -72,6 +91,12 @@ def __init__( else: self._hello = connection_hello(profile) self._execution = execution + if supports_managed_mux(profile) != (managed_mux is not None): + raise ValueError("managed profile requires its explicit capability") + self._managed_mux = managed_mux + if supports_managed_mux_close(profile) != (managed_mux_close is not None): + raise ValueError("close profile requires its explicit capability") + self._managed_mux_close = managed_mux_close self._profile = profile self._discovery = discovery self._client = client @@ -114,9 +139,17 @@ async def _receive_requests(self) -> None: payload = await self._stream.receive() except AppConnectionEOFError: return + version = _loads(payload).get("protocolVersion") if ( + self._execution is not None or self._managed_mux is not None + ) else None request = ( - decode_call(payload) - if self._execution is not None and is_execution_frame(payload) + decode_close_call(payload) + if self._managed_mux_close is not None and version == MANAGED_MUX_CLOSE_PROTOCOL_VERSION + else + decode_managed_call(payload) + if self._managed_mux is not None and version == MANAGED_MUX_PROTOCOL_VERSION + else decode_call(payload) + if self._execution is not None and version == EXECUTION_PROTOCOL_VERSION else decode_request(payload) ) number = connection_request_number(request.request_id) @@ -125,11 +158,16 @@ async def _receive_requests(self) -> None: self._last_id = number control = request.operation in CONTROL_OPERATIONS or request.operation in { ExecutionOperationV1.INTERRUPT, ExecutionOperationV1.GET, ExecutionOperationV1.FIND, - } + } or isinstance(request, ManagedMuxCloseCallV1) and request.read_result maximum = MAX_CONTROL_REQUESTS if control else MAX_ORDINARY_REQUESTS if self._counts[control] >= maximum: await self._stream.send( - encode_execution_response(ExecutionResponseV1( + encode_close_response(ManagedMuxCloseResponseV1( + request.request_id, AppFailureV1(AppErrorCodeV1.OPERATION_UNAVAILABLE) + )) if isinstance(request, ManagedMuxCloseCallV1) else + encode_managed_response(ManagedMuxResponseV1( + request.request_id, AppFailureV1(AppErrorCodeV1.OPERATION_UNAVAILABLE) + )) if isinstance(request, ManagedMuxCallV1) else encode_execution_response(ExecutionResponseV1( request.request_id, AppFailureV1(AppErrorCodeV1.OPERATION_UNAVAILABLE) )) if isinstance(request, ExecutionCallV1) else encode_response( AppResponseV1( @@ -144,8 +182,14 @@ async def _receive_requests(self) -> None: self._tasks.add(task) task.add_done_callback(self._tasks.discard) - async def _execute(self, request: AppRequestV1 | ExecutionCallV1, *, control: bool) -> None: + async def _execute(self, request: AppRequestV1 | ExecutionCallV1 | ManagedMuxCallV1 | ManagedMuxCloseCallV1, *, control: bool) -> None: try: + if isinstance(request, ManagedMuxCloseCallV1): + await self._execute_managed_close(request) + return + if isinstance(request, ManagedMuxCallV1): + await self._execute_managed(request) + return if isinstance(request, ExecutionCallV1): await self._execute_execution(request) return @@ -169,6 +213,31 @@ async def _execute(self, request: AppRequestV1 | ExecutionCallV1, *, control: bo finally: self._counts[control] -= 1 + async def _execute_managed_close(self, request: ManagedMuxCloseCallV1) -> None: + assert self._managed_mux_close is not None + try: + result = (await self._managed_mux_close.read_managed_mux_close(request.request) if request.read_result + else await self._managed_mux_close.close_managed_mux(request.request)) + if result is None and not request.read_result: + raise AppServiceError(AppErrorCodeV1.OPERATION_UNAVAILABLE) + response = ManagedMuxCloseResponseV1(request.request_id, result) + except AppServiceError as error: + response = ManagedMuxCloseResponseV1(request.request_id, AppFailureV1(error.code)) + except Exception: + response = ManagedMuxCloseResponseV1(request.request_id, AppFailureV1(AppErrorCodeV1.OPERATION_UNAVAILABLE)) + await self._stream.send(encode_close_response(response)) + + async def _execute_managed(self, request: ManagedMuxCallV1) -> None: + assert self._managed_mux is not None + try: + result = await self._managed_mux.create_managed_mux(request.request) + response = ManagedMuxResponseV1(request.request_id, result) + except AppServiceError as error: + response = ManagedMuxResponseV1(request.request_id, AppFailureV1(error.code)) + except Exception: + response = ManagedMuxResponseV1(request.request_id, AppFailureV1(AppErrorCodeV1.OPERATION_UNAVAILABLE)) + await self._stream.send(encode_managed_response(response)) + async def _execute_execution(self, request: ExecutionCallV1) -> None: assert self._execution is not None result: ExecutionResultV1 diff --git a/src/loushang/appserver/local.py b/src/loushang/appserver/local.py index d2ad2bf75..85208758c 100644 --- a/src/loushang/appserver/local.py +++ b/src/loushang/appserver/local.py @@ -21,11 +21,13 @@ OwnedLocalClientScopeV1, OwnedLocalDiscoveryScopeV1, OwnedLocalExecutionScopeV1, + OwnedLocalManagedMuxScopeV1, _failed, _LocalPeer, _observe, _spawn, ) +from ._local_record_values import _hex from .client import AppClientV1, SessionDiscoveryClientV1 from .execution.client import ExecutionClientV1 from .framing import AppConnectionClosedError, AsyncioStreamTransportV1, require_timeout @@ -36,6 +38,8 @@ LocalEndpointReservationV1, LocalRecordScopeV1, ) +from .managed_mux import ManagedMuxCreationClientV1 +from .managed_mux_close import ManagedMuxCloseClientV1 from .protocol import AppErrorCodeV1, AppServiceError from .remote_client import RemoteAppClientV1 @@ -86,6 +90,9 @@ def __init__( auth_timeout: float = 5.0, close_timeout: float = 10.0, discovery_scope_factory: Callable[[], OwnedLocalDiscoveryScopeV1] | None = None, execution_scope_factory: Callable[[], OwnedLocalExecutionScopeV1] | None = None, + managed_mux_scope_factory: Callable[[], OwnedLocalManagedMuxScopeV1] | None = None, + mux_closure: bool = False, + instance: str | None = None, ) -> None: require_timeout(auth_timeout) require_timeout(close_timeout) @@ -95,15 +102,22 @@ def __init__( raise TypeError("invalid discovery scope factory") if execution_scope_factory is not None and not callable(execution_scope_factory): raise TypeError("invalid execution scope factory") + if managed_mux_scope_factory is not None and not callable(managed_mux_scope_factory): + raise TypeError("invalid managed Mux scope factory") + if type(mux_closure) is not bool or mux_closure and managed_mux_scope_factory is None: + raise ValueError("close requires an explicit managed scope factory") + self._mux_closure = mux_closure # Validate all record facts before obtaining a lock or opening IO. LocalConnectionRecordV1(endpoint=endpoint, application_id=application_id, product_id=product_id, scopes=scopes, port=1, - instance="0" * 32, key=bytes(32)) + instance="0" * 32 if instance is None else instance, key=bytes(32)) + self._instance = instance self._directory, self._endpoint = directory, endpoint self._application_id, self._product_id, self._scopes = application_id, product_id, scopes self._scope_factory, self._request_stop = scope_factory, request_stop self._discovery_factory = discovery_scope_factory self._execution_factory = execution_scope_factory + self._managed_mux_factory = managed_mux_scope_factory self._auth_timeout, self._timeout = auth_timeout, close_timeout self._reservation: LocalEndpointReservationV1 | None = None self._record: LocalConnectionRecordV1 | None = None @@ -231,6 +245,9 @@ async def _activate_once(self) -> None: port=port, scopes=self._scopes, session_discovery=self._discovery_factory is not None, session_execution=self._execution_factory is not None, + mux_management=self._managed_mux_factory is not None, + mux_closure=self._mux_closure, + instance=self._instance, ) self._remaining_startup() self._ready = True @@ -252,6 +269,8 @@ def _accepted(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) profile=self._record.semantic_profile, discovery_scope_factory=self._discovery_factory, execution_scope_factory=self._execution_factory, + managed_mux_scope_factory=self._managed_mux_factory, + mux_closure=self._mux_closure, ) except Exception: writer.transport.abort() @@ -328,6 +347,7 @@ def __init__( self, directory: LocalConnectionDirectoryV1, endpoint: str, *, mode: LocalConnectionModeV1 = LocalConnectionModeV1.APP, timeout: float = 10.0, expected_product_id: str | None = None, + expected_instance: str | None = None, ) -> None: require_timeout(timeout) if timeout > 30 or type(mode) is not LocalConnectionModeV1: @@ -336,14 +356,18 @@ def __init__( type(expected_product_id) is not str or not 1 <= len(expected_product_id) <= 128 ): raise ValueError("invalid expected local Product") + if expected_instance is not None and not _hex(expected_instance, 32): + raise ValueError("invalid expected local instance") self._directory, self._endpoint = directory, endpoint self._expected_product_id = expected_product_id + self._expected_instance = expected_instance self._mode, self._timeout = mode, timeout self._socket: socket.socket | None = None self._writer: asyncio.StreamWriter | None = None self._transport: _LocalTransport | None = None self._client: RemoteAppClientV1 | None = None self._scopes: tuple[LocalRecordScopeV1, ...] = () + self._authenticated_application_id: str | None = None self._start_task: asyncio.Task[None] | None = None self._close_task: asyncio.Task[None] | None = None self._closed = False @@ -368,6 +392,25 @@ def execution_client(self) -> ExecutionClientV1 | None: return None return self._client.execution_client + @property + def managed_mux_client(self) -> ManagedMuxCreationClientV1 | None: + if not self._ready or self._closed or self._client is None: + return None + return self._client.managed_mux_client + + @property + def managed_mux_close_client(self) -> ManagedMuxCloseClientV1 | None: + if not self._ready or self._closed or self._client is None: + return None + return self._client.managed_mux_close_client + + @property + def application_id(self) -> str: + """Identity from this exact authenticated record, never a fresh lookup.""" + if not self._ready or self._closed or self._authenticated_application_id is None: + raise AppConnectionClosedError() + return self._authenticated_application_id + @property def stop_requested(self) -> bool: return self._stop_requested @@ -396,6 +439,8 @@ async def _start_once(self) -> None: record = self._directory.read(self._endpoint) if self._expected_product_id is not None and record.product_id != self._expected_product_id: raise AppServiceError(AppErrorCodeV1.OPERATION_UNAVAILABLE) + if self._expected_instance is not None and record.instance != self._expected_instance: + raise AppServiceError(AppErrorCodeV1.OPERATION_UNAVAILABLE) async with asyncio.timeout(self._timeout): self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._socket.set_inheritable(False) @@ -417,6 +462,7 @@ async def _start_once(self) -> None: ) await self._client.start() self._scopes = record.scopes + self._authenticated_application_id = record.application_id else: if await frames.receive() != LOCAL_STOP_ACK: raise AppConnectionClosedError() diff --git a/src/loushang/appserver/local_record.py b/src/loushang/appserver/local_record.py index 4ddbc2c4b..2d4c88ede 100644 --- a/src/loushang/appserver/local_record.py +++ b/src/loushang/appserver/local_record.py @@ -122,14 +122,19 @@ def publish( scopes: tuple[LocalRecordScopeV1, ...], session_discovery: bool = False, session_execution: bool = False, + mux_management: bool = False, + mux_closure: bool = False, + instance: str | None = None, ) -> LocalConnectionRecordV1: if self._closed or self._directory._closed: raise LocalRecordError(LocalRecordErrorCodeV1.CLOSED) record = LocalConnectionRecordV1( endpoint=self._endpoint, application_id=application_id, product_id=product_id, - instance=self._instance, port=port, scopes=scopes, key=self._key, + instance=self._instance if instance is None else instance, port=port, scopes=scopes, key=self._key, session_discovery=session_discovery, session_execution=session_execution, + mux_management=mux_management, + mux_closure=mux_closure, ) if self._published or (self._record is not None and self._record != record): raise LocalRecordError(LocalRecordErrorCodeV1.CONFLICT) diff --git a/src/loushang/appserver/managed_mux_wire.py b/src/loushang/appserver/managed_mux_wire.py new file mode 100644 index 000000000..35146b603 --- /dev/null +++ b/src/loushang/appserver/managed_mux_wire.py @@ -0,0 +1,221 @@ +"""Closed optional management wire; transports authority but never issues it.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .managed_mux import ManagedMuxCreatedV1, ManagedMuxCreateV1 +from .managed_mux_close import ( + ManagedMuxClosePhaseV1, + ManagedMuxCloseStateV1, + ManagedMuxCloseV1, +) +from .protocol import ( + AppErrorCodeV1, + AppFailureV1, + AppOperationV1, + InvalidAppMessageError, +) +from .protocol.codec import _dumps, _loads, _object, _string +from .protocol.stdio_profile import connection_request_number + +MANAGED_MUX_PROTOCOL_VERSION = "loushang.managed-mux/v1" +MANAGED_MUX_CLOSE_PROTOCOL_VERSION = "loushang.managed-mux-close/v1" +MAX_MANAGED_MUX_FRAME = 4096 + + +@dataclass(frozen=True, slots=True) +class ManagedMuxCallV1: + request_id: str + request: ManagedMuxCreateV1 + + def __post_init__(self) -> None: + connection_request_number(self.request_id) + if type(self.request) is not ManagedMuxCreateV1: + raise InvalidAppMessageError() + + @property + def operation(self) -> AppOperationV1: + return AppOperationV1.MUX_CREATE + + +@dataclass(frozen=True, slots=True) +class ManagedMuxResponseV1: + request_id: str + result: ManagedMuxCreatedV1 | AppFailureV1 + + def __post_init__(self) -> None: + connection_request_number(self.request_id) + if type(self.result) not in (ManagedMuxCreatedV1, AppFailureV1): + raise InvalidAppMessageError() + + +def _frame(payload: bytes, fields: set[str], version: str = MANAGED_MUX_PROTOCOL_VERSION) -> dict[str, object]: + if type(payload) is not bytes or not 1 <= len(payload) <= MAX_MANAGED_MUX_FRAME: + raise InvalidAppMessageError() + value = _object(_loads(payload), fields | {"protocolVersion", "requestId"}) + if value["protocolVersion"] != version: + raise InvalidAppMessageError() + connection_request_number(_string(value["requestId"])) + return value + + +def encode_call(call: ManagedMuxCallV1) -> bytes: + if type(call) is not ManagedMuxCallV1: + raise InvalidAppMessageError() + request = call.request + return _dumps({ + "protocolVersion": MANAGED_MUX_PROTOCOL_VERSION, "requestId": call.request_id, + "operation": "mux/create", "request": { + "serviceId": request.service_id, "instanceId": request.instance_id, + "operationId": request.operation_id, "name": request.name, "authority": request.authority, + }, + }) + + +def decode_call(payload: bytes) -> ManagedMuxCallV1: + try: + value = _frame(payload, {"operation", "request"}) + if value["operation"] != "mux/create": + raise InvalidAppMessageError() + request = _object(value["request"], {"serviceId", "instanceId", "operationId", "name", "authority"}) + return ManagedMuxCallV1(_string(value["requestId"]), ManagedMuxCreateV1( + _string(request["serviceId"]), _string(request["instanceId"]), + _string(request["operationId"]), _string(request["name"]), _string(request["authority"]), + )) + except (ValueError, TypeError): + raise InvalidAppMessageError() from None + + +def encode_response(response: ManagedMuxResponseV1) -> bytes: + if type(response) is not ManagedMuxResponseV1: + raise InvalidAppMessageError() + result = response.result + body: dict[str, object] + if type(result) is AppFailureV1: + body = {"kind": "failure", "code": result.code.value} + elif type(result) is ManagedMuxCreatedV1: + body = {"kind": "created", "operationId": result.operation_id, + "instanceId": result.instance_id, "name": result.name, "muxSpaceId": result.mux_space_id} + else: + raise InvalidAppMessageError() + return _dumps({"protocolVersion": MANAGED_MUX_PROTOCOL_VERSION, + "requestId": response.request_id, "result": body}) + + +def decode_response(payload: bytes) -> ManagedMuxResponseV1: + try: + value = _frame(payload, {"result"}) + result = value["result"] + if type(result) is not dict: + raise InvalidAppMessageError() + decoded: ManagedMuxCreatedV1 | AppFailureV1 + if result.get("kind") == "failure": + fields = _object(result, {"kind", "code"}) + decoded = AppFailureV1(AppErrorCodeV1(_string(fields["code"]))) + else: + fields = _object(result, {"kind", "operationId", "instanceId", "name", "muxSpaceId"}) + if fields["kind"] != "created": + raise InvalidAppMessageError() + decoded = ManagedMuxCreatedV1(_string(fields["operationId"]), _string(fields["instanceId"]), + _string(fields["name"]), _string(fields["muxSpaceId"])) + return ManagedMuxResponseV1(_string(value["requestId"]), decoded) + except (ValueError, TypeError): + raise InvalidAppMessageError() from None + + +@dataclass(frozen=True, slots=True) +class ManagedMuxCloseCallV1: + request_id: str + request: ManagedMuxCloseV1 + read_result: bool = False + + def __post_init__(self) -> None: + connection_request_number(self.request_id) + if type(self.request) is not ManagedMuxCloseV1 or type(self.read_result) is not bool: + raise InvalidAppMessageError() + + @property + def operation(self) -> AppOperationV1: + return AppOperationV1.MUX_READ if self.read_result else AppOperationV1.MUX_CLOSE + + +@dataclass(frozen=True, slots=True) +class ManagedMuxCloseResponseV1: + request_id: str + result: ManagedMuxCloseStateV1 | AppFailureV1 | None + + def __post_init__(self) -> None: + connection_request_number(self.request_id) + if self.result is not None and type(self.result) not in (ManagedMuxCloseStateV1, AppFailureV1): + raise InvalidAppMessageError() + + +def encode_close_call(call: ManagedMuxCloseCallV1) -> bytes: + if type(call) is not ManagedMuxCloseCallV1: + raise InvalidAppMessageError() + request = call.request + return _dumps({ + "protocolVersion": MANAGED_MUX_CLOSE_PROTOCOL_VERSION, "requestId": call.request_id, + "operation": "mux/close/read" if call.read_result else "mux/close", "request": { + "serviceId": request.service_id, "instanceId": request.instance_id, + "operationId": request.operation_id, "creationOperationId": request.creation_operation_id, + "name": request.name, "muxSpaceId": request.mux_space_id, "authority": request.authority, + }, + }) + + +def decode_close_call(payload: bytes) -> ManagedMuxCloseCallV1: + try: + value = _frame(payload, {"operation", "request"}, MANAGED_MUX_CLOSE_PROTOCOL_VERSION) + if value["operation"] not in ("mux/close", "mux/close/read"): + raise InvalidAppMessageError() + request = _object(value["request"], { + "serviceId", "instanceId", "operationId", "creationOperationId", "name", "muxSpaceId", "authority", + }) + return ManagedMuxCloseCallV1(_string(value["requestId"]), ManagedMuxCloseV1( + _string(request["serviceId"]), _string(request["instanceId"]), _string(request["operationId"]), + _string(request["creationOperationId"]), _string(request["name"]), + _string(request["muxSpaceId"]), _string(request["authority"]), + ), read_result=value["operation"] == "mux/close/read") + except (ValueError, TypeError): + raise InvalidAppMessageError() from None + + +def encode_close_response(response: ManagedMuxCloseResponseV1) -> bytes: + if type(response) is not ManagedMuxCloseResponseV1: + raise InvalidAppMessageError() + result = response.result + body: dict[str, object] | None = None + if type(result) is AppFailureV1: + body = {"kind": "failure", "code": result.code.value} + elif type(result) is ManagedMuxCloseStateV1: + body = {"kind": "close_state", "operationId": result.operation_id, "instanceId": result.instance_id, + "creationOperationId": result.creation_operation_id, "name": result.name, + "muxSpaceId": result.mux_space_id, "phase": result.phase.value} + return _dumps({"protocolVersion": MANAGED_MUX_CLOSE_PROTOCOL_VERSION, + "requestId": response.request_id, "result": body}) + + +def decode_close_response(payload: bytes) -> ManagedMuxCloseResponseV1: + try: + value = _frame(payload, {"result"}, MANAGED_MUX_CLOSE_PROTOCOL_VERSION) + result = value["result"] + decoded: ManagedMuxCloseStateV1 | AppFailureV1 | None = None + if result is not None: + if type(result) is not dict: + raise InvalidAppMessageError() + if result.get("kind") == "failure": + fields = _object(result, {"kind", "code"}) + decoded = AppFailureV1(AppErrorCodeV1(_string(fields["code"]))) + else: + fields = _object(result, {"kind", "operationId", "instanceId", "creationOperationId", + "name", "muxSpaceId", "phase"}) + if fields["kind"] != "close_state": + raise InvalidAppMessageError() + decoded = ManagedMuxCloseStateV1(_string(fields["operationId"]), _string(fields["instanceId"]), + _string(fields["creationOperationId"]), _string(fields["name"]), _string(fields["muxSpaceId"]), + ManagedMuxClosePhaseV1(_string(fields["phase"]))) + return ManagedMuxCloseResponseV1(_string(value["requestId"]), decoded) + except (ValueError, TypeError): + raise InvalidAppMessageError() from None diff --git a/src/loushang/appserver/protocol/connection_profile.py b/src/loushang/appserver/protocol/connection_profile.py index c5c3bdd93..e69526ad4 100644 --- a/src/loushang/appserver/protocol/connection_profile.py +++ b/src/loushang/appserver/protocol/connection_profile.py @@ -19,6 +19,14 @@ class AppConnectionProfileV1(str, Enum): LOCAL_DISCOVERY = "local-detachable-discovery/v1" LOCAL_EXECUTION = "local-detachable-execution/v1" LOCAL_DISCOVERY_EXECUTION = "local-detachable-discovery-execution/v1" + LOCAL_MANAGED = "local-detachable-managed/v1" + LOCAL_DISCOVERY_MANAGED = "local-detachable-discovery-managed/v1" + LOCAL_EXECUTION_MANAGED = "local-detachable-execution-managed/v1" + LOCAL_DISCOVERY_EXECUTION_MANAGED = "local-detachable-discovery-execution-managed/v1" + LOCAL_MANAGED_CLOSE = "local-detachable-managed/v2" + LOCAL_DISCOVERY_MANAGED_CLOSE = "local-detachable-discovery-managed/v2" + LOCAL_EXECUTION_MANAGED_CLOSE = "local-detachable-execution-managed/v2" + LOCAL_DISCOVERY_EXECUTION_MANAGED_CLOSE = "local-detachable-discovery-execution-managed/v2" _HELLOS = { @@ -30,6 +38,18 @@ class AppConnectionProfileV1(str, Enum): AppConnectionProfileV1.LOCAL_DISCOVERY: ( b'{"profile":"local-detachable-discovery/v1","protocolVersion":"loushang.app/v1"}' ), + AppConnectionProfileV1.LOCAL_MANAGED: ( + b'{"profile":"local-detachable-managed/v1","protocolVersion":"loushang.app/v1"}' + ), + AppConnectionProfileV1.LOCAL_DISCOVERY_MANAGED: ( + b'{"profile":"local-detachable-discovery-managed/v1","protocolVersion":"loushang.app/v1"}' + ), + AppConnectionProfileV1.LOCAL_MANAGED_CLOSE: ( + b'{"profile":"local-detachable-managed/v2","protocolVersion":"loushang.app/v1"}' + ), + AppConnectionProfileV1.LOCAL_DISCOVERY_MANAGED_CLOSE: ( + b'{"profile":"local-detachable-discovery-managed/v2","protocolVersion":"loushang.app/v1"}' + ), } @@ -55,6 +75,10 @@ def supports_execution(profile: AppConnectionProfileV1) -> bool: return profile in { AppConnectionProfileV1.LOCAL_EXECUTION, AppConnectionProfileV1.LOCAL_DISCOVERY_EXECUTION, + AppConnectionProfileV1.LOCAL_EXECUTION_MANAGED, + AppConnectionProfileV1.LOCAL_DISCOVERY_EXECUTION_MANAGED, + AppConnectionProfileV1.LOCAL_EXECUTION_MANAGED_CLOSE, + AppConnectionProfileV1.LOCAL_DISCOVERY_EXECUTION_MANAGED_CLOSE, } @@ -65,6 +89,32 @@ def supports_session_discovery(profile: AppConnectionProfileV1) -> bool: AppConnectionProfileV1.STDIO_DISCOVERY, AppConnectionProfileV1.LOCAL_DISCOVERY, AppConnectionProfileV1.LOCAL_DISCOVERY_EXECUTION, + AppConnectionProfileV1.LOCAL_DISCOVERY_MANAGED, + AppConnectionProfileV1.LOCAL_DISCOVERY_EXECUTION_MANAGED, + AppConnectionProfileV1.LOCAL_DISCOVERY_MANAGED_CLOSE, + AppConnectionProfileV1.LOCAL_DISCOVERY_EXECUTION_MANAGED_CLOSE, + } + + +def supports_managed_mux(profile: AppConnectionProfileV1) -> bool: + if type(profile) is not AppConnectionProfileV1: + raise ValueError("invalid application connection profile") + return supports_managed_mux_close(profile) or profile in { + AppConnectionProfileV1.LOCAL_MANAGED, + AppConnectionProfileV1.LOCAL_DISCOVERY_MANAGED, + AppConnectionProfileV1.LOCAL_EXECUTION_MANAGED, + AppConnectionProfileV1.LOCAL_DISCOVERY_EXECUTION_MANAGED, + } + + +def supports_managed_mux_close(profile: AppConnectionProfileV1) -> bool: + if type(profile) is not AppConnectionProfileV1: + raise ValueError("invalid application connection profile") + return profile in { + AppConnectionProfileV1.LOCAL_MANAGED_CLOSE, + AppConnectionProfileV1.LOCAL_DISCOVERY_MANAGED_CLOSE, + AppConnectionProfileV1.LOCAL_EXECUTION_MANAGED_CLOSE, + AppConnectionProfileV1.LOCAL_DISCOVERY_EXECUTION_MANAGED_CLOSE, } @@ -77,3 +127,5 @@ def require_profile_operation( discovery = supports_session_discovery(profile) if operation is AppOperationV1.SESSIONS_LIST and not discovery: raise AppServiceError(AppErrorCodeV1.OPERATION_UNAVAILABLE) + if supports_managed_mux(profile) and operation in {AppOperationV1.MUX_CREATE, AppOperationV1.MUX_CLOSE}: + raise AppServiceError(AppErrorCodeV1.OPERATION_UNAVAILABLE) diff --git a/src/loushang/appserver/remote_client.py b/src/loushang/appserver/remote_client.py index 357784c4f..615776f76 100644 --- a/src/loushang/appserver/remote_client.py +++ b/src/loushang/appserver/remote_client.py @@ -6,13 +6,13 @@ from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass -from typing import TypeVar, cast +from typing import Literal, TypeVar, cast from .client import SessionDiscoveryClientV1 from .execution.codec import ( + EXECUTION_PROTOCOL_VERSION, decode_execution_hello, encode_call, - is_execution_frame, ) from .execution.codec import decode_response as decode_execution_response from .execution.model import ( @@ -27,6 +27,26 @@ AppMessageStreamV1, require_timeout, ) +from .managed_mux import ( + ManagedMuxCreatedV1, + ManagedMuxCreateV1, + ManagedMuxCreationClientV1, +) +from .managed_mux_close import ( + ManagedMuxCloseClientV1, + ManagedMuxCloseStateV1, + ManagedMuxCloseV1, +) +from .managed_mux_wire import ( + MANAGED_MUX_CLOSE_PROTOCOL_VERSION, + MANAGED_MUX_PROTOCOL_VERSION, + ManagedMuxCallV1, + ManagedMuxCloseCallV1, + decode_close_response, + encode_close_call, +) +from .managed_mux_wire import decode_response as decode_managed_response +from .managed_mux_wire import encode_call as encode_managed_call from .protocol import ( AckV1, AppErrorCodeV1, @@ -61,11 +81,14 @@ decode_response, encode_request, ) +from .protocol.codec import _loads from .protocol.connection_profile import ( AppConnectionProfileV1, connection_hello, require_profile_operation, supports_execution, + supports_managed_mux, + supports_managed_mux_close, supports_session_discovery, ) from .protocol.stdio_profile import ( @@ -75,6 +98,7 @@ ) _Result = TypeVar("_Result", bound=AppResultPayloadV1) +_Family = Literal["app", "execution", "managed_mux", "managed_mux_close"] @dataclass(frozen=True, slots=True) @@ -82,8 +106,9 @@ class _Pending: future: asyncio.Future[object] result_type: type[object] control: bool - execution: bool = False + family: _Family = "app" allow_missing: bool = False + managed_intent: tuple[str, ...] | None = None class RemoteAppClientV1: @@ -114,6 +139,46 @@ def __init__( def execution_client(self) -> RemoteExecutionClientV1 | None: return self._execution_client if self._ready and not self._closed else None + @property + def managed_mux_client(self) -> ManagedMuxCreationClientV1 | None: + if self._ready and not self._closed and supports_managed_mux(self._profile): + return self + return None + + async def create_managed_mux(self, request: ManagedMuxCreateV1) -> ManagedMuxCreatedV1: + if self.managed_mux_client is None: + raise AppConnectionClosedError() + if type(request) is not ManagedMuxCreateV1: + raise InvalidAppMessageError() + return cast(ManagedMuxCreatedV1, await self._send_encoded( + lambda request_id: encode_managed_call(ManagedMuxCallV1(request_id, request)), + ManagedMuxCreatedV1, control=False, family="managed_mux", + managed_intent=(request.operation_id, request.name), + )) + + @property + def managed_mux_close_client(self) -> ManagedMuxCloseClientV1 | None: + if self._ready and not self._closed and supports_managed_mux_close(self._profile): + return self + return None + + async def close_managed_mux(self, request: ManagedMuxCloseV1) -> ManagedMuxCloseStateV1: + return cast(ManagedMuxCloseStateV1, await self._managed_close(request, read=False)) + + async def read_managed_mux_close(self, request: ManagedMuxCloseV1) -> ManagedMuxCloseStateV1 | None: + return await self._managed_close(request, read=True) + + async def _managed_close(self, request: ManagedMuxCloseV1, *, read: bool) -> ManagedMuxCloseStateV1 | None: + if self.managed_mux_close_client is None: + raise AppConnectionClosedError() + if type(request) is not ManagedMuxCloseV1: + raise InvalidAppMessageError() + return cast(ManagedMuxCloseStateV1 | None, await self._send_encoded( + lambda request_id: encode_close_call(ManagedMuxCloseCallV1(request_id, request, read_result=read)), + ManagedMuxCloseStateV1, control=read, family="managed_mux_close", allow_missing=read, + managed_intent=(request.operation_id, request.creation_operation_id, request.name, request.mux_space_id), + )) + @property def discovery_client(self) -> SessionDiscoveryClientV1 | None: """Borrow only the explicitly selected, ready discovery capability.""" @@ -169,12 +234,13 @@ async def _send_execution( raise AppConnectionClosedError() return await self._send_encoded( lambda request_id: encode_call(build(request_id)), result_type, - control=control, execution=True, allow_missing=allow_missing, + control=control, family="execution", allow_missing=allow_missing, ) async def _send_encoded( self, encode: Callable[[str], bytes], result_type: type[object], *, - control: bool, execution: bool = False, allow_missing: bool = False, + control: bool, family: _Family = "app", allow_missing: bool = False, + managed_intent: tuple[str, ...] | None = None, ) -> object: if not self._ready or self._closed: raise AppConnectionClosedError() @@ -199,7 +265,7 @@ async def _send_encoded( # A cancelled local caller still owns a bounded remote slot. future.add_done_callback(_observe_future) self._pending[request_id] = _Pending( - future, result_type, control, execution, allow_missing + future, result_type, control, family, allow_missing, managed_intent ) registered = True try: @@ -218,10 +284,24 @@ async def _receive_responses(self) -> None: try: while not self._closed: payload = await self._stream.receive() - execution = self._execution_client is not None and is_execution_frame(payload) - response = decode_execution_response(payload) if execution else decode_response(payload) + version = _loads(payload).get("protocolVersion") if ( + self._execution_client is not None or supports_managed_mux(self._profile) + ) else None + family: _Family = ( + "managed_mux_close" if supports_managed_mux_close(self._profile) and version == MANAGED_MUX_CLOSE_PROTOCOL_VERSION + else + "managed_mux" if supports_managed_mux(self._profile) and version == MANAGED_MUX_PROTOCOL_VERSION + else "execution" if self._execution_client is not None and version == EXECUTION_PROTOCOL_VERSION + else "app" + ) + response = ( + decode_close_response(payload) if family == "managed_mux_close" else + decode_managed_response(payload) if family == "managed_mux" + else decode_execution_response(payload) if family == "execution" + else decode_response(payload) + ) pending = self._pending.get(response.request_id) - if pending is None or pending.execution != execution: + if pending is None or pending.family != family: raise InvalidAppMessageError() result = response.result if ( @@ -230,6 +310,16 @@ async def _receive_responses(self) -> None: and not (pending.allow_missing and result is None) ): raise InvalidAppMessageError() + # A cancelled delivery waiter still owns its pending request. + # Validate here, before releasing its slot or publishing a value. + if type(result) is ManagedMuxCreatedV1 and ( + pending.managed_intent != (result.operation_id, result.name) + ): + raise InvalidAppMessageError() + if type(result) is ManagedMuxCloseStateV1 and pending.managed_intent != ( + result.operation_id, result.creation_operation_id, result.name, result.mux_space_id, + ): + raise InvalidAppMessageError() del self._pending[response.request_id] self._counts[pending.control] -= 1 if type(result) is AppFailureV1: diff --git a/src/loushang/appservice/client_scope.py b/src/loushang/appservice/client_scope.py index 6befc4409..1f527672f 100644 --- a/src/loushang/appservice/client_scope.py +++ b/src/loushang/appservice/client_scope.py @@ -11,6 +11,16 @@ from dataclasses import dataclass, replace from typing import TypeVar +from loushang.appserver.managed_mux import ( + ManagedMuxCreatedV1, + ManagedMuxCreateV1, + ManagedMuxCreationClientV1, +) +from loushang.appserver.managed_mux_close import ( + ManagedMuxCloseClientV1, + ManagedMuxCloseStateV1, + ManagedMuxCloseV1, +) from loushang.appserver.protocol import ( AckV1, AppErrorCodeV1, @@ -214,11 +224,48 @@ def discovery_client(self) -> SessionDiscoveryViewV1 | None: return self._discovery def _require_open(self) -> None: - if self._closed or self._owner._closed or self._service._closed: + if self._closed or self._owner._closed or self._service._closed or self._service._managed_shutdown: raise AppServiceError(AppErrorCodeV1.SERVICE_CLOSED) + @property + def managed_mux_client(self) -> ManagedMuxCreationClientV1 | None: + self._require_open() + return self if self._service._managed_mux is not None else None + + async def create_managed_mux(self, request: ManagedMuxCreateV1) -> ManagedMuxCreatedV1: + self._require_open() + self._service._require_request(request, ManagedMuxCreateV1) + if self._service._managed_mux is None: + raise AppServiceError(AppErrorCodeV1.OPERATION_UNAVAILABLE) + # The existing application operation owner retains accepted creation + # across client EOF. It does not grant deployment admission itself. + return await self._owner._operations.execute( + lambda: self._service.create_managed_mux(request), + ) + + @property + def managed_mux_close_client(self) -> ManagedMuxCloseClientV1 | None: + self._require_open() + binding = self._service._managed_mux + return self if binding is not None and binding.closing is not None else None + + async def close_managed_mux(self, request: ManagedMuxCloseV1) -> ManagedMuxCloseStateV1: + self._require_open() + self._service._require_request(request, ManagedMuxCloseV1) + if self.managed_mux_close_client is None: + raise AppServiceError(AppErrorCodeV1.OPERATION_UNAVAILABLE) + return await self._owner._operations.execute(lambda: self._service.close_managed_mux(request)) + + async def read_managed_mux_close(self, request: ManagedMuxCloseV1) -> ManagedMuxCloseStateV1 | None: + self._require_open() + self._service._require_request(request, ManagedMuxCloseV1) + if self.managed_mux_close_client is None: + raise AppServiceError(AppErrorCodeV1.OPERATION_UNAVAILABLE) + return await self._owner._operations.execute(lambda: self._service.read_managed_mux_close(request), control=True) + async def create_mux(self, request: MuxCreateV1) -> MuxSpaceV1: self._require_open() + self._service._require_legacy_mux_mutation() self._service._require_request(request, MuxCreateV1) owner = self._owner if len(self._service._mux_by_id) + owner._creating >= 32: @@ -393,6 +440,7 @@ async def close_member(self, request: MuxMemberCloseV1) -> MuxSpaceV1: ) async def close_mux(self, request: MuxCloseV1) -> AckV1: + self._service._require_legacy_mux_mutation() self._service._require_request(request, MuxCloseV1) return await self._change( request.selector, lambda selector: self._service.close_mux(MuxCloseV1(selector)), diff --git a/src/loushang/appservice/continuity.py b/src/loushang/appservice/continuity.py index 8e00492e6..53d8ac9dd 100644 --- a/src/loushang/appservice/continuity.py +++ b/src/loushang/appservice/continuity.py @@ -4,11 +4,16 @@ import json import re -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum from pathlib import Path from typing import Protocol, cast +from loushang.appserver.managed_mux import ManagedMuxCreatedV1 +from loushang.appserver.managed_mux_close import ( + ManagedMuxClosePhaseV1, + ManagedMuxCloseStateV1, +) from loushang.appserver.protocol import ( MAX_MEMBERS, MAX_MUX_SPACES, @@ -17,6 +22,10 @@ ) APPLICATION_CONTINUITY_VERSION = "loushang.appservice.continuity/v1" +MANAGED_CONTINUITY_VERSION = "loushang.appservice.continuity/v2" +MANAGED_CLOSE_CONTINUITY_VERSION = "loushang.appservice.continuity/v3" +MAX_MANAGED_CREATIONS = 4096 +MAX_MANAGED_CLOSURES = 4096 MAX_CONTINUITY_RECORD_BYTES = 1_048_576 MAX_APPLICATION_RECORDS = 256 @@ -99,13 +108,33 @@ class ApplicationContinuityRecordV1: record_revision: int mux_spaces: tuple[MuxSpaceContinuityV1, ...] = () contract_version: str = APPLICATION_CONTINUITY_VERSION + managed_service_id: str | None = None + managed_creations: tuple[ManagedMuxCreatedV1, ...] = () + managed_closures: tuple[ManagedMuxCloseStateV1, ...] = field(default=(), kw_only=True) def __post_init__(self) -> None: _require_stable_id(self.application_id, "application identity") _require_stable_id(self.product_id, "Product identity") _require_positive(self.record_revision, "record revision") - if self.contract_version != APPLICATION_CONTINUITY_VERSION: + if self.contract_version not in ( + APPLICATION_CONTINUITY_VERSION, MANAGED_CONTINUITY_VERSION, MANAGED_CLOSE_CONTINUITY_VERSION, + ): raise ValueError("unsupported continuity record") + if self.contract_version == APPLICATION_CONTINUITY_VERSION: + if self.managed_service_id is not None or self.managed_creations != (): + raise ValueError("legacy continuity has no managed authority") + elif ( + type(self.managed_service_id) is not str + or re.fullmatch(r"[0-9a-f]{64}", self.managed_service_id) is None + or type(self.managed_creations) is not tuple + or len(self.managed_creations) > MAX_MANAGED_CREATIONS + or any(type(item) is not ManagedMuxCreatedV1 for item in self.managed_creations) + or len({item.operation_id for item in self.managed_creations}) + != len(self.managed_creations) + or len({item.mux_space_id for item in self.managed_creations}) + != len(self.managed_creations) + ): + raise ValueError("invalid managed continuity receipts") if ( not isinstance(self.mux_spaces, tuple) or len(self.mux_spaces) > MAX_MUX_SPACES @@ -115,6 +144,15 @@ def __post_init__(self) -> None: or len({item.name for item in self.mux_spaces}) != len(self.mux_spaces) ): raise ValueError("invalid continuity MuxSpace registry") + if self.contract_version != APPLICATION_CONTINUITY_VERSION: + receipts = {item.mux_space_id: item for item in self.managed_creations} + if any( + mux.mux_space_id not in receipts + or receipts[mux.mux_space_id].name != mux.name + for mux in self.mux_spaces + ): + raise ValueError("managed MuxSpace has no matching creation receipt") + self._validate_closures() sessions = tuple( member.session.session_id for mux in self.mux_spaces @@ -129,10 +167,36 @@ def __post_init__(self) -> None: ): raise ValueError("continuity Product identity mismatch") + def _validate_closures(self) -> None: + closures = self.managed_closures + if self.contract_version != MANAGED_CLOSE_CONTINUITY_VERSION: + if type(closures) is not tuple or closures: + raise ValueError("previous continuity has no managed close authority") + return + if ( + type(closures) is not tuple or len(closures) > MAX_MANAGED_CLOSURES + or any(type(item) is not ManagedMuxCloseStateV1 for item in closures) + or len({item.operation_id for item in closures}) != len(closures) + or len({item.creation_operation_id for item in closures}) != len(closures) + or len({item.mux_space_id for item in closures}) != len(closures) + ): + raise ValueError("invalid managed closure history") + creations = {item.operation_id: item for item in self.managed_creations} + muxes = {item.mux_space_id: item for item in self.mux_spaces} + for closure in closures: + creation = creations.get(closure.creation_operation_id) + if ( + closure.operation_id in creations or creation is None + or creation.name != closure.name or creation.mux_space_id != closure.mux_space_id + or ((closure.phase is ManagedMuxClosePhaseV1.CLEANUP_PENDING) + != (closure.mux_space_id in muxes)) + ): + raise ValueError("managed closure has no matching durable target") + @dataclass(frozen=True, slots=True) class ApplicationContinuitySummaryV1: - """Bounded inert listing fact; never service-liveness evidence.""" + """Bounded retained-state counts, including cleanup debt; not active Muxes.""" application_id: str product_id: str @@ -200,13 +264,31 @@ def encode_application_continuity_record( if type(record) is not ApplicationContinuityRecordV1: raise TypeError("invalid continuity record") - payload = { + payload: _Object = { "applicationId": record.application_id, "contractVersion": record.contract_version, "muxSpaces": [_encode_mux(mux) for mux in record.mux_spaces], "productId": record.product_id, "recordRevision": record.record_revision, } + if record.contract_version != APPLICATION_CONTINUITY_VERSION: + payload["managedServiceId"] = record.managed_service_id + payload["managedCreations"] = [ + { + "operationId": item.operation_id, + "instanceId": item.instance_id, + "name": item.name, + "muxSpaceId": item.mux_space_id, + } + for item in record.managed_creations + ] + if record.contract_version == MANAGED_CLOSE_CONTINUITY_VERSION: + payload["managedClosures"] = [ + {"operationId": item.operation_id, "instanceId": item.instance_id, + "creationOperationId": item.creation_operation_id, "name": item.name, + "muxSpaceId": item.mux_space_id, "phase": item.phase.value} + for item in record.managed_closures + ] encoded = json.dumps( payload, ensure_ascii=False, @@ -236,10 +318,16 @@ def decode_application_continuity_record( object_pairs_hook=_pairs, parse_constant=lambda _value: (_ for _ in ()).throw(ValueError()), ) - root = _object( - value, - {"applicationId", "contractVersion", "muxSpaces", "productId", "recordRevision"}, + fields = {"applicationId", "contractVersion", "muxSpaces", "productId", "recordRevision"} + managed = type(value) is dict and value.get("contractVersion") in ( + MANAGED_CONTINUITY_VERSION, MANAGED_CLOSE_CONTINUITY_VERSION, ) + managed_close = type(value) is dict and value.get("contractVersion") == MANAGED_CLOSE_CONTINUITY_VERSION + if managed: + fields |= {"managedServiceId", "managedCreations"} + if managed_close: + fields.add("managedClosures") + root = _object(value, fields) mux_values = _array(root["muxSpaces"], maximum=MAX_MUX_SPACES) return ApplicationContinuityRecordV1( application_id=_string(root["applicationId"]), @@ -247,6 +335,15 @@ def decode_application_continuity_record( record_revision=_integer(root["recordRevision"]), mux_spaces=tuple(_decode_mux(item) for item in mux_values), contract_version=_string(root["contractVersion"]), + managed_service_id=_string(root["managedServiceId"]) if managed else None, + managed_creations=tuple( + _decode_creation(item) + for item in _array(root["managedCreations"], maximum=MAX_MANAGED_CREATIONS) + ) if managed else (), + managed_closures=tuple( + _decode_closure(item) + for item in _array(root["managedClosures"], maximum=MAX_MANAGED_CLOSURES) + ) if managed_close else (), ) except ApplicationContinuityError: raise @@ -342,6 +439,22 @@ def _decode_member(value: object) -> MuxMemberContinuityV1: ) +def _decode_creation(value: object) -> ManagedMuxCreatedV1: + raw = _object(value, {"operationId", "instanceId", "name", "muxSpaceId"}) + return ManagedMuxCreatedV1( + _string(raw["operationId"]), _string(raw["instanceId"]), + _string(raw["name"]), _string(raw["muxSpaceId"]), + ) + + +def _decode_closure(value: object) -> ManagedMuxCloseStateV1: + raw = _object(value, {"operationId", "instanceId", "creationOperationId", "name", "muxSpaceId", "phase"}) + return ManagedMuxCloseStateV1( + _string(raw["operationId"]), _string(raw["instanceId"]), _string(raw["creationOperationId"]), + _string(raw["name"]), _string(raw["muxSpaceId"]), ManagedMuxClosePhaseV1(_string(raw["phase"])), + ) + + def _pairs(pairs: list[tuple[str, object]]) -> _Object: value: _Object = {} for key, item in pairs: diff --git a/src/loushang/appservice/continuity_runtime.py b/src/loushang/appservice/continuity_runtime.py index d3c481ae3..ba1d57289 100644 --- a/src/loushang/appservice/continuity_runtime.py +++ b/src/loushang/appservice/continuity_runtime.py @@ -4,10 +4,12 @@ import asyncio import inspect -from collections.abc import Callable -from contextlib import suppress -from dataclasses import dataclass, field +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager, suppress +from dataclasses import dataclass, field, replace +from typing import TypeVar +from loushang.appserver.managed_mux_close import ManagedMuxClosePhaseV1 from loushang.appserver.protocol import ( AppErrorCodeV1, AppServiceError, @@ -16,6 +18,9 @@ ) from .continuity import ( + APPLICATION_CONTINUITY_VERSION, + MANAGED_CLOSE_CONTINUITY_VERSION, + MANAGED_CONTINUITY_VERSION, ApplicationContinuityLeaseV1, ApplicationContinuityRecordV1, require_application_id, @@ -23,11 +28,14 @@ ) from .discovery_ports import HostedSessionDiscoveryBindingV1, require_discovery_context from .execution_service import HostedExecutionServiceBindingV1 +from .managed_mux import ManagedMuxServiceBindingV1 +from .managed_mux_close import ManagedMuxCloseRecoveryAdmissionV1, ManagedMuxCloseUseV1 from .ports import HostedSessionPortV1, HostedSessionResolverV1 from .runtime import ( _CONTINUITY_TOKEN, AppServiceV1, _SessionOwner, + _start_continuity_io, ) @@ -42,9 +50,15 @@ class AppServiceRecoveryRequestV1: close_timeout_seconds: float = 10.0 discovery: HostedSessionDiscoveryBindingV1 | None = field(default=None, repr=False) execution: HostedExecutionServiceBindingV1 | None = field(default=None, repr=False) + managed_mux: ManagedMuxServiceBindingV1 | None = field(default=None, repr=False) def __post_init__(self) -> None: require_discovery_context(self.discovery, self.product_id) + if self.managed_mux is not None and ( + type(self.managed_mux) is not ManagedMuxServiceBindingV1 + or self.managed_mux.application_id != self.continuity_lease.application_id + ): + raise ValueError("invalid managed Mux recovery activation") if self.execution is not None and ( type(self.execution) is not HostedExecutionServiceBindingV1 or self.execution.application_id != self.continuity_lease.application_id @@ -78,12 +92,25 @@ def __post_init__(self) -> None: raise ValueError("invalid recovery close timeout") +@dataclass(slots=True) +class _CloseRecoveryState: + """Data retained by the original attempt, never an independent runtime.""" + + original: ApplicationContinuityRecordV1 | None + sessions: dict[str, _SessionOwner] = field(default_factory=dict) + admission: ManagedMuxCloseRecoveryAdmissionV1 | None = None + confirmed: ApplicationContinuityRecordV1 | None = None + uncertain: bool = False + + class AppServiceRecoveryAttemptV1: """Published owner for every Session adopted before recovery publication.""" __slots__ = ( "_cleanup_debt", "_closed", + "_close_task", + "_close_recovery", "_control_lock", "_open_task", "_request", @@ -97,6 +124,8 @@ def __init__(self, request: AppServiceRecoveryRequestV1) -> None: self._request = request self._control_lock = asyncio.Lock() self._open_task: asyncio.Task[AppServiceV1] | None = None + self._close_task: asyncio.Task[None] | None = None + self._close_recovery: _CloseRecoveryState | None = None self._service: AppServiceV1 | None = None self._cleanup_debt: list[_SessionOwner | _RawSessionOwner] = [] self._transferred = False @@ -104,8 +133,11 @@ def __init__(self, request: AppServiceRecoveryRequestV1) -> None: @property def cleanup_pending(self) -> bool: + recovery = self._close_recovery return bool(self._cleanup_debt) or ( self._service is not None and not self._transferred + ) or (self._open_task is not None and not self._open_task.done()) or ( + recovery is not None and (recovery.uncertain or recovery.admission is not None) ) async def open(self) -> AppServiceV1: @@ -119,12 +151,11 @@ async def open(self) -> AppServiceV1: if task is None or ( task.done() and (task.cancelled() or task.exception() is not None) ): - task = asyncio.create_task(self._open_once()) + task = _start_continuity_io(self._open_once) task.add_done_callback(_observe_background_result) self._open_task = task service = await _join_owned(task) async with self._control_lock: - self._service = service rejected = self._closed if not rejected: self._transferred = True @@ -135,20 +166,21 @@ async def open(self) -> AppServiceV1: async def close(self) -> None: async with self._control_lock: - if self._closed and not self.cleanup_pending: - return self._closed = True - task = self._open_task - if task is not None and not task.done(): - try: - service = await _join_owned(task) - except BaseException: - service = None - if service is not None: - self._service = service - elif task is not None and self._service is None and not task.cancelled(): + task = self._close_task + if task is None or ( + task.done() and (task.cancelled() or task.exception() is not None) + ): + task = _start_continuity_io(self._close_once) + task.add_done_callback(_observe_background_result) + self._close_task = task + await _join_owned(task) + + async def _close_once(self) -> None: + task = self._open_task + if task is not None: with suppress(BaseException): - self._service = task.result() + await _join_owned(task) if self._service is not None and not self._transferred: try: await self._service.close() @@ -156,9 +188,13 @@ async def close(self) -> None: raise _error(AppErrorCodeV1.CLEANUP_INCOMPLETE) from None self._service = None await self._settle_cleanup_debt() + if self._close_recovery is not None and self._close_recovery.uncertain: + raise _error(AppErrorCodeV1.CLEANUP_INCOMPLETE) async def _open_once(self) -> AppServiceV1: await self._settle_cleanup_debt() + if self._close_recovery is not None and self._close_recovery.uncertain: + raise _error(AppErrorCodeV1.OPERATION_UNAVAILABLE) try: record = await self._request.continuity_lease.load() except BaseException: @@ -171,9 +207,27 @@ async def _open_once(self) -> AppServiceV1: raise _error(AppErrorCodeV1.OPERATION_UNAVAILABLE) if record is not None and record.product_id != self._request.product_id: raise _error(AppErrorCodeV1.PRODUCT_MISMATCH) + state = self._close_recovery + if state is not None and record != (state.confirmed or state.original): + raise _error(AppErrorCodeV1.REVISION_CONFLICT) + managed = self._request.managed_mux + versions: tuple[str, ...] = (MANAGED_CONTINUITY_VERSION,) + if managed is not None and managed.closing is not None and managed.closing.recovery is not None: + versions = (*versions, MANAGED_CLOSE_CONTINUITY_VERSION) + if record is not None and ( + (managed is None and record.contract_version != APPLICATION_CONTINUITY_VERSION) + or (managed is not None and ( + record.contract_version not in versions + or record.managed_service_id != managed.service_id + )) + ): + # Reject profile/owner mismatch before opening any retained Session. + raise _error(AppErrorCodeV1.OPERATION_UNAVAILABLE) sessions: dict[str, _SessionOwner] = {} try: + if managed is not None and managed.closing is not None and managed.closing.recovery is not None: + record = await self._recover_managed_closes(record) if record is not None: for mux in record.mux_spaces: for member in mux.members: @@ -191,6 +245,7 @@ async def _open_once(self) -> AppServiceV1: close_timeout_seconds=self._request.close_timeout_seconds, discovery=self._request.discovery, execution=self._request.execution, + managed_mux=self._request.managed_mux, ) service._adopt_continuity_state( lease=self._request.continuity_lease, @@ -199,6 +254,8 @@ async def _open_once(self) -> AppServiceV1: _token=_CONTINUITY_TOKEN, ) sessions.clear() + # Own the successful result before cancellation reaches any waiter. + self._service = service return service except BaseException as error: self._retain_cleanup(tuple(reversed(tuple(sessions.values())))) @@ -210,6 +267,87 @@ async def _open_once(self) -> AppServiceV1: raise raise _error(AppErrorCodeV1.SESSION_UNAVAILABLE) from None + async def _recover_managed_closes( + self, record: ApplicationContinuityRecordV1 | None, + ) -> ApplicationContinuityRecordV1 | None: + state = self._close_recovery + if state is None: + state = _CloseRecoveryState(record) + self._close_recovery = state + if record != (state.confirmed or state.original): + raise _error(AppErrorCodeV1.REVISION_CONFLICT) + async with self._recovery_permission(state.original, ManagedMuxCloseUseV1.ADMIT): + pass + if record is None: + return None + if state.confirmed is not None: + return state.confirmed + pending = {item.mux_space_id for item in record.managed_closures + if item.phase is ManagedMuxClosePhaseV1.CLEANUP_PENDING} + if not pending: + state.confirmed = record + return record + for mux in record.mux_spaces: + if mux.mux_space_id not in pending: + continue + for member in mux.members: + if member.session.session_id in state.sessions: + continue + owner = await self._recover_session(member.session, member.title) + self._retain_cleanup((owner,)) + if owner.identity != member.session: + raise _error(AppErrorCodeV1.PRODUCT_MISMATCH) + state.sessions[owner.identity.session_id] = owner + await self._settle_cleanup_debt() + settled = replace( + record, record_revision=record.record_revision + 1, + mux_spaces=tuple(mux for mux in record.mux_spaces if mux.mux_space_id not in pending), + managed_closures=tuple(replace(item, phase=ManagedMuxClosePhaseV1.CLOSED) + for item in record.managed_closures), + ) + async with self._recovery_permission(record, ManagedMuxCloseUseV1.SETTLE): + try: + await self._request.continuity_lease.commit( + expected_revision=record.record_revision, record=settled, + ) + except BaseException: + state.uncertain = True + raise _error(AppErrorCodeV1.OPERATION_UNAVAILABLE) from None + # Save confirmed outcome before release can fail, never repeat CAS. + state.confirmed = settled + return settled + + @asynccontextmanager + async def _recovery_permission( + self, record: ApplicationContinuityRecordV1 | None, use: ManagedMuxCloseUseV1, + ) -> AsyncIterator[None]: + managed, state = self._request.managed_mux, self._close_recovery + if (managed is None or managed.closing is None or managed.closing.recovery is None + or state is None or state.admission is not None): + raise _error(AppErrorCodeV1.OPERATION_UNAVAILABLE) + try: + owner = managed.closing.recovery.prepare(record, use) + state.admission = owner + await owner.acquire() + owner.check_record(record) + yield + except AppServiceError: + raise + except BaseException: + raise _error(AppErrorCodeV1.OPERATION_UNAVAILABLE) from None + finally: + await self._release_recovery_admission() + + async def _release_recovery_admission(self) -> None: + state = self._close_recovery + if state is None or state.admission is None: + return + try: + await state.admission.close() + except BaseException: + raise _error(AppErrorCodeV1.CLEANUP_INCOMPLETE) from None + state.admission = None + async def _recover_session( self, identity: SessionIdentityV1, @@ -242,6 +380,7 @@ def _retain_cleanup( self._cleanup_debt.append(owner) async def _settle_cleanup_debt(self) -> None: + await self._release_recovery_admission() if not self._cleanup_debt: return owners = tuple(self._cleanup_debt) @@ -313,7 +452,10 @@ def create_appservice_recovery_attempt( return AppServiceRecoveryAttemptV1(request) -async def _join_owned(task: asyncio.Task[AppServiceV1]) -> AppServiceV1: +_OwnedResult = TypeVar("_OwnedResult") + + +async def _join_owned(task: asyncio.Task[_OwnedResult]) -> _OwnedResult: cancellation: asyncio.CancelledError | None = None caller = asyncio.current_task() while not task.done(): diff --git a/src/loushang/appservice/managed_mux.py b/src/loushang/appservice/managed_mux.py new file mode 100644 index 000000000..e36750cb0 --- /dev/null +++ b/src/loushang/appservice/managed_mux.py @@ -0,0 +1,57 @@ +"""Consumer-supplied admission for optional managed Mux mutation. + +The consumer, not AppService, owns namespace reservations and instance fencing. +An admission holds that permission through commit settlement; a boolean check +before an await is not sufficient. No global registry transaction spans RPC. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Protocol + +from loushang.appserver.managed_mux import ManagedMuxCreatedV1, ManagedMuxCreateV1 + +from .continuity import require_application_id +from .managed_mux_close import ManagedMuxCloseBindingV1 + + +class ManagedMuxAdmissionV1(Protocol): + """Original owner, constructed without IO and retained before acquire. + + acquire proves the exact service, instance, operation and reserved name, + rejecting stopped/revoked requests. Once admitted, stop/replacement must + join this operation before retiring its authority. close releases only + this admission, is retryable on the original owner, and never stops a + service. Failure/cancellation of acquire still requires close. + """ + + async def acquire(self) -> None: ... + + def check_creation(self, previous: ManagedMuxCreatedV1 | None) -> None: + """Reject missing/conflicting local history against known durable facts. + + Called after acquire and before returning a replay or committing a new + creation; it neither releases permission nor writes an external result. + """ + ... + + async def close(self) -> None: ... + + +@dataclass(frozen=True, slots=True) +class ManagedMuxServiceBindingV1: + application_id: str + service_id: str + instance_id: str + prepare: Callable[[ManagedMuxCreateV1], ManagedMuxAdmissionV1] = field(repr=False) + closing: ManagedMuxCloseBindingV1 | None = field(default=None, repr=False, kw_only=True) + + def __post_init__(self) -> None: + require_application_id(self.application_id) + ManagedMuxCreateV1(self.service_id, self.instance_id, "0" * 32, "check", "check") + if not callable(self.prepare): + raise TypeError("invalid managed Mux admission factory") + if self.closing is not None and type(self.closing) is not ManagedMuxCloseBindingV1: + raise TypeError("invalid managed Mux close binding") diff --git a/src/loushang/appservice/managed_mux_close.py b/src/loushang/appservice/managed_mux_close.py new file mode 100644 index 000000000..b0627deac --- /dev/null +++ b/src/loushang/appservice/managed_mux_close.py @@ -0,0 +1,86 @@ +"""Consumer-owned close permission; AppService owns membership and settlement.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import Enum +from typing import TYPE_CHECKING, Protocol + +from loushang.appserver.managed_mux import ManagedMuxCreatedV1 +from loushang.appserver.managed_mux_close import ( + ManagedMuxCloseStateV1, + ManagedMuxCloseV1, +) + +if TYPE_CHECKING: + from .continuity import ApplicationContinuityRecordV1 + + +class ManagedMuxCloseUseV1(str, Enum): + ADMIT = "admit" + OBSERVE = "observe" + SETTLE = "settle" + + +class ManagedMuxCloseAdmissionV1(Protocol): + """Retained original permission for one short local commit or observation. + + ADMIT rejects stop. SETTLE permits only previously admitted exact-instance + debt, including after stop. OBSERVE grants no mutation or cleanup. The + consumer fences instance replacement and verifies purpose-specific tokens; + AppService releases the physical fence before waiting for Session cleanup. + Failed acquire/close retains this original owner for cleanup, never a copy. + """ + + async def acquire(self) -> None: ... + + def check_closure(self, creation: ManagedMuxCreatedV1, previous: ManagedMuxCloseStateV1 | None) -> str: + """Validate and return immutable operation origin, not serving identity. + + Continuing an unaccepted old intent requires the consumer's original + startup proof and current live admission. Old pending still requires + startup recovery unless this exact manager admitted it in this run. + """ + ... + + async def close(self) -> None: ... + + +class ManagedMuxCloseRecoveryAdmissionV1(Protocol): + """Original startup fence: prove prior clean stop before any Session IO. + + ADMIT checks the current startup instance and previous cleanly_stopped + facts. SETTLE admits only this same instance's accepted recovery, including + after stop_requested. Neither permits crossing an instance replacement. + None also requires admission: missing continuity must not erase confirmed + managed history. A genuinely empty initial startup needs no predecessor. + Factories are effect-free; the attempt retains this owner before acquire. + """ + + async def acquire(self) -> None: ... + + def check_record(self, record: ApplicationContinuityRecordV1 | None) -> None: ... + + async def close(self) -> None: ... + + +@dataclass(frozen=True, slots=True) +class ManagedMuxCloseRecoveryBindingV1: + prepare: Callable[[ApplicationContinuityRecordV1 | None, ManagedMuxCloseUseV1], ManagedMuxCloseRecoveryAdmissionV1] = field(repr=False) + + def __post_init__(self) -> None: + if not callable(self.prepare): + raise TypeError("invalid managed Mux close recovery factory") + + +@dataclass(frozen=True, slots=True) +class ManagedMuxCloseBindingV1: + prepare: Callable[[ManagedMuxCloseV1, ManagedMuxCloseUseV1], ManagedMuxCloseAdmissionV1] = field(repr=False) + recovery: ManagedMuxCloseRecoveryBindingV1 | None = field(default=None, repr=False, kw_only=True) + + def __post_init__(self) -> None: + if not callable(self.prepare): + raise TypeError("invalid managed Mux close admission factory") + if self.recovery is not None and type(self.recovery) is not ManagedMuxCloseRecoveryBindingV1: + raise TypeError("invalid managed Mux close recovery activation") diff --git a/src/loushang/appservice/runtime.py b/src/loushang/appservice/runtime.py index fd22ce8fc..ca6bab829 100644 --- a/src/loushang/appservice/runtime.py +++ b/src/loushang/appservice/runtime.py @@ -4,13 +4,19 @@ import asyncio import inspect -from collections.abc import Awaitable, Callable -from contextlib import suppress -from dataclasses import dataclass +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager, suppress +from dataclasses import dataclass, replace from hashlib import sha256 from secrets import token_hex -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, TypeVar, cast +from loushang.appserver.managed_mux import ManagedMuxCreatedV1, ManagedMuxCreateV1 +from loushang.appserver.managed_mux_close import ( + ManagedMuxClosePhaseV1, + ManagedMuxCloseStateV1, + ManagedMuxCloseV1, +) from loushang.appserver.protocol import ( MAX_MEMBERS, MAX_MUX_SPACES, @@ -41,12 +47,18 @@ ) from .continuity import ( + APPLICATION_CONTINUITY_VERSION, + MANAGED_CLOSE_CONTINUITY_VERSION, + MANAGED_CONTINUITY_VERSION, + MAX_MANAGED_CLOSURES, + MAX_MANAGED_CREATIONS, ApplicationContinuityError, ApplicationContinuityErrorCodeV1, ApplicationContinuityLeaseV1, ApplicationContinuityRecordV1, MuxMemberContinuityV1, MuxSpaceContinuityV1, + encode_application_continuity_record, ) from .discovery_ports import HostedSessionDiscoveryBindingV1 from .execution_registry import ( @@ -56,6 +68,8 @@ ExecutionServiceErrorV1, ) from .execution_service import HostedExecutionServiceBindingV1 +from .managed_mux import ManagedMuxAdmissionV1, ManagedMuxServiceBindingV1 +from .managed_mux_close import ManagedMuxCloseAdmissionV1, ManagedMuxCloseUseV1 from .ports import ( HostedSessionPortV1, HostedSessionResolutionErrorV1, @@ -417,6 +431,14 @@ def take_attachments(self) -> tuple[_Attachment, ...]: return attachments +@dataclass(slots=True) +class _PendingMuxClose: + request: ManagedMuxCloseV1 + snapshot: MuxSpaceContinuityV1 + sessions: tuple[_SessionOwner, ...] + task: asyncio.Task[None] | None = None + + class AppServiceV1: """In-process hosted application boundary with no transport authority.""" @@ -434,6 +456,13 @@ class AppServiceV1: "_continuity_owner_epoch", "_continuity_revision", "_id_factory", + "_managed_mux", + "_managed_creations", + "_managed_admission", + "_managed_uncertain", + "_managed_closures", + "_managed_closing_muxes", + "_managed_shutdown", "_mux_by_id", "_mux_by_name", "_resolver", @@ -451,6 +480,7 @@ def __init__( close_timeout_seconds: float = 10.0, discovery: HostedSessionDiscoveryBindingV1 | None = None, execution: HostedExecutionServiceBindingV1 | None = None, + managed_mux: ManagedMuxServiceBindingV1 | None = None, ) -> None: if not product_id: raise ValueError("product_id must be non-empty") @@ -464,6 +494,15 @@ def __init__( ): raise ValueError("close_timeout_seconds must be in (0, 60]") self.product_id = product_id + if managed_mux is not None and type(managed_mux) is not ManagedMuxServiceBindingV1: + raise TypeError("invalid managed Mux service binding") + self._managed_mux = managed_mux + self._managed_creations: dict[str, ManagedMuxCreatedV1] = {} + self._managed_admission: ManagedMuxAdmissionV1 | ManagedMuxCloseAdmissionV1 | None = None + self._managed_uncertain = False + self._managed_closures: dict[str, ManagedMuxCloseStateV1] = {} + self._managed_closing_muxes: dict[str, _PendingMuxClose] = {} + self._managed_shutdown = False if execution is not None and type(execution) is not HostedExecutionServiceBindingV1: raise TypeError("invalid execution service configuration") self._execution_config = execution @@ -548,6 +587,7 @@ def _adopt_continuity_state( if record is not None and ( record.application_id != application_id or record.product_id != self.product_id + or any(item.phase is ManagedMuxClosePhaseV1.CLEANUP_PENDING for item in record.managed_closures) ): raise ValueError("AppService continuity identity mismatch") mux_by_id: dict[str, _MuxOwner] = {} @@ -580,8 +620,274 @@ def _adopt_continuity_state( self._mux_by_id = mux_by_id self._mux_by_name = mux_by_name self._sessions = dict(sessions) + self._managed_creations = { + item.operation_id: item + for item in (() if record is None else record.managed_creations) + } + self._managed_closures = { + item.operation_id: item + for item in (() if record is None else record.managed_closures) + } + + def _require_legacy_mux_mutation(self) -> None: + if self._managed_mux is not None: + raise _error(AppErrorCodeV1.OPERATION_UNAVAILABLE) + + async def create_managed_mux(self, request: ManagedMuxCreateV1) -> ManagedMuxCreatedV1: + """Commit one admitted creation and its immutable receipt atomically. + + The original admission spans settled native commit and in-memory + publication, including cancellation. It is never reconstructed for + cleanup. Unknown commit outcome fences subsequent operations. + """ + self._require_request(request, ManagedMuxCreateV1) + async with self._state_lock: + self._require_open() + binding = self._managed_mux + if ( + binding is None or self._continuity_lease is None + or request.service_id != binding.service_id + or request.instance_id != binding.instance_id + ): + raise _error(AppErrorCodeV1.OPERATION_UNAVAILABLE) + try: + admission = binding.prepare(request) + except Exception: + raise _error(AppErrorCodeV1.OPERATION_UNAVAILABLE) from None + self._managed_admission = admission + primary: BaseException | None = None + try: + cancellation = await _join_continuity_commit( + _start_continuity_io(admission.acquire) + ) + if cancellation is not None: + raise cancellation + previous = self._managed_creations.get(request.operation_id) + try: + admission.check_creation(previous) + except Exception: + raise _error(AppErrorCodeV1.OPERATION_UNAVAILABLE) from None + if previous is not None: + if previous.name != request.name: + raise _error(AppErrorCodeV1.REVISION_CONFLICT) + return previous + if request.name in self._mux_by_name or any( + item.snapshot.name == request.name for item in self._managed_closing_muxes.values() + ): + raise _error(AppErrorCodeV1.ALREADY_EXISTS) + if request.operation_id in self._managed_closures: + raise _error(AppErrorCodeV1.REVISION_CONFLICT) + if ( + len(self._mux_by_id) >= (32 if self._client_scopes is not None else MAX_MUX_SPACES) + or len(self._managed_creations) >= MAX_MANAGED_CREATIONS + ): + raise _error(AppErrorCodeV1.OPERATION_UNAVAILABLE) + mux_id = self._new_id() + if mux_id in self._mux_by_id or any( + item.mux_space_id == mux_id for item in self._managed_creations.values() + ): + raise _error(AppErrorCodeV1.OPERATION_UNAVAILABLE) + mux = _MuxOwner(mux_id, request.name) + result = ManagedMuxCreatedV1( + request.operation_id, request.instance_id, request.name, mux_id, + ) + cancellation = await self._commit_continuity( + self._next_continuity_record(add_mux=mux, creation=result) + ) + self._mux_by_id[mux_id] = mux + self._mux_by_name[mux.name] = mux + self._managed_creations[result.operation_id] = result + if cancellation is not None: + raise cancellation + return result + except BaseException as error: + primary = error + raise + finally: + try: + cancellation = await self._release_managed_admission() + if cancellation is not None and primary is None: + raise cancellation + except BaseException: + if primary is None: + raise + primary.add_note("managed admission cleanup incomplete") + + async def _release_managed_admission(self) -> asyncio.CancelledError | None: + admission = self._managed_admission + if admission is None: + return None + try: + cancellation = await _join_continuity_commit( + _start_continuity_io(admission.close) + ) + except BaseException: + raise _error(AppErrorCodeV1.CLEANUP_INCOMPLETE) from None + self._managed_admission = None + return cancellation + + def _managed_close_target( + self, request: ManagedMuxCloseV1, + ) -> tuple[ManagedMuxCreatedV1, ManagedMuxCloseStateV1 | None]: + self._require_request(request, ManagedMuxCloseV1) + binding = self._managed_mux + if (binding is None or binding.closing is None or self._continuity_lease is None + or request.service_id != binding.service_id or request.instance_id != binding.instance_id): + raise _error(AppErrorCodeV1.OPERATION_UNAVAILABLE) + creation = self._managed_creations.get(request.creation_operation_id) + previous = self._managed_closures.get(request.operation_id) + if (creation is None or creation.name != request.name or creation.mux_space_id != request.mux_space_id + or request.operation_id in self._managed_creations + or (previous is not None and (previous.creation_operation_id != creation.operation_id + or previous.name != request.name or previous.mux_space_id != request.mux_space_id)) + or any(item.creation_operation_id == creation.operation_id + and item.operation_id != request.operation_id for item in self._managed_closures.values())): + raise _error(AppErrorCodeV1.REVISION_CONFLICT) + return creation, previous + + @asynccontextmanager + async def _managed_close_permission( + self, request: ManagedMuxCloseV1, use: ManagedMuxCloseUseV1, + *, release_cancellations: list[asyncio.CancelledError] | None = None, + ) -> AsyncIterator[tuple[ManagedMuxCloseStateV1 | None, str]]: + creation, previous = self._managed_close_target(request) + assert self._managed_mux is not None and self._managed_mux.closing is not None + try: + admission = self._managed_mux.closing.prepare(request, use) + except Exception: + raise _error(AppErrorCodeV1.OPERATION_UNAVAILABLE) from None + self._managed_admission = admission + primary: BaseException | None = None + try: + cancellation = await _join_continuity_commit(_start_continuity_io(admission.acquire)) + if cancellation is not None: + raise cancellation + try: + origin = admission.check_closure(creation, previous) + except Exception: + raise _error(AppErrorCodeV1.OPERATION_UNAVAILABLE) from None + yield previous, origin + except BaseException as error: + primary = error + raise + finally: + try: + cancellation = await self._release_managed_admission() + if cancellation is not None and primary is None: + if release_cancellations is None: + raise cancellation + release_cancellations.append(cancellation) + except BaseException: + if primary is None: + raise + primary.add_note("managed admission cleanup incomplete") + + async def read_managed_mux_close(self, request: ManagedMuxCloseV1) -> ManagedMuxCloseStateV1 | None: + async with self._state_lock: + self._require_open() + async with self._managed_close_permission(request, ManagedMuxCloseUseV1.OBSERVE) as (previous, _origin): + return previous + + async def close_managed_mux(self, request: ManagedMuxCloseV1) -> ManagedMuxCloseStateV1: + cancellation: asyncio.CancelledError | None = None + release_cancellations: list[asyncio.CancelledError] = [] + async with self._state_lock: + self._require_open() + async with self._managed_close_permission( + request, ManagedMuxCloseUseV1.ADMIT, release_cancellations=release_cancellations, + ) as (previous, origin): + if previous is None: + if len(self._managed_closures) >= MAX_MANAGED_CLOSURES: + raise _error(AppErrorCodeV1.OPERATION_UNAVAILABLE) + mux = self._mux_by_id.get(request.mux_space_id) + if mux is None or mux.name != request.name: + raise _error(AppErrorCodeV1.NOT_FOUND) + async with mux.lock: + self._require_mux_open(mux) + state = ManagedMuxCloseStateV1( + request.operation_id, origin, request.creation_operation_id, + request.name, request.mux_space_id, ManagedMuxClosePhaseV1.CLEANUP_PENDING, + ) + pending = _PendingMuxClose(request, self._continuity_mux(mux), + tuple(member.session for member in mux.members)) + cancellation = await self._commit_continuity(self._next_continuity_record(closure=state)) + self._managed_closures[state.operation_id] = state + self._managed_closing_muxes[state.operation_id] = pending + self._cleanup_debt.update(pending.sessions) + members = tuple(mux.members) + attachments = mux.take_attachments() + mux.closed = True + mux.members.clear() + self._mux_by_id.pop(mux.mux_space_id) + self._mux_by_name.pop(mux.name) + for session in pending.sessions: + self._sessions.pop(session.identity.session_id, None) + for attachment in attachments: + self._attachments.pop(attachment.attachment_id, None) + self._settle_attachments(attachments, members) + # Native permission must be fully released before cleanup starts. + if previous is not None and previous.phase is ManagedMuxClosePhaseV1.CLOSED: + if release_cancellations: + raise release_cancellations[0] + return previous + pending = self._managed_closing_muxes[request.operation_id] + task = self._start_managed_close(pending) + if cancellation is not None: + raise cancellation + if release_cancellations: + raise release_cancellations[0] + await asyncio.shield(task) + return self._managed_closures[request.operation_id] + + def _start_managed_close(self, pending: _PendingMuxClose) -> asyncio.Task[None]: + task = pending.task + if task is None or (task.done() and (task.cancelled() or task.exception() is not None)): + task = _start_continuity_io(lambda: self._settle_managed_close(pending)) + pending.task = task + task.add_done_callback(_observe_managed_close) + return task + + async def _settle_managed_close(self, pending: _PendingMuxClose) -> None: + await self._close_sessions(pending.sessions) + async with self._state_lock: + if self._closed or self._managed_uncertain or self._managed_admission is not None: + raise _error(AppErrorCodeV1.OPERATION_UNAVAILABLE) + request = pending.request + async with self._managed_close_permission(request, ManagedMuxCloseUseV1.SETTLE) as (previous, _origin): + if (previous is None or previous.phase is not ManagedMuxClosePhaseV1.CLEANUP_PENDING + or self._managed_closing_muxes.get(request.operation_id) is not pending): + raise _error(AppErrorCodeV1.REVISION_CONFLICT) + state = replace(previous, phase=ManagedMuxClosePhaseV1.CLOSED) + cancellation = await self._commit_continuity(self._next_continuity_record(closure=state)) + self._managed_closures[state.operation_id] = state + self._managed_closing_muxes.pop(state.operation_id) + if cancellation is not None: + raise cancellation + + async def _drain_managed_closes(self) -> None: + deadline = asyncio.get_running_loop().time() + self._close_timeout_seconds + # Join before retrying or releasing any original admission. No Session + # owner is handed to generic service shutdown while this task can use it. + for retry in (False, True): + async with self._state_lock: + if self._managed_uncertain: + raise _error(AppErrorCodeV1.CLEANUP_INCOMPLETE) + if retry: + await self._release_managed_admission() + for pending in self._managed_closing_muxes.values(): + if asyncio.get_running_loop().time() >= deadline: + raise _error(AppErrorCodeV1.CLEANUP_INCOMPLETE) + self._start_managed_close(pending) + tasks = {item.task for item in self._managed_closing_muxes.values() if item.task is not None} + if tasks: + _done, unsettled = await asyncio.wait(tasks, timeout=max(0.0, deadline - asyncio.get_running_loop().time())) + if unsettled: + raise _error(AppErrorCodeV1.CLEANUP_INCOMPLETE) + if self._managed_closing_muxes or self._managed_uncertain: + raise _error(AppErrorCodeV1.CLEANUP_INCOMPLETE) async def create_mux(self, request: MuxCreateV1) -> MuxSpaceV1: + self._require_legacy_mux_mutation() if type(request) is not MuxCreateV1: raise _error(AppErrorCodeV1.INVALID_REQUEST) async with self._state_lock: @@ -696,6 +1002,7 @@ async def detach_mux(self, request: MuxDetachV1) -> AckV1: return AckV1() async def close_mux(self, request: MuxCloseV1) -> AckV1: + self._require_legacy_mux_mutation() self._require_request(request, MuxCloseV1) mux = await self._resolve_mux(request.selector) async with self._state_lock: @@ -766,6 +1073,9 @@ async def open_member(self, request: MuxMemberOpenV1) -> MuxSpaceV1: if session.identity.session_id in self._sessions or any( item.identity.session_id == session.identity.session_id for item in self._cleanup_debt + ) or any( + member.session.session_id == session.identity.session_id + for pending in self._managed_closing_muxes.values() for member in pending.snapshot.members ): raise _error(AppErrorCodeV1.ALREADY_EXISTS) async with mux.lock: @@ -924,16 +1234,27 @@ async def read_events( async def close(self) -> None: scope_failure = False + managed_failure = False + managed_cancellation: asyncio.CancelledError | None = None if self._execution_registry is not None: self._execution_registry.fence() if self._discovery is not None: self._discovery.fence() + if self._managed_mux is not None and self._managed_mux.closing is not None: + self._managed_shutdown = True + if self._client_scopes is not None: + self._client_scopes.fence() + await self._drain_managed_closes() if self._client_scopes is not None: try: await self._client_scopes.close() except AppServiceError: scope_failure = True async with self._state_lock: + try: + managed_cancellation = await self._release_managed_admission() + except AppServiceError: + managed_failure = True if self._closed: muxes: tuple[_MuxOwner, ...] = () attachments: tuple[_Attachment, ...] = () @@ -959,8 +1280,10 @@ async def close(self) -> None: if self._discovery is not None: await self._discovery.close() await self._close_sessions(sessions) - if scope_failure: + if scope_failure or managed_failure: raise _error(AppErrorCodeV1.CLEANUP_INCOMPLETE) + if managed_cancellation is not None: + raise managed_cancellation async def _resolve_mux(self, selector: MuxSelectorV1) -> _MuxOwner: async with self._state_lock: @@ -1105,6 +1428,8 @@ def _next_continuity_record( replace_mux: _MuxOwner | None = None, members: tuple[_Member, ...] | None = None, mux_revision: int | None = None, + creation: ManagedMuxCreatedV1 | None = None, + closure: ManagedMuxCloseStateV1 | None = None, ) -> ApplicationContinuityRecordV1 | None: if self._continuity_lease is None: return None @@ -1126,6 +1451,11 @@ def _next_continuity_record( retained.append(self._continuity_mux(mux)) if add_mux is not None: retained.append(self._continuity_mux(add_mux)) + retained.extend( + item.snapshot for operation, item in self._managed_closing_muxes.items() + if closure is None or closure.operation_id != operation + or closure.phase is not ManagedMuxClosePhaseV1.CLOSED + ) retained.sort(key=lambda item: (item.name, item.mux_space_id)) application_id = self._continuity_application_id if application_id is None: @@ -1138,6 +1468,20 @@ def _next_continuity_record( product_id=self.product_id, record_revision=revision, mux_spaces=tuple(retained), + contract_version=(APPLICATION_CONTINUITY_VERSION if self._managed_mux is None + else MANAGED_CONTINUITY_VERSION if self._managed_mux.closing is None + else MANAGED_CLOSE_CONTINUITY_VERSION), + managed_service_id=(None if self._managed_mux is None + else self._managed_mux.service_id), + managed_creations=( + *self._managed_creations.values(), + *((creation,) if creation is not None else ()), + ), + managed_closures=( + *(item for operation, item in self._managed_closures.items() + if closure is None or operation != closure.operation_id), + *((closure,) if closure is not None else ()), + ), ) @staticmethod @@ -1172,13 +1516,25 @@ async def _commit_continuity( lease = self._continuity_lease if lease is None: raise RuntimeError("continuity record has no lease") - task = asyncio.create_task( - lease.commit( - expected_revision=self._continuity_revision, - record=record, - ) - ) - cancellation = await _join_continuity_commit(task) + if self._managed_mux is not None: + try: + encode_application_continuity_record(record) + except ApplicationContinuityError: + # Capacity rejection before any commit IO is not an unknown + # write outcome. Existing service operations remain available. + raise _error(AppErrorCodeV1.OPERATION_UNAVAILABLE) from None + try: + task = _start_continuity_io(lambda: lease.commit( + expected_revision=self._continuity_revision, record=record, + )) + cancellation = await _join_continuity_commit(task) + except BaseException: + if self._managed_mux is not None: + # A failed receipt can follow replace/fsync. Never retry a new + # mutation against stale in-memory state or claim durability + # from a read alone. Recovery uses the original stored record. + self._managed_uncertain = True + raise self._continuity_revision = record.record_revision return cancellation @@ -1210,8 +1566,10 @@ def _new_id(self) -> str: return value def _require_open(self) -> None: - if self._closed: + if self._closed or self._managed_shutdown: raise _error(AppErrorCodeV1.SERVICE_CLOSED) + if self._managed_uncertain or self._managed_admission is not None: + raise _error(AppErrorCodeV1.OPERATION_UNAVAILABLE) @staticmethod def _require_request(request: object, expected: type[object]) -> None: @@ -1224,6 +1582,37 @@ def _require_mux_open(mux: _MuxOwner) -> None: raise _error(AppErrorCodeV1.NOT_FOUND) +def _observe_managed_close(task: asyncio.Task[None]) -> None: + if not task.cancelled(): + task.exception() + + +_ContinuityResult = TypeVar("_ContinuityResult") + + +def _start_continuity_io(operation: Callable[[], Awaitable[_ContinuityResult]]) -> asyncio.Task[_ContinuityResult]: + """Publish before effects, including task-factory receipt failure. + + Same publication gate as scoped _OwnedAppOperations: the factory can run + the wrapper eagerly, but it cannot enter the actual operation until the + original Task has been returned to its joining owner. + """ + published = asyncio.get_running_loop().create_future() + + async def invoke() -> _ContinuityResult: + await published + return await operation() + + invocation = invoke() + try: + task = asyncio.create_task(invocation) + except BaseException: + invocation.close() + raise _error(AppErrorCodeV1.OPERATION_UNAVAILABLE) from None + published.set_result(None) + return task + + async def _join_continuity_commit( task: asyncio.Task[None], ) -> asyncio.CancelledError | None: diff --git a/src/loushang/foundation/observability/runtime.py b/src/loushang/foundation/observability/runtime.py index 94fb22f16..5ee6d392b 100644 --- a/src/loushang/foundation/observability/runtime.py +++ b/src/loushang/foundation/observability/runtime.py @@ -41,11 +41,19 @@ def observability_runtime_context( debug_path: str | Path | None = None, debug_scopes: frozenset[str] = frozenset(), trace_path: str | Path | None = None, + trace_sink: TraceSinkProtocol | None = None, trace_scopes: frozenset[str] = frozenset(), problem_sink: InMemoryProblemStore | None = None, ) -> Iterator[None]: - """Temporarily bind sinks and context, then restore the previous state.""" + """Temporarily bind process-wide sinks and context, then restore state. + An injected trace sink is borrowed: this context neither drains nor closes + it. Hosts must settle its work before releasing its backing resources. + Sink binding is process-wide, not a per-task isolation mechanism. + """ + + if trace_path is not None and trace_sink is not None: + raise ValueError("trace_path and trace_sink are mutually exclusive") configure_kwargs: _ConfigureKwargs = {} if debug_path is not None: resolved_debug_path = Path(debug_path) @@ -61,6 +69,9 @@ def observability_runtime_context( latest_path=resolved_trace_path.parent / "latest", ) configure_kwargs["trace_scopes"] = trace_scopes + elif trace_sink is not None: + configure_kwargs["trace_sink"] = trace_sink + configure_kwargs["trace_scopes"] = trace_scopes if problem_sink is not None: configure_kwargs["problem_sink"] = problem_sink diff --git a/tests/apphost/_managed_process_fault.py b/tests/apphost/_managed_process_fault.py new file mode 100644 index 000000000..db1ad04e6 --- /dev/null +++ b/tests/apphost/_managed_process_fault.py @@ -0,0 +1,82 @@ +"""Real process-shell fault fixture; test-only ports and non-clean exit.""" + +import asyncio +import json +import signal +import sys +import threading + +from loushang.apphost.managed import _process as module + + +class Settled: + async def run(self): + return 0 + + async def close(self, *, retry_timeout=None): + pass + + +def main(): + fault = sys.argv[1] + original_runner = asyncio.Runner + + class FaultRunner(original_runner): + def get_loop(self): + loop = super().get_loop() + if fault == "init": + raise RuntimeError("injected setup after native loop creation") + return loop + + def close(self): + super().close() + if fault == "close": + raise RuntimeError("injected partial close") + + module.asyncio.Runner = FaultRunner + original_signal = signal.signal + failed_install = False + + def setter(kind, value): + nonlocal failed_install + result = original_signal(kind, value) + if fault == "install" and kind == signal.SIGTERM and not failed_install: + failed_install = True + raise RuntimeError("injected installation after native effect") + return result + + module.signal.signal = setter + process = module._ChildProcess(Settled(), Settled()) + finished = threading.Event() + companion = threading.Thread(target=finished.wait) + companion.start() # Real unmasked concurrent thread, including after return. + + def receipt(): + return {"unknown": process.unknown, "pending": process.cleanup_pending, + "stopRequested": process._stop_requested, + "protected": signal.getsignal(signal.SIGHUP) == signal.SIG_IGN + and all(signal.getsignal(kind) == process._signal for kind in (signal.SIGINT, signal.SIGTERM))} + + def observe(): + print(json.dumps(receipt()), flush=True) + for command in sys.stdin: + if command.strip() == "quit": + # Deliberate test-only termination, not a production receipt. + raise SystemExit(9 if fault == "success" else 7) + assert command.strip() == "status" + print(json.dumps(receipt()), flush=True) + raise SystemExit(8) + + module._park = observe + try: + result = process.run() + assert fault == "success" and result == 0 + observe() # Final test diagnostics; never start another application. + finally: + finished.set() + companion.join(2) + assert not companion.is_alive() + + +if __name__ == "__main__": + main() diff --git a/tests/apphost/test_managed_bootstrap.py b/tests/apphost/test_managed_bootstrap.py new file mode 100644 index 000000000..b81d7ade3 --- /dev/null +++ b/tests/apphost/test_managed_bootstrap.py @@ -0,0 +1,589 @@ +from __future__ import annotations + +import asyncio +import os +import socket +import sys +from contextlib import contextmanager +from dataclasses import replace +from pathlib import Path +from threading import get_ident +from time import monotonic + +import pytest + +from loushang.apphost.managed import bootstrap as module +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.bootstrap import ManagedChildBootstrapV1 +from loushang.apphost.managed.contracts import ( + ManagedContractError, + ManagedInstanceRefV1, + ManagedNamespaceV1, + ManagedServiceKeyV1, +) +from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 +from loushang.apphost.managed.paths import resolve_managed_paths +from loushang.apphost.managed.registry import ManagedMuxReservationV1, ManagedRegistryV1 +from loushang.hosting.service import LinuxServiceObserverV1 + +from .test_managed_child import Application, until + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux child bootstrap") + + +@pytest.fixture +def deployment(tmp_path): + namespace = ManagedNamespaceV1(str(tmp_path / "platform"), os.geteuid(), "a" * 32) + service = ManagedServiceKeyV1("coding", str(tmp_path)) + placeholder = ManagedInstanceRefV1(namespace.namespace_key, service.service_id, "d" * 32) + paths = resolve_managed_paths(namespace, service, placeholder, runtime_root=str(tmp_path / "runtime")) + for path in (paths.registry, paths.lifecycle, paths.application, paths.connection): + # mkdir(parents=True) does not apply mode to intermediate directories. + for parent in reversed(Path(path).parents): + if parent != tmp_path and parent.is_relative_to(tmp_path): + parent.mkdir(mode=0o700, exist_ok=True) + registry = ManagedRegistryV1(Path(paths.registry), namespace, create=True) + registry.reserve_mux(ManagedMuxReservationV1("dev", service, "b" * 32)) + journal = ManagedServiceJournalV1(registry, namespace, service, Path(paths.lifecycle), create=True) + state = journal.prepare("c" * 32, expected=None) + paths = resolve_managed_paths(namespace, service, state.handoff.instance, runtime_root=str(tmp_path / "runtime")) + try: + yield namespace, service, journal, state, paths + finally: + journal.close() + registry.close() + + +def make_bootstrap(deployment, tmp_path, **overrides): + namespace, service, _, state, _ = deployment + parent, endpoint = socket.socketpair() + try: + owner = ManagedChildBootstrapV1( + overrides.get("namespace", namespace), service, + overrides.get("instance", state.handoff.instance), overrides.get("attempt", state.handoff.attempt_id), + endpoint, runtime_root=str(tmp_path / "runtime"), + diagnostics=overrides.get("diagnostics", False), + ) + except BaseException: + parent.close() + endpoint.close() + raise + return owner, parent + + +def test_managed_binding_borrows_original_dependencies_only_before_application_adoption(deployment, tmp_path, monkeypatch): + owner, parent = make_bootstrap(deployment, tmp_path) + try: + with pytest.raises(ManagedStorageError, match="closed"): + owner.managed_mux_binding(application_id="coding.default") + assert owner._mux_manager is None + owner.open(deadline=monotonic() + 5) + original_registry, original_journal = owner._registry, owner._journal + + def forbidden(*args, **kwargs): + pytest.fail("pure management binding acquired another control owner") + + monkeypatch.setattr(module, "ManagedRegistryV1", forbidden) + monkeypatch.setattr(module, "ManagedServiceJournalV1", forbidden) + binding = owner.managed_mux_binding(application_id="coding.default") + assert binding == owner.managed_mux_binding(application_id="coding.default") + assert owner._mux_manager._registry is original_registry + assert owner._mux_manager._journal is original_journal + assert binding.closing is not None and binding.closing.recovery is not None + assert owner._mux_manager._startup_native is owner._observer.identity + assert owner._mux_manager._startup_attempt == deployment[3].handoff.attempt_id + assert original_journal.read().handoff.phase.value == "provisional" + with pytest.raises(ManagedStorageError, match="conflict"): + owner.managed_mux_binding(application_id="coding.other") + owner.close() + with pytest.raises(ManagedStorageError, match="closed"): + owner.managed_mux_binding(application_id="coding.default") + assert not owner.cleanup_pending + finally: + owner.close() + parent.close() + + +def test_original_control_worker_waits_for_managed_commit_without_blocking_loop(deployment, tmp_path, monkeypatch): + from loushang.appservice import ( + AppServiceRecoveryRequestV1, + create_appservice_recovery_attempt, + ) + from tests.appservice.test_continuity_runtime import _MemoryLease, _Resolver + + owner, parent = make_bootstrap(deployment, tmp_path) + owner.open(deadline=monotonic() + 5) + binding = owner.managed_mux_binding(application_id="coding.default") + journal = owner._journal + instance, attempt_id = deployment[3].handoff.instance, deployment[3].handoff.attempt_id + journal.register_native(instance, attempt_id, owner._observer.identity) + + async def scenario(): + entered, release, waiting = asyncio.Event(), asyncio.Event(), asyncio.Event() + loop, loop_thread = asyncio.get_running_loop(), get_ident() + original_lock = journal._fence.lock + + @contextmanager + def observed_lock(*args, **kwargs): + if get_ident() != loop_thread: + loop.call_soon_threadsafe(waiting.set) + with original_lock(*args, **kwargs): + yield + + monkeypatch.setattr(journal._fence, "lock", observed_lock) + + class Lease(_MemoryLease): + async def commit(self, *, expected_revision, record): + entered.set() + await release.wait() + await super().commit(expected_revision=expected_revision, record=record) + + lease = Lease() + recovery = create_appservice_recovery_attempt(AppServiceRecoveryRequestV1( + "coding", _Resolver([]), lease, managed_mux=binding, + )) + service = await recovery.open() + journal.commit(instance, attempt_id, native_identity=owner._observer.identity) + permit = owner._mux_manager.issue_create(owner._registry.resolve("dev"), deadline=monotonic() + 5) + creation = asyncio.create_task(service.create_managed_mux(permit)) + control = None + try: + await entered.wait() + control = asyncio.create_task(asyncio.to_thread(owner._control.observe, "stop", monotonic() + 5)) + await waiting.wait() + assert not control.done() and not creation.done() + release.set() # Same loop remains able to settle the original commit. + result = await creation + stopped = await control + assert result == lease.record.managed_creations[0] + assert stopped.handoff.stop_requested + assert len(lease.commits) == 1 + finally: + release.set() + await asyncio.gather(creation, *(() if control is None else (control,)), return_exceptions=True) + await service.close() + + try: + asyncio.run(asyncio.wait_for(scenario(), 10)) + finally: + owner.close() + parent.close() + + +def test_capture_factory_before_failed_bind_can_close_without_entering_loop(deployment, tmp_path): + from loushang.apphost.managed._files import PrivateManagedDirectory + + owner, parent = make_bootstrap(deployment, tmp_path) + scratch = PrivateManagedDirectory(Path(deployment[4].temporary), create=True, create_parents=True) + scratch.close() + try: + owner.open(deadline=monotonic() + 5) + factory = owner.output_capture_factory(deadline=monotonic() + 5) + with pytest.raises(TypeError): + owner.bind(object()) + assert owner._child is None and factory._loop is None + owner.close() + assert factory.settled and not owner.cleanup_pending + finally: + parent.close() + owner.close() + + +def test_capture_factory_settles_before_shared_directory_and_registry(deployment, tmp_path, monkeypatch): + from loushang.apphost.managed._files import PrivateManagedDirectory + + owner, parent = make_bootstrap(deployment, tmp_path) + _, _, _, _, paths = deployment + scratch = PrivateManagedDirectory(Path(paths.temporary), create=True, create_parents=True) + scratch.close() + try: + owner.open(deadline=monotonic() + 5) + factory = owner.output_capture_factory(deadline=monotonic() + 5) + assert owner.output_capture_factory(deadline=monotonic() + 5) is factory + registry, observer = owner._registry, owner._observer + + async def scenario(): + factory.new_capture() + with pytest.raises(ManagedStorageError, match="busy"): + await asyncio.to_thread(owner.close) + assert owner._registry is registry and owner._capture_directory is not None + await factory.close() + assert factory.settled + + asyncio.run(scenario()) + directory = owner._capture_directory + original_close = directory.close + calls = 0 + + def fail_once(): + nonlocal calls + calls += 1 + if calls == 1: + raise ManagedStorageError("busy") + original_close() + + monkeypatch.setattr(directory, "close", fail_once) + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.close() + assert owner._capture_directory is directory + assert owner._registry is registry and owner._observer is observer + owner.close() + assert not owner.cleanup_pending and calls == 2 + finally: + parent.close() + owner.close() + + +def test_bootstrap_real_layout_late_registration_and_bound_dependency_lifetime(deployment, tmp_path, monkeypatch): + _, _, journal, state, paths = deployment + owner, parent = make_bootstrap(deployment, tmp_path) + owner.open(deadline=monotonic() + 5) + assert owner.paths == paths + assert journal.read().native_identity is None + application = Application() + child = owner.bind(application) + with pytest.raises(ManagedStorageError, match="busy"): + owner.close() # Child has not even run yet, but owns its dependencies. + assert not owner._closing + + async def scenario(): + observed = asyncio.Event() + loop = asyncio.get_running_loop() + original_observe = owner._control.observe + observations = 0 + + def observe(*args, **kwargs): + nonlocal observations + result = original_observe(*args, **kwargs) + if result is not None and result.native_identity is None: + observations += 1 + if observations == 2: # Driver has processed the first snapshot. + loop.call_soon_threadsafe(observed.set) + return result + + monkeypatch.setattr(owner._control, "observe", observe) + runner = asyncio.create_task(child.run()) + try: + await observed.wait() + assert not child.accepting and child._prepare_task is None + assert application.prepares == 0 and not application.prepared.is_set() + journal.register_native(state.handoff.instance, state.handoff.attempt_id, owner._observer.identity) + await until(lambda: child.accepting) + assert application.prepares == 1 + parent.close() + await asyncio.sleep(0.03) + assert child.accepting + await child.close() + await runner + finally: + await child.close(retry_timeout=5) + await asyncio.gather(runner, return_exceptions=True) + try: + asyncio.run(asyncio.wait_for(scenario(), 10)) + finally: + parent.close() + owner.close() + assert not owner.cleanup_pending + assert journal.read().evidence.application_cleanup_completed + + +def test_bootstrap_call_matrix_rejects_without_new_io_or_adoption(deployment, tmp_path, monkeypatch): + owner, parent = make_bootstrap(deployment, tmp_path) + application = Application() + try: + with pytest.raises(ManagedStorageError, match="closed"): + owner.bind(application) + owner.open(deadline=monotonic() + 5) + with pytest.raises(ManagedStorageError, match="closed"): + owner.open(deadline=monotonic() + 5) + child = owner.bind(application) + with monkeypatch.context() as patch: + patch.setattr(os, "open", lambda *a, **kw: pytest.fail("illegal operation performed IO")) + with pytest.raises(ManagedStorageError, match="closed"): + owner.open(deadline=monotonic() + 5) + with pytest.raises(ManagedStorageError, match="closed"): + owner.bind(application) + asyncio.run(child.close()) + owner.close() + with pytest.raises(ManagedStorageError, match="closed"): + owner.bind(Application()) + with pytest.raises(ManagedStorageError, match="closed"): + owner.open(deadline=monotonic() + 5) + finally: + parent.close() + owner.close() + + +@pytest.mark.parametrize("mismatch", ["attempt", "instance", "native"]) +def test_bootstrap_refuses_mismatched_binding(deployment, tmp_path, mismatch): + _, _, journal, state, _ = deployment + overrides = {} + if mismatch == "native": + observer = LinuxServiceObserverV1.capture(os.getpid()) + try: + journal.register_native(state.handoff.instance, state.handoff.attempt_id, + replace(observer.identity, start_ticks=observer.identity.start_ticks + 1)) + finally: + observer.close() + else: + overrides[mismatch] = ("e" * 32 if mismatch == "attempt" + else replace(state.handoff.instance, instance_id="e" * 32)) + owner, parent = make_bootstrap(deployment, tmp_path, **overrides) + before = journal.read() + try: + with pytest.raises(ManagedStorageError, match="conflict"): + owner.open(deadline=monotonic() + 5) + assert owner._control is None and owner._child is None + assert journal.read() == before + finally: + parent.close() + owner.close() + + +def test_bootstrap_failed_admission_retry_replaces_only_failed_container(deployment, tmp_path, monkeypatch): + owner, parent = make_bootstrap(deployment, tmp_path) + original = ManagedServiceJournalV1.open + calls = [] + + def fail_once(self, *, deadline=None): + calls.append(self) + original(self, deadline=deadline) + if len(calls) == 1: + raise ManagedStorageError("busy") + + monkeypatch.setattr(ManagedServiceJournalV1, "open", fail_once) + deadline = monotonic() + 5 + try: + with pytest.raises(ManagedStorageError, match="busy"): + owner.open(deadline=deadline) + registry, failed = owner._registry, owner._journal + assert failed is not None + owner.open(deadline=deadline + 100) + assert owner._registry is registry and owner._journal is not failed + assert owner._deadline == deadline + with pytest.raises(ManagedStorageError, match="closed"): + failed.read() + finally: + parent.close() + owner.close() + + +def test_bootstrap_cannot_extend_failed_admission_deadline(deployment, tmp_path, monkeypatch): + from loushang.apphost.managed import _files + + owner, parent = make_bootstrap(deployment, tmp_path) + now = [1.0] + monkeypatch.setattr(_files, "monotonic", lambda: now[0]) + + def fail(self, **kwargs): + raise ManagedStorageError("busy") + + monkeypatch.setattr(ManagedRegistryV1, "open", fail) + try: + with pytest.raises(ManagedStorageError, match="busy"): + owner.open(deadline=2) + registry = owner._registry + now[0] = 3.0 + with pytest.raises(ManagedStorageError, match="busy"): + owner.open(deadline=100) + assert owner._registry is registry and owner._deadline == 2 + finally: + parent.close() + owner.close() + + +@pytest.mark.parametrize("dependency", ["control", "journal", "registry"]) +def test_bootstrap_partial_close_preserves_failed_owner_and_registry_order(deployment, tmp_path, monkeypatch, dependency): + owner, parent = make_bootstrap(deployment, tmp_path) + owner.open(deadline=monotonic() + 5) + resource = getattr(owner, "_" + dependency) + registry = owner._registry + original = resource.close + calls = [] + + def fail_once(): + calls.append(1) + if len(calls) == 1: + raise ManagedStorageError("busy") + original() + + monkeypatch.setattr(resource, "close", fail_once) + try: + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.close() + assert getattr(owner, "_" + dependency) is resource + if dependency == "journal": + assert owner._registry is registry + assert registry.resolve("dev") is not None + with pytest.raises(ManagedStorageError, match="closed"): + owner.open(deadline=monotonic() + 5) + owner.close() + assert not owner.cleanup_pending and len(calls) == 2 + finally: + parent.close() + owner.close() + + +def test_bootstrap_native_close_uncertainty_does_not_skip_other_dependencies(deployment, tmp_path, monkeypatch): + owner, parent = make_bootstrap(deployment, tmp_path) + owner.open(deadline=monotonic() + 5) + observer = owner._observer + original = observer.close + calls = [] + + def unknown_close(): + calls.append(1) + original() + raise OSError("uncertain native close") + + monkeypatch.setattr(observer, "close", unknown_close) + try: + for _ in range(2): + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.close() + assert owner.cleanup_pending and calls == [1] + assert owner._journal is None and owner._registry is None and owner._control is None + finally: + parent.close() + + +def test_failed_native_capture_is_not_repeated_or_declared_clean(deployment, tmp_path, monkeypatch): + owner, parent = make_bootstrap(deployment, tmp_path) + calls = [] + + def fail(pid): + calls.append(pid) + raise OSError("capture failed without a returned owner") + + monkeypatch.setattr(module.LinuxServiceObserverV1, "capture", fail) + try: + with pytest.raises(OSError): + owner.open(deadline=monotonic() + 5) + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.open(deadline=monotonic() + 5) + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.close() + assert owner.cleanup_pending and calls == [os.getpid()] + assert owner._journal is None and owner._registry is None and owner._endpoint is None + finally: + parent.close() + + +def test_adopted_control_native_close_failure_remains_debt_and_preserves_reused_fd(deployment, tmp_path, monkeypatch): + owner, parent = make_bootstrap(deployment, tmp_path) + endpoint = owner._endpoint + owner.open(deadline=monotonic() + 5) + original = socket.socket._real_close + calls, replacements = [], [] + + def uncertain_close(self): + original(self) + if self is endpoint: + calls.append(1) + replacements.append(os.open(tmp_path, os.O_RDONLY | os.O_DIRECTORY)) + raise OSError("native socket closed but result is uncertain") + + try: + with monkeypatch.context() as patch: + patch.setattr(socket.socket, "_real_close", uncertain_close) + for _ in range(2): + with pytest.raises(ManagedStorageError, match="unavailable"): + owner.close() + assert owner.cleanup_pending and owner._control is not None + assert calls == [1] and endpoint.fileno() == -1 + assert owner._journal is None and owner._registry is None + os.fstat(replacements[0]) + finally: + parent.close() + for fd in replacements: + os.close(fd) + + +def test_constructor_revokes_inheritance_on_adopted_endpoint_without_database_io(deployment, tmp_path, monkeypatch): + namespace, service, _, state, _ = deployment + parent, endpoint = socket.socketpair() + endpoint.set_inheritable(True) + owner = None + try: + with monkeypatch.context() as patch: + patch.setattr(os, "open", lambda *a, **kw: pytest.fail("construction performed file IO")) + owner = ManagedChildBootstrapV1(namespace, service, state.handoff.instance, state.handoff.attempt_id, + endpoint, runtime_root=str(tmp_path / "runtime")) + assert not endpoint.get_inheritable() and owner._endpoint is endpoint + assert owner._registry is None and owner._observer is None + finally: + parent.close() + if owner is not None: + owner.close() + else: + endpoint.close() + + +@pytest.mark.parametrize("socket_kind", ["unconnected", "datagram", "tcp"]) +def test_constructor_rejects_wrong_socket_shape_without_adopting_it(deployment, tmp_path, socket_kind): + namespace, service, _, state, _ = deployment + family = socket.AF_INET if socket_kind == "tcp" else socket.AF_UNIX + kind = socket.SOCK_DGRAM if socket_kind == "datagram" else socket.SOCK_STREAM + endpoint = socket.socket(family, kind) + endpoint.set_inheritable(True) + try: + with pytest.raises(ManagedContractError): + ManagedChildBootstrapV1(namespace, service, state.handoff.instance, state.handoff.attempt_id, + endpoint, runtime_root=str(tmp_path / "runtime")) + assert endpoint.fileno() >= 0 and endpoint.get_inheritable() + finally: + endpoint.close() + + +def test_bootstrap_rejects_native_io_on_application_event_loop(deployment, tmp_path): + owner, parent = make_bootstrap(deployment, tmp_path) + + async def scenario(): + with pytest.raises(ManagedStorageError, match="busy"): + owner.open(deadline=monotonic() + 5) + with pytest.raises(ManagedStorageError, match="busy"): + owner.close() + try: + asyncio.run(scenario()) + assert owner._registry is None + finally: + parent.close() + owner.close() + + +def test_constructor_and_bind_failure_leave_inputs_with_caller(deployment, tmp_path): + namespace, service, _, state, _ = deployment + parent, endpoint = socket.socketpair() + try: + wrong_namespace = replace(namespace, user_id=namespace.user_id + 1) + with pytest.raises(ManagedContractError): + ManagedChildBootstrapV1(wrong_namespace, service, + replace(state.handoff.instance, namespace_key=wrong_namespace.namespace_key), + state.handoff.attempt_id, endpoint, + runtime_root=str(tmp_path / "runtime")) + assert endpoint.fileno() >= 0 + finally: + parent.close() + endpoint.close() + owner, parent = make_bootstrap(deployment, tmp_path) + application = Application() + try: + owner.open(deadline=monotonic() + 5) + with pytest.raises(ValueError): + owner.bind(application, startup_timeout=0) + assert owner._child is None and application.closes == 0 and not application.fenced + finally: + parent.close() + owner.close() + + +@pytest.mark.parametrize("deadline", [None, True, 0, float("inf"), float("nan")]) +def test_bootstrap_requires_finite_absolute_deadline_before_io(deployment, tmp_path, deadline): + owner, parent = make_bootstrap(deployment, tmp_path) + try: + with pytest.raises(ManagedContractError): + owner.open(deadline=deadline) + assert owner._registry is None and owner._observer is None + finally: + parent.close() + owner.close() diff --git a/tests/apphost/test_managed_bootstrap_trace.py b/tests/apphost/test_managed_bootstrap_trace.py new file mode 100644 index 000000000..482816a25 --- /dev/null +++ b/tests/apphost/test_managed_bootstrap_trace.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from time import monotonic + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError +from loushang.foundation.observability.records import DebugEventRecord +from loushang.foundation.observability.runtime import observability_runtime_context + +from . import test_managed_bootstrap as fixtures +from . import test_managed_child as child_fixtures + +deployment = fixtures.deployment +pytestmark = fixtures.pytestmark + + +@pytest.mark.parametrize("trace_first", [False, True]) +def test_bootstrap_formats_share_one_native_directory_admission(deployment, tmp_path, monkeypatch, trace_first): + _, _, journal, state, paths = deployment + Path(paths.logs).mkdir(mode=0o700) + owner, parent = fixtures.make_bootstrap(deployment, tmp_path, diagnostics=True) + try: + owner.open(deadline=monotonic() + 5) + deadline = monotonic() + 30 + buffer = owner.prepare_trace(deadline=deadline) + assert owner.prepare_trace(deadline=deadline) is buffer + with pytest.raises(ManagedStorageError, match="conflict"): + owner.prepare_trace(deadline=deadline + 1) + assert not tuple(Path(paths.logs).iterdir()) + journal.register_native(state.handoff.instance, state.handoff.attempt_id, + owner._observer.identity, deadline=monotonic() + 5) + buffer.write_debug_event(DebugEventRecord("turn.start.performance", "turn", {"total_ms": 1})) + frame = buffer.take() + assert frame is not None + directory = owner._log_directory + original = directory.open + opens = [] + def counted(**kwargs): + opens.append(kwargs) + original(**kwargs) + monkeypatch.setattr(directory, "open", counted) + def write_trace(): + owner._record_trace(frame, deadline) + def write_lifecycle(): + owner._record_lifecycle("ready", None) + for write in ((write_trace, write_lifecycle) if trace_first else (write_lifecycle, write_trace)): + write() + assert len(opens) == 1 + assert owner._trace_writer._directory is owner._log_writer._directory is directory + assert json.loads((Path(paths.logs) / "trace-0.jsonl").read_bytes())["record"]["instanceId"] == state.handoff.instance.instance_id + finally: + owner.close() + parent.close() + buffer.write_debug_event(DebugEventRecord("turn.start.performance", "turn", {"total_ms": 2})) + assert buffer.take() is None + + +def test_failed_shared_directory_admission_is_not_replayed_by_other_format(deployment, tmp_path, monkeypatch): + _, _, journal, state, _ = deployment + owner, parent = fixtures.make_bootstrap(deployment, tmp_path, diagnostics=True) + try: + owner.open(deadline=monotonic() + 5) + deadline = monotonic() + 30 + buffer = owner.prepare_trace(deadline=deadline) + journal.register_native(state.handoff.instance, state.handoff.attempt_id, + owner._observer.identity, deadline=monotonic() + 5) + calls = [] + def failed(**kwargs): + calls.append(kwargs) + raise ManagedStorageError("unavailable") + monkeypatch.setattr(owner._log_directory, "open", failed) + with pytest.raises(ManagedStorageError, match="unavailable"): + owner._record_lifecycle("ready", None) + buffer.write_debug_event(DebugEventRecord("turn.start.performance", "turn", {"total_ms": 1})) + with pytest.raises(ManagedStorageError, match="closed"): + owner._record_trace(buffer.take(), deadline) + assert len(calls) == 1 + finally: + owner.close() + parent.close() + + +@pytest.mark.parametrize("installed,diagnostics", [(False, False), (True, False), (True, True)]) +def test_trace_only_bootstrap_does_not_enable_or_charge_lifecycle(deployment, tmp_path, installed, diagnostics): + _, _, journal, state, paths = deployment + Path(paths.logs).mkdir(mode=0o700) + owner, parent = fixtures.make_bootstrap(deployment, tmp_path, diagnostics=diagnostics) + try: + owner.open(deadline=monotonic() + 5) + buffer = owner.prepare_trace(deadline=monotonic() + 30) + journal.register_native(state.handoff.instance, state.handoff.attempt_id, + owner._observer.identity, deadline=monotonic() + 5) + child = owner.bind(child_fixtures.Application()) + assert (child._diagnostic is not None) == diagnostics + assert journal.read().trace_application is None + async def run(): + waiter = asyncio.create_task(owner.run()) + try: + await child_fixtures.until(lambda: child.accepting) + buffer.write_debug_event(DebugEventRecord("turn.start.performance", "turn", {"total_ms": 2})) + await child_fixtures.until(lambda: child._diagnostic_task.done() + and (child._trace_initialized or child._trace_disabled)) + finally: + await child.close() + assert await waiter == 0 + with observability_runtime_context(session_id=None, cwd=tmp_path, mode="managed", + trace_sink=buffer, trace_scopes=frozenset({"turn.start.performance"})): + if installed: + owner.trace_sink_installed(buffer) + asyncio.run(asyncio.wait_for(run(), 10)) + expected_names = {"trace.lock", "trace-0.jsonl", "trace-1.jsonl"} if installed else set() + if diagnostics: + expected_names |= {"lifecycle.lock", "lifecycle-0.jsonl"} + assert {path.name for path in Path(paths.logs).iterdir()} == expected_names + receipt = journal.read().trace_application + assert (receipt is not None) == installed + if installed: + assert receipt.instance_id == state.handoff.instance.instance_id + assert receipt.attempt_id == state.handoff.attempt_id + assert receipt.deadline_ms == round(buffer.deadline * 1000) + with journal._database.transaction() as connection: + expected = ([("log", 1)] if diagnostics else []) + ([("trace", 2)] if installed else []) + assert connection.execute("SELECT kind, count(*) FROM storage_allocations GROUP BY kind ORDER BY kind").fetchall() == expected + finally: + owner.close() + parent.close() diff --git a/tests/apphost/test_managed_child.py b/tests/apphost/test_managed_child.py index 5de784db8..66f176dac 100644 --- a/tests/apphost/test_managed_child.py +++ b/tests/apphost/test_managed_child.py @@ -169,7 +169,7 @@ def test_failed_cleanup_record_retries_fact_not_successful_application_close(bin async def scenario(): application = Application() - owner = ManagedChildApplicationV1(application, control) + owner = ManagedChildApplicationV1(application, control, settlement_timeout=0.2) waiter = asyncio.create_task(owner.run()) await until(lambda: owner.accepting) original = journal.record_child_cleanup @@ -181,8 +181,14 @@ def unavailable(*args, **kwargs): with pytest.raises(ManagedChildError, match="cleanup_incomplete"): await owner.close() assert application.closes == 1 and owner.cleanup_pending - assert not journal.read().evidence.application_cleanup_completed + # The retained control observation may still own the native fence after + # the close waiter's deadline. Inspect without blocking the app loop. + observed = await asyncio.to_thread(journal.read, deadline=monotonic() + 2, wait_for_lock=True) + assert not observed.evidence.application_cleanup_completed monkeypatch.setattr(journal, "record_child_cleanup", original) + # A timed-out public wait does not cancel the original receipt loop. + # Join that exact attempt before granting a subsequent retry budget. + await asyncio.gather(owner._close_task, return_exceptions=True) await owner.close(retry_timeout=2) await asyncio.gather(waiter, return_exceptions=True) assert application.closes == 1 and not owner.cleanup_pending @@ -313,7 +319,7 @@ def blocked(action, deadline): asyncio.run(asyncio.wait_for(scenario(), 10)) -@pytest.mark.parametrize("failure", ["submit", "wait_task"]) +@pytest.mark.parametrize("failure", ["submit", "submitted", "wait_task"]) def test_internal_scheduling_failure_fences_and_settles_owned_application(binding, monkeypatch, failure): _, _, _, _, control = binding @@ -344,6 +350,8 @@ def fail_submit(self, *args, **kwargs): nonlocal injected if not injected: injected = True + if failure == "submitted": + original_submit(self, *args, **kwargs) raise RuntimeError("private submit failure") return original_submit(self, *args, **kwargs) @@ -355,6 +363,78 @@ def fail_submit(self, *args, **kwargs): asyncio.run(asyncio.wait_for(scenario(), 10)) +def test_control_submit_lost_receipt_has_no_native_effect(binding, monkeypatch): + from concurrent.futures import ThreadPoolExecutor + + _, _, _, _, control = binding + original = ThreadPoolExecutor.submit + queued = [] + effects = [] + + def lose_receipt(executor, function): + queued.append(original(executor, function)) + raise RuntimeError("lost receipt") + + async def scenario(): + owner = ManagedChildApplicationV1(Application(), control) + try: + with monkeypatch.context() as patch: + patch.setattr(ThreadPoolExecutor, "submit", lose_receipt) + with pytest.raises(RuntimeError, match="lost receipt"): + await owner._io(lambda: effects.append("unauthorized")) + await asyncio.wrap_future(queued[0]) + assert effects == [] and owner._pending_io is None + await owner.close() + finally: + if owner._worker is not None: + owner._worker.shutdown(wait=True) + + asyncio.run(asyncio.wait_for(scenario(), 10)) + + +@pytest.mark.parametrize("native_failure", [False, True]) +def test_control_cancelled_internal_waiter_retains_native_receipt(binding, native_failure): + _, _, _, _, control = binding + entered, release = threading.Event(), threading.Event() + + def operation(): + entered.set() + assert release.wait(5) + if native_failure: + raise asyncio.CancelledError() + + async def scenario(): + owner = ManagedChildApplicationV1(Application(), control) + waiter = asyncio.create_task(owner._io(operation)) + closer = None + try: + await until(entered.is_set) + receipt = owner._pending_io + assert receipt is not None and not receipt.cancel() + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + closer = asyncio.create_task(owner.close()) + await asyncio.sleep(0.02) + assert not closer.done() and owner.cleanup_pending + assert owner._pending_io is receipt and not receipt.done() + finally: + release.set() + if closer is not None: + if native_failure: + with pytest.raises(ManagedChildError): + await closer + assert owner._pending_io is None + await owner.close() + else: + await closer + else: + await owner.close() + assert receipt.done() and not owner.cleanup_pending + + asyncio.run(asyncio.wait_for(scenario(), 10)) + + @pytest.mark.parametrize("phase", ["prepare", "activate"]) def test_cancel_run_during_stage_keeps_exact_task_and_service(binding, monkeypatch, phase): _, _, _, _, control = binding diff --git a/tests/apphost/test_managed_child_logging.py b/tests/apphost/test_managed_child_logging.py new file mode 100644 index 000000000..8a1471d4e --- /dev/null +++ b/tests/apphost/test_managed_child_logging.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +import asyncio +import json +import os +import threading +from pathlib import Path +from time import monotonic + +import pytest + +from loushang.apphost.managed import child as child_module +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.child import ManagedChildApplicationV1, ManagedChildError + +from . import test_managed_bootstrap as bootstrap_fixtures +from . import test_managed_child as fixtures + +binding = fixtures.binding +deployment = bootstrap_fixtures.deployment +pytestmark = fixtures.pytestmark + + +def test_blocked_log_does_not_block_activation_or_application_close(binding): + journal, _, _, _, control = binding + entered, release = threading.Event(), threading.Event() + events = [] + + def record(event, code): + events.append((event, code)) + if event == "starting": + entered.set() + assert release.wait(5) + + async def scenario(): + app = fixtures.Application() + owner = ManagedChildApplicationV1(app, control, diagnostic=record, settlement_timeout=0.1) + waiter = asyncio.create_task(owner.run()) + try: + await fixtures.until(lambda: entered.is_set() and owner.accepting) + with pytest.raises(ManagedChildError, match="cleanup_incomplete"): + await owner.close() + assert app.closed.is_set() and owner.cleanup_pending + assert journal.read().evidence.application_cleanup_completed + assert events == [("starting", None)] + finally: + release.set() + await asyncio.gather(owner._close_task, return_exceptions=True) + await owner.close(retry_timeout=2) + await asyncio.gather(waiter, return_exceptions=True) + assert [item[0] for item in events] == ["starting", "ready", "stopping", "stopped"] + assert not owner.cleanup_pending + asyncio.run(asyncio.wait_for(scenario(), 10)) + + +def test_failed_logging_is_dropped_without_changing_application_result(binding): + _, _, _, _, control = binding + calls = [] + + def record(event, code): + calls.append(event) + raise OSError("secret diagnostic failure") + + async def scenario(): + app = fixtures.Application() + owner = ManagedChildApplicationV1(app, control, diagnostic=record) + waiter = asyncio.create_task(owner.run()) + await fixtures.until(lambda: owner.accepting) + await owner.close() + await waiter + assert not owner.cleanup_pending and app.closes == 1 and calls == ["starting"] + asyncio.run(asyncio.wait_for(scenario(), 10)) + + +def test_cleanup_retry_does_not_emit_stopped_before_success(binding): + _, _, _, _, control = binding + events = [] + + async def scenario(): + app = fixtures.Application() + owner = ManagedChildApplicationV1(app, control, diagnostic=lambda event, code: events.append(event)) + waiter = asyncio.create_task(owner.run()) + await fixtures.until(lambda: owner.accepting) + app.fail_close = True + with pytest.raises(ManagedChildError): + await owner.close() + assert "stopped" not in events + app.fail_close = False + await owner.close(retry_timeout=2) + await asyncio.gather(waiter, return_exceptions=True) + assert events.count("stopped") == events.count("stopping") == 1 + asyncio.run(asyncio.wait_for(scenario(), 10)) + + +def test_bootstrap_admits_default_logs_only_after_native_binding(deployment, tmp_path): + _, _, journal, state, paths = deployment + Path(paths.logs).mkdir(mode=0o700) + owner, parent = bootstrap_fixtures.make_bootstrap(deployment, tmp_path, diagnostics=True) + owner.open(deadline=monotonic() + 5) + app = fixtures.Application() + child = owner.bind(app) + + async def scenario(): + waiter = asyncio.create_task(owner.run()) + await fixtures.until(lambda: child._observation_task is not None) + assert not app.prepared.is_set() and not tuple(Path(paths.logs).iterdir()) + deadline = monotonic() + 5 + while True: + try: + await asyncio.to_thread(journal.register_native, state.handoff.instance, + state.handoff.attempt_id, owner._observer.identity, deadline=deadline) + break + except ManagedStorageError as error: + if error.code != "busy" or monotonic() >= deadline: + raise + await asyncio.sleep(0.01) # Same idempotent birth, not another launch. + await fixtures.until(lambda: child.accepting) + await child.close() + assert await waiter == 0 + assert not owner.cleanup_pending + + try: + asyncio.run(asyncio.wait_for(scenario(), 10)) + events = [json.loads(line) for path in Path(paths.logs).glob("*.jsonl") + for line in path.read_bytes().splitlines()] + assert [item["event"] for item in events] == ["starting", "ready", "stopping", "stopped"] + assert {item["instanceId"] for item in events} == {state.handoff.instance.instance_id} + assert all(set(item) == {"v", "event", "instanceId", "code", "sequence"} for item in events) + assert not journal.read().evidence.process_exited + finally: + owner.close() + parent.close() + + +@pytest.mark.parametrize("queued", [False, True]) +def test_lost_submit_receipt_cannot_enter_diagnostic_callback(binding, monkeypatch, queued): + _, _, _, _, control = binding + native = child_module.ThreadPoolExecutor.submit + calls, submissions = [], [] + + def submit(executor, function, *args, **kwargs): + if function.__name__ == "deliver": + if queued: + submissions.append(native(executor, function, *args, **kwargs)) + raise RuntimeError("lost submission receipt") + return native(executor, function, *args, **kwargs) + + async def scenario(): + owner = ManagedChildApplicationV1(fixtures.Application(), control, + diagnostic=lambda event, code: calls.append(event)) + waiter = asyncio.create_task(owner.run()) + await fixtures.until(lambda: owner.accepting and owner._diagnostics_disabled) + await owner.close() + await waiter + if queued: + assert len(submissions) == 1 + assert await asyncio.wrap_future(submissions[0]) is False + assert not calls + assert not owner.cleanup_pending + + with monkeypatch.context() as patch: + patch.setattr(child_module.ThreadPoolExecutor, "submit", submit) + asyncio.run(asyncio.wait_for(scenario(), 10)) + + +def test_cancelled_log_waiter_retains_exact_native_future(binding): + _, _, _, _, control = binding + entered, release = threading.Event(), threading.Event() + calls = [] + + def record(event, code): + calls.append(event) + entered.set() + assert release.wait(5) + + async def scenario(): + app = fixtures.Application() + owner = ManagedChildApplicationV1(app, control, diagnostic=record, settlement_timeout=0.1) + waiter = asyncio.create_task(owner.run()) + try: + await fixtures.until(lambda: owner.accepting and entered.is_set()) + original = owner._pending_log + owner._diagnostic_task.cancel() + await asyncio.gather(owner._diagnostic_task, return_exceptions=True) + with pytest.raises(ManagedChildError): + await owner.close() + assert owner._pending_log is original and not original.done() + assert app.closed.is_set() and owner.cleanup_pending + finally: + release.set() + await asyncio.gather(owner._close_task, return_exceptions=True) + await owner.close(retry_timeout=2) + await asyncio.gather(waiter, return_exceptions=True) + assert calls == ["starting"] and not owner.cleanup_pending + asyncio.run(asyncio.wait_for(scenario(), 10)) + + +def test_late_activation_success_during_close_does_not_log_ready(binding): + _, _, _, _, control = binding + events = [] + + async def scenario(): + entered, release = asyncio.Event(), asyncio.Event() + + class LateApplication(fixtures.Application): + async def activate(self): + entered.set() + await release.wait() + + app = LateApplication() + owner = ManagedChildApplicationV1(app, control, settlement_timeout=0.1, + diagnostic=lambda event, code: events.append(event)) + waiter = asyncio.create_task(owner.run()) + try: + await entered.wait() + with pytest.raises(ManagedChildError): + await owner.close() + assert "ready" not in events and "stopped" not in events + finally: + release.set() + await asyncio.gather(owner._close_task, return_exceptions=True) + await owner.close(retry_timeout=2) + await asyncio.gather(waiter, return_exceptions=True) + assert events == ["starting", "stopping", "stopped"] + asyncio.run(asyncio.wait_for(scenario(), 10)) + + +def test_real_log_fsync_releases_service_fence_and_retains_bootstrap_dependencies(deployment, tmp_path, monkeypatch): + _, _, journal, state, paths = deployment + Path(paths.logs).mkdir(mode=0o700) + owner, parent = bootstrap_fixtures.make_bootstrap(deployment, tmp_path, diagnostics=True) + owner.open(deadline=monotonic() + 5) + journal.register_native(state.handoff.instance, state.handoff.attempt_id, owner._observer.identity) + app = fixtures.Application() + child = owner.bind(app, settlement_timeout=0.1) + entered, release = threading.Event(), threading.Event() + native = os.fsync + path = Path(paths.logs) / "lifecycle-0.jsonl" + + def fsync(fd): + try: + target = path.stat() + except FileNotFoundError: + target = None + if target is not None and os.fstat(fd).st_ino == target.st_ino and not entered.is_set(): + entered.set() + assert release.wait(5) + return native(fd) + + async def scenario(): + waiter = asyncio.create_task(child.run()) + try: + await fixtures.until(lambda: entered.is_set() and child.accepting) + with pytest.raises(ManagedChildError): + await child.close() + observed = await asyncio.to_thread(journal.read, deadline=monotonic() + 2, wait_for_lock=True) + assert observed.handoff.stop_requested and observed.evidence.application_cleanup_completed + assert app.closed.is_set() and child.cleanup_pending + assert owner._journal is not None and owner._registry is not None and owner._observer is not None + with pytest.raises(ManagedStorageError, match="busy"): + await asyncio.to_thread(owner.close) + finally: + release.set() + await asyncio.gather(child._close_task, return_exceptions=True) + await child.close(retry_timeout=2) + await asyncio.gather(waiter, return_exceptions=True) + await asyncio.to_thread(owner.close) + assert not owner.cleanup_pending + + try: + with monkeypatch.context() as patch: + patch.setattr(os, "fsync", fsync) + asyncio.run(asyncio.wait_for(scenario(), 10)) + assert [json.loads(line)["event"] for line in path.read_bytes().splitlines()] == [ + "starting", "ready", "stopping", "stopped", + ] + finally: + release.set() + owner.close() + parent.close() + + +@pytest.mark.parametrize("root_state", ["missing", "unknown-entry"]) +def test_unavailable_default_logging_does_not_fail_application(deployment, tmp_path, root_state): + _, _, journal, state, paths = deployment + if root_state == "unknown-entry": + Path(paths.logs).mkdir(mode=0o700) + (Path(paths.logs) / "foreign").touch(mode=0o600) + owner, parent = bootstrap_fixtures.make_bootstrap(deployment, tmp_path, diagnostics=True) + owner.open(deadline=monotonic() + 5) + journal.register_native(state.handoff.instance, state.handoff.attempt_id, owner._observer.identity) + app = fixtures.Application() + child = owner.bind(app) + + async def scenario(): + waiter = asyncio.create_task(owner.run()) + await fixtures.until(lambda: child.accepting and child._diagnostics_disabled) + await child.close() + assert await waiter == 0 and not owner.cleanup_pending + assert app.activations == app.closes == 1 + + try: + asyncio.run(asyncio.wait_for(scenario(), 10)) + assert not tuple(Path(paths.logs).glob("*.jsonl")) + finally: + owner.close() + parent.close() diff --git a/tests/apphost/test_managed_child_trace.py b/tests/apphost/test_managed_child_trace.py new file mode 100644 index 000000000..f37a08b07 --- /dev/null +++ b/tests/apphost/test_managed_child_trace.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import asyncio +import threading +from time import monotonic + +import pytest + +from loushang.apphost.managed.child import ManagedChildApplicationV1, ManagedChildError +from loushang.apphost.managed.trace_buffer import ManagedTraceBuffer +from loushang.foundation.observability.records import DebugEventRecord + +from . import test_managed_child as fixtures + +binding = fixtures.binding +pytestmark = fixtures.pytestmark + + +def trace(buffer): + buffer.write_debug_event(DebugEventRecord("turn.start.performance", "turn", {"total_ms": 1})) + + +@pytest.mark.parametrize("fail", [False, True]) +def test_trace_uses_original_diagnostic_slot_and_failure_keeps_lifecycle(binding, fail): + _, _, _, _, control = binding + buffer = ManagedTraceBuffer("a" * 32, monotonic() + 60) + calls, events = [], [] + def write(frame, deadline): + calls.append((frame, deadline, threading.current_thread().name)) + if fail: + raise OSError("private failure") + async def run(): + app = fixtures.Application() + owner = ManagedChildApplicationV1(app, control, trace_buffer=buffer, trace_write=write, + diagnostic=lambda event, code: events.append(event)) + waiter = asyncio.create_task(owner.run()) + try: + await fixtures.until(lambda: owner.accepting) + trace(buffer) + await fixtures.until(lambda: bool(calls)) + finally: + await owner.close() + await asyncio.gather(waiter, return_exceptions=True) + assert not owner.cleanup_pending + assert len(calls) == 1 and calls[0][1] == buffer.deadline + assert calls[0][2].startswith("lmux-control") + assert events == ["starting", "ready", "stopping", "stopped"] + trace(buffer) + assert buffer.take() is None + asyncio.run(asyncio.wait_for(run(), 10)) + + +def test_blocked_trace_does_not_block_application_stop_but_retains_native_work(binding): + _, _, _, _, control = binding + buffer = ManagedTraceBuffer("a" * 32, monotonic() + 60) + entered, release = threading.Event(), threading.Event() + def write(frame, deadline): + entered.set() + assert release.wait(5) + async def run(): + app = fixtures.Application() + owner = ManagedChildApplicationV1(app, control, trace_buffer=buffer, trace_write=write, + settlement_timeout=0.1) + waiter = asyncio.create_task(owner.run()) + try: + await fixtures.until(lambda: owner.accepting) + trace(buffer) + await fixtures.until(entered.is_set) + with pytest.raises(ManagedChildError, match="cleanup_incomplete"): + await owner.close() + assert app.closed.is_set() and owner.cleanup_pending + finally: + release.set() + await asyncio.gather(owner._close_task, return_exceptions=True) + await owner.close(retry_timeout=2) + await asyncio.gather(waiter, return_exceptions=True) + assert not owner.cleanup_pending + asyncio.run(asyncio.wait_for(run(), 10)) + + +@pytest.mark.parametrize("scheduled", [False, True]) +def test_final_trace_drain_publication_failure_has_no_native_effect(binding, scheduled): + _, _, _, _, control = binding + buffer = ManagedTraceBuffer("a" * 32, monotonic() + 60) + calls = [] + async def run(): + owner = ManagedChildApplicationV1(fixtures.Application(), control, + trace_buffer=buffer, trace_write=lambda *args: calls.append(args)) + trace(buffer) + buffer.fence() + loop = asyncio.get_running_loop() + original = loop.get_task_factory() + tasks = [] + def fail(loop, coroutine, **kwargs): + if scheduled: + tasks.append(asyncio.Task(coroutine, loop=loop, **kwargs)) + raise RuntimeError("lost task publication") + loop.set_task_factory(fail) + try: + await owner._settle_diagnostics(monotonic() + 2) + finally: + loop.set_task_factory(original) + await asyncio.gather(*tasks, return_exceptions=True) + assert owner._trace_disabled and owner._pending_log is None + assert calls == [] and buffer.snapshot() == (0, 0, 1) + asyncio.run(asyncio.wait_for(run(), 10)) + + +def test_trace_only_close_drains_tail_without_another_poll(binding): + _, _, _, _, control = binding + buffer = ManagedTraceBuffer("a" * 32, monotonic() + 60) + calls = [] + async def run(): + owner = ManagedChildApplicationV1(fixtures.Application(), control, + trace_buffer=buffer, trace_write=lambda *args: calls.append(args)) + waiter = asyncio.create_task(owner.run()) + await fixtures.until(lambda: owner.accepting and owner._diagnostic_task.done()) + trace(buffer) + await owner.close() + await waiter + assert len(calls) == 1 and not owner.cleanup_pending + assert buffer.snapshot() == (0, 0, 0) + asyncio.run(asyncio.wait_for(run(), 10)) diff --git a/tests/apphost/test_managed_coordinator.py b/tests/apphost/test_managed_coordinator.py new file mode 100644 index 000000000..5c582d532 --- /dev/null +++ b/tests/apphost/test_managed_coordinator.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +import asyncio +import sys +from dataclasses import replace +from threading import Event +from time import monotonic + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.coordinator import ManagedServiceCoordinatorV1 +from loushang.appserver.client import AppConnectionClosedError +from loushang.appserver.local_record import LocalRecordError, LocalRecordErrorCodeV1 +from tests.apphost.test_managed_connection import committed +from tests.apphost.test_managed_starter import owners as owners + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed coordinator") + + +def coordinator(owners): + journal, namespace, service, runtime = owners + + def forbidden(*args): + raise AssertionError("unit observation must not launch a child") + + return ManagedServiceCoordinatorV1( + journal, namespace, service, runtime_root=runtime, endpoint="workspace", request_factory=forbidden, + ) + + +def fake_leases(monkeypatch, hook=None): + created = [] + + class Lease: + def __init__(self, journal, namespace, service, instance, **kwargs): + self.instance = instance + self.closed = False + self.close_error = False + created.append(self) + + @property + def client(self): + if self.closed: + raise AppConnectionClosedError() + return self + + async def prepare(self, **kwargs): + if hook is not None: + await hook(self) + + async def close(self): + if self.close_error: + raise RuntimeError("injected_close") + self.closed = True + + monkeypatch.setattr("loushang.apphost.managed.coordinator.ManagedConnectionLeaseV1", Lease) + return created + + +def test_reuse_returns_same_operation_without_launch_or_budget_renewal(owners, monkeypatch): + state = committed(owners) + created = fake_leases(monkeypatch) + owner = coordinator(owners) + + async def scenario(): + deadline = monotonic() + 5 + first = await owner.ensure_started(deadline=deadline) + assert await owner.ensure_started(deadline=deadline) is first + assert first.instance == owner.instance == state.handoff.instance + with pytest.raises(ManagedStorageError, match="conflict"): + await owner.ensure_started(deadline=deadline + 1) + assert len(created) == 1 and owner._starter._process is None + await owner.close() + assert first.closed and not owner.cleanup_pending + + asyncio.run(scenario()) + assert owners[0].read() == state + + +def test_start_reply_loss_is_observed_not_replayed(owners, monkeypatch): + owner = coordinator(owners) + created = fake_leases(monkeypatch) + calls = [] + + def lost(**kwargs): + calls.append(1) + committed(owners) # Pure durable fixture; a competing start won. + raise ManagedStorageError("unavailable") + + monkeypatch.setattr(owner._starter, "start", lost) + + async def scenario(): + try: + result = await owner.ensure_started(deadline=monotonic() + 5) + assert result is created[0] and calls == [1] + finally: + await owner.close() + + asyncio.run(scenario()) + + +def test_unknown_start_without_durable_result_is_not_replayed(owners, monkeypatch): + owner = coordinator(owners) + calls = [] + + def lost(**kwargs): + calls.append(1) + raise ManagedStorageError("unavailable") + + monkeypatch.setattr(owner._starter, "start", lost) + + async def scenario(): + deadline = monotonic() + 5 + try: + for _ in range(2): + with pytest.raises(ManagedStorageError, match="unavailable"): + await owner.ensure_started(deadline=deadline) + assert calls == [1] and owner.instance is None + finally: + await owner.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("kind", ["not_found", "busy", "corrupt"]) +def test_readiness_retries_only_read_failures_after_settlement(owners, monkeypatch, kind): + committed(owners) + calls = [] + + async def hook(lease): + calls.append(lease) + if len(calls) == 1: + if kind == "busy": + raise ManagedStorageError("busy") + raise LocalRecordError(LocalRecordErrorCodeV1.NOT_FOUND if kind == "not_found" else LocalRecordErrorCodeV1.CORRUPT) + assert calls[0].closed + + created = fake_leases(monkeypatch, hook) + owner = coordinator(owners) + + async def scenario(): + try: + if kind == "corrupt": + with pytest.raises(LocalRecordError): + await owner.ensure_started(deadline=monotonic() + 5) + assert len(created) == 1 + else: + result = await owner.ensure_started(deadline=monotonic() + 5) + assert result is created[1] and created[0].closed + finally: + await owner.close() + assert all(item.closed for item in created) + + asyncio.run(scenario()) + + +def test_failed_readiness_cleanup_blocks_replacement(owners, monkeypatch): + committed(owners) + + async def hook(lease): + lease.close_error = True + raise LocalRecordError(LocalRecordErrorCodeV1.NOT_FOUND) + + created = fake_leases(monkeypatch, hook) + owner = coordinator(owners) + + async def scenario(): + try: + with pytest.raises(RuntimeError, match="injected_close"): + await owner.ensure_started(deadline=monotonic() + 5) + assert len(created) == 1 and owner._connection is created[0] and owner.cleanup_pending + finally: + created[0].close_error = False + await owner.close() + assert not owner.cleanup_pending + + asyncio.run(scenario()) + + +def test_generation_change_during_wait_does_not_retarget(owners, monkeypatch): + journal = owners[0] + state = journal.prepare("c" * 32, expected=None) + owner = coordinator(owners) + calls = [] + + async def change(deadline): + calls.append(1) + changed = replace(state, handoff=replace(state.handoff, instance=replace(state.handoff.instance, instance_id="d" * 32)), + evidence=replace(state.evidence, instance=replace(state.handoff.instance, instance_id="d" * 32))) + monkeypatch.setattr(journal, "read", lambda **kwargs: changed) + + monkeypatch.setattr(owner, "_pause", change) + created = fake_leases(monkeypatch) + + async def scenario(): + try: + with pytest.raises(ManagedStorageError, match="conflict"): + await owner.ensure_started(deadline=monotonic() + 5) + assert calls == [1] and owner.instance == state.handoff.instance and not created + finally: + await owner.close() + + asyncio.run(scenario()) + + +def test_stopping_instance_does_not_start_or_connect(owners, monkeypatch): + state = committed(owners) + owners[0].request_stop(state.handoff.instance) + owner = coordinator(owners) + created = fake_leases(monkeypatch) + + async def scenario(): + try: + with pytest.raises(ManagedStorageError, match="conflict"): + await owner.ensure_started(deadline=monotonic() + 5) + assert not owner._starter._started and not created + finally: + await owner.close() + + asyncio.run(scenario()) + + +def test_close_fences_start_and_joins_cancelled_native_waiter(owners, monkeypatch): + owner = coordinator(owners) + entered, release = Event(), Event() + original = owners[0].read + + def held(**kwargs): + entered.set() + assert release.wait(5) + return original(**kwargs) + + monkeypatch.setattr(owners[0], "read", held) + + async def scenario(): + work = asyncio.create_task(owner.ensure_started(deadline=monotonic() + 10)) + closing = None + try: + assert await asyncio.to_thread(entered.wait, 5) + work.cancel() + with pytest.raises(asyncio.CancelledError): + await work + closing = asyncio.create_task(owner.close()) + await asyncio.sleep(0) + assert owner._starter._closing.is_set() and not closing.done() + finally: + release.set() + await owner.close() + await asyncio.gather(work, *([closing] if closing else []), return_exceptions=True) + assert not owner._starter._started and not owner.cleanup_pending + + asyncio.run(scenario()) + + +def test_cancelled_waiter_can_rejoin_same_operation_without_renewal(owners, monkeypatch): + committed(owners) + owner = coordinator(owners) + + async def scenario(): + entered, release = asyncio.Event(), asyncio.Event() + + async def held(lease): + entered.set() + await release.wait() + + created = fake_leases(monkeypatch, held) + deadline = monotonic() + 5 + work = asyncio.create_task(owner.ensure_started(deadline=deadline)) + try: + await asyncio.wait_for(entered.wait(), 3) + original_task = owner._task + work.cancel() + with pytest.raises(asyncio.CancelledError): + await work + assert not original_task.done() + release.set() + result = await owner.ensure_started(deadline=deadline) + assert result is created[0] and len(created) == 1 + assert owner._task is original_task and owner._deadline == deadline + await result.close() + with pytest.raises(AppConnectionClosedError): + await owner.ensure_started(deadline=deadline) + assert len(created) == 1 + finally: + release.set() + await owner.close() + await asyncio.gather(work, return_exceptions=True) + + asyncio.run(scenario()) + + +def test_failed_connection_close_still_closes_starter_and_retains_original(owners, monkeypatch): + committed(owners) + owner = coordinator(owners) + created = fake_leases(monkeypatch) + original = owner._starter.close + closes = [] + + def close(): + closes.append(1) + original() + + monkeypatch.setattr(owner._starter, "close", close) + + async def scenario(): + result = await owner.ensure_started(deadline=monotonic() + 5) + result.close_error = True + try: + with pytest.raises(RuntimeError, match="injected_close"): + await owner.close() + assert closes == [1] and owner._connection is result and owner.cleanup_pending + finally: + result.close_error = False + await owner.close() + assert len(created) == 1 and result.closed and not owner.cleanup_pending + + asyncio.run(scenario()) diff --git a/tests/apphost/test_managed_lifetime.py b/tests/apphost/test_managed_lifetime.py new file mode 100644 index 000000000..fa193ddc7 --- /dev/null +++ b/tests/apphost/test_managed_lifetime.py @@ -0,0 +1,446 @@ +from __future__ import annotations + +import asyncio +import os +import threading +from time import monotonic + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError + +from .test_managed_bootstrap import deployment as deployment +from .test_managed_bootstrap import make_bootstrap +from .test_managed_child import Application, until + + +@pytest.mark.parametrize("busy_method", ["request_child_stop", "record_child_cleanup"]) +def test_transient_stop_receipt_busy_settles_within_original_budget(deployment, tmp_path, monkeypatch, busy_method): + bootstrap, parent = make_bootstrap(deployment, tmp_path) + bootstrap.open(deadline=monotonic() + 5) + app = Application() + child = bootstrap.bind(app) + _, _, journal, state, _ = deployment + journal.register_native(state.handoff.instance, state.handoff.attempt_id, bootstrap._observer.identity) + original = getattr(bootstrap._journal, busy_method) + calls = [] + + def once_busy(*args, **kwargs): + calls.append(1) + if len(calls) == 1: + raise ManagedStorageError("busy") + return original(*args, **kwargs) + + monkeypatch.setattr(bootstrap._journal, busy_method, once_busy) + + async def scenario(): + waiter = asyncio.create_task(bootstrap.run()) + await until(lambda: child.accepting) + app.closed.set() # A normal application stop; no injected app failure. + assert await waiter == 0 + assert len(calls) >= 2 and app.closes == 1 + assert journal.read().evidence.application_cleanup_completed + assert not bootstrap.cleanup_pending + + try: + asyncio.run(asyncio.wait_for(scenario(), 10)) + finally: + parent.close() + bootstrap.close() + + +def test_run_call_matrix_and_cancelled_waiters_do_not_stop_service(deployment, tmp_path): + bootstrap, parent = make_bootstrap(deployment, tmp_path) + + async def before_bind(): + with pytest.raises(ManagedStorageError, match="closed"): + await bootstrap.run() + + asyncio.run(before_bind()) + bootstrap.open(deadline=monotonic() + 5) + asyncio.run(before_bind()) + app = Application() + child = bootstrap.bind(app) + _, _, journal, state, _ = deployment + journal.register_native(state.handoff.instance, state.handoff.attempt_id, bootstrap._observer.identity) + + async def scenario(): + first, second = asyncio.create_task(bootstrap.run()), asyncio.create_task(bootstrap.run()) + try: + await until(lambda: child.accepting) + parent.close() + first.cancel() + with pytest.raises(asyncio.CancelledError): + await first + assert child.accepting and not app.fenced and app.closes == 0 + await child.close() + assert await second == 0 + assert await bootstrap.run() == 0 + assert not bootstrap.cleanup_pending and app.closes == app.prepares == app.activations == 1 + finally: + await child.close(retry_timeout=2) + await asyncio.gather(first, second, return_exceptions=True) + + try: + asyncio.run(asyncio.wait_for(scenario(), 10)) + + async def different_loop(): + with pytest.raises(ManagedStorageError, match="conflict"): + await bootstrap.run() + + asyncio.run(different_loop()) + finally: + parent.close() + bootstrap.close() + + +def test_bootstrap_close_native_job_is_not_replaced_after_waiter_cancel(deployment, tmp_path, monkeypatch): + bootstrap, parent = make_bootstrap(deployment, tmp_path) + bootstrap.open(deadline=monotonic() + 5) + child = bootstrap.bind(Application()) + original_close = bootstrap.close + entered, release = threading.Event(), threading.Event() + calls = [] + + def blocked_close(): + calls.append(1) + entered.set() + assert release.wait(5) + original_close() + + monkeypatch.setattr(bootstrap, "close", blocked_close) + + async def scenario(): + first = asyncio.create_task(bootstrap.run()) + parent.close() # Pre-commit startup abort, then graceful settlement. + await until(entered.is_set) + assert not child.cleanup_pending and bootstrap.cleanup_pending + first.cancel() + with pytest.raises(asyncio.CancelledError): + await first + second = asyncio.create_task(bootstrap.run()) + await asyncio.sleep(0) + assert calls == [1] and not second.done() + release.set() + assert await second == 0 and calls == [1] + assert not bootstrap.cleanup_pending + + try: + asyncio.run(asyncio.wait_for(scenario(), 10)) + finally: + release.set() + parent.close() + original_close() + + +def test_pending_application_cleanup_is_not_replaced_or_closed_underneath(deployment, tmp_path, monkeypatch): + from loushang.apphost.managed import _lifetime as module + + bootstrap, parent = make_bootstrap(deployment, tmp_path) + bootstrap.open(deadline=monotonic() + 5) + gate, entered, pause_gate = asyncio.Event(), asyncio.Event(), asyncio.Queue() + + class SlowApplication(Application): + async def close(self, *, retry_timeout=None): + self.closes += 1 + entered.set() + await gate.wait() + self.closed.set() + + app = SlowApplication() + child = bootstrap.bind(app, settlement_timeout=0.02) + delays = [] + + async def pause(delay): + delays.append(delay) + await pause_gate.get() + + monkeypatch.setattr(module, "_pause", pause) + + async def scenario(): + waiter = asyncio.create_task(bootstrap.run()) + parent.close() + await entered.wait() + await until(lambda: bool(delays)) + original_task = child._application_close + assert child.cleanup_pending and app.closes == 1 and not bootstrap._closing + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + gate.set() + pause_gate.put_nowait(None) + assert await bootstrap.run() == 1 + assert child._application_close is original_task and app.closes == 1 + assert not bootstrap.cleanup_pending + + try: + asyncio.run(asyncio.wait_for(scenario(), 10)) + finally: + parent.close() + bootstrap.close() + + +def test_driver_publication_failure_is_retryable_without_product_effects(deployment, tmp_path, monkeypatch): + from loushang.apphost.managed import _lifetime as module + + bootstrap, parent = make_bootstrap(deployment, tmp_path) + bootstrap.open(deadline=monotonic() + 5) + app = Application() + bootstrap.bind(app) + spawn, pauses = module._spawn, [] + + def fail_once(work): + monkeypatch.setattr(module, "_spawn", spawn) + work.close() + raise RuntimeError("task factory rejected before publication") + + async def pause(delay): + pauses.append(delay) + assert app.prepares == app.closes == 0 + assert bootstrap.cleanup_pending + + monkeypatch.setattr(module, "_spawn", fail_once) + monkeypatch.setattr(module, "_pause", pause) + + async def scenario(): + parent.close() + assert await bootstrap.run() == 1 + assert pauses == [1] and not bootstrap.cleanup_pending + + try: + asyncio.run(asyncio.wait_for(scenario(), 10)) + finally: + parent.close() + bootstrap.close() + + +def test_native_close_unknown_never_returns_or_closes_reused_fd(deployment, tmp_path, monkeypatch): + from loushang.apphost.managed import _lifetime as module + + bootstrap, parent = make_bootstrap(deployment, tmp_path) + bootstrap.open(deadline=monotonic() + 5) + bootstrap.bind(Application()) + observer_close = bootstrap._observer.close + pauses, calls, replacement = [], [], [] + gate = asyncio.Queue() + + def uncertain_close(): + calls.append(1) + observer_close() + replacement.append(os.open("/dev/null", os.O_RDONLY)) + raise OSError("injected uncertainty after native close") + + async def pause(delay): + pauses.append(delay) + await gate.get() + + monkeypatch.setattr(bootstrap._observer, "close", uncertain_close) + monkeypatch.setattr(module, "_pause", pause) + + async def scenario(): + waiter = asyncio.create_task(bootstrap.run()) + parent.close() + for index in range(1, 4): + await until(lambda index=index: len(pauses) == index) + assert not waiter.done() and bootstrap.cleanup_pending + assert calls == [1] + os.fstat(replacement[0]) + if index < 3: + gate.put_nowait(None) + waiter.cancel() + await asyncio.gather(waiter, return_exceptions=True) + # Test-only teardown of an injected permanent-debt driver, while it is + # parked at the pause gate. This is not a production exit/cleanup receipt. + bootstrap._lifetime._task.cancel() + await asyncio.gather(bootstrap._lifetime._task, return_exceptions=True) + + try: + asyncio.run(asyncio.wait_for(scenario(), 10)) + with pytest.raises(ManagedStorageError, match="unavailable"): + bootstrap.close() + assert calls == [1] and bootstrap.cleanup_pending + finally: + parent.close() + for fd in replacement: + os.close(fd) + + +def test_failed_dependency_cleanup_keeps_backoff_and_sticky_failure(deployment, tmp_path, monkeypatch): + from loushang.apphost.managed import _lifetime as module + + bootstrap, parent = make_bootstrap(deployment, tmp_path) + bootstrap.open(deadline=monotonic() + 5) + child = bootstrap.bind(Application()) + original_close = bootstrap.close + delays, calls = [], [] + release = asyncio.Queue() + failing = True + + async def pause(delay): + delays.append(delay) + await release.get() + + def retryable_close(): + calls.append(1) + if failing: + raise ManagedStorageError("unavailable") + original_close() + + monkeypatch.setattr(module, "_pause", pause) + monkeypatch.setattr(bootstrap, "close", retryable_close) + + async def scenario(): + nonlocal failing + first = asyncio.create_task(bootstrap.run()) + parent.close() + for count in range(1, 8): + await until(lambda count=count: len(delays) == count) + assert len(calls) == count and not first.done() + assert not child.cleanup_pending and bootstrap.cleanup_pending + if count < 7: + release.put_nowait(None) + assert delays == [1, 2, 4, 8, 16, 30, 30] + first.cancel() + with pytest.raises(asyncio.CancelledError): + await first + failing = False + release.put_nowait(None) + assert await bootstrap.run() == 1 + assert await bootstrap.run() == 1 + assert not bootstrap.cleanup_pending and len(calls) == 8 + + try: + asyncio.run(asyncio.wait_for(scenario(), 10)) + finally: + parent.close() + original_close() + + +def test_cancelled_offload_task_retains_unknown_after_native_callable_finishes(deployment, tmp_path, monkeypatch): + from loushang.apphost.managed import _lifetime as module + + bootstrap, parent = make_bootstrap(deployment, tmp_path) + bootstrap.open(deadline=monotonic() + 5) + bootstrap.bind(Application()) + original_close = bootstrap.close + entered, release, finished = threading.Event(), threading.Event(), threading.Event() + parked, gate = asyncio.Event(), asyncio.Event() + calls = [] + + def blocked_close(): + calls.append(1) + entered.set() + assert release.wait(5) + try: + original_close() + finally: + finished.set() + + async def pause(delay): + parked.set() + await gate.wait() + + monkeypatch.setattr(bootstrap, "close", blocked_close) + monkeypatch.setattr(module, "_pause", pause) + + async def scenario(): + waiter = asyncio.create_task(bootstrap.run()) + parent.close() + await until(entered.is_set) + lifetime = bootstrap._lifetime + original_task = lifetime._close_task + original_task.cancel() # Private fault injection, not public waiter cancellation. + await parked.wait() + assert lifetime._close_unknown and not waiter.done() + release.set() + await until(finished.is_set) + assert calls == [1] and lifetime._close_task is original_task + assert not waiter.done() and bootstrap.cleanup_pending + assert not bootstrap._resources_pending() # Raw resources alone cannot forge an IO receipt. + waiter.cancel() + await asyncio.gather(waiter, return_exceptions=True) + lifetime._task.cancel() # Test-only teardown at a known pause gate. + await asyncio.gather(lifetime._task, return_exceptions=True) + + try: + asyncio.run(asyncio.wait_for(scenario(), 10)) + finally: + release.set() + parent.close() + with pytest.raises(ManagedStorageError, match="unavailable"): + original_close() + + +def test_offload_task_publication_failure_does_not_start_or_duplicate_close(deployment, tmp_path, monkeypatch): + from loushang.apphost.managed import _lifetime as module + + bootstrap, parent = make_bootstrap(deployment, tmp_path) + bootstrap.open(deadline=monotonic() + 5) + child = bootstrap.bind(Application()) + original_close, spawn = bootstrap.close, module._spawn + schedules, closes, pauses = [], [], [] + + def fail_close_publication(work): + schedules.append(1) + if len(schedules) == 2: # Driver first, then the dependency-close task. + work.close() + raise RuntimeError("close task publication failed") + return spawn(work) + + def close(): + closes.append(1) + original_close() + + async def pause(delay): + pauses.append(delay) + assert not child.cleanup_pending and bootstrap.cleanup_pending and not closes + + monkeypatch.setattr(module, "_spawn", fail_close_publication) + monkeypatch.setattr(module, "_pause", pause) + monkeypatch.setattr(bootstrap, "close", close) + + async def scenario(): + parent.close() + assert await bootstrap.run() == 1 + assert pauses == [1] and closes == [1] and len(schedules) == 3 + + try: + asyncio.run(asyncio.wait_for(scenario(), 10)) + finally: + parent.close() + original_close() + + +def test_prepare_waiter_cancel_does_not_stop_and_business_failure_stays_nonzero(deployment, tmp_path): + bootstrap, parent = make_bootstrap(deployment, tmp_path) + bootstrap.open(deadline=monotonic() + 5) + _, _, journal, state, _ = deployment + journal.register_native(state.handoff.instance, state.handoff.attempt_id, bootstrap._observer.identity) + gate = asyncio.Event() + + class FailingApplication(Application): + async def prepare(self, *, deadline=None): + self.prepares += 1 + self.prepared.set() + await gate.wait() + raise RuntimeError("injected Product startup failure") + + app = FailingApplication() + bootstrap.bind(app) + + async def scenario(): + first = asyncio.create_task(bootstrap.run()) + await app.prepared.wait() + first.cancel() + with pytest.raises(asyncio.CancelledError): + await first + assert not app.fenced and app.closes == 0 and app.prepares == 1 + gate.set() + assert await bootstrap.run() == 1 + assert not bootstrap.cleanup_pending and app.closes == 1 and app.activations == 0 + + try: + asyncio.run(asyncio.wait_for(scenario(), 10)) + finally: + parent.close() + bootstrap.close() diff --git a/tests/apphost/test_managed_mux_closure.py b/tests/apphost/test_managed_mux_closure.py new file mode 100644 index 000000000..ff529d833 --- /dev/null +++ b/tests/apphost/test_managed_mux_closure.py @@ -0,0 +1,638 @@ +"""Real current-instance close permission and historical-name settlement.""" + +from __future__ import annotations + +import asyncio +import json +import os +import sqlite3 +import subprocess +import sys +from dataclasses import asdict, replace +from pathlib import Path +from time import monotonic + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.contracts import ManagedStopEvidenceV1 +from loushang.apphost.managed.mux_management import ManagedMuxManagerV1 +from loushang.apphost.managed.registry import ManagedMuxReservationV1 +from loushang.appserver.managed_mux_close import ManagedMuxClosePhaseV1 +from loushang.appserver.protocol import AppServiceError +from loushang.appservice import ( + AppServiceRecoveryRequestV1, + create_appservice_recovery_attempt, +) +from loushang.appservice.managed_mux_close import ManagedMuxCloseUseV1 +from tests.apphost.test_managed_lifecycle import _identity +from tests.apphost.test_managed_mux_management import _ready +from tests.apphost.test_managed_mux_management import owners as _base_owners +from tests.appservice.test_continuity_runtime import _MemoryLease, _Resolver + +owners = _base_owners +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed Mux closure") + + +def test_authenticated_close_survives_eof_and_query_reconciles_exact_name(owners, tmp_path, monkeypatch): + from loushang.appserver.local import LocalAppClientConnectionV1, LocalAppServerV1 + from loushang.appserver.local_record import ( + LocalConnectionDirectoryV1, + LocalRecordScopeV1, + ) + from loushang.appserver.protocol import SessionScopeV1 + from loushang.appservice.client_scope import ScopedAppServiceV1 + from tests.appserver.test_local import _until + from tests.appservice.test_managed_mux_close_runtime import member + + async def run(): + manager, service, lease, resolver, _, reservation, _, request = await opened(owners) + await member(service, request) + owner = ScopedAppServiceV1(service) + entered, release = asyncio.Event(), asyncio.Event() + original_close, original_cleanup = service.close_managed_mux, resolver.sessions[0].close + calls = 0 + + async def counted(self, value): + nonlocal calls + assert self is service + calls += 1 + return await original_close(value) + + async def held(): + entered.set() + await release.wait() + await original_cleanup() + + monkeypatch.setattr(type(service), "close_managed_mux", counted) + resolver.sessions[0].close = held + directory = LocalConnectionDirectoryV1(tmp_path / "connection") + server = LocalAppServerV1(directory, "managed", application_id="coding.default", product_id="coding", + scopes=(LocalRecordScopeV1(SessionScopeV1.CWD, "a" * 64),), + scope_factory=owner.open_client_scope, managed_mux_scope_factory=owner.open_client_scope, + mux_closure=True, request_stop=lambda _: pytest.fail("EOF must not stop the service")) + first, second = (LocalAppClientConnectionV1(directory, "managed") for _ in range(2)) + waiter = None + try: + await server.start() + await first.start() + assert directory.read("managed").mux_closure + assert await first.managed_mux_close_client.read_managed_mux_close(request) is None + waiter = asyncio.create_task(first.managed_mux_close_client.close_managed_mux(request)) + await asyncio.wait_for(entered.wait(), 3) + await first.close() + await asyncio.gather(waiter, return_exceptions=True) + assert owner.pending_counts == (1, 0) and calls == 1 + await second.start() + pending = await second.managed_mux_close_client.read_managed_mux_close(request) + assert pending.phase is ManagedMuxClosePhaseV1.CLEANUP_PENDING + manager.record_close(request, pending, deadline=monotonic() + 5) + assert owners[0].resolve("dev") == reservation + release.set() + await _until(lambda: owner.pending_counts == (0, 0)) + closed = await second.managed_mux_close_client.read_managed_mux_close(request) + assert closed.phase is ManagedMuxClosePhaseV1.CLOSED and calls == 1 + manager.record_close(request, closed, deadline=monotonic() + 5) + reused = ManagedMuxReservationV1("dev", reservation.service, "e" * 32) + owners[0].reserve_mux(reused) + create = manager.issue_create(reused, deadline=monotonic() + 5) + created = await second.managed_mux_client.create_managed_mux(create) + manager.record_created(create, created, deadline=monotonic() + 5) + assert await second.managed_mux_close_client.read_managed_mux_close(request) == closed + manager.record_close(request, closed, deadline=monotonic() + 5) + assert owners[0].resolve("dev") == reused + assert (await second.client.list_muxes()).mux_spaces[0].mux_space_id == created.mux_space_id + owner.fence() + with pytest.raises(AppServiceError): + await second.managed_mux_close_client.read_managed_mux_close(request) + finally: + release.set() + if waiter is not None: + await asyncio.gather(waiter, return_exceptions=True) + await first.close() + await second.close() + await server.close() + await service.close() + directory.close() + asyncio.run(asyncio.wait_for(run(), 15)) + + +async def opened(owners): + manager, instance, reservation = _ready(owners) + create = manager.issue_create(reservation, deadline=monotonic() + 5) + lease, resolver = _MemoryLease(), _Resolver([]) + binding = replace(manager.binding(), closing=manager.closing_binding()) + attempt = create_appservice_recovery_attempt(AppServiceRecoveryRequestV1( + "coding", resolver, lease, managed_mux=binding, + )) + service = await attempt.open() + created = await service.create_managed_mux(create) + manager.record_created(create, created, deadline=monotonic() + 5) + request = manager.issue_close(reservation, operation_id="d" * 32, deadline=monotonic() + 5) + return manager, service, lease, resolver, instance, reservation, create, request + + +async def other_target(owners, manager, service, *, issue=True): + reservation = ManagedMuxReservationV1("other", owners[3], "e" * 32) + owners[0].reserve_mux(reservation) + create = manager.issue_create(reservation, deadline=monotonic() + 5) + created = await service.create_managed_mux(create) + manager.record_created(create, created, deadline=monotonic() + 5) + request = manager.issue_close(reservation, operation_id="f" * 32, deadline=monotonic() + 5) if issue else None + return reservation, create, request + + +def test_inspection_freezes_confirmed_target_and_optional_close_without_writes(owners, monkeypatch): + from contextlib import contextmanager + + async def scenario(): + manager, service, _, _, _, reservation, create, request = await opened(owners) + try: + other, other_create, _ = await other_target(owners, manager, service, issue=False) + original = type(owners[0]._database).transaction + + @contextmanager + def readonly(database, **kwargs): + assert not kwargs.get("write", False) + with original(database, **kwargs) as connection: + yield connection + + monkeypatch.setattr(type(owners[0]._database), "transaction", readonly) + view = manager.inspect_mux(reservation, deadline=monotonic() + 5) + assert view.creation == manager.read_created(create, deadline=monotonic() + 5) + assert view.close_request == request and view.close_result is None + assert request.authority not in repr(view) + assert manager.inspect_close_operation(request.operation_id, deadline=monotonic() + 5) == view + fresh = await asyncio.to_thread(manager.inspect_mux, other, deadline=monotonic() + 5, wait_for_lock=True) + assert fresh.creation == manager.read_created(other_create, deadline=monotonic() + 5) + assert fresh.close_request is fresh.close_result is None + with pytest.raises(ManagedStorageError, match="not_found"): + manager.inspect_close_operation("9" * 32, deadline=monotonic() + 5) + finally: + monkeypatch.undo() + await service.close() + asyncio.run(scenario()) + + +def test_inspection_rejects_unknown_creation_and_old_active_name_reference(owners): + async def scenario(): + manager, service, _, _, _, reservation, _, request = await opened(owners) + try: + result = await service.close_managed_mux(request) + manager.record_close(request, result, deadline=monotonic() + 5) + reused = ManagedMuxReservationV1(reservation.name, reservation.service, "e" * 32) + owners[0].reserve_mux(reused) + for target in (reservation, reused): + with pytest.raises(ManagedStorageError, match="conflict"): + manager.inspect_mux(target, deadline=monotonic() + 5) + create = manager.issue_create(reused, deadline=monotonic() + 5) + created = await service.create_managed_mux(create) + manager.record_created(create, created, deadline=monotonic() + 5) + assert manager.inspect_mux(reused, deadline=monotonic() + 5).creation == created + with pytest.raises(ManagedStorageError, match="conflict"): + manager.inspect_mux(reservation, deadline=monotonic() + 5) + old = manager.inspect_close_operation(request.operation_id, deadline=monotonic() + 5) + assert old.close_result == result and old.creation.mux_space_id != created.mux_space_id + finally: + await service.close() + asyncio.run(scenario()) + + +def test_historical_close_inspection_survives_recorded_stop_without_renewing(owners): + async def scenario(): + manager, service, _, _, instance, reservation, _, request = await opened(owners) + try: + result = await service.close_managed_mux(request) + manager.record_close(request, result, deadline=monotonic() + 5) + owners[1].request_stop(instance) + assert owners[0].resolve(reservation.name) is None + view = manager.inspect_close_operation(request.operation_id, deadline=monotonic() + 5) + assert view.close_request == request and view.close_result == result + finally: + await service.close() + asyncio.run(scenario()) + + +def test_historical_inspection_keeps_old_permit_after_real_journal_replacement(owners, monkeypatch): + from contextlib import contextmanager + + async def scenario(): + manager, service, _, _, instance, reservation, _, request = await opened(owners) + original_view = manager.inspect_mux(reservation, deadline=monotonic() + 5) + await service.close() + registry, journal, namespace, key = owners + journal.request_stop(instance) + journal.record_stop_evidence(ManagedStopEvidenceV1(instance, True, True, True)) + current = journal.prepare("f" * 32, expected=journal.read()).handoff.instance + journal.register_native(current, "f" * 32, _identity()) + journal.commit(current, "f" * 32, native_identity=_identity()) + replacement = ManagedMuxManagerV1(registry, journal, namespace, key, current, application_id="coding.default") + original = type(registry._database).transaction + + @contextmanager + def readonly(database, **kwargs): + assert not kwargs.get("write", False) + with original(database, **kwargs) as connection: + yield connection + + monkeypatch.setattr(type(registry._database), "transaction", readonly) + for selected in (manager, replacement): + view = selected.inspect_close_operation(request.operation_id, deadline=monotonic() + 5) + assert view == original_view + assert view.close_request.instance_id == instance.instance_id != current.instance_id + assert view.close_request.authority == request.authority + with pytest.raises(ManagedStorageError, match="conflict"): + manager.inspect_mux(reservation, deadline=monotonic() + 5) + assert replacement.inspect_mux(reservation, deadline=monotonic() + 5) == original_view + + asyncio.run(scenario()) + + +def test_result_verification_is_readonly_and_checks_origin_and_monotonicity(owners, monkeypatch): + from contextlib import contextmanager + + async def scenario(): + manager, service, _, _, _, _, _, request = await opened(owners) + try: + closed = await service.close_managed_mux(request) + manager.record_close(request, closed, deadline=monotonic() + 5) + original = type(owners[0]._database).transaction + + @contextmanager + def readonly(database, **kwargs): + assert not kwargs.get("write", False) + with original(database, **kwargs) as connection: + yield connection + + monkeypatch.setattr(type(owners[0]._database), "transaction", readonly) + assert manager.verify_close_result(request, closed, deadline=monotonic() + 5) == closed + for invalid in (replace(closed, instance_id="9" * 32), + replace(closed, phase=ManagedMuxClosePhaseV1.CLEANUP_PENDING)): + with pytest.raises(ManagedStorageError, match="conflict"): + manager.verify_close_result(request, invalid, deadline=monotonic() + 5) + finally: + monkeypatch.undo() + await service.close() + asyncio.run(scenario()) + + +@pytest.mark.parametrize("kind", ["create", "close"]) +@pytest.mark.parametrize("boundary", ["fence", "database"]) +def test_live_admission_waits_for_read_busy_without_repeating_business_effects(owners, monkeypatch, kind, boundary): + from contextlib import contextmanager + + async def scenario(): + manager, service, lease, _, _, reservation, _, close = await opened(owners) + if kind == "create": + new = ManagedMuxReservationV1("other", reservation.service, "e" * 32) + owners[0].reserve_mux(new) + request = manager.issue_create(new, deadline=monotonic() + 5) + operation = service.create_managed_mux + else: + request, operation = close, service.close_managed_mux + before = len(lease.commits) + target, method = (owners[1]._fence, "lock") if boundary == "fence" else (owners[0]._database, "transaction") + original = getattr(target, method) + attempts = 0 + + @contextmanager + def busy_twice(*args, **kwargs): + nonlocal attempts + attempts += 1 + if attempts <= 2: + assert len(lease.commits) == before + raise ManagedStorageError("busy") + with original(*args, **kwargs) as value: + yield value + + try: + with monkeypatch.context() as patch: + patch.setattr(target, method, busy_twice) + result = await operation(request) # One invocation, never replayed by this test. + assert attempts == (3 if kind == "create" else 4) + assert len(lease.commits) == before + (1 if kind == "create" else 2) + assert result.operation_id == request.operation_id + assert service._managed_admission is None + finally: + await service.close() + asyncio.run(scenario()) + + +def test_real_close_releases_only_original_name_and_keeps_historical_result(owners): + async def scenario(): + manager, service, lease, _, _, reservation, create, request = await opened(owners) + registry = owners[0] + try: + assert manager.issue_close(reservation, operation_id=request.operation_id, deadline=monotonic() + 5) == request + assert request.authority != create.authority and request.authority not in repr(request) + before = len(lease.commits) + assert manager.read_close(request, deadline=monotonic() + 5) is None + assert len(lease.commits) == before + result = await service.close_managed_mux(request) + assert registry.resolve(reservation.name) == reservation + assert manager.record_close(request, result, deadline=monotonic() + 5) == result + assert registry.resolve(reservation.name) is None + new = ManagedMuxReservationV1(reservation.name, reservation.service, "e" * 32) + registry.reserve_mux(new) + assert manager.record_close(request, result, deadline=monotonic() + 5) == result + assert manager.read_close(request, deadline=monotonic() + 5) == result + assert registry.resolve(reservation.name) == new + with pytest.raises(ManagedStorageError): + manager.issue_create(reservation, deadline=monotonic() + 5) + with pytest.raises(ManagedStorageError): + manager.record_close(request, replace(result, phase=ManagedMuxClosePhaseV1.CLEANUP_PENDING), deadline=monotonic() + 5) + finally: + await service.close() + + asyncio.run(scenario()) + + +def test_creation_token_and_cross_purpose_operations_are_rejected(owners): + async def scenario(): + manager, service, lease, _, _, reservation, create, request = await opened(owners) + try: + other, _, _ = await other_target(owners, manager, service, issue=False) + before = len(lease.commits) + with pytest.raises((ManagedStorageError, AppServiceError)): + await service.close_managed_mux(replace(request, authority=create.authority)) + assert len(lease.commits) == before + with pytest.raises(ManagedStorageError): + manager.issue_close(other, operation_id=create.operation_id, deadline=monotonic() + 5) + with pytest.raises(ManagedStorageError): + manager.issue_close(reservation, operation_id="f" * 32, deadline=monotonic() + 5) + with pytest.raises(ManagedStorageError): + owners[0].reserve_mux(ManagedMuxReservationV1("fresh", reservation.service, request.operation_id)) + with owners[0]._database.transaction() as connection: + assert connection.execute("SELECT count(*) FROM mux_close_authorities").fetchone() == (1,) + assert len(lease.commits) == before + finally: + await service.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("stage", ["UPDATE mux_close_authorities", "DELETE FROM muxes"]) +def test_closed_record_and_name_delete_rollback_together(owners, monkeypatch, stage): + async def scenario(): + manager, service, _, _, _, reservation, _, request = await opened(owners) + try: + result = await service.close_managed_mux(request) + original_connect = sqlite3.connect + + class Failure(sqlite3.Connection): + def execute(self, sql, parameters=()): + result = super().execute(sql, parameters) + if sql.startswith(stage): + raise sqlite3.OperationalError("delete receipt failed") + return result + + def connect(*args, **kwargs): + return original_connect(*args, factory=Failure, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(sqlite3, "connect", connect) + with pytest.raises(ManagedStorageError): + manager.record_close(request, result, deadline=monotonic() + 5) + assert owners[0].resolve(reservation.name) == reservation + assert manager.read_close(request, deadline=monotonic() + 5) is None + manager.record_close(request, result, deadline=monotonic() + 5) + assert owners[0].resolve(reservation.name) is None + finally: + await service.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("use", [ManagedMuxCloseUseV1.ADMIT, ManagedMuxCloseUseV1.SETTLE]) +def test_old_pending_cannot_use_live_permission_after_instance_replacement(owners, use): + async def scenario(): + manager, service, lease, _, instance, reservation, create, request = await opened(owners) + creation = manager.read_created(create, deadline=monotonic() + 5) + result = await service.close_managed_mux(request) + pending = lease.commits[-2].managed_closures[0] + assert pending.phase is ManagedMuxClosePhaseV1.CLEANUP_PENDING + await service.close() + registry, journal, namespace, key = owners + journal.request_stop(instance) + journal.record_stop_evidence(ManagedStopEvidenceV1(instance, True, True, True)) + new = journal.prepare("f" * 32, expected=journal.read()) + current = new.handoff.instance + journal.register_native(current, "f" * 32, _identity()) + journal.commit(current, "f" * 32, native_identity=_identity()) + replacement = ManagedMuxManagerV1(registry, journal, namespace, key, current, application_id="coding.default") + renewed = replacement.issue_close(reservation, operation_id=request.operation_id, deadline=monotonic() + 5) + assert renewed.authority != request.authority + admission = replacement.prepare_close(renewed, use) + try: + await admission.acquire() + with pytest.raises(ManagedStorageError): + admission.check_closure(creation, None) + with pytest.raises(ManagedStorageError): + admission.check_closure(creation, pending) + finally: + await admission.close() + observer = replacement.prepare_close(renewed, ManagedMuxCloseUseV1.OBSERVE) + try: + await observer.acquire() + observer.check_closure(creation, pending) + observer.check_closure(creation, result) + finally: + await observer.close() + with pytest.raises(ManagedStorageError): + manager.record_close(request, result, deadline=monotonic() + 5) + assert replacement.record_close(renewed, result, deadline=monotonic() + 5) == result + assert result.instance_id == instance.instance_id != renewed.instance_id + assert registry.resolve(reservation.name) is None + + asyncio.run(scenario()) + + +def test_local_pending_settles_after_stop_while_registry_result_is_still_null(owners): + from tests.appservice.test_managed_mux_close_runtime import member + + async def scenario(): + manager, service, _, resolver, instance, reservation, _, request = await opened(owners) + _, _, other_request = await other_target(owners, manager, service) + await member(service, request) + entered, release = asyncio.Event(), asyncio.Event() + original = resolver.sessions[0].close + + async def held(): + entered.set() + await release.wait() + await original() + + resolver.sessions[0].close = held + closing = asyncio.create_task(service.close_managed_mux(request)) + try: + await asyncio.wait_for(entered.wait(), 2) + assert manager.read_close(request, deadline=monotonic() + 5) is None + # A separate process must acquire the actual fence while cleanup + # is held; same-thread RLock reentry would not prove lock release. + script = """ +import os, sys +from pathlib import Path +from loushang.apphost.managed.registry import ManagedRegistryV1 +from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 +from loushang.apphost.managed.contracts import ManagedNamespaceV1, ManagedServiceKeyV1 +root, fence, platform = sys.argv[1:] +namespace = ManagedNamespaceV1(platform, os.geteuid(), 'a'*32) +key = ManagedServiceKeyV1('coding', '/workspace') +registry = ManagedRegistryV1(Path(root), namespace) +journal = ManagedServiceJournalV1(registry, namespace, key, Path(fence)) +try: + journal.request_stop(journal.read().handoff.instance) +finally: + journal.close() + registry.close() +""" + env = dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[2] / "src")) + process = await asyncio.to_thread(subprocess.run, + [sys.executable, "-c", script, str(owners[0]._database._directory._root), + str(owners[1]._fence._root), owners[2].platform_home], + env=env, capture_output=True, timeout=10, + ) + assert process.returncode == 0, process.stderr + state = owners[1].read() + assert state.handoff.instance == instance and state.handoff.stop_requested + with pytest.raises((ManagedStorageError, AppServiceError)): + await service.close_managed_mux(other_request) + assert await service.read_managed_mux_close(other_request) is None + release.set() + result = await closing + assert result.phase is ManagedMuxClosePhaseV1.CLOSED + manager.record_close(request, result, deadline=monotonic() + 5) + assert owners[0].resolve(reservation.name) is None + finally: + release.set() + await asyncio.gather(closing, return_exceptions=True) + await service.close() + + asyncio.run(scenario()) + + +def test_observation_never_mutates_pending_or_closed_records(owners): + async def scenario(): + manager, service, lease, _, _, reservation, create, request = await opened(owners) + assert manager.binding().closing is None + created = manager.read_created(create, deadline=monotonic() + 5) + unset = manager.prepare_close(request, ManagedMuxCloseUseV1.SETTLE) + try: + await unset.acquire() + with pytest.raises(ManagedStorageError): + unset.check_closure(created, None) + finally: + await unset.close() + result = await service.close_managed_mux(request) + pending = lease.commits[-2].managed_closures[0] + path = owners[0]._database._directory._root / "registry.sqlite3" + try: + for state in (None, pending, result): + if state is not None: + manager.record_close(request, state, deadline=monotonic() + 5) + before = (path.read_bytes(), path.stat().st_mtime_ns) + assert manager.read_close(request, deadline=monotonic() + 5) == state + assert await service.read_managed_mux_close(request) == result + assert (path.read_bytes(), path.stat().st_mtime_ns) == before + assert (owners[0].resolve(reservation.name) is not None) is (state != result) + finally: + await service.close() + + asyncio.run(scenario()) + + +def test_commit_success_with_lost_receipt_can_read_original_closed_fact(owners, monkeypatch): + async def scenario(): + manager, service, _, _, _, reservation, _, request = await opened(owners) + try: + result = await service.close_managed_mux(request) + original_connect = sqlite3.connect + + class Failure(sqlite3.Connection): + def commit(self): + super().commit() + raise sqlite3.OperationalError("commit receipt lost") + + def connect(*args, **kwargs): + return original_connect(*args, factory=Failure, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(sqlite3, "connect", connect) + with pytest.raises(ManagedStorageError): + manager.record_close(request, result, deadline=monotonic() + 5) + assert manager.read_close(request, deadline=monotonic() + 5) == result + assert owners[0].resolve(reservation.name) is None + new = ManagedMuxReservationV1(reservation.name, reservation.service, "e" * 32) + owners[0].reserve_mux(new) + assert manager.record_close(request, result, deadline=monotonic() + 5) == result + assert owners[0].resolve(reservation.name) == new + finally: + await service.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("stage", ["UPDATE mux_close_authorities", "DELETE FROM muxes"]) +def test_real_exit_cannot_commit_only_half_of_closed_name_settlement(owners, stage): + from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 + from loushang.apphost.managed.registry import ManagedRegistryV1 + + async def scenario(): + manager, service, _, _, instance, reservation, _, request = await opened(owners) + result = await service.close_managed_mux(request) + await service.close() + registry, journal, namespace, key = owners + root, fence = registry._database._directory._root, journal._fence._root + script = """ +import json, os, sqlite3, sys +from pathlib import Path +from time import monotonic +from loushang.apphost.managed.registry import ManagedRegistryV1, ManagedMuxReservationV1 +from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 +from loushang.apphost.managed.mux_management import ManagedMuxManagerV1 +from loushang.apphost.managed.contracts import ManagedNamespaceV1, ManagedServiceKeyV1 +from loushang.appserver.managed_mux_close import ManagedMuxCloseStateV1, ManagedMuxClosePhaseV1 +root, fence, platform, stage, raw = sys.argv[1:] +namespace = ManagedNamespaceV1(platform, os.geteuid(), 'a'*32) +key = ManagedServiceKeyV1('coding', '/workspace') +registry = ManagedRegistryV1(Path(root), namespace) +journal = ManagedServiceJournalV1(registry, namespace, key, Path(fence)) +manager = ManagedMuxManagerV1(registry, journal, namespace, key, journal.read().handoff.instance, application_id='coding.default') +request = manager.issue_close(ManagedMuxReservationV1('dev', key, 'b'*32), operation_id='d'*32, deadline=monotonic()+5) +value = json.loads(raw) +value['phase'] = ManagedMuxClosePhaseV1(value['phase']) +result = ManagedMuxCloseStateV1(**value) +original = sqlite3.connect +class Crash(sqlite3.Connection): + def execute(self, sql, parameters=()): + result = super().execute(sql, parameters) + if sql.startswith(stage): + os._exit(23) + return result +def connect(*args, **kwargs): + return original(*args, factory=Crash, **kwargs) +sqlite3.connect = connect +manager.record_close(request, result, deadline=monotonic()+5) +os._exit(24) +""" + env = dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[2] / "src")) + process = await asyncio.to_thread(subprocess.run, + [sys.executable, "-c", script, str(root), str(fence), namespace.platform_home, stage, json.dumps(asdict(result))], + env=env, capture_output=True, timeout=10, + ) + assert process.returncode == 23, process.stderr + journal.close() + registry.close() + restored = ManagedRegistryV1(root, namespace, create=True) + recovered_journal = ManagedServiceJournalV1(restored, namespace, key, fence) + try: + recovered = ManagedMuxManagerV1(restored, recovered_journal, namespace, key, instance, application_id="coding.default") + assert restored.resolve(reservation.name) == reservation + assert recovered.read_close(request, deadline=monotonic() + 5) is None + recovered.record_close(request, result, deadline=monotonic() + 5) + assert restored.resolve(reservation.name) is None + finally: + recovered_journal.close() + restored.close() + + asyncio.run(scenario()) diff --git a/tests/apphost/test_managed_mux_creation.py b/tests/apphost/test_managed_mux_creation.py new file mode 100644 index 000000000..463173355 --- /dev/null +++ b/tests/apphost/test_managed_mux_creation.py @@ -0,0 +1,365 @@ +from __future__ import annotations + +import asyncio +from dataclasses import replace +from time import monotonic + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.mux_creation import ManagedMuxCreateOperationV1 +from loushang.apphost.managed.registry import ManagedMuxReservationV1 +from loushang.appserver.client import AppConnectionClosedError +from loushang.appservice.client_scope import ScopedAppServiceV1 +from tests.apphost.test_managed_mux_management import _ready, _service +from tests.apphost.test_managed_mux_management import owners as owners +from tests.appservice.test_continuity_runtime import _MemoryLease + + +def operation(owners, monkeypatch, service, *, create=None): + registry, journal, namespace, key = owners + state = journal.read() + coordinators = [] + + class Lease: + instance = state.handoff.instance + managed_mux_client = service + application_id = "coding.default" + + @property + def client(self): + return service + + class Coordinator: + def __init__(self, *args, **kwargs): + self.starts = self.closes = 0 + self.fenced = self.fail_close = False + self.lease = Lease() + coordinators.append(self) + + async def ensure_started(self, *, deadline): + self.starts += 1 + if create is not None: + await create() + return self.lease + + def fence(self): + self.fenced = True + + async def close(self): + self.closes += 1 + if self.fail_close: + raise ManagedStorageError("unavailable") + + monkeypatch.setattr("loushang.apphost.managed.mux_creation.ManagedServiceCoordinatorV1", Coordinator) + owner = ManagedMuxCreateOperationV1( + registry, journal, namespace, ManagedMuxReservationV1("dev", key, "b" * 32), + runtime_root=namespace.platform_home + "-runtime", endpoint="workspace", application_id="coding.default", + request_factory=lambda *_: pytest.fail("fixture must not launch process"), + ) + return owner, coordinators + + +def test_creation_operation_joins_once_and_closes_only_its_connection_owner(owners, monkeypatch): + async def scenario(): + manager, _, _ = _ready(owners) + lease = _MemoryLease() + service = await _service(manager, lease) + owner, coordinators = operation(owners, monkeypatch, service) + deadline = monotonic() + 5 + try: + one, two = await asyncio.gather(owner.run(deadline=deadline), owner.run(deadline=deadline)) + assert one == two == owner.result + assert len(lease.commits) == 1 and coordinators[0].starts == 1 + assert owner.connection is coordinators[0].lease + with pytest.raises(ManagedStorageError, match="conflict"): + await owner.run(deadline=deadline + 1) + assert owners[0].resolve("dev").operation_id == "b" * 32 + finally: + await owner.close() + await service.close() + assert not owner.cleanup_pending + assert coordinators[0].closes == 1 + assert owners[1].read().handoff.stop_requested is False + assert owners[0].resolve("dev") is not None + + asyncio.run(asyncio.wait_for(scenario(), 10)) + + +def test_conflicting_name_intent_never_starts_service(owners, monkeypatch): + async def scenario(): + manager, _, _ = _ready(owners) + service = await _service(manager, _MemoryLease()) + owner, coordinators = operation(owners, monkeypatch, service) + owner._reservation = replace(owner._reservation, operation_id="e" * 32) + try: + with pytest.raises(ManagedStorageError, match="conflict"): + await owner.run(deadline=monotonic() + 5) + assert coordinators[0].starts == 0 + assert owner.result is None + finally: + await owner.close() + await service.close() + + asyncio.run(scenario()) + + +def test_cancelled_waiter_and_close_join_accepted_creation_before_releasing_connection(owners, monkeypatch): + async def scenario(): + manager, _, reservation = _ready(owners) + entered, release = asyncio.Event(), asyncio.Event() + + class Lease(_MemoryLease): + async def commit(self, *, expected_revision, record): + entered.set() + await release.wait() + await super().commit(expected_revision=expected_revision, record=record) + + lease = Lease() + service = await _service(manager, lease) + owner, coordinators = operation(owners, monkeypatch, service) + deadline = monotonic() + 5 + waiter = asyncio.create_task(owner.run(deadline=deadline)) + closing = None + try: + await entered.wait() + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + closing = asyncio.create_task(owner.close()) + await asyncio.sleep(0) + assert coordinators[0].fenced and coordinators[0].closes == 0 + assert not closing.done() + release.set() + await closing + assert owner.created == owner.result == lease.record.managed_creations[0] + permit = manager.issue_create(reservation, deadline=deadline) + assert manager.read_created(permit, deadline=deadline) == owner.result + assert coordinators[0].closes == 1 + with pytest.raises(ManagedStorageError, match="closed"): + _ = owner.connection + finally: + release.set() + await asyncio.gather(waiter, *(() if closing is None else (closing,)), return_exceptions=True) + await owner.close() + await service.close() + + asyncio.run(asyncio.wait_for(scenario(), 10)) + + +@pytest.mark.parametrize("failure", ("rpc", "reconciliation", "reconciliation_after_commit")) +def test_unknown_reply_or_result_registration_never_replays_rpc_or_releases_name(owners, monkeypatch, failure): + async def scenario(): + manager, _, _ = _ready(owners) + lease = _MemoryLease() + service = await _service(manager, lease) + calls = [] + + class Capability: + async def create_managed_mux(self, request): + calls.append(request) + result = await service.create_managed_mux(request) + if failure == "rpc": + raise AppConnectionClosedError() + return result + + if failure.startswith("reconciliation"): + original = type(manager).record_created + def failed(*args, **kwargs): + if failure == "reconciliation_after_commit": + original(*args, **kwargs) + raise ManagedStorageError("unavailable") + monkeypatch.setattr(type(manager), "record_created", failed) + owner, coordinators = operation(owners, monkeypatch, Capability()) + deadline = monotonic() + 5 + try: + for _ in range(2): + with pytest.raises((ManagedStorageError, AppConnectionClosedError)): + await owner.run(deadline=deadline) + assert len(calls) == len(lease.commits) == 1 + assert coordinators[0].starts == 1 + assert owner.result is None + assert owner.created == (None if failure == "rpc" else lease.record.managed_creations[0]) + assert owners[0].resolve("dev") is not None + assert not owners[1].read().handoff.stop_requested + if failure == "reconciliation_after_commit": + assert manager.read_created(calls[0], deadline=deadline) == owner.created + finally: + await owner.close() + await service.close() + + asyncio.run(asyncio.wait_for(scenario(), 10)) + + +def test_rpc_deadline_does_not_cancel_scoped_accepted_work_or_renew_budget(owners, monkeypatch): + async def scenario(): + manager, _, _ = _ready(owners) + entered, release = asyncio.Event(), asyncio.Event() + + class Lease(_MemoryLease): + async def commit(self, *, expected_revision, record): + entered.set() + await release.wait() + await super().commit(expected_revision=expected_revision, record=record) + + lease = Lease() + service = await _service(manager, lease) + scopes = ScopedAppServiceV1(service) + scope = scopes.open_client_scope() + owner, _ = operation(owners, monkeypatch, scope) + deadline = monotonic() + 0.3 + waiter = asyncio.create_task(owner.run(deadline=deadline)) + try: + await entered.wait() + with pytest.raises(ManagedStorageError, match="unavailable"): + await waiter + assert scopes.pending_counts == (1, 0) + assert not lease.commits + assert owner.created is owner.result is None + with pytest.raises(ManagedStorageError): + await owner.run(deadline=deadline + 5) + await owner.close() + assert owners[0].resolve("dev") is not None + release.set() + finally: + release.set() + await asyncio.gather(waiter, return_exceptions=True) + await owner.close() + await scope.close() + await service.close() + assert len(lease.commits) == 1 + + asyncio.run(asyncio.wait_for(scenario(), 10)) + + +def test_creation_cleanup_failure_retains_original_coordinator_for_retry(owners, monkeypatch): + async def scenario(): + manager, _, _ = _ready(owners) + service = await _service(manager, _MemoryLease()) + owner, coordinators = operation(owners, monkeypatch, service) + await owner.run(deadline=monotonic() + 5) + coordinators[0].fail_close = True + try: + with pytest.raises(ManagedStorageError): + await owner.close() + assert owner.cleanup_pending and len(coordinators) == 1 + assert owners[0].resolve("dev") is not None + coordinators[0].fail_close = False + await owner.close() + assert not owner.cleanup_pending and coordinators[0].closes == 2 + finally: + coordinators[0].fail_close = False + await owner.close() + await service.close() + + asyncio.run(scenario()) + + +def test_result_registration_lost_busy_reply_retries_only_same_control_fact(owners, monkeypatch): + async def scenario(): + manager, _, _ = _ready(owners) + lease = _MemoryLease() + service = await _service(manager, lease) + original = type(manager).record_created + registrations, requests = [], [] + + def lost(self, request, result, **kwargs): + registrations.append((request, result)) + confirmed = original(self, request, result, **kwargs) + if len(registrations) == 1: + raise ManagedStorageError("busy") + return confirmed + + class Capability: + async def create_managed_mux(self, request): + requests.append(request) + return await service.create_managed_mux(request) + + monkeypatch.setattr(type(manager), "record_created", lost) + owner, _ = operation(owners, monkeypatch, Capability()) + try: + result = await owner.run(deadline=monotonic() + 5) + assert owner.created == owner.result == result + assert len(requests) == len(lease.commits) == 1 + assert len(registrations) == 2 and registrations[0] == registrations[1] + finally: + await owner.close() + await service.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("deadline", (None, True, float("nan"), float("inf"), 10**30)) +def test_invalid_creation_deadline_has_no_task_or_storage_effects(owners, monkeypatch, deadline): + async def scenario(): + manager, _, _ = _ready(owners) + service = await _service(manager, _MemoryLease()) + owner, coordinators = operation(owners, monkeypatch, service) + try: + with pytest.raises(ManagedStorageError): + await owner.run(deadline=deadline) + assert owner._task is None and owner._deadline is None + assert coordinators[0].starts == 0 + finally: + await owner.close() + await service.close() + + asyncio.run(scenario()) + + +def test_wrong_authenticated_application_identity_never_issues_permit_or_rpc(owners, monkeypatch): + async def scenario(): + manager, _, _ = _ready(owners) + lease = _MemoryLease() + service = await _service(manager, lease) + owner, _ = operation(owners, monkeypatch, service) + owner._application_id = "coding.other" + try: + with pytest.raises(ManagedStorageError, match="conflict"): + await owner.run(deadline=monotonic() + 5) + assert not lease.commits + with owners[0]._database.transaction() as connection: + assert connection.execute("SELECT count(*) FROM mux_authorities").fetchone() == (0,) + assert owner.created is owner.result is None + finally: + await owner.close() + await service.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("phase", ("run", "close")) +def test_task_publication_failure_has_no_unowned_creation_or_cleanup(owners, monkeypatch, phase): + async def scenario(): + manager, _, _ = _ready(owners) + service = await _service(manager, _MemoryLease()) + owner, coordinators = operation(owners, monkeypatch, service) + loop = asyncio.get_running_loop() + factory = loop.get_task_factory() + abandoned = [] + + def spawn_then_fail(loop, coroutine, **kwargs): + task = asyncio.Task(coroutine, loop=loop, **kwargs) + abandoned.append(task) + raise RuntimeError("task publication failed") + + try: + loop.set_task_factory(spawn_then_fail) + with pytest.raises(RuntimeError, match="publication failed"): + if phase == "run": + await owner.run(deadline=monotonic() + 5) + else: + await owner.close() + finally: + loop.set_task_factory(factory) + await asyncio.gather(*abandoned, return_exceptions=True) + try: + assert coordinators[0].starts == coordinators[0].closes == 0 + assert owner.created is owner.result is None + assert owner.cleanup_pending + finally: + await owner.close() + await service.close() + assert coordinators[0].closes == 1 and not owner.cleanup_pending + + asyncio.run(scenario()) diff --git a/tests/apphost/test_managed_mux_management.py b/tests/apphost/test_managed_mux_management.py new file mode 100644 index 000000000..5d81c2f7c --- /dev/null +++ b/tests/apphost/test_managed_mux_management.py @@ -0,0 +1,462 @@ +"""Real registry/fence consumer of AppService's managed creation capability.""" + +from __future__ import annotations + +import asyncio +import os +import subprocess +import sys +from dataclasses import replace +from pathlib import Path +from time import monotonic + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.contracts import ManagedStopEvidenceV1 +from loushang.apphost.managed.mux_management import ManagedMuxManagerV1 +from loushang.apphost.managed.registry import ManagedMuxReservationV1 +from loushang.appserver.protocol import AppServiceError +from loushang.appservice import ( + AppServiceRecoveryRequestV1, + create_appservice_recovery_attempt, +) +from tests.apphost.test_managed_lifecycle import _identity +from tests.apphost.test_managed_lifecycle import owners as _base_owners +from tests.appservice.test_continuity_runtime import _MemoryLease, _Resolver + +owners = _base_owners + + +def test_real_permit_crosses_authenticated_local_wire_and_settles_registry_receipt(owners, tmp_path): + from loushang.appserver.local import LocalAppClientConnectionV1, LocalAppServerV1 + from loushang.appserver.local_record import ( + LocalConnectionDirectoryV1, + LocalRecordScopeV1, + ) + from loushang.appserver.protocol import SessionScopeV1 + from loushang.appservice.client_scope import ScopedAppServiceV1 + + async def scenario(): + manager, _, reservation = _ready(owners) + request = manager.issue_create(reservation, deadline=monotonic() + 5) + lease = _MemoryLease() + service = await _service(manager, lease) + owner = ScopedAppServiceV1(service) + directory = LocalConnectionDirectoryV1(tmp_path / "connection") + server = LocalAppServerV1( + directory, "managed", application_id="coding.default", product_id="coding", + scopes=(LocalRecordScopeV1(SessionScopeV1.CWD, "a" * 64),), + scope_factory=owner.open_client_scope, managed_mux_scope_factory=owner.open_client_scope, + request_stop=lambda _: pytest.fail("connection close must not stop service"), + ) + client = LocalAppClientConnectionV1(directory, "managed") + try: + await server.start() + await client.start() + capability = client.managed_mux_client + assert capability is not None + with pytest.raises(AppServiceError): + await capability.create_managed_mux(replace(request, authority="forged")) + assert not lease.commits + assert manager.read_created(request, deadline=monotonic() + 5) is None + result = await capability.create_managed_mux(request) + assert manager.record_created(request, result, deadline=monotonic() + 5) == result + assert await capability.create_managed_mux(request) == result + assert manager.read_created(request, deadline=monotonic() + 5) == result + assert len(lease.commits) == 1 + assert request.authority.encode() not in repr(directory.read("managed")).encode() + finally: + await client.close() + await server.close() + await service.close() + directory.close() + + asyncio.run(asyncio.wait_for(scenario(), 15)) + + +def _ready(owners): + registry, journal, namespace, service = owners + state = journal.prepare("c" * 32, expected=None) + instance = state.handoff.instance + journal.register_native(instance, "c" * 32, _identity()) + journal.commit(instance, "c" * 32, native_identity=_identity()) + manager = ManagedMuxManagerV1(registry, journal, namespace, service, instance, + application_id="coding.default") + return manager, instance, ManagedMuxReservationV1("dev", service, "b" * 32) + + +async def _service(manager, lease): + attempt = create_appservice_recovery_attempt(AppServiceRecoveryRequestV1( + "coding", _Resolver([]), lease, managed_mux=manager.binding(), + )) + return await attempt.open() + + +def test_real_management_issues_exact_idempotent_permit_and_records_result(owners): + async def scenario(): + manager, instance, reservation = _ready(owners) + request = manager.issue_create(reservation, deadline=monotonic() + 5) + assert manager.issue_create(reservation, deadline=monotonic() + 5) == request + assert request.instance_id == instance.instance_id + assert request.authority not in repr(request) + assert manager.read_created(request, deadline=monotonic() + 5) is None + lease = _MemoryLease() + service = await _service(manager, lease) + try: + result = await service.create_managed_mux(request) + assert manager.record_created(request, result, deadline=monotonic() + 5) == result + assert manager.record_created(request, result, deadline=monotonic() + 5) == result + assert manager.read_created(request, deadline=monotonic() + 5) == result + with pytest.raises(ManagedStorageError): + manager.record_created(request, replace(result, mux_space_id="another"), + deadline=monotonic() + 5) + assert len(lease.commits) == 1 + finally: + await service.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("field,value", ( + ("name", "unreserved"), ("operation_id", "d" * 32), + ("authority", "forged"), ("instance_id", "e" * 32), ("service_id", "f" * 64), +)) +def test_real_management_rejects_forged_or_mismatched_create_without_commit(owners, field, value): + async def scenario(): + manager, _, reservation = _ready(owners) + permit = manager.issue_create(reservation, deadline=monotonic() + 5) + lease = _MemoryLease() + service = await _service(manager, lease) + try: + with pytest.raises(AppServiceError): + await service.create_managed_mux(replace(permit, **{field: value})) + assert not lease.commits + assert owners[1].read() is not None + finally: + await service.close() + + asyncio.run(scenario()) + + +def test_real_management_creation_holds_only_service_fence_until_commit_settles(owners): + async def scenario(): + manager, instance, reservation = _ready(owners) + request = manager.issue_create(reservation, deadline=monotonic() + 5) + entered, release = asyncio.Event(), asyncio.Event() + + class PausedLease(_MemoryLease): + async def commit(self, *, expected_revision, record): + entered.set() + await release.wait() + await super().commit(expected_revision=expected_revision, record=record) + + lease = PausedLease() + service = await _service(manager, lease) + creation = asyncio.create_task(service.create_managed_mux(request)) + await entered.wait() + try: + # Same service cannot pass the admission's original native lock. + with pytest.raises(ManagedStorageError, match="busy"): + owners[1].request_stop(instance) + # The global registry transaction has already been released. + other = ManagedMuxReservationV1("other", reservation.service, "d" * 32) + assert owners[0].reserve_mux(other) == other + release.set() + result = await creation + owners[1].request_stop(instance) + assert manager.record_created(request, result, deadline=monotonic() + 5) == result + with pytest.raises(AppServiceError): + await service.create_managed_mux(request) + with pytest.raises(ManagedStorageError): + manager.issue_create(other, deadline=monotonic() + 5) + assert len(lease.commits) == 1 + finally: + release.set() + await asyncio.gather(creation, return_exceptions=True) + await service.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("already_recorded", (False, True)) +def test_real_management_reauthorization_keeps_historical_receipt_and_rejects_old_permit(owners, already_recorded, monkeypatch): + async def scenario(): + manager, first_instance, reservation = _ready(owners) + first_request = manager.issue_create(reservation, deadline=monotonic() + 5) + lease = _MemoryLease() + service = await _service(manager, lease) + result = await service.create_managed_mux(first_request) + if already_recorded: + manager.record_created(first_request, result, deadline=monotonic() + 5) + await service.close() + # Simulate the trusted three-fact clean retirement; no native process + # is launched/killed by this fixture. + registry, journal, namespace, key = owners + journal.request_stop(first_instance) + journal.record_stop_evidence(ManagedStopEvidenceV1(first_instance, True, True, True)) + state = journal.prepare("d" * 32, expected=journal.read()) + second_instance = state.handoff.instance + journal.register_native(second_instance, "d" * 32, _identity()) + journal.commit(second_instance, "d" * 32, native_identity=_identity()) + second = ManagedMuxManagerV1(registry, journal, namespace, key, second_instance, + application_id="coding.default") + def no_normal_growth(_connection): + raise ManagedStorageError("capacity") + + monkeypatch.setattr(registry._database, "admit_growth", no_normal_growth) + request = second.issue_create(reservation, deadline=monotonic() + 5) + assert request.authority != first_request.authority + assert second.read_created(request, deadline=monotonic() + 5) == (result if already_recorded else None) + recovered = await _service(second, _MemoryLease(lease.record)) + try: + assert await recovered.create_managed_mux(request) == result + assert second.record_created(request, result, deadline=monotonic() + 5) == result + assert result.instance_id == first_instance.instance_id + for selected in (manager, second): + with pytest.raises(ManagedStorageError): + selected.record_created(first_request, result, deadline=monotonic() + 5) + finally: + await recovered.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("history", ("missing", "conflicting", "unrecorded")) +def test_real_management_reauthorization_cannot_recreate_missing_or_conflicting_history(owners, history): + async def scenario(): + manager, instance, reservation = _ready(owners) + request = manager.issue_create(reservation, deadline=monotonic() + 5) + lease = _MemoryLease() + original = await _service(manager, lease) + result = await original.create_managed_mux(request) + if history != "unrecorded": + manager.record_created(request, result, deadline=monotonic() + 5) + await original.close() + registry, journal, namespace, key = owners + journal.request_stop(instance) + journal.record_stop_evidence(ManagedStopEvidenceV1(instance, True, True, True)) + state = journal.prepare("d" * 32, expected=journal.read()) + replacement = state.handoff.instance + journal.register_native(replacement, "d" * 32, _identity()) + journal.commit(replacement, "d" * 32, native_identity=_identity()) + second = ManagedMuxManagerV1(registry, journal, namespace, key, replacement, + application_id="coding.default") + permit = second.issue_create(reservation, deadline=monotonic() + 5) + record = None + if history == "conflicting": + # Keep this malformed-history fixture internally coherent, so the + # consumer's stronger known-result comparison must reject it. + record = replace(lease.record, + mux_spaces=(replace(lease.record.mux_spaces[0], mux_space_id="different"),), + managed_creations=(replace(result, mux_space_id="different"),)) + fresh_lease = _MemoryLease(record) + recovered = await _service(second, fresh_lease) + try: + with pytest.raises(AppServiceError): + await recovered.create_managed_mux(permit) + assert not fresh_lease.commits + finally: + await recovered.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("closed_before_error", (False, True)) +def test_real_management_acquire_unwind_keeps_unknown_close_debt(tmp_path, monkeypatch, closed_before_error): + from loushang.apphost.managed.contracts import ( + ManagedNamespaceV1, + ManagedServiceKeyV1, + ) + from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 + from loushang.apphost.managed.registry import ManagedRegistryV1 + + async def scenario(): + namespace = ManagedNamespaceV1(str(tmp_path / "platform"), os.geteuid(), "a" * 32) + key = ManagedServiceKeyV1("coding", "/workspace") + registry = ManagedRegistryV1(tmp_path / "registry", namespace, create=True) + registry.reserve_mux(ManagedMuxReservationV1("dev", key, "b" * 32)) + journal = ManagedServiceJournalV1(registry, namespace, key, tmp_path / "fence", create=True) + manager, _, reservation = _ready((registry, journal, namespace, key)) + request = manager.issue_create(reservation, deadline=monotonic() + 5) + service = await _service(manager, _MemoryLease()) + fence = journal._fence + original_open, original_check, original_close = fence._open, fence._check_named, os.close + target: int | None = None + replacement: int | None = None + close_calls = 0 + + def capture(name, *args, **kwargs): + nonlocal target + value = original_open(name, *args, **kwargs) + if name == "lifecycle.lock": + target = value + return value + + def reject(name, identity): + if name == "lifecycle.lock" and target is not None: + raise ManagedStorageError("conflict") + return original_check(name, identity) + + def interrupted_close(fd): + nonlocal replacement, close_calls + if fd != target: + return original_close(fd) + close_calls += 1 + if closed_before_error: + original_close(fd) + replacement = os.open("/dev/null", os.O_RDONLY) + assert replacement == fd + raise KeyboardInterrupt("injected close receipt loss") + + try: + with monkeypatch.context() as patch: + patch.setattr(fence, "_open", capture) + patch.setattr(fence, "_check_named", reject) + patch.setattr(os, "close", interrupted_close) + with pytest.raises(AppServiceError): + await service.create_managed_mux(request) + assert close_calls == 1 + assert target in fence._uncertain_closes + assert service._managed_admission is not None + for _ in range(2): + with pytest.raises(AppServiceError): + await service.close() + assert close_calls == 1 + assert target is not None + os.fstat(target) + with pytest.raises(ManagedStorageError): + journal.close() + assert target in fence._uncertain_closes + os.fstat(target) + finally: + # Test instrumentation alone knows whether this exact descriptor + # is the original still-open lock or our /dev/null replacement. + # Production owners never clear the unknown-close ledger or retry. + if target is not None: + original_close(target) + registry.close() + + asyncio.run(scenario()) + + +def test_real_management_native_competing_stop_waits_for_cancelled_create(owners, tmp_path): + script = """ +import os, sys +from pathlib import Path +from loushang.apphost.managed.registry import ManagedRegistryV1 +from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 +from loushang.apphost.managed.contracts import ManagedNamespaceV1, ManagedServiceKeyV1, ManagedInstanceRefV1 +from loushang.apphost.managed._files import ManagedStorageError +namespace = ManagedNamespaceV1(sys.argv[2], os.geteuid(), 'a'*32) +service = ManagedServiceKeyV1('coding', '/workspace') +registry = ManagedRegistryV1(Path(sys.argv[1])/'registry', namespace) +journal = ManagedServiceJournalV1(registry, namespace, service, Path(sys.argv[1])/'fence') +try: + journal.request_stop(ManagedInstanceRefV1(namespace.namespace_key, service.service_id, sys.argv[3])) + print('stopped') +except ManagedStorageError as error: + print(error.code) +finally: + journal.close() + registry.close() +""" + + async def scenario(): + manager, instance, reservation = _ready(owners) + request = manager.issue_create(reservation, deadline=monotonic() + 5) + entered, release = asyncio.Event(), asyncio.Event() + + class PausedLease(_MemoryLease): + async def commit(self, *, expected_revision, record): + entered.set() + await release.wait() + await super().commit(expected_revision=expected_revision, record=record) + + lease = PausedLease() + service = await _service(manager, lease) + task = asyncio.create_task(service.create_managed_mux(request)) + await entered.wait() + + def external_stop(): + env = dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[2] / "src")) + result = subprocess.run( + [sys.executable, "-c", script, str(tmp_path), owners[2].platform_home, instance.instance_id], + env=env, capture_output=True, text=True, timeout=10, + ) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + try: + task.cancel() + await asyncio.sleep(0) + assert not task.done() + assert external_stop() == "busy" + release.set() + with pytest.raises(asyncio.CancelledError): + await task + assert len(lease.commits) == 1 + assert external_stop() == "stopped" + with pytest.raises(AppServiceError): + await service.create_managed_mux(request) + finally: + release.set() + await asyncio.gather(task, return_exceptions=True) + await service.close() + + asyncio.run(scenario()) + + +def test_real_management_expired_and_unreserved_issuance_never_creates_authority(owners): + manager, _, reservation = _ready(owners) + for selected, deadline in ( + (reservation, monotonic() - 1), + (replace(reservation, operation_id="f" * 32), monotonic() + 5), + (replace(reservation, name="unreserved"), monotonic() + 5), + ): + with pytest.raises(ManagedStorageError): + manager.issue_create(selected, deadline=deadline) + with owners[0]._database.transaction() as connection: + assert connection.execute("SELECT count(*) FROM mux_authorities").fetchone()[0] == 0 + + +def test_real_management_lost_registry_replies_do_not_rotate_permit_or_result(owners, monkeypatch): + from contextlib import contextmanager + + async def scenario(): + manager, _, reservation = _ready(owners) + database = owners[0]._database + original = database.transaction + lose_reply = False + + @contextmanager + def transaction(*args, **kwargs): + nonlocal lose_reply + with original(*args, **kwargs) as connection: + yield connection + if lose_reply and kwargs.get("write"): + lose_reply = False + raise ManagedStorageError("unavailable") + + monkeypatch.setattr(database, "transaction", transaction) + lose_reply = True + with pytest.raises(ManagedStorageError): + manager.issue_create(reservation, deadline=monotonic() + 5) + with original() as connection: + authority = connection.execute("SELECT authority FROM mux_authorities").fetchone()[0] + request = manager.issue_create(reservation, deadline=monotonic() + 5) + assert request.authority == authority + lease = _MemoryLease() + service = await _service(manager, lease) + try: + result = await service.create_managed_mux(request) + lose_reply = True + with pytest.raises(ManagedStorageError): + manager.record_created(request, result, deadline=monotonic() + 5) + assert manager.read_created(request, deadline=monotonic() + 5) == result + assert manager.record_created(request, result, deadline=monotonic() + 5) == result + assert len(lease.commits) == 1 + finally: + await service.close() + + asyncio.run(scenario()) diff --git a/tests/apphost/test_managed_mux_probe.py b/tests/apphost/test_managed_mux_probe.py new file mode 100644 index 000000000..097800ec3 --- /dev/null +++ b/tests/apphost/test_managed_mux_probe.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import asyncio +from dataclasses import replace +from time import monotonic +from types import SimpleNamespace + +import pytest + +from loushang.apphost.managed import mux_probe as module +from loushang.apphost.managed.contracts import ManagedServiceKeyV1 +from loushang.apphost.managed.registry import ManagedMuxReservationV1 +from loushang.appserver.protocol import AppErrorCodeV1, AppServiceError, MuxSpaceV1 + +from .test_managed_connection import committed +from .test_managed_discovery import discovery as discovery +from .test_managed_discovery import owners as owners +from .test_managed_discovery import pytestmark as pytestmark + + +@pytest.fixture +def probe(owners, discovery, monkeypatch): + committed(owners) + reader, registry = discovery + registry.reserve_mux(ManagedMuxReservationV1("second", owners[2], "d" * 32)) + leases = [] + behavior = SimpleNamespace(absent=False, failed=False, close_failed=False, gate=None, close_gate=None) + + class Lease: + def __init__(self, *args, **kwargs): + self.client = self + self.application_id = "coding.default" + self.closed = False + leases.append(self) + + async def prepare(self, **kwargs): + if behavior.gate is not None: + await behavior.gate.wait() + if behavior.failed: + raise AppServiceError(AppErrorCodeV1.SERVICE_CLOSED) + + async def read_mux(self, request): + name = request.selector.mux_space_id + if name == "second" and behavior.absent: + raise AppServiceError(AppErrorCodeV1.NOT_FOUND) + return MuxSpaceV1(name, name, 1) + + async def close(self): + if behavior.close_gate is not None: + await behavior.close_gate.wait() + if behavior.close_failed: + raise RuntimeError("injected close failure") + self.closed = True + + class Manager: + def __init__(self, *args, **kwargs): + pass + + def inspect_mux(self, reservation, **kwargs): + return SimpleNamespace(creation=SimpleNamespace(mux_space_id=reservation.name)) + + monkeypatch.setattr(module, "ManagedConnectionLeaseV1", Lease) + monkeypatch.setattr(module, "ManagedMuxManagerV1", Manager) + operation = module.ManagedMuxProbeOperationV1(registry, owners[1], product_id="coding", + runtime_root=owners[3], endpoint="workspace", expected_application_id="coding.default", + deadline=monotonic() + 10) + return operation, leases, behavior + + +@pytest.mark.parametrize("outcome", ["both", "absent", "failed"]) +def test_one_authentication_per_service_and_exact_mux_results(probe, outcome): + operation, leases, behavior = probe + behavior.absent = outcome == "absent" + behavior.failed = outcome == "failed" + async def run(): + try: + result = await operation.run() + assert result.candidates_unchanged + assert len(result.results) == 2 + if outcome == "failed": + assert all(row.status == "unknown" for row in result.results) + elif outcome == "absent": + assert result.unique_present.observation.name == "main" + else: + assert all(row.status == "authenticated_present" for row in result.results) + if outcome != "absent": + assert result.unique_present is None + assert len(leases) == 1 and leases[0].closed + finally: + await operation.close() + asyncio.run(run()) + assert not operation.cleanup_pending + + +def test_cleanup_failure_prevents_results_and_retains_original_owner(probe): + operation, leases, behavior = probe + behavior.close_failed = True + async def run(): + with pytest.raises(RuntimeError, match="injected close"): + await operation.run() + assert operation.cleanup_pending and operation._connection is leases[0] + assert operation._journal is not None + behavior.close_failed = False + await operation.close() + asyncio.run(run()) + assert not operation.cleanup_pending and leases[0].closed + + +def test_cancelled_waiter_rejoins_original_probe(probe): + operation, leases, behavior = probe + async def run(): + behavior.gate = asyncio.Event() + waiter = asyncio.create_task(operation.run()) + try: + async with asyncio.timeout(5): + while not leases: + await asyncio.sleep(0) + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + assert not operation._task.done() + behavior.gate.set() + assert (await operation.run()).candidates_unchanged + assert len(leases) == 1 + finally: + behavior.gate.set() + await operation.close() + asyncio.run(run()) + + +def test_journal_close_failure_retains_journal_without_reopening_connection(probe, monkeypatch): + operation, leases, _ = probe + original_close = module.ManagedServiceJournalV1.close + failed_journals = [] + fail = [True] + + def close(journal): + if fail[0]: + failed_journals.append(journal) + raise RuntimeError("injected journal close failure") + return original_close(journal) + + monkeypatch.setattr(module.ManagedServiceJournalV1, "close", close) + + async def run(): + try: + with pytest.raises(RuntimeError, match="injected journal close"): + await operation.run() + retained = operation._journal + assert retained is failed_journals[0] + assert operation._connection is None + assert len(leases) == 1 and leases[0].closed + assert operation.cleanup_pending + # Rejoining the failed original task cannot authenticate again or + # turn its discarded observations into a successful result. + with pytest.raises(RuntimeError, match="injected journal close"): + await operation.run() + assert len(leases) == 1 and operation._journal is retained + finally: + fail[0] = False + await operation.close() + assert operation._journal is None and not operation.cleanup_pending + assert len(leases) == 1 + + asyncio.run(run()) + + +def test_changed_candidate_set_prevents_automatic_choice(probe, monkeypatch): + operation, _, behavior = probe + behavior.absent = True + original = operation._snapshot + calls = [] + def snapshot(): + calls.append(1) + if len(calls) == 2: + service = original()[0].service + operation._registry.reserve_mux(ManagedMuxReservationV1("late", service, "e" * 32)) + return original() + monkeypatch.setattr(operation, "_snapshot", snapshot) + async def run(): + try: + result = await operation.run() + assert not result.candidates_unchanged + assert result.unique_present is None + assert [row.observation.name for row in result.results] == ["main", "second"] + finally: + await operation.close() + asyncio.run(run()) + + +def test_exhausted_first_service_budget_never_admits_second(probe, monkeypatch): + operation, _, _ = probe + first = operation._snapshot()[0] + service = ManagedServiceKeyV1("coding", "/another-probe-workspace") + second = replace(first, reservation=ManagedMuxReservationV1("other", service, "e" * 32), + instance=replace(first.instance, service_id=service.service_id)) + monkeypatch.setattr(operation, "_snapshot", lambda: (first, second)) + clock = [monotonic()] + monkeypatch.setattr(module, "monotonic", lambda: clock[0]) + admitted = [] + async def group(items, results): + admitted.append(items[0].service) + clock[0] = operation._deadline + 1 + monkeypatch.setattr(operation, "_probe_group", group) + async def run(): + try: + result = await operation.run() + assert admitted == [first.service] + assert not result.candidates_unchanged and result.unique_present is None + assert all(row.status == "unknown" for row in result.results) + finally: + await operation.close() + asyncio.run(run()) + + +def test_close_timeout_retains_task_and_journal_until_original_cleanup_finishes(probe): + operation, leases, behavior = probe + async def run(): + behavior.close_gate = asyncio.Event() + waiter = asyncio.create_task(operation.run()) + try: + async with asyncio.timeout(5): + while operation._connection is None or operation._journal is None: + await asyncio.sleep(0) + operation._close_deadline = monotonic() + 0.02 + with pytest.raises(TimeoutError): + await operation.close() + closing = operation._close_task + assert not closing.done() and operation._journal is not None + assert operation.cleanup_pending and not leases[0].closed + behavior.close_gate.set() + with pytest.raises(module.ManagedStorageError, match="closed"): + await waiter + await asyncio.shield(closing) + await operation.close() + assert operation._close_task is closing + assert operation._journal is None and not operation.cleanup_pending + finally: + behavior.close_gate.set() + await asyncio.gather(waiter, return_exceptions=True) + await operation.close() + asyncio.run(run()) + + +@pytest.mark.parametrize("late_delivery", [False, True]) +def test_expired_result_never_authorizes_automatic_selection(probe, monkeypatch, late_delivery): + operation, _, behavior = probe + behavior.absent = True + original = operation._run_once + async def complete(): + result = await original() + if late_delivery: + operation._deadline = monotonic() - 1 + return result + monkeypatch.setattr(operation, "_run_once", complete) + async def run(): + try: + first = await operation.run() + if not late_delivery: + assert first.unique_present is not None + operation._deadline = monotonic() - 1 + first = await operation.run() + assert not first.candidates_unchanged and first.unique_present is None + finally: + await operation.close() + asyncio.run(run()) diff --git a/tests/apphost/test_managed_mux_recovery.py b/tests/apphost/test_managed_mux_recovery.py new file mode 100644 index 000000000..67be8c5f9 --- /dev/null +++ b/tests/apphost/test_managed_mux_recovery.py @@ -0,0 +1,712 @@ +"""Startup recovery against the original registry and real AppService attempt.""" + +from __future__ import annotations + +import asyncio +import sys +from contextlib import contextmanager +from dataclasses import replace +from time import monotonic + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.contracts import ManagedStopEvidenceV1 +from loushang.apphost.managed.mux_management import ManagedMuxManagerV1 +from loushang.apphost.managed.registry import ManagedMuxReservationV1 +from loushang.appserver.managed_mux_close import ManagedMuxClosePhaseV1 +from loushang.appserver.protocol import ( + AppServiceError, + MuxMemberOpenV1, + MuxSelectorV1, + SessionOpenSpecV1, + SessionScopeV1, +) +from loushang.appservice import ( + AppServiceRecoveryRequestV1, + create_appservice_recovery_attempt, +) +from loushang.appservice.continuity import MANAGED_CONTINUITY_VERSION +from loushang.appservice.managed_mux_close import ManagedMuxCloseUseV1 +from tests.apphost.test_managed_lifecycle import _identity +from tests.apphost.test_managed_mux_closure import opened, other_target +from tests.apphost.test_managed_mux_management import _ready +from tests.apphost.test_managed_mux_management import owners as _base_owners +from tests.appservice.test_continuity_runtime import _MemoryLease, _Resolver + +owners = _base_owners +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux startup recovery") + + +async def member(service, request, title="one", *, session_id=None): + return await service.open_member(MuxMemberOpenV1( + MuxSelectorV1(mux_space_id=request.mux_space_id), + SessionOpenSpecV1( + "coding", "continuity-1", SessionScopeV1.CWD, "a" * 64, + title, session_id=session_id, + ), + )) + + +def successor(owners, *, native=True, attempt="f" * 32): + registry, journal, namespace, key = owners + previous = journal.read() + if previous is not None: + journal.request_stop(previous.handoff.instance) + journal.record_stop_evidence(ManagedStopEvidenceV1(previous.handoff.instance, True, True, True)) + state = journal.prepare(attempt, expected=journal.read()) + if native: + journal.register_native(state.handoff.instance, attempt, _identity()) + return ManagedMuxManagerV1(registry, journal, namespace, key, state.handoff.instance, + application_id="coding.default", startup_attempt_id=attempt, + startup_native_identity=_identity()) + + +def recovery(manager, lease, resolver=None): + return create_appservice_recovery_attempt(AppServiceRecoveryRequestV1( + "coding", resolver or _Resolver([]), lease, managed_mux=manager.binding(), + )) + + +def test_initial_none_is_admitted_without_writes(owners): + async def scenario(): + manager = successor(owners) + lease = _MemoryLease() + service = await recovery(manager, lease).open() + try: + assert manager.binding().closing.recovery is not None + assert not lease.commits + finally: + await service.close() + asyncio.run(scenario()) + + +@pytest.mark.parametrize("version", ["v2", "v3"]) +def test_real_creation_restores_before_commit_without_rewriting(owners, version): + async def scenario(): + _, service, lease, _, _, _, _, _ = await opened(owners) + await service.close() + if version == "v2": + lease.record = replace(lease.record, contract_version=MANAGED_CONTINUITY_VERSION) + original, commits = lease.record, len(lease.commits) + manager = successor(owners) + restored = await recovery(manager, lease).open() + try: + assert len((await restored.list_muxes()).mux_spaces) == 1 + assert lease.record == original and len(lease.commits) == commits + assert owners[1].read().handoff.phase.value == "provisional" + finally: + await restored.close() + asyncio.run(scenario()) + + +def test_explicit_close_continues_unaccepted_intent_after_proven_recovery(owners): + async def scenario(): + _, old_service, lease, _, _, reservation, _, request = await opened(owners) + await member(old_service, request) + await old_service.close() + manager = successor(owners) + events = [] + resolver = _Resolver(events) + service = await recovery(manager, lease, resolver).open() + owners[1].commit(manager._instance, "f" * 32, native_identity=_identity()) + try: + renewed = manager.issue_close(reservation, operation_id=request.operation_id, deadline=monotonic() + 5) + assert renewed.instance_id != request.instance_id + assert await service.read_managed_mux_close(renewed) is None + before = len(lease.commits) + result = await service.close_managed_mux(renewed) + assert result.phase is ManagedMuxClosePhaseV1.CLOSED + assert result.instance_id == request.instance_id != renewed.instance_id + assert len(lease.commits) == before + 2 + assert [item.managed_closures[0].instance_id for item in lease.commits[before:]] == [request.instance_id] * 2 + assert events.count("close:" + resolver.sessions[0].identity.session_id) == 1 + assert await service.close_managed_mux(renewed) == result + assert len(lease.commits) == before + 2 + assert manager.record_close(renewed, result, deadline=monotonic() + 5) == result + assert owners[0].resolve(reservation.name) is None + finally: + await service.close() + asyncio.run(scenario()) + + +def test_cross_origin_pending_requires_original_live_admission_and_allows_stop(owners): + from loushang.appserver.managed_mux_close import ManagedMuxCloseStateV1 + + async def scenario(): + _, old_service, lease, _, _, reservation, _, request = await opened(owners) + await member(old_service, request) + await old_service.close() + manager = successor(owners) + resolver = _Resolver([]) + service = await recovery(manager, lease, resolver).open() + owners[1].commit(manager._instance, "f" * 32, native_identity=_identity()) + renewed = manager.issue_close(reservation, operation_id=request.operation_id, deadline=monotonic() + 5) + creation = manager.inspect_mux(reservation, deadline=monotonic() + 5).creation + expected = ManagedMuxCloseStateV1(request.operation_id, request.instance_id, + creation.operation_id, creation.name, creation.mux_space_id, ManagedMuxClosePhaseV1.CLEANUP_PENDING) + admission = manager.prepare_close(renewed, ManagedMuxCloseUseV1.SETTLE) + try: + await admission.acquire() + with pytest.raises(ManagedStorageError): + admission.check_closure(creation, expected) + finally: + await admission.close() + entered, release = asyncio.Event(), asyncio.Event() + original = resolver.sessions[0].close + + async def held(): + entered.set() + await release.wait() + await original() + + resolver.sessions[0].close = held + task = asyncio.create_task(service.close_managed_mux(renewed)) + try: + await asyncio.wait_for(entered.wait(), 2) + assert await service.read_managed_mux_close(renewed) == expected + other_manager = ManagedMuxManagerV1(*owners[:2], owners[2], owners[3], manager._instance, + application_id="coding.default") + for use in (ManagedMuxCloseUseV1.ADMIT, ManagedMuxCloseUseV1.SETTLE): + other = other_manager.prepare_close(renewed, use) + try: + await other.acquire() + with pytest.raises(ManagedStorageError): + other.check_closure(creation, expected) + finally: + await other.close() + owners[1].request_stop(manager._instance) + release.set() + result = await task + assert result.phase is ManagedMuxClosePhaseV1.CLOSED + assert manager.record_close(renewed, result, deadline=monotonic() + 5) == result + finally: + release.set() + await asyncio.gather(task, return_exceptions=True) + await service.close() + asyncio.run(scenario()) + + +@pytest.mark.parametrize("fault", ["not_admitted", "missing_record", "missing_creation", "missing_mux", + "old_pending", "old_closed", "recorded_pending", "capacity"]) +def test_cross_origin_continuation_rejects_missing_proof_before_pending_commit(owners, monkeypatch, fault): + from loushang.apphost.managed import mux_management + from loushang.appserver.managed_mux_close import ManagedMuxCloseStateV1 + + async def scenario(): + _, old_service, lease, _, _, reservation, _, request = await opened(owners) + await old_service.close() + manager = successor(owners) + service = await recovery(manager, lease).open() + owners[1].commit(manager._instance, "f" * 32, native_identity=_identity()) + renewed = manager.issue_close(reservation, operation_id=request.operation_id, deadline=monotonic() + 5) + record = manager._recovery_record + pending = ManagedMuxCloseStateV1(request.operation_id, request.instance_id, + request.creation_operation_id, request.name, request.mux_space_id, ManagedMuxClosePhaseV1.CLEANUP_PENDING) + if fault == "not_admitted": + manager._recovery_admitted = False + elif fault == "missing_record": + manager._recovery_record = None + elif fault == "missing_creation": + manager._recovery_record = replace(record, managed_creations=(), mux_spaces=()) + elif fault == "missing_mux": + manager._recovery_record = replace(record, mux_spaces=()) + elif fault == "old_pending": + manager._recovery_record = replace(record, managed_closures=(pending,)) + elif fault == "old_closed": + manager._recovery_record = replace(record, mux_spaces=(), + managed_closures=(replace(pending, phase=ManagedMuxClosePhaseV1.CLOSED),)) + elif fault == "recorded_pending": + manager.record_close(renewed, pending, deadline=monotonic() + 5) + else: + monkeypatch.setattr(mux_management, "MAX_MUXES", 0) + before = len(lease.commits) + try: + with pytest.raises(AppServiceError): + await service.close_managed_mux(renewed) + assert len(lease.commits) == before + assert not manager._continued_closures + assert len((await service.list_muxes()).mux_spaces) == 1 + assert owners[0].resolve(reservation.name) == reservation + finally: + await service.close() + asyncio.run(scenario()) + + +def test_cross_origin_pending_commit_ack_loss_keeps_unknown_even_after_admission(owners): + class LostReceipt(_MemoryLease): + async def commit(self, *, expected_revision, record): + await super().commit(expected_revision=expected_revision, record=record) + raise RuntimeError("test pending receipt lost") + + async def scenario(): + _, old_service, old_lease, _, _, reservation, _, request = await opened(owners) + await member(old_service, request) + await old_service.close() + manager = successor(owners) + events = [] + lease = LostReceipt(old_lease.record) + service = await recovery(manager, lease, _Resolver(events)).open() + owners[1].commit(manager._instance, "f" * 32, native_identity=_identity()) + renewed = manager.issue_close(reservation, operation_id=request.operation_id, deadline=monotonic() + 5) + with pytest.raises(AppServiceError): + await service.close_managed_mux(renewed) + assert manager._continued_closures and service._managed_uncertain + assert lease.record.managed_closures[0].instance_id == request.instance_id + assert lease.record.managed_closures[0].phase is ManagedMuxClosePhaseV1.CLEANUP_PENDING + assert not any(event.startswith("close:") for event in events) + for call in (lambda: service.read_managed_mux_close(renewed), + lambda: service.close_managed_mux(renewed), service.close): + with pytest.raises(AppServiceError): + await call() + assert owners[0].resolve(reservation.name) == reservation + # Settle fake ports only; do not clear unknown state or manufacture a + # successful service close to make teardown look successful. + await service._close_sessions(tuple(service._sessions.values())) + asyncio.run(scenario()) + + +def test_cross_origin_cancelled_admission_release_preserves_original_cleanup(owners, monkeypatch): + from loushang.apphost.managed.mux_management import ManagedMuxCloseAdmission + + async def scenario(): + _, old_service, lease, _, _, reservation, _, request = await opened(owners) + await member(old_service, request) + await old_service.close() + manager = successor(owners) + events = [] + resolver = _Resolver(events) + service = await recovery(manager, lease, resolver).open() + owners[1].commit(manager._instance, "f" * 32, native_identity=_identity()) + renewed = manager.issue_close(reservation, operation_id=request.operation_id, deadline=monotonic() + 5) + entered, release, cleaned = asyncio.Event(), asyncio.Event(), asyncio.Event() + original_release, original_close = ManagedMuxCloseAdmission.close, resolver.sessions[0].close + + async def held(owner): + if owner._manager is manager and owner._use is ManagedMuxCloseUseV1.ADMIT: + entered.set() + await release.wait() + await original_release(owner) + + async def closed(): + await original_close() + cleaned.set() + + monkeypatch.setattr(ManagedMuxCloseAdmission, "close", held) + resolver.sessions[0].close = closed + task = asyncio.create_task(service.close_managed_mux(renewed)) + try: + await asyncio.wait_for(entered.wait(), 2) + assert manager._continued_closures[request.operation_id] == lease.record.managed_closures[0] + task.cancel() + release.set() + with pytest.raises(asyncio.CancelledError): + await task + await asyncio.wait_for(cleaned.wait(), 2) + assert events.count("close:" + resolver.sessions[0].identity.session_id) == 1 + finally: + release.set() + await asyncio.gather(task, return_exceptions=True) + await service.close() + assert lease.record.managed_closures[0].phase is ManagedMuxClosePhaseV1.CLOSED + asyncio.run(scenario()) + + +def test_missing_whole_record_cannot_erase_confirmed_history(owners): + async def scenario(): + _, service, _, _, _, _, _, _ = await opened(owners) + await service.close() + manager = successor(owners) + lease, resolver = _MemoryLease(), _Resolver([]) + attempt = recovery(manager, lease, resolver) + try: + with pytest.raises(AppServiceError): + await attempt.open() + assert not resolver.requests and not lease.commits + finally: + await attempt.close() + asyncio.run(scenario()) + + +@pytest.mark.parametrize("fault", ["attempt", "native", "unregistered", "stop", "committed"]) +def test_startup_identity_and_phase_are_required_before_restore(owners, fault): + async def scenario(): + _, service, lease, _, _, _, _, _ = await opened(owners) + await service.close() + manager = successor(owners, native=fault != "unregistered") + if fault == "attempt": + manager._startup_attempt = "e" * 32 + elif fault == "native": + manager._startup_native = replace(_identity(), start_ticks=101) + elif fault == "stop": + owners[1].request_stop(manager._instance) + elif fault == "committed": + owners[1].commit(manager._instance, "f" * 32, native_identity=_identity()) + resolver = _Resolver([]) + before = len(lease.commits) + attempt = recovery(manager, lease, resolver) + try: + with pytest.raises(AppServiceError): + await attempt.open() + assert not resolver.requests and len(lease.commits) == before + finally: + await attempt.close() + asyncio.run(scenario()) + + +def test_settlement_requires_original_admit_and_allows_same_instance_stop(owners): + async def scenario(): + _, service, lease, _, _, _, _, _ = await opened(owners) + await service.close() + manager = successor(owners) + async def check(use, allowed): + admission = manager.prepare_recovery(lease.record, use) + try: + if allowed: + await admission.acquire() + admission.check_record(lease.record) + else: + with pytest.raises(ManagedStorageError): + await admission.acquire() + finally: + await admission.close() + await check(ManagedMuxCloseUseV1.SETTLE, False) + await check(ManagedMuxCloseUseV1.ADMIT, True) + owners[1].request_stop(manager._instance) + await check(ManagedMuxCloseUseV1.ADMIT, False) + await check(ManagedMuxCloseUseV1.SETTLE, True) + successor(owners, attempt="e" * 32) + await check(ManagedMuxCloseUseV1.SETTLE, False) + asyncio.run(scenario()) + + +@pytest.mark.parametrize("known", [False, True]) +def test_pending_recovery_settles_before_active_and_retries_original_intent(owners, known): + async def scenario(): + manager, service, lease, _, _, _, _, request = await opened(owners) + await member(service, request, session_id="pending-session") + _, _, active_request = await other_target(owners, manager, service) + await member(service, active_request, session_id="active-session") + await service.close_managed_mux(request) + # Retain the genuine first close CAS, the crash-recovery input, rather + # than synthesizing a close intent or changing immutable origins. + pending = lease.commits[-2] + await service.close() + if known: + manager.record_close(request, pending.managed_closures[0], deadline=monotonic() + 5) + lease = _MemoryLease(pending) + manager = successor(owners) + events = [] + + class Resolver(_Resolver): + async def open_session(self, request): + if request.session_id == "active-session": + assert events == ["close:pending-session"] + assert len(lease.commits) == 1 + return await super().open_session(request) + + resolver = Resolver(events, fail_session_id="active-session") + attempt = recovery(manager, lease, resolver) + with pytest.raises(AppServiceError): + await attempt.open() + assert lease.record.managed_closures[0] == replace( + pending.managed_closures[0], phase=ManagedMuxClosePhaseV1.CLOSED, + ) + assert manager._recovery_record == pending + resolver.fail_session_id = None + restored = await attempt.open() + try: + assert len(lease.commits) == 1 + assert [item.session_id for item in resolver.requests].count("pending-session") == 1 + assert [mux.name for mux in (await restored.list_muxes()).mux_spaces] == ["other"] + assert owners[0].resolve("dev") is not None # Not silently reconciled/released. + finally: + await restored.close() + asyncio.run(scenario()) + + +@pytest.mark.parametrize("fault", ["creation_origin", "close_origin", "missing_close", "missing_creation", + "closed_regression", "active_reference"]) +def test_registry_history_mismatch_rejects_before_any_session_io(owners, fault): + async def scenario(): + manager, service, lease, _, _, _, _, request = await opened(owners) + await member(service, request) + await service.close_managed_mux(request) + pending, closed = lease.commits[-2], lease.record + result = closed.managed_closures[0] + manager.record_close(request, result if fault == "closed_regression" else pending.managed_closures[0], + deadline=monotonic() + 5) + await service.close() + record = pending + if fault == "creation_origin": + record = replace(record, managed_creations=(replace(record.managed_creations[0], instance_id="a" * 32),)) + elif fault == "close_origin": + record = replace(record, managed_closures=(replace(record.managed_closures[0], instance_id="a" * 32),)) + elif fault == "missing_close": + record = replace(record, managed_closures=()) + elif fault == "missing_creation": + record = replace(record, mux_spaces=(), managed_creations=(), managed_closures=()) + elif fault == "active_reference": + with owners[0]._database.transaction(write=True) as connection: + connection.execute("DELETE FROM muxes WHERE name='dev'") + manager = successor(owners) + resolver, lease = _Resolver([]), _MemoryLease(record) + attempt = recovery(manager, lease, resolver) + try: + with pytest.raises(AppServiceError): + await attempt.open() + assert not resolver.requests and not lease.commits + finally: + await attempt.close() + asyncio.run(scenario()) + + +def test_closed_origin_survives_multiple_generations_and_same_name_reuse(owners): + async def scenario(): + manager, service, lease, _, _, reservation, _, request = await opened(owners) + result = await service.close_managed_mux(request) + manager.record_close(request, result, deadline=monotonic() + 5) + await service.close() + reused = ManagedMuxReservationV1(reservation.name, reservation.service, "e" * 32) + owners[0].reserve_mux(reused) + for attempt_id in ("f" * 32, "a" * 32): + manager = successor(owners, attempt=attempt_id) + before = len(lease.commits) + restored = await recovery(manager, lease).open() + try: + assert lease.record.managed_closures == (result,) and len(lease.commits) == before + assert owners[0].resolve(reservation.name) == reused + finally: + await restored.close() + asyncio.run(scenario()) + + +def test_issued_only_missing_result_stays_unknown_and_reserved(owners): + async def scenario(): + manager, _, reservation = _ready(owners) + manager.issue_create(reservation, deadline=monotonic() + 5) + manager = successor(owners) + lease = _MemoryLease() + restored = await recovery(manager, lease).open() + try: + assert not lease.commits and owners[0].resolve(reservation.name) == reservation + assert not (await restored.list_muxes()).mux_spaces + finally: + await restored.close() + asyncio.run(scenario()) + + +def test_creation_without_issued_permit_cannot_restore(owners): + async def scenario(): + manager, service, lease, _, _, _, _, _ = await opened(owners) + reservation, create, _ = await other_target(owners, manager, service, issue=False) + await service.close() + # Independent creation-only target: no close FK masks the actual + # recovery check, and the malformed record remains structurally valid. + with owners[0]._database.transaction(write=True) as connection: + connection.execute("DELETE FROM mux_authorities WHERE operation_id=?", (create.operation_id,)) + manager = successor(owners) + resolver = _Resolver([]) + before = len(lease.commits) + attempt = recovery(manager, lease, resolver) + try: + with pytest.raises(AppServiceError): + await attempt.open() + assert not resolver.requests and len(lease.commits) == before + assert owners[0].resolve(reservation.name) == reservation + finally: + await attempt.close() + asyncio.run(scenario()) + + +def test_real_file_absence_after_clean_restart_is_not_a_fresh_application(owners, tmp_path): + from loushang.appservice import JsonFileApplicationContinuityStoreV1 + + async def scenario(): + _, service, memory, _, _, _, _, _ = await opened(owners) + await service.close() + root = tmp_path / "continuity" + store = JsonFileApplicationContinuityStoreV1(root) + lease = await store.acquire(application_id="coding.default", owner_epoch="before") + await lease.commit(expected_revision=None, record=memory.record) + await lease.close() + paths = tuple(root.glob("*.json")) + assert len(paths) == 1 + original = paths[0].read_bytes() + backup = tmp_path / "retained-test-continuity.json" + paths[0].rename(backup) # Preserve the fixture; never remove user state. + manager = successor(owners) + lease = await store.acquire(application_id="coding.default", owner_epoch="after") + resolver = _Resolver([]) + attempt = recovery(manager, lease, resolver) + try: + assert await lease.load() is None + with pytest.raises(AppServiceError): + await attempt.open() + assert not resolver.requests and not paths[0].exists() + assert backup.read_bytes() == original + finally: + await attempt.close() + await lease.close() + asyncio.run(scenario()) + + +@pytest.mark.parametrize("boundary", ["fence", "database"]) +def test_startup_retries_clean_read_contention_on_original_owner(owners, monkeypatch, boundary): + async def scenario(): + manager = successor(owners) + target = owners[1]._fence if boundary == "fence" else owners[0]._database + method = "lock" if boundary == "fence" else "transaction" + original = getattr(target, method) + calls = 0 + + @contextmanager + def contended(*args, **kwargs): + nonlocal calls + calls += 1 + if calls < 3: + raise ManagedStorageError("busy") + with original(*args, **kwargs) as result: + yield result + + monkeypatch.setattr(target, method, contended) + lease = _MemoryLease() + attempt = recovery(manager, lease) + service = None + try: + service = await attempt.open() + assert calls == 3 and not lease.commits + assert manager._recovery_admitted and manager._recovery_record is None + finally: + if service is not None: + await service.close() + await attempt.close() + asyncio.run(scenario()) + + +async def _permission_case(owners, mode): + if mode == "recovery": + manager = successor(owners) + return manager, manager.prepare_recovery(None, ManagedMuxCloseUseV1.ADMIT), None + manager, service, _, _, _, _, create, close = await opened(owners) + admission = manager.prepare(create) if mode == "create" else manager.prepare_close(close, ManagedMuxCloseUseV1.ADMIT) + return manager, admission, service + + +@pytest.mark.parametrize("mode", ["recovery", "create", "close"]) +@pytest.mark.parametrize("action", ["close", "stop"]) +def test_backoff_rechecks_admission_close_and_durable_stop(owners, monkeypatch, action, mode): + async def scenario(): + manager, admission, service = await _permission_case(owners, mode) + target = owners[1]._fence + original = target.lock + entered, release = asyncio.Event(), asyncio.Event() + calls = 0 + + @contextmanager + def contended(*args, **kwargs): + nonlocal calls + calls += 1 + if calls == 1: + raise ManagedStorageError("busy") + with original(*args, **kwargs): + yield + + async def pause(_): + entered.set() + await release.wait() + + monkeypatch.setattr(target, "lock", contended) + monkeypatch.setattr(asyncio, "sleep", pause) + task = asyncio.create_task(admission.acquire()) + try: + await asyncio.wait_for(entered.wait(), 2) + if action == "close": + await admission.close() + else: + owners[1].request_stop(manager._instance) + release.set() + with pytest.raises(ManagedStorageError): + await task + if action == "close": + assert calls == 1 + assert not manager._recovery_admitted + finally: + release.set() + await asyncio.gather(task, return_exceptions=True) + await admission.close() + if service is not None: + await service.close() + asyncio.run(scenario()) + + +@pytest.mark.parametrize("mode", ["recovery", "create", "close"]) +def test_unknown_fence_release_is_not_retried_as_busy(owners, monkeypatch, mode): + async def scenario(): + manager, admission, service = await _permission_case(owners, mode) + original = owners[1]._fence.lock + entries = exits = 0 + + @contextmanager + def uncertain(*args, **kwargs): + nonlocal entries, exits + entries += 1 + with original(*args, **kwargs): + yield + exits += 1 + raise ManagedStorageError("busy") # Release receipt lost, not a retry permit. + + @contextmanager + def busy(*args, **kwargs): + raise ManagedStorageError("busy") + yield # pragma: no cover + + with monkeypatch.context() as patch: + patch.setattr(owners[1]._fence, "lock", uncertain) + patch.setattr(owners[0]._database, "transaction", busy) + with pytest.raises(ManagedStorageError): + await admission.acquire() + with pytest.raises(ManagedStorageError, match="unavailable"): + await admission.close() + assert entries == exits == 1 and not manager._recovery_admitted + if service is not None: + await service.close() + asyncio.run(scenario()) + + +@pytest.mark.parametrize("mode", ["recovery", "create", "close"]) +def test_persistent_read_busy_exhausts_original_deadline_without_renewal(owners, monkeypatch, mode): + from loushang.apphost.managed import _files + from loushang.apphost.managed import mux_management as module + + async def scenario(): + manager, admission, service = await _permission_case(owners, mode) + now = 100.0 + deadlines = [] + + @contextmanager + def busy(*args, deadline=None, **kwargs): + deadlines.append(deadline) + raise ManagedStorageError("busy") + yield # pragma: no cover + + async def advance(_): + nonlocal now + now += 1.0 + + with monkeypatch.context() as patch: + patch.setattr(module, "monotonic", lambda: now) + patch.setattr(_files, "monotonic", lambda: now) + patch.setattr(owners[1]._fence, "lock", busy) + patch.setattr(asyncio, "sleep", advance) + with pytest.raises(ManagedStorageError) as error: + await admission.acquire() + assert error.value.code == "busy" # Existing deadline contract, no new error taxonomy. + assert deadlines == [105.0] * 5 and now == 105.0 + with pytest.raises(ManagedStorageError, match="conflict"): + await admission.acquire() + assert len(deadlines) == 5 and not manager._recovery_admitted + await admission.close() + if service is not None: + await service.close() + asyncio.run(scenario()) diff --git a/tests/apphost/test_managed_process.py b/tests/apphost/test_managed_process.py new file mode 100644 index 000000000..092df2491 --- /dev/null +++ b/tests/apphost/test_managed_process.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +import asyncio +import signal +import threading +from time import monotonic + +import pytest + +from loushang.apphost.managed import _process as module +from loushang.apphost.managed._files import ManagedStorageError + +from .test_managed_bootstrap import deployment as deployment +from .test_managed_bootstrap import make_bootstrap +from .test_managed_child import Application + + +@pytest.fixture +def handlers(monkeypatch): + original = {signal.SIGINT: lambda *args: None, signal.SIGTERM: signal.SIG_IGN, signal.SIGHUP: signal.SIG_DFL} + current, changes = dict(original), [] + + def setter(kind, value): + previous = current[kind] + current[kind] = value + changes.append((kind, value)) + return previous + + monkeypatch.setattr(module.signal, "getsignal", current.__getitem__) + monkeypatch.setattr(module.signal, "signal", setter) + return original, current, changes, setter + + +def assert_policy(bootstrap, handlers): + current = handlers[1] + assert current[signal.SIGINT] == current[signal.SIGTERM] == bootstrap._process._signal + assert current[signal.SIGHUP] == signal.SIG_IGN + + +def test_process_joins_all_resources_and_keeps_process_policy(deployment, tmp_path, handlers): + bootstrap, parent = make_bootstrap(deployment, tmp_path) + bootstrap.open(deadline=monotonic() + 5) + app = Application() + bootstrap.bind(app) + parent.close() + try: + assert bootstrap.run_process() == 0 + assert_policy(bootstrap, handlers) + assert len(handlers[2]) == 3 + process = bootstrap._process + assert process._loop.is_closed() and not process.cleanup_pending + assert not asyncio.all_tasks(process._loop) + assert process._waiter.done() and process._signal_waiter.done() + assert not bootstrap.cleanup_pending and app.closes == 1 + with pytest.raises(ManagedStorageError, match="closed"): + bootstrap.run_process() + finally: + bootstrap.close() + + +def test_process_preconditions_reject_before_runner_or_signal_effects(deployment, tmp_path, handlers, monkeypatch): + bootstrap, parent = make_bootstrap(deployment, tmp_path) + try: + with pytest.raises(ManagedStorageError, match="closed"): + bootstrap.run_process() + bootstrap.open(deadline=monotonic() + 5) + with pytest.raises(ManagedStorageError, match="closed"): + bootstrap.run_process() + child = bootstrap.bind(Application()) + failures = [] + + def other_thread(): + try: + bootstrap.run_process() + except ManagedStorageError as error: + failures.append(error.code) + + thread = threading.Thread(target=other_thread) + thread.start() + thread.join(2) + assert not thread.is_alive() and failures == ["unsupported"] + + async def existing_loop(): + with pytest.raises(ManagedStorageError, match="busy"): + bootstrap.run_process() + await child.close() + assert await bootstrap.run() == 1 # Child was stopped before its runner started. + + asyncio.run(existing_loop()) + with pytest.raises(ManagedStorageError, match="closed"): + bootstrap.run_process() + assert bootstrap._process is None and handlers[2] == [] + finally: + parent.close() + bootstrap.close() + + +def test_cancelled_process_waiter_rejoins_one_application(deployment, tmp_path, handlers): + bootstrap, parent = make_bootstrap(deployment, tmp_path) + bootstrap.open(deadline=monotonic() + 5) + _, _, journal, state, _ = deployment + journal.register_native(state.handoff.instance, state.handoff.attempt_id, bootstrap._observer.identity) + original_waiters = [] + + class CancelWaiter(Application): + async def activate(self): + await super().activate() + original_waiters.append(bootstrap._process._waiter) + bootstrap._process._waiter.cancel() + bootstrap._process._signal(signal.SIGTERM, None) + + app = CancelWaiter() + bootstrap.bind(app) + try: + assert bootstrap.run_process() == 0 + assert len(original_waiters) == 1 and original_waiters[0].cancelled() + assert bootstrap._process._waiter is not original_waiters[0] + assert app.prepares == app.activations == app.closes == 1 + assert not bootstrap.cleanup_pending + assert_policy(bootstrap, handlers) + finally: + parent.close() + bootstrap.close() + + +def test_failed_bootstrap_waiter_is_rejoined_without_replaying_product(deployment, tmp_path, handlers, monkeypatch): + bootstrap, parent = make_bootstrap(deployment, tmp_path) + bootstrap.open(deadline=monotonic() + 5) + app = Application() + bootstrap.bind(app) + original_run, calls = bootstrap.run, [] + + async def fail_once(): + calls.append(1) + if len(calls) == 1: + raise ManagedStorageError("busy") + return await original_run() + + monkeypatch.setattr(bootstrap, "run", fail_once) + try: + assert bootstrap.run_process() == 1 + assert calls == [1, 1] and app.prepares == app.activations == 0 and app.closes == 1 + assert not bootstrap.cleanup_pending + assert_policy(bootstrap, handlers) + finally: + parent.close() + bootstrap.close() + + +def test_signals_during_cleanup_preserve_exact_task_deadline_and_single_close(deployment, tmp_path, handlers): + bootstrap, parent = make_bootstrap(deployment, tmp_path) + bootstrap.open(deadline=monotonic() + 5) + _, _, journal, state, _ = deployment + journal.register_native(state.handoff.instance, state.handoff.attempt_id, bootstrap._observer.identity) + captured = [] + + class HeldClose(Application): + async def activate(self): + await super().activate() + handlers[1][signal.SIGINT](signal.SIGINT, None) + + async def close(self, *, retry_timeout=None): + self.closes += 1 + task, deadline = bootstrap._process._stop_task, child._close_deadline + gate = asyncio.Event() + handlers[1][signal.SIGTERM](signal.SIGTERM, None) + handlers[1][signal.SIGINT](signal.SIGINT, None) + asyncio.get_running_loop().call_soon(gate.set) + await gate.wait() + captured.append((task is bootstrap._process._stop_task, deadline == child._close_deadline, self.closes)) + self.closed.set() + + app = HeldClose() + child = bootstrap.bind(app) + try: + assert bootstrap.run_process() == 0 + assert captured == [(True, True, 1)] and not bootstrap.cleanup_pending + finally: + parent.close() + bootstrap.close() + + +def test_cancelled_process_serve_does_not_abandon_pending_stop_waiter(deployment, tmp_path, handlers, monkeypatch): + bootstrap, parent = make_bootstrap(deployment, tmp_path) + bootstrap.open(deadline=monotonic() + 5) + _, _, journal, state, _ = deployment + journal.register_native(state.handoff.instance, state.handoff.attempt_id, bootstrap._observer.identity) + entered, release = asyncio.Event(), asyncio.Event() + probes, checks = [], [] + + class SignalStop(Application): + async def activate(self): + await super().activate() + handlers[1][signal.SIGTERM](signal.SIGTERM, None) + + child = bootstrap.bind(SignalStop()) + real_close, real_run = child.close, bootstrap.run + + async def held_stop(*, retry_timeout=None): + await real_close(retry_timeout=retry_timeout) + if asyncio.current_task() is bootstrap._process._stop_task: + entered.set() + await release.wait() # Actual resource cleanup done; exact wrapper remains pending. + + async def probe(): + from .test_managed_child import until + + await entered.wait() + await until(lambda: bootstrap._process._waiter.done()) + process = bootstrap._process + original_serve, original_stop = process._serve_task, process._stop_task + original_serve.cancel() + await until(lambda: process._serve_task is not original_serve) + checks.append((process._stop_task is original_stop, not original_stop.cancelled(), not process._ending)) + release.set() + + async def observed_run(): + probes.append(asyncio.create_task(probe())) + return await real_run() + + monkeypatch.setattr(child, "close", held_stop) + monkeypatch.setattr(bootstrap, "run", observed_run) + try: + assert bootstrap.run_process() == 0 + assert checks == [(True, True, True)] and len(probes) == 1 + assert probes[0].done() and not probes[0].cancelled() + assert not bootstrap.cleanup_pending + assert_policy(bootstrap, handlers) + finally: + parent.close() + bootstrap.close() + + +def test_stop_before_driver_never_prepares_or_activates(deployment, tmp_path, handlers, monkeypatch): + bootstrap, parent = make_bootstrap(deployment, tmp_path) + bootstrap.open(deadline=monotonic() + 5) + app = Application() + bootstrap.bind(app) + _, current, _, setter = handlers + injected = False + + def change(kind, value): + nonlocal injected + result = setter(kind, value) + if not injected and kind == signal.SIGHUP: + injected = True + current[signal.SIGTERM](signal.SIGTERM, None) + current[signal.SIGINT](signal.SIGINT, None) + return result + + monkeypatch.setattr(module.signal, "signal", change) + try: + assert bootstrap.run_process() == 1 # Stopped before startup admission, not a ready service. + assert injected and app.prepares == app.activations == 0 and app.closes == 1 + assert_policy(bootstrap, handlers) + assert not bootstrap.cleanup_pending + assert bootstrap._process._stop_task.done() + finally: + parent.close() + bootstrap.close() + + +class Parked(BaseException): + """Test-only escape from an intentionally nonreturning maintenance state.""" + + +@pytest.mark.parametrize("fault", ["initialize", "runner_close", "install_int", "install_term", "install_hup"]) +def test_partial_process_resource_failure_retains_unknown_without_retry(deployment, tmp_path, handlers, monkeypatch, fault): + bootstrap, parent = make_bootstrap(deployment, tmp_path) + bootstrap.open(deadline=monotonic() + 5) + app = Application() + child = bootstrap.bind(app) + real_runner, _, current, _, setter = module.asyncio.Runner, *handlers + calls = [] + target = {"install_int": signal.SIGINT, "install_term": signal.SIGTERM, "install_hup": signal.SIGHUP}.get(fault) + injected = False + + class Runner(real_runner): + def get_loop(self): + loop = super().get_loop() + if fault == "initialize": + raise RuntimeError("failure after loop creation") + return loop + + def close(self): + calls.append("close") + super().close() + if fault == "runner_close": + raise RuntimeError("failure after runner close") + + def install(kind, value): + nonlocal injected + result = setter(kind, value) + if kind == target and not injected: + injected = True + raise OSError("handler install failed after effect") + return result + + def park(): + raise Parked() + + monkeypatch.setattr(module.asyncio, "Runner", Runner) + monkeypatch.setattr(module.signal, "signal", install) + monkeypatch.setattr(module, "_park", park) + parent.close() + try: + with pytest.raises(Parked): + bootstrap.run_process() + process = bootstrap._process + assert process.unknown and process.cleanup_pending and bootstrap.cleanup_pending + assert process._runner is not None and process._loop is not None + with pytest.raises(ManagedStorageError, match="closed"): + bootstrap.run_process() + if fault == "initialize": + assert app.prepares == app.closes == 0 and calls == [] + else: + assert not bootstrap._resources_pending() and calls == ["close"] + assert process._loop.is_closed() + process._signal(signal.SIGTERM, None) # Closing-loop window is harmless. + assert current[signal.SIGINT] == current[signal.SIGTERM] == process._signal + assert current[signal.SIGHUP] == signal.SIG_IGN + assert_policy(bootstrap, handlers) + if target is not None: + assert injected and process._protection_unknown + assert app.prepares == app.activations == 0 and app.closes == 1 + finally: + # Explicit teardown of precisely known injected test resources; the + # production owner retains unknown and never reports a successful exit. + monkeypatch.setattr(module.asyncio, "Runner", real_runner) + if child.cleanup_pending: + asyncio.run(child.close(retry_timeout=2)) + with pytest.raises(ManagedStorageError, match="unavailable"): + bootstrap.close() + if not bootstrap._process._loop.is_closed(): + real_runner.close(bootstrap._process._runner) diff --git a/tests/apphost/test_managed_process_signals.py b/tests/apphost/test_managed_process_signals.py new file mode 100644 index 000000000..8bc80a1fe --- /dev/null +++ b/tests/apphost/test_managed_process_signals.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import json +import os +import select +import signal +import subprocess +import sys +from pathlib import Path +from time import monotonic + +import pytest + +from loushang.hosting.service import LinuxServiceObserverV1 + +from ..hosting._pidfd_signal import send_signal + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux process-shell signal policy") + + +@pytest.mark.parametrize("fault", ["init", "close", "install", "success"]) +def test_process_policy_survives_real_signals_with_an_unmasked_thread(fault): + child = subprocess.Popen( + [sys.executable, str(Path(__file__).with_name("_managed_process_fault.py")), fault], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + start_new_session=True, + ) + observer = None + + def receipt(): + result = bytearray() + deadline = monotonic() + 8 + while not result.endswith(b"\n"): + remaining = deadline - monotonic() + assert remaining > 0, "child receipt timed out" + assert select.select([child.stdout], [], [], remaining)[0], "child receipt timed out" + data = os.read(child.stdout.fileno(), 1) + assert data, "child exited before receipt" + result.extend(data) + assert len(result) < 1024 + return json.loads(result) + + def check(): + facts = receipt() + assert facts["unknown"] == facts["pending"] == (fault != "success") + assert facts["protected"] + assert child.poll() is None + return facts + + try: + check() + observer = LinuxServiceObserverV1.capture(child.pid) + for kind in (signal.SIGHUP, signal.SIGTERM, signal.SIGINT): + send_signal(observer, kind) + child.stdin.write(b"status\n") + child.stdin.flush() + facts = check() + if kind != signal.SIGHUP: + assert facts["stopRequested"] + child.stdin.write(b"quit\n") + child.stdin.flush() + assert child.wait(timeout=8) == (9 if fault == "success" else 7) + finally: + if child.poll() is None: + child.kill() # Exact retained test child; only failure hygiene. + _, errors = child.communicate(timeout=8) + if observer is not None: + observer.close() + assert not errors, errors.decode(errors="replace") diff --git a/tests/apphost/test_managed_stopper.py b/tests/apphost/test_managed_stopper.py new file mode 100644 index 000000000..c1d107a0a --- /dev/null +++ b/tests/apphost/test_managed_stopper.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import asyncio +from dataclasses import replace +from threading import Event +from time import monotonic +from types import SimpleNamespace + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.contracts import ManagedContractError +from loushang.apphost.managed.stopper import ManagedServiceStopOperationV1 + +from .test_managed_connection import committed +from .test_managed_starter import owners as owners + + +def operation(owners, state): + journal, namespace, service, runtime = owners + return ManagedServiceStopOperationV1(journal, namespace, service, state.handoff.instance, runtime_root=runtime) + + +def native(owner, state, monkeypatch): + flags = {"leader": False, "group": False, "closes": 0, "close_error": False} + + def close(): + flags["closes"] += 1 + if flags["close_error"]: + raise OSError("lost close receipt") + + def admit(value): + assert value.native_identity == state.native_identity + owner._observer = SimpleNamespace(identity=state.native_identity, exited=lambda: flags["leader"], close=close) + owner._group = SimpleNamespace(exited=lambda: flags["group"]) + + monkeypatch.setattr(owner, "_admit", admit) + return flags + + +def test_stop_waits_for_scope_and_child_receipt_and_survives_waiter_cancel(owners, monkeypatch): + journal = owners[0] + state = committed(owners) + owner = operation(owners, state) + flags = native(owner, state, monkeypatch) + stopped = Event() + original = journal.request_stop + requests = [] + + def request(*args, **kwargs): + result = original(*args, **kwargs) + requests.append(1) + stopped.set() + return result + + monkeypatch.setattr(journal, "request_stop", request) + + async def scenario(): + deadline = monotonic() + 5 + waiter = asyncio.create_task(owner.run(deadline=deadline)) + assert await asyncio.to_thread(stopped.wait, 3) + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + flags["leader"] = True + async with asyncio.timeout(3): + while owner.state is None or not owner.state.evidence.process_exited: + await asyncio.sleep(0.01) + assert not owner.state.cleanly_stopped and not owner.state.evidence.process_scope_settled + flags["group"] = True + async with asyncio.timeout(3): + while not owner.state.evidence.process_scope_settled: + await asyncio.sleep(0.01) + assert not owner.state.evidence.application_cleanup_completed + await asyncio.to_thread(journal.record_child_cleanup, state.handoff.instance, + state.handoff.attempt_id, state.native_identity) + result = await owner.run(deadline=deadline) + assert result.cleanly_stopped and requests == [1] + with pytest.raises(ManagedStorageError, match="conflict"): + await owner.run(deadline=deadline + 1) + await owner.close() + assert not owner.cleanup_pending and flags["closes"] == 1 + assert journal.read().cleanly_stopped + + asyncio.run(scenario()) + + +def test_timeout_retains_stop_intent_without_fabricating_exit(owners, monkeypatch): + state = committed(owners) + owner = operation(owners, state) + flags = native(owner, state, monkeypatch) + + async def scenario(): + with pytest.raises(ManagedStorageError): + await owner.run(deadline=monotonic() + 0.15) + await owner.close() + assert flags["closes"] == 1 and not owner.cleanup_pending + + asyncio.run(scenario()) + current = owners[0].read() + assert current.handoff.stop_requested and not current.cleanly_stopped + assert not current.evidence.process_exited and not current.evidence.application_cleanup_completed + + +def test_unknown_close_not_retried_as_a_numeric_descriptor(owners, monkeypatch): + state = committed(owners) + owner = operation(owners, state) + flags = native(owner, state, monkeypatch) + + async def scenario(): + with pytest.raises(ManagedStorageError): + await owner.run(deadline=monotonic() + 0.1) + flags["close_error"] = True + with pytest.raises(OSError): + await owner.close() + with pytest.raises(ManagedStorageError, match="unavailable"): + await owner.close() + assert owner.cleanup_pending and flags["closes"] == 1 + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("deadline", [None, True, float("nan"), float("inf"), 0]) +def test_invalid_deadline_before_task_or_stop(owners, deadline): + state = committed(owners) + owner = operation(owners, state) + + async def scenario(): + with pytest.raises(ManagedContractError): + await owner.run(deadline=deadline) + assert owner._task is None + await owner.close() + + asyncio.run(scenario()) + assert owners[0].read() == state + + +def test_native_evidence_cannot_change_app_fact_or_mismatch_birth(owners): + state = committed(owners) + journal = owners[0] + journal.request_stop(state.handoff.instance) + with pytest.raises(ManagedStorageError, match="conflict"): + journal.record_native_stop(state.handoff.instance, replace(state.native_identity, start_ticks=1), + process_exited=True, process_scope_settled=True, deadline=monotonic() + 2) + result = journal.record_native_stop(state.handoff.instance, state.native_identity, + process_exited=True, process_scope_settled=True, deadline=monotonic() + 2) + assert not result.evidence.application_cleanup_completed and not result.cleanly_stopped + + +@pytest.mark.parametrize("delay_before_native", [False, True]) +def test_original_deadline_covers_native_entry_and_result_delivery(owners, monkeypatch, delay_before_native): + from loushang.apphost.managed import stopper + + state = committed(owners) + journal = owners[0] + journal.request_stop(state.handoff.instance) + journal.record_child_cleanup(state.handoff.instance, state.handoff.attempt_id, state.native_identity) + state = journal.record_native_stop(state.handoff.instance, state.native_identity, + process_exited=True, process_scope_settled=True, deadline=monotonic() + 5) + owner = operation(owners, state) + expired = False + operations = [] + + def check(deadline): + if expired: + raise ManagedStorageError("busy") + + async def receipt(operation): + nonlocal expired + if delay_before_native: + expired = True + result = operation() + operations.append(result) + expired = True + return result + + monkeypatch.setattr(stopper, "_check_deadline", check) + monkeypatch.setattr(stopper, "_settled_native", receipt) + + async def scenario(): + deadline = monotonic() + 5 + with pytest.raises(ManagedStorageError, match="busy"): + await owner.run(deadline=deadline) + if delay_before_native: + assert operations == [] and owner.state is None + else: + assert operations == [state] and owner.state == state + await owner.close() + assert not owner.cleanup_pending + + asyncio.run(scenario()) + assert journal.read() == state diff --git a/tests/appserver/test_connection_profiles.py b/tests/appserver/test_connection_profiles.py index ebfc2e792..675f96d0b 100644 --- a/tests/appserver/test_connection_profiles.py +++ b/tests/appserver/test_connection_profiles.py @@ -10,6 +10,8 @@ from loushang.appserver.connection import AppServerConnectionV1 from loushang.appserver.execution.client import ExecutionClientV1 from loushang.appserver.framing import AppFramedStreamV1 +from loushang.appserver.managed_mux import ManagedMuxCreationClientV1 +from loushang.appserver.managed_mux_close import ManagedMuxCloseClientV1 from loushang.appserver.protocol import ( AppServiceError, InvalidAppMessageError, @@ -19,6 +21,8 @@ AppConnectionProfileV1, connection_hello, supports_execution, + supports_managed_mux, + supports_managed_mux_close, ) from loushang.appserver.remote_client import RemoteAppClientV1 @@ -34,7 +38,11 @@ async def scenario(): execution = create_autospec(ExecutionClientV1, instance=True) execution.service_instance_id = "instance" server = AppServerConnectionV1(cast(AppClientV1, _SemanticClient()), - AppFramedStreamV1(right), profile=profile, execution=execution) + AppFramedStreamV1(right), profile=profile, execution=execution, + managed_mux=(create_autospec(ManagedMuxCreationClientV1, instance=True) + if supports_managed_mux(profile) else None), + managed_mux_close=(create_autospec(ManagedMuxCloseClientV1, instance=True) + if supports_managed_mux_close(profile) else None)) client = RemoteAppClientV1(AppFramedStreamV1(left), profile=profile) serving = asyncio.create_task(server.serve()) try: diff --git a/tests/appserver/test_local.py b/tests/appserver/test_local.py index af9980589..aea61296b 100644 --- a/tests/appserver/test_local.py +++ b/tests/appserver/test_local.py @@ -43,7 +43,7 @@ async def close(self): self.closed = True -def _server(directory, *, auth_timeout=1, close_timeout=1, request_stop=None): +def _server(directory, *, auth_timeout=1, close_timeout=1, request_stop=None, instance=None): scopes = [] def factory(): scope = _Scope() @@ -54,10 +54,59 @@ def factory(): scopes=(LocalRecordScopeV1(SessionScopeV1.CWD, "a" * 64),), scope_factory=factory, request_stop=request_stop or (lambda _: pytest.fail("unexpected application stop")), auth_timeout=auth_timeout, close_timeout=close_timeout, + instance=instance, ) return server, scopes +def test_managed_instance_is_published_and_stale_client_rejects_before_socket(tmp_path, monkeypatch): + from loushang.appserver import local as module + + async def scenario(): + directory = LocalConnectionDirectoryV1(tmp_path / "runtime") + server, scopes = _server(directory, instance="c" * 32) + stale = LocalAppClientConnectionV1(directory, "workspace", expected_instance="d" * 32) + current = LocalAppClientConnectionV1(directory, "workspace", expected_instance="c" * 32) + try: + with pytest.raises(AppServiceError): + _ = current.application_id + await server.start() + assert directory.read("workspace").instance == "c" * 32 + with monkeypatch.context() as patch: + patch.setattr(module.socket, "socket", lambda *a, **k: pytest.fail("stale reference opened socket")) + with pytest.raises(AppServiceError): + await stale.start() + assert not scopes + await current.start() + assert current.application_id == "application" + with monkeypatch.context() as patch: + patch.setattr(directory, "read", lambda *_: pytest.fail("authenticated identity reread mutable record")) + assert current.application_id == "application" + assert await current.client.list_muxes() == MuxListResultV1(()) + finally: + await asyncio.gather(stale.close(), current.close()) + with pytest.raises(AppServiceError): + _ = current.application_id + await server.close() + directory.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("value", ["", "C" * 32, "c" * 31, True, 7]) +def test_invalid_managed_instance_rejected_before_record_or_socket(tmp_path, value): + root = tmp_path / "runtime" + directory = LocalConnectionDirectoryV1(root) + try: + with pytest.raises(ValueError): + _server(directory, instance=value) + with pytest.raises(ValueError): + LocalAppClientConnectionV1(directory, "workspace", expected_instance=value) + assert not root.exists() + finally: + directory.close() + + async def _until(predicate): async with asyncio.timeout(3): while not predicate(): diff --git a/tests/appserver/test_managed_mux_close_wire.py b/tests/appserver/test_managed_mux_close_wire.py new file mode 100644 index 000000000..ca4f55ae9 --- /dev/null +++ b/tests/appserver/test_managed_mux_close_wire.py @@ -0,0 +1,282 @@ +"""Explicit close negotiation, strict receipt transport and retained delivery.""" + +from __future__ import annotations + +import asyncio +import json +from dataclasses import replace + +import pytest + +from loushang.appserver import managed_mux_wire as wire +from loushang.appserver.framing import AppConnectionClosedError, AppFramedStreamV1 +from loushang.appserver.local import LocalAppServerV1 +from loushang.appserver.local_record import ( + LocalConnectionDirectoryV1, + LocalRecordScopeV1, +) +from loushang.appserver.managed_mux_close import ( + ManagedMuxClosePhaseV1, + ManagedMuxCloseStateV1, + ManagedMuxCloseV1, +) +from loushang.appserver.protocol import ( + AppFailureV1, + AppResponseV1, + InvalidAppMessageError, + SessionScopeV1, +) +from loushang.appserver.protocol import encode_response as encode_app_response +from loushang.appserver.protocol.connection_profile import ( + AppConnectionProfileV1, + connection_hello, +) +from loushang.appserver.remote_client import RemoteAppClientV1 +from tests.appserver.test_connection import _pair +from tests.appserver.test_local import _until + + +def request(): + return ManagedMuxCloseV1("a" * 64, "b" * 32, "c" * 32, "d" * 32, "dev", "mux-1", "secret") + + +def state(*, phase=ManagedMuxClosePhaseV1.CLOSED): + value = request() + return ManagedMuxCloseStateV1(value.operation_id, "e" * 32, value.creation_operation_id, + value.name, value.mux_space_id, phase) + + +@pytest.mark.parametrize("read", [False, True]) +def test_close_and_lookup_are_distinct_bounded_calls(read): + call = wire.ManagedMuxCloseCallV1("1", request(), read_result=read) + assert wire.decode_close_call(wire.encode_close_call(call)) == call + assert "secret" not in repr(call) + assert len(wire.encode_close_call(call)) <= wire.MAX_MANAGED_MUX_FRAME + with pytest.raises(InvalidAppMessageError): + wire.decode_call(wire.encode_close_call(call)) + + +@pytest.mark.parametrize("result", [None, state(), state(phase=ManagedMuxClosePhaseV1.CLEANUP_PENDING)]) +def test_close_response_preserves_historical_origin_without_authority(result): + response = wire.ManagedMuxCloseResponseV1("1", result) + encoded = wire.encode_close_response(response) + assert wire.decode_close_response(encoded) == response + assert b"secret" not in encoded and b"authority" not in encoded + + +@pytest.mark.parametrize("mutation", [ + {"extra": True}, {"protocolVersion": "loushang.managed-mux/v1"}, + {"operation": "mux/create"}, {"requestId": True}, {"request": None}, +]) +def test_close_wire_rejects_foreign_fields_and_operations(mutation): + encoded = wire.encode_close_call(wire.ManagedMuxCloseCallV1("1", request())) + raw = json.loads(encoded) + with pytest.raises(InvalidAppMessageError): + wire.decode_close_call(json.dumps(raw | mutation).encode()) + + +def test_close_wire_rejects_duplicate_keys_and_oversize_frames(): + encoded = wire.encode_close_call(wire.ManagedMuxCloseCallV1("1", request())) + with pytest.raises(InvalidAppMessageError): + wire.decode_close_call(encoded.replace(b'"requestId":"1"', b'"requestId":"1","requestId":"2"')) + with pytest.raises(InvalidAppMessageError): + wire.decode_close_call(encoded + b" " * 4096) + + +@pytest.mark.parametrize("field,value", [ + ("operation_id", "f" * 32), ("creation_operation_id", "f" * 32), + ("name", "other"), ("mux_space_id", "other"), ("none", None), ("family", None), +]) +def test_cancelled_waiter_does_not_bypass_close_response_validation(field, value): + async def run(): + left, right = _pair() + frames = AppFramedStreamV1(right) + profile = AppConnectionProfileV1.LOCAL_MANAGED_CLOSE + remote = RemoteAppClientV1(AppFramedStreamV1(left), profile=profile) + hello = connection_hello(profile) + await frames.send(hello) + await remote.start() + assert await frames.receive() == hello + waiter = asyncio.create_task(remote.close_managed_mux(request())) + try: + call = wire.decode_close_call(await frames.receive()) + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + assert len(remote._pending) == 1 + if field == "family": + from loushang.appserver.protocol import AppErrorCodeV1 + payload = encode_app_response(AppResponseV1(call.request_id, AppFailureV1(AppErrorCodeV1.OPERATION_UNAVAILABLE))) + else: + result = None if field == "none" else replace(state(), **{field: value}) + payload = wire.encode_close_response(wire.ManagedMuxCloseResponseV1(call.request_id, result)) + await frames.send(payload) + await _until(lambda: not remote._pending) + assert remote.managed_mux_close_client is None and remote._counts == {False: 0, True: 0} + finally: + await remote.close() + await frames.close() + asyncio.run(asyncio.wait_for(run(), 5)) + + +def test_close_capability_without_management_is_rejected_before_io(tmp_path): + directory = LocalConnectionDirectoryV1(tmp_path / "connection") + try: + with pytest.raises(ValueError): + LocalAppServerV1(directory, "managed", application_id="coding.default", product_id="coding", + scopes=(LocalRecordScopeV1(SessionScopeV1.CWD, "a" * 64),), mux_closure=True, + scope_factory=lambda: pytest.fail("no scope before validation"), request_stop=lambda _: None) + finally: + directory.close() + + +def test_mixed_ordinary_server_saturation_preserves_close_lookup_capacity(): + from loushang.appserver.connection import AppServerConnectionV1 + from loushang.appserver.managed_mux import ManagedMuxCreatedV1 + from loushang.appserver.protocol import ( + AppOperationV1, + AppRequestV1, + MuxListResultV1, + MuxListV1, + encode_request, + ) + from loushang.appserver.protocol.stdio_profile import MAX_ORDINARY_REQUESTS + from tests.appserver.test_connection import _SemanticClient + from tests.appservice.test_managed_mux import _request + + async def run(): + release = asyncio.Event() + entered = 0 + + async def hold(): + nonlocal entered + entered += 1 + await release.wait() + + class Semantic(_SemanticClient): + async def list_muxes(self): + await hold() + return MuxListResultV1(()) + + async def create_managed_mux(self, value): + await hold() + return ManagedMuxCreatedV1(value.operation_id, value.instance_id, value.name, "created") + + async def close_managed_mux(self, value): + await hold() + return state() + + async def read_managed_mux_close(self, value): + return state(phase=ManagedMuxClosePhaseV1.CLEANUP_PENDING) + + left, right = _pair() + frames = AppFramedStreamV1(left) + semantic = Semantic() + profile = AppConnectionProfileV1.LOCAL_MANAGED_CLOSE + server = AppServerConnectionV1(semantic, AppFramedStreamV1(right), profile=profile, + managed_mux=semantic, managed_mux_close=semantic) + serving = asyncio.create_task(server.serve()) + try: + assert await frames.receive() == connection_hello(profile) + await frames.send(connection_hello(profile)) + for number in range(1, MAX_ORDINARY_REQUESTS + 1): + if number % 3 == 0: + payload = wire.encode_close_call(wire.ManagedMuxCloseCallV1(str(number), request())) + elif number % 3 == 1: + payload = encode_request(AppRequestV1(str(number), AppOperationV1.MUX_LIST, MuxListV1())) + else: + payload = wire.encode_call(wire.ManagedMuxCallV1(str(number), _request())) + await frames.send(payload) + await _until(lambda: entered == MAX_ORDINARY_REQUESTS) + overflow = str(MAX_ORDINARY_REQUESTS + 1) + await frames.send(wire.encode_close_call(wire.ManagedMuxCloseCallV1(overflow, request()))) + assert isinstance(wire.decode_close_response(await frames.receive()).result, AppFailureV1) + lookup = str(MAX_ORDINARY_REQUESTS + 2) + await frames.send(wire.encode_close_call(wire.ManagedMuxCloseCallV1(lookup, request(), read_result=True))) + result = wire.decode_close_response(await frames.receive()) + assert result.request_id == lookup and result.result.phase is ManagedMuxClosePhaseV1.CLEANUP_PENDING + assert entered == MAX_ORDINARY_REQUESTS and server._counts[False] == MAX_ORDINARY_REQUESTS + finally: + release.set() + await frames.close() + await server.close() + await asyncio.gather(serving, return_exceptions=True) + asyncio.run(asyncio.wait_for(run(), 5)) + + +@pytest.mark.parametrize("missing", [False, True]) +def test_failed_close_capability_borrow_closes_original_scope(tmp_path, missing): + from loushang.appserver.local import LocalAppClientConnectionV1 + from loushang.appservice.client_scope import ScopedAppServiceV1 + from tests.appservice.test_continuity_runtime import _MemoryLease + from tests.appservice.test_managed_mux import _binding, _open + + async def run(): + service = await _open(_MemoryLease(), _binding([])) + owner = ScopedAppServiceV1(service) + opened, closed = [], [] + + class Scope: + def __init__(self): + self.inner = owner.open_client_scope() + opened.append(self) + + @property + def managed_mux_client(self): + return self.inner.managed_mux_client + + @property + def managed_mux_close_client(self): + if missing: + return None + raise RuntimeError("private capability failure") + + async def close(self): + await self.inner.close() + closed.append(self) + + directory = LocalConnectionDirectoryV1(tmp_path / "connection") + server = LocalAppServerV1(directory, "managed", application_id="coding.default", product_id="coding", + scopes=(LocalRecordScopeV1(SessionScopeV1.CWD, "a" * 64),), scope_factory=lambda: pytest.fail("no fallback"), + managed_mux_scope_factory=Scope, mux_closure=True, request_stop=lambda _: None) + client = LocalAppClientConnectionV1(directory, "managed") + try: + await server.start() + with pytest.raises(AppConnectionClosedError): + await client.start() + await _until(lambda: bool(closed)) + assert len(opened) == 1 and closed == opened + finally: + await client.close() + await server.close() + await service.close() + directory.close() + asyncio.run(asyncio.wait_for(run(), 5)) + + +def test_remote_close_accepts_historical_origin_and_lookup_none(): + async def run(): + left, right = _pair() + frames = AppFramedStreamV1(right) + profile = AppConnectionProfileV1.LOCAL_MANAGED_CLOSE + remote = RemoteAppClientV1(AppFramedStreamV1(left), profile=profile) + hello = connection_hello(profile) + await frames.send(hello) + await remote.start() + assert await frames.receive() == hello + waiters = [] + try: + for read, result in ((True, None), (False, state())): + waiter = asyncio.create_task(remote.read_managed_mux_close(request()) if read + else remote.close_managed_mux(request())) + waiters.append(waiter) + call = wire.decode_close_call(await frames.receive()) + assert call.read_result is read + await frames.send(wire.encode_close_response(wire.ManagedMuxCloseResponseV1(call.request_id, result))) + assert await waiter == result + assert remote.managed_mux_close_client is not None + finally: + await remote.close() + await asyncio.gather(*waiters, return_exceptions=True) + await frames.close() + asyncio.run(asyncio.wait_for(run(), 5)) diff --git a/tests/appserver/test_managed_mux_wire.py b/tests/appserver/test_managed_mux_wire.py new file mode 100644 index 000000000..7ed71cb4c --- /dev/null +++ b/tests/appserver/test_managed_mux_wire.py @@ -0,0 +1,545 @@ +from __future__ import annotations + +import asyncio +import json +from dataclasses import replace +from types import SimpleNamespace + +import pytest + +from loushang.appserver.connection import AppServerConnectionV1 +from loushang.appserver.execution.codec import ( + encode_response as encode_execution_response, +) +from loushang.appserver.execution.model import ExecutionResponseV1 +from loushang.appserver.framing import AppConnectionClosedError, AppFramedStreamV1 +from loushang.appserver.local import LocalAppClientConnectionV1, LocalAppServerV1 +from loushang.appserver.local_record import ( + LocalConnectionDirectoryV1, + LocalRecordScopeV1, + decode_connection_record, + encode_connection_record, +) +from loushang.appserver.managed_mux import ManagedMuxCreatedV1 +from loushang.appserver.managed_mux_wire import ( + ManagedMuxCallV1, + ManagedMuxResponseV1, + decode_call, + decode_response, + encode_call, + encode_response, +) +from loushang.appserver.protocol import ( + AppErrorCodeV1, + AppFailureV1, + AppOperationV1, + AppRequestV1, + AppResponseV1, + AppServiceError, + InvalidAppMessageError, + MuxCreateV1, + MuxListV1, + SessionScopeV1, + SessionSnapshotRequestV1, + encode_request, +) +from loushang.appserver.protocol import ( + encode_response as encode_app_response, +) +from loushang.appserver.protocol.connection_profile import ( + AppConnectionProfileV1, + connection_hello, +) +from loushang.appserver.remote_client import RemoteAppClientV1 +from loushang.appservice.client_scope import ScopedAppServiceV1 +from tests.appservice.test_continuity_runtime import _MemoryLease +from tests.appservice.test_managed_mux import _binding, _open, _request + +from .test_connection import _pair, _SemanticClient +from .test_local import _until + + +def test_managed_wire_closed_roundtrip_and_credential_safe_repr(): + call = ManagedMuxCallV1("1", _request()) + result = ManagedMuxCreatedV1(call.request.operation_id, call.request.instance_id, "dev", "mux-1") + response = ManagedMuxResponseV1("1", result) + assert decode_call(encode_call(call)) == call + assert decode_response(encode_response(response)) == response + assert "secret" not in repr(call) + assert b"secret" not in encode_response(response) + + +@pytest.mark.parametrize("mutation", ( + {"extra": True}, {"protocolVersion": "loushang.app/v1"}, + {"requestId": True}, {"operation": "mux/close"}, {"request": None}, +)) +def test_managed_wire_rejects_unknown_or_cross_protocol_fields(mutation): + raw = json.loads(encode_call(ManagedMuxCallV1("1", _request()))) + with pytest.raises(InvalidAppMessageError): + decode_call(json.dumps(raw | mutation).encode()) + + +def test_managed_wire_rejects_duplicate_keys_and_oversized_authority(): + encoded = encode_call(ManagedMuxCallV1("1", _request())) + with pytest.raises(InvalidAppMessageError): + decode_call(encoded.replace(b'"requestId":"1"', b'"requestId":"1","requestId":"2"')) + raw = json.loads(encoded) + raw["request"]["authority"] = "x" * 1025 + with pytest.raises(InvalidAppMessageError): + decode_call(json.dumps(raw).encode()) + + +def test_authenticated_managed_creation_survives_client_eof_without_replay(tmp_path): + async def run(): + entered, release = asyncio.Event(), asyncio.Event() + + class PausedLease(_MemoryLease): + async def commit(self, *, expected_revision, record): + entered.set() + await release.wait() + await super().commit(expected_revision=expected_revision, record=record) + + lease = PausedLease() + service = await _open(lease, _binding([])) + owner = ScopedAppServiceV1(service) + directory = LocalConnectionDirectoryV1(tmp_path / "connection") + server = LocalAppServerV1( + directory, "managed", application_id="coding.default", product_id="coding", + scopes=(LocalRecordScopeV1(SessionScopeV1.CWD, "a" * 64),), + scope_factory=owner.open_client_scope, + managed_mux_scope_factory=owner.open_client_scope, + request_stop=lambda _: pytest.fail("client EOF must not stop service"), + ) + first, second = (LocalAppClientConnectionV1(directory, "managed") for _ in range(2)) + waiter = None + try: + await server.start() + record = directory.read("managed") + assert record.mux_management + await first.start() + assert first.managed_mux_client is not None + with pytest.raises(AppServiceError): + await first.client.create_mux(MuxCreateV1("legacy")) + waiter = asyncio.create_task(first.managed_mux_client.create_managed_mux(_request())) + await entered.wait() + await first.close() + assert not lease.commits + assert owner.pending_counts == (1, 0) + release.set() + await second.start() + result = await second.managed_mux_client.create_managed_mux(_request()) + assert result == lease.record.managed_creations[0] + assert len(lease.commits) == 1 + assert len((await second.client.list_muxes()).mux_spaces) == 1 + with pytest.raises(AppServiceError): + await second.managed_mux_client.create_managed_mux(replace(_request(), authority="wrong")) + finally: + release.set() + if waiter is not None: + await asyncio.gather(waiter, return_exceptions=True) + await first.close() + await second.close() + await server.close() + await service.close() + directory.close() + + asyncio.run(asyncio.wait_for(run(), 15)) + + +@pytest.mark.parametrize("request_family,reply_family", ( + ("app", "execution"), ("app", "managed"), + ("execution", "app"), ("execution", "managed"), + ("managed", "app"), ("managed", "execution"), + ("close", "app"), ("close", "execution"), ("close", "managed"), + ("app", "close"), ("execution", "close"), ("managed", "close"), +)) +def test_matching_request_id_cannot_accept_failure_from_another_protocol(request_family, reply_family): + from loushang.appserver.managed_mux_wire import ( + ManagedMuxCloseResponseV1, + encode_close_response, + ) + from tests.appserver.test_managed_mux_close_wire import request as close_request + + async def run(): + left, right = _pair() + frames = AppFramedStreamV1(right) + profile = (AppConnectionProfileV1.LOCAL_EXECUTION_MANAGED_CLOSE if "close" in (request_family, reply_family) + else AppConnectionProfileV1.LOCAL_EXECUTION_MANAGED) + remote = RemoteAppClientV1(AppFramedStreamV1(left), profile=profile) + + async def peer(): + hello = connection_hello(profile, service_instance_id="instance") + await frames.send(hello) + assert await frames.receive() == hello + call = json.loads(await frames.receive()) + request_id = call["requestId"] + failure = AppFailureV1(AppErrorCodeV1.OPERATION_UNAVAILABLE) + payload = { + "app": lambda: encode_app_response(AppResponseV1(request_id, failure)), + "execution": lambda: encode_execution_response(ExecutionResponseV1(request_id, failure)), + "managed": lambda: encode_response(ManagedMuxResponseV1(request_id, failure)), + "close": lambda: encode_close_response(ManagedMuxCloseResponseV1(request_id, failure)), + }[reply_family]() + await frames.send(payload) + + serving = asyncio.create_task(peer()) + try: + await remote.start() + with pytest.raises(AppConnectionClosedError): + if request_family == "app": + await remote.list_muxes() + elif request_family == "execution": + await remote.execution_client.get_execution( + SessionSnapshotRequestV1("attachment", 1, "member"), "instance", "execution", + ) + elif request_family == "close": + await remote.managed_mux_close_client.close_managed_mux(close_request()) + else: + await remote.managed_mux_client.create_managed_mux(_request()) + assert remote.managed_mux_client is None + assert not remote._pending + assert remote._counts == {False: 0, True: 0} + finally: + await remote.close() + await serving + await frames.close() + + asyncio.run(asyncio.wait_for(run(), 5)) + + +@pytest.mark.parametrize("discovery,execution,expected_profile", ( + (False, False, AppConnectionProfileV1.LOCAL_MANAGED), + (True, False, AppConnectionProfileV1.LOCAL_DISCOVERY_MANAGED), + (False, True, AppConnectionProfileV1.LOCAL_EXECUTION_MANAGED), + (True, True, AppConnectionProfileV1.LOCAL_DISCOVERY_EXECUTION_MANAGED), +)) +@pytest.mark.parametrize("closure", [False, True]) +def test_local_managed_profiles_borrow_all_capabilities_from_one_scope( + tmp_path, discovery, execution, expected_profile, closure, +): + if closure: + expected_profile = AppConnectionProfileV1(expected_profile.value.removesuffix("/v1") + "/v2") + async def run(): + service = await _open(_MemoryLease(), _binding([])) + owner = ScopedAppServiceV1(service) + opened, closed = [], [] + + class Scope: + def __init__(self): + self.inner = owner.open_client_scope() + self.managed_mux_client = self.inner.managed_mux_client + self.managed_mux_close_client = object() if closure else None + self.discovery_client = object() if discovery else None + self.execution_client = SimpleNamespace(service_instance_id="instance") if execution else None + opened.append(self) + + def __getattr__(self, name): + return getattr(self.inner, name) + + async def close(self): + await self.inner.close() + closed.append(self) + + def fallback(): + pytest.fail("optional capabilities must not acquire separate scopes") + + directory = LocalConnectionDirectoryV1(tmp_path / "connection") + server = LocalAppServerV1( + directory, "managed", application_id="coding.default", product_id="coding", + scopes=(LocalRecordScopeV1(SessionScopeV1.CWD, "a" * 64),), + scope_factory=fallback, managed_mux_scope_factory=Scope, + mux_closure=closure, + discovery_scope_factory=fallback if discovery else None, + execution_scope_factory=fallback if execution else None, + request_stop=lambda _: pytest.fail("client EOF must not stop service"), + ) + client = LocalAppClientConnectionV1(directory, "managed") + try: + assert client.managed_mux_client is None + await server.start() + record = directory.read("managed") + assert record.semantic_profile is expected_profile + assert decode_connection_record(encode_connection_record(record)) == record + assert "mux_management" in json.loads(encode_connection_record(record))["capabilities"] + await client.start() + assert (client.discovery_client is not None) is discovery + assert (client.execution_client is not None) is execution + assert client.managed_mux_client is not None + assert (client.managed_mux_close_client is not None) is closure + result = await client.managed_mux_client.create_managed_mux(_request()) + assert result.name == "dev" + assert len(opened) == 1 + await client.close() + await _until(lambda: server.connection_counts == (0, 0)) + assert closed == opened + assert client.managed_mux_client is None + finally: + await client.close() + await server.close() + await service.close() + directory.close() + + asyncio.run(asyncio.wait_for(run(), 10)) + + +def _failure_frame(family, request_id): + failure = AppFailureV1(AppErrorCodeV1.OPERATION_UNAVAILABLE) + if family == "loushang.managed-mux/v1": + return encode_response(ManagedMuxResponseV1(request_id, failure)) + if family == "loushang.execution/v1": + return encode_execution_response(ExecutionResponseV1(request_id, failure)) + return encode_app_response(AppResponseV1(request_id, failure)) + + +@pytest.mark.parametrize("field,value", (("operation_id", "e" * 32), ("name", "other"))) +def test_cancelled_waiter_cannot_bypass_managed_receipt_intent_validation(field, value): + async def run(): + left, right = _pair() + frames = AppFramedStreamV1(right) + remote = RemoteAppClientV1(AppFramedStreamV1(left), profile=AppConnectionProfileV1.LOCAL_MANAGED) + hello = connection_hello(AppConnectionProfileV1.LOCAL_MANAGED) + await frames.send(hello) + await remote.start() + assert await frames.receive() == hello + request = _request() + waiter = asyncio.create_task(remote.create_managed_mux(request)) + try: + call = decode_call(await frames.receive()) + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + assert len(remote._pending) == 1 + result = ManagedMuxCreatedV1(request.operation_id, request.instance_id, request.name, "mux") + await frames.send(encode_response(ManagedMuxResponseV1(call.request_id, replace(result, **{field: value})))) + await _until(lambda: not remote._pending) + assert remote.managed_mux_client is None + assert remote._closed + assert remote._counts == {False: 0, True: 0} + finally: + await remote.close() + await frames.close() + await asyncio.gather(waiter, return_exceptions=True) + + asyncio.run(asyncio.wait_for(run(), 5)) + + +def test_historical_managed_receipt_instance_is_not_rewritten_or_rejected(): + async def run(): + left, right = _pair() + frames = AppFramedStreamV1(right) + remote = RemoteAppClientV1(AppFramedStreamV1(left), profile=AppConnectionProfileV1.LOCAL_MANAGED) + hello = connection_hello(AppConnectionProfileV1.LOCAL_MANAGED) + await frames.send(hello) + await remote.start() + assert await frames.receive() == hello + request = _request() + waiter = asyncio.create_task(remote.create_managed_mux(request)) + try: + call = decode_call(await frames.receive()) + historical = ManagedMuxCreatedV1(request.operation_id, "e" * 32, request.name, "mux") + await frames.send(encode_response(ManagedMuxResponseV1(call.request_id, historical))) + assert await waiter == historical + assert remote.managed_mux_client is not None + finally: + await remote.close() + await frames.close() + await asyncio.gather(waiter, return_exceptions=True) + + asyncio.run(asyncio.wait_for(run(), 5)) + + +def test_mixed_protocols_share_client_request_numbers_and_ordinary_capacity(): + async def run(): + left, right = _pair() + frames = AppFramedStreamV1(right) + profile = AppConnectionProfileV1.LOCAL_EXECUTION_MANAGED + remote = RemoteAppClientV1(AppFramedStreamV1(left), profile=profile) + full, release = asyncio.Event(), asyncio.Event() + calls = [] + + async def peer(): + hello = connection_hello(profile, service_instance_id="instance") + await frames.send(hello) + assert await frames.receive() == hello + for _ in range(16): + calls.append(json.loads(await frames.receive())) + full.set() + # The independent control allowance still uses the SAME ID sequence. + control = json.loads(await frames.receive()) + assert control["requestId"] == "17" + await frames.send(_failure_frame(control["protocolVersion"], "17")) + await release.wait() + for call in calls: + await frames.send(_failure_frame(call["protocolVersion"], call["requestId"])) + + serving = asyncio.create_task(peer()) + pending = [] + try: + await remote.start() + for index in range(16): + if index % 3 == 0: + work = remote.list_muxes() + elif index % 3 == 1: + work = remote.managed_mux_client.create_managed_mux(_request()) + else: + work = remote.execution_client.submit_execution( + SessionSnapshotRequestV1("attachment", 1, "member"), "instance", f"s{index}", "text", + ) + pending.append(asyncio.create_task(work)) + await full.wait() + assert [call["requestId"] for call in calls] == [str(n) for n in range(1, 17)] + assert len({call["protocolVersion"] for call in calls}) == 3 + assert remote._counts == {False: 16, True: 0} + with pytest.raises(AppServiceError) as error: + await remote.managed_mux_client.create_managed_mux(_request()) + assert error.value.code is AppErrorCodeV1.OPERATION_UNAVAILABLE + assert remote._next_id == 16 + with pytest.raises(AppServiceError): + await remote.execution_client.get_execution( + SessionSnapshotRequestV1("attachment", 1, "member"), "instance", "execution", + ) + release.set() + results = await asyncio.gather(*pending, return_exceptions=True) + assert all(type(result) is AppServiceError for result in results) + assert not remote._pending + assert remote._counts == {False: 0, True: 0} + finally: + release.set() + await remote.close() + await asyncio.gather(*pending, return_exceptions=True) + await serving + await frames.close() + + asyncio.run(asyncio.wait_for(run(), 5)) + + +@pytest.mark.parametrize("managed_profile", (False, True)) +def test_unnegotiated_or_reused_managed_request_id_is_rejected_before_dispatch(managed_profile): + async def run(): + left, right = _pair() + frames = AppFramedStreamV1(left) + calls = [] + + class Semantic(_SemanticClient): + async def create_managed_mux(self, request): + calls.append(request) + raise AssertionError("invalid request must not dispatch") + + semantic = Semantic() + profile = AppConnectionProfileV1.LOCAL_MANAGED if managed_profile else AppConnectionProfileV1.LOCAL + server = AppServerConnectionV1( + semantic, AppFramedStreamV1(right), profile=profile, + managed_mux=semantic if managed_profile else None, + ) + serving = asyncio.create_task(server.serve()) + try: + assert await frames.receive() == connection_hello(profile) + await frames.send(connection_hello(profile)) + await frames.send(encode_request(AppRequestV1("1", AppOperationV1.MUX_LIST, MuxListV1()))) + await frames.receive() + await frames.send(encode_call(ManagedMuxCallV1("1" if managed_profile else "2", _request()))) + with pytest.raises(InvalidAppMessageError): + await serving + assert calls == [] + finally: + await server.close() + await frames.close() + await asyncio.gather(serving, return_exceptions=True) + + asyncio.run(asyncio.wait_for(run(), 5)) + + +def test_raw_mixed_requests_share_server_capacity_and_reject_overflow_before_dispatch(): + async def run(): + left, right = _pair() + frames = AppFramedStreamV1(left) + entered = asyncio.Queue() + release = asyncio.Event() + + class Semantic(_SemanticClient): + async def create_managed_mux(self, request): + entered.put_nowait("managed") + await release.wait() + return ManagedMuxCreatedV1(request.operation_id, request.instance_id, request.name, "mux") + + async def list_muxes(self): + entered.put_nowait("app") + await release.wait() + return await super().list_muxes() + + semantic = Semantic() + profile = AppConnectionProfileV1.LOCAL_MANAGED + server = AppServerConnectionV1(semantic, AppFramedStreamV1(right), profile=profile, managed_mux=semantic) + serving = asyncio.create_task(server.serve()) + try: + assert await frames.receive() == connection_hello(profile) + await frames.send(connection_hello(profile)) + for number in range(1, 17): + payload = (encode_call(ManagedMuxCallV1(str(number), _request())) if number % 2 + else encode_request(AppRequestV1(str(number), AppOperationV1.MUX_LIST, MuxListV1()))) + await frames.send(payload) + assert await entered.get() == ("managed" if number % 2 else "app") + assert server._counts == {False: 16, True: 0} + await frames.send(encode_call(ManagedMuxCallV1("17", _request()))) + result = decode_response(await frames.receive()) + assert result == ManagedMuxResponseV1("17", AppFailureV1(AppErrorCodeV1.OPERATION_UNAVAILABLE)) + assert entered.empty() + assert server._counts == {False: 16, True: 0} + release.set() + for _ in range(16): + await frames.receive() + await _until(lambda: server._counts == {False: 0, True: 0}) + finally: + release.set() + await frames.close() + await server.close() + await asyncio.gather(serving, return_exceptions=True) + + asyncio.run(asyncio.wait_for(run(), 5)) + + +@pytest.mark.parametrize("missing", ("managed_mux_client", "discovery_client", "execution_client")) +@pytest.mark.parametrize("raises", (False, True)) +def test_declared_capability_borrow_failure_closes_original_scope_without_fallback(tmp_path, missing, raises): + async def run(): + closed = [] + + class Scope: + def __getattr__(self, name): + if name == missing: + if raises: + raise RuntimeError("private getter failure") + return None + if name == "execution_client": + return SimpleNamespace(service_instance_id="instance") + return object() + + async def close(self): + closed.append(self) + + scope = Scope() + + def fallback(): + pytest.fail("no fallback is permitted") + + directory = LocalConnectionDirectoryV1(tmp_path / "connection") + server = LocalAppServerV1( + directory, "managed", application_id="coding.default", product_id="coding", + scopes=(LocalRecordScopeV1(SessionScopeV1.CWD, "a" * 64),), + scope_factory=fallback, managed_mux_scope_factory=lambda: scope, + discovery_scope_factory=fallback, execution_scope_factory=fallback, + request_stop=lambda _: pytest.fail("borrow failure must not stop service"), + ) + client = LocalAppClientConnectionV1(directory, "managed") + try: + await server.start() + with pytest.raises(AppServiceError): + await client.start() + await _until(lambda: server.connection_counts == (0, 0)) + assert closed == [scope] + finally: + await client.close() + await server.close() + directory.close() + + asyncio.run(asyncio.wait_for(run(), 10)) diff --git a/tests/appserver/test_session_discovery_connection.py b/tests/appserver/test_session_discovery_connection.py index 72163ea26..963050c52 100644 --- a/tests/appserver/test_session_discovery_connection.py +++ b/tests/appserver/test_session_discovery_connection.py @@ -11,6 +11,8 @@ from loushang.appserver.connection import AppServerConnectionV1 from loushang.appserver.execution.client import ExecutionClientV1 from loushang.appserver.framing import AppFramedStreamV1 +from loushang.appserver.managed_mux import ManagedMuxCreationClientV1 +from loushang.appserver.managed_mux_close import ManagedMuxCloseClientV1 from loushang.appserver.protocol import ( AppErrorCodeV1, AppFailureV1, @@ -29,6 +31,8 @@ AppConnectionProfileV1, connection_hello, supports_execution, + supports_managed_mux, + supports_managed_mux_close, ) from loushang.appserver.remote_client import RemoteAppClientV1 @@ -199,10 +203,15 @@ async def scenario(): if supports_execution(server_profile): execution = create_autospec(ExecutionClientV1, instance=True) execution.service_instance_id = "instance" + managed_close = (create_autospec(ManagedMuxCloseClientV1, instance=True) + if supports_managed_mux_close(server_profile) else None) server = AppServerConnectionV1( cast(AppClientV1, _SemanticClient()), AppFramedStreamV1(right), profile=server_profile, discovery=discovery, phase_timeout=0.2, execution=execution, + managed_mux=(create_autospec(ManagedMuxCreationClientV1, instance=True) + if supports_managed_mux(server_profile) else None), + managed_mux_close=managed_close, ) client = RemoteAppClientV1( AppFramedStreamV1(left), profile=client_profile, phase_timeout=0.2, @@ -214,6 +223,8 @@ async def scenario(): with pytest.raises(AppServiceError): await serving assert not discovery.requests + if managed_close is not None: + assert managed_close.mock_calls == [] assert client.discovery_client is None finally: await client.close() diff --git a/tests/appservice/test_continuity_runtime.py b/tests/appservice/test_continuity_runtime.py index 29ed56939..5cb8ccaa8 100644 --- a/tests/appservice/test_continuity_runtime.py +++ b/tests/appservice/test_continuity_runtime.py @@ -390,6 +390,108 @@ async def test_G13_ALL_OR_NOTHING_close_wins_race_before_publication() -> None: assert attempt.cleanup_pending is False +@_async_test +async def test_G13_RECOVERY_double_cancel_retains_success_and_closes_once() -> None: + events: list[str] = [] + entered, release = asyncio.Event(), asyncio.Event() + + class Resolver(_Resolver): + async def open_session(self, request): + entered.set() + await release.wait() + return await super().open_session(request) + + lease = _MemoryLease(_record("session-1")) + attempt = create_appservice_recovery_attempt( + AppServiceRecoveryRequestV1("coding", Resolver(events), lease) + ) + opening = asyncio.create_task(attempt.open()) + await asyncio.wait_for(entered.wait(), 2) + opening.cancel() + closing = asyncio.create_task(attempt.close()) + await asyncio.sleep(0) + closing.cancel() + release.set() + await asyncio.gather(opening, closing, return_exceptions=True) + await attempt.close() + assert events == ["close:session-1"] + assert not attempt.cleanup_pending + assert not lease.closed + + +@_async_test +async def test_G13_RECOVERY_concurrent_close_joins_original_cleanup() -> None: + events: list[str] = [] + resolver = _Resolver(events) + attempt = create_appservice_recovery_attempt(AppServiceRecoveryRequestV1( + "coding", resolver, _MemoryLease(_record("session-1")), + )) + # Cancel only the waiting client: successful service has not been transferred. + gate = asyncio.Event() + resolver.gate = gate + opening = asyncio.create_task(attempt.open()) + while not resolver.requests: + await asyncio.sleep(0) + opening.cancel() + gate.set() + with pytest.raises(asyncio.CancelledError): + await opening + entered, release = asyncio.Event(), asyncio.Event() + original = resolver.sessions[0].close + calls = 0 + + async def held(): + nonlocal calls + calls += 1 + entered.set() + await release.wait() + await original() + + resolver.sessions[0].close = held + first = asyncio.create_task(attempt.close()) + await asyncio.wait_for(entered.wait(), 2) + second = asyncio.create_task(attempt.close()) + first.cancel() + await asyncio.sleep(0) + assert not second.done() + release.set() + await asyncio.gather(first, second, return_exceptions=True) + assert calls == 1 and events == ["close:session-1"] + assert not attempt.cleanup_pending + + +@_async_test +async def test_G13_RECOVERY_late_open_delivery_cannot_reown_closed_service(monkeypatch) -> None: + from loushang.appservice import continuity_runtime + + events: list[str] = [] + attempt = create_appservice_recovery_attempt(AppServiceRecoveryRequestV1( + "coding", _Resolver(events), _MemoryLease(_record("session-1")), + )) + delivered, release = asyncio.Event(), asyncio.Event() + original_join = continuity_runtime._join_owned + opening = None + + async def delayed_join(task): + result = await original_join(task) + if asyncio.current_task() is opening: + delivered.set() + await release.wait() + return result + + monkeypatch.setattr(continuity_runtime, "_join_owned", delayed_join) + opening = asyncio.create_task(attempt.open()) + await asyncio.wait_for(delivered.wait(), 2) + await attempt.close() + assert events == ["close:session-1"] and not attempt.cleanup_pending + release.set() + with pytest.raises(AppServiceError) as caught: + await opening + assert caught.value.code is AppErrorCodeV1.SERVICE_CLOSED + await attempt.close() + assert events == ["close:session-1"] and not attempt.cleanup_pending + + @_async_test async def test_G13_ATOMIC_MUTATION_persists_all_mux_member_transitions() -> None: events: list[str] = [] diff --git a/tests/appservice/test_managed_mux.py b/tests/appservice/test_managed_mux.py new file mode 100644 index 000000000..1df7019da --- /dev/null +++ b/tests/appservice/test_managed_mux.py @@ -0,0 +1,564 @@ +"""Managed creation is an authorized durable operation, not create-by-name.""" + +from __future__ import annotations + +import asyncio +from dataclasses import replace +from pathlib import Path + +import pytest + +from loushang.appserver.managed_mux import ManagedMuxCreateV1 +from loushang.appserver.protocol import ( + AppErrorCodeV1, + AppServiceError, + MuxCloseV1, + MuxCreateV1, + MuxSelectorV1, +) +from loushang.appservice import ( + AppServiceRecoveryRequestV1, + JsonFileApplicationContinuityStoreV1, + create_appservice_recovery_attempt, +) +from loushang.appservice.continuity import ( + APPLICATION_CONTINUITY_VERSION, + MANAGED_CONTINUITY_VERSION, + ApplicationContinuityRecordV1, + decode_application_continuity_record, + encode_application_continuity_record, +) +from loushang.appservice.managed_mux import ManagedMuxServiceBindingV1 +from tests.appservice.test_continuity_runtime import _MemoryLease as _Lease +from tests.appservice.test_continuity_runtime import _Resolver + +SERVICE = "a" * 64 +INSTANCE = "b" * 32 +OPERATION = "c" * 32 + + +class _Admission: + def __init__(self, *, allowed: bool = True) -> None: + self.allowed = allowed + self.acquired = False + self.closed = 0 + self.fail_close = False + self.entered = asyncio.Event() + self.release: asyncio.Event | None = None + + async def acquire(self) -> None: + self.entered.set() + if self.release is not None: + await self.release.wait() + if not self.allowed: + raise AppServiceError(AppErrorCodeV1.OPERATION_UNAVAILABLE) + self.acquired = True + + async def close(self) -> None: + self.closed += 1 + if self.fail_close: + raise RuntimeError("private authority failure") + self.acquired = False + + def check_creation(self, previous) -> None: + assert self.acquired + + +def _request(**changes: object) -> ManagedMuxCreateV1: + return replace( + ManagedMuxCreateV1(SERVICE, INSTANCE, OPERATION, "dev", "secret"), + **changes, + ) + + +def _binding(admissions: list[_Admission], *, instance: str = INSTANCE): + def prepare(request: ManagedMuxCreateV1) -> _Admission: + owner = _Admission(allowed=request.authority == "secret") + admissions.append(owner) + return owner + + return ManagedMuxServiceBindingV1("coding.default", SERVICE, instance, prepare) + + +async def _open(lease, binding): + attempt = create_appservice_recovery_attempt(AppServiceRecoveryRequestV1( + "coding", _Resolver([]), lease, managed_mux=binding, + )) + return await attempt.open() + + +def test_managed_create_concurrent_replay_is_one_durable_result() -> None: + async def scenario(): + admissions: list[_Admission] = [] + lease = _Lease() + service = await _open(lease, _binding(admissions)) + try: + first, second = await asyncio.gather( + service.create_managed_mux(_request()), + service.create_managed_mux(_request()), + ) + assert first == second + assert first.operation_id == OPERATION + assert first.instance_id == INSTANCE + assert len(lease.commits) == 1 + assert lease.record.managed_creations == (first,) + assert lease.record.mux_spaces[0].mux_space_id == first.mux_space_id + assert len((await service.list_muxes()).mux_spaces) == 1 + assert len(admissions) == 2 + assert all(owner.closed == 1 and not owner.acquired for owner in admissions) + for request in ( + _request(name="other"), + _request(operation_id="d" * 32), + ): + with pytest.raises(AppServiceError): + await service.create_managed_mux(request) + assert len(lease.commits) == 1 + finally: + await service.close() + + asyncio.run(scenario()) + + +def test_managed_create_real_store_recovery_requires_fresh_instance_authority( + tmp_path: Path, +) -> None: + async def scenario(): + admissions: list[_Admission] = [] + store = JsonFileApplicationContinuityStoreV1(tmp_path / "continuity") + lease = await store.acquire(application_id="coding.default", owner_epoch="one") + service = await _open(lease, _binding(admissions)) + result = await service.create_managed_mux(_request()) + await service.close() + await lease.close() + lease = await store.acquire(application_id="coding.default", owner_epoch="two") + second = await _open(lease, _binding(admissions, instance="e" * 32)) + try: + with pytest.raises(AppServiceError): + await second.create_managed_mux(_request()) + assert await second.create_managed_mux( + _request(instance_id="e" * 32) + ) == result + assert second.continuity_revision == 1 + finally: + await second.close() + await lease.close() + + asyncio.run(scenario()) + + +def test_managed_legacy_mutations_and_bad_authority_have_no_effect() -> None: + async def scenario(): + admissions: list[_Admission] = [] + lease = _Lease() + service = await _open(lease, _binding(admissions)) + try: + for call in ( + lambda: service.create_mux(MuxCreateV1("dev")), + lambda: service.close_mux(MuxCloseV1(MuxSelectorV1(name="dev"))), + lambda: service.create_managed_mux(_request(authority="wrong")), + lambda: service.create_managed_mux(_request(service_id="f" * 64)), + ): + with pytest.raises(AppServiceError): + await call() + assert not lease.commits + assert len(admissions) == 1 + assert admissions[0].closed == 1 + await service.create_managed_mux(_request()) + with pytest.raises(AppServiceError): + await service.create_managed_mux(_request(authority="wrong")) + assert len(lease.commits) == 1 + finally: + await service.close() + + asyncio.run(scenario()) + + +def test_managed_create_cancelled_commit_still_publishes_original_receipt() -> None: + async def scenario(): + gate = asyncio.Event() + entered = asyncio.Event() + + class PausedLease(_Lease): + async def commit(self, *, expected_revision, record): + entered.set() + await super().commit(expected_revision=expected_revision, record=record) + + lease = PausedLease(commit_gate=gate) + admissions: list[_Admission] = [] + service = await _open(lease, _binding(admissions)) + task = asyncio.create_task(service.create_managed_mux(_request())) + await entered.wait() + task.cancel() + await asyncio.sleep(0) + assert not task.done() + assert admissions[0].acquired + assert admissions[0].closed == 0 + gate.set() + with pytest.raises(asyncio.CancelledError): + await task + result = await service.create_managed_mux(_request()) + assert lease.record.managed_creations == (result,) + assert len(lease.commits) == 1 + assert admissions[0].closed == 1 + await service.close() + + asyncio.run(scenario()) + + +def test_managed_cleanup_debt_retains_original_and_blocks_new_admission() -> None: + async def scenario(): + admission = _Admission() + admission.fail_close = True + binding = ManagedMuxServiceBindingV1( + "coding.default", SERVICE, INSTANCE, lambda _request: admission, + ) + lease = _Lease() + service = await _open(lease, binding) + with pytest.raises(AppServiceError) as error: + await service.create_managed_mux(_request()) + assert error.value.code is AppErrorCodeV1.CLEANUP_INCOMPLETE + assert len(lease.commits) == 1 + with pytest.raises(AppServiceError): + await service.create_managed_mux(_request(operation_id="d" * 32)) + assert admission.closed == 1 + admission.fail_close = False + await service.close() + assert admission.closed == 2 + assert not admission.acquired + + asyncio.run(scenario()) + + +def test_managed_unknown_commit_fences_runtime_and_recovery_finds_receipt() -> None: + class LostReplyLease(_Lease): + async def commit(self, *, expected_revision, record): + await super().commit(expected_revision=expected_revision, record=record) + raise RuntimeError("commit reply lost after write") + + async def scenario(): + admissions: list[_Admission] = [] + lease = LostReplyLease() + service = await _open(lease, _binding(admissions)) + with pytest.raises(AppServiceError): + await service.create_managed_mux(_request()) + assert len(lease.commits) == 1 + for request in (_request(), _request(operation_id="d" * 32, name="other")): + with pytest.raises(AppServiceError): + await service.create_managed_mux(request) + assert len(admissions) == 1 + await service.close() + recovered_lease = _Lease(lease.record) + recovered = await _open(recovered_lease, _binding(admissions)) + assert await recovered.create_managed_mux(_request()) == lease.record.managed_creations[0] + assert not recovered_lease.commits + await recovered.close() + + asyncio.run(scenario()) + + +def test_managed_recovery_cannot_silently_switch_owner_or_legacy_profile() -> None: + async def scenario(): + admissions: list[_Admission] = [] + original = ApplicationContinuityRecordV1("coding.default", "coding", 1) + with pytest.raises(AppServiceError): + await _open(_Lease(original), _binding(admissions)) + managed = replace(original, contract_version=MANAGED_CONTINUITY_VERSION, + managed_service_id=SERVICE) + for binding in (None, replace(_binding(admissions), service_id="f" * 64)): + with pytest.raises(AppServiceError): + await _open(_Lease(managed), binding) + assert not admissions + + asyncio.run(scenario()) + + +def test_managed_receipts_are_bounded_strict_and_do_not_change_legacy_bytes() -> None: + import json + + from loushang.appserver.managed_mux import ManagedMuxCreatedV1 + from loushang.appservice.continuity import ApplicationContinuityError + + legacy = ApplicationContinuityRecordV1("coding.default", "coding", 1) + assert encode_application_continuity_record(legacy) == ( + b'{"applicationId":"coding.default","contractVersion":' + b'"loushang.appservice.continuity/v1","muxSpaces":[],"productId":"coding",' + b'"recordRevision":1}' + ) + receipt = ManagedMuxCreatedV1(OPERATION, INSTANCE, "dev", "mux-old") + managed = replace(legacy, contract_version=MANAGED_CONTINUITY_VERSION, + managed_service_id=SERVICE, managed_creations=(receipt,)) + assert decode_application_continuity_record(encode_application_continuity_record(managed)) == managed + raw = json.loads(encode_application_continuity_record(managed)) + for mutation in ( + {"contractVersion": APPLICATION_CONTINUITY_VERSION}, + {"managedServiceId": True}, + {"managedCreations": raw["managedCreations"] * 2}, + {"managedCreations": raw["managedCreations"] * 4097}, + {"extra": "ignored?"}, + {"managedCreations": [dict(raw["managedCreations"][0], authority="secret")]}, + ): + with pytest.raises(ApplicationContinuityError): + decode_application_continuity_record(json.dumps(raw | mutation).encode()) + assert "secret" not in repr(_request()) + + +def test_managed_retired_creation_replay_never_resurrects_mux() -> None: + from loushang.appserver.managed_mux import ManagedMuxCreatedV1 + + async def scenario(): + # Simulate a future logical close: the historical creation stays, the + # live Mux does not. A delayed create RPC must not recreate that Mux. + receipt = ManagedMuxCreatedV1(OPERATION, INSTANCE, "dev", "mux-retired") + record = ApplicationContinuityRecordV1( + "coding.default", "coding", 2, contract_version=MANAGED_CONTINUITY_VERSION, + managed_service_id=SERVICE, managed_creations=(receipt,), + ) + lease = _Lease(record) + service = await _open(lease, _binding([])) + assert await service.create_managed_mux(_request()) == receipt + assert not (await service.list_muxes()).mux_spaces + assert not lease.commits + await service.close() + + asyncio.run(scenario()) + + +def test_managed_byte_capacity_rejection_does_not_fence_existing_runtime(monkeypatch) -> None: + async def scenario(): + lease = _Lease() + service = await _open(lease, _binding([])) + first = await service.create_managed_mux(_request()) + size = len(encode_application_continuity_record(lease.record)) + monkeypatch.setattr("loushang.appservice.continuity.MAX_CONTINUITY_RECORD_BYTES", size + 1) + with pytest.raises(AppServiceError): + await service.create_managed_mux(_request(operation_id="d" * 32, name="other")) + assert await service.create_managed_mux(_request()) == first + assert len((await service.list_muxes()).mux_spaces) == 1 + assert len(lease.commits) == 1 + await service.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("phase", ("acquire", "commit", "close")) +@pytest.mark.parametrize("spawned", (False, True)) +def test_managed_task_publication_failure_cannot_start_late_effect(phase, spawned) -> None: + async def scenario(): + lease = _Lease() + admissions: list[_Admission] = [] + service = await _open(lease, _binding(admissions)) + loop = asyncio.get_running_loop() + original = loop.get_task_factory() + tasks: list[asyncio.Task] = [] + calls = 0 + failed_at = {"acquire": 1, "commit": 2, "close": 3}[phase] + + def factory(loop, coroutine, **kwargs): + nonlocal calls + calls += 1 + if calls == failed_at: + if spawned: + tasks.append(asyncio.Task(coroutine, loop=loop, **kwargs)) + raise RuntimeError("lost original task receipt") + return asyncio.Task(coroutine, loop=loop, **kwargs) + + loop.set_task_factory(factory) + try: + with pytest.raises(AppServiceError): + await service.create_managed_mux(_request()) + finally: + loop.set_task_factory(original) + await asyncio.gather(*tasks, return_exceptions=True) + assert len(lease.commits) == (1 if phase == "close" else 0) + assert len(admissions) == 1 + admission = admissions[0] + if phase == "close": + assert admission.acquired and admission.closed == 0 + else: + assert not admission.acquired and admission.closed == 1 + await service.close() + assert admission.closed == 1 + assert not admission.acquired + + asyncio.run(scenario()) + + +def test_managed_cancelled_acquisition_closes_only_after_original_settles() -> None: + async def scenario(): + admission = _Admission() + admission.release = asyncio.Event() + binding = ManagedMuxServiceBindingV1( + "coding.default", SERVICE, INSTANCE, lambda _request: admission, + ) + lease = _Lease() + service = await _open(lease, binding) + task = asyncio.create_task(service.create_managed_mux(_request())) + await admission.entered.wait() + task.cancel() + await asyncio.sleep(0) + assert not task.done() and admission.closed == 0 + closing = asyncio.create_task(service.close()) + await asyncio.sleep(0) + assert not closing.done() + admission.release.set() + with pytest.raises(asyncio.CancelledError): + await task + await closing + assert admission.closed == 1 + assert not lease.commits + + asyncio.run(scenario()) + + +def test_managed_scoped_create_survives_delivery_eof_and_blocks_legacy_before_tasks() -> None: + from loushang.appserver.protocol import MuxAttachV1 + from loushang.appservice.client_scope import ScopedAppServiceV1 + + async def scenario(): + entered, release = asyncio.Event(), asyncio.Event() + + class PausedLease(_Lease): + async def commit(self, *, expected_revision, record): + entered.set() + await release.wait() + await super().commit(expected_revision=expected_revision, record=record) + + lease = PausedLease() + admissions: list[_Admission] = [] + service = await _open(lease, _binding(admissions)) + owner = ScopedAppServiceV1(service) + first = owner.open_client_scope() + assert first.managed_mux_client is first + waiter = asyncio.create_task(first.create_managed_mux(_request())) + await entered.wait() + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + await first.close() + assert owner.pending_counts == (1, 0) + assert admissions[0].acquired and admissions[0].closed == 0 + release.set() + second = owner.open_client_scope() + receipt = await second.create_managed_mux(_request()) + assert len(lease.commits) == 1 + selector = MuxSelectorV1(mux_space_id=receipt.mux_space_id) + attached = await second.attach_mux(MuxAttachV1(selector)) + for call in ( + lambda: second.create_mux(MuxCreateV1("other")), + lambda: second.close_mux(MuxCloseV1(selector)), + ): + with pytest.raises(AppServiceError): + await call() + assert owner.pending_counts == (0, 0) + control = second._controllers[receipt.mux_space_id] + assert not control.fenced and control.attachment == attached + assert len(lease.commits) == 1 + await second.close() + await service.close() + + asyncio.run(scenario()) + + +def test_managed_full_receipt_history_allows_replay_but_no_new_commit() -> None: + from loushang.appserver.managed_mux import ManagedMuxCreatedV1 + from loushang.appservice.continuity import MAX_MANAGED_CREATIONS + + async def scenario(): + receipts = tuple( + ManagedMuxCreatedV1(f"{index:032x}", INSTANCE, f"mux-{index}", f"id-{index}") + for index in range(MAX_MANAGED_CREATIONS) + ) + record = ApplicationContinuityRecordV1( + "coding.default", "coding", 1, contract_version=MANAGED_CONTINUITY_VERSION, + managed_service_id=SERVICE, managed_creations=receipts, + ) + assert decode_application_continuity_record(encode_application_continuity_record(record)) == record + lease = _Lease(record) + service = await _open(lease, _binding([])) + assert await service.create_managed_mux( + _request(operation_id=receipts[0].operation_id, name=receipts[0].name) + ) == receipts[0] + with pytest.raises(AppServiceError): + await service.create_managed_mux(_request()) + assert not lease.commits + assert not (await service.list_muxes()).mux_spaces + await service.close() + + asyncio.run(scenario()) + + +def test_managed_continuity_requires_unique_operations_and_exact_live_mux_receipts() -> None: + from loushang.appserver.managed_mux import ManagedMuxCreatedV1 + from loushang.appservice.continuity import MuxSpaceContinuityV1 + + receipt = ManagedMuxCreatedV1(OPERATION, INSTANCE, "dev", "mux-1") + record = ApplicationContinuityRecordV1( + "coding.default", "coding", 1, contract_version=MANAGED_CONTINUITY_VERSION, + managed_service_id=SERVICE, managed_creations=(receipt,), + ) + for receipts in ( + (receipt, replace(receipt, mux_space_id="mux-2")), + (receipt, replace(receipt, operation_id="d" * 32)), + ): + with pytest.raises(ValueError): + replace(record, managed_creations=receipts) + for live in (MuxSpaceContinuityV1("missing", "dev", 1), + MuxSpaceContinuityV1("mux-1", "other", 1)): + with pytest.raises(ValueError): + replace(record, mux_spaces=(live,)) + + +def test_managed_tab_mutations_preserve_original_creation_through_recovery() -> None: + from loushang.appserver.protocol import ( + MuxMemberCloseV1, + MuxMemberOpenV1, + SessionOpenSpecV1, + SessionScopeV1, + ) + + async def scenario(): + lease = _Lease() + service = await _open(lease, _binding([])) + receipt = await service.create_managed_mux(_request()) + selector = MuxSelectorV1(mux_space_id=receipt.mux_space_id) + for index in range(2): + mux = await service.open_member(MuxMemberOpenV1(selector, SessionOpenSpecV1( + "coding", f"continuity-{index}", SessionScopeV1.CWD, "a" * 64, f"Tab {index}", + ))) + assert lease.record.managed_creations == (receipt,) + assert len(mux.members) == 2 + await service.close_member(MuxMemberCloseV1(selector, mux.members[0].member_id)) + assert lease.record.managed_creations == (receipt,) + assert len(lease.record.mux_spaces[0].members) == 1 + await service.close() + recovered = await _open(_Lease(lease.record), _binding([], instance="e" * 32)) + assert await recovered.create_managed_mux(_request(instance_id="e" * 32)) == receipt + assert len((await recovered.list_muxes()).mux_spaces[0].members) == 1 + await recovered.close() + + asyncio.run(scenario()) + + +def test_managed_scoped_live_capacity_still_allows_historical_replay() -> None: + from loushang.appservice.client_scope import ScopedAppServiceV1 + + async def scenario(): + lease = _Lease() + service = await _open(lease, _binding([])) + owner = ScopedAppServiceV1(service) + client = owner.open_client_scope() + first_request = _request(operation_id="0" * 32, name="mux-0") + first = await client.create_managed_mux(first_request) + for index in range(1, 32): + await client.create_managed_mux(_request( + operation_id=f"{index:032x}", name=f"mux-{index}", + )) + assert await client.create_managed_mux(first_request) == first + with pytest.raises(AppServiceError): + await client.create_managed_mux(_request()) + assert len(lease.commits) == 32 + assert len((await client.list_muxes()).mux_spaces) == 32 + await client.close() + await service.close() + + asyncio.run(scenario()) diff --git a/tests/appservice/test_managed_mux_close_continuity.py b/tests/appservice/test_managed_mux_close_continuity.py new file mode 100644 index 000000000..eb868fdc7 --- /dev/null +++ b/tests/appservice/test_managed_mux_close_continuity.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import asyncio +import json +from dataclasses import replace + +import pytest + +from loushang.appserver.managed_mux import ManagedMuxCreatedV1 +from loushang.appserver.protocol import AppErrorCodeV1, AppServiceError +from loushang.appservice import ( + AppServiceRecoveryRequestV1, + create_appservice_recovery_attempt, +) +from loushang.appservice.continuity import ( + APPLICATION_CONTINUITY_VERSION, + MANAGED_CONTINUITY_VERSION, + ApplicationContinuityError, + ApplicationContinuityRecordV1, + decode_application_continuity_record, + encode_application_continuity_record, +) +from tests.appservice.test_continuity import _record +from tests.appservice.test_continuity_runtime import _MemoryLease, _Resolver +from tests.appservice.test_managed_mux import _binding + +SERVICE = "a" * 64 + + +def record(*, closed=False): + from loushang.appserver.managed_mux_close import ( + ManagedMuxClosePhaseV1, + ManagedMuxCloseStateV1, + ) + from loushang.appservice.continuity import MANAGED_CLOSE_CONTINUITY_VERSION + + original = _record() + creation = ManagedMuxCreatedV1("a" * 32, "b" * 32, "dev", "mux-1") + close = ManagedMuxCloseStateV1( + "c" * 32, "d" * 32, creation.operation_id, creation.name, creation.mux_space_id, + ManagedMuxClosePhaseV1.CLOSED if closed else ManagedMuxClosePhaseV1.CLEANUP_PENDING, + ) + return replace(original, contract_version=MANAGED_CLOSE_CONTINUITY_VERSION, + managed_service_id=SERVICE, managed_creations=(creation,), + managed_closures=(close,), mux_spaces=() if closed else original.mux_spaces) + + +@pytest.mark.parametrize("closed", [False, True]) +def test_close_record_roundtrips_exact_phase_and_retained_members(closed): + value = record(closed=closed) + encoded = encode_application_continuity_record(value) + assert decode_application_continuity_record(encoded) == value + assert (not value.mux_spaces) is closed + assert "authority" not in encoded.decode() + assert "managedClosures" in encoded.decode() + # Creation may precede the closing instance, including after a clean restart. + assert value.managed_creations[0].instance_id != value.managed_closures[0].instance_id + + +def test_previous_versions_keep_exact_bytes_and_reject_close_fields(): + legacy = ApplicationContinuityRecordV1("coding.default", "coding", 1) + assert encode_application_continuity_record(legacy) == ( + b'{"applicationId":"coding.default","contractVersion":' + b'"loushang.appservice.continuity/v1","muxSpaces":[],"productId":"coding",' + b'"recordRevision":1}' + ) + previous = replace(legacy, contract_version=MANAGED_CONTINUITY_VERSION, + managed_service_id=SERVICE) + assert encode_application_continuity_record(previous) == ( + b'{"applicationId":"coding.default","contractVersion":' + b'"loushang.appservice.continuity/v2","managedCreations":[],"managedServiceId":"' + + SERVICE.encode() + b'","muxSpaces":[],"productId":"coding","recordRevision":1}' + ) + for value in (legacy, previous): + raw = json.loads(encode_application_continuity_record(value)) + with pytest.raises(ApplicationContinuityError): + decode_application_continuity_record(json.dumps(raw | {"managedClosures": []}).encode()) + with pytest.raises(ValueError): + replace(value, managed_closures=record().managed_closures) + + +@pytest.mark.parametrize("mutation", [ + "missing_field", "unknown_field", "duplicate_key", "duplicate_record", + "duplicate_target", "unknown_creation", "name_mismatch", + "mux_mismatch", "invalid_phase", "authority", "missing_pending_mux", + "retained_closed_mux", "aliased_operation", "list_not_tuple", +]) +def test_close_record_rejects_inconsistent_or_ambiguous_state(mutation): + value = record() + raw = json.loads(encode_application_continuity_record(value)) + closure = raw["managedClosures"][0] + if mutation == "list_not_tuple": + with pytest.raises(ValueError): + replace(value, managed_closures=list(value.managed_closures)) + return + if mutation == "missing_field": + del raw["managedClosures"] + elif mutation == "unknown_field": + raw["unknown"] = True + elif mutation == "duplicate_key": + payload = encode_application_continuity_record(value) + payload = payload.replace(b'"phase":', b'"phase":"closed","phase":') + with pytest.raises(ApplicationContinuityError): + decode_application_continuity_record(payload) + return + elif mutation == "duplicate_record": + raw["managedClosures"].append(dict(closure)) + elif mutation == "duplicate_target": + raw["managedClosures"].append(dict(closure, operationId="e" * 32)) + elif mutation == "unknown_creation": + closure["creationOperationId"] = "f" * 32 + elif mutation == "name_mismatch": + closure["name"] = "other" + elif mutation == "mux_mismatch": + closure["muxSpaceId"] = "other" + elif mutation == "invalid_phase": + closure["phase"] = "stop_requested" + elif mutation == "authority": + closure["authority"] = "secret" + elif mutation == "missing_pending_mux": + raw["muxSpaces"] = [] + elif mutation == "retained_closed_mux": + closure["phase"] = "closed" + elif mutation == "aliased_operation": + closure["operationId"] = raw["managedCreations"][0]["operationId"] + with pytest.raises(ApplicationContinuityError): + decode_application_continuity_record(json.dumps(raw).encode()) + + +def test_old_closed_mux_does_not_forbid_different_mux_reusing_name(): + value = record(closed=True) + creation = ManagedMuxCreatedV1("e" * 32, "f" * 32, "dev", "mux-new") + new_mux = replace(_record().mux_spaces[0], mux_space_id=creation.mux_space_id) + value = replace(value, managed_creations=(*value.managed_creations, creation), mux_spaces=(new_mux,)) + assert decode_application_continuity_record(encode_application_continuity_record(value)) == value + + +@pytest.mark.parametrize("alias", ["other_close", "other_creation"]) +def test_close_operation_is_unique_across_independently_valid_targets(alias): + value = record(closed=True) + creation = ManagedMuxCreatedV1("e" * 32, "b" * 32, "other", "mux-other") + closure = replace(value.managed_closures[0], operation_id="f" * 32, + creation_operation_id=creation.operation_id, + name=creation.name, mux_space_id=creation.mux_space_id) + value = replace(value, managed_creations=(*value.managed_creations, creation), + managed_closures=(*value.managed_closures, closure)) + encoded = encode_application_continuity_record(value) + assert decode_application_continuity_record(encoded) == value + raw = json.loads(encoded) + raw["managedClosures"][1]["operationId"] = ( + value.managed_closures[0].operation_id if alias == "other_close" + else value.managed_creations[0].operation_id + ) + with pytest.raises(ApplicationContinuityError): + decode_application_continuity_record(json.dumps(raw).encode()) + + +def test_close_history_is_bounded_before_encoding_and_still_obeys_byte_limit(monkeypatch): + from loushang.appservice import continuity + + original = record(closed=True) + creations = tuple( + ManagedMuxCreatedV1(f"{index:032x}", "b" * 32, "dev", f"mux-{index}") + for index in range(continuity.MAX_MANAGED_CLOSURES) + ) + closures = tuple( + replace(original.managed_closures[0], operation_id=f"{index + 65536:032x}", + creation_operation_id=creation.operation_id, mux_space_id=creation.mux_space_id) + for index, creation in enumerate(creations) + ) + maximum = replace(original, managed_creations=creations, managed_closures=closures) + assert len(maximum.managed_closures) == continuity.MAX_MANAGED_CLOSURES + with pytest.raises(ApplicationContinuityError): + encode_application_continuity_record(maximum) + # Independently exercise the closure-count limit with otherwise valid history. + monkeypatch.setattr(continuity, "MAX_MANAGED_CLOSURES", 2) + with pytest.raises(ValueError, match="closure history"): + replace(original, managed_creations=creations[:3], managed_closures=closures[:3]) + + +def test_absent_mux_without_close_fact_does_not_invent_settlement(): + value = replace(record(closed=True), managed_closures=()) + restored = decode_application_continuity_record(encode_application_continuity_record(value)) + assert restored.managed_creations and not restored.mux_spaces and not restored.managed_closures + + +def test_real_store_reopens_pending_and_closed_without_losing_identity(tmp_path): + from loushang.appservice import JsonFileApplicationContinuityStoreV1 + + async def scenario(): + store = JsonFileApplicationContinuityStoreV1(tmp_path / "continuity") + pending = record() + closed = replace(record(closed=True), record_revision=2) + first = await store.acquire(application_id=pending.application_id, owner_epoch="first") + try: + await first.commit(expected_revision=None, record=pending) + finally: + await first.close() + second = await store.acquire(application_id=pending.application_id, owner_epoch="second") + try: + assert await second.load() == pending + await second.commit(expected_revision=1, record=closed) + finally: + await second.close() + third = await store.acquire(application_id=pending.application_id, owner_epoch="third") + try: + assert await third.load() == closed + finally: + await third.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("version", [APPLICATION_CONTINUITY_VERSION, MANAGED_CONTINUITY_VERSION]) +def test_close_record_cannot_be_downgraded_by_version_tag(version): + raw = json.loads(encode_application_continuity_record(record())) + raw["contractVersion"] = version + with pytest.raises(ApplicationContinuityError): + decode_application_continuity_record(json.dumps(raw).encode()) + + +@pytest.mark.parametrize("managed", [False, True]) +@pytest.mark.parametrize("closed", [False, True]) +def test_unactivated_close_recovery_rejects_before_any_session_open(managed, closed): + async def scenario(): + events = [] + admissions = [] + value = record(closed=closed) + if closed: + # Retain an unrelated active Mux so accidental recovery calls the resolver. + creation = ManagedMuxCreatedV1("e" * 32, "f" * 32, "other", "mux-other") + mux = replace(_record().mux_spaces[0], mux_space_id="mux-other", name="other") + value = replace(value, managed_creations=(*value.managed_creations, creation), mux_spaces=(mux,)) + lease = _MemoryLease(decode_application_continuity_record(encode_application_continuity_record(value))) + attempt = create_appservice_recovery_attempt(AppServiceRecoveryRequestV1( + "coding", _Resolver(events), lease, managed_mux=_binding(admissions) if managed else None, + )) + try: + with pytest.raises(AppServiceError) as error: + await attempt.open() + assert error.value.code is AppErrorCodeV1.OPERATION_UNAVAILABLE + assert not events and not admissions and not lease.commits + finally: + await attempt.close() + + asyncio.run(scenario()) diff --git a/tests/appservice/test_managed_mux_close_recovery.py b/tests/appservice/test_managed_mux_close_recovery.py new file mode 100644 index 000000000..ff4cb94c4 --- /dev/null +++ b/tests/appservice/test_managed_mux_close_recovery.py @@ -0,0 +1,406 @@ +"""Recovery settles hidden close debt before publishing unrelated active Muxes.""" + +from __future__ import annotations + +import asyncio +from dataclasses import replace + +import pytest + +from loushang.appserver.managed_mux import ManagedMuxCreatedV1 +from loushang.appserver.managed_mux_close import ( + ManagedMuxClosePhaseV1, + ManagedMuxCloseV1, +) +from loushang.appserver.protocol import AppErrorCodeV1, AppServiceError +from loushang.appservice import ( + AppServiceRecoveryRequestV1, + create_appservice_recovery_attempt, +) +from tests.appservice.test_continuity_runtime import _MemoryLease, _Resolver +from tests.appservice.test_managed_mux import INSTANCE, SERVICE, _binding, _request +from tests.appservice.test_managed_mux_close_continuity import record +from tests.appservice.test_managed_mux_close_runtime import Admission + + +def mixed_record(*, closed=False): + original = record(closed=closed) + active = record().mux_spaces[0] + member = active.members[0] + active = replace(active, mux_space_id="mux-active", name="active", members=( + replace(member, member_id="member-active", session=replace(member.session, session_id="session-active")), + )) + creation = ManagedMuxCreatedV1("e" * 32, INSTANCE, active.name, active.mux_space_id) + return replace(original, mux_spaces=(*original.mux_spaces, active), + managed_creations=(*original.managed_creations, creation)) + + +class Permission: + def __init__(self, value, use, owners): + self.value, self.use, self.owners = value, use, owners + self.acquired = False + self.denied = False + self.fail_release = False + self.release_calls = 0 + + async def acquire(self): + if self.denied: + raise AppServiceError(AppErrorCodeV1.OPERATION_UNAVAILABLE) + self.acquired = True + + def check_record(self, value): + assert self.acquired and value == self.value + + async def close(self): + self.release_calls += 1 + if self.fail_release: + raise RuntimeError("release unavailable") + self.acquired = False + + +def setup(value=None, *, lease=None, resolver=None, factory=None): + from loushang.appservice.managed_mux_close import ( + ManagedMuxCloseBindingV1, + ManagedMuxCloseRecoveryBindingV1, + ) + + owners, events = [], [] + lease = lease or _MemoryLease(value or mixed_record()) + resolver = resolver or _Resolver(events) + + def prepare(value, use): + owner = Permission(value, use, owners) + owners.append(owner) + if factory: + factory(owner) + return owner + + closing = ManagedMuxCloseBindingV1( + lambda request, use: Admission(request, use, events, [False]), + recovery=ManagedMuxCloseRecoveryBindingV1(prepare), + ) + binding = replace(_binding([]), closing=closing) + attempt = create_appservice_recovery_attempt(AppServiceRecoveryRequestV1( + "coding", resolver, lease, managed_mux=binding, + )) + return attempt, lease, resolver, owners + + +def test_pending_settles_before_active_and_preserves_origin_history(): + async def scenario(): + events = [] + + class Resolver(_Resolver): + async def open_session(self, request): + assert all(not owner.acquired for owner in owners) + if request.session_id == "session-active": + assert events == ["close:session-1"] + assert len(lease.commits) == 1 + return await super().open_session(request) + + attempt, lease, resolver, owners = setup(resolver=Resolver(events)) + original = lease.record + service = await attempt.open() + try: + assert [mux.name for mux in (await service.list_muxes()).mux_spaces] == ["active"] + result = lease.record.managed_closures[0] + assert result == replace(original.managed_closures[0], phase=ManagedMuxClosePhaseV1.CLOSED) + request = ManagedMuxCloseV1(SERVICE, INSTANCE, result.operation_id, + result.creation_operation_id, result.name, result.mux_space_id, "close-secret") + assert await service.read_managed_mux_close(request) == result + assert result.instance_id != request.instance_id + await service.create_managed_mux(_request(operation_id="f" * 32, name="new")) + assert lease.record.managed_closures == (result,) + assert [request.session_id for request in resolver.requests] == ["session-1", "session-active"] + finally: + await service.close() + assert events == ["close:session-1", "close:session-active"] + assert not lease.closed + + asyncio.run(scenario()) + + +def test_closed_history_opens_only_unrelated_active_members_without_writing(): + async def scenario(): + attempt, lease, resolver, _ = setup(mixed_record(closed=True)) + original = lease.record + service = await attempt.open() + try: + assert [request.session_id for request in resolver.requests] == ["session-active"] + assert lease.record == original and not lease.commits + assert service._managed_closures == {item.operation_id: item for item in original.managed_closures} + finally: + await service.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("failure", ["admit", "release", "settle"]) +def test_permission_failure_never_publishes_active_and_retains_original_release(failure): + async def scenario(): + from loushang.appservice.managed_mux_close import ManagedMuxCloseUseV1 + + def configure(owner): + owner.denied = (failure == "admit" and owner.use is ManagedMuxCloseUseV1.ADMIT + or failure == "settle" and owner.use is ManagedMuxCloseUseV1.SETTLE) + owner.fail_release = failure == "release" + + attempt, lease, resolver, owners = setup(factory=configure) + with pytest.raises(AppServiceError): + await attempt.open() + assert not lease.commits + assert not resolver.requests or failure == "settle" + assert all(request.session_id != "session-active" for request in resolver.requests) + if failure == "release": + assert attempt.cleanup_pending and len(owners) == 1 + original = owners[0] + original.fail_release = False + await asyncio.gather(attempt.close(), attempt.close()) + assert owners == [original] and not original.acquired + else: + await attempt.close() + assert not lease.closed + + asyncio.run(scenario()) + + +def test_confirmed_commit_then_active_failure_retries_without_pending_reopen_or_commit(): + async def scenario(): + events = [] + resolver = _Resolver(events, fail_session_id="session-active") + attempt, lease, _, _ = setup(resolver=resolver) + with pytest.raises(AppServiceError): + await attempt.open() + assert len(lease.commits) == 1 and events == ["close:session-1"] + resolver.fail_session_id = None + service = await attempt.open() + try: + assert len(lease.commits) == 1 + assert [request.session_id for request in resolver.requests].count("session-1") == 1 + assert events == ["close:session-1"] + finally: + await service.close() + + asyncio.run(scenario()) + + +def test_unknown_commit_is_not_repaired_by_reading_closed_bytes(): + class LostReceipt(_MemoryLease): + async def commit(self, *, expected_revision, record): + await super().commit(expected_revision=expected_revision, record=record) + raise RuntimeError("lost durable receipt") + + async def scenario(): + attempt, lease, resolver, _ = setup(lease=LostReceipt(mixed_record())) + for _ in range(2): + with pytest.raises(AppServiceError): + await attempt.open() + assert lease.record.managed_closures[0].phase is ManagedMuxClosePhaseV1.CLOSED + assert len(lease.commits) == 1 + assert [request.session_id for request in resolver.requests] == ["session-1"] + assert attempt.cleanup_pending + with pytest.raises(AppServiceError) as caught: + await attempt.close() + assert caught.value.code is AppErrorCodeV1.CLEANUP_INCOMPLETE + assert not lease.closed + + asyncio.run(scenario()) + + +def test_partial_cleanup_retries_original_failed_owner_without_reopening_success(): + async def scenario(): + events, allow_close = [], [False] + + class Resolver(_Resolver): + async def open_session(self, request): + port = await super().open_session(request) + if request.session_id == "session-2": + original = port.close + + async def close(): + if not allow_close[0]: + events.append("failed:session-2") + raise RuntimeError("cleanup unavailable") + await original() + + port.close = close + return port + + value = mixed_record() + pending = value.mux_spaces[0] + member = pending.members[0] + pending = replace(pending, members=(*pending.members, replace( + member, member_id="member-2", position=2, + session=replace(member.session, session_id="session-2"), + ))) + value = replace(value, mux_spaces=(pending, value.mux_spaces[1])) + attempt, lease, resolver, _ = setup(value, resolver=Resolver(events)) + with pytest.raises(AppServiceError): + await attempt.open() + original_ports = tuple(resolver.sessions) + assert events.count("close:session-1") == 1 and "failed:session-2" in events + assert not lease.commits and attempt.cleanup_pending + assert [request.session_id for request in resolver.requests] == ["session-1", "session-2"] + allow_close[0] = True + service = await attempt.open() + try: + assert tuple(resolver.sessions[:2]) == original_ports + assert [request.session_id for request in resolver.requests] == ["session-1", "session-2", "session-active"] + assert events.count("close:session-1") == events.count("close:session-2") == 1 + assert len(lease.commits) == 1 + finally: + await service.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("phase", ["admit", "settle"]) +def test_release_barrier_cancellation_joins_original_recovery_before_active(phase): + async def scenario(): + entered, release = asyncio.Event(), asyncio.Event() + + def configure(owner): + if owner.use.value != phase: + return + original = owner.close + + async def held(): + entered.set() + await release.wait() + await original() + + owner.close = held + + attempt, lease, resolver, owners = setup(factory=configure) + opening = asyncio.create_task(attempt.open()) + await asyncio.wait_for(entered.wait(), 2) + original_task = attempt._open_task + assert [request.session_id for request in resolver.requests] == ([] if phase == "admit" else ["session-1"]) + assert len(lease.commits) == (0 if phase == "admit" else 1) + assert owners[-1].acquired + opening.cancel() + second = asyncio.create_task(attempt.open()) + await asyncio.sleep(0) + assert attempt._open_task is original_task and not second.done() + release.set() + service = await second + with pytest.raises(asyncio.CancelledError): + await opening + try: + assert len(lease.commits) == 1 + assert [request.session_id for request in resolver.requests] == ["session-1", "session-active"] + assert all(not owner.acquired for owner in owners) + finally: + await service.close() + + asyncio.run(scenario()) + + +def test_confirmed_commit_release_failure_retries_same_permission_without_cas(): + async def scenario(): + def configure(owner): + owner.fail_release = owner.use.value == "settle" + + attempt, lease, resolver, owners = setup(factory=configure) + with pytest.raises(AppServiceError): + await attempt.open() + permission = owners[-1] + assert permission.use.value == "settle" and attempt.cleanup_pending + assert len(lease.commits) == 1 + assert [request.session_id for request in resolver.requests] == ["session-1"] + permission.fail_release = False + service = await attempt.open() + try: + assert not permission.acquired and len(lease.commits) == 1 + assert [owner.use.value for owner in owners] == ["admit", "settle", "admit"] + assert [request.session_id for request in resolver.requests] == ["session-1", "session-active"] + finally: + await service.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("replacement", ["absent", "revision"]) +def test_retry_rejects_replaced_frozen_record_before_more_session_io(replacement): + async def scenario(): + def configure(owner): + owner.denied = owner.use.value == "settle" + + attempt, lease, resolver, _ = setup(factory=configure) + with pytest.raises(AppServiceError): + await attempt.open() + lease.record = None if replacement == "absent" else replace(lease.record, record_revision=2) + with pytest.raises(AppServiceError) as caught: + await attempt.open() + assert caught.value.code is AppErrorCodeV1.REVISION_CONFLICT + assert [request.session_id for request in resolver.requests] == ["session-1"] + assert not lease.commits + await attempt.close() + + asyncio.run(scenario()) + + +def test_task_factory_scheduled_without_receipt_cannot_enter_recovery_effects(): + async def scenario(): + attempt, lease, resolver, owners = setup() + loop = asyncio.get_running_loop() + original_factory, orphaned = loop.get_task_factory(), [] + + def factory(loop, coroutine, **kwargs): + task = asyncio.Task(coroutine, loop=loop, **kwargs) + orphaned.append(task) + raise RuntimeError("scheduled but no receipt") + + loop.set_task_factory(factory) + try: + with pytest.raises(AppServiceError): + await attempt.open() + finally: + loop.set_task_factory(original_factory) + await asyncio.gather(*orphaned, return_exceptions=True) + assert not resolver.requests and not owners and not lease.commits + assert attempt._open_task is None + service = await attempt.open() + await service.close() + + asyncio.run(scenario()) + + +def test_real_lease_reopen_settles_pending_and_reopens_only_active_afterward(tmp_path): + from loushang.appservice import JsonFileApplicationContinuityStoreV1 + + async def scenario(): + value = mixed_record() + store = JsonFileApplicationContinuityStoreV1(tmp_path / "continuity") + lease = await store.acquire(application_id=value.application_id, owner_epoch="before") + await lease.commit(expected_revision=None, record=value) + await lease.close() + lease = await store.acquire(application_id=value.application_id, owner_epoch="after") + attempt, _, resolver, _ = setup(lease=lease) + try: + service = await attempt.open() + assert [mux.name for mux in (await service.list_muxes()).mux_spaces] == ["active"] + assert [request.session_id for request in resolver.requests] == ["session-1", "session-active"] + await service.close() + finally: + await attempt.close() + await lease.close() + lease = await store.acquire(application_id=value.application_id, owner_epoch="third") + attempt, _, resolver, _ = setup(lease=lease) + try: + paths = tuple((tmp_path / "continuity").glob("*.json")) + assert len(paths) == 1 + path = paths[0] + before = (path.read_bytes(), path.stat().st_mtime_ns) + recovered = await lease.load() + assert recovered.record_revision == value.record_revision + 1 + assert recovered.managed_closures == (replace(value.managed_closures[0], phase=ManagedMuxClosePhaseV1.CLOSED),) + service = await attempt.open() + assert [request.session_id for request in resolver.requests] == ["session-active"] + await service.close() + assert (path.read_bytes(), path.stat().st_mtime_ns) == before + finally: + await attempt.close() + await lease.close() + + asyncio.run(scenario()) diff --git a/tests/appservice/test_managed_mux_close_runtime.py b/tests/appservice/test_managed_mux_close_runtime.py new file mode 100644 index 000000000..c5b51b227 --- /dev/null +++ b/tests/appservice/test_managed_mux_close_runtime.py @@ -0,0 +1,515 @@ +from __future__ import annotations + +import asyncio +from dataclasses import replace + +import pytest + +from loushang.appserver.managed_mux_close import ( + ManagedMuxClosePhaseV1, + ManagedMuxCloseV1, +) +from loushang.appserver.protocol import ( + AppErrorCodeV1, + AppServiceError, + MuxMemberOpenV1, + MuxSelectorV1, + SessionOpenSpecV1, + SessionScopeV1, +) +from loushang.appservice import ( + AppServiceRecoveryRequestV1, + create_appservice_recovery_attempt, +) +from loushang.appservice.managed_mux_close import ( + ManagedMuxCloseBindingV1, + ManagedMuxCloseUseV1, +) +from tests.appservice.test_continuity_runtime import _MemoryLease, _Resolver +from tests.appservice.test_managed_mux import INSTANCE, SERVICE, _binding, _request + + +class Admission: + def __init__(self, request, use, events, stopped): + self.request, self.use, self.events, self.stopped = request, use, events, stopped + self.acquired = False + + async def acquire(self): + if self.request.authority != "close-secret" or ( + self.use is ManagedMuxCloseUseV1.ADMIT and self.stopped[0] + ): + raise RuntimeError("denied") + self.acquired = True + self.events.append(("acquire", self.use)) + + def check_closure(self, creation, previous): + assert self.acquired and creation.operation_id == self.request.creation_operation_id + if self.use is ManagedMuxCloseUseV1.SETTLE: + assert previous is not None + return self.request.instance_id if previous is None else previous.instance_id + + async def close(self): + self.acquired = False + self.events.append(("release", self.use)) + + +async def opened(lease=None, *, timeout=1, discovery=None): + lease = lease or _MemoryLease() + events, admissions, stopped = [], [], [False] + resolver = _Resolver(events) + + def prepare(request, use): + owner = Admission(request, use, events, stopped) + admissions.append(owner) + return owner + + binding = replace(_binding([]), closing=ManagedMuxCloseBindingV1(prepare)) + attempt = create_appservice_recovery_attempt(AppServiceRecoveryRequestV1( + "coding", resolver, lease, managed_mux=binding, close_timeout_seconds=timeout, discovery=discovery, + )) + service = await attempt.open() + created = await service.create_managed_mux(_request()) + request = ManagedMuxCloseV1(SERVICE, INSTANCE, "d" * 32, created.operation_id, + created.name, created.mux_space_id, "close-secret") + return service, lease, resolver, events, admissions, stopped, request + + +async def member(service, request, title="one", *, session_id=None): + return await service.open_member(MuxMemberOpenV1( + MuxSelectorV1(mux_space_id=request.mux_space_id), + SessionOpenSpecV1("coding", "continuity-1", SessionScopeV1.CWD, "a" * 64, title, session_id=session_id), + )) + + +def test_close_empty_mux_commits_pending_then_closed_and_queries_are_readonly(): + async def scenario(): + service, lease, resolver, events, admissions, _, request = await opened() + try: + assert await service.read_managed_mux_close(request) is None + before = len(lease.commits) + result = await service.close_managed_mux(request) + assert result.phase is ManagedMuxClosePhaseV1.CLOSED + assert [item.managed_closures[0].phase for item in lease.commits[before:]] == [ + ManagedMuxClosePhaseV1.CLEANUP_PENDING, ManagedMuxClosePhaseV1.CLOSED, + ] + assert not lease.record.mux_spaces and not (await service.list_muxes()).mux_spaces + for _ in range(2): + assert await service.read_managed_mux_close(request) == result + assert len(lease.commits) == before + 2 and not resolver.sessions + assert all(not owner.acquired for owner in admissions) + assert await service.close_managed_mux(request) == result + assert len(lease.commits) == before + 2 + finally: + await service.close() + + asyncio.run(scenario()) + + +def test_pending_hidden_but_retained_across_unrelated_commit_and_cancelled_waiter(): + async def scenario(): + service, lease, resolver, events, admissions, _, request = await opened() + await member(service, request) + entered, release = asyncio.Event(), asyncio.Event() + original = resolver.sessions[0].close + + async def held(): + assert not service._state_lock.locked() + assert all(not owner.acquired for owner in admissions) + entered.set() + await release.wait() + await original() + + resolver.sessions[0].close = held + closing = asyncio.create_task(service.close_managed_mux(request)) + try: + await asyncio.wait_for(entered.wait(), 2) + assert not (await service.list_muxes()).mux_spaces + snapshot = lease.record.mux_spaces[0] + pending = await service.read_managed_mux_close(request) + assert pending.phase is ManagedMuxClosePhaseV1.CLEANUP_PENDING + commits = len(lease.commits) + assert await service.read_managed_mux_close(request) == pending + assert len(lease.commits) == commits + closing.cancel() + with pytest.raises(asyncio.CancelledError): + await closing + other = await service.create_managed_mux(_request(name="other", operation_id="e" * 32)) + assert snapshot in lease.record.mux_spaces + assert lease.record.managed_closures == (pending,) + assert [mux.mux_space_id for mux in (await service.list_muxes()).mux_spaces] == [other.mux_space_id] + finally: + release.set() + await service.close() + await asyncio.gather(closing, return_exceptions=True) + assert lease.record.managed_closures[0].phase is ManagedMuxClosePhaseV1.CLOSED + assert events.count("close:session-1") == 1 + + asyncio.run(scenario()) + + +def test_partial_cleanup_retries_only_original_unsettled_member(): + async def scenario(): + service, lease, resolver, events, _, _, request = await opened() + await member(service, request, "one") + await member(service, request, "two") + resolver.sessions[1]._fail_close_once = True + try: + with pytest.raises(AppServiceError) as error: + await service.close_managed_mux(request) + assert error.value.code is AppErrorCodeV1.CLEANUP_INCOMPLETE + assert (await service.read_managed_mux_close(request)).phase is ManagedMuxClosePhaseV1.CLEANUP_PENDING + assert lease.record.mux_spaces[0].members + assert (await service.close_managed_mux(request)).phase is ManagedMuxClosePhaseV1.CLOSED + assert events.count("close:session-1") == 1 + assert events.count("close:session-2") == 2 + finally: + await service.close() + + asyncio.run(scenario()) + + +def test_stop_fences_new_close_but_joins_original_settlement_without_double_cleanup(): + async def scenario(): + service, lease, resolver, events, _, stopped, request = await opened() + await member(service, request) + other = await service.create_managed_mux(_request(name="other", operation_id="e" * 32)) + other_request = replace(request, name=other.name, mux_space_id=other.mux_space_id, + creation_operation_id=other.operation_id, operation_id="f" * 32) + entered, release = asyncio.Event(), asyncio.Event() + original = resolver.sessions[0].close + + async def held(): + entered.set() + await release.wait() + await original() + + resolver.sessions[0].close = held + closing = asyncio.create_task(service.close_managed_mux(request)) + stopping = None + try: + await asyncio.wait_for(entered.wait(), 2) + stopped[0] = True + stopping = asyncio.create_task(service.close()) + await asyncio.sleep(0) + assert service._managed_shutdown and not stopping.done() + commits = len(lease.commits) + with pytest.raises(AppServiceError): + await service.close_managed_mux(other_request) + assert len(lease.commits) == commits + assert [item.operation_id for item in lease.record.managed_closures] == [request.operation_id] + release.set() + assert (await closing).phase is ManagedMuxClosePhaseV1.CLOSED + await asyncio.wait_for(stopping, 2) + assert events.count("close:session-1") == 1 + finally: + release.set() + await service.close() + await asyncio.gather(closing, *([stopping] if stopping else []), return_exceptions=True) + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("phase", [ManagedMuxClosePhaseV1.CLEANUP_PENDING, ManagedMuxClosePhaseV1.CLOSED]) +def test_unknown_commit_never_reports_closed_or_discards_original_owners(phase): + class LostReceipt(_MemoryLease): + async def commit(self, *, expected_revision, record): + await super().commit(expected_revision=expected_revision, record=record) + if record.managed_closures and record.managed_closures[0].phase is phase: + raise RuntimeError("private write acknowledgement lost") + + async def scenario(): + service, lease, resolver, events, admissions, _, request = await opened(LostReceipt()) + await member(service, request) + with pytest.raises(AppServiceError): + await service.close_managed_mux(request) + assert service._managed_uncertain + assert lease.record.managed_closures[0].phase is phase + assert events.count("close:session-1") == (1 if phase is ManagedMuxClosePhaseV1.CLOSED else 0) + for call in (lambda: service.read_managed_mux_close(request), + lambda: service.close_managed_mux(request), service.close): + with pytest.raises(AppServiceError): + await call() + assert all(not owner.acquired for owner in admissions) + assert service._managed_closing_muxes or service._sessions + # The application deliberately retains unresolved durable debt. Settle + # only fake member resources for test teardown, without clearing it. + await service._close_sessions(tuple(service._sessions.values())) + + asyncio.run(scenario()) + + +def test_task_factory_failure_after_pending_retains_members_for_explicit_retry(): + async def scenario(): + service, lease, resolver, events, _, _, request = await opened() + await member(service, request) + loop = asyncio.get_running_loop() + original_factory = loop.get_task_factory() + + def factory(loop, coroutine, **kwargs): + operation = coroutine.cr_frame.f_locals.get("operation") + code = getattr(operation, "__code__", None) + if code is not None and "_settle_managed_close" in code.co_names: + raise RuntimeError("injected publication failure") + return asyncio.Task(coroutine, loop=loop, **kwargs) + + try: + loop.set_task_factory(factory) + with pytest.raises(AppServiceError): + await service.close_managed_mux(request) + assert lease.record.managed_closures[0].phase is ManagedMuxClosePhaseV1.CLEANUP_PENDING + assert not (await service.list_muxes()).mux_spaces + pending = service._managed_closing_muxes[request.operation_id] + assert pending.task is None and len(pending.sessions) == 1 + assert not events.count("close:session-1") + loop.set_task_factory(original_factory) + assert (await service.close_managed_mux(request)).phase is ManagedMuxClosePhaseV1.CLOSED + assert events.count("close:session-1") == 1 + finally: + loop.set_task_factory(original_factory) + await service.close() + + asyncio.run(scenario()) + + +def test_cancellation_during_pending_commit_still_publishes_retained_cleanup(): + class PausedLease(_MemoryLease): + def __init__(self): + super().__init__() + self.entered, self.release = asyncio.Event(), asyncio.Event() + + async def commit(self, *, expected_revision, record): + await super().commit(expected_revision=expected_revision, record=record) + if record.managed_closures and record.managed_closures[0].phase is ManagedMuxClosePhaseV1.CLEANUP_PENDING: + self.entered.set() + await self.release.wait() + + async def scenario(): + lease = PausedLease() + service, _, resolver, events, _, _, request = await opened(lease) + await member(service, request) + closing = asyncio.create_task(service.close_managed_mux(request)) + try: + await asyncio.wait_for(lease.entered.wait(), 2) + closing.cancel() + await asyncio.sleep(0) + assert not closing.done() + lease.release.set() + with pytest.raises(asyncio.CancelledError): + await closing + await service.close() + assert lease.record.managed_closures[0].phase is ManagedMuxClosePhaseV1.CLOSED + assert events.count("close:session-1") == 1 + finally: + lease.release.set() + await service.close() + await asyncio.gather(closing, return_exceptions=True) + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("mutation", ["name", "mux_space_id", "creation_operation_id", "instance_id", "authority"]) +def test_close_requires_exact_target_and_purpose_specific_permission(mutation): + async def scenario(): + service, lease, _, events, _, _, request = await opened() + before = len(lease.commits) + value = "e" * 32 if mutation.endswith("_id") and mutation != "mux_space_id" else "wrong" + try: + with pytest.raises(AppServiceError): + await service.close_managed_mux(replace(request, **{mutation: value})) + assert len(lease.commits) == before and (await service.list_muxes()).mux_spaces + finally: + await service.close() + + asyncio.run(scenario()) + + +def test_cancelled_admission_release_cannot_strand_durable_close(monkeypatch): + async def scenario(): + service, lease, resolver, events, _, _, request = await opened() + await member(service, request) + entered, release, cleaned = asyncio.Event(), asyncio.Event(), asyncio.Event() + original_release, original_close = Admission.close, resolver.sessions[0].close + + async def held(owner): + if owner.use is ManagedMuxCloseUseV1.ADMIT: + entered.set() + await release.wait() + await original_release(owner) + + async def closed(): + await original_close() + cleaned.set() + + monkeypatch.setattr(Admission, "close", held) + resolver.sessions[0].close = closed + closing = asyncio.create_task(service.close_managed_mux(request)) + try: + await asyncio.wait_for(entered.wait(), 2) + assert lease.record.managed_closures[0].phase is ManagedMuxClosePhaseV1.CLEANUP_PENDING + closing.cancel() + release.set() + with pytest.raises(asyncio.CancelledError): + await closing + # No second close request/service shutdown may start this cleanup. + await asyncio.wait_for(cleaned.wait(), 2) + assert events.count("close:session-1") == 1 + finally: + release.set() + await service.close() + await asyncio.gather(closing, return_exceptions=True) + + asyncio.run(scenario()) + + +def test_pending_snapshot_reserves_even_already_cleaned_member_identity(): + async def scenario(): + service, lease, resolver, events, _, _, request = await opened() + await member(service, request, "one") + await member(service, request, "two") + resolver.sessions[1]._fail_close_once = True + other = await service.create_managed_mux(_request(name="other", operation_id="e" * 32)) + other_request = replace(request, mux_space_id=other.mux_space_id, + creation_operation_id=other.operation_id, operation_id="f" * 32, name=other.name) + try: + with pytest.raises(AppServiceError): + await service.close_managed_mux(request) + with pytest.raises(AppServiceError) as error: + await member(service, other_request, session_id="session-1") + assert error.value.code is AppErrorCodeV1.ALREADY_EXISTS + assert (await service.close_managed_mux(request)).phase is ManagedMuxClosePhaseV1.CLOSED + opened_mux = await member(service, other_request, session_id="session-1") + assert opened_mux.members[0].session.session_id == "session-1" + finally: + await service.close() + + asyncio.run(scenario()) + + +def test_shutdown_fences_borrowed_discovery_before_waiting_for_close(): + from loushang.appservice.client_scope import ScopedAppServiceV1 + from tests.appservice.test_session_discovery import _binding as discovery_binding + from tests.appservice.test_session_discovery import _Product, _query + + async def scenario(): + product = _Product() + service, _, resolver, _, _, _, request = await opened(discovery=discovery_binding(product)) + scoped = ScopedAppServiceV1(service) + scope = scoped.open_client_scope() + view = scope.discovery_client + await view.list_sessions(_query()) + await member(service, request) + entered, release = asyncio.Event(), asyncio.Event() + original = resolver.sessions[0].close + + async def held(): + entered.set() + await release.wait() + await original() + + resolver.sessions[0].close = held + closing = asyncio.create_task(service.close_managed_mux(request)) + stopping = None + try: + await asyncio.wait_for(entered.wait(), 2) + stopping = asyncio.create_task(service.close()) + await asyncio.sleep(0) + assert service._managed_shutdown and not stopping.done() + before = len(product.requests) + with pytest.raises(AppServiceError): + await view.list_sessions(_query()) + with pytest.raises(AppServiceError): + _ = scope.managed_mux_client + assert len(product.requests) == before + finally: + release.set() + await service.close() + await asyncio.gather(closing, *([stopping] if stopping else []), return_exceptions=True) + + asyncio.run(scenario()) + + +def test_concurrent_targets_preserve_each_other_and_duplicate_waiter_joins_original(monkeypatch): + async def scenario(): + service, lease, resolver, events, _, _, first = await opened() + await member(service, first) + created = await service.create_managed_mux(_request(name="other", operation_id="e" * 32)) + second = replace(first, name=created.name, mux_space_id=created.mux_space_id, + creation_operation_id=created.operation_id, operation_id="f" * 32) + await member(service, second) + entered = [asyncio.Event(), asyncio.Event()] + release = [asyncio.Event(), asyncio.Event()] + replay_admitted = asyncio.Event() + original_check = Admission.check_closure + + def checked(owner, creation, previous): + origin = original_check(owner, creation, previous) + if owner.use is ManagedMuxCloseUseV1.ADMIT and previous is not None: + replay_admitted.set() + return origin + + monkeypatch.setattr(Admission, "check_closure", checked) + for index, session in enumerate(resolver.sessions): + original = session.close + + async def held(index=index, original=original): + entered[index].set() + await release[index].wait() + await original() + + session.close = held + tasks = [asyncio.create_task(service.close_managed_mux(request)) for request in (first, second)] + duplicate = None + try: + await asyncio.wait_for(asyncio.gather(*(item.wait() for item in entered)), 2) + assert len(lease.record.managed_closures) == 2 and len(lease.record.mux_spaces) == 2 + second_snapshot = next(item for item in lease.record.mux_spaces if item.mux_space_id == second.mux_space_id) + original_task = service._managed_closing_muxes[first.operation_id].task + duplicate = asyncio.create_task(service.close_managed_mux(first)) + await asyncio.wait_for(replay_admitted.wait(), 2) + assert service._managed_closing_muxes[first.operation_id].task is original_task + release[0].set() + results = await asyncio.wait_for(asyncio.gather(tasks[0], duplicate), 2) + assert results[0] == results[1] and results[0].phase is ManagedMuxClosePhaseV1.CLOSED + phases = {item.operation_id: item.phase for item in lease.record.managed_closures} + assert phases == {first.operation_id: ManagedMuxClosePhaseV1.CLOSED, + second.operation_id: ManagedMuxClosePhaseV1.CLEANUP_PENDING} + assert lease.record.mux_spaces == (second_snapshot,) + release[1].set() + assert (await tasks[1]).phase is ManagedMuxClosePhaseV1.CLOSED + assert events.count("close:session-1") == events.count("close:session-2") == 1 + finally: + for gate in release: + gate.set() + await service.close() + await asyncio.gather(*tasks, *([duplicate] if duplicate else []), return_exceptions=True) + + asyncio.run(scenario()) + + +def test_failed_admission_release_keeps_original_and_does_not_start_cleanup(monkeypatch): + async def scenario(): + service, lease, resolver, events, admissions, _, request = await opened() + await member(service, request) + original = Admission.close + attempts = [] + + async def fail_once(owner): + attempts.append(owner) + if len(attempts) == 1: + raise RuntimeError("original permission release failed") + await original(owner) + + monkeypatch.setattr(Admission, "close", fail_once) + with pytest.raises(AppServiceError) as error: + await service.close_managed_mux(request) + assert error.value.code is AppErrorCodeV1.CLEANUP_INCOMPLETE + original_owner = admissions[0] + assert service._managed_admission is original_owner and original_owner.acquired + assert service._managed_closing_muxes[request.operation_id].task is None + assert events.count("close:session-1") == 0 + await service.close() + assert attempts[0] is attempts[1] is original_owner + assert events.count("close:session-1") == 1 + assert lease.record.managed_closures[0].phase is ManagedMuxClosePhaseV1.CLOSED + + asyncio.run(scenario()) diff --git a/tests/foundation/observability/test_runtime.py b/tests/foundation/observability/test_runtime.py index a1445930d..56da37388 100644 --- a/tests/foundation/observability/test_runtime.py +++ b/tests/foundation/observability/test_runtime.py @@ -1,6 +1,8 @@ from __future__ import annotations -from loushang.foundation.observability import get_log +import pytest + +from loushang.foundation.observability import get_log, runtime from loushang.foundation.observability._router import ( configure_observability, reset_observability, @@ -9,6 +11,70 @@ from loushang.foundation.observability.runtime import observability_runtime_context +class RecordingTrace: + def __init__(self): + self.events = [] + self.closed = False + + def write_debug_event(self, record): + self.events.append(record) + + def write_problem(self, record): + self.events.append(record) + + def close(self): + self.closed = True + + +@pytest.mark.parametrize("fail", [False, True]) +def test_injected_trace_is_borrowed_and_previous_sink_restored(tmp_path, monkeypatch, fail): + previous, injected = RecordingTrace(), RecordingTrace() + + def forbidden(*args, **kwargs): + pytest.fail("injected trace must not construct a file sink") + + monkeypatch.setattr(runtime, "TraceJSONLSink", forbidden) + reset_observability() + try: + configure_observability(trace_sink=previous, trace_scopes={"host"}) + try: + with observability_runtime_context( + session_id=None, cwd=tmp_path, mode="managed", + trace_sink=injected, trace_scopes=frozenset({"host"}), + ): + get_log("example").debug_event("host", "during") + get_log("example").debug_event("other", "excluded") + if fail: + raise RuntimeError("body failed") + except RuntimeError: + assert fail + get_log("example").debug_event("host", "after") + assert len(injected.events) == 1 + assert len(previous.events) == 1 + assert not injected.closed and not previous.closed + assert not tuple(tmp_path.iterdir()) + finally: + reset_observability() + + +def test_trace_path_and_injected_sink_conflict_before_reconfiguration(tmp_path): + previous = RecordingTrace() + reset_observability() + try: + configure_observability(trace_sink=previous, trace_scopes={"host"}) + with pytest.raises(ValueError, match="mutually exclusive"): + with observability_runtime_context( + session_id=None, cwd=tmp_path, mode="managed", + trace_sink=RecordingTrace(), trace_path=tmp_path / "trace.jsonl", + ): + pytest.fail("conflicting configuration entered") + get_log("example").debug_event("host", "after") + assert len(previous.events) == 1 + assert not tuple(tmp_path.iterdir()) + finally: + reset_observability() + + def test_observability_runtime_context_restores_existing_debug_sink(tmp_path) -> None: before = tmp_path / "before.log" during = tmp_path / "during.log" From 8b3be111a0635d738206aad78c8166ad57bd8d49 Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 06:11:38 +0000 Subject: [PATCH 26/44] feat(coding): compose managed local applications --- src/loushang/apphost/application.py | 16 + src/loushang/apphost/continuity.py | 11 + src/loushang/apphost/local.py | 10 + src/loushang/coding/hosted_application.py | 98 +++- src/loushang/coding/hosted_bootstrap.py | 33 +- src/loushang/coding/hosted_catalog.py | 321 +++++++++-- src/loushang/coding/hosted_continuity.py | 5 +- src/loushang/coding/hosted_local.py | 50 +- src/loushang/coding/hosted_session.py | 4 + src/loushang/coding/managed_bootstrap.py | 142 +++++ src/loushang/coding/managed_catalog.py | 301 ++++++++++ src/loushang/coding/managed_local.py | 155 +++++ src/loushang/coding/managed_process.py | 164 ++++++ tests/apphost/test_local.py | 14 + tests/coding/_managed_close_probe.py | 40 ++ tests/coding/_managed_product_child.py | 164 ++++++ tests/coding/_managed_starter.py | 76 +++ tests/coding/test_hosted_owned_catalog.py | 198 +++++++ tests/coding/test_hosted_owned_shutdown.py | 134 +++++ tests/coding/test_managed_application.py | 256 +++++++++ tests/coding/test_managed_capture_product.py | 90 +++ tests/coding/test_managed_catalog.py | 455 +++++++++++++++ tests/coding/test_managed_detached.py | 280 +++++++++ tests/coding/test_managed_launch.py | 103 ++++ tests/coding/test_managed_local.py | 201 +++++++ .../coding/test_managed_process_arguments.py | 196 +++++++ tests/coding/test_managed_process_entry.py | 532 ++++++++++++++++++ tests/coding/test_managed_store_discovery.py | 305 ++++++++++ 28 files changed, 4277 insertions(+), 77 deletions(-) create mode 100644 src/loushang/coding/managed_bootstrap.py create mode 100644 src/loushang/coding/managed_catalog.py create mode 100644 src/loushang/coding/managed_local.py create mode 100644 src/loushang/coding/managed_process.py create mode 100644 tests/coding/_managed_close_probe.py create mode 100644 tests/coding/_managed_product_child.py create mode 100644 tests/coding/_managed_starter.py create mode 100644 tests/coding/test_hosted_owned_catalog.py create mode 100644 tests/coding/test_hosted_owned_shutdown.py create mode 100644 tests/coding/test_managed_application.py create mode 100644 tests/coding/test_managed_capture_product.py create mode 100644 tests/coding/test_managed_catalog.py create mode 100644 tests/coding/test_managed_detached.py create mode 100644 tests/coding/test_managed_launch.py create mode 100644 tests/coding/test_managed_local.py create mode 100644 tests/coding/test_managed_process_arguments.py create mode 100644 tests/coding/test_managed_process_entry.py create mode 100644 tests/coding/test_managed_store_discovery.py diff --git a/src/loushang/apphost/application.py b/src/loushang/apphost/application.py index 1a22dd127..b25734f99 100644 --- a/src/loushang/apphost/application.py +++ b/src/loushang/apphost/application.py @@ -21,6 +21,7 @@ require_discovery_context, ) from loushang.appservice.execution_service import HostedExecutionServiceBindingV1 +from loushang.appservice.managed_mux import ManagedMuxServiceBindingV1 from .contracts import AppHostShutdownBudgetV1, AppHostShutdownReportV1 @@ -94,11 +95,14 @@ class HostedApplicationRequestV1: service_id_factory: Callable[[], str] | None = None discovery: HostedSessionDiscoveryBindingV1 | None = None execution: HostedExecutionServiceBindingV1 | None = None + managed_mux: ManagedMuxServiceBindingV1 | None = None def __post_init__(self) -> None: require_discovery_context(self.discovery, self.product_id, self.generation_id) if self.execution is not None and type(self.execution) is not HostedExecutionServiceBindingV1: raise TypeError("invalid execution activation") + if self.managed_mux is not None and type(self.managed_mux) is not ManagedMuxServiceBindingV1: + raise TypeError("invalid managed Mux activation") if type(self.activation) is not HostedApplicationActivationV1: raise TypeError("hosted application requires explicit activation") if _STABLE_ID.fullmatch(self.product_id) is None: @@ -245,6 +249,16 @@ def discovery_client(self) -> SessionDiscoveryClientV1 | None: def execution_enabled(self) -> bool: return self._service is not None and self._service._execution_registry is not None + @property + def managed_mux_instance(self) -> str | None: + binding = None if self._service is None else self._service._managed_mux + return None if binding is None else binding.instance_id + + @property + def managed_mux_close_enabled(self) -> bool: + binding = None if self._service is None else self._service._managed_mux + return binding is not None and binding.closing is not None + def enable_client_scopes(self) -> None: """Select scoped authority after recovery and before borrowing any client. @@ -428,6 +442,8 @@ def create_hosted_application_runtime( if type(request) is not HostedApplicationRequestV1: raise TypeError("invalid hosted application request") + if request.managed_mux is not None: + raise ValueError("managed Mux requires application continuity") service = AppServiceV1( product_id=request.product_id, resolver=request.resolver, diff --git a/src/loushang/apphost/continuity.py b/src/loushang/apphost/continuity.py index 43cfa611f..64801aa1d 100644 --- a/src/loushang/apphost/continuity.py +++ b/src/loushang/apphost/continuity.py @@ -89,6 +89,8 @@ def __post_init__(self) -> None: raise TypeError("invalid hosted continuity application request") if _STABLE_ID.fullmatch(self.application_id) is None: raise ValueError("invalid hosted continuity application identity") + if self.application.managed_mux is not None and self.application.managed_mux.application_id != self.application_id: + raise ValueError("managed Mux application identity mismatch") if _OPAQUE_TOKEN.fullmatch(self.owner_epoch) is None: raise ValueError("invalid hosted continuity owner epoch") if (self.store is None) == (self.continuity_lease is None): @@ -238,6 +240,14 @@ def discovery_client(self) -> SessionDiscoveryClientV1 | None: def execution_enabled(self) -> bool: return self._application is not None and self._application.execution_enabled + @property + def managed_mux_instance(self) -> str | None: + return None if self._application is None else self._application.managed_mux_instance + + @property + def managed_mux_close_enabled(self) -> bool: + return self._application is not None and self._application.managed_mux_close_enabled + def enable_client_scopes(self) -> None: """Opt in only after this recovered runtime is published by its attempt.""" if not self.accepting: @@ -476,6 +486,7 @@ async def _open_once(self) -> HostedApplicationContinuityRuntimeV1: resolver=self._request.application.resolver, discovery=self._request.application.discovery, execution=self._request.application.execution, + managed_mux=self._request.application.managed_mux, continuity_lease=self._lease, id_factory=self._request.application.service_id_factory, close_timeout_seconds=( diff --git a/src/loushang/apphost/local.py b/src/loushang/apphost/local.py index f58db384b..7f58953ad 100644 --- a/src/loushang/apphost/local.py +++ b/src/loushang/apphost/local.py @@ -42,11 +42,18 @@ def __init__( settlement_timeout: float = 30.0, session_discovery: bool = False, session_execution: bool = False, + mux_management: bool = False, + connection_instance: str | None = None, ) -> None: if type(session_discovery) is not bool: raise TypeError("invalid discovery activation") if type(session_execution) is not bool or (session_execution and not application.execution_enabled): raise ValueError("execution deployment requires an admitted application capability") + if type(mux_management) is not bool: + raise TypeError("invalid managed Mux activation") + if mux_management and (application.managed_mux_instance is None + or application.managed_mux_instance != connection_instance): + raise ValueError("managed deployment requires its admitted application instance") for timeout in (startup_timeout, settlement_timeout): _require_budget(timeout) self._application, self._directory = application, directory @@ -61,6 +68,9 @@ def __init__( close_timeout=connection_timeout, discovery_scope_factory=application.open_client_scope if session_discovery else None, execution_scope_factory=application.open_client_scope if session_execution else None, + managed_mux_scope_factory=application.open_client_scope if mux_management else None, + mux_closure=mux_management and application.managed_mux_close_enabled, + instance=connection_instance, ) self._startup_timeout, self._timeout = startup_timeout, settlement_timeout self._start_task: asyncio.Task[None] | None = None diff --git a/src/loushang/coding/hosted_application.py b/src/loushang/coding/hosted_application.py index b48ad1b38..6aa6321ae 100644 --- a/src/loushang/coding/hosted_application.py +++ b/src/loushang/coding/hosted_application.py @@ -6,7 +6,7 @@ import inspect import re from collections.abc import Callable -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from secrets import token_hex from typing import Protocol, cast @@ -55,6 +55,7 @@ require_discovery_context, ) from loushang.appservice.execution_service import HostedExecutionServiceBindingV1 +from loushang.appservice.managed_mux import ManagedMuxServiceBindingV1 from loushang.appservice.ports import ( HostedSessionResolutionErrorV1, HostedSessionResolutionFailureV1, @@ -69,6 +70,7 @@ from .product_plan import CODING_PRODUCT_ID CODING_HOSTED_APPLICATION_PROFILE_ID = "coding.hosted-mux" +CODING_MANAGED_APPLICATION_PROFILE_ID = "coding.managed-mux" CODING_HOSTED_APPLICATION_PROFILE_VERSION = "1" CODING_HOSTED_APPLICATION_MAX_CANDIDATES = 256 @@ -87,6 +89,14 @@ async def create_session( ) -> CodingHostedSessionBindingV1: ... +class CodingHostedSessionOwnerV1(Protocol): + """Product-selected catalog resources, separate from delivered Sessions.""" + + def fence(self) -> None: ... + + async def close(self) -> None: ... + + @dataclass(frozen=True, slots=True) class CodingForegroundHostedApplicationRequestV1: """Complete explicit input for one Coding foreground hosted application.""" @@ -113,8 +123,24 @@ class CodingForegroundHostedApplicationRequestV1: discovery: HostedSessionDiscoveryBindingV1 | None = field(default=None, repr=False) admitted_scopes: tuple[HostedSessionDiscoveryScopeV1, ...] | None = None execution: HostedExecutionServiceBindingV1 | None = field(default=None, repr=False) + session_owner: CodingHostedSessionOwnerV1 | None = field(default=None, repr=False) + managed_selection: bool = False + managed_mux: ManagedMuxServiceBindingV1 | None = field(default=None, repr=False) def __post_init__(self) -> None: + _require_managed_selection(self.managed_selection, self.sessions, self.profile_id, self.admitted_scopes) + if self.managed_selection and self.session_owner is not self.sessions: + raise ValueError("managed Session catalog must retain its shutdown owner") + if self.managed_selection and self.execution is not None: + raise ValueError("managed selection execution adapter is not admitted") + if self.managed_mux is not None and (type(self.managed_mux) is not ManagedMuxServiceBindingV1 + or not self.managed_selection): + raise ValueError("managed Mux requires managed Coding selection") + if self.session_owner is not None: + _require_async_method(self.session_owner, "close") + fence = inspect.getattr_static(type(self.session_owner), "fence", None) + if not callable(fence) or inspect.iscoroutinefunction(fence): + raise TypeError("invalid Coding Session owner fence") require_discovery_context(self.discovery, CODING_PRODUCT_ID, self.generation_id) if self.execution is not None and type(self.execution) is not HostedExecutionServiceBindingV1: raise TypeError("invalid Coding execution activation") @@ -237,13 +263,14 @@ async def close(self) -> None: class CodingForegroundProductFactoryV1: """Create foreground Product Sessions without a Hosting/Worker owner.""" - __slots__ = ("_active", "_create_session", "_debt") + __slots__ = ("_active", "_create_session", "_debt", "_session_owner") - def __init__(self, factory: CodingForegroundSessionFactoryV1) -> None: + def __init__(self, factory: CodingForegroundSessionFactoryV1, *, session_owner: CodingHostedSessionOwnerV1 | None = None) -> None: _require_async_method(factory, "create_session") self._create_session = factory.create_session self._active: set[_ForegroundSessionOwner] = set() self._debt: set[_ForegroundSessionOwner] = set() + self._session_owner = session_owner async def create_runtime( self, @@ -288,7 +315,23 @@ async def settle_pending_cleanup(self) -> None: raise RuntimeError("coding_foreground_cleanup_incomplete") async def close(self) -> None: - await self.settle_pending_cleanup() + failures: list[Exception] = [] + if self._session_owner is not None: + try: + self._session_owner.fence() + except Exception as error: + failures.append(error) + try: + await self.settle_pending_cleanup() + except Exception as error: + failures.append(error) + if self._session_owner is not None: + try: + await self._session_owner.close() + except Exception as error: + failures.append(error) + if failures: + raise failures[0] async def _settle_unpublished(self, owner: _ForegroundSessionOwner) -> None: try: @@ -361,6 +404,7 @@ class _LeasedCodingHostedBinding: "_lease_closed", "_runtime", "_runtime_closed", + "_identity", ) def __init__( @@ -369,10 +413,12 @@ def __init__( runtime: AppHostRuntimeV1, lease: AppHostSessionLeaseV1, binding: CodingHostedSessionBindingV1, + identity: SessionIdentityV1 | None = None, ) -> None: self._runtime = runtime self._lease = lease self._binding = binding + self._identity = identity self._lease_closed = False self._runtime_closed = False self._close_lock = asyncio.Lock() @@ -380,7 +426,7 @@ def __init__( @property def identity(self) -> SessionIdentityV1: - return self._binding.identity + return self._binding.identity if self._identity is None else self._identity @property def control(self): # type: ignore[no-untyped-def] @@ -418,7 +464,7 @@ async def _close_once(self) -> None: class CodingAppHostHostedSessionResolverV1: """Resolve G11 Session requests exclusively through canonical AppHost routes.""" - __slots__ = ("_operation_id", "_profile_id", "_runtime", "_sessions", "_scopes", "_execution") + __slots__ = ("_operation_id", "_profile_id", "_runtime", "_sessions", "_scopes", "_execution", "_managed_selection") def __init__( self, @@ -429,14 +475,19 @@ def __init__( operation_id_factory: Callable[[], str], admitted_scopes: tuple[HostedSessionDiscoveryScopeV1, ...] | None = None, execution: bool = False, + managed_selection: bool = False, ) -> None: if type(runtime) is not AppHostRuntimeV1: raise TypeError("Coding hosted resolver AppHost Runtime is invalid") if not callable(operation_id_factory): raise TypeError("Coding hosted resolver operation factory is invalid") require_admitted_scopes(admitted_scopes, CODING_PRODUCT_ID) + _require_managed_selection(managed_selection, sessions, profile_id, admitted_scopes) + self._managed_selection = managed_selection if type(execution) is not bool: raise TypeError("invalid Coding execution activation") + if managed_selection and execution: + raise ValueError("managed selection execution adapter is not admitted") self._execution = execution self._runtime = runtime self._sessions = sessions @@ -462,10 +513,21 @@ async def open_session(self, request: SessionOpenSpecV1) -> HostedSessionPortV1: try: binding = lease.profile_binding _validate_hosted_binding(binding, lease.binding_key) + identity = cast(CodingHostedSessionBindingV1, binding).identity + if self._managed_selection: + if (identity.product_id != request.product_id + or identity.continuity_id != request.continuity_id + or (request.session_id is not None and identity.session_id != request.session_id)): + raise ValueError("managed canonical identity mismatch") + # Only the lease's wire view changes. Catalog admission already + # validated selection scope; Product/runtime/writer identities + # and the original header remain canonical. + identity = replace(identity, scope=request.scope, scope_fingerprint=request.scope_fingerprint) wrapper = _LeasedCodingHostedBinding( runtime=self._runtime, lease=lease, binding=cast(CodingHostedSessionBindingV1, binding), + identity=identity if self._managed_selection else None, ) if not _identity_matches_open(request, wrapper.identity): raise ValueError("Coding hosted binding identity mismatch") @@ -543,7 +605,9 @@ async def create_coding_foreground_hosted_application( if type(request) is not CodingForegroundHostedApplicationRequestV1: raise TypeError("Coding foreground hosted application request is invalid") - product_factory = CodingForegroundProductFactoryV1(request.session_factory) + if request.managed_mux is not None: + raise ValueError("managed Mux requires Coding application continuity") + product_factory = CodingForegroundProductFactoryV1(request.session_factory, session_owner=request.session_owner) catalog: AppHostCatalogV1 | None = None runtime: AppHostRuntimeV1 | None = None try: @@ -563,6 +627,7 @@ async def create_coding_foreground_hosted_application( operation_id_factory=request.operation_id_factory, admitted_scopes=request.admitted_scopes, execution=request.execution is not None, + managed_selection=request.managed_selection, ) return create_hosted_application_runtime( _coding_hosted_application_request( @@ -633,6 +698,7 @@ def _coding_hosted_application_request( service_id_factory=request.service_id_factory, discovery=request.discovery, execution=request.execution, + managed_mux=request.managed_mux, ) @@ -718,6 +784,22 @@ def _validate_hosted_binding( raise TypeError("Coding hosted Session control is invalid") +def _require_managed_selection( + enabled: bool, sessions: object, profile_id: str, + scopes: tuple[HostedSessionDiscoveryScopeV1, ...] | None, +) -> None: + if type(enabled) is not bool or (profile_id == CODING_MANAGED_APPLICATION_PROFILE_ID) != enabled: + raise ValueError("invalid managed selection activation") + if not enabled: + return + from .managed_catalog import CodingManagedSessionCatalogV1 + + if type(sessions) is not CodingManagedSessionCatalogV1 or scopes != tuple( + HostedSessionDiscoveryScopeV1("coding", scope.scope, scope.fingerprint) for scope in sessions.scopes + ): + raise ValueError("managed selection requires the exact owned canonical catalog and scopes") + + def _identity_matches_open( request: SessionOpenSpecV1, identity: SessionIdentityV1, @@ -788,9 +870,11 @@ def _void_task_needs_retry(task: asyncio.Task[None]) -> bool: __all__ = [ + "CodingHostedSessionOwnerV1", "CODING_HOSTED_APPLICATION_MAX_CANDIDATES", "CODING_HOSTED_APPLICATION_PROFILE_ID", "CODING_HOSTED_APPLICATION_PROFILE_VERSION", + "CODING_MANAGED_APPLICATION_PROFILE_ID", "CodingAppHostHostedSessionResolverV1", "CodingForegroundHostedApplicationRequestV1", "CodingForegroundProductFactoryV1", diff --git a/src/loushang/coding/hosted_bootstrap.py b/src/loushang/coding/hosted_bootstrap.py index 7740f631d..30c3c881d 100644 --- a/src/loushang/coding/hosted_bootstrap.py +++ b/src/loushang/coding/hosted_bootstrap.py @@ -29,8 +29,10 @@ HostedSessionDiscoveryBindingV1, HostedSessionDiscoveryScopeV1, ) +from loushang.appservice.managed_mux import ManagedMuxServiceBindingV1 from loushang.harness.config.agent import SettingsManager from loushang.harness.tools.core import ToolDefinition +from loushang.harness.workspace.exec.capture_lease import ExecCaptureFactory from .bootstrap import BootstrapServices, create_services from .control.settings_store import ( @@ -146,12 +148,30 @@ def create_coding_hosted_attempt( stream_fn: StreamFn | None = None, tools: list[ToolDefinition] | None = None, session_discovery: bool = False, + owned_transcripts: bool = False, ) -> CodingHostedContinuityAttemptV1: """Bind real Coding/G13 once; test seams never enter command-line input.""" if type(session_discovery) is not bool: raise TypeError("invalid discovery activation") generation = token_hex(16) - catalog = CodingHostedSessionCatalogV1(launch.scopes) + catalog = CodingHostedSessionCatalogV1(launch.scopes, owned_transcripts=owned_transcripts) + return _create_coding_attempt( + application_root=launch.application_root, application_id=launch.application_id, + catalog=catalog, generation=generation, model=model, stream_fn=stream_fn, tools=tools, + session_discovery=session_discovery, owned_transcripts=owned_transcripts, + profile_id=CODING_HOSTED_APPLICATION_PROFILE_ID, + ) + + +def _create_coding_attempt( + *, application_root: Path, application_id: str, catalog: CodingHostedSessionCatalogV1, + generation: str, model: Model | ModelSelection | None, stream_fn: StreamFn | None, + tools: list[ToolDefinition] | None, session_discovery: bool, owned_transcripts: bool, + profile_id: str, managed_selection: bool = False, + managed_mux: ManagedMuxServiceBindingV1 | None = None, + output_capture_factory: ExecCaptureFactory | None = None, +) -> CodingHostedContinuityAttemptV1: + """Installed Product wiring shared by explicit legacy/managed launch facts.""" admitted_scopes = tuple(HostedSessionDiscoveryScopeV1( "coding", scope.scope, scope.fingerprint, ) for scope in catalog.scopes) @@ -173,7 +193,7 @@ def create_coding_hosted_attempt( AdmissionIdentityV1( generation, AppHostAdmissionSubjectKind.PROFILE, - CODING_HOSTED_APPLICATION_PROFILE_ID, + profile_id, ) ), candidate_validator=CodingHostedCandidateValidatorV1(), @@ -185,16 +205,21 @@ def create_coding_hosted_attempt( model=model, stream_fn=stream_fn, tools=tools, + output_capture_factory=output_capture_factory, ), shutdown_budget=AppHostShutdownBudgetV1(10.0, 5.0), + session_owner=catalog if owned_transcripts else None, + profile_id=profile_id, + managed_selection=managed_selection, + managed_mux=managed_mux, ) return create_coding_hosted_continuity_attempt( CodingHostedContinuityRequestV1( activation=HostedApplicationContinuityActivationV1(), foreground=foreground, - application_id=launch.application_id, + application_id=application_id, owner_epoch=token_hex(16), - store=JsonFileApplicationContinuityStoreV1(launch.application_root), + store=JsonFileApplicationContinuityStoreV1(application_root), ) ) diff --git a/src/loushang/coding/hosted_catalog.py b/src/loushang/coding/hosted_catalog.py index 8f5e60378..5fa1ce1c9 100644 --- a/src/loushang/coding/hosted_catalog.py +++ b/src/loushang/coding/hosted_catalog.py @@ -44,7 +44,9 @@ from loushang.harness.conversation import ConversationHeader from loushang.harness.runtime import ResolvedRuntimeProfile, RuntimeProfileBinding from loushang.harness.transcript import ( + AgentTranscriptLifecycle, AgentTranscriptLifecycleContext, + AgentTranscriptLifecycleSession, AgentTranscriptSessionFactory, ) from loushang.harness.transcript.jsonl_file import ( @@ -56,6 +58,7 @@ session_file_authority_fingerprint, ) +from .product_plan import CODING_TRANSCRIPT_RUNTIME from .session_manager import ( _LIFECYCLE, SessionManager, @@ -95,15 +98,7 @@ def __post_init__(self) -> None: @property def fingerprint(self) -> str: - parts = [ - "coding.hosted.scope/v1", - self.scope.value, - os.path.normcase(str(self.session_dir)), - ] - if self.scope is SessionScopeV1.CWD: - parts.append(os.path.normcase(str(self.cwd))) - material = "\0".join(parts) - return hashlib.sha256(material.encode()).hexdigest() + return _scope_fingerprint(self.scope, self.session_dir, str(self.cwd)) @property def discovery_scope(self) -> SessionDiscoveryScope: @@ -118,6 +113,13 @@ def source_id(self) -> str: return f"coding.hosted.{self.scope.value}" +def _scope_fingerprint(scope: SessionScopeV1, root: Path, cwd: str) -> str: + parts = ["coding.hosted.scope/v1", scope.value, os.path.normcase(str(root))] + if scope is SessionScopeV1.CWD: + parts.append(os.path.normcase(cwd)) + return hashlib.sha256("\0".join(parts).encode()).hexdigest() + + def _session_path(root: Path, header: ConversationHeader) -> Path: # This callback only receives the owner's SHA-256 create identity. session_id = header.conversation_id @@ -145,6 +147,20 @@ def _session_factory( return _HOSTED_FACTORY +class _OwnedHostedTranscript(SessionManager): + """Explicit managed candidate; never route an instance to legacy storage.""" + + async def publish_index_summary(self) -> None: + # The aggregate index is optional, and does not yet borrow owned IO. + # Hosted discovery reads canonical headers instead of this cache. + return None + + async def fork(self, leaf_id: str) -> _OwnedHostedTranscript: + # Hosted continuity/create identities require their own clone intent. + # Ordinary factory fork does not issue that identity envelope. + raise CodingHostedCatalogError() + + @dataclass(frozen=True, slots=True) class _Record: projection: SessionIdentityProjectionV1 @@ -153,6 +169,7 @@ class _Record: path: Path = field(repr=False) operation_id: str header: ConversationHeader + compatible: bool = True class CodingHostedCandidateBindingV1: @@ -286,20 +303,174 @@ async def open_product_candidate( class CodingHostedSessionCatalogV1: """Canonical create/resume owner over exact injected transcript directories.""" - def __init__(self, scopes: tuple[CodingHostedScopeV1, ...]) -> None: + def __init__(self, scopes: tuple[CodingHostedScopeV1, ...], *, owned_transcripts: bool = False, + store_state_root: Path | None = None, store_root_observed: threading.Event | None = None) -> None: + if type(owned_transcripts) is not bool: + raise TypeError("invalid owned transcript activation") + if store_state_root is not None and not owned_transcripts: + raise ValueError("store admission requires owned transcripts") if not scopes or any(type(item) is not CodingHostedScopeV1 for item in scopes): raise ValueError("invalid hosted scope bindings") if len({item.scope for item in scopes}) != len(scopes): raise ValueError("duplicate hosted scope") - if len({item.session_dir for item in scopes}) != len(scopes): - raise ValueError("hosted scopes require distinct Session roots") + self._validate_scope_roots(scopes) self.scopes = scopes self._records: dict[SessionCandidateRefV1, _Record] = {} self._lock = asyncio.Lock() + self._owned_factory = ( + AgentTranscriptSessionFactory( + lifecycle=AgentTranscriptLifecycle( + bind_runtime=CODING_TRANSCRIPT_RUNTIME.bind_lifecycle, + bind_runtime_owned=CODING_TRANSCRIPT_RUNTIME.bind_lifecycle_owned, + ), + resolve_binding_input=_resolve_coding_binding_input, + header_metadata=_coding_header_metadata, + validate_restored_header=_validate_coding_restored_header, + session_file_factory=_session_path, + owned_product_id="coding", + store_state_root=store_state_root, + store_root_observed=store_root_observed, + ) if owned_transcripts else None + ) + self._pending_sessions: dict[int, AgentTranscriptLifecycleSession[RuntimeProfileBinding]] = {} + self._closing = False + self._loop: asyncio.AbstractEventLoop | None = None + + def _validate_scope_roots(self, scopes: tuple[CodingHostedScopeV1, ...]) -> None: + if len({item.session_dir for item in scopes}) != len(scopes): + raise ValueError("hosted scopes require distinct Session roots") + + def _read_record(self, scope: CodingHostedScopeV1, path: Path, *, discovery: bool = False) -> _Record | None: + return _read_record(scope, path, discovery=discovery, owned=self._owned_factory is not None) + + def _record_compatible(self, record: _Record) -> bool: + envelope = record.projection.envelope + return record.compatible and envelope is not None and ( + envelope.product_compatibility_id == CODING_HOSTED_COMPATIBILITY_ID + ) + + def _record_available(self, record: _Record) -> bool: + return self._record_compatible(record) + + def _validate_open_record(self, record: _Record) -> None: + # Legacy v1 compatibility remains the Product validator's decision. + # Managed catalogs additionally fence workspace/runtime before IO. + return None + + def _display_identity(self, record: _Record) -> SessionIdentityV1: + return record.identity + + def _record_visible(self, record: _Record) -> bool: + return True + + def _new_root_creation(self) -> bool: + return False + + def _directory_revision(self, path: Path) -> tuple[int, int, int] | None: + return _directory_revision(path) + + def _validate_records(self, records: list[_Record]) -> None: + identities = [item.identity.session_id for item in records] + if len(set(identities)) != len(identities): + raise CodingHostedCatalogError() + + @property + def pending_sessions(self) -> tuple[AgentTranscriptLifecycleSession[RuntimeProfileBinding], ...]: + return tuple(self._pending_sessions.values()) + + def _on_loop(self) -> None: + loop = asyncio.get_running_loop() + if self._loop is None: + self._loop = loop + elif self._loop is not loop: + raise CodingHostedCatalogError() + + def _accepting(self) -> None: + if self._owned_factory is not None: + self._on_loop() + if self._closing: + raise CodingHostedCatalogError() + + def fence(self) -> None: + if self._owned_factory is not None: + self._on_loop() + self._closing = True + self._owned_factory.fence() + + async def close(self) -> None: + if self._owned_factory is None: + return + self._on_loop() + failures: list[Exception] = [] + try: + self.fence() + except Exception as error: + failures.append(error) + async with self._lock: + for session in self.pending_sessions: + try: + await self._discard_session(session) + except Exception as error: + failures.append(error) + try: + await self._owned_factory.close() + except Exception as error: + failures.append(error) + if failures: + raise failures[0] + + async def _discard_session(self, session: AgentTranscriptLifecycleSession[RuntimeProfileBinding]) -> None: + await session.dispose() + self._pending_sessions.pop(id(session), None) + + async def _candidate_from_owned( + self, session: AgentTranscriptLifecycleSession[RuntimeProfileBinding], scope: CodingHostedScopeV1, + *, expected: _Record | None = None, + ) -> _Candidate: + # No await between receipt and retention; wrapper construction itself + # can fail. Keep the original lifecycle rather than only its wrapper. + self._pending_sessions[id(session)] = session + try: + self._accepting() + manager = _OwnedHostedTranscript(lifecycle_session=session) + assert manager.session_file is not None + with session.sync_operation_scope(): + record = self._read_record(scope, manager.session_file) + if (record is None or record.header != session.context.header + or (expected is not None and record != expected)): + raise CodingHostedCatalogError() + self._validate_open_record(record) + # Recheck root identity after the read-only catalog observation. + with session.sync_operation_scope(): + pass + candidate = _Candidate(record, CodingHostedCandidateBindingV1(record, manager)) + self._accepting() + self._pending_sessions.pop(id(session)) + return candidate + except BaseException as error: + try: + await self._discard_session(session) + except BaseException as cleanup_error: + error.add_note(f"owned catalog cleanup retained: {type(cleanup_error).__name__}") + raise + + async def _open(self, record: _Record) -> _Candidate: + self._validate_open_record(record) + if self._owned_factory is None: + return await _open_record(record) + if _revision(record.path) != record.projection.reference.revision: + raise CodingHostedCatalogError() + context = AgentTranscriptLifecycleContext( + session_dir=record.scope.session_dir, cwd=str(record.scope.cwd), persist=True, + header=record.header, session_file=record.path, + ) + session = await self._owned_factory.restore_context(context) + return await self._candidate_from_owned(session, record.scope, expected=record) async def discover_sessions( self, scope: HostedSessionDiscoveryScopeV1, *, stop: Callable[[], bool], ) -> HostedSessionDiscoverySnapshotV1: + self._accepting() if type(scope) is not HostedSessionDiscoveryScopeV1 or scope.product_id != "coding": raise ValueError("unadmitted discovery scope") selected = next((item for item in self.scopes if ( @@ -310,8 +481,13 @@ async def discover_sessions( cancelled = threading.Event() loop = asyncio.get_running_loop() completed: asyncio.Future[HostedSessionDiscoverySnapshotV1 | None] = loop.create_future() + submitted = threading.Event() + admitted = False def read() -> None: + submitted.wait() + if not admitted: + return # Submission without a receipt grants no native read. try: value = self._read_discovery( selected, scope, lambda: cancelled.is_set() or stop(), @@ -322,7 +498,11 @@ def read() -> None: # cancellation cannot manufacture it by cancelling a to_thread Task. loop.call_soon_threadsafe(completed.set_result, value) - loop.run_in_executor(None, read) + try: + loop.run_in_executor(None, read) + admitted = True + finally: + submitted.set() while True: try: result = await asyncio.shield(completed) @@ -333,6 +513,7 @@ def read() -> None: raise asyncio.CancelledError() if result is None: raise CodingHostedCatalogError() + self._accepting() return result def _read_discovery( @@ -344,7 +525,7 @@ def stopped() -> bool: return stop() or time.monotonic() >= deadline if stopped(): return HostedSessionDiscoverySnapshotV1(scope, (), False, omitted_count_exact=False) - before = _directory_revision(admitted.session_dir) + before = self._directory_revision(admitted.session_dir) if before is None: return HostedSessionDiscoverySnapshotV1(scope, (), True) layout = AgentTranscriptFileLayout(admitted.session_dir) @@ -357,32 +538,33 @@ def stopped() -> bool: ) complete, omitted, duplicate = scan.complete, 0, False rows: dict[str, SessionDiscoveryCandidateV1] = {} + seen: set[str] = set() for path in scan.paths: if stopped() or not budget.reserve(candidates=1, bytes_=64 * 1024): complete = False break try: - record = _read_record(admitted, path, discovery=True) + record = self._read_record(admitted, path, discovery=True) except (OSError, ValueError, CodingHostedCatalogError): complete, omitted = False, omitted + 1 continue if record is None: omitted += 1 # Legacy history is observed, never adopted. continue - identity = record.identity - if identity.session_id in rows: + identity = self._display_identity(record) + if identity.session_id in seen: duplicate, omitted = True, omitted + 1 continue - envelope = record.projection.envelope - compatible = envelope is not None and ( - envelope.product_compatibility_id == CODING_HOSTED_COMPATIBILITY_ID - ) + seen.add(identity.session_id) + if not self._record_visible(record): + continue + compatible = self._record_compatible(record) rows[identity.session_id] = SessionDiscoveryCandidateV1( identity, f"Session {identity.session_id[:12]}", SessionCompatibilityV1.COMPATIBLE if compatible else SessionCompatibilityV1.UNSUPPORTED, - SessionAvailabilityV1.AVAILABLE if compatible else SessionAvailabilityV1.UNAVAILABLE, + SessionAvailabilityV1.AVAILABLE if self._record_available(record) else SessionAvailabilityV1.UNAVAILABLE, ) - if _directory_revision(admitted.session_dir) != before or stopped(): + if self._directory_revision(admitted.session_dir) != before or stopped(): complete = False candidates = tuple(rows[key] for key in sorted(rows)) if not complete or duplicate: @@ -399,15 +581,17 @@ async def list_identities( *, limit: int, ) -> tuple[SessionIdentityProjectionV1, ...]: + self._accepting() if type(limit) is not int or not 1 <= limit <= _MAX_CANDIDATES: raise CodingHostedCatalogError() selected = tuple(item for item in self.scopes if item.discovery_scope in scopes) if len(selected) != len(scopes): raise CodingHostedCatalogError() async with self._lock: + self._accepting() records: list[_Record] = [] for scope in selected: - before = _directory_revision(scope.session_dir) + before = self._directory_revision(scope.session_dir) if before is None: continue layout = AgentTranscriptFileLayout(scope.session_dir) @@ -424,13 +608,14 @@ async def list_identities( raise CodingHostedCatalogError() # Routing validates every bounded canonical header. A display # summary's omitted row is never proof of missing authority. - record = _read_record(scope, path) + record = self._read_record(scope, path) if record is not None: records.append(record) - if _directory_revision(scope.session_dir) != before: + if self._directory_revision(scope.session_dir) != before: raise CodingHostedCatalogError() - identities = [item.identity.session_id for item in records] - if len(set(identities)) != len(identities) or len(records) > limit: + self._validate_records(records) + records = [record for record in records if self._record_visible(record)] + if len(records) > limit: raise CodingHostedCatalogError() # Independent cwd/home discovery must not invalidate an in-flight # candidate from the other source. Each source still replaces its @@ -447,11 +632,13 @@ async def list_identities( return tuple(record.projection for record in records) async def open_candidate(self, reference: SessionCandidateRefV1) -> _Candidate: + self._accepting() async with self._lock: + self._accepting() record = self._records.get(reference) if record is None: raise CodingHostedCatalogError() - return await _open_record(record) + return await self._open(record) def _scope_for(self, request: SessionCreateRequestV1) -> CodingHostedScopeV1: if request.product_id != "coding" or request.requested_continuity_id is None: @@ -467,7 +654,9 @@ def _scope_for(self, request: SessionCreateRequestV1) -> CodingHostedScopeV1: async def find_created_candidate( self, request: SessionCreateRequestV1 ) -> _Candidate | None: + self._accepting() async with self._lock: + self._accepting() return await self._find(request) async def _find(self, request: SessionCreateRequestV1) -> _Candidate | None: @@ -475,7 +664,7 @@ async def _find(self, request: SessionCreateRequestV1) -> _Candidate | None: path = scope.session_dir / f"hosted-{_create_identity(request)}.jsonl" if not path.exists() and not path.is_symlink(): return None - record = _read_record(scope, path) + record = self._read_record(scope, path) if ( record is None or record.operation_id != request.operation_id @@ -485,12 +674,14 @@ async def _find(self, request: SessionCreateRequestV1) -> _Candidate | None: != CODING_HOSTED_COMPATIBILITY_ID ): raise CodingHostedCatalogError() - return await _open_record(record) + return await self._open(record) async def create_candidate(self, intent: SessionCreateIntentV1) -> _Candidate: + self._accepting() if intent.product_compatibility_id != CODING_HOSTED_COMPATIBILITY_ID: raise CodingHostedCatalogError() async with self._lock: + self._accepting() existing = await self._find(intent.request) if existing is not None: return existing @@ -505,6 +696,13 @@ async def create_candidate(self, intent: SessionCreateIntentV1) -> _Candidate: "scopeFingerprint": scope.fingerprint, "operationId": intent.request.operation_id, } + if self._owned_factory is not None: + session = await self._owned_factory.new( + session_dir=scope.session_dir, cwd=str(scope.cwd), session_id=session_id, + additional_header_metadata={_METADATA_KEY: metadata}, defer_materialization=False, + create_root=self._new_root_creation(), + ) + return await self._candidate_from_owned(session, scope) try: manager = await _HostedTranscript.new( session_dir=scope.session_dir, @@ -521,7 +719,7 @@ async def create_candidate(self, intent: SessionCreateIntentV1) -> _Candidate: return recovered try: assert manager.session_file is not None - record = _read_record(scope, manager.session_file) + record = self._read_record(scope, manager.session_file) if record is None: raise CodingHostedCatalogError() return _Candidate( @@ -571,18 +769,40 @@ def _revision(path: Path) -> str: def _read_record( - scope: CodingHostedScopeV1, path: Path, *, discovery: bool = False, + scope: CodingHostedScopeV1, path: Path, *, discovery: bool = False, owned: bool = False, ) -> _Record | None: if path.parent != scope.session_dir: raise CodingHostedCatalogError() before = _revision(path) - header = (load_agent_transcript_header(path, blocking=False, create_lock=False) - if discovery else load_agent_transcript_header(path)) + header = ( + load_agent_transcript_header(path, read_only=True) if owned else + load_agent_transcript_header(path, blocking=False, create_lock=False) if discovery else + load_agent_transcript_header(path) + ) if _revision(path) != before: raise CodingHostedCatalogError() raw = header.metadata.get(_METADATA_KEY) if raw is None: return None # Legacy/default TUI transcripts are not implicitly adopted. + identity, compatibility, operation = _hosted_header_identity( + header, scope=scope.scope, fingerprint=scope.fingerprint, + ) + envelope = SessionIdentityEnvelopeV1( + "coding", compatibility, identity.continuity_id, identity.session_id, + "coding.jsonl", identity.session_id, + ) + projection = SessionIdentityProjectionV1( + SessionCandidateRefV1(scope.source_id, identity.session_id, before), + scope.discovery_scope, SessionCandidateMode.CANONICAL, envelope, + ) + return _Record(projection, identity, scope, path, operation, header) + + +def _hosted_header_identity( + header: ConversationHeader, *, scope: SessionScopeV1, fingerprint: str, +) -> tuple[SessionIdentityV1, str, str]: + """Validate the original v1 identity independently of a selection view.""" + raw = header.metadata.get(_METADATA_KEY) if not isinstance(raw, Mapping) or set(raw) != { "version", "compatibilityId", @@ -599,16 +819,17 @@ def _read_record( raise CodingHostedCatalogError() if ( raw["sessionId"] != header.conversation_id - or raw["scope"] != scope.scope.value - or raw["scopeFingerprint"] != scope.fingerprint + or raw["scope"] != scope.value + or raw["scopeFingerprint"] != fingerprint ): raise CodingHostedCatalogError() create_request = SessionCreateRequestV1( "coding", - scope.fingerprint, + fingerprint, cast(str, raw["operationId"]), requested_continuity_id=cast(str, raw["continuityId"]), - requested_scope=scope.discovery_scope, + requested_scope=(SessionDiscoveryScope.CURRENT_DIRECTORY if scope is SessionScopeV1.CWD + else SessionDiscoveryScope.USER_GLOBAL_CANONICAL), ) if _create_identity(create_request) != header.conversation_id: raise CodingHostedCatalogError() @@ -616,26 +837,10 @@ def _read_record( "coding", cast(str, raw["continuityId"]), header.conversation_id, - scope.scope, - scope.fingerprint, - ) - envelope = SessionIdentityEnvelopeV1( - "coding", - cast(str, raw["compatibilityId"]), - identity.continuity_id, - identity.session_id, - "coding.jsonl", - identity.session_id, - ) - projection = SessionIdentityProjectionV1( - SessionCandidateRefV1(scope.source_id, identity.session_id, before), - scope.discovery_scope, - SessionCandidateMode.CANONICAL, - envelope, - ) - return _Record( - projection, identity, scope, path, cast(str, raw["operationId"]), header + scope, + fingerprint, ) + return identity, cast(str, raw["compatibilityId"]), cast(str, raw["operationId"]) async def _open_record(record: _Record) -> _Candidate: diff --git a/src/loushang/coding/hosted_continuity.py b/src/loushang/coding/hosted_continuity.py index c58786f38..03b7a2572 100644 --- a/src/loushang/coding/hosted_continuity.py +++ b/src/loushang/coding/hosted_continuity.py @@ -55,6 +55,8 @@ def __post_init__(self) -> None: raise TypeError("invalid Coding foreground application request") if _STABLE_ID.fullmatch(self.application_id) is None: raise ValueError("invalid Coding hosted continuity application identity") + if self.foreground.managed_mux is not None and self.foreground.managed_mux.application_id != self.application_id: + raise ValueError("managed Mux application identity mismatch") if _OPAQUE_TOKEN.fullmatch(self.owner_epoch) is None: raise ValueError("invalid Coding hosted continuity owner epoch") for name in ("acquire", "list_applications"): @@ -95,7 +97,7 @@ def __init__(self, request: CodingHostedContinuityRequestV1) -> None: raise TypeError("invalid Coding hosted continuity request") self._request = request self._product_factory = CodingForegroundProductFactoryV1( - request.foreground.session_factory + request.foreground.session_factory, session_owner=request.foreground.session_owner, ) self._catalog: AppHostCatalogV1 | None = None self._runtime: AppHostRuntimeV1 | None = None @@ -185,6 +187,7 @@ async def _open_once(self) -> HostedApplicationContinuityRuntimeV1: operation_id_factory=request.operation_id_factory, admitted_scopes=request.admitted_scopes, execution=request.execution is not None, + managed_selection=request.managed_selection, ) application = _coding_hosted_application_request( request, diff --git a/src/loushang/coding/hosted_local.py b/src/loushang/coding/hosted_local.py index 5b95e3ac9..f5a0a90df 100644 --- a/src/loushang/coding/hosted_local.py +++ b/src/loushang/coding/hosted_local.py @@ -6,6 +6,7 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass, field from pathlib import Path +from typing import Protocol from loushang.agent.types import StreamFn from loushang.ai.model import Model, ModelSelection @@ -22,6 +23,23 @@ from loushang.harness.tools.core import ToolDefinition from .hosted_bootstrap import CodingHostedLaunchV1, create_coding_hosted_attempt +from .hosted_continuity import CodingHostedContinuityAttemptV1 + + +class _LocalLaunchFacts(Protocol): + """Only facts consumed by the shared local application lifetime.""" + + @property + def connection_root(self) -> Path: ... + + @property + def endpoint(self) -> str: ... + + @property + def session_discovery(self) -> bool: ... + + @property + def scopes(self) -> tuple[LocalRecordScopeV1, ...]: ... @dataclass(frozen=True, slots=True) @@ -89,7 +107,7 @@ class CodingLocalCommandV1: def __init__( self, - launch: CodingLocalLaunchV1, + launch: _LocalLaunchFacts, *, model: Model | ModelSelection | None = None, stream_fn: StreamFn | None = None, @@ -97,16 +115,12 @@ def __init__( startup_timeout: float = 30.0, settlement_timeout: float = 30.0, ) -> None: - if type(launch) is not CodingLocalLaunchV1: - raise TypeError("local command requires admitted launch facts") + self._validate_launch(launch) for timeout in (startup_timeout, settlement_timeout): _require_budget(timeout) self._launch = launch self._directory = LocalConnectionDirectoryV1(launch.connection_root) - self._attempt = create_coding_hosted_attempt( - launch.application, model=model, stream_fn=stream_fn, tools=tools, - session_discovery=launch.session_discovery, - ) + self._attempt = self._create_attempt(launch, model=model, stream_fn=stream_fn, tools=tools) self._application: HostedApplicationContinuityRuntimeV1 | None = None self._local: HostedLocalRuntimeV1 | None = None self._startup_timeout, self._timeout = startup_timeout, settlement_timeout @@ -121,6 +135,26 @@ def __init__( self._closing = False self._settled = False + def _validate_launch(self, launch: _LocalLaunchFacts) -> None: + if type(launch) is not CodingLocalLaunchV1: + raise TypeError("local command requires admitted launch facts") + + def _connection_instance(self) -> str | None: + return None + + def _mux_management(self) -> bool: + return False + + def _create_attempt( + self, launch: _LocalLaunchFacts, *, model: Model | ModelSelection | None, + stream_fn: StreamFn | None, tools: list[ToolDefinition] | None, + ) -> CodingHostedContinuityAttemptV1: + assert type(launch) is CodingLocalLaunchV1 + return create_coding_hosted_attempt( + launch.application, model=model, stream_fn=stream_fn, tools=tools, + session_discovery=launch.session_discovery, + ) + @property def cleanup_pending(self) -> bool: return not self._settled @@ -176,6 +210,8 @@ async def _start_once(self) -> None: scopes=self._launch.scopes, settlement_timeout=self._timeout, session_discovery=self._launch.session_discovery, + connection_instance=self._connection_instance(), + mux_management=self._mux_management(), ) self._application = None # AppHost has adopted both application and directory. await self._local.prepare(deadline=self._startup_deadline) diff --git a/src/loushang/coding/hosted_session.py b/src/loushang/coding/hosted_session.py index 73c306c27..81512e586 100644 --- a/src/loushang/coding/hosted_session.py +++ b/src/loushang/coding/hosted_session.py @@ -25,6 +25,7 @@ from loushang.harness.session import SessionControlPort from loushang.harness.tools.core import ToolDefinition from loushang.harness.tools.workspace import workspace_tool_runtime_settings +from loushang.harness.workspace.exec.capture_lease import ExecCaptureFactory from .appservice_adapter import ( CodingHostedEventProjectionV1, @@ -231,11 +232,13 @@ def __init__( model: Model | ModelSelection | None = None, stream_fn: StreamFn | None = None, tools: list[ToolDefinition] | None = None, + output_capture_factory: ExecCaptureFactory | None = None, ) -> None: self._services_factory = services_factory self._model = model self._stream_fn = stream_fn self._tools = tools + self._output_capture_factory = output_capture_factory async def create_session( self, @@ -263,6 +266,7 @@ async def create_session( tools=self._tools, approval_resolver=approval, tool_policy_evaluator=policy.policy_engine, + output_capture_factory=self._output_capture_factory, ) opaque_session_binding.retain_constructed_owner(session.dispose) binding = CodingRealHostedSessionV1(session, identity, approval) diff --git a/src/loushang/coding/managed_bootstrap.py b/src/loushang/coding/managed_bootstrap.py new file mode 100644 index 000000000..483c16121 --- /dev/null +++ b/src/loushang/coding/managed_bootstrap.py @@ -0,0 +1,142 @@ +"""Explicit Coding launch facts and optional managed application preparation. + +Construction discovers no defaults and starts no Product or deployment. The +returned application attempt retains admission before its explicit open. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from secrets import token_hex + +from loushang.agent.types import StreamFn +from loushang.ai.model import Model, ModelSelection +from loushang.apphost.managed.contracts import ManagedContractError +from loushang.apphost.managed.invocation import ManagedChildInvocationV1 +from loushang.apphost.managed.paths import ( + resolve_managed_admission_root, + resolve_managed_paths, +) +from loushang.appserver.protocol import SessionScopeV1 +from loushang.appservice.continuity import require_application_id +from loushang.appservice.managed_mux import ManagedMuxServiceBindingV1 +from loushang.harness.tools.core import ToolDefinition +from loushang.harness.workspace.exec.capture_lease import ExecCaptureFactory + +from .hosted_application import CODING_MANAGED_APPLICATION_PROFILE_ID +from .hosted_bootstrap import CodingHostedLaunchV1, _create_coding_attempt +from .hosted_catalog import CodingHostedScopeV1 +from .hosted_continuity import CodingHostedContinuityAttemptV1 +from .hosted_local import CodingLocalLaunchV1 +from .managed_catalog import CodingManagedSessionCatalogV1 + + +@dataclass(frozen=True, slots=True) +class CodingManagedApplicationLaunchV1: + """Explicit canonical Session launch, separate from legacy G16 roots.""" + + workspace: Path = field(repr=False) + application_root: Path = field(repr=False) + application_id: str + session_root: Path = field(repr=False) + store_state_root: Path | None = field(default=None, repr=False, kw_only=True) + + def __post_init__(self) -> None: + require_application_id(self.application_id) + for path in (self.workspace, self.application_root, self.session_root): + if not isinstance(path, Path) or not path.is_absolute() or path != path.resolve(): + raise ValueError("managed application requires canonical paths") + if not self.workspace.is_dir() or not self.application_root.parent.is_dir(): + raise ValueError("managed workspace and application parent must exist") + for data in ( + self.session_root, self.session_root.parent / "session-assets", + self.session_root.parent / ".session-blob-writers", + ): + if self.application_root.is_relative_to(data) or data.is_relative_to(self.application_root): + raise ValueError("managed application state must be separate from Session storage") + if self.store_state_root is not None: + state = self.store_state_root + if (not state.is_absolute() or state != state.resolve() + or state.is_relative_to(self.session_root.parent) + or self.session_root.parent.is_relative_to(state) + or state.is_relative_to(self.application_root) + or self.application_root.is_relative_to(state)): + raise ValueError("store admission state must be separate from writable application data") + + @property + def scopes(self) -> tuple[CodingHostedScopeV1, ...]: + return tuple(CodingHostedScopeV1(scope, self.session_root, self.workspace) + for scope in (SessionScopeV1.CWD, SessionScopeV1.USER_HOME)) + + +def create_coding_managed_attempt( + launch: CodingManagedApplicationLaunchV1, *, model: Model | ModelSelection | None = None, + stream_fn: StreamFn | None = None, tools: list[ToolDefinition] | None = None, + session_discovery: bool = False, + managed_mux: ManagedMuxServiceBindingV1 | None = None, + output_capture_factory: ExecCaptureFactory | None = None, +) -> CodingHostedContinuityAttemptV1: + """Prepare the owned Product application; no daemon/default-path effects.""" + if type(launch) is not CodingManagedApplicationLaunchV1 or type(session_discovery) is not bool: + raise TypeError("invalid managed application launch") + if managed_mux is not None and (type(managed_mux) is not ManagedMuxServiceBindingV1 + or managed_mux.application_id != launch.application_id): + raise ValueError("managed Mux binding does not match application") + catalog = CodingManagedSessionCatalogV1(session_root=launch.session_root, workspace=launch.workspace, + store_state_root=launch.store_state_root) + return _create_coding_attempt( + application_root=launch.application_root, application_id=launch.application_id, + catalog=catalog, generation=token_hex(16), model=model, stream_fn=stream_fn, tools=tools, + session_discovery=session_discovery, owned_transcripts=True, + profile_id=CODING_MANAGED_APPLICATION_PROFILE_ID, managed_selection=True, + managed_mux=managed_mux, + output_capture_factory=output_capture_factory, + ) + + +def create_coding_managed_launch( + invocation: ManagedChildInvocationV1, *, application_id: str, endpoint: str, + cwd_sessions: Path, home_sessions: Path, session_discovery: bool = False, +) -> CodingLocalLaunchV1: + """Bind Coding to exactly the invocation workspace and managed layout. + + Session roots remain explicit and pass the existing G16 disjoint-root + checks. They are not decoded from the neutral message or inferred from cwd; + the canonical shared default catalog must be admitted separately before + managed CLI activation. No factory or model selection comes from this value. + """ + if type(invocation) is not ManagedChildInvocationV1 or invocation.service.product_id != "coding": + raise ManagedContractError() + paths = resolve_managed_paths( + invocation.namespace, invocation.service, invocation.instance, runtime_root=invocation.runtime_root, + temporary_override=invocation.temporary_override, + ) + launch = CodingLocalLaunchV1( + CodingHostedLaunchV1( + Path(invocation.service.workspace), Path(paths.application), application_id, + cwd_sessions, home_sessions, + ), + Path(paths.connection), endpoint, session_discovery=session_discovery, + ) + # Deployment control and scratch are not Session catalogs, even where they + # are siblings of (rather than nested in) the application/connection roots. + for catalog in (cwd_sessions, home_sessions): + for data in (catalog, catalog.parent / "session-assets", catalog.parent / ".session-blob-writers"): + for managed in _managed_control_roots(invocation): + if data.is_relative_to(managed) or managed.is_relative_to(data): + raise ManagedContractError() + return launch + + +def _managed_control_roots(invocation: ManagedChildInvocationV1) -> tuple[Path, ...]: + return ( + Path(invocation.namespace.platform_home) / "lmux", + Path(invocation.runtime_root) / "lmux", + Path(resolve_managed_admission_root(invocation.namespace)).parent, + Path(invocation.namespace.platform_home) / "state/session-stores", + *((Path(invocation.temporary_override) / "lmux",) if invocation.temporary_override is not None else ()), + ) + + +__all__ = ["CodingManagedApplicationLaunchV1", "create_coding_managed_attempt", "create_coding_managed_launch"] diff --git a/src/loushang/coding/managed_catalog.py b/src/loushang/coding/managed_catalog.py new file mode 100644 index 000000000..06c469359 --- /dev/null +++ b/src/loushang/coding/managed_catalog.py @@ -0,0 +1,301 @@ +"""Coding's shared-store catalog; discovery scopes are views, not stores. + +Explicit composition only: the caller supplies an admitted canonical Session +root and workspace. Construction and discovery create no directories. An +explicit first-initialization grant permits the original transcript writer to +prepare a new root; this module resolves no defaults and does not change +Embedded writer behavior. +""" + +from __future__ import annotations + +import hashlib +import os +import threading +from collections.abc import Callable, Mapping +from dataclasses import replace +from pathlib import Path + +from loushang.apphost import ( + SessionCandidateMode, + SessionCandidateRefV1, + SessionIdentityEnvelopeV1, + SessionIdentityProjectionV1, +) +from loushang.appserver.protocol import SessionIdentityV1, SessionScopeV1 +from loushang.appservice.discovery_ports import ( + HostedSessionDiscoveryScopeV1, + HostedSessionDiscoverySnapshotV1, +) +from loushang.harness.journal._owned_io import settled_io +from loushang.harness.transcript.jsonl_file import load_agent_transcript_header +from loushang.harness.transcript.store_admission import ( + TranscriptStoreAdmission, + TranscriptStoreBinding, +) + +from .hosted_catalog import ( + CODING_HOSTED_COMPATIBILITY_ID, + CodingHostedCatalogError, + CodingHostedScopeV1, + CodingHostedSessionCatalogV1, + _hosted_header_identity, + _Record, + _revision, + _scope_fingerprint, +) +from .session_manager import ( + _resolve_coding_binding_input, + _validate_coding_restored_header, +) + +_MAX_STORE_OBSERVATIONS = 8 + + +class CodingManagedSessionCatalogV1(CodingHostedSessionCatalogV1): + """Reuse the existing owned lifecycle for two views of one physical root.""" + + def __init__(self, *, session_root: Path, workspace: Path, initialize_session_root: bool = False, + store_state_root: Path | None = None) -> None: + if type(initialize_session_root) is not bool: + raise TypeError("invalid Session root initialization grant") + if store_state_root is not None and initialize_session_root: + raise ValueError("store admission cannot be combined with a root creation grant") + # Trusted first-initialization authority, NOT a missing-directory test. + # A production coordinator must prove this is a new store before + # granting it; no CLI/default/restore path supplies it automatically. + self._root_initialization = initialize_session_root + self._root_observed = threading.Event() + self._store_state_root = store_state_root + self._store_probes: dict[TranscriptStoreAdmission, bool] = {} + self._store_probe_lock = threading.Lock() + self._store_probes_fenced = False + self._store_active_reads = 0 + self._observed_store_binding: TranscriptStoreBinding | None = None + self._observed_root_identity: tuple[int, int] | None = None + super().__init__(tuple( + CodingHostedScopeV1(scope, session_root, workspace) + for scope in (SessionScopeV1.CWD, SessionScopeV1.USER_HOME) + ), owned_transcripts=True, store_state_root=store_state_root, store_root_observed=self._root_observed) + + def _accepting(self) -> None: + super()._accepting() + if os.path.lexists(self.scopes[0].session_dir): + self._root_observed.set() + if self._root_observed.is_set(): + # Once this catalog observes an existing root it cannot authorize + # recreating it, even if it is later moved or lost. + self._root_initialization = False + + def _new_root_creation(self) -> bool: + grant, self._root_initialization = self._root_initialization, False + return grant and not self._root_observed.is_set() + + def _directory_revision(self, path: Path) -> tuple[int, int, int] | None: + probe = None + if self._store_state_root is not None: + probe = TranscriptStoreAdmission(path, state_root=self._store_state_root, + root_observed=self._root_observed) + with self._store_probe_lock: + if (self._store_probes_fenced or len(self._store_probes) >= _MAX_STORE_OBSERVATIONS + or any(not active for active in self._store_probes.values())): + raise CodingHostedCatalogError() + self._store_probes[probe] = True # Retain before native IO, including worker reads. + try: + binding = probe.inspect() if probe is not None else None + if probe is not None: + with self._store_probe_lock: + if self._observed_store_binding is not None and binding != self._observed_store_binding: + raise CodingHostedCatalogError() + if binding is not None: + self._observed_store_binding = binding + revision = super()._directory_revision(path) + if revision is not None: + self._root_observed.set() + if probe is not None: + with self._store_probe_lock: + if self._observed_root_identity is not None and revision[:2] != self._observed_root_identity: + raise CodingHostedCatalogError() + self._observed_root_identity = revision[:2] + if binding is not None: + assert probe is not None + probe.check() + if revision is None or revision[:2] != binding.root_identity: + raise CodingHostedCatalogError() + if revision is None and self._root_observed.is_set(): + raise CodingHostedCatalogError() + except BaseException as error: + self._root_observed.set() # Unknown observation cannot authorize creation. + if probe is not None: + try: + self._finish_store_probe(probe) + except BaseException as cleanup_error: + error.add_note(f"store observation cleanup retained: {type(cleanup_error).__name__}") + raise + else: + if probe is not None: + self._finish_store_probe(probe) + return revision + + async def discover_sessions( + self, scope: HostedSessionDiscoveryScopeV1, *, stop: Callable[[], bool], + ) -> HostedSessionDiscoverySnapshotV1: + self._accepting() + with self._store_probe_lock: + if self._store_probes_fenced or self._store_active_reads >= _MAX_STORE_OBSERVATIONS: + raise CodingHostedCatalogError() + self._store_active_reads += 1 + try: + return await super().discover_sessions(scope, stop=stop) + finally: + with self._store_probe_lock: + self._store_active_reads -= 1 + + def _finish_store_probe(self, probe: TranscriptStoreAdmission) -> None: + try: + probe.close() + except BaseException: + with self._store_probe_lock: + self._store_probes[probe] = False # Native read ended, cleanup still owned. + raise + else: + with self._store_probe_lock: + self._store_probes.pop(probe, None) + + def fence(self) -> None: + self._on_loop() + try: + super().fence() + finally: + with self._store_probe_lock: + self._store_probes_fenced = True + + async def close(self) -> None: + self._on_loop() + failures: list[Exception] = [] + try: + await super().close() + except Exception as error: + failures.append(error) + if self._store_state_root is not None: + try: + await settled_io(self._close_store_probes) + except Exception as error: + failures.append(error) + if failures: + raise failures[0] + + def _close_store_probes(self) -> None: + with self._store_probe_lock: + probes = tuple(self._store_probes.items()) + active_reads = self._store_active_reads + failures: list[Exception] = [CodingHostedCatalogError()] if active_reads else [] + for probe, active in probes: + if active: + # Discovery's original worker still owns the native call. Its + # caller waits for completion; close must not free borrowed fds. + failures.append(CodingHostedCatalogError()) + continue + try: + self._finish_store_probe(probe) + except Exception as error: + failures.append(error) + if failures: + raise failures[0] + + def _validate_scope_roots(self, scopes: tuple[CodingHostedScopeV1, ...]) -> None: + if len({item.session_dir for item in scopes}) != 1 or len({item.cwd for item in scopes}) != 1: + raise ValueError("managed views require one canonical root and workspace") + + def _read_record(self, scope: CodingHostedScopeV1, path: Path, *, discovery: bool = False) -> _Record | None: + if path.parent != scope.session_dir: + raise CodingHostedCatalogError() + self._root_observed.set() # Reading a selected history is not first initialization. + before = _revision(path) + header = load_agent_transcript_header(path, read_only=True) + if _revision(path) != before: + raise CodingHostedCatalogError() + cwd = header.metadata.get("cwd") + if (type(cwd) is not str or not cwd or "\0" in cwd or not os.path.isabs(cwd) + or cwd.startswith("//") or os.path.normpath(cwd) != cwd): + raise CodingHostedCatalogError() + # Never resolve an untrusted header path or require a historical + # workspace to exist merely to display it as unavailable. + try: + cwd.encode("utf-8") + except UnicodeError: + raise CodingHostedCatalogError() from None + if "coding.hosted" in header.metadata: + raw = header.metadata["coding.hosted"] + if not isinstance(raw, Mapping): + raise CodingHostedCatalogError() + try: + original_scope = SessionScopeV1(raw.get("scope")) + except (TypeError, ValueError): + raise CodingHostedCatalogError() from None + identity, compatibility, operation = _hosted_header_identity( + header, scope=original_scope, + fingerprint=_scope_fingerprint(original_scope, scope.session_dir, cwd), + ) + else: + session_id = header.conversation_id + try: + if "\0" in session_id: + raise ValueError("invalid canonical identity") + continuity = hashlib.sha256("\0".join(( + "coding.managed.continuity/v1", "coding", str(scope.session_dir), session_id, + )).encode("utf-8")).hexdigest() + identity = SessionIdentityV1( + "coding", continuity, session_id, SessionScopeV1.USER_HOME, + _scope_fingerprint(SessionScopeV1.USER_HOME, scope.session_dir, cwd), + ) + except (ValueError, UnicodeError): + raise CodingHostedCatalogError() from None + compatibility, operation = CODING_HOSTED_COMPATIBILITY_ID, "" + compatible = True + try: + _validate_coding_restored_header(header, _resolve_coding_binding_input(True), True) + except (TypeError, ValueError): + compatible = False + envelope = SessionIdentityEnvelopeV1( + "coding", compatibility, identity.continuity_id, identity.session_id, + "coding.jsonl", identity.session_id, + ) + projection = SessionIdentityProjectionV1( + SessionCandidateRefV1(scope.source_id, identity.session_id, before), + scope.discovery_scope, SessionCandidateMode.CANONICAL, envelope, + ) + return _Record(projection, identity, scope, path, operation, header, compatible) + + def _record_available(self, record: _Record) -> bool: + return self._record_compatible(record) and record.header.metadata.get("cwd") == str(record.scope.cwd) + + def _record_visible(self, record: _Record) -> bool: + # Keep hidden records in the authority scan until duplicate detection + # completes; a workspace filter cannot conceal a conflicting file. + return record.scope.scope is SessionScopeV1.USER_HOME or record.header.metadata.get("cwd") == str(record.scope.cwd) + + def _validate_open_record(self, record: _Record) -> None: + if not self._record_available(record): + raise CodingHostedCatalogError() + + def _display_identity(self, record: _Record) -> SessionIdentityV1: + # Wire observations belong to the admitted selection view. The + # candidate binding retains the original canonical identity above. + return replace(record.identity, scope=record.scope.scope, scope_fingerprint=record.scope.fingerprint) + + def _validate_records(self, records: list[_Record]) -> None: + seen: dict[str, _Record] = {} + for record in records: + previous = seen.get(record.identity.session_id) + if previous is not None and ( + previous.path != record.path or previous.header != record.header + or previous.projection.envelope != record.projection.envelope + or previous.projection.reference.revision != record.projection.reference.revision + or previous.scope.scope is record.scope.scope + ): + raise CodingHostedCatalogError() + seen[record.identity.session_id] = record + + +__all__ = ["CodingManagedSessionCatalogV1"] diff --git a/src/loushang/coding/managed_local.py b/src/loushang/coding/managed_local.py new file mode 100644 index 000000000..b7d08c82b --- /dev/null +++ b/src/loushang/coding/managed_local.py @@ -0,0 +1,155 @@ +"""Explicit canonical Coding application on the existing local listener owner. + +No service discovery, directory initialization, daemon spawn or CLI activation. +The managed child still owns durable handoff and every shutdown phase. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from loushang.agent.types import StreamFn +from loushang.ai.model import Model, ModelSelection +from loushang.apphost.managed.contracts import ManagedContractError +from loushang.apphost.managed.invocation import ManagedChildInvocationV1 +from loushang.apphost.managed.output_capture import ManagedOutputCaptureFactory +from loushang.apphost.managed.paths import resolve_managed_paths +from loushang.appserver.local_record import LocalConnectionRecordV1, LocalRecordScopeV1 +from loushang.appservice.managed_mux import ManagedMuxServiceBindingV1 +from loushang.harness.tools.core import ToolDefinition + +from .hosted_continuity import CodingHostedContinuityAttemptV1 +from .hosted_local import CodingLocalCommandV1, _LocalLaunchFacts +from .managed_bootstrap import ( + CodingManagedApplicationLaunchV1, + _managed_control_roots, + create_coding_managed_attempt, +) + + +@dataclass(frozen=True, slots=True) +class CodingManagedLocalLaunchV1: + application: CodingManagedApplicationLaunchV1 + connection_root: Path = field(repr=False) + endpoint: str + session_discovery: bool = field(default=False, kw_only=True) + instance_id: str | None = field(default=None, kw_only=True) + managed_mux: ManagedMuxServiceBindingV1 | None = field(default=None, repr=False, kw_only=True) + + def __post_init__(self) -> None: + if self.managed_mux is not None and (type(self.managed_mux) is not ManagedMuxServiceBindingV1 + or self.managed_mux.application_id != self.application.application_id + or self.managed_mux.instance_id != self.instance_id): + raise ValueError("managed Mux binding must match launch identity") + root = self.connection_root + if (type(self.application) is not CodingManagedApplicationLaunchV1 + or type(self.session_discovery) is not bool or not isinstance(root, Path) + or not root.is_absolute() or root != root.resolve() + or not root.parent.is_dir() or self.application.workspace.is_relative_to(root)): + raise ValueError("invalid managed connection root") + for durable in ( + self.application.application_root, self.application.session_root, + self.application.session_root.parent / "session-assets", + self.application.session_root.parent / ".session-blob-writers", + ): + if root.is_relative_to(durable) or durable.is_relative_to(root): + raise ValueError("managed connection and Session authority must be separate") + state = self.application.store_state_root + if state is not None and (root.is_relative_to(state) or state.is_relative_to(root)): + raise ValueError("managed connection and store admission state must be separate") + LocalConnectionRecordV1( + endpoint=self.endpoint, application_id=self.application.application_id, + product_id="coding", instance="0" * 32 if self.instance_id is None else self.instance_id, + key=bytes(32), port=1, scopes=self.scopes, + ) + + @property + def scopes(self) -> tuple[LocalRecordScopeV1, ...]: + return tuple(LocalRecordScopeV1(scope.scope, scope.fingerprint) for scope in self.application.scopes) + + +class CodingManagedLocalCommandV1(CodingLocalCommandV1): + """Select only the managed attempt; inherit the original lifetime intact.""" + + def __init__(self, launch: CodingManagedLocalLaunchV1, *, model: Model | ModelSelection | None = None, + stream_fn: StreamFn | None = None, tools: list[ToolDefinition] | None = None, + startup_timeout: float = 30.0, settlement_timeout: float = 30.0, + output_capture_factory: ManagedOutputCaptureFactory | None = None) -> None: + self._output_capture_factory = output_capture_factory + super().__init__(launch, model=model, stream_fn=stream_fn, tools=tools, + startup_timeout=startup_timeout, settlement_timeout=settlement_timeout) + + @property + def cleanup_pending(self) -> bool: + return super().cleanup_pending or bool(self._output_capture_factory is not None + and not self._output_capture_factory.settled) + + async def close(self, *, retry_timeout: float | None = None) -> None: + await super().close(retry_timeout=retry_timeout) + if self._output_capture_factory is not None: + await self._output_capture_factory.close() + + def _validate_launch(self, launch: _LocalLaunchFacts) -> None: + if type(launch) is not CodingManagedLocalLaunchV1: + raise TypeError("managed local command requires exact admitted launch facts") + + def _connection_instance(self) -> str | None: + assert type(self._launch) is CodingManagedLocalLaunchV1 + return self._launch.instance_id + + def _mux_management(self) -> bool: + assert type(self._launch) is CodingManagedLocalLaunchV1 + return self._launch.managed_mux is not None + + def _create_attempt( + self, launch: _LocalLaunchFacts, *, model: Model | ModelSelection | None, + stream_fn: StreamFn | None, tools: list[ToolDefinition] | None, + ) -> CodingHostedContinuityAttemptV1: + assert type(launch) is CodingManagedLocalLaunchV1 + return create_coding_managed_attempt( + launch.application, model=model, stream_fn=stream_fn, tools=tools, + session_discovery=launch.session_discovery, + managed_mux=launch.managed_mux, + output_capture_factory=self._output_capture_factory, + ) + + +def create_coding_managed_local_launch( + invocation: ManagedChildInvocationV1, *, session_root: Path, + application_id: str, endpoint: str, session_discovery: bool = False, + store_state_root: Path | None = None, + managed_mux: ManagedMuxServiceBindingV1 | None = None, +) -> CodingManagedLocalLaunchV1: + """Derive neutral deployment paths; keep canonical Session selection Coding-owned.""" + if type(invocation) is not ManagedChildInvocationV1 or invocation.service.product_id != "coding": + raise ManagedContractError() + if managed_mux is not None and (type(managed_mux) is not ManagedMuxServiceBindingV1 + or managed_mux.service_id != invocation.service.service_id + or managed_mux.instance_id != invocation.instance.instance_id + or managed_mux.application_id != application_id): + raise ManagedContractError() + paths = resolve_managed_paths( + invocation.namespace, invocation.service, invocation.instance, runtime_root=invocation.runtime_root, + temporary_override=invocation.temporary_override, + ) + application = CodingManagedApplicationLaunchV1( + Path(invocation.service.workspace), Path(paths.application), application_id, session_root, + store_state_root=store_state_root, + ) + state = application.store_state_root + runtime = Path(invocation.runtime_root) + if state is not None and (state.is_relative_to(runtime) or runtime.is_relative_to(state)): + raise ManagedContractError() + for data in (session_root, session_root.parent / "session-assets", session_root.parent / ".session-blob-writers"): + for managed in _managed_control_roots(invocation): + if data.is_relative_to(managed) or managed.is_relative_to(data): + raise ManagedContractError() + return CodingManagedLocalLaunchV1( + application, Path(paths.connection), endpoint, session_discovery=session_discovery, + instance_id=invocation.instance.instance_id, + managed_mux=managed_mux, + ) + + +__all__ = ["CodingManagedLocalLaunchV1", "CodingManagedLocalCommandV1", "create_coding_managed_local_launch"] diff --git a/src/loushang/coding/managed_process.py b/src/loushang/coding/managed_process.py new file mode 100644 index 000000000..8f72e7ebc --- /dev/null +++ b/src/loushang/coding/managed_process.py @@ -0,0 +1,164 @@ +"""Dedicated Coding child entry for the managed Linux deployment. + +The parent supplies a durable invocation and one inherited control descriptor. +This module does not initialize directories, discover services or infer stop +facts. Before application binding, failure exits nonzero without a clean-stop +claim; after binding, the existing child process owner controls all settlement. +""" + +from __future__ import annotations + +import os +import socket +import sys +from collections.abc import Mapping, Sequence +from pathlib import Path +from time import monotonic, sleep + +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.bootstrap import ManagedChildBootstrapV1 +from loushang.apphost.managed.contracts import ManagedContractError, _path +from loushang.apphost.managed.invocation import ManagedChildInvocationV1 +from loushang.apphost.managed.paths import resolve_managed_paths +from loushang.hosting.contracts import ( + ProcessLaunchRequest, + ProcessStderrMode, + ProcessStdinMode, + ProcessStdoutMode, + ProcessStreamSpec, +) + +APPLICATION_ID = "coding.default" +ENDPOINT = "workspace" + + +def coding_managed_process_request( + invocation: ManagedChildInvocationV1, descriptor: int, *, executable: str, + environment: Mapping[str, str], session_root: Path | None = None, +) -> ProcessLaunchRequest: + """Pure launch material for the installed module, with frozen home/runtime.""" + if type(invocation) is not ManagedChildInvocationV1 or invocation.service.product_id != "coding": + raise ManagedContractError() + _descriptor(descriptor) + _path(executable) + selected = session_root if session_root is not None else Path(invocation.namespace.platform_home) / "data/sessions" + if not isinstance(selected, Path): + raise ManagedContractError() + _path(str(selected)) + effective = dict(environment) + effective["LOUSHANG_HOME"] = invocation.namespace.platform_home + effective["LOUSHANG_RUNTIME_DIR"] = invocation.runtime_root + effective["LOUSHANG_TMPDIR"] = _temporary(invocation) + return ProcessLaunchRequest( + (executable, "-m", "loushang.coding.managed_process", invocation.to_json(), str(selected), str(descriptor)), + invocation.service.workspace, tuple(effective.items()), + ProcessStreamSpec(ProcessStdinMode.CLOSED, ProcessStdoutMode.DISCARD, ProcessStderrMode.DISCARD), + ) + + +def _descriptor(value: int) -> None: + if type(value) is not int or not 3 <= value < 2**31: + raise ManagedContractError() + + +def _temporary(invocation: ManagedChildInvocationV1) -> str: + return str(resolve_managed_paths( + invocation.namespace, invocation.service, invocation.instance, + runtime_root=invocation.runtime_root, temporary_override=invocation.temporary_override, + ).temporary) + + +def _run(arguments: Sequence[str]) -> int: + if len(arguments) != 3: + raise ManagedContractError() + invocation = ManagedChildInvocationV1.from_json(arguments[0]) + if invocation.service.product_id != "coding": + raise ManagedContractError() + _path(arguments[1]) + if not arguments[2].isascii() or not arguments[2].isdecimal() or len(arguments[2]) > 10: + raise ManagedContractError() + descriptor = int(arguments[2]) + _descriptor(descriptor) + # Invocation identity wins over mutable inherited environment. Product + # configuration is consumed only after this dedicated process is bound. + os.environ["LOUSHANG_HOME"] = invocation.namespace.platform_home + os.environ["LOUSHANG_RUNTIME_DIR"] = invocation.runtime_root + os.environ["LOUSHANG_TMPDIR"] = _temporary(invocation) + endpoint = socket.socket(fileno=descriptor) + bootstrap: ManagedChildBootstrapV1 | None = None + try: + # The frontend imports this module only to construct launch material. + # Load the backend in its actual child, with endpoint cleanup already + # owned and before the native admission budget begins. + from .managed_local import ( + CodingManagedLocalCommandV1, + create_coding_managed_local_launch, + ) + + bootstrap = ManagedChildBootstrapV1( + invocation.namespace, invocation.service, invocation.instance, + invocation.attempt_id, endpoint, runtime_root=invocation.runtime_root, + temporary_override=invocation.temporary_override, + diagnostics=True, + ) + deadline = monotonic() + 15 + while True: + try: + bootstrap.open(deadline=deadline) + break + except ManagedStorageError as error: + if error.code != "busy" or monotonic() >= deadline: + raise + sleep(0.01) # Same admission and frozen budget, never a new owner. + launch = create_coding_managed_local_launch( + invocation, session_root=Path(arguments[1]), application_id=APPLICATION_ID, + endpoint=ENDPOINT, session_discovery=True, + store_state_root=Path(invocation.namespace.platform_home) / "state/session-stores", + managed_mux=bootstrap.managed_mux_binding(application_id=APPLICATION_ID), + ) + capture_factory = bootstrap.output_capture_factory(deadline=deadline) + trace = None + if invocation.trace_deadline_ms is not None: + trace_deadline = invocation.trace_deadline_ms / 1000 + if monotonic() < trace_deadline: + try: + trace = bootstrap.prepare_trace(deadline=trace_deadline) + except ManagedStorageError as error: + if error.code != "busy" or monotonic() < trace_deadline: + raise + bootstrap.bind(CodingManagedLocalCommandV1(launch, output_capture_factory=capture_factory)) + if trace is not None: + # Dedicated process-wide composition, never a per-Session context. + # The child settles native work before this borrowed sink is removed. + from loushang.foundation.observability.runtime import ( + observability_runtime_context, + ) + + with observability_runtime_context( + session_id=None, cwd=invocation.service.workspace, mode="managed", + trace_sink=trace, trace_scopes=frozenset({"turn.start.performance", "host"}), + ): + bootstrap.trace_sink_installed(trace) + return bootstrap.run_process() + return bootstrap.run_process() + finally: + if bootstrap is not None: + bootstrap.close() + else: + endpoint.close() + + +def main(argv: Sequence[str] | None = None) -> int: + """Dedicated-process entry; never expose untrusted argument/exception text.""" + try: + return _run(sys.argv[1:] if argv is None else argv) + except Exception: + print("managed_process_failed", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) + + +__all__ = ["coding_managed_process_request"] diff --git a/tests/apphost/test_local.py b/tests/apphost/test_local.py index 2ebbaf676..56a7d2b49 100644 --- a/tests/apphost/test_local.py +++ b/tests/apphost/test_local.py @@ -28,6 +28,20 @@ SCOPES = (LocalRecordScopeV1(SessionScopeV1.CWD, "a" * 64),) +@pytest.mark.parametrize("binding_instance,connection_instance", ((None, "a" * 32), ("a" * 32, None), ("a" * 32, "b" * 32))) +def test_managed_listener_rejects_unbound_or_wrong_instance_before_io(monkeypatch, binding_instance, connection_instance): + from types import SimpleNamespace + + from loushang.apphost import local as module + + monkeypatch.setattr(module, "LocalAppServerV1", lambda *a, **k: pytest.fail("invalid activation constructed listener")) + with pytest.raises(ValueError, match="admitted application instance"): + HostedLocalRuntimeV1( + SimpleNamespace(managed_mux_instance=binding_instance), object(), "workspace", scopes=SCOPES, + mux_management=True, connection_instance=connection_instance, + ) + + class _Application: application_id = "coding.default" product_id = "coding" diff --git a/tests/coding/_managed_close_probe.py b/tests/coding/_managed_close_probe.py new file mode 100644 index 000000000..2382fb256 --- /dev/null +++ b/tests/coding/_managed_close_probe.py @@ -0,0 +1,40 @@ +"""Bounded test-only failure probe around the actual managed process entry. + +Only exception type/code and stack locations are retained, never request data, +locals, tokens or model configuration. No application lifecycle is replaced. +""" + +from __future__ import annotations + +import json +import os +import sys +import traceback + +from loushang.apphost.managed.mux_management import _ManagedMuxFence +from loushang.appservice.runtime import AppServiceV1 +from loushang.coding.managed_process import main + + +def _observe(label, operation): + async def observed(*args, **kwargs): + try: + return await operation(*args, **kwargs) + except Exception as error: + record = {"stage": label, "error": type(error).__name__, "code": getattr(error, "code", None), + "stack": [(os.path.basename(frame.filename), frame.name, frame.lineno) + for frame in traceback.extract_tb(error.__traceback__)[-12:]]} + descriptor = os.open(sys.argv[4], os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW, 0o600) + try: + if os.fstat(descriptor).st_size < 4096: + os.write(descriptor, (json.dumps(record) + "\n").encode()[:2048]) + finally: + os.close(descriptor) + raise + return observed + + +if __name__ == "__main__": + _ManagedMuxFence._acquire_permission = _observe("native_admission", _ManagedMuxFence._acquire_permission) + AppServiceV1.create_managed_mux = _observe("create", AppServiceV1.create_managed_mux) + raise SystemExit(main(sys.argv[1:4])) diff --git a/tests/coding/_managed_product_child.py b/tests/coding/_managed_product_child.py new file mode 100644 index 000000000..173a7f5da --- /dev/null +++ b/tests/coding/_managed_product_child.py @@ -0,0 +1,164 @@ +"""Test-only detached Product composition, not an installed daemon entry point. + +The separate test socket controls model release and emergency graceful teardown; +it is created here, never added to the production launcher's inherited fd set. +""" + +from __future__ import annotations + +import asyncio +import faulthandler +import os +import signal +import socket +import sys +import threading +import traceback +from pathlib import Path +from time import monotonic, sleep +from typing import TextIO + +from _hosted_product_child import scripted_stream + +from loushang.agent import synthetic_model_transport +from loushang.ai.model import Capabilities, Model +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.bootstrap import ManagedChildBootstrapV1 +from loushang.apphost.managed.invocation import ManagedChildInvocationV1 +from loushang.coding.hosted_local import CodingLocalCommandV1 +from loushang.coding.managed_bootstrap import create_coding_managed_launch +from loushang.coding.managed_local import ( + CodingManagedLocalCommandV1, + create_coding_managed_local_launch, +) + + +def run(root: Path, endpoint: socket.socket, bootstrap: ManagedChildBootstrapV1, + invocation: ManagedChildInvocationV1, diagnostic: TextIO) -> None: + def mark(stage): + print(stage, file=diagnostic, flush=True) + + endpoint.settimeout(0.1) + write_lock = threading.Lock() + + def send(data): + with write_lock: + endpoint.sendall(data) + + mark("bootstrap_opened") + send(b"B") # Test observation only; not birth/commit authority. + release, finished = threading.Event(), threading.Event() + failures = [] + calls = 0 + + @synthetic_model_transport + async def held_stream(model, context, options=None): + nonlocal calls + calls += 1 + mark("model_entered") + await asyncio.to_thread(send, b"E") + while not release.is_set(): + await asyncio.sleep(0.01) + return await scripted_stream(model, context, options) + + model = Model(id="faux-model", name="Faux", provider="faux", endpoint="anthropic-messages", + capabilities=Capabilities(input=("text",), context_window=128000, max_tokens=4096)) + if sys.argv[3] == "canonical": + canonical = create_coding_managed_local_launch( + invocation, application_id="coding.default", endpoint="workspace", + session_root=root / "sessions", session_discovery=True, + ) + application = CodingManagedLocalCommandV1(canonical, model=model, stream_fn=held_stream, tools=[]) + else: + launch = create_coding_managed_launch( + invocation, application_id="coding.default", endpoint="workspace", + cwd_sessions=root / "cwd-sessions", home_sessions=root / "home-sessions", + ) + application = CodingLocalCommandV1(launch, model=model, stream_fn=held_stream, tools=[]) + owner = bootstrap.bind(application) + + def test_control(): + try: + while not owner.accepting: + if finished.wait(0.005): + return + send(b"R") + mark("accepting") + while not finished.is_set(): + try: + command = endpoint.recv(1) + except TimeoutError: + continue + if not command: + if not finished.is_set(): + os.kill(os.getpid(), signal.SIGTERM) # Test fallback, explicit self-stop. + return + if command == b"G": + release.set() + elif command == b"C": + send(str(calls).encode() + b"\n") + else: + raise AssertionError("invalid test command") + except BaseException as error: + failures.append(error) + if not finished.is_set(): + os.kill(os.getpid(), signal.SIGTERM) + + controller = threading.Thread(target=test_control, name="managed-test-control") + controller.start() + try: + status = bootstrap.run_process() # Owns the actual main-thread loop and signal policy. + finished.set() + controller.join(2) + assert not controller.is_alive() + if failures: + raise failures[0] + assert status == 0 and not bootstrap.cleanup_pending + mark("all_owners_closed") + send(b"D") # Production owners and the test controller have all settled. + finally: + finished.set() + release.set() + controller.join(2) + endpoint.close() + + +if __name__ == "__main__": + # A test watchdog exit is failure, never evidence of graceful cleanup. + root = Path(sys.argv[1]) + fd = os.open(root / "child-diagnostic.log", os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "w") as diagnostic: + faulthandler.dump_traceback_later(55, exit=True, file=diagnostic) + bootstrap = None + try: + os.environ["LOUSHANG_HOME"] = str(root / "platform") + os.environ["LOUSHANG_RUNTIME_DIR"] = str(root / "runtime") + invocation = ManagedChildInvocationV1.from_json(sys.argv[4]) + assert invocation.service.product_id == "coding" and invocation.service.workspace == str(root) + bootstrap = ManagedChildBootstrapV1( + invocation.namespace, invocation.service, invocation.instance, invocation.attempt_id, + socket.socket(fileno=int(sys.argv[5])), runtime_root=invocation.runtime_root, + ) + deadline = monotonic() + 15 + while True: + try: + bootstrap.open(deadline=deadline) + break + except ManagedStorageError as error: + if error.code != "busy" or monotonic() >= deadline: + raise + sleep(0.01) + endpoint = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + endpoint.settimeout(10) + endpoint.connect("\0" + sys.argv[2]) + run(root, endpoint, bootstrap, invocation, diagnostic) + except BaseException: + traceback.print_exc(file=diagnostic) + diagnostic.flush() + raise + finally: + try: + if bootstrap is not None: + bootstrap.close() + finally: + faulthandler.cancel_dump_traceback_later() diff --git a/tests/coding/_managed_starter.py b/tests/coding/_managed_starter.py new file mode 100644 index 000000000..a77f7aab7 --- /dev/null +++ b/tests/coding/_managed_starter.py @@ -0,0 +1,76 @@ +"""Test starter using the production Linux launcher and canonical managed layout.""" + +from __future__ import annotations + +import json +import os +import socket +import sys +from pathlib import Path +from time import monotonic, sleep + +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.contracts import ( + ManagedInstanceRefV1, + ManagedNamespaceV1, + ManagedServiceKeyV1, +) +from loushang.apphost.managed.invocation import ManagedChildInvocationV1 +from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 +from loushang.apphost.managed.paths import resolve_managed_paths +from loushang.apphost.managed.registry import ManagedRegistryV1 +from loushang.hosting.contracts import ( + ProcessLaunchRequest, + ProcessStderrMode, + ProcessStdinMode, + ProcessStdoutMode, + ProcessStreamSpec, +) +from loushang.hosting.service_process import LinuxServiceProcessV1 + +root = Path(sys.argv[2]) +namespace = ManagedNamespaceV1(str(root / "platform"), os.geteuid(), "a" * 32) +service = ManagedServiceKeyV1("coding", str(root)) +reference = ManagedInstanceRefV1(namespace.namespace_key, service.service_id, "d" * 32) +paths = resolve_managed_paths(namespace, service, reference, runtime_root=str(root / "runtime")) +registry = ManagedRegistryV1(Path(paths.registry), namespace) +journal = ManagedServiceJournalV1(registry, namespace, service, Path(paths.lifecycle)) +state = journal.read() +parent, child = socket.socketpair() +invocation = ManagedChildInvocationV1(namespace, service, state.handoff.instance, + state.handoff.attempt_id, str(root / "runtime")) +command = json.loads(sys.argv[1]) + [invocation.to_json(), str(child.fileno())] +request = ProcessLaunchRequest(tuple(command), str(root), tuple(os.environ.items()), + ProcessStreamSpec(ProcessStdinMode.CLOSED, ProcessStdoutMode.DISCARD, ProcessStderrMode.DISCARD)) +owner = LinuxServiceProcessV1(request, child) +identity = owner.spawn() +print(identity.pid, flush=True) # Retain exact child observation even if registration fails. +late_birth = sys.argv[4] == "late" +if late_birth: + assert sys.stdin.buffer.read(1) == b"R" +deadline = monotonic() + 5 +busy_reported = False +while True: + try: + observed = journal.read(deadline=deadline) + if (observed is None or observed.handoff.instance != state.handoff.instance + or observed.handoff.attempt_id != state.handoff.attempt_id): + raise ManagedStorageError("conflict") + if observed.native_identity == identity: + break + journal.register_native(state.handoff.instance, state.handoff.attempt_id, identity, deadline=deadline) + break + except ManagedStorageError as error: + if error.code != "busy" or monotonic() >= deadline: + raise + if late_birth and not busy_reported: + print("registration_busy", flush=True) # Test-only proof that the competing admission was attempted. + busy_reported = True + sleep(0.01) # Exact durable reobservation; never replay native spawn. +sys.stdin.buffer.read(1) +if sys.argv[3] == "abrupt": + os._exit(0) +owner.close() +parent.close() +journal.close() +registry.close() diff --git a/tests/coding/test_hosted_owned_catalog.py b/tests/coding/test_hosted_owned_catalog.py new file mode 100644 index 000000000..c697b4451 --- /dev/null +++ b/tests/coding/test_hosted_owned_catalog.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import asyncio +import subprocess +import sys + +import pytest + +from loushang.coding import hosted_catalog as module +from loushang.coding.hosted_catalog import ( + CodingHostedCatalogError, + CodingHostedSessionCatalogV1, +) +from loushang.harness.transcript.writer_lease import TranscriptWriterError + +from .test_hosted_catalog import _intent, _scope + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux owned hosted transcripts") + + +def catalog(tmp_path): + scope = _scope(tmp_path) + scope.session_dir.mkdir(mode=0o700, exist_ok=True) + return CodingHostedSessionCatalogV1((scope,), owned_transcripts=True), scope + + +def test_owned_catalog_real_create_busy_resume_and_close_preserves_delivered_candidate(tmp_path): + async def scenario(): + first, scope = catalog(tmp_path) + second, _ = catalog(tmp_path) + candidate = await first.create_candidate(_intent(scope)) + manager = candidate._binding.manager_for_construction() + assert manager._lifecycle_session._writer_owner is not None + await first.close() + assert not manager._lifecycle_session._writer_owner.closing + with pytest.raises(TranscriptWriterError, match="busy"): + await second.find_created_candidate(_intent(scope).request) + assert not second._owned_factory.pending_preparations + with pytest.raises(CodingHostedCatalogError): + await manager.fork("unused") + await candidate.close() + resumed = await second.find_created_candidate(_intent(scope).request) + assert resumed is not None and resumed.projection.envelope == candidate.projection.envelope + await resumed.close() + await second.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("stage", ["manager", "candidate"]) +def test_owned_catalog_retains_raw_session_when_wrapper_and_cleanup_fail(tmp_path, monkeypatch, stage): + async def scenario(): + owner, scope = catalog(tmp_path) + runtime = module.CODING_TRANSCRIPT_RUNTIME + + def fail(*args, **kwargs): + raise ValueError("test wrapper failed") + + async def failed_cleanup(binding): + raise OSError("test cleanup unavailable") + + with monkeypatch.context() as patch: + patch.setattr(module, "_OwnedHostedTranscript" if stage == "manager" else "_Candidate", fail) + patch.setattr(runtime._binder, "dispose", failed_cleanup) + with pytest.raises(ValueError, match="wrapper failed") as failure: + await owner.create_candidate(_intent(scope)) + retained, = owner.pending_sessions + assert "cleanup retained" in failure.value.__notes__[0] + assert not owner._owned_factory.pending_preparations + with pytest.raises(OSError, match="cleanup unavailable"): + await owner.close() + assert owner.pending_sessions == (retained,) + assert owner._owned_factory._closing + await owner.close() + assert not owner.pending_sessions + fresh, _ = catalog(tmp_path) + candidate = await fresh.find_created_candidate(_intent(scope).request) + await candidate.close() + await fresh.close() + + asyncio.run(scenario()) + + +def test_owned_catalog_close_fences_inflight_factory_before_waiting_lock(tmp_path, monkeypatch): + async def scenario(): + owner, scope = catalog(tmp_path) + lifecycle = owner._owned_factory._lifecycle + bind = lifecycle._bind_runtime_owned + entered, release = asyncio.Event(), asyncio.Event() + + async def delayed(*args): + result = await bind(*args) + entered.set() + await release.wait() + return result + + monkeypatch.setattr(lifecycle, "_bind_runtime_owned", delayed) + caller = asyncio.create_task(owner.create_candidate(_intent(scope))) + closing = None + try: + await asyncio.wait_for(entered.wait(), 5) + preparation, = owner._owned_factory.pending_preparations + closing = asyncio.create_task(owner.close()) + await asyncio.sleep(0) + assert owner._closing and preparation.closing + assert not closing.done() + release.set() + with pytest.raises((RuntimeError, TranscriptWriterError)): + await caller + await closing + assert not owner.pending_sessions and not owner._owned_factory.pending_preparations + finally: + release.set() + await asyncio.gather(caller, *([closing] if closing else []), return_exceptions=True) + await owner.close() + + asyncio.run(scenario()) + + +def test_owned_catalog_does_not_publish_pathname_index_on_disposal(tmp_path, monkeypatch): + async def scenario(): + owner, scope = catalog(tmp_path) + candidate = await owner.create_candidate(_intent(scope)) + + def forbidden(*args, **kwargs): + raise AssertionError("owned wrapper used pathname aggregate index") + + from loushang.harness.transcript import product_session + monkeypatch.setattr(product_session, "AgentTranscriptSessionCatalog", forbidden) + manager = candidate._binding.manager_for_construction() + await manager.publish_index_summary() + await candidate.close() + await owner.close() + + asyncio.run(scenario()) + + +def test_owned_catalog_requires_prepared_safe_session_root(tmp_path): + async def scenario(): + scope = _scope(tmp_path) + owner = CodingHostedSessionCatalogV1((scope,), owned_transcripts=True) + with pytest.raises(TranscriptWriterError): + await owner.create_candidate(_intent(scope)) + assert not scope.session_dir.exists() + await owner.close() + + asyncio.run(scenario()) + + +def test_owned_catalog_real_process_busy_then_reopen(tmp_path): + script = """ +import asyncio, sys +from pathlib import Path +from loushang.apphost import SessionCreateRequestV1 +from loushang.appserver.protocol import SessionScopeV1 +from loushang.coding.hosted_catalog import CodingHostedScopeV1, CodingHostedSessionCatalogV1 +from loushang.harness.transcript.writer_lease import TranscriptWriterError +async def main(): + root = Path(sys.argv[1]) + scope = CodingHostedScopeV1(SessionScopeV1.CWD, root / 'cwd', root) + owner = CodingHostedSessionCatalogV1((scope,), owned_transcripts=True) + try: + try: + candidate = await owner.find_created_candidate(SessionCreateRequestV1( + 'coding', scope.fingerprint, 'a' * 32, + requested_continuity_id='continuity-1', requested_scope=scope.discovery_scope)) + except TranscriptWriterError as error: + assert 'busy' in str(error) + print('busy') + else: + assert candidate is not None + await candidate.close() + print('opened') + finally: + await owner.close() +asyncio.run(main()) +""" + + async def scenario(): + owner, scope = catalog(tmp_path) + candidate = await owner.create_candidate(_intent(scope)) + + def child(): + result = subprocess.run( + [sys.executable, "-c", script, str(tmp_path)], capture_output=True, text=True, timeout=20, + ) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + try: + assert await asyncio.to_thread(child) == "busy" + assert not candidate._binding.manager_for_construction()._lifecycle_session._writer_owner.closing + finally: + await candidate.close() + await owner.close() + assert await asyncio.to_thread(child) == "opened" + + asyncio.run(scenario()) diff --git a/tests/coding/test_hosted_owned_shutdown.py b/tests/coding/test_hosted_owned_shutdown.py new file mode 100644 index 000000000..4059f8882 --- /dev/null +++ b/tests/coding/test_hosted_owned_shutdown.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import asyncio +import sys + +import pytest + +from loushang.appserver.protocol import MuxCreateV1, MuxSelectorV1, SessionOpenSpecV1 +from loushang.appservice import ApplicationContinuityError +from loushang.coding.hosted_application import CodingForegroundProductFactoryV1 +from loushang.coding.hosted_bootstrap import create_coding_hosted_attempt +from loushang.harnesstui.mux import open_hosted_mux_profile + +from ._hosted_product_child import scripted_stream +from .test_hosted_bootstrap import _launch +from .test_hosted_local import _model + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux owned hosted transcripts") + + +def test_real_owned_hosted_shutdown_and_continuity_reopen(tmp_path, monkeypatch): + async def scenario(): + monkeypatch.setenv("LOUSHANG_HOME", str(tmp_path / "platform")) + monkeypatch.setenv("LOUSHANG_RUNTIME_DIR", str(tmp_path / "runtime")) + launch = _launch(tmp_path) + launch.cwd_sessions.mkdir(mode=0o700) + launch.home_sessions.mkdir(mode=0o700) + attempt = create_coding_hosted_attempt( + launch, owned_transcripts=True, model=_model(), stream_fn=scripted_stream, tools=[], + ) + catalog = attempt._request.foreground.sessions + assert attempt._request.foreground.session_owner is catalog + runtime = await attempt.open() + controller = None + try: + await runtime.client.create_mux(MuxCreateV1("dev")) + controller = await open_hosted_mux_profile(runtime.client, selector=MuxSelectorV1(name="dev")) + scope = launch.scopes[0] + state = await controller.open_member(SessionOpenSpecV1( + "coding", "continuity-owned", scope.scope, scope.fingerprint, title="owned", + )) + identity = state.windows[0].session_id + await controller.submit("hello owned") + await controller.poll() + await controller.close() + controller = None + finally: + if controller is not None: + await controller.close() + assert (await runtime.shutdown()).completed + await attempt.close() + assert catalog._closing and catalog._owned_factory._closing + assert not catalog.pending_sessions and not catalog._owned_factory.pending_preparations + fresh = create_coding_hosted_attempt( + launch, owned_transcripts=True, model=_model(), stream_fn=scripted_stream, tools=[], + ) + resumed = await fresh.open() + try: + view = await open_hosted_mux_profile(resumed.client, selector=MuxSelectorV1(name="dev")) + assert view.state.windows[0].session_id == identity + await view.close() + finally: + assert (await resumed.shutdown()).completed + await fresh.close() + + asyncio.run(scenario()) + + +def test_unopened_attempt_does_close_explicit_catalog_without_creating_roots(tmp_path): + async def scenario(): + attempt = create_coding_hosted_attempt(_launch(tmp_path), owned_transcripts=True) + owner = attempt._request.foreground.session_owner + assert not tuple(tmp_path.iterdir()) + await attempt.close() + assert owner._closing and owner._owned_factory._closing + assert not attempt.cleanup_pending and not tuple(tmp_path.iterdir()) + + asyncio.run(scenario()) + + +def test_catalog_cleanup_failure_prevents_continuity_lease_release_until_retry(tmp_path, monkeypatch): + async def scenario(): + attempt = create_coding_hosted_attempt(_launch(tmp_path), owned_transcripts=True) + catalog = attempt._request.foreground.session_owner + runtime = await attempt.open() + close = catalog.close + calls = [] + + async def unavailable(): + calls.append(True) + raise OSError("catalog cleanup unavailable") + + with monkeypatch.context() as patch: + patch.setattr(catalog, "close", unavailable) + assert not (await runtime.shutdown()).completed + assert catalog._closing and calls == [True] + with pytest.raises(ApplicationContinuityError): + await attempt._request.store.acquire(application_id="coding.default", owner_epoch="contender") + assert catalog.close == close + assert (await runtime.shutdown()).completed + lease = await attempt._request.store.acquire(application_id="coding.default", owner_epoch="after-cleanup") + await lease.close() + await attempt.close() + + asyncio.run(scenario()) + + +def test_product_failure_still_attempts_independent_catalog_cleanup(): + async def scenario(): + calls = [] + + class SessionFactory: + async def create_session(self, **kwargs): + raise AssertionError("not constructing") + + class CatalogOwner: + def fence(self): + calls.append("fence") + raise ValueError("fence failure") + + async def close(self): + calls.append("catalog") + + class ProductFactory(CodingForegroundProductFactoryV1): + async def settle_pending_cleanup(self): + calls.append("product") + raise OSError("product cleanup failure") + + product = ProductFactory(SessionFactory(), session_owner=CatalogOwner()) + with pytest.raises(ValueError, match="fence failure"): + await product.close() + assert calls == ["fence", "product", "catalog"] + + asyncio.run(scenario()) diff --git a/tests/coding/test_managed_application.py b/tests/coding/test_managed_application.py new file mode 100644 index 000000000..dfe184752 --- /dev/null +++ b/tests/coding/test_managed_application.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import asyncio +import sys +from dataclasses import replace + +import pytest + +from loushang.agent import synthetic_model_transport +from loushang.appserver.protocol import ( + AppServiceError, + MuxCreateV1, + MuxSelectorV1, + SessionOpenSpecV1, +) +from loushang.appservice.execution_service import HostedExecutionServiceBindingV1 +from loushang.appservice.managed_mux import ManagedMuxServiceBindingV1 +from loushang.coding.hosted_application import ( + CODING_HOSTED_APPLICATION_PROFILE_ID, + CodingAppHostHostedSessionResolverV1, +) +from loushang.coding.hosted_session import CodingRealHostedSessionFactoryV1 +from loushang.coding.managed_bootstrap import ( + CodingManagedApplicationLaunchV1, + create_coding_managed_attempt, +) +from loushang.harness.transcript.writer_lease import TranscriptWriterError +from loushang.harnesstui.mux import open_hosted_mux_profile + +from ._hosted_product_child import scripted_stream +from .test_hosted_catalog import _intent +from .test_hosted_local import _model +from .test_managed_catalog import catalog, ordinary, tree + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed application") + + +def launch(root): + return CodingManagedApplicationLaunchV1(root, root / "application", "coding.managed", root / "sessions") + + +def attempt(root): + return create_coding_managed_attempt( + launch(root), model=_model(), stream_fn=scripted_stream, tools=[], session_discovery=True, + ) + + +def test_noncontinuity_managed_request_rejects_before_admission_or_catalog_cleanup(tmp_path, monkeypatch): + from loushang.coding import hosted_application as module + + async def scenario(): + owner = attempt(tmp_path) + request = replace(owner._request.foreground, managed_mux=ManagedMuxServiceBindingV1( + "coding.managed", "a" * 64, "b" * 32, lambda _: pytest.fail("unexpected permission acquisition"), + )) + effects = [] + + def forbidden(*args, **kwargs): + effects.append(1) + raise AssertionError("invalid entry acquired or fenced dependencies") + + with monkeypatch.context() as selected: + selected.setattr(module, "CodingForegroundProductFactoryV1", forbidden) + selected.setattr(module.AppHostCatalogV1, "admit", forbidden) + selected.setattr(type(request.sessions), "fence", forbidden) + selected.setattr(type(request.sessions), "close", forbidden) + with pytest.raises(ValueError, match="continuity"): + await module.create_coding_foreground_hosted_application(request) + assert not effects + await owner.close() + + asyncio.run(scenario()) + + +def test_managed_continuity_application_identity_rejects_before_store_acquisition(tmp_path): + async def scenario(): + owner = attempt(tmp_path) + try: + foreground = replace(owner._request.foreground, managed_mux=ManagedMuxServiceBindingV1( + "coding.other", "a" * 64, "b" * 32, lambda _: pytest.fail("unexpected admission"), + )) + before = tree(tmp_path) + with pytest.raises(ValueError, match="identity mismatch"): + replace(owner._request, foreground=foreground) + assert tree(tmp_path) == before + finally: + await owner.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("history", ["ordinary", "v1_cwd", "v1_home"]) +def test_managed_real_product_scope_projection_reconnect_and_continuity(tmp_path, monkeypatch, history): + async def scenario(): + monkeypatch.setenv("LOUSHANG_HOME", str(tmp_path / "platform")) + monkeypatch.setenv("LOUSHANG_RUNTIME_DIR", str(tmp_path / "runtime")) + source = catalog(tmp_path) + if history == "ordinary": + path = await ordinary(source, image=True) + selected = source.scopes[0] + else: + original_scope = source.scopes[0 if history == "v1_cwd" else 1] + created = await source.create_candidate(_intent(original_scope)) + path = created._binding.record.path + await created.close() + selected = source.scopes[1 if history == "v1_cwd" else 0] + rows = await source.list_identities((selected.discovery_scope,), limit=256) + envelope = rows[0].envelope + original = path.read_bytes() + await source.close() + entered, release = asyncio.Event(), asyncio.Event() + + @synthetic_model_transport + async def gated_stream(*args, **kwargs): + entered.set() + await release.wait() + return await scripted_stream(*args, **kwargs) + + create = CodingRealHostedSessionFactoryV1.create_session + products = [] + + async def observe(self, **kwargs): + result = await create(self, **kwargs) + products.append(result) + return result + + # Production freezes static native methods during attempt construction. + monkeypatch.setattr(CodingRealHostedSessionFactoryV1, "create_session", observe) + owner = create_coding_managed_attempt( + launch(tmp_path), model=_model(), stream_fn=gated_stream, tools=[], session_discovery=True, + ) + runtime = await owner.open() + controller = None + submitting = None + spec = SessionOpenSpecV1( + "coding", envelope.continuity_id, selected.scope, selected.fingerprint, + session_id=envelope.session_id, title="shared history", + ) + try: + await runtime.client.create_mux(MuxCreateV1("dev")) + controller = await open_hosted_mux_profile(runtime.client, selector=MuxSelectorV1(name="dev")) + state = await controller.open_member(spec) + assert len(state.windows) == 1 and state.windows[0].session_id == envelope.session_id + assert len(products) == 1 + assert products[0].identity.scope is not selected.scope + assert products[0].identity.continuity_id == envelope.continuity_id + assert path.read_bytes() == original + # Reattaching an existing Mux does not invoke a second factory. + await controller.close() + controller = await open_hosted_mux_profile(runtime.client, selector=MuxSelectorV1(name="dev")) + assert len(controller.state.windows) == 1 and len(products) == 1 + submitting = asyncio.create_task(controller.submit("accepted before duplicate")) + await asyncio.wait_for(entered.wait(), 10) + other_scope = owner._request.foreground.sessions.scopes[1 if selected.scope.value == "cwd" else 0] + with pytest.raises(AppServiceError): + await controller.open_member(replace(spec, scope=other_scope.scope, scope_fingerprint=other_scope.fingerprint)) + assert len(controller.state.windows) == 1 and len(products) == 1 + contender = catalog(tmp_path) + current = await contender.list_identities((selected.discovery_scope,), limit=256) + with pytest.raises(TranscriptWriterError, match="busy"): + await contender.open_candidate(current[0].reference) + await contender.close() + release.set() + await asyncio.wait_for(submitting, 10) + await asyncio.wait_for(products[0].control.wait_for_idle(), 10) + assert sum(message.role == "assistant" for message in products[0].control.messages) == 1 + assert products[0].control.messages[-1].content[0].text == "真实跨进程回复\nG14" + await controller.submit("hello after duplicate rejection") + await asyncio.wait_for(products[0].control.wait_for_idle(), 10) + await controller.poll() + assert sum(message.role == "assistant" for message in products[0].control.messages) == 2 + assert path.read_bytes().startswith(original) + finally: + release.set() + if submitting is not None: + await asyncio.wait_for(asyncio.gather(submitting, return_exceptions=True), 10) + if controller is not None: + await controller.close() + assert (await runtime.shutdown()).completed + await owner.close() + fresh = attempt(tmp_path) + restored = await fresh.open() + try: + view = await open_hosted_mux_profile(restored.client, selector=MuxSelectorV1(name="dev")) + assert len(view.state.windows) == 1 and view.state.windows[0].session_id == envelope.session_id + assert len(products) == 2 + await view.submit("continue after application restart") + await asyncio.wait_for(products[1].control.wait_for_idle(), 10) + assert sum(message.role == "assistant" for message in products[1].control.messages) == 3 + assert products[1].control.messages[-1].content[0].text == "真实跨进程回复\nG14" + await view.close() + finally: + assert (await restored.shutdown()).completed + await fresh.close() + + asyncio.run(scenario()) + + +def test_unadmitted_scope_rejected_before_factory_or_session_writes(tmp_path, monkeypatch): + async def scenario(): + monkeypatch.setenv("LOUSHANG_HOME", str(tmp_path / "platform")) + owner = attempt(tmp_path) + runtime = await owner.open() + view = None + try: + await runtime.client.create_mux(MuxCreateV1("dev")) + view = await open_hosted_mux_profile(runtime.client, selector=MuxSelectorV1(name="dev")) + scope = owner._request.foreground.sessions.scopes[0] + before = tree(tmp_path) + foreground = owner._request.foreground + with pytest.raises(ValueError, match="execution adapter"): + CodingAppHostHostedSessionResolverV1( + runtime=owner._runtime, sessions=foreground.sessions, + profile_id=foreground.profile_id, operation_id_factory=lambda: "unused", + admitted_scopes=foreground.admitted_scopes, managed_selection=True, execution=True, + ) + with pytest.raises(AppServiceError): + await view.open_member(SessionOpenSpecV1( + "coding", "rejected", scope.scope, "f" * 64, title="invalid scope", + )) + assert not view.state.windows and tree(tmp_path) == before + assert not owner._request.foreground.sessions.pending_sessions + finally: + if view is not None: + await view.close() + assert (await runtime.shutdown()).completed + await owner.close() + + asyncio.run(scenario()) + + +def test_managed_activation_requires_exact_catalog_scope_profile_and_owner(tmp_path): + async def scenario(): + owner = attempt(tmp_path) + request = owner._request.foreground + before = tree(tmp_path) + for changes in ( + {"managed_selection": False}, {"managed_selection": 1}, + {"profile_id": CODING_HOSTED_APPLICATION_PROFILE_ID}, + {"admitted_scopes": request.admitted_scopes[:1]}, {"session_owner": None}, + {"sessions": object()}, + {"execution": HostedExecutionServiceBindingV1("app", "instance", lambda port: None)}, + ): + with pytest.raises((TypeError, ValueError)): + replace(request, **changes) + await owner.close() + assert tree(tmp_path) == before and not owner.cleanup_pending + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("target", ["sessions", "session-assets", ".session-blob-writers", ".session-blob-writers/app"]) +def test_managed_application_cannot_overlap_session_data(tmp_path, target): + (tmp_path / target).parent.mkdir(mode=0o700, exist_ok=True) + with pytest.raises(ValueError, match="separate"): + CodingManagedApplicationLaunchV1(tmp_path, tmp_path / target, "coding.managed", tmp_path / "sessions") diff --git a/tests/coding/test_managed_capture_product.py b/tests/coding/test_managed_capture_product.py new file mode 100644 index 000000000..9dd477bbe --- /dev/null +++ b/tests/coding/test_managed_capture_product.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import asyncio +import sys + +import pytest + +from loushang.apphost.managed import storage_budget +from loushang.apphost.managed.output_capture import ManagedOutputCaptureFactory +from loushang.coding import bootstrap +from loushang.coding.bootstrap import create_agent_session +from loushang.coding.runtime.agent_session_runtime import AgentSessionRuntime +from loushang.harness.session import output_artifacts +from loushang.harness.workspace.exec import ExecRequest +from tests.apphost.test_managed_files import directory as directory +from tests.apphost.test_managed_storage_budget import namespace as namespace +from tests.apphost.test_managed_storage_budget import pytestmark as pytestmark +from tests.apphost.test_managed_storage_budget import registry as registry +from tests.apphost.test_native_output_capture import capture + +from .test_agent_session_runtime import _model + + +@pytest.mark.parametrize("scenario", ["text", "binary", "capacity_refused", "overflow"]) +def test_real_coding_session_uses_managed_capture_and_keeps_session_blob(registry, directory, tmp_path, monkeypatch, scenario): + def reject_unmanaged_scratch(*args, **kwargs): + pytest.fail("persistent managed execution must not allocate legacy temporary directories") + + # Capacity refusal/overflow must degrade retention, not fall back to an + # unbudgeted spool. Persistent Product construction needs no ephemeral + # plugin state either. Patch the consumers, not tempfile globally. + monkeypatch.setattr(output_artifacts, "TemporaryDirectory", reject_unmanaged_scratch) + monkeypatch.setattr(bootstrap, "_ExplicitTemporaryDirectory", reject_unmanaged_scratch) + native = capture(registry, directory) + request = native.allocations[0] + factory = ManagedOutputCaptureFactory(native.owner, native.budget, service_id=request.service_id, + instance_id=request.instance_id, capacity=4096) + root = tmp_path / "sessions" + root.mkdir(mode=0o700) + + def build(manager, *, session_start_event): + return create_agent_session(session_manager=manager, model=_model(), no_tools=True, + session_start_event=session_start_event, output_capture_factory=factory) + + runtime = AgentSessionRuntime(session_dir=root, session_factory=build, owned_transcripts=True) + script = "print('captured')" + stdout, stderr, exit_code = b"captured\n", b"", 0 + if scenario == "binary": + script = "import os; os.write(1,b'\\xe4'); os.write(1,b'\\xbd\\xa0\\xff'); os.write(2,b'\\x80tail')" + stdout, stderr, exit_code = "你".encode() + b"\xff", b"\x80tail", 7 + if scenario == "overflow": + script = "import os; os.write(1,b'x'*5000); os.write(2,b'finished')" + stdout, stderr, exit_code = b"x" * 5000, b"finished", 9 + marker = tmp_path / "command-finished" + script += f"; from pathlib import Path; Path({str(marker)!r}).write_text('done'); raise SystemExit({exit_code})" + if scenario == "capacity_refused": + monkeypatch.setattr(storage_budget, "TEMPORARY_INSTANCE_BYTES", 8192) + + async def run(): + held = None + try: + if scenario == "capacity_refused": + held = factory.new_capture() + await held.prepare() + session = await runtime.create_session(cwd=str(tmp_path)) + assert session.session_manager.persist + service = session._exec_service + result = await service.execute(ExecRequest((sys.executable, "-c", script))) + assert result.exit_code == exit_code + assert marker.read_text() == "done" + assert result.stdout.encode("utf-8", errors="surrogateescape") == stdout + assert result.stderr.encode("utf-8", errors="surrogateescape") == stderr + assert result.artifact_cleanup_error is None + manager = session.session_manager + if scenario in {"capacity_refused", "overflow"}: + assert result.stdout_artifact_ref is None and result.stderr_artifact_ref is None + assert result.artifact_retention_error is not None + else: + assert result.stdout_artifact_ref is not None and result.stderr_artifact_ref is not None + with manager._lifecycle_session.sync_operation_scope(): + assert service._store.read_bytes(result.stdout_artifact_ref) == stdout + assert service._store.read_bytes(result.stderr_artifact_ref) == stderr + assert factory._leases and all(lease._closed for lease in factory._leases.values() if lease is not held) + finally: + await runtime.dispose_session_runtime() + await factory.close() + assert not factory.cleanup_pending + + asyncio.run(run()) + assert all(native.budget.lookup(allocation) is None for allocation in native.allocations) diff --git a/tests/coding/test_managed_catalog.py b/tests/coding/test_managed_catalog.py new file mode 100644 index 000000000..fbc5df346 --- /dev/null +++ b/tests/coding/test_managed_catalog.py @@ -0,0 +1,455 @@ +from __future__ import annotations + +import asyncio +import hashlib +import os +import sys +from dataclasses import replace + +import pytest + +from loushang.ai.types import ImagePart, UserMessage +from loushang.appserver.protocol import SessionAvailabilityV1, SessionCompatibilityV1 +from loushang.appservice.discovery_ports import HostedSessionDiscoveryScopeV1 +from loushang.coding.hosted_catalog import ( + CodingHostedCatalogError, + CodingHostedSessionCatalogV1, +) +from loushang.coding.managed_catalog import CodingManagedSessionCatalogV1 +from loushang.coding.session_manager import SessionManager +from loushang.harness.conversation import StoreCommitOutcomeUnknown +from loushang.harness.conversation.stores import file as file_module +from loushang.harness.transcript.jsonl_file import ( + load_agent_transcript_file, + write_agent_transcript_export, +) +from loushang.harness.transcript.writer_lease import TranscriptWriterError + +from .test_hosted_catalog import _intent + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux owned managed catalog") + + +def catalog(tmp_path, *, workspace=None): + root = tmp_path / "sessions" + root.mkdir(mode=0o700, exist_ok=True) + return CodingManagedSessionCatalogV1(session_root=root, workspace=workspace or tmp_path) + + +def tree(root): + # Same complete oracle as test_writer_root_binding; callers include the + # Session root's parent so sibling attachment/index trees are covered. + return {str(path.relative_to(root)): ( + path.lstat().st_mode, path.lstat().st_ino, path.lstat().st_mtime_ns, + path.read_bytes() if path.is_file() else None, + ) for path in (root, *root.rglob("*"))} + + +@pytest.mark.parametrize("grant", [False, True]) +def test_missing_root_discovery_readonly_and_creation_requires_grant(tmp_path, grant): + async def scenario(): + root = tmp_path / "new" / "sessions" + owner = CodingManagedSessionCatalogV1( + session_root=root, workspace=tmp_path, initialize_session_root=grant, + ) + try: + before = tree(tmp_path) + for scope in owner.scopes: + assert not (await discovery(owner, scope)).candidates + assert not await owner.list_identities(tuple(s.discovery_scope for s in owner.scopes), limit=256) + assert tree(tmp_path) == before + if not grant: + with pytest.raises(TranscriptWriterError, match="unavailable"): + await owner.create_candidate(_intent(owner.scopes[0])) + assert tree(tmp_path) == before + else: + active = await owner.create_candidate(_intent(owner.scopes[0])) + try: + assert len(tuple(root.glob("*.jsonl"))) == 1 + assert root.stat().st_mode & 0o777 == 0o700 + assert not owner._root_initialization + finally: + await active.close() + finally: + await owner.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("observe_existing", [False, True]) +def test_missing_old_root_never_gets_a_second_initialization_grant(tmp_path, observe_existing): + async def scenario(): + root = tmp_path / "sessions" + owner = CodingManagedSessionCatalogV1( + session_root=root, workspace=tmp_path, initialize_session_root=True, + ) + active = None + try: + if observe_existing: + root.mkdir(mode=0o700) + await discovery(owner, owner.scopes[0]) + else: + active = await owner.create_candidate(_intent(owner.scopes[0])) + assert not owner._root_initialization + saved = tmp_path / "saved-sessions" + root.rename(saved) + with pytest.raises(TranscriptWriterError, match="unavailable"): + await owner.create_candidate(_intent(owner.scopes[1])) + assert not root.exists() and saved.is_dir() + fresh = CodingManagedSessionCatalogV1(session_root=root, workspace=tmp_path) + try: + with pytest.raises(TranscriptWriterError, match="unavailable"): + await fresh.create_candidate(_intent(fresh.scopes[0])) + assert not root.exists() + finally: + await fresh.close() + finally: + if active is not None: + await active.close() + await owner.close() + + asyncio.run(scenario()) + + +def test_failed_initialization_keeps_sync_owner_without_reissuing_grant(tmp_path, monkeypatch): + from loushang.harness.journal import _directory_lease as directory_module + + async def scenario(): + root = tmp_path / "new" / "sessions" + owner = CodingManagedSessionCatalogV1( + session_root=root, workspace=tmp_path, initialize_session_root=True, + ) + sync = os.fsync + identity = tmp_path.stat() + + def fail_sync(fd): + if os.path.samestat(os.fstat(fd), identity): + raise OSError("sync unavailable") + return sync(fd) + + try: + with monkeypatch.context() as patch: + patch.setattr(directory_module.os, "fsync", fail_sync) + with pytest.raises(TranscriptWriterError, match="unavailable"): + await owner.create_candidate(_intent(owner.scopes[0])) + retained, = owner._owned_factory.pending_preparations + assert retained.cleanup_pending and not owner._root_initialization + assert root.parent.exists() and not root.exists() + with pytest.raises(TranscriptWriterError, match="unavailable"): + await owner.create_candidate(_intent(owner.scopes[0])) + assert owner._owned_factory.pending_preparations == (retained,) + with pytest.raises(TranscriptWriterError, match="unavailable"): + await owner.close() + assert not root.exists() + await owner.close() + assert not retained.cleanup_pending and not root.exists() + assert root.parent.is_dir() + finally: + await owner.close() + + asyncio.run(scenario()) + + +def test_discovery_observation_revokes_grant_even_if_root_disappears_before_return(tmp_path, monkeypatch): + async def scenario(): + root = tmp_path / "sessions" + owner = CodingManagedSessionCatalogV1( + session_root=root, workspace=tmp_path, initialize_session_root=True, + ) + read = owner._read_discovery + + def appear_read_disappear(*args, **kwargs): + root.mkdir(mode=0o700) + result = read(*args, **kwargs) + root.rename(tmp_path / "saved-sessions") + return result + + monkeypatch.setattr(owner, "_read_discovery", appear_read_disappear) + try: + result = await discovery(owner, owner.scopes[0]) + assert result.complete and not result.candidates + assert not root.exists() and not owner._root_initialization + with pytest.raises(TranscriptWriterError, match="unavailable"): + await owner.create_candidate(_intent(owner.scopes[0])) + assert not root.exists() + finally: + await owner.close() + + asyncio.run(scenario()) + + +async def ordinary(owner, *, metadata=None, image=False): + scope = owner.scopes[0] + mask = os.umask(0o077) + try: + manager = await SessionManager.new( + session_dir=scope.session_dir, cwd=str(scope.cwd), + additional_header_metadata=metadata, defer_materialization=False, + ) + try: + await manager.append_message(UserMessage( + role="user", timestamp=1.0, + content=[ImagePart(type="image", data="aGVsbG8=", mime_type="image/png")] if image else "original", + )) + return manager.session_file + finally: + await manager.dispose_runtime_profile() + finally: + os.umask(mask) + + +async def discovery(owner, scope): + return await owner.discover_sessions( + HostedSessionDiscoveryScopeV1("coding", scope.scope, scope.fingerprint), stop=lambda: False, + ) + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_ordinary_history_two_views_same_identity_readonly_and_same_writer(tmp_path, reverse): + async def scenario(): + first, second = catalog(tmp_path), catalog(tmp_path) + path = await ordinary(first, image=True) + original = path.read_bytes() + header, _ = load_agent_transcript_file(path) + expected = hashlib.sha256("\0".join(( + "coding.managed.continuity/v1", "coding", str(path.parent), header.conversation_id, + )).encode()).hexdigest() + scopes = first.scopes[::-1] if reverse else first.scopes + before = tree(tmp_path) + rows = await first.list_identities(tuple(s.discovery_scope for s in scopes), limit=256) + assert len(rows) == 2 and rows[0].envelope == rows[1].envelope + assert rows[0].envelope.continuity_id == expected + assert rows[0].reference != rows[1].reference + for scope in scopes: + snapshot = await discovery(first, scope) + assert snapshot.complete and len(snapshot.candidates) == 1 + assert snapshot.candidates[0].identity.scope is scope.scope + assert tree(tmp_path) == before + active = await first.open_candidate(rows[0].reference) + try: + other_rows = await second.list_identities((rows[1].scope,), limit=256) + with pytest.raises(TranscriptWriterError, match="busy"): + await second.open_candidate(other_rows[0].reference) + manager = active._binding.manager_for_construction() + assert manager.build_session_context().messages[0].content[0].data == "aGVsbG8=" + assert path.read_bytes() == original + finally: + await active.close() + reopened = await second.open_candidate(other_rows[0].reference) + try: + await reopened._binding.manager_for_construction().append_message( + UserMessage(role="user", content="continued", timestamp=2.0) + ) + assert path.read_bytes().startswith(original) + finally: + await reopened.close() + await first.close() + await second.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("created_scope", [0, 1]) +def test_managed_create_preserves_v1_identity_across_views_and_retry(tmp_path, created_scope): + async def scenario(): + first = catalog(tmp_path) + scope = first.scopes[created_scope] + candidate = await first.create_candidate(_intent(scope)) + envelope = candidate.projection.envelope + await candidate.close() + await first.close() + fresh = catalog(tmp_path) + retry = await fresh.find_created_candidate(_intent(scope).request) + assert retry.projection.envelope == envelope + await retry.close() + rows = await fresh.list_identities(tuple(s.discovery_scope for s in fresh.scopes), limit=256) + assert len(rows) == 2 and all(row.envelope == envelope for row in rows) + active = await fresh.open_candidate(rows[1 - created_scope].reference) + assert active._binding.record.identity.scope is scope.scope + await active.close() + assert len(tuple(scope.session_dir.glob("*.jsonl"))) == 1 + await fresh.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("raw", [None, {}, {"version": 2}, {"scope": "invalid"}]) +def test_invalid_hosted_metadata_never_falls_back_to_ordinary(tmp_path, raw): + async def scenario(): + owner = catalog(tmp_path) + await ordinary(owner, metadata={"coding.hosted": raw}) + before = tree(tmp_path) + with pytest.raises(CodingHostedCatalogError): + await owner.list_identities((owner.scopes[1].discovery_scope,), limit=256) + assert tree(tmp_path) == before + await owner.close() + + asyncio.run(scenario()) + + +def test_cross_workspace_is_visible_only_globally_and_cannot_open(tmp_path): + async def scenario(): + source = catalog(tmp_path) + await ordinary(source) + other = tmp_path / "other" + other.mkdir() + owner = catalog(tmp_path, workspace=other) + before = tree(tmp_path) + cwd, home = owner.scopes + assert not (await discovery(owner, cwd)).candidates + snapshot = await discovery(owner, home) + assert snapshot.candidates[0].availability is SessionAvailabilityV1.UNAVAILABLE + rows = await owner.list_identities((home.discovery_scope,), limit=256) + with pytest.raises(CodingHostedCatalogError): + await owner.open_candidate(rows[0].reference) + assert tree(tmp_path) == before and not owner._owned_factory.pending_preparations + await owner.close() + await source.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("hidden_workspace", [False, True]) +def test_different_files_with_same_identity_are_not_deduplicated(tmp_path, hidden_workspace): + async def scenario(): + owner = catalog(tmp_path) + path = await ordinary(owner) + duplicate = path.parent / ("duplicate-" + path.name) + header, records = load_agent_transcript_file(path) + if hidden_workspace: + header = replace(header, metadata={**header.metadata, "cwd": "/another-workspace"}) + write_agent_transcript_export(duplicate, header, records) + for scopes in (owner.scopes, owner.scopes[::-1], (owner.scopes[0],)): + with pytest.raises(CodingHostedCatalogError): + await owner.list_identities(tuple(s.discovery_scope for s in scopes), limit=256) + await owner.close() + + asyncio.run(scenario()) + + +def test_changed_header_after_discovery_is_rejected_without_owned_admission(tmp_path): + async def scenario(): + owner = catalog(tmp_path) + path = await ordinary(owner) + rows = await owner.list_identities((owner.scopes[0].discovery_scope,), limit=256) + header, records = load_agent_transcript_file(path) + write_agent_transcript_export(path, replace(header, metadata={**header.metadata, "cwd": "/elsewhere"}), records) + before = tree(tmp_path) + with pytest.raises(CodingHostedCatalogError): + await owner.open_candidate(rows[0].reference) + assert tree(tmp_path) == before + await owner.close() + + asyncio.run(scenario()) + + +def test_unsupported_runtime_is_observed_but_not_bound(tmp_path, monkeypatch): + async def scenario(): + owner = catalog(tmp_path) + path = await ordinary(owner) + from loushang.coding.product_plan import CODING_TRANSCRIPT_RUNTIME + + header, records = load_agent_transcript_file(path) + key = CODING_TRANSCRIPT_RUNTIME.spec.metadata_key + runtime = dict(header.metadata[key]) + runtime["productId"] = "another-product" + write_agent_transcript_export(path, replace(header, metadata={**header.metadata, key: runtime}), records) + + async def forbidden(*args): + raise AssertionError("unsupported catalog called runtime binder") + + monkeypatch.setattr(owner._owned_factory._lifecycle, "_bind_runtime_owned", forbidden) + before = tree(tmp_path) + scope = owner.scopes[1] + snapshot = await discovery(owner, scope) + assert snapshot.candidates[0].compatibility is SessionCompatibilityV1.UNSUPPORTED + assert snapshot.candidates[0].availability is SessionAvailabilityV1.UNAVAILABLE + rows = await owner.list_identities((scope.discovery_scope,), limit=256) + with pytest.raises(CodingHostedCatalogError): + await owner.open_candidate(rows[0].reference) + assert tree(tmp_path) == before and not owner._owned_factory.pending_preparations + await owner.close() + + asyncio.run(scenario()) + + +def test_old_catalog_still_rejects_same_root_views(tmp_path): + owner = catalog(tmp_path) + with pytest.raises(ValueError, match="distinct Session roots"): + CodingHostedSessionCatalogV1(owner.scopes) + + +def test_committed_create_lost_receipt_recovers_same_canonical_file(tmp_path, monkeypatch): + async def scenario(): + owner = catalog(tmp_path) + scope = owner.scopes[0] + write = file_module._write_unlocked + + def committed_then_failed(*args, **kwargs): + write(*args, **kwargs) + raise OSError("managed create receipt lost") + + with monkeypatch.context() as patch: + patch.setattr(file_module, "_write_unlocked", committed_then_failed) + with pytest.raises(StoreCommitOutcomeUnknown): + await owner.create_candidate(_intent(scope)) + await owner.close() + fresh = catalog(tmp_path) + found = await fresh.find_created_candidate(_intent(scope).request) + assert found is not None + envelope = found.projection.envelope + await found.close() + repeated = await fresh.create_candidate(_intent(scope)) + assert repeated.projection.envelope == envelope + await repeated.close() + assert len(tuple(scope.session_dir.glob("*.jsonl"))) == 1 + await fresh.close() + + asyncio.run(scenario()) + + +def test_post_restore_rejection_retains_original_session_on_cleanup_failure(tmp_path, monkeypatch): + async def scenario(): + owner = catalog(tmp_path) + await ordinary(owner) + scope = owner.scopes[0] + rows = await owner.list_identities((scope.discovery_scope,), limit=256) + restore = owner._owned_factory.restore_context + from loushang.coding import managed_catalog as module + + def unsupported(*args): + raise ValueError("runtime changed after restore") + + async def failed_cleanup(): + raise OSError("retained original Session cleanup") + + retained = [] + with monkeypatch.context() as patch: + async def restore_then_reject(*args): + session = await restore(*args) + retained.append(session) + patch.setattr(module, "_validate_coding_restored_header", unsupported) + patch.setattr(session, "dispose", failed_cleanup) + return session + + patch.setattr(owner._owned_factory, "restore_context", restore_then_reject) + with pytest.raises(CodingHostedCatalogError): + await owner.open_candidate(rows[0].reference) + assert owner.pending_sessions == tuple(retained) + # Use the already-captured canonical record to test actual leases; + # the injected new validator intentionally rejects fresh discovery. + contender = catalog(tmp_path) + with pytest.raises(TranscriptWriterError, match="busy"): + await contender._owned_factory.restore_context(retained[0].context) + await contender.close() + with pytest.raises(OSError, match="retained original"): + await owner.close() + assert owner.pending_sessions == tuple(retained) + await owner.close() + fresh = catalog(tmp_path) + current = await fresh.list_identities((scope.discovery_scope,), limit=256) + opened = await fresh.open_candidate(current[0].reference) + await opened.close() + await fresh.close() + + asyncio.run(scenario()) diff --git a/tests/coding/test_managed_detached.py b/tests/coding/test_managed_detached.py new file mode 100644 index 000000000..e22ab3c28 --- /dev/null +++ b/tests/coding/test_managed_detached.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +import asyncio +import json +import os +import select +import signal +import socket +import subprocess +import sys +from dataclasses import replace +from pathlib import Path +from secrets import token_hex +from time import monotonic + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError +from loushang.appserver.local import LocalAppClientConnectionV1, LocalConnectionModeV1 +from loushang.appserver.local_record import LocalConnectionDirectoryV1 +from loushang.appserver.protocol import ( + AppErrorCodeV1, + AppServiceError, + MuxAttachV1, + MuxCreateV1, + MuxMemberOpenV1, + MuxSelectorV1, + SessionOpenSpecV1, + SessionSnapshotRequestV1, + TranscriptRecordKindV1, + TurnTextV1, +) +from loushang.coding.managed_bootstrap import CodingManagedApplicationLaunchV1 +from loushang.coding.managed_local import CodingManagedLocalLaunchV1 +from loushang.hosting.service import LinuxServiceObserverV1 + +from ..apphost.test_managed_bootstrap import deployment as deployment +from ..hosting._pidfd_signal import send_signal as _send_signal +from .test_hosted_local import _local_launch + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux detached Product") + + +@pytest.mark.parametrize("exit_mode,controller_failure,late_birth,stop_signal,canonical", [ + ("normal", False, False, None, False), ("abrupt", False, False, None, False), + ("normal", True, False, None, False), ("normal", False, True, None, False), + ("normal", False, False, signal.SIGTERM, False), ("normal", False, False, signal.SIGINT, False), + pytest.param("normal", False, False, None, True, id="canonical-parent-exit"), + pytest.param("abrupt", False, False, None, True, id="canonical-parent-crash"), + pytest.param("normal", False, True, signal.SIGTERM, True, id="canonical-late-birth-term"), +]) +def test_actual_coding_service_outlives_starter_and_reconnects(deployment, tmp_path, exit_mode, controller_failure, late_birth, stop_signal, canonical): + _, _, journal, _, paths = deployment + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + address = "lmux-product-test-" + token_hex(16) + listener.bind("\0" + address) + listener.listen(1) + listener.settimeout(15) + if canonical: + (tmp_path / "sessions").mkdir(mode=0o700) + command = [sys.executable, str(Path(__file__).with_name("_managed_product_child.py")), str(tmp_path), address, + "canonical" if canonical else "legacy"] + starter = subprocess.Popen( + [sys.executable, str(Path(__file__).with_name("_managed_starter.py")), json.dumps(command), + str(tmp_path), exit_mode, "late" if late_birth else "normal"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + observer, endpoint = None, None + + async def read_state(): + deadline = monotonic() + 3 + while True: + try: + return journal.read(deadline=deadline) + except ManagedStorageError as error: + if error.code != "busy" or monotonic() >= deadline: + raise + await asyncio.sleep(0.005) + + async def scenario(): + endpoint.setblocking(False) + reader, writer = await asyncio.open_connection(sock=endpoint) + launch = _local_launch(tmp_path) + launch = replace(launch, connection_root=Path(paths.connection), + application=replace(launch.application, application_root=Path(paths.application))) + if canonical: + launch = CodingManagedLocalLaunchV1( + CodingManagedApplicationLaunchV1(tmp_path, Path(paths.application), "coding.default", tmp_path / "sessions"), + Path(paths.connection), launch.endpoint, session_discovery=True, + ) + directory = LocalConnectionDirectoryV1(launch.connection_root) + client = LocalAppClientConnectionV1(directory, launch.endpoint) + fresh = LocalAppClientConnectionV1(directory, launch.endpoint) + stop = LocalAppClientConnectionV1(directory, launch.endpoint, mode=LocalConnectionModeV1.STOP) + turn = None + try: + assert await reader.readexactly(1) == b"B" + if late_birth: + assert (await read_state()).native_identity is None + gate_deadline = monotonic() + 3 + while True: + gate = journal._database._directory.lock("registry.lock", deadline=gate_deadline) + try: + gate.__enter__() + break + except ManagedStorageError as error: + if error.code != "busy" or monotonic() >= gate_deadline: + raise + await asyncio.sleep(0.005) + try: + starter.stdin.write(b"R") + starter.stdin.flush() + # Keep the lock until the starter proves it actually took + # the busy branch. Scheduler timing cannot make old code pass. + receipt = bytearray() + ready = select.poll() + ready.register(starter.stdout, select.POLLIN | select.POLLHUP) + while b"\n" not in receipt: + assert monotonic() < gate_deadline, "no registration contention receipt" + if ready.poll(0): + chunk = os.read(starter.stdout.fileno(), 64 - len(receipt)) + assert chunk, "starter exited before observing contention" + receipt.extend(chunk) + assert len(receipt) < 64 + else: + await asyncio.sleep(0.005) + assert receipt == b"registration_busy\n" + finally: + gate.__exit__(None, None, None) + assert await reader.readexactly(1) == b"R" + state = await read_state() + assert state.native_identity == observer.identity + assert state.handoff.phase.value == "committed" + if controller_failure: + writer.write(b"X") + await writer.drain() + assert await reader.read() == b"" # Failure must never send D. + assert await asyncio.to_thread(observer.exited, timeout=8) + assert "invalid test command" in (tmp_path / "child-diagnostic.log").read_text() + return + await client.start() + mux = await client.client.create_mux(MuxCreateV1("dev")) + selector = MuxSelectorV1(mux_space_id=mux.mux_space_id) + await client.client.attach_mux(MuxAttachV1(selector)) + scope = launch.application.scopes[0] + mux = await client.client.open_member(MuxMemberOpenV1(selector, SessionOpenSpecV1( + "coding", "detached-test", scope.scope, scope.fingerprint, "dev", + ))) + attached = await client.client.attach_mux(MuxAttachV1(selector)) + turn = asyncio.create_task(client.client.start_turn(TurnTextV1( + attached.attachment_id, attached.controller_generation, mux.members[0].member_id, "hello", + ))) + assert await reader.readexactly(1) == b"E" # Model really entered. + # Exact test-owned pidfd, not a possibly reused numeric PID. HUP is + # injected while real accepted work is still held and must survive. + _send_signal(observer, signal.SIGHUP) + original = await client.client.snapshot_session(SessionSnapshotRequestV1( + attached.attachment_id, attached.controller_generation, mux.members[0].member_id, + )) + _, errors = await asyncio.to_thread(starter.communicate, b"Q", timeout=5) + assert starter.returncode == 0, errors.decode(errors="replace") + assert not observer.exited() + await client.close() + await asyncio.gather(turn, return_exceptions=True) + await fresh.start() + while True: + try: + attached = await fresh.client.attach_mux(MuxAttachV1(selector)) + break + except AppServiceError as error: + if error.code is not AppErrorCodeV1.ALREADY_ATTACHED: + raise + await asyncio.sleep(0.01) + request = SessionSnapshotRequestV1( + attached.attachment_id, attached.controller_generation, mux.members[0].member_id, + ) + running = await fresh.client.snapshot_session(request) + assert running.running and running.identity == original.identity + writer.write(b"G") + await writer.drain() + while (snapshot := await fresh.client.snapshot_session(request)).running: + await asyncio.sleep(0.01) + assert snapshot.identity == original.identity + assert [record.text for record in snapshot.records if record.kind is TranscriptRecordKindV1.ASSISTANT] == [ + "真实跨进程回复\nG14", + ] + writer.write(b"C") + await writer.drain() + assert await reader.readline() == b"1\n" + if stop_signal is None: + await stop.start() + assert stop.stop_requested + else: + _send_signal(observer, stop_signal) + _send_signal(observer, stop_signal) # Never escalates to a kill. + assert await reader.readexactly(1) == b"D" # All helper-owned dependencies closed successfully. + assert await asyncio.to_thread(observer.exited, timeout=8) + evidence = (await read_state()).evidence + assert evidence.application_cleanup_completed + # A pidfd observation is not a persisted scope settlement claim. + assert not evidence.process_exited and not evidence.process_scope_settled + if canonical: + assert len(tuple((tmp_path / "sessions").glob("*.jsonl"))) == 1 + assert not (tmp_path / "cwd-sessions").exists() and not (tmp_path / "home-sessions").exists() + finally: + active_failure = sys.exception() + writer.close() # Test-only fallback asks the child to close itself. + results = await asyncio.gather( + writer.wait_closed(), client.close(), fresh.close(), stop.close(), return_exceptions=True, + ) + try: + if turn is not None: + await asyncio.gather(turn, return_exceptions=True) + finally: + directory.close() + cleanup = [result for result in results if isinstance(result, BaseException)] + if cleanup: + if active_failure is not None: + active_failure.add_note(repr(cleanup)) + else: + raise BaseExceptionGroup("client cleanup failed", cleanup) + + failure = None + try: + poller = select.poll() + poller.register(starter.stdout, select.POLLIN) + assert poller.poll(10000), "starter did not report child" + observer = LinuxServiceObserverV1.capture(int(starter.stdout.readline())) + endpoint, _ = listener.accept() + asyncio.run(asyncio.wait_for(scenario(), 35)) + except BaseException as error: + failure = error + finally: + listener.close() + if endpoint is not None: + endpoint.close() + cleanup_errors = [] + try: + _, starter_errors = starter.communicate(b"Q" if starter.poll() is None else None, timeout=5) + if starter.returncode != 0: + cleanup_errors.append(RuntimeError("starter failed: " + starter_errors.decode(errors="replace")[-8192:])) + except Exception as error: + cleanup_errors.append(error) + # Only this test-owned direct Popen may be forcefully reaped; this + # is failed-test hygiene, never a production graceful-stop path. + try: + if starter.poll() is None: + starter.terminate() + try: + starter.wait(timeout=2) + except subprocess.TimeoutExpired: + starter.kill() + starter.wait(timeout=5) + except Exception as error: + cleanup_errors.append(error) + finally: + for pipe in (starter.stdin, starter.stdout, starter.stderr): + if pipe is not None: + pipe.close() + if observer is not None: + try: + # On failure, retain the exact observer beyond the private child + # watchdog. Never delete fixture storage while the child is live. + deadline = monotonic() + 60 + while not observer.exited(timeout=5): + assert monotonic() < deadline, "test child did not exit" + except Exception as error: + cleanup_errors.append(error) + finally: + observer.close() + if cleanup_errors: + if failure is None: + failure = ExceptionGroup("detached test cleanup failed", cleanup_errors) + else: + failure.add_note(repr(cleanup_errors)) + if failure is not None: + diagnostic = tmp_path / "child-diagnostic.log" + if diagnostic.is_file(): + failure.add_note(diagnostic.read_text()[-16384:]) + raise failure diff --git a/tests/coding/test_managed_launch.py b/tests/coding/test_managed_launch.py new file mode 100644 index 000000000..bb3166ddd --- /dev/null +++ b/tests/coding/test_managed_launch.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path + +import pytest + +from loushang.apphost.managed.contracts import ( + ManagedContractError, + ManagedInstanceRefV1, + ManagedServiceKeyV1, +) +from loushang.apphost.managed.invocation import ManagedChildInvocationV1 +from loushang.apphost.managed.paths import resolve_managed_admission_root +from loushang.coding.managed_bootstrap import create_coding_managed_launch + +from ..apphost.test_managed_bootstrap import deployment as deployment + + +def invocation(deployment, tmp_path): + namespace, service, _, state, _ = deployment + return ManagedChildInvocationV1(namespace, service, state.handoff.instance, + state.handoff.attempt_id, str(tmp_path / "runtime")) + + +def test_coding_launch_binds_exact_workspace_layout_and_explicit_session_roots(deployment, tmp_path): + value = invocation(deployment, tmp_path) + launch = create_coding_managed_launch( + value, application_id="coding.default", endpoint="workspace", + cwd_sessions=tmp_path / "cwd-sessions", home_sessions=tmp_path / "home-sessions", + session_discovery=True, + ) + assert launch.application.workspace == Path(value.service.workspace) + assert launch.application.application_root == Path(deployment[4].application) + assert launch.connection_root == Path(deployment[4].connection) + assert launch.application.cwd_sessions == tmp_path / "cwd-sessions" + assert launch.application.home_sessions == tmp_path / "home-sessions" + assert launch.session_discovery + assert not launch.application.cwd_sessions.exists() + assert not launch.application.home_sessions.exists() + + +def test_other_product_is_rejected_before_any_path_admission(deployment, tmp_path, monkeypatch): + value = invocation(deployment, tmp_path) + service = ManagedServiceKeyV1("work", value.service.workspace) + other = ManagedChildInvocationV1(value.namespace, service, ManagedInstanceRefV1( + value.namespace.namespace_key, service.service_id, value.instance.instance_id, + ), value.attempt_id, value.runtime_root) + + def forbidden(*args, **kwargs): + raise AssertionError("wrong Product cannot perform path admission") + + monkeypatch.setattr(Path, "resolve", forbidden) + for candidate in (None, other): + with pytest.raises(ManagedContractError): + create_coding_managed_launch(candidate, application_id="coding.default", endpoint="workspace", + cwd_sessions=tmp_path / "cwd", home_sessions=tmp_path / "home") + + +@pytest.mark.parametrize("mode", [ + "same", "nested", "application", "connection", "relative", "registry", "lifecycle", "logs", "temporary", + "runtime_control", "platform_parent", "runtime_parent", +]) +def test_explicit_session_roots_cannot_bypass_existing_disjoint_admission(deployment, tmp_path, mode): + cwd, home = tmp_path / "cwd-sessions", tmp_path / "home-sessions" + if mode == "same": + home = cwd + elif mode == "nested": + home = cwd / "nested" + elif mode == "application": + cwd = Path(deployment[4].application) + elif mode == "connection": + cwd = Path(deployment[4].connection) + elif mode == "relative": + cwd = Path("relative") + elif mode == "platform_parent": + cwd = tmp_path / "platform" + elif mode == "runtime_parent": + cwd = tmp_path / "runtime" + else: + cwd = Path(getattr(deployment[4], mode)) + with pytest.raises(ValueError): + create_coding_managed_launch(invocation(deployment, tmp_path), application_id="coding.default", + endpoint="workspace", cwd_sessions=cwd, home_sessions=home) + + +@pytest.mark.parametrize("relationship", ["equal", "ancestor", "descendant"]) +def test_explicit_legacy_catalog_cannot_overlap_admission(deployment, tmp_path, relationship): + value = invocation(deployment, tmp_path) + root = Path(resolve_managed_admission_root(value.namespace)) + selected = {"equal": root, "ancestor": root.parent.parent, "descendant": root / "sessions"}[relationship] + with pytest.raises(ManagedContractError): + create_coding_managed_launch( + value, application_id="coding.default", endpoint="workspace", + cwd_sessions=selected, home_sessions=tmp_path / "other-sessions", + ) + + +def test_explicit_scratch_is_not_a_session_root(deployment, tmp_path): + value = replace(invocation(deployment, tmp_path), temporary_override=str(tmp_path / "scratch")) + with pytest.raises(ManagedContractError): + create_coding_managed_launch(value, application_id="coding.default", endpoint="workspace", + cwd_sessions=tmp_path / "scratch/lmux/sessions", home_sessions=tmp_path / "home") diff --git a/tests/coding/test_managed_local.py b/tests/coding/test_managed_local.py new file mode 100644 index 000000000..cf17a4ee9 --- /dev/null +++ b/tests/coding/test_managed_local.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import asyncio +from dataclasses import replace +from pathlib import Path + +import pytest + +from loushang.apphost.managed.contracts import ( + ManagedContractError, + ManagedInstanceRefV1, + ManagedServiceKeyV1, +) +from loushang.apphost.managed.invocation import ManagedChildInvocationV1 +from loushang.apphost.managed.paths import resolve_managed_admission_root +from loushang.appservice.managed_mux import ManagedMuxServiceBindingV1 +from loushang.coding.hosted_local import CodingLocalCommandV1 +from loushang.coding.managed_bootstrap import CodingManagedApplicationLaunchV1 +from loushang.coding.managed_local import ( + CodingManagedLocalCommandV1, + CodingManagedLocalLaunchV1, + create_coding_managed_local_launch, +) + +from ..apphost.test_managed_bootstrap import deployment as deployment +from .test_hosted_local import _local_launch +from .test_managed_catalog import tree +from .test_managed_launch import invocation + + +def test_invocation_selects_same_canonical_scopes_without_effects(deployment, tmp_path): + async def scenario(): + value = invocation(deployment, tmp_path) + before = tree(tmp_path) + launch = create_coding_managed_local_launch( + value, session_root=tmp_path / "sessions", application_id="coding.default", + endpoint="workspace", session_discovery=True, + ) + owner = CodingManagedLocalCommandV1(launch) + catalog = owner._attempt._request.foreground.sessions + assert catalog.scopes == launch.application.scopes + assert {scope.session_dir for scope in catalog.scopes} == {tmp_path / "sessions"} + assert launch.connection_root == Path(deployment[4].connection) + assert launch.application.application_root == Path(deployment[4].application) + assert owner._attempt._request.foreground.session_owner is catalog + assert tree(tmp_path) == before + await owner.close() + assert not owner.cleanup_pending and tree(tmp_path) == before + for method in ("prepare", "activate", "start"): + assert getattr(CodingManagedLocalCommandV1, method) is getattr(CodingLocalCommandV1, method) + + asyncio.run(scenario()) + + +def test_managed_close_keeps_factory_debt_after_application_closes(deployment, tmp_path): + async def scenario(): + value = invocation(deployment, tmp_path) + launch = create_coding_managed_local_launch( + value, session_root=tmp_path / "sessions", application_id="coding.default", endpoint="workspace", + ) + before = tree(tmp_path) + + class Factory: + settled = False + calls = 0 + + def new_capture(self): + raise AssertionError("unstarted application must not allocate a capture") + + async def close(self): + assert owner._settled # Original application close must complete first. + self.calls += 1 + if self.calls == 1: + raise OSError("temporary cleanup needs retry") + self.settled = True + + factory = Factory() + owner = CodingManagedLocalCommandV1(launch, output_capture_factory=factory) + with pytest.raises(OSError): + await owner.close() + assert owner._settled and owner.cleanup_pending and not factory.settled + await owner.close() + assert factory.calls == 2 and not owner.cleanup_pending + assert tree(tmp_path) == before + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("field", ("application_id", "service_id", "instance_id")) +def test_managed_binding_mismatch_is_rejected_before_path_io(deployment, tmp_path, monkeypatch, field): + value = invocation(deployment, tmp_path) + binding = ManagedMuxServiceBindingV1( + "coding.default", value.service.service_id, value.instance.instance_id, + lambda _: pytest.fail("launch construction must not acquire authority"), + ) + replacement = {"application_id": "coding.other", "service_id": "e" * 64, "instance_id": "e" * 32}[field] + binding = replace(binding, **{field: replacement}) + monkeypatch.setattr(Path, "resolve", lambda *_: pytest.fail("invalid binding performed path IO")) + with pytest.raises(ManagedContractError): + create_coding_managed_local_launch( + value, session_root=tmp_path / "sessions", application_id="coding.default", endpoint="workspace", + managed_mux=binding, + ) + + +def test_legacy_and_managed_commands_reject_each_others_launch(deployment, tmp_path, monkeypatch): + value = invocation(deployment, tmp_path) + managed = create_coding_managed_local_launch( + value, session_root=tmp_path / "sessions", application_id="coding.default", endpoint="workspace", + ) + before = tree(tmp_path) + + def forbidden(*args, **kwargs): + raise AssertionError("wrong launch constructed a directory or attempt") + + from loushang.coding import hosted_local, managed_local + + monkeypatch.setattr(hosted_local, "LocalConnectionDirectoryV1", forbidden) + monkeypatch.setattr(hosted_local, "create_coding_hosted_attempt", forbidden) + monkeypatch.setattr(managed_local, "create_coding_managed_attempt", forbidden) + with pytest.raises(TypeError): + CodingLocalCommandV1(managed) + with pytest.raises(TypeError): + CodingManagedLocalCommandV1(_local_launch(tmp_path)) + assert tree(tmp_path) == before + + +@pytest.mark.parametrize("enclosed", ["session_storage", "application"]) +def test_connection_cannot_enclose_durable_authority(tmp_path, enclosed): + workspace = tmp_path / "workspace" + workspace.mkdir() + data = tmp_path / "data" + data.mkdir() + application = CodingManagedApplicationLaunchV1( + workspace, data / "application" if enclosed == "application" else tmp_path / "application", + "coding.default", data / "sessions" if enclosed == "session_storage" else tmp_path / "sessions", + ) + with pytest.raises(ValueError, match="separate"): + CodingManagedLocalLaunchV1(application, data, "workspace") + + +@pytest.mark.parametrize("domain", ["sessions", "session-assets", ".session-blob-writers", "application"]) +@pytest.mark.parametrize("nested", [False, True]) +def test_connection_cannot_overlap_session_or_application_authority(tmp_path, domain, nested): + application = CodingManagedApplicationLaunchV1(tmp_path, tmp_path / "application", "coding.default", tmp_path / "sessions") + target = tmp_path / domain + if nested: + target.mkdir(mode=0o700) + target /= "connection" + with pytest.raises(ValueError, match="separate"): + CodingManagedLocalLaunchV1(application, target, "workspace") + + +@pytest.mark.parametrize("domain", ["registry", "lifecycle", "logs", "temporary", "runtime_control"]) +def test_session_root_cannot_be_a_managed_control_directory(deployment, tmp_path, domain): + with pytest.raises(ValueError): + create_coding_managed_local_launch( + invocation(deployment, tmp_path), session_root=Path(getattr(deployment[4], domain)), + application_id="coding.default", endpoint="workspace", + ) + + +@pytest.mark.parametrize("relationship", ["equal", "ancestor", "descendant", "sibling_namespace"]) +def test_session_authority_cannot_overlap_admission_witness(deployment, tmp_path, relationship): + value = invocation(deployment, tmp_path) + root = Path(resolve_managed_admission_root(value.namespace)) + selected = { + "equal": root, "ancestor": root.parent.parent, "descendant": root / "sessions", + "sibling_namespace": root.parent / ("f" * 64) / "sessions", + }[relationship] + before = tree(tmp_path) + with pytest.raises(ManagedContractError): + create_coding_managed_local_launch( + value, session_root=selected, application_id="coding.default", endpoint="workspace", + ) + assert tree(tmp_path) == before + + +def test_wrong_product_is_rejected_before_native_path_checks(deployment, tmp_path, monkeypatch): + value = invocation(deployment, tmp_path) + service = ManagedServiceKeyV1("work", value.service.workspace) + other = ManagedChildInvocationV1(value.namespace, service, ManagedInstanceRefV1( + value.namespace.namespace_key, service.service_id, value.instance.instance_id, + ), value.attempt_id, value.runtime_root) + + def forbidden(*args, **kwargs): + raise AssertionError("wrong Product performed path checks") + + monkeypatch.setattr(Path, "resolve", forbidden) + with pytest.raises(ManagedContractError): + create_coding_managed_local_launch(other, session_root=tmp_path / "sessions", application_id="coding.default", endpoint="workspace") + + +def test_scope_and_endpoint_fields_keep_existing_closed_record_validation(tmp_path): + launch = CodingManagedLocalLaunchV1( + CodingManagedApplicationLaunchV1(tmp_path, tmp_path / "application", "coding.default", tmp_path / "sessions"), + tmp_path / "connection", "workspace", + ) + for changes in ({"session_discovery": 1}, {"endpoint": "../other"}, {"connection_root": Path("relative")}): + with pytest.raises((TypeError, ValueError)): + replace(launch, **changes) diff --git a/tests/coding/test_managed_process_arguments.py b/tests/coding/test_managed_process_arguments.py new file mode 100644 index 000000000..eed6cda82 --- /dev/null +++ b/tests/coding/test_managed_process_arguments.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import builtins +import os +import subprocess +import sys +from dataclasses import replace +from pathlib import Path + +import pytest + +from loushang.apphost.managed.contracts import ( + ManagedContractError, + ManagedInstanceRefV1, + ManagedNamespaceV1, + ManagedServiceKeyV1, +) +from loushang.apphost.managed.invocation import ManagedChildInvocationV1 +from loushang.apphost.managed.paths import resolve_managed_paths +from loushang.coding import managed_local +from loushang.coding import managed_process as module + + +@pytest.mark.parametrize("frontend", [False]) +def test_launch_material_does_not_import_backend(tmp_path, frontend): + selected = invocation(tmp_path) + script = """ +import sys +from loushang.coding.managed_process import coding_managed_process_request +from loushang.apphost.managed.invocation import ManagedChildInvocationV1 +assert 'loushang.coding.managed_local' not in sys.modules +request = coding_managed_process_request( + ManagedChildInvocationV1.from_json(sys.argv[1]), 7, + executable=sys.executable, environment={}, +) +assert request.argv[2] == 'loushang.coding.managed_process' +assert 'loushang.coding.managed_local' not in sys.modules +""" + if frontend: + script = "import loushang.coding.cli.lmux_command\n" + script + environment = dict(os.environ, PYTHONPATH=str(Path(__file__).resolve().parents[2] / "src")) + result = subprocess.run( + [sys.executable, "-c", script, selected.to_json()], + env=environment, capture_output=True, text=True, timeout=60, + ) + assert result.returncode == 0, result.stderr + assert not tuple(tmp_path.iterdir()) + + +def test_backend_import_failure_closes_adopted_endpoint(tmp_path, monkeypatch, capsys): + selected = invocation(tmp_path) + closed = [] + + class Endpoint: + def close(self): + closed.append(True) + + original_import = builtins.__import__ + + def import_module(name, *args, **kwargs): + if name == "managed_local": + raise ImportError("private-backend-failure") + return original_import(name, *args, **kwargs) + + for name in ("LOUSHANG_HOME", "LOUSHANG_RUNTIME_DIR", "LOUSHANG_TMPDIR"): + monkeypatch.setenv(name, "test-original") + monkeypatch.setattr(module.socket, "socket", lambda **kwargs: Endpoint()) + monkeypatch.setattr(module, "ManagedChildBootstrapV1", lambda *a, **k: pytest.fail("bootstrap before import")) + monkeypatch.setattr(builtins, "__import__", import_module) + assert module.main([selected.to_json(), str(tmp_path / "sessions"), "7"]) == 1 + assert closed == [True] + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "managed_process_failed\n" + + +@pytest.mark.parametrize("mode", ["disabled", "active", "expired"]) +def test_process_installs_only_unexpired_explicit_trace(tmp_path, monkeypatch, mode): + from loushang.apphost.managed.trace_buffer import ManagedTraceBuffer + from loushang.foundation.observability import get_log + from loushang.foundation.observability._router import reset_observability + + selected = invocation(tmp_path) + if mode != "disabled": + selected = replace(selected, trace_deadline_ms=101000 if mode == "active" else 99000) + instances = [] + class Bootstrap: + def __init__(self, *args, **kwargs): + self.trace = None + self.record = None + self.closed = False + instances.append(self) + def open(self, **kwargs): + pass + def managed_mux_binding(self, **kwargs): + return object() + def output_capture_factory(self, **kwargs): + return object() + def prepare_trace(self, *, deadline): + assert deadline == 101 + self.trace = ManagedTraceBuffer(selected.instance.instance_id, deadline, clock=lambda: 100) + return self.trace + def bind(self, app): + pass + def trace_sink_installed(self, buffer): + assert buffer is self.trace + def run_process(self): + get_log("test").debug_event("turn.start.performance", "turn", total_ms=1, prompt="secret") + if self.trace is not None: + self.trace.fence() + return 0 + def close(self): + self.closed = True + if self.trace is not None: + self.record = self.trace.take() + for name in ("LOUSHANG_HOME", "LOUSHANG_RUNTIME_DIR", "LOUSHANG_TMPDIR"): + monkeypatch.setenv(name, "test-original") + monkeypatch.setattr(module, "monotonic", lambda: 100) + monkeypatch.setattr(module.socket, "socket", lambda **kwargs: object()) + monkeypatch.setattr(module, "ManagedChildBootstrapV1", Bootstrap) + monkeypatch.setattr(managed_local, "create_coding_managed_local_launch", lambda *args, **kwargs: object()) + monkeypatch.setattr(managed_local, "CodingManagedLocalCommandV1", lambda *args, **kwargs: object()) + reset_observability() + try: + assert module._run([selected.to_json(), str(tmp_path / "sessions"), "7"]) == 0 + assert instances[0].closed + assert (instances[0].record is not None) == (mode == "active") + if mode == "active": + assert b"secret" not in instances[0].record + finally: + reset_observability() + + +def invocation(tmp_path, product="coding"): + namespace = ManagedNamespaceV1(str(tmp_path / "platform"), 1000, "a" * 32) + service = ManagedServiceKeyV1(product, str(tmp_path)) + return ManagedChildInvocationV1( + namespace, service, ManagedInstanceRefV1(namespace.namespace_key, service.service_id, "b" * 32), + "c" * 32, str(tmp_path / "runtime"), + ) + + +@pytest.mark.parametrize("override", [False, True]) +def test_request_is_pure_and_freezes_environment_without_mutating_input(tmp_path, monkeypatch, override): + selected = invocation(tmp_path) + if override: + selected = replace(selected, temporary_override=str(tmp_path / "scratch")) + environment = {"LOUSHANG_HOME": "/other", "LOUSHANG_RUNTIME_DIR": "/elsewhere", "LOUSHANG_TMPDIR": "/mutable", "EMPTY": "", "LABEL": "中文"} + original = dict(environment) + monkeypatch.setattr(module.socket, "socket", lambda *a, **k: pytest.fail("pure request opened socket")) + value = module.coding_managed_process_request(selected, 7, executable=sys.executable, environment=environment) + assert environment == original + assert value.argv == ( + sys.executable, "-m", "loushang.coding.managed_process", selected.to_json(), + str(tmp_path / "platform/data/sessions"), "7", + ) + assert dict(value.effective_environment) == { + **original, "LOUSHANG_HOME": selected.namespace.platform_home, "LOUSHANG_RUNTIME_DIR": selected.runtime_root, + "LOUSHANG_TMPDIR": str(resolve_managed_paths(selected.namespace, selected.service, selected.instance, + runtime_root=selected.runtime_root, + temporary_override=selected.temporary_override).temporary), + } + assert not tuple(tmp_path.iterdir()) + + +@pytest.mark.parametrize("kind", ["product", "bool_fd", "stdin", "huge_fd", "executable", "session_root"]) +def test_request_rejects_invalid_facts_without_io(tmp_path, monkeypatch, kind): + selected = invocation(tmp_path, "work" if kind == "product" else "coding") + descriptor = {"bool_fd": True, "stdin": 0, "huge_fd": 2**31}.get(kind, 7) + from pathlib import Path + + monkeypatch.setattr(module.socket, "socket", lambda *a, **k: pytest.fail("invalid request opened socket")) + with pytest.raises(ManagedContractError): + module.coding_managed_process_request( + selected, descriptor, executable="relative" if kind == "executable" else sys.executable, + environment={}, session_root=Path("relative") if kind == "session_root" else None, + ) + assert not tuple(tmp_path.iterdir()) + + +@pytest.mark.parametrize("kind", ["count", "json", "duplicate", "fd", "product"]) +def test_invalid_entry_is_bounded_and_never_adopts_descriptor(tmp_path, monkeypatch, capsys, kind): + selected = invocation(tmp_path, "work" if kind == "product" else "coding") + payload = selected.to_json() + if kind == "json": + payload = "private-secret-invalid" + elif kind == "duplicate": + payload = payload.replace("{", '{"version":"private-secret",', 1) + arguments = [payload, str(tmp_path / "sessions"), "0" if kind == "fd" else "7"] + if kind == "count": + arguments = [] + monkeypatch.setattr(module.socket, "socket", lambda *a, **k: pytest.fail("invalid entry adopted fd")) + assert module.main(arguments) == 1 + captured = capsys.readouterr() + assert captured.out == "" and captured.err == "managed_process_failed\n" + assert not tuple(tmp_path.iterdir()) diff --git a/tests/coding/test_managed_process_entry.py b/tests/coding/test_managed_process_entry.py new file mode 100644 index 000000000..d99cf8743 --- /dev/null +++ b/tests/coding/test_managed_process_entry.py @@ -0,0 +1,532 @@ +from __future__ import annotations + +import asyncio +import json +import os +import signal +import sys +from dataclasses import replace +from pathlib import Path +from time import monotonic + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.connection import ManagedConnectionLeaseV1 +from loushang.apphost.managed.contracts import ManagedNamespaceV1, ManagedServiceKeyV1 +from loushang.apphost.managed.coordinator import ManagedServiceCoordinatorV1 +from loushang.apphost.managed.discovery import ManagedDiscoveryV1 +from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 +from loushang.apphost.managed.mux_creation import ManagedMuxCreateOperationV1 +from loushang.apphost.managed.namespace_admission import ManagedNamespaceAdmissionV1 +from loushang.apphost.managed.paths import ( + resolve_managed_registry_root, + resolve_managed_service_paths, +) +from loushang.apphost.managed.registry import ManagedMuxReservationV1, ManagedRegistryV1 +from loushang.apphost.managed.service_admission import ManagedServiceAdmissionV1 +from loushang.apphost.managed.starter import ManagedServiceStarterV1 +from loushang.appserver.local import LocalAppClientConnectionV1, LocalConnectionModeV1 +from loushang.appserver.local_record import LocalConnectionDirectoryV1, LocalRecordError +from loushang.appserver.protocol import ( + AppErrorCodeV1, + AppServiceError, + MuxCreateV1, + MuxSelectorV1, + SessionListV1, + SessionOpenSpecV1, +) +from loushang.coding.managed_process import ( + APPLICATION_ID, + ENDPOINT, + coding_managed_process_request, +) +from loushang.harnesstui.mux import open_hosted_mux_profile +from loushang.hosting.service import LinuxServiceObserverV1 +from tests.apphost.test_managed_starter import owners as owners +from tests.hosting._pidfd_signal import send_signal + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed process entry") + + +async def _same_control_operation(operation, *, deadline): + """Retry only explicit short native lock contention, on the same intent.""" + while True: + try: + return operation() + except ManagedStorageError as error: + if error.code != "busy" or monotonic() >= deadline: + raise + await asyncio.sleep(min(0.01, max(0, deadline - monotonic()))) + + +def test_production_module_starts_real_service_reconnects_and_stops(owners, tmp_path): + journal, namespace, service, runtime_root = owners + sessions = Path(namespace.platform_home) / "data/sessions" + sessions.mkdir(parents=True, mode=0o700) + + def request(invocation, descriptor): + return coding_managed_process_request( + invocation, descriptor, executable=sys.executable, environment=os.environ, + ) + + starter = ManagedServiceStarterV1(journal, namespace, service, runtime_root=runtime_root, request_factory=request) + observer = None + process = None + + async def scenario(): + directory = LocalConnectionDirectoryV1(Path(starter._layout.paths.connection)) + registry = ManagedRegistryV1(Path(resolve_managed_registry_root(namespace)), namespace, defer_open=True) + manager_journal = None + clients = [] + try: + deadline = monotonic() + 20 + while True: + try: + record = directory.read(ENDPOINT) + break + except LocalRecordError: + assert monotonic() < deadline, "production child did not publish a connection" + assert process.poll() is None, "production child exited before connection publication" + await asyncio.sleep(0.02) + await asyncio.to_thread(registry.open, deadline=deadline, wait_for_lock=True) + discovery = ManagedDiscoveryV1(registry, namespace) + target = discovery.resolve("main", deadline=deadline) + assert target is not None and target.instance is not None + reference = target.instance + instance = reference.instance_id + + async def prepare_exact(deadline): + # A published endpoint is not a promise that the short native + # journal fence is currently free. Settle each failed lease + # before retrying the same frozen instance and budget. + while True: + candidate = ManagedConnectionLeaseV1( + journal, namespace, service, reference, runtime_root=runtime_root, endpoint=ENDPOINT, + ) + clients.append(candidate) + try: + await candidate.prepare(deadline=deadline) + return candidate + except ManagedStorageError as error: + await candidate.close() + assert not candidate.cleanup_pending + if error.code != "busy" or monotonic() >= deadline: + raise + await asyncio.sleep(min(0.01, max(0, deadline - monotonic()))) + + stop = LocalAppClientConnectionV1(directory, ENDPOINT, mode=LocalConnectionModeV1.STOP, expected_instance=instance) + clients.append(stop) + assert record.application_id == APPLICATION_ID and record.product_id == "coding" + assert record.instance == instance + assert record.mux_management, "production managed child must publish managed creation" + assert record.mux_closure, "production recovery binding must explicitly publish close capability" + client = await prepare_exact(deadline) + from loushang.apphost.managed.mux_management import ManagedMuxManagerV1 + + manager_journal = ManagedServiceJournalV1( + registry, namespace, service, Path(starter._layout.paths.lifecycle), + ) + manager = ManagedMuxManagerV1(registry, manager_journal, namespace, service, reference, + application_id=APPLICATION_ID) + reservation = registry.resolve(target.name) + permit = await _same_control_operation(lambda: manager.issue_create(reservation, deadline=deadline), deadline=deadline) + with pytest.raises(AppServiceError): + await client.client.create_mux(MuxCreateV1(target.name)) + with pytest.raises(AppServiceError): + await client.managed_mux_client.create_managed_mux(replace(permit, authority="forged")) + assert not (await client.client.list_muxes()).mux_spaces + created = await client.managed_mux_client.create_managed_mux(permit) + assert await _same_control_operation( + lambda: manager.record_created(permit, created, deadline=deadline), deadline=deadline, + ) == created + before = await client.client.list_muxes() + assert before.mux_spaces and before.mux_spaces[0].mux_space_id == created.mux_space_id + assert journal.read().handoff.phase.value == "committed" + # Close only parent launch/client handles. Real child remains alive + # and a fresh protocol connection recovers the same Mux metadata. + starter.close() + await client.close() + assert not observer.exited() + assert discovery.resolve("main", deadline=monotonic() + 5).instance == reference + fresh = await prepare_exact(monotonic() + 10) + after = await fresh.client.list_muxes() + assert after == before + assert await fresh.managed_mux_client.create_managed_mux(permit) == created + close_deadline = monotonic() + 10 + close = await _same_control_operation( + lambda: manager.issue_close(reservation, operation_id="d" * 32, deadline=close_deadline), + deadline=close_deadline, + ) + assert fresh.managed_mux_close_client is not None + assert await fresh.managed_mux_close_client.read_managed_mux_close(close) is None + closed = await fresh.managed_mux_close_client.close_managed_mux(close) + assert closed.phase.value == "closed" + assert await fresh.managed_mux_close_client.read_managed_mux_close(close) == closed + await _same_control_operation(lambda: manager.record_close(close, closed, deadline=close_deadline), + deadline=close_deadline) + assert registry.resolve(target.name) is None + await stop.start() + assert stop.stop_requested + assert await asyncio.to_thread(observer.exited, timeout=10) + assert process.wait(timeout=5) == 0 + events = [json.loads(line) for path in Path(starter._layout.paths.logs).glob("*.jsonl") + for line in path.read_bytes().splitlines()] + # Diagnostics are finite best-effort, not a delivery/exit authority. + expected_events = ["starting", "ready", "stopping", "stopped"] + names = [item["event"] for item in events] + assert len(names) == len(set(names)) + assert names == [name for name in expected_events if name in names] + assert all(item["instanceId"] == instance for item in events) + assert all(set(item) == {"v", "event", "instanceId", "code", "sequence"} for item in events) + evidence = journal.read().evidence + assert evidence.application_cleanup_completed + assert not evidence.process_exited and not evidence.process_scope_settled + assert not tuple(sessions.glob("*.jsonl")) # No model/Session invocation. + finally: + try: + await asyncio.gather(*(item.close() for item in clients)) + finally: + try: + directory.close() + finally: + if manager_journal is not None: + manager_journal.close() + registry.close() + + try: + # A busy durable registration is reconciled on the original process. + try: + state = starter.start(expected=None, deadline=monotonic() + 10) + except ManagedStorageError as error: + if error.code != "busy": + raise + state = starter.register_birth(deadline=monotonic() + 5) + process = starter._process._process + observer = LinuxServiceObserverV1.capture(state.native_identity.pid) + assert observer.identity == state.native_identity + asyncio.run(scenario()) + finally: + # Failure teardown signals only the exact retained test-owned pidfd. + _stop_original(starter, observer) + + +def _stop_original(starter, observer=None): + native = starter._process + process = native._process if native is not None else None + selected = observer if observer is not None else (native._observer if native is not None else None) + try: + if process is not None and process.poll() is None: + assert selected is not None, "live child lacks its retained native observer" + if not selected.exited(): + send_signal(selected, signal.SIGTERM) + assert selected.exited(timeout=20) + if process is not None: + process.wait(timeout=5) + finally: + starter.close() + if observer is not None: + observer.close() + + +def test_entry_teardown_retains_child_after_lost_registration(owners, monkeypatch): + journal, namespace, service, runtime_root = owners + (Path(namespace.platform_home) / "data/sessions").mkdir(parents=True, mode=0o700) + original = journal.register_native + + def lost(*args, **kwargs): + original(*args, **kwargs) + raise ManagedStorageError("unavailable") + + monkeypatch.setattr(journal, "register_native", lost) + starter = ManagedServiceStarterV1( + journal, namespace, service, runtime_root=runtime_root, + request_factory=lambda invocation, fd: coding_managed_process_request( + invocation, fd, executable=sys.executable, environment=os.environ, + ), + ) + try: + with pytest.raises(ManagedStorageError, match="unavailable"): + starter.start(expected=None, deadline=monotonic() + 10) + assert starter._process._process is not None + finally: + _stop_original(starter) + assert starter._process._process.poll() is not None + + +@pytest.fixture(params=["manual", "admitted"]) +def production_owners(request, tmp_path): + if request.param == "manual": + yield request.getfixturevalue("owners") + return + namespace = ManagedNamespaceV1(str(tmp_path / "platform"), os.geteuid(), "a" * 32) + service = ManagedServiceKeyV1("coding", str(tmp_path)) + runtime = str(tmp_path / "runtime") + admission = ManagedNamespaceAdmissionV1(namespace, runtime_root=runtime, create_if_missing=True) + control = None + assert not Path(namespace.platform_home).exists() + try: + registry = admission.open(deadline=monotonic() + 10) + registry.reserve_mux(ManagedMuxReservationV1("main", service, "b" * 32)) + control = ManagedServiceAdmissionV1(admission, service) + journal = control.open(deadline=monotonic() + 10) + yield journal, namespace, service, runtime + finally: + try: + if control is not None: + control.close() + finally: + admission.close() + + +@pytest.mark.parametrize("scratch_override", [False, True]) +def test_creation_operation_cold_start_and_warm_second_name_use_one_real_child(production_owners, tmp_path, scratch_override): + _, namespace, service, runtime = production_owners + paths = resolve_managed_service_paths(namespace, service, runtime_root=runtime) + registry = ManagedRegistryV1(Path(paths.registry), namespace) + journal = ManagedServiceJournalV1(registry, namespace, service, Path(paths.lifecycle)) + launches = [] + temporary = str(tmp_path / "explicit-scratch") if scratch_override else None + + def request(invocation, descriptor): + launches.append(invocation) + return coding_managed_process_request(invocation, descriptor, executable=sys.executable, environment=os.environ) + + operations = [ManagedMuxCreateOperationV1( + registry, journal, namespace, ManagedMuxReservationV1(name, service, operation_id), + runtime_root=runtime, endpoint=ENDPOINT, application_id=APPLICATION_ID, request_factory=request, + temporary_override=temporary, + ) for name, operation_id in (("main", "b" * 32), ("second", "d" * 32))] + observer = None + + async def scenario(): + nonlocal observer + directory = LocalConnectionDirectoryV1(Path(paths.connection)) + stop = None + try: + first = await operations[0].run(deadline=monotonic() + 25) + native = operations[0]._coordinator._starter._process + observer = LinuxServiceObserverV1.reopen(native.identity) + assert launches[0].temporary_override == temporary + scratch = Path(operations[0]._coordinator._starter._layout.paths.temporary) + assert scratch.is_dir() + if temporary is not None: + assert scratch.is_relative_to(temporary) + assert not (Path(namespace.platform_home) / "lmux/machines" / namespace.machine_id / + "servers" / service.service_id / "tmp").exists() + assert operations[0].connection.application_id == APPLICATION_ID + await operations[0].close() + assert not observer.exited() + second = await operations[1].run(deadline=monotonic() + 15) + assert first.instance_id == second.instance_id + assert first.mux_space_id != second.mux_space_id + assert len(launches) == 1 and operations[1]._coordinator._starter._process is None + assert {item.name for item in (await operations[1].connection.client.list_muxes()).mux_spaces} == {"main", "second"} + assert registry.resolve("main").operation_id == first.operation_id + assert registry.resolve("second").operation_id == second.operation_id + stop = LocalAppClientConnectionV1( + directory, ENDPOINT, mode=LocalConnectionModeV1.STOP, expected_instance=second.instance_id, + ) + await stop.start() + assert await asyncio.to_thread(observer.exited, timeout=10) + assert native._process.wait(timeout=5) == 0 + finally: + try: + if stop is not None: + await stop.close() + await asyncio.gather(*(operation.close() for operation in operations)) + finally: + directory.close() + + try: + asyncio.run(asyncio.wait_for(scenario(), 50)) + finally: + try: + _stop_original(operations[0]._coordinator._starter, observer) + _stop_original(operations[1]._coordinator._starter) + finally: + journal.close() + registry.close() + + +@pytest.mark.parametrize("busy_birth", [False, True]) +def test_concurrent_start_and_warm_reuse_use_one_production_child(production_owners, monkeypatch, busy_birth): + journal, namespace, service, runtime = production_owners + session_root = Path(namespace.platform_home) / "data/sessions" + lazy_store = journal._database.service_admission_required + if lazy_store: + assert not session_root.exists() + else: + session_root.mkdir(parents=True, mode=0o700) + requests = [] + registrations = [] + original_register = journal.register_native + + def register(*args, **kwargs): + registrations.append(1) + if busy_birth and len(registrations) <= 2: + raise ManagedStorageError("busy") + return original_register(*args, **kwargs) + + monkeypatch.setattr(journal, "register_native", register) + + def request(invocation, descriptor): + requests.append(invocation) + return coding_managed_process_request( + invocation, descriptor, executable=sys.executable, environment=os.environ, + ) + + coordinators = [ManagedServiceCoordinatorV1( + journal, namespace, service, runtime_root=runtime, endpoint=ENDPOINT, request_factory=request, + ) for _ in range(3)] + observers = {} + + async def scenario(): + paths = resolve_managed_service_paths(namespace, service, runtime_root=runtime) + directory = LocalConnectionDirectoryV1(Path(paths.connection)) + registry = ManagedRegistryV1(Path(paths.registry), namespace, defer_open=True) + manager_journal = None + stop = None + tasks = [] + try: + cold, release = [], asyncio.Event() + for item in coordinators[:2]: + original_read = item._read + + def gated_read(original): + first = True + + async def read(deadline): + nonlocal first + value = await original(deadline) + if first: + first = False + assert value is None + cold.append(1) + if len(cold) == 2: + release.set() + await asyncio.wait_for(release.wait(), 5) + return value + + return read + + monkeypatch.setattr(item, "_read", gated_read(original_read)) + deadline = monotonic() + 25 + tasks = [asyncio.create_task(item.ensure_started(deadline=deadline)) for item in coordinators[:2]] + first, second = await asyncio.gather(*tasks) + for item in coordinators: + native = item._starter._process + if native is not None and native.identity is not None: + observers[item] = LinuxServiceObserverV1.reopen(native.identity) + assert len(requests) == len(observers) == 1 + assert len(cold) == 2 + assert sum(item._starter._process is not None and item._starter._process._process is not None + for item in coordinators) == 1 + if busy_birth: + assert len(registrations) >= 3 + assert first.instance == second.instance + from loushang.apphost.managed.mux_management import ManagedMuxManagerV1 + + registry.open(deadline=deadline) + manager_journal = ManagedServiceJournalV1( + registry, namespace, service, Path(paths.lifecycle), defer_open=True, + ) + await asyncio.to_thread( + manager_journal.open, deadline=deadline, wait_for_lock=True, + ) + manager = ManagedMuxManagerV1(registry, manager_journal, namespace, service, first.instance, + application_id=APPLICATION_ID) + reservation = registry.resolve("main") + permit = await _same_control_operation(lambda: manager.issue_create(reservation, deadline=deadline), deadline=deadline) + assert first.managed_mux_client is not None + created = await first.managed_mux_client.create_managed_mux(permit) + assert await _same_control_operation( + lambda: manager.record_created(permit, created, deadline=deadline), deadline=deadline, + ) == created + before = await second.client.list_muxes() + assert before.mux_spaces[0].mux_space_id == created.mux_space_id + await asyncio.gather(*(item.close() for item in coordinators[:2])) + native_observer = next(iter(observers.values())) + assert not native_observer.exited() + warm = await coordinators[2].ensure_started(deadline=monotonic() + 10) + assert warm.instance == first.instance and len(requests) == 1 + assert coordinators[2]._starter._process is None + assert await warm.client.list_muxes() == before + assert await warm.managed_mux_client.create_managed_mux(permit) == created + if lazy_store: + assert not session_root.exists(), "empty Mux startup/reconnect created a Session store" + assert not (Path(namespace.platform_home) / "state/session-stores").exists() + scope = directory.read(ENDPOINT).scopes[0] + query = SessionListV1("coding", scope.scope, scope.fingerprint) + assert warm.discovery_client is not None + empty = await warm.discovery_client.list_sessions(query) + assert empty.complete and not empty.candidates + assert not session_root.exists() + assert not (Path(namespace.platform_home) / "state/session-stores").exists() + controller = await open_hosted_mux_profile(warm.client, selector=MuxSelectorV1(name="main")) + try: + view = await controller.open_member(SessionOpenSpecV1( + "coding", "first-manual-session", scope.scope, scope.fingerprint, + title="lazy first Session", + )) + assert len(view.windows) == 1 and session_root.is_dir() + session_id = view.windows[0].session_id + finally: + await controller.close() + controller = await open_hosted_mux_profile(warm.client, selector=MuxSelectorV1(name="main")) + try: + assert len(controller.state.windows) == 1 + assert controller.state.windows[0].session_id == session_id + finally: + await controller.close() + page = await warm.discovery_client.list_sessions(query) + assert page.complete and len(page.candidates) == 1 + saved = session_root.with_name("saved-sessions") + session_root.rename(saved) + try: + with pytest.raises(AppServiceError) as unavailable: + await warm.discovery_client.list_sessions(query) + assert unavailable.value.code is AppErrorCodeV1.SESSION_UNAVAILABLE + assert not session_root.exists() + finally: + saved.rename(session_root) + assert len((await warm.discovery_client.list_sessions(query)).candidates) == 1 + stop = LocalAppClientConnectionV1( + directory, ENDPOINT, mode=LocalConnectionModeV1.STOP, + expected_product_id="coding", expected_instance=warm.instance.instance_id, + ) + await stop.start() + assert stop.stop_requested + assert await asyncio.to_thread(native_observer.exited, timeout=10) + original = next(item for item in coordinators if item._starter._process is not None) + assert original._starter._process._process.wait(timeout=5) == 0 + finally: + try: + if stop is not None: + await stop.close() + finally: + try: + await asyncio.gather(*(item.close() for item in coordinators)) + await asyncio.gather(*tasks, return_exceptions=True) + finally: + try: + directory.close() + finally: + try: + if manager_journal is not None: + manager_journal.close() + finally: + registry.close() + + try: + asyncio.run(scenario()) + finally: + for item in coordinators: + native = item._starter._process + observer = observers.get(item) + # On setup failure close may already have released the parent's + # pidfd. Re-admit only this test's exact original native identity. + if (observer is None and native is not None and native._process is not None + and native._process.poll() is None): + observer = LinuxServiceObserverV1.reopen(native.identity) + _stop_original(item._starter, observer) diff --git a/tests/coding/test_managed_store_discovery.py b/tests/coding/test_managed_store_discovery.py new file mode 100644 index 000000000..096e11347 --- /dev/null +++ b/tests/coding/test_managed_store_discovery.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import asyncio +import sys +import threading + +import pytest + +from loushang.coding.hosted_catalog import ( + CodingHostedCatalogError, + CodingHostedSessionCatalogV1, +) +from loushang.coding.managed_catalog import CodingManagedSessionCatalogV1 +from loushang.harness.transcript.store_admission import TranscriptStoreAdmission +from loushang.harness.transcript.writer_lease import TranscriptWriterError + +from ..harness.transcript.test_writer_io import wait_until +from .test_hosted_catalog import _intent +from .test_managed_catalog import discovery, ordinary, tree + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux shared store discovery") + + +def catalog(root): + return CodingManagedSessionCatalogV1(session_root=root / "data/sessions", workspace=root, + store_state_root=root / "state/session-stores") + + +async def initialize(root): + selected = catalog(root) + candidate = None + try: + candidate = await selected.create_candidate(_intent(selected.scopes[0])) + finally: + if candidate is not None: + await candidate.close() + await selected.close() + + +@pytest.mark.parametrize("legacy", [False, True]) +def test_browse_never_initializes_or_registers_unknown_store(tmp_path, legacy): + async def scenario(): + selected = catalog(tmp_path) + if legacy: + selected.scopes[0].session_dir.mkdir(parents=True, mode=0o700) + await ordinary(selected, image=True) + before = tree(tmp_path) + try: + for scope in selected.scopes: + snapshot = await discovery(selected, scope) + assert snapshot.complete and len(snapshot.candidates) == int(legacy) + rows = await selected.list_identities((scope.discovery_scope,), limit=256) + assert len(rows) == int(legacy) + assert not selected._store_probes + finally: + await selected.close() + assert tree(tmp_path) == before + assert not (tmp_path / "state").exists() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("mode", ["discovery", "routing"]) +@pytest.mark.parametrize("loss", ["none", "missing", "replacement", "parent", "marker", "corrupt", "family_marker", "family_corrupt"]) +def test_fresh_catalog_known_store_loss_is_unavailable_not_empty(tmp_path, mode, loss): + async def scenario(): + await initialize(tmp_path) + root = tmp_path / "data/sessions" + if loss in ("missing", "replacement"): + root.rename(tmp_path / "original") + if loss == "replacement": + root.mkdir(mode=0o700) + elif loss == "parent": + root.parent.rename(tmp_path / "original") + root.parent.mkdir(mode=0o700) + (tmp_path / "original/sessions").rename(root) + elif loss in ("marker", "corrupt", "family_marker", "family_corrupt"): + admission = TranscriptStoreAdmission(root, state_root=tmp_path / "state/session-stores") + witness = admission._family.root if loss.startswith("family_") else admission.witness_root + marker = witness / "admission.json" + if loss.endswith("marker"): + marker.rename(tmp_path / "original-marker") + else: + marker.write_bytes(b"{}") + before = tree(tmp_path) + selected = catalog(tmp_path) # No in-memory observation from the creator. + try: + scope = selected.scopes[0] + call = (discovery(selected, scope) if mode == "discovery" + else selected.list_identities((scope.discovery_scope,), limit=256)) + if loss == "none": + result = await call + assert len(result.candidates if mode == "discovery" else result) == 1 + else: + with pytest.raises((CodingHostedCatalogError, TranscriptWriterError)): + await call + assert not selected._store_probes + finally: + await selected.close() + assert tree(tmp_path) == before + + asyncio.run(scenario()) + + +def test_failed_probe_cleanup_is_retained_and_fences_additional_reads(tmp_path, monkeypatch): + async def scenario(): + await initialize(tmp_path) + selected = catalog(tmp_path) + close = TranscriptStoreAdmission.close + failures = [] + + def fail(owner): + failures.append(owner) + raise OSError("injected observation cleanup failure") + + with monkeypatch.context() as patch: + patch.setattr(TranscriptStoreAdmission, "close", fail) + with pytest.raises(CodingHostedCatalogError): + await discovery(selected, selected.scopes[0]) + original, = selected._store_probes + assert selected._store_probes[original] is False and original.cleanup_pending + with pytest.raises(CodingHostedCatalogError): + await discovery(selected, selected.scopes[1]) + assert tuple(selected._store_probes) == (original,) and len(failures) == 1 + with pytest.raises(OSError, match="cleanup"): + await selected.close() + assert failures == [original, original] + patch.setattr(TranscriptStoreAdmission, "close", close) + await selected.close() + assert not original.cleanup_pending and not selected._store_probes + + asyncio.run(scenario()) + + +def test_cancelled_browse_and_close_keep_active_probe_until_real_worker_finishes(tmp_path, monkeypatch): + async def scenario(): + await initialize(tmp_path) + selected = catalog(tmp_path) + entered, release = threading.Event(), threading.Event() + inspect = TranscriptStoreAdmission.inspect + + def paused(owner): + result = inspect(owner) + entered.set() + assert release.wait(10) + return result + + monkeypatch.setattr(TranscriptStoreAdmission, "inspect", paused) + caller = asyncio.create_task(discovery(selected, selected.scopes[0])) + try: + await wait_until(entered.is_set) + original, = selected._store_probes + assert original.cleanup_pending + caller.cancel() + await asyncio.sleep(0) + assert not caller.done() + with pytest.raises(CodingHostedCatalogError): + await selected.close() + assert original.cleanup_pending and selected._store_probes[original] is True + release.set() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(caller, 10) + await selected.close() + assert not original.cleanup_pending and not selected._store_probes + assert selected._store_active_reads == 0 + finally: + release.set() + await asyncio.gather(caller, return_exceptions=True) + await selected.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("mode", ["discovery", "routing"]) +@pytest.mark.parametrize("replacement", ["root", "parent"]) +def test_store_replaced_after_inspect_is_rejected_by_original_probe(tmp_path, monkeypatch, mode, replacement): + async def scenario(): + await initialize(tmp_path) + selected = catalog(tmp_path) + root = selected.scopes[0].session_dir + inspect = TranscriptStoreAdmission.inspect + snapshots = [] + + def replace(owner): + result = inspect(owner) + source = root if replacement == "root" else root.parent + source.rename(tmp_path / "original") + source.mkdir(mode=0o700) + if replacement == "parent": + (tmp_path / "original/sessions").rename(root) + snapshots.append(tree(tmp_path)) + return result + + monkeypatch.setattr(TranscriptStoreAdmission, "inspect", replace) + try: + scope = selected.scopes[0] + with pytest.raises((CodingHostedCatalogError, TranscriptWriterError)): + if mode == "discovery": + await discovery(selected, scope) + else: + await selected.list_identities((scope.discovery_scope,), limit=256) + assert not selected._store_probes + finally: + await selected.close() + assert len(snapshots) == 1 and tree(tmp_path) == snapshots[0] + + asyncio.run(scenario()) + + +def test_lost_executor_submission_receipt_does_not_admit_untracked_discovery(tmp_path, monkeypatch): + async def scenario(): + await initialize(tmp_path) + selected = catalog(tmp_path) + loop = asyncio.get_running_loop() + submit = loop.run_in_executor + entered, release, finished = threading.Event(), threading.Event(), threading.Event() + futures, reads = [], [] + + def forbidden(*args, **kwargs): + reads.append(True) + raise AssertionError("unpublished submission ran actual discovery") + + def lost(executor, work, *args): + def gated(): + entered.set() + assert release.wait(10) + try: + return work(*args) + finally: + finished.set() + + futures.append(submit(executor, gated)) + assert entered.wait(10) + raise OSError("lost executor submission receipt") + + monkeypatch.setattr(selected, "_read_discovery", forbidden) + try: + with monkeypatch.context() as patch: + patch.setattr(loop, "run_in_executor", lost) + with pytest.raises(OSError, match="submission receipt"): + await discovery(selected, selected.scopes[0]) + assert not selected._store_probes and selected._store_active_reads == 0 + await selected.close() + assert not finished.is_set() + release.set() + await asyncio.wait_for(asyncio.gather(*futures), 10) + assert finished.is_set() and not reads + finally: + release.set() + await asyncio.gather(*futures, return_exceptions=True) + await selected.close() + + asyncio.run(scenario()) + + +def test_read_capacity_is_bounded_before_executor_submission(tmp_path, monkeypatch): + async def scenario(): + selected = catalog(tmp_path) + release = asyncio.Event() + entered = [] + + async def paused(*args, **kwargs): + entered.append(True) + await release.wait() + + monkeypatch.setattr(CodingHostedSessionCatalogV1, "discover_sessions", paused) + callers = [asyncio.create_task(discovery(selected, selected.scopes[0])) for _ in range(8)] + try: + await wait_until(lambda: len(entered) == 8) + with pytest.raises(CodingHostedCatalogError): + await discovery(selected, selected.scopes[1]) + assert len(entered) == selected._store_active_reads == 8 + finally: + release.set() + await asyncio.gather(*callers) + await selected.close() + assert selected._store_active_reads == 0 and not selected._store_probes + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("known_witness", [False, True]) +def test_live_catalog_does_not_forget_known_identity_when_persistent_evidence_disappears(tmp_path, known_witness): + async def scenario(): + if known_witness: + await initialize(tmp_path) + else: + (tmp_path / "data/sessions").mkdir(parents=True, mode=0o700) + selected = catalog(tmp_path) + try: + await discovery(selected, selected.scopes[0]) + if known_witness: + (tmp_path / "state/session-stores").rename(tmp_path / "original-witness") + else: + root = selected.scopes[0].session_dir + root.rename(tmp_path / "original-sessions") + root.mkdir(mode=0o700) + before = tree(tmp_path) + with pytest.raises(CodingHostedCatalogError): + await discovery(selected, selected.scopes[0]) + assert tree(tmp_path) == before and not selected._store_probes + finally: + await selected.close() + + asyncio.run(scenario()) From 43c5bf47211c5f86268ea02ec2669f85f282eb9d Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 06:39:01 +0000 Subject: [PATCH 27/44] feat(harnesstui): project conversation capabilities --- src/loushang/coding/ui/mode.py | 17 ++++ src/loushang/coding/ui/screen_app.py | 30 +------ src/loushang/coding/ui/screen_input.py | 50 +++++++++++ .../conversation/agent_application.py | 5 +- .../conversation/application_host.py | 19 +++- src/loushang/harnesstui/conversation/input.py | 33 ++++++- .../harnesstui/conversation/input_policy.py | 67 +++++++++++++++ .../conversation/request_presentation.py | 26 ++++++ .../harnesstui/conversation/screen_app.py | 7 ++ .../harnesstui/conversation/screen_frame.py | 2 +- .../harnesstui/conversation/screen_state.py | 12 +++ src/loushang/harnesstui/conversation/theme.py | 38 ++++++++ .../test_screen_capability_projection.py | 28 ++++++ .../conversation/test_agent_application.py | 5 ++ .../test_capability_projection.py | 86 +++++++++++++++++++ tests/harnesstui/conversation/test_input.py | 42 +++++++++ 16 files changed, 432 insertions(+), 35 deletions(-) create mode 100644 src/loushang/harnesstui/conversation/request_presentation.py create mode 100644 src/loushang/harnesstui/conversation/theme.py create mode 100644 tests/coding/test_screen_capability_projection.py create mode 100644 tests/harnesstui/conversation/test_capability_projection.py diff --git a/src/loushang/coding/ui/mode.py b/src/loushang/coding/ui/mode.py index 0e0ec0798..9bb01e3ba 100644 --- a/src/loushang/coding/ui/mode.py +++ b/src/loushang/coding/ui/mode.py @@ -225,6 +225,22 @@ def report_rebind_problem(code: str, error: Exception) -> None: exc=error, ) + capability_session: object | None = None + capability_generation = 0 + + def current_capabilities(): + from .screen_input import project_coding_capabilities + + nonlocal capability_session, capability_generation + active = current_session() + if active is not capability_session: + capability_session = active + capability_generation += 1 + return project_coding_capabilities( + ("coding", str(id(app)), str(capability_generation)), controller.get_operations, + clipboard_declared=screen_run_profile is CODING_SCREEN_RUN_PROFILE, + ) + prepared = AgentScreenConversationApplicationBinding( session=session, app=app, @@ -241,6 +257,7 @@ def report_rebind_problem(code: str, error: Exception) -> None: stdout=stdout, now=time.monotonic, completion_provider=completion_provider, + capability_provider=current_capabilities, resume_command_prefix=("loushang", "--resume"), session_provider=lambda: current_agent_runtime_session(runtime, session), get_operations=controller.get_operations, diff --git a/src/loushang/coding/ui/screen_app.py b/src/loushang/coding/ui/screen_app.py index 88d7c325f..49be08c99 100644 --- a/src/loushang/coding/ui/screen_app.py +++ b/src/loushang/coding/ui/screen_app.py @@ -10,6 +10,9 @@ ) from loushang.harnesstui.conversation.screen_frame import ScreenFrameCopy from loushang.harnesstui.conversation.screen_state import ScreenConversationState +from loushang.harnesstui.conversation.theme import ( + terminal_transcript_theme as _terminal_transcript_theme, +) from loushang.harnesstui.conversation.transcript_display import ( TranscriptDisplayProjectionProfile, compact_absolute_display_paths, @@ -53,33 +56,6 @@ def _coding_compaction_summary(summary: str) -> str: return f"Compacted summary:\n\n{summary.strip()}" -def _terminal_transcript_theme() -> ThemeResolver: - return ThemeResolver( - defaults={ - "markdown.heading": {"color": "yellow"}, - "markdown.link": {"color": "blue"}, - "markdown.link.url": {"color": "bright_black"}, - "markdown.code.inline": {"color": "cyan"}, - "markdown.code.block": {"color": "green"}, - "markdown.code.block.border": {"color": "bright_black"}, - "markdown.code.indent": {"text": ""}, - "markdown.quote.text": {"color": "bright_black"}, - "markdown.quote.border": {"color": "bright_black"}, - "markdown.hr": {"color": "bright_black"}, - "markdown.list.bullet": {"color": "green"}, - "transcript.divider": {"color": "bright_black", "dim": True}, - "transcript.error": {"color": "red"}, - "transcript.tool.action": {"color": "bright_cyan"}, - "transcript.tool.connector": {"color": "bright_black", "dim": True}, - "transcript.tool.error_marker": {"color": "red", "bold": True}, - "transcript.tool.flag": {"color": "bright_cyan"}, - "transcript.tool.marker": {"color": "bright_cyan", "bold": True}, - "transcript.tool.meta": {"color": "bright_black", "dim": True}, - "transcript.tool.verb": {"bold": True}, - } - ) - - def _project_coding_tool_name( record: ToolExecutionRecord, *, diff --git a/src/loushang/coding/ui/screen_input.py b/src/loushang/coding/ui/screen_input.py index f3ef30fbc..679594c42 100644 --- a/src/loushang/coding/ui/screen_input.py +++ b/src/loushang/coding/ui/screen_input.py @@ -1,5 +1,15 @@ from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from loushang.harness.session import SessionOperationResolver + from loushang.harnesstui.conversation.input_policy import ( + ConversationCapabilities, + ConversationCapability, + ConversationOperation, + ) + from loushang.foundation.observability import get_log, log_context from loushang.foundation.runtime_scope import RuntimeScope, RuntimeSweepReport from loushang.harnesstui.conversation.host import ( @@ -23,6 +33,46 @@ log = get_log(__name__).bind(component="CodingScreenRuntime") + +def project_coding_capabilities( + binding_key: tuple[str, ...], resolver: SessionOperationResolver, *, clipboard_declared: bool, +) -> ConversationCapabilities: + """Read declarations only; never resolve operations or pending approvals.""" + from loushang.harness.session import ( + SessionInputCapability, + SessionOperationAvailability, + SessionOperationCapability, + ) + from loushang.harnesstui.conversation.input_policy import ( + ConversationCapabilities, + ConversationCapability, + ) + + availability = resolver.availability + if availability is None: + availability = SessionOperationAvailability.standard() + inputs = resolver.input_capabilities + has_input = availability.supports(SessionOperationCapability.INPUT) + + def declared(operation: ConversationOperation, supported: bool) -> ConversationCapability: + return ConversationCapability(operation, "available" if supported else "unavailable", + "supported" if supported else "not_supported") + + return ConversationCapabilities(binding_key, ( + ConversationCapability("transcript", "read_only", "read_only"), + declared("submit", has_input), + declared("steer", has_input and inputs.supports(SessionInputCapability.STEER)), + declared("follow_up", has_input and inputs.supports(SessionInputCapability.FOLLOW_UP)), + declared("interrupt", availability.supports(SessionOperationCapability.LIFECYCLE) + and availability.supports(SessionOperationCapability.QUEUE)), + ConversationCapability("approval_details", "unavailable", "not_projected"), + ConversationCapability("approve", "unavailable", "not_projected"), + ConversationCapability("deny", "unavailable", "not_projected"), + declared("image_paste", True) if clipboard_declared else + ConversationCapability("image_paste", "unavailable", "not_projected"), + declared("product_commands", True), # Standard Coding surface/dispatch is bound by this composition. + )) + build_screen_input_router = bind_clipboard_image_input_router( policy=CODING_CONVERSATION_INPUT_POLICY, ) diff --git a/src/loushang/harnesstui/conversation/agent_application.py b/src/loushang/harnesstui/conversation/agent_application.py index 7368e254e..61901df84 100644 --- a/src/loushang/harnesstui/conversation/agent_application.py +++ b/src/loushang/harnesstui/conversation/agent_application.py @@ -4,7 +4,7 @@ import inspect from collections.abc import Awaitable, Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Generic, Protocol, TextIO, TypeVar, cast from loushang.harness.approval import ApprovalOutcome @@ -40,6 +40,7 @@ from loushang.harnesstui.conversation.control import ConversationActionHost from loushang.harnesstui.conversation.host import ConversationScreenRunProfile from loushang.harnesstui.conversation.input_policy import ( + ConversationCapabilities, ConversationInputCapabilities, ) from loushang.harnesstui.conversation.intents import ( @@ -170,6 +171,7 @@ class AgentScreenConversationApplicationBinding(Generic[SurfaceT]): stdout: TextIO now: Callable[[], float] completion_provider: object | None = None + capability_provider: Callable[[], ConversationCapabilities] | None = field(default=None, kw_only=True) bind_presenter: Callable[[SurfaceT], Cleanup] = _ignore_surface bind_transition: Callable[[SurfaceT], Cleanup] = _ignore_surface resume_command_prefix: tuple[str, ...] = () @@ -288,6 +290,7 @@ def refresh_session_label() -> None: active_window_state=self.app.state, ), completion_provider=self.completion_provider, + capability_provider=self.capability_provider, bind_presenter=bind_presenters, bind_transition=bind_transitions, on_history_installed=lambda history: _trace_installed_history( diff --git a/src/loushang/harnesstui/conversation/application_host.py b/src/loushang/harnesstui/conversation/application_host.py index a743c49a1..5d1fae7ae 100644 --- a/src/loushang/harnesstui/conversation/application_host.py +++ b/src/loushang/harnesstui/conversation/application_host.py @@ -2,7 +2,8 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Iterator +from contextlib import contextmanager from dataclasses import dataclass, field from typing import Protocol, TextIO @@ -11,6 +12,7 @@ ConversationScreenRunProfile, ) from loushang.harnesstui.conversation.input_policy import ( + ConversationCapabilities, ConversationInputCapabilities, ) from loushang.harnesstui.conversation.plain_app import PlainConversationApp @@ -123,6 +125,7 @@ class PreparedScreenConversationRun: history_records: tuple[DisplayRecord, ...] = () transcript_source_factory: Callable[[], TranscriptSource] | None = None completion_provider: object | None = None + capability_provider: Callable[[], ConversationCapabilities] | None = field(default=None, kw_only=True) bind_presenter: CleanupBinder = _bind_no_cleanup bind_transition: CleanupBinder = _bind_no_cleanup on_history_installed: Callable[[InstalledConversationHistory], None] = ( @@ -147,7 +150,7 @@ async def run_prepared_screen_conversation( try: unbind_transition = run.bind_transition() listener = run.event_listener_factory() - with run.interaction_context: + with run.interaction_context, _capability_projection(run): unsubscribe = _no_cleanup try: run.on_start() @@ -181,6 +184,18 @@ async def run_prepared_screen_conversation( unbind_presenter() +@contextmanager +def _capability_projection(run: PreparedScreenConversationRun) -> Iterator[None]: + """Borrow an explicit Product projection for exactly this screen run.""" + previous = run.app.capability_provider, run.app.state.capabilities + try: + if run.capability_provider is not None: + run.app.capability_provider = run.capability_provider + yield + finally: + run.app.capability_provider, run.app.state.capabilities = previous + + def _install_screen_state(run: PreparedScreenConversationRun) -> None: run.app.state.input_capabilities = run.input_capabilities run.app.transcript_source_factory = run.transcript_source_factory diff --git a/src/loushang/harnesstui/conversation/input.py b/src/loushang/harnesstui/conversation/input.py index 69c0fd271..0b65e3a91 100644 --- a/src/loushang/harnesstui/conversation/input.py +++ b/src/loushang/harnesstui/conversation/input.py @@ -228,6 +228,9 @@ class ConversationInputRouter: height: int = 12 prompt_image_stager: PromptImageAttachmentStager | None = None clipboard_outcome_presenter: ClipboardOutcomePresenter | None = None + # Deferred is a text-only intent port: its caller accepts the action before + # clearing/history presentation. It never consumes remote display queues. + submission_presentation: Literal["optimistic", "deferred"] = "optimistic" _jump_mode: PromptJumpDirection | None = None draft_store: DraftStore = field( default_factory=DraftStore, @@ -241,6 +244,9 @@ class ConversationInputRouter: _composer_target: ComposerInputTarget = field(init=False, repr=False) def __post_init__(self) -> None: + if self.submission_presentation not in {"optimistic", "deferred"}: + raise ValueError("invalid submission presentation") + self._check_deferred_attachments() self.keybindings = conversation_keybinding_manager(self.keybindings) self._composer_target = ComposerInputTarget(self.app.composer) @@ -249,6 +255,7 @@ def replace_app(self, app: ConversationScreenInputPort) -> None: self.app = app self._composer_target = ComposerInputTarget(app.composer) + self._jump_mode = None def dispose(self) -> None: """Idempotently release draft-owned resources on every runner exit.""" @@ -299,6 +306,8 @@ def handle(self, event: InputEvent) -> ConversationInputResult: return ConversationInputHandled() self._jump_mode = None if keybindings.matches(event.key, CONVERSATION_QUEUE_EDIT_LAST_ACTION): + if self.submission_presentation == "deferred": + return ConversationInputIgnored() self._restore_queued_messages() return ConversationInputHandled() if keybindings.matches(event.key, "tui.transcript.open"): @@ -374,6 +383,8 @@ def _submit_selected_completion(self) -> ConversationInputResult: def _abort_or_clear(self) -> ConversationInputResult: if self.app.state.running: return ConversationAbortResult() + if self.submission_presentation == "deferred": + return ConversationInputIgnored() if self.app.state.pending_steers: pending_steer = self.app.state.pending_steers.pop(0) return ConversationSteerResult(text=pending_steer) @@ -389,16 +400,19 @@ def _restore_queued_messages(self) -> None: self.app.composer.set_text(text) def _submit(self) -> ConversationInputResult: + self._check_deferred_attachments() text = self.app.composer.value if not text.strip(): return ConversationInputIgnored() if self.should_exit(text.strip()): - self.app.composer.clear() - self._clear_prompt_attachments() + if self.submission_presentation == "optimistic": + self.app.composer.clear() + self._clear_prompt_attachments() return ConversationExitResult(exit_code=0) if self.is_local_command(text.strip()): - self.app.composer.clear() - self._clear_prompt_attachments() + if self.submission_presentation == "optimistic": + self.app.composer.clear() + self._clear_prompt_attachments() return ConversationLocalResult(text=text.strip()) if self.app.state.running: mode = self.policy.resolve_running_submit(self.app.state.input_capabilities) @@ -407,6 +421,8 @@ def _submit(self) -> ConversationInputResult: if mode is None else self._submit_running(mode=mode) ) + if self.submission_presentation == "deferred": + return ConversationPromptResult(text=text) attachments = self._take_prompt_attachments_for_text(text) self.app.start_prompt(text) return ConversationPromptResult( @@ -419,11 +435,15 @@ def _submit_running( *, mode: RunningSubmitMode, ) -> ConversationInputResult: + self._check_deferred_attachments() if not self.app.state.input_capabilities.supports(mode): return ConversationInputIgnored() text = self.app.composer.value if not text.strip(): return ConversationInputIgnored() + if self.submission_presentation == "deferred": + return (ConversationFollowupResult(text=text) if mode == "follow_up" + else ConversationSteerResult(text=text)) attachments = self._take_prompt_attachments_for_text(text) self.app.composer.add_history(text) self.app.composer.clear() @@ -478,6 +498,7 @@ def _route_runtime_surface( return ConversationInputHandled() def _paste_clipboard_image(self) -> ConversationInputResult: + self._check_deferred_attachments() if self.prompt_image_stager is None: return ConversationInputIgnored() outcome = self.prompt_image_stager() @@ -522,6 +543,10 @@ def _take_prompt_attachments_for_text( def _clear_prompt_attachments(self) -> None: self.draft_store.clear() + def _check_deferred_attachments(self) -> None: + if self.submission_presentation == "deferred" and (self.prompt_image_stager is not None or self.draft_store): + raise ValueError("deferred input requires an empty attachment store and no image stager") + def bind_clipboard_image_input_router( profile: ClipboardImageInputProfile = STANDARD_CLIPBOARD_IMAGE_INPUT_PROFILE, diff --git a/src/loushang/harnesstui/conversation/input_policy.py b/src/loushang/harnesstui/conversation/input_policy.py index a68eadb2c..756823e26 100644 --- a/src/loushang/harnesstui/conversation/input_policy.py +++ b/src/loushang/harnesstui/conversation/input_policy.py @@ -14,6 +14,71 @@ RunningSubmitMode: TypeAlias = Literal["steer", "follow_up"] +ConversationOperation: TypeAlias = Literal[ + "transcript", "submit", "steer", "follow_up", "interrupt", + "approval_details", "approve", "deny", "image_paste", "product_commands", +] +_OPERATIONS = frozenset({ + "transcript", "submit", "steer", "follow_up", "interrupt", + "approval_details", "approve", "deny", "image_paste", "product_commands", +}) +CapabilityAvailability: TypeAlias = Literal["available", "read_only", "unavailable"] +CapabilityReason: TypeAlias = Literal[ + "supported", "read_only", "no_member", "snapshot_required", "closing", + "membership_pending", "no_interaction", "presentation_required", + "protocol_unavailable", "binding_changed", "not_projected", "not_supported", +] +_REASONS = frozenset({ + "supported", "read_only", "no_member", "snapshot_required", "closing", + "membership_pending", "no_interaction", "presentation_required", + "protocol_unavailable", "binding_changed", "not_projected", "not_supported", +}) + + +@dataclass(frozen=True, slots=True) +class ConversationCapability: + """Presentation only: never a permission, receipt, or execution promise.""" + + operation: ConversationOperation + availability: CapabilityAvailability + reason: CapabilityReason + + def __post_init__(self) -> None: + if (type(self.operation) is not str or self.operation not in _OPERATIONS + or type(self.availability) is not str + or self.availability not in {"available", "read_only", "unavailable"} + or type(self.reason) is not str or self.reason not in _REASONS): + raise ValueError("invalid conversation capability") + + +@dataclass(frozen=True, slots=True) +class ConversationCapabilities: + """Immutable complete observation, scoped to an opaque view binding. + + Consumers must obtain a fresh projection for current eligibility. Binding + equality is necessary but not sufficient for reusing an old observation. + """ + + binding_key: tuple[str, ...] + entries: tuple[ConversationCapability, ...] + + def __post_init__(self) -> None: + if (type(self.binding_key) is not tuple or not self.binding_key + or any(type(value) is not str for value in self.binding_key) + or type(self.entries) is not tuple + or any(type(entry) is not ConversationCapability for entry in self.entries) + or len(self.entries) != len(_OPERATIONS) + or {entry.operation for entry in self.entries} != _OPERATIONS): + raise ValueError("invalid conversation capability snapshot") + + def get(self, operation: ConversationOperation, *, binding_key: tuple[str, ...]) -> ConversationCapability: + if binding_key != self.binding_key: + return ConversationCapability(operation, "unavailable", "binding_changed") + for entry in self.entries: + if entry.operation == operation: + return entry + raise ValueError("invalid conversation operation") + CONVERSATION_FOLLOW_UP_ACTION = "conversation.input.followUp" CONVERSATION_PASTE_IMAGE_ACTION = "conversation.input.pasteImage" CONVERSATION_QUEUE_EDIT_LAST_ACTION = "tui.queue.editLast" @@ -91,6 +156,8 @@ def conversation_keybinding_manager( "CONVERSATION_KEYBINDING_DEFINITIONS", "CONVERSATION_PASTE_IMAGE_ACTION", "CONVERSATION_QUEUE_EDIT_LAST_ACTION", + "ConversationCapabilities", + "ConversationCapability", "ConversationInputCapabilities", "ConversationInputPolicy", "DEFAULT_CONVERSATION_INPUT_POLICY", diff --git a/src/loushang/harnesstui/conversation/request_presentation.py b/src/loushang/harnesstui/conversation/request_presentation.py new file mode 100644 index 000000000..7e52a1975 --- /dev/null +++ b/src/loushang/harnesstui/conversation/request_presentation.py @@ -0,0 +1,26 @@ +"""Bounded request-delivery presentation, independent of execution state.""" + +from dataclasses import dataclass +from typing import Literal + +RequestDeliveryState = Literal["pending", "acknowledged", "unknown"] + + +@dataclass(frozen=True, slots=True) +class ConversationRequestPresentation: + operation: Literal["submit", "steer", "follow_up", "interrupt"] + state: RequestDeliveryState + + def __post_init__(self) -> None: + if self.operation not in {"submit", "steer", "follow_up", "interrupt"} or self.state not in { + "pending", "acknowledged", "unknown", + }: + raise ValueError("invalid request presentation") + + @property + def message(self) -> str: + suffix = "; result unconfirmed, no retry" if self.state == "unknown" else "" + return f"{self.operation}: request_{self.state}{suffix}" + + +__all__ = ["ConversationRequestPresentation", "RequestDeliveryState"] diff --git a/src/loushang/harnesstui/conversation/screen_app.py b/src/loushang/harnesstui/conversation/screen_app.py index f7efda7a1..e06e70ba6 100644 --- a/src/loushang/harnesstui/conversation/screen_app.py +++ b/src/loushang/harnesstui/conversation/screen_app.py @@ -7,6 +7,7 @@ from dataclasses import dataclass, field, replace from typing import Any +from loushang.harnesstui.conversation.input_policy import ConversationCapabilities from loushang.harnesstui.conversation.reader import TranscriptReaderSurface from loushang.harnesstui.conversation.screen_frame import ScreenFramePresentation from loushang.harnesstui.conversation.screen_state import ( @@ -72,6 +73,7 @@ class ScreenConversationApp: now: Callable[[], float] = time.monotonic composer: Composer = field(default_factory=Composer) state: ScreenConversationState = field(init=False) + capability_provider: Callable[[], ConversationCapabilities] | None = field(default=None, kw_only=True) active_surface: Any | None = None surface_host: SurfaceHost | None = None transcript_theme: ThemeResolver | None = None @@ -368,6 +370,11 @@ def next_frame_due_ms(self, *, after_ms: int) -> int | None: return min(active_due_ms, completion_due_ms) def render(self, constraints: RenderConstraints) -> RenderResult: + if self.capability_provider is not None: + current = self.capability_provider() + if type(current) is not ConversationCapabilities: + raise ValueError("invalid conversation capability projection") + self.state.capabilities = current self._refresh_context_usage() visible_height = constraints.visible_height or constraints.max_height editor_height = self._bottom_frame_height(visible_height) diff --git a/src/loushang/harnesstui/conversation/screen_frame.py b/src/loushang/harnesstui/conversation/screen_frame.py index 8c247c8dd..04014725a 100644 --- a/src/loushang/harnesstui/conversation/screen_frame.py +++ b/src/loushang/harnesstui/conversation/screen_frame.py @@ -53,7 +53,7 @@ def statusline_preview_snapshot( permission_profile=state.permission_profile, pending_followups=len(state.pending_followups), pending_steers=len(state.pending_steers), - status_message=state.status_message, + status_message=state.presentation_status_message, context_usage=state.context_usage, ) diff --git a/src/loushang/harnesstui/conversation/screen_state.py b/src/loushang/harnesstui/conversation/screen_state.py index 7e9bd843b..433a5913d 100644 --- a/src/loushang/harnesstui/conversation/screen_state.py +++ b/src/loushang/harnesstui/conversation/screen_state.py @@ -4,8 +4,12 @@ from dataclasses import dataclass, field from loushang.harnesstui.conversation.input_policy import ( + ConversationCapabilities, ConversationInputCapabilities, ) +from loushang.harnesstui.conversation.request_presentation import ( + ConversationRequestPresentation, +) from loushang.harnesstui.status.line import StatusLineSettings from loushang.tui.transcript import ( AssistantMessageRecord, @@ -41,8 +45,10 @@ class ScreenConversationState: input_capabilities: ConversationInputCapabilities = field( default_factory=ConversationInputCapabilities ) + capabilities: ConversationCapabilities | None = field(default=None, kw_only=True) interruption_message: str | None = None status_message: str | None = None + request_presentation: ConversationRequestPresentation | None = field(default=None, kw_only=True) startup_pending: bool = False model_label: str | None = None cwd: str = "" @@ -58,6 +64,12 @@ class ScreenConversationState: _tool_record_indices: dict[str, int] = field(default_factory=dict, repr=False) _pending_user_echo: str | None = field(default=None, init=False, repr=False) + @property + def presentation_status_message(self) -> str | None: + if self.request_presentation is None: + return self.status_message + return self.request_presentation.message + ("; " + self.status_message if self.status_message else "") + @property def running(self) -> bool: return self.active_started_at is not None diff --git a/src/loushang/harnesstui/conversation/theme.py b/src/loushang/harnesstui/conversation/theme.py new file mode 100644 index 000000000..61c9fa72b --- /dev/null +++ b/src/loushang/harnesstui/conversation/theme.py @@ -0,0 +1,38 @@ +"""Shared transcript presentation defaults; no Product or terminal discovery.""" + +from loushang.tui.theme import ThemeResolver + + +def terminal_transcript_theme() -> ThemeResolver: + """Create an independent theme for one client composition. + + Terminal capabilities are supplied separately by the existing terminal + owner. A theme enables Markdown; it grants no remote action capability. + """ + return ThemeResolver( + defaults={ + "markdown.heading": {"color": "yellow"}, + "markdown.link": {"color": "blue"}, + "markdown.link.url": {"color": "bright_black"}, + "markdown.code.inline": {"color": "cyan"}, + "markdown.code.block": {"color": "green"}, + "markdown.code.block.border": {"color": "bright_black"}, + "markdown.code.indent": {"text": ""}, + "markdown.quote.text": {"color": "bright_black"}, + "markdown.quote.border": {"color": "bright_black"}, + "markdown.hr": {"color": "bright_black"}, + "markdown.list.bullet": {"color": "green"}, + "transcript.divider": {"color": "bright_black", "dim": True}, + "transcript.error": {"color": "red"}, + "transcript.tool.action": {"color": "bright_cyan"}, + "transcript.tool.connector": {"color": "bright_black", "dim": True}, + "transcript.tool.error_marker": {"color": "red", "bold": True}, + "transcript.tool.flag": {"color": "bright_cyan"}, + "transcript.tool.marker": {"color": "bright_cyan", "bold": True}, + "transcript.tool.meta": {"color": "bright_black", "dim": True}, + "transcript.tool.verb": {"bold": True}, + } + ) + + +__all__ = ["terminal_transcript_theme"] diff --git a/tests/coding/test_screen_capability_projection.py b/tests/coding/test_screen_capability_projection.py new file mode 100644 index 000000000..e17cbd451 --- /dev/null +++ b/tests/coding/test_screen_capability_projection.py @@ -0,0 +1,28 @@ +from loushang.coding.ui.screen_input import project_coding_capabilities +from loushang.harness.session import ( + SessionInputCapabilities, + SessionInputCapability, + SessionOperationAvailability, + SessionOperationCapability, + SessionOperationResolver, +) + + +def test_coding_projection_reads_declarations_without_resolving_or_inventing_approval(): + def forbidden(): + raise AssertionError("projection must not instantiate or resolve Session operations") + + resolver = SessionOperationResolver( + forbidden, + availability=SessionOperationAvailability.from_capabilities([SessionOperationCapability.INPUT]), + declared_input_capabilities=SessionInputCapabilities.from_capabilities([SessionInputCapability.STEER]), + ) + value = project_coding_capabilities(("local", "1"), resolver, clipboard_declared=False) + entries = {entry.operation: entry for entry in value.entries} + assert entries["submit"].availability == entries["steer"].availability == "available" + assert entries["follow_up"].reason == entries["interrupt"].reason == "not_supported" + assert entries["approve"].reason == entries["deny"].reason == "not_projected" + assert entries["image_paste"].reason == "not_projected" + standard = project_coding_capabilities(("local", "2"), SessionOperationResolver(forbidden), clipboard_declared=True) + assert standard.get("interrupt", binding_key=standard.binding_key).availability == "available" + assert standard.get("image_paste", binding_key=standard.binding_key).availability == "available" diff --git a/tests/harnesstui/conversation/test_agent_application.py b/tests/harnesstui/conversation/test_agent_application.py index 8dc25b4ec..c00613960 100644 --- a/tests/harnesstui/conversation/test_agent_application.py +++ b/tests/harnesstui/conversation/test_agent_application.py @@ -90,6 +90,9 @@ def set_statusline_settings(self, settings: object) -> None: app = App() statuses: list[object] = [] traces: list[tuple[str, dict[str, object]]] = [] + def capabilities(): + raise AssertionError("prepare must not sample live presentation eligibility") + binding = AgentScreenConversationApplicationBinding( session=session, app=cast(Any, app), @@ -106,11 +109,13 @@ def set_statusline_settings(self, settings: object) -> None: stdout=cast(Any, SimpleNamespace(write=lambda _value: None)), now=lambda: 1.0, resume_command_prefix=("research", "--resume"), + capability_provider=capabilities, ) prepared = binding.prepare() assert prepared.app is app + assert prepared.capability_provider is capabilities assert prepared.event_source is session assert prepared.history_records == () assert prepared.should_exit("/quit") is True diff --git a/tests/harnesstui/conversation/test_capability_projection.py b/tests/harnesstui/conversation/test_capability_projection.py new file mode 100644 index 000000000..2ce0686f2 --- /dev/null +++ b/tests/harnesstui/conversation/test_capability_projection.py @@ -0,0 +1,86 @@ +import asyncio +from dataclasses import FrozenInstanceError, replace +from io import StringIO + +import pytest + +from loushang.harnesstui.conversation.input_policy import ( + ConversationCapabilities, + ConversationCapability, +) + + +def snapshot(): + return ConversationCapabilities(("attachment", "1", "member", "session"), tuple( + ConversationCapability(operation, "unavailable", "protocol_unavailable") + for operation in ( + "transcript", "submit", "steer", "follow_up", "interrupt", + "approval_details", "approve", "deny", "image_paste", "product_commands", + ) + )) + + +def test_capability_snapshot_is_complete_immutable_and_binding_scoped(): + value = snapshot() + assert value.get("submit", binding_key=value.binding_key).reason == "protocol_unavailable" + assert value.get("submit", binding_key=("replacement",)).reason == "binding_changed" + with pytest.raises(FrozenInstanceError): + value.binding_key = ("replacement",) + with pytest.raises(ValueError): + replace(value, entries=value.entries[:-1]) + with pytest.raises(ValueError): + replace(value, entries=(*value.entries[:-1], value.entries[0])) + + +@pytest.mark.parametrize("changes", [ + {"operation": "unknown"}, {"availability": "authorized"}, + {"reason": "arbitrary remote text"}, +]) +def test_capability_values_reject_open_vocabulary(changes): + with pytest.raises(ValueError): + replace(snapshot().entries[0], **changes) + + +@pytest.mark.parametrize("failure", [None, RuntimeError, asyncio.CancelledError], ids=["normal", "error", "cancel"]) +@pytest.mark.parametrize("existing", [False, True], ids=["empty", "existing"]) +def test_prepared_embedded_projection_refreshes_and_restores_on_exit(failure, existing): + from loushang.harnesstui.conversation.application_host import ( + run_prepared_screen_conversation, + ) + from loushang.tui.core import RenderConstraints + + from .test_application_host import _screen_run + + run = _screen_run([]) + prior_value = replace(snapshot(), binding_key=("prior",)) if existing else None + prior_provider = (lambda: prior_value) if existing else None + run.app.capability_provider = prior_provider + run.app.state.capabilities = prior_value + current = [snapshot()] + run = replace(run, capability_provider=lambda: current[0]) + original_input = run.app.state.input_capabilities + + async def runner(**kwargs): + app = kwargs["app"] + app.render(RenderConstraints(width=100, max_height=30)) + assert app.state.capabilities is current[0] + current[0] = replace(current[0], binding_key=("new-local-binding",)) + app.render(RenderConstraints(width=100, max_height=30)) + assert app.state.capabilities is current[0] + assert app.state.input_capabilities == original_input + if failure is not None: + raise failure("runner failed") + return 0 + + async def execute(): + return await run_prepared_screen_conversation( + run, stdin=StringIO(), stdout=StringIO(), screen_runner=runner, + ) + + if failure is not None: + with pytest.raises(failure, match="runner failed"): + asyncio.run(execute()) + else: + assert asyncio.run(execute()) == 0 + assert run.app.capability_provider is prior_provider + assert run.app.state.capabilities is prior_value diff --git a/tests/harnesstui/conversation/test_input.py b/tests/harnesstui/conversation/test_input.py index 09b0be7db..52c7383ca 100644 --- a/tests/harnesstui/conversation/test_input.py +++ b/tests/harnesstui/conversation/test_input.py @@ -104,6 +104,48 @@ def _owned_attachment(tmp_path: Path, *, name: str = "image") -> PromptImageAtta ) +@pytest.mark.parametrize(("text", "running", "key", "kind"), [ + ("prompt", False, "enter", "prompt"), + ("prompt", True, "enter", "steer"), + ("prompt", True, "alt+enter", "follow_up"), + ("/local", True, "enter", "local"), + ("/exit", False, "enter", "exit"), +]) +def test_deferred_input_returns_intent_without_presenting_or_consuming(text, running, key, kind): + app = _ConversationApp() + if running: + app.state.begin_run(started_at=1.0) + app.composer.set_text(text) + router = ConversationInputRouter(app=app, should_exit=lambda value: value == "/exit", + is_local_command=lambda value: value == "/local", submission_presentation="deferred") + result = router.handle(InputEvent(kind="key", key=key)) + assert result.kind == kind + assert app.composer.value == text + assert app.state.running is running + assert not app.state.records and not app.state.pending_steers and not app.state.pending_followups + + +def test_deferred_input_refuses_attachment_transfer_and_remote_queue_edits(tmp_path): + app = _ConversationApp() + router = ConversationInputRouter(app=app, should_exit=lambda _: False, + submission_presentation="deferred") + attachment = _owned_attachment(tmp_path) + router.draft_store.add(attachment) + app.composer.set_text(attachment.marker) + with pytest.raises(ValueError, match="deferred input requires an empty attachment store"): + router.handle(InputEvent(kind="key", key="enter")) + assert len(router.draft_store) == 1 and attachment.path.exists() + app.state.pending_steers.append("remote steer") + app.state.pending_followups.append("remote followup") + for key in ("alt+up", "escape"): + assert router.handle(InputEvent(kind="key", key=key)).kind == "ignored" + assert app.state.pending_steers == ["remote steer"] + assert app.state.pending_followups == ["remote followup"] + assert app.composer.value == attachment.marker + router.dispose() + assert not attachment.path.exists() + + def test_conversation_input_results_are_discriminated_and_payload_valid() -> None: from loushang.harnesstui.conversation.input import ( ConversationAbortResult, From 03c825e5b5748fe329fa8072f2fd27ced33710f2 Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 06:41:43 +0000 Subject: [PATCH 28/44] feat(harnesstui): bind managed mux conversations --- src/loushang/coding/cli/hosted_client.py | 2 + src/loushang/coding/cli/mux.py | 4 + src/loushang/harnesstui/mux/_shell_screen.py | 38 ++- .../harnesstui/mux/conversation_binding.py | 118 +++++++++ src/loushang/harnesstui/mux/model.py | 4 + src/loushang/harnesstui/mux/projection.py | 42 +++ src/loushang/harnesstui/mux/shell.py | 178 +++++++++---- .../test_hosted_mux_approval_requests.py | 207 +++++++++++++++ .../test_hosted_mux_capabilities.py | 109 ++++++++ tests/harnesstui/test_hosted_mux_input.py | 202 ++++++++++++++ tests/harnesstui/test_hosted_mux_markdown.py | 120 +++++++++ tests/harnesstui/test_hosted_mux_requests.py | 250 ++++++++++++++++++ .../harnesstui/test_hosted_mux_settlement.py | 3 +- .../harnesstui/test_hosted_session_picker.py | 54 ++-- 14 files changed, 1252 insertions(+), 79 deletions(-) create mode 100644 src/loushang/harnesstui/mux/conversation_binding.py create mode 100644 tests/harnesstui/test_hosted_mux_approval_requests.py create mode 100644 tests/harnesstui/test_hosted_mux_capabilities.py create mode 100644 tests/harnesstui/test_hosted_mux_input.py create mode 100644 tests/harnesstui/test_hosted_mux_markdown.py create mode 100644 tests/harnesstui/test_hosted_mux_requests.py diff --git a/src/loushang/coding/cli/hosted_client.py b/src/loushang/coding/cli/hosted_client.py index 24f5bb0c9..2964d73d1 100644 --- a/src/loushang/coding/cli/hosted_client.py +++ b/src/loushang/coding/cli/hosted_client.py @@ -160,6 +160,7 @@ async def _prepare(self) -> None: MuxReadV1, MuxSelectorV1, ) + from loushang.harnesstui.conversation.theme import terminal_transcript_theme from loushang.harnesstui.mux.shell import HostedMuxShellV1 self._admit_next() @@ -185,6 +186,7 @@ async def _prepare(self) -> None: scopes=tuple((scope.scope, scope.fingerprint) for scope in self._launch.scopes), discovery_client=discovery, close_timeout=20, exit_ends_application=True, + transcript_theme=terminal_transcript_theme(), ) async def run(self, *, stdin: TextIO, stdout: TextIO) -> int: diff --git a/src/loushang/coding/cli/mux.py b/src/loushang/coding/cli/mux.py index 36c0b86d6..244ef33f9 100644 --- a/src/loushang/coding/cli/mux.py +++ b/src/loushang/coding/cli/mux.py @@ -70,6 +70,9 @@ async def run(self) -> None: return client = self._connection.client if self._action == "attach": + from loushang.harnesstui.conversation.theme import ( + terminal_transcript_theme, + ) from loushang.harnesstui.mux.shell import HostedMuxShellV1 from loushang.harnesstui.mux.terminal import run_hosted_mux_shell @@ -82,6 +85,7 @@ async def run(self) -> None: for item in self._connection.scopes ), discovery_client=self._connection.discovery_client, + transcript_theme=terminal_transcript_theme(), ) status = await run_hosted_mux_shell( self._shell, stdin=sys.stdin, stdout=self._output diff --git a/src/loushang/harnesstui/mux/_shell_screen.py b/src/loushang/harnesstui/mux/_shell_screen.py index 9bbae6256..62a929e22 100644 --- a/src/loushang/harnesstui/mux/_shell_screen.py +++ b/src/loushang/harnesstui/mux/_shell_screen.py @@ -10,6 +10,7 @@ from loushang.tui.cell_width import strip_control_sequences, truncate_to_width from loushang.tui.core import RenderConstraints, RenderLine, RenderResult from loushang.tui.input import InputEvent +from loushang.tui.theme import ThemeResolver from loushang.tui.ui_parts.text_pager import TextPager from ..conversation.screen_app import ScreenConversationApp @@ -21,11 +22,13 @@ if TYPE_CHECKING: from .shell import HostedMuxShellV1 -_HELP = """Enter sends a turn. // sends text beginning with a literal slash. +_HELP = """Enter sends a turn when idle, or steers when running; Alt+Enter queues a follow-up while running. +Shift+Enter inserts a newline. // sends text beginning with a literal slash. Tab / Shift+Tab or Ctrl+B n/p selects another Session window. Ctrl+B 1..9 selects a window. Each window retains its own local draft. PageUp / PageDown scrolls history, or the open read-only details. F1 or /help opens this help. Esc closes details without changing a draft. +Details and the picker consume ordinary keys; Ctrl+B remains a mux shortcut. F2 or /question opens the current approval details. F3 or /sessions [cwd|user_home|global] discovers saved Sessions. In the picker: Tab scope, arrows select, n next page, r refresh, Enter resume. @@ -63,19 +66,21 @@ def working_line( class HostedMuxScreenV1(ScreenConversationApp): - def __init__(self, shell: HostedMuxShellV1) -> None: + def __init__(self, shell: HostedMuxShellV1, *, transcript_theme: ThemeResolver | None = None) -> None: self.shell = shell self._view_key: object = None self._view_revision = 0 self._detail: TextPager | None = None self._detail_key: tuple[object, ...] | None = None self._reviewed_key: tuple[object, ...] | None = None + self._help_capabilities: object = None super().__init__( model_label="Hosted", cwd="", branch=None, session_label=None, composer=Composer(max_undo_depth=16), + transcript_theme=transcript_theme, ) def _create_frame_presentation(self) -> ScreenFramePresentation: @@ -116,11 +121,19 @@ def show_help(self) -> None: management_help = ( "--mux selects a named mux; this foreground application has no background management endpoint." if self.shell.exit_ends_application else - "The installed create/list/attach/close/stop commands manage named muxes." + "Use lmux new -s NAME, lmux ls, lmux attach -t NAME, " + "lmux close -t NAME and lmux stop --server NAME for management. " + "lmux create --continue explicitly resumes an original create operation." ) - self._detail, self._detail_key = TextPager("Hosted help", _HELP.format(exit_help=exit_help, management_help=management_help)), None + capabilities = self.shell.current_capabilities() + summary = "\n\nCurrent capabilities (presentation, not permission):\n" + "\n".join( + f"{entry.operation}: {entry.availability} ({entry.reason})" for entry in capabilities.entries + ) + self._help_capabilities = capabilities + self._detail, self._detail_key = TextPager("Hosted help", _HELP.format(exit_help=exit_help, management_help=management_help) + summary), None def show_approval(self) -> None: + self._help_capabilities = None key = self._approval_key() if key is None: raise ValueError("no current approval details") @@ -130,13 +143,22 @@ def show_approval(self) -> None: self._detail_key = key def approval_presented(self) -> bool: + self._sync_details() key = self._approval_key() return key is not None and key == self._reviewed_key def dismiss_details(self) -> None: + self._help_capabilities = None self._detail, self._detail_key = None, None + def invalidate_binding(self) -> None: + """Explicit rebind/refresh revokes presentation, unlike ordinary Esc.""" + self.dismiss_details() + self._reviewed_key = None + def _sync_details(self) -> None: + if self._reviewed_key is not None and self._reviewed_key != self._approval_key(): + self._reviewed_key = None if self._detail_key is not None and self._detail_key != self._approval_key(): self._detail = TextPager( "Approval expired", @@ -156,6 +178,10 @@ def handle_details(self, event: InputEvent) -> bool: def render(self, constraints: RenderConstraints) -> RenderResult: mux = self.shell.state + capabilities = self.shell.current_capabilities() + self.shell.refresh_capability_completions() + if self._help_capabilities is not None and self._help_capabilities != capabilities: + self.show_help() window = mux.active_window key = ( mux.attachment_id, @@ -163,7 +189,7 @@ def render(self, constraints: RenderConstraints) -> RenderResult: mux.active_index, None if window is None - else (window.last_cursor, window.scroll_anchor, window.title), + else (window.last_cursor, window.scroll_anchor, window.title, window.request_presentation), ) if key != self._view_key: self._view_key = key @@ -206,6 +232,8 @@ def render(self, constraints: RenderConstraints) -> RenderResult: self.state.status_message = ( "Approval pending: F2 details; /approve /deny" ) + # Eligibility can change without a transcript/cache-key change. + self.state.capabilities = capabilities footer = ( safe_text(mux.mux_name) + " | " diff --git a/src/loushang/harnesstui/mux/conversation_binding.py b/src/loushang/harnesstui/mux/conversation_binding.py new file mode 100644 index 000000000..2c020a0b3 --- /dev/null +++ b/src/loushang/harnesstui/mux/conversation_binding.py @@ -0,0 +1,118 @@ +"""Hosted adapter for the shared conversation action port; no task owner.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from typing import Literal + +from loushang.appserver.client import AppClientV1 +from loushang.appserver.protocol import AckV1, TurnInterruptV1, TurnTextV1 + +from ..conversation.control import ConversationActionHost, ConversationTextAction +from ..conversation.request_presentation import ( + ConversationRequestPresentation, + RequestDeliveryState, +) +from ._shell_tasks import ShellActions +from .model import HarnessWindowState, HostedMuxState + +ConversationMode = Literal["start", "steer", "followup", "interrupt"] + + +class HostedConversationActionBinding: + """Borrow one exact target captured by the outer attachment owner. + + Implements ConversationActionHost; the shell keeps its original bounded + action tasks. Report only delivery, never infer execution from Ack or idle. + The callback must reject results for a replaced target or newer request. + """ + + def __init__(self, client: AppClientV1, *, attachment_id: str, generation: int, + member_id: str, present: Callable[[RequestDeliveryState], None]) -> None: + self._client = client + self._attachment_id, self._generation, self._member_id = attachment_id, generation, member_id + self._present = present + self._interrupt = TurnInterruptV1(attachment_id, generation, member_id) + + async def _request(self, operation: Callable[[], Awaitable[AckV1]]) -> None: + try: + result = await operation() + if type(result) is not AckV1: + raise ValueError("invalid request acknowledgement") + except asyncio.CancelledError: + self._present("unknown") + raise + except Exception: + # Do not forward unscoped errors to the shell's global callback: + # even an old failure must respect the captured presentation key. + self._present("unknown") + else: + self._present("acknowledged") + + def _text(self, action: ConversationTextAction) -> TurnTextV1: + if action.attachments: + raise ValueError("image_paste_unavailable") + return TurnTextV1(self._attachment_id, self._generation, self._member_id, action.text) + + def validate_text(self, action: ConversationTextAction) -> None: + """Reject unsupported/invalid input before the caller publishes a task.""" + self._text(action) + + async def submit(self, action: ConversationTextAction) -> int | None: + request = self._text(action) + await self._request(lambda: self._client.start_turn(request)) + return None + + async def steer(self, action: ConversationTextAction) -> int | None: + request = self._text(action) + await self._request(lambda: self._client.steer_turn(request)) + return None + + async def follow_up(self, action: ConversationTextAction) -> int | None: + request = self._text(action) + await self._request(lambda: self._client.follow_up_turn(request)) + return None + + async def abort(self) -> None: + await self._request(lambda: self._client.interrupt_turn(self._interrupt)) + + +def submit_hosted_conversation_action( + client: AppClientV1, actions: ShellActions, state: HostedMuxState, window: HarnessWindowState, + *, current: Callable[[], HostedMuxState | None], request_id: int, text: str, mode: ConversationMode, +) -> None: + """Capture a view binding and borrow the shell's original action capacity.""" + attachment, generation = state.attachment_id, state.controller_generation + member, session = window.member_id, window.session_id + pending = ConversationRequestPresentation( + "interrupt" if mode == "interrupt" else "submit" if mode == "start" + else "steer" if mode == "steer" else "follow_up", "pending", + ) + + def present(delivery: RequestDeliveryState) -> None: + active = current() + if (active is None or active.snapshot_required or active.attachment_id != attachment + or active.controller_generation != generation): + return + target = next((item for item in active.windows + if item.member_id == member and item.session_id == session), None) + if target is not None and target.request_id == request_id: + target.request_presentation = ConversationRequestPresentation(pending.operation, delivery) + + binding = HostedConversationActionBinding(client, attachment_id=attachment, generation=generation, + member_id=member, present=present) + port: ConversationActionHost = binding + if mode == "interrupt": + actions.submit(port.abort, control=True) + else: + action = ConversationTextAction(text) + binding.validate_text(action) + operation = {"start": port.submit, "steer": port.steer, "followup": port.follow_up}[mode] + actions.submit(lambda: operation(action)) + # The original publication-gated task is now retained. Rejected validation + # or capacity leaves the draft and previous presentation untouched. + window.request_id, window.request_presentation = request_id, pending + + +__all__ = ["HostedConversationActionBinding", "submit_hosted_conversation_action"] diff --git a/src/loushang/harnesstui/mux/model.py b/src/loushang/harnesstui/mux/model.py index 53179e4ef..863ca11f6 100644 --- a/src/loushang/harnesstui/mux/model.py +++ b/src/loushang/harnesstui/mux/model.py @@ -6,6 +6,8 @@ from loushang.appserver.protocol import TranscriptRecordV1 +from ..conversation.request_presentation import ConversationRequestPresentation + @dataclass(slots=True) class HarnessWindowState: @@ -20,6 +22,8 @@ class HarnessWindowState: unread: bool = False draft: str = "" draft_revision: int = field(default=0, kw_only=True) + request_id: int = field(default=0, kw_only=True) + request_presentation: ConversationRequestPresentation | None = field(default=None, kw_only=True) assistant_draft: str = "" pending_interaction_id: str | None = None pending_interaction_text: str | None = None diff --git a/src/loushang/harnesstui/mux/projection.py b/src/loushang/harnesstui/mux/projection.py index ba153a697..c35a4089f 100644 --- a/src/loushang/harnesstui/mux/projection.py +++ b/src/loushang/harnesstui/mux/projection.py @@ -11,10 +11,51 @@ UserPromptRecord, ) +from ..conversation.input_policy import ( + CapabilityReason, + ConversationCapabilities, + ConversationCapability, + ConversationOperation, +) from ..conversation.screen_state import ScreenConversationState from .model import HostedMuxState +def project_capabilities(state: HostedMuxState, *, closing: bool = False, + membership_pending: bool = False, + approval_presented: bool = False) -> ConversationCapabilities: + """Synchronously observe current eligibility; never retain authorization.""" + window = state.active_window + key = (state.attachment_id, str(state.controller_generation), + window.member_id if window else "", window.session_id if window else "") + unavailable: CapabilityReason | None = ( + "closing" if closing else "snapshot_required" if state.snapshot_required else + "membership_pending" if membership_pending else "no_member" if window is None else None + ) + def action(operation: ConversationOperation) -> ConversationCapability: + return ConversationCapability(operation, "unavailable" if unavailable else "available", + unavailable or "supported") + def approval(operation: ConversationOperation) -> ConversationCapability: + if unavailable: + return action(operation) + if window is None or window.pending_interaction_id is None: + return ConversationCapability(operation, "unavailable", "no_interaction") + if operation == "approval_details": + return ConversationCapability(operation, "read_only" if window.pending_interaction_text else "unavailable", + "read_only" if window.pending_interaction_text else "no_interaction") + if operation == "approve" and not approval_presented: + return ConversationCapability(operation, "unavailable", "presentation_required") + return action(operation) + return ConversationCapabilities(key, ( + ConversationCapability("transcript", "read_only" if window else "unavailable", + "read_only" if window else "no_member"), + action("submit"), action("steer"), action("follow_up"), action("interrupt"), + approval("approval_details"), approval("approve"), approval("deny"), + ConversationCapability("image_paste", "unavailable", "protocol_unavailable"), + ConversationCapability("product_commands", "unavailable", "protocol_unavailable"), + )) + + def project_active_conversation(state: HostedMuxState) -> ScreenConversationState: """Reuse the shared presentation core without retaining Product objects.""" @@ -35,6 +76,7 @@ def project_active_conversation(state: HostedMuxState) -> ScreenConversationStat records.append(ErrorRecord(record.text, "")) projected.replace_transcript_window(records) projected.session_label = window.title + projected.request_presentation = window.request_presentation if window.running: projected.begin_run(started_at=0.0) if window.assistant_draft: diff --git a/src/loushang/harnesstui/mux/shell.py b/src/loushang/harnesstui/mux/shell.py index 3ebb9a321..aa513fde1 100644 --- a/src/loushang/harnesstui/mux/shell.py +++ b/src/loushang/harnesstui/mux/shell.py @@ -10,6 +10,7 @@ from loushang.appserver.client import AppClientV1, SessionDiscoveryClientV1 from loushang.appserver.protocol import ( + AckV1, AppErrorCodeV1, AppServiceError, InteractionOutcomeV1, @@ -18,16 +19,20 @@ MuxSelectorV1, SessionOpenSpecV1, SessionScopeV1, - TurnInterruptV1, - TurnTextV1, ) from loushang.tui import Composer -from loushang.tui.input import InputEvent, InputRouter +from loushang.tui.completion import SlashCommand, SlashCommandCompletionProvider +from loushang.tui.input import InputEvent +from loushang.tui.theme import ThemeResolver +from ..conversation.input import ConversationInputRouter +from ..conversation.input_policy import ConversationCapabilities, ConversationOperation from ._shell_screen import HostedMuxScreenV1, safe_text from ._shell_tasks import ShellActions, join, owned_task from .controller import HostedMuxControllerV1 +from .conversation_binding import ConversationMode, submit_hosted_conversation_action from .model import HarnessWindowState, HostedMuxState +from .projection import project_capabilities from .reducer import select_next, select_previous, select_window, set_active_draft from .session_picker import SessionPickerV1 @@ -53,6 +58,7 @@ def __init__( close_timeout: float = 5.0, discovery_client: SessionDiscoveryClientV1 | None = None, exit_ends_application: bool = False, + transcript_theme: ThemeResolver | None = None, ) -> None: if ( type(exit_ends_application) is not bool @@ -71,6 +77,11 @@ def __init__( self._controller = HostedMuxControllerV1(client, selector=selector) self._actions = ShellActions(self._failed) self._editors: dict[tuple[str, str, str], Composer] = {} + self._completion = SlashCommandCompletionProvider(tuple(SlashCommand(name) for name in ( + "help", "question", "refresh", "sessions", "resume", "new", "close", + "steer", "followup", "approve", "deny", "interrupt", "detach", "exit", + ))) + self._completion_capabilities: ConversationCapabilities | None = None self.picker = SessionPickerV1( discovery_client, actions=self._actions, @@ -86,11 +97,16 @@ def __init__( self._timeout = close_timeout self._closing = self._settled = self._membership_pending = False self._prefix = False + self._request_serial = 0 self.notice = "cwd / user_home: /new ; /help" self.exit_requested = False self.exit_code = 0 - self.screen = HostedMuxScreenV1(self) - self._router = InputRouter(composer=self.screen.composer) + self.screen = HostedMuxScreenV1(self, transcript_theme=transcript_theme) + self._router = ConversationInputRouter( + app=self.screen, should_exit=lambda _: False, + is_local_command=lambda text: text.startswith("/") and not text.startswith("//"), + submission_presentation="deferred", + ) @property def state(self) -> HostedMuxState: @@ -130,6 +146,7 @@ def handle(self, event: InputEvent) -> None: if self._closing or self.exit_requested or event.event_type == "release": return try: + self.refresh_capability_completions() self._handle(event) except AppServiceError as error: self.notice = error.code.value @@ -177,6 +194,8 @@ def _handle(self, event: InputEvent) -> None: select_window(self.state, int(choice) - 1) self._sync_editor() return + if self.picker.handle(event) or self.screen.handle_details(event): + return if key == "ctrl+d" and not self.screen.composer.value: self.exit_requested = True return @@ -196,16 +215,9 @@ def _handle(self, event: InputEvent) -> None: self.picker.dismiss() self.screen.show_approval() return - if key == "ctrl+c": - self._command("/interrupt") - return - if self.picker.handle(event): - return if key in {"tab", "shift+tab"} and not self.screen.composer.has_completions: self._select(key == "tab") return - if self.screen.handle_details(event): - return if key in {"pageUp", "pageDown"}: window = self.state.active_window if window is not None: @@ -219,17 +231,6 @@ def _handle(self, event: InputEvent) -> None: ) window.scroll_anchor = None if end == len(window.records) else end return - if key == "enter": - text = self.screen.composer.value - if not text: - return - if text.startswith("/") and not text.startswith("//"): - self._command(text) - else: - self._turn(text[1:] if text.startswith("//") else text) - self._set_draft("") - self.screen.composer.clear() - return if self._membership_pending and self.state.active_window is None: self.notice = "member_pending: wait for the first Session" return @@ -238,12 +239,28 @@ def _handle(self, event: InputEvent) -> None: text = safe_text(event.text) self._check_draft(before + text) event = InputEvent(kind=event.kind, text=text) - self._router.route(event) + window = self.state.active_window + # Rendering is not an authority barrier; route against the current + # remote run fact even when no frame has been drawn since it changed. + self.screen.state.active_started_at = 0.0 if window is not None and window.running else None + result = self._router.handle(event) try: self._set_draft(self.screen.composer.value) except ValueError: self.screen.composer.set_text(before) raise + if result.kind == "local": + self._command(result.text) + elif result.kind == "prompt" or result.kind == "steer" or result.kind == "follow_up": + text = result.text + self._turn(text[1:] if text.startswith("//") else text, + mode="start" if result.kind == "prompt" else "steer" if result.kind == "steer" else "followup") + elif result.kind == "abort": + self._command("/interrupt") + if result.kind in {"local", "prompt", "steer", "follow_up"}: + self.screen.composer.add_history(self.screen.composer.value) + self.screen.composer.clear() + self._set_draft("") def _check_draft(self, text: str) -> None: size = len(text.encode("utf-8")) @@ -265,7 +282,10 @@ def _select(self, forward: bool) -> None: self._sync_editor() def _sync_editor(self) -> None: - self.screen.dismiss_details() + # A -> B -> A is still a new presentation binding. Accepted remote + # actions keep running, but their old global notices cannot follow it. + self._request_serial += 1 + self.screen.invalidate_binding() self.picker.dismiss() keys = { (self.state.mux_space_id, item.member_id, item.session_id) @@ -283,6 +303,7 @@ def _sync_editor(self) -> None: editor = self._editors.get(key) if key else None if editor is None: editor = Composer(max_undo_depth=16) + editor.set_completion_provider(self._completion) if key is not None: self._editors[key] = editor draft = window.draft if window else "" @@ -290,13 +311,11 @@ def _sync_editor(self) -> None: editor.set_text(draft) if editor is not self.screen.composer: self.screen.bind_editor(editor) - self._router = InputRouter( - composer=editor, - surface_host=self._router.surface_host, - width=self._router.width, - height=self._router.height, - keybindings=self._router.keybindings, - ) + self.refresh_capability_completions(force=True) + # Explicit refresh retains local cursor/undo, never a stale completion + # or prefix. Remote actions separately capture the full authority key. + editor.clear_completion_items() + self._router.replace_app(self.screen) def _resume_selected(self, spec: SessionOpenSpecV1) -> None: if self.state.snapshot_required: @@ -312,6 +331,30 @@ async def resume() -> HostedMuxState: self._membership(resume) + def current_capabilities(self) -> ConversationCapabilities: + return project_capabilities(self.state, closing=self._closing, + membership_pending=self._membership_pending, + approval_presented=self.screen.approval_presented()) + + def refresh_capability_completions(self, *, force: bool = False) -> None: + capabilities = self.current_capabilities() + if not force and capabilities == self._completion_capabilities: + return + operations: dict[str, ConversationOperation] = { + "question": "approval_details", "steer": "steer", "followup": "follow_up", + "approve": "approve", "deny": "deny", "interrupt": "interrupt", + } + commands = [SlashCommand(name) for name in ( + "help", "refresh", "sessions", "resume", "new", "close", "detach", "exit", + )] + for name, operation in operations.items(): + entry = capabilities.get(operation, binding_key=capabilities.binding_key) + if entry.availability != "unavailable": + commands.append(SlashCommand(name, description=entry.reason)) + self._completion = SlashCommandCompletionProvider(tuple(commands)) + self._completion_capabilities = capabilities + self.screen.composer.set_completion_provider(self._completion) + def _target(self) -> tuple[HostedMuxState, HarnessWindowState]: state = self.state if state.snapshot_required or self._membership_pending: @@ -321,18 +364,15 @@ def _target(self) -> tuple[HostedMuxState, HarnessWindowState]: raise ValueError("no active member") return state, window - def _turn(self, text: str, *, mode: str = "start") -> None: + def _turn(self, text: str, *, mode: ConversationMode = "start") -> None: state, window = self._target() - request = TurnTextV1( - state.attachment_id, state.controller_generation, window.member_id, text + request_id = self._request_serial + 1 + submit_hosted_conversation_action( + self._client, self._actions, state, window, + current=lambda: None if self._closing else self.state, + request_id=request_id, text=text, mode=mode, ) - operation = { - "start": self._client.start_turn, - "steer": self._client.steer_turn, - "followup": self._client.follow_up_turn, - }[mode] - self._actions.submit(lambda: operation(request)) - self.notice = "request_pending; lost replies are not retried" + self._request_serial = request_id def _command(self, text: str) -> None: parts = shlex.split(text) @@ -375,16 +415,11 @@ async def close_member() -> HostedMuxState: self._membership(close_member) elif command in {"/steer", "/followup"} and args: - self._turn(" ".join(args), mode=command[1:]) + self._turn(" ".join(args), mode="steer" if command == "/steer" else "followup") elif command in {"/approve", "/deny", "/interrupt"} and not args: state, window = self._target() if command == "/interrupt": - interrupt = TurnInterruptV1( - state.attachment_id, state.controller_generation, window.member_id - ) - self._actions.submit( - lambda: self._client.interrupt_turn(interrupt), control=True - ) + self._turn("", mode="interrupt") else: if window.pending_interaction_id is None: raise ValueError("no active interaction") @@ -401,12 +436,46 @@ async def close_member() -> HostedMuxState: if command == "/approve" else InteractionOutcomeV1.DENY, ) - self._actions.submit( - lambda: self._client.respond_interaction(response), control=True - ) + self._respond_interaction(state, window, response) else: raise ValueError("unsupported action") + def _respond_interaction( + self, state: HostedMuxState, window: HarnessWindowState, response: InteractionRespondV1, + ) -> None: + request_id = self._request_serial + 1 + + def target_key() -> tuple[object, ...]: + return ( + state.mux_space_id, state.attachment_id, state.controller_generation, + window.member_id, window.session_id, + window.pending_interaction_id, window.pending_interaction_text, + ) + + key = target_key() + + async def respond() -> None: + try: + result = await self._client.respond_interaction(response) + if type(result) is not AckV1: + raise ValueError("invalid request acknowledgement") + except Exception as error: + # ShellActions' fallback is deliberately global. Consume this + # target-specific failure here even when its view has expired. + # CancelledError still propagates to the original waiter owner; + # no cancellation or replay is sent to accepted remote work. + if ( + not self._closing and not self._membership_pending + and self.state is state and not state.snapshot_required + and state.active_window is window + and self._request_serial == request_id and target_key() == key + ): + self._failed(error) + + self._actions.submit(respond, control=True) + # Failed publication leaves the prior request and draft untouched. + self._request_serial = request_id + def _membership( self, operation: Callable[[], Coroutine[object, object, HostedMuxState]] ) -> None: @@ -424,12 +493,15 @@ async def apply() -> HostedMuxState: self._actions.submit(apply) self._membership_pending = True + self._request_serial += 1 + self.screen.invalidate_binding() async def poll(self) -> None: if self._closing or self._membership_pending: return try: await self._controller.poll() + self.screen.approval_presented() # Revoke receipts on observed invalidation, even with details closed. if self.state.snapshot_required: self.notice = "snapshot_required: /refresh" # Presentation retention does not delete canonical history. @@ -491,8 +563,8 @@ async def _close_once(self) -> None: self._detach_task = owned_task(self._controller.close) await join(self._detach_task, self._deadline) self._editors.clear() + self._router.dispose() self.screen.bind_editor(Composer(max_undo_depth=16)) - self._router = InputRouter(composer=self.screen.composer) self._settled = True diff --git a/tests/harnesstui/test_hosted_mux_approval_requests.py b/tests/harnesstui/test_hosted_mux_approval_requests.py new file mode 100644 index 000000000..b8ae723f1 --- /dev/null +++ b/tests/harnesstui/test_hosted_mux_approval_requests.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from loushang.appserver.protocol import AckV1, AppErrorCodeV1, AppServiceError +from loushang.tui import RenderConstraints +from loushang.tui.input import InputEvent + +from .test_hosted_mux_profile import _Client +from .test_hosted_mux_requests import send, settle +from .test_hosted_mux_shell import _shell + + +def present(shell): + shell.handle(InputEvent(kind="key", key="f2")) + shell.screen.render(RenderConstraints(width=100, max_height=24)) + assert shell.screen.approval_presented() + shell.handle(InputEvent(kind="key", key="escape")) + + +@pytest.mark.parametrize("command", ["/approve", "/deny"]) +@pytest.mark.parametrize("change", [ + "tab", "tab_aba", "refresh", "attachment", "generation", "member", "session", + "interaction", "interaction_text", "request", "approval", "snapshot", "membership", +]) +def test_late_approval_failure_cannot_change_replaced_view_or_new_request(command, change): + async def scenario(): + client = _Client() + entered, release = asyncio.Event(), asyncio.Event() + requests, cancelled = [], [] + + async def respond(request): + requests.append(request) + if len(requests) > 1: + return AckV1() + entered.set() + try: + await release.wait() + except asyncio.CancelledError: + cancelled.append(request) + raise + raise AppServiceError(AppErrorCodeV1.SERVICE_CLOSED) + + client.respond_interaction = respond + shell = _shell(client) + await shell.start() + try: + window = shell.state.active_window + window.pending_interaction_id = "question-1" + window.pending_interaction_text = "Current action" + if command == "/approve": + present(shell) + send(shell, command) + async with asyncio.timeout(2): + await entered.wait() + assert tuple(shell._actions._tasks.values()) == (True,) + assert len(requests) == 1 + assert requests[0].interaction_id == "question-1" + if change in {"tab", "tab_aba"}: + shell.handle(InputEvent(kind="key", key="tab")) + if change == "tab_aba": + shell.handle(InputEvent(kind="key", key="tab")) + elif change == "refresh": + await shell._controller.refresh_snapshot() + shell._sync_editor() + elif change == "attachment": + shell.state.attachment_id = "replacement" + elif change == "generation": + shell.state.controller_generation += 1 + elif change == "member": + window.member_id = "replacement" + elif change == "session": + window.session_id = "replacement" + elif change == "interaction": + window.pending_interaction_id = "question-2" + elif change == "interaction_text": + window.pending_interaction_text = "Changed action" + elif change == "request": + send(shell, "new request") + elif change == "approval": + send(shell, "/deny") + elif change == "snapshot": + shell.state.snapshot_required = True + else: + shell._membership_pending = True + assert not cancelled and not release.is_set() + shell.notice = "current view notice" + release.set() + await settle(shell) + assert shell.notice == "current view notice" + assert not shell.exit_requested and shell.exit_code == 0 + assert len(requests) == (2 if change == "approval" else 1) + assert not cancelled + if change == "request": + assert window.request_presentation.state == "acknowledged" + finally: + release.set() + shell._membership_pending = False + await shell.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("outcome", ["ack", "failure", "invalid_ack", "service_closed"]) +def test_current_approval_response_keeps_safe_failure_and_never_replays(outcome): + async def scenario(): + client, requests = _Client(), [] + + async def respond(request): + requests.append(request) + if outcome == "failure": + raise RuntimeError("private response error") + if outcome == "invalid_ack": + return None + if outcome == "service_closed": + raise AppServiceError(AppErrorCodeV1.SERVICE_CLOSED) + return AckV1() + + client.respond_interaction = respond + shell = _shell(client) + await shell.start() + try: + shell.state.active_window.pending_interaction_id = "question" + before = shell.notice + send(shell, "/deny") + await settle(shell) + expected = (before if outcome == "ack" else + "service_closed; outcome unknown, no retry" if outcome == "service_closed" else + "action_failed; outcome unknown, no retry") + assert shell.notice == expected + assert shell.exit_requested == (outcome == "service_closed") + await shell.poll() + assert len(requests) == 1 + finally: + await shell.close() + + asyncio.run(scenario()) + + +def test_close_cancels_only_local_approval_waiter_not_accepted_work(): + async def scenario(): + client = _Client() + accepted, release = asyncio.Event(), asyncio.Event() + completed, requests, remote_tasks = [], [], [] + + async def remote_work(): + await release.wait() + completed.append("responded") + return AckV1() + + async def respond(request): + requests.append(request) + task = asyncio.create_task(remote_work()) + remote_tasks.append(task) + accepted.set() + # Model the existing server's ownership after accepting the RPC. + return await asyncio.shield(task) + + client.respond_interaction = respond + shell = _shell(client) + await shell.start() + try: + shell.state.active_window.pending_interaction_id = "question" + send(shell, "/deny") + async with asyncio.timeout(2): + await accepted.wait() + await shell.close() + assert not remote_tasks[0].done() + assert len(requests) == 1 and shell.pending_actions == 0 + assert [name for name, _ in client.calls] == ["attach", "detach"] + assert "unknown" not in shell.notice + release.set() + await remote_tasks[0] + assert completed == ["responded"] + finally: + release.set() + await asyncio.gather(*remote_tasks) + await shell.close() + + asyncio.run(scenario()) + + +def test_rejected_approval_publication_preserves_request_generation_and_draft(monkeypatch): + async def scenario(): + client = _Client() + shell = _shell(client) + await shell.start() + try: + shell.state.active_window.pending_interaction_id = "question" + serial = shell._request_serial + + def reject(*args, **kwargs): + raise ValueError("action_queue_full") + + monkeypatch.setattr(shell._actions, "submit", reject) + send(shell, "/deny") + assert shell.notice == "action_queue_full" + assert shell._request_serial == serial + assert shell.screen.composer.value == shell.state.active_window.draft == "/deny " + assert shell.pending_actions == 0 + assert [name for name, _ in client.calls] == ["attach"] + finally: + await shell.close() + + asyncio.run(scenario()) diff --git a/tests/harnesstui/test_hosted_mux_capabilities.py b/tests/harnesstui/test_hosted_mux_capabilities.py new file mode 100644 index 000000000..282a85d70 --- /dev/null +++ b/tests/harnesstui/test_hosted_mux_capabilities.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from loushang.tui.core import RenderConstraints +from loushang.tui.input import InputEvent + +from .test_hosted_mux_profile import _Client +from .test_hosted_mux_shell import _shell + + +@pytest.mark.parametrize("changed", ["closing", "snapshot", "membership", "content"]) +def test_current_capabilities_replace_same_binding_eligibility_without_transcript_change(changed): + async def run(): + shell = _shell(_Client()) + await shell.start() + try: + window = shell.state.active_window + window.pending_interaction_id = "interaction" + window.pending_interaction_text = "Approve this exact action" + constraints = RenderConstraints(width=160, max_height=100) + shell.screen.show_approval() + shell.screen.render(constraints) + old = shell.current_capabilities() + assert old.get("approve", binding_key=old.binding_key).availability == "available" + cursor = window.last_cursor + shell.screen.show_help() + if changed == "closing": + shell._closing = True + elif changed == "snapshot": + shell.state.snapshot_required = True + elif changed == "membership": + shell._membership_pending = True + else: + window.pending_interaction_text = "Changed action" + rendered = shell.screen.render(constraints) + current = shell.screen.state.capabilities + assert current.binding_key == old.binding_key + assert window.last_cursor == cursor + assert current.get("approve", binding_key=current.binding_key).availability == "unavailable" + text = "\n".join(line.text for line in rendered.lines) + for command in ("lmux new -s NAME", "lmux ls", "lmux attach -t NAME", + "lmux close -t NAME", "lmux stop --server NAME", + "lmux create --continue"): + assert command in text + assert "create/list/attach/close/stop" not in text + assert "approve: unavailable" in text + assert "approve: available" not in text + assert "approve" not in {item.name for item in shell._completion.commands} + # Reading the old value has no publication path back to the view. + assert old.get("approve", binding_key=old.binding_key).availability == "available" + assert shell.current_capabilities() == current + if changed == "snapshot": + shell.state.snapshot_required = False + restored = shell.current_capabilities() + assert restored.get("approve", binding_key=restored.binding_key).reason == "presentation_required" + finally: + await shell.close() + asyncio.run(run()) + + +def test_unpresented_approval_keeps_deny_and_details_available(): + async def run(): + shell = _shell(_Client()) + await shell.start() + try: + window = shell.state.active_window + window.pending_interaction_id = "interaction" + window.pending_interaction_text = "Exact action" + value = shell.current_capabilities() + assert value.get("deny", binding_key=value.binding_key).availability == "available" + assert value.get("approval_details", binding_key=value.binding_key).availability == "read_only" + assert value.get("approve", binding_key=value.binding_key).reason == "presentation_required" + assert value.get("image_paste", binding_key=value.binding_key).reason == "protocol_unavailable" + shell.refresh_capability_completions() + names = {item.name for item in shell._completion.commands} + assert {"deny", "question"} <= names and "approve" not in names + assert not shell._completion.complete("/approve") + assert shell._completion.complete("/deny") + constraints = RenderConstraints(width=160, max_height=100) + shell.screen.show_approval() + shell.screen.render(constraints) + shell.screen.dismiss_details() + shell.screen.render(constraints) + assert "approve" in {item.name for item in shell._completion.commands} + assert shell._completion.complete("/approve") + shell.handle(InputEvent(kind="key", key="ctrl+b")) + shell.handle(InputEvent(kind="text", text="2")) + current = shell.current_capabilities() + assert value.get("deny", binding_key=current.binding_key).reason == "binding_changed" + shell.screen.render(constraints) + assert "approve" not in {item.name for item in shell._completion.commands} + assert not shell._completion.complete("/approve") + shell.handle(InputEvent(kind="key", key="ctrl+b")) + shell.handle(InputEvent(kind="text", text="1")) + returned = shell.current_capabilities() + assert returned.binding_key == value.binding_key + assert returned.get("approve", binding_key=returned.binding_key).reason == "presentation_required" + assert shell._completion.complete("/deny") + assert not shell._completion.complete("/approve") + calls = len(shell._client.calls) + shell._command("/approve") + assert len(shell._client.calls) == calls + assert "Present all approval details" in shell.notice + finally: + await shell.close() + asyncio.run(run()) diff --git a/tests/harnesstui/test_hosted_mux_input.py b/tests/harnesstui/test_hosted_mux_input.py new file mode 100644 index 000000000..82ddcab0a --- /dev/null +++ b/tests/harnesstui/test_hosted_mux_input.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from loushang.harnesstui.conversation.input import ConversationInputRouter +from loushang.tui.input import InputEvent + +from .test_hosted_mux_profile import _Client +from .test_hosted_mux_requests import settle +from .test_hosted_mux_shell import _shell + + +def key(shell, value): + shell.handle(InputEvent(kind="key", key=value)) + + +def test_hosted_uses_shared_input_and_live_running_state_without_render(): + async def scenario(): + client = _Client() + shell = _shell(client) + await shell.start() + try: + assert isinstance(shell._router, ConversationInputRouter) + window = shell.state.active_window + original_records = list(window.records) + for running, submit_key, expected in ((False, "enter", "start"), + (True, "enter", "steer"), (True, "alt+enter", "follow_up"), + (False, "enter", "start")): + window.running = running + shell.handle(InputEvent(kind="text", text="hello")) + key(shell, submit_key) + assert shell.screen.composer.value == window.draft == "" + await settle(shell) + assert client.calls[-1][0] == expected + assert window.running is running and window.records == original_records + assert not shell.screen.state.pending_steers + assert not shell.screen.state.pending_followups + assert window.request_presentation.state == "acknowledged" + finally: + await shell.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("modal", ["help", "approval", "picker"]) +@pytest.mark.parametrize("pressed", ["tab", "enter", "ctrl+c", "ctrl+d"]) +def test_modal_input_cannot_switch_submit_interrupt_or_detach(modal, pressed): + async def scenario(): + client = _Client() + shell = _shell(client) + await shell.start() + try: + window = shell.state.active_window + window.running = True + if modal == "help": + shell.screen.show_help() + elif modal == "approval": + window.pending_interaction_id = "question" + window.pending_interaction_text = "Sensitive action" + shell.screen.show_approval() + else: + shell.picker.open() + key(shell, pressed) + assert shell.state.active_window is window + assert not shell.exit_requested + assert shell.pending_actions == 0 + assert [name for name, _ in client.calls] == ["attach"] + finally: + await shell.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("running", [False, True]) +def test_rejected_shared_submission_keeps_draft_and_does_not_add_history(monkeypatch, running): + async def scenario(): + shell = _shell(_Client()) + await shell.start() + try: + shell.state.active_window.running = running + editor = shell.screen.composer + history = [] + monkeypatch.setattr(type(editor), "add_history", lambda self, text: history.append(text)) + def refuse(*args, **kwargs): + raise ValueError("action_queue_full") + monkeypatch.setattr(shell._actions, "submit", refuse) + shell.handle(InputEvent(kind="text", text="keep me")) + key(shell, "enter") + assert editor.value == shell.state.active_window.draft == "keep me" + assert history == [] and shell.pending_actions == 0 + assert shell.state.active_window.request_presentation is None + finally: + await shell.close() + + asyncio.run(scenario()) + + +def test_shared_completion_consumes_keys_but_never_enables_product_commands(): + async def scenario(): + client = _Client() + shell = _shell(client) + await shell.start() + try: + editor, window = shell.screen.composer, shell.state.active_window + shell.handle(InputEvent(kind="text", text="/he")) + editor.refresh_completions(force=True) + assert editor.has_completions + key(shell, "tab") + assert shell.state.active_window is window + assert editor.value.strip() == "/help" + key(shell, "enter") + assert shell.screen._detail is not None and not editor.value + key(shell, "escape") + for text in ("/model", "/tools", "/bash"): + editor.set_text(text) + editor.refresh_completions(force=True) + assert not editor.has_completions + key(shell, "enter") + assert editor.value == text and shell.pending_actions == 0 + editor.clear() + shell.handle(InputEvent(kind="text", text="//help")) + key(shell, "enter") + await settle(shell) + assert client.calls[-1][0] == "start" and client.calls[-1][1].text == "/help" + finally: + await shell.close() + + asyncio.run(scenario()) + + +def test_shared_editor_rebind_clears_context_keeps_cursor_and_has_no_late_submit(monkeypatch): + async def scenario(): + client = _Client() + shell = _shell(client) + await shell.start() + try: + first = shell.screen.composer + shell.handle(InputEvent(kind="text", text="abc def")) + first.move_to_line_start() + key(shell, "ctrl+]") + key(shell, "tab") + shell.handle(InputEvent(kind="text", text="second")) + assert shell.screen.composer.value == "second" + key(shell, "shift+tab") + shell.handle(InputEvent(kind="text", text="X")) + assert first.value == "Xabc def" + old_cursor = first._buffer.cursor + await shell._controller.refresh_snapshot() + shell._sync_editor() + assert shell.screen.composer is first and first._buffer.cursor == old_cursor + assert shell._router._jump_mode is None and not first.has_completions + shell.handle(InputEvent(kind="key", key="enter", event_type="release")) + assert shell.pending_actions == 0 and first.value == "Xabc def" + history = [] + original = type(first).add_history + def remember(editor, text): + history.append(text) + original(editor, text) + monkeypatch.setattr(type(first), "add_history", remember) + key(shell, "enter") + await settle(shell) + assert history == ["Xabc def"] + assert first.value == shell.state.active_window.draft == "" + router = shell._router + finally: + await shell.close() + assert router._disposed + + asyncio.run(scenario()) + + +def test_rejected_completed_command_keeps_one_consistent_draft(monkeypatch): + async def scenario(): + shell = _shell(_Client()) + await shell.start() + try: + shell.handle(InputEvent(kind="text", text="/inter")) + shell.screen.composer.refresh_completions(force=True) + assert shell.screen.composer.has_completions + def refuse(*args, **kwargs): + raise ValueError("action_queue_full") + monkeypatch.setattr(shell._actions, "submit", refuse) + key(shell, "enter") + completed = shell.screen.composer.value + assert completed.strip() == "/interrupt" + assert shell.state.active_window.draft == completed + assert not shell.screen.composer._history + assert shell.notice == "action_queue_full" + assert shell.state.active_window.request_presentation is None + key(shell, "tab") + key(shell, "shift+tab") + assert shell.screen.composer.value == completed + assert shell.state.active_window.draft == completed + await shell._controller.refresh_snapshot() + shell._sync_editor() + assert shell.screen.composer.value == shell.state.active_window.draft == completed + finally: + await shell.close() + + asyncio.run(scenario()) diff --git a/tests/harnesstui/test_hosted_mux_markdown.py b/tests/harnesstui/test_hosted_mux_markdown.py new file mode 100644 index 000000000..a0d0e49e7 --- /dev/null +++ b/tests/harnesstui/test_hosted_mux_markdown.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from loushang.appserver.protocol import ( + MuxSelectorV1, + SessionScopeV1, + TranscriptRecordKindV1, + TranscriptRecordV1, +) +from loushang.coding.ui.screen_app import ScreenCodingTuiApp, _terminal_transcript_theme +from loushang.harnesstui.conversation.screen_app import ScreenConversationApp +from loushang.harnesstui.conversation.screen_frame import ( + ScreenFrameCopy, + ScreenFramePresentation, +) +from loushang.harnesstui.mux.shell import HostedMuxShellV1 +from loushang.tui import RenderConstraints, TerminalRuntimeCapabilities +from loushang.tui.cell_width import strip_control_sequences +from loushang.tui.input import InputEvent +from loushang.tui.transcript import AssistantMessageRecord, UserPromptRecord + +from .test_hosted_mux_profile import FINGERPRINT, _Client + +MARKDOWN = "## Result\n\nUse **bold**, `pytest` and [docs](https://example.invalid).\n\n```python\nprint('中文')\n```" + + +class EmbeddedView(ScreenConversationApp): + def _create_frame_presentation(self): + return ScreenFramePresentation(ScreenFrameCopy("Running", "Steer", "", "Follow-up", "")) + + +@pytest.mark.parametrize("width", [24, 80]) +@pytest.mark.parametrize("streaming", [False, True]) +def test_hosted_and_embedded_use_shared_markdown_view(width, streaming): + async def scenario(): + client = _Client() + theme = _terminal_transcript_theme() + shell = HostedMuxShellV1(client, selector=MuxSelectorV1(name="dev"), product_id="coding", + scopes=((SessionScopeV1.CWD, FINGERPRINT),), transcript_theme=theme) + await shell.start() + try: + window = shell.state.active_window + window.records = [TranscriptRecordV1(TranscriptRecordKindV1.USER, "prompt")] + embedded = EmbeddedView(model_label="local", cwd="", branch=None, session_label=None, + transcript_theme=theme) + embedded.state.replace_transcript_window([UserPromptRecord("prompt")]) + if streaming: + window.running = True + window.assistant_draft = MARKDOWN + embedded.append_assistant_chunk(MARKDOWN) + else: + window.records.append(TranscriptRecordV1(TranscriptRecordKindV1.ASSISTANT, MARKDOWN)) + embedded.state.records.append(AssistantMessageRecord(MARKDOWN, stable=True)) + embedded.state.records_revision += 1 + window.last_cursor += 1 + capabilities = TerminalRuntimeCapabilities(hyperlinks=False) + embedded.terminal_capabilities = shell.screen.terminal_capabilities = capabilities + constraints = RenderConstraints(width=width, max_height=40) + shell.screen.render(constraints).validate(constraints) + embedded.render(constraints).validate(constraints) + # Both bindings feed the same retained transcript renderer. Compare + # rendered content, not just inheritance or a non-None theme. + hosted = shell.screen._transcript_region.render(constraints) + local = embedded._transcript_region.render(constraints) + assert hosted.lines == local.lines + text = "\n".join(strip_control_sequences(line.text) for line in hosted.lines) + assert "Result" in text and "## Result" not in text and "**bold**" not in text + assert "中文" in text and "pytest" in text + assert not any("\x1b]8;" in line.text for line in hosted.lines) + assert (window.assistant_draft if streaming else window.records[-1].text) == MARKDOWN + original_region = shell.screen._transcript_region + shell.handle(InputEvent(kind="text", text="draft one")) + shell.handle(InputEvent(kind="key", key="tab")) + shell.screen.render(constraints).validate(constraints) + shell.handle(InputEvent(kind="key", key="tab")) + shell.screen.render(constraints).validate(constraints) + assert shell.screen.composer.value == "draft one" + assert shell.screen._transcript_region is original_region + finally: + await shell.close() + assert [name for name, _ in client.calls] == ["attach", "detach"] + + asyncio.run(scenario()) + + +def test_embedded_keeps_shared_default_transcript_theme(): + from loushang.harnesstui.conversation.theme import terminal_transcript_theme + + assert _terminal_transcript_theme is terminal_transcript_theme + app = ScreenCodingTuiApp(model_label=None, cwd="", branch=None, session_label=None) + assert app.transcript_theme.defaults == terminal_transcript_theme().defaults + + +@pytest.mark.parametrize("hyperlinks", [False, True]) +def test_hosted_markdown_sanitizes_wire_text_and_respects_terminal_link_capability(hyperlinks): + async def scenario(): + theme = _terminal_transcript_theme() + theme.update_overrides({"markdown.link": {"hyperlink": True}}) + shell = HostedMuxShellV1(_Client(), selector=MuxSelectorV1(name="dev"), product_id="coding", + scopes=((SessionScopeV1.CWD, FINGERPRINT),), transcript_theme=theme) + await shell.start() + try: + payload = "## Safe\x1b[2J\n\x1b]52;c;secret\x07[docs](https://example.invalid)" + window = shell.state.active_window + window.records = [TranscriptRecordV1(TranscriptRecordKindV1.ASSISTANT, payload)] + window.last_cursor += 1 + shell.screen.terminal_capabilities = TerminalRuntimeCapabilities(hyperlinks=hyperlinks) + result = shell.screen.render(RenderConstraints(width=100, max_height=24)) + text = "\n".join(line.text for line in result.lines) + assert "\x1b[2J" not in text and "\x1b]52;" not in text and "secret" not in text + assert ("\x1b]8;" in text) == hyperlinks + assert "Safe" in text and "## Safe" not in text + assert window.records[0].text == payload + finally: + await shell.close() + + asyncio.run(scenario()) diff --git a/tests/harnesstui/test_hosted_mux_requests.py b/tests/harnesstui/test_hosted_mux_requests.py new file mode 100644 index 000000000..448ef0a37 --- /dev/null +++ b/tests/harnesstui/test_hosted_mux_requests.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from loushang.appserver.protocol import AckV1 +from loushang.tui import RenderConstraints +from loushang.tui.input import InputEvent + +from .test_hosted_mux_profile import _Client +from .test_hosted_mux_shell import _shell + + +async def settle(shell): + async with asyncio.timeout(2): + while shell.pending_actions: + await asyncio.sleep(0) + + +def send(shell, text): + shell.handle(InputEvent(kind="text", text=text)) + shell.handle(InputEvent(kind="key", key="enter")) + + +def test_ack_clears_pending_without_claiming_execution_completed(): + async def scenario(): + shell = _shell(_Client()) + await shell.start() + try: + window = shell.state.active_window + window.running = True + send(shell, "prompt") + assert window.request_presentation.state == "pending" + await settle(shell) + assert window.request_presentation.state == "acknowledged" + assert window.running + result = shell.screen.render(RenderConstraints(width=160, max_height=24)) + text = "\n".join(line.text for line in result.lines) + assert "request_acknowledged" in text and "request_pending" not in text + assert "running" in text + finally: + await shell.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("old_fails", [False, True]) +def test_late_old_result_does_not_overwrite_new_request(old_fails): + async def scenario(): + client = _Client() + entered = [asyncio.Event(), asyncio.Event()] + release = [asyncio.Event(), asyncio.Event()] + + async def operation(request): + index = int(request.text) + entered[index].set() + await release[index].wait() + if index == 0 and old_fails: + raise RuntimeError("private old failure") + return AckV1() + + client.start_turn = operation + shell = _shell(client) + await shell.start() + try: + send(shell, "0") + await entered[0].wait() + send(shell, "1") + await entered[1].wait() + current = shell.state.active_window.request_presentation + release[0].set() + async with asyncio.timeout(2): + while shell.pending_actions != 1: + await asyncio.sleep(0) + assert shell.state.active_window.request_presentation is current + assert "unknown" not in shell.notice + release[1].set() + await settle(shell) + assert shell.state.active_window.request_presentation.state == "acknowledged" + finally: + await shell.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("refresh", [False, True]) +def test_reply_is_bound_to_original_attachment_and_member(refresh): + async def scenario(): + client = _Client() + entered, release = asyncio.Event(), asyncio.Event() + + async def operation(request): + entered.set() + await release.wait() + return AckV1() + + client.start_turn = operation + shell = _shell(client) + await shell.start() + try: + send(shell, "prompt") + await entered.wait() + old = shell.state.active_window + if refresh: + await shell._controller.refresh_snapshot() + shell._sync_editor() + else: + shell.handle(InputEvent(kind="key", key="tab")) + assert shell.state.active_window.request_presentation is None + release.set() + await settle(shell) + assert shell.state.active_window.request_presentation is None + assert old.request_presentation.state == ("pending" if refresh else "acknowledged") + finally: + await shell.close() + + asyncio.run(scenario()) + + +def test_unconfirmed_result_is_not_cleared_by_idle_or_retried(): + async def scenario(): + client = _Client() + calls = [] + + async def operation(request): + calls.append(request) + raise RuntimeError("private transport failure") + + client.start_turn = operation + shell = _shell(client) + await shell.start() + try: + send(shell, "prompt") + await settle(shell) + window = shell.state.active_window + assert not window.running and window.request_presentation.state == "unknown" + await shell.poll() + text = "\n".join(line.text for line in shell.screen.render( + RenderConstraints(width=160, max_height=24)).lines) + assert "result unconfirmed, no retry" in text and "private" not in text + assert len(calls) == 1 + finally: + await shell.close() + + asyncio.run(scenario()) + + +def test_task_publication_failure_does_not_publish_pending(monkeypatch): + async def scenario(): + shell = _shell(_Client()) + await shell.start() + + def refused(*args, **kwargs): + raise ValueError("action_queue_full") + + monkeypatch.setattr(shell._actions, "submit", refused) + try: + send(shell, "kept") + assert shell.state.active_window.request_presentation is None + assert shell.screen.composer.value == "kept" + assert shell.notice == "action_queue_full" + finally: + await shell.close() + + asyncio.run(scenario()) + + +def test_hosted_action_binding_rejects_attachments_before_wire(): + from loushang.harnesstui.conversation.control import ConversationTextAction + from loushang.harnesstui.mux.conversation_binding import ( + HostedConversationActionBinding, + ) + + async def scenario(): + client = _Client() + presented = [] + binding = HostedConversationActionBinding(client, attachment_id="attachment-1", generation=1, + member_id="member-1", present=presented.append) + with pytest.raises(ValueError, match="image_paste_unavailable"): + await binding.submit(ConversationTextAction("prompt", attachments=(object(),))) + assert client.calls == [] and presented == [] + + asyncio.run(scenario()) + + +def test_invalid_whitespace_prompt_is_rejected_before_task_and_keeps_draft(): + async def scenario(): + client = _Client() + shell = _shell(client) + await shell.start() + try: + send(shell, " ") + assert shell.pending_actions == 0 + assert shell.screen.composer.value == " " + assert shell.state.active_window.request_presentation is None + assert [name for name, _ in client.calls] == ["attach"] + finally: + await shell.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("change", ["tab", "request", "refresh"]) +@pytest.mark.parametrize("fails", [False, True]) +def test_interrupt_response_uses_same_scoped_delivery_and_control_slot(change, fails): + async def scenario(): + client = _Client() + entered, release = asyncio.Event(), asyncio.Event() + + async def operation(request): + entered.set() + await release.wait() + if fails: + raise RuntimeError("private old interrupt failure") + return AckV1() + + client.interrupt_turn = operation + shell = _shell(client) + await shell.start() + try: + old = shell.state.active_window + old.running = True + shell.handle(InputEvent(kind="key", key="ctrl+c")) + await entered.wait() + assert old.request_presentation.operation == "interrupt" + assert old.request_presentation.state == "pending" + assert tuple(shell._actions._tasks.values()) == (True,) + if change == "tab": + shell.handle(InputEvent(kind="key", key="tab")) + elif change == "request": + send(shell, "new") + else: + await shell._controller.refresh_snapshot() + shell._sync_editor() + release.set() + await settle(shell) + assert "unknown" not in shell.notice + current = shell.state.active_window.request_presentation + if change == "request": + assert current.operation == "steer" and current.state == "acknowledged" + else: + assert current is None + if change == "tab": + assert old.request_presentation.state == ("unknown" if fails else "acknowledged") + assert old.running # Only authoritative execution events may change this. + finally: + await shell.close() + + asyncio.run(scenario()) diff --git a/tests/harnesstui/test_hosted_mux_settlement.py b/tests/harnesstui/test_hosted_mux_settlement.py index 4b7347d5f..b7dc89ff7 100644 --- a/tests/harnesstui/test_hosted_mux_settlement.py +++ b/tests/harnesstui/test_hosted_mux_settlement.py @@ -365,7 +365,8 @@ async def scenario(): assert ("ends this application" in text) is foreground assert ("accepted work continues" in text) is not foreground assert ("no background management endpoint" in text) is foreground - assert ("create/list/attach/close/stop" in text) is not foreground + assert ("lmux new -s NAME" in text) is not foreground + assert ("lmux create --continue" in text) is not foreground shell.handle(InputEvent(kind="key", key="escape")) shell.handle(InputEvent(kind="text", text="/exit")) shell.handle(InputEvent(kind="key", key="enter")) diff --git a/tests/harnesstui/test_hosted_session_picker.py b/tests/harnesstui/test_hosted_session_picker.py index 654d854ad..87703907b 100644 --- a/tests/harnesstui/test_hosted_session_picker.py +++ b/tests/harnesstui/test_hosted_session_picker.py @@ -294,27 +294,39 @@ async def query(request): _text(shell, "kept") editor = shell.screen.composer _key(shell, "f3") - await asyncio.wait_for(entered.wait(), 1) - _key(shell, "ctrl+c") - async with asyncio.timeout(1): - while not any(name == "interrupt" for name, _ in client.calls): - await asyncio.sleep(0) - window = shell.state.active_window - window.pending_interaction_id = "question" - window.pending_interaction_text = "Inspect this action" - _key(shell, "f2") - assert not shell.picker.visible - assert "Inspect this action" in _render(shell) - assert shell.screen.composer is editor and editor.value == "kept" - with pytest.raises(AppServiceError) as debt: + try: + await asyncio.wait_for(entered.wait(), 1) + shell.state.active_window.running = True + _key(shell, "ctrl+c") + assert not any(name == "interrupt" for name, _ in client.calls) + assert shell.picker.visible + _key(shell, "escape") + _key(shell, "ctrl+c") + async with asyncio.timeout(1): + while not any(name == "interrupt" for name, _ in client.calls): + await asyncio.sleep(0) + window = shell.state.active_window + window.pending_interaction_id = "question" + window.pending_interaction_text = "Inspect this action" + _key(shell, "f2") + assert not shell.picker.visible + assert "Inspect this action" in _render(shell) + assert shell.screen.composer is editor and editor.value == "kept" + with pytest.raises(AppServiceError) as debt: + await shell.close() + assert debt.value.code is AppErrorCodeV1.CLEANUP_INCOMPLETE + deadline = shell._deadline + assert cancelled.is_set() and shell.cleanup_pending + assert shell.pending_actions == 1 + assert not any(name == "detach" for name, _ in client.calls) + finally: + # A failed assertion must not strand the deliberately resistant + # query during asyncio.run's cancellation sweep. + release.set() + for task in tuple(shell._actions._tasks): + task.cancel() + await _settle(shell) await shell.close() - assert debt.value.code is AppErrorCodeV1.CLEANUP_INCOMPLETE - deadline = shell._deadline - assert cancelled.is_set() and shell.cleanup_pending - assert shell.pending_actions == 1 - assert not any(name == "detach" for name, _ in client.calls) - release.set() - await _settle(shell) assert shell.picker.page is None and not shell.picker.visible await shell.close() assert shell._deadline == deadline and not shell.cleanup_pending @@ -354,6 +366,8 @@ async def query(request): assert shell.notice == "action_queue_full" assert shell.picker.page is None assert len(discovery.requests) == 2 + _key(shell, "escape") + shell.state.active_window.running = True _key(shell, "ctrl+c") assert shell.pending_actions == 57 # The reserved control remains usable. finally: From 78b5d5c5984a7affe4970fdcb109bd9ff400b718 Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 06:48:31 +0000 Subject: [PATCH 29/44] feat(coding): add managed lmux CLI --- pyproject.toml | 1 + src/loushang/coding/cli/lmux.py | 116 +++ src/loushang/coding/cli/lmux_command.py | 810 ++++++++++++++++++ src/loushang/coding/cli/lmux_stop_all.py | 125 +++ .../test_detachable_local_workspace_g16.py | 31 +- tests/coding/test_lmux_command.py | 738 ++++++++++++++++ tests/coding/test_lmux_creation_recovery.py | 317 +++++++ tests/coding/test_lmux_logs.py | 98 +++ tests/coding/test_lmux_selector.py | 256 ++++++ tests/coding/test_lmux_server_start.py | 187 ++++ tests/coding/test_lmux_service_aliases.py | 128 +++ tests/coding/test_lmux_status.py | 138 +++ tests/coding/test_lmux_status_summary.py | 83 ++ tests/coding/test_lmux_stop_all.py | 356 ++++++++ .../coding/test_managed_process_arguments.py | 2 +- 15 files changed, 3378 insertions(+), 8 deletions(-) create mode 100644 src/loushang/coding/cli/lmux.py create mode 100644 src/loushang/coding/cli/lmux_command.py create mode 100644 src/loushang/coding/cli/lmux_stop_all.py create mode 100644 tests/coding/test_lmux_command.py create mode 100644 tests/coding/test_lmux_creation_recovery.py create mode 100644 tests/coding/test_lmux_logs.py create mode 100644 tests/coding/test_lmux_selector.py create mode 100644 tests/coding/test_lmux_server_start.py create mode 100644 tests/coding/test_lmux_service_aliases.py create mode 100644 tests/coding/test_lmux_status.py create mode 100644 tests/coding/test_lmux_status_summary.py create mode 100644 tests/coding/test_lmux_stop_all.py diff --git a/pyproject.toml b/pyproject.toml index 2fbece4fa..51fc57f13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ loushang-tui = "loushang.coding.ui.cli:main" loushang-hosted = "loushang.coding.cli.hosted:main" loushang-hosted-tui = "loushang.coding.cli.hosted_client:main" loushang-mux = "loushang.coding.cli.mux:main" +lmux = "loushang.coding.cli.lmux:main" loushang-plugin = "loushang.plugin.__main__:main" [project.optional-dependencies] diff --git a/src/loushang/coding/cli/lmux.py b/src/loushang/coding/cli/lmux.py new file mode 100644 index 000000000..d140fef6f --- /dev/null +++ b/src/loushang/coding/cli/lmux.py @@ -0,0 +1,116 @@ +"""Short Linux managed-mux entry; parse and terminal checks precede native IO.""" + +from __future__ import annotations + +import argparse +import sys +from collections.abc import Sequence + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="lmux", description="Linux named workspace muxes (managed preview).") + commands = parser.add_subparsers(dest="action") + new = commands.add_parser("new", help="create a named mux, start/reuse its service, and attach") + new.add_argument("-s", "--name", required=True) + new.add_argument("--workspace", help="workspace for the new mux; defaults to cwd") + creation = commands.add_parser("create", help="query an exact previous creation, or explicitly continue it") + creation.add_argument("--server", required=True, help="exact original service ID (not an alias)") + creation.add_argument("--operation", required=True, help="original creation operation ID") + creation.add_argument("--continue", dest="continue_create", action="store_true", + help="confirm and continue the same creation once; never attach automatically") + creation.add_argument("--yes", action="store_true", help="confirm continuing this exact creation") + creation_status = commands.add_parser("create-status", help="read-only recorded creation fact; never start or resend") + creation_status.add_argument("--server", required=True) + creation_status.add_argument("--operation", required=True) + attach = commands.add_parser("attach", help="reconnect by global name; never start a service") + attach.add_argument("-t", "--target") + listing = commands.add_parser("ls", help="read-only global reservations and recorded state (not liveness)") + listing.add_argument("--after", help="page after this global name") + diagnostic = commands.add_parser("status", help="read recorded service state and paths; never start or probe readiness") + selected = diagnostic.add_mutually_exclusive_group() + selected.add_argument("-t", "--target", help="global mux name, independent of cwd") + selected.add_argument("--server", help="service alias or exact service ID, including services without mux names") + logs = commands.add_parser("logs", help="read bounded lifecycle log tails; never create or repair logs") + selected_logs = logs.add_mutually_exclusive_group(required=True) + selected_logs.add_argument("-t", "--target") + selected_logs.add_argument("--server", help="service alias or exact service ID") + logs.add_argument("--limit", type=int, default=50, help="maximum visible events, 1..100 (default: 50)") + start = commands.add_parser("start", help="explicitly start/reuse a named mux's service; never replay mux creation") + start.add_argument("-t", "--target", required=True) + server = commands.add_parser("server", help="manage a workspace service independently of mux names") + server_commands = server.add_subparsers(dest="server_action", required=True) + server_start = server_commands.add_parser("start", help="start/reuse a workspace service without creating a mux") + server_start.add_argument("--workspace", help="service workspace; defaults to cwd") + server_start.add_argument("--name", help="optional stable service alias (separate from mux names)") + server_start.add_argument("--trace-for", type=int, metavar="SECONDS", + help="request bounded trace for this new instance only, 1..3600 seconds") + stop = commands.add_parser("stop", help="gracefully stop a selected service; never force-kill") + stopped = stop.add_mutually_exclusive_group(required=True) + stopped.add_argument("--server", help="service alias or exact service ID") + stopped.add_argument("--all", action="store_true", help="stop only the confirmed namespace instance snapshot") + stop.add_argument("--yes", action="store_true", help="confirm stopping this service and all its muxes") + close = commands.add_parser("close", help="close one mux, or reconcile an exact previous close; keep Session files") + target = close.add_mutually_exclusive_group(required=True) + target.add_argument("-t", "--target") + target.add_argument("--server") + close.add_argument("--operation", help="exact previous close operation ID; requires --server") + close.add_argument("--continue", dest="continue_close", action="store_true", + help="explicitly resend the same idempotent close; requires --server and --operation") + close.add_argument("--yes", action="store_true", help="confirm this exact close or reconciliation") + status = commands.add_parser("close-status", help="read-only close result; never start, renew, close, or reconcile") + status.add_argument("--server", required=True) + status.add_argument("--operation", required=True) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = _parser() + args = parser.parse_args(argv) + if args.action == "logs" and not 1 <= args.limit <= 100: + parser.error("--limit must be between 1 and 100") + if getattr(args, "trace_for", None) is not None and not 1 <= args.trace_for <= 3600: + parser.error("--trace-for must be between 1 and 3600 seconds") + if args.action in (None, "new", "attach") and (not sys.stdin.isatty() or not sys.stdout.isatty()): + parser.error("interactive lmux requires a terminal on stdin and stdout") + if args.action in ("stop", "close") and not args.yes and (not sys.stdin.isatty() or not sys.stdout.isatty()): + parser.error("non-interactive " + args.action + " requires --yes") + if args.action == "create": + if args.yes and not args.continue_create: + parser.error("--yes requires --continue") + if args.continue_create and not args.yes and (not sys.stdin.isatty() or not sys.stdout.isatty()): + parser.error("non-interactive create --continue requires --yes") + if args.action == "close" and (bool(args.server) != bool(args.operation) or args.continue_close and not args.operation): + parser.error("--operation requires --server; --continue requires both") + from loushang.apphost.managed.contracts import ( + ManagedContractError, + require_mux_name, + require_service_alias, + ) + + for name in (getattr(args, "name", None), getattr(args, "target", None), getattr(args, "after", None)): + if name is not None: + try: + (require_service_alias if args.action == "server" else require_mux_name)(name) + except ManagedContractError: + parser.error("invalid service alias" if args.action == "server" else "invalid mux name") + if args.action in ("stop", "close", "close-status", "create", "create-status", "status", "logs"): + from loushang.apphost.managed.contracts import _HEX32, _HEX64, _match + + try: + if args.server is not None: + if args.action in ("stop", "status", "logs") and _HEX64.fullmatch(args.server) is None: + require_service_alias(args.server) + else: + _match(args.server, _HEX64) + if getattr(args, "operation", None) is not None: + _match(args.operation, _HEX32) + except ManagedContractError: + parser.error("expected service alias or exact service ID" if args.action in ("stop", "status", "logs") + else "expected exact service and operation IDs") + from .lmux_command import execute + + return execute(args, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/loushang/coding/cli/lmux_command.py b/src/loushang/coding/cli/lmux_command.py new file mode 100644 index 000000000..8720f139e --- /dev/null +++ b/src/loushang/coding/cli/lmux_command.py @@ -0,0 +1,810 @@ +"""Coding CLI composition over public managed discovery/start/connection owners. + +Synchronous admission and selection precede the event loop. No native worker +outlives its borrowed journal; terminal detach closes local resources only. +The thin HostedMuxShell is an interim UI, not the final full conversation view. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +from pathlib import Path +from secrets import token_hex +from time import monotonic +from typing import TYPE_CHECKING, TextIO + +from loushang.apphost.managed._files import ( + ManagedStorageError, + PrivateManagedDirectory, + _check_deadline, +) +from loushang.apphost.managed.connection import ( + ManagedConnectionLeaseV1, + _settled_native, +) +from loushang.apphost.managed.contracts import ( + _HEX64, + ManagedContractError, + ManagedHandoffPhaseV1, + ManagedServiceKeyV1, +) +from loushang.apphost.managed.coordinator import ManagedServiceCoordinatorV1 +from loushang.apphost.managed.defaults import ( + ManagedDefaultsV1, + resolve_managed_defaults, +) +from loushang.apphost.managed.discovery import ( + ManagedDiscoveryV1, + ManagedMuxObservationV1, +) +from loushang.apphost.managed.event_log import ManagedLifecycleLogV1 +from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 +from loushang.apphost.managed.mux_creation import ManagedMuxCreateOperationV1 +from loushang.apphost.managed.mux_management import ( + ManagedMuxInspectionV1, + ManagedMuxManagerV1, +) +from loushang.apphost.managed.mux_probe import ( + ManagedMuxProbeOperationV1, + ManagedMuxProbeSnapshotV1, +) +from loushang.apphost.managed.namespace_admission import ManagedNamespaceAdmissionV1 +from loushang.apphost.managed.paths import resolve_managed_service_paths +from loushang.apphost.managed.registry import ( + MAX_PAGE, + ManagedMuxCreationInspectionV1, + ManagedMuxReservationV1, + ManagedServiceAliasReservationV1, +) +from loushang.apphost.managed.service_admission import ManagedServiceAdmissionV1 +from loushang.apphost.managed.stopper import ManagedServiceStopOperationV1 +from loushang.apphost.managed.storage_budget import ManagedStorageBudgetV1 +from loushang.appserver.managed_mux_close import ( + ManagedMuxClosePhaseV1, + ManagedMuxCloseStateV1, + ManagedMuxCloseV1, +) +from loushang.appserver.protocol import MuxSelectorV1 + +from ..managed_process import APPLICATION_ID, ENDPOINT, coding_managed_process_request +from .lmux_stop_all import StopAll +from .mux import _execute + +if TYPE_CHECKING: + from loushang.harnesstui.mux.shell import HostedMuxShellV1 + + +def _emit(value: object, output: TextIO) -> None: + # Escape workspace/control characters before any terminal output. + print(json.dumps(value, ensure_ascii=True, sort_keys=True), file=output, flush=True) + + +def _observation(value: ManagedMuxObservationV1) -> dict[str, object]: + return {"name": value.name, "workspace": value.service.workspace, "serviceId": value.service.service_id, + "creationOperationId": value.reservation.operation_id, + "recordedPhase": None if value.recorded_phase is None else value.recorded_phase.value, + "stopRequested": value.stop_requested, "cleanlyStopped": value.cleanly_stopped, + "status": "unknown", "tabs": None} + + +def _single_candidate(page: tuple[ManagedMuxObservationV1, ...]) -> ManagedMuxObservationV1 | None: + """Choose a sole recorded candidate, never certify it as online.""" + if len(page) != 1 or len(page) >= MAX_PAGE: + return None + item = page[0] + if (item.service.product_id != "coding" or item.instance is None + or item.recorded_phase is not ManagedHandoffPhaseV1.COMMITTED + or item.stop_requested or item.cleanly_stopped): + return None + return item + + +class ManagedMuxCommand: + """Process-local original owners; retain through all errors and cleanup.""" + + def __init__(self, defaults: ManagedDefaultsV1, environment: dict[str, str], *, stdin: TextIO, stdout: TextIO) -> None: + self.defaults, self.environment = defaults, dict(environment) + self.stdin, self.stdout = stdin, stdout + self.namespace: ManagedNamespaceAdmissionV1 | None = None + self.service: ManagedServiceAdmissionV1 | None = None + self.journal: ManagedServiceJournalV1 | None = None + self.log_directory: PrivateManagedDirectory | None = None + self.creation: ManagedMuxCreateOperationV1 | None = None + self.creation_recovery = False + self.creation_reservation: ManagedMuxReservationV1 | None = None + self.starter: ManagedServiceCoordinatorV1 | None = None + self.alias_reservation: ManagedServiceAliasReservationV1 | None = None + self.stopper: ManagedServiceStopOperationV1 | None = None + self.stop_all: StopAll | None = None + self.connection: ManagedConnectionLeaseV1 | None = None + self.shell: HostedMuxShellV1 | None = None + self.target: str | None = None + self.mux_space_id: str | None = None + self.close_manager: ManagedMuxManagerV1 | None = None + self.close_inspection: ManagedMuxInspectionV1 | None = None + self.close_reservation: ManagedMuxReservationV1 | None = None + self.close_operation: str | None = None + self.close_instance_id: str | None = None + self.close_request: ManagedMuxCloseV1 | None = None + self.close_result: ManagedMuxCloseStateV1 | None = None + self.close_readonly = self.close_continue = self.close_by_name = False + self.deadline = 0.0 + self.active = False + self.native_closed = False + self.trace_deadline_ms: int | None = None + self.result_status = 0 + self.probe: ManagedMuxProbeOperationV1 | None = None + self.probe_result: ManagedMuxProbeSnapshotV1 | None = None + self.probe_entered = False + self.probed_mux_id: str | None = None + + @property + def cleanup_pending(self) -> bool: + return self.active or not self.native_closed + + def finish_native_if_idle(self) -> bool: + # Runner.run can reject before entering our coroutine/finally. In that + # case no async borrower exists, but synchronous admission still owns IO. + if not self.active: + try: + self.close_native() + except BaseException: + return True + return self.cleanup_pending + + def prepare(self, args: argparse.Namespace) -> bool: + self.deadline = monotonic() + 30 + trace_for = getattr(args, "trace_for", None) + if trace_for is not None: + if args.action != "server" or type(trace_for) is not int or not 1 <= trace_for <= 3600: + raise ManagedContractError() + self.trace_deadline_ms = int((monotonic() + trace_for) * 1000) + selected_service = None + if args.action in (None, "new", "server"): + workspace = Path(getattr(args, "workspace", None) or Path.cwd()).expanduser().resolve(strict=True) + if not workspace.is_dir(): + raise ManagedContractError() + selected_service = ManagedServiceKeyV1("coding", str(workspace)) + self.namespace = ManagedNamespaceAdmissionV1( + self.defaults.namespace, runtime_root=str(self.defaults.platform.runtime), + create_if_missing=args.action in (None, "new", "server"), + ) + try: + registry = self.namespace.open(deadline=self.deadline, wait_for_lock=True) + except ManagedStorageError as error: + if error.code == "not_found" and args.action == "status" and args.server is None and args.target is None: + _emit({"services": [], "observation": "recorded_only", "liveStatus": "not_probed"}, self.stdout) + return False + if error.code != "not_found" or args.action != "ls": + raise + _emit({"muxes": [], "nextAfter": None}, self.stdout) + return False + discovery = ManagedDiscoveryV1(registry, self.defaults.namespace) + if args.action in ("stop", "status", "logs") and args.server is not None and _HEX64.fullmatch(args.server) is None: + alias = registry.resolve_service_alias(args.server, deadline=self.deadline, wait_for_lock=True) + if alias is None: + raise ManagedStorageError("not_found") + # Resolve exactly once; all later confirmation and effects use this + # immutable service identity, never a mutable display selector. + args.server = alias.service.service_id + if args.action == "server": + assert selected_service is not None + if args.name is not None: + existing = registry.resolve_service_alias(args.name, deadline=self.deadline, wait_for_lock=True) + if existing is not None: + if existing.service != selected_service: + raise ManagedStorageError("conflict") + self.alias_reservation = existing + else: + self.alias_reservation = ManagedServiceAliasReservationV1(args.name, selected_service, token_hex(16)) + try: + registry.reserve_service_alias(self.alias_reservation, deadline=self.deadline, wait_for_lock=True) + except ManagedStorageError as error: + if error.code != "conflict" or registry.cleanup_pending: + raise + # Another first caller may have won this same mapping. + # Reconcile once, read-only; never retry a new intent or + # adopt a different workspace/profile after a conflict. + winner = registry.resolve_service_alias(args.name, deadline=self.deadline, wait_for_lock=True) + if winner is None or winner.service != selected_service: + raise + self.alias_reservation = winner + self.service = ManagedServiceAdmissionV1(self.namespace, selected_service) + self.journal = self.service.open(deadline=self.deadline, wait_for_lock=True) + self._prepare_starter(selected_service) + return True + if args.action in ("status", "logs"): + return self._prepare_status(args, discovery) + if args.action == "ls": + page = discovery.list_muxes(deadline=self.deadline, after=args.after, wait_for_lock=True) + _emit({"muxes": [_observation(item) for item in page], + "nextAfter": page[-1].name if len(page) == MAX_PAGE else None}, self.stdout) + return False + if args.action == "stop": + if getattr(args, "all", False): + return self._prepare_stop_all(args, discovery) + return self._prepare_stop(args, discovery) + if args.action in ("close", "close-status"): + return self._prepare_close(args, discovery) + if args.action in ("create", "create-status"): + return self._prepare_creation_recovery(args) + if args.action == "new": + assert selected_service is not None + self._new(args.name, selected_service) + return True + target = getattr(args, "target", None) + selected = None + if target is None: + page = discovery.list_muxes(deadline=self.deadline, wait_for_lock=True) + if not page: + if args.action is None: + assert selected_service is not None + self._new("main", selected_service) + return True + raise ManagedStorageError("not_found") + selected = _single_candidate(page) + if selected is None: + snapshot = self._probe_snapshot() + unique = snapshot.unique_present + selected = unique.observation if unique is not None else self._select_probe(snapshot) + if selected is None: + return False + if self.probe_result is not None: + self.probed_mux_id = next((row.mux_space_id for row in self.probe_result.results + if row.observation == selected), None) + target = selected.name + observed = discovery.resolve(target, deadline=self.deadline, wait_for_lock=True) + if observed is None: + raise ManagedStorageError("not_found") + if selected is not None and (observed.reservation != selected.reservation or observed.instance != selected.instance): + raise ManagedStorageError("conflict") + if args.action != "start" and (observed.instance is None or observed.stop_requested or observed.cleanly_stopped): + raise ManagedStorageError("unavailable") + if observed.service.product_id != "coding": + raise ManagedStorageError("conflict") + paths = resolve_managed_service_paths(self.defaults.namespace, observed.service, + runtime_root=str(self.defaults.platform.runtime)) + self.journal = ManagedServiceJournalV1(registry, self.defaults.namespace, observed.service, + Path(paths.lifecycle), defer_open=True) + self.journal.open(deadline=self.deadline, wait_for_lock=True) + self.target = observed.name + if args.action == "start": + self._prepare_starter(observed.service) + return True + assert observed.instance is not None + manager = ManagedMuxManagerV1(registry, self.journal, self.defaults.namespace, + observed.service, observed.instance, application_id=APPLICATION_ID) + inspection = manager.inspect_mux(observed.reservation, deadline=self.deadline, wait_for_lock=True) + self.mux_space_id = inspection.creation.mux_space_id + if self.probed_mux_id is not None and self.mux_space_id != self.probed_mux_id: + raise ManagedStorageError("conflict") + self.connection = ManagedConnectionLeaseV1( + self.journal, self.defaults.namespace, observed.service, observed.instance, + runtime_root=str(self.defaults.platform.runtime), endpoint=ENDPOINT, + ) + return True + + def _prepare_starter(self, service: ManagedServiceKeyV1) -> None: + assert self.journal is not None + self.starter = ManagedServiceCoordinatorV1( + self.journal, self.defaults.namespace, service, + runtime_root=str(self.defaults.platform.runtime), endpoint=ENDPOINT, + temporary_override=self.defaults.temporary_override, + trace_deadline_ms=self.trace_deadline_ms, + request_factory=lambda invocation, descriptor: coding_managed_process_request( + invocation, descriptor, executable=sys.executable, environment=self.environment, + ), + ) + + def _prepare_status(self, args: argparse.Namespace, discovery: ManagedDiscoveryV1) -> bool: + service_id = args.server + if args.action == "status" and service_id is None and args.target is None: + snapshot = discovery.snapshot_namespace(deadline=self.deadline, wait_for_lock=True) + muxes: dict[str, list[str]] = {} + for item in snapshot.muxes: + muxes.setdefault(item.service.service_id, []).append(item.name) + services = [{"serviceId": item.service.service_id, "productId": item.service.product_id, + "workspace": item.service.workspace, "profile": item.service.profile, + "instanceId": None if item.instance is None else item.instance.instance_id, + "revision": item.revision, + "recordedPhase": None if item.recorded_phase is None else item.recorded_phase.value, + "stopRequested": item.stop_requested, "cleanlyStopped": item.cleanly_stopped, + "reservedMuxes": muxes.get(item.service.service_id, [])} for item in snapshot.services] + _check_deadline(self.deadline) + _emit({"services": services, "observation": "recorded_only", "liveStatus": "not_probed"}, self.stdout) + return False + if args.target is not None: + named = discovery.resolve(args.target, deadline=self.deadline, wait_for_lock=True) + if named is None: + raise ManagedStorageError("not_found") + service_id = named.service.service_id + observed = discovery.inspect_service(service_id, deadline=self.deadline, wait_for_lock=True) + if observed is None: + raise ManagedStorageError("not_found") + paths = resolve_managed_service_paths(self.defaults.namespace, observed.service, + runtime_root=str(self.defaults.platform.runtime)) + if args.action == "logs": + assert self.namespace is not None + self.log_directory = PrivateManagedDirectory(Path(paths.logs), defer_open=True) + self.log_directory.open(deadline=self.deadline) + reader = ManagedLifecycleLogV1(self.log_directory, ManagedStorageBudgetV1(self.namespace.registry), service_id) + records = reader.read_tail(limit=args.limit, deadline=self.deadline) + _emit({"serviceId": service_id, "observation": "bounded_tail", "completeHistory": False, + "scan": {"maxSegments": 5, "maxBytesPerSegment": 16384}, + "events": [{"sequence": record.sequence, "instanceId": record.event.instance_id, + "event": record.event.event, "code": record.event.code} for record in records]}, self.stdout) + return False + _emit({"serviceId": service_id, "productId": observed.service.product_id, + "workspace": observed.service.workspace, "profile": observed.service.profile, + "instanceId": None if observed.instance is None else observed.instance.instance_id, + "revision": observed.revision, + "recordedPhase": None if observed.recorded_phase is None else observed.recorded_phase.value, + "stopRequested": observed.stop_requested, "cleanlyStopped": observed.cleanly_stopped, + "liveStatus": "not_probed", "observation": "recorded_only", + "paths": {"logs": str(paths.logs), "application": str(paths.application)}, + "temporary": {"defaultBase": str(paths.logs.parent / "tmp"), "actualRoot": None, + "reason": "instance_override_not_recorded"}}, self.stdout) + return False + + def _close_output(self, status: str, *, reconciled: bool = False, historical: bool = False) -> None: + assert self.close_inspection is not None and self.close_reservation is not None + creation = self.close_inspection.creation + result = self.close_result or self.close_inspection.close_result + followup: dict[str, object] = {} + if status in ("unknown", "reauthorization_required"): + command = ["lmux", "close", "--server", self.close_reservation.service.service_id, + "--operation", self.close_operation] + if status == "unknown": + command.append("--continue") + followup = {"nextCommand": command, "requiresConfirmation": True, + "automaticReplay": False} + _emit({"status": status, "serviceId": self.close_reservation.service.service_id, + "observedInstanceId": self.close_instance_id, + "originInstanceId": None if result is None else result.instance_id, + "operationId": self.close_operation, "creationOperationId": creation.operation_id, + "name": creation.name, "muxId": creation.mux_space_id, + "reconciled": reconciled, "historical": historical, **followup}, self.stdout) + + def _prepare_close(self, args: argparse.Namespace, discovery: ManagedDiscoveryV1) -> bool: + assert self.namespace is not None + self.close_readonly = args.action == "close-status" + self.close_continue = getattr(args, "continue_close", False) + self.close_by_name = getattr(args, "target", None) is not None + observed = discovery.resolve(args.target, deadline=self.deadline, wait_for_lock=True) if self.close_by_name else None + if self.close_by_name and observed is None: + raise ManagedStorageError("not_found") + service = observed.service if observed is not None else discovery.resolve_service( + args.server, deadline=self.deadline, wait_for_lock=True) + if service is None: + raise ManagedStorageError("not_found") + if service.product_id != "coding": + raise ManagedStorageError("conflict") + paths = resolve_managed_service_paths(self.defaults.namespace, service, + runtime_root=str(self.defaults.platform.runtime)) + self.journal = ManagedServiceJournalV1(self.namespace.registry, self.defaults.namespace, service, + Path(paths.lifecycle), defer_open=True) + self.journal.open(deadline=self.deadline, wait_for_lock=True) + state = self.journal.read(deadline=self.deadline, wait_for_lock=True) + if state is None: + raise ManagedStorageError("unavailable") + if observed is not None and observed.instance != state.handoff.instance: + raise ManagedStorageError("conflict") + self.close_instance_id = state.handoff.instance.instance_id + manager = self.close_manager = ManagedMuxManagerV1(self.namespace.registry, self.journal, + self.defaults.namespace, service, state.handoff.instance, application_id=APPLICATION_ID) + inspection = self.close_inspection = manager.inspect_mux( + observed.reservation, deadline=self.deadline, wait_for_lock=True) if observed is not None else manager.inspect_close_operation( + args.operation, deadline=self.deadline, wait_for_lock=True) + creation, request = inspection.creation, inspection.close_request + self.close_reservation = ManagedMuxReservationV1(creation.name, service, creation.operation_id) + self.close_operation = token_hex(16) if request is None else request.operation_id + if self.close_readonly and inspection.close_result is not None and inspection.close_result.phase is ManagedMuxClosePhaseV1.CLOSED: + self._close_output("closed", reconciled=True, historical=True) + return False + if not self.close_readonly: + self._close_output("planned_close" if request is None else "close_preview") + if not args.yes: + print("This closes only the selected mux; persistent Session files are kept. " + "Continue [yes/No]?" if self.close_continue or request is None else + "Query and reconcile only; no close request will be resent. Continue [yes/No]?", + file=self.stdout, flush=True) + answer = self.stdin.readline(8) + if not answer.endswith("\n") or answer.strip().lower() != "yes": + return False + self.deadline = monotonic() + 30 + if inspection.close_result is not None and inspection.close_result.phase is ManagedMuxClosePhaseV1.CLOSED: + self._close_output("closed", reconciled=True, historical=True) + return False + elif request is None or request.instance_id != state.handoff.instance.instance_id: + self._close_output("reauthorization_required") + raise ManagedStorageError("unavailable") + self.connection = ManagedConnectionLeaseV1(self.journal, self.defaults.namespace, service, + state.handoff.instance, runtime_root=str(self.defaults.platform.runtime), endpoint=ENDPOINT) + return True + + async def _run_close(self) -> None: + assert self.connection is not None and self.close_manager is not None + assert self.close_inspection is not None and self.close_reservation is not None and self.close_operation is not None + manager, inspection, reservation = self.close_manager, self.close_inspection, self.close_reservation + operation = self.close_operation + await self.connection.prepare(deadline=self.deadline) + if self.connection.application_id != APPLICATION_ID: + raise ManagedStorageError("conflict") + client = self.connection.managed_mux_close_client + if client is None: + raise ManagedStorageError("unavailable") + # Recheck the frozen target/permission, not a later name selection. + current = await _settled_native(lambda: manager.inspect_mux(reservation, deadline=self.deadline, wait_for_lock=True) + if self.close_by_name else manager.inspect_close_operation(operation, deadline=self.deadline, wait_for_lock=True)) + if current.creation != inspection.creation or current.close_request != inspection.close_request: + raise ManagedStorageError("conflict") + request = inspection.close_request + if not self.close_readonly: + request = await _settled_native(lambda: manager.issue_close(reservation, operation_id=operation, + deadline=self.deadline, wait_for_lock=True)) + assert request is not None + self.close_request = request + _check_deadline(self.deadline) + async with asyncio.timeout_at(self.deadline): + # At most one RPC effect per explicit invocation. A status query or + # ordinary rejoin never interprets None as permission to replay. + result = await client.close_managed_mux(request) if not self.close_readonly and ( + inspection.close_request is None or self.close_continue) else await client.read_managed_mux_close(request) + _check_deadline(self.deadline) + if result is None: + self._close_output("unknown") + raise ManagedStorageError("unavailable") + await _settled_native(lambda: manager.verify_close_result(request, result, deadline=self.deadline, wait_for_lock=True)) + self.close_result = result + _check_deadline(self.deadline) + if not self.close_readonly: + self._close_output(result.phase.value) + await _settled_native(lambda: manager.record_close(request, result, deadline=self.deadline, wait_for_lock=True)) + _check_deadline(self.deadline) + self._close_output(result.phase.value, reconciled=not self.close_readonly) + + def _prepare_stop_all(self, args: argparse.Namespace, discovery: ManagedDiscoveryV1) -> bool: + assert self.namespace is not None + snapshot = discovery.snapshot_namespace(deadline=self.deadline, wait_for_lock=True) + _emit({"action": "stop_all_preview", "namespaceKey": self.defaults.namespace.namespace_key, + "ordering": "service_id", "budgetSeconds": 30, "sharedBudget": True, + "services": [{"serviceId": item.service.service_id, "productId": item.service.product_id, + "workspace": item.service.workspace, + "instanceId": None if item.instance is None else item.instance.instance_id, + "muxes": [mux.name for mux in snapshot.muxes if mux.service == item.service]} + for item in snapshot.services]}, self.stdout) + if not args.yes: + print("Stop these exact instances using one shared 30s budget? Type yes: ", + file=self.stdout, end="", flush=True) + confirmation = self.stdin.readline(8) + if not confirmation.endswith("\n") or confirmation.strip() != "yes": + return False + self.deadline = monotonic() + 30 + self.stop_all = StopAll(self.namespace, self.defaults, snapshot, deadline=self.deadline, + emit=lambda value: _emit(value, self.stdout)) + return True + + def _prepare_stop(self, args: argparse.Namespace, discovery: ManagedDiscoveryV1) -> bool: + assert self.namespace is not None + service = discovery.resolve_service(args.server, deadline=self.deadline, wait_for_lock=True) + if service is None: + raise ManagedStorageError("not_found") + paths = resolve_managed_service_paths(self.defaults.namespace, service, + runtime_root=str(self.defaults.platform.runtime)) + self.journal = ManagedServiceJournalV1(self.namespace.registry, self.defaults.namespace, service, + Path(paths.lifecycle), defer_open=True) + self.journal.open(deadline=self.deadline, wait_for_lock=True) + state = self.journal.read(deadline=self.deadline, wait_for_lock=True) + if state is None: + raise ManagedStorageError("unavailable") + names: list[str] = [] + after = None + while True: + page = discovery.list_muxes(deadline=self.deadline, after=after, wait_for_lock=True) + names.extend(item.name for item in page if item.service == service) + if len(page) < MAX_PAGE: + break + after = page[-1].name + _emit({"action": "stop_preview", "serviceId": service.service_id, + "instanceId": state.handoff.instance.instance_id, "muxes": names}, self.stdout) + if not args.yes: + print("Stop this service and all its muxes? Type yes: ", file=self.stdout, end="", flush=True) + confirmation = self.stdin.readline(8) + if not confirmation.endswith("\n") or confirmation.strip() != "yes": + return False + self.deadline = monotonic() + 30 + self.stopper = ManagedServiceStopOperationV1( + self.journal, self.defaults.namespace, service, state.handoff.instance, + runtime_root=str(self.defaults.platform.runtime), + ) + return True + + async def _run_probe(self) -> None: + assert self.probe is not None + self.probe_entered = True + try: + self.probe_result = await self.probe.run() + finally: + await self.probe.close() + + def _probe_pending(self) -> bool: + assert self.probe is not None + if not self.probe_entered: + self.probe.close_unstarted() + return self.probe.cleanup_pending + + def _probe_snapshot(self) -> ManagedMuxProbeSnapshotV1: + assert self.namespace is not None + if self.probe is not None and self.probe.cleanup_pending: + raise ManagedStorageError("busy") + self.probe_entered = False + self.probe_result = None + self.probe = ManagedMuxProbeOperationV1(self.namespace.registry, self.defaults.namespace, + product_id="coding", runtime_root=str(self.defaults.platform.runtime), endpoint=ENDPOINT, + expected_application_id=APPLICATION_ID, deadline=self.deadline) + status = _execute(self._run_probe, self._probe_pending) + if status or self.probe_result is None: + raise ManagedStorageError("unavailable") + return self.probe_result + + def _select_probe(self, snapshot: ManagedMuxProbeSnapshotV1) -> ManagedMuxObservationV1 | None: + offset = 0 + while True: + page = snapshot.results[offset:offset + MAX_PAGE] + for index, row in enumerate(page, 1): + _emit({"choice": index, **_observation(row.observation), "status": row.status, + "control": "not_requested"}, self.stdout) + next_hint = "; n next page" if offset + MAX_PAGE < len(snapshot.results) else "" + print("Choose a number (Enter cancels)" + next_hint + "; r first page; f refresh: ", + file=self.stdout, end="", flush=True) + line = self.stdin.readline(32) + if len(line) == 32 and not line.endswith("\n"): + raise ManagedContractError() + choice = line.strip() + if not choice: + return None + if choice == "r": + offset = 0 + continue + if choice == "n": + if offset + MAX_PAGE < len(snapshot.results): + offset += MAX_PAGE + else: + print("No more muxes; staying on this page.", file=self.stdout, flush=True) + continue + if choice == "f": + self.deadline = monotonic() + 30 + snapshot = self._probe_snapshot() + offset = 0 + if snapshot.unique_present is not None: + return snapshot.unique_present.observation + continue + if not choice.isascii() or not choice.isdecimal() or not 1 <= int(choice) <= len(page): + raise ManagedContractError() + self.deadline = monotonic() + 30 + return page[int(choice) - 1].observation + + def _creation_output(self, status: str, inspection: ManagedMuxCreationInspectionV1 | None = None) -> None: + assert self.creation_reservation is not None + reservation = self.creation_reservation + created = None if inspection is None else inspection.created + continuation: dict[str, object] = {} + if created is None and (inspection is None or inspection.active): + continuation = {"continueCommand": ["lmux", "create", "--server", reservation.service.service_id, + "--operation", reservation.operation_id, "--continue"], + "requiresConfirmation": True} + _emit({"status": status, "serviceId": reservation.service.service_id, + "workspace": reservation.service.workspace, "name": reservation.name, + "operationId": reservation.operation_id, + "muxId": None if created is None else created.mux_space_id, + "originInstanceId": None if created is None else created.instance_id, + "activeReservation": None if inspection is None else inspection.active, + "observation": "recorded_only", "liveStatus": "not_probed", + "automaticReplay": False, + "nextCommand": ["lmux", "create-status", "--server", reservation.service.service_id, + "--operation", reservation.operation_id], + **continuation}, self.stdout) + + def _prepare_creation_recovery(self, args: argparse.Namespace) -> bool: + assert self.namespace is not None + registry = self.namespace.registry + inspection = registry.inspect_mux_creation(args.operation, deadline=self.deadline, wait_for_lock=True) + if inspection is None: + raise ManagedStorageError("not_found") + reservation = inspection.reservation + if reservation.service.service_id != args.server or reservation.service.product_id != "coding": + raise ManagedStorageError("conflict") + self.creation_reservation = reservation + if not getattr(args, "continue_create", False): + self._creation_output("created" if inspection.created is not None else "unknown", inspection) + self.result_status = 0 if inspection.created is not None else 1 + return False + if not inspection.active: + # An old operation cannot reclaim its name after a confirmed close. + raise ManagedStorageError("conflict") + if inspection.created is not None: + self._creation_output("created", inspection) + return False + self._creation_output("creation_continue_preview", inspection) + if not args.yes: + print("Start/reuse this original service and resend this same idempotent creation once? " + "Type yes: ", file=self.stdout, end="", flush=True) + answer = self.stdin.readline(8) + if not answer.endswith("\n") or answer.strip().lower() != "yes": + return False + self.deadline = monotonic() + 30 + current = registry.inspect_mux_creation(args.operation, deadline=self.deadline, wait_for_lock=True) + if current != inspection: + raise ManagedStorageError("conflict") + self.creation_recovery = True + self._prepare_creation_owner(reservation) + return True + + def _new(self, name: str, service: ManagedServiceKeyV1) -> None: + assert self.namespace is not None + registry = self.namespace.registry + reservation = self.creation_reservation = ManagedMuxReservationV1(name, service, token_hex(16)) + # Printed before the first fallible commit: this is only a planned + # intent, not a creation receipt, serving claim, or proof of reservation. + self._creation_output("planned_creation") + # First reserve the exact global intent, before service creation. The + # operation rejoins this same reservation; it never allocates a new ID. + registry.reserve_mux(reservation, deadline=self.deadline, wait_for_lock=True) + self._prepare_creation_owner(reservation) + + def _prepare_creation_owner(self, reservation: ManagedMuxReservationV1) -> None: + assert self.namespace is not None + registry, service = self.namespace.registry, reservation.service + self.service = ManagedServiceAdmissionV1(self.namespace, service) + self.journal = self.service.open(deadline=self.deadline, wait_for_lock=True) + self.creation = ManagedMuxCreateOperationV1( + registry, self.journal, self.defaults.namespace, reservation, + runtime_root=str(self.defaults.platform.runtime), endpoint=ENDPOINT, application_id=APPLICATION_ID, + temporary_override=self.defaults.temporary_override, + request_factory=lambda invocation, descriptor: coding_managed_process_request( + invocation, descriptor, executable=sys.executable, environment=self.environment, + ), + ) + self.target = reservation.name + + async def run(self) -> None: + self.active = True + try: + if self.stop_all is not None: + await self.stop_all.run() + return + if self.close_manager is not None: + await self._run_close() + return + if self.stopper is not None: + state = await self.stopper.run(deadline=self.deadline) + _emit({"status": "stopped", "instanceId": state.handoff.instance.instance_id}, self.stdout) + return + if self.starter is not None: + ready = await self.starter.ensure_started(deadline=self.deadline) + if ready.application_id != APPLICATION_ID: + raise ManagedStorageError("conflict") + result: dict[str, object] = {"status": "service_ready", "serviceId": ready.instance.service_id, + "instanceId": ready.instance.instance_id} + if self.trace_deadline_ms is not None: + trace_result: dict[str, object] = { + "deadlineMs": self.trace_deadline_ms, + "operationId": self.starter.operation_id, + "observation": "historical_configuration_not_write_guarantee", + } + try: + trace_result["status"] = await self.starter.observe_requested_trace(deadline=self.deadline) + except ManagedStorageError as error: + trace_result.update(status="observation_failed", errorCode=error.code) + except Exception: + trace_result.update(status="observation_failed", errorCode="unavailable") + result["trace"] = trace_result + self.result_status = 0 if trace_result["status"] == "applied" else 1 + _emit(result, self.stdout) + return + if self.creation is not None: + created = await self.creation.run(deadline=self.deadline) + if self.creation_recovery: + assert self.creation_reservation is not None + self._creation_output("created", ManagedMuxCreationInspectionV1( + self.creation_reservation, True, created)) + return + connection = self.creation.connection + selector = MuxSelectorV1(mux_space_id=created.mux_space_id) + else: + assert self.connection is not None + await self.connection.prepare(deadline=self.deadline) + connection = self.connection + assert self.mux_space_id is not None + selector = MuxSelectorV1(mux_space_id=self.mux_space_id) + if connection.application_id != APPLICATION_ID: + raise ManagedStorageError("conflict") + from loushang.harnesstui.conversation.theme import terminal_transcript_theme + from loushang.harnesstui.mux.shell import HostedMuxShellV1 + from loushang.harnesstui.mux.terminal import run_hosted_mux_shell + + self.shell = HostedMuxShellV1( + connection.client, selector=selector, product_id="coding", + scopes=tuple((item.scope, item.fingerprint) for item in connection.scopes), + discovery_client=connection.discovery_client, + transcript_theme=terminal_transcript_theme(), + ) + status = await run_hosted_mux_shell(self.shell, stdin=self.stdin, stdout=self.stdout, + startup_deadline=self.deadline) + if status: + raise ManagedStorageError("unavailable") + finally: + try: + if self.shell is not None: + await self.shell.close() + finally: + if self.stop_all is not None: + await self.stop_all.close() + elif self.stopper is not None: + await self.stopper.close() + elif self.starter is not None: + await self.starter.close() + elif self.creation is not None: + await self.creation.close() + elif self.connection is not None: + await self.connection.close() + self.active = False + self.close_native() + + def close_native(self) -> None: + if self.active: + raise ManagedStorageError("busy") + if self.probe is not None and self.probe.cleanup_pending: + raise ManagedStorageError("busy") + if self.stop_all is not None and self.stop_all.cleanup_pending: + raise ManagedStorageError("busy") + if self.log_directory is not None: + self.log_directory.close() + self.log_directory = None + if self.service is not None: + self.service.close() + self.service = None + self.journal = None # borrowed from the service owner + elif self.journal is not None: + self.journal.close() + self.journal = None + if self.namespace is not None: + self.namespace.close() + self.namespace = None + self.native_closed = True + + +def execute(args: argparse.Namespace, *, stdin: TextIO, stdout: TextIO, stderr: TextIO) -> int: + command = None + status = 0 + try: + environment = dict(os.environ) + defaults = resolve_managed_defaults(environ=environment) + command = ManagedMuxCommand(defaults, environment, stdin=stdin, stdout=stdout) + if command.prepare(args): + status = _execute(command.run, command.finish_native_if_idle) + status = status or command.result_status + except KeyboardInterrupt: + print("lmux_interrupted", file=stderr) + status = 130 + except ManagedStorageError as error: + print("lmux_" + error.code, file=stderr) + status = 1 + except Exception: + print("lmux_unavailable", file=stderr) + status = 1 + finally: + if command is not None: + if command.active: + print("lmux_cleanup_incomplete", file=stderr, flush=True) + os._exit(status or 1) + try: + command.close_native() + except BaseException: + print("lmux_cleanup_incomplete", file=stderr, flush=True) + os._exit(status or 1) + return status diff --git a/src/loushang/coding/cli/lmux_stop_all.py b/src/loushang/coding/cli/lmux_stop_all.py new file mode 100644 index 000000000..55edeed0f --- /dev/null +++ b/src/loushang/coding/cli/lmux_stop_all.py @@ -0,0 +1,125 @@ +"""CLI batch composition; exact frozen instances, original owners and no force.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from functools import partial +from pathlib import Path +from time import monotonic + +from loushang.apphost.managed._files import ManagedStorageError, _check_deadline +from loushang.apphost.managed.child import _spawn +from loushang.apphost.managed.connection import _settled_native +from loushang.apphost.managed.defaults import ManagedDefaultsV1 +from loushang.apphost.managed.discovery import ( + ManagedDiscoverySnapshotV1, + ManagedServiceObservationV1, +) +from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 +from loushang.apphost.managed.namespace_admission import ManagedNamespaceAdmissionV1 +from loushang.apphost.managed.paths import resolve_managed_service_paths +from loushang.apphost.managed.stopper import ManagedServiceStopOperationV1 + + +@dataclass +class _Entry: + observation: ManagedServiceObservationV1 + journal: ManagedServiceJournalV1 | None = None + stopper: ManagedServiceStopOperationV1 | None = None + closing: asyncio.Task[None] | None = None + + +class StopAll: + """Borrow namespace until all original entry cleanup has completed. + + The stop budget never renews. Cleanup gets a separate bounded wait, never + another stop attempt. Cancelling a waiter does not cancel owned cleanup. + """ + + def __init__(self, namespace: ManagedNamespaceAdmissionV1, defaults: ManagedDefaultsV1, + snapshot: ManagedDiscoverySnapshotV1, *, deadline: float, + emit: Callable[[dict[str, object]], None]) -> None: + self.namespace, self.defaults, self.deadline, self.emit = namespace, defaults, deadline, emit + self.entries = tuple(_Entry(item) for item in snapshot.services) + self.started = self.closed = False + + @property + def cleanup_pending(self) -> bool: + return any(entry.journal is not None or entry.stopper is not None for entry in self.entries) + + async def _close_entry(self, entry: _Entry) -> None: + if entry.stopper is not None: + await entry.stopper.close() + entry.stopper = None + if entry.journal is not None: + await _settled_native(entry.journal.close) + entry.journal = None + + def _begin_close(self, entry: _Entry) -> None: + previous = entry.closing + retry = previous is None or (previous.done() and ( + previous.cancelled() or previous.exception() is not None)) + if retry and (entry.stopper is not None or entry.journal is not None): + entry.closing = _spawn(self._close_entry(entry)) + + async def _wait_close(self, entries: tuple[_Entry, ...]) -> None: + # Start every original cleanup before waiting for any one of them. + admission_failed = False + for entry in entries: + try: + self._begin_close(entry) + except Exception: + admission_failed = True + tasks = [entry.closing for entry in entries if entry.closing is not None] + if not tasks: + if admission_failed: + raise ManagedStorageError("unavailable") + return + done, pending = await asyncio.wait(tasks, timeout=2.0) + failed = any([task.cancelled() or task.exception() is not None for task in done]) + if pending or failed or admission_failed: + raise ManagedStorageError("busy" if pending else "unavailable") + + async def close(self) -> None: + self.closed = True + await self._wait_close(self.entries) + + async def run(self) -> None: + if self.started or self.closed: + raise ManagedStorageError("closed") + self.started = True + failed = False + for entry in self.entries: + observed = entry.observation + result: dict[str, object] = {"action": "stop_result", "serviceId": observed.service.service_id, + "instanceId": None if observed.instance is None else observed.instance.instance_id} + if observed.instance is None: + self.emit({**result, "status": "skipped", "reason": "no_instance_at_snapshot"}) + continue + if monotonic() >= self.deadline: + failed = True + self.emit({**result, "status": "not_attempted", "reason": "deadline"}) + continue + try: + _check_deadline(self.deadline) + paths = resolve_managed_service_paths(self.defaults.namespace, observed.service, + runtime_root=str(self.defaults.platform.runtime)) + entry.journal = ManagedServiceJournalV1(self.namespace.registry, self.defaults.namespace, + observed.service, Path(paths.lifecycle), defer_open=True) + journal = entry.journal + await _settled_native(partial(journal.open, deadline=self.deadline, wait_for_lock=True)) + entry.stopper = ManagedServiceStopOperationV1(entry.journal, self.defaults.namespace, + observed.service, observed.instance, runtime_root=str(self.defaults.platform.runtime)) + await entry.stopper.run(deadline=self.deadline) + await self._wait_close((entry,)) + except Exception as error: + # Cancellation and KeyboardInterrupt must escape this loop. + failed = True + code = error.code if isinstance(error, ManagedStorageError) else "unavailable" + self.emit({**result, "status": "failed", "code": code}) + else: + self.emit({**result, "status": "stopped"}) + if failed: + raise ManagedStorageError("unavailable") diff --git a/tests/architecture/test_detachable_local_workspace_g16.py b/tests/architecture/test_detachable_local_workspace_g16.py index b3e717edd..c4838365d 100644 --- a/tests/architecture/test_detachable_local_workspace_g16.py +++ b/tests/architecture/test_detachable_local_workspace_g16.py @@ -50,9 +50,12 @@ def test_G16_BOUNDARIES_optional_scopes_have_separate_reviewable_budgets() -> No node.module for node in ast.walk(ast.parse(source.read_text())) if isinstance(node, ast.ImportFrom) and node.module } - assert {name for name in imports if name.startswith("loushang.")} == { - "loushang.appserver.protocol" - } + expected = {"loushang.appserver.protocol"} + if name == "client_scope.py": + expected.add("loushang.appserver.managed_mux") + # LMUX §78: the same accepted-work scope owns optional close calls. + expected.add("loushang.appserver.managed_mux_close") + assert {name for name in imports if name.startswith("loushang.")} == expected # Explicit optional construction is not activation of G14 or a CLI. for name in ( "src/loushang/appservice/__init__.py", @@ -89,7 +92,10 @@ def test_G16_BOUNDARIES_authentication_is_stdlib_only_and_off_default_routes() - def test_G16_BOUNDARIES_native_record_is_one_optional_stdlib_component() -> None: root = Path("src/loushang/appserver") budgets = { - "local_record.py": 200, "_local_record_values.py": 180, + # The reviewed optional managed-mux capability adds closed record fields; + # keep the aggregate budget and native-only dependency fence unchanged. + # LMUX §78 adds the closed mux_closure capability; aggregate unchanged. + "local_record.py": 200, "_local_record_values.py": 201, "_local_record_files.py": 300, "_posix_local_record.py": 130, "_windows_local_record.py": 380, } @@ -128,7 +134,8 @@ def test_G16_BOUNDARIES_native_io_is_confined_to_explicit_local_adapter() -> Non # LMUX-M0 §17 reviewed two-stage owner delta from 0351f8b0: 392 -> 463. # Preserve the previous allowance; no other file or native boundary is exempt. assert len(source.splitlines()) <= 450 + 71 - assert len((root / "_local_peer.py").read_text().splitlines()) <= 240 + # LMUX §78 routes the close-control family through the original peer owner. + assert len((root / "_local_peer.py").read_text().splitlines()) <= 244 assert '_LOOPBACK = "127.0.0.1"' in source assert "MAX_LOCAL_CONNECTIONS = 8" in source assert "MAX_LOCAL_APP_CONNECTIONS = 7" in source @@ -225,11 +232,21 @@ def test_managed_staging_keeps_exact_optional_signatures_and_sync_fence(): def test_G16_BOUNDARIES_shell_borrows_only_semantics_and_owns_no_native_connection(): root = Path("src/loushang/harnesstui/mux") paths = [root / name for name in ("shell.py", "terminal.py", "_shell_tasks.py", "_shell_screen.py")] - # G17 reviewed presentation supplement; the semantic controller cap stays 600. - assert sum(len(path.read_text().splitlines()) for path in paths) <= 950 + # LMUX reviewed capability help/completion and stale-result fencing: + # shell +72, screen +22 versus da820585; same four owners and import guards. + assert sum(len(path.read_text().splitlines()) for path in paths) <= 950 + 72 + 22 picker = root / "session_picker.py" assert len(picker.read_text().splitlines()) <= 450 paths.append(picker) + # LMUX M3 reviewed action-port adapter; retain separate caps and apply + # the same dependency prohibitions to the new, explicitly counted binding. + binding = root / "conversation_binding.py" + assert len(binding.read_text().splitlines()) <= 150 + paths.append(binding) + for name in ("request_presentation.py", "theme.py"): + neutral = root.parent / "conversation" / name + assert len(neutral.read_text().splitlines()) <= 60 + paths.append(neutral) for path in paths: source = path.read_text() for node in ast.walk(ast.parse(source)): diff --git a/tests/coding/test_lmux_command.py b/tests/coding/test_lmux_command.py new file mode 100644 index 000000000..1d80df75c --- /dev/null +++ b/tests/coding/test_lmux_command.py @@ -0,0 +1,738 @@ +from __future__ import annotations + +import json +import os +import sys +import traceback +from pathlib import Path + +import pytest + +from loushang.coding.cli import lmux + + +def _failure_diagnostic(command): + """Original failure locations only; never stringify exceptions or locals.""" + from loushang.apphost.managed._files import ManagedStorageError + from loushang.appserver.local_record import LocalRecordError, LocalRecordErrorCodeV1 + from loushang.appserver.protocol import AppErrorCodeV1, AppServiceError + + error = command.failure + if error is None: + return {"type": None, "code": None, "frames": []} + code = None + if isinstance(error, ManagedStorageError) and type(error.code) is str and error.code in { + "unsupported", "unavailable", "not_found", "conflict", "busy", "closed", "invalid_record", "capacity", + }: + code = error.code + elif isinstance(error, (AppServiceError, LocalRecordError)) and type(error.code) in ( + AppErrorCodeV1, LocalRecordErrorCodeV1, + ): + code = error.code.value + root = Path(__file__).parents[2] + frames = [] + for frame, line in traceback.walk_tb(error.__traceback__): + path = Path(frame.f_code.co_filename) + filename = str(path.relative_to(root)) if path.is_relative_to(root) else path.name + frames.append({"file": filename, "line": line, "function": frame.f_code.co_name}) + return {"type": type(error).__name__, "code": code, "frames": frames} + + +@pytest.mark.parametrize("kind,expected", [("managed", "busy"), ("local", "local_record_not_found"), + ("app", "operation_unavailable"), ("other", None)]) +def test_failure_diagnostic_retains_locations_but_not_exception_messages_or_untrusted_codes(kind, expected): + from types import SimpleNamespace + + from loushang.apphost.managed._files import ManagedStorageError + from loushang.appserver.local_record import LocalRecordError, LocalRecordErrorCodeV1 + from loushang.appserver.protocol import AppErrorCodeV1, AppServiceError + + secret = "private-message-and-authority-token" + error = {"managed": lambda: ManagedStorageError("busy"), + "local": lambda: LocalRecordError(LocalRecordErrorCodeV1.NOT_FOUND), + "app": lambda: AppServiceError(AppErrorCodeV1.OPERATION_UNAVAILABLE), + "other": lambda: RuntimeError(secret)}[kind]() + error.args = (secret,) + error.add_note(secret) + if kind == "other": + error.code = secret + try: + raise error + except Exception as original: + report = _failure_diagnostic(SimpleNamespace(failure=original)) + assert report["type"] == type(error).__name__ and report["code"] == expected + assert report["frames"][-1]["function"] == "test_failure_diagnostic_retains_locations_but_not_exception_messages_or_untrusted_codes" + assert report["frames"][-1]["file"] == "tests/coding/test_lmux_command.py" + assert report["frames"][-1]["line"] > 0 + assert secret not in json.dumps(report) + + +def test_help_does_not_resolve_namespace(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("LOUSHANG_HOME", str(tmp_path / "platform")) + with pytest.raises(SystemExit) as caught: + lmux.main(["--help"]) + assert caught.value.code == 0 + assert "attach" in capsys.readouterr().out + assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize("args", [[], ["new", "-s", "dev"], ["attach"], ["attach", "-t", "dev"]]) +def test_interactive_commands_reject_non_tty_before_default_lookup(tmp_path, monkeypatch, args): + from loushang.apphost.managed import defaults + + monkeypatch.setattr(defaults, "resolve_managed_defaults", lambda **k: pytest.fail("non-tty native lookup")) + monkeypatch.setenv("LOUSHANG_HOME", str(tmp_path / "platform")) + with pytest.raises(SystemExit) as caught: + lmux.main(args) + assert caught.value.code == 2 + assert not list(tmp_path.iterdir()) + + +def test_ls_missing_namespace_is_empty_and_readonly(tmp_path, monkeypatch, capsys): + from loushang.apphost.managed import defaults + + monkeypatch.setattr(defaults, "linux_machine_key", lambda **k: "a" * 32) + monkeypatch.setenv("LOUSHANG_HOME", str(tmp_path / "platform")) + monkeypatch.setenv("LOUSHANG_RUNTIME_DIR", str(tmp_path / "runtime")) + monkeypatch.delenv("LOUSHANG_TMPDIR", raising=False) + assert lmux.main(["ls"]) == 0 + assert json.loads(capsys.readouterr().out) == {"muxes": [], "nextAfter": None} + assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize("args", [["new"], ["new", "-s", "bad:name"], ["attach", "-t", "bad:name"], ["stop"]]) +def test_invalid_commands_have_no_side_effects(tmp_path, monkeypatch, args): + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.setenv("LOUSHANG_HOME", str(tmp_path / "platform")) + with pytest.raises(SystemExit) as caught: + lmux.main(args) + assert caught.value.code == 2 + assert not list(tmp_path.iterdir()) + + +@pytest.fixture +def managed_cli(tmp_path, monkeypatch, capsys): + from loushang.apphost.managed import defaults + from loushang.coding.cli import lmux_command as module + + monkeypatch.setattr(defaults, "linux_machine_key", lambda **k: "a" * 32) + monkeypatch.setenv("LOUSHANG_HOME", str(tmp_path / "platform")) + monkeypatch.setenv("LOUSHANG_RUNTIME_DIR", str(tmp_path / "runtime")) + monkeypatch.delenv("LOUSHANG_TMPDIR", raising=False) + monkeypatch.chdir(tmp_path) + original_main = lmux.main + + def invoke(args): + # pytest reinstalls capture streams between fixture and call phases. + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + return original_main(args) + + monkeypatch.setattr(lmux, "main", invoke) + commands = [] + original = module.ManagedMuxCommand + + class Command(original): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.failure = None + commands.append(self) + + async def run(self): + try: + await super().run() + except Exception as error: + self.failure = error + raise + + monkeypatch.setattr(module, "ManagedMuxCommand", Command) + monkeypatch.setattr(os, "_exit", lambda code: pytest.fail(f"unexpected hard exit {code}")) + try: + yield module, commands + finally: + from loushang.hosting.service import LinuxServiceObserverV1 + + from .test_managed_process_entry import _stop_original + + for command in commands: + coordinator = command.starter if command.starter is not None else ( + command.creation._coordinator if command.creation is not None else None + ) + if coordinator is None: + continue + starter = coordinator._starter + native = starter._process + observer = None + if native is not None and native._process is not None and native._process.poll() is None: + observer = LinuxServiceObserverV1.reopen(native.identity) + _stop_original(starter, observer) + + +@pytest.mark.parametrize("first_name", ["dev", "main"]) +def test_real_cli_cold_new_global_ls_attach_and_warm_new(managed_cli, tmp_path, monkeypatch, capsys, first_name): + from loushang.harnesstui.mux import terminal + + _, commands = managed_cli + attached = [] + + async def screen(shell, **kwargs): + await shell.start() + attached.append(shell.state) + return 0 + + monkeypatch.setattr(terminal, "run_hosted_mux_shell", screen) + assert lmux.main([] if first_name == "main" else ["new", "-s", first_name]) == 0, _failure_diagnostic(commands[-1]) + starter = commands[0].creation._coordinator._starter + assert starter._process._process.poll() is None + assert commands[0].native_closed and not commands[0].active + other = tmp_path / "elsewhere" + other.mkdir() + monkeypatch.chdir(other) + capsys.readouterr() + assert lmux.main(["ls"]) == 0 + listing = json.loads(capsys.readouterr().out) + assert [item["name"] for item in listing["muxes"]] == [first_name] + assert listing["muxes"][0]["workspace"] == str(tmp_path) + assert listing["muxes"][0]["status"] == "unknown" and listing["muxes"][0]["tabs"] is None + assert lmux.main(["attach", "-t", first_name]) == 0, repr(commands[-1].failure) + assert commands[-1].creation is None + assert lmux.main(["new", "-s", "second", "--workspace", str(tmp_path)]) == 0 + assert commands[-1].creation._coordinator._starter._process is None + assert len(attached) == 3 + assert lmux.main(["new", "-s", first_name]) == 1 + assert commands[-1].service is None and commands[-1].creation is None + assert not list(other.iterdir()) + assert starter._process._process.poll() is None + + +def test_stop_and_explicit_restart_restore_mux_without_recreating_it(managed_cli, tmp_path, monkeypatch, capsys): + from concurrent.futures import ThreadPoolExecutor + + from loushang.apphost.managed.contracts import ManagedServiceKeyV1 + from loushang.harnesstui.mux import terminal + + _, commands = managed_cli + seen = [] + + async def screen(shell, **kwargs): + await shell.start() + seen.append(await shell._client.list_muxes()) + return 0 + + monkeypatch.setattr(terminal, "run_hosted_mux_shell", screen) + assert lmux.main(["new", "-s", "dev"]) == 0, repr(commands[-1].failure) + first = commands[-1].creation._coordinator._starter + service_id = ManagedServiceKeyV1("coding", str(tmp_path)).service_id + + def stop(native): + # This test process is the actual original Popen parent. An unrelated + # stop client must not fabricate reaping from pidfd readiness. + with ThreadPoolExecutor(max_workers=1) as pool: + reaped = pool.submit(native._process.wait, 20) + assert lmux.main(["stop", "--server", service_id, "--yes"]) == 0 + assert reaped.result(timeout=2) == 0 + + stop(first._process) + assert commands[-1].stopper.state.cleanly_stopped + assert lmux.main(["attach", "-t", "dev"]) == 1 + assert commands[-1].starter is None and commands[-1].creation is None + assert lmux.main(["start", "-t", "dev"]) == 0 + second = commands[-1].starter._starter + assert first._state.handoff.instance != second._state.handoff.instance + assert lmux.main(["attach", "-t", "dev"]) == 0, repr(commands[-1].failure) + assert seen[0].mux_spaces == seen[1].mux_spaces + stop(second._process) + assert lmux.main(["stop", "--server", service_id, "--yes"]) == 0 + + +def test_noninteractive_stop_requires_confirmation_before_lookup(tmp_path, monkeypatch): + from loushang.apphost.managed import defaults + + monkeypatch.setattr(defaults, "resolve_managed_defaults", lambda **k: pytest.fail("lookup before confirmation")) + with pytest.raises(SystemExit) as caught: + lmux.main(["stop", "--server", "a" * 64]) + assert caught.value.code == 2 + assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize("args", [["close", "-t", "dev"], + ["close", "--server", "a" * 64, "--operation", "b" * 32, "--continue"]]) +def test_noninteractive_close_requires_confirmation_before_lookup(monkeypatch, args): + from loushang.apphost.managed import defaults + + monkeypatch.setattr(defaults, "resolve_managed_defaults", lambda **k: pytest.fail("lookup before confirmation")) + with pytest.raises(SystemExit) as caught: + lmux.main(args) + assert caught.value.code == 2 + + +@pytest.mark.parametrize("args", [["close", "--server", "a" * 64, "--yes"], + ["close", "-t", "dev", "--operation", "b" * 32, "--yes"], + ["close", "-t", "dev", "--continue", "--yes"], + ["close-status", "--server", "a" * 64, "--operation", "bad"], + ["close-status", "--server", "a" * 64, "--operation", "b" * 32, "--yes"]]) +def test_invalid_close_forms_never_resolve_defaults(monkeypatch, args): + from loushang.apphost.managed import defaults + + monkeypatch.setattr(defaults, "resolve_managed_defaults", lambda **k: pytest.fail("invalid form lookup")) + with pytest.raises(SystemExit) as caught: + lmux.main(args) + assert caught.value.code == 2 + + +def test_close_status_accepts_non_tty_without_confirmation(monkeypatch): + from loushang.coding.cli import lmux_command + + calls = [] + monkeypatch.setattr(lmux_command, "execute", lambda args, **k: calls.append(args.action) or 0) + assert lmux.main(["close-status", "--server", "a" * 64, "--operation", "b" * 32]) == 0 + assert calls == ["close-status"] + + +@pytest.mark.parametrize("fault", ["permit_ack", "before_rpc", "restart"]) +def test_public_close_explicit_continuation_recovers_unsent_intent(managed_cli, monkeypatch, capsys, fault): + from concurrent.futures import ThreadPoolExecutor + + from loushang.apphost.managed.connection import ManagedConnectionLeaseV1 + from loushang.apphost.managed.mux_management import ManagedMuxManagerV1 + from loushang.appserver.remote_client import RemoteAppClientV1 + from loushang.harnesstui.mux import terminal + + _, commands = managed_cli + + async def screen(shell, **kwargs): + await shell.start() + return 0 + + monkeypatch.setattr(terminal, "run_hosted_mux_shell", screen) + assert lmux.main(["new", "-s", "dev"]) == 0, _failure_diagnostic(commands[-1]) + native = commands[-1].creation._coordinator._starter._process + original_issue, original_close = ManagedMuxManagerV1.issue_close, RemoteAppClientV1.close_managed_mux + calls = [] + + def lost_receipt(manager, *args, **kwargs): + original_issue(manager, *args, **kwargs) + raise RuntimeError("test permit receipt lost") + + async def unsent(client, request): + calls.append(request) + raise RuntimeError("test failed before sending close") + + async def counted(client, request): + calls.append(request) + return await original_close(client, request) + + if fault == "permit_ack": + monkeypatch.setattr(ManagedMuxManagerV1, "issue_close", lost_receipt) + else: + monkeypatch.setattr(RemoteAppClientV1, "close_managed_mux", unsent) + capsys.readouterr() + assert lmux.main(["close", "-t", "dev", "--yes"]) == 1 + planned = json.loads(capsys.readouterr().out.splitlines()[0]) + assert planned["status"] == "planned_close" and not planned["reconciled"] + previous = len(calls) + assert previous == (0 if fault == "permit_ack" else 1) + monkeypatch.setattr(ManagedMuxManagerV1, "issue_close", original_issue) + monkeypatch.setattr(RemoteAppClientV1, "close_managed_mux", counted) + history = ["--server", planned["serviceId"], "--operation", planned["operationId"]] + if fault == "restart": + with ThreadPoolExecutor(max_workers=1) as pool: + reaped = pool.submit(native._process.wait, 20) + assert lmux.main(["stop", "--server", planned["serviceId"], "--yes"]) == 0 + assert reaped.result(timeout=2) == 0 + assert lmux.main(["start", "-t", "dev"]) == 0 + capsys.readouterr() + assert lmux.main(["close-status", *history]) == 1 + stale = json.loads(capsys.readouterr().out) + assert stale["status"] == "reauthorization_required" + assert stale["nextCommand"] == ["lmux", "close", *history] + assert commands[-1].connection is None + assert lmux.main(["close", *history, "--yes"]) == 1 + unknown = json.loads(capsys.readouterr().out.splitlines()[-1]) + assert unknown["nextCommand"] == ["lmux", "close", *history, "--continue"] + assert unknown["requiresConfirmation"] and not unknown["automaticReplay"] + assert lmux.main(["close-status", *history]) == 1 + assert len(calls) == previous + assert lmux.main(["close", *history, "--continue", "--yes"]) == 0, repr(commands[-1].failure) + assert len(calls) == previous + 1 + assert calls[-1].operation_id == planned["operationId"] + assert calls[-1].mux_space_id == planned["muxId"] + assert commands[-1].close_result.phase.value == "closed" + assert lmux.main(["new", "-s", "dev"]) == 0, repr(commands[-1].failure) + # Cached status needs no connection or renewed permit, even though a new + # Mux now uses the old display name. + monkeypatch.setattr(ManagedConnectionLeaseV1, "__init__", lambda *a, **k: pytest.fail("cached status connected")) + monkeypatch.setattr(ManagedMuxManagerV1, "issue_close", lambda *a, **k: pytest.fail("status issued permission")) + monkeypatch.setattr(ManagedMuxManagerV1, "record_close", lambda *a, **k: pytest.fail("status reconciled")) + capsys.readouterr() + assert lmux.main(["close-status", *history]) == 0 + result = json.loads(capsys.readouterr().out) + assert result["status"] == "closed" and result["historical"] + assert lmux.main(["ls"]) == 0 + assert [item["name"] for item in json.loads(capsys.readouterr().out)["muxes"]] == ["dev"] + + +def test_close_confirmation_cancels_before_permit_or_connection(managed_cli, monkeypatch): + from io import StringIO + + from loushang.apphost.managed.connection import ManagedConnectionLeaseV1 + from loushang.apphost.managed.mux_management import ManagedMuxManagerV1 + from loushang.harnesstui.mux import terminal + + _, commands = managed_cli + + async def screen(shell, **kwargs): + await shell.start() + return 0 + + monkeypatch.setattr(terminal, "run_hosted_mux_shell", screen) + assert lmux.main(["new", "-s", "dev"]) == 0 + monkeypatch.setattr(ManagedConnectionLeaseV1, "__init__", lambda *a, **k: pytest.fail("cancelled close connected")) + monkeypatch.setattr(ManagedMuxManagerV1, "issue_close", lambda *a, **k: pytest.fail("cancelled close issued")) + for answer in ("", "\n", "no\n", "yes", "yes no\n"): + monkeypatch.setattr(sys, "stdin", StringIO(answer)) + assert lmux.main(["close", "-t", "dev"]) == 0 + assert commands[-1].native_closed and commands[-1].close_request is None + + +def test_close_deadline_after_issuance_prevents_rpc_and_status_rejects_wrong_origin(managed_cli, monkeypatch, capsys): + from dataclasses import replace + from time import monotonic + + from loushang.apphost.managed.mux_management import ManagedMuxManagerV1 + from loushang.appserver.managed_mux_close import ( + ManagedMuxClosePhaseV1, + ManagedMuxCloseStateV1, + ) + from loushang.appserver.remote_client import RemoteAppClientV1 + from loushang.harnesstui.mux import terminal + + _, commands = managed_cli + + async def screen(shell, **kwargs): + await shell.start() + return 0 + + monkeypatch.setattr(terminal, "run_hosted_mux_shell", screen) + assert lmux.main(["new", "-s", "dev"]) == 0 + original_issue = ManagedMuxManagerV1.issue_close + + def late_receipt(manager, *args, **kwargs): + result = original_issue(manager, *args, **kwargs) + commands[-1].deadline = monotonic() - 1 + return result + + async def forbidden(*args): + pytest.fail("expired native receipt started close RPC") + + monkeypatch.setattr(ManagedMuxManagerV1, "issue_close", late_receipt) + monkeypatch.setattr(RemoteAppClientV1, "close_managed_mux", forbidden) + assert lmux.main(["close", "-t", "dev", "--yes"]) == 1 + request = commands[-1].close_request + assert request is not None and commands[-1].close_result is None + monkeypatch.setattr(ManagedMuxManagerV1, "issue_close", original_issue) + + async def wrong_origin(client, value): + assert value == request + result = ManagedMuxCloseStateV1(value.operation_id, value.instance_id, value.creation_operation_id, + value.name, value.mux_space_id, ManagedMuxClosePhaseV1.CLOSED) + return replace(result, instance_id="9" * 32) + + monkeypatch.setattr(RemoteAppClientV1, "read_managed_mux_close", wrong_origin) + capsys.readouterr() + assert lmux.main(["close-status", "--server", request.service_id, "--operation", request.operation_id]) == 1 + assert not capsys.readouterr().out + assert commands[-1].close_result is None + + +@pytest.mark.parametrize("race", ["name_reuse", "instance", "other_operation"]) +def test_close_confirmation_race_rejects_original_preview(managed_cli, monkeypatch, race): + from concurrent.futures import ThreadPoolExecutor + from dataclasses import replace + from io import StringIO + from time import monotonic + + from loushang.appserver.remote_client import RemoteAppClientV1 + from loushang.harnesstui.mux import terminal + + module, commands = managed_cli + diagnostic = Path.cwd() / "close-failure-probe.jsonl" + factory = module.coding_managed_process_request + + def probed(*args, **kwargs): + request = factory(*args, **kwargs) + return replace(request, argv=(request.argv[0], str(Path(__file__).with_name("_managed_close_probe.py")), + *request.argv[3:], str(diagnostic))) + + monkeypatch.setattr(module, "coding_managed_process_request", probed) + + async def screen(shell, **kwargs): + await shell.start() + return 0 + + monkeypatch.setattr(terminal, "run_hosted_mux_shell", screen) + assert lmux.main(["new", "-s", "dev"]) == 0 + native = commands[-1].creation._coordinator._starter._process + original_close = RemoteAppClientV1.close_managed_mux + frozen = [] + calls = [] + race_completed = [] + cycles = 8 if race == "name_reuse" else 1 + + async def counted(client, request): + calls.append(request) + assert not frozen or request.operation_id != frozen[0].close_operation + return await original_close(client, request) + + monkeypatch.setattr(RemoteAppClientV1, "close_managed_mux", counted) + + class Confirm(StringIO): + def readline(self, limit=-1): + outer = commands[-1] + frozen.append(outer) + service_id = outer.close_reservation.service.service_id + if race == "name_reuse": + for _ in range(cycles): + assert lmux.main(["close", "-t", "dev", "--yes"]) == 0 + assert lmux.main(["new", "-s", "dev"]) == 0 + assert commands[-1].creation.result.mux_space_id != outer.close_inspection.creation.mux_space_id + elif race == "instance": + with ThreadPoolExecutor(max_workers=1) as pool: + reaped = pool.submit(native._process.wait, 20) + assert lmux.main(["stop", "--server", service_id, "--yes"]) == 0 + assert reaped.result(timeout=2) == 0 + assert lmux.main(["start", "-t", "dev"]) == 0 + assert commands[-1].starter._starter._state.handoff.instance.instance_id != outer.close_instance_id + else: + winner = outer.close_manager.issue_close(outer.close_reservation, operation_id="e" * 32, + deadline=monotonic() + 5, wait_for_lock=True) + assert winner.operation_id != outer.close_operation + race_completed.append(race) + return "yes\n" + + monkeypatch.setattr(sys, "stdin", Confirm()) + assert lmux.main(["close", "-t", "dev"]) == 1 + assert race_completed == [race], diagnostic.read_text() if diagnostic.exists() else "no child failure record" + assert len(frozen) == 1 and frozen[0].close_request is None + assert frozen[0].native_closed and not frozen[0].active + assert len(calls) == (cycles if race == "name_reuse" else 0) + assert lmux.main(["attach", "-t", "dev"]) == 0 + + +@pytest.mark.parametrize("answer", ["\n", "", "no\n", "yes no\n", "yes\n"]) +def test_stop_confirmation_requires_complete_line_before_operation(managed_cli, monkeypatch, answer): + from io import StringIO + + from loushang.apphost.managed.contracts import ManagedServiceKeyV1 + + module, commands = managed_cli + # A pending durable service is sufficient to test confirmation; no process + # needs to be launched or terminated for this input-boundary regression. + monkeypatch.setattr(module, "_execute", lambda *a, **k: 0) + assert lmux.main(["new", "-s", "pending"]) == 0 + service = commands[-1].service + assert service is None # Native admission handles settled by command finalizer. + key = ManagedServiceKeyV1("coding", str(Path.cwd())) + # prepare new has reserved the name but run has not created a journal state. + # Supply a recorded state through the existing journal read seam, not a fake + # confirmation implementation. + from types import SimpleNamespace + + from loushang.apphost.managed.contracts import ManagedInstanceRefV1 + from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 + + instance = ManagedInstanceRefV1(commands[0].defaults.namespace.namespace_key, key.service_id, "b" * 32) + monkeypatch.setattr(ManagedServiceJournalV1, "read", lambda *a, **k: SimpleNamespace(handoff=SimpleNamespace(instance=instance))) + stream = StringIO(answer) + monkeypatch.setattr(sys, "stdin", stream) + assert lmux.main(["stop", "--server", key.service_id]) == 0 + command = commands[-1] + assert (command.stopper is not None) == (answer == "yes\n") + assert command.native_closed and not command.active + + +def test_attach_missing_target_never_initializes_or_starts(managed_cli, tmp_path): + _, commands = managed_cli + assert lmux.main(["attach", "-t", "missing"]) == 1 + assert not list(tmp_path.iterdir()) + assert commands[0].creation is None and commands[0].native_closed + + +def test_attach_does_not_follow_name_reused_during_connection(managed_cli, monkeypatch): + import asyncio + from time import monotonic + + from loushang.apphost.managed._files import ManagedStorageError + from loushang.apphost.managed.connection import ( + ManagedConnectionLeaseV1, + _settled_native, + ) + from loushang.apphost.managed.mux_management import ManagedMuxManagerV1 + from loushang.apphost.managed.registry import ManagedMuxReservationV1 + from loushang.coding.managed_process import APPLICATION_ID + from loushang.harnesstui.mux import terminal + + _, commands = managed_cli + attached = [] + + async def screen(shell, **kwargs): + await shell.start() + attached.append(shell.state.mux_space_id) + return 0 + + monkeypatch.setattr(terminal, "run_hosted_mux_shell", screen) + assert lmux.main(["new", "-s", "dev"]) == 0, _failure_diagnostic(commands[-1]) + original_prepare = ManagedConnectionLeaseV1.prepare + replaced = [] + + async def native(operation): + deadline = monotonic() + 5 + while True: + try: + return await _settled_native(operation) + except ManagedStorageError as error: + if error.code != "busy" or monotonic() >= deadline: + raise + await asyncio.sleep(0.01) + + async def replace_after_connect(connection, **kwargs): + await original_prepare(connection, **kwargs) + command = commands[-1] + registry, journal = command.namespace.registry, command.journal + state = await native(lambda: journal.read(deadline=monotonic() + 5)) + reservation = await native(lambda: registry.resolve("dev")) + manager = ManagedMuxManagerV1(registry, journal, command.defaults.namespace, reservation.service, + state.handoff.instance, application_id=APPLICATION_ID) + request = await native(lambda: manager.issue_close( + reservation, operation_id="d" * 32, deadline=monotonic() + 5)) + closed = await connection.managed_mux_close_client.close_managed_mux(request) + await native(lambda: manager.record_close(request, closed, deadline=monotonic() + 5)) + new = ManagedMuxReservationV1("dev", reservation.service, "e" * 32) + await native(lambda: registry.reserve_mux(new, deadline=monotonic() + 5)) + create = await native(lambda: manager.issue_create(new, deadline=monotonic() + 5)) + created = await connection.managed_mux_client.create_managed_mux(create) + await native(lambda: manager.record_created(create, created, deadline=monotonic() + 5)) + replaced.append(created.mux_space_id) + + monkeypatch.setattr(ManagedConnectionLeaseV1, "prepare", replace_after_connect) + status = lmux.main(["attach", "-t", "dev"]) + assert replaced, repr(commands[-1].failure) + assert status == 1 + assert len(attached) == len(replaced) == 1 and attached[0] != replaced[0] + assert commands[-1].native_closed and not commands[-1].active + monkeypatch.setattr(ManagedConnectionLeaseV1, "prepare", original_prepare) + assert lmux.main(["attach", "-t", "dev"]) == 0 + assert attached == [attached[0], replaced[0]] + + +@pytest.mark.parametrize("args", [[], ["attach"]]) +@pytest.mark.parametrize("changed", ["reservation", "instance", "missing", "stop"]) +def test_automatic_selection_does_not_replace_frozen_observation(managed_cli, monkeypatch, changed, args): + from dataclasses import replace + + from loushang.apphost.managed.discovery import ManagedDiscoveryV1 + from loushang.harnesstui.mux import terminal + + module, commands = managed_cli + entered = [] + + async def screen(shell, **kwargs): + await shell.start() + entered.append(shell.state) + return 0 + + monkeypatch.setattr(terminal, "run_hosted_mux_shell", screen) + assert lmux.main(["new", "-s", "dev"]) == 0 + resolve = ManagedDiscoveryV1.resolve + + def changed_after_selection(discovery, *args, **kwargs): + observed = resolve(discovery, *args, **kwargs) + if changed == "reservation": + return replace(observed, reservation=replace(observed.reservation, operation_id="e" * 32)) + if changed == "missing": + return None + if changed == "stop": + return replace(observed, stop_requested=True) + return replace(observed, instance=replace(observed.instance, instance_id="f" * 32)) + + monkeypatch.setattr(ManagedDiscoveryV1, "resolve", changed_after_selection) + monkeypatch.setattr(module.ManagedMuxCommand, "_select_probe", lambda *a: pytest.fail("unexpected selector")) + assert lmux.main(args) == 1 + assert len(entered) == 1 + assert commands[-1].connection is commands[-1].creation is commands[-1].starter is None + assert commands[-1].native_closed and not commands[-1].active + + +def test_runner_construction_failure_settles_original_native_owners(managed_cli, monkeypatch): + module, commands = managed_cli + + def fail(*a, **k): + raise RuntimeError("private failure") + + monkeypatch.setattr(module, "_execute", fail) + assert lmux.main(["new", "-s", "pending"]) == 1 + command = commands[0] + assert command.native_closed and command.namespace is command.service is command.journal is None + assert command.creation._coordinator._starter._process is None + + +def test_runner_refuses_to_enter_settles_original_native_owners(managed_cli, monkeypatch): + from loushang.coding.cli import mux + + _, commands = managed_cli + + class Runner: + def run(self, coroutine): + coroutine.close() + raise RuntimeError("runner refused before execution") + + def close(self): + pass + + monkeypatch.setattr(mux.asyncio, "Runner", Runner) + assert lmux.main(["new", "-s", "pending"]) == 1 + assert commands[0].native_closed and commands[0].namespace is None + + +def test_bare_preserves_pending_names_in_selector_instead_of_creating_main(managed_cli, monkeypatch, capsys): + from io import StringIO + + module, commands = managed_cli + with monkeypatch.context() as patch: + patch.setattr(module, "_execute", lambda *a, **k: 1) + assert lmux.main(["new", "-s", "pending"]) == 1 + capsys.readouterr() + stdin = StringIO("\n") + monkeypatch.setattr(stdin, "isatty", lambda: True) + monkeypatch.setattr(sys, "stdin", stdin) + assert lmux.main([]) == 0 + assert '"name": "pending"' in capsys.readouterr().out + assert commands[-1].creation is None + assert commands[-1].probe_result is not None + assert not commands[-1].probe.cleanup_pending + assert commands[-1].native_closed + + +@pytest.mark.parametrize("missing", ["registry", "witness", "database", "lock"]) +def test_ls_damaged_namespace_is_not_an_empty_first_start(managed_cli, monkeypatch, capsys, tmp_path, missing): + from loushang.apphost.managed.paths import ( + resolve_managed_admission_root, + resolve_managed_registry_root, + ) + + from ..apphost.test_managed_namespace_admission import tree + + module, commands = managed_cli + monkeypatch.setattr(module, "_execute", lambda *a, **k: 1) + assert lmux.main(["new", "-s", "pending"]) == 1 + namespace = commands[0].defaults.namespace + source = Path(resolve_managed_registry_root(namespace) if missing == "registry" else resolve_managed_admission_root(namespace)) + if missing in ("database", "lock"): + source = Path(resolve_managed_registry_root(namespace)) / ("registry.sqlite3" if missing == "database" else "registry.lock") + source.rename(tmp_path / "held-original") + before = tree(tmp_path) + capsys.readouterr() + assert lmux.main(["ls"]) == 1 + assert capsys.readouterr().out == "" + assert tree(tmp_path) == before diff --git a/tests/coding/test_lmux_creation_recovery.py b/tests/coding/test_lmux_creation_recovery.py new file mode 100644 index 000000000..4ae24f9c0 --- /dev/null +++ b/tests/coding/test_lmux_creation_recovery.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import json +import sys +from io import StringIO + +import pytest + +from loushang.coding.cli import lmux + +from .test_lmux_command import _failure_diagnostic +from .test_lmux_command import managed_cli as managed_cli + + +def _json_lines(output): + return [json.loads(line) for line in output.splitlines() if line.startswith("{")] + + +def _tree(root): + return {str(path.relative_to(root)): (path.lstat().st_mode, path.lstat().st_ino, + path.lstat().st_mtime_ns, + path.read_bytes() if path.is_file() else None) + for path in (root, *sorted(root.rglob("*")))} + + +@pytest.mark.parametrize("fault", ["reservation_ack", "permit_ack", "before_rpc", "after_rpc", "record_ack"]) +def test_real_creation_recovery_uses_original_operation_without_query_replay( + managed_cli, tmp_path, monkeypatch, capsys, fault, +): + from loushang.apphost.managed._files import ManagedStorageError + from loushang.apphost.managed.mux_management import ManagedMuxManagerV1 + from loushang.apphost.managed.registry import ManagedRegistryV1 + from loushang.appserver.remote_client import RemoteAppClientV1 + from loushang.harnesstui.mux import terminal + + _, commands = managed_cli + calls, attached = [], [] + original_rpc = RemoteAppClientV1.create_managed_mux + + async def rpc(client, request): + calls.append(request) + return await original_rpc(client, request) + + async def screen(shell, **kwargs): + await shell.start() + attached.append(shell.state.mux_space_id) + return 0 + + monkeypatch.setattr(RemoteAppClientV1, "create_managed_mux", rpc) + monkeypatch.setattr(terminal, "run_hosted_mux_shell", screen) + injected = [] + with monkeypatch.context() as patch: + if fault in ("reservation_ack", "permit_ack", "record_ack"): + cls, name = ((ManagedRegistryV1, "reserve_mux") if fault == "reservation_ack" else + (ManagedMuxManagerV1, "issue_create") if fault == "permit_ack" else + (ManagedMuxManagerV1, "record_created")) + original = getattr(cls, name) + + def lost(owner, *args, **kwargs): + original(owner, *args, **kwargs) + injected.append(fault) + raise ManagedStorageError("unavailable") + + patch.setattr(cls, name, lost) + else: + async def lost_rpc(client, request): + calls.append(request) + if fault == "after_rpc": + await original_rpc(client, request) + injected.append(fault) + raise ManagedStorageError("unavailable") + + patch.setattr(RemoteAppClientV1, "create_managed_mux", lost_rpc) + assert lmux.main(["new", "-s", "dev"]) == 1 + assert injected == [fault] + original_intent = commands[0].creation_reservation + assert original_intent is not None and not attached + planned = _json_lines(capsys.readouterr().out)[0] + assert planned["status"] == "planned_creation" and planned["muxId"] is None + assert planned["operationId"] == original_intent.operation_id + assert planned["activeReservation"] is None and not planned["automaticReplay"] + server, operation = original_intent.service.service_id, original_intent.operation_id + arguments = ["--server", server, "--operation", operation] + other = tmp_path / "elsewhere" + other.mkdir() + monkeypatch.chdir(other) + before_calls = len(calls) + # Read-only query neither connects/starts nor issues a permit, including + # the case where no service-admission record has ever been created. + before = _tree(tmp_path) + for verb in ("create-status", "create"): + assert lmux.main([verb, *arguments]) == (0 if fault == "record_ack" else 1) + report = _json_lines(capsys.readouterr().out)[-1] + assert report["status"] == ("created" if fault == "record_ack" else "unknown") + assert report["operationId"] == operation and report["liveStatus"] == "not_probed" + assert commands[-1].creation is commands[-1].connection is commands[-1].service is None + assert _tree(tmp_path) == before and len(calls) == before_calls + assert lmux.main(["ls"]) == 0 + listed = _json_lines(capsys.readouterr().out)[-1]["muxes"] + assert listed[0]["creationOperationId"] == operation + assert lmux.main(["create", *arguments, "--continue", "--yes"]) == 0, _failure_diagnostic(commands[-1]) + report = _json_lines(capsys.readouterr().out)[-1] + assert report["status"] == "created" and report["operationId"] == operation + assert report["workspace"] == str(tmp_path) + assert len(calls) == before_calls + (0 if fault == "record_ack" else 1) + assert all(request.operation_id == operation and request.name == "dev" for request in calls) + assert not attached # Recovery is scriptable, never implicitly a TUI entry. + assert commands[-1].native_closed and not commands[-1].active + assert lmux.main(["attach", "-t", "dev"]) == 0 + assert attached == [report["muxId"]] + capsys.readouterr() + assert lmux.main(["create-status", *arguments]) == 0 + assert _json_lines(capsys.readouterr().out)[-1]["muxId"] == report["muxId"] + # Original name protection is still in force; new never adopts old intent. + assert lmux.main(["new", "-s", "dev", "--workspace", str(tmp_path)]) == 1 + assert len(calls) == before_calls + (0 if fault == "record_ack" else 1) + if fault == "record_ack": + assert lmux.main(["close", "-t", "dev", "--yes"]) == 0 + assert lmux.main(["new", "-s", "dev", "--workspace", str(tmp_path)]) == 0 + replacement = commands[-1].creation.result + assert replacement.operation_id != operation and replacement.mux_space_id != report["muxId"] + capsys.readouterr() + count = len(calls) + assert lmux.main(["create-status", *arguments]) == 0 + history = _json_lines(capsys.readouterr().out)[-1] + assert history["muxId"] == report["muxId"] and not history["activeReservation"] + assert "continueCommand" not in history + assert lmux.main(["create", *arguments, "--continue", "--yes"]) == 1 + assert len(calls) == count and commands[-1].creation is None + + +@pytest.mark.parametrize("answer", ["", "\n", "no\n", "yes no\n"]) +def test_confirmation_rejection_does_not_admit_or_start_service(managed_cli, monkeypatch, capsys, answer): + module, commands = managed_cli + with monkeypatch.context() as patch: + patch.setattr(module.ManagedMuxCommand, "_prepare_creation_owner", lambda *_: (_ for _ in ()).throw(RuntimeError())) + assert lmux.main(["new", "-s", "pending"]) == 1 + reservation = commands[-1].creation_reservation + capsys.readouterr() + monkeypatch.setattr(sys, "stdin", StringIO(answer)) + assert lmux.main(["create", "--server", reservation.service.service_id, + "--operation", reservation.operation_id, "--continue"]) == 0 + assert commands[-1].creation is commands[-1].service is commands[-1].connection is None + + +def test_confirmation_race_does_not_adopt_released_name(managed_cli, monkeypatch): + from loushang.apphost.managed.registry import ManagedMuxReservationV1 + + module, commands = managed_cli + with monkeypatch.context() as patch: + patch.setattr(module.ManagedMuxCommand, "_prepare_creation_owner", lambda *_: (_ for _ in ()).throw(RuntimeError())) + assert lmux.main(["new", "-s", "pending"]) == 1 + reservation = commands[-1].creation_reservation + raced = [] + + class Confirm(StringIO): + def readline(self, size=-1): + registry = commands[-1].namespace.registry + # Storage-level target-replacement fault, not a public release API. + with registry._database.transaction(write=True) as connection: + connection.execute("DELETE FROM muxes WHERE operation_id=?", (reservation.operation_id,)) + winner = ManagedMuxReservationV1(reservation.name, reservation.service, "e" * 32) + registry.reserve_mux(winner) + assert registry.resolve(reservation.name) == winner + raced.append(True) + return "yes\n" + + monkeypatch.setattr(sys, "stdin", Confirm()) + assert lmux.main(["create", "--server", reservation.service.service_id, + "--operation", reservation.operation_id, "--continue"]) == 1 + assert raced == [True] + assert commands[-1].creation is commands[-1].service is commands[-1].connection is None + + +def test_release_after_recheck_is_rejected_by_original_reserve_before_start(managed_cli, monkeypatch): + from loushang.apphost.managed.coordinator import ManagedServiceCoordinatorV1 + from loushang.apphost.managed.registry import ( + ManagedMuxReservationV1, + ManagedRegistryV1, + ) + from loushang.appserver.remote_client import RemoteAppClientV1 + + module, commands = managed_cli + with monkeypatch.context() as patch: + patch.setattr(module.ManagedMuxCommand, "_prepare_creation_owner", lambda *_: (_ for _ in ()).throw(RuntimeError())) + assert lmux.main(["new", "-s", "pending"]) == 1 + reservation = commands[-1].creation_reservation + original = ManagedRegistryV1.reserve_mux + races, starts, rpcs = [], [], [] + + def replace_before_reserve(registry, request, **kwargs): + assert commands[-1].creation_recovery and commands[-1].creation is not None + with registry._database.transaction(write=True) as connection: + connection.execute("DELETE FROM muxes WHERE operation_id=?", (reservation.operation_id,)) + winner = ManagedMuxReservationV1(reservation.name, reservation.service, "e" * 32) + original(registry, winner, **kwargs) + assert registry.resolve(reservation.name) == winner + races.append(True) + return original(registry, request, **kwargs) + + async def unexpected_start(*args, **kwargs): + starts.append(True) + raise AssertionError("must not start") + + async def unexpected_rpc(*args, **kwargs): + rpcs.append(True) + raise AssertionError("must not create") + + monkeypatch.setattr(ManagedRegistryV1, "reserve_mux", replace_before_reserve) + monkeypatch.setattr(ManagedServiceCoordinatorV1, "ensure_started", unexpected_start) + monkeypatch.setattr(RemoteAppClientV1, "create_managed_mux", unexpected_rpc) + assert lmux.main(["create", "--server", reservation.service.service_id, + "--operation", reservation.operation_id, "--continue", "--yes"]) == 1 + assert races == [True] and starts == rpcs == [] + assert commands[-1].creation._coordinator._starter._process is None + assert commands[-1].creation.result is None + + +def test_prior_instance_unreceived_creation_is_not_guessed_safe_after_clean_restart(managed_cli, monkeypatch): + from concurrent.futures import ThreadPoolExecutor + + from loushang.apphost.managed._files import ManagedStorageError + from loushang.apphost.managed.mux_management import ManagedMuxManagerV1 + from loushang.appserver.remote_client import RemoteAppClientV1 + + _, commands = managed_cli + original_issue = ManagedMuxManagerV1.issue_create + issued, observed = [], [] + + def lost_permit(manager, *args, **kwargs): + result = original_issue(manager, *args, **kwargs) + issued.append(result) + raise ManagedStorageError("unavailable") + + with monkeypatch.context() as patch: + patch.setattr(ManagedMuxManagerV1, "issue_create", lost_permit) + assert lmux.main(["new", "-s", "pending"]) == 1 + assert len(issued) == 1 + reservation = commands[0].creation_reservation + native = commands[0].creation._coordinator._starter._process + service_id = reservation.service.service_id + with ThreadPoolExecutor(max_workers=1) as pool: + reaped = pool.submit(native._process.wait, 20) + assert lmux.main(["stop", "--server", service_id, "--yes"]) == 0 + assert reaped.result(timeout=2) == 0 + assert lmux.main(["start", "-t", "pending"]) == 0 + new_instance = commands[-1].starter._starter._state.handoff.instance.instance_id + assert new_instance != issued[0].instance_id + original_rpc = RemoteAppClientV1.create_managed_mux + + async def inspect_rejection(client, request): + assert (await client.list_muxes()).mux_spaces == () + try: + return await original_rpc(client, request) + finally: + observed.append((request, (await client.list_muxes()).mux_spaces)) + + monkeypatch.setattr(RemoteAppClientV1, "create_managed_mux", inspect_rejection) + assert lmux.main(["create", "--server", service_id, "--operation", reservation.operation_id, + "--continue", "--yes"]) == 1 + assert len(observed) == 1 and observed[0][1] == () + assert observed[0][0].operation_id == reservation.operation_id + assert observed[0][0].instance_id == new_instance + assert commands[-1].creation.result is None + assert lmux.main(["create-status", "--server", service_id, "--operation", reservation.operation_id]) == 1 + assert len(observed) == 1 + + +@pytest.mark.parametrize("wrong", ["service", "operation"]) +def test_recovery_wrong_identity_is_readonly_and_never_adopts_current_name(managed_cli, tmp_path, monkeypatch, wrong): + module, commands = managed_cli + with monkeypatch.context() as patch: + patch.setattr(module.ManagedMuxCommand, "_prepare_creation_owner", lambda *_: (_ for _ in ()).throw(RuntimeError())) + assert lmux.main(["new", "-s", "pending"]) == 1 + reservation = commands[-1].creation_reservation + before = _tree(tmp_path) + args = ["--server", "e" * 64 if wrong == "service" else reservation.service.service_id, + "--operation", "e" * 32 if wrong == "operation" else reservation.operation_id] + for verb, extra in (("create-status", []), ("create", ["--continue", "--yes"])): + assert lmux.main([verb, *args, *extra]) == 1 + assert commands[-1].creation is commands[-1].service is commands[-1].connection is None + assert _tree(tmp_path) == before + + +@pytest.mark.parametrize("args", [ + ["create-status", "--server", "a" * 64, "--operation", "b" * 32], + ["create", "--server", "a" * 64, "--operation", "b" * 32], + ["create", "--server", "a" * 64, "--operation", "b" * 32, "--continue", "--yes"], +]) +def test_non_tty_queries_and_confirmed_continue_do_not_initialize_missing_namespace(tmp_path, monkeypatch, args): + from loushang.apphost.managed import defaults + + monkeypatch.setattr(defaults, "linux_machine_key", lambda **_: "a" * 32) + monkeypatch.setenv("LOUSHANG_HOME", str(tmp_path / "platform")) + monkeypatch.setenv("LOUSHANG_RUNTIME_DIR", str(tmp_path / "runtime")) + monkeypatch.delenv("LOUSHANG_TMPDIR", raising=False) + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + assert lmux.main(args) == 1 # not_found, not a TTY parser rejection. + assert list(tmp_path.iterdir()) == [] + + +@pytest.mark.parametrize("arguments", [ + ["create"], ["create-status", "--server", "alias", "--operation", "b" * 32], + ["create", "--server", "a" * 64, "--operation", "bad"], + ["create", "--server", "a" * 64, "--operation", "b" * 32, "--yes"], + ["create", "--server", "a" * 64, "--operation", "b" * 32, "--continue"], +]) +def test_invalid_or_unconfirmed_non_tty_creation_recovery_has_zero_lookup(monkeypatch, arguments): + from loushang.coding.cli import lmux_command + + monkeypatch.setattr(lmux_command, "resolve_managed_defaults", lambda **_: pytest.fail("unexpected lookup")) + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + with pytest.raises(SystemExit) as caught: + lmux.main(arguments) + assert caught.value.code == 2 diff --git a/tests/coding/test_lmux_logs.py b/tests/coding/test_lmux_logs.py new file mode 100644 index 000000000..1df692b4a --- /dev/null +++ b/tests/coding/test_lmux_logs.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import json +from pathlib import Path +from time import monotonic + +import pytest + +from loushang.apphost.managed._files import PrivateManagedDirectory +from loushang.apphost.managed.contracts import ManagedServiceKeyV1 +from loushang.apphost.managed.event_log import ( + ManagedLifecycleEventV1, + ManagedLifecycleLogV1, +) +from loushang.apphost.managed.namespace_admission import ManagedNamespaceAdmissionV1 +from loushang.apphost.managed.paths import resolve_managed_service_paths +from loushang.apphost.managed.registry import ManagedMuxReservationV1 +from loushang.apphost.managed.storage_budget import ManagedStorageBudgetV1 +from loushang.coding.cli import lmux +from tests.apphost.test_managed_namespace_admission import tree + +from . import test_lmux_status as fixtures + +namespace = fixtures.namespace +pytestmark = fixtures.pytestmark + + +@pytest.fixture +def logs(namespace, tmp_path): + admission = ManagedNamespaceAdmissionV1(namespace.namespace, runtime_root=str(namespace.platform.runtime), + create_if_missing=True) + directory = None + service = ManagedServiceKeyV1("coding", str(tmp_path)) + try: + registry = admission.open(deadline=monotonic() + 5) + registry.reserve_mux(ManagedMuxReservationV1("dev", service, "b" * 32)) + paths = resolve_managed_service_paths(namespace.namespace, service, runtime_root=str(namespace.platform.runtime)) + directory = PrivateManagedDirectory(Path(paths.logs), create=True, create_parents=True) + writer = ManagedLifecycleLogV1(directory, ManagedStorageBudgetV1(registry), service.service_id) + for name in ("starting", "ready", "stopping", "stopped"): + writer.write(ManagedLifecycleEventV1(name, "c" * 32)) + yield Path(paths.logs), service + finally: + if directory is not None: + directory.close() + admission.close() + + +@pytest.mark.parametrize("limit", ["0", "101", "-1", "true", "text"]) +def test_bad_limit_rejects_before_default_resolution(namespace, tmp_path, monkeypatch, limit): + from loushang.apphost.managed import defaults + + monkeypatch.setattr(defaults, "resolve_managed_defaults", lambda **kw: pytest.fail("invalid limit resolved paths")) + with pytest.raises(SystemExit) as error: + lmux.main(["logs", "-t", "dev", "--limit", limit]) + assert error.value.code == 2 and not tuple(tmp_path.iterdir()) + + +def test_logs_are_bounded_pure_values_without_connection_or_writes(logs, tmp_path, monkeypatch, capsys): + _, service = logs + from loushang.coding.cli import lmux_command + + def forbidden(*args, **kwargs): + pytest.fail("logs attempted activation or file mutation") + + monkeypatch.setattr(lmux_command, "ManagedConnectionLeaseV1", forbidden) + monkeypatch.setattr(lmux_command, "ManagedServiceCoordinatorV1", forbidden) + monkeypatch.setattr(PrivateManagedDirectory, "append_data", forbidden) + monkeypatch.setattr(ManagedStorageBudgetV1, "reserve", forbidden) + before = tree(tmp_path) + assert lmux.main(["logs", "-t", "dev", "--limit", "2"]) == 0 + result = json.loads(capsys.readouterr().out) + assert result["observation"] == "bounded_tail" and not result["completeHistory"] + assert result["scan"] == {"maxSegments": 5, "maxBytesPerSegment": 16384} + assert [item["event"] for item in result["events"]] == ["stopping", "stopped"] + assert [item["sequence"] for item in result["events"]] == [3, 4] + assert all(item["instanceId"] == "c" * 32 for item in result["events"]) + assert lmux.main(["logs", "--server", service.service_id, "--limit", "2"]) == 0 + assert json.loads(capsys.readouterr().out) == result + assert tree(tmp_path) == before + + +def test_bad_visible_frame_has_no_partial_output_or_secret(logs, tmp_path, capsys): + root, _ = logs + path = root / "lifecycle-0.jsonl" + last = path.read_bytes().splitlines(keepends=True)[-1] + path.write_bytes(b'{"message":"secret text"}\n' + last) + before = tree(tmp_path) + assert lmux.main(["logs", "-t", "dev", "--limit", "1"]) == 1 + captured = capsys.readouterr() + assert not captured.out and captured.err == "lmux_invalid_record\n" + assert tree(tmp_path) == before + + +def test_missing_namespace_logs_never_create_it(namespace, tmp_path, capsys): + assert lmux.main(["logs", "-t", "dev"]) == 1 + assert capsys.readouterr().err == "lmux_not_found\n" + assert not tuple(tmp_path.iterdir()) diff --git a/tests/coding/test_lmux_selector.py b/tests/coding/test_lmux_selector.py new file mode 100644 index 000000000..d4f2a2154 --- /dev/null +++ b/tests/coding/test_lmux_selector.py @@ -0,0 +1,256 @@ +import sys +from concurrent.futures import ThreadPoolExecutor +from dataclasses import replace +from io import StringIO +from time import monotonic + +import pytest + +from loushang.apphost.managed.contracts import ( + ManagedContractError, + ManagedHandoffPhaseV1, + ManagedInstanceRefV1, + ManagedServiceKeyV1, +) +from loushang.apphost.managed.discovery import ManagedMuxObservationV1 +from loushang.apphost.managed.registry import ManagedMuxReservationV1 +from loushang.coding.cli import lmux +from loushang.coding.cli import lmux_command as module + +from .test_lmux_command import managed_cli as managed_cli + + +def observation(name): + reservation = ManagedMuxReservationV1(name, ManagedServiceKeyV1("coding", "/workspace"), "a" * 32) + return ManagedMuxObservationV1(reservation, None, None, None, False, False) + + +def committed_observation(name="dev"): + item = observation(name) + return replace(item, instance=ManagedInstanceRefV1("b" * 64, item.service.service_id, "c" * 32), + revision=1, recorded_phase=ManagedHandoffPhaseV1.COMMITTED) + + +def test_sole_committed_candidate_is_only_a_selection(): + item = committed_observation() + assert module._single_candidate((item,)) is item + + +@pytest.mark.parametrize("case", ["empty", "multiple", "pending", "provisional", "aborting", "stop", "clean", "product", "full_page"]) +def test_automatic_selection_rejects_ineligible_observations(monkeypatch, case): + item = committed_observation() + page = (item,) + if case == "empty": + page = () + elif case == "multiple": + page = (item, observation("pending")) + elif case == "pending": + page = (observation("pending"),) + elif case in {"provisional", "aborting"}: + page = (replace(item, recorded_phase=ManagedHandoffPhaseV1(case)),) + elif case in {"stop", "clean"}: + page = (replace(item, stop_requested=True, cleanly_stopped=case == "clean"),) + elif case == "product": + service = ManagedServiceKeyV1("work", "/workspace") + page = (replace(item, reservation=replace(item.reservation, service=service), + instance=replace(item.instance, service_id=service.service_id)),) + elif case == "full_page": + monkeypatch.setattr(module, "MAX_PAGE", 1) + assert module._single_candidate(page) is None + + +def select(monkeypatch, text, observations): + from loushang.apphost.managed.mux_probe import ( + ManagedMuxProbeResultV1, + ManagedMuxProbeSnapshotV1, + ) + + monkeypatch.setattr(module, "MAX_PAGE", 2) + command = object.__new__(module.ManagedMuxCommand) + command.stdin, command.stdout = StringIO(text), StringIO() + command.deadline = 0.0 + monkeypatch.setattr(command, "_probe_snapshot", lambda: pytest.fail("paging must not probe")) + rows = tuple(ManagedMuxProbeResultV1(item, "unknown") for item in observations) + result = command._select_probe(ManagedMuxProbeSnapshotV1(rows, True)) + return result, command.stdout.getvalue() + + +def test_next_page_returns_original_displayed_observation(monkeypatch): + last = observation("z") + result, output = select(monkeypatch, "n\n1\n", (observation("a"), observation("b"), last)) + assert result is last + assert '"name": "z"' in output and '"status": "unknown"' in output + + +def test_restart_page_and_cancel(monkeypatch): + result, _ = select(monkeypatch, "n\nr\n\n", ( + observation("a"), observation("b"), observation("z"), + )) + assert result is None + + +def test_exact_full_final_page_stays_then_allows_selection(monkeypatch): + first = observation("a") + result, output = select(monkeypatch, "n\n1\n", (first, observation("b"))) + assert result is first and "No more muxes" in output + + +@pytest.mark.parametrize("text", ["3\n", "x\n", "1" * 40 + "\n"]) +def test_invalid_selection_rejects_without_page_io(monkeypatch, text): + with pytest.raises(ManagedContractError): + select(monkeypatch, text, (observation("a"),)) + + +def test_bare_pending_snapshot_cancel_never_creates_main(managed_cli, monkeypatch): + _, commands = managed_cli + with monkeypatch.context() as patch: + patch.setattr(module, "_execute", lambda *args, **kwargs: 1) + assert lmux.main(["new", "-s", "pending"]) == 1 + monkeypatch.setattr(sys, "stdin", StringIO("\n")) + assert lmux.main([]) == 0 + command = commands[-1] + assert [(row.observation.name, row.status) for row in command.probe_result.results] == [("pending", "unknown")] + assert not command.probe.cleanup_pending + assert command.creation is command.connection is command.starter is None + assert command.native_closed and not command.active + + +def test_probe_selector_pages_only_frozen_values(monkeypatch): + from loushang.apphost.managed.mux_probe import ( + ManagedMuxProbeResultV1, + ManagedMuxProbeSnapshotV1, + ) + + monkeypatch.setattr(module, "MAX_PAGE", 2) + command = object.__new__(module.ManagedMuxCommand) + command.stdin, command.stdout = StringIO("n\nr\nn\n1\n"), StringIO() + monkeypatch.setattr(command, "_probe_snapshot", lambda: pytest.fail("paging must not probe")) + rows = tuple(ManagedMuxProbeResultV1(observation(name), "unknown") for name in ("a", "b", "z")) + assert command._select_probe(ManagedMuxProbeSnapshotV1(rows, True)) == rows[2].observation + assert command.deadline > monotonic() + + +def test_probe_selector_refresh_replaces_snapshot(monkeypatch): + from loushang.apphost.managed.mux_probe import ( + ManagedMuxProbeResultV1, + ManagedMuxProbeSnapshotV1, + ) + + command = object.__new__(module.ManagedMuxCommand) + command.stdin, command.stdout = StringIO("f\n1\n"), StringIO() + original = ManagedMuxProbeSnapshotV1((ManagedMuxProbeResultV1(observation("old"), "unknown"),), True) + refreshed = ManagedMuxProbeSnapshotV1((ManagedMuxProbeResultV1(observation("new"), "unknown"),), True) + calls = [] + monkeypatch.setattr(command, "_probe_snapshot", lambda: calls.append(1) or refreshed) + assert command._select_probe(original).name == "new" + assert calls == [1] + + +def test_probe_selector_last_page_next_does_not_fail_or_probe(monkeypatch): + from loushang.apphost.managed.mux_probe import ( + ManagedMuxProbeResultV1, + ManagedMuxProbeSnapshotV1, + ) + + command = object.__new__(module.ManagedMuxCommand) + command.stdin, command.stdout = StringIO("n\n\n"), StringIO() + monkeypatch.setattr(command, "_probe_snapshot", lambda: pytest.fail("last page must not probe")) + snapshot = ManagedMuxProbeSnapshotV1((ManagedMuxProbeResultV1(observation("pending"), "unknown"),), True) + assert command._select_probe(snapshot) is None + assert "staying on this page" in command.stdout.getvalue() + assert "n next page" not in command.stdout.getvalue() + + +@pytest.mark.parametrize("args", [[], ["attach"]]) +def test_single_candidate_reconnects_without_reading_selection(managed_cli, tmp_path, monkeypatch, args): + from loushang.harnesstui.mux import terminal + + _, commands = managed_cli + attached = [] + + async def screen(shell, **kwargs): + await shell.start() + attached.append(shell.state) + return 0 + + monkeypatch.setattr(terminal, "run_hosted_mux_shell", screen) + assert lmux.main(["new", "-s", "dev"]) == 0 + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + monkeypatch.setattr(module.ManagedMuxCommand, "_select_probe", lambda *a: pytest.fail("unexpected selector")) + assert lmux.main(args) == 0, repr(commands[-1].failure) + assert len(attached) == 2 + assert commands[-1].creation is commands[-1].starter is None + assert commands[-1].native_closed and not commands[-1].active + assert not list(elsewhere.iterdir()) + + +def test_only_live_mux_among_multiple_services_attaches_without_selector(managed_cli, tmp_path, monkeypatch): + from loushang.harnesstui.mux import terminal + + _, commands = managed_cli + attached = [] + + async def screen(shell, **kwargs): + await shell.start() + attached.append(shell.state) + return 0 + + monkeypatch.setattr(terminal, "run_hosted_mux_shell", screen) + assert lmux.main(["new", "-s", "live"]) == 0 + live_process = commands[-1].creation._coordinator._starter._process + other = tmp_path / "other" + other.mkdir() + assert lmux.main(["new", "-s", "stopped", "--workspace", str(other)]) == 0 + stopped_process = commands[-1].creation._coordinator._starter._process + stopped_service = ManagedServiceKeyV1("coding", str(other)).service_id + # This test remains the original Popen parent. Reap its own child while the + # unrelated stop operation waits for the real process group to disappear. + with ThreadPoolExecutor(max_workers=1) as pool: + reaped = pool.submit(stopped_process._process.wait, 20) + assert lmux.main(["stop", "--server", stopped_service, "--yes"]) == 0, repr(commands[-1].failure) + assert reaped.result(timeout=2) == 0 + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.chdir(elsewhere) + monkeypatch.setattr(module.ManagedMuxCommand, "_select_probe", lambda *a: pytest.fail("unique live mux opened selector")) + monkeypatch.setattr(module.ManagedMuxCommand, "_select_probe", lambda *a: pytest.fail("unique live mux opened selector")) + assert lmux.main(["attach"]) == 0, repr(commands[-1].failure) + assert len(attached) == 3 + assert commands[-1].target == "live" + assert commands[-1].creation is commands[-1].starter is None + assert live_process._process.poll() is None + assert commands[-1].native_closed and not commands[-1].active + assert not tuple(elsewhere.iterdir()) + + +@pytest.mark.parametrize("args", [[], ["attach"]]) +def test_single_candidate_handshake_failure_does_not_restart_or_reselect(managed_cli, monkeypatch, args): + from loushang.apphost.managed import connection + from loushang.harnesstui.mux import terminal + + _, commands = managed_cli + entered = [] + + async def screen(shell, **kwargs): + await shell.start() + entered.append(shell.state) + return 0 + + monkeypatch.setattr(terminal, "run_hosted_mux_shell", screen) + assert lmux.main(["new", "-s", "dev"]) == 0 + attempts = [] + + async def reject_handshake(client): + attempts.append(client) + raise connection.AppConnectionClosedError() + + monkeypatch.setattr(connection.LocalAppClientConnectionV1, "start", reject_handshake) + monkeypatch.setattr(module.ManagedMuxCommand, "_select_probe", lambda *a: pytest.fail("unexpected selector")) + assert lmux.main(args) == 1 + command = commands[-1] + assert len(attempts) == len(entered) == 1 + assert command.creation is command.starter is None + assert command.connection is not None and not command.connection.cleanup_pending + assert command.native_closed and not command.active diff --git a/tests/coding/test_lmux_server_start.py b/tests/coding/test_lmux_server_start.py new file mode 100644 index 000000000..a114bedd8 --- /dev/null +++ b/tests/coding/test_lmux_server_start.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import json +import sys + +import pytest + +from loushang.apphost.managed.contracts import ManagedServiceKeyV1 +from loushang.coding.cli import lmux + +from .test_lmux_command import managed_cli as managed_cli + +CLI_MAIN = lmux.main + + +@pytest.mark.parametrize("args", [["server"], ["server", "stop"], + ["server", "start", "-t", "dev"]]) +def test_invalid_server_forms_reject_before_defaults(monkeypatch, args): + from loushang.apphost.managed import defaults + + monkeypatch.setattr(defaults, "resolve_managed_defaults", lambda **kw: pytest.fail("invalid form lookup")) + with pytest.raises(SystemExit) as caught: + lmux.main(args) + assert caught.value.code == 2 + + +def test_server_start_is_scriptable(monkeypatch): + from loushang.coding.cli import lmux_command + + calls = [] + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.setattr(lmux_command, "execute", lambda args, **kw: calls.append(args) or 0) + assert lmux.main(["server", "start", "--workspace", "/example"]) == 0 + assert calls[0].action == "server" and calls[0].workspace == "/example" + + +@pytest.mark.parametrize("duration", ["0", "-1", "3601", "1.5", "invalid"]) +def test_invalid_trace_duration_rejects_before_defaults(monkeypatch, duration): + from loushang.coding.cli import lmux_command + + monkeypatch.setattr(lmux_command, "execute", lambda *a, **kw: pytest.fail("invalid trace executed")) + with pytest.raises(SystemExit) as caught: + CLI_MAIN(["server", "start", "--trace-for", duration]) + assert caught.value.code == 2 + + +def test_real_trace_start_and_reuse_do_not_renew_request(managed_cli, capsys): + _, commands = managed_cli + status = lmux.main(["server", "start", "--trace-for", "60"]) + first = json.loads(capsys.readouterr().out) + if status != 0: + from pathlib import Path + + logs = Path(commands[-1].starter._starter._layout.paths.logs) + pytest.fail(f"trace startup result: {first!r}; log files: {sorted(p.name for p in logs.iterdir())!r}") + assert first["status"] == "service_ready" + assert first["trace"]["status"] == "applied" + assert first["trace"]["deadlineMs"] == commands[-1].trace_deadline_ms + assert first["trace"]["operationId"] == commands[-1].starter.operation_id + assert first["trace"]["observation"] == "historical_configuration_not_write_guarantee" + native = commands[-1].starter._starter._process + assert native._process.poll() is None + assert lmux.main(["server", "start", "--trace-for", "120"]) == 1 + reused = json.loads(capsys.readouterr().out) + assert reused["status"] == "service_ready" + assert reused["instanceId"] == first["instanceId"] + assert reused["trace"]["status"] == "not_applied_reused_instance" + assert reused["trace"]["operationId"] != first["trace"]["operationId"] + assert commands[-1].starter._starter._process is None + assert native._process.poll() is None + + +@pytest.mark.parametrize("outcome", ["expired", "not_confirmed", "conflict", "invalid_record", "unavailable", "unexpected"]) +def test_trace_observation_outcome_preserves_ready_service(managed_cli, monkeypatch, capsys, outcome): + from loushang.apphost.managed._files import ManagedStorageError + from loushang.apphost.managed.coordinator import ManagedServiceCoordinatorV1 + + _, commands = managed_cli + observations = [] + + async def observe(owner, **kwargs): + observations.append(owner.instance) + if outcome in {"expired", "not_confirmed"}: + return outcome + if outcome == "unexpected": + raise RuntimeError("private diagnostic detail") + raise ManagedStorageError(outcome) + + monkeypatch.setattr(ManagedServiceCoordinatorV1, "observe_requested_trace", observe) + assert lmux.main(["server", "start", "--trace-for", "60"]) == 1 + captured = capsys.readouterr() + result = json.loads(captured.out) + assert captured.err == "" + assert len(observations) == 1 + assert result["status"] == "service_ready" + assert result["serviceId"] == observations[0].service_id + assert result["instanceId"] == observations[0].instance_id + if outcome in {"expired", "not_confirmed"}: + assert result["trace"]["status"] == outcome + assert "errorCode" not in result["trace"] + else: + assert result["trace"]["status"] == "observation_failed" + assert result["trace"]["errorCode"] == ("unavailable" if outcome == "unexpected" else outcome) + assert "private diagnostic detail" not in captured.out + assert commands[-1].starter._starter._process._process.poll() is None + assert commands[-1].native_closed + + +def test_real_empty_service_start_reuse_then_new(managed_cli, tmp_path, monkeypatch, capsys): + from loushang.apphost.managed.coordinator import ManagedServiceCoordinatorV1 + from loushang.harnesstui.mux import terminal + + _, commands = managed_cli + original_start = ManagedServiceCoordinatorV1.ensure_started + inspected = [] + + async def checked_start(coordinator, **kwargs): + ready = await original_start(coordinator, **kwargs) + assert not (await ready.client.list_muxes()).mux_spaces + inspected.append(ready.instance) + return ready + + async def screen(shell, **kwargs): + await shell.start() + return 0 + + monkeypatch.setattr(ManagedServiceCoordinatorV1, "ensure_started", checked_start) + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + monkeypatch.setattr(sys.stdin, "readline", lambda *a: pytest.fail("server read stdin")) + monkeypatch.setattr(terminal, "run_hosted_mux_shell", lambda *a, **k: pytest.fail("server entered terminal")) + assert CLI_MAIN(["server", "start"]) == 0, repr(commands[-1].failure) + first = json.loads(capsys.readouterr().out) + service_id = ManagedServiceKeyV1("coding", str(tmp_path)).service_id + assert first["status"] == "service_ready" and first["serviceId"] == service_id + assert commands[-1].creation is None and commands[-1].native_closed + assert not (commands[-1].defaults.platform.data / "sessions").exists() + native = commands[-1].starter._starter._process + assert native._process.poll() is None + assert lmux.main(["ls"]) == 0 + assert json.loads(capsys.readouterr().out)["muxes"] == [] + other = tmp_path / "other" + other.mkdir() + monkeypatch.chdir(other) + assert lmux.main(["server", "start", "--workspace", str(tmp_path)]) == 0 + assert json.loads(capsys.readouterr().out) == first + assert commands[-1].starter._starter._process is None + assert lmux.main(["status", "--server", service_id]) == 0 + assert json.loads(capsys.readouterr().out)["instanceId"] == first["instanceId"] + assert len(inspected) == 2 and inspected[0] == inspected[1] + monkeypatch.setattr(ManagedServiceCoordinatorV1, "ensure_started", original_start) + monkeypatch.setattr(terminal, "run_hosted_mux_shell", screen) + assert lmux.main(["new", "-s", "dev", "--workspace", str(tmp_path)]) == 0 + assert commands[-1].creation._coordinator._starter._process is None + assert native._process.poll() is None + assert not tuple(other.iterdir()) + + +def test_missing_workspace_does_not_admit_namespace(managed_cli, tmp_path): + _, commands = managed_cli + assert lmux.main(["server", "start", "--workspace", str(tmp_path / "missing")]) == 1 + assert commands[-1].namespace is None and commands[-1].native_closed + assert not tuple(tmp_path.iterdir()) + + +def test_coordinator_construction_failure_closes_original_admission(managed_cli, tmp_path, monkeypatch, capsys): + module, commands = managed_cli + retained = [] + + def fail(journal, *args, **kwargs): + retained.extend((commands[-1].namespace, commands[-1].service)) + raise RuntimeError("injected coordinator construction failure") + + monkeypatch.setattr(module, "ManagedServiceCoordinatorV1", fail) + assert lmux.main(["server", "start"]) == 1 + assert capsys.readouterr().err == "lmux_unavailable\n" + assert commands[-1].native_closed and commands[-1].starter is None + assert commands[-1].service is None and commands[-1].namespace is None + assert all(not owner.cleanup_pending for owner in retained) + # A durable admitted service is not deleted to hide a failed command. + service_id = ManagedServiceKeyV1("coding", str(tmp_path)).service_id + assert lmux.main(["status", "--server", service_id]) == 0 + recorded = json.loads(capsys.readouterr().out) + assert recorded["serviceId"] == service_id and recorded["instanceId"] is None + assert lmux.main(["ls"]) == 0 + assert json.loads(capsys.readouterr().out)["muxes"] == [] diff --git a/tests/coding/test_lmux_service_aliases.py b/tests/coding/test_lmux_service_aliases.py new file mode 100644 index 000000000..072a78eb6 --- /dev/null +++ b/tests/coding/test_lmux_service_aliases.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import json +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from loushang.coding.cli import lmux + +from .test_lmux_command import managed_cli as managed_cli + + +@pytest.mark.parametrize("args", [ + ["server", "start", "--name", "a" * 64], + ["server", "start", "--name", "A" * 64], + ["server", "start", "--name", "bad:name"], + ["close-status", "--server", "build", "--operation", "a" * 32], +]) +def test_bad_alias_and_historical_alias_forms_reject_before_lookup(monkeypatch, args): + from loushang.apphost.managed import defaults + + monkeypatch.setattr(defaults, "resolve_managed_defaults", lambda **kw: pytest.fail("invalid alias lookup")) + with pytest.raises(SystemExit) as error: + lmux.main(args) + assert error.value.code == 2 + + +def test_real_alias_start_reuse_cross_cwd_diagnostics_and_stop(managed_cli, tmp_path, monkeypatch, capsys): + _, commands = managed_cli + assert lmux.main(["server", "start", "--name", "build"]) == 0 + ready = json.loads(capsys.readouterr().out) + first = commands[-1] + native = first.starter._starter._process + reservation = first.alias_reservation + assert reservation.name == "build" + assert lmux.main(["server", "start", "--name", "build"]) == 0 + assert json.loads(capsys.readouterr().out) == ready + assert commands[-1].alias_reservation == reservation + assert commands[-1].starter._starter._process is None + # A service cannot silently adopt a second label. + assert lmux.main(["server", "start", "--name", "other"]) == 1 + assert commands[-1].starter is None and commands[-1].service is None + other = tmp_path / "other" + other.mkdir() + monkeypatch.chdir(other) + assert lmux.main(["server", "start", "--name", "build"]) == 1 + assert commands[-1].starter is None and commands[-1].service is None + capsys.readouterr() + assert lmux.main(["status", "--server", "build"]) == 0 + status = json.loads(capsys.readouterr().out) + assert status["serviceId"] == ready["serviceId"] and status["instanceId"] == ready["instanceId"] + assert lmux.main(["logs", "--server", "build"]) == 0 + capsys.readouterr() + assert lmux.main(["stop", "--server", "missing", "--yes"]) == 1 + assert commands[-1].stopper is None and native._process.poll() is None + with ThreadPoolExecutor(max_workers=1) as pool: + reaped = pool.submit(native._process.wait, 20) + assert lmux.main(["stop", "--server", "build", "--yes"]) == 0 + assert reaped.result(timeout=2) == 0 + capsys.readouterr() + assert lmux.main(["status", "--server", "build"]) == 0 + assert json.loads(capsys.readouterr().out)["serviceId"] == ready["serviceId"] + assert not tuple(other.iterdir()) + + +@pytest.mark.parametrize("same_service", [True, False]) +def test_first_lookup_miss_reconciles_only_same_service_winner(managed_cli, tmp_path, monkeypatch, same_service): + import os + from dataclasses import replace + from io import StringIO + + from loushang.apphost.managed._files import ManagedStorageError + from loushang.apphost.managed.contracts import ManagedServiceKeyV1 + from loushang.apphost.managed.defaults import resolve_managed_defaults + from loushang.apphost.managed.registry import ManagedRegistryV1 + + module, _ = managed_cli + original = ManagedRegistryV1.reserve_service_alias + winners = [] + starts = [] + + def race(registry, request, **kwargs): + # Deterministically schedule the other caller's commit after this + # caller's missing lookup and before its INSERT, with a distinct op. + winner = replace(request, operation_id="f" * 32, + service=request.service if same_service else ManagedServiceKeyV1("coding", "/other")) + original(registry, winner, **kwargs) + winners.append(winner) + return original(registry, request, **kwargs) + + monkeypatch.setattr(ManagedRegistryV1, "reserve_service_alias", race) + monkeypatch.setattr(module.ManagedMuxCommand, "_prepare_starter", lambda owner, service: starts.append(service)) + command = module.ManagedMuxCommand(resolve_managed_defaults(), dict(os.environ), stdin=StringIO(), stdout=StringIO()) + try: + args = lmux._parser().parse_args(["server", "start", "--name", "build"]) + if same_service: + assert command.prepare(args) + assert command.alias_reservation == winners[0] + assert starts == [winners[0].service] + else: + with pytest.raises(ManagedStorageError, match="conflict"): + command.prepare(args) + assert not starts and command.service is None + finally: + command.close_native() + + +def test_committed_alias_lost_receipt_does_not_start_or_delete(managed_cli, monkeypatch, capsys): + from loushang.apphost.managed._files import ManagedStorageError + from loushang.apphost.managed.registry import ManagedRegistryV1 + + _, commands = managed_cli + original = ManagedRegistryV1.reserve_service_alias + + def lost(registry, request, **kwargs): + original(registry, request, **kwargs) + raise ManagedStorageError("unavailable") + + monkeypatch.setattr(ManagedRegistryV1, "reserve_service_alias", lost) + assert lmux.main(["server", "start", "--name", "build"]) == 1 + command = commands[-1] + assert command.alias_reservation is not None + assert command.native_closed and command.service is None and command.starter is None + capsys.readouterr() + assert lmux.main(["status", "--server", "build"]) == 0 + record = json.loads(capsys.readouterr().out) + assert record["serviceId"] == command.alias_reservation.service.service_id + assert record["instanceId"] is None diff --git a/tests/coding/test_lmux_status.py b/tests/coding/test_lmux_status.py new file mode 100644 index 000000000..9b92f6d22 --- /dev/null +++ b/tests/coding/test_lmux_status.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import json +import os +import sys +from time import monotonic + +import pytest + +from loushang.apphost.managed.contracts import ( + ManagedServiceKeyV1, + ManagedStopEvidenceV1, +) +from loushang.apphost.managed.defaults import resolve_managed_defaults +from loushang.apphost.managed.namespace_admission import ManagedNamespaceAdmissionV1 +from loushang.apphost.managed.registry import ManagedMuxReservationV1 +from loushang.apphost.managed.service_admission import ManagedServiceAdmissionV1 +from loushang.coding.cli import lmux +from loushang.hosting.service import LinuxServiceObserverV1 +from tests.apphost.test_managed_namespace_admission import tree + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed status") + + +@pytest.fixture +def namespace(tmp_path, monkeypatch): + from loushang.apphost.managed import defaults + + monkeypatch.setattr(defaults, "linux_machine_key", lambda **kw: "a" * 32) + monkeypatch.setenv("LOUSHANG_HOME", str(tmp_path / "home")) + monkeypatch.setenv("LOUSHANG_RUNTIME_DIR", str(tmp_path / "runtime")) + monkeypatch.delenv("LOUSHANG_TMPDIR", raising=False) + return resolve_managed_defaults() + + +@pytest.mark.parametrize("args", [["status", "--server", "bad:name"], + ["status", "-t", "bad:name"], ["status", "-t", "dev", "--server", "a" * 64]]) +def test_bad_status_selectors_do_not_create_directories(namespace, tmp_path, args): + with pytest.raises(SystemExit) as error: + lmux.main(args) + assert error.value.code == 2 and not tuple(tmp_path.iterdir()) + + +def test_missing_namespace_status_is_readonly_not_found(namespace, tmp_path, capsys): + assert lmux.main(["status", "-t", "dev"]) == 1 + captured = capsys.readouterr() + assert captured.err == "lmux_not_found\n" and not captured.out + assert not tuple(tmp_path.iterdir()) + + +def test_status_by_name_and_id_from_other_cwd_never_starts(namespace, tmp_path, monkeypatch, capsys): + admission = ManagedNamespaceAdmissionV1(namespace.namespace, runtime_root=str(namespace.platform.runtime), + create_if_missing=True) + service = ManagedServiceKeyV1("coding", str(tmp_path)) + try: + registry = admission.open(deadline=monotonic() + 5) + registry.reserve_mux(ManagedMuxReservationV1("dev", service, "b" * 32)) + finally: + admission.close() + other = tmp_path / "other" + other.mkdir() + monkeypatch.chdir(other) + from loushang.coding.cli import lmux_command + + def forbidden(*args, **kwargs): + pytest.fail("status attempted service activation") + + monkeypatch.setattr(lmux_command, "ManagedServiceCoordinatorV1", forbidden) + monkeypatch.setattr(lmux_command, "ManagedConnectionLeaseV1", forbidden) + before = tree(tmp_path) + assert lmux.main(["status", "-t", "dev"]) == 0 + named = json.loads(capsys.readouterr().out) + assert named["serviceId"] == service.service_id and named["workspace"] == str(tmp_path) + assert named["instanceId"] is None and named["recordedPhase"] is None + assert named["liveStatus"] == "not_probed" and named["observation"] == "recorded_only" + assert named["temporary"]["actualRoot"] is None + assert named["paths"]["logs"].endswith(f"/servers/{service.service_id}/logs") + assert lmux.main(["status", "--server", service.service_id]) == 0 + assert json.loads(capsys.readouterr().out) == named + assert tree(tmp_path) == before + + +def test_status_projects_journal_facts_without_readiness_or_mutation(namespace, tmp_path, monkeypatch, capsys): + admission = ManagedNamespaceAdmissionV1(namespace.namespace, runtime_root=str(namespace.platform.runtime), + create_if_missing=True) + service = ManagedServiceKeyV1("coding", str(tmp_path)) + registry = admission.open(deadline=monotonic() + 5) + registry.reserve_mux(ManagedMuxReservationV1("dev", service, "b" * 32)) + service_admission = ManagedServiceAdmissionV1(admission, service) + journal = service_admission.open(deadline=monotonic() + 5) + observer = LinuxServiceObserverV1.capture(os.getpid()) + from loushang.coding.cli import lmux_command + + def forbidden(*args, **kwargs): + pytest.fail("status attempted connection or startup") + + monkeypatch.setattr(lmux_command, "ManagedConnectionLeaseV1", forbidden) + monkeypatch.setattr(lmux_command, "ManagedServiceCoordinatorV1", forbidden) + + def query(phase, stopped=False, clean=False): + before = tree(tmp_path) + assert lmux.main(["status", "--server", service.service_id]) == 0 + result = json.loads(capsys.readouterr().out) + assert result["recordedPhase"] == phase and result["stopRequested"] is stopped + assert result["cleanlyStopped"] is clean and result["liveStatus"] == "not_probed" + assert result["observation"] == "recorded_only" + assert lmux.main(["status"]) == 0 + summary = json.loads(capsys.readouterr().out) + assert summary["observation"] == "recorded_only" and summary["liveStatus"] == "not_probed" + assert len(summary["services"]) == 1 + recorded = summary["services"][0] + for field in ("serviceId", "instanceId", "revision", "recordedPhase", "stopRequested", "cleanlyStopped"): + assert recorded[field] == result[field] + assert tree(tmp_path) == before + return result + + try: + state = journal.prepare("c" * 32, expected=None) + assert query("provisional")["instanceId"] == state.handoff.instance.instance_id + journal.register_native(state.handoff.instance, "c" * 32, observer.identity) + journal.commit(state.handoff.instance, "c" * 32, native_identity=observer.identity) + query("committed") + journal.request_stop(state.handoff.instance) + query("committed", stopped=True) + # Durable fixture facts, not claims that this test's process has exited. + journal.record_stop_evidence(ManagedStopEvidenceV1(state.handoff.instance, True, True, False)) + query("committed", stopped=True) + journal.record_stop_evidence(ManagedStopEvidenceV1(state.handoff.instance, True, True, True)) + expected = query("committed", stopped=True, clean=True) + with registry._database.transaction(write=True) as connection: + connection.execute("DELETE FROM muxes WHERE service_id=?", (service.service_id,)) + assert query("committed", stopped=True, clean=True) == expected + assert lmux.main(["status", "-t", "dev"]) == 1 + assert capsys.readouterr().err == "lmux_not_found\n" + finally: + observer.close() + service_admission.close() + admission.close() diff --git a/tests/coding/test_lmux_status_summary.py b/tests/coding/test_lmux_status_summary.py new file mode 100644 index 000000000..e04cb9de4 --- /dev/null +++ b/tests/coding/test_lmux_status_summary.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import json +import sqlite3 +from time import monotonic + +import pytest + +from loushang.apphost.managed._database import DATABASE_NAME +from loushang.apphost.managed.contracts import ManagedServiceKeyV1 +from loushang.apphost.managed.namespace_admission import ManagedNamespaceAdmissionV1 +from loushang.apphost.managed.registry import ( + ManagedMuxReservationV1, + ManagedServiceAliasReservationV1, +) +from loushang.coding.cli import lmux +from tests.apphost.test_managed_namespace_admission import tree + +from .test_lmux_status import namespace as namespace + + +def test_missing_status_namespace_is_empty_without_creation(namespace, tmp_path, capsys): + assert lmux.main(["status"]) == 0 + assert json.loads(capsys.readouterr().out) == {"services": [], "observation": "recorded_only", "liveStatus": "not_probed"} + assert not tuple(tmp_path.iterdir()) + + +def test_status_summary_includes_services_without_mux_from_other_cwd(namespace, tmp_path, monkeypatch, capsys): + from loushang.coding.cli import lmux_command + + owner = ManagedNamespaceAdmissionV1(namespace.namespace, runtime_root=str(namespace.platform.runtime), create_if_missing=True) + empty, populated = ManagedServiceKeyV1("coding", "/empty"), ManagedServiceKeyV1("coding", "/populated") + try: + registry = owner.open(deadline=monotonic() + 5) + registry.reserve_service_alias(ManagedServiceAliasReservationV1("build", empty, "a" * 32)) + registry.reserve_mux(ManagedMuxReservationV1("main", populated, "b" * 32)) + finally: + owner.close() + other = tmp_path / "other" + other.mkdir() + monkeypatch.chdir(other) + + def forbidden(*args, **kwargs): + raise AssertionError("summary must not admit a service, start or connect") + + monkeypatch.setattr(lmux_command, "ManagedServiceAdmissionV1", forbidden) + monkeypatch.setattr(lmux_command, "ManagedServiceCoordinatorV1", forbidden) + monkeypatch.setattr(lmux_command, "ManagedConnectionLeaseV1", forbidden) + before = tree(tmp_path) + assert lmux.main(["status"]) == 0 + result = json.loads(capsys.readouterr().out) + assert result["observation"] == "recorded_only" and result["liveStatus"] == "not_probed" + services = {item["serviceId"]: item for item in result["services"]} + assert set(services) == {empty.service_id, populated.service_id} + assert services[empty.service_id]["reservedMuxes"] == [] + assert services[populated.service_id]["reservedMuxes"] == ["main"] + assert all(item["instanceId"] is None and item["recordedPhase"] is None for item in services.values()) + assert not tuple(other.iterdir()) + assert tree(tmp_path) == before + + +@pytest.mark.parametrize("damage", ["missing_database", "unsupported_version"]) +def test_damaged_namespace_is_not_an_empty_summary(namespace, tmp_path, capsys, damage): + from pathlib import Path + + from loushang.apphost.managed.paths import resolve_managed_registry_root + + owner = ManagedNamespaceAdmissionV1(namespace.namespace, runtime_root=str(namespace.platform.runtime), create_if_missing=True) + try: + owner.open(deadline=monotonic() + 5) + finally: + owner.close() + database = Path(resolve_managed_registry_root(namespace.namespace)) / DATABASE_NAME + if damage == "missing_database": + database.unlink() # Only this fixture's private, freshly created DB. + else: + with sqlite3.connect(database) as connection: + connection.execute("PRAGMA user_version=42") + before = tree(tmp_path) + assert lmux.main(["status"]) == 1 + captured = capsys.readouterr() + assert not captured.out and captured.err.startswith("lmux_") + assert tree(tmp_path) == before diff --git a/tests/coding/test_lmux_stop_all.py b/tests/coding/test_lmux_stop_all.py new file mode 100644 index 000000000..75cd0b278 --- /dev/null +++ b/tests/coding/test_lmux_stop_all.py @@ -0,0 +1,356 @@ +import asyncio +import json +import sys +from io import StringIO +from time import monotonic +from types import SimpleNamespace + +import pytest + +from loushang.apphost.managed._files import ManagedStorageError +from loushang.apphost.managed.contracts import ( + ManagedHandoffPhaseV1, + ManagedInstanceRefV1, + ManagedServiceKeyV1, +) +from loushang.apphost.managed.discovery import ( + ManagedDiscoverySnapshotV1, + ManagedServiceObservationV1, +) +from loushang.coding.cli import lmux +from loushang.coding.cli import lmux_stop_all as module + +from .test_lmux_command import managed_cli as managed_cli +from .test_lmux_status import namespace as namespace +from .test_lmux_status import pytestmark as pytestmark + + +@pytest.mark.parametrize("args", [["stop", "--all"], ["stop", "--all", "--server", "a" * 64, "--yes"]]) +def test_invalid_batch_flags_before_defaults(monkeypatch, args): + from loushang.apphost.managed import defaults + + monkeypatch.setattr(defaults, "resolve_managed_defaults", lambda **kw: pytest.fail("default lookup")) + with pytest.raises(SystemExit) as error: + lmux.main(args) + assert error.value.code == 2 + + +@pytest.fixture +def batch(namespace, monkeypatch): + events, results = [], [] + behavior = {} + observations = [] + for index in range(3): + service = ManagedServiceKeyV1("coding", f"/workspace-{index}") + instance = ManagedInstanceRefV1(namespace.namespace.namespace_key, service.service_id, str(index) * 32) + observations.append(ManagedServiceObservationV1(service, instance, 1, + ManagedHandoffPhaseV1.PROVISIONAL, False, False)) + + class Journal: + def __init__(self, registry, ns, service, path, **kwargs): + self.service = service + events.append(("construct", service.service_id)) + + def open(self, **kwargs): + events.append(("open", self.service.service_id)) + + def close(self): + events.append(("journal_close", self.service.service_id)) + + class Stopper: + def __init__(self, journal, ns, service, instance, **kwargs): + self.instance = instance + self.service = service + + async def run(self, *, deadline): + events.append(("run", self.instance)) + action = behavior.get(self.service.service_id) + if action is not None: + await action() + + async def close(self): + events.append(("stopper_close", self.service.service_id)) + + monkeypatch.setattr(module, "ManagedServiceJournalV1", Journal) + monkeypatch.setattr(module, "ManagedServiceStopOperationV1", Stopper) + owner = module.StopAll(SimpleNamespace(registry=object()), namespace, + ManagedDiscoverySnapshotV1(tuple(observations), ()), deadline=monotonic() + 30, emit=results.append) + return owner, behavior, events, results + + +def test_partial_failure_keeps_later_frozen_targets_and_closes_all(batch): + owner, behavior, events, results = batch + + async def fail(): + raise ManagedStorageError("conflict") + + behavior[owner.entries[0].observation.service.service_id] = fail + + async def scenario(): + try: + with pytest.raises(ManagedStorageError): + await owner.run() + finally: + await owner.close() + + asyncio.run(scenario()) + assert [result["status"] for result in results] == ["failed", "stopped", "stopped"] + assert [item[1] for item in events if item[0] == "run"] == [entry.observation.instance for entry in owner.entries] + assert len([item for item in events if item[0] == "journal_close"]) == 3 + assert not owner.cleanup_pending + + +def test_shared_deadline_does_not_admit_later_entries(batch, monkeypatch): + owner, behavior, events, results = batch + + async def expire(): + monkeypatch.setattr(module, "monotonic", lambda: owner.deadline + 1) + + behavior[owner.entries[0].observation.service.service_id] = expire + + async def scenario(): + try: + with pytest.raises(ManagedStorageError): + await owner.run() + finally: + await owner.close() + + asyncio.run(scenario()) + assert len([item for item in events if item[0] == "construct"]) == 1 + assert [result["status"] for result in results] == ["stopped", "not_attempted", "not_attempted"] + + +def test_cancellation_does_not_stop_next_instance(batch): + owner, behavior, events, _ = batch + + async def cancel(): + raise asyncio.CancelledError() + + behavior[owner.entries[0].observation.service.service_id] = cancel + + async def scenario(): + try: + with pytest.raises(asyncio.CancelledError): + await owner.run() + finally: + await owner.close() + + asyncio.run(scenario()) + assert len([item for item in events if item[0] == "run"]) == 1 + assert not owner.cleanup_pending + + +def test_close_starts_all_original_tasks_even_when_first_hangs(batch): + owner, _, _, _ = batch + + async def scenario(): + release = asyncio.Event() + seen = [] + + class Stopper: + def __init__(self, index): + self.index = index + + async def close(self): + seen.append(self.index) + if self.index == 0: + await release.wait() + + for index, entry in enumerate(owner.entries): + entry.stopper = Stopper(index) + with pytest.raises(ManagedStorageError, match="busy"): + await owner.close() + assert seen == [0, 1, 2] and owner.cleanup_pending + original = owner.entries[0].closing + release.set() + await owner.close() + assert owner.entries[0].closing is original and not owner.cleanup_pending + + asyncio.run(scenario()) + + +def test_missing_namespace_batch_does_not_create(namespace, tmp_path, capsys): + assert lmux.main(["stop", "--all", "--yes"]) == 1 + assert capsys.readouterr().err == "lmux_not_found\n" + assert not tuple(tmp_path.iterdir()) + + +def test_confirmation_freezes_services_and_skips_absent_instances(namespace, tmp_path, monkeypatch, capsys): + from loushang.apphost.managed.namespace_admission import ManagedNamespaceAdmissionV1 + from loushang.apphost.managed.registry import ManagedMuxReservationV1 + + admission = ManagedNamespaceAdmissionV1(namespace.namespace, runtime_root=str(namespace.platform.runtime), + create_if_missing=True) + try: + registry = admission.open(deadline=monotonic() + 5) + service = ManagedServiceKeyV1("coding", str(tmp_path)) + registry.reserve_mux(ManagedMuxReservationV1("first", service, "b" * 32)) + late = ManagedServiceKeyV1("coding", str(tmp_path / "late")) + + class Confirm(StringIO): + def isatty(self): + return True + + def readline(self, *args): + registry.reserve_mux(ManagedMuxReservationV1("late", late, "e" * 32)) + return "yes\n" + + monkeypatch.setattr(sys, "stdin", Confirm()) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + assert lmux.main(["stop", "--all"]) == 0 + output = capsys.readouterr().out + assert late.service_id not in output + assert output.count('"status": "skipped"') == 1 + assert '"reason": "no_instance_at_snapshot"' in output + assert registry.resolve("first") is not None and registry.resolve("late") is not None + finally: + admission.close() + + +def test_two_muxes_share_one_real_batch_stop(managed_cli, monkeypatch, capsys): + from concurrent.futures import ThreadPoolExecutor + + from loushang.harnesstui.mux import terminal + + _, commands = managed_cli + + async def screen(shell, **kwargs): + await shell.start() + return 0 + + monkeypatch.setattr(terminal, "run_hosted_mux_shell", screen) + assert lmux.main(["new", "-s", "one"]) == 0 + native = commands[-1].creation._coordinator._starter._process + assert lmux.main(["new", "-s", "two"]) == 0 + capsys.readouterr() + # This fixture is the original Popen parent; the stop client cannot reap it. + with ThreadPoolExecutor(max_workers=1) as pool: + reaped = pool.submit(native._process.wait, 20) + assert lmux.main(["stop", "--all", "--yes"]) == 0 + assert reaped.result(timeout=2) == 0 + output = [json.loads(line) for line in capsys.readouterr().out.splitlines()] + preview, stopped = output + assert preview["services"][0]["muxes"] == ["one", "two"] + assert len(preview["services"]) == 1 and stopped["status"] == "stopped" + assert stopped["instanceId"] == preview["services"][0]["instanceId"] + + +def test_failed_cleanup_retries_original_owner_without_stop_replay(batch): + owner, _, events, _ = batch + + class Journal: + def __init__(self): + self.calls = 0 + + def close(self): + self.calls += 1 + if self.calls == 1: + raise ManagedStorageError("busy") + + journal = Journal() + owner.entries[0].journal = journal + + async def scenario(): + with pytest.raises(ManagedStorageError): + await owner.close() + assert owner.entries[0].journal is journal + await owner.close() + assert journal.calls == 2 and not owner.cleanup_pending + + asyncio.run(scenario()) + assert events == [] + + +def test_cancelled_close_waiter_retains_original_cleanup_task(batch): + owner, _, _, _ = batch + + async def scenario(): + entered, release = asyncio.Event(), asyncio.Event() + + class Stopper: + async def close(self): + entered.set() + await release.wait() + + owner.entries[0].stopper = Stopper() + waiter = asyncio.create_task(owner.close()) + await entered.wait() + original = owner.entries[0].closing + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter + assert original is not None and not original.done() + release.set() + await owner.close() + assert owner.entries[0].closing is original and not owner.cleanup_pending + + asyncio.run(scenario()) + + +def test_confirmation_rejects_successor_without_native_or_stop_effect(namespace, tmp_path, monkeypatch, capsys): + from loushang.apphost.managed.contracts import ManagedStopEvidenceV1 + from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 + from loushang.apphost.managed.namespace_admission import ManagedNamespaceAdmissionV1 + from loushang.apphost.managed.registry import ManagedMuxReservationV1 + from loushang.apphost.managed.service_admission import ManagedServiceAdmissionV1 + from loushang.apphost.managed.stopper import ManagedServiceStopOperationV1 + + admission = ManagedNamespaceAdmissionV1(namespace.namespace, runtime_root=str(namespace.platform.runtime), + create_if_missing=True) + service_owner = None + successor = [] + try: + registry = admission.open(deadline=monotonic() + 5) + service = ManagedServiceKeyV1("coding", str(tmp_path)) + registry.reserve_mux(ManagedMuxReservationV1("dev", service, "b" * 32)) + service_owner = ManagedServiceAdmissionV1(admission, service) + journal = service_owner.open(deadline=monotonic() + 5) + first = journal.prepare("c" * 32, expected=None) + + class Confirm(StringIO): + def isatty(self): + return True + + def readline(self, *args): + # Legitimate durable transition with no launched native process. + journal.abort(first.handoff.instance, "c" * 32) + settled = journal.record_stop_evidence(ManagedStopEvidenceV1(first.handoff.instance, True, True, True)) + successor.append(journal.prepare("d" * 32, expected=settled)) + return "yes\n" + + def forbidden(*args, **kwargs): + pytest.fail("stale confirmation admitted a native or stop effect") + + monkeypatch.setattr(ManagedServiceStopOperationV1, "_admit", forbidden) + monkeypatch.setattr(ManagedServiceJournalV1, "request_stop", forbidden) + monkeypatch.setattr(sys, "stdin", Confirm()) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + assert lmux.main(["stop", "--all"]) == 1 + output = capsys.readouterr().out + assert '"code": "conflict"' in output + assert first.handoff.instance.instance_id in output + assert successor[0].handoff.instance.instance_id not in output + assert journal.read() == successor[0] and not successor[0].handoff.stop_requested + finally: + if service_owner is not None: + service_owner.close() + admission.close() + + +@pytest.mark.parametrize("answer", ["\n", "no\n", "yes", "yesxxxxx\n"]) +def test_batch_cancel_has_no_stop_owner(namespace, monkeypatch, answer): + from loushang.apphost.managed.namespace_admission import ManagedNamespaceAdmissionV1 + from loushang.coding.cli import lmux_command + + admission = ManagedNamespaceAdmissionV1(namespace.namespace, runtime_root=str(namespace.platform.runtime), + create_if_missing=True) + try: + admission.open(deadline=monotonic() + 5) + finally: + admission.close() + stdin = StringIO(answer) + monkeypatch.setattr(stdin, "isatty", lambda: True) + monkeypatch.setattr(sys, "stdin", stdin) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.setattr(lmux_command, "StopAll", lambda *a, **kw: pytest.fail("cancel allocated batch")) + assert lmux.main(["stop", "--all"]) == 0 diff --git a/tests/coding/test_managed_process_arguments.py b/tests/coding/test_managed_process_arguments.py index eed6cda82..4d8a5b970 100644 --- a/tests/coding/test_managed_process_arguments.py +++ b/tests/coding/test_managed_process_arguments.py @@ -21,7 +21,7 @@ from loushang.coding import managed_process as module -@pytest.mark.parametrize("frontend", [False]) +@pytest.mark.parametrize("frontend", [False, True]) def test_launch_material_does_not_import_backend(tmp_path, frontend): selected = invocation(tmp_path) script = """ From 3e77578bd34d0add5e53ffc0b3b3d35431e96d05 Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 06:55:49 +0000 Subject: [PATCH 30/44] test(coding): validate managed lmux product flows --- tests/coding/_g18_native_probe.py | 476 ++++++++++++++- tests/coding/_hosted_product_child.py | 2 + tests/coding/_lmux_admission_diagnostic.py | 88 +++ tests/coding/_lmux_adopted_process.py | 120 ++++ tests/coding/_lmux_completion_gate.py | 64 ++ tests/coding/_lmux_history_canonical.py | 75 +++ tests/coding/_lmux_history_frames.py | 30 + tests/coding/_lmux_history_recipe.py | 69 +++ tests/coding/_lmux_history_restore.py | 104 ++++ tests/coding/_lmux_history_seed.py | 169 ++++++ tests/coding/_lmux_interaction_receipt.py | 28 + tests/coding/_lmux_product_child.py | 70 +++ tests/coding/_lmux_product_entry.py | 50 ++ tests/coding/_lmux_product_frames.py | 138 +++++ tests/coding/_lmux_product_probe.py | 560 ++++++++++++++++++ tests/coding/_lmux_product_snapshot.py | 175 ++++++ tests/coding/_lmux_startup_diagnostic.py | 78 +++ tests/coding/_lmux_synthetic_product.py | 161 +++++ .../coding/test_lmux_admission_diagnostic.py | 82 +++ tests/coding/test_lmux_adopted_process.py | 157 +++++ tests/coding/test_lmux_completion_gate.py | 65 ++ tests/coding/test_lmux_completion_witness.py | 23 + .../coding/test_lmux_first_use_composition.py | 65 ++ tests/coding/test_lmux_history_attach.py | 79 +++ tests/coding/test_lmux_history_attachment.py | 67 +++ tests/coding/test_lmux_history_canonical.py | 94 +++ tests/coding/test_lmux_history_frames.py | 78 +++ tests/coding/test_lmux_history_mailbox.py | 55 ++ tests/coding/test_lmux_history_recipe.py | 125 ++++ tests/coding/test_lmux_history_restore.py | 132 +++++ tests/coding/test_lmux_history_seed.py | 136 +++++ tests/coding/test_lmux_interaction_receipt.py | 67 +++ tests/coding/test_lmux_line_terminal.py | 54 ++ tests/coding/test_lmux_native_body.py | 144 +++++ tests/coding/test_lmux_product_child.py | 102 ++++ tests/coding/test_lmux_product_entry.py | 82 +++ tests/coding/test_lmux_product_frames.py | 238 ++++++++ tests/coding/test_lmux_product_probe.py | 507 ++++++++++++++++ tests/coding/test_lmux_product_snapshot.py | 282 +++++++++ tests/coding/test_lmux_pty_hangup.py | 225 +++++++ tests/coding/test_lmux_read_observer.py | 301 ++++++++++ tests/coding/test_lmux_startup_diagnostic.py | 109 ++++ tests/coding/test_lmux_synthetic_product.py | 280 +++++++++ tests/coding/test_lmux_terminal_process.py | 168 ++++++ tests/coding/test_mux_markdown_terminal.py | 53 ++ .../tui/terminal_process_support/posix_pty.py | 115 +++- 46 files changed, 6322 insertions(+), 20 deletions(-) create mode 100644 tests/coding/_lmux_admission_diagnostic.py create mode 100644 tests/coding/_lmux_adopted_process.py create mode 100644 tests/coding/_lmux_completion_gate.py create mode 100644 tests/coding/_lmux_history_canonical.py create mode 100644 tests/coding/_lmux_history_frames.py create mode 100644 tests/coding/_lmux_history_recipe.py create mode 100644 tests/coding/_lmux_history_restore.py create mode 100644 tests/coding/_lmux_history_seed.py create mode 100644 tests/coding/_lmux_interaction_receipt.py create mode 100644 tests/coding/_lmux_product_child.py create mode 100644 tests/coding/_lmux_product_entry.py create mode 100644 tests/coding/_lmux_product_frames.py create mode 100644 tests/coding/_lmux_product_probe.py create mode 100644 tests/coding/_lmux_product_snapshot.py create mode 100644 tests/coding/_lmux_startup_diagnostic.py create mode 100644 tests/coding/_lmux_synthetic_product.py create mode 100644 tests/coding/test_lmux_admission_diagnostic.py create mode 100644 tests/coding/test_lmux_adopted_process.py create mode 100644 tests/coding/test_lmux_completion_gate.py create mode 100644 tests/coding/test_lmux_completion_witness.py create mode 100644 tests/coding/test_lmux_first_use_composition.py create mode 100644 tests/coding/test_lmux_history_attach.py create mode 100644 tests/coding/test_lmux_history_attachment.py create mode 100644 tests/coding/test_lmux_history_canonical.py create mode 100644 tests/coding/test_lmux_history_frames.py create mode 100644 tests/coding/test_lmux_history_mailbox.py create mode 100644 tests/coding/test_lmux_history_recipe.py create mode 100644 tests/coding/test_lmux_history_restore.py create mode 100644 tests/coding/test_lmux_history_seed.py create mode 100644 tests/coding/test_lmux_interaction_receipt.py create mode 100644 tests/coding/test_lmux_line_terminal.py create mode 100644 tests/coding/test_lmux_native_body.py create mode 100644 tests/coding/test_lmux_product_child.py create mode 100644 tests/coding/test_lmux_product_entry.py create mode 100644 tests/coding/test_lmux_product_frames.py create mode 100644 tests/coding/test_lmux_product_probe.py create mode 100644 tests/coding/test_lmux_product_snapshot.py create mode 100644 tests/coding/test_lmux_pty_hangup.py create mode 100644 tests/coding/test_lmux_read_observer.py create mode 100644 tests/coding/test_lmux_startup_diagnostic.py create mode 100644 tests/coding/test_lmux_synthetic_product.py create mode 100644 tests/coding/test_lmux_terminal_process.py create mode 100644 tests/coding/test_mux_markdown_terminal.py diff --git a/tests/coding/_g18_native_probe.py b/tests/coding/_g18_native_probe.py index 96e3fd7da..d069705a8 100644 --- a/tests/coding/_g18_native_probe.py +++ b/tests/coding/_g18_native_probe.py @@ -27,6 +27,15 @@ # support is admitted from the checkout; never put checkout/src on sys.path. ROOT = Path(__file__).resolve().parents[2] FAILURE_TREE_DIAGNOSTIC = False +PRODUCT_DIAGNOSTIC_CASES = ( + "managed-product-first-reply", "managed-product-admission-diagnostic", + "managed-product-delayed-final", "managed-product-interrupt-next-turn", + "managed-product-tool-approval", "managed-product-tool-denial", + "managed-product-hangup-interrupt-next-turn", + "managed-product-hangup-natural-completion", + "managed-product-history-warm", + "managed-product-history-restore", +) sys.path.insert(0, str(ROOT)) if __name__ == "__main__": if len(sys.argv) not in (6, 7) or ( @@ -267,10 +276,14 @@ def chain_valid(chain): @contextmanager -def observed_terminal(argv, root, environment, *, failure_report=None): +def observed_terminal(argv, root, environment, *, failure_report=None, settlements=None, + line_settlements=None): import pty import termios + if settlements is not None and line_settlements is not None: + raise ValueError("terminal receipt must select one presentation mode") + openpty = pty.openpty terminals = [] @@ -317,8 +330,67 @@ def open_observed(): assert driver.diagnostics.termination is None, ( "fixture fallback is not normal success" ) + restored_at = time.perf_counter() assert not driver.diagnostics.reader_alive, "native reader did not settle" assert driver.diagnostics.termination is None + if line_settlements is not None: + assert driver.diagnostics.exit_status == 0, "line command did not exit successfully" + line_settlements.append({ + "presentation": "line", "pid": driver.diagnostics.pid, + "argv": list(map(str, argv)), "cwd": str(root.resolve()), + "exit_status": 0, "termios_restored_at": restored_at, + "settled_at": time.perf_counter(), "reader_settled": True, + "fallback": False, + }) + if settlements is not None: + assert driver.diagnostics.exit_status == 0, "terminal did not exit successfully" + output = driver.raw_output + assert output.rfind("\x1b[?25h") > output.rfind("\x1b[?25l"), "cursor not restored" + assert output.rfind("\x1b[?2004l") > output.rfind("\x1b[?2004h"), "bracketed paste not disabled" + settlements.append({ + "pid": driver.diagnostics.pid, "argv": list(map(str, argv)), + "cwd": str(root.resolve()), "exit_status": 0, + "termios_restored_at": restored_at, "settled_at": time.perf_counter(), + "cursor_restored": True, "bracketed_paste_disabled": True, + "reader_settled": True, "fallback": False, + }) + + +@contextmanager +def abrupt_terminal(argv, root, environment, *, failure_report=None): + """Original terminal owner, with transport loss instead of a detach receipt.""" + import pty + import termios + + from tests.tui.terminal_process_support.posix_pty import PosixPtyDriver + + openpty = pty.openpty + terminals = [] + + def capture(): + master, slave = openpty() + terminals.append((master, termios.tcgetattr(slave))) + return master, slave + + with patch.object(pty, "openpty", capture), foreground_terminal( + argv, cwd=root, env=environment, columns=100, rows=30, + ) as driver: + assert isinstance(driver, PosixPtyDriver) + ((master, original),) = terminals + yield driver, master, original + assert driver._transport_state == "closed", "transport hangup must succeed" + assert not driver.is_alive() and not driver.diagnostics.reader_alive + assert driver.diagnostics.termination is None, "fallback is not a successful hangup" + exit_status = driver.diagnostics.exit_status + assert type(exit_status) is int and exit_status >= 0, "client did not exit on its own" + assert not driver.diagnostics.reader_alive and driver.diagnostics.termination is None + if failure_report is not None: + failure_report["terminal_transport"] = { + "stimulus": "pty-master-close", "client_settled": True, + "client_exit_status": exit_status, + "client_exit_clean": exit_status == 0, + "terminal_mode_restoration": "not-observable-after-hangup", + } def mark(report, name, started): @@ -439,6 +511,243 @@ def _replay_embedded_output(output, *, screen=None): return screen +def managed_read_observation(environment, stage, *, name="perf", native_identity=None): + """Read exact authenticated members without taking a controller. + + Reuse the original process-only runner: unresolved connection/native cleanup + is an observer failure retained by its outer evidence owner, never a result. + """ + return _managed_observation(environment, stage, name=name, native_identity=native_identity) + + +def managed_reply_observation(environment, expected_target, expected_reply, *, pending=False, interrupted_nonce=None, tool_approval=False, tool_denial=False, natural_nonce=None): + """After terminal settlement only: temporarily control and verify its reply.""" + from tests.coding._lmux_product_snapshot import ( + confirm_denied_tool_reply, + confirm_interrupted_reply, + confirm_natural_reply, + confirm_pending_reply, + confirm_reply, + confirm_tool_reply, + ) + + if sum((pending, interrupted_nonce is not None, tool_approval, tool_denial)) > 1: + raise ValueError("reply observation modes are distinct") + if natural_nonce is not None and (interrupted_nonce is not None or tool_approval or tool_denial): + raise ValueError("natural completion cannot use interrupt/tool observation") + confirmed_snapshot = None + + async def verify(connection, mux, target, deadline): + nonlocal confirmed_snapshot + for key in ("instanceId", "serviceId", "muxId", "members"): + assert target[key] == expected_target[key], "reply observer target changed" + assert len(mux.members) == 1 + member = mux.members[0] + confirm = (confirm_natural_reply if natural_nonce is not None + else confirm_denied_tool_reply if tool_denial else confirm_tool_reply if tool_approval + else confirm_interrupted_reply if interrupted_nonce is not None + else confirm_pending_reply if pending else confirm_reply) + snapshot = await confirm( + connection.client, mux_id=mux.mux_space_id, member_id=member.member_id, + identity=member.session, expected=expected_reply, deadline=deadline, + **({"interrupted_nonce": interrupted_nonce} if interrupted_nonce is not None else {}), + **({"natural_nonce": natural_nonce, "pending": pending} if natural_nonce is not None else {}), + ) + identity = snapshot.identity + confirmed_snapshot = { + "confirmed_at": time.perf_counter(), "running": snapshot.running, + "expected_reply": expected_reply, + "identity": { + "product_id": identity.product_id, "continuity_id": identity.continuity_id, + "session_id": identity.session_id, "scope": identity.scope.value, + "scope_fingerprint": identity.scope_fingerprint, + }, + "records": [{"kind": record.kind.value, "text": record.text} for record in snapshot.records], + } + + result = _managed_observation(environment, "detached", name="perf", verify=verify) + assert confirmed_snapshot is not None + return {**result, "pendingConfirmed" if pending else "replyConfirmed": True, + "snapshot": confirmed_snapshot} + + +def managed_history_confirmation(environment, target, identity, *, native_identity=None): + from tests.coding._lmux_product_snapshot import confirm_history + + confirmed = None + + async def verify(connection, mux, result, deadline): + nonlocal confirmed + assert all(result[key] == target[key] for key in ("instanceId", "serviceId", "muxId", "members")) + assert len(mux.members) == 1 + member = mux.members[0] + actual = member.session + observed_identity = {"product_id": actual.product_id, "continuity_id": actual.continuity_id, + "session_id": actual.session_id, "scope": actual.scope.value, + "scope_fingerprint": actual.scope_fingerprint} + assert observed_identity == identity, "warm history Session identity changed" + snapshot = await confirm_history(connection.client, mux_id=mux.mux_space_id, + member_id=member.member_id, identity=actual, deadline=deadline) + confirmed = {"confirmed_at": time.perf_counter(), + "identity": observed_identity, "running": snapshot.running, + "records": [{"kind": row.kind.value, "text": row.text} for row in snapshot.records]} + + result = _managed_observation(environment, "detached", name="perf", + native_identity=native_identity, verify=verify) + assert confirmed is not None + return {**result, "history_snapshot": confirmed, + "connection_settled_at": time.perf_counter()} + + +def managed_history_observation(environment, target): + from tests.coding._lmux_history_seed import seed_attached_history + + evidence = identity = None + + async def verify(connection, mux, result, deadline): + nonlocal evidence, identity + assert all(result[key] == target[key] for key in ("instanceId", "serviceId", "muxId", "members")) + assert len(mux.members) == 1 + member = mux.members[0] + identity = {"product_id": member.session.product_id, "continuity_id": member.session.continuity_id, + "session_id": member.session.session_id, "scope": member.session.scope.value, + "scope_fingerprint": member.session.scope_fingerprint} + evidence = await seed_attached_history(connection.client, mux_id=mux.mux_space_id, + member_id=member.member_id, identity=member.session, deadline=deadline) + + result = _managed_observation(environment, "detached", name="perf", verify=verify, verification_seconds=660) + assert evidence is not None + return {**result, "history_seed": evidence, "history_identity": identity, + "connection_settled_at": time.perf_counter()} # Original owners closed. + + +def _managed_observation(environment, stage, *, name, native_identity=None, verify=None, verification_seconds=0): + if verification_seconds not in (0, 660) or type(verification_seconds) is not int: + raise ValueError("unsupported managed verification budget") + if verification_seconds and verify is None: + raise ValueError("extended verification requires explicit callback") + from loushang.apphost.managed.connection import ( + ManagedConnectionLeaseV1, + _settled_native, + ) + from loushang.apphost.managed.defaults import resolve_managed_defaults + from loushang.apphost.managed.discovery import ManagedDiscoveryV1 + from loushang.apphost.managed.lifecycle import ManagedServiceJournalV1 + from loushang.apphost.managed.mux_management import ManagedMuxManagerV1 + from loushang.apphost.managed.namespace_admission import ManagedNamespaceAdmissionV1 + from loushang.apphost.managed.paths import resolve_managed_service_paths + from loushang.appserver.protocol import MuxReadV1, MuxSelectorV1 + from loushang.coding.cli.mux import _execute + from loushang.coding.managed_process import APPLICATION_ID, ENDPOINT + + if stage not in {"first-member", "second-member", "detached", "reattached"}: + raise ValueError("unknown managed observation stage") + defaults = resolve_managed_defaults(environ=environment) + runtime = str(defaults.platform.runtime) + namespace = ManagedNamespaceAdmissionV1(defaults.namespace, runtime_root=runtime) + journal = connection = result = None + operation_failure = None + native = None + deadline = time.monotonic() + 30 + + def pending(): + if connection is not None and connection.cleanup_pending: + return True + try: + if journal is not None: + journal.close() + namespace.close() + except BaseException: + return True + return namespace.cleanup_pending + + async def read(): + nonlocal connection, result, native, operation_failure + connection = ManagedConnectionLeaseV1( + journal, defaults.namespace, item.service, item.instance, + runtime_root=runtime, endpoint=ENDPOINT, + ) + try: + await connection.prepare(deadline=deadline) + assert connection.application_id == APPLICATION_ID + if time.monotonic() >= deadline: + raise TimeoutError("managed read deadline expired before dispatch") + async with asyncio.timeout(max(0, deadline - time.monotonic())): + mux = await connection.client.read_mux(MuxReadV1(MuxSelectorV1(mux_space_id=mux_id))) + if time.monotonic() >= deadline: + raise TimeoutError("managed read returned after deadline") + assert mux.mux_space_id == mux_id and mux.name == name + if native_identity is not None: + state = await _settled_native(lambda: journal.read(deadline=deadline, wait_for_lock=True)) + assert state is not None and state.handoff.instance == connection.instance + assert state.native_identity is not None + native = state.native_identity + result = { + "stage": stage, "observed_at": time.perf_counter(), + "instanceId": connection.instance.instance_id, "serviceId": item.service.service_id, + "muxId": mux_id, + "members": [{"memberId": member.member_id, "sessionId": member.session.session_id} + for member in mux.members], + } + if verify is not None: + if time.monotonic() >= deadline: + raise TimeoutError("managed verification admission expired") + verification_started = time.monotonic() + verification_deadline = verification_started + verification_seconds if verification_seconds else deadline + await verify(connection, mux, result, verification_deadline) + if verification_seconds: + verification_completed = time.monotonic() + if verification_completed >= verification_deadline: + raise TimeoutError("managed verification returned after deadline") + result["verification"] = {"started_at": verification_started, + "deadline": verification_deadline, + "completed_at": verification_completed} + except BaseException as error: + operation_failure = error + raise + finally: + await connection.close() + + try: + registry = namespace.open(deadline=deadline, wait_for_lock=True) + item = ManagedDiscoveryV1(registry, defaults.namespace).resolve(name, deadline=deadline, wait_for_lock=True) + assert item is not None and item.instance is not None + paths = resolve_managed_service_paths(defaults.namespace, item.service, runtime_root=runtime) + journal = ManagedServiceJournalV1(registry, defaults.namespace, item.service, + Path(paths.lifecycle), defer_open=True) + journal.open(deadline=deadline, wait_for_lock=True) + manager = ManagedMuxManagerV1(registry, journal, defaults.namespace, item.service, + item.instance, application_id=APPLICATION_ID) + mux_id = manager.inspect_mux(item.reservation, deadline=deadline, wait_for_lock=True).creation.mux_space_id + if _execute(read, pending) != 0 or result is None: + raise RuntimeError("managed authenticated read did not settle successfully") from operation_failure + if native_identity is not None: + assert native is not None + native_identity.append(native) + return result + finally: + if pending(): + raise RuntimeError("managed authenticated observer retains cleanup debt") + + +def managed_completion_frame(output, *, after, columns=100, rows=30): + """Witness /help in the current suggestion panel, not the static footer. + + Called after writing /he in an empty managed composer. Only complete native + frames count; transcript/history or a prior frame cannot satisfy the probe. + """ + end = output.rfind("\x1b[?2026l") + if end < after: + return False + screen = _replay_embedded_output( + output[:end + len("\x1b[?2026l")], + screen=FakeScreen.empty(TerminalSize(columns=columns, rows=rows)), + ) + lines = tuple(line.strip() for line in screen.visible_lines) + # A standalone suggestion row is distinct from `main | /help /detach`. + return "> /he" in lines and "/help" in lines + + def ready(driver, embedded=False): driver.read_until( lambda out: ( @@ -671,6 +980,148 @@ def interaction(driver, isolated=isolated, views=views): report["isolation"].update(ambient_after=after, environment_unchanged=True) +def managed_mux(root, report): + """Observe eight intervals; outer physical settlement supplies the ninth.""" + executable = str(Path(report["measured_prefix"]) / "bin/lmux") + environment = _terminal_environment(root) + environment.pop("LOUSHANG_TMPDIR", None) + elsewhere = root / "elsewhere" + elsewhere.mkdir() + report["managed_actions"], report["managed_observations"] = {}, [] + stopped = False + primary = None + native_identities = [] + + def command(*arguments): + completed = subprocess.run( + [executable, *arguments], cwd=root, env=environment, + capture_output=True, text=True, timeout=45, + ) + assert completed.returncode == 0, (completed.stdout, completed.stderr) + return [json.loads(line) for line in completed.stdout.splitlines()] + + def frame(driver, footer, *, after=0): + def witness(output): + end = output.rfind("\x1b[?2026l") + if end < after: + return False + screen = _replay_embedded_output(output[:end + len("\x1b[?2026l")]) + lines = tuple(line.strip() for line in screen.visible_lines) + return ( + footer in lines and ">" in lines + and not any("request_pending" in line or "member_pending" in line for line in lines) + ) + driver.read_until(witness, timeout=40) + + def action(name, started): + finished = time.perf_counter() + report["managed_actions"][name] = {"started_at": started, "finished_at": finished} + report["milestones"][name + "_seconds"] = finished - started + return finished + + def observe(stage): + value = managed_read_observation(environment, stage, native_identity=native_identities) if stage == "reattached" else managed_read_observation(environment, stage) + previous = report["managed_observations"] + if previous: + assert all(value[key] == previous[0][key] for key in ("instanceId", "serviceId", "muxId")), ( + "managed query changed the frozen target" + ) + if stage in {"detached", "reattached"}: + assert value["members"] == previous[1]["members"], "managed query changed members" + members = value["members"] + assert len({member["memberId"] for member in members}) == len(members) + assert len({member["sessionId"] for member in members}) == len(members) + report["managed_observations"].append(value) + return value + + try: + with observe_spawn(executable) as cold: + report["spawns"].append(cold) + with observed_terminal([executable, "new", "-s", "perf"], root, environment, + failure_report=report) as (driver, master, original): + frame(driver, "perf | | /help /detach") + mark(report, "cold_frame_seconds", cold["start"]) + assert_active_terminal(master, original) + offset, started = len(driver.raw_output), time.perf_counter() + driver.write("/he") + driver.read_until(lambda output: managed_completion_frame(output, after=offset), timeout=40) + action("first_completion", started) + driver.write("\x7f\x7f\x7f") + offset, started = len(driver.raw_output), time.perf_counter() + driver.write("/new user_home First\r") + frame(driver, "perf | *1 | /help /detach", after=offset) + first_end = action("first_member_ready", started) + report["milestones"]["cold_through_first_member_seconds"] = first_end - cold["start"] + first = observe("first-member") + assert len(first["members"]) == 1 + report["authenticated_instance_id"] = first["instanceId"] + offset, started = len(driver.raw_output), time.perf_counter() + driver.write("/new user_home Second\r") + frame(driver, "perf | *1 2 | /help /detach", after=offset) + action("warm_member_ready", started) + second = observe("second-member") + assert len(second["members"]) == 2 and second["members"][:1] == first["members"] + detach = time.perf_counter() + driver.write("\x02d") + assert driver.wait(timeout=20) == 0, driver.diagnostics + action("detach_settlement", detach) + observe("detached") + with observe_spawn(executable) as warm: + report["spawns"].append(warm) + with observed_terminal([executable, "attach", "-t", "perf"], elsewhere, environment, + failure_report=report) as (driver, master, original): + frame(driver, "perf | *1 2 | /help /detach") + mark(report, "warm_attach_frame_seconds", warm["start"]) + assert_active_terminal(master, original) + observe("reattached") + detach = time.perf_counter() + driver.write("\x02d") + assert driver.wait(timeout=20) == 0, driver.diagnostics + action("reattach_detach_settlement", detach) + assert not list(elsewhere.iterdir()), "reattach must not initialize its caller's cwd" + from tests.coding._lmux_adopted_process import AdoptedLeader, stop_command + + assert len(native_identities) == 1 + leader = AdoptedLeader(native_identities[0]) + stop_error = None + try: + report["managed_stop"] = {"started_at": time.perf_counter(), "result": None} + completed = stop_command( + [executable, "stop", "--server", first["serviceId"], "--yes"], + cwd=root, env=environment, leader=leader, + ) + results = [json.loads(line) for line in completed.stdout.splitlines()] + except BaseException as error: + stop_error = error + raise + finally: + try: + leader.close() + except BaseException as cleanup: + if stop_error is None: + raise + stop_error.add_note("adopted leader close failed: " + type(cleanup).__name__) + assert len(results) == 2 and results[0]["action"] == "stop_preview" + assert results[-1] == {"status": "stopped", "instanceId": first["instanceId"]} + report["managed_stop"]["result"] = results[-1] + stopped = True + except BaseException as error: + primary = error + raise + finally: + if not stopped: + # Only this sample namespace. Recovery of a failed run never + # supplies its normal stop metric or changes its failed verdict. + try: + command("stop", "--all", "--yes") + except BaseException as cleanup: + code = type(cleanup).__name__[:128] + report["managed_cleanup_failure"] = {"type": code} + if primary is None: + raise + primary.add_note("managed fallback cleanup failed: " + code) + + def local_mux(root, report): executable = str(Path(report["measured_prefix"]) / "bin/loushang-mux") with observe_spawn(executable) as server_spawn: @@ -1231,7 +1682,7 @@ def main(root, case, receipt, measured_prefix): "recovery-global", }: raise ValueError("unsupported recovery stage") - if sys.platform != "linux" or case not in (*CASES, "home-isolation"): + if sys.platform != "linux" or case not in (*CASES, "home-isolation", "managed-mux", "managed-product-first-use", *PRODUCT_DIAGNOSTIC_CASES): raise ValueError("unsupported native measurement case") import loushang.coding @@ -1273,6 +1724,27 @@ def operation(): foreground(root, report, embedded=case == "embedded") elif case == "local-mux": local_mux(root, report) + elif case == "managed-mux": + managed_mux(root, report) + elif case == "managed-product-first-use": + from tests.coding._lmux_product_probe import first_use + first_use(root, report) + elif case == "managed-product-history-restore": + from tests.coding._lmux_history_restore import restore_history + restore_history(root, report) + elif case in PRODUCT_DIAGNOSTIC_CASES: + # Diagnostic-only until the coordinator supplies strict receipt + # validation. Reuse the original guarded ownership scope below; + # this entry never publishes valid=True on its own. + from tests.coding._lmux_product_probe import first_reply + first_reply(root, report, admission_diagnostic=case == "managed-product-admission-diagnostic", + delayed_final=case in {"managed-product-delayed-final", "managed-product-interrupt-next-turn", "managed-product-hangup-interrupt-next-turn", "managed-product-hangup-natural-completion"}, + interrupt_next_turn=case in {"managed-product-interrupt-next-turn", "managed-product-hangup-interrupt-next-turn"}, + transport_loss=case in {"managed-product-hangup-interrupt-next-turn", "managed-product-hangup-natural-completion"}, + natural_completion=case == "managed-product-hangup-natural-completion", + tool_approval=case == "managed-product-tool-approval", + tool_denial=case == "managed-product-tool-denial", + history=case == "managed-product-history-warm") elif case == "g14-stdio": with ( stdio_observer_scope() as backend, diff --git a/tests/coding/_hosted_product_child.py b/tests/coding/_hosted_product_child.py index 908d13598..0b1629945 100644 --- a/tests/coding/_hosted_product_child.py +++ b/tests/coding/_hosted_product_child.py @@ -68,6 +68,8 @@ async def scripted_stream(model, context, options=None): ) user_text = latest_text if latest.role == "user" else "" text = "waiting" if user_text == "hold" else "真实跨进程回复\nG14" + if user_text == "markdown": + text = "## Render witness\n\nUse **bold witness**.\n\n```python\nprint('code witness')\n```" emit("model_input", roles=[message.role for message in context.messages[-8:]], message_count=len(context.messages), latest=text_shape(latest_text), latest_role=latest.role) diff --git a/tests/coding/_lmux_admission_diagnostic.py b/tests/coding/_lmux_admission_diagnostic.py new file mode 100644 index 000000000..05b01271f --- /dev/null +++ b/tests/coding/_lmux_admission_diagnostic.py @@ -0,0 +1,88 @@ +"""Diagnostic-only binding observation; never use for performance samples.""" + +from __future__ import annotations + +from dataclasses import replace + + +class AdmissionTrace: + """Fail closed for observation, without changing the observed operation.""" + + def __init__(self, emit): + self.emit = emit + self.disabled = False + + def record(self, action, outcome, error=None): + if self.disabled: + return + try: + details = {} + if error is not None: + name = type(error).__name__ + details["error_type"] = name if name in { + "ManagedStorageError", "AppServiceError", "TimeoutError", + "CancelledError", "ValueError", "TypeError", "OSError", + "RuntimeError", "AssertionError", "KeyboardInterrupt", + } else "OtherError" + code = getattr(error, "code", None) + details["error_code"] = code if type(code) is str and code in { + "busy", "unavailable", "closed", "conflict", "invalid_record", + } else "other" + self.emit("admission_" + action + "_" + outcome, **details) + except BaseException: + # This is the diagnostic sink, not the delegated operation. Even + # cancellation/interrupt here must not strand an acquired owner or + # replace the operation's original exception. Missing stages mean + # incomplete diagnostic evidence, never a successful observation. + self.disabled = True + + +class _Admission: + def __init__(self, trace): + self.trace = trace + self.owner = None + + async def acquire(self): + self.trace.record("acquire", "enter") + try: + result = await self.owner.acquire() + except BaseException as error: + self.trace.record("acquire", "fail", error) + raise + self.trace.record("acquire", "return") + return result + + def check_creation(self, previous): + self.trace.record("check", "enter") + try: + result = self.owner.check_creation(previous) + except BaseException as error: + self.trace.record("check", "fail", error) + raise + self.trace.record("check", "return") + return result + + async def close(self): + self.trace.record("close", "enter") + try: + result = await self.owner.close() + except BaseException as error: + self.trace.record("close", "fail", error) + raise + self.trace.record("close", "return") + return result + + +def observe_binding(binding, trace): + def prepare(request): + proxy = _Admission(trace) # Allocate before receiving the original owner. + trace.record("prepare", "enter") + try: + proxy.owner = binding.prepare(request) + except BaseException as error: + trace.record("prepare", "fail", error) + raise + trace.record("prepare", "return") + return proxy + + return replace(binding, prepare=prepare) diff --git a/tests/coding/_lmux_adopted_process.py b/tests/coding/_lmux_adopted_process.py new file mode 100644 index 000000000..4ea5fefec --- /dev/null +++ b/tests/coding/_lmux_adopted_process.py @@ -0,0 +1,120 @@ +"""Test-observer wait duty for one exact adopted managed leader. + +No signals, process scans, journal writes or production stop substitutes. The +original stopper must still prove application and process-group settlement. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import time +from pathlib import Path + +from loushang.hosting.service import LinuxServiceObserverV1 + + +class AdoptedLeader: + def __init__(self, identity): + if sys.platform != "linux" or not callable(getattr(os, "waitid", None)): + raise RuntimeError("managed measurement requires P_PIDFD") + self._observer = LinuxServiceObserverV1.reopen(identity) + self._result = None + self._close_unknown = False + self._closed = False + try: + # A retained pidfd and before/after liveness bind this parent check + # to the original live leader, not a recycled numeric PID. + assert not self._observer.exited(), "leader exited before adoption check" + status = Path(f"/proc/{identity.pid}/status").read_text() + parents = [line.split()[1] for line in status.splitlines() if line.startswith("PPid:")] + assert parents == [str(os.getpid())], "leader is not adopted by this observer" + assert not self._observer.exited(), "leader exited during adoption check" + except BaseException as primary: + try: + self.close() + except BaseException as cleanup: + primary.add_note("adopted observer close failed: " + type(cleanup).__name__) + raise + + def poll(self) -> bool: + if self._closed or self._close_unknown: + raise RuntimeError("adopted leader close is complete or unknown") + # Deliberate test-only borrow, serialized with the original pidfd owner. + # Do not expose raw pidfds or wait/reap in the production observation API. + with self._observer._mutex: + if self._observer._fd is None: + raise RuntimeError("adopted leader observer closed") + if self._result is None: + self._result = os.waitid( + # Linux UAPI linux/wait.h: P_PIDFD = 3. Standalone + # CPython may omit the name while exposing native waitid. + # Unsupported kernels fail here; never fall back to P_PID. + getattr(os, "P_PIDFD", 3), self._observer._fd, os.WEXITED | os.WNOHANG, + ) + result = self._result + if result is None: + return False + assert result.si_pid == self._observer.identity.pid + assert result.si_code == os.CLD_EXITED and result.si_status == 0, ( + "managed leader did not exit successfully" + ) + return True + + def close(self) -> None: + if self._close_unknown: + raise RuntimeError("adopted leader close outcome unknown") + if self._closed: + return + self._close_unknown = True + self._observer.close() + self._closed = True + self._close_unknown = False + + +def stop_command(argv, *, cwd, env, leader, timeout=45): + """Wait for stop while fulfilling only the original adopted leader wait.""" + process = subprocess.Popen(argv, cwd=cwd, env=env, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + deadline = time.monotonic() + timeout + primary = None + try: + while True: + leader.poll() + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("managed stop command deadline") + try: + stdout, stderr = process.communicate(timeout=min(0.05, remaining)) + break + except subprocess.TimeoutExpired: + continue + assert process.returncode == 0, (stdout, stderr) + assert leader.poll(), "stop returned without exact leader reap" + return subprocess.CompletedProcess(argv, process.returncode, stdout, stderr) + except BaseException as error: + primary = error + raise + finally: + failures = [] + + def attempt(operation): + try: + operation() + except BaseException as cleanup: + failures.append(cleanup) + + def stop_foreground(): + if process.poll() is None: + process.kill() # Only our foreground stop command, never the service. + + attempt(stop_foreground) + attempt(lambda: process.communicate(timeout=10)) + for stream in (process.stdout, process.stderr): + if stream is not None: + attempt(stream.close) + if failures: + if primary is None: + raise failures[0] + primary.add_note("stop command cleanup failed: " + type(failures[0]).__name__) diff --git a/tests/coding/_lmux_completion_gate.py b/tests/coding/_lmux_completion_gate.py new file mode 100644 index 000000000..f24566f99 --- /dev/null +++ b/tests/coding/_lmux_completion_gate.py @@ -0,0 +1,64 @@ +"""Single-use, private test control; never a production authorization channel.""" +from __future__ import annotations + +import asyncio +import math +import os +import re +import stat +from pathlib import Path + + +def _path(root: Path, instance: str, nonce: str) -> Path: + if any(re.fullmatch(r"[0-9a-f]{32}", value) is None for value in (instance, nonce)): + raise ValueError("invalid completion gate identity") + info = root.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_mode & 0o077 or root.resolve() != root: + raise ValueError("completion gate requires a private original test directory") + return root / f"release-{instance}-{nonce}" + + +def release(root: Path, instance: str, nonce: str) -> None: + path = _path(root, instance, nonce) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "wb") as stream: + stream.write(f"{instance}:{nonce}".encode("ascii")) + + +class CompletionGate: + def __init__(self, root: Path, instance: str, *, timeout: float = 90): + if not math.isfinite(timeout) or timeout <= 0: + raise ValueError("invalid completion gate deadline") + self.root, self.instance, self.timeout = root, instance, timeout + self.claimed: set[str] = set() + + async def __call__(self, nonce: str) -> None: + path = _path(self.root, self.instance, nonce) + if nonce in self.claimed: + raise ValueError("completion gate already consumed") + self.claimed.add(nonce) + expected = f"{self.instance}:{nonce}".encode("ascii") + loop = asyncio.get_running_loop() + deadline = loop.time() + self.timeout + async with asyncio.timeout_at(deadline): + while True: + try: + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + except FileNotFoundError: + await asyncio.sleep(0.01) + continue + with os.fdopen(fd, "rb") as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_mode & 0o077 or info.st_nlink != 1: + raise ValueError("invalid completion release file") + payload = stream.read(len(expected) + 1) + # The exclusive release file can be observed before its writer + # closes. Incomplete bytes never release; the same deadline applies. + if len(payload) < len(expected) and expected.startswith(payload): + await asyncio.sleep(0.01) + continue + if payload != expected: + raise ValueError("completion release identity mismatch") + if loop.time() >= deadline: + raise TimeoutError("completion release arrived after deadline") + return diff --git a/tests/coding/_lmux_history_canonical.py b/tests/coding/_lmux_history_canonical.py new file mode 100644 index 000000000..9eceb6460 --- /dev/null +++ b/tests/coding/_lmux_history_canonical.py @@ -0,0 +1,75 @@ +"""Post-stop canonical read through public transcript APIs, never a writer.""" + +import time +from dataclasses import asdict +from pathlib import Path + +from loushang.ai.types import AssistantMessage, TextPart, UserMessage +from loushang.appserver.protocol import SessionIdentityV1 +from loushang.coding.hosted_catalog import CODING_HOSTED_COMPATIBILITY_ID +from loushang.harness.transcript import ( + CONTEXT_BRANCH_SUMMARY_KIND, + CONTEXT_COMPACTION_CHECKPOINT_KIND, + AgentTranscriptProfile, + load_agent_transcript_file, +) + +from ._lmux_history_recipe import validate_history + + +def read_history(root: Path, identity: SessionIdentityV1, workspace: Path, *, receipt=None) -> dict: + """Caller must first settle its original service and local owners. + + root is the explicitly selected private canonical Session root, not a search + path. Returned text evidence does not prove that lifecycle precondition. + """ + if type(identity) is not SessionIdentityV1 or identity.product_id != "coding": + raise ValueError("invalid history Session identity") + if root.is_symlink() or not root.is_dir(): + raise ValueError("invalid history canonical root") + path = root / f"hosted-{identity.session_id}.jsonl" + if path.parent != root or path.is_symlink(): + raise ValueError("invalid history canonical path") + started = time.perf_counter() + header, records = load_agent_transcript_file(path, read_only=True, max_bytes=2_097_152) + hosted = header.metadata.get("coding.hosted") + if (header.conversation_id != identity.session_id or header.parent_conversation_id is not None + or header.metadata.get("cwd") != str(workspace) + or not isinstance(hosted, dict) + or set(hosted) != {"version", "compatibilityId", "continuityId", "sessionId", "scope", "scopeFingerprint", "operationId"} + or type(hosted["version"]) is not int or hosted["version"] != 1 + or hosted["compatibilityId"] != CODING_HOSTED_COMPATIBILITY_ID + or hosted["continuityId"] != identity.continuity_id + or hosted["sessionId"] != identity.session_id + or hosted["scope"] != identity.scope.value + or hosted["scopeFingerprint"] != identity.scope_fingerprint + or type(hosted["operationId"]) is not str or not hosted["operationId"]): + raise ValueError("canonical history header differs from authenticated Session") + previous, seen = None, set() + for record in records: + if (record.record_id in seen or record.parent_id != previous + or record.kind in {CONTEXT_BRANCH_SUMMARY_KIND, CONTEXT_COMPACTION_CHECKPOINT_KIND}): + raise ValueError("canonical history is not the fixed linear uncompressed chain") + seen.add(record.record_id) + previous = record.record_id + projected = [] + for message in AgentTranscriptProfile.default().replay(records).messages: + if not isinstance(message, (UserMessage, AssistantMessage)): + raise ValueError("unexpected history message") + content = message.content + if isinstance(content, str): + text = content + else: + if any(type(part) is not TextPart for part in content): + raise ValueError("non-text history content") + text = "".join(part.text for part in content) + projected.append([message.role, text]) + result = validate_history(projected) + if receipt is not None: + # Exact inputs to this public read, not an unrelated post-read stat or + # a claim that a pathname itself proves native file identity. + receipt.update(started_at=started, completed_at=time.perf_counter(), + root=str(root), path=str(path), workspace=str(workspace), + identity={**asdict(identity), "scope": identity.scope.value}, + canonical=dict(result)) + return result diff --git a/tests/coding/_lmux_history_frames.py b/tests/coding/_lmux_history_frames.py new file mode 100644 index 000000000..2d8bf90d2 --- /dev/null +++ b/tests/coding/_lmux_history_frames.py @@ -0,0 +1,30 @@ +"""Current-frame history witness; never a replacement for canonical evidence.""" + +from ._lmux_product_frames import _frame_lines, target_state_visible + + +def history_content_visible(lines) -> bool: + lines = tuple(line.strip() for line in lines) + # The shared terminal theme retains code fences; headings lose Markdown + # prefixes. Do not require a different rendering style from this observer. + if any("## History 0127" in line for line in lines): + return False + markers = ("History 0127", "completed round 0127", "code-0127", "LMUX_HISTORY_0127_END") + positions = [] + for marker in markers: + found = [index for index, line in enumerate(lines) if marker in line] + if len(found) != 1: + return False + positions.append(found[0]) + expected = ( + "History 0127", "", "- completed round 0127", "", "```text", + "code-0127", "```", "", "LMUX_HISTORY_0127_END", + ) + # Pin the actual terminal theme, not an imagined bullet or code border. + # Exact lines also reject markers embedded in unrelated prose. + return lines[positions[0]:positions[-1] + 1] == expected + + +def history_frame_visible(output: str, *, after: int = 0) -> bool: + return (history_content_visible(_frame_lines(output, after=after)) + and target_state_visible(output, running=False, after=after)) diff --git a/tests/coding/_lmux_history_recipe.py b/tests/coding/_lmux_history_recipe.py new file mode 100644 index 000000000..12579f4f5 --- /dev/null +++ b/tests/coding/_lmux_history_recipe.py @@ -0,0 +1,69 @@ +"""Deterministic test-only long-history bytes; no runtime or filesystem IO.""" + +from __future__ import annotations + +import hashlib +import json + +RECIPE = "lmux-history-128x2048/v1" +ROUNDS = 128 +REPLY_BYTES = 2048 +TEXT_BYTES = 263680 +OMITTED = "Earlier messages omitted from this bounded snapshot; the canonical transcript is unchanged." + + +def history_turn(index: int) -> tuple[str, str]: + if type(index) is not int or not 0 <= index < ROUNDS: + raise ValueError("invalid history round") + title = f"{index:04d}" + ending = ( + f"\n\n## History {title}\n\n" + f"- completed round {title}\n\n" + f"```text\ncode-{title}\n```\n\n" + f"LMUX_HISTORY_{title}_END" + ) + pattern = f"history-{title} deterministic evidence. " + size = REPLY_BYTES - len(ending) + body = (pattern * ((size + len(pattern) - 1) // len(pattern)))[:size] + return "history " + title, body + ending + + +def history_records() -> list[list[str]]: + return [[kind, text] for index in range(ROUNDS) + for kind, text in zip(("user", "assistant"), history_turn(index), strict=True)] + + +def history_digest(records: list[list[str]]) -> str: + """Exact canonical text projection, not a Session or file identity proof.""" + if type(records) is not list or any( + type(row) is not list or len(row) != 2 or row[0] not in ("user", "assistant") + or type(row[0]) is not str or type(row[1]) is not str for row in records + ): + raise ValueError("invalid history text records") + payload = json.dumps(records, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def validate_history(records: list[list[str]]) -> dict: + if records != history_records(): + raise ValueError("history differs from fixed recipe") + digest = history_digest(records) + return {"recipe": RECIPE, "rounds": ROUNDS, "records": len(records), + "text_bytes": sum(len(text.encode("utf-8")) for _, text in records), "sha256": digest} + + +def validate_history_window(records: list[list[str]], index: int) -> None: + """Exact current Product tail, not proof of idle, identity or persistence. + + The 16,384-character Product budget fits seven complete 2,060-byte turns. + The preceding assistant cannot fit, so no partial eighth turn is admitted. + Keep this independent of the production projection to detect drift. + """ + history_turn(index) # Reject invalid/bool round numbers before slicing. + first = max(0, index - 6) + expected = [[kind, text] for turn in range(first, index + 1) + for kind, text in zip(("user", "assistant"), history_turn(turn), strict=True)] + if first: + expected.insert(0, ["status", OMITTED]) + if type(records) is not list or records != expected: + raise ValueError("history snapshot differs from expected bounded tail") diff --git a/tests/coding/_lmux_history_restore.py b/tests/coding/_lmux_history_restore.py new file mode 100644 index 000000000..40a6aead7 --- /dev/null +++ b/tests/coding/_lmux_history_restore.py @@ -0,0 +1,104 @@ +"""Restart a seeded service under the original retained evidence process.""" + +import json +import time +from dataclasses import asdict +from pathlib import Path + +from loushang.appserver.protocol import SessionIdentityV1, SessionScopeV1 +from loushang.tui.cell_width import strip_control_sequences + +from . import _lmux_product_probe as probe +from ._lmux_history_canonical import read_history + + +def restore_history(root, report): + """Old and new generations have distinct receipts and stop attempts.""" + old = {"measured_prefix": report["measured_prefix"], "valid": False, "status": "running"} + new = {"measured_prefix": report["measured_prefix"], "valid": False, "status": "not-started"} + report["history_generations"] = {"old": old, "new": new} + probe.first_reply(root, old, history=True) + old["status"] = "observed" + prior = old["fixed_product_history"] + target = old["fixed_product_target"] + assert prior["stop"] == {"status": "stopped", "instanceId": target["instanceId"]} + assert old["fixed_product_stop"]["result"] == prior["stop"] + identity = prior["seed"]["history_identity"] + canonical_read = prior["canonical_read"] + assert old["fixed_product_stop"]["local_owner_settled_at"] <= canonical_read["started_at"] + assert canonical_read["started_at"] <= canonical_read["completed_at"] + new["status"] = "running" + _restart(root, new, target, old["fixed_product_native"]["stop"], identity, + canonical_read["completed_at"], prior["canonical"]) + new["status"] = "observed" + report["fixed_product_history_restore"] = new["restored_history"] + report["spawns"] = [*old["spawns"], *new["spawns"]] + + +def _restart(root, report, old_target, old_native, identity, old_completed_at, old_canonical): + prefix = Path(report["measured_prefix"]) + environment = probe._terminal_environment(root) + environment.pop("LOUSHANG_TMPDIR", None) + argv = [str(prefix / "bin/python"), "-I", str(Path(__file__).with_name("_lmux_product_entry.py")), + "start", "-t", "perf"] + target, native = None, [] + attempted = stopped = False + primary = None + try: + with probe.observe_spawn(argv[0]) as spawned, probe.observed_terminal( + argv, root, environment, failure_report=report, + line_settlements=report.setdefault("line_terminal_settlements", []), + ) as (driver, _master, _original): + report.setdefault("spawns", []).append(spawned) + assert spawned["start"] >= old_completed_at + assert driver.wait(timeout=45) == 0, driver.diagnostics + # start is a line-oriented command, not a TUI: the original context + # still proves termios/reader settlement without inventing mode toggles. + rows = [json.loads(line) for line in strip_control_sequences(driver.raw_output).splitlines() if line.strip()] + assert len(rows) == 1 and rows[0]["status"] == "service_ready" + ready = rows[0] + report["start_command"] = {"started_at": spawned["start"], "settled_at": time.perf_counter(), + "result": ready, "exit_status": 0} + candidates = [] + candidate = probe.managed_read_observation(environment, "first-member", native_identity=candidates) + assert len(candidates) == 1 + assert candidate["serviceId"] == old_target["serviceId"] == ready["serviceId"] + assert candidate["instanceId"] == ready["instanceId"] != old_target["instanceId"] + assert asdict(candidates[0]) != old_native + # Only the fully checked new generation is eligible for exact stop. + # Name/ready mismatches remain the original outer owner's responsibility. + target, native = candidate, candidates + report["fixed_product_target"] = dict(target) + report["authenticated_at"] = time.perf_counter() + probe._record_native(report, "restored", native[0]) + attached = probe._history_attach(root, report, environment, target, native, identity, + directory="restored-elsewhere") + attempted = True + result = probe._stop_generation(root, report, environment, target, native[0]) + stopped = True + restored_identity = {**identity, "scope": SessionScopeV1(identity["scope"])} + canonical_read = {} + canonical = read_history(Path(environment["LOUSHANG_HOME"]) / "data/sessions", + SessionIdentityV1(**restored_identity), root, receipt=canonical_read) + assert canonical == old_canonical + finished = attached["actions"]["history_frame"]["finished_at"] + report["restored_history"] = {"attach": attached, "canonical": canonical, "stop": result, + "canonical_read": canonical_read, + "restored_history_frame_seconds": finished - spawned["start"], + "action": {"started_at": spawned["start"], "finished_at": finished}} + except BaseException as error: + primary = error + report.update(status="failed", failure=type(error).__name__) + raise + finally: + if not stopped: + try: + if attempted or target is None or len(native) != 1: + raise RuntimeError("restart exact stop unavailable; original outer owner must settle") + attempted = True + probe._stop_generation(root, report, environment, target, native[0]) + except BaseException as cleanup: + report["cleanup_failure"] = type(cleanup).__name__ + if primary is None: + raise + primary.add_note("restart cleanup failed: " + type(cleanup).__name__) diff --git a/tests/coding/_lmux_history_seed.py b/tests/coding/_lmux_history_seed.py new file mode 100644 index 000000000..7a71cecab --- /dev/null +++ b/tests/coding/_lmux_history_seed.py @@ -0,0 +1,169 @@ +"""Public request/snapshot seed, borrowing an already owned attachment. + +The caller owns authentication, attachment, connection close and service stop. +Returning here never authorizes starting a PTY before those resources settle. +""" + +import asyncio +import math +import sys +import time + +from loushang.appserver.protocol import ( + AckV1, + AttachmentEventV1, + MuxAttachV1, + MuxDetachV1, + MuxSelectorV1, + SessionEventKindV1, + SessionSnapshotRequestV1, + TurnTextV1, +) + +from ._lmux_history_recipe import ROUNDS, history_turn, validate_history_window + +SEED_SECONDS = 600.0 +ROUND_SECONDS = 40.0 +MAX_POLLS = 80 +MAX_EVENT_READS = 64 +POLL_SECONDS = 0.1 + + +async def seed_attached_history(client, *, mux_id, member_id, identity, deadline): + """Own a temporary attachment, borrowing the caller's original connection. + + Lost attach/detach responses remain failures. Only the original connection + owner can settle an attachment whose response was lost. + """ + if (type(deadline) not in (int, float) or not 0 <= deadline <= sys.float_info.max + or not math.isfinite(deadline)): + raise ValueError("invalid attachment deadline") + + async def bounded(operation, limit): + remaining = limit - time.monotonic() + if remaining <= 0: + raise TimeoutError("history attachment deadline expired") + async with asyncio.timeout(remaining): + result = await operation() + if time.monotonic() >= limit: + raise TimeoutError("history attachment result arrived too late") + return result + + attach_started = time.monotonic() + attach_deadline = min(deadline, attach_started + 30) + attachment = await bounded(lambda: client.attach_mux(MuxAttachV1(MuxSelectorV1(mux_space_id=mux_id))), + attach_deadline) + attached = time.monotonic() + primary = None + try: + mux = attachment.mux_space + if (mux.mux_space_id != mux_id or len(mux.members) != 1 + or mux.members[0].member_id != member_id or mux.members[0].session != identity): + raise ValueError("history attachment differs from authenticated target") + evidence = await seed_history(client, attachment_id=attachment.attachment_id, + generation=attachment.controller_generation, member_id=member_id, identity=identity, + deadline=deadline - 30) # Reserve bounded detach time, never borrow from the seed. + except BaseException as error: + primary = error + raise + finally: + try: + detach_started = time.monotonic() + detach_deadline = min(deadline, detach_started + 30) + ack = await bounded(lambda: client.detach_mux(MuxDetachV1( + attachment.attachment_id, attachment.controller_generation)), + detach_deadline) + if type(ack) is not AckV1: + raise ValueError("history detach requires successful Ack") + except BaseException as cleanup: + if primary is None: + raise + primary.add_note("history detach failed: " + type(cleanup).__name__) + detached = time.monotonic() + if detached >= deadline: + raise TimeoutError("history attachment completion exceeded deadline") + return {**evidence, "detached_at": detached, + "attachment": {"deadline": deadline, "attach_started_at": attach_started, + "attach_deadline": attach_deadline, "attached_at": attached, + "detach_started_at": detach_started, "detach_deadline": detach_deadline}} + + +async def seed_history(client, *, attachment_id, generation, member_id, identity, deadline): + if (type(deadline) not in (int, float) or not 0 <= deadline <= sys.float_info.max + or not math.isfinite(deadline)): + raise ValueError("invalid seed deadline") + started = time.monotonic() + deadline = min(deadline, started + SEED_SECONDS) + request = SessionSnapshotRequestV1(attachment_id, generation, member_id) + + async def bounded(operation, limit): + remaining = limit - time.monotonic() + if remaining <= 0: + raise TimeoutError("history seed deadline expired before dispatch") + async with asyncio.timeout(remaining): + result = await operation() + if time.monotonic() >= limit: + raise TimeoutError("history seed result arrived after deadline") + return result + + def records(snapshot): + if snapshot.identity != identity: + raise ValueError("history seed Session identity changed") + rows = [[record.kind.value, record.text] for record in snapshot.records] + if any(kind == "error" for kind, _ in rows): + raise ValueError("history seed received error record") + return rows + + initial = await bounded(lambda: client.snapshot_session(request), min(deadline, started + ROUND_SECONDS)) + if records(initial) or initial.running is not False: + raise ValueError("history seed requires a fresh idle Session") + evidence = [] + previous = [] + for index in range(ROUNDS): + began = time.monotonic() + limit = min(deadline, began + ROUND_SECONDS) + text, _ = history_turn(index) + ack = await bounded(lambda text=text: client.start_turn(TurnTextV1( + attachment_id, generation, member_id, text, + )), limit) + if type(ack) is not AckV1: + raise ValueError("history seed requires a successful Ack") + acknowledged = time.monotonic() + for poll in range(MAX_POLLS): + # Snapshot reads do not consume the bounded attachment mailbox. + # Borrow the same client's normal event path; never reattach or + # replay a turn to recover from lag. The wire may return one event + # per frame: only an empty batch proves this drain completed. + for _ in range(MAX_EVENT_READS): + events = await bounded(lambda: client.read_events(attachment_id=attachment_id, + controller_generation=generation, limit=64), limit) + if type(events) is not tuple or len(events) > 64: + raise ValueError("invalid history event batch") + if not events: + break + for event in events: + if (type(event) is not AttachmentEventV1 or event.attachment_id != attachment_id + or event.member_id != member_id or event.event.session_id != identity.session_id + or event.event.kind is SessionEventKindV1.ERROR): + raise ValueError("history event differs from current Session") + else: + raise TimeoutError("history event read budget exhausted") + snapshot = await bounded(lambda: client.snapshot_session(request), limit) + rows = records(snapshot) + if snapshot.running is False and rows != previous: + validate_history_window(rows, index) + settled = time.monotonic() + if settled >= limit: + raise TimeoutError("history seed validation exceeded round deadline") + evidence.append({"round": index, "started_at": began, "acknowledged_at": acknowledged, + "settled_at": settled, "snapshot_reads": poll + 1}) + previous = rows + break + # Old idle is not this turn's success; never send the request twice. + if poll + 1 == MAX_POLLS: + raise TimeoutError("history seed snapshot read budget exhausted") + await bounded(lambda: asyncio.sleep(POLL_SECONDS), limit) + finished = time.monotonic() + if finished >= deadline: + raise TimeoutError("history seed completion exceeded total deadline") + return {"started_at": started, "finished_at": finished, "rounds": evidence} diff --git a/tests/coding/_lmux_interaction_receipt.py b/tests/coding/_lmux_interaction_receipt.py new file mode 100644 index 000000000..405e48916 --- /dev/null +++ b/tests/coding/_lmux_interaction_receipt.py @@ -0,0 +1,28 @@ +"""Fixed diagnostic observer of the original interaction RPC, never a retry.""" + +from contextlib import contextmanager + + +@contextmanager +def observe_interaction_receipts(client_type, trace): + original = client_type.respond_interaction + + async def observed(self, request): + from loushang.appserver.protocol import AckV1 + + fields = dict(attachment_id=request.attachment_id, + controller_generation=request.controller_generation, + member_id=request.member_id, interaction_id=request.interaction_id, + outcome=request.outcome.value) + trace.emit("interaction_sent", **fields) + result = await original(self, request) + if type(result) is not AckV1: + raise TypeError("unexpected original interaction receipt") + trace.emit("interaction_accepted", **fields) + return result + + client_type.respond_interaction = observed + try: + yield + finally: + client_type.respond_interaction = original diff --git a/tests/coding/_lmux_product_child.py b/tests/coding/_lmux_product_child.py new file mode 100644 index 000000000..cc4c75046 --- /dev/null +++ b/tests/coding/_lmux_product_child.py @@ -0,0 +1,70 @@ +"""Fixed installed managed-child composition for first-use tests only. + +Accept only the production invocation/session-root/control-descriptor arguments. +The test parent precreates a private observations directory in its workspace. +No dynamic provider/module/executable selection is exposed to production. +""" + +from __future__ import annotations + +import os +import runpy +import stat +import sys +from dataclasses import replace +from pathlib import Path + + +def request_factory(invocation, descriptor, *, executable, environment, session_root=None, admission_diagnostic=False): + """Keep original launch facts; select only this fixed, frozen test entry.""" + from loushang.coding.managed_process import coding_managed_process_request + + original = coding_managed_process_request( + invocation, descriptor, executable=executable, + environment=environment, session_root=session_root, + ) + return replace(original, argv=( + original.argv[0], "-I", str(Path(__file__).resolve()), *original.argv[3:], + *(("--admission-diagnostic",) if admission_diagnostic else ()), + )) + + +def main(arguments=None): + from loushang.apphost.managed.invocation import ManagedChildInvocationV1 + from loushang.coding import managed_process + + arguments = list(sys.argv[1:] if arguments is None else arguments) + diagnostic = len(arguments) == 4 and arguments[-1] == "--admission-diagnostic" + if diagnostic: + arguments = arguments[:-1] + if len(arguments) != 3: + raise ValueError("fixed child requires original managed arguments") + origin = Path(managed_process.__file__).resolve() + if not origin.is_relative_to(Path(sys.prefix).resolve()): + raise ValueError("fixed child must use its measured installation") + invocation = ManagedChildInvocationV1.from_json(arguments[0]) + root = Path(invocation.service.workspace) / "lmux-test-observations" + info = root.lstat() + if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid() + or info.st_mode & 0o077 or root.resolve() != root): + raise ValueError("test observation root must be precreated and private") + + # Fixed sibling resources are bound by the collector's helper manifest. + # Loading BoundaryTrace alone does not call its instrumenting install(). + helpers = Path(__file__).resolve().parent + trace_type = runpy.run_path(str(helpers / "_hosted_boundary_trace.py"))["BoundaryTrace"] + run_product = runpy.run_path(str(helpers / "_lmux_synthetic_product.py"))["run_product"] + trace = trace_type(root) + trace.emit("fixed_product_selected", instance_id=invocation.instance.instance_id) + options = {"admission_diagnostic": trace.emit} if diagnostic else {} + gate_root = root / "completion-gates" + if gate_root.exists(): + gate_type = runpy.run_path(str(helpers / "_lmux_completion_gate.py"))["CompletionGate"] + options["completion_gate"] = gate_type(gate_root, invocation.instance.instance_id) + result = run_product(arguments, lambda phase, identity: trace.emit(phase, call_id=identity), **options) + trace.emit("fixed_product_returned", status=result) + return result + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/coding/_lmux_product_entry.py b/tests/coding/_lmux_product_entry.py new file mode 100644 index 000000000..6d7d8df1d --- /dev/null +++ b/tests/coding/_lmux_product_entry.py @@ -0,0 +1,50 @@ +"""Fixed installed parent entry for synthetic managed Product tests only.""" + +from __future__ import annotations + +import runpy +import sys +from contextlib import nullcontext +from functools import partial +from pathlib import Path + + +def main(arguments=None): + arguments = list(sys.argv[1:] if arguments is None else arguments) + diagnostic = arguments[:1] == ["--admission-diagnostic"] + if diagnostic: + arguments = arguments[1:] + interaction_diagnostic = arguments[:1] == ["--interaction-diagnostic"] + if interaction_diagnostic: + arguments = arguments[1:] + if arguments not in (["new", "-s", "perf"], ["start", "-t", "perf"]): + raise ValueError("fixed Product parent accepts only new -s perf or start -t perf") + + from loushang.coding.cli import lmux, lmux_command + + prefix = Path(sys.prefix).resolve() + for module in (lmux, lmux_command): + if not Path(module.__file__).resolve().is_relative_to(prefix): + raise ValueError("fixed parent must use its measured installation") + factory = runpy.run_path(str(Path(__file__).with_name("_lmux_product_child.py")))["request_factory"] + if diagnostic: + factory = partial(factory, admission_diagnostic=True) + original = lmux_command.coding_managed_process_request + observation = nullcontext() + if interaction_diagnostic: + from loushang.appserver.remote_client import RemoteAppClientV1 + + helpers = Path(__file__).resolve().parent + trace_type = runpy.run_path(str(helpers / "_hosted_boundary_trace.py"))["BoundaryTrace"] + observe = runpy.run_path(str(helpers / "_lmux_interaction_receipt.py"))["observe_interaction_receipts"] + observation = observe(RemoteAppClientV1, trace_type(Path.cwd() / "lmux-interaction-observations")) + try: + lmux_command.coding_managed_process_request = factory + with observation: + return lmux.main(arguments) + finally: + lmux_command.coding_managed_process_request = original + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/coding/_lmux_product_frames.py b/tests/coding/_lmux_product_frames.py new file mode 100644 index 000000000..ab16624f9 --- /dev/null +++ b/tests/coding/_lmux_product_frames.py @@ -0,0 +1,138 @@ +"""Current-frame witnesses for the fixed fresh-Session Product scenario. + +This proves only the displayed state. Explicit post-detach authenticated +snapshot verification is still required; no native settlement is inferred. +""" + +from __future__ import annotations + +import re + +from ._g18_native_probe import _replay_embedded_output + + +def _frame_lines(output: str, *, after: int) -> tuple[str, ...]: + marker = "\x1b[?2026l" + end = output.rfind(marker) + if end < after: + return () + return tuple(line.strip() for line in _replay_embedded_output(output[:end + len(marker)]).visible_lines) + + +def approval_pending_visible(output: str, *, after: int) -> bool: + lines = _frame_lines(output, after=after) + notice = "Approval pending: F2 details; /approve /deny" + statuses = { + "Hosted | FirstUse | running | " + prefix + notice + for prefix in ("", "submit: request_pending; ") + } + return ( + ">" in lines and "perf | *1! | /help /detach" in lines + and any(line in statuses for line in lines) + ) + + +def approval_details_visible(output: str, *, after: int) -> bool: + lines = _frame_lines(output, after=after) + complete_page = any( + (match := re.fullmatch(r"1-([1-9][0-9]*)/([1-9][0-9]*) \| PgUp/PgDn \| Esc back", line)) + and int(match[1]) == int(match[2]) and int(match[1]) >= 3 for line in lines + ) + return ( + "Approval details — Esc back, then /approve or /deny" in lines + and "lmux_evidence tool call" in lines and "{}" in lines and complete_page + and "perf | *1! | /help /detach" in lines + ) + + +def tool_reply_completed(output: str, *, after: int) -> bool: + lines = _frame_lines(output, after=after) + return ( + ("LMUX_TOOL_COMPLETED" in lines or "* LMUX_TOOL_COMPLETED" in lines) + and _completed_lines(lines) + ) + + +def denied_tool_reply_completed(output: str, *, after: int) -> bool: + lines = _frame_lines(output, after=after) + expected = "Tool lmux_evidence requires approval" + return (expected in lines or "* " + expected in lines) and _completed_lines(lines) + + +def _settled_statuses(state: str, acknowledgement: str) -> set[str]: + base = f"Hosted | FirstUse | {state}" + return {base} | { + base + " | " + prefix + hint + for prefix in ("", acknowledgement + ": request_acknowledged; ") + for hint in ("cwd / user_home; /help", "cwd / user_home: /new ; /help") + } + + +def target_state_visible(output: str, *, running: bool, after: int = 0) -> bool: + """Current target state for reattach/interrupt; not reply completion.""" + marker = "\x1b[?2026l" + end = output.rfind(marker) + if end < after: + return False + lines = tuple(line.strip() for line in _replay_embedded_output(output[:end + len(marker)]).visible_lines) + state = "running" if running else "idle" + prefix = f"Hosted | FirstUse | {state} | " + statuses = _settled_statuses(state, "interrupt") + if running: + statuses.add(prefix + "running; earlier partial output is not in the v1 snapshot") + footer = "perf | *1~ | /help /detach" if running else "perf | *1 | /help /detach" + return ">" in lines and footer in lines and any(line in statuses for line in lines) + + +def _reply_lines(output: str, expected: str, *, after: int) -> tuple[str, ...]: + if re.fullmatch(r"LMUX_REPLY_[0-9a-f]{32}", expected) is None: + return () + marker = "\x1b[?2026l" + end = output.rfind(marker) + if end < after: + return () + screen = _replay_embedded_output(output[:end + len(marker)]) + lines = tuple(line.strip() for line in screen.visible_lines) + if expected not in lines and "* " + expected not in lines: + return () + return lines + + +def reply_streaming(output: str, expected: str, *, after: int) -> bool: + """Full text in the explicitly running target is not completion.""" + lines = _reply_lines(output, expected, after=after) + statuses = { + "Hosted | FirstUse | running | cwd / user_home; /help", + "Hosted | FirstUse | running | cwd / user_home: /new ; /help", + "Hosted | FirstUse | running | submit: request_acknowledged; cwd / user_home; /help", + "Hosted | FirstUse | running | submit: request_pending; cwd / user_home; /help", + } + return ( + ">" in lines and "perf | *1~ | /help /detach" in lines + and any(line in statuses for line in lines) + ) + + +def reply_completed(output: str, expected: str, *, after: int) -> bool: + lines = _reply_lines(output, expected, after=after) + return _completed_lines(lines) + + +def _completed_lines(lines: tuple[str, ...]) -> bool: + statuses = _settled_statuses("idle", "submit") + return ( + ">" in lines and "perf | *1 | /help /detach" in lines + and any(line in statuses for line in lines) + ) + + +def assert_no_completed_reply(output: str, expected: str, *, before_send: str) -> None: + """After PTY settlement, inspect every retained complete post-input frame.""" + assert output.startswith(before_send), "terminal output window was truncated" + marker = "\x1b[?2026l" + offset = len(before_send) + start = offset + while (end := output.find(marker, start)) >= 0: + end += len(marker) + assert not reply_completed(output[:end], expected, after=offset), "blocked final reported complete" + start = end diff --git a/tests/coding/_lmux_product_probe.py b/tests/coding/_lmux_product_probe.py new file mode 100644 index 000000000..7d60064d5 --- /dev/null +++ b/tests/coding/_lmux_product_probe.py @@ -0,0 +1,560 @@ +"""Fixed Product first-reply scenario for the existing guarded PTY collector. + +Call inside the original evidence process/_guarded ownership scope. This is +test composition, not an untouched provider benchmark. Its measured prefix +and helpers must pass the collector's existing provenance checks beforehand. +""" + +from __future__ import annotations + +import json +import time +import uuid +from pathlib import Path + +from ._g18_native_probe import ( + abrupt_terminal, + assert_active_terminal, + managed_completion_frame, + managed_history_confirmation, + managed_history_observation, + managed_read_observation, + managed_reply_observation, + observe_spawn, + observed_terminal, +) +from ._lmux_adopted_process import AdoptedLeader, stop_command +from ._lmux_product_frames import ( + approval_details_visible, + approval_pending_visible, + assert_no_completed_reply, + denied_tool_reply_completed, + reply_completed, + reply_streaming, + target_state_visible, + tool_reply_completed, +) +from .test_mux_product_terminal import _see +from .test_mux_terminal_process import _terminal_environment + + +def _history_attach(root, report, environment, target, native, identity, *, directory="elsewhere"): + from ._lmux_history_frames import history_frame_visible + + executable = str(Path(report["measured_prefix"]) / "bin/lmux") + if directory not in {"elsewhere", "restored-elsewhere"}: + raise ValueError("unsupported fixed history directory") + elsewhere = root / directory + elsewhere.mkdir(mode=0o700) + with observe_spawn(executable) as spawned, observed_terminal( + [executable, "attach", "-t", "perf"], elsewhere, environment, + failure_report=report, settlements=report.setdefault("terminal_settlements", []), + ) as (driver, master, original): + report.setdefault("spawns", []).append(spawned) + driver.read_until(history_frame_visible, timeout=40) + visible = time.perf_counter() + assert_active_terminal(master, original) + offset, completion_start = len(driver.raw_output), time.perf_counter() + driver.write("/he") + driver.read_until(lambda output: managed_completion_frame(output, after=offset), timeout=40) + completed = time.perf_counter() + driver.write("\x7f\x7f\x7f") + driver.write("\x02d") + assert driver.wait(timeout=20) == 0, driver.diagnostics + final_native = [] + final = managed_history_confirmation(environment, target, identity, native_identity=final_native) + assert all(final[key] == target[key] for key in ("instanceId", "serviceId", "muxId", "members")) + assert final_native == native and not list(elsewhere.iterdir()) + _record_native(report, "history-detached", final_native[0]) + return {"history_frame_seconds": visible - spawned["start"], + "history_completion_seconds": completed - completion_start, + "actions": {"history_frame": {"started_at": spawned["start"], "finished_at": visible}, + "history_completion": {"started_at": completion_start, "finished_at": completed}}, + "detached": final} + + +def _record_native(report, stage, identity): + """Serialize an already observed identity, never reopen or signal it.""" + from dataclasses import asdict + report.setdefault("fixed_product_native", {})[stage] = asdict(identity) + + +def _stop_generation(root, report, environment, target, native): + """One exact generation; the caller guards against repeating this attempt.""" + executable = str(Path(report["measured_prefix"]) / "bin/lmux") + stop_started = time.perf_counter() + leader = AdoptedLeader(native) + stop_error = None + try: + _record_native(report, "stop", native) + completed = stop_command([executable, "stop", "--server", target["serviceId"], "--yes"], + cwd=root, env=environment, leader=leader) + results = [json.loads(line) for line in completed.stdout.splitlines()] + assert len(results) == 2 and results[0]["action"] == "stop_preview" + assert results[1] == {"status": "stopped", "instanceId": target["instanceId"]} + stop_observed = time.perf_counter() + except BaseException as error: + stop_error = error + raise + finally: + try: + leader.close() + except BaseException as cleanup: + report.setdefault("fixed_product_cleanup_failure", {"type": type(cleanup).__name__[:128]}) + if stop_error is None: + raise + stop_error.add_note("first reply leader close failed: " + type(cleanup).__name__) + report["fixed_product_stop"] = { + "started_at": stop_started, "observed_at": stop_observed, + "local_owner_settled_at": time.perf_counter(), + "service_id": target["serviceId"], "result": results[1], + } + return results[1] # Original outer evidence process still owes settlement. + + +def first_use(root, report): + """Three fresh scenarios under the caller's original guarded owner. + + This only produces observations. The collector must validate every receipt + and its own physical settlement before accepting a sample. + """ + report["fixed_product_scenarios"] = {} + for name, options, result_key in ( + ("reply", {}, "fixed_product_first_reply"), + ("approval", {"tool_approval": True}, "fixed_product_tool_approval"), + ("interrupt", {"delayed_final": True, "interrupt_next_turn": True}, + "fixed_product_interrupt_next_turn"), + ): + workspace = root / name + workspace.mkdir(mode=0o700) + child = { + "measured_prefix": report["measured_prefix"], "workspace": str(workspace), + "status": "running", "valid": False, "spawns": [], + "started_at": time.perf_counter(), + } + report["fixed_product_scenarios"][name] = child + try: + first_reply(workspace, child, **options) + result = child[result_key] + timing = result["next_turn"] if name == "interrupt" else result + child["actions"] = timing["actions"] + child["milestones"] = { + key + "_seconds": timing[ + "spawn_through_visible_reply_seconds" + if key == "fixed_entry_through_visible_reply" else key + "_seconds" + ] + for key in child["actions"] + } + child.update(status="observed", finished_at=time.perf_counter()) + except BaseException as error: + child.update(status="failed", failure=type(error).__name__) + raise # Never continue into the next fresh scenario after failure. + report["spawns"] = [spawn for child in report["fixed_product_scenarios"].values() for spawn in child["spawns"]] + report["milestones"] = {key: value for child in report["fixed_product_scenarios"].values() + for key, value in child["milestones"].items()} + + +def _fixed_product_trace(root, instance_id): + """Read only bounded test observations; never supply lifecycle authority.""" + from ._hosted_boundary_trace import MAX_RECORD_BYTES, MAX_RECORDS + + paths = tuple((root / "lmux-test-observations").glob("boundary-*.jsonl")) + assert len(paths) == 1, "expected one fixed Product observation stream" + limit = MAX_RECORD_BYTES * MAX_RECORDS + with paths[0].open("rb") as stream: + payload = stream.read(limit + 1) + assert len(payload) <= limit and payload.endswith(b"\n") + rows = [json.loads(line) for line in payload.splitlines()] + assert len(rows) < MAX_RECORDS, "saturated trace is incomplete evidence" + assert [row["sequence"] for row in rows] == list(range(len(rows))) + selected = [row for row in rows if row["phase"] == "fixed_product_selected"] + assert len(selected) == 1 and selected[0]["instance_id"] == instance_id + return rows + + +def _tool_effect_witness(root, instance_id, *, executed, not_before_ns=None, evidence=None): + """A model echo is not a tool effect; only the fixed handler emits this.""" + rows = _fixed_product_trace(root, instance_id) + effects = [row["call_id"] for row in rows if row["phase"] == "tool_executed"] + assert effects == (["lmux-call-1"] if executed else []), "tool effect count or identity mismatch" + if not_before_ns is not None: + assert executed + effect = [row for row in rows if row["phase"] == "tool_executed"] + assert type(effect[0]["monotonic_ns"]) is int + assert effect[0]["monotonic_ns"] >= not_before_ns, "tool executed before this approval" + if evidence is not None: + evidence.extend({"instance_id": instance_id, "call_id": row["call_id"], + "monotonic_ns": row["monotonic_ns"], "sequence": row["sequence"]} + for row in rows if row["phase"] == "tool_executed") + return tuple(effects) + + +def _producer_witness(root, instance_id, *, settled, not_before_ns=None, evidence=None): + rows = _fixed_product_trace(root, instance_id) + producers = [(row["phase"], row["call_id"]) for row in rows + if row["phase"] in {"producer_started", "producer_settled"}] + expected = [("producer_started", "lmux-call-1")] + if settled: + expected.append(("producer_settled", "lmux-call-1")) + assert producers == expected + if not_before_ns is not None: + assert settled + finished = [row for row in rows if row["phase"] == "producer_settled"] + assert len(finished) == 1 and type(finished[0]["monotonic_ns"]) is int + assert finished[0]["monotonic_ns"] >= not_before_ns, "producer settled before this interrupt" + if evidence is not None: + evidence.extend({"instance_id": instance_id, "call_id": row["call_id"], + "phase": row["phase"], "monotonic_ns": row["monotonic_ns"], + "sequence": row["sequence"]} + for row in rows if row["phase"] in {"producer_started", "producer_settled"}) + return "lmux-call-1" + + +def _interrupt_next_turn(root, report, environment, target, native, interrupted_nonce): + """Borrow the caller's service; this function owns only its terminal.""" + executable = str(Path(report["measured_prefix"]) / "bin/lmux") + elsewhere = root / "elsewhere" + elsewhere.mkdir(mode=0o700) + next_nonce = uuid.uuid4().hex + assert next_nonce != interrupted_nonce + expected = "LMUX_REPLY_" + next_nonce + with observe_spawn(executable) as spawned, observed_terminal([executable, "attach", "-t", "perf"], elsewhere, environment, + failure_report=report, settlements=report.setdefault("terminal_settlements", [])) as (driver, master, original): + report.setdefault("spawns", []).append(spawned) + driver.read_until(lambda output: target_state_visible(output, running=True), timeout=40) + assert_active_terminal(master, original) + attached_native = [] + attached = managed_read_observation(environment, "reattached", native_identity=attached_native) + assert all(attached[key] == target[key] for key in ("instanceId", "serviceId", "muxId", "members")) + assert attached_native == native + _record_native(report, "reattached", attached_native[0]) + before_producer, after_producer = [], [] + _producer_witness(root, target["instanceId"], settled=False, evidence=before_producer) + offset, interrupted = len(driver.raw_output), time.perf_counter() + interrupt_sent_ns = time.monotonic_ns() + driver.write("\x03") + driver.read_until(lambda output: target_state_visible(output, running=False, after=offset), timeout=40) + _producer_witness(root, target["instanceId"], settled=True, not_before_ns=interrupt_sent_ns, + evidence=after_producer) + settled = time.perf_counter() + offset, sent = len(driver.raw_output), time.perf_counter() + driver.write("reply " + next_nonce + "\r") # One attempt, never retry busy/lost replies. + driver.read_until(lambda output: reply_completed(output, expected, after=offset), timeout=40) + visible = time.perf_counter() + driver.write("\x02d") + assert driver.wait(timeout=20) == 0, driver.diagnostics + confirmation = managed_reply_observation(environment, target, expected, interrupted_nonce=interrupted_nonce) + final_native = [] + final = managed_read_observation(environment, "detached", native_identity=final_native) + assert all(final[key] == target[key] for key in ("instanceId", "serviceId", "muxId", "members")) + assert final_native == native and not list(elsewhere.iterdir()) + _record_native(report, "final-detached", final_native[0]) + return { + "interrupt_through_idle_and_producer_seconds": settled - interrupted, + "next_reply_seconds": visible - sent, + "interrupt_through_next_reply_seconds": visible - interrupted, + "actions": { + "interrupt_through_idle_and_producer": {"started_at": interrupted, "finished_at": settled}, + "next_reply": {"started_at": sent, "finished_at": visible}, + "interrupt_through_next_reply": {"started_at": interrupted, "finished_at": visible}, + }, + "next_reply_confirmation": confirmation, + "interrupted_nonce": interrupted_nonce, "next_nonce": next_nonce, + "interrupt_sent_ns": interrupt_sent_ns, + "producer_before": before_producer, "producer_after": after_producer, + "reattached": attached, "detached": final, + } + + +def _natural_completion(root, report, environment, target, native, nonce): + """Release only the fixed producer; obtain both replies through public TUI/IPC.""" + from ._lmux_completion_gate import release + + release(root / "lmux-test-observations/completion-gates", target["instanceId"], nonce) + elsewhere = root / "elsewhere" + elsewhere.mkdir(mode=0o700) + expected = "LMUX_REPLY_" + nonce + next_nonce = uuid.uuid4().hex + assert next_nonce != nonce + executable = str(Path(report["measured_prefix"]) / "bin/lmux") + with observe_spawn(executable) as spawned, observed_terminal([executable, "attach", "-t", "perf"], elsewhere, environment, + failure_report=report, settlements=report.setdefault("terminal_settlements", [])) as (driver, master, original): + report.setdefault("spawns", []).append(spawned) + driver.read_until(lambda output: reply_completed(output, expected, after=0), timeout=40) + assert_active_terminal(master, original) + attached_native = [] + attached = managed_read_observation(environment, "reattached", native_identity=attached_native) + assert all(attached[key] == target[key] for key in ("instanceId", "serviceId", "muxId", "members")) + assert attached_native == native + phases = [row["phase"] for row in _fixed_product_trace(root, target["instanceId"]) + if row.get("call_id") == "lmux-call-1"] + assert phases == ["producer_started", "gate_released", "final_emitted", "producer_settled"] + offset = len(driver.raw_output) + driver.write("reply " + next_nonce + "\r") + driver.read_until(lambda output: reply_completed(output, "LMUX_REPLY_" + next_nonce, after=offset), timeout=40) + driver.write("\x02d") + assert driver.wait(timeout=20) == 0, driver.diagnostics + confirmation = managed_reply_observation(environment, target, "LMUX_REPLY_" + next_nonce, + natural_nonce=nonce) + assert not list(elsewhere.iterdir()) + return {"original_producer_phases": phases, "two_turn_confirmation": confirmation, + "original_request_resent": False} + + +def _approve_first_tool(driver, root, target): + _tool_effect_witness(root, target["instanceId"], executed=False) + offset, submitted = len(driver.raw_output), time.perf_counter() + driver.write("approval\r") + driver.read_until(lambda output: approval_pending_visible(output, after=offset), timeout=40) + pending = time.perf_counter() + _tool_effect_witness(root, target["instanceId"], executed=False) + offset, opened = len(driver.raw_output), time.perf_counter() + driver.write("/question\r") + driver.read_until(lambda output: approval_details_visible(output, after=offset), timeout=40) + presented = time.perf_counter() + _tool_effect_witness(root, target["instanceId"], executed=False) + offset = len(driver.raw_output) + driver.write("\x1b") + driver.read_until(lambda output: approval_pending_visible(output, after=offset), timeout=40) + _tool_effect_witness(root, target["instanceId"], executed=False) + offset, approved = len(driver.raw_output), time.perf_counter() + approved_sent_ns = time.monotonic_ns() + driver.write("/approve\r") + driver.read_until(lambda output: tool_reply_completed(output, after=offset), timeout=40) + completed = time.perf_counter() + _tool_effect_witness(root, target["instanceId"], executed=True, not_before_ns=approved_sent_ns) + return { + "approval_pending_seconds": pending - submitted, + "approval_details_seconds": presented - opened, + "approved_tool_reply_seconds": completed - approved, + "actions": { + "approval_pending": {"started_at": submitted, "finished_at": pending}, + "approval_details": {"started_at": opened, "finished_at": presented}, + "approved_tool_reply": {"started_at": approved, "finished_at": completed}, + }, + "approved_sent_ns": approved_sent_ns, + } + + +def _denial_receipt(root, target, *, not_before_ns): + from ._hosted_boundary_trace import MAX_RECORD_BYTES, MAX_RECORDS + + paths = tuple((root / "lmux-interaction-observations").glob("boundary-*.jsonl")) + assert len(paths) == 1 + limit = MAX_RECORD_BYTES * MAX_RECORDS + with paths[0].open("rb") as stream: + payload = stream.read(limit + 1) + assert len(payload) <= limit and payload.endswith(b"\n") + rows = [json.loads(line) for line in payload.splitlines()] + assert len(rows) == 2 and [row["sequence"] for row in rows] == [0, 1] + assert [row["phase"] for row in rows] == ["interaction_sent", "interaction_accepted"] + keys = ("attachment_id", "controller_generation", "member_id", "interaction_id", "outcome") + assert all(rows[0][key] == rows[1][key] for key in keys) + assert rows[0]["outcome"] == "deny" + assert rows[0]["member_id"] == target["members"][0]["memberId"] + assert rows[0]["interaction_id"] and rows[0]["attachment_id"] + assert type(rows[0]["controller_generation"]) is int and rows[0]["controller_generation"] > 0 + assert all(type(row["monotonic_ns"]) is int for row in rows) + assert not_before_ns <= rows[0]["monotonic_ns"] <= rows[1]["monotonic_ns"] + + +def _deny_first_tool(driver, root, target): + """Deny once without reviewing; never infer zero effects from model text.""" + _tool_effect_witness(root, target["instanceId"], executed=False) + offset = len(driver.raw_output) + driver.write("approval\r") + driver.read_until(lambda output: approval_pending_visible(output, after=offset), timeout=40) + _tool_effect_witness(root, target["instanceId"], executed=False) + offset = len(driver.raw_output) + denied_sent_ns = time.monotonic_ns() + driver.write("/deny\r") + driver.read_until(lambda output: denied_tool_reply_completed(output, after=offset), timeout=40) + _tool_effect_witness(root, target["instanceId"], executed=False) + return denied_sent_ns + + +def first_reply(root, report, *, admission_diagnostic=False, delayed_final=False, interrupt_next_turn=False, tool_approval=False, tool_denial=False, transport_loss=False, natural_completion=False, history=False): + if history and any((admission_diagnostic, delayed_final, interrupt_next_turn, tool_approval, tool_denial, transport_loss, natural_completion)): + raise ValueError("history requires a fresh independent scenario") + if natural_completion and (not transport_loss or not delayed_final or interrupt_next_turn): + raise ValueError("natural completion requires a distinct delayed transport loss scenario") + if transport_loss and not (delayed_final and (interrupt_next_turn or natural_completion)): + raise ValueError("transport loss requires the running interrupt/next-turn scenario") + if tool_approval and tool_denial: + raise ValueError("tool approval and denial are distinct scenarios") + if (tool_approval or tool_denial) and (admission_diagnostic or delayed_final or interrupt_next_turn): + raise ValueError("tool approval uses a fresh independent Session") + if interrupt_next_turn and not delayed_final: + raise ValueError("interrupt scenario requires the delayed first turn") + if admission_diagnostic and delayed_final: + raise ValueError("delayed-final acceptance must not enable admission tracing") + prefix = Path(report["measured_prefix"]) + environment = _terminal_environment(root) + environment.pop("LOUSHANG_TMPDIR", None) + if tool_approval or tool_denial: + config = root / ".loushang" + config.mkdir(mode=0o700) + with (config / "settings.json").open("x", encoding="utf-8") as stream: + json.dump({"tools": {"ask_tools": ["lmux_evidence"]}}, stream) + (root / "lmux-test-observations").mkdir(mode=0o700) + if natural_completion: + (root / "lmux-test-observations/completion-gates").mkdir(mode=0o700) + if tool_denial: + (root / "lmux-interaction-observations").mkdir(mode=0o700) + nonce = uuid.uuid4().hex + expected = "LMUX_REPLY_" + nonce + argv = [str(prefix / "bin/python"), "-I", str(Path(__file__).with_name("_lmux_product_entry.py")), + *(["--admission-diagnostic"] if admission_diagnostic else []), + *(["--interaction-diagnostic"] if tool_denial else []), + "new", "-s", "perf"] + if admission_diagnostic: + report["diagnostic_instrumentation"] = "admission-proxy-not-for-performance" + primary = None + stopped = False + target = None + native = [] + stop_attempted = False + + def exact_stop(): + nonlocal stop_attempted + if stop_attempted or target is None or len(native) != 1: + raise RuntimeError("exact stop unavailable; original outer owner must settle") + stop_attempted = True + return _stop_generation(root, report, environment, target, native[0]) + + try: + terminal = abrupt_terminal if transport_loss else observed_terminal + with observe_spawn(argv[0]) as spawned, terminal( + argv, root, environment, failure_report=report, + **({} if transport_loss else {"settlements": report.setdefault("terminal_settlements", [])}), + ) as (driver, master, original): + started = spawned["start"] + report.setdefault("spawns", []).append(spawned) + _see(driver, "perf |") + assert_active_terminal(master, original) + driver.write("/new user_home FirstUse\r") + _see(driver, "*1") + target = managed_read_observation(environment, "first-member", native_identity=native) + assert len(target["members"]) == 1 + report["fixed_product_target"] = target + _record_native(report, "first-member", native[0]) + before_send = driver.raw_output + offset, sent = len(before_send), time.perf_counter() + if tool_denial: + denied_sent_ns = _deny_first_tool(driver, root, target) + elif tool_approval: + tool_steps = _approve_first_tool(driver, root, target) + elif not history: + driver.write(("gated " if natural_completion else "delayed " if delayed_final else "reply ") + nonce + "\r") + if delayed_final: + def still_streaming(output): + assert not reply_completed(output, expected, after=offset), "blocked final reported complete" + return reply_streaming(output, expected, after=offset) + driver.read_until(still_streaming, timeout=40) + elif not (tool_approval or tool_denial or history): + driver.read_until(lambda output: reply_completed(output, expected, after=offset), timeout=40) + visible = time.perf_counter() + if transport_loss: + assert driver.is_alive(), "client exited before transport hangup" + exit_status = driver.hangup_transport(timeout=20) + # Destroyed stdout/termios may fail during interpreter shutdown. + # Record that outcome; service continuity must still be proven below. + assert type(exit_status) is int and exit_status >= 0, driver.diagnostics + else: + driver.write("\x02d") + assert driver.wait(timeout=20) == 0, driver.diagnostics + # The original client/driver have settled before any observer takes a + # controller. Only normal detach proves mode restoration; a destroyed + # PTY cannot provide that observation. Never count a later snapshot as a + # replacement timestamp for a missing visible completion frame. + if delayed_final: + assert_no_completed_reply(driver.raw_output, expected, before_send=before_send) + confirmation = (managed_history_observation(environment, target) if history else + managed_reply_observation(environment, target, "Tool lmux_evidence requires approval", tool_denial=True) + if tool_denial else managed_reply_observation(environment, target, "LMUX_TOOL_COMPLETED", tool_approval=True) + if tool_approval else managed_reply_observation(environment, target, expected, pending=True, + **({"natural_nonce": nonce} if natural_completion else {})) + if delayed_final else managed_reply_observation(environment, target, expected)) + later_native = [] + observed = managed_read_observation(environment, "detached", native_identity=later_native) + assert all(observed[key] == target[key] for key in ("instanceId", "serviceId", "muxId", "members")) + assert len(native) == 1 and later_native == native + _record_native(report, "detached", later_native[0]) + report["fixed_product_detached"] = observed + if delayed_final: + _producer_witness(root, target["instanceId"], settled=False) + next_turn = (_interrupt_next_turn(root, report, environment, target, native, nonce) + if interrupt_next_turn else None) + natural_result = (_natural_completion(root, report, environment, target, native, nonce) + if natural_completion else None) + history_result = (_history_attach(root, report, environment, target, native, confirmation["history_identity"]) + if history else None) + result = exact_stop() + stopped = True + if history: + from loushang.appserver.protocol import SessionIdentityV1, SessionScopeV1 + + from ._lmux_history_canonical import read_history + identity = dict(confirmation["history_identity"]) + identity["scope"] = SessionScopeV1(identity["scope"]) + canonical_read = {} + canonical = read_history(Path(environment["LOUSHANG_HOME"]) / "data/sessions", + SessionIdentityV1(**identity), root, receipt=canonical_read) + report["fixed_product_history"] = {"seed": confirmation, "attach": history_result, + "canonical": canonical, "stop": result, + "canonical_read": canonical_read} + return + if tool_denial: + _denial_receipt(root, target, not_before_ns=denied_sent_ns) + _tool_effect_witness(root, target["instanceId"], executed=False) + report["fixed_product_tool_denial"] = { + "confirmation": confirmation, "stop": result, "tool_execution_count": 0, + } + return + if tool_approval: + effects = [] + _tool_effect_witness(root, target["instanceId"], executed=True, + not_before_ns=tool_steps["approved_sent_ns"], evidence=effects) + report["fixed_product_tool_approval"] = { + **tool_steps, "confirmation": confirmation, "stop": result, + "tool_call_id": "lmux-call-1", "tool_execution_count": 1, + "tool_effects": effects, + } + return + if delayed_final: + producer = _producer_witness(root, target["instanceId"], settled=True) + report["fixed_product_natural_completion" if natural_completion else "fixed_product_interrupt_next_turn" if interrupt_next_turn else "fixed_product_delayed_final"] = { + "request_nonce": nonce, + "pending_confirmation": confirmation, "stop": result, + "producer": producer, "producer_settled": True, + "full_text_not_completed": True, + **({"next_turn": next_turn} if interrupt_next_turn else {}), + **({"natural_completion": natural_result} if natural_completion else {}), + } + return + report["fixed_product_first_reply"] = { + "request_nonce": nonce, + "visible_reply_seconds": visible - sent, + "spawn_through_visible_reply_seconds": visible - started, + "actions": { + "visible_reply": {"started_at": sent, "finished_at": visible}, + "fixed_entry_through_visible_reply": {"started_at": started, "finished_at": visible}, + }, + "confirmation": confirmation, "stop": result, + "composition": "managed-infrastructure-with-fixed-test-product", + } + except BaseException as error: + primary = error + raise + finally: + if not stopped: + try: + if stop_attempted: + # Never reopen an already consumed or close-unknown owner. + raise RuntimeError("exact stop incomplete; original outer owner must settle") + exact_stop() + except BaseException as cleanup: + report.setdefault("fixed_product_cleanup_failure", {"type": type(cleanup).__name__[:128]}) + if primary is None: + raise + primary.add_note("first reply fallback cleanup failed: " + type(cleanup).__name__) diff --git a/tests/coding/_lmux_product_snapshot.py b/tests/coding/_lmux_product_snapshot.py new file mode 100644 index 000000000..a6d628eed --- /dev/null +++ b/tests/coding/_lmux_product_snapshot.py @@ -0,0 +1,175 @@ +"""Post-detach confirmation; never used to timestamp the visible reply. + +The caller must have settled its terminal before calling, and must retain and +close its authenticated connection even if attach or detach fails. This helper +borrows that connection; it is not an anonymous/read-only observation API. +""" + +from __future__ import annotations + +import asyncio +import math +import re +import time + +from loushang.appserver.protocol import ( + MuxAttachV1, + MuxDetachV1, + MuxSelectorV1, + SessionSnapshotRequestV1, + TranscriptRecordKindV1, +) + + +async def confirm_reply(client, *, mux_id, member_id, identity, expected, deadline): + return await _confirm(client, mux_id=mux_id, member_id=member_id, identity=identity, + expected=expected, deadline=deadline, pending=False) + + +async def confirm_history(client, *, mux_id, member_id, identity, deadline): + """Read the exact seeded tail under the existing temporary attachment.""" + return await _confirm(client, mux_id=mux_id, member_id=member_id, identity=identity, + expected=None, deadline=deadline, pending=False, history=True) + + +async def confirm_pending_reply(client, *, mux_id, member_id, identity, expected, deadline): + """Confirm the detached delayed-final turn still runs without a commit.""" + return await _confirm(client, mux_id=mux_id, member_id=member_id, identity=identity, + expected=expected, deadline=deadline, pending=True) + + +async def confirm_tool_reply(client, *, mux_id, member_id, identity, expected, deadline): + if expected != "LMUX_TOOL_COMPLETED": + raise ValueError("invalid fixed tool reply witness") + return await _confirm(client, mux_id=mux_id, member_id=member_id, identity=identity, + expected=expected, deadline=deadline, pending=False, tool_approval=True) + + +async def confirm_denied_tool_reply(client, *, mux_id, member_id, identity, expected, deadline): + """Confirm the fixed policy-error echo, not proof of zero execution. + + The caller must separately witness the same handler's zero effects both + before denial and after exact service settlement. A model echo alone + cannot establish that authorization prevented execution. + """ + if expected != "Tool lmux_evidence requires approval": + raise ValueError("invalid fixed denied tool reply witness") + return await _confirm(client, mux_id=mux_id, member_id=member_id, identity=identity, + expected=expected, deadline=deadline, pending=False, tool_approval=True) + + +async def confirm_interrupted_reply(client, *, mux_id, member_id, identity, expected, + interrupted_nonce, deadline): + """Verify B after interrupted A; the empty record itself proves no cause.""" + if re.fullmatch(r"[0-9a-f]{32}", interrupted_nonce) is None: + raise ValueError("invalid interrupted request witness") + if expected == "LMUX_REPLY_" + interrupted_nonce: + raise ValueError("next request must use a different nonce") + return await _confirm(client, mux_id=mux_id, member_id=member_id, identity=identity, + expected=expected, deadline=deadline, pending=False, + interrupted_nonce=interrupted_nonce) + + +async def confirm_natural_reply(client, *, mux_id, member_id, identity, expected, + natural_nonce, pending, deadline): + if re.fullmatch(r"[0-9a-f]{32}", natural_nonce) is None: + raise ValueError("invalid natural completion nonce") + return await _confirm(client, mux_id=mux_id, member_id=member_id, identity=identity, + expected=expected, deadline=deadline, pending=pending, + natural_nonce=natural_nonce) + + +async def _confirm(client, *, mux_id, member_id, identity, expected, deadline, pending, + interrupted_nonce=None, tool_approval=False, natural_nonce=None, history=False): + if not history and not tool_approval and re.fullmatch(r"LMUX_REPLY_[0-9a-f]{32}", expected) is None: + raise ValueError("invalid reply witness") + if not math.isfinite(deadline): + raise ValueError("invalid observation deadline") + + async def bounded(operation): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("observation deadline expired before dispatch") + async with asyncio.timeout(remaining): + result = await operation() + if time.monotonic() >= deadline: + raise TimeoutError("observation response arrived after deadline") + return result + + attachment = await bounded(lambda: client.attach_mux(MuxAttachV1(MuxSelectorV1(mux_space_id=mux_id)))) + primary = None + try: + assert attachment.mux_space.mux_space_id == mux_id + members = attachment.mux_space.members + assert len(members) == 1 + assert members[0].member_id == member_id and members[0].session == identity + assert members[0].title == "FirstUse" + snapshot = await bounded(lambda: client.snapshot_session(SessionSnapshotRequestV1( + attachment.attachment_id, attachment.controller_generation, member_id, + ))) + # Mux display title and Product-owned snapshot title are independent. + # The fresh Coding Product can still project its default "Coding". + assert snapshot.identity == identity + assert snapshot.running is pending + assistants = tuple(record.text for record in snapshot.records + if record.kind is TranscriptRecordKindV1.ASSISTANT) + if history: + from ._lmux_history_recipe import ROUNDS, validate_history_window + validate_history_window([[record.kind.value, record.text] for record in snapshot.records], ROUNDS - 1) + if time.monotonic() >= deadline: + raise TimeoutError("history snapshot validation exceeded deadline") + elif pending: + assert assistants == (), "delayed final must not commit an assistant reply" + users = tuple(record.text for record in snapshot.records + if record.kind is TranscriptRecordKindV1.USER) + if natural_nonce is not None: + assert expected == "LMUX_REPLY_" + natural_nonce + assert users == (("gated " if natural_nonce is not None else "delayed ") + + expected.removeprefix("LMUX_REPLY_"),) + elif natural_nonce is not None: + expected_records = ( + (TranscriptRecordKindV1.USER, "gated " + natural_nonce), + (TranscriptRecordKindV1.ASSISTANT, "LMUX_REPLY_" + natural_nonce), + ) + if expected != "LMUX_REPLY_" + natural_nonce: + expected_records += ( + (TranscriptRecordKindV1.USER, "reply " + expected.removeprefix("LMUX_REPLY_")), + (TranscriptRecordKindV1.ASSISTANT, expected), + ) + assert tuple((record.kind, record.text) for record in snapshot.records) == expected_records + elif tool_approval: + assert tuple((record.kind, record.text) for record in snapshot.records) == ( + (TranscriptRecordKindV1.USER, "approval"), + (TranscriptRecordKindV1.ASSISTANT, ""), + (TranscriptRecordKindV1.ASSISTANT, expected), + ), "fresh tool turn must have one final reply after its tool-call message" + elif interrupted_nonce is not None: + # The fixed Agent abort path retains one empty assistant record. + # Do not ignore arbitrary empty/error records or infer an abort + # reason from a projection that carries no stop_reason field. + records = tuple((record.kind, record.text) for record in snapshot.records) + assert records == ( + (TranscriptRecordKindV1.USER, "delayed " + interrupted_nonce), + (TranscriptRecordKindV1.ASSISTANT, ""), + (TranscriptRecordKindV1.USER, "reply " + expected.removeprefix("LMUX_REPLY_")), + (TranscriptRecordKindV1.ASSISTANT, expected), + ), "same Session must retain A then exactly one successful B reply" + else: + assert tuple((record.kind, record.text) for record in snapshot.records) == ( + (TranscriptRecordKindV1.USER, "reply " + expected.removeprefix("LMUX_REPLY_")), + (TranscriptRecordKindV1.ASSISTANT, expected), + ), "fresh Session must contain exactly the requested user turn and committed reply" + assert not any(record.kind is TranscriptRecordKindV1.ERROR for record in snapshot.records) + return snapshot + except BaseException as error: + primary = error + raise + finally: + try: + await bounded(lambda: client.detach_mux(MuxDetachV1( + attachment.attachment_id, attachment.controller_generation, + ))) + except BaseException as cleanup: + if primary is None: + raise + primary.add_note("post-detach snapshot observer detach failed: " + type(cleanup).__name__) diff --git a/tests/coding/_lmux_startup_diagnostic.py b/tests/coding/_lmux_startup_diagnostic.py new file mode 100644 index 000000000..284851c99 --- /dev/null +++ b/tests/coding/_lmux_startup_diagnostic.py @@ -0,0 +1,78 @@ +"""Failure-edge and post-return owner facts, never performance evidence.""" + +import time +from contextlib import ExitStack, contextmanager +from unittest.mock import patch + + +def _error(value): + if value is None: + return None + name = type(value).__name__ + allowed = {"ManagedChildError", "ManagedStorageError", "AppServiceError", "TimeoutError", + "CancelledError", "ValueError", "TypeError", "OSError", "RuntimeError", + "AssertionError", "KeyboardInterrupt", "FileNotFoundError", "PermissionError"} + code = getattr(value, "code", None) + return {"type": name if name in allowed else "OtherError", + "code": code if type(code) is str and code in { + "startup_failed", "activation_failed", "cleanup_incomplete", "busy", + "unavailable", "closed", "conflict", "invalid_record"} else "other"} + + +def _task(task): + if task is None: + return {"state": "absent"} + if not task.done(): + return {"state": "pending"} + if task.cancelled(): + return {"state": "cancelled"} + return {"state": "done", "error": _error(task.exception())} + + +@contextmanager +def observe_startup(bootstrap_type, emit): + """Borrow original owners; record drive failure and eventual entry return. + + Diagnostic errors cannot alter application results. No task is awaited, + cancelled, retried, or created here; no IO occurs inside bind's mutex. + Failure observation awaits the original drive exactly once, before cleanup. + """ + original = bootstrap_type.bind + retained = [None, None] + patches = ExitStack() + + def snapshot(phase, child, application, failure): + try: + deadline = child._startup_deadline + emit(phase, committed=child._committed, + failure=_error(failure), prepare=_task(child._prepare_task), + activate=_task(child._activate_task), start=_task(application._start_task), + deadline_elapsed=(time.monotonic() >= deadline + if type(deadline) in (int, float) else None)) + except BaseException: + pass # Diagnostics never replace the original outcome. + + def bind(bootstrap, application, *args, **kwargs): + child = original(bootstrap, application, *args, **kwargs) + retained[0], retained[1] = child, application + child_type = type(child) + drive = getattr(child_type, "_drive", None) + if drive is not None: + async def observed_drive(instance): + try: + return await drive(instance) + except BaseException as error: + if instance is child: + snapshot("startup_drive_failed", child, application, error) + raise + + patches.enter_context(patch.object(child_type, "_drive", observed_drive)) + return child + + try: + with patches, patch.object(bootstrap_type, "bind", bind): + yield + finally: + child, application = retained + if child is not None: + snapshot("startup_post_return", child, application, child._failure) diff --git a/tests/coding/_lmux_synthetic_product.py b/tests/coding/_lmux_synthetic_product.py new file mode 100644 index 000000000..321a6ae86 --- /dev/null +++ b/tests/coding/_lmux_synthetic_product.py @@ -0,0 +1,161 @@ +"""Side-effect-free fixed Product components for managed first-use evidence. + +No tracing installation, environment selection, IO or production monkeypatching +happens on import. The dedicated test child supplies a bounded witness sink. +Witnesses are test observations, never authorization or lifecycle authority. +""" + +from __future__ import annotations + +import asyncio +import re +import runpy +from collections.abc import Awaitable, Callable +from contextlib import nullcontext +from dataclasses import replace +from functools import partial +from pathlib import Path + +from loushang.agent import synthetic_model_transport +from loushang.agent.types import AgentToolResult +from loushang.ai.event_stream.stream import AssistantMessageEventStream +from loushang.ai.model import Capabilities, Model +from loushang.ai.types import AssistantMessage, TextPart, ToolCall, Usage +from loushang.harness.tools.core import ToolDefinition +from loushang.harness.tools.execution import ( + AuthorizedExecution, + CallableToolActionAdapter, + PreparedToolAction, +) + + +def components(witness: Callable[[str, str], None], *, completion_gate: Callable[[str], Awaitable[None]] | None = None): + """Select only synthetic model/tool behavior, retaining real authorization.""" + calls = 0 + history_turn = None + + async def execute(action, context): + witness("tool_executed", context.tool_call_id) + return AgentToolResult( + content=[TextPart(type="text", text="LMUX_TOOL_COMPLETED")], details={}, + ) + + tool = ToolDefinition( + name="lmux_evidence", label="Managed evidence", description="In-memory test effect", + parameters={"type": "object", "properties": {}, "additionalProperties": False}, + execution=AuthorizedExecution( + action_adapter=CallableToolActionAdapter( + lambda call, context: PreparedToolAction( + tool_name=call.name, authorization_arguments=call.arguments, + execution_arguments=call.arguments, cwd=context.cwd, + ) + ), + handler=execute, + ), + ) + model = Model( + id="lmux-fixed", name="LMUX fixed", provider="faux", endpoint="anthropic-messages", + capabilities=Capabilities(input=("text",), context_window=128000, max_tokens=4096), + ) + + @synthetic_model_transport + async def stream(model, context, options=None): + nonlocal calls, history_turn + calls += 1 + identity = f"lmux-call-{calls}" + latest = context.messages[-1] + content = latest.content + text = content if isinstance(content, str) else "".join( + part.text for part in content if isinstance(part, TextPart) + ) + request = text if latest.role == "user" else "" + use_tool = request == "approval" + reply = text if latest.role == "toolResult" else "LMUX_REPLY_COMPLETED" + unique = re.fullmatch(r"(reply|delayed|gated) ([0-9a-f]{32})", request) + gated = unique is not None and unique.group(1) == "gated" + if gated and completion_gate is None: + raise ValueError("gated test request requires an explicit completion gate") + if unique is not None: + reply = "LMUX_REPLY_" + unique.group(2) + history = re.fullmatch(r"history ([0-9]{4})", request) + if history is not None: + if history_turn is None: + # The installed child loads this fixed sibling with run_path, + # so it has no package context for a relative import. + history_turn = runpy.run_path( + str(Path(__file__).with_name("_lmux_history_recipe.py")) + )["history_turn"] + _, reply = history_turn(int(history.group(1))) + parked = request == "hold" or (unique is not None and unique.group(1) in {"delayed", "gated"}) + if request == "hold": + reply = "LMUX_WAITING" + message = AssistantMessage( + endpoint="anthropic-messages", role="assistant", + content=[ToolCall(type="toolCall", id=identity, name=tool.name, arguments={})] + if use_tool else [TextPart(type="text", text=reply)], + api="anthropic-messages", provider="faux", model="lmux-fixed", response_id=None, + usage=Usage(input=0, output=0, cache_read=0, cache_write=0, total_tokens=0, cost={}), + stop_reason="toolUse" if use_tool else "stop", error_message=None, timestamp=0.0, + ) + result = AssistantMessageEventStream() + result.push({"type": "start", "partial": message}) + if not use_tool: + result.push({"type": "text_delta", "content_index": 0, "delta": reply, "partial": message}) + if parked: + async def producer(): + try: + witness("producer_started", identity) + if gated: + assert completion_gate is not None and unique is not None + await completion_gate(unique.group(2)) + witness("gate_released", identity) + result.push({"type": "done", "reason": message.stop_reason, "message": message}) + witness("final_emitted", identity) + else: + await asyncio.Event().wait() + finally: + witness("producer_settled", identity) + + # A foreign task factory may schedule and then raise, losing the + # handle before stream adoption. Keep this test producer owned. + task = asyncio.Task(producer(), loop=asyncio.get_running_loop()) + result.attach_task(task) + else: + result.push({"type": "done", "reason": message.stop_reason, "message": message}) + return result + + return model, stream, [tool] + + +def run_product(argv: list[str], witness: Callable[[str, str], None], *, admission_diagnostic=None, completion_gate=None) -> int: + """Dedicated-test-process composition; production main owns all lifecycle. + + Not a public launcher: the future fixed child must verify installed origins + and supply its own bounded witness sink. Never call concurrently in-process. + """ + from loushang.coding import managed_local, managed_process + + original = managed_local.CodingManagedLocalCommandV1 + model, stream, tools = components(witness, completion_gate=completion_gate) + constructor = original + startup_observation = nullcontext() + if admission_diagnostic is not None: + diagnostic = runpy.run_path(str(Path(__file__).with_name("_lmux_admission_diagnostic.py"))) + trace = diagnostic["AdmissionTrace"](admission_diagnostic) + startup = runpy.run_path(str(Path(__file__).with_name("_lmux_startup_diagnostic.py"))) + startup_observation = startup["observe_startup"](managed_process.ManagedChildBootstrapV1, admission_diagnostic) + + def constructor(launch, **kwargs): + if launch.managed_mux is None: + raise ValueError("diagnostic requires original managed binding") + observed = diagnostic["observe_binding"](launch.managed_mux, trace) + return original(replace(launch, managed_mux=observed), **kwargs) + + try: + managed_local.CodingManagedLocalCommandV1 = partial( + constructor, model=model, stream_fn=stream, tools=tools, + ) + with startup_observation: + return managed_process.main(argv) + finally: + managed_local.CodingManagedLocalCommandV1 = original diff --git a/tests/coding/test_lmux_admission_diagnostic.py b/tests/coding/test_lmux_admission_diagnostic.py new file mode 100644 index 000000000..08920567f --- /dev/null +++ b/tests/coding/test_lmux_admission_diagnostic.py @@ -0,0 +1,82 @@ +import asyncio + +import pytest + +from loushang.appservice.managed_mux import ManagedMuxServiceBindingV1 + +from ._lmux_admission_diagnostic import AdmissionTrace, observe_binding + + +@pytest.mark.parametrize("fault", [None, "prepare", "acquire", "check", "close", "cancel"]) +@pytest.mark.parametrize("broken_sink", [False, True]) +def test_diagnostic_never_loses_owner_or_replaces_original_failure(fault, broken_sink): + events, records = [], [] + failure = asyncio.CancelledError() if fault == "cancel" else ValueError("private request details") + request, previous = object(), object() + + class Owner: + async def acquire(self): + events.append("acquire") + if fault in {"acquire", "cancel"}: + raise failure + + def check_creation(self, value): + assert value is previous + events.append("check") + if fault == "check": + raise failure + + async def close(self): + events.append("close") + if fault == "close": + raise failure + + owner = Owner() + + def prepare(value): + assert value is request + events.append("prepare") + if fault == "prepare": + raise failure + return owner + + def emit(phase, **fields): + if broken_sink: + raise OSError("trace disk failed") + records.append((phase, fields)) + + trace = AdmissionTrace(emit) + binding = ManagedMuxServiceBindingV1("coding.default", "a" * 64, "b" * 32, prepare) + observed = observe_binding(binding, trace) + assert (observed.application_id, observed.service_id, observed.instance_id, observed.closing) == ( + binding.application_id, binding.service_id, binding.instance_id, binding.closing, + ) + if fault == "prepare": + with pytest.raises(ValueError) as caught: + observed.prepare(request) + assert caught.value is failure and events == ["prepare"] + else: + proxy = observed.prepare(request) + assert proxy.owner is owner + + async def run(): + try: + await proxy.acquire() + proxy.check_creation(previous) + except BaseException as error: + assert fault in {"acquire", "check", "cancel"} and error is failure + finally: + # Caller retains the original debt semantics: every close is + # delegated, even after an earlier close failure or success. + for _ in range(2): + try: + await proxy.close() + except BaseException as error: + assert fault == "close" and error is failure + + asyncio.run(run()) + assert events.count("prepare") == 1 and events.count("close") == 2 + assert trace.disabled is broken_sink + assert "private request details" not in repr(records) + if not broken_sink and fault is not None: + assert any(phase.endswith("_fail") for phase, fields in records) diff --git a/tests/coding/test_lmux_adopted_process.py b/tests/coding/test_lmux_adopted_process.py new file mode 100644 index 000000000..ba2f981ea --- /dev/null +++ b/tests/coding/test_lmux_adopted_process.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import subprocess +import sys +import time + +import pytest + +from loushang.hosting.service import LinuxServiceObserverV1 + +from ._lmux_adopted_process import AdoptedLeader, stop_command + +pytestmark = pytest.mark.skipif( + sys.platform != "linux", reason="Linux pidfd wait", +) + + +def cleanup(owner, leader, other): + failures = [] + + def attempt(operation): + try: + operation() + except BaseException as error: + failures.append(error) + + if owner is not None: + attempt(owner.close) + for process in (leader, other): + def stop(process=process): + if process.poll() is None: + process.kill() + attempt(stop) + attempt(lambda process=process: process.wait(timeout=10)) + if leader.stdin is not None and not leader.stdin.closed: + attempt(leader.stdin.close) + if failures: + primary = sys.exception() + if primary is None: + raise failures[0] + primary.add_note("test resource cleanup also failed: " + type(failures[0]).__name__) + + +@pytest.mark.parametrize("exit_code", [0, 7]) +def test_exact_leader_wait_does_not_consume_other_popen_status(exit_code): + leader = subprocess.Popen( + [sys.executable, "-I", "-c", f"import sys; sys.stdin.buffer.read(1); sys.exit({exit_code})"], + stdin=subprocess.PIPE, start_new_session=True, + ) + other = subprocess.Popen([sys.executable, "-I", "-c", "raise SystemExit(23)"]) + owner = None + try: + captured = LinuxServiceObserverV1.capture(leader.pid) + try: + owner = AdoptedLeader(captured.identity) + finally: + captured.close() + assert not owner.poll() + leader.stdin.write(b"x") + leader.stdin.close() + if exit_code == 0: + # Like the real stopper, this process cannot finish while an + # unreaped leader still keeps its original process group present. + command = ( + "import os,time\nwhile True:\n" + f" try: os.killpg({leader.pid}, 0)\n" + " except ProcessLookupError: break\n time.sleep(0.01)\n" + ) + result = stop_command([sys.executable, "-I", "-c", command], + cwd=None, env=None, leader=owner, timeout=10) + assert result.returncode == 0 + leader.returncode = 0 + deadline = time.monotonic() + 10 + while True: + try: + complete = owner.poll() + except AssertionError: + assert exit_code == 7 + leader.returncode = exit_code # Exact wait above consumed our child's status. + with pytest.raises(AssertionError): + owner.poll() # A failed wait cannot become success on retry. + break + if complete: + assert exit_code == 0 + leader.returncode = exit_code + assert owner.poll() + break + assert time.monotonic() < deadline + time.sleep(0.01) + assert other.wait(timeout=10) == 23 + finally: + cleanup(owner, leader, other) + + +def test_close_failure_is_sticky_and_does_not_replay_native_close(): + calls = [] + + class Observer: + def close(self): + calls.append("native close") + raise OSError("lost close receipt") + + owner = object.__new__(AdoptedLeader) + owner._observer = Observer() + owner._closed = owner._close_unknown = False + with pytest.raises(OSError): + owner.close() + with pytest.raises(RuntimeError, match="unknown"): + owner.close() + with pytest.raises(RuntimeError, match="unknown"): + owner.poll() + assert calls == ["native close"] + + +def test_cleanup_attempts_children_even_when_owner_close_fails(): + from types import SimpleNamespace + + events = [] + failure = OSError("close") + + def close(): + raise failure + + def process(name): + return SimpleNamespace( + poll=lambda: None, kill=lambda: events.append((name, "kill")), + wait=lambda **_: events.append((name, "wait")), stdin=None, + ) + + with pytest.raises(OSError) as caught: + cleanup(SimpleNamespace(close=close), process("leader"), process("other")) + assert caught.value is failure + assert events == [("leader", "kill"), ("leader", "wait"), ("other", "kill"), ("other", "wait")] + + +def test_stop_cleanup_failure_is_not_hidden_by_callers_exception(monkeypatch): + from types import SimpleNamespace + + from . import _lmux_adopted_process as module + + failure = OSError("pipe close failed") + + def close(): + raise failure + + process = SimpleNamespace( + returncode=0, communicate=lambda **_: ("", ""), poll=lambda: 0, + stdout=SimpleNamespace(close=close), stderr=None, + ) + monkeypatch.setattr(module.subprocess, "Popen", lambda *a, **k: process) + try: + raise LookupError("caller exception, unrelated to stop") + except LookupError: + with pytest.raises(OSError) as caught: + stop_command(["fixed-test-stop"], cwd=None, env=None, + leader=SimpleNamespace(poll=lambda: True)) + assert caught.value is failure diff --git a/tests/coding/test_lmux_completion_gate.py b/tests/coding/test_lmux_completion_gate.py new file mode 100644 index 000000000..bde3e5afd --- /dev/null +++ b/tests/coding/test_lmux_completion_gate.py @@ -0,0 +1,65 @@ +import asyncio +import os + +import pytest + +from ._lmux_completion_gate import CompletionGate, release + +INSTANCE = "a" * 32 +NONCE = "b" * 32 + + +def test_gate_releases_only_exact_instance_nonce_and_only_once(tmp_path): + tmp_path.chmod(0o700) + async def check(): + gate = CompletionGate(tmp_path, INSTANCE) + waiter = asyncio.create_task(gate(NONCE)) + await asyncio.sleep(0) + release(tmp_path, "c" * 32, NONCE) + release(tmp_path, INSTANCE, "d" * 32) + await asyncio.sleep(0.03) + assert not waiter.done() + release(tmp_path, INSTANCE, NONCE) + await asyncio.wait_for(waiter, 1) + with pytest.raises(ValueError, match="already consumed"): + await gate(NONCE) + with pytest.raises(FileExistsError): + release(tmp_path, INSTANCE, NONCE) + asyncio.run(check()) + + +@pytest.mark.parametrize("cancel", [False, True]) +def test_unreleased_gate_never_finishes_successfully(tmp_path, cancel): + tmp_path.chmod(0o700) + async def check(): + gate = CompletionGate(tmp_path, INSTANCE, timeout=0.03) + task = asyncio.create_task(gate(NONCE)) + await asyncio.sleep(0) + if cancel: + task.cancel() + with pytest.raises(asyncio.CancelledError if cancel else TimeoutError): + await task + assert not list(tmp_path.iterdir()) + asyncio.run(check()) + + +def test_partial_release_does_not_complete_gate(tmp_path): + tmp_path.chmod(0o700) + async def check(): + gate = CompletionGate(tmp_path, INSTANCE, timeout=1) + task = asyncio.create_task(gate(NONCE)) + path = tmp_path / f"release-{INSTANCE}-{NONCE}" + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + os.write(fd, INSTANCE.encode()) + await asyncio.sleep(0.03) + assert not task.done() + os.write(fd, (":" + NONCE).encode()) + await asyncio.wait_for(task, 1) + finally: + os.close(fd) + if not task.done(): + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + asyncio.run(check()) diff --git a/tests/coding/test_lmux_completion_witness.py b/tests/coding/test_lmux_completion_witness.py new file mode 100644 index 000000000..956b5456b --- /dev/null +++ b/tests/coding/test_lmux_completion_witness.py @@ -0,0 +1,23 @@ +from ._g18_native_probe import managed_completion_frame + + +def frame(*lines, completed=True): + output = "\x1b[?2026h\x1b[2J\x1b[H" + for index, line in enumerate(lines, 1): + output += f"\x1b[{index};1H{line}" + return output + ("\x1b[?2026l" if completed else "") + + +def test_managed_completion_requires_new_complete_frame_and_suggestion_row(): + valid = frame("> /he", " /help", "dev | /help /detach") + assert managed_completion_frame(valid, after=0) + assert not managed_completion_frame(valid, after=len(valid)) + assert not managed_completion_frame(frame("> /he", " /help", completed=False), after=0) + assert not managed_completion_frame(frame("> /he", "dev | /help /detach"), after=0) + assert not managed_completion_frame(frame("> /help", " /help"), after=0) + + +def test_managed_completion_does_not_match_old_frame_cleared_from_viewport(): + old = frame("> /he", " /help") + current = old + frame("> /he", "dev | /help /detach") + assert not managed_completion_frame(current, after=len(old)) diff --git a/tests/coding/test_lmux_first_use_composition.py b/tests/coding/test_lmux_first_use_composition.py new file mode 100644 index 000000000..58095671e --- /dev/null +++ b/tests/coding/test_lmux_first_use_composition.py @@ -0,0 +1,65 @@ +"""Fresh scenario sequencing only; not a substitute for installed evidence.""" + +import stat +import sys + +import pytest + +from . import _lmux_product_probe as probe + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed measurement composition") + + +def test_fresh_scenarios_project_distinct_platform_and_runtime_roots(tmp_path, monkeypatch): + monkeypatch.setenv("LOUSHANG_HOME", str(tmp_path / "ambient-platform")) + monkeypatch.setenv("LOUSHANG_RUNTIME_DIR", str(tmp_path / "ambient-runtime")) + environments = [probe._terminal_environment(tmp_path / name) + for name in ("reply", "approval", "interrupt")] + for name, environment in zip(("reply", "approval", "interrupt"), environments, strict=True): + assert environment["LOUSHANG_HOME"] == str(tmp_path / name / "platform") + assert environment["LOUSHANG_RUNTIME_DIR"] == str(tmp_path / name / "runtime") + assert len({item["LOUSHANG_HOME"] for item in environments}) == 3 + assert len({item["LOUSHANG_RUNTIME_DIR"] for item in environments}) == 3 + assert list(tmp_path.iterdir()) == [] # Projection alone must not create state. + + +@pytest.mark.parametrize("failure", [None, "reply", "approval", "interrupt"]) +def test_first_use_runs_fresh_scenarios_in_order_and_stops_after_failure(tmp_path, monkeypatch, failure): + calls = [] + + def scenario(root, report, **options): + name = root.name + assert stat.S_IMODE(root.stat().st_mode) == 0o700 + assert report["valid"] is False and report["status"] == "running" + assert report["measured_prefix"] == "fixed-install" + assert options == ({"tool_approval": True} if name == "approval" else + {"delayed_final": True, "interrupt_next_turn": True} if name == "interrupt" else {}) + calls.append(name) + if name == failure: + raise RuntimeError("original owner cleanup failed") + timing = {"actions": {"fixed_entry_through_visible_reply" if name == "reply" else "next_reply": + {"started_at": 1.0, "finished_at": 3.0}}, + "spawn_through_visible_reply_seconds" if name == "reply" else "next_reply_seconds": 2.0} + key = {"reply": "fixed_product_first_reply", "approval": "fixed_product_tool_approval", + "interrupt": "fixed_product_interrupt_next_turn"}[name] + report[key] = {"next_turn": timing} if name == "interrupt" else timing + + monkeypatch.setattr(probe, "first_reply", scenario) + report = {"measured_prefix": "fixed-install", "valid": False} + if failure: + with pytest.raises(RuntimeError): + probe.first_use(tmp_path, report) + assert calls == ["reply", "approval", "interrupt"][:["reply", "approval", "interrupt"].index(failure) + 1] + assert report["fixed_product_scenarios"][failure]["status"] == "failed" + else: + probe.first_use(tmp_path, report) + assert calls == ["reply", "approval", "interrupt"] + assert report["fixed_product_scenarios"]["reply"]["milestones"] == { + "fixed_entry_through_visible_reply_seconds": 2.0, + } + previous = 0.0 + for child in report["fixed_product_scenarios"].values(): + assert previous <= child["started_at"] <= child["finished_at"] + previous = child["finished_at"] + assert report["valid"] is False + assert all(child["valid"] is False for child in report["fixed_product_scenarios"].values()) diff --git a/tests/coding/test_lmux_history_attach.py b/tests/coding/test_lmux_history_attach.py new file mode 100644 index 000000000..2614e74b9 --- /dev/null +++ b/tests/coding/test_lmux_history_attach.py @@ -0,0 +1,79 @@ +"""Warm history attach orchestration, not installed performance evidence.""" + +from contextlib import contextmanager + +import pytest + +from . import _lmux_product_probe as probe + + +@pytest.mark.parametrize("fault", [None, "history", "completion", "exit", "close", "read", "native", "member", "workspace"]) +def test_history_attach_settles_terminal_before_authenticated_read(tmp_path, monkeypatch, fault): + from loushang.hosting.service import LinuxServiceIdentityV1 + + native = LinuxServiceIdentityV1(123, 100, "12345678-1234-1234-1234-123456789abc", 1000, 4, 5) + target = {"instanceId": "instance", "serviceId": "service", "muxId": "mux", "members": []} + events, reads, writes = [], [], [] + + class Driver: + raw_output = "" + diagnostics = "unexpected terminal exit" + + def read_until(self, predicate, **kwargs): + phase = ("history", "completion")[len(reads)] + reads.append(phase) + if fault == phase: + raise TimeoutError(phase) + + def write(self, text): + writes.append(text) + + def wait(self, **kwargs): + return 1 if fault == "exit" else 0 + + @contextmanager + def terminal(argv, cwd, environment, **kwargs): + assert argv == [str(tmp_path / "install/bin/lmux"), "attach", "-t", "perf"] + assert cwd == tmp_path / "elsewhere" + if fault == "workspace": + (cwd / "unexpected").mkdir() + try: + yield Driver(), None, None + finally: + events.append("closed") + if fault == "close": + raise OSError("terminal cleanup failed") + + @contextmanager + def spawn(executable): + yield {"start": 1.0} + + def observe(environment, expected, identity, *, native_identity): + assert events == ["closed"] and expected == target and identity == {"session_id": "seed"} + events.append("read") + if fault == "read": + raise OSError("authenticated read failed") + native_identity.append(object() if fault == "native" else native) + return {**target, "members": ["different"]} if fault == "member" else target + + monkeypatch.setattr(probe, "observed_terminal", terminal) + monkeypatch.setattr(probe, "observe_spawn", spawn) + monkeypatch.setattr(probe, "assert_active_terminal", lambda *args: None) + monkeypatch.setattr(probe, "managed_history_confirmation", observe) + report = {"measured_prefix": str(tmp_path / "install")} + if fault is None: + clock = iter([4.0, 5.0, 7.0]) + monkeypatch.setattr(probe.time, "perf_counter", lambda: next(clock)) + result = probe._history_attach(tmp_path, report, {}, target, [native], {"session_id": "seed"}) + assert result["history_frame_seconds"] == 3.0 + assert result["history_completion_seconds"] == 2.0 + assert result["detached"] == target + assert list(report["fixed_product_native"]) == ["history-detached"] + assert writes == ["/he", "\x7f\x7f\x7f", "\x02d"] + else: + with pytest.raises((TimeoutError, OSError, AssertionError)): + probe._history_attach(tmp_path, report, {}, target, [native], {"session_id": "seed"}) + assert "fixed_product_native" not in report + assert events.count("closed") == 1 + assert events.count("read") == (0 if fault in {"history", "completion", "exit", "close"} else 1) + assert writes.count("/he") <= 1 and writes.count("\x02d") <= 1 diff --git a/tests/coding/test_lmux_history_attachment.py b/tests/coding/test_lmux_history_attachment.py new file mode 100644 index 000000000..bf6a90ff0 --- /dev/null +++ b/tests/coding/test_lmux_history_attachment.py @@ -0,0 +1,67 @@ +"""Temporary attachment ownership; connection remains with the outer owner.""" + +import asyncio +import time +from types import SimpleNamespace + +import pytest + +from loushang.appserver.protocol import AckV1 + +from . import _lmux_history_seed as seed + + +@pytest.mark.parametrize("fault", [None, "attach-lost", "wrong-member", "seed-error", "seed-cancel", + "detach-lost", "detach-bad-ack", "both"]) +def test_attachment_failure_never_returns_seed_success(monkeypatch, fault): + events, identity = [], object() + primary = asyncio.CancelledError() if fault == "seed-cancel" else RuntimeError("seed failed") + deadline = time.monotonic() + 660 + + async def attach(request): + events.append("attach") + assert request.selector.mux_space_id == "mux" + if fault == "attach-lost": + raise TimeoutError("accepted attachment reply lost") + return SimpleNamespace(attachment_id="attachment", controller_generation=2, + mux_space=SimpleNamespace(mux_space_id="mux", members=[SimpleNamespace( + member_id="other" if fault == "wrong-member" else "member", session=identity)])) + + async def populate(client, **kwargs): + events.append("seed") + assert kwargs == dict(attachment_id="attachment", generation=2, member_id="member", + identity=identity, deadline=deadline - 30) + if fault in {"seed-error", "seed-cancel", "both"}: + raise primary + return {"rounds": ["synthetic-evidence"]} + + async def detach(request): + events.append("detach") + assert request.attachment_id == "attachment" and request.controller_generation == 2 + if fault in {"detach-lost", "both"}: + raise TimeoutError("detach reply lost") + return object() if fault == "detach-bad-ack" else AckV1() + + monkeypatch.setattr(seed, "seed_history", populate) + client = SimpleNamespace(attach_mux=attach, detach_mux=detach) + async def run(): + return await seed.seed_attached_history(client, mux_id="mux", member_id="member", identity=identity, deadline=deadline) + if fault: + expected = asyncio.CancelledError if fault == "seed-cancel" else RuntimeError if fault in {"seed-error", "both"} else ValueError if fault in {"wrong-member", "detach-bad-ack"} else TimeoutError + with pytest.raises(expected) as caught: + asyncio.run(run()) + if fault in {"seed-error", "seed-cancel", "both"}: + assert caught.value is primary + if fault == "both": + assert primary.__notes__ == ["history detach failed: TimeoutError"] + else: + result = asyncio.run(run()) + assert result["rounds"] == ["synthetic-evidence"] + assert result["detached_at"] < deadline + receipt = result["attachment"] + assert receipt["deadline"] == deadline + assert receipt["attach_started_at"] <= receipt["attached_at"] <= receipt["detach_started_at"] <= result["detached_at"] + assert receipt["attach_deadline"] == min(deadline, receipt["attach_started_at"] + 30) + assert receipt["detach_deadline"] == min(deadline, receipt["detach_started_at"] + 30) + assert events == (["attach"] if fault == "attach-lost" else + ["attach", "detach"] if fault == "wrong-member" else ["attach", "seed", "detach"]) diff --git a/tests/coding/test_lmux_history_canonical.py b/tests/coding/test_lmux_history_canonical.py new file mode 100644 index 000000000..6691c4c2d --- /dev/null +++ b/tests/coding/test_lmux_history_canonical.py @@ -0,0 +1,94 @@ +"""Real typed file/replay checks; fixtures are not the public seed workflow.""" + +import asyncio +from dataclasses import replace +from types import SimpleNamespace + +import pytest + +from loushang.ai.types import UserMessage +from loushang.appserver.protocol import SessionIdentityV1, SessionScopeV1 +from loushang.harness.conversation import ConversationHeader +from loushang.harness.transcript import ( + AGENT_MESSAGE_KIND, + AgentTranscriptRecord, + write_agent_transcript_export, +) + +from . import _lmux_history_canonical as reader +from ._lmux_history_recipe import history_turn +from ._lmux_synthetic_product import components + + +@pytest.mark.parametrize("fault", [None, "wrong-session", "wrong-scope", "wrong-cwd", "early-edit", + "broken-parent", "old-parent", "duplicate-id"]) +def test_canonical_read_uses_typed_codec_and_never_changes_files(tmp_path, monkeypatch, fault): + identity = SessionIdentityV1("coding", "continuity", "session", SessionScopeV1.USER_HOME, "a" * 64) + metadata = {"cwd": str(tmp_path), "coding.hosted": { + "version": 1, "compatibilityId": reader.CODING_HOSTED_COMPATIBILITY_ID, + "continuityId": identity.continuity_id, "sessionId": identity.session_id, + "scope": identity.scope.value, "scopeFingerprint": identity.scope_fingerprint, + "operationId": "create-history", + }} + header = ConversationHeader(identity.session_id, 1, "2026-09-15T00:00:00Z", metadata=metadata) + + async def records(): + result = [] + model, stream, _ = components(lambda *_: None) + for index in range(128): + text, _ = history_turn(index) + user = UserMessage(role="user", content=text, timestamp=0.0) + response = await stream(model, SimpleNamespace(messages=[user])) + try: + assistant = await response.result() + finally: + await response.aclose() + if index == 0 and fault == "early-edit": + user = replace(user, content="corrupted early history") + for message in (user, assistant): + result.append(AgentTranscriptRecord( + record_id=f"r{len(result)}", parent_id=f"r{len(result)-1}" if result else None, + kind=AGENT_MESSAGE_KIND, payload_version=1, created_at="2026-09-15T00:00:00Z", payload=message, + )) + if fault == "broken-parent": + result[20] = replace(result[20], parent_id=None) + elif fault == "old-parent": + result[20] = replace(result[20], parent_id="r0") + elif fault == "duplicate-id": + result[20] = replace(result[20], record_id="r0") + return result + + path = tmp_path / "hosted-session.jsonl" + write_agent_transcript_export(path, header, asyncio.run(records())) + + def snapshot(): + return {p.name: (p.read_bytes(), p.stat().st_mtime_ns, p.stat().st_ino, p.stat().st_mode) + for p in tmp_path.iterdir() if p.is_file()} + + before = snapshot() + original_read, calls = reader.load_agent_transcript_file, [] + def read(selected_path, **kwargs): + assert selected_path == path + assert kwargs == {"read_only": True, "max_bytes": 2_097_152} + calls.append(selected_path) + return original_read(selected_path, **kwargs) + monkeypatch.setattr(reader, "load_agent_transcript_file", read) + selected = replace(identity, continuity_id="other") if fault == "wrong-session" else identity + if fault == "wrong-scope": + selected = replace(identity, scope_fingerprint="b" * 64) + workspace = tmp_path / "other" if fault == "wrong-cwd" else tmp_path + evidence = {} + if fault: + with pytest.raises(ValueError): + reader.read_history(tmp_path, selected, workspace, receipt=evidence) + assert evidence == {} + else: + receipt = reader.read_history(tmp_path, selected, workspace, receipt=evidence) + assert receipt["sha256"] == "00e1c01bb0a4603b94f5fbd70ea802f24a9389471893e5883310ba7c0c0fbb41" + assert receipt["text_bytes"] == 263680 + assert evidence["canonical"] == receipt + assert evidence["root"] == str(tmp_path) and evidence["path"] == str(path) + assert evidence["identity"]["session_id"] == identity.session_id + assert evidence["started_at"] <= evidence["completed_at"] + assert snapshot() == before + assert calls == [path] diff --git a/tests/coding/test_lmux_history_frames.py b/tests/coding/test_lmux_history_frames.py new file mode 100644 index 000000000..eeb639c3a --- /dev/null +++ b/tests/coding/test_lmux_history_frames.py @@ -0,0 +1,78 @@ +"""Shared renderer and current viewport checks for the fixed history ending.""" + +import asyncio + +import pytest + +from loushang.appserver.protocol import ( + MuxSelectorV1, + SessionScopeV1, + TranscriptRecordKindV1, + TranscriptRecordV1, +) +from loushang.harnesstui.conversation.theme import terminal_transcript_theme +from loushang.harnesstui.mux.shell import HostedMuxShellV1 +from loushang.tui import RenderConstraints +from loushang.tui.cell_width import strip_control_sequences +from tests.harnesstui.test_hosted_mux_profile import FINGERPRINT, _Client + +from ._lmux_history_frames import history_content_visible +from ._lmux_history_recipe import history_records + + +def test_long_history_ending_is_visible_with_real_shared_renderer(): + async def run(): + shell = HostedMuxShellV1(_Client(), selector=MuxSelectorV1(name="dev"), product_id="coding", + scopes=((SessionScopeV1.CWD, FINGERPRINT),), transcript_theme=terminal_transcript_theme()) + await shell.start() + try: + window = shell.state.active_window + window.records = [TranscriptRecordV1(TranscriptRecordKindV1(kind), text) + for kind, text in history_records()[-14:]] + window.last_cursor += 1 + constraints = RenderConstraints(width=100, max_height=30) + frame = shell.screen.render(constraints) + frame.validate(constraints) + lines = tuple(strip_control_sequences(line.text) for line in frame.lines) + normalized = tuple(line.strip() for line in lines) + start = normalized.index("History 0127") + assert normalized[start:start + 9] == ( + "History 0127", "", "- completed round 0127", "", "```text", + "code-0127", "```", "", "LMUX_HISTORY_0127_END", + ) + assert history_content_visible(lines), "\n".join(lines) + finally: + await shell.close() + asyncio.run(run()) + + +@pytest.mark.parametrize("fault", [None, "raw-markdown", "missing-header", "reordered", "tail-only", "duplicate", + "bare-list", "wrong-bullet", "missing-fence", "wrong-language", "inline-code", + "prose-header", "prose-list", "prose-code", "prose-tail"]) +def test_marker_alone_or_wrong_markdown_cannot_pass(fault): + lines = ["History 0127", "", "- completed round 0127", "", "```text", + "code-0127", "```", "", "LMUX_HISTORY_0127_END"] + if fault == "raw-markdown": + lines[0] = "## History 0127" + elif fault == "missing-header": + lines.pop(0) + elif fault == "reordered": + lines[0], lines[1] = lines[1], lines[0] + elif fault == "tail-only": + lines = lines[-1:] + elif fault == "duplicate": + lines.append(lines[-1]) + elif fault == "bare-list": + lines[2] = "completed round 0127" + elif fault == "wrong-bullet": + lines[2] = "• completed round 0127" + elif fault == "missing-fence": + lines.pop(6) + elif fault == "wrong-language": + lines[4] = "```python" + elif fault == "inline-code": + lines[4:7] = ["code-0127"] + elif fault is not None and fault.startswith("prose-"): + index = {"prose-header": 0, "prose-list": 2, "prose-code": 5, "prose-tail": 8}[fault] + lines[index] = "unrelated " + lines[index] + " prose" + assert history_content_visible(lines) is (fault is None) diff --git a/tests/coding/test_lmux_history_mailbox.py b/tests/coding/test_lmux_history_mailbox.py new file mode 100644 index 000000000..3aa476499 --- /dev/null +++ b/tests/coding/test_lmux_history_mailbox.py @@ -0,0 +1,55 @@ +"""Long public seed must consume the real bounded attachment mailbox.""" + +import asyncio +import time +from types import SimpleNamespace as NS + +from loushang.appserver.protocol import AckV1, SessionEventKindV1, SessionEventV1 +from loushang.appservice.runtime import _Attachment + +from ._lmux_history_recipe import OMITTED, history_records +from ._lmux_history_seed import seed_history + + +def test_seed_consumes_events_without_replacing_or_enlarging_attachment(): + identity = NS(session_id="session") + mailbox = _Attachment(attachment_id="attachment", mux_space_id="mux", + controller_generation=1, mailbox_capacity=256) + mailbox.bind_member(identity, "member") + sent, consumed = [], [] + full = history_records() + + class Client: + async def start_turn(self, request): + sent.append(request.text) + for offset, kind in enumerate((SessionEventKindV1.TURN_STARTED, SessionEventKindV1.USER_MESSAGE, + SessionEventKindV1.ASSISTANT_DELTA, SessionEventKindV1.ASSISTANT_MESSAGE, + SessionEventKindV1.TURN_COMPLETED), start=1): + mailbox.push(SessionEventV1("session", (len(sent) - 1) * 5 + offset, kind)) + return AckV1() + + async def snapshot_session(self, request): + assert mailbox.active, f"mailbox lagged after {len(sent)} turns" + count = len(sent) + rows = full[max(0, count * 2 - 14):count * 2] + if count > 7: + rows = [["status", OMITTED], *rows] + return NS(identity=identity, running=False, + records=[NS(kind=NS(value=kind), text=text) for kind, text in rows]) + + async def read_events(self, **kwargs): + assert kwargs == {"attachment_id": "attachment", "controller_generation": 1, "limit": 64} + # The real wire dispatcher caps each response at one event even + # when the client requests 64 (bounded maximum frame size). + events = mailbox.read(limit=1) + consumed.extend(events) + return events + + async def run(): + return await seed_history(Client(), attachment_id="attachment", generation=1, + member_id="member", identity=identity, deadline=time.monotonic() + 600) + + result = asyncio.run(run()) + assert len(result["rounds"]) == 128 and len(consumed) == 640 + assert sent == [f"history {index:04d}" for index in range(128)] + assert mailbox.active and mailbox.queue.empty() and mailbox.queue.maxsize == 256 diff --git a/tests/coding/test_lmux_history_recipe.py b/tests/coding/test_lmux_history_recipe.py new file mode 100644 index 000000000..491f58152 --- /dev/null +++ b/tests/coding/test_lmux_history_recipe.py @@ -0,0 +1,125 @@ +"""Pinned text recipe and synthetic model contract, not installed acceptance.""" + +import asyncio +import copy +from types import SimpleNamespace + +import pytest + +from ._lmux_history_recipe import ( + history_digest, + history_records, + history_turn, + validate_history, + validate_history_window, +) +from ._lmux_synthetic_product import components + + +def test_exact_recipe_bytes_and_digest(): + records = history_records() + assert validate_history(records) == { + "recipe": "lmux-history-128x2048/v1", "rounds": 128, "records": 256, + "text_bytes": 263680, + "sha256": "00e1c01bb0a4603b94f5fbd70ea802f24a9389471893e5883310ba7c0c0fbb41", + } + assert history_digest([]) == "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" + for index in range(128): + user, assistant = history_turn(index) + assert user == f"history {index:04d}" + assert len(user.encode("ascii")) == 12 + assert len(assistant.encode("ascii")) == 2048 + assert assistant.endswith(f"LMUX_HISTORY_{index:04d}_END") + assert f"## History {index:04d}\n" in assistant + assert f"- completed round {index:04d}\n" in assistant + assert f"```text\ncode-{index:04d}\n```" in assistant + assert "\r" not in assistant + + +@pytest.mark.parametrize("index", [-1, 128, True, 0.0, "0000", None]) +def test_history_round_is_bounded(index): + with pytest.raises(ValueError): + history_turn(index) + + +@pytest.mark.parametrize("fault", ["early-edit", "missing", "duplicate", "reorder", "wrong-kind", "extra"]) +def test_correct_tail_cannot_hide_corrupted_early_history(fault): + records = history_records() + tail = copy.deepcopy(records[-2:]) + if fault == "early-edit": + records[1][1] = "x" + records[1][1][1:] + elif fault == "missing": + records.pop(0) + elif fault == "duplicate": + records[1] = records[0] + elif fault == "reorder": + records[0], records[1] = records[1], records[0] + elif fault == "wrong-kind": + records[0][0] = "assistant" + else: + records.insert(0, ["user", "extra"]) + assert records[-2:] == tail + with pytest.raises(ValueError): + validate_history(records) + + +def test_synthetic_product_emits_exact_recipe_without_tools(): + async def check(): + def witness(*args): + pytest.fail("history does not execute tools or park producers") + model, stream, _ = components(witness) + for index in (0, 127): + user, expected = history_turn(index) + response = await stream(model, SimpleNamespace(messages=[SimpleNamespace(role="user", content=user)])) + try: + message = await response.result() + assert message.content[0].text == expected + assert message.stop_reason == "stop" + finally: + await response.aclose() + asyncio.run(check()) + + +@pytest.mark.parametrize("last", [0, 6, 7, 127]) +def test_real_product_projection_matches_recipe_window(last): + from loushang.ai.types import UserMessage + from loushang.coding.hosted_session import CodingRealHostedSessionV1 + + async def check(): + messages = [] + model, stream, _ = components(lambda *_: None) + for index in range(last + 1): + user, _ = history_turn(index) + messages.append(UserMessage(role="user", content=user, timestamp=0.0)) + response = await stream(model, SimpleNamespace(messages=messages)) + try: + messages.append(await response.result()) + finally: + await response.aclose() + # Exercise the real pure projection without opening a Session/store. + owner = SimpleNamespace(_session=SimpleNamespace(messages=messages, session_name="History", + is_streaming=False), _revision=last + 1) + snapshot = CodingRealHostedSessionV1.project_snapshot(owner) + records = [[record.kind.value, record.text] for record in snapshot.records] + validate_history_window(records, last) + assert snapshot.truncated is (last >= 7) + assert len(records) == (2 * (last + 1) if last < 7 else 15) + asyncio.run(check()) + + +@pytest.mark.parametrize("fault", ["old-round", "missing-latest", "duplicate", "changed-text", "missing-status"]) +def test_tail_rejects_stale_or_wrong_latest_round(fault): + from ._lmux_history_recipe import OMITTED + records = [["status", OMITTED], *history_records()[-14:]] + if fault == "old-round": + records = [["status", OMITTED], *history_records()[-16:-2]] + elif fault == "missing-latest": + records.pop() + elif fault == "duplicate": + records.append(records[-1]) + elif fault == "changed-text": + records[-1][1] = "wrong" + records[-1][1] + else: + records.pop(0) + with pytest.raises(ValueError): + validate_history_window(records, 127) diff --git a/tests/coding/test_lmux_history_restore.py b/tests/coding/test_lmux_history_restore.py new file mode 100644 index 000000000..fbce0a3f2 --- /dev/null +++ b/tests/coding/test_lmux_history_restore.py @@ -0,0 +1,132 @@ +"""Restart ordering and failure handling; not installed recovery acceptance.""" + +import json +from contextlib import contextmanager +from dataclasses import asdict +from types import SimpleNamespace as NS + +import pytest + +from loushang.hosting.service import LinuxServiceIdentityV1 + +from . import _lmux_history_restore as restore + + +@pytest.mark.parametrize("fault", [None, "start", "read", "instance", "native", "service", "ready-instance", "attach", "stop", "canonical"]) +def test_restart_keeps_generation_stops_separate(tmp_path, monkeypatch, fault): + events = [] + old_target = {"serviceId": "service", "instanceId": "old"} + native = LinuxServiceIdentityV1(123, 100, "12345678-1234-1234-1234-123456789abc", 1000, 4, 5) + old_native = {**asdict(native), "starttime_ticks": -1} + if fault == "native": + old_native = asdict(native) + target = {"serviceId": "other" if fault == "service" else "service", "instanceId": "old" if fault == "instance" else "new"} + identity = {"product_id": "coding", "continuity_id": "continuity", "session_id": "session", + "scope": "user_home", "scope_fingerprint": "c" * 64} + ready = {"status": "service_ready", **target} + if fault == "ready-instance": + ready["instanceId"] = "different" + + @contextmanager + def spawn(executable): + events.append("spawn") + yield {"start": 2.0} + + @contextmanager + def terminal(argv, root, environment, **kwargs): + assert argv[-3:] == ["start", "-t", "perf"] and root == tmp_path + try: + yield NS(raw_output=json.dumps(ready), diagnostics=None, + wait=lambda **kwargs: 1 if fault == "start" else 0), None, None + finally: + events.append("start-closed") + + def read(environment, stage, *, native_identity): + assert events[-1] == "start-closed" + events.append("read") + if fault == "read": + raise ValueError("read failed") + native_identity.append(native) + return target + + def attach(root, report, environment, actual, natives, expected, *, directory): + assert directory == "restored-elsewhere" and expected == identity + assert actual == target and natives == [native] + events.append("attach") + if fault == "attach": + raise ValueError("attach failed") + return {"actions": {"history_frame": {"started_at": 10.0, "finished_at": 15.0}}} + + def stop(root, report, environment, actual, original): + assert original == native and actual == target + events.append("new-stop") + if fault == "stop": + raise ValueError("stop failed") + return {"status": "stopped", "instanceId": target["instanceId"]} + + def canonical(root, restored_identity, workspace, *, receipt): + assert events[-1] == "new-stop" and workspace == tmp_path + events.append("canonical") + receipt.update(started_at=20.0, completed_at=21.0) + return {"digest": "wrong" if fault == "canonical" else "same"} + + monkeypatch.setattr(restore.probe, "observe_spawn", spawn) + monkeypatch.setattr(restore.probe, "observed_terminal", terminal) + monkeypatch.setattr(restore.probe, "managed_read_observation", read) + monkeypatch.setattr(restore.probe, "_history_attach", attach) + monkeypatch.setattr(restore.probe, "_stop_generation", stop) + monkeypatch.setattr(restore.probe, "_terminal_environment", lambda _: {"LOUSHANG_HOME": str(tmp_path / "platform")}) + monkeypatch.setattr(restore, "read_history", canonical) + report = {"measured_prefix": str(tmp_path / "install")} + if fault is None: + restore._restart(tmp_path, report, old_target, old_native, identity, 1.0, {"digest": "same"}) + assert report["restored_history"]["restored_history_frame_seconds"] == 13.0 + assert report["fixed_product_target"] == target + assert report["restored_history"]["canonical_read"]["completed_at"] == 21.0 + assert events == ["spawn", "start-closed", "read", "attach", "new-stop", "canonical"] + else: + with pytest.raises((ValueError, AssertionError)): + restore._restart(tmp_path, report, old_target, old_native, identity, 1.0, {"digest": "same"}) + assert "restored_history" not in report + assert events.count("new-stop") == (0 if fault in {"start", "read", "instance", "native", "service", "ready-instance"} else 1) + + +@pytest.mark.parametrize("fault", [None, "old-failure", "old-stop", "canonical-before-stop", "canonical-reversed", "new-failure"]) +def test_new_generation_cannot_start_before_old_success(tmp_path, monkeypatch, fault): + events = [] + target = {"serviceId": "service", "instanceId": "old"} + stop = {"status": "failed" if fault == "old-stop" else "stopped", "instanceId": "old"} + identity, canonical, native = {"identity": "old"}, {"digest": "same"}, {"native": "old"} + + def first(root, report, *, history): + assert root == tmp_path and history is True + events.append("old") + if fault == "old-failure": + raise ValueError("old did not settle") + report.update(fixed_product_history={"stop": stop, "canonical": canonical, + "canonical_read": {"started_at": 1.0 if fault == "canonical-before-stop" else 3.0, + "completed_at": 2.5 if fault == "canonical-reversed" else 4.0}, + "seed": {"history_identity": identity}}, fixed_product_target=target, + fixed_product_native={"stop": native}, spawns=[{"generation": "old"}], + fixed_product_stop={"result": stop, "local_owner_settled_at": 2.0}) + + def restart(root, report, old_target, old_native, expected, settled_at, old_canonical): + assert events == ["old"] and root == tmp_path + assert (old_target, old_native, expected, settled_at, old_canonical) == (target, native, identity, 4.0, canonical) + events.append("new") + if fault == "new-failure": + raise ValueError("new failed") + report.update(restored_history={"canonical": canonical}, spawns=[{"generation": "new"}]) + + monkeypatch.setattr(restore.probe, "first_reply", first) + monkeypatch.setattr(restore, "_restart", restart) + report = {"measured_prefix": "install"} + if fault is None: + restore.restore_history(tmp_path, report) + assert report["fixed_product_history_restore"] == {"canonical": canonical} + assert report["spawns"] == [{"generation": "old"}, {"generation": "new"}] + else: + with pytest.raises((ValueError, AssertionError)): + restore.restore_history(tmp_path, report) + assert "fixed_product_history_restore" not in report + assert ("new" in events) is (fault == "new-failure") diff --git a/tests/coding/test_lmux_history_seed.py b/tests/coding/test_lmux_history_seed.py new file mode 100644 index 000000000..4defa4e22 --- /dev/null +++ b/tests/coding/test_lmux_history_seed.py @@ -0,0 +1,136 @@ +"""Borrowed public seed calls; no claim of real connection settlement.""" + +import asyncio +import time +from types import SimpleNamespace + +import pytest + +from loushang.appserver.protocol import ( + AckV1, + AttachmentEventV1, + SessionEventKindV1, + SessionEventV1, +) + +from . import _lmux_history_seed as seed +from ._lmux_history_recipe import OMITTED, history_records + + +async def _empty_events(**kwargs): + return () + + +@pytest.mark.parametrize("fault", [None, "lost-ack", "bad-ack", "cancel", "identity", "initial-busy", + "old-then-new", "forever-old", "bad-text", "error", "expired", + "event-lost", "event-cancel", "event-identity", "event-error", "event-shape", "event-size", "event-endless"]) +def test_public_seed_never_retries_or_accepts_stale_idle(monkeypatch, fault): + identity = SimpleNamespace(session_id="session") + sends, snapshots = [], [] + full = history_records() + + async def sleep(_): + return None + monkeypatch.setattr(seed.asyncio, "sleep", sleep) + + async def start(request): + sends.append(request) + assert request.attachment_id == "attachment" and request.controller_generation == 1 + assert request.member_id == "member" + if fault == "lost-ack": + raise TimeoutError("lost original reply") + if fault == "cancel": + raise asyncio.CancelledError() + return object() if fault == "bad-ack" else AckV1() + + async def snapshot(request): + snapshots.append(request) + count = len(sends) + if fault == "forever-old" or (fault == "old-then-new" and len(snapshots) == 2): + count = 0 + rows = full[max(0, count * 2 - 14):count * 2] + if count > 7: + rows = [["status", OMITTED], *rows] + if count and fault == "bad-text": + rows = [*rows[:-1], ["assistant", "wrong"]] + if count and fault == "error": + rows = [["error", "failed"]] + return SimpleNamespace(identity=object() if fault == "identity" else identity, + running=fault == "initial-busy", + records=[SimpleNamespace(kind=SimpleNamespace(value=kind), text=text) for kind, text in rows]) + + async def events(**kwargs): + if fault == "event-lost": + raise TimeoutError("event response lost") + if fault == "event-cancel": + raise asyncio.CancelledError() + if fault == "event-shape": + return [] + if fault in {"event-identity", "event-error", "event-size", "event-endless"}: + value = AttachmentEventV1("attachment", "member", SessionEventV1( + "other" if fault == "event-identity" else "session", 1, + SessionEventKindV1.ERROR if fault == "event-error" else SessionEventKindV1.STATUS)) + return (value,) * (65 if fault == "event-size" else 1) + return () + + async def run(): + return await seed.seed_history(SimpleNamespace(start_turn=start, snapshot_session=snapshot, read_events=events), + attachment_id="attachment", generation=1, member_id="member", identity=identity, + deadline=0.0 if fault == "expired" else time.monotonic() + 600) + + if fault in {None, "old-then-new"}: + result = asyncio.run(run()) + assert len(sends) == len(result["rounds"]) == 128 + assert [request.text for request in sends] == [f"history {i:04d}" for i in range(128)] + for row in result["rounds"]: + assert row["started_at"] <= row["acknowledged_at"] <= row["settled_at"] + assert result["rounds"][0]["snapshot_reads"] == (2 if fault == "old-then-new" else 1) + else: + expected = asyncio.CancelledError if fault in {"cancel", "event-cancel"} else TimeoutError if fault in {"lost-ack", "forever-old", "expired", "event-lost", "event-endless"} else ValueError + with pytest.raises(expected): + asyncio.run(run()) + assert len(sends) == (0 if fault in {"identity", "initial-busy", "expired"} else 1) + if fault.startswith("event-"): + assert len(snapshots) == 1 # Only the pre-turn snapshot; no success observation after event failure. + if fault == "forever-old": + assert len(snapshots) == 1 + seed.MAX_POLLS + + +@pytest.mark.parametrize("deadline", [True, -1, float("inf"), float("nan"), 10**400, "later"]) +def test_invalid_deadline_is_rejected_before_client_use(deadline): + with pytest.raises(ValueError): + asyncio.run(seed.seed_history(None, attachment_id="attachment", generation=1, + member_id="member", identity=object(), deadline=deadline)) + + +@pytest.mark.parametrize("late_round", [0, 127]) +@pytest.mark.parametrize("budget", [10.0, 600.0]) +def test_validation_time_cannot_cross_round_or_total_deadline(monkeypatch, late_round, budget): + clock, count = [0.0], [0] + identity = object() + full = history_records() + monkeypatch.setattr(seed, "time", SimpleNamespace(monotonic=lambda: clock[0])) + validate = seed.validate_history_window + + def slow_validation(rows, index): + validate(rows, index) + if index == late_round: + clock[0] = min(budget, seed.ROUND_SECONDS) + 1 + monkeypatch.setattr(seed, "validate_history_window", slow_validation) + + async def start(request): + count[0] += 1 + return AckV1() + + async def snapshot(request): + total = count[0] + rows = full[max(0, total * 2 - 14):total * 2] + if total > 7: + rows = [["status", OMITTED], *rows] + return SimpleNamespace(identity=identity, running=False, + records=[SimpleNamespace(kind=SimpleNamespace(value=kind), text=text) for kind, text in rows]) + + with pytest.raises(TimeoutError, match="validation exceeded"): + asyncio.run(seed.seed_history(SimpleNamespace(start_turn=start, snapshot_session=snapshot, read_events=_empty_events), + attachment_id="attachment", generation=1, member_id="member", identity=identity, deadline=budget)) + assert count[0] == late_round + 1 diff --git a/tests/coding/test_lmux_interaction_receipt.py b/tests/coding/test_lmux_interaction_receipt.py new file mode 100644 index 000000000..49afdd5d6 --- /dev/null +++ b/tests/coding/test_lmux_interaction_receipt.py @@ -0,0 +1,67 @@ +import asyncio +import time +from types import SimpleNamespace as NS + +import pytest + +from loushang.appserver.protocol import ( + AckV1, + InteractionOutcomeV1, + InteractionRespondV1, +) + +from ._hosted_boundary_trace import BoundaryTrace +from ._lmux_interaction_receipt import observe_interaction_receipts +from ._lmux_product_probe import _denial_receipt + + +@pytest.mark.parametrize("fault", [None, "lost", "wrong-result", "wrong-member", "approve", "duplicate", "old"]) +def test_original_denial_receipt_is_required_not_just_model_echo(tmp_path, fault): + root = tmp_path / "lmux-interaction-observations" + root.mkdir(mode=0o700) + trace = BoundaryTrace(root) + calls = [] + receipt = AckV1() + request = InteractionRespondV1("attachment", 1, "member", "interaction", + InteractionOutcomeV1.APPROVE if fault == "approve" else InteractionOutcomeV1.DENY) + + class Client: + async def respond_interaction(self, value): + assert value is request + calls.append(value) + if fault == "lost": + raise TimeoutError("lost reply") + return NS() if fault == "wrong-result" else receipt + + original = Client.respond_interaction + started = time.monotonic_ns() + + async def run(): + with observe_interaction_receipts(Client, trace): + assert await Client().respond_interaction(request) is receipt + if fault == "duplicate": + await Client().respond_interaction(request) + + if fault in {"lost", "wrong-result"}: + with pytest.raises((TimeoutError, TypeError)): + asyncio.run(run()) + else: + asyncio.run(run()) + assert Client.respond_interaction is original + assert len(calls) == (2 if fault == "duplicate" else 1) + target = {"members": [{"memberId": "other" if fault == "wrong-member" else "member"}]} + if fault == "old": + started = time.monotonic_ns() + if fault is None: + _denial_receipt(tmp_path, target, not_before_ns=started) + else: + with pytest.raises(AssertionError): + _denial_receipt(tmp_path, target, not_before_ns=started) + + +def test_dropped_deny_cannot_be_replaced_by_identical_error_echo(tmp_path): + root = tmp_path / "lmux-interaction-observations" + root.mkdir(mode=0o700) + BoundaryTrace(root) # Parent exists, but no original request was sent. + with pytest.raises(AssertionError): + _denial_receipt(tmp_path, {"members": [{"memberId": "member"}]}, not_before_ns=0) diff --git a/tests/coding/test_lmux_line_terminal.py b/tests/coding/test_lmux_line_terminal.py new file mode 100644 index 000000000..a7d3cf8e0 --- /dev/null +++ b/tests/coding/test_lmux_line_terminal.py @@ -0,0 +1,54 @@ +"""Line commands retain original terminal ownership without TUI marker claims.""" +import os +import sys +from contextlib import contextmanager +from types import SimpleNamespace as NS + +import pytest + +from . import _g18_native_probe as probe + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux PTY receipts") + + +def test_real_line_command_publishes_settled_receipt_without_tui_markers(tmp_path): + receipts = [] + argv = [sys.executable, "-I", "-c", "print('line-ready')"] + with probe.observed_terminal(argv, tmp_path, dict(os.environ), + line_settlements=receipts) as (driver, _, _): + assert driver.wait(timeout=10) == 0 + assert "line-ready" in driver.raw_output + assert len(receipts) == 1 + receipt = receipts[0] + assert receipt["presentation"] == "line" and receipt["argv"] == argv + assert receipt["exit_status"] == 0 and receipt["reader_settled"] + assert not receipt["fallback"] + assert receipt["termios_restored_at"] <= receipt["settled_at"] + assert "cursor_restored" not in receipt and "bracketed_paste_disabled" not in receipt + + +@pytest.mark.parametrize("failure", ["exit", "reader", "fallback", "modes", "close"]) +def test_failed_line_settlement_publishes_no_receipt(tmp_path, monkeypatch, failure): + import pty + import termios + + monkeypatch.setattr(pty, "openpty", lambda: (1, 2)) + monkeypatch.setattr(termios, "tcgetattr", lambda fd: [fd] if failure == "modes" else []) + monkeypatch.setattr(probe, "FAILURE_TREE_DIAGNOSTIC", False) + driver = NS(is_alive=lambda: False, diagnostics=NS( + pid=123, exit_status=1 if failure == "exit" else 0, + reader_alive=failure == "reader", termination="fallback" if failure == "fallback" else None)) + + @contextmanager + def terminal(*args, **kwargs): + pty.openpty() + yield driver + if failure == "close": + raise RuntimeError("close receipt lost") + + monkeypatch.setattr(probe, "foreground_terminal", terminal) + receipts = [] + with pytest.raises((AssertionError, RuntimeError)): + with probe.observed_terminal([], tmp_path, {}, line_settlements=receipts): + pass + assert receipts == [] diff --git a/tests/coding/test_lmux_native_body.py b/tests/coding/test_lmux_native_body.py new file mode 100644 index 000000000..c7b812a3b --- /dev/null +++ b/tests/coding/test_lmux_native_body.py @@ -0,0 +1,144 @@ +"""Failure/target boundaries of native observation orchestration, with fake IO.""" + +from __future__ import annotations + +import copy +import json +import time +from contextlib import contextmanager +from types import SimpleNamespace + +import pytest + +from . import _g18_native_probe as probe + + +@pytest.fixture +def body_io(monkeypatch): + state = {"frame_error": None, "cleanup_error": None, "mutate": None, "events": []} + members = [{"memberId": "member-1", "sessionId": "session-1"}, + {"memberId": "member-2", "sessionId": "session-2"}] + + class Driver: + raw_output = "" + diagnostics = None + + def write(self, text): + state["events"].append(("write", text)) + + def read_until(self, predicate, **kwargs): + if state["frame_error"] is not None: + raise state["frame_error"] + + def wait(self, **kwargs): + return 0 + + @contextmanager + def terminal(*args, **kwargs): + state["events"].append("terminal-open") + try: + yield Driver(), None, None + finally: + state["events"].append("terminal-closed") + + @contextmanager + def spawn(*args): + yield {"start": time.perf_counter()} + + def observation(environment, stage, *, native_identity=None): + if native_identity is not None: + native_identity.append(object()) + value = {"stage": stage, "observed_at": time.perf_counter(), + "instanceId": "a" * 32, "serviceId": "b" * 64, "muxId": "mux-perf", + "members": copy.deepcopy(members[:1] if stage == "first-member" else members)} + if state["mutate"]: + state["mutate"](stage, value) + return value + + def command(argv, **kwargs): + state["events"].append(("command", tuple(argv[1:]))) + if "--all" in argv and state["cleanup_error"] is not None: + raise state["cleanup_error"] + return SimpleNamespace(returncode=0, stderr="", stdout="\n".join(map(json.dumps, [ + {"action": "stop_preview"}, {"status": "stopped", "instanceId": "a" * 32}, + ]))) + + monkeypatch.setattr(probe, "observed_terminal", terminal) + monkeypatch.setattr(probe, "observe_spawn", spawn) + monkeypatch.setattr(probe, "assert_active_terminal", lambda *args: None) + monkeypatch.setattr(probe, "managed_read_observation", observation) + monkeypatch.setattr(probe, "_terminal_environment", lambda root: {}) + monkeypatch.setattr(probe.subprocess, "run", command) + from . import _lmux_adopted_process as adopted + + monkeypatch.setattr(adopted, "AdoptedLeader", lambda identity: SimpleNamespace(close=lambda: None)) + monkeypatch.setattr(adopted, "stop_command", command) + return state + + +def report(tmp_path): + return {"measured_prefix": str(tmp_path / "install"), "spawns": [], "milestones": {}} + + +def test_adopted_close_failure_is_not_hidden_by_callers_exception(tmp_path, monkeypatch, body_io): + from . import _lmux_adopted_process as adopted + + failure = OSError("pidfd close unknown") + + def close(): + raise failure + + monkeypatch.setattr(adopted, "AdoptedLeader", lambda identity: SimpleNamespace(close=close)) + try: + raise LookupError("unrelated caller exception") + except LookupError: + with pytest.raises(OSError) as caught: + probe.managed_mux(tmp_path, report(tmp_path)) + assert caught.value is failure + + +@pytest.mark.parametrize("primary", [TimeoutError("original frame timeout"), KeyboardInterrupt()]) +def test_failed_fallback_keeps_original_error_and_bounded_diagnostic(tmp_path, body_io, primary): + body_io.update(frame_error=primary, cleanup_error=OSError("sensitive details must not be copied")) + value = report(tmp_path) + with pytest.raises(type(primary)) as caught: + probe.managed_mux(tmp_path, value) + assert caught.value is primary + assert value["managed_cleanup_failure"] == {"type": "OSError"} + assert "sensitive" not in repr(value) + assert "stop_settlement_seconds" not in value["milestones"] + assert body_io["events"].index("terminal-closed") < next( + index for index, event in enumerate(body_io["events"]) if isinstance(event, tuple) and event[0] == "command" + ) + + +@pytest.mark.parametrize("stage,key", [ + ("second-member", "instanceId"), ("detached", "muxId"), + ("reattached", "members"), ("second-member", "duplicate"), +]) +def test_native_body_rejects_target_or_member_replacement_before_success(tmp_path, body_io, stage, key): + def mutate(current, value): + if current == stage: + if key == "members": + value["members"][0]["sessionId"] = "replacement" + elif key == "duplicate": + value["members"][1]["memberId"] = value["members"][0]["memberId"] + else: + value[key] = "replacement" + + body_io["mutate"] = mutate + value = report(tmp_path) + with pytest.raises(AssertionError): + probe.managed_mux(tmp_path, value) + assert body_io["events"][-1] == ("command", ("stop", "--all", "--yes")) + assert "stop_settlement_seconds" not in value["milestones"] + + +def test_native_body_leaves_ninth_metric_to_outer_physical_settlement(tmp_path, body_io): + value = report(tmp_path) + probe.managed_mux(tmp_path, value) + assert len(value["milestones"]) == 8 + assert "stop_settlement_seconds" not in value["milestones"] + assert value["managed_stop"]["result"] == {"status": "stopped", "instanceId": "a" * 32} + assert len(value["managed_observations"]) == 4 + assert body_io["events"][-1] == ("command", ("stop", "--server", "b" * 64, "--yes")) diff --git a/tests/coding/test_lmux_product_child.py b/tests/coding/test_lmux_product_child.py new file mode 100644 index 000000000..acacbe411 --- /dev/null +++ b/tests/coding/test_lmux_product_child.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from loushang.coding import managed_process + +from . import _lmux_product_child as child +from .test_managed_process_arguments import invocation + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed test child") + + +@pytest.mark.parametrize("diagnostic", [False, True]) +def test_fixed_request_preserves_production_environment_streams_and_arguments(tmp_path, diagnostic): + selected = invocation(tmp_path) + environment = {"LOUSHANG_HOME": "/other", "LABEL": "test"} + options = dict(executable=sys.executable, environment=environment, + session_root=tmp_path / "sessions") + original = managed_process.coding_managed_process_request(selected, 7, **options) + fixed = child.request_factory(selected, 7, **options, admission_diagnostic=diagnostic) + from dataclasses import replace + + assert replace(fixed, argv=original.argv) == original + assert fixed.argv == (sys.executable, "-I", str(Path(child.__file__).resolve()), *original.argv[3:], + *(("--admission-diagnostic",) if diagnostic else ())) + assert environment == {"LOUSHANG_HOME": "/other", "LABEL": "test"} + + +@pytest.mark.parametrize("fault", [None, "origin", "permissions", "run"]) +@pytest.mark.parametrize("diagnostic", [False, True]) +@pytest.mark.parametrize("gated", [False, True]) +def test_fixed_child_uses_only_fixed_helpers_and_original_arguments(tmp_path, monkeypatch, fault, diagnostic, gated): + selected = invocation(tmp_path) + arguments = [selected.to_json(), str(tmp_path / "sessions"), "7"] + original_arguments = list(arguments) + if diagnostic: + arguments.append("--admission-diagnostic") + root = tmp_path / "lmux-test-observations" + root.mkdir(mode=0o700) + if gated: + (root / "completion-gates").mkdir(mode=0o700) + if fault == "permissions": + root.chmod(0o755) + monkeypatch.setattr( + managed_process, "__file__", + str(tmp_path / "foreign.py" if fault == "origin" else Path(sys.prefix) / "installed-module.py"), + ) + events, helpers = [], [] + failure = RuntimeError("fixed composition failed") + + class Trace: + def __init__(self, selected_root): + assert selected_root == root + + def emit(self, phase, **fields): + events.append((phase, fields)) + + def run_product(argv, witness, **options): + assert argv == original_arguments + assert set(options) == (({"admission_diagnostic"} if diagnostic else set()) + | ({"completion_gate"} if gated else set())) + if fault == "run": + raise failure + witness("tool_executed", "lmux-call-1") + return 17 + + def load(path): + name = Path(path).name + helpers.append(name) + if name == "_hosted_boundary_trace.py": + return {"BoundaryTrace": Trace, "install": lambda: pytest.fail("must not install tracing")} + if name == "_lmux_completion_gate.py": + def gate(selected_root, instance): + assert selected_root == root / "completion-gates" + assert instance == selected.instance.instance_id + return object() + return {"CompletionGate": gate} + assert name == "_lmux_synthetic_product.py" + return {"run_product": run_product} + + monkeypatch.setattr(child.runpy, "run_path", load) + if fault in {"origin", "permissions"}: + with pytest.raises(ValueError): + child.main(arguments) + assert helpers == [] and events == [] + elif fault == "run": + with pytest.raises(RuntimeError) as caught: + child.main(arguments) + assert caught.value is failure + assert [phase for phase, _ in events] == ["fixed_product_selected"] + else: + assert child.main(arguments) == 17 + assert helpers == ["_hosted_boundary_trace.py", "_lmux_synthetic_product.py", + *(["_lmux_completion_gate.py"] if gated else [])] + assert events == [ + ("fixed_product_selected", {"instance_id": selected.instance.instance_id}), + ("tool_executed", {"call_id": "lmux-call-1"}), + ("fixed_product_returned", {"status": 17}), + ] diff --git a/tests/coding/test_lmux_product_entry.py b/tests/coding/test_lmux_product_entry.py new file mode 100644 index 000000000..f230aa858 --- /dev/null +++ b/tests/coding/test_lmux_product_entry.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import sys +from contextlib import contextmanager +from pathlib import Path + +import pytest + +from loushang.coding.cli import lmux, lmux_command + +from . import _lmux_product_entry as entry + + +@pytest.mark.parametrize("failure", [None, RuntimeError("startup"), KeyboardInterrupt()]) +@pytest.mark.parametrize("diagnostic", [False, True, "interaction"]) +@pytest.mark.parametrize("command", [["new", "-s", "perf"], ["start", "-t", "perf"]]) +def test_fixed_parent_retains_cli_exit_and_restores_factory(monkeypatch, failure, diagnostic, command): + original = lmux_command.coding_managed_process_request + observed = [] + def selected(): + return None + arguments = (["--interaction-diagnostic"] if diagnostic == "interaction" else + ["--admission-diagnostic"] if diagnostic else []) + command + for module in (lmux, lmux_command): + monkeypatch.setattr(module, "__file__", str(Path(sys.prefix) / "installed.py")) + + def load(path): + if Path(path).name == "_hosted_boundary_trace.py": + return {"BoundaryTrace": lambda root: root} + if Path(path).name == "_lmux_interaction_receipt.py": + @contextmanager + def observe(client_type, trace): + observed.append("enter") + try: + yield + finally: + observed.append("exit") + return {"observe_interaction_receipts": observe} + assert Path(path) == Path(entry.__file__).with_name("_lmux_product_child.py") + return {"request_factory": selected} + + def main(argv): + assert argv == command + factory = lmux_command.coding_managed_process_request + if diagnostic is True: + assert factory.func is selected and factory.keywords == {"admission_diagnostic": True} + else: + assert factory is selected + assert observed == (["enter"] if diagnostic == "interaction" else []) + if failure is not None: + raise failure + return 130 + + monkeypatch.setattr(entry.runpy, "run_path", load) + monkeypatch.setattr(lmux, "main", main) + if failure is None: + assert entry.main(arguments) == 130 + else: + with pytest.raises(type(failure)) as caught: + entry.main(arguments) + assert caught.value is failure + assert lmux_command.coding_managed_process_request is original + assert observed == (["enter", "exit"] if diagnostic == "interaction" else []) + + +@pytest.mark.parametrize("arguments", [["start"], ["start", "-t", "other"], + ["start", "-t", "perf", "--workspace", "/tmp"], ["attach", "-t", "perf"], + ["new", "-s", "other"], ["start", "--server", "perf"]]) +def test_fixed_restart_does_not_accept_arbitrary_launch_arguments(monkeypatch, arguments): + monkeypatch.setattr(entry.runpy, "run_path", lambda *_: pytest.fail("must not load helper")) + with pytest.raises(ValueError, match="fixed Product parent"): + entry.main(arguments) + + +@pytest.mark.parametrize("fault", ["arguments", "origin"]) +def test_fixed_parent_rejects_before_loading_helpers(monkeypatch, tmp_path, fault): + original = lmux_command.coding_managed_process_request + monkeypatch.setattr(lmux, "__file__", str(tmp_path / "outside.py")) + monkeypatch.setattr(entry.runpy, "run_path", lambda *_: pytest.fail("must reject before helper load")) + with pytest.raises(ValueError): + entry.main(["server", "start"] if fault == "arguments" else ["new", "-s", "perf"]) + assert lmux_command.coding_managed_process_request is original diff --git a/tests/coding/test_lmux_product_frames.py b/tests/coding/test_lmux_product_frames.py new file mode 100644 index 000000000..2fee462a7 --- /dev/null +++ b/tests/coding/test_lmux_product_frames.py @@ -0,0 +1,238 @@ +import pytest + +from ._lmux_product_frames import ( + approval_details_visible, + approval_pending_visible, + assert_no_completed_reply, + denied_tool_reply_completed, + reply_completed, + reply_streaming, + target_state_visible, + tool_reply_completed, +) +from .test_lmux_completion_witness import frame + +EXPECTED = "LMUX_REPLY_" + "a" * 32 +STATUS = "Hosted | FirstUse | idle | cwd / user_home; /help" +FOOTER = "perf | *1 | /help /detach" + + +@pytest.mark.parametrize("kind", ["reply", "tool", "denial"]) +def test_completed_reply_accepts_exact_bare_idle_not_unknown_or_stale(kind): + text, predicate = { + "reply": (EXPECTED, lambda output, after: reply_completed(output, EXPECTED, after=after)), + "tool": ("LMUX_TOOL_COMPLETED", lambda output, after: tool_reply_completed(output, after=after)), + "denial": ("Tool lmux_evidence requires approval", lambda output, after: denied_tool_reply_completed(output, after=after)), + }[kind] + status = "Hosted | FirstUse | idle" + output = frame("* " + text, ">", status, FOOTER) + assert predicate(output, 0) + assert not predicate(output, len(output)) + for wrong in (status + " | request_unknown", status.replace("idle", "running"), + status.replace("FirstUse", "Other")): + assert not predicate(frame("* " + text, ">", wrong, FOOTER), 0) + assert not predicate(frame("* " + text, ">", status, FOOTER.replace("*1", "*2")), 0) + + +@pytest.mark.parametrize("hint", ["cwd / user_home; /help", "cwd / user_home: /new ; /help"]) +def test_reply_acknowledgement_accepts_both_exact_help_variants(hint): + status = "Hosted | FirstUse | idle | submit: request_acknowledged; " + hint + output = frame("* " + EXPECTED, ">", status, FOOTER) + assert reply_completed(output, EXPECTED, after=0) + for receipt in ("request_pending", "request_unknown", "request_failed"): + assert not reply_completed(frame("* " + EXPECTED, ">", + status.replace("request_acknowledged", receipt), FOOTER), EXPECTED, after=0) + + +def test_denied_tool_reply_is_not_approval_pending_or_success(): + text = "* Tool lmux_evidence requires approval" + assert denied_tool_reply_completed(frame(text, ">", STATUS, FOOTER), after=0) + for status in (STATUS.replace("idle", "running"), STATUS.replace("FirstUse", "Other"), + STATUS.replace("cwd /", "submit: request_pending; cwd /")): + assert not denied_tool_reply_completed(frame(text, ">", status, FOOTER), after=0) + assert not denied_tool_reply_completed(frame("* LMUX_TOOL_COMPLETED", ">", STATUS, FOOTER), after=0) + assert not tool_reply_completed(frame(text, ">", STATUS, FOOTER), after=0) + + +@pytest.mark.parametrize("receipt_state", ["request_pending", "request_acknowledged", "request_unknown", "request_failed"]) +def test_pending_approval_can_coexist_with_submit_receipt(receipt_state): + status = ("Hosted | FirstUse | running | submit: " + receipt_state + + "; Approval pending: F2 details; /approve /deny") + output = frame(">", status, FOOTER.replace("*1", "*1!")) + # The acknowledged variant exceeds this fixed 100-column fixture; it is + # not a fully presented single-line status and must not prove readiness. + assert approval_pending_visible(output, after=0) is (receipt_state == "request_pending") + assert not tool_reply_completed(output, after=0) + assert not approval_pending_visible(output, after=len(output)) + assert not approval_pending_visible(frame(">", status, FOOTER), after=0) + assert not approval_pending_visible(frame(">", status.replace("FirstUse", "Other"), + FOOTER.replace("*1", "*1!")), after=0) + + +def test_approval_and_tool_reply_require_distinct_current_states(): + pending = frame(">", "Hosted | FirstUse | running | Approval pending: F2 details; /approve /deny", + FOOTER.replace("*1", "*1!")) + assert approval_pending_visible(pending, after=0) + assert not approval_pending_visible(pending, after=len(pending)) + assert not tool_reply_completed(pending, after=0) + complete = frame("* LMUX_TOOL_COMPLETED", ">", STATUS, FOOTER) + assert tool_reply_completed(complete, after=0) + assert not tool_reply_completed(frame("* LMUX_TOOL_COMPLETED", ">", STATUS.replace("idle", "running"), + FOOTER.replace("*1", "*1!")), after=0) + assert not tool_reply_completed(frame("* LMUX_TOOL_COMPLETED", ">", STATUS.replace("FirstUse", "Other"), + FOOTER), after=0) + + +@pytest.mark.parametrize("fault", [None, "partial-page", "wrong-tool", "missing-args", "incomplete-frame", "wrong-mux", "wrong-tab", "missing-footer"]) +def test_approval_details_must_be_fully_presented(fault): + output = frame( + "Approval details — Esc back, then /approve or /deny", + "other tool call" if fault == "wrong-tool" else "lmux_evidence tool call", + "Tool call requires approval", + "" if fault == "missing-args" else "{}", + "1-3/4 | PgUp/PgDn | Esc back" if fault == "partial-page" else "1-3/3 | PgUp/PgDn | Esc back", + "other | *1! | /help /detach" if fault == "wrong-mux" else + "perf | 1! *2 | /help /detach" if fault == "wrong-tab" else + "" if fault == "missing-footer" else "perf | *1! | /help /detach", + completed=fault != "incomplete-frame", + ) + assert approval_details_visible(output, after=0) is (fault is None) + + +@pytest.mark.parametrize("running", [False, True]) +def test_target_state_requires_current_complete_exact_target(running): + status = STATUS.replace("idle", "running") if running else STATUS + footer = FOOTER.replace("*1", "*1~") if running else FOOTER + output = frame(">", status, footer) + assert target_state_visible(output, running=running) + assert not target_state_visible(output, running=not running) + assert not target_state_visible(output, running=running, after=len(output)) + assert not target_state_visible(frame(">", status, footer, completed=False), running=running) + assert not target_state_visible(frame(">", status.replace("FirstUse", "Other"), footer), running=running) + assert not target_state_visible(frame(">", status, footer.replace("*1", "1 *2")), running=running) + + +@pytest.mark.parametrize("running", [False, True]) +def test_settled_bare_status_is_exact_and_requires_fresh_complete_frame(running): + state = "running" if running else "idle" + status = f"Hosted | FirstUse | {state}" + footer = FOOTER.replace("*1", "*1~") if running else FOOTER + output = frame(">", status, footer) + assert target_state_visible(output, running=running) + assert not target_state_visible(output, running=running, after=len(output)) + assert not target_state_visible(frame(">", status, footer, completed=False), running=running) + for wrong in (status + " | request_unknown", status.replace("FirstUse", "Other")): + assert not target_state_visible(frame(">", wrong, footer), running=running) + assert not target_state_visible(frame(">", status, footer.replace("*1", "*2")), running=running) + + +def test_interrupt_acknowledged_idle_is_not_unknown_or_pending(): + status = "Hosted | FirstUse | idle | interrupt: request_acknowledged; cwd / user_home; /help" + assert target_state_visible(frame(">", status, FOOTER), running=False) + for state in ("request_pending", "request_unknown", "request_failed"): + assert not target_state_visible(frame(">", status.replace("request_acknowledged", state), FOOTER), running=False) + + +def test_running_reattach_accepts_explicit_partial_output_omission_notice(): + status = "Hosted | FirstUse | running | running; earlier partial output is not in the v1 snapshot" + output = frame(">", status, FOOTER.replace("*1", "*1~")) + assert target_state_visible(output, running=True) + assert not target_state_visible(output, running=False) + assert not reply_completed(frame("* " + EXPECTED, ">", status, FOOTER.replace("*1", "*1~")), EXPECTED, after=0) + assert not target_state_visible(frame(">", status.replace("FirstUse", "Other"), + FOOTER.replace("*1", "*1~")), running=True) + assert not target_state_visible(frame(">", status.replace("| running |", "| idle |"), FOOTER), running=False) + + +def test_delayed_final_rejects_late_completion_even_if_running_again_at_exit(): + complete = frame("* " + EXPECTED, ">", STATUS, FOOTER) + running = frame("* " + EXPECTED, ">", STATUS.replace("idle", "running"), + FOOTER.replace("*1", "*1~")) + # An old frame before input is excluded, but every subsequent frame counts. + assert_no_completed_reply(complete + running, EXPECTED, before_send=complete) + with pytest.raises(AssertionError, match="blocked final"): + assert_no_completed_reply(complete + running + complete + running, + EXPECTED, before_send=complete) + with pytest.raises(AssertionError, match="truncated"): + assert_no_completed_reply(running, EXPECTED, before_send="lost prefix") + + +def test_full_text_running_frame_is_not_completion(): + running = frame("* " + EXPECTED, ">", STATUS.replace("idle", "running"), + FOOTER.replace("*1", "*1~")) + assert reply_streaming(running, EXPECTED, after=0) + assert not reply_completed(running, EXPECTED, after=0) + assert not reply_streaming(running, EXPECTED, after=len(running)) + complete = frame("* " + EXPECTED, ">", STATUS, FOOTER) + assert not reply_streaming(complete, EXPECTED, after=0) + assert reply_completed(complete, EXPECTED, after=0) + + +def test_pending_request_may_stream_but_cannot_be_completed(): + status = "Hosted | FirstUse | running | submit: request_pending; cwd / user_home; /help" + output = frame("* " + EXPECTED, ">", status, FOOTER.replace("*1", "*1~")) + assert reply_streaming(output, EXPECTED, after=0) + assert not reply_completed(output, EXPECTED, after=0) + idle = frame("* " + EXPECTED, ">", status.replace("running", "idle"), FOOTER) + assert not reply_streaming(idle, EXPECTED, after=0) + assert not reply_completed(idle, EXPECTED, after=0) + for state in ("request_unknown", "request_failed"): + assert not reply_streaming(frame("* " + EXPECTED, ">", status.replace("request_pending", state), + FOOTER.replace("*1", "*1~")), EXPECTED, after=0) + + +@pytest.mark.parametrize("fault", ["missing-text", "wrong-session", "wrong-tab", "incomplete", "wrong-nonce"]) +def test_streaming_witness_rejects_unrelated_or_incomplete_frames(fault): + text = "* " + EXPECTED if fault != "missing-text" else "" + status = STATUS.replace("idle", "running") + footer = FOOTER.replace("*1", "*1~") + if fault == "wrong-session": + status = status.replace("FirstUse", "Other") + if fault == "wrong-tab": + footer = footer.replace("*1~", "1~ *2") + expected = "LMUX_REPLY_" + "b" * 32 if fault == "wrong-nonce" else EXPECTED + assert not reply_streaming(frame(text, ">", status, footer, completed=fault != "incomplete"), + expected, after=0) + + +def test_reply_requires_a_new_complete_current_frame(): + valid = frame("* " + EXPECTED, ">", STATUS, FOOTER) + assert reply_completed(valid, EXPECTED, after=0) + assert not reply_completed(valid, EXPECTED, after=len(valid)) + assert not reply_completed(frame("* " + EXPECTED, ">", STATUS, FOOTER, completed=False), EXPECTED, after=0) + cleared = valid + frame(">", STATUS, FOOTER) + assert not reply_completed(cleared, EXPECTED, after=len(valid)) + + +def test_acknowledged_request_still_requires_idle_and_exact_reply(): + status = "Hosted | FirstUse | idle | submit: request_acknowledged; cwd / user_home; /help" + assert reply_completed(frame("* " + EXPECTED, ">", status, FOOTER), EXPECTED, after=0) + assert not reply_completed(frame("* " + EXPECTED, ">", status.replace("idle", "running"), + FOOTER.replace("*1", "*1~")), EXPECTED, after=0) + for state in ("request_pending", "request_unknown", "request_failed"): + assert not reply_completed(frame("* " + EXPECTED, ">", status.replace("request_acknowledged", state), + FOOTER), EXPECTED, after=0) + + +@pytest.mark.parametrize("fault", [ + "running", "approval", "unread", "wrong-tab", "wrong-session", "error", + "unknown", "snapshot", "stale-reply", "composer-only", +]) +def test_full_reply_text_is_insufficient_for_completion(fault): + text, composer, status, footer = "* " + EXPECTED, ">", STATUS, FOOTER + if fault in {"running", "approval", "unread"}: + footer = footer.replace("*1", "*1" + {"running": "~", "approval": "!", "unread": "+"}[fault]) + elif fault == "wrong-tab": + footer = footer.replace("*1", "1 *2") + elif fault == "wrong-session": + status = status.replace("FirstUse", "Other") + elif fault in {"error", "unknown", "snapshot"}: + status = "Hosted | FirstUse | idle | " + { + "error": "invalid_or_unavailable_action", "unknown": "request_unknown", + "snapshot": "snapshot_required: /refresh", + }[fault] + elif fault == "stale-reply": + text = "* LMUX_REPLY_" + "b" * 32 + else: + text, composer = "", "> " + EXPECTED + assert not reply_completed(frame(text, composer, status, footer), EXPECTED, after=0) diff --git a/tests/coding/test_lmux_product_probe.py b/tests/coding/test_lmux_product_probe.py new file mode 100644 index 000000000..5ded8db11 --- /dev/null +++ b/tests/coding/test_lmux_product_probe.py @@ -0,0 +1,507 @@ +"""Orchestration fault tests, not installed Product acceptance.""" + +import json +import time +from contextlib import contextmanager +from types import SimpleNamespace as NS + +import pytest + +from . import _lmux_product_probe as probe + + +@pytest.mark.parametrize("fault", [None, "premature", "duplicate", "wrong-call", "wrong-instance", "echo-only"]) +def test_tool_effect_is_exact_handler_execution_not_model_echo(tmp_path, fault): + from ._hosted_boundary_trace import BoundaryTrace + + root = tmp_path / "lmux-test-observations" + root.mkdir(mode=0o700) + trace = BoundaryTrace(root) + trace.emit("fixed_product_selected", instance_id="other" if fault == "wrong-instance" else "instance") + if fault == "wrong-instance": + with pytest.raises(AssertionError): + probe._tool_effect_witness(tmp_path, "instance", executed=False) + return + assert probe._tool_effect_witness(tmp_path, "instance", executed=False) == () + if fault == "echo-only": + trace.emit("model_echo", call_id="lmux-call-1") + else: + trace.emit("tool_executed", call_id="lmux-call-2" if fault == "wrong-call" else "lmux-call-1") + if fault == "duplicate": + trace.emit("tool_executed", call_id="lmux-call-1") + if fault is None: + assert probe._tool_effect_witness(tmp_path, "instance", executed=True) == ("lmux-call-1",) + else: + with pytest.raises(AssertionError): + probe._tool_effect_witness(tmp_path, "instance", executed=fault != "premature") + + +@pytest.mark.parametrize("fault", [None, "early", "duplicate", "wrong-call"]) +def test_effect_receipt_retains_original_trace_only_after_validation(tmp_path, monkeypatch, fault): + row = {"phase": "tool_executed", "call_id": "other" if fault == "wrong-call" else "lmux-call-1", + "monotonic_ns": 9 if fault == "early" else 11, "sequence": 3} + monkeypatch.setattr(probe, "_fixed_product_trace", lambda *args: [row, row] if fault == "duplicate" else [row]) + receipt = [] + if fault is None: + assert probe._tool_effect_witness(tmp_path, "instance", executed=True, not_before_ns=10, evidence=receipt) == ("lmux-call-1",) + assert receipt == [{"instance_id": "instance", "call_id": "lmux-call-1", "monotonic_ns": 11, "sequence": 3}] + else: + with pytest.raises(AssertionError): + probe._tool_effect_witness(tmp_path, "instance", executed=True, not_before_ns=10, evidence=receipt) + assert receipt == [] + + +@pytest.mark.parametrize("fault", [None, "wrong-instance", "wrong-call", "duplicate-start", "missing-settlement", "early-settlement"]) +def test_producer_witness_requires_same_instance_and_exact_phase_order(tmp_path, fault): + from ._hosted_boundary_trace import BoundaryTrace + + root = tmp_path / "lmux-test-observations" + root.mkdir(mode=0o700) + trace = BoundaryTrace(root) + trace.emit("fixed_product_selected", instance_id="other" if fault == "wrong-instance" else "instance") + trace.emit("producer_started", call_id="lmux-call-2" if fault == "wrong-call" else "lmux-call-1") + if fault == "duplicate-start": + trace.emit("producer_started", call_id="lmux-call-1") + if fault == "early-settlement": + trace.emit("producer_settled", call_id="lmux-call-1") + if fault in {"wrong-instance", "wrong-call", "duplicate-start", "early-settlement"}: + with pytest.raises(AssertionError): + probe._producer_witness(tmp_path, "instance", settled=False) + return + assert probe._producer_witness(tmp_path, "instance", settled=False) == "lmux-call-1" + if fault == "missing-settlement": + with pytest.raises(AssertionError): + probe._producer_witness(tmp_path, "instance", settled=True) + else: + trace.emit("producer_settled", call_id="lmux-call-1") + assert probe._producer_witness(tmp_path, "instance", settled=True) == "lmux-call-1" + with pytest.raises(AssertionError, match="before this interrupt"): + probe._producer_witness(tmp_path, "instance", settled=True, not_before_ns=time.monotonic_ns() + 1) + + +@pytest.mark.parametrize("fault", [None, "early", "duplicate", "wrong-call"]) +def test_producer_receipt_retains_original_phases_after_validation(tmp_path, monkeypatch, fault): + rows = [ + {"phase": "producer_started", "call_id": "lmux-call-1", "monotonic_ns": 5, "sequence": 1}, + {"phase": "producer_settled", "call_id": "other" if fault == "wrong-call" else "lmux-call-1", + "monotonic_ns": 9 if fault == "early" else 11, "sequence": 2}, + ] + if fault == "duplicate": + rows.append(dict(rows[-1])) + monkeypatch.setattr(probe, "_fixed_product_trace", lambda *args: rows) + receipt = [] + if fault is None: + assert probe._producer_witness(tmp_path, "instance", settled=True, not_before_ns=10, evidence=receipt) == "lmux-call-1" + assert receipt == [{**row, "instance_id": "instance"} for row in rows] + else: + with pytest.raises(AssertionError): + probe._producer_witness(tmp_path, "instance", settled=True, not_before_ns=10, evidence=receipt) + assert receipt == [] + + +@pytest.mark.parametrize("fault", [None, "snapshot", "leader-close", "stop-receipt", "fallback", "frame", "first-read", "adopt", "record-error", "record-interrupt"]) +@pytest.mark.parametrize("scenario", ["reply", "delayed", "interrupt", "tool", "denial", "hangup", "natural", "history"]) +def test_first_reply_publishes_only_after_snapshot_and_exact_stop(tmp_path, monkeypatch, fault, scenario): + delayed_final = scenario in {"delayed", "interrupt", "hangup", "natural"} + interrupt_next_turn = scenario in {"interrupt", "hangup"} + transport_loss = scenario in {"hangup", "natural"} + natural_completion = scenario == "natural" + history = scenario == "history" + tool_approval = scenario == "tool" + tool_denial = scenario == "denial" + events = [] + from loushang.hosting.service import LinuxServiceIdentityV1 + native = LinuxServiceIdentityV1(123, 100, "12345678-1234-1234-1234-123456789abc", 1000, 4, 5) + target = {"instanceId": "instance", "serviceId": "service", "muxId": "mux", + "members": [{"memberId": "member", "sessionId": "session"}]} + + class Driver: + raw_output = "" + diagnostics = None + + def write(self, text): + events.append("write") + + def read_until(self, predicate, **kwargs): + events.append("frame") + if fault == "frame": + raise ValueError("frame timeout") + + def wait(self, **kwargs): + events.append("terminal-wait") + return 0 + + def hangup_transport(self, **kwargs): + events.append("transport-hangup") + return 0 + + def is_alive(self): + return True + + @contextmanager + def terminal(*args, **kwargs): + try: + yield Driver(), None, None + finally: + events.append("terminal-closed") + + @contextmanager + def observe_spawn(executable): + yield {"start": time.perf_counter(), "pid": 123, + "argv": [executable, "test-entry"], "cwd": str(tmp_path)} + + def observation(environment, stage, *, native_identity=None): + if fault == "first-read": + raise ValueError("no authenticated identity") + if native_identity is not None: + native_identity.append(native) + return dict(target) + + def confirm(*args, **kwargs): + if natural_completion: + assert len(kwargs.pop("natural_nonce")) == 32 + assert kwargs == ({"tool_denial": True} if tool_denial else {"tool_approval": True} if tool_approval else {"pending": True} if delayed_final else {}) + assert events[-1] == "terminal-closed" + events.append("snapshot") + if fault in {"snapshot", "fallback"}: + raise ValueError("snapshot failure") + return {"pendingConfirmed" if delayed_final else "replyConfirmed": True} + + def producer(root, instance, *, settled): + assert instance == "instance" and "snapshot" in events + assert ("leader-close" in events) is settled + events.append("producer-settled" if settled else "producer-live") + return "lmux-call-1" + + def stop(*args, **kwargs): + events.append("stop") + if fault == "stop-receipt": + raise TimeoutError("lost stop receipt") + if fault == "fallback": + raise OSError("private details must not enter report") + return NS(stdout="\n".join(map(json.dumps, [ + {"action": "stop_preview"}, {"status": "stopped", "instanceId": "instance"}, + ]))) + + def close(): + events.append("leader-close") + if fault == "leader-close": + raise OSError("leader close unknown") + + def adopt(identity): + assert identity is native and "terminal-closed" in events + events.append("adopt") + if fault == "adopt": + raise ValueError("original leader already exited") + return NS(close=close) + + monkeypatch.setattr(probe, "observed_terminal", terminal) + monkeypatch.setattr(probe, "observe_spawn", observe_spawn) + monkeypatch.setattr(probe, "abrupt_terminal", terminal) + monkeypatch.setattr(probe, "assert_active_terminal", lambda *args: None) + monkeypatch.setattr(probe, "_see", lambda *args: None) + monkeypatch.setattr(probe, "_terminal_environment", lambda root: {"LOUSHANG_HOME": str(root / "platform")}) + monkeypatch.setattr(probe, "managed_read_observation", observation) + monkeypatch.setattr(probe, "managed_reply_observation", confirm) + def history_seed(*args): + confirm() + return {"history_identity": {"product_id": "coding", "continuity_id": "continuity", "session_id": "session", + "scope": "user_home", "scope_fingerprint": "a" * 64}} + def history_attach(*args): + assert events[-1] == "snapshot" and "stop" not in events + if fault == "frame": + raise ValueError("history frame timeout") + return {"history_frame_seconds": 1.0} + from . import _lmux_history_canonical + def canonical(root, identity, workspace, *, receipt): + assert events[-1] == "leader-close" + assert root == tmp_path / "platform/data/sessions" and workspace == tmp_path + receipt.update(started_at=1.0, completed_at=2.0) + return {"verified": True} + monkeypatch.setattr(probe, "managed_history_observation", history_seed) + monkeypatch.setattr(probe, "_history_attach", history_attach) + monkeypatch.setattr(_lmux_history_canonical, "read_history", canonical) + monkeypatch.setattr(probe, "AdoptedLeader", adopt) + monkeypatch.setattr(probe, "stop_command", stop) + record = probe._record_native + record_error = KeyboardInterrupt() if fault == "record-interrupt" else OSError("record failed") + def record_native(report, stage, identity): + if stage == "stop" and fault in {"record-error", "record-interrupt"}: + raise record_error + record(report, stage, identity) + monkeypatch.setattr(probe, "_record_native", record_native) + monkeypatch.setattr(probe, "_producer_witness", producer) + def next_turn(*args): + assert events[-1] == "producer-live" + events.append("next-turn") + return {"next_reply_confirmation": {"replyConfirmed": True}} + monkeypatch.setattr(probe, "_interrupt_next_turn", next_turn) + monkeypatch.setattr(probe, "_natural_completion", next_turn) + def approve_tool(*args): + if fault == "frame": + raise ValueError("tool frame timeout") + events.append("tool-approved") + return {"approved_tool_reply_seconds": 1.0, "approved_sent_ns": 0} + monkeypatch.setattr(probe, "_approve_first_tool", approve_tool) + monkeypatch.setattr(probe, "_deny_first_tool", approve_tool) + monkeypatch.setattr(probe, "_denial_receipt", lambda *args, **kwargs: None) + def effect(*args, **kwargs): + assert "leader-close" in events + assert kwargs["executed"] is (not tool_denial) + return () if tool_denial else ("lmux-call-1",) + monkeypatch.setattr(probe, "_tool_effect_witness", effect) + report = {"measured_prefix": str(tmp_path / "install")} + key = ("fixed_product_history" if history else "fixed_product_natural_completion" if natural_completion else "fixed_product_tool_denial" if tool_denial else "fixed_product_tool_approval" if tool_approval else "fixed_product_interrupt_next_turn" if interrupt_next_turn + else "fixed_product_delayed_final" if delayed_final else "fixed_product_first_reply") + if fault is None: + probe.first_reply(tmp_path, report, delayed_final=delayed_final, interrupt_next_turn=interrupt_next_turn, tool_approval=tool_approval, tool_denial=tool_denial, transport_loss=transport_loss, natural_completion=natural_completion, history=history) + assert ("transport-hangup" in events) is transport_loss + assert report[key]["stop"]["instanceId"] == "instance" + assert report["fixed_product_target"] == target + assert report["fixed_product_detached"] == target + stopped = report["fixed_product_stop"] + assert stopped["started_at"] <= stopped["observed_at"] <= stopped["local_owner_settled_at"] + assert stopped["result"] == report[key]["stop"] + assert "outer_owner_settled_at" not in stopped + assert events[-1] == ("producer-settled" if delayed_final else "leader-close") + if delayed_final: + assert "fixed_product_first_reply" not in report + if scenario == "reply": + value = report[key] + actions = value["actions"] + reply = actions["visible_reply"] + cumulative = actions["fixed_entry_through_visible_reply"] + assert cumulative["started_at"] == report["spawns"][0]["start"] + assert cumulative["started_at"] <= reply["started_at"] <= reply["finished_at"] + assert cumulative["finished_at"] == reply["finished_at"] + assert value["visible_reply_seconds"] == reply["finished_at"] - reply["started_at"] + assert value["spawn_through_visible_reply_seconds"] == cumulative["finished_at"] - cumulative["started_at"] + else: + with pytest.raises({"snapshot": ValueError, "fallback": ValueError, "frame": ValueError, + "first-read": ValueError, "adopt": ValueError, + "leader-close": OSError, "stop-receipt": TimeoutError, + "record-error": OSError, "record-interrupt": KeyboardInterrupt}[fault]) as caught: + probe.first_reply(tmp_path, report, delayed_final=delayed_final, interrupt_next_turn=interrupt_next_turn, tool_approval=tool_approval, tool_denial=tool_denial, transport_loss=transport_loss, natural_completion=natural_completion, history=history) + assert key not in report + if fault in {"first-read", "adopt", "leader-close", "stop-receipt", "fallback", "record-error", "record-interrupt"}: + assert "fixed_product_stop" not in report + assert events.count("adopt") == (0 if fault == "first-read" else 1) + assert events.count("stop") == (0 if fault in {"first-read", "adopt", "record-error", "record-interrupt"} else 1) + if fault in {"record-error", "record-interrupt"}: + assert events.count("leader-close") == 1 + assert caught.value is record_error + if fault == "fallback": + assert report["fixed_product_cleanup_failure"] == {"type": "OSError"} + + +@pytest.mark.parametrize("fault", [None, "pending", "details", "back", "reply", "effect-before", "effect-after"]) +def test_tool_approval_requires_details_and_no_premature_effect(tmp_path, monkeypatch, fault): + writes, reads, effects = [], [], [] + + class Driver: + raw_output = "" + + def write(self, text): + writes.append(text) + + def read_until(self, *args, **kwargs): + phase = ("pending", "details", "back", "reply")[len(reads)] + reads.append(phase) + if fault == phase: + raise TimeoutError(phase) + + def effect(root, instance, *, executed, not_before_ns=None): + assert instance == "instance" + effects.append(executed) + if executed: + assert writes[-1] == "/approve\r" and reads[-1] == "reply" + assert type(not_before_ns) is int + else: + assert "/approve\r" not in writes + if fault == ("effect-after" if executed else "effect-before"): + raise AssertionError("effect evidence mismatch") + + monkeypatch.setattr(probe, "_tool_effect_witness", effect) + if fault is None: + clock = iter([10.0, 12.0, 20.0, 23.0, 30.0, 34.0]) + monkeypatch.setattr(probe.time, "perf_counter", lambda: next(clock)) + result = probe._approve_first_tool(Driver(), tmp_path, {"instanceId": "instance"}) + assert set(result) == {"approval_pending_seconds", "approval_details_seconds", "approved_tool_reply_seconds", "approved_sent_ns", "actions"} + assert result["actions"] == { + "approval_pending": {"started_at": 10.0, "finished_at": 12.0}, + "approval_details": {"started_at": 20.0, "finished_at": 23.0}, + "approved_tool_reply": {"started_at": 30.0, "finished_at": 34.0}, + } + assert [result[key + "_seconds"] for key in result["actions"]] == [2.0, 3.0, 4.0] + assert writes == ["approval\r", "/question\r", "\x1b", "/approve\r"] + assert effects == [False, False, False, False, True] + else: + with pytest.raises((TimeoutError, AssertionError)): + probe._approve_first_tool(Driver(), tmp_path, {"instanceId": "instance"}) + assert writes.count("/approve\r") <= 1 + if fault in {"pending", "details", "back", "effect-before"}: + assert "/approve\r" not in writes + + +@pytest.mark.parametrize("fault", [None, "pending", "reply", "before", "after"]) +def test_denial_is_once_without_review_and_requires_zero_effects(tmp_path, monkeypatch, fault): + writes, reads, checks = [], [], [] + + class Driver: + raw_output = "" + + def write(self, text): + writes.append(text) + + def read_until(self, *args, **kwargs): + phase = ("pending", "reply")[len(reads)] + reads.append(phase) + if phase == fault: + raise TimeoutError(phase) + + def effect(*args, executed): + assert executed is False + checks.append(False) + if fault == ("after" if "/deny\r" in writes else "before"): + raise AssertionError("unexpected effect") + + monkeypatch.setattr(probe, "_tool_effect_witness", effect) + if fault is None: + probe._deny_first_tool(Driver(), tmp_path, {"instanceId": "instance"}) + assert writes == ["approval\r", "/deny\r"] and len(checks) == 3 + else: + with pytest.raises((TimeoutError, AssertionError)): + probe._deny_first_tool(Driver(), tmp_path, {"instanceId": "instance"}) + assert writes.count("/deny\r") <= 1 + assert "/question\r" not in writes and "/approve\r" not in writes + + +def test_effect_after_last_zero_check_but_before_approval_is_rejected(tmp_path, monkeypatch): + from ._hosted_boundary_trace import BoundaryTrace + + clock, checks, writes = [10], [], [] + monkeypatch.setattr(probe.time, "monotonic_ns", lambda: clock[0]) + root = tmp_path / "lmux-test-observations" + root.mkdir(mode=0o700) + trace = BoundaryTrace(root) + trace.emit("fixed_product_selected", instance_id="instance") + original = probe._tool_effect_witness + + def effect(*args, **kwargs): + result = original(*args, **kwargs) + if not kwargs["executed"]: + checks.append(False) + if len(checks) == 4: + trace.emit("tool_executed", call_id="lmux-call-1") + clock[0] = 100 + return result + + class Driver: + raw_output = "" + + def write(self, text): + writes.append(text) + + def read_until(self, *args, **kwargs): + pass + + monkeypatch.setattr(probe, "_tool_effect_witness", effect) + with pytest.raises(AssertionError, match="before this approval"): + probe._approve_first_tool(Driver(), tmp_path, {"instanceId": "instance"}) + assert checks == [False] * 4 and writes.count("/approve\r") == 1 + + +@pytest.mark.parametrize("fault", [None, "reattach", "idle", "producer", "early-settled", "reply", "snapshot", "native"]) +def test_interrupt_next_turn_never_retries_and_closes_terminal_before_snapshot(tmp_path, monkeypatch, fault): + events, writes, reads = [], [], [] + from loushang.hosting.service import LinuxServiceIdentityV1 + native = LinuxServiceIdentityV1(123, 100, "12345678-1234-1234-1234-123456789abc", 1000, 4, 5) + target = {"instanceId": "instance", "serviceId": "service", "muxId": "mux", "members": []} + + class Driver: + raw_output = "" + diagnostics = None + + def read_until(self, *args, **kwargs): + phase = ("reattach", "idle", "reply")[len(reads)] + reads.append(phase) + if fault == phase: + raise TimeoutError(phase) + + def write(self, text): + writes.append(text) + + def wait(self, **kwargs): + return 0 + + @contextmanager + def terminal(argv, cwd, env, **kwargs): + assert argv[-3:] == ["attach", "-t", "perf"] and cwd == tmp_path / "elsewhere" + try: + yield Driver(), None, None + finally: + events.append("terminal-closed") + + def observe(env, stage, *, native_identity): + native_identity.append(object() if fault == "native" else native) + return target + + @contextmanager + def spawn(executable): + yield {"start": 1.0, "pid": 456, "argv": [executable, "attach", "-t", "perf"], + "cwd": str(tmp_path / "elsewhere")} + + def producer(root, instance, *, settled, not_before_ns=None, evidence=None): + if evidence is not None: + evidence.append({"phase": "producer_started"}) + if not settled: + assert reads == ["reattach"] and writes == [] + if fault == "early-settled": + raise AssertionError("reattach settled producer before interrupt") + return + assert reads[-1] == "idle" and writes == ["\x03"] + assert type(not_before_ns) is int + if fault == "producer": + raise AssertionError("producer not settled") + if evidence is not None: + evidence.append({"phase": "producer_settled"}) + + def confirm(env, actual, expected, *, interrupted_nonce): + assert events == ["terminal-closed"] and actual is target + assert interrupted_nonce == "a" * 32 and expected == "LMUX_REPLY_" + "b" * 32 + if fault == "snapshot": + raise AssertionError("snapshot mismatch") + return {"replyConfirmed": True} + + monkeypatch.setattr(probe, "observed_terminal", terminal) + monkeypatch.setattr(probe, "assert_active_terminal", lambda *args: None) + monkeypatch.setattr(probe, "managed_read_observation", observe) + monkeypatch.setattr(probe, "observe_spawn", spawn) + monkeypatch.setattr(probe, "managed_reply_observation", confirm) + monkeypatch.setattr(probe, "_producer_witness", producer) + monkeypatch.setattr(probe.uuid, "uuid4", lambda: NS(hex="b" * 32)) + report = {"measured_prefix": str(tmp_path / "install")} + if fault is None: + clock = iter([10.0, 12.0, 20.0, 25.0]) + monkeypatch.setattr(probe.time, "perf_counter", lambda: next(clock)) + result = probe._interrupt_next_turn(tmp_path, report, {}, target, [native], "a" * 32) + assert result["next_reply_confirmation"]["replyConfirmed"] is True + assert result["interrupted_nonce"] == "a" * 32 and result["next_nonce"] == "b" * 32 + assert result["producer_before"] == [{"phase": "producer_started"}] + assert result["producer_after"] == [{"phase": "producer_started"}, {"phase": "producer_settled"}] + assert report["spawns"][0]["argv"][-3:] == ["attach", "-t", "perf"] + assert result["actions"] == { + "interrupt_through_idle_and_producer": {"started_at": 10.0, "finished_at": 12.0}, + "next_reply": {"started_at": 20.0, "finished_at": 25.0}, + "interrupt_through_next_reply": {"started_at": 10.0, "finished_at": 25.0}, + } + # The cumulative interval includes the eight-second observation/input gap. + assert result["interrupt_through_next_reply_seconds"] == 15.0 + assert result["interrupt_through_idle_and_producer_seconds"] + result["next_reply_seconds"] == 7.0 + assert writes == ["\x03", "reply " + "b" * 32 + "\r", "\x02d"] + else: + with pytest.raises((TimeoutError, AssertionError)): + probe._interrupt_next_turn(tmp_path, report, {}, target, [native], "a" * 32) + assert events == ["terminal-closed"] + assert sum(text.startswith("reply ") for text in writes) <= 1 + assert writes.count("\x03") <= 1 diff --git a/tests/coding/test_lmux_product_snapshot.py b/tests/coding/test_lmux_product_snapshot.py new file mode 100644 index 000000000..79886e1f1 --- /dev/null +++ b/tests/coding/test_lmux_product_snapshot.py @@ -0,0 +1,282 @@ +import asyncio +import time +from types import SimpleNamespace as NS + +import pytest + +from loushang.appserver.protocol import TranscriptRecordKindV1 as Kind + +from . import _lmux_product_snapshot as observer +from ._lmux_product_snapshot import ( + confirm_denied_tool_reply, + confirm_interrupted_reply, + confirm_natural_reply, + confirm_pending_reply, + confirm_reply, + confirm_tool_reply, +) + + +@pytest.mark.parametrize("pending", [False, True]) +@pytest.mark.parametrize("fault", [None, "duplicate", "wrong-nonce", "wrong-state", "error"]) +def test_natural_snapshot_requires_exact_original_and_next_turn(pending, fault): + identity, detached = object(), [] + nonce, next_nonce = "a" * 32, "b" * 32 + expected = "LMUX_REPLY_" + (nonce if pending else next_nonce) + records = [(Kind.USER, "gated " + ("c" * 32 if fault == "wrong-nonce" else nonce))] + if not pending: + records += [(Kind.ASSISTANT, "LMUX_REPLY_" + nonce), + (Kind.USER, "reply " + next_nonce), (Kind.ASSISTANT, expected)] + if fault == "duplicate": + records.append(records[-1]) + if fault == "error": + records.append((Kind.ERROR, "failure")) + + class Client: + async def attach_mux(self, request): + return NS(attachment_id="attachment", controller_generation=1, + mux_space=NS(mux_space_id="mux", members=( + NS(member_id="member", session=identity, title="FirstUse"),))) + + async def snapshot_session(self, request): + return NS(identity=identity, running=not pending if fault == "wrong-state" else pending, + records=tuple(NS(kind=k, text=t) for k, t in records)) + + async def detach_mux(self, request): + detached.append(request) + + async def run(): + return await confirm_natural_reply(Client(), mux_id="mux", member_id="member", + identity=identity, expected=expected, natural_nonce=nonce, pending=pending, + deadline=time.monotonic() + 5) + if fault is None: + asyncio.run(run()) + else: + with pytest.raises(AssertionError): + asyncio.run(run()) + assert len(detached) == 1 + + +@pytest.mark.parametrize("denied", [False, True]) +@pytest.mark.parametrize("fault", [None, "duplicate", "wrong-user", "missing-call", "wrong-reply", "running", "error"]) +def test_tool_snapshot_is_fresh_exact_formal_result(fault, denied): + identity, detached = object(), [] + expected = "Tool lmux_evidence requires approval" if denied else "LMUX_TOOL_COMPLETED" + unexpected = "LMUX_TOOL_COMPLETED" if denied else "Tool lmux_evidence requires approval" + records = [(Kind.USER, "wrong" if fault == "wrong-user" else "approval"), + (Kind.ASSISTANT, ""), (Kind.ASSISTANT, unexpected if fault == "wrong-reply" else expected)] + if fault == "duplicate": + records.append(records[-1]) + elif fault == "missing-call": + records.pop(1) + elif fault == "error": + records.append((Kind.ERROR, "failure")) + + class Client: + async def attach_mux(self, request): + return NS(attachment_id="attachment", controller_generation=1, + mux_space=NS(mux_space_id="mux", members=( + NS(member_id="member", session=identity, title="FirstUse"),))) + + async def snapshot_session(self, request): + return NS(identity=identity, running=fault == "running", title="Coding", + records=tuple(NS(kind=k, text=t) for k, t in records)) + + async def detach_mux(self, request): + detached.append(request) + + async def run(): + confirm = confirm_denied_tool_reply if denied else confirm_tool_reply + return await confirm(Client(), mux_id="mux", member_id="member", identity=identity, + expected=expected, deadline=time.monotonic() + 5) + if fault is None: + assert asyncio.run(run()).running is False + else: + with pytest.raises(AssertionError): + asyncio.run(run()) + assert len(detached) == 1 + + +@pytest.mark.parametrize("expected", ["LMUX_TOOL_COMPLETED", "", "Tool another requires approval"]) +def test_denial_snapshot_rejects_wrong_witness_before_attaching(expected): + with pytest.raises(ValueError, match="denied tool reply witness"): + asyncio.run(confirm_denied_tool_reply(object(), mux_id="mux", member_id="member", + identity=object(), expected=expected, deadline=time.monotonic() + 5)) + + +@pytest.mark.parametrize("fault", [None, "missing-empty", "extra-empty", "old-success", "duplicate-b", "reordered", "running", "identity", "error"]) +def test_reply_after_interrupt_requires_exact_order_and_one_new_reply(fault): + identity = object() + prior, next_nonce = "a" * 32, "b" * 32 + expected = "LMUX_REPLY_" + next_nonce + records = [(Kind.USER, "delayed " + prior), (Kind.ASSISTANT, ""), + (Kind.USER, "reply " + next_nonce), (Kind.ASSISTANT, expected)] + if fault == "missing-empty": + records.pop(1) + elif fault == "extra-empty": + records.insert(2, (Kind.ASSISTANT, "")) + elif fault == "old-success": + records[1] = (Kind.ASSISTANT, "LMUX_REPLY_" + prior) + elif fault == "duplicate-b": + records.append((Kind.ASSISTANT, expected)) + elif fault == "reordered": + records[0], records[2] = records[2], records[0] + elif fault == "error": + records.append((Kind.ERROR, "failure")) + detached = [] + + class Client: + async def attach_mux(self, request): + return NS(attachment_id="attachment", controller_generation=1, + mux_space=NS(mux_space_id="mux", members=( + NS(member_id="member", session=identity, title="FirstUse"),))) + + async def snapshot_session(self, request): + return NS(identity=object() if fault == "identity" else identity, title="Coding", + running=fault == "running", records=tuple(NS(kind=k, text=t) for k, t in records)) + + async def detach_mux(self, request): + detached.append(request) + + async def run(): + return await confirm_interrupted_reply(Client(), mux_id="mux", member_id="member", identity=identity, + expected=expected, interrupted_nonce=prior, + deadline=time.monotonic() + 5) + + if fault is None: + assert asyncio.run(run()).running is False + else: + with pytest.raises(AssertionError): + asyncio.run(run()) + assert len(detached) == 1 + + +@pytest.mark.parametrize("fault", [None, "finished", "committed", "wrong-request", "error", "detach"]) +def test_delayed_snapshot_is_running_without_a_committed_reply(fault): + identity = object() + nonce = "b" * 32 + calls = [] + + class Client: + async def attach_mux(self, request): + calls.append("attach") + return NS(attachment_id="attachment", controller_generation=1, + mux_space=NS(mux_space_id="mux", members=( + NS(member_id="member", session=identity, title="FirstUse"),))) + + async def snapshot_session(self, request): + calls.append("snapshot") + records = [NS(kind=Kind.USER, text="delayed " + ("a" * 32 if fault == "wrong-request" else nonce))] + if fault == "committed": + records.append(NS(kind=Kind.ASSISTANT, text="LMUX_REPLY_" + nonce)) + if fault == "error": + records.append(NS(kind=Kind.ERROR, text="error")) + return NS(identity=identity, title="Coding", running=fault != "finished", records=records) + + async def detach_mux(self, request): + calls.append("detach") + if fault == "detach": + raise RuntimeError("detach failed") + + async def run(): + return await confirm_pending_reply(Client(), mux_id="mux", member_id="member", identity=identity, + expected="LMUX_REPLY_" + nonce, deadline=time.monotonic() + 5) + + if fault is None: + assert asyncio.run(run()).running is True + else: + with pytest.raises(RuntimeError if fault == "detach" else AssertionError): + asyncio.run(run()) + assert calls == ["attach", "snapshot", "detach"] + + +@pytest.mark.parametrize("fault", [None, "mux", "member", "member-title", "identity", "running", "duplicate", "error", "detach", "missing-user", "wrong-user", "extra-user", "reordered"]) +def test_snapshot_confirmation_requires_exact_committed_reply_and_detaches(fault): + expected = "LMUX_REPLY_" + "a" * 32 + identity = object() + calls = [] + member = NS(member_id="wrong" if fault == "member" else "member", session=identity, + title="Wrong" if fault == "member-title" else "FirstUse") + records = [NS(kind=Kind.USER, text="reply " + "a" * 32), NS(kind=Kind.ASSISTANT, text=expected)] + if fault == "missing-user": + records.pop(0) + elif fault == "wrong-user": + records[0].text = "reply " + "b" * 32 + elif fault == "extra-user": + records.insert(0, NS(kind=Kind.USER, text="old request")) + elif fault == "reordered": + records.reverse() + if fault == "duplicate": + records *= 2 + if fault == "error": + records.append(NS(kind=Kind.ERROR, text="failed")) + snapshot = NS(identity=object() if fault == "identity" else identity, + title="Coding", running=fault == "running", records=records) + + class Client: + async def attach_mux(self, request): + calls.append("attach") + assert request.selector.mux_space_id == "mux" + return NS(attachment_id="attachment", controller_generation=1, + mux_space=NS(mux_space_id="wrong" if fault == "mux" else "mux", members=(member,))) + + async def snapshot_session(self, request): + calls.append("snapshot") + assert (request.attachment_id, request.controller_generation, request.member_id) == ("attachment", 1, "member") + return snapshot + + async def detach_mux(self, request): + calls.append("detach") + assert (request.attachment_id, request.controller_generation) == ("attachment", 1) + if fault == "detach": + raise RuntimeError("detach failed") + + async def run(): + return await confirm_reply(Client(), mux_id="mux", member_id="member", identity=identity, + expected=expected, deadline=time.monotonic() + 5) + + if fault is None: + assert asyncio.run(run()) is snapshot + else: + with pytest.raises(RuntimeError if fault == "detach" else AssertionError): + asyncio.run(run()) + assert calls[0] == "attach" and calls[-1] == "detach" + + +@pytest.mark.parametrize("deadline", [0, float("nan"), float("inf")]) +def test_invalid_or_expired_deadline_does_not_dispatch(deadline): + class Client: + def attach_mux(self, request): + pytest.fail("expired observation dispatched attach") + + with pytest.raises(TimeoutError if deadline == 0 else ValueError): + asyncio.run(confirm_reply(Client(), mux_id="mux", member_id="member", identity=object(), + expected="LMUX_REPLY_" + "a" * 32, deadline=deadline)) + + +def test_late_synchronous_snapshot_is_not_success(monkeypatch): + clock = [100.0] + monkeypatch.setattr(observer.time, "monotonic", lambda: clock[0]) + identity = object() + calls = [] + + class Client: + async def attach_mux(self, request): + calls.append("attach") + return NS(attachment_id="attachment", controller_generation=1, + mux_space=NS(mux_space_id="mux", members=(NS(member_id="member", session=identity, title="FirstUse"),))) + + async def snapshot_session(self, request): + calls.append("snapshot") + clock[0] = 102.0 + return NS(identity=identity, title="FirstUse", running=False, + records=(NS(kind=Kind.ASSISTANT, text="LMUX_REPLY_" + "a" * 32),)) + + def detach_mux(self, request): + pytest.fail("deadline-expired detach must be left to outer connection close") + + with pytest.raises(TimeoutError, match="response arrived") as failure: + asyncio.run(confirm_reply(Client(), mux_id="mux", member_id="member", identity=identity, + expected="LMUX_REPLY_" + "a" * 32, deadline=101.0)) + assert calls == ["attach", "snapshot"] + assert any("detach failed: TimeoutError" in note for note in failure.value.__notes__) diff --git a/tests/coding/test_lmux_pty_hangup.py b/tests/coding/test_lmux_pty_hangup.py new file mode 100644 index 000000000..5892d42e3 --- /dev/null +++ b/tests/coding/test_lmux_pty_hangup.py @@ -0,0 +1,225 @@ +"""Real transport-loss stimulus, using the original POSIX driver owner.""" +from __future__ import annotations + +import errno +import os +import sys +import threading + +import pytest + +from tests.tui.terminal_process_support.posix_pty import PosixPtyDriver + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux PTY hangup") + + +EOF_READER = """ +import errno, os +from pathlib import Path +print('READY', flush=True) +try: + value = os.read(0, 1) + assert value == b'' +except OSError as error: + assert error.errno == errno.EIO +Path('hung-up').touch() +""" + + +def spawn_reader(tmp_path): + driver = PosixPtyDriver.spawn( + [sys.executable, "-c", EOF_READER], cwd=tmp_path, env=dict(os.environ), + columns=80, rows=24, + ) + driver.read_until(lambda text: "READY" in text, timeout=10) + assert driver.is_alive() + return driver + + +def test_abrupt_context_records_transport_not_unobservable_termios(tmp_path): + from tests.coding._g18_native_probe import abrupt_terminal + + report = {} + with abrupt_terminal( + [sys.executable, "-c", EOF_READER], tmp_path, dict(os.environ), + failure_report=report, + ) as (driver, master, original): + driver.read_until(lambda text: "READY" in text, timeout=10) + assert master == driver._master_fd and original + assert driver.hangup_transport(timeout=10) == 0 + assert report["terminal_transport"] == { + "stimulus": "pty-master-close", "client_settled": True, + "client_exit_status": 0, "client_exit_clean": True, + "terminal_mode_restoration": "not-observable-after-hangup", + } + assert driver._closed and not driver.diagnostics.reader_alive + assert driver.diagnostics.termination is None + + +def test_already_exited_client_is_not_a_successful_transport_hangup(tmp_path): + driver = PosixPtyDriver.spawn( + [sys.executable, "-c", "pass"], cwd=tmp_path, env=dict(os.environ), + columns=80, rows=24, + ) + try: + assert driver.wait(timeout=10) == 0 + with pytest.raises(RuntimeError, match="exited before transport hangup"): + driver.hangup_transport(timeout=10) + assert driver._transport_state == "open" + os.fstat(driver._master_fd) + finally: + driver.close() + + +def test_hangup_is_real_eof_and_close_does_not_close_reused_fd(tmp_path): + driver = spawn_reader(tmp_path) + replacement = None + try: + old_fd = driver._master_fd + assert driver.hangup_transport(timeout=10) == 0 + assert (tmp_path / "hung-up").is_file() + assert not driver.diagnostics.reader_alive + assert driver.diagnostics.termination is None + replacement = os.open(os.devnull, os.O_RDONLY) + if replacement != old_fd: + os.dup2(replacement, old_fd) + os.close(replacement) + replacement = old_fd + assert driver.hangup_transport(timeout=2) == 0 + with pytest.raises(RuntimeError, match="fenced"): + driver.write("x") + with pytest.raises(RuntimeError, match="fenced"): + driver.resize(columns=90, rows=30) + driver.close() + os.fstat(replacement) + finally: + driver.close() + if replacement is not None: + os.close(replacement) + + +def test_lost_hangup_close_receipt_never_recloses_reused_fd(tmp_path, monkeypatch): + driver = spawn_reader(tmp_path) + original = os.close + target = driver._master_fd + replacement = None + closes = 0 + + def lose_receipt(fd): + nonlocal replacement, closes + if fd != target: + return original(fd) + closes += 1 + original(fd) + replacement = os.open(os.devnull, os.O_RDONLY) + if replacement != target: + os.dup2(replacement, target) + original(replacement) + replacement = target + raise OSError(errno.EIO, "lost close receipt") + + try: + monkeypatch.setattr(os, "close", lose_receipt) + with pytest.raises(OSError, match="lost close receipt"): + driver.hangup_transport(timeout=10) + assert driver.wait(timeout=10) == 0 + for operation in (driver.close, lambda: driver.hangup_transport(timeout=1), driver.close): + with pytest.raises(RuntimeError, match="outcome unknown"): + operation() + assert closes == 1 + os.fstat(replacement) + assert driver.diagnostics.termination is None + finally: + monkeypatch.setattr(os, "close", original) + # Test-only resolution: this injector knows the original close succeeded. + driver._transport_state = "closed" + driver.close() + if replacement is not None: + original(replacement) + + +@pytest.mark.parametrize("timeout_first", [False, True, "close-retry"]) +def test_inflight_reader_query_settles_without_writer_lock_deadlock(tmp_path, monkeypatch, timeout_first): + entered, release = threading.Event(), threading.Event() + driver = PosixPtyDriver.spawn( + [sys.executable, "-c", "import os; print('READY', flush=True); os.read(0, 1); print('QUERY', flush=True); os.read(0, 1)"], + cwd=tmp_path, env=dict(os.environ), columns=80, rows=24, + ) + original_record = driver._record_output + errors = [] + waiter = None + + def record(text): + if "QUERY" in text: + entered.set() + assert release.wait(10) + driver.write("terminal query reply") # Fenced reader replies are suppressed. + original_record(text) + + def hangup(): + try: + driver.hangup_transport(timeout=10) + except BaseException as error: + errors.append(error) + + try: + driver.read_until(lambda text: "READY" in text, timeout=10) + monkeypatch.setattr(driver, "_record_output", record) + driver.write("\n") + assert entered.wait(10) + if timeout_first: + with pytest.raises(TimeoutError, match="reader did not settle"): + driver.hangup_transport(timeout=0.01) + assert driver._transport_state == "fenced" + os.fstat(driver._master_fd) # Still owned; reader has not settled. + assert driver.is_alive() + if timeout_first == "close-retry": + with pytest.raises(TimeoutError): + driver.close(timeout=0.01) + assert not driver._closed + os.fstat(driver._master_fd) + waiter = threading.Thread(target=hangup) + waiter.start() + assert driver._stop_reader.wait(10) + release.set() + waiter.join(10) + assert not waiter.is_alive() + assert not errors + assert driver._reader_error is None + assert (driver.diagnostics.termination is not None) is (timeout_first == "close-retry") + finally: + release.set() + if waiter is not None: + waiter.join(10) + driver.close() + + +@pytest.mark.parametrize("lock_name", ["_close_lock", "_writer_lock"]) +def test_hangup_lock_deadline_has_no_transport_effect(tmp_path, lock_name): + driver = spawn_reader(tmp_path) + lock = getattr(driver, lock_name) + try: + lock.acquire() + try: + with pytest.raises(TimeoutError, match="deadline"): + driver.hangup_transport(timeout=0.01) + finally: + lock.release() + assert driver._transport_state == "open" + assert driver.is_alive() + os.fstat(driver._master_fd) + assert driver.hangup_transport(timeout=10) == 0 + finally: + driver.close() + + +@pytest.mark.parametrize("timeout", [float("nan"), float("inf"), -1]) +def test_hangup_invalid_timeout_has_no_transport_effect(tmp_path, timeout): + driver = spawn_reader(tmp_path) + try: + with pytest.raises(ValueError, match="finite"): + driver.hangup_transport(timeout=timeout) + assert driver._transport_state == "open" + assert driver.hangup_transport(timeout=10) == 0 + finally: + driver.close() diff --git a/tests/coding/test_lmux_read_observer.py b/tests/coding/test_lmux_read_observer.py new file mode 100644 index 000000000..9b98bb8dc --- /dev/null +++ b/tests/coding/test_lmux_read_observer.py @@ -0,0 +1,301 @@ +"""Fault boundaries of the process-only test observer, not a new runtime port.""" + +from __future__ import annotations + +import asyncio +import os +from types import SimpleNamespace + +import pytest + +from loushang.apphost.managed import ( + connection, + defaults, + discovery, + lifecycle, + mux_management, + namespace_admission, + paths, +) +from loushang.coding.cli import mux as mux_cli + +from ._g18_native_probe import ( + managed_history_confirmation, + managed_history_observation, + managed_read_observation, + managed_reply_observation, +) + + +@pytest.fixture +def observer_runners(monkeypatch): + original, runners = asyncio.Runner, [] + + def retained_runner(): + runner = original() + runners.append(runner) + return runner + + monkeypatch.setattr(asyncio, "Runner", retained_runner) + try: + yield + finally: + # Production would exit its observer process on cleanup debt. Our + # intercepted hard exit must not leak that process's fake-resource loop. + for runner in runners: + runner.close() + + +@pytest.mark.parametrize("fault", [ + None, "prepare-cancel", "read-cancel", "connection-close", "journal-close", + "namespace-close", "runner-start", "native-missing", "native-replaced", + "reply-ok", "pending-ok", "attach-lost", "snapshot-cancel", + "history-ok", "history-seed-error", "history-close", "history-late-read", "history-late-verify", + "warm-ok", "warm-tail", "warm-running", "warm-identity", "warm-snapshot-id", "warm-close", "warm-detach", +]) +def test_managed_read_retains_borrowed_owners_until_connection_settles(monkeypatch, tmp_path, fault, observer_runners): + events = [] + clock = [0.0] + if fault == "history-late-read": + from . import _g18_native_probe + monkeypatch.setattr(_g18_native_probe, "time", SimpleNamespace(monotonic=lambda: clock[0], perf_counter=lambda: clock[0])) + retained = {} + native, identities = object(), [] + from loushang.appserver.protocol import SessionScopeV1 + session = SimpleNamespace(product_id="coding", continuity_id="continuity-1", + session_id="session-1", scope=SessionScopeV1.USER_HOME, + scope_fingerprint="c" * 64) + + class Namespace: + def __init__(self, *args, **kwargs): + self.cleanup_pending = True + retained["namespace"] = self + + def open(self, **kwargs): + events.append("namespace-open") + return object() + + def close(self): + assert "connection" not in retained or not retained["connection"].cleanup_pending + assert "journal" not in retained or retained["journal"].closed + events.append("namespace-close") + if fault == "namespace-close": + raise OSError("namespace still owned") + self.cleanup_pending = False + + class Journal: + def __init__(self, *args, **kwargs): + self.closed = False + retained["journal"] = self + + def open(self, **kwargs): + events.append("journal-open") + + def read(self, **kwargs): + assert not self.closed + assert kwargs["wait_for_lock"] is True + with pytest.raises(RuntimeError): + asyncio.get_running_loop() + return SimpleNamespace( + handoff=SimpleNamespace(instance=object() if fault == "native-replaced" else retained["connection"].instance), + native_identity=None if fault == "native-missing" else native, + ) + + def close(self): + assert "connection" not in retained or not retained["connection"].cleanup_pending + events.append("journal-close") + if fault == "journal-close": + raise OSError("journal still owned") + self.closed = True + + class Connection: + def __init__(self, *args, **kwargs): + self.cleanup_pending = True + self.instance = SimpleNamespace(instance_id="a" * 32) + self.application_id = "coding.default" + self.client = self + retained["connection"] = self + + async def prepare(self, **kwargs): + events.append("prepare") + if fault == "prepare-cancel": + raise asyncio.CancelledError() + + async def read_mux(self, request): + events.append("read") + if fault == "history-late-read": + clock[0] = 31.0 + assert request.selector.mux_space_id == "mux-perf" + if fault == "read-cancel": + raise asyncio.CancelledError() + return SimpleNamespace(mux_space_id="mux-perf", name="perf", members=( + SimpleNamespace(member_id="member-1", session=session, title="FirstUse"), + )) + + async def close(self): + events.append("connection-close") + if fault in {"connection-close", "history-close", "warm-close"}: + raise TimeoutError("connection still owns native IO") + self.cleanup_pending = False + + async def attach_mux(self, request): + events.append("attach") + if fault == "attach-lost": + raise TimeoutError("attachment accepted but response lost") + return SimpleNamespace( + attachment_id="attachment", controller_generation=1, + mux_space=await self.read_mux(request), + ) + + async def snapshot_session(self, request): + from loushang.appserver.protocol import TranscriptRecordKindV1 + events.append("snapshot") + if fault is not None and fault.startswith("warm-"): + from ._lmux_history_recipe import OMITTED, history_records + rows = [["status", OMITTED], *history_records()[-14:]] + if fault == "warm-tail": + rows[-1][1] = "incorrect final text" + return SimpleNamespace(identity=object() if fault == "warm-snapshot-id" else session, + running=fault == "warm-running", + records=[SimpleNamespace(kind=TranscriptRecordKindV1(kind), text=text) for kind, text in rows]) + if fault == "snapshot-cancel": + raise asyncio.CancelledError() + records = [SimpleNamespace(kind=TranscriptRecordKindV1.USER, + text=("delayed " if fault == "pending-ok" else "reply ") + "a" * 32)] + if fault != "pending-ok": + records.append(SimpleNamespace(kind=TranscriptRecordKindV1.ASSISTANT, text="LMUX_REPLY_" + "a" * 32)) + return SimpleNamespace(identity=session, title="Coding", running=fault == "pending-ok", records=records) + + async def detach_mux(self, request): + events.append("detach") + if fault == "warm-detach": + raise TimeoutError("detach response lost") + + item = SimpleNamespace(service=SimpleNamespace(service_id="b" * 64), + instance=SimpleNamespace(instance_id="a" * 32), reservation=object()) + monkeypatch.setattr(defaults, "resolve_managed_defaults", lambda **kwargs: SimpleNamespace( + namespace=object(), platform=SimpleNamespace(runtime=tmp_path), + )) + monkeypatch.setattr(namespace_admission, "ManagedNamespaceAdmissionV1", Namespace) + monkeypatch.setattr(lifecycle, "ManagedServiceJournalV1", Journal) + monkeypatch.setattr(connection, "ManagedConnectionLeaseV1", Connection) + monkeypatch.setattr(discovery, "ManagedDiscoveryV1", lambda *args: SimpleNamespace(resolve=lambda *a, **k: item)) + monkeypatch.setattr(paths, "resolve_managed_service_paths", lambda *a, **k: SimpleNamespace(lifecycle=tmp_path)) + monkeypatch.setattr(mux_management, "ManagedMuxManagerV1", lambda *a, **k: SimpleNamespace( + inspect_mux=lambda *a, **k: SimpleNamespace(creation=SimpleNamespace(mux_space_id="mux-perf")), + )) + + def hard_exit(code): + events.append("hard-exit") + raise RuntimeError("outer observer must classify unresolved cleanup as failure") + + monkeypatch.setattr(os, "_exit", hard_exit) + if fault == "runner-start": + def refused(*args): + raise RuntimeError("runner unavailable") + monkeypatch.setattr(mux_cli, "_execute", refused) + if fault is not None and fault.startswith("warm-"): + target = {"instanceId": "a" * 32, "serviceId": "b" * 64, "muxId": "mux-perf", + "members": [{"memberId": "member-1", "sessionId": "session-1"}]} + expected = {**vars(session), "scope": session.scope.value} + if fault == "warm-identity": + expected["continuity_id"] = "different-continuity" + if fault == "warm-ok": + result = managed_history_confirmation({}, target, expected, native_identity=identities) + assert result["history_snapshot"]["identity"] == expected + assert result["history_snapshot"]["running"] is False + assert len(result["history_snapshot"]["records"]) == 15 + assert result["observed_at"] <= result["history_snapshot"]["confirmed_at"] <= result["connection_settled_at"] + assert identities == [native] + else: + with pytest.raises(RuntimeError): + managed_history_confirmation({}, target, expected, native_identity=identities) + if fault == "warm-identity": + assert "attach" not in events and "snapshot" not in events + else: + assert events.index("attach") < events.index("snapshot") < events.index("detach") < events.index("connection-close") + if fault != "warm-close": + assert events.index("connection-close") < events.index("journal-close") < events.index("namespace-close") + elif fault in {"history-ok", "history-seed-error", "history-close", "history-late-read", "history-late-verify"}: + import time + + from . import _g18_native_probe, _lmux_history_seed + offset = [0.0] + if fault == "history-late-verify": + # Advance only this observer's clock; never change asyncio's clock. + monkeypatch.setattr(_g18_native_probe, "time", SimpleNamespace(**( + vars(time) | {"monotonic": lambda: time.monotonic() + offset[0]}))) + target = {"instanceId": "a" * 32, "serviceId": "b" * 64, "muxId": "mux-perf", + "members": [{"memberId": "member-1", "sessionId": "session-1"}]} + async def seed(client, **kwargs): + events.append("history-seed") + assert 650 < kwargs["deadline"] - time.monotonic() <= 660 + assert kwargs["identity"] is session + if fault == "history-seed-error": + raise ValueError("seed failed after admission") + if fault == "history-late-verify": + offset[0] = 700.0 + return {"rounds": ["settled"]} + monkeypatch.setattr(_lmux_history_seed, "seed_attached_history", seed) + if fault == "history-ok": + result = managed_history_observation({}, target) + assert result["history_seed"] == {"rounds": ["settled"]} + receipt = result["verification"] + assert receipt["deadline"] == receipt["started_at"] + 660 + assert result["observed_at"] <= receipt["started_at"] <= receipt["completed_at"] < receipt["deadline"] + assert receipt["completed_at"] <= result["connection_settled_at"] + else: + with pytest.raises(RuntimeError) as caught: + managed_history_observation({}, target) + if fault == "history-late-verify": + assert isinstance(caught.value.__cause__, TimeoutError) + assert str(caught.value.__cause__) == "managed verification returned after deadline" + if fault == "history-late-read": + assert "history-seed" not in events and "attach" not in events + else: + assert events.index("history-seed") < events.index("connection-close") + if fault != "history-close": + assert events.index("connection-close") < events.index("journal-close") < events.index("namespace-close") + elif fault in {"reply-ok", "pending-ok", "attach-lost", "snapshot-cancel"}: + target = {"instanceId": "a" * 32, "serviceId": "b" * 64, "muxId": "mux-perf", + "members": [{"memberId": "member-1", "sessionId": "session-1"}]} + if fault in {"reply-ok", "pending-ok"}: + result = managed_reply_observation({}, target, "LMUX_REPLY_" + "a" * 32, pending=fault == "pending-ok") + assert result["pendingConfirmed" if fault == "pending-ok" else "replyConfirmed"] is True + assert ("replyConfirmed" in result) is (fault == "reply-ok") + snapshot = result["snapshot"] + assert snapshot["identity"]["session_id"] == "session-1" + assert snapshot["identity"]["scope"] == SessionScopeV1.USER_HOME.value + assert snapshot["confirmed_at"] >= result["observed_at"] + assert snapshot["records"][0] == {"kind": "user", "text": ("delayed " if fault == "pending-ok" else "reply ") + "a" * 32} + assert len(snapshot["records"]) == (1 if fault == "pending-ok" else 2) + else: + with pytest.raises(asyncio.CancelledError if fault.endswith("cancel") else RuntimeError) as failure: + managed_reply_observation({}, target, "LMUX_REPLY_" + "a" * 32) + if fault == "attach-lost": + assert isinstance(failure.value.__cause__, TimeoutError) + assert str(failure.value.__cause__) == "attachment accepted but response lost" + assert events.index("attach") < events.index("connection-close") + assert events.index("connection-close") < events.index("journal-close") < events.index("namespace-close") + assert ("detach" in events) is (fault != "attach-lost") + elif fault is None: + result = managed_read_observation({}, "first-member", native_identity=identities) + assert identities == [native] + assert result["members"] == [{"memberId": "member-1", "sessionId": "session-1"}] + assert events.index("connection-close") < events.index("journal-close") < events.index("namespace-close") + else: + with pytest.raises(asyncio.CancelledError if fault.endswith("cancel") else RuntimeError) as failure: + managed_read_observation({}, "first-member", native_identity=identities) + if fault in {"native-missing", "native-replaced"}: + assert isinstance(failure.value.__cause__, AssertionError) + assert identities == [] + if fault in {"connection-close", "history-close", "warm-close"}: + assert retained["connection"].cleanup_pending + assert "journal-close" not in events and "namespace-close" not in events + elif fault == "journal-close": + assert not retained["journal"].closed and "namespace-close" not in events + elif fault == "namespace-close": + assert retained["namespace"].cleanup_pending + else: + assert not retained["namespace"].cleanup_pending + assert ("hard-exit" in events) is (fault in {"connection-close", "history-close", "warm-close", "journal-close", "namespace-close"}) diff --git a/tests/coding/test_lmux_startup_diagnostic.py b/tests/coding/test_lmux_startup_diagnostic.py new file mode 100644 index 000000000..96401c0eb --- /dev/null +++ b/tests/coding/test_lmux_startup_diagnostic.py @@ -0,0 +1,109 @@ +"""Diagnostic reads must neither change owners nor expose exception content.""" + +import asyncio +from types import SimpleNamespace as NS + +import pytest + +from ._lmux_startup_diagnostic import _task, observe_startup + + +@pytest.mark.parametrize("sink_fails", [False, True]) +def test_drive_failure_is_observed_before_cleanup_and_preserves_primary(sink_fails): + records = [] + primary = KeyboardInterrupt("private error message") + + class Child: + _startup_deadline = 0.0 + _committed = False + _failure = None + _prepare_task = _activate_task = None + + async def _drive(self): + raise primary + + child = Child() + application = NS(_start_task=None) + + class Bootstrap: + def bind(self, app): + assert app is application + return child + + original_drive = Child._drive + + def emit(phase, **fields): + records.append((phase, fields)) + if sink_fails: + raise RuntimeError("sink failed") + + async def scenario(): + with observe_startup(Bootstrap, emit): + assert Bootstrap().bind(application) is child + with pytest.raises(KeyboardInterrupt) as caught: + await child._drive() + assert caught.value is primary + assert [phase for phase, _ in records] == ["startup_drive_failed"] + assert child._failure is None + assert Child._drive is original_drive + + asyncio.run(scenario()) + assert [phase for phase, _ in records] == ["startup_drive_failed", "startup_post_return"] + assert "private error message" not in repr(records) + + +@pytest.mark.parametrize("state", ["absent", "pending", "cancelled", "done"]) +def test_task_exception_is_read_only_when_done_and_not_cancelled(state): + reads = [] + task = NS(done=lambda: state != "pending", cancelled=lambda: state == "cancelled", + exception=lambda: reads.append(True)) + assert _task(None if state == "absent" else task)["state"] == state + assert reads == ([True] if state == "done" else []) + + +@pytest.mark.parametrize("fault", [None, "primary", "sink", "bind"]) +def test_startup_diagnostic_borrows_original_bind_and_preserves_failure(fault): + events, records = [], [] + secret = "secret-marker-not-for-diagnostics" + primary = KeyboardInterrupt(secret) + task = NS(done=lambda: True, cancelled=lambda: False, exception=lambda: ValueError(secret)) + child = NS(_startup_deadline=0.0, _committed=False, _failure=ValueError(secret), + _prepare_task=task, _activate_task=None) + application = NS(_start_task=task) + + class Bootstrap: + def bind(self, value, **kwargs): + assert value is application and kwargs == {"startup_timeout": 30} + events.append("bind") + if fault == "bind": + raise primary + return child + + original = Bootstrap.bind + + def emit(phase, **fields): + assert Bootstrap.bind is original and events[-1] == "entry-returned" + records.append((phase, fields)) + if fault == "sink": + raise KeyboardInterrupt("diagnostic sink failed") + + def run(): + with observe_startup(Bootstrap, emit): + assert Bootstrap().bind(application, startup_timeout=30) is child + assert records == [] + events.append("entry-returned") + if fault == "primary": + raise primary + return 7 + + if fault in {"primary", "bind"}: + with pytest.raises(KeyboardInterrupt) as caught: + run() + assert caught.value is primary + else: + assert run() == 7 + assert Bootstrap.bind is original and events.count("bind") == 1 + assert len(records) == (0 if fault == "bind" else 1) + assert secret not in repr(records) + if records: + assert records[0][1]["prepare"] == {"state": "done", "error": {"type": "ValueError", "code": "other"}} diff --git a/tests/coding/test_lmux_synthetic_product.py b/tests/coding/test_lmux_synthetic_product.py new file mode 100644 index 000000000..2c495636a --- /dev/null +++ b/tests/coding/test_lmux_synthetic_product.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +import asyncio +import runpy +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from loushang.harness.tools.execution import AuthorizedExecution + +from ._lmux_synthetic_product import components, run_product + + +def test_history_stream_works_in_fixed_child_run_path_context(): + from ._lmux_history_recipe import history_turn + + namespace = runpy.run_path(str(Path(__file__).with_name("_lmux_synthetic_product.py"))) + assert not namespace["__package__"] + + async def check(): + model, stream, _ = namespace["components"](lambda *_: None) + for index in (0, 127): + request, expected = history_turn(index) + response = await stream(model, context(request)) + try: + message = await response.result() + assert message.content[0].text == expected + finally: + await response.aclose() + + asyncio.run(check()) + + +def context(text, role="user"): + return SimpleNamespace(messages=[SimpleNamespace(role=role, content=text)]) + + +def test_unique_reply_is_bound_to_the_current_request(): + async def check(): + model, stream, _ = components(lambda *_: None) + for token in ("a" * 32, "b" * 32): + response = await stream(model, context("reply " + token)) + try: + message = await response.result() + assert message.content[0].text == "LMUX_REPLY_" + token + finally: + await response.aclose() + + asyncio.run(check()) + + +def test_complete_streamed_text_does_not_supply_a_final_message(): + async def check(): + model, stream, _ = components(lambda *_: None) + response = await stream(model, context("delayed " + "a" * 32)) + try: + events = response.__aiter__() + assert (await anext(events))["type"] == "start" + delta = await anext(events) + assert delta["type"] == "text_delta" and delta["delta"] == "LMUX_REPLY_" + "a" * 32 + with pytest.raises(TimeoutError): + await asyncio.wait_for(response.result(), timeout=0.02) + finally: + await response.aclose() + + asyncio.run(check()) + + +@pytest.mark.parametrize("cancel", [False, True]) +def test_gated_original_producer_finishes_only_after_release(cancel): + async def check(): + started, release = asyncio.Event(), asyncio.Event() + events = [] + token = "c" * 32 + + async def gate(actual): + assert actual == token + started.set() + await release.wait() + + model, stream, _ = components(lambda *row: events.append(row), completion_gate=gate) + response = await stream(model, context("gated " + token)) + try: + await asyncio.wait_for(started.wait(), 1) + assert events == [("producer_started", "lmux-call-1")] + if cancel: + await response.aclose() + else: + release.set() + message = await asyncio.wait_for(response.result(), 1) + assert message.content[0].text == "LMUX_REPLY_" + token + finally: + await response.aclose() + expected = [("producer_started", "lmux-call-1")] + if not cancel: + expected += [("gate_released", "lmux-call-1"), ("final_emitted", "lmux-call-1")] + assert events == [*expected, ("producer_settled", "lmux-call-1")] + + asyncio.run(check()) + + +def test_gated_request_does_not_fall_back_without_explicit_test_gate(): + async def check(): + model, stream, _ = components(lambda *_: None) + with pytest.raises(ValueError, match="explicit completion gate"): + await stream(model, context("gated " + "d" * 32)) + asyncio.run(check()) + + +def test_synthetic_requests_do_not_execute_authorized_tool(): + async def check(): + events = [] + model, stream, tools = components(lambda *event: events.append(event)) + assert isinstance(tools[0].execution, AuthorizedExecution) + identifiers = [] + for _ in range(2): + response = await stream(model, context("approval")) + try: + message = await response.result() + assert message.stop_reason == "toolUse" + identifiers.append(message.content[0].id) + finally: + await response.aclose() + assert len(set(identifiers)) == 2 + assert events == [] # Generation is not approval or execution. + + asyncio.run(check()) + + +@pytest.mark.parametrize("failure", [None, RuntimeError("construction"), KeyboardInterrupt()]) +def test_product_composition_preserves_launch_capture_and_restores_name(monkeypatch, failure): + from loushang.coding import managed_local, managed_process + + launch, capture = object(), object() + arguments = ["original-invocation", "original-session-root", "original-descriptor"] + seen = [] + + def original(selected, **kwargs): + assert selected is launch + assert kwargs["output_capture_factory"] is capture + assert set(kwargs) == {"model", "stream_fn", "tools", "output_capture_factory"} + seen.append(kwargs) + if failure is not None: + raise failure + return object() + + def entry(argv): + assert argv is arguments + managed_local.CodingManagedLocalCommandV1(launch, output_capture_factory=capture) + return 7 + + monkeypatch.setattr(managed_local, "CodingManagedLocalCommandV1", original) + monkeypatch.setattr(managed_process, "main", entry) + if failure is None: + assert run_product(arguments, lambda *_: None) == 7 + else: + with pytest.raises(type(failure)) as caught: + run_product(arguments, lambda *_: None) + assert caught.value is failure + assert len(seen) == 1 + assert managed_local.CodingManagedLocalCommandV1 is original + + +@pytest.mark.parametrize("failure", [None, OSError("construction failed"), KeyboardInterrupt()]) +def test_diagnostic_constructor_preserves_launch_capture_and_original_entry(monkeypatch, failure): + from dataclasses import dataclass, replace + + from loushang.appservice.managed_mux import ManagedMuxServiceBindingV1 + from loushang.coding import managed_local, managed_process + + @dataclass(frozen=True) + class Launch: + managed_mux: object + retained: object + + owner, request, capture = object(), object(), object() + calls, records = [], [] + + def prepare(value): + assert value is request + calls.append("prepare") + return owner + + binding = ManagedMuxServiceBindingV1("coding.default", "a" * 64, "b" * 32, prepare) + launch = Launch(binding, object()) + + def original(selected, **kwargs): + assert replace(selected, managed_mux=binding) == launch + assert selected.retained is launch.retained + assert kwargs["output_capture_factory"] is capture + calls.append("construct") + if failure is not None: + raise failure + proxy = selected.managed_mux.prepare(request) + assert proxy.owner is owner + return object() + + def entry(argv): + assert argv == ["original"] + managed_local.CodingManagedLocalCommandV1(launch, output_capture_factory=capture) + return 9 + + monkeypatch.setattr(managed_local, "CodingManagedLocalCommandV1", original) + monkeypatch.setattr(managed_process, "main", entry) + options = {"admission_diagnostic": lambda phase, **fields: records.append((phase, fields))} + if failure is None: + assert run_product(["original"], lambda *_: None, **options) == 9 + assert calls == ["construct", "prepare"] + assert [phase for phase, fields in records] == ["admission_prepare_enter", "admission_prepare_return"] + else: + with pytest.raises(type(failure)) as caught: + run_product(["original"], lambda *_: None, **options) + assert caught.value is failure and calls == ["construct"] + assert launch.managed_mux is binding and binding.prepare is prepare + assert managed_local.CodingManagedLocalCommandV1 is original + + +def test_synthetic_model_echo_is_not_an_execution_witness(): + async def check(): + events = [] + model, stream, _ = components(lambda *event: events.append(event)) + response = await stream(model, context("LMUX_TOOL_COMPLETED", "toolResult")) + try: + message = await response.result() + assert message.content[0].text == "LMUX_TOOL_COMPLETED" + assert events == [] + finally: + await response.aclose() + + asyncio.run(check()) + + +@pytest.mark.parametrize("factory_fault", [None, "before", "after"]) +def test_held_producer_witness_is_emitted_only_after_actual_settlement(factory_fault): + async def check(): + events = [] + started = asyncio.Event() + + def witness(kind, identity): + events.append((kind, identity)) + if kind == "producer_started": + started.set() + + model, stream, _ = components(witness) + loop = asyncio.get_running_loop() + original_factory = loop.get_task_factory() + factory_calls, factory_tasks = [], [] + + def broken_factory(loop, coroutine, **kwargs): + factory_calls.append(factory_fault) + if factory_fault == "after": + factory_tasks.append(asyncio.Task(coroutine, loop=loop, **kwargs)) + else: + coroutine.close() + raise RuntimeError("foreign task factory failed") + + try: + if factory_fault is not None: + loop.set_task_factory(broken_factory) + try: + response = await stream(model, context("hold")) + finally: + loop.set_task_factory(original_factory) + finally: + for task in factory_tasks: + task.cancel() + if factory_tasks: + await asyncio.gather(*factory_tasks, return_exceptions=True) + try: + assert factory_calls == [] + await asyncio.wait_for(started.wait(), timeout=2) + assert events == [("producer_started", "lmux-call-1")] + finally: + await response.aclose() + assert events == [ + ("producer_started", "lmux-call-1"), ("producer_settled", "lmux-call-1"), + ] + + asyncio.run(check()) diff --git a/tests/coding/test_lmux_terminal_process.py b/tests/coding/test_lmux_terminal_process.py new file mode 100644 index 000000000..e9ba37251 --- /dev/null +++ b/tests/coding/test_lmux_terminal_process.py @@ -0,0 +1,168 @@ +"""Installed short-command acceptance using the existing native PTY driver.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from loushang.tui.cell_width import strip_control_sequences +from loushang.tui.terminal import FakeScreen, TerminalSize +from tests.tui.terminal_process_support import spawn_terminal_process + +from ._g18_native_probe import ( + _replay_embedded_output, + managed_completion_frame, + managed_read_observation, +) +from .test_mux_terminal_process import _terminal_environment + +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux managed lmux") + + +@pytest.mark.parametrize("reconnect_exit", ["\x02d", "\x04"], ids=["detach", "empty-ctrl-d"]) +def test_installed_lmux_one_command_two_tabs_detach_and_cross_cwd_reconnect(tmp_path, reconnect_exit): + import pty + import termios + + executable = Path(sys.executable).parent / "lmux" + assert executable.is_file(), "installed lmux entry point is required" + environment = _terminal_environment(tmp_path) + environment.pop("LOUSHANG_TMPDIR", None) + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + drivers = [] + modes = {} + + def command(*args, cwd=tmp_path): + result = subprocess.run( + [str(executable), *args], cwd=cwd, env=environment, + capture_output=True, text=True, timeout=45, + ) + assert result.returncode == 0, (result.stdout, result.stderr) + if args[:1] == ("stop",): + # Batch stop emits one JSON record per target plus its summary. + return [json.loads(line) for line in result.stdout.splitlines()] + return json.loads(result.stdout) + + def terminal(*args, cwd=tmp_path): + original_open = pty.openpty + captured = [] + + def observed_open(): + master, slave = original_open() + captured.append((master, termios.tcgetattr(slave))) + return master, slave + + with patch.object(pty, "openpty", observed_open): + driver = spawn_terminal_process( + [str(executable), *args], cwd=cwd, env=environment, columns=80, rows=24, + ) + drivers.append(driver) + (modes[id(driver)],) = captured + return driver + + def exited(driver): + assert driver.wait(timeout=20) == 0, driver.diagnostics + master, original = modes[id(driver)] + assert termios.tcgetattr(master) == original, "terminal modes not restored" + output = driver.raw_output + assert output.rfind("\x1b[?25h") > output.rfind("\x1b[?25l") + assert output.rfind("\x1b[?2004l") > output.rfind("\x1b[?2004h") + assert driver.diagnostics.termination is None + + def see(driver, text, *, after=0): + driver.read_until( + lambda output: text in strip_control_sequences(output[after:]), timeout=40, + ) + + def see_draft(driver, expected, forbidden, *, after=0, columns=80, rows=24): + def matches(output): + end = output.rfind("\x1b[?2026l") + if end < after: + return False + screen = _replay_embedded_output( + output[:end + len("\x1b[?2026l")], + screen=FakeScreen.empty(TerminalSize(columns=columns, rows=rows)), + ) + lines = tuple(line.strip() for line in screen.visible_lines) + return f"> {expected}" in lines and not any(forbidden in line for line in lines) + driver.read_until(matches, timeout=40) + + try: + driver = terminal("new", "-s", "dev") + see(driver, "dev |") + offset = len(driver.raw_output) + driver.write("/he") + driver.read_until( + lambda output: managed_completion_frame(output, after=offset, columns=80, rows=24), + timeout=40, + ) + # Use editor keys, not an injected provider call. Never submit /he. + driver.write("\x7f\x7f\x7f") + driver.write("/new user_home First\r") + see(driver, "*1") + first = managed_read_observation(environment, "first-member", name="dev") + assert len(first["members"]) == 1 + driver.write("/new user_home Second\r") + see(driver, "*1 2") + second = managed_read_observation(environment, "second-member", name="dev") + assert len(second["members"]) == 2 and second["members"][:1] == first["members"] + assert all(second[key] == first[key] for key in ("instanceId", "serviceId", "muxId")) + offset = len(driver.raw_output) + driver.write("\x022") + see(driver, "*2", after=offset) + driver.write("second unsent draft") + see(driver, "second unsent draft") + offset = len(driver.raw_output) + driver.write("\x021") + see(driver, "*1", after=offset) + driver.write("first unsent draft") + see_draft(driver, "first unsent draft", "second unsent draft", after=offset) + offset = len(driver.raw_output) + driver.write("\x022") + see_draft(driver, "second unsent draft", "first unsent draft", after=offset) + offset = len(driver.raw_output) + driver.resize(columns=100, rows=30) + see_draft(driver, "second unsent draft", "first unsent draft", + after=offset, columns=100, rows=30) + driver.write("\x02d") + exited(driver) + detached = managed_read_observation(environment, "detached", name="dev") + assert all(detached[key] == second[key] for key in ("instanceId", "serviceId", "muxId", "members")) + before = command("ls") + assert [row["name"] for row in before["muxes"]] == ["dev"] + service_id = before["muxes"][0]["serviceId"] + instance = command("status", "--server", service_id)["instanceId"] + assert instance is not None + + reconnect = terminal("attach", "-t", "dev", cwd=elsewhere) + see(reconnect, "dev |") + reattached = managed_read_observation(environment, "reattached", name="dev") + assert all(reattached[key] == second[key] for key in ("instanceId", "serviceId", "muxId", "members")) + # Both server-owned members survive; client-local drafts need not. + offset = len(reconnect.raw_output) + reconnect.write("\x022") + see(reconnect, "*2", after=offset) + offset = len(reconnect.raw_output) + reconnect.write("\x021") + see(reconnect, "*1", after=offset) + reconnect.write(reconnect_exit) + exited(reconnect) + after = command("ls", cwd=elsewhere) + assert after == before, "reattach must not change registered muxes" + assert command("status", "--server", service_id, cwd=elsewhere)["instanceId"] == instance + assert not list(elsewhere.iterdir()) + finally: + # Only this test's private namespace is selected. A failed stop stays + # visible; never infer cleanup from a vanished client or force-kill an + # unverified background PID. + try: + for driver in drivers: + driver.close(timeout=10) + finally: + command("stop", "--all", "--yes") diff --git a/tests/coding/test_mux_markdown_terminal.py b/tests/coding/test_mux_markdown_terminal.py new file mode 100644 index 000000000..2ea6d3569 --- /dev/null +++ b/tests/coding/test_mux_markdown_terminal.py @@ -0,0 +1,53 @@ +"""Real Product/PTY Markdown path with the existing synthetic model fixture.""" + +from ._g18_native_probe import _replay_embedded_output +from .test_mux_product_terminal import _command, _product, _see, _terminal + + +def test_installed_hosted_markdown_survives_detach_and_snapshot(tmp_path): + def rendered(driver, *, after=0): + def witness(output): + end = output.rfind("\x1b[?2026l") + if end < after: + return False + screen = _replay_embedded_output(output[:end + len("\x1b[?2026l")]) + text = "\n".join(screen.visible_lines) + + def styled_token(token, *, bold=False): + for row, line in enumerate(screen.visible_lines): + column = line.find(token) + if column >= 0: + styles = screen.cell_styles[row][column:column + len(token)] + return all(style.foreground is not None and (not bold or style.bold) for style in styles) + return False + + return ( + "Render witness" in text and "bold witness" in text and "code witness" in text + and "## Render witness" not in text and "**bold witness**" not in text + # Shared Markdown deliberately retains styled code fences. + # Plain raw Markdown must still fail the heading/code styles. + and styled_token("Render witness", bold=True) + and styled_token("```python") and styled_token("print") + and "*1 |" in text + ) + driver.read_until(witness, timeout=35) + + # Test helper supplies only a synthetic transport; Product, IPC, installed + # terminal renderer and session persistence are real. No network model IO. + with _product(tmp_path) as (server, environment): + _command(tmp_path, environment, "create", "markdown-review") + with _terminal(tmp_path, environment, "markdown-review") as driver: + driver.write("/new user_home Markdown\r") + _see(driver, "*1") + offset = len(driver.raw_output) + driver.write("markdown\r") + rendered(driver, after=offset) + driver.write("\x02d") + assert driver.wait(timeout=15) == 0, driver.diagnostics + assert server.poll() is None + with _terminal(tmp_path, environment, "markdown-review") as reattached: + rendered(reattached) + reattached.write("\x02d") + assert reattached.wait(timeout=15) == 0, reattached.diagnostics + _command(tmp_path, environment, "stop") + assert server.wait(timeout=20) == 0 diff --git a/tests/tui/terminal_process_support/posix_pty.py b/tests/tui/terminal_process_support/posix_pty.py index 053e6db9e..add9b067b 100644 --- a/tests/tui/terminal_process_support/posix_pty.py +++ b/tests/tui/terminal_process_support/posix_pty.py @@ -3,6 +3,7 @@ import codecs import errno import fcntl +import math import os import pty import select @@ -13,7 +14,7 @@ import threading import time from collections.abc import Mapping, Sequence -from contextlib import suppress +from contextlib import contextmanager from pathlib import Path from typing import Self @@ -39,6 +40,7 @@ def __init__( args, cwd=cwd, env=env, columns=columns, rows=rows ) self._master_fd = master_fd + self._transport_state = "open" self._process = process self._stop_reader = threading.Event() self._reader = threading.Thread( @@ -91,6 +93,10 @@ def spawn( def write(self, text: str) -> None: with self._writer_lock: + if self._transport_state != "open": + if threading.current_thread() is self._reader: + return # No terminal query replies after the transport fence. + raise RuntimeError("terminal transport is fenced") if self._closed: raise RuntimeError("terminal driver is closed") payload = text.encode("utf-8") @@ -99,13 +105,55 @@ def write(self, text: str) -> None: offset += os.write(self._master_fd, payload[offset:]) def resize(self, *, columns: int, rows: int) -> None: - _set_window_size(self._master_fd, columns=columns, rows=rows) - self._columns = columns - self._rows = rows - self._responder.columns = columns - self._responder.rows = rows - with _ignore_process_lookup(): - os.killpg(self._process.pid, signal.SIGWINCH) + with self._writer_lock: + if self._transport_state != "open": + raise RuntimeError("terminal transport is fenced") + _set_window_size(self._master_fd, columns=columns, rows=rows) + self._columns = columns + self._rows = rows + self._responder.columns = columns + self._responder.rows = rows + with _ignore_process_lookup(): + os.killpg(self._process.pid, signal.SIGWINCH) + + def hangup_transport(self, *, timeout: float) -> int: + """Close the last PTY master, then wait for the original client, without signals. + + This is EOF/EIO transport loss, not a claim that SIGHUP was delivered. + A failed wait remains a failed observation; close retains its usual cleanup duty. + """ + deadline = _deadline(timeout) + with _timed_lock(self._close_lock, deadline): + if self._closed: + raise RuntimeError("terminal driver is closed") + if self._transport_state == "unknown": + raise RuntimeError("PTY master close outcome unknown") + if self._transport_state != "closed": + with _timed_lock(self._writer_lock, deadline): + if self._transport_state == "open" and not self.is_alive(): + raise RuntimeError("client exited before transport hangup") + self._transport_state = "fenced" + self._stop_reader.set() + # The reader may be in write(query_reply); never join under its lock. + self._reader.join(timeout=_remaining(deadline)) + if self._reader.is_alive(): + raise TimeoutError("PTY reader did not settle before hangup") + with _timed_lock(self._writer_lock, deadline): + _remaining(deadline) + self._close_master_once() + if self._reader_error is not None: + raise RuntimeError("PTY reader failed before hangup") from self._reader_error + status = self.wait(timeout=_remaining(deadline)) + _remaining(deadline) + return status + + def _close_master_once(self) -> None: + if self._transport_state == "unknown": + raise RuntimeError("PTY master close outcome unknown") + if self._transport_state != "closed": + self._transport_state = "unknown" + os.close(self._master_fd) + self._transport_state = "closed" def is_alive(self) -> bool: return self._process.poll() is None @@ -143,24 +191,31 @@ def terminate_tree(self, *, timeout: float) -> None: ) from error def close(self, *, timeout: float = 5.0) -> None: - with self._close_lock: + deadline = _deadline(timeout) + with _timed_lock(self._close_lock, deadline): if self._closed: return - deadline = time.monotonic() + max(0.0, timeout) try: if self.is_alive(): - self.terminate_tree(timeout=max(0.01, deadline - time.monotonic())) + self.terminate_tree(timeout=_remaining(deadline)) self._wait_for_idle_output( - timeout=max(0.01, min(0.5, deadline - time.monotonic())) + timeout=min(0.5, _remaining(deadline)) ) finally: - self._stop_reader.set() - with suppress(OSError): - os.close(self._master_fd) - self._reader.join(timeout=max(0.0, deadline - time.monotonic())) + with _timed_lock(self._writer_lock, deadline): + if self._transport_state == "open": + self._transport_state = "fenced" + self._stop_reader.set() + self._reader.join(timeout=_remaining(deadline)) + if self._reader.is_alive(): + raise TimeoutError(f"POSIX PTY reader did not stop:\n{self.diagnostics}") + with _timed_lock(self._writer_lock, deadline): + _remaining(deadline) + self._close_master_once() + if self.is_alive(): + raise TimeoutError("POSIX PTY process did not settle") + _remaining(deadline) self._closed = True - if self._reader.is_alive(): - raise TimeoutError(f"POSIX PTY reader did not stop:\n{self.diagnostics}") @property def diagnostics(self) -> TerminalProcessDiagnostics: @@ -198,6 +253,30 @@ def _read_loop(self) -> None: self._record_reader_done() +def _deadline(timeout: float) -> float: + if not math.isfinite(timeout) or timeout < 0: + raise ValueError("terminal timeout must be finite and nonnegative") + return time.monotonic() + timeout + + +def _remaining(deadline: float) -> float: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("terminal cleanup deadline expired") + return remaining + + +@contextmanager +def _timed_lock(lock, deadline): + if not lock.acquire(timeout=_remaining(deadline)): + raise TimeoutError("terminal cleanup lock deadline expired") + try: + _remaining(deadline) + yield + finally: + lock.release() + + def _set_window_size(fd: int, *, columns: int, rows: int) -> None: fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, columns, 0, 0)) From 162602e00f679ba1bb56522d99869cacc33a813d Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 06:59:37 +0000 Subject: [PATCH 31/44] test(dev): measure managed lmux evidence --- scripts/dev/_g18_comparison.py | 103 +- scripts/dev/measure_g18_native.py | 1052 ++++++++++++++++- scripts/dev/reevaluate_g18_comparison.py | 182 +++ tests/dev/test_g18_comparison.py | 36 +- tests/dev/test_lmux_comparison.py | 148 +++ tests/dev/test_lmux_history_collection.py | 132 +++ tests/dev/test_lmux_history_confirmation.py | 87 ++ tests/dev/test_lmux_history_receipts.py | 78 ++ .../test_lmux_history_restore_validation.py | 106 ++ tests/dev/test_lmux_history_seed_receipts.py | 136 +++ .../test_lmux_history_terminal_validation.py | 98 ++ tests/dev/test_lmux_history_timings.py | 87 ++ .../dev/test_lmux_history_warm_validation.py | 114 ++ tests/dev/test_lmux_native_receipt.py | 143 +++ tests/dev/test_lmux_native_selection.py | 81 ++ tests/dev/test_lmux_product_collection.py | 176 +++ tests/dev/test_lmux_product_confirmation.py | 56 + tests/dev/test_lmux_product_scenario.py | 153 +++ .../test_lmux_product_terminal_validation.py | 75 ++ tests/dev/test_lmux_product_timings.py | 75 ++ tests/dev/test_lmux_stop_measurement.py | 148 +++ tests/dev/test_lmux_terminal_receipts.py | 64 + tests/dev/test_measure_g18_native.py | 65 +- tests/dev/test_measure_g18_startup.py | 8 +- tests/dev/test_reevaluate_g18_comparison.py | 181 +++ 25 files changed, 3544 insertions(+), 40 deletions(-) create mode 100644 scripts/dev/reevaluate_g18_comparison.py create mode 100644 tests/dev/test_lmux_comparison.py create mode 100644 tests/dev/test_lmux_history_collection.py create mode 100644 tests/dev/test_lmux_history_confirmation.py create mode 100644 tests/dev/test_lmux_history_receipts.py create mode 100644 tests/dev/test_lmux_history_restore_validation.py create mode 100644 tests/dev/test_lmux_history_seed_receipts.py create mode 100644 tests/dev/test_lmux_history_terminal_validation.py create mode 100644 tests/dev/test_lmux_history_timings.py create mode 100644 tests/dev/test_lmux_history_warm_validation.py create mode 100644 tests/dev/test_lmux_native_receipt.py create mode 100644 tests/dev/test_lmux_native_selection.py create mode 100644 tests/dev/test_lmux_product_collection.py create mode 100644 tests/dev/test_lmux_product_confirmation.py create mode 100644 tests/dev/test_lmux_product_scenario.py create mode 100644 tests/dev/test_lmux_product_terminal_validation.py create mode 100644 tests/dev/test_lmux_product_timings.py create mode 100644 tests/dev/test_lmux_stop_measurement.py create mode 100644 tests/dev/test_lmux_terminal_receipts.py create mode 100644 tests/dev/test_reevaluate_g18_comparison.py diff --git a/scripts/dev/_g18_comparison.py b/scripts/dev/_g18_comparison.py index d2bb82aca..a86cb8b7c 100644 --- a/scripts/dev/_g18_comparison.py +++ b/scripts/dev/_g18_comparison.py @@ -10,6 +10,25 @@ import statistics from fractions import Fraction +# Accepted A/A judgement contract (ARD-004, accepted 2026-09-18). +# +# These two constants have different meanings and must stay jointly calibrated: +# STABILITY_RATIO bounds the dispersion we accept as "this measurement is +# precise enough to compare" (relative MAD, plus the absolute floors below). +# REGRESSION_RATIO bounds the smallest true regression the verdict can claim +# to detect. A meaningful pass needs REGRESSION_RATIO clearly above +# STABILITY_RATIO; when both were 1/10 the threshold-level detection power +# was ~0.5%, so the verdict carried no information. +# +# Relaxing only the stability side would invert that relation and make the +# verdict less meaningful. Measured outcome of the accepted pair: all 22 +# side/block groups across the frozen history and managed-mux campaigns are +# stable, and a true 30% regression is detected ~46-52% of the time +# (previously ~0.5%). Detail and limits: docs/internals/architecture/apphost/ +# decisions/accepted/ARD-004-aa-stability-gate-vs-interactive-metric-scale.md +STABILITY_RATIO = Fraction(1, 4) +REGRESSION_RATIO = Fraction(3, 10) + PRIORITY_HELP = {"cli-help", "hosted-tui-help"} A_ELAPSED_CASES = { "import-harness", @@ -84,6 +103,21 @@ } +MANAGED_METRICS = { + "managed-mux": ( + "cold_frame_seconds", + "first_completion_seconds", + "first_member_ready_seconds", + "cold_through_first_member_seconds", + "warm_member_ready_seconds", + "detach_settlement_seconds", + "warm_attach_frame_seconds", + "reattach_detach_settlement_seconds", + "stop_settlement_seconds", + ), +} + + def compare_case( samples, *, @@ -117,6 +151,60 @@ def compare_case( def compare_native(samples, *, cache_mode, phase="ab", blocks=2, pairs_per_block=10): """All native cases/metrics in one condition, after caller provenance gates.""" + return _compare_native_inventory( + samples, inventory=NATIVE_METRICS, cache_mode=cache_mode, + phase=phase, blocks=blocks, pairs_per_block=pairs_per_block, + ) + + +MANAGED_PRODUCT_METRICS = { + "managed-product-first-use": ( + "fixed_entry_through_visible_reply_seconds", "visible_reply_seconds", + "approval_pending_seconds", "approval_details_seconds", "approved_tool_reply_seconds", + "interrupt_through_idle_and_producer_seconds", "next_reply_seconds", + "interrupt_through_next_reply_seconds", + ), +} + + +HISTORY_METRICS = { + "managed-product-history-warm": ("history_frame_seconds", "history_completion_seconds"), + "managed-product-history-restore": ("restored_history_frame_seconds",), +} + + +def compare_managed_history(samples, *, case, cache_mode, phase="ab", blocks=2, pairs_per_block=10): + """Explicit history campaigns retain the original pairing/statistical policy.""" + if type(case) is not str or case not in HISTORY_METRICS: + raise ValueError("unknown managed history comparison case") + return _compare_native_inventory( + samples, inventory={case: HISTORY_METRICS[case]}, cache_mode=cache_mode, + phase=phase, blocks=blocks, pairs_per_block=pairs_per_block, + ) + + +def compare_managed_product(samples, *, cache_mode, phase="ab", blocks=2, pairs_per_block=10): + """Use the existing statistical policy for the explicit fresh Product case.""" + return _compare_native_inventory( + samples, inventory=MANAGED_PRODUCT_METRICS, cache_mode=cache_mode, + phase=phase, blocks=blocks, pairs_per_block=pairs_per_block, + ) + + +def compare_managed(samples, *, cache_mode, phase="ab", blocks=2, pairs_per_block=10): + """Opt-in managed short-entry policy, not full LMUX acceptance. + + The collector must verify installation, exact live instance and physical + settlement before marking a sample complete. Pure timings cannot prove them. + This never adds optional cases to the original seven-case native policy. + """ + return _compare_native_inventory( + samples, inventory=MANAGED_METRICS, cache_mode=cache_mode, + phase=phase, blocks=blocks, pairs_per_block=pairs_per_block, + ) + + +def _compare_native_inventory(samples, *, inventory, cache_mode, phase, blocks, pairs_per_block): if ( type(blocks) is not int or type(pairs_per_block) is not int @@ -133,17 +221,17 @@ def compare_native(samples, *, cache_mode, phase="ab", blocks=2, pairs_per_block milestones = sample.get("milestones") if ( type(case) is not str - or case not in NATIVE_METRICS + or case not in inventory or sample.get("cache_mode") != cache_mode or sample.get("status") != "complete" or type(milestones) is not dict - or set(milestones) != set(NATIVE_METRICS[case]) + or set(milestones) != set(inventory[case]) ): raise ValueError( "incomplete native case, cache condition or metric inventory" ) results = {} - for case, metrics in NATIVE_METRICS.items(): + for case, metrics in inventory.items(): results[case] = {} for metric in metrics: projected = [ @@ -226,9 +314,9 @@ def _compare(samples, *, case, metric, blocks, pairs_per_block, phase, required_ for group, median in zip(groups, medians, strict=True) ] stable = max(medians) - min(medians) <= max( - min(medians) / 10, Fraction(1, 50) + min(medians) * STABILITY_RATIO, Fraction(1, 50) ) and all( - mad <= max(median / 10, Fraction(1, 100)) + mad <= max(median * STABILITY_RATIO, Fraction(1, 100)) for mad, median in zip(mads, medians, strict=True) ) ordered = sorted(item for group in groups for item in group) @@ -236,6 +324,7 @@ def _compare(samples, *, case, metric, blocks, pairs_per_block, phase, required_ "stable": stable, "block_medians": medians, "block_mads": mads, + "mean_seconds_descriptive": sum(ordered) / len(ordered), "median_seconds": statistics.median(ordered), "p95_seconds_descriptive": ordered[(95 * len(ordered) + 99) // 100 - 1], "samples": len(ordered), @@ -243,12 +332,12 @@ def _compare(samples, *, case, metric, blocks, pairs_per_block, phase, required_ baseline = variants["a"]["median_seconds"] candidate = variants["b"]["median_seconds"] improvement = 1 - candidate / baseline - regression_limit = max(baseline / 10, Fraction(1, 50)) + regression_limit = max(baseline * REGRESSION_RATIO, Fraction(1, 50)) all_medians = [ value for variant in variants.values() for value in variant["block_medians"] ] calibrated = max(all_medians) - min(all_medians) <= max( - min(all_medians) / 10, Fraction(1, 50) + min(all_medians) * STABILITY_RATIO, Fraction(1, 50) ) if not all(variant["stable"] for variant in variants.values()) or ( phase == "aa" and not calibrated diff --git a/scripts/dev/measure_g18_native.py b/scripts/dev/measure_g18_native.py index 9ab303286..c54f2ed62 100644 --- a/scripts/dev/measure_g18_native.py +++ b/scripts/dev/measure_g18_native.py @@ -8,6 +8,7 @@ from __future__ import annotations import argparse +import hashlib import importlib.util import json import math @@ -46,6 +47,14 @@ def support(name): "recovery-global", "product-first-use", ) +HISTORY_CASES = ("managed-product-history-warm", "managed-product-history-restore") +OPTIONAL_CASES = ("managed-mux", "managed-product-first-use", *HISTORY_CASES) +HISTORY_WARM_FIELDS = { + "measured_prefix", "status", "valid", "spawns", "terminal_settlements", + "fixed_product_target", "fixed_product_detached", "fixed_product_native", + "fixed_product_stop", "fixed_product_history", +} +HISTORY_RESTORE_FIELDS = {"history_generations", "fixed_product_history_restore"} OBSERVER_FIELDS = { @@ -63,6 +72,10 @@ def support(name): "seed_setup", } +MANAGED_OBSERVER_FIELDS = { + "managed_stop", "authenticated_instance_id", "managed_observations", "managed_actions", +} + def validate_isolation(value, prefix, root): isolation = value["isolation"] @@ -235,6 +248,12 @@ def validate_observation( ) if case == "g14-stdio": fields = fields | {"stdio_observer"} + elif case == "managed-mux": + fields = fields | MANAGED_OBSERVER_FIELDS + elif case == "managed-product-first-use": + fields = fields | {"fixed_product_scenarios", "outer_settlement"} + elif case in HISTORY_CASES: + fields = fields | (HISTORY_WARM_FIELDS if case == HISTORY_CASES[0] else HISTORY_RESTORE_FIELDS) | {"outer_settlement"} if ( type(value) is not dict or set(value) != fields @@ -267,6 +286,17 @@ def validate_observation( if case == "home-isolation": validate_isolation(value, prefix, root) return + if case == "managed-mux": + validate_managed_observation(value, prefix, root) + return + if case == "managed-product-first-use": + validate_managed_product_collection(value, prefix, root) + return + if case in HISTORY_CASES: + milestones = validate_managed_history_collection(value, prefix, root) + if value["milestones"] != milestones or any(type(value["milestones"][key]) not in (int, float) for key in milestones): + raise ValueError("history milestone summary differs from original endpoints") + return required = set() if case == "product-first-use" else {"first_command_seconds"} if case == "product-first-use": required.update( @@ -370,6 +400,936 @@ def validate_observation( raise ValueError("fresh native sample contains recovery state") +def validate_managed_observation(value, prefix, root): + """Validate the optional scenario's exact frame/query receipt inventory. + + These are values from the trusted installed observer, not an authentication + mechanism. Its source and wheel provenance are still checked by the caller. + """ + metrics = inert.comparison_module().MANAGED_METRICS["managed-mux"] + milestones = value["milestones"] + if ( + type(milestones) is not dict or set(milestones) != set(metrics) + or any(type(item) not in (int, float) or not math.isfinite(item) or item <= 0 + for item in milestones.values()) + or value["seed"] != "empty" or value["seed_setup"] != [] + ): + raise ValueError("managed milestone or fresh-state inventory mismatch") + spawns = value["spawns"] + if type(spawns) is not list or len(spawns) != 2: + raise ValueError("managed foreground spawn inventory mismatch") + workspace = (root / "workspace").resolve() + for spawn, cwd, arguments in zip( + spawns, (workspace, workspace / "elsewhere"), + (("new", "-s", "perf"), ("attach", "-t", "perf")), strict=True, + ): + if ( + type(spawn) is not dict or set(spawn) != {"pid", "start", "argv", "cwd"} + or type(spawn["pid"]) is not int or spawn["pid"] <= 0 + or type(spawn["start"]) not in (int, float) + or not math.isfinite(spawn["start"]) or spawn["start"] <= 0 + or spawn["cwd"] != str(cwd) + or spawn["argv"] != [str(prefix / "bin/lmux"), *arguments] + ): + raise ValueError("managed foreground identity mismatch") + observations = value.get("managed_observations") + if type(observations) is not list or len(observations) != 4: + raise ValueError("managed authenticated observations missing") + instance = value.get("authenticated_instance_id") + if type(instance) is not str or re.fullmatch(r"[0-9a-f]{32}", instance) is None: + raise ValueError("managed authenticated instance invalid") + original = None + previous_at = spawns[0]["start"] + milestones["cold_through_first_member_seconds"] + previous_members = None + for record, stage, count in zip( + observations, ("first-member", "second-member", "detached", "reattached"), + (1, 2, 2, 2), strict=True, + ): + if ( + type(record) is not dict + or set(record) != {"stage", "observed_at", "instanceId", "serviceId", "muxId", "members"} + or record["stage"] != stage or record["instanceId"] != instance + or type(record["serviceId"]) is not str + or re.fullmatch(r"[0-9a-f]{64}", record["serviceId"]) is None + or type(record["muxId"]) is not str or not 1 <= len(record["muxId"]) <= 256 + or type(record["observed_at"]) not in (int, float) + or not math.isfinite(record["observed_at"]) or record["observed_at"] < previous_at + or type(record["members"]) is not list or len(record["members"]) != count + ): + raise ValueError("managed authenticated query identity mismatch") + identity = record["serviceId"], record["muxId"] + if original is not None and identity != original: + raise ValueError("managed query changed service or mux") + original, previous_at = identity, record["observed_at"] + members = record["members"] + if any(type(member) is not dict or set(member) != {"memberId", "sessionId"} + or any(type(item) is not str or not 1 <= len(item) <= 256 for item in member.values()) + for member in members): + raise ValueError("managed member identity malformed") + if (len({member["memberId"] for member in members}) != count + or len({member["sessionId"] for member in members}) != count + or previous_members is not None and members[:len(previous_members)] != previous_members): + raise ValueError("managed member was replaced or duplicated") + previous_members = members + if not observations[2]["observed_at"] <= spawns[1]["start"] <= observations[3]["observed_at"]: + raise ValueError("managed reconnect does not follow detached live observation") + actions = value.get("managed_actions") + action_names = ("first_completion", "first_member_ready", "warm_member_ready", + "detach_settlement", "reattach_detach_settlement") + if type(actions) is not dict or set(actions) != set(action_names): + raise ValueError("managed action intervals missing") + for name in action_names: + action = actions[name] + if ( + type(action) is not dict or set(action) != {"started_at", "finished_at"} + or any(type(item) not in (int, float) or not math.isfinite(item) or item <= 0 + for item in action.values()) + or action["finished_at"] - action["started_at"] != milestones[name + "_seconds"] + ): + raise ValueError("managed action duration mismatch") + cold_end = spawns[0]["start"] + milestones["cold_frame_seconds"] + first_end = spawns[0]["start"] + milestones["cold_through_first_member_seconds"] + if not ( + cold_end <= actions["first_completion"]["started_at"] + < actions["first_completion"]["finished_at"] <= actions["first_member_ready"]["started_at"] + < actions["first_member_ready"]["finished_at"] == first_end + <= observations[0]["observed_at"] <= actions["warm_member_ready"]["started_at"] + < actions["warm_member_ready"]["finished_at"] <= observations[1]["observed_at"] + <= actions["detach_settlement"]["started_at"] < actions["detach_settlement"]["finished_at"] + <= observations[2]["observed_at"] <= spawns[1]["start"] + < spawns[1]["start"] + milestones["warm_attach_frame_seconds"] + <= observations[3]["observed_at"] <= actions["reattach_detach_settlement"]["started_at"] + < actions["reattach_detach_settlement"]["finished_at"] + ): + raise ValueError("managed action and authenticated observation order mismatch") + stop = value["managed_stop"] + if (type(stop) is not dict or set(stop) != {"started_at", "result"} + or type(stop["started_at"]) not in (int, float) + or not math.isfinite(stop["started_at"]) + or stop["started_at"] < actions["reattach_detach_settlement"]["finished_at"] + or stop["result"] != {"status": "stopped", "instanceId": instance}): + raise ValueError("managed stop does not match authenticated lifecycle") + + +def validate_managed_history_collection(value, prefix, root): + """Join generation evidence to the original observer's physical lifetime.""" + outer = value.get("outer_settlement") + if (type(outer) is not dict or set(outer) != {"started_at", "settled_at"} + or any(type(item) not in (int, float) or not 0 <= item <= sys.float_info.max + or not math.isfinite(item) for item in outer.values()) + or not outer["started_at"] < outer["settled_at"] + or value.get("status") != "observed" or value.get("valid") is not False + or value.get("seed") != "empty" or value.get("seed_setup") != []): + raise ValueError("history original outer settlement or seed inventory invalid") + bounds = dict(prefix=prefix, workspace=root / "workspace", earliest=outer["started_at"], latest=outer["settled_at"]) + if value["case"] == HISTORY_CASES[0]: + return validate_managed_history_warm({key: value[key] for key in HISTORY_WARM_FIELDS}, **bounds) + if value["case"] != HISTORY_CASES[1]: + raise ValueError("unknown history collection case") + generations = value["history_generations"] + metrics = validate_managed_history_restore(generations, **bounds) + if (value["spawns"] != [*generations["old"]["spawns"], *generations["new"]["spawns"]] + or value["fixed_product_history_restore"] != generations["new"]["restored_history"]): + raise ValueError("history restart summary differs from original generations") + return metrics + + +def complete_managed_history(value, *, observer_started, owner_settled, prefix, root): + """Only the original successful run_python caller may add outer settlement.""" + if type(value) is not dict or value.get("case") not in HISTORY_CASES: + raise ValueError("history receipt cannot be completed") + extras = HISTORY_WARM_FIELDS if value["case"] == HISTORY_CASES[0] else HISTORY_RESTORE_FIELDS + if (set(value) != OBSERVER_FIELDS | extras or value.get("schema_version") != 2 + or value.get("measured_prefix") != str(prefix) or value.get("sample_id") != str(root.resolve()) + or value.get("milestones") != {}): + raise ValueError("history raw observer inventory mismatch") + completed = {**value, "outer_settlement": {"started_at": observer_started, "settled_at": owner_settled}} + completed["milestones"] = validate_managed_history_collection(completed, prefix, root) + return completed + + +def validate_managed_history_native(natives, *, first, stages): + """All observations in one generation must identify the same native leader.""" + if type(natives) is not dict or set(natives) != stages: + raise ValueError("history native stages missing") + native = natives[first] + if (type(native) is not dict or set(native) != { + "pid", "start_ticks", "boot_id", "user_id", "pid_namespace_device", "pid_namespace_inode"} + or type(native["pid"]) is not int or not 0 < native["pid"] < 2**31 + or type(native["boot_id"]) is not str + or re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", native["boot_id"]) is None + or any(type(native[key]) is not int or not 0 <= native[key] < 2**64 + for key in ("start_ticks", "user_id", "pid_namespace_device", "pid_namespace_inode")) + or native["pid_namespace_inode"] == 0 + or any(type(item) is not dict or item != native + or any(type(item[key]) is not type(native[key]) for key in native) for item in natives.values())): + raise ValueError("history native identity mismatch") + return native + + +def validate_managed_history_restore(generations, *, prefix, workspace, earliest, latest): + """Validate both complete lifetimes and the restart gap without promoting.""" + if type(generations) is not dict or set(generations) != {"old", "new"}: + raise ValueError("history restart generation inventory mismatch") + old, new = generations["old"], generations["new"] + fields = {"measured_prefix", "status", "valid", "spawns", "terminal_settlements", + "line_terminal_settlements", "start_command", "authenticated_at", + "fixed_product_target", "fixed_product_native", "fixed_product_stop", "restored_history"} + if (type(new) is not dict or set(new) != fields or new["status"] != "observed" + or new["valid"] is not False or new["measured_prefix"] != str(prefix)): + raise ValueError("history restart generation status mismatch") + + def timestamp(item): + return (type(item) in (int, float) and 0 <= item <= sys.float_info.max and math.isfinite(item)) + + try: + validate_managed_history_warm(old, prefix=prefix, workspace=workspace, + earliest=earliest, latest=new["spawns"][0]["start"]) + prior, target, result = old["fixed_product_history"], new["fixed_product_target"], new["restored_history"] + if (type(result) is not dict or set(result) != { + "attach", "canonical", "stop", "canonical_read", "action", "restored_history_frame_seconds"} + or type(target) is not dict): + raise ValueError("history restored result inventory mismatch") + terminals = validate_managed_history_terminals(new, prefix=prefix, workspace=workspace, + restored=True, earliest=prior["canonical_read"]["completed_at"], latest=latest) + command = new["start_command"] + if (type(command) is not dict or set(command) != {"started_at", "settled_at", "result", "exit_status"} + or type(command["exit_status"]) is not int or command["exit_status"] != 0 + or command["result"] != {"status": "service_ready", "serviceId": target["serviceId"], "instanceId": target["instanceId"]} + or not all(timestamp(item) for item in (command["started_at"], command["settled_at"], + target["observed_at"], new["authenticated_at"])) + or command["started_at"] != new["spawns"][0]["start"] + or not terminals[0]["settled_at"] <= command["settled_at"] <= target["observed_at"] + <= new["authenticated_at"] <= new["spawns"][1]["start"]): + raise ValueError("history restart ready or authentication order mismatch") + if (any(target[key] != old["fixed_product_target"][key] for key in ("serviceId", "muxId", "members")) + or target["instanceId"] == old["fixed_product_target"]["instanceId"]): + raise ValueError("history restart must retain service and Session but change instance") + native = validate_managed_history_native(new["fixed_product_native"], first="restored", + stages={"restored", "history-detached", "stop"}) + prior_native = old["fixed_product_native"]["stop"] + if (native == prior_native or any(native[key] != prior_native[key] + for key in ("boot_id", "user_id", "pid_namespace_device", "pid_namespace_inode"))): + raise ValueError("history restart native leader must change within the same namespace") + attached, stop = result["attach"], new["fixed_product_stop"] + if type(stop) is not dict or set(stop) != {"started_at", "observed_at", "local_owner_settled_at", "service_id", "result"}: + raise ValueError("history restart stop inventory mismatch") + validate_managed_history_timings(attached, attach_started_at=new["spawns"][1]["start"], + restored={key: result[key] for key in ("action", "restored_history_frame_seconds")}, + service_started_at=new["spawns"][0]["start"], authenticated_at=new["authenticated_at"]) + validate_managed_history_confirmation(attached["detached"], target, identity=prior["seed"]["history_identity"], + earliest=terminals[1]["settled_at"], latest=stop["started_at"]) + if (attached["actions"]["history_completion"]["finished_at"] > terminals[1]["termios_restored_at"] + or stop["service_id"] != target["serviceId"] + or stop["result"] != {"status": "stopped", "instanceId": target["instanceId"]} + or result["stop"] != stop["result"] + or not all(timestamp(stop[key]) for key in ("started_at", "observed_at", "local_owner_settled_at")) + or not attached["detached"]["connection_settled_at"] <= stop["started_at"] <= stop["observed_at"] <= stop["local_owner_settled_at"]): + raise ValueError("history restart exact stop or final terminal mismatch") + validate_managed_history_canonical(result["canonical_read"], root=workspace / "platform/data/sessions", + workspace=workspace, identity=prior["seed"]["history_identity"], owner_settled_at=stop["local_owner_settled_at"], + next_started_at=latest) + if result["canonical"] != prior["canonical"] or result["canonical"] != result["canonical_read"]["canonical"]: + raise ValueError("history restart canonical changed") + except (KeyError, TypeError, IndexError) as error: + raise ValueError("history restart evidence malformed") from error + return {"restored_history_frame_seconds": result["restored_history_frame_seconds"]} + + +def validate_managed_history_warm(child, *, prefix, workspace, earliest, latest): + """Join warm-generation proofs; outer provenance/promotion remain separate.""" + fields = HISTORY_WARM_FIELDS + if (type(child) is not dict or set(child) != fields or child["status"] != "observed" + or child["valid"] is not False or child["measured_prefix"] != str(prefix)): + raise ValueError("history warm generation inventory or status mismatch") + + def timestamp(item): + return (type(item) in (int, float) and 0 <= item <= sys.float_info.max + and math.isfinite(item)) + + try: + target, result, stop = child["fixed_product_target"], child["fixed_product_history"], child["fixed_product_stop"] + if (type(result) is not dict or set(result) != {"seed", "attach", "canonical", "stop", "canonical_read"} + or type(stop) is not dict or set(stop) != { + "started_at", "observed_at", "local_owner_settled_at", "service_id", "result"} + or type(target) is not dict): + raise ValueError("history warm result inventory mismatch") + terminals = validate_managed_history_terminals(child, prefix=prefix, workspace=workspace, + restored=False, earliest=earliest, latest=latest) + attached, seed = result["attach"], result["seed"] + observation_fields = {"stage", "observed_at", "instanceId", "serviceId", "muxId", "members"} + if (type(seed) is not dict or set(seed) != observation_fields | { + "history_seed", "history_identity", "connection_settled_at", "verification"}): + raise ValueError("history warm seed observation inventory mismatch") + validate_managed_history_timings(attached, attach_started_at=child["spawns"][1]["start"]) + validate_managed_history_seed(seed["history_seed"], terminal_settled_at=terminals[0]["settled_at"], + connection_settled_at=seed["connection_settled_at"], attach_started_at=child["spawns"][1]["start"], + verification=seed["verification"]) + validate_managed_history_confirmation(attached["detached"], target, identity=seed["history_identity"], + earliest=terminals[1]["settled_at"], latest=stop["started_at"]) + if (not timestamp(target["observed_at"]) + or not child["spawns"][0]["start"] <= target["observed_at"] <= terminals[0]["termios_restored_at"] + or attached["actions"]["history_completion"]["finished_at"] > terminals[1]["termios_restored_at"]): + raise ValueError("history warm frame or authentication outside terminal") + detached = child["fixed_product_detached"] + if type(detached) is not dict or set(detached) != observation_fields: + raise ValueError("history warm detached observation inventory mismatch") + for observation, before, after in ( + (seed, terminals[0]["settled_at"], seed["verification"]["started_at"]), + (detached, seed["connection_settled_at"], child["spawns"][1]["start"]), + ): + if (observation["stage"] != "detached" or not timestamp(observation["observed_at"]) + or not before <= observation["observed_at"] <= after + or any(observation[key] != target[key] for key in ("instanceId", "serviceId", "muxId", "members"))): + raise ValueError("history warm authenticated observation mismatch") + validate_managed_history_native(child["fixed_product_native"], first="first-member", + stages={"first-member", "detached", "history-detached", "stop"}) + if (stop["service_id"] != target["serviceId"] + or stop["result"] != {"status": "stopped", "instanceId": target["instanceId"]} + or result["stop"] != stop["result"] + or not all(timestamp(stop[key]) for key in ("started_at", "observed_at", "local_owner_settled_at")) + or not attached["detached"]["connection_settled_at"] <= stop["started_at"] <= stop["observed_at"] <= stop["local_owner_settled_at"]): + raise ValueError("history warm exact stop or settlement mismatch") + validate_managed_history_canonical(result["canonical_read"], root=workspace / "platform/data/sessions", + workspace=workspace, identity=seed["history_identity"], owner_settled_at=stop["local_owner_settled_at"], + next_started_at=latest) + if result["canonical"] != result["canonical_read"]["canonical"]: + raise ValueError("history warm duplicated canonical evidence disagrees") + except (KeyError, TypeError, IndexError) as error: + raise ValueError("history warm generation evidence malformed") from error + return {name: attached[name] for name in ("history_frame_seconds", "history_completion_seconds")} + + +def validate_managed_history_confirmation(value, target, *, identity, earliest, latest): + """Bind the exact bounded tail to the authenticated Session after detach. + + The frozen digest is for 1 omitted STATUS plus rounds 121..127 (14 messages), + not the full 256-message canonical transcript validated separately. + """ + fields = {"stage", "observed_at", "instanceId", "serviceId", "muxId", "members"} + if (type(value) is not dict or set(value) != fields | {"history_snapshot", "connection_settled_at"} + or type(target) is not dict or set(target) != fields + or value["stage"] != "detached" or target["stage"] != "first-member" + or any(value[key] != target[key] for key in ("instanceId", "serviceId", "muxId", "members"))): + raise ValueError("history confirmation target mismatch") + token = r"[A-Za-z0-9][A-Za-z0-9._~-]{0,511}" + for key, pattern in (("instanceId", r"[0-9a-f]{32}"), ("serviceId", r"[0-9a-f]{64}"), ("muxId", token)): + if type(target[key]) is not str or re.fullmatch(pattern, target[key]) is None: + raise ValueError("history confirmation service identity invalid") + members = target["members"] + if (type(members) is not list or len(members) != 1 or type(members[0]) is not dict + or set(members[0]) != {"memberId", "sessionId"} + or any(type(item) is not str or re.fullmatch(token, item) is None for item in members[0].values())): + raise ValueError("history confirmation membership invalid") + if (type(identity) is not dict or set(identity) != { + "product_id", "continuity_id", "session_id", "scope", "scope_fingerprint"} + or any(type(item) is not str for item in identity.values()) + or identity["product_id"] != "coding" or identity["scope"] != "user_home" + or identity["session_id"] != members[0]["sessionId"] + or re.fullmatch(token, identity["continuity_id"]) is None + or re.fullmatch(r"[0-9a-f]{64}", identity["scope_fingerprint"]) is None): + raise ValueError("history confirmation Session identity invalid") + snapshot = value["history_snapshot"] + if (type(snapshot) is not dict or set(snapshot) != {"confirmed_at", "identity", "running", "records"} + or type(snapshot["identity"]) is not dict or snapshot["identity"] != identity + or snapshot["running"] is not False): + raise ValueError("history confirmation snapshot identity or idle mismatch") + times = (target["observed_at"], earliest, value["observed_at"], snapshot["confirmed_at"], + value["connection_settled_at"], latest) + if (any(type(item) not in (int, float) or item < 0 or item > sys.float_info.max + or not math.isfinite(item) for item in times) + or any(left > right for left, right in zip(times, times[1:]))): + raise ValueError("history confirmation snapshot or connection order mismatch") + rows = snapshot["records"] + if (type(rows) is not list or len(rows) != 15 + or any(type(row) is not dict or set(row) != {"kind", "text"} + or type(row["text"]) is not str or type(row["kind"]) is not str + or row["kind"] != ("status" if index == 0 else "user" if index % 2 else "assistant") + for index, row in enumerate(rows))): + raise ValueError("history confirmation requires the exact 15-record tail") + digest = hashlib.sha256(json.dumps(rows, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode()).hexdigest() + if digest != "327b38a5d29980cf550d442423e65e999ce4ca8abc9695ed6f80150a3740ea49": + raise ValueError("history confirmation differs from the fixed bounded tail") + + +def validate_managed_history_terminals(child, *, prefix, workspace, restored, + earliest, latest): + """Validate each real history foreground, without inventing TUI line modes. + + Bounds belong to the enclosing original owner. Authentication, service stop + and history content are separate checks. Return receipts in spawn order. + """ + def timestamp(item): + return (type(item) in (int, float) and 0 <= item <= sys.float_info.max + and math.isfinite(item)) + + if (type(restored) is not bool or type(child) is not dict + or not timestamp(earliest) or not timestamp(latest) or earliest > latest): + raise ValueError("history foreground bounds invalid") + spawns, tui = child.get("spawns"), child.get("terminal_settlements") + line = child.get("line_terminal_settlements", []) + if (type(spawns) is not list or len(spawns) != 2 + or type(tui) is not list or len(tui) != (1 if restored else 2) + or type(line) is not list or len(line) != (1 if restored else 0)): + raise ValueError("history foreground inventory mismatch") + terminals = [line[0], tui[0]] if restored else tui + previous = earliest + for index, (spawn, terminal) in enumerate(zip(spawns, terminals, strict=True)): + is_line = restored and index == 0 + cwd = workspace if index == 0 else workspace / ("restored-elsewhere" if restored else "elsewhere") + argv = ([str(prefix / "bin/python"), "-I", str(inert.ROOT / "tests/coding/_lmux_product_entry.py"), + "start", "-t", "perf"] if is_line else + [str(prefix / "bin/python"), "-I", str(inert.ROOT / "tests/coding/_lmux_product_entry.py"), + "new", "-s", "perf"] if index == 0 else + [str(prefix / "bin/lmux"), "attach", "-t", "perf"]) + fields = {"pid", "argv", "cwd", "exit_status", "termios_restored_at", "settled_at", "reader_settled", "fallback"} + fields |= {"presentation"} if is_line else {"cursor_restored", "bracketed_paste_disabled"} + if (type(spawn) is not dict or set(spawn) != {"pid", "start", "argv", "cwd"} + or type(spawn["pid"]) is not int or not 0 < spawn["pid"] < 2**31 + or spawn["argv"] != argv or spawn["cwd"] != str(cwd) or not timestamp(spawn["start"]) + or type(terminal) is not dict or set(terminal) != fields + or type(terminal["pid"]) is not int or terminal["pid"] != spawn["pid"] + or terminal["argv"] != argv or terminal["cwd"] != str(cwd) + or type(terminal["exit_status"]) is not int or terminal["exit_status"] != 0 + or terminal["reader_settled"] is not True or terminal["fallback"] is not False + or (terminal["presentation"] != "line" if is_line else + terminal["cursor_restored"] is not True or terminal["bracketed_paste_disabled"] is not True) + or not timestamp(terminal["termios_restored_at"]) or not timestamp(terminal["settled_at"]) + or not previous <= spawn["start"] <= terminal["termios_restored_at"] <= terminal["settled_at"] <= latest): + raise ValueError("history foreground identity or settlement mismatch") + previous = terminal["settled_at"] + return terminals + + +def validate_managed_history_seed(value, *, terminal_settled_at, + connection_settled_at, attach_started_at, verification): + """Require the complete bounded seed before a measured warm attachment. + + External terminal/connection/spawn bounds are validated by the enclosing + lifecycle check. This does not substitute for snapshots or persisted text. + """ + def timestamp(item): + return (type(item) in (int, float) and 0 <= item <= sys.float_info.max + and math.isfinite(item)) + + if type(value) is not dict or set(value) != {"started_at", "finished_at", "rounds", "detached_at", "attachment"}: + raise ValueError("history seed inventory mismatch") + attachment = value["attachment"] + if (type(verification) is not dict or set(verification) != {"started_at", "deadline", "completed_at"} + or not all(timestamp(item) for item in verification.values()) + or verification["deadline"] != verification["started_at"] + 660 + or not verification["started_at"] <= verification["completed_at"] < verification["deadline"]): + raise ValueError("history verification admission deadline mismatch") + if (type(attachment) is not dict or set(attachment) != { + "deadline", "attach_started_at", "attach_deadline", "attached_at", + "detach_started_at", "detach_deadline"} + or not all(timestamp(item) for item in attachment.values()) + or not all(timestamp(value[key]) for key in ("started_at", "finished_at", "detached_at")) + or not timestamp(terminal_settled_at) + or attachment["deadline"] != verification["deadline"] + or not terminal_settled_at <= verification["started_at"] <= attachment["attach_started_at"] <= attachment["attached_at"] + <= value["started_at"] <= value["finished_at"] <= attachment["detach_started_at"] <= value["detached_at"] + or attachment["attach_deadline"] != min(attachment["deadline"], attachment["attach_started_at"] + 30) + or attachment["detach_deadline"] != min(attachment["deadline"], attachment["detach_started_at"] + 30) + or attachment["attached_at"] >= attachment["attach_deadline"] + or value["detached_at"] >= attachment["detach_deadline"] + or value["finished_at"] >= attachment["deadline"] - 30): + raise ValueError("history attachment dispatch or deadline mismatch") + times = (terminal_settled_at, value["started_at"], value["finished_at"], + value["detached_at"], verification["completed_at"], connection_settled_at, attach_started_at) + if (not all(timestamp(item) for item in times) + or any(left > right for left, right in zip(times, times[1:])) + or value["finished_at"] - value["started_at"] >= 600): + raise ValueError("history seed must finish and release owners before attach") + rounds = value["rounds"] + if type(rounds) is not list or len(rounds) != 128: + raise ValueError("history seed requires all 128 rounds") + previous = value["started_at"] + for index, row in enumerate(rounds): + if (type(row) is not dict or set(row) != { + "round", "started_at", "acknowledged_at", "settled_at", "snapshot_reads"} + or type(row["round"]) is not int or row["round"] != index + or type(row["snapshot_reads"]) is not int or not 1 <= row["snapshot_reads"] <= 80): + raise ValueError("history seed round inventory mismatch") + interval = (previous, row["started_at"], row["acknowledged_at"], row["settled_at"], + value["finished_at"]) + if (not all(timestamp(item) for item in interval) + or any(left > right for left, right in zip(interval, interval[1:])) + or row["settled_at"] - row["started_at"] >= 40): + raise ValueError("history seed round order or deadline mismatch") + previous = row["settled_at"] + + +def validate_managed_history_canonical(value, *, root, workspace, identity, + owner_settled_at, next_started_at): + """Check the read receipt independently; this alone never accepts a sample. + + Bounds must come from the original generation/outer owner, not from this + receipt. Paths bind the actual public read inputs, not native file identity. + """ + fields = {"started_at", "completed_at", "root", "path", "workspace", "identity", "canonical"} + identity_fields = {"product_id", "continuity_id", "session_id", "scope", "scope_fingerprint"} + expected = {"recipe": "lmux-history-128x2048/v1", "rounds": 128, + "records": 256, "text_bytes": 263680, + "sha256": "00e1c01bb0a4603b94f5fbd70ea802f24a9389471893e5883310ba7c0c0fbb41"} + if (type(value) is not dict or set(value) != fields + or type(identity) is not dict or set(identity) != identity_fields + or any(type(item) is not str or not item for item in identity.values()) + or identity["product_id"] != "coding" + or identity["scope"] not in {"cwd", "user_home"} + or type(value["identity"]) is not dict or value["identity"] != identity): + raise ValueError("history canonical identity or inventory mismatch") + session = identity["session_id"] + if ("/" in session or "\\" in session or "\x00" in session + or value["root"] != str(root) or value["workspace"] != str(workspace) + or value["path"] != str(Path(root) / f"hosted-{session}.jsonl")): + raise ValueError("history canonical read selection mismatch") + canonical = value["canonical"] + if (type(canonical) is not dict or canonical != expected + or any(type(canonical[key]) is not type(item) for key, item in expected.items())): + raise ValueError("history canonical recipe mismatch") + times = (owner_settled_at, value["started_at"], value["completed_at"], next_started_at) + if (any(type(item) not in (int, float) or item < 0 or item > sys.float_info.max + or not math.isfinite(item) for item in times) + or any(left > right for left, right in zip(times, times[1:]))): + raise ValueError("history canonical owner/read ordering mismatch") + + +def validate_managed_history_timings(attached, *, attach_started_at, restored=None, + service_started_at=None, authenticated_at=None): + """Bind history durations to real spawn endpoints, including restart gaps. + + The caller must independently validate those spawn/authentication receipts, + frames and owners. This only verifies the reviewed timing subset. + """ + if type(attached) is not dict or set(attached) != { + "actions", "history_frame_seconds", "history_completion_seconds", "detached", + }: + raise ValueError("history attach timing inventory mismatch") + actions = attached["actions"] + validate_managed_product_timings(actions, { + name + "_seconds": attached[name + "_seconds"] + for name in ("history_frame", "history_completion") + }, "history") + frame = actions["history_frame"] + if (type(attach_started_at) not in (int, float) + or attach_started_at != frame["started_at"]): + raise ValueError("history frame must start at actual attach spawn") + if restored is None: + if service_started_at is not None or authenticated_at is not None: + raise ValueError("warm history must not carry restart timing inputs") + return + if type(restored) is not dict or set(restored) != {"action", "restored_history_frame_seconds"}: + raise ValueError("restored history timing inventory mismatch") + action, duration = restored["action"], restored["restored_history_frame_seconds"] + if type(action) is not dict or set(action) != {"started_at", "finished_at"}: + raise ValueError("restored history action inventory mismatch") + times = (service_started_at, authenticated_at, attach_started_at, frame["finished_at"], + action["started_at"], action["finished_at"], duration) + if (any(type(item) not in (int, float) or item < 0 or item > sys.float_info.max + or not math.isfinite(item) for item in times) + or not service_started_at <= authenticated_at <= attach_started_at + or action["started_at"] != service_started_at + or action["finished_at"] != frame["finished_at"] + or duration != frame["finished_at"] - service_started_at): + raise ValueError("restored history must include actual startup and authentication gap") + + +def validate_managed_product_timings(actions, milestones, scenario): + """Validate only the reviewed timing subset, never whole-sample validity. + + Identity, terminal, snapshot, stop and outer-owner proofs are separate + requirements. This does not promote diagnostic receipts to accepted samples. + """ + inventories = { + "reply": ("fixed_entry_through_visible_reply", "visible_reply"), + "approval": ("approval_pending", "approval_details", "approved_tool_reply"), + "interrupt": ("interrupt_through_idle_and_producer", "next_reply", + "interrupt_through_next_reply"), + "history": ("history_frame", "history_completion"), + } + if type(scenario) is not str or scenario not in inventories: + raise ValueError("unknown managed Product timing scenario") + names = inventories[scenario] + if (type(actions) is not dict or set(actions) != set(names) + or type(milestones) is not dict + or set(milestones) != {name + "_seconds" for name in names}): + raise ValueError("managed Product timing inventory mismatch") + for name in names: + action, duration = actions[name], milestones[name + "_seconds"] + if (type(action) is not dict or set(action) != {"started_at", "finished_at"} + or any(type(item) not in (int, float) or item < 0 + or item > sys.float_info.max or not math.isfinite(item) + for item in (*action.values(), duration)) + or action["finished_at"] < action["started_at"] + or duration != action["finished_at"] - action["started_at"]): + raise ValueError("managed Product action duration mismatch") + intervals = [actions[name] for name in names] + if scenario == "reply": + cumulative, reply = intervals + ordered = (cumulative["started_at"] <= reply["started_at"] + and cumulative["finished_at"] == reply["finished_at"]) + elif scenario in {"approval", "history"}: + ordered = all(left["finished_at"] <= right["started_at"] + for left, right in zip(intervals, intervals[1:])) + else: + interrupted, reply, cumulative = intervals + ordered = (interrupted["finished_at"] <= reply["started_at"] + and cumulative["started_at"] == interrupted["started_at"] + and cumulative["finished_at"] == reply["finished_at"]) + if not ordered: + raise ValueError("managed Product action order or shared endpoint mismatch") + + +def validate_managed_product_terminals(child, scenario, *, prefix, workspace, instance, service): + """Bind every normal foreground lifetime to its spawn and exact stop. + + The caller still owes authenticated identity/snapshot/effect validation and + original outer-process settlement. A local stop is not outer settlement. + """ + def timestamp(value): + return (type(value) in (int, float) and 0 <= value <= sys.float_info.max + and math.isfinite(value)) + + if (scenario not in ("reply", "approval", "interrupt") or type(child) is not dict + or child.get("status") != "observed" or child.get("valid") is not False + or any(key in child for key in ("failure", "fixed_product_cleanup_failure", + "diagnostic_instrumentation", "terminal_transport")) + or child.get("measured_prefix") != str(prefix) + or child.get("workspace") != str(workspace) + or not timestamp(child.get("started_at")) or not timestamp(child.get("finished_at"))): + raise ValueError("managed Product terminal scenario invalid") + if (type(instance) is not str or re.fullmatch(r"[0-9a-f]{32}", instance) is None + or type(service) is not str or re.fullmatch(r"[0-9a-f]{64}", service) is None): + raise ValueError("managed Product stop identity invalid") + spawns, terminals = child.get("spawns"), child.get("terminal_settlements") + count = 2 if scenario == "interrupt" else 1 + if type(spawns) is not list or type(terminals) is not list or len(spawns) != count or len(terminals) != count: + raise ValueError("managed Product terminal inventory mismatch") + previous = child["started_at"] + for index, (spawn, terminal) in enumerate(zip(spawns, terminals, strict=True)): + cwd = workspace if index == 0 else workspace / "elsewhere" + argv = ([str(prefix / "bin/python"), "-I", + str(inert.ROOT / "tests/coding/_lmux_product_entry.py"), "new", "-s", "perf"] + if index == 0 else [str(prefix / "bin/lmux"), "attach", "-t", "perf"]) + if (type(spawn) is not dict or set(spawn) != {"pid", "start", "argv", "cwd"} + or type(spawn["pid"]) is not int or spawn["pid"] <= 0 + or not timestamp(spawn["start"]) or spawn["argv"] != argv or spawn["cwd"] != str(cwd) + or type(terminal) is not dict or set(terminal) != { + "pid", "argv", "cwd", "exit_status", "termios_restored_at", "settled_at", + "cursor_restored", "bracketed_paste_disabled", "reader_settled", "fallback"} + or type(terminal["pid"]) is not int or terminal["pid"] != spawn["pid"] + or terminal["argv"] != argv or terminal["cwd"] != str(cwd) + or type(terminal["exit_status"]) is not int or terminal["exit_status"] != 0 + or any(terminal[key] is not True for key in ( + "cursor_restored", "bracketed_paste_disabled", "reader_settled")) + or terminal["fallback"] is not False + or not timestamp(terminal["termios_restored_at"]) or not timestamp(terminal["settled_at"]) + or not previous <= spawn["start"] <= terminal["termios_restored_at"] <= terminal["settled_at"]): + raise ValueError("managed Product foreground identity or settlement mismatch") + previous = terminal["settled_at"] + stop = child.get("fixed_product_stop") + if (type(stop) is not dict or set(stop) != { + "started_at", "observed_at", "local_owner_settled_at", "service_id", "result"} + or stop["service_id"] != service + or stop["result"] != {"status": "stopped", "instanceId": instance} + or not all(timestamp(stop[key]) for key in ( + "started_at", "observed_at", "local_owner_settled_at")) + or not previous <= stop["started_at"] <= stop["observed_at"] + <= stop["local_owner_settled_at"] <= child["finished_at"]): + raise ValueError("managed Product stop order or identity mismatch") + + +def validate_managed_product_confirmation(value, target, *, expected_reply, records, earliest, latest, pending=False): + """Bind a post-detach snapshot to the original target and expected records. + + This validates trusted observer values, not a new authentication channel. + Source/installation provenance and outer cleanup remain caller obligations. + """ + def timestamp(item): + return (type(item) in (int, float) and 0 <= item <= sys.float_info.max + and math.isfinite(item)) + + fields = {"stage", "observed_at", "instanceId", "serviceId", "muxId", "members"} + flag = "pendingConfirmed" if pending else "replyConfirmed" + if (type(pending) is not bool or type(value) is not dict or set(value) != fields | {flag, "snapshot"} + or type(target) is not dict or set(target) != fields + or target["stage"] != "first-member" or value["stage"] != "detached" + or value[flag] is not True or not all(timestamp(item) for item in ( + earliest, latest, target["observed_at"], value["observed_at"])) + or not target["observed_at"] <= earliest <= value["observed_at"] <= latest + or any(value[key] != target[key] for key in ("instanceId", "serviceId", "muxId", "members"))): + raise ValueError("managed Product confirmation target or order mismatch") + for name, pattern in (("instanceId", r"[0-9a-f]{32}"), ("serviceId", r"[0-9a-f]{64}"), + ("muxId", r"[A-Za-z0-9][A-Za-z0-9._~-]{0,511}")): + if type(target[name]) is not str or re.fullmatch(pattern, target[name]) is None: + raise ValueError("managed Product original identity invalid") + members = target["members"] + if (type(members) is not list or len(members) != 1 or type(members[0]) is not dict + or set(members[0]) != {"memberId", "sessionId"} + or any(type(item) is not str or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._~-]{0,511}", item) is None + for item in members[0].values())): + raise ValueError("managed Product member identity invalid") + snapshot = value["snapshot"] + if (type(snapshot) is not dict or set(snapshot) != {"confirmed_at", "running", "expected_reply", "identity", "records"} + or snapshot["running"] is not pending or snapshot["expected_reply"] != expected_reply + or snapshot["records"] != records or type(snapshot["records"]) is not list + or not timestamp(snapshot["confirmed_at"]) + or not value["observed_at"] <= snapshot["confirmed_at"] <= latest): + raise ValueError("managed Product snapshot records or order mismatch") + identity = snapshot["identity"] + if (type(identity) is not dict or set(identity) != { + "product_id", "continuity_id", "session_id", "scope", "scope_fingerprint"} + or identity["product_id"] != "coding" or identity["scope"] != "user_home" + or identity["session_id"] != members[0]["sessionId"] + or type(identity["continuity_id"]) is not str + or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._~-]{0,511}", identity["continuity_id"]) is None + or type(identity["scope_fingerprint"]) is not str + or re.fullmatch(r"[0-9a-f]{64}", identity["scope_fingerprint"]) is None): + raise ValueError("managed Product snapshot scope identity mismatch") + + +def validate_managed_product_scenario(child, scenario, *, prefix, workspace): + """Combine timing, foreground, original-target and final-record checks. + + The outer collector must still validate all three fresh scenarios, frozen + inputs and its original evidence owner's physical settlement. + """ + result_keys = {"reply": "fixed_product_first_reply", "approval": "fixed_product_tool_approval", + "interrupt": "fixed_product_interrupt_next_turn"} + if type(scenario) is not str or scenario not in result_keys: + raise ValueError("unknown managed Product scenario") + fields = {"measured_prefix", "workspace", "status", "valid", "spawns", "started_at", "finished_at", + "terminal_settlements", "fixed_product_stop", "fixed_product_target", "fixed_product_detached", + "actions", "milestones", "fixed_product_native", result_keys[scenario]} + if type(child) is not dict or set(child) != fields: + raise ValueError("managed Product scenario field inventory mismatch") + natives = child["fixed_product_native"] + stages = {"first-member", "detached", "stop"} | ({"reattached", "final-detached"} if scenario == "interrupt" else set()) + if type(natives) is not dict or set(natives) != stages: + raise ValueError("managed Product native identity stages missing") + native = natives["first-member"] + if (type(native) is not dict or set(native) != { + "pid", "start_ticks", "boot_id", "user_id", "pid_namespace_device", "pid_namespace_inode"} + or type(native["pid"]) is not int or not 1 <= native["pid"] < 2**31 + or type(native["boot_id"]) is not str + or re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", native["boot_id"]) is None + or any(type(native[key]) is not int or not 0 <= native[key] < 2**64 + for key in ("start_ticks", "user_id", "pid_namespace_device", "pid_namespace_inode")) + or native["pid_namespace_inode"] == 0 + or any(type(item) is not dict or item != native + or any(type(item[key]) is not type(native[key]) for key in native) + for item in natives.values())): + raise ValueError("managed Product native identity differs or is malformed") + try: + target, result = child["fixed_product_target"], child[result_keys[scenario]] + if type(target) is not dict or type(result) is not dict: + raise ValueError("managed Product result or target invalid") + validate_managed_product_timings(child["actions"], child["milestones"], scenario) + validate_managed_product_terminals(child, scenario, prefix=prefix, workspace=workspace, + instance=target.get("instanceId"), service=target.get("serviceId")) + actions, terminals, stop = child["actions"], child["terminal_settlements"], child["fixed_product_stop"] + timing = result["next_turn"] if scenario == "interrupt" else result + duration_keys = {"spawn_through_visible_reply_seconds" if name == "fixed_entry_through_visible_reply_seconds" else name + for name in child["milestones"]} + expected_result = ( + duration_keys | {"actions", "request_nonce", "confirmation", "stop", "composition"} + if scenario == "reply" else duration_keys | {"actions", "approved_sent_ns", "confirmation", "stop", + "tool_call_id", "tool_execution_count", "tool_effects"} + if scenario == "approval" else {"request_nonce", "pending_confirmation", "stop", "producer", + "producer_settled", "full_text_not_completed", "next_turn"} + ) + if set(result) != expected_result: + raise ValueError("managed Product raw result inventory mismatch") + if scenario == "interrupt" and (type(timing) is not dict or set(timing) != duration_keys | { + "actions", "next_reply_confirmation", "interrupted_nonce", "next_nonce", "interrupt_sent_ns", + "producer_before", "producer_after", "reattached", "detached"}): + raise ValueError("managed Product next-turn inventory mismatch") + if timing["actions"] != actions or result["stop"] != stop["result"]: + raise ValueError("managed Product duplicated evidence disagrees") + for name, duration in child["milestones"].items(): + key = "spawn_through_visible_reply_seconds" if name == "fixed_entry_through_visible_reply_seconds" else name + if type(timing[key]) not in (int, float) or timing[key] != duration: + raise ValueError("managed Product raw duration disagrees") + + def observation(value, stage, earliest, latest): + if (type(value) is not dict or set(value) != {"stage", "observed_at", "instanceId", "serviceId", "muxId", "members"} + or value["stage"] != stage or type(value["observed_at"]) not in (int, float) + or not earliest <= value["observed_at"] <= latest + or any(value[key] != target[key] for key in ("instanceId", "serviceId", "muxId", "members"))): + raise ValueError("managed Product authenticated observation mismatch") + + def nonce(value): + if type(value) is not str or re.fullmatch(r"[0-9a-f]{32}", value) is None: + raise ValueError("managed Product request nonce invalid") + return value + + def confirm(value, expected, records, terminal_index, latest, *, pending=False): + validate_managed_product_confirmation(value, target, expected_reply=expected, records=records, + earliest=terminals[terminal_index]["settled_at"], latest=latest, pending=pending) + + def record(kind, text): + return {"kind": kind, "text": text} + + def trace(rows, phases): + if type(rows) is not list or len(rows) != len(phases): + raise ValueError("managed Product trace inventory mismatch") + previous = -1 + for row, phase in zip(rows, phases, strict=True): + keys = {"instance_id", "call_id", "monotonic_ns", "sequence"} | ({"phase"} if phase else set()) + if (type(row) is not dict or set(row) != keys or row["instance_id"] != target["instanceId"] + or row["call_id"] != "lmux-call-1" or (phase and row["phase"] != phase) + or type(row["sequence"]) is not int or not previous < row["sequence"] + or type(row["monotonic_ns"]) is not int or not 0 <= row["monotonic_ns"] < 2**64): + raise ValueError("managed Product original trace invalid") + previous = row["sequence"] + + first_action = actions["visible_reply"] if scenario == "reply" else actions[ + "approval_pending" if scenario == "approval" else "interrupt_through_idle_and_producer"] + last_action = actions["visible_reply" if scenario == "reply" else "approved_tool_reply" if scenario == "approval" else "next_reply"] + observation(target, "first-member", child["spawns"][0]["start"], first_action["started_at"]) + if last_action["finished_at"] > terminals[-1]["termios_restored_at"]: + raise ValueError("managed Product completion follows terminal restoration") + if scenario == "reply": + request = nonce(result["request_nonce"]) + expected = "LMUX_REPLY_" + request + if (result["composition"] != "managed-infrastructure-with-fixed-test-product" + or actions["fixed_entry_through_visible_reply"]["started_at"] != child["spawns"][0]["start"]): + raise ValueError("managed Product fixed-entry start mismatch") + confirm(result["confirmation"], expected, [record("user", "reply " + request), record("assistant", expected)], 0, stop["started_at"]) + elif scenario == "approval": + expected = "LMUX_TOOL_COMPLETED" + confirm(result["confirmation"], expected, [record("user", "approval"), record("assistant", ""), record("assistant", expected)], 0, stop["started_at"]) + if type(result["tool_execution_count"]) is not int or result["tool_execution_count"] != 1 or result["tool_call_id"] != "lmux-call-1": + raise ValueError("managed Product tool effect count invalid") + trace(result["tool_effects"], [None]) + approved = result["approved_sent_ns"] + if (type(approved) is not int or not actions["approved_tool_reply"]["started_at"] + <= approved / 1e9 <= result["tool_effects"][0]["monotonic_ns"] / 1e9 <= last_action["finished_at"]): + raise ValueError("managed Product tool effect predates approval or follows completion") + else: + request, following = nonce(result["request_nonce"]), nonce(timing["next_nonce"]) + if (timing["interrupted_nonce"] != request or following == request or result["producer"] != "lmux-call-1" + or result["producer_settled"] is not True or result["full_text_not_completed"] is not True): + raise ValueError("managed Product interrupted request identity invalid") + before_reply = "LMUX_REPLY_" + request + confirm(result["pending_confirmation"], before_reply, [record("user", "delayed " + request)], 0, child["spawns"][1]["start"], pending=True) + observation(timing["reattached"], "reattached", child["spawns"][1]["start"], first_action["started_at"]) + trace(timing["producer_before"], ["producer_started"]) + trace(timing["producer_after"], ["producer_started", "producer_settled"]) + sent = timing["interrupt_sent_ns"] + if (timing["producer_before"][0] != timing["producer_after"][0] or type(sent) is not int + or not first_action["started_at"] <= sent / 1e9 + <= timing["producer_after"][1]["monotonic_ns"] / 1e9 <= first_action["finished_at"] + or timing["producer_before"][0]["monotonic_ns"] > sent): + raise ValueError("managed Product producer settlement order invalid") + expected = "LMUX_REPLY_" + following + confirm(timing["next_reply_confirmation"], expected, [record("user", "delayed " + request), record("assistant", ""), + record("user", "reply " + following), record("assistant", expected)], 1, stop["started_at"]) + if result["pending_confirmation"]["snapshot"]["identity"] != timing["next_reply_confirmation"]["snapshot"]["identity"]: + raise ValueError("managed Product interrupt changed Session identity") + observation(timing["detached"], "detached", timing["next_reply_confirmation"]["snapshot"]["confirmed_at"], stop["started_at"]) + confirmation = result["pending_confirmation"] if scenario == "interrupt" else result["confirmation"] + observation(child["fixed_product_detached"], "detached", confirmation["snapshot"]["confirmed_at"], + child["spawns"][1]["start"] if scenario == "interrupt" else stop["started_at"]) + except (KeyError, TypeError, IndexError, OverflowError) as error: + raise ValueError("malformed managed Product scenario evidence") from error + + +def validate_managed_product_collection(value, prefix, root): + """Validate all three fresh scenarios inside a settled original observer.""" + children, outer = value.get("fixed_product_scenarios"), value.get("outer_settlement") + if (type(children) is not dict or set(children) != {"reply", "approval", "interrupt"} + or type(outer) is not dict or set(outer) != {"started_at", "settled_at"} + or any(type(item) not in (int, float) or not 0 <= item <= sys.float_info.max or not math.isfinite(item) + for item in outer.values()) + or not outer["started_at"] < outer["settled_at"] + or value.get("status") != "observed" or value.get("valid") is not False + or value.get("seed") != "empty" or value.get("seed_setup") != []): + raise ValueError("managed Product collection inventory or outer settlement invalid") + previous, milestones, spawns = outer["started_at"], {}, [] + instances, services, sessions = set(), set(), set() + for scenario in ("reply", "approval", "interrupt"): + child = children[scenario] + validate_managed_product_scenario(child, scenario, prefix=prefix, workspace=root / "workspace" / scenario) + if not previous <= child["started_at"] <= child["finished_at"] <= outer["settled_at"]: + raise ValueError("managed Product fresh scenarios overlap or outlive observer") + previous = child["finished_at"] + target = child["fixed_product_target"] + result = child[{"reply": "fixed_product_first_reply", "approval": "fixed_product_tool_approval", + "interrupt": "fixed_product_interrupt_next_turn"}[scenario]] + confirmation = result["pending_confirmation"] if scenario == "interrupt" else result["confirmation"] + identity = confirmation["snapshot"]["identity"] + session = tuple(identity[key] for key in ("product_id", "continuity_id", "session_id", "scope", "scope_fingerprint")) + if target["instanceId"] in instances or target["serviceId"] in services or session in sessions: + raise ValueError("managed Product scenarios did not use fresh service and Session identities") + instances.add(target["instanceId"]) + services.add(target["serviceId"]) + sessions.add(session) + if milestones.keys() & child["milestones"].keys(): + raise ValueError("managed Product scenario metrics overlap") + milestones.update(child["milestones"]) + spawns.extend(child["spawns"]) + if type(value.get("milestones")) is not dict or value["milestones"] != milestones or value.get("spawns") != spawns: + raise ValueError("managed Product summary differs from original child evidence") + + +def complete_managed_product(value, *, observer_started, owner_settled, prefix, root): + """Call only after original owner.run_python succeeds; never set valid here.""" + if (type(value) is not dict or set(value) != OBSERVER_FIELDS | {"fixed_product_scenarios"} + or value.get("case") != "managed-product-first-use" or value.get("schema_version") != 2 + or value.get("status") != "observed" or value.get("valid") is not False + or value.get("measured_prefix") != str(prefix) or value.get("sample_id") != str(root.resolve())): + raise ValueError("managed Product receipt cannot be completed") + completed = {**value, "outer_settlement": {"started_at": observer_started, "settled_at": owner_settled}} + validate_managed_product_collection(completed, prefix, root) + return completed + + +def complete_managed_stop(value, *, observer_started, owner_settled): + """Add the stop duration only after the original outer owner has settled. + + The trusted installed observer records the stop command's exact-instance + success, not its own physical exit. The caller must invoke this only after + owner.run_python returns successfully; this pure transform is not cleanup. + Other provenance, frame and authenticated membership checks remain required. + """ + if ( + type(value) is not dict + or value.get("case") != "managed-mux" + or value.get("status") != "observed" + or value.get("valid") is not False + or type(value.get("milestones")) is not dict + or "stop_settlement_seconds" in value["milestones"] + ): + raise ValueError("managed stop must await outer settlement") + stop = value.get("managed_stop") + if ( + type(stop) is not dict + or set(stop) != {"started_at", "result"} + or type(stop["result"]) is not dict + or set(stop["result"]) != {"status", "instanceId"} + or stop["result"]["status"] != "stopped" + or type(value.get("authenticated_instance_id")) is not str + or re.fullmatch(r"[0-9a-f]{32}", value["authenticated_instance_id"]) is None + or stop["result"]["instanceId"] != value["authenticated_instance_id"] + ): + raise ValueError("managed stop lacks exact authenticated instance success") + times = (observer_started, stop["started_at"], owner_settled) + if ( + any(type(item) not in (int, float) or not math.isfinite(item) or item <= 0 for item in times) + or not observer_started <= stop["started_at"] < owner_settled + ): + raise ValueError("managed stop lies outside original observer lifetime") + return { + **value, + "milestones": { + **value["milestones"], + "stop_settlement_seconds": owner_settled - stop["started_at"], + }, + } + + def run_sample( prefix, case, @@ -385,6 +1345,10 @@ def run_sample( defer_validation=False, ): parent_environment = dict(os.environ) + if case in OPTIONAL_CASES: + # mkdir(parents=True) applies mode only to the final workspace, not + # its sample parent. Managed admission also checks ancestor modes. + root.mkdir(mode=0o700, exist_ok=True) workspace = root / "workspace" workspace.mkdir(parents=True, mode=0o700) environment = inert.private_environment(root / "environment", prefix, bytecode) @@ -415,6 +1379,7 @@ def run_sample( try: if before_launch is not None: before_launch(attempt) + observer_started = time.perf_counter() owner.run_python( [ str(observer_prefix / "bin/python"), @@ -430,13 +1395,36 @@ def run_sample( environment=environment, # Includes trusted fixture setup; Product deadlines inside stay at # their existing G14/G16/G17 values. Recovery has two untimed seeds. - timeout=360 + # Three fresh scenarios retain existing inner deadlines. + # History: original verification660 + 600 per generation for + # serial terminal/auth/stop/read phases + 120 outer cleanup reserve. + # No inner operation deadline is extended by this outer ceiling. + timeout=(660 + 600 * (2 if case == HISTORY_CASES[1] else 1) + 120) + if case in HISTORY_CASES + else 3 * 240 + if case == "managed-product-first-use" + else 360 if case == "home-isolation" else 240 - if case.startswith("recovery-") or case == "product-first-use" + if case.startswith("recovery-") or case in {"product-first-use", "managed-mux"} else 150, ) + owner_settled = time.perf_counter() observed = json.loads(receipt.read_text()) + if case == "managed-mux": + observed = complete_managed_stop( + observed, observer_started=observer_started, owner_settled=owner_settled, + ) + elif case == "managed-product-first-use": + observed = complete_managed_product( + observed, observer_started=observer_started, owner_settled=owner_settled, + prefix=prefix, root=root, + ) + elif case in HISTORY_CASES: + observed = complete_managed_history( + observed, observer_started=observer_started, owner_settled=owner_settled, + prefix=prefix, root=root, + ) validate_observation( observed, case, @@ -467,6 +1455,16 @@ def run_sample( "sample_id", }: attempt[key] = observed[key] + if case == "managed-mux": + for key in MANAGED_OBSERVER_FIELDS: + attempt[key] = observed[key] + elif case == "managed-product-first-use": + for key in ("fixed_product_scenarios", "outer_settlement"): + attempt[key] = observed[key] + elif case in HISTORY_CASES: + extras = HISTORY_WARM_FIELDS if case == HISTORY_CASES[0] else HISTORY_RESTORE_FIELDS + for key in (extras - OBSERVER_FIELDS) | {"outer_settlement"}: + attempt[key] = observed[key] if case == "home-isolation": attempt["isolation"] = observed["isolation"] elif case == "g14-stdio": @@ -991,6 +1989,10 @@ def prepare(prefix, *, case=case, state=state, attempt=attempt): ) control = state.control if state is not None else root / "control" if state is None: + if case in OPTIONAL_CASES: + # Create the private ancestor before control's + # parents=True can apply the ambient umask to it. + root.mkdir(mode=0o700) control.mkdir(parents=True) inert.write_report(path, report) @@ -1232,7 +2234,7 @@ def main(argv=None): action="store_true", help="four real HOME controls per installation, without timing samples", ) - selection.add_argument("--cases", nargs="+", choices=CASES, default=list(CASES)) + selection.add_argument("--cases", nargs="+", choices=(*CASES, *OPTIONAL_CASES), default=list(CASES)) parser.add_argument("--blocks", type=int, default=2) parser.add_argument("--pairs-per-block", type=int, default=10) args = parser.parse_args(argv) @@ -1276,6 +2278,27 @@ def main(argv=None): return collect_main(args, parser) +def compare_selected_samples(args, samples, *, phase): + """Select an existing frozen policy; partial diagnostics never pass.""" + if ( + args.fixed_slot and args.blocks == 2 and args.pairs_per_block == 10 + and (any(args.cases == [case] for case in OPTIONAL_CASES) or set(args.cases) == set(CASES)) + ): + comparison = inert.comparison_module() + if args.cases == ["managed-product-first-use"]: + compare = comparison.compare_managed_product + elif len(args.cases) == 1 and args.cases[0] in HISTORY_CASES: + compare = partial(comparison.compare_managed_history, case=args.cases[0]) + else: + compare = comparison.compare_managed if args.cases == ["managed-mux"] else comparison.compare_native + return compare(samples, cache_mode=args.cache_mode, phase=phase, + blocks=args.blocks, pairs_per_block=args.pairs_per_block) + return { + "verdict": "not-evaluated", + "reason": "requires all seven original cases or one explicit managed case, fixed-slot, and exactly two blocks of ten pairs", + } + + def collect_main(args, parser, campaign=None): if platform.system() != "Linux": parser.error("Linux native collection only") @@ -1285,6 +2308,11 @@ def collect_main(args, parser, campaign=None): or len(set(args.cases)) != len(args.cases) ): parser.error("positive counts and unique cases required") + if set(OPTIONAL_CASES) & set(args.cases) and ( + len(args.cases) != 1 or args.home_isolation_only + or args.seed_preparation_diagnostic or args.restored_recovery_preflight + ): + parser.error("managed cases require their own explicit native campaign") if args.home_isolation_only: args.cases, args.blocks, args.pairs_per_block = ["home-isolation"], 1, 1 if args.seed_preparation_diagnostic: @@ -1535,23 +2563,7 @@ def collect_main(args, parser, campaign=None): report["helpers_after"] = inert.provenance_module().helper_manifest(inert.ROOT) if report["helpers_after"] != report["helpers_before"]: raise ValueError("trusted helper inputs changed during measurement") - if ( - args.fixed_slot - and set(args.cases) == set(CASES) - and args.blocks == 2 - and args.pairs_per_block == 10 - ): - report["comparison"] = inert.comparison_module().compare_native( - report["samples"], - cache_mode=args.cache_mode, - phase=mode, - blocks=args.blocks, - pairs_per_block=args.pairs_per_block, - ) - else: - report["comparison"]["reason"] = ( - "requires all seven fixed-slot cases and exactly two blocks of ten pairs" - ) + report["comparison"] = compare_selected_samples(args, report["samples"], phase=mode) report["status"] = "complete-record-only" if campaign is not None: campaign.finish(report) diff --git a/scripts/dev/reevaluate_g18_comparison.py b/scripts/dev/reevaluate_g18_comparison.py new file mode 100644 index 000000000..a2b9088ae --- /dev/null +++ b/scripts/dev/reevaluate_g18_comparison.py @@ -0,0 +1,182 @@ +"""Re-evaluate an existing G18 campaign under the accepted ARD-004 contract. + +This is a read-only re-evaluation, never a new measurement and never a rewrite: + +- the original ``report.json`` is only read; its sha256 is recorded so the + re-evaluation stays bound to the exact bytes it interpreted; +- the verdict is recomputed with the currently accepted constants in + ``_g18_comparison`` (ARD-004: ``STABILITY_RATIO`` 1/4, ``REGRESSION_RATIO`` + 3/10); +- output goes to a new file and refuses to overwrite anything. + +A ``pass`` here means "no regression beyond the accepted ratio was detected +under the new gate". It is not proof that no regression exists: the accepted +ratio bounds what this design can detect, and that limit must travel with the +result. This module never prints or implies "performance accepted". +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent +CASES = ( + "embedded", + "foreground", + "local-mux", + "g14-stdio", + "recovery-cwd", + "recovery-global", + "product-first-use", +) +OPTIONAL_CASES = ( + "managed-mux", + "managed-product-first-use", + "managed-product-history-warm", + "managed-product-history-restore", +) +HISTORY_CASES = ( + "managed-product-history-warm", + "managed-product-history-restore", +) +SUPPORT_NAMES = ( + "_g18_comparison", + "_g18_provenance", + "_g18_recovery", + "_g18_slot", + "_g18_bytecode", + "_g18_checkpoint", +) + + +def _support(name): + spec = importlib.util.spec_from_file_location(name, SCRIPT / (name + ".py")) + if spec is None or spec.loader is None: + raise ValueError(f"comparison support missing: {name}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def digest(path): + with Path(path).open("rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + + +def select_comparator(comparison, report): + """Mirror the collector's own frozen dispatch, refusing anything else.""" + cases = report.get("cases") + blocks = report.get("blocks") + pairs = report.get("pairs_per_block") + if type(cases) is not list or blocks != 2 or pairs != 10: + raise ValueError( + "re-evaluation requires the frozen two-block ten-pair policy" + ) + if cases == ["managed-mux"]: + return lambda samples, **kw: comparison.compare_managed(samples, **kw) + if len(cases) == 1 and cases[0] in HISTORY_CASES: + return lambda samples, **kw: comparison.compare_managed_history( + samples, case=cases[0], **kw + ) + if cases == ["managed-product-first-use"]: + return lambda samples, **kw: comparison.compare_managed_product( + samples, **kw + ) + if set(cases) == set(CASES): + return lambda samples, **kw: comparison.compare_native(samples, **kw) + raise ValueError("campaign does not match a frozen comparison policy") + + +def reevaluate(report_path, output): + comparison = _support("_g18_comparison") + report_path = Path(report_path) + output = Path(output) + if output.exists(): + raise ValueError("refusing to overwrite an existing re-evaluation") + source_sha256 = digest(report_path) + report = json.loads(report_path.read_text()) + recorded = report.get("comparison") or {} + samples = report.get("samples") + if report.get("status") != "complete-record-only" or not isinstance(samples, list): + raise ValueError("only a completed uninterrupted campaign can be re-evaluated") + # A checkpoint-enabled campaign is fine only if it never actually resumed: + # exactly one uninterrupted segment and automatically eligible. A real + # resume records extra segments and sets eligible_for_automatic_acceptance + # false, which needs its own separately declared calibration audit. + segments = report.get("segments") + if segments is not None: + if type(segments) is not list or len(segments) != 1: + raise ValueError("resumed campaigns need their own calibration audit") + acceptance = report.get("segmented_acceptance") + if ( + type(acceptance) is not dict + or acceptance.get("eligible_for_automatic_acceptance") is not True + ): + raise ValueError("resumed campaigns need their own calibration audit") + phase = recorded.get("phase") + cache_mode = recorded.get("cache_mode") + if phase not in {"aa", "ab"} or cache_mode not in {"warm", "absent"}: + raise ValueError("recorded phase or cache condition is unusable") + compare = select_comparator(comparison, report) + result = compare( + samples, + cache_mode=cache_mode, + phase=phase, + blocks=report["blocks"], + pairs_per_block=report["pairs_per_block"], + ) + value = { + "schema_version": 1, + "record_kind": "g18-contract-reevaluation-not-a-new-measurement", + "decision": "apphost/ARD-004 (accepted 2026-09-18)", + "source_report": str(report_path.resolve()), + "source_report_sha256": source_sha256, + "source_status": report["status"], + "source_cases": report["cases"], + "source_phase": phase, + "source_cache_mode": cache_mode, + "source_verdict_before": recorded.get("verdict"), + "contract": { + "stability_ratio": str(comparison.STABILITY_RATIO), + "regression_ratio": str(comparison.REGRESSION_RATIO), + "reason": "unstaged judgement constants replaced by the accepted ADR pair", + }, + "verdict": result["verdict"], + "comparison": result, + "claims": { + "is_new_measurement": False, + "is_performance_acceptance": False, + "statements": [ + "verdict change is caused by the judgement contract, not by new data", + "a pass means no regression beyond the accepted ratio was detected", + "regressions below the accepted ratio are not detectable by this design", + "the original report was not modified; this file references its sha256", + ], + }, + } + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n") + return value + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--report", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args(argv) + value = reevaluate(args.report, args.output) + print( + f"{value['source_report']}: " + f"{value['source_verdict_before']} -> {value['verdict']} " + f"(contract change, not a new measurement)", + flush=True, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/dev/test_g18_comparison.py b/tests/dev/test_g18_comparison.py index aa3382c21..366b80c3c 100644 --- a/tests/dev/test_g18_comparison.py +++ b/tests/dev/test_g18_comparison.py @@ -71,9 +71,11 @@ def test_native_policy_checks_all_41_metrics_without_mutating_inputs(cache_mode, ) def test_any_native_metric_regression_is_not_hidden_by_gains_elsewhere(case, metric): data = native_samples(right=0.5) + # Must exceed the accepted regression ratio against the 1.0 baseline. + regressed = 1.0 * (1 + float(comparison.REGRESSION_RATIO)) + 0.01 for sample in data: if sample["case"] == case and sample["side"] == "b": - sample["milestones"][metric] = math.nextafter(1.1, math.inf) + sample["milestones"][metric] = regressed result = comparison.compare_native(data, cache_mode="warm") assert result["verdict"] == "regression" assert result["cases"][case][metric]["verdict"] == "regression" @@ -188,7 +190,7 @@ def test_large_real_gain_is_not_misclassified_as_cross_variant_instability(): [ ("cli-help", 8.0, "target-not-met"), ("hosted-tui-help", 7.0, "pass"), - ("import-coding", 11.1, "regression"), + ("import-coding", 10.0 * (1 + float(comparison.REGRESSION_RATIO)) + 1e-9, "regression"), ("mux-help", 10.5, "pass"), ], ) @@ -249,16 +251,21 @@ def test_unfrozen_case_or_metric_is_not_silently_given_a_policy(case, metric): @pytest.mark.parametrize( - "case,left,boundary,rejected", + "case,left,ratio,rejected", [ - ("cli-help", 3.0, 2.1, "target-not-met"), - ("import-coding", 0.3, 0.33, "regression"), - ("import-harness", 0.1, 0.12, "regression"), + # cli-help keeps the unchanged priority required-gain boundary; this + # ARD changed the regression ratio only. + ("cli-help", 3.0, None, "target-not-met"), + ("import-coding", 0.3, comparison.REGRESSION_RATIO, "regression"), + ("import-harness", 0.1, comparison.REGRESSION_RATIO, "regression"), ], ) def test_exact_gain_and_regression_boundary_and_next_float( - case, left, boundary, rejected + case, left, ratio, rejected ): + # The regression boundary is the accepted ratio, not a frozen 10%. The + # required-gain case keeps its own unchanged 3/10 policy boundary. + boundary = 2.1 if ratio is None else left * (1 + float(ratio)) assert ( comparison.compare_case( samples(case=case, left=left, right=boundary), case=case @@ -274,9 +281,11 @@ def test_exact_gain_and_regression_boundary_and_next_float( ) -@pytest.mark.parametrize("minimum,boundary", [(0.3, 0.33), (0.1, 0.12)]) +@pytest.mark.parametrize("minimum", [0.3, 0.1]) @pytest.mark.parametrize("outside", [False, True]) -def test_stability_span_boundary_is_exact(minimum, boundary, outside): +def test_stability_span_boundary_is_exact(minimum, outside): + # Boundary follows the accepted stability ratio, including its 1/50 floor. + boundary = minimum + max(minimum * float(comparison.STABILITY_RATIO), 1 / 50) data = samples(case="import-harness", left=minimum, right=minimum) for sample in data: if sample["block"] == 1: @@ -292,9 +301,14 @@ def test_preflight_cannot_be_reported_as_full_comparison(): comparison.compare_case(samples(), case="cli-help", blocks=1, pairs_per_block=1) -@pytest.mark.parametrize("low,high", [(0.9, 1.1), (0.09, 0.11)]) +@pytest.mark.parametrize("low", [0.9, 0.09, 0.02]) @pytest.mark.parametrize("outside", [False, True]) -def test_mad_relative_and_absolute_boundaries(low, high, outside): +def test_mad_relative_and_absolute_boundaries(low, outside): + # At the boundary MAD == max(median * STABILITY_RATIO, 1/100) exactly; the + # small-`low` case exercises the absolute floor branch. + ratio = float(comparison.STABILITY_RATIO) + relative = low * (1 + ratio) / (1 - ratio) + high = relative if (low + relative) / 2 * ratio >= 1 / 100 else low + 1 / 50 data = samples(case="import-harness") for sample in data: sample["elapsed_seconds"] = ( diff --git a/tests/dev/test_lmux_comparison.py b/tests/dev/test_lmux_comparison.py new file mode 100644 index 000000000..1da5d4298 --- /dev/null +++ b/tests/dev/test_lmux_comparison.py @@ -0,0 +1,148 @@ +"""Independent inventory and fail-closed checks for the optional managed policy.""" + +from __future__ import annotations + +import copy + +import pytest + +from .test_g18_comparison import comparison, samples + +EXPECTED = ( + "cold_frame first_completion first_member_ready cold_through_first_member " + "warm_member_ready detach_settlement warm_attach_frame " + "reattach_detach_settlement stop_settlement" +).split() + +PRODUCT_EXPECTED = ( + "fixed_entry_through_visible_reply visible_reply approval_pending approval_details " + "approved_tool_reply interrupt_through_idle_and_producer next_reply interrupt_through_next_reply" +).split() + + +@pytest.mark.parametrize("case,names", [ + ("managed-product-history-warm", ("history_frame_seconds", "history_completion_seconds")), + ("managed-product-history-restore", ("restored_history_frame_seconds",)), +]) +@pytest.mark.parametrize("fault", [None, "regression", "missing", "extra", "unfinished"]) +def test_history_uses_exact_independent_inventory(case, names, fault): + data = managed_samples() + for item in data: + item.update(case=case, milestones={name: 1.0 for name in names}) + if fault == "regression" and item["side"] == "b": + item["milestones"][names[0]] = REGRESSED + if fault == "missing": + data.pop() + elif fault == "extra": + data[-1]["milestones"]["unknown_seconds"] = 1.0 + elif fault == "unfinished": + data[-1]["status"] = "observed" + if fault in {"missing", "extra", "unfinished"}: + with pytest.raises(ValueError): + comparison.compare_managed_history(data, case=case, cache_mode="warm") + else: + result = comparison.compare_managed_history(data, case=case, cache_mode="warm") + assert result["verdict"] == ("pass" if fault is None else "regression") + assert set(result["cases"][case]) == set(names) + assert case not in comparison.NATIVE_METRICS + + +@pytest.mark.parametrize("fault", [None, *PRODUCT_EXPECTED, "missing", "extra", "unfinished"]) +def test_first_use_policy_checks_every_metric_without_changing_old_inventory(fault): + data = managed_samples() + for item in data: + item["case"] = "managed-product-first-use" + item["milestones"] = {name + "_seconds": 1.0 for name in PRODUCT_EXPECTED} + if fault in PRODUCT_EXPECTED and item["side"] == "b": + item["milestones"][fault + "_seconds"] = REGRESSED + before = copy.deepcopy(data) + if fault == "missing": + data.pop() + elif fault == "extra": + data[-1]["milestones"]["unknown_seconds"] = 1.0 + elif fault == "unfinished": + data[-1]["status"] = "observed" + if fault in {"missing", "extra", "unfinished"}: + with pytest.raises(ValueError): + comparison.compare_managed_product(data, cache_mode="warm") + else: + result = comparison.compare_managed_product(data, cache_mode="warm") + assert result["verdict"] == ("pass" if fault is None else "regression") + assert set(result["cases"]["managed-product-first-use"]) == {name + "_seconds" for name in PRODUCT_EXPECTED} + assert data == before + assert set(comparison.MANAGED_METRICS) == {"managed-mux"} + assert "managed-product-first-use" not in comparison.NATIVE_METRICS + + +# Accepted ARD-004 regression ratio; a pin below it is no longer a regression. +REGRESSED = 1.0 * (1 + float(comparison.REGRESSION_RATIO)) + 0.01 + + +def managed_samples(cache_mode="warm"): + result = [] + for sample in samples(case="managed-mux", left=1.0, right=1.0): + duration = sample.pop("elapsed_seconds") + result.append({ + **sample, "status": "complete", "cache_mode": cache_mode, + "milestones": {name + "_seconds": duration for name in EXPECTED}, + }) + return result + + +@pytest.mark.parametrize("phase", ["aa", "ab"]) +@pytest.mark.parametrize("cache_mode", ["warm", "absent"]) +def test_managed_comparison_exact_inventory_and_no_input_mutation(phase, cache_mode): + data = managed_samples(cache_mode) + before = copy.deepcopy(data) + result = comparison.compare_managed(data, cache_mode=cache_mode, phase=phase) + assert result["verdict"] == "pass" + assert set(result["cases"]) == {"managed-mux"} + assert set(result["cases"]["managed-mux"]) == {name + "_seconds" for name in EXPECTED} + assert data == before + for metric in result["cases"]["managed-mux"].values(): + assert all(variant["mean_seconds_descriptive"] == 1.0 for variant in metric["variants"].values()) + assert "managed-mux" not in comparison.NATIVE_METRICS + with pytest.raises(ValueError): + comparison.compare_native(data, cache_mode=cache_mode) + + +@pytest.mark.parametrize("metric", EXPECTED) +def test_each_managed_metric_regression_prevents_pass(metric): + data = managed_samples() + for sample in data: + if sample["side"] == "b": + sample["milestones"][metric + "_seconds"] = REGRESSED + assert comparison.compare_managed(data, cache_mode="warm")["verdict"] == "regression" + + +@pytest.mark.parametrize("fault", [ + "missing", "duplicate", "warmup", "unfinished", "debt", "extra-metric", + "missing-metric", "wrong-case", "wrong-cache", "nan", "boolean", "small", +]) +def test_managed_invalid_or_incomplete_evidence_cannot_pass(fault): + data = managed_samples() + kwargs = {"cache_mode": "warm"} + if fault == "missing": + data.pop() + elif fault == "duplicate": + data.append(data[-1]) + elif fault == "warmup": + data[0]["valid"] = False + elif fault == "unfinished": + data[-1]["status"] = "observed" + elif fault == "debt": + data[-1]["failure"] = "cleanup_pending" + elif fault == "extra-metric": + data[-1]["milestones"]["unreviewed_seconds"] = 1.0 + elif fault == "missing-metric": + del data[-1]["milestones"]["stop_settlement_seconds"] + elif fault == "wrong-case": + data[-1]["case"] = "local-mux" + elif fault == "wrong-cache": + data[-1]["cache_mode"] = "absent" + elif fault == "small": + kwargs["pairs_per_block"] = 1 + else: + data[-1]["milestones"]["first_completion_seconds"] = float("nan") if fault == "nan" else True + with pytest.raises(ValueError): + comparison.compare_managed(data, **kwargs) diff --git a/tests/dev/test_lmux_history_collection.py b/tests/dev/test_lmux_history_collection.py new file mode 100644 index 000000000..32f220b07 --- /dev/null +++ b/tests/dev/test_lmux_history_collection.py @@ -0,0 +1,132 @@ +"""Formal history dispatch cannot promote a receipt before the original owner.""" + +import asyncio +import copy +import json +from types import SimpleNamespace + +import pytest + +from .test_lmux_history_restore_validation import evidence as restore_evidence +from .test_lmux_history_warm_validation import evidence as warm_evidence +from .test_measure_g18_native import runner + + +def collection(root, restored): + evidence, bounds = restore_evidence() if restored else warm_evidence() + prefix, observer = root.parent / "install", root.parent / "observer" + + def paths(value): + if type(value) is dict: + return {key: paths(item) for key, item in value.items()} + if type(value) is list: + return [paths(item) for item in value] + if type(value) is str: + return value.replace("/workspace", str(root / "workspace")).replace("/install", str(prefix)) + return value + + evidence = paths(evidence) + value = dict.fromkeys(runner.OBSERVER_FIELDS) + value.update(schema_version=2, case=runner.HISTORY_CASES[int(restored)], status="observed", valid=False, + measured_prefix=str(prefix), observer_prefix=str(observer), + observer_origin=str(observer / "lib/loushang/coding/__init__.py"), + sample_id=str(root.resolve()), seed="empty", seed_setup=[], milestones={}) + if restored: + value.update(history_generations=evidence, fixed_product_history_restore=evidence["new"]["restored_history"], + spawns=[*evidence["old"]["spawns"], *evidence["new"]["spawns"]]) + else: + value.update(evidence) + return value, dict(prefix=prefix, root=root, observer_started=bounds["earliest"], owner_settled=bounds["latest"]) + + +@pytest.mark.parametrize("restored", [False, True]) +def test_original_receipt_remains_diagnostic_after_completion(tmp_path, restored): + original_fields = set(runner.OBSERVER_FIELDS) + value, bounds = collection(tmp_path, restored) + before = copy.deepcopy(value) + completed = runner.complete_managed_history(value, **bounds) + assert value == before and completed["valid"] is False + assert len(completed["milestones"]) == (1 if restored else 2) + runner.validate_observation(completed, value["case"], bounds["prefix"], tmp_path, tmp_path.parent / "observer") + assert runner.OBSERVER_FIELDS == original_fields + + +@pytest.mark.parametrize("restored", [False, True]) +@pytest.mark.parametrize("outcome", ["complete", "deferred", "failure", "cancel", "wrong-install"]) +def test_original_run_sample_owns_promotion(tmp_path, monkeypatch, restored, outcome): + root = tmp_path / "sample" + value, parameters = collection(root, restored) + prefix, observer = parameters["prefix"], tmp_path / "observer" + if outcome == "wrong-install": + value["observer_prefix"] = str(tmp_path / "unrelated") + events, clock = [], iter((parameters["observer_started"], parameters["owner_settled"])) + monkeypatch.setattr(runner, "time", SimpleNamespace(perf_counter=lambda: next(clock))) + failure = asyncio.CancelledError() if outcome == "cancel" else RuntimeError("original cleanup failed") + + def supervise(argv, **kwargs): + assert argv[4] == value["case"] + assert kwargs["timeout"] == (1980 if restored else 1380) + runner.inert.write_report(root / "native.json", value) + if outcome in {"failure", "cancel"}: + raise failure + events.append("owner-return") + + original = runner.complete_managed_history + + def complete(*args, **kwargs): + assert events == ["owner-return"] + return original(*args, **kwargs) + + monkeypatch.setattr(runner.owner, "run_python", supervise) + monkeypatch.setattr(runner, "complete_managed_history", complete) + report, path = {"samples": []}, tmp_path / "report.json" + + def run(): + runner.run_sample(prefix, value["case"], root, tmp_path / "pyc", report, path, + dict(side="a", block=0, pair=0), observer_prefix=observer, defer_validation=outcome == "deferred") + + if outcome in {"failure", "cancel", "wrong-install"}: + with pytest.raises(ValueError if outcome == "wrong-install" else type(failure)): + run() + else: + run() + sample, = json.loads(path.read_text())["samples"] + assert sample["valid"] is (outcome == "complete") + if outcome in {"failure", "cancel"}: + assert events == [] and sample["status"] == "failed" + assert "outer_settlement" not in sample and sample["partial_observation"] == value + elif outcome == "wrong-install": + assert sample["status"] == "failed" + else: + assert sample["status"] == ("observed" if outcome == "deferred" else "complete") + assert "outer_settlement" in sample + assert ("history_generations" if restored else "fixed_product_history") in sample + + +@pytest.mark.parametrize("restored", [False, True]) +@pytest.mark.parametrize("fault", ["late-owner", "missing-owner", "boolean-owner", "extra-raw", "precomputed", + "wrong-seed", "wrong-summary", "wrong-sample"]) +def test_failed_or_tampered_receipt_never_promotes(tmp_path, restored, fault): + value, bounds = collection(tmp_path, restored) + if fault == "late-owner": + bounds["observer_started"] = 2.0 + elif fault == "missing-owner": + bounds["owner_settled"] = None + elif fault == "boolean-owner": + bounds["owner_settled"] = True + elif fault == "extra-raw": + value["cleanup_failure"] = "OSError" + elif fault == "precomputed": + value["milestones"] = {"history_frame_seconds": 1.0} + elif fault == "wrong-seed": + value["seed_setup"] = ["unknown-seed"] + elif fault == "wrong-sample": + value["sample_id"] = "/other" + else: + completed = runner.complete_managed_history(value, **bounds) + completed["milestones"][next(iter(completed["milestones"]))] += 1.0 + with pytest.raises(ValueError): + runner.validate_observation(completed, value["case"], bounds["prefix"], tmp_path, tmp_path.parent / "observer") + return + with pytest.raises(ValueError): + runner.complete_managed_history(value, **bounds) diff --git a/tests/dev/test_lmux_history_confirmation.py b/tests/dev/test_lmux_history_confirmation.py new file mode 100644 index 000000000..133dafba3 --- /dev/null +++ b/tests/dev/test_lmux_history_confirmation.py @@ -0,0 +1,87 @@ +"""Tail equality never substitutes for full canonical history or ownership.""" + +import copy + +import pytest + +from tests.coding._lmux_history_recipe import OMITTED, history_turn + +from .test_measure_g18_native import runner + + +def evidence(): + target = dict(stage="first-member", observed_at=1.0, instanceId="a" * 32, + serviceId="b" * 64, muxId="mux", members=[dict(memberId="member", sessionId="session")]) + identity = dict(product_id="coding", continuity_id="continuity", session_id="session", + scope="user_home", scope_fingerprint="c" * 64) + rows = [dict(kind="status", text=OMITTED)] + [dict(kind=kind, text=text) + for index in range(121, 128) for kind, text in zip(("user", "assistant"), history_turn(index))] + value = dict(copy.deepcopy(target), stage="detached", observed_at=3.0, connection_settled_at=5.0, + history_snapshot=dict(confirmed_at=4.0, identity=dict(identity), running=False, records=rows)) + return value, target, dict(identity=identity, earliest=2.0, latest=6.0) + + +def test_exact_fixed_tail_after_terminal_and_before_stop(): + value, target, bounds = evidence() + runner.validate_managed_history_confirmation(value, target, **bounds) + + +@pytest.mark.parametrize("field", ["instanceId", "serviceId", "muxId", "members"]) +def test_correct_tail_from_other_target_is_not_accepted(field): + value, target, bounds = evidence() + value[field] = "other" + with pytest.raises(ValueError): + runner.validate_managed_history_confirmation(value, target, **bounds) + + +@pytest.mark.parametrize("field", ["product_id", "continuity_id", "session_id", "scope", "scope_fingerprint"]) +def test_all_session_fields_must_match(field): + value, target, bounds = evidence() + value["history_snapshot"]["identity"][field] = "other" + with pytest.raises(ValueError): + runner.validate_managed_history_confirmation(value, target, **bounds) + + +@pytest.mark.parametrize("fault", ["text", "role", "reorder", "missing-status", "extra", "running", "boolean-idle", + "early-snapshot", "early-observation", "late-close", "extra-field", "missing-time"]) +def test_tail_and_lifecycle_receipt_faults(fault): + value, target, bounds = evidence() + snapshot = value["history_snapshot"] + if fault == "text": + snapshot["records"][2]["text"] += "wrong" + elif fault == "role": + snapshot["records"][1]["kind"] = "assistant" + elif fault == "reorder": + snapshot["records"][1:3], snapshot["records"][3:5] = snapshot["records"][3:5], snapshot["records"][1:3] + elif fault == "missing-status": + snapshot["records"].pop(0) + elif fault == "extra": + snapshot["records"].append(dict(kind="user", text="unexpected")) + elif fault in {"running", "boolean-idle"}: + snapshot["running"] = True if fault == "running" else 0 + elif fault == "early-snapshot": + snapshot["confirmed_at"] = 2.5 + elif fault == "early-observation": + value["observed_at"] = 1.5 + elif fault == "late-close": + value["connection_settled_at"] = 6.5 + elif fault == "extra-field": + snapshot["accepted"] = True + else: + snapshot.pop("confirmed_at") + with pytest.raises(ValueError): + runner.validate_managed_history_confirmation(value, target, **bounds) + + +@pytest.mark.parametrize("field", ["earliest", "latest", "observed_at", "confirmed_at", "connection_settled_at"]) +@pytest.mark.parametrize("bad", [True, float("nan"), float("inf"), -1, 10**400, None]) +def test_timing_fields_fail_closed(field, bad): + value, target, bounds = evidence() + if field in bounds: + bounds[field] = bad + elif field == "confirmed_at": + value["history_snapshot"][field] = bad + else: + value[field] = bad + with pytest.raises(ValueError): + runner.validate_managed_history_confirmation(value, target, **bounds) diff --git a/tests/dev/test_lmux_history_receipts.py b/tests/dev/test_lmux_history_receipts.py new file mode 100644 index 000000000..5ce5e6944 --- /dev/null +++ b/tests/dev/test_lmux_history_receipts.py @@ -0,0 +1,78 @@ +"""Independent canonical evidence checks; not whole-sample promotion.""" + +import pytest + +from .test_measure_g18_native import runner + + +def evidence(tmp_path): + identity = dict(product_id="coding", continuity_id="continuity", session_id="session", + scope="user_home", scope_fingerprint="a" * 64) + value = dict(started_at=2.0, completed_at=3.0, root=str(tmp_path), + path=str(tmp_path / "hosted-session.jsonl"), workspace=str(tmp_path), + identity=dict(identity), canonical={ + "recipe": "lmux-history-128x2048/v1", "rounds": 128, + "records": 256, "text_bytes": 263680, + "sha256": "00e1c01bb0a4603b94f5fbd70ea802f24a9389471893e5883310ba7c0c0fbb41"}) + return value, dict(root=tmp_path, workspace=tmp_path, identity=identity, + owner_settled_at=1.0, next_started_at=4.0) + + +def test_exact_canonical_receipt(tmp_path): + value, bounds = evidence(tmp_path) + runner.validate_managed_history_canonical(value, **bounds) + + +@pytest.mark.parametrize("field", ["product_id", "continuity_id", "session_id", "scope", "scope_fingerprint"]) +def test_every_authenticated_identity_field_is_bound(tmp_path, field): + value, bounds = evidence(tmp_path) + value["identity"][field] = "other" + with pytest.raises(ValueError): + runner.validate_managed_history_canonical(value, **bounds) + + +@pytest.mark.parametrize("field", ["root", "path", "workspace"]) +def test_exact_read_arguments_are_bound(tmp_path, field): + value, bounds = evidence(tmp_path) + value[field] += ".other" + with pytest.raises(ValueError): + runner.validate_managed_history_canonical(value, **bounds) + + +@pytest.mark.parametrize("field", ["recipe", "rounds", "records", "text_bytes", "sha256"]) +def test_full_recipe_is_required_not_a_recent_tail(tmp_path, field): + value, bounds = evidence(tmp_path) + value["canonical"][field] = 15 + with pytest.raises(ValueError): + runner.validate_managed_history_canonical(value, **bounds) + + +@pytest.mark.parametrize("field", ["started_at", "completed_at", "owner_settled_at", "next_started_at"]) +@pytest.mark.parametrize("bad", [True, float("nan"), float("inf"), -1, 10**400, "2", None]) +def test_all_timestamps_fail_closed(tmp_path, field, bad): + value, bounds = evidence(tmp_path) + (value if field in value else bounds)[field] = bad + with pytest.raises(ValueError): + runner.validate_managed_history_canonical(value, **bounds) + + +@pytest.mark.parametrize("fault", ["read-before-stop", "reversed-read", "restart-before-read", + "missing", "extra", "float-count", "extra-recipe"]) +def test_order_and_exact_inventories(tmp_path, fault): + value, bounds = evidence(tmp_path) + if fault == "read-before-stop": + bounds["owner_settled_at"] = 2.5 + elif fault == "reversed-read": + value["completed_at"] = 1.5 + elif fault == "restart-before-read": + bounds["next_started_at"] = 2.5 + elif fault == "missing": + value.pop("completed_at") + elif fault == "extra": + value["accepted"] = True + elif fault == "float-count": + value["canonical"]["records"] = 256.0 + else: + value["canonical"]["extra"] = True + with pytest.raises(ValueError): + runner.validate_managed_history_canonical(value, **bounds) diff --git a/tests/dev/test_lmux_history_restore_validation.py b/tests/dev/test_lmux_history_restore_validation.py new file mode 100644 index 000000000..a1ebde72b --- /dev/null +++ b/tests/dev/test_lmux_history_restore_validation.py @@ -0,0 +1,106 @@ +"""Restart acceptance requires old and new generation lifetimes separately.""" + +import copy + +import pytest + +from .test_lmux_history_terminal_validation import evidence as terminal_evidence +from .test_lmux_history_warm_validation import evidence as warm_evidence +from .test_measure_g18_native import runner + + +def evidence(): + old, bounds = warm_evidence() + new, _ = terminal_evidence(True) + for spawn, start in zip(new["spawns"], (151.0, 157.0)): + spawn["start"] = start + new["line_terminal_settlements"][0].update(termios_restored_at=153.0, settled_at=154.0) + new["terminal_settlements"][0].update(termios_restored_at=162.0, settled_at=163.0) + target = dict(copy.deepcopy(old["fixed_product_target"]), instanceId="d" * 32, observed_at=155.0) + native = dict(old["fixed_product_native"]["stop"], pid=501, start_ticks=1200) + attached = copy.deepcopy(old["fixed_product_history"]["attach"]) + attached["actions"] = dict(history_frame=dict(started_at=157.0, finished_at=159.0), + history_completion=dict(started_at=160.0, finished_at=161.0)) + attached["detached"].update(instanceId=target["instanceId"], observed_at=164.0, connection_settled_at=166.0) + attached["detached"]["history_snapshot"]["confirmed_at"] = 165.0 + stop = dict(started_at=167.0, observed_at=168.0, local_owner_settled_at=169.0, + service_id=target["serviceId"], result=dict(status="stopped", instanceId=target["instanceId"])) + canonical = copy.deepcopy(old["fixed_product_history"]["canonical_read"]) + canonical.update(started_at=170.0, completed_at=171.0) + new.update(status="observed", valid=False, measured_prefix=str(bounds["prefix"]), + fixed_product_target=target, authenticated_at=156.0, + start_command=dict(started_at=151.0, settled_at=154.5, exit_status=0, + result=dict(status="service_ready", serviceId=target["serviceId"], instanceId=target["instanceId"])), + fixed_product_native={stage: dict(native) for stage in ("restored", "history-detached", "stop")}, + fixed_product_stop=stop, + restored_history=dict(attach=attached, canonical=dict(canonical["canonical"]), canonical_read=canonical, + stop=dict(stop["result"]), action=dict(started_at=151.0, finished_at=159.0), restored_history_frame_seconds=8.0)) + bounds["latest"] = 172.0 + return dict(old=old, new=new), bounds + + +def test_both_complete_generations_and_actual_restart_gap(): + generations, bounds = evidence() + assert runner.validate_managed_history_restore(generations, **bounds) == {"restored_history_frame_seconds": 8.0} + assert all(child["valid"] is False for child in generations.values()) + + +@pytest.mark.parametrize("fault", ["old-failed", "new-failed", "new-cleanup", "old-canonical-late", "missing-line", + "same-instance", "same-native", "namespace-changed", "native-changed-on-stop", + "wrong-ready", "ready-before-line-close", "auth-before-ready", "wrong-service", + "changed-session", "changed-scope", "old-stop-reused", "canonical-before-stop", + "new-canonical-changed", "late-canonical", "attach-duration", "late-completion"]) +def test_one_generation_cannot_substitute_for_the_other(fault): + generations, bounds = evidence() + old, new = generations["old"], generations["new"] + result = new["restored_history"] + if fault == "old-failed": + old["status"] = "failed" + elif fault == "new-failed": + new["status"] = "failed" + elif fault == "new-cleanup": + new["cleanup_failure"] = "OSError" + elif fault == "old-canonical-late": + old["fixed_product_history"]["canonical_read"]["completed_at"] = 151.5 + elif fault == "missing-line": + new.pop("line_terminal_settlements") + elif fault == "same-instance": + new["fixed_product_target"]["instanceId"] = old["fixed_product_target"]["instanceId"] + new["start_command"]["result"]["instanceId"] = new["fixed_product_target"]["instanceId"] + elif fault == "same-native": + new["fixed_product_native"] = {stage: dict(old["fixed_product_native"]["stop"]) for stage in new["fixed_product_native"]} + elif fault == "namespace-changed": + for native in new["fixed_product_native"].values(): + native["pid_namespace_inode"] += 1 + elif fault == "native-changed-on-stop": + new["fixed_product_native"]["stop"]["pid"] += 1 + elif fault == "wrong-ready": + new["start_command"]["result"]["serviceId"] = "e" * 64 + elif fault == "ready-before-line-close": + new["start_command"]["settled_at"] = 153.5 + elif fault == "auth-before-ready": + new["authenticated_at"] = 154.0 + elif fault == "wrong-service": + new["fixed_product_target"]["serviceId"] = "e" * 64 + new["start_command"]["result"]["serviceId"] = "e" * 64 + elif fault == "changed-session": + new["fixed_product_target"]["members"][0]["sessionId"] = "other" + elif fault == "changed-scope": + result["attach"]["detached"]["history_snapshot"]["identity"]["scope_fingerprint"] = "e" * 64 + elif fault == "old-stop-reused": + new["fixed_product_stop"] = copy.deepcopy(old["fixed_product_stop"]) + result["stop"] = dict(old["fixed_product_stop"]["result"]) + elif fault == "canonical-before-stop": + result["canonical_read"]["started_at"] = 168.5 + elif fault == "new-canonical-changed": + result["canonical"]["sha256"] = "e" * 64 + elif fault == "late-canonical": + result["canonical_read"]["completed_at"] = 172.5 + elif fault == "attach-duration": + result["action"]["started_at"] = 157.0 + result["restored_history_frame_seconds"] = 2.0 + else: + result["attach"]["actions"]["history_completion"]["finished_at"] = 162.5 + result["attach"]["history_completion_seconds"] = 2.5 + with pytest.raises(ValueError): + runner.validate_managed_history_restore(generations, **bounds) diff --git a/tests/dev/test_lmux_history_seed_receipts.py b/tests/dev/test_lmux_history_seed_receipts.py new file mode 100644 index 000000000..db03574e8 --- /dev/null +++ b/tests/dev/test_lmux_history_seed_receipts.py @@ -0,0 +1,136 @@ +"""Do not accept a warm measurement with unfinished or incomplete seed work.""" + +import pytest + +from .test_measure_g18_native import runner + + +def evidence(): + value = dict(started_at=1.0, finished_at=130.0, detached_at=131.0, rounds=[ + dict(round=index, started_at=2.0 + index, acknowledged_at=2.1 + index, + settled_at=2.5 + index, snapshot_reads=1) for index in range(128)]) + bounds = dict(terminal_settled_at=0.5, connection_settled_at=132.0, attach_started_at=133.0) + bounds["verification"] = dict(started_at=0.5, deadline=660.5, completed_at=131.5) + value["attachment"] = dict(deadline=660.5, attach_started_at=0.6, attached_at=0.8, + attach_deadline=30.6, detach_started_at=130.5, detach_deadline=160.5) + return value, bounds + + +def test_complete_seed_precedes_measured_attach(): + value, bounds = evidence() + runner.validate_managed_history_seed(value, **bounds) + + +@pytest.mark.parametrize("fault", ["missing-round", "extra-round", "wrong-round", "boolean-round", + "zero-reads", "excess-reads", "boolean-reads", "round-overlap", + "ack-before-start", "settle-before-ack", "late-last-round", + "late-terminal", "late-detach", "late-connection", "total-deadline", + "round-deadline", "extra", "missing", "extra-round-field"]) +def test_seed_failures_cannot_be_hidden_by_successful_attach(fault): + value, bounds = evidence() + row = value["rounds"][1] + if fault == "missing-round": + value["rounds"].pop() + elif fault == "extra-round": + value["rounds"].append(dict(row)) + elif fault == "wrong-round": + row["round"] = 0 + elif fault == "boolean-round": + row["round"] = True + elif fault.endswith("reads"): + row["snapshot_reads"] = {"zero-reads": 0, "excess-reads": 81, "boolean-reads": True}[fault] + elif fault == "round-overlap": + row["started_at"] = 2.4 + elif fault == "ack-before-start": + row["acknowledged_at"] = 2.9 + elif fault == "settle-before-ack": + row["settled_at"] = 3.05 + elif fault == "late-last-round": + value["rounds"][-1]["settled_at"] = 130.5 + elif fault == "late-terminal": + bounds["terminal_settled_at"] = 1.5 + elif fault == "late-detach": + value["detached_at"] = 132.5 + elif fault == "late-connection": + bounds["connection_settled_at"] = 133.5 + elif fault == "total-deadline": + value.update(finished_at=601.0, detached_at=602.0) + value["attachment"].update(detach_started_at=601.5, detach_deadline=631.5) + bounds.update(connection_settled_at=603.0, attach_started_at=604.0) + bounds["verification"]["completed_at"] = 602.5 + elif fault == "round-deadline": + # Remain ordered and inside the total budget, but exceed one round's limit. + value["rounds"][-1].update(started_at=129.0, acknowledged_at=130.0, settled_at=169.0) + value.update(finished_at=170.0, detached_at=171.0) + value["attachment"].update(detach_started_at=170.5, detach_deadline=200.5) + bounds.update(connection_settled_at=172.0, attach_started_at=173.0) + bounds["verification"]["completed_at"] = 171.5 + elif fault == "extra": + value["accepted"] = True + elif fault == "missing": + value.pop("detached_at") + else: + row["replayed"] = False + with pytest.raises(ValueError): + runner.validate_managed_history_seed(value, **bounds) + + +def test_late_detach_cannot_pass_by_delaying_every_later_owner(): + value, bounds = evidence() + value["detached_at"] = 1000.0 + bounds.update(connection_settled_at=1001.0, attach_started_at=1002.0) + with pytest.raises(ValueError, match="deadline"): + runner.validate_managed_history_seed(value, **bounds) + + +@pytest.mark.parametrize("fault", ["missing", "extra", "extended", "different-deadline", + "late-return", "return-before-detach", "connection-before-return", + "admission-after-attach", "admission-before-terminal"]) +def test_verification_deadline_is_the_original_admitted_budget(fault): + value, bounds = evidence() + verification = bounds["verification"] + if fault == "missing": + verification.pop("deadline") + elif fault == "extra": + verification["accepted"] = True + elif fault == "extended": + verification["deadline"] += 1 + elif fault == "different-deadline": + value["attachment"]["deadline"] += 1 + elif fault == "late-return": + verification["completed_at"] = verification["deadline"] + bounds.update(connection_settled_at=700.0, attach_started_at=701.0) + elif fault == "return-before-detach": + verification["completed_at"] = 130.9 + elif fault == "connection-before-return": + bounds["connection_settled_at"] = 131.4 + else: + verification["started_at"] = 0.7 if fault == "admission-after-attach" else 0.4 + verification["deadline"] = verification["started_at"] + 660 + value["attachment"]["deadline"] = verification["deadline"] + with pytest.raises(ValueError): + runner.validate_managed_history_seed(value, **bounds) + + +@pytest.mark.parametrize("field", ["started_at", "deadline", "completed_at"]) +@pytest.mark.parametrize("bad", [True, float("inf"), float("nan"), 10**400, -1, None]) +def test_invalid_verification_times_are_rejected(field, bad): + value, bounds = evidence() + bounds["verification"][field] = bad + with pytest.raises(ValueError): + runner.validate_managed_history_seed(value, **bounds) + + +@pytest.mark.parametrize("field", ["started_at", "finished_at", "detached_at", "terminal_settled_at", + "connection_settled_at", "attach_started_at", + "round-start", "round-ack", "round-settle"]) +@pytest.mark.parametrize("bad", [True, float("nan"), float("inf"), -1, 10**400, None]) +def test_all_seed_times_are_finite_and_typed(field, bad): + value, bounds = evidence() + if field.startswith("round-"): + key = {"round-start": "started_at", "round-ack": "acknowledged_at", "round-settle": "settled_at"}[field] + value["rounds"][0][key] = bad + else: + (value if field in value else bounds)[field] = bad + with pytest.raises(ValueError): + runner.validate_managed_history_seed(value, **bounds) diff --git a/tests/dev/test_lmux_history_terminal_validation.py b/tests/dev/test_lmux_history_terminal_validation.py new file mode 100644 index 000000000..12ea04bd2 --- /dev/null +++ b/tests/dev/test_lmux_history_terminal_validation.py @@ -0,0 +1,98 @@ +"""History starts are line commands; attaches are fully restored TUI owners.""" + +from pathlib import Path + +import pytest + +from .test_measure_g18_native import runner + + +def evidence(restored): + prefix, workspace = Path("/install"), Path("/workspace") + child = {"spawns": [], "terminal_settlements": []} + if restored: + child["line_terminal_settlements"] = [] + for index in range(2): + is_line = restored and index == 0 + cwd = workspace if index == 0 else workspace / ("restored-elsewhere" if restored else "elsewhere") + argv = (["/install/bin/python", "-I", str(runner.inert.ROOT / "tests/coding/_lmux_product_entry.py"), + *( ["start", "-t", "perf"] if restored else ["new", "-s", "perf"] )] + if index == 0 else ["/install/bin/lmux", "attach", "-t", "perf"]) + child["spawns"].append(dict(pid=100 + index, start=1.0 + index * 3, argv=argv, cwd=str(cwd))) + terminal = dict(pid=100 + index, argv=list(argv), cwd=str(cwd), exit_status=0, + termios_restored_at=2.0 + index * 3, settled_at=3.0 + index * 3, + reader_settled=True, fallback=False) + if is_line: + terminal["presentation"] = "line" + child["line_terminal_settlements"].append(terminal) + else: + terminal.update(cursor_restored=True, bracketed_paste_disabled=True) + child["terminal_settlements"].append(terminal) + return child, dict(prefix=prefix, workspace=workspace, restored=restored, earliest=0.5, latest=7.0) + + +@pytest.mark.parametrize("restored", [False, True]) +def test_exact_two_foregrounds(restored): + child, bounds = evidence(restored) + terminals = runner.validate_managed_history_terminals(child, **bounds) + assert [row["pid"] for row in terminals] == [100, 101] + + +@pytest.mark.parametrize("restored", [False, True]) +@pytest.mark.parametrize("index", [0, 1]) +@pytest.mark.parametrize("fault", ["pid", "argv", "cwd", "exit", "boolean-exit", "reader", "fallback", + "time", "out-of-bounds", "extra", "missing"]) +def test_each_real_foreground_is_required(restored, index, fault): + child, bounds = evidence(restored) + terminals = ([*child["line_terminal_settlements"], *child["terminal_settlements"]] + if restored else child["terminal_settlements"]) + row = terminals[index] + if fault == "pid": + row["pid"] += 10 + elif fault == "argv": + row["argv"] = ["wrong-command"] + elif fault == "cwd": + row["cwd"] = "/other" + elif fault in {"exit", "boolean-exit"}: + row["exit_status"] = 1 if fault == "exit" else False + elif fault == "reader": + row["reader_settled"] = False + elif fault == "fallback": + row["fallback"] = True + elif fault == "time": + row["settled_at"] = float("nan") + elif fault == "out-of-bounds": + row["settled_at"] = 8.0 + elif fault == "extra": + row["accepted"] = True + else: + row.pop("termios_restored_at") + with pytest.raises(ValueError): + runner.validate_managed_history_terminals(child, **bounds) + + +@pytest.mark.parametrize("restored", [False, True]) +def test_tui_requires_both_mode_restorations(restored): + for field in ("cursor_restored", "bracketed_paste_disabled"): + child, bounds = evidence(restored) + child["terminal_settlements"][-1][field] = False + with pytest.raises(ValueError): + runner.validate_managed_history_terminals(child, **bounds) + + +def test_line_cannot_claim_tui_restoration_or_borrow_tui_receipt(): + child, bounds = evidence(True) + child["line_terminal_settlements"][0]["cursor_restored"] = True + with pytest.raises(ValueError): + runner.validate_managed_history_terminals(child, **bounds) + child["line_terminal_settlements"] = [child["terminal_settlements"][0]] + with pytest.raises(ValueError): + runner.validate_managed_history_terminals(child, **bounds) + + +@pytest.mark.parametrize("restored", [False, True]) +def test_attach_must_follow_original_terminal_settlement(restored): + child, bounds = evidence(restored) + child["spawns"][1]["start"] = 2.5 + with pytest.raises(ValueError): + runner.validate_managed_history_terminals(child, **bounds) diff --git a/tests/dev/test_lmux_history_timings.py b/tests/dev/test_lmux_history_timings.py new file mode 100644 index 000000000..1288f1ade --- /dev/null +++ b/tests/dev/test_lmux_history_timings.py @@ -0,0 +1,87 @@ +"""History timing subset is independent of helper assertions and sample validity.""" + +import pytest + +from .test_measure_g18_native import runner + + +def evidence(): + attached = {"actions": { + "history_frame": {"started_at": 10.0, "finished_at": 12.0}, + "history_completion": {"started_at": 14.0, "finished_at": 15.0}}, + "history_frame_seconds": 2.0, "history_completion_seconds": 1.0, + "detached": {}} + restored = {"action": {"started_at": 1.0, "finished_at": 12.0}, + "restored_history_frame_seconds": 11.0} + return attached, dict(attach_started_at=10.0, restored=restored, + service_started_at=1.0, authenticated_at=8.0) + + +def test_warm_and_restore_share_attach_but_not_metric_start(): + attached, bounds = evidence() + runner.validate_managed_history_timings(attached, attach_started_at=10.0) + runner.validate_managed_history_timings(attached, **bounds) + + +@pytest.mark.parametrize("fault", ["attach-start", "restore-attach-start", "sum-segments", + "wrong-final-frame", "authentication-late", "service-late", + "completion-before-frame", "completion-duration", "missing", + "extra", "extra-restored", "extra-action", "missing-restored"]) +def test_endpoint_and_inventory_faults(fault): + attached, bounds = evidence() + restored = bounds["restored"] + if fault == "attach-start": + bounds["attach_started_at"] = 9.0 + elif fault == "restore-attach-start": + restored["action"]["started_at"] = 10.0 + restored["restored_history_frame_seconds"] = 2.0 + elif fault == "sum-segments": + restored["restored_history_frame_seconds"] = 9.0 # Loses auth-to-attach gap. + elif fault == "wrong-final-frame": + restored["action"]["finished_at"] = 13.0 + restored["restored_history_frame_seconds"] = 12.0 + elif fault == "authentication-late": + bounds["authenticated_at"] = 11.0 + elif fault == "service-late": + bounds["service_started_at"] = 9.0 + elif fault == "completion-before-frame": + attached["actions"]["history_completion"]["started_at"] = 11.0 + attached["history_completion_seconds"] = 4.0 + elif fault == "completion-duration": + attached["history_completion_seconds"] = 2.0 + elif fault == "missing": + attached.pop("history_completion_seconds") + elif fault == "extra": + attached["unknown_seconds"] = 2.0 + elif fault == "extra-restored": + restored["unknown_seconds"] = 2.0 + elif fault == "extra-action": + restored["action"]["observed_at"] = 12.0 + else: + restored.pop("action") + with pytest.raises(ValueError): + runner.validate_managed_history_timings(attached, **bounds) + + +@pytest.mark.parametrize("field", ["attach_started_at", "service_started_at", "authenticated_at", + "started_at", "finished_at", "duration", "completion"]) +@pytest.mark.parametrize("bad", [True, float("nan"), float("inf"), -1, 10**400, "1", None]) +def test_invalid_time_never_passes(field, bad): + attached, bounds = evidence() + if field in bounds: + bounds[field] = bad + elif field == "duration": + bounds["restored"]["restored_history_frame_seconds"] = bad + elif field == "completion": + attached["history_completion_seconds"] = bad + else: + bounds["restored"]["action"][field] = bad + with pytest.raises(ValueError): + runner.validate_managed_history_timings(attached, **bounds) + + +def test_warm_cannot_silently_drop_restart_inputs(): + attached, _ = evidence() + with pytest.raises(ValueError): + runner.validate_managed_history_timings(attached, attach_started_at=10.0, + service_started_at=1.0) diff --git a/tests/dev/test_lmux_history_warm_validation.py b/tests/dev/test_lmux_history_warm_validation.py new file mode 100644 index 000000000..6bc19d411 --- /dev/null +++ b/tests/dev/test_lmux_history_warm_validation.py @@ -0,0 +1,114 @@ +"""A warm generation needs all independent proofs, not just passing timings.""" + +import copy + +import pytest + +from .test_lmux_history_confirmation import evidence as confirmation_evidence +from .test_lmux_history_receipts import evidence as canonical_evidence +from .test_lmux_history_seed_receipts import evidence as seed_evidence +from .test_lmux_history_terminal_validation import evidence as terminal_evidence +from .test_measure_g18_native import runner + + +def evidence(): + child, bounds = terminal_evidence(False) + snapshot, target, identity_bounds = confirmation_evidence() + target["observed_at"] = 2.0 + identity = identity_bounds["identity"] + snapshot.update(observed_at=143.0, connection_settled_at=145.0) + snapshot["history_snapshot"]["confirmed_at"] = 144.0 + child["spawns"][1]["start"] = 136.0 + child["terminal_settlements"][1].update(termios_restored_at=141.0, settled_at=142.0) + + def shift(value): + if type(value) is float: + return value + 3 + if type(value) is dict: + return {key: shift(item) for key, item in value.items()} + if type(value) is list: + return [shift(item) for item in value] + return value + + seeded, seed_bounds = seed_evidence() + seeded, seed_bounds = shift(seeded), shift(seed_bounds) + seed = dict(copy.deepcopy(target), stage="detached", observed_at=3.25, + history_seed=seeded, history_identity=dict(identity), + connection_settled_at=135.0, verification=seed_bounds["verification"]) + stop = dict(started_at=146.0, observed_at=147.0, local_owner_settled_at=148.0, + service_id=target["serviceId"], result=dict(status="stopped", instanceId=target["instanceId"])) + canonical, _ = canonical_evidence(bounds["workspace"] / "platform/data/sessions") + canonical.update(started_at=149.0, completed_at=150.0, identity=dict(identity), workspace=str(bounds["workspace"])) + native = dict(pid=500, start_ticks=1000, boot_id="12345678-1234-1234-1234-123456789abc", + user_id=1000, pid_namespace_device=4, pid_namespace_inode=5) + attached = dict(history_frame_seconds=2.0, history_completion_seconds=1.0, detached=snapshot, + actions=dict(history_frame=dict(started_at=136.0, finished_at=138.0), + history_completion=dict(started_at=139.0, finished_at=140.0))) + child.update(status="observed", valid=False, measured_prefix=str(bounds["prefix"]), + fixed_product_target=target, + fixed_product_detached=dict(copy.deepcopy(target), stage="detached", observed_at=135.5), + fixed_product_stop=stop, + fixed_product_native={stage: dict(native) for stage in ("first-member", "detached", "history-detached", "stop")}, + fixed_product_history=dict(seed=seed, attach=attached, canonical=dict(canonical["canonical"]), + stop=dict(stop["result"]), canonical_read=canonical)) + bounds.pop("restored") + bounds["latest"] = 151.0 + return child, bounds + + +def test_complete_warm_generation_joins_all_proofs(): + child, bounds = evidence() + assert runner.validate_managed_history_warm(child, **bounds) == { + "history_frame_seconds": 2.0, "history_completion_seconds": 1.0} + assert child["valid"] is False + + +@pytest.mark.parametrize("fault", ["cleanup-failure", "valid", "wrong-status", "wrong-prefix", + "native-replaced", "native-missing", "native-boolean", "wrong-stop", + "stop-before-read-close", "canonical-before-stop", "late-canonical", + "wrong-root", "changed-canonical", "late-seed-close", "early-detached", + "wrong-detached", "late-frame", "late-auth", "seed-missing-verification"]) +def test_partial_success_never_accepts_a_warm_generation(fault): + child, bounds = evidence() + result = child["fixed_product_history"] + if fault == "cleanup-failure": + child["cleanup_failure"] = "OSError" + elif fault == "valid": + child["valid"] = True + elif fault == "wrong-status": + child["status"] = "failed" + elif fault == "wrong-prefix": + child["measured_prefix"] = "/other" + elif fault == "native-replaced": + child["fixed_product_native"]["stop"]["start_ticks"] += 1 + elif fault == "native-missing": + child["fixed_product_native"].pop("history-detached") + elif fault == "native-boolean": + child["fixed_product_native"]["stop"]["user_id"] = True + elif fault == "wrong-stop": + child["fixed_product_stop"]["result"]["instanceId"] = "d" * 32 + elif fault == "stop-before-read-close": + child["fixed_product_stop"]["started_at"] = 144.5 + elif fault == "canonical-before-stop": + result["canonical_read"]["started_at"] = 147.5 + elif fault == "late-canonical": + result["canonical_read"]["completed_at"] = 152.0 + elif fault == "wrong-root": + result["canonical_read"]["root"] = "/other" + elif fault == "changed-canonical": + result["canonical"]["sha256"] = "0" * 64 + elif fault == "late-seed-close": + result["seed"]["connection_settled_at"] = 136.5 + elif fault == "early-detached": + child["fixed_product_detached"]["observed_at"] = 134.5 + elif fault == "wrong-detached": + child["fixed_product_detached"]["serviceId"] = "d" * 64 + elif fault == "late-frame": + result["attach"]["actions"]["history_completion"]["finished_at"] = 141.5 + result["attach"]["history_completion_seconds"] = 2.5 + elif fault == "late-auth": + child["fixed_product_target"]["observed_at"] = 2.5 + else: + result["seed"].pop("verification") + with pytest.raises(ValueError): + runner.validate_managed_history_warm(child, **bounds) diff --git a/tests/dev/test_lmux_native_receipt.py b/tests/dev/test_lmux_native_receipt.py new file mode 100644 index 000000000..054860c85 --- /dev/null +++ b/tests/dev/test_lmux_native_receipt.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import copy +import json + +import pytest + +from .test_lmux_comparison import EXPECTED +from .test_measure_g18_native import observation, runner + + +def managed_receipt(prefix, root, observer=None): + value = observation(prefix, root, observer) + value.update( + case="managed-mux", authenticated_instance_id="a" * 32, + milestones={name + "_seconds": 1.0 for name in EXPECTED}, + managed_stop={"started_at": 18.0, "result": {"status": "stopped", "instanceId": "a" * 32}}, + spawns=[ + {"pid": 101, "start": 10.0, "argv": [str(prefix / "bin/lmux"), "new", "-s", "perf"], + "cwd": str(root / "workspace")}, + {"pid": 102, "start": 15.0, "argv": [str(prefix / "bin/lmux"), "attach", "-t", "perf"], + "cwd": str(root / "workspace/elsewhere")}, + ], + ) + members = [{"memberId": "member-1", "sessionId": "session-1"}, + {"memberId": "member-2", "sessionId": "session-2"}] + value["managed_observations"] = [ + {"stage": stage, "observed_at": at, "instanceId": "a" * 32, + "serviceId": "b" * 64, "muxId": "mux-perf", "members": copy.deepcopy(members[:count])} + for stage, at, count in (("first-member", 12.0, 1), ("second-member", 13.0, 2), + ("detached", 14.0, 2), ("reattached", 16.0, 2)) + ] + value["managed_actions"] = { + name: {"started_at": start, "finished_at": end} + for name, start, end in (("first_completion", 10.1, 10.2), + ("first_member_ready", 10.2, 11.0), + ("warm_member_ready", 12.0, 13.0), + ("detach_settlement", 13.0, 14.0), + ("reattach_detach_settlement", 16.0, 17.0)) + } + for name, action in value["managed_actions"].items(): + value["milestones"][name + "_seconds"] = action["finished_at"] - action["started_at"] + value["milestones"]["cold_frame_seconds"] = 10.1 - 10.0 + return value + + +def test_managed_receipt_preserves_old_default_cases_and_exact_member_sequence(tmp_path): + value = managed_receipt(tmp_path, tmp_path) + original = copy.deepcopy(value) + runner.validate_observation(value, "managed-mux", tmp_path, tmp_path, tmp_path) + assert value == original + assert "managed-mux" not in runner.CASES # Explicit opt-in never changes old defaults. + # Preserve the original intent (opt-in cases never leak into the defaults). + # The optional tuple legitimately grew when the history cases were added, so + # assert membership rather than a frozen snapshot of the whole tuple. + assert set(runner.OPTIONAL_CASES) >= {"managed-mux", "managed-product-first-use"} + assert set(runner.HISTORY_CASES).isdisjoint(runner.CASES) + assert set(runner.OPTIONAL_CASES).isdisjoint(runner.CASES) + + +@pytest.mark.parametrize("fault", [ + "wrong-install", "same-cwd", "wrong-argv", "missing-frame-metric", "extra-metric", + "no-live-query", "old-instance", "new-service", "new-mux", "replaced-first", + "reordered-tabs", "duplicate-session", "missing-member", "stale-query", + "query-before-frame", "attach-before-detach", "wrong-stop", "early-stop", "dirty-seed", + "late-attach-frame", "late-cold-frame", "missing-action", "duration-mismatch", "late-detach", +]) +def test_managed_receipt_rejects_incomplete_or_mismatched_evidence(tmp_path, fault): + value = managed_receipt(tmp_path, tmp_path) + records = value["managed_observations"] + if fault == "wrong-install": + value["measured_prefix"] = "/wrong" + elif fault == "same-cwd": + value["spawns"][1]["cwd"] = value["spawns"][0]["cwd"] + elif fault == "wrong-argv": + value["spawns"][1]["argv"][-1] = "other" + elif fault == "missing-frame-metric": + del value["milestones"]["warm_attach_frame_seconds"] + elif fault == "extra-metric": + value["milestones"]["unreviewed_seconds"] = 1.0 + elif fault == "no-live-query": + records.pop(2) + elif fault == "old-instance": + records[-1]["instanceId"] = "c" * 32 + elif fault == "new-service": + records[-1]["serviceId"] = "c" * 64 + elif fault == "new-mux": + records[-1]["muxId"] = "other" + elif fault == "replaced-first": + records[1]["members"][0]["memberId"] = "other" + elif fault == "reordered-tabs": + records[-1]["members"].reverse() + elif fault == "duplicate-session": + records[1]["members"][1]["sessionId"] = "session-1" + elif fault == "missing-member": + records[-1]["members"].pop() + elif fault == "stale-query": + records[-1]["observed_at"] = 13.0 + elif fault == "query-before-frame": + records[0]["observed_at"] = 10.5 + elif fault == "attach-before-detach": + value["spawns"][1]["start"] = 13.5 + elif fault == "wrong-stop": + value["managed_stop"]["result"]["instanceId"] = "c" * 32 + elif fault == "early-stop": + value["managed_stop"]["started_at"] = 15.0 + elif fault == "late-attach-frame": + value["milestones"]["warm_attach_frame_seconds"] = 10000.0 + elif fault == "late-cold-frame": + value["milestones"]["cold_frame_seconds"] = 2.0 + elif fault == "missing-action": + del value["managed_actions"]["warm_member_ready"] + elif fault == "duration-mismatch": + value["milestones"]["warm_member_ready_seconds"] = 0.5 + elif fault == "late-detach": + value["managed_actions"]["reattach_detach_settlement"]["finished_at"] = 19.0 + value["milestones"]["reattach_detach_settlement_seconds"] = 3.0 + else: + value["seed"] = "recovered" + with pytest.raises(ValueError): + runner.validate_observation(value, "managed-mux", tmp_path, tmp_path, tmp_path) + + +def test_original_outer_owner_completes_stop_then_validates_and_retains_queries(tmp_path, monkeypatch): + prefix, observer, root = tmp_path / "install", tmp_path / "observer", tmp_path / "sample" + clock = iter((9.0, 20.0)) + monkeypatch.setattr(runner.time, "perf_counter", lambda: next(clock)) + + def supervise(*args, **kwargs): + value = managed_receipt(prefix, root, observer) + del value["milestones"]["stop_settlement_seconds"] + (root / "native.json").write_text(json.dumps(value)) + + monkeypatch.setattr(runner.owner, "run_python", supervise) + report = {"samples": []} + runner.run_sample(prefix, "managed-mux", root, tmp_path / "pyc", report, + tmp_path / "report.json", dict(side="a", block=0, pair=0), observer_prefix=observer) + (sample,) = report["samples"] + assert sample["valid"] is True and sample["status"] == "complete" + assert sample["milestones"]["stop_settlement_seconds"] == 2.0 + assert [entry["stage"] for entry in sample["managed_observations"]] == [ + "first-member", "second-member", "detached", "reattached", + ] diff --git a/tests/dev/test_lmux_native_selection.py b/tests/dev/test_lmux_native_selection.py new file mode 100644 index 000000000..8782c7ee6 --- /dev/null +++ b/tests/dev/test_lmux_native_selection.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from .test_measure_g18_native import runner + + +def cli_arguments(root): + return ["--install-a", str(root / "a"), "--install-b", str(root / "b"), + "--observer-install", str(root / "observer"), "--wheel", str(root / "baseline.whl"), + "--output", str(root / "output")] + + +@pytest.mark.parametrize("explicit", [None, "managed-mux", "managed-product-first-use", *runner.HISTORY_CASES]) +def test_managed_case_is_explicit_and_original_cli_default_is_unchanged(tmp_path, monkeypatch, explicit): + captured = [] + monkeypatch.setattr(runner, "collect_main", lambda args, parser: captured.append(args) or 0) + assert runner.main(cli_arguments(tmp_path) + (["--cases", explicit] if explicit else [])) == 0 + assert captured[0].cases == ([explicit] if explicit else list(runner.CASES)) + + +@pytest.mark.parametrize("case", runner.OPTIONAL_CASES) +def test_managed_mixed_campaign_rejected_before_source_or_installation_io(tmp_path, monkeypatch, case): + monkeypatch.setattr(runner.platform, "system", lambda: "Linux") + monkeypatch.setattr(runner.inert, "source_pair", lambda *a, **k: pytest.fail("no source IO for invalid campaign")) + with pytest.raises(SystemExit) as caught: + runner.main(cli_arguments(tmp_path) + ["--cases", "embedded", case]) + assert caught.value.code == 2 + assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize("case", ["managed", "legacy", "product"]) +def test_exact_campaign_selects_its_original_policy(monkeypatch, case): + samples, calls = [], [] + + def compare(label): + def run(values, **kwargs): + assert values is samples + calls.append((label, kwargs)) + return {"verdict": "inconclusive"} + return run + + monkeypatch.setattr(runner.inert, "comparison_module", lambda: SimpleNamespace( + compare_managed=compare("managed"), compare_native=compare("legacy"), compare_managed_product=compare("product"), + )) + args = SimpleNamespace(fixed_slot=True, blocks=2, pairs_per_block=10, cache_mode="warm", + cases={"managed": ["managed-mux"], "product": ["managed-product-first-use"], "legacy": list(runner.CASES)}[case]) + assert runner.compare_selected_samples(args, samples, phase="aa") == {"verdict": "inconclusive"} + assert calls == [(case, {"cache_mode": "warm", "phase": "aa", "blocks": 2, "pairs_per_block": 10})] + + +@pytest.mark.parametrize("case", runner.OPTIONAL_CASES) +@pytest.mark.parametrize("fault", ["small", "unfixed", "subset", "mixed"]) +def test_diagnostic_or_partial_selection_never_gets_a_statistical_pass(monkeypatch, fault, case): + monkeypatch.setattr(runner.inert, "comparison_module", lambda: pytest.fail("must not compare partial diagnostics")) + args = SimpleNamespace(fixed_slot=True, blocks=2, pairs_per_block=10, cases=[case]) + if fault == "small": + args.pairs_per_block = 1 + elif fault == "unfixed": + args.fixed_slot = False + elif fault == "subset": + args.cases = ["embedded"] + else: + args.cases = [case, "embedded"] + assert runner.compare_selected_samples(args, [], phase="aa")["verdict"] == "not-evaluated" + + +@pytest.mark.parametrize("case", runner.HISTORY_CASES) +def test_history_campaign_selects_only_its_frozen_metrics(monkeypatch, case): + calls = [] + + def compare(values, **kwargs): + calls.append(kwargs) + return {"verdict": "inconclusive"} + + monkeypatch.setattr(runner.inert, "comparison_module", lambda: SimpleNamespace(compare_managed_history=compare)) + args = SimpleNamespace(fixed_slot=True, blocks=2, pairs_per_block=10, cases=[case], cache_mode="warm") + assert runner.compare_selected_samples(args, [], phase="aa") == {"verdict": "inconclusive"} + assert calls == [dict(case=case, cache_mode="warm", phase="aa", blocks=2, pairs_per_block=10)] diff --git a/tests/dev/test_lmux_product_collection.py b/tests/dev/test_lmux_product_collection.py new file mode 100644 index 000000000..34109b4e6 --- /dev/null +++ b/tests/dev/test_lmux_product_collection.py @@ -0,0 +1,176 @@ +"""Three-child receipt aggregation, not a substitute for installed runs.""" + +import asyncio +import copy +import json +from types import SimpleNamespace + +import pytest + +from .test_lmux_product_scenario import scenario_value +from .test_measure_g18_native import runner + + +def collection(tmp_path): + children = {} + for index, name in enumerate(("reply", "approval", "interrupt")): + child, parameters = scenario_value(tmp_path / "workspace", name) + replacements = {"a" * 32: str(index + 1) * 32, "b" * 64: str(index + 4) * 64, + "d" * 64: str(index + 7) * 64} + + def shift(value, key="", replacements=replacements, index=index): + if isinstance(value, dict): + return {key: shift(item, key) for key, item in value.items()} + if isinstance(value, list): + return [shift(item) for item in value] + if isinstance(value, str): + return replacements.get(value, value) + if key.endswith("_at") or key == "start": + return value + index * 20 + if key.endswith("_ns"): + return value + index * 20_000_000_000 + return value + + shifted = shift(child) + # Recompute from shifted endpoints just as the producer does; floating + # subtraction at a new clock origin need not retain the old bit pattern. + result = shifted[{"reply": "fixed_product_first_reply", "approval": "fixed_product_tool_approval", + "interrupt": "fixed_product_interrupt_next_turn"}[name]] + timing = result["next_turn"] if name == "interrupt" else result + shifted["milestones"] = {key + "_seconds": action["finished_at"] - action["started_at"] + for key, action in shifted["actions"].items()} + for key, duration in shifted["milestones"].items(): + timing["spawn_through_visible_reply_seconds" if key == "fixed_entry_through_visible_reply_seconds" else key] = duration + children[name] = shifted + prefix = parameters["prefix"] + value = dict.fromkeys(runner.OBSERVER_FIELDS) + value.update(schema_version=2, case="managed-product-first-use", status="observed", valid=False, + measured_prefix=str(prefix), sample_id=str(tmp_path.resolve()), seed="empty", seed_setup=[], + fixed_product_scenarios=children, + spawns=[spawn for child in children.values() for spawn in child["spawns"]], + milestones={key: item for child in children.values() for key, item in child["milestones"].items()}) + return value, dict(prefix=prefix, root=tmp_path, observer_started=0.5, owner_settled=60.0) + + +def test_collection_completion_keeps_original_and_does_not_promote_valid(tmp_path): + value, parameters = collection(tmp_path) + original = copy.deepcopy(value) + completed = runner.complete_managed_product(value, **parameters) + assert value == original + assert completed["valid"] is False + assert completed["outer_settlement"] == {"started_at": 0.5, "settled_at": 60.0} + assert len(completed["spawns"]) == 4 + assert len(completed["milestones"]) == 8 + + +@pytest.mark.parametrize("outcome", ["complete", "deferred", "failure", "cancel", "wrong-install"]) +def test_original_owner_must_return_before_receipt_completion(tmp_path, monkeypatch, outcome): + root = tmp_path / "sample" + value, parameters = collection(root) + prefix, observer = parameters["prefix"], tmp_path / "observer" + value.update(observer_prefix=str(observer), observer_origin=str(observer / "lib/loushang/coding/__init__.py")) + if outcome == "wrong-install": + value["observer_prefix"] = str(tmp_path / "unrelated") + failure = asyncio.CancelledError() if outcome == "cancel" else RuntimeError("owner cleanup failed") + events, clock = [], iter((0.5, 60.0)) + monkeypatch.setattr(runner, "time", SimpleNamespace(perf_counter=lambda: next(clock))) + + def supervise(argv, **kwargs): + assert argv[4] == "managed-product-first-use" + assert kwargs["timeout"] == 720 + runner.inert.write_report(root / "native.json", value) + if outcome in {"failure", "cancel"}: + raise failure # Even a complete-looking file cannot override failure. + events.append("owner-return") + + complete = runner.complete_managed_product + + def after_owner(*args, **kwargs): + assert events == ["owner-return"] + events.append("complete-receipt") + return complete(*args, **kwargs) + + monkeypatch.setattr(runner.owner, "run_python", supervise) + monkeypatch.setattr(runner, "complete_managed_product", after_owner) + report, path = {"samples": []}, tmp_path / "report.json" + + def run(): + runner.run_sample(prefix, "managed-product-first-use", root, tmp_path / "pyc", report, path, + dict(side="a", block=0, pair=0), observer_prefix=observer, + defer_validation=outcome == "deferred") + + if outcome in {"failure", "cancel", "wrong-install"}: + with pytest.raises(ValueError if outcome == "wrong-install" else type(failure)): + run() + else: + run() + (sample,) = json.loads(path.read_text())["samples"] + assert sample["valid"] is (outcome == "complete") + if outcome in {"failure", "cancel"}: + assert events == [] + assert sample["status"] == "failed" + assert "outer_settlement" not in sample + assert sample["partial_observation"] == value + elif outcome == "wrong-install": + assert sample["status"] == "failed" + else: + assert sample["status"] == ("observed" if outcome == "deferred" else "complete") + assert sample["fixed_product_scenarios"] == value["fixed_product_scenarios"] + assert sample["outer_settlement"] == {"started_at": 0.5, "settled_at": 60.0} + + +@pytest.mark.parametrize("identity", ["instance", "service", "session"]) +def test_collection_rejects_reused_identity_even_when_child_is_valid(tmp_path, identity): + value, parameters = collection(tmp_path) + old, new = {"instance": ("2" * 32, "1" * 32), + "service": ("5" * 64, "4" * 64), + "session": ("8" * 64, "7" * 64)}[identity] + + def replace(item): + if isinstance(item, dict): + return {key: replace(part) for key, part in item.items()} + if isinstance(item, list): + return [replace(part) for part in item] + return new if isinstance(item, str) and item == old else item + + child = replace(value["fixed_product_scenarios"]["approval"]) + value["fixed_product_scenarios"]["approval"] = child + runner.validate_managed_product_scenario(child, "approval", prefix=parameters["prefix"], + workspace=tmp_path / "workspace" / "approval") + with pytest.raises(ValueError, match="fresh service and Session identities"): + runner.complete_managed_product(value, **parameters) + + +@pytest.mark.parametrize("fault", ["missing", "extra", "valid", "self-settlement", "failed-child", + "summary", "spawns", "outer-early", "outer-late", "outer-bool", + "outer-nan", "seed", "overlap"]) +def test_collection_rejects_incomplete_or_premature_receipts(tmp_path, fault): + value, parameters = collection(tmp_path) + if fault == "missing": + del value["fixed_product_scenarios"]["approval"] + elif fault == "extra": + value["failure"] = "late failure" + elif fault == "valid": + value["valid"] = True + elif fault == "self-settlement": + value["outer_settlement"] = {"started_at": 0, "settled_at": 60} + elif fault == "failed-child": + value["fixed_product_scenarios"]["approval"]["status"] = "failed" + elif fault == "summary": + value["milestones"]["visible_reply_seconds"] += 1 + elif fault == "spawns": + value["spawns"] = value["spawns"][:-1] + elif fault == "outer-early": + parameters["owner_settled"] = 52 + elif fault == "outer-late": + parameters["observer_started"] = 2 + elif fault == "outer-bool": + parameters["observer_started"] = False + elif fault == "outer-nan": + parameters["owner_settled"] = float("nan") + elif fault == "seed": + value["seed"] = "history" + elif fault == "overlap": + value["fixed_product_scenarios"]["approval"]["started_at"] = 12 + with pytest.raises(ValueError): + runner.complete_managed_product(value, **parameters) diff --git a/tests/dev/test_lmux_product_confirmation.py b/tests/dev/test_lmux_product_confirmation.py new file mode 100644 index 000000000..397a4f9cd --- /dev/null +++ b/tests/dev/test_lmux_product_confirmation.py @@ -0,0 +1,56 @@ +"""Strict snapshot value binding, independent of IPC/native authenticity.""" + +import copy + +import pytest + +from .test_measure_g18_native import runner + + +@pytest.mark.parametrize("pending", [False, True]) +@pytest.mark.parametrize("fault", [None, "early", "late", "instance", "member", "extra-record", "wrong-request", + "running", "session", "scope", "fingerprint", "bool-time", "extra-flag"]) +def test_confirmation_requires_original_target_exact_records_and_post_detach_order(pending, fault): + target = {"stage": "first-member", "observed_at": 1.0, "instanceId": "a" * 32, + "serviceId": "b" * 64, "muxId": "mux-1", "members": [{"memberId": "member-1", "sessionId": "session-1"}]} + records = [{"kind": "user", "text": ("delayed " if pending else "reply ") + "c" * 32}] + reply = "LMUX_REPLY_" + "c" * 32 + if not pending: + records.append({"kind": "assistant", "text": reply}) + value = {**copy.deepcopy(target), "stage": "detached", "observed_at": 5.0, + "pendingConfirmed" if pending else "replyConfirmed": True, + "snapshot": {"confirmed_at": 6.0, "running": pending, "expected_reply": reply, + "records": copy.deepcopy(records), "identity": { + "product_id": "coding", "continuity_id": "continuity-1", + "session_id": "session-1", "scope": "user_home", "scope_fingerprint": "d" * 64}}} + snapshot = value["snapshot"] + if fault == "early": + value["observed_at"] = 2.0 + elif fault == "late": + snapshot["confirmed_at"] = 10.0 + elif fault == "instance": + value["instanceId"] = "e" * 32 + elif fault == "member": + value["members"][0]["memberId"] = "other" + elif fault == "extra-record": + snapshot["records"].append({"kind": "user", "text": "another"}) + elif fault == "wrong-request": + snapshot["records"][0]["text"] = "reply " + "e" * 32 + elif fault == "running": + snapshot["running"] = not pending + elif fault == "session": + snapshot["identity"]["session_id"] = "other" + elif fault == "scope": + snapshot["identity"]["scope"] = "cwd" + elif fault == "fingerprint": + snapshot["identity"]["scope_fingerprint"] = "invalid" + elif fault == "bool-time": + snapshot["confirmed_at"] = True + elif fault == "extra-flag": + value["replyConfirmed" if pending else "pendingConfirmed"] = True + parameters = dict(expected_reply=reply, records=records, earliest=3.0, latest=9.0, pending=pending) + if fault is None: + runner.validate_managed_product_confirmation(value, target, **parameters) + else: + with pytest.raises(ValueError): + runner.validate_managed_product_confirmation(value, target, **parameters) diff --git a/tests/dev/test_lmux_product_scenario.py b/tests/dev/test_lmux_product_scenario.py new file mode 100644 index 000000000..bd64daf38 --- /dev/null +++ b/tests/dev/test_lmux_product_scenario.py @@ -0,0 +1,153 @@ +"""Complete synthetic child receipts; independent of installed provenance.""" + +import copy + +import pytest + +from .test_lmux_product_terminal_validation import sample +from .test_measure_g18_native import runner + + +def scenario_value(tmp_path, scenario): + child, parameters = sample(tmp_path, scenario) + target = {"stage": "first-member", "observed_at": 2.05, "instanceId": "a" * 32, + "serviceId": "b" * 64, "muxId": "mux-1", "members": [{"memberId": "member-1", "sessionId": "session-1"}]} + identity = {"product_id": "coding", "continuity_id": "continuity-1", "session_id": "session-1", + "scope": "user_home", "scope_fingerprint": "d" * 64} + + def observation(stage, at): + return {**copy.deepcopy(target), "stage": stage, "observed_at": at} + + def confirm(records, expected, *, at=4.1, pending=False): + return {**observation("detached", at), "pendingConfirmed" if pending else "replyConfirmed": True, + "snapshot": {"confirmed_at": at + 0.1, "running": pending, "expected_reply": expected, + "identity": dict(identity), "records": [{"kind": kind, "text": text} for kind, text in records]}} + + intervals = { + "reply": {"fixed_entry_through_visible_reply": (2.0, 2.8), "visible_reply": (2.1, 2.8)}, + "approval": {"approval_pending": (2.1, 2.2), "approval_details": (2.3, 2.4), "approved_tool_reply": (2.5, 2.8)}, + "interrupt": {"interrupt_through_idle_and_producer": (6.2, 6.4), "next_reply": (6.5, 6.8), + "interrupt_through_next_reply": (6.2, 6.8)}, + }[scenario] + actions = {name: {"started_at": start, "finished_at": end} for name, (start, end) in intervals.items()} + milestones = {name + "_seconds": end - start for name, (start, end) in intervals.items()} + timing = {**milestones, "actions": copy.deepcopy(actions)} + child.update(actions=actions, milestones=milestones, fixed_product_target=target, + fixed_product_detached=observation("detached", 4.3)) + native = {"pid": 123, "start_ticks": 100, "boot_id": "12345678-1234-1234-1234-123456789abc", + "user_id": 1000, "pid_namespace_device": 4, "pid_namespace_inode": 5} + child["fixed_product_native"] = {stage: dict(native) for stage in + ("first-member", "detached", "stop", "reattached", "final-detached")[:5 if scenario == "interrupt" else 3]} + stop, nonce, next_nonce = dict(child["fixed_product_stop"]["result"]), "c" * 32, "e" * 32 + reply = "LMUX_REPLY_" + nonce + if scenario == "reply": + timing["spawn_through_visible_reply_seconds"] = timing.pop("fixed_entry_through_visible_reply_seconds") + child["fixed_product_first_reply"] = {**timing, "request_nonce": nonce, "stop": stop, + "composition": "managed-infrastructure-with-fixed-test-product", + "confirmation": confirm([("user", "reply " + nonce), ("assistant", reply)], reply)} + elif scenario == "approval": + child["fixed_product_tool_approval"] = {**timing, "stop": stop, "approved_sent_ns": 2_600_000_000, + "tool_call_id": "lmux-call-1", "tool_execution_count": 1, + "tool_effects": [{"instance_id": "a" * 32, "call_id": "lmux-call-1", "monotonic_ns": 2_700_000_000, "sequence": 3}], + "confirmation": confirm([("user", "approval"), ("assistant", ""), ("assistant", "LMUX_TOOL_COMPLETED")], "LMUX_TOOL_COMPLETED")} + else: + producer = {"instance_id": "a" * 32, "call_id": "lmux-call-1", "phase": "producer_started", + "monotonic_ns": 2_500_000_000, "sequence": 1} + timing.update(interrupted_nonce=nonce, next_nonce=next_nonce, interrupt_sent_ns=6_300_000_000, + producer_before=[producer], producer_after=[dict(producer), {**producer, "phase": "producer_settled", "sequence": 2, "monotonic_ns": 6_350_000_000}], + reattached=observation("reattached", 6.1), detached=observation("detached", 8.3), + next_reply_confirmation=confirm([("user", "delayed " + nonce), ("assistant", ""), + ("user", "reply " + next_nonce), ("assistant", "LMUX_REPLY_" + next_nonce)], "LMUX_REPLY_" + next_nonce, at=8.1)) + child["fixed_product_interrupt_next_turn"] = {"request_nonce": nonce, "stop": stop, + "producer": "lmux-call-1", "producer_settled": True, "full_text_not_completed": True, + "pending_confirmation": confirm([("user", "delayed " + nonce)], reply, pending=True), "next_turn": timing} + return child, dict(prefix=parameters["prefix"], workspace=parameters["workspace"]) + + +@pytest.mark.parametrize("scenario", ["reply", "approval", "interrupt"]) +@pytest.mark.parametrize("fault", [None, "missing", "extra", "extra-result", "extra-timing", "late-action", "snapshot-record", "snapshot-early", "stop-copy", "raw-duration", "detached-instance"]) +def test_complete_scenario_reuses_all_evidence_gates(tmp_path, scenario, fault): + child, parameters = scenario_value(tmp_path, scenario) + key = {"reply": "fixed_product_first_reply", "approval": "fixed_product_tool_approval", "interrupt": "fixed_product_interrupt_next_turn"}[scenario] + result = child[key] + timing = result["next_turn"] if scenario == "interrupt" else result + confirmation = result["pending_confirmation"] if scenario == "interrupt" else result["confirmation"] + if fault == "missing": + del child["fixed_product_detached"] + elif fault == "extra": + child["failure"] = "late error" + elif fault == "extra-result": + result["failure"] = "late error" + elif fault == "extra-timing": + timing["failure"] = "late error" + elif fault == "late-action": + child["terminal_settlements"][-1]["termios_restored_at"] = child["spawns"][-1]["start"] + elif fault == "snapshot-record": + confirmation["snapshot"]["records"].append({"kind": "user", "text": "extra"}) + elif fault == "snapshot-early": + confirmation["observed_at"] = 2.9 + elif fault == "stop-copy": + result["stop"]["instanceId"] = "f" * 32 + elif fault == "raw-duration": + timing[next(name for name in timing if name.endswith("_seconds"))] += 1 + elif fault == "detached-instance": + child["fixed_product_detached"]["instanceId"] = "f" * 32 + if fault is None: + runner.validate_managed_product_scenario(child, scenario, **parameters) + else: + with pytest.raises(ValueError): + runner.validate_managed_product_scenario(child, scenario, **parameters) + + +@pytest.mark.parametrize("fault", ["early-effect", "duplicate-effect", "bool-count"]) +def test_tool_success_requires_unique_post_approval_effect(tmp_path, fault): + child, parameters = scenario_value(tmp_path, "approval") + result = child["fixed_product_tool_approval"] + if fault == "early-effect": + result["tool_effects"][0]["monotonic_ns"] = 2_400_000_000 + elif fault == "duplicate-effect": + result["tool_effects"] *= 2 + else: + result["tool_execution_count"] = True + with pytest.raises(ValueError): + runner.validate_managed_product_scenario(child, "approval", **parameters) + + +@pytest.mark.parametrize("scenario", ["reply", "approval", "interrupt"]) +@pytest.mark.parametrize("fault", ["missing", "extra", "changed-start", "bool-uid", "bad-boot", "stop-pid"]) +def test_original_native_identity_is_preserved_through_stop(tmp_path, scenario, fault): + child, parameters = scenario_value(tmp_path, scenario) + natives = child["fixed_product_native"] + if fault == "missing": + del natives["detached"] + elif fault == "extra": + natives["unknown"] = dict(natives["first-member"]) + elif fault == "changed-start": + natives["detached"]["start_ticks"] += 1 + elif fault == "bool-uid": + for item in natives.values(): + item["user_id"] = 1 + natives["stop"]["user_id"] = True + elif fault == "bad-boot": + for item in natives.values(): + item["boot_id"] = "invalid" + else: + natives["stop"]["pid"] += 1 + with pytest.raises(ValueError, match="native identity"): + runner.validate_managed_product_scenario(child, scenario, **parameters) + + +@pytest.mark.parametrize("fault", ["same-nonce", "early-settle", "different-producer", "different-continuity"]) +def test_interrupt_cannot_replay_or_change_original_producer_and_session(tmp_path, fault): + child, parameters = scenario_value(tmp_path, "interrupt") + timing = child["fixed_product_interrupt_next_turn"]["next_turn"] + if fault == "same-nonce": + timing["next_nonce"] = timing["interrupted_nonce"] + elif fault == "early-settle": + timing["producer_after"][1]["monotonic_ns"] = 6_100_000_000 + elif fault == "different-producer": + timing["producer_after"][0]["sequence"] = 0 + else: + timing["next_reply_confirmation"]["snapshot"]["identity"]["continuity_id"] = "other" + with pytest.raises(ValueError): + runner.validate_managed_product_scenario(child, "interrupt", **parameters) diff --git a/tests/dev/test_lmux_product_terminal_validation.py b/tests/dev/test_lmux_product_terminal_validation.py new file mode 100644 index 000000000..550bd445f --- /dev/null +++ b/tests/dev/test_lmux_product_terminal_validation.py @@ -0,0 +1,75 @@ +"""Pure terminal/stop receipt checks; no claim of native authentication.""" + +import pytest + +from .test_measure_g18_native import runner + + +def sample(tmp_path, scenario): + prefix, workspace = tmp_path / "install", tmp_path / scenario + spawns, terminals = [], [] + for index in range(2 if scenario == "interrupt" else 1): + cwd = workspace if index == 0 else workspace / "elsewhere" + argv = ([str(prefix / "bin/python"), "-I", str(runner.inert.ROOT / "tests/coding/_lmux_product_entry.py"), "new", "-s", "perf"] + if index == 0 else [str(prefix / "bin/lmux"), "attach", "-t", "perf"]) + spawns.append({"pid": 100 + index, "start": 2.0 + index * 4, "argv": argv, "cwd": str(cwd)}) + terminals.append({"pid": 100 + index, "argv": argv, "cwd": str(cwd), "exit_status": 0, + "termios_restored_at": 3.0 + index * 4, "settled_at": 4.0 + index * 4, + "cursor_restored": True, "bracketed_paste_disabled": True, + "reader_settled": True, "fallback": False}) + child = {"status": "observed", "valid": False, "measured_prefix": str(prefix), + "workspace": str(workspace), "started_at": 1.0, "finished_at": 13.0, + "spawns": spawns, "terminal_settlements": terminals, + "fixed_product_stop": {"started_at": 10.0, "observed_at": 11.0, + "local_owner_settled_at": 12.0, "service_id": "b" * 64, + "result": {"status": "stopped", "instanceId": "a" * 32}}} + return child, dict(prefix=prefix, workspace=workspace, instance="a" * 32, service="b" * 64) + + +@pytest.mark.parametrize("scenario", ["reply", "approval", "interrupt"]) +@pytest.mark.parametrize("fault", [None, "missing", "extra", "pid", "cwd", "argv", "exit", "bool-exit", + "cursor", "paste", "reader", "fallback", "overlap", "nan", + "early-stop", "late-close", "wrong-instance", "wrong-service", + "self-outer", "failed", "cleanup-debt"]) +def test_every_actual_terminal_and_exact_stop_must_match(tmp_path, scenario, fault): + child, parameters = sample(tmp_path, scenario) + terminal, stop = child["terminal_settlements"][-1], child["fixed_product_stop"] + if fault == "missing": + child["terminal_settlements"].pop() + elif fault == "extra": + child["terminal_settlements"].append(dict(terminal)) + elif fault == "pid": + terminal["pid"] = 999 + elif fault == "cwd": + terminal["cwd"] = str(tmp_path) + elif fault == "argv": + child["spawns"][0]["argv"] = ["arbitrary-entry"] + elif fault in {"exit", "bool-exit"}: + terminal["exit_status"] = 1 if fault == "exit" else False + elif fault in {"cursor", "paste", "reader"}: + terminal[{"cursor": "cursor_restored", "paste": "bracketed_paste_disabled", "reader": "reader_settled"}[fault]] = False + elif fault == "fallback": + terminal["fallback"] = True + elif fault == "overlap": + child["spawns"][-1]["start"] = 0.5 + elif fault == "nan": + terminal["settled_at"] = float("nan") + elif fault == "early-stop": + stop["started_at"] = 0.5 + elif fault == "late-close": + stop["local_owner_settled_at"] = 14.0 + elif fault == "wrong-instance": + stop["result"]["instanceId"] = "c" * 32 + elif fault == "wrong-service": + stop["service_id"] = "c" * 64 + elif fault == "self-outer": + stop["outer_owner_settled_at"] = 13.0 + elif fault == "failed": + child["status"] = "failed" + elif fault == "cleanup-debt": + child["fixed_product_cleanup_failure"] = {"type": "TimeoutError"} + if fault is None: + runner.validate_managed_product_terminals(child, scenario, **parameters) + else: + with pytest.raises(ValueError): + runner.validate_managed_product_terminals(child, scenario, **parameters) diff --git a/tests/dev/test_lmux_product_timings.py b/tests/dev/test_lmux_product_timings.py new file mode 100644 index 000000000..1898636f2 --- /dev/null +++ b/tests/dev/test_lmux_product_timings.py @@ -0,0 +1,75 @@ +"""Pure timing subset tests; no claim of whole-sample acceptance.""" + +import pytest + +from .test_measure_g18_native import runner + + +def values(scenario): + intervals = { + "reply": {"fixed_entry_through_visible_reply": (1.0, 9.0), "visible_reply": (6.0, 9.0)}, + "approval": {"approval_pending": (1.0, 2.0), "approval_details": (4.0, 6.0), + "approved_tool_reply": (8.0, 11.0)}, + "interrupt": {"interrupt_through_idle_and_producer": (10.0, 12.0), + "next_reply": (20.0, 25.0), "interrupt_through_next_reply": (10.0, 25.0)}, + }[scenario] + return ({key: {"started_at": start, "finished_at": end} for key, (start, end) in intervals.items()}, + {key + "_seconds": end - start for key, (start, end) in intervals.items()}) + + +@pytest.mark.parametrize("scenario", ["reply", "approval", "interrupt"]) +@pytest.mark.parametrize("fault", [None, "missing", "extra", "boolean", "nan", "negative", "duration", "reversed", "extra-endpoint"]) +def test_exact_timing_subset(scenario, fault): + actions, milestones = values(scenario) + first = next(iter(actions)) + if fault == "missing": + milestones.pop(first + "_seconds") + elif fault == "extra": + milestones["unknown_seconds"] = 1.0 + elif fault in {"boolean", "nan", "negative"}: + actions[first]["started_at"] = {"boolean": True, "nan": float("nan"), "negative": -1.0}[fault] + elif fault == "duration": + milestones[first + "_seconds"] += 1.0 + elif fault == "reversed": + actions[first]["started_at"] = actions[first]["finished_at"] + 1.0 + elif fault == "extra-endpoint": + actions[first]["observed_at"] = 1.0 + if fault is None: + runner.validate_managed_product_timings(actions, milestones, scenario) + else: + with pytest.raises(ValueError): + runner.validate_managed_product_timings(actions, milestones, scenario) + + +@pytest.mark.parametrize("scenario", ["reply", "approval", "interrupt"]) +def test_matching_durations_do_not_hide_wrong_shared_endpoints_or_order(scenario): + actions, milestones = values(scenario) + if scenario == "reply": + actions["fixed_entry_through_visible_reply"]["finished_at"] += 1.0 + elif scenario == "approval": + actions["approval_details"]["started_at"] = 1.0 + else: + # Summing the two segments loses the eight-second gap. + actions["interrupt_through_next_reply"]["finished_at"] = 17.0 + for name, action in actions.items(): + milestones[name + "_seconds"] = action["finished_at"] - action["started_at"] + with pytest.raises(ValueError): + runner.validate_managed_product_timings(actions, milestones, scenario) + + +@pytest.mark.parametrize("value", [True, float("inf"), float("-inf"), float("nan"), 10**400, -1.0, "1"]) +@pytest.mark.parametrize("target", ["endpoint", "duration"]) +def test_invalid_numeric_values_are_rejected_without_conversion_overflow(value, target): + actions, milestones = values("reply") + if target == "endpoint": + actions["visible_reply"]["started_at"] = value + else: + milestones["visible_reply_seconds"] = value + with pytest.raises(ValueError): + runner.validate_managed_product_timings(actions, milestones, "reply") + + +@pytest.mark.parametrize("scenario", [None, [], {}, "unknown"]) +def test_unknown_scenario_fails_before_inspecting_payload(scenario): + with pytest.raises(ValueError, match="unknown"): + runner.validate_managed_product_timings(None, None, scenario) diff --git a/tests/dev/test_lmux_stop_measurement.py b/tests/dev/test_lmux_stop_measurement.py new file mode 100644 index 000000000..fcf55cba3 --- /dev/null +++ b/tests/dev/test_lmux_stop_measurement.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import copy +import json +import os +import stat +from types import SimpleNamespace + +import pytest + +from .test_measure_g18_native import runner + + +def observation(): + return { + "case": "managed-mux", "status": "observed", "valid": False, + "authenticated_instance_id": "a" * 32, + "milestones": {"cold_frame_seconds": 1.0}, + "managed_stop": { + "started_at": 12.0, + "result": {"status": "stopped", "instanceId": "a" * 32}, + }, + } + + +def test_stop_measurement_includes_outer_settlement_without_mutating_receipt(): + value = observation() + original = copy.deepcopy(value) + completed = runner.complete_managed_stop(value, observer_started=10.0, owner_settled=15.0) + assert completed["milestones"] == {"cold_frame_seconds": 1.0, "stop_settlement_seconds": 3.0} + assert completed["valid"] is False # Remaining provenance checks must still run. + assert value == original + + +@pytest.mark.parametrize("fault", [ + "before-launch", "after-settlement", "nan", "boolean", "wrong-instance", + "unproven-instance", "requested", "unclean", "missing", "extra", + "premature-duration", "failed-observer", "premature-valid", +]) +def test_stop_measurement_rejects_unproven_or_premature_completion(fault): + value = observation() + stop = value["managed_stop"] + if fault == "before-launch": + stop["started_at"] = 9.0 + elif fault == "after-settlement": + stop["started_at"] = 15.0 + elif fault == "nan": + stop["started_at"] = float("nan") + elif fault == "boolean": + stop["started_at"] = True + elif fault == "wrong-instance": + stop["result"]["instanceId"] = "b" * 32 + elif fault == "unproven-instance": + del value["authenticated_instance_id"] + elif fault in {"requested", "unclean"}: + stop["result"]["status"] = "stop_requested" if fault == "requested" else "exited_unclean" + elif fault == "missing": + del stop["result"] + elif fault == "extra": + stop["result"]["other"] = True + elif fault == "premature-duration": + value["milestones"]["stop_settlement_seconds"] = 0.1 + elif fault == "failed-observer": + value["status"] = "failed" + else: + value["valid"] = True + with pytest.raises(ValueError): + runner.complete_managed_stop(value, observer_started=10.0, owner_settled=15.0) + + +@pytest.mark.parametrize("failure", [RuntimeError("cleanup debt"), KeyboardInterrupt()]) +def test_outer_owner_failure_never_completes_managed_stop(tmp_path, monkeypatch, failure): + def supervise(*args, **kwargs): + raise failure + + monkeypatch.setattr(runner.owner, "run_python", supervise) + monkeypatch.setattr( + runner, "complete_managed_stop", + lambda *args, **kwargs: pytest.fail("failed owner must not publish settlement"), + ) + report, path = {"samples": []}, tmp_path / "report.json" + with pytest.raises(type(failure)) as caught: + runner.run_sample( + tmp_path / "install", "managed-mux", tmp_path / "sample", + tmp_path / "pyc", report, path, dict(side="a", block=0, pair=0), + observer_prefix=tmp_path / "observer", + ) + assert caught.value is failure + (sample,) = json.loads(path.read_text())["samples"] + assert sample["status"] == "failed" and sample["valid"] is False + assert "stop_settlement_seconds" not in sample.get("milestones", {}) + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX managed ancestor modes") +@pytest.mark.parametrize("case", ["managed-mux", "managed-product-first-use"]) +def test_managed_sample_parent_is_private_with_group_writable_umask(tmp_path, monkeypatch, case): + sample_root = tmp_path / "sample" + failure = RuntimeError("stop before launching") + + def supervise(*args, **kwargs): + assert stat.S_IMODE(sample_root.stat().st_mode) == 0o700 + assert stat.S_IMODE((sample_root / "workspace").stat().st_mode) == 0o700 + raise failure + + monkeypatch.setattr(runner.owner, "run_python", supervise) + previous = os.umask(0o002) + try: + with pytest.raises(RuntimeError) as caught: + runner.run_sample( + tmp_path / "install", case, sample_root, + tmp_path / "pyc", {"samples": []}, tmp_path / "report.json", + dict(side="a", block=0, pair=0), observer_prefix=tmp_path / "observer", + ) + assert caught.value is failure + finally: + os.umask(previous) + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX managed ancestor modes") +@pytest.mark.parametrize("case", ["managed-mux", "managed-product-first-use"]) +def test_fixed_collector_creates_private_parent_before_control(tmp_path, monkeypatch, case): + output, scratch = tmp_path / "output", tmp_path / "scratch" + output.mkdir() + scratch.mkdir(mode=0o700) + failure = RuntimeError("stop before slot execution") + + def run(side, operation): + root = scratch / "sample-1" + assert stat.S_IMODE(root.stat().st_mode) == 0o700 + assert (root / "control").is_dir() + raise failure + + monkeypatch.setattr( + runner.bytecode_policy, "BytecodePolicy", + lambda *_: SimpleNamespace(external=lambda side: output / side), + ) + slot = SimpleNamespace(run=run, receipt={}) + report = {"scratch": str(scratch), "samples": []} + previous = os.umask(0o002) + try: + with pytest.raises(RuntimeError) as caught: + runner.collect_fixed_native( + slot, "warm", [case], 1, 1, tmp_path / "observer", + {}, {}, output, report, output / "report.json", + ) + assert caught.value is failure + finally: + os.umask(previous) diff --git a/tests/dev/test_lmux_terminal_receipts.py b/tests/dev/test_lmux_terminal_receipts.py new file mode 100644 index 000000000..a33fbfd59 --- /dev/null +++ b/tests/dev/test_lmux_terminal_receipts.py @@ -0,0 +1,64 @@ +"""Receipt publication order; mocked native owner, not installed acceptance.""" + +import sys +from contextlib import contextmanager +from types import SimpleNamespace + +import pytest + +from tests.coding import _g18_native_probe as probe + + +@pytest.mark.skipif(sys.platform != "linux", reason="POSIX terminal observer") +@pytest.mark.parametrize("fault", [None, "body", "alive", "modes", "fallback", "reader", "exit", "close", "cursor", "paste"]) +def test_terminal_receipt_requires_original_complete_settlement(tmp_path, monkeypatch, fault): + import pty + import termios + + receipts, events = [], [] + diagnostics = SimpleNamespace(pid=123, reader_alive=fault == "reader", + termination="fallback" if fault == "fallback" else None, + exit_status=1 if fault == "exit" else 0) + monkeypatch.setattr(probe, "FAILURE_TREE_DIAGNOSTIC", False) + monkeypatch.setattr(pty, "openpty", lambda: (1, 2)) + monkeypatch.setattr(termios, "tcgetattr", lambda fd: [1] if fault == "modes" and fd == 1 else []) + output = "\x1b[?25l\x1b[?2004h\x1b[?25h\x1b[?2004l" + if fault == "cursor": + output += "\x1b[?25l" + if fault == "paste": + output += "\x1b[?2004h" + + @contextmanager + def terminal(*args, **kwargs): + pty.openpty() + try: + yield SimpleNamespace(diagnostics=diagnostics, is_alive=lambda: fault == "alive", raw_output=output) + finally: + assert receipts == [], "receipt preceded original owner close" + events.append("closed") + if fault == "close": + raise RuntimeError("late close failure") + + monkeypatch.setattr(probe, "foreground_terminal", terminal) + clock = iter([10.0, 12.0]) + monkeypatch.setattr(probe.time, "perf_counter", lambda: next(clock)) + + def run(): + with probe.observed_terminal(["lmux", "attach"], tmp_path, {}, settlements=receipts): + assert receipts == [] + if fault == "body": + raise RuntimeError("body failure") + + if fault is None: + run() + assert receipts == [{ + "pid": 123, "argv": ["lmux", "attach"], "cwd": str(tmp_path.resolve()), + "exit_status": 0, "termios_restored_at": 10.0, "settled_at": 12.0, + "cursor_restored": True, "bracketed_paste_disabled": True, + "reader_settled": True, "fallback": False, + }] + else: + with pytest.raises((AssertionError, RuntimeError)): + run() + assert receipts == [] + assert events == ["closed"] diff --git a/tests/dev/test_measure_g18_native.py b/tests/dev/test_measure_g18_native.py index b4ceed47f..e1088973d 100644 --- a/tests/dev/test_measure_g18_native.py +++ b/tests/dev/test_measure_g18_native.py @@ -606,6 +606,64 @@ def collect(*args, **kwargs): ) +@pytest.mark.parametrize("scenario", [ + "first-reply", "admission-diagnostic", "delayed-final", + "interrupt-next-turn", "tool-approval", "tool-denial", "hangup-interrupt-next-turn", + "hangup-natural-completion", "history-warm", "history-restore", +]) +def test_product_diagnostic_main_reaches_guarded_dispatch(tmp_path, monkeypatch, scenario): + from contextlib import nullcontext + from types import SimpleNamespace + + import loushang.coding + from tests.coding import _g18_native_probe as probe + from tests.coding import _lmux_history_restore as history_restore + from tests.coding import _lmux_product_probe as product + + root = tmp_path / "workspace" + root.mkdir() + monkeypatch.chdir(root) + prefix = tmp_path / "install" + monkeypatch.setattr(probe, "sys", SimpleNamespace(platform="linux", prefix=str(prefix))) + monkeypatch.setattr(loushang.coding, "__file__", str(prefix / "lib/coding.py")) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path / "home")) + monkeypatch.setattr(probe, "measured_entries", lambda _: nullcontext()) + calls = [] + + def guarded(operation): + calls.append("enter") + operation() + calls.append("settled") + + def first_reply(actual_root, report, **options): + assert actual_root == root and calls == ["enter"] + assert scenario != "history-restore" + assert options == { + "admission_diagnostic": scenario == "admission-diagnostic", + "delayed_final": scenario in {"delayed-final", "interrupt-next-turn", "hangup-interrupt-next-turn", "hangup-natural-completion"}, + "interrupt_next_turn": scenario in {"interrupt-next-turn", "hangup-interrupt-next-turn"}, + "transport_loss": scenario in {"hangup-interrupt-next-turn", "hangup-natural-completion"}, + "natural_completion": scenario == "hangup-natural-completion", + "tool_approval": scenario == "tool-approval", + "tool_denial": scenario == "tool-denial", + "history": scenario == "history-warm", + } + calls.append("product") + + monkeypatch.setattr(probe, "_guarded", guarded) + monkeypatch.setattr(product, "first_reply", first_reply) + def restored(actual_root, report): + assert actual_root == root and calls == ["enter"] and scenario == "history-restore" + calls.append("product") + monkeypatch.setattr(history_restore, "restore_history", restored) + receipt = tmp_path / "report.json" + probe.main(root, f"managed-product-{scenario}", receipt, prefix) + assert calls == ["enter", "product", "settled"] + report = json.loads(receipt.read_text()) + assert report["status"] == "observed" + assert report["valid"] is False # Only the outer owner may accept the sample. + + def test_diagnostic_observer_publication_failure_does_not_replace_owner_error( tmp_path, monkeypatch ): @@ -736,8 +794,11 @@ def test_native_main_publishes_comparison_only_after_all_evidence_gates( ): from types import SimpleNamespace + from tests.dev import test_g18_comparison from tests.dev.test_g18_comparison import native_samples + comparison = test_g18_comparison.comparison + source = dict(commit="baseline", lock_sha256="lock", wheel_sha256="wheel") manifest_calls = [] @@ -764,7 +825,9 @@ def collect(*args, temporary_parent): report = args[-2] assert report["comparison"]["verdict"] == "not-evaluated" report["samples"] = native_samples( - right=1.2 if outcome == "regression" else 1.0 + right=(1.0 * (1 + float(comparison.REGRESSION_RATIO)) + 0.01) + if outcome == "regression" + else 1.0 ) if outcome == "inconclusive": for sample in report["samples"]: diff --git a/tests/dev/test_measure_g18_startup.py b/tests/dev/test_measure_g18_startup.py index 21ee0c1f9..e557da3ec 100644 --- a/tests/dev/test_measure_g18_startup.py +++ b/tests/dev/test_measure_g18_startup.py @@ -18,6 +18,11 @@ SPEC.loader.exec_module(runner) +def _regressed_duration(): + """A duration exceeding the accepted ARD-004 regression ratio (baseline 1.0).""" + return 1.0 * (1 + float(runner.comparison_module().REGRESSION_RATIO)) + 0.01 + + @pytest.fixture(autouse=True) def _synthetic_collector_load(monkeypatch): # Collector unit tests exercise synthetic receipts, not host performance. @@ -433,7 +438,8 @@ def capture(argv, *, cwd, **_): if phase == "ab" and side == "b": duration = 0.7 if outcome != "target-not-met" else 1.0 if outcome == "regression" and Path(argv[0]).name == "loushang-plugin": - duration = 1.2 + # Must exceed the accepted ratio; a fixed 20% is below it now. + duration = _regressed_duration() if outcome == "inconclusive" and cwd.name.startswith("b1-"): duration += 0.3 if argv[-1] == "--version": diff --git a/tests/dev/test_reevaluate_g18_comparison.py b/tests/dev/test_reevaluate_g18_comparison.py new file mode 100644 index 000000000..df1c497e0 --- /dev/null +++ b/tests/dev/test_reevaluate_g18_comparison.py @@ -0,0 +1,181 @@ +"""The ARD-004 re-evaluation must never masquerade as a new measurement.""" + +from __future__ import annotations + +import copy +import importlib.util +import json +from pathlib import Path + +import pytest + +SCRIPT = ( + Path(__file__).resolve().parents[2] / "scripts/dev/reevaluate_g18_comparison.py" +) +SPEC = importlib.util.spec_from_file_location("reevaluate_g18_comparison", SCRIPT) +module = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(module) + +COMPARISON = module._support("_g18_comparison") + + +def campaign(*, cases=("managed-mux",), cache_mode="warm", samples=None): + if samples is None: + samples = [ + { + "case": cases[0], + "side": side, + "block": block, + "pair": pair, + "warmup": pair == -1, + "valid": True, + "failure": None, + "status": "complete", + "cache_mode": cache_mode, + # MANAGED_METRICS already carries the _seconds suffix. + "milestones": { + name: 1.0 for name in COMPARISON.MANAGED_METRICS["managed-mux"] + }, + } + for block in range(2) + for pair in range(-1, 10) + for side in ("a", "b") + ] + return { + "schema_version": 3, + "status": "complete-record-only", + "cases": list(cases), + "blocks": 2, + "pairs_per_block": 10, + "samples": samples, + "comparison": {"verdict": "inconclusive", "phase": "aa", "cache_mode": cache_mode}, + } + + +def write(tmp_path, value, name="report.json"): + path = tmp_path / name + path.write_text(json.dumps(value)) + return path + + +def test_reevaluation_records_contract_change_and_never_claims_acceptance(tmp_path): + source = write(tmp_path, campaign()) + out = tmp_path / "reevaluation.json" + value = module.reevaluate(source, out) + assert value["verdict"] == "pass" + assert value["source_verdict_before"] == "inconclusive" + assert value["record_kind"] == "g18-contract-reevaluation-not-a-new-measurement" + assert value["claims"]["is_new_measurement"] is False + assert value["claims"]["is_performance_acceptance"] is False + assert value["contract"]["stability_ratio"] == str(COMPARISON.STABILITY_RATIO) + assert value["contract"]["regression_ratio"] == str(COMPARISON.REGRESSION_RATIO) + # The original report must be byte-identical after re-evaluation. + assert module.digest(source) == value["source_report_sha256"] + + +def test_reevaluation_binds_the_exact_source_bytes(tmp_path): + source = write(tmp_path, campaign()) + first = module.reevaluate(source, tmp_path / "a.json") + # Any byte change to the source changes the recorded binding. + data = json.loads(source.read_text()) + data["samples"][0]["milestones"]["cold_frame_seconds"] = 1.5 + source.write_text(json.dumps(data)) + second = module.reevaluate(source, tmp_path / "b.json") + assert first["source_report_sha256"] != second["source_report_sha256"] + + +def test_reevaluation_refuses_to_overwrite(tmp_path): + source = write(tmp_path, campaign()) + out = tmp_path / "out.json" + out.write_text("{}") + with pytest.raises(ValueError, match="refusing to overwrite"): + module.reevaluate(source, out) + + +@pytest.mark.parametrize("status", ["running", "failed", "paused"]) +def test_reevaluation_refuses_an_incomplete_campaign(tmp_path, status): + data = campaign() + data["status"] = status + with pytest.raises(ValueError, match="completed uninterrupted campaign"): + module.reevaluate(write(tmp_path, data), tmp_path / "out.json") + + +def test_reevaluation_accepts_one_uninterrupted_checkpoint_segment(tmp_path): + # A --checkpoint campaign that never resumed has exactly one segment and + # stays automatically eligible. + data = campaign() + data["segments"] = [{"number": 0, "first_index": 0, "next_index": 44}] + data["segmented_acceptance"] = { + "eligible_for_automatic_acceptance": True, + "reason": "single uninterrupted segment; original gates still required", + } + assert module.reevaluate(write(tmp_path, data), tmp_path / "out.json")["verdict"] == "pass" + + +@pytest.mark.parametrize( + "segments,acceptance", + [ + ([{"number": 0}, {"number": 1}], {"eligible_for_automatic_acceptance": True}), + ([{"number": 0}], {"eligible_for_automatic_acceptance": False}), + ([{"number": 0}], None), + ], +) +def test_reevaluation_refuses_a_resumed_or_ineligible_campaign( + tmp_path, segments, acceptance +): + data = campaign() + data["segments"] = segments + if acceptance is not None: + data["segmented_acceptance"] = acceptance + with pytest.raises(ValueError, match="calibration audit"): + module.reevaluate(write(tmp_path, data), tmp_path / "out.json") + + +@pytest.mark.parametrize( + "field,value,message", + [ + ("blocks", 1, "two-block ten-pair"), + ("pairs_per_block", 5, "two-block ten-pair"), + ("cases", ["not-a-case"], "frozen comparison policy"), + ], +) +def test_reevaluation_refuses_an_unfrozen_policy(tmp_path, field, value, message): + data = campaign() + data[field] = value + with pytest.raises(ValueError, match=message): + module.reevaluate(write(tmp_path, data), tmp_path / "out.json") + + +def test_reevaluation_refuses_a_missing_phase_or_cache_condition(tmp_path): + data = campaign() + data["comparison"] = {} + with pytest.raises(ValueError, match="phase or cache condition"): + module.reevaluate(write(tmp_path, data), tmp_path / "out.json") + + +def test_reevaluation_does_not_mutate_the_source_file(tmp_path): + source = write(tmp_path, campaign()) + before = copy.deepcopy(json.loads(source.read_text())) + module.reevaluate(source, tmp_path / "out.json") + assert json.loads(source.read_text()) == before + + +def test_regression_beyond_the_accepted_ratio_is_still_reported(tmp_path): + # Phase "ab" with a stable candidate that sits above the accepted ratio. + # (In a real A/A both sides are the same wheel, so the cross-side + # "calibrated" rule legitimately rejects a large split like this one.) + samples = campaign()["samples"] + regressed = 1.0 * (1 + float(COMPARISON.REGRESSION_RATIO)) + 0.05 + for sample in samples: + for name in COMPARISON.MANAGED_METRICS["managed-mux"]: + sample["milestones"][name] = regressed if sample["side"] == "b" else 1.0 + # Small symmetric dispersion keeps both sides internally stable. + sample["milestones"][name] += 0.001 * (sample["pair"] % 3) + data = campaign(samples=samples) + data["comparison"] = {"verdict": "inconclusive", "phase": "ab", "cache_mode": "warm"} + source = write(tmp_path, data) + value = module.reevaluate(source, tmp_path / "out.json") + assert value["verdict"] == "regression" + assert value["comparison"]["cases"]["managed-mux"]["cold_frame_seconds"]["verdict"] == ( + "regression" + ) From 2ecf52ae505dedf18094aba1b9aed4ee23937931 Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 07:20:25 +0000 Subject: [PATCH 32/44] docs(architecture): record managed lmux boundaries --- docs/internals/architecture/apphost/README.md | 1 + ...osted-product-g9-entrypoint-inventory.json | 12 +- .../generated/current-package-dependencies.md | 4 +- .../agent-transcript-maintenance-boundary.md | 32 ++ .../plugin/plugin-boundary-sinks-plc9b.md | 37 ++ .../hosted-product-runtime-v1-inventory.md | 54 ++- scripts/ci/check-scopes.json | 4 +- .../test_apphost_a02_architecture.py | 8 + .../test_apphost_a03_a04_architecture.py | 28 ++ .../architecture/test_apphost_a0_contract.py | 8 + ...test_capability_runtime_convergence_pr0.py | 13 + .../architecture/test_coding_wave_a_budget.py | 43 ++- ...st_composition_lifecycle_authority_cla0.py | 14 + ...rable_hosted_application_continuity_g13.py | 5 +- .../test_foreground_hosted_application_g12.py | 8 +- .../test_hosted_application_g11.py | 36 +- ...test_hosted_product_g10_explicit_canary.py | 28 +- .../test_hosted_product_runtime_g9_closure.py | 19 +- ...test_hosted_product_runtime_v1_baseline.py | 97 +++++ ...test_hosted_session_workflow_g17_design.py | 2 + .../test_hosting_architecture_baseline.py | 26 ++ .../architecture/test_hosting_h0_contract.py | 4 + ...st_plugin_lifecycle_plc9c5_c50_baseline.py | 53 +++ .../test_unified_plugin_architecture.py | 360 +++++++++++++++++- tests/coding/test_apphost_composition.py | 6 +- tests/coding/test_ui_import_boundaries.py | 14 +- 26 files changed, 882 insertions(+), 34 deletions(-) create mode 100644 docs/internals/architecture/harness/plugin/plugin-boundary-sinks-plc9b.md diff --git a/docs/internals/architecture/apphost/README.md b/docs/internals/architecture/apphost/README.md index c903bb957..a796567a9 100644 --- a/docs/internals/architecture/apphost/README.md +++ b/docs/internals/architecture/apphost/README.md @@ -15,6 +15,7 @@ [G15 Foreground Hosted TUI Design](foreground-hosted-tui-g15.md) · [G17 Explicit Hosted Session Workflow](hosted-session-workflow-g17.md) · [lmux Managed Contract M0](lmux-contract-m0.md) · +[ARD-004 A/A Stability Gate vs Interactive Metric Scale (accepted)](decisions/accepted/ARD-004-aa-stability-gate-vs-interactive-metric-scale.md) · [Hosted Product Runtime V1 Plan](../drafts/hosted-product-runtime-v1-plan.md) ## Status diff --git a/docs/internals/architecture/apphost/hosted-product-g9-entrypoint-inventory.json b/docs/internals/architecture/apphost/hosted-product-g9-entrypoint-inventory.json index 229e9cd2c..979df860f 100644 --- a/docs/internals/architecture/apphost/hosted-product-g9-entrypoint-inventory.json +++ b/docs/internals/architecture/apphost/hosted-product-g9-entrypoint-inventory.json @@ -1,6 +1,16 @@ { "decision": "RETAIN", "entries": [ + { + "disposition": "explicit-managed-linux-preview", + "entrypointId": "coding.lmux.command", + "importsComposition": false, + "omissionOwner": null, + "packagingBinding": "project.scripts.lmux", + "source": "src/loushang/coding/cli/lmux.py", + "supportStatus": "installed-preview", + "surface": "mux" + }, { "disposition": "explicit-owned-foreground-tui", "entrypointId": "coding.hosted-tui.command", @@ -142,5 +152,5 @@ "surface": "mux" } ], - "inventoryVersion": 6 + "inventoryVersion": 7 } diff --git a/docs/internals/architecture/generated/current-package-dependencies.md b/docs/internals/architecture/generated/current-package-dependencies.md index 46a7e1482..43f6f31e4 100644 --- a/docs/internals/architecture/generated/current-package-dependencies.md +++ b/docs/internals/architecture/generated/current-package-dependencies.md @@ -50,6 +50,7 @@ graph TD PKG_AI --> PKG_FOUNDATION PKG_APPHOST --> PKG_APPSERVER PKG_APPHOST --> PKG_APPSERVICE + PKG_APPHOST --> PKG_FOUNDATION PKG_APPHOST --> PKG_HARNESS PKG_APPHOST --> PKG_HOSTING PKG_APPSERVICE --> PKG_APPSERVER @@ -97,7 +98,7 @@ graph TD | --- | --- | | `loushang.agent` | `loushang.ai`, `loushang.foundation` | | `loushang.ai` | `loushang.foundation` | -| `loushang.apphost` | `loushang.appserver`, `loushang.appservice`, `loushang.harness`, `loushang.hosting` | +| `loushang.apphost` | `loushang.appserver`, `loushang.appservice`, `loushang.foundation`, `loushang.harness`, `loushang.hosting` | | `loushang.appserver` | None | | `loushang.appservice` | `loushang.appserver` | | `loushang.channel` | `loushang.foundation`, `loushang.harness`, `loushang.harnesswork` | @@ -118,6 +119,7 @@ graph TD | Command | Implementation | | --- | --- | +| `lmux` | `loushang.coding.cli.lmux:main` | | `loushang` | `loushang.coding.cli.__main__:main` | | `loushang-hosted` | `loushang.coding.cli.hosted:main` | | `loushang-hosted-tui` | `loushang.coding.cli.hosted_client:main` | diff --git a/docs/internals/architecture/harness/agent-transcript-maintenance-boundary.md b/docs/internals/architecture/harness/agent-transcript-maintenance-boundary.md index 22893df62..d0245a680 100644 --- a/docs/internals/architecture/harness/agent-transcript-maintenance-boundary.md +++ b/docs/internals/architecture/harness/agent-transcript-maintenance-boundary.md @@ -103,6 +103,38 @@ The resolved runtime capability configuration remains authoritative per field. Product compaction settings use `None` to inherit capability values, while concrete live settings override only their corresponding fields. +## Linux transcript deletion ownership + +Linux callers of `delete_agent_transcript_jsonl` and the base +`ProductTranscriptSession.delete_session` must supply `maintenance_owner`. +The narrow `TranscriptDeletionOwner` protocol delegates to the application's +existing owned `AgentTranscriptSessionFactory.delete_transcript`; it does not +construct a temporary factory or decide authority by looking for lock files. + +```python +deleted = await delete_agent_transcript_jsonl( + session_file, + current_session_file=current_session_file, + maintenance_owner=application_transcript_factory, +) +``` + +Here `application_transcript_factory` is the caller's already retained owned +factory. The application keeps it through completion and shutdown, including +any pending preparation or cleanup error. The facade neither closes that +factory nor converts an unknown commit or cleanup failure into success. + +This is an intentional Linux low-level API compatibility change: without an +owner the call raises before accessing the path, even if the file does not +exist. The ordinary Linux Coding application runtime uses its original owned +factory for deletion. Its bound SessionManager rejects a different owner. +Non-Linux unowned behavior is unchanged; that is not a claim of shared Linux +writer protection on those platforms. + +Owned deletion removes the transcript under its admitted writer authority. +It leaves attachments recoverable for explicit maintenance; successful +transcript deletion does not mean all associated storage has been reclaimed. + ## Verification - Harness tests cover context checkpoint staleness, compaction checkpoint diff --git a/docs/internals/architecture/harness/plugin/plugin-boundary-sinks-plc9b.md b/docs/internals/architecture/harness/plugin/plugin-boundary-sinks-plc9b.md new file mode 100644 index 000000000..6232be57c --- /dev/null +++ b/docs/internals/architecture/harness/plugin/plugin-boundary-sinks-plc9b.md @@ -0,0 +1,37 @@ +# PLC9B aggregate boundary inventory reconciliation + +The aggregate plugin boundary guard predates the PLC9B lifecycle components. +During lmux acceptance its scan found 58 additional `(qualified function, +operation)` entries: 48 functions containing 64 calls. The four scanned plugin +roots have no lmux source changes. This is a baseline inventory reconciliation, +not new plugin authority introduced by lmux. + +The reviewed, literal inventory is `PLC9B_BOUNDARY_SINKS` in +`tests/architecture/test_unified_plugin_architecture.py`, plus the installed +distribution top-level metadata reader. Expected values are not generated from +the source scan. Exact sites, operation counts, owner names, all four scan roots, +and the synthetic bypass cases remain checked. + +## Ownership rationale + +| Component | Boundary owner and responsibility | +| --- | --- | +| Ten lifecycle journal JSON helpers | Their respective journal owns strict duplicate-key decoding and its input read. | +| Quarantine acquisition | Quarantine native boundary owns artifact and verified tree handles. | +| POSIX/Windows role stores | Platform role store owns verified materialization handles and receipts. | +| POSIX/Windows epoch cutover | Platform cutover owner owns pinned roots and ancestor traversal. | +| POSIX/Windows offline restore | Platform restore materializer owns exclusive restore roots, input handles and strict records. | +| Wheel verification | Safe wheel verifier owns archive entry reads, not unrestricted filesystem access. | +| Verified tree transfer | Transfer owner calls the typed sink's verified file-open operation. | +| Retention handoff | Journal constructs its receipt; handoff owner invokes that journal's open protocol. | +| Installed distribution evidence | Existing evidence resolver reads `top_level.txt` metadata; it does not execute the package. | + +`path_read` is a syntactic detector label. In particular, archive `.open`, typed +journal `.open`, and the pure retention receipt constructor are not interchangeable +with native filesystem opens. Recording them does not grant callers additional +filesystem authority or exempt future calls from the guard. + +The component contracts remain authoritative: +[PLC9B contract](plugin-lifecycle-plc9b-contract.md), and the component inventory in +`tests/architecture/test_plugin_lifecycle_plc9b_contract.py`. This reconciliation +does not claim a fresh full security audit of every PLC9B implementation. diff --git a/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md b/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md index 6e8c735a7..887ba9de0 100644 --- a/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md +++ b/docs/internals/architecture/hosting/validation/hosted-product-runtime-v1-inventory.md @@ -56,19 +56,65 @@ lease, or factory. | Product Worker lifecycle | `src/loushang/harness/worker/product_activation.py` | strict Product policy/receipt, serialized freshness gate, durable attempt aggregate, publication/retirement, kill switch, cleanup settlement/debt, and bounded restart evidence | | Linux Product native-profile friend | `src/loushang/harness/worker/_native_profile_bridge.py` | binds one exact C5.1 receipt/request to the single-use C5.2 Linux x86_64 contained profile while keeping Hosting-private capture material opaque | | Session discovery contracts | `src/loushang/harness/transcript/discovery.py` and `src/loushang/harness/transcript/session_catalog.py` | canonical/compatibility source identity, stable locator, bounded summaries, aliases/conflicts, and multi-source read model; no generic `product_id` envelope yet | +| optional Session writer primitive | `src/loushang/harness/transcript/writer_lease.py` and `src/loushang/harness/journal/_directory_lease.py` | Linux single-root conversation exclusion across Product bindings; shared neutral descriptor/lock mechanism preserves exact transcript lock names, fork-copy disposal and unknown-close fencing; not wired into runtime/catalog defaults | +| explicit first Session-root initialization | `src/loushang/harness/journal/_directory_lease.py`, `src/loushang/harness/transcript/writer_lifecycle.py` and `src/loushang/coding/managed_catalog.py` | opt-in owned create retains no-follow parent creation, sync debt and temporary ancestor descriptors in the original writer; restore refuses the grant, managed consumption is one-shot and positive discovery observations revoke it even if the root subsequently disappears; no missing-root inference, automatic defaults or cross-process freshness proof | +| explicit owned transcript deletion | `src/loushang/harness/transcript/session_factory.py` and `src/loushang/harness/conversation/stores/file.py` | original internal source preparation retains writer and native cleanup through Store deletion; exact receipt validation separates uncertain commit from committed deletion with pending disposal; attachment bytes remain untouched, missing-target False does not settle prior debt, and default Product/Embedded deletion is not yet redirected | +| owned Product wrapper handoff | `src/loushang/harness/transcript/session_factory.py` and `src/loushang/harness/transcript/product_session.py` | synchronous wrapper construction and creating-factory binding complete before the original pending preparation is released; failed projection/fence retains original cleanup, instance fork keeps its exact creating factory, and legacy calls do not receive the private callback; no default application factory activation or complete Graph assembly claim | +| Product construction cleanup retention | `src/loushang/harness/session/transcript_lifecycle.py` and `product_runtime.py` | the original store retains returned transcripts through validation/build failures until disposal succeeds; runtime shutdown fences construction and joins original lifecycle disposal before retrying undelivered cleanup; started builders settle through the existing transition lock, Graph no-op is not completion; pre-return factory debt belongs to the separately held application factory (wired in explicit Coding mode), not this store; no default activation is claimed | +| explicit Coding application transcript factory | `src/loushang/coding/runtime/agent_session_runtime.py` and `session_manager.py` | opt-in runtime owns/fences/closes its persistent factory; legacy singleton remains unchanged, transient open/recent uses non-mutating transcript and advisory blob reads, rename retains original source cleanup and delete retains attachments; actual Coding Graph create/restore/fork and failed-wrapper cleanup are tested, but default CLI/Embedded activation and full managed delivery remain unfinished | +| managed parent startup bridge | `src/loushang/apphost/managed/starter.py` | borrows an admitted journal, reserves one generation before real-instance layout and original native spawn, reconciles birth without replay, rejects foreign UID and late close admission, and closes only local resources; stable service paths need no invented instance; not application readiness, initialization/recovery coordination, or installed CLI activation | +| managed Coding process and connection instance | `src/loushang/coding/managed_process.py`, `managed_local.py`, AppHost/AppServer local adapters | explicit production module uses the original bootstrap/process lifetime, frozen launch environment and canonical Session path; managed instance flows into the existing authenticated record and optional client expectation rejects stale records before socket creation; real source-environment service create/list/reconnect/stop is tested without Session/model calls; automatic startup/recovery coordination, installed wheel and default CLI remain unfinished | +| managed public connection lease | `src/loushang/apphost/managed/connection.py` | fixed instance, same-user journal/native/stop admission before and after AppServer authentication; exposes semantic clients and pathless scopes without retargeting or spawning; close joins actual native receipts and never stops the service, unknown acquisition/close remains debt; production source-environment fresh reconnect uses this lease, while automatic startup, initialization/recovery, default CLI and installed acceptance remain unfinished | +| managed read-only discovery | `src/loushang/apphost/managed/discovery.py` | borrows the canonical registry for bounded global-name snapshots or exact service/instance inspection independent of active mux names; shared strict durable decoding without native observation, credentials or authority; status CLI reports recorded-only facts and never infers readiness, creates directories, recovers or replays mutations | +| managed start/reuse coordinator | `src/loushang/apphost/managed/coordinator.py` | caller retains one generated operation over admitted storage; at most one native start, journal contention/unknown reply reconciliation and original birth registration only, then an exact-instance connection; read-only readiness retries settle prior leases first, close fences and joins original tasks without stopping service; namespace initialization, historical operation reconciliation, unclean recovery, CLI and installed acceptance remain unfinished | +| namespace and service admission contracts | `src/loushang/apphost/managed/admission_record.py` | bounded v2 namespace witness with deployment nonce and five exact admission/registry identities; separate bounded service-control initializing/initialized record binds operation and fence root/lock independently of instances; neither is service readiness | +| Linux namespace storage admission | `src/loushang/apphost/managed/namespace_admission.py` | retained first-use/open-only owner; independent state witness precedes exclusive registry initialization, strict SQLite identity binds deployment nonce and required service-admission mode while pinning DB/lock identity on later transactions; missing witnesses with machine/runtime residue refuse implicit recovery, original registry and directory cleanup settle independently; no Session creation or CLI activation yet | +| Linux service-control admission | `src/loushang/apphost/managed/service_admission.py` | borrows original namespace registry, commits a control intent before exclusive fence creation, publishes bound identities under fence then short registry transaction and returns the original journal; bounded publication contention rejoins the original fence, no native recreation; managed journal rejects opportunistic create and verifies initialized control in every state transaction, with a negative precheck before taking a newly created lock; no process readiness or Session/CLI authority | +| optional joint Session/blob admission | `src/loushang/harness/artifacts/_writer_lease.py` and `src/loushang/harness/transcript/writer_lifecycle.py` | explicit `manage_blobs` preparation retains a normalized data-root blob writer before binding and releases it before the transcript writer only after IO cleanup; sibling Session roots and Products share exclusion, partial admission/unknown close remain owned; default factory activation remains pending | +| optional rooted attachment IO | `src/loushang/harness/artifacts/_blob_io.py`, `session_blobs.py` and `src/loushang/harness/journal/_rooted_io.py` | actual Product image publication, context hydration and Model Input binary codecs consume the retained data-root port; manifests, objects, rollback and bounded deletion stay on original directory descriptors; failed recovery retains full receipts and short locks, contention fails fast so drain can finish; synchronous consumers share the original owner's admission and retained codecs recheck each use; remaining import/fork/delete/tool/default consumers are not yet composed | +| retained first acquisition and restore health | `src/loushang/harness/transcript/writer_lifecycle.py`, `lifecycle.py` and `session_artifacts.py` | explicit `prepare_owned_writer` retains the same preparation before native acquire/borrow/claim; partial acquisition and cancellation are settled by its original driver; actual restore health uses the retained blob port inside original admission, busy is not corrupt; import/fork use frozen initial blob intent in the same driver; default activation remains pending | +| shared lazy store admission | `src/loushang/harness/transcript/store_admission.py`, `writer_lifecycle.py`, `lifecycle.py` and `session_factory.py` | explicit state root is keyed by UID/canonical store, not Product/workspace/machine; first persistent write durably publishes intent before exclusive native creation, legacy registration retains exact root/data-parent before intent, complete witnesses bind both plus witness/lock identities; original preparation settles the short store lock before acquiring identity-constrained transcript/blob writers; known loss, incomplete facts and monotonic observed-root evidence cannot grant recreation; inspect never initializes/registers and checks retained known facts read-only; dedicated managed Coding process selects home/state/session-stores, while startup/empty Mux/browse creates neither Session root nor witness; Embedded adoption remains pending | +| explicit owned factory delivery | `src/loushang/harness/transcript/session_factory.py` | opt-in `owned_product_id` new/open/restore/recent/import/fork routes retain original preparations until synchronous delivery; import/fork freeze blob intent before target acquisition, publish through the original rooted port and retain rollback settlement receipts; ambiguous transcript commits preserve attachments; fork_from retains its internal source preparation until cleanup completes before target construction; live fork requires the original source ports and never closes the source; pending cleanup handles survive failure/cancellation, close fences all pending before disposal and leaves delivered Sessions alone; header/catalog discovery is read-only; transient remains explicitly rejected in this unfinished mode, legacy/default paths unchanged | +| owned Hosted transcript composition | `src/loushang/coding/hosted_catalog.py`, `hosted_bootstrap.py`, `hosted_application.py` and `hosted_continuity.py` | explicit default-false owned_transcripts binds an instance factory; raw returned Sessions stay retained through Candidate wrapping; synchronous fence precedes cleanup waits; the original PRODUCT owner settles catalog debt before continuity release; delivered Candidates remain AppHost-owned; auxiliary pathname index writes and unregistered Hosted instance fork are disabled in this unfinished mode; prepared safe roots required | +| managed canonical Coding catalog | `src/loushang/coding/managed_catalog.py` | explicit owned-only same-root cwd/user_home discovery views reuse the Hosted candidate lifecycle; ordinary history receives a stable non-mutating envelope, valid v1 retains its original identity and malformed v1 never falls back; duplicate authority is checked before workspace filtering, unsupported/cross-workspace opens are refused before binding and rechecked after owned restore; optional store-state observations reject known missing/replaced roots instead of empty results, retain observed identities and original cleanup probes, cap active reads/probes at eight and fence new probes behind cleanup debt; original worker completion governs cancellation/close, no default-directory creation on browse or Embedded writer activation | +| explicit managed Coding application | `src/loushang/coding/managed_bootstrap.py`, `hosted_bootstrap.py`, `hosted_application.py` and `hosted_continuity.py` | separately admitted coding.managed-mux profile requires exact canonical catalog/scopes and original shutdown owner; per-lease wire scope projection preserves canonical Product identity; explicit canonical launch separates application state from transcripts, attachments and blob writer authority; managed plus optional execution remains refused at construction; reuses existing continuity owner and single-Session-member semantics, with no daemon/CLI/default-directory activation | +| explicit managed Coding local listener composition | `src/loushang/coding/managed_local.py` and `hosted_local.py` | exact canonical local launch selects the managed application through two narrow validation/factory hooks while inheriting the original LocalCommand lifetime; derives deployment paths from the neutral invocation and scopes from the canonical application, isolates connection/Session/attachment/writer/control roots, and reuses the existing ManagedChildBootstrap and Linux signal/handoff owners; no automatic roots, managed service coordinator, CLI or extra inherited descriptors | +| owned command output composition | `src/loushang/harness/session/output_artifacts.py` and `agent_product.py` | AgentProduct borrows the existing Session blob port and initialization/execution scopes, including delegate completion and scratch cleanup; retention errors do not erase original rooted recovery debt; no chmod or additional owner; the adapter does not strengthen arbitrary delegates' native settlement contracts | | optional AppHost/Harness Session integration | `src/loushang/apphost/integrations/harness_session.py` | AppHost-owned optional projection of explicitly injected Harness source identities to path-free migration candidates; seals an unchanged single-descriptor snapshot up to 8 MiB, adopts rejected canonical returns into its own retryable cleanup lifecycle, bounds canonical delegation to eight concurrent calls and refuses new calls while debt remains, never retries a relinquished POSIX fd number after an ambiguous close, derives no roots or second index, remains uncomposed, and fails closed on Windows pending a native retained-handle backend | | AppHost live binding owner | `src/loushang/apphost/runtime.py` | process-local single-flight Product/runtime binding keyed by canonical Session identity, exact retained catalog generation, per-profile attachment lifetime, admission fencing, retryable dependency-ordered close, and bounded phased shutdown; remains explicitly constructed and uncomposed | | optional AppHost/AppServer binder | `src/loushang/apphost/hosted.py` | validates one explicitly selected profile as an exact AppServer structural port bundle and preserves the canonical binding identity; owns no listener, protocol, transport, or service semantics | | optional AppHost foreground application owner | `src/loushang/apphost/application.py` | explicitly composes one AppService with one already admitted AppHost Runtime, fences service admission before runtime shutdown, and settles Product construction debt last; remains an uninstalled in-process library and owns no listener, IPC, Hosting, daemon, or Product semantics | | optional managed-deployment values | `src/loushang/apphost/managed/__init__.py`, `src/loushang/apphost/managed/contracts.py` and `src/loushang/apphost/managed/paths.py` | closed pure namespace/service/instance identities, handoff transitions, three-fact stop evidence and injected layout; no facade/CLI activation or native authority | -| optional Linux managed-file owner | `src/loushang/apphost/managed/_files.py` | private canonical directory/regular-file admission, stable nonblocking locks and bounded identity-checked record publication; uncomposed, not a service registry or process owner | -| optional managed name registry | `src/loushang/apphost/managed/_database.py` and `src/loushang/apphost/managed/registry.py` | bounded SQLite name intents and Product/workspace/profile reuse index with exact operation matching; no native liveness authority, process activation or CLI composition | -| optional managed instance journal | `src/loushang/apphost/managed/lifecycle.py` | stable service fence, durable generations and immutable Linux native lookup registration; commit requires matching registration, stop/settlement facts stay separate; stored identity is neither liveness nor permission to terminate | +| optional Linux managed-file owner | `src/loushang/apphost/managed/_files.py` | private directory/regular-file admission, optional deferred opening under an inherited deadline, stable nonblocking locks and bounded identity-checked record publication; uncertain native close remains debt, never a right to close a reused fd; not a service registry or process owner | +| optional new-deployment layout preparation | `src/loushang/apphost/managed/layout.py` | pure fixed nine-directory preparation, opt-in private parent creation through original managed-file owners, shared deadline and bounded descriptor intent; binds child identity before parent sync, retains sync/close debt and attempts independent cleanup after interruption; no Session roots, environment resolution, service recovery, authority publication or CLI activation | +| optional managed name registry | `src/loushang/apphost/managed/_database.py` and `src/loushang/apphost/managed/registry.py` | strict schema 14 retains bounded creation intents, aliases, active global references, purpose-separated close permissions, current-generation transition evidence and storage allocations; instance records optionally retain an exact versioned trace-application fact, without minting liveness authority; monotonic temporary IDs and transaction origins distinguish original lost receipts from later allocations; origin rows cascade with allocations; old schemas are rejected without migration, with no generic release bypass | +| managed storage accounting | `src/loushang/apphost/managed/storage_budget.py` | borrowed original registry transactions reserve complete fixed log/trace slots or instance-bound temporary pairs; namespace byte and allocation-count ceilings include unused reservations; exact temporary refund requires original native deletion completion or original fenced never-created attempt, full allocation identity and physical growth admission; unknown reserve reconciliation requires original in-memory attempt and persisted origin, never restart adoption; no file IO or native write authority | +| managed lifecycle log | `src/loushang/apphost/managed/event_log.py` | borrowed original directory and accounting; five fixed charged segments, canonical closed lifecycle events, all-tail validation, same-inode rotation, no receipt replay or unbound adoption; shared layout accepts exactly six lifecycle and three trace names without operating on trace content; production managed Coding enables bootstrap diagnostics, with no independent runtime/owner; general trace and other temporary consumers remain pending | +| explicit managed trace consumers | `src/loushang/apphost/managed/trace_buffer.py` and `src/loushang/apphost/managed/trace_log.py` | borrowed synchronous sink projects aggregate timing and closed error codes into a 128-record/64 KiB buffer, at most 512 bytes per frame; original child diagnostic slot initializes and drains two charged trace segments under the frozen deadline; explicit server-start CLI observes exact historical application facts, never renews reused instances or guarantees continuing writes; admitted unknown failures retain debt | +| managed read-only live Mux probe | `src/loushang/apphost/managed/mux_probe.py` | full bounded namespace snapshot; one authenticated connection per service and exact read_mux per candidate, no attach or control claim; unknown prevents automatic uniqueness, changed candidate sets invalidate it; original tasks retain journals and connections through cancellation/cleanup, and only values cross the CLI probe/final-attachment loops; CLI reauthenticates exact instance/Mux ID; fault and real-install acceptance remains in progress | +| managed command-output capture | `src/loushang/apphost/managed/_capture_native.py` and `src/loushang/apphost/managed/output_capture.py` | two-stream native storage owner and bounded loop-owned factory/lease implement the Harness capture port; original worker receipts survive public waiter cancellation, sealed reads verify original identity, capacity loss suppresses both artifact references without stopping command preview, and deletion precedes exact refund; managed Coding injects the factory, closes Sessions then captures, and bootstrap retains scratch/registry until settlement; this does not govern all temporary files or activate a separate RuntimeScope | +| managed Mux permit consumer | `src/loushang/apphost/managed/mux_management.py` | private purpose-specific tokens preserve immutable origin/results and current-instance fencing; original same-loop fence supports creation, close and startup recovery; bootstrap passes its original native identity and attempt, matching the journal transition and confirmed history even for absent continuity; recovery accepts only provisional startup and same-admission settlement after stop, never cross-generation permission; trusted closed and exact active deletion commit together, replay cannot delete a new same-name intent; direct managers remain creation-only, close CLI composition remains pending | +| optional managed Mux creation semantics | `src/loushang/appserver/managed_mux.py`, `src/loushang/appservice/managed_mux.py`, `src/loushang/appservice/continuity.py`, `src/loushang/appservice/continuity_runtime.py`, `src/loushang/appservice/runtime.py` and `src/loushang/appservice/client_scope.py` | consumer-injected original admission spans settled create; versioned continuity atomically retains exact Mux and immutable operation receipt, with capacity bounds, no replay resurrection, profile/owner recovery checks and unknown-write fencing; scoped delivery uses the existing accepted-work owner and legacy mutations reject before effects; dedicated production child and short CLI preview are wired explicitly, while ManagedClose CLI activation remains pending | +| optional managed Mux creation wire | `src/loushang/appserver/managed_mux_wire.py`, `src/loushang/appserver/connection.py`, `src/loushang/appserver/remote_client.py`, `src/loushang/appserver/protocol/connection_profile.py`, `src/loushang/appserver/_local_peer.py`, `src/loushang/appserver/local.py`, `src/loushang/appserver/local_record.py` and `src/loushang/appserver/_local_record_values.py` | closed 4 KiB management frames and explicit local capability combinations reuse original authentication, request IDs, capacity and scope owner; response family and creation intent are checked before delivery even when the caller cancels; dedicated managed child enables the capability for its admitted instance, legacy entry defaults and bytes are unchanged, and no authority is issued by transport; short CLI preview uses the same capability | +| managed Mux close wire | same original management codec, connection, record and scoped-client owners | independent close/read family uses explicit mux_closure capability and four managed v2 profiles without changing v1 bytes; original scope owns accepted close through EOF, read uses control capacity, pending responses validate family/type/full target before delivery even after cancellation; actual AppHost closing binding enables production negotiation and the public managed connection exposes the optional client; origin/monotonicity verification and exact name settlement stay in the original manager, CLI close/query/explicit continuation now wired | +| managed creation operation | `src/loushang/apphost/managed/mux_creation.py` | one retained task over original admitted registry/journal: exact intent reservation, original service-start/reuse coordinator, authenticated application identity, one authorized create RPC and result CAS; known creation and reconciled result stay distinct, only bounded native busy retries, and cancellation/close never releases names or stops a service; short CLI preview borrows this operation | +| optional managed instance journal | `src/loushang/apphost/managed/lifecycle.py` | original service fence atomically writes each successor and its bounded immutable previous-state receipt; deferred composite FK and fenced reads reject missing/mismatched pairs, strict canonical previous state includes namespace/service/native and complete cleanup facts; ordinary state updates preserve the receipt, failed generations with no native identity remain representable; evidence alone is neither current liveness, termination permission nor production recovery authority | | optional Linux service observation | `src/loushang/hosting/service.py` | boot/PID/start-time/UID/namespace lookup facts with retained pidfd capture, re-admission and bounded exit observation; no process launch, signal, process-tree settlement or facade activation | | optional inherited-channel handoff | `src/loushang/hosting/service_handoff.py` | child-side durable commit/abort proposals through a borrowed consumer port; EOF and acknowledgement hints never replace durable authority; no launch or application cleanup | | optional Linux service launch | `src/loushang/hosting/service_process.py` | one owned Popen effect, detached stdio/session and exact inherited endpoint; retained identity and existing POSIX group observation; no implicit signals, sealed-preparation fallback or application-success inference | | optional managed handoff binding | `src/loushang/apphost/managed/handoff.py` | exact instance/attempt/native journal adapter and child control channel; bounded stop/cleanup proposals, stale/uncertain reads never grant current authority; no process ownership | -| optional managed child application owner | `src/loushang/apphost/managed/child.py` | staged application lifecycle around durable handoff, one retained control IO worker and independent application cleanup; no Product selection, spawn, CLI activation or process-exit claims | +| optional managed child application owner | `src/loushang/apphost/managed/child.py` | staged application lifecycle around durable handoff, original single-flight control IO and independent application cleanup; optional diagnostics add one execution slot to the same retained pool, five once-only events and gated native publication; log work settles after application/control, before pool shutdown; no Product selection, spawn or process-exit claims | +| optional managed child bootstrap | `src/loushang/apphost/managed/bootstrap.py` | derives layout, retains deferred dependencies before admission, verifies native identity and binds the original child owner once; optional diagnostics validate original journal identity before offloop log IO, retain log-directory debt ahead of observer/journal/registry close; production Coding explicitly enables this option; no Product selection, discovery or process launch | +| optional managed child invocation | `src/loushang/apphost/managed/invocation.py` | pure closed UTF-8 lookup message, unchanged v1 (64 KiB) and explicit scratch v2 (96 KiB); exact namespace/service/instance binding, no credentials, descriptor, factory or native authority | +| managed default selection | `src/loushang/apphost/managed/defaults.py` | optional Linux composition over Foundation platform paths and Hosting machine lookup; cwd-independent roots, no deployment/Session directory creation; no eager core activation | +| Linux machine lookup | `src/loushang/hosting/machine_identity.py` | bounded trusted OS identity read and domain-separated hash; no raw machine ID disclosure, process authority, or Product policy | +| short managed CLI | `src/loushang/coding/cli/lmux.py`, `src/loushang/coding/cli/lmux_command.py`, `src/loushang/coding/cli/lmux_stop_all.py` | preview new/attach/ls, paginated bare selection, explicit start and workspace server-start without Mux creation; public Registry service aliases resolve once to exact IDs for status/logs/confirmed stop; confirmed namespace-snapshot stop-all has one shared deadline and retained per-entry cleanup; recorded-only status with stable log paths and explicitly unknown actual scratch override; bounded read-only lifecycle logs; frozen-ID attach, confirmed exact-target close, read-only close-status and opt-in same-operation continuation including proven startup recovery; TTY-before-admission for interactive commands and original shared owners; Hosted consumes shared deferred conversation input with modal-first routing and local command completion; full capability presentation, remaining command set and final installed validation still pending | +| managed graceful stop | `src/loushang/apphost/managed/stopper.py`, `src/loushang/hosting/service_group.py` | retained exact-instance stop operation and borrowed original pidfd/group observer; only graceful request, success requires application cleanup plus exact leader and group exit, original deadline covers queued work and delivery; no force signal, no all-services CLI activation | +| optional managed Mux close values | `src/loushang/appserver/managed_mux_close.py` | separate exact close/create/Mux identity, redacted request authority and authority-free durable results plus an optional client protocol; bounded pending/closed history in continuity v3, previous v1/v2 bytes unchanged; recovery rejects v3 before Session IO unless both live-close and recovery bindings are explicit; managed bootstrap supplies both and production wire is explicit, close CLI remains pending | +| optional managed Mux close permission | `src/loushang/appservice/managed_mux_close.py` | consumer permission separates ADMIT/OBSERVE/SETTLE; original AppService retains pending snapshots, publishes closed after cleanup and commit, and fences capabilities before shutdown waits; original RecoveryAttempt passes None/v2/v3 through an explicitly bound startup barrier, retains original cleanup/permission owners and confirmed versus unknown receipts, settles pending before active Session recovery and preserves origin history; bootstrap binds the original manager without adding a recovery owner, close CLI remains pending | +| optional managed child lifetime wait | `src/loushang/apphost/managed/_lifetime.py` | bootstrap run joins one retained driver; child owns every application phase, then dependency close runs off-loop; capped retry backoff retains pending/unknown debt without an exit receipt; no signal policy, forced exit or managed CLI activation | +| optional managed child process shell | `src/loushang/apphost/managed/_process.py` | Linux main-thread Runner and signal ownership through bootstrap run_process; HUP ignored, INT/TERM coalesced into one graceful child stop, exact loop retained until application settlement; shell debt remains separate and visible, no force-exit or CLI/default catalog activation | +| explicit Coding managed launch | `src/loushang/coding/managed_bootstrap.py` | accepts only Coding invocation facts, derives workspace/application/connection from the same managed identity, validates explicit G16 Session roots outside managed control/scratch; no default catalog, Product start, CLI or dynamic factory activation | | AppServer Product ports | `src/loushang/appserver/ports.py` | immutable contract-only identity and Product-supplied Session/projection/work/interaction port bundle; no runtime, listener, protocol, or composition owner | | Session discovery composition | `src/loushang/harness/transcript/directory.py`, `src/loushang/harness/machine_resources/control_plane.py`, and `src/loushang/coding/cli/__main__.py` | canonical global plus cwd/home compatibility sources are selected at composition; cwd is a query/filter and compatibility roots are not writable authorities | | Linux/Windows Coding Product canary | `src/loushang/coding/_product_worker_canary.py` | sole explicit C5.4/C5.5c Product root; joins Coding Product/Session evidence to the real lifecycle coordinator, exact native-profile friend, Worker health, Capability publication, normal close, rollback, and recovery; omission remains Current and unlisted profiles remain closed | diff --git a/scripts/ci/check-scopes.json b/scripts/ci/check-scopes.json index 71e0e2fec..a5e565a8b 100644 --- a/scripts/ci/check-scopes.json +++ b/scripts/ci/check-scopes.json @@ -54,7 +54,9 @@ "tests/dev/test_interactive_campaign.py", "docs/internals/architecture/harness/startup-performance-g18-linux-aa-baseline.json", "docs/internals/architecture/harness/startup-performance-g18-linux-native-warm-aa-baseline.json", - "docs/internals/architecture/harness/startup-performance-g18-linux-absent-aa-failure.json" + "docs/internals/architecture/harness/startup-performance-g18-linux-absent-aa-failure.json", + "tests/dev/test_reevaluate_g18_comparison.py", + "scripts/dev/reevaluate_g18_comparison.py" ], "checks": ["appservice"], "reason": "G18 installed startup evidence and collector regressions" diff --git a/tests/architecture/test_apphost_a02_architecture.py b/tests/architecture/test_apphost_a02_architecture.py index 420519fa0..b5f0d4f99 100644 --- a/tests/architecture/test_apphost_a02_architecture.py +++ b/tests/architecture/test_apphost_a02_architecture.py @@ -177,6 +177,14 @@ def test_optional_adapter_is_dark_with_only_reviewed_g8_through_g13_consumers() Path("src/loushang/coding/cli/hosted_client.py"), Path("src/loushang/coding/hosted_bootstrap.py"), Path("src/loushang/coding/hosted_local.py"), + # Accepted optional lmux deployment composition; never a core edge. + Path("src/loushang/coding/cli/lmux.py"), + Path("src/loushang/coding/cli/lmux_command.py"), + Path("src/loushang/coding/cli/lmux_stop_all.py"), + Path("src/loushang/coding/managed_bootstrap.py"), + Path("src/loushang/coding/managed_catalog.py"), + Path("src/loushang/coding/managed_local.py"), + Path("src/loushang/coding/managed_process.py"), } adapter_consumers = { path diff --git a/tests/architecture/test_apphost_a03_a04_architecture.py b/tests/architecture/test_apphost_a03_a04_architecture.py index 32670fae8..315f96e95 100644 --- a/tests/architecture/test_apphost_a03_a04_architecture.py +++ b/tests/architecture/test_apphost_a03_a04_architecture.py @@ -135,11 +135,15 @@ def test_a0_4_hosted_binder_stays_wiring_only_after_g11_consumers() -> None: consumer == Path("src/loushang/coding/appservice_adapter.py") or consumer == Path("src/loushang/coding/hosted_application.py") or consumer == Path("src/loushang/coding/hosted_catalog.py") + or consumer == Path("src/loushang/coding/managed_catalog.py") + or consumer == Path("src/loushang/coding/managed_bootstrap.py") + or consumer == Path("src/loushang/coding/managed_local.py") or consumer == Path("src/loushang/coding/hosted_session.py") or consumer == Path("src/loushang/coding/hosted_execution.py") or consumer == Path("src/loushang/coding/cli/hosted.py") or consumer == Path("src/loushang/coding/cli/hosted_client.py") or consumer == Path("src/loushang/coding/cli/mux.py") + or consumer == Path("src/loushang/coding/cli/lmux_command.py") or consumer == Path("src/loushang/coding/hosted_bootstrap.py") or consumer == Path("src/loushang/coding/hosted_local.py") or consumer == APPLICATION @@ -147,9 +151,33 @@ def test_a0_4_hosted_binder_stays_wiring_only_after_g11_consumers() -> None: or consumer == FOREGROUND or consumer == APPHOST / "local.py" or consumer == APPHOST / "launcher.py" + or consumer == APPHOST / "managed/connection.py" + or consumer == APPHOST / "managed/coordinator.py" + or consumer == APPHOST / "managed/mux_creation.py" + or consumer == APPHOST / "managed/mux_management.py" + or consumer == APPHOST / "managed/registry.py" + or consumer == APPHOST / "managed/mux_probe.py" or consumer.is_relative_to(Path("src/loushang/appservice")) or consumer.is_relative_to(Path("src/loushang/harnesstui/mux")) ), consumer + # Reviewed LMUX receipt decoding and read-only authenticated discovery. + for filename, expected in { + "registry.py": { + "loushang.appserver.managed_mux", + "loushang.appserver.managed_mux.ManagedMuxCreatedV1", + }, + "mux_probe.py": { + "loushang.appserver.local_record", + "loushang.appserver.local_record.require_endpoint", + "loushang.appserver.protocol", + *(f"loushang.appserver.protocol.{name}" for name in ( + "AppErrorCodeV1", "AppServiceError", "MuxReadV1", + "MuxSelectorV1", "MuxSpaceV1", + )), + }, + }.items(): + assert {name for name in _imports(APPHOST / "managed" / filename) + if name.startswith("loushang.appserver")} == expected hosted = _source(HOSTED) for forbidden in ( ".session.", diff --git a/tests/architecture/test_apphost_a0_contract.py b/tests/architecture/test_apphost_a0_contract.py index be956d9f7..ce3c7f776 100644 --- a/tests/architecture/test_apphost_a0_contract.py +++ b/tests/architecture/test_apphost_a0_contract.py @@ -388,6 +388,14 @@ def test_a0_1_has_only_the_reviewed_g8_through_g17_consumers() -> None: Path("src/loushang/coding/cli/hosted_client.py"), Path("src/loushang/coding/hosted_bootstrap.py"), Path("src/loushang/coding/hosted_local.py"), + # Accepted optional lmux deployment composition; never a core edge. + Path("src/loushang/coding/cli/lmux.py"), + Path("src/loushang/coding/cli/lmux_command.py"), + Path("src/loushang/coding/cli/lmux_stop_all.py"), + Path("src/loushang/coding/managed_bootstrap.py"), + Path("src/loushang/coding/managed_catalog.py"), + Path("src/loushang/coding/managed_local.py"), + Path("src/loushang/coding/managed_process.py"), } reverse_adapter_consumers = { path diff --git a/tests/architecture/test_capability_runtime_convergence_pr0.py b/tests/architecture/test_capability_runtime_convergence_pr0.py index 2a83aa5b9..a07ba0148 100644 --- a/tests/architecture/test_capability_runtime_convergence_pr0.py +++ b/tests/architecture/test_capability_runtime_convergence_pr0.py @@ -5,9 +5,22 @@ import subprocess import sys from collections import defaultdict +from collections.abc import Iterator from functools import cache from pathlib import Path +import pytest + + +@pytest.fixture(scope="module", autouse=True) +def _release_source_cache() -> Iterator[None]: + """Keep repeated scans cheap without retaining the full AST across modules.""" + try: + yield + finally: + _python_trees.cache_clear() + + BASELINE_PATH = Path( "docs/internals/architecture/harness/capability-runtime-convergence-pr0-baseline.md" ) diff --git a/tests/architecture/test_coding_wave_a_budget.py b/tests/architecture/test_coding_wave_a_budget.py index 1ae180486..a6516ca79 100644 --- a/tests/architecture/test_coding_wave_a_budget.py +++ b/tests/architecture/test_coding_wave_a_budget.py @@ -20,6 +20,10 @@ G17_PRODUCT_SLICE = frozenset({"cli/hosted_client.py"}) G18_FACADE_SLICE = frozenset({"__init__.py"}) EXECUTION_PRODUCT_SLICE = frozenset({"hosted_execution.py", "_hosted_execution_work.py"}) +LMUX_PRODUCT_SLICE = frozenset({ + "managed_process.py", "managed_local.py", "managed_bootstrap.py", "managed_catalog.py", + "cli/lmux.py", "cli/lmux_command.py", "cli/lmux_stop_all.py", +}) APPROVED_SLICES = { "g10": G10_PRODUCT_SLICE, @@ -31,6 +35,7 @@ "g17": G17_PRODUCT_SLICE, "g18": G18_FACADE_SLICE, "execution": EXECUTION_PRODUCT_SLICE, + "lmux": LMUX_PRODUCT_SLICE, } @@ -80,21 +85,51 @@ def test_coding_package_stays_within_wave_a_budget() -> None: # Linux screen adapter +17, canonical early route +98. Worker/terminal # ownership remains in HarnessTUI/TUI; no new Coding path is exempt. interactive_startup_allowance = 15 - 29 + 17 + 98 + # Reviewed LMUX-M0 owned-factory binding in original owners (+42/+77), + # offset by moving the original theme to shared Harnesstui (-24). + # Keep these existing files in core, including its original 14-line margin. + lmux_owned_core_allowance = 42 + 77 - 24 + # Reviewed capability composition versus the validated da820585 wheel: + # ui/mode.py +17 current-binding provider; ui/screen_input.py +50 Product + # declaration projection. Both remain core; preserve its six-line margin. + capability_projection_allowance = 17 + 50 + # LMUX reviewed default-owned wiring: bootstrap +7, runtime +5, + # manager +1 versus da820585. Keep all three in core and its six-line margin. + lmux_default_owned_allowance = 7 + 5 + 1 assert ( sum(groups["core"].values()) - <= 33_686 + g18_core_allowance + interactive_startup_allowance + <= 33_686 + g18_core_allowance + interactive_startup_allowance + lmux_owned_core_allowance + + capability_projection_allowance + lmux_default_owned_allowance ), groups["core"] assert sum(groups["g10"].values()) <= 1_800, groups["g10"] # Preserve main's optional execution projection allowance. assert sum(groups["g11"].values()) <= 420, groups["g11"] - assert sum(groups["g12"].values()) <= 800, groups["g12"] + # LMUX-M0: original Product/catalog cleanup and managed activation (+84). + assert sum(groups["g12"].values()) <= 800 + 84, groups["g12"] assert sum(groups["g13"].values()) <= 350, groups["g13"] - assert sum(groups["g14"].values()) <= 1_300, groups["g14"] + # LMUX-M0: original catalog ownership, readonly hooks and validation (+205). + assert sum(groups["g14"].values()) <= 1_300 + 205, groups["g14"] assert sum(groups["g16"].values()) <= 900, groups["g16"] assert sum(groups["g17"].values()) <= 450, groups["g17"] assert sum(groups["g18"].values()) <= 200, groups["g18"] assert sum(groups["execution"].values()) <= 400, groups["execution"] assert max(groups["execution"].values()) <= 250, groups["execution"] + # Exact reviewed optional Product composition baseline; not an exemption + # for any future managed/CLI module, nor a general core-budget increase. + # Reviewed single-candidate selection adds 13 lines to the same CLI owner. + # Reviewed capture/trace composition: process +23, local +20, bootstrap +3, + # parser +4; command probe/trace composition +108 minus obsolete selector 29. + # Authentication and candidate truth remain in AppHost, not this CLI slice. + managed_composition_allowance = 23 + 20 + 3 + 4 + 108 - 29 + # Reviewed creation-receipt recovery: parser +14, command +75. + # The removed selector -29 is already counted above, not deducted twice. + creation_recovery_composition_allowance = 14 + 75 + # Reviewed child-only backend import and ownership explanation: net +3. + child_import_boundary_allowance = 3 + assert sum(groups["lmux"].values()) <= ( + 1_579 + 13 + managed_composition_allowance + creation_recovery_composition_allowance + + child_import_boundary_allowance + ), groups["lmux"] def test_unapproved_files_stay_in_core_and_every_file_is_counted_once() -> None: @@ -105,6 +140,8 @@ def test_unapproved_files_stay_in_core_and_every_file_is_counted_once() -> None: "cli/__init__.py": 103, "nested/__init__.py": 107, "nested/new_feature.py": 109, + "managed_unreviewed.py": 113, + "cli/lmux_unreviewed.py": 127, } line_counts.update(unapproved) diff --git a/tests/architecture/test_composition_lifecycle_authority_cla0.py b/tests/architecture/test_composition_lifecycle_authority_cla0.py index 6d71ff26b..38de6ac12 100644 --- a/tests/architecture/test_composition_lifecycle_authority_cla0.py +++ b/tests/architecture/test_composition_lifecycle_authority_cla0.py @@ -5,9 +5,23 @@ import subprocess import sys from collections import Counter +from collections.abc import Iterator from functools import cache from pathlib import Path +import pytest + + +@pytest.fixture(scope="module", autouse=True) +def _release_source_caches() -> Iterator[None]: + """Release derived indexes before the module's full source AST cache.""" + try: + yield + finally: + _tracked_call_sites.cache_clear() + _source_trees.cache_clear() + + BASELINE_PATH = Path( "docs/internals/architecture/harness/" "composition-lifecycle-authority-cla0-baseline.md" diff --git a/tests/architecture/test_durable_hosted_application_continuity_g13.py b/tests/architecture/test_durable_hosted_application_continuity_g13.py index 5ca617046..84334dc8b 100644 --- a/tests/architecture/test_durable_hosted_application_continuity_g13.py +++ b/tests/architecture/test_durable_hosted_application_continuity_g13.py @@ -191,7 +191,10 @@ def test_G13_NEW_OWNERS_remain_independently_reviewable() -> None: "coding-continuity": (CODING_CONTINUITY,), } limits = { - "appservice-continuity": 1_250, + # lmux vs a3dbec60: versioned create/close receipt codec +113, + # original recovery owner settlement/permission +142. Keep the original + # margin and scan every continuity*.py; no renamed-file exclusion. + "appservice-continuity": 1_250 + 113 + 142, # G17's reviewed borrowed discovery getter and recovery passthrough # add seven lines to this exact file, not another lifecycle owner. "apphost-continuity": 675, diff --git a/tests/architecture/test_foreground_hosted_application_g12.py b/tests/architecture/test_foreground_hosted_application_g12.py index 64b777aea..6c10d0db0 100644 --- a/tests/architecture/test_foreground_hosted_application_g12.py +++ b/tests/architecture/test_foreground_hosted_application_g12.py @@ -192,8 +192,12 @@ def test_g12_new_owners_remain_independently_reviewable() -> None: limits = { # G16 adds public client-mode selection/fencing to this same application # boundary. Its native deployment owner has a separate 300-line gate. - APPHOST_APPLICATION: 550, - CODING_APPLICATION: 800, + # lmux vs a3dbec60 adds 16 lines for explicit managed binding, borrowed + # getters and rejection without continuity; retain the original margin. + APPHOST_APPLICATION: 550 + 16, + # Same reviewed +84 owned-transcript composition allowance as the G12 + # slice in test_coding_wave_a_budget; not an additional allowance there. + CODING_APPLICATION: 800 + 84, } for path, limit in limits.items(): lines = len(_read(path).splitlines()) diff --git a/tests/architecture/test_hosted_application_g11.py b/tests/architecture/test_hosted_application_g11.py index 1bc068ede..8f1774c11 100644 --- a/tests/architecture/test_hosted_application_g11.py +++ b/tests/architecture/test_hosted_application_g11.py @@ -68,6 +68,7 @@ def test_G11_DEPENDENCY_GRAPH_appserver_remains_contract_and_client_only() -> No assert not _imports_prefix(imports, forbidden) for path in ( APPSERVER / "ports.py", + APPSERVER / "managed_mux_close.py", *sorted((APPSERVER / "protocol").glob("*.py")), ): for imported in _imports(path): @@ -132,7 +133,16 @@ def test_G11_PRODUCT_ADAPTER_is_the_only_product_harness_bridge() -> None: Path("src/loushang/coding/hosted_catalog.py"), Path("src/loushang/coding/hosted_execution.py"), Path("src/loushang/coding/_hosted_execution_work.py"), + Path("src/loushang/apphost/managed/bootstrap.py"), + Path("src/loushang/apphost/managed/mux_creation.py"), + Path("src/loushang/apphost/managed/mux_management.py"), + Path("src/loushang/apphost/managed/mux_probe.py"), + Path("src/loushang/coding/managed_local.py"), + Path("src/loushang/coding/managed_bootstrap.py"), + Path("src/loushang/coding/managed_catalog.py"), } + assert {name for name in _imports(Path("src/loushang/apphost/managed/mux_probe.py")) + if name.startswith("loushang.appservice")} == {"loushang.appservice.continuity"} assert {name for name in _imports(Path("src/loushang/coding/hosted_catalog.py")) if name.startswith("loushang.appservice")} == { "loushang.appservice.discovery_ports", @@ -235,6 +245,8 @@ def test_g11_package_budgets_keep_new_owners_reviewable() -> None: for name in ("__init__.py", "client.py", "ports.py", "runtime.py") ), "appservice-continuity": tuple(APPSERVICE.glob("continuity*.py")), + "appservice-managed-contract": (APPSERVICE / "managed_mux.py", APPSERVICE / "managed_mux_close.py"), + "appserver-close-values": (APPSERVER / "managed_mux_close.py",), "coding-adapter": (CODING_ADAPTER,), "harnesstui-mux": tuple( HARNESSTUI_MUX / name for name in @@ -246,20 +258,32 @@ def test_g11_package_budgets_keep_new_owners_reviewable() -> None: # 271 lines to this exact group; all protocol modules still count. # See hosted-session-workflow-g17.md, Reviewability Budget Supplement. "appserver": 2_100, - "appservice-core": 1_500, - "appservice-continuity": 1_250, + # LMUX M0 §60 reviewed ManagedCreate adds 170 net lines for the same + # state lock/atomic continuity authority; all four core files still count. + # LMUX M0 §72 keeps close coordination on the same state/continuity + # owner; reviewed core is 1876 lines, including original core files. + "appservice-core": 1_900, + # LMUX M0 §71 reviewed v3 close history adds 59 net lines to the + # same continuity schema/recovery boundary; no owner split or activation. + # LMUX M0 §73 reviewed recovery stays on the original Attempt/lease; + # complete continuity group 1378 lines, two managed contracts 134. + "appservice-continuity": 1_400, + "appservice-managed-contract": 150, + "appserver-close-values": 80, # Optional execution adds omission metadata and a synchronous projection # seam; its two owners have a separate budget in the Wave A contract. "coding-adapter": 420, - "harnesstui-mux": 600, + # Reviewed neutral capability projection +41 versus da820585; + # retain the original 21-line margin and exact six-file group. + "harnesstui-mux": 600 + 41, } for name, paths in groups.items(): lines = sum(len(_read(path).splitlines()) for path in paths) assert lines <= limits[name], (name, lines, limits[name]) - # The G17 terminal owner has its own exact, separately tested 950-line - # budget; do not expand the G11 semantic controller budget or hide new files. + # The terminal owner has its own separately tested 1042-line budget; + # do not move shell code into this semantic group or hide new files. assert {path.name for path in HARNESSTUI_MUX.glob("*.py")} == { *(path.name for path in groups["harnesstui-mux"]), "shell.py", "terminal.py", "_shell_tasks.py", "_shell_screen.py", - "session_picker.py", + "session_picker.py", "conversation_binding.py", } diff --git a/tests/architecture/test_hosted_product_g10_explicit_canary.py b/tests/architecture/test_hosted_product_g10_explicit_canary.py index a8c7fcb65..0e62d9106 100644 --- a/tests/architecture/test_hosted_product_g10_explicit_canary.py +++ b/tests/architecture/test_hosted_product_g10_explicit_canary.py @@ -51,8 +51,9 @@ def test_inventory_v3_records_one_explicit_canary_and_current_omission( ) -> None: del _case inventory = json.loads(_read(INVENTORY)) - # G14 v4, G16 v5 and G17 v6 add commands; the G10 v3 canary stays exact. - assert inventory["inventoryVersion"] == 6 + # G14 v4, G16 v5, G17 v6 and LMUX v7 add explicit commands; + # the G10 v3 canary and default-entry omissions stay exact. + assert inventory["inventoryVersion"] == 7 assert inventory["decision"] == "RETAIN" rows = {row["entrypointId"]: row for row in inventory["entries"]} assert rows["coding.apphost.canary"] == { @@ -73,6 +74,17 @@ def test_inventory_v3_records_one_explicit_canary_and_current_omission( scripts = tomllib.loads(_read(Path("pyproject.toml")))["project"]["scripts"] assert scripts["loushang"] == "loushang.coding.cli.__main__:main" assert scripts["loushang-tui"] == "loushang.coding.ui.cli:main" + assert rows["coding.lmux.command"] == { + "disposition": "explicit-managed-linux-preview", + "entrypointId": "coding.lmux.command", + "importsComposition": False, + "omissionOwner": None, + "packagingBinding": "project.scripts.lmux", + "source": "src/loushang/coding/cli/lmux.py", + "supportStatus": "installed-preview", + "surface": "mux", + } + assert scripts["lmux"] == "loushang.coding.cli.lmux:main" @pytest.mark.parametrize( @@ -134,6 +146,18 @@ def test_dependency_graph_has_only_the_accepted_product_owned_edges( Path("src/loushang/apphost/launcher.py"): {"loushang.hosting.contracts"}, Path("src/loushang/apphost/managed/handoff.py"): {"loushang.hosting.errors", "loushang.hosting.service", "loushang.hosting.service_handoff"}, Path("src/loushang/apphost/managed/lifecycle.py"): {"loushang.hosting.errors", "loushang.hosting.service"}, + Path("src/loushang/apphost/managed/bootstrap.py"): {"loushang.hosting.service"}, + Path("src/loushang/apphost/managed/starter.py"): { + "loushang.hosting.contracts", "loushang.hosting.service_process", + }, + Path("src/loushang/apphost/managed/stopper.py"): { + "loushang.hosting.service", "loushang.hosting.service_group", + }, + Path("src/loushang/apphost/managed/defaults.py"): { + "loushang.hosting.errors", "loushang.hosting.machine_identity", + }, + Path("src/loushang/apphost/managed/connection.py"): {"loushang.hosting.service"}, + Path("src/loushang/apphost/managed/mux_management.py"): {"loushang.hosting.service"}, }.get(path) if accepted_hosting is not None: assert {name for name in imports if name.startswith("loushang.hosting")} == accepted_hosting diff --git a/tests/architecture/test_hosted_product_runtime_g9_closure.py b/tests/architecture/test_hosted_product_runtime_g9_closure.py index 3fc213c4f..f4200b748 100644 --- a/tests/architecture/test_hosted_product_runtime_g9_closure.py +++ b/tests/architecture/test_hosted_product_runtime_g9_closure.py @@ -311,7 +311,7 @@ def test_g9_3_inventory_disposes_every_supported_surface_and_retains_current() - inventory = json.loads(_read(G9_ENTRYPOINTS)) assert set(inventory) == {"inventoryVersion", "decision", "entries"} - assert inventory["inventoryVersion"] == 6 + assert inventory["inventoryVersion"] == 7 assert inventory["decision"] == "RETAIN" rows = {row["entrypointId"]: row for row in inventory["entries"]} assert set(rows) == { @@ -325,6 +325,7 @@ def test_g9_3_inventory_disposes_every_supported_surface_and_retains_current() - "coding.hosted.command", "coding.hosted-tui.command", "coding.mux.command", + "coding.lmux.command", "coding.sdk", "coding.tui", "harnesstui.named-mux", @@ -410,6 +411,7 @@ def test_g9_3_inventory_disposes_every_supported_surface_and_retains_current() - "coding.hosted.command": ("hosted", "installed"), "coding.hosted-tui.command": ("hosted", "installed"), "coding.mux.command": ("mux", "installed"), + "coding.lmux.command": ("mux", "installed-preview"), "coding.sdk": ("sdk", "supported-library"), "coding.tui": ("tui", "installed"), "harnesstui.named-mux": ("mux", "client-library"), @@ -423,6 +425,7 @@ def test_g9_3_inventory_disposes_every_supported_surface_and_retains_current() - "loushang-hosted": "loushang.coding.cli.hosted:main", "loushang-hosted-tui": "loushang.coding.cli.hosted_client:main", "loushang-mux": "loushang.coding.cli.mux:main", + "lmux": "loushang.coding.cli.lmux:main", "loushang-plugin": "loushang.plugin.__main__:main", "loushang-tui": "loushang.coding.ui.cli:main", } @@ -436,6 +439,7 @@ def test_g9_3_inventory_disposes_every_supported_surface_and_retains_current() - "project.scripts.loushang-hosted": "coding.hosted.command", "project.scripts.loushang-hosted-tui": "coding.hosted-tui.command", "project.scripts.loushang-mux": "coding.mux.command", + "project.scripts.lmux": "coding.lmux.command", "project.scripts.loushang-plugin": "plugin.cli", "project.scripts.loushang-tui": "coding.tui", } @@ -465,6 +469,9 @@ def test_g9_3_inventory_disposes_every_supported_surface_and_retains_current() - "_windows_local_record.py", "ports.py", "remote_client.py", + "managed_mux.py", + "managed_mux_wire.py", + "managed_mux_close.py", "stdio.py", } assert (Path("src/loushang/harnesstui/mux/profile.py")).is_file() @@ -550,7 +557,15 @@ def test_g9_4_retains_apphost_core_and_current_inventory_fences() -> None: APPHOST / "launcher.py": {"loushang.hosting.contracts"}, APPHOST / "managed/handoff.py": {"loushang.hosting.errors", "loushang.hosting.service", "loushang.hosting.service_handoff"}, APPHOST / "managed/lifecycle.py": {"loushang.hosting.errors", "loushang.hosting.service"}, - }.get(path, set()) + APPHOST / "managed/bootstrap.py": {"loushang.hosting.service"}, + # Managed startup/continuation compares the original native value; + # process creation and cleanup remain with their existing owners. + APPHOST / "managed/mux_management.py": {"loushang.hosting.service"}, + APPHOST / "managed/starter.py": {"loushang.hosting.contracts", "loushang.hosting.service_process"}, + APPHOST / "managed/stopper.py": {"loushang.hosting.service", "loushang.hosting.service_group"}, + APPHOST / "managed/connection.py": {"loushang.hosting.service"}, + APPHOST / "managed/defaults.py": {"loushang.hosting.errors", "loushang.hosting.machine_identity"}, + }.get(path, set()), str(path) for path in APPHOST_CORE: source = _read(path) imports = _imports(path) diff --git a/tests/architecture/test_hosted_product_runtime_v1_baseline.py b/tests/architecture/test_hosted_product_runtime_v1_baseline.py index 52f9552d7..83349f2da 100644 --- a/tests/architecture/test_hosted_product_runtime_v1_baseline.py +++ b/tests/architecture/test_hosted_product_runtime_v1_baseline.py @@ -42,6 +42,7 @@ HOSTING_SOURCE / "_posix_process.py", HOSTING_SOURCE / "service.py", HOSTING_SOURCE / "service_handoff.py", + HOSTING_SOURCE / "machine_identity.py", HOSTING_SOURCE / "service_process.py", HOSTING_SOURCE / "_windows_process.py", HOSTING_SOURCE / "_win32_process.py", @@ -81,10 +82,50 @@ APPHOST_SOURCE / "managed/_files.py", APPHOST_SOURCE / "managed/_database.py", APPHOST_SOURCE / "managed/registry.py", + APPHOST_SOURCE / "managed/storage_budget.py", + APPHOST_SOURCE / "managed/event_log.py", + APPHOST_SOURCE / "managed/_capture_native.py", + APPHOST_SOURCE / "managed/output_capture.py", + APPHOST_SOURCE / "managed/trace_buffer.py", + APPHOST_SOURCE / "managed/trace_log.py", APPHOST_SOURCE / "managed/lifecycle.py", APPHOST_SOURCE / "managed/handoff.py", APPHOST_SOURCE / "managed/child.py", + APPHOST_SOURCE / "managed/bootstrap.py", + APPHOST_SOURCE / "managed/_lifetime.py", + APPHOST_SOURCE / "managed/_process.py", + APPHOST_SOURCE / "managed/invocation.py", + APPHOST_SOURCE / "managed/defaults.py", + APPHOST_SOURCE / "managed/layout.py", + APPHOST_SOURCE / "managed/starter.py", + APPHOST_SOURCE / "managed/stopper.py", + HOSTING_SOURCE / "service_group.py", + APPHOST_SOURCE / "managed/connection.py", + APPHOST_SOURCE / "managed/discovery.py", + APPHOST_SOURCE / "managed/coordinator.py", + APPHOST_SOURCE / "managed/admission_record.py", + APPHOST_SOURCE / "managed/namespace_admission.py", + APPHOST_SOURCE / "managed/service_admission.py", + APPHOST_SOURCE / "managed/mux_management.py", APPSERVER_SOURCE / "ports.py", + APPSERVER_SOURCE / "managed_mux.py", + APPSERVER_SOURCE / "managed_mux_close.py", + APPHOST_SOURCE / "managed/mux_creation.py", + APPHOST_SOURCE / "managed/mux_probe.py", + APPSERVER_SOURCE / "managed_mux_wire.py", + APPSERVER_SOURCE / "connection.py", + APPSERVER_SOURCE / "remote_client.py", + APPSERVER_SOURCE / "protocol/connection_profile.py", + APPSERVER_SOURCE / "_local_peer.py", + APPSERVER_SOURCE / "local.py", + APPSERVER_SOURCE / "local_record.py", + APPSERVER_SOURCE / "_local_record_values.py", + APPSERVICE_SOURCE / "managed_mux.py", + APPSERVICE_SOURCE / "managed_mux_close.py", + APPSERVICE_SOURCE / "continuity.py", + APPSERVICE_SOURCE / "continuity_runtime.py", + APPSERVICE_SOURCE / "runtime.py", + APPSERVICE_SOURCE / "client_scope.py", HARNESS_SOURCE / "machine_resources/control_plane.py", Path("src/loushang/coding/cli/__main__.py"), Path("src/loushang/coding/_product_worker_canary.py"), @@ -92,6 +133,27 @@ Path("src/loushang/coding/apphost_composition.py"), Path("src/loushang/coding/apphost_product.py"), Path("src/loushang/coding/hosted_application.py"), + Path("src/loushang/coding/managed_bootstrap.py"), + Path("src/loushang/coding/hosted_catalog.py"), + Path("src/loushang/coding/managed_catalog.py"), + Path("src/loushang/coding/managed_local.py"), + Path("src/loushang/coding/managed_process.py"), + Path("src/loushang/coding/cli/lmux.py"), + Path("src/loushang/coding/cli/lmux_command.py"), + Path("src/loushang/coding/cli/lmux_stop_all.py"), + Path("src/loushang/coding/runtime/agent_session_runtime.py"), + HARNESS_SOURCE / "artifacts/_blob_io.py", + HARNESS_SOURCE / "artifacts/_writer_lease.py", + HARNESS_SOURCE / "conversation/stores/file.py", + HARNESS_SOURCE / "journal/_directory_lease.py", + HARNESS_SOURCE / "journal/_rooted_io.py", + HARNESS_SOURCE / "session/output_artifacts.py", + HARNESS_SOURCE / "session/transcript_lifecycle.py", + HARNESS_SOURCE / "transcript/product_session.py", + HARNESS_SOURCE / "transcript/session_factory.py", + HARNESS_SOURCE / "transcript/writer_lease.py", + HARNESS_SOURCE / "transcript/writer_lifecycle.py", + HARNESS_SOURCE / "transcript/store_admission.py", ) @@ -453,9 +515,32 @@ def test_current_inventory_matches_source_and_retained_absences() -> None: "managed/_files.py", "managed/_database.py", "managed/registry.py", + "managed/storage_budget.py", + "managed/event_log.py", + "managed/_capture_native.py", + "managed/output_capture.py", + "managed/trace_buffer.py", + "managed/trace_log.py", "managed/lifecycle.py", "managed/handoff.py", "managed/child.py", + "managed/bootstrap.py", + "managed/_lifetime.py", + "managed/_process.py", + "managed/invocation.py", + "managed/defaults.py", + "managed/layout.py", + "managed/starter.py", + "managed/stopper.py", + "managed/connection.py", + "managed/discovery.py", + "managed/coordinator.py", + "managed/admission_record.py", + "managed/namespace_admission.py", + "managed/service_admission.py", + "managed/mux_management.py", + "managed/mux_creation.py", + "managed/mux_probe.py", } assert { path.relative_to(APPSERVER_SOURCE).as_posix() @@ -491,6 +576,9 @@ def test_current_inventory_matches_source_and_retained_absences() -> None: "execution/dispatch.py", "execution/remote.py", "execution/recovery.py", + "managed_mux.py", + "managed_mux_wire.py", + "managed_mux_close.py", } assert { path.relative_to(APPSERVICE_SOURCE).as_posix() @@ -512,7 +600,9 @@ def test_current_inventory_matches_source_and_retained_absences() -> None: "execution_notifications.py", "execution_registry.py", "execution_service.py", + "managed_mux.py", "session_discovery.py", + "managed_mux_close.py", "ports.py", "runtime.py", } @@ -567,6 +657,13 @@ def test_current_inventory_matches_source_and_retained_absences() -> None: Path("src/loushang/coding/cli/hosted_client.py"), Path("src/loushang/coding/hosted_bootstrap.py"), Path("src/loushang/coding/hosted_local.py"), + Path("src/loushang/coding/managed_bootstrap.py"), + Path("src/loushang/coding/managed_catalog.py"), + Path("src/loushang/coding/managed_local.py"), + Path("src/loushang/coding/managed_process.py"), + Path("src/loushang/coding/cli/lmux.py"), + Path("src/loushang/coding/cli/lmux_command.py"), + Path("src/loushang/coding/cli/lmux_stop_all.py"), } retained_fences = " ".join(_section(inventory, "Retained Fences").split()) for statement in ( diff --git a/tests/architecture/test_hosted_session_workflow_g17_design.py b/tests/architecture/test_hosted_session_workflow_g17_design.py index 9d8488283..7b612b56e 100644 --- a/tests/architecture/test_hosted_session_workflow_g17_design.py +++ b/tests/architecture/test_hosted_session_workflow_g17_design.py @@ -117,6 +117,8 @@ def test_G17_COMMAND_is_one_optional_product_composition_with_fixed_dependencies "loushang.appserver.protocol.connection_profile", "loushang.hosting.contracts", "loushang.hosting.runtime", "loushang.harnesstui.mux.shell", "loushang.harnesstui.mux.terminal", + # Hosted and Embedded clients share the same Markdown presentation. + "loushang.harnesstui.conversation.theme", "loushang.coding.hosted_bootstrap", } for entry in ( diff --git a/tests/architecture/test_hosting_architecture_baseline.py b/tests/architecture/test_hosting_architecture_baseline.py index 7c69ea82b..1405e49b8 100644 --- a/tests/architecture/test_hosting_architecture_baseline.py +++ b/tests/architecture/test_hosting_architecture_baseline.py @@ -717,9 +717,32 @@ def test_hosting_apphost_and_appserver_optional_modules_are_exact() -> None: "managed/_files.py", "managed/_database.py", "managed/registry.py", + "managed/storage_budget.py", + "managed/event_log.py", + "managed/_capture_native.py", + "managed/output_capture.py", + "managed/trace_buffer.py", + "managed/trace_log.py", "managed/lifecycle.py", "managed/handoff.py", "managed/child.py", + "managed/bootstrap.py", + "managed/_lifetime.py", + "managed/_process.py", + "managed/invocation.py", + "managed/defaults.py", + "managed/layout.py", + "managed/starter.py", + "managed/stopper.py", + "managed/connection.py", + "managed/discovery.py", + "managed/coordinator.py", + "managed/admission_record.py", + "managed/namespace_admission.py", + "managed/service_admission.py", + "managed/mux_management.py", + "managed/mux_creation.py", + "managed/mux_probe.py", "router.py", "runtime.py", "hosted.py", @@ -743,6 +766,9 @@ def test_hosting_apphost_and_appserver_optional_modules_are_exact() -> None: "_windows_local_record.py", "ports.py", "remote_client.py", + "managed_mux.py", + "managed_mux_wire.py", + "managed_mux_close.py", "stdio.py", "protocol/__init__.py", "protocol/codec.py", diff --git a/tests/architecture/test_hosting_h0_contract.py b/tests/architecture/test_hosting_h0_contract.py index ebe9f07e5..9e93f0224 100644 --- a/tests/architecture/test_hosting_h0_contract.py +++ b/tests/architecture/test_hosting_h0_contract.py @@ -37,6 +37,10 @@ HOSTING_ROOT / "_windows_launch_preparation.py", } OPTIONAL_MANAGED_SERVICE_MODULES = { + # Linux lookup/observation only; still subject to the standard-library and + # product-neutral import checks below, not added to the public surface. + HOSTING_ROOT / "machine_identity.py", + HOSTING_ROOT / "service_group.py", HOSTING_ROOT / "service.py", HOSTING_ROOT / "service_handoff.py", HOSTING_ROOT / "service_process.py", diff --git a/tests/architecture/test_plugin_lifecycle_plc9c5_c50_baseline.py b/tests/architecture/test_plugin_lifecycle_plc9c5_c50_baseline.py index aafe48e48..1d75f08ca 100644 --- a/tests/architecture/test_plugin_lifecycle_plc9c5_c50_baseline.py +++ b/tests/architecture/test_plugin_lifecycle_plc9c5_c50_baseline.py @@ -898,7 +898,54 @@ def test_c50_keeps_private_profiles_confined_and_product_layers_clean() -> None: APPHOST_ROOT / "launcher.py", APPHOST_ROOT / "managed/handoff.py", APPHOST_ROOT / "managed/lifecycle.py", + APPHOST_ROOT / "managed/bootstrap.py", + APPHOST_ROOT / "managed/starter.py", + APPHOST_ROOT / "managed/stopper.py", + APPHOST_ROOT / "managed/defaults.py", + APPHOST_ROOT / "managed/connection.py", + APPHOST_ROOT / "managed/mux_management.py", + CODING_ROOT / "managed_process.py", } + # LMUX composition consumes public Hosting contracts only. Keep the reviewed + # module and symbol edges exact rather than exempting the managed package. + managed_edges = { + APPHOST_ROOT / "managed/starter.py": { + "contracts": {"ProcessLaunchRequest"}, + "service_process": {"LinuxServiceProcessV1"}, + }, + APPHOST_ROOT / "managed/stopper.py": { + "service": {"LinuxServiceObserverV1"}, + "service_group": {"LinuxServiceGroupObservationV1"}, + }, + APPHOST_ROOT / "managed/defaults.py": { + "errors": {"HostingError", "HostingFailureCategory"}, + "machine_identity": {"linux_machine_key"}, + }, + APPHOST_ROOT / "managed/connection.py": { + "service": {"LinuxServiceObserverV1"}, + }, + APPHOST_ROOT / "managed/mux_management.py": { + "service": {"LinuxServiceIdentityV1"}, + }, + CODING_ROOT / "managed_process.py": { + "contracts": { + "ProcessLaunchRequest", "ProcessStreamSpec", "ProcessStdinMode", + "ProcessStdoutMode", "ProcessStderrMode", + }, + }, + } + for path, modules in managed_edges.items(): + expected = { + name + for module, symbols in modules.items() + for name in ( + f"loushang.hosting.{module}", + *(f"loushang.hosting.{module}.{symbol}" for symbol in symbols), + ) + } + assert { + name for name in _imports(path) if name.startswith("loushang.hosting") + } == expected, path assert { imported for imported in _imports(APPHOST_ROOT / "managed/handoff.py") if imported.startswith("loushang.hosting") @@ -917,6 +964,12 @@ def test_c50_keeps_private_profiles_confined_and_product_layers_clean() -> None: "loushang.hosting.errors", "loushang.hosting.errors.HostingError", "loushang.hosting.service", "loushang.hosting.service.LinuxServiceIdentityV1", } + assert { + imported for imported in _imports(APPHOST_ROOT / "managed/bootstrap.py") + if imported.startswith("loushang.hosting") + } == { + "loushang.hosting.service", "loushang.hosting.service.LinuxServiceObserverV1", + } for consumer in hosting_consumers: assert not any( imported.startswith("loushang.hosting._") diff --git a/tests/architecture/test_unified_plugin_architecture.py b/tests/architecture/test_unified_plugin_architecture.py index 69034fc65..78f50df0f 100644 --- a/tests/architecture/test_unified_plugin_architecture.py +++ b/tests/architecture/test_unified_plugin_architecture.py @@ -3,18 +3,30 @@ import ast import inspect import json -from collections.abc import Mapping +from collections.abc import Iterator, Mapping from dataclasses import fields from functools import cache from hashlib import sha256 from pathlib import Path from typing import get_args +import pytest + import loushang.harness.capabilities as public_capabilities import loushang.harness.resources.plugins as public_plugins import loushang.harness.runtime as public_runtime from loushang.harness.runtime import RuntimeCapabilityScope + +@pytest.fixture(scope="module", autouse=True) +def _release_source_cache() -> Iterator[None]: + """Do not retain the repository text snapshot after this module finishes.""" + try: + yield + finally: + _source_texts.cache_clear() + + ARCHITECTURE_PATH = Path("docs/internals/architecture/harness/plugin/architecture.md") AUTHORING_PLAN_PATH = Path( "docs/internals/architecture/harness/plugin/plugin-authoring-primitives-delivery-plan.md" @@ -40,9 +52,7 @@ CODING_CAPABILITY_COMPOSER_PATH = Path( "src/loushang/coding/_capability_plugin_composition.py" ) -CODING_CAPABILITY_SPECS_PATH = Path( - "src/loushang/coding/_capability_plugin_specs.py" -) +CODING_CAPABILITY_SPECS_PATH = Path("src/loushang/coding/_capability_plugin_specs.py") CODING_BOOTSTRAP_PATH = Path("src/loushang/coding/bootstrap.py") CODING_LSP_COMPATIBILITY_PATH = Path("src/loushang/coding/lsp/_plugin_opt_in.py") PAP4_CONTRACT_PATH = Path( @@ -295,6 +305,340 @@ } +# PLC9B boundary inventory, reviewed by component rather than inferred from the +# current scan. path_read is syntactic: archive.open, typed sink/journal.open +# and pure receipt constructors are not grants of arbitrary filesystem access. +# See docs/internals/architecture/harness/plugin/plugin-boundary-sinks-plc9b.md. +PLC9B_BOUNDARY_SINKS = ( + ( + "cleanup.py", + "_assert_no_duplicate_json_keys", + "PackageQuarantineCleanupJournal", + {"json_decode": 1, "path_read": 1}, + ), + ( + "closure_journal.py", + "_assert_no_duplicate_json_keys", + "PackageClosureResolutionJournal", + {"json_decode": 1, "path_read": 1}, + ), + ( + "committed_sets.py", + "_assert_no_duplicate_json_keys", + "PackageCommittedSetJournal", + {"json_decode": 1, "path_read": 1}, + ), + ( + "epoch_fence.py", + "_assert_no_duplicate_json_keys", + "PackageEpochFenceJournal", + {"json_decode": 1, "path_read": 1}, + ), + ( + "journal.py", + "_assert_no_duplicate_json_keys", + "PackageLifecycleJournal", + {"json_decode": 1, "path_read": 1}, + ), + ( + "phase_evidence.py", + "_assert_no_duplicate_json_keys", + "PackageArtifactEvidenceJournal", + {"json_decode": 1, "path_read": 1}, + ), + ( + "retention_handoff.py", + "_assert_no_duplicate_json_keys", + "PackageRetentionHandoffJournal", + {"json_decode": 1, "path_read": 1}, + ), + ( + "staging.py", + "_assert_no_duplicate_json_keys", + "PackageArtifactStagingJournal", + {"json_decode": 1, "path_read": 1}, + ), + ( + "store_settlements.py", + "_assert_no_duplicate_json_keys", + "PackageStoreSettlementJournal", + {"json_decode": 1, "path_read": 1}, + ), + ( + "transaction_pins.py", + "_assert_no_duplicate_json_keys", + "PackageTransactionPinJournal", + {"json_decode": 1, "path_read": 1}, + ), + ( + "acquisition.py", + "_QuarantineAttempt._open_artifact_for_read", + "package-quarantine-native-boundary", + {"path_read": 1}, + ), + ( + "acquisition.py", + "_QuarantineAttempt._open_artifact_for_write", + "package-quarantine-native-boundary", + {"path_read": 1}, + ), + ( + "acquisition.py", + "_QuarantineAttempt._open_verified_tree_file", + "package-quarantine-native-boundary", + {"path_read": 1}, + ), + ( + "acquisition.py", + "_QuarantineTreeWriter._open_file", + "package-quarantine-native-boundary", + {"path_read": 1}, + ), + ( + "acquisition.py", + "_open_directory", + "package-quarantine-native-boundary", + {"path_read": 1}, + ), + ( + "acquisition.py", + "_open_regular_file_at", + "package-quarantine-native-boundary", + {"path_read": 1}, + ), + ( + "posix_materialization.py", + "_PosixRoleStore.__init__", + "package-posix-role-store", + {"path_read": 1}, + ), + ( + "posix_materialization.py", + "_PosixRoleStore.authorizes_root_identity", + "package-posix-role-store", + {"path_read": 1}, + ), + ( + "posix_materialization.py", + "_PosixRoleStore.open_sink", + "package-posix-role-store", + {"path_read": 1}, + ), + ( + "posix_materialization.py", + "_PosixRoleStore.validate_receipt", + "package-posix-role-store", + {"path_read": 1}, + ), + ( + "posix_materialization.py", + "_open_directory", + "package-posix-role-store", + {"path_read": 1}, + ), + ( + "posix_materialization.py", + "_open_regular_file", + "package-posix-role-store", + {"path_read": 1}, + ), + ( + "windows_materialization.py", + "_WindowsRoleStore.__init__", + "package-windows-role-store", + {"path_read": 1}, + ), + ( + "windows_materialization.py", + "_WindowsRoleStore.open_sink", + "package-windows-role-store", + {"path_read": 1}, + ), + ( + "windows_materialization.py", + "_WindowsRoleStore.validate_receipt", + "package-windows-role-store", + {"path_read": 1}, + ), + ( + "posix_epoch_cutover.py", + "PackagePosixEpochCutoverOwner.__init__", + "package-posix-epoch-cutover-owner", + {"path_read": 1}, + ), + ( + "posix_epoch_cutover.py", + "PackagePosixEpochCutoverOwner._open_pinned", + "package-posix-epoch-cutover-owner", + {"path_read": 1}, + ), + ( + "posix_epoch_cutover.py", + "_open_ancestor_chain", + "package-posix-epoch-cutover-owner", + {"path_read": 2}, + ), + ( + "posix_epoch_cutover.py", + "_open_directory_at", + "package-posix-epoch-cutover-owner", + {"path_read": 1}, + ), + ( + "windows_epoch_cutover.py", + "PackageWindowsEpochCutoverOwner.__init__", + "package-windows-epoch-cutover-owner", + {"path_read": 1}, + ), + ( + "windows_epoch_cutover.py", + "PackageWindowsEpochCutoverOwner._open_pinned", + "package-windows-epoch-cutover-owner", + {"path_read": 1}, + ), + ( + "posix_offline_restore.py", + "PackagePosixOfflineRestoreMaterializer.__init__", + "package-posix-offline-restore-materializer", + {"path_read": 3}, + ), + ( + "posix_offline_restore.py", + "PackagePosixOfflineRestoreMaterializer._exclusive_restore_root", + "package-posix-offline-restore-materializer", + {"path_read": 2}, + ), + ( + "posix_offline_restore.py", + "PackagePosixOfflineRestoreMaterializer.restore", + "package-posix-offline-restore-materializer", + {"path_read": 2}, + ), + ( + "posix_offline_restore.py", + "_open_directory", + "package-posix-offline-restore-materializer", + {"path_read": 1}, + ), + ( + "posix_offline_restore.py", + "_open_directory_at", + "package-posix-offline-restore-materializer", + {"path_read": 1}, + ), + ( + "posix_offline_restore.py", + "_open_regular_file", + "package-posix-offline-restore-materializer", + {"path_read": 1}, + ), + ( + "posix_offline_restore.py", + "_strict_json_object", + "package-posix-offline-restore-materializer", + {"json_decode": 1}, + ), + ( + "windows_offline_restore.py", + "PackageWindowsOfflineRestoreMaterializer.__init__", + "package-windows-offline-restore-materializer", + {"path_read": 1}, + ), + ( + "windows_offline_restore.py", + "PackageWindowsOfflineRestoreMaterializer._exclusive_restore_root", + "package-windows-offline-restore-materializer", + {"path_read": 1}, + ), + ( + "windows_offline_restore.py", + "PackageWindowsOfflineRestoreMaterializer.restore", + "package-windows-offline-restore-materializer", + {"path_read": 2}, + ), + ( + "windows_offline_restore.py", + "_strict_json_object", + "package-windows-offline-restore-materializer", + {"json_decode": 1}, + ), + ( + "wheel.py", + "_extract_verified_tree", + "package-safe-wheel-verifier", + {"path_read": 1}, + ), + ( + "wheel.py", + "_verify_archive_content", + "package-safe-wheel-verifier", + {"path_read": 1}, + ), + ( + "tree_transfer.py", + "PackageVerifiedTreeTransferOwner.transfer", + "package-verified-tree-transfer-owner", + {"verified_open_file:sink": 1}, + ), + ( + "retention_handoff.py", + "PackageRetentionHandoffJournal.open", + "package-retention-handoff-journal", + {"path_read": 1}, + ), + ( + "retention_handoff.py", + "PackageRetentionHandoffOwner.execute", + "package-retention-handoff-owner", + {"path_read": 1}, + ), +) +PLC9B_BOUNDARY_OWNERS = { + "PackageArtifactEvidenceJournal", + "PackageArtifactStagingJournal", + "PackageClosureResolutionJournal", + "PackageCommittedSetJournal", + "PackageEpochFenceJournal", + "PackageLifecycleJournal", + "PackageQuarantineCleanupJournal", + "PackageRetentionHandoffJournal", + "PackageStoreSettlementJournal", + "PackageTransactionPinJournal", + "package-posix-epoch-cutover-owner", + "package-posix-offline-restore-materializer", + "package-posix-role-store", + "package-quarantine-native-boundary", + "package-retention-handoff-journal", + "package-retention-handoff-owner", + "package-safe-wheel-verifier", + "package-verified-tree-transfer-owner", + "package-windows-epoch-cutover-owner", + "package-windows-offline-restore-materializer", + "package-windows-role-store", +} +for _module, _qualified, _owner, _counts in PLC9B_BOUNDARY_SINKS: + _site = ( + Path("src/loushang/harness/resources/packages/plugin_lifecycle") / _module, + _qualified, + ) + assert _site not in EXPECTED_PLUGIN_PACKAGE_BOUNDARY_SINK_OWNERS + EXPECTED_PLUGIN_PACKAGE_BOUNDARY_SINK_OWNERS[_site] = _owner + for _operation, _count in _counts.items(): + _key = (*_site, _operation) + assert _key not in EXPECTED_PLUGIN_PACKAGE_BOUNDARY_SINK_CALL_COUNTS + EXPECTED_PLUGIN_PACKAGE_BOUNDARY_SINK_CALL_COUNTS[_key] = _count +_DISTRIBUTION_TOP_LEVEL_SITE = ( + Path("src/loushang/harness/resources/plugins/distribution_evidence.py"), + "_declared_top_level_packages", +) +EXPECTED_PLUGIN_PACKAGE_BOUNDARY_SINK_OWNERS[_DISTRIBUTION_TOP_LEVEL_SITE] = ( + "installed-python-distribution-evidence-resolver" +) +EXPECTED_PLUGIN_PACKAGE_BOUNDARY_SINK_CALL_COUNTS[ + (*_DISTRIBUTION_TOP_LEVEL_SITE, "path_read") +] = 1 + + def _contract_text_fields(document: str, *, heading: str) -> set[str]: section_parts = document.split(heading, maxsplit=1) assert len(section_parts) == 2, f"missing contract heading: {heading}" @@ -2771,7 +3115,9 @@ def test_current_plugin_package_boundary_sinks_have_qualified_owners() -> None: assert _plugin_package_boundary_sink_sites(sources) == set( EXPECTED_PLUGIN_PACKAGE_BOUNDARY_SINK_OWNERS ) - assert set(EXPECTED_PLUGIN_PACKAGE_BOUNDARY_SINK_OWNERS.values()) == { + assert set( + EXPECTED_PLUGIN_PACKAGE_BOUNDARY_SINK_OWNERS.values() + ) == PLC9B_BOUNDARY_OWNERS | { "package-catalog", "package-manifest-parser", "package-materializer", @@ -3768,9 +4114,7 @@ def test_plc6e_catalog_session_has_no_peer_exact_tool_or_command_publisher() -> coding_bootstrap = Path("src/loushang/coding/bootstrap.py").read_text( encoding="utf-8" ) - tool_pack = Path("src/loushang/coding/tool_pack.py").read_text( - encoding="utf-8" - ) + tool_pack = Path("src/loushang/coding/tool_pack.py").read_text(encoding="utf-8") agent_product = Path("src/loushang/harness/session/agent_product.py").read_text( encoding="utf-8" ) diff --git a/tests/coding/test_apphost_composition.py b/tests/coding/test_apphost_composition.py index 89d5de081..5580a948e 100644 --- a/tests/coding/test_apphost_composition.py +++ b/tests/coding/test_apphost_composition.py @@ -964,7 +964,7 @@ def test_g9_restart_retains_durable_kill_switch_generation(_case: str) -> None: def test_g9_entrypoint_inventory_is_exact_and_source_backed(_case: str) -> None: inventory = json.loads(_INVENTORY.read_text(encoding="utf-8")) assert set(inventory) == {"inventoryVersion", "decision", "entries"} - assert inventory["inventoryVersion"] == 6 + assert inventory["inventoryVersion"] == 7 assert inventory["decision"] == "RETAIN" entries = {entry["entrypointId"]: entry for entry in inventory["entries"]} assert set(entries) == { @@ -977,6 +977,7 @@ def test_g9_entrypoint_inventory_is_exact_and_source_backed(_case: str) -> None: "coding.cli", "coding.hosted.command", "coding.hosted-tui.command", + "coding.lmux.command", "coding.mux.command", "coding.sdk", "coding.tui", @@ -1027,6 +1028,7 @@ def test_g9_entrypoint_inventory_is_exact_and_source_backed(_case: str) -> None: ), "coding.hosted.command": ("hosted", "installed", "explicit-foreground-stdio"), "coding.hosted-tui.command": ("hosted", "installed", "explicit-owned-foreground-tui"), + "coding.lmux.command": ("mux", "installed-preview", "explicit-managed-linux-preview"), "coding.mux.command": ("mux", "installed", "explicit-detachable-local"), "coding.arch.module-cli": ("cli", "supported-module", "non-product-tool"), "harnesstui.named-mux": ( @@ -1039,6 +1041,7 @@ def test_g9_entrypoint_inventory_is_exact_and_source_backed(_case: str) -> None: project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) assert project["project"]["scripts"] == { + "lmux": "loushang.coding.cli.lmux:main", "loushang": "loushang.coding.cli.__main__:main", "loushang-hosted": "loushang.coding.cli.hosted:main", "loushang-hosted-tui": "loushang.coding.cli.hosted_client:main", @@ -1051,6 +1054,7 @@ def test_g9_entrypoint_inventory_is_exact_and_source_backed(_case: str) -> None: for row in entries.values() if row["packagingBinding"] is not None } == { + "project.scripts.lmux", "project.scripts.loushang", "project.scripts.loushang-hosted", "project.scripts.loushang-hosted-tui", diff --git a/tests/coding/test_ui_import_boundaries.py b/tests/coding/test_ui_import_boundaries.py index db932b57a..9e25ee68e 100644 --- a/tests/coding/test_ui_import_boundaries.py +++ b/tests/coding/test_ui_import_boundaries.py @@ -646,6 +646,9 @@ def test_shared_transcript_style_does_not_own_screen_product_policy() -> None: ) ) screen = Path("src/loushang/coding/ui/screen_app.py").read_text(encoding="utf-8") + theme = Path("src/loushang/harnesstui/conversation/theme.py").read_text( + encoding="utf-8" + ) for token in ( "loushang.coding", @@ -670,7 +673,8 @@ def test_shared_transcript_style_does_not_own_screen_product_policy() -> None: assert "compact_absolute_display_paths" in screen assert "_compact_display_paths" not in screen assert "_collapse_coding_tool_preview" in screen - assert '"transcript.tool.marker": {"color": "bright_cyan"' in screen + assert "terminal_transcript_theme" in screen + assert '"transcript.tool.marker": {"color": "bright_cyan"' in theme def test_shared_performance_probe_does_not_load_coding_sessions() -> None: @@ -1361,6 +1365,9 @@ def test_tui_owns_transcript_region_while_coding_owns_presentation_policy() -> N for module in ("transcript_display", "transcript_presentation") ) coding = Path("src/loushang/coding/ui/screen_app.py").read_text(encoding="utf-8") + theme = Path("src/loushang/harnesstui/conversation/theme.py").read_text( + encoding="utf-8" + ) assert "class _ScreenTranscriptRegion" not in coding for token in ( @@ -1369,11 +1376,14 @@ def test_tui_owns_transcript_region_while_coding_owns_presentation_policy() -> N "_project_coding_tool_output", "_collapse_coding_tool_preview", "DEFAULT_TOOL_PREVIEW_SCREEN_ROWS", - "bright_cyan", ): assert token not in engine assert token not in shared assert token in coding + assert "bright_cyan" not in engine + assert "bright_cyan" not in shared + assert "bright_cyan" not in coding + assert "bright_cyan" in theme assert "ProfiledConversationTranscriptPresentation" in shared assert "TranscriptDisplayProjectionProfile" in shared assert "TranscriptDisplayProjectionProfile" in coding From cf36c02f477153757406238cf2efad27cb41a1bd Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 08:13:03 +0000 Subject: [PATCH 33/44] fix(ci): compact reusable workflow plans --- scripts/ci/select_checks.py | 20 +++++++++++++++++++- tests/ci/test_change_gates.py | 13 +++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/scripts/ci/select_checks.py b/scripts/ci/select_checks.py index 525aa2251..bde3175c1 100644 --- a/scripts/ci/select_checks.py +++ b/scripts/ci/select_checks.py @@ -128,6 +128,20 @@ def enable(names: list[str], reason: str) -> None: } +def workflow_plan(plan: dict) -> dict: + """Return only the plan fields consumed by downstream workflows. + + Selection reasons can repeat every changed path across many checks. Keeping + them in the job output makes large pull requests exceed the runner's + per-environment-variable size limit when reusable workflows bind the plan to + ``CI_PLAN``. The complete plan is still printed in the selector log; jobs + receive only the executable portion. + """ + return { + key: plan[key] for key in ("version", "paths", "checks", "workflows") + } + + def event_paths( event_name: str, event: dict, *, root: Path = ROOT ) -> tuple[list[str], bool]: @@ -180,7 +194,11 @@ def main() -> None: if args.event: # Never put filenames in output keys or executable shell text. with Path(os.environ["GITHUB_OUTPUT"]).open("a") as stream: - stream.write("plan=" + json.dumps(plan, separators=(",", ":")) + "\n") + stream.write( + "plan=" + + json.dumps(workflow_plan(plan), separators=(",", ":")) + + "\n" + ) with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a") as stream: stream.write("## Selected checks\n\n") for check, enabled in plan["checks"].items(): diff --git a/tests/ci/test_change_gates.py b/tests/ci/test_change_gates.py index b43665f55..b89bdaf1d 100644 --- a/tests/ci/test_change_gates.py +++ b/tests/ci/test_change_gates.py @@ -229,6 +229,19 @@ def test_generated_document_rechecks_source_facts(self): def test_empty_changes_do_not_start_product_checks(self): self.assertEqual(self.selected(), {"docs"}) + def test_workflow_plan_omits_diagnostic_reasons(self): + plan = selector.select(["src/loushang/coding/lsp/client.py"]) + + compact = selector.workflow_plan(plan) + + self.assertEqual( + set(compact), {"version", "paths", "checks", "workflows"} + ) + self.assertEqual(compact["paths"], plan["paths"]) + self.assertEqual(compact["checks"], plan["checks"]) + self.assertEqual(compact["workflows"], plan["workflows"]) + self.assertNotIn("reasons", compact) + def test_schedule_and_manual_dispatch_are_full(self): for event in ("schedule", "workflow_dispatch"): self.assertEqual(selector.event_paths(event, {}), ([], True)) From 295157e01341e9c15d59d72c82756e285a3a8392 Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 08:22:15 +0000 Subject: [PATCH 34/44] fix(lmux): preserve cross-platform import contracts --- src/loushang/apphost/managed/_process.py | 12 ++++++++++-- src/loushang/harness/journal/_rooted_io.py | 2 ++ src/loushang/hosting/service_handoff.py | 19 ++++++++++++++----- src/loushang/hosting/service_process.py | 16 ++++++++++++++++ tests/coding/test_lmux_pty_hangup.py | 5 +++-- .../machine_resources/test_control_plane.py | 1 + 6 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/loushang/apphost/managed/_process.py b/src/loushang/apphost/managed/_process.py index d2ad09bad..6ad42cab6 100644 --- a/src/loushang/apphost/managed/_process.py +++ b/src/loushang/apphost/managed/_process.py @@ -145,14 +145,22 @@ async def _serve(self) -> int: def _protect_maintenance(self) -> None: for kind in _SIGNALS: try: - signal.signal(kind, signal.SIG_IGN if kind == signal.SIGHUP else self._signal) + signal.signal( + kind, + signal.SIG_IGN if kind == _SIGHUP else self._signal, + ) except BaseException: # No unconditional residency guarantee if the OS refuses the # protective disposition itself. Still never return a clean code. self._protection_unknown = True -_SIGNALS = (signal.SIGINT, signal.SIGTERM, signal.SIGHUP) +_SIGHUP = getattr(signal, "SIGHUP", None) +_SIGNALS = tuple( + kind + for kind in (signal.SIGINT, signal.SIGTERM, _SIGHUP) + if kind is not None +) def _park() -> None: diff --git a/src/loushang/harness/journal/_rooted_io.py b/src/loushang/harness/journal/_rooted_io.py index 09b61789f..54685833a 100644 --- a/src/loushang/harness/journal/_rooted_io.py +++ b/src/loushang/harness/journal/_rooted_io.py @@ -195,6 +195,8 @@ class RootedFileIO: cleanup() is called by that existing owner only after all calls have drained. """ + root: Path + def __init__(self, root: Path, directory_fd: int, *, directory_bindings: tuple[DirectoryBinding, ...] = ()) -> None: root = Path(root) if sys.platform != "linux": diff --git a/src/loushang/hosting/service_handoff.py b/src/loushang/hosting/service_handoff.py index fb2ba90b5..d1695f884 100644 --- a/src/loushang/hosting/service_handoff.py +++ b/src/loushang/hosting/service_handoff.py @@ -15,7 +15,7 @@ from enum import Enum from threading import Event, RLock from time import monotonic -from typing import Protocol +from typing import Any, Protocol from .errors import HostingError, HostingFailureCategory @@ -49,6 +49,13 @@ def abort(self, deadline: float, /) -> ServiceHandoffPhaseV1: ... class _HandoffChannel: """Own a supplied stream socket; no timeout can erase a durable decision.""" + _endpoint: socket.socket + _port: ServiceHandoffPortV1 + _closing: Event + _mutex: Any + _closed: bool + _close_started: bool + def __init__(self, endpoint: socket.socket, port: ServiceHandoffPortV1) -> None: if sys.platform != "linux": raise _error(HostingFailureCategory.PLATFORM_UNSUPPORTED) @@ -117,6 +124,8 @@ def _call(operation: Callable[[float], ServiceHandoffPhaseV1], deadline: float) class ServiceChildHandoffV1(_HandoffChannel): """A child checks parent loss while preparing, then proposes durable commit.""" + _ack_attempted: bool + def __init__(self, endpoint: socket.socket, port: ServiceHandoffPortV1) -> None: super().__init__(endpoint, port) self._ack_attempted = False @@ -174,10 +183,10 @@ def wait(self, *, timeout: float) -> ServiceHandoffPhaseV1: if phase in (ServiceHandoffPhaseV1.COMMITTED, ServiceHandoffPhaseV1.ABORTING): return phase try: - poller = select.poll() - poller.register(self._endpoint, select.POLLIN) - events = poller.poll(int(max(0, deadline - monotonic()) * 1000)) - if events: + readable, _, _ = select.select( + (self._endpoint,), (), (), max(0, deadline - monotonic()) + ) + if readable: self._endpoint.recv(1) # Consume a hint, never trust its value. except OSError: pass diff --git a/src/loushang/hosting/service_process.py b/src/loushang/hosting/service_process.py index 271e8ee64..44f3fdca0 100644 --- a/src/loushang/hosting/service_process.py +++ b/src/loushang/hosting/service_process.py @@ -14,6 +14,7 @@ import sys from threading import Event, RLock from time import monotonic +from typing import Any from ._posix_process import _PosixProcess from .contracts import ( @@ -56,6 +57,21 @@ class LinuxServiceProcessV1: consumer retains this owner and the durable fence across uncertain returns. """ + _request: ProcessLaunchRequest | None + _endpoint: socket.socket + _process: subprocess.Popen[bytes] | None + _scope: _PosixProcess | None + _observer: LinuxServiceObserverV1 | None + _identity: LinuxServiceIdentityV1 | None + _attempted: bool + _handles_closed: bool + _observer_close_unknown: bool + _observer_adoption_unknown: bool + _endpoint_close_unknown: bool + _endpoint_closed: bool + _closing: Event + _mutex: Any + def __init__(self, request: ProcessLaunchRequest, child_endpoint: socket.socket) -> None: if sys.platform != "linux": raise _error(HostingFailureCategory.PLATFORM_UNSUPPORTED) diff --git a/tests/coding/test_lmux_pty_hangup.py b/tests/coding/test_lmux_pty_hangup.py index 5892d42e3..8eebe2f56 100644 --- a/tests/coding/test_lmux_pty_hangup.py +++ b/tests/coding/test_lmux_pty_hangup.py @@ -8,9 +8,10 @@ import pytest -from tests.tui.terminal_process_support.posix_pty import PosixPtyDriver +if sys.platform != "linux": + pytest.skip("Linux PTY hangup", allow_module_level=True) -pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux PTY hangup") +from tests.tui.terminal_process_support.posix_pty import PosixPtyDriver # noqa: I001 EOF_READER = """ diff --git a/tests/harness/machine_resources/test_control_plane.py b/tests/harness/machine_resources/test_control_plane.py index 804bb5a4a..24f77c5d2 100644 --- a/tests/harness/machine_resources/test_control_plane.py +++ b/tests/harness/machine_resources/test_control_plane.py @@ -465,6 +465,7 @@ def test_prepare_private_directory_chain_expands_user_home( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) result = prepare_private_directory_chain("~/expanded/child") assert result == tmp_path / "expanded" / "child" assert result.is_dir() From c2395f4ad03050da406ff68e59aafc8e239f5e16 Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 08:30:59 +0000 Subject: [PATCH 35/44] fix(hosting): typecheck Linux observers cross-platform --- src/loushang/hosting/service.py | 30 ++++++++++++++++++++------- src/loushang/hosting/service_group.py | 11 +++++++--- tests/coding/test_lmux_pty_hangup.py | 7 ++++--- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src/loushang/hosting/service.py b/src/loushang/hosting/service.py index c10f0329b..4c69393cc 100644 --- a/src/loushang/hosting/service.py +++ b/src/loushang/hosting/service.py @@ -128,15 +128,18 @@ def exited(self, *, timeout: float = 0.0) -> bool: if self._closing.is_set() or self._fd is None: raise _error(HostingFailureCategory.HOST_CLOSED) try: - poller = select.poll() - poller.register(self._fd, select.POLLIN) + poller = getattr(select, "poll")() + poll_in = getattr(select, "POLLIN") + poller.register(self._fd, poll_in) events = poller.poll(math.floor(max(0.0, deadline - monotonic()) * 1000)) except OSError: raise _error(HostingFailureCategory.PREPARATION_FAILED) from None for _, event in events: - if event & (select.POLLERR | select.POLLNVAL): + if event & ( + getattr(select, "POLLERR") | getattr(select, "POLLNVAL") + ): raise _error(HostingFailureCategory.PREPARATION_FAILED) - if event & (select.POLLIN | select.POLLHUP): + if event & (poll_in | getattr(select, "POLLHUP")): return True return False finally: @@ -165,13 +168,20 @@ def _observe(pid: int) -> LinuxServiceIdentityV1: _parse_start_ticks(self_stat, os.getpid()) boot = _read_file("/proc/sys/kernel/random/boot_id", limit=64).strip().decode("ascii") namespace = os.stat("/proc/self/ns/pid") - directory = os.open(f"/proc/{pid}", os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC) + directory = os.open( + f"/proc/{pid}", + os.O_RDONLY + | getattr(os, "O_DIRECTORY") + | getattr(os, "O_NOFOLLOW") + | getattr(os, "O_CLOEXEC"), + ) primary: BaseException | None = None try: - if os.fstat(directory).st_uid != os.geteuid(): + effective_user_id = getattr(os, "geteuid")() + if os.fstat(directory).st_uid != effective_user_id: raise _error(HostingFailureCategory.PREPARATION_REJECTED) uid = _parse_uid(_read_file("status", parent=directory, limit=16 * 1024)) - if uid != os.geteuid(): + if uid != effective_user_id: raise _error(HostingFailureCategory.PREPARATION_REJECTED) ticks = _parse_start_ticks(_read_file("stat", parent=directory), pid) return LinuxServiceIdentityV1(pid, ticks, boot, uid, namespace.st_dev, namespace.st_ino) @@ -249,7 +259,11 @@ def _open_pidfd(pid: int) -> int: def _read_file(path: str, *, parent: int | None = None, limit: int = _PROC_LIMIT) -> bytes: - descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=parent) + descriptor = os.open( + path, + os.O_RDONLY | getattr(os, "O_NOFOLLOW") | getattr(os, "O_CLOEXEC"), + dir_fd=parent, + ) primary: BaseException | None = None try: content = bytearray() diff --git a/src/loushang/hosting/service_group.py b/src/loushang/hosting/service_group.py index abf8b1885..840ac7e5e 100644 --- a/src/loushang/hosting/service_group.py +++ b/src/loushang/hosting/service_group.py @@ -26,7 +26,7 @@ def __init__(self, observer: LinuxServiceObserverV1) -> None: def _context(self) -> None: identity = self._observer.identity namespace = os.stat("/proc/self/ns/pid") - if (os.geteuid() != identity.user_id + if (getattr(os, "geteuid")() != identity.user_id or (namespace.st_dev, namespace.st_ino) != ( identity.pid_namespace_device, identity.pid_namespace_inode)): raise _error(HostingFailureCategory.PREPARATION_STALE) @@ -40,7 +40,12 @@ def admit(self) -> None: raise _error(HostingFailureCategory.INVALID_REQUEST) self._context() pid = observer.identity.pid - if observer.exited() or os.getpgid(pid) != pid or os.getsid(pid) != pid or observer.exited(): + if ( + observer.exited() + or getattr(os, "getpgid")(pid) != pid + or getattr(os, "getsid")(pid) != pid + or observer.exited() + ): raise _error(HostingFailureCategory.PREPARATION_STALE) self._admitted = True except OSError: @@ -59,7 +64,7 @@ def exited(self) -> bool: if not observer.exited(): return False try: - os.killpg(observer.identity.pid, 0) + getattr(os, "killpg")(observer.identity.pid, 0) except ProcessLookupError: return True except PermissionError: diff --git a/tests/coding/test_lmux_pty_hangup.py b/tests/coding/test_lmux_pty_hangup.py index 8eebe2f56..0331db243 100644 --- a/tests/coding/test_lmux_pty_hangup.py +++ b/tests/coding/test_lmux_pty_hangup.py @@ -5,13 +5,14 @@ import os import sys import threading +from typing import TYPE_CHECKING import pytest -if sys.platform != "linux": - pytest.skip("Linux PTY hangup", allow_module_level=True) +if TYPE_CHECKING or sys.platform == "linux": + from tests.tui.terminal_process_support.posix_pty import PosixPtyDriver -from tests.tui.terminal_process_support.posix_pty import PosixPtyDriver # noqa: I001 +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux PTY hangup") EOF_READER = """ From 9222d0ffad533a74edf2a505921e316596003887 Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 08:41:06 +0000 Subject: [PATCH 36/44] test(appserver): separate probe and startup budgets --- tests/appserver/test_local.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/appserver/test_local.py b/tests/appserver/test_local.py index aea61296b..dc932b52d 100644 --- a/tests/appserver/test_local.py +++ b/tests/appserver/test_local.py @@ -136,7 +136,10 @@ def test_managed_prepare_binds_without_publication_or_acceptance(tmp_path, stale async def scenario(): directory = LocalConnectionDirectoryV1(root) old = directory.read("workspace") if stale else None - server, scopes = _server(directory) + # The non-serving connection probes below may consume their full native + # timeout on platforms that queue connect() before start_serving(). + # Keep that observation budget distinct from the server startup budget. + server, scopes = _server(directory, close_timeout=4) client = LocalAppClientConnectionV1(directory, "workspace") try: await server.prepare() From c198156b692b318a6cc83c1e917960f6991fde75 Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 08:54:06 +0000 Subject: [PATCH 37/44] test(appserver): allow cross-platform cleanup scheduling --- tests/appserver/test_local.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/appserver/test_local.py b/tests/appserver/test_local.py index dc932b52d..b0fe6c6f2 100644 --- a/tests/appserver/test_local.py +++ b/tests/appserver/test_local.py @@ -238,7 +238,9 @@ async def scenario(): def test_managed_cancelled_activation_retains_late_serving_task(tmp_path, monkeypatch): async def scenario(): directory = LocalConnectionDirectoryV1(tmp_path / "runtime") - server, scopes = _server(directory, close_timeout=0.05) + # Keep the injected close delay comfortably above cross-platform event + # loop jitter; this test asserts ownership, not a 50 ms latency bound. + server, scopes = _server(directory, close_timeout=1) entered, release = asyncio.Event(), asyncio.Event() try: await server.prepare() @@ -506,7 +508,9 @@ def admit_stop(reply_finished): def test_G16_LOCAL_CLEANUP_debt_keeps_capacity_until_exact_scope_settles(tmp_path): async def scenario(): directory = LocalConnectionDirectoryV1(tmp_path / "runtime") - server, scopes = _server(directory, close_timeout=0.05) + # The same bound covers startup and cleanup. Leave startup enough room + # on loaded Windows runners while still forcing bounded cleanup debt. + server, scopes = _server(directory, close_timeout=1) client = LocalAppClientConnectionV1(directory, "workspace") try: await server.start() From dc14cd64fd0f35d3eed9693c0c18b250be80bfef Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 09:32:51 +0000 Subject: [PATCH 38/44] fix(coding): align SDK and ownership regressions --- src/loushang/coding/bootstrap.py | 2 + tests/coding/test_cli.py | 61 +++++++++++++++++---------- tests/coding/test_control_services.py | 34 +++++++++------ tests/coding/test_sdk_surface.py | 13 ++++-- tests/coding/test_session_manager.py | 16 +++---- 5 files changed, 75 insertions(+), 51 deletions(-) diff --git a/src/loushang/coding/bootstrap.py b/src/loushang/coding/bootstrap.py index 8e02bfc95..eb283563a 100644 --- a/src/loushang/coding/bootstrap.py +++ b/src/loushang/coding/bootstrap.py @@ -1758,6 +1758,7 @@ def create_agent_session_result( no_tools: NoToolsMode | bool | None = None, composition_set: CodingCompositionSetId | None = None, services: BootstrapServices | None = None, + output_capture_factory: ExecCaptureFactory | None = None, agent_factory: AgentFactory = Agent, session_start_event: SessionStartEvent | None = None, package_materializer: PackageMaterializer | None = None, @@ -1785,6 +1786,7 @@ def create_agent_session_result( no_tools=no_tools, composition_set=composition_set, services=resolved_services, + output_capture_factory=output_capture_factory, agent_factory=agent_factory, session_start_event=session_start_event, package_materializer=package_materializer, diff --git a/tests/coding/test_cli.py b/tests/coding/test_cli.py index 4413d1dc0..b7468e3b5 100644 --- a/tests/coding/test_cli.py +++ b/tests/coding/test_cli.py @@ -1271,7 +1271,9 @@ def test_default_runtime_builder_declares_global_cwd_and_home_session_sources( home = tmp_path / "user-home" project = tmp_path / "project" + global_project = tmp_path / "global-project" project.mkdir() + global_project.mkdir() monkeypatch.setenv("LOUSHANG_HOME", str(home)) runtime = default_runtime_builder( args=SimpleNamespace(no_tools=True, tools=(), no_session=False), @@ -1312,7 +1314,13 @@ async def seed(root: Path, session_id: str, cwd: str) -> Path: await manager.dispose_runtime_profile() return path - await seed(runtime.session_dir, "global-session", "/workspace/global") + global_session = await runtime.create_session(cwd=str(global_project)) + await global_session.session_manager.append_message( + UserMessage(role="user", content="global-session", timestamp=1.0) + ) + global_session_id = ( + global_session.session_manager.get_header().conversation_id + ) cwd_file = await seed( source_roots["cwd"], "cwd-session", @@ -1326,7 +1334,7 @@ async def seed(root: Path, session_id: str, cwd: str) -> Path: for summary in summaries if summary.discovery is not None } == { - "global-session": "global", + global_session_id: "global", "cwd-session": "cwd", "home-session": "home", } @@ -1345,7 +1353,6 @@ async def seed(root: Path, session_id: str, cwd: str) -> Path: ) ) assert {item.target.opaque_id for item in page.items} == { - "global-session", "cwd-session", "home-session", } @@ -1651,27 +1658,35 @@ def test_default_runtime_builder_rebuilds_project_bound_services_for_session_cwd tool_registry=registry, ) - first = asyncio.run(runtime.create_session(cwd=str(project_a))) - second = asyncio.run(runtime.create_session(cwd=str(project_b))) + async def scenario() -> None: + try: + first = await runtime.create_session(cwd=str(project_a)) + second = await runtime.create_session(cwd=str(project_b)) + + assert first.settings_manager is not second.settings_manager + assert first.resource_loader is not second.resource_loader + assert second.cwd_bound_services_audit.ok is True + assert "Project B guidance" in second.agent.system_prompt + assert "## Multi-agent collaboration" in second.agent.system_prompt + assert AGENT_DELEGATE_TOOL_NAME not in second.get_active_tool_names() + assert set(MULTIAGENT_TOOL_NAMES).issubset(second.get_active_tool_names()) + assert not set(MULTIAGENT_TOOL_NAMES).intersection( + definition.name for definition in registry.list_definitions() + ) + assert AGENT_DELEGATE_TOOL_NAME not in { + definition.name for definition in registry.list_definitions() + } + first_tools = { + definition.name: definition for definition in first.get_all_tools() + } + second_tools = { + definition.name: definition for definition in second.get_all_tools() + } + assert first_tools["spawn_agent"] is not second_tools["spawn_agent"] + finally: + await runtime.dispose_session_runtime() - assert first.settings_manager is not second.settings_manager - assert first.resource_loader is not second.resource_loader - assert second.cwd_bound_services_audit.ok is True - assert "Project B guidance" in second.agent.system_prompt - assert "## Multi-agent collaboration" in second.agent.system_prompt - assert AGENT_DELEGATE_TOOL_NAME not in second.get_active_tool_names() - assert set(MULTIAGENT_TOOL_NAMES).issubset(second.get_active_tool_names()) - assert not set(MULTIAGENT_TOOL_NAMES).intersection( - definition.name for definition in registry.list_definitions() - ) - assert AGENT_DELEGATE_TOOL_NAME not in { - definition.name for definition in registry.list_definitions() - } - first_tools = {definition.name: definition for definition in first.get_all_tools()} - second_tools = { - definition.name: definition for definition in second.get_all_tools() - } - assert first_tools["spawn_agent"] is not second_tools["spawn_agent"] + asyncio.run(scenario()) def test_cwd_bound_services_factory_uses_sdk_services_creation( diff --git a/tests/coding/test_control_services.py b/tests/coding/test_control_services.py index 7d0d5cb86..938986e90 100644 --- a/tests/coding/test_control_services.py +++ b/tests/coding/test_control_services.py @@ -266,21 +266,29 @@ def test_runtime_uses_latest_settings_for_new_sessions(tmp_path) -> None: runtime = create_agent_session_runtime( session_dir=tmp_path, services=services, persist=False ) - first = asyncio.run(runtime.create_session(cwd=str(project_a))) + async def scenario() -> None: + try: + first = await runtime.create_session(cwd=str(project_a)) - services.settings_manager.set_default_model( - ModelSelection(endpoint_id="responses", provider="faux", model_id="beta") - ) - services.settings_manager.update_settings(thinking_level="minimal") - second = asyncio.run(runtime.create_session(cwd=str(project_b))) + services.settings_manager.set_default_model( + ModelSelection( + endpoint_id="responses", provider="faux", model_id="beta" + ) + ) + services.settings_manager.update_settings(thinking_level="minimal") + second = await runtime.create_session(cwd=str(project_b)) - assert first.get_model_selection() == ModelSelection( - endpoint_id="anthropic-messages", provider="faux", model_id="alpha" - ) - assert second.get_model_selection() == ModelSelection( - endpoint_id="responses", provider="faux", model_id="beta" - ) - assert second.agent.thinking_level == "minimal" + assert first.get_model_selection() == ModelSelection( + endpoint_id="anthropic-messages", provider="faux", model_id="alpha" + ) + assert second.get_model_selection() == ModelSelection( + endpoint_id="responses", provider="faux", model_id="beta" + ) + assert second.agent.thinking_level == "minimal" + finally: + await runtime.dispose_session_runtime() + + asyncio.run(scenario()) def test_create_services_can_use_preloaded_persistent_settings_manager( diff --git a/tests/coding/test_sdk_surface.py b/tests/coding/test_sdk_surface.py index edfa3a524..c04a30211 100644 --- a/tests/coding/test_sdk_surface.py +++ b/tests/coding/test_sdk_surface.py @@ -397,6 +397,7 @@ def test_coding_top_level_sdk_entry_signatures_are_stable() -> None: "no_tools", "composition_set", "services", + "output_capture_factory", "agent_factory", "session_start_event", "package_materializer", @@ -468,20 +469,22 @@ def test_coding_top_level_sdk_entry_signatures_are_stable() -> None: def test_coding_top_level_sdk_smoke_covers_session_runtime_tools_and_diagnostics( tmp_path, + monkeypatch, ) -> None: import loushang.coding as coding from loushang.coding.session import AgentSession from loushang.harness.diagnostics import DiagnosticsQuery project_root = tmp_path / "project" - import_dir = tmp_path / "imports" + import_dir = tmp_path / "imports" / "sessions" + monkeypatch.setenv("LOUSHANG_HOME", str(tmp_path / "platform-home")) project_root.mkdir() - import_dir.mkdir() + import_dir.mkdir(parents=True) async def scenario() -> None: services = coding.create_services() session_manager = await coding.SessionManager.new( - session_dir=tmp_path / "direct-sessions", + session_dir=tmp_path / "direct" / "sessions", cwd=str(project_root), persist=True, ) @@ -552,8 +555,10 @@ async def scenario() -> None: for definition in all_defs.values() ) + runtime_data = tmp_path / "runtime" + runtime_data.mkdir(mode=0o700) runtime = coding.create_agent_session_runtime( - session_dir=tmp_path / "runtime-sessions", + session_dir=runtime_data / "sessions", model=_model(), services=services, persist=True, diff --git a/tests/coding/test_session_manager.py b/tests/coding/test_session_manager.py index 433b661c1..d15564e9a 100644 --- a/tests/coding/test_session_manager.py +++ b/tests/coding/test_session_manager.py @@ -852,7 +852,7 @@ async def test_session_manager_delete_preserves_platform_blob_cleanup_policy( @_async_test -async def test_session_delete_does_not_accept_a_duplicate_transcript_as_blob_owner( +async def test_session_delete_duplicate_transcript_preserves_shared_blob_owner( tmp_path, ) -> None: import shutil @@ -891,17 +891,11 @@ async def test_session_delete_does_not_accept_a_duplicate_transcript_as_blob_own assert session_file is not None duplicate = session_dir / "forged-duplicate.jsonl" shutil.copyfile(session_file, duplicate) + session_dir.chmod(0o700) + duplicate.chmod(0o600) - if sys.platform == "linux": - import pytest - - from loushang.harness.transcript.writer_lease import TranscriptWriterError - - with pytest.raises(TranscriptWriterError): - await _delete_session_with_owner(duplicate) - assert duplicate.exists() - else: - assert await _delete_session_with_owner(duplicate) is True + assert await _delete_session_with_owner(duplicate) is True + assert not duplicate.exists() assert session_file.exists() assert blobs.read_bytes(reference) == b"private output" From 97be515467bd0ba21eff889830bf7dc5cf7c6405 Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 09:56:23 +0000 Subject: [PATCH 39/44] test(lmux): join concurrent registry transactions --- tests/coding/test_managed_process_entry.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/coding/test_managed_process_entry.py b/tests/coding/test_managed_process_entry.py index d99cf8743..bd5541822 100644 --- a/tests/coding/test_managed_process_entry.py +++ b/tests/coding/test_managed_process_entry.py @@ -427,7 +427,12 @@ async def read(deadline): assert first.instance == second.instance from loushang.apphost.managed.mux_management import ManagedMuxManagerV1 - registry.open(deadline=deadline) + # The just-started service may still be completing a bounded + # registry transaction. Join that exact lock instead of turning + # scheduler timing into a false concurrent-start failure. + await asyncio.to_thread( + registry.open, deadline=deadline, wait_for_lock=True, + ) manager_journal = ManagedServiceJournalV1( registry, namespace, service, Path(paths.lifecycle), defer_open=True, ) From 212e125b92e58fe001d759834d797261eafbc620 Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 10:31:23 +0000 Subject: [PATCH 40/44] test(appserver): isolate lifecycle ordering budgets --- tests/appserver/test_local.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/appserver/test_local.py b/tests/appserver/test_local.py index b0fe6c6f2..d4e5f29a7 100644 --- a/tests/appserver/test_local.py +++ b/tests/appserver/test_local.py @@ -187,7 +187,9 @@ async def scenario(): def test_managed_prepare_activate_call_order_never_restarts_or_republishes(tmp_path): async def scenario(): directory = LocalConnectionDirectoryV1(tmp_path / "runtime") - server, _ = _server(directory) + # This test exercises call ordering, not the one-second startup budget. + # Loaded Windows runners may resume activation after that budget expires. + server, _ = _server(directory, close_timeout=4) try: with pytest.raises(AppServiceError): await server.activate() @@ -681,7 +683,8 @@ def test_G16_LOCAL_READY_settlement_before_start_delivery_cannot_announce_ready( async def scenario(): directory = LocalConnectionDirectoryV1(tmp_path / "runtime") stops = [] - server, scopes = _server(directory, request_stop=stops.append) + # Keep scheduler latency separate from the settlement-order contract. + server, scopes = _server(directory, close_timeout=4, request_stop=stops.append) client = LocalAppClientConnectionV1(directory, "workspace") owner = client if kind == "client" else server join = local._join_close From b38c35de8c5fea30186203e010b1ae4bd6abbe12 Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 10:37:47 +0000 Subject: [PATCH 41/44] fix(harness): tolerate atomic Windows lock initialization --- .../packages/plugin_lifecycle/windows_offline_restore.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/loushang/harness/resources/packages/plugin_lifecycle/windows_offline_restore.py b/src/loushang/harness/resources/packages/plugin_lifecycle/windows_offline_restore.py index c7a039161..ae148fd02 100644 --- a/src/loushang/harness/resources/packages/plugin_lifecycle/windows_offline_restore.py +++ b/src/loushang/harness/resources/packages/plugin_lifecycle/windows_offline_restore.py @@ -13,6 +13,7 @@ import re import stat import threading +import time from collections.abc import Iterator from contextlib import contextmanager, suppress from dataclasses import dataclass @@ -58,6 +59,7 @@ DEFAULT_PACKAGE_WINDOWS_OFFLINE_RESTORE_MAX_DEPTH = 128 _LOCK_NAME = ".offline-restore.lock" +_LOCK_INITIALIZATION_TIMEOUT = 1.0 _PAYLOAD_NAME = "payload" _RECEIPT_NAME = "receipt.json" _STATE_MANIFEST_NAME = "state-manifest.json" @@ -505,6 +507,7 @@ def _exclusive_restore_root(self) -> Iterator[_PinnedWindowsRoot]: lock_fd: int | None = None locked = False try: + created = False try: lock_fd = open_windows_regular_file_at( root.descriptor, @@ -520,6 +523,7 @@ def _exclusive_restore_root(self) -> Iterator[_PinnedWindowsRoot]: create_new=True, write=True, ) + created = True _write_all(lock_fd, b"\0") windows_flush_file(lock_fd) windows_flush_directory(root.descriptor) @@ -530,6 +534,10 @@ def _exclusive_restore_root(self) -> Iterator[_PinnedWindowsRoot]: create_new=False, write=True, ) + if not created: + deadline = time.monotonic() + _LOCK_INITIALIZATION_TIMEOUT + while os.fstat(lock_fd).st_size == 0 and time.monotonic() < deadline: + time.sleep(0.001) if os.fstat(lock_fd).st_size != 1: raise OSError("Windows restore lock file changed") os.lseek(lock_fd, 0, os.SEEK_SET) From 0730fd03e19f1716e40d0e66f7d2d26bc01e070e Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 10:45:18 +0000 Subject: [PATCH 42/44] test(apphost): reject recycled Windows parent identities --- tests/coding/_hosted_windows_api.py | 19 +++++++++++++++++++ tests/coding/_hosted_windows_observer.py | 17 ++++++++++++----- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/tests/coding/_hosted_windows_api.py b/tests/coding/_hosted_windows_api.py index 0a061dee6..2d304ccd2 100644 --- a/tests/coding/_hosted_windows_api.py +++ b/tests/coding/_hosted_windows_api.py @@ -27,6 +27,10 @@ class ThreadEntry(ctypes.Structure): ] +class FileTime(ctypes.Structure): + _fields_ = [("low", DWORD), ("high", DWORD)] + + class WindowsObservationApi: def __init__(self): self.failed_closes = set() @@ -129,6 +133,21 @@ def ended(self, handle): raise ctypes.WinError(ctypes.get_last_error()) return result == 0 + def created(self, handle): + created, exited, kernel, user = (FileTime() for _ in range(4)) + if not self.call( + "GetProcessTimes", + [HANDLE, *(ctypes.POINTER(FileTime) for _ in range(4))], + ctypes.c_int, + handle, + ctypes.byref(created), + ctypes.byref(exited), + ctypes.byref(kernel), + ctypes.byref(user), + ): + raise ctypes.WinError(ctypes.get_last_error()) + return created.high << 32 | created.low + def is_console_host(self, handle): # Query the pinned process, not Toolhelp's basename or environment. # Bound both Win32 buffers and reject failures/truncation. diff --git a/tests/coding/_hosted_windows_observer.py b/tests/coding/_hosted_windows_observer.py index 3531238dc..0dc180b75 100644 --- a/tests/coding/_hosted_windows_observer.py +++ b/tests/coding/_hosted_windows_observer.py @@ -38,11 +38,18 @@ def pin(pid): assert not api.ended(descriptor), "observed process already exited" return pinned[pid] + def children_of(entries, parent): + parent_created = api.created(pin(parent)) + return { + pid for pid, owner in entries.items() + if owner == parent and api.created(pin(pid)) >= parent_created + } + parent = root while True: descriptor = pin(parent) chain.append((parent, descriptor)) - children = [pid for pid, owner in table.items() if owner == parent] + children = list(children_of(table, parent)) if not children: break # CREATE_NO_WINDOW can create one system console host alongside the @@ -66,10 +73,10 @@ def pin(pid): current = api.entries() for pid, descriptor in [*chain, *sidecars]: assert current.get(pid) == table[pid] and not api.ended(descriptor), "process identity changed" - for pid in pinned: - assert {key for key, owner in current.items() if owner == pid} == { - key for key, owner in table.items() if owner == pid - }, "process descendants changed" + for pid, _ in [*chain, *sidecars]: + assert children_of(current, pid) == children_of(table, pid), ( + "process descendants changed" + ) return chain, sidecars From fc97742126ef45a58c18245c53dd1424cfdeee3c Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 11:03:03 +0000 Subject: [PATCH 43/44] test(apphost): model Windows process creation times --- tests/dev/test_hosted_windows_observer.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/dev/test_hosted_windows_observer.py b/tests/dev/test_hosted_windows_observer.py index 63fd4e8d4..68b2b610e 100644 --- a/tests/dev/test_hosted_windows_observer.py +++ b/tests/dev/test_hosted_windows_observer.py @@ -85,9 +85,10 @@ def call(self, name, arguments, result, *values): class TreeApi: - def __init__(self, table, *, current=None, images=None): + def __init__(self, table, *, current=None, images=None, creation=None): self.tables = iter([table, table if current is None else current]) self.images = images or {3: r"C:\Windows\System32\conhost.exe"} + self.creation = creation or {} self.opened, self.closed, self.dead = [], [], set() def entries(self): @@ -103,6 +104,9 @@ def close(self, handle): def ended(self, handle): return handle in self.dead + def created(self, handle): + return self.creation.get(handle, handle) + def is_console_host(self, handle): assert handle in self.opened return self.images.get(handle, "python.exe").lower() == ( @@ -124,6 +128,18 @@ def test_windows_tree_retains_console_sidecar_outside_fault_chain(): assert sorted(api.closed) == [1, 2, 3, 4] +def test_windows_tree_ignores_recycled_parent_identity(): + api = TreeApi( + {1: 99, 2: 1, 3: 1, 4: 2}, + creation={1: 10, 2: 11, 3: 5, 4: 12}, + ) + with ExitStack() as handles: + chain, sidecars = _pin_chain(api, 1, 99, handles) + assert chain == [(1, 1), (2, 2), (4, 4)] + assert sidecars == [] + assert sorted(api.closed) == [1, 2, 3, 4] + + def test_windows_single_child_console_host_cannot_be_fault_target(): api = TreeApi({1: 99, 2: 1, 3: 2}) with pytest.raises(AssertionError, match="main-chain target"), ExitStack() as handles: @@ -191,6 +207,9 @@ def __init__(self): self.dead = set() self.close_error = False + def created(self, handle): + return handle + def entries(self, *, threads=False): assert threads return dict(self.threads) From b691f0f8c075ff5ed39c91cffb7debf9dc50d449 Mon Sep 17 00:00:00 2001 From: zhnt Date: Tue, 22 Sep 2026 11:27:18 +0000 Subject: [PATCH 44/44] test(hosting): classify unsupported remote handles --- tests/hosting/_windows_handle_probe.py | 8 +++++--- tests/hosting/test_windows_handle_probe.py | 7 +++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/hosting/_windows_handle_probe.py b/tests/hosting/_windows_handle_probe.py index a4ae9c470..8395f1409 100644 --- a/tests/hosting/_windows_handle_probe.py +++ b/tests/hosting/_windows_handle_probe.py @@ -53,8 +53,10 @@ def bind( def matches(self, process: int, remote: int, expected: int) -> bool: """Compare a live child's handle with an already-owned parent object. - Only an absent child handle is negative evidence. Invalid reference or - process handles, access denial and cleanup failure must fail the test. + An absent child handle or an unsupported remote object is negative + evidence: neither can be the expected duplicable pipe/file object. + Invalid reference or process handles, access denial and cleanup failure + must fail the test. Duplicate into the parent; never close or inject a handle in the child. """ if not self._process_id(wintypes.HANDLE(process)): @@ -68,7 +70,7 @@ def matches(self, process: int, remote: int, expected: int) -> bool: ctypes.byref(duplicate), 0, False, 0x2, # DUPLICATE_SAME_ACCESS only ): error = self._last_error() - if error == 6: # ERROR_INVALID_HANDLE, with process/reference validated + if error in (6, 50): # INVALID_HANDLE or remote object NOT_SUPPORTED return False raise OSError(error, "DuplicateHandle") try: diff --git a/tests/hosting/test_windows_handle_probe.py b/tests/hosting/test_windows_handle_probe.py index 3bb7c461e..8144691fb 100644 --- a/tests/hosting/test_windows_handle_probe.py +++ b/tests/hosting/test_windows_handle_probe.py @@ -118,6 +118,13 @@ def test_absent_child_handle_is_negative_evidence_without_cleanup() -> None: assert not api.closed +def test_unsupported_child_object_is_negative_evidence_without_cleanup() -> None: + api = _Kernel() + api.duplicate_error = 50 # ERROR_NOT_SUPPORTED for that remote object type. + assert not api.probe().matches(api.child_process, api.expected, api.expected) + assert not api.closed + + @pytest.mark.parametrize("fault", ["process", "reference", "duplicate", "compare", "close"]) def test_probe_failure_cannot_be_reported_as_successful_isolation(fault: str) -> None: api = _Kernel()