ktstr/workload/config/
work.rs

1//! Per-group worker specification: the [`WorkSpec`] struct, its
2//! `Default`, and the chainable builder methods on `impl WorkSpec`.
3//!
4//! WorkSpec is the per-cgroup-group worker shape that composes into
5//! [`WorkloadConfig::composed`](super::WorkloadConfig::composed) or
6//! `CgroupDef`'s `merged_works`. Each WorkSpec spawns its own set of
7//! worker processes with its own work_type, sched_policy, affinity,
8//! mem_policy, nice, comm, pcomm, uid, gid, numa_node, and
9//! workers_pct.
10//!
11//! WorkSpec deliberately omits `clone_mode` — clone-mode is a
12//! workload-wide property carried by `WorkloadConfig`.
13//!
14//! Per-spec validation lives at apply-setup / spawn time:
15//! `mem_policy` is validated by
16//! [`WorkloadConfig::validate`](super::WorkloadConfig::validate)
17//! before any worker context exists; `workers_pct` is resolved
18//! per-cpuset by [`WorkSpec::resolve_workers_pct`] at dispatch.
19
20use std::borrow::Cow;
21
22use super::super::{AffinityIntent, WorkType};
23use super::{MemPolicy, MpolFlags, SchedPolicy};
24
25/// Validate a `comm` / `pcomm` builder argument.
26///
27/// Centralizes the rejection contract for the task-name fields the
28/// framework writes via `prctl(PR_SET_NAME)`:
29///
30/// - Empty: produces an empty kernel comm (surprising; breaks
31///   scheduler matchers that look for non-empty names).
32/// - Interior NUL: `prctl` takes a C string and would truncate at
33///   the NUL silently.
34/// - Length > 15: `__set_task_comm` (fs/exec.c) writes
35///   `min(strlen(buf), sizeof(tsk->comm) - 1)` bytes and
36///   `TASK_COMM_LEN = 16` (include/linux/sched.h), so any 16th
37///   byte (and beyond) is silently dropped. Rejecting at builder
38///   time means the operator sees the limit at the call site
39///   instead of debugging a truncated comm.
40///
41/// `field` names the call site for the panic message (e.g.
42/// `"WorkSpec::comm"`, `"CgroupDef::pcomm"`) so the operator
43/// can grep the offending builder from the panic.
44///
45/// Panics intentionally — these are builder-time input errors
46/// that the test author must fix at the source. Returning a
47/// `Result` would force every caller to `unwrap` and lose the
48/// site context.
49pub(crate) fn validate_task_comm_string(field: &str, name: &str) {
50    assert!(
51        !name.is_empty(),
52        "{field}: empty string rejected — use `None` (default) for no override, not an empty value",
53    );
54    assert!(
55        !name.contains('\0'),
56        "{field}: string {name:?} contains an interior NUL byte; \
57         prctl(PR_SET_NAME) treats it as a C string and would \
58         truncate at the NUL — strip it before calling .{field}()",
59    );
60    assert!(
61        name.len() <= 15,
62        "{field}: name {name:?} is {} bytes; kernel TASK_COMM_LEN \
63         limit is 15 bytes (TASK_COMM_LEN-1=15 in include/linux/sched.h; \
64         `__set_task_comm` truncates at that cap) — shorten before \
65         calling .{field}()",
66        name.len(),
67    );
68}
69
70// PartialEq (not Eq): the [`Self::workers_pct`] field is `Option<f64>`
71// and `f64` is `PartialEq` only — `f64::partial_cmp(NaN, NaN)` is
72// `None` (IEEE-754 semantics). The [`Self::workers_pct`] builder
73// rejects NaN at construction (see the `assert!` near the top of
74// `impl WorkSpec::workers_pct`), so production `WorkSpec` values
75// are NaN-free in practice — the derive inherits f64's standard
76// semantics without surfacing them at typical call sites. Tests
77// that synthesize WorkSpec values via struct-literal syntax can
78// still introduce NaN; concretely, `assert_eq!(spec, spec)` will
79// FAIL (panic) for a spec containing NaN `workers_pct` because
80// NaN != NaN per IEEE-754. Avoid synthesizing NaN even in tests.
81#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
82// See [`WorkType`]'s `#[serde(bound(...))]` comment — embedding
83// `WorkType` here propagates the same lifetime-bound issue, so we
84// pass through the same explicit empty bound.
85#[serde(bound(deserialize = ""))]
86pub struct WorkSpec {
87    /// What each worker does.
88    pub work_type: WorkType,
89    /// Linux scheduling policy.
90    pub sched_policy: SchedPolicy,
91    /// Number of workers. `None` means use `Ctx::workers_per_cgroup`.
92    ///
93    /// Composition-sensitive: different work groups within the same
94    /// cgroup commonly want different worker counts (e.g. an
95    /// antagonist with 4 spinners alongside a victim with 1
96    /// SCHED_FIFO worker). For that reason `CgroupDef` does NOT
97    /// expose a cgroup-level default for `num_workers` — multi-group
98    /// cgroups set the count per-[`WorkSpec`] here.
99    ///
100    /// Type asymmetry with [`crate::workload::WorkloadConfig::num_workers`]
101    /// (`usize`, no Option) is deliberate. `WorkSpec` is the
102    /// declarative spec layer where `None` is a meaningful
103    /// "inherit the cgroup-level default" sentinel;
104    /// `resolve_num_workers` coalesces it to a
105    /// concrete `usize` against the `Ctx` before
106    /// `WorkloadConfig::for_scenario_engine`
107    /// constructs the spawn-time config. The coalesce happens at
108    /// the resolution boundary, not silently inside any builder.
109    pub num_workers: Option<usize>,
110    /// Per-worker affinity intent. Resolved to `ResolvedAffinity` at
111    /// runtime via [`resolve_affinity_for_cgroup()`](crate::scenario::resolve_affinity_for_cgroup).
112    pub affinity: AffinityIntent,
113    /// NUMA memory placement policy. Applied via `set_mempolicy(2)`
114    /// after fork, before the work loop.
115    ///
116    /// Validated against the resolved cpuset per-WorkSpec at
117    /// apply-setup time. Because validation is per-group, a
118    /// cgroup-level default would mask per-group failures with
119    /// confusing diagnostics — `CgroupDef` deliberately does not
120    /// expose a cgroup-level default for `mem_policy`; multi-group
121    /// cgroups set it per-[`WorkSpec`] here.
122    pub mem_policy: MemPolicy,
123    /// Optional mode flags for `set_mempolicy(2)`.
124    pub mpol_flags: MpolFlags,
125    /// Per-worker nice value applied via `setpriority(2)` after
126    /// fork, before the work loop. See [`crate::workload::WorkloadConfig::nice`]
127    /// for range, `None`-vs-`Some(n)` semantics, and `CAP_SYS_NICE`
128    /// rules.
129    ///
130    /// To inherit a cgroup-level default stored at
131    /// [`CgroupDef::default_nice`](crate::scenario::ops::CgroupDef::default_nice),
132    /// leave this `None`. `Some(0)` opts out of the cgroup-level
133    /// merge — see [`crate::workload::WorkloadConfig::nice`] for the underlying
134    /// `setpriority(PRIO_PROCESS, 0, 0)` semantics.
135    pub nice: Option<i32>,
136    /// Per-worker comm set via `prctl(PR_SET_NAME)` at thread
137    /// creation time. The setter rejects > 15 bytes
138    /// (TASK_COMM_LEN-1) at construction so the operator sees the
139    /// cap at the call site instead of debugging a kernel-truncated
140    /// comm — see `validate_task_comm_string`. `None` inherits the
141    /// binary name. Useful for scheduler matchers that filter on
142    /// `task->comm` (e.g. layered's `CommPrefix`). The comm is
143    /// applied once per worker; it is NOT live-propagated after
144    /// the worker enters its work loop.
145    pub comm: Option<Cow<'static, str>>,
146    /// The thread-group leader's comm — what schedulers read as
147    /// `task->group_leader->comm`. When set, `apply_setup` coalesces
148    /// every WorkSpec sharing this `pcomm` value (within one
149    /// CgroupDef) into ONE forked thread-group leader. The leader's
150    /// `task->comm` is set via `prctl(PR_SET_NAME)`; the setter
151    /// rejects > 15 bytes (TASK_COMM_LEN-1) at construction so the
152    /// `task->group_leader->comm == pcomm` invariant every worker
153    /// thread observes for the leader's lifetime matches the
154    /// requested string exactly (no silent kernel truncation).
155    /// WorkSpecs with `pcomm = None` (or empty pcomm string,
156    /// treated as `None`) spawn via the conventional fork path —
157    /// one process per worker.
158    ///
159    /// **Dispatch is `apply_setup`-only.** The `WorkSpec::pcomm`
160    /// setter itself always accepts a valid value (subject to the
161    /// existing 15-byte / NUL / empty-string checks). The bail
162    /// fires later at **`WorkloadConfig` dispatch-construction
163    /// time** — direct calls to
164    /// [`crate::workload::WorkloadHandle::spawn`] (composed entries)
165    /// and the scenario-engine spawn-dispatch sites
166    /// ([`crate::scenario::ops::Op::Spawn`] / `apply_setup` non-pcomm
167    /// path) all reject a pcomm-bearing
168    /// WorkSpec when they synthesize the per-spawn `WorkloadConfig`.
169    /// Those paths always fork one process per worker (fork mode),
170    /// so `task->group_leader->comm` would be left at the parent's
171    /// task->comm at fork time (the scenario runner's binary name)
172    /// and scheduler matchers filtering on the leader's comm would
173    /// see zero matches. The bail surfaces the misuse at the call
174    /// site instead of producing a workload that silently fails to
175    /// match its fixture. To drive the pcomm container path without
176    /// going through `CgroupDef`, callers may invoke
177    /// [`crate::workload::WorkloadHandle::spawn_pcomm_cgroup`]
178    /// directly with a `&[WorkSpec]` slice.
179    ///
180    /// This is the AUTHORITATIVE source for the pcomm dispatch:
181    /// `apply_setup` reads it directly from each WorkSpec.
182    /// `crate::scenario::ops::types::CgroupDef::pcomm` is a
183    /// convenience method that writes the same value into every
184    /// WorkSpec at builder time; there is no separate cgroup-level
185    /// pcomm field.
186    ///
187    /// Per-thread comm goes through [`Self::comm`] and the worker's
188    /// own `prctl(PR_SET_NAME)` at thread creation time. Models
189    /// real workloads like `chrome` (pcomm) hosting
190    /// `ThreadPoolForeg` and `GPU Process` worker threads
191    /// (per-thread comm), or `java` (pcomm) hosting `GC Thread`
192    /// and `C2 CompilerThre` worker threads.
193    ///
194    /// Declarative-only field — absent from
195    /// [`crate::workload::WorkloadConfig`] by design (same shape as
196    /// [`Self::num_workers`] / [`Self::workers_pct`]). pcomm is the
197    /// operator-facing intent that drives the apply_setup pcomm-
198    /// aware coalesce path; by the time a `WorkloadConfig` is
199    /// constructed for spawn, the per-WorkSpec pcomm has either
200    /// routed through `spawn_pcomm_cgroup` (then no longer needed
201    /// at the WorkloadConfig layer) or hit the dispatch-construction
202    /// bail at
203    /// `WorkloadConfig::for_scenario_engine`.
204    pub pcomm: Option<Cow<'static, str>>,
205    /// Effective UID set via `setresuid(uid, uid, uid)` after fork.
206    /// `None` inherits the parent's euid. Useful for scheduler
207    /// matchers that filter on `task->real_cred->euid` (e.g.
208    /// layered's `UIDEquals`).
209    pub uid: Option<u32>,
210    /// Effective GID set via `setresgid(gid, gid, gid)` after fork.
211    /// `None` inherits the parent's egid.
212    pub gid: Option<u32>,
213    /// Restrict worker affinity to the CPUs of this NUMA node.
214    /// Applied via `sched_setaffinity` after fork. Useful for
215    /// scheduler matchers that check `bpf_cpumask_subset(cpus_ptr,
216    /// node_cpumask)` (e.g. layered's `NumaNode`).
217    pub numa_node: Option<u32>,
218    /// Optional fraction-of-cpuset worker count. When `Some(p)`, the
219    /// dispatch site computes `ceil(cpuset_cpus * p)` and writes the
220    /// result into `num_workers`. The denominator is the cgroup's
221    /// currently-recorded cpuset at dispatch time:
222    ///
223    /// - `apply_setup` dispatch: the cgroup was just created and its
224    ///   cpuset just resolved via `CpusetSpec::resolve(ctx)` (or
225    ///   inherited from `ctx.topo.usable_cpuset()` when the
226    ///   `CgroupDef` has no `.cpuset(...)`), so the denominator
227    ///   matches the declared `CpusetSpec`.
228    /// - `Op::Spawn(SpawnPlacement::Cgroup)` dispatch: the denominator
229    ///   is whatever cpuset is currently recorded for the cgroup. A
230    ///   prior `Op::SetCpuset` that narrowed the cgroup will narrow
231    ///   the denominator too. Workers already spawned by a prior
232    ///   `apply_setup` are not re-counted.
233    ///
234    /// Cannot coexist with `num_workers = Some(_)` — validation
235    /// rejects that combination because it's ambiguous which source
236    /// wins. Values > 1.0 are accepted as deliberate oversubscription
237    /// (e.g. `workers_pct(2.0)` on a 10-CPU cpuset produces 20
238    /// workers). NaN/Inf/negative are rejected at construction time.
239    ///
240    /// Declarative-only field — absent from
241    /// [`crate::workload::WorkloadConfig`] by design. The same
242    /// asymmetry as [`Self::num_workers`]: `WorkSpec` is the
243    /// operator-facing declarative spec where `workers_pct(p)` is
244    /// a meaningful "scale with the cpuset" intent;
245    /// `Self::resolve_workers_pct` (called at apply_setup and at
246    /// each dispatch site) computes the concrete worker count
247    /// against the dispatch-time cpuset and writes the result into
248    /// the WorkSpec's `num_workers` field, which then flows through
249    /// the standard `resolve_num_workers` →
250    /// [`crate::workload::WorkloadConfig::num_workers`] migration
251    /// boundary. There is no `workers_pct` field on `WorkloadConfig`
252    /// because by spawn time the scale-with-cpuset intent has
253    /// already collapsed to a concrete count.
254    pub workers_pct: Option<f64>,
255}
256
257impl Default for WorkSpec {
258    /// Single SpinWait worker under the kernel's default scheduling
259    /// class — the framework's no-customization baseline. Every
260    /// other field is `None` / inherit so a test that needs a
261    /// specific knob (`affinity`, `mem_policy`, `nice`, etc.) sets
262    /// only that one via the corresponding `WorkSpec::with_*`
263    /// builder. `num_workers = None` defers count selection to
264    /// `CgroupDef`'s merged-works contract (the cgroup-level
265    /// default applies; see `CgroupDef::workers` /
266    /// `CgroupDef::merged_works`). The `workers_pct` mutex with
267    /// `num_workers` only fires when BOTH are `Some(_)` — at
268    /// default neither is set, so the
269    /// `WorkSpec::resolve_workers_pct` arm that emits the
270    /// `WorkSpec sets BOTH workers(...) and workers_pct(...)` bail
271    /// does not trigger.
272    fn default() -> Self {
273        Self {
274            work_type: WorkType::SpinWait,
275            sched_policy: SchedPolicy::Normal,
276            num_workers: None,
277            affinity: AffinityIntent::Inherit,
278            mem_policy: MemPolicy::Default,
279            mpol_flags: MpolFlags::NONE,
280            nice: None,
281            comm: None,
282            pcomm: None,
283            uid: None,
284            gid: None,
285            numa_node: None,
286            workers_pct: None,
287        }
288    }
289}
290
291impl WorkSpec {
292    /// Set the number of workers.
293    #[must_use = "builder methods consume self; bind the result"]
294    pub fn workers(mut self, n: usize) -> Self {
295        self.num_workers = Some(n);
296        self
297    }
298
299    /// Set the worker count as a fraction of the resolved cpuset
300    /// CPU count. Apply-setup computes `ceil(cpuset_cpus * pct)` and
301    /// writes the result into `num_workers`. Use this when the worker
302    /// count should scale with the cpuset rather than hardcoding a
303    /// per-topology constant.
304    ///
305    /// Setting BOTH `workers(n)` and `workers_pct(p)` on the same
306    /// WorkSpec is rejected at apply-setup time because the two sources
307    /// would silently fight; pick one. Values > 1.0 are accepted as
308    /// deliberate oversubscription; NaN, infinite, and non-positive
309    /// values are rejected here at construction time via an assertion.
310    ///
311    /// # Panics
312    ///
313    /// Panics when `pct` is NaN, infinite, or `<= 0.0`. The builder
314    /// returns `Self`, so the construction-time gate uses `assert!`
315    /// rather than a fallible `Result`. Negative or zero fractions
316    /// would resolve to zero workers — caught at apply-setup time by
317    /// `resolve_num_workers`'s zero-workers rejection anyway, but the
318    /// construction-time message is more actionable.
319    ///
320    /// Extreme finite values (e.g. `1e100`) pass the construction gate
321    /// and saturate to `usize::MAX` via the `as` cast in
322    /// `resolve_workers_pct` (RFC 2484 / Rust 1.45+). Attempting to
323    /// spawn that many workers would OOM the host. The framework
324    /// imposes no upper cap; as a rule of thumb keep `pct` near the
325    /// intended oversubscription factor (e.g. `1.0`, `2.0`, `4.0`).
326    #[must_use = "builder methods consume self; bind the result"]
327    pub fn workers_pct(mut self, pct: f64) -> Self {
328        assert!(
329            pct.is_finite() && pct > 0.0,
330            "WorkSpec::workers_pct({pct}): pct must be finite and > 0.0",
331        );
332        self.workers_pct = Some(pct);
333        self
334    }
335
336    /// Resolve `workers_pct` against a cpuset size into a concrete
337    /// `num_workers` count and clear the fractional state, leaving
338    /// `num_workers = Some(scaled)` and `workers_pct = None`. Used by
339    /// both `apply_setup` (per-CgroupDef WorkSpec) and
340    /// `Op::Spawn(SpawnPlacement::Cgroup)` (mid-step ad-hoc spawn)
341    /// so the two paths produce identical counts for the same
342    /// `(pct, cpuset_size)` pair.
343    ///
344    /// Rejects the ambiguous `(num_workers = Some, workers_pct =
345    /// Some)` combination with an `anyhow::bail!` naming the cgroup.
346    /// Rejects a computed count of zero (e.g. empty cpuset, or
347    /// fraction so small it rounds down) with an actionable diagnostic
348    /// naming the cgroup, the cpuset size, and the requested fraction.
349    /// Returns the original [`WorkSpec`] unchanged when `workers_pct` is
350    /// `None`.
351    pub(crate) fn resolve_workers_pct(
352        mut self,
353        cpuset_cpus: usize,
354        cgroup_name: &str,
355    ) -> anyhow::Result<Self> {
356        let Some(pct) = self.workers_pct else {
357            return Ok(self);
358        };
359        if let Some(n) = self.num_workers {
360            anyhow::bail!(
361                "cgroup '{}': WorkSpec sets BOTH workers({n}) and \
362                 workers_pct({pct}); pick one — workers_pct resolves the \
363                 cpuset fraction at apply-setup time and is incompatible \
364                 with an explicit count",
365                cgroup_name,
366            );
367        }
368        let scaled = (cpuset_cpus as f64 * pct).ceil() as usize;
369        if scaled == 0 {
370            anyhow::bail!(
371                "cgroup '{cgroup_name}': workers_pct({pct}) on a cpuset of \
372                 {cpuset_cpus} CPU(s) resolved to 0 workers \
373                 (ceil({cpuset_cpus} * {pct}) = 0); the cgroup would \
374                 have no workers and downstream assertions would \
375                 vacuously pass — narrow the cpuset, raise the fraction, \
376                 or use `workers(N)` instead",
377            );
378        }
379        self.num_workers = Some(scaled);
380        self.workers_pct = None;
381        Ok(self)
382    }
383
384    /// Set the work type.
385    #[must_use = "builder methods consume self; bind the result"]
386    pub fn work_type(mut self, wt: WorkType) -> Self {
387        self.work_type = wt;
388        self
389    }
390
391    /// Set the Linux scheduling policy.
392    #[must_use = "builder methods consume self; bind the result"]
393    pub fn sched_policy(mut self, p: SchedPolicy) -> Self {
394        self.sched_policy = p;
395        self
396    }
397
398    /// Set the per-worker affinity intent.
399    #[must_use = "builder methods consume self; bind the result"]
400    pub fn affinity(mut self, a: AffinityIntent) -> Self {
401        self.affinity = a;
402        self
403    }
404
405    /// Set the NUMA memory placement policy.
406    #[must_use = "builder methods consume self; bind the result"]
407    pub fn mem_policy(mut self, p: MemPolicy) -> Self {
408        self.mem_policy = p;
409        self
410    }
411
412    /// Set the NUMA memory policy mode flags.
413    #[must_use = "builder methods consume self; bind the result"]
414    pub fn mpol_flags(mut self, f: MpolFlags) -> Self {
415        self.mpol_flags = f;
416        self
417    }
418
419    /// Set the per-worker nice value applied via `setpriority(2)`.
420    ///
421    /// Stores `Some(n)` on the spec; the spawn pipeline calls
422    /// `setpriority(PRIO_PROCESS, 0, n)` unconditionally (including
423    /// `n == 0`). The "skip the syscall, inherit the parent's nice"
424    /// state is the type-level default `None` — leave the builder
425    /// unchained for inherit semantics. Values below the calling
426    /// task's current nice require `CAP_SYS_NICE`; see
427    /// [`crate::workload::WorkloadConfig::nice`] for the full `can_nice` rule.
428    #[must_use = "builder methods consume self; bind the result"]
429    pub fn nice(mut self, n: i32) -> Self {
430        self.nice = Some(n);
431        self
432    }
433
434    /// Set the worker process name via `prctl(PR_SET_NAME)`.
435    ///
436    /// # Panics
437    ///
438    /// Panics on programmer-error inputs — same three cases as
439    /// [`Self::pcomm`]:
440    /// - Empty string (silent kernel-comm clobber).
441    /// - Interior NUL byte (prctl C-string truncation).
442    /// - More than 15 bytes (`TASK_COMM_LEN - 1` —
443    ///   `__set_task_comm` truncates at 15 so the framework rejects
444    ///   at construction to keep the kernel-observed comm equal to
445    ///   the requested value).
446    ///
447    /// See `validate_task_comm_string` for the centralized
448    /// rationale; `name.len()` is the BYTE length (UTF-8 multi-byte
449    /// chars count as their byte width, not their codepoint count).
450    #[must_use = "builder methods consume self; bind the result"]
451    pub fn comm(mut self, name: impl Into<Cow<'static, str>>) -> Self {
452        let name: Cow<'static, str> = name.into();
453        validate_task_comm_string("WorkSpec::comm", &name);
454        self.comm = Some(name);
455        self
456    }
457
458    /// Set the worker's effective UID via `setresuid`.
459    #[must_use = "builder methods consume self; bind the result"]
460    pub fn uid(mut self, uid: u32) -> Self {
461        self.uid = Some(uid);
462        self
463    }
464
465    /// Set the worker's effective GID via `setresgid`.
466    #[must_use = "builder methods consume self; bind the result"]
467    pub fn gid(mut self, gid: u32) -> Self {
468        self.gid = Some(gid);
469        self
470    }
471
472    /// Restrict worker affinity to a NUMA node's CPU set.
473    #[must_use = "builder methods consume self; bind the result"]
474    pub fn numa_node(mut self, node: u32) -> Self {
475        self.numa_node = Some(node);
476        self
477    }
478
479    /// Set the thread-group leader's comm. Triggers fork-then-thread
480    /// spawn through `apply_setup` (or via
481    /// [`crate::workload::WorkloadHandle::spawn_pcomm_cgroup`] for
482    /// the direct entry point): one forked leader process whose
483    /// `task->comm` is `name`, threads spawned inside it. Each
484    /// thread additionally sets its own `task->comm` via
485    /// [`Self::comm`] at thread creation time.
486    ///
487    /// # Panics
488    ///
489    /// Panics on programmer-error inputs:
490    /// - Empty string — the empty pcomm has no observable effect
491    ///   (kernel sets task->comm to ""), so it's a no-op disguised
492    ///   as configuration. `apply_setup` treats empty as `None` to
493    ///   keep the dispatch contract unambiguous, but accepting the
494    ///   builder call would silently drop user intent. Reject up
495    ///   front.
496    /// - Interior NUL byte — `prctl(PR_SET_NAME)` takes a C string;
497    ///   any embedded NUL truncates the kernel-side comm at the
498    ///   first NUL silently, producing a comm value the caller
499    ///   didn't ask for. Reject so the operator sees the error
500    ///   immediately instead of debugging a truncated comm.
501    /// - More than 15 bytes — `__set_task_comm` writes
502    ///   `min(strlen(buf), sizeof(tsk->comm) - 1)` bytes and
503    ///   `TASK_COMM_LEN = 16`, so the 16th byte (and beyond) is
504    ///   silently dropped. Rejecting at construction time means the
505    ///   `task->group_leader->comm == pcomm` invariant the rest of
506    ///   the framework relies on holds exactly, and the operator
507    ///   sees the cap at the call site instead of debugging a
508    ///   truncated comm.
509    #[must_use = "builder methods consume self; bind the result"]
510    pub fn pcomm(mut self, name: impl Into<Cow<'static, str>>) -> Self {
511        let name: Cow<'static, str> = name.into();
512        validate_task_comm_string("WorkSpec::pcomm", &name);
513        self.pcomm = Some(name);
514        self
515    }
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    #[test]
523    #[should_panic(expected = "WorkSpec::comm: empty string rejected")]
524    fn work_spec_comm_rejects_empty() {
525        let _ = WorkSpec::default().comm("");
526    }
527
528    #[test]
529    #[should_panic(expected = "interior NUL byte")]
530    fn work_spec_comm_rejects_interior_nul() {
531        let _ = WorkSpec::default().comm("foo\0bar");
532    }
533
534    #[test]
535    #[should_panic(expected = "WorkSpec::pcomm: empty string rejected")]
536    fn work_spec_pcomm_rejects_empty() {
537        let _ = WorkSpec::default().pcomm("");
538    }
539
540    #[test]
541    #[should_panic(expected = "interior NUL byte")]
542    fn work_spec_pcomm_rejects_interior_nul() {
543        let _ = WorkSpec::default().pcomm("foo\0bar");
544    }
545
546    #[test]
547    fn work_spec_comm_accepts_15_byte_boundary() {
548        let fifteen = "a".repeat(15);
549        let spec = WorkSpec::default().comm(fifteen.clone());
550        assert_eq!(spec.comm.as_deref(), Some(fifteen.as_str()));
551    }
552
553    #[test]
554    #[should_panic(expected = "WorkSpec::comm: name")]
555    fn work_spec_comm_rejects_16_byte_overflow() {
556        let _ = WorkSpec::default().comm("a".repeat(16));
557    }
558
559    #[test]
560    fn work_spec_pcomm_accepts_15_byte_boundary() {
561        let fifteen = "a".repeat(15);
562        let spec = WorkSpec::default().pcomm(fifteen.clone());
563        assert_eq!(spec.pcomm.as_deref(), Some(fifteen.as_str()));
564    }
565
566    #[test]
567    #[should_panic(expected = "WorkSpec::pcomm: name")]
568    fn work_spec_pcomm_rejects_16_byte_overflow() {
569        let _ = WorkSpec::default().pcomm("a".repeat(16));
570    }
571
572    /// UTF-8 boundary: the length cap is byte-counted, not
573    /// codepoint-counted. 5 Cyrillic chars = 10 bytes (each char in
574    /// U+0400-U+04FF is 2 bytes) — accepts. 8 Cyrillic chars = 16
575    /// bytes — panics. Sanity-check the assumption with `s.len()`
576    /// at runtime so a future Cyrillic literal in this test that
577    /// drifts off the 2-byte assumption surfaces immediately
578    /// instead of as a false acceptance.
579    #[test]
580    fn work_spec_comm_accepts_10_byte_utf8_within_cap() {
581        let cyr10 = "приве";
582        assert_eq!(
583            cyr10.len(),
584            10,
585            "test fixture: cyrillic 5-char must be 10 bytes"
586        );
587        let spec = WorkSpec::default().comm(cyr10);
588        assert_eq!(spec.comm.as_deref(), Some(cyr10));
589    }
590
591    #[test]
592    #[should_panic(expected = "16 bytes")]
593    fn work_spec_comm_rejects_16_byte_utf8_overflow() {
594        let cyr16 = "приветик";
595        assert_eq!(
596            cyr16.len(),
597            16,
598            "test fixture: cyrillic 8-char must be 16 bytes"
599        );
600        let _ = WorkSpec::default().comm(cyr16);
601    }
602}