Sync config from arch
- hypr/apps.lua - hypr/autostart.lua - hypr/envs.lua - hypr/hyprland.lua - hypr/hyprsunset.conf - hypr/input.lua - hypr/looknfeel.lua - hypr/omasettings.lua - hypr/xdph.conf - omarchy/branding/about.txt - omarchy/branding/screensaver.txt - omarchy/extensions/omarchy-menu.jsonc - omarchy/hooks/battery-low.d/play-warning-sound.sample - omarchy/hooks/font-set.d/show-font-notification.sample - omarchy/hooks/post-boot.d/weather.sample - omarchy/hooks/post-update.d/install-voxtype.hook - omarchy/hooks/post-update.d/setup-agent.hook - omarchy/hooks/post-update.d/setup-fingerprint.hook - omarchy/hooks/post-update.d/show-update-notification.sample - omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample - omarchy/hooks/theme-set.d/show-theme-notification.sample - omarchy/shell.json - omarchy/shell.toml - omarchy/theme.name - omarchy/themes/azure-glow/README.md - omarchy/themes/azure-glow/alacritty.toml - omarchy/themes/azure-glow/btop.theme - omarchy/themes/azure-glow/hyprland.conf - omarchy/themes/azure-glow/hyprlock.conf - omarchy/themes/azure-glow/icons.theme - … 269 more
This commit is contained in:
commit
1cdb82a76f
300 files changed
+78143
No files matched your search
@@ -0,0 +1,706 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const activity = requireFromRoot('Model.js')
|
||||
|
||||
const storage = activity.parseStorageSnapshot([
|
||||
'schema\tactivity-storage\t1',
|
||||
'sample\t99.75',
|
||||
'storage\t/\t4096000\t3072000\t819200',
|
||||
''
|
||||
].join('\n'))
|
||||
|
||||
assertEqual(storage.schema, 1, 'activity parses the storage schema version')
|
||||
assertEqual(storage.sample, 99.75, 'activity parses the storage monotonic sample')
|
||||
assertEqual(storage.path, '/', 'activity identifies the root filesystem')
|
||||
assertEqual(storage.total, 4096000, 'activity parses total storage bytes')
|
||||
assertEqual(storage.used, 3072000, 'activity parses used storage bytes')
|
||||
assertEqual(storage.available, 819200, 'activity parses user-available storage bytes')
|
||||
assertEqual(storage.volumes.length, 1, 'activity keeps a single storage line as one volume')
|
||||
|
||||
const volumes = activity.parseStorageSnapshot([
|
||||
'schema\tactivity-storage\t1',
|
||||
'sample\t12',
|
||||
'storage\t/data\t200\t180\t20',
|
||||
'storage\t/\t100\t20\t80',
|
||||
''
|
||||
].join('\n'))
|
||||
assertEqual(volumes.volumes.length, 2, 'activity keeps multiple storage volumes')
|
||||
assertEqual(volumes.path, '/', 'activity still exposes root as the primary storage path')
|
||||
assertEqual(volumes.volumes[0].path, '/', 'activity sorts the root volume first')
|
||||
assertEqual(volumes.volumes[1].path, '/data', 'activity keeps additional mountpoints')
|
||||
assertEqual(activity.storageVolumeLabel('/'), '/', 'activity labels the root volume as /')
|
||||
assertEqual(activity.storageVolumeLabel('/mnt/data'), 'data', 'activity labels a volume by its last path component')
|
||||
assertEqual(activity.tightestStorageVolume(volumes.volumes).path, '/data', 'activity prefers the fullest volume')
|
||||
assertEqual(activity.selectStorageVolume(volumes.volumes, '', false).path, '/data', 'activity defaults storage to the fullest volume')
|
||||
assertEqual(activity.selectStorageVolume(volumes.volumes, '/', true).path, '/', 'activity keeps a pinned storage volume')
|
||||
assertEqual(activity.selectStorageVolume(volumes.volumes, '/gone', true).path, '/data', 'activity falls back when a pinned volume disappears')
|
||||
assertEqual(activity.cycleItemId(volumes.volumes, '/data', 'path', 1), '/', 'activity wraps the storage pager')
|
||||
assertEqual(activity.nextCycleIndex(3, 0, -1), 2, 'activity wraps a pager backward')
|
||||
|
||||
const invalidStorage = activity.parseStorageSnapshot(
|
||||
'schema\tactivity-storage\t1\nstorage\t/\t100\t120\t110\n'
|
||||
)
|
||||
assertEqual(invalidStorage.used, 100, 'activity bounds used storage by filesystem size')
|
||||
assertEqual(invalidStorage.available, 100, 'activity bounds available storage by filesystem size')
|
||||
|
||||
const firstGpuSnapshot = activity.parseGpuSnapshot([
|
||||
'schema\tactivity-gpus\t1',
|
||||
'sample\t10',
|
||||
'gpu\t0000:00:02.0\tIntel\txe\tIntel Arc Test\t-1\t-1\t-1\tunknown\t600',
|
||||
'engine\t0000:00:02.0\tclient-a\trcs\t100\t1000\t1\tcycles',
|
||||
'engine\t0000:00:02.0\tclient-b\trcs\t50\t1000\t1\tcycles',
|
||||
'engine\t0000:00:02.0\tclient-a\tvcs\t40\t1000\t1\tcycles',
|
||||
'gpu-memory\t0000:00:02.0\tshared\t536870912',
|
||||
'gpu\t0000:04:00.0\tNVIDIA\tnvidia\tNVIDIA GeForce Test\t62\t2147483648\t8589934592\tvram\t1950',
|
||||
''
|
||||
].join('\n'))
|
||||
const secondGpuSnapshot = activity.parseGpuSnapshot([
|
||||
'schema\tactivity-gpus\t1',
|
||||
'sample\t12',
|
||||
'gpu\t0000:00:02.0\tIntel\txe\tIntel Arc Test\t-1\t-1\t-1\tunknown\t700',
|
||||
'engine\t0000:00:02.0\tclient-a\trcs\t200\t1200\t1\tcycles',
|
||||
'engine\t0000:00:02.0\tclient-b\trcs\t100\t1200\t1\tcycles',
|
||||
'engine\t0000:00:02.0\tclient-a\tvcs\t80\t1200\t1\tcycles',
|
||||
'gpu-memory\t0000:00:02.0\tshared\t805306368',
|
||||
'gpu\t0000:04:00.0\tNVIDIA\tnvidia\tNVIDIA GeForce Test\t47\t3221225472\t8589934592\tvram\t1905',
|
||||
''
|
||||
].join('\n'))
|
||||
const sampledGpus = activity.nextGpus(firstGpuSnapshot, secondGpuSnapshot)
|
||||
|
||||
assertEqual(firstGpuSnapshot.schema, 1, 'activity parses the GPU schema version')
|
||||
assertEqual(firstGpuSnapshot.gpus.length, 2, 'activity keeps multiple GPU adapters separate')
|
||||
assertEqual(firstGpuSnapshot.gpus[0].memoryKind, 'shared', 'activity labels DRM allocation as shared GPU memory')
|
||||
assertEqual(firstGpuSnapshot.gpus[0].memoryUsed, 536870912, 'activity parses resident shared GPU memory')
|
||||
assertEqual(sampledGpus[0].usage, 75, 'activity sums clients within an engine and avoids adding parallel engine classes')
|
||||
assertEqual(sampledGpus[0].frequencyMHz, 700, 'activity carries the current Intel GPU clock into display metrics')
|
||||
assertEqual(sampledGpus[0].memoryUsed, 805306368, 'activity carries current shared allocation into display metrics')
|
||||
assertEqual(sampledGpus[1].usage, 47, 'activity prefers a native GPU utilization reading')
|
||||
assertEqual(sampledGpus[1].frequencyMHz, 1905, 'activity parses the NVIDIA graphics clock')
|
||||
assertEqual(sampledGpus[1].memoryTotal, 8589934592, 'activity exposes dedicated VRAM capacity')
|
||||
|
||||
const firstTimedGpu = activity.parseGpuSnapshot(
|
||||
'schema\tactivity-gpus\t1\nsample\t20\ngpu\t0000:03:00.0\tAMD\tamdgpu\tAMD Test\t-1\t-1\t-1\tunknown\t1800\n'
|
||||
+ 'engine\t0000:03:00.0\t7\tgfx\t1000000000\t-1\t1\ttime\n'
|
||||
)
|
||||
const secondTimedGpu = activity.parseGpuSnapshot(
|
||||
'schema\tactivity-gpus\t1\nsample\t22\ngpu\t0000:03:00.0\tAMD\tamdgpu\tAMD Test\t-1\t-1\t-1\tunknown\t1750\n'
|
||||
+ 'engine\t0000:03:00.0\t7\tgfx\t2000000000\t-1\t1\ttime\n'
|
||||
)
|
||||
assertEqual(
|
||||
activity.nextGpus(firstTimedGpu, secondTimedGpu)[0].usage,
|
||||
50,
|
||||
'activity derives utilization from standard DRM nanosecond engine counters'
|
||||
)
|
||||
assertEqual(
|
||||
activity.nextGpus(activity.emptyGpuSnapshot(), firstGpuSnapshot)[0].usage,
|
||||
-1,
|
||||
'activity waits for a baseline before presenting counter-based GPU usage'
|
||||
)
|
||||
|
||||
const thermals = activity.parseThermalSnapshot([
|
||||
'schema\tactivity-thermals\t1',
|
||||
'sample\t100.25',
|
||||
'temperature\thwmon0/temp1\tcoretemp\tPackage id 0\t62500',
|
||||
''
|
||||
].join('\n'))
|
||||
|
||||
assertEqual(thermals.schema, 1, 'activity parses the thermal schema version')
|
||||
assertEqual(thermals.sample, 100.25, 'activity parses the thermal monotonic sample')
|
||||
assertEqual(thermals.temperatures[0].value, 62.5, 'activity parses the CPU temperature')
|
||||
|
||||
const resources = activity.parseSnapshot(
|
||||
'schema\tactivity-resources\t1\nsample\t100.00\nmemory\tMemTotal\t1024\n'
|
||||
)
|
||||
assertEqual(
|
||||
activity.cpuTemperature(thermals.temperatures).label,
|
||||
'Package id 0',
|
||||
'activity selects CPU temperature from its independent thermal snapshot'
|
||||
)
|
||||
assertEqual(
|
||||
Object.prototype.hasOwnProperty.call(resources, 'temperatures'),
|
||||
false,
|
||||
'activity keeps thermal state out of resource snapshots'
|
||||
)
|
||||
assertDeepEqual(
|
||||
activity.emptyPower(),
|
||||
{ available: false, watts: -1 },
|
||||
'activity exposes a dedicated empty power model'
|
||||
)
|
||||
|
||||
const firstPower = activity.parsePackagePowerSnapshot([
|
||||
'schema\tactivity-process-power\t1',
|
||||
'sample\t200',
|
||||
'package\tintel-rapl:0\tpackage-0\t2000000\t10000000',
|
||||
'package\tintel-rapl:1\tpackage-1\t4000000\t20000000',
|
||||
''
|
||||
].join('\n'))
|
||||
const secondPower = activity.parsePackagePowerSnapshot([
|
||||
'schema\tactivity-process-power\t1',
|
||||
'sample\t202',
|
||||
'package\tintel-rapl:0\tpackage-0\t6000000\t10000000',
|
||||
'package\tintel-rapl:1\tpackage-1\t10000000\t20000000',
|
||||
''
|
||||
].join('\n'))
|
||||
const power = activity.nextPackagePower(firstPower, secondPower)
|
||||
assertEqual(power.watts, 5, 'activity sums package power from raw energy deltas')
|
||||
assertEqual(power.available, true, 'activity exposes complete multi-package measurements')
|
||||
assertEqual(
|
||||
activity.packageEnergyReadable(secondPower),
|
||||
true,
|
||||
'activity accepts a complete set of readable package counters'
|
||||
)
|
||||
|
||||
const metadataOnlyPower = activity.parsePackagePowerSnapshot([
|
||||
'schema\tactivity-process-power\t1',
|
||||
'sample\t203',
|
||||
'package\tintel-rapl:0\tpackage-0\t\t10000000',
|
||||
''
|
||||
].join('\n'))
|
||||
const metadataOnlyDerived = activity.nextPackagePower(activity.emptyPackagePowerSnapshot(), metadataOnlyPower)
|
||||
assertEqual(metadataOnlyPower.packages[0].energy, -1, 'activity marks an unreadable energy counter')
|
||||
assertEqual(metadataOnlyDerived.available, false, 'activity does not invent watts without an energy counter')
|
||||
assertEqual(
|
||||
activity.packageEnergyReadable(metadataOnlyPower),
|
||||
false,
|
||||
'activity rejects package metadata without a readable energy counter'
|
||||
)
|
||||
|
||||
const beforeWrap = activity.parsePackagePowerSnapshot(
|
||||
'schema\tactivity-process-power\t1\nsample\t300\npackage\tintel-rapl:0\tpackage-0\t9000000\t10000000\n'
|
||||
)
|
||||
const afterWrap = activity.parsePackagePowerSnapshot(
|
||||
'schema\tactivity-process-power\t1\nsample\t302\npackage\tintel-rapl:0\tpackage-0\t1000000\t10000000\n'
|
||||
)
|
||||
assertEqual(
|
||||
activity.nextPackagePower(beforeWrap, afterWrap).watts,
|
||||
1,
|
||||
'activity handles a RAPL energy counter wrap'
|
||||
)
|
||||
|
||||
const beforeReset = activity.parsePackagePowerSnapshot(
|
||||
'schema\tactivity-process-power\t1\nsample\t600\npackage\tintel-rapl:0\tpackage-0\t6000000\t10000000\n'
|
||||
)
|
||||
const afterReset = activity.parsePackagePowerSnapshot(
|
||||
'schema\tactivity-process-power\t1\nsample\t602\npackage\tintel-rapl:0\tpackage-0\t1000000\t10000000\n'
|
||||
)
|
||||
assertEqual(
|
||||
activity.nextPackagePower(beforeReset, afterReset).watts,
|
||||
-1,
|
||||
'activity rejects a decreasing RAPL counter away from its wrap boundary'
|
||||
)
|
||||
assertEqual(
|
||||
activity.packageEnergyDelta(beforeReset.packages[0], afterReset.packages[0]),
|
||||
0,
|
||||
'activity treats a decreasing mid-range RAPL counter as reset'
|
||||
)
|
||||
|
||||
const beforeMultiSocketPower = activity.parsePackagePowerSnapshot([
|
||||
'schema\tactivity-process-power\t1',
|
||||
'sample\t200',
|
||||
'package\tintel-rapl:0\tpackage-0\t2000000\t10000000',
|
||||
'package\tintel-rapl:1\tpackage-1\t4000000\t20000000',
|
||||
''
|
||||
].join('\n'))
|
||||
const partialSocketPower = activity.parsePackagePowerSnapshot([
|
||||
'schema\tactivity-process-power\t1',
|
||||
'sample\t202',
|
||||
'package\tintel-rapl:0\tpackage-0\t6000000\t10000000',
|
||||
'package\tintel-rapl:1\tpackage-1\t\t20000000',
|
||||
''
|
||||
].join('\n'))
|
||||
assertEqual(
|
||||
activity.nextPackagePower(beforeMultiSocketPower, partialSocketPower).available,
|
||||
false,
|
||||
'activity does not present partial multi-socket energy as whole-system power'
|
||||
)
|
||||
assertEqual(
|
||||
activity.packageEnergyReadable(partialSocketPower),
|
||||
false,
|
||||
'activity retries privilege when any processor package is unreadable'
|
||||
)
|
||||
|
||||
const changedRange = activity.parsePackagePowerSnapshot(
|
||||
'schema\tactivity-process-power\t1\nsample\t304\npackage\tintel-rapl:0\tpackage-0\t2000000\t20000000\n'
|
||||
)
|
||||
assertEqual(
|
||||
activity.nextPackagePower(afterWrap, changedRange).watts,
|
||||
-1,
|
||||
'activity treats a changed RAPL range as a new counter'
|
||||
)
|
||||
assertEqual(
|
||||
activity.packageEnergyDelta(afterWrap.packages[0], changedRange.packages[0]),
|
||||
0,
|
||||
'activity rejects a changed RAPL counter range'
|
||||
)
|
||||
const beforePackageWrap = activity.parsePackagePowerSnapshot(
|
||||
'schema\tactivity-process-power\t1\nsample\t500\npackage\tintel-rapl:0\tpackage-0\t9000000\t10000000\n'
|
||||
)
|
||||
const afterPackageWrap = activity.parsePackagePowerSnapshot(
|
||||
'schema\tactivity-process-power\t1\nsample\t502\npackage\tintel-rapl:0\tpackage-0\t1000000\t10000000\n'
|
||||
)
|
||||
assertEqual(
|
||||
activity.nextPackagePower(beforePackageWrap, afterPackageWrap).watts,
|
||||
1,
|
||||
'activity process power handles a package energy counter wrap'
|
||||
)
|
||||
|
||||
const firstProcessPowerSample = activity.parseProcessSnapshot([
|
||||
'schema\tactivity-processes\t1',
|
||||
'sample\t400\t100\t10000',
|
||||
'process\t10\t1000\tS\t100\t1000\t100\tbrowser',
|
||||
''
|
||||
].join('\n'))
|
||||
const secondProcessPowerSample = activity.parseProcessSnapshot([
|
||||
'schema\tactivity-processes\t1',
|
||||
'sample\t402\t100\t10200',
|
||||
'process\t10\t1000\tR\t100\t1100\t100\tbrowser',
|
||||
''
|
||||
].join('\n'))
|
||||
const sampledPowerProcesses = activity.nextProcesses(
|
||||
firstProcessPowerSample,
|
||||
secondProcessPowerSample,
|
||||
1000
|
||||
)
|
||||
assertEqual(secondProcessPowerSample.systemBusyTicks, 10200, 'activity parses aggregate system busy ticks')
|
||||
assertEqual(sampledPowerProcesses[0].cpuTicksDelta, 100, 'activity retains a process CPU tick delta')
|
||||
assertEqual(
|
||||
activity.processSystemBusyDelta(firstProcessPowerSample, secondProcessPowerSample),
|
||||
200,
|
||||
'activity derives the matching aggregate system busy tick delta'
|
||||
)
|
||||
|
||||
const processRows = [
|
||||
{ pid: 10, name: 'browser', cpuTicksDelta: 100, memory: 10 },
|
||||
{ pid: 20, name: 'music', cpuTicksDelta: 50, memory: 5 },
|
||||
{ pid: 30, name: 'idle', cpuTicksDelta: 25, memory: 1 },
|
||||
{ pid: 40, name: 'reset', cpuTicksDelta: -25, memory: 2 }
|
||||
]
|
||||
const estimatedPower = activity.estimateProcessPower(processRows, 10, 200)
|
||||
assertEqual(estimatedPower[0].power, 5, 'activity allocates package power by system CPU ticks')
|
||||
assertEqual(estimatedPower[1].power, 2.5, 'activity allocates a second proportional package power estimate')
|
||||
assertEqual(estimatedPower[2].power, 1.25, 'activity allocates a third proportional package power estimate')
|
||||
assertEqual(estimatedPower[3].power, 0, 'activity treats a negative CPU delta as no package share')
|
||||
assertEqual(
|
||||
estimatedPower.reduce((total, process) => total + process.power, 0),
|
||||
8.75,
|
||||
'activity leaves kernel and unobserved package power unallocated'
|
||||
)
|
||||
assert(
|
||||
estimatedPower[0] !== processRows[0] && !Object.prototype.hasOwnProperty.call(processRows[0], 'power'),
|
||||
'activity process power estimation does not mutate input rows'
|
||||
)
|
||||
assertDeepEqual(
|
||||
activity.estimateProcessPower(processRows, -1, 200).map(process => process.power),
|
||||
[-1, -1, -1, -1],
|
||||
'activity marks process power unavailable without a package measurement'
|
||||
)
|
||||
assertDeepEqual(
|
||||
activity.estimateProcessPower(processRows, 10, 0).map(process => process.power),
|
||||
[-1, -1, -1, -1],
|
||||
'activity marks process power unavailable across a zero busy interval'
|
||||
)
|
||||
assertDeepEqual(
|
||||
activity.estimateProcessPower(processRows, 10, -50).map(process => process.power),
|
||||
[-1, -1, -1, -1],
|
||||
'activity marks process power unavailable after a system counter reset'
|
||||
)
|
||||
assertDeepEqual(
|
||||
activity.filterAndSortProcesses(estimatedPower, '', 'power', false)
|
||||
.map(process => process.pid),
|
||||
[10, 20, 30, 40],
|
||||
'activity sorts processes by estimated power'
|
||||
)
|
||||
const overAccountedPower = activity.estimateProcessPower([
|
||||
{ pid: 1, cpuTicksDelta: 80 },
|
||||
{ pid: 2, cpuTicksDelta: 60 }
|
||||
], 10, 100)
|
||||
assert(
|
||||
Math.abs(overAccountedPower.reduce((total, process) => total + process.power, 0) - 10) < 1e-9,
|
||||
'activity caps over-accounted process estimates at measured package power'
|
||||
)
|
||||
JS
|
||||
|
||||
fixture_root=$(mktemp -d)
|
||||
trap 'rm -rf -- "$fixture_root"' EXIT
|
||||
|
||||
proc_path="$fixture_root/proc"
|
||||
sys_path="$fixture_root/sys"
|
||||
hwmon_path="$sys_path/class/hwmon/hwmon0"
|
||||
powercap_path="$sys_path/class/powercap/intel-rapl"
|
||||
package_path="$powercap_path/intel-rapl:0"
|
||||
subzone_path="$powercap_path/intel-rapl:0:0"
|
||||
gpu_devices="$fixture_root/gpu-devices"
|
||||
gpu_drivers="$fixture_root/gpu-drivers"
|
||||
cpu_policy0="$sys_path/devices/system/cpu/cpufreq/policy0"
|
||||
cpu_policy1="$sys_path/devices/system/cpu/cpufreq/policy1"
|
||||
intel_frequency="$gpu_devices/0000:00:02.0/tile0/gt0/freq0"
|
||||
amd_hwmon="$gpu_devices/0000:03:00.0/hwmon/hwmon0"
|
||||
|
||||
mkdir -p \
|
||||
"$proc_path/net" \
|
||||
"$fixture_root/root filesystem" \
|
||||
"$sys_path/class/block" \
|
||||
"$sys_path/class/drm/card0" \
|
||||
"$sys_path/class/drm/card1" \
|
||||
"$sys_path/class/drm/card2" \
|
||||
"$sys_path/class/net/eth0/device" \
|
||||
"$cpu_policy0" \
|
||||
"$cpu_policy1" \
|
||||
"$gpu_devices/0000:00:02.0" \
|
||||
"$gpu_devices/0000:03:00.0" \
|
||||
"$gpu_devices/0000:04:00.0" \
|
||||
"$gpu_drivers/xe" \
|
||||
"$gpu_drivers/amdgpu" \
|
||||
"$gpu_drivers/nvidia" \
|
||||
"$intel_frequency" \
|
||||
"$amd_hwmon" \
|
||||
"$proc_path/500/fd" \
|
||||
"$proc_path/500/fdinfo" \
|
||||
"$proc_path/501/fd" \
|
||||
"$proc_path/501/fdinfo" \
|
||||
"$hwmon_path" \
|
||||
"$package_path" \
|
||||
"$subzone_path"
|
||||
|
||||
printf '321.50 100.00\n' >"$proc_path/uptime"
|
||||
printf 'cpu 100 0 50 800 10 5 5 0 0 0\ncpu0 50 0 25 400 5 2 3 0 0 0\n' >"$proc_path/stat"
|
||||
printf 'MemTotal: 1024 kB\nHugePages_Total: 0\nMemAvailable: 512 kB\nCached: 128 kB\nSReclaimable: 32 kB\nSwapTotal: 0 kB\nSwapFree: 0 kB\n' >"$proc_path/meminfo"
|
||||
printf '0.50 0.25 0.10 1/100 123\n' >"$proc_path/loadavg"
|
||||
printf 'Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\tMTU\tWindow\tIRTT\neth0\t00000000\t00000000\t0001\t0\t0\t25\t00000000\t0\t0\t0\n' \
|
||||
>"$proc_path/net/route"
|
||||
printf 'Inter-| Receive | Transmit\n face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed\neth0: 1000 1 0 0 0 0 0 0 2000 1 0 0 0 0 0 0\n' \
|
||||
>"$proc_path/net/dev"
|
||||
printf 'up\n' >"$sys_path/class/net/eth0/operstate"
|
||||
printf 'DRIVER=test\n' >"$sys_path/class/net/eth0/device/uevent"
|
||||
printf '2400000\n' >"$cpu_policy0/scaling_cur_freq"
|
||||
printf '3200000\n' >"$cpu_policy1/scaling_cur_freq"
|
||||
|
||||
printf 'coretemp\n' >"$hwmon_path/name"
|
||||
printf '62500\n' >"$hwmon_path/temp1_input"
|
||||
printf 'Package id 0\n' >"$hwmon_path/temp1_label"
|
||||
|
||||
ln -s "$gpu_devices/0000:00:02.0" "$sys_path/class/drm/card0/device"
|
||||
ln -s "$gpu_devices/0000:03:00.0" "$sys_path/class/drm/card1/device"
|
||||
ln -s "$gpu_devices/0000:04:00.0" "$sys_path/class/drm/card2/device"
|
||||
ln -s "$gpu_drivers/xe" "$gpu_devices/0000:00:02.0/driver"
|
||||
ln -s "$gpu_drivers/amdgpu" "$gpu_devices/0000:03:00.0/driver"
|
||||
ln -s "$gpu_drivers/nvidia" "$gpu_devices/0000:04:00.0/driver"
|
||||
printf '0x8086\n' >"$gpu_devices/0000:00:02.0/vendor"
|
||||
printf 'Intel Arc Fixture\n' >"$gpu_devices/0000:00:02.0/product_name"
|
||||
printf '600\n' >"$intel_frequency/act_freq"
|
||||
printf '0x1002\n' >"$gpu_devices/0000:03:00.0/vendor"
|
||||
printf 'AMD Radeon Fixture\n' >"$gpu_devices/0000:03:00.0/product_name"
|
||||
printf '47\n' >"$gpu_devices/0000:03:00.0/gpu_busy_percent"
|
||||
printf '268435456\n' >"$gpu_devices/0000:03:00.0/mem_info_vram_used"
|
||||
printf '2147483648\n' >"$gpu_devices/0000:03:00.0/mem_info_vram_total"
|
||||
printf '1800000000\n' >"$amd_hwmon/freq1_input"
|
||||
printf '0x10de\n' >"$gpu_devices/0000:04:00.0/vendor"
|
||||
printf 'NVIDIA Fixture\n' >"$gpu_devices/0000:04:00.0/product_name"
|
||||
|
||||
printf '%s\n' \
|
||||
$'drm-driver:\txe' \
|
||||
$'drm-client-id:\t77' \
|
||||
$'drm-pdev:\t0000:00:02.0' \
|
||||
$'drm-resident-gtt:\t64 MiB' \
|
||||
$'drm-cycles-rcs:\t100' \
|
||||
$'drm-total-cycles-rcs:\t1000' \
|
||||
>"$proc_path/500/fdinfo/5"
|
||||
cp "$proc_path/500/fdinfo/5" "$proc_path/500/fdinfo/6"
|
||||
ln -s /dev/dri/renderD128 "$proc_path/500/fd/5"
|
||||
ln -s /dev/dri/renderD128 "$proc_path/500/fd/6"
|
||||
printf '%s\n' \
|
||||
$'drm-driver:\txe' \
|
||||
$'drm-client-id:\t78' \
|
||||
$'drm-pdev:\t0000:00:02.0' \
|
||||
$'drm-resident-gtt:\t32 MiB' \
|
||||
$'drm-cycles-rcs:\t50' \
|
||||
$'drm-total-cycles-rcs:\t1000' \
|
||||
>"$proc_path/501/fdinfo/8"
|
||||
ln -s /dev/dri/renderD128 "$proc_path/501/fd/8"
|
||||
|
||||
printf '4096\t1000\t250\t200\n' >"$fixture_root/statfs"
|
||||
printf '00000000:04:00.0\tNVIDIA GeForce Fixture\t62\t2147483648\t8589934592\t1950\n' \
|
||||
>"$fixture_root/nvidia.tsv"
|
||||
printf 'E:MEMORY_DEVICE_0_SPEED_MTS=7200\nE:MEMORY_DEVICE_0_CONFIGURED_SPEED_GTS=6.4\n' \
|
||||
>"$fixture_root/udev-data"
|
||||
|
||||
storage_snapshot=$(
|
||||
OMARCHY_SYSTEM_STATS_PROC_PATH="$proc_path" \
|
||||
OMARCHY_SYSTEM_STATS_ROOT_PATH="$fixture_root/root filesystem" \
|
||||
OMARCHY_SYSTEM_STATS_STATFS_FIXTURE_PATH="$fixture_root/statfs" \
|
||||
"$ROOT/activity-stats" --activity-storage
|
||||
)
|
||||
expected_storage=$'schema\tactivity-storage\t1\nsample\t321.50\nstorage\t/\t4096000\t3072000\t819200'
|
||||
[[ $storage_snapshot == "$expected_storage" ]] ||
|
||||
fail "activity root storage output is exact, versioned, and byte-based" "$storage_snapshot"
|
||||
pass "activity root storage output is exact, versioned, and byte-based"
|
||||
|
||||
printf '%s\n' \
|
||||
$'/data 4096 2000 100 50' \
|
||||
$'/ 4096 1000 250 200' \
|
||||
>"$fixture_root/statfs-multi"
|
||||
multi_storage_snapshot=$(
|
||||
OMARCHY_SYSTEM_STATS_PROC_PATH="$proc_path" \
|
||||
OMARCHY_SYSTEM_STATS_ROOT_PATH="$fixture_root/root filesystem" \
|
||||
OMARCHY_SYSTEM_STATS_STATFS_FIXTURE_PATH="$fixture_root/statfs-multi" \
|
||||
"$ROOT/activity-stats" --activity-storage
|
||||
)
|
||||
expected_multi_storage=$'schema\tactivity-storage\t1\nsample\t321.50\nstorage\t/\t4096000\t3072000\t819200\nstorage\t/data\t8192000\t7782400\t204800'
|
||||
[[ $multi_storage_snapshot == "$expected_multi_storage" ]] ||
|
||||
fail "activity storage output lists local volumes in stable order" "$multi_storage_snapshot"
|
||||
pass "activity storage output lists local volumes in stable order"
|
||||
|
||||
live_storage=$("$ROOT/activity-stats" --activity-storage)
|
||||
grep -Eq $'^storage\t/' <<<"$live_storage" ||
|
||||
fail "activity live storage does not include the root filesystem" "$live_storage"
|
||||
if findmnt -n -o FSTYPE /tmp >/dev/null 2>&1 && [[ $(findmnt -n -o FSTYPE /tmp) == tmpfs ]]; then
|
||||
if grep -Eq $'^storage\t/tmp\t' <<<"$live_storage"; then
|
||||
fail "activity live storage includes tmpfs" "$live_storage"
|
||||
fi
|
||||
fi
|
||||
root_source=$(findmnt -n -o SOURCE / 2>/dev/null || true)
|
||||
home_source=$(findmnt -n -o SOURCE /home 2>/dev/null || true)
|
||||
if [[ -n $home_source && $home_source == "$root_source" ]] &&
|
||||
grep -Eq $'^storage\t/home\t' <<<"$live_storage"; then
|
||||
fail "activity storage splits subvolumes of the same device" "$live_storage"
|
||||
fi
|
||||
pass "activity live storage stays on local filesystems"
|
||||
|
||||
thermal_snapshot=$(
|
||||
OMARCHY_SYSTEM_STATS_PROC_PATH="$proc_path" \
|
||||
OMARCHY_SYSTEM_STATS_SYS_PATH="$sys_path" \
|
||||
"$ROOT/activity-stats" --activity-thermals
|
||||
)
|
||||
expected_thermals=$'schema\tactivity-thermals\t1\nsample\t321.50\ntemperature\thwmon0/temp1\tcoretemp\tPackage id 0\t62500'
|
||||
[[ $thermal_snapshot == "$expected_thermals" ]] ||
|
||||
fail "activity CPU thermal output is exact and versioned" "$thermal_snapshot"
|
||||
pass "activity CPU thermal output is exact and versioned"
|
||||
|
||||
gpu_snapshot=$(
|
||||
OMARCHY_SYSTEM_STATS_PROC_PATH="$proc_path" \
|
||||
OMARCHY_SYSTEM_STATS_SYS_PATH="$sys_path" \
|
||||
OMARCHY_SYSTEM_STATS_NVIDIA_FIXTURE_PATH="$fixture_root/nvidia.tsv" \
|
||||
"$ROOT/activity-stats" --activity-gpus
|
||||
)
|
||||
grep -Fxq $'schema\tactivity-gpus\t1' <<<"$gpu_snapshot" ||
|
||||
fail "activity GPU output has its own schema"
|
||||
grep -Fxq $'gpu\t0000:00:02.0\tIntel\txe\tIntel Arc Fixture\t-1\t-1\t-1\tunknown\t600' \
|
||||
<<<"$gpu_snapshot" ||
|
||||
fail "activity GPU output reads an Intel/Xe graphics clock"
|
||||
grep -Fxq $'gpu\t0000:03:00.0\tAMD\tamdgpu\tAMD Radeon Fixture\t47\t268435456\t2147483648\tvram\t1800' \
|
||||
<<<"$gpu_snapshot" ||
|
||||
fail "activity GPU output reads AMD busy, VRAM, and graphics clock counters"
|
||||
grep -Fxq $'gpu\t0000:04:00.0\tNVIDIA\tnvidia\tNVIDIA GeForce Fixture\t62\t2147483648\t8589934592\tvram\t1950' \
|
||||
<<<"$gpu_snapshot" ||
|
||||
fail "activity GPU output reads NVIDIA utilization, VRAM, and graphics clock through persistent NVML"
|
||||
grep -Fxq $'gpu-memory\t0000:00:02.0\tshared\t100663296' <<<"$gpu_snapshot" ||
|
||||
fail "activity GPU output does not aggregate shared DRM memory"
|
||||
[[ $(grep -c $'^engine\t0000:00:02.0\t77\trcs\t' <<<"$gpu_snapshot") -eq 1 ]] ||
|
||||
fail "activity GPU output double-counts duplicated DRM file descriptors"
|
||||
[[ $(grep -c $'^engine\t0000:00:02.0\t78\trcs\t' <<<"$gpu_snapshot") -eq 1 ]] ||
|
||||
fail "activity GPU output loses a distinct DRM client"
|
||||
pass "activity GPU collector supports shared Intel, AMD, NVIDIA, and multiple adapters"
|
||||
|
||||
read_gpu_frame() {
|
||||
local output_fd="$1"
|
||||
local result_name="$2"
|
||||
local line
|
||||
local -n result="$result_name"
|
||||
result=""
|
||||
|
||||
while IFS= read -r -u "$output_fd" line; do
|
||||
[[ $line == $'snapshot-end\tgpus' ]] && return 0
|
||||
result+="$line"$'\n'
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
coproc GPU_READER {
|
||||
exec env \
|
||||
OMARCHY_SYSTEM_STATS_PROC_PATH="$proc_path" \
|
||||
OMARCHY_SYSTEM_STATS_SYS_PATH="$sys_path" \
|
||||
OMARCHY_SYSTEM_STATS_NVIDIA_FIXTURE_PATH="$fixture_root/nvidia.tsv" \
|
||||
OMARCHY_SYSTEM_STATS_GPU_DISCOVERY_INTERVAL_MS=500 \
|
||||
"$ROOT/activity-sampler" --activity-reader
|
||||
}
|
||||
gpu_reader_pid=$GPU_READER_PID
|
||||
gpu_reader_input=${GPU_READER[1]}
|
||||
gpu_reader_output=${GPU_READER[0]}
|
||||
printf 'gpus\n' >&"$gpu_reader_input"
|
||||
read_gpu_frame "$gpu_reader_output" first_gpu_frame ||
|
||||
fail "activity GPU reader stopped before its first frame"
|
||||
|
||||
mkdir -p "$proc_path/502/fd" "$proc_path/502/fdinfo"
|
||||
printf '%s\n' \
|
||||
$'drm-driver:\txe' \
|
||||
$'drm-client-id:\t79' \
|
||||
$'drm-pdev:\t0000:00:02.0' \
|
||||
$'drm-resident-gtt:\t16 MiB' \
|
||||
$'drm-cycles-rcs:\t25' \
|
||||
$'drm-total-cycles-rcs:\t1000' \
|
||||
>"$proc_path/502/fdinfo/9"
|
||||
ln -s /dev/dri/renderD128 "$proc_path/502/fd/9"
|
||||
|
||||
printf 'gpus\n' >&"$gpu_reader_input"
|
||||
read_gpu_frame "$gpu_reader_output" cached_gpu_frame ||
|
||||
fail "activity GPU reader stopped during its cached frame"
|
||||
if grep -Fq $'engine\t0000:00:02.0\t79\t' <<<"$cached_gpu_frame"; then
|
||||
fail "activity GPU reader rescanned every process file descriptor on every sample"
|
||||
fi
|
||||
|
||||
sleep 0.55
|
||||
printf 'gpus\n' >&"$gpu_reader_input"
|
||||
read_gpu_frame "$gpu_reader_output" rediscovered_gpu_frame ||
|
||||
fail "activity GPU reader stopped before its discovery refresh"
|
||||
exec {gpu_reader_input}>&-
|
||||
wait "$gpu_reader_pid"
|
||||
grep -Fq $'engine\t0000:00:02.0\t79\trcs\t25\t1000\t1\tcycles' \
|
||||
<<<"$rediscovered_gpu_frame" ||
|
||||
fail "activity GPU reader did not discover a new client after its cache interval"
|
||||
pass "activity GPU reader refreshes counters without rescanning process FDs every sample"
|
||||
|
||||
read_thermal_frame() {
|
||||
local output_fd="$1"
|
||||
local result_name="$2"
|
||||
local line
|
||||
local -n result="$result_name"
|
||||
result=""
|
||||
|
||||
while IFS= read -r -u "$output_fd" line; do
|
||||
[[ $line == $'snapshot-end\tthermals' ]] && return 0
|
||||
result+="$line"$'\n'
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
coproc THERMAL_READER {
|
||||
exec env \
|
||||
OMARCHY_SYSTEM_STATS_PROC_PATH="$proc_path" \
|
||||
OMARCHY_SYSTEM_STATS_SYS_PATH="$sys_path" \
|
||||
"$ROOT/activity-stats" --activity-reader
|
||||
}
|
||||
thermal_reader_pid=$THERMAL_READER_PID
|
||||
thermal_reader_input=${THERMAL_READER[1]}
|
||||
thermal_reader_output=${THERMAL_READER[0]}
|
||||
printf 'thermals\n' >&"$thermal_reader_input"
|
||||
read_thermal_frame "$thermal_reader_output" first_thermal_frame ||
|
||||
fail "activity thermal reader stopped before its first frame"
|
||||
grep -Fq $'temperature\thwmon0/temp1\tcoretemp\tPackage id 0\t62500' \
|
||||
<<<"$first_thermal_frame" ||
|
||||
fail "activity thermal reader did not cache its selected CPU sensor"
|
||||
|
||||
rm -- "$hwmon_path/temp1_input"
|
||||
fallback_hwmon_path="$sys_path/class/hwmon/hwmon1"
|
||||
mkdir -p "$fallback_hwmon_path"
|
||||
printf 'k10temp\n' >"$fallback_hwmon_path/name"
|
||||
printf '55000\n' >"$fallback_hwmon_path/temp1_input"
|
||||
printf 'Tctl\n' >"$fallback_hwmon_path/temp1_label"
|
||||
printf 'thermals\n' >&"$thermal_reader_input"
|
||||
read_thermal_frame "$thermal_reader_output" second_thermal_frame ||
|
||||
fail "activity thermal reader stopped before its fallback frame"
|
||||
exec {thermal_reader_input}>&-
|
||||
wait "$thermal_reader_pid"
|
||||
grep -Fq $'temperature\thwmon1/temp1\tk10temp\tTctl\t55000' \
|
||||
<<<"$second_thermal_frame" ||
|
||||
fail "activity thermal reader did not rescan after its cached sensor vanished"
|
||||
pass "activity thermal reader caches and safely invalidates its CPU sensor"
|
||||
|
||||
resource_snapshot=$(
|
||||
OMARCHY_SYSTEM_STATS_PROC_PATH="$proc_path" \
|
||||
OMARCHY_SYSTEM_STATS_SYS_PATH="$sys_path" \
|
||||
OMARCHY_SYSTEM_STATS_UDEV_DATA_PATH="$fixture_root/udev-data" \
|
||||
"$ROOT/activity-stats" --activity-resources
|
||||
)
|
||||
grep -Fxq $'schema\tactivity-resources\t1' <<<"$resource_snapshot" ||
|
||||
fail "activity resources output has its own schema"
|
||||
grep -Fxq $'memory\t1024\t512\t0\t0\t160' <<<"$resource_snapshot" ||
|
||||
fail "activity resources output includes page cache and reclaimable slabs"
|
||||
grep -Fxq $'frequency\tcpu\t2800\tMHz' <<<"$resource_snapshot" ||
|
||||
fail "activity resources output averages current CPU policy frequencies"
|
||||
grep -Fxq $'frequency\tmemory\t6400\tMT/s' <<<"$resource_snapshot" ||
|
||||
fail "activity resources output reads the configured DDR transfer rate"
|
||||
grep -Fxq $'network\teth0\t1000\t2000\tup\t1\t1' <<<"$resource_snapshot" ||
|
||||
fail "activity resources do not follow an up default route without a gateway"
|
||||
if grep -q '^temperature' <<<"$resource_snapshot"; then
|
||||
fail "activity resources output avoids detailed sensor reads"
|
||||
fi
|
||||
pass "activity resources output avoids detailed sensor reads"
|
||||
|
||||
rm -- "$proc_path/net/route"
|
||||
resource_without_route=$(
|
||||
OMARCHY_SYSTEM_STATS_PROC_PATH="$proc_path" \
|
||||
OMARCHY_SYSTEM_STATS_SYS_PATH="$sys_path" \
|
||||
OMARCHY_SYSTEM_STATS_UDEV_DATA_PATH="$fixture_root/udev-data" \
|
||||
"$ROOT/activity-stats" --activity-resources
|
||||
)
|
||||
grep -Fxq $'schema\tactivity-resources\t1' <<<"$resource_without_route" ||
|
||||
fail "activity resources fail when optional route metadata is absent"
|
||||
pass "activity resources tolerate missing optional procfs inputs"
|
||||
|
||||
process_snapshot=$(
|
||||
OMARCHY_SYSTEM_STATS_PROC_PATH="$proc_path" \
|
||||
OMARCHY_SYSTEM_STATS_PASSWD_PATH="$fixture_root/missing-passwd" \
|
||||
OMARCHY_SYSTEM_STATS_CLOCK_TICKS=100 \
|
||||
"$ROOT/activity-stats" --activity-processes
|
||||
)
|
||||
expected_process_snapshot=$'schema\tactivity-processes\t1\nsample\t321.50\t100\t160'
|
||||
[[ $process_snapshot == "$expected_process_snapshot" ]] ||
|
||||
fail "activity process output includes exact aggregate busy ticks" "$process_snapshot"
|
||||
pass "activity process output includes exact aggregate busy ticks"
|
||||
|
||||
printf 'package-0\n' >"$package_path/name"
|
||||
printf '9000000\n' >"$package_path/energy_uj"
|
||||
printf '10000000\n' >"$package_path/max_energy_range_uj"
|
||||
printf 'core\n' >"$subzone_path/name"
|
||||
printf '0\n' >"$subzone_path/enabled"
|
||||
printf '1000000\n' >"$subzone_path/energy_uj"
|
||||
printf '10000000\n' >"$subzone_path/max_energy_range_uj"
|
||||
|
||||
psys_path="$powercap_path/intel-rapl:1"
|
||||
non_package_path="$powercap_path/intel-rapl:2"
|
||||
uncore_path="$powercap_path/intel-rapl:0:1"
|
||||
dram_path="$powercap_path/intel-rapl:0:2"
|
||||
mmio_package_path="$sys_path/class/powercap/intel-rapl-mmio/intel-rapl-mmio:0"
|
||||
mkdir -p "$psys_path" "$non_package_path" "$uncore_path" "$dram_path" "$mmio_package_path"
|
||||
printf 'psys\n' >"$psys_path/name"
|
||||
printf '8000000\n' >"$psys_path/energy_uj"
|
||||
printf '10000000\n' >"$psys_path/max_energy_range_uj"
|
||||
printf 'dram\n' >"$non_package_path/name"
|
||||
printf '7000000\n' >"$non_package_path/energy_uj"
|
||||
printf '10000000\n' >"$non_package_path/max_energy_range_uj"
|
||||
printf 'uncore\n' >"$uncore_path/name"
|
||||
printf '6000000\n' >"$uncore_path/energy_uj"
|
||||
printf '10000000\n' >"$uncore_path/max_energy_range_uj"
|
||||
printf 'dram\n' >"$dram_path/name"
|
||||
printf '5000000\n' >"$dram_path/energy_uj"
|
||||
printf '10000000\n' >"$dram_path/max_energy_range_uj"
|
||||
printf 'package-0\n' >"$mmio_package_path/name"
|
||||
printf '8000000\n' >"$mmio_package_path/energy_uj"
|
||||
printf '10000000\n' >"$mmio_package_path/max_energy_range_uj"
|
||||
|
||||
process_power_snapshot=$(
|
||||
OMARCHY_SYSTEM_STATS_PROC_PATH="$proc_path" \
|
||||
OMARCHY_SYSTEM_STATS_SYS_PATH="$sys_path" \
|
||||
"$ROOT/activity-stats" --activity-process-power
|
||||
)
|
||||
expected_process_power=$'schema\tactivity-process-power\t1\nsample\t321.50\npackage\tintel-rapl:0\tpackage-0\t9000000\t10000000'
|
||||
[[ $process_power_snapshot == "$expected_process_power" ]] ||
|
||||
fail "activity process power output is exact and includes only the RAPL package domain" "$process_power_snapshot"
|
||||
pass "activity process power output is exact and includes only the RAPL package domain"
|
||||
|
||||
process_power_reader_snapshot=$(
|
||||
printf 'ignored\nsample\nsample\n' |
|
||||
OMARCHY_SYSTEM_STATS_PROC_PATH="$proc_path" \
|
||||
OMARCHY_SYSTEM_STATS_SYS_PATH="$sys_path" \
|
||||
"$ROOT/activity-stats" --activity-process-power-reader
|
||||
)
|
||||
[[ $(grep -c '^snapshot-end' <<<"$process_power_reader_snapshot") -eq 2 ]] ||
|
||||
fail "activity process power reader does not frame each requested sample"
|
||||
[[ $(grep -c '^package' <<<"$process_power_reader_snapshot") -eq 2 ]] ||
|
||||
fail "activity process power reader does not reuse its process for requested samples"
|
||||
pass "activity process power reader serves framed samples until its input closes"
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
require_command make
|
||||
require_command g++
|
||||
require_command file
|
||||
require_command readelf
|
||||
|
||||
make -C "$ROOT" -B activity-sampler >/dev/null
|
||||
|
||||
file "$ROOT/activity-sampler" | grep -Fq 'ELF 64-bit' ||
|
||||
fail "activity sampler is not a native 64-bit executable"
|
||||
[[ $(stat -c %s "$ROOT/activity-sampler") -lt 524288 ]] ||
|
||||
fail "activity sampler binary exceeds its 512 KiB size budget"
|
||||
[[ $("$ROOT/activity-sampler" --version) == 'activity-sampler 2.1.1' ]] ||
|
||||
fail "activity sampler binary does not match its source release"
|
||||
readelf -lW "$ROOT/activity-sampler" | grep -Fq 'GNU_RELRO' ||
|
||||
fail "activity sampler is missing RELRO hardening"
|
||||
readelf -dW "$ROOT/activity-sampler" | grep -Fq 'BIND_NOW' ||
|
||||
fail "activity sampler is missing immediate binding hardening"
|
||||
pass "activity sampler builds as a compact hardened native executable"
|
||||
|
||||
read_native_frame() {
|
||||
local output_fd="$1"
|
||||
local kind="$2"
|
||||
local line
|
||||
while IFS= read -r -u "$output_fd" line; do
|
||||
[[ $line == "snapshot-end"$'\t'"$kind" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
coproc NATIVE_READER { exec "$ROOT/activity-sampler" --activity-reader; }
|
||||
native_reader_pid=$NATIVE_READER_PID
|
||||
native_reader_input=${NATIVE_READER[1]}
|
||||
native_reader_output=${NATIVE_READER[0]}
|
||||
|
||||
printf 'resources\n' >&"$native_reader_input"
|
||||
read_native_frame "$native_reader_output" resources ||
|
||||
fail "native activity reader stopped during its resource warmup"
|
||||
printf 'processes\n' >&"$native_reader_input"
|
||||
read_native_frame "$native_reader_output" processes ||
|
||||
fail "native activity reader stopped during its process warmup"
|
||||
|
||||
children=$(<"/proc/$native_reader_pid/task/$native_reader_pid/children")
|
||||
[[ -z $children ]] || fail "native activity reader launched a subprocess"
|
||||
pss_kib=$(awk '/^Pss:/ { total += $2 } END { print total + 0 }' "/proc/$native_reader_pid/smaps")
|
||||
(( pss_kib < 4096 )) ||
|
||||
fail "native activity reader exceeds its 4 MiB proportional-memory budget" "PSS: ${pss_kib} KiB"
|
||||
|
||||
exec {native_reader_input}>&-
|
||||
wait "$native_reader_pid"
|
||||
pass "activity reader stays below 4 MiB PSS and launches no child collectors"
|
||||
|
||||
grep -Fq 'dlopen("libnvidia-ml.so.1"' "$ROOT/activity-sampler.cpp" &&
|
||||
grep -Fq 'next_fd_discovery_' "$ROOT/activity-sampler.cpp" &&
|
||||
grep -Fq 'std::unordered_map<pid_t, Metadata> cache_' "$ROOT/activity-sampler.cpp" ||
|
||||
fail "activity sampler does not retain its expensive discovery state"
|
||||
if grep -Eq 'nvidia-smi|popen[[:space:]]*\(|system[[:space:]]*\(|fork[[:space:]]*\(' \
|
||||
"$ROOT/activity-sampler.cpp"; then
|
||||
fail "activity sampler invokes an external collector"
|
||||
fi
|
||||
pass "activity sampler caches GPU/process discovery and keeps NVML in-process"
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
sudoers_file="$ROOT/stappmus-activity-monitor.sudoers"
|
||||
launcher="$ROOT/activity-power-reader"
|
||||
expected='%wheel ALL=(root) NOPASSWD: /usr/lib/stappmus-activity-monitor/activity-sampler --activity-process-power-reader'
|
||||
|
||||
[[ -f $sudoers_file ]] ||
|
||||
fail "activity process power installs its narrow sudoers rule"
|
||||
[[ $(<"$sudoers_file") == "$expected" ]] ||
|
||||
fail "activity process power sudoers rule permits only the exact collector command"
|
||||
[[ $(wc -l <"$sudoers_file") -eq 1 ]] ||
|
||||
fail "activity process power sudoers rule contains only one allowance"
|
||||
if command -v visudo >/dev/null 2>&1; then
|
||||
visudo -cf "$sudoers_file" >/dev/null ||
|
||||
fail "activity process power sudoers rule is not valid sudoers syntax"
|
||||
fi
|
||||
pass "activity process power sudoers rule permits only the exact collector command"
|
||||
|
||||
[[ -x $launcher ]] ||
|
||||
fail "activity process power launcher is not executable"
|
||||
grep -Fxq 'exec sudo -n /usr/lib/stappmus-activity-monitor/activity-sampler --activity-process-power-reader' "$launcher" ||
|
||||
fail "activity process power launcher does not invoke the exact guarded command"
|
||||
if grep -Eq '\$@|\$\*' "$launcher"; then
|
||||
fail "activity process power launcher forwards arbitrary command arguments"
|
||||
fi
|
||||
if "$launcher" unexpected >/dev/null 2>&1; then
|
||||
fail "activity process power launcher accepts command arguments"
|
||||
fi
|
||||
pass "activity process power launcher preserves the exact sudoers command"
|
||||
@@ -0,0 +1,513 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
victim_pid=""
|
||||
|
||||
cleanup() {
|
||||
[[ -n $victim_pid ]] || return 0
|
||||
kill -KILL "$victim_pid" 2>/dev/null || true
|
||||
wait "$victim_pid" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
start_ticks_for() {
|
||||
local pid="$1"
|
||||
local stat_line stat_tail
|
||||
local -a stat_fields
|
||||
|
||||
stat_line=$(<"/proc/$pid/stat")
|
||||
stat_tail="${stat_line##*) }"
|
||||
read -r -a stat_fields <<<"$stat_tail"
|
||||
printf '%s\n' "${stat_fields[19]}"
|
||||
}
|
||||
|
||||
assert_alive() {
|
||||
local pid="$1"
|
||||
local description="$2"
|
||||
kill -0 "$pid" 2>/dev/null || fail "$description"
|
||||
}
|
||||
|
||||
sleep 30 &
|
||||
victim_pid=$!
|
||||
victim_start=$(start_ticks_for "$victim_pid")
|
||||
|
||||
if "$ROOT/process-signal" TERM "$victim_pid" invalid 2>/dev/null; then
|
||||
fail "process action rejects malformed start ticks"
|
||||
fi
|
||||
assert_alive "$victim_pid" "process action rejects malformed start ticks"
|
||||
pass "process action rejects malformed start ticks"
|
||||
|
||||
if "$ROOT/process-signal" TERM "$victim_pid" 0 2>/dev/null; then
|
||||
fail "process action rejects zero start ticks"
|
||||
fi
|
||||
assert_alive "$victim_pid" "process action rejects zero start ticks"
|
||||
pass "process action rejects zero start ticks"
|
||||
|
||||
if "$ROOT/process-signal" TERM "$victim_pid" "$((victim_start + 1))" 2>/dev/null; then
|
||||
fail "process action rejects a stale PID identity"
|
||||
fi
|
||||
assert_alive "$victim_pid" "process action left a process alive after rejecting stale identity"
|
||||
pass "process action rejects a stale PID identity"
|
||||
|
||||
if "$ROOT/process-signal" APP_TERM "$victim_pid" "$((victim_start + 1))" 2>/dev/null; then
|
||||
fail "app action rejects a stale selected-process identity"
|
||||
fi
|
||||
assert_alive "$victim_pid" "app action left a process alive after rejecting stale identity"
|
||||
pass "app action rejects a stale selected-process identity"
|
||||
|
||||
"$ROOT/process-signal" TERM "$victim_pid" "$victim_start" >/dev/null ||
|
||||
fail "process action gracefully terminates a matching process"
|
||||
wait "$victim_pid" 2>/dev/null || true
|
||||
if kill -0 "$victim_pid" 2>/dev/null; then
|
||||
fail "process action gracefully terminates a matching process"
|
||||
fi
|
||||
victim_pid=""
|
||||
pass "process action gracefully terminates a matching process"
|
||||
|
||||
python -c '
|
||||
import time
|
||||
with open("/proc/self/comm", "w", encoding="ascii") as comm:
|
||||
comm.write("quickshell")
|
||||
time.sleep(30)
|
||||
' &
|
||||
victim_pid=$!
|
||||
for _ in {1..100}; do
|
||||
[[ $(<"/proc/$victim_pid/comm") == "quickshell" ]] && break
|
||||
sleep 0.01
|
||||
done
|
||||
[[ $(<"/proc/$victim_pid/comm") == "quickshell" ]] ||
|
||||
fail "protected desktop-session fixture did not become ready"
|
||||
victim_start=$(start_ticks_for "$victim_pid")
|
||||
protected_result=$(
|
||||
"$ROOT/process-signal" TERM "$victim_pid" "$victim_start" 2>&1
|
||||
) && fail "process action signaled a protected desktop-session process"
|
||||
[[ $protected_result == *"protected desktop-session process"* ]] ||
|
||||
fail "process action does not explain protected desktop-session rejection" "$protected_result"
|
||||
assert_alive "$victim_pid" "process action killed a protected desktop-session process"
|
||||
kill -KILL "$victim_pid"
|
||||
wait "$victim_pid" 2>/dev/null || true
|
||||
victim_pid=""
|
||||
pass "process action protects desktop-session processes in the signal helper"
|
||||
|
||||
python - "$ROOT/process-signal" <<'PY' ||
|
||||
import os
|
||||
import select
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
|
||||
helper = sys.argv[1]
|
||||
app_name = "activity-app"
|
||||
|
||||
|
||||
def start_ticks(pid):
|
||||
raw = open(f"/proc/{pid}/stat", "rb").read()
|
||||
return raw[raw.rfind(b") ") + 2:].split()[19].decode()
|
||||
|
||||
|
||||
worker_code = textwrap.dedent(
|
||||
f"""
|
||||
import time
|
||||
with open("/proc/self/comm", "w", encoding="ascii") as comm:
|
||||
comm.write({app_name!r})
|
||||
time.sleep(30)
|
||||
"""
|
||||
)
|
||||
|
||||
app_code = textwrap.dedent(
|
||||
f"""
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
with open("/proc/self/comm", "w", encoding="ascii") as comm:
|
||||
comm.write({app_name!r})
|
||||
|
||||
worker = None
|
||||
|
||||
def stop_app(_signal, _frame):
|
||||
if worker is not None and worker.poll() is None:
|
||||
worker.terminate()
|
||||
# Do not return to the supervisor loop where the worker would be
|
||||
# replaced. Immediate exit also keeps this fixture deterministic when
|
||||
# SIGTERM interrupts subprocess.wait().
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
raise SystemExit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, stop_app)
|
||||
while True:
|
||||
worker = subprocess.Popen(
|
||||
[sys.executable, "-c", {worker_code!r}],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
for _ in range(300):
|
||||
try:
|
||||
worker_name = open(
|
||||
f"/proc/{{worker.pid}}/comm", encoding="ascii"
|
||||
).read().strip()
|
||||
except FileNotFoundError:
|
||||
worker_name = ""
|
||||
if worker_name == {app_name!r}:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
else:
|
||||
raise SystemExit(1)
|
||||
print(worker.pid, flush=True)
|
||||
worker.wait()
|
||||
time.sleep(0.05)
|
||||
"""
|
||||
)
|
||||
|
||||
unrelated = subprocess.Popen(
|
||||
[sys.executable, "-c", worker_code],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
app = subprocess.Popen(
|
||||
[sys.executable, "-c", app_code],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
selected_pid = None
|
||||
selected_start = None
|
||||
|
||||
try:
|
||||
ready, _, _ = select.select([app.stdout], [], [], 3)
|
||||
if not ready:
|
||||
raise SystemExit(1)
|
||||
selected_pid = int(app.stdout.readline().strip())
|
||||
selected_start = start_ticks(selected_pid)
|
||||
result = subprocess.run(
|
||||
[helper, "APP_TERM", str(selected_pid), selected_start],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise SystemExit(1)
|
||||
fields = result.stdout.strip().split("\t")
|
||||
if fields != ["signaled", str(app.pid), "APP_TERM", "graceful"]:
|
||||
raise SystemExit(1)
|
||||
if app.wait(timeout=3) != 0:
|
||||
raise SystemExit(1)
|
||||
if app.stdout.read().strip():
|
||||
# Signaling only the selected child would make the app supervisor
|
||||
# replace it and announce another PID here.
|
||||
raise SystemExit(1)
|
||||
if unrelated.poll() is not None:
|
||||
# A same-name process outside the selected direct ancestry is not part
|
||||
# of the app action.
|
||||
raise SystemExit(1)
|
||||
|
||||
stubborn_worker_code = textwrap.dedent(
|
||||
f"""
|
||||
import ctypes
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
|
||||
with open("/proc/self/comm", "w", encoding="ascii") as comm:
|
||||
comm.write({app_name!r})
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
if libc.prctl(1, signal.SIGKILL, 0, 0, 0) != 0:
|
||||
raise OSError(ctypes.get_errno(), "prctl")
|
||||
if os.getppid() == 1:
|
||||
raise SystemExit(1)
|
||||
time.sleep(30)
|
||||
"""
|
||||
)
|
||||
stubborn_app_code = textwrap.dedent(
|
||||
f"""
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
with open("/proc/self/comm", "w", encoding="ascii") as comm:
|
||||
comm.write({app_name!r})
|
||||
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||
worker = subprocess.Popen(
|
||||
[sys.executable, "-c", {stubborn_worker_code!r}],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
for _ in range(300):
|
||||
try:
|
||||
worker_name = open(
|
||||
f"/proc/{{worker.pid}}/comm", encoding="ascii"
|
||||
).read().strip()
|
||||
except FileNotFoundError:
|
||||
worker_name = ""
|
||||
if worker_name == {app_name!r}:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
else:
|
||||
raise SystemExit(1)
|
||||
print(worker.pid, flush=True)
|
||||
while True:
|
||||
worker.wait()
|
||||
worker = subprocess.Popen(
|
||||
[sys.executable, "-c", {stubborn_worker_code!r}],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
print(worker.pid, flush=True)
|
||||
"""
|
||||
)
|
||||
app = subprocess.Popen(
|
||||
[sys.executable, "-c", stubborn_app_code],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
ready, _, _ = select.select([app.stdout], [], [], 3)
|
||||
if not ready:
|
||||
raise SystemExit(1)
|
||||
selected_pid = int(app.stdout.readline().strip())
|
||||
selected_start = start_ticks(selected_pid)
|
||||
escalation_started = time.monotonic()
|
||||
result = subprocess.run(
|
||||
[helper, "APP_TERM", str(selected_pid), selected_start],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise SystemExit(1)
|
||||
fields = result.stdout.strip().split("\t")
|
||||
if fields != ["signaled", str(app.pid), "APP_TERM", "escalated"]:
|
||||
raise SystemExit(1)
|
||||
if time.monotonic() - escalation_started < 2.8:
|
||||
raise SystemExit(1)
|
||||
if app.wait(timeout=5) != -signal.SIGKILL:
|
||||
raise SystemExit(1)
|
||||
if unrelated.poll() is not None:
|
||||
raise SystemExit(1)
|
||||
finally:
|
||||
if app.poll() is None:
|
||||
app.terminate()
|
||||
try:
|
||||
app.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
app.kill()
|
||||
app.wait()
|
||||
if selected_pid is not None:
|
||||
try:
|
||||
if start_ticks(selected_pid) == selected_start:
|
||||
os.kill(selected_pid, signal.SIGKILL)
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
pass
|
||||
if unrelated.poll() is None:
|
||||
unrelated.kill()
|
||||
unrelated.wait()
|
||||
PY
|
||||
fail "app action gracefully closes or escalates app roots without crossing boundaries"
|
||||
pass "app action gracefully closes or escalates app roots without crossing boundaries"
|
||||
|
||||
python - "$ROOT/process-signal" <<'PY' ||
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
helper = sys.argv[1]
|
||||
victim = subprocess.Popen(["sleep", "30"])
|
||||
try:
|
||||
raw = open(f"/proc/{victim.pid}/stat", "rb").read()
|
||||
start_ticks = raw[raw.rfind(b") ") + 2:].split()[19].decode()
|
||||
result = subprocess.run(
|
||||
[helper, "KILL", str(victim.pid), start_ticks],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise SystemExit(1)
|
||||
if victim.wait(timeout=3) != -signal.SIGKILL:
|
||||
raise SystemExit(1)
|
||||
finally:
|
||||
if victim.poll() is None:
|
||||
victim.kill()
|
||||
victim.wait()
|
||||
PY
|
||||
fail "process action force kills a matching disposable process"
|
||||
pass "process action force kills a matching disposable process"
|
||||
|
||||
python - "$ROOT/process-signal" <<'PY' ||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
helper = sys.argv[1]
|
||||
|
||||
|
||||
def start_ticks(pid):
|
||||
raw = open(f"/proc/{pid}/stat", "rb").read()
|
||||
return raw[raw.rfind(b") ") + 2:].split()[19].decode()
|
||||
|
||||
|
||||
ancestor_pid = os.getpid()
|
||||
ancestor_start = start_ticks(ancestor_pid)
|
||||
|
||||
# Direct parent: the helper is subprocess.run()'s child.
|
||||
result = subprocess.run(
|
||||
[helper, "TERM", str(ancestor_pid), ancestor_start],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0 or "ancestor chain" not in result.stderr:
|
||||
raise SystemExit(1)
|
||||
|
||||
# Grandparent: the forked intermediary starts the helper while its parent
|
||||
# remains the protected target.
|
||||
intermediary_pid = os.fork()
|
||||
if intermediary_pid == 0:
|
||||
nested = subprocess.run(
|
||||
[helper, "TERM", str(ancestor_pid), ancestor_start],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
os._exit(0 if nested.returncode != 0 and "ancestor chain" in nested.stderr else 1)
|
||||
|
||||
_, status = os.waitpid(intermediary_pid, 0)
|
||||
if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0:
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
fail "process action protects its helper ancestor chain"
|
||||
pass "process action protects its helper ancestor chain"
|
||||
|
||||
python - "$ROOT/process-signal" <<'PY' ||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
helper = sys.argv[1]
|
||||
zombie_pid = os.fork()
|
||||
if zombie_pid == 0:
|
||||
os._exit(0)
|
||||
|
||||
try:
|
||||
for _ in range(100):
|
||||
status = open(f"/proc/{zombie_pid}/status", encoding="ascii").read()
|
||||
if "\nState:\tZ " in "\n" + status:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
else:
|
||||
raise SystemExit(1)
|
||||
|
||||
raw = open(f"/proc/{zombie_pid}/stat", "rb").read()
|
||||
start_ticks = raw[raw.rfind(b") ") + 2:].split()[19].decode()
|
||||
result = subprocess.run(
|
||||
[helper, "TERM", str(zombie_pid), start_ticks],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0 or "zombie or dead" not in result.stderr:
|
||||
raise SystemExit(1)
|
||||
finally:
|
||||
os.waitpid(zombie_pid, 0)
|
||||
PY
|
||||
fail "process action rejects zombie processes"
|
||||
pass "process action rejects zombie processes"
|
||||
|
||||
foreign_uid_test=$(python - "$ROOT/process-signal" <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
helper = sys.argv[1]
|
||||
current_uid = os.getuid()
|
||||
target = None
|
||||
|
||||
for status_path in Path("/proc").glob("[0-9]*/status"):
|
||||
try:
|
||||
status_lines = status_path.read_text(encoding="ascii").splitlines()
|
||||
status = {
|
||||
line.partition(":")[0]: line.partition(":")[2].strip()
|
||||
for line in status_lines
|
||||
if ":" in line
|
||||
}
|
||||
pid = int(status["Pid"])
|
||||
user_ids = tuple(int(value) for value in status["Uid"].split())
|
||||
raw = (status_path.parent / "stat").read_bytes()
|
||||
fields = raw[raw.rfind(b") ") + 2:].split()
|
||||
flags = int(fields[6])
|
||||
start_ticks = int(fields[19])
|
||||
except (FileNotFoundError, KeyError, OSError, ValueError, IndexError):
|
||||
continue
|
||||
if pid > 1 and current_uid not in user_ids and not flags & 0x00200000:
|
||||
target = (pid, start_ticks)
|
||||
break
|
||||
|
||||
if target is None:
|
||||
print("skip")
|
||||
raise SystemExit(0)
|
||||
|
||||
result = subprocess.run(
|
||||
[helper, "TERM", str(target[0]), str(target[1])],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0 or "another user" not in result.stderr:
|
||||
raise SystemExit(1)
|
||||
print("checked")
|
||||
PY
|
||||
) || fail "process action validates ownership from process status"
|
||||
|
||||
if [[ $foreign_uid_test == "checked" ]]; then
|
||||
pass "process action validates ownership from process status"
|
||||
else
|
||||
pass "no foreign userspace process; skipping ownership rejection test"
|
||||
fi
|
||||
|
||||
kernel_thread_pid=""
|
||||
kernel_thread_start=""
|
||||
for stat_file in /proc/[0-9]*/stat; do
|
||||
[[ -r $stat_file ]] || continue
|
||||
stat_line=$(<"$stat_file") || continue
|
||||
stat_tail="${stat_line##*) }"
|
||||
read -r -a stat_fields <<<"$stat_tail"
|
||||
((${#stat_fields[@]} > 19)) || continue
|
||||
if ((stat_fields[6] & 0x00200000)); then
|
||||
kernel_thread_pid="${stat_file%/stat}"
|
||||
kernel_thread_pid="${kernel_thread_pid##*/}"
|
||||
kernel_thread_start="${stat_fields[19]}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n $kernel_thread_pid ]]; then
|
||||
kernel_result=$("$ROOT/process-signal" TERM "$kernel_thread_pid" "$kernel_thread_start" 2>&1) &&
|
||||
fail "process action rejects kernel threads"
|
||||
[[ $kernel_result == *"kernel thread"* ]] ||
|
||||
fail "process action rejects kernel threads" "$kernel_result"
|
||||
pass "process action rejects kernel threads"
|
||||
else
|
||||
pass "no visible kernel thread; skipping kernel-thread rejection test"
|
||||
fi
|
||||
|
||||
if "$ROOT/process-signal" TERM 1 0 2>/dev/null; then
|
||||
fail "process action protects PID 1"
|
||||
fi
|
||||
pass "process action protects PID 1"
|
||||
@@ -0,0 +1,872 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const activity = requireFromRoot('Model.js')
|
||||
|
||||
const first = activity.parseSnapshot([
|
||||
'schema\tactivity-resources\t1',
|
||||
'sample\t1000',
|
||||
'frequency\tcpu\t2400\tMHz',
|
||||
'frequency\tmemory\t6400\tMT/s',
|
||||
'cpu\tcpu\t1000\t700',
|
||||
'cpu\tcpu0\t500\t350',
|
||||
'cpu\tcpu1\t500\t350',
|
||||
'memory\t1000\t400\t200\t150\t125',
|
||||
'tasks\t2\t100',
|
||||
'network\teth0\t10000\t20000\tup\t1\t1',
|
||||
'network\ttailscale0\t500000\t600000\tup\t0\t0',
|
||||
'disk\t8:0\tsda\t100\t200',
|
||||
''
|
||||
].join('\n'))
|
||||
|
||||
assertEqual(first.sample, 1000, 'activity parses the monotonic resource sample')
|
||||
assertEqual(first.cpuFrequencyMHz, 2400, 'activity parses the average current CPU clock')
|
||||
assertEqual(first.memorySpeedMTs, 6400, 'activity parses the configured DDR transfer rate')
|
||||
assertEqual(first.cores.length, 2, 'activity parses per-core counters')
|
||||
assertEqual(first.memory.cached, 125 * 1024, 'activity parses reclaimable cache')
|
||||
assertEqual(first.memory.swapTotal, 200 * 1024, 'activity parses swap counters')
|
||||
assertEqual(
|
||||
activity.parseSnapshot('schema\tactivity-processes\t1\nsample\t1000\n').schema,
|
||||
0,
|
||||
'activity rejects a mismatched snapshot contract'
|
||||
)
|
||||
|
||||
const second = activity.parseSnapshot([
|
||||
'schema\tactivity-resources\t1',
|
||||
'sample\t1001',
|
||||
'frequency\tcpu\t2600\tMHz',
|
||||
'frequency\tmemory\t6400\tMT/s',
|
||||
'cpu\tcpu\t1200\t800',
|
||||
'cpu\tcpu0\t600\t390',
|
||||
'cpu\tcpu1\t600\t410',
|
||||
'memory\t1000\t350\t200\t100\t120',
|
||||
'tasks\t3\t110',
|
||||
'network\teth0\t12048\t21024\tup\t1\t1',
|
||||
'network\ttailscale0\t900000\t1000000\tup\t0\t0',
|
||||
'disk\t8:0\tsda\t108\t204',
|
||||
''
|
||||
].join('\n'))
|
||||
|
||||
const metrics = activity.nextMetrics(first, second, {}, 2)
|
||||
assertEqual(metrics.cpu, 50, 'activity derives CPU usage from counter deltas')
|
||||
assertEqual(metrics.memory, 65, 'activity derives memory usage')
|
||||
assertEqual(metrics.swap, 50, 'activity derives swap usage')
|
||||
assertEqual(metrics.download, 2048, 'activity derives network receive rate')
|
||||
assertEqual(metrics.upload, 1024, 'activity derives network transmit rate')
|
||||
assertEqual(metrics.diskRead, 4096, 'activity derives disk read rate')
|
||||
assertEqual(metrics.diskWrite, 2048, 'activity derives disk write rate')
|
||||
assertEqual(metrics.diskDevices.length, 1, 'activity keeps a single disk as one device')
|
||||
assertEqual(metrics.diskDevices[0].name, 'sda', 'activity names the sampled disk')
|
||||
assertEqual(activity.diskCycleItems(metrics).length, 1, 'activity does not page a single disk')
|
||||
assertEqual(metrics.cores[0].usage, 60, 'activity derives per-core usage')
|
||||
assertEqual(metrics.networkInterface, 'eth0', 'activity follows the default interface without double-counting tunnels')
|
||||
|
||||
const firstMultiDisk = activity.parseSnapshot([
|
||||
'schema\tactivity-resources\t1',
|
||||
'sample\t2000',
|
||||
'disk\t8:0\tsda\t100\t200',
|
||||
'disk\t259:0\tnvme0n1\t50\t80',
|
||||
''
|
||||
].join('\n'))
|
||||
const secondMultiDisk = activity.parseSnapshot([
|
||||
'schema\tactivity-resources\t1',
|
||||
'sample\t2001',
|
||||
'disk\t8:0\tsda\t108\t204',
|
||||
'disk\t259:0\tnvme0n1\t58\t88',
|
||||
''
|
||||
].join('\n'))
|
||||
const multiDisk = activity.nextMetrics(firstMultiDisk, secondMultiDisk, {}, 4)
|
||||
assertEqual(multiDisk.diskRead, 8192, 'activity sums read rates across disks')
|
||||
assertEqual(multiDisk.diskWrite, 6144, 'activity sums write rates across disks')
|
||||
assertEqual(multiDisk.diskDevices.length, 2, 'activity keeps per-disk rates')
|
||||
assertEqual(multiDisk.diskDevices[0].name, 'nvme0n1', 'activity sorts disks by name')
|
||||
assertEqual(multiDisk.diskDevices[0].read, 4096, 'activity derives the first disk read rate')
|
||||
assertEqual(multiDisk.diskDevices[1].write, 2048, 'activity derives the second disk write rate')
|
||||
const diskItems = activity.diskCycleItems(multiDisk)
|
||||
assertEqual(diskItems.length, 3, 'activity prepends an All slot when there are multiple disks')
|
||||
assertEqual(diskItems[0].id, 'all', 'activity starts the disk pager on the combined total')
|
||||
assertEqual(diskItems[0].read, 8192, 'activity All slot uses the combined read rate')
|
||||
assertEqual(activity.selectDiskItem(diskItems, '8:0').name, 'sda', 'activity can select a named disk')
|
||||
assertEqual(activity.cycleItemId(diskItems, 'all', 'id', 1), '259:0', 'activity cycles from All to the first named disk')
|
||||
assertEqual(activity.cycleItemId(diskItems, '8:0', 'id', 1), 'all', 'activity wraps the disk pager')
|
||||
|
||||
const hotplugDisk = activity.parseSnapshot([
|
||||
'schema\tactivity-resources\t1',
|
||||
'sample\t2002',
|
||||
'disk\t8:0\tsda\t116\t208',
|
||||
'disk\t8:16\tsdb\t10\t20',
|
||||
'disk\t259:0\tnvme0n1\t66\t96',
|
||||
''
|
||||
].join('\n'))
|
||||
const afterHotplug = activity.nextMetrics(secondMultiDisk, hotplugDisk, multiDisk, 4)
|
||||
assertEqual(afterHotplug.diskRead, 8192, 'activity keeps existing disk rates when a disk appears')
|
||||
assertEqual(afterHotplug.diskDevices.length, 3, 'activity adds a newly attached disk')
|
||||
assertEqual(
|
||||
afterHotplug.diskDevices.find(disk => disk.name === 'sdb').read,
|
||||
0,
|
||||
'activity baselines a newly attached disk at zero'
|
||||
)
|
||||
assertEqual(afterHotplug.diskHistories['259:0'].length, 2, 'activity keeps per-disk sparkline history')
|
||||
|
||||
const withHistory = activity.nextMetrics(second, second, {
|
||||
cpuHistory: [10, 20],
|
||||
memoryHistory: [30, 40]
|
||||
}, 2)
|
||||
assertDeepEqual(withHistory.cpuHistory, [20, 0], 'activity caps history buffers')
|
||||
assertEqual(activity.counterPercent({ total: 100, idle: 20 }, { total: 50, idle: 10 }), 0, 'activity handles reset CPU counters')
|
||||
assertEqual(activity.counterRate(100, 50, 1), 0, 'activity handles reset byte counters')
|
||||
|
||||
const baseline = activity.baselineMetrics(second, {
|
||||
cpu: 42,
|
||||
download: 100,
|
||||
upload: 50,
|
||||
diskRead: 25,
|
||||
diskWrite: 10,
|
||||
cpuHistory: [20, 42],
|
||||
memoryHistory: [60, 65]
|
||||
})
|
||||
assertEqual(baseline.cpu, 42, 'activity retains the last CPU rate while taking a fresh baseline')
|
||||
assertEqual(baseline.memory, 65, 'activity immediately refreshes single-sample memory usage')
|
||||
assertDeepEqual(baseline.cpuHistory, [20, 42], 'activity does not add zeroes while taking a fresh baseline')
|
||||
|
||||
const firstProcesses = activity.parseProcessSnapshot([
|
||||
'schema\tactivity-processes\t1',
|
||||
'sample\t100\t100',
|
||||
'process\t42\talice\tS\t1000\t500\t100\tcode helper',
|
||||
''
|
||||
].join('\n'))
|
||||
const secondProcesses = activity.parseProcessSnapshot([
|
||||
'schema\tactivity-processes\t1',
|
||||
'sample\t102\t100',
|
||||
'process\t42\talice\tR\t1000\t600\t100\tcode helper',
|
||||
''
|
||||
].join('\n'))
|
||||
const sampledProcesses = activity.nextProcesses(firstProcesses, secondProcesses, 1000 * 1024)
|
||||
assertEqual(sampledProcesses[0].name, 'code helper', 'activity preserves process names with spaces')
|
||||
assertEqual(sampledProcesses[0].user, 'alice', 'activity resolves process users')
|
||||
assertEqual(sampledProcesses[0].cpu, 50, 'activity derives sampled process CPU')
|
||||
assertEqual(sampledProcesses[0].memory, 10, 'activity derives process memory share')
|
||||
assertEqual(sampledProcesses[0].elapsed, 92, 'activity derives process runtime from monotonic uptime')
|
||||
const reusedPid = activity.parseProcessSnapshot(
|
||||
'schema\tactivity-processes\t1\nsample\t104\t100\nprocess\t42\talice\tR\t2000\t900\t100\tnew process\n'
|
||||
)
|
||||
assertEqual(
|
||||
activity.nextProcesses(secondProcesses, reusedPid, 1000 * 1024)[0].cpu,
|
||||
0,
|
||||
'activity does not carry CPU history across PID reuse'
|
||||
)
|
||||
|
||||
const processes = [
|
||||
{ pid: 3, name: 'firefox', user: 'alice', cpu: 4, memory: 10, elapsed: 50 },
|
||||
{ pid: 1, name: 'quickshell', user: 'alice', cpu: 8, memory: 2, elapsed: 100 },
|
||||
{ pid: 2, name: 'postgres', user: 'postgres', cpu: 1, memory: 20, elapsed: 200 }
|
||||
]
|
||||
assertDeepEqual(
|
||||
activity.filterAndSortProcesses(processes, '', 'cpu', false).map(process => process.pid),
|
||||
[1, 3, 2],
|
||||
'activity sorts processes by CPU'
|
||||
)
|
||||
assertDeepEqual(
|
||||
activity.filterAndSortProcesses(processes, 'post', 'memory', false).map(process => process.pid),
|
||||
[2],
|
||||
'activity filters processes by name and user'
|
||||
)
|
||||
assertEqual(
|
||||
activity.processIdentityKey({ pid: 42, startTicks: 1234 }),
|
||||
'42:1234',
|
||||
'activity ties process selection to PID and sampled start time'
|
||||
)
|
||||
assertEqual(
|
||||
activity.processActionBlockReason(
|
||||
{ pid: 42, startTicks: 1234, state: 'S', user: 'alice', name: 'firefox' },
|
||||
'alice'
|
||||
),
|
||||
'',
|
||||
'activity allows an actionable same-user app'
|
||||
)
|
||||
assertEqual(
|
||||
activity.processActionBlockReason(
|
||||
{ pid: 42, startTicks: 1234, state: 'S', user: 'alice', name: 'quickshell' },
|
||||
'alice'
|
||||
),
|
||||
'Desktop session is protected',
|
||||
'activity gives immediate feedback for a protected session process'
|
||||
)
|
||||
assertEqual(
|
||||
activity.processActionBlockReason(
|
||||
{ pid: 42, startTicks: 1234, state: 'S', user: 'root', name: 'worker' },
|
||||
'alice'
|
||||
),
|
||||
'Only your own processes can be ended',
|
||||
'activity gives immediate feedback for a foreign process'
|
||||
)
|
||||
assertEqual(activity.formatBytes(1536, '/s'), '1.50 KiB/s', 'activity formats byte rates')
|
||||
assertEqual(activity.formatDuration(42), '42s', 'activity formats short process runtimes')
|
||||
assertEqual(activity.formatDuration(90061), '1d 1h', 'activity formats long durations')
|
||||
assertEqual(activity.samplingProfile('Efficient').resources, 3000, 'activity offers a low-overhead sampling profile')
|
||||
assertEqual(activity.samplingProfile('Balanced').processes, 3000, 'activity keeps the balanced process cadence')
|
||||
assertEqual(activity.samplingProfile('Fast').resources, 750, 'activity offers a responsive sampling profile')
|
||||
assertEqual(activity.samplingProfile('unknown').name, 'Balanced', 'activity safely defaults unknown sampling profiles')
|
||||
assertEqual(activity.formatTemperature(20, 'Celsius'), '20°C', 'activity formats Celsius temperatures')
|
||||
assertEqual(activity.formatTemperature(20, 'Fahrenheit'), '68°F', 'activity converts temperatures to Fahrenheit')
|
||||
|
||||
function topo(id, cls, domain, cluster, core) {
|
||||
return { id, cls, domain, cluster, core, maxKhz: 0 }
|
||||
}
|
||||
function usages(count) {
|
||||
return Array.from({ length: count }, (_, i) => ({ name: 'cpu' + i, usage: i + 1 }))
|
||||
}
|
||||
const panther = []
|
||||
for (let i = 0; i < 4; i++) panther.push(topo(i, 'performance', '0-11', String(i), i))
|
||||
for (let i = 4; i < 8; i++) panther.push(topo(i, 'efficiency', '0-11', '4-7', i))
|
||||
for (let i = 8; i < 12; i++) panther.push(topo(i, 'efficiency', '0-11', '8-11', i))
|
||||
for (let i = 12; i < 16; i++) panther.push(topo(i, 'lowpower', '12-15', '12-15', i))
|
||||
const pantherDie = activity.coreLayout(usages(16), panther)
|
||||
assertEqual(pantherDie.mode, 'die', 'activity uses a specialized die for P+E+LP-E')
|
||||
assertEqual(pantherDie.domains.length, 2, 'activity separates Panther Lake cache domains')
|
||||
assertDeepEqual(pantherDie.domains[0].rows.map(row => row.kind + ':' + row.cells.length),
|
||||
['performance:4', 'efficiency:4', 'efficiency:4'],
|
||||
'activity places 4 P-cores above two E-core rows in the shared-cache square')
|
||||
assertEqual(pantherDie.domains[1].rows[0].kind, 'lowpower', 'activity keeps LP-E cores in their own square')
|
||||
assertEqual(pantherDie.domains[1].rows[0].cells.length, 4, 'activity shows four LP-E cores')
|
||||
const pantherPaint = activity.flattenCoreLayout(pantherDie, {
|
||||
gap: 2, domainGap: 4, pad: 2, performance: 9, efficiency: 6, same: 7
|
||||
})
|
||||
assertEqual(pantherPaint.frames.length, 2, 'activity paints a frame per cache domain')
|
||||
assertEqual(pantherPaint.frames[0].h, pantherPaint.frames[1].h,
|
||||
'activity keeps both cache squares the same height')
|
||||
assertEqual(pantherPaint.frames[1].w < pantherPaint.frames[0].w, true,
|
||||
'activity rotates the LP-E cache into a narrower column')
|
||||
assertEqual(pantherPaint.cells.length, 16, 'activity paints every hybrid core as a positioned cell')
|
||||
assertEqual(pantherPaint.cells[0].size, 9, 'activity draws P-cores larger than E-cores')
|
||||
assertEqual(pantherPaint.cells[4].size, 6, 'activity draws E-cores at the cluster size')
|
||||
assertEqual(pantherPaint.cells[4].kind, 'efficiency', 'activity starts the E-core rows after the P-cores')
|
||||
assertEqual(pantherPaint.cells[8].kind, 'efficiency', 'activity keeps a second E-core row in the shared-cache square')
|
||||
assertEqual(pantherPaint.cells[12].kind, 'lowpower', 'activity paints LP-E cores after the shared-cache square')
|
||||
assertEqual(pantherPaint.cells[12].x === pantherPaint.cells[15].x, true,
|
||||
'activity stacks LP-E cores in a column')
|
||||
assertEqual(pantherPaint.cells[15].y > pantherPaint.cells[12].y, true,
|
||||
'activity spreads the LP-E column down the tall cache')
|
||||
assertEqual(Math.abs(pantherPaint.cells[4].x - pantherPaint.cells[0].x) < 1, true,
|
||||
'activity does not place the leftmost E-core outside the P-core row')
|
||||
assertEqual(
|
||||
Math.abs((pantherPaint.cells[7].x + pantherPaint.cells[7].size)
|
||||
- (pantherPaint.cells[3].x + pantherPaint.cells[3].size)) < 1,
|
||||
true,
|
||||
'activity does not place the rightmost E-core outside the P-core row'
|
||||
)
|
||||
assertEqual(pantherPaint.cells[5].x > pantherPaint.cells[4].x, true,
|
||||
'activity spreads E-cores between the P-core edges')
|
||||
const lpFrame = pantherPaint.frames[1]
|
||||
const lpMidX = pantherPaint.cells[12].x + pantherPaint.cells[12].size / 2
|
||||
assertEqual(Math.abs(lpMidX - (lpFrame.x + lpFrame.w / 2)) < 1, true,
|
||||
'activity centers LP-E cores horizontally in the small cache')
|
||||
const lpTop = pantherPaint.cells[12].y - lpFrame.y
|
||||
const lpBottom = (lpFrame.y + lpFrame.h) - (pantherPaint.cells[15].y + pantherPaint.cells[15].size)
|
||||
assertEqual(Math.abs(lpTop - lpBottom) <= 1, true,
|
||||
'activity centers the LP-E column vertically in the small cache')
|
||||
const paintedAgain = activity.paintCoreLayout(
|
||||
usages(16).map(core => ({ name: core.name, usage: core.usage + 5 })),
|
||||
panther,
|
||||
{ gap: 2, domainGap: 4, pad: 2, performance: 9, efficiency: 6, same: 7 },
|
||||
'live'
|
||||
)
|
||||
const paintedFirst = activity.paintCoreLayout(
|
||||
usages(16),
|
||||
panther,
|
||||
{ gap: 2, domainGap: 4, pad: 2, performance: 9, efficiency: 6, same: 7 },
|
||||
'live'
|
||||
)
|
||||
assertEqual(paintedFirst.cells[0].x, paintedAgain.cells[0].x,
|
||||
'activity reuses die geometry when only usage changes')
|
||||
assertEqual(paintedFirst.frames, paintedAgain.frames,
|
||||
'activity keeps cache-domain frames stable across usage samples')
|
||||
assertEqual(paintedAgain.cells[3].usage > paintedFirst.cells[3].usage, true,
|
||||
'activity still refreshes per-core usage on a cached die')
|
||||
|
||||
const meteor = []
|
||||
for (let i = 0; i < 6; i++) meteor.push(topo(i, 'performance', '0-13', String(i), i))
|
||||
for (let i = 6; i < 14; i++) meteor.push(topo(i, 'efficiency', '0-13', i < 10 ? '6-9' : '10-13', i))
|
||||
for (let i = 14; i < 16; i++) meteor.push(topo(i, 'lowpower', '14-15', '14-15', i))
|
||||
const meteorDie = activity.coreLayout(usages(16), meteor)
|
||||
assertEqual(meteorDie.domains[0].rows[0].cells.length, 6, 'activity keeps six Meteor Lake P-cores in one row')
|
||||
assertEqual(meteorDie.domains[1].rows[0].cells.length, 2, 'activity isolates Meteor Lake LP-E cores')
|
||||
const meteorPaint = activity.flattenCoreLayout(meteorDie, {
|
||||
gap: 2, domainGap: 4, pad: 2, performance: 9, efficiency: 6, same: 7
|
||||
})
|
||||
const meteorE = meteorPaint.cells.filter(cell => cell.kind === 'efficiency')
|
||||
assertEqual(meteorE.length, 8, 'activity paints eight Meteor Lake E-cores')
|
||||
assertEqual(meteorE.every(cell => cell.y === meteorE[0].y), true,
|
||||
'activity keeps Meteor Lake E-cores on one row under six P-cores')
|
||||
|
||||
const alder = []
|
||||
for (let i = 0; i < 16; i += 2) alder.push(topo(i, 'performance', '0-23', String(i), i), topo(i + 1, 'performance', '0-23', String(i), i))
|
||||
for (let i = 16; i < 20; i++) alder.push(topo(i, 'efficiency', '0-23', '16-19', i))
|
||||
for (let i = 20; i < 24; i++) alder.push(topo(i, 'efficiency', '0-23', '20-23', i))
|
||||
const alderDie = activity.coreLayout(usages(24), alder)
|
||||
assertEqual(alderDie.mode, 'die', 'activity specializes Alder Lake P+E')
|
||||
assertEqual(alderDie.domains.length, 1, 'activity keeps a single Alder Lake cache domain together')
|
||||
assertEqual(alderDie.domains[0].rows[0].cells.length, 8, 'activity collapses SMT siblings into one P-core cell')
|
||||
assertEqual(alderDie.domains[0].rows[0].cells[0].logicals.length, 2, 'activity tracks both threads inside a P-core cell')
|
||||
const alderPaint = activity.flattenCoreLayout(alderDie, {
|
||||
gap: 2, domainGap: 4, pad: 2, performance: 9, efficiency: 6, same: 7
|
||||
})
|
||||
const alderE = alderPaint.cells.filter(cell => cell.kind === 'efficiency')
|
||||
assertEqual(alderE.length, 8, 'activity paints eight Alder Lake E-cores')
|
||||
assertEqual(alderE.every(cell => cell.y === alderE[0].y), true,
|
||||
'activity keeps Alder Lake E-cores on one row under eight P-cores')
|
||||
|
||||
const raptor = []
|
||||
for (let i = 0; i < 6; i++) raptor.push(topo(i, 'performance', '0-13', String(i), i))
|
||||
for (let i = 6; i < 14; i++) raptor.push(topo(i, 'efficiency', '0-13', i < 10 ? '6-9' : '10-13', i))
|
||||
const raptorPaint = activity.flattenCoreLayout(activity.coreLayout(usages(14), raptor), {
|
||||
gap: 2, domainGap: 4, pad: 2, performance: 9, efficiency: 6, same: 7
|
||||
})
|
||||
const raptorE = raptorPaint.cells.filter(cell => cell.kind === 'efficiency')
|
||||
assertEqual(raptorE.length, 8, 'activity paints eight Raptor Lake E-cores')
|
||||
assertEqual(raptorE.every(cell => cell.y === raptorE[0].y), true,
|
||||
'activity keeps Raptor Lake E-cores on one row under six P-cores')
|
||||
|
||||
const lunar = []
|
||||
for (let i = 0; i < 4; i++) lunar.push(topo(i, 'performance', '0-3', String(i), i))
|
||||
for (let i = 4; i < 8; i++) lunar.push(topo(i, 'lowpower', '4-7', '4-7', i))
|
||||
const lunarDie = activity.coreLayout(usages(8), lunar)
|
||||
assertEqual(lunarDie.domains.length, 2, 'activity splits Lunar Lake P-cores from LP-E')
|
||||
assertEqual(lunarDie.domains[0].rows[0].kind, 'performance', 'activity shows Lunar Lake P-cores as the primary square')
|
||||
const lunarPaint = activity.flattenCoreLayout(lunarDie, {
|
||||
gap: 2, domainGap: 4, pad: 2, performance: 9, efficiency: 6, same: 7
|
||||
})
|
||||
assertEqual(lunarPaint.frames[1].y > lunarPaint.frames[0].y, true,
|
||||
'activity stacks Lunar Lake cache squares instead of placing them in a row')
|
||||
assertEqual(lunarPaint.frames[1].x >= lunarPaint.frames[0].x, true,
|
||||
'activity centers the narrower Lunar Lake LP-E square under the P-cores')
|
||||
const lunarP = lunarPaint.cells.filter(cell => cell.kind === 'performance')
|
||||
const lunarLp = lunarPaint.cells.filter(cell => cell.kind === 'lowpower')
|
||||
assertEqual(lunarP.every(cell => cell.y === lunarP[0].y), true,
|
||||
'activity keeps Lunar Lake P-cores on one row')
|
||||
assertEqual(lunarLp.every(cell => cell.y === lunarLp[0].y), true,
|
||||
'activity keeps Lunar Lake LP-E cores on one row')
|
||||
assertEqual(lunarLp[0].y > lunarP[0].y, true,
|
||||
'activity places Lunar Lake LP-E under the P-cores')
|
||||
assertEqual(lunarPaint.width < lunarPaint.frames[0].w + lunarPaint.frames[1].w,
|
||||
true, 'activity stacks Lunar Lake caches to take less width')
|
||||
|
||||
const strix = []
|
||||
for (let i = 0; i < 4; i++) strix.push(topo(i, 'performance', '0-3', '0-3', i))
|
||||
for (let i = 4; i < 12; i++) strix.push(topo(i, 'efficiency', '4-11', '4-11', i))
|
||||
const strixDie = activity.coreLayout(usages(12), strix)
|
||||
assertEqual(strixDie.domains.length, 2, 'activity splits Strix Point Zen 5 and Zen 5c CCX squares')
|
||||
assertEqual(strixDie.domains[0].rows[0].cells.length, 4, 'activity shows four Zen 5 cores in the larger square')
|
||||
assertEqual(strixDie.domains[1].rows.length, 2, 'activity keeps Zen 5c cores as two rows in their CCX')
|
||||
assertEqual(strixDie.domains[1].rows[0].cells.length, 4, 'activity shows four Zen 5c cores per row')
|
||||
const strixPaint = activity.flattenCoreLayout(strixDie, {
|
||||
gap: 2, domainGap: 4, pad: 2, performance: 9, efficiency: 6, same: 7
|
||||
})
|
||||
assertEqual(strixPaint.frames[1].y > strixPaint.frames[0].y, true,
|
||||
'activity stacks Strix Point CCX squares instead of placing them in a row')
|
||||
const strixP = strixPaint.cells.filter(cell => cell.kind === 'performance')
|
||||
const strixE = strixPaint.cells.filter(cell => cell.kind === 'efficiency')
|
||||
assertEqual(strixE[0].y > strixP[0].y, true,
|
||||
'activity places Strix Point Zen 5c under the Zen 5 cores')
|
||||
assertEqual(strixPaint.width < strixPaint.frames[0].w + strixPaint.frames[1].w,
|
||||
true, 'activity stacks Strix Point caches to take less width')
|
||||
|
||||
const arm = []
|
||||
for (let i = 0; i < 1; i++) arm.push(topo(i, 'performance', '0-7', '0', i))
|
||||
for (let i = 1; i < 4; i++) arm.push(topo(i, 'efficiency', '0-7', '1-3', i))
|
||||
for (let i = 4; i < 8; i++) arm.push(topo(i, 'lowpower', '0-7', '4-7', i))
|
||||
const armDie = activity.coreLayout(usages(8), arm)
|
||||
assertEqual(armDie.domains.length, 1, 'activity keeps a shared-DSU ARM cluster in one square')
|
||||
assertDeepEqual(armDie.domains[0].rows.map(row => row.kind + ':' + row.cells.length),
|
||||
['performance:1', 'efficiency:3', 'lowpower:4'],
|
||||
'activity stacks ARM big.LITTLE tiers by class')
|
||||
const armPaint = activity.flattenCoreLayout(armDie, {
|
||||
gap: 2, domainGap: 4, pad: 2, performance: 9, efficiency: 6, same: 7
|
||||
})
|
||||
const armP = armPaint.cells.filter(cell => cell.kind === 'performance')
|
||||
const armE = armPaint.cells.filter(cell => cell.kind === 'efficiency')
|
||||
const armLp = armPaint.cells.filter(cell => cell.kind === 'lowpower')
|
||||
assertEqual(armP.length, 1, 'activity paints one ARM big core')
|
||||
assertEqual(armE.length, 3, 'activity paints three ARM middle cores')
|
||||
assertEqual(armLp.length, 4, 'activity paints four ARM little cores')
|
||||
assertEqual(armE.every(cell => cell.y === armE[0].y), true, 'activity keeps ARM middle cores on one row')
|
||||
assertEqual(armLp.every(cell => cell.y === armLp[0].y), true, 'activity keeps ARM little cores on one row')
|
||||
assertEqual(armE[0].y > armP[0].y, true, 'activity stacks ARM tiers with the big core on top')
|
||||
assertEqual(armLp[0].y > armE[0].y, true, 'activity stacks ARM little cores under the middle row')
|
||||
const armMid = armPaint.frames[0].x + armPaint.frames[0].w / 2
|
||||
assertEqual(Math.abs(armP[0].x + armP[0].size / 2 - armMid) < 1, true,
|
||||
'activity centers the ARM big core over the shared DSU')
|
||||
assertEqual(Math.abs((armLp[0].x + armLp[3].x + armLp[3].size) / 2 - armMid) < 1, true,
|
||||
'activity centers the ARM little-core row in the shared DSU')
|
||||
|
||||
const uniform = Array.from({ length: 16 }, (_, i) => topo(i, 'performance', '0-15', String(i), i))
|
||||
assertEqual(activity.coreLayout(usages(16), uniform).mode, 'uniform',
|
||||
'activity keeps a homogeneous desktop CPU on the regular grid')
|
||||
assertEqual(activity.coreLayout(usages(8), []).mode, 'uniform',
|
||||
'activity falls back to the regular grid without topology')
|
||||
const catalog = activity.previewCoreCatalog({
|
||||
gap: 2, domainGap: 4, pad: 2, performance: 9, efficiency: 6, same: 7
|
||||
})
|
||||
assertEqual(catalog.length >= 8, true, 'activity offers a review catalog of CPU layouts')
|
||||
assertDeepEqual(catalog.map(entry => entry.id), [
|
||||
'panther-16', 'panther-8', 'meteor-16', 'alder-24', 'raptor-14',
|
||||
'strix-12', 'arm-8', 'oryon-12', 'desktop-16'
|
||||
], 'activity previews the researched hybrid and homogeneous layouts')
|
||||
assertEqual(catalog[0].paint.frames.length, 2, 'activity catalog paints the Panther Lake die')
|
||||
assertEqual(catalog[7].mode, 'uniform', 'activity catalog keeps Snapdragon X Elite on the regular grid')
|
||||
|
||||
const parsedTopo = activity.parseSnapshot([
|
||||
'schema\tactivity-resources\t1',
|
||||
'sample\t1',
|
||||
'cpu-topo\t0\tperformance\t0-11\t0\t0\t4800000',
|
||||
'cpu-topo\t4\tefficiency\t0-11\t4-7\t4\t3700000',
|
||||
'cpu-topo\t12\tlowpower\t12-15\t12-15\t12\t3300000',
|
||||
''
|
||||
].join('\n'))
|
||||
assertEqual(parsedTopo.topology.length, 3, 'activity parses cpu-topo lines into the resource snapshot')
|
||||
assertEqual(parsedTopo.topology[2].cls, 'lowpower', 'activity preserves LP-E classification')
|
||||
|
||||
const manifest = requireFromRoot('manifest.json')
|
||||
const settingKeys = manifest.barWidget.schema.map(entry => entry.key)
|
||||
assertDeepEqual(
|
||||
settingKeys,
|
||||
['samplingSpeed', 'historySamples', 'temperatureUnit', 'showFrequencies', 'openExpanded', 'processPowerEnabled'],
|
||||
'activity publishes the same focused preferences offered by its settings menu'
|
||||
)
|
||||
JS
|
||||
|
||||
snapshot=$("$ROOT/activity-stats" --activity-resources)
|
||||
grep -Eq '^schema[[:space:]]+activity-resources[[:space:]]+1$' <<<"$snapshot" || fail "activity collector emits its schema"
|
||||
grep -Eq '^sample[[:space:]][0-9]+([.][0-9]+)?$' <<<"$snapshot" || fail "activity collector emits a monotonic sample"
|
||||
grep -Eq '^cpu[[:space:]]+cpu[[:space:]]' <<<"$snapshot" || fail "activity collector emits CPU counters"
|
||||
grep -Eq '^memory[[:space:]]' <<<"$snapshot" || fail "activity collector emits memory counters"
|
||||
grep -Eq '^network[[:space:]]' <<<"$snapshot" || fail "activity collector emits network counters"
|
||||
grep -Eq '^disk[[:space:]]' <<<"$snapshot" || fail "activity collector emits disk counters"
|
||||
pass "activity collector emits the resource snapshot contract"
|
||||
|
||||
write_cpu_topo() {
|
||||
local sys="$1" id="$2" core="$3" cluster="$4" l2="$5" l3="$6" khz="$7"
|
||||
local cpu="$sys/devices/system/cpu/cpu$id"
|
||||
mkdir -p "$cpu/topology" "$cpu/cache/index2" "$cpu/cpufreq"
|
||||
printf '%s\n' "$core" >"$cpu/topology/core_id"
|
||||
printf '%s\n' "$cluster" >"$cpu/topology/cluster_cpus_list"
|
||||
printf '2\n' >"$cpu/cache/index2/level"
|
||||
printf 'Unified\n' >"$cpu/cache/index2/type"
|
||||
printf '%s\n' "$l2" >"$cpu/cache/index2/shared_cpu_list"
|
||||
if [[ -n $l3 ]]; then
|
||||
mkdir -p "$cpu/cache/index3"
|
||||
printf '3\n' >"$cpu/cache/index3/level"
|
||||
printf 'Unified\n' >"$cpu/cache/index3/type"
|
||||
printf '%s\n' "$l3" >"$cpu/cache/index3/shared_cpu_list"
|
||||
fi
|
||||
printf '%s\n' "$khz" >"$cpu/cpufreq/cpuinfo_max_freq"
|
||||
}
|
||||
|
||||
topo_root=$(mktemp -d)
|
||||
mkdir -p "$topo_root/sys/devices/cpu_core" "$topo_root/sys/devices/cpu_atom"
|
||||
printf '0-3\n' >"$topo_root/sys/devices/cpu_core/cpus"
|
||||
printf '4-15\n' >"$topo_root/sys/devices/cpu_atom/cpus"
|
||||
for id in 0 1 2 3; do
|
||||
write_cpu_topo "$topo_root/sys" "$id" "$id" "$id" "$id" "0-11" 4800000
|
||||
done
|
||||
for id in 4 5 6 7; do
|
||||
write_cpu_topo "$topo_root/sys" "$id" "$id" "4-7" "4-7" "0-11" 3700000
|
||||
done
|
||||
for id in 8 9 10 11; do
|
||||
write_cpu_topo "$topo_root/sys" "$id" "$id" "8-11" "8-11" "0-11" 3700000
|
||||
done
|
||||
for id in 12 13 14 15; do
|
||||
write_cpu_topo "$topo_root/sys" "$id" "$id" "12-15" "12-15" "" 3300000
|
||||
done
|
||||
topo_snapshot=$(
|
||||
OMARCHY_SYSTEM_STATS_SYS_PATH="$topo_root/sys" \
|
||||
"$ROOT/activity-stats" --activity-resources
|
||||
)
|
||||
rm -rf -- "$topo_root"
|
||||
[[ $(grep -c $'^cpu-topo\t' <<<"$topo_snapshot") -eq 16 ]] ||
|
||||
fail "activity collector does not emit a topology row for every logical CPU"
|
||||
[[ $(grep -c $'^cpu-topo\t[0-9]\tperformance\t0-11\t' <<<"$topo_snapshot") -eq 4 ]] ||
|
||||
fail "activity collector does not classify Intel P-cores"
|
||||
[[ $(grep -c $'\tefficiency\t0-11\t' <<<"$topo_snapshot") -eq 8 ]] ||
|
||||
fail "activity collector does not classify Intel E-cores that share the P-core L3"
|
||||
[[ $(grep -c $'\tlowpower\t12-15\t' <<<"$topo_snapshot") -eq 4 ]] ||
|
||||
fail "activity collector does not isolate LP-E cores without that L3"
|
||||
pass "activity collector classifies hybrid CPU cache domains"
|
||||
|
||||
gpu_snapshot=$("$ROOT/activity-stats" --activity-gpus)
|
||||
grep -Eq '^schema[[:space:]]+activity-gpus[[:space:]]+1$' <<<"$gpu_snapshot" ||
|
||||
fail "activity GPU collector emits its schema"
|
||||
grep -Eq '^sample[[:space:]][0-9]+([.][0-9]+)?$' <<<"$gpu_snapshot" ||
|
||||
fail "activity GPU collector emits a monotonic sample"
|
||||
pass "activity collector emits the independent GPU snapshot contract"
|
||||
|
||||
process_snapshot=$("$ROOT/activity-stats" --activity-processes)
|
||||
grep -Eq '^schema[[:space:]]+activity-processes[[:space:]]+1$' <<<"$process_snapshot" || fail "activity process collector emits its schema"
|
||||
grep -Eq '^sample[[:space:]][0-9]+([.][0-9]+)?[[:space:]][0-9]+[[:space:]][0-9]+$' <<<"$process_snapshot" ||
|
||||
fail "activity process collector emits its sample and CPU clocks"
|
||||
grep -Eq '^process[[:space:]]' <<<"$process_snapshot" || fail "activity process collector emits process rows"
|
||||
pass "activity collector emits the process snapshot contract"
|
||||
|
||||
[[ -x $ROOT/activity-sampler ]] || fail "native activity sampler is executable"
|
||||
[[ $("$ROOT/activity-sampler" --version) == "activity-sampler 2.1.1" ]] ||
|
||||
fail "native activity sampler reports the release protocol version"
|
||||
[[ ! -e $ROOT/activity-process-stats ]] ||
|
||||
fail "legacy process helper remains beside the unified native sampler"
|
||||
pass "activity ships one native sampler instead of a process-helper chain"
|
||||
|
||||
reader_snapshot=$(
|
||||
printf 'resources\nprocesses\nprocesses\nthermals\ngpus\nstorage\n' |
|
||||
"$ROOT/activity-stats" --activity-reader
|
||||
)
|
||||
[[ $(grep -c '^snapshot-end' <<<"$reader_snapshot") -eq 6 ]] ||
|
||||
fail "activity reader does not frame every requested snapshot"
|
||||
grep -Fq $'snapshot-end\tresources' <<<"$reader_snapshot" ||
|
||||
fail "activity reader does not frame resource snapshots"
|
||||
grep -Fq $'snapshot-end\tprocesses' <<<"$reader_snapshot" ||
|
||||
fail "activity reader does not frame process snapshots"
|
||||
[[ $(grep -c $'^snapshot-end\tprocesses$' <<<"$reader_snapshot") -eq 2 ]] ||
|
||||
fail "activity reader does not serve repeated process snapshots"
|
||||
grep -Fq $'snapshot-end\tthermals' <<<"$reader_snapshot" ||
|
||||
fail "activity reader does not frame thermal snapshots"
|
||||
grep -Fq $'snapshot-end\tgpus' <<<"$reader_snapshot" ||
|
||||
fail "activity reader does not frame GPU snapshots"
|
||||
grep -Fq $'snapshot-end\tstorage' <<<"$reader_snapshot" ||
|
||||
fail "activity reader does not frame storage snapshots"
|
||||
pass "activity reader serves independently requested snapshots in one native process"
|
||||
|
||||
coproc SELF_READER { exec "$ROOT/activity-stats" --activity-reader; }
|
||||
self_reader_pid=$SELF_READER_PID
|
||||
self_reader_input=${SELF_READER[1]}
|
||||
self_reader_output=${SELF_READER[0]}
|
||||
self_reader_snapshot=""
|
||||
printf 'processes\n' >&"$self_reader_input"
|
||||
while IFS= read -r -u "$self_reader_output" line; do
|
||||
[[ $line == $'snapshot-end\tprocesses' ]] && break
|
||||
self_reader_snapshot+="$line"$'\n'
|
||||
done
|
||||
process_children=$(<"/proc/$self_reader_pid/task/$self_reader_pid/children")
|
||||
[[ -z $process_children ]] ||
|
||||
fail "native activity reader spawned a child collector"
|
||||
|
||||
printf 'processes\n' >&"$self_reader_input"
|
||||
while IFS= read -r -u "$self_reader_output" line; do
|
||||
[[ $line == $'snapshot-end\tprocesses' ]] && break
|
||||
done
|
||||
process_children=$(<"/proc/$self_reader_pid/task/$self_reader_pid/children")
|
||||
[[ -z $process_children ]] ||
|
||||
fail "native activity reader spawned a child between process snapshots"
|
||||
|
||||
exec {self_reader_input}>&-
|
||||
wait "$self_reader_pid"
|
||||
pass "activity reader keeps all unprivileged sampling in one native process"
|
||||
|
||||
if grep -Fq $'process\t'"$self_reader_pid"$'\t' <<<"$self_reader_snapshot"; then
|
||||
fail "activity reader reports its own sampling process as workload"
|
||||
fi
|
||||
pass "activity reader excludes its own sampling process"
|
||||
|
||||
fixture_root=$(mktemp -d)
|
||||
trap 'rm -rf -- "$fixture_root"' EXIT
|
||||
|
||||
mkdir -p "$fixture_root/proc/101" "$fixture_root/proc/102" "$fixture_root/proc/103"
|
||||
printf '200.00 100.00\n' >"$fixture_root/proc/uptime"
|
||||
printf '%s\n' \
|
||||
'101 (worker task) R 1 1 1 0 -1 0 0 0 0 0 12 8 0 0 20 0 3 0 1000 4096000 25' \
|
||||
>"$fixture_root/proc/101/stat"
|
||||
printf 'Name:\tworker task\nUid:\t1000\t1000\t1000\t1000\nVmRSS:\t120 kB\n' \
|
||||
>"$fixture_root/proc/101/status"
|
||||
printf '%s\n' \
|
||||
'103 (kernel worker) I 2 0 0 0 0 2097152 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0' \
|
||||
>"$fixture_root/proc/103/stat"
|
||||
printf 'alice:x:1000:1000:Alice:/home/alice:/bin/bash\n' >"$fixture_root/passwd"
|
||||
|
||||
fixture_process_snapshot=$(
|
||||
OMARCHY_SYSTEM_STATS_PROC_PATH="$fixture_root/proc" \
|
||||
OMARCHY_SYSTEM_STATS_PASSWD_PATH="$fixture_root/passwd" \
|
||||
OMARCHY_SYSTEM_STATS_CLOCK_TICKS=100 \
|
||||
OMARCHY_SYSTEM_STATS_PAGE_SIZE=4096 \
|
||||
"$ROOT/activity-stats" --activity-processes
|
||||
)
|
||||
grep -Fq $'process\t101\talice\tR\t1000\t20\t100\tworker task' \
|
||||
<<<"$fixture_process_snapshot" || fail "activity process collector reads a stable fixture process"
|
||||
[[ $(grep -c '^process' <<<"$fixture_process_snapshot") -eq 1 ]] ||
|
||||
fail "activity process collector did not skip a vanished fixture PID"
|
||||
pass "activity process collector skips PIDs that vanish during a snapshot"
|
||||
|
||||
if grep -Fq $'process\t103\t' <<<"$fixture_process_snapshot"; then
|
||||
fail "activity process collector included a kernel thread"
|
||||
fi
|
||||
pass "activity process collector omits non-actionable kernel threads"
|
||||
|
||||
grep -Fq 'centerOnBar: false' "$ROOT/Panel.qml" ||
|
||||
fail "activity panel keeps compact and expanded layouts on the same bar anchor"
|
||||
pass "activity panel expansion preserves its bar anchor"
|
||||
|
||||
grep -Fq 'implicitWidth: button.implicitWidth' "$ROOT/Panel.qml" ||
|
||||
fail "activity bar widget does not expose its icon width"
|
||||
grep -Fq 'implicitHeight: button.implicitHeight' "$ROOT/Panel.qml" ||
|
||||
fail "activity bar widget does not expose its icon height"
|
||||
pass "activity bar widget exposes its icon geometry"
|
||||
|
||||
grep -Fq 'id: headerActions' "$ROOT/Panel.qml" ||
|
||||
fail "activity header actions do not share a header row"
|
||||
grep -A3 -F 'id: headerActions' "$ROOT/Panel.qml" |
|
||||
grep -Fq 'anchors.verticalCenter: parent.verticalCenter' ||
|
||||
fail "activity header controls are not vertically centered together"
|
||||
pass "activity header actions stay aligned"
|
||||
|
||||
panel_file="$ROOT/Panel.qml"
|
||||
controller_file="$ROOT/ActivityController.qml"
|
||||
action_controller_file="$ROOT/ProcessActionController.qml"
|
||||
if grep -Eq 'label: "LOAD"|detail: .*"LOAD ' "$panel_file"; then
|
||||
fail "activity panel exposes the Linux load average"
|
||||
fi
|
||||
if grep -Fq 'LOGICAL CPUS' "$panel_file"; then
|
||||
fail "activity panel still advertises logical CPU count as card filler"
|
||||
fi
|
||||
grep -Fq 'function cpuCardDetail()' "$panel_file" &&
|
||||
grep -Fq 'Model.formatTemperature(cpuTemperature.value, temperatureUnitSetting)' "$panel_file" ||
|
||||
fail "activity CPU card does not own the CPU temperature"
|
||||
if grep -Fq 'component TemperatureBadge' "$panel_file"; then
|
||||
fail "activity temperature still lives in the header"
|
||||
fi
|
||||
pass "activity keeps CPU temperature on the CPU card"
|
||||
|
||||
grep -Fq 'label: root.storageCardLabel()' "$panel_file" ||
|
||||
fail "activity expanded details do not show disk storage"
|
||||
grep -Fq 'label: root.gpuHeadingText()' "$panel_file" &&
|
||||
grep -Fq 'gpuUsageText(adapters[0])' "$panel_file" ||
|
||||
fail "activity expanded details do not show GPU adapters"
|
||||
grep -Fq 'return "Shared"' "$panel_file" ||
|
||||
fail "activity panel does not distinguish integrated shared GPU memory"
|
||||
grep -Fq 'return "VRAM"' "$panel_file" ||
|
||||
fail "activity panel does not label dedicated GPU memory"
|
||||
grep -Fq 'function storageCardValue()' "$panel_file" &&
|
||||
grep -Fq 'function storageCardDetail()' "$panel_file" &&
|
||||
grep -Fq '" free"' "$panel_file" ||
|
||||
fail "activity storage card does not show used storage and free space"
|
||||
grep -Fq 'function cycleDisk(' "$panel_file" &&
|
||||
grep -Fq 'function cycleStorage(' "$panel_file" &&
|
||||
grep -Fq 'interactive: root.canCycleDisks' "$panel_file" &&
|
||||
grep -Fq 'interactive: root.canCycleStorage' "$panel_file" &&
|
||||
grep -Fq 'raw === "D" ? -1 : 1' "$panel_file" &&
|
||||
grep -Fq 'raw === "V" ? -1 : 1' "$panel_file" &&
|
||||
grep -Fq 'd/v cycle drives' "$panel_file" ||
|
||||
fail "activity does not page Disk and Storage with click and d/v"
|
||||
grep -Fq 'resetDriveSelection()' "$panel_file" ||
|
||||
fail "activity does not reset drive selection when the panel closes"
|
||||
if grep -Fq 'POWER & THERMALS' "$panel_file"; then
|
||||
fail "activity expanded details still show power and thermals"
|
||||
fi
|
||||
if grep -Fq 'label: "RAM TOTAL"' "$panel_file" || grep -Fq 'label: "CORES"' "$panel_file"; then
|
||||
fail "activity GPU card is still padded with unrelated system facts"
|
||||
fi
|
||||
pass "activity shows GPU and storage without filler metrics"
|
||||
|
||||
grep -Fq 'ActivityController {' "$panel_file" &&
|
||||
grep -Fq 'active: root.opened' "$panel_file" &&
|
||||
grep -Fq 'expanded: root.expanded' "$panel_file" ||
|
||||
fail "activity panel does not delegate sampling lifecycle to its controller"
|
||||
grep -Fq 'readonly property var snapshot: _snapshot' "$controller_file" &&
|
||||
grep -Fq 'readonly property var processes: _processes' "$controller_file" ||
|
||||
fail "activity controller does not expose read-only sampled state"
|
||||
if grep -Eq '^[[:space:]]*Process[[:space:]]*[{]' "$panel_file"; then
|
||||
fail "activity visual panel still owns a helper process"
|
||||
fi
|
||||
grep -Fq 'command: [root.statsHelperPath, "--activity-reader"]' "$controller_file" ||
|
||||
fail "activity panel does not reuse a single unprivileged stats reader"
|
||||
grep -Fq 'String(Qt.resolvedUrl("activity-sampler"))' "$controller_file" ||
|
||||
fail "activity panel does not launch the native sampler directly"
|
||||
grep -Fq '? [powerHelperPath]' "$controller_file" ||
|
||||
fail "activity panel does not use the persistent guarded CPU-energy reader"
|
||||
grep -Fq 'if (!active || !expanded || !processPowerEnabled' "$controller_file" &&
|
||||
grep -Fq '|| processPowerUnavailable || _processPowerSamplePending) return' "$controller_file" ||
|
||||
fail "activity panel samples process power outside the advanced view"
|
||||
grep -Fq 'onExpandedChanged:' "$controller_file" &&
|
||||
grep -Fq 'resetProcessPowerMeasurement()' "$controller_file" ||
|
||||
fail "activity panel carries an energy baseline across collapsed time"
|
||||
grep -Fq 'root.markProcessPowerUnavailable()' "$controller_file" ||
|
||||
fail "activity panel does not clear stale watts after an energy read failure"
|
||||
grep -Fq 'refreshProcessCycle()' "$controller_file" ||
|
||||
fail "activity panel does not coordinate energy and process refreshes"
|
||||
grep -Fq 'id: deadlineScheduler' "$controller_file" &&
|
||||
grep -Fq 'function collectDueSnapshots()' "$controller_file" &&
|
||||
grep -Fq '_resourceDue = nextDeadline(_resourceDue, refreshInterval, now)' "$controller_file" &&
|
||||
grep -Fq '_processDue = nextDeadline(_processDue, processRefreshInterval, now)' "$controller_file" &&
|
||||
grep -Fq '_thermalDue = nextDeadline(_thermalDue, thermalRefreshInterval, now)' "$controller_file" &&
|
||||
grep -Fq '_gpuDue = nextDeadline(_gpuDue, gpuRefreshInterval, now)' "$controller_file" &&
|
||||
grep -Fq '_storageDue = nextDeadline(_storageDue, storageRefreshInterval, now)' "$controller_file" ||
|
||||
fail "activity panel does not coalesce its independent deadlines"
|
||||
if grep -Fq 'repeat: true' "$controller_file"; then
|
||||
fail "activity panel still wakes separate recurring timers"
|
||||
fi
|
||||
grep -Fq 'id: statsReaderWatchdog' "$controller_file" &&
|
||||
grep -Fq 'id: processPowerWatchdog' "$controller_file" ||
|
||||
fail "activity readers can remain stuck indefinitely"
|
||||
grep -Fq 'powerStateSwitchingRoot: "switching-root"' "$controller_file" &&
|
||||
grep -Fq 'readonly property string processPowerState: _processPowerState' "$controller_file" ||
|
||||
fail "activity power reader lifecycle is not represented by an explicit state"
|
||||
if grep -Eq 'processPowerRestartWithSudo|processPowerUseSudo|processPowerReaderReady[[:space:]]*:' "$controller_file"; then
|
||||
fail "activity power reader still uses overlapping lifecycle booleans"
|
||||
fi
|
||||
if grep -Eq 'activity-package-power|refreshPackagePower|packagePowerProc' "$controller_file"; then
|
||||
fail "activity panel still polls package power"
|
||||
fi
|
||||
grep -Fq 'if (expanded) sendStatsRequest("gpus")' "$controller_file" &&
|
||||
grep -Fq '_gpuDue = 0' "$controller_file" &&
|
||||
grep -Fq 'resetGpuMeasurement()' "$controller_file" ||
|
||||
fail "activity panel samples GPU details while the advanced view is collapsed"
|
||||
grep -Fq '_metrics = Model.baselineMetrics(next, metrics)' "$controller_file" &&
|
||||
grep -Fq 'function resetSessionSampling()' "$controller_file" &&
|
||||
grep -Fq 'if (_gpus.length === 0)' "$controller_file" ||
|
||||
fail "activity panel does not retain valid display data while taking fresh baselines"
|
||||
if grep -Fq ' _snapshot = Model.emptySnapshot()' "$controller_file" ||
|
||||
grep -Fq ' _processes = []' "$controller_file" ||
|
||||
grep -Fq ' _gpus = []' "$controller_file"; then
|
||||
fail "activity panel clears stale display data when a sampling session changes"
|
||||
fi
|
||||
pass "activity controller coalesces deadlines and refreshes retained values without blanking"
|
||||
|
||||
grep -Fq 'label: "~W"' "$panel_file" ||
|
||||
fail "activity process table does not label estimated CPU watts"
|
||||
grep -Fq 'measuredPackagePowerText(processPower.watts)' "$panel_file" ||
|
||||
fail "activity CPU summary does not expose measured total CPU-package power"
|
||||
grep -Fq 'return "~" + watts.toFixed' "$panel_file" ||
|
||||
fail "activity process watts are not visibly marked as estimates"
|
||||
grep -Fq 'root.setSort("power")' "$panel_file" ||
|
||||
fail "activity process power cannot be sorted"
|
||||
grep -Fq 'if (nextKey === "power" && (!powerEstimatesEnabled || !processPower.available)) return' "$panel_file" ||
|
||||
fail "activity process power sorting remains active without a measurement"
|
||||
grep -Fq 'showProcessUserColumn' "$panel_file" &&
|
||||
grep -Fq 'showProcessTimeColumn' "$panel_file" ||
|
||||
fail "activity process table does not adapt its optional columns on narrow screens"
|
||||
grep -Fq 'model: root.sortedProcesses' "$panel_file" ||
|
||||
fail "activity process table silently caps the virtualized process list"
|
||||
grep -Fq 'Model.formatBytes(snapshot.memory.cached)' "$panel_file" ||
|
||||
fail "activity panel does not expose reclaimable memory cache"
|
||||
[[ $(grep -Fc 'label: root.memoryCardLabel()' "$panel_file") -eq 2 ]] ||
|
||||
fail "activity panel does not place RAM speed in both card headings"
|
||||
[[ $(grep -Fc 'label: root.cpuCardLabel()' "$panel_file") -eq 2 ]] ||
|
||||
fail "activity panel does not place CPU frequency in both card headings"
|
||||
grep -Fq 'function memoryBreakdownText()' "$panel_file" &&
|
||||
grep -Fq 'if (usedUnit && usedUnit === cacheUnit)' "$panel_file" &&
|
||||
grep -Fq '" · cache " + cacheText' "$panel_file" ||
|
||||
fail "activity RAM card does not show used memory and cache together"
|
||||
grep -Fq 'snapshot.memorySpeedMTs, "MT/s"' "$panel_file" &&
|
||||
grep -Fq 'snapshot.cpuFrequencyMHz, "MHz"' "$panel_file" &&
|
||||
grep -Fq 'var frequency = gpuFrequencyText(adapters[0])' "$panel_file" &&
|
||||
grep -Fq 'if (frequency === 0) return "IDLE"' "$panel_file" ||
|
||||
fail "activity card headings do not distinguish DDR transfer rate from CPU and GPU clocks"
|
||||
[[ $(grep -Fc 'badge: root.swapBadgeText()' "$panel_file") -eq 2 ]] &&
|
||||
grep -Fq 'return "Swap " + percentText(metrics.swap)' "$panel_file" ||
|
||||
fail "activity RAM cards do not expose swap usage"
|
||||
if grep -Fq 'label: "DISK"' "$panel_file"; then
|
||||
fail "activity disk card still shouts its heading"
|
||||
fi
|
||||
if grep -Fq 'component CoreLine' "$panel_file"; then
|
||||
fail "activity still draws a labeled bar for every logical core"
|
||||
fi
|
||||
grep -Fq 'component CoreHeatGrid' "$panel_file" &&
|
||||
grep -Fq 'paint: root.corePaintCompact' "$panel_file" &&
|
||||
grep -Fq 'paint: root.corePaintExpanded' "$panel_file" &&
|
||||
grep -Fq 'corePaintSizesExpanded' "$panel_file" &&
|
||||
grep -Fq 'id: coreDie' "$panel_file" ||
|
||||
fail "activity does not draw per-core load as a die on the CPU card"
|
||||
if grep -Fq 'text: "Cores"' "$panel_file"; then
|
||||
fail "activity still treats cores as a separate strip under the cards"
|
||||
fi
|
||||
grep -Fq 'component ProcessSortCell' "$panel_file" &&
|
||||
grep -Fq 'sortKey: "cpu"' "$panel_file" &&
|
||||
grep -Fq 'sortKey: "runtime"' "$panel_file" ||
|
||||
fail "activity process column headers are not the sort controls"
|
||||
if grep -Fq 'text: "More details"' "$panel_file"; then
|
||||
fail "activity compact view still duplicates the expand control"
|
||||
fi
|
||||
grep -Fq 'property bool hintsVisible: false' "$panel_file" &&
|
||||
grep -Fq 'function shortcutHintText()' "$panel_file" ||
|
||||
fail "activity keyboard hints are not opt-in"
|
||||
grep -Fq 'contentHeight: panel.fittedContentHeight(' "$panel_file" &&
|
||||
grep -Fq 'contentLoader.item.implicitHeight' "$panel_file" ||
|
||||
fail "activity panel does not size the window to its content"
|
||||
if grep -Fq 'processFooterReserve' "$panel_file"; then
|
||||
fail "activity still shrinks the process list to make room for keyboard hints"
|
||||
fi
|
||||
if grep -Fq 'Style.space(720)' "$panel_file" || grep -Fq 'Style.space(560))' "$panel_file"; then
|
||||
fail "activity still caps the window below the height of keyboard hints"
|
||||
fi
|
||||
grep -Fq 'height: Style.space(300)' "$panel_file" ||
|
||||
fail "activity expanded process list is not a stable height while hints open"
|
||||
if grep -Fq 'Preview CPU layouts' "$panel_file" || grep -Fq 'layoutPreviewContent' "$panel_file"; then
|
||||
fail "activity still ships the CPU layout preview in the production panel"
|
||||
fi
|
||||
if grep -Fq 'label: "GPU"' "$panel_file"; then
|
||||
fail "activity compact view still shows a GPU row"
|
||||
fi
|
||||
grep -Fq 'id: settingsContent' "$panel_file" &&
|
||||
grep -Fq 'label: "Update speed"' "$panel_file" &&
|
||||
grep -Fq 'label: "Graph history"' "$panel_file" &&
|
||||
grep -Fq 'label: "Temperature"' "$panel_file" &&
|
||||
grep -Fq 'function persistSettings(values)' "$panel_file" &&
|
||||
grep -Fq 'root.bar.shell.updateEntryInline(root.moduleName, entry)' "$panel_file" ||
|
||||
fail "activity panel does not expose persistent personalization controls"
|
||||
grep -Fq 'processPowerEnabled: root.powerEstimatesEnabled' "$panel_file" &&
|
||||
grep -Fq 'onProcessPowerEnabledChanged:' "$controller_file" &&
|
||||
grep -Fq 'visible: !compact && root.powerEstimatesEnabled' "$panel_file" ||
|
||||
fail "activity power preference does not stop sampling and simplify the process table"
|
||||
if grep -Fq 'text: "POWER"' "$panel_file"; then
|
||||
fail "activity presents estimated per-process power as an exact measurement"
|
||||
fi
|
||||
pass "activity clearly labels and sorts estimated per-process CPU power"
|
||||
|
||||
grep -Fq 'if (cursorActive && selectedProcessIndex === index) return' "$panel_file" ||
|
||||
fail "activity process rows remap pointer coordinates after already being selected"
|
||||
grep -Fq 'hoverEnabled: !compact' "$panel_file" ||
|
||||
fail "activity compact process rows subscribe to unused hover movement"
|
||||
if grep -Fq 'animateCursor: true' "$panel_file"; then
|
||||
fail "activity process row selection starts multi-frame hover animations"
|
||||
fi
|
||||
pass "activity process rows avoid input-rate pointer and animation work"
|
||||
|
||||
grep -Fq 'ConfirmDialog {' "$panel_file" ||
|
||||
fail "activity process actions are not confirmed"
|
||||
grep -Fq 'processConfirm.selectedIndex = 0' "$panel_file" ||
|
||||
fail "activity process confirmation does not default to Cancel"
|
||||
grep -Fq 'if (!root.settingsOpen && !processActions.confirmationOpen && root.cursorActive)' "$panel_file" ||
|
||||
fail "activity process shortcut can act without an explicit selection"
|
||||
grep -Fq 'ProcessActionController {' "$panel_file" &&
|
||||
grep -Fq 'active: root.opened' "$panel_file" &&
|
||||
grep -Fq 'enabled: !root.settingsOpen' "$panel_file" ||
|
||||
fail "activity panel does not delegate guarded app actions to their controller"
|
||||
grep -Fq 'function requestCloseProcess(process, index)' "$panel_file" &&
|
||||
grep -Fq 'onClicked: root.requestCloseProcess(parent.processData, compact ? -1 : parent.rowIndex)' "$panel_file" ||
|
||||
fail "activity process rows do not confirm close on left click"
|
||||
if grep -Fq 'id: endProcessButton' "$panel_file" || grep -Fq 'id: endProcessHost' "$panel_file"; then
|
||||
fail "activity still shows a separate End app button"
|
||||
fi
|
||||
grep -Fq 'Model.processIdentityKey' "$panel_file" ||
|
||||
fail "activity process selection is not tied to its sampled start time"
|
||||
grep -Fq 'signalHelperPath,' "$action_controller_file" ||
|
||||
fail "activity panel does not use the guarded process signal helper"
|
||||
grep -Fq 'action: "APP_TERM"' "$action_controller_file" ||
|
||||
fail "activity panel does not resolve worker processes to their parent app"
|
||||
grep -Fq 'confirmText: "Close"' "$panel_file" ||
|
||||
fail "activity panel does not confirm closing the selected process"
|
||||
grep -Fq 'Do you want to close ' "$panel_file" ||
|
||||
fail "activity panel does not ask a simple close confirmation"
|
||||
grep -Fq '_status = reason' "$action_controller_file" ||
|
||||
fail "activity panel does not explain why a process cannot be closed"
|
||||
grep -Fq '"hyprland"' "$ROOT/Model.js" ||
|
||||
fail "activity panel does not protect the desktop compositor"
|
||||
grep -Fq '"systemd-executo"' "$ROOT/Model.js" ||
|
||||
fail "activity panel does not protect the kernel-truncated systemd executor name"
|
||||
grep -Fq 'disposition === "graceful"' "$action_controller_file" &&
|
||||
grep -Fq 'disposition === "escalated"' "$action_controller_file" ||
|
||||
fail "activity app action status does not distinguish normal and forced closure"
|
||||
if grep -Eq 'bash[[:space:]]+-c|pkexec' "$panel_file" "$action_controller_file"; then
|
||||
fail "activity process actions invoke a shell or privilege prompt"
|
||||
fi
|
||||
pass "activity app actions use guarded identity and safe confirmation"
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
|
||||
for test_file in \
|
||||
activity-test.sh \
|
||||
activity-details-test.sh \
|
||||
activity-native-test.sh \
|
||||
activity-privilege-test.sh \
|
||||
activity-process-action-test.sh; do
|
||||
"$root/test/$test_file"
|
||||
done
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
|
||||
echo "source test/shell.d/base-test.sh from a shell test; do not run it directly" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
SHELL_TEST_DIR="$ROOT/test"
|
||||
|
||||
export ROOT
|
||||
|
||||
pass() {
|
||||
printf 'ok - %s\n' "$1"
|
||||
}
|
||||
|
||||
fail() {
|
||||
local description="$1"
|
||||
local detail="${2:-}"
|
||||
|
||||
[[ -n $detail ]] && printf '%s\n' "$detail" >&2
|
||||
printf 'not ok - %s\n' "$description" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_command() {
|
||||
local command="$1"
|
||||
|
||||
command -v "$command" >/dev/null || fail "required command is available: $command"
|
||||
}
|
||||
|
||||
run_node_test() {
|
||||
require_command node
|
||||
|
||||
{
|
||||
cat <<'JS_PRELUDE'
|
||||
const path = require('path')
|
||||
const root = process.env.ROOT
|
||||
|
||||
function fail(description, detail) {
|
||||
if (detail) console.error(detail)
|
||||
console.error(`not ok - ${description}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function pass(description) {
|
||||
console.log(`ok - ${description}`)
|
||||
}
|
||||
|
||||
function assert(condition, description, detail) {
|
||||
if (!condition) fail(description, detail)
|
||||
pass(description)
|
||||
}
|
||||
|
||||
function assertEqual(actual, expected, description) {
|
||||
assert(
|
||||
actual === expected,
|
||||
description,
|
||||
`expected: ${expected}\nactual: ${actual}`
|
||||
)
|
||||
}
|
||||
|
||||
function assertDeepEqual(actual, expected, description) {
|
||||
const actualJson = JSON.stringify(actual)
|
||||
const expectedJson = JSON.stringify(expected)
|
||||
assert(
|
||||
actualJson === expectedJson,
|
||||
description,
|
||||
`expected: ${expectedJson}\nactual: ${actualJson}`
|
||||
)
|
||||
}
|
||||
|
||||
function requireFromRoot(relativePath) {
|
||||
return require(path.join(root, relativePath))
|
||||
}
|
||||
|
||||
JS_PRELUDE
|
||||
cat
|
||||
} | node
|
||||
}
|
||||
Reference in new issue
Block a user