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}