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:
- 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.
interleaver.cancel() reaching cross-thread into greenlets it does not own.
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.
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 killon the wedged request reports success and returns the queue counters toExecuting: 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=20on the Ray head,openai-community/gpt2pinned as replicafab50(SandboxModelActor), sandbox pool size 2.Run a block that outlives the timeout, then immediately send a normal trace to the same replica:
Observed
After the untrusted case,
ndif queueshowed the timed-out request still executing long past its own timeout:with the recovery request queued behind it.
ndif kill b40ba757…returned✓ Request b40ba757… cancelled while executing.and the queue went toExecuting: 0, Queued: 0. However, a plaingpt2trace 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
"Your job has started running."— no timeout line, no error line, nothing after.ray.util.state.list_tasks: oneSandboxModelActor.runin RUNNING, the rest FINISHED — arun()coroutine that never returned.Sandbox.stop()did its job.ReplicaState.dispatch()hasfinally: self.current_request = None, so it had not returned either.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:350await asyncio.wait({job, kill}, timeout=self.execution_timeout, ...)— base.py:357Cancelling
jobat base.py:362-363 does not stop the underlying thread —asyncio.to_threadwraps a real thread, and cancelling the task only abandons it. That thread is insideSandboxDriver.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 callsself.model.interleaver.cancel()(base.py:656), which throwsGreenletExitinto parked workers — greenlets owned by the abandoned execution thread, not this one. It also callstorch.cuda.synchronize()on the event loop thread.Candidates worth checking, in order:
executethread is never joined, and the next request'sawait asyncio.to_thread(...)(or something that thread still holds — the greenlet-parked forward, the interleaver, a pool slot) blocks on it.interleaver.cancel()reaching cross-thread into greenlets it does not own.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 onworker=Nonefor ndif'sMediatorProxy— it catchesBaseExceptionand 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
whileloop) or one issuing repeated small CUDA ops, versus only one blocked in a C call liketime.sleep. Both attempts at the bytecode-loop case were themselves swallowed by the still-wedged replica from thesleepcase, 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.