Prhub

#2274 refactor(openenv): move duplicated sandbox helpers into the TB2 recipe

原始 PR 作者 nblintao 合并时间 2026-08-10 11:05 文件变更 4 提交数 1 评论 0 代码增减 +99 / -33

执行摘要

沙箱辅助函数上收共享 recipe,统一多 provider 资源策略

PR body 明确说明这是 3 步栈的 1/3,独立成立:Daytona 和 E2B 物化各有两份既非 provider 特有、漂移又有后果的副本。task_env_resources 的地板是 recipe 策略(env server 需要与任务并行运行的余量),但 Daytona 副本以 GB 应用(max(2, memory_mb // 1024))、其他以 MB 应用,同一 task.toml 在不同 provider 上可能被不同地遵守,已经出现实际分歧;run_with_deadline 中“为什么不能是 with 块”的推理也只应存在一份。

值得精读,尤其是三个设计决策:run_with_deadlinewith 块的论证(线程池 __exit__ 会 join 被放弃的调用)、配置归属原则(描述一个沙箱的 knob 放 create 旁、fan-out knob 放后端)、异常清理的 try/except 保护模式。建议结合栈的 3/3(#2276 Modal 后端)一起看完整演进,并留意后续 PR 是否补充 provider 层单位映射测试。

讨论亮点

Review 活动极少:Shi-Dong 直接 APPROVED,评论仅一句 “LGTM!”,无实质交锋。本 PR 最有价值的“讨论”其实在 PR body 里:run_with_deadline 为何不能写成 with ThreadPoolExecutor 块——__exit__ 会 join worker,等完超时本要放弃的调用;以及“描述一个沙箱的 knob 应放在 create 旁边,fan-out knob 留在后端”的配置归属原则。

实现拆解

  1. 上收共享 helperexamples/experimental/openenv/tb2_sandbox_recipe.py):新增 task_env_resources(task_dir),从 task.toml [environment] 读取 cpus / memory_mb / storage_mb 并统一应用地板(1 / 2048 / 10240),把“地板是 recipe 策略”固定在单一位置;新增 run_with_deadline(fn, timeout_s),用单 worker ThreadPoolExecutor 包裹阻塞型 provider 调用,超时抛 TimeoutErrorshutdown(wait=False) 不等待被放弃的调用;新增模块级常量 COMMAND_TIMEOUT_S(读 TB2_COMMAND_TIMEOUT_S,默认 900),server_cmd 默认参数从字面量 900 改为引用它;并将 _env_src_dir / wait_server_ready 的第三方依赖改为函数内 import,保证离线测试无需安装 tbench2_env 和 requests。

  2. 重构 Daytona 物化tb2_sandbox_daytona.py):task_resources 删除本地读取 task.toml 的副本,改用 task_env_resources 的 MB 返回值再做 GB 整数除法映射;create_task_sandboxcommand_timeout_s / ready_timeout_s 默认值改为 COMMAND_TIMEOUT_S_READY_TIMEOUT_S(新环境变量 OPENENV_DAYTONA_READY_TIMEOUT_S),knob 移到 create 旁并写明“描述一个沙箱的配置归属 create、fan-out 配置归属后端”的原则;auto_stop / auto_delete 刻意保持参数而非环境变量,因为它们与心跳节奏耦合。

  3. 重构 E2B 物化tb2_sandbox_e2b.py):task_build_resources 改用 task_env_resources 后只保留 cpu / memory 字段(E2B 在模板构建时定资源规格);ensure_task_template 内联的线程池超时逻辑替换为 run_with_deadline 调用;create_task_sandbox 删除 ttl_s 参数——keepalive 线程固定按 _SANDBOX_TTL_S 刷新,外部传值会在第一次心跳被静默覆盖,删除参数消除无效配置;新增 OPENENV_E2B_READY_TIMEOUT_S

  4. 异常路径加固:两个 provider 的失败创建清理(daytona.delete / sandbox.kill)都包上 try/except,清理自身失败不再掩盖 create 失败的真实异常,由 provider TTL / auto-delete 兜底回收。

  5. 测试配套tests/test_tb2_sandbox_recipe.py 新增 test_task_env_resources_floors,用地板下 / 正常两档值验证 floor 只在 recipe 层应用一次;无配置、schema 或部署文件改动,离线测试 61 通过 2 跳过。

文件 模块 状态 重要度
examples/experimental/openenv/tb2_sandbox_recipe.py 构建配方 modified 7.58
examples/experimental/openenv/tb2_sandbox_daytona.py 沙箱后端 modified 6.46
examples/experimental/openenv/tb2_sandbox_e2b.py 沙箱后端 modified 6.55
examples/experimental/openenv/tests/test_tb2_sandbox_recipe.py 构建配方 modified 4.5

关键符号

task_env_resources run_with_deadline server_cmd task_resources task_build_resources ensure_task_template create_task_sandbox test_task_env_resources_floors

关键源码片段

examples/experimental/openenv/tb2_sandbox_recipe.py core-logic

共享 recipe 层,新增 task_env_resources、run_with_deadline 与 COMMAND_TIMEOUT_S,是本次去重的唯一事实来源

# tb2_sandbox_recipe.py —— 共享层
# 沙箱内单条命令的执行超时:以 TB2_COMMAND_TIMEOUT_S 为唯一来源
# (这是 tbench2_env 的契约变量,命名对齐服务器侧),默认 900 秒。
# 归属 recipe 而非各 provider:只有这里构建 server 命令行,
# 所有后端自然拿到同一值,不再维护三个可能漂移的 getenv 副本。
COMMAND_TIMEOUT_S = int(os.getenv("TB2_COMMAND_TIMEOUT_S", "900"))
​
​
def task_env_resources(task_dir: Path) -> tuple[int, int, int]:
    """从 task.toml 的 [environment] 读取 CPU/内存/磁盘并应用地板值。    地板(1 CPU / 2048 MB 内存 / 10240 MB 磁盘)是 recipe 策略——
    环境服务器需要与任务并行运行的余量——只在这里应用一次;
    各 provider 自行映射单位、丢弃自己不用的字段,
    避免同一份 task.toml 在不同 provider 上被不同对待。
    """
    env_cfg = read_task_config(task_dir).get("environment", {})
    return (
        max(1, int(env_cfg.get("cpus", 1))),
        max(2048, int(env_cfg.get("memory_mb", 2048))),
        max(10240, int(env_cfg.get("storage_mb", 10240))),
    )
​
​
def run_with_deadline(fn, timeout_s: float):
    """在单 worker 线程上执行 *fn*,超过 *timeout_s* 抛 TimeoutError。    用于没有自身超时、跨多次请求阻塞的 provider 调用(如 E2B 模板构建)。
    刻意不用 with ThreadPoolExecutor 块:其 __exit__ 会 join worker,
    等于等完超时本要放弃的那个调用。超时后 provider 侧工作仍可能继续,
    回收它产出的东西是调用方的 provider 特有事务;
    但本调用方不再持有锁和信号量槽位。
    """
    pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
    try:
        return pool.submit(fn).result(timeout=timeout_s)
    finally:
        pool.shutdown(wait=False) # 不 join 仍在运行的 provider 调用
examples/experimental/openenv/tb2_sandbox_daytona.py core-logic

Daytona 物化改用共享 floor 并做 GB 映射,清理路径加保护;修复了与其他 provider 的资源解释漂移

# tb2_sandbox_daytona.py —— provider 物化
def task_resources(task_dir: Path):
    from daytona import Resources
​
    # Daytona 按整 GB 计资源;recipe 的地板(2048 MB / 10240 MB)
    # 保证整数除法不会把最小值舍成 0。
    cpus, memory_mb, storage_mb = task_env_resources(task_dir)
    return Resources(cpu=cpus, memory=memory_mb // 1024, disk=storage_mb // 1024)
​
​
def create_task_sandbox(
    daytona,
    task_dir: Path,
    *,
    command_timeout_s: int = COMMAND_TIMEOUT_S, # 默认值统一来自 recipe
    create_timeout_s: float = 1800.0,
    ready_timeout_s: float = _READY_TIMEOUT_S, # OPENENV_DAYTONA_READY_TIMEOUT_S
    auto_stop_minutes: int = 30, # 刻意保持参数而非环境变量:
    auto_delete_minutes: int = 120, # 它们是 Daytona 自有区间,与心跳节奏耦合
):
    from daytona import CreateSandboxFromImageParams
​
    params = CreateSandboxFromImageParams(
        image=build_task_image(task_dir),
        resources=task_resources(task_dir),
        auto_stop_interval=auto_stop_minutes,
        auto_delete_interval=auto_delete_minutes,
        labels=sandbox_labels(task_dir),
    )
    sandbox = daytona.create(params, timeout=create_timeout_s)
    try:
        cmd = server_cmd(command_timeout_s, default_task_id=task_dir.name)
        # Daytona 不执行镜像 CMD,需显式 nohup 启动环境服务器并记录 PID
        sandbox.process.exec(
            f"nohup bash -c {shlex.quote(cmd)} > /tmp/openenv-server.log 2>&1 &"
            " echo $! > /tmp/openenv-server.pid",
            timeout=10,
        )
        url = sandbox.create_signed_preview_url(8000, expires_in_seconds=86400).url
        wait_server_ready(url, timeout_s=ready_timeout_s)
        _start_keepalive(sandbox, task_dir.name)
        return sandbox, url
    except Exception:
        # 清理失败不得掩盖 create 失败的真实异常;
        # 即便 delete 也失败,auto-stop/auto-delete TTL 仍会兜底回收。
        try:
            daytona.delete(sandbox)
        except Exception:
            pass
        raise

评论区精华

重构整体审批 other

Shi-Dong 对本次纯重构变更直接批准,评论仅为 LGTM!,未提出任何异议或改进建议

结论:直接合并 · 已解决

风险与影响

  1. API 破坏(影响面小)tb2_sandbox_e2b.create_task_sandbox 删除了 ttl_s 参数,显式传参的调用方会直接 TypeError;该目录为 experimental,影响有限,但迁移时需确认调用方。
  2. 环境变量影响面扩大(有意的):设置 TB2_COMMAND_TIMEOUT_S 后统一影响所有 provider 的 server 命令超时;不设置时默认值 900 / 300 与原来一致,行为兼容。COMMAND_TIMEOUT_S 在 import 时读取,依赖“rollout worker 每次是新进程”的进程模型。
  3. 行为修复:Daytona 的 GB 地板与其他 provider 的 MB 地板已出现语义漂移,统一后同一 task.toml 在各 provider 上遵守一致,这是本 PR 的核心收益。
  4. 测试缺口:新测试只覆盖 recipe 层 floor,provider 层的 GB 整数除法映射没有新增单测,主要靠既有离线测试兜底。

影响范围限定在 examples/experimental/openenv 下的 TB2 沙箱后端(Daytona / E2B / 共享 recipe),不触及训练主路径、ppo、rollout 等核心模块。对用户而言,显式传 ttl_s 的 E2B 调用方需要迁移;对系统而言,统一策略降低了多 provider 维护成本,为 #2276 Modal 后端复用 helper 铺路。对团队而言,本 PR 确立了两个可复用的设计原则:provider 无关策略只放 recipe 一处、清理失败不得掩盖主异常。

API 参数删除 环境变量影响面扩大 provider 映射缺测试

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论