Skip to content

Untrusted execution timeout wedges the replica: a stale SystemExit kills the shared asyncio executor thread #284

Description

@JadenFiotto-Kaufman

Summary

When an untrusted request hits the execution timeout, the replica never becomes usable again. Every later request to that replica hangs with no response and no error, indefinitely. The trusted path recovers cleanly from the same blocks in well under a second.

ndif kill on the wedged request reports success and returns the queue counters to Executing: 0, Queued: 0, but this frees only the queue's bookkeeping — the replica still does not serve requests afterwards. There is currently no known operator remedy short of restarting the replica.

Found while testing execution-timeout recovery on the Test stack ahead of the prod release.

Reproduction

Test stack, NDIF_DEFAULT_EXECUTION_TIMEOUT_SECONDS=20 on the Ray head, openai-community/gpt2 pinned as replica fab50 (SandboxModelActor), sandbox pool size 2.

Run a block that outlives the timeout, then immediately send a normal trace to the same replica:

# times out
with m.trace("hello", remote=True):
    import time as _t
    _t.sleep(60)
    m.transformer.h[0].output[0].save()

# recovery probe
with m.trace("The Eiffel Tower is in the city of", remote=True):
    lg = m.lm_head.output.save()

Observed

path       block     first request              recovery
---------  --------  -------------------------  --------------------------
trusted    sleep      21.5s  "exceeded ... 20.0s"   0.8s  OK
trusted    pyloop     20.3s  "exceeded ... 20.0s"   0.5s  OK
untrusted  sleep      20.3s  "exceeded ... 20.0s"   HUNG (>18 min)

After the untrusted case, ndif queue showed the timed-out request still executing long past its own timeout:

⚙ [fab50] executing b40ba757… (for 0:18:50)

with the recovery request queued behind it.

ndif kill b40ba757… returned ✓ Request b40ba757… cancelled while executing. and the queue went to Executing: 0, Queued: 0. However, a plain gpt2 trace submitted afterwards also hung indefinitely (>6 min, no RUNNING→COMPLETED, no error). So the kill restores the queue's view but not the replica.

Evidence gathered

  • Loki: the request trail ends at "Your job has started running." — no timeout line, no error line, nothing after.
  • ray.util.state.list_tasks: one SandboxModelActor.run in RUNNING, the rest FINISHED — a run() coroutine that never returned.
  • No runner subprocess alive on the GPU worker, so Sandbox.stop() did its job.
  • ReplicaState.dispatch() has finally: self.current_request = None, so it had not returned either.
  • py-spy on the actor: no ndif/nnsight frames on any thread. Consistent with a coroutine suspended at an await (py-spy cannot see those) rather than a thread spinning.

Notes on the mechanism (not confirmed)

run() has only two await points, and nothing after the timeout branch awaits:

  • await asyncio.to_thread(self.model._remoteable_set_env, request.env)base.py:350
  • await asyncio.wait({job, kill}, timeout=self.execution_timeout, ...)base.py:357

Cancelling job at base.py:362-363 does not stop the underlying thread — asyncio.to_thread wraps a real thread, and cancelling the task only abandons it. That thread is inside SandboxDriver.pump(connection), and for the untrusted path the host's model forward is parked mid-forward in a greenlet belonging to that thread.

cleanup() then runs on the event loop thread and calls self.model.interleaver.cancel() (base.py:656), which throws GreenletExit into parked workers — greenlets owned by the abandoned execution thread, not this one. It also calls torch.cuda.synchronize() on the event loop thread.

Candidates worth checking, in order:

  1. The abandoned execute thread is never joined, and the next request's await asyncio.to_thread(...) (or something that thread still holds — the greenlet-parked forward, the interleaver, a pool slot) blocks on it.
  2. interleaver.cancel() reaching cross-thread into greenlets it does not own.
  3. torch.cuda.synchronize() on the event loop thread while an abandoned thread still has work in flight.

Ruled out as the blocking point:

  • Sandbox.stop() — bounded: terminate(), wait(timeout=5), kill(), wait() (host.py:134-143).
  • Pool.acquire() — bounded at 30 s, spawns if drained (host.py:278).
  • Interleaver.cancel() raising on worker=None for ndif's MediatorProxy — it catches BaseException and downgrades to a warning, so it cannot propagate or hang there.

Still to establish

Whether the wedge also happens for an untrusted block that stays in Python bytecode (a while loop) or one issuing repeated small CUDA ops, versus only one blocked in a C call like time.sleep. Both attempts at the bytecode-loop case were themselves swallowed by the still-wedged replica from the sleep case, so this is untested rather than negative.

Why this matters for the release

Untrusted is the path real users take. One user submitting a block that runs long takes the replica out until someone notices and restarts it, and the queue's own counters say everything is fine.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions