-
Notifications
You must be signed in to change notification settings - Fork 1.3k
fix(harness): defer self-managed sandbox shutdown until session mirro… #3094
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,7 +21,9 @@ | |
| import io.agentscope.harness.agent.filesystem.model.ReadResult; | ||
| import io.agentscope.harness.agent.filesystem.sandbox.PinnedSandboxFilesystem; | ||
| import io.agentscope.harness.agent.sandbox.Sandbox; | ||
| import io.agentscope.harness.agent.sandbox.SandboxAcquireResult; | ||
| import io.agentscope.harness.agent.sandbox.SandboxAware; | ||
| import io.agentscope.harness.agent.sandbox.SandboxMirrorReleaseCoordinator; | ||
| import io.agentscope.harness.agent.transcript.ObjectStoreTranscriptStore; | ||
| import io.agentscope.harness.agent.transcript.TranscriptRef; | ||
| import io.agentscope.harness.agent.transcript.TranscriptStore; | ||
|
|
@@ -112,9 +114,15 @@ public class SessionTree { | |
| * @return {@code true} if the mirrors quiesced within the timeout | ||
| */ | ||
| public static boolean awaitMirrorQuiescence(long timeout, TimeUnit unit) { | ||
| long deadlineNanos = System.nanoTime() + unit.toNanos(timeout); | ||
| try { | ||
| MIRROR_EXECUTOR.submit(() -> {}).get(timeout, unit); | ||
| return true; | ||
| long mirrorWaitNanos = Math.max(0L, deadlineNanos - System.nanoTime()); | ||
| MIRROR_EXECUTOR.submit(() -> {}).get(mirrorWaitNanos, TimeUnit.NANOSECONDS); | ||
| // Deferred stop/shutdown runs on a separate executor; drain it too so graceful close | ||
| // does not race sandbox teardown that was scheduled from mirror finally blocks. | ||
| long releaseWaitNanos = Math.max(0L, deadlineNanos - System.nanoTime()); | ||
| return SandboxMirrorReleaseCoordinator.awaitReleaseQuiescence( | ||
| releaseWaitNanos, TimeUnit.NANOSECONDS); | ||
| } catch (InterruptedException e) { | ||
| Thread.currentThread().interrupt(); | ||
| return false; | ||
|
|
@@ -550,17 +558,18 @@ private void scheduleSegmentMirror(List<SessionEntry> entries, long seqStart, lo | |
| if (transcriptStore == null || transcriptRef == null || entries.isEmpty()) { | ||
| return; | ||
| } | ||
| // Same pin as scheduleMirror: async segment upload must survive call unbind. | ||
| // Prepare work before retain so a failure here cannot leak a deferred sandbox release. | ||
| final AbstractFilesystem mirrorFs = pinIfSandbox(filesystem); | ||
| final TranscriptStore store = transcriptStoreForMirror(mirrorFs); | ||
| final TranscriptRef ref = transcriptRef; | ||
| StringBuilder sb = new StringBuilder(); | ||
| for (SessionEntry entry : entries) { | ||
| sb.append(JsonUtils.getJsonCodec().toJson(entry)).append('\n'); | ||
| } | ||
| byte[] payload = sb.toString().getBytes(StandardCharsets.UTF_8); | ||
| String wid = writerId; | ||
| MIRROR_EXECUTOR.execute( | ||
| final byte[] payload = sb.toString().getBytes(StandardCharsets.UTF_8); | ||
| final String wid = writerId; | ||
| submitPinnedMirror( | ||
| mirrorFs, | ||
| () -> { | ||
| try { | ||
| store.appendSegment(ref, seqStart, seqEnd, wid, payload); | ||
|
|
@@ -584,30 +593,90 @@ private void scheduleMirror() { | |
| if (filesystem == null || workspaceRoot == null) { | ||
| return; | ||
| } | ||
| // Resolve paths before retain so retain↔execute has no prepare-side leak window. | ||
| final AbstractFilesystem mirrorFs = pinIfSandbox(filesystem); | ||
| final String contextRel = resolveRelativePath(contextFile); | ||
| final String logRel = resolveRelativePath(logFile); | ||
| MIRROR_EXECUTOR.execute( | ||
| submitPinnedMirror( | ||
| mirrorFs, | ||
| () -> { | ||
| mirrorToFilesystem(mirrorFs, contextFile, contextRel); | ||
| mirrorToFilesystem(mirrorFs, logFile, logRel); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * When {@code fs} is a call-scoped sandbox proxy with an active binding, return a pinned | ||
| * filesystem that keeps that sandbox for async uploads. Otherwise return {@code fs} as-is. | ||
| * Retains a pinned sandbox (if any), submits {@code task} to the mirror executor, and always | ||
| * pairs {@code releaseMirror} — either in the task {@code finally} or immediately when | ||
| * submission itself fails. | ||
| */ | ||
| private static AbstractFilesystem pinIfSandbox(AbstractFilesystem fs) { | ||
| if (fs instanceof SandboxAware aware) { | ||
| Sandbox sb = aware.getSandbox(); | ||
| if (sb != null) { | ||
| return new PinnedSandboxFilesystem(sb); | ||
| private static void submitPinnedMirror(AbstractFilesystem mirrorFs, Runnable task) { | ||
| final Sandbox retainedSandbox = sandboxForMirrorRetain(mirrorFs); | ||
| if (retainedSandbox != null) { | ||
| SandboxMirrorReleaseCoordinator.retain(retainedSandbox); | ||
| } | ||
| try { | ||
| MIRROR_EXECUTOR.execute( | ||
| () -> { | ||
| try { | ||
| task.run(); | ||
| } finally { | ||
| if (retainedSandbox != null) { | ||
| SandboxMirrorReleaseCoordinator.releaseMirror(retainedSandbox); | ||
| } | ||
| } | ||
| }); | ||
| } catch (RuntimeException e) { | ||
| if (retainedSandbox != null) { | ||
| SandboxMirrorReleaseCoordinator.releaseMirror(retainedSandbox); | ||
| } | ||
| throw e; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * When {@code fs} is a sandbox-backed filesystem, return a pinned filesystem that keeps the | ||
| * <em>per-call</em> sandbox for async uploads. Prefers {@link SandboxAcquireResult} on {@link | ||
| * #fsRc} (issue #2490) over the legacy shared {@link SandboxAware#getSandbox()} field, which | ||
| * is last-writer-wins under concurrent distinct sessions. | ||
| */ | ||
| private AbstractFilesystem pinIfSandbox(AbstractFilesystem fs) { | ||
| Sandbox sb = resolveSandboxForPin(fs); | ||
| if (sb != null) { | ||
| return new PinnedSandboxFilesystem(sb); | ||
| } | ||
| return fs; | ||
| } | ||
|
|
||
| /** | ||
| * Resolves the sandbox to pin for an async mirror: per-call {@link RuntimeContext} binding | ||
| * first, then the shared {@link SandboxAware} fallback for context-free callers. | ||
| */ | ||
| private Sandbox resolveSandboxForPin(AbstractFilesystem fs) { | ||
| RuntimeContext rc = fsRc; | ||
| if (rc != null) { | ||
| SandboxAcquireResult bound = rc.get(SandboxAcquireResult.class); | ||
| if (bound != null && bound.getSandbox() != null) { | ||
| return bound.getSandbox(); | ||
| } | ||
| } | ||
| if (fs instanceof SandboxAware aware) { | ||
| return aware.getSandbox(); | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Returns the sandbox pinned for an async mirror task, or {@code null} when the mirror does | ||
| * not hold a sandbox connection that must defer self-managed release. | ||
| */ | ||
| private static Sandbox sandboxForMirrorRetain(AbstractFilesystem mirrorFs) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| if (mirrorFs instanceof PinnedSandboxFilesystem pinned) { | ||
| return pinned.getSandbox(); | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| private TranscriptStore transcriptStoreForMirror(AbstractFilesystem mirrorFs) { | ||
| if (transcriptStore instanceof ObjectStoreTranscriptStore ost) { | ||
| return ost.withFilesystem(mirrorFs).withRuntimeContext(fsRc); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,7 @@ | |
| import io.agentscope.harness.agent.sandbox.SandboxAcquireResult; | ||
| import io.agentscope.harness.agent.sandbox.SandboxContext; | ||
| import io.agentscope.harness.agent.sandbox.SandboxManager; | ||
| import io.agentscope.harness.agent.sandbox.SandboxMirrorReleaseCoordinator; | ||
| import java.util.function.Consumer; | ||
| import org.slf4j.Logger; | ||
| import org.slf4j.LoggerFactory; | ||
|
|
@@ -39,10 +40,12 @@ | |
| * | ||
| * <h2>doFinally</h2> | ||
| * <ol> | ||
| * <li>Clear this call's session binding from the {@link RuntimeContext} (and the filesystem | ||
| * proxy fallback) so concurrent calls cannot observe a stale binding</li> | ||
| * <li>Persist sandbox session state via {@link SandboxManager} and | ||
| * {@link io.agentscope.harness.agent.sandbox.SessionSandboxStateStore}</li> | ||
| * <li>Release the session via {@link SandboxManager} (stop + optional shutdown)</li> | ||
| * <li>Clear this call's session binding from the {@link RuntimeContext}</li> | ||
| * <li>Request release via {@link SandboxMirrorReleaseCoordinator} (defers stop/shutdown while | ||
| * session mirrors still need the connection; otherwise releases immediately)</li> | ||
| * </ol> | ||
| * | ||
| * <p>Post-call failures (persist, release) are logged but do not propagate — this ensures | ||
|
|
@@ -51,7 +54,9 @@ | |
| * <p>The sandbox is bound <em>per call</em> on the invocation's {@link RuntimeContext} rather than | ||
| * on a shared agent-level slot: distinct {@code (userId, sessionId)} sessions run in parallel on | ||
| * the same agent bean, so a shared slot would let concurrent calls corrupt each other's binding | ||
| * (issue #2490). | ||
| * (issue #2490). {@link SandboxMirrorReleaseCoordinator#requestRelease} always closes the acquire | ||
| * result's lease ({@link io.agentscope.harness.agent.sandbox.SandboxLease#noop()} when no guard | ||
| * is configured). | ||
| */ | ||
| public class SandboxLifecycleMiddleware implements HarnessRuntimeMiddleware { | ||
|
|
||
|
|
@@ -126,14 +131,14 @@ public void acquireForCall(RuntimeContext ctx) { | |
| ctx.put(SandboxAcquireResult.class, null); | ||
| filesystemProxy.clearSandboxIfCurrent(sandbox); | ||
| try { | ||
| sandboxManager.release(result); | ||
| // No mirrors can be pending before start succeeds; release immediately. | ||
| SandboxMirrorReleaseCoordinator.requestRelease(sandboxManager, result); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moving the destructive release plus lease close behind the coordinator looks correct for the two call sites here, and the duplicate-request branch closes the extra lease explicitly. Could you confirm |
||
| } catch (Exception releaseErr) { | ||
| log.warn( | ||
| "[sandbox-mw] Failed to release session after pre-call failure: {}", | ||
| releaseErr.getMessage(), | ||
| releaseErr); | ||
| } | ||
| result.getLease().close(); | ||
| throw e; | ||
| } | ||
| } catch (Exception e) { | ||
|
|
@@ -169,10 +174,11 @@ public void releaseForCall(RuntimeContext ctx) { | |
| log.warn("[sandbox-mw] Failed to persist sandbox state: {}", e.getMessage(), e); | ||
| } | ||
| try { | ||
| sandboxManager.release(result); | ||
| // Hand destructive stop/shutdown (and lease close) to outstanding session mirrors | ||
| // when present; otherwise release immediately. Call binding is already cleared above. | ||
| SandboxMirrorReleaseCoordinator.requestRelease(sandboxManager, result); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Warning] Deferred-release race worth confirming: when call A's release is deferred behind outstanding mirrors, a call B for the same |
||
| } catch (Exception e) { | ||
| log.warn("[sandbox-mw] Failed to release sandbox session: {}", e.getMessage(), e); | ||
| } | ||
| result.getLease().close(); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Info] Nice fix draining the release executor within a shared deadline. Two small notes: (1)
awaitReleaseQuiescencesubmits an empty task, so it only waits for releases enqueued before the barrier call — releases scheduled by mirror tasks that begin executing after the submit are not covered; that matches the documented "submitted before this call" contract, just make sure graceful-shutdown callers invoke it afterawaitMirrorQuiescencereturns true (which the current call ordering does). (2) The process-wide singleton coordinator means multi-agent JVMs share one release thread — fine for teardown throughput, but a slowstop()head-of-line blocks other sessions' deferred releases; acceptable given it is off the mirror path.