Skip to content

Commit bba295d

Browse files
committed
fix(sandbox): serialize same-key concurrent sandbox calls to prevent state races
Two concurrent HarnessAgent calls resolving to the same SandboxIsolationKey shared one persisted state slot. The acquire/resume/persist/release window runs outside the delegate's serializeOnKey gate and the sandbox guard defaulted to noop(), so both calls started containers from the same state and overwrote each other on completion (last write wins). - Add JVM-local InProcessSandboxExecutionGuard (fair per-key Semaphore; not thread-ownership bound so acquire/release may cross threads) and make it the default instead of noop(). - Run sandbox acquire/release on boundedElastic since the guard now blocks; a busy slot must never stall the subscriber's event-loop thread. - Release the guard lease in a finally and restore the interrupt flag cleared by InterruptedException; add inProcess(Duration) as a wedged-holder backstop.
1 parent ddad42e commit bba295d

11 files changed

Lines changed: 1159 additions & 84 deletions

File tree

agentscope-harness/src/main/java/io/agentscope/harness/agent/DistributedStore.java

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,17 @@ default SandboxSnapshotSpec sandboxSnapshotSpec() {
100100
/**
101101
* Creates the {@link SandboxExecutionGuard} for distributed sandbox concurrency control.
102102
*
103-
* <p>Override this when the store supports distributed locking. The default returns
104-
* a no-op guard (no cross-node coordination).
103+
* <p>Override this when the store supports distributed locking. The default returns {@code
104+
* null}, meaning "this store has no cross-node guard" — <em>not</em> "use a no-op guard".
105+
* Returning null lets HarnessAgent fall back to its built-in JVM-local {@code inProcess()}
106+
* guard, which still serialises same-key calls within one process (issue #2800). Returning
107+
* {@link SandboxExecutionGuard#noop()} instead would suppress that default and re-open the
108+
* same-slot race, so only do so to deliberately opt out of all serialisation.
105109
*
106-
* @return a sandbox execution guard; must not be {@code null}
110+
* @return a sandbox execution guard, or {@code null} to use HarnessAgent's built-in default
107111
*/
108112
default SandboxExecutionGuard sandboxExecutionGuard() {
109-
return SandboxExecutionGuard.noop();
113+
return null;
110114
}
111115

112116
/**
@@ -303,15 +307,13 @@ public DistributedStore build() {
303307
Objects.requireNonNull(baseStore, "baseStore is required");
304308
SandboxSnapshotSpec snap =
305309
sandboxSnapshotSpec != null ? sandboxSnapshotSpec : new NoopSnapshotSpec();
306-
SandboxExecutionGuard guard =
307-
sandboxExecutionGuard != null
308-
? sandboxExecutionGuard
309-
: SandboxExecutionGuard.noop();
310310
return new CompositeDistributedStore(
311311
agentStateStore,
312312
baseStore,
313313
snap,
314-
guard,
314+
// Left null when unset so HarnessAgent falls back to its built-in inProcess()
315+
// guard; coercing to noop() here would suppress that default (issue #2800).
316+
sandboxExecutionGuard,
315317
messageBus,
316318
asyncToolRegistry,
317319
taskRepository,

agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java

Lines changed: 148 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -127,14 +127,18 @@
127127
import java.util.List;
128128
import java.util.Map;
129129
import java.util.Set;
130+
import java.util.concurrent.atomic.AtomicInteger;
130131
import java.util.concurrent.atomic.AtomicReference;
131132
import java.util.function.BiFunction;
133+
import java.util.function.Consumer;
132134
import java.util.function.Function;
133135
import java.util.function.Supplier;
134136
import org.slf4j.Logger;
135137
import org.slf4j.LoggerFactory;
136138
import reactor.core.publisher.Flux;
137139
import reactor.core.publisher.Mono;
140+
import reactor.core.scheduler.Scheduler;
141+
import reactor.core.scheduler.Schedulers;
138142

139143
/**
140144
* HarnessAgent is the user-facing harness API that wraps a {@link ReActAgent} with workspace /
@@ -890,22 +894,137 @@ public Toolkit getToolkit() {
890894

891895
// ==================== Call/stream wrappers ====================
892896

893-
private Mono<Msg> wrappedCall(
894-
List<Msg> msgs, RuntimeContext effective, Supplier<Mono<Msg>> inner) {
895-
Mono<Msg> base =
896-
Mono.using(
897+
/**
898+
* Dedicated scheduler for the blocking guard acquire, kept separate from {@link
899+
* Schedulers#boundedElastic()} on purpose. The guard's {@code semaphore.acquire()} can park a
900+
* thread for the entire duration of a same-slot peer call (issue #2800); if those long-parked
901+
* waiters shared the pool that runs the holder's release, they could consume its whole thread
902+
* cap and the release that frees the permit would queue behind them forever — a starvation
903+
* deadlock. Releasing stays on {@code boundedElastic}, so it can always make progress and wake a
904+
* waiter here. Daemon threads, shared JVM-wide like {@code boundedElastic}, so no disposal.
905+
*/
906+
private static final Scheduler SANDBOX_ACQUIRE_SCHEDULER =
907+
Schedulers.newBoundedElastic(
908+
Schedulers.DEFAULT_BOUNDED_ELASTIC_SIZE,
909+
Schedulers.DEFAULT_BOUNDED_ELASTIC_QUEUESIZE,
910+
"as-sandbox-acquire",
911+
60,
912+
true);
913+
914+
/**
915+
* Acquires the sandbox for {@code effective} on {@link #SANDBOX_ACQUIRE_SCHEDULER}, returning
916+
* the same context as the reactive resource. The guard's {@code semaphore.acquire()} can block
917+
* for the entire duration of a same-slot peer call (issue #2800), so it must never run on the
918+
* subscriber's thread — a shared event loop there would stall unrelated sessions. Used as the
919+
* resource supplier for the {@code usingWhen} wrappers below.
920+
*
921+
* <p>Cancellation safety: {@code usingWhen} only registers its cleanup once the resource is
922+
* emitted. If the subscription is cancelled after {@code acquireForCall} already took the
923+
* permit and started the container but before the value reaches {@code usingWhen}, that value
924+
* is dropped unconsumed and cleanup never runs — leaking the permit and container forever
925+
* (issue #2800). {@link #acquireOffThread} closes that window by releasing whenever a cancel
926+
* and a finished acquire coincide; {@code releaseForCall} is idempotent (it no-ops once the
927+
* per-call binding is cleared), so it stays safe even though the normal path releases via
928+
* {@code usingWhen}.
929+
*/
930+
private Mono<RuntimeContext> acquireSandboxOffThread(RuntimeContext effective) {
931+
return acquireOffThread(
932+
effective,
933+
() -> {
934+
if (sandboxLifecycleMw != null) {
935+
sandboxLifecycleMw.acquireForCall(effective);
936+
}
937+
},
938+
orphaned -> {
939+
if (sandboxLifecycleMw != null) {
940+
sandboxLifecycleMw.releaseForCall(orphaned);
941+
}
942+
});
943+
}
944+
945+
/**
946+
* Runs the (potentially blocking) {@code acquire} on {@link #SANDBOX_ACQUIRE_SCHEDULER} and
947+
* yields {@code ctx} as the reactive resource. Package-private so a test can inject an arbitrary
948+
* blocking action and assert it never runs on the subscriber's thread.
949+
*/
950+
static Mono<RuntimeContext> acquireOffThread(RuntimeContext ctx, Runnable acquire) {
951+
return acquireOffThread(ctx, acquire, orphaned -> {});
952+
}
953+
954+
/**
955+
* Like {@link #acquireOffThread(RuntimeContext, Runnable)}, but invokes {@code
956+
* releaseOnCancel} exactly once if the subscription is cancelled after {@code acquire} has
957+
* completed — the window where {@code usingWhen} would otherwise never see the resource and so
958+
* never clean it up (issue #2800). Package-private so a test can assert the compensation fires
959+
* on cancel-during-acquire.
960+
*
961+
* <p>{@code doOnDiscard} cannot cover this: a {@link Mono#fromCallable} value produced after
962+
* cancellation is dropped without routing through the discard hook. Cancel and acquire land on
963+
* different threads (the cancelling subscriber vs. {@link #SANDBOX_ACQUIRE_SCHEDULER}), so the
964+
* two events are reconciled through a 2-bit state: bit 0 = acquire finished (permit taken,
965+
* result bound), bit 1 = cancelled. Whichever side sets its bit <em>second</em> observes the
966+
* other's bit already set and runs the release, so it fires exactly once and never while the
967+
* acquire is still binding its result. On the normal path the supplier completes rather than
968+
* cancels, so bit 1 is never set and cleanup flows through {@code usingWhen} as usual.
969+
*/
970+
static Mono<RuntimeContext> acquireOffThread(
971+
RuntimeContext ctx, Runnable acquire, Consumer<RuntimeContext> releaseOnCancel) {
972+
AtomicInteger state = new AtomicInteger(0);
973+
Runnable compensate = () -> releaseOnCancel.accept(ctx);
974+
return Mono.fromCallable(
897975
() -> {
898-
if (sandboxLifecycleMw != null) {
899-
sandboxLifecycleMw.acquireForCall(effective);
976+
acquire.run();
977+
// Acquire done: publish bit 0. If a cancel already set bit 1 while we
978+
// were acquiring, this resource will never reach usingWhen — release
979+
// it.
980+
if ((state.getAndUpdate(v -> v | 0b01) & 0b10) != 0) {
981+
compensate.run();
900982
}
901-
return effective;
902-
},
903-
eff -> inner.get(),
904-
eff -> {
905-
if (sandboxLifecycleMw != null) {
906-
sandboxLifecycleMw.releaseForCall(eff);
983+
return ctx;
984+
})
985+
.subscribeOn(SANDBOX_ACQUIRE_SCHEDULER)
986+
.doOnCancel(
987+
() -> {
988+
// Cancel: publish bit 1. If acquire already set bit 0, the resource is
989+
// now orphaned (usingWhen registered no cleanup for it) — release it.
990+
if ((state.getAndUpdate(v -> v | 0b10) & 0b01) != 0) {
991+
compensate.run();
907992
}
908993
});
994+
}
995+
996+
/**
997+
* Releases the sandbox on a boundedElastic thread. Persist + container stop + {@code
998+
* lease.close()} are all blocking, so like {@link #acquireSandboxOffThread} they must stay off
999+
* the subscriber's thread. Runs on {@code boundedElastic} rather than {@link
1000+
* #SANDBOX_ACQUIRE_SCHEDULER} so it can never be starved by the acquire waiters it must wake.
1001+
* {@code usingWhen} invokes this on complete, error and cancel alike, so the guard lease is
1002+
* always released.
1003+
*/
1004+
private Mono<Void> releaseSandboxOffThread(RuntimeContext eff) {
1005+
return releaseOffThread(
1006+
() -> {
1007+
if (sandboxLifecycleMw != null) {
1008+
sandboxLifecycleMw.releaseForCall(eff);
1009+
}
1010+
});
1011+
}
1012+
1013+
/**
1014+
* Runs the (potentially blocking) {@code release} on a boundedElastic thread. Package-private so
1015+
* a test can assert the release never runs on the subscriber's thread either.
1016+
*/
1017+
static Mono<Void> releaseOffThread(Runnable release) {
1018+
return Mono.<Void>fromRunnable(release).subscribeOn(Schedulers.boundedElastic());
1019+
}
1020+
1021+
private Mono<Msg> wrappedCall(
1022+
List<Msg> msgs, RuntimeContext effective, Supplier<Mono<Msg>> inner) {
1023+
Mono<Msg> base =
1024+
Mono.usingWhen(
1025+
acquireSandboxOffThread(effective),
1026+
eff -> inner.get(),
1027+
this::releaseSandboxOffThread);
9091028
if (compactionHook != null) {
9101029
return base.onErrorResume(
9111030
e -> {
@@ -924,36 +1043,18 @@ private Mono<Msg> wrappedCall(
9241043
*/
9251044
@Deprecated(since = "2.0.0", forRemoval = true)
9261045
private Flux<Event> wrappedStream(RuntimeContext effective, Supplier<Flux<Event>> inner) {
927-
return Flux.using(
928-
() -> {
929-
if (sandboxLifecycleMw != null) {
930-
sandboxLifecycleMw.acquireForCall(effective);
931-
}
932-
return effective;
933-
},
1046+
return Flux.usingWhen(
1047+
acquireSandboxOffThread(effective),
9341048
eff -> inner.get(),
935-
eff -> {
936-
if (sandboxLifecycleMw != null) {
937-
sandboxLifecycleMw.releaseForCall(eff);
938-
}
939-
});
1049+
this::releaseSandboxOffThread);
9401050
}
9411051

9421052
private Flux<AgentEvent> wrappedStreamEvents(
9431053
RuntimeContext effective, Supplier<Flux<AgentEvent>> inner) {
944-
return Flux.using(
945-
() -> {
946-
if (sandboxLifecycleMw != null) {
947-
sandboxLifecycleMw.acquireForCall(effective);
948-
}
949-
return effective;
950-
},
1054+
return Flux.usingWhen(
1055+
acquireSandboxOffThread(effective),
9511056
eff -> inner.get(),
952-
eff -> {
953-
if (sandboxLifecycleMw != null) {
954-
sandboxLifecycleMw.releaseForCall(eff);
955-
}
956-
});
1057+
this::releaseSandboxOffThread);
9571058
}
9581059

9591060
/**
@@ -2261,7 +2362,12 @@ public HarnessAgent build() {
22612362
if (sandboxFilesystemSpec.getSnapshotSpecOverride() == null) {
22622363
sandboxFilesystemSpec.snapshotSpec(distributedStore.sandboxSnapshotSpec());
22632364
}
2264-
if (sandboxFilesystemSpec.getExecutionGuard() == null) {
2365+
// Only adopt the store's guard when it actually supplies one. A store that
2366+
// returns null is opting out, NOT requesting a no-op guard — leaving the spec
2367+
// null lets the inProcess() default below still serialise same-key calls
2368+
// (issue #2800). Injecting a noop here would silently suppress that default.
2369+
if (sandboxFilesystemSpec.getExecutionGuard() == null
2370+
&& distributedStore.sandboxExecutionGuard() != null) {
22652371
sandboxFilesystemSpec.executionGuard(
22662372
distributedStore.sandboxExecutionGuard());
22672373
}
@@ -2335,10 +2441,14 @@ public HarnessAgent build() {
23352441

23362442
SessionSandboxStateStore stateStore =
23372443
new SessionSandboxStateStore(effectiveSession, resolvedAgentId);
2444+
// Default to a JVM-local guard so same-slot concurrent calls (e.g. two requests on
2445+
// one sessionId) serialise their acquire/persist window instead of racing on the
2446+
// shared state slot (issue #2800). Multi-instance deployments override this with a
2447+
// distributed guard via the spec or a DistributedStore.
23382448
SandboxExecutionGuard executionGuard =
23392449
sandboxFilesystemSpec.getExecutionGuard() != null
23402450
? sandboxFilesystemSpec.getExecutionGuard()
2341-
: SandboxExecutionGuard.noop();
2451+
: SandboxExecutionGuard.inProcess();
23422452
SandboxManager sandboxManager =
23432453
new SandboxManager(
23442454
defaultSandboxContext.getClient(),

agentscope-harness/src/main/java/io/agentscope/harness/agent/IsolationScope.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,12 @@
4747
* </ul>
4848
*
4949
* <p><b>Concurrency note:</b> for sandbox mode this is sequential-reuse sharing, not
50-
* live-instance sharing. Concurrent calls at the same scope each get their own running container;
51-
* they converge on the last persisted snapshot at the end of the call.
50+
* live-instance sharing. By default a JVM-local
51+
* {@link io.agentscope.harness.agent.sandbox.SandboxExecutionGuard#inProcess() execution guard}
52+
* serialises concurrent calls that resolve to the same scope key, so each call resumes the
53+
* snapshot the previous one persisted rather than racing on it. Configure a distributed guard for
54+
* multi-instance deployments, or {@link
55+
* io.agentscope.harness.agent.sandbox.SandboxExecutionGuard#noop()} to opt out.
5256
*/
5357
public enum IsolationScope {
5458

agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/SandboxFilesystemSpec.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,13 @@ public SandboxSnapshotSpec getSnapshotSpecOverride() {
7777
* Sets a {@link SandboxExecutionGuard} that serialises concurrent executions on the same
7878
* isolation slot.
7979
*
80-
* <p>Only relevant for {@link io.agentscope.harness.agent.IsolationScope#AGENT} and
81-
* {@link io.agentscope.harness.agent.IsolationScope#GLOBAL} scopes, where multiple callers
82-
* could otherwise race on the same persistent state. When {@code null} (default), no guard is
83-
* applied and the existing no-lock behaviour is preserved.
80+
* <p>Relevant for every scope where concurrent calls can resolve to the same state slot,
81+
* including same-session concurrency. When {@code null} (default), the harness applies a
82+
* JVM-local guard ({@link SandboxExecutionGuard#inProcess()}) that serialises same-slot calls
83+
* within one process. Supply a distributed guard when the same slot can be contended across
84+
* multiple JVM instances.
8485
*
85-
* @param executionGuard the guard to apply, or {@code null} for no guard
86+
* @param executionGuard the guard to apply, or {@code null} to use the in-process default
8687
* @return this spec
8788
*/
8889
public SandboxFilesystemSpec executionGuard(SandboxExecutionGuard executionGuard) {

0 commit comments

Comments
 (0)