[Test] Consolidate test cleanup and CI taxonomy (net -11.4K lines) (#37436)

Co-authored-by: Mick Qian <mickqian@users.noreply.github.com>
This commit is contained in:
Xiaoyu Zhang
2026-09-07 15:13:59 +08:00
committed by GitHub
co-authored by Mick Qian
parent 6a1ff90f2d
commit 4d23a4fa6d
199 changed files with 1185 additions and 11812 deletions
@@ -1,765 +0,0 @@
"""`ModelConfig` is built from values resolution has already decided.
Resolution builds a `ModelConfig` partway through and keys later decisions off
it, so the pipeline reads its own output through that object. The loop is only
benign while every field `ModelConfig.from_server_args` reads has been resolved
by the time it is built -- otherwise the model configuration describes a
half-resolved input, and every handler downstream of it inherits that.
Nothing enforces the ordering today; it holds because the path and quantization
handlers happen to run early. So this derives both sides from the source -- the
fields the constructor reads, and the step each is declared at -- and pins the
one field that is deliberately read before resolution touches it.
"""
import ast
import functools
import pathlib
import unittest
import sglang
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=17, suite="base-a-test-cpu")
_SRT = pathlib.Path(sglang.__file__).resolve().parent / "srt"
# Read for what the caller asked for: the constructor passes it through and
# never stores it, while resolution declares the value the architecture implies.
# Two quantities sharing one name.
_READ_BEFORE_RESOLUTION = frozenset({"is_embedding"})
# Declared after the first `model_config_of()`, so the cached configuration
# holds the earlier value. Nothing reads the stale copy today (its one consumer
# is on the `is_draft_model` branch, built after resolution), and fixing it
# means moving the build or the hook. Pinned so a second field in this position
# has to be looked at.
_STALE_IN_THE_MODEL_CONFIG = frozenset({"speculative_algorithm"})
# Behind the expert-pack build. `expert_pack_hook.handle_expert_pack` builds a
# model configuration, and it always did -- the walk stopped at the record's
# file and never saw it, so these three read as decided before the first build.
# The call sits behind `load_format != "expert_pack": return`, so it is the
# first build only on an expert-pack launch. Pre-existing; named rather than
# fixed, because fixing it means moving the build or the hook.
_STALE_BEHIND_THE_EXPERT_PACK_BUILD = frozenset(
{
"_speculative_draft_quantization_explicitly_set",
"model_path",
"speculative_draft_model_quantization",
}
)
# The same staleness through the registries: `_handle_model_specific_adjustments`
# builds the model configuration and *then* collects the override declarations,
# both inside one handler body. Named rather than fixed (that means moving the
# build or the collection), so a fifth field here has to be looked at -- and so
# does fixing the ordering.
_STALE_FROM_THE_REGISTRIES = frozenset(
{
"disable_hybrid_swa_memory",
"dtype",
"enable_multi_layer_eagle",
"quantization",
}
)
@functools.lru_cache(maxsize=None)
def _parsed(path):
return ast.parse(path.read_text(encoding="utf-8-sig"))
@functools.lru_cache(maxsize=None)
def _declared_resolution_fields(path):
fields = set()
for node in ast.walk(_parsed(path)):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "declare_resolution"
):
fields |= {kw.arg for kw in node.keywords if kw.arg}
return frozenset(fields)
def _registry_declared_fields():
"""What the live registries and passes declare.
Imported from the chain ratchet by path instead of re-derived: two
derivations of the same set drift, and the one that drifts narrower makes
this check quietly vacuous. Keying on `self._declare(...)` alone is what
hid these four -- 26 of the providers register through a helper call, and
none of them spell a keyword this file can see.
"""
import importlib.util
ratchet = (
pathlib.Path(__file__).resolve().parent.parent / "test_chain_read_ratchet.py"
)
spec = importlib.util.spec_from_file_location("_chain_ratchet_for_pin", ratchet)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module._declared_by_registry_and_passes()
def _registry_collection_is_after_the_build():
"""(collection line, first build line) inside the model-specific handler.
Handler-local ordering only -- the caller still has to compare against the
pipeline-wide first build, which sits in an *earlier* step: hoisting the
collection above this handler's own `model_config_of()` call does not move
it above the configuration another handler already cached.
"""
handler = None
for source, wanted in (
(_SRT / "server_args.py", "_handle_model_specific_adjustments"),
*(
(path, "handle_model_specific_adjustments")
for path in sorted((_SRT / "arg_groups").glob("*.py"))
),
):
for node in ast.walk(_parsed(source)):
if isinstance(node, ast.FunctionDef) and node.name == wanted:
if any(
isinstance(child, ast.Call)
and getattr(child.func, "attr", getattr(child.func, "id", None))
== "collect_model_override_declarations"
for child in ast.walk(node)
):
handler = node
break
if handler is not None:
break
assert handler is not None, "the model-specific handler was not found"
build = collect = None
for node in ast.walk(handler):
if not isinstance(node, ast.Call):
continue
# Both spellings: an Attribute call and a bare Name call.
func = node.func
if isinstance(func, ast.Attribute):
name = func.attr
elif isinstance(func, ast.Name):
name = func.id
else:
continue
if name == "model_config_of" and build is None:
build = node.lineno
if name == "collect_model_override_declarations" and collect is None:
collect = node.lineno
return collect, build
def _server_args_names(tree, path):
"""Every local that names the record, including the read views over it.
A resolution-time reader reads through `resolving_view(server_args)` (the
declaration stash over the fields): declaration-only resolvers write no
field, so a field read there answers with the raw input. `cfg.dtype` after `cfg = resolving_view(sa)` is
the same read this scan is looking for, so the local it binds counts.
"""
names = {"self"} if path.name == "server_args.py" else {"server_args"}
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
args = node.args
for arg in args.posonlyargs + args.args + args.kwonlyargs:
annotation = arg.annotation
if isinstance(annotation, ast.Constant):
text = annotation.value
elif isinstance(annotation, ast.Name):
text = annotation.id
elif isinstance(annotation, ast.Attribute):
text = annotation.attr
else:
continue
if text == "ServerArgs":
names.add(arg.arg)
# `cfg = resolving_view(server_args)` / `resolved_view(server_args)`
for _ in range(2): # a view over a view-holding local is still one
for node in ast.walk(tree):
if not isinstance(node, ast.Assign):
continue
value = node.value
bare = (
isinstance(value, ast.Call)
and isinstance(value.func, ast.Name)
and value.func.id in ("resolving_view", "resolved_view")
and value.args
and isinstance(value.args[0], ast.Name)
and value.args[0].id in names
)
# `resolved = self._resolved()` is the same view, spelled as the
# resolution vocabulary.
member = (
isinstance(value, ast.Call)
and isinstance(value.func, ast.Attribute)
and isinstance(value.func, ast.Name)
and value.func.id == "resolved_view"
and isinstance(value.func.value, ast.Name)
and value.func.value.id in names
)
if not (bare or member):
continue
names |= {t.id for t in node.targets if isinstance(t, ast.Name)}
return names
def _constructor_reads():
"""Fields `ModelConfig.from_server_args` takes off the record."""
path = _SRT / "configs/model_config.py"
tree = _parsed(path)
constructor = next(
node
for node in ast.walk(tree)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.name == "from_server_args"
)
names = _server_args_names(tree, path)
reads = {
node.attr
for node in ast.walk(constructor)
if isinstance(node, ast.Attribute)
and isinstance(node.value, ast.Name)
and node.value.id in names
and isinstance(node.ctx, ast.Load)
}
# `getattr(server_args, "field", default)` is the normal spelling for an
# optional input and is a `Call`, not an `Attribute`.
for node in ast.walk(constructor):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "getattr"
and len(node.args) >= 2
and isinstance(node.args[0], ast.Name)
and node.args[0].id in names
and isinstance(node.args[1], ast.Constant)
and isinstance(node.args[1].value, str)
):
reads.add(node.args[1].value)
return reads
def _late_resolution_fields():
"""Fields written through `_late_resolution` / `declare_late_resolution`.
All of them land after the model configuration is built: the launcher's
validation stage runs long after `__post_init__`.
"""
fields = set()
for name in (
"server_args.py",
"arg_groups/overrides.py",
"parser/template_detection.py",
):
path = _SRT / name
# A named file that moved away has to be loud; skipping it silently
# leaves the scan believing it read a module it never opened.
assert path.exists(), f"{name} is not where this scan looks for it"
tree = _parsed(path)
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
called = (
node.func.attr
if isinstance(node.func, ast.Attribute)
else getattr(node.func, "id", "")
)
if called == "declare_late_resolution":
fields |= {kw.arg for kw in node.keywords if kw.arg}
return fields
def _hook_declarations(dispatch, source_module):
"""{field: dispatcher line} for hooks the dispatch calls on other objects.
`handle_speculative_decoding(self)` is not a `self.<handler>()` call, so a
scan of the dispatcher's own method calls never reaches its
`declare_resolution` sites -- and the speculative hooks decide
`speculative_algorithm`, which the model configuration reads.
The platform hook is *not* covered here: it reaches the pipeline as a
callback argument, so there is no call node to follow and its writes live
outside this tree. Its position is pinned instead --
`test_every_opaque_callback_is_still_late`.
"""
imported = {}
for node in ast.walk(_parsed(source_module)):
if isinstance(node, ast.ImportFrom) and node.module:
for alias in node.names:
imported[alias.asname or alias.name] = node.module
out = {}
for node in ast.walk(dispatch):
if not isinstance(node, ast.Call):
continue
name = (
node.func.id
if isinstance(node.func, ast.Name)
else (node.func.attr if isinstance(node.func, ast.Attribute) else None)
)
module = imported.get(name)
if not module or not module.startswith("sglang.srt."):
continue
path = _SRT / (module[len("sglang.srt.") :].replace(".", "/") + ".py")
if not path.exists():
continue
for field in _declared_resolution_fields(path):
out[field] = max(out.get(field, 0), node.lineno)
return out
# The dispatcher's own file: its imports are what map a bare-name call in it
# to the family that defines the callable.
_DISPATCH_MODULE = _SRT / "arg_groups" / "pipeline.py"
def _hook_functions():
"""Module-level resolution functions under `arg_groups/`.
A handler that moved out of the record leaves a slot behind that imports
one of these and calls it. Without following that hop the scan stops at
the slot and silently loses everything the handler does.
"""
functions = {}
for path in sorted((_SRT / "arg_groups").glob("*.py")):
for node in _parsed(path).body:
if isinstance(node, ast.FunctionDef):
functions.setdefault(node.name, node)
return functions
def _pipeline():
"""(ordered steps, {step: methods it reaches}) for the resolution dispatch."""
tree = _parsed(_SRT / "server_args.py")
record = next(
node
for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == "ServerArgs"
)
methods = {
node.name: node for node in record.body if isinstance(node, ast.FunctionDef)
}
# The dispatcher calls its hooks by bare name, so the walk resolves those
# against `arg_groups/` alongside the record's own methods.
hooks = _hook_functions()
methods.update({name: node for name, node in hooks.items() if name not in methods})
dispatch = methods["run_resolution_pipeline"]
# A step is either a record method (`self._x()`) or a bare-name hook call.
steps = [
name
for _line, name in sorted(
(
node.lineno,
(
node.func.attr
if isinstance(node.func, ast.Attribute)
else node.func.id
),
)
for node in ast.walk(dispatch)
if isinstance(node, ast.Call)
and (
(
isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "self"
)
or (isinstance(node.func, ast.Name) and node.func.id in hooks)
)
)
]
def reaches(name, seen=None):
seen = seen if seen is not None else set()
if name in seen or name not in methods:
return seen
seen.add(name)
for node in ast.walk(methods[name]):
if not isinstance(node, ast.Call):
continue
if (
isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "self"
and node.func.attr in methods
):
reaches(node.func.attr, seen)
elif isinstance(node.func, ast.Name) and node.func.id in hooks:
reaches(node.func.id, seen)
return seen
step_lines = {}
for node in ast.walk(dispatch):
if not isinstance(node, ast.Call):
continue
if (
isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "self"
):
step_lines.setdefault(node.func.attr, node.lineno)
elif isinstance(node.func, ast.Name) and node.func.id in hooks:
step_lines.setdefault(node.func.id, node.lineno)
return steps, methods, {name: reaches(name) for name in steps}, step_lines
def _opaque_callback_positions(dispatch, source_module):
"""{callback spelling: dispatcher line} for every resolver handed in.
`declare_direct_writes(record, source, callback)` runs a callable instead of
code in this tree -- a platform plugin, a registered speculative algorithm.
Which fields such a callback writes is not a static question; only *when* it
runs is, so the position is what gets pinned.
Two spellings reach the pipeline: the dispatcher wraps a callback itself, or
it calls a hook in this tree that wraps one. The line recorded is always the
dispatcher's, because that is where the ordering against the build is
decided -- a hook body sits further down its own file and says nothing about
it.
"""
imported = {}
for node in ast.walk(_parsed(source_module)):
if isinstance(node, ast.ImportFrom) and node.module:
for alias in node.names:
imported[alias.asname or alias.name] = node.module
def callbacks_in(tree):
found = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
name = (
node.func.id
if isinstance(node.func, ast.Name)
else (node.func.attr if isinstance(node.func, ast.Attribute) else None)
)
if name == "declare_direct_writes" and len(node.args) > 2:
found.append(ast.unparse(node.args[2]))
return found
positions = {}
for spelling in callbacks_in(dispatch):
positions[spelling] = min(
positions.get(spelling, 10**9),
next(
node.lineno
for node in ast.walk(dispatch)
if isinstance(node, ast.Call)
and getattr(node.func, "id", None) == "declare_direct_writes"
),
)
for node in ast.walk(dispatch):
if not isinstance(node, ast.Call):
continue
name = (
node.func.id
if isinstance(node.func, ast.Name)
else (node.func.attr if isinstance(node.func, ast.Attribute) else None)
)
module = imported.get(name)
if not module or not module.startswith("sglang.srt."):
continue
path = _SRT / (module[len("sglang.srt.") :].replace(".", "/") + ".py")
if not path.exists():
continue
for spelling in callbacks_in(_parsed(path)):
positions[spelling] = min(positions.get(spelling, 10**9), node.lineno)
return positions
def _declaration_positions():
"""({field: position}, first_build) over the fields the constructor reads.
A position is `(step index, rank)`, and `rank` is 0 only for a declaration
that sits *above* the build in the very method that builds: a declaration
applies where it is written, so one statement earlier in the same body is
genuinely earlier. Everything else in the build's step gets rank 1 and
counts as late -- line numbers say nothing across two method bodies, since
a handler sits further down the file than the dispatcher that calls it.
One derivation, two callers: the check below asks which fields land after
the build, and the pin check asks whether an exempted field is still one
of them. Two derivations of that answer drift apart.
"""
steps, methods, reached, step_lines = _pipeline()
wanted = _constructor_reads()
def build_site():
"""(step index, method name, line) of the first `model_config_of()`."""
for index, step in enumerate(steps):
for method in reached[step]:
for node in ast.walk(methods[method]):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "model_config_of"
):
return index, step, method, node.lineno
return None
site = build_site()
if site is None:
return {}, None
build_index, build_step, build_method, build_line_in_body = site
first_build = (build_index, build_step)
source_module = _DISPATCH_MODULE
imported = {}
for node in ast.walk(_parsed(source_module)):
if isinstance(node, ast.ImportFrom) and node.module:
for alias in node.names:
imported[alias.asname or alias.name] = node.module
def _hook_declared_fields(name):
"""Fields a hook imported from `sglang.srt` declares, by callable name."""
module = imported.get(name)
if not module or not module.startswith("sglang.srt."):
return frozenset()
path = _SRT / (module[len("sglang.srt.") :].replace(".", "/") + ".py")
if not path.exists():
return frozenset()
return _declared_resolution_fields(path)
declared_at = {}
for index, step in enumerate(steps):
for method in reached[step]:
for node in ast.walk(methods[method]):
if not isinstance(node, ast.Call):
continue
same_body = index == build_index and method == build_method
rank = 0 if same_body and node.lineno < build_line_in_body else 1
if (
isinstance(node.func, ast.Name)
and node.func.id == "declare_resolution"
):
fields = {kw.arg for kw in node.keywords if kw.arg}
# A handler that calls an imported hook (the Kimi and DeepSeek
# defaults live in arg_groups modules) declares through it, and
# the hook can sit below the build inside the same handler.
elif isinstance(node.func, ast.Name):
fields = _hook_declared_fields(node.func.id)
else:
continue
for field in fields:
if field in wanted:
# The *last* declaration is the one that has to precede
# the build.
declared_at[field] = max(
declared_at.get(field, (index, rank)), (index, rank)
)
# Hooks the dispatch calls on other objects declare too, and a hook below
# the first build is late by definition. Both positions are read *inside
# the dispatcher*: a handler body sits further down the file than the
# dispatcher that calls it, so a line number taken from one scope says
# nothing about ordering against the other.
dispatch = methods["run_resolution_pipeline"]
build_line = step_lines[first_build[1]]
for field, line in _hook_declarations(dispatch, _DISPATCH_MODULE).items():
if field in wanted and line > build_line:
declared_at[field] = max(
declared_at.get(field, (build_index, 1)), (10**6, 1)
)
# Late resolution is the other channel that can decide a field the
# constructor reads, and it runs after every build.
for field in _late_resolution_fields():
if field in wanted:
declared_at[field] = (10**6, 1)
return declared_at, first_build
class TestModelConfigReadsResolvedInput(CustomTestCase):
def test_every_field_it_reads_is_resolved_before_it_is_built(self):
declared_at, first_build = _declaration_positions()
self.assertIsNotNone(
first_build, "no handler builds a ModelConfig; the scan broke"
)
known = (
_READ_BEFORE_RESOLUTION
| _STALE_IN_THE_MODEL_CONFIG
| _STALE_FROM_THE_REGISTRIES
| _STALE_BEHIND_THE_EXPERT_PACK_BUILD
)
late = sorted(
field
for field, position in declared_at.items()
if position >= (first_build[0], 1) and field not in known
)
self.assertEqual(
late,
[],
"resolution decides these after it builds the ModelConfig that reads "
f"them, so the model configuration describes a half-resolved input "
f"(first build: step {first_build[0]}, {first_build[1]}): {late}",
)
def test_the_registry_stale_set_is_exactly_what_is_late(self):
"""Equality, not membership.
A fifth field the registries decide after the build fails here, and so
does fixing the ordering -- either way someone has to come back and
read this. The earlier version of this file derived declarations only
from `self._declare(...)` keywords, so it passed while these four were
already stale.
"""
collect_line, build_line = _registry_collection_is_after_the_build()
self.assertIsNotNone(
collect_line, "the handler no longer collects registry declarations"
)
reads = _constructor_reads()
registry = _registry_declared_fields()
self.assertGreater(
len(registry), 20, "the registry-declared set collapsed; nothing to compare"
)
# Late against the *pipeline-wide* first build, not only the build in
# the collection's own handler: `_handle_gpu_memory_settings` builds
# the configuration many steps earlier, so hoisting the collection
# above the local build still leaves that cache describing raw input.
steps, methods, reached, _step_lines = _pipeline()
_declared_at, first_build = _declaration_positions()
self.assertIsNotNone(
first_build, "no handler builds a ModelConfig; the scan broke"
)
collecting_steps = [
index
for index, step in enumerate(steps)
for method in reached[step]
if any(
isinstance(node, ast.Call)
and (
node.func.attr
if isinstance(node.func, ast.Attribute)
else getattr(node.func, "id", None)
)
== "collect_model_override_declarations"
for node in ast.walk(methods[method])
)
]
self.assertTrue(collecting_steps, "no pipeline step collects the registry")
collection_is_late = min(collecting_steps) > first_build[0] or (
build_line is not None and collect_line > build_line
)
late = frozenset(reads & registry) if collection_is_late else frozenset()
self.assertEqual(
sorted(late),
sorted(_STALE_FROM_THE_REGISTRIES),
"the set of ModelConfig-read fields the registries decide after the "
f"build changed (collection at line {collect_line}, build at line "
f"{build_line}); read the comment on _STALE_FROM_THE_REGISTRIES "
"before editing it",
)
def test_the_pinned_stale_field_is_still_stale(self):
"""If the ordering gets fixed, this pin has to be retired, not kept.
A pin that outlives the defect it describes is worse than none: it
documents a hazard that no longer exists and hides the day one appears.
"""
steps, methods, reached, step_lines = _pipeline()
dispatch = methods["run_resolution_pipeline"]
hooks = _hook_declarations(dispatch, _DISPATCH_MODULE)
build_line = min(
step_lines[step]
for step in steps
for method in reached[step]
if any(
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "model_config_of"
for node in ast.walk(methods[method])
)
)
for field in _STALE_IN_THE_MODEL_CONFIG:
self.assertIn(
field,
hooks,
f"{field} is pinned as decided after the build, but no hook "
"declares it any more; retire the pin",
)
self.assertGreater(
hooks[field],
build_line,
f"{field} is now decided before the model configuration is "
"built; retire the pin",
)
def test_every_opaque_callback_is_still_late(self):
"""The opaque resolvers all run after the model configuration is built.
A plugin that rewrites `dtype` or `model_path` in one of them is
invisible to the configuration already cached, and no scan can say
whether it does: the implementations are out of tree. So the positions
are the pin, and the set of callbacks is pinned with them -- a new one
has to be placed against the build by whoever adds it. Moving them all
above the build fixes the hazard and fails this test; retire the pin
then, rather than keeping a note about a hazard that is gone.
"""
steps, methods, reached, step_lines = _pipeline()
dispatch = methods["run_resolution_pipeline"]
positions = _opaque_callback_positions(dispatch, _DISPATCH_MODULE)
self.assertEqual(
sorted(positions),
[
"algo.handle_server_args",
"algo.validate_server_args",
"current_platform.apply_server_args_defaults",
],
"the set of resolvers handed to declare_direct_writes changed; each "
"one needs its position against the ModelConfig build looked at",
)
_declared_at, first_build = _declaration_positions()
self.assertIsNotNone(
first_build, "no handler builds a ModelConfig; the scan broke"
)
build_line = step_lines[first_build[1]]
for spelling, line in sorted(positions.items()):
self.assertGreater(
line,
build_line,
f"{spelling} now runs before the model configuration is built, "
"so a plugin's writes reach it; retire the pin",
)
def test_the_documented_exception_is_still_the_only_one(self):
"""A field pinned as read-before-resolution has to still be all three.
Read by the constructor, written by resolution, and written *after* the
build -- the last one is what makes the exemption load-bearing. Without
it, moving the declaration earlier leaves the name sitting in the
exempt set with nothing to exempt, and the next field that lands in
this position gets waved through by a pin nobody re-read.
"""
wanted = _constructor_reads()
declared_at, first_build = _declaration_positions()
self.assertIsNotNone(
first_build, "no handler builds a ModelConfig; the scan broke"
)
for field in sorted(_READ_BEFORE_RESOLUTION):
self.assertIn(
field,
wanted,
f"{field} is pinned as read before resolution, but the "
"constructor no longer reads it; retire the pin",
)
self.assertIn(
field,
declared_at,
f"{field} is pinned as read before resolution, but resolution "
"no longer writes it; retire the pin",
)
self.assertGreaterEqual(
declared_at[field],
(first_build[0], 1),
f"{field} is now decided before the model configuration is "
"built, so the exemption covers nothing; retire the pin",
)
if __name__ == "__main__":
unittest.main()
@@ -1,86 +0,0 @@
"""The record grows no attribute the projection cannot see.
A publicly-named attribute that is not a dataclass field is invisible to every
other guard here: the namespace coverage walks fields, the projection walks
fields, and the read ratchets watch field reads. Three of them accumulated that
way -- a `ModelConfig` cache, an `moe_ep_size` that only a log line read, and an
env-derived `grpc_worker_threads` that one entry point read across the boundary.
Leading-underscore names are the record's own bookkeeping and stay: the
read-only guard classifies writability by that spelling, so a private name is
already outside the config tier by construction.
"""
import ast
import dataclasses
import pathlib
import unittest
import sglang
from sglang.srt.server_args import ServerArgs
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=10, suite="base-a-test-cpu")
def _self_written_attributes() -> set:
"""Names `ServerArgs` writes on itself, by either spelling."""
source = (
pathlib.Path(next(iter(sglang.__path__))) / "srt" / "server_args.py"
).read_text(encoding="utf-8-sig")
tree = ast.parse(source)
cls = next(
node
for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == "ServerArgs"
)
written = set()
for node in ast.walk(cls):
if isinstance(node, ast.Assign):
for target in node.targets:
if (
isinstance(target, ast.Attribute)
and isinstance(target.value, ast.Name)
and target.value.id == "self"
):
written.add(target.attr)
if (
isinstance(node, ast.Call)
and getattr(node.func, "attr", None) == "__setattr__"
and getattr(getattr(node.func, "value", None), "id", None) == "object"
and len(node.args) >= 2
and isinstance(node.args[1], ast.Constant)
):
written.add(node.args[1].value)
return written
class TestNoPublicNonFieldSlot(CustomTestCase):
def test_every_public_attribute_is_a_field(self):
written = _self_written_attributes()
# Anchor on a name, not a count: the count falls every time a derived
# read leaves the record, so a floor erodes with what it measures.
self.assertIn(
"_resolution_finished",
written,
f"the scan did not find the resolution flag the record sets on "
f"itself, so it is the scan that is broken, not the record: "
f"{sorted(written)}",
)
fields = {field.name for field in dataclasses.fields(ServerArgs)}
stray = sorted(
name for name in written if not name.startswith("_") and name not in fields
)
self.assertEqual(
[],
stray,
"these are written on the record under a public name but are not "
"fields, so the projection cannot see them and no other guard "
"watches them: make each a field, or give it the leading underscore "
f"that says it is the record's own bookkeeping: {stray}",
)
if __name__ == "__main__":
unittest.main()
@@ -1,144 +0,0 @@
"""Every `server_args.<name>()` in the tree names something the record has.
Removing a member from `ServerArgs` means rewriting its callers, and the ones
inside `server_args.py` are the ones you fix by reflex. The cross-file caller is
what bites: `ServerArgs.ssl_verify()` moved to `serving_hook.ssl_verify_of()` and
one call site kept the old spelling as `self.server_args.ssl_verify()` -- a grep
for `server_args.ssl_verify()` does not find that, and nothing else looks. Every
`HttpServerEngineAdapter` request raised `AttributeError` before sending.
So this resolves the call sites instead of grepping for them: every attribute
*called* on something statically known to be a record has to exist on the record.
It is deliberately not limited to methods the refactor touched -- the next
removal gets the same check for free.
`multimodal_gen` carries a different, same-named class outside this contract, as
the other record ratchets also record.
"""
from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=19, suite="base-a-test-cpu")
import ast
import dataclasses
import pathlib
import unittest
import sglang
from sglang.srt.server_args import ServerArgs
from sglang.test.test_utils import CustomTestCase
_ROOTS = (
pathlib.Path(next(iter(sglang.__path__))) / "srt",
pathlib.Path(__file__).resolve().parents[3], # test/
)
_EXCLUDED = ("multimodal_gen",)
# Attribute names that hold a `ServerArgs`. `resolving_view` and `resolved_view`
# proxy the record but answer for names it does not carry, so they are not here.
_RECORD_NAMES = ("server_args", "_server_args")
def _is_record(node) -> bool:
"""`server_args`, `self.server_args`, `self._server_args`, `cls.server_args`."""
if isinstance(node, ast.Name):
return node.id in _RECORD_NAMES
if isinstance(node, ast.Attribute):
return node.attr in _RECORD_NAMES
return False
def _rebound_locally(tree) -> set:
"""Names assigned something that is plainly not a record.
`server_args` is also a natural name for a dict of CLI flags or a list of
argv strings in test helpers, and those legitimately answer `.update()` and
`.items()`. A function that assigns one of those to the name is not talking
about the record in that scope.
"""
literal = (ast.Dict, ast.List, ast.DictComp, ast.ListComp)
builders = {"dict", "list", "tuple", "set"}
def _not_a_record(value) -> bool:
if isinstance(value, literal):
return True
# `dict(...)` / `list(...)`, and an annotated `server_args: list[str] = [...]`
return (
isinstance(value, ast.Call) and getattr(value.func, "id", None) in builders
)
rebound = set()
for node in ast.walk(tree):
if isinstance(node, ast.AnnAssign):
targets, value = [node.target], node.value
elif isinstance(node, ast.Assign):
targets, value = node.targets, node.value
else:
continue
if value is None or not _not_a_record(value):
continue
for target in targets:
if isinstance(target, ast.Name) and target.id in _RECORD_NAMES:
rebound.add(target.id)
return rebound
def _called_members():
"""{name: [file:line]} for every `<record>.<name>(...)` in the tree."""
found: dict[str, list[str]] = {}
for root in _ROOTS:
for path in sorted(root.rglob("*.py")):
text = path.as_posix()
if any(part in text for part in _EXCLUDED):
continue
source = path.read_text(encoding="utf-8-sig")
if "server_args" not in source:
continue
try:
tree = ast.parse(source)
except SyntaxError:
continue
rebound = _rebound_locally(tree)
for node in ast.walk(tree):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and _is_record(node.func.value)
and getattr(node.func.value, "id", None) not in rebound
):
found.setdefault(node.func.attr, []).append(
f"{path.name}:{node.lineno}"
)
return found
class TestRecordMemberCallsResolve(CustomTestCase):
def test_every_called_member_exists_on_the_record(self):
called = _called_members()
self.assertGreater(
len(called),
5,
f"only {len(called)} members called on a record; the scan is broken, "
"not the tree",
)
available = set(dir(ServerArgs)) | {
field.name for field in dataclasses.fields(ServerArgs)
}
missing = {
name: sites
for name, sites in sorted(called.items())
if name not in available
}
self.assertEqual(
{},
missing,
"these are called on a ServerArgs but the record has no such member -- "
"each one raises AttributeError at the call. A member that moved out of "
"the record has to be rewritten at every call site, including the ones "
f"reached through `self.server_args`: {missing}",
)
if __name__ == "__main__":
unittest.main()
@@ -1,15 +1,10 @@
"""Resolution writes are recorded, not just applied.
The projection that replaces field materialization reads the declaration stash,
so a resolution write that only assigns the field is invisible to it. Every
resolver declares now -- the record's handlers through `self._declare`, the
hooks and hardware defaults through `declare_resolution` -- and that is pinned
two ways: no bare assignment to a field survives anywhere a ServerArgs instance
is in reach, and after resolution `resolution_result` answers for every declared
field with what the stash holds. The second check is what the stash is measured
against: the two can disagree only if something wrote behind the stash's back. A
third check runs the other way -- every field resolution moved has to be
explained by the stash, which covers the spellings a source scan cannot see.
so a resolution write that bypasses the stash is invisible to it. These tests
compare the raw input, resolved record, declaration result, and published bags
across representative configurations. A field that moves without a declaration
or is projected into the wrong namespace therefore fails on observed state.
"""
import ast
@@ -122,114 +117,6 @@ _REACHED_BY_SHAPES = frozenset(
)
def _late_resolvers():
"""Callables that reach `declare_late_resolution`, derived per module."""
found = set()
for relative in ("server_args.py", "parser/template_detection.py"):
tree = ast.parse((_SRT / relative).read_text(encoding="utf-8-sig"))
functions = {
node.name: node
for node in ast.walk(tree)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
}
def reaches(name, seen=None):
seen = seen if seen is not None else set()
if name in seen or name not in functions:
return False
seen.add(name)
for node in ast.walk(functions[name]):
if not isinstance(node, ast.Call):
continue
called = (
node.func.attr
if isinstance(node.func, ast.Attribute)
else getattr(node.func, "id", None)
)
if called == "declare_late_resolution":
return True
if called and reaches(called, seen):
return True
return False
found |= {name for name in functions if reaches(name)}
return found
def _server_args_writers(tree, path):
"""Assignment targets that land on a ServerArgs instance.
Two mechanisms reach the same instance during resolution: a handler writing
`self.<field>`, and a helper elsewhere in the tree writing through a
`ServerArgs`-annotated parameter -- `set_default_server_args(args)` is
called from the pipeline and writes `args.<field>`. Both bypass the
declaration stash, so both have to be scanned; scanning only the handlers
would let a field look converted while a second writer still assigns it.
"""
names = {"self"} if path.name == "server_args.py" else set()
# A parameter *named* `server_args` counts with or without the annotation.
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
args = node.args
for arg in args.posonlyargs + args.args + args.kwonlyargs:
annotation = arg.annotation
if isinstance(annotation, ast.Constant):
text = annotation.value
elif isinstance(annotation, ast.Name):
text = annotation.id
elif isinstance(annotation, ast.Attribute):
text = annotation.attr
else:
continue
if text == "ServerArgs":
names.add(arg.arg)
names |= {
arg.arg for arg in args.posonlyargs + args.args if arg.arg == "server_args"
}
return names
def _bare_assignments():
"""Assignments to a converted field that never reach the stash."""
found = []
for path in sorted(_SRT.rglob("*.py")):
try:
tree = ast.parse(path.read_text(encoding="utf-8-sig"))
except SyntaxError:
continue
names = _server_args_writers(tree, path)
if not names:
continue
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
targets = node.targets
elif isinstance(node, (ast.AugAssign, ast.AnnAssign)):
targets = [node.target]
else:
continue
# Destructured targets count: `(sa.a, sa.b) = f()` writes two
# fields and is not an `ast.Attribute` at the top level.
flat = []
for target in targets:
if isinstance(target, (ast.Tuple, ast.List)):
flat.extend(target.elts)
else:
flat.append(target)
for target in flat:
if (
isinstance(target, ast.Attribute)
and isinstance(target.value, ast.Name)
and target.value.id in names
and target.attr in _RESOLVED_FIELDS
):
found.append(
f"{path.relative_to(_SRT)}:{node.lineno} "
f"{target.value.id}.{target.attr}"
)
return sorted(found)
def shape_key(shape):
"""A shape rendered short enough for a failure message."""
return ",".join(f"{k}={v}" for k, v in sorted(shape.items())) or "defaults"
@@ -299,15 +186,6 @@ class TestResolutionDeclarations(CustomTestCase):
server_args.resolve_once()
return server_args
def test_converted_fields_are_not_assigned_bare(self):
bare = _bare_assignments()
self.assertEqual(
bare,
[],
"a converted field is assigned directly, so the projection would "
"not see this write:\n " + "\n ".join(bare),
)
def test_the_stash_accounts_for_every_change_resolution_made(self):
"""The other direction: a field resolution moved is in the stash.
@@ -419,10 +297,7 @@ class TestResolutionDeclarations(CustomTestCase):
the last hop: whether the leaf is reachable through the path the
metadata declares, and whether it carries the resolved value once it
is. Both sides here come from that metadata, so this cannot tell that
a field is assigned to the *wrong* group -- the readers are the
independent source for that, and
`test_server_args_namespaces.py::test_the_readers_agree_with_the_namespace_metadata`
is where the two are compared.
a field is assigned to the *wrong* group.
"""
import sglang.srt.runtime_context as runtime_context
from sglang.srt.arg_groups.arg_utils import namespace_of
@@ -643,54 +518,6 @@ class TestResolutionDeclarations(CustomTestCase):
"normalization is a declaration; the record keeps what was passed",
)
def test_the_launcher_finishes_resolving_before_it_publishes(self):
"""Every late resolver runs above the publish, in the source.
A published record refuses to be written, so a late resolver below the
publish raises at startup rather than at test time -- and only for the
configuration that reaches it, which is why the LoRA path can break
while every other launch stays green. Both sides are derived: which
callables reach `declare_late_resolution`, and where the launcher calls
them.
"""
launcher = _SRT / "entrypoints/engine.py"
late = {"check_server_args", "resolve_auto_parsers"} | _late_resolvers()
tree = ast.parse(launcher.read_text(encoding="utf-8-sig"))
function = next(
node
for node in ast.walk(tree)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.name == "_launch_subprocesses"
)
published_at = [
node.lineno
for node in ast.walk(function)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "publish"
]
self.assertEqual(len(published_at), 1, "the launcher publishes once")
too_late = sorted(
f"{name}() at line {node.lineno}"
for node in ast.walk(function)
if isinstance(node, ast.Call)
for name in [
(
node.func.attr
if isinstance(node.func, ast.Attribute)
else getattr(node.func, "id", None)
)
]
if name in late and node.lineno > published_at[0]
)
self.assertEqual(
too_late,
[],
f"these resolve after the launcher publishes at line "
f"{published_at[0]}, and a published record refuses to be "
f"written:\n " + "\n ".join(too_late),
)
def test_an_undeclared_field_still_holds_the_raw_input(self):
"""Nothing writes a field behind the stash's back.
@@ -832,48 +659,6 @@ class TestResolutionDeclarations(CustomTestCase):
"so a decision made inside the declared object was dropped",
)
def test_every_platform_hook_that_takes_the_record_is_captured(self):
"""A second out-of-tree config hook must not arrive uncaptured.
`apply_server_args_defaults` is the one method on the platform
interface that is handed the record, and its implementations live in
other distributions -- no source scan of this tree can see what they
write, so the pipeline diffs the record across the call instead. A new
hook of the same shape would be invisible again, and this is what
notices. Derived from the interface rather than listed: a rename keeps
working, an addition fails.
"""
interface = _SRT / "platforms" / "interface.py"
tree = ast.parse(interface.read_text(encoding="utf-8-sig"))
taking_the_record = set()
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
arguments = node.args
names = [
arg.arg
for arg in arguments.posonlyargs + arguments.args + arguments.kwonlyargs
]
if any(name == "server_args" or name.endswith("_args") for name in names):
taking_the_record.add(node.name)
self.assertEqual(
taking_the_record,
{"apply_server_args_defaults"},
"the platform interface hands the startup record to a method this "
"test does not know about; either it only reads, or its writes need "
"capturing like apply_server_args_defaults",
)
pipeline = (_SRT / "arg_groups" / "pipeline.py").read_text(encoding="utf-8-sig")
for hook in sorted(taking_the_record):
self.assertIn(
f"current_platform.{hook},",
pipeline,
f"{hook} is called directly instead of through the write "
"capture, so an out-of-tree plugin's defaults would be dropped "
"by the projection",
)
def test_the_shapes_reach_the_fields_they_are_meant_to(self):
"""A green agreement check over an empty stash would prove nothing."""
declared = set()
@@ -35,7 +35,6 @@ import unittest.mock
import torch
import sglang
from sglang.srt.arg_groups.overrides import model_config_of, resolution_result
from sglang.srt.environ import EnvField, envs
from sglang.srt.server_args import ServerArgs
@@ -481,274 +480,6 @@ class TestResolutionIsReproducible(_RestoresProcessState, CustomTestCase):
self.assertEqual(getattr(first, "_resolved_overrides", None), first_provenance)
class TestProgramsResolveBeforeReadingResolution(CustomTestCase):
"""A program that builds its own record resolves it before reading what
resolution decides.
Construction is inert, so a program that builds a record and then reads a
resolution-written field reads the CLI default. Two of these shipped past
the earlier censuses because those are rooted at the `sglang` package: the
model gateway's launcher sized its worker plan from a raw `dp_size`
(`--dwdp-size 4` launched one server instead of four) and a speculative
benchmark forwarded `--mem-fraction-static None` to the server it spawns.
So the universe here is the *repository*, not the package.
"""
# Entries that hand the record on instead of reading it. Reason required.
_EXEMPT: dict = {}
def _repo_root(self):
# <repo>/python/sglang/__init__.py -> <repo>
root = pathlib.Path(next(iter(sglang.__path__))).resolve().parents[1]
if root.name == "python":
root = root.parent
return root
def _written_fields(self):
"""Fields resolution declares, read out of the pipeline's own source.
Deliberately local: the chain ratchet has a wider derivation (it also
walks the model-override registries), but it arrives later in this
series, and a check that imports it would fail at this PR's boundary.
Coarser is fine here -- what this needs is the fields the entries below
actually read -- and the floor keeps it from drifting narrower.
"""
import ast
import dataclasses as _dataclasses
from sglang.srt.server_args import ServerArgs as _ServerArgs
srt = pathlib.Path(next(iter(sglang.__path__))).resolve() / "srt"
declarers = {"declare_resolution", "declare_late_resolution"}
fields = set()
field_names = {field.name for field in _dataclasses.fields(_ServerArgs)}
# The record plus every module under `arg_groups/`: a handler declares
# from whichever of the two it lives in.
sources = [srt / "server_args.py", *sorted((srt / "arg_groups").rglob("*.py"))]
for source in sources:
tree = ast.parse(source.read_text(encoding="utf-8-sig"))
for node in ast.walk(tree):
# Registry data: provider dict keys are field names as
# *data*, invisible to the keyword scan below. Filtered
# against the real field set.
if isinstance(node, ast.Dict):
fields |= {
key.value
for key in node.keys
if isinstance(key, ast.Constant)
and isinstance(key.value, str)
and key.value in field_names
}
if not isinstance(node, ast.Call):
continue
func = node.func
called = (
func.attr
if isinstance(func, ast.Attribute)
else getattr(func, "id", "")
)
if called in declarers or called == "update":
fields |= {
kw.arg
for kw in node.keywords
if kw.arg and (called != "update" or kw.arg in field_names)
}
return fields
def _candidates(self, root):
"""Source files that build a record, with the names they bind it to."""
import ast
skip = {".git", "build", "dist", "node_modules", ".venv", "target"}
found = {}
for path in sorted(root.rglob("*.py")):
parts = set(path.relative_to(root).parts)
if parts & skip:
continue
rel = path.relative_to(root).as_posix()
# Tests build raw records on purpose.
if rel.startswith("test/") or "/test/" in rel or "/tests/" in rel:
continue
try:
tree = ast.parse(path.read_text(encoding="utf-8-sig"))
except (SyntaxError, UnicodeDecodeError):
continue
# Which local names are *the srt record*, by import source: the
# diffusion runtime has a same-spelled `ServerArgs` with no
# resolution, so the spelling alone is not enough.
record_classes, record_helpers = set(), set()
for node in ast.walk(tree):
if not isinstance(node, ast.ImportFrom):
continue
for alias in node.names:
bound = alias.asname or alias.name
if node.module == "sglang" and alias.name == "ServerArgs":
record_classes.add(bound)
if node.module == "sglang.srt.server_args":
if alias.name == "ServerArgs":
record_classes.add(bound)
if alias.name == "prepare_server_args":
record_helpers.add(bound)
names = set()
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
targets = node.targets
elif isinstance(node, (ast.AnnAssign, ast.NamedExpr)):
# `x: ServerArgs = ...` is an AnnAssign, not an Assign.
targets = [node.target]
else:
continue
call = getattr(node, "value", None)
if not isinstance(call, ast.Call):
continue
func = call.func
# `prepare_server_args(argv)` is the CLI launcher's way.
builds = (
isinstance(func, ast.Name)
and func.id in (record_classes | record_helpers)
) or (
isinstance(func, ast.Attribute)
and func.attr == "from_cli_args"
and isinstance(func.value, ast.Name)
and func.value.id in record_classes
)
if builds:
names |= {t.id for t in targets if isinstance(t, ast.Name)}
if names:
found[rel] = (tree, names, path)
return found
def test_every_program_that_builds_a_record_resolves_it(self):
import ast
root = self._repo_root()
candidates = self._candidates(root)
self.assertGreater(
len(candidates),
10,
f"only {len(candidates)} files build a record under {root}; either "
"this is not a source checkout or the scan broke",
)
written = self._written_fields()
self.assertGreater(len(written), 50, "the written-field set collapsed")
# What the escaped entries actually read: a narrower derivation goes
# quiet on exactly those.
for field in ("dp_size", "mem_fraction_static"):
self.assertIn(field, written)
offenders = []
for rel, (tree, names, path) in sorted(candidates.items()):
source = path.read_text(encoding="utf-8-sig")
if "resolve_once(" in source or "publish(" in source:
continue
reads = sorted(
{
f"{node.attr}:{node.lineno}"
for node in ast.walk(tree)
if isinstance(node, ast.Attribute)
and isinstance(node.value, ast.Name)
and node.value.id in names
and node.attr in written
}
)
if reads and rel not in self._EXEMPT:
offenders.append(f"{rel} reads {', '.join(reads[:4])}")
self.assertEqual(
offenders,
[],
"a program builds its own record and reads what resolution decides "
"without resolving it, so it reads the CLI default:\n "
+ "\n ".join(offenders),
)
self.assertEqual(
sorted(set(self._EXEMPT) - set(candidates)),
[],
"an exemption names a file that no longer builds a record",
)
class TestForksResolveFirst(CustomTestCase):
"""A process that forks a child to run the record resolves it first.
The pipeline probes the device (the default attention backend reads the CUDA
capability), and a forked child cannot initialize CUDA once its parent has.
Construction used to resolve, so the probe always happened in whoever built
the record; now it happens at the gate, and the gate must not be reached for
the first time inside a fork.
"""
# Sites inside the launcher: `_launch_subprocesses` resolves at its top, so
# every fork below it already has a resolved record.
_AFTER_LAUNCHER_RESOLVE = {
"srt/entrypoints/engine.py",
"srt/managers/data_parallel_controller.py",
"srt/disaggregation/encoder/grpc_server.py",
"srt/disaggregation/encoder/runtime.py",
"srt/elastic_ep/expert_backup_manager.py",
}
def test_every_fork_of_a_record_has_a_resolved_one(self):
import ast
package_root = pathlib.Path(next(iter(sglang.__path__))).resolve()
offenders, examined = [], 0
for path in sorted(package_root.rglob("*.py")):
rel = path.relative_to(package_root).as_posix()
if rel.startswith("test/") or "/test/" in rel:
continue
# The diffusion runtime has its own record with no gate.
if rel.startswith("multimodal_gen/"):
continue
try:
source = path.read_text(encoding="utf-8-sig")
if "Process" not in source:
continue
tree = ast.parse(source)
except (SyntaxError, UnicodeDecodeError):
continue
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
forks = [
call
for call in ast.walk(node)
if isinstance(call, ast.Call)
and (
(
isinstance(call.func, ast.Attribute)
and call.func.attr == "Process"
)
or (
isinstance(call.func, ast.Name)
and call.func.id == "Process"
)
)
and "server_args" in (ast.get_source_segment(source, call) or "")
]
if not forks:
continue
body = ast.get_source_segment(source, node) or ""
examined += 1
# `spawn` starts a fresh interpreter, so the child may probe.
if 'get_context("spawn")' in body or "'spawn'" in body:
continue
if "resolve_once(" in body or "publish(" in body:
continue
if rel in self._AFTER_LAUNCHER_RESOLVE:
continue
offenders.append(f"{rel}:{forks[0].lineno} {node.name}")
self.assertGreater(
examined, 5, f"only {examined} fork sites found; the scan broke"
)
self.assertEqual(
offenders,
[],
"these fork a child that will resolve the record, without resolving "
"it first -- the child cannot initialize CUDA if this process "
f"already has:\n " + "\n ".join(offenders),
)
class TestACopyStaysResolved(_RestoresProcessState, CustomTestCase):
"""A resolved record copied with `dataclasses.replace` loses what makes it
resolved, and the next publish resolves it a second time -- over values it
@@ -876,215 +607,6 @@ class TestACopyStaysResolved(_RestoresProcessState, CustomTestCase):
"the parent's resolution decided",
)
def test_no_bare_replace_of_a_record_outside_the_helper(self):
"""`dataclasses.replace` on a record is the helper's job now.
Derived, not listed: any `dataclasses.replace` whose first argument is
named for a record. The helper's own call is the positive control -- if
the scan stops seeing it, the scan broke rather than the tree.
"""
import ast
# The repository, not the package: the gateway is outside `sglang/`.
package_root = pathlib.Path(next(iter(sglang.__path__))).resolve().parents[1]
if package_root.name == "python":
package_root = package_root.parent
helper = "python/sglang/srt/server_args.py"
bare, inside_helper = [], 0
def replaces_a_record(node, record_names):
if not (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "replace"
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "dataclasses"
and node.args
):
return False
first = node.args[0]
name = (
first.id if isinstance(first, ast.Name) else getattr(first, "attr", "")
)
return name in record_names or "server_args" in name
for path in sorted(package_root.rglob("*.py")):
try:
tree = ast.parse(path.read_text(encoding="utf-8-sig"))
except SyntaxError:
continue
rel = path.relative_to(package_root).as_posix()
# `self` is a record only inside the record's own class body.
in_record_class = [
node
for node in ast.walk(tree)
if isinstance(node, ast.ClassDef) and node.name == "ServerArgs"
]
for scope, record_names in [(tree, set())] + [
(klass, {"self"}) for klass in in_record_class
]:
for node in ast.walk(scope):
if not replaces_a_record(node, record_names):
continue
if rel == helper and record_names:
inside_helper += 1
elif not record_names:
bare.append(f"{rel}:{node.lineno}")
self.assertEqual(
inside_helper,
1,
"the scan no longer finds `replace_resolved`'s own call; it broke",
)
self.assertEqual(
bare,
[],
"a record is copied with a bare `dataclasses.replace`, so the copy "
"loses the parent's resolution and the next publish resolves it "
"again: " + ", ".join(bare),
)
class TestTheResolutionSeamHasOneCaller(CustomTestCase):
"""The pipeline is entered from exactly one place, and that place decides
whether it runs at all.
``resolve_once`` is the gate: the handlers are not written to survive a
second pass over their own output, so a record must go through the pipeline
at most once. Keeping the pipeline itself down to a single caller is what
makes that gate impossible to bypass -- and what keeps the remaining move
(construction time to publish time) a matter of who calls the gate.
"""
def test_only_the_gate_runs_the_pipeline(self):
import ast
from pathlib import Path
import sglang
package_root = Path(next(iter(sglang.__path__)))
callers = []
for path in sorted(package_root.rglob("*.py")):
try:
source = path.read_text()
if "run_resolution_pipeline" not in source:
continue
tree = ast.parse(source)
except SyntaxError:
continue
# The full (class, function, ...) scope chain, so the assertion can
# say "the one caller is ServerArgs.__post_init__" -- not merely
# that nothing outside a function named __post_init__ calls it.
scopes = {}
for node in ast.walk(tree):
own = scopes.get(id(node), ())
if isinstance(
node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
):
own = own + (node.name,)
for child in ast.iter_child_nodes(node):
scopes[id(child)] = own
for node in ast.walk(tree):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "run_resolution_pipeline"
):
rel = path.relative_to(package_root).as_posix()
callers.append((rel, ".".join(scopes.get(id(node), ()))))
# Every call, compared whole: a removed call, a duplicate inside
# __post_init__, or another class growing a same-named __post_init__
# all show up here.
self.assertEqual(
[("srt/server_args.py", "ServerArgs.resolve_once")],
callers,
"the resolution pipeline must be entered exactly once, from "
f"ServerArgs.resolve_once; found: {callers}",
)
def test_the_gate_is_reached_from_the_launcher_and_from_publish(self):
"""Both entries go through the gate, so neither can resolve twice.
The launcher resolves the engine's record before reading any resolved
value from it; every publishing process asks the gate on the way in and
finds nothing left to do when the record arrived resolved.
"""
import ast
from pathlib import Path
import sglang
package_root = Path(next(iter(sglang.__path__)))
callers = []
for path in sorted(package_root.rglob("*.py")):
try:
source = path.read_text()
if "resolve_once" not in source:
continue
tree = ast.parse(source)
except SyntaxError:
continue
for node in ast.walk(tree):
# `self.resolve_once()` at construction; publish looks the
# attribute up first, so it appears as a bare name call.
called = (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "resolve_once"
) or (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "resolve_once"
)
if called:
callers.append(path.relative_to(package_root).as_posix())
machinery = {"srt/entrypoints/engine.py", "srt/runtime_context.py"}
self.assertEqual(
[
# Program entries: each builds a record from its own
# arguments and then reads effective configuration, or hands it
# to a fork that must not be the first to probe the device.
"benchmark/endpoint.py",
"benchmark/offline_throughput.py",
"benchmark/one_batch.py",
"benchmark/one_batch_server.py",
"compile_deep_gemm.py",
"lang/backend/runtime_endpoint.py",
"launch_server.py",
# The mechanism.
"srt/entrypoints/engine.py",
"srt/entrypoints/http_server_engine.py",
"srt/runtime_context.py",
],
sorted(set(callers)),
f"the resolution gate grew or lost a caller: {sorted(set(callers))}",
)
# The rule the list stands for: a caller that is not the mechanism
# resolves a record it built itself from argv. Anything else was handed
# one someone already resolved, or should publish.
for caller in sorted(set(callers) - machinery):
source = (package_root / caller).read_text()
# Either the module turned argv into the record -- the dataclass,
# the CLI classmethod, or the argv helper `launch_server.py` uses
# -- or it hands the record to a fork, which has to resolve first:
# the pipeline probes the device and a forked child cannot
# re-initialize CUDA. A worker handed a resolved record is neither.
builds_its_own = any(
spelling in source
for spelling in (
"ServerArgs(",
".from_cli_args(",
"prepare_server_args(",
)
) or ("Process(" in source and "server_args" in source)
# `assertTrue`, not `assertIn`: the container is a whole module.
self.assertTrue(
builds_its_own,
f"{caller} calls the resolution gate but does not build the "
"record it resolves; a record it was handed is already "
"resolved by whoever built it, and publish resolves what it "
"is handed",
)
class TestResolutionStaysLazy(CustomTestCase):
"""Resolving a dummy model must not load the families it never reaches.
@@ -1096,92 +618,6 @@ class TestResolutionStaysLazy(CustomTestCase):
`override_server_args` in the test suite.
"""
def test_no_hook_module_imports_another_at_module_scope(self):
import ast
import sglang
srt = pathlib.Path(next(iter(sglang.__path__))).resolve() / "srt"
offenders = []
for path in sorted((srt / "arg_groups").glob("*.py")):
for node in ast.parse(path.read_text(encoding="utf-8-sig")).body:
if (
isinstance(node, ast.ImportFrom)
and node.module
and node.module.startswith("sglang.srt.arg_groups")
and node.module.endswith("_hook")
):
offenders.append(f"{path.name}:{node.lineno} -> {node.module}")
self.assertEqual(
offenders,
[],
"a hook module imports another at module scope, so loading one "
"family drags in a family it may never call. Import it inside the "
"function that calls it:\n " + "\n ".join(offenders),
)
def test_no_family_is_imported_before_the_step_that_calls_it(self):
"""Source-level, so it holds whatever else the process has imported.
Every hook import inside the dispatcher must come after the imports of
the families reached earlier and before its own first call -- what an
eager block at the top of the function breaks, and what a `sys.modules`
diff cannot see once another test has loaded those modules.
"""
import ast
import sglang
srt = pathlib.Path(next(iter(sglang.__path__))).resolve() / "srt"
tree = ast.parse(
(srt / "arg_groups" / "pipeline.py").read_text(encoding="utf-8-sig")
)
dispatch = next(
node
for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef)
and node.name == "run_resolution_pipeline"
)
early_return = min(
(
n.lineno
for n in ast.walk(dispatch)
if isinstance(n, ast.Return) and n.value is None
),
default=None,
)
self.assertIsNotNone(early_return, "the dummy short circuit is gone")
imported_early, called_early = set(), set()
for node in ast.walk(dispatch):
if (
isinstance(node, ast.ImportFrom)
and node.module
and node.module.endswith("_hook")
and node.lineno < early_return
):
imported_early.add(node.module.rsplit(".", 1)[-1])
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.lineno < early_return
):
called_early.add(node.func.id)
hooks = {}
for path in sorted((srt / "arg_groups").glob("*_hook.py")):
for node in ast.parse(path.read_text(encoding="utf-8-sig")).body:
if isinstance(node, ast.FunctionDef):
hooks[node.name] = path.stem
needed_early = {hooks[name] for name in called_early if name in hooks}
self.assertEqual(
imported_early - needed_early,
set(),
"the dispatcher imports a hook family before the dummy short "
"circuit without calling it there, so every dummy resolution pays "
"for a family it never reaches",
)
def test_a_dummy_resolution_loads_only_what_it_reaches(self):
"""The same claim measured, in an interpreter of its own.
@@ -1,333 +0,0 @@
"""Resolution does not read the config bags, because they do not exist yet.
The bags are projected from what resolution decides, so anything the pipeline
calls has to read the resolving state instead — `resolved_view(server_args)`,
or the view a handler already holds. A bag read reached from resolution raises
`config namespace ... not published`, and only on the branch that reaches it:
the diffusion-LM page-size pass needed one model family, the Marlin LoRA
validation needed one MoE runner backend. Both were written, merged into a
branch, and stayed green for everything except the configuration that triggers
them.
`test_publish_precedes_bag_reads.py` is the same worry from the other side, but
it walks the *process entries* — it cannot see a helper the pipeline calls, and
neither of the two above appeared in it.
The walk starts from three places: the symbols the pipeline imports, the live
resolution registries (every pass and override provider, taken from the
registries themselves rather than from the decorator that put it there -- most
providers register through a helper call), and the passes named at a
`run_post_process_pass(sa, fn)` call site. From there it follows calls
in-module, one hop out, and matches an accessor whether it is spelled bare or
through an object.
What this still cannot see: a bag read reached through a method rather than a
module-level function, one behind an import the walk does not follow, and one
in a callable that reaches the pipeline through a variable no call site names.
It is a ratchet, not a proof.
"""
import ast
import functools
import inspect
import pathlib
import unittest
import sglang
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=22, suite="base-a-test-cpu")
_SRT = pathlib.Path(sglang.__file__).resolve().parent / "srt"
def _accessor_names():
"""Every bag accessor `runtime_context` exports, read from the module.
Listing them by hand is how this went stale once already: the list had
eighteen names while the module exported twenty-five, so a resolution-time
`get_flags().x` or `get_resources().y` would have walked straight past.
"""
tree = ast.parse((_SRT / "runtime_context.py").read_text(encoding="utf-8-sig"))
names = {
node.name
for node in tree.body
if isinstance(node, ast.FunctionDef) and node.name.startswith("get_")
}
# Two that are not bags: the context object itself, and the platform facts.
# Both answer before anything is published.
return frozenset(names - {"get_context", "get_platform"})
_BAG_ACCESSORS = _accessor_names()
# `get_device` also names the device-string utility and the platform method,
# so only the bare spelling is the accessor.
_ATTRIBUTE_SPELLED = _BAG_ACCESSORS - {"get_device"}
# The pipeline itself and the mechanism it publishes through: `runtime_context`
# defines the accessors, and `arg_groups` is the pipeline's own code.
_OWN = ("server_args.py", "runtime_context.py")
def _pipeline_sources():
"""The record plus every module under `arg_groups/`.
A handler that moved out of the record takes its imports with it, so
seeding the walk from two files would stop covering it.
"""
return [_SRT / "server_args.py", *sorted((_SRT / "arg_groups").rglob("*.py"))]
def _module_of(name):
"""`sglang.srt.a.b` -> the file, if it is one of ours."""
if not name or not name.startswith("sglang.srt."):
return None
rel = name[len("sglang.srt.") :].replace(".", "/")
for candidate in (_SRT / f"{rel}.py", _SRT / rel / "__init__.py"):
if candidate.exists():
return candidate
return None
def _imported_symbols(paths):
"""{module file: {symbol names imported from it}} across the given sources."""
out = {}
for path in paths:
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8-sig"))):
if not isinstance(node, ast.ImportFrom):
continue
target = _module_of(node.module)
if target is None or target.name in _OWN:
continue
out.setdefault(target, set()).update(alias.name for alias in node.names)
return out
def _registry_functions():
"""Every callable the resolution registries will call, from the registries.
Not from decorator syntax: most model-override providers register through
a `_register_for(...)` helper rather than a decorator, so a scan keyed on
the decorator name walked past all of them -- 39 entries found where the
registries hold 65. However a provider registers, it is in the registry
once its module is imported, and `inspect` says where it came from.
"""
from sglang.srt.arg_groups import overrides
functions = list(overrides.POST_PROCESS_PASSES)
functions += [fn for fns in overrides._MODEL_OVERRIDE_FNS.values() for fn in fns]
functions += [fn for _predicate, fn in overrides._PREDICATE_OVERRIDE_FNS]
return functions
@functools.lru_cache(maxsize=None)
def _registered_entries():
"""Entries the import map cannot reach: passes and override providers.
A pass arrives at the pipeline as a value, and the registry calls its
providers by dictionary lookup. Both run during resolution, so a bag read
inside one raises exactly like a bag read in a handler -- and neither is
named by an import the walk can follow.
"""
entries = set()
for fn in _registry_functions():
target = inspect.unwrap(fn)
name = getattr(target, "__name__", "")
if not name or name == "<lambda>":
continue
source = inspect.getsourcefile(target)
if source is None:
continue
path = pathlib.Path(source).resolve()
if _SRT in path.parents:
entries.add((path, name))
# A pass handed over by value is in no registry, so its call sites are read
# from the source. The entry carries the *defining* file: `_reaches_a_bag`
# walks functions in the entry's file, so a call-site key walks nothing.
by_value = set()
sources = {
path: path.read_text(encoding="utf-8-sig")
for path in sorted(_SRT.rglob("*.py"))
}
for path, source in sources.items():
if "run_post_process_pass" not in source:
continue
try:
tree = ast.parse(source)
except SyntaxError:
continue
for node in ast.walk(tree):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "run_post_process_pass"
and len(node.args) >= 2
and isinstance(node.args[1], ast.Name)
):
by_value.add(node.args[1].id)
trees = {}
for path, source in sources.items():
if not any(name in source for name in by_value):
continue
try:
trees[path] = ast.parse(source)
except SyntaxError:
continue
for name in sorted(by_value):
defined_in = [
path
for path, tree in trees.items()
if any(
isinstance(node, ast.FunctionDef) and node.name == name
for node in tree.body
)
]
if not defined_in:
raise AssertionError(
f"pass {name!r} is handed to run_post_process_pass by value "
"but defined in no scanned module; the walk cannot see it"
)
for path in defined_in:
entries.add((path, name))
return entries
@functools.lru_cache(maxsize=None)
def _functions_in(path):
tree = ast.parse(path.read_text(encoding="utf-8-sig"))
return {
node.name: node
for node in ast.walk(tree)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
}
def _locally_shadowed_accessors(path):
"""Accessor names this file imports from somewhere that is not the context.
`get_device` is both the `device` bag accessor and the hardware probe in
`utils.common`. Matching the bare name would report the probe as a bag read,
so a name imported from elsewhere in this file is not the accessor.
"""
shadowed = set()
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8-sig"))):
if isinstance(node, ast.ImportFrom) and node.module:
if node.module.endswith("runtime_context"):
continue
for alias in node.names:
name = alias.asname or alias.name
if name in _BAG_ACCESSORS:
shadowed.add(name)
return shadowed
def _reaches_a_bag(path, entry):
"""Does `entry` in `path` reach a bag accessor, following calls in-module?"""
functions = _functions_in(path)
shadowed = _locally_shadowed_accessors(path)
seen = set()
def walk(name):
if name in seen or name not in functions:
return None
seen.add(name)
for node in ast.walk(functions[name]):
if not isinstance(node, ast.Call):
continue
if isinstance(node.func, ast.Attribute):
# `rc.get_exec()`, `self.get_schedule()`: the same accessor
# reached through a module alias or an object.
if node.func.attr in _ATTRIBUTE_SPELLED:
return node.lineno
continue
if not isinstance(node.func, ast.Name):
continue
if node.func.id in _BAG_ACCESSORS and node.func.id not in shadowed:
return node.lineno
found = walk(node.func.id)
if found is not None:
return found
return None
return walk(entry)
class TestResolutionReadsNoBag(CustomTestCase):
def test_the_accessor_set_is_derived_and_whole(self):
"""A shrunken accessor set would make every other check pass quietly."""
self.assertGreaterEqual(
len(_BAG_ACCESSORS),
15,
f"only {len(_BAG_ACCESSORS)} accessors were derived from "
"runtime_context; the derivation broke",
)
# Spelled out so a rename that drops one fails here.
for name in ("get_exec", "get_flags", "get_parallel", "get_resources"):
self.assertIn(name, _BAG_ACCESSORS)
def test_the_walk_finds_something_to_walk(self):
"""A collapsed import map would make the pin vacuous."""
imported = _imported_symbols(_pipeline_sources())
self.assertGreater(
len(imported),
20,
f"the pipeline only imports from {len(imported)} of our modules; "
"the scan broke",
)
def test_the_registered_entries_are_found(self):
"""The passes and providers are the half the import map cannot see."""
entries = _registered_entries()
self.assertGreater(
len(entries),
60,
f"only {len(entries)} passes and providers were found; the scan broke",
)
# Every registered callable that lives in our tree has to appear: the
# derivation reads the registries through `inspect`, so an interpreter
# that imported a *different* checkout would resolve them outside
# `_SRT` and quietly leave the walk with nothing to walk.
missing = sorted(
name
for name in (
getattr(inspect.unwrap(fn), "__name__", "")
for fn in _registry_functions()
)
if name
and name != "<lambda>"
and name not in {entry for _path, entry in entries}
)
self.assertEqual(
missing,
[],
"a registered pass or provider did not resolve to a file under "
f"{_SRT}; the entry set is narrower than the registries:\n "
+ "\n ".join(missing),
)
def test_nothing_the_pipeline_calls_reads_a_bag(self):
imported = _imported_symbols(_pipeline_sources())
reachable = {
(path, symbol) for path, symbols in imported.items() for symbol in symbols
} | _registered_entries()
found = []
for path, symbol in sorted(reachable):
line = _reaches_a_bag(path, symbol)
if line is not None:
found.append(
f"{path.relative_to(_SRT)}:{line} reached from "
f"{symbol}(), which resolution calls"
)
self.assertEqual(
found,
[],
"resolution reaches a config-bag read, which raises on whichever "
"branch gets there first; read the resolving state instead:\n "
+ "\n ".join(found),
)
if __name__ == "__main__":
unittest.main()