diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..b44d1ae --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,44 @@ +name: Build + +on: + push: + pull_request: + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + + # Alire brings the GNAT toolchain, and picks PJ_OS for the system it runs on + - uses: alire-project/setup-alire@v6 + + - name: Build the static library + run: alr --non-interactive build + + - name: Build the relocatable (shared) library + run: alr exec -- gprbuild -P cpuload.gpr -XCPULOAD_LIBRARY_TYPE=relocatable -p + + - name: Build the Ada example + run: alr exec -- gprbuild -P example/example.gpr -p + + - name: Build the C example (Linux and macOS) + if: runner.os != 'Windows' + run: alr exec -- make -C example/c + + - name: Build the C example (Windows) + if: runner.os == 'Windows' + run: | + # make is not installed on the Windows runner, unlike gcc + # The Makefile runs its commands in cmd, so the default PowerShell shell of the runner is fine + choco install make --no-progress -y + alr exec -- make -C example/c + + # The Python example is not compiled, ctypes calls the shared library directly, so this only builds the library it loads and puts it where the example looks + # make is on every runner by this point, the Windows step above having installed it + - name: Build the library the Python example loads + run: alr exec -- make -C example/python diff --git a/README.md b/README.md index 9321109..3026e2d 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,17 @@ The library is thread-safe, written in Ada, and also provides a [C interface](in | What is measured | OS | Method | |---|---|---| | The whole system | Linux | The `cpu` line of `/proc/stat` | +| The whole system | macOS | `host_statistics`, the machine's own CPU counters | | The whole system | Windows | `GetSystemTimes` | | One process, by its number | Linux | `utime` + `stime` of `/proc//stat` | +| One process, by its number | macOS | `proc_pidinfo`, the user and system time of the process | | One process, by its number | Windows | `OpenProcess` + `GetProcessTimes` | | An application, every process of it | Linux | `/proc` scanned, each process named by `/proc//comm` | +| An application, every process of it | macOS | `proc_listpids`, each process named by `proc_pidpath` | | An application, every process of it | Windows | `EnumProcesses` + `QueryFullProcessImageNameW` | -macOS and BSD support is planned and will come in a future version. +macOS is supported on Apple Silicon. +BSD support is planned and will come in a future version. ## Building @@ -32,18 +36,23 @@ alr build Or directly with GNAT: ```bash -gprbuild -P cpuload.gpr +gprbuild -P cpuload.gpr -XPJ_OS=macos ``` -The build produces a static library by default, and will detect the OS to compile the appropriate version. You can specify a specific OS to compile with `-XPJ_OS` (ex. `-XPJ_OS=windows`) to gprbuild (Alire sets it on its own). +The build produces a static library by default. `-XPJ_OS` says which system to build for: `linux`, `macos` or `windows`. Windows is the only one recognised on its own, as nothing tells macOS from Linux at build time, so **pass `-XPJ_OS` yourself on the other two**. Alire sets it on its own, and so do the Makefiles of the examples, which ask `uname`. A library built for another system reads no counters at all and reports 0% for everything. For other library types (shared, etc.), set `-XCPULOAD_LIBRARY_TYPE`: ```bash -gprbuild -P cpuload.gpr -XCPULOAD_LIBRARY_TYPE=relocatable +gprbuild -P cpuload.gpr -XPJ_OS=macos -XCPULOAD_LIBRARY_TYPE=relocatable ``` -`relocatable` builds the shared library (`libCPU_Load.so` / `.dll`) that carries the C interface, is standalone (it starts itself up when loaded), and is encapsulated (it carries the Ada runtime too, so it is one self-contained file). +`relocatable` builds the shared library (`libCPU_Load.so` / `.dll` / `.dylib`) that carries the C interface and is standalone: it starts itself up when loaded. On Linux and Windows it is encapsulated as well, carrying the Ada runtime with it, so it is one self-contained file. +gprbuild cannot encapsulate on macOS, so the library reads the Ada runtime from its own file there, and looks for it next to itself. Copy it in once the library is built (the Makefiles of the examples do this for you): + +```bash +cp "$(gnatls -v | grep adalib | tr -d ' ')"/libgnat-*.dylib lib/relocatable/ +``` ## Using from Ada @@ -75,10 +84,12 @@ The whole interface is five functions: `Sample`, the three `Take` functions (the A full example program is in [example/src/example_cpu_load.adb](example/src/example_cpu_load.adb). It follows the machine, itself, and an application named on the command line, once per second until stopped with Ctrl+C: ```bash -gprbuild -P example/example.gpr -p +gprbuild -P example/example.gpr -XPJ_OS=macos -p ./example/example_cpu_load firefox ``` +Give `-XPJ_OS` here too: the example builds the library with it, and one built for another system reads no counters at all and reports 0% for everything. + With Alire, add the library to your project with `alr with cpuload`. ## Using from C (and any other language) @@ -107,7 +118,7 @@ make -C example/c run APP=firefox To build it by hand instead, from the root of the repository, first compile the library: ```bash -gprbuild -P cpuload.gpr -XCPULOAD_LIBRARY_TYPE=relocatable +gprbuild -P cpuload.gpr -XPJ_OS=macos -XCPULOAD_LIBRARY_TYPE=relocatable ``` Then compile the C program: @@ -116,7 +127,7 @@ Then compile the C program: gcc example/c/main.c -Iinclude -Llib/relocatable -lCPU_Load -Wl,-rpath,"$PWD/lib/relocatable" -o example/c/example_c ``` -`-I` is the folder holding `cpuload.h`, `-L` and `-l` the library to link with, and `-rpath` the folder where the program looks for the library when it runs. Without `-rpath`, the program still compiles but stops on start with a "library not loaded" error, unless you set `LD_LIBRARY_PATH` yourself. Windows has no `-rpath`: put a copy of the DLL next to the program instead (which is what the Makefile does). +`-I` is the folder holding `cpuload.h`, `-L` and `-l` the library to link with, and `-rpath` the folder where the program looks for the library when it runs. Without `-rpath`, the program still compiles but stops on start with a "library not loaded" error, unless you set `LD_LIBRARY_PATH` yourself. macOS works the same way, as long as the Ada runtime sits next to the library as above. Windows has no `-rpath`: put a copy of the DLL next to the program instead (which is what the Makefile does). ## Using from Python @@ -128,7 +139,7 @@ import ctypes, time class Sample(ctypes.Structure): _fields_ = [("busy", ctypes.c_int64), ("total", ctypes.c_int64), ("used", ctypes.c_int64)] -lib = ctypes.CDLL("lib/relocatable/libCPU_Load.so") +lib = ctypes.CDLL("lib/relocatable/libCPU_Load.so") # libCPU_Load.dylib on macOS, CPU_Load.dll on Windows lib.cpuload_system_usage.restype = ctypes.c_double lib.cpuload_process_usage.restype = ctypes.c_double lib.cpuload_version.restype = ctypes.c_char_p @@ -149,7 +160,7 @@ Java (through FFM or JNA), Rust (through `libloading` or FFI declarations), and ## Adding a new OS -The package spec [src/cpu_load.ads](src/cpu_load.ads) is shared by every OS, and holds the two usage functions, which are pure arithmetic on a pair of samples. Each OS brings its own body of the three `Take` functions ([src/linux](src/linux/cpu_load.adb), [src/windows](src/windows/cpu_load.adb)), and [cpuload.gpr](cpuload.gpr) picks the folder for the OS being built from the `PJ_OS` symbol. To support a new OS, write the implementation body and add its folder there. +The package spec [src/cpu_load.ads](src/cpu_load.ads) is shared by every OS, and holds the usage functions. Each OS brings its own body of the three `Take` functions ([src/linux](src/linux/cpu_load.adb), [src/macos](src/macos/cpu_load.adb), [src/windows](src/windows/cpu_load.adb)), and [cpuload.gpr](cpuload.gpr) picks the folder for the OS being built from the `PJ_OS` symbol. To support a new OS, write the implementation body and add its folder there. ## 📜 License diff --git a/alire.toml b/alire.toml index a22cfba..6386b9d 100644 --- a/alire.toml +++ b/alire.toml @@ -1,6 +1,6 @@ name = "cpuload" description = "Library to monitor processes, applications and system CPU usage" -version = "0.0.1" +version = "0.0.2" long-description = """ CPU Load reports system, processes and applications (every process of it) CPU load. @@ -11,17 +11,25 @@ authors = ["Adel Noureddine"] maintainers = ["Adel Noureddine "] maintainers-logins = ["adelnoureddine"] -tags = ["cpu", "process", "monitoring", "linux", "windows", "application", "usage", "load"] +tags = ["cpu", "process", "monitoring", "linux", "windows", "macos", "application", "usage", "load"] licenses = "LGPL-3.0-only" website = "https://www.noureddine.org/research/joular" [gpr-externals] -PJ_OS = ["linux", "windows"] +PJ_OS = ["linux", "windows", "macos"] CPULOAD_LIBRARY_TYPE = ["static", "relocatable", "static-pic"] [gpr-set-externals."case(os)".linux] PJ_OS = "linux" [gpr-set-externals."case(os)".windows] PJ_OS = "windows" +[gpr-set-externals."case(os)".macos] +PJ_OS = "macos" + +[available.'case(os)'] +linux = true +windows = true +macos = true +'...' = false diff --git a/cpuload.gpr b/cpuload.gpr index 46f8b6b..60ad840 100755 --- a/cpuload.gpr +++ b/cpuload.gpr @@ -14,7 +14,7 @@ library project CpuLoad is -- Symbols to indicate which OS to compile for -- Build with: gprbuild -XPJ_OS=windows - type OS_Platform is ("linux", "windows"); --, "macos", "bsd"); + type OS_Platform is ("linux", "windows", "macos"); --, "bsd"); PJ_OS : OS_Platform := external ("PJ_OS", Default_OS); -- Default platform directory for each CPU load implementation @@ -25,8 +25,8 @@ library project CpuLoad is Platform_Dir := "src/linux"; when "windows" => Platform_Dir := "src/windows"; --- when "macos" => --- Platform_Dir := "src/macos"; + when "macos" => + Platform_Dir := "src/macos"; -- when "bsd" => -- Platform_Dir := "src/bsd"; end case; @@ -47,18 +47,18 @@ library project CpuLoad is for Library_Kind use Library_Type; for Create_Missing_Dirs use "True"; - -- The relocatable (shared) library is stand-alone and encapsulated: it carries the Ada runtime and starts itself up when loaded, so programs in other languages (C, Python, Java, Rust, etc.) can simply load it and call the functions of include/cpuload.h, with nothing else needed + -- The relocatable (shared) library is stand-alone: it starts itself up when loaded, so programs in other languages (C, Python, Java, Rust, etc.) can simply load it and call the functions of include/cpuload.h, with nothing else needed + -- It is encapsulated as well where it can be, meaning it carries the Ada runtime and is one self-contained file case Library_Type is when "relocatable" => for Library_Interface use ("CPU_Load", "CPU_Load.C_API"); - for Library_Standalone use "encapsulated"; --- case PJ_OS is --- when "macos" => + case PJ_OS is + when "macos" => -- gprbuild cannot encapsulate on macOS, so the library loads the Ada runtime from its own file there --- for Library_Standalone use "standard"; --- when others => --- for Library_Standalone use "encapsulated"; --- end case; + for Library_Standalone use "standard"; + when others => + for Library_Standalone use "encapsulated"; + end case; case PJ_OS is when "linux" => @@ -66,6 +66,9 @@ library project CpuLoad is when "windows" => -- Linker_Options below is for whoever links against this library, and it comes too late for the library itself: the DLL calls EnumProcesses, so it has to link psapi on its own for Library_Options use ("-lpsapi"); + when "macos" => + -- Nothing to add: the counters the library reads are in libSystem, which every program links already + null; end case; when others => diff --git a/example/c/Makefile b/example/c/Makefile index d8b8883..48b9775 100755 --- a/example/c/Makefile +++ b/example/c/Makefile @@ -1,4 +1,4 @@ -# Builds the C example of CPU Load, on Linux and Windows +# Builds the C example of CPU Load, on Linux, macOS and Windows # # make builds the shared library and the example # make run builds it, then runs it (stop it with Ctrl+C) @@ -34,20 +34,29 @@ ifeq ($(OS),Windows_NT) else - # CPU Load has a body for Linux and for Windows only + # Nothing but Windows sets OS, so ask uname which of the others this is ifeq ($(shell uname),Darwin) - $(error CPU Load has no macOS implementation yet, so build this on Linux or Windows) + + PJ_OS = macos + + # gprbuild cannot put the Ada runtime inside the library on macOS, and the library looks for it next to itself, so put a copy there + ADALIB = $(shell gnatls -v | grep adalib | head -1 | tr -d ' ') + COPY_LIBRARY = cp -f $(ADALIB)/libgnat-*.dylib $(LIB_DIR) + + else + + PJ_OS = linux + + # The library carries the Ada runtime inside it, so nothing to copy + COPY_LIBRARY = true + endif - PJ_OS = linux EXE = example_c - # Linux records the folder of the library inside the program, so it is found when running + # Linux and macOS record the folder of the library inside the program, so it is found when running LDFLAGS += -Wl,-rpath,$(abspath $(LIB_DIR)) - # Nothing to copy - COPY_LIBRARY = true - RUN = ./$(EXE) $(APP) DELETE = rm -f diff --git a/example/python/Makefile b/example/python/Makefile index f3e1261..bbb725a 100644 --- a/example/python/Makefile +++ b/example/python/Makefile @@ -1,4 +1,4 @@ -# Builds the shared library the Python example of CPU Load needs, on Linux and Windows +# Builds the shared library the Python example of CPU Load needs, on Linux, macOS and Windows # # make builds the shared library # make run builds it, then runs the example (stop it with Ctrl+C) @@ -31,17 +31,26 @@ ifeq ($(OS),Windows_NT) else - # CPU Load has a body for Linux and for Windows only + # Nothing but Windows sets OS, so ask uname which of the others this is ifeq ($(shell uname),Darwin) - $(error CPU Load has no macOS implementation yet, so build this on Linux or Windows) + + PJ_OS = macos + + # gprbuild cannot put the Ada runtime inside the library on macOS, and the library looks for it next to itself, so put a copy there + ADALIB = $(shell gnatls -v | grep adalib | head -1 | tr -d ' ') + COPY_LIBRARY = cp -f $(ADALIB)/libgnat-*.dylib $(LIB_DIR) + + else + + PJ_OS = linux + + # Linux records the folder of the Ada runtime inside the library, so nothing to copy + COPY_LIBRARY = true + endif - PJ_OS = linux PYTHON = python3 - # Linux records the folder of the Ada runtime inside the library, so nothing to copy - COPY_LIBRARY = true - CLEAN = rm -f *.dll && rm -rf __pycache__ endif diff --git a/example/python/main.py b/example/python/main.py index 6b3d144..bb9cb39 100644 --- a/example/python/main.py +++ b/example/python/main.py @@ -60,14 +60,13 @@ def library_names(): """The name the shared library takes on this OS.""" if sys.platform == "win32": return ("libCPU_Load.dll", "CPU_Load.dll") + if sys.platform == "darwin": + return ("libCPU_Load.dylib",) return ("libCPU_Load.so",) def find_library(): """The file holding the shared library, or a message on how to build it when there is none.""" - if sys.platform == "darwin": - sys.exit("CPU Load has no macOS implementation yet, so run this on Linux or Windows") - # Look next to this program first, as Windows has no rpath and wants a copy # of the DLL there, then in the folder gprbuild builds the library into for folder in (Path(__file__).resolve().parent, LIBRARY_DIR): diff --git a/example/src/example_cpu_load.adb b/example/src/example_cpu_load.adb index b516ac9..385bef6 100755 --- a/example/src/example_cpu_load.adb +++ b/example/src/example_cpu_load.adb @@ -11,8 +11,8 @@ -- Prints the CPU load of the machine, of this very program, and of an application named on the command line, every second, until stopped with Ctrl+C -- --- Build and run it with: --- gprbuild -P example/example.gpr -p +-- Build and run it with (-XPJ_OS says which system to build for: linux, macos or windows): +-- gprbuild -P example/example.gpr -XPJ_OS=macos -p -- ./example/example_cpu_load firefox with Ada.Command_Line; use Ada.Command_Line; @@ -25,6 +25,10 @@ with System.Multiprocessors; with CPU_Load; use CPU_Load; +-- A Sample's counters are Integer_64, so comparing one needs its operators here +with Interfaces; +use type Interfaces.Integer_64; + procedure Example_CPU_Load is -- Time between two samples @@ -52,6 +56,7 @@ procedure Example_CPU_Load is Mine_Colour : constant String := Escape & "[1;35m"; App_Colour : constant String := Escape & "[1;33m"; Ready_Colour : constant String := Escape & "[1;32m"; + Trouble_Colour : constant String := Escape & "[1;31m"; -- Goes back to the beginning of the line and erases it, so each reading overwrites the previous one instead of scrolling Clear_Line : constant String := ASCII.CR & Escape & "[2K"; @@ -116,6 +121,16 @@ begin Mine_Before := Take (Ours); App_Before := Take (App); + -- A Total of zero is the library saying it could not read the machine's counters at all + -- It is what a library built for another system does here, and it would otherwise show as a row of 0% every second, which looks like an idle machine rather than a build to redo + if Machine_Before.Total = 0 then + Put_Line (Trouble_Colour + & "The machine's counters could not be read at all." + & " This is what a library built for another system does: build it again with -XPJ_OS for this one (linux, macos or windows)." + & Reset); + return; + end if; + while not Stop_Asked loop delay Interval; diff --git a/include/cpuload.h b/include/cpuload.h index 107ba06..761f9b0 100644 --- a/include/cpuload.h +++ b/include/cpuload.h @@ -12,7 +12,7 @@ /* * C interface of CPU Load, a library reporting how much of a machine's CPU is in use: the whole system, one process by its number, or an application, meaning every process running it * - * Use it with the relocatable (shared) build of the library (libCPU_Load.so on Linux, CPU_Load.dll on Windows), which starts itself up when loaded: no other initialization call is needed + * Use it with the relocatable (shared) build of the library (libCPU_Load.so on Linux, libCPU_Load.dylib on macOS, CPU_Load.dll on Windows), which starts itself up when loaded: no other initialization call is needed * * Library is thread-safe * @@ -50,6 +50,7 @@ void cpuload_take_pid(unsigned int pid, cpuload_sample *out); /* Take a sample of the system and of every process running the named application * The name is the program's own, without its folder, and it is exactly matched and case insensitive, so "firefox" finds "Firefox" + * On macOS the program's own name is matched the same way, so "firefox" finds the firefox inside Firefox.app * On Windows a trailing ".exe" is ignored as well, so "firefox" also finds "firefox.exe" */ void cpuload_take_app(const char *app, cpuload_sample *out); diff --git a/src/cpu_load.ads b/src/cpu_load.ads index ff88201..d5c9cb5 100755 --- a/src/cpu_load.ads +++ b/src/cpu_load.ads @@ -13,7 +13,7 @@ -- the system CPU usage -- a specific process CPU usage -- an application CPU usage (including all its processes, tracked on creation/destruction) --- CPU Load works on Linux and Windows, and is thread-safe +-- CPU Load works on Linux, macOS and Windows, and is thread-safe -- How to use it: take a sample, wait, take another sample, calculate CPU load -- Before := Take ("firefox"); -- delay 1.0; @@ -47,6 +47,7 @@ package CPU_Load is -- Take a sample of an application (all of its PIDs) -- On Linux, application name is case-insensitive but with exact match + -- On macOS, also case-insensitive with exact match, on the name of the program itself, so "firefox" matches the firefox inside Firefox.app -- On Windows, also, the trailing ".exe" is ignored (so "firefox" will also match "firefox.exe") -- An empty string means taking a sample reading of the entire system only function Take (App : in String) return Sample; @@ -81,7 +82,7 @@ package CPU_Load is function Version return String is ( -- Keep it the same as the version in alire.toml - "0.0.1" + "0.0.2" ); end CPU_Load; \ No newline at end of file diff --git a/src/macos/cpu_load.adb b/src/macos/cpu_load.adb new file mode 100644 index 0000000..8e03dd2 --- /dev/null +++ b/src/macos/cpu_load.adb @@ -0,0 +1,299 @@ +-- +-- Copyright (c) 2026, Adel Noureddine. +-- All rights reserved. This program and the accompanying materials +-- are made available under the terms of the +-- GNU Lesser General Public License v3.0 only (LGPL-3.0-only) +-- which accompanies this distribution, and is available at: +-- https://www.gnu.org/licenses/lgpl-3.0.en.html +-- +-- Author : Adel Noureddine +-- + +with Interfaces.C; +with System; +with Ada.Characters.Handling; +with GNAT.Directory_Operations; + +package body CPU_Load is + + -- Variables for macOS types + subtype Mach_Port is Interfaces.C.unsigned; -- The port + subtype Kern_Return is Interfaces.C.int; -- What a machine call answers, zero when it worked + subtype Counter is Interfaces.C.unsigned; -- The 32 bits numbers the machine counts its time in + + Kern_Success : constant Kern_Return := 0; + + use type Interfaces.C.int; + use type Interfaces.C.unsigned; + + -- What each call is asked for + CPU_Load_Info : constant Interfaces.C.int := 3; -- The machine's CPU counters + Task_Times_Wanted : constant Interfaces.C.int := 4; -- One process's CPU counters + All_Processes : constant Interfaces.C.unsigned := 1; -- Every process running + + Room_For : constant := 4096; + Bytes_Per_Number : constant := Interfaces.C.int'Size / 8; + + -- The machine refuses to write a program's path into any smaller buffer + Path_Max : constant := 1_024; + + type Number_Array is array (Positive range <>) of aliased Interfaces.C.int; + + -- The machine's four counters, in the order it fills them + -- Each is counted once per CPU core, so a machine of twelve cores counts twelve seconds of time per second + type CPU_State is (User_Time, System_Time, Idle_Time, Nice_Time); + type CPU_Ticks is array (CPU_State) of Counter with Convention => C; + + -- macOS needs this as 16 bytes (four numbers of 32 bits) + pragma Compile_Time_Error + (CPU_Ticks'Size /= 128, "host_cpu_load_info must be exactly 16 bytes"); + + -- A tick is a hundredth of a second (the hz of kern.clockrate, which is 100 on macOS) + -- A process is counted in another unit altogether, so both are turned into nanoseconds here and the two can then be compared + Nanoseconds_Per_Tick : constant := 10_000_000; + + -- The counters of one process, as the machine writes them + -- It writes the whole record or nothing at all, so all of it has to be here, though only the two times are read + type Unread_Numbers is array (1 .. 64) of Interfaces.C.unsigned_char; + + type Task_Times is + record + Virtual_Size : Unsigned_64 := 0; + Resident_Size : Unsigned_64 := 0; + Total_User : Unsigned_64 := 0; + Total_System : Unsigned_64 := 0; + Rest : Unread_Numbers := (others => 0); -- Page faults, context switches, thread counts: none of it is read here + end record + with Convention => C; + + -- macOS needs this as 96 bytes + pragma Compile_Time_Error + (Task_Times'Size /= 96 * 8, "proc_taskinfo must be exactly 96 bytes"); + + Task_Times_Bytes : constant Interfaces.C.int := Task_Times'Size / 8; + + -- How the machine's own time units turn into nanoseconds: multiply by the first, divide by the second + type Timebase is + record + Numerator : Interfaces.C.unsigned := 1; + Denominator : Interfaces.C.unsigned := 1; + end record + with Convention => C; + + -------------------------------------------------- + + -- macOS helper functions + + -- The port + function Mach_Host_Self return Mach_Port + with Import, Convention => C, External_Name => "mach_host_self"; + + -- Write what was asked about the machine into Info, here its CPU counters + function Host_Statistics (Host : in Mach_Port; + Wanted : in Interfaces.C.int; + Info : in System.Address; + Room : access Counter) return Kern_Return + with Import, Convention => C, External_Name => "host_statistics"; + + -- The two numbers turning the machine's time units into nanoseconds + function Mach_Timebase (Info : access Timebase) return Kern_Return + with Import, Convention => C, External_Name => "mach_timebase_info"; + + -- Write the counters of one process into Info + function Proc_Info (PID : in Interfaces.C.int; + Wanted : in Interfaces.C.int; + Unused : in Unsigned_64; + Info : in System.Address; + Room : in Interfaces.C.int) return Interfaces.C.int + with Import, Convention => C, External_Name => "proc_pidinfo"; + + -- The full path of a process's program + function Proc_Path (PID : in Interfaces.C.int; + Buffer : in System.Address; + Room : in Interfaces.C.unsigned) return Interfaces.C.int + with Import, Convention => C, External_Name => "proc_pidpath"; + + -- Fill the array with process numbers + function List_Processes (Kind : in Interfaces.C.unsigned; + Unused : in Interfaces.C.unsigned; + Buffer : in System.Address; + Room : in Interfaces.C.int) return Interfaces.C.int + with Import, Convention => C, External_Name => "proc_listpids"; + + -------------------------------------------------- + + -- Read the two numbers turning the machine's time units into nanoseconds + -- They are 125 and 3 on Apple Silicon, where a process is counted in 24 MHz units, but the machine is asked for them rather than told + function Read_Timebase return Timebase is + Answer : aliased Timebase; + begin + if Mach_Timebase (Answer'Access) /= Kern_Success + or else Answer.Denominator = 0 + then + -- Leave the times as they are, rather than divide by zero + return (Numerator => 1, Denominator => 1); + end if; + + return Answer; + end Read_Timebase; + + -- Asked once: the machine hands out a new right to its port on every call, and every one of them would have to be given back + Host : constant Mach_Port := Mach_Host_Self; + + -- Asked once as well, as it does not change while the machine is running + Time_Unit : constant Timebase := Read_Timebase; + + -------------------------------------------------- + + -- Returns the name of a program by its PID, in lower case + -- Returns "" if it doesn't have a name or any other issue + function Program_Of (PID : in Process_ID) return String is + use GNAT.Directory_Operations; + use Ada.Characters.Handling; + + -- The path of the program, ex. /Applications/Firefox.app/Contents/MacOS/firefox + Buffer : String (1 .. Path_Max) := (others => ' '); + Filled : Interfaces.C.int; + begin + Filled := Proc_Path (PID => Interfaces.C.int (PID), + Buffer => Buffer'Address, + Room => Buffer'Length); + + if Filled <= 0 or else Natural (Filled) > Buffer'Length then + return ""; + end if; + + -- Keep the name of the program alone, without the folders leading to it + return To_Lower (Base_Name (Buffer (1 .. Natural (Filled)))); + exception + when others => + return ""; + end Program_Of; + + -------------------------------------------------- + + -- Measure a specific PID CPU time, in nanoseconds + -- Returns 0 if process does not exist, stopped, or belongs to another user (the machine only tells root about those) + function Ticks_Of_PID (PID : in Process_ID) return Integer_64 is + Times : Task_Times; + begin + -- The machine writes the whole record or nothing at all + if Proc_Info (PID => Interfaces.C.int (PID), + Wanted => Task_Times_Wanted, + Unused => 0, + Info => Times'Address, + Room => Task_Times_Bytes) /= Task_Times_Bytes + then + return 0; + end if; + + -- A process is counted in the machine's own time units, so they are turned into nanoseconds + -- Multiplied before divided, so the fraction is not lost on the way + return (Integer_64 (Times.Total_User) + Integer_64 (Times.Total_System)) + * Integer_64 (Time_Unit.Numerator) + / Integer_64 (Time_Unit.Denominator); + exception + when others => + return 0; + end Ticks_Of_PID; + + -------------------------------------------------- + + -- Measure CPU time of the entire system + function Measure_System return Sample is + -- The machine's counters, ex. 561652 323108 6266574 0 + -- user system idle nice + Ticks : CPU_Ticks := (others => 0); + Room : aliased Counter := Ticks'Length; + + Result : Sample; + begin + if Host_Statistics (Host => Host, + Wanted => CPU_Load_Info, + Info => Ticks'Address, + Room => Room'Access) /= Kern_Success + then + return Result; + end if; + + -- Everything the machine did other than idle + Result.Busy := (Integer_64 (Ticks (User_Time)) + + Integer_64 (Ticks (System_Time)) + + Integer_64 (Ticks (Nice_Time))) * Nanoseconds_Per_Tick; + + -- And the idle time to get total time + Result.Total := Result.Busy + + Integer_64 (Ticks (Idle_Time)) * Nanoseconds_Per_Tick; + + return Result; + exception + when others => + return (others => 0); + end Measure_System; + + -------------------------------------------------- + + function Take return Sample is (Measure_System); + + -------------------------------------------------- + + function Take (PID : in Process_ID) return Sample is + Result : Sample := Measure_System; + begin + if PID = 0 then + -- No PID, so return system CPU load + return Result; + else + Result.Used := Ticks_Of_PID (PID); + return Result; + end if; + end Take; + + -------------------------------------------------- + + function Take (App : in String) return Sample is + use Ada.Characters.Handling; + + Result : Sample := Measure_System; + + -- App name in lower case, so we can be case insensitive + App_Name : constant String := To_Lower (App); + + Numbers : Number_Array (1 .. Room_For) := (others => 0); + Filled : Interfaces.C.int; + Counted : Natural; + begin + -- No app name, so return system CPU load + if App = "" then + return Result; + end if; + + Filled := List_Processes (Kind => All_Processes, + Unused => 0, + Buffer => Numbers'Address, + Room => Room_For * Bytes_Per_Number); + + if Filled <= 0 then + return Result; + end if; + + Counted := Natural'Min (Natural (Filled) / Bytes_Per_Number, Room_For); + + for Walked in 1 .. Counted loop + -- Number 0 is the machine's own kernel, and a negative one is no process at all + -- Nothing is checked above: macOS counts its processes in the very numbers a Process_ID holds + if Numbers (Walked) > 0 then + -- Asking the name first is what keeps this cheap: a process that is not the one wanted is never asked for its times + if Program_Of (Process_ID (Numbers (Walked))) = App_Name then + Result.Used := Result.Used + Ticks_Of_PID (Process_ID (Numbers (Walked))); + end if; + end if; + end loop; + + return Result; + exception + when others => + return Result; + end Take; + +end CPU_Load;