traffic_ctl: emit JSON null instead of YAML tilde - #13609
Conversation
The JSON encoders are yaml-cpp emitters, and yaml-cpp spells null as `~`, so any RPC payload holding a null value was rejected by every JSON parser. `hostdb status` hit this on every freshly started server and still exited 0, so callers saw success and then failed to parse. Initialise the two accumulator nodes as sequences so an empty result is `[]` rather than null, which also satisfies the published schema, and set LowerNull on the four JSON emitters so any remaining null is spelled `null`. Both spellings parse back as null in YAML, so the server's ability to accept YAML input is unaffected.
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Fixes invalid JSON output caused by yaml-cpp emitting null as ~, ensuring RPC/traffic_ctl JSON outputs use literal null and empty accumulators emit [] per schema.
Changes:
- Configure multiple yaml-cpp emitters to output
nullviaYAML::LowerNull. - Initialize accumulator
YAML::Nodes as sequences so empty results serialize as[]instead ofnull. - Update JSON-RPC architecture/docs to reflect the null-emission behavior.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/traffic_ctl/CtrlPrinters.cc | Ensures traffic_ctl JSON output uses literal null rather than ~. |
| src/mgmt/rpc/handlers/plugins/Plugins.cc | Makes plugin list accumulator a sequence so empty results emit []. |
| src/mgmt/rpc/handlers/hostdb/HostDB.cc | Makes partitions accumulator a sequence so empty results emit []. |
| include/shared/rpc/yaml_codecs.h | Configures JSONRPC request encoder emitter to output literal null. |
| include/mgmt/rpc/jsonrpc/json/YAMLCodec.h | Configures response encoders to output literal null and updates inline docs. |
| doc/developer-guide/jsonrpc/jsonrpc-architecture.en.rst | Documents the null vs ~ behavior for JSON output. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The tilde fix set LowerNull at the four emitters on the RPC path, but SSLMultiCertMarshaller::to_json and StorageMarshaller::to_json build their own emitters and were missed. Neither can emit a null today, so nothing is broken, but both are one null away from the same bug. Collapse the DoubleQuoted/Flow/LowerNull idiom into ts::Yaml::configure_json_emitter() so a new emitter cannot silently omit part of it. There is now exactly one place in the tree that puts an emitter into JSON mode.
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Include tsutil/YamlCfg.h directly in CtrlPrinters.cc rather than relying on it arriving through the codec headers. Correct the helper comment, which claimed the output was valid JSON for every node type but null. That holds only for the node shapes these callers build; a tag, anchor or alias still emits YAML that JSON does not accept. Say so, and use the canonical yaml-cpp, YAML and JSON spellings in the comments and the architecture doc.
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Nothing asserted that -f json output parses, which is how the yaml-cpp `~` went unnoticed with every CI job green. A gold file cannot catch it either, since it matches `~` forever, so this parses the output for real and covers the empty HostDB and no-plugins cases that trigger it. Also scope the emitter comment: flow style with every scalar quoted parses as JSON but is not type-faithful, so numbers and booleans arrive as strings.
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.
validate_is_valid_json and validate_json_contains appended `| python3 -c ...` to the command, putting a second process at the end of the pipeline. The shell reports only the last command's exit status, so traffic_ctl's own exit code never reached the ReturnCode check that TrafficCtl.add_test sets on every test run. A run whose traffic_ctl exited non-zero passed as long as the output happened to parse. Both checks now run in the autest process as a Testers.Lambda over the captured stdout file. traffic_ctl stays the only process in the run, so its exit status is the one the harness compares, and the JSON result is reported separately with the raw output attached rather than collapsing into the return code. set -o pipefail is not an option here: CI runs /bin/sh as dash, which rejects the flag outright, and it would still merge the two distinct failures into one number. Also quote the hostname interpolated into `hostdb status`, omitting it entirely when empty since an empty argument is not the same as no argument, and factor the duplicated lookup of autest's injected Testers/All into one helper that searches out the frame carrying those names.
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.
…ministic _test_file_globals searched for autest's injected `Testers` alone, so a frame carrying that name but not `All` satisfied the search and then failed inside validate_contains_all with a bare KeyError. Require every injected name the module uses, and name them in the error. _read_stdout opened the captured stream in text mode with no encoding, which means the decode follows the runner's locale. Under LC_ALL=C the preferred encoding is US-ASCII, so a non-ASCII byte in otherwise identical output decoded to U+FFFD there and nowhere else; a validate_json_contains comparison against such a field would then fail for no real reason. Decode as UTF-8, which JSON requires anyway. Undecodable bytes stay replaced rather than strict on purpose. autest treats an exception raised by a tester callback as fatal, setting KillOnFailure and abandoning the remainder of the test run, whereas a replaced byte simply fails the JSON parse and is reported with the output attached.
There was a problem hiding this comment.
🔵 Needs a closer look
The new JSON field-check helper compares parsed values to un-stringified expected values, which can produce false negatives when callers pass non-string expectations.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
tests/gold_tests/traffic_ctl/traffic_ctl_test_utils.py:128
_check_json_fields()comparesstr(doc.get(key))to the rawexpectedvalue. If a caller passes a non-string expected value (e.g.True,0,[]), this will report a mismatch even when the JSON value matches semantically. Coerce the expected side tostr(...)as well so the helper behaves consistently regardless of the expected value type (and matches the previous behavior where everything was stringified into the generated python snippet).
- Files reviewed: 11/11 changed files
- Comments generated: 0 new
- Review effort level: Lite
_check_json_fields stringified only the value read from the output and left the expected side as whatever the caller passed. The shell pipeline it replaced had stringified both sides, because it formatted expectations into python source as quoted string literals, so `validate_json_contains( partitions=[])` matched there and reported a false mismatch here. Coerce both sides. Document the rule that follows from comparing against str() of the parsed value: the expectation is written the way Python renders that value, not the way JSON spells it. Those agree for `[]` and for numbers, but a JSON boolean reads as 'true' rather than True, which matters because get_server_status emits its flags as the strings "true" and "false". Note also that a missing key and a JSON null both render as 'None', so `field=None` passes for a misspelled field. No current caller is affected: all three pass their expectations as strings already.
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.
The note added with the string comparison had it backwards. It claimed a JSON boolean reads as 'true' rather than True, but json.loads turns a real JSON boolean into Python True, which str() renders as 'True'. The 'false' spelling these tests use comes from somewhere else: the emitters they cover set YAML::DoubleQuoted, so every scalar leaves as a JSON string, and get_server_status sends "is_draining": "false". Say that instead, and keep the contrast with a genuine JSON boolean. While here, read each field once per key instead of calling doc.get twice, and capitalize json in the id comment in YAMLCodec.h.
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.
_test_file_globals reached up the call stack for `Testers` and `All` on the premise that autest injects them into each test file's globals rather than exposing them for import. The premise is wrong: core/test.py imports the testers module itself and injects that module under the name, so both are ordinary importables. Import them and delete the helper, which retires the frame walk together with the question of which call site needs which name. Decode captured output as strict UTF-8 and return the failure as a value. Replacing undecodable bytes let output that is not UTF-8, and so not JSON, parse cleanly and pass a check meant to reject exactly that. No wrapped command reaches this today, because yaml-cpp substitutes U+FFFD in its own double quoted scalar writer, so the strict decode states the requirement rather than resting on that staying true. It is a value and not an exception because autest treats an exception from a tester callback as fatal. Guard the field check against a JSON document that is not an object. doc.get assumed a dict, so valid non-object JSON raised AttributeError out of the callback, which sets KillOnFailure and abandons the remainder of the run. Every input now returns a result triple. Drop a docstring claim that an unnamed encoding would follow the runner's locale and mangle non-ASCII output. PEP 540 enables UTF-8 mode for the C and POSIX locales, so it would not.
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.
_check_json_fields read every field with doc.get, so a key that was absent and a key present with a JSON null both arrived as None. An expectation of None therefore matched a misspelled field name, and the check silently accepted output that did not carry the field at all. Test presence before comparing, and report a missing key as missing. That also makes `field=None` mean what it looks like: the field is there and it is null. The docstring said this gap existed; enforce it instead.
The hostdb case asserted `partitions` is `[]`, but the plugin case only checked that the response parsed, so it would have passed on any shape the emitter produced. Compare the whole result instead, the way the connection tracker cases in traffic_ctl_server_output.test.py already do, which pins `plugins` to `[]`. The field sits at result.data.plugins, out of reach of validate_json_contains. Verified in both directions: the assertion passes as written and fails when the expectation is changed to the `~` the emitter produced before the fix.
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.
|
[approve ci autest 3] |
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.
_check_json_fields compared str(actual) to str(want), which flattens the JSON type out of both sides. An array and a string spelling of it become the same text, so a field regressing from "partitions": [] to "partitions": "[]" passed the check -- the class of bug these tests were added to catch. A number regressing to a quoted number was masked the same way. Compare with == against the value the JSON parses to, so expectations are written as Python values: partitions=[] for the array, is_draining='false' for the JSON string the DoubleQuoted emitters produce. Both existing call sites hold under that rule; only the partitions one changes spelling. Null, the case this branch fixes, failed the check before and still does. Render both sides with !r in the failure text. Without it a type mismatch reported "partitions = [] (expected [])".
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
What
traffic_ctladvertises JSON output but emits YAML. The JSON encoders areyaml-cpp emitters configured with
YAML::DoubleQuoted << YAML::Flow, whichproduces something that usually looks like JSON — until a value is null,
which yaml-cpp spells
~.traffic_ctl hostdb statushits this on every freshly started server, in bothdefault and
-f jsonmode, and exits 0 while doing it. A monitoring scriptor dashboard sees success, then dies on the parse.
The
-f rpcwire trace shows the~arrives already formed from the server, sothis is not a client-side rendering problem:
traffic_ctl plugin listhas the same defect whenplugin.configis empty.It also violates its own schema
src/mgmt/rpc/schema/hostdb_status_schema.jsondeclarespartitionsas"type": "array". Validating the live payload against that schema:yaml.safe_loadsucceeding wherejson.loadsfails is the whole bug in oneline: this is YAML labelled as JSON.
Why it happens
Two independent layers.
Layer 1 — the accumulator node is never initialised as a sequence. A
default-constructed
YAML::Nodeis Null, not an empty Sequence. InHostDB.cc, if every partition is empty thepush_backloop never runs, sopartitionsis emitted as null.Plugins.cchas the identical shape.Layer 2 — there is no JSON serializer. Six sites each re-implemented "JSON"
as a yaml-cpp emitter with two manipulators, and none of them mapped null to
null.This is a known limitation finally coming due. Standing comment in
YAMLCodec.h:A second comment in the same file omits the response
idrather than emit itas null, specifically to dodge this. Every other null in every other payload
was still exposed.
The fix
Three commits.
1. Emit
nullinstead of~, and[]for empty accumulators.[].This is what makes the payload schema-conformant —
nullwould still not bean array.
LowerNullon the JSON emitters, so any remaining null anywhere in anypayload is spelled
null. This fixes the class of bug, not just the twoobserved instances.
2. Route every JSON emitter through one helper.
The first commit set
LowerNullat the four emitters on the RPC path, butSSLMultiCertMarshaller::to_jsonandStorageMarshaller::to_jsonbuild theirown emitters off that path and were missed. Neither can emit a null today, so
nothing was broken, but both were one null away from the same bug.
ts::Yaml::configure_json_emitter()ininclude/tsutil/YamlCfg.his now thesingle place that puts an emitter into JSON mode. The invariant is greppable:
3. Review fixes. Direct include of
tsutil/YamlCfg.hinCtrlPrinters.ccrather than relying on a transitive one, and corrected comment wording — the
helper comment had overstated the output as valid JSON for every node type but
null, which holds only for the node shapes these callers build. Tags, anchors
and aliases still emit YAML that JSON does not accept.
LowerNullandEmitter::SetNullFormatexist both in the in-tree yaml-cpp0.9.0 and in 0.8.0, the minimum
EXTERNAL_YAML_CPPaccepts, so both buildconfigurations are covered.
SetNullFormat(...)is used rather than the<< YAML::LowerNullmanipulator because the method isFmtScope::Globalwhilethe stream form routes through
SetLocalValueatFmtScope::Local.Compatibility
This does not trade YAML support for JSON support. In YAML,
~andnullare the same value — YAML 1.2 resolves
~,null,Null,NULLand empty allto null, and yaml-cpp implements exactly that:
The two spellings differ only in portability:
~nullnullis the strict superset, so this moves the output into the intersection ofboth formats rather than favouring one. Verified by round-trip: emitting with
LowerNullproduces{"a": null}, which re-parses through yaml-cpp asIsNull() == true— a genuine null, not the string"null".LowerNullchanges nothing else. Same node set emitted both ways:~null[][]{}{}"~""~""~""null""null""null"Specifically unaffected:
accepting JSON or YAML. This changes encoders only; the decoder and
YAML::Loadpath are untouched.jsonrpc_response_schema.json. It places no type constraint onresult.The
idfield keeps its documented deviation — still omitted, not nulled.tests/contains a tilde.The only conceivable breakage is a consumer string-matching
~, which would bea consumer hand-parsing invalid JSON. No metric names, config keys, or API
signatures change.
Testing
hostdb statushuman"partitions": ~, exit 0"partitions": []hostdb status→json.loadJSONDecodeErrorcol 160hostdb status -f json→json.loadJSONDecodeErrorcol 198-f rpc"partitions": ~"partitions": []plugin list, emptyplugin.config"plugins": ~"plugins": []plugin list, plugins loadedconfig ssl-multicert show --jsonjsonrpc_api_schema)formattargetBoth layers were verified independently. To prove the emitter change on its
own, the sequence-init hunk was reverted and the build rerun, exercising the
untouched uninitialised-node path:
nullrather than~, and it parses.The one autest failure is
basic_plugin_handler, which fails identically onunmodified
master— three plugin init strings never reachtraffic.outonmacOS. Confirmed by building pristine
d01f3caf6aand running it there.Still to do
No autest asserts that
-f jsonoutput parses. That gap is why the tildeshipped green: the suite's only JSON parse assertions are
validate_json_contains(used twice, both onserver status) and the JSONRPCresponse validator, and none of them exercises a payload containing a null.
hostdb statusandplugin listhave no autest coverage at all. A gold filewould not help — it would have matched
~indefinitely, so the assertion has tobe a real parse on the empty-HostDB and no-plugins cases.
Candidate for Backport to 10.2.x, where
hostdb statusfirst shipped.Out of scope
hostdb statusstill emits"timestamp": "1788196772"— a string where theschema declares
integer. This is the remaining schema violation above.YAML::DoubleQuotedquotes every scalar, so every numeric field in every-f jsonpayload is a JSON string. DroppingDoubleQuotedis not a fix —yaml-cpp would then emit unquoted strings and break JSON differently. Needs its
own design decision and a separate issue.
traffic_ctl plugin list -f jsonignores the format flag and prints thehuman-readable table. Unrelated to this change; present before and after.