diff --git a/docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md b/docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md new file mode 100644 index 000000000..47cec8c57 --- /dev/null +++ b/docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md @@ -0,0 +1,82 @@ +# Architectural decision record (ADR) 0061: Select Ninja's console pool per build edge + +## Status + +Accepted. Actions and targets may set `console: true`; manifest lowering stores +the backend-neutral request as `BuildEdge.output_visibility`, and the Ninja +adapter maps `Immediate` to `pool = console` on the generated build edge. The +option defaults to `false` and does not change `Action` identity. + +## Date + +2026-10-25. + +## Context and problem statement + +Ninja buffers a command's output until it exits unless the edge uses Ninja's +special `console` pool. A manifest currently cannot select that pool. Long +developer gates can therefore appear idle while they run, even when their +commands are waiting on a lock or making progress. + +The IR shares actions when their resolved recipes and file sets match. Making +console selection part of `Action` would make a scheduling choice affect action +identity and could prevent otherwise identical edges from sharing an action. +The pool is also an edge-level Ninja binding, so the graph edge is its natural +owner. + +## Decision drivers + +- Preserve action hashing and rule sharing when only one edge needs streamed + output. +- Keep scheduling validation common to direct generation and dyndep bundles. +- Reject combinations that Ninja cannot represent without silently dropping a + requested guarantee. +- Explain the serialization and output-forwarding limits to manifest authors. + +## Decision outcome + +- Add a default-false `console` Boolean to manifest actions and targets, and + lower it to `EdgeOutputVisibility::Immediate` or `Buffered` on the + `BuildEdge`. +- Keep `EdgeOutputVisibility` backend-neutral. Manifest lowering owns the + conversion from the YAML option; each backend owns the mapping to its + scheduling and output mechanisms. The Ninja adapter alone knows the pool name + and edge-level clearing syntax. +- Emit an edge-level `pool = console` binding only for selected user edges. + When an action rule declares `pool = console`, emit an empty edge-level + `pool =` binding on its ordinary edges so they do not inherit console + scheduling. Synthetic serial gates and dyndep sidecars do not inherit either + binding. +- Keep `Action.pool` and action hashing unchanged. A pre-existing + `Action.pool: console` is accepted as redundant; a different custom pool + conflicts with console selection and is rejected by the generator. +- Reject console selection on dependency-only aggregates. Reject console plus + a named contention class while continuing to allow console selection by + itself. +- Validate these rules before both direct Ninja generation and dyndep-bundle + generation, preserving the existing missing-action diagnostic path. + +Adding fields to the public `Target` and `BuildEdge` structs breaks downstream +Rust callers that construct either type with a struct literal. Both structs +expose public fields and neither is `#[non_exhaustive]`, so callers must add +`console` to `Target` and `output_visibility` to `BuildEdge`. The package +currently declares version `0.1.0-beta4`, and this source break is accepted for +the next pre-release API. The default only preserves manifest compatibility +through Serde. + +Ninja runs console-pool edges one at a time in one invocation. Output from +other edges is held while a console edge runs. Netsuke forwards Ninja output as +it reads it outside JSON mode, but it does not allocate a PTY; recipes see a +pipe and their own buffering can still delay visible output. + +## Consequences + +Manifest authors can opt long or interactive gates into prompt output without +changing reusable rules or action identity. They also accept reduced edge +concurrency while any console edge is running. The option cannot provide +interactive terminal semantics, and it does not bypass buffering inside a +recipe. + +The contention-class RFC must reject only the combination of console and a +contention class, not console selection in general. Generator validation also +guards against incompatible custom pools supplied through existing IR callers. diff --git a/docs/contents.md b/docs/contents.md index eb78b24ca..91c5dd80e 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -318,6 +318,9 @@ operator, user, and contributor references are easier to find. moving package verification into `make test-package-verify`. - [ADR-0060](adrs/adr-0060-readable-inline-recipe-progress.md): Bounded, localized Ninja progress labels for inline recipe edges. +- [ADR-0061](adrs/adr-0061-select-ninja-console-pool-per-build-edge.md): + Select Ninja's console pool on individual build edges while preserving action + identity and rejecting incompatible scheduling annotations. - [ADR-0068](adrs/adr-0068-detect-cycles-across-all-prerequisite-kinds.md): Include every prerequisite kind in whole-manifest cycle detection while keeping output aliases and serial scheduling semantics explicit. diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 2d6f7dbf7..615807ede 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -6502,6 +6502,40 @@ variants, and `parallel` remains the default. The ordering policy applies only to a manifest `deps` list; never infer it from the number or shape of graph edges, and do not apply it to inputs or order-only dependencies. +The manifest `console` Boolean lowers to `BuildEdge.output_visibility`, using +`EdgeOutputVisibility::Immediate` for `true` and `Buffered` for `false`. This +backend-neutral request belongs to the edge because two targets can share one +action and rule while only one target needs streamed output. `Action.pool` +remains unchanged, so console selection does not alter action identity or +action deduplication. `src/ninja_gen/display_edge.rs` emits `pool = console` on +that edge. If a shared action rule already declares `pool = console`, ordinary +edges get an empty edge-level `pool =` binding to clear that inherited +selection. Shared console-scheduling validation runs before both direct Ninja +generation and dyndep-bundle generation. That validation rejects +dependency-only edges and custom action pools that conflict with `console`; it +accepts an existing `Action.pool` value of `console`. Synthetic serial gates +and dyndep sidecars never inherit either binding. + +`ninja_gen::CONSOLE_POOL` is the single production spelling of Ninja's built-in +pool. Keep its use within the edge renderer and shared console-scheduling +validator; tests continue to assert the emitted `console` value directly. + +The emitted `pool = console` binding requires Ninja 1.5 or newer. This minimum +is separate from the conditional Ninja 1.10 requirement for staged serial +dependency bundles described below. + +`IrGenError::InvalidManifest` retains its stable English schema diagnostic when +formatted. Manifest loading localizes that diagnostic at the parsing boundary; +IR error formatting must not resolve the process-wide active locale. + +Manifest validation keeps stable diagnostic details separate from localized +display text. `src/manifest/mod.rs` maps a recognized detail to the localized +parse message. Direct IR lowering retains the stable detail in +`IrGenError::InvalidManifest`, whose formatting does not consult global locale +state. Ninja generator errors use an output-specific message when an edge is +available; the build-script localization slice does not depend on either +library-side path. + `src/ir/cycle/detector.rs::CycleDetector::visit_known_edge` traverses `inputs`, `implicit_deps`, and `order_only_deps` when detecting cycles. Order-only dependencies do not trigger a rebuild when they change, but they still have to @@ -6602,11 +6636,12 @@ or add manifest-controlled values to telemetry. The runner-internal `GraphGenerationContext` in `src/runner/graph/generation/mod.rs` groups the selected `RecipeShell`, an -optional preflighted `BashExecutable`, and injected monotonic clock solely for -this graph-generation composition path. Build and Ninja-tool operations attach -the runtime binding after preflight; query-only generation leaves it absent. It -is not a general runner context, shared state container, or reusable public -API; keep unrelated runner inputs and concerns outside it. +optional preflighted `BashExecutable`, and injected monotonic clock for the +graph-generation composition path. The build runner also uses that clock to +measure its Ninja invocation. Build and Ninja-tool operations attach the +runtime binding after preflight; query-only generation leaves it absent. It is +not a general runner context, shared state container, or reusable public API; +keep unrelated runner inputs and concerns outside it. The intended serial guarantee is path-scoped. A later dependency that is independently reachable elsewhere in the requested graph may start via that @@ -7310,6 +7345,29 @@ The production generator has its own `netsuke::ninja_gen::GeneratedNinja` output type; `test_support::ninja_semantics::GeneratedNinja` is the test-side borrowed view of generated text. +### `test_support::ninja_edge` + +`test_support::ninja_edge::edge_has_console_pool` +(`test_support/src/ninja_edge.rs`) checks edge-local pool metadata on a named +generated `build` statement. Snapshot, BDD, and documentation-example tests use +this shared helper, so their assertions agree about which edge owns an indented +`pool = console` binding. Its scope is deliberately narrow: it inspects the +generated text used by these tests and does not parse general Ninja syntax. +Permitted call sites are test-support and test code only; production code must +not depend on `test_support`. + +`test_support::ninja_edge::edge_pool_bindings` returns the named edge's exact +`pool =` bindings. It distinguishes `pool = console` for an opted-in edge, an +empty `pool =` binding that clears an inherited console pool, and no binding +for an ordinary edge. The property test in +`src/ninja_gen/property_tests/console.rs` uses it for user-edge, serial-parent +and synthetic-gate assertions. Keep this helper within test-support and test +code; it does not parse general Ninja syntax. + +The local `edge_factory` fixture in `src/ninja_gen/tests/console.rs` constructs +parallel, single-output edges for console generator tests. Keep it within that +module; other graph shapes use their own fixtures. + ### Temporary Ninja build files `runner::process::create_temp_ninja_file` writes, flushes, and synchronizes a @@ -10158,7 +10216,9 @@ success envelope; diagnostic serialization remains in generator metadata from the private `src/json_envelope.rs` module. Within process execution, `forward_stdout` is the single composition point for choosing status-aware or plain child-output draining, and its callers select -either the terminal or a JSON-mode sink. +either the terminal or a JSON-mode sink. The copy loop itself, and the +forwarding-failure vocabulary around it, are owned by +[`runner::process::streaming`](#module-runnerprocessstreaming). `ExecutionContext` is the private dispatch context shared by build and clean handlers. `run_with_ninja_program` constructs it after resolving output mode @@ -10249,6 +10309,38 @@ no fixture sentinel text. The governing decision record is which forbids unbounded or caller-controlled values in metric labels and trace fields. +### Module: `runner::console_telemetry` + +`src/runner/console_telemetry/mod.rs` instruments only the actual Ninja build +invocation in `execute_build`. It supplements the complete legacy-recipe +operation timing with subprocess duration and outcome grouped by +`console_scheduling=enabled|disabled`. Its `runner.ninja.build` span and +completion event include the aggregate `console_edge_count`, scheduling state, +`outcome=success|error`, and `error_category=none|io`. Paths, action +identities, recipe contents, and process error text never enter this boundary. +The runner's injected monotonic clock supplies the duration measurement. + +The generator stores the count of selected user edges privately in +`GeneratedNinja`; the runner reads that aggregate before consuming the bundle. +Synthetic serial gates and dyndep sidecars do not contribute to it. The count +describes configured edges in each attempted build graph, rather than edges +that Ninja actually executed or time spent in its pool. The subprocess boundary +emits `netsuke_runner_ninja_build_executions_total`, +`netsuke_runner_ninja_build_execution_duration_seconds`, and +`netsuke_runner_ninja_build_console_edges_total`. The first two use scheduling, +outcome, and error-category labels; the edge counter uses only outcome. + +Bundle-generation telemetry records the same scheduling state and aggregate +count. `netsuke_ninja_dyndep_bundle_generations_total` and +`netsuke_ninja_dyndep_bundle_generation_duration_seconds` carry scheduling, +outcome, and the generator's fixed error category, including +`console_dependency_only` and `console_pool_conflict`. +`netsuke_ninja_dyndep_bundle_console_edges_total` counts configured console +edges by generation outcome. `src/observability/recorder/console.rs` owns the +production retention policy for these six series: it admits their exact kinds +and finite label sets, rejecting unknown values, extra labels, and missing +labels. Both emission and retention tests use local recorders. + ### Module: `runner::recipe_shell::telemetry` `src/runner/recipe_shell/telemetry/mod.rs` owns bounded observability for shell @@ -10365,6 +10457,58 @@ measuring the parent test process. It compares progress parsing with `--progress never` and permits a fixed 16 MiB overhead, rejecting memory growth proportional to the payload. +### Module: `runner::process::streaming` + +`src/runner/process/streaming/` owns the only byte-for-byte copy loop between a +child's standard streams and a forwarding writer, the bounded Ninja +status-parsing adapter, and the closed forwarding-failure vocabulary in +`forward_failure.rs`. No other module may add a second copy loop. + +#### Streaming reuse boundary + +- **Ownership:** the streaming module owns chunk reads, per-chunk writes and + flushes, interrupted-read retries, draining after a failure, the + `ForwardStats` record, and the failure classification that telemetry + publishes. +- **Permitted call-sites:** exactly two. `output_forwarding::forward_stdout` + forwards stdout, choosing `forward_child_output_with_ninja_status` when + progress updates are wanted and `forward_child_output` otherwise. + `failure_attribution::forward_stderr_with_attribution` forwards stderr + through its marker-observing writer. Both pass the stream identity as + `ForwardStream`; no caller may invent a stream label. +- **Composition rules:** flush each chunk before reading the next one, so a + flush failure cannot cause already-accepted bytes to be written again. Retry + `io::ErrorKind::Interrupted` reads rather than reporting them. Treat a reader + that reports more bytes than the buffer holds as a read failure, because the + count is only meaningful as a read result. After any failure, drain the + remaining child output so the child cannot block on a full pipe. + +#### Failure classification and telemetry + +A forwarding failure is classified as one of `read`, `write`, `flush`, or +`drain`, and attributed to `stdout` or `stderr`. A drain failure after an +earlier failure is counted separately and never replaces the failure that +stopped forwarding. The counter `netsuke_ninja_forward_failures_total` carries +exactly the two labels `stream` and `failure_kind`, whose permitted values are +the closed arrays `FORWARD_STREAM_VALUES` and `FORWARD_FAILURE_KIND_VALUES`. +`ConfigMetricsRecorder` admits only those exact series, so a widened label +value is dropped rather than retained. Each failure also emits a warning event +carrying the same two fields plus the I/O error. Neither label admits output +content, paths, or reader and writer types. Adding a failure kind means adding +an enum variant and its array entry together, never passing a raw string. + +#### Result policy + +Forwarding loss never changes the reported build result. The child's exit +status remains authoritative, and a truncated output stream is reported through +the classified counter and warning event instead. This keeps a deliberately +closed parent pipe, as produced by `netsuke build | head`, from being reported +as a build failure. `finalize_streaming` therefore joins the stderr forwarding +thread for its command-list attribution and discards the forwarding statistics +the helper has already published. The policy tests in +`src/runner/process/tests/mod.rs` cover a successful child, a failing child, +and a failed wait, each with forwarding loss. + ### Module: `runner::process::ninja::program` `src/runner/process/ninja/program.rs` owns the executable-resolution boundary. diff --git a/docs/localization-glossary.md b/docs/localization-glossary.md index 19a900e2f..0a1f08170 100644 --- a/docs/localization-glossary.md +++ b/docs/localization-glossary.md @@ -80,6 +80,7 @@ Table 2: manifest and build-model terms | order-only dependency | `order-only dependency` | — | `weak dependency` | | build graph | `build graph` | `dependency graph`; `graph` | — | | build edge | `build edge` | `edge` | — | +| pool | `pool` | `Ninja pool`; `console pool`; `custom pool` | `queue`; `thread pool` | | phony target | `phony target` | `implicitly phony target` | `fake target`; `false target` | | default target | `default target` | `manifest default` | — | | build | `build` (noun and verb) | `build run` | `compilation` (generic use) | @@ -2171,8 +2172,9 @@ gerund, e.g. "A compilar…" for "Building…". Several Netsuke terms stay as English loanwords in European Portuguese technical prose: `cache` (feminine, written in italics per Priberam and the -[translatewiki.net Portuguese cheatsheet](https://translatewiki.net/wiki/Portal:Pt/Cheatsheet/pt)) -and `macro` (feminine, unchanged). Product and syntax identifiers (Netsuke, +[translatewiki.net Portuguese cheatsheet](https://translatewiki.net/wiki/Portal:Pt/Cheatsheet/pt)), +`macro` (feminine, unchanged), and Ninja's `pool` (masculine; use "pool da +consola" and "pool personalizado"). Product and syntax identifiers (Netsuke, Ninja, Fluent, Jinja, YAML, `stdout`, `stderr`, `dyndep`) are invariant and carry no diacritics or gender marking. @@ -2210,6 +2212,7 @@ Table 30: Portuguese, Portugal terminology | action | ação | AO90 spelling (not "acção"); distinct word from "alvo" and "regra" per Netsuke's three-way distinction. | | rule | regra | Matches the [GNU Make Portuguese manual](https://www.ecoop.net/coop/translated/GNUMake4.4/ch09.pt.html) ("regra"). | | dependency | dependência | Standard software term. | +| pool | pool | Retain Ninja's English scheduling term; use "pool da consola" and "pool personalizado". | | order-only dependency | dependência apenas de ordem | Compositional; no established Portuguese precedent found for this Ninja-specific concept. | | build (noun) | compilação | Use "build" as an unmarked loan only when referring to a specific numbered build artefact, not the process. | | build (verb) | compilar | "Gerar" is acceptable when the build does not compile source code, mirroring the same ambiguity in English. | @@ -2248,7 +2251,7 @@ inappropriately casual for a build tool's diagnostics. Several Netsuke terms conventionally stay as English loan words in Romanian technical writing: **build** (as a noun, e.g. "un build reușit"), **cache**, **template** (though "șablon" is also common and preferred for Netsuke's Jinja -templates), **stdlib**, **dyndep**, **phony**, and **pipeline**. +templates), **stdlib**, **dyndep**, **phony**, **pool**, and **pipeline**. **Allowlist**/**blocklist** are calqued, not borrowed, per Microsoft's inclusive-language table (see below). **Macro** and **glob** are established loans that need no gloss. Loans are written in Latin script with standard @@ -2284,6 +2287,7 @@ Table 31: Romanian terminology | target | țintă | Distinct from acțiune/regulă; standard in Romanian Makefile/build tutorials. | | action | acțiune | Netsuke's implicitly phony target; keep distinct from țintă and regulă. | | rule | regulă | The Ninja rule construct a target references. | +| pool | pool | Loan word, like "build" and "cache", whose definite article also takes a hyphen: "pool-ul consolei", "pool-ul personalizat". | | dependency | dependență (pl. dependențe) | Standard Romanian computing term. | | order-only dependency | dependență doar-de-ordine | Calque; gloss as "dependență ce `impune` doar ordinea" on first use if needed. | | build (noun) | build | Loan word; widely used untranslated in Romanian developer writing, e.g. "un build reușit". | @@ -2801,6 +2805,7 @@ Table 37: Vietnamese terminology | target | mục tiêu | Follows [Bazel Vietnamese docs](https://bazel.build/versions/7.4.0/extending/rules?hl=vi); "đích" also seen in GNU Make/manpage translations | | action | hành động | Follows [Bazel Action docs](https://bazel.build/rules/lib/builtins/Action?hl=vi); distinct from "thao tác" (operation) | | rule | quy tắc | Confirmed across Bazel, gcc, and Poedit `vi.po` translations | +| pool | pool | Loan word; compounds stay uninflected: "pool console", "pool tùy chỉnh". Avoid "nhóm" (generic group). | | dependency | phần phụ thuộc | Confirmed in Bazel, Git, and Debian apt `vi.po` translations | | order-only dependency | phần phụ thuộc chỉ theo thứ tự | Calque; Ninja's order-only concept has no established Vietnamese term | | build (noun) | bản dựng | Confirmed in [Bazel Vietnamese user manual](https://bazel.build/versions/8.1.0/docs/user-manual?hl=vi) | diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 8cd6b2064..cb8be5a03 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -277,6 +277,7 @@ erDiagram string description bool phony bool always + bool console } RECIPE { StringOrList command @@ -514,6 +515,15 @@ The cleaner model is: - `always`: When set to `true`, the target runs on every invocation regardless of timestamps or dependencies. The default value is `false`. +- `console`: When set to `true`, Netsuke assigns this action or target's build + edge to Ninja's special `console` pool so command output can be forwarded as + it is produced. The default is `false`. Dependency-only aggregates cannot use + it, and it cannot be combined with a contention class or a custom action pool + other than `console`. Console selection is edge metadata; it does not change + action identity or prevent otherwise identical actions from sharing a rule. + Ninja runs console-pool edges one at a time. See + [ADR-0061](adrs/adr-0061-select-ninja-console-pool-per-build-edge.md). + ### 2.5 Generated Targets and Actions with `foreach` #### Manifest resource budget @@ -1058,6 +1068,10 @@ pub struct Target { /// Run this target on every invocation regardless of timestamps. #[serde(default)] pub always: bool, + + /// Select Ninja's special console pool for this target's build edge. + #[serde(default)] + pub console: bool, } /// FUTURE: Environment variable operations applied to a recipe invocation. @@ -1141,6 +1155,7 @@ let ast = NetsukeManifest { description: None, phony: false, always: false, + console: false, }], defaults: vec![], }; @@ -2342,12 +2357,14 @@ distributed build system), only a new generator module (`IR -> NewBackend`) would need to be written, leaving the entire front-end parsing and validation logic untouched. -Importantly, the IR contains **no Ninja-isms**. Netsuke markers such as -`{{ ins }}` and `{{ outs }}` are resolved to plain lists of file paths, and -command strings are expanded before hashing. This deliberate absence of -Ninja-specific syntax makes the IR a stable contract that future -back-ends--distributed builders, remote executors, or otherwise--can consume -without modification. +The IR keeps recipe and command data free of Ninja-specific syntax. Netsuke +markers such as `{{ ins }}` and `{{ outs }}` are resolved to plain lists of +file paths, and command strings are expanded before hashing. +`EdgeOutputVisibility` carries an edge's request for immediate or normally +buffered output without naming a backend mechanism. Each backend maps that +request to its own scheduling and forwarding behaviour. Recipe and command data +therefore provide a stable contract that future back-ends--distributed +builders, remote executors, or otherwise--can consume without modification. Furthermore, the IR is the ideal stage at which to perform graph-level analysis and optimizations, such as detecting circular dependencies, pruning unused @@ -2422,6 +2439,12 @@ pub enum EnvBinding { Unset, } +/// Desired output visibility, translated by each backend. +pub enum EdgeOutputVisibility { + Buffered, + Immediate, +} + /// Represents a single build statement, analogous to a Ninja 'build' edge. /// It connects a set of inputs to a set of outputs via an Action. The `phony` /// and `always` flags control execution when outputs already exist or when @@ -2456,6 +2479,9 @@ pub struct BuildEdge { /// Run this edge on every invocation regardless of timestamps. pub always: bool, + + /// Backend-neutral request for immediate or buffered output. + pub output_visibility: EdgeOutputVisibility, } ``` @@ -2491,6 +2517,7 @@ classDiagram +Vec order_only_deps +bool phony +bool always + +EdgeOutputVisibility output_visibility } class EdgeId { +usize index @@ -2550,15 +2577,17 @@ This transformation involves several steps: fully resolved recipe and file set, so identical rule templates yield distinct actions when their paths differ. Create a corresponding `ir::BuildEdge` linking the target to the action identifier and transfer the - `phony` and `always` flags. `sources` are lowered into the edge's explicit - input list so recipe interpolation and Ninja `$in` see only material inputs. - `deps` are lowered into a separate `implicit_deps` list, which maps to - Ninja's implicit dependency syntax (`|`) so Ninja orders and rebuilds them - without exposing them as recipe arguments; `order_only_deps` remains - separate and maps to Ninja's `||` class. The edge enters the arena once; - every explicit output is then indexed to its stable `EdgeId`. Duplicate - outputs are rejected before this mutation, so each alias resolves to one - canonical edge without copying its output vector. + `phony` and `always` flags. Lower the manifest `console` option to + `EdgeOutputVisibility::Immediate` or `Buffered`; this edge-level request + does not change action hashing or rule sharing. `sources` are lowered into + the edge's explicit input list so recipe interpolation and Ninja `$in` see + only material inputs. `deps` are lowered into a separate `implicit_deps` + list, which maps to Ninja's implicit dependency syntax (`|`) so Ninja orders + and rebuilds them without exposing them as recipe arguments; + `order_only_deps` remains separate and maps to Ninja's `||` class. The edge + enters the arena once; every explicit output is then indexed to its stable + `EdgeId`. Duplicate outputs are rejected before this mutation, so each alias + resolves to one canonical edge without copying its output vector. FUTURE: @@ -2853,13 +2882,16 @@ default my_app The live IR structures defined in [src/ir/graph/mod.rs](../src/ir/graph/mod.rs), and re-exported through [src/ir/mod.rs](../src/ir/mod.rs), are minimal containers that mirror Ninja's -conceptual model while remaining backend-agnostic. `BuildGraph` collects -actions in a hash map, canonical edges in an insertion-ordered arena, and -output aliases in a `Utf8PathBuf` to `EdgeId` hash map. Actions hold the parsed -`Recipe` and optional execution metadata. `BuildEdge` connects inputs to -outputs using an action identifier and carries the `phony` and `always` flags -verbatim from the manifest. No Ninja-specific placeholders are stored in the IR -to keep the representation portable. +conceptual model while keeping most scheduling data backend-agnostic. +`BuildGraph` collects actions in a hash map, canonical edges in an +insertion-ordered arena, and a map from `Utf8PathBuf` to `EdgeId` for output +aliases. Actions hold the parsed `Recipe` and optional execution metadata. +`BuildEdge` connects inputs to outputs using an action identifier and carries +the `phony` and `always` flags from the manifest. Its `EdgeOutputVisibility` +field expresses the desired output behaviour without exposing a Ninja pool +name. The Ninja adapter maps `Immediate` to its console pool and leaves +`Buffered` edges in the normal pool. This edge-level choice does not affect +action identity. - Actions are deduplicated using a SHA-256 hash of a canonical JSON serialization of their recipe, inputs, and outputs. Because commands embed diff --git a/docs/rfcs/rfc-0024-named-contention-classes.md b/docs/rfcs/rfc-0024-named-contention-classes.md index 2f3e8cee5..f754e851d 100644 --- a/docs/rfcs/rfc-0024-named-contention-classes.md +++ b/docs/rfcs/rfc-0024-named-contention-classes.md @@ -81,7 +81,11 @@ only, with an explicit unsupported-field diagnostic on rules. Dependency-only aggregates cannot hold a slot because they execute no command. Reject aggregate annotations with a remedy naming the executable children; do not infer recursive inheritance across their dependency closure. An annotation -on a consumer neither constrains its prerequisites nor changes their order. +on a consumer neither constrains its prerequisites nor changes their order. The +independent `console: true` action or target option selects Ninja's special +console pool for that edge. It remains valid on its own, but a console edge +cannot also request a contention class. Reject only that combination; do not +reject console selection generally. ## 5. Scheduling and lowering @@ -103,8 +107,11 @@ Unrelated classes cannot express overlapping exclusion sets in this version; combining their names into a new class would not enforce the original limits. Ninja's special `console` pool cannot simultaneously be combined with a custom -pool on the same edge. Reject a request that requires both guarantees. Do not -silently discard either console behaviour or contention limits. +pool on the same edge. When a manifest edge opts into `console`, generation +rejects an existing custom `Action.pool` value other than `console`; an existing +`Action.pool` setting to `console` is redundant and accepted. A console edge +with a resolved contention class is rejected as well. Console selection remains +available without contention, and neither behaviour is silently discarded. Serialize resolved class definitions and references in generated plans and include scheduling metadata in the existing graph/plan identity. Reject unknown @@ -156,8 +163,9 @@ actions, but may not infer such a requirement from a command containing the word Compare unannotated graph and Ninja output against existing fixtures. Add a small legacy-command example before any larger structured-runner canary. Test -unknown classes, capacity bounds, conflicting definitions, console conflicts, -aggregates, namespace privacy, and persisted-plan compatibility. +unknown classes, capacity bounds, conflicting definitions, the combination of +console selection and a contention class, custom-pool conflicts, aggregates, +namespace privacy, and persisted-plan compatibility. Use controlled child processes and a shared test journal to measure live concurrency. Under global `-j` greater than one, assert that a capacity-one @@ -178,10 +186,12 @@ scheduling. A generalized multi-resource scheduler would duplicate Ninja and create a much larger correctness burden. Mandatory resource budgets would undermine the shallow end without necessarily controlling subprocesses. -Ratify the operator ceiling field, class-name lowering, console interaction, -and any rule-default semantics before implementing the public grammar. Reserve -multi-resource allocation and cross-host scheduling for separate evidence-led -proposals. Ordinary native compilation limits must not depend on those features. +Ratify the operator ceiling field, class-name lowering, and any rule-default +semantics before implementing the public grammar. Console selection is an +independent edge option; only its combination with a contention class is +prohibited. Reserve multi-resource allocation and cross-host scheduling for +separate evidence-led proposals. Ordinary native compilation limits must not +depend on those features. ## 10. Recommendation diff --git a/docs/roadmap-progressive-enhancement.md b/docs/roadmap-progressive-enhancement.md index 15e754e55..3aa286083 100644 --- a/docs/roadmap-progressive-enhancement.md +++ b/docs/roadmap-progressive-enhancement.md @@ -189,7 +189,9 @@ pool machinery, with no hidden overlapping-resource scheduler. - [ ] 22.1.2. Lower resolved classes into Ninja pool definitions. Requires 22.1.1. - [ ] Reuse `Action.pool`, emit stable bounded names, and preserve dependency - and dyndep semantics. Reject console/custom-pool conflicts. + and dyndep semantics. Keep `console: true` as independent edge-level + selection; reject only console plus contention and reject conflicting + custom action pools during generation. - [ ] Include complete action sequences and persisted scheduling metadata; integrate with 12.3.1 rather than reimplementing the codec. - [ ] Property-test declaration-order independence and unannotated-output @@ -203,8 +205,9 @@ the guarantee and exposes its invocation-only boundary. - [ ] 22.2.1. Add controlled-process concurrency tests. Requires 22.1.2. - [ ] Assert capacities one and two under larger global `-j`, and show an unrelated action can run while a class is occupied. - - [ ] Exercise multi-command edges, failure, cancellation, and console - rejection without relying solely on sleeps. + - [ ] Exercise multi-command edges, failure, cancellation, and rejection of + console plus contention without relying solely on sleeps. Prove console + output forwarding and console-pool exclusivity with controlled handshakes. - [ ] Demonstrate that separate Ninja invocations do not share the limit. See RFC 0024 sections 6 and 8. - [ ] 22.2.2. Publish class inspection and the native-build canary. Requires diff --git a/docs/users-guide.md b/docs/users-guide.md index a1094228c..87469337f 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -609,6 +609,9 @@ A target supports these fields: and `glob` restriction above applies here too. - `phony`: marks a logical target that does not represent a file. - `always`: forces the recipe to run whenever the target is requested. +- `console`: selects Ninja's serial console pool for this executable action or + target. It defaults to `false` and is unavailable to dependency-only + aggregates. - `description`: an optional human-readable summary of the public operation the target performs. It is discovery metadata shown by `netsuke help targets`; it never replaces a referenced rule's `description` @@ -617,6 +620,36 @@ A target supports these fields: `name`, `sources`, `deps`, and `order_only_deps` accept either one string or a list of strings. +### Stream long-running output with the console pool + +The `console` field ships in the next pre-release, `v0.1.0-beta5`. The published +`v0.1.0-beta4` release rejects it during manifest validation, so install a +beta5-or-later release, or build from a checkout of the `main` branch, before +relying on it. + +Set `console: true` on a long-running or interactive action or target, such as +`lint` or `test`, when its output should appear as it runs: + + + +```yaml +netsuke_version: "1.0.0" +targets: + - name: lint + command: cargo clippy --all-targets + console: true +``` + +Ninja's console pool is available from Ninja 1.5. It runs console-pool edges +one at a time in one invocation. While one such edge runs, Ninja buffers +progress and output from other edges, so enabling the field can reduce parallel +build throughput. + +Outside JSON mode, Netsuke forwards Ninja output as it reads it. Netsuke does +not provide a pseudo-terminal: the recipe sees a pipe, and visibility still +depends on the recipe flushing its own output. JSON mode suppresses subprocess +output so that diagnostics remain parseable. + Netsuke quotes paths inserted through `{{ ins }}` and `{{ outs }}`. Other Jinja values render as ordinary command text and are not automatically shell-quoted. Use the `shell_quote` filter to encode one value as a single shell word, and diff --git a/docs/v0-1-0-migration-guide.md b/docs/v0-1-0-migration-guide.md index 30cefc126..2dd1df4d9 100644 --- a/docs/v0-1-0-migration-guide.md +++ b/docs/v0-1-0-migration-guide.md @@ -1,7 +1,28 @@ # Migrating to v0.1.0 -This guide covers the changes released in the v0.1.0 beta series, up to and -including v0.1.0-beta4. +## Who should read this + +Read this guide when upgrading within the v0.1.0 beta series. It records +changes through beta4 and migration notes for the next pre-release, beta5. +Manifest authors, source-build users, and Rust API callers should follow the +sections that match the features they use. + +## Impact at a glance + +| Priority | Area | What to do | +| ----------------- | -------------------------- | ------------------------------------------------------------------- | +| Required | Checkout source builds | Use the pinned Rust toolchain before building from a checkout. | +| Required | Linux checkout builds | Install the pinned `mold` linker before building from a checkout. | +| Review if enabled | Manifest scheduling | Opt in only where immediate console output is useful. | +| Required | `Target` literals | Add `description` and `console` to every literal. | +| Required | `BuildEdge` literals | Add `output_visibility` to every literal. | +| Review if enabled | Rust `Action.pool` choices | Check custom pool values for conflicts with `console`. | +| Optional | Existing manifests | Omit `console` to retain ordinary scheduling; it defaults to false. | + +_Table 1: migration obligations for the beta series and console-pool option._ + +This guide covers the changes released in the v0.1.0 beta series through +v0.1.0-beta4, plus migration notes for the next pre-release. The v0.1.0-beta3 release added the injectable child environment (`CommandEnv`), the named Ninja request types, narrow process options (`NinjaProcessOptions`), @@ -24,6 +45,15 @@ Beta4 also adds the opt-in `netsuke --json help targets --edges` graph export. no changes. See the [graph export reference](users-guide.md#export-dependency-edges-and-raw-recipes). +The next pre-release (`v0.1.0-beta5`) adds the opt-in `console` field for +actions and targets. It selects Ninja's special console pool for that build +edge, which runs alone and holds back progress from other edges. Netsuke +forwards Ninja's output as it reads it outside JSON mode, but recipes receive a +pipe rather than a PTY and may buffer their own output. See the +[console-pool guidance][console]. + +[console]: users-guide.md#stream-long-running-output-with-the-console-pool + Most existing manifests remain compatible, and callers of the convenience wrappers retain their child-process behaviour. Manifests using Jinja `glob()` must use shell-inert matched paths. The cached configuration discovery API is a @@ -32,10 +62,16 @@ no action. Non-UTF-8 build-file and working-directory paths are rejected at their input boundary. Rust callers that construct `Target` with a struct literal must add the new -`description` field (set it to `None` or `Some(...)`); deserialized manifests -remain compatible. Callers constructing `NinjaBuildRequest` or -`NinjaToolRequest` must replace `cli: &cli` with `options: &options`. Callers -that read `BuildGraph::targets` must use the graph accessors instead; see +`description` field (set it to `None` or `Some(...)`) and the `console` field +(set it to `false` or `true`). Callers that construct `BuildEdge` with a struct +literal must add `output_visibility` for the same reason: both types expose +public fields and neither is `#[non_exhaustive]`. Use +`EdgeOutputVisibility::Buffered` for ordinary edges and `Immediate` when the +manifest's `console: true` behaviour is required. Deserialized manifests remain +compatible because `console` defaults to `false`. Callers constructing +`NinjaBuildRequest` or `NinjaToolRequest` must replace `cli: &cli` with +`options: &options`. Callers that read `BuildGraph::targets` must use the graph +accessors instead; see [Resolve build-graph outputs through the accessors](#resolve-build-graph-outputs-through-the-accessors). Every other addition is opt-in. @@ -163,21 +199,6 @@ loading. See the [resource budgets](users-guide.md#resource-budgets) section for the complete key, environment-variable, and flag reference, along with the failure diagnostic. -## Nothing to change for existing callers - -The convenience wrappers keep their child-process behaviour: the child inherits -the calling process's environment, and Ninja is resolved exactly as before. -Their program and build-file path parameters now use `&Utf8Path`, so callers -using `std::path::Path` must convert those values. A caller that constructs -`NinjaBuildRequest`/`NinjaToolRequest` directly must pass `options: &options` -and supply the required `stderr_mode: StderrMode` field. - -Existing callers that construct `VerboseTimingReporter::new` continue to -receive timing summaries on stderr. Callers that need to capture or redirect -those summaries can opt into `VerboseTimingReporter::with_writer`; the -[developers' guide](developers-guide.md#verbose-timing-sink) documents the -owned sink and completion-ordering contract. - ## Use UTF-8 manifest and working-directory paths The Ninja invocation chain accepts only UTF-8 paths. This includes the manifest @@ -639,6 +660,44 @@ for other byte sequences; `hash` and `digest` stay byte-oriented and accept any content. See the [users' guide](users-guide.md#configure-file-reading-limits) for the full policy and its diagnostics. +## What does not need to change + +The convenience wrappers keep their child-process behaviour: the child inherits +the calling process's environment, and Ninja is resolved exactly as before. +Their program and build-file path parameters now use `&Utf8Path`, so callers +using `std::path::Path` must convert those values. A caller that constructs +`NinjaBuildRequest`/`NinjaToolRequest` directly must pass `options: &options` +and supply the required `stderr_mode: StderrMode` field. + +Existing callers that construct `VerboseTimingReporter::new` continue to +receive timing summaries on stderr. Callers that need to capture or redirect +those summaries can opt into `VerboseTimingReporter::with_writer`; the +[developers' guide](developers-guide.md#verbose-timing-sink) documents the +owned sink and completion-ordering contract. + +## Migration checklist + +- [ ] Install the pinned Rust toolchain before building from a checkout. +- [ ] On Linux, install the pinned linker before the first source build. +- [ ] Review the migration sections for manifest or API features in use. +- [ ] Before opting into `console`, use Ninja 1.5 or later. +- [ ] Add `console: false` or `console: true` to Rust `Target` literals. +- [ ] Add `output_visibility: EdgeOutputVisibility::Buffered` or `Immediate` + to Rust `BuildEdge` literals. + +## Common errors and fixes + +- **Error:** `Dependency-only entries cannot select the console pool.` + - **Fix:** Remove `console: true` from any dependency-only target. +- **Error:** `Build edge 'build/stamp' cannot select the console pool because its + action is dependency-only.` + - **Fix:** Enable console scheduling only on an executable action or target. +- **Rust API error (`Action.pool`):** + `Build edge 'build/stamp' selects the console pool but its action requests + custom pool 'compile'.` + - **Fix:** For Rust callers, remove the conflicting `Action.pool` value or + set it to `console` when that edge should use the console pool. + ## Diagnostics Ninja subprocess spans and warn events carry two bounded fields, diff --git a/locales/ar/messages.ftl b/locales/ar/messages.ftl index 27c6ea17c..6c4ba4e52 100644 --- a/locales/ar/messages.ftl +++ b/locales/ar/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = خطأ إدخال/إخراج غير معروف. manifest.command_list_empty = يجب ألّا يكون الحقل «command» فارغًا: قدِّم سلسلة أمر أو قائمة غير فارغة. manifest.command_isolation_recipe = يتطلب الحقل «command_isolation» وصفةً تتضمن قائمة أوامر. manifest.command_isolation_scalar = يتطلب «command_isolation: isolated» قائمة أوامر. +manifest.console_dependency_only = لا يمكن للإدخالات التي تقتصر على التبعيات اختيار مجموعة وحدة التحكم. # أخطاء التمثيل الوسيط. ir.rule_not_found = تعذّر العثور على القاعدة «{ $rule }» التي يشير إليها الهدف «{ $target }». @@ -198,6 +199,8 @@ ir.invalid_command = إقحام غير صالح داخل الأمر: { $snippet # أخطاء توليد ملفات Ninja. ninja_gen.missing_action = الإجراء «{ $id }» الذي تشير إليه حافة بناء مفقود. +ninja_gen.console_dependency_only = ‏لا يمكن لحافة البناء «{ $output }» اختيار مجموعة وحدة التحكم لأن إجراءها يقتصر على التبعيات. +ninja_gen.console_pool_conflict = ‏تختار حافة البناء «{ $output }» مجموعة وحدة التحكم، لكن إجراءها يطلب مجموعة مخصصة «{ $pool }». ninja_gen.format = تعذّر تنسيق مخرجات ملف بيانات Ninja. ninja_gen.dyndep_files_required = ‏ يتطلّب هذا البناء حزمة Ninja مُنشأة؛ استخدم `netsuke build` أو `netsuke clean` أو `netsuke generate` لتجهيز ملفات dyndep. ninja_gen.reserved_output_path = ‏ المسار '{ $path }' محجوز لحالة تبعيات Netsuke التسلسلية. diff --git a/locales/cs/messages.ftl b/locales/cs/messages.ftl index dab5e7e39..fd2c3aa58 100644 --- a/locales/cs/messages.ftl +++ b/locales/cs/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = neznámá vstupně-výstupní chyba. manifest.command_list_empty = Pole „command“ nesmí být prázdné: zadejte řetězec s příkazem nebo neprázdný seznam. manifest.command_isolation_recipe = Pole „command_isolation“ vyžaduje recept se seznamem příkazů. manifest.command_isolation_scalar = „command_isolation: isolated“ vyžaduje seznam příkazů. +manifest.console_dependency_only = Položky obsahující pouze závislosti nemohou vybrat fond konzole. # Chyby mezikódu. ir.rule_not_found = Pravidlo „{ $rule }“, na které odkazuje cíl „{ $target }“, nebylo nalezeno. @@ -198,6 +199,8 @@ ir.invalid_command = Neplatné vložení v příkazu: { $snippet }. # Chyby při generování souborů Ninja. ninja_gen.missing_action = Chybí akce „{ $id }“, na kterou odkazuje hrana sestavení. +ninja_gen.console_dependency_only = Sestavovací hrana „{ $output }“ nemůže vybrat fond konzole, protože její akce slouží pouze jako závislost. +ninja_gen.console_pool_conflict = Sestavovací hrana „{ $output }“ vybírá fond konzole, ale její akce požaduje vlastní fond „{ $pool }“. ninja_gen.format = Výstup manifestu Ninja se nepodařilo naformátovat. ninja_gen.dyndep_files_required = Toto sestavení vyžaduje vygenerovaný balíček Ninja; použijte `netsuke build`, `netsuke clean` nebo `netsuke generate`, aby se soubory dyndep materializovaly. ninja_gen.reserved_output_path = Cesta '{ $path }' je vyhrazena pro stav sériových závislostí Netsuke. diff --git a/locales/cy/messages.ftl b/locales/cy/messages.ftl index 81c6c550d..c5ddd4797 100644 --- a/locales/cy/messages.ftl +++ b/locales/cy/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = gwall mewnbwn/allbwn anhysbys. manifest.command_list_empty = Rhaid i’r maes ‘command’ beidio â bod yn wag: rhowch linyn gorchymyn neu restr nad yw’n wag. manifest.command_isolation_recipe = Mae’r maes ‘command_isolation’ yn gofyn am rysáit rhestr orchmynion. manifest.command_isolation_scalar = Mae ‘command_isolation: isolated’ yn gofyn am restr orchmynion. +manifest.console_dependency_only = Ni all cofnodion sy'n cynnwys dibyniaethau yn unig ddewis pwll y consol. # Gwallau'r cynrychioliad canolradd. ir.rule_not_found = Ni chafwyd hyd i'r rheol ‘{ $rule }’ y cyfeirir ati gan y targed ‘{ $target }’. @@ -198,6 +199,8 @@ ir.invalid_command = Mewnosodiad annilys yn y gorchymyn: { $snippet }. # Gwallau cynhyrchu Ninja. ninja_gen.missing_action = Mae'r weithred ‘{ $id }’ y cyfeirir ati gan ymyl adeiladu ar goll. +ninja_gen.console_dependency_only = Ni all ymyl adeiladu ‘{ $output }’ ddewis pwll y consol am mai dibyniaethau yn unig yw ei weithred. +ninja_gen.console_pool_conflict = Mae ymyl adeiladu ‘{ $output }’ yn dewis pwll y consol, ond mae ei weithred yn gofyn am gronfa addasedig ‘{ $pool }’. ninja_gen.format = Methwyd â fformatio allbwn y maniffest Ninja. ninja_gen.dyndep_files_required = Mae'r adeilad hwn yn gofyn am fwndel Ninja a gynhyrchwyd; defnyddiwch `netsuke build`, `netsuke clean` neu `netsuke generate` er mwyn deunyddoli ffeiliau dyndep. ninja_gen.reserved_output_path = Mae'r llwybr '{ $path }' wedi'i gadw ar gyfer cyflwr dibyniaethau cyfresol Netsuke. diff --git a/locales/da/messages.ftl b/locales/da/messages.ftl index 7ba601867..7e4cb239d 100644 --- a/locales/da/messages.ftl +++ b/locales/da/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = ukendt I/O-fejl. manifest.command_list_empty = Feltet "command" må ikke være tomt: angiv en kommandostreng eller en ikke-tom liste. manifest.command_isolation_recipe = Feltet ‘command_isolation’ kræver en opskrift med en kommandoliste. manifest.command_isolation_scalar = ‘command_isolation: isolated’ kræver en kommandoliste. +manifest.console_dependency_only = Poster, der kun angiver afhængigheder, kan ikke vælge konsolpuljen. # Fejl i den interne repræsentation. ir.rule_not_found = Reglen "{ $rule }", som målet "{ $target }" henviser til, blev ikke fundet. @@ -198,6 +199,8 @@ ir.invalid_command = Ugyldig indsættelse i kommandoen: { $snippet }. # Fejl under generering af Ninja. ninja_gen.missing_action = Handlingen "{ $id }", som en byggekant henviser til, mangler. +ninja_gen.console_dependency_only = Byggekanten ‘{ $output }’ kan ikke vælge konsolpuljen, fordi handlingen kun indeholder afhængigheder. +ninja_gen.console_pool_conflict = Byggekanten ‘{ $output }’ vælger konsolpuljen, men handlingen anmoder om den brugerdefinerede pulje ‘{ $pool }’. ninja_gen.format = Ninja-manifestets output kunne ikke formateres. ninja_gen.dyndep_files_required = Dette build kræver en genereret Ninja-pakke; brug `netsuke build`, `netsuke clean` eller `netsuke generate`, så dyndep-filerne materialiseres. ninja_gen.reserved_output_path = Stien '{ $path }' er reserveret til Netsukes serielle afhængighedstilstand. diff --git a/locales/de/messages.ftl b/locales/de/messages.ftl index d6a324641..57513b196 100644 --- a/locales/de/messages.ftl +++ b/locales/de/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = unbekannter E/A-Fehler. manifest.command_list_empty = Das Feld „command“ darf nicht leer sein: Geben Sie eine Befehlszeichenkette oder eine nicht leere Liste an. manifest.command_isolation_recipe = Das Feld „command_isolation“ erfordert ein Rezept mit einer Befehlsliste. manifest.command_isolation_scalar = „command_isolation: isolated“ erfordert eine Befehlsliste. +manifest.console_dependency_only = Einträge, die nur Abhängigkeiten angeben, können den Konsolen-Pool nicht auswählen. # Fehler der Zwischendarstellung. ir.rule_not_found = Die vom Ziel „{ $target }“ referenzierte Regel „{ $rule }“ wurde nicht gefunden. @@ -198,6 +199,8 @@ ir.invalid_command = Ungültige Befehlsinterpolation: { $snippet }. # Fehler bei der Ninja-Erzeugung. ninja_gen.missing_action = Die von einer Build-Kante referenzierte Aktion „{ $id }“ fehlt. +ninja_gen.console_dependency_only = Die Build-Kante „{ $output }“ kann den Konsolen-Pool nicht auswählen, weil ihre Aktion nur Abhängigkeiten enthält. +ninja_gen.console_pool_conflict = Die Build-Kante „{ $output }“ wählt den Konsolen-Pool aus, aber ihre Aktion fordert den benutzerdefinierten Pool „{ $pool }“ an. ninja_gen.format = Die Ausgabe des Ninja-Manifests konnte nicht formatiert werden. ninja_gen.dyndep_files_required = Dieser Build erfordert ein generiertes Ninja-Bundle; verwenden Sie `netsuke build`, `netsuke clean` oder `netsuke generate`, damit die dyndep-Dateien materialisiert werden. ninja_gen.reserved_output_path = Der Pfad '{ $path }' ist für den seriellen Abhängigkeitsstatus von Netsuke reserviert. diff --git a/locales/el/messages.ftl b/locales/el/messages.ftl index 84cfbe93c..f47c2c76a 100644 --- a/locales/el/messages.ftl +++ b/locales/el/messages.ftl @@ -187,6 +187,7 @@ manifest.glob.unknown_io_error = άγνωστο σφάλμα εισόδου/εξ manifest.command_list_empty = Το πεδίο «command» δεν πρέπει να είναι κενό: δώστε μια συμβολοσειρά εντολής ή μια μη κενή λίστα. manifest.command_isolation_recipe = Το πεδίο ‘command_isolation’ απαιτεί συνταγή με λίστα εντολών. manifest.command_isolation_scalar = Το ‘command_isolation: isolated’ απαιτεί λίστα εντολών. +manifest.console_dependency_only = Οι εγγραφές που δηλώνουν μόνο εξαρτήσεις δεν μπορούν να επιλέξουν το pool της κονσόλας. # Σφάλματα της ενδιάμεσης αναπαράστασης. ir.rule_not_found = Ο κανόνας «{ $rule }» στον οποίο παραπέμπει ο στόχος «{ $target }» δεν βρέθηκε. @@ -199,6 +200,8 @@ ir.invalid_command = Μη έγκυρη παρεμβολή στην εντολή: # Σφάλματα παραγωγής αρχείων Ninja. ninja_gen.missing_action = Λείπει η ενέργεια «{ $id }» στην οποία παραπέμπει ακμή δόμησης. +ninja_gen.console_dependency_only = Η ακμή δόμησης «{ $output }» δεν μπορεί να επιλέξει το pool της κονσόλας, επειδή η ενέργειά της περιλαμβάνει μόνο εξαρτήσεις. +ninja_gen.console_pool_conflict = Η ακμή δόμησης «{ $output }» επιλέγει το pool της κονσόλας, αλλά η ενέργειά της ζητά το προσαρμοσμένο pool «{ $pool }». ninja_gen.format = Δεν ήταν δυνατή η μορφοποίηση της εξόδου του δηλωτικού Ninja. ninja_gen.dyndep_files_required = Αυτή η δόμηση απαιτεί παραγόμενο πακέτο Ninja· χρησιμοποιήστε `netsuke build`, `netsuke clean` ή `netsuke generate`, ώστε να δημιουργηθούν τα αρχεία dyndep. ninja_gen.reserved_output_path = Η διαδρομή '{ $path }' είναι δεσμευμένη για την κατάσταση σειριακών εξαρτήσεων του Netsuke. diff --git a/locales/en-GB/messages.ftl b/locales/en-GB/messages.ftl index b645b694d..560e97a3d 100644 --- a/locales/en-GB/messages.ftl +++ b/locales/en-GB/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = unknown I/O error. manifest.command_list_empty = The 'command' field must not be empty: provide a command string or a non-empty list. manifest.command_isolation_recipe = The 'command_isolation' field requires a command-list recipe. manifest.command_isolation_scalar = 'command_isolation: isolated' requires a command list. +manifest.console_dependency_only = Dependency-only entries cannot select the console pool. # IR errors. ir.rule_not_found = Rule '{ $rule }' referenced by target '{ $target }' was not found. @@ -198,6 +199,8 @@ ir.invalid_command = Invalid command interpolation: { $snippet }. # Ninja generation errors. ninja_gen.missing_action = Missing action '{ $id }' referenced by a build edge. +ninja_gen.console_dependency_only = Build edge '{ $output }' cannot select the console pool because its action is dependency-only. +ninja_gen.console_pool_conflict = Build edge '{ $output }' selects the console pool but its action requests custom pool '{ $pool }'. ninja_gen.format = Failed to format the Ninja manifest output. ninja_gen.dyndep_files_required = This build requires a generated Ninja bundle; use `netsuke build`, `netsuke clean`, or `netsuke generate` so the dyndep files are materialised. ninja_gen.reserved_output_path = The path '{ $path }' is reserved for Netsuke's serial dependency state. diff --git a/locales/en-US/messages.ftl b/locales/en-US/messages.ftl index 4d61e6e30..650f0788b 100644 --- a/locales/en-US/messages.ftl +++ b/locales/en-US/messages.ftl @@ -189,6 +189,7 @@ manifest.glob.unknown_io_error = unknown IO error. manifest.command_list_empty = The 'command' field must not be empty: provide a command string or a non-empty list. manifest.command_isolation_recipe = The 'command_isolation' field requires a command-list recipe. manifest.command_isolation_scalar = 'command_isolation: isolated' requires a command list. +manifest.console_dependency_only = Dependency-only entries cannot select the console pool. # IR errors. ir.rule_not_found = Rule '{ $rule }' referenced by target '{ $target }' was not found. @@ -201,6 +202,8 @@ ir.invalid_command = Invalid command interpolation: { $snippet }. # Ninja generation errors. ninja_gen.missing_action = Missing action '{ $id }' referenced by a build edge. +ninja_gen.console_dependency_only = Build edge '{ $output }' cannot select the console pool because its action is dependency-only. +ninja_gen.console_pool_conflict = Build edge '{ $output }' selects the console pool but its action requests custom pool '{ $pool }'. ninja_gen.format = Failed to format the Ninja manifest output. ninja_gen.dyndep_files_required = This build requires a generated Ninja bundle; use `netsuke build`, `netsuke clean`, or `netsuke generate` so the dyndep files are materialized. ninja_gen.reserved_output_path = The path '{ $path }' is reserved for Netsuke's serial dependency state. diff --git a/locales/es-419/messages.ftl b/locales/es-419/messages.ftl index a253521b6..448d9694f 100644 --- a/locales/es-419/messages.ftl +++ b/locales/es-419/messages.ftl @@ -187,6 +187,7 @@ manifest.glob.unknown_io_error = error de E/S desconocido. manifest.command_list_empty = El campo 'command' no debe estar vacío: proporcione una cadena de comando o una lista no vacía. manifest.command_isolation_recipe = El campo 'command_isolation' requiere una receta con una lista de comandos. manifest.command_isolation_scalar = 'command_isolation: isolated' requiere una lista de comandos. +manifest.console_dependency_only = Las entradas que solo declaran dependencias no pueden seleccionar el grupo de consola. # Errores de la representación intermedia. ir.rule_not_found = No se encontró la regla '{ $rule }' referenciada por el objetivo '{ $target }'. @@ -199,6 +200,8 @@ ir.invalid_command = Interpolación de comando no válida: { $snippet }. # Errores de generación de Ninja. ninja_gen.missing_action = Falta la acción '{ $id }' referenciada por una arista de compilación. +ninja_gen.console_dependency_only = La arista de compilación «{ $output }» no puede seleccionar el grupo de consola porque su acción solo declara dependencias. +ninja_gen.console_pool_conflict = La arista de compilación «{ $output }» selecciona el grupo de consola, pero su acción solicita el grupo personalizado «{ $pool }». ninja_gen.format = No se pudo dar formato a la salida del manifiesto de Ninja. ninja_gen.dyndep_files_required = Esta compilación requiere un paquete Ninja generado; use `netsuke build`, `netsuke clean` o `netsuke generate` para materializar los archivos dyndep. ninja_gen.reserved_output_path = La ruta '{ $path }' está reservada para el estado de dependencias seriales de Netsuke. diff --git a/locales/es-ES/messages.ftl b/locales/es-ES/messages.ftl index 50a5357a5..9c13d88a2 100644 --- a/locales/es-ES/messages.ftl +++ b/locales/es-ES/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = error de E/S desconocido. manifest.command_list_empty = El campo 'command' no debe estar vacío: proporcione una cadena de comando o una lista no vacía. manifest.command_isolation_recipe = El campo 'command_isolation' requiere una receta con una lista de comandos. manifest.command_isolation_scalar = 'command_isolation: isolated' requiere una lista de comandos. +manifest.console_dependency_only = Las entradas que solo declaran dependencias no pueden seleccionar el grupo de consola. # Errores de IR. ir.rule_not_found = No se encontró la regla '{ $rule }' referenciada por el objetivo '{ $target }'. @@ -198,6 +199,8 @@ ir.invalid_command = Interpolación de comando inválida: { $snippet }. # Errores de generación de Ninja. ninja_gen.missing_action = Falta la acción '{ $id }' referenciada por un borde de compilación. +ninja_gen.console_dependency_only = El borde de compilación «{ $output }» no puede seleccionar el grupo de consola porque su acción solo declara dependencias. +ninja_gen.console_pool_conflict = El borde de compilación «{ $output }» selecciona el grupo de consola, pero su acción solicita el grupo personalizado «{ $pool }». ninja_gen.format = No se pudo formatear la salida del manifiesto Ninja. ninja_gen.dyndep_files_required = Esta compilación requiere un paquete Ninja generado; use `netsuke build`, `netsuke clean` o `netsuke generate` para materializar los archivos dyndep. ninja_gen.reserved_output_path = La ruta '{ $path }' está reservada para el estado de dependencias seriales de Netsuke. diff --git a/locales/fa/messages.ftl b/locales/fa/messages.ftl index 4e7e4fd04..f70835a7c 100644 --- a/locales/fa/messages.ftl +++ b/locales/fa/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = خطای ورودی/خروجی ناشناخته manifest.command_list_empty = فیلد «command» نباید خالی باشد: یک رشتهٔ فرمان یا فهرستی ناتهی ارائه دهید. manifest.command_isolation_recipe = فیلد «command_isolation» به دستوری با فهرست فرمان‌ها نیاز دارد. manifest.command_isolation_scalar = ‏«command_isolation: isolated» به فهرست فرمان‌ها نیاز دارد. +manifest.console_dependency_only = ورودی‌هایی که فقط وابستگی‌ها را اعلام می‌کنند نمی‌توانند مخزن کنسول را انتخاب کنند. # خطاهای بازنمایی میانی. ir.rule_not_found = قاعدهٔ «{ $rule }» که هدف «{ $target }» به آن ارجاع می‌دهد یافت نشد. @@ -198,6 +199,8 @@ ir.invalid_command = درج نامعتبر در فرمان: { $snippet }. # خطاهای تولید پرونده‌های Ninja. ninja_gen.missing_action = کنش «{ $id }» که یک یال ساخت به آن ارجاع می‌دهد وجود ندارد. +ninja_gen.console_dependency_only = ‏یال ساخت «{ $output }» نمی‌تواند مخزن کنسول را انتخاب کند، زیرا کنش آن فقط شامل وابستگی‌هاست. +ninja_gen.console_pool_conflict = ‏یال ساخت «{ $output }» مخزن کنسول را انتخاب می‌کند، اما کنش آن مخزن سفارشی «{ $pool }» را درخواست می‌کند. ninja_gen.format = قالب‌بندی خروجی مانیفست Ninja ممکن نشد. ninja_gen.dyndep_files_required = ‏ این ساخت به بستهٔ تولیدشدهٔ Ninja نیاز دارد؛ از `netsuke build`، `netsuke clean` یا `netsuke generate` استفاده کنید تا فایل‌های dyndep ایجاد شوند. ninja_gen.reserved_output_path = ‏ مسیر '{ $path }' برای وضعیت وابستگی‌های ترتیبی Netsuke رزرو شده است. diff --git a/locales/fi/messages.ftl b/locales/fi/messages.ftl index 1dc4f9272..e8943131b 100644 --- a/locales/fi/messages.ftl +++ b/locales/fi/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = tuntematon siirräntävirhe. manifest.command_list_empty = Kenttä ”command” ei saa olla tyhjä: anna komentomerkkijono tai ei-tyhjä luettelo. manifest.command_isolation_recipe = Kenttä ”command_isolation” edellyttää komentoluetteloreseptiä. manifest.command_isolation_scalar = ”command_isolation: isolated” edellyttää komentoluetteloa. +manifest.console_dependency_only = Vain riippuvuuksia ilmoittavat tietueet eivät voi valita konsolipoolia. # Välimuotoesityksen virheet. ir.rule_not_found = Sääntöä ”{ $rule }”, johon kohde ”{ $target }” viittaa, ei löytynyt. @@ -198,6 +199,8 @@ ir.invalid_command = Virheellinen komennon sijoitus: { $snippet }. # Ninja-generoinnin virheet. ninja_gen.missing_action = Toiminto ”{ $id }”, johon koontikaari viittaa, puuttuu. +ninja_gen.console_dependency_only = Koontikaari ”{ $output }” ei voi valita konsolipoolia, koska sen toiminto sisältää vain riippuvuuksia. +ninja_gen.console_pool_conflict = Koontikaari ”{ $output }” valitsee konsolipoolin, mutta sen toiminto pyytää mukautettua poolia ”{ $pool }”. ninja_gen.format = Ninja-manifestin tulostetta ei voitu muotoilla. ninja_gen.dyndep_files_required = Tämä toiminto vaatii luodun Ninja-paketin; käytä komentoa `netsuke build`, `netsuke clean` tai `netsuke generate`, jotta dyndep-tiedostot luodaan. ninja_gen.reserved_output_path = Polku '{ $path }' on varattu Netsuken sarjariippuvuuksien tilan tallentamiseen. diff --git a/locales/fr/messages.ftl b/locales/fr/messages.ftl index bd7b18cf1..29479c894 100644 --- a/locales/fr/messages.ftl +++ b/locales/fr/messages.ftl @@ -187,6 +187,7 @@ manifest.glob.unknown_io_error = erreur d'E/S inconnue. manifest.command_list_empty = Le champ « command » ne doit pas être vide : indiquez une chaîne de commande ou une liste non vide. manifest.command_isolation_recipe = Le champ « command_isolation » exige une recette avec une liste de commandes. manifest.command_isolation_scalar = « command_isolation: isolated » exige une liste de commandes. +manifest.console_dependency_only = Les entrées qui ne déclarent que des dépendances ne peuvent pas sélectionner le pool console. # Erreurs de la représentation intermédiaire. ir.rule_not_found = La règle « { $rule } » référencée par la cible « { $target } » est introuvable. @@ -199,6 +200,8 @@ ir.invalid_command = Interpolation de commande non valide : { $snippet }. # Erreurs de génération Ninja. ninja_gen.missing_action = Action « { $id } » manquante alors qu'une arête de compilation la référence. +ninja_gen.console_dependency_only = L’arête de compilation « { $output } » ne peut pas sélectionner le pool console, car son action ne contient que des dépendances. +ninja_gen.console_pool_conflict = L’arête de compilation « { $output } » sélectionne le pool console, mais son action demande le pool personnalisé « { $pool } ». ninja_gen.format = Impossible de formater la sortie du manifeste Ninja. ninja_gen.dyndep_files_required = Cette opération nécessite un bundle Ninja généré ; utilisez `netsuke build`, `netsuke clean` ou `netsuke generate` pour matérialiser les fichiers dyndep. ninja_gen.reserved_output_path = Le chemin '{ $path }' est réservé à l’état des dépendances séquentielles de Netsuke. diff --git a/locales/gd/messages.ftl b/locales/gd/messages.ftl index 5937807a8..4e435d793 100644 --- a/locales/gd/messages.ftl +++ b/locales/gd/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = mearachd ion-chuir/às-chuir neo-aithnichte. manifest.command_list_empty = Chan fhaod an raon “command” a bhith falamh: thoir seachad sreang àithne no liosta nach eil falamh. manifest.command_isolation_recipe = Feumaidh an raon ‘command_isolation’ reasabaidh le liosta àitheantan. manifest.command_isolation_scalar = Feumaidh ‘command_isolation: isolated’ liosta àitheantan. +manifest.console_dependency_only = Chan urrainn do dh’inntrigidhean a tha ag ainmeachadh eisimeileachdan a-mhàin amar a’ chonsail a thaghadh. # Mearachdan an riochdachaidh mheadhanaich. ir.rule_not_found = Cha deach an riaghailt “{ $rule }” air a bheil an targaid “{ $target }” a' toirt iomradh a lorg. @@ -198,6 +199,8 @@ ir.invalid_command = Cur a-steach mì-dhligheach san àithne: { $snippet }. # Mearachdan dèanamh Ninja. ninja_gen.missing_action = Tha an gnìomh “{ $id }” air a bheil oir togail a' toirt iomradh a dhìth. +ninja_gen.console_dependency_only = Chan urrainn do dh’oir togail ‘{ $output }’ amar a’ chonsail a thaghadh oir chan eil san ghnìomh aige ach eisimeileachdan. +ninja_gen.console_pool_conflict = Tha oir togail ‘{ $output }’ a’ taghadh amar a’ chonsail, ach tha an gnìomh aige ag iarraidh an amar ghnàthaichte ‘{ $pool }’. ninja_gen.format = Cha b' urrainnear às-chur an fhoirm-liosta Ninja fhòrmatadh. ninja_gen.dyndep_files_required = Feumaidh an togail seo pasgan Ninja a chaidh a chruthachadh; cleachd `netsuke build`, `netsuke clean` no `netsuke generate` gus na faidhlichean dyndep a chruthachadh. ninja_gen.reserved_output_path = Tha an t-slighe '{ $path }' glèidhte airson staid eisimeileachdan shreathach Netsuke. diff --git a/locales/he/messages.ftl b/locales/he/messages.ftl index 9b72587d2..da9a3a682 100644 --- a/locales/he/messages.ftl +++ b/locales/he/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = שגיאת קלט/פלט לא ידועה. manifest.command_list_empty = השדה „command” אינו יכול להיות ריק: יש לספק מחרוזת פקודה או רשימה שאינה ריקה. manifest.command_isolation_recipe = השדה ‘command_isolation’ מחייב מתכון עם רשימת פקודות. manifest.command_isolation_scalar = ‏‘command_isolation: isolated’ מחייב רשימת פקודות. +manifest.console_dependency_only = רשומות שמציינות תלויות בלבד אינן יכולות לבחור במאגר המסוף. # שגיאות הייצוג הביניימי. ir.rule_not_found = הכלל „{ $rule }” שאליו מפנה היעד „{ $target }” לא נמצא. @@ -198,6 +199,8 @@ ir.invalid_command = שיבוץ לא תקין בפקודה: { $snippet }. # שגיאות ביצירת קובצי Ninja. ninja_gen.missing_action = הפעולה „{ $id }” שאליה מפנה קשת בנייה חסרה. +ninja_gen.console_dependency_only = ‏קשת הבנייה „{ $output }” אינה יכולה לבחור במאגר המסוף, כי הפעולה שלה כוללת תלויות בלבד. +ninja_gen.console_pool_conflict = ‏קשת הבנייה „{ $output }” בוחרת במאגר המסוף, אך הפעולה שלה מבקשת את המאגר המותאם אישית „{ $pool }”. ninja_gen.format = לא ניתן היה לעצב את פלט מניפסט Ninja. ninja_gen.dyndep_files_required = ‏בנייה זו דורשת חבילת Ninja שנוצרה; השתמשו ב־`netsuke build`, ב־`netsuke clean` או ב־`netsuke generate` כדי ליצור את קובצי dyndep. ninja_gen.reserved_output_path = ‏הנתיב '{ $path }' שמור למצב התלויות הסדרתיות של Netsuke. diff --git a/locales/hi/messages.ftl b/locales/hi/messages.ftl index 9032071e9..527bbe0b1 100644 --- a/locales/hi/messages.ftl +++ b/locales/hi/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = अज्ञात इनपुट/आउटप manifest.command_list_empty = “command” फ़ील्ड रिक्त नहीं होना चाहिए: कोई कमांड स्ट्रिंग या ग़ैर-रिक्त सूची दें। manifest.command_isolation_recipe = 'command_isolation' फ़ील्ड के लिए कमांड-सूची वाली रेसिपी आवश्यक है। manifest.command_isolation_scalar = 'command_isolation: isolated' के लिए कमांड सूची आवश्यक है। +manifest.console_dependency_only = केवल निर्भरताएँ बताने वाली प्रविष्टियाँ कंसोल पूल का चयन नहीं कर सकतीं। # मध्यवर्ती निरूपण की त्रुटियाँ। ir.rule_not_found = लक्ष्य “{ $target }” जिस नियम “{ $rule }” का संदर्भ देता है वह नहीं मिला। @@ -198,6 +199,8 @@ ir.invalid_command = आदेश में अमान्य प्रक् # Ninja निर्माण की त्रुटियाँ। ninja_gen.missing_action = किसी बिल्ड कोर द्वारा संदर्भित क्रिया “{ $id }” अनुपस्थित है। +ninja_gen.console_dependency_only = बिल्ड कोर ‘{ $output }’ कंसोल पूल नहीं चुन सकता, क्योंकि इसकी क्रिया केवल निर्भरताएँ बताती है। +ninja_gen.console_pool_conflict = बिल्ड कोर ‘{ $output }’ कंसोल पूल चुनता है, लेकिन इसकी क्रिया कस्टम पूल ‘{ $pool }’ का अनुरोध करती है। ninja_gen.format = Ninja मैनिफ़ेस्ट का निर्गम स्वरूपित नहीं किया जा सका। ninja_gen.dyndep_files_required = इस बिल्ड के लिए जनरेट किया गया Ninja बंडल आवश्यक है; dyndep फ़ाइलों को मूर्त रूप देने के लिए `netsuke build`, `netsuke clean` या `netsuke generate` का उपयोग करें। ninja_gen.reserved_output_path = पथ '{ $path }' Netsuke की क्रमिक निर्भरता स्थिति के लिए आरक्षित है। diff --git a/locales/hu/messages.ftl b/locales/hu/messages.ftl index 51d1999a2..d2f51371c 100644 --- a/locales/hu/messages.ftl +++ b/locales/hu/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = ismeretlen be- és kiviteli hiba. manifest.command_list_empty = A „command” mező nem lehet üres: adjon meg egy parancs-karakterláncot vagy egy nem üres listát. manifest.command_isolation_recipe = Az „command_isolation” mezőhöz parancslistás recept szükséges. manifest.command_isolation_scalar = Az „command_isolation: isolated” értékhez parancslista szükséges. +manifest.console_dependency_only = A csak függőségeket megadó bejegyzések nem választhatják ki a konzolkészletet. # A köztes ábrázolás hibái. ir.rule_not_found = A(z) „{ $target }” cél által hivatkozott „{ $rule }” szabály nem található. @@ -198,6 +199,8 @@ ir.invalid_command = Érvénytelen behelyettesítés a parancsban: { $snippet }. # A Ninja-fájlok előállításának hibái. ninja_gen.missing_action = Hiányzik a(z) „{ $id }” művelet, amelyre egy építési él hivatkozik. +ninja_gen.console_dependency_only = A(z) „{ $output }” építési él nem választhatja a konzolkészletet, mert a művelete csak függőségeket tartalmaz. +ninja_gen.console_pool_conflict = A(z) „{ $output }” építési él a konzolkészletet választja, de a művelete a(z) „{ $pool }” egyéni készletet kéri. ninja_gen.format = A Ninja-jegyzék kimenetét nem sikerült formázni. ninja_gen.dyndep_files_required = Ehhez a művelethez generált Ninja-csomag szükséges; a dyndep fájlok létrehozásához használja a `netsuke build`, `netsuke clean` vagy `netsuke generate` parancsot. ninja_gen.reserved_output_path = A(z) '{ $path }' útvonal a Netsuke soros függőségi állapota számára van fenntartva. diff --git a/locales/id/messages.ftl b/locales/id/messages.ftl index 5b001d617..f6560f51b 100644 --- a/locales/id/messages.ftl +++ b/locales/id/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = galat masukan/keluaran yang tidak dikenal. manifest.command_list_empty = Bidang "command" tidak boleh kosong: berikan string perintah atau daftar yang tidak kosong. manifest.command_isolation_recipe = Bidang ‘command_isolation’ memerlukan resep dengan daftar perintah. manifest.command_isolation_scalar = ‘command_isolation: isolated’ memerlukan daftar perintah. +manifest.console_dependency_only = Entri yang hanya menyatakan dependensi tidak dapat memilih pool konsol. # Galat representasi antara. ir.rule_not_found = Aturan "{ $rule }" yang dirujuk target "{ $target }" tidak ditemukan. @@ -198,6 +199,8 @@ ir.invalid_command = Penyisipan tidak sah pada perintah: { $snippet }. # Galat pembuatan berkas Ninja. ninja_gen.missing_action = Tindakan "{ $id }" yang dirujuk sebuah sisi build tidak ada. +ninja_gen.console_dependency_only = Sisi build ‘{ $output }’ tidak dapat memilih pool konsol karena tindakannya hanya berisi dependensi. +ninja_gen.console_pool_conflict = Sisi build ‘{ $output }’ memilih pool konsol, tetapi tindakannya meminta pool khusus ‘{ $pool }’. ninja_gen.format = Keluaran manifes Ninja tidak dapat diformat. ninja_gen.dyndep_files_required = Build ini memerlukan bundel Ninja yang dibuat; gunakan `netsuke build`, `netsuke clean`, atau `netsuke generate` agar berkas dyndep diwujudkan. ninja_gen.reserved_output_path = Jalur '{ $path }' dicadangkan untuk status dependensi serial Netsuke. diff --git a/locales/it/messages.ftl b/locales/it/messages.ftl index e8dc0e0c7..4c7e382ad 100644 --- a/locales/it/messages.ftl +++ b/locales/it/messages.ftl @@ -187,6 +187,7 @@ manifest.glob.unknown_io_error = errore di I/O sconosciuto. manifest.command_list_empty = Il campo «command» non deve essere vuoto: fornire una stringa di comando o un elenco non vuoto. manifest.command_isolation_recipe = Il campo «command_isolation» richiede una ricetta con un elenco di comandi. manifest.command_isolation_scalar = «command_isolation: isolated» richiede un elenco di comandi. +manifest.console_dependency_only = Le voci che dichiarano solo dipendenze non possono selezionare il pool console. # Errori della rappresentazione intermedia. ir.rule_not_found = La regola «{ $rule }» referenziata dal target «{ $target }» non è stata trovata. @@ -199,6 +200,8 @@ ir.invalid_command = Interpolazione del comando non valida: { $snippet }. # Errori di generazione Ninja. ninja_gen.missing_action = Manca l'azione «{ $id }» referenziata da un arco di build. +ninja_gen.console_dependency_only = L’arco di build ‘{ $output }’ non può selezionare il pool console perché la sua azione dichiara solo dipendenze. +ninja_gen.console_pool_conflict = L’arco di build ‘{ $output }’ seleziona il pool console, ma la sua azione richiede il pool personalizzato ‘{ $pool }’. ninja_gen.format = Impossibile formattare l'output del manifest Ninja. ninja_gen.dyndep_files_required = Questa operazione richiede un bundle Ninja generato; usare `netsuke build`, `netsuke clean` o `netsuke generate` per materializzare i file dyndep. ninja_gen.reserved_output_path = Il percorso '{ $path }' è riservato allo stato delle dipendenze seriali di Netsuke. diff --git a/locales/ja/messages.ftl b/locales/ja/messages.ftl index 3510404f7..dcc1167a4 100644 --- a/locales/ja/messages.ftl +++ b/locales/ja/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = 不明な入出力エラー。 manifest.command_list_empty = 「command」フィールドは空にできません: コマンド文字列または空でないリストを指定してください。 manifest.command_isolation_recipe = 「command_isolation」フィールドにはコマンドリストのレシピが必要です。 manifest.command_isolation_scalar = 「command_isolation: isolated」にはコマンドリストが必要です。 +manifest.console_dependency_only = 依存関係のみを宣言する項目は、コンソールプールを選択できません。 # 中間表現のエラー。 ir.rule_not_found = ターゲット「{ $target }」が参照する規則「{ $rule }」が見つかりません。 @@ -198,6 +199,8 @@ ir.invalid_command = コマンドの補間が無効です: { $snippet }。 # Ninja 生成のエラー。 ninja_gen.missing_action = ビルド辺が参照するアクション「{ $id }」がありません。 +ninja_gen.console_dependency_only = ビルド辺「{ $output }」はコンソールプールを選択できません。アクションが依存関係のみだからです。 +ninja_gen.console_pool_conflict = ビルド辺「{ $output }」はコンソールプールを選択していますが、アクションはカスタムプール「{ $pool }」を要求しています。 ninja_gen.format = Ninja マニフェストの出力を整形できませんでした。 ninja_gen.dyndep_files_required = この操作には生成済み Ninja バンドルが必要です。dyndep ファイルを具体化するには `netsuke build`、`netsuke clean`、または `netsuke generate` を使用してください。 ninja_gen.reserved_output_path = パス '{ $path }' は Netsuke の直列依存関係の状態用に予約されています。 diff --git a/locales/ko/messages.ftl b/locales/ko/messages.ftl index 6938f3357..bc10f36ba 100644 --- a/locales/ko/messages.ftl +++ b/locales/ko/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = 알 수 없는 입출력 오류. manifest.command_list_empty = 'command' 필드는 비어 있을 수 없습니다: 명령 문자열 또는 비어 있지 않은 목록을 지정하십시오. manifest.command_isolation_recipe = ‘command_isolation’ 필드에는 명령 목록 레시피가 필요합니다. manifest.command_isolation_scalar = ‘command_isolation: isolated’에는 명령 목록이 필요합니다. +manifest.console_dependency_only = 종속성만 지정하는 항목은 콘솔 풀을 선택할 수 없습니다. # 중간 표현 오류. ir.rule_not_found = 대상 '{ $target }'이(가) 참조하는 규칙 '{ $rule }'을(를) 찾을 수 없습니다. @@ -198,6 +199,8 @@ ir.invalid_command = 명령의 보간이 잘못되었습니다: { $snippet }. # Ninja 생성 오류. ninja_gen.missing_action = 빌드 간선이 참조하는 동작 '{ $id }'이(가) 없습니다. +ninja_gen.console_dependency_only = 빌드 간선 ‘{ $output }’은(는) 콘솔 풀을 선택할 수 없습니다. 작업이 종속성만으로 이루어져 있기 때문입니다. +ninja_gen.console_pool_conflict = 빌드 간선 ‘{ $output }’은(는) 콘솔 풀을 선택하지만 작업은 사용자 지정 풀 ‘{ $pool }’을(를) 요청합니다. ninja_gen.format = Ninja 매니페스트 출력의 서식을 지정하지 못했습니다. ninja_gen.dyndep_files_required = 이 작업에는 생성된 Ninja 번들이 필요합니다. dyndep 파일을 생성하려면 `netsuke build`, `netsuke clean` 또는 `netsuke generate`를 사용하세요. ninja_gen.reserved_output_path = 경로 '{ $path }'은(는) Netsuke의 직렬 종속성 상태용으로 예약되어 있습니다. diff --git a/locales/nb/messages.ftl b/locales/nb/messages.ftl index 0fc1212d8..4fd8d196a 100644 --- a/locales/nb/messages.ftl +++ b/locales/nb/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = ukjent I/U-feil. manifest.command_list_empty = Feltet «command» kan ikke være tomt: oppgi en kommandostreng eller en ikke-tom liste. manifest.command_isolation_recipe = Feltet ‘command_isolation’ krever en oppskrift med en kommandoliste. manifest.command_isolation_scalar = ‘command_isolation: isolated’ krever en kommandoliste. +manifest.console_dependency_only = Oppføringer som bare angir avhengigheter kan ikke velge konsollpuljen. # Feil i den interne representasjonen. ir.rule_not_found = Regelen «{ $rule }» som målet «{ $target }» viser til, ble ikke funnet. @@ -198,6 +199,8 @@ ir.invalid_command = Ugyldig interpolasjon i kommandoen: { $snippet }. # Feil ved generering av Ninja. ninja_gen.missing_action = Handlingen «{ $id }» som en byggekant viser til, mangler. +ninja_gen.console_dependency_only = Byggekanten «{ $output }» kan ikke velge konsollpuljen fordi handlingen bare inneholder avhengigheter. +ninja_gen.console_pool_conflict = Byggekanten «{ $output }» velger konsollpuljen, men handlingen ber om den egendefinerte puljen «{ $pool }». ninja_gen.format = Utdataene fra Ninja-manifestet kunne ikke formateres. ninja_gen.dyndep_files_required = Denne operasjonen krever en generert Ninja-pakke. Bruk `netsuke build`, `netsuke clean` eller `netsuke generate` for å materialisere dyndep-filene. ninja_gen.reserved_output_path = Stien '{ $path }' er reservert for Netsukes serielle avhengighetstilstand. diff --git a/locales/nl/messages.ftl b/locales/nl/messages.ftl index 86da4412c..44be742f5 100644 --- a/locales/nl/messages.ftl +++ b/locales/nl/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = onbekende I/O-fout. manifest.command_list_empty = Het veld ‘command’ mag niet leeg zijn: geef een opdrachtreeks of een niet-lege lijst op. manifest.command_isolation_recipe = Het veld ‘command_isolation’ vereist een recept met een opdrachtenlijst. manifest.command_isolation_scalar = ‘command_isolation: isolated’ vereist een opdrachtenlijst. +manifest.console_dependency_only = Items die alleen afhankelijkheden aangeven, kunnen de consolepool niet selecteren. # Fouten in de tussenrepresentatie. ir.rule_not_found = De regel ‘{ $rule }’ waarnaar doel ‘{ $target }’ verwijst, is niet gevonden. @@ -198,6 +199,8 @@ ir.invalid_command = Ongeldige interpolatie in de opdracht: { $snippet }. # Fouten bij het genereren van Ninja. ninja_gen.missing_action = De actie ‘{ $id }’ waarnaar een bouwtak verwijst, ontbreekt. +ninja_gen.console_dependency_only = De bouwtak ‘{ $output }’ kan de consolepool niet selecteren, omdat de actie alleen afhankelijkheden bevat. +ninja_gen.console_pool_conflict = De bouwtak ‘{ $output }’ selecteert de consolepool, maar de actie vraagt om de aangepaste pool ‘{ $pool }’. ninja_gen.format = De uitvoer van het Ninja-manifest kon niet worden opgemaakt. ninja_gen.dyndep_files_required = Deze bewerking vereist een gegenereerde Ninja-bundel; gebruik `netsuke build`, `netsuke clean` of `netsuke generate` om de dyndep-bestanden te materialiseren. ninja_gen.reserved_output_path = Het pad '{ $path }' is gereserveerd voor de seriële afhankelijkheidsstatus van Netsuke. diff --git a/locales/pl/messages.ftl b/locales/pl/messages.ftl index c9e85900c..1b59c3b13 100644 --- a/locales/pl/messages.ftl +++ b/locales/pl/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = nieznany błąd wejścia/wyjścia. manifest.command_list_empty = Pole „command” nie może być puste: podaj łańcuch polecenia lub niepustą listę. manifest.command_isolation_recipe = Pole „command_isolation” wymaga receptury z listą poleceń. manifest.command_isolation_scalar = „command_isolation: isolated” wymaga listy poleceń. +manifest.console_dependency_only = Wpisy, które deklarują wyłącznie zależności, nie mogą wybrać puli konsoli. # Błędy reprezentacji pośredniej. ir.rule_not_found = Nie znaleziono reguły „{ $rule }”, do której odwołuje się cel „{ $target }”. @@ -198,6 +199,8 @@ ir.invalid_command = Nieprawidłowa interpolacja w poleceniu: { $snippet }. # Błędy generowania plików Ninja. ninja_gen.missing_action = Brakuje akcji „{ $id }”, do której odwołuje się krawędź budowania. +ninja_gen.console_dependency_only = Krawędź budowania „{ $output }” nie może wybrać puli konsoli, ponieważ jej akcja zawiera tylko zależności. +ninja_gen.console_pool_conflict = Krawędź budowania „{ $output }” wybiera pulę konsoli, ale jej akcja żąda niestandardowej puli „{ $pool }”. ninja_gen.format = Nie udało się sformatować wyjścia manifestu Ninja. ninja_gen.dyndep_files_required = Ta operacja wymaga wygenerowanego pakietu Ninja; użyj `netsuke build`, `netsuke clean` lub `netsuke generate`, aby utworzyć pliki dyndep. ninja_gen.reserved_output_path = Ścieżka '{ $path }' jest zarezerwowana dla stanu szeregowych zależności Netsuke. diff --git a/locales/pt-BR/messages.ftl b/locales/pt-BR/messages.ftl index f32493e72..132996cb1 100644 --- a/locales/pt-BR/messages.ftl +++ b/locales/pt-BR/messages.ftl @@ -187,6 +187,7 @@ manifest.glob.unknown_io_error = erro de E/S desconhecido. manifest.command_list_empty = O campo "command" não pode estar vazio: forneça uma cadeia de comando ou uma lista não vazia. manifest.command_isolation_recipe = O campo ‘command_isolation’ exige uma receita com uma lista de comandos. manifest.command_isolation_scalar = ‘command_isolation: isolated’ exige uma lista de comandos. +manifest.console_dependency_only = Entradas que declaram apenas dependências não podem selecionar o pool do console. # Erros da representação intermediária. ir.rule_not_found = A regra "{ $rule }" referenciada pelo alvo "{ $target }" não foi encontrada. @@ -199,6 +200,8 @@ ir.invalid_command = Interpolação de comando inválida: { $snippet }. # Erros de geração do Ninja. ninja_gen.missing_action = Falta a ação "{ $id }" referenciada por uma aresta de build. +ninja_gen.console_dependency_only = A aresta de build "{ $output }" não pode selecionar o pool do console porque sua ação declara apenas dependências. +ninja_gen.console_pool_conflict = A aresta de build "{ $output }" seleciona o pool do console, mas sua ação solicita o pool personalizado "{ $pool }". ninja_gen.format = Não foi possível formatar a saída do manifesto do Ninja. ninja_gen.dyndep_files_required = Esta operação requer um pacote Ninja gerado; use `netsuke build`, `netsuke clean` ou `netsuke generate` para materializar os arquivos dyndep. ninja_gen.reserved_output_path = O caminho '{ $path }' está reservado para o estado de dependências seriais do Netsuke. diff --git a/locales/pt-PT/messages.ftl b/locales/pt-PT/messages.ftl index 72bc48302..2ff457123 100644 --- a/locales/pt-PT/messages.ftl +++ b/locales/pt-PT/messages.ftl @@ -187,6 +187,7 @@ manifest.glob.unknown_io_error = erro de E/S desconhecido. manifest.command_list_empty = O campo «command» não pode estar vazio: forneça uma cadeia de comando ou uma lista não vazia. manifest.command_isolation_recipe = O campo «command_isolation» exige uma receita com uma lista de comandos. manifest.command_isolation_scalar = «command_isolation: isolated» exige uma lista de comandos. +manifest.console_dependency_only = Entradas que declaram apenas dependências não podem selecionar o pool da consola. # Erros da representação intermédia. ir.rule_not_found = A regra «{ $rule }» referenciada pelo alvo «{ $target }» não foi encontrada. @@ -199,6 +200,8 @@ ir.invalid_command = Interpolação de comando inválida: { $snippet }. # Erros de geração do Ninja. ninja_gen.missing_action = Falta a ação «{ $id }» referenciada por uma aresta de compilação. +ninja_gen.console_dependency_only = A aresta de compilação «{ $output }» não pode selecionar o pool da consola porque a sua ação só declara dependências. +ninja_gen.console_pool_conflict = A aresta de compilação «{ $output }» seleciona o pool da consola, mas a sua ação pede o pool personalizado «{ $pool }». ninja_gen.format = Não foi possível formatar a saída do manifesto Ninja. ninja_gen.dyndep_files_required = Esta operação requer um pacote Ninja gerado; utilize `netsuke build`, `netsuke clean` ou `netsuke generate` para materializar os ficheiros dyndep. ninja_gen.reserved_output_path = O caminho '{ $path }' está reservado para o estado de dependências seriais do Netsuke. diff --git a/locales/ro/messages.ftl b/locales/ro/messages.ftl index f150a29d8..9f3276623 100644 --- a/locales/ro/messages.ftl +++ b/locales/ro/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = eroare de intrare/ieșire necunoscută. manifest.command_list_empty = Câmpul „command” nu trebuie să fie gol: furnizați un șir de comandă sau o listă nevidă. manifest.command_isolation_recipe = Câmpul „command_isolation” necesită o rețetă cu o listă de comenzi. manifest.command_isolation_scalar = „command_isolation: isolated” necesită o listă de comenzi. +manifest.console_dependency_only = Intrările care declară doar dependențe nu pot selecta pool-ul consolei. # Erori ale reprezentării intermediare. ir.rule_not_found = Regula „{ $rule }” la care face referire ținta „{ $target }” nu a fost găsită. @@ -198,6 +199,8 @@ ir.invalid_command = Interpolare nevalidă în comandă: { $snippet }. # Erori la generarea fișierelor Ninja. ninja_gen.missing_action = Lipsește acțiunea „{ $id }” la care face referire o muchie de construire. +ninja_gen.console_dependency_only = Muchia de construire „{ $output }” nu poate selecta pool-ul consolei, deoarece acțiunea sa conține doar dependențe. +ninja_gen.console_pool_conflict = Muchia de construire „{ $output }” selectează pool-ul consolei, dar acțiunea sa solicită pool-ul personalizat „{ $pool }”. ninja_gen.format = Ieșirea manifestului Ninja nu a putut fi formatată. ninja_gen.dyndep_files_required = Această operație necesită un pachet Ninja generat; utilizați `netsuke build`, `netsuke clean` sau `netsuke generate` pentru a materializa fișierele dyndep. ninja_gen.reserved_output_path = Calea '{ $path }' este rezervată pentru starea dependențelor seriale Netsuke. diff --git a/locales/ru/messages.ftl b/locales/ru/messages.ftl index 2b34cfb54..f7e67f180 100644 --- a/locales/ru/messages.ftl +++ b/locales/ru/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = неизвестная ошибка ввода- manifest.command_list_empty = Поле «command» не должно быть пустым: укажите строку команды или непустой список. manifest.command_isolation_recipe = Поле «command_isolation» требует рецепт со списком команд. manifest.command_isolation_scalar = «command_isolation: isolated» требует список команд. +manifest.console_dependency_only = Записи, объявляющие только зависимости, не могут выбрать пул консоли. # Ошибки промежуточного представления. ir.rule_not_found = Правило «{ $rule }», на которое ссылается цель «{ $target }», не найдено. @@ -198,6 +199,8 @@ ir.invalid_command = Некорректная подстановка в кома # Ошибки генерации файлов Ninja. ninja_gen.missing_action = Отсутствует действие «{ $id }», на которое ссылается ребро сборки. +ninja_gen.console_dependency_only = Ребро сборки «{ $output }» не может выбрать пул консоли, поскольку действие содержит только зависимости. +ninja_gen.console_pool_conflict = Ребро сборки «{ $output }» выбирает пул консоли, но действие запрашивает пользовательский пул «{ $pool }». ninja_gen.format = Не удалось отформатировать вывод манифеста Ninja. ninja_gen.dyndep_files_required = Для этой операции требуется созданный пакет Ninja; используйте `netsuke build`, `netsuke clean` или `netsuke generate`, чтобы материализовать файлы dyndep. ninja_gen.reserved_output_path = Путь '{ $path }' зарезервирован для состояния последовательных зависимостей Netsuke. diff --git a/locales/sv/messages.ftl b/locales/sv/messages.ftl index c516c7a7f..632687c54 100644 --- a/locales/sv/messages.ftl +++ b/locales/sv/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = okänt I/O-fel. manifest.command_list_empty = Fältet ”command” får inte vara tomt: ange en kommandosträng eller en icke-tom lista. manifest.command_isolation_recipe = Fältet ”command_isolation” kräver ett recept med en kommandolista. manifest.command_isolation_scalar = ”command_isolation: isolated” kräver en kommandolista. +manifest.console_dependency_only = Poster som bara anger beroenden kan inte välja konsolpoolen. # Fel i den interna representationen. ir.rule_not_found = Regeln ”{ $rule }” som målet ”{ $target }” hänvisar till hittades inte. @@ -198,6 +199,8 @@ ir.invalid_command = Ogiltig interpolering i kommandot: { $snippet }. # Fel vid generering av Ninja. ninja_gen.missing_action = Åtgärden ”{ $id }” som en byggbåge hänvisar till saknas. +ninja_gen.console_dependency_only = Byggbågen ”{ $output }” kan inte välja konsolpoolen eftersom åtgärden bara innehåller beroenden. +ninja_gen.console_pool_conflict = Byggbågen ”{ $output }” väljer konsolpoolen, men åtgärden begär den anpassade poolen ”{ $pool }”. ninja_gen.format = Ninja-manifestets utdata kunde inte formateras. ninja_gen.dyndep_files_required = Den här åtgärden kräver ett genererat Ninja-paket; använd `netsuke build`, `netsuke clean` eller `netsuke generate` för att materialisera dyndep-filerna. ninja_gen.reserved_output_path = Sökvägen '{ $path }' är reserverad för Netsukes seriella beroendetillstånd. diff --git a/locales/th/messages.ftl b/locales/th/messages.ftl index 47a3deeda..8292c6fb8 100644 --- a/locales/th/messages.ftl +++ b/locales/th/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = ข้อผิดพลาดรับส่ manifest.command_list_empty = ฟิลด์ “command” ต้องไม่ว่าง: ระบุสตริงคำสั่งหรือรายการที่ไม่ว่าง manifest.command_isolation_recipe = ฟิลด์ “command_isolation” ต้องใช้สูตรที่มีรายการคำสั่ง manifest.command_isolation_scalar = “command_isolation: isolated” ต้องใช้รายการคำสั่ง +manifest.console_dependency_only = รายการที่ระบุเฉพาะการพึ่งพาไม่สามารถเลือกพูลคอนโซลได้ # ข้อผิดพลาดของรูปแทนระดับกลาง ir.rule_not_found = ไม่พบกฎ “{ $rule }” ที่เป้าหมาย “{ $target }” อ้างถึง @@ -198,6 +199,8 @@ ir.invalid_command = การแทรกค่าในคำสั่งไ # ข้อผิดพลาดในการสร้างไฟล์ Ninja ninja_gen.missing_action = ไม่มีการกระทำ “{ $id }” ที่เส้นเชื่อมของการสร้างอ้างถึง +ninja_gen.console_dependency_only = เส้นเชื่อมของการสร้าง “{ $output }” เลือกพูลคอนโซลไม่ได้ เพราะการกระทำมีเฉพาะการพึ่งพาเท่านั้น +ninja_gen.console_pool_conflict = เส้นเชื่อมของการสร้าง “{ $output }” เลือกพูลคอนโซล แต่การกระทำร้องขอพูลที่กำหนดเอง “{ $pool }” ninja_gen.format = จัดรูปแบบผลลัพธ์ของไฟล์รายการ Ninja ไม่สำเร็จ ninja_gen.dyndep_files_required = การดำเนินการนี้ต้องใช้บันเดิล Ninja ที่สร้างขึ้น ให้ใช้ `netsuke build`, `netsuke clean` หรือ `netsuke generate` เพื่อทำให้ไฟล์ dyndep พร้อมใช้งาน ninja_gen.reserved_output_path = เส้นทาง '{ $path }' สงวนไว้สำหรับสถานะการขึ้นต่อกันแบบลำดับของ Netsuke diff --git a/locales/tr/messages.ftl b/locales/tr/messages.ftl index 73c402125..a7e0fd1a5 100644 --- a/locales/tr/messages.ftl +++ b/locales/tr/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = bilinmeyen G/Ç hatası. manifest.command_list_empty = "command" alanı boş olmamalıdır: bir komut dizesi veya boş olmayan bir liste verin. manifest.command_isolation_recipe = "command_isolation" alanı, komut listesi içeren bir tarif gerektirir. manifest.command_isolation_scalar = "command_isolation: isolated" bir komut listesi gerektirir. +manifest.console_dependency_only = Yalnızca bağımlılık bildiren girdiler konsol havuzunu seçemez. # Ara gösterim hataları. ir.rule_not_found = "{ $target }" hedefinin başvurduğu "{ $rule }" kuralı bulunamadı. @@ -198,6 +199,8 @@ ir.invalid_command = Komutta geçersiz yerleştirme: { $snippet }. # Ninja üretimi hataları. ninja_gen.missing_action = Bir derleme kenarının başvurduğu "{ $id }" eylemi eksik. +ninja_gen.console_dependency_only = "{ $output }" derleme kenarı, eylemi yalnızca bağımlılıklardan oluştuğu için konsol havuzunu seçemez. +ninja_gen.console_pool_conflict = "{ $output }" derleme kenarı konsol havuzunu seçiyor, ancak eylemi özel "{ $pool }" havuzunu istiyor. ninja_gen.format = Ninja bildiriminin çıktısı biçimlendirilemedi. ninja_gen.dyndep_files_required = Bu işlem oluşturulmuş bir Ninja paketi gerektiriyor; dyndep dosyalarını oluşturmak için `netsuke build`, `netsuke clean` veya `netsuke generate` kullanın. ninja_gen.reserved_output_path = '{ $path }' yolu Netsuke'nin seri bağımlılık durumu için ayrılmıştır. diff --git a/locales/uk/messages.ftl b/locales/uk/messages.ftl index 8bae729a3..1b72f0f98 100644 --- a/locales/uk/messages.ftl +++ b/locales/uk/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = невідома помилка вводу-ви manifest.command_list_empty = Поле «command» не має бути порожнім: укажіть рядок команди або непорожній список. manifest.command_isolation_recipe = Поле «command_isolation» потребує рецепта зі списком команд. manifest.command_isolation_scalar = «command_isolation: isolated» потребує списку команд. +manifest.console_dependency_only = Записи, що містять лише залежності, не можуть вибрати пул консолі. # Помилки проміжного подання. ir.rule_not_found = Правило «{ $rule }», на яке посилається ціль «{ $target }», не знайдено. @@ -198,6 +199,8 @@ ir.invalid_command = Некоректна підстановка в команд # Помилки створення файлів Ninja. ninja_gen.missing_action = Відсутня дія «{ $id }», на яку посилається ребро збирання. +ninja_gen.console_dependency_only = Ребро збирання «{ $output }» не може вибрати пул консолі, оскільки його дія містить лише залежності. +ninja_gen.console_pool_conflict = Ребро збирання «{ $output }» вибирає пул консолі, але його дія запитує власний пул «{ $pool }». ninja_gen.format = Не вдалося відформатувати вивід маніфесту Ninja. ninja_gen.dyndep_files_required = Для цієї операції потрібні згенеровані Ninja dyndep файли; використовуйте `netsuke build`, `netsuke clean` або `netsuke generate`, щоб матеріалізувати їх. ninja_gen.reserved_output_path = Шлях '{ $path }' зарезервовано для стану послідовних залежностей Netsuke. diff --git a/locales/vi/messages.ftl b/locales/vi/messages.ftl index b53faf8f2..9ab5632c1 100644 --- a/locales/vi/messages.ftl +++ b/locales/vi/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = lỗi vào/ra không xác định. manifest.command_list_empty = Trường “command” không được để trống: hãy cung cấp một chuỗi lệnh hoặc một danh sách không rỗng. manifest.command_isolation_recipe = Trường “command_isolation” yêu cầu công thức có danh sách lệnh. manifest.command_isolation_scalar = “command_isolation: isolated” yêu cầu danh sách lệnh. +manifest.console_dependency_only = Các mục chỉ khai báo phần phụ thuộc không thể chọn pool console. # Lỗi của biểu diễn trung gian. ir.rule_not_found = Không tìm thấy quy tắc “{ $rule }” mà đích “{ $target }” tham chiếu. @@ -198,6 +199,8 @@ ir.invalid_command = Nội suy không hợp lệ trong lệnh: { $snippet }. # Lỗi khi tạo tệp Ninja. ninja_gen.missing_action = Thiếu hành động “{ $id }” mà một cạnh dựng tham chiếu. +ninja_gen.console_dependency_only = Cạnh dựng “{ $output }” không thể chọn pool console vì hành động của nó chỉ chứa các phụ thuộc. +ninja_gen.console_pool_conflict = Cạnh dựng “{ $output }” chọn pool console, nhưng hành động yêu cầu pool tùy chỉnh “{ $pool }”. ninja_gen.format = Không định dạng được đầu ra của tệp kê khai Ninja. ninja_gen.dyndep_files_required = Thao tác này yêu cầu các tệp Ninja dyndep đã tạo; hãy dùng `netsuke build`, `netsuke clean` hoặc `netsuke generate` để hiện thực hóa chúng. ninja_gen.reserved_output_path = Đường dẫn '{ $path }' được dành riêng cho trạng thái phụ thuộc tuần tự của Netsuke. diff --git a/locales/zh-Hans/messages.ftl b/locales/zh-Hans/messages.ftl index 15f84353d..eb52c86d0 100644 --- a/locales/zh-Hans/messages.ftl +++ b/locales/zh-Hans/messages.ftl @@ -185,6 +185,7 @@ manifest.glob.unknown_io_error = 未知的输入输出错误。 manifest.command_list_empty = “command”字段不能为空:请提供命令字符串或非空列表。 manifest.command_isolation_recipe = “command_isolation”字段需要包含命令列表的配方。 manifest.command_isolation_scalar = “command_isolation: isolated”需要命令列表。 +manifest.console_dependency_only = 仅声明依赖项的条目无法选择控制台池。 # 中间表示的错误。 ir.rule_not_found = 找不到目标“{ $target }”引用的规则“{ $rule }”。 @@ -197,6 +198,8 @@ ir.invalid_command = 命令中的插值无效:{ $snippet }。 # Ninja 生成错误。 ninja_gen.missing_action = 缺少构建边引用的动作“{ $id }”。 +ninja_gen.console_dependency_only = 构建边“{ $output }”无法选择控制台池,因为它的动作仅包含依赖项。 +ninja_gen.console_pool_conflict = 构建边“{ $output }”选择了控制台池,但它的动作请求自定义池“{ $pool }”。 ninja_gen.format = 无法格式化 Ninja 清单的输出。 ninja_gen.dyndep_files_required = 此操作需要生成的 Ninja dyndep 文件;请使用 `netsuke build`、`netsuke clean` 或 `netsuke generate` 以生成这些文件。 ninja_gen.reserved_output_path = 路径“{ $path }”已保留给 Netsuke 的串行依赖状态。 diff --git a/locales/zh-Hant/messages.ftl b/locales/zh-Hant/messages.ftl index 3c90d87ee..c1df50cb7 100644 --- a/locales/zh-Hant/messages.ftl +++ b/locales/zh-Hant/messages.ftl @@ -185,6 +185,7 @@ manifest.glob.unknown_io_error = 未知的輸入輸出錯誤。 manifest.command_list_empty = 「command」欄位不得為空:請提供命令字串或非空清單。 manifest.command_isolation_recipe = 「command_isolation」欄位需要包含命令清單的配方。 manifest.command_isolation_scalar = 「command_isolation: isolated」需要命令清單。 +manifest.console_dependency_only = 僅宣告相依項目的項目無法選取主控台集區。 # 中介表示法的錯誤。 ir.rule_not_found = 找不到目標「{ $target }」所參照的規則「{ $rule }」。 @@ -197,6 +198,8 @@ ir.invalid_command = 命令中的插值無效:{ $snippet }。 # Ninja 產生錯誤。 ninja_gen.missing_action = 缺少建置邊所參照的動作「{ $id }」。 +ninja_gen.console_dependency_only = 建置邊「{ $output }」無法選取主控台集區,因為它的動作只包含相依項目。 +ninja_gen.console_pool_conflict = 建置邊「{ $output }」選取了主控台集區,但它的動作要求自訂集區「{ $pool }」。 ninja_gen.format = 無法格式化 Ninja 資訊清單的輸出。 ninja_gen.dyndep_files_required = 此操作需要產生的 Ninja dyndep 檔案;請使用 `netsuke build`、`netsuke clean` 或 `netsuke generate` 以產生這些檔案。 ninja_gen.reserved_output_path = 路徑「{ $path }」已保留給 Netsuke 的序列相依性狀態。 diff --git a/proptest-regressions/ninja_gen/property_tests/console.txt b/proptest-regressions/ninja_gen/property_tests/console.txt new file mode 100644 index 000000000..3a41c5aeb --- /dev/null +++ b/proptest-regressions/ninja_gen/property_tests/console.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 7e26a15f98f3494221509552e080ff20866d56aff6843f173095b3d96fc51b27 # shrinks to selections = [false, false], parent_console = false diff --git a/public_api_fixture/src/lib.rs b/public_api_fixture/src/lib.rs index dadcc88ae..17fc02be6 100644 --- a/public_api_fixture/src/lib.rs +++ b/public_api_fixture/src/lib.rs @@ -6,6 +6,8 @@ //! compiled, and Cargo compiles them in the build phase with every other //! workspace library. See ADR-0059 for why the checks left the test suite. +#[path = "../../tests/ui/build_edge_console_compile_pass.rs"] +pub mod build_edge_console; pub mod cli_configuration; pub mod command_env; pub mod command_list; diff --git a/src/ast/mod.rs b/src/ast/mod.rs index a1194f4e3..34aa8d7e2 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -186,6 +186,10 @@ pub enum Recipe { /// Preserve the established diagnostic for entries without a recipe or deps. pub(crate) const MISSING_RECIPE_ERROR: &str = "missing one of command, script, or rule"; +/// Explain why dependency-only entries cannot request the console pool. +pub(crate) const CONSOLE_DEPENDENCY_ONLY_ERROR: &str = + "console cannot be enabled for dependency-only entries"; + /// Neutral schema error for modes that do not accompany a command list. pub(crate) const COMMAND_ISOLATION_RECIPE_ERROR: &str = "command_isolation requires a command-list recipe"; @@ -307,6 +311,14 @@ impl NetsukeManifest { { return Err(MISSING_RECIPE_ERROR); } + if self + .actions + .iter() + .chain(&self.targets) + .any(|target| target.console && target.recipe.is_dependency_only()) + { + return Err(CONSOLE_DEPENDENCY_ONLY_ERROR); + } Ok(()) } } diff --git a/src/ast/target.rs b/src/ast/target.rs index 15aeb7901..c31bec2ee 100644 --- a/src/ast/target.rs +++ b/src/ast/target.rs @@ -8,7 +8,13 @@ use super::{DependencyOrder, Recipe, StringOrList, Vars}; /// /// Targets describe the files produced by a rule and their dependencies. /// `phony` targets are always considered out of date, while `always` targets are -/// regenerated even if their inputs are unchanged. +/// regenerated even if their inputs are unchanged. `console` asks Ninja to run +/// an executable edge in its serial console pool, which can make long-running +/// output visible sooner at the cost of serializing other console edges. +#[expect( + clippy::struct_excessive_bools, + reason = "manifest controls are independent and may be combined" +)] #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct Target { @@ -46,6 +52,12 @@ pub struct Target { #[serde(default)] pub always: bool, + /// Run an executable action or target in Ninja's serial console pool. + /// + /// Defaults to `false`. Dependency-only entries cannot use this setting. + #[serde(default)] + pub console: bool, + /// Internal discovery metadata marking a target with an unevaluated `when`. #[serde(default)] pub conditional: bool, diff --git a/src/graph_view/tests/support.rs b/src/graph_view/tests/support.rs index fed8bcafa..caa911961 100644 --- a/src/graph_view/tests/support.rs +++ b/src/graph_view/tests/support.rs @@ -69,6 +69,7 @@ pub(super) fn add_edge(graph: &mut BuildGraph, fixture: EdgeFixture<'_>) { progress_label: None, phony: fixture.phony, always: fixture.always, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }; assert!( graph.insert_edge(edge).is_ok(), diff --git a/src/ir/cycle/participation_tests.rs b/src/ir/cycle/participation_tests.rs index a394d04fe..aa9ea1a97 100644 --- a/src/ir/cycle/participation_tests.rs +++ b/src/ir/cycle/participation_tests.rs @@ -38,6 +38,7 @@ fn build_edge(spec: EdgeSpec<'_>) -> BuildEdge { order_only_deps: paths(spec.order_only_deps), phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::Buffered, } } diff --git a/src/ir/cycle/property_tests/analyse_tests.rs b/src/ir/cycle/property_tests/analyse_tests.rs index 09c96b60a..99d47b1ff 100644 --- a/src/ir/cycle/property_tests/analyse_tests.rs +++ b/src/ir/cycle/property_tests/analyse_tests.rs @@ -127,6 +127,7 @@ fn analyse_resolves_non_first_output_alias_as_dependency() { progress_label: None, phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }; graph .insert_edge(producer) diff --git a/src/ir/cycle/property_tests/issue322_property_tests.rs b/src/ir/cycle/property_tests/issue322_property_tests.rs index 53d956dab..6f75214e7 100644 --- a/src/ir/cycle/property_tests/issue322_property_tests.rs +++ b/src/ir/cycle/property_tests/issue322_property_tests.rs @@ -51,6 +51,7 @@ fn build_edge(output: Utf8PathBuf) -> BuildEdge { progress_label: None, phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), } } diff --git a/src/ir/cycle/property_tests/mod.rs b/src/ir/cycle/property_tests/mod.rs index 3e310df7f..841e92a0b 100644 --- a/src/ir/cycle/property_tests/mod.rs +++ b/src/ir/cycle/property_tests/mod.rs @@ -59,6 +59,7 @@ impl EdgeBuilder { progress_label: None, phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), } } } diff --git a/src/ir/cycle/property_tests/participation_tests.rs b/src/ir/cycle/property_tests/participation_tests.rs index 1c1a09b48..dbcfcba94 100644 --- a/src/ir/cycle/property_tests/participation_tests.rs +++ b/src/ir/cycle/property_tests/participation_tests.rs @@ -319,6 +319,7 @@ fn build_edge(index: usize, counts: AliasCounts) -> BuildEdge { order_only_deps: Vec::new(), phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::Buffered, } } diff --git a/src/ir/cycle/tests.rs b/src/ir/cycle/tests.rs index cf2592fe4..382f1b3a5 100644 --- a/src/ir/cycle/tests.rs +++ b/src/ir/cycle/tests.rs @@ -22,6 +22,7 @@ fn build_edge(inputs: &[&str], implicit_deps: &[&str], output: &str) -> BuildEdg progress_label: None, phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), } } struct MissingDepsCase<'a> { diff --git a/src/ir/cycle/verification.rs b/src/ir/cycle/verification.rs index fe5b43a75..0c0a9e07b 100644 --- a/src/ir/cycle/verification.rs +++ b/src/ir/cycle/verification.rs @@ -198,6 +198,7 @@ fn edge_with(spec: EdgeSpec<'_>) -> BuildEdge { order_only_deps: spec.order_only_deps, phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), } } diff --git a/src/ir/from_manifest/mod.rs b/src/ir/from_manifest/mod.rs index 061ac6551..83b44feda 100644 --- a/src/ir/from_manifest/mod.rs +++ b/src/ir/from_manifest/mod.rs @@ -10,13 +10,13 @@ use std::sync::Arc; use camino::Utf8PathBuf; -use crate::ast::{MISSING_RECIPE_ERROR, NetsukeManifest, Recipe, Rule}; +use crate::ast::{NetsukeManifest, Recipe, Rule}; use crate::localization::{self, keys}; use crate::recipe_shell::RecipeShell; use super::{ cycle::{self, CycleDetectionReport}, - graph::{BuildEdge, BuildGraph, DependencyOrder, IrGenError, IrHashMap}, + graph::{BuildEdge, BuildGraph, DependencyOrder, EdgeOutputVisibility, IrGenError, IrHashMap}, }; mod support; @@ -66,9 +66,7 @@ impl BuildGraph { ) -> Result { manifest .validate_recipes() - .map_err(|_| IrGenError::InvalidManifest { - message: MISSING_RECIPE_ERROR, - })?; + .map_err(|message| IrGenError::InvalidManifest { message })?; let mut graph = Self::default(); let mut rule_map = IrHashMap::>::default(); @@ -174,6 +172,11 @@ impl BuildGraph { order_only_deps: to_paths(&target.order_only_deps), phony: target.phony, always: target.always, + output_visibility: if target.console { + EdgeOutputVisibility::Immediate + } else { + EdgeOutputVisibility::Buffered + }, }; #[cfg(not(kani))] diff --git a/src/ir/graph/mod.rs b/src/ir/graph/mod.rs index 6d46d70a8..22184e3fc 100644 --- a/src/ir/graph/mod.rs +++ b/src/ir/graph/mod.rs @@ -268,6 +268,20 @@ pub struct Action { pub restat: bool, } +/// Desired visibility for output produced by one build edge. +/// +/// Backends translate this domain request into their own scheduling and +/// forwarding mechanisms. `Immediate` asks that output become visible as the +/// action produces it; `Buffered` keeps the backend's normal behaviour. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum EdgeOutputVisibility { + /// Use the backend's normal output buffering behaviour. + #[default] + Buffered, + /// Make output visible while the action is still running. + Immediate, +} + /// A single build statement connecting inputs to outputs. #[derive(Debug, Clone, PartialEq)] pub struct BuildEdge { @@ -296,6 +310,11 @@ pub struct BuildEdge { pub phony: bool, /// Run the command on every invocation regardless of timestamps. pub always: bool, + /// Output visibility requested for this edge. + /// + /// Keeping the request on the edge lets targets share one action while + /// selecting different output behaviour. + pub output_visibility: EdgeOutputVisibility, } mod error; diff --git a/src/ir/graph/property_tests/cycles.rs b/src/ir/graph/property_tests/cycles.rs index cc8223358..3a5edb605 100644 --- a/src/ir/graph/property_tests/cycles.rs +++ b/src/ir/graph/property_tests/cycles.rs @@ -287,6 +287,7 @@ fn target(slot: usize, deps: &[usize]) -> Target { vars: Vars::default(), phony: false, always: false, + console: false, conditional: false, description: None, } diff --git a/src/ir/graph/property_tests/duplicates.rs b/src/ir/graph/property_tests/duplicates.rs index 64b2f3753..c9f07af12 100644 --- a/src/ir/graph/property_tests/duplicates.rs +++ b/src/ir/graph/property_tests/duplicates.rs @@ -247,6 +247,7 @@ fn target(slot: usize, outputs: Vec) -> Target { vars: Vars::default(), phony: false, always: false, + console: false, conditional: false, description: None, } diff --git a/src/ir/mod.rs b/src/ir/mod.rs index 24a86a37a..05eb52a36 100644 --- a/src/ir/mod.rs +++ b/src/ir/mod.rs @@ -1,8 +1,8 @@ //! Intermediate Representation structures. //! -//! This module defines the backend-agnostic build graph used by Netsuke after -//! validation. The IR mirrors the conceptual model of Ninja without embedding -//! any Ninja-specific syntax. +//! This module defines the validated build graph Netsuke passes to Ninja +//! generation. Recipe and command data contain no Ninja syntax; +//! [`EdgeOutputVisibility`] carries an edge's backend-neutral output request. //! //! # Examples //! @@ -35,4 +35,7 @@ mod from_manifest; mod graph; pub use cmd_interpolate::{INS_TOKEN, OUTS_TOKEN}; -pub use graph::{Action, BuildEdge, BuildGraph, DependencyOrder, EdgeArena, EdgeId, IrGenError}; +pub use graph::{ + Action, BuildEdge, BuildGraph, DependencyOrder, EdgeArena, EdgeId, EdgeOutputVisibility, + IrGenError, +}; diff --git a/src/localization/keys.rs b/src/localization/keys.rs index fa42d532b..982058310 100644 --- a/src/localization/keys.rs +++ b/src/localization/keys.rs @@ -1,5 +1,4 @@ //! Fluent message identifiers used by Netsuke. -//! //! This file holds the general key table: the build-time localization audit //! parses its `define_keys!` invocation, and the macro itself is defined in //! the parent module. The `netsuke check` keys live in `check_keys.rs`, the @@ -31,7 +30,6 @@ define_keys! { CLI_FLAG_FETCH_BLOCK_HOST_HELP => "cli.flag.fetch_block_host.help", CLI_FLAG_FETCH_DEFAULT_DENY_HELP => "cli.flag.fetch_default_deny.help", CLI_FLAG_TRUST_PROJECT_FETCH_POLICY_HELP => "cli.flag.trust_project_fetch_policy.help", - CLI_FLAG_MANIFEST_EVALUATION_FUEL_HELP => "cli.flag.manifest_evaluation_fuel.help", CLI_FLAG_MANIFEST_FUEL_HELP => "cli.flag.manifest_fuel.help", CLI_FLAG_MANIFEST_RENDERED_VALUE_BYTES_HELP => "cli.flag.manifest_rendered_value_bytes.help", @@ -123,6 +121,8 @@ define_keys! { IR_ACTION_SERIALISATION => "ir.action_serialisation", IR_INVALID_COMMAND => "ir.invalid_command", NINJA_GEN_MISSING_ACTION => "ninja_gen.missing_action", + NINJA_GEN_CONSOLE_DEPENDENCY_ONLY => "ninja_gen.console_dependency_only", + NINJA_GEN_CONSOLE_POOL_CONFLICT => "ninja_gen.console_pool_conflict", NINJA_GEN_FORMAT => "ninja_gen.format", NINJA_GEN_DYNDEP_FILES_REQUIRED => "ninja_gen.dyndep_files_required", NINJA_GEN_RESERVED_OUTPUT_PATH => "ninja_gen.reserved_output_path", diff --git a/src/localization/manifest_keys.rs b/src/localization/manifest_keys.rs index 1c5f509fc..0eaecc385 100644 --- a/src/localization/manifest_keys.rs +++ b/src/localization/manifest_keys.rs @@ -59,4 +59,5 @@ define_keys! { MANIFEST_COMMAND_LIST_EMPTY => "manifest.command_list_empty", MANIFEST_COMMAND_ISOLATION_RECIPE => "manifest.command_isolation_recipe", MANIFEST_COMMAND_ISOLATION_SCALAR => "manifest.command_isolation_scalar", + MANIFEST_CONSOLE_DEPENDENCY_ONLY => "manifest.console_dependency_only", } diff --git a/src/manifest/mod.rs b/src/manifest/mod.rs index a2e09f5ec..a7e0a976d 100644 --- a/src/manifest/mod.rs +++ b/src/manifest/mod.rs @@ -262,12 +262,17 @@ fn expand_and_render(request: ExpansionRequest<'_, '_, '_>) -> Result Result<()> { vars: target_vars, phony: false, always: false, + console: false, conditional: false, description: None, }], diff --git a/src/manifest/render/script_tests.rs b/src/manifest/render/script_tests.rs index 46008e50c..0196a2de4 100644 --- a/src/manifest/render/script_tests.rs +++ b/src/manifest/render/script_tests.rs @@ -30,6 +30,7 @@ fn script_recipe_context_reserves_ins_and_outs_placeholders() -> Result<()> { vars: target_vars, phony: false, always: false, + console: false, conditional: false, description: None, }], diff --git a/src/manifest/render/tests.rs b/src/manifest/render/tests.rs index 570dfecd1..6aa390f06 100644 --- a/src/manifest/render/tests.rs +++ b/src/manifest/render/tests.rs @@ -28,6 +28,7 @@ fn sample_manifest() -> Result { vars: target_vars, phony: false, always: false, + console: false, conditional: false, description: Some("{{ message }}".into()), }; @@ -220,6 +221,7 @@ fn render_manifest_renders_script_and_rule_ref_recipes() -> Result<()> { vars: target_vars, phony: false, always: false, + console: false, conditional: false, description: None, }; diff --git a/src/manifest/render/tests/query.rs b/src/manifest/render/tests/query.rs index c488b2beb..81c1c346f 100644 --- a/src/manifest/render/tests/query.rs +++ b/src/manifest/render/tests/query.rs @@ -38,6 +38,7 @@ fn manifest_with_build_only_recipe_helper() -> Result { vars: action_vars, phony: true, always: false, + console: false, conditional: false, description: Some("{{ description }}".into()), }], diff --git a/src/ninja_gen/determinism/property_tests/declaration.rs b/src/ninja_gen/determinism/property_tests/declaration.rs index 3c3147f56..e173b170e 100644 --- a/src/ninja_gen/determinism/property_tests/declaration.rs +++ b/src/ninja_gen/determinism/property_tests/declaration.rs @@ -354,6 +354,7 @@ fn rule_backed_target(name: &str) -> Target { vars: Vars::default(), phony: false, always: false, + console: false, conditional: false, description: None, } diff --git a/src/ninja_gen/determinism/property_tests/defaults/generator.rs b/src/ninja_gen/determinism/property_tests/defaults/generator.rs index 7e3027165..b60895aa3 100644 --- a/src/ninja_gen/determinism/property_tests/defaults/generator.rs +++ b/src/ninja_gen/determinism/property_tests/defaults/generator.rs @@ -172,6 +172,7 @@ pub(super) fn edge(index: usize) -> BuildEdge { progress_label: None, phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), } } diff --git a/src/ninja_gen/determinism/property_tests/ninja_oracle/relay_regression.rs b/src/ninja_gen/determinism/property_tests/ninja_oracle/relay_regression.rs index 73938b738..e39848468 100644 --- a/src/ninja_gen/determinism/property_tests/ninja_oracle/relay_regression.rs +++ b/src/ninja_gen/determinism/property_tests/ninja_oracle/relay_regression.rs @@ -110,6 +110,7 @@ fn crash_shaped_graph() -> BuildGraph { progress_label: None, phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }, BuildEdge { action_id: "aggregate".into(), @@ -122,6 +123,7 @@ fn crash_shaped_graph() -> BuildGraph { progress_label: None, phony: true, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }, BuildEdge { action_id: "aggregate".into(), @@ -134,6 +136,7 @@ fn crash_shaped_graph() -> BuildGraph { progress_label: None, phony: true, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }, BuildEdge { action_id: "aggregate".into(), @@ -146,6 +149,7 @@ fn crash_shaped_graph() -> BuildGraph { progress_label: None, phony: true, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }, ] { // A duplicate alias would silently change the graph this test exists to diff --git a/src/ninja_gen/determinism/property_tests/no_loss.rs b/src/ninja_gen/determinism/property_tests/no_loss.rs index 2789ac298..eb35b0e35 100644 --- a/src/ninja_gen/determinism/property_tests/no_loss.rs +++ b/src/ninja_gen/determinism/property_tests/no_loss.rs @@ -191,6 +191,7 @@ fn edge_with_explicit_outputs(outputs: &[&str]) -> BuildEdge { progress_label: None, phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), } } diff --git a/src/ninja_gen/determinism/property_tests/order.rs b/src/ninja_gen/determinism/property_tests/order.rs index 870342d5a..6012e29a6 100644 --- a/src/ninja_gen/determinism/property_tests/order.rs +++ b/src/ninja_gen/determinism/property_tests/order.rs @@ -328,6 +328,7 @@ fn the_edge_rule_keys_on_the_output_set() { progress_label: None, phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }; let later = edge(["out/a", "out/z"]); let earlier = edge(["out/a", "out/m"]); diff --git a/src/ninja_gen/determinism/strategy/graph/blueprint.rs b/src/ninja_gen/determinism/strategy/graph/blueprint.rs index 41c800d86..927cf4272 100644 --- a/src/ninja_gen/determinism/strategy/graph/blueprint.rs +++ b/src/ninja_gen/determinism/strategy/graph/blueprint.rs @@ -149,6 +149,7 @@ impl Blueprint { progress_label: None, phony: plan.is_phony, always: plan.is_always, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }); } edges diff --git a/src/ninja_gen/determinism/strategy/manifest/mod.rs b/src/ninja_gen/determinism/strategy/manifest/mod.rs index 9aae510aa..9dc38deb2 100644 --- a/src/ninja_gen/determinism/strategy/manifest/mod.rs +++ b/src/ninja_gen/determinism/strategy/manifest/mod.rs @@ -185,6 +185,7 @@ impl TargetPlan { vars: Vars::default(), phony: false, always: false, + console: false, conditional: false, description: None, } diff --git a/src/ninja_gen/display_edge.rs b/src/ninja_gen/display_edge.rs index a26416960..c2aa54f89 100644 --- a/src/ninja_gen/display_edge.rs +++ b/src/ninja_gen/display_edge.rs @@ -1,5 +1,6 @@ //! Ninja build-edge display rendering. +use super::CONSOLE_POOL; use super::escape::NinjaValue; use super::join; use crate::ir::BuildEdge; @@ -14,6 +15,8 @@ pub(crate) struct DisplayEdge<'a> { pub(crate) action_name: &'a str, /// Whether the action sets `restat`, suppressing the edge-level override. pub(crate) action_restat: bool, + /// Pool selected by the shared action rule, if any. + pub(crate) action_pool: Option<&'a str>, /// Dependencies rendered after `|`, either the edge's implicit deps or lowered serial gates. pub(crate) implicit_deps: &'a [Utf8PathBuf], /// Optional localized inline-recipe progress description, escaped for Ninja. @@ -39,6 +42,13 @@ impl Display for DisplayEdge<'_> { writeln!(output)?; write_flag!(output, "restat", self.edge.always && !self.action_restat); write_kv!(output, "description", self.description); + if self.edge.output_visibility == crate::ir::EdgeOutputVisibility::Immediate { + writeln!(output, " pool = {CONSOLE_POOL}")?; + } else if self.action_pool == Some(CONSOLE_POOL) { + // Rule bindings are inherited by edges, so clear a rule-level + // console pool on edges that did not opt in themselves. + writeln!(output, " pool =")?; + } writeln!(output) } } diff --git a/src/ninja_gen/dyndep/bundle.rs b/src/ninja_gen/dyndep/bundle.rs index ba448d65c..eeb0ec003 100644 --- a/src/ninja_gen/dyndep/bundle.rs +++ b/src/ninja_gen/dyndep/bundle.rs @@ -46,9 +46,17 @@ pub struct GeneratedNinja { pub(super) build_file: String, /// Dyndep sidecars `build_file` requires, in generation order. pub(super) dyndep_files: Vec, + /// Number of user build edges selecting the console pool. + pub(super) console_edge_count: usize, } impl GeneratedNinja { + /// Return the console-edge aggregate retained for runner telemetry. + #[must_use] + pub(crate) const fn console_edge_count(&self) -> usize { + self.console_edge_count + } + /// Borrow the main Ninja build file text. #[must_use] pub fn build_file(&self) -> &str { diff --git a/src/ninja_gen/dyndep/mod.rs b/src/ninja_gen/dyndep/mod.rs index 8333be4fb..4f7cf6a19 100644 --- a/src/ninja_gen/dyndep/mod.rs +++ b/src/ninja_gen/dyndep/mod.rs @@ -54,6 +54,7 @@ //! progress_label: None, //! phony: false, //! always: false, +//! output_visibility: netsuke::ir::EdgeOutputVisibility::default(), //! }).expect("test graph output aliases must be unique"); //! let bundle = generate_bundle(&graph).expect("generate bundle"); //! assert!(bundle.build_file().contains("ninja_required_version = 1.10")); @@ -92,7 +93,10 @@ const DYNDEP_NAMESPACE: &str = ".netsuke/dyndep"; /// Returns [`NinjaGenError::ReservedOutputPath`] when a user output or /// dependency collides with the reserved `.netsuke/serial` or /// `.netsuke/dyndep` namespace, and [`NinjaGenError::MissingAction`] when an -/// edge references an unknown action. +/// edge references an unknown action. Returns +/// [`NinjaGenError::ConsoleDependencyOnly`] when a console edge uses a +/// dependency-only action, and [`NinjaGenError::ConsolePoolConflict`] when its +/// action requests a conflicting custom pool. pub fn generate_bundle(graph: &BuildGraph) -> Result { generate_bundle_for_shell(graph, RecipeShell::host_default()) } @@ -137,9 +141,12 @@ pub(crate) fn generate_bundle_for_shell_with_bash( /// [`NinjaGenError::UnsupportedCommandListExec`], /// [`NinjaGenError::UnanalyzableCommandListEval`], or /// [`NinjaGenError::NinjaControlCharacter`] when an action recipe cannot be -/// rendered safely. Returns [`NinjaGenError::MissingAction`] when an edge -/// references an action absent from the graph, or [`NinjaGenError::Format`] -/// when rendering the bundle fails. +/// rendered safely. Returns [`NinjaGenError::ConsoleDependencyOnly`] when a +/// console edge uses a dependency-only action or +/// [`NinjaGenError::ConsolePoolConflict`] when its action requests a +/// conflicting custom pool. Returns [`NinjaGenError::MissingAction`] when an +/// edge references an action absent from the graph, or +/// [`NinjaGenError::Format`] when rendering the bundle fails. fn generate_bundle_inner( graph: &BuildGraph, shell: RecipeShell, @@ -147,10 +154,10 @@ fn generate_bundle_inner( ) -> Result { reject_unsupported_path_characters(graph)?; reject_reserved_paths(graph)?; - let serial_present = graph_requires_dyndep(graph); + super::validation::validate_console_scheduling(graph)?; let mut out = String::new(); - if serial_present { + if graph_requires_dyndep(graph) { writeln!(out, "ninja_required_version = 1.10\n")?; } @@ -167,6 +174,10 @@ fn generate_bundle_inner( Ok(GeneratedNinja { build_file: out, dyndep_files, + console_edge_count: graph + .edges() + .filter(|edge| edge.output_visibility == crate::ir::EdgeOutputVisibility::Immediate) + .count(), }) } diff --git a/src/ninja_gen/dyndep/render.rs b/src/ninja_gen/dyndep/render.rs index d9dd4ffc7..690d66df3 100644 --- a/src/ninja_gen/dyndep/render.rs +++ b/src/ninja_gen/dyndep/render.rs @@ -55,6 +55,7 @@ impl EdgeRenderer<'_> { let rendered_action = RenderedAction { name: metadata.action_name, restat: metadata.restat, + pool: metadata.pool, }; let rendered_edge = RenderedEdge { action: rendered_action, @@ -91,6 +92,7 @@ impl EdgeRenderer<'_> { edge, action_name: rendered_edge.action.name, action_restat: rendered_edge.action.restat, + action_pool: rendered_edge.action.pool, implicit_deps, description: rendered_edge.description, } @@ -115,4 +117,6 @@ struct RenderedAction<'a> { name: &'a str, /// Preserve whether Ninja should restat this edge after execution. restat: bool, + /// Pool selected by the shared action, if any. + pool: Option<&'a str>, } diff --git a/src/ninja_gen/dyndep/tests/console.rs b/src/ninja_gen/dyndep/tests/console.rs new file mode 100644 index 000000000..e203619d3 --- /dev/null +++ b/src/ninja_gen/dyndep/tests/console.rs @@ -0,0 +1,130 @@ +//! Tests for console selection across staged serial dependency bundles. + +use super::{generate_bundle, graph_with_edge, serial_edge}; +use crate::ir::{BuildEdge, DependencyOrder, EdgeOutputVisibility}; +use anyhow::{Result, ensure}; +use camino::Utf8PathBuf; + +/// Keep console scheduling on the user edge, away from synthetic relays, gates, +/// and sidecars. +#[test] +fn console_serial_parent_does_not_mark_gates_or_sidecars() -> Result<()> { + let mut edge = serial_edge("all", &["check-fmt", "lint"]); + edge.output_visibility = EdgeOutputVisibility::Immediate; + let graph = graph_with_edge(edge); + let bundle = generate_bundle(&graph)?; + ensure!( + bundle.console_edge_count() == 1, + "console telemetry must count the user edge without synthetic gates or sidecars" + ); + let file = bundle.build_file(); + let lines: Vec<_> = file.lines().collect(); + let parent_line = lines + .iter() + .position(|line| line.starts_with("build all: a |")) + .expect("console parent edge must be emitted"); + + ensure!( + lines.get(parent_line + 1) == Some(&" pool = console"), + "console binding must follow the parent build statement:\n{file}" + ); + ensure!( + lines + .iter() + .filter(|line| **line == " pool = console") + .count() + == 1, + "synthetic gate edges must not select the console pool:\n{file}" + ); + let serial_statements: Vec<&str> = file + .lines() + .filter(|line| line.starts_with("build .netsuke/serial/")) + .collect(); + ensure!( + serial_statements + .iter() + .filter(|line| line.contains(": phony || ")) + .count() + == 2, + "expected two synthetic serial gates:\n{file}" + ); + // Match the relay name without its leading separator: staging writes the + // literal `.netsuke/serial/` prefix, but joins the generated components + // with the platform separator, which is `\` on Windows. + ensure!( + serial_statements + .iter() + .filter(|line| line.contains("relay-") && line.contains(": phony | ")) + .count() + == 2, + "expected one relay phony per serial gate:\n{file}" + ); + ensure!( + bundle + .dyndep_files() + .iter() + .all(|sidecar| !sidecar.content().contains("console")), + "dyndep sidecars must not contain console scheduling metadata" + ); + Ok(()) +} + +/// Clear a shared console rule pool from ordinary edges in a dyndep bundle. +#[test] +fn ordinary_dyndep_edge_clears_a_shared_console_action_pool() -> Result<()> { + let mut selected = serial_edge("all", &["check-fmt", "lint"]); + selected.output_visibility = EdgeOutputVisibility::Immediate; + let mut graph = graph_with_edge(selected); + graph.actions.get_mut("a").expect("test action exists").pool = Some("console".into()); + graph + .insert_edge(BuildEdge { + action_id: "a".into(), + inputs: Vec::new(), + implicit_deps: Vec::new(), + dependency_order: DependencyOrder::Parallel, + explicit_outputs: vec![Utf8PathBuf::from("ordinary")], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + progress_label: None, + phony: false, + always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), + }) + .expect("test graph output aliases must be unique"); + + let bundle = generate_bundle(&graph)?; + let file = bundle.build_file(); + let lines: Vec<_> = file.lines().collect(); + let selected_index = lines + .iter() + .position(|line| line.starts_with("build all: a |")) + .expect("selected serial edge must be emitted"); + ensure!( + lines + .iter() + .skip(selected_index + 1) + .take_while(|line| line.starts_with(" ")) + .any(|line| *line == " pool = console"), + "selected serial edge must retain console scheduling:\n{file}" + ); + ensure!( + file.contains("build ordinary: a\n pool =\n\n"), + "ordinary edge must clear the action rule's console pool:\n{file}" + ); + let synthetic_edge_selects_console = lines + .iter() + .enumerate() + .filter(|(_, line)| line.starts_with("build .netsuke/serial/")) + .any(|(index, _)| { + lines + .iter() + .skip(index + 1) + .take_while(|line| line.starts_with(" ")) + .any(|line| *line == " pool = console") + }); + ensure!( + !synthetic_edge_selects_console, + "synthetic serial gates must not select the console pool:\n{file}" + ); + Ok(()) +} diff --git a/src/ninja_gen/dyndep/tests/dependency_only.rs b/src/ninja_gen/dyndep/tests/dependency_only.rs index 524050765..676e16e7b 100644 --- a/src/ninja_gen/dyndep/tests/dependency_only.rs +++ b/src/ninja_gen/dyndep/tests/dependency_only.rs @@ -35,6 +35,7 @@ fn dependency_only_serial_bundle_uses_phony_without_a_recipe() -> Result<()> { progress_label: None, phony: true, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }) .expect("test graph output aliases must be unique"); diff --git a/src/ninja_gen/dyndep/tests/mod.rs b/src/ninja_gen/dyndep/tests/mod.rs index b94240eb5..8ecfac52b 100644 --- a/src/ninja_gen/dyndep/tests/mod.rs +++ b/src/ninja_gen/dyndep/tests/mod.rs @@ -33,6 +33,7 @@ fn serial_edge(output: &str, deps: &[&str]) -> BuildEdge { progress_label: None, phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), } } @@ -348,8 +349,8 @@ fn repeated_dependency_keeps_separate_stage_sidecars() -> Result<()> { Ok(()) } +mod console; mod dependency_only; - mod multi_output; mod paths; diff --git a/src/ninja_gen/edge_metadata.rs b/src/ninja_gen/edge_metadata.rs index 27ae1e6ff..82fe87a1d 100644 --- a/src/ninja_gen/edge_metadata.rs +++ b/src/ninja_gen/edge_metadata.rs @@ -16,6 +16,8 @@ pub(super) struct EdgeMetadata<'a> { pub(super) action_name: &'a str, /// Whether Ninja should remove the output before running the recipe. pub(super) restat: bool, + /// Pool selected by the shared action, if any. + pub(super) pool: Option<&'a str>, /// Optional, validated edge-level description with Ninja dollars escaped. pub(super) description: Option, } @@ -61,6 +63,7 @@ pub(super) fn for_edge<'a>( Ok(EdgeMetadata { action_name, restat: action.restat, + pool: action.pool.as_deref(), description, }) } diff --git a/src/ninja_gen/error.rs b/src/ninja_gen/error.rs index adc344ec1..29fc31fdf 100644 --- a/src/ninja_gen/error.rs +++ b/src/ninja_gen/error.rs @@ -15,6 +15,24 @@ pub enum NinjaGenError { /// Localized error message. message: LocalizedMessage, }, + /// A dependency-only edge cannot request Ninja's console pool. + #[error("{message}")] + ConsoleDependencyOnly { + /// First explicit output identifying the offending build edge. + output: String, + /// Localized error message. + message: LocalizedMessage, + }, + /// A console edge conflicts with the pool selected by its action. + #[error("{message}")] + ConsolePoolConflict { + /// First explicit output identifying the offending build edge. + output: String, + /// Action pool that conflicts with console scheduling. + pool: String, + /// Localized error message. + message: LocalizedMessage, + }, /// An action built outside manifest deserialization has no command entries. #[error("command-list action {action_index} has no command entries")] EmptyCommandRecipe { diff --git a/src/ninja_gen/mod.rs b/src/ninja_gen/mod.rs index f2ee00e90..ef304b15a 100644 --- a/src/ninja_gen/mod.rs +++ b/src/ninja_gen/mod.rs @@ -28,6 +28,7 @@ mod explicit_shell; mod escape; mod recipe_shell; mod validation; +use validation::validate_console_scheduling; pub use crate::recipe_shell::RecipeShell; pub use error::NinjaGenError; @@ -55,6 +56,9 @@ macro_rules! write_flag { }; } +/// Name Ninja's built-in pool shared by edge rendering and validation. +const CONSOLE_POOL: &str = "console"; + mod display_edge; pub(crate) use display_edge::DisplayEdge; mod edge_metadata; @@ -68,7 +72,7 @@ use order::{ordered_default_targets, ordered_edges}; /// # Examples /// ``` /// use netsuke::ast::{CommandIsolation, Recipe}; -/// use netsuke::ir::{Action, BuildEdge, BuildGraph}; +/// use netsuke::ir::{Action, BuildEdge, BuildGraph, EdgeOutputVisibility}; /// use camino::Utf8PathBuf; /// let mut graph = BuildGraph::default(); /// graph.actions.insert("a".into(), Action { @@ -85,7 +89,8 @@ use order::{ordered_default_targets, ordered_edges}; /// dependency_order: netsuke::ir::DependencyOrder::Parallel, /// explicit_outputs: vec![Utf8PathBuf::from("out")], /// implicit_outputs: Vec::new(), order_only_deps: Vec::new(), -/// progress_label: None, phony: false, always: false +/// progress_label: None, phony: false, always: false, +/// output_visibility: EdgeOutputVisibility::Buffered /// }).expect("test graph output aliases must be unique"); /// # let result: Result<(), netsuke::ninja_gen::NinjaGenError> = (|| { /// let text = netsuke::ninja_gen::generate(&graph)?; @@ -104,7 +109,9 @@ use order::{ordered_default_targets, ordered_edges}; /// entry contains a Ninja control character, isolated mode is attached to a /// non-list command, a PowerShell recipe exceeds its size limit, an isolated /// PowerShell entry exceeds Windows' command-line limit, a graph path uses -/// Netsuke's reserved serial-ordering namespace, or writing to the output fails. +/// Netsuke's reserved serial-ordering namespace, a console edge uses a +/// dependency-only action or conflicts with its custom pool, or writing to the +/// output fails. pub fn generate(graph: &BuildGraph) -> Result { let mut out = String::new(); generate_into(graph, &mut out)?; @@ -116,7 +123,7 @@ pub fn generate(graph: &BuildGraph) -> Result { /// # Examples /// ``` /// use netsuke::ast::{CommandIsolation, Recipe}; -/// use netsuke::ir::{Action, BuildEdge, BuildGraph}; +/// use netsuke::ir::{Action, BuildEdge, BuildGraph, EdgeOutputVisibility}; /// use camino::Utf8PathBuf; /// let mut graph = BuildGraph::default(); /// graph.actions.insert("a".into(), Action { @@ -133,7 +140,8 @@ pub fn generate(graph: &BuildGraph) -> Result { /// dependency_order: netsuke::ir::DependencyOrder::Parallel, /// explicit_outputs: vec![Utf8PathBuf::from("out")], /// implicit_outputs: Vec::new(), order_only_deps: Vec::new(), -/// progress_label: None, phony: false, always: false +/// progress_label: None, phony: false, always: false, +/// output_visibility: EdgeOutputVisibility::Buffered /// }).expect("test graph output aliases must be unique"); /// let mut out = String::new(); /// # let result: Result<(), netsuke::ninja_gen::NinjaGenError> = (|| { @@ -153,7 +161,9 @@ pub fn generate(graph: &BuildGraph) -> Result { /// entry contains a Ninja control character, isolated mode is attached to a /// non-list command, a PowerShell recipe exceeds its size limit, an isolated /// PowerShell entry exceeds Windows' command-line limit, a graph path uses -/// Netsuke's reserved serial-ordering namespace, or writing to the output fails. +/// Netsuke's reserved serial-ordering namespace, a console edge uses a +/// dependency-only action or conflicts with its custom pool, or writing to the +/// output fails. pub fn generate_into(graph: &BuildGraph, out: &mut W) -> Result<(), NinjaGenError> { generate_into_with_shell(graph, out, RecipeShell::host_default()) } @@ -172,6 +182,7 @@ pub(crate) fn generate_into_with_shell( let localizer = localization::localizer(); reject_unsupported_path_characters(graph)?; reject_reserved_paths(graph)?; + validate_console_scheduling(graph)?; if graph_requires_dyndep(graph) { return Err(NinjaGenError::DyndepFilesRequired { message: localization::message(keys::NINJA_GEN_DYNDEP_FILES_REQUIRED), @@ -187,6 +198,7 @@ pub(crate) fn generate_into_with_shell( edge, action_name: metadata.action_name, action_restat: metadata.restat, + action_pool: metadata.pool, implicit_deps: &edge.implicit_deps, description: metadata.description.as_ref(), } diff --git a/src/ninja_gen/progress_label.rs b/src/ninja_gen/progress_label.rs index 949256a60..5401769ec 100644 --- a/src/ninja_gen/progress_label.rs +++ b/src/ninja_gen/progress_label.rs @@ -121,6 +121,7 @@ mod tests { progress_label: progress_label.map(str::to_owned), phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), } } diff --git a/src/ninja_gen/property_tests/console.rs b/src/ninja_gen/property_tests/console.rs new file mode 100644 index 000000000..5d8090142 --- /dev/null +++ b/src/ninja_gen/property_tests/console.rs @@ -0,0 +1,181 @@ +//! Check console scheduling across generated user and serial edges. +//! +//! Each property generates a serial parent edge plus parallel sibling edges +//! and inspects the exact `pool` binding of every emitted statement. Coverage +//! includes the inherited case where the shared action already selects the +//! console pool, so a generated edge must clear it rather than silently +//! inherit console scheduling. + +use super::{aggregate_gates, generate_bundle, serial_graph}; +use crate::ir::{BuildGraph, DependencyOrder, EdgeOutputVisibility}; +use camino::{Utf8Path, Utf8PathBuf}; +use proptest::prelude::*; +use test_support::ninja_edge::edge_pool_bindings; + +/// Return the exact edge-local bindings a console state must emit. +/// +/// A console edge selects the pool, an edge that inherits a console action +/// pool without opting in clears it with an empty binding, and an edge whose +/// action carries no pool emits nothing at all. +fn expected_pool_bindings(console: bool, inherits_console_pool: bool) -> Vec { + match (console, inherits_console_pool) { + (true, _) => vec!["pool = console".to_owned()], + (false, true) => vec!["pool =".to_owned()], + (false, false) => Vec::new(), + } +} + +/// Build the serial fixture, optionally inheriting a console action pool. +fn console_graph( + dependencies: Vec, + inherits_console_pool: bool, +) -> Result { + let mut graph = serial_graph(dependencies); + if inherits_console_pool { + graph + .actions + .get_mut("a") + .ok_or_else(|| TestCaseError::fail("fixture action must exist"))? + .pool = Some("console".into()); + } + Ok(graph) +} + +/// Generate a bundle whose parent and sibling edges share one action. +fn generate_console_bundle( + selections: &[bool], + parent_console: bool, + inherits_console_pool: bool, +) -> Result { + let outputs: Vec<_> = (0..selections.len()) + .map(|index| Utf8PathBuf::from(format!("dep{index}"))) + .collect(); + let mut graph = console_graph(outputs.clone(), inherits_console_pool)?; + let mut parent = graph + .target_for_output(Utf8Path::new("aggregate")) + .map(|(_, edge)| edge.clone()) + .ok_or_else(|| TestCaseError::fail("serial parent must exist"))?; + parent.output_visibility = if parent_console { + EdgeOutputVisibility::Immediate + } else { + EdgeOutputVisibility::Buffered + }; + if !graph.replace_edge_for_output(Utf8Path::new("aggregate"), parent.clone()) { + return Err(TestCaseError::fail("serial parent must be replaceable")); + } + + for (output, selected) in outputs.iter().zip(selections) { + let mut edge = parent.clone(); + edge.explicit_outputs = vec![output.clone()]; + edge.implicit_deps.clear(); + edge.dependency_order = DependencyOrder::Parallel; + edge.output_visibility = if *selected { + EdgeOutputVisibility::Immediate + } else { + EdgeOutputVisibility::Buffered + }; + graph + .insert_edge(edge) + .map_err(|error| TestCaseError::fail(error.to_string()))?; + } + + generate_bundle(&graph).map_err(|error| TestCaseError::fail(error.to_string())) +} + +/// Assert every user, synthetic gate, and sidecar binding for one case. +fn assert_console_bindings( + bundle: &super::GeneratedNinja, + selections: &[bool], + parent_console: bool, + inherits_console_pool: bool, +) -> Result<(), TestCaseError> { + let file = bundle.build_file(); + let mut selected_count = usize::from(parent_console); + + for (index, selected) in selections.iter().enumerate() { + let output = format!("dep{index}"); + let bindings = edge_pool_bindings(file, &output) + .map_err(|error| TestCaseError::fail(error.to_string()))?; + prop_assert_eq!( + &bindings, + &expected_pool_bindings(*selected, inherits_console_pool), + "edge {} must carry exactly its own console binding", + output + ); + selected_count += usize::from(*selected); + } + + let parent_bindings = edge_pool_bindings(file, "aggregate") + .map_err(|error| TestCaseError::fail(error.to_string()))?; + prop_assert_eq!( + &parent_bindings, + &expected_pool_bindings(parent_console, inherits_console_pool), + "the serial parent must carry exactly its own console binding" + ); + + let gates = + aggregate_gates(file).ok_or_else(|| TestCaseError::fail("aggregate gate list missing"))?; + for gate in gates { + let bindings = edge_pool_bindings(file, gate) + .map_err(|error| TestCaseError::fail(error.to_string()))?; + prop_assert!( + bindings.is_empty(), + "synthetic gate {gate} must carry no pool binding: {bindings:?}" + ); + } + + prop_assert_eq!(bundle.console_edge_count(), selected_count); + // A console action also renders one `pool = console` binding in its shared + // rule block, so the whole file holds one binding per console edge plus + // that single rule binding. + let rule_bindings = usize::from(inherits_console_pool); + prop_assert_eq!( + file.lines() + .filter(|line| *line == " pool = console") + .count(), + selected_count + rule_bindings, + "console bindings must be one per console edge plus the shared rule" + ); + prop_assert!( + bundle + .dyndep_files() + .iter() + .all(|sidecar| !sidecar.content().contains("pool")) + ); + Ok(()) +} + +/// Generate sibling console selections that always cover both states. +/// +/// Two fixed states lead the list so every case pairs a console sibling with a +/// non-console one, which is what distinguishes a selected binding from a +/// cleared one. The rest of the list stays arbitrary. +fn console_selections() -> impl Strategy> { + prop::collection::vec(any::(), 0..5).prop_map(|mut tail| { + tail.insert(0, true); + tail.insert(1, false); + tail + }) +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 64, .. ProptestConfig::default() })] + + #[test] + fn console_bindings_match_user_edges( + selections in console_selections(), + parent_console in any::(), + ) { + let bundle = generate_console_bundle(&selections, parent_console, false)?; + assert_console_bindings(&bundle, &selections, parent_console, false)?; + } + + #[test] + fn inherited_console_pool_is_cleared_on_non_console_edges( + selections in console_selections(), + parent_console in any::(), + ) { + let bundle = generate_console_bundle(&selections, parent_console, true)?; + assert_console_bindings(&bundle, &selections, parent_console, true)?; + } +} diff --git a/src/ninja_gen/property_tests/dependency_only.rs b/src/ninja_gen/property_tests/dependency_only.rs index bbf35b60c..ec8671f1b 100644 --- a/src/ninja_gen/property_tests/dependency_only.rs +++ b/src/ninja_gen/property_tests/dependency_only.rs @@ -42,6 +42,7 @@ fn dependency_only_graph(dependencies: &[String], order: DependencyOrder) -> Bui progress_label: None, phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }) .is_ok(), "test graph output aliases must be unique", diff --git a/src/ninja_gen/property_tests/edge_strategy.rs b/src/ninja_gen/property_tests/edge_strategy.rs new file mode 100644 index 000000000..f6cadda26 --- /dev/null +++ b/src/ninja_gen/property_tests/edge_strategy.rs @@ -0,0 +1,50 @@ +//! Proptest strategy for generated Ninja build edges. +//! +//! The separator properties in the parent module generate edges with varied +//! input, implicit, and order-only dependency counts. Generating them here +//! keeps the parent module inside the repository's module-size limit. + +use proptest::prelude::*; +use test_support::ninja_gen::paths_strategy; + +use crate::ir::BuildEdge; + +/// Build a strategy for edges drawing dependency paths from the given ranges. +pub(super) fn edge_strategy_with_ranges( + input_range: std::ops::Range, + implicit_range: std::ops::Range, + order_only_range: std::ops::Range, +) -> impl Strategy { + ( + "[a-z][a-z0-9_]{0,8}", + paths_strategy("in", input_range), + paths_strategy("out", 1..5), + paths_strategy("iout", 0..5), + paths_strategy("imp", implicit_range), + paths_strategy("order", order_only_range), + ) + .prop_map( + |( + action_id, + inputs, + explicit_outputs, + implicit_outputs, + implicit_deps, + order_only_deps, + )| { + BuildEdge { + action_id, + inputs, + implicit_deps, + dependency_order: crate::ir::DependencyOrder::Parallel, + explicit_outputs, + implicit_outputs, + order_only_deps, + progress_label: None, + phony: false, + always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), + } + }, + ) +} diff --git a/src/ninja_gen/property_tests/mod.rs b/src/ninja_gen/property_tests/mod.rs index aa1ade048..fd4c2dc81 100644 --- a/src/ninja_gen/property_tests/mod.rs +++ b/src/ninja_gen/property_tests/mod.rs @@ -18,54 +18,20 @@ use crate::{ use camino::Utf8PathBuf; use std::collections::{HashMap, HashSet}; +mod console; mod dependency_only; +mod edge_strategy; mod ninja_oracle; +use edge_strategy::edge_strategy_with_ranges; use ninja_oracle::scalar_graph; -fn edge_strategy_with_ranges( - input_range: std::ops::Range, - implicit_range: std::ops::Range, - order_only_range: std::ops::Range, -) -> impl Strategy { - ( - "[a-z][a-z0-9_]{0,8}", - paths_strategy("in", input_range), - paths_strategy("out", 1..5), - paths_strategy("iout", 0..5), - paths_strategy("imp", implicit_range), - paths_strategy("order", order_only_range), - ) - .prop_map( - |( - action_id, - inputs, - explicit_outputs, - implicit_outputs, - implicit_deps, - order_only_deps, - )| { - BuildEdge { - action_id, - inputs, - implicit_deps, - dependency_order: crate::ir::DependencyOrder::Parallel, - explicit_outputs, - implicit_outputs, - order_only_deps, - progress_label: None, - phony: false, - always: false, - } - }, - ) -} - fn format_edge(edge: &BuildEdge) -> String { DisplayEdge { edge, action_name: &edge.action_id, action_restat: false, + action_pool: None, implicit_deps: &edge.implicit_deps, description: None, } @@ -124,6 +90,7 @@ fn serial_graph(dependencies: Vec) -> BuildGraph { progress_label: None, phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }) .is_ok(), "test graph output aliases must be unique", diff --git a/src/ninja_gen/property_tests/ninja_oracle.rs b/src/ninja_gen/property_tests/ninja_oracle.rs index 17801b875..96dbf7094 100644 --- a/src/ninja_gen/property_tests/ninja_oracle.rs +++ b/src/ninja_gen/property_tests/ninja_oracle.rs @@ -52,6 +52,7 @@ pub(super) fn scalar_graph(command: String) -> BuildGraph { progress_label: None, phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }) .is_ok(), "test graph output aliases must be unique", diff --git a/src/ninja_gen/tests/command_syntax.rs b/src/ninja_gen/tests/command_syntax.rs new file mode 100644 index 000000000..bd994650d --- /dev/null +++ b/src/ninja_gen/tests/command_syntax.rs @@ -0,0 +1,10 @@ +//! Test shell command text accepted by Ninja generation. + +use super::NamedAction; + +/// Retain support for shell command text with nested quoting and subshells. +#[test] +fn assert_shell_command_tolerates_complex_syntax() { + let command = r#"/bin/sh -c "echo 'nested quotes' && echo \"double\" && (echo subshell)""#; + NamedAction::assert_shell_command(command); +} diff --git a/src/ninja_gen/tests/console.rs b/src/ninja_gen/tests/console.rs new file mode 100644 index 000000000..6a87a6fd4 --- /dev/null +++ b/src/ninja_gen/tests/console.rs @@ -0,0 +1,129 @@ +//! Tests for edge-level Ninja console pool selection. + +use super::command_action; +use super::{NinjaGenError, generate, generate_bundle, generate_posix}; +use crate::ir::{BuildEdge, BuildGraph, DependencyOrder, EdgeOutputVisibility}; +use anyhow::{Result, ensure}; +use camino::Utf8PathBuf; +use rstest::{fixture, rstest}; + +type EdgeFactory = fn(&str, &str, bool) -> BuildEdge; + +/// Construct parallel, single-output edges for console generator tests. +#[fixture] +fn edge_factory() -> EdgeFactory { + |action_id, output, console| BuildEdge { + action_id: action_id.into(), + inputs: Vec::new(), + implicit_deps: Vec::new(), + dependency_order: DependencyOrder::Parallel, + explicit_outputs: vec![Utf8PathBuf::from(output)], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + progress_label: None, + phony: false, + always: false, + output_visibility: if console { + EdgeOutputVisibility::Immediate + } else { + EdgeOutputVisibility::Buffered + }, + } +} + +/// Emit console selection on one edge while reusing the action's rule normally. +#[rstest] +fn console_pool_is_edge_level_and_keeps_shared_action_rules( + edge_factory: EdgeFactory, +) -> Result<()> { + let mut graph = BuildGraph::default(); + graph + .actions + .insert("shared".into(), command_action("echo ready".into())); + for (output, console) in [("console.out", true), ("ordinary.out", false)] { + graph + .insert_edge(edge_factory("shared", output, console)) + .expect("test graph output aliases must be unique"); + } + + let ninja = generate_posix(&graph)?; + let expected = concat!( + "rule shared\n", + " command = echo ready\n\n", + "build console.out: shared\n", + " pool = console\n\n", + "build ordinary.out: shared\n\n", + ); + ensure!( + ninja == expected, + "expected Ninja manifest:\n{expected}\nactual:\n{ninja}" + ); + Ok(()) +} + +/// Clear rule-level console scheduling from an edge without the opt-in. +#[rstest] +fn ordinary_edge_clears_a_shared_console_action_pool(edge_factory: EdgeFactory) -> Result<()> { + let mut graph = BuildGraph::default(); + let mut action = command_action("echo ready".into()); + action.pool = Some("console".into()); + graph.actions.insert("shared".into(), action); + for (output, console) in [("console.out", true), ("ordinary.out", false)] { + graph + .insert_edge(edge_factory("shared", output, console)) + .expect("test graph output aliases must be unique"); + } + + let ninja = generate_posix(&graph)?; + ensure!( + ninja.contains("pool = console\n\nbuild console.out: shared\n pool = console\n"), + "selected edge must retain console scheduling:\n{ninja}" + ); + ensure!( + ninja.contains("build ordinary.out: shared\n pool =\n\n"), + "ordinary edge must clear the action rule's console pool:\n{ninja}" + ); + Ok(()) +} + +/// Accept the redundant combination of an edge flag and `pool = console` metadata. +#[rstest] +fn console_pool_accepts_matching_action_pool(edge_factory: EdgeFactory) -> Result<()> { + let mut graph = BuildGraph::default(); + let mut action = command_action("echo ready".into()); + action.pool = Some("console".into()); + graph.actions.insert("shared".into(), action); + graph + .insert_edge(edge_factory("shared", "console.out", true)) + .expect("test graph output aliases must be unique"); + + let ninja = generate_posix(&graph)?; + ensure!( + ninja.contains(" pool = console\n\nbuild console.out: shared\n pool = console\n\n"), + "matching action and edge pools must both be accepted:\n{ninja}" + ); + Ok(()) +} + +/// Preserve the existing missing-action error when a console edge has no action. +#[rstest] +fn console_validation_leaves_missing_actions_to_generation( + edge_factory: EdgeFactory, +) -> Result<()> { + let mut graph = BuildGraph::default(); + graph + .insert_edge(edge_factory("missing", "console.out", true)) + .expect("test graph output aliases must be unique"); + + let generate_error = generate(&graph).expect_err("missing action must remain an error"); + ensure!( + matches!(generate_error, NinjaGenError::MissingAction { ref id, .. } if id == "missing"), + "unexpected direct generation error: {generate_error:?}" + ); + let bundle_error = generate_bundle(&graph).expect_err("missing action must remain an error"); + ensure!( + matches!(bundle_error, NinjaGenError::MissingAction { ref id, .. } if id == "missing"), + "unexpected bundle generation error: {bundle_error:?}" + ); + Ok(()) +} diff --git a/src/ninja_gen/tests/mod.rs b/src/ninja_gen/tests/mod.rs index e977094cd..08ca8bfad 100644 --- a/src/ninja_gen/tests/mod.rs +++ b/src/ninja_gen/tests/mod.rs @@ -37,6 +37,7 @@ fn generate_simple_ninja() -> Result<()> { progress_label: None, phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert("a".into(), action); @@ -73,6 +74,7 @@ fn string_generation_apis_reject_reserved_paths() -> Result<()> { progress_label: None, phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert("reserved".into(), action); @@ -174,6 +176,7 @@ fn generate_script_ninja_round_trips() -> Result<()> { progress_label: None, phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert("a".into(), action); @@ -210,6 +213,7 @@ fn generate_command_list_ninja_joins_a_fail_fast_chain() -> Result<()> { progress_label: None, phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert("a".into(), action); @@ -330,11 +334,6 @@ fn unsafe_command_list_entries_return_typed_generation_errors( ); } -#[test] -fn assert_shell_command_tolerates_complex_syntax() { - let command = r#"/bin/sh -c "echo 'nested quotes' && echo \"double\" && (echo subshell)""#; - NamedAction::assert_shell_command(command); -} /// Generate Ninja text using the explicit POSIX compatibility renderer. fn generate_posix(graph: &BuildGraph) -> Result { let mut ninja = String::new(); @@ -343,6 +342,8 @@ fn generate_posix(graph: &BuildGraph) -> Result { } mod bash_binding; +mod command_syntax; +mod console; mod isolated_command_list; #[path = "legacy_shell.rs"] mod power_shell; diff --git a/src/ninja_gen/validation.rs b/src/ninja_gen/validation.rs index f6e833085..a781f6293 100644 --- a/src/ninja_gen/validation.rs +++ b/src/ninja_gen/validation.rs @@ -1,8 +1,61 @@ //! Validate recipes and metadata before Ninja rendering. use super::command_list::{CommandListEntry, CommandListEntryError, command_list_entry_error}; -use super::{NinjaGenError, RecipeShell, escape::validate_ninja_value}; +use super::{CONSOLE_POOL, NinjaGenError, RecipeShell, escape::validate_ninja_value}; use crate::ast::{CommandIsolation, Recipe, StringOrList}; +use crate::ir::{Action, BuildEdge, BuildGraph}; +use crate::localization::{self, keys}; + +/// Reject console scheduling that conflicts with an edge's action. +/// +/// # Errors +/// +/// Returns an error when a console edge uses a dependency-only recipe or +/// conflicts with an action's custom pool. +pub(super) fn validate_console_scheduling(graph: &BuildGraph) -> Result<(), NinjaGenError> { + for edge in graph + .edges() + .filter(|edge| edge.output_visibility == crate::ir::EdgeOutputVisibility::Immediate) + { + if let Some(action) = graph.actions.get(&edge.action_id) { + validate_console_edge(edge, action)?; + } + } + Ok(()) +} + +/// Reject a console edge whose action conflicts with console scheduling. +/// +/// # Errors +/// +/// Returns an error for dependency-only recipes, conflicting pools, or invalid +/// Ninja pool values. +fn validate_console_edge(edge: &BuildEdge, action: &Action) -> Result<(), NinjaGenError> { + let output = edge + .explicit_outputs + .first() + .map_or_else(|| "".to_owned(), ToString::to_string); + if action.recipe.is_dependency_only() { + return Err(NinjaGenError::ConsoleDependencyOnly { + message: localization::message(keys::NINJA_GEN_CONSOLE_DEPENDENCY_ONLY) + .with_arg("output", &output), + output, + }); + } + if let Some(pool) = action.pool.as_deref() { + validate_ninja_value(pool)?; + if pool != CONSOLE_POOL { + return Err(NinjaGenError::ConsolePoolConflict { + message: localization::message(keys::NINJA_GEN_CONSOLE_POOL_CONFLICT) + .with_arg("output", &output) + .with_arg("pool", pool), + output, + pool: pool.to_owned(), + }); + } + } + Ok(()) +} /// Reject recipes the generated shell cannot execute with stable semantics. pub(super) fn validate_action_recipe( @@ -104,9 +157,11 @@ pub(super) fn validate_action_metadata(action: &crate::ir::Action) -> Result<(), mod tests { //! Verifies shell-specific recipe validation boundaries. - use super::{NinjaGenError, RecipeShell, validate_action_recipe}; + use super::{NinjaGenError, RecipeShell, validate_action_recipe, validate_console_scheduling}; use crate::ast::{CommandIsolation, Recipe, StringOrList}; - use crate::ir::Action; + use crate::ir::{Action, BuildEdge, BuildGraph, DependencyOrder}; + use crate::ninja_gen::{generate, generate_bundle}; + use camino::Utf8PathBuf; /// Construct one action with a supplied command recipe for validation tests. fn command_action(command: StringOrList) -> Action { @@ -123,6 +178,38 @@ mod tests { } } + /// Build one console edge whose recipe and pool can vary across cases. + fn console_graph(recipe: Recipe, pool: Option<&str>) -> BuildGraph { + let mut graph = BuildGraph::default(); + graph.actions.insert( + "console-action".into(), + Action { + recipe, + description: None, + depfile: None, + deps_format: None, + pool: pool.map(str::to_owned), + restat: false, + }, + ); + graph + .insert_edge(BuildEdge { + action_id: "console-action".into(), + inputs: Vec::new(), + implicit_deps: Vec::new(), + dependency_order: DependencyOrder::Parallel, + explicit_outputs: vec![Utf8PathBuf::from("console.out")], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + progress_label: None, + phony: false, + always: false, + output_visibility: crate::ir::EdgeOutputVisibility::Immediate, + }) + .expect("test graph output aliases must be unique"); + graph + } + /// Accept multi-line scalar text because PowerShell receives it through its transport. #[test] fn power_shell_accepts_a_multi_line_scalar_command() { @@ -174,4 +261,110 @@ mod tests { Err(NinjaGenError::InvalidCommandIsolation { action_index: 1 }) )); } + + /// Reject console scheduling when the action is only a dependency marker. + #[test] + fn console_edges_reject_dependency_only_recipes() { + let graph = console_graph( + Recipe::Command { + command: StringOrList::Empty, + isolation: CommandIsolation::Shared, + }, + None, + ); + let error = validate_console_scheduling(&graph) + .expect_err("dependency-only console edge must fail validation"); + assert!(error.to_string().contains("console.out")); + assert!(matches!( + error, + NinjaGenError::ConsoleDependencyOnly { output, .. } if output == "console.out" + )); + } + + /// Reject custom action pools when the edge independently requests console scheduling. + #[test] + fn console_edges_reject_conflicting_action_pools() { + let graph = console_graph( + Recipe::Command { + command: "echo ready".into(), + isolation: CommandIsolation::Shared, + }, + Some("link_pool"), + ); + let error = validate_console_scheduling(&graph) + .expect_err("custom pool conflict must fail validation"); + let rendered = error.to_string(); + assert!(rendered.contains("console.out")); + assert!(rendered.contains("link_pool")); + assert!(matches!( + error, + NinjaGenError::ConsolePoolConflict { output, pool, .. } + if output == "console.out" && pool == "link_pool" + )); + } + + /// Accept an explicit `console` action pool when it matches the edge selection. + #[test] + fn console_edges_accept_matching_action_pools() { + let graph = console_graph( + Recipe::Command { + command: "echo ready".into(), + isolation: CommandIsolation::Shared, + }, + Some("console"), + ); + assert!(validate_console_scheduling(&graph).is_ok()); + } + + /// Return the same localized scheduling errors from direct and bundled generation. + #[test] + fn direct_and_bundle_generators_reject_invalid_console_scheduling() { + let dependency_only = console_graph( + Recipe::Command { + command: StringOrList::Empty, + isolation: CommandIsolation::Shared, + }, + None, + ); + let direct_error = generate(&dependency_only) + .expect_err("direct generation must reject dependency-only console edges"); + assert!(direct_error.to_string().contains("console.out")); + assert!(matches!( + direct_error, + NinjaGenError::ConsoleDependencyOnly { output, .. } if output == "console.out" + )); + let bundle_error = generate_bundle(&dependency_only) + .expect_err("bundle generation must reject dependency-only console edges"); + assert!(bundle_error.to_string().contains("console.out")); + assert!(matches!( + bundle_error, + NinjaGenError::ConsoleDependencyOnly { output, .. } if output == "console.out" + )); + + let pool_conflict = console_graph( + Recipe::Command { + command: "echo ready".into(), + isolation: CommandIsolation::Shared, + }, + Some("link_pool"), + ); + let direct_pool_error = generate(&pool_conflict) + .expect_err("direct generation must reject console pool conflicts"); + assert!(direct_pool_error.to_string().contains("console.out")); + assert!(direct_pool_error.to_string().contains("link_pool")); + assert!(matches!( + direct_pool_error, + NinjaGenError::ConsolePoolConflict { output, pool, .. } + if output == "console.out" && pool == "link_pool" + )); + let bundle_pool_error = generate_bundle(&pool_conflict) + .expect_err("bundle generation must reject console pool conflicts"); + assert!(bundle_pool_error.to_string().contains("console.out")); + assert!(bundle_pool_error.to_string().contains("link_pool")); + assert!(matches!( + bundle_pool_error, + NinjaGenError::ConsolePoolConflict { output, pool, .. } + if output == "console.out" && pool == "link_pool" + )); + } } diff --git a/src/observability/recorder/console.rs b/src/observability/recorder/console.rs new file mode 100644 index 000000000..4619d7bc3 --- /dev/null +++ b/src/observability/recorder/console.rs @@ -0,0 +1,66 @@ +//! Admit only the fixed console-scheduling generation and build metric series. + +use super::labels::exact_labels; +use metrics::Key; +use metrics_util::MetricKind; +use netsuke::runner::{ + BUNDLE_CONSOLE_EDGES_TOTAL, BUNDLE_GENERATION_DURATION, BUNDLE_GENERATIONS_TOTAL, + NINJA_BUILD_CONSOLE_EDGES_TOTAL, NINJA_BUILD_EXECUTION_DURATION, NINJA_BUILD_EXECUTIONS_TOTAL, +}; + +/// Bound scheduling labels to the presence of selected console edges. +const SCHEDULING: &[&str] = &["enabled", "disabled"]; +/// Bound operation outcomes to completed successes and errors. +const OUTCOMES: &[&str] = &["success", "error"]; +/// Bound Ninja invocation errors without retaining process details. +const BUILD_ERRORS: &[&str] = &["none", "io"]; +/// Bound bundle errors to the generator's fixed classification vocabulary. +const GENERATION_ERRORS: &[&str] = &[ + "none", + "missing_action", + "console_dependency_only", + "console_pool_conflict", + "command_list", + "unsafe_ninja_value", + "power_shell_recipe_too_large", + "format", + "dyndep_files_required", + "reserved_output_path", + "unsupported_path_character", + "unsafe_ninja_path", +]; + +/// Report whether a description belongs to the console-scheduling metric set. +pub(super) fn is_console_metric(name: &str) -> bool { + matches!( + name, + BUNDLE_CONSOLE_EDGES_TOTAL + | BUNDLE_GENERATION_DURATION + | BUNDLE_GENERATIONS_TOTAL + | NINJA_BUILD_CONSOLE_EDGES_TOTAL + | NINJA_BUILD_EXECUTION_DURATION + | NINJA_BUILD_EXECUTIONS_TOTAL + ) +} + +/// Admit only the metric kind and exact finite labels emitted by the runner. +pub(super) fn accepts_registration(key: &Key, kind: MetricKind) -> bool { + let errors = match (key.name(), kind) { + (BUNDLE_CONSOLE_EDGES_TOTAL | NINJA_BUILD_CONSOLE_EDGES_TOTAL, MetricKind::Counter) => { + return exact_labels(key, &[("outcome", OUTCOMES)]); + } + (BUNDLE_GENERATIONS_TOTAL, MetricKind::Counter) + | (BUNDLE_GENERATION_DURATION, MetricKind::Histogram) => GENERATION_ERRORS, + (NINJA_BUILD_EXECUTIONS_TOTAL, MetricKind::Counter) + | (NINJA_BUILD_EXECUTION_DURATION, MetricKind::Histogram) => BUILD_ERRORS, + _ => return false, + }; + exact_labels( + key, + &[ + ("outcome", OUTCOMES), + ("error_category", errors), + ("console_scheduling", SCHEDULING), + ], + ) +} diff --git a/src/observability/recorder/mod.rs b/src/observability/recorder/mod.rs index 500cc4590..13bdeb6a4 100644 --- a/src/observability/recorder/mod.rs +++ b/src/observability/recorder/mod.rs @@ -9,6 +9,7 @@ //! retained snapshot. mod check; +mod console; mod labels; #[cfg(feature = "lint")] @@ -33,7 +34,8 @@ use netsuke::{ }, manifest::{ENV_LOOKUP_OUTCOME_VALUES, ENV_LOOKUP_TOTAL}, runner::{ - BASH_PREFLIGHT_TOTAL, LEGACY_RECIPE_EXECUTION_DURATION, LEGACY_RECIPE_EXECUTIONS_TOTAL, + BASH_PREFLIGHT_TOTAL, FORWARD_FAILURE_LABELS, FORWARD_FAILURES_TOTAL, + LEGACY_RECIPE_EXECUTION_DURATION, LEGACY_RECIPE_EXECUTIONS_TOTAL, NINJA_STATUS_OVERSIZED_LINES_TOTAL, RECIPE_SHELL_RESOLUTIONS_TOTAL, }, stdlib::{ @@ -153,8 +155,10 @@ impl ConfigMetricsRecorder { | WHICH_RESOLUTION_TOTAL | MANIFEST_STRUCTURES_TOTAL | NINJA_STATUS_OVERSIZED_LINES_TOTAL + | FORWARD_FAILURES_TOTAL | SHELL_QUOTE_DIALECT_TOTAL ) || is_check_metric(name) + || console::is_console_metric(name) } /// Admit exact bounded counter series by their registered name. @@ -204,6 +208,7 @@ impl ConfigMetricsRecorder { ("failure_category", &LEGACY_RECIPE_FAILURE_CATEGORIES), ], ), + FORWARD_FAILURES_TOTAL => exact_labels(key, &FORWARD_FAILURE_LABELS), FILTERED_TARGETS_TOTAL | FILTERED_ACTIONS_TOTAL | OMITTED_FILTERED_ENTRIES_TOTAL @@ -216,7 +221,7 @@ impl ConfigMetricsRecorder { | SHELL_QUOTE_DIALECT_TOTAL => accepts_stdlib_counter_registration(key), #[cfg(feature = "lint")] CHECK_TOTAL => exact_labels(key, &[(OUTCOME_LABEL, &CHECK_OUTCOMES)]), - _ => false, + _ => console::accepts_registration(key, MetricKind::Counter), } } @@ -238,7 +243,7 @@ impl ConfigMetricsRecorder { ), #[cfg(feature = "lint")] CHECK_DURATION => exact_labels(key, &[(OUTCOME_LABEL, &CHECK_OUTCOMES)]), - _ => false, + _ => console::accepts_registration(key, MetricKind::Histogram), } } diff --git a/src/observability/recorder/tests/console_tests.rs b/src/observability/recorder/tests/console_tests.rs new file mode 100644 index 000000000..635752331 --- /dev/null +++ b/src/observability/recorder/tests/console_tests.rs @@ -0,0 +1,89 @@ +//! Verify production snapshots retain only bounded console-scheduling series. + +use super::{ConfigMetricsRecorder, SnapshotEntry}; +use metrics::{counter, gauge, histogram}; +use metrics_util::{MetricKind, debugging::DebugValue}; +use netsuke::runner::{ + BUNDLE_CONSOLE_EDGES_TOTAL, BUNDLE_GENERATION_DURATION, BUNDLE_GENERATIONS_TOTAL, + NINJA_BUILD_CONSOLE_EDGES_TOTAL, NINJA_BUILD_EXECUTION_DURATION, NINJA_BUILD_EXECUTIONS_TOTAL, +}; + +/// Emit valid series and adversarial variants through the application recorder. +fn record_console_series() { + for name in [BUNDLE_GENERATIONS_TOTAL, NINJA_BUILD_EXECUTIONS_TOTAL] { + counter!(name, "outcome" => "success", "error_category" => "none", "console_scheduling" => "enabled").increment(1); + counter!(name, "console_scheduling" => "private-output", "outcome" => "success", "error_category" => "none").increment(1); + counter!(name, "console_scheduling" => "enabled", "outcome" => "success", "error_category" => "none", "output" => "private-output").increment(1); + counter!(name, "outcome" => "success", "error_category" => "none").increment(1); + gauge!(name, "console_scheduling" => "enabled", "outcome" => "success", "error_category" => "none").set(1.0); + } + for name in [BUNDLE_GENERATION_DURATION, NINJA_BUILD_EXECUTION_DURATION] { + histogram!(name, "outcome" => "error", "error_category" => if name == BUNDLE_GENERATION_DURATION { "console_pool_conflict" } else { "io" }, "console_scheduling" => "disabled").record(0.25); + histogram!(name, "console_scheduling" => "disabled", "outcome" => "error", "error_category" => "private-process-error").record(0.25); + } + for name in [BUNDLE_CONSOLE_EDGES_TOTAL, NINJA_BUILD_CONSOLE_EDGES_TOTAL] { + counter!(name, "outcome" => "success").increment(2); + counter!(name, "outcome" => "success", "edge" => "private-output").increment(2); + counter!(name, "outcome" => "private-outcome").increment(2); + } +} + +/// Find a retained counter with its exact expected value. +fn has_counter(snapshot: &[SnapshotEntry], name: &str, expected: u64) -> bool { + snapshot.iter().any(|entry| { + entry.0.kind() == MetricKind::Counter + && entry.0.key().name() == name + && matches!(&entry.3, DebugValue::Counter(value) if *value == expected) + }) +} + +/// Find a retained histogram containing the single expected sample. +fn has_single_sample_histogram(snapshot: &[SnapshotEntry], name: &str) -> bool { + snapshot.iter().any(|entry| { + entry.0.kind() == MetricKind::Histogram + && entry.0.key().name() == name + && matches!(&entry.3, DebugValue::Histogram(samples) if samples.len() == 1) + }) +} + +/// Check that the retained snapshot contains only bounded console series. +fn assert_bounded_console_snapshot(snapshot: &[SnapshotEntry]) { + assert_eq!( + snapshot.len(), + 6, + "only the six exact bounded series survive: {snapshot:?}" + ); + for (name, expected) in [ + (BUNDLE_GENERATIONS_TOTAL, 1), + (NINJA_BUILD_EXECUTIONS_TOTAL, 1), + (BUNDLE_CONSOLE_EDGES_TOTAL, 2), + (NINJA_BUILD_CONSOLE_EDGES_TOTAL, 2), + ] { + assert!( + has_counter(snapshot, name, expected), + "snapshot must retain {name} with value {expected}: {snapshot:?}" + ); + } + for name in [BUNDLE_GENERATION_DURATION, NINJA_BUILD_EXECUTION_DURATION] { + assert!( + has_single_sample_histogram(snapshot, name), + "snapshot must retain one sample for {name}: {snapshot:?}" + ); + } + assert!(snapshot.iter().all(|entry| { + entry + .0 + .key() + .labels() + .all(|label| !label.value().contains("private")) + })); +} + +#[test] +fn recorder_retains_exact_bounded_console_scheduling_series() { + let recorder = ConfigMetricsRecorder::new(); + let snapshotter = recorder.snapshotter(); + metrics::with_local_recorder(&recorder, record_console_series); + let snapshot = snapshotter.snapshot().into_vec(); + assert_bounded_console_snapshot(&snapshot); +} diff --git a/src/observability/recorder/tests/forwarding_tests.rs b/src/observability/recorder/tests/forwarding_tests.rs new file mode 100644 index 000000000..faa64a06f --- /dev/null +++ b/src/observability/recorder/tests/forwarding_tests.rs @@ -0,0 +1,130 @@ +//! Verify the bounded child-output forwarding failure counter series. +//! +//! The forwarding helper records `stream` and `failure_kind` from closed +//! vocabularies, so a series the recorder rejects is silently dropped: the +//! counter handle becomes a noop and no other test notices. These cases drive +//! the real filter through the production recorder, then assert both the +//! admitted combinations and the rejected widening attempts. + +use super::{ConfigMetricsRecorder, SnapshotEntry}; +use metrics::counter; +use metrics_util::{MetricKind, debugging::DebugValue}; +use netsuke::runner::{FORWARD_FAILURE_LABELS, FORWARD_FAILURES_TOTAL}; + +/// Return the retained total for one exact `(stream, failure_kind)` series. +fn retained_count(snapshot: &[SnapshotEntry], stream: &str, failure_kind: &str) -> u64 { + snapshot + .iter() + .find_map(|entry| { + if entry.0.kind() != MetricKind::Counter + || entry.0.key().name() != FORWARD_FAILURES_TOTAL + { + return None; + } + let labels: Vec<_> = entry.0.key().labels().collect(); + let is_exact = labels.len() == 2 + && labels + .iter() + .any(|label| label.key() == "stream" && label.value() == stream) + && labels + .iter() + .any(|label| label.key() == "failure_kind" && label.value() == failure_kind); + match (is_exact, &entry.3) { + (true, DebugValue::Counter(count)) => Some(*count), + _ => None, + } + }) + .unwrap_or(0) +} + +/// Enumerate every series the closed vocabularies declare. +fn declared_series() -> impl Iterator { + let [streams, failure_kinds] = FORWARD_FAILURE_LABELS.map(|(_, values)| values); + streams + .iter() + .flat_map(move |stream| failure_kinds.iter().map(move |kind| (*stream, *kind))) +} + +/// Count the series the closed vocabularies declare. +fn declared_series_count() -> usize { + let [streams, failure_kinds] = FORWARD_FAILURE_LABELS.map(|(_, values)| values); + streams.len() * failure_kinds.len() +} + +/// Record one increment for every series the forwarding helper can emit. +fn record_declared_series(recorder: &ConfigMetricsRecorder) { + let [stream_label, failure_kind_label] = FORWARD_FAILURE_LABELS.map(|(label, _)| label); + metrics::with_local_recorder(recorder, || { + for (stream, failure_kind) in declared_series() { + counter!( + FORWARD_FAILURES_TOTAL, + stream_label => stream, + failure_kind_label => failure_kind, + ) + .increment(1); + } + }); +} + +/// Assert every declared series was retained exactly once. +fn assert_every_declared_series_retained(snapshot: &[SnapshotEntry]) { + for (stream, failure_kind) in declared_series() { + assert_eq!( + retained_count(snapshot, stream, failure_kind), + 1, + "missing bounded series ({stream}, {failure_kind}): {snapshot:?}" + ); + } +} + +/// Retain every combination the forwarding helper can emit. +#[test] +fn recorder_retains_every_bounded_forwarding_failure_series() { + let recorder = ConfigMetricsRecorder::new(); + record_declared_series(&recorder); + + let snapshot = recorder.snapshotter().snapshot().into_vec(); + assert_eq!( + snapshot.len(), + declared_series_count(), + "every closed stream and failure-kind pair should be retained: {snapshot:?}" + ); + assert_every_declared_series_retained(&snapshot); +} + +/// Reject series that widen either label vocabulary or add a label. +#[test] +fn recorder_rejects_widened_forwarding_failure_series() { + let recorder = ConfigMetricsRecorder::new(); + let snapshotter = recorder.snapshotter(); + + metrics::with_local_recorder(&recorder, || { + counter!( + FORWARD_FAILURES_TOTAL, + "stream" => "private-output", + "failure_kind" => "read", + ) + .increment(1); + counter!( + FORWARD_FAILURES_TOTAL, + "stream" => "stdout", + "failure_kind" => "private-output", + ) + .increment(1); + counter!( + FORWARD_FAILURES_TOTAL, + "stream" => "stdout", + "failure_kind" => "read", + "target" => "private-target", + ) + .increment(1); + counter!(FORWARD_FAILURES_TOTAL, "stream" => "stdout").increment(1); + counter!(FORWARD_FAILURES_TOTAL).increment(1); + }); + + let snapshot = snapshotter.snapshot().into_vec(); + assert!( + snapshot.is_empty(), + "unbounded forwarding-failure series must not be retained: {snapshot:?}" + ); +} diff --git a/src/observability/recorder/tests/mod.rs b/src/observability/recorder/tests/mod.rs index 1bfa9eeb6..3613e43ac 100644 --- a/src/observability/recorder/tests/mod.rs +++ b/src/observability/recorder/tests/mod.rs @@ -17,6 +17,8 @@ type SnapshotEntry = (CompositeKey, Option, Option, DebugVal /// Fixed three-label layout used by recipe-shell counters. type MetricLabels = [(&'static str, &'static str); 3]; +/// Cover retained console-scheduling generation and build metrics separately. +mod console_tests; /// Cover bounded legacy-recipe operation metric registrations separately. mod legacy_recipe_tests; @@ -29,6 +31,9 @@ mod manifest_structure_tests; /// Cover the unlabelled oversized Ninja status counter separately. mod ninja_status_tests; +/// Cover the bounded child-output forwarding failure counter separately. +mod forwarding_tests; + /// Cover the bounded manifest environment-lookup counter series separately. mod env_lookup_tests; diff --git a/src/runner/console_telemetry/mod.rs b/src/runner/console_telemetry/mod.rs new file mode 100644 index 000000000..0971df55b --- /dev/null +++ b/src/runner/console_telemetry/mod.rs @@ -0,0 +1,119 @@ +//! Record bounded console-scheduling telemetry around actual Ninja builds. +//! +//! This runner-only boundary accepts a graph aggregate and an invocation +//! closure. It emits no paths, identifiers, commands, or process error text. + +use metrics::{counter, describe_counter, describe_histogram, histogram}; +use monotony::MonotonicClock; +use std::{io, sync::Once, time::Duration}; +use tracing::field; + +/// Metric counting actual runner-owned Ninja build invocations. +pub const NINJA_BUILD_EXECUTIONS_TOTAL: &str = "netsuke_runner_ninja_build_executions_total"; +/// Metric measuring actual runner-owned Ninja build duration in seconds. +pub const NINJA_BUILD_EXECUTION_DURATION: &str = + "netsuke_runner_ninja_build_execution_duration_seconds"; +/// Metric counting selected console edges across attempted Ninja build invocations. +pub const NINJA_BUILD_CONSOLE_EDGES_TOTAL: &str = "netsuke_runner_ninja_build_console_edges_total"; + +/// Return the fixed scheduling label for one aggregate console-edge count. +pub(in crate::runner) const fn scheduling_label(console_edge_count: usize) -> &'static str { + if console_edge_count == 0 { + "disabled" + } else { + "enabled" + } +} + +/// Record the outcome and duration of one actual Ninja build invocation. +/// +/// Count configured user edges in the attempted build graph, rather than +/// inferring which edges Ninja actually executed or how long they occupied its pool. +/// +/// # Errors +/// +/// Returns the invocation's I/O error unchanged when Ninja execution fails. +pub(in crate::runner) fn instrument_ninja_build( + clock: &dyn MonotonicClock, + console_edge_count: usize, + invoke: impl FnOnce() -> io::Result, +) -> io::Result { + describe_metrics(); + let console_scheduling = scheduling_label(console_edge_count); + let span = tracing::info_span!( + "runner.ninja.build", + console_edge_count, + console_scheduling, + outcome = field::Empty, + error_category = field::Empty, + ); + let _guard = span.enter(); + let started = clock.now(); + let result = invoke(); + record_result( + &span, + console_edge_count, + clock.now().duration_since(started), + &result, + ); + result +} + +/// Emit the bounded result, duration, and configured-edge aggregate. +fn record_result( + span: &tracing::Span, + console_edge_count: usize, + elapsed: Duration, + result: &io::Result, +) { + let console_scheduling = scheduling_label(console_edge_count); + let (outcome, error_category) = if result.is_ok() { + ("success", "none") + } else { + ("error", "io") + }; + span.record("outcome", outcome); + span.record("error_category", error_category); + tracing::info!( + console_edge_count, + console_scheduling, + outcome, + error_category, + "Completed Ninja build invocation" + ); + counter!(NINJA_BUILD_EXECUTIONS_TOTAL, + "outcome" => outcome, "error_category" => error_category, + "console_scheduling" => console_scheduling) + .increment(1); + histogram!(NINJA_BUILD_EXECUTION_DURATION, + "outcome" => outcome, "error_category" => error_category, + "console_scheduling" => console_scheduling) + .record(elapsed); + counter!(NINJA_BUILD_CONSOLE_EDGES_TOTAL, "outcome" => outcome) + .increment(u64::try_from(console_edge_count).unwrap_or(u64::MAX)); +} + +/// Register the fixed Ninja-build metric descriptions once per process. +fn describe_metrics() { + static DESCRIBE: Once = Once::new(); + DESCRIBE.call_once(|| { + describe_counter!( + NINJA_BUILD_EXECUTIONS_TOTAL, + "Counts runner-owned Ninja builds by console scheduling, outcome, and error category." + ); + describe_histogram!( + NINJA_BUILD_EXECUTION_DURATION, + "Measures Ninja build duration in seconds by console scheduling, outcome, and error category." + ); + describe_counter!( + NINJA_BUILD_CONSOLE_EDGES_TOTAL, + concat!( + "Counts configured console edges across attempted runner-owned builds; ", + "it does not count executed edges." + ) + ); + }); +} + +#[cfg(test)] +mod tests; diff --git a/src/runner/console_telemetry/tests.rs b/src/runner/console_telemetry/tests.rs new file mode 100644 index 000000000..7bc04769b --- /dev/null +++ b/src/runner/console_telemetry/tests.rs @@ -0,0 +1,122 @@ +//! Verify bounded console-scheduling metrics for actual Ninja invocations. + +use super::*; +use crate::test_tracing_capture::with_test_subscriber; +use metrics_util::{ + CompositeKey, MetricKind, + debugging::{DebugValue, DebuggingRecorder}, +}; +use monotony::test_util::FixedMonotonicClock; +use rstest::rstest; +use tracing_subscriber::filter::LevelFilter; + +/// Duration reported by the injected clock for each test invocation. +const BUILD_ELAPSED: Duration = Duration::from_millis(1_250); + +/// Represent the local recorder's drained metric series. +type Snapshot = Vec<( + CompositeKey, + Option, + Option, + DebugValue, +)>; + +/// Find one named metric with exactly the expected bounded labels. +fn metric<'a>( + snapshot: &'a Snapshot, + name: &str, + kind: MetricKind, + labels: &[(&str, &str)], +) -> Option<&'a DebugValue> { + snapshot.iter().find_map(|(key, _, _, value)| { + let actual = key + .key() + .labels() + .map(|label| (label.key(), label.value())) + .collect::>(); + (key.kind() == kind + && key.key().name() == name + && actual.len() == labels.len() + && labels.iter().all(|label| actual.contains(label))) + .then_some(value) + }) +} + +/// Assert one bounded operation, one duration sample, and the configured-edge count. +fn assert_metrics(snapshot: &Snapshot, console_edges: usize, labels: [(&str, &str); 3]) { + assert_eq!( + snapshot.len(), + 3, + "only three bounded series are emitted: {snapshot:?}" + ); + assert!(matches!( + metric( + snapshot, + NINJA_BUILD_EXECUTIONS_TOTAL, + MetricKind::Counter, + &labels + ), + Some(DebugValue::Counter(1)) + )); + assert!( + matches!(metric(snapshot, NINJA_BUILD_EXECUTION_DURATION, MetricKind::Histogram, &labels), Some(DebugValue::Histogram(samples)) if samples.iter().map(|sample| sample.0).eq([BUILD_ELAPSED.as_secs_f64()])) + ); + let outcome = labels[1].1; + assert!( + matches!(metric(snapshot, NINJA_BUILD_CONSOLE_EDGES_TOTAL, MetricKind::Counter, &[("outcome", outcome)]), Some(DebugValue::Counter(count)) if *count == u64::try_from(console_edges).expect("small fixture count")) + ); +} + +#[rstest] +#[case::ordinary_success(0, false)] +#[case::console_success(2, false)] +#[case::ordinary_failure(0, true)] +#[case::console_failure(2, true)] +fn invocation_records_bounded_scheduling_outcome_and_duration( + #[case] console_edges: usize, + #[case] fail: bool, +) { + let scheduling = scheduling_label(console_edges); + let (outcome, category) = if fail { + ("error", "io") + } else { + ("success", "none") + }; + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let clock = FixedMonotonicClock::with_elapsed(BUILD_ELAPSED); + let (result, events) = metrics::with_local_recorder(&recorder, || { + with_test_subscriber(LevelFilter::INFO, |captured| { + let result = instrument_ninja_build(&clock, console_edges, || { + if fail { + Err(io::Error::other("private process detail")) + } else { + Ok(()) + } + }); + (result, captured.snapshot()) + }) + }); + assert_eq!(result.is_err(), fail); + let snapshot = snapshotter.snapshot().into_vec(); + let labels = [ + ("console_scheduling", scheduling), + ("outcome", outcome), + ("error_category", category), + ]; + assert_metrics(&snapshot, console_edges, labels); + assert!( + events.iter().any(|event| { + event.contains(&format!("console_edge_count={console_edges}")) + && event.contains(&format!("console_scheduling={scheduling:?}")) + && event.contains(&format!("outcome={outcome:?}")) + && event.contains(&format!("error_category={category:?}")) + }), + "build event must expose the aggregate and fixed outcomes: {events:?}" + ); + assert!( + events + .iter() + .all(|event| !event.contains("private process detail")) + ); +} diff --git a/src/runner/dyndep/generation_telemetry.rs b/src/runner/dyndep/generation_telemetry.rs index 7253d3558..37313e043 100644 --- a/src/runner/dyndep/generation_telemetry.rs +++ b/src/runner/dyndep/generation_telemetry.rs @@ -12,9 +12,12 @@ use std::{sync::Once, time::Instant}; use tracing::field; /// Metric name counting runner-owned bundle generation outcomes. -const BUNDLE_GENERATIONS_TOTAL: &str = "netsuke_ninja_dyndep_bundle_generations_total"; +pub const BUNDLE_GENERATIONS_TOTAL: &str = "netsuke_ninja_dyndep_bundle_generations_total"; /// Metric name measuring runner-owned bundle generation duration in seconds. -const BUNDLE_GENERATION_DURATION: &str = "netsuke_ninja_dyndep_bundle_generation_duration_seconds"; +pub const BUNDLE_GENERATION_DURATION: &str = + "netsuke_ninja_dyndep_bundle_generation_duration_seconds"; +/// Metric counting configured console edges across bundle-generation attempts. +pub const BUNDLE_CONSOLE_EDGES_TOTAL: &str = "netsuke_ninja_dyndep_bundle_console_edges_total"; /// Record one runner-owned bundle-generation operation. pub(in crate::runner) fn instrument_bundle_generation( @@ -29,36 +32,53 @@ pub(in crate::runner) fn instrument_bundle_generation( // Count logical build operations once; `output_count` measures aliases and // would inflate this span for a single multi-output edge. let canonical_target_count = graph.edge_count(); + let console_edge_count = graph + .edges() + .filter(|edge| edge.output_visibility == crate::ir::EdgeOutputVisibility::Immediate) + .count(); + let console_scheduling = crate::runner::console_telemetry::scheduling_label(console_edge_count); let span = tracing::trace_span!( "runner.ninja.dyndep_bundle.generate", action_count = graph.actions.len(), target_count = canonical_target_count, dependency_count, + console_edge_count, + console_scheduling, outcome = field::Empty, error_category = field::Empty, ); let _guard = span.enter(); let started = Instant::now(); let result = generate(); - let outcome = record_outcome(&span, &result); - counter!(BUNDLE_GENERATIONS_TOTAL, "outcome" => outcome).increment(1); - histogram!(BUNDLE_GENERATION_DURATION).record(started.elapsed()); + let (outcome, error_category) = record_outcome(&span, &result); + counter!(BUNDLE_GENERATIONS_TOTAL, "outcome" => outcome, + "error_category" => error_category, "console_scheduling" => console_scheduling) + .increment(1); + histogram!(BUNDLE_GENERATION_DURATION, "outcome" => outcome, + "error_category" => error_category, "console_scheduling" => console_scheduling) + .record(started.elapsed()); + counter!(BUNDLE_CONSOLE_EDGES_TOTAL, "outcome" => outcome) + .increment(u64::try_from(console_edge_count).unwrap_or(u64::MAX)); result } /// Record the bounded outcome and error category on the span and return it. -fn record_outcome(span: &tracing::Span, result: &Result) -> &'static str { +fn record_outcome( + span: &tracing::Span, + result: &Result, +) -> (&'static str, &'static str) { match result { Ok(_) => { span.record("outcome", "success"); - "success" + span.record("error_category", "none"); + ("success", "none") } Err(error) => { let category = error_category(error); span.record("outcome", "error"); span.record("error_category", category); tracing::debug!(error_category = category, "dyndep bundle generation failed"); - "error" + ("error", category) } } } @@ -67,6 +87,8 @@ fn record_outcome(span: &tracing::Span, result: &Result) -> const fn error_category(error: &NinjaGenError) -> &'static str { match error { NinjaGenError::MissingAction { .. } => "missing_action", + NinjaGenError::ConsoleDependencyOnly { .. } => "console_dependency_only", + NinjaGenError::ConsolePoolConflict { .. } => "console_pool_conflict", NinjaGenError::EmptyCommandRecipe { .. } | NinjaGenError::InvalidCommandIsolation { .. } | NinjaGenError::MultipleBackgroundJobs { .. } @@ -90,11 +112,15 @@ fn describe_metrics() { DESCRIBE.call_once(|| { describe_counter!( BUNDLE_GENERATIONS_TOTAL, - "Counts runner-owned dyndep bundle generation outcomes." + "Counts bundle-generation outcomes by bounded console-scheduling state and error category." ); describe_histogram!( BUNDLE_GENERATION_DURATION, - "Measures runner-owned dyndep bundle generation duration in seconds." + "Measures bundle-generation duration by bounded outcome, error category, and console-scheduling state." + ); + describe_counter!( + BUNDLE_CONSOLE_EDGES_TOTAL, + "Counts configured console edges across runner-owned bundle-generation attempts." ); }); } @@ -104,13 +130,16 @@ mod tests { //! Tests for bounded generation telemetry at the runner boundary. use super::*; + use crate::ir::{BuildEdge, DependencyOrder}; + use camino::Utf8PathBuf; use metrics_util::MetricKind; use metrics_util::debugging::{DebugValue, DebuggingRecorder}; + use rstest::rstest; use std::sync::{Arc, Mutex, PoisonError}; use tracing::{ Subscriber, field::{Field, Visit}, - span::{Id, Record}, + span::{Attributes, Id, Record}, }; use tracing_subscriber::{ Layer, filter::LevelFilter, layer::Context as LayerContext, prelude::*, @@ -135,6 +164,15 @@ mod tests { where S: Subscriber + for<'span> LookupSpan<'span>, { + fn on_new_span(&self, values: &Attributes<'_>, _span: &Id, _ctx: LayerContext<'_, S>) { + let mut visitor = SpanFieldVisitor::default(); + values.record(&mut visitor); + self.fields + .lock() + .unwrap_or_else(PoisonError::into_inner) + .extend(visitor.fields); + } + fn on_record(&self, _span: &Id, values: &Record<'_>, _ctx: LayerContext<'_, S>) { let mut visitor = SpanFieldVisitor::default(); values.record(&mut visitor); @@ -168,69 +206,168 @@ mod tests { tracing::subscriber::with_default(subscriber, || test(captured)) } - fn bundle_generation_count( - snapshot: &[( - metrics_util::CompositeKey, - Option, - Option, - DebugValue, - )], - outcome: &str, - ) -> Option { + /// Represent one locally captured metric snapshot. + type Snapshot = Vec<( + metrics_util::CompositeKey, + Option, + Option, + DebugValue, + )>; + + /// Find a metric with the exact finite label set. + fn metric<'a>( + snapshot: &'a Snapshot, + name: &str, + kind: MetricKind, + labels: &[(&str, &str)], + ) -> Option<&'a DebugValue> { snapshot.iter().find_map(|(key, _, _, value)| { - let has_outcome = key + let actual = key .key() .labels() - .any(|label| label.key() == "outcome" && label.value() == outcome); - match (key.kind(), key.key().name(), value) { - (MetricKind::Counter, BUNDLE_GENERATIONS_TOTAL, DebugValue::Counter(count)) - if has_outcome => - { - Some(*count) - } - _ => None, - } + .map(|label| (label.key(), label.value())) + .collect::>(); + (key.kind() == kind + && key.key().name() == name + && actual.len() == labels.len() + && labels.iter().all(|label| actual.contains(label))) + .then_some(value) + }) + } + + /// Construct graph shape without invoking the generator under observation. + fn graph_with_console_edges(console_edges: usize) -> BuildGraph { + let mut graph = BuildGraph::default(); + for index in 0..=console_edges { + graph + .insert_edge(BuildEdge { + action_id: "shape-only".into(), + inputs: Vec::new(), + implicit_deps: Vec::new(), + dependency_order: DependencyOrder::Parallel, + explicit_outputs: vec![Utf8PathBuf::from(format!("private-{index}.out"))], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + progress_label: None, + phony: false, + always: false, + output_visibility: if index < console_edges { + crate::ir::EdgeOutputVisibility::Immediate + } else { + crate::ir::EdgeOutputVisibility::Buffered + }, + }) + .expect("fixture aliases must be distinct"); + } + graph + } + + /// Build a controlled generation error with the requested fixed category. + /// + /// # Errors + /// + /// Returns an error when the category is not a supported fixture value. + fn generation_error(category: &str) -> anyhow::Result { + use crate::localization::{self, keys}; + Ok(match category { + "command_list" => NinjaGenError::EmptyCommandRecipe { action_index: 1 }, + "console_dependency_only" => NinjaGenError::ConsoleDependencyOnly { + output: "private-output".into(), + message: localization::message(keys::NINJA_GEN_CONSOLE_DEPENDENCY_ONLY) + .with_arg("output", "private-output"), + }, + "console_pool_conflict" => NinjaGenError::ConsolePoolConflict { + output: "private-output".into(), + pool: "private-pool".into(), + message: localization::message(keys::NINJA_GEN_CONSOLE_POOL_CONFLICT) + .with_arg("output", "private-output") + .with_arg("pool", "private-pool"), + }, + _ => anyhow::bail!("unknown generation fixture category: {category}"), }) } - fn record_generation( - recorder: &DebuggingRecorder, - graph: &BuildGraph, - generate: impl FnOnce() -> Result, - ) -> Result { - metrics::with_local_recorder(recorder, || instrument_bundle_generation(graph, generate)) + /// Assert one bounded operation, one duration sample, and the configured-edge count. + fn assert_metrics(snapshot: &Snapshot, console_edges: usize, labels: [(&str, &str); 3]) { + assert_eq!( + snapshot.len(), + 3, + "only three bounded series are emitted: {snapshot:?}" + ); + assert!(matches!( + metric( + snapshot, + BUNDLE_GENERATIONS_TOTAL, + MetricKind::Counter, + &labels + ), + Some(DebugValue::Counter(1)) + )); + assert!( + matches!(metric(snapshot, BUNDLE_GENERATION_DURATION, MetricKind::Histogram, &labels), Some(DebugValue::Histogram(samples)) if samples.len() == 1) + ); + let outcome = labels[0].1; + assert!( + matches!(metric(snapshot, BUNDLE_CONSOLE_EDGES_TOTAL, MetricKind::Counter, &[("outcome", outcome)]), Some(DebugValue::Counter(count)) if *count == u64::try_from(console_edges).expect("small fixture count")) + ); } - #[test] - fn runner_boundary_records_bundle_generation() { + #[rstest] + #[case::ordinary_success(0, "disabled", "none")] + #[case::console_success(2, "enabled", "none")] + #[case::command_list_error(0, "disabled", "command_list")] + #[case::dependency_only_error(2, "enabled", "console_dependency_only")] + #[case::pool_conflict_error(2, "enabled", "console_pool_conflict")] + fn generation_records_console_shape_and_bounded_outcomes( + #[case] console_edges: usize, + #[case] scheduling: &str, + #[case] category: &str, + ) -> anyhow::Result<()> { let recorder = DebuggingRecorder::new(); let snapshotter = recorder.snapshotter(); - let graph = BuildGraph::default(); - let (success, error, span_fields) = with_span_capture(|captured| { - let success = record_generation(&recorder, &graph, || Ok::<_, NinjaGenError>(())); - let error = record_generation(&recorder, &graph, || { - Err::<(), _>(NinjaGenError::EmptyCommandRecipe { action_index: 1 }) + let graph = graph_with_console_edges(console_edges); + let error = if category == "none" { + None + } else { + Some(generation_error(category)?) + }; + let (result, fields) = with_span_capture(|captured| { + let result = metrics::with_local_recorder(&recorder, || { + instrument_bundle_generation(&graph, || error.map_or(Ok(()), Err)) }); - (success, error, captured.snapshot()) + (result, captured.snapshot()) }); - assert!(success.is_ok()); - assert!(matches!( - error, - Err(NinjaGenError::EmptyCommandRecipe { action_index: 1 }) - )); + let outcome = if category == "none" { + "success" + } else { + "error" + }; + anyhow::ensure!( + result.is_err() == (category != "none"), + "unexpected generation result for {category}: {result:?}" + ); let snapshot = snapshotter.snapshot().into_vec(); - assert_eq!(bundle_generation_count(&snapshot, "success"), Some(1)); - assert_eq!(bundle_generation_count(&snapshot, "error"), Some(1)); - assert!(snapshot.iter().any(|(key, _, _, value)| { - key.kind() == MetricKind::Histogram - && key.key().name() == BUNDLE_GENERATION_DURATION - && matches!(value, DebugValue::Histogram(samples) if samples.len() == 2) - })); - assert!( - span_fields - .iter() - .any(|field| field == "error_category=\"command_list\""), - "the error span must record the bounded command-list category: {span_fields:?}" + let labels = [ + ("outcome", outcome), + ("error_category", category), + ("console_scheduling", scheduling), + ]; + assert_metrics(&snapshot, console_edges, labels); + for expected in [ + format!("console_edge_count={console_edges}"), + format!("console_scheduling={scheduling:?}"), + format!("outcome={outcome:?}"), + format!("error_category={category:?}"), + ] { + anyhow::ensure!( + fields.contains(&expected), + "span must record {expected}: {fields:?}" + ); + } + anyhow::ensure!( + fields.iter().all(|field| !field.contains("private-")), + "span fields must not include private values: {fields:?}" ); + Ok(()) } } diff --git a/src/runner/graph/generation/mod.rs b/src/runner/graph/generation/mod.rs index 5bd250681..fa6d17a04 100644 --- a/src/runner/graph/generation/mod.rs +++ b/src/runner/graph/generation/mod.rs @@ -19,14 +19,14 @@ use monotony::MonotonicClock; /// Supply the dependencies that select and measure graph generation. /// -/// Keep this runner-internal composition boundary limited to graph generation -/// so unrelated command dispatch does not acquire a clock dependency. +/// Keep this runner-internal composition boundary limited to the build +/// pipeline. The same clock also measures its Ninja invocation. pub(in crate::runner) struct GraphGenerationContext<'a> { /// Select the legacy-recipe interpreter used during graph generation. pub(in crate::runner) recipe_shell: crate::recipe_shell::RecipeShell, /// Bind builds to the Bash executable that passed runner preflight. pub(in crate::runner) bash_executable: Option, - /// Measure graph generation with a runner-provided monotonic clock. + /// Measure graph generation and Ninja execution with a runner-provided clock. pub(in crate::runner) clock: &'a dyn MonotonicClock, } diff --git a/src/runner/help/graph_json/tests.rs b/src/runner/help/graph_json/tests.rs index 1aa966dbe..8b42208dc 100644 --- a/src/runner/help/graph_json/tests.rs +++ b/src/runner/help/graph_json/tests.rs @@ -177,6 +177,7 @@ fn target_strategy() -> impl Strategy { vars: HashMap::default(), phony, always, + console: false, conditional, description, }, diff --git a/src/runner/help/tests.rs b/src/runner/help/tests.rs index 9fa4e9e98..f1a775ba8 100644 --- a/src/runner/help/tests.rs +++ b/src/runner/help/tests.rs @@ -313,6 +313,7 @@ fn catalogue_target( vars: crate::ast::Vars::default(), phony, always: false, + console: false, conditional, description, } diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 8971fb049..bae6b089f 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -7,6 +7,7 @@ mod callsite_interest_reproduction; #[cfg(feature = "lint")] mod check; +mod console_telemetry; mod dispatch; mod dyndep; mod error; @@ -26,6 +27,12 @@ pub use camino::{Utf8Path, Utf8PathBuf}; pub use check::diagnostics as check_diagnostics; #[cfg(feature = "lint")] pub use check::diagnostics::FindingDiagnostic; +pub use console_telemetry::{ + NINJA_BUILD_CONSOLE_EDGES_TOTAL, NINJA_BUILD_EXECUTION_DURATION, NINJA_BUILD_EXECUTIONS_TOTAL, +}; +pub use dyndep::generation_telemetry::{ + BUNDLE_CONSOLE_EDGES_TOTAL, BUNDLE_GENERATION_DURATION, BUNDLE_GENERATIONS_TOTAL, +}; pub use error::RunnerError; use monotony::StdMonotonicClock; use std::io::IsTerminal; @@ -59,9 +66,9 @@ pub use ninja::{NinjaContent, run_ninja, run_ninja_tool}; #[cfg(doctest)] pub use process::doc; pub use process::{ - CommandEnv, MAX_RETAINED_DYNDEP_FILES, NINJA_STATUS_OVERSIZED_LINES_TOTAL, NinjaBuildRequest, - NinjaJobCount, NinjaProcessOptions, NinjaToolRequest, StderrMode, run_ninja_tool_with, - run_ninja_with, + CommandEnv, FORWARD_FAILURE_LABELS, FORWARD_FAILURES_TOTAL, MAX_RETAINED_DYNDEP_FILES, + NINJA_STATUS_OVERSIZED_LINES_TOTAL, NinjaBuildRequest, NinjaJobCount, NinjaProcessOptions, + NinjaToolRequest, StderrMode, run_ninja_tool_with, run_ninja_with, }; pub use recipe_shell::telemetry::{ BASH_PREFLIGHT_TOTAL, LEGACY_RECIPE_EXECUTION_DURATION, LEGACY_RECIPE_EXECUTIONS_TOTAL, @@ -177,13 +184,6 @@ fn run_with_ninja_program_resolver( dispatch::execute(cli, command, &context) } -/// Adapt the reporter's task-progress method into a mutable callback. -fn on_task_progress_callback(reporter: &dyn StatusReporter) -> impl FnMut(u32, u32, &str) + '_ { - move |current: u32, total: u32, description: &str| { - reporter.report_task_progress(current, total, description); - } -} - /// Resolve the manifest, generate the Ninja file and invoke the build. /// /// # Errors @@ -223,6 +223,7 @@ fn execute_build( graph_generation, )?; let (bundle, command_list_targets) = generated.into_build_parts(); + let console_edge_count = bundle.console_edge_count(); let publication = materialize_dyndep_bundle(cli, &bundle)?; prune_dyndep_bundle(cli, bundle.dyndep_files(), &publication)?; let ninja = NinjaContent::new(bundle.into_parts().0); @@ -234,13 +235,9 @@ fn execute_build( let build_file = process::create_temp_ninja_file(&ninja)?; let build_path = build_file.as_path(); - let ctx = || { - format!( - "running {} with build file {}", - context.ninja_program, build_path - ) - }; - let options = ninja::ninja_process_options(cli)?; + let ninja_program = context.ninja_program; + let ctx = || format!("running {ninja_program} with build file {build_path}"); + let options = ninja::ninja_process_options(cli).with_context(ctx)?; let env = CommandEnv::inherit(); let build_request = process::NinjaBuildRequest { program: context.ninja_program, @@ -250,16 +247,19 @@ fn execute_build( env: &env, stderr_mode: StderrMode::from_json_enabled(cli.json), }; - let ninja_result = if context.progress_enabled { - let mut on_task_progress = on_task_progress_callback(context.reporter); - process::run_ninja_with_command_list_targets( - build_request, - Some(&mut on_task_progress), - &command_list_targets, - ) - } else { - ninja::run_ninja_with_command_list_targets(build_request, &command_list_targets) - }; + let clock = graph_generation.clock; + let ninja_result = console_telemetry::instrument_ninja_build(clock, console_edge_count, || { + if context.progress_enabled { + let mut on_task_progress = reporter::on_task_progress_callback(context.reporter); + process::run_ninja_with_command_list_targets( + build_request, + Some(&mut on_task_progress), + &command_list_targets, + ) + } else { + ninja::run_ninja_with_command_list_targets(build_request, &command_list_targets) + } + }); ninja_result.map_err(|error| { // Keep marker text in the CLI's outer context. let error_context = if error.to_string().contains(COMMAND_LIST_FAILURE_PREFIX) { @@ -346,7 +346,7 @@ fn execute_ninja_tool( }; if context.progress_enabled { let options = ninja::ninja_process_options(cli)?; - let mut on_task_progress = on_task_progress_callback(context.reporter); + let mut on_task_progress = reporter::on_task_progress_callback(context.reporter); process::run_ninja_tool_with_status( process::NinjaToolRequest { program: context.ninja_program, diff --git a/src/runner/process/child_exit.rs b/src/runner/process/child_exit.rs index 381298245..edd80b49d 100644 --- a/src/runner/process/child_exit.rs +++ b/src/runner/process/child_exit.rs @@ -102,17 +102,22 @@ pub(super) fn ninja_exit_error( } /// Join stderr forwarding and surface the child's wait result. +/// +/// Forwarding loss never changes the reported build result: the child's exit +/// status remains authoritative, and a truncated output stream is reported +/// through the classified forwarding-failure counter and a warning event +/// instead. This policy keeps a closed parent pipe, as produced by +/// `netsuke build | head`, from being reported as a build failure. Both +/// statistics are therefore intentionally discarded here; the forwarding +/// helper has already emitted their telemetry. pub(super) fn finalize_streaming( wait_result: io::Result, stdout_stats: ForwardStats, err_handle: thread::JoinHandle<(ForwardStats, Option)>, ) -> io::Result<(ExitStatus, Option)> { - handle_forwarding_stats(stdout_stats, "stdout"); + let _ = stdout_stats; let command_list_failure = match err_handle.join() { - Ok((stats, context)) => { - handle_forwarding_stats(stats, "stderr"); - context - } + Ok((_stderr_stats, context)) => context, Err(error) => { tracing::warn!("stderr forwarding thread panicked: {error:?}"); None @@ -120,10 +125,3 @@ pub(super) fn finalize_streaming( }; wait_result.map(|status| (status, command_list_failure)) } - -/// Log a truncation debug event when a forwarding stream hit a closed pipe. -fn handle_forwarding_stats(stats: ForwardStats, stream_name: &str) { - if stats.write_failed { - tracing::debug!("{stream_name} forwarding encountered closed pipe; output truncated"); - } -} diff --git a/src/runner/process/failure_attribution.rs b/src/runner/process/failure_attribution.rs index ca687557c..62134c020 100644 --- a/src/runner/process/failure_attribution.rs +++ b/src/runner/process/failure_attribution.rs @@ -3,7 +3,7 @@ use crate::ninja_gen::command_list::COMMAND_LIST_FAILURE_PREFIX; use std::io::{self, Write}; -use super::streaming::{ForwardStats, forward_child_output}; +use super::streaming::{ForwardStats, ForwardStream, forward_child_output}; /// Forward stderr while retaining only the bounded command-list failure marker. pub(super) fn forward_stderr_with_attribution( @@ -15,7 +15,7 @@ where W: Write, { let mut attribution_writer = FailureAttributionWriter::new(output); - let stats = forward_child_output(reader, &mut attribution_writer, "stderr"); + let stats = forward_child_output(reader, &mut attribution_writer, ForwardStream::Stderr); (stats, attribution_writer.into_failure()) } diff --git a/src/runner/process/mod.rs b/src/runner/process/mod.rs index e3488331f..0cca3b647 100644 --- a/src/runner/process/mod.rs +++ b/src/runner/process/mod.rs @@ -34,7 +34,9 @@ pub use file_io::*; use logging::{CommandLogContext, command_span, log_command_execution, log_command_spawn_failure}; pub use ninja::resolve_ninja_program; use output_forwarding::{StatusObserver, spawn_and_stream_output}; -pub use streaming::NINJA_STATUS_OVERSIZED_LINES_TOTAL; +pub use streaming::{ + FORWARD_FAILURE_LABELS, FORWARD_FAILURES_TOTAL, NINJA_STATUS_OVERSIZED_LINES_TOTAL, +}; mod configure; mod environment; diff --git a/src/runner/process/output_forwarding.rs b/src/runner/process/output_forwarding.rs index 9643d0a9f..eb4adbef0 100644 --- a/src/runner/process/output_forwarding.rs +++ b/src/runner/process/output_forwarding.rs @@ -6,7 +6,9 @@ use super::{ failure_attribution::{ CommandListFailure, NinjaFailureOutputTail, forward_stderr_with_attribution, }, - streaming::{ForwardStats, forward_child_output, forward_child_output_with_ninja_status}, + streaming::{ + ForwardStats, ForwardStream, forward_child_output, forward_child_output_with_ninja_status, + }, }; use std::{ io::{self, BufReader}, @@ -42,9 +44,13 @@ where BufReader::new(stdout), &mut tail_writer, observer, - "stdout", + ForwardStream::Stdout, + ), + None => forward_child_output( + BufReader::new(stdout), + &mut tail_writer, + ForwardStream::Stdout, ), - None => forward_child_output(BufReader::new(stdout), &mut tail_writer, "stdout"), }; return (stats, tail_writer.into_failure()); } @@ -54,9 +60,9 @@ where BufReader::new(stdout), output, observer, - "stdout", + ForwardStream::Stdout, ), - None => forward_child_output(BufReader::new(stdout), output, "stdout"), + None => forward_child_output(BufReader::new(stdout), output, ForwardStream::Stdout), }; (stats, None) } diff --git a/src/runner/process/streaming/forward_failure.rs b/src/runner/process/streaming/forward_failure.rs new file mode 100644 index 000000000..d5ba4a63f --- /dev/null +++ b/src/runner/process/streaming/forward_failure.rs @@ -0,0 +1,157 @@ +//! Classify child-output forwarding failures and bound their telemetry. +//! +//! Every forwarding failure reaches a [`ForwardFailureKind`] before it is +//! counted or logged, so neither a metric label nor a warning field can carry +//! a path, command line, or child output. The label vocabularies are closed +//! arrays: [`ForwardStream::label`] and [`ForwardFailureKind::label`] choose +//! values from those arrays, and the application recorder admits exactly the +//! same arrays, so a new label value cannot reach the retained snapshot +//! unless it is declared here first. + +use metrics::{counter, describe_counter}; +use std::{io, sync::Once}; + +/// Counter recording classified child-output forwarding failures. +pub const FORWARD_FAILURES_TOTAL: &str = "netsuke_ninja_forward_failures_total"; + +/// Bounded `stream` label values accepted for forwarding-failure telemetry. +pub const FORWARD_STREAM_VALUES: [&str; 2] = ["stdout", "stderr"]; + +/// Bounded `failure_kind` label values accepted for forwarding-failure telemetry. +pub const FORWARD_FAILURE_KIND_VALUES: [&str; 4] = ["read", "write", "flush", "drain"]; + +/// Complete labelled series admitted for the forwarding-failure counter. +/// +/// The recorder admits this pairing directly, so a label name and the +/// vocabulary behind it cannot drift apart from the arrays declared above. +pub const FORWARD_FAILURE_LABELS: [(&str, &[&str]); 2] = [ + ("stream", &FORWARD_STREAM_VALUES), + ("failure_kind", &FORWARD_FAILURE_KIND_VALUES), +]; + +/// One child stream whose output the process layer forwards. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::runner::process) enum ForwardStream { + /// The child's standard output. + Stdout, + /// The child's standard error. + Stderr, +} + +impl ForwardStream { + /// Return the bounded `stream` metric label for this stream. + pub(in crate::runner::process) const fn label(self) -> &'static str { + match self { + Self::Stdout => FORWARD_STREAM_VALUES[0], + Self::Stderr => FORWARD_STREAM_VALUES[1], + } + } +} + +/// Bounded classification of a child-output forwarding failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::runner::process) enum ForwardFailureKind { + /// The child stream could not be read. + Read, + /// The destination rejected a chunk. + Write, + /// The destination rejected the flush that publishes a chunk. + Flush, + /// Remaining child output could not be drained after a failure. + Drain, +} + +impl ForwardFailureKind { + /// Return the bounded `failure_kind` metric label for this classification. + pub(in crate::runner::process) const fn label(self) -> &'static str { + match self { + Self::Read => FORWARD_FAILURE_KIND_VALUES[0], + Self::Write => FORWARD_FAILURE_KIND_VALUES[1], + Self::Flush => FORWARD_FAILURE_KIND_VALUES[2], + Self::Drain => FORWARD_FAILURE_KIND_VALUES[3], + } + } +} + +/// A classified forwarding failure paired with the underlying I/O error. +/// +/// The error is retained only for the warning event's `error` field; it never +/// reaches a metric label. +#[derive(Debug)] +pub(super) struct ForwardFailure { + /// Classification reported through telemetry. + pub(super) kind: ForwardFailureKind, + /// Original error, reported in the warning event. + pub(super) source: io::Error, +} + +impl ForwardFailure { + /// Classify a failure to read the child stream. + pub(super) const fn read(source: io::Error) -> Self { + Self { + kind: ForwardFailureKind::Read, + source, + } + } + + /// Classify a failure to write a chunk to the destination. + pub(super) const fn write(source: io::Error) -> Self { + Self { + kind: ForwardFailureKind::Write, + source, + } + } + + /// Classify a failure to flush a written chunk to the destination. + pub(super) const fn flush(source: io::Error) -> Self { + Self { + kind: ForwardFailureKind::Flush, + source, + } + } +} + +/// Describe the forwarding-failure counter once per process. +fn describe_metric() { + static DESCRIBE: Once = Once::new(); + DESCRIBE.call_once(|| { + describe_counter!( + FORWARD_FAILURES_TOTAL, + "Counts child-output forwarding failures by stream and failure kind." + ); + }); +} + +/// Record one classified forwarding failure under bounded labels. +pub(super) fn record_failure(stream: ForwardStream, kind: ForwardFailureKind) { + describe_metric(); + counter!( + FORWARD_FAILURES_TOTAL, + "stream" => stream.label(), + "failure_kind" => kind.label() + ) + .increment(1); +} + +#[cfg(test)] +mod tests { + //! Verify the label vocabularies stay closed and distinct. + + use super::{ + FORWARD_FAILURE_KIND_VALUES, FORWARD_STREAM_VALUES, ForwardFailureKind, ForwardStream, + }; + + #[test] + fn labels_are_drawn_from_the_declared_vocabularies() { + assert_eq!(ForwardStream::Stdout.label(), FORWARD_STREAM_VALUES[0]); + assert_eq!(ForwardStream::Stderr.label(), FORWARD_STREAM_VALUES[1]); + for (kind, expected) in [ + (ForwardFailureKind::Read, FORWARD_FAILURE_KIND_VALUES[0]), + (ForwardFailureKind::Write, FORWARD_FAILURE_KIND_VALUES[1]), + (ForwardFailureKind::Flush, FORWARD_FAILURE_KIND_VALUES[2]), + (ForwardFailureKind::Drain, FORWARD_FAILURE_KIND_VALUES[3]), + ] { + assert_eq!(kind.label(), expected); + } + } +} diff --git a/src/runner/process/streaming/mod.rs b/src/runner/process/streaming/mod.rs index 64bbd6a57..2ee6f7349 100644 --- a/src/runner/process/streaming/mod.rs +++ b/src/runner/process/streaming/mod.rs @@ -1,4 +1,8 @@ //! Streaming helpers for subprocess output forwarding. +//! +//! The module owns the only byte-for-byte copy loop between a child's standard +//! streams and a forwarding writer. See `forward_failure` for the closed +//! failure vocabulary that keeps forwarding telemetry bounded. use super::ninja::{NinjaTaskProgressTracker, parse_ninja_status_line}; use metrics::{counter, describe_counter}; @@ -7,6 +11,11 @@ use std::{ sync::Once, }; +mod forward_failure; +pub use forward_failure::{FORWARD_FAILURE_LABELS, FORWARD_FAILURES_TOTAL}; +use forward_failure::{ForwardFailure, record_failure}; +pub(super) use forward_failure::{ForwardFailureKind, ForwardStream}; + /// Forwarding statistics for a child output stream. #[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] pub(super) struct ForwardStats { @@ -14,8 +23,10 @@ pub(super) struct ForwardStats { pub(super) bytes_read: usize, /// Bytes accepted by the forwarding writer. pub(super) bytes_written: usize, - /// Whether the forwarding writer failed, truncating output. - pub(super) write_failed: bool, + /// Classification of the first forwarding failure, if forwarding failed. + pub(super) failure: Option, + /// Whether draining the remaining child output failed after that failure. + pub(super) drain_failed: bool, } /// Read wrapper that counts bytes surfaced to the caller. @@ -196,8 +207,70 @@ fn clamp_u64_to_usize(value: u64) -> usize { usize::try_from(value).unwrap_or(usize::MAX) } +/// Read one child-output chunk, retrying interrupted reads. +/// +/// # Errors +/// +/// Returns an error when the reader fails. +fn read_next_chunk(reader: &mut R, buffer: &mut [u8]) -> io::Result +where + R: Read, +{ + loop { + match reader.read(buffer) { + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + result => break result, + } + } +} + +/// Write a complete child-output chunk and flush it to the destination. +/// +/// # Errors +/// +/// Returns a classified failure when the writer rejects the chunk or its +/// flush. +fn write_and_flush_chunk(writer: &mut W, chunk: &[u8]) -> Result<(), ForwardFailure> +where + W: Write, +{ + writer.write_all(chunk).map_err(ForwardFailure::write)?; + writer.flush().map_err(ForwardFailure::flush) +} + +/// Copy and flush each child-read chunk before reading the next one. +/// +/// Keeping the flush separate from `Write::write` ensures a flush failure +/// cannot cause accepted bytes to be retried as another write. +/// +/// # Errors +/// +/// Returns the first failure encountered while reading, writing, or flushing, +/// classified by stage. A reader that reports more bytes than the buffer holds +/// is a read failure: the count is only meaningful as a read result. +fn copy_and_flush(reader: &mut R, writer: &mut W) -> Result<(), ForwardFailure> +where + R: Read, + W: Write, +{ + let mut buffer = [0_u8; 8 * 1024]; + loop { + let count = read_next_chunk(reader, &mut buffer).map_err(ForwardFailure::read)?; + if count == 0 { + return Ok(()); + } + let chunk = buffer.get(..count).ok_or_else(|| { + ForwardFailure::read(io::Error::other("reader reported an invalid byte count")) + })?; + write_and_flush_chunk(writer, chunk)?; + } +} + /// Copy `reader` to `writer`, tracking statistics and draining on failure. -fn copy_with_stats(reader: &mut R, writer: &mut W, stream_name: &'static str) -> ForwardStats +/// +/// Byte counts are recorded on every path, including a failed drain, so they +/// always describe bytes the forwarder actually consumed and accepted. +fn copy_with_stats(reader: &mut R, writer: &mut W, stream: ForwardStream) -> ForwardStats where R: Read, W: Write, @@ -212,41 +285,58 @@ where written: 0, }; - match io::copy(&mut counting_reader, &mut counting_writer) { - Ok(_) => { - stats.bytes_read = clamp_u64_to_usize(counting_reader.read); - stats.bytes_written = clamp_u64_to_usize(counting_writer.written); - } - Err(err) => { - stats.write_failed = true; - stats.bytes_read = clamp_u64_to_usize(counting_reader.read); - stats.bytes_written = clamp_u64_to_usize(counting_writer.written); - tracing::debug!( - "Failed to write child {stream_name} output to parent: {err}; discarding remaining bytes" + if let Err(failure) = copy_and_flush(&mut counting_reader, &mut counting_writer) { + stats.failure = Some(failure.kind); + record_failure(stream, failure.kind); + tracing::warn!( + stream = stream.label(), + failure_kind = failure.kind.label(), + error = %failure.source, + "failed to forward child output; discarding the remaining bytes" + ); + stats.drain_failed = drain_remaining(&mut counting_reader, stream); + } + + stats.bytes_read = clamp_u64_to_usize(counting_reader.read); + stats.bytes_written = clamp_u64_to_usize(counting_writer.written); + stats +} + +/// Drain the remaining child output after a forwarding failure. +/// +/// Draining keeps the child from blocking on a full pipe once the parent has +/// stopped consuming its output. A drain failure is recorded under its own +/// classification so it never replaces the failure that stopped forwarding. +fn drain_remaining(reader: &mut CountingReader<'_, R>, stream: ForwardStream) -> bool +where + R: Read, +{ + match io::copy(reader, &mut io::sink()) { + Ok(_) => false, + Err(error) => { + record_failure(stream, ForwardFailureKind::Drain); + tracing::warn!( + stream = stream.label(), + failure_kind = ForwardFailureKind::Drain.label(), + error = %error, + "failed to drain child output after a forwarding failure" ); - if let Err(drain_err) = io::copy(&mut counting_reader, &mut io::sink()) { - tracing::debug!( - "Failed to drain child {stream_name} output after writer closed: {drain_err}" - ); - } else { - stats.bytes_read = clamp_u64_to_usize(counting_reader.read); - } + true } } - stats } /// Forward child output to a writer while tracking read/write statistics. pub(super) fn forward_child_output( mut reader: R, mut writer: W, - stream_name: &'static str, + stream: ForwardStream, ) -> ForwardStats where R: Read, W: Write, { - copy_with_stats(&mut reader, &mut writer, stream_name) + copy_with_stats(&mut reader, &mut writer, stream) } /// Forward child output and parse Ninja status updates from complete lines. @@ -254,7 +344,7 @@ pub(super) fn forward_child_output_with_ninja_status( mut reader: R, mut writer: W, mut observer: F, - stream_name: &'static str, + stream: ForwardStream, ) -> ForwardStats where R: Read, @@ -268,7 +358,7 @@ where line_state: NinjaStatusLineState::Buffering, observer: &mut observer, }; - copy_with_stats(&mut parsing_reader, &mut writer, stream_name) + copy_with_stats(&mut parsing_reader, &mut writer, stream) } #[cfg(test)] diff --git a/src/runner/process/streaming/telemetry_tests.rs b/src/runner/process/streaming/telemetry_tests.rs index ca3e08025..5d275f945 100644 --- a/src/runner/process/streaming/telemetry_tests.rs +++ b/src/runner/process/streaming/telemetry_tests.rs @@ -1,6 +1,8 @@ //! Tests for bounded oversized Ninja status-line telemetry. -use super::{NINJA_STATUS_OVERSIZED_LINES_TOTAL, forward_child_output_with_ninja_status}; +use super::{ + ForwardStream, NINJA_STATUS_OVERSIZED_LINES_TOTAL, forward_child_output_with_ninja_status, +}; use metrics_util::{ MetricKind, debugging::{DebugValue, DebuggingRecorder}, @@ -22,7 +24,7 @@ fn records_oversized_status_lines_once_per_line() { Cursor::new(input), std::io::sink(), |_current, _total, _description| {}, - "stdout", + ForwardStream::Stdout, ); }); diff --git a/src/runner/process/streaming/tests.rs b/src/runner/process/streaming/tests.rs index 163413870..223159ec3 100644 --- a/src/runner/process/streaming/tests.rs +++ b/src/runner/process/streaming/tests.rs @@ -4,8 +4,8 @@ //! these parser checks cover only the progress-enabled stdout path. use super::{ - NinjaStatusLineState, NinjaStatusParsingReader, NinjaTaskProgressTracker, forward_child_output, - forward_child_output_with_ninja_status, + ForwardFailureKind, ForwardStream, NinjaStatusLineState, NinjaStatusParsingReader, + NinjaTaskProgressTracker, forward_child_output, forward_child_output_with_ninja_status, }; use proptest::prelude::*; use std::{ @@ -16,6 +16,11 @@ use std::{ }, }; +#[path = "tests/failure_tests.rs"] +mod failure_tests; +#[path = "tests/flush_tests.rs"] +mod flush_tests; + #[derive(Clone)] struct FailingWriter { writes: Arc, @@ -111,7 +116,7 @@ where reader, &mut output, |current, total, description| updates.push((current, total, description.to_owned())), - "stdout", + ForwardStream::Stdout, ); assert_eq!(stats.bytes_read, input.len()); @@ -174,11 +179,11 @@ fn peak_rss_bytes() -> io::Result { fn forward_output_writes_all_bytes_when_parent_alive() { let input = b"alpha\nbravo\ncharlie\n".to_vec(); let reader = BufReader::new(Cursor::new(input.clone())); - let stats = forward_child_output(reader, Vec::new(), "stdout"); + let stats = forward_child_output(reader, Vec::new(), ForwardStream::Stdout); assert_eq!(stats.bytes_read, input.len()); assert_eq!(stats.bytes_written, input.len()); - assert!(!stats.write_failed); + assert_eq!(stats.failure, None); } #[test] @@ -187,11 +192,11 @@ fn forward_output_continues_draining_after_write_failure() { let reader = BufReader::new(Cursor::new(input.clone())); let write_attempts = Arc::new(AtomicUsize::new(0)); let failing_writer = FailingWriter::new(write_attempts.clone()); - let stats = forward_child_output(reader, failing_writer, "stdout"); + let stats = forward_child_output(reader, failing_writer, ForwardStream::Stdout); assert_eq!(stats.bytes_read, input.len()); assert_eq!(write_attempts.load(Ordering::SeqCst), 1); - assert!(stats.write_failed); + assert_eq!(stats.failure, Some(ForwardFailureKind::Write)); assert_eq!(stats.bytes_written, 0); } @@ -212,7 +217,7 @@ fn forward_output_with_ninja_status_parses_monotonic_updates() { |current, total, description| { updates.push((current, total, description.to_owned())); }, - "stdout", + ForwardStream::Stdout, ); assert_eq!(stats.bytes_read, input.len()); @@ -254,7 +259,7 @@ fn bounded_status_reader_forwards_large_unterminated_output() { ChunkedReader::new(input.clone(), vec![127; 4096]), &mut forwarded, |_current, _total, _description| {}, - "stdout", + ForwardStream::Stdout, ); assert_eq!(forwarded, input); @@ -294,7 +299,7 @@ fn split_status_line_updates_once_and_eof_partial_line_stays_bounded() { reader, &mut output, |current, total, description| updates.push((current, total, description.to_owned())), - "stdout", + ForwardStream::Stdout, ); assert_eq!(stats.bytes_read, input.len()); @@ -356,15 +361,18 @@ fn status_parsing_peak_rss_does_not_scale_with_a_large_unterminated_stream() { const RSS_ALLOWANCE_BYTES: u64 = 4 * 1024 * 1024; let plain_before = peak_rss_bytes().expect("getrusage should report initial RSS"); - let plain_stats = - forward_child_output(RepeatingByteReader::new(STREAM_BYTES), io::sink(), "stdout"); + let plain_stats = forward_child_output( + RepeatingByteReader::new(STREAM_BYTES), + io::sink(), + ForwardStream::Stdout, + ); let plain_after = peak_rss_bytes().expect("getrusage should report plain forwarding RSS"); let status_before = peak_rss_bytes().expect("getrusage should report status forwarding RSS"); let status_stats = forward_child_output_with_ninja_status( RepeatingByteReader::new(STREAM_BYTES), io::sink(), |_current, _total, _description| {}, - "stdout", + ForwardStream::Stdout, ); let status_after = peak_rss_bytes().expect("getrusage should report status parsing RSS"); diff --git a/src/runner/process/streaming/tests/failure_tests.rs b/src/runner/process/streaming/tests/failure_tests.rs new file mode 100644 index 000000000..17cf4cb0b --- /dev/null +++ b/src/runner/process/streaming/tests/failure_tests.rs @@ -0,0 +1,376 @@ +//! Verify forwarding failures are classified and counted with bounded labels. +//! +//! Each case drives the production forwarding helper with an injected reader +//! or writer, then reads the counter series the helper registered through a +//! local debugging recorder. The assertions compare the exact label values, so +//! an unclassified or unbounded series fails the test rather than widening the +//! vocabulary. + +use super::super::{FORWARD_FAILURES_TOTAL, ForwardFailureKind, ForwardStream}; +use super::{ChunkedReader, forward_child_output}; +use metrics::{SharedString, Unit}; +use metrics_util::{ + CompositeKey, + debugging::{DebugValue, DebuggingRecorder}, +}; +use std::{ + collections::HashMap, + io::{self, Cursor, Read, Write}, +}; + +/// One entry of a drained debugging-recorder snapshot. +type SnapshotEntry = (CompositeKey, Option, Option, DebugValue); + +/// Collect the recorded forwarding-failure series as `(stream, kind, count)`. +fn failure_series(entries: &[SnapshotEntry]) -> Vec<(String, String, u64)> { + let mut series: Vec<_> = entries + .iter() + .filter(|entry| entry.0.key().name() == FORWARD_FAILURES_TOTAL) + .map(|entry| { + let labels: HashMap<_, _> = entry + .0 + .key() + .labels() + .map(|label| (label.key(), label.value().to_owned())) + .collect(); + let DebugValue::Counter(count) = &entry.3 else { + panic!("forwarding-failure series must be a counter: {entry:?}"); + }; + let stream = labels + .get("stream") + .cloned() + .expect("recorded forwarding-failure series must carry a stream label"); + let kind = labels + .get("failure_kind") + .cloned() + .expect("recorded forwarding-failure series must carry a failure_kind label"); + (stream, kind, *count) + }) + .collect(); + series.sort(); + series +} + +/// Reader that fails its first read once, then reports end of input. +struct FailsOnce { + /// Error kind reported by the failing read. + kind: io::ErrorKind, + /// Whether the failing read has already been served. + failed: bool, +} + +impl Read for FailsOnce { + fn read(&mut self, _buffer: &mut [u8]) -> io::Result { + if self.failed { + return Ok(0); + } + self.failed = true; + Err(io::Error::new(self.kind, "read failed")) + } +} + +/// Reader that returns one chunk and then fails while the forwarder drains. +struct FailsWhileDraining { + /// Bytes delivered by the first read. + bytes: Vec, + /// Whether the chunk has already been delivered. + delivered: bool, +} + +impl Read for FailsWhileDraining { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if self.delivered { + return Err(io::Error::other("drain read failed")); + } + self.delivered = true; + let count = self.bytes.len().min(buffer.len()); + let (Some(source), Some(destination)) = (self.bytes.get(..count), buffer.get_mut(..count)) + else { + // `count` is the smaller of the two lengths, so both slices + // always exist; report EOF rather than panicking if that changes. + return Ok(0); + }; + destination.copy_from_slice(source); + Ok(count) + } +} + +/// Reader that reports one byte more than the buffer can hold, then EOF. +#[derive(Default)] +struct OverlongOnce { + /// Whether the invalid count has already been reported. + reported: bool, +} + +impl Read for OverlongOnce { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if self.reported { + return Ok(0); + } + self.reported = true; + Ok(buffer.len().saturating_add(1)) + } +} + +/// Reader that reports an interruption a fixed number of times before EOF. +struct Interrupting { + /// Interruptions still to report. + remaining: usize, +} + +impl Read for Interrupting { + fn read(&mut self, _buffer: &mut [u8]) -> io::Result { + if self.remaining > 0 { + self.remaining -= 1; + return Err(io::Error::new(io::ErrorKind::Interrupted, "interrupted")); + } + Ok(0) + } +} + +/// Writer that rejects every chunk with a closed pipe. +struct ClosedWriter; + +impl Write for ClosedWriter { + fn write(&mut self, _bytes: &[u8]) -> io::Result { + Err(io::Error::new(io::ErrorKind::BrokenPipe, "sink closed")) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +/// Writer that accepts its chunk but fails the following flush. +#[derive(Default)] +struct FlushFails { + /// Number of chunk writes attempted. + writes: usize, + /// Bytes accepted across those writes. + accepted: Vec, +} + +impl Write for FlushFails { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.writes += 1; + self.accepted.extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::BrokenPipe, "flush failed")) + } +} + +#[test] +fn read_failure_is_classified_and_counted_once() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let stats = metrics::with_local_recorder(&recorder, || { + forward_child_output( + FailsOnce { + kind: io::ErrorKind::Other, + failed: false, + }, + io::sink(), + ForwardStream::Stdout, + ) + }); + + assert_eq!(stats.failure, Some(ForwardFailureKind::Read)); + assert!(!stats.drain_failed); + assert_eq!(stats.bytes_read, 0); + assert_eq!( + failure_series(&snapshotter.snapshot().into_vec()), + vec![("stdout".to_owned(), "read".to_owned(), 1)] + ); +} + +#[test] +fn invalid_read_count_is_classified_as_a_read_failure() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let stats = metrics::with_local_recorder(&recorder, || { + forward_child_output(OverlongOnce::default(), io::sink(), ForwardStream::Stdout) + }); + + assert_eq!(stats.failure, Some(ForwardFailureKind::Read)); + assert!(!stats.drain_failed); + // The impossible count is credited to `bytes_read` before the bounds check + // rejects it, so this case asserts the classification, not the accounting. + assert_eq!( + failure_series(&snapshotter.snapshot().into_vec()), + vec![("stdout".to_owned(), "read".to_owned(), 1)] + ); +} + +#[test] +fn interrupted_reads_are_retried_without_recording_a_failure() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let input = b"retried".to_vec(); + let stats = metrics::with_local_recorder(&recorder, || { + forward_child_output( + io::Cursor::new(input.clone()).chain(Interrupting { remaining: 2 }), + Vec::new(), + ForwardStream::Stdout, + ) + }); + + assert_eq!( + ( + stats.failure, + stats.drain_failed, + stats.bytes_read, + stats.bytes_written, + ), + (None, false, input.len(), input.len()), + "interrupted reads must be retried without being recorded" + ); + assert!( + failure_series(&snapshotter.snapshot().into_vec()).is_empty(), + "retried interruptions must not be recorded as terminal failures" + ); +} + +#[test] +fn write_failures_are_counted_for_each_stream_separately() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let (stdout_stats, stderr_stats) = metrics::with_local_recorder(&recorder, || { + let stdout = forward_child_output( + Cursor::new(b"lost".to_vec()), + ClosedWriter, + ForwardStream::Stdout, + ); + let stderr = forward_child_output( + Cursor::new(b"lost".to_vec()), + ClosedWriter, + ForwardStream::Stderr, + ); + (stdout, stderr) + }); + + assert_eq!( + ( + stdout_stats.failure, + stdout_stats.bytes_read, + stdout_stats.bytes_written, + ), + (Some(ForwardFailureKind::Write), 4, 0), + "a closed stdout pipe must be classified as a write failure" + ); + assert_eq!( + ( + stderr_stats.failure, + stderr_stats.bytes_read, + stderr_stats.bytes_written, + ), + (Some(ForwardFailureKind::Write), 4, 0), + "a closed stderr pipe must be classified as a write failure" + ); + assert_eq!( + failure_series(&snapshotter.snapshot().into_vec()), + vec![ + ("stderr".to_owned(), "write".to_owned(), 1), + ("stdout".to_owned(), "write".to_owned(), 1), + ] + ); +} + +#[test] +fn flush_failure_keeps_accepted_bytes_without_rewriting_them() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let (stats, writer) = metrics::with_local_recorder(&recorder, || { + let mut writer = FlushFails::default(); + let stats = forward_child_output( + ChunkedReader::new(b"firstlast".to_vec(), vec![5, 4]), + &mut writer, + ForwardStream::Stdout, + ); + (stats, writer) + }); + + assert_eq!( + ( + stats.failure, + stats.drain_failed, + stats.bytes_read, + stats.bytes_written, + ), + ( + Some(ForwardFailureKind::Flush), + false, + "firstlast".len(), + "first".len(), + ), + "a failed flush must keep the bytes it already accepted" + ); + assert_eq!( + (writer.writes, writer.accepted.as_slice()), + (1, b"first".as_slice()), + "accepted bytes must not be rewritten" + ); + assert_eq!( + failure_series(&snapshotter.snapshot().into_vec()), + vec![("stdout".to_owned(), "flush".to_owned(), 1)] + ); +} + +#[test] +fn drain_failure_is_recorded_without_replacing_the_initiating_failure() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let stats = metrics::with_local_recorder(&recorder, || { + forward_child_output( + FailsWhileDraining { + bytes: b"first".to_vec(), + delivered: false, + }, + ClosedWriter, + ForwardStream::Stderr, + ) + }); + + assert_eq!( + ( + stats.failure, + stats.drain_failed, + stats.bytes_read, + stats.bytes_written, + ), + (Some(ForwardFailureKind::Write), true, b"first".len(), 0), + "a drain failure must not replace the initiating write failure" + ); + assert_eq!( + failure_series(&snapshotter.snapshot().into_vec()), + vec![ + ("stderr".to_owned(), "drain".to_owned(), 1), + ("stderr".to_owned(), "write".to_owned(), 1), + ] + ); +} + +#[test] +fn successful_forwarding_records_no_failure_series() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let stats = metrics::with_local_recorder(&recorder, || { + forward_child_output( + Cursor::new(b"forwarded".to_vec()), + Vec::new(), + ForwardStream::Stdout, + ) + }); + + assert_eq!(stats.failure, None); + assert_eq!(stats.bytes_written, "forwarded".len()); + assert!( + snapshotter + .snapshot() + .into_vec() + .iter() + .all(|entry| { entry.0.key().name() != FORWARD_FAILURES_TOTAL }) + ); +} diff --git a/src/runner/process/streaming/tests/flush_tests.rs b/src/runner/process/streaming/tests/flush_tests.rs new file mode 100644 index 000000000..43c1aa920 --- /dev/null +++ b/src/runner/process/streaming/tests/flush_tests.rs @@ -0,0 +1,184 @@ +//! Verify each forwarded output chunk becomes visible before the next read. + +use super::{ChunkedReader, ForwardFailureKind, ForwardStream, forward_child_output}; +use proptest::prelude::*; +use std::{ + cell::RefCell, + io::{self, Read, Write}, + rc::Rc, +}; + +#[derive(Debug, PartialEq, Eq)] +enum ForwardingEvent { + Read(usize), + Flush, +} + +struct ObservedReader { + inner: R, + events: Rc>>, +} + +impl Read for ObservedReader { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + let count = self.inner.read(buffer)?; + self.events.borrow_mut().push(ForwardingEvent::Read(count)); + Ok(count) + } +} + +#[derive(Default)] +struct FlushBufferWriter { + pending: Vec, + visible: Vec, + snapshots: Vec>, + maximum_write: usize, + write_count: usize, + events: Rc>>, +} + +impl FlushBufferWriter { + fn new(maximum_write: usize, events: Rc>>) -> Self { + Self { + maximum_write, + events, + ..Self::default() + } + } +} + +impl Write for FlushBufferWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.write_count = self.write_count.saturating_add(1); + let count = bytes.len().min(self.maximum_write); + let Some(prefix) = bytes.get(..count) else { + return Err(io::Error::other("writer prefix is outside the input")); + }; + self.pending.extend_from_slice(prefix); + Ok(count) + } + + fn flush(&mut self) -> io::Result<()> { + self.events.borrow_mut().push(ForwardingEvent::Flush); + self.visible.append(&mut self.pending); + self.snapshots.push(self.visible.clone()); + Ok(()) + } +} + +proptest! { + #[test] + fn copy_and_flush_preserves_generated_chunks_and_flush_order( + chunks in prop::collection::vec( + prop::collection::vec(any::(), 1..20), + 0..9, + ), + maximum_write in 1_usize..9, + ) { + let input = chunks.iter().flatten().copied().collect::>(); + let chunk_sizes = chunks.iter().map(Vec::len).collect::>(); + let events = Rc::default(); + let mut reader = ObservedReader { + inner: ChunkedReader::new(input.clone(), chunk_sizes), + events: Rc::clone(&events), + }; + let mut writer = FlushBufferWriter::new(maximum_write, Rc::clone(&events)); + + let result = super::super::copy_and_flush(&mut reader, &mut writer); + prop_assert!(result.is_ok(), "copy_and_flush failed: {:?}", result.err()); + prop_assert_eq!(&writer.visible, &input); + + let mut visible = Vec::new(); + let expected_snapshots = chunks.iter().map(|chunk| { + visible.extend_from_slice(chunk); + visible.clone() + }).collect::>(); + prop_assert_eq!(&writer.snapshots, &expected_snapshots); + + let expected_write_count = chunks.iter() + .map(|chunk| chunk.len().div_ceil(maximum_write)) + .sum::(); + prop_assert_eq!(writer.write_count, expected_write_count); + + let mut expected_events = Vec::new(); + for chunk in &chunks { + expected_events.push(ForwardingEvent::Read(chunk.len())); + expected_events.push(ForwardingEvent::Flush); + } + expected_events.push(ForwardingEvent::Read(0)); + prop_assert_eq!(&*events.borrow(), &expected_events); + } +} + +#[test] +fn forward_output_flushes_each_complete_chunk_before_reading_more() { + let input = b"firstlast".to_vec(); + let events = Rc::default(); + let reader = ObservedReader { + inner: ChunkedReader::new(input.clone(), vec![5, 4]), + events: Rc::clone(&events), + }; + let mut writer = FlushBufferWriter::new(2, Rc::clone(&events)); + + let stats = forward_child_output(reader, &mut writer, ForwardStream::Stdout); + + assert_eq!(stats.bytes_read, input.len()); + assert_eq!(stats.bytes_written, input.len()); + assert_eq!(stats.failure, None); + assert_eq!( + *events.borrow(), + vec![ + ForwardingEvent::Read(5), + ForwardingEvent::Flush, + ForwardingEvent::Read(4), + ForwardingEvent::Flush, + ForwardingEvent::Read(0), + ] + ); + assert_eq!( + writer.snapshots, + vec![b"first".to_vec(), b"firstlast".to_vec()] + ); +} + +struct FailingFlushWriter { + accepted: Vec, + write_count: usize, + flush_count: usize, +} + +impl Write for FailingFlushWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.write_count += 1; + self.accepted.extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.flush_count += 1; + Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "destination flush failed", + )) + } +} + +#[test] +fn flush_failure_keeps_accepted_bytes_and_drains_the_remaining_input() { + let input = b"firstlast".to_vec(); + let reader = ChunkedReader::new(input.clone(), vec![5, 4]); + let mut writer = FailingFlushWriter { + accepted: Vec::new(), + write_count: 0, + flush_count: 0, + }; + + let stats = forward_child_output(reader, &mut writer, ForwardStream::Stdout); + + assert_eq!(writer.accepted, b"first"); + assert_eq!(writer.write_count, 1, "accepted bytes must not be retried"); + assert_eq!(writer.flush_count, 1); + assert_eq!(stats.bytes_read, input.len(), "remaining bytes are drained"); + assert_eq!(stats.bytes_written, b"first".len()); + assert_eq!(stats.failure, Some(ForwardFailureKind::Flush)); +} diff --git a/src/runner/process/test_fixtures.rs b/src/runner/process/test_fixtures.rs index d10e1a8f7..d665f8e25 100644 --- a/src/runner/process/test_fixtures.rs +++ b/src/runner/process/test_fixtures.rs @@ -49,6 +49,7 @@ pub(super) fn insert_edge( progress_label: None, phony, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }); assert!(result.is_ok(), "test edge outputs should be unique"); } diff --git a/src/runner/process/tests/command_list_targets.rs b/src/runner/process/tests/command_list_targets.rs index 721398cba..453b0dbf8 100644 --- a/src/runner/process/tests/command_list_targets.rs +++ b/src/runner/process/tests/command_list_targets.rs @@ -84,6 +84,7 @@ fn implicit_output_is_used_when_an_edge_has_no_explicit_output( progress_label: None, phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }); assert!(result.is_ok(), "test edge outputs should be unique"); diff --git a/src/runner/process/tests/mod.rs b/src/runner/process/tests/mod.rs index ef5b4bc2c..903af6e1f 100644 --- a/src/runner/process/tests/mod.rs +++ b/src/runner/process/tests/mod.rs @@ -3,8 +3,11 @@ use super::child_exit::finalize_streaming; #[cfg(unix)] use super::list_failure_telemetry::COMMAND_LIST_FAILURE_DURATION; -use super::streaming::ForwardStats; use super::*; +use super::{ + failure_attribution::CommandListFailure, + streaming::{ForwardFailureKind, ForwardStats}, +}; use crate::test_tracing_capture::with_test_subscriber; use camino::{Utf8Path, Utf8PathBuf}; #[cfg(unix)] @@ -68,6 +71,75 @@ fn finalize_streaming_joins_stderr_thread_when_wait_fails() { ); } +/// Build forwarding statistics for a stream that lost part of its output. +fn failed_forwarding_stats(kind: ForwardFailureKind) -> ForwardStats { + ForwardStats { + failure: Some(kind), + ..ForwardStats::default() + } +} + +/// Report a child exit status without spawning a process. +#[cfg(unix)] +fn child_exit_status(code: i32) -> std::process::ExitStatus { + use std::os::unix::process::ExitStatusExt; + + std::process::ExitStatus::from_raw(code << 8) +} + +/// Report a child exit status without spawning a process. +#[cfg(windows)] +fn child_exit_status(code: u32) -> std::process::ExitStatus { + use std::os::windows::process::ExitStatusExt; + + std::process::ExitStatus::from_raw(code) +} + +/// Join a stderr forwarding thread that recorded no command-list failure. +fn joined_stderr( + stats: ForwardStats, +) -> thread::JoinHandle<(ForwardStats, Option)> { + thread::spawn(move || (stats, None)) +} + +#[test] +fn forwarding_loss_preserves_a_successful_child_result() { + let status = child_exit_status(0); + let result = finalize_streaming( + Ok(status), + failed_forwarding_stats(ForwardFailureKind::Flush), + joined_stderr(failed_forwarding_stats(ForwardFailureKind::Write)), + ) + .expect("forwarding loss must not turn a successful child into an error"); + + assert_eq!(result.0, status); + assert!(result.1.is_none()); +} + +#[test] +fn forwarding_loss_preserves_a_failed_child_result() { + let status = child_exit_status(1); + let result = finalize_streaming( + Ok(status), + failed_forwarding_stats(ForwardFailureKind::Write), + joined_stderr(ForwardStats::default()), + ) + .expect("the child's own exit status remains the reported result"); + + assert_eq!(result.0, status); +} + +#[test] +fn forwarding_loss_does_not_mask_a_wait_failure() { + let result = finalize_streaming( + Err(io::Error::other("simulated wait failure")), + failed_forwarding_stats(ForwardFailureKind::Read), + joined_stderr(failed_forwarding_stats(ForwardFailureKind::Drain)), + ); + + assert!(result.is_err(), "a wait() failure must still propagate"); +} + #[cfg(unix)] #[test] fn command_list_failure_duration_uses_the_injected_monotonic_clock() { diff --git a/src/runner/reporter.rs b/src/runner/reporter.rs index 7692aefe4..3b6893145 100644 --- a/src/runner/reporter.rs +++ b/src/runner/reporter.rs @@ -1,4 +1,4 @@ -//! Construction of the run's [`StatusReporter`] from resolved output settings. +//! Construct reporters and adapt their progress updates for Ninja execution. use crate::output_mode::OutputMode; use crate::output_prefs::OutputPrefs; @@ -50,6 +50,15 @@ const fn should_force_text_task_updates(mode: OutputMode, stdout_is_tty: bool) - mode.is_accessible() || !stdout_is_tty } +/// Adapt the reporter's task-progress method into a mutable callback. +pub(super) fn on_task_progress_callback( + reporter: &dyn StatusReporter, +) -> impl FnMut(u32, u32, &str) + '_ { + move |current: u32, total: u32, description: &str| { + reporter.report_task_progress(current, total, description); + } +} + #[cfg(test)] mod tests { //! Unit tests for the forced-text-update predicate. diff --git a/src/runner/tests/mod.rs b/src/runner/tests/mod.rs index 2b1f9e20e..27c9ff49e 100644 --- a/src/runner/tests/mod.rs +++ b/src/runner/tests/mod.rs @@ -22,6 +22,7 @@ use tracing_subscriber::filter::LevelFilter; #[cfg(feature = "lint")] mod check_dispatch_tests; mod manifest_structure_telemetry_tests; +mod ninja_build_telemetry_tests; #[path = "shell_seam_tests.rs"] mod shell_seam_tests; @@ -246,6 +247,7 @@ fn ninja_text_propagates_typed_generation_errors() { progress_label: None, phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }) .expect("test graph output aliases must be unique"); diff --git a/src/runner/tests/ninja_build_telemetry_tests.rs b/src/runner/tests/ninja_build_telemetry_tests.rs new file mode 100644 index 000000000..3afe4a99e --- /dev/null +++ b/src/runner/tests/ninja_build_telemetry_tests.rs @@ -0,0 +1,45 @@ +//! Check telemetry boundaries around runner-owned Ninja builds. + +use super::*; +use crate::runner::{NINJA_BUILD_EXECUTIONS_TOTAL, run_with_ninja_program}; +use anyhow::{Result, ensure}; +use metrics_util::debugging::DebuggingRecorder; +use rstest::rstest; +use test_support::{localizer_test_lock, set_en_localizer}; + +#[rstest] +#[case::progress_enabled(crate::cli::ProgressPolicy::Auto)] +#[case::progress_disabled(crate::cli::ProgressPolicy::Never)] +fn invalid_job_options_do_not_record_a_ninja_build( + #[case] progress: crate::cli::ProgressPolicy, +) -> Result<()> { + let _lock = localizer_test_lock().map_err(|error| anyhow::anyhow!("{error}"))?; + let _guard = set_en_localizer(); + let (_temp, manifest_path) = write_manifest(MINIMAL_MANIFEST)?; + let cli = Cli { + file: manifest_path, + jobs: Some(0), + progress, + ..Cli::default() + }; + let program = camino::Utf8Path::new("ninja"); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + + let (result, metrics) = metrics::with_local_recorder(&recorder, || { + let result = run_with_ninja_program(&cli, crate::output_prefs::resolve(None), program); + (result, snapshotter.snapshot().into_vec()) + }); + + ensure!( + result.is_err(), + "an invalid job count must fail before Ninja runs" + ); + ensure!( + !metrics + .iter() + .any(|(key, _, _, _)| key.key().name() == NINJA_BUILD_EXECUTIONS_TOTAL), + "option validation must not record a Ninja build: {metrics:?}" + ); + Ok(()) +} diff --git a/src/snapshots/manifest_load_options/from_path_with_limits.snap b/src/snapshots/manifest_load_options/from_path_with_limits.snap index 932dfadaf..0a375e82f 100644 --- a/src/snapshots/manifest_load_options/from_path_with_limits.snap +++ b/src/snapshots/manifest_load_options/from_path_with_limits.snap @@ -1,5 +1,6 @@ --- source: src/manifest/tests/wrapper_equivalence.rs +assertion_line: 146 expression: document --- === pure === @@ -16,6 +17,7 @@ expression: document }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -46,6 +48,7 @@ Manifest parse failed.: YAML parse error at line ⁨1⁩, column ⁨11⁩: ⁨un }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: True/False", diff --git a/test_support/src/lib.rs b/test_support/src/lib.rs index 82e75da16..308ddbfa1 100644 --- a/test_support/src/lib.rs +++ b/test_support/src/lib.rs @@ -32,6 +32,7 @@ pub mod localizer; pub mod manifest; pub mod netsuke; pub mod ninja; +pub mod ninja_edge; pub mod ninja_gen; pub mod ninja_semantics; pub mod stdlib_assert; diff --git a/test_support/src/ninja_edge.rs b/test_support/src/ninja_edge.rs new file mode 100644 index 000000000..38d5b97f3 --- /dev/null +++ b/test_support/src/ninja_edge.rs @@ -0,0 +1,184 @@ +//! Inspect named build-edge bindings in generated Ninja text. +//! +//! This module gives tests one shared way to check edge-local metadata such as +//! `pool = console`. Keep it limited to simple generated-manifest assertions; +//! parsing arbitrary Ninja syntax belongs in a real parser. + +use anyhow::{Context, Result}; + +/// Return whether the named output edge selects Ninja's console pool. +/// +/// The lookup is scoped to the selected `build` statement, so a pool binding +/// inherited from a rule or attached to a neighbouring edge cannot satisfy the +/// assertion. +/// +/// # Examples +/// +/// ``` +/// use test_support::ninja_edge::edge_has_console_pool; +/// +/// let ninja = "build lint: command\n pool = console\n"; +/// assert!(edge_has_console_pool(ninja, "lint")?); +/// # Ok::<(), anyhow::Error>(()) +/// ``` +/// +/// # Errors +/// +/// Returns an error when the named build edge is absent. +pub fn edge_has_console_pool(ninja: &str, output: &str) -> Result { + Ok(edge_bindings(ninja, output)? + .into_iter() + .any(|line| line.trim() == "pool = console")) +} + +/// Return every edge-local `pool` binding attached to the named build edge. +/// +/// A console edge carries `pool = console`, an edge that inherits a console +/// action pool but does not opt in carries the empty `pool =` clearing +/// binding, and an edge with no console inheritance carries none. Returning +/// the bindings lets a test distinguish those three states instead of +/// reducing them to a Boolean. +/// +/// # Examples +/// +/// ``` +/// use test_support::ninja_edge::edge_pool_bindings; +/// +/// let ninja = "build ordinary: command\n pool =\n"; +/// assert_eq!(edge_pool_bindings(ninja, "ordinary")?, vec!["pool =".to_owned()]); +/// # Ok::<(), anyhow::Error>(()) +/// ``` +/// +/// # Errors +/// +/// Returns an error when the named build edge is absent. +pub fn edge_pool_bindings(ninja: &str, output: &str) -> Result> { + Ok(edge_bindings(ninja, output)? + .into_iter() + .filter(|line| line.trim_start().starts_with("pool =")) + .map(|line| line.trim().to_owned()) + .collect()) +} + +/// Return the indented binding lines that follow the named build statement. +/// +/// Ninja ends an edge at the first line that is not indented, so taking the +/// contiguous indented run scopes every lookup to one build statement and +/// keeps a neighbouring edge's binding from satisfying an assertion. +fn edge_bindings<'a>(ninja: &'a str, output: &str) -> Result> { + let lines: Vec<_> = ninja.lines().collect(); + let edge_index = lines + .iter() + .position(|line| build_edge_has_output(line, output)) + .with_context(|| format!("missing Ninja build edge for {output}"))?; + + Ok(lines + .iter() + .skip(edge_index + 1) + .take_while(|line| line.starts_with(" ")) + .copied() + .collect()) +} + +/// Return whether a generated build statement names `output` in its output list. +fn build_edge_has_output(line: &str, output: &str) -> bool { + let Some(outputs) = line + .strip_prefix("build ") + .and_then(|statement| statement.split_once(": ").map(|(outputs, _)| outputs)) + else { + return false; + }; + + let ninja_output = output.replace(' ', "$ "); + ninja_output_tokens(outputs) + .iter() + .any(|candidate| !candidate.is_empty() && candidate == &ninja_output) +} + +/// Split generated Ninja outputs while retaining escaped spaces within a path. +fn ninja_output_tokens(outputs: &str) -> Vec { + let mut tokens = Vec::new(); + let mut token = String::new(); + let mut is_escaped = false; + + for character in outputs.chars() { + match character { + '$' if !is_escaped => { + token.push('$'); + is_escaped = true; + } + escaped if is_escaped => { + token.push(escaped); + is_escaped = false; + } + delimiter if delimiter.is_whitespace() || delimiter == '|' => { + tokens.push(std::mem::take(&mut token)); + } + other => token.push(other), + } + } + + tokens.push(token); + + tokens +} + +#[cfg(test)] +mod tests { + //! Cover output-list matching for generated Ninja build statements. + + use super::{edge_has_console_pool, edge_pool_bindings}; + use anyhow::{Result, ensure}; + + #[test] + fn console_pool_lookup_matches_explicit_and_implicit_outputs() -> Result<()> { + let ninja = "build first second | implicit: command\n pool = console\n"; + + ensure!(edge_has_console_pool(ninja, "second")?); + ensure!(edge_has_console_pool(ninja, "implicit")?); + Ok(()) + } + + #[test] + fn console_pool_lookup_matches_escaped_spaces_in_outputs() -> Result<()> { + let ninja = "build first$ output: command\n pool = console\n"; + + ensure!(edge_has_console_pool(ninja, "first output")?); + Ok(()) + } + + #[test] + fn console_pool_lookup_does_not_match_a_partial_output() { + let ninja = "build target-extra: command\n pool = console\n"; + + assert!(edge_has_console_pool(ninja, "target").is_err()); + } + + #[test] + fn pool_bindings_distinguish_selected_cleared_and_absent_states() -> Result<()> { + let ninja = concat!( + "build selected: command\n pool = console\n\n", + "build cleared: command\n pool =\n\n", + "build absent: command\n command = touch absent\n", + ); + + ensure!(edge_pool_bindings(ninja, "selected")? == vec!["pool = console".to_owned()]); + ensure!(edge_pool_bindings(ninja, "cleared")? == vec!["pool =".to_owned()]); + ensure!(edge_pool_bindings(ninja, "absent")?.is_empty()); + Ok(()) + } + + #[test] + fn pool_bindings_are_scoped_to_the_named_edge() -> Result<()> { + let ninja = "build first: command\n pool = console\n\nbuild second: command\n"; + + ensure!(edge_pool_bindings(ninja, "first")? == vec!["pool = console".to_owned()]); + ensure!(edge_pool_bindings(ninja, "second")?.is_empty()); + Ok(()) + } + + #[test] + fn pool_bindings_report_a_missing_edge() { + assert!(edge_pool_bindings("build present: command\n", "absent").is_err()); + } +} diff --git a/tests/ast_tests.rs b/tests/ast_tests.rs index ff85e6e54..72db06198 100644 --- a/tests/ast_tests.rs +++ b/tests/ast_tests.rs @@ -6,6 +6,9 @@ #[path = "ast_tests/actions.rs"] mod actions; +#[path = "ast_tests/console.rs"] +mod console; + #[path = "ast_tests/dependency_order.rs"] mod dependency_order; diff --git a/tests/ast_tests/actions.rs b/tests/ast_tests/actions.rs index 96edb47ab..bac80a30f 100644 --- a/tests/ast_tests/actions.rs +++ b/tests/ast_tests/actions.rs @@ -18,6 +18,7 @@ use super::support::parse_manifest; command: "true" "#, true, + false, false )] #[case::explicit_phony_false( @@ -32,6 +33,22 @@ use super::support::parse_manifest; command: "true" "#, true, + false, + false +)] +#[case::explicit_console_false( + r#" + netsuke_version: "1.0.0" + actions: + - name: setup + command: "echo hi" + console: false + targets: + - name: done + command: "true" +"#, + true, + false, false )] #[case::explicit_always_true( @@ -41,10 +58,12 @@ use super::support::parse_manifest; - name: setup command: "echo hi" always: true + console: true targets: - name: done command: "true" "#, + true, true, true )] @@ -52,6 +71,7 @@ fn actions_behaviour( #[case] yaml: &str, #[case] expected_phony: bool, #[case] expected_always: bool, + #[case] expected_console: bool, ) -> Result<()> { let manifest = parse_manifest(yaml)?; let action = manifest.actions.first().context("expected action entry")?; @@ -67,6 +87,12 @@ fn actions_behaviour( action.always, expected_always ); + ensure!( + action.console == expected_console, + "unexpected console flag: got {}, expected {}", + action.console, + expected_console + ); Ok(()) } diff --git a/tests/ast_tests/console.rs b/tests/ast_tests/console.rs new file mode 100644 index 000000000..72d8f7918 --- /dev/null +++ b/tests/ast_tests/console.rs @@ -0,0 +1,79 @@ +//! Tests for the opt-in Ninja console scheduling field. + +use anyhow::{Context, Result, ensure}; +use netsuke::localization::{self, keys}; +use rstest::rstest; + +use super::support::parse_manifest; +use netsuke::manifest::ManifestError; + +/// Default target console scheduling to false and accept either explicit value. +#[rstest] +#[case::omitted("", false)] +#[case::explicit_false("console: false", false)] +#[case::explicit_true("console: true", true)] +fn target_console_defaults_and_overrides( + #[case] console_field: &str, + #[case] expected_console: bool, +) -> Result<()> { + let yaml = format!( + "netsuke_version: '1.0.0'\ntargets:\n - name: lint\n command: 'cargo clippy'\n {console_field}\n" + ); + let manifest = parse_manifest(&yaml)?; + let target = manifest.targets.first().context("expected target entry")?; + ensure!( + target.console == expected_console, + "unexpected console flag: got {}, expected {}", + target.console, + expected_console + ); + Ok(()) +} + +/// Preserve the parse diagnostic path while localizing dependency-only console errors. +#[test] +fn dependency_only_console_manifest_error_is_localized() -> Result<()> { + let _locale = test_support::localizer::locale_localizer("fr"); + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - name: all + deps: [lint] + console: true + "#; + let error = parse_manifest(yaml).expect_err("dependency-only console must be rejected"); + ensure!( + matches!( + error.downcast_ref::(), + Some(ManifestError::Parse { .. }) + ), + "manifest validation should retain the parse diagnostic path: {error:#}" + ); + let rendered = format!("{error:#}"); + let expected = localization::message(keys::MANIFEST_CONSOLE_DEPENDENCY_ONLY).to_string(); + ensure!( + rendered.contains(&expected), + "dependency-only console diagnostic should use the French catalogue: {rendered}" + ); + Ok(()) +} + +/// Keep contention fields unknown until RFC 0024 defines a manifest schema. +#[test] +fn contention_remains_an_unknown_manifest_field() -> Result<()> { + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - name: lint + command: "cargo clippy" + console: true + contention: developer-gates + "#; + let error = parse_manifest(yaml).expect_err("contention must be rejected"); + let rendered = format!("{error:#}"); + ensure!( + rendered.contains("unknown field `contention`"), + "contention should fail as an unknown manifest field: {rendered}" + ); + Ok(()) +} diff --git a/tests/ast_tests/dependency_only.rs b/tests/ast_tests/dependency_only.rs index b126f2fc2..e4e68f119 100644 --- a/tests/ast_tests/dependency_only.rs +++ b/tests/ast_tests/dependency_only.rs @@ -4,10 +4,10 @@ use anyhow::{Context, Result, ensure}; use netsuke::localization::{self, keys}; use netsuke::{ ast::{CommandIsolation, NetsukeManifest, Recipe, StringOrList}, - ir::BuildGraph, + ir::{BuildGraph, IrGenError}, }; use rstest::rstest; -use test_support::display_error_chain; +use test_support::{display_error_chain, localizer::locale_localizer}; use super::support::parse_manifest; @@ -225,3 +225,60 @@ fn direct_ast_deserialisation_is_validated_before_ir_lowering() -> Result<()> { ); Ok(()) } + +/// Reject console scheduling on dependency-only entries during parsing. +#[rstest] +#[case::action("actions:\n - name: all\n deps: [lint]\n console: true\ntargets: []\n")] +#[case::target("actions: []\ntargets:\n - name: release\n deps: [build]\n console: true\n")] +fn dependency_only_console_entries_are_rejected(#[case] sections: &str) -> Result<()> { + let _locale = locale_localizer("en-US"); + let yaml = format!("netsuke_version: '1.0.0'\n{sections}"); + let error = + parse_manifest(&yaml).expect_err("dependency-only entries cannot use the console pool"); + let chain = display_error_chain(error.as_ref()); + let expected = localization::message(keys::MANIFEST_CONSOLE_DEPENDENCY_ONLY).to_string(); + ensure!( + chain.contains(&expected), + "unexpected console dependency-only diagnostic: {chain}" + ); + Ok(()) +} + +/// Preserve the console-specific validation diagnostic during direct IR lowering. +#[test] +fn direct_ir_lowering_preserves_console_dependency_only_diagnostic() -> Result<()> { + let error = { + let _locale = locale_localizer("en-US"); + let yaml = concat!( + "netsuke_version: '1.0.0'\n", + "targets:\n", + " - name: release\n", + " deps: [build]\n", + " console: true\n", + ); + let manifest: NetsukeManifest = + serde_saphyr::from_str(yaml).context("directly deserialize the manifest")?; + BuildGraph::from_manifest(&manifest) + .expect_err("console scheduling on an aggregate should fail IR lowering") + }; + ensure!( + matches!( + &error, + IrGenError::InvalidManifest { message } + if *message == "console cannot be enabled for dependency-only entries" + ), + "direct AST lowering should preserve the console diagnostic code: {error}" + ); + let rendered = error.to_string(); + let expected = "console cannot be enabled for dependency-only entries"; + ensure!( + rendered == expected, + "direct AST lowering should render its stable diagnostic: {rendered}" + ); + let _locale = locale_localizer("fr"); + ensure!( + error.to_string() == expected, + "IR diagnostic rendering must not depend on the active locale" + ); + Ok(()) +} diff --git a/tests/ast_tests/parsing.rs b/tests/ast_tests/parsing.rs index 9450b59fb..0b12fd1c5 100644 --- a/tests/ast_tests/parsing.rs +++ b/tests/ast_tests/parsing.rs @@ -45,7 +45,6 @@ targets: } Ok(()) } - #[test] fn missing_required_fields() -> Result<()> { { diff --git a/tests/bdd/steps/manifest/actions.rs b/tests/bdd/steps/manifest/actions.rs new file mode 100644 index 000000000..a4e0a3c53 --- /dev/null +++ b/tests/bdd/steps/manifest/actions.rs @@ -0,0 +1,26 @@ +//! Action-specific step definitions for manifest parsing scenarios. + +use super::assert_action_console; +use crate::bdd::fixtures::TestWorld; +use anyhow::Result; +use rstest_bdd_macros::then; + +/// Assert that an action requests Ninja's console pool. +/// +/// # Errors +/// +/// Returns an error when the action is missing or does not request the pool. +#[then("the action {index:usize} requests console scheduling")] +fn action_requests_console_scheduling(world: &TestWorld, index: usize) -> Result<()> { + assert_action_console(world, index, true) +} + +/// Assert that an action keeps ordinary Ninja scheduling. +/// +/// # Errors +/// +/// Returns an error when the action is missing or requests the pool. +#[then("the action {index:usize} does not request console scheduling")] +fn action_does_not_request_console_scheduling(world: &TestWorld, index: usize) -> Result<()> { + assert_action_console(world, index, false) +} diff --git a/tests/bdd/steps/manifest/helpers.rs b/tests/bdd/steps/manifest/helpers.rs index fb01067aa..23eedf421 100644 --- a/tests/bdd/steps/manifest/helpers.rs +++ b/tests/bdd/steps/manifest/helpers.rs @@ -164,6 +164,21 @@ pub(super) fn assert_target_always(world: &TestWorld, index: usize, expected: bo }) } +/// Validate the console-scheduling flag on a target. +/// +/// # Errors +/// +/// Returns an error when the target is missing or its flag differs. +pub(super) fn assert_target_console(world: &TestWorld, index: usize, expected: bool) -> Result<()> { + with_target(world, index, |target| { + ensure!( + target.console == expected, + "target {index} console scheduling should be {expected}" + ); + Ok(()) + }) +} + /// Generic manifest collection count assertion. fn assert_manifest_count( world: &TestWorld, diff --git a/tests/bdd/steps/manifest/mod.rs b/tests/bdd/steps/manifest/mod.rs index 314bf57e8..e4f6d0aef 100644 --- a/tests/bdd/steps/manifest/mod.rs +++ b/tests/bdd/steps/manifest/mod.rs @@ -5,6 +5,7 @@ //! - `helpers.rs` - Typed assertion utilities and target accessor functions //! - `targets.rs` - Target-specific assertion steps +mod actions; pub(super) mod environment; mod helpers; mod targets; @@ -298,6 +299,21 @@ where with_manifest_error_context(world, result.context("manifest has not been parsed"))? } +/// Validate the console-scheduling flag on an action. +/// +/// # Errors +/// +/// Returns an error when the action is missing or its flag differs. +fn assert_action_console(world: &TestWorld, index: usize, expected: bool) -> Result<()> { + with_action(world, index, |action| { + ensure!( + action.console == expected, + "action {index} console scheduling should be {expected}" + ); + Ok(()) + }) +} + #[then("the manifest has {count:usize} actions")] fn manifest_has_actions(world: &TestWorld, count: usize) -> Result<()> { let result = world.manifest.with_ref(|m| { diff --git a/tests/bdd/steps/manifest/targets.rs b/tests/bdd/steps/manifest/targets.rs index 21c35de1a..8a725a592 100644 --- a/tests/bdd/steps/manifest/targets.rs +++ b/tests/bdd/steps/manifest/targets.rs @@ -5,9 +5,9 @@ use super::helpers::{ assert_macro_count, assert_macro_signature_eq, assert_target_always, assert_target_command_eq, - assert_target_count, assert_target_has_dep, assert_target_has_order_only_dep, - assert_target_has_source, assert_target_name_eq, assert_target_phony, assert_target_rule_eq, - assert_target_script_eq, with_target, + assert_target_console, assert_target_count, assert_target_has_dep, + assert_target_has_order_only_dep, assert_target_has_source, assert_target_name_eq, + assert_target_phony, assert_target_rule_eq, assert_target_script_eq, with_target, }; use super::{get_string_from_string_or_list, with_manifest_error_context}; use crate::bdd::fixtures::{RefCellOptionExt, TestWorld}; @@ -60,6 +60,26 @@ fn target_not_always(world: &TestWorld, index: usize) -> Result<()> { assert_target_always(world, index, false) } +/// Assert that a target requests Ninja's console pool. +/// +/// # Errors +/// +/// Returns an error when the target is missing or does not request the pool. +#[then("the target {index:usize} requests console scheduling")] +fn target_requests_console_scheduling(world: &TestWorld, index: usize) -> Result<()> { + assert_target_console(world, index, true) +} + +/// Assert that a target keeps ordinary Ninja scheduling. +/// +/// # Errors +/// +/// Returns an error when the target is missing or requests the pool. +#[then("the target {index:usize} does not request console scheduling")] +fn target_does_not_request_console_scheduling(world: &TestWorld, index: usize) -> Result<()> { + assert_target_console(world, index, false) +} + #[expect( clippy::shadow_reuse, reason = "rstest-bdd macro generates wrapper; FIXME: https://github.com/leynos/rstest-bdd/issues/381" diff --git a/tests/bdd/steps/ninja.rs b/tests/bdd/steps/ninja.rs index ec4cce8a7..a16ed44eb 100644 --- a/tests/bdd/steps/ninja.rs +++ b/tests/bdd/steps/ninja.rs @@ -7,13 +7,60 @@ use anyhow::{Context, Result, anyhow, ensure}; use camino::Utf8PathBuf; use cap_std::{ambient_authority, fs_utf8::Dir}; use mockable::{DefaultEnv, Env}; +use netsuke::ast::{CommandIsolation, Recipe, StringOrList}; +use netsuke::ir::{Action, BuildEdge, BuildGraph, DependencyOrder}; use netsuke::ninja_gen::{self, RecipeShell}; -use rstest_bdd_macros::{then, when}; +use rstest_bdd_macros::{given, then, when}; use std::process::Command; use test_support::ninja::ninja_integration_workspace; const SENTINEL: &str = "NETSUKE_TEST_SENTINEL"; +// --------------------------------------------------------------------------- +// Given steps +// --------------------------------------------------------------------------- + +/// Set up a dependency-only graph whose edge requests console scheduling. +/// +/// # Errors +/// +/// Returns an error when the graph edge cannot be inserted. +#[given("a dependency-only action graph with console scheduling")] +fn dependency_only_console_graph(world: &TestWorld) -> Result<()> { + let mut graph = BuildGraph::default(); + graph.actions.insert( + "aggregate".into(), + Action { + recipe: Recipe::Command { + command: StringOrList::Empty, + isolation: CommandIsolation::Shared, + }, + description: None, + depfile: None, + deps_format: None, + pool: None, + restat: false, + }, + ); + graph + .insert_edge(BuildEdge { + action_id: "aggregate".into(), + inputs: Vec::new(), + implicit_deps: vec!["lint".into(), "test".into()], + dependency_order: DependencyOrder::Parallel, + explicit_outputs: vec!["all".into()], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + progress_label: None, + phony: true, + always: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::Immediate, + }) + .context("insert dependency-only console edge")?; + world.build_graph.set_value(graph); + Ok(()) +} + // --------------------------------------------------------------------------- // Typed helper functions // --------------------------------------------------------------------------- @@ -157,6 +204,18 @@ fn ninja_contains(world: &TestWorld, fragment: &str) -> Result<()> { ) } +/// Verify that one named edge has its own console-pool binding. +#[then("the Ninja edge {output:string} has a console pool binding")] +fn ninja_edge_has_console_pool_binding(world: &TestWorld, output: &str) -> Result<()> { + let ninja = get_ninja_content(world)?; + let has_console_pool = test_support::ninja_edge::edge_has_console_pool(&ninja, output)?; + ensure!( + has_console_pool, + "Ninja edge {output} has no console pool binding:\n{ninja}" + ); + Ok(()) +} + #[then("shlex splitting command {index:usize} yields {tokens:string}")] fn ninja_command_tokens(world: &TestWorld, index: usize, tokens: &str) -> Result<()> { let ninja = get_ninja_content(world)?; diff --git a/tests/console_pool_runtime_tests.rs b/tests/console_pool_runtime_tests.rs new file mode 100644 index 000000000..60c6527bd --- /dev/null +++ b/tests/console_pool_runtime_tests.rs @@ -0,0 +1,288 @@ +//! Real-process checks for prompt console output and exclusive console edges. +//! +//! Each scenario launches Netsuke with piped output and controls recipe +//! completion through files, proving behaviour at the Ninja process boundary. + +#![cfg(unix)] + +use anyhow::{Context, Result, bail, ensure}; +use assert_cmd::cargo::cargo_bin; +use camino::Utf8PathBuf; +use cap_std::{ambient_authority, fs_utf8::Dir}; +use mockable::{DefaultEnv, Env}; +use std::{ + ffi::OsString, + io::{self, BufRead, BufReader, Read}, + process::{Child, ChildStdout, Command, ExitStatus, Stdio}, + sync::mpsc::{self, Receiver, RecvTimeoutError}, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; +use tempfile::TempDir; +use test_support::ninja::{NinjaWorkspaceError, ninja_integration_workspace}; + +const PROCESS_DEADLINE: Duration = Duration::from_secs(60); +const POLL_INTERVAL: Duration = Duration::from_millis(10); + +/// Release blocked recipes and reap Netsuke if a runtime assertion fails. +struct ChildCleanup { + child: Child, + workspace: Dir, + release_files: Vec, + finished: bool, +} + +impl ChildCleanup { + /// Retain the child and the files that unblock its recipes. + fn new(child: Child, workspace: Dir, release_files: Vec) -> Self { + Self { + child, + workspace, + release_files, + finished: false, + } + } + + /// Mark a successfully reaped child so cleanup does not send it a signal. + fn mark_finished(&mut self) { + self.finished = true; + } +} + +impl Drop for ChildCleanup { + fn drop(&mut self) { + for release_file in &self.release_files { + drop(self.workspace.write(release_file, b"")); + } + if !self.finished { + let deadline = Instant::now() + Duration::from_secs(2); + while matches!(self.child.try_wait(), Ok(None)) && Instant::now() < deadline { + thread::sleep(POLL_INTERVAL); + } + if matches!(self.child.try_wait(), Ok(None)) { + drop(self.child.kill()); + } + } + drop(self.child.wait()); + } +} + +/// Create a Netsuke child with a private configuration environment and piped output. +/// +/// # Errors +/// +/// Returns an error when the host PATH is unavailable or the child configuration +/// directory cannot be created. +fn netsuke_build(workspace: &TempDir, arguments: &[&str]) -> Result { + let path = host_path()?; + let config_home = workspace.path().join(".config"); + workspace_dir(workspace)? + .create_dir_all(".config") + .context("create child configuration directory")?; + let mut command = Command::new(cargo_bin!("netsuke")); + command + .current_dir(workspace.path()) + .env_clear() + .env("PATH", path) + .env("HOME", workspace.path()) + .env("XDG_CONFIG_HOME", config_home) + .args(["--progress", "never"]) + .args(arguments) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + Ok(command) +} + +/// Open a capability directory for a temporary process workspace. +/// +/// # Errors +/// +/// Returns an error when the temporary path is not UTF-8 or cannot be opened. +fn workspace_dir(workspace: &TempDir) -> Result { + let root = Utf8PathBuf::from_path_buf(workspace.path().to_path_buf()) + .map_err(|path| anyhow::anyhow!("temporary workspace path is not UTF-8: {path:?}"))?; + Dir::open_ambient_dir(root, ambient_authority()).context("open temporary workspace") +} + +/// Read the host `PATH` for the isolated Netsuke child process. +/// +/// # Errors +/// +/// Returns an error when the host environment does not provide `PATH`. +fn host_path() -> Result { + DefaultEnv + .os_string("PATH") + .context("host PATH is required to run Ninja") +} + +/// Read stdout on a helper thread so the parent can observe lines before exit. +fn spawn_line_reader(stdout: ChildStdout) -> (Receiver>, JoinHandle<()>) { + let (sender, receiver) = mpsc::channel(); + let reader = thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) => break, + Ok(_) => drop(sender.send(Ok(line))), + Err(error) => { + drop(sender.send(Err(error))); + break; + } + } + } + }); + (receiver, reader) +} + +/// Return the next output line before the bounded deadline. +/// +/// # Errors +/// +/// Returns an error when stdout closes, fails to read, or misses the deadline. +fn receive_line(receiver: &Receiver>, deadline: Instant) -> Result { + let remaining = deadline.saturating_duration_since(Instant::now()); + ensure!(!remaining.is_zero(), "timed out waiting for Netsuke output"); + match receiver.recv_timeout(remaining) { + Ok(Ok(line)) => Ok(line), + Ok(Err(error)) => Err(error).context("read Netsuke stdout"), + Err(RecvTimeoutError::Timeout) => bail!("timed out waiting for Netsuke output"), + Err(RecvTimeoutError::Disconnected) => { + bail!("Netsuke stdout closed before the expected marker appeared") + } + } +} + +/// Wait for a marker line while the recipe remains blocked on its release file. +/// +/// # Errors +/// +/// Returns an error when stdout closes or the marker does not arrive in time. +fn wait_for_marker(receiver: &Receiver>, marker: &str) -> Result { + let deadline = Instant::now() + PROCESS_DEADLINE; + loop { + let line = receive_line(receiver, deadline)?; + if line.trim_start().starts_with(marker) { + return Ok(line); + } + } +} + +/// Wait for the Netsuke process to exit before the bounded deadline. +/// +/// # Errors +/// +/// Returns an error when process status cannot be read or the child misses the deadline. +fn wait_for_exit(child: &mut Child) -> Result { + let deadline = Instant::now() + PROCESS_DEADLINE; + loop { + if let Some(status) = child.try_wait().context("poll Netsuke child status")? { + return Ok(status); + } + ensure!( + Instant::now() < deadline, + "Netsuke build did not exit before the deadline" + ); + thread::sleep(POLL_INTERVAL); + } +} + +/// Require a successful build and retain the bounded child diagnostic on failure. +/// +/// # Errors +/// +/// Returns an error when the child does not exit successfully or stderr cannot be read. +fn finish_successfully(cleanup: &mut ChildCleanup) -> Result<()> { + let status = wait_for_exit(&mut cleanup.child)?; + let mut stderr = String::new(); + cleanup + .child + .stderr + .take() + .context("Netsuke child should expose stderr")? + .read_to_string(&mut stderr) + .context("read Netsuke stderr")?; + ensure!( + status.success(), + "Netsuke build failed with {status}: {stderr}" + ); + cleanup.mark_finished(); + Ok(()) +} + +/// Probe Ninja and skip this optional local runtime test when it cannot run. +/// +/// # Errors +/// +/// Returns a workspace-creation error instead of silently skipping the test. +fn runtime_workspace() -> Result, NinjaWorkspaceError> { + match ninja_integration_workspace() { + Ok(workspace) => Ok(Some(workspace)), + Err( + error @ (NinjaWorkspaceError::ProbeSpawn(_) + | NinjaWorkspaceError::ProbeFailed(_) + | NinjaWorkspaceError::ProbeTimeout(_)), + ) => { + tracing::warn!(%error, "skipping console runtime test without Ninja"); + Ok(None) + } + Err(error) => Err(error), + } +} + +/// Forward a flushed console-pool marker before the blocked command completes. +#[test] +fn console_output_reaches_piped_stdout_before_the_recipe_exits() -> Result<()> { + let Some(workspace) = runtime_workspace()? else { + return Ok(()); + }; + let root = workspace_dir(&workspace)?; + let release_file = Utf8PathBuf::from(".release-stream"); + root.write( + "Netsukefile", + concat!( + "netsuke_version: \"1.0.0\"\n", + "targets:\n", + " - name: streamed\n", + " command: 'printf \"STREAM-READY\\n\"; while [ ! -f .release-stream ]; do sleep 0.02; done; touch streamed'\n", + " console: true\n", + ), + ) + .context("write streaming console manifest")?; + + let child = netsuke_build(&workspace, &["build", "streamed"])? + .spawn() + .context("spawn Netsuke streaming build")?; + let mut cleanup = ChildCleanup::new(child, root.try_clone()?, vec![release_file.clone()]); + let stdout = cleanup + .child + .stdout + .take() + .context("Netsuke child should expose stdout")?; + let (output, reader) = spawn_line_reader(stdout); + + wait_for_marker(&output, "STREAM-READY")?; + ensure!( + cleanup + .child + .try_wait() + .context("check streaming child status")? + .is_none(), + "the recipe exited before the parent released it" + ); + root.write(&release_file, b"release") + .context("release streaming recipe")?; + finish_successfully(&mut cleanup)?; + reader + .join() + .map_err(|_| anyhow::anyhow!("stdout forwarding thread panicked"))?; + ensure!( + root.exists("streamed"), + "the released recipe should create its output" + ); + Ok(()) +} + +#[path = "console_pool_runtime_tests/exclusivity.rs"] +mod exclusivity; diff --git a/tests/console_pool_runtime_tests/exclusivity.rs b/tests/console_pool_runtime_tests/exclusivity.rs new file mode 100644 index 000000000..e37161cb0 --- /dev/null +++ b/tests/console_pool_runtime_tests/exclusivity.rs @@ -0,0 +1,130 @@ +//! Verify that Ninja serializes independent console-pool recipes. + +use super::{ + ChildCleanup, finish_successfully, netsuke_build, runtime_workspace, spawn_line_reader, + wait_for_marker, workspace_dir, +}; +use anyhow::{Context, Result, ensure}; +use camino::Utf8PathBuf; +use cap_std::fs_utf8::Dir; + +/// Keep two console recipes from overlapping under a parallel Ninja job limit. +#[test] +fn console_edges_are_exclusive_with_parallel_jobs_enabled() -> Result<()> { + let Some(workspace) = runtime_workspace()? else { + return Ok(()); + }; + let root = workspace_dir(&workspace)?; + let first_release = Utf8PathBuf::from(".release-one"); + let second_release = Utf8PathBuf::from(".release-two"); + write_console_exclusivity_manifest(&root)?; + + let mut command = netsuke_build(&workspace, &["--jobs", "2", "build", "one", "two"])?; + let child = command + .spawn() + .context("spawn Netsuke console exclusivity build")?; + let mut cleanup = ChildCleanup::new( + child, + root.try_clone()?, + vec![first_release.clone(), second_release.clone()], + ); + let stdout = cleanup + .child + .stdout + .take() + .context("Netsuke child should expose stdout")?; + let (output, reader) = spawn_line_reader(stdout); + + let first_start = wait_for_marker(&output, "START:")?; + let first_output = parse_start_output(&first_start)?; + release_edge(&root, &first_output)?; + let second_start = wait_for_marker(&output, "START:")?; + let second_output = parse_start_output(&second_start)?; + ensure!( + first_output != second_output, + "the two console edges should each start once" + ); + release_edge(&root, &second_output)?; + finish_successfully(&mut cleanup)?; + reader + .join() + .map_err(|_| anyhow::anyhow!("stdout forwarding thread panicked"))?; + for target in ["one", "two"] { + ensure!( + root.exists(target), + "console edge {target} should complete after release" + ); + } + ensure!( + !root.exists(".console-running"), + "the shared sentinel should be removed after both recipes finish" + ); + Ok(()) +} + +/// Write the shared-sentinel recipes for the console exclusivity scenario. +/// +/// Keep this fixture private to the integration-test module because its marker +/// and release-file protocol is specific to the two edges under test. +/// +/// # Errors +/// +/// Returns an error when the capability directory cannot write the manifest. +fn write_console_exclusivity_manifest(root: &Dir) -> Result<()> { + root.write( + "Netsukefile", + concat!( + "netsuke_version: \"1.0.0\"\n", + "targets:\n", + " - name: one\n", + " command: >-\n", + " mkdir .console-running 2>/dev/null || { echo COLLISION >&2; exit 1; };\n", + " printf 'START:one\\n';\n", + " while [ ! -f .release-one ]; do sleep 0.02; done;\n", + " rmdir .console-running; touch one; printf 'DONE:one\\n'\n", + " console: true\n", + " - name: two\n", + " command: >-\n", + " mkdir .console-running 2>/dev/null || { echo COLLISION >&2; exit 1; };\n", + " printf 'START:two\\n';\n", + " while [ ! -f .release-two ]; do sleep 0.02; done;\n", + " rmdir .console-running; touch two; printf 'DONE:two\\n'\n", + " console: true\n", + ), + ) + .context("write exclusive console manifest")?; + Ok(()) +} + +/// Release one exclusivity-test recipe by its declared output name. +/// +/// # Errors +/// +/// Returns an error for unexpected output names or failed release-file writes. +fn release_edge(workspace: &Dir, output: &str) -> Result<()> { + ensure!( + matches!(output, "one" | "two"), + "unexpected output marker {output}" + ); + workspace + .write(format!(".release-{output}"), b"release") + .with_context(|| format!("release console edge {output}"))?; + Ok(()) +} + +/// Extract and validate the target name printed by the shared recipe. +/// +/// # Errors +/// +/// Returns an error when the marker is absent or does not name a test output. +fn parse_start_output(line: &str) -> Result { + let output = line + .split_once("START:") + .map(|(_, output)| output.trim()) + .context("console recipe start marker should include its output")?; + ensure!( + matches!(output, "one" | "two"), + "unexpected console output {output}" + ); + Ok(output.to_owned()) +} diff --git a/tests/data/console.yml b/tests/data/console.yml new file mode 100644 index 000000000..8415a251c --- /dev/null +++ b/tests/data/console.yml @@ -0,0 +1,16 @@ +netsuke_version: "1.0.0" +actions: + - name: streamed-action + rule: shared + console: true + - name: ordinary-action + rule: shared +rules: + - name: shared + command: "echo {{ outs }}" +targets: + - name: ordinary-target + rule: shared + - name: inline-console + command: "echo {{ outs }}" + console: true diff --git a/tests/documentation_examples_tests.rs b/tests/documentation_examples_tests.rs index 9d91327e0..a5ed0c876 100644 --- a/tests/documentation_examples_tests.rs +++ b/tests/documentation_examples_tests.rs @@ -46,6 +46,7 @@ const EXPECTED_EXAMPLE_IDS: &[&str] = &[ "guide-command-list-failure-ambiguous-diagnostic", "guide-command-list-failure-unique-diagnostic", "guide-complete-manifest", + "guide-console-target", "guide-configuration-observability", "guide-crates-io-install", "guide-direct-command-list", @@ -242,6 +243,7 @@ fn every_documented_fence_has_a_known_unique_identifier() -> Result<()> { #[case("guide-direct-command-list")] #[case("guide-command-available-manifest")] #[case("guide-serial-dependency-order-manifest")] +#[case("guide-console-target")] #[case("stdlib-yaml-syntax-manifest")] #[case("stdlib-jinja-syntax-manifest")] #[case("stdlib-optional-rustflags-manifest")] @@ -252,6 +254,23 @@ fn documented_manifest_generates_ninja(#[case] example_id: &str) -> Result<()> { assert_generates_valid_ninja(&run, example_id) } +/// Keep the console-pool guide example tied to its generated Ninja binding. +#[test] +fn documented_console_example_selects_the_console_pool() -> Result<()> { + let workspace = manifest_workspace("guide-console-target")?; + let run = run_netsuke_in(workspace.path(), &["--progress", "never", "generate"])?; + + assert_success(&run, "guide console-pool target")?; + let has_console_pool = test_support::ninja_edge::edge_has_console_pool(&run.stdout, "lint") + .context("generated Ninja should contain the lint build edge")?; + ensure!( + has_console_pool, + "the documented lint target should select the console pool: {}", + run.stdout + ); + Ok(()) +} + #[test] fn serial_aggregate_example_generates_a_dependency_only_node() -> Result<()> { let workspace = manifest_workspace("guide-serial-dependency-order-manifest")?; diff --git a/tests/features/manifest.feature b/tests/features/manifest.feature index b28cabafa..648455a7b 100644 --- a/tests/features/manifest.feature +++ b/tests/features/manifest.feature @@ -15,6 +15,14 @@ Feature: Manifest Parsing Then the target 1 is phony And the target 1 is always rebuilt + Scenario: Parsing console scheduling for actions and targets + Given the manifest file "tests/data/console.yml" is parsed + When the flags are checked + Then the action 1 requests console scheduling + And the action 2 does not request console scheduling + And the target 1 does not request console scheduling + And the target 2 requests console scheduling + Scenario: A target in the 'actions' block is implicitly phony Given the manifest file "tests/data/actions.yml" is parsed When the flags are checked diff --git a/tests/features/ninja.feature b/tests/features/ninja.feature index d9c9199be..e72a78d9e 100644 --- a/tests/features/ninja.feature +++ b/tests/features/ninja.feature @@ -12,6 +12,19 @@ Feature: Ninja file generation Then the ninja file contains "build clean:" And the ninja file contains "rm -rf build" + Scenario: Console actions emit an edge-level console pool binding + When the manifest file "tests/data/console.yml" is compiled to IR + And the ninja file is generated + Then the ninja file contains "build streamed-action:" + And the ninja file contains "build inline-console:" + And the Ninja edge "streamed-action" has a console pool binding + And the Ninja edge "inline-console" has a console pool binding + + Scenario: Dependency-only console edges are rejected + Given a dependency-only action graph with console scheduling + When the ninja file is generated + Then ninja generation fails with "dependency-only" + Scenario: The child shell receives the sentinel environment When the manifest file "tests/data/dollar_escaping.yml" is compiled to IR And the ninja file is generated diff --git a/tests/ir_tests.rs b/tests/ir_tests.rs index 02b581a36..e7c8ac57e 100644 --- a/tests/ir_tests.rs +++ b/tests/ir_tests.rs @@ -2,12 +2,12 @@ //! //! Directly constructs [`netsuke::ir::BuildGraph`], [`BuildEdge`], and //! [`Action`] values and asserts their default state, field semantics, and -//! canonical edge ownership and output-index lookup. Does not exercise manifest -//! parsing or Ninja generation. +//! canonical edge ownership and output-index lookup. It also checks direct +//! manifest-to-IR validation without invoking Ninja generation. use camino::Utf8PathBuf; use netsuke::ast::Recipe; -use netsuke::ir::{Action, BuildEdge, BuildGraph, IrGenError}; +use netsuke::ir::{Action, BuildEdge, BuildGraph, EdgeOutputVisibility, IrGenError}; use rstest::rstest; #[rstest] @@ -43,6 +43,7 @@ fn create_action_and_edge() { progress_label: None, phony: false, always: true, + output_visibility: EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert("id".into(), action); @@ -107,6 +108,7 @@ fn build_graph_indexes_canonical_targets() { progress_label: None, phony: false, always: false, + output_visibility: EdgeOutputVisibility::default(), }; let edge_id = graph .insert_edge(edge1) @@ -141,6 +143,7 @@ fn build_graph_rejects_duplicate_aliases_before_mutation() { progress_label: None, phony: false, always: false, + output_visibility: EdgeOutputVisibility::default(), }; let result = graph.insert_edge(edge); diff --git a/tests/ninja_dollar_escaping_tests.rs b/tests/ninja_dollar_escaping_tests.rs index 63e3e2cfa..d5b0fad41 100644 --- a/tests/ninja_dollar_escaping_tests.rs +++ b/tests/ninja_dollar_escaping_tests.rs @@ -67,6 +67,7 @@ fn graph(recipe: Recipe, input: &str, output: &str) -> Result { progress_label: None, phony: false, always: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert("action".into(), action(recipe)); diff --git a/tests/ninja_gen_command_list_integration_tests.rs b/tests/ninja_gen_command_list_integration_tests.rs index 0e0ebfae3..7859f6b53 100644 --- a/tests/ninja_gen_command_list_integration_tests.rs +++ b/tests/ninja_gen_command_list_integration_tests.rs @@ -10,7 +10,7 @@ use anyhow::{Context, Result, ensure}; use camino::Utf8PathBuf; use cap_std::{ambient_authority, fs_utf8::Dir}; use netsuke::ast::{CommandIsolation, Recipe, StringOrList}; -use netsuke::ir::{Action, BuildEdge, BuildGraph, DependencyOrder}; +use netsuke::ir::{Action, BuildEdge, BuildGraph, DependencyOrder, EdgeOutputVisibility}; use netsuke::ninja_gen::{NinjaGenError, generate}; use std::process::Command; use tempfile::TempDir; @@ -36,6 +36,7 @@ fn run_command_list( progress_label: None, phony: false, always: false, + output_visibility: EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert("chain".into(), action); diff --git a/tests/ninja_gen_command_list_process_integration_tests.rs b/tests/ninja_gen_command_list_process_integration_tests.rs index 6a1664939..a0650bf27 100644 --- a/tests/ninja_gen_command_list_process_integration_tests.rs +++ b/tests/ninja_gen_command_list_process_integration_tests.rs @@ -77,6 +77,7 @@ fn run_command_list_with_progress( progress_label: progress_label.map(str::to_owned), phony: false, always: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert("chain".into(), action); diff --git a/tests/ninja_gen_inline_progress_process_tests.rs b/tests/ninja_gen_inline_progress_process_tests.rs index 5252c7df7..1a8f8619c 100644 --- a/tests/ninja_gen_inline_progress_process_tests.rs +++ b/tests/ninja_gen_inline_progress_process_tests.rs @@ -6,7 +6,7 @@ use anyhow::{Context, Result, ensure}; use camino::Utf8PathBuf; use cap_std::{ambient_authority, fs_utf8::Dir}; use netsuke::ast::{CommandIsolation, Recipe, StringOrList}; -use netsuke::ir::{Action, BuildEdge, BuildGraph, DependencyOrder}; +use netsuke::ir::{Action, BuildEdge, BuildGraph, DependencyOrder, EdgeOutputVisibility}; use netsuke::ninja_gen::generate; use rstest::{fixture, rstest}; use std::process::Command; @@ -54,6 +54,7 @@ fn run_action( progress_label: progress_label.map(str::to_owned), phony: false, always: false, + output_visibility: EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert(edge.action_id.clone(), action); diff --git a/tests/ninja_gen_integration_tests.rs b/tests/ninja_gen_integration_tests.rs index 77c785325..846dd405b 100644 --- a/tests/ninja_gen_integration_tests.rs +++ b/tests/ninja_gen_integration_tests.rs @@ -54,6 +54,7 @@ fn ninja_integration_setup() -> Option { progress_label: None, phony: false, always: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }, target_name: Utf8PathBuf::from("out"), ninja_args: vec!["-n"], @@ -79,6 +80,7 @@ fn ninja_integration_setup() -> Option { progress_label: None, phony: false, always: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }, target_name: Utf8PathBuf::from("out"), ninja_args: vec!["out"], @@ -104,6 +106,7 @@ fn ninja_integration_setup() -> Option { progress_label: None, phony: false, always: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }, target_name: Utf8PathBuf::from("out"), ninja_args: vec!["out"], @@ -129,6 +132,7 @@ fn ninja_integration_setup() -> Option { progress_label: None, phony: true, always: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }, target_name: Utf8PathBuf::from("action-called.txt"), ninja_args: vec!["say-hello"], @@ -224,6 +228,7 @@ fn errors_when_action_missing() -> Result<()> { progress_label: None, phony: false, always: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }; graph .insert_edge(edge) @@ -271,6 +276,7 @@ fn generate_format_error() -> Result<()> { progress_label: None, phony: false, always: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert("a".into(), action); @@ -312,6 +318,7 @@ fn serial_graph(command: &str, dependencies: &[&str]) -> Result { progress_label: None, phony: false, always: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert("a".into(), action); diff --git a/tests/ninja_gen_integration_tests/progress_localization.rs b/tests/ninja_gen_integration_tests/progress_localization.rs index 38d24fc2c..5590026f2 100644 --- a/tests/ninja_gen_integration_tests/progress_localization.rs +++ b/tests/ninja_gen_integration_tests/progress_localization.rs @@ -104,6 +104,7 @@ fn arabic_generation_preserves_fluent_isolates_and_sanitizes_input() -> Result<( progress_label: Some("left\u{202e}right".into()), phony: false, always: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert(edge.action_id.clone(), action); diff --git a/tests/ninja_gen_unit_tests.rs b/tests/ninja_gen_unit_tests.rs index 669c54d04..e4eb771d0 100644 --- a/tests/ninja_gen_unit_tests.rs +++ b/tests/ninja_gen_unit_tests.rs @@ -37,6 +37,7 @@ fn generate_posix(graph: &BuildGraph) -> Result { progress_label: None, phony: true, always: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }, concat!( "rule a\n", @@ -64,6 +65,7 @@ fn generate_posix(graph: &BuildGraph) -> Result { progress_label: None, phony: false, always: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }, concat!( "rule compile\n", @@ -91,6 +93,7 @@ fn generate_posix(graph: &BuildGraph) -> Result { progress_label: None, phony: false, always: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }, concat!( "rule b\n", @@ -118,6 +121,7 @@ fn generate_posix(graph: &BuildGraph) -> Result { progress_label: None, phony: false, always: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }, concat!( "rule b\n", @@ -145,6 +149,7 @@ fn generate_posix(graph: &BuildGraph) -> Result { progress_label: None, phony: false, always: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }, concat!( "rule b\n", @@ -172,6 +177,7 @@ fn generate_posix(graph: &BuildGraph) -> Result { progress_label: None, phony: false, always: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }, concat!( "rule b\n", @@ -199,6 +205,7 @@ fn generate_posix(graph: &BuildGraph) -> Result { progress_label: None, phony: true, always: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }, concat!( "rule phony\n", @@ -259,6 +266,7 @@ fn generate_multiline_script_snapshot() { progress_label: None, phony: false, always: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }) .expect("test graph output aliases must be unique"); graph.default_targets.push(Utf8PathBuf::from("out")); diff --git a/tests/ninja_snapshot_tests.rs b/tests/ninja_snapshot_tests.rs index e82f27fea..bc764e2c4 100644 --- a/tests/ninja_snapshot_tests.rs +++ b/tests/ninja_snapshot_tests.rs @@ -240,7 +240,7 @@ fn multi_command_manifest_ninja_snapshot() -> Result<()> { Ok(()) } -/// Snapshot command-list lowering for isolated and implicit-dependency recipes. +/// Snapshot selected manifest recipes and edge-level Ninja metadata. #[rstest] #[case::isolated_command_list( "tests/data/isolated_command_list.yml", @@ -249,16 +249,30 @@ fn multi_command_manifest_ninja_snapshot() -> Result<()> { "command = ( { _netsuke_background_before=", "} )", ], + &[], )] #[case::implicit_deps( "tests/data/implicit_deps.yml", "implicit_deps_manifest_ninja", &[" | "], + &[], )] -fn command_list_manifest_ninja_snapshot( +#[case::console( + "tests/data/console.yml", + "console_manifest_ninja", + &["build streamed-action:", "build inline-console:", "pool = console"], + &[ + ("streamed-action", true), + ("ordinary-action", false), + ("ordinary-target", false), + ("inline-console", true), + ], +)] +fn manifest_ninja_snapshot( #[case] fixture_path: &str, #[case] snapshot_name: &str, #[case] expected_fragments: &[&str], + #[case] expected_console_bindings: &[(&str, bool)], ) -> Result<()> { let fixture_dir = Dir::open_ambient_dir(env!("CARGO_MANIFEST_DIR"), ambient_authority()) .context("open repository root to read command-list test data")?; @@ -275,7 +289,13 @@ fn command_list_manifest_ninja_snapshot( "expected `{fragment}` in generated Ninja output:\n{ninja_content}" ); } - + for (output, expected) in expected_console_bindings { + let actual = test_support::ninja_edge::edge_has_console_pool(&ninja_content, output)?; + ensure!( + actual == *expected, + "edge {output} console binding should be {expected}, generated Ninja:\n{ninja_content}" + ); + } let mut settings = Settings::new(); settings.set_snapshot_path(concat!( env!("CARGO_MANIFEST_DIR"), diff --git a/tests/runner_legacy_recipe_telemetry_tests.rs b/tests/runner_legacy_recipe_telemetry_tests.rs index 65aef764d..e7a56a254 100644 --- a/tests/runner_legacy_recipe_telemetry_tests.rs +++ b/tests/runner_legacy_recipe_telemetry_tests.rs @@ -1,4 +1,4 @@ -//! Runner-boundary coverage for bounded legacy-recipe operation telemetry. +//! Runner-boundary coverage for bounded recipe and console telemetry. //! //! Each case invokes [`netsuke::runner::run_with_ninja_program`] with an //! injected executable and local metrics recorder. This verifies the complete @@ -13,8 +13,12 @@ use metrics_util::{ use netsuke::cli::{Cli, Commands}; use netsuke::output_prefs; use netsuke::runner::{ - LEGACY_RECIPE_EXECUTION_DURATION, LEGACY_RECIPE_EXECUTIONS_TOTAL, run_with_ninja_program, + BUNDLE_CONSOLE_EDGES_TOTAL, BUNDLE_GENERATION_DURATION, BUNDLE_GENERATIONS_TOTAL, + LEGACY_RECIPE_EXECUTION_DURATION, LEGACY_RECIPE_EXECUTIONS_TOTAL, + NINJA_BUILD_CONSOLE_EDGES_TOTAL, NINJA_BUILD_EXECUTION_DURATION, NINJA_BUILD_EXECUTIONS_TOTAL, + run_with_ninja_program, }; +use rstest::rstest; use std::path::{Path, PathBuf}; use test_support::check_ninja::{ToolName, fake_ninja_check_build_file, fake_ninja_expect_tool}; use test_support::fs as test_fs; @@ -135,6 +139,60 @@ fn build_dispatch_records_successful_legacy_recipe_operation() -> Result<()> { Ok(()) } +/// Verify both console-edge counters carry the expected bounded outcome. +fn assert_console_edge_counts(snapshot: &Snapshot, expected_edges: u64) -> Result<()> { + for name in [NINJA_BUILD_CONSOLE_EDGES_TOTAL, BUNDLE_CONSOLE_EDGES_TOTAL] { + let matching = snapshot + .iter() + .filter(|(key, _, _, _)| key.kind() == MetricKind::Counter && key.key().name() == name) + .collect::>(); + ensure!( + matching.len() == 1 + && matching.first().is_some_and(|(key, _, _, value)| { + key.key().labels().count() == 1 + && key + .key() + .labels() + .any(|label| label.key() == "outcome" && label.value() == "success") + && matches!(value, DebugValue::Counter(count) if *count == expected_edges) + }), + "expected {expected_edges} configured edges for {name}: {snapshot:?}" + ); + } + Ok(()) +} + +#[rstest] +#[case::ordinary(include_str!("data/minimal.yml"), "disabled", 0)] +#[case::console(include_str!("data/console.yml"), "enabled", 2)] +fn build_dispatch_records_console_edge_counts( + #[case] manifest: &str, + #[case] scheduling: &str, + #[case] expected_edges: u64, +) -> Result<()> { + let (_ninja_directory, ninja_program) = fake_ninja_check_build_file()?; + let (directory, manifest_path) = temporary_manifest(manifest)?; + let cli = runner_cli(directory.path(), manifest_path, None)?; + let (result, snapshot) = capture_operation(&cli, &ninja_program); + result.context("the injected Ninja build should succeed")?; + + let labels = [ + ("console_scheduling", scheduling), + ("outcome", "success"), + ("error_category", "none"), + ]; + for (kind, name) in [ + (MetricKind::Counter, NINJA_BUILD_EXECUTIONS_TOTAL), + (MetricKind::Histogram, NINJA_BUILD_EXECUTION_DURATION), + (MetricKind::Counter, BUNDLE_GENERATIONS_TOTAL), + (MetricKind::Histogram, BUNDLE_GENERATION_DURATION), + ] { + assert_metric(&snapshot, kind, name, &labels); + } + assert_console_edge_counts(&snapshot, expected_edges)?; + Ok(()) +} + /// Verify the Ninja-tool dispatch path records one successful operation. #[test] fn ninja_tool_dispatch_records_successful_legacy_recipe_operation() -> Result<()> { diff --git a/tests/serial_dependency_runtime_tests.rs b/tests/serial_dependency_runtime_tests.rs index 62111580b..903f94a9d 100644 --- a/tests/serial_dependency_runtime_tests.rs +++ b/tests/serial_dependency_runtime_tests.rs @@ -50,6 +50,7 @@ fn edge( progress_label: None, phony: false, always: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), } } diff --git a/tests/snapshots/manifest_load_options/from_path.snap b/tests/snapshots/manifest_load_options/from_path.snap index 1a9a28e8b..698c2e790 100644 --- a/tests/snapshots/manifest_load_options/from_path.snap +++ b/tests/snapshots/manifest_load_options/from_path.snap @@ -1,5 +1,6 @@ --- -source: tests/manifest_load_options/wrapper_equivalence.rs +source: tests/manifest_load_options/wrapper_equivalence/path_rows.rs +assertion_line: 62 expression: document --- === pure === @@ -17,6 +18,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -49,6 +51,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: true/false", @@ -80,6 +83,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, @@ -98,6 +102,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, diff --git a/tests/snapshots/manifest_load_options/from_path_with_policy.snap b/tests/snapshots/manifest_load_options/from_path_with_policy.snap index 109d72203..00492487e 100644 --- a/tests/snapshots/manifest_load_options/from_path_with_policy.snap +++ b/tests/snapshots/manifest_load_options/from_path_with_policy.snap @@ -1,5 +1,6 @@ --- -source: tests/manifest_load_options/wrapper_equivalence.rs +source: tests/manifest_load_options/wrapper_equivalence/path_rows.rs +assertion_line: 62 expression: document --- === pure === @@ -17,6 +18,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -49,6 +51,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: true/false", @@ -80,6 +83,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, @@ -98,6 +102,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, diff --git a/tests/snapshots/manifest_load_options/from_path_with_policy_and_env.snap b/tests/snapshots/manifest_load_options/from_path_with_policy_and_env.snap index 3d9454f12..884b36ffc 100644 --- a/tests/snapshots/manifest_load_options/from_path_with_policy_and_env.snap +++ b/tests/snapshots/manifest_load_options/from_path_with_policy_and_env.snap @@ -1,5 +1,6 @@ --- -source: tests/manifest_load_options/wrapper_equivalence.rs +source: tests/manifest_load_options/wrapper_equivalence/path_rows.rs +assertion_line: 62 expression: document --- === pure === @@ -17,6 +18,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -45,6 +47,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "profile characterization", @@ -77,6 +80,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: true/false", @@ -108,6 +112,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, @@ -126,6 +131,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, diff --git a/tests/snapshots/manifest_load_options/from_path_with_policy_and_env_and_limits.snap b/tests/snapshots/manifest_load_options/from_path_with_policy_and_env_and_limits.snap index 3d9454f12..884b36ffc 100644 --- a/tests/snapshots/manifest_load_options/from_path_with_policy_and_env_and_limits.snap +++ b/tests/snapshots/manifest_load_options/from_path_with_policy_and_env_and_limits.snap @@ -1,5 +1,6 @@ --- -source: tests/manifest_load_options/wrapper_equivalence.rs +source: tests/manifest_load_options/wrapper_equivalence/path_rows.rs +assertion_line: 62 expression: document --- === pure === @@ -17,6 +18,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -45,6 +47,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "profile characterization", @@ -77,6 +80,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: true/false", @@ -108,6 +112,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, @@ -126,6 +131,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, diff --git a/tests/snapshots/manifest_load_options/from_path_with_policy_and_environment.snap b/tests/snapshots/manifest_load_options/from_path_with_policy_and_environment.snap index 3d9454f12..884b36ffc 100644 --- a/tests/snapshots/manifest_load_options/from_path_with_policy_and_environment.snap +++ b/tests/snapshots/manifest_load_options/from_path_with_policy_and_environment.snap @@ -1,5 +1,6 @@ --- -source: tests/manifest_load_options/wrapper_equivalence.rs +source: tests/manifest_load_options/wrapper_equivalence/path_rows.rs +assertion_line: 62 expression: document --- === pure === @@ -17,6 +18,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -45,6 +47,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "profile characterization", @@ -77,6 +80,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: true/false", @@ -108,6 +112,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, @@ -126,6 +131,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, diff --git a/tests/snapshots/manifest_load_options/from_path_with_policy_and_environment_and_limits.snap b/tests/snapshots/manifest_load_options/from_path_with_policy_and_environment_and_limits.snap index 3d9454f12..884b36ffc 100644 --- a/tests/snapshots/manifest_load_options/from_path_with_policy_and_environment_and_limits.snap +++ b/tests/snapshots/manifest_load_options/from_path_with_policy_and_environment_and_limits.snap @@ -1,5 +1,6 @@ --- -source: tests/manifest_load_options/wrapper_equivalence.rs +source: tests/manifest_load_options/wrapper_equivalence/path_rows.rs +assertion_line: 62 expression: document --- === pure === @@ -17,6 +18,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -45,6 +47,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "profile characterization", @@ -77,6 +80,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: true/false", @@ -108,6 +112,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, @@ -126,6 +131,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, diff --git a/tests/snapshots/manifest_load_options/from_path_with_policy_and_limits.snap b/tests/snapshots/manifest_load_options/from_path_with_policy_and_limits.snap index 109d72203..00492487e 100644 --- a/tests/snapshots/manifest_load_options/from_path_with_policy_and_limits.snap +++ b/tests/snapshots/manifest_load_options/from_path_with_policy_and_limits.snap @@ -1,5 +1,6 @@ --- -source: tests/manifest_load_options/wrapper_equivalence.rs +source: tests/manifest_load_options/wrapper_equivalence/path_rows.rs +assertion_line: 62 expression: document --- === pure === @@ -17,6 +18,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -49,6 +51,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: true/false", @@ -80,6 +83,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, @@ -98,6 +102,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, diff --git a/tests/snapshots/manifest_load_options/from_str.snap b/tests/snapshots/manifest_load_options/from_str.snap index 83da27e17..f38314357 100644 --- a/tests/snapshots/manifest_load_options/from_str.snap +++ b/tests/snapshots/manifest_load_options/from_str.snap @@ -1,5 +1,6 @@ --- -source: tests/manifest_load_options/wrapper_equivalence.rs +source: tests/manifest_load_options/wrapper_equivalence/mod.rs +assertion_line: 170 expression: document --- === pure === @@ -17,6 +18,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -49,6 +51,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: true/false", diff --git a/tests/snapshots/manifest_load_options/from_str_with_env.snap b/tests/snapshots/manifest_load_options/from_str_with_env.snap index 430f05a6f..9c1729aac 100644 --- a/tests/snapshots/manifest_load_options/from_str_with_env.snap +++ b/tests/snapshots/manifest_load_options/from_str_with_env.snap @@ -1,5 +1,6 @@ --- -source: tests/manifest_load_options/wrapper_equivalence.rs +source: tests/manifest_load_options/wrapper_equivalence/mod.rs +assertion_line: 179 expression: document --- === pure === @@ -17,6 +18,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -45,6 +47,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "profile characterization", @@ -77,6 +80,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: true/false", diff --git a/tests/snapshots/manifest_load_options/from_str_with_env_and_config.snap b/tests/snapshots/manifest_load_options/from_str_with_env_and_config.snap index f30a02eeb..fb488d001 100644 --- a/tests/snapshots/manifest_load_options/from_str_with_env_and_config.snap +++ b/tests/snapshots/manifest_load_options/from_str_with_env_and_config.snap @@ -1,5 +1,6 @@ --- -source: tests/manifest_load_options/wrapper_equivalence.rs +source: tests/manifest_load_options/wrapper_equivalence/mod.rs +assertion_line: 200 expression: document --- === pure === @@ -17,6 +18,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -45,6 +47,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "profile characterization", @@ -77,6 +80,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: true/false", @@ -108,6 +112,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "started 2026-06-08T12:00:00Z", diff --git a/tests/snapshots/manifest_load_options/from_str_with_env_and_policy.snap b/tests/snapshots/manifest_load_options/from_str_with_env_and_policy.snap index 430f05a6f..d23fd8e59 100644 --- a/tests/snapshots/manifest_load_options/from_str_with_env_and_policy.snap +++ b/tests/snapshots/manifest_load_options/from_str_with_env_and_policy.snap @@ -1,5 +1,6 @@ --- -source: tests/manifest_load_options/wrapper_equivalence.rs +source: tests/manifest_load_options/wrapper_equivalence/mod.rs +assertion_line: 189 expression: document --- === pure === @@ -17,6 +18,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -45,6 +47,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "profile characterization", @@ -77,6 +80,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: true/false", diff --git a/tests/snapshots/ninja/ninja_snapshot_tests__console_manifest_ninja.snap b/tests/snapshots/ninja/ninja_snapshot_tests__console_manifest_ninja.snap new file mode 100644 index 000000000..8b7097460 --- /dev/null +++ b/tests/snapshots/ninja/ninja_snapshot_tests__console_manifest_ninja.snap @@ -0,0 +1,26 @@ +--- +source: tests/ninja_snapshot_tests.rs +expression: ninja_content +--- +rule 04ca8490eedc2a14f7ea86f319edd5b7ea47a9b1dc3deab48f610cfba9a50fe0 + command = echo inline-console + +rule 409e0ba0938d3fa40f7344fdd2f33e631f5b4ec8b51527e101bd8f6f29851b1c + command = echo streamed-action + +rule 942a9563150dd5319d3478ac8b180f7d234b732e102e1a2e1f190dbbc53562e0 + command = echo ordinary-target + +rule aed5f83a13f8763315f10af13630dd88eccbc70c46a8f0a4c2ac132e7f6f2858 + command = echo ordinary-action + +build inline-console: 04ca8490eedc2a14f7ea86f319edd5b7ea47a9b1dc3deab48f610cfba9a50fe0 + description = Building ⁨inline-console⁩ + pool = console + +build ordinary-action: aed5f83a13f8763315f10af13630dd88eccbc70c46a8f0a4c2ac132e7f6f2858 + +build ordinary-target: 942a9563150dd5319d3478ac8b180f7d234b732e102e1a2e1f190dbbc53562e0 + +build streamed-action: 409e0ba0938d3fa40f7344fdd2f33e631f5b4ec8b51527e101bd8f6f29851b1c + pool = console diff --git a/tests/ui/build_edge_console_compile_pass.rs b/tests/ui/build_edge_console_compile_pass.rs new file mode 100644 index 000000000..91eb8c63c --- /dev/null +++ b/tests/ui/build_edge_console_compile_pass.rs @@ -0,0 +1,47 @@ +//! Compile the public output visibility request as an external crate would. + +use netsuke::ast::{ + CommandIsolation, DependencyOrder as AstDependencyOrder, Recipe, StringOrList, Target, +}; +use netsuke::ir::{BuildEdge, DependencyOrder as IrDependencyOrder}; +use std::collections::HashMap; + +/// Construct a public build edge that requests immediate output visibility. +#[must_use] +pub fn console_edge() -> BuildEdge { + BuildEdge { + action_id: "lint".into(), + inputs: Vec::new(), + implicit_deps: Vec::new(), + dependency_order: IrDependencyOrder::Parallel, + explicit_outputs: vec!["lint".into()], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + progress_label: None, + phony: false, + always: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::Immediate, + } +} + +/// Construct a public target that opts into Ninja's console pool. +#[must_use] +pub fn console_target() -> Target { + Target { + name: "lint".into(), + recipe: Recipe::Command { + command: StringOrList::from("cargo clippy"), + isolation: CommandIsolation::Shared, + }, + sources: StringOrList::Empty, + deps: StringOrList::Empty, + dependency_order: AstDependencyOrder::Parallel, + order_only_deps: StringOrList::Empty, + vars: HashMap::default(), + phony: false, + always: false, + console: true, + conditional: false, + description: None, + } +}