Skip to content
xiaoyuuuuuupeng
Go back

一次 Sandbox Fallback 并发生命周期 PR 的排查与 Review 实录

本文记录的是审查当日的代码与结论;PR 状态和代码行号可能继续变化,请以文首 GitHub 链接为准。 PRagentscope-ai/agentscope-java#2854
Issue:#2849(延续 #2490)
标题:fix(sandbox): retain active fallback across concurrent calls
作者:Oxygen56
Reviewxiaoyuuuuuupeng 的 Approve + 行内 Suggestion
日期:2026-08-26 ~ 2026-08-27


前言

这个 PR 只动 4 个文件(2 个生产 + 2 个测试),CI 全绿,单测是确定性的(无 sleep、无真实 sandbox backend)。但它修的是 #2490 只修了一半 的并发问题:工具调用已经 per-call 隔离了,context-free 内部读者 仍靠单槽 volatile sandbox fallback,多 session 并发时会出现 No active sandbox

在 Agent 服务里,一个 HarnessAgent bean 上跑多个 (userId, sessionId) 是框架允许的(serializeOnKey 只串行同 session)。一旦用户 A 还在 stream、用户 B 先结束,MessageBus / 异步子 agent 等内部路径就可能踩雷——不是工具坏了,是 fallback 被误清空

本文记录:上下文怎么理解、竞态案例、如何本地复现、PR 修了什么,以及还剩什么。


1. 先搞清上下文:两层 sandbox 解析

1.1 调用链

HarnessAgent.call / stream
  └─ SandboxLifecycleMiddleware.acquireForCall(ctx)
       ├─ ctx.put(SandboxAcquireResult, result)   ← 路径 ① per-call 绑定
       └─ filesystemProxy.setSandbox(sandbox)       ← 路径 ② fallback 字段
  └─ 工具执行 / 内部组件读文件
       └─ SandboxBackedFilesystem.requireSandbox(ctx)
            ├─ ① ctx 里有 SandboxAcquireResult → 用对的 sandbox ✅
            └─ ② 否则用 fallback 字段(volatile / 栈)⚠️

1.2 #2490 修了什么、没修什么

路径谁在用#2490 之后
① per-callread_fileshell 等工具✅ 各 session 隔离
② fallbackWorkspaceMessageBusWorkspaceAsyncToolRegistry、部分 WorkspaceTaskRepository❌ 仍共享,last-writer-wins

内部组件典型写法(context-free):

// WorkspaceMessageBus.java
private static final RuntimeContext RC = RuntimeContext.empty();

fs.write(RC, path, json);  // RC 里没有 SandboxAcquireResult

requireSandbox 只能走 fallback:

private Sandbox requireSandbox(RuntimeContext runtimeContext) {
    Sandbox s = null;
    if (runtimeContext != null) {
        SandboxAcquireResult bound = runtimeContext.get(SandboxAcquireResult.class);
        if (bound != null) s = bound.getSandbox();
    }
    if (s == null) s = sandbox;  // fallback
    if (s == null) throw new SandboxConfigurationException("No active sandbox ...");
    return s;
}

2. 旧代码有什么问题:后 acquire 先 release → fallback 变 null

2.1 案例:小明和小红共用一个 Agent

两个不同 session 合法并发(s1 != s2 不互斥)。

步骤事件单槽 fallback(修前)
1小明 A acquireForCallsbA
2小红 B acquireForCallsbB(覆盖)
3小红 B releaseForCallclearSandboxIfCurrent(sbB) → field == sbB → null
4小明还在 stream,子 agent 完成 → MessageBus.inboxPushfs.write(empty RC, ...) → fallback null → 💥

异常:

SandboxConfigurationException: No active sandbox — sandbox filesystem used outside of a call context

2.2 为什么 #2490 的 compare-and-clear 不够

clearSandboxIfCurrent 设计是:只有 field 仍指向本 call 的 sandbox 时才清空,防止 A release 时误清 B 的 binding。

但在「B 后 acquire、先 release」场景里,field 本来就指向 B 自己的 sandbox,清空是「合法」的——单槽没有记忆「A 还在跑」。

2.3 第二个 bug:start 失败误清别人的 fallback

若 A、B 共用同一个 user-managed sandbox 对象,B 的 start()setSandbox 之前失败,旧代码 catch 里仍调用 clearSandboxIfCurrent(sandbox),会把 A 已注册的 binding 清掉。PR 第二个 commit 用 fallbackBound 标志修掉。


3. 如何排查:我们的 Review 步骤

3.1 第一步:读核心 API

  1. SandboxBackedFilesystem.setSandbox / clearSandboxIfCurrent
  2. SandboxLifecycleMiddleware.acquireForCall / releaseForCall
  3. 对照 PR 新增的单测

3.2 第二步:重点检查清单

检查项结论
同一 sandbox 重复绑定的引用计数sharedSandboxRemainsBoundUntilEveryCallReleases
先 release 非当前 binding 是否会「复活」旧 sandbox✅ 不会,只删列表项
start() / getState() 异常是否只清本 call 注册的 fallbackfallbackBound
context-free fallback 是否承担 session 隔离未修,仍看栈顶
setSandbox(null) 清整栈⚠️ 无生产调用,建议 follow-up(见 §8)

3.3 第三步:跑回归测试

git fetch upstream pull/2854/head:pr-2854
git checkout pr-2854

# 根目录先装 BOM(首次)
mvn -q install -DskipTests

cd agentscope-harness
mvn -q test "-Dtest=SandboxBackedFilesystemTest,SandboxLifecycleConcurrencyReproTest"

本地结果:全部通过(约 24s)。

3.4 在 main 上复现「修前会红」

只 cherry-pick 测试、不打补丁时,以下用例会 FAIL:

最小手工复现(main 单槽逻辑):

var fs = new SandboxBackedFilesystem();
fs.setSandbox(sbA);
fs.setSandbox(sbB);
fs.clearSandboxIfCurrent(sbB);  // fallback = null
fs.execute(RuntimeContext.empty(), "cmd", null);  // 抛 SandboxConfigurationException

4. PR 如何修复

4.1 binding 栈替代单槽

private final List<Sandbox> fallbackBindings = new ArrayList<>();

public synchronized void setSandbox(Sandbox sandbox) {
    fallbackBindings.add(sandbox);
    this.sandbox = sandbox;
}

public synchronized void clearSandboxIfCurrent(Sandbox expected) {
    for (int i = fallbackBindings.size() - 1; i >= 0; i--) {
        if (fallbackBindings.get(i) == expected) {
            fallbackBindings.remove(i);
            break;
        }
    }
    this.sandbox = fallbackBindings.isEmpty()
            ? null
            : fallbackBindings.get(fallbackBindings.size() - 1);
}

同样案例修后:

步骤栈顶
A acquire[sbA]sbA
B acquire[sbA, sbB]sbB
B release[sbA]sbA
小明 MessageBus 写 inboxfallback = sbA不崩

4.2 fallbackBound:start 失败不误清

boolean fallbackBound = false;
try {
    sandbox.start();
    ctx.put(SandboxAcquireResult.class, result);
    filesystemProxy.setSandbox(sandbox);
    fallbackBound = true;
    log.debug("...", sandbox.getState() ...);
} catch (Exception e) {
    if (fallbackBound) {
        filesystemProxy.clearSandboxIfCurrent(sandbox);
    }
    ...
}

4.3 新增测试覆盖

测试验证点
contextFreeCallUsesRemainingSandboxWhenLatestCallReleasesB release 后 fallback 回到 A
releasingNonCurrentSandboxDoesNotRestoreItLater删非栈顶不会复活
sharedSandboxRemainsBoundUntilEveryCallReleases同对象两次 acquire 要 release 两次
failedSharedSandboxStartDoesNotRemoveActiveFallbackB start 失败不清 A
failureAfterFallbackRegistrationClearsOwnBindinggetState 失败只清自己

5. 还没解决什么:fallback 仍可能「混 sandbox」

PR 故意收窄范围,只修空窗崩溃,不做 session 路由。

5.1 案例:两人都在线时

时间事件fallback 栈顶
T1A、B 都在跑sbB(后 acquire)
T2小明的子 agent inboxPush(session-A, ...)context-free → 写到 sbB ⚠️

5.2 建议的 follow-up(未在本 PR 做)

阶段 1:fallback 从栈顶改成 Map<SandboxIsolationKey, Sandbox> + 引用计数,requireSandboxctx.getSessionId() 查。

阶段 2WorkspaceMessageBus.inboxPush(sessionId, ...) 内部用 RuntimeContext.builder().sessionId(sessionId).build(),别用 RC.empty()

应用层 workaround:每个 (agentId, sessionId) 单独缓存 HarnessAgent(issue #2849 原文)。


6. Review 结论

6.1 已解决 ✅

6.2 未解决,建议 follow-up ⚠️

6.3 最终表态

LGTM / Approve — 针对 #2849 报告的空窗问题,生命周期语义成立,测试可稳定复现;混 sandbox 与 setSandbox(null) 作为 non-blocking follow-up。


7. 附录:几个高频问题的答案

Q:context-free 是什么?什么时候触发?

不是特殊 API,就是框架内部用 RuntimeContext.empty()fs.read/write,例如:

Agent 正常跑起来且用到上述功能时就会触发;和用户直接调工具有关但不是同一条路。

Q:并发下会有两个 sandbox 吗?

会,而且应该有两个(各 session 各一个)。工具走 per-call context 隔离;fallback 栈只是记住「还有谁在跑」,避免后结束的 call 把 fallback 清成 null。

Q:PR 修完后 fallback 和 session 绑定了吗?

没有。 release 时只删自己的 binding 并恢复栈顶;context-free 仍可能拿到别的 session 的 sandbox。要根治需 session-keyed map 或给内部调用传 context。

Q:sharedUserManagedManager 测试 helper 有问题吗?

没有。故意让多个 acquire 返回同一 userManaged sandbox,模拟外部注入共享容器;userManagedrelease 是 no-op,不会干扰 fallback 生命周期断言。


8. 小结

阶段要点
理解上下文两层解析:per-call(#2490)+ fallback(本 PR)
根因单槽 last-writer-wins;后 release 的 call 清空 field,先开始的 call 的 context-free 读者崩溃
修复fallbackBindings 栈 + fallbackBound
复现PR 单测或 main 上手写 setSandbox / clearSandboxIfCurrent 序列
Review生命周期语义 ✅;混 sandbox ❌(scope 外);setSandbox(null) nit
后续session-keyed fallback;MessageBus 传 sessionId;或 per-session Agent 缓存

这是一个「改动小、钉住 reported bug、边界写得清楚」的 follow-up PR。空窗修复值得合入;session 级 fallback 路由值得另开 issue/PR 跟踪。


相关 Issue:#2849
前置修复:#2490 / #2675 isolate sandbox binding per call


Share this post:

Previous Post
一次 Harness Shell 管道死锁 PR 的排查与 Review 实录
Next Post
一次 Harness 异步 Memory Flush PR 的排查与 Review 实录