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,629 @@
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
import "Model.js" as Model
|
||||
|
||||
// Owns the Activity panel's sampling lifecycle and derived data. Keeping process I/O,
|
||||
// framing, watchdogs, and power-reader escalation here leaves Panel.qml focused
|
||||
// on presentation and interaction.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
width: 0
|
||||
height: 0
|
||||
visible: false
|
||||
|
||||
property var settings: ({})
|
||||
property bool active: false
|
||||
property bool expanded: false
|
||||
property bool processPowerEnabled: true
|
||||
|
||||
readonly property string statsHelperPath: decodeURIComponent(
|
||||
String(Qt.resolvedUrl("activity-sampler")).replace("file://", ""))
|
||||
readonly property string powerHelperPath: decodeURIComponent(
|
||||
String(Qt.resolvedUrl("activity-power-reader")).replace("file://", ""))
|
||||
|
||||
readonly property var snapshot: _snapshot
|
||||
readonly property var thermalSnapshot: _thermalSnapshot
|
||||
readonly property var storageSnapshot: _storageSnapshot
|
||||
readonly property var gpuSnapshot: _gpuSnapshot
|
||||
readonly property var gpus: _gpus
|
||||
readonly property var processPower: _processPower
|
||||
readonly property var processes: _processes
|
||||
readonly property var metrics: _metrics
|
||||
readonly property bool processMetricsReady: _processMetricsReady
|
||||
readonly property bool processPowerUnavailable: _processPowerState === powerStateUnavailable
|
||||
readonly property string processPowerState: _processPowerState
|
||||
|
||||
readonly property string powerStateStopped: "stopped"
|
||||
readonly property string powerStateStartingUser: "starting-user"
|
||||
readonly property string powerStateReadyUser: "ready-user"
|
||||
readonly property string powerStateSwitchingRoot: "switching-root"
|
||||
readonly property string powerStateStartingRoot: "starting-root"
|
||||
readonly property string powerStateReadyRoot: "ready-root"
|
||||
readonly property string powerStateUnavailable: "unavailable"
|
||||
|
||||
readonly property bool samplingProfileConfigured: String(setting("samplingSpeed", "")).trim() !== ""
|
||||
readonly property var samplingProfile: Model.samplingProfile(setting("samplingSpeed", "Balanced"))
|
||||
readonly property int refreshInterval: intervalSetting("refreshIntervalMs", 1500, 500, 10000, samplingProfile.resources)
|
||||
readonly property int processRefreshInterval: intervalSetting("processRefreshIntervalMs", 3000, 1000, 15000, samplingProfile.processes)
|
||||
readonly property int thermalRefreshInterval: intervalSetting("thermalRefreshIntervalMs", 10000, 3000, 60000, samplingProfile.thermals)
|
||||
readonly property int gpuRefreshInterval: intervalSetting("gpuRefreshIntervalMs", 2000, 1000, 15000, samplingProfile.gpus)
|
||||
readonly property int storageRefreshInterval: intervalSetting("storageRefreshIntervalMs", 30000, 10000, 120000, samplingProfile.storage)
|
||||
readonly property int historySamples: boundedSetting("historySamples", 60, 20, 240)
|
||||
|
||||
property var _snapshot: Model.emptySnapshot()
|
||||
property var _thermalSnapshot: Model.emptyThermalSnapshot()
|
||||
property var _storageSnapshot: Model.emptyStorageSnapshot()
|
||||
property var _gpuSnapshot: Model.emptyGpuSnapshot()
|
||||
property var _gpus: []
|
||||
property var _processPower: Model.emptyPower()
|
||||
property var _processes: []
|
||||
property var _metrics: Model.emptyMetrics()
|
||||
property bool _processMetricsReady: false
|
||||
|
||||
property var _previousSnapshot: Model.emptySnapshot()
|
||||
property var _previousProcessSnapshot: Model.emptyProcessSnapshot()
|
||||
property var _previousProcessPowerSnapshot: Model.emptyPackagePowerSnapshot()
|
||||
property var _previousGpuSnapshot: Model.emptyGpuSnapshot()
|
||||
|
||||
property var _statsReaderBuffer: []
|
||||
property bool _statsReaderReady: false
|
||||
property bool _resourceSamplePending: false
|
||||
property bool _processSamplePending: false
|
||||
property bool _thermalSamplePending: false
|
||||
property bool _gpuSamplePending: false
|
||||
property bool _storageSamplePending: false
|
||||
|
||||
property double _resourceDue: 0
|
||||
property double _processDue: 0
|
||||
property double _thermalDue: 0
|
||||
property double _gpuDue: 0
|
||||
property double _storageDue: 0
|
||||
|
||||
property var _processPowerBuffer: []
|
||||
property bool _processPowerSamplePending: false
|
||||
property string _processPowerState: powerStateStopped
|
||||
|
||||
function setting(name, fallback) {
|
||||
var value = settings ? settings[name] : undefined
|
||||
return value === undefined || value === null ? fallback : value
|
||||
}
|
||||
|
||||
function boundedSetting(name, fallback, minimum, maximum) {
|
||||
var value = Number(setting(name, fallback))
|
||||
if (!isFinite(value)) value = fallback
|
||||
return Math.max(minimum, Math.min(maximum, value))
|
||||
}
|
||||
|
||||
function intervalSetting(name, fallback, minimum, maximum, profileValue) {
|
||||
if (samplingProfileConfigured)
|
||||
return Math.max(minimum, Math.min(maximum, Number(profileValue) || fallback))
|
||||
return boundedSetting(name, fallback, minimum, maximum)
|
||||
}
|
||||
|
||||
function nextDeadline(previous, interval, now) {
|
||||
if (previous <= 0) return now + interval
|
||||
if (previous > now) return previous
|
||||
return previous + (Math.floor((now - previous) / interval) + 1) * interval
|
||||
}
|
||||
|
||||
function scheduleNextDeadline() {
|
||||
deadlineScheduler.stop()
|
||||
if (!active) return
|
||||
|
||||
var next = Math.min(_resourceDue, _processDue, _thermalDue, _storageDue)
|
||||
if (expanded && _gpuDue > 0) next = Math.min(next, _gpuDue)
|
||||
deadlineScheduler.interval = Math.max(1, Math.round(next - Date.now()))
|
||||
deadlineScheduler.start()
|
||||
}
|
||||
|
||||
function resetPeriodicDeadlines() {
|
||||
var now = Date.now()
|
||||
_resourceDue = now + refreshInterval
|
||||
_processDue = now + processRefreshInterval
|
||||
_thermalDue = now + thermalRefreshInterval
|
||||
_storageDue = now + storageRefreshInterval
|
||||
_gpuDue = expanded ? now + gpuRefreshInterval : 0
|
||||
scheduleNextDeadline()
|
||||
}
|
||||
|
||||
function collectDueSnapshots() {
|
||||
if (!active) return
|
||||
var now = Date.now()
|
||||
if (_resourceDue <= now + 1) {
|
||||
refreshResources()
|
||||
_resourceDue = nextDeadline(_resourceDue, refreshInterval, now)
|
||||
}
|
||||
if (_processDue <= now + 1) {
|
||||
refreshProcessCycle()
|
||||
_processDue = nextDeadline(_processDue, processRefreshInterval, now)
|
||||
}
|
||||
if (_thermalDue <= now + 1) {
|
||||
refreshThermals()
|
||||
_thermalDue = nextDeadline(_thermalDue, thermalRefreshInterval, now)
|
||||
}
|
||||
if (_storageDue <= now + 1) {
|
||||
refreshStorage()
|
||||
_storageDue = nextDeadline(_storageDue, storageRefreshInterval, now)
|
||||
}
|
||||
if (expanded && _gpuDue > 0 && _gpuDue <= now + 1) {
|
||||
refreshGpus()
|
||||
_gpuDue = nextDeadline(_gpuDue, gpuRefreshInterval, now)
|
||||
}
|
||||
scheduleNextDeadline()
|
||||
}
|
||||
|
||||
function statsPending(kind) {
|
||||
if (kind === "resources") return _resourceSamplePending
|
||||
if (kind === "processes") return _processSamplePending
|
||||
if (kind === "thermals") return _thermalSamplePending
|
||||
if (kind === "gpus") return _gpuSamplePending
|
||||
if (kind === "storage") return _storageSamplePending
|
||||
return false
|
||||
}
|
||||
|
||||
function anyStatsPending() {
|
||||
return _resourceSamplePending
|
||||
|| _processSamplePending
|
||||
|| _thermalSamplePending
|
||||
|| _gpuSamplePending
|
||||
|| _storageSamplePending
|
||||
}
|
||||
|
||||
function setStatsPending(kind, value) {
|
||||
if (kind === "resources") _resourceSamplePending = value
|
||||
else if (kind === "processes") _processSamplePending = value
|
||||
else if (kind === "thermals") _thermalSamplePending = value
|
||||
else if (kind === "gpus") _gpuSamplePending = value
|
||||
else if (kind === "storage") _storageSamplePending = value
|
||||
}
|
||||
|
||||
function resetStatsReaderState() {
|
||||
_statsReaderReady = false
|
||||
_statsReaderBuffer = []
|
||||
_resourceSamplePending = false
|
||||
_processSamplePending = false
|
||||
_thermalSamplePending = false
|
||||
_gpuSamplePending = false
|
||||
_storageSamplePending = false
|
||||
statsReaderWatchdog.stop()
|
||||
}
|
||||
|
||||
function startStatsReader() {
|
||||
if (!active || statsReaderProc.running) return
|
||||
_statsReaderReady = false
|
||||
_statsReaderBuffer = []
|
||||
statsReaderProc.running = true
|
||||
}
|
||||
|
||||
function sendStatsRequest(kind) {
|
||||
if (!active || statsPending(kind)) return
|
||||
setStatsPending(kind, true)
|
||||
statsReaderWatchdog.restart()
|
||||
if (!statsReaderProc.running) startStatsReader()
|
||||
if (_statsReaderReady) statsReaderProc.write(kind + "\n")
|
||||
}
|
||||
|
||||
function sendPendingStatsRequests() {
|
||||
if (!_statsReaderReady) return
|
||||
if (_resourceSamplePending) statsReaderProc.write("resources\n")
|
||||
if (_processSamplePending) statsReaderProc.write("processes\n")
|
||||
if (_thermalSamplePending) statsReaderProc.write("thermals\n")
|
||||
if (_gpuSamplePending) statsReaderProc.write("gpus\n")
|
||||
if (_storageSamplePending) statsReaderProc.write("storage\n")
|
||||
}
|
||||
|
||||
function refreshResources() {
|
||||
sendStatsRequest("resources")
|
||||
}
|
||||
|
||||
function refreshProcesses() {
|
||||
sendStatsRequest("processes")
|
||||
}
|
||||
|
||||
function refreshThermals() {
|
||||
sendStatsRequest("thermals")
|
||||
}
|
||||
|
||||
function refreshGpus() {
|
||||
if (expanded) sendStatsRequest("gpus")
|
||||
}
|
||||
|
||||
function refreshStorage() {
|
||||
sendStatsRequest("storage")
|
||||
}
|
||||
|
||||
function consumeStatsReaderLine(line) {
|
||||
var value = String(line || "")
|
||||
if (value.indexOf("snapshot-end\t") !== 0) {
|
||||
_statsReaderBuffer.push(value)
|
||||
return
|
||||
}
|
||||
|
||||
var kind = value.slice("snapshot-end\t".length)
|
||||
var raw = _statsReaderBuffer.join("\n")
|
||||
_statsReaderBuffer = []
|
||||
setStatsPending(kind, false)
|
||||
if (anyStatsPending()) statsReaderWatchdog.restart()
|
||||
else statsReaderWatchdog.stop()
|
||||
if (kind === "resources") consumeSnapshot(raw)
|
||||
else if (kind === "processes") consumeProcesses(raw)
|
||||
else if (kind === "thermals") consumeThermals(raw)
|
||||
else if (kind === "gpus") consumeGpus(raw)
|
||||
else if (kind === "storage") consumeStorage(raw)
|
||||
}
|
||||
|
||||
function processPowerUsesUserReader() {
|
||||
return _processPowerState === powerStateStartingUser
|
||||
|| _processPowerState === powerStateReadyUser
|
||||
}
|
||||
|
||||
function processPowerReaderReady() {
|
||||
return _processPowerState === powerStateReadyUser
|
||||
|| _processPowerState === powerStateReadyRoot
|
||||
}
|
||||
|
||||
function startProcessPowerReader() {
|
||||
if (!active || !expanded || !processPowerEnabled
|
||||
|| processPowerUnavailable || processPowerProc.running) return
|
||||
|
||||
var useRoot = _processPowerState === powerStateSwitchingRoot
|
||||
_processPowerBuffer = []
|
||||
_processPowerState = useRoot ? powerStateStartingRoot : powerStateStartingUser
|
||||
processPowerProc.command = useRoot
|
||||
? [powerHelperPath]
|
||||
: [statsHelperPath, "--activity-process-power-reader"]
|
||||
processPowerProc.running = true
|
||||
}
|
||||
|
||||
function requestProcessPowerSample() {
|
||||
if (!active || !expanded || !processPowerEnabled
|
||||
|| processPowerUnavailable || _processPowerSamplePending) return
|
||||
_processPowerSamplePending = true
|
||||
processPowerWatchdog.restart()
|
||||
if (!processPowerProc.running) startProcessPowerReader()
|
||||
if (processPowerReaderReady()) processPowerProc.write("sample\n")
|
||||
}
|
||||
|
||||
function stopProcessPowerReader() {
|
||||
_processPowerState = powerStateStopped
|
||||
_processPowerSamplePending = false
|
||||
_processPowerBuffer = []
|
||||
processPowerWatchdog.stop()
|
||||
if (processPowerProc.running) processPowerProc.running = false
|
||||
}
|
||||
|
||||
function consumeProcessPowerLine(line) {
|
||||
var value = String(line || "")
|
||||
if (value !== "snapshot-end\tpower") {
|
||||
_processPowerBuffer.push(value)
|
||||
return
|
||||
}
|
||||
|
||||
var raw = _processPowerBuffer.join("\n")
|
||||
_processPowerBuffer = []
|
||||
_processPowerSamplePending = false
|
||||
processPowerWatchdog.stop()
|
||||
consumeProcessPower(raw)
|
||||
}
|
||||
|
||||
function refreshProcessCycle() {
|
||||
if (expanded && processPowerEnabled && !processPowerUnavailable) requestProcessPowerSample()
|
||||
else refreshProcesses()
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
refreshResources()
|
||||
refreshProcessCycle()
|
||||
refreshThermals()
|
||||
refreshGpus()
|
||||
refreshStorage()
|
||||
}
|
||||
|
||||
function consumeSnapshot(raw) {
|
||||
var next = Model.parseSnapshot(raw)
|
||||
if (next.schema !== 1 || next.sample <= 0) return
|
||||
if (Model.sameTopology(_snapshot.topology, next.topology))
|
||||
next.topology = _snapshot.topology
|
||||
if (_previousSnapshot.sample <= 0) {
|
||||
_metrics = Model.baselineMetrics(next, metrics)
|
||||
_previousSnapshot = next
|
||||
_snapshot = next
|
||||
if (active) resourceWarmup.restart()
|
||||
return
|
||||
}
|
||||
_metrics = Model.nextMetrics(_previousSnapshot, next, metrics, historySamples)
|
||||
_previousSnapshot = next
|
||||
_snapshot = next
|
||||
}
|
||||
|
||||
function consumeThermals(raw) {
|
||||
var next = Model.parseThermalSnapshot(raw)
|
||||
if (next.schema !== 1 || next.sample <= 0) return
|
||||
_thermalSnapshot = next
|
||||
}
|
||||
|
||||
function consumeStorage(raw) {
|
||||
var next = Model.parseStorageSnapshot(raw)
|
||||
if (next.schema !== 1 || next.sample <= 0) return
|
||||
_storageSnapshot = next
|
||||
}
|
||||
|
||||
function consumeGpus(raw) {
|
||||
if (!expanded) return
|
||||
var next = Model.parseGpuSnapshot(raw)
|
||||
if (next.schema !== 1 || next.sample <= 0) return
|
||||
if (_previousGpuSnapshot.sample <= 0) {
|
||||
if (_gpus.length === 0) _gpus = Model.nextGpus(_previousGpuSnapshot, next)
|
||||
_previousGpuSnapshot = next
|
||||
_gpuSnapshot = next
|
||||
if (active) gpuWarmup.restart()
|
||||
return
|
||||
}
|
||||
_gpus = Model.nextGpus(_previousGpuSnapshot, next)
|
||||
_previousGpuSnapshot = next
|
||||
_gpuSnapshot = next
|
||||
}
|
||||
|
||||
function consumeProcessPower(raw) {
|
||||
if (!expanded || !processPowerEnabled) return
|
||||
var next = Model.parsePackagePowerSnapshot(raw)
|
||||
if (next.schema !== 1 || next.sample <= 0 || !Model.packageEnergyReadable(next)) {
|
||||
rejectProcessPowerSample()
|
||||
return
|
||||
}
|
||||
|
||||
var needsWarmup = _previousProcessPowerSnapshot.sample <= 0
|
||||
_processPower = Model.nextPackagePower(_previousProcessPowerSnapshot, next)
|
||||
_previousProcessPowerSnapshot = next
|
||||
if (needsWarmup && active) powerWarmup.restart()
|
||||
refreshProcesses()
|
||||
}
|
||||
|
||||
function rejectProcessPowerSample() {
|
||||
var retryAsRoot = processPowerUsesUserReader()
|
||||
if (retryAsRoot) {
|
||||
resetProcessPowerMeasurement()
|
||||
_processPowerState = powerStateSwitchingRoot
|
||||
} else {
|
||||
markProcessPowerUnavailable()
|
||||
refreshProcesses()
|
||||
}
|
||||
if (processPowerProc.running) processPowerProc.running = false
|
||||
}
|
||||
|
||||
function consumeProcesses(raw) {
|
||||
var next = Model.parseProcessSnapshot(raw)
|
||||
if (next.schema !== 1 || next.sample <= 0) return
|
||||
if (_previousProcessSnapshot.sample <= 0) {
|
||||
_previousProcessSnapshot = next
|
||||
if (active) processWarmup.restart()
|
||||
return
|
||||
}
|
||||
var busyDelta = Model.processSystemBusyDelta(_previousProcessSnapshot, next)
|
||||
var nextProcesses = Model.nextProcesses(
|
||||
_previousProcessSnapshot,
|
||||
next,
|
||||
snapshot.memory.total
|
||||
)
|
||||
_processes = Model.estimateProcessPower(
|
||||
nextProcesses,
|
||||
processPower.available ? processPower.watts : -1,
|
||||
busyDelta
|
||||
)
|
||||
_previousProcessSnapshot = next
|
||||
_processMetricsReady = true
|
||||
}
|
||||
|
||||
function resetProcessPowerMeasurement() {
|
||||
_previousProcessPowerSnapshot = Model.emptyPackagePowerSnapshot()
|
||||
_processPower = Model.emptyPower()
|
||||
_processes = Model.estimateProcessPower(processes, -1, -1)
|
||||
}
|
||||
|
||||
function resetGpuMeasurement() {
|
||||
_previousGpuSnapshot = Model.emptyGpuSnapshot()
|
||||
}
|
||||
|
||||
function markProcessPowerUnavailable() {
|
||||
_processPowerState = powerStateUnavailable
|
||||
resetProcessPowerMeasurement()
|
||||
}
|
||||
|
||||
function resetSessionSampling() {
|
||||
_previousSnapshot = Model.emptySnapshot()
|
||||
_previousProcessSnapshot = Model.emptyProcessSnapshot()
|
||||
_previousProcessPowerSnapshot = Model.emptyPackagePowerSnapshot()
|
||||
_previousGpuSnapshot = Model.emptyGpuSnapshot()
|
||||
resetProcessPowerMeasurement()
|
||||
resetStatsReaderState()
|
||||
_processPowerBuffer = []
|
||||
_processPowerSamplePending = false
|
||||
_processPowerState = powerStateStopped
|
||||
}
|
||||
|
||||
function startSession() {
|
||||
resetSessionSampling()
|
||||
refresh()
|
||||
resetPeriodicDeadlines()
|
||||
}
|
||||
|
||||
function stopSession() {
|
||||
deadlineScheduler.stop()
|
||||
resetStatsReaderState()
|
||||
if (statsReaderProc.running) statsReaderProc.running = false
|
||||
stopProcessPowerReader()
|
||||
resourceWarmup.stop()
|
||||
processWarmup.stop()
|
||||
gpuWarmup.stop()
|
||||
powerWarmup.stop()
|
||||
statsReaderRetry.stop()
|
||||
}
|
||||
|
||||
onActiveChanged: {
|
||||
if (active) startSession()
|
||||
else stopSession()
|
||||
}
|
||||
|
||||
onExpandedChanged: {
|
||||
if (!expanded) {
|
||||
stopProcessPowerReader()
|
||||
resetProcessPowerMeasurement()
|
||||
resetGpuMeasurement()
|
||||
_gpuDue = 0
|
||||
scheduleNextDeadline()
|
||||
} else if (active) {
|
||||
if (processPowerEnabled)
|
||||
_previousProcessPowerSnapshot = Model.emptyPackagePowerSnapshot()
|
||||
_previousGpuSnapshot = Model.emptyGpuSnapshot()
|
||||
refreshProcessCycle()
|
||||
refreshGpus()
|
||||
_gpuDue = Date.now() + gpuRefreshInterval
|
||||
scheduleNextDeadline()
|
||||
}
|
||||
}
|
||||
|
||||
onProcessPowerEnabledChanged: {
|
||||
if (!processPowerEnabled) {
|
||||
stopProcessPowerReader()
|
||||
resetProcessPowerMeasurement()
|
||||
if (active) refreshProcesses()
|
||||
} else if (active && expanded) {
|
||||
_previousProcessPowerSnapshot = Model.emptyPackagePowerSnapshot()
|
||||
refreshProcessCycle()
|
||||
}
|
||||
}
|
||||
|
||||
onRefreshIntervalChanged: if (active) resetPeriodicDeadlines()
|
||||
onProcessRefreshIntervalChanged: if (active) resetPeriodicDeadlines()
|
||||
onThermalRefreshIntervalChanged: if (active) resetPeriodicDeadlines()
|
||||
onGpuRefreshIntervalChanged: if (active) resetPeriodicDeadlines()
|
||||
onStorageRefreshIntervalChanged: if (active) resetPeriodicDeadlines()
|
||||
|
||||
Process {
|
||||
id: statsReaderProc
|
||||
command: [root.statsHelperPath, "--activity-reader"]
|
||||
stdinEnabled: true
|
||||
stdout: SplitParser {
|
||||
onRead: function(line) { root.consumeStatsReaderLine(line) }
|
||||
}
|
||||
onStarted: {
|
||||
root._statsReaderReady = true
|
||||
root.sendPendingStatsRequests()
|
||||
}
|
||||
onExited: {
|
||||
root.resetStatsReaderState()
|
||||
if (root.active) statsReaderRetry.restart()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: processPowerProc
|
||||
stdinEnabled: true
|
||||
stdout: SplitParser {
|
||||
onRead: function(line) { root.consumeProcessPowerLine(line) }
|
||||
}
|
||||
onStarted: {
|
||||
if (!root.active || !root.expanded || !root.processPowerEnabled) {
|
||||
root.stopProcessPowerReader()
|
||||
return
|
||||
}
|
||||
if (root._processPowerState === root.powerStateStartingUser)
|
||||
root._processPowerState = root.powerStateReadyUser
|
||||
else if (root._processPowerState === root.powerStateStartingRoot)
|
||||
root._processPowerState = root.powerStateReadyRoot
|
||||
else {
|
||||
root.stopProcessPowerReader()
|
||||
return
|
||||
}
|
||||
if (root._processPowerSamplePending) processPowerProc.write("sample\n")
|
||||
}
|
||||
onExited: {
|
||||
var exitedState = root._processPowerState
|
||||
root._processPowerSamplePending = false
|
||||
root._processPowerBuffer = []
|
||||
processPowerWatchdog.stop()
|
||||
|
||||
if (!root.active || !root.expanded || !root.processPowerEnabled) {
|
||||
root._processPowerState = root.powerStateStopped
|
||||
return
|
||||
}
|
||||
if (exitedState === root.powerStateStopped) {
|
||||
root._processPowerState = root.powerStateStopped
|
||||
Qt.callLater(function() { root.requestProcessPowerSample() })
|
||||
return
|
||||
}
|
||||
if (exitedState === root.powerStateUnavailable) return
|
||||
|
||||
if (exitedState === root.powerStateSwitchingRoot
|
||||
|| exitedState === root.powerStateStartingUser
|
||||
|| exitedState === root.powerStateReadyUser) {
|
||||
root._processPowerState = root.powerStateSwitchingRoot
|
||||
Qt.callLater(function() { root.requestProcessPowerSample() })
|
||||
return
|
||||
}
|
||||
|
||||
root.markProcessPowerUnavailable()
|
||||
Qt.callLater(function() { root.refreshProcesses() })
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: statsReaderRetry
|
||||
interval: 250
|
||||
repeat: false
|
||||
onTriggered: if (root.active) root.refresh()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: resourceWarmup
|
||||
interval: 300
|
||||
repeat: false
|
||||
onTriggered: if (root.active) root.refreshResources()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: processWarmup
|
||||
interval: 300
|
||||
repeat: false
|
||||
onTriggered: if (root.active) root.refreshProcesses()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: gpuWarmup
|
||||
interval: 300
|
||||
repeat: false
|
||||
onTriggered: if (root.active && root.expanded) root.refreshGpus()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: powerWarmup
|
||||
interval: 300
|
||||
repeat: false
|
||||
onTriggered: if (root.active && root.expanded && root.processPowerEnabled)
|
||||
root.requestProcessPowerSample()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: statsReaderWatchdog
|
||||
interval: 5000
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
root._statsReaderReady = false
|
||||
if (statsReaderProc.running) statsReaderProc.running = false
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: processPowerWatchdog
|
||||
interval: 5000
|
||||
repeat: false
|
||||
onTriggered: if (processPowerProc.running) processPowerProc.running = false
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: deadlineScheduler
|
||||
repeat: false
|
||||
onTriggered: root.collectDueSnapshots()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) David Heinemeier Hansson
|
||||
Copyright (c) 2026 Kristoffer Haugland
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,22 @@
|
||||
CXX ?= c++
|
||||
CPPFLAGS ?=
|
||||
CXXFLAGS ?= -O2 -pipe
|
||||
LDFLAGS ?=
|
||||
LDLIBS ?= -ldl
|
||||
|
||||
SAMPLER_FLAGS = -std=c++17 -DNDEBUG -fno-exceptions -fno-rtti \
|
||||
-fstack-protector-strong -fcf-protection -D_FORTIFY_SOURCE=3 \
|
||||
-Wall -Wextra -Wpedantic -Werror -ffile-prefix-map=$(CURDIR)=.
|
||||
SAMPLER_LDFLAGS = -Wl,--as-needed -Wl,-z,relro -Wl,-z,now \
|
||||
-Wl,--build-id=none -s
|
||||
|
||||
.PHONY: all clean
|
||||
|
||||
all: activity-sampler
|
||||
|
||||
activity-sampler: activity-sampler.cpp
|
||||
LC_ALL=C $(CXX) $(CPPFLAGS) $(CXXFLAGS) $(SAMPLER_FLAGS) \
|
||||
$(LDFLAGS) $(SAMPLER_LDFLAGS) -o $@ $< $(LDLIBS)
|
||||
|
||||
clean:
|
||||
rm -f -- activity-sampler
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,51 @@
|
||||
# Maintainer: Kristoffer Haugland <stappmus at gmail dot com>
|
||||
|
||||
pkgname=omarchy-activity-monitor-power-helper
|
||||
pkgver=2.1.1
|
||||
pkgrel=1
|
||||
pkgdesc="Optional root-only native RAPL reader for the Omarchy Activity Monitor plugin"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/stappmus/omarchy-activity-monitor"
|
||||
license=('MIT')
|
||||
depends=('gcc-libs' 'glibc' 'sudo')
|
||||
makedepends=('gcc' 'make')
|
||||
options=('!debug')
|
||||
source=(
|
||||
'activity-sampler.cpp'
|
||||
'Makefile'
|
||||
'stappmus-activity-monitor.sudoers'
|
||||
'README.md'
|
||||
'LICENSE'
|
||||
)
|
||||
sha256sums=(
|
||||
'bc8463aaac0e75dcea5c36597d5031ab28630d078a438ca87a9044a422ee026c'
|
||||
'2ea69385047c3a1d1893378e2866a464cba7ae28040c0add8a3eaa80f94700a3'
|
||||
'e248f015e89bc7f3df4714e7d1e0248c4ac9cc4b4642dda6aa428741b6c4f2ca'
|
||||
'e02f364d97fd1c7ef95bb94d607c857ae5052961cdd0c8ac9b24ba2626ca1480'
|
||||
'dd56ead2d3379b1d8298bbd1b905188b21894c46312ea4186b2afde9b03b3184'
|
||||
)
|
||||
|
||||
build() {
|
||||
make activity-sampler
|
||||
}
|
||||
|
||||
check() {
|
||||
[[ $(./activity-sampler --version) == 'activity-sampler 2.1.1' ]]
|
||||
grep -Fxq \
|
||||
'%wheel ALL=(root) NOPASSWD: /usr/lib/stappmus-activity-monitor/activity-sampler --activity-process-power-reader' \
|
||||
stappmus-activity-monitor.sudoers
|
||||
if command -v visudo >/dev/null 2>&1; then
|
||||
visudo -cf stappmus-activity-monitor.sudoers >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
package() {
|
||||
install -Dm755 activity-sampler \
|
||||
"$pkgdir/usr/lib/stappmus-activity-monitor/activity-sampler"
|
||||
install -Dm440 stappmus-activity-monitor.sudoers \
|
||||
"$pkgdir/etc/sudoers.d/stappmus-activity-monitor"
|
||||
install -Dm644 README.md \
|
||||
"$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
install -Dm644 LICENSE \
|
||||
"$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,151 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import "Model.js" as Model
|
||||
|
||||
// Owns the guarded process-action lifecycle. The visual panel decides when to
|
||||
// offer an action; this controller owns its immutable identity, helper process,
|
||||
// result interpretation, and transient status.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
width: 0
|
||||
height: 0
|
||||
visible: false
|
||||
|
||||
property bool active: false
|
||||
|
||||
readonly property string signalHelperPath: decodeURIComponent(
|
||||
String(Qt.resolvedUrl("process-signal")).replace("file://", ""))
|
||||
|
||||
readonly property string currentUser: Quickshell.env("USER") || Quickshell.env("LOGNAME")
|
||||
readonly property bool confirmationOpen: _pendingAction !== null
|
||||
readonly property var pendingAction: _pendingAction
|
||||
readonly property bool running: signalProc.running
|
||||
readonly property string status: _status
|
||||
readonly property bool failed: _failed
|
||||
|
||||
property var _pendingAction: null
|
||||
property var _activeAction: null
|
||||
property string _status: ""
|
||||
property bool _failed: false
|
||||
|
||||
signal confirmationRequested()
|
||||
signal focusRequested()
|
||||
signal refreshRequested()
|
||||
|
||||
function blockReason(process) {
|
||||
return Model.processActionBlockReason(process, currentUser)
|
||||
}
|
||||
|
||||
function request(process) {
|
||||
if (!active || !enabled || running) return false
|
||||
|
||||
var reason = blockReason(process)
|
||||
if (reason !== "") {
|
||||
_pendingAction = null
|
||||
_failed = true
|
||||
_status = reason
|
||||
clearStatus.restart()
|
||||
focusRequested()
|
||||
return false
|
||||
}
|
||||
|
||||
_failed = false
|
||||
_status = ""
|
||||
clearStatus.stop()
|
||||
_pendingAction = {
|
||||
pid: Number(process.pid),
|
||||
startTicks: Number(process.startTicks),
|
||||
name: String(process.name || "process"),
|
||||
user: String(process.user || ""),
|
||||
action: "APP_TERM"
|
||||
}
|
||||
confirmationRequested()
|
||||
focusRequested()
|
||||
return true
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
_pendingAction = null
|
||||
focusRequested()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (!_pendingAction || running) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
|
||||
_activeAction = _pendingAction
|
||||
_pendingAction = null
|
||||
_failed = false
|
||||
_status = "Ending " + _activeAction.name + "…"
|
||||
signalProc.command = [
|
||||
signalHelperPath,
|
||||
_activeAction.action,
|
||||
String(_activeAction.pid),
|
||||
String(_activeAction.startTicks)
|
||||
]
|
||||
signalProc.running = true
|
||||
}
|
||||
|
||||
function finish(exitCode) {
|
||||
var action = _activeAction
|
||||
var error = String(signalStderr.text || "").trim().split("\n")[0]
|
||||
var result = String(signalStdout.text || "").trim().split(/\s+/)
|
||||
var disposition = result.length ? result[result.length - 1] : ""
|
||||
|
||||
if (active) {
|
||||
if (exitCode === 0) {
|
||||
_failed = false
|
||||
if (!action) _status = "App ended"
|
||||
else if (disposition === "graceful") _status = "Closed " + action.name
|
||||
else if (disposition === "escalated") _status = "Force-closed " + action.name
|
||||
else _status = "Ended " + action.name
|
||||
} else {
|
||||
_failed = true
|
||||
_status = error || "Could not end the selected app"
|
||||
}
|
||||
}
|
||||
|
||||
_activeAction = null
|
||||
if (!active) return
|
||||
refreshRequested()
|
||||
clearStatus.restart()
|
||||
focusRequested()
|
||||
}
|
||||
|
||||
function clearSessionState() {
|
||||
_pendingAction = null
|
||||
_status = ""
|
||||
_failed = false
|
||||
clearStatus.stop()
|
||||
}
|
||||
|
||||
onActiveChanged: if (!active) clearSessionState()
|
||||
onEnabledChanged: if (!enabled) _pendingAction = null
|
||||
|
||||
Process {
|
||||
id: signalProc
|
||||
stdout: StdioCollector {
|
||||
id: signalStdout
|
||||
waitForEnd: true
|
||||
}
|
||||
stderr: StdioCollector {
|
||||
id: signalStderr
|
||||
waitForEnd: true
|
||||
}
|
||||
onExited: function(exitCode) { root.finish(exitCode) }
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: clearStatus
|
||||
interval: 4000
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
root._status = ""
|
||||
root._failed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# Activity Monitor
|
||||
|
||||
A calm glance at what your machine is doing.
|
||||
|
||||
Open it from the bar when you want the picture — CPU, memory, network, disk, and the processes that actually matter. Close it, and it goes quiet. Nothing sits in the background burning power or attention.
|
||||
|
||||
It is built to be easy on your eyes and easy on the computer. One small native reader runs only while the panel is open. No extra daemons. No blinking wall of numbers.
|
||||
|
||||

|
||||
|
||||
Need more? Expand for GPU and storage, a live process table, and a little die that matches *your* CPU — performance cores, efficient cores, and low-power islands drawn the way the silicon is laid out.
|
||||
|
||||

|
||||
|
||||
Hybrid Intel, AMD Zen 5c, and ARM big.LITTLE each get their own shape. Older, even chips stay on a simple grid.
|
||||
|
||||

|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
omarchy plugin add https://github.com/stappmus/omarchy-activity-monitor.git --enable
|
||||
```
|
||||
|
||||
Or from Setup → Plugins. To turn it off later:
|
||||
|
||||
```bash
|
||||
omarchy plugin disable stappmus.activity-monitor
|
||||
omarchy plugin remove stappmus.activity-monitor
|
||||
```
|
||||
|
||||
## Using it
|
||||
|
||||
- Click the bar icon to open. `e` expands, Esc collapses.
|
||||
- `/` search processes. `j` / `k` move. Click a column header to sort.
|
||||
- `s` opens settings — update speed, graph history, °C/°F, and whether to estimate process watts.
|
||||
- Left-click a process to close it. A popup asks first; `x` does the same for the selected row.
|
||||
|
||||
Settings are saved with your bar layout and apply immediately.
|
||||
|
||||
## A few honest details
|
||||
|
||||
Used memory follows Linux available-memory accounting, with cache shown separately because the kernel can reclaim it. GPU memory is what the driver actually reports — shared for integrated, VRAM for discrete. A power-gated GPU clock shows as `IDLE`.
|
||||
|
||||
Estimated watts (`~W`) are a share of measured CPU-package energy, not wall power. They only run in the expanded view, and you can turn them off.
|
||||
|
||||
If your kernel hides RAPL energy from users, an optional helper package can read it without a password prompt. The plugin never asks for sudo while you are watching the panel.
|
||||
|
||||
```bash
|
||||
makepkg --cleanbuild --install
|
||||
sudo pacman -Rns omarchy-activity-monitor-power-helper # to remove it later
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
make -B activity-sampler
|
||||
./test/all.sh
|
||||
qmllint -I /usr/share/omarchy/shell ActivityController.qml Panel.qml ProcessActionController.qml Sparkline.qml
|
||||
omarchy plugin validate .
|
||||
```
|
||||
|
||||
Licensed under the MIT License. Started as an Omarchy activity panel and maintained as an independent plugin by Kristoffer Haugland.
|
||||
@@ -0,0 +1,68 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
|
||||
Canvas {
|
||||
id: root
|
||||
|
||||
property var values: []
|
||||
property color lineColor: Color.accent
|
||||
property color fillColor: Util.alpha(lineColor, 0.12)
|
||||
property real ceiling: 0
|
||||
|
||||
antialiasing: true
|
||||
|
||||
onValuesChanged: requestPaint()
|
||||
onLineColorChanged: requestPaint()
|
||||
onFillColorChanged: requestPaint()
|
||||
onCeilingChanged: requestPaint()
|
||||
onWidthChanged: requestPaint()
|
||||
onHeightChanged: requestPaint()
|
||||
|
||||
onPaint: {
|
||||
var context = getContext("2d")
|
||||
context.clearRect(0, 0, width, height)
|
||||
|
||||
var points = Array.isArray(values) ? values : []
|
||||
if (points.length === 0 || width <= 0 || height <= 0) return
|
||||
|
||||
var maximum = Number(ceiling)
|
||||
if (!isFinite(maximum) || maximum <= 0) {
|
||||
maximum = 1
|
||||
for (var i = 0; i < points.length; i++) {
|
||||
var candidate = Number(points[i])
|
||||
if (isFinite(candidate)) maximum = Math.max(maximum, candidate)
|
||||
}
|
||||
maximum *= 1.12
|
||||
}
|
||||
|
||||
var step = points.length > 1 ? width / (points.length - 1) : width
|
||||
var yFor = function(value) {
|
||||
var normalized = Math.max(0, Math.min(1, Number(value || 0) / maximum))
|
||||
return height - normalized * Math.max(1, height - Style.spacing.hairline)
|
||||
}
|
||||
|
||||
context.beginPath()
|
||||
context.moveTo(0, height)
|
||||
for (var j = 0; j < points.length; j++) {
|
||||
var x = points.length > 1 ? j * step : width
|
||||
context.lineTo(x, yFor(points[j]))
|
||||
}
|
||||
context.lineTo(width, height)
|
||||
context.closePath()
|
||||
context.fillStyle = fillColor
|
||||
context.fill()
|
||||
|
||||
context.beginPath()
|
||||
for (var k = 0; k < points.length; k++) {
|
||||
var lineX = points.length > 1 ? k * step : width
|
||||
var lineY = yFor(points[k])
|
||||
if (k === 0) context.moveTo(lineX, lineY)
|
||||
else context.lineTo(lineX, lineY)
|
||||
}
|
||||
context.strokeStyle = lineColor
|
||||
context.lineWidth = Math.max(1, Style.spaceReal(1.25))
|
||||
context.lineJoin = "round"
|
||||
context.lineCap = "round"
|
||||
context.stroke()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly helper=/usr/lib/stappmus-activity-monitor/activity-sampler
|
||||
|
||||
[[ $# -eq 0 ]] || exit 64
|
||||
[[ -x $helper ]] || exit 77
|
||||
exec sudo -n /usr/lib/stappmus-activity-monitor/activity-sampler --activity-process-power-reader
|
||||
Binary file not shown.
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Print system activity stats for the shell
|
||||
# omarchy:group=system
|
||||
# omarchy:args=[--bar-widget|--activity-reader|--activity-resources|--activity-processes|--activity-thermals|--activity-gpus|--activity-process-power|--activity-process-power-reader|--activity-storage]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly helper="${BASH_SOURCE[0]%/*}/activity-sampler"
|
||||
[[ -x $helper ]] || exit 77
|
||||
exec "$helper" "$@"
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "stappmus.activity-monitor",
|
||||
"name": "Activity Monitor",
|
||||
"version": "2.1.1",
|
||||
"author": "Kristoffer Haugland",
|
||||
"license": "MIT",
|
||||
"description": "A calm, low-overhead glance at CPU, memory, GPU, storage, and processes — with a die that matches your chip",
|
||||
"kinds": [
|
||||
"bar-widget"
|
||||
],
|
||||
"entryPoints": {
|
||||
"barWidget": "Panel.qml"
|
||||
},
|
||||
"barWidget": {
|
||||
"displayName": "Activity Monitor",
|
||||
"description": "A calm system glance with an expandable process view and a CPU die that matches your hardware.",
|
||||
"category": "System",
|
||||
"aliases": [
|
||||
"activity",
|
||||
"system-monitor",
|
||||
"btop"
|
||||
],
|
||||
"allowMultiple": false,
|
||||
"defaultSection": "right",
|
||||
"defaults": {
|
||||
"samplingSpeed": "Balanced",
|
||||
"historySamples": 60,
|
||||
"temperatureUnit": "Celsius",
|
||||
"showFrequencies": true,
|
||||
"openExpanded": false,
|
||||
"processPowerEnabled": true
|
||||
},
|
||||
"schema": [
|
||||
{
|
||||
"key": "samplingSpeed",
|
||||
"type": "enum",
|
||||
"label": "Update speed",
|
||||
"options": ["Efficient", "Balanced", "Fast"],
|
||||
"defaultValue": "Balanced",
|
||||
"description": "Efficient uses fewer samples; Fast makes the graphs react sooner."
|
||||
},
|
||||
{
|
||||
"key": "historySamples",
|
||||
"type": "integer",
|
||||
"label": "History samples",
|
||||
"min": 20,
|
||||
"max": 240,
|
||||
"step": 10,
|
||||
"defaultValue": 60
|
||||
},
|
||||
{
|
||||
"key": "temperatureUnit",
|
||||
"type": "enum",
|
||||
"label": "Temperature unit",
|
||||
"options": ["Celsius", "Fahrenheit"],
|
||||
"defaultValue": "Celsius"
|
||||
},
|
||||
{
|
||||
"key": "showFrequencies",
|
||||
"type": "boolean",
|
||||
"label": "Show hardware speeds",
|
||||
"defaultValue": true
|
||||
},
|
||||
{
|
||||
"key": "openExpanded",
|
||||
"type": "boolean",
|
||||
"label": "Open detailed view",
|
||||
"defaultValue": false
|
||||
},
|
||||
{
|
||||
"key": "processPowerEnabled",
|
||||
"type": "boolean",
|
||||
"label": "Process power estimates",
|
||||
"defaultValue": true,
|
||||
"description": "Disable to skip CPU package-energy sampling in the detailed view."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
@@ -0,0 +1,359 @@
|
||||
#!/usr/bin/python
|
||||
|
||||
# omarchy:summary=Safely signal a process selected in Activity Monitor
|
||||
# omarchy:group=system
|
||||
# omarchy:args=<TERM|KILL|APP_TERM> <PID> <START_TICKS>
|
||||
|
||||
"""Signal a same-user process or app root while guarding against PID reuse."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import select
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
APP_TERM_GRACE_MS = 3000
|
||||
PF_KTHREAD = 0x00200000
|
||||
DEAD_STATES = frozenset(("Z", "X", "x"))
|
||||
PROTECTED_NAMES = frozenset(
|
||||
(
|
||||
b"quickshell",
|
||||
b"hyprland",
|
||||
b"uwsm",
|
||||
b"systemd",
|
||||
b"systemd-executo",
|
||||
b"systemd-executor",
|
||||
b"(sd-pam)",
|
||||
b"dbus-broker",
|
||||
b"dbus-broker-lau",
|
||||
b"omarchy-hyprlan",
|
||||
)
|
||||
)
|
||||
PROC_ROOT = Path("/proc")
|
||||
|
||||
|
||||
def fail(message: str, status: int = 1) -> None:
|
||||
print(message, file=sys.stderr)
|
||||
raise SystemExit(status)
|
||||
|
||||
|
||||
class InspectionError(Exception):
|
||||
"""A procfs identity record could not be read or validated."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProcessStat:
|
||||
pid: int
|
||||
name: bytes
|
||||
state: str
|
||||
parent_pid: int
|
||||
flags: int
|
||||
start_ticks: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProcessStatus:
|
||||
pid: int
|
||||
state: str
|
||||
user_ids: tuple[int, int, int, int]
|
||||
|
||||
|
||||
def read_process_stat(proc_root: Path, pid: int) -> ProcessStat:
|
||||
try:
|
||||
raw = (proc_root / str(pid) / "stat").read_bytes()
|
||||
except FileNotFoundError:
|
||||
raise InspectionError(f"Process {pid} no longer exists") from None
|
||||
except OSError as error:
|
||||
raise InspectionError(f"Could not inspect process {pid}: {error}") from error
|
||||
|
||||
command_start = raw.find(b"(")
|
||||
command_end = raw.rfind(b") ")
|
||||
if command_start < 1 or command_end <= command_start:
|
||||
raise InspectionError(f"Process {pid} returned an invalid stat record")
|
||||
|
||||
try:
|
||||
record_pid = int(raw[:command_start].strip())
|
||||
except ValueError:
|
||||
raise InspectionError(f"Process {pid} returned an invalid PID") from None
|
||||
if record_pid != pid:
|
||||
raise InspectionError(f"Process {pid} returned a mismatched stat record")
|
||||
|
||||
name = raw[command_start + 1 : command_end]
|
||||
|
||||
# The remaining fields begin with field 3 (state), making starttime
|
||||
# (field 22) zero-based index 19. Splitting after the final ") " also
|
||||
# preserves process names containing spaces or parentheses.
|
||||
fields = raw[command_end + 2 :].split()
|
||||
if len(fields) <= 19:
|
||||
raise InspectionError(f"Process {pid} returned an incomplete stat record")
|
||||
|
||||
try:
|
||||
state = fields[0].decode("ascii")
|
||||
parent_pid = int(fields[1])
|
||||
flags = int(fields[6])
|
||||
start_ticks = int(fields[19])
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
raise InspectionError(f"Process {pid} returned malformed stat fields") from None
|
||||
|
||||
if len(state) != 1 or not state.isalpha():
|
||||
raise InspectionError(f"Process {pid} returned an invalid state")
|
||||
if parent_pid < 0 or flags < 0:
|
||||
raise InspectionError(f"Process {pid} returned invalid process metadata")
|
||||
if start_ticks <= 0:
|
||||
raise InspectionError(f"Process {pid} returned an invalid start time")
|
||||
|
||||
return ProcessStat(record_pid, name, state, parent_pid, flags, start_ticks)
|
||||
|
||||
|
||||
def read_process_status(proc_root: Path, pid: int) -> ProcessStatus:
|
||||
try:
|
||||
lines = (proc_root / str(pid) / "status").read_text(
|
||||
encoding="ascii", errors="strict"
|
||||
).splitlines()
|
||||
except FileNotFoundError:
|
||||
raise InspectionError(f"Process {pid} no longer exists") from None
|
||||
except (OSError, UnicodeError) as error:
|
||||
raise InspectionError(f"Could not inspect process {pid}: {error}") from error
|
||||
|
||||
values: dict[str, str] = {}
|
||||
for line in lines:
|
||||
key, separator, value = line.partition(":")
|
||||
if separator and key in ("Pid", "State", "Uid"):
|
||||
values[key] = value.strip()
|
||||
|
||||
if set(values) != {"Pid", "State", "Uid"}:
|
||||
raise InspectionError(f"Process {pid} returned an incomplete status record")
|
||||
|
||||
try:
|
||||
status_pid = int(values["Pid"])
|
||||
user_ids_raw = values["Uid"].split()
|
||||
if len(user_ids_raw) != 4:
|
||||
raise ValueError
|
||||
user_ids = tuple(int(value) for value in user_ids_raw)
|
||||
except ValueError:
|
||||
raise InspectionError(f"Process {pid} returned malformed ownership data") from None
|
||||
|
||||
state_fields = values["State"].split()
|
||||
state = state_fields[0] if state_fields else ""
|
||||
if status_pid != pid:
|
||||
raise InspectionError(f"Process {pid} returned a mismatched status record")
|
||||
if len(state) != 1 or not state.isalpha():
|
||||
raise InspectionError(f"Process {pid} returned an invalid status state")
|
||||
if any(user_id < 0 for user_id in user_ids):
|
||||
raise InspectionError(f"Process {pid} returned invalid ownership data")
|
||||
|
||||
return ProcessStatus(status_pid, state, user_ids) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def protected_processes(proc_root: Path) -> set[int]:
|
||||
"""Return this helper and every currently observable ancestor."""
|
||||
protected: set[int] = set()
|
||||
current_pid = os.getpid()
|
||||
|
||||
# A process cannot have a legitimate ancestry chain this deep. The bound
|
||||
# also makes malformed procfs data fail closed without looping forever.
|
||||
for _ in range(256):
|
||||
if current_pid <= 0 or current_pid in protected:
|
||||
break
|
||||
protected.add(current_pid)
|
||||
try:
|
||||
current_pid = read_process_stat(proc_root, current_pid).parent_pid
|
||||
except InspectionError:
|
||||
break
|
||||
|
||||
# getppid() remains useful if the parent exited between walking self/stat
|
||||
# and reading its record.
|
||||
parent_pid = os.getppid()
|
||||
if parent_pid > 0:
|
||||
protected.add(parent_pid)
|
||||
return protected
|
||||
|
||||
|
||||
def reject_unsafe_target(
|
||||
process_stat: ProcessStat, process_status: ProcessStatus, expected_start_ticks: int
|
||||
) -> None:
|
||||
if process_stat.start_ticks != expected_start_ticks:
|
||||
fail(f"Process {process_stat.pid} changed before it could be signaled")
|
||||
if process_stat.flags & PF_KTHREAD:
|
||||
fail("Refusing to signal a kernel thread", 77)
|
||||
if process_stat.state in DEAD_STATES or process_status.state in DEAD_STATES:
|
||||
fail("Refusing to signal a zombie or dead process", 77)
|
||||
if process_stat.name.lower() in PROTECTED_NAMES:
|
||||
fail("Refusing to signal a protected desktop-session process", 77)
|
||||
|
||||
current_uid = os.getuid()
|
||||
if any(user_id != current_uid for user_id in process_status.user_ids):
|
||||
fail("Refusing to signal a process owned by another user", 77)
|
||||
|
||||
|
||||
def safe_app_ancestor(
|
||||
process_stat: ProcessStat,
|
||||
process_status: ProcessStatus,
|
||||
expected_name: bytes,
|
||||
) -> bool:
|
||||
"""Return whether a parent is safe to adopt as this app's signal target."""
|
||||
return (
|
||||
process_stat.name == expected_name
|
||||
and not process_stat.flags & PF_KTHREAD
|
||||
and process_stat.state not in DEAD_STATES
|
||||
and process_status.state not in DEAD_STATES
|
||||
and all(user_id == os.getuid() for user_id in process_status.user_ids)
|
||||
)
|
||||
|
||||
|
||||
def app_signal_target(
|
||||
proc_root: Path,
|
||||
selected_stat: ProcessStat,
|
||||
selected_fd: int,
|
||||
protected: set[int],
|
||||
) -> tuple[ProcessStat, int, list[int]]:
|
||||
"""Resolve the highest safe, direct same-name ancestor of a selected process."""
|
||||
current_stat = selected_stat
|
||||
current_fd = selected_fd
|
||||
opened_fds = [selected_fd]
|
||||
seen = {selected_stat.pid}
|
||||
|
||||
# The bound fails closed on malformed procfs ancestry without allowing an
|
||||
# unbounded walk. Each retained pidfd also prevents a validated identity
|
||||
# from silently becoming a recycled PID while the chain is resolved.
|
||||
for _ in range(256):
|
||||
parent_pid = current_stat.parent_pid
|
||||
if parent_pid <= 1 or parent_pid in protected or parent_pid in seen:
|
||||
break
|
||||
|
||||
try:
|
||||
parent_fd = os.pidfd_open(parent_pid)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
break
|
||||
|
||||
try:
|
||||
parent_stat = read_process_stat(proc_root, parent_pid)
|
||||
parent_status = read_process_status(proc_root, parent_pid)
|
||||
current_check = read_process_stat(proc_root, current_stat.pid)
|
||||
except InspectionError:
|
||||
os.close(parent_fd)
|
||||
break
|
||||
|
||||
# Recheck the direct edge after opening the parent pidfd. This avoids
|
||||
# promoting a process that ceased to be the selected app's parent
|
||||
# during inspection.
|
||||
if (
|
||||
current_check.start_ticks != current_stat.start_ticks
|
||||
or current_check.parent_pid != parent_pid
|
||||
or current_check.name != current_stat.name
|
||||
or not safe_app_ancestor(
|
||||
parent_stat, parent_status, selected_stat.name
|
||||
)
|
||||
):
|
||||
os.close(parent_fd)
|
||||
break
|
||||
|
||||
opened_fds.append(parent_fd)
|
||||
seen.add(parent_pid)
|
||||
current_stat = parent_stat
|
||||
current_fd = parent_fd
|
||||
|
||||
return current_stat, current_fd, opened_fds
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Signal a same-user process or matching app root if its PID and "
|
||||
"start time still match."
|
||||
)
|
||||
)
|
||||
parser.add_argument("signal_name", choices=("TERM", "KILL", "APP_TERM"))
|
||||
parser.add_argument("pid", type=int)
|
||||
parser.add_argument("start_ticks", type=int)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
if args.pid <= 1:
|
||||
fail("Refusing to signal a protected system process", 77)
|
||||
if args.start_ticks <= 0:
|
||||
fail("Process start time must be positive", 64)
|
||||
protected = protected_processes(PROC_ROOT)
|
||||
if args.pid in protected:
|
||||
fail("Refusing to signal Activity Monitor or its ancestor chain", 77)
|
||||
|
||||
try:
|
||||
pid_fd = os.pidfd_open(args.pid)
|
||||
except ProcessLookupError:
|
||||
fail(f"Process {args.pid} no longer exists")
|
||||
except OSError as error:
|
||||
fail(f"Could not open process {args.pid}: {error}")
|
||||
|
||||
opened_fds = [pid_fd]
|
||||
app_result = ""
|
||||
target_stat: ProcessStat | None = None
|
||||
target_fd = pid_fd
|
||||
try:
|
||||
try:
|
||||
process_stat = read_process_stat(PROC_ROOT, args.pid)
|
||||
process_status = read_process_status(PROC_ROOT, args.pid)
|
||||
except InspectionError as error:
|
||||
fail(str(error))
|
||||
|
||||
reject_unsafe_target(process_stat, process_status, args.start_ticks)
|
||||
if args.signal_name == "APP_TERM":
|
||||
target_stat, target_fd, opened_fds = app_signal_target(
|
||||
PROC_ROOT, process_stat, pid_fd, protected
|
||||
)
|
||||
try:
|
||||
target_check = read_process_stat(PROC_ROOT, target_stat.pid)
|
||||
target_status = read_process_status(PROC_ROOT, target_stat.pid)
|
||||
except InspectionError as error:
|
||||
fail(str(error))
|
||||
reject_unsafe_target(
|
||||
target_check, target_status, target_stat.start_ticks
|
||||
)
|
||||
if target_check.name != process_stat.name:
|
||||
fail("App process identity changed before it could be signaled")
|
||||
requested_signal = signal.SIGTERM
|
||||
else:
|
||||
target_stat = process_stat
|
||||
requested_signal = getattr(signal, f"SIG{args.signal_name}")
|
||||
|
||||
signal.pidfd_send_signal(target_fd, requested_signal)
|
||||
if args.signal_name == "APP_TERM":
|
||||
target_poll = select.poll()
|
||||
target_poll.register(target_fd, select.POLLIN)
|
||||
if target_poll.poll(APP_TERM_GRACE_MS):
|
||||
app_result = "graceful"
|
||||
else:
|
||||
try:
|
||||
signal.pidfd_send_signal(target_fd, signal.SIGKILL)
|
||||
app_result = "escalated"
|
||||
except ProcessLookupError:
|
||||
# The exact pidfd target exited between the grace-period
|
||||
# timeout and escalation, so no force signal was needed.
|
||||
app_result = "graceful"
|
||||
except ProcessLookupError:
|
||||
target_pid = target_stat.pid if target_stat else args.pid
|
||||
fail(f"Process {target_pid} no longer exists")
|
||||
except PermissionError:
|
||||
target_pid = target_stat.pid if target_stat else args.pid
|
||||
fail(f"Permission denied while signaling process {target_pid}", 77)
|
||||
except OSError as error:
|
||||
target_pid = target_stat.pid if target_stat else args.pid
|
||||
fail(f"Could not signal process {target_pid}: {error}")
|
||||
finally:
|
||||
for opened_fd in opened_fds:
|
||||
os.close(opened_fd)
|
||||
|
||||
result = f"signaled\t{target_stat.pid}\t{args.signal_name}"
|
||||
if app_result:
|
||||
result += f"\t{app_result}"
|
||||
print(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,13 @@
|
||||
const activity = require('../Model.js')
|
||||
|
||||
const sizes = {
|
||||
gap: 3,
|
||||
domainGap: 8,
|
||||
pad: 6,
|
||||
performance: 18,
|
||||
efficiency: 12,
|
||||
same: 12
|
||||
}
|
||||
|
||||
const catalog = activity.previewCoreCatalog(sizes)
|
||||
process.stdout.write(JSON.stringify(catalog, null, 2))
|
||||
@@ -0,0 +1 @@
|
||||
%wheel ALL=(root) NOPASSWD: /usr/lib/stappmus-activity-monitor/activity-sampler --activity-process-power-reader
|
||||
@@ -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