diff --git a/daemon/launcher/CMakeLists.txt b/daemon/launcher/CMakeLists.txt index e8dec44..ee5b1f1 100644 --- a/daemon/launcher/CMakeLists.txt +++ b/daemon/launcher/CMakeLists.txt @@ -21,7 +21,11 @@ endif() add_executable(gpufl_launcher main.cpp agent_launcher.cpp + cli_help.cpp cli_parse.cpp + cli_parse_internal.cpp + cli_subcommand_options.cpp + cli_trace_options.cpp info_command.cpp trace_command_common.cpp deep_window_env.cpp diff --git a/daemon/launcher/cli_help.cpp b/daemon/launcher/cli_help.cpp new file mode 100644 index 0000000..a4b658c --- /dev/null +++ b/daemon/launcher/cli_help.cpp @@ -0,0 +1,149 @@ +#include "cli_parse.hpp" +#include "cli_trace_options.hpp" + +#include + +namespace gpufl::launcher { + +const char* topLevelHelp() { + return R"HELP(gpufl - GPUFlight launcher + +USAGE: + gpufl [OPTIONS] + +SUBCOMMANDS: + trace Inject GPUFlight into a target process and capture telemetry + monitor Run long-lived GPU/host telemetry collection + info Print local GPU device capabilities + upload Upload a captured session's NDJSON logs to the backend + version Print version + build info + +Run `gpufl --help` for subcommand-specific help. +)HELP"; +} + +const char* traceHelp() { + static const std::string help = [] { + std::string out = R"HELP(gpufl trace - Capture telemetry from a target process + +USAGE: + gpufl trace [OPTIONS] -- ... + +OPTIONS: +)HELP"; + // Every option below is rendered from the registry in + // cli_trace_options.cpp, so a new flag documents itself. Only prose + // that belongs to no single option is written here. + out += formatTraceSimpleOptions(TraceHelpSection::Capture); + out += formatTraceSimpleOptions(TraceHelpSection::Runtime); + out += formatTraceSimpleOptions(TraceHelpSection::Segmentation); + out += formatTraceSimpleOptions(TraceHelpSection::Window); + out += R"HELP( A deep window is ONE adaptive run: gpufl picks the deep engine, so + --deep-* cannot be combined with --passes. Which engine was selected + is printed at startup rather than promised here - it depends on the + GPU, and today only PM sampling is selected. + +)HELP"; + out += formatTraceSimpleOptions(TraceHelpSection::Deep); + out += formatTraceSimpleOptions(TraceHelpSection::Sampling); + out += R"HELP( -h, --help Print this help + +EXAMPLES: + gpufl trace -- python train.py + gpufl trace --name=quantize -- ./inference_server + gpufl trace --passes=Trace,PmSampling -- python train.py + gpufl trace --passes=Deep -- python train.py # multi-pass + gpufl trace --passes=Trace,SassMetrics -- ./app # custom plan + gpufl trace --passes=Trace+PcSampling -- ./app # one-process composite + gpufl trace --passes=Trace+PcSampling --warmup=60s --window=5m -- ./serve +)HELP"; + return out; + }(); + return help.c_str(); +} + +const char* uploadHelp() { + return R"HELP(gpufl upload - Upload a captured session's NDJSON logs to the backend + +USAGE: + gpufl upload [OPTIONS] + +ARGS: + Output directory written by `gpufl trace`, or + the InitOptions log_path directory. Looks for + '//.log[.gz]'. + A trace dir works directly: + e.g. ~/.gpufl/traces/20260603-101500_ab12cd34 + +OPTIONS: + --backend-url= Backend base URL. Env: GPUFL_BACKEND_URL + --api-key= Bearer token. Env: GPUFL_API_KEY + --api-path= Reverse-proxy mount. Defaults to /api/v1 + --agent-jar= Run the uploader as `java -jar `. + Env: GPUFL_AGENT_JAR (else gpufl-agent on PATH) + --timeout= Cap on waiting for the upload to finish. Default 300 + --retries= Accepted for compatibility; the agent retries internally + -q, --quiet Suppress periodic progress lines + --all-sessions Upload every session in the dir (this is the default) + --force Re-upload even if the cursor says it shipped + -h, --help Print this help + +EXAMPLES: + gpufl upload ~/.gpufl/traces/20260603-101500_ab12cd34 + gpufl upload ./logs --force + GPUFL_API_KEY=gpfl_… GPUFL_BACKEND_URL=https://api.gpuflight.com \ + gpufl upload ./logs +)HELP"; +} + +const char* monitorHelp() { + return R"HELP(gpufl monitor - Run long-lived GPU/host telemetry collection + +USAGE: + gpufl monitor [OPTIONS] + +OPTIONS: + -n, --name= Monitor session name. Default: gpufl-monitor + -o, --output= Local NDJSON output dir + (default: ~/.gpufl/monitor/{ts}_{session_id}/) + --interval= Sampling interval in milliseconds. Default: 5000 + --upload Start gpufl-agent as the live uploader + --backend-url= Backend base URL for --upload + Env fallback: GPUFL_BACKEND_URL + --api-key= Bearer token for --upload + Env fallback: GPUFL_API_KEY + --api-version= Agent HTTP API version. Default: v1 + --agent-jar= Run agent as `java -jar ` + Env fallback: GPUFL_AGENT_JAR + --agent-cursor=

Agent cursor file. Default: /cursor.json + --log-types= Agent channels to upload. Default: system + -q, --quiet Suppress launcher chatter + -v, --verbose Verbose launcher logging + -h, --help Print this help + +EXAMPLES: + gpufl monitor + gpufl monitor --interval=1000 + gpufl monitor --name=llm-node-1 --upload +)HELP"; +} + +const char* infoHelp() { + return R"HELP(gpufl info - Print local GPU device capabilities + +USAGE: + gpufl info [OPTIONS] + +OPTIONS: + --json Emit stable machine-readable JSON + --device= Limit output to one zero-based device ID + -h, --help Print this help + +EXAMPLES: + gpufl info + gpufl info --json + gpufl info --device=0 --json +)HELP"; +} + +} // namespace gpufl::launcher diff --git a/daemon/launcher/cli_option_manager.hpp b/daemon/launcher/cli_option_manager.hpp new file mode 100644 index 0000000..a909938 --- /dev/null +++ b/daemon/launcher/cli_option_manager.hpp @@ -0,0 +1,182 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cli_parse_internal.hpp" + +namespace gpufl::launcher::detail { + +/** + * Alias-to-handler dispatch for one subcommand's option table, plus the help + * text for those options. + * + * Help lives next to the option on purpose: a flag and its documentation are + * added in one place, so it is not possible to ship a flag nobody can discover. + * An option with an empty description is deliberately undocumented (removed + * flags kept only to print a migration hint). + */ +template +class CliOptionManager { + public: + using Handler = std::string (*)( + const FlagBreak&, const std::vector&, std::size_t&, Args&); + + /** Undocumented option: dispatch only, never rendered into help. */ + CliOptionManager& add(std::initializer_list aliases, + const Handler handler) { + return add(aliases, "", "", 0, handler); + } + + /** + * Documented option. `value_name` is the metavariable ("") or "" for a + * boolean flag; `help_section` groups the option in help output and must be + * nonzero for the option to be rendered. + * + * Aliases MUST have static storage duration - string literals. Only the + * views are stored, so a temporary std::string would dangle. + */ + CliOptionManager& add(std::initializer_list aliases, + const std::string_view value_name, + const std::string_view description, + const int help_section, + const Handler handler) { + if (aliases.size() == 0 || handler == nullptr) { + throw std::logic_error("CLI option requires an alias and handler"); + } + for (const std::string_view alias : aliases) { + if (find(alias) != nullptr) { + throw std::logic_error( + "duplicate CLI option alias: " + std::string(alias)); + } + } + options_.push_back({std::vector(aliases), value_name, + description, help_section, handler}); + return *this; + } + + bool parse(const FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + Args& args, + std::string& error) const { + const Option* option = find(flag.key); + if (option == nullptr) return false; + error = option->handler(flag, argv, index, args); + return true; + } + + /** Render every documented option of one section, in registration order. */ + std::string formatHelp(const int help_section) const { + std::string out; + for (const Option& option : options_) { + if (option.help_section != help_section) continue; + if (option.description.empty()) continue; + appendHelpLine(out, option); + } + return out; + } + + /** + * Every alias in the table, documented or not. Lets a test assert that the + * table and the help output agree instead of trusting review. + */ + std::vector aliases() const { + std::vector all; + for (const Option& option : options_) { + all.insert(all.end(), option.aliases.begin(), option.aliases.end()); + } + return all; + } + + private: + struct Option { + std::vector aliases; + std::string_view value_name; + std::string_view description; + int help_section = 0; + Handler handler = nullptr; + }; + + const Option* find(const std::string_view key) const { + const auto option = std::find_if( + options_.begin(), options_.end(), + [key](const Option& candidate) { + return std::find(candidate.aliases.begin(), + candidate.aliases.end(), key) != + candidate.aliases.end(); + }); + return option == options_.end() ? nullptr : &*option; + } + + // Matches the launcher's long-standing help layout: descriptions start at + // one column, and a long-only option indents to where a "-x, " prefix would + // have ended so both kinds line up. + static void appendHelpLine(std::string& out, const Option& option) { + constexpr std::size_t kShortIndent = 4; + constexpr std::size_t kLongOnlyIndent = 8; + constexpr std::size_t kDescriptionColumn = 28; + constexpr std::size_t kLineWidth = 96; + + const bool has_short = std::any_of( + option.aliases.begin(), option.aliases.end(), + [](const std::string_view alias) { + return alias.size() > 1 && alias[0] == '-' && alias[1] != '-'; + }); + const std::size_t indent = has_short ? kShortIndent : kLongOnlyIndent; + + std::string spelling; + for (std::size_t i = 0; i < option.aliases.size(); ++i) { + if (i > 0) spelling += ", "; + spelling += option.aliases[i]; + } + if (!option.value_name.empty()) { + spelling += "="; + spelling += option.value_name; + } + + out.append(indent, ' '); + out += spelling; + std::size_t column = indent + spelling.size(); + // A spelling that reaches the description column takes the next line, + // so a long flag name never pushes its description out of alignment. + if (column >= kDescriptionColumn) { + out += '\n'; + out.append(kDescriptionColumn, ' '); + } else { + out.append(kDescriptionColumn - column, ' '); + } + column = kDescriptionColumn; + + std::istringstream words{std::string(option.description)}; + std::string word; + bool first = true; + while (words >> word) { + const std::size_t separator = first ? 0 : 1; + if (column + separator + word.size() > kLineWidth) { + out += '\n'; + out.append(kDescriptionColumn, ' '); + column = kDescriptionColumn; + first = true; + } + if (!first) { + out += ' '; + ++column; + } + out += word; + column += word.size(); + first = false; + } + out += '\n'; + } + + std::vector

Local NDJSON output dir\n" - " (default: ~/.gpufl/traces/{ts}_{session_id}/)\n" - " --passes= Capture pass list: comma-separated values from:\n" - " Trace | PcSampling | SassMetrics | PmSampling |\n" - " RangeProfiler | RangeProfilerKernelReplay | Deep\n" - " Each comma is a separate pass (relaunch). Join\n" - " engines with + to run them in ONE process, e.g.\n" - " Trace+PcSampling (timeline + PC stalls, one run).\n" - " Default: Trace. Deep runs PcSampling+SassMetrics\n" - " in one pass (same as the embedded Deep engine);\n" - " for timeline+stalls+SASS list passes explicitly,\n" - " e.g. Trace,PcSampling,SassMetrics. SassMetrics\n" - " must be its own pass (deadlocks if shared). Use\n" - " gpufl monitor for monitoring-only telemetry.\n" - " PcSampling / PM / Range passes may need NVIDIA\n" - " performance-counter access.\n" - " -q, --quiet Suppress launcher chatter (errors still printed)\n" - " -v, --verbose Verbose launcher logging\n" - " --upload Start gpufl-agent as the live uploader\n" - " --backend-url= Backend base URL for --upload\n" - " Env fallback: GPUFL_BACKEND_URL\n" - " --api-key= Bearer token for --upload\n" - " Env fallback: GPUFL_API_KEY\n" - " --api-version= Agent HTTP API version. Default: v1\n" - " --agent-jar= Run agent as `java -jar `\n" - " Env fallback: GPUFL_AGENT_JAR\n" - " --agent-cursor=

Agent cursor file. Default: /cursor.json\n" - " --log-types= Agent channels to upload. Default: device,scope,system,sass\n" - " --agent-drain-ms=\n" - " Max wait for the agent to finish uploading before\n" - " stopping it (it exits on its own when done). Default: 60000\n" - " --segment-every=\n" - " Split a long run on this cadence (minimum: 60s).\n" - " Example: --segment-every=5m. Default: off\n" - " --segment-max-rows=\n" - " Also split after this many logical telemetry rows.\n" - " The batch crossing N stays in the old segment.\n" - " Default: off. V1 supports one Trace or PM pass;\n" - " multi-pass analyses cannot be segmented.\n" - " --warmup= Skip cold start: defer capture by this long\n" - " (e.g. 30s, 500ms, 5m; bare number = seconds)\n" - " --window= Bounded window: capture this long after warmup,\n" - " then STOP the target. For servers that never\n" - " exit. Omit to run to the target's own exit.\n" - " --window-timeout=\n" - " Hard cap on total target runtime (safety).\n" - " --after-window=\n" - " What to do at window end. Only 'stop' today.\n" - " A deep window is ONE adaptive run: gpufl picks the deep engine, so\n" - " --deep-* cannot be combined with --passes. Which engine was selected\n" - " is printed at startup rather than promised here - it depends on the\n" - " GPU, and today only PM sampling is selected.\n" - "\n" - " --deep-after= Arm the deep engine this long into the run,\n" - " then disarm. Unlike --window the target keeps\n" - " running. Needs a bound below. Default: 0 (arm\n" - " at the first kernel launch).\n" - " --deep-for= How long the deep window stays armed. Note this\n" - " bounds TIME, which does not bound how much the\n" - " engines actually collect - see --deep-launches.\n" - " --deep-when= Open the window when a metric crosses a threshold,\n" - " e.g. \"custom.token_rate<1000 for 2s\". This and\n" - " --deep-after are two answers to the same\n" - " question, so pass one or the other.\n" - " --deep-launches= Kernel-launch bound on the deep window; ends it\n" - " at whichever bound is hit first. PREFER THIS:\n" - " wall time does not bound how much an engine\n" - " collects, and how far one second of it goes\n" - " differs by more than an order of magnitude\n" - " between engines.\n" - " --deep-cooldown=\n" - " Quiet time before another window may open.\n" - " --pc-sample-period=\n" - " PC sampling period: log2 of GPU cycles per sample\n" - " (5..31; default 10). Lower = more frequent — for\n" - " short kernels that yield no PC samples by default.\n" - " -h, --help Print this help\n" - "\n" - "EXAMPLES:\n" - " gpufl trace -- python train.py\n" - " gpufl trace --name=quantize -- ./inference_server\n" - " gpufl trace --passes=Trace,PmSampling -- python train.py\n" - " gpufl trace --passes=Deep -- python train.py # multi-pass\n" - " gpufl trace --passes=Trace,SassMetrics -- ./app # custom plan\n" - " gpufl trace --passes=Trace+PcSampling -- ./app # one-process composite\n" - " gpufl trace --passes=Trace+PcSampling --warmup=60s --window=5m -- ./serve\n"; -} - ParsedTopLevel parseTopLevel(int argc, char** argv) { ParsedTopLevel out; if (argc < 2) { @@ -267,14 +56,6 @@ ParsedTopLevel parseTopLevel(int argc, char** argv) { TraceParseResult parseTraceArgs(const std::vector& argv) { TraceArgs out; bool seen_dash_dash = false; - auto parseNonNegativeInt = [](const std::string& s, int& slot) -> bool { - if (s.empty()) return false; - char* end = nullptr; - long v = std::strtol(s.c_str(), &end, 10); - if (*end != '\0' || v < 0) return false; - slot = static_cast(v); - return true; - }; for (size_t i = 0; i < argv.size(); ++i) { const std::string& tok = argv[i]; @@ -290,258 +71,24 @@ TraceParseResult parseTraceArgs(const std::vector& argv) { // Caller prints help; signal via empty error + no command. return {std::nullopt, "__help__"}; } - if (tok == "-v" || tok == "--verbose") { out.verbose = true; continue; } - if (tok == "-q" || tok == "--quiet") { out.quiet = true; continue; } - if (tok == "--upload") { out.upload = true; continue; } auto fb = splitFlag(tok); const std::string& key = fb.key; - auto take_value = [&](std::string& slot) -> std::string { - if (fb.inline_value) { slot = *fb.inline_value; return ""; } - if (i + 1 >= argv.size()) return "missing value for " + key; - slot = argv[++i]; - return ""; - }; - - if (key == "-n" || key == "--name") { - auto err = take_value(out.name); - if (!err.empty()) return {std::nullopt, err}; - } else if (key == "-o" || key == "--output") { - auto err = take_value(out.output_dir); - if (!err.empty()) return {std::nullopt, err}; - } else if (key == "--profile") { - std::string ignored; - auto err = take_value(ignored); - if (!err.empty()) return {std::nullopt, err}; - return {std::nullopt, - "`gpufl trace --profile` has been removed; use --passes=Trace, " - "--passes=Deep, or `gpufl monitor` for monitoring-only telemetry"}; - } else if (key == "--engine") { - std::string ignored; - auto err = take_value(ignored); - if (!err.empty()) return {std::nullopt, err}; - return {std::nullopt, - "`gpufl trace --engine` has been removed; use --passes=Trace, " - "--passes=Deep, or an explicit list like --passes=Trace,PmSampling"}; - } else if (key == "--passes") { - std::string v; - auto err = take_value(v); - if (!err.empty()) return {std::nullopt, err}; - // Comma-separated pass list -> one isolated pass each. A token may - // be a single engine, or a '+'-joined group ("Trace+PcSampling") - // that runs those engines together in one process (a composite). - out.passes.clear(); - size_t start = 0; - while (true) { - const size_t comma = v.find(',', start); - const std::string item = trim(v.substr( - start, - comma == std::string::npos ? std::string::npos : comma - start)); - if (!item.empty()) { - const std::string perr = validatePassToken(item); - if (!perr.empty()) return {std::nullopt, perr}; - out.passes.push_back(item); - } - if (comma == std::string::npos) break; - start = comma + 1; - } - if (out.passes.empty()) { - return {std::nullopt, "--passes requires at least one engine"}; - } - } else if (key == "--backend-url") { - auto err = take_value(out.backend_url); - if (!err.empty()) return {std::nullopt, err}; - } else if (key == "--api-key") { - auto err = take_value(out.api_key); - if (!err.empty()) return {std::nullopt, err}; - } else if (key == "--api-version") { - auto err = take_value(out.api_version); - if (!err.empty()) return {std::nullopt, err}; - if (out.api_version.empty()) return {std::nullopt, "--api-version cannot be empty"}; - } else if (key == "--agent-jar") { - auto err = take_value(out.agent_jar); - if (!err.empty()) return {std::nullopt, err}; - if (out.agent_jar.empty()) return {std::nullopt, "--agent-jar cannot be empty"}; - } else if (key == "--agent-cursor") { - auto err = take_value(out.agent_cursor); - if (!err.empty()) return {std::nullopt, err}; - if (out.agent_cursor.empty()) return {std::nullopt, "--agent-cursor cannot be empty"}; - } else if (key == "--log-types") { - auto err = take_value(out.log_types); - if (!err.empty()) return {std::nullopt, err}; - if (out.log_types.empty()) return {std::nullopt, "--log-types cannot be empty"}; - } else if (key == "--agent-drain-ms") { - std::string v; - auto err = take_value(v); - if (!err.empty()) return {std::nullopt, err}; - if (!parseNonNegativeInt(v, out.agent_drain_ms)) { - return {std::nullopt, - "invalid --agent-drain-ms value: " + v + - " (expected a non-negative integer, milliseconds)"}; - } - } else if (key == "--segment-every") { - std::string v; - auto err = take_value(v); - if (!err.empty()) return {std::nullopt, err}; - if (!parseDurationMs(v, out.segment_every_ms)) { - return {std::nullopt, - "invalid --segment-every value: " + v + - " (expected a duration like 60s, 5m, 1h, or a bare " - "number of seconds)"}; - } - if (out.segment_every_ms > 0 && - out.segment_every_ms < kMinSegmentEveryMs) { - return {std::nullopt, - "--segment-every must be at least 60s; shorter cadences " - "can create a session storm"}; - } - } else if (key == "--segment-max-rows") { - std::string v; - auto err = take_value(v); - if (!err.empty()) return {std::nullopt, err}; - if (v.empty() || v.front() == '-') { - return {std::nullopt, - "invalid --segment-max-rows value: " + v + - " (expected a non-negative integer; 0 disables it)"}; - } - char* end = nullptr; - errno = 0; - const unsigned long long n = std::strtoull(v.c_str(), &end, 10); - if (end == v.c_str() || (end && *end != '\0') || errno == ERANGE) { - return {std::nullopt, - "invalid --segment-max-rows value: " + v + - " (expected a non-negative integer; 0 disables it)"}; - } - out.segment_max_rows = static_cast(n); - } else if (key == "--pc-sample-period") { - std::string v; - auto err = take_value(v); - if (!err.empty()) return {std::nullopt, err}; - int period = 0; - if (!parseNonNegativeInt(v, period) || period < 5 || period > 31) { - return {std::nullopt, - "invalid --pc-sample-period value: " + v + - " (expected an integer 5..31; the log2 of GPU cycles per " - "PC sample - lower = more frequent, catches short kernels)"}; - } - out.pc_sample_period = static_cast(period); - } else if (key == "--warmup") { - std::string v; - auto err = take_value(v); - if (!err.empty()) return {std::nullopt, err}; - if (!parseDurationMs(v, out.warmup_ms)) { - return {std::nullopt, - "invalid --warmup value: " + v + - " (expected a duration like 30s, 500ms, 5m, 1h, " - "or a bare number of seconds)"}; - } - } else if (key == "--window") { - std::string v; - auto err = take_value(v); - if (!err.empty()) return {std::nullopt, err}; - if (!parseDurationMs(v, out.window_ms)) { - return {std::nullopt, - "invalid --window value: " + v + - " (expected a duration like 30s, 500ms, 5m, 1h, " - "or a bare number of seconds)"}; - } - } else if (key == "--window-timeout") { - std::string v; - auto err = take_value(v); - if (!err.empty()) return {std::nullopt, err}; - if (!parseDurationMs(v, out.window_timeout_ms)) { - return {std::nullopt, - "invalid --window-timeout value: " + v + - " (expected a duration like 30s, 5m, 1h, " - "or a bare number of seconds)"}; - } - } else if (key == "--deep-after" || key == "--deep-for" || - key == "--deep-cooldown") { - std::string v; - auto err = take_value(v); - if (!err.empty()) return {std::nullopt, err}; - int64_t ms = 0; - if (!parseDurationMs(v, ms)) { - return {std::nullopt, - "invalid " + key + " value: " + v + - " (expected a duration like 30s, 500ms, 5m, 1h, " - "or a bare number of seconds)"}; - } - // A bare number is seconds, which collides with --deep-launches: - // `--deep-for=2000` meaning "2000 launches" silently becomes a - // 33-minute window, and the no-bound check below can't catch it - // because a bound *was* given. Reject the unit-less form once it - // is too large to plausibly be a duration someone typed on - // purpose - naming the alternative, since that is the mistake. - const bool unitless = - !v.empty() && - v.find_first_not_of("0123456789") == std::string::npos; - if (key == "--deep-for" && unitless && ms >= 600'000) { - return {std::nullopt, - "--deep-for=" + v + " means " + std::to_string(ms / 1000) + - " SECONDS (a bare number is seconds), which is almost " - "certainly not what you meant. Add a unit (e.g. " + v + - "s, 5m) if you really want that long a window, or use " - "--deep-launches " + v + " to bound it by kernel " - "launches instead"}; - } - if (key == "--deep-after") { - out.deep_after_ms = ms; - out.deep_after_set = true; - } - else if (key == "--deep-for") out.deep_for_ms = ms; - else out.deep_cooldown_ms = ms; - out.deep_requested = true; - } else if (key == "--deep-when") { - std::string v; - auto err = take_value(v); - if (!err.empty()) return {std::nullopt, err}; - if (v.empty()) { - return {std::nullopt, - "--deep-when needs an expression, e.g. " - "--deep-when=\"custom.token_rate<1000 for 2s\""}; - } - // Parsed by the client, not here: telling a misspelled built-in - // from a custom counter that has simply not registered yet needs - // the metric registry, and duplicating that check would give two - // places to disagree about what a metric name means. - out.deep_when = v; - out.deep_requested = true; - } else if (key == "--deep-launches") { - std::string v; - auto err = take_value(v); - if (!err.empty()) return {std::nullopt, err}; - char* end = nullptr; - const unsigned long long n = std::strtoull(v.c_str(), &end, 10); - if (end == v.c_str() || (end && *end != '\0') || n == 0) { - return {std::nullopt, - "invalid --deep-launches value: " + v + - " (expected a positive number of kernel launches)"}; - } - out.deep_launches = static_cast(n); - out.deep_requested = true; - } else if (key == "--after-window") { - auto err = take_value(out.after_window); - if (!err.empty()) return {std::nullopt, err}; - if (out.after_window == "keep") { - return {std::nullopt, - "--after-window=keep is not yet implemented; the launcher " - "stops the target at window end (restart it with a script)"}; - } - if (out.after_window != "stop") { - return {std::nullopt, - "invalid --after-window value: " + out.after_window + - " (expected: stop)"}; - } - } else { - // A non-flag token before `--` is almost certainly the - // caller forgetting the splitter, e.g. `gpufl trace python - // train.py`. Distinguish that from a real typo on a flag. - if (!tok.empty() && tok[0] != '-') { - return {std::nullopt, "missing `--` separator before command"}; - } - return {std::nullopt, "unknown flag: " + key}; + + const TraceSimpleOptionResult simple = + parseTraceSimpleOption(fb, argv, i, out); + if (simple.found) { + if (!simple.error.empty()) return {std::nullopt, simple.error}; + continue; } + + // Not an option the registry knows. A non-flag token before `--` is + // almost certainly the caller forgetting the splitter, e.g. + // `gpufl trace python train.py`; distinguish that from a flag typo. + if (!tok.empty() && tok[0] != '-') { + return {std::nullopt, "missing `--` separator before command"}; + } + return {std::nullopt, "unknown flag: " + key}; } if (!seen_dash_dash) { return {std::nullopt, "missing `--` separator before command"}; @@ -586,151 +133,27 @@ TraceParseResult parseTraceArgs(const std::vector& argv) { return {out, ""}; } -const char* uploadHelp() { - return - "gpufl upload - Upload a captured session's NDJSON logs to the backend\n" - "\n" - "USAGE:\n" - " gpufl upload [OPTIONS]\n" - "\n" - "ARGS:\n" - " Output directory written by `gpufl trace`, or\n" - " the InitOptions log_path directory. Looks for\n" - " '//.log[.gz]'.\n" - " A trace dir works directly:\n" - " e.g. ~/.gpufl/traces/20260603-101500_ab12cd34\n" - "\n" - "OPTIONS:\n" - " --backend-url= Backend base URL. Env: GPUFL_BACKEND_URL\n" - " --api-key= Bearer token. Env: GPUFL_API_KEY\n" - " --api-path= Reverse-proxy mount. Defaults to /api/v1\n" - " --agent-jar= Run the uploader as `java -jar `.\n" - " Env: GPUFL_AGENT_JAR (else gpufl-agent on PATH)\n" - " --timeout= Cap on waiting for the upload to finish. Default 300\n" - " --retries= Accepted for compatibility; the agent retries internally\n" - " -q, --quiet Suppress periodic progress lines\n" - " --all-sessions Upload every session in the dir (this is the default)\n" - " --force Re-upload even if the cursor says it shipped\n" - " -h, --help Print this help\n" - "\n" - "EXAMPLES:\n" - " gpufl upload ~/.gpufl/traces/20260603-101500_ab12cd34\n" - " gpufl upload ./logs --force\n" - " GPUFL_API_KEY=gpfl_… GPUFL_BACKEND_URL=https://api.gpuflight.com \\\n" - " gpufl upload ./logs\n"; -} - -const char* monitorHelp() { - return - "gpufl monitor - Run long-lived GPU/host telemetry collection\n" - "\n" - "USAGE:\n" - " gpufl monitor [OPTIONS]\n" - "\n" - "OPTIONS:\n" - " -n, --name= Monitor session name. Default: gpufl-monitor\n" - " -o, --output=

Local NDJSON output dir\n" - " (default: ~/.gpufl/monitor/{ts}_{session_id}/)\n" - " --interval= Sampling interval in milliseconds. Default: 5000\n" - " --upload Start gpufl-agent as the live uploader\n" - " --backend-url= Backend base URL for --upload\n" - " Env fallback: GPUFL_BACKEND_URL\n" - " --api-key= Bearer token for --upload\n" - " Env fallback: GPUFL_API_KEY\n" - " --api-version= Agent HTTP API version. Default: v1\n" - " --agent-jar= Run agent as `java -jar `\n" - " Env fallback: GPUFL_AGENT_JAR\n" - " --agent-cursor=

Agent cursor file. Default: /cursor.json\n" - " --log-types= Agent channels to upload. Default: system\n" - " -q, --quiet Suppress launcher chatter\n" - " -v, --verbose Verbose launcher logging\n" - " -h, --help Print this help\n" - "\n" - "EXAMPLES:\n" - " gpufl monitor\n" - " gpufl monitor --interval=1000\n" - " gpufl monitor --name=llm-node-1 --upload\n"; -} - -const char* infoHelp() { - return - "gpufl info - Print local GPU device capabilities\n" - "\n" - "USAGE:\n" - " gpufl info [OPTIONS]\n" - "\n" - "OPTIONS:\n" - " --json Emit stable machine-readable JSON\n" - " --device= Limit output to one zero-based device ID\n" - " -h, --help Print this help\n" - "\n" - "EXAMPLES:\n" - " gpufl info\n" - " gpufl info --json\n" - " gpufl info --device=0 --json\n"; -} - UploadParseResult parseUploadArgs(const std::vector& argv) { UploadArgs out; bool have_log_path = false; - auto parseInt = [](const std::string& s, int& slot) -> bool { - if (s.empty()) return false; - char* end = nullptr; - long v = std::strtol(s.c_str(), &end, 10); - if (*end != '\0' || v < 0) return false; - slot = static_cast(v); - return true; - }; - for (size_t i = 0; i < argv.size(); ++i) { const std::string& tok = argv[i]; if (tok == "-h" || tok == "--help") return {std::nullopt, "__help__"}; - if (tok == "-q" || tok == "--quiet") { out.quiet = true; continue; } - if (tok == "--all-sessions") { out.all_sessions = true; continue; } - if (tok == "--force") { out.force = true; continue; } auto fb = splitFlag(tok); const std::string& key = fb.key; - auto take_value = [&](std::string& slot) -> std::string { - if (fb.inline_value) { slot = *fb.inline_value; return ""; } - if (i + 1 >= argv.size()) return "missing value for " + key; - slot = argv[++i]; - return ""; - }; - - if (key == "--backend-url") { - auto err = take_value(out.backend_url); - if (!err.empty()) return {std::nullopt, err}; - } else if (key == "--api-key") { - auto err = take_value(out.api_key); - if (!err.empty()) return {std::nullopt, err}; - } else if (key == "--api-path") { - auto err = take_value(out.api_path); - if (!err.empty()) return {std::nullopt, err}; - } else if (key == "--agent-jar") { - auto err = take_value(out.agent_jar); - if (!err.empty()) return {std::nullopt, err}; - } else if (key == "--session-id") { + const SubcommandOptionResult simple = + parseUploadSimpleOption(fb, argv, i, out); + if (simple.found) { + if (!simple.error.empty()) return {std::nullopt, simple.error}; + continue; + } + + if (key == "--session-id") { return {std::nullopt, "--session-id is no longer supported; point at a " "directory containing only that session"}; - } else if (key == "--timeout") { - std::string v; - auto err = take_value(v); - if (!err.empty()) return {std::nullopt, err}; - if (!parseInt(v, out.timeout_s)) { - return {std::nullopt, "invalid --timeout value: " + v + - " (expected a non-negative integer, seconds)"}; - } - } else if (key == "--retries") { - std::string v; - auto err = take_value(v); - if (!err.empty()) return {std::nullopt, err}; - if (!parseInt(v, out.retries)) { - return {std::nullopt, "invalid --retries value: " + v + - " (expected a non-negative integer)"}; - } } else if (!tok.empty() && tok[0] == '-') { return {std::nullopt, "unknown flag: " + key}; } else { @@ -753,71 +176,20 @@ UploadParseResult parseUploadArgs(const std::vector& argv) { MonitorParseResult parseMonitorArgs(const std::vector& argv) { MonitorArgs out; - auto parsePositiveInt = [](const std::string& s, int& slot) -> bool { - if (s.empty()) return false; - char* end = nullptr; - long v = std::strtol(s.c_str(), &end, 10); - if (*end != '\0' || v <= 0) return false; - slot = static_cast(v); - return true; - }; - for (size_t i = 0; i < argv.size(); ++i) { const std::string& tok = argv[i]; if (tok == "-h" || tok == "--help") return {std::nullopt, "__help__"}; - if (tok == "-v" || tok == "--verbose") { out.verbose = true; continue; } - if (tok == "-q" || tok == "--quiet") { out.quiet = true; continue; } - if (tok == "--upload") { out.upload = true; continue; } auto fb = splitFlag(tok); const std::string& key = fb.key; - auto take_value = [&](std::string& slot) -> std::string { - if (fb.inline_value) { slot = *fb.inline_value; return ""; } - if (i + 1 >= argv.size()) return "missing value for " + key; - slot = argv[++i]; - return ""; - }; - - if (key == "-n" || key == "--name") { - auto err = take_value(out.name); - if (!err.empty()) return {std::nullopt, err}; - if (out.name.empty()) return {std::nullopt, "--name cannot be empty"}; - } else if (key == "-o" || key == "--output") { - auto err = take_value(out.output_dir); - if (!err.empty()) return {std::nullopt, err}; - if (out.output_dir.empty()) return {std::nullopt, "--output cannot be empty"}; - } else if (key == "--interval") { - std::string v; - auto err = take_value(v); - if (!err.empty()) return {std::nullopt, err}; - if (!parsePositiveInt(v, out.interval_ms)) { - return {std::nullopt, - "invalid --interval value: " + v + - " (expected a positive integer, milliseconds)"}; - } - } else if (key == "--backend-url") { - auto err = take_value(out.backend_url); - if (!err.empty()) return {std::nullopt, err}; - } else if (key == "--api-key") { - auto err = take_value(out.api_key); - if (!err.empty()) return {std::nullopt, err}; - } else if (key == "--api-version") { - auto err = take_value(out.api_version); - if (!err.empty()) return {std::nullopt, err}; - if (out.api_version.empty()) return {std::nullopt, "--api-version cannot be empty"}; - } else if (key == "--agent-jar") { - auto err = take_value(out.agent_jar); - if (!err.empty()) return {std::nullopt, err}; - if (out.agent_jar.empty()) return {std::nullopt, "--agent-jar cannot be empty"}; - } else if (key == "--agent-cursor") { - auto err = take_value(out.agent_cursor); - if (!err.empty()) return {std::nullopt, err}; - if (out.agent_cursor.empty()) return {std::nullopt, "--agent-cursor cannot be empty"}; - } else if (key == "--log-types") { - auto err = take_value(out.log_types); - if (!err.empty()) return {std::nullopt, err}; - if (out.log_types.empty()) return {std::nullopt, "--log-types cannot be empty"}; - } else if (!tok.empty() && tok[0] == '-') { + const SubcommandOptionResult simple = + parseMonitorSimpleOption(fb, argv, i, out); + if (simple.found) { + if (!simple.error.empty()) return {std::nullopt, simple.error}; + continue; + } + + if (!tok.empty() && tok[0] == '-') { return {std::nullopt, "unknown flag: " + key}; } else { return {std::nullopt, @@ -837,34 +209,14 @@ InfoParseResult parseInfoArgs(const std::vector& argv) { if (tok == "-h" || tok == "--help") { return {std::nullopt, "__help__"}; } - if (tok == "--json") { - out.json = true; + auto fb = splitFlag(tok); + const SubcommandOptionResult simple = + parseInfoSimpleOption(fb, argv, i, out); + if (simple.found) { + if (!simple.error.empty()) return {std::nullopt, simple.error}; continue; } - - auto fb = splitFlag(tok); - if (fb.key == "--device") { - std::string value; - if (fb.inline_value) { - value = *fb.inline_value; - } else if (i + 1 < argv.size()) { - value = argv[++i]; - } else { - return {std::nullopt, "missing value for --device"}; - } - - if (value.empty()) { - return {std::nullopt, "invalid --device value: expected a non-negative integer"}; - } - char* end = nullptr; - const long parsed = std::strtol(value.c_str(), &end, 10); - if (*end != '\0' || parsed < 0) { - return {std::nullopt, - "invalid --device value: " + value + - " (expected a non-negative integer)"}; - } - out.device_id = static_cast(parsed); - } else if (!tok.empty() && tok[0] == '-') { + if (!tok.empty() && tok[0] == '-') { return {std::nullopt, "unknown flag: " + fb.key}; } else { return {std::nullopt, diff --git a/daemon/launcher/cli_parse_internal.cpp b/daemon/launcher/cli_parse_internal.cpp new file mode 100644 index 0000000..bed24ce --- /dev/null +++ b/daemon/launcher/cli_parse_internal.cpp @@ -0,0 +1,101 @@ +#include "cli_parse_internal.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace gpufl::launcher::detail { + +FlagBreak splitFlag(const std::string& token) { + const auto equal = token.find('='); + if (equal == std::string::npos) return {token, std::nullopt}; + return {token.substr(0, equal), token.substr(equal + 1)}; +} + +std::string takeFlagValue(const FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + std::string& value) { + if (flag.inline_value) { + value = *flag.inline_value; + return {}; + } + if (index + 1 >= argv.size()) return "missing value for " + flag.key; + value = argv[++index]; + return {}; +} + +std::string trim(const std::string& value) { + const auto begin = value.find_first_not_of(" \t"); + if (begin == std::string::npos) return {}; + const auto end = value.find_last_not_of(" \t"); + return value.substr(begin, end - begin + 1); +} + +bool parseDurationMs(const std::string& value, std::int64_t& out_ms) { + if (value.empty()) return false; + char* end = nullptr; + errno = 0; + const double number = std::strtod(value.c_str(), &end); + if (end == value.c_str() || errno == ERANGE || !std::isfinite(number) || + number < 0) { + return false; + } + + const std::string unit = trim(end); + double multiplier_ms; + if (unit.empty() || unit == "s") multiplier_ms = 1000.0; + else if (unit == "ms") multiplier_ms = 1.0; + else if (unit == "m") multiplier_ms = 60.0 * 1000.0; + else if (unit == "h") multiplier_ms = 60.0 * 60.0 * 1000.0; + else return false; + + const double milliseconds = number * multiplier_ms; + if (!std::isfinite(milliseconds) || + milliseconds >= static_cast( + (std::numeric_limits::max)()) || + (number > 0 && milliseconds < 1.0)) { + return false; + } + out_ms = static_cast(milliseconds); + return true; +} + +bool parseUint64(const std::string& value, std::uint64_t& out) { + const char* begin = value.data(); + const char* end = begin + value.size(); + while (begin != end && + std::isspace(static_cast(*begin))) { + ++begin; + } + if (begin != end && *begin == '+') ++begin; + if (begin == end) return false; + + std::uint64_t parsed = 0; + const auto result = std::from_chars(begin, end, parsed); + if (result.ec != std::errc{} || result.ptr != end) { + return false; + } + out = parsed; + return true; +} + +bool parseNonNegativeInt(const std::string& value, int& out) { + std::uint64_t parsed = 0; + if (!parseUint64(value, parsed) || + parsed > static_cast((std::numeric_limits::max)())) { + return false; + } + out = static_cast(parsed); + return true; +} + +bool parsePositiveInt(const std::string& value, int& out) { + return parseNonNegativeInt(value, out) && out > 0; +} + +} // namespace gpufl::launcher::detail diff --git a/daemon/launcher/cli_parse_internal.hpp b/daemon/launcher/cli_parse_internal.hpp new file mode 100644 index 0000000..ef5472f --- /dev/null +++ b/daemon/launcher/cli_parse_internal.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include +#include + +namespace gpufl::launcher::detail { + +// The spelling of one option token, e.g. `--flag` or `--flag=value`. +struct FlagBreak { + std::string key; + std::optional inline_value; +}; + +FlagBreak splitFlag(const std::string& token); + +// Consume the value of flag from either its inline spelling or the next argv +// token. Returns an empty string on success, otherwise a user-facing error. +std::string takeFlagValue(const FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + std::string& value); + +std::string trim(const std::string& value); + +bool parseDurationMs(const std::string& value, std::int64_t& out_ms); +bool parseUint64(const std::string& value, std::uint64_t& out); +bool parseNonNegativeInt(const std::string& value, int& out); +bool parsePositiveInt(const std::string& value, int& out); + +} // namespace gpufl::launcher::detail diff --git a/daemon/launcher/cli_subcommand_options.cpp b/daemon/launcher/cli_subcommand_options.cpp new file mode 100644 index 0000000..3f44b5c --- /dev/null +++ b/daemon/launcher/cli_subcommand_options.cpp @@ -0,0 +1,179 @@ +#include "cli_subcommand_options.hpp" + +#include "cli_option_manager.hpp" + +namespace gpufl::launcher { + +namespace { + +template +std::string parseString(const detail::FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + Args& args) { + return detail::takeFlagValue(flag, argv, index, args.*Slot); +} + +template +std::string parseNonEmptyString(const detail::FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + Args& args) { + std::string& value = args.*Slot; + if (const std::string error = detail::takeFlagValue(flag, argv, index, value); + !error.empty()) { + return error; + } + return value.empty() ? flag.key + " cannot be empty" : std::string(); +} + +template +std::string setFlag(const detail::FlagBreak& flag, + const std::vector&, + std::size_t&, + Args& args) { + if (flag.inline_value) return "unknown flag: " + flag.key; + args.*Slot = true; + return {}; +} + +std::string parseUploadTimeout(const detail::FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + UploadArgs& args) { + std::string value; + if (const std::string error = detail::takeFlagValue(flag, argv, index, value); + !error.empty()) return error; + if (!detail::parseNonNegativeInt(value, args.timeout_s)) { + return "invalid --timeout value: " + value + + " (expected a non-negative integer, seconds)"; + } + return {}; +} + +std::string parseUploadRetries(const detail::FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + UploadArgs& args) { + std::string value; + if (const std::string error = detail::takeFlagValue(flag, argv, index, value); + !error.empty()) return error; + if (!detail::parseNonNegativeInt(value, args.retries)) { + return "invalid --retries value: " + value + + " (expected a non-negative integer)"; + } + return {}; +} + +std::string parseMonitorInterval(const detail::FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + MonitorArgs& args) { + std::string value; + if (const std::string error = detail::takeFlagValue(flag, argv, index, value); + !error.empty()) return error; + if (!detail::parsePositiveInt(value, args.interval_ms)) { + return "invalid --interval value: " + value + + " (expected a positive integer, milliseconds)"; + } + return {}; +} + +std::string parseInfoDevice(const detail::FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + InfoArgs& args) { + std::string value; + if (const std::string error = detail::takeFlagValue(flag, argv, index, value); + !error.empty()) return error; + int device_id = 0; + if (!detail::parseNonNegativeInt(value, device_id)) { + return "invalid --device value: " + value + + " (expected a non-negative integer)"; + } + args.device_id = device_id; + return {}; +} + +const detail::CliOptionManager& uploadOptions() { + static const detail::CliOptionManager options = [] { + detail::CliOptionManager registry; + registry + .add({"-q", "--quiet"}, &setFlag) + .add({"--all-sessions"}, &setFlag) + .add({"--force"}, &setFlag) + .add({"--backend-url"}, &parseString) + .add({"--api-key"}, &parseString) + .add({"--api-path"}, &parseString) + .add({"--agent-jar"}, &parseString) + .add({"--timeout"}, &parseUploadTimeout) + .add({"--retries"}, &parseUploadRetries); + return registry; + }(); + return options; +} + +const detail::CliOptionManager& monitorOptions() { + static const detail::CliOptionManager options = [] { + detail::CliOptionManager registry; + registry + .add({"-v", "--verbose"}, &setFlag) + .add({"-q", "--quiet"}, &setFlag) + .add({"--upload"}, &setFlag) + .add({"-n", "--name"}, &parseNonEmptyString) + .add({"-o", "--output"}, &parseNonEmptyString) + .add({"--interval"}, &parseMonitorInterval) + .add({"--backend-url"}, &parseString) + .add({"--api-key"}, &parseString) + .add({"--api-version"}, &parseNonEmptyString) + .add({"--agent-jar"}, &parseNonEmptyString) + .add({"--agent-cursor"}, &parseNonEmptyString) + .add({"--log-types"}, &parseNonEmptyString); + return registry; + }(); + return options; +} + +const detail::CliOptionManager& infoOptions() { + static const detail::CliOptionManager options = [] { + detail::CliOptionManager registry; + registry + .add({"--json"}, &setFlag) + .add({"--device"}, &parseInfoDevice); + return registry; + }(); + return options; +} + +template +SubcommandOptionResult parse(const detail::CliOptionManager& options, + const detail::FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + Args& args) { + std::string error; + const bool found = options.parse(flag, argv, index, args, error); + return {found, std::move(error)}; +} + +} // namespace + +SubcommandOptionResult parseUploadSimpleOption( + const detail::FlagBreak& flag, const std::vector& argv, + std::size_t& index, UploadArgs& args) { + return parse(uploadOptions(), flag, argv, index, args); +} + +SubcommandOptionResult parseMonitorSimpleOption( + const detail::FlagBreak& flag, const std::vector& argv, + std::size_t& index, MonitorArgs& args) { + return parse(monitorOptions(), flag, argv, index, args); +} + +SubcommandOptionResult parseInfoSimpleOption( + const detail::FlagBreak& flag, const std::vector& argv, + std::size_t& index, InfoArgs& args) { + return parse(infoOptions(), flag, argv, index, args); +} + +} // namespace gpufl::launcher diff --git a/daemon/launcher/cli_subcommand_options.hpp b/daemon/launcher/cli_subcommand_options.hpp new file mode 100644 index 0000000..dc32824 --- /dev/null +++ b/daemon/launcher/cli_subcommand_options.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include + +#include "cli_parse.hpp" +#include "cli_parse_internal.hpp" + +namespace gpufl::launcher { + +struct SubcommandOptionResult { + bool found = false; + std::string error; +}; + +SubcommandOptionResult parseUploadSimpleOption( + const detail::FlagBreak& flag, const std::vector& argv, + std::size_t& index, UploadArgs& args); +SubcommandOptionResult parseMonitorSimpleOption( + const detail::FlagBreak& flag, const std::vector& argv, + std::size_t& index, MonitorArgs& args); +SubcommandOptionResult parseInfoSimpleOption( + const detail::FlagBreak& flag, const std::vector& argv, + std::size_t& index, InfoArgs& args); + +} // namespace gpufl::launcher diff --git a/daemon/launcher/cli_trace_options.cpp b/daemon/launcher/cli_trace_options.cpp new file mode 100644 index 0000000..fea33a0 --- /dev/null +++ b/daemon/launcher/cli_trace_options.cpp @@ -0,0 +1,567 @@ +#include "cli_trace_options.hpp" + +#include +#include +#include +#include + +#include "cli_option_manager.hpp" +#include "gpufl/core/segmentation_config.hpp" + +namespace gpufl::launcher { + +namespace { + +using detail::CliOptionManager; +using detail::FlagBreak; + +constexpr int kSection(const TraceHelpSection section) { + return static_cast(section); +} + +// ── Generic handlers ────────────────────────────────────────────────────── + +template +std::string parseStringOption(const FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + TraceArgs& args) { + return detail::takeFlagValue(flag, argv, index, args.*Slot); +} + +template +std::string parseNonEmptyStringOption(const FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + TraceArgs& args) { + std::string& value = args.*Slot; + if (const std::string error = + detail::takeFlagValue(flag, argv, index, value); + !error.empty()) { + return error; + } + return value.empty() ? flag.key + " cannot be empty" : std::string{}; +} + +template +std::string setFlag(const FlagBreak& flag, + const std::vector&, + std::size_t&, + TraceArgs& args) { + // A boolean flag with `=value` is a misunderstanding, not a value: report it + // the same way an unknown flag is reported rather than silently ignoring it. + if (flag.inline_value) return "unknown flag: " + flag.key; + args.*Slot = true; + return {}; +} + +/** Duration into a slot, with the shared "expected a duration" message. */ +template +std::string parseDurationOption(const FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + TraceArgs& args) { + std::string value; + if (const std::string error = + detail::takeFlagValue(flag, argv, index, value); + !error.empty()) { + return error; + } + if (!detail::parseDurationMs(value, args.*Slot)) { + return "invalid " + flag.key + " value: " + value + + " (expected a duration like 30s, 500ms, 5m, 1h, or a bare " + "number of seconds)"; + } + return {}; +} + +/** Non-negative int into a slot (milliseconds today). */ +template +std::string parseNonNegativeIntOption(const FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + TraceArgs& args) { + std::string value; + if (const std::string error = + detail::takeFlagValue(flag, argv, index, value); + !error.empty()) { + return error; + } + if (!detail::parseNonNegativeInt(value, args.*Slot)) { + return "invalid " + flag.key + " value: " + value + + " (expected a non-negative integer, milliseconds)"; + } + return {}; +} + +/** uint64 into a slot; MinValue > 0 rejects zero as well as garbage. */ +template +std::string parseUint64Option(const FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + TraceArgs& args) { + std::string value; + if (const std::string error = + detail::takeFlagValue(flag, argv, index, value); + !error.empty()) { + return error; + } + bool ok = detail::parseUint64(value, args.*Slot); + // if constexpr: `unsigned < 0` is a tautology the compiler warns about. + if constexpr (MinValue > 0) { + ok = ok && args.*Slot >= MinValue; + } + if (!ok) { + if constexpr (MinValue > 0) { + return "invalid " + flag.key + " value: " + value + + " (expected a positive integer)"; + } else { + return "invalid " + flag.key + " value: " + value + + " (expected a non-negative integer; 0 disables it)"; + } + } + return {}; +} + +// A flag that no longer exists: consume its value so the next token is not +// mistaken for a command, then explain where it went. Two named handlers rather +// than a template over the message: taking the address of an internal-linkage +// constant as a template argument is portable in theory and fussy in practice. +std::string consumeRemovedValue(const FlagBreak& flag, + const std::vector& argv, + std::size_t& index) { + std::string ignored; + return detail::takeFlagValue(flag, argv, index, ignored); +} + +std::string rejectProfileOption(const FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + TraceArgs&) { + if (const std::string error = consumeRemovedValue(flag, argv, index); + !error.empty()) { + return error; + } + return "`gpufl trace --profile` has been removed; use --passes=Trace, " + "--passes=Deep, or `gpufl monitor` for monitoring-only telemetry"; +} + +std::string rejectEngineOption(const FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + TraceArgs&) { + if (const std::string error = consumeRemovedValue(flag, argv, index); + !error.empty()) { + return error; + } + return "`gpufl trace --engine` has been removed; use --passes=Trace, " + "--passes=Deep, or an explicit list like --passes=Trace,PmSampling"; +} + +// ── --passes ────────────────────────────────────────────────────────────── + +// Canonical engine names accepted by each --passes token. Must match the set +// gpufl::init() parses for GPUFL_PROFILING_ENGINE (see gpufl.cpp). The launcher +// only validates + forwards verbatim; init() is the single string->enum parser, +// so this is the one launcher-side copy to keep in sync with the ladder. +constexpr const char* kEngines[] = { + "Trace", "PcSampling", "SassMetrics", + "PmSampling", "RangeProfiler", "RangeProfilerKernelReplay", "Deep"}; + +bool isValidEngine(const std::string& engine) { + for (const char* known : kEngines) { + if (engine == known) return true; + } + return false; +} + +// A token may be a single engine ("Trace") or a '+'-joined composite group +// ("Trace+PcSampling") that runs those engines together in ONE process. +std::string validatePassToken(const std::string& token) { + std::vector parts; + std::size_t start = 0; + while (true) { + const std::size_t plus = token.find('+', start); + parts.push_back(detail::trim(token.substr( + start, plus == std::string::npos ? std::string::npos : plus - start))); + if (plus == std::string::npos) break; + start = plus + 1; + } + const bool composite = parts.size() > 1; + for (const std::string& part : parts) { + if (part.empty()) { + return "empty engine in --passes group '" + token + + "' (expected e.g. Trace+PcSampling)"; + } + if (!isValidEngine(part)) { + return "invalid --passes engine: " + part + + " (expected a comma-separated list of: Trace | PcSampling | " + "SassMetrics | PmSampling | RangeProfiler | " + "RangeProfilerKernelReplay | Deep; join engines with + to run " + "them in one process, e.g. Trace+PcSampling)"; + } + if (composite && part == "Deep") { + return "Deep cannot be combined in a '+' group (it already runs " + "PcSampling + SassMetrics together); give it its own pass"; + } + if (composite && part == "SassMetrics") { + return "SassMetrics cannot share a process (it deadlocks with kernel " + "tracing); give it its own pass with a comma, e.g. " + "Trace+PcSampling,SassMetrics"; + } + } + return {}; +} + +std::string parsePasses(const FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + TraceArgs& args) { + std::string value; + if (const std::string error = + detail::takeFlagValue(flag, argv, index, value); + !error.empty()) { + return error; + } + args.passes.clear(); + std::size_t start = 0; + while (true) { + const std::size_t comma = value.find(',', start); + const std::string item = detail::trim(value.substr( + start, comma == std::string::npos ? std::string::npos : comma - start)); + if (!item.empty()) { + if (const std::string error = validatePassToken(item); + !error.empty()) { + return error; + } + args.passes.push_back(item); + } + if (comma == std::string::npos) break; + start = comma + 1; + } + if (args.passes.empty()) { + return "--passes requires at least one engine"; + } + return {}; +} + +// ── Segmentation, window, deep, sampling ────────────────────────────────── + +std::string parseSegmentEvery(const FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + TraceArgs& args) { + if (const std::string error = + parseDurationOption<&TraceArgs::segment_every_ms>( + flag, argv, index, args); + !error.empty()) { + return error; + } + if (args.segment_every_ms > 0 && + args.segment_every_ms < kMinSegmentEveryMs) { + return "--segment-every must be at least 60s; shorter cadences can " + "create a session storm"; + } + return {}; +} + +std::string parseAfterWindow(const FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + TraceArgs& args) { + if (const std::string error = + detail::takeFlagValue(flag, argv, index, args.after_window); + !error.empty()) { + return error; + } + if (args.after_window == "keep") { + return "--after-window=keep is not yet implemented; the launcher stops " + "the target at window end (restart it with a script)"; + } + if (args.after_window != "stop") { + return "invalid --after-window value: " + args.after_window + + " (expected: stop)"; + } + return {}; +} + +// One handler for the three deep durations: they share the unit-less-seconds +// trap and the deep_requested side effect, and flag.key says which one fired. +std::string parseDeepDuration(const FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + TraceArgs& args) { + std::string value; + if (const std::string error = + detail::takeFlagValue(flag, argv, index, value); + !error.empty()) { + return error; + } + std::int64_t ms = 0; + if (!detail::parseDurationMs(value, ms)) { + return "invalid " + flag.key + " value: " + value + + " (expected a duration like 30s, 500ms, 5m, 1h, or a bare " + "number of seconds)"; + } + // A bare number is seconds, which collides with --deep-launches: + // `--deep-for=2000` meaning "2000 launches" silently becomes a 33-minute + // window, and the no-bound check cannot catch it because a bound *was* + // given. Reject the unit-less form once it is too large to plausibly be a + // duration someone typed on purpose - naming the alternative, since that is + // the mistake. + const bool unitless = + !value.empty() && + value.find_first_not_of("0123456789") == std::string::npos; + if (flag.key == "--deep-for" && unitless && ms >= 600'000) { + return "--deep-for=" + value + " means " + std::to_string(ms / 1000) + + " SECONDS (a bare number is seconds), which is almost certainly " + "not what you meant. Add a unit (e.g. " + value + + "s, 5m) if you really want that long a window, or use " + "--deep-launches " + value + " to bound it by kernel launches " + "instead"; + } + if (flag.key == "--deep-after") { + args.deep_after_ms = ms; + args.deep_after_set = true; + } else if (flag.key == "--deep-for") { + args.deep_for_ms = ms; + } else { + args.deep_cooldown_ms = ms; + } + args.deep_requested = true; + return {}; +} + +std::string parseDeepWhen(const FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + TraceArgs& args) { + if (const std::string error = + detail::takeFlagValue(flag, argv, index, args.deep_when); + !error.empty()) { + return error; + } + if (args.deep_when.empty()) { + return "--deep-when needs an expression, e.g. " + "--deep-when=\"custom.token_rate<1000 for 2s\""; + } + // Parsed by the client, not here: telling a misspelled built-in from a + // custom counter that has simply not registered yet needs the metric + // registry, and duplicating that check would give two places to disagree + // about what a metric name means. + args.deep_requested = true; + return {}; +} + +std::string parseDeepLaunches(const FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + TraceArgs& args) { + std::string value; + if (const std::string error = + detail::takeFlagValue(flag, argv, index, value); + !error.empty()) { + return error; + } + if (!detail::parseUint64(value, args.deep_launches) || + args.deep_launches == 0) { + return "invalid --deep-launches value: " + value + + " (expected a positive number of kernel launches)"; + } + args.deep_requested = true; + return {}; +} + +std::string parsePcSamplePeriod(const FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + TraceArgs& args) { + std::string value; + if (const std::string error = + detail::takeFlagValue(flag, argv, index, value); + !error.empty()) { + return error; + } + int period = 0; + if (!detail::parseNonNegativeInt(value, period) || period < 5 || + period > 31) { + return "invalid --pc-sample-period value: " + value + + " (expected an integer 5..31: log2 of GPU cycles per sample)"; + } + args.pc_sample_period = static_cast(period); + return {}; +} + +// ── The table ───────────────────────────────────────────────────────────── + +const CliOptionManager& traceOptions() { + static const CliOptionManager manager = [] { + CliOptionManager options; + options + // Capture + .add({"-n", "--name"}, "", + "Session name (default: basename of )", + kSection(TraceHelpSection::Capture), + &parseStringOption<&TraceArgs::name>) + .add({"-o", "--output"}, "

", + "Local NDJSON output dir (default: " + "~/.gpufl/traces/{ts}_{session_id}/)", + kSection(TraceHelpSection::Capture), + &parseStringOption<&TraceArgs::output_dir>) + .add({"--passes"}, "", + "Capture pass list: comma-separated values from Trace | " + "PcSampling | SassMetrics | PmSampling | RangeProfiler | " + "RangeProfilerKernelReplay | Deep. Each comma is a separate " + "pass (relaunch). Join engines with + to run them in ONE " + "process, e.g. Trace+PcSampling (timeline + PC stalls, one " + "run). Default: Trace. Deep runs PcSampling+SassMetrics in one " + "pass (same as the embedded Deep engine); for " + "timeline+stalls+SASS list passes explicitly, e.g. " + "Trace,PcSampling,SassMetrics. SassMetrics must be its own " + "pass (deadlocks if shared). Use gpufl monitor for " + "monitoring-only telemetry. PcSampling / PM / Range passes may " + "need NVIDIA performance-counter access.", + kSection(TraceHelpSection::Capture), &parsePasses) + + // Runtime + .add({"-q", "--quiet"}, "", + "Suppress launcher chatter (errors still print)", + kSection(TraceHelpSection::Runtime), + &setFlag<&TraceArgs::quiet>) + .add({"-v", "--verbose"}, "", "Verbose launcher logging", + kSection(TraceHelpSection::Runtime), + &setFlag<&TraceArgs::verbose>) + .add({"--upload"}, "", "Start gpufl-agent as the live uploader", + kSection(TraceHelpSection::Runtime), + &setFlag<&TraceArgs::upload>) + .add({"--backend-url"}, "", + "Backend base URL for --upload (env: GPUFL_BACKEND_URL)", + kSection(TraceHelpSection::Runtime), + &parseStringOption<&TraceArgs::backend_url>) + .add({"--api-key"}, "", + "Bearer token for --upload (env: GPUFL_API_KEY)", + kSection(TraceHelpSection::Runtime), + &parseStringOption<&TraceArgs::api_key>) + .add({"--api-version"}, "", + "Agent HTTP API version (default: v1)", + kSection(TraceHelpSection::Runtime), + &parseNonEmptyStringOption<&TraceArgs::api_version>) + .add({"--agent-jar"}, "", + "Run agent as java -jar (env: GPUFL_AGENT_JAR)", + kSection(TraceHelpSection::Runtime), + &parseNonEmptyStringOption<&TraceArgs::agent_jar>) + .add({"--agent-cursor"}, "", + "Agent cursor file (default: /cursor.json)", + kSection(TraceHelpSection::Runtime), + &parseNonEmptyStringOption<&TraceArgs::agent_cursor>) + .add({"--log-types"}, "", + "Agent channels to upload (default: device,scope,system,sass)", + kSection(TraceHelpSection::Runtime), + &parseNonEmptyStringOption<&TraceArgs::log_types>) + .add({"--agent-drain-ms"}, "", + "Maximum wait for the agent to finish uploading " + "(default: 60000)", + kSection(TraceHelpSection::Runtime), + &parseNonNegativeIntOption<&TraceArgs::agent_drain_ms>) + + // Segmentation + .add({"--segment-every"}, "", + "Split a long run on this cadence (minimum: 60s). Example: " + "--segment-every=5m. Default: off", + kSection(TraceHelpSection::Segmentation), &parseSegmentEvery) + .add({"--segment-max-rows"}, "", + "Also split after this many logical telemetry rows. The batch " + "crossing N stays in the old segment. Default: off. V1 " + "supports one Trace or PM pass; multi-pass analyses cannot be " + "segmented.", + kSection(TraceHelpSection::Segmentation), + &parseUint64Option<&TraceArgs::segment_max_rows, 0>) + + // Window + .add({"--warmup"}, "", + "Skip cold start: defer capture by this long (e.g. 30s, " + "500ms, 5m; bare number = seconds)", + kSection(TraceHelpSection::Window), + &parseDurationOption<&TraceArgs::warmup_ms>) + .add({"--window"}, "", + "Bounded window: capture this long after warmup, then STOP " + "the target. For servers that never exit. Omit to run to the " + "target's own exit.", + kSection(TraceHelpSection::Window), + &parseDurationOption<&TraceArgs::window_ms>) + .add({"--window-timeout"}, "", + "Hard cap on total target runtime (safety).", + kSection(TraceHelpSection::Window), + &parseDurationOption<&TraceArgs::window_timeout_ms>) + .add({"--after-window"}, "", + "What to do at window end. Only 'stop' today.", + kSection(TraceHelpSection::Window), &parseAfterWindow) + + // Deep window + .add({"--deep-after"}, "", + "Arm the deep engine this long into the run, then disarm. " + "Unlike --window the target keeps running. Needs a bound " + "below. Default: 0 (arm at the first kernel launch).", + kSection(TraceHelpSection::Deep), &parseDeepDuration) + .add({"--deep-for"}, "", + "How long the deep window stays armed. Note this bounds TIME, " + "which does not bound how much the engines actually collect - " + "see --deep-launches.", + kSection(TraceHelpSection::Deep), &parseDeepDuration) + .add({"--deep-when"}, "", + "Open the window when a metric crosses a threshold, e.g. " + "\"custom.token_rate<1000 for 2s\". This and --deep-after are " + "two answers to the same question, so pass one or the other.", + kSection(TraceHelpSection::Deep), &parseDeepWhen) + .add({"--deep-launches"}, "", + "Kernel-launch bound on the deep window; ends it at whichever " + "bound is hit first. PREFER THIS: wall time does not bound how " + "much an engine collects, and how far one second of it goes " + "differs by more than an order of magnitude between engines.", + kSection(TraceHelpSection::Deep), &parseDeepLaunches) + .add({"--deep-cooldown"}, "", + "Quiet time before another window may open.", + kSection(TraceHelpSection::Deep), &parseDeepDuration) + + // Sampling + .add({"--pc-sample-period"}, "", + "PC sampling period: log2 of GPU cycles per sample (5..31; " + "default 10). Lower = more frequent - for short kernels that " + "yield no PC samples by default.", + kSection(TraceHelpSection::Sampling), &parsePcSamplePeriod) + + // Removed flags: dispatched so the migration hint prints, but + // deliberately undocumented (no description => absent from help). + .add({"--profile"}, &rejectProfileOption) + .add({"--engine"}, &rejectEngineOption); + return options; + }(); + return manager; +} + +} // namespace + +TraceSimpleOptionResult parseTraceSimpleOption( + const detail::FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + TraceArgs& args) { + std::string error; + const bool found = + traceOptions().parse(flag, argv, index, args, error); + return {found, std::move(error)}; +} + +std::string formatTraceSimpleOptions(const TraceHelpSection section) { + return traceOptions().formatHelp(kSection(section)); +} + +std::vector traceOptionAliases() { + std::vector out; + for (const std::string_view alias : traceOptions().aliases()) { + out.emplace_back(alias); + } + return out; +} + +} // namespace gpufl::launcher diff --git a/daemon/launcher/cli_trace_options.hpp b/daemon/launcher/cli_trace_options.hpp new file mode 100644 index 0000000..c74ce0a --- /dev/null +++ b/daemon/launcher/cli_trace_options.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include + +#include "cli_parse.hpp" +#include "cli_parse_internal.hpp" + +namespace gpufl::launcher { + +// Help grouping for `gpufl trace` options. Nonzero because the option manager +// treats section 0 as "undocumented"; the order here is the order help prints. +enum class TraceHelpSection { + Capture = 1, + Runtime, + Segmentation, + Window, + Deep, + Sampling, +}; + +struct TraceSimpleOptionResult { + bool found = false; + std::string error; +}; + +// Parses one `gpufl trace` option from the registry. A false found value means +// the token is not an option at all, which parseTraceArgs() reports as either a +// missing `--` separator or an unknown flag. +TraceSimpleOptionResult parseTraceSimpleOption( + const detail::FlagBreak& flag, + const std::vector& argv, + std::size_t& index, + TraceArgs& args); + +// Renders the registered options of one section, in registration order. +std::string formatTraceSimpleOptions(TraceHelpSection section); + +// Every alias the registry knows, documented or not. Exposed so a test can +// assert the registry and the rendered help do not drift apart. +std::vector traceOptionAliases(); + +} // namespace gpufl::launcher diff --git a/docs/session-segmentation-client-contract.md b/docs/session-segmentation-client-contract.md deleted file mode 100644 index d2dab53..0000000 --- a/docs/session-segmentation-client-contract.md +++ /dev/null @@ -1,893 +0,0 @@ -# Long-Running Session Segmentation — Client Contract - -Status: **PROPOSED — WIRE AND LIFECYCLE CONTRACT** -Repository baseline reviewed: 2026-07-29 -Companion frontend plan: -`gpufl-product-front/docs/session-segmentation-front-plan.md` - -This document defines the client-side identity, lifecycle events, boundary -semantics, and runtime ownership required to split one long-running profiling -run into independently uploadable and queryable session segments. - -It does not authorize implementation yet. Backend ingestion and agent -transport must accept this contract before segmentation can be enabled outside -an opt-in test path. - ---- - -## 1. Outcome - -A ten-minute profiling target configured with a five-minute cadence produces: - -```text -run R -├── session S0 · segment 0 · 0–5 min -└── session S1 · segment 1 · 5–10 min -``` - -Each segment: - -- has its own `session_id` and log directory; -- begins with an ordinary `job_start`; -- ends with an ordinary `shutdown`; -- can upload, finalize, and open in the existing session detail page without - waiting for the target process to exit; -- carries enough metadata and dictionaries to be interpreted independently; -- remains linked to the logical run by `run_id` and `segment_index`. - -The GPU runtime, CUDA context, CUPTI subscription, profiling engines, metric -baselines, and deep-window evaluator are not restarted at a boundary. - ---- - -## 2. Fixed Product and Runtime Decisions - -1. The client chooses segment boundaries. The backend never slices a completed - session by timestamps. -2. Segmentation rotates session identity and output ownership only. It never - calls `gpufl::shutdown()` followed by `gpufl::init()` at a boundary. -3. `analysis_id` and `run_id` are orthogonal: - - analysis passes overlay the same workload interval; - - run segments concatenate adjacent workload intervals. -4. V1 rejects multi-pass plus segmentation before the target launches. -5. V1 supports the launcher/injection plus agent path. Embedded self-upload is - deferred. -6. Segmentation is opt-in and off by default. -7. Time and row-budget triggers may coexist; the first due trigger requests a - boundary. -8. A deep window is never split. A requested boundary waits until the window - closes. -9. No boundary inserts `cudaDeviceSynchronize` or otherwise changes target - CUDA behavior. -10. `run_end` is the only authority for the final segment index. -11. A crashed or killed process may have no `segment_end` and no `run_end`. - Backend liveness/finality timeout is mandatory. -12. Event and batch IDs remain unique across the run. They are not reset per - segment. - ---- - -## 3. Configuration Contract - -Draft launcher options: - -```text ---segment-every ---segment-max-rows -``` - -Internal environment contract: - -```text -GPUFL_RUN_ID -GPUFL_SEGMENT_EVERY_MS -GPUFL_SEGMENT_MAX_ROWS -``` - -Rules: - -- the launcher generates one UUIDv4 `GPUFL_RUN_ID` before starting the target; -- the injected runtime requires that ID when segmentation is enabled; -- an absent `GPUFL_RUN_ID` while a segment option is present is a startup - error, not a request to generate unrelated IDs in multiple modules; -- a normal non-segmented invocation does not set these variables and preserves - today's wire format; -- `GPUFL_ANALYSIS_ID` plus either segment option is rejected before target - execution; -- until `SegmentCoordinator` lands, one shared compile-time readiness gate is - enforced by both the launcher and `gpufl::init()`. Directly injecting the - internal environment variables must not bypass the launcher and emit a - misleading one-session segmented run; -- a zero or absent time/row value disables that trigger; -- both disabled means segmentation is off; -- production CLI requires a non-zero `--segment-every` cadence of at least 60 - seconds to prevent an accidental session storm; unit tests use a fake - coordinator clock instead of weakening that minimum. - -The launcher owns configuration and validation. The target runtime owns -individual `session_id` generation after the initial session. - ---- - -## 4. Identity and Clock Domains - -### 4.1 Identity - -```text -run_id UUIDv4 · constant for the target process -session_id UUIDv4 · unique for each segment -segment_index uint32 · zero-based and contiguous within the run -``` - -The invariant is: - -```text -UNIQUE(run_id, segment_index) -UNIQUE(session_id) -``` - -`segment_index` is logical order. Arrival and finalization order may differ. - -### 4.2 Time - -Two clocks have different responsibilities: - -- `std::chrono::steady_clock` decides when a duration boundary is due and - measures deferral; -- the existing `detail::GetTimestampNs()` event clock stamps wire events and - segment diagnostic bounds. - -Never compare a raw steady-clock value with a telemetry timestamp. - -Wire fields: - -```text -actual_start_ns event-clock timestamp at context cutover -actual_end_ns event-clock timestamp at context cutover -requested_boundary_ns event-clock projection of the steady deadline -boundary_delay_ns monotonic elapsed delay after the requested deadline -``` - -`boundary_delay_ns` is authoritative for “how late did this boundary occur?” -because wall time may adjust during a long run. Diagnostics use only -`actual_start_ns` and `actual_end_ns`. - ---- - -## 5. Existing Lifecycle Compatibility - -Every segment is deliberately an ordinary session to the current transport and -backend pipeline. - -### 5.1 Start - -`job_start` is the first NDJSON event in every new segment channel. It gains -two optional fields: - -```json -{ - "version": 1, - "type": "job_start", - "session_id": "S1", - "run_id": "R", - "segment_index": 1 -} -``` - -The fields are emitted together only when segmentation is enabled. An ordinary -session remains byte-compatible with the current wire. - -`segment_start` follows `job_start`. It never precedes it, because current -upload ordering and backend placeholder creation depend on `job_start`. - -### 5.2 End - -Every segment ends with the existing `shutdown` event so current finalization, -retention, status, and session-complete behavior continue to work. - -Segmentation does not add provenance fields to `shutdown`. Its wire shape and -meaning remain unchanged: it is a session terminal record, not an authority -for whether the profiled process continues. - -Segment-boundary provenance belongs only to `segment_end.end_reason`. Process -finality belongs only to `run_end`. This remains unambiguous when launcher -crash repair appends an existing synthetic `shutdown`, and it avoids storing a -stale process-continuation claim in an earlier segment. - -The final event order is: - -```text -... data -segment-local terminal metadata -segment_end -run_end # final segment only -shutdown # last lifecycle record for this session -``` - -The agent sends session-complete only after the segment sink is closed and its -`.tmp` directory is gone. - ---- - -## 6. Wire Events - -All new NDJSON events use `"version":1`, include `session_id`, and go to -`Channel::All` unless specified otherwise. - -### 6.1 `segment_start` - -```json -{ - "version": 1, - "type": "segment_start", - "session_id": "S1", - "run_id": "R", - "segment_index": 1, - "ts_ns": 300000000001, - "actual_start_ns": 300000000000, - "previous_session_id": "S0", - "requested_boundary_ns": 300000000000, - "boundary_delay_ns": 12500000, - "deferred_by": "deep_window" -} -``` - -Rules: - -- Segment 0 has `previous_session_id: null`. -- Segment 0 has `requested_boundary_ns: null`, `boundary_delay_ns: 0`, and - `deferred_by: null`. -- `deferred_by` is `deep_window` or null. -- There is no `start_reason`. The previous segment's `segment_end.end_reason` - is the single authority for why the cut occurred. - -### 6.2 `segment_end` - -```json -{ - "version": 1, - "type": "segment_end", - "session_id": "S0", - "run_id": "R", - "segment_index": 0, - "ts_ns": 300000000020, - "actual_end_ns": 300000000000, - "requested_boundary_ns": 300000000000, - "boundary_delay_ns": 12500000, - "end_reason": "time", - "deferred_by": "deep_window", - "records_outside_segment_window": 3 -} -``` - -`end_reason` is one of: - -```text -time -row_budget -process_shutdown -``` - -When both time and row budget become due, the row-budget crossing is -timestamped with the steady-clock time at which the batch that caused the -crossing commits. That timestamp, not a later coordinator observation time, -is compared with the time deadline. The earlier timestamp wins; exact equality -resolves to `time`. - -`records_outside_segment_window` counts stored records whose complete event -interval falls outside `[actual_start_ns, actual_end_ns]`. It is a data-quality -count, not a reason to discard the records. - -### 6.3 `run_end` - -```json -{ - "version": 1, - "type": "run_end", - "session_id": "S1", - "run_id": "R", - "final_segment_index": 1, - "ts_ns": 600000000000, - "ended_ns": 600000000000 -} -``` - -Rules: - -- emitted exactly once, in the final segment; -- emitted only during the runtime's terminal capture shutdown; -- emitted before that segment's ordinary `shutdown`; -- never synthesized by log salvage or the launcher crash-repair path; -- absent after `SIGKILL`, fatal process loss, or a crash before terminal - capture shutdown; -- means “the profiling capture ended cleanly,” not “the target returned exit - code zero.” Target exit provenance remains separate. - -The backend marks a run complete only when `run_end.final_segment_index` is -known and every segment `0..final_segment_index` is finalized. Delivery is -order-independent: segment directories are uploaded by independent Agent -drains, so `run_end` may become queryable before an earlier segment even -though the client closes prior segments before writing the final `run_end`. -Seeing `run_end` is therefore never sufficient by itself to mark the run -complete. - ---- - -## 7. Segment Bootstrap and Terminal Snapshots - -### 7.1 Bootstrap order - -The client creates the new session directory and acquires its -`SessionOwnershipLock` before writing any visible bootstrap artifact. Before a -new `SegmentContext` becomes visible to producers, its sink receives: - -1. `job_start`; -2. `segment_start`; -3. cached host/device/static capture configuration; -4. a full dictionary snapshot; -5. continuation-open scope rows; -6. `rule_state_checkpoint`; -7. counter-quality carry-in metadata where applicable. - -Only after these records are durable in the new active files may the -coordinator publish the new context. - -The host and static GPU inventory is cached from initial runtime setup. -Boundaries must not rerun slow NVML/NVAPI/CUDA inventory calls. - -### 7.2 Terminal order - -After old-context writers drain, the coordinator emits into the retiring -segment: - -1. every remaining buffered batch; -2. continuation-close scope rows; -3. per-segment capture capability outcome; -4. per-segment deep-window rule and counter-quality deltas; -5. `segment_end`; -6. `run_end` when terminal; -7. `shutdown`; -8. sink close/retirement and transport reconciliation; -9. release the retiring segment's `SessionOwnershipLock`. - -Compression, publish retry, and `.tmp` cleanup execute on the existing -retirement/export worker, never on a CUPTI callback or application hot path. - -The retiring lock is released only after channel writers are closed, pending -windows have been reconciled or deliberately left visible for salvage, and -the segment `.tmp` directory has been removed or intentionally retained as an -incomplete transport state. Releasing it earlier lets the Agent salvage files -that the client may still mutate. Holding it until process exit defeats early -segment availability. - ---- - -## 8. `SegmentContext` and Cutover Linearization - -Current session identity is split across `Runtime::session_id`, -`Runtime::logger`, `MonitorBatchManager::FlushSink`, sampler configuration, and -event builders. Updating these fields independently can put an old -`session_id` into a new directory or vice versa. - -One immutable context becomes the ownership unit: - -```cpp -struct SegmentContext { - std::string run_id; - std::string session_id; - uint32_t segment_index; - int64_t actual_start_ns; - std::shared_ptr logger; - std::shared_ptr dictionary; -}; -``` - -Publication uses C++17 `std::atomic_load`/`std::atomic_store` overloads for -`shared_ptr`. A `use_count()` barrier would not close early merely because its -load is relaxed: it can observe an older, larger count, not a decrement that -has not happened. It is still the wrong expression of ownership here because -producer leases and unrelated control-plane snapshots are indistinguishable; -one cached snapshot would turn a safe over-count into an unbounded retirement -wait. Each context therefore owns an explicit sealed writer-lease counter and -drain condition. Check-only call sites use `hasSegmentContext()` and never -manufacture a short-lived writer lease. - -### 8.1 Writer contract - -A writer: - -1. acquires one move-only `SegmentWriteLease`; -2. uses that same context to build the event/batch JSON; -3. writes through that context's logger; -4. releases the lease only after the complete record or batch is committed. - -Publication seals the old context before storing the new one. An acquire that -races with sealing either increments the old context before the seal and is -included in its drain, or observes the seal and retries against the new -context. The retirement worker waits on the explicit counter with a bounded -timeout. A timeout emits an ERROR and leaves that segment deliberately -incomplete; it must not close a logger underneath a live writer or block -process teardown forever. - -Batch-scoped acquisition is preferred. Per-kernel shared-pointer reference -traffic is prohibited until benchmarked. - -### 8.2 Linearization point - -The atomic publication of the new context is the boundary's storage -linearization point. - -- acquisitions after publication use the new segment; -- a writer that acquired the old context before publication may finish writing - to the old segment afterward; -- the retiring sink remains open until all such references drain; -- sink close never happens on the thread that releases the last producer - reference. - -Therefore the precise attribution rule is: - -> A record belongs to the immutable segment context acquired for its complete -> serialization/write operation. - -This replaces the less implementable phrase “the context active when the write -finished.” - -### 8.3 Boundary sequence - -```text -1. boundary request becomes due -2. if deep window active, defer -3. coordinator serializes against periodic CUPTI flush -4. flush/drain available activity without device synchronization -5. capture open-scope snapshot -6. choose actual boundary timestamp -7. create the next segment directory and acquire its ownership lock -8. prepare new context and write its bootstrap records -9. atomically publish new context and seal the old context from new boundaries -10. new producers continue immediately -11. wait for old producer references on retirement coordinator -12. finish old batches/snapshots/lifecycle records -13. close old sink and reconcile its transport/.tmp state -14. release old segment ownership lock; Agent may complete/upload it -``` - -The coordinator does not wait for gzip or backend network activity before -publishing the new context. - -For a bounded handoff interval, one process therefore owns two -`SessionOwnershipLock`s for two different session directories. This is -intentional. The in-process ownership registry must reject duplicate -acquisition of the same directory but permit distinct old/new segment paths. -The new segment lock must be acquired before its bootstrap is visible; -otherwise the Agent can classify the directory as legacy and bypass the -window-identity contract. - ---- - -## 9. Dictionary Contract - -The current `DictionaryManager` has one global dirty map. That is insufficient -for segmentation: - -- a new segment needs a full mapping even when no IDs are globally dirty; -- an old-context flush can consume a dirty entry after the new segment's - snapshot; -- a producer may intern an ID around cutover and write through either context. - -The implementation must separate: - -```text -GlobalDictionaryRegistry - stable name ↔ id mappings for the run - -SegmentDictionaryEmitter - which mappings have been emitted into one segment -``` - -Requirements: - -1. Numeric dictionary IDs remain stable for the full run. -2. Every new segment receives a full registry snapshot before context - publication. -3. Each segment independently tracks emitted IDs after bootstrap. -4. Before writing a batch, the batch's segment emitter writes every referenced - mapping not yet present in that segment. -5. Emission precedes the referencing batch in that segment's channel. -6. A flush in Segment N cannot clear emission state required by Segment N+1. -7. Source collection privacy settings apply identically to every segment. - -Adding only `flushFullDictionary()` beside the existing global dirty maps is -not sufficient; the cutover race would remain. - ---- - -## 10. Scope Continuation - -An open scope must be locally balanced in every segment it spans. - -Extend the scope batch contract: - -```text -event_type 0 = begin -event_type 1 = end -event_type 2 = continuation_open -event_type 3 = continuation_close -``` - -Add `original_start_ns` to the row schema. Continue using the same -`scope_instance_id` for the logical scope across all segments; it is already -run-global and does not reset. - -Example: - -```text -Segment 0 - begin(id=17, ts=1s, original_start_ns=1s) - continuation_close(id=17, ts=5m) - -Segment 1 - continuation_open(id=17, ts=5m, original_start_ns=1s) - continuation_close(id=17, ts=10m) - -Segment 2 - continuation_open(id=17, ts=10m, original_start_ns=1s) - end(id=17, ts=11m) -``` - -Rules: - -- continuation-close and continuation-open use the exact same boundary - timestamp; -- name ID, logical instance ID, depth, and benchmark metadata remain stable; -- the new segment's continuation-open is written before context publication; -- the old segment's continuation-close is emitted after old writers drain but - carries the boundary timestamp; -- final process shutdown closes remaining scopes with ordinary `end` rows; -- backend session views pair `begin|continuation_open` with - `end|continuation_close`; -- a future run-wide view stitches by `(run_id, scope_instance_id)`. - -The open-scope snapshot must be captured under the scope-state mutex. It must -not rely only on a thread-local name stack because scopes may exist on multiple -application threads. - ---- - -## 11. Event Attribution and Diagnostics - -No device synchronization is added. A kernel, memcpy, or scope may straddle a -boundary. - -Storage attribution follows the acquired `SegmentContext`. In particular: - -- a CUPTI record drained before cutover normally lands in the old segment; -- a running kernel whose completion record arrives after cutover normally - lands in the new segment; -- a pre-cutover writer holding the old context may commit after the boundary. - -Diagnostics use: - -```text -window = [segment_start.actual_start_ns, segment_end.actual_end_ns] -contribution = intersection(event interval, window) -``` - -Records entirely outside that interval: - -- remain available for timeline forensics; -- contribute zero to segment diagnostics; -- increment `records_outside_segment_window`. - -Timeline bounds may union full timestamps for visualization, but data bounds -must never replace the diagnostic denominator. - ---- - -## 12. Deep Window and Rule State - -### 12.1 Boundary deferral - -- A segment boundary does not close an active deep window. -- The coordinator records the original requested deadline and waits for the - window's normal close. -- `boundary_delay_ns` measures the monotonic deferral. -- The following segment records `deferred_by: "deep_window"`. -- Segmentation rejects any configuration capable of opening an unbounded deep - window; every supported window must have a duration or launch-count bound. - -### 12.2 Rule state - -`DeepWindowRules::Finish()` remains terminal and is never called at an -intermediate boundary. - -Required APIs: - -```cpp -SnapshotSegment(closing_context) -BeginNextSegment(new_context) -Checkpoint(new_context) -``` - -Behavior: - -- evaluator state, warm-up, rate baseline, cooldown, and `max_windows` budget - continue across boundaries; -- `SnapshotSegment` emits segment-local windows opened, samples, and quality - reset deltas plus explicitly named cumulative values; -- `BeginNextSegment` resets delta accumulators only; -- `Checkpoint` writes carry-in state to the new segment before publication and - contains no segment delta; -- terminal `Finish()` writes the final summary into the final segment. - -Counter data quality follows the same snapshot/delta pattern. Process-lifetime -tracked-counter context is not misrepresented as a segment-local count. - ---- - -## 13. Capture Capabilities and Run Artifacts - -Current capability emission is effectively one-shot. Segmentation requires: - -- cached requested/configured capability information at segment start; -- segment-local collected/partial/no-data outcomes at segment end; -- counters reset only for per-segment observations, not engine/runtime state. - -Large artifacts such as source content, PTX, and cubin disassembly must not be -blindly duplicated into every segment. - -The first implementation slice is limited to capture modes whose segment is -independently useful without an unresolved cross-segment artifact reference. -Before enabling a source/SASS-producing mode, implement a content-addressed -run artifact plus a per-segment artifact-reference contract. Do not silently -show an empty Source/SASS tab in later segments. - -The V1 launcher/injection whitelist is intentionally narrow: - -- `Monitor`; -- one native `Trace` pass; -- one native `PmSampling` pass; -- the adaptive plan with native `Trace` as the base and window-only - `PmSampling` as the prepared deep engine. - -V1 rejects `PcSampling`, `SassMetrics`, `RangeProfiler`, -`RangeProfilerKernelReplay`, `Deep`, composites containing any of them, and -all user-specified multi-pass lists. An opened `sass` channel alone does not -reject a run; producing a source/SASS artifact outside the whitelisted plan -does. Rejections occur before the target starts and name the unsupported -engine or pass. - ---- - -## 14. Row Budget - -The row trigger counts logical telemetry data rows, not NDJSON lines: - -- kernel rows; -- memcpy/memset rows; -- scope rows; -- synchronization rows; -- allocation rows; -- profile/PM sample rows; -- system metric rows. - -It excludes lifecycle events, dictionary mappings, capability/configuration -events, and artifact payloads. - -The count belongs to the `SegmentContext`. A batch crossing the budget is -written atomically to one segment; the boundary is requested after that batch. -Rows are never split across two segment contexts solely to hit an exact count. -Every new context starts its counter at zero. Once a context retires, -`boundary_requests_enabled` becomes false: writers already holding it may -still commit late rows and increment its final statistics, but those rows -must never request another cutover. - ---- - -## 15. Crash and Recovery Contract - -### 15.1 Process dies before a boundary - -- active transport windows and raw retired files follow the existing salvage - contract; -- `segment_end`, `run_end`, and `shutdown` may all be absent; -- launcher repair may append the existing synthetic `shutdown`; -- launcher repair must not fabricate `segment_end` or `run_end`; -- backend timeout makes the segment/run stale, crashed, or incomplete. - -### 15.2 Process dies during cutover - -Possible durable states must converge: - -- old segment complete, new segment absent; -- old segment retiring, new segment has bootstrap only; -- both segment directories exist, with one awaiting salvage. - -Requirements: - -- a published `segment_start` always has a preceding `job_start`; -- `(run_id, segment_index)` prevents duplicate logical segments; -- sidecar/tombstone sequence prevents transport-window identity reuse; -- salvage never invents a new `session_id`, `segment_index`, or `run_end`; -- an orphan bootstrap-only segment becomes incomplete after timeout rather than - completed. - -### 15.3 Transport health - -Backend timeout is mandatory even if no health record can leave the machine. - -A later Agent control-plane contract may upload precise durable loss reasons. -It must bypass the normal session-complete gate; otherwise the loss marker that -blocks completion would also block its own report. - ---- - -## 16. Agent Contract - -Each segment directory is an ordinary session directory. Existing window -identity, ACK retention, retry, and session-complete behavior remain -session-scoped. - -Required verification: - -- multiple live segment directories from one target process are discovered; -- the new segment lock is acquired before bootstrap becomes visible; -- a cutover may temporarily hold locks for distinct old and new directories; -- the retiring lock remains held through sink close and `.tmp` reconciliation; -- an older segment can complete and upload while a newer segment remains live; -- out-of-order segment finalization is allowed; -- final process exit prompts the remaining segment promptly; -- Agent never assumes only one live session per process or log root; -- loss in one segment does not allow run completion to be reported as healthy. - -No Agent grouping logic is required. Run lifecycle is a backend concern. - ---- - -## 17. Implementation Boundaries - -New client components: - -```text -SegmentCoordinator -SegmentContext -SegmentDictionaryEmitter -SegmentLifecycleModels -``` - -Existing areas requiring refactor: - -- `Runtime` - - replace mutable `session_id`/`logger` reads with current context access; - - cache bootstrap inventory. -- `MonitorBatchManager` - - remove the single cached `FlushSink`; - - flush a complete batch through one acquired context; - - preserve run-global batch and scope IDs. -- `Sampler` - - acquire the current context for each sample batch instead of caching one - session ID and logger forever. -- `Logger` - - one logger/sink per segment context; - - retirement remains asynchronous. -- `DictionaryManager` - - split run-global registry from segment-local emission state. -- lifecycle/capability/deep-rule models - - add versioned segment events and snapshot APIs. -- launcher parser - - options, environment, incompatibility validation, and run ID generation. - ---- - -## 18. Test and Mutation Plan - -### 18.1 Pure contract tests - -- exact JSON shape for all three events; -- segment fields omitted from non-segmented `job_start`; -- segmented and non-segmented `shutdown` have the same existing wire shape; -- `job_start` is first and `shutdown` last in every segment; -- `run_end` occurs once and only in the final segment; -- no `start_reason` duplicate authority; -- row-budget crossing uses batch-commit steady time, not coordinator poll time; -- exact time/row trigger ties resolve to `time`; -- the V1 engine/pass whitelist rejects unsupported modes before target start. - -### 18.2 Context concurrency - -- a writer holding old context cannot write into the new directory; -- a new writer cannot emit before new `job_start`; -- new bootstrap is not visible before its ownership lock is acquired; -- distinct old/new ownership locks overlap during handoff; -- old sink closes only after old references drain; -- a leaked writer reaches the bounded timeout, emits a diagnostic, publishes - no false `segment_end`/`run_end`, and does not hang shutdown; -- old ownership lock releases only after sink/transport retirement completes; -- a retiring context cannot request a second boundary from late rows; -- last producer release does not execute filesystem close/compression; -- batch-scoped context acquisition has measured overhead within budget. - -Mutation checks: - -- build JSON with one context and write with another; -- close sink on last-reference thread; -- reset batch IDs at segment start. - -### 18.3 Dictionary - -- Segment 1 resolves IDs created only in Segment 0; -- IDs interned immediately before, during, and after cutover resolve in every - segment that references them; -- an old flush cannot consume a new segment's required mapping; -- full snapshot precedes the first referencing batch. - -Mutation check: replace per-segment emission state with the current global dirty -map and require the race fixture to fail. - -### 18.4 Scopes - -- scope spanning one, two, and three boundaries is balanced in every segment; -- same `scope_instance_id`, name, depth, metadata, and original start persist; -- multiple threads with overlapping open scopes snapshot correctly; -- final shutdown produces ordinary ends rather than continuation closes. - -### 18.5 Deep window and counters - -- due boundary defers during a bounded deep window; -- evaluator baseline, cooldown, and max-window budget survive the boundary; -- checkpoint contains no prior segment deltas; -- segment summaries contain only their own deltas; -- terminal `Finish()` still runs once. - -### 18.6 Crash states - -Kill at: - -- before new bootstrap; -- after new `job_start` but before context publish; -- immediately after context publish; -- while old context retires; -- while gzip `.part` is being written. - -After salvage, assert no fabricated `run_end`, no duplicate segment identity, -and no session promoted without its first `job_start`. - -### 18.7 Hardware/full-stack release gate - -On L4 and RTX 3090: - -1. run a ten-minute target with five-minute segmentation; -2. verify Segment 0 becomes queryable before target exit; -3. measure boundary-to-queryable latency, initial target <= 60 seconds; -4. verify kernels, memcpy, scopes, dictionaries, capabilities, and diagnostics - in both segments; -5. verify a scope and a kernel spanning the boundary; -6. verify a conditional deep window that crosses the requested boundary; -7. verify Agent restart and backend outage between segments; -8. verify final run status after out-of-order ingest; -9. use real upload/ingest only—no synthetic database rows. - ---- - -## 19. Delivery Order - -1. Commit this contract after review. -2. Add wire structs/models and exact serialization tests without enabling - runtime segmentation. -3. Add launcher parsing, run ID generation, and invalid-combination tests. - This slice remains behind an explicit execution-boundary gate until the - coordinator lands. The launcher and injected runtime consult the same gate; - neither the CLI nor direct environment injection may silently accept - segmentation while still producing only one session. -4. Implement `SegmentContext` and refactor producers to acquire it while still - running one segment. -5. Implement global dictionary registry plus segment-local emission. -6. Add coordinator boundary state machine and asynchronous retirement. -7. Add scope continuation and segment-local rule/capability snapshots. -8. Verify Agent multi-directory behavior. -9. Implement backend lifecycle, timeout, uniqueness, quota, retention, and - paginated read APIs. -10. Implement frontend plan. -11. Run L4/3090 and full-stack early-queryability gates. - -Segmentation remains opt-in until the transport-health timeout contract and -time-to-first-queryable-segment release gate both pass. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ab23812..de0f8d6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -32,14 +32,18 @@ set(GPUFL_TEST_SOURCES core/test_segment_coordinator.cpp upload/test_upload_logs.cpp # Launcher CLI parser test - portable (no CUDA / no POSIX). - # cli_parse.cpp is compiled directly into the test binary so we don't - # need to depend on the launcher target (which is Linux-only). + # The portable CLI parser sources are compiled directly into the test + # binary so we don't need to depend on the launcher target (Linux-only). launcher/test_cli_parse.cpp launcher/test_info_command.cpp launcher/test_deep_window_env.cpp launcher/test_segmentation_env.cpp launcher/test_agent_launcher.cpp ${CMAKE_SOURCE_DIR}/daemon/launcher/cli_parse.cpp + ${CMAKE_SOURCE_DIR}/daemon/launcher/cli_help.cpp + ${CMAKE_SOURCE_DIR}/daemon/launcher/cli_parse_internal.cpp + ${CMAKE_SOURCE_DIR}/daemon/launcher/cli_subcommand_options.cpp + ${CMAKE_SOURCE_DIR}/daemon/launcher/cli_trace_options.cpp ${CMAKE_SOURCE_DIR}/daemon/launcher/info_command.cpp ${CMAKE_SOURCE_DIR}/daemon/launcher/deep_window_env.cpp ${CMAKE_SOURCE_DIR}/daemon/launcher/segmentation_env.cpp diff --git a/tests/launcher/test_cli_parse.cpp b/tests/launcher/test_cli_parse.cpp index b2bffe6..fe4539c 100644 --- a/tests/launcher/test_cli_parse.cpp +++ b/tests/launcher/test_cli_parse.cpp @@ -5,11 +5,13 @@ #include #include +#include #include #include #include #include "cli_parse.hpp" +#include "cli_trace_options.hpp" using namespace gpufl::launcher; @@ -31,6 +33,40 @@ TEST(CliParseTrace, BasicCommand) { EXPECT_FALSE(r.args->quiet); } +TEST(CliParseNumbers, RejectsOutOfRangeIntegerOptions) { + constexpr const char* kIntOverflow = "2147483648"; + constexpr const char* kUint64Overflow = "18446744073709551616"; + + auto trace_drain = parseTraceArgs( + argsFor({"--agent-drain-ms", kIntOverflow, "--", "./app"})); + EXPECT_FALSE(trace_drain.args.has_value()); + + auto trace_rows = parseTraceArgs( + argsFor({"--segment-max-rows", kUint64Overflow, "--", "./app"})); + EXPECT_FALSE(trace_rows.args.has_value()); + + auto trace_launches = parseTraceArgs( + argsFor({"--deep-launches", kUint64Overflow, "--", "./app"})); + EXPECT_FALSE(trace_launches.args.has_value()); + + auto upload = parseUploadArgs( + argsFor({"--timeout", kIntOverflow, "./logs"})); + EXPECT_FALSE(upload.args.has_value()); + + auto monitor = parseMonitorArgs(argsFor({"--interval", kIntOverflow})); + EXPECT_FALSE(monitor.args.has_value()); + + auto info = parseInfoArgs(argsFor({"--device", kIntOverflow})); + EXPECT_FALSE(info.args.has_value()); +} + +TEST(CliParseNumbers, RetainsStrtolCompatibleLeadingSpaceAndPlusSign) { + auto trace = parseTraceArgs( + argsFor({"--agent-drain-ms", " +500", "--", "./app"})); + ASSERT_TRUE(trace.args.has_value()) << trace.error; + EXPECT_EQ(trace.args->agent_drain_ms, 500); +} + // ── Long-running session segmentation ───────────────────────────────────── TEST(CliParseTrace, ParsesBothSegmentationTriggers) { auto r = parseTraceArgs(argsFor( @@ -179,6 +215,65 @@ TEST(CliParseTrace, VerboseAndQuiet) { EXPECT_TRUE(r.args->quiet); } +TEST(CliParseTrace, BooleanFlagsRejectInlineValues) { + const auto r = parseTraceArgs( + argsFor({"--verbose=true", "--", "./bin"})); + ASSERT_FALSE(r.args.has_value()); + EXPECT_EQ(r.error, "unknown flag: --verbose"); +} + +TEST(CliParseHelp, TraceSimpleOptionsComeFromTheRegistry) { + const std::string help = traceHelp(); + EXPECT_NE(help.find("-n, --name="), std::string::npos); + EXPECT_NE(help.find("--backend-url="), std::string::npos); + EXPECT_NE(help.find("GPUFL_BACKEND_URL"), std::string::npos); +} + +// The registry is the only source of trace help, so every flag a user can pass +// must appear in the rendered output. Without this, adding an option to the +// table and forgetting its help section produces a flag that works but that +// nothing documents - the exact drift the registry exists to prevent. +TEST(CliParseHelp, EveryTraceOptionIsDocumentedOrDeliberatelyRemoved) { + // Flags kept only to print a migration hint; they must NOT be advertised. + const std::set removed = {"--profile", "--engine"}; + const std::string help = traceHelp(); + + for (const std::string& alias : traceOptionAliases()) { + const bool documented = help.find(alias) != std::string::npos; + if (removed.count(alias) > 0) { + EXPECT_FALSE(documented) + << alias << " is a removed flag and must stay out of help"; + } else { + EXPECT_TRUE(documented) + << alias << " is accepted by the parser but missing from help; " + "give it a help section and description in the registry"; + } + } +} + +// Help is assembled section by section, so a section that exists in the enum but +// is never rendered would silently swallow its options. +TEST(CliParseHelp, EveryTraceHelpSectionRendersSomething) { + const TraceHelpSection sections[] = { + TraceHelpSection::Capture, TraceHelpSection::Runtime, + TraceHelpSection::Segmentation, TraceHelpSection::Window, + TraceHelpSection::Deep, TraceHelpSection::Sampling, + }; + const std::string help = traceHelp(); + for (const TraceHelpSection section : sections) { + const std::string rendered = formatTraceSimpleOptions(section); + EXPECT_FALSE(rendered.empty()) + << "help section " << static_cast(section) << " is empty"; + // And it actually reached the assembled help, not just the formatter. + const std::size_t first_newline = rendered.find('\n'); + ASSERT_NE(first_newline, std::string::npos); + EXPECT_NE(help.find(rendered.substr(0, first_newline)), + std::string::npos) + << "section " << static_cast(section) + << " renders but is not included in traceHelp()"; + } +} + TEST(CliParseTrace, ProfileFlagRejectedWithMigrationHint) { auto r = parseTraceArgs(argsFor({"--profile=light", "--", "./bin"})); EXPECT_FALSE(r.args.has_value());