Impact
ArgParser matches a command's options anywhere in the rest of the command line, and the -- escape added in #13570 does not reach that scan. For the top-level command that is the whole argv, so a global option is recognised even when it appears as a subcommand's value, and even after --, which #13570 made the way to pass a value that starts with -. The same scan lets a global option silently shadow a subcommand option that has the same short name.
What an operator sees, with traffic_ctl (it applies equally to traffic_layout and traffic_cache_tool):
- A value spelled like a global option (
-f, -w, --run-root, --read-timeout-ms, ...) is taken as that option: usage error, exit 64, and -- does not help.
- A value of
-h or -V prints help or the version and exits 0. The command never runs, but a script sees success.
traffic_ctl config reload -w <seconds> sets the global --watch, which nothing in the tree reads, instead of the documented config reload --initial-wait, -w.
It takes a value that happens to match a global option name, so real-world impact is low. There is no workaround for such values. For the -w clash, --initial-wait works.
Version: master @ 738cd651b3
top-level scan unchanged since d9b06a12acf (2018); the "--" escape added in d0fb283406 (#13570)
only covers collecting an option's own values. The -w clash dates from 5bab268cb4 (#12892).
Platform: Darwin 25.6.0, Apple clang 21.0.0
Config: n/a
Proof
Command::parse() runs append_option_data() on the command's own argv before recursing into subcommands, and append_option_data() walks every remaining token with no check for --:
src/tscore/ArgParser.cc#L737-L740, called at #L861:
ArgParser::Command::append_option_data(Arguments &ret, AP_StrVec &args, int index)
{
std::map<std::string, unsigned> check_map;
for (unsigned i = index; i < args.size(); i++) {
-- is only honoured inside handle_args(), once an option has been matched and is collecting its values (#L564, #L591, #L620). A -h anywhere prints help with exit code 0 (#L768-L778).
traffic_ctl registers -w twice, globally for --watch (src/traffic_ctl/traffic_ctl.cc#L95) and on config reload for --initial-wait (#L172). The watch key is never read.
Reproduction, with no traffic_server running; the RPC connection error shows parsing got through:
$ traffic_ctl config set proxy.config.diags.debug.tags -- -m # -m is not a global option: passed through
Error found:
RPC Node Error: connect(attempts=1/5): Couldn't open connection with .../jsonrpc20.sock. Last error: No such file or directory(2)
[exit 2]
$ traffic_ctl config set proxy.config.diags.debug.tags -- -f # -f is: taken as --format
Error: 1 argument(s) expected by format
[exit 64]
$ traffic_ctl config set proxy.config.diags.debug.tags -- -f json
Error: 2 argument(s) expected by set
[exit 64]
$ traffic_ctl config set proxy.config.diags.debug.tags -h # help, exit 0, nothing set
Usage: traffic_ctl [OPTIONS] CMD [ARGS ...]
[exit 0]
$ traffic_ctl config reload -w # documented as --initial-wait
Error: 1 argument(s) expected by watch
[exit 64]
JSON output form (the parse fails before any output is formatted, so it matches):
$ traffic_ctl --format json config set proxy.config.diags.debug.tags -- -f json
Error: 2 argument(s) expected by set
[exit 64]
Expected: after --, -f and -h are the value of proxy.config.diags.debug.tags, as -m already is.
-- itself works. It is the global scan that ignores it: -m is not a global option and passes, -f and -h are and do not.
Proposed change
-- is scoped to one option today (#13570):
- For a fixed or at-most-one option it escapes the next value, and options written after that value still parse:
server debug enable --tags -- -a, config get -c -- -weird.yaml proxy.config.x.
- For a variable-arity option it ends that option's own recognition:
config reload -D -- -m.
The parent levels just need to leave alone the token that -- escapes. Let append_option_data() skip -- and the token after it, and leave both in place for the option or command further down that owns them:
--- a/src/tscore/ArgParser.cc
+++ b/src/tscore/ArgParser.cc
@@ -738,6 +738,11 @@ ArgParser::Command::append_option_data(Arguments &ret, AP_StrVec &args, int inde
{
std::map<std::string, unsigned> check_map;
for (unsigned i = index; i < args.size(); i++) {
+ // "--" escapes the token after it, which belongs to an option or command further down: leave both for it.
+ if (args[i] == "--") {
+ ++i;
+ continue;
+ }
// find matches of the arg
if (args[i][0] == '-' && args[i][1] == '-' && args[i].find('=') != std::string::npos) {
// deal with --args=
I tried it on the tree above. The escaped tokens are handed on:
config set <rec> -- -f and config set <rec> -- -h now reach the RPC.
Every existing pattern keeps its behaviour, with exit codes and results identical to master:
server debug enable --tags -- -a -f json
config get --cold -- -weird.yaml <rec> -f json
config reload -D -- -m -f json
config set <rec> -f json -- -x
The tscore unit tests pass (115/115). So do the AuTests that use --: traffic_ctl_server_debug, traffic_ctl_cold_config, config_reload_directive_cli, traffic_ctl_config_output, traffic_ctl_config_reload.
I rejected the obvious alternative, stopping option recognition at -- altogether (break). It turns -- into an end-of-line marker at every level and breaks a global option written after an escaped value: server debug enable --tags -- -a -f json fails with Unknown command, option or args: '-f' 'json'.
What skip-one does not cover:
- After a variable-arity option's
--, only the first value is protected from the global scan. In config reload -D -- -a -f json, -f json is still taken as the global --format, the same as today. That contradicts the traffic_ctl.en.rst note that options written after -- are swallowed by -D. --directive=-value stays the way to pass several dash values.
- The
-w clash. The global --watch is undocumented and unused, so dropping it (or its short form) gives config reload -w back to --initial-wait.
Still to do:
- A unit test in
src/tscore/unit_tests/test_ArgParser.cc: a subcommand value after -- that spells a global option is left to the subcommand, and a global option after the escaped value is still recognised.
- Optionally, reject at registration a subcommand option whose long or short name is already taken by an ancestor, so the next clash fails at startup instead of being silently shadowed.
Related:
Impact
ArgParsermatches a command's options anywhere in the rest of the command line, and the--escape added in #13570 does not reach that scan. For the top-level command that is the whole argv, so a global option is recognised even when it appears as a subcommand's value, and even after--, which #13570 made the way to pass a value that starts with-. The same scan lets a global option silently shadow a subcommand option that has the same short name.What an operator sees, with
traffic_ctl(it applies equally totraffic_layoutandtraffic_cache_tool):-f,-w,--run-root,--read-timeout-ms, ...) is taken as that option: usage error, exit 64, and--does not help.-hor-Vprints help or the version and exits 0. The command never runs, but a script sees success.traffic_ctl config reload -w <seconds>sets the global--watch, which nothing in the tree reads, instead of the documentedconfig reload --initial-wait, -w.It takes a value that happens to match a global option name, so real-world impact is low. There is no workaround for such values. For the
-wclash,--initial-waitworks.Proof
Command::parse()runsappend_option_data()on the command's own argv before recursing into subcommands, andappend_option_data()walks every remaining token with no check for--:src/tscore/ArgParser.cc#L737-L740, called at#L861:--is only honoured insidehandle_args(), once an option has been matched and is collecting its values (#L564,#L591,#L620). A-hanywhere prints help with exit code 0 (#L768-L778).traffic_ctlregisters-wtwice, globally for--watch(src/traffic_ctl/traffic_ctl.cc#L95) and onconfig reloadfor--initial-wait(#L172). Thewatchkey is never read.Reproduction, with no traffic_server running; the RPC connection error shows parsing got through:
JSON output form (the parse fails before any output is formatted, so it matches):
Expected: after
--,-fand-hare the value ofproxy.config.diags.debug.tags, as-malready is.--itself works. It is the global scan that ignores it:-mis not a global option and passes,-fand-hare and do not.Proposed change
--is scoped to one option today (#13570):server debug enable --tags -- -a,config get -c -- -weird.yaml proxy.config.x.config reload -D -- -m.The parent levels just need to leave alone the token that
--escapes. Letappend_option_data()skip--and the token after it, and leave both in place for the option or command further down that owns them:I tried it on the tree above. The escaped tokens are handed on:
config set <rec> -- -fandconfig set <rec> -- -hnow reach the RPC.Every existing pattern keeps its behaviour, with exit codes and results identical to master:
server debug enable --tags -- -a -f jsonconfig get --cold -- -weird.yaml <rec> -f jsonconfig reload -D -- -m -f jsonconfig set <rec> -f json -- -xThe tscore unit tests pass (115/115). So do the AuTests that use
--:traffic_ctl_server_debug,traffic_ctl_cold_config,config_reload_directive_cli,traffic_ctl_config_output,traffic_ctl_config_reload.I rejected the obvious alternative, stopping option recognition at
--altogether (break). It turns--into an end-of-line marker at every level and breaks a global option written after an escaped value:server debug enable --tags -- -a -f jsonfails withUnknown command, option or args: '-f' 'json'.What skip-one does not cover:
--, only the first value is protected from the global scan. Inconfig reload -D -- -a -f json,-f jsonis still taken as the global--format, the same as today. That contradicts thetraffic_ctl.en.rstnote that options written after--are swallowed by-D.--directive=-valuestays the way to pass several dash values.-wclash. The global--watchis undocumented and unused, so dropping it (or its short form) givesconfig reload -wback to--initial-wait.Still to do:
src/tscore/unit_tests/test_ArgParser.cc: a subcommand value after--that spells a global option is left to the subcommand, and a global option after the escaped value is still recognised.Related:
--escape for option values.