From f41599c8e9435f3dfec60b872c2b4ae31177efdd Mon Sep 17 00:00:00 2001 From: Karen Arutyunov Date: Sat, 10 Oct 2020 17:22:46 +0300 Subject: Add support for test timeouts --- doc/cli.sh | 1 + doc/manual.cli | 22 + doc/testscript.cli | 52 +- libbuild2/adhoc-rule-buildscript.cxx | 25 +- libbuild2/adhoc-rule-buildscript.hxx | 9 +- libbuild2/algorithm.cxx | 2 +- libbuild2/build/script/script.cxx | 28 +- libbuild2/build/script/script.hxx | 26 +- libbuild2/file.cxx | 1 + libbuild2/rule.cxx | 7 + libbuild2/rule.hxx | 16 + libbuild2/script/builtin-options.cxx | 271 ++++++++++ libbuild2/script/builtin-options.hxx | 58 ++ libbuild2/script/builtin-options.ixx | 9 + libbuild2/script/builtin.cli | 5 + libbuild2/script/parser.cxx | 95 +++- libbuild2/script/parser.hxx | 16 +- libbuild2/script/run.cxx | 639 +++++++++++++++++++---- libbuild2/script/script.cxx | 26 +- libbuild2/script/script.hxx | 53 +- libbuild2/script/script.ixx | 35 +- libbuild2/script/timeout.cxx | 26 + libbuild2/script/timeout.hxx | 52 ++ libbuild2/script/timeout.ixx | 44 ++ libbuild2/test/common.cxx | 72 +++ libbuild2/test/common.hxx | 39 ++ libbuild2/test/init.cxx | 38 +- libbuild2/test/module.cxx | 12 + libbuild2/test/module.hxx | 2 + libbuild2/test/operation.cxx | 36 +- libbuild2/test/rule.cxx | 149 +++++- libbuild2/test/script/parser+env.test.testscript | 20 +- libbuild2/test/script/script.cxx | 106 +++- libbuild2/test/script/script.hxx | 61 ++- libbuild2/utility.cxx | 18 + libbuild2/utility.hxx | 7 + tests/recipe/buildscript/testscript | 120 +++++ tests/test/script/builtin/sleep.testscript | 18 + tests/test/script/runner/driver.cxx | 30 +- tests/test/script/runner/env.testscript | 29 +- tests/test/script/runner/set.testscript | 118 ++++- tests/test/script/runner/timeout.testscript | 503 ++++++++++++++++++ tests/test/simple/generated/buildfile | 3 +- tests/test/simple/generated/driver.cxx | 32 +- tests/test/simple/generated/testscript | 57 ++ 45 files changed, 2779 insertions(+), 209 deletions(-) create mode 100644 libbuild2/script/timeout.cxx create mode 100644 libbuild2/script/timeout.hxx create mode 100644 libbuild2/script/timeout.ixx create mode 100644 libbuild2/test/module.cxx create mode 100644 tests/test/script/runner/timeout.testscript diff --git a/doc/cli.sh b/doc/cli.sh index 21d38d2..d1fcb77 100755 --- a/doc/cli.sh +++ b/doc/cli.sh @@ -101,6 +101,7 @@ function compile_doc () # --link-regex '%bpkg(#.+)?%../../bpkg/doc/build2-package-manager-manual.xhtml$1%' \ --link-regex '%bdep([-.].+)%../../bdep/doc/bdep$1%' \ --link-regex '%testscript(#.+)?%build2-testscript-manual.xhtml$1%' \ +--link-regex '%build2(#.+)?%build2-build-system-manual.xhtml$1%' \ --output-prefix "$2" \ --output-suffix "$3" \ "$1" diff --git a/doc/manual.cli b/doc/manual.cli index 73f15a5..4d4e67d 100644 --- a/doc/manual.cli +++ b/doc/manual.cli @@ -5595,6 +5595,28 @@ treated specially. It enables all the tests at and under its directory. This special treatment can be inhibited by specifying the target type explicitly (for example, \c{dir{foo/\}}). +The test execution time can be limited using the \c{config.test.timeout} +variable. Its value has the \c{/} form where +the timeouts are specified in seconds and either of them (but not both) can be +omitted. The left hand side sets the timeout for the whole \c{test} operation +and the right hand side \- for individual tests. The zero value clears the +previously set timeout. For example: + +\ +b test config.test.timeout=20 # Test operation. +b test config.test.timeout=20/5 # Test operation and individual tests. +b test config.test.timeout=/5 # Individual tests. +\ + +The test timeout can be specified on multiple nested root scopes. For example, +we can specify a greater timeout for the entire build configuration and lesser +ones for individual projects. The tests must complete before the nearest of +the enclosing scope timeouts. Failed that, the timed out tests are terminated +forcibly causing the entire \c{test} operation to fail. See also the +\l{testscript#builtins-timeout \c{timeout}} builtin for specifying timeouts +from within the tests and test groups. + + \h1#module-install|\c{install} Module| \N{This chapter is a work in progress and is incomplete.} diff --git a/doc/testscript.cli b/doc/testscript.cli index d838cc0..b68844b 100644 --- a/doc/testscript.cli +++ b/doc/testscript.cli @@ -1206,7 +1206,7 @@ token is an unquoted word, then the second token of the line is examined in the \c{second_token} mode (see below). If it is a variable assignment (either \c{+=}, \c{=+}, or \c{=}), then the line type is a variable line. Otherwise, it is a test command line. Note that variables with computed names can only -be set using the \l{#builtins-set \c{set} pseudo-builtin}. +be set using the \l{#builtins-set \c{set}} pseudo-builtin. The Testscript language defines the following distinct lexing modes (or contexts): @@ -2429,10 +2429,11 @@ with a newline. \h#builtins-env|\c{env}| \ -env [-u ]... [-] [=]... -- +env [-t ] [-u ]... [-] [=]... -- \ -Run a command adding/removing the variables to/from the environment. +Run a command limiting its execution time and/or adding/removing the variables +to/from the environment. Note that \c{env} is a \i{pseudo-builtin}. In particular, its name and the \c{--} separator must be specified \i{literally} on the command line. @@ -2449,6 +2450,11 @@ env - --unset=FOO -- $* \dl| +\li|\n\c{-t|--timeout } + + Terminate the command if it fails to complete within the specified number + of seconds. See also \l{#builtins-timeout \c{timeout}} builtin.| + \li|\n\c{-u|--unset } Remove the specified variable from the environment.|| @@ -2823,6 +2829,46 @@ Test the specified \i{path} according to one of the following options. Succeed Note that tests dereference symbolic links. +\h#builtins-timeout|\c{timeout}| + +\ +timeout [-s] []/[] +timeout [-s] +\ + +Specify test and/or test group timeout. + +The first form sets the test group and/or individual test timeouts and can +only be used as a setup command. Either of the timeouts (but not both) can be +omitted. + +The second form sets the test group timeout if used as a setup or teardown +command and the remaining test fragment timeout if used as a test command. + +In both forms the timeouts are specified in seconds with the zero value +clearing the previously set timeout. + +Note that \c{timeout} is a \i{pseudo-builtin}. In particular, it must be the +only command in the pipe expression, it either succeeds or terminates +abnormally, and its standard streams cannot be redirected. + +The timeouts can be set on multiple levels: via the \c{config.test.timeout} +variable on the (potentially nested) project root scopes (see +\l{build2#module-test \c{test}} module for details), with the \c{timeout} +builtin in the nested test group scopes and the test scope, and with the +\c{env} builtin for individual commands. Each command must complete before the +nearest timeout from its timeout hierarchy. Failed that, a command is +terminated forcibly causing the entire \c{test} operation to fail unless the +expired timeout was specified with the \c{--success} option, in which case the +timed out command is assumed to have succeeded. + +\dl| + +\li|\n\c{-s|--success} + + Assume a command terminated due to this timeout to have succeeded.|| + + \h#builtins-touch|\c{touch}| \ diff --git a/libbuild2/adhoc-rule-buildscript.cxx b/libbuild2/adhoc-rule-buildscript.cxx index 3dbe817..a09d2c7 100644 --- a/libbuild2/adhoc-rule-buildscript.cxx +++ b/libbuild2/adhoc-rule-buildscript.cxx @@ -124,8 +124,21 @@ namespace build2 } recipe adhoc_buildscript_rule:: - apply (action a, target& t, match_extra&) const + apply (action a, target& t, match_extra& e) const { + return apply (a, t, e, nullopt); + } + + recipe adhoc_buildscript_rule:: + apply (action a, target& t, match_extra&, const optional& d) const + { + // We don't support deadlines of any of these case (see below). + // + if (d && (a.outer () || + t.data () || + (a == perform_update_id && t.is_a ()))) + return empty_recipe; + // If this is an outer operation (e.g., update-for-test), then delegate to // the inner. // @@ -171,9 +184,9 @@ namespace build2 } else { - return [this] (action a, const target& t) + return [d, this] (action a, const target& t) { - return default_action (a, t); + return default_action (a, t, d); }; } } @@ -584,7 +597,9 @@ namespace build2 } target_state adhoc_buildscript_rule:: - default_action (action a, const target& t) const + default_action (action a, + const target& t, + const optional& deadline) const { tracer trace ("adhoc_buildscript_rule::default_action"); @@ -597,7 +612,7 @@ namespace build2 const scope& bs (t.base_scope ()); const scope& rs (*bs.root_scope ()); - build::script::environment e (a, t, script.temp_dir); + build::script::environment e (a, t, script.temp_dir, deadline); build::script::parser p (ctx); if (verb == 1) diff --git a/libbuild2/adhoc-rule-buildscript.hxx b/libbuild2/adhoc-rule-buildscript.hxx index d7543c4..89515eb 100644 --- a/libbuild2/adhoc-rule-buildscript.hxx +++ b/libbuild2/adhoc-rule-buildscript.hxx @@ -18,7 +18,8 @@ namespace build2 // // Note: not exported and should not be used directly (i.e., registered). // - class adhoc_buildscript_rule: public adhoc_rule + class adhoc_buildscript_rule: public adhoc_rule, + public adhoc_rule_with_deadline { public: virtual bool @@ -28,11 +29,15 @@ namespace build2 virtual recipe apply (action, target&, match_extra&) const override; + virtual recipe + apply (action, target&, match_extra&, + const optional&) const override; + target_state perform_update_file (action, const target&) const; target_state - default_action (action, const target&) const; + default_action (action, const target&, const optional&) const; adhoc_buildscript_rule (const location& l, size_t b) : adhoc_rule ("", l, b) {} diff --git a/libbuild2/algorithm.cxx b/libbuild2/algorithm.cxx index b540138..e535af3 100644 --- a/libbuild2/algorithm.cxx +++ b/libbuild2/algorithm.cxx @@ -527,7 +527,7 @@ namespace build2 { case target_decl::prereq_new: { - dr << info << "target " << t << " is no declared in any buildfile"; + dr << info << "target " << t << " is not declared in any buildfile"; if (t.is_a ()) dr << info << "perhaps it is a missing source file?"; diff --git a/libbuild2/build/script/script.cxx b/libbuild2/build/script/script.cxx index 3485f54..c6b57c3 100644 --- a/libbuild2/build/script/script.cxx +++ b/libbuild2/build/script/script.cxx @@ -7,6 +7,8 @@ #include +#include + #include using namespace std; @@ -17,12 +19,17 @@ namespace build2 { namespace script { + using build2::script::to_deadline; + // environment // static const optional wd_name ("current directory"); environment:: - environment (action a, const target_type& t, bool temp) + environment (action a, + const target_type& t, + bool temp, + const optional& dl) : build2::script::environment ( t.ctx, cast (t.ctx.global_scope["build.host"]), @@ -32,7 +39,8 @@ namespace build2 redirect (redirect_type::merge, 2), redirect (redirect_type::pass)), target (t), - vars (context, false /* global */) + vars (context, false /* global */), + script_deadline (to_deadline (dl, false /* success */)) { // Set special variables. // @@ -58,8 +66,10 @@ namespace build2 // names ns; for (const target_type* pt: t.prerequisite_targets[a]) + { if (pt != nullptr) pt->as_name (ns); + } assign (var_pool.insert ("<")) = move (ns); } @@ -231,6 +241,20 @@ namespace build2 return r; } + + void environment:: + set_timeout (const string& t, bool success, const location& l) + { + fragment_deadline = + to_deadline (parse_deadline (t, "buildscript timeout", l), + success); + } + + optional environment:: + effective_deadline () + { + return earlier (script_deadline, fragment_deadline); + } } } } diff --git a/libbuild2/build/script/script.hxx b/libbuild2/build/script/script.hxx index f4d70ac..9284813 100644 --- a/libbuild2/build/script/script.hxx +++ b/libbuild2/build/script/script.hxx @@ -26,6 +26,8 @@ namespace build2 using build2::script::redirect_type; using build2::script::expr_term; using build2::script::command_expr; + using build2::script::deadline; + using build2::script::timeout; // Notes: // @@ -83,7 +85,10 @@ namespace build2 public: using target_type = build2::target; - environment (action, const target_type&, bool temp_dir); + environment (action, + const target_type&, + bool temp_dir, + const optional& deadline = nullopt); environment (environment&&) = delete; environment (const environment&) = delete; @@ -98,7 +103,7 @@ namespace build2 // Script-local variable pool and map. // // Note that it may be tempting to reuse the rule-specific variables - // for this but they should no be modified during execution (i.e., + // for this but they should not be modified during execution (i.e., // they are for intra-rule communication; perhaps we could have a // special builtin that sets such variables during match). // @@ -125,12 +130,29 @@ namespace build2 // auto_rmdir temp_dir; + // The whole script and the remaining script fragment execution + // deadlines (the latter is set by the timeout builtin). + // + optional script_deadline; + optional fragment_deadline; + virtual void set_variable (string&& name, names&&, const string& attrs, const location&) override; + // Parse the specified in seconds timeout and set the remaining script + // fragment execution deadline. Reset it to nullopt on zero. + // + virtual void + set_timeout (const string&, bool success, const location&) override; + + // Return the nearest of the script and fragment execution deadlines. + // + virtual optional + effective_deadline () override; + virtual void create_temp_dir () override; diff --git a/libbuild2/file.cxx b/libbuild2/file.cxx index 701367f..23bd103 100644 --- a/libbuild2/file.cxx +++ b/libbuild2/file.cxx @@ -1767,6 +1767,7 @@ namespace build2 { is.close (); pr.kill (); + pr.wait (); throw_generic_ios_failure (EFBIG, "output too large"); } } diff --git a/libbuild2/rule.cxx b/libbuild2/rule.cxx index a671b04..6af074c 100644 --- a/libbuild2/rule.cxx +++ b/libbuild2/rule.cxx @@ -349,6 +349,13 @@ namespace build2 { } + // adhoc_rule_with_deadline (vtable) + // + adhoc_rule_with_deadline:: + ~adhoc_rule_with_deadline () + { + } + // Scope operation callback that cleans up recipe builds. // target_state adhoc_rule:: diff --git a/libbuild2/rule.hxx b/libbuild2/rule.hxx index fa61bc5..8796659 100644 --- a/libbuild2/rule.hxx +++ b/libbuild2/rule.hxx @@ -205,6 +205,22 @@ namespace build2 static target_state clean_recipes_build (action, const scope&, const dir&); }; + + // A mix-in interface for ad hoc rules that support recipes with deadlines. + // + class adhoc_rule_with_deadline + { + public: + virtual + ~adhoc_rule_with_deadline (); + + // Return empty recipe if one with the deadline cannot be provided for + // this action. In this case the caller may fallback to the normal + // apply(). + // + virtual recipe + apply (action, target&, match_extra&, const optional&) const = 0; + }; } #endif // LIBBUILD2_RULE_HXX diff --git a/libbuild2/script/builtin-options.cxx b/libbuild2/script/builtin-options.cxx index abf325f..9b91bd2 100644 --- a/libbuild2/script/builtin-options.cxx +++ b/libbuild2/script/builtin-options.cxx @@ -652,6 +652,277 @@ namespace build2 return r; } + + // timeout_options + // + + timeout_options:: + timeout_options () + : success_ () + { + } + + timeout_options:: + timeout_options (int& argc, + char** argv, + bool erase, + ::build2::script::cli::unknown_mode opt, + ::build2::script::cli::unknown_mode arg) + : success_ () + { + ::build2::script::cli::argv_scanner s (argc, argv, erase); + _parse (s, opt, arg); + } + + timeout_options:: + timeout_options (int start, + int& argc, + char** argv, + bool erase, + ::build2::script::cli::unknown_mode opt, + ::build2::script::cli::unknown_mode arg) + : success_ () + { + ::build2::script::cli::argv_scanner s (start, argc, argv, erase); + _parse (s, opt, arg); + } + + timeout_options:: + timeout_options (int& argc, + char** argv, + int& end, + bool erase, + ::build2::script::cli::unknown_mode opt, + ::build2::script::cli::unknown_mode arg) + : success_ () + { + ::build2::script::cli::argv_scanner s (argc, argv, erase); + _parse (s, opt, arg); + end = s.end (); + } + + timeout_options:: + timeout_options (int start, + int& argc, + char** argv, + int& end, + bool erase, + ::build2::script::cli::unknown_mode opt, + ::build2::script::cli::unknown_mode arg) + : success_ () + { + ::build2::script::cli::argv_scanner s (start, argc, argv, erase); + _parse (s, opt, arg); + end = s.end (); + } + + timeout_options:: + timeout_options (::build2::script::cli::scanner& s, + ::build2::script::cli::unknown_mode opt, + ::build2::script::cli::unknown_mode arg) + : success_ () + { + _parse (s, opt, arg); + } + + typedef + std::map + _cli_timeout_options_map; + + static _cli_timeout_options_map _cli_timeout_options_map_; + + struct _cli_timeout_options_map_init + { + _cli_timeout_options_map_init () + { + _cli_timeout_options_map_["--success"] = + &::build2::script::cli::thunk< timeout_options, bool, &timeout_options::success_ >; + _cli_timeout_options_map_["-s"] = + &::build2::script::cli::thunk< timeout_options, bool, &timeout_options::success_ >; + } + }; + + static _cli_timeout_options_map_init _cli_timeout_options_map_init_; + + bool timeout_options:: + _parse (const char* o, ::build2::script::cli::scanner& s) + { + _cli_timeout_options_map::const_iterator i (_cli_timeout_options_map_.find (o)); + + if (i != _cli_timeout_options_map_.end ()) + { + (*(i->second)) (*this, s); + return true; + } + + return false; + } + + bool timeout_options:: + _parse (::build2::script::cli::scanner& s, + ::build2::script::cli::unknown_mode opt_mode, + ::build2::script::cli::unknown_mode arg_mode) + { + // Can't skip combined flags (--no-combined-flags). + // + assert (opt_mode != ::build2::script::cli::unknown_mode::skip); + + bool r = false; + bool opt = true; + + while (s.more ()) + { + const char* o = s.peek (); + + if (std::strcmp (o, "--") == 0) + { + opt = false; + s.skip (); + r = true; + continue; + } + + if (opt) + { + if (_parse (o, s)) + { + r = true; + continue; + } + + if (std::strncmp (o, "-", 1) == 0 && o[1] != '\0') + { + // Handle combined option values. + // + std::string co; + if (const char* v = std::strchr (o, '=')) + { + co.assign (o, 0, v - o); + ++v; + + int ac (2); + char* av[] = + { + const_cast (co.c_str ()), + const_cast (v) + }; + + ::build2::script::cli::argv_scanner ns (0, ac, av); + + if (_parse (co.c_str (), ns)) + { + // Parsed the option but not its value? + // + if (ns.end () != 2) + throw ::build2::script::cli::invalid_value (co, v); + + s.next (); + r = true; + continue; + } + else + { + // Set the unknown option and fall through. + // + o = co.c_str (); + } + } + + // Handle combined flags. + // + char cf[3]; + { + const char* p = o + 1; + for (; *p != '\0'; ++p) + { + if (!((*p >= 'a' && *p <= 'z') || + (*p >= 'A' && *p <= 'Z') || + (*p >= '0' && *p <= '9'))) + break; + } + + if (*p == '\0') + { + for (p = o + 1; *p != '\0'; ++p) + { + std::strcpy (cf, "-"); + cf[1] = *p; + cf[2] = '\0'; + + int ac (1); + char* av[] = + { + cf + }; + + ::build2::script::cli::argv_scanner ns (0, ac, av); + + if (!_parse (cf, ns)) + break; + } + + if (*p == '\0') + { + // All handled. + // + s.next (); + r = true; + continue; + } + else + { + // Set the unknown option and fall through. + // + o = cf; + } + } + } + + switch (opt_mode) + { + case ::build2::script::cli::unknown_mode::skip: + { + s.skip (); + r = true; + continue; + } + case ::build2::script::cli::unknown_mode::stop: + { + break; + } + case ::build2::script::cli::unknown_mode::fail: + { + throw ::build2::script::cli::unknown_option (o); + } + } + + break; + } + } + + switch (arg_mode) + { + case ::build2::script::cli::unknown_mode::skip: + { + s.skip (); + r = true; + continue; + } + case ::build2::script::cli::unknown_mode::stop: + { + break; + } + case ::build2::script::cli::unknown_mode::fail: + { + throw ::build2::script::cli::unknown_argument (o); + } + } + + break; + } + + return r; + } } } diff --git a/libbuild2/script/builtin-options.hxx b/libbuild2/script/builtin-options.hxx index 5a3f153..d0d3c31 100644 --- a/libbuild2/script/builtin-options.hxx +++ b/libbuild2/script/builtin-options.hxx @@ -326,6 +326,64 @@ namespace build2 bool newline_; bool whitespace_; }; + + class timeout_options + { + public: + timeout_options (); + + timeout_options (int& argc, + char** argv, + bool erase = false, + ::build2::script::cli::unknown_mode option = ::build2::script::cli::unknown_mode::fail, + ::build2::script::cli::unknown_mode argument = ::build2::script::cli::unknown_mode::stop); + + timeout_options (int start, + int& argc, + char** argv, + bool erase = false, + ::build2::script::cli::unknown_mode option = ::build2::script::cli::unknown_mode::fail, + ::build2::script::cli::unknown_mode argument = ::build2::script::cli::unknown_mode::stop); + + timeout_options (int& argc, + char** argv, + int& end, + bool erase = false, + ::build2::script::cli::unknown_mode option = ::build2::script::cli::unknown_mode::fail, + ::build2::script::cli::unknown_mode argument = ::build2::script::cli::unknown_mode::stop); + + timeout_options (int start, + int& argc, + char** argv, + int& end, + bool erase = false, + ::build2::script::cli::unknown_mode option = ::build2::script::cli::unknown_mode::fail, + ::build2::script::cli::unknown_mode argument = ::build2::script::cli::unknown_mode::stop); + + timeout_options (::build2::script::cli::scanner&, + ::build2::script::cli::unknown_mode option = ::build2::script::cli::unknown_mode::fail, + ::build2::script::cli::unknown_mode argument = ::build2::script::cli::unknown_mode::stop); + + // Option accessors. + // + const bool& + success () const; + + // Implementation details. + // + protected: + bool + _parse (const char*, ::build2::script::cli::scanner&); + + private: + bool + _parse (::build2::script::cli::scanner&, + ::build2::script::cli::unknown_mode option, + ::build2::script::cli::unknown_mode argument); + + public: + bool success_; + }; } } diff --git a/libbuild2/script/builtin-options.ixx b/libbuild2/script/builtin-options.ixx index dc59f98..5edf31a 100644 --- a/libbuild2/script/builtin-options.ixx +++ b/libbuild2/script/builtin-options.ixx @@ -173,6 +173,15 @@ namespace build2 { return this->whitespace_; } + + // timeout_options + // + + inline const bool& timeout_options:: + success () const + { + return this->success_; + } } } diff --git a/libbuild2/script/builtin.cli b/libbuild2/script/builtin.cli index 68db23e..1a6f523 100644 --- a/libbuild2/script/builtin.cli +++ b/libbuild2/script/builtin.cli @@ -17,5 +17,10 @@ namespace build2 bool --newline|-n; bool --whitespace|-w; }; + + class timeout_options + { + bool --success|-s; + }; } } diff --git a/libbuild2/script/parser.cxx b/libbuild2/script/parser.cxx index d5cff1a..b4184ea 100644 --- a/libbuild2/script/parser.cxx +++ b/libbuild2/script/parser.cxx @@ -1027,7 +1027,9 @@ namespace build2 bool env (false); if (prog && tt == type::word && t.value == "env") { - c.variables = parse_env_builtin (t, tt); + parsed_env r (parse_env_builtin (t, tt)); + c.variables = move (r.variables); + c.timeout = r.timeout; env = true; } @@ -1287,7 +1289,7 @@ namespace build2 return make_pair (move (expr), move (hd)); } - environment_vars parser:: + parser::parsed_env parser:: parse_env_builtin (token& t, token_type& tt) { // enter: 'env' word token @@ -1295,11 +1297,10 @@ namespace build2 next (t, tt); // Skip 'env'. - // Note that the -u option and its value can belong to the different - // name chunks. That's why we parse the env builtin arguments in the - // chunking mode into the argument/location pair list up to the '--' - // separator and parse this list into the variable sets/unsets - // afterwords. + // Note that an option name and value can belong to different name + // chunks. That's why we parse the env builtin arguments in the chunking + // mode into the argument/location pair list up to the '--' separator + // and parse this list into the variable sets/unsets afterwords. // // Align the size with environment_vars (double because of -u // which is two arguments). @@ -1352,13 +1353,13 @@ namespace build2 // Parse the env builtin options and arguments. // - environment_vars r; + parsed_env r; // Note: args is empty in the pre-parse mode. // auto i (as.begin ()), e (as.end ()); - // Parse the variable unsets (from options). + // Parse options (the timeout and variable unsets). // for (; i != e; ++i) { @@ -1372,32 +1373,76 @@ namespace build2 break; } - // Unset the variable, adding its name to the resulting variable list. + // We should probably switch to CLI if we need anything more + // elaborate. Note however, that we will have no precise error + // location then. // - auto unset = [&r, &i, this] (string&& v, const char* o) + + // If this is an option represented with its long or short name, then + // return its value as string and nullopt otherwise. In the former + // case strip the value assignment from the option, if it is in the + // = form, and fail if the option value is empty. + // + auto str = [&i, &e, &o, &l, this] (const char* lo, const char* so) { - if (v.empty ()) + optional r; + + if (o == lo || o == so) + { + if (++i == e) + fail (l) << "env: missing value for option '" << o << "'"; + + r = move (i->first); + } + else + { + size_t n (string::traits_type::length (lo)); + + if (o.compare (0, n, lo) == 0 && o[n] == '=') + { + r = string (o, n + 1); + o.resize (n); + } + } + + if (r && r->empty ()) fail (i->second) << "env: empty value for option '" << o << "'"; - if (v.find ('=') != string::npos) - fail (i->second) << "env: invalid value '" << v << "' for " - << "option '" << o << "': contains '='"; + return r; + }; + + // As above but convert the option value to a number and fail on + // error. + // + auto num = [&i, &o, &str, this] (const char* ln, const char* sn) + { + optional r; + if (optional s = str (ln, sn)) + { + r = parse_number (*s); + + if (!r) + fail (i->second) << "env: invalid value '" << *s + << "' for option '" << o << "'"; + } - r.push_back (move (v)); + return r; }; - // If this is the --unset|-u option then add the variable unset and - // bail out to parsing the variable sets otherwise. + // Parse a known option or bail out to parsing the variable sets. // - if (o == "--unset" || o == "-u") + if (optional v = num ("--timeout", "-t")) { - if (++i == e) - fail (l) << "env: missing value for option '" << o << "'"; + r.timeout = chrono::seconds (*v); + } + else if (optional v = str ("--unset", "-u")) + { + if (v->find ('=') != string::npos) + fail (i->second) << "env: invalid value '" << *v << "' for " + << "option '" << o << "': contains '='"; - unset (move (i->first), o.c_str ()); + r.variables.push_back (move (*v)); } - else if (o.compare (0, 8, "--unset=") == 0) - unset (string (o, 8), "--unset"); else break; } @@ -1421,7 +1466,7 @@ namespace build2 // Add the variable set to the resulting list. // - r.push_back (move (a)); + r.variables.push_back (move (a)); } return r; diff --git a/libbuild2/script/parser.hxx b/libbuild2/script/parser.hxx index da69591..9098b3c 100644 --- a/libbuild2/script/parser.hxx +++ b/libbuild2/script/parser.hxx @@ -130,12 +130,18 @@ namespace build2 pre_parse_line_start (token&, token_type&, lexer_mode); // Parse the env pseudo-builtin arguments up to the program name. Return - // the list of the variables that should be unset ("name") and/or set - // ("name=value") in the command environment and the token/type that - // starts the program name. Note that the variable unsets come first, if - // present. + // the program execution timeout, the list of the variables that should + // be unset ("name") and/or set ("name=value") in the command + // environment, and the token/type that starts the program name. Note + // that the variable unsets come first, if present. // - environment_vars + struct parsed_env + { + optional timeout; + environment_vars variables; + }; + + parsed_env parse_env_builtin (token&, token_type&); // Execute. diff --git a/libbuild2/script/run.cxx b/libbuild2/script/run.cxx index 8c71b32..f13359f 100644 --- a/libbuild2/script/run.cxx +++ b/libbuild2/script/run.cxx @@ -3,6 +3,12 @@ #include +#ifndef _WIN32 +# include // SIG* +#else +# include // DBG_TERMINATE_PROCESS +#endif + #include // streamsize #include @@ -15,6 +21,7 @@ #include #include +#include #include using namespace std; @@ -757,6 +764,39 @@ namespace build2 return false; } + // The timeout pseudo-builtin: set the script timeout. See the script- + // specific set_timeout() implementations for the exact semantics. + // + // timeout [--success|-s] + // + static void + timeout_builtin (environment& env, + const strings& args, + const location& ll) + { + try + { + // Parse arguments. + // + cli::vector_scanner scan (args); + timeout_options ops (scan); + + if (!scan.more ()) + fail (ll) << "missing timeout"; + + string a (scan.next ()); + + if (scan.more ()) + fail (ll) << "unexpected argument '" << scan.next () << "'"; + + env.set_timeout (a, ops.success (), ll); + } + catch (const cli::exception& e) + { + fail (ll) << "timeout: " << e; + } + } + // The exit pseudo-builtin: exit the script successfully, or print the // diagnostics and exit the script unsuccessfully. Always throw exit // exception. @@ -786,6 +826,16 @@ namespace build2 throw exit (false); } + // Return the command program path for diagnostics. + // + static inline path + cmd_path (const command& c) + { + return c.program.initial == nullptr // Not pre-searched? + ? c.program.recall + : path (c.program.recall_string ()); + } + // The set pseudo-builtin: set variable from the stdin input. // // set [-e|--exact] [(-n|--newline)|(-w|--whitespace)] [] @@ -794,16 +844,12 @@ namespace build2 set_builtin (environment& env, const strings& args, auto_fd in, + const optional& dl, + const command& deadline_cmd, const location& ll) { try { - // Do not throw when eofbit is set (end of stream reached), and - // when failbit is set (read operation failed to extract any - // character). - // - ifdstream cin (move (in), ifdstream::badbit); - // Parse arguments. // cli::vector_scanner scan (args); @@ -828,62 +874,136 @@ namespace build2 if (vname.empty ()) fail (ll) << "empty variable name"; - // Read the input. + // Read out the stream content into a string while keeping an eye on + // the deadline. Then parse it according to the split mode. // - cin.peek (); // Sets eofbit for an empty stream. - - names ns; - while (!cin.eof ()) + string s; { - // Read next element that depends on the whitespace mode being - // enabled or not. For the later case it also make sense to strip - // the trailing CRs that can appear while, for example, - // cross-testing Windows target or as a part of msvcrt junk - // production (see above). + ifdstream cin; + + // If the execution deadline is specified, then turn the stream into + // the non-blocking mode reading its content in chunks and with a + // single operation otherwise. If the specified deadline is reached + // while reading the stream, then bail out for the successful + // deadline and fail otherwise. Note that in the former case the + // variable value will be incomplete, but we leave it to the caller + // to handle that. // - string s; - if (ops.whitespace ()) - cin >> s; - else + if (dl) { - getline (cin, s); + fdselect_set fds {in.get ()}; + cin.open (move (in), fdstream_mode::non_blocking); - while (!s.empty () && s.back () == '\r') - s.pop_back (); - } + const timestamp& dlt (dl->value); - // If failbit is set then we read nothing into the string as eof is - // reached. That in particular means that the stream has trailing - // whitespaces (possibly including newlines) if the whitespace mode - // is enabled, or the trailing newline otherwise. If so then - // we append the "blank" to the variable value in the exact mode - // prior to bailing out. - // - if (cin.fail ()) - { - if (ops.exact ()) + for (char buf[4096];; ) { - if (ops.whitespace () || ops.newline ()) - ns.emplace_back (move (s)); // Reuse empty string. - else if (ns.empty ()) - ns.emplace_back ("\n"); - else - ns[0].value += '\n'; - } + timestamp now (system_clock::now ()); - break; - } + if (dlt <= now || ifdselect (fds, dlt - now) == 0) + { + if (!dl->success) + fail (ll) << cmd_path (deadline_cmd) + << " terminated: execution timeout expired"; + else + break; + } + + streamsize n (cin.readsome (buf, sizeof (buf))); + + // Bail out if eos is reached. + // + if (n == 0) + break; - if (ops.whitespace () || ops.newline () || ns.empty ()) - ns.emplace_back (move (s)); + s.append (buf, n); + } + } else { - ns[0].value += '\n'; - ns[0].value += s; + cin.open (move (in)); + s = cin.read_text (); } + + cin.close (); } - cin.close (); + // Parse the stream content into the variable value. + // + names ns; + + if (!s.empty ()) + { + if (ops.whitespace ()) // The whitespace mode. + { + // Note that we collapse multiple consecutive whitespaces. + // + for (size_t p (0); p != string::npos; ) + { + // Skip the whitespaces. + // + const char* sep (" \n\r\t"); + size_t b (s.find_first_not_of (sep, p)); + + if (b != string::npos) // Word beginning. + { + size_t e (s.find_first_of (sep, b)); // Find the word end. + ns.emplace_back (string (s, b, e != string::npos ? e - b : e)); + + p = e; + } + else // Trailings whitespaces. + { + // Append the trailing "blank" after the trailing whitespaces + // in the exact mode. + // + if (ops.exact ()) + ns.emplace_back (empty_string); + + // Bail out since the end of the string is reached. + // + break; + } + } + } + else // The newline or no-split mode. + { + // Note that we don't collapse multiple consecutive newlines. + // + // Note also that we always sanitize CRs so this loop is always + // needed. + // + for (size_t p (0); p != string::npos; ) + { + size_t e (s.find ('\n', p)); + string l (s, p, e != string::npos ? e - p : e); + + // Strip the trailing CRs that can appear while, for example, + // cross-testing Windows target or as a part of msvcrt junk + // production (see above). + // + while (!l.empty () && l.back () == '\r') + l.pop_back (); + + // Append the line. + // + if (!l.empty () || // Non-empty. + e != string::npos || // Empty, non-trailing. + ops.exact ()) // Empty, trailing, in the exact mode. + { + if (ops.newline () || ns.empty ()) + ns.emplace_back (move (l)); + else + { + ns[0].value += '\n'; + ns[0].value += l; + } + } + + p = e != string::npos ? e + 1 : e; + } + } + } env.set_variable (move (vname), move (ns), @@ -915,24 +1035,70 @@ namespace build2 name); } + // Stack-allocated linked list of information about the running pipeline + // processes and builtins. + // + struct pipe_command + { + // We could probably use a union here, but let's keep it simple for now + // (one is NULL). + // + process* proc; + builtin* bltn; + + // True if this command has been terminated. + // + bool terminated = false; + + // Only for diagnostics. + // + const command& cmd; + const location& loc; + + pipe_command* prev; // NULL for the left-most command. + + pipe_command (process& p, + const command& c, + const location& l, + pipe_command* v) + : proc (&p), bltn (nullptr), cmd (c), loc (l), prev (v) {} + + pipe_command (builtin& b, + const command& c, + const location& l, + pipe_command* v) + : proc (nullptr), bltn (&b), cmd (c), loc (l), prev (v) {} + }; + static bool run_pipe (environment& env, command_pipe::const_iterator bc, command_pipe::const_iterator ec, auto_fd ifd, size_t ci, size_t li, const location& ll, - bool diag) + bool diag, + optional dl = nullopt, + const command* dl_cmd = nullptr, // env -t + pipe_command* prev_cmd = nullptr) { + tracer trace ("script::run_pipe"); + if (bc == ec) // End of the pipeline. return true; - // The overall plan is to run the first command in the pipe, reading - // its input from the file descriptor passed (or, for the first - // command, according to stdin redirect specification) and redirecting - // its output to the right-hand part of the pipe recursively. Fail if - // the right-hand part fails. Otherwise check the process exit code, - // match stderr (and stdout for the last command in the pipe) according - // to redirect specification(s) and fail if any of the above fails. + // The overall plan is to run the first command in the pipe, reading its + // input from the file descriptor passed (or, for the first command, + // according to stdin redirect specification) and redirecting its output + // to the right-hand part of the pipe recursively. Fail if the + // right-hand part fails. Otherwise check the process exit code, match + // stderr (and stdout for the last command in the pipe) according to + // redirect specification(s) and fail if any of the above fails. + // + // If the command has a deadline, then terminate the whole pipeline when + // the deadline is reached. This way the pipeline processes get a chance + // to terminate gracefully, which in particular may require to interrupt + // their IO operations, closing their standard streams readers and + // writers. // const command& c (*bc); @@ -996,48 +1162,69 @@ namespace build2 return args; }; - // Prior to opening file descriptors for command input/output - // redirects let's check if the command is the exit builtin. Being a - // builtin syntactically it differs from the regular ones in a number - // of ways. It doesn't communicate with standard streams, so - // redirecting them is meaningless. It may appear only as a single - // command in a pipeline. It doesn't return any value and stops the - // script execution, so checking its exit status is meaningless as - // well. That all means we can short-circuit here calling the builtin - // and bailing out right after that. Checking that the user didn't - // specify any redirects or exit code check sounds like a right thing - // to do. + // Prior to opening file descriptors for command input/output redirects + // let's check if the command is the timeout or exit builtin. Being a + // builtin syntactically they differ from the regular ones in a number + // of ways. They don't communicate with standard streams, so redirecting + // them is meaningless. They may appear only as a single command in a + // pipeline. They don't return any value, so checking their exit status + // is meaningless as well. That all means we can short-circuit here + // calling the builtin and bailing out right after that. Checking that + // the user didn't specify any variables, timeout, redirects, or exit + // code check sounds like a right thing to do. // - if (resolve && program == "exit") + if (resolve && (program == "timeout" || program == "exit")) { // In case the builtin is erroneously pipelined from the other // command, we will close stdin gracefully (reading out the stream - // content), to make sure that the command doesn't print any - // unwanted diagnostics about IO operation failure. + // content), to make sure that the command doesn't print any unwanted + // diagnostics about IO operation failure. // - // Note that dtor will ignore any errors (which is what we want). + // Note though, that doing so would be a bad idea if the deadline is + // specified, since we can block on read and miss the deadline. // - ifdstream is (move (ifd), fdstream_mode::skip); + if (!dl) + { + // Note that dtor will ignore any errors (which is what we want). + // + ifdstream (move (ifd), fdstream_mode::skip); + } if (!first || !last) - fail (ll) << "exit builtin must be the only pipe command"; + fail (ll) << program << " builtin must be the only pipe command"; + + if (!c.variables.empty ()) + fail (ll) << "environment variables cannot be (un)set for " + << program << " builtin"; + + if (c.timeout) + fail (ll) << "timeout cannot be specified for " << program + << " builtin"; if (c.in) - fail (ll) << "exit builtin stdin cannot be redirected"; + fail (ll) << program << " builtin stdin cannot be redirected"; if (c.out) - fail (ll) << "exit builtin stdout cannot be redirected"; + fail (ll) << program << " builtin stdout cannot be redirected"; if (c.err) - fail (ll) << "exit builtin stderr cannot be redirected"; + fail (ll) << program << " builtin stderr cannot be redirected"; if (c.exit) - fail (ll) << "exit builtin exit code cannot be checked"; + fail (ll) << program << " builtin exit code cannot be checked"; if (verb >= 2) print_process (process_args ()); - exit_builtin (c.arguments, ll); // Throws exit exception. + if (program == "timeout") + { + timeout_builtin (env, c.arguments, ll); + return true; + } + else if (program == "exit") + exit_builtin (c.arguments, ll); // Throws exit exception. + else + assert (false); } // Create a unique path for a command standard stream cache file. @@ -1121,6 +1308,9 @@ namespace build2 // process to hang which can be interpreted as a command failure. // @@ Both ways are quite ugly. Is there some better way to do // this? + // @@ Maybe we can create a pipe, write a byte into it, close the + // writing end, and after the process terminates make sure we can + // still read this byte out? // // Fall through. // @@ -1163,6 +1353,24 @@ namespace build2 assert (ifd.get () != -1); + // Calculate the process/builtin execution deadline. Note that we should + // also consider the left-hand side processes deadlines, not to keep + // them waiting for us and allow them to terminate not later than their + // deadlines. Thus, let's also track which command has introduced the + // deadline, so we can report it if the deadline is missed. + // + dl = earlier (dl, env.effective_deadline ()); + + if (c.timeout) + { + deadline d (system_clock::now () + *c.timeout, false /* success */); + if (!dl || d < *dl) + { + dl = d; + dl_cmd = &c; + } + } + // Prior to opening file descriptors for command outputs redirects // let's check if the command is the set builtin. Being a builtin // syntactically it differs from the regular ones in a number of ways. @@ -1190,7 +1398,10 @@ namespace build2 if (verb >= 2) print_process (process_args ()); - set_builtin (env, c.arguments, move (ifd), ll); + set_builtin (env, c.arguments, move (ifd), + dl, dl_cmd != nullptr ? *dl_cmd : c, + ll); + return true; } @@ -1344,10 +1555,119 @@ namespace build2 // assert (ofd.out.get () != -1 && efd.get () != -1); + // Wait for a process/builtin to complete until the deadline is reached + // and return the underlying wait function result (optional). + // + auto timed_wait = [] (auto& p, const timestamp& deadline) + { + timestamp now (system_clock::now ()); + return deadline > now ? p.timed_wait (deadline - now) : p.try_wait (); + }; + + // Terminate the pipeline processes starting from the specified one and + // up to the leftmost one and then kill those which didn't terminate + // after 1 second. + // + // After that wait for the pipeline builtins completion. Since their + // standard streams should no longer be written to or read from by any + // process, that shouldn't take long. If, however, they won't be able to + // complete in 1 second, then some of them have probably stuck while + // communicating with a slow filesystem device or similar, and since we + // currently have no way to terminate asynchronous builtins, we have no + // choice but to abort. + // + // Issue diagnostics and fail if something goes wrong, but still try to + // terminate/kill all the pipe processes. + // + auto term_pipe = [&timed_wait, &trace] (pipe_command* pc) + { + diag_record dr; + + auto prog = [] (pipe_command* c) {return cmd_path (c->cmd);}; + + // Terminate processes gracefully and set the terminate flag for the + // pipe commands. + // + for (pipe_command* c (pc); c != nullptr; c = c->prev) + { + if (process* p = c->proc) + try + { + l5 ([&]{trace (c->loc) << "terminating: " << c->cmd;}); + + p->term (); + } + catch (const process_error& e) + { + // If unable to terminate the process for any reason (the process + // is exiting on Windows, etc) then just ignore this, postponing + // the potential failure till the kill() call. + // + l5 ([&]{trace (c->loc) <<"unable to terminate " << prog (c) + << ": " << e;}); + } + + c->terminated = true; + } + + // Wait a bit for the processes to terminate and kill the remaining + // ones. + // + timestamp dl (system_clock::now () + chrono::seconds (1)); + + for (pipe_command* c (pc); c != nullptr; c = c->prev) + { + if (process* p = c->proc) + try + { + l5 ([&]{trace (c->loc) << "waiting: " << c->cmd;}); + + if (!timed_wait (*p, dl)) + { + l5 ([&]{trace (c->loc) << "killing: " << c->cmd;}); + + p->kill (); + p->wait (); + } + } + catch (const process_error& e) + { + dr << fail (c->loc) << "unable to wait/kill " << prog (c) << ": " + << e; + } + } + + // Wait a bit for the builtins to complete and abort if any remain + // running. + // + dl = system_clock::now () + chrono::seconds (1); + + for (pipe_command* c (pc); c != nullptr; c = c->prev) + { + if (builtin* b = c->bltn) + try + { + l5 ([&]{trace (c->loc) << "waiting: " << c->cmd;}); + + if (!timed_wait (*b, dl)) + { + error (c->loc) << prog (c) << " builtin hanged, aborting"; + terminate (false /* trace */); + } + } + catch (const system_error& e) + { + dr << fail (c->loc) << "unable to wait for " << prog (c) << ": " + << e; + } + } + }; + + // Absent if the process/builtin misses the "unsuccessful" deadline. + // optional exit; - const builtin_info* bi (resolve - ? builtins.find (program) - : nullptr); + + const builtin_info* bi (resolve ? builtins.find (program) : nullptr); bool success; @@ -1394,6 +1714,21 @@ namespace build2 if (cleanup_builtin (program)) cln = cleanup (); + // We also extend the sleep builtin, deactivating the thread before + // going to sleep and waking up before the deadline is reached. + // + // Let's "wrap up" the sleep-related values into the single object to + // rely on "small function object" optimization. + // + struct sleep + { + optional deadline; + bool terminated = false; + + sleep (const optional& d): deadline (d) {} + }; + sleep slp (dl ? dl->value : optional ()); + builtin_callbacks bcs { // create @@ -1555,14 +1890,29 @@ namespace build2 // sleep // - // Deactivate the thread before going to sleep. - // - [&env] (const duration& d) + [&env, &slp] (const duration& d) { + duration t (d); + const optional& dl (slp.deadline); + + if (dl) + { + timestamp now (system_clock::now ()); + + slp.terminated = now + t > *dl; + + if (*dl <= now) + return; + + duration d (*dl - now); + if (t > d) + t = d; + } + // If/when required we could probably support the precise sleep // mode (e.g., via an option). // - env.context.sched.sleep (d); + env.context.sched.sleep (t); } }; @@ -1575,13 +1925,49 @@ namespace build2 *env.work_dir.path, bcs)); + pipe_command pc (b, c, ll, prev_cmd); + + // If the deadline is specified, then make sure we don't miss it + // waiting indefinitely in the builtin destructor on the right-hand + // side of the pipe failure. + // + auto g (make_exception_guard ([&dl, &pc, &term_pipe] () + { + if (dl) + try + { + term_pipe (&pc); + } + catch (const failed&) + { + // We can't do much here. + } + })); + success = run_pipe (env, - nc, - ec, + nc, ec, move (ofd.in), - ci + 1, li, ll, diag); + ci + 1, li, ll, diag, + dl, dl_cmd, + &pc); + + if (!dl) + b.wait (); + else if (!timed_wait (b, dl->value)) + term_pipe (&pc); + + // Note that this also handles ad hoc termination (without the call + // to term_pipe()) by the sleep builtin (see above). + // + if (pc.terminated || slp.terminated) + { + assert (dl); - exit = process_exit (b.wait ()); + if (dl->success) + exit = process_exit (0); + } + else + exit = process_exit (r); } catch (const system_error& e) { @@ -1654,19 +2040,60 @@ namespace build2 env.work_dir.path->string ().c_str (), pe.vars); + // Can't throw. + // ifd.reset (); ofd.out.reset (); efd.reset (); + pipe_command pc (pr, c, ll, prev_cmd); + + // If the deadline is specified, then make sure we don't miss it + // waiting indefinitely in the process destructor on the right-hand + // part of the pipe failure. + // + auto g (make_exception_guard ([&dl, &pc, &term_pipe] () + { + if (dl) + try + { + term_pipe (&pc); + } + catch (const failed&) + { + // We can't do much here. + } + })); + success = run_pipe (env, - nc, - ec, + nc, ec, move (ofd.in), - ci + 1, li, ll, diag); - - pr.wait (); + ci + 1, li, ll, diag, + dl, dl_cmd, + &pc); + + if (!dl) + pr.wait (); + else if (!timed_wait (pr, dl->value)) + term_pipe (&pc); + +#ifndef _WIN32 + if (pc.terminated && + !pr.exit->normal () && + pr.exit->signal () == SIGTERM) +#else + if (pc.terminated && + !pr.exit->normal () && + pr.exit->status == DBG_TERMINATE_PROCESS) +#endif + { + assert (dl); - exit = move (pr.exit); + if (dl->success) + exit = process_exit (0); + } + else + exit = pr.exit; } catch (const process_error& e) { @@ -1679,19 +2106,19 @@ namespace build2 } } - assert (exit); - // If the righ-hand side pipeline failed than the whole pipeline fails, // and no further checks are required. // if (!success) return false; - // Use the program path for diagnostics (print relative, etc). + // Fail if the process is terminated due to reaching the deadline. // - const path& pr (resolve - ? c.program.recall - : path (c.program.recall_string ())); // Can't throw. + if (!exit) + fail (ll) << cmd_path (dl_cmd != nullptr ? *dl_cmd : c) + << " terminated: execution timeout expired"; + + path pr (cmd_path (c)); // If there is no valid exit code available by whatever reason then we // print the proper diagnostics, dump stderr (if cached and not too @@ -1794,7 +2221,7 @@ namespace build2 // pipe that "switches on" the diagnostics potential printing. // command_expr::const_iterator trailing_ands; // Undefined if diag is - // disallowed. + // disallowed. if (diag) { auto i (expr.crbegin ()); @@ -1817,8 +2244,10 @@ namespace build2 // with false. // if (!((or_op && r) || (!or_op && !r))) - r = run_pipe ( - env, p.begin (), p.end (), auto_fd (), ci, li, ll, print); + r = run_pipe (env, + p.begin (), p.end (), + auto_fd (), + ci, li, ll, print); ci += p.size (); } diff --git a/libbuild2/script/script.cxx b/libbuild2/script/script.cxx index 6ee702e..f540687 100644 --- a/libbuild2/script/script.cxx +++ b/libbuild2/script/script.cxx @@ -3,6 +3,7 @@ #include +#include #include #include // strchr() @@ -408,13 +409,20 @@ namespace build2 if ((m & command_to_stream::header) == command_to_stream::header) { - // Print the env builtin arguments, if any environment variable - // (un)sets are present. + // Print the env builtin if any of its options/arguments are present. // - if (!c.variables.empty ()) + if (!c.variables.empty () || c.timeout) { o << "env"; + // Timeout. + // + if (c.timeout) + o << " -t " + << chrono::duration_cast (*c.timeout).count (); + + // Variable unsets/sets. + // auto b (c.variables.begin ()), i (b), e (c.variables.end ()); // Print a variable name or assignment to the stream, quoting it if @@ -456,8 +464,7 @@ namespace build2 // // Print the variable unsets as the -u options until a variable set // is encountered (contains '=') or the end of the variable list is - // reached. In the former case, to avoid a potential ambiguity add - // the '-' separator, if there are any options. + // reached. // // Note that we rely on the fact that unsets come first, which is // guaranteed by parser::parse_env_builtin(). @@ -471,16 +478,15 @@ namespace build2 o << " -u "; print (v, true /* name*/); } else // Variable set. - { - if (i != b) - o << " -"; - break; - } } // Variable sets. // + // Note that we don't add the '-' separator since we always use the + // `-* ` option notation and so there can't be any ambiguity + // with a variable set. + // for (; i != e; ++i) { o << ' '; print (*i, false /* name */); diff --git a/libbuild2/script/script.hxx b/libbuild2/script/script.hxx index 4a62c77..ecd2c2b 100644 --- a/libbuild2/script/script.hxx +++ b/libbuild2/script/script.hxx @@ -308,8 +308,9 @@ namespace build2 // process_path program; - strings arguments; - environment_vars variables; + strings arguments; + environment_vars variables; // From env builtin. + optional timeout; // From env builtin. optional in; optional out; @@ -363,6 +364,37 @@ namespace build2 ostream& operator<< (ostream&, const command_expr&); + struct timeout + { + duration value; + bool success; + + timeout (duration d, bool s): value (d), success (s) {} + }; + + struct deadline + { + timestamp value; + bool success; + + deadline (timestamp t, bool s): value (t), success (s) {} + }; + + // If timestamps/durations are equal, the failure is less than the + // success. + // + bool + operator< (const deadline&, const deadline&); + + bool + operator< (const timeout&, const timeout&); + + optional + to_deadline (const optional&, bool success); + + optional + to_timeout (const optional&, bool success); + // Script execution environment. // class environment @@ -469,6 +501,23 @@ namespace build2 const string& attrs, const location&) = 0; + // Set the script execution timeout from the timeout builtin call. + // + // The builtin argument semantics is script implementation-dependent. If + // success is true then a process missing this deadline should not be + // considered as failed unless it didn't terminate gracefully and had to + // be killed. + // + virtual void + set_timeout (const string& arg, bool success, const location&) = 0; + + // Return the script execution deadline which can potentially rely on + // factors besides the latest timeout builtin call (variables, scoping, + // etc). + // + virtual optional + effective_deadline () = 0; + // Create the temporary directory and set the temp_dir reference target // to its path. Must only be called if temp_dir is empty. // diff --git a/libbuild2/script/script.ixx b/libbuild2/script/script.ixx index 56043b2..37c77ec 100644 --- a/libbuild2/script/script.ixx +++ b/libbuild2/script/script.ixx @@ -25,7 +25,6 @@ namespace build2 inline command_to_stream operator| (command_to_stream x, command_to_stream y) {return x |= y;} - // command // inline ostream& @@ -52,5 +51,39 @@ namespace build2 to_stream (o, e, command_to_stream::all); return o; } + + // deadline + // + inline bool + operator< (const deadline& x, const deadline& y) + { + if (x.value != y.value) + return x.value < y.value; + + return x.success < y.success; + } + + inline optional + to_deadline (const optional& ts, bool success) + { + return ts ? deadline (*ts, success) : optional (); + } + + // timeout + // + inline bool + operator< (const timeout& x, const timeout& y) + { + if (x.value != y.value) + return x.value < y.value; + + return x.success < y.success; + } + + inline optional + to_timeout (const optional& d, bool success) + { + return d ? timeout (*d, success) : optional (); + } } } diff --git a/libbuild2/script/timeout.cxx b/libbuild2/script/timeout.cxx new file mode 100644 index 0000000..a44e1bb --- /dev/null +++ b/libbuild2/script/timeout.cxx @@ -0,0 +1,26 @@ +// file : libbuild2/script/timeout.cxx -*- C++ -*- +// license : MIT; see accompanying LICENSE file + +#include + +#include + +#include + +using namespace std; + +namespace build2 +{ + optional + parse_timeout (const string& s, const char* what, const location& l) + { + if (optional n = parse_number (s)) + { + return *n != 0 + ? chrono::duration_cast (chrono::seconds (*n)) + : optional (); + } + else + fail (l) << "invalid " << what << " '" << s << "'" << endf; + } +} diff --git a/libbuild2/script/timeout.hxx b/libbuild2/script/timeout.hxx new file mode 100644 index 0000000..9991ad6 --- /dev/null +++ b/libbuild2/script/timeout.hxx @@ -0,0 +1,52 @@ +// file : libbuild2/script/timeout.hxx -*- C++ -*- +// license : MIT; see accompanying LICENSE file + +#ifndef LIBBUILD2_SCRIPT_TIMEOUT_HXX +#define LIBBUILD2_SCRIPT_TIMEOUT_HXX + +#include +#include + +namespace build2 +{ + // Parse the specified in seconds timeout returning it if the value is not + // zero and nullopt otherwise. Issue diagnostics and fail if the argument is + // not a valid timeout. + // + optional + parse_timeout (const string&, + const char* what, + const location& = location ()); + + // As above, but return the timepoint which is away from now by the + // specified timeout. + // + optional + parse_deadline (const string&, + const char* what, + const location& = location ()); + + // Return the earlier timeout/deadline of two values, if any is present. + // + // Note that earlier(nullopt, v) and earlier(v, nullopt) return v. + // + template + T + earlier (const T&, const T&); + + template + T + earlier (const optional&, const T&); + + template + T + earlier (const T&, const optional&); + + template + optional + earlier (const optional&, const optional&); +} + +#include + +#endif // LIBBUILD2_SCRIPT_TIMEOUT_HXX diff --git a/libbuild2/script/timeout.ixx b/libbuild2/script/timeout.ixx new file mode 100644 index 0000000..755af17 --- /dev/null +++ b/libbuild2/script/timeout.ixx @@ -0,0 +1,44 @@ +// file : libbuild2/script/timeout.ixx -*- C++ -*- +// license : MIT; see accompanying LICENSE file + +namespace build2 +{ + inline optional + parse_deadline (const string& s, const char* what, const location& l) + { + if (optional t = parse_timeout (s, what, l)) + return system_clock::now () + *t; + else + return nullopt; + } + + template + inline T + earlier (const T& x, const T& y) + { + return x < y ? x : y; + } + + template + inline T + earlier (const T& x, const optional& y) + { + return y ? earlier (x, *y) : x; + } + + template + inline T + earlier (const optional& x, const T& y) + { + return earlier (y, x); + } + + template + inline optional + earlier (const optional& x, const optional& y) + { + return x ? earlier (*x, y) : + y ? earlier (*y, x) : + optional (); + } +} diff --git a/libbuild2/test/common.cxx b/libbuild2/test/common.cxx index f50d289..7fdb347 100644 --- a/libbuild2/test/common.cxx +++ b/libbuild2/test/common.cxx @@ -6,6 +6,10 @@ #include #include +#include + +#include + using namespace std; namespace build2 @@ -215,5 +219,73 @@ namespace build2 return r; } + + optional common:: + operation_deadline () const + { + if (!operation_timeout) + return nullopt; + + duration::rep r (operation_deadline_.load (memory_order_consume)); + + if (r == timestamp_unknown_rep) + { + duration::rep t (timestamp (system_clock::now () + *operation_timeout). + time_since_epoch ().count ()); + + if (operation_deadline_.compare_exchange_strong (r, + t, + memory_order_release, + memory_order_consume)) + r = t; + } + + return timestamp (duration (r)); + } + + // Helpers. + // + optional + operation_deadline (const target& t) + { + optional r; + + for (const scope* s (t.base_scope ().root_scope ()); + s != nullptr; + s = s->parent_scope ()->root_scope ()) + { + if (auto* m = s->find_module (module::name)) + r = earlier (r, m->operation_deadline ()); + } + + return r; + } + + optional + test_timeout (const target& t) + { + optional r; + + for (const scope* s (t.base_scope ().root_scope ()); + s != nullptr; + s = s->parent_scope ()->root_scope ()) + { + if (auto* m = s->find_module (module::name)) + r = earlier (r, m->test_timeout); + } + + return r; + } + + optional + test_deadline (const target& t) + { + optional r (operation_deadline (t)); + + if (optional d = test_timeout (t)) + r = earlier (r, system_clock::now () + *d); + + return r; + } } } diff --git a/libbuild2/test/common.hxx b/libbuild2/test/common.hxx index 01628fd..a43b2b1 100644 --- a/libbuild2/test/common.hxx +++ b/libbuild2/test/common.hxx @@ -20,6 +20,7 @@ namespace build2 { const variable& config_test; const variable& config_test_output; + const variable& config_test_timeout; const variable& var_test; const variable& test_options; @@ -40,11 +41,28 @@ namespace build2 output_before before = output_before::warn; output_after after = output_after::clean; + // The config.test.timeout values. + // + optional operation_timeout; + optional test_timeout; + // The config.test query interface. // const names* test_ = nullptr; // The config.test value if any. scope* root_ = nullptr; // The root scope for target resolution. + // Store it as the underlying representation and use the release-consume + // ordering (see mtime_target for the reasoning). + // + mutable atomic operation_deadline_ { + timestamp_unknown_rep}; + + // Return the test operation deadline, calculating it on the first call + // as an offset from now by the operation timeout. + // + optional + operation_deadline () const; + // Return true if the specified alias target should pass-through to its // prerequisites. // @@ -65,6 +83,27 @@ namespace build2 explicit common (common_data&& d): common_data (move (d)) {} }; + + // Helpers. + // + + // Return the nearest of the target-enclosing root scopes test operation + // deadlines. + // + optional + operation_deadline (const target&); + + // Return the lesser of the target-enclosing root scopes test timeouts. + // + optional + test_timeout (const target&); + + // Convert the test timeouts in the target-enclosing root scopes into + // deadlines and return the nearest between them and the operation + // deadlines in the enclosing root scopes. + // + optional + test_deadline (const target&); } } diff --git a/libbuild2/test/init.cxx b/libbuild2/test/init.cxx index aaacdc6..0a47842 100644 --- a/libbuild2/test/init.cxx +++ b/libbuild2/test/init.cxx @@ -10,6 +10,8 @@ #include +#include + #include #include #include @@ -44,8 +46,8 @@ namespace build2 // // Specified as @ pairs with both sides being // optional. The variable is untyped (we want a list of name-pairs), - // overridable, and with global visibiility. The target is relative - // (in essence a prerequisite) which is resolved from the (root) scope + // overridable, and with global visibility. The target is relative (in + // essence a prerequisite) which is resolved from the (root) scope // where the config.test value is defined. // vp.insert ("config.test"), @@ -55,6 +57,11 @@ namespace build2 // vp.insert ("config.test.output"), + // Test operation and individual test execution timeouts (see the + // manual for semantics). + // + vp.insert ("config.test.timeout"), + // The test variable is a name which can be a path (with the // true/false special values) or a target name. // @@ -189,6 +196,33 @@ namespace build2 else fail << "invalid config.test.output before value '" << b << "'"; } + // config.test.timeout + // + if (lookup l = lookup_config (rs, m.config_test_timeout)) + { + const string& t (cast (l)); + + const char* ot ("config.test.timeout test operation timeout value"); + const char* tt ("config.test.timeout test timeout value"); + + size_t p (t.find ('/')); + if (p != string::npos) + { + // Note: either of the timeouts can be omitted but not both. + // + if (t.size () == 1) + fail << "invalid config.test.timeout value '" << t << "'"; + + if (p != 0) + m.operation_timeout = parse_timeout (string (t, 0, p), ot); + + if (p != t.size () - 1) + m.test_timeout = parse_timeout (string (t, p + 1), tt); + } + else + m.test_timeout = parse_timeout (t, ot); + } + //@@ TODO: Need ability to specify extra diff options (e.g., // --strip-trailing-cr, now hardcoded). // diff --git a/libbuild2/test/module.cxx b/libbuild2/test/module.cxx new file mode 100644 index 0000000..6b2cbdf --- /dev/null +++ b/libbuild2/test/module.cxx @@ -0,0 +1,12 @@ +// file : libbuild2/test/module.cxx -*- C++ -*- +// license : MIT; see accompanying LICENSE file + +#include + +namespace build2 +{ + namespace test + { + const string module::name ("test"); + } +} diff --git a/libbuild2/test/module.hxx b/libbuild2/test/module.hxx index 7635f01..c278f5c 100644 --- a/libbuild2/test/module.hxx +++ b/libbuild2/test/module.hxx @@ -21,6 +21,8 @@ namespace build2 default_rule, group_rule { + static const string name; + const test::group_rule& group_rule () const { diff --git a/libbuild2/test/operation.cxx b/libbuild2/test/operation.cxx index e9635cf..0a65bed 100644 --- a/libbuild2/test/operation.cxx +++ b/libbuild2/test/operation.cxx @@ -3,7 +3,11 @@ #include +#include #include +#include + +#include // test_deadline() using namespace std; using namespace butl; @@ -23,6 +27,36 @@ namespace build2 return mo != disfigure_id ? update_id : 0; } + // Ad hoc rule apply callback. + // + // If this is not perform(test) or there is no deadline set for the test + // execution, then forward the call to the ad hoc rule's apply(). + // Otherwise, return a recipe that will execute with the deadline if we + // can get it and return the noop recipe that just issues a warning if we + // can't. + // + static recipe + adhoc_apply (const adhoc_rule& ar, action a, target& t, match_extra& me) + { + optional d; + + if (a != perform_test_id || !(d = test_deadline (t))) + return ar.apply (a, t, me); + + if (const auto* dr = dynamic_cast (&ar)) + { + if (recipe r = dr->apply (a, t, me, d)) + return r; + } + + return [] (action a, const target& t) + { + warn << "unable to impose timeout on test for target " << t + << ", skipping"; + return noop_action (a, t); + }; + } + const operation_info op_test { test_id, 0, @@ -36,7 +70,7 @@ namespace build2 &test_pre, nullptr, nullptr, - nullptr + &adhoc_apply }; // Also the explicit update-for-test operation alias. diff --git a/libbuild2/test/rule.cxx b/libbuild2/test/rule.cxx index db490d9..df2d5ba 100644 --- a/libbuild2/test/rule.cxx +++ b/libbuild2/test/rule.cxx @@ -3,6 +3,12 @@ #include +#ifndef _WIN32 +# include // SIG* +#else +# include // DBG_TERMINATE_PROCESS +#endif + #include #include #include @@ -632,11 +638,30 @@ namespace build2 // ... // nameN arg arg ... nullptr nullptr // + // Stack-allocated linked list of information about the running pipeline + // processes. + // + struct pipe_process + { + process& proc; + const char* prog; // Only for diagnostics. + + // True if this process has been terminated. + // + bool terminated = false; + + pipe_process* prev; // NULL for the left-most program. + + pipe_process (process& p, const char* g, pipe_process* r) + : proc (p), prog (g), prev (r) {} + }; + static bool run_test (const target& t, diag_record& dr, char const** args, - process* prev = nullptr) + const optional& deadline, + pipe_process* prev = nullptr) { // Find the next process, if any. // @@ -648,19 +673,116 @@ namespace build2 // int out (*next != nullptr ? -1 : 1); bool pr; - process_exit pe; + + // Absent if the process misses the deadline. + // + optional pe; try { + // Wait for a process to complete until the deadline is reached and + // return the underlying wait function result. + // + auto timed_wait = [] (process& p, const timestamp& deadline) + { + timestamp now (system_clock::now ()); + return deadline > now + ? p.timed_wait (deadline - now) + : p.try_wait (); + }; + + // Terminate the pipeline processes starting from the specified one + // and up to the leftmost one and then kill those which didn't + // terminate in 1 second. Issue diagnostics and fail if something goes + // wrong, but still try to terminate all processes. + // + auto term_pipe = [&timed_wait] (pipe_process* pp) + { + diag_record dr; + + // Terminate processes gracefully and set the terminate flag for + // them. + // + for (pipe_process* p (pp); p != nullptr; p = p->prev) + { + try + { + p->proc.term (); + } + catch (const process_error& e) + { + dr << fail << "unable to terminate " << p->prog << ": " << e; + } + + p->terminated = true; + } + + // Wait a bit for the processes to terminate and kill the remaining + // ones. + // + timestamp deadline (system_clock::now () + chrono::seconds (1)); + + for (pipe_process* p (pp); p != nullptr; p = p->prev) + { + process& pr (p->proc); + + try + { + if (!timed_wait (pr, deadline)) + { + pr.kill (); + pr.wait (); + } + } + catch (const process_error& e) + { + dr << fail << "unable to wait/kill " << p->prog << ": " << e; + } + } + }; + process p (prev == nullptr - ? process (args, 0, out) // First process. - : process (args, *prev, out)); // Next process. + ? process (args, 0, out) // First process. + : process (args, prev->proc, out)); // Next process. - pr = *next == nullptr || run_test (t, dr, next, &p); - p.wait (); + pipe_process pp (p, args[0], prev); + + // If the deadline is specified, then make sure we don't miss it + // waiting indefinitely in the process destructor on the right-hand + // part of the pipe failure. + // + auto g (make_exception_guard ([&deadline, &pp, &term_pipe] () + { + if (deadline) + try + { + term_pipe (&pp); + } + catch (const failed&) + { + // We can't do much here. + } + })); + + pr = *next == nullptr || run_test (t, dr, next, deadline, &pp); + + if (!deadline) + p.wait (); + else if (!timed_wait (p, *deadline)) + term_pipe (&pp); assert (p.exit); - pe = *p.exit; + +#ifndef _WIN32 + if (!(pp.terminated && + !p.exit->normal () && + p.exit->signal () == SIGTERM)) +#else + if (!(pp.terminated && + !p.exit->normal () && + p.exit->status == DBG_TERMINATE_PROCESS)) +#endif + pe = *p.exit; } catch (const process_error& e) { @@ -672,7 +794,7 @@ namespace build2 throw failed (); } - bool wr (pe.normal () && pe.code () == 0); + bool wr (pe && pe->normal () && pe->code () == 0); if (!wr) { @@ -681,7 +803,11 @@ namespace build2 dr << error; print_process (dr, args); - dr << " " << pe; + + if (pe) + dr << " " << *pe; + else + dr << " terminated: execution timeout expired"; } return pr && wr; @@ -896,10 +1022,13 @@ namespace build2 if (!ctx.dry_run) { diag_record dr; + pipe_process pp (cat, "cat", nullptr); + if (!run_test (tt, dr, args.data () + (sin ? 3 : 0), // Skip cat. - sin ? &cat : nullptr)) + test_deadline (tt), + sin ? &pp : nullptr)) { dr << info << "test command line: "; print_process (dr, args); diff --git a/libbuild2/test/script/parser+env.test.testscript b/libbuild2/test/script/parser+env.test.testscript index b1e864c..b6fb305 100644 --- a/libbuild2/test/script/parser+env.test.testscript +++ b/libbuild2/test/script/parser+env.test.testscript @@ -48,10 +48,10 @@ : set : { - $* <'env a=b -- cmd' >'env a=b -- cmd' : var - $* <'env -u a b=c -- cmd' >'env -u a - b=c -- cmd' : opt-var - $* <'env a="b c" -- cmd' >"env a='b c' -- cmd" : quote - $* <'env "a b"=c -- cmd' >"env 'a b=c' -- cmd" : quote-name + $* <'env a=b -- cmd' >'env a=b -- cmd' : var + $* <'env -u a b=c -- cmd' >'env -u a b=c -- cmd' : opt-var + $* <'env a="b c" -- cmd' >"env a='b c' -- cmd" : quote + $* <'env "a b"=c -- cmd' >"env 'a b=c' -- cmd" : quote-name : double-quote : @@ -66,9 +66,19 @@ EOE } +: timeout +: +{ + $* <'env -t 5 -- cmd' >'env -t 5 -- cmd' : short-opt + $* <'env --timeout 5 -- cmd' >'env -t 5 -- cmd' : long-opt + $* <'env --timeout=5 -- cmd' >'env -t 5 -- cmd' : long-opt-eq + $* <'env -u a -t 5 -- cmd' >'env -t 5 -u a -- cmd' : mult-opt + $* <'env -t 5 a=b -- cmd' >'env -t 5 a=b -- cmd' : args +} + : non-first : -$* <'cmd1 && env -u a b=c -- cmd2' >'cmd1 && env -u a - b=c -- cmd2' +$* <'cmd1 && env -u a b=c -- cmd2' >'cmd1 && env -u a b=c -- cmd2' : no-cmd : diff --git a/libbuild2/test/script/script.cxx b/libbuild2/test/script/script.cxx index 34d4723..3f615ee 100644 --- a/libbuild2/test/script/script.cxx +++ b/libbuild2/test/script/script.cxx @@ -8,6 +8,10 @@ #include #include +#include + +#include // operation_deadline(), + // test_timeout() #include using namespace std; @@ -18,6 +22,9 @@ namespace build2 { namespace script { + using build2::script::to_deadline; + using build2::script::to_timeout; + // scope_base // scope_base:: @@ -188,11 +195,14 @@ namespace build2 // script // script:: - script (const target& tt, - const testscript& st, - const dir_path& rwd) + script (const target& tt, const testscript& st, const dir_path& rwd) : script_base (tt, st), - group (st.name == "testscript" ? string () : st.name, *this) + group (st.name == "testscript" ? string () : st.name, *this), + operation_deadline ( + to_deadline (build2::test::operation_deadline (tt), + false /* success */)), + test_timeout (to_timeout (build2::test::test_timeout (tt), + false /* success */)) { // Set the script working dir ($~) to $out_base/test/ (id_path // for root is just the id which is empty if st is 'testscript'). @@ -282,6 +292,14 @@ namespace build2 reset_special (); } + optional script:: + effective_deadline () + { + return earlier (operation_deadline, group_deadline); + } + + // scope + // lookup scope:: lookup (const variable& var) const { @@ -409,6 +427,86 @@ namespace build2 // assign (root.cmd_var) = move (s); } + + // group + // + void group:: + set_timeout (const string& t, bool success, const location& l) + { + const char* gt (parent != nullptr + ? "test group timeout" + : "testscript timeout"); + + const char* tt ("test timeout"); + + size_t p (t.find ('/')); + if (p != string::npos) + { + // Note: either of the timeouts can be omitted but not both. + // + if (t.size () == 1) + fail (l) << "invalid timeout '" << t << "'"; + + if (p != 0) + group_deadline = + to_deadline (parse_deadline (string (t, 0, p), gt, l), + success); + + if (p != t.size () - 1) + test_timeout = + to_timeout (parse_timeout (string (t, p + 1), tt, l), success); + } + else + group_deadline = to_deadline (parse_deadline (t, gt, l), success); + } + + optional group:: + effective_deadline () + { + return parent != nullptr + ? earlier (parent->effective_deadline (), group_deadline) + : group_deadline; + } + + // test + // + void test:: + set_timeout (const string& t, bool success, const location& l) + { + fragment_deadline = + to_deadline (parse_deadline (t, "test fragment timeout", l), + success); + } + + optional test:: + effective_deadline () + { + if (!test_deadline) + { + assert (parent != nullptr); // Test is always inside a group scope. + + test_deadline = parent->effective_deadline (); + + // Calculate the minimum timeout and factor it into the resulting + // deadline. + // + optional t (root.test_timeout); // config.test.timeout + for (const scope* p (parent); p != nullptr; p = p->parent) + { + const group* g (dynamic_cast (p)); + assert (g != nullptr); + + t = earlier (t, g->test_timeout); + } + + if (t) + test_deadline = + earlier (*test_deadline, + deadline (system_clock::now () + t->value, t->success)); + } + + return earlier (*test_deadline, fragment_deadline); + } } } } diff --git a/libbuild2/test/script/script.hxx b/libbuild2/test/script/script.hxx index 6356501..2789cab 100644 --- a/libbuild2/test/script/script.hxx +++ b/libbuild2/test/script/script.hxx @@ -28,6 +28,8 @@ namespace build2 using build2::script::redirect_type; using build2::script::line_type; using build2::script::command_expr; + using build2::script::deadline; + using build2::script::timeout; class parser; // Required by VC for 'friend class parser' declaration. @@ -168,10 +170,29 @@ namespace build2 class group: public scope { public: - vector> scopes; + group (const string& id, group& p): scope (id, &p, p.root) {} public: - group (const string& id, group& p): scope (id, &p, p.root) {} + vector> scopes; + + // The test group execution deadline and the individual test timeout. + // + optional group_deadline; + optional test_timeout; + + // Parse the argument having the '[]/[]' + // form, where the values are expressed in seconds and either of them + // (but not both) can be omitted, and set the group deadline and test + // timeout respectively, if specified. Reset them to nullopt on zero. + // + virtual void + set_timeout (const string&, bool success, const location&) override; + + // Return the nearest of the own deadline and the enclosing groups + // deadlines. + // + virtual optional + effective_deadline () override; protected: group (const string& id, script& r): scope (id, nullptr, r) {} @@ -207,6 +228,29 @@ namespace build2 public: test (const string& id, group& p): scope (id, &p, p.root) {} + public: + // The whole test and the remaining test fragment execution deadlines. + // + // The former is based on the minimum of the test timeouts set for the + // enclosing scopes and is calculated on the first deadline() call. + // The later is set by set_timeout() from the timeout builtin call + // during the test execution. + // + optional> test_deadline; // calculated> + optional fragment_deadline; + + // Parse the specified in seconds timeout and set the remaining test + // fragment execution deadline. Reset it to nullopt on zero. + // + virtual void + set_timeout (const string&, bool success, const location&) override; + + // Return the nearest of the test and fragment execution deadlines, + // calculating the former on the first call. + // + virtual optional + effective_deadline () override; + // Pre-parse data. // public: @@ -254,6 +298,13 @@ namespace build2 class script: public script_base, public group { public: + // The test operation deadline and the individual test timeout (see + // the config.test.timeout variable for details). + // + optional operation_deadline; + optional test_timeout; + + public: script (const target& test_target, const testscript& script_target, const dir_path& root_wd); @@ -263,6 +314,12 @@ namespace build2 script& operator= (script&&) = delete; script& operator= (const script&) = delete; + // Return the nearest of the test operation and group execution + // deadlines. + // + virtual optional + effective_deadline () override; + // Pre-parse data. // private: diff --git a/libbuild2/utility.cxx b/libbuild2/utility.cxx index 81f6809..193610f 100644 --- a/libbuild2/utility.cxx +++ b/libbuild2/utility.cxx @@ -593,4 +593,22 @@ namespace build2 script::regex::init (); } + + optional + parse_number (const string& s, uint64_t max_num) + { + optional r; + + if (!s.empty ()) + { + const char* b (s.c_str ()); + char* e (nullptr); + uint64_t v (strtoull (b, &e, 10)); // Can't throw. + + if (errno != ERANGE && e == b + s.size () && v <= max_num) + r = v; + } + + return r; + } } diff --git a/libbuild2/utility.hxx b/libbuild2/utility.hxx index 4560a73..7dbff2f 100644 --- a/libbuild2/utility.hxx +++ b/libbuild2/utility.hxx @@ -798,6 +798,13 @@ namespace build2 { return apply_pattern (s, p.c_str ()); } + + // Try to parse a string as a non-negative number returning nullopt if the + // argument is not a valid number or the number is greater than the + // specified maximum. + // + optional + parse_number (const string&, uint64_t max = UINT64_MAX); } #include diff --git a/tests/recipe/buildscript/testscript b/tests/recipe/buildscript/testscript index 551f64a..787bafd 100644 --- a/tests/recipe/buildscript/testscript +++ b/tests/recipe/buildscript/testscript @@ -107,3 +107,123 @@ $* clean 2>- } + +: timeout +: +if ($cxx.target.class != 'windows') +{ + : update + : + { + : expired + : + { + echo 'bar' >=bar; + + cat <=buildfile; + foo: bar + % [diag=update] + {{ + cp $path($<) $path($>) + timeout 1 + ^sleep 5 + }} + EOI + + $* 2>>~%EOE% != 0; + update file{foo} + buildfile:6:3: error: ^sleep terminated: execution timeout expired + info: while updating file{foo} + %.+ + EOE + + $* clean 2>- + } + + : successful-timeout + : + { + echo 'bar' >=bar; + + cat <=buildfile; + foo: bar + % [diag=update] + {{ + cp $path($<) $path($>) + timeout --success 1 + ^sleep 5 + }} + EOI + + $* 2>>EOE; + update file{foo} + EOE + + $* clean 2>- + } + } + + : test + : + { + : expired + : + { + echo 'bar' >=bar; + + cat <=buildfile; + foo: bar + {{ + cp $path($<) $path($>) + }} + % [diag=test] test + {{ + ^sleep 5 + }} + EOI + + $* test config.test.timeout=1 2>>~%EOE% != 0; + cp file{foo} + test file{foo} + buildfile:7:3: error: ^sleep terminated: execution timeout expired + info: while testing file{foo} + %.+ + EOE + + $* test config.test.timeout=/1 2>>~%EOE% != 0; + test file{foo} + buildfile:7:3: error: ^sleep terminated: execution timeout expired + info: while testing file{foo} + %.+ + EOE + + $* clean 2>- + } + + : not-expired + : + { + echo 'bar' >=bar; + + cat <=buildfile; + foo: bar + % [diag=cp] + {{ + ^sleep 4 + cp $path($<) $path($>) + }} + % [diag=test] test + {{ + ^sleep 1 + }} + EOI + + $* test config.test.timeout=3 2>>EOE; + cp file{foo} + test file{foo} + EOE + + $* clean 2>- + } + } +} diff --git a/tests/test/script/builtin/sleep.testscript b/tests/test/script/builtin/sleep.testscript index 21ed07b..e1410ac 100644 --- a/tests/test/script/builtin/sleep.testscript +++ b/tests/test/script/builtin/sleep.testscript @@ -6,3 +6,21 @@ : success : $c <'sleep 1' && $b + +: timeout +: +{ + : failure + : + $c <'env -t 1 -- sleep 86400' && $b 2>>~%EOE% != 0 + %testscript:.*: error: sleep terminated: execution timeout expired% + %. + EOE + + : success + : + $c < // this_thread::sleep_for() +# include +#else +# include +#endif + #include // numeric_limits #include #include // abort() @@ -36,10 +43,10 @@ main (int argc, char* argv[]) // Usage: driver [-i ] (-o )* (-e )* (-f )* // (-d )* (-v )* [(-t (a|m|s|z)) | (-s )] // - // Execute actions specified by -i, -o, -e, -f, -d, and -v options in the - // order as they appear on the command line. After that terminate abnormally - // if -t option is provided, otherwise exit normally with the status - // specified by -s option (0 by default). + // Execute actions specified by -i, -o, -e, -f, -d, -v, and -l options in + // the order as they appear on the command line. After that terminate + // abnormally if -t option is provided, otherwise exit normally with the + // status specified by -s option (0 by default). // // -i // Forward stdin data to the standard stream denoted by the file @@ -62,6 +69,9 @@ main (int argc, char* argv[]) // If the specified variable is set the print its value to stdout and the // string '' otherwise. // + // -l + // Sleep the specified number of seconds. + // // -t // Abnormally terminate itself using one of the following methods: // @@ -144,6 +154,18 @@ main (int argc, char* argv[]) optional var (getenv (v)); cout << (var ? *var : "") << endl; } + else if (o == "-l") + { + size_t t (toi (v)); + + // MinGW GCC 4.9 doesn't implement this_thread so use Win32 Sleep(). + // +#ifndef _WIN32 + this_thread::sleep_for (chrono::seconds (t)); +#else + Sleep (static_cast (t * 1000)); +#endif + } else if (o == "-t") { assert (aterm == '\0' && !status); // Make sure exit method is not set. diff --git a/tests/test/script/runner/env.testscript b/tests/test/script/runner/env.testscript index 6fcedfa..ef90c3b 100644 --- a/tests/test/script/runner/env.testscript +++ b/tests/test/script/runner/env.testscript @@ -3,5 +3,30 @@ .include ../common.testscript -$c <'env abc=xyz -- $* -v abc >xyz' && $b : set -$c <'env --unset=abc -- $* -v abc >""' && env abc=xyz -- $b : unset +: variables +: +{ + $c <'env abc=xyz -- $* -v abc >xyz' && $b : set + $c <'env --unset=abc -- $* -v abc >""' && env abc=xyz -- $b : unset +} + +: timeout +: +{ + : expired + : + $c <'env --timeout 1 -- $* -l 5' && $b 2>>~%EOE% != 0 + %testscript:1:1: error: .+ terminated: execution timeout expired% + info: test id: 1 + EOE + + : not-expired + : + $c <'env --timeout 5 -- $* -l 1' && $b + + : invalid + : + $c <'env --timeout a -- $*' && $b 2>>EOE != 0 + testscript:1:15: error: env: invalid value 'a' for option '--timeout' + EOE +} diff --git a/tests/test/script/runner/set.testscript b/tests/test/script/runner/set.testscript index 9219cbb..41709e4 100644 --- a/tests/test/script/runner/set.testscript +++ b/tests/test/script/runner/set.testscript @@ -94,10 +94,28 @@ { : non-exact : - $c <'foo bar' - EOI + { + : non-empty + : + $c <'"foo" "bar"' + EOI + + : empty + : + $c <'' + EOI + + : spaces + : + $c <'' + EOI + } : exact : @@ -106,7 +124,7 @@ : $c <'foo bar ' + echo $regex.apply($baz, '^(.*)$', '"\1"') >'"foo" "bar" ""' EOI : no-trailing-ws @@ -115,8 +133,22 @@ : ':' modifier. : $c <'foo bar' + set -e -w baz <:' foo bar'; + echo $regex.apply($baz, '^(.*)$', '"\1"') >'"foo" "bar"' + EOI + + : empty + : + $c <'' + EOI + + : spaces + : + $c <'""' EOI } } @@ -134,7 +166,7 @@ bar EOF - echo $baz >' foo bar ' + echo $regex.apply($baz, '^(.*)$', '"\1"') >'"" "foo" "" "bar" ""' EOI : exact @@ -150,7 +182,7 @@ bar EOF - echo $baz >' foo bar ' + echo $regex.apply($baz, '^(.*)$', '"\1"') >'"" "foo" "" "bar" "" ""' EOI : no-trailing-newline @@ -162,7 +194,7 @@ bar EOF - echo $baz >' foo bar' + echo $regex.apply($baz, '^(.*)$', '"\1"') >'"" "foo" "" "bar"' EOI } } @@ -180,7 +212,7 @@ bar EOF - echo $baz >>EOO + echo ($baz[0]) >>EOO foo @@ -209,7 +241,7 @@ bar EOF - echo "$baz" >>EOO + echo ($baz[0]) >>EOO foo @@ -227,7 +259,7 @@ bar EOF - echo "$baz" >>EOO + echo ($baz[0]) >>EOO foo @@ -237,6 +269,66 @@ } } +: deadline +: +{ + : not-reached + : + $c <foo 2>| + EOI + + : set-reached + : + $c <>~%EOE% != 0 + $* -o 'foo' -l 10 | env -t 1 -- set bar + EOI + %testscript:.*: error: set terminated: execution timeout expired% + %. + EOE + + : driver-reached + : + $c <>~%EOE% != 0 + env -t 1 -- $* -o 'foo' -l 10 | set bar + EOI + %testscript:.*: error: .+driver.* terminated: execution timeout expired% + %. + EOE + + : read-some-data + : + { + s="----------------------------------------------------------------------" + s="$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s" + s="$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s$s" + + : failure + : + $c <>~%EOE% != 0 + echo "$s" >=f; + $* -o 'foo' -l 10 | cat f - | env -t 2 -- set bar + EOI + %testscript:.*: error: set terminated: execution timeout expired% + %. + EOE + + : success + : + : Note that the cat builtin ends up with the 'broken pipe' diagnostics or + : similar. + : + $c <=f; + timeout --success 2; + $* -o 'foo' -l 10 | cat f - 2>>~%EOE% | set bar + %cat: .+% + EOE + EOI + } +} + : attributes : { diff --git a/tests/test/script/runner/timeout.testscript b/tests/test/script/runner/timeout.testscript new file mode 100644 index 0000000..ae8f535 --- /dev/null +++ b/tests/test/script/runner/timeout.testscript @@ -0,0 +1,503 @@ +# file : tests/test/script/runner/timeout.testscript +# license : MIT; see accompanying LICENSE file + +.include ../common.testscript + +: test +: +{ + : fragment-timeout + : + { + : set + : + $c <>~%EOE% != 0 + timeout 1; + $* -l 3 + EOI + %testscript:.*: error: .+ terminated: execution timeout expired% + %. + EOE + + : reset + : + $c <>~%EOE% != 0 + +timeout /10 + + { + +timeout /10 + + timeout 1; + env -t 10 -- $* -l 3 + } + EOI + %testscript:.*: error: .+ terminated: execution timeout expired% + %. + EOE + + : successful + : + $c <>~%EOE% != 0 + testscript:1:1: error: missing timeout + %. + EOE + + : invalid + : + $c <'timeout foo' && $b 2>>~%EOE% != 0 + testscript:1:1: error: invalid test fragment timeout 'foo' + %. + EOE +} + +: group +: +{ + : group-timeout + : + { + : set + : + $c <>~%EOE% != 0 + { + +timeout 1 + + $* -l 3 + } + EOI + %testscript:.*: error: .+ terminated: execution timeout expired% + %. + EOE + + : reset + : + $c <>~%EOE% != 0 + +timeout 10/10 + + { + +timeout 1/ + + timeout 10; + env -t 10 -- $* -l 3 + } + EOI + %testscript:.*: error: .+ terminated: execution timeout expired% + %. + EOE + + : invalid + : + $c <>~%EOE% != 0 + { + +timeout foo/ + } + EOI + testscript:2:4: error: invalid test group timeout 'foo' + %. + EOE + } + + : test-timeout + : + { + : set + : + $c <>~%EOE% != 0 + { + +timeout /1 + + $* -l 3 + } + EOI + %testscript:.*: error: .+ terminated: execution timeout expired% + %. + EOE + + : reset + : + $c <>~%EOE% != 0 + +timeout 10/10 + + { + +timeout /1 + + timeout 10; + env -t 10 -- $* -l 3 + } + EOI + %testscript:.*: error: .+ terminated: execution timeout expired% + %. + EOE + + : invalid + : + $c <>~%EOE% != 0 + { + +timeout /foo + } + EOI + testscript:2:4: error: invalid test timeout 'foo' + %. + EOE + } +} + +: script +: +{ + : group-timeout + : + { + : set + : + $c <>~%EOE% != 0 + +timeout 1 + + $* -l 3 + EOI + %testscript:.*: error: .+ terminated: execution timeout expired% + %. + EOE + + : reset + : + $c <>~%EOE% != 0 + +timeout 1 + + { + +timeout 10 + + timeout 10; + env -t 10 -- $* -l 3 + } + EOI + %testscript:.*: error: .+ terminated: execution timeout expired% + %. + EOE + + : invalid + : + $c <>~%EOE% != 0 + +timeout foo/ + EOI + testscript:1:2: error: invalid testscript timeout 'foo' + %. + EOE + + : successful + : + $c <>~%EOE% != 0 + +timeout /1 + + $* -l 3 + EOI + %testscript:.*: error: .+ terminated: execution timeout expired% + %. + EOE + + : reset + : + $c <>~%EOE% != 0 + +timeout /1 + + { + +timeout --success /1 + + { + +timeout 10/10 + + timeout 10; + env -t 10 -- $* -l 3 + } + } + EOI + %testscript:.*: error: .+ terminated: execution timeout expired% + %. + EOE + + : successful + : + $c <>~%EOE% != 0 + { + +timeout /foo + } + EOI + testscript:2:4: error: invalid test timeout 'foo' + %. + EOE + } +} + +: config +: +{ + : operation + : + { + : set + : + $c <>~%EOE% != 0 + $* -l 3 + EOI + %testscript:.*: error: .+ terminated: execution timeout expired% + %. + EOE + + : reset + : + $c <>~%EOE% != 0 + +timeout 10 + + { + +timeout 10/10 + + timeout 10; + env -t 10 -- $* -l 3 + } + EOI + %testscript:.*: error: .+ terminated: execution timeout expired% + %. + EOE + + : invalid + : + $c && $b config.test.timeout=foo 2>>EOE != 0 + error: invalid config.test.timeout test operation timeout value 'foo' + EOE + } + + : test + : + { + : set + : + $c <>~%EOE% != 0 + $* -l 3 + EOI + %testscript:.*: error: .+ terminated: execution timeout expired% + %. + EOE + + : reset + : + $c <>~%EOE% != 0 + +timeout 10 + + { + +timeout 10/10 + + timeout 10; + env -t 10 -- $* -l 3 + } + EOI + %testscript:.*: error: .+ terminated: execution timeout expired% + %. + EOE + + : invalid + : + $c && $b config.test.timeout=/foo 2>>EOE != 0 + error: invalid config.test.timeout test timeout value 'foo' + EOE + } +} + +: failures +: +: Here we test that the left-hand side processes are terminated on failure. +: +{ + : set + : + $c <>~%EOE% != 0 + env -t 1 -- $* -l 86400 -o 'foo' | set --foo bar + EOI + %testscript:.*: error: set: unknown option '--foo'% + %. + EOE + + : exit + : + $c <>~%EOE% != 0 + env -t 1 -- $* -l 86400 -o 'foo' | exit 0 + EOI + %testscript:.*: error: exit builtin must be the only pipe command% + %. + EOE + + : redirect + : + $c <>~%EOE% != 0 + env -t 1 -- $* -l 86400 -o 'foo' | touch $~/foo/bar + EOI + %testscript:.*: error: touch exited with code 1% + %.+ + EOE +} + +: pipeline +: +{ + : prog-tm-prog + : + $c <'$* -l 10 | env -t 1 -- $* -i 0' && $b 2>>~%EOE% != 0 + %testscript:.*: error: .+driver.* terminated: execution timeout expired% + %. + EOE + + : tm-prog-prog + : + $c <'env -t 1 -- $* -l 10 | $* -i 0' && $b 2>>~%EOE% != 0 + %testscript:.*: error: .+driver.* terminated: execution timeout expired% + %. + EOE + + : tm-cat-prog + : + $c <'env -t 1 -- cat <"test" | $* -l 10' && $b 2>>~%EOE% != 0 + %testscript:.*: error: cat terminated: execution timeout expired% + %. + EOE + + : cat-tm-prog + : + $c <'cat <"test" | env -t 1 -- $* -l 10' && $b 2>>~%EOE% != 0 + %testscript:.*: error: .+driver.* terminated: execution timeout expired% + %. + EOE + + : tm-prog-cat + : + $c <'env -t 1 -- $* -l 10 | cat >-' && $b 2>>~%EOE% != 0 + %testscript:.*: error: .+driver.* terminated: execution timeout expired% + %. + EOE + + : tm-echo-prog + : + $c <'env -t 1 -- echo "test" | $* -l 10' && $b 2>>~%EOE% != 0 + %testscript:.*: error: echo terminated: execution timeout expired% + %. + EOE + + : successful + : + { + : prog-prog + : + $c <>~%EOE% | $* -l 10 -i 0 + %cat: unable to print stdin: .+% + EOE + EOI + } +} diff --git a/tests/test/simple/generated/buildfile b/tests/test/simple/generated/buildfile index 0809bdf..0344891 100644 --- a/tests/test/simple/generated/buildfile +++ b/tests/test/simple/generated/buildfile @@ -6,4 +6,5 @@ ./: testscript exe{driver} $b file{*.in} -exe{driver}: cxx{driver} +import libs = libbutl%lib{butl} +exe{driver}: cxx{driver} $libs diff --git a/tests/test/simple/generated/driver.cxx b/tests/test/simple/generated/driver.cxx index 1d911df..18fd0ae 100644 --- a/tests/test/simple/generated/driver.cxx +++ b/tests/test/simple/generated/driver.cxx @@ -1,24 +1,52 @@ // file : tests/test/simple/generated/driver.cxx -*- C++ -*- // license : MIT; see accompanying LICENSE file +#ifndef _WIN32 +# include +# include +#else +# include +#endif + #include #include #include using namespace std; +// If the -s option is specified, then also sleep for 5 seconds. +// int main (int argc, char* argv[]) { + int i (1); + for (; i != argc; ++i) + { + string a (argv[i]); + + if (a == "-s") + { + // MINGW GCC 4.9 doesn't implement this_thread so use Win32 Sleep(). + // +#ifndef _WIN32 + this_thread::sleep_for (chrono::seconds (5)); +#else + Sleep (5000); +#endif + } + else + break; + } + int r (0); - if (argc == 1) + if (i == argc) { cout << "1.2.3" << endl; } else { - ifstream ifs (argv[1]); + ifstream ifs (argv[i]); if (!ifs.is_open ()) cerr << "unable to open " << argv[1] << endl; diff --git a/tests/test/simple/generated/testscript b/tests/test/simple/generated/testscript index a04dccc..f6a89d8 100644 --- a/tests/test/simple/generated/testscript +++ b/tests/test/simple/generated/testscript @@ -42,3 +42,60 @@ driver = $src_root/../exe{driver} ./: file{output}: test.stdout = true file{output}: in{output} $src_root/manifest #@@ in module EOI + +: timeout +: +{ + : operation + : + { + : no-output + : + ln -s $src_base/output.in ./; + $* config.test.timeout=1 <>/~%EOE% != 0 + driver = $src_root/../exe{driver} + ./: test = $driver + ./: test.options = -s + ./: $driver + EOI + error: test dir{./} failed + % error: .+ -s terminated: execution timeout expired% + % info: test command line: .+% + EOE + + : output + : + ln -s $src_base/output.in ./; + $* config.test.timeout=1 &output &output.d <>/~%EOE% != 0 + driver = $src_root/../exe{driver} + ./: test = $driver + ./: test.options = -s + ./: $driver + ./: file{output}: test.stdout = true + file{output}: in{output} $src_root/manifest #@@ in module + EOI + error: test dir{./} failed + % error: diff .+ terminated: execution timeout expired% + % error: .+ -s terminated: execution timeout expired% + % info: test command line: .+% + EOE + } + + : test + : + { + : no-output + : + ln -s $src_base/output.in ./; + $* config.test.timeout=/1 <>/~%EOE% != 0 + driver = $src_root/../exe{driver} + ./: test = $driver + ./: test.options = -s + ./: $driver + EOI + error: test dir{./} failed + % error: .+ -s terminated: execution timeout expired% + % info: test command line: .+% + EOE + } +} -- cgit v1.1