Skip to content

fix(sandbox): keep a stopped run from starting or touching the runner's next run - #164

Merged
ttbombadil merged 1 commit into
mainfrom
fix/runner-run-generation
Oct 3, 2026
Merged

ttbombadil merged 1 commit into
mainfrom
fix/runner-run-generation

Conversation

@ttbombadil

Copy link
Copy Markdown
Collaborator

Purpose

R3a of the refactoring series (docs/UNOSIM_REFACTORING_OPL.md), audit finding S4, plus S4-CHILD found while verifying it.

Findings – reproduced deterministically

  • S4: The ExecutionState is reused across runs of a pooled runner and had no run identity.
    • Scenario: user A waits for a sandbox-start slot → A stops → the pool hands the same runner to user B → B also queues → the slot frees.
    • A's waiter (FIFO) passed its post-await guard, which only looked at the shared state, now B's STARTING. A started the sandbox with its own, already cleaned-up sketch directory, on B's session and batchers.
    • The test failed with expected '<A's removed sketch dir>' to contain 'RUN_B'.
  • S4-CHILD: ProcessController forwarded stdout/stderr/close/error of every child to whatever listeners the controller held at the time. A replaced child's late output and close reached the next run (expected 'OLD-CHILD\n' to be 'NEW-CHILD\n').

Change

  • ExecutionState.runGeneration / runAbort (optional fields). runSketch starts a run token; every step after an await checks run.isStale(). A stale run:
    • cleans up only its own sketch directory,
    • reports nothing to callbacks,
    • leaves state, sockets and the successor's container alone.
  • currentSketchDir and currentContainerName are set only after the run has proven it is current; the container name is set after the slot is granted.
  • SandboxStartSemaphore.acquire(onQueued, timeoutMs, signal): an aborted waiter leaves the queue and never takes a slot.
  • SandboxRunner.stop() aborts the current run first.
  • ProcessController forwarders ignore events from a child that is no longer the current one. this.proc is only reassigned by spawn, so the current child's events are unaffected.

Remaining window (documented in the OPL): stop() landing exactly during the millisecond-scale spawn call. Orphan cleanup (R4a) covers it. Reset ownership in the runner is the separate follow-up R3b.

Tests

  • RED → GREEN: tests/server/services/sandbox/runner-reuse-race.test.ts (real pool, runner, execution manager and semaphore; only the child process and the Docker CLI are faked). It asserts:
    • exactly one sandbox start, with B's sketch;
    • B's serial output reaches only B;
    • runA resolves false;
    • no docker rm touches B.
    • Verified RED without the ExecutionManager change.
  • docker-compile-semaphore-abort.test.ts, process-controller-stale-child.test.ts (both RED before).
  • npm run check, ESLint, unit 2714 passed, Docker integration 26/26 locally against real sandbox containers, pre-push incl. Sonar quality gate PASSED.

🤖 Generated with Claude Code

…'s next run

A start waiting for a sandbox slot survived stop(); when the pool handed the
runner to the next user, the stale run started first, with its own removed
sketch directory, on the next user's session. Runs now carry a generation and
an abort signal, the slot wait is cancellable, and the process controller drops
events of replaced children.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ttbombadil
ttbombadil merged commit 0042bc0 into main Oct 3, 2026
5 checks passed
@ttbombadil
ttbombadil deleted the fix/runner-run-generation branch October 3, 2026 21:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant