diff --git a/dag/extdeps/systemd/unit_file.dag b/dag/extdeps/systemd/unit_file.dag index 5262a201da1..381bb0ad7f6 100644 --- a/dag/extdeps/systemd/unit_file.dag +++ b/dag/extdeps/systemd/unit_file.dag @@ -1,6 +1,12 @@ module extdeps.systemd.unit_file import std.types { NonEmptyStr, String, List, Bool, Int } +import std.measure { ByteSize, byte_size_count } +import extdeps.systemd { + SystemdUnitProperty, + MemoryMax, MemoryHigh, MemorySwapMax, TasksMax, CPUWeight, + systemd_unit_property_wire, +} import extdeps.external_authority { ExternalAuthority, ExternalModelScope, ExternalSubjectRef } import extdeps.uri { Uri, Https } import std.decl_ref { DeclarationRef, WholeDeclaration } @@ -301,20 +307,145 @@ type SystemdInstallDirective = WantedBy { target: NonEmptyStr } | RequiredBy { target: NonEmptyStr } -// A [Slice] UNIT'S DIRECTIVES. `MemoryMax=` is the hard ceiling the kernel enforces on the cgroup; -// `MemoryHigh=` is a throttling watermark the kernel permits to be exceeded. They are two directives -// with two meanings, so they are two variants rather than one `MemoryLimit { key, value }` -- fusing -// them would put the difference between a bound and a watermark back into a string, and a consumer -// comparing an observed limit against a declared one has to know which of the two it read. +// A [Slice] UNIT'S DIRECTIVES, AND THE KNOB NAME IS NOT MINTED HERE. +// +// The previous shape was a two-arm coproduct, `SliceMemoryMax { bytes: NonEmptyStr }` and +// `SliceMemoryHigh { bytes: NonEmptyStr }`, and it had two defects that only show up when a third +// knob is needed. It spelled `MemoryMax` and `MemoryHigh` a SECOND time -- `extdeps.systemd` +// `SystemdUnitProperty` already carries both names and `systemd_unit_property_wire` already renders +// them -- so a boundary needing `MemorySwapMax=`, `TasksMax=` and `CPUWeight=` would have widened +// that fork from two names to five. And it carried every value as `NonEmptyStr`, so a magnitude the +// caller was holding as a `ByteSize` was flattened one call before the wire. +// +// SO THE KNOB IS THE PROPERTY AND THE VALUE IS A MAGNITUDE, and the record is `sole_constructor` so +// that pairing is the only one a consumer can obtain. Zero knob names are minted here: the wire +// spelling has exactly one owner, reached through `property`, and a name added or corrected upstream +// moves both surfaces at once. +// +// WHY THE PROPERTY TYPE IS SAFE HERE WHEN GROUNDING THE DIRECTIVE *COPRODUCT* ON IT WOULD NOT BE. +// `gunbc.systemd_property_directive_overlap` is the authority on this question and its ruling is +// that `SystemdUnitProperty` is the SHOW SURFACE: it mixes settable knobs with observations the +// manager computes, so a directive type that admitted any member would make `MemoryCurrent=`, +// `ActiveState=` and `MainPID=` writable into a unit file -- reuse bought by making an invalid state +// representable. What closes that is not a check and not a narrower field type: it is that the only +// constructors in existence are the mints below, and `sole_constructor` refuses a record literal +// from any other module. `MemoryCurrent=` has no spelling because nothing produces it. +// +// SCOPE OF THAT GUARANTEE, DECLARED RATHER THAN INHERITED (DESIGN section 4b). `sole_constructor` +// confines cross-module construction on the source-to-`.dag` acceptance path, and DESIGN records by +// execution that an emitted Rust mirror of such a type is silently forgeable +// (`extdeps.uri` `UriValidatedScalar`). This record is constructed and rendered entirely inside the +// `.dag` pipeline -- it is never deserialized, and nothing on the emitted side reconstructs one -- +// so the mint is sufficient FOR THE PATH IT TRAVELS. If a directive ever arrives from outside that +// path, this paragraph is the notice that the invariant needs a fresh answer rather than an +// inherited one. Same-module construction also remains possible by design; the confinement is about +// who else may write the pairing, not about the declaring module disciplining itself. // -// They are deliberately NOT shared with [Service], even though systemd.resource-control permits both -// directives in either section. That is this module's established reading, stated at +// THEY ARE STILL DELIBERATELY NOT SHARED WITH [Service], even though systemd.resource-control +// permits these directives in either section. That is this module's established reading, stated at // `SystemdUnitDirective`: one directive type per section, so a misplaced directive has no -// representation. Sharing one resource-control type across both sections would buy a little reuse and -// sell the property the whole module is built on. -type SystemdSliceDirective - = SliceMemoryMax { bytes: NonEmptyStr } - | SliceMemoryHigh { bytes: NonEmptyStr } +// representation. The record is `SystemdSliceDirective` and not a shared resource-control type for +// exactly that reason -- sharing one across both sections would buy a little reuse and sell the +// property the whole module is built on. +type SystemdSliceDirective sole_constructor { + property: SystemdUnitProperty + value: SystemdDirectiveValue +} + +// THE WRITE-SIDE VALUE, AND IT IS ITS OWN TYPE RATHER THAN THE READ-SIDE ONE. +// +// `extdeps.systemd` `SystemdCgroupMemoryLimit` looks like it fits -- bytes, unbounded, absent -- and +// reusing it would be the same defect as grounding on the show surface, one level down: its third +// arm exists because a `systemctl show` READBACK can be a string this repository cannot parse, and a +// value that cannot be parsed is not a value a unit file may assign. The read-side type stays the +// wider one and the write side gets these arms. +// +// `DirectiveUnlimited` renders systemd's own sentinel. It is a variant rather than a magic magnitude +// because "no ceiling" is not a large number: a consumer comparing a declared ceiling against an +// observed one must not have to decide which integer means absent. +// +// WHAT IS DELIBERATELY NOT MODELED, and it is a residue rather than a closed set left open. +// systemd.resource-control(5) also admits a K/M/G/T-suffixed byte count and a percentage of +// installed memory for the memory limits, and a percentage for `TasksMax=`. No producer in this +// repository emits either form, so arms for them would be constructors with no consumer -- and the +// suffixed form in particular is a spelling of the same magnitude this type already carries, which +// is a rendering choice rather than a distinct value. `gunbc.systemd_directive_value_grain` carries +// the standing obligation for the percentage form, which is the one that genuinely cannot be +// expressed here. +// A COUNT AND A WEIGHT ARE TWO ARMS AND NOT ONE `Int`, even though they render identically today. +// `TasksMax=` is a cardinal -- how many processes -- and `CPUWeight=` is a relative share against +// every sibling cgroup, an amount of nothing on its own. Fusing them into one integer arm would put +// that difference into position, which is the same move this module refuses for `RestartSec=` versus +// `TimeoutStopSec=` above; and the two ranges are unrelated, so a bound modeled later narrows one +// arm rather than needing to be told which meaning it is narrowing. +type SystemdDirectiveValue + = DirectiveByteCount { bytes: ByteSize } + | DirectiveCardinal { count: Int } + | DirectiveWeight { weight: Int } + | DirectiveUnlimited + +fn systemd_directive_value_wire(value: SystemdDirectiveValue) -> String { + match value { + DirectiveByteCount { bytes: b } => to_string(byte_size_count(b: b)) + DirectiveCardinal { count: n } => to_string(n) + DirectiveWeight { weight: w } => to_string(w) + DirectiveUnlimited => systemd_directive_unlimited_wire + } +} + +// systemd's sentinel for "no limit", spelled once. `max` is the cgroup v2 kernel interface's word; +// `infinity` is systemd's, and a unit file is read by systemd rather than by the kernel. +data systemd_directive_unlimited_wire: String = "infinity" + +// THE MINTS, WHICH ARE THE WHOLE ADMISSION SURFACE. +// +// One per (knob, value-shape) pair systemd admits, so a caller states which knob it means and hands +// over a magnitude of the right kind -- and cannot state a knob systemd would reject, nor pair a +// byte count with a task ceiling. `CPUWeight=` has no unlimited mint because systemd defines no +// such value for it: the asymmetry is the model rather than an omission. +fn slice_memory_max(bytes: ByteSize) -> SystemdSliceDirective { + SystemdSliceDirective { property: MemoryMax, value: DirectiveByteCount { bytes: bytes } } +} + +fn slice_memory_max_unlimited() -> SystemdSliceDirective { + SystemdSliceDirective { property: MemoryMax, value: DirectiveUnlimited } +} + +fn slice_memory_high(bytes: ByteSize) -> SystemdSliceDirective { + SystemdSliceDirective { property: MemoryHigh, value: DirectiveByteCount { bytes: bytes } } +} + +fn slice_memory_high_unlimited() -> SystemdSliceDirective { + SystemdSliceDirective { property: MemoryHigh, value: DirectiveUnlimited } +} + +fn slice_memory_swap_max(bytes: ByteSize) -> SystemdSliceDirective { + SystemdSliceDirective { property: MemorySwapMax, value: DirectiveByteCount { bytes: bytes } } +} + +fn slice_memory_swap_max_unlimited() -> SystemdSliceDirective { + SystemdSliceDirective { property: MemorySwapMax, value: DirectiveUnlimited } +} + +// `TasksMax=` IS A CARDINAL AND NOT A MAGNITUDE THIS REPOSITORY HAS A CARRIER FOR. `extdeps.systemd` +// already declares that gap against its own read-side parse (feature:task-count-measure-carrier), +// and inventing a `TaskCount` here to avoid a bare `Int` would mint the concept in the consumer +// rather than beside the parse that needs it too. So the `Int` is the same honest grain the read +// side carries, and it moves when that carrier lands. +fn slice_tasks_max(count: Int) -> SystemdSliceDirective { + SystemdSliceDirective { property: TasksMax, value: DirectiveCardinal { count: count } } +} + +fn slice_tasks_max_unlimited() -> SystemdSliceDirective { + SystemdSliceDirective { property: TasksMax, value: DirectiveUnlimited } +} + +// `CPUWeight=` is a relative share over 1..10000, not an amount of anything -- a bound this mint +// does not narrow, which is a range left unmodeled and not a value set left open, exactly as +// `ServiceNice`'s -20..19 above. +fn slice_cpu_weight(weight: Int) -> SystemdSliceDirective { + SystemdSliceDirective { property: CPUWeight, value: DirectiveWeight { weight: weight } } +} // The directive names are systemd's, spelled once here. A consumer never writes the literal // "ExecStart"; it constructs `ExecStart { command }` and this module decides how that renders. @@ -368,11 +499,15 @@ fn systemd_timer_directive_line(directive: SystemdTimerDirective) -> String { } } +// THE LINE HAS NO MATCH LEFT TO GET WRONG. Both halves come from an authority: the knob name from +// `systemd_unit_property_wire` and the value from `systemd_directive_value_wire`, so a fifth knob is +// a mint above and no edit here. fn systemd_slice_directive_line(directive: SystemdSliceDirective) -> String { - match directive { - SliceMemoryMax { bytes: v } => join(["MemoryMax=", v as String], "") - SliceMemoryHigh { bytes: v } => join(["MemoryHigh=", v as String], "") - } + join([ + systemd_unit_property_wire(property: directive.property) as String, + "=", + systemd_directive_value_wire(value: directive.value), + ], "") } fn systemd_install_directive_line(directive: SystemdInstallDirective) -> String { diff --git a/dag/gunbc/build_cache_unit.dag b/dag/gunbc/build_cache_unit.dag index 79250a18272..c8b23728c6b 100644 --- a/dag/gunbc/build_cache_unit.dag +++ b/dag/gunbc/build_cache_unit.dag @@ -10,7 +10,7 @@ import extdeps.systemd.unit_file { SystemdInstallSection, Installable, NotInstallable, Description, After, Before, Requires, ServiceType, ExecStart, Restart, Environment, ServiceUser, ServiceGroup, ServiceSlice, - SliceMemoryMax, SliceMemoryHigh, + slice_memory_max, slice_memory_high, WantedBy, serialize_systemd_unit_file, systemd_unit_file_lines, systemd_drop_in_lines, @@ -157,8 +157,8 @@ fn compile_pool_slice_unit_file(slice_unit: NonEmptyStr, sized: ByteSize) -> Sys Before { target: "slices.target" as NonEmptyStr }, ], slice: [ - SliceMemoryMax { bytes: to_string(byte_size_count(b: sized)) as NonEmptyStr }, - SliceMemoryHigh { bytes: to_string(byte_size_count(b: sized)) as NonEmptyStr }, + slice_memory_max(bytes: sized), + slice_memory_high(bytes: sized), ], install: NotInstallable, } diff --git a/dag/gunbc/systemd_directive_value_grain.dag b/dag/gunbc/systemd_directive_value_grain.dag index 5fff8e6d7ff..3ca754af2ba 100644 --- a/dag/gunbc/systemd_directive_value_grain.dag +++ b/dag/gunbc/systemd_directive_value_grain.dag @@ -8,7 +8,9 @@ import std.dissolution { DissolutionCondition, unbound_dissolution } // // The systemd unit grammar in extdeps.systemd.unit_file models WHICH directive goes in WHICH // section as structure -- Slice= cannot be written into [Unit], WantedBy= cannot be written into -// [Service] -- and models every directive's VALUE as a string. That is the correct boundary for a +// [Service] -- and, WHEN THIS ROW WAS FILED, modeled every directive's VALUE as a string. That +// reading is still true of the [Unit], [Service] and [Timer] vocabularies and no longer of [Slice]; +// the discharge is narrated below. Carrying the wire as text was the correct boundary for a // cited transport: systemd's own directive syntax is textual and admits several spellings per axis, // so a grammar that refused everything but one form would be modeling a choice upstream did not // make. The residue this module files is not that the wire is text. It is that two specific values @@ -19,24 +21,36 @@ import std.dissolution { DissolutionCondition, unbound_dissolution } // -- and #8827 was a renderer migration. A contract change smuggled inside a migration is read by // reviewers who came for the migration. // -// THE ByteSize AUTHORITY IS INTACT AND IS SPENT AT EXACTLY ONE LINE, which is the difference -// between this filing and a general complaint about stringly values. gunbc.build_cache_unit -// compile_pool_slice_unit_file receives a ByteSize and calls to_string(byte_size_count(b: sized)) -// to fill SliceMemoryMax and SliceMemoryHigh. So the magnitude is modeled all the way down to the -// constructor argument and flattened there, not carried as text through the vertical -- a reviewer -// checking whether a flat scalar propagates INWARD will correctly find that it does not. -// -// WHAT IS ACTUALLY LOST is narrower and worth naming precisely: systemd.resource-control(5) accepts -// MemoryMax as a plain byte count OR with a K/M/G/T suffix OR as a percentage of installed memory, -// and the directive type cannot say which of those a given value is. Today every producer emits the -// plain count, so the loss is latent rather than live -- there is no defect to point at, which is -// exactly why it is filed rather than fixed. It becomes live the first time a producer wants a -// percentage, at which point a producer emitting a percentage and one emitting a count are -// indistinguishable to any consumer reading the directive back. -// -// STATED AS A RUNG (DESIGN section 4b): that axis sits at MITIGATABLE and its ceiling is +// THE SLICE HALF OF THIS FILING IS DISCHARGED, AND WHAT REPLACED IT IS NOT WHAT THE ROW PREDICTED. +// +// AS FILED, the two slice directives took NonEmptyStr and gunbc.build_cache_unit +// compile_pool_slice_unit_file spent its ByteSize at exactly two lines -- +// to_string(byte_size_count(b: sized)) -- so the magnitude was modeled all the way down to the +// constructor argument and flattened there. Both of those lines are gone: the mint takes the +// ByteSize directly and the flattening deleted with them. +// +// WHAT MOVED THE SHAPE was gunbc.systemd_property_directive_overlap rather than this row. The cell +// resource boundary needs five knobs, not two, and adding MemorySwapMax=, TasksMax= and CPUWeight= +// as three more arms would have widened the knob-name fork that module counts from two to five. So +// SystemdSliceDirective is now a sole_constructor record pairing a SystemdUnitProperty with a +// modeled value, reachable only through per-knob mints -- zero knob names minted, one wire owner, +// and no observation-only property producible because nothing mints one. The named declarations +// this row was premised on surviving -- SliceMemoryMax and SliceMemoryHigh -- DISSOLVED, which +// falsifies the clause below that assumed they would stay and only their field type would change. +// +// WHAT IS STILL LOST ON THIS AXIS, narrowed to what the new shape genuinely cannot say: +// systemd.resource-control(5) accepts MemoryMax as a plain byte count OR with a K/M/G/T suffix OR +// as a PERCENTAGE of installed memory. The byte count is now a ByteSize and the suffixed form is a +// rendering of that same magnitude, so neither is a lost distinction any more. The PERCENTAGE is: +// it is a different quantity -- a fraction of a host figure this repository does not hold -- and +// SystemdDirectiveValue has no arm for it. Today every producer emits the plain count, so the loss +// stays latent exactly as filed, and it becomes live the first time a producer wants a percentage. +// +// STATED AS A RUNG (DESIGN section 4b): the percentage axis sits at MITIGATABLE and its ceiling is // structurally impossible, because membership is decidable -- the accepted forms are a closed, -// cited set. It is not a ratchet. +// cited set. It is not a ratchet. TasksMax= carries a second, separate grain residue: its value is +// a bare Int on both the write side and extdeps.systemd's read-side parse, and extdeps.systemd +// declares that gap itself against feature:task-count-measure-carrier. data systemd_directive_value_grain_scope: Disposition = SingleAuthority // Environment { assignment: NonEmptyStr } CARRIES A KEY=VALUE PAIR AS ONE OPAQUE STRING, and the @@ -121,15 +135,23 @@ fn environment_name_authority_is_split() -> Bool { |> any(v => match v.authority { ModeledUpstream => false SpelledInConsumer => true })) } -// THE TRIGGER IS UNBOUND ON PURPOSE. -// -// The terminal shape does not delete either named declaration: SliceMemoryMax stays and its `bytes` -// field becomes a modeled value, Environment stays and its `assignment` becomes two fields. So -// neither DeclarationRetires nor DeclarationAppears fits -- the first is false because the subject -// survives, and the second would require naming a symbol nobody has declared, which is the -// fabricated-citation class. Picking a ref that makes the row LOOK bound would be that failure -// arriving by the back door, so the row states its condition in prose and is honest that no -// mechanism watches it. +// THE TRIGGER IS UNBOUND ON PURPOSE, AND IT STILL IS AFTER THE SLICE HALF LANDED. +// +// The original reasoning was that the terminal shape deletes no named declaration -- SliceMemoryMax +// stays and its `bytes` field becomes a modeled value, Environment stays and its `assignment` +// becomes two fields -- so neither DeclarationRetires nor DeclarationAppears fits. THE FIRST HALF OF +// THAT PREMISE TURNED OUT FALSE: SliceMemoryMax and SliceMemoryHigh were deleted rather than +// re-fielded. The conclusion survives anyway, for the SECOND half's reason: Environment stays and is +// re-fielded, so a retirement ref would be false of the obligation that remains, and naming a symbol +// nobody has declared is the fabricated-citation class. Picking a ref that makes the row LOOK bound +// would be that failure arriving by the back door. +// +// THE ROW IS NOT DELETED, AND THAT IS THE POINT OF REWRITING RATHER THAN REMOVING IT. It carries +// THREE obligations -- the slice value grain, Environment becoming two fields, and the three +// remaining sccache names moving to extdeps.cache.sccache -- and it dissolves only when all three +// land together. One of the three is now done; a prose row deleted for one reason takes everything +// in it, so the other two are restated below unchanged and the deletion condition still requires +// them. data systemd_directive_value_grain_dissolution: DissolutionCondition = unbound_dissolution( - description: "the systemd directive vocabulary decomposes its values: SliceMemoryMax and SliceMemoryHigh take a modeled magnitude that can express the byte count, the suffixed form and the percentage form systemd.resource-control(5) admits, and Environment takes a variable name and a value as two fields rather than one fused assignment -- at which point the remaining three sccache variable names gunbc spells as literals move to extdeps.cache.sccache beside the five modeled rows. SCCACHE_DIR and SCCACHE_CACHE_SIZE moved early because the readiness producer now reads those exact upstream names as well as the renderer writing them; leaving either literal in both consumers would have grown the fork this roster exists to count. This row deletes when the value-grain work and the remaining name move land together." as NonEmptyStr, + description: "the systemd directive vocabulary decomposes its values. THE SLICE CLAUSE IS DISCHARGED AND IS RESTATED HERE AS DONE RATHER THAN DROPPED, because the shape that discharged it is not the shape this clause predicted: SliceMemoryMax and SliceMemoryHigh do not survive taking a modeled magnitude -- they DISSOLVED into extdeps.systemd.unit_file SystemdSliceDirective, a sole_constructor record pairing a SystemdUnitProperty with a modeled SystemdDirectiveValue and reachable only through per-knob mints, on gunbc.systemd_property_directive_overlap's reading that adding three more knob-named arms would widen the fork that module counts. The byte count and the suffixed form are one ByteSize and are no longer a lost distinction; the PERCENTAGE form remains unmodeled and is the residue that survives on this axis. WHAT REMAINS OUTSTANDING, and what this row still waits on: Environment takes a variable name and a value as two fields rather than one fused assignment -- at which point the remaining three sccache variable names gunbc spells as literals move to extdeps.cache.sccache beside the five modeled rows. SCCACHE_DIR and SCCACHE_CACHE_SIZE moved early because the readiness producer now reads those exact upstream names as well as the renderer writing them; leaving either literal in both consumers would have grown the fork this roster exists to count. This row deletes when the Environment decomposition and the remaining name move land together; the slice value grain no longer gates it." as NonEmptyStr, ) diff --git a/dag/test/claim/systemd_unit_file_witness_test.dag b/dag/test/claim/systemd_unit_file_witness_test.dag index d938f41ee0a..7f992304f1e 100644 --- a/dag/test/claim/systemd_unit_file_witness_test.dag +++ b/dag/test/claim/systemd_unit_file_witness_test.dag @@ -1,6 +1,7 @@ module test.claim.systemd_unit_file_witness_test import std.types { Bool, String, NonEmptyStr } +import std.measure { ByteSize, byte_size } import extdeps.systemd.unit_file { SystemdUnitFile, ServiceUnitFile, @@ -43,8 +44,15 @@ import extdeps.systemd.unit_file { ServiceSlice, SliceUnitFile, SystemdSliceDirective, - SliceMemoryMax, - SliceMemoryHigh, + slice_memory_max, + slice_memory_high, + slice_memory_swap_max, + slice_tasks_max, + slice_cpu_weight, + slice_memory_max_unlimited, + slice_memory_high_unlimited, + slice_memory_swap_max_unlimited, + slice_tasks_max_unlimited, NotInstallable, systemd_slice_directive_line, systemd_unit_file_lines, @@ -303,11 +311,64 @@ test fn the_slice_directive_renders_in_the_service_section() -> Bool { == "Slice=p.slice" } -test fn each_slice_directive_renders_its_own_systemd_name() -> Bool { - systemd_slice_directive_line(directive: SliceMemoryMax { bytes: "17179869184" as NonEmptyStr }) +// THE FIVE KNOBS THE CELL BOUNDARY DECLARES, EACH SPELLED EXACTLY AS systemd SPELLS IT. +// +// This is the discriminating half of the grounding claim rather than a restatement of it. The knob +// name now comes from `systemd_unit_property_wire` through the mint's chosen property, so a mint +// wired to the wrong member of `SystemdUnitProperty` -- `slice_memory_high` reaching MemoryMax, say +// -- compiles clean, renders a plausible line, and silently writes the wrong ceiling onto a host. +// Nothing but this comparison against the literal upstream spelling catches that. +test fn each_slice_knob_renders_its_own_systemd_name() -> Bool { + let sixteen_gib = byte_size(count: 17179869184) + systemd_slice_directive_line(directive: slice_memory_max(bytes: sixteen_gib)) == "MemoryMax=17179869184" - && systemd_slice_directive_line(directive: SliceMemoryHigh { bytes: "17179869184" as NonEmptyStr }) + && systemd_slice_directive_line(directive: slice_memory_high(bytes: sixteen_gib)) == "MemoryHigh=17179869184" + && systemd_slice_directive_line(directive: slice_memory_swap_max(bytes: byte_size(count: 34359738368))) + == "MemorySwapMax=34359738368" + && systemd_slice_directive_line(directive: slice_tasks_max(count: 16384)) + == "TasksMax=16384" + && systemd_slice_directive_line(directive: slice_cpu_weight(weight: 100)) + == "CPUWeight=100" +} + +// THE FIVE LINES ARE PAIRWISE DISTINCT, which is what a shared renderer over one property field can +// get wrong in the direction the assertion above cannot see on its own. A `systemd_slice_directive_line` +// that ignored `directive.property` and emitted one name would fail the test above at the second +// conjunct -- but a mint set where two knobs collapsed onto ONE property would pass a per-line check +// written less carefully than the one above. Asserting distinctness states the property directly: +// five mints, five spellings, no two the same. +test fn the_five_slice_knobs_render_five_distinct_names() -> Bool { + let lines = [ + systemd_slice_directive_line(directive: slice_memory_max(bytes: byte_size(count: 1))), + systemd_slice_directive_line(directive: slice_memory_high(bytes: byte_size(count: 1))), + systemd_slice_directive_line(directive: slice_memory_swap_max(bytes: byte_size(count: 1))), + systemd_slice_directive_line(directive: slice_tasks_max(count: 1)), + systemd_slice_directive_line(directive: slice_cpu_weight(weight: 1)), + ] + (filter(lines, a => (filter(lines, b => a == b) |> count) == 1) |> count) == 5 +} + +// THE UNLIMITED MINTS RENDER systemd's SENTINEL RATHER THAN A LARGE NUMBER, and the asymmetry is +// asserted alongside: there is no `slice_cpu_weight_unlimited`, because systemd defines no unlimited +// CPUWeight. That absence is not testable by constructing the bad value -- it has no constructor -- +// so what is asserted is the observable half, that the four knobs which DO admit the sentinel each +// render it under their own name. +test fn the_unlimited_mints_render_systemds_sentinel() -> Bool { + systemd_slice_directive_line(directive: slice_memory_max_unlimited()) == "MemoryMax=infinity" + && systemd_slice_directive_line(directive: slice_memory_high_unlimited()) == "MemoryHigh=infinity" + && systemd_slice_directive_line(directive: slice_memory_swap_max_unlimited()) + == "MemorySwapMax=infinity" + && systemd_slice_directive_line(directive: slice_tasks_max_unlimited()) == "TasksMax=infinity" +} + +// A BYTE COUNT AND A SENTINEL ARE DIFFERENT LINES FOR THE SAME KNOB. The RED this discriminates is a +// value renderer that dropped its `DirectiveUnlimited` arm into the numeric one, or a mint that +// filled `DirectiveByteCount { bytes: byte_size(count: 0) }` where the sentinel belonged -- both of +// which write a ZERO ceiling onto a host, which is not "no limit" but the strictest limit there is. +test fn an_unlimited_ceiling_is_not_a_zero_ceiling() -> Bool { + systemd_slice_directive_line(directive: slice_memory_max_unlimited()) + != systemd_slice_directive_line(directive: slice_memory_max(bytes: byte_size(count: 0))) } fn slice_sample() -> SystemdUnitFile { @@ -317,8 +378,8 @@ fn slice_sample() -> SystemdUnitFile { Before { target: "slices.target" as NonEmptyStr }, ], slice: [ - SliceMemoryMax { bytes: "17179869184" as NonEmptyStr }, - SliceMemoryHigh { bytes: "17179869184" as NonEmptyStr }, + slice_memory_max(bytes: byte_size(count: 17179869184)), + slice_memory_high(bytes: byte_size(count: 17179869184)), ], install: NotInstallable, }