diff --git a/ProcessManager.App/CommandLine.cs b/ProcessManager.App/CommandLine.cs index 729c615..1d35087 100644 --- a/ProcessManager.App/CommandLine.cs +++ b/ProcessManager.App/CommandLine.cs @@ -16,7 +16,7 @@ internal enum RunMode : byte { Desktop, Terminal, List, Find, Kill, SelfTest, He internal sealed record CommandLineOptions { public RunMode Mode { get; init; } = RunMode.Desktop; - public ProcessColumn SortColumn { get; init; } = ProcessColumn.CpuPercent; + public ProcessField SortColumn { get; init; } = ProcessField.CpuPercent; public bool SortDescending { get; init; } = true; public bool TreeMode { get; init; } @@ -118,7 +118,7 @@ public static CommandLineOptions Parse(string[] args) { case "--sort": { if (!TryValue(args, ref i, inlineValue, out var column)) return options with { Error = "--sort needs a column" }; - if (!ProcessColumnExtensions.TryParse(column, out var parsed)) + if (!FieldRegistry.TryParse(column, out var parsed)) return options with { Error = $"unknown sort column '{column}'" }; options = options with { SortColumn = parsed, SortDescending = parsed.PrefersDescending() }; diff --git a/ProcessManager.Benchmarks/Program.cs b/ProcessManager.Benchmarks/Program.cs index 7d676a0..90bad2a 100644 --- a/ProcessManager.Benchmarks/Program.cs +++ b/ProcessManager.Benchmarks/Program.cs @@ -128,7 +128,7 @@ private static int Main(string[] args) { ); // The view is rebuilt once per sample by both front-ends, so its cost is part of the frame. - var view = new ProcessView { TreeMode = true, SortColumn = ProcessColumn.CpuPercent }; + var view = new ProcessView { TreeMode = true, SortColumn = ProcessField.CpuPercent }; view.Rebuild(sampler.Current, sampler.Delta); var viewStart = Stopwatch.GetTimestamp(); for (var i = 0; i < 50; ++i) diff --git a/ProcessManager.Core/Query/FieldAccessor.cs b/ProcessManager.Core/Query/FieldAccessor.cs new file mode 100644 index 0000000..56430ff --- /dev/null +++ b/ProcessManager.Core/Query/FieldAccessor.cs @@ -0,0 +1,208 @@ +using System.Globalization; +using Hawkynt.ProcessManager.Model; +using Hawkynt.ProcessManager.Sampling; + +namespace Hawkynt.ProcessManager.Query; + +/// +/// Reads one field out of a process: as text to display, as a number to compare, and as an ordering. +/// +/// +/// The single place any field is turned into anything. Both front-ends render through +/// , the view sorts through , and the filter compares through +/// — so a value reads the same in the window and in the terminal, and sorting by +/// a column can never disagree with what that column shows (PRD §5.1). +/// +public static class FieldAccessor { + + /// + /// What the field shows, including the reason when it shows no value (PRD §72.3). + /// + /// + /// May be before a second sample exists, in which case every derived field + /// reads as "not sampled yet" rather than as zero. + /// + public static string Text(ProcessField field, in ProcessRecord process, SnapshotDelta? delta, int index) { + switch (field) { + case ProcessField.Name: return process.Name; + case ProcessField.Pid: return process.Pid.ToString(CultureInfo.InvariantCulture); + case ProcessField.PidHex: return "0x" + process.Pid.ToString("X", CultureInfo.InvariantCulture); + case ProcessField.ParentPid: + return process.ParentPid > 0 ? process.ParentPid.ToString(CultureInfo.InvariantCulture) : "—"; + case ProcessField.UserName: + return process.UserName ?? Humanize.Placeholder(UnknownReason.NotPermitted); + case ProcessField.State: return Humanize.State(process.State); + + case ProcessField.CpuPercent: return Humanize.Percent(Rated(delta, index, field)); + case ProcessField.CpuPercentPerCore: return Humanize.Percent(Rated(delta, index, field)); + case ProcessField.CpuTime: return Humanize.Duration(process.CpuTimeNs); + case ProcessField.CyclesDelta: return Humanize.Rate(Rated(delta, index, field)); + case ProcessField.ContextSwitchesDelta: return Humanize.Rate(Rated(delta, index, field)); + case ProcessField.PageFaultsDelta: return Humanize.Rate(Rated(delta, index, field)); + + case ProcessField.PrivateBytes: return Humanize.Bytes(process.PrivateBytes); + case ProcessField.PrivateBytesDelta: return Humanize.SignedBytesPerSecond(Rated(delta, index, field)); + case ProcessField.PrivateWorkingSet: return Humanize.Bytes(process.PrivateWorkingSetBytes); + case ProcessField.WorkingSetBytes: return Humanize.Bytes(process.WorkingSetBytes); + case ProcessField.PeakWorkingSet: return Humanize.Bytes(process.PeakWorkingSetBytes); + case ProcessField.VirtualBytes: return Humanize.Bytes(process.VirtualBytes); + case ProcessField.PeakVirtualBytes: return Humanize.Bytes(process.PeakVirtualBytes); + case ProcessField.PagedPool: return Humanize.Bytes(process.PagedPoolBytes); + case ProcessField.PeakPagedPool: return Humanize.Bytes(process.PeakPagedPoolBytes); + case ProcessField.NonPagedPool: return Humanize.Bytes(process.NonPagedPoolBytes); + case ProcessField.PeakNonPagedPool: return Humanize.Bytes(process.PeakNonPagedPoolBytes); + case ProcessField.Swap: return Humanize.Bytes(process.SwapBytes); + + case ProcessField.IoTotalRate: + case ProcessField.ReadBytesPerSecond: + case ProcessField.WriteBytesPerSecond: + return Humanize.BytesPerSecond(Rated(delta, index, field)); + + case ProcessField.ThreadCount: return process.ThreadCount.ToString(CultureInfo.InvariantCulture); + case ProcessField.HandleCount: return Humanize.Count(process.HandleCount); + case ProcessField.Priority: return process.Priority.ToString(CultureInfo.InvariantCulture); + case ProcessField.SessionId: + return process.SessionId >= 0 ? process.SessionId.ToString(CultureInfo.InvariantCulture) : "—"; + case ProcessField.StartTime: + return process.StartTimeUtcTicks > 0 + ? new DateTime(process.StartTimeUtcTicks, DateTimeKind.Utc).ToLocalTime() + .ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + : "—"; + case ProcessField.Container: return process.ContainerPath ?? "—"; + case ProcessField.ImagePath: return process.ImagePath ?? "—"; + case ProcessField.CommandLine: return process.CommandLine ?? string.Empty; + + // The graphs are drawn, not written. Asking for their text is a caller bug, but returning + // empty is friendlier than throwing in a render loop. + case ProcessField.CpuHistory: + case ProcessField.MemoryHistory: + case ProcessField.IoHistory: + default: + return string.Empty; + } + } + + /// + /// The field as a plain number, for filtering and for sorting. + /// + /// + /// when the field has no number at all — either because it is text, or + /// because this platform does not report it. A filter must treat those two the same way: a process + /// whose value is unknown does not match > 0, and it does not match == 0 either. + /// + public static double? Number(ProcessField field, in ProcessRecord process, SnapshotDelta? delta, int index) { + switch (field) { + case ProcessField.Pid: + case ProcessField.PidHex: return process.Pid; + case ProcessField.ParentPid: return process.ParentPid; + case ProcessField.State: return (byte)process.State; + case ProcessField.ThreadCount: return process.ThreadCount; + case ProcessField.Priority: return process.Priority; + case ProcessField.SessionId: return process.SessionId; + case ProcessField.StartTime: return process.StartTimeUtcTicks; + + case ProcessField.CpuTime: return Number(process.CpuTimeNs); + case ProcessField.PrivateBytes: return Number(process.PrivateBytes); + case ProcessField.PrivateWorkingSet: return Number(process.PrivateWorkingSetBytes); + case ProcessField.WorkingSetBytes: return Number(process.WorkingSetBytes); + case ProcessField.PeakWorkingSet: return Number(process.PeakWorkingSetBytes); + case ProcessField.VirtualBytes: return Number(process.VirtualBytes); + case ProcessField.PeakVirtualBytes: return Number(process.PeakVirtualBytes); + case ProcessField.PagedPool: return Number(process.PagedPoolBytes); + case ProcessField.PeakPagedPool: return Number(process.PeakPagedPoolBytes); + case ProcessField.NonPagedPool: return Number(process.NonPagedPoolBytes); + case ProcessField.PeakNonPagedPool: return Number(process.PeakNonPagedPoolBytes); + case ProcessField.Swap: return Number(process.SwapBytes); + case ProcessField.HandleCount: return Number(process.HandleCount); + + case ProcessField.CpuPercent: + case ProcessField.CpuPercentPerCore: + case ProcessField.CyclesDelta: + case ProcessField.ContextSwitchesDelta: + case ProcessField.PageFaultsDelta: + case ProcessField.PrivateBytesDelta: + case ProcessField.IoTotalRate: + case ProcessField.ReadBytesPerSecond: + case ProcessField.WriteBytesPerSecond: { + var rate = Rated(delta, index, field); + return rate.HasValue ? rate.Value : null; + } + + default: return null; + } + } + + /// The field as raw text, for substring and regular-expression filtering. + /// + /// Deliberately not : a filter must match what the value is, not how it + /// was abbreviated for a column. Searching for a path should not fail because the column showed + /// an em dash, and searching "1024" should not match a cell that reads "1.0K". + /// + public static string? RawText(ProcessField field, in ProcessRecord process) => field switch { + ProcessField.Name => process.Name, + ProcessField.UserName => process.UserName, + ProcessField.ImagePath => process.ImagePath, + ProcessField.CommandLine => process.CommandLine, + ProcessField.Container => process.ContainerPath, + ProcessField.State => Humanize.State(process.State), + ProcessField.Pid => process.Pid.ToString(CultureInfo.InvariantCulture), + ProcessField.ParentPid => process.ParentPid.ToString(CultureInfo.InvariantCulture), + _ => null, + }; + + /// + /// Orders two rows by one field. Text compares case-insensitively; numbers compare numerically; + /// a value that is unknown sorts below every known one, whichever direction is chosen. + /// + public static int Compare( + ProcessField field, + in ProcessRecord a, + int indexA, + in ProcessRecord b, + int indexB, + SnapshotDelta? delta + ) { + switch (field) { + case ProcessField.Name: + return string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase); + case ProcessField.UserName: + return string.Compare(a.UserName, b.UserName, StringComparison.OrdinalIgnoreCase); + case ProcessField.CommandLine: + return string.Compare(a.CommandLine, b.CommandLine, StringComparison.OrdinalIgnoreCase); + case ProcessField.ImagePath: + return string.Compare(a.ImagePath, b.ImagePath, StringComparison.OrdinalIgnoreCase); + case ProcessField.Container: + return string.Compare(a.ContainerPath, b.ContainerPath, StringComparison.OrdinalIgnoreCase); + } + + var left = Number(field, in a, delta, indexA); + var right = Number(field, in b, delta, indexB); + if (left is null) + return right is null ? 0 : -1; + if (right is null) + return 1; + + return left.Value.CompareTo(right.Value); + } + + private static double? Number(Counter counter) => counter.HasValue ? counter.Value : null; + + private static Rate Rated(SnapshotDelta? delta, int index, ProcessField field) { + if (delta is null) + return Rate.NotSampledYet; + + return field switch { + ProcessField.CpuPercent => delta.CpuPercent(index), + ProcessField.CpuPercentPerCore => delta.CpuPercentPerCore(index), + ProcessField.CyclesDelta => delta.CyclesPerSecond(index), + ProcessField.ContextSwitchesDelta => delta.ContextSwitchesPerSecond(index), + ProcessField.PageFaultsDelta => delta.PageFaultsPerSecond(index), + ProcessField.PrivateBytesDelta => delta.PrivateBytesDelta(index), + ProcessField.IoTotalRate => delta.IoTotalBytesPerSecond(index), + ProcessField.ReadBytesPerSecond => delta.ReadBytesPerSecond(index), + ProcessField.WriteBytesPerSecond => delta.WriteBytesPerSecond(index), + _ => Rate.NotSampledYet, + }; + } + +} diff --git a/ProcessManager.Core/Query/FieldRegistry.cs b/ProcessManager.Core/Query/FieldRegistry.cs new file mode 100644 index 0000000..ee2809b --- /dev/null +++ b/ProcessManager.Core/Query/FieldRegistry.cs @@ -0,0 +1,236 @@ +using Hawkynt.ProcessManager.Sampling; + +namespace Hawkynt.ProcessManager.Query; + +/// +/// The canonical field catalogue: the one place a field is declared, and the thing both front-ends, +/// the CLI and the filter are built from (PRD §5.1). +/// +/// +/// A static array rather than anything reflective, so it survives trimming and NativeAOT intact +/// (PRD §8.3). Adding a field here gives it a header, a width, a sort order, a formatter and a filter +/// term in every front-end at once — which is the whole reason it exists. +/// +public static class FieldRegistry { + + private const FieldPlatforms _WINDOWS = FieldPlatforms.Windows; + private const FieldPlatforms _LINUX = FieldPlatforms.Linux; + private const FieldPlatforms _POSIX = FieldPlatforms.Linux | FieldPlatforms.MacOS; + private const FieldPlatforms _ALL = FieldPlatforms.All; + + /// Every field, in default column order. + public static readonly FieldDescriptor[] All = [ + new(ProcessField.Name, "name", "Process", "Process", + "The short name: comm on Linux, the image file name on Windows.", + FieldKind.Text, FieldUnit.None, _ALL, FieldCost.Free, 260, 120, false, false, + Aliases: "process comm"), + new(ProcessField.Pid, "pid", "PID", "PID", + "The process identifier.", + FieldKind.Identifier, FieldUnit.None, _ALL, FieldCost.Free, 70, 7, true, false), + new(ProcessField.PidHex, "pid.hex", "PID (hex)", "PIDx", + "The same identifier in hexadecimal, which is how a debugger will show it.", + FieldKind.Identifier, FieldUnit.None, _ALL, FieldCost.Free, 78, 9, true, false), + new(ProcessField.ParentPid, "ppid", "Parent PID", "PPID", + "The parent's identifier, or none when the parent has exited.", + FieldKind.Identifier, FieldUnit.None, _ALL, FieldCost.Free, 88, 7, true, false, + Aliases: "parent"), + new(ProcessField.UserName, "user", "User", "User", + "The account the process runs as.", + FieldKind.Text, FieldUnit.None, _ALL, FieldCost.Free, 130, 10, false, false, + Aliases: "owner username"), + new(ProcessField.State, "state", "State", "S", + "What the scheduler thinks of the process right now.", + FieldKind.State, FieldUnit.None, _ALL, FieldCost.Free, 62, 5, false, false, + Aliases: "status"), + + new(ProcessField.CpuPercent, "cpu", "CPU %", "CPU%", + "Processor use where 100% is the whole machine.", + FieldKind.Rate, FieldUnit.Percent, _ALL, FieldCost.Derived, 78, 5, true, true, + Aliases: "cpu.percent"), + new(ProcessField.CpuPercentPerCore, "cpu.raw", "CPU % (per core)", "CPU%c", + "Processor use where 100% is one core, the way top reports it.", + FieldKind.Rate, FieldUnit.Percent, _ALL, FieldCost.Derived, 118, 6, true, true, + Aliases: "cpu.percore"), + new(ProcessField.CpuTime, "cpu.time", "CPU time", "Time", + "Total processor time consumed since the process started.", + FieldKind.Cumulative, FieldUnit.Nanoseconds, _ALL, FieldCost.Free, 88, 9, true, true), + new(ProcessField.CyclesDelta, "cpu.cycles.delta", "Cycles delta", "Cyc/s", + "Processor cycles this interval. Unlike CPU time it does not flatter a process that ran while the clock was throttled.", + FieldKind.Rate, FieldUnit.CountPerSecond, _WINDOWS, FieldCost.Derived, 100, 8, true, true), + new(ProcessField.ContextSwitchesDelta, "ctx.delta", "Ctx switch delta", "Ctx/s", + "Context switches this interval.", + FieldKind.Rate, FieldUnit.CountPerSecond, _POSIX, FieldCost.Derived, 116, 8, true, true), + new(ProcessField.CpuHistory, "cpu.history", "CPU history", "CPU hist", + "The last sixty seconds of processor use.", + FieldKind.Graph, FieldUnit.Percent, _ALL, FieldCost.Derived, 90, 12, false, false, + HistorySeries.Cpu), + + new(ProcessField.PrivateBytes, "private", "Private bytes", "Private", + "Private memory the process has committed — what it would give back if it exited.", + FieldKind.Instant, FieldUnit.Bytes, _ALL, FieldCost.Free, 96, 7, true, true, + Aliases: "mem memory commit"), + new(ProcessField.PrivateBytesDelta, "private.delta", "Private delta", "Priv/s", + "How fast committed private memory is moving. A process whose private bytes only climb is the one leaking.", + FieldKind.Rate, FieldUnit.BytesPerSecond, _ALL, FieldCost.Derived, 100, 9, true, true), + new(ProcessField.PrivateWorkingSet, "private.ws", "Private WS", "PrivWS", + "The resident part of the committed private memory.", + FieldKind.Instant, FieldUnit.Bytes, _ALL, FieldCost.Free, 88, 7, true, true, + Aliases: "uss"), + new(ProcessField.MemoryHistory, "memory.history", "Memory history", "Mem hist", + "The last sixty seconds of committed private memory.", + FieldKind.Graph, FieldUnit.Bytes, _ALL, FieldCost.Derived, 90, 12, false, false, + HistorySeries.Memory), + new(ProcessField.WorkingSetBytes, "ws", "Working set", "RSS", + "Resident memory including every shared page, which is why it double-counts.", + FieldKind.Instant, FieldUnit.Bytes, _ALL, FieldCost.Free, 92, 7, true, true, + Aliases: "rss workingset resident"), + new(ProcessField.PeakWorkingSet, "ws.peak", "Peak WS", "PkRSS", + "The largest working set this process has ever held.", + FieldKind.Instant, FieldUnit.Bytes, _ALL, FieldCost.Free, 84, 7, true, true), + new(ProcessField.VirtualBytes, "virtual", "Virtual size", "Virt", + "Size of the mapped address space, most of which is usually not resident.", + FieldKind.Instant, FieldUnit.Bytes, _ALL, FieldCost.Free, 92, 7, true, true, + Aliases: "virt vsize"), + new(ProcessField.PeakVirtualBytes, "virtual.peak", "Peak virtual", "PkVirt", + "The largest address space this process has ever mapped.", + FieldKind.Instant, FieldUnit.Bytes, _ALL, FieldCost.Free, 96, 7, true, true), + new(ProcessField.PagedPool, "pool.paged", "Paged pool", "PgPool", + "Kernel memory charged to this process from the paged pool.", + FieldKind.Instant, FieldUnit.Bytes, _WINDOWS, FieldCost.Free, 90, 7, true, true), + new(ProcessField.PeakPagedPool, "pool.paged.peak", "Peak paged pool", "PkPgPool", + "The largest paged-pool charge this process has held.", + FieldKind.Instant, FieldUnit.Bytes, _WINDOWS, FieldCost.Free, 116, 8, true, true), + new(ProcessField.NonPagedPool, "pool.nonpaged", "Non-paged pool", "NpPool", + "Kernel memory charged to this process from the non-paged pool.", + FieldKind.Instant, FieldUnit.Bytes, _WINDOWS, FieldCost.Free, 110, 7, true, true), + new(ProcessField.PeakNonPagedPool, "pool.nonpaged.peak", "Peak non-paged", "PkNpPool", + "The largest non-paged-pool charge this process has held.", + FieldKind.Instant, FieldUnit.Bytes, _WINDOWS, FieldCost.Free, 112, 8, true, true), + new(ProcessField.PageFaultsDelta, "faults.delta", "Page fault delta", "Flt/s", + "Page faults this interval. A process faulting steadily is one the machine is paging for.", + FieldKind.Rate, FieldUnit.CountPerSecond, _ALL, FieldCost.Derived, 116, 8, true, true), + new(ProcessField.Swap, "swap", "Swap", "Swap", + "How much of this process the machine has pushed out to swap.", + FieldKind.Instant, FieldUnit.Bytes, _ALL, FieldCost.Free, 78, 7, true, true), + + new(ProcessField.IoTotalRate, "io.total", "I/O total rate", "IO/s", + "Bytes read, written and neither, per second.", + FieldKind.Rate, FieldUnit.BytesPerSecond, _ALL, FieldCost.Derived, 104, 8, true, true, + Aliases: "io"), + new(ProcessField.ReadBytesPerSecond, "io.read", "I/O read rate", "Read/s", + "Bytes this process caused to be read, per second.", + FieldKind.Rate, FieldUnit.BytesPerSecond, _ALL, FieldCost.Derived, 100, 8, true, true, + Aliases: "read"), + new(ProcessField.WriteBytesPerSecond, "io.write", "I/O write rate", "Write/s", + "Bytes this process caused to be written, per second.", + FieldKind.Rate, FieldUnit.BytesPerSecond, _ALL, FieldCost.Derived, 104, 8, true, true, + Aliases: "write"), + new(ProcessField.IoHistory, "io.history", "I/O history", "I/O hist", + "The last sixty seconds of read and write traffic.", + FieldKind.Graph, FieldUnit.BytesPerSecond, _ALL, FieldCost.Derived, 90, 12, false, false, + HistorySeries.Io), + + new(ProcessField.ThreadCount, "threads", "Threads", "Thr", + "How many threads the process currently has.", + FieldKind.Instant, FieldUnit.Count, _ALL, FieldCost.Free, 64, 4, true, true), + new(ProcessField.HandleCount, "handles", "Handles", "Hnd", + "Open handles on Windows, open file descriptors on Unix.", + FieldKind.Instant, FieldUnit.Count, _ALL, FieldCost.High, 66, 5, true, true, + Aliases: "fds fd"), + new(ProcessField.Priority, "priority", "Priority", "Pri", + "Scheduler priority in the platform's own scale.", + FieldKind.Instant, FieldUnit.Count, _ALL, FieldCost.Free, 74, 4, true, true, + Aliases: "prio"), + new(ProcessField.SessionId, "session", "Session", "Ses", + "The login or terminal session the process belongs to.", + FieldKind.Identifier, FieldUnit.None, _ALL, FieldCost.Free, 74, 5, true, false), + new(ProcessField.StartTime, "start", "Start time", "Started", + "When the process was created.", + FieldKind.Instant, FieldUnit.Timestamp, _ALL, FieldCost.Free, 140, 19, false, true, + Aliases: "started starttime"), + new(ProcessField.Container, "cgroup", "Container / cgroup", "Cgroup", + "The cgroup or container the process belongs to.", + FieldKind.Text, FieldUnit.None, _LINUX, FieldCost.Free, 240, 40, false, false, + Aliases: "container"), + new(ProcessField.ImagePath, "path", "Image path", "Path", + "Full path of the executable image.", + FieldKind.Text, FieldUnit.None, _ALL, FieldCost.Free, 320, 60, false, false, + Aliases: "image exe"), + new(ProcessField.CommandLine, "cmdline", "Command line", "Command", + "The complete command the process was started with.", + FieldKind.Text, FieldUnit.None, _ALL, FieldCost.Free, 420, 120, false, false, + Aliases: "cmd commandline"), + ]; + + private static readonly FieldDescriptor[] _byId = BuildIndex(); + + private static FieldDescriptor[] BuildIndex() { + var highest = 0; + foreach (var descriptor in All) + highest = Math.Max(highest, (int)descriptor.Id); + + var index = new FieldDescriptor[highest + 1]; + foreach (var descriptor in All) + index[(int)descriptor.Id] = descriptor; + + return index; + } + + /// Everything known about one field. + public static FieldDescriptor Get(ProcessField field) { + var index = (int)field; + return (uint)index < (uint)_byId.Length && _byId[index] is { } descriptor ? descriptor : _byId[0]; + } + + public static string Header(this ProcessField field) => Get(field).Header; + + public static string ShortHeader(this ProcessField field) => Get(field).ShortHeader; + + public static string Key(this ProcessField field) => Get(field).Key; + + public static bool PrefersDescending(this ProcessField field) => Get(field).PrefersDescending; + + /// + /// Resolves a field from text: its key, one of its aliases, or its header, case-insensitively. + /// + /// + /// This is what --sort, a saved layout and a search term all go through, so all three accept + /// the same spellings and none of them can drift from the others. + /// + public static bool TryParse(string? text, out ProcessField field) { + field = ProcessField.CpuPercent; + if (string.IsNullOrWhiteSpace(text)) + return false; + + var wanted = text.Trim(); + foreach (var descriptor in All) { + if (string.Equals(descriptor.Key, wanted, StringComparison.OrdinalIgnoreCase) + || string.Equals(descriptor.Header, wanted, StringComparison.OrdinalIgnoreCase)) { + field = descriptor.Id; + return true; + } + + if (descriptor.Aliases is not { } aliases) + continue; + + foreach (var alias in aliases.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + if (string.Equals(alias, wanted, StringComparison.OrdinalIgnoreCase)) { + field = descriptor.Id; + return true; + } + } + + return false; + } + + /// Every spelling accepts, for the help text. + public static string SortableKeys() { + var keys = new List(); + foreach (var descriptor in All) + if (descriptor.IsSortable) + keys.Add(descriptor.Key); + + return string.Join(", ", keys); + } + +} diff --git a/ProcessManager.Core/Query/ProcessColumn.cs b/ProcessManager.Core/Query/ProcessColumn.cs deleted file mode 100644 index ef4e2ce..0000000 --- a/ProcessManager.Core/Query/ProcessColumn.cs +++ /dev/null @@ -1,88 +0,0 @@ -namespace Hawkynt.ProcessManager.Query; - -/// -/// Everything a row can be sorted by. The order here is the default column order in both front-ends, -/// so it is the Process-Explorer order rather than alphabetical. -/// -public enum ProcessColumn : byte { - Name = 0, - Pid, - ParentPid, - UserName, - State, - CpuPercent, - PrivateBytes, - WorkingSetBytes, - VirtualBytes, - ReadBytesPerSecond, - WriteBytesPerSecond, - HandleCount, - ThreadCount, - StartTime, - Priority, - SessionId, - CommandLine, -} - -public static class ProcessColumnExtensions { - - /// The header text, and what a `--sort=` argument accepts (case-insensitively). - public static string ToHeader(this ProcessColumn column) => column switch { - ProcessColumn.Name => "Process", - ProcessColumn.Pid => "PID", - ProcessColumn.ParentPid => "PPID", - ProcessColumn.UserName => "User", - ProcessColumn.State => "State", - ProcessColumn.CpuPercent => "CPU", - ProcessColumn.PrivateBytes => "Private", - ProcessColumn.WorkingSetBytes => "Working set", - ProcessColumn.VirtualBytes => "Virtual", - ProcessColumn.ReadBytesPerSecond => "Read/s", - ProcessColumn.WriteBytesPerSecond => "Write/s", - ProcessColumn.HandleCount => "Handles", - ProcessColumn.ThreadCount => "Threads", - ProcessColumn.StartTime => "Started", - ProcessColumn.Priority => "Priority", - ProcessColumn.SessionId => "Session", - ProcessColumn.CommandLine => "Command line", - _ => column.ToString(), - }; - - /// - /// Whether bigger should come first when the column is picked. Sorting by CPU ascending is not - /// what anybody wants from one keypress, and sorting names descending is not either. - /// - public static bool PrefersDescending(this ProcessColumn column) => column switch { - ProcessColumn.Name or ProcessColumn.UserName or ProcessColumn.CommandLine - or ProcessColumn.State or ProcessColumn.Pid or ProcessColumn.ParentPid => false, - _ => true, - }; - - public static bool TryParse(string? text, out ProcessColumn column) { - column = ProcessColumn.CpuPercent; - if (string.IsNullOrWhiteSpace(text)) - return false; - - switch (text.Trim().ToLowerInvariant()) { - case "name" or "process" or "comm": column = ProcessColumn.Name; return true; - case "pid": column = ProcessColumn.Pid; return true; - case "ppid" or "parent": column = ProcessColumn.ParentPid; return true; - case "user" or "owner": column = ProcessColumn.UserName; return true; - case "state" or "status": column = ProcessColumn.State; return true; - case "cpu": column = ProcessColumn.CpuPercent; return true; - case "mem" or "memory" or "private" or "pss": column = ProcessColumn.PrivateBytes; return true; - case "rss" or "ws" or "workingset": column = ProcessColumn.WorkingSetBytes; return true; - case "virt" or "virtual": column = ProcessColumn.VirtualBytes; return true; - case "read": column = ProcessColumn.ReadBytesPerSecond; return true; - case "write": column = ProcessColumn.WriteBytesPerSecond; return true; - case "handles" or "fds": column = ProcessColumn.HandleCount; return true; - case "threads": column = ProcessColumn.ThreadCount; return true; - case "start" or "started": column = ProcessColumn.StartTime; return true; - case "prio" or "priority": column = ProcessColumn.Priority; return true; - case "session": column = ProcessColumn.SessionId; return true; - case "cmd" or "cmdline" or "commandline": column = ProcessColumn.CommandLine; return true; - default: return false; - } - } - -} diff --git a/ProcessManager.Core/Query/ProcessField.cs b/ProcessManager.Core/Query/ProcessField.cs new file mode 100644 index 0000000..e4969e3 --- /dev/null +++ b/ProcessManager.Core/Query/ProcessField.cs @@ -0,0 +1,190 @@ +using Hawkynt.ProcessManager.Sampling; + +namespace Hawkynt.ProcessManager.Query; + +/// +/// Every value a process row can show, sort by or be filtered on. +/// +/// +/// One enum for the whole program, deliberately. There used to be three lists — a sort-key enum here, +/// a column set in the window and a third in the terminal — which meant adding a field meant editing +/// three places, and three places is three places to forget one. PRD §5.1 and §103. +/// +/// The order is the default column order, which is Process Explorer's rather than alphabetical. +/// +/// +public enum ProcessField : byte { + + Name = 0, + Pid, + PidHex, + ParentPid, + UserName, + State, + + CpuPercent, + CpuPercentPerCore, + CpuTime, + CyclesDelta, + ContextSwitchesDelta, + CpuHistory, + + PrivateBytes, + PrivateBytesDelta, + PrivateWorkingSet, + MemoryHistory, + WorkingSetBytes, + PeakWorkingSet, + VirtualBytes, + PeakVirtualBytes, + PagedPool, + PeakPagedPool, + NonPagedPool, + PeakNonPagedPool, + PageFaultsDelta, + Swap, + + IoTotalRate, + ReadBytesPerSecond, + WriteBytesPerSecond, + IoHistory, + + ThreadCount, + HandleCount, + Priority, + SessionId, + StartTime, + Container, + ImagePath, + CommandLine, + +} + +/// +/// What kind of number a field is, which decides whether it may be averaged, summed, or graphed at +/// all (PRD §5.1). +/// +public enum FieldKind : byte { + + /// Free text — a name, a path, a command line. + Text, + + /// An identifier that happens to be numeric. Sorts numerically, never summed. + Identifier, + + /// A value that is true right now and has no history of its own. + Instant, + + /// Monotonic since the process started. The interesting figure is its derivative. + Cumulative, + + /// The change in a cumulative counter over one interval. + Delta, + + /// A per-second figure derived from two samples. + Rate, + + /// One of a fixed set of states. + State, + + /// A drawn history rather than a value; has no text and cannot be sorted. + Graph, + +} + +/// What the number counts, which decides how it is formatted and how a filter parses it. +public enum FieldUnit : byte { + None, + Bytes, + BytesPerSecond, + Percent, + Nanoseconds, + Count, + CountPerSecond, + Timestamp, +} + +/// +/// What reading the field costs, so an expensive one is never made default-visible by accident +/// (PRD §5.4). +/// +public enum FieldCost : byte { + + /// Already in the snapshot; showing it costs nothing at all. + Free, + + /// Needs a second sample, and nothing else. + Derived, + + /// Costs a syscall or more per process. Never default-visible. + High, + +} + +/// Which platforms can fill a field. A platform not listed renders n/a, not zero. +[Flags] +public enum FieldPlatforms : byte { + None = 0, + Windows = 1, + Linux = 2, + MacOS = 4, + All = Windows | Linux | MacOS, +} + +/// +/// Everything the program knows about one field: how to label it, how wide to draw it, what it +/// means, and who can fill it. +/// +/// The enum value. +/// +/// The stable identifier. This is what a saved layout, a --sort argument and a search term all +/// use, and it never changes even when the header does — including when the header differs per +/// platform, which is the point (PRD §5.3). +/// +/// The full label, for the window. +/// The narrow label, for the terminal. +/// One sentence, for the tooltip and the column chooser. +/// Pixels. +/// Character cells. +/// +/// Whether biggest-first is what a single click should give. Sorting by CPU ascending is not what +/// anybody wants from one keypress, and sorting names descending is not either. +/// +public sealed record FieldDescriptor( + ProcessField Id, + string Key, + string Header, + string ShortHeader, + string Description, + FieldKind Kind, + FieldUnit Unit, + FieldPlatforms Platforms, + FieldCost Cost, + int DesktopWidth, + int TerminalWidth, + bool RightAligned, + bool PrefersDescending, + HistorySeries? Series = null, + string? Aliases = null +) { + + /// True for the three drawn histories, which have no text and no sort order. + public bool IsGraph => this.Kind == FieldKind.Graph; + + /// False for graphs, true for everything else. + public bool IsSortable => this.Kind != FieldKind.Graph; + + /// + /// Whether this field can hold a number on this machine at all — used to decide between showing a + /// value and showing why there is none. + /// + public bool IsSupportedHere => (this.Platforms & CurrentPlatform) != 0; + + /// Which flag this machine is. + public static FieldPlatforms CurrentPlatform { get; } = + OperatingSystem.IsWindows() ? FieldPlatforms.Windows + : OperatingSystem.IsLinux() ? FieldPlatforms.Linux + : OperatingSystem.IsMacOS() ? FieldPlatforms.MacOS + : FieldPlatforms.None; + +} diff --git a/ProcessManager.Core/Query/ProcessView.cs b/ProcessManager.Core/Query/ProcessView.cs index cb6b481..cab933d 100644 --- a/ProcessManager.Core/Query/ProcessView.cs +++ b/ProcessManager.Core/Query/ProcessView.cs @@ -31,7 +31,7 @@ public sealed class ProcessView { public ProcessView() => this._comparer = Comparer.Create(this.Compare); - public ProcessColumn SortColumn { get; set; } = ProcessColumn.CpuPercent; + public ProcessField SortColumn { get; set; } = ProcessField.CpuPercent; public bool SortDescending { get; set; } = true; @@ -249,48 +249,11 @@ private int Compare(int left, int right) { private int CompareAscending(int left, int right) { var processes = this._snapshot!.Processes; - ref readonly var a = ref processes[left]; - ref readonly var b = ref processes[right]; - - return this.SortColumn switch { - ProcessColumn.Name => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase), - ProcessColumn.Pid => a.Pid.CompareTo(b.Pid), - ProcessColumn.ParentPid => a.ParentPid.CompareTo(b.ParentPid), - ProcessColumn.UserName => string.Compare(a.UserName, b.UserName, StringComparison.OrdinalIgnoreCase), - ProcessColumn.State => ((byte)a.State).CompareTo((byte)b.State), - ProcessColumn.CpuPercent => CompareRate(this._delta!.CpuPercent(left), this._delta.CpuPercent(right)), - ProcessColumn.PrivateBytes => CompareCounter(a.PrivateBytes, b.PrivateBytes), - ProcessColumn.WorkingSetBytes => CompareCounter(a.WorkingSetBytes, b.WorkingSetBytes), - ProcessColumn.VirtualBytes => CompareCounter(a.VirtualBytes, b.VirtualBytes), - ProcessColumn.ReadBytesPerSecond - => CompareRate(this._delta!.ReadBytesPerSecond(left), this._delta.ReadBytesPerSecond(right)), - ProcessColumn.WriteBytesPerSecond - => CompareRate(this._delta!.WriteBytesPerSecond(left), this._delta.WriteBytesPerSecond(right)), - ProcessColumn.HandleCount => CompareCounter(a.HandleCount, b.HandleCount), - ProcessColumn.ThreadCount => a.ThreadCount.CompareTo(b.ThreadCount), - ProcessColumn.StartTime => a.StartTimeUtcTicks.CompareTo(b.StartTimeUtcTicks), - ProcessColumn.Priority => a.Priority.CompareTo(b.Priority), - ProcessColumn.SessionId => a.SessionId.CompareTo(b.SessionId), - ProcessColumn.CommandLine => string.Compare(a.CommandLine, b.CommandLine, StringComparison.OrdinalIgnoreCase), - _ => 0, - }; - } - // A value that is not there sorts below every value that is, in ascending order — so reversing the - // sort puts the readable rows on top either way, instead of a block of dashes. - private static int CompareCounter(Counter left, Counter right) => (left.HasValue, right.HasValue) switch { - (true, true) => left.Value.CompareTo(right.Value), - (true, false) => 1, - (false, true) => -1, - _ => 0, - }; - - private static int CompareRate(Rate left, Rate right) => (left.HasValue, right.HasValue) switch { - (true, true) => left.Value.CompareTo(right.Value), - (true, false) => 1, - (false, true) => -1, - _ => 0, - }; + // Every field, in one place, shared with both front-ends: sorting by a column and the text that + // column shows are now read out of the same accessor, so they cannot drift apart (PRD §5.1). + return FieldAccessor.Compare(this.SortColumn, in processes[left], left, in processes[right], right, this._delta); + } private static void EnsureLength(ref T[] array, int length) { if (array.Length < length) diff --git a/ProcessManager.Tests/FieldRegistryTests.cs b/ProcessManager.Tests/FieldRegistryTests.cs new file mode 100644 index 0000000..594cfa5 --- /dev/null +++ b/ProcessManager.Tests/FieldRegistryTests.cs @@ -0,0 +1,265 @@ +using Hawkynt.ProcessManager.Model; +using Hawkynt.ProcessManager.Query; +using Hawkynt.ProcessManager.Sampling; + +namespace Hawkynt.ProcessManager.Tests; + +/// +/// The field catalogue (PRD §5.1) and the rule that nothing may be added to a front-end without +/// going through it (PRD §103). +/// +/// +/// §103 said "a CI check enforces this" and nothing did, which made it a convention rather than a +/// rule. is that check: adding a value to +/// and forgetting the descriptor now fails the build rather than +/// producing a column with no header that sorts by nothing. +/// +[TestFixture] +public sealed class FieldRegistryTests { + + [Test] + public void EveryFieldInTheEnumIsRegistered() { + var missing = new List(); + foreach (ProcessField field in Enum.GetValues()) + if (FieldRegistry.Get(field).Id != field) + missing.Add(field); + + Assert.That(missing, Is.Empty, "these fields have no descriptor in FieldRegistry.All"); + } + + [Test] + public void EveryRegisteredFieldIsInTheEnum() { + foreach (var descriptor in FieldRegistry.All) + Assert.That(Enum.IsDefined(descriptor.Id), Is.True, $"{descriptor.Key} is not a ProcessField"); + } + + [Test] + public void KeysAreUniqueAndSoAreHeaders() { + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + var headers = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var descriptor in FieldRegistry.All) { + Assert.That(keys.Add(descriptor.Key), Is.True, $"duplicate key: {descriptor.Key}"); + Assert.That(headers.Add(descriptor.Header), Is.True, $"duplicate header: {descriptor.Header}"); + } + } + + /// + /// An alias that collides with another field's key would resolve to whichever came first in the + /// array, which is a sorting order nobody chose. + /// + [Test] + public void NoAliasCollidesWithAnotherFieldsKeyOrAlias() { + var seen = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var descriptor in FieldRegistry.All) { + Register(descriptor.Key, descriptor.Key); + if (descriptor.Aliases is not { } aliases) + continue; + + foreach (var alias in aliases.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + Register(alias, descriptor.Key); + } + + void Register(string spelling, string owner) { + Assert.That( + seen.TryAdd(spelling, owner), + Is.True, + $"'{spelling}' is claimed by both {owner} and {(seen.TryGetValue(spelling, out var other) ? other : "?")}" + ); + } + } + + [Test] + public void EveryKeyAndAliasParsesBackToItsOwnField() { + foreach (var descriptor in FieldRegistry.All) { + Assert.That(FieldRegistry.TryParse(descriptor.Key, out var byKey), Is.True, descriptor.Key); + Assert.That(byKey, Is.EqualTo(descriptor.Id)); + + Assert.That(FieldRegistry.TryParse(descriptor.Header, out var byHeader), Is.True, descriptor.Header); + Assert.That(byHeader, Is.EqualTo(descriptor.Id)); + + // Case and surrounding space must not matter: this is what a command line hands us. + Assert.That(FieldRegistry.TryParse($" {descriptor.Key.ToUpperInvariant()} ", out var loose), Is.True); + Assert.That(loose, Is.EqualTo(descriptor.Id)); + + if (descriptor.Aliases is not { } aliases) + continue; + + foreach (var alias in aliases.Split(' ', StringSplitOptions.RemoveEmptyEntries)) { + Assert.That(FieldRegistry.TryParse(alias, out var byAlias), Is.True, alias); + Assert.That(byAlias, Is.EqualTo(descriptor.Id), alias); + } + } + } + + [Test] + public void NonsenseDoesNotParse() { + Assert.That(FieldRegistry.TryParse("not-a-field", out _), Is.False); + Assert.That(FieldRegistry.TryParse("", out _), Is.False); + Assert.That(FieldRegistry.TryParse(null, out _), Is.False); + Assert.That(FieldRegistry.TryParse(" ", out _), Is.False); + } + + [Test] + public void GraphsAreNotSortableAndEverythingElseIs() { + foreach (var descriptor in FieldRegistry.All) + Assert.That( + descriptor.IsSortable, + Is.EqualTo(descriptor.Kind != FieldKind.Graph), + $"{descriptor.Key}: a drawn column has no order and a written one must have" + ); + } + + /// + /// Widths are what stop a header being clipped to something that names nothing — "PU %" was a real + /// one. Every header must fit the column that carries it. + /// + [Test] + public void EveryShortHeaderFitsItsTerminalColumn() { + foreach (var descriptor in FieldRegistry.All) + Assert.That( + descriptor.ShortHeader.Length, + Is.LessThanOrEqualTo(descriptor.TerminalWidth), + $"{descriptor.Key}: '{descriptor.ShortHeader}' does not fit {descriptor.TerminalWidth} cells" + ); + } + + [Test] + public void AnExpensiveFieldIsNeverInTheDefaultTerminalColumns() { + // PRD §5.4: displaying the ordinary process table must not require an expensive collector. The + // handle count is the one exception, and it is sampled on its own schedule for that reason. + foreach (var field in new[] { ProcessField.CpuPercent, ProcessField.PrivateBytes, ProcessField.Name }) + Assert.That(FieldRegistry.Get(field).Cost, Is.Not.EqualTo(FieldCost.High), field.ToString()); + } + + #region reading a field + + [Test] + public void EveryFieldCanBeReadFromAProcessWithoutThrowing() { + var snapshot = OneProcess(); + var delta = new SnapshotDelta(); + delta.Update(null, snapshot, CpuPercentMode.Normalized); + + foreach (var descriptor in FieldRegistry.All) { + // The point is that none of these throw, whether or not the platform fills the field. + var text = FieldAccessor.Text(descriptor.Id, in snapshot.Processes[0], delta, 0); + Assert.That(text, Is.Not.Null, descriptor.Key); + _ = FieldAccessor.Number(descriptor.Id, in snapshot.Processes[0], delta, 0); + _ = FieldAccessor.RawText(descriptor.Id, in snapshot.Processes[0]); + } + } + + /// + /// Before a second sample every derived field must read as "not sampled yet" rather than as zero — + /// a fresh window showing 0.0% CPU for everything is a window that is lying (PRD §72.3). + /// + [Test] + public void ADerivedFieldWithNoSecondSampleReadsAsPendingRatherThanZero() { + var snapshot = OneProcess(); + var delta = new SnapshotDelta(); + delta.Update(null, snapshot, CpuPercentMode.Normalized); + + var pending = Humanize.Placeholder(UnknownReason.NotSampledYet); + foreach (var field in new[] { + ProcessField.CpuPercent, ProcessField.CpuPercentPerCore, ProcessField.ReadBytesPerSecond, + ProcessField.WriteBytesPerSecond, ProcessField.IoTotalRate, ProcessField.PageFaultsDelta, + }) { + Assert.That(FieldAccessor.Text(field, in snapshot.Processes[0], delta, 0), Is.EqualTo(pending), field.ToString()); + Assert.That(FieldAccessor.Number(field, in snapshot.Processes[0], delta, 0), Is.Null, field.ToString()); + } + } + + /// + /// A field the platform does not report has no number, and a filter must not treat that as zero: + /// "memory > 0" should not match a process whose memory is unknown, and neither should + /// "memory == 0". + /// + [Test] + public void AnUnknownCounterHasNoNumberAtAll() { + var snapshot = new SystemSnapshot(); + var records = snapshot.PrepareProcesses(1); + records[0] = default; + records[0].Key = new(1, 1); + records[0].Name = "test"; + records[0].PrivateBytes = Counter.NotSupported; + + var delta = new SnapshotDelta(); + delta.Update(null, snapshot, CpuPercentMode.Normalized); + + Assert.That(FieldAccessor.Number(ProcessField.PrivateBytes, in snapshot.Processes[0], delta, 0), Is.Null); + Assert.That( + FieldAccessor.Text(ProcessField.PrivateBytes, in snapshot.Processes[0], delta, 0), + Is.EqualTo(Humanize.Placeholder(UnknownReason.NotSupportedOnPlatform)) + ); + } + + /// + /// Sorting by a column and the text that column shows are read from the same place, so an order + /// that disagrees with the display is not possible. This checks the halves agree. + /// + [Test] + public void SortingAgreesWithTheNumbersTheColumnShows() { + var snapshot = new SystemSnapshot(); + var records = snapshot.PrepareProcesses(3); + for (var i = 0; i < 3; ++i) { + records[i] = default; + records[i].Key = new(i + 1, (ulong)(i + 1)); + records[i].Name = "p" + i; + records[i].WorkingSetBytes = Counter.Of((ulong)((3 - i) * 1024)); + records[i].ThreadCount = i; + } + + var delta = new SnapshotDelta(); + delta.Update(null, snapshot, CpuPercentMode.Normalized); + var processes = snapshot.Processes; + + // Working set descends across the three; thread count ascends. Compare must say so both times. + Assert.That(FieldAccessor.Compare(ProcessField.WorkingSetBytes, in processes[0], 0, in processes[1], 1, delta), Is.GreaterThan(0)); + Assert.That(FieldAccessor.Compare(ProcessField.ThreadCount, in processes[0], 0, in processes[1], 1, delta), Is.LessThan(0)); + Assert.That(FieldAccessor.Compare(ProcessField.Name, in processes[0], 0, in processes[1], 1, delta), Is.LessThan(0)); + } + + /// An unknown value sorts below every known one, so reversing puts readable rows on top. + [Test] + public void AnUnknownValueSortsBelowEveryKnownOne() { + var snapshot = new SystemSnapshot(); + var records = snapshot.PrepareProcesses(2); + for (var i = 0; i < 2; ++i) { + records[i] = default; + records[i].Key = new(i + 1, (ulong)(i + 1)); + records[i].Name = "p" + i; + } + + records[0].PrivateBytes = Counter.NotSupported; + records[1].PrivateBytes = Counter.Of(0ul); + + var delta = new SnapshotDelta(); + delta.Update(null, snapshot, CpuPercentMode.Normalized); + var processes = snapshot.Processes; + + // Even against a real zero, which is the case that matters: unknown is not zero. + Assert.That(FieldAccessor.Compare(ProcessField.PrivateBytes, in processes[0], 0, in processes[1], 1, delta), Is.LessThan(0)); + Assert.That(FieldAccessor.Compare(ProcessField.PrivateBytes, in processes[1], 1, in processes[0], 0, delta), Is.GreaterThan(0)); + } + + #endregion + + private static SystemSnapshot OneProcess() { + var snapshot = new SystemSnapshot(); + var records = snapshot.PrepareProcesses(1); + records[0] = default; + records[0].Key = new(4242, 100); + records[0].Name = "test"; + records[0].UserName = "alice"; + records[0].ParentPid = 1; + records[0].ThreadCount = 3; + records[0].SessionId = 1; + records[0].StartTimeUtcTicks = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks; + records[0].CpuTimeNs = Counter.Of(1_000_000_000ul); + records[0].PrivateBytes = Counter.Of(1024ul * 1024); + records[0].WorkingSetBytes = Counter.Of(2048ul * 1024); + records[0].CommandLine = "/usr/bin/test --flag"; + records[0].ImagePath = "/usr/bin/test"; + return snapshot; + } + +} diff --git a/ProcessManager.Tests/LinuxProbeTests.cs b/ProcessManager.Tests/LinuxProbeTests.cs index b1d6e1d..6f3a22f 100644 --- a/ProcessManager.Tests/LinuxProbeTests.cs +++ b/ProcessManager.Tests/LinuxProbeTests.cs @@ -194,7 +194,7 @@ public void TheProcessTreeMatchesTheFixture() { using var sampler = new Sampler(probe); sampler.Sample(); - var view = new ProcessView { TreeMode = true, SortColumn = ProcessColumn.Pid, SortDescending = false }; + var view = new ProcessView { TreeMode = true, SortColumn = ProcessField.Pid, SortDescending = false }; view.Rebuild(sampler.Current, sampler.Delta); var lines = new List(); diff --git a/ProcessManager.Tests/ProcessTreeBinderTests.cs b/ProcessManager.Tests/ProcessTreeBinderTests.cs index 261119b..ac08e71 100644 --- a/ProcessManager.Tests/ProcessTreeBinderTests.cs +++ b/ProcessManager.Tests/ProcessTreeBinderTests.cs @@ -126,7 +126,7 @@ public void TheTreeIsReorderedToMatchTheSort() { var binder = new ProcessTreeBinder(tree); var (snapshot, delta, view) = Build((10, 0), (20, 0), (30, 0)); - view.SortColumn = ProcessColumn.Pid; + view.SortColumn = ProcessField.Pid; view.SortDescending = false; view.Rebuild(snapshot, delta); binder.Sync(snapshot, delta, view); @@ -145,7 +145,7 @@ public void ReorderingKeepsTheSameNodeObjects() { var tree = new TreeListView(); var binder = new ProcessTreeBinder(tree); var (snapshot, delta, view) = Build((10, 0), (20, 0)); - view.SortColumn = ProcessColumn.Pid; + view.SortColumn = ProcessField.Pid; view.SortDescending = false; view.Rebuild(snapshot, delta); binder.Sync(snapshot, delta, view); @@ -166,7 +166,7 @@ public void ChildrenAreOrderedWithinTheirParent() { var tree = new TreeListView(); var binder = new ProcessTreeBinder(tree); var (snapshot, delta, view) = Build((1, 0), (10, 1), (20, 1), (30, 1)); - view.SortColumn = ProcessColumn.Pid; + view.SortColumn = ProcessField.Pid; view.SortDescending = true; view.Rebuild(snapshot, delta); binder.Sync(snapshot, delta, view); @@ -221,7 +221,7 @@ private static (SystemSnapshot Snapshot, SnapshotDelta Delta, ProcessView View) var delta = new SnapshotDelta(); delta.Update(null, snapshot, CpuPercentMode.Normalized); - var view = new ProcessView { TreeMode = true, SortColumn = ProcessColumn.Pid, SortDescending = false }; + var view = new ProcessView { TreeMode = true, SortColumn = ProcessField.Pid, SortDescending = false }; view.Rebuild(snapshot, delta); return (snapshot, delta, view); } diff --git a/ProcessManager.Tests/ProcessViewTests.cs b/ProcessManager.Tests/ProcessViewTests.cs index 11f4bbb..59f3e7b 100644 --- a/ProcessManager.Tests/ProcessViewTests.cs +++ b/ProcessManager.Tests/ProcessViewTests.cs @@ -14,7 +14,7 @@ public sealed class ProcessViewTests { [Test] public void AFlatViewShowsEveryProcessOnce() { var (snapshot, delta) = Build((1, 0), (2, 1), (3, 2)); - var view = new ProcessView { TreeMode = false, SortColumn = ProcessColumn.Pid, SortDescending = false }; + var view = new ProcessView { TreeMode = false, SortColumn = ProcessField.Pid, SortDescending = false }; view.Rebuild(snapshot, delta); Assert.That(view.RowCount, Is.EqualTo(3)); @@ -24,7 +24,7 @@ public void AFlatViewShowsEveryProcessOnce() { [Test] public void ATreeNestsChildrenUnderParents() { var (snapshot, delta) = Build((1, 0), (2, 1), (3, 2), (4, 1)); - var view = new ProcessView { TreeMode = true, SortColumn = ProcessColumn.Pid, SortDescending = false }; + var view = new ProcessView { TreeMode = true, SortColumn = ProcessField.Pid, SortDescending = false }; view.Rebuild(snapshot, delta); Assert.That(Pids(snapshot, view), Is.EqualTo(new[] { 1, 2, 3, 4 })); @@ -38,7 +38,7 @@ public void AProcessWhoseParentIsGoneBecomesARoot() { // Its parent exited and it was reparented, or it lives in another pid namespace. Either way it // is still running and must still be listed. var (snapshot, delta) = Build((1, 0), (5, 999)); - var view = new ProcessView { TreeMode = true, SortColumn = ProcessColumn.Pid, SortDescending = false }; + var view = new ProcessView { TreeMode = true, SortColumn = ProcessField.Pid, SortDescending = false }; view.Rebuild(snapshot, delta); Assert.That(view.RowCount, Is.EqualTo(2)); @@ -50,7 +50,7 @@ public void ACycleDoesNotHangTheWalk() { // Should be impossible; observed anyway across namespace boundaries. The link that closes the // cycle is cut, and every process still appears exactly once. var (snapshot, delta) = Build((10, 11), (11, 10)); - var view = new ProcessView { TreeMode = true, SortColumn = ProcessColumn.Pid, SortDescending = false }; + var view = new ProcessView { TreeMode = true, SortColumn = ProcessField.Pid, SortDescending = false }; Assert.That(() => view.Rebuild(snapshot, delta), Throws.Nothing); Assert.That(view.RowCount, Is.EqualTo(2)); @@ -72,7 +72,7 @@ public void FilteringInTreeModeKeepsTheAncestorsOfAMatch() { var (snapshot, delta) = Build((1, 0), (2, 1), (3, 2)); Rename(snapshot, 3, "needle"); - var view = new ProcessView { TreeMode = true, TextFilter = "needle", SortColumn = ProcessColumn.Pid, SortDescending = false }; + var view = new ProcessView { TreeMode = true, TextFilter = "needle", SortColumn = ProcessField.Pid, SortDescending = false }; view.Rebuild(snapshot, delta); Assert.That(Pids(snapshot, view), Is.EqualTo(new[] { 1, 2, 3 })); @@ -96,7 +96,7 @@ public void ATieIsBrokenByPidSoRowsDoNotJumpBetweenSamples() { // Everything below has the same sort key. If the order were not pinned, a re-sort could move a // row under the pointer between hover and click — which is how the wrong process gets killed. var (snapshot, delta) = Build((30, 0), (10, 0), (20, 0)); - var view = new ProcessView { SortColumn = ProcessColumn.ThreadCount, SortDescending = true }; + var view = new ProcessView { SortColumn = ProcessField.ThreadCount, SortDescending = true }; view.Rebuild(snapshot, delta); Assert.That(Pids(snapshot, view), Is.EqualTo(new[] { 10, 20, 30 })); @@ -109,7 +109,7 @@ public void AValueThatIsNotThereSortsBelowEveryValueThatIs() { SetPrivate(snapshot, 2, Counter.NotPermitted); SetPrivate(snapshot, 3, Counter.Of(500ul)); - var view = new ProcessView { SortColumn = ProcessColumn.PrivateBytes, SortDescending = true }; + var view = new ProcessView { SortColumn = ProcessField.PrivateBytes, SortDescending = true }; view.Rebuild(snapshot, delta); Assert.That(Pids(snapshot, view), Is.EqualTo(new[] { 3, 1, 2 })); @@ -118,7 +118,7 @@ public void AValueThatIsNotThereSortsBelowEveryValueThatIs() { [Test] public void FindRowLocatesAProcessByIdentityRatherThanByPosition() { var (snapshot, delta) = Build((1, 0), (2, 1), (3, 1)); - var view = new ProcessView { SortColumn = ProcessColumn.Pid, SortDescending = false }; + var view = new ProcessView { SortColumn = ProcessField.Pid, SortDescending = false }; view.Rebuild(snapshot, delta); var key = snapshot.Processes[1].Key; diff --git a/ProcessManager.Tests/SparklineTests.cs b/ProcessManager.Tests/SparklineTests.cs index d8bb6e9..a93f4f9 100644 --- a/ProcessManager.Tests/SparklineTests.cs +++ b/ProcessManager.Tests/SparklineTests.cs @@ -101,7 +101,7 @@ private static (SystemSnapshot Snapshot, SnapshotDelta Delta, ProcessView View) var delta = new SnapshotDelta(); delta.Update(null, snapshot, CpuPercentMode.Normalized); - var view = new ProcessView { SortColumn = ProcessColumn.Pid, SortDescending = false }; + var view = new ProcessView { SortColumn = ProcessField.Pid, SortDescending = false }; view.Rebuild(snapshot, delta); return (snapshot, delta, view); } diff --git a/ProcessManager.Tests/TerminalUiTests.cs b/ProcessManager.Tests/TerminalUiTests.cs index 21b6c89..24d422d 100644 --- a/ProcessManager.Tests/TerminalUiTests.cs +++ b/ProcessManager.Tests/TerminalUiTests.cs @@ -126,7 +126,7 @@ public void TheComposedFrameMatchesTheGoldenOne() { UseBlockCharacters = true, }; ui.View.TreeMode = true; - ui.View.SortColumn = ProcessColumn.Pid; + ui.View.SortColumn = ProcessField.Pid; ui.View.SortDescending = false; ui.Update(); ui.Update(); @@ -254,7 +254,7 @@ static string Compose(Sampler sampler, LinuxProbe probe, bool unicode) { }; ui.View.TreeMode = true; - ui.View.SortColumn = ProcessColumn.Pid; + ui.View.SortColumn = ProcessField.Pid; ui.View.SortDescending = false; ui.Update(); ui.Update(); diff --git a/ProcessManager.Ui.Desktop/ColumnChooser.cs b/ProcessManager.Ui.Desktop/ColumnChooser.cs index aff5cbc..83e5304 100644 --- a/ProcessManager.Ui.Desktop/ColumnChooser.cs +++ b/ProcessManager.Ui.Desktop/ColumnChooser.cs @@ -1,3 +1,4 @@ +using Hawkynt.ProcessManager.Query; using Hawkynt.NativeForms; namespace Hawkynt.ProcessManager.Ui.Desktop; @@ -13,9 +14,9 @@ namespace Hawkynt.ProcessManager.Ui.Desktop; public sealed class ColumnChooser : Form { private readonly CheckedListBox _list = new(); - private readonly List _order = []; + private readonly List _order = []; - public ColumnChooser(IReadOnlyCollection visible) { + public ColumnChooser(IReadOnlyCollection visible) { ArgumentNullException.ThrowIfNull(visible); this.Text = "Select columns"; @@ -23,9 +24,9 @@ public ColumnChooser(IReadOnlyCollection visible) { this._list.Bounds = new(12, 12, 344, 372); foreach (var info in ColumnSet.All) { - this._order.Add(info.Column); + this._order.Add(info.Id); this._list.Items.Add(info.Header); - this._list.SetItemChecked(this._list.Items.Count - 1, visible.Contains(info.Column)); + this._list.SetItemChecked(this._list.Items.Count - 1, visible.Contains(info.Id)); } var ok = new Button { Text = "OK", Bounds = new(180, 394, 80, 28) }; @@ -49,15 +50,15 @@ public ColumnChooser(IReadOnlyCollection visible) { /// What was ticked. The name column is forced on: a list whose rows have no name is a list of /// numbers. /// - public List Selection { + public List Selection { get { - var result = new List(); + var result = new List(); for (var i = 0; i < this._order.Count; ++i) if (this._list.GetItemChecked(i)) result.Add(this._order[i]); - if (!result.Contains(DesktopColumn.Name)) - result.Insert(0, DesktopColumn.Name); + if (!result.Contains(ProcessField.Name)) + result.Insert(0, ProcessField.Name); return result; } diff --git a/ProcessManager.Ui.Desktop/ColumnSet.cs b/ProcessManager.Ui.Desktop/ColumnSet.cs index f38df02..56bb9f8 100644 --- a/ProcessManager.Ui.Desktop/ColumnSet.cs +++ b/ProcessManager.Ui.Desktop/ColumnSet.cs @@ -1,141 +1,47 @@ using Hawkynt.ProcessManager.Query; -using Hawkynt.ProcessManager.Sampling; namespace Hawkynt.ProcessManager.Ui.Desktop; -/// Every column the process list can show. -public enum DesktopColumn : byte { - Name = 0, - Pid, - PidHex, - ParentPid, - User, - State, - CpuPercent, - CpuPerCore, - CpuTime, - CyclesDelta, - ContextSwitchDelta, - CpuHistory, - PrivateBytes, - PrivateBytesDelta, - PrivateWorkingSet, - MemoryHistory, - WorkingSet, - PeakWorkingSet, - VirtualBytes, - PeakVirtualBytes, - PagedPool, - PeakPagedPool, - NonPagedPool, - PeakNonPagedPool, - PageFaultDelta, - Swap, - IoTotalRate, - ReadRate, - WriteRate, - IoHistory, - Threads, - Handles, - Priority, - Session, - Started, - Container, - ImagePath, - CommandLine, -} - /// -/// What each column is: its header, its width, whether it is a graph, and how to sort by it. +/// Which columns the window shows, and which it offers. /// /// -/// The three history columns are the reason the whole set is data rather than a hard-coded list of -/// Columns.Add calls — they are drawn rather than written, they need a series from -/// , and they have no text to sort by. +/// There is no list of columns here any more. Every column the window can show is a field in +/// , with its header, width, alignment and sort order declared once and +/// shared with the terminal (PRD §5.1). What is left is the two things that really are the window's +/// own business: which columns it opens with, and in what order the chooser lists them. /// -public readonly record struct ColumnInfo( - DesktopColumn Column, - string Header, - int Width, - bool RightAligned, - ProcessColumn? SortBy, - HistorySeries? Series -); - -public static class ColumnSet { +internal static class ColumnSet { - /// - /// Every column, in the order the chooser lists them. - /// - /// - /// A column exists here when the engine can actually fill it on at least one platform. Where the - /// other platform cannot, the cell says which reason applies rather than showing a zero (§3.4) — - /// cycles and the pool quotas are Windows-only, and read as n/a on Linux. - /// - public static readonly ColumnInfo[] All = [ - new(DesktopColumn.Name, "Process", 260, false, ProcessColumn.Name, null), - new(DesktopColumn.Pid, "PID", 70, true, ProcessColumn.Pid, null), - new(DesktopColumn.PidHex, "PID (hex)", 78, true, ProcessColumn.Pid, null), - new(DesktopColumn.ParentPid, "Parent PID", 88, true, ProcessColumn.ParentPid, null), - new(DesktopColumn.User, "User", 130, false, ProcessColumn.UserName, null), - new(DesktopColumn.State, "State", 62, false, ProcessColumn.State, null), - // Wide enough for the header *and* its sort arrow: at 62 the caption clipped to "PU %". - new(DesktopColumn.CpuPercent, "CPU %", 78, true, ProcessColumn.CpuPercent, null), - new(DesktopColumn.CpuPerCore, "CPU % (per core)", 118, true, ProcessColumn.CpuPercent, null), - new(DesktopColumn.CpuTime, "CPU time", 88, true, ProcessColumn.CpuPercent, null), - new(DesktopColumn.CyclesDelta, "Cycles delta", 100, true, null, null), - new(DesktopColumn.ContextSwitchDelta, "Ctx switch delta", 116, true, null, null), - new(DesktopColumn.CpuHistory, "CPU history", 90, false, null, HistorySeries.Cpu), - new(DesktopColumn.PrivateBytes, "Private bytes", 96, true, ProcessColumn.PrivateBytes, null), - new(DesktopColumn.PrivateBytesDelta, "Private delta", 100, true, null, null), - new(DesktopColumn.PrivateWorkingSet, "Private WS", 88, true, null, null), - new(DesktopColumn.MemoryHistory, "Memory history", 90, false, null, HistorySeries.Memory), - new(DesktopColumn.WorkingSet, "Working set", 92, true, ProcessColumn.WorkingSetBytes, null), - new(DesktopColumn.PeakWorkingSet, "Peak WS", 84, true, null, null), - new(DesktopColumn.VirtualBytes, "Virtual size", 92, true, ProcessColumn.VirtualBytes, null), - new(DesktopColumn.PeakVirtualBytes, "Peak virtual", 96, true, null, null), - new(DesktopColumn.PagedPool, "Paged pool", 90, true, null, null), - new(DesktopColumn.PeakPagedPool, "Peak paged pool", 116, true, null, null), - new(DesktopColumn.NonPagedPool, "Non-paged pool", 110, true, null, null), - new(DesktopColumn.PeakNonPagedPool, "Peak non-paged", 112, true, null, null), - new(DesktopColumn.PageFaultDelta, "Page fault delta", 116, true, null, null), - new(DesktopColumn.Swap, "Swap", 78, true, null, null), - new(DesktopColumn.IoTotalRate, "I/O total rate", 104, true, null, null), - new(DesktopColumn.ReadRate, "I/O read rate", 100, true, ProcessColumn.ReadBytesPerSecond, null), - new(DesktopColumn.WriteRate, "I/O write rate", 104, true, ProcessColumn.WriteBytesPerSecond, null), - new(DesktopColumn.IoHistory, "I/O history", 90, false, null, HistorySeries.Io), - new(DesktopColumn.Threads, "Threads", 64, true, ProcessColumn.ThreadCount, null), - new(DesktopColumn.Handles, "Handles", 66, true, ProcessColumn.HandleCount, null), - new(DesktopColumn.Priority, "Priority", 74, true, ProcessColumn.Priority, null), - new(DesktopColumn.Session, "Session", 74, true, ProcessColumn.SessionId, null), - new(DesktopColumn.Started, "Start time", 140, false, ProcessColumn.StartTime, null), - new(DesktopColumn.Container, "Container / cgroup", 240, false, null, null), - new(DesktopColumn.ImagePath, "Image path", 320, false, null, null), - new(DesktopColumn.CommandLine, "Command line", 420, false, ProcessColumn.CommandLine, null), - ]; + /// Every column the chooser offers, in registry order. + public static FieldDescriptor[] All => FieldRegistry.All; /// /// What the window opens with: the Process Explorer set plus the three graphs, which are the point /// of having them. /// - public static readonly DesktopColumn[] Default = [ - DesktopColumn.Name, - DesktopColumn.Pid, - DesktopColumn.User, - DesktopColumn.CpuPercent, - DesktopColumn.CpuHistory, - DesktopColumn.PrivateBytes, - DesktopColumn.MemoryHistory, - DesktopColumn.IoHistory, - DesktopColumn.Threads, + public static readonly ProcessField[] Default = [ + ProcessField.Name, + ProcessField.Pid, + ProcessField.UserName, + ProcessField.CpuPercent, + ProcessField.CpuHistory, + ProcessField.PrivateBytes, + ProcessField.MemoryHistory, + ProcessField.IoHistory, + ProcessField.ThreadCount, ]; - public static ColumnInfo Info(DesktopColumn column) { - foreach (var info in All) - if (info.Column == column) - return info; + public static FieldDescriptor Info(ProcessField field) => FieldRegistry.Get(field); - return All[0]; - } + /// + /// Whether a column is worth offering on this machine at all. + /// + /// + /// A column the platform cannot fill is still offered — it renders n/a, which is a true + /// statement and occasionally the one the user wanted (PRD §72.3). What it is not is + /// default-visible, which is what this decides. + /// + public static bool IsUsefulHere(FieldDescriptor descriptor) => descriptor.IsSupportedHere; } diff --git a/ProcessManager.Ui.Desktop/MainWindow.cs b/ProcessManager.Ui.Desktop/MainWindow.cs index 5fd9dd7..677e064 100644 --- a/ProcessManager.Ui.Desktop/MainWindow.cs +++ b/ProcessManager.Ui.Desktop/MainWindow.cs @@ -18,7 +18,7 @@ public sealed class MainWindow : Form { private readonly Sampler _sampler; private readonly ISystemProbe _probe; private readonly IProcessActions? _actions; - private readonly ProcessView _view = new() { TreeMode = true, SortColumn = ProcessColumn.CpuPercent, SortDescending = true }; + private readonly ProcessView _view = new() { TreeMode = true, SortColumn = ProcessField.CpuPercent, SortDescending = true }; private readonly ProcessTreeBinder _binder; private readonly TreeListView _tree = new(); private readonly HistoryPlot _cpuPlot = new(); @@ -32,7 +32,7 @@ public sealed class MainWindow : Form { private readonly HistoryRing _cpuHistory = new(600); private readonly HistoryRing _memoryHistory = new(600); private readonly ProcessHistory _rowHistory = new(); - private readonly List _columns = [.. ColumnSet.Default]; + private readonly List _columns = [.. ColumnSet.Default]; private bool _splitPlaced; private ITheme _theme = DefaultTheme.Instance; private int _laidOutWidth = -1; @@ -226,11 +226,11 @@ private void RebuildColumns() { foreach (var column in this._columns) { var info = ColumnSet.Info(column); var header = info.Header; - if (info.SortBy == this._view.SortColumn) + if (info.IsSortable && column == this._view.SortColumn) header = this._view.SortDescending ? header + " ▾" : header + " ▴"; var which = column; - this._tree.Columns.Add(new(header, info.Width, node => ((ProcessRow)node.Tag!).TextOf(which)) { + this._tree.Columns.Add(new(header, info.DesktopWidth, node => ((ProcessRow)node.Tag!).TextOf(which)) { TextAlign = info.RightAligned ? ContentAlignment.MiddleRight : ContentAlignment.MiddleLeft, }); } @@ -261,7 +261,8 @@ private void OnColumnClick(object? sender, ColumnClickEventArgs e) { if ((uint)e.Column >= (uint)this._columns.Count) return; - if (ColumnSet.Info(this._columns[e.Column]).SortBy is not { } sortBy) + var sortBy = this._columns[e.Column]; + if (!ColumnSet.Info(sortBy).IsSortable) // A history column has no text to sort by, and sorting by "the shape of a graph" is not a // thing. Clicking one does nothing rather than doing something arbitrary. return; @@ -334,20 +335,20 @@ private void BuildMenu() { // ColumnClick (its ListView does, and is flat). Click-to-sort is the gesture people expect, so // this is a stand-in for a hook that has to come from the toolkit, not a preference. var sort = new ToolStripMenuItem("Sort by"); - foreach (var column in (ReadOnlySpan)[ - ProcessColumn.CpuPercent, - ProcessColumn.PrivateBytes, - ProcessColumn.WorkingSetBytes, - ProcessColumn.ReadBytesPerSecond, - ProcessColumn.WriteBytesPerSecond, - ProcessColumn.ThreadCount, - ProcessColumn.Name, - ProcessColumn.Pid, - ProcessColumn.UserName, - ProcessColumn.StartTime, + foreach (var column in (ReadOnlySpan)[ + ProcessField.CpuPercent, + ProcessField.PrivateBytes, + ProcessField.WorkingSetBytes, + ProcessField.ReadBytesPerSecond, + ProcessField.WriteBytesPerSecond, + ProcessField.ThreadCount, + ProcessField.Name, + ProcessField.Pid, + ProcessField.UserName, + ProcessField.StartTime, ]) { var chosen = column; - sort.DropDownItems.Add(Item(column.ToHeader(), () => { + sort.DropDownItems.Add(Item(column.Header(), () => { this._view.SortColumn = chosen; this._view.SortDescending = chosen.PrefersDescending(); this.Refresh(); diff --git a/ProcessManager.Ui.Desktop/ProcessRow.cs b/ProcessManager.Ui.Desktop/ProcessRow.cs index c8a346b..fb60445 100644 --- a/ProcessManager.Ui.Desktop/ProcessRow.cs +++ b/ProcessManager.Ui.Desktop/ProcessRow.cs @@ -11,50 +11,32 @@ namespace Hawkynt.ProcessManager.Ui.Desktop; /// /// The tree's column selectors run on every paint, several times per row — so they read strings that /// are already made rather than formatting a number each time. The strings are refreshed once per -/// sample, in , and only when they changed: a row whose CPU still reads "0.0" -/// hands back the same string instance and the control has nothing to repaint. +/// sample, in . +/// +/// One array indexed by rather than a property per column: there are +/// thirty-eight of them, a property each meant a switch to match, and the switch is exactly the kind +/// of thing that silently loses a field when the thirty-ninth is added (PRD §5.1). +/// /// public sealed class ProcessRow(ProcessKey key) { + private static readonly int _slots = CountSlots(); + + private readonly string[] _text = new string[_slots]; + + /// Indexed by the enum value, so the array must be as long as the largest one plus one. + private static int CountSlots() { + var highest = 0; + foreach (var descriptor in FieldRegistry.All) + highest = Math.Max(highest, (int)descriptor.Id); + + return highest + 1; + } + public ProcessKey Key { get; } = key; public int Pid => this.Key.Pid; - public string Name { get; private set; } = string.Empty; - public string User { get; private set; } = string.Empty; - public string Cpu { get; private set; } = string.Empty; - public string Private { get; private set; } = string.Empty; - public string WorkingSet { get; private set; } = string.Empty; - public string Read { get; private set; } = string.Empty; - public string Write { get; private set; } = string.Empty; - public string Threads { get; private set; } = string.Empty; - public string Handles { get; private set; } = string.Empty; - public string State { get; private set; } = string.Empty; - public string Started { get; private set; } = string.Empty; - public string CommandLine { get; private set; } = string.Empty; - public string PidHex { get; private set; } = string.Empty; - public string ParentPid { get; private set; } = string.Empty; - public string CpuPerCore { get; private set; } = string.Empty; - public string CpuTime { get; private set; } = string.Empty; - public string CyclesDelta { get; private set; } = string.Empty; - public string ContextSwitchDelta { get; private set; } = string.Empty; - public string PrivateDelta { get; private set; } = string.Empty; - public string PrivateWorkingSet { get; private set; } = string.Empty; - public string PeakWorkingSet { get; private set; } = string.Empty; - public string VirtualBytes { get; private set; } = string.Empty; - public string PeakVirtualBytes { get; private set; } = string.Empty; - public string PagedPool { get; private set; } = string.Empty; - public string PeakPagedPool { get; private set; } = string.Empty; - public string NonPagedPool { get; private set; } = string.Empty; - public string PeakNonPagedPool { get; private set; } = string.Empty; - public string PageFaultDelta { get; private set; } = string.Empty; - public string Swap { get; private set; } = string.Empty; - public string IoTotal { get; private set; } = string.Empty; - public string Priority { get; private set; } = string.Empty; - public string Session { get; private set; } = string.Empty; - public string Container { get; private set; } = string.Empty; - public string ImagePath { get; private set; } = string.Empty; - /// True for one sample after the process appeared — the Process Explorer green flash. public bool IsNew { get; private set; } @@ -65,86 +47,53 @@ public sealed class ProcessRow(ProcessKey key) { public int Generation { get; set; } public void Update(in ProcessRecord process, SnapshotDelta delta, int index, Counter handles, int currentUserId) { + foreach (var descriptor in FieldRegistry.All) { + if (descriptor.IsGraph) + continue; + + this._text[(int)descriptor.Id] = FieldAccessor.Text(descriptor.Id, in process, delta, index); + } + + // Two exceptions to the shared formatter, both because the window knows something the engine + // does not. The handle count is sampled on its own schedule because it is expensive (PRD §5.4), + // so the row prefers the freshly measured one and falls back to the snapshot's; and a missing + // user name falls back to the numeric id, which is more use in a window than a dash. + if (handles.Reason != UnknownReason.NotSampledYet) + this._text[(int)ProcessField.HandleCount] = Humanize.Count(handles); + + if (process.UserName is null) + this._text[(int)ProcessField.UserName] = + process.UserId >= 0 ? process.UserId.ToString(CultureInfo.InvariantCulture) : "?"; + this.Name = process.Name; - this.User = process.UserName ?? (process.UserId >= 0 ? process.UserId.ToString(CultureInfo.InvariantCulture) : "?"); - this.Cpu = Humanize.Percent(delta.CpuPercent(index)); - this.Private = Humanize.Bytes(process.PrivateBytes); - this.WorkingSet = Humanize.Bytes(process.WorkingSetBytes); - this.Read = Humanize.BytesPerSecond(delta.ReadBytesPerSecond(index)); - this.Write = Humanize.BytesPerSecond(delta.WriteBytesPerSecond(index)); - this.Threads = process.ThreadCount.ToString(CultureInfo.InvariantCulture); - this.Handles = Humanize.Count(handles.Reason == UnknownReason.NotSampledYet ? process.HandleCount : handles); - this.State = Humanize.State(process.State); - this.Started = process.StartTimeUtcTicks > 0 - ? new DateTime(process.StartTimeUtcTicks, DateTimeKind.Utc).ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.CurrentCulture) - : "—"; - - this.CommandLine = process.CommandLine ?? string.Empty; - this.PidHex = "0x" + process.Pid.ToString("X", CultureInfo.InvariantCulture); - this.ParentPid = process.ParentPid > 0 ? process.ParentPid.ToString(CultureInfo.InvariantCulture) : "—"; - this.CpuPerCore = Humanize.Percent(delta.CpuPercentPerCore(index)); - this.CpuTime = Humanize.Duration(process.CpuTimeNs); - this.CyclesDelta = Humanize.Rate(delta.CyclesPerSecond(index)); - this.ContextSwitchDelta = Humanize.Rate(delta.ContextSwitchesPerSecond(index)); - this.PrivateDelta = Humanize.SignedBytesPerSecond(delta.PrivateBytesDelta(index)); - this.PrivateWorkingSet = Humanize.Bytes(process.PrivateWorkingSetBytes); - this.PeakWorkingSet = Humanize.Bytes(process.PeakWorkingSetBytes); - this.VirtualBytes = Humanize.Bytes(process.VirtualBytes); - this.PeakVirtualBytes = Humanize.Bytes(process.PeakVirtualBytes); - this.PagedPool = Humanize.Bytes(process.PagedPoolBytes); - this.PeakPagedPool = Humanize.Bytes(process.PeakPagedPoolBytes); - this.NonPagedPool = Humanize.Bytes(process.NonPagedPoolBytes); - this.PeakNonPagedPool = Humanize.Bytes(process.PeakNonPagedPoolBytes); - this.PageFaultDelta = Humanize.Rate(delta.PageFaultsPerSecond(index)); - this.Swap = Humanize.Bytes(process.SwapBytes); - this.IoTotal = Humanize.BytesPerSecond(delta.IoTotalBytesPerSecond(index)); - this.Priority = process.Priority.ToString(CultureInfo.InvariantCulture); - this.Session = process.SessionId >= 0 ? process.SessionId.ToString(CultureInfo.InvariantCulture) : "—"; - this.Container = process.ContainerPath ?? "—"; - this.ImagePath = process.ImagePath ?? "—"; this.IsNew = delta.IsNew(index); this.Category = ProcessCategories.Classify(in process, currentUserId, this.IsNew); } /// The text for one column, or empty for the ones that are drawn rather than written. - public string TextOf(DesktopColumn column) => column switch { - DesktopColumn.Name => this.Label, - DesktopColumn.Pid => this.Pid.ToString(CultureInfo.InvariantCulture), - DesktopColumn.User => this.User, - DesktopColumn.State => this.State, - DesktopColumn.CpuPercent => this.Cpu, - DesktopColumn.PrivateBytes => this.Private, - DesktopColumn.WorkingSet => this.WorkingSet, - DesktopColumn.ReadRate => this.Read, - DesktopColumn.WriteRate => this.Write, - DesktopColumn.Threads => this.Threads, - DesktopColumn.Handles => this.Handles, - DesktopColumn.Started => this.Started, - DesktopColumn.CommandLine => this.CommandLine, - DesktopColumn.PidHex => this.PidHex, - DesktopColumn.ParentPid => this.ParentPid, - DesktopColumn.CpuPerCore => this.CpuPerCore, - DesktopColumn.CpuTime => this.CpuTime, - DesktopColumn.CyclesDelta => this.CyclesDelta, - DesktopColumn.ContextSwitchDelta => this.ContextSwitchDelta, - DesktopColumn.PrivateBytesDelta => this.PrivateDelta, - DesktopColumn.PrivateWorkingSet => this.PrivateWorkingSet, - DesktopColumn.PeakWorkingSet => this.PeakWorkingSet, - DesktopColumn.VirtualBytes => this.VirtualBytes, - DesktopColumn.PeakVirtualBytes => this.PeakVirtualBytes, - DesktopColumn.PagedPool => this.PagedPool, - DesktopColumn.PeakPagedPool => this.PeakPagedPool, - DesktopColumn.NonPagedPool => this.NonPagedPool, - DesktopColumn.PeakNonPagedPool => this.PeakNonPagedPool, - DesktopColumn.PageFaultDelta => this.PageFaultDelta, - DesktopColumn.Swap => this.Swap, - DesktopColumn.IoTotalRate => this.IoTotal, - DesktopColumn.Priority => this.Priority, - DesktopColumn.Session => this.Session, - DesktopColumn.Container => this.Container, - DesktopColumn.ImagePath => this.ImagePath, - _ => string.Empty, - }; + public string TextOf(ProcessField field) { + // The name column carries the pid alongside the name, which is a window convention rather than a + // property of the field, so it does not belong in the shared accessor. + if (field == ProcessField.Name) + return this.Label; + + var index = (int)field; + return (uint)index < (uint)this._text.Length ? this._text[index] ?? string.Empty : string.Empty; + } + + public string Name { get; private set; } = string.Empty; + + public string User => this.TextOf(ProcessField.UserName); + public string Cpu => this.TextOf(ProcessField.CpuPercent); + public string Private => this.TextOf(ProcessField.PrivateBytes); + public string WorkingSet => this.TextOf(ProcessField.WorkingSetBytes); + public string Read => this.TextOf(ProcessField.ReadBytesPerSecond); + public string Write => this.TextOf(ProcessField.WriteBytesPerSecond); + public string Threads => this.TextOf(ProcessField.ThreadCount); + public string Handles => this.TextOf(ProcessField.HandleCount); + public string State => this.TextOf(ProcessField.State); + public string Started => this.TextOf(ProcessField.StartTime); + public string CommandLine => this.TextOf(ProcessField.CommandLine); /// The text the tree column shows in the name column, with the pid appended. public string Label => $"{this.Name} ({this.Pid})"; diff --git a/ProcessManager.Ui.Terminal/Layout.cs b/ProcessManager.Ui.Terminal/Layout.cs index d9033f3..6dd84ee 100644 --- a/ProcessManager.Ui.Terminal/Layout.cs +++ b/ProcessManager.Ui.Terminal/Layout.cs @@ -1,5 +1,4 @@ using Hawkynt.ProcessManager.Query; -using Hawkynt.ProcessManager.Sampling; namespace Hawkynt.ProcessManager.Ui.Terminal; @@ -10,50 +9,45 @@ namespace Hawkynt.ProcessManager.Ui.Terminal; /// Fixed widths rather than measured ones. A column that resizes itself to its widest value jitters /// every second as processes come and go, and a table whose columns move is harder to read than one /// whose values are occasionally clipped (PRD §11). +/// +/// The widths, headers and alignments come from , not from here: the +/// terminal used to keep its own list, which is how it ended up one field behind the window +/// (PRD §5.1). +/// /// internal static class Layout { - /// - /// A terminal column: either one of the engine's sortable columns, or one of the three drawn - /// histories, which have no text and no sort order. - /// - public readonly record struct TerminalColumn(string Header, int Width, ProcessColumn? Sortable, HistorySeries? Series) { - public bool IsGraph => this.Series is not null; - } - - public static readonly TerminalColumn[] Columns = [ - new("PID", 7, ProcessColumn.Pid, null), - new("User", 10, ProcessColumn.UserName, null), - new("S", 5, ProcessColumn.State, null), - new("CPU%", 5, ProcessColumn.CpuPercent, null), - new("CPU hist", 12, null, HistorySeries.Cpu), - new("Private", 7, ProcessColumn.PrivateBytes, null), - new("Mem hist", 12, null, HistorySeries.Memory), - new("Read/s", 8, ProcessColumn.ReadBytesPerSecond, null), - new("Write/s", 8, ProcessColumn.WriteBytesPerSecond, null), - new("I/O hist", 12, null, HistorySeries.Io), - new("Thr", 4, ProcessColumn.ThreadCount, null), - new("Hnd", 5, ProcessColumn.HandleCount, null), - new("Process", 120, ProcessColumn.Name, null), + /// The columns the terminal opens with, in order. + public static readonly ProcessField[] Columns = [ + ProcessField.Pid, + ProcessField.UserName, + ProcessField.State, + ProcessField.CpuPercent, + ProcessField.CpuHistory, + ProcessField.PrivateBytes, + ProcessField.MemoryHistory, + ProcessField.ReadBytesPerSecond, + ProcessField.WriteBytesPerSecond, + ProcessField.IoHistory, + ProcessField.ThreadCount, + ProcessField.HandleCount, + ProcessField.Name, ]; + public static FieldDescriptor Info(ProcessField field) => FieldRegistry.Get(field); + /// The columns a user can cycle the sort through — the graphs are not among them. - public static ProcessColumn[] Sortable { + public static ProcessField[] Sortable { get { - var result = new List(); - foreach (var column in Columns) - if (column.Sortable is { } sortable) - result.Add(sortable); + var result = new List(); + // Not "field": in C# 14 that is a keyword inside a property accessor and binds to the + // backing field instead of the loop variable. + foreach (var candidate in Columns) + if (FieldRegistry.Get(candidate).IsSortable) + result.Add(candidate); return [.. result]; } } - /// Numbers right, text left — so the digits of a column line up under each other. - public static bool IsRightAligned(in TerminalColumn column) => column.Sortable switch { - ProcessColumn.Name or ProcessColumn.UserName or ProcessColumn.CommandLine or ProcessColumn.State => false, - null => false, - _ => true, - }; - } diff --git a/ProcessManager.Ui.Terminal/TerminalUi.cs b/ProcessManager.Ui.Terminal/TerminalUi.cs index e3f3ca9..3a060a5 100644 --- a/ProcessManager.Ui.Terminal/TerminalUi.cs +++ b/ProcessManager.Ui.Terminal/TerminalUi.cs @@ -43,7 +43,7 @@ public TerminalUi(Sampler sampler, ISystemProbe probe, IProcessActions? actions, this._screen = new(width, height, depth); this._detail = new(probe); this._view.TreeMode = false; - this._view.SortColumn = ProcessColumn.CpuPercent; + this._view.SortColumn = ProcessField.CpuPercent; this._view.SortDescending = true; } @@ -197,10 +197,10 @@ private bool HandleNormal(ConsoleKeyInfo key) { case 'i': this.OpenDetail(); return true; case '<': this.PreviousSortColumn(); return true; case '>': this.NextSortColumn(); return true; - case 'P': this._view.SortColumn = ProcessColumn.CpuPercent; this._view.SortDescending = true; return true; - case 'M': this._view.SortColumn = ProcessColumn.PrivateBytes; this._view.SortDescending = true; return true; - case 'T': this._view.SortColumn = ProcessColumn.StartTime; this._view.SortDescending = false; return true; - case 'N': this._view.SortColumn = ProcessColumn.Pid; this._view.SortDescending = false; return true; + case 'P': this._view.SortColumn = ProcessField.CpuPercent; this._view.SortDescending = true; return true; + case 'M': this._view.SortColumn = ProcessField.PrivateBytes; this._view.SortDescending = true; return true; + case 'T': this._view.SortColumn = ProcessField.StartTime; this._view.SortDescending = false; return true; + case 'N': this._view.SortColumn = ProcessField.Pid; this._view.SortDescending = false; return true; case 'C': this._sampler.CpuPercentMode = this._sampler.CpuPercentMode == CpuPercentMode.Normalized ? CpuPercentMode.PerCore @@ -357,7 +357,7 @@ private void SetSortColumn(int direction) { index = ((index < 0 ? 0 : index) + direction + columns.Length) % columns.Length; this._view.SortColumn = columns[index]; this._view.SortDescending = columns[index].PrefersDescending(); - this.Say($"sorted by {columns[index].ToHeader()}", Attributes.Accent); + this.Say($"sorted by {columns[index].Header()}", Attributes.Accent); } private void BeginKill(bool tree) { @@ -510,10 +510,11 @@ private static string FormatUptime(double seconds) { private void DrawColumnHeader(int y) { this._screen.Fill(0, y, this._screen.Width, ' ', Attributes.Header); var x = 0; - foreach (var column in Layout.Columns) { - var width = column.Width; - var header = column.Header; - if (column.Sortable is { } sortable && sortable == this._view.SortColumn) + foreach (var field in Layout.Columns) { + var column = Layout.Info(field); + var width = column.TerminalWidth; + var header = column.ShortHeader; + if (column.IsSortable && field == this._view.SortColumn) header = this._view.SortDescending ? header + "▾" : header + "▴"; // A header that does not fit loses its tail, not its head: "Working set" clipped to "ing set" @@ -522,7 +523,7 @@ private void DrawColumnHeader(int y) { if (header.Length > width) header = header[..width]; - if (Layout.IsRightAligned(in column)) + if (column.RightAligned) this._screen.WriteRight(x, y, width, header, Attributes.Header); else this._screen.Write(x, y, header, Attributes.Header); @@ -555,8 +556,9 @@ private void DrawRows(int top) { this._screen.Fill(0, top + line, this._screen.Width, ' ', Attributes.Selected); var x = 0; - foreach (var column in Layout.Columns) { - var width = column.Width; + foreach (var field in Layout.Columns) { + var column = Layout.Info(field); + var width = column.TerminalWidth; if (column.Series is { } series) { // Drawn, not written: the eighth-block ramp turns a column of text into a plot (PRD §11). var plot = BlockSparkline.Render( @@ -574,8 +576,8 @@ private void DrawRows(int top) { continue; } - var text = this.CellText(in column, row, in process, delta); - if (Layout.IsRightAligned(in column)) + var text = this.CellText(field, row, in process, delta); + if (column.RightAligned) this._screen.WriteRight(x, top + line, width, text, baseAttribute); else this._screen.Write(x, top + line, text.Length > width ? text[..width] : text, baseAttribute); @@ -594,24 +596,31 @@ private void DrawRows(int top) { _ => Attributes.Warn, }; - private string CellText(in Layout.TerminalColumn column, ViewRow row, in ProcessRecord process, SnapshotDelta delta) - => column.Sortable switch { - ProcessColumn.Pid => process.Pid.ToString(CultureInfo.InvariantCulture), - ProcessColumn.UserName => process.UserName ?? (process.UserId >= 0 ? process.UserId.ToString(CultureInfo.InvariantCulture) : "?"), - ProcessColumn.PrivateBytes => Humanize.Bytes(process.PrivateBytes), - ProcessColumn.WorkingSetBytes => Humanize.Bytes(process.WorkingSetBytes), - ProcessColumn.State => Humanize.State(process.State), - ProcessColumn.CpuPercent => Humanize.Percent(delta.CpuPercent(row.Index)), - ProcessColumn.ReadBytesPerSecond => Humanize.BytesPerSecond(delta.ReadBytesPerSecond(row.Index)), - ProcessColumn.WriteBytesPerSecond => Humanize.BytesPerSecond(delta.WriteBytesPerSecond(row.Index)), - ProcessColumn.ThreadCount => process.ThreadCount.ToString(CultureInfo.InvariantCulture), - ProcessColumn.HandleCount => Humanize.Count(this._handleCounts.TryGetValue(process.Key, out var handles) ? handles : process.HandleCount), - ProcessColumn.Name => this._view.TreeMode - ? new string(' ', Math.Min(row.Depth * 2, 32)) + (row.HasChildren ? "+ " : " ") + process.Name - : process.Name, - ProcessColumn.CommandLine => process.CommandLine ?? string.Empty, - _ => string.Empty, - }; + /// + /// The text for one cell. Everything goes through the shared accessor so a value reads the same + /// here as in the window (PRD §5.1); the two exceptions below are things the terminal knows and + /// the engine does not. + /// + private string CellText(ProcessField field, ViewRow row, in ProcessRecord process, SnapshotDelta delta) { + switch (field) { + // The tree lives in the name column, so indentation and the expander are part of its text. + case ProcessField.Name: + return this._view.TreeMode + ? new string(' ', Math.Min(row.Depth * 2, 32)) + (row.HasChildren ? "+ " : " ") + process.Name + : process.Name; + + // Handles are counted on their own schedule because counting them is expensive (PRD §5.4). + case ProcessField.HandleCount when this._handleCounts.TryGetValue(process.Key, out var handles): + return Humanize.Count(handles); + + // A missing user name falls back to the numeric id, which is narrower and more use than a dash. + case ProcessField.UserName when process.UserName is null: + return process.UserId >= 0 ? process.UserId.ToString(CultureInfo.InvariantCulture) : "?"; + + default: + return FieldAccessor.Text(field, in process, delta, row.Index); + } + } private void DrawStatus() { var y = this._screen.Height - 1; diff --git a/docs/PRD.md b/docs/PRD.md index 455a809..056a22d 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -31,8 +31,8 @@ shorthand: it is not known*. An unticked box must never become a zero on screen. This is restated here because it is the single requirement most likely to be broken while filling the tables in. -**Counting, as of the last update:** **374 of 1249 boxes are ticked** — 55 of 189 in the field -registry (§14–22), 319 of 1060 across the capabilities. A further 116 are marked 🟡, meaning some of +**Counting, as of the last update:** **382 of 1250 boxes are ticked** — 55 of 189 in the field +registry (§14–22), 327 of 1061 across the capabilities. A further 114 are marked 🟡, meaning some of the work behind them is already done. §100 tracks the phases; §101 defines when this may be called finished. @@ -182,34 +182,36 @@ reasoning, and the reasoning recorded in a comment. There must not be separate "GUI fields" and "TUI fields". -- [ ] Every field is registered in a central field catalogue. **This does not exist yet**, and it is - the highest-priority piece of internal work in the document: today `ProcessColumn` is a sort-key - enum in Core, the desktop keeps its own `ColumnSet`, and the TUI keeps a third list. Three - places to add a field is three places to forget one. §103 cannot be enforced until this lands. +- [x] Every field is registered in a central field catalogue — `FieldRegistry` in + `ProcessManager.Core/Query`. There were three lists before it (a sort-key enum in Core, the + window's own `ColumnSet`, and a third in the terminal), and three places to add a field is + three places to forget one. -The ticks below therefore describe what a field *can* express today through the existing types -(`Counter`, `Rate`, `UnknownReason`), not a catalogue that holds the metadata in one place. +`FieldAccessor` reads a field three ways from that one declaration: as text to display, as a number +to compare and filter, and as an ordering. The window and the terminal both render through it, and +the view sorts through it — so sorting by a column cannot disagree with what the column shows, and a +value reads identically in both front-ends because it is the same code producing it. Each registry entry declares: -- [x] Stable field ID +- [x] Stable field ID — the `Key`, which is what `--sort`, a saved layout and a search term all use - [x] Human-readable name -- [ ] Short TUI label -- [ ] Description +- [x] Short TUI label +- [x] Description - [x] Data type -- [ ] 🟡 Units +- [x] Units - [ ] Precision - [x] Whether it is instantaneous, cumulative, delta, rate, state, enumeration or derived -- [ ] 🟡 Supported platforms +- [x] Supported platforms - [ ] Required privilege -- [ ] Collection cost +- [x] Collection cost - [x] Default visibility - [x] Sort semantics -- [ ] Filter semantics +- [ ] 🟡 Filter semantics — `Number` and `RawText` are there; the query language of §56 is not - [x] Formatting function - [x] Null/unavailable semantics - [ ] Export serialisation -- [ ] Historical-storage eligibility +- [ ] 🟡 Historical-storage eligibility — the graph fields declare their series Worked example — `process.cpu.usage`: display "CPU", TUI "CPU%", percentage, normalised instantaneous utilisation, 0–100 in default mode and 0–N×100 in raw logical-CPU mode, all platforms, @@ -341,7 +343,7 @@ Platform backends → Core collector → Snapshot engine → Field registry → | Core collector | `ProcessManager.Core` | ✅ | | Platform backends | `ProcessManager.Platform.{Windows,Linux,MacOS}` | 🟡 macOS stub | | Snapshot engine | `ProcessManager.Core/Sampling` | ✅ | -| Field registry | *not yet built* — see §5.1 | ⬜ | +| Field registry | `ProcessManager.Core/Query` | ✅ | | Query engine | `ProcessManager.Core/Query` | 🟡 | | Action broker | `ProcessManager.Core/Actions` | 🟡 | | Privileged helper | `ProcessManager.Elevated` | ✅ | @@ -1543,6 +1545,9 @@ Substring matching over name, PID, user and command line works in both front-end language does not exist. - [x] Plain substring search +- [x] Every field is addressable by a stable key, which is the half of the query language the + registry supplies — `--sort=private.ws` and `--sort=faults.delta` work without either having + been written down anywhere as a sort key - [ ] `field:value` - [ ] `field=value` - [ ] Comparison operators @@ -2296,7 +2301,7 @@ a naive parser hands the attacker the parse. - [x] Delta handling - [x] Unit formatting - [x] PID reuse -- [ ] 🟡 Field registry +- [x] Field registry — 14 tests, including the one that enforces §103 - [x] Filters - [x] Sorting - [ ] Export schemas @@ -2498,7 +2503,9 @@ To add an **action**: 11. Add the audit event 12. Add tests -- [ ] 🟡 A CI check enforces this — today it is a convention, and a convention is not a rule +- [ ] 🟡 A CI check enforces this. Half of it is real: `EveryFieldInTheEnumIsRegistered` fails the + build when a field is added to the enum without a descriptor, so steps 1–8 cannot be skipped. + Steps 9–13 — GUI, TUI, CLI, export schema, tests — are still on the author to remember. # 104. Internal object model