127127import java .util .List ;
128128import java .util .Map ;
129129import java .util .Set ;
130+ import java .util .concurrent .atomic .AtomicInteger ;
130131import java .util .concurrent .atomic .AtomicReference ;
131132import java .util .function .BiFunction ;
133+ import java .util .function .Consumer ;
132134import java .util .function .Function ;
133135import java .util .function .Supplier ;
134136import org .slf4j .Logger ;
135137import org .slf4j .LoggerFactory ;
136138import reactor .core .publisher .Flux ;
137139import 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 (),
0 commit comments