Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 28 additions & 19 deletions components/src/dynamo/sglang/request_handlers/handler_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1134,15 +1134,30 @@ def _get_bootstrap_info(engine: sgl.Engine) -> Tuple[str, int]:

return bootstrap_host, bootstrap_port

def _abort_requests(self, request_ids: set[str], context: Context) -> None:
if not request_ids:
return
if not getattr(self.engine, "tokenizer_manager", None):
logging.error("SGLang tokenizer_manager not found for abort requests")
return
for request_id in request_ids:
self.engine.tokenizer_manager.abort_request(rid=request_id, abort_all=False)
request_ids.clear()
logging.info("Aborted Request ID: %s", context.id())

async def _handle_cancellation(
self, request_id_future: asyncio.Future, context: Context
self,
request_id_future: asyncio.Future,
context: Context,
request_ids: set[str] | None = None,
):
"""Background task to handle cancellation and shutdown by monitoring both signals.

Args:
request_id_future: Future that will be set with the SGLang request ID
when the first response arrives.
context: Context object for cancellation handling.
request_ids: Live set of unfinished parallel-choice IDs, when available.

Raises:
EngineShutdown: If shutdown event was triggered.
Expand Down Expand Up @@ -1188,22 +1203,9 @@ async def _handle_cancellation(
f"Cancellation or shutdown signal received for SGLang Request ID {sglang_request_id}, Context: {context.id()}"
)

# Call abort_request on the tokenizer_manager through the engine
if (
hasattr(self.engine, "tokenizer_manager")
and self.engine.tokenizer_manager
):
logging.info(
f"Calling SGLang abort_request for Request ID {sglang_request_id}"
)
self.engine.tokenizer_manager.abort_request(
rid=sglang_request_id, abort_all=False
)
logging.info(f"Aborted Request ID: {context.id()}")
else:
logging.error(
f"SGLang tokenizer_manager not found for abort request: {context.id()}"
)
self._abort_requests(
request_ids if request_ids is not None else {sglang_request_id}, context
)

# Check which event triggered and raise EngineShutdown if shutdown
if shutdown_task and shutdown_task in done:
Expand Down Expand Up @@ -1233,7 +1235,10 @@ async def _handle_cancellation(

@asynccontextmanager
async def _cancellation_monitor(
self, request_id_future: asyncio.Future, context: Context
self,
request_id_future: asyncio.Future,
context: Context,
request_ids: set[str] | None = None,
) -> AsyncGenerator[asyncio.Task, None]:
"""
Context manager for monitoring request cancellation and shutdown.
Expand All @@ -1246,6 +1251,7 @@ async def _cancellation_monitor(
request_id_future: Future that will be set with the SGLang request ID
when the first response arrives.
context: Context object for cancellation handling
request_ids: Live set of unfinished parallel-choice IDs to abort on exit.

Yields:
asyncio.Task: The cancellation monitoring task being managed
Expand All @@ -1254,12 +1260,15 @@ async def _cancellation_monitor(

# Start the cancellation monitoring task
cancellation_task = asyncio.create_task(
self._handle_cancellation(request_id_future, context)
self._handle_cancellation(request_id_future, context, request_ids)
)

try:
yield cancellation_task
finally:
# Stream closure can beat the monitor's cancellation wake-up.
if request_ids is not None:
self._abort_requests(request_ids, context)
# Clean up the background cancellation task
request_id = "unknown"
if request_id_future.done() and not request_id_future.cancelled():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -360,12 +360,25 @@ def _build_sampling_params(self, request: Dict[str, Any]) -> Dict[str, Any]:
_plain = stop_conditions.get("stop_token_ids") or []
_merged = list(set(_hidden).union(_plain))
stop_token_ids = _merged if _merged else None
# A tokenizer-free SGLang rejects positive min_new_tokens. Let
# Dynamo's decoder enforce the floor and keep the engine running so
# it cannot stop before Dynamo reaches it.
min_tokens = stop_conditions.get("min_tokens")
hold_engine_open = bool(
min_tokens
and min_tokens > 0
and self.config.server_args.skip_tokenizer_init
)
if hold_engine_open:
stop_token_ids = None

param_mapping = {
"n": sampling_opts.get("n"),
"max_new_tokens": stop_conditions.get("max_tokens"),
"min_new_tokens": stop_conditions.get("min_tokens"),
"ignore_eos": stop_conditions.get("ignore_eos"),
"min_new_tokens": None if hold_engine_open else min_tokens,
"ignore_eos": True
if hold_engine_open
else stop_conditions.get("ignore_eos"),
"stop_token_ids": stop_token_ids,
**_sampling_option_params(sampling_opts),
**self._get_guided_decoding_params(
Expand Down Expand Up @@ -691,16 +704,23 @@ async def _process_token_stream(
"""
# Use Future pattern for request ID - will be set when first response arrives
request_id_future: asyncio.Future[str] = asyncio.Future()
request_ids: set[str] = set()
first_output_seen = False
async with self._cancellation_monitor(request_id_future, context):
async with self._cancellation_monitor(request_id_future, context, request_ids):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] The pick stops two commits short of #14276. components/src/dynamo/sglang/request_handlers/llm/decode_handler.py:792 got the per-choice abort set, but _process_text_stream at line 990 did not. A cancelled n>1 text request still aborts one choice. Please also pick 21638df03d and c66ce8a852.

The two commits that landed on #14276 after this pick was cut

The pick commit c14512f3 was authored 2026-09-16T19:10Z. #14276 merged 2026-09-17T00:12Z. Three commits landed in between:

Commit Time Carried here
7ca35cee32 test(sglang): provide stop state in shutdown context stub 18:47Z yes, the pick is cut here
c66ce8a852 fix(sglang): retain context logging after request aborts 20:08Z no
21638df03d fix(sglang): abort every unfinished text-stream choice 20:23Z no

21638df03d is the functional one. It gives _process_text_stream the same treatment _process_token_stream already has here:

-        async with self._cancellation_monitor(request_id_future, context):
+        request_ids: set[str] = set()
+        async with self._cancellation_monitor(request_id_future, context, request_ids):

Without it, _cancellation_monitor falls back to {sglang_request_id}, the id of the first chunk only. SGLang gives each choice its own id under n>1: tokenizer_manager.py calls tmp_obj.regenerate_rid() once per parallel_sample_num in the SGLang 0.5.18 image. So the other choices keep generating to max_new_tokens after the client goes away.

c66ce8a852 is logging only. It adds context to _abort_requests and one logging.info line. Left as it is, handler_base.py and decode_handler.py differ from main in signature, which makes the next pick on these files conflict.

Both are still absent at head 859c8f27. Checked with gh api repos/ai-dynamo/dynamo/commits/<sha> against the head tree.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] still open at head 859c8f27, and I was wrong to call c66ce8a852 "logging only". The pick deletes logging.info(f"Aborted Request ID: {context.id()}") from components/src/dynamo/sglang/request_handlers/handler_base.py:1269 and never puts it back, so the string is absent from that file. Please pick both commits.

The deleted log line traced across refs, the runtime probe, and the test that polls for it

I re-ran this against the commit that merged on main (e5ef9d778b, a single-parent squash), not the branch head.

Count of Aborted Request ID under components/:

ref hits sglang handler_base.py
merge base 55589a5e72 4 :1269 logging.info(f"Aborted Request ID: {context.id()}")
after commit 1 74a5cef4da 4 :1269, same line
after commit 2 c14512f325 3 absent
head 859c8f2732 3 absent
release/1.5.0 tip 32b8b2f8c6 4 :1202, present
main before #14276 232f881e07 4 :1235, present
main after #14276 e5ef9d778b 4 :1179 logging.info("Aborted Request ID: %s", context.id())

#14276 removed the old line and re-added it inside _abort_requests in c66ce8a852. The pick took the removal and not the re-add. The hunk in c14512f325:

-                self.engine.tokenizer_manager.abort_request(
-                    rid=sglang_request_id, abort_all=False
-                )
-                logging.info(f"Aborted Request ID: {context.id()}")
+            self._abort_requests(
+                request_ids if request_ids is not None else {sglang_request_id}

Runtime probe in the sglang runtime-test image, PR tree at /tmp/work, module path printed to prove which tree loaded:

### MODULE PATH PROOF: /tmp/work/components/src/dynamo/sglang/request_handlers/handler_base.py
signature args : (self, request_ids: set[str]) -> None
emits 'Aborted Request ID' : False
'Aborted Request ID' anywhere in handler_base.py : False

tests/fault_tolerance/cancellation/test_sglang.py:290 and :409 poll for f"Aborted Request ID: {request_id}". Both are marked gpu_2 and pre_merge, and the lane sglang-runtime / 2-GPU Test cuda13.0, amd64 selects them with pre_merge and sglang and gpu_2. I collected that expression against this tree and got 5 tests, 2 of them from that file. Both failed on this head:

FAILED tests/fault_tolerance/cancellation/test_sglang.py::test_request_cancellation_sglang_decode_cancel[nats] - AssertionError: Failed to find 'Aborted Request ID: bbb351ad-1046-4b28-ab01-062333fa31f6' pattern after 100 iterations (500ms)
FAILED tests/fault_tolerance/cancellation/test_sglang.py::test_request_cancellation_sglang_decode_cancel[tcp] - AssertionError: Failed to find 'Aborted Request ID: 9d87c48e-841d-45aa-af1c-f4e21e908c4c' pattern after 100 iterations (500ms)

21638df03d is still missing too. _process_text_stream at decode_handler.py:990 still calls self._cancellation_monitor(request_id_future, context) with two arguments, while _process_token_stream at :792 passes request_ids.

Per-file comparison of the pick against the main squash, added and removed lines checksummed on each side:

file added main added pick removed main removed pick
handler_base.py 28 (12323242) 27 (a223981d) 19 (51548e4e) 19 (51548e4e)
decode_handler.py 38 (b2312646) 28 (559bc111) 12 (d4e1fdb3) 7 (55e00c9d)
test_sglang_decode_handler.py 57 (2df75e94) 57 (2df75e94) 11 (5a2608ff) 11 (5a2608ff)
test_stop_behavior.rs 7 (f9624810) 7 (f9624810) 1 (7e098327) 1 (7e098327)

The two test files are byte-identical to main. The two source files are not. The empty-string control for this checksum is da39a3ee, and no cell above carries it.

No unit test pins either gap. Mutating _process_text_stream to apply 21638df03d left the suite at 111 passed, and the control mutation that strips request_ids from _process_token_stream also left it at 111 passed. Both mutations landed: baseline sha 1ee20a96fb26, mutated d504aafd4da4 and 416258669c93, restored 1ee20a96fb26 both times. The liveness control proves the suite can fail: disabling the hold_engine_open gate gave 1 failed, 110 passed on test_build_sampling_params_holds_tokenizer_free_engine_open_for_min_tokens.

Not verified: a live multi-choice cancellation on two GPUs. This box has one.

async for res in stream_source:
meta_info = res.get("meta_info", {})
# Extract SGLang request ID from the first response and set the future
if not request_id_future.done():
sglang_request_id = meta_info.get("id")
if sglang_request_id:
sglang_request_id = meta_info.get("id")
if sglang_request_id:
request_ids.add(sglang_request_id)
if not request_id_future.done():
request_id_future.set_result(sglang_request_id)
logging.debug(f"New SGLang Request ID: {sglang_request_id}")
if meta_info.get("finish_reason"):
request_ids.discard(sglang_request_id)
Comment on lines +718 to +719

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain shared request IDs until every choice finishes

For n > 1, SGLang chunks can share one meta_info["id"] while carrying distinct choice indexes, as the existing fixtures in test_sglang_decode_handler.py:1218-1230 demonstrate. Discarding that ID when any one choice reports a finish leaves request_ids empty while other choices are still running; if the client cancels or closes the stream in that interval, both the monitor and context-manager cleanup abort nothing, allowing the remaining generation to continue orphaned. Track finished choice indexes and remove the shared ID only after all choices complete.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The premise does not hold on the engine this branch pins, so I do not think there is anything to fix here.

SGLang 0.5.18 gives each choice its own id under n > 1

release/1.5.0 pins lmsysorg/sglang:v0.5.18-cu130-runtime at container/context.yaml:123. In that image, python/sglang/srt/managers/tokenizer_manager.py takes the parallel_sample_num > 1 branch and calls tmp_obj.regenerate_rid() once per choice before sending each one:

for i in range(batch_size):
    for _ in range(obj.parallel_sample_num):
        ...
        tokenized_obj.rid = tmp_obj.regenerate_rid()
        self._send_one_request(tokenized_obj)

Each choice therefore carries a distinct meta_info["id"], and request_ids.discard(sglang_request_id) removes only the choice that reported the finish. The shared "id": "request-1" in the fixtures you cite is a test artifact, not engine behavior.

What I did not verify: a live multi-choice generation on a GPU. This is read from the pinned image source plus the parameter path, not from a served request.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-checked this round, and the premise still does not hold. The engine pin has not moved, so each choice still gets its own identifier and there is nothing to fix here.

The pin, read at three refs

container/context.yaml holds runtime_image: lmsysorg/sglang with runtime_image_tag: v0.5.18-cu130-runtime at all three of:

ref line
head 859c8f2732 container/context.yaml:123
merge base 55589a5e72 container/context.yaml:123
release/1.5.0 tip 32b8b2f8c6 container/context.yaml:126

The release branch changed container/context.yaml since the merge base, but the only change is three added lines under vllm.xpu, so the sglang tag shifts down by three lines and keeps its value. A tag change would revive this, so it is worth re-reading if the pin ever moves.

if context.is_stopped():
# A choice's first chunk can arrive after the monitor fired.
self._abort_requests(request_ids, context)
continue

# Check cancellation before yielding to allow proper cleanup.
# This lets SGLang proceed to the second token generation, which will
Expand Down Expand Up @@ -829,16 +849,22 @@ async def _process_text_stream(

# Use Future pattern for request ID - will be set when first response arrives
request_id_future: asyncio.Future[str] = asyncio.Future()
request_ids: set[str] = set()
first_output_seen = False
async with self._cancellation_monitor(request_id_future, context):
async with self._cancellation_monitor(request_id_future, context, request_ids):
async for res in stream_source:
meta_info = res.get("meta_info", {})
# Extract SGLang request ID from the first response and set the future
if not request_id_future.done():
sglang_request_id = meta_info.get("id")
if sglang_request_id:
sglang_request_id = meta_info.get("id")
if sglang_request_id:
request_ids.add(sglang_request_id)
if not request_id_future.done():
request_id_future.set_result(sglang_request_id)
logging.debug(f"New SGLang Request ID: {sglang_request_id}")
if meta_info.get("finish_reason"):
request_ids.discard(sglang_request_id)
if context.is_stopped():
self._abort_requests(request_ids, context)
continue

# Check cancellation before yielding to allow proper cleanup.
# This lets SGLang proceed to the second token generation, which will
Expand Down
133 changes: 121 additions & 12 deletions components/src/dynamo/sglang/tests/test_sglang_decode_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
# SPDX-License-Identifier: Apache-2.0

import asyncio
from contextlib import asynccontextmanager
from contextlib import aclosing, asynccontextmanager
from types import SimpleNamespace
from unittest.mock import Mock, call

import pytest

Expand Down Expand Up @@ -239,12 +240,20 @@ def test_openai_stop_sampling_params_maps_token_id_stop_array():
}


def _new_decode_handler(*, use_sglang_tokenizer: bool = False, enable_rl: bool = False):
def _new_decode_handler(
*,
use_sglang_tokenizer: bool = False,
skip_tokenizer_init: bool = False,
enable_rl: bool = False,
):
handler = DecodeWorkerHandler.__new__(DecodeWorkerHandler)
handler.shutdown_event = None
handler.use_sglang_tokenizer = use_sglang_tokenizer
handler.config = SimpleNamespace(
server_args=SimpleNamespace(served_model_name="test-model"),
server_args=SimpleNamespace(
served_model_name="test-model",
skip_tokenizer_init=skip_tokenizer_init,
),
dynamo_args=SimpleNamespace(enable_rl=enable_rl),
)
handler._first_token_source = None
Expand All @@ -257,6 +266,68 @@ async def no_cancellation_monitor(*args, **kwargs):
return handler


@pytest.mark.asyncio
@pytest.mark.parametrize(
"processor_name", ["_process_token_stream", "_process_text_stream"]
)
@pytest.mark.parametrize("cancel_from_client", [False, True], ids=["close", "cancel"])
async def test_stream_cancellation_aborts_unfinished_choices(
processor_name, cancel_from_client, caplog
):
handler = _new_decode_handler()
# Exercise the real monitor, including closure before its task can run.
del handler._cancellation_monitor
cancellation = asyncio.get_running_loop().create_future()
first_abort = asyncio.Event()
abort_request = Mock(side_effect=lambda **_kwargs: first_abort.set())
handler.engine = SimpleNamespace(
tokenizer_manager=SimpleNamespace(abort_request=abort_request)
)
context = SimpleNamespace(
id=lambda: "context-request-id",
is_stopped=cancellation.done,
async_killed_or_stopped=lambda: cancellation,
notify_first_token=lambda: None,
)
caplog.set_level("INFO")

async def stream():
for index in range(4):
if index == 3:
# A new choice can first appear after the monitor has aborted
# the choices it already knows about.
await first_abort.wait()
yield {
"index": index,
"text": "hello",
"output_ids": [11],
"meta_info": {
"id": f"choice-{index}",
"finish_reason": {"type": "length"} if index == 2 else None,
},
}

async with aclosing(getattr(handler, processor_name)(stream(), context)) as output:
for _ in range(3):
await anext(output)
abort_request.assert_not_called()
if cancel_from_client:
cancellation.set_result(None)
with pytest.raises(StopAsyncIteration):
await asyncio.wait_for(anext(output), timeout=5)

expected_ids = ["choice-0", "choice-1"]
if cancel_from_client:
expected_ids.append("choice-3")
abort_request.assert_has_calls(
[call(rid=request_id, abort_all=False) for request_id in expected_ids],
any_order=True,
)
assert abort_request.call_count == len(expected_ids)
# Preserve the context-ID log consumed by the GPU cancellation tests.
assert "Aborted Request ID: context-request-id" in caplog.messages


@pytest.mark.asyncio
@pytest.mark.parametrize(
"processor_name", ["_process_token_stream", "_process_text_stream"]
Expand All @@ -265,7 +336,7 @@ async def test_shutdown_abort_chunk_raises_engine_shutdown(processor_name):
handler = _new_decode_handler()
handler.shutdown_event = asyncio.Event()
handler.shutdown_event.set()
context = SimpleNamespace(id=lambda: "request-id")
context = SimpleNamespace(id=lambda: "request-id", is_stopped=lambda: False)

async def stream():
yield {
Expand Down Expand Up @@ -747,23 +818,55 @@ def test_build_sampling_params_maps_guided_decoding_to_regex():
assert sampling_params["regex"] == r"\{\}"


def test_build_sampling_params_maps_min_tokens_for_token_requests():
handler = _new_decode_handler(use_sglang_tokenizer=False)
def test_build_sampling_params_holds_tokenizer_free_engine_open_for_min_tokens():
handler = _new_decode_handler(
use_sglang_tokenizer=False,
skip_tokenizer_init=True,
)

sampling_params = handler._build_sampling_params(
{
"sampling_options": {},
"stop_conditions": {
"max_tokens": 64,
"min_tokens": 64,
"ignore_eos": True,
"min_tokens": 8,
"stop_token_ids": [7],
"stop_token_ids_hidden": [151643],
},
}
)

assert sampling_params["min_new_tokens"] == 64
assert sampling_params["max_new_tokens"] == 64
assert "min_new_tokens" not in sampling_params
assert "stop_token_ids" not in sampling_params
assert sampling_params["ignore_eos"] is True
assert sampling_params["max_new_tokens"] == 64

from sglang.srt.sampling.sampling_params import SamplingParams

SamplingParams(**sampling_params).normalize(tokenizer=None)


def test_build_sampling_params_forwards_min_tokens_when_engine_has_tokenizer():
handler = _new_decode_handler(
use_sglang_tokenizer=False,
skip_tokenizer_init=False,
)

sampling_params = handler._build_sampling_params(
{
"sampling_options": {},
"stop_conditions": {
"max_tokens": 64,
"min_tokens": 8,
"stop_token_ids": [7],
},
}
)

assert sampling_params["min_new_tokens"] == 8
assert sampling_params["stop_token_ids"] == [7]
assert "ignore_eos" not in sampling_params
assert sampling_params["max_new_tokens"] == 64


def test_build_sampling_params_maps_min_tokens_for_sglang_tokenizer_requests():
Expand All @@ -778,7 +881,10 @@ def test_build_sampling_params_maps_min_tokens_for_sglang_tokenizer_requests():


def test_build_sampling_params_omits_min_new_tokens_when_min_tokens_absent():
handler = _new_decode_handler(use_sglang_tokenizer=False)
handler = _new_decode_handler(
use_sglang_tokenizer=False,
skip_tokenizer_init=True,
)

sampling_params = handler._build_sampling_params(
{"sampling_options": {}, "stop_conditions": {"max_tokens": 8}}
Expand All @@ -788,7 +894,10 @@ def test_build_sampling_params_omits_min_new_tokens_when_min_tokens_absent():


def test_build_sampling_params_forwards_explicit_zero_min_tokens():
handler = _new_decode_handler(use_sglang_tokenizer=False)
handler = _new_decode_handler(
use_sglang_tokenizer=False,
skip_tokenizer_init=True,
)

sampling_params = handler._build_sampling_params(
{"sampling_options": {}, "stop_conditions": {"max_tokens": 8, "min_tokens": 0}}
Expand Down
2 changes: 2 additions & 0 deletions container/deps/requirements.test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ mistral-common>=1.11.2
mypy==1.18.2
# For NATS object store verification in router tests
nats-py==2.12.0
# Router benchmark tests import graph synthesis during whole-tree collection.
networkx
openai==2.32.0 # for Fault Tolerance migration tests
pandas-stubs>=1.3.0
psutil<=7.0.0 # System package, may vary by platform (was >=5.0.0)
Expand Down
1 change: 0 additions & 1 deletion deploy/operator/docs/footer.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,6 @@ For users who want to understand the implementation details or contribute to the
- **Checkpoint / Restore**:
- [`internal/checkpoint/podspec.go`](https://github.com/ai-dynamo/dynamo/blob/main/deploy/operator/internal/checkpoint/podspec.go) - Checkpoint env var injection and volume setup
- [`internal/checkpoint/resolve.go`](https://github.com/ai-dynamo/dynamo/blob/main/deploy/operator/internal/checkpoint/resolve.go) - Checkpoint resolution logic
- [`internal/checkpoint/resource.go`](https://github.com/ai-dynamo/dynamo/blob/main/deploy/operator/internal/checkpoint/resource.go) - Checkpoint resource management
- **Constants & Annotations**: [`internal/consts/consts.go`](https://github.com/ai-dynamo/dynamo/blob/main/deploy/operator/internal/consts/consts.go) - Defines annotation keys and other constants

## Notes
Expand Down
Loading
Loading