diff --git a/Makefile.am b/Makefile.am index 5df89b4..2fa97a3 100644 --- a/Makefile.am +++ b/Makefile.am @@ -11,5 +11,18 @@ LIBS = -lg15daemon_client -lg15render -lpthread -lm -lrt IMCLUDES = -I g15stats_LDFLAGS = -avoid-version g15stats_SOURCES = g15stats.c g15stats.h +EXTRA_DIST = gpu.h tests/gpu-test.c tests/render-test.c tests/runtime-test.c tests/render-layouts.sh tests/render-images.sh +noinst_HEADERS += gpu.h +AUTOMAKE_OPTIONS = subdir-objects +check_PROGRAMS = gpu-test render-test runtime-test +TESTS = $(check_PROGRAMS) tests/render-layouts.sh +TEST_EXTENSIONS = .sh +SH_LOG_COMPILER = $(SHELL) +gpu_test_SOURCES = tests/gpu-test.c +gpu_test_LDADD = -lpthread +render_test_SOURCES = tests/render-test.c +render_test_LDADD = -lg15daemon_client -lg15render -lpthread -lm -lrt $(GTOP_LIBS) +runtime_test_SOURCES = tests/runtime-test.c +runtime_test_LDADD = -lg15render -lpthread -lm -lrt $(GTOP_LIBS) diff --git a/README b/README index 756cdfc..db581da 100644 --- a/README +++ b/README @@ -2,6 +2,113 @@ G15Stats - a CPU/Memory/Swap/Network/Battery/Temperature/Fan Speed/CPU Frequenci The G15Stats requires libgtop development packages to be installed before compilation. +GPU summary columns: +The default --gpu auto collects ALL PCI devices (sorted PCI address) bound +to amdgpu or nvidia at startup, including mixed vendors. Use --gpu dddd:bb:ss.f (also -gpu), for example +--gpu 0000:01:00.0, to select a particular card. --gpu off restores the original +layout and disables collection. An explicit missing/unsupported card shows N/A; +auto with no supported card leaves the original layout unchanged. Restart to +rediscover cards after hotplug. Intel and the nouveau/radeon drivers are not +supported. Missing tools, permissions or individual metrics produce N/A, not 0. + +With one GPU the 160x43 summary retains its existing 4/5 indicator rows and +labels. Graphs end at x=96 instead of 153; TOTAL moves to x=98. A divider +at x=105 separates the column at x=108..159. With two or more GPUs the left +x=0..50 shows compact CPU percent, R used system RAM, T total system RAM, +SWP percent, and (in five-row mode) GPU count. Four-row mode uses y=1,10,19,28; +five-row mode uses y=1,8,15,22,29. System RAM uses the same explicit units as VRAM. +Two GPU columns at x=54..104 and x=108..159 are visible together, with dividers +at x=51 and x=105 (the first column's glyphs end before its right divider). +Each GPU has five lines at y=1,8,15,22,29: vendor/index plus PCI bus:slot.function, +UTL percent, U used VRAM, T total VRAM, TEM temperature in Celsius (C). +For example NV1 01:00.0 and AMD2 11:00.0 identify different cards. Indices are +one-based positions in the selected PCI-sorted inventory, not NVIDIA ordinals; +startup diagnostics map each index to its full domain:bus:slot.function address. +More than two GPUs cycle through consecutive pairs every five seconds from +startup (1/2, 3/4, ..., then 1/2); the last odd card occupies the first column. +All cards are collected continuously, not just the visible pair. The display +refresh interval can delay a page change: each draw advances at most one pair, +so slow refresh never skips pairs. Inventory is bounded at 64 cards; +overflow/enumeration failure is an explicit startup error, never silent omission. +Use explicit PCI selection or off to recover. Hotplug requires restart. +The bottom info bar at y=37..42 and all non-summary screens retain their geometry. +Used and total are on separate lines so each retains an explicit MiB/GiB unit +(TiB/PiB for very large values), rounded to one decimal. Other screens do not +change. U/T refer to VRAM, not memory-controller utilization or system RAM; +on AMD APUs the driver may report only the reserved VRAM carveout. + +AMD reads gpu_busy_percent, mem_info_vram_used/total (bytes), and the selected +PCI device's hwmon/temp1_input (millidegrees C). NVIDIA optionally executes +/usr/bin/nvidia-smi with pci.bus_id,utilization.gpu,memory.used,memory.total,temperature.gpu +and --format=csv,noheader,nounits: one query for all NVIDIA cards, matched by PCI +identity (including NVIDIA's eight-digit domain form), not response order. No CUDA/NVML SDK +or additional build dependency is needed. Run as an ordinary user. + +Independent AMD workers and one batched NVIDIA worker wait two seconds between +queries; drawing uses only a mutex-protected snapshot array. A stalled AMD read +does not block another card or NVIDIA. NVIDIA queries share one 750ms monotonic deadline, fixed +arguments/environment, no shell, bounded output, and SIGKILL on timeout or +parent death. An unreaped child prevents further launches. Samples older than +five seconds display N/A per card, including when a sysfs read stalls. Worker +shutdown has one shared one-second join/reap budget; a stuck kernel read can +outlive that budget in static storage until process exit (no restart after a +failed stop). Normal application termination exits the process; no driver waits +are added to signal handling. The -r display +refresh interval still applies independently. No GPU settings are written. + +Official interface references: +https://docs.kernel.org/gpu/amdgpu/thermal.html +https://docs.kernel.org/gpu/amdgpu/driver-misc.html +https://docs.nvidia.com/deploy/nvidia-smi/index.html + +GPU validation: +After configuring/building, make check runs compiled parsing, units, missing-data, +expiry, selection, subprocess deadline/reaping/parent-death and descriptor checks, +plus real libg15render buffer checks. No daemon, input devices or synthetic GPU +sysfs fixtures are used. ./gpu-test auto additionally checks every real GPU, +its sysfs vendor identity and background cache (requires all four metrics). +Explicit PCI arguments still test one card. ./render-test auto output writes +real live 160x43 buffers to output-0.pgm and output-1.pgm (five/four rows), plus +output (four rows). ./render-test off output renders the original GPU-off layout. +For in-memory cases, ./render-test off output COUNT PAGE [large|unknown] selects +1..64 cards and a zero-based pair page. Omit PAGE to export the last pair. +The default values exercise LLONG_MAX VRAM and -273C/1000C; large uses +100.0/192.0GiB and 1000C, while unknown uses N/A for all GPU metrics. +For example ./render-test off output 2 tests worst-width dual-card values; +./render-test off output 3 1 tests the odd last page. With no arguments, +make check retains the worst-width two-card case. These are rendering inputs, +not simulated hardware or claims about the installed GPUs. +These do not connect to g15daemon and are not physical LCD tests. + +make check also runs tests/render-layouts.sh for off, single, dual, large, +unknown, odd-last and 64-card layouts, each in both summary modes. In addition +to isolated-column comparisons, the test compares final GPU text, vertical +TOTAL and time-footer cells directly with the installed font's bitmap bits. +This independent oracle includes blank cells and checks full glyph bounds; +repeating renderString on another canvas alone cannot detect shared clipping. +Canvas initialization is tested starting from poisoned storage. + +Startup regression: make check also runs runtime-test through the full main +loop for three frames with real libgtop and bitmap rendering. Only daemon +transport is replaced by a private socketpair; no keyboard events are sent. +./runtime-test auto 21 additionally requires valid live GPU samples after +twenty seconds. This is not a physical LCD or real daemon integration test. +The bitmap-only client initializes its own canvas, avoiding g15r_initCanvas: +a TTF-enabled renderer can otherwise write beyond a non-TTF caller's canvas +and corrupt malloc metadata at startup, even with --gpu off. + +Each visual export also writes output-0.bin and output-1.bin: the actual 860 +packed framebuffer bytes (one means black). With ImageMagick installed, run +sh tests/render-images.sh /absolute/output/directory after generating frames +there. It compares packed PBM, PGM and native PNG pixels, generates exact +nearest-neighbor 8x images, and checks the 8x-to-native round trip. Per-scenario +contact sheets use 4x tiles with labels outside the LCD and one column, avoiding +very wide sheets that viewers may downscale. No trim or crop is applied. +Inspect the individual native and 8x files as well as the contact sheets. +The supplied default small font has only three ink columns plus a blank column +per character; its N, M and other shapes can look incomplete when enlarged. +Use the bitmap checks to distinguish those font shapes from missing pixels. + Features: Summary Screen displays 4 or 5 indicators from CPU/Frequency/Memory/NET/Temperature/Fan Speed/Swap, along with current time. diff --git a/README.rst b/README.rst index c4a2ea1..91aa59e 100644 --- a/README.rst +++ b/README.rst @@ -30,6 +30,40 @@ Requirements - libgtop +GPU monitoring +-------------- + +The summary automatically collects ALL supported PCI addresses (amdgpu or nvidia), +including mixed vendors. ``--gpu dddd:bb:ss.f`` (alias ``-gpu``) selects +a card; ``--gpu off`` disables collection and restores the original layout. +``--gpu auto`` is the default. Intel, nouveau and radeon are not supported. +Missing metrics show N/A. No supported GPU in auto mode leaves the layout intact. +An explicitly selected missing card keeps an N/A column. Selection is at startup. + +Each column shows vendor/index and PCI bus:slot.function, UTL %, U used VRAM, T total VRAM, and TEM in C on five +lines. VRAM uses explicit MiB/GiB units (TiB/PiB if needed), with one decimal. +One GPU retains summary labels and shortened graphs. Two GPUs appear together +beside compact CPU/RAM-used/RAM-total/SWP rows (plus GPU count in five-row mode). +RAM also has explicit units. More than two cycle in PCI-sorted pairs every five +seconds from startup; all continue collecting. NV1/AMD2-style indices are one-based +selected-inventory positions, mapped to full PCI addresses in startup diagnostics. +The 64-card bound fails explicitly on overflow. The bottom bar and other screens +are unchanged. AMD APU VRAM may mean its reserved +carveout, not all shared system memory. + +AMD uses read-only PCI-device sysfs/hwmon files. NVIDIA uses the optional +``/usr/bin/nvidia-smi`` without a shell or SDK dependency. Independent AMD workers +and one PCI-keyed all-NVIDIA query wait two seconds between samples, with a shared +750ms NVIDIA deadline and per-card five-second cache expiry. Shutdown has one +one-second worker join/reap budget. Display refresh remains controlled by ``-r``. See README for +exact layout, interfaces and subprocess lifecycle details. Run as an ordinary user. + +``make check`` covers GPU-off, single-card and multi-card native framebuffers. +Final GPU, vertical TOTAL and time-footer glyphs are checked against font bitmap +bits independently of the text renderer. See README for packed framebuffer +exports and ``tests/render-images.sh``, which generates verified native/nearest-8x +PNGs and narrow contact sheets without cropping the LCD area. + ======= Options ======= diff --git a/debian/changelog b/debian/changelog index b103d38..a199714 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,5 +1,17 @@ g15stats (3.0.3+git20240609.95de11a-1~local1) UNRELEASED; urgency=medium + * Add cached multi-vendor GPU summary columns: independent read-only amdgpu + workers and optional, deadline-bounded batched shell-free nvidia-smi + queries. + Collect all supported cards by default, display two columns concurrently + beside compact CPU/RAM data, and cycle larger inventories in sorted pairs. + Document PCI selection, bounds, units, missing metrics and unchanged no-GPU + layouts; add multi-adapter isolation, live collector and framebuffer tests. + Check complete GPU/TOTAL/footer glyphs against font bits independently; + exercise off/single/paged layouts and verify lossless visual exports. + * Initialize the bitmap canvas locally, avoiding the optional FreeType + canvas ABI mismatch that corrupts the heap during application startup. + Add a full-main regression with isolated daemon transport. * Import Debian 1.9.2-2 packaging and manual, retaining its changelog, attribution and checksum-recorded archive provenance. * Package Menelkir master 95de11a526346b3d9fd33df051b330c36b1c8a16, diff --git a/debian/g15stats.1 b/debian/g15stats.1 index c056a81..7ef52b0 100644 --- a/debian/g15stats.1 +++ b/debian/g15stats.1 @@ -28,6 +28,14 @@ additional statistics where the relevant system sensors are available. .SH "OPTIONS" g15stats understands the following options: +.TP +.BI "\-gpu, --gpu " "auto|off|dddd:bb:ss.f" +Select the GPU summary columns. Default auto collects all PCI addresses +bound to amdgpu or nvidia at startup, including mixed vendors; off disables collection and retains +the original layout. A PCI address selects one card explicitly. +No supported card in auto mode leaves the original layout intact; an explicit +missing card shows N/A. Restart after hotplug to rediscover devices. +Intel, nouveau and radeon are not supported. .br .P .HP @@ -72,6 +80,30 @@ Recalculate the CPU count for systems supporting CPU hotplug. L2 selects the previous screen; L3 selects the next screen. L4 selects an alternative view where available; L5 changes the bottom bar. .SH "NOTES" +Each GPU column shows vendor/index and PCI bus:slot.function, UTL percent, +U used VRAM, T total VRAM, and TEM in C. +Used and total occupy separate rows with explicit MiB/GiB units (TiB/PiB +if needed), rounded to one decimal. On AMD APUs VRAM can mean only the reserved +carveout. With one GPU the summary retains its 4/5 indicators and shortened +graphs. With two GPUs both columns appear together beside compact CPU percent, +R used system RAM, T total system RAM and SWP percent (plus GPU count in five-row +mode). RAM has explicit units too. More than two cycle in PCI-sorted pairs every +five seconds from startup; all cards continue collecting. NV1/AMD2-style indices +are one-based selected-inventory positions, not NVIDIA ordinals; startup diagnostics +map them to full PCI addresses. The 64-card bound fails explicitly on overflow. +The bottom bar and other screens are unchanged. See README for pixel coordinates. +.PP +AMD reads device-local sysfs/hwmon metrics. NVIDIA optionally uses +/usr/bin/nvidia-smi with no shell or SDK dependency. Independent AMD workers and +one batched NVIDIA worker wait two seconds between queries. All NVIDIA cards +share one PCI-keyed query with a 750ms deadline; +timeouts kill the child, and an unreaped child prevents additional launches. +The child is also killed on parent death. Data older than five seconds and +unavailable metrics show N/A, never fabricated zero. Display refresh remains +controlled by the refresh option. Worker shutdown has one shared one-second +join/reap budget; a stuck kernel read can outlive it until process exit. +No GPU settings are written. +.PP Normal operation requires a running g15daemon. This package does not install or enable a g15stats service. Run the client as an ordinary user. The help options exit before connecting to g15daemon. diff --git a/g15stats.c b/g15stats.c index 3145f93..c771fa3 100644 --- a/g15stats.c +++ b/g15stats.c @@ -55,6 +55,53 @@ This is a simple stats client showing graphs for CPU, MEM & Swap usage, Network #include #include #include "g15stats.h" +#include "gpu.h" + +/* Main-thread-only summary geometry; other screens retain their full width. */ +static int summary_gpu; + +static void init_canvas(g15canvas *canvas) +{ + /* We use bitmap fonts only. libg15render's g15r_initCanvas can write a + * FreeType handle beyond sizeof(g15canvas) when the library was built + * with TTF_SUPPORT but its consumer was not. Initialize our own storage + * instead; the bitmap API uses only the common buffer/mode prefix. */ + memset(canvas, 0, sizeof(*canvas)); +} + +static void gpu_lines(size_t index, gpu_sample sample, char lines[5][32]) +{ + char memory[24]; + snprintf(lines[0], 32, "%s%zu %.7s", + gpu_devices[index].backend == 1 ? "AMD" : gpu_devices[index].backend == 2 ? "NV" : "GPU", + index + 1, gpu_devices[index].bus + 5); + snprintf(lines[1], sizeof(lines[1]), "UTL N/A"); + if (sample.busy >= 0) + snprintf(lines[1], sizeof(lines[1]), "UTL %lld%%", sample.busy); + gpu_memory(memory, sizeof(memory), sample.used); + snprintf(lines[2], sizeof(lines[2]), "U %s", memory); + gpu_memory(memory, sizeof(memory), sample.total); + snprintf(lines[3], sizeof(lines[3]), "T %s", memory); + snprintf(lines[4], sizeof(lines[4]), "TEM N/A"); + if (sample.temp != LLONG_MIN) + snprintf(lines[4], sizeof(lines[4]), "TEM %lldC", sample.temp); +} + +static void draw_gpu_column(g15canvas *canvas) +{ + gpu_frame frame = gpu_snapshot(); + size_t first = gpu_display_first(frame.count, gpu_milliseconds()); + size_t i; + for (i = first; i < frame.count && i < first + 2; i++) { + char lines[5][32]; + int row, x = frame.count == 1 ? 108 : 54 + (int)(i - first) * 54; + gpu_lines(i, frame.samples[i], lines); + g15r_drawLine(canvas, x - 3, 0, x - 3, 34, G15_COLOR_BLACK); + for (row = 0; row < 5; row++) + g15r_renderString(canvas, (unsigned char *)lines[row], 0, + G15_TEXT_SMALL, x, 1 + row * 7); + } +} int g15screen_fd; @@ -213,7 +260,7 @@ void print_vert_label_logic(g15canvas *canvas, char *label, unsigned int sx){ } void print_vert_label(g15canvas *canvas, char *label){ - print_vert_label_logic(canvas, label, TEXT_RIGHT); + print_vert_label_logic(canvas, label, summary_gpu ? 98 : TEXT_RIGHT); } void drawBar_reversed (g15canvas * canvas, int x1, int y1, int x2, int y2, int color, @@ -264,8 +311,9 @@ void drawBar_reversed (g15canvas * canvas, int x1, int y1, int x2, int y2, int c } void drawBar_both(g15canvas *canvas, int y1, int y2, int bar_current, int bar_total, int rbar_current, int rbar_total) { - g15r_drawBar(canvas, BAR_START, y1, BAR_END, y2, G15_COLOR_BLACK, bar_current, bar_total, 4); - drawBar_reversed(canvas, BAR_START, y1, BAR_END, y2, G15_COLOR_BLACK, rbar_current, rbar_total, 5); + int end = summary_gpu ? 96 : BAR_END; + g15r_drawBar(canvas, BAR_START, y1, end, y2, G15_COLOR_BLACK, bar_current, bar_total, 4); + drawBar_reversed(canvas, BAR_START, y1, end, y2, G15_COLOR_BLACK, rbar_current, rbar_total, 5); } void drawLine_both(g15canvas *canvas, int y1, int y2) { @@ -826,6 +874,42 @@ void draw_cpu_screen_unicore(g15canvas *canvas, char *tmpstr, int drawgraph, int draw_cpu_screen_unicore_logic(canvas, cpu, tmpstr, drawgraph, printlabels, 0); } +/* Two 52px GPU columns leave 51px for a compact CPU/system-RAM summary. + * Keep this separate from the legacy geometry and generic sensor probing. */ +static void compact_ram_lines(char lines[5][32], long long used, long long total) +{ + char value[24]; + gpu_memory(value, sizeof(value), used); + snprintf(lines[1], 32, "R %s", value); + gpu_memory(value, sizeof(value), total); + snprintf(lines[2], 32, "T %s", value); +} + +static void draw_compact_summary(g15canvas *canvas) +{ + glibtop_cpu cpu; + glibtop_mem mem; + glibtop_swap swap; + static guint64 last_total, last_idle; + char lines[5][32]; + int row, rows = mode[SCREEN_SUMMARY] ? 4 : 5; + glibtop_get_cpu(&cpu); + glibtop_get_mem(&mem); + glibtop_get_swap(&swap); + snprintf(lines[0], 32, "CPU N/A"); + if (cpu.total > last_total && cpu.idle >= last_idle && cpu.idle - last_idle <= cpu.total - last_total) + snprintf(lines[0], 32, "CPU %.0f%%", 100.0 * (1.0 - (double)(cpu.idle - last_idle) / (cpu.total - last_total))); + last_total = cpu.total; + last_idle = cpu.idle; + compact_ram_lines(lines, mem.total - mem.free, mem.total); + snprintf(lines[3], 32, "SWP %.0f%%", swap.total ? 100.0 * swap.used / swap.total : 0.0); + snprintf(lines[4], 32, "GPUs %zu", gpu_count); + g15r_clearScreen(canvas, G15_COLOR_WHITE); + for (row = 0; row < rows; row++) + g15r_renderString(canvas, (unsigned char *)lines[row], 0, + G15_TEXT_SMALL, 1, 1 + row * (rows == 5 ? 7 : 9)); +} + void draw_cpu_screen_multicore(g15canvas *canvas, char *tmpstr, int unicore) { glibtop_cpu cpu; int core,ncpumax; @@ -839,6 +923,10 @@ void draw_cpu_screen_multicore(g15canvas *canvas, char *tmpstr, int unicore) { b_nice[GLIBTOP_NCPU],b_sys[GLIBTOP_NCPU],b_idle[GLIBTOP_NCPU], b_irq[GLIBTOP_NCPU],b_iowait[GLIBTOP_NCPU]; + if (summary_gpu && gpu_count > 1 && cycle == SCREEN_SUMMARY) { + draw_compact_summary(canvas); + return; + } init_cpu_count(); ncpumax = ncpu; @@ -1694,8 +1782,21 @@ int main(int argc, char *argv[]){ unsigned char interface[128]; static char tmpstr[MAX_LINES]; int unicore = 0; + const char *gpu_selection = "auto"; for (i=0;i= argc) { + fprintf(stderr, "--gpu requires auto, off or a PCI address\n"); + return 1; + } + gpu_selection = argv[i]; + if (!gpu_selection_valid(gpu_selection)) { + fprintf(stderr, "Invalid GPU selection; use auto, off or dddd:bb:ss.f\n"); + return 1; + } + continue; + } if(0==strncmp(argv[i],"-d",2)||0==strncmp(argv[i],"--daemon",8)) { go_daemon=1; } @@ -1722,6 +1823,8 @@ int main(int argc, char *argv[]){ printf("%s %s - (c) 2008-2010 Mike Lampard, Piotr Czarnecki\n",PACKAGE_NAME,VERSION); printf("Usage: %s [Options]\n", PACKAGE_NAME); printf("Options:\n"); + printf("--gpu (-gpu) [auto|off|dddd:bb:ss.f] GPU columns (default: auto, all supported GPUs).\n"); + printf(" Two GPUs side by side; more cycle in PCI-sorted pairs every 5 seconds.\n"); printf("--daemon (-d) run in background\n"); printf("--unicore (-u) display unicore graphs only on the CPU screen\n"); printf("--help (-h) this help text.\n"); @@ -1785,6 +1888,13 @@ int main(int argc, char *argv[]){ } } } + if (gpu_detect(gpu_selection)) { + fprintf(stderr, "GPU enumeration failed or exceeds %d adapters; use --gpu PCI or off\n", GPU_MAX); + return 1; + } + for (size_t gpu = 0; gpu < gpu_count; gpu++) + fprintf(stderr, "GPU %zu: %s (%s)\n", gpu + 1, gpu_devices[gpu].bus, + gpu_devices[gpu].backend == 1 ? "AMD" : gpu_devices[gpu].backend == 2 ? "NV" : "unsupported/missing"); if((g15screen_fd = new_g15_screen(G15_G15RBUF))<0){ printf("Sorry, cant connect to the G15daemon\n"); return -1; @@ -1795,11 +1905,12 @@ int main(int argc, char *argv[]){ daemonise(0,0); if(canvas != NULL) - g15r_initCanvas(canvas); + init_canvas(canvas); else return -1; glibtop_init(); + if (gpu_start()) fprintf(stderr, "GPU collector unavailable; affected cards display N/A\n"); pthread_create(&keys_thread,NULL,(void*)keyboard_watch,NULL); if(have_nic==1) @@ -1813,6 +1924,7 @@ int main(int argc, char *argv[]){ int cycle_old = cycle; while(1) { calc_info_cycle(); + summary_gpu = cycle == SCREEN_SUMMARY && gpu_count; switch (cycle) { case SCREEN_SUMMARY: case SCREEN_CPU: @@ -1919,6 +2031,8 @@ int main(int argc, char *argv[]){ break; } cycle_old = cycle; + if (summary_gpu) draw_gpu_column(canvas); + summary_gpu = 0; print_info_label(canvas, tmpstr); canvas->mode_xor = 0; @@ -1926,6 +2040,7 @@ int main(int argc, char *argv[]){ g15_send(g15screen_fd,(char *)canvas->buffer,G15_BUFFER_LEN); g15stats_wait(wait_seconds); } + gpu_stop(); glibtop_close(); close(g15screen_fd); diff --git a/gpu.h b/gpu.h new file mode 100644 index 0000000..ad0be9c --- /dev/null +++ b/gpu.h @@ -0,0 +1,443 @@ +/* SPDX-License-Identifier: GPL-2.0-or-later + * Read-only Linux GPU telemetry. Included privately by g15stats.c. + */ +#ifndef G15STATS_GPU_H +#define G15STATS_GPU_H +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +typedef struct { + long long busy, used, total, temp; /* percent, bytes, bytes, Celsius */ +} gpu_sample; + +static const gpu_sample gpu_unknown = {-1, -1, -1, LLONG_MIN}; +#define GPU_MAX 64 +typedef struct { + int backend; /* 0 missing, 1 amdgpu, 2 nvidia */ + char device[PATH_MAX], bus[32]; +} gpu_adapter; +typedef struct { + size_t count; + gpu_sample samples[GPU_MAX]; +} gpu_frame; +static gpu_adapter gpu_devices[GPU_MAX]; /* immutable while workers run */ +static size_t gpu_count; +static gpu_sample gpu_cache[GPU_MAX]; +static long long gpu_stamp[GPU_MAX]; +static pthread_mutex_t gpu_lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_t gpu_threads[GPU_MAX]; +static size_t gpu_thread_count; +static atomic_int gpu_stopping; +static pid_t gpu_pending = -1; /* NVIDIA worker owns until joined. */ +static long long gpu_epoch; +static size_t gpu_page; +static long long gpu_page_due; + +static int gpu_selection_valid(const char *selection) +{ + size_t i; + if (!strcmp(selection, "auto") || !strcmp(selection, "off")) return 1; + if (strlen(selection) != 12) return 0; + for (i = 0; i < 12; i++) { + if (i == 4 || i == 7) { if (selection[i] != ':') return 0; } + else if (i == 10) { if (selection[i] != '.') return 0; } + else if (!isxdigit((unsigned char)selection[i])) return 0; + } + return selection[8] <= '1' && selection[11] <= '7'; +} + +static long long gpu_milliseconds(void) +{ + struct timespec now; + if (clock_gettime(CLOCK_MONOTONIC, &now)) + return 0; + return (long long)now.tv_sec * 1000 + now.tv_nsec / 1000000; +} + +/* Reject partial values, overflow and unsupported textual fields. */ +static long long gpu_number(const char *text, long long min, long long max, + long long missing) +{ + char *end; + long long value; + while (isspace((unsigned char)*text)) text++; + if (!isdigit((unsigned char)*text) && *text != '-') return missing; + errno = 0; + value = strtoll(text, &end, 10); + if (end == text || errno) return missing; + while (isspace((unsigned char)*end)) end++; + return *end || value < min || value > max ? missing : value; +} + +static long long gpu_file(const char *path, long long min, long long max, + long long missing) +{ + char text[96]; + FILE *file = fopen(path, "re"); + long long value = missing; + if (!file) return missing; + if (fgets(text, sizeof(text), file) && !ferror(file)) { + /* sysfs values fit in one short line; do not accept truncated input. */ + if (fgetc(file) == EOF && !ferror(file)) + value = gpu_number(text, min, max, missing); + } + fclose(file); + return value; +} + +/* Select once, in sorted PCI-address order, not unstable DRM card order. + * Explicit PCI selection keeps an N/A column even for missing devices. + */ +static int gpu_detect(const char *selection) +{ + glob_t paths = {0}; + size_t i; + gpu_count = 0; + gpu_epoch = gpu_milliseconds(); + gpu_page = 0; + gpu_page_due = gpu_epoch + 5000; + memset(gpu_stamp, 0, sizeof(gpu_stamp)); + for (i = 0; i < GPU_MAX; i++) gpu_cache[i] = gpu_unknown; + if (!strcmp(selection, "off")) return 0; + int error = glob("/sys/bus/pci/devices/*", 0, NULL, &paths); + if (error && error != GLOB_NOMATCH) { + globfree(&paths); + return -1; + } + for (i = 0; i < paths.gl_pathc; i++) { + char path[PATH_MAX], driver[PATH_MAX]; + const char *bus = strrchr(paths.gl_pathv[i], '/') + 1; + ssize_t len; + int backend; + if (strcmp(selection, "auto") && strcasecmp(selection, bus)) continue; + snprintf(path, sizeof(path), "%s/driver", paths.gl_pathv[i]); + len = readlink(path, driver, sizeof(driver) - 1); + if (len < 0) continue; + driver[len] = 0; + if (!strcmp(strrchr(driver, '/') + 1, "amdgpu")) + backend = 1; + else if (!strcmp(strrchr(driver, '/') + 1, "nvidia")) + backend = 2; + else continue; + if (gpu_count == GPU_MAX) { + globfree(&paths); + gpu_count = 0; + return -1; /* Never silently truncate the inventory. */ + } + gpu_devices[gpu_count].backend = backend; + snprintf(gpu_devices[gpu_count].device, PATH_MAX, "%s", paths.gl_pathv[i]); + snprintf(gpu_devices[gpu_count].bus, 32, "%s", bus); + gpu_count++; + } + globfree(&paths); + if (!gpu_count && strcmp(selection, "auto")) { + memset(&gpu_devices[0], 0, sizeof(gpu_devices[0])); + snprintf(gpu_devices[0].bus, 32, "%s", selection); + gpu_count = 1; + } + return 0; +} + +static gpu_sample gpu_amd(const gpu_adapter *adapter) +{ + gpu_sample sample = gpu_unknown; + char path[PATH_MAX + 64]; + glob_t paths = {0}; + snprintf(path, sizeof(path), "%s/gpu_busy_percent", adapter->device); + sample.busy = gpu_file(path, 0, 100, -1); + snprintf(path, sizeof(path), "%s/mem_info_vram_used", adapter->device); + sample.used = gpu_file(path, 0, LLONG_MAX, -1); + snprintf(path, sizeof(path), "%s/mem_info_vram_total", adapter->device); + sample.total = gpu_file(path, 1, LLONG_MAX, -1); + snprintf(path, sizeof(path), "%s/hwmon/hwmon*/temp1_input", adapter->device); + if (!glob(path, 0, NULL, &paths) && paths.gl_pathc) { + long long temp = gpu_file(paths.gl_pathv[0], -273000, 1000000, LLONG_MIN); + if (temp != LLONG_MIN) sample.temp = temp / 1000; + } + globfree(&paths); + if (sample.total >= 0 && sample.used > sample.total) sample.used = -1; + return sample; +} + +static gpu_sample gpu_csv(char *text) +{ + gpu_sample sample = gpu_unknown; + char *fields[4], *cursor = text; + int i; + for (i = 0; i < 4; i++) { + fields[i] = strsep(&cursor, ","); + if (!fields[i]) return sample; + } + if (cursor) return sample; + sample.busy = gpu_number(fields[0], 0, 100, -1); + sample.used = gpu_number(fields[1], 0, LLONG_MAX / 1048576, -1); + sample.total = gpu_number(fields[2], 1, LLONG_MAX / 1048576, -1); + sample.temp = gpu_number(fields[3], -273, 1000, LLONG_MIN); + if (sample.used >= 0) sample.used *= 1048576; + if (sample.total >= 0) sample.total *= 1048576; + if (sample.total >= 0 && sample.used > sample.total) sample.used = -1; + return sample; +} + +/* Only the collector calls this. Never wait for a driver in the draw thread. + * A killed but uninterruptible child remains owned, preventing process buildup. + */ +static int gpu_above_stdio(int fd) +{ + int copy; + if (fd < 0 || fd >= 3) return fd; + copy = fcntl(fd, F_DUPFD_CLOEXEC, 3); + close(fd); + return copy; +} + +static int gpu_command(const char *executable, char *const args[], char *text, size_t capacity) +{ + int pipes[2], status, nullfd; + pid_t pid, parent = getpid(), result; + size_t used = 0; + long long deadline; + long maxfd = sysconf(_SC_OPEN_MAX); + char *const env[] = {"LC_ALL=C", NULL}; + if (gpu_pending > 0) { + result = waitpid(gpu_pending, &status, WNOHANG); + if (!result || (result < 0 && errno == EINTR)) return 0; + gpu_pending = -1; + } + if (pipe2(pipes, O_CLOEXEC | O_NONBLOCK)) return 0; + pipes[0] = gpu_above_stdio(pipes[0]); + pipes[1] = gpu_above_stdio(pipes[1]); + if (pipes[0] < 0 || pipes[1] < 0) { + if (pipes[0] >= 0) close(pipes[0]); + if (pipes[1] >= 0) close(pipes[1]); + return 0; + } + /* Only the reader is nonblocking. A large all-card response must not + * make nvidia-smi fail with EAGAIN when the pipe temporarily fills. */ + if (fcntl(pipes[1], F_SETFL, 0) < 0) { + close(pipes[0]); + close(pipes[1]); + return 0; + } + nullfd = gpu_above_stdio(open("/dev/null", O_RDWR | O_CLOEXEC)); + if (nullfd < 0) { close(pipes[0]); close(pipes[1]); return 0; } + pid = fork(); + if (!pid) { + /* Async-signal-safe child path: no stdio, allocation or shell. */ + if (prctl(PR_SET_PDEATHSIG, SIGKILL) || getppid() != parent) _exit(127); + if (dup2(nullfd, 0) < 0 || dup2(pipes[1], 1) < 0 || dup2(nullfd, 2) < 0) + _exit(127); +#ifdef SYS_close_range + if (syscall(SYS_close_range, 3U, ~0U, 0) < 0) +#endif + { + long fd; + if (maxfd < 0) _exit(127); + for (fd = 3; fd < maxfd; fd++) close((int)fd); + } + execve(executable, args, env); + _exit(127); + } + close(nullfd); + close(pipes[1]); + if (pid < 0) { close(pipes[0]); return 0; } + deadline = gpu_milliseconds() + 750; + for (;;) { + ssize_t n = read(pipes[0], text + used, capacity - 1 - used); + if (n > 0) used += (size_t)n; + result = waitpid(pid, &status, WNOHANG); + if (result == pid) { + /* Drain the final bytes after exit before parsing. */ + while (used < capacity - 1 && + (n = read(pipes[0], text + used, capacity - 1 - used)) > 0) + used += (size_t)n; + close(pipes[0]); + text[used] = 0; + return used < capacity - 1 && WIFEXITED(status) && !WEXITSTATUS(status); + } + if (result < 0 && errno != EINTR) { close(pipes[0]); return 0; } + if (atomic_load(&gpu_stopping) || gpu_milliseconds() >= deadline || used == capacity - 1) break; + /* Avoid spinning on POLLHUP if stdout closes before the process exits. */ + poll(NULL, 0, 20); + } + kill(pid, SIGKILL); + gpu_pending = pid; + close(pipes[0]); + return 0; +} + +/* Match numeric PCI identity: NVIDIA commonly prints an eight-digit domain. + * Malformed/duplicate rows must never contaminate another adapter's sample. */ +static int gpu_bus_equal(const char *a, const char *b) +{ + unsigned ad, ab, as, af, bd, bb, bs, bf; + int n = 0, m = 0; + if (sscanf(a, "%8x:%2x:%2x.%1x %n", &ad, &ab, &as, &af, &n) != 4 || a[n]) return 0; + if (sscanf(b, "%8x:%2x:%2x.%1x %n", &bd, &bb, &bs, &bf, &m) != 4 || b[m]) return 0; + return ad == bd && ab == bb && as == bs && af == bf; +} + +static void gpu_nvidia_parse(char *text, gpu_frame *frame) +{ + char *line; + unsigned char seen[GPU_MAX] = {0}; + size_t i; + while ((line = strsep(&text, "\n"))) { + char *bus = strsep(&line, ","); + if (!line) continue; + for (i = 0; i < gpu_count; i++) { + if (gpu_devices[i].backend != 2 || !gpu_bus_equal(bus, gpu_devices[i].bus)) continue; + frame->samples[i] = seen[i] ? gpu_unknown : gpu_csv(line); + seen[i] = 1; + break; + } + } +} + +static void gpu_nvidia(gpu_frame *frame) +{ + char text[GPU_MAX * 256]; + char *const args[] = {"nvidia-smi", + "--query-gpu=pci.bus_id,utilization.gpu,memory.used,memory.total,temperature.gpu", + "--format=csv,noheader,nounits", NULL}; + if (gpu_command("/usr/bin/nvidia-smi", args, text, sizeof(text))) + gpu_nvidia_parse(text, frame); +} + +static void gpu_publish(size_t i, gpu_sample sample) +{ + pthread_mutex_lock(&gpu_lock); + gpu_cache[i] = sample; + gpu_stamp[i] = gpu_milliseconds(); + pthread_mutex_unlock(&gpu_lock); +} + +/* Each AMD adapter has an independent worker; one NVIDIA worker queries all + * NVIDIA cards within one deadline. A stalled sysfs driver cannot stall peers. */ +static void *gpu_watch(void *arg) +{ + size_t index = (gpu_adapter *)arg - gpu_devices, i; + while (!atomic_load(&gpu_stopping)) { + if (gpu_devices[index].backend == 1) { + gpu_publish(index, gpu_amd(&gpu_devices[index])); + } else { + gpu_frame frame = {.count = gpu_count}; + for (i = 0; i < gpu_count; i++) frame.samples[i] = gpu_unknown; + gpu_nvidia(&frame); + pthread_mutex_lock(&gpu_lock); + for (i = 0; i < gpu_count; i++) if (gpu_devices[i].backend == 2) { + gpu_cache[i] = frame.samples[i]; + gpu_stamp[i] = gpu_milliseconds(); + } + pthread_mutex_unlock(&gpu_lock); + } + for (i = 0; i < 100 && !atomic_load(&gpu_stopping); i++) poll(NULL, 0, 20); + } + return NULL; +} + +static int gpu_start(void) +{ + size_t i; + int nvidia = 0, failed = 0; + atomic_store(&gpu_stopping, 0); + for (i = 0; i < gpu_count; i++) { + if (!gpu_devices[i].backend || (gpu_devices[i].backend == 2 && nvidia++)) continue; + if (pthread_create(&gpu_threads[gpu_thread_count], NULL, gpu_watch, &gpu_devices[i])) failed = 1; + else gpu_thread_count++; + } + return failed; +} + +/* One shared shutdown deadline, not one per adapter. A stuck kernel read may + * outlive this call; its static storage remains valid until process exit. + * Never redetect/restart after a failed stop. */ +static int gpu_stop(void) +{ + struct timespec deadline; + long long end = gpu_milliseconds() + 1000; + size_t i; + int failed = 0; + atomic_store(&gpu_stopping, 1); + clock_gettime(CLOCK_REALTIME, &deadline); + deadline.tv_sec++; + for (i = 0; i < gpu_thread_count; i++) { + if (pthread_timedjoin_np(gpu_threads[i], NULL, &deadline)) { + pthread_detach(gpu_threads[i]); + failed = 1; + } + } + gpu_thread_count = 0; + if (!failed && gpu_pending > 0) { + int status; + pid_t result; + do { + result = waitpid(gpu_pending, &status, WNOHANG); + if (result == gpu_pending || (result < 0 && errno == ECHILD)) { + gpu_pending = -1; + break; + } + poll(NULL, 0, 5); + } while (gpu_milliseconds() < end); + if (gpu_pending > 0) failed = 1; + } + return failed; +} + +static gpu_frame gpu_snapshot(void) +{ + gpu_frame frame = {.count = gpu_count}; + size_t i; + pthread_mutex_lock(&gpu_lock); + for (i = 0; i < gpu_count; i++) + frame.samples[i] = !gpu_stamp[i] || gpu_milliseconds() - gpu_stamp[i] > 5000 ? gpu_unknown : gpu_cache[i]; + pthread_mutex_unlock(&gpu_lock); + return frame; +} + +static size_t gpu_page_first(size_t count, unsigned long long elapsed) +{ + return count > 2 ? (elapsed / 5000 % ((count + 1) / 2)) * 2 : 0; +} + +/* Main thread only. Advance at most one pair per actual draw, so -r 10 (or + * leaving the summary screen) cannot alias the cycle and hide half the GPUs. */ +static size_t gpu_display_first(size_t count, long long now) +{ + if (count > 2 && now >= gpu_page_due) { + gpu_page = (gpu_page + 1) % ((count + 1) / 2); + gpu_page_due = now + 5000; + } + return gpu_page_first(count, gpu_page * 5000ULL); +} + +/* Each line fits the 52-pixel column (13 small-font character cells). + * Separate used/total rows retain useful precision, even on large cards. + */ +static void gpu_memory(char *text, size_t size, long long bytes) +{ + const char *unit = "MiB"; + double value = (double)bytes / 1048576; + if (bytes < 0) { snprintf(text, size, "N/A"); return; } + if (value >= 1024) { value /= 1024; unit = "GiB"; } + if (value >= 1024) { value /= 1024; unit = "TiB"; } + if (value >= 1024) { value /= 1024; unit = "PiB"; } + snprintf(text, size, "%.1f%s", value, unit); +} +#endif \ No newline at end of file diff --git a/tests/gpu-test.c b/tests/gpu-test.c new file mode 100644 index 0000000..31b0cd6 --- /dev/null +++ b/tests/gpu-test.c @@ -0,0 +1,284 @@ +/* SPDX-License-Identifier: GPL-2.0-or-later */ +#define _GNU_SOURCE 1 +#include "../gpu.h" +#include +#include + +static int descriptors(void) +{ + DIR *dir = opendir("/proc/self/fd"); + struct dirent *entry; + int count = 0; + assert(dir); + while ((entry = readdir(dir))) + if (entry->d_name[0] != '.') count++; + closedir(dir); + return count; +} + +static void unknown(gpu_sample s) +{ + assert(s.busy == -1 && s.used == -1 && s.total == -1 && s.temp == LLONG_MIN); +} + +static gpu_sample test_command(const char *path, char *const args[]) +{ + char text[256]; + return gpu_command(path, args, text, sizeof(text)) ? gpu_csv(text) : gpu_unknown; +} + +static void *cache_writer(void *arg) +{ + size_t index = *(size_t *)arg; + int i; + for (i = 0; i < 20000; i++) + gpu_publish(index, (gpu_sample){i % 101, i, i + 1, (long long)index}); + return NULL; +} + +static void cache_concurrency(void) +{ + pthread_t writers[2]; + size_t indices[2] = {0, 1}, i; + int repeat; + gpu_count = 2; + for (i = 0; i < 2; i++) { + gpu_publish(i, (gpu_sample){0, 0, 1, (long long)i}); + assert(!pthread_create(&writers[i], NULL, cache_writer, &indices[i])); + } + for (repeat = 0; repeat < 20000; repeat++) { + gpu_frame frame = gpu_snapshot(); + assert(frame.count == 2); + for (i = 0; i < 2; i++) { + assert(frame.samples[i].total == frame.samples[i].used + 1); + assert(frame.samples[i].busy == frame.samples[i].used % 101); + assert(frame.samples[i].temp == (long long)i); + } + } + for (i = 0; i < 2; i++) assert(!pthread_join(writers[i], NULL)); +} + +static void parent_death(void) +{ + pid_t child, grandchild = 0, result; + long long deadline; + char path[96]; + int status; + assert(!prctl(PR_SET_CHILD_SUBREAPER, 1)); + child = fork(); + assert(child >= 0); + if (!child) { + char *const args[] = {"sleep", "10", NULL}; + test_command("/usr/bin/sleep", args); + _exit(0); + } + snprintf(path, sizeof(path), "/proc/%ld/task/%ld/children", (long)child, (long)child); + deadline = gpu_milliseconds() + 500; + do { + FILE *file = fopen(path, "r"); + long id; + if (file) { + if (fscanf(file, "%ld", &id) == 1) grandchild = (pid_t)id; + fclose(file); + } + if (!grandchild) poll(NULL, 0, 5); + } while (!grandchild && gpu_milliseconds() < deadline); + assert(grandchild > 0); + assert(!kill(child, SIGKILL)); + assert(waitpid(child, &status, 0) == child); + deadline = gpu_milliseconds() + 1000; + do { + result = waitpid(grandchild, &status, WNOHANG); + if (!result) poll(NULL, 0, 5); + } while (!result && gpu_milliseconds() < deadline); + assert(result == grandchild && WIFSIGNALED(status) && WTERMSIG(status) == SIGKILL); + assert(!prctl(PR_SET_CHILD_SUBREAPER, 0)); +} + +int main(int argc, char **argv) +{ + gpu_sample s; + char text[256], memory[32]; + char *const sleeper[] = {"sleep", "10", NULL}; + char *const failure[] = {"false", NULL}; + long long start; + int status; + pid_t result; + int fd_count, repeat; + assert(gpu_page_first(0, 5000) == 0); + assert(gpu_page_first(2, 5000) == 0); + assert(gpu_page_first(3, 5000) == 2); + assert(gpu_page_first(3, 10000) == 0); + for (repeat = 0; repeat < GPU_MAX; repeat += 2) + assert(gpu_page_first(GPU_MAX, (unsigned long long)repeat / 2 * 5000) == (size_t)repeat); + gpu_page = 0; + gpu_page_due = 5000; + assert(gpu_display_first(5, 0) == 0); + assert(gpu_display_first(5, 10000) == 2); + assert(gpu_display_first(5, 20000) == 4); + assert(gpu_display_first(5, 30000) == 0); + assert(gpu_display_first(5, 30001) == 0); + assert(gpu_selection_valid("auto") && gpu_selection_valid("off")); + assert(gpu_selection_valid("0000:AB:1f.7")); + assert(!gpu_selection_valid("0000:01:20.0")); + assert(!gpu_selection_valid("0000:01:00.8")); + assert(!gpu_selection_valid("-001:01:00.0")); + assert(!gpu_selection_valid("../../passwd")); + assert(!gpu_selection_valid("0000:01:00.0;id")); + assert(gpu_number(" 0\n", 0, 100, -1) == 0); + assert(gpu_number("100", 0, 100, -1) == 100); + assert(gpu_number("101", 0, 100, -1) == -1); + assert(gpu_number("12oops", 0, 100, -1) == -1); + assert(gpu_number("", 0, 100, -1) == -1); + assert(gpu_number("N/A", 0, 100, -1) == -1); + assert(gpu_number("999999999999999999999999", 0, LLONG_MAX, -1) == -1); + strcpy(text, "100, 12288, 16384, -5\n"); + s = gpu_csv(text); + assert(s.busy == 100 && s.used == 12884901888LL && + s.total == 17179869184LL && s.temp == -5); + strcpy(text, "N/A, [Not Supported], 16384, 0\n"); + s = gpu_csv(text); + assert(s.busy == -1 && s.used == -1 && s.total == 17179869184LL && s.temp == 0); + strcpy(text, "0, 0, 0, N/A\n"); + s = gpu_csv(text); + assert(s.busy == 0 && s.used == 0 && s.total == -1 && s.temp == LLONG_MIN); + strcpy(text, "0, 20, 10, 35\n"); + assert(gpu_csv(text).used == -1); + strcpy(text, "0, 1, 2"); unknown(gpu_csv(text)); + strcpy(text, "0, 1, 2, 3, 4"); unknown(gpu_csv(text)); + strcpy(text, "N/A, N/A, N/A, N/A"); unknown(gpu_csv(text)); + gpu_memory(memory, sizeof(memory), -1); assert(!strcmp(memory, "N/A")); + gpu_memory(memory, sizeof(memory), 19 * 1048576); assert(!strcmp(memory, "19.0MiB")); + gpu_memory(memory, sizeof(memory), 17179869184LL); assert(!strcmp(memory, "16.0GiB")); + gpu_memory(memory, sizeof(memory), LLONG_MAX); assert(strlen(memory) <= 11); + assert(!gpu_detect("off") && !gpu_count); + assert(!gpu_detect("ffff:ff:1f.7") && gpu_count == 1 && !gpu_devices[0].backend); + gpu_cache[0] = s; + gpu_stamp[0] = gpu_milliseconds() - 6000; + unknown(gpu_snapshot().samples[0]); + { + gpu_frame frame = {.count = 3}; + gpu_count = 3; + for (repeat = 0; repeat < 3; repeat++) { + snprintf(gpu_devices[repeat].bus, 32, "0000:%02x:00.0", repeat + 1); + gpu_devices[repeat].backend = repeat == 1 ? 1 : 2; + frame.samples[repeat] = gpu_unknown; + } + frame.samples[1] = (gpu_sample){77, 1, 2, 42}; + strcpy(text, "00000000:03:00.0, 90, 4, 8, 55\n00000000:01:00.0, N/A, 1, 2, N/A\n"); + gpu_nvidia_parse(text, &frame); + assert(frame.samples[0].busy == -1 && frame.samples[0].used == 1048576); + assert(frame.samples[1].busy == 77 && frame.samples[1].used == 1); + assert(frame.samples[2].busy == 90 && frame.samples[2].temp == 55); + strcpy(text, "00000000:03:00.0, 1, 4, 8, 55\n0000:03:00.0, 2, 4, 8, 55\n"); + gpu_nvidia_parse(text, &frame); + unknown(frame.samples[2]); + assert(!gpu_bus_equal("0000:01:00.0junk", "0000:01:00.0")); + assert(gpu_bus_equal("00000000:AB:00.0 ", "0000:ab:00.0")); + assert(!gpu_bus_equal("0001:01:00.0", "0000:01:00.0")); + for (repeat = 0; repeat < 3; repeat++) gpu_publish(repeat, frame.samples[repeat]); + gpu_stamp[0] = gpu_milliseconds() - 6000; + frame = gpu_snapshot(); + unknown(frame.samples[0]); + assert(frame.samples[1].busy == 77); + unknown(frame.samples[2]); + /* Missing AMD path is independent of the populated neighboring cache. */ + snprintf(gpu_devices[0].device, PATH_MAX, "/nonexistent/g15stats-test"); + gpu_publish(0, gpu_amd(&gpu_devices[0])); + assert(gpu_snapshot().samples[1].busy == 77); + } + + /* Real processes exercise the deadline and reaping, not fake GPU files. */ + start = gpu_milliseconds(); + unknown(test_command("/usr/bin/sleep", sleeper)); + assert(gpu_milliseconds() - start >= 750); + assert(gpu_milliseconds() - start < 1500); + poll(NULL, 0, 50); + unknown(test_command("/usr/bin/false", failure)); + result = waitpid(-1, &status, WNOHANG); + assert(result == -1 && errno == ECHILD); + unknown(test_command("/nonexistent/g15stats-test", failure)); + fd_count = descriptors(); + for (repeat = 0; repeat < 20; repeat++) + unknown(test_command("/usr/bin/false", failure)); + assert(descriptors() == fd_count); + { + char payload[12001], output[GPU_MAX * 256]; + char *const args[] = {"printf", "%s", payload, NULL}; + memset(payload, 'x', sizeof(payload) - 1); + payload[sizeof(payload) - 1] = 0; + assert(gpu_command("/usr/bin/printf", args, output, sizeof(output))); + assert(!strcmp(payload, output)); + assert(!gpu_command("/usr/bin/printf", args, output, 256)); + poll(NULL, 0, 50); + unknown(test_command("/usr/bin/false", failure)); + } + parent_death(); + cache_concurrency(); + + if (argc == 2) { + gpu_frame frame; + size_t i; + assert(!gpu_detect(argv[1]) && gpu_count); + assert(!gpu_start()); + start = gpu_milliseconds(); + do { + frame = gpu_snapshot(); + for (i = 0; i < frame.count; i++) if (frame.samples[i].total <= 0) break; + if (i == frame.count) break; + poll(NULL, 0, 10); + } while (gpu_milliseconds() - start < 1800); + for (i = 0; i < frame.count; i++) { + char path[PATH_MAX + 32]; + s = frame.samples[i]; + if (i) assert(strcmp(gpu_devices[i-1].bus, gpu_devices[i].bus) < 0); + snprintf(path, sizeof(path), "%s/vendor", gpu_devices[i].device); + FILE *vendor = fopen(path, "r"); + unsigned id; + assert(vendor && fscanf(vendor, "%x", &id) == 1); + fclose(vendor); + assert(id == (gpu_devices[i].backend == 1 ? 0x1002U : 0x10deU)); + printf("LIVE %zu %s vendor=%04x backend=%d utilization=%lld used=%lld total=%lld temp=%lld\n", + i+1, gpu_devices[i].bus, id, gpu_devices[i].backend, s.busy, s.used, s.total, s.temp); + assert(s.busy >= 0 && s.used >= 0 && s.total > 0 && s.temp != LLONG_MIN); + } + for (repeat = 0; repeat < 100000; repeat++) { + frame = gpu_snapshot(); + assert(frame.count == gpu_count); + for (i = 0; i < frame.count; i++) assert(frame.samples[i].total > 0); + } + start = gpu_milliseconds(); + assert(!gpu_stop()); + assert(gpu_milliseconds() - start < 1200); + puts("PASS all live adapters, sysfs vendor identities, concurrent snapshots and bounded shutdown"); + /* Read-only failure injection: point just the stopped AMD collector at + * a nonexistent path, then prove the actual NVIDIA peer remains live. */ + if (gpu_count > 1) { + size_t amd = GPU_MAX, nv = GPU_MAX; + char saved[PATH_MAX]; + for (i = 0; i < gpu_count; i++) { + if (gpu_devices[i].backend == 1) amd = i; + if (gpu_devices[i].backend == 2) nv = i; + } + if (amd < GPU_MAX && nv < GPU_MAX) { + strcpy(saved, gpu_devices[amd].device); + strcpy(gpu_devices[amd].device, "/nonexistent/g15stats-test"); + for (i = 0; i < gpu_count; i++) gpu_publish(i, gpu_unknown); + assert(!gpu_start()); + start = gpu_milliseconds(); + do { + frame = gpu_snapshot(); + if (frame.samples[nv].total > 0) break; + poll(NULL, 0, 10); + } while (gpu_milliseconds() - start < 1800); + assert(frame.samples[nv].total > 0 && frame.samples[nv].busy >= 0); + unknown(frame.samples[amd]); + assert(!gpu_stop()); + strcpy(gpu_devices[amd].device, saved); + puts("PASS failed AMD collector isolated from real live NVIDIA peer"); + } + } + } + puts("PASS GPU parsing, units, missing data, expiry, selection, deadline, reap and parent death"); + return 0; +} \ No newline at end of file diff --git a/tests/render-images.sh b/tests/render-images.sh new file mode 100644 index 0000000..a3a059c --- /dev/null +++ b/tests/render-images.sh @@ -0,0 +1,53 @@ +#!/bin/sh +# Convert existing render-test exports without trimming, resampling or labels +# inside the framebuffer. ImageMagick is optional, not a make check dependency. +set -eu +if [ "$#" -ne 1 ]; then + echo "Usage: sh tests/render-images.sh ABSOLUTE_OUTPUT_DIRECTORY" >&2 + exit 2 +fi +cd "$1" +for pgm in ./*-[01].pgm; do + [ -f "$pgm" ] || continue + base=${pgm%.pgm} + [ "$(wc -c < "$base.bin")" -eq 860 ] + { printf 'P4\n160 43\n'; cat "$base.bin"; } > "$base.pbm" + # Compare the packed 1-bit buffer independently with the PGM export. + compare -metric AE "$base.pbm" "$pgm" null: 2> "$base-pixels.log" + convert "$pgm" +repage -type TrueColor "PNG24:$base-native.png" + convert "$base-native.png" -sample 800% +repage "PNG24:$base-8x.png" + convert "$base-8x.png" -sample 160x43! +repage "PNG24:$base-roundtrip.png" + compare -metric AE "$pgm" "$base-native.png" null: 2>> "$base-pixels.log" + compare -metric AE "$pgm" "$base-roundtrip.png" null: 2>> "$base-pixels.log" + rm "$base-roundtrip.png" + # Smaller contact tiles avoid shrinking wide sheets in image viewers. + # Keep the eightfold originals available for individual pixel inspection. + convert "$base-native.png" -sample 400% +repage "PNG24:$base-4x.png" +done +for group in off nvidia amd single dual large unknown odd maximum; do + [ -f "$group-0-4x.png" ] || continue + montage -font DejaVu-Sans -pointsize 14 -background '#dddddd' \ + -label "$group: five-row summary (4x)" "$group-0-4x.png" \ + -label "$group: four-row summary (4x)" "$group-1-4x.png" \ + -tile 1x2 -geometry +8+8 "PNG24:contact-$group.png" + height=$(identify -format '%h' "contact-$group.png") + for variant in 0 1; do + top=$((8 + variant * height / 2)) + convert "contact-$group.png" -crop "640x172+8+$top" +repage \ + "PNG24:$group-tile-check.png" + compare -metric AE "$group-$variant-4x.png" "$group-tile-check.png" \ + null: 2>> "$group-contact-pixels.log" + done + rm "$group-tile-check.png" +done +if [ -f contact-off.png ] && [ -f contact-nvidia.png ] && [ -f contact-amd.png ]; then + convert contact-off.png contact-nvidia.png contact-amd.png -append +repage \ + PNG24:contact-legacy.png +fi +if [ -f contact-dual.png ] && [ -f contact-large.png ] && [ -f contact-unknown.png ]; then + convert contact-dual.png contact-large.png contact-unknown.png -append +repage \ + PNG24:contact-metrics.png +fi +if [ -f contact-odd.png ] && [ -f contact-maximum.png ]; then + convert contact-odd.png contact-maximum.png -append +repage PNG24:contact-paging.png +fi \ No newline at end of file diff --git a/tests/render-layouts.sh b/tests/render-layouts.sh new file mode 100644 index 0000000..e38847d --- /dev/null +++ b/tests/render-layouts.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Native-pixel checks only: no image conversion, daemon or input-device access. +set -eu +renderer=${RENDER_TEST:-./render-test} +out=$(mktemp -d "${TMPDIR:-/tmp}/g15stats-layouts-XXXXXX") +trap 'rm -rf "$out"' EXIT HUP INT TERM +"$renderer" off "$out/off" +"$renderer" off "$out/single" 1 +"$renderer" off "$out/dual" 2 +"$renderer" off "$out/large" 2 0 large +"$renderer" off "$out/unknown" 2 0 unknown +"$renderer" off "$out/odd" 3 1 +"$renderer" off "$out/maximum" 64 31 \ No newline at end of file diff --git a/tests/render-test.c b/tests/render-test.c new file mode 100644 index 0000000..afa76e4 --- /dev/null +++ b/tests/render-test.c @@ -0,0 +1,230 @@ +/* SPDX-License-Identifier: GPL-2.0-or-later + * Exercise the real renderer without any daemon connection or input thread. + */ +#define main g15stats_application_main +#include "../g15stats.c" +#undef main +#include + +/* Independent font-bitmap oracle: do not repeat renderString on a second + * canvas, since that also repeats any clipping/erasure in the renderer. + * The default fonts have zero gap and one blank trailing pixel column. + * Compare every cell, including blank pixels, against the font file's bits. */ +static void assert_text(g15canvas *canvas, const char *text, int size, int x, int y, int right) +{ + g15font *font = g15r_requestG15DefaultFont(size); + assert(font && font->default_gap == 0); + assert(font->font_height == font->ascender_height); + for (; *text; text++) { + unsigned char c = (unsigned char)*text; + int width = font->glyph[c].width; + int stride = (width + 7) / 8; + assert(font->active[c] && font->glyph[c].buffer); + assert(x >= 0 && x + width <= 160); + assert(y >= 0 && y + font->font_height <= 43); + for (int dy = 0; dy < font->font_height; dy++) + for (int dx = 0; dx < width; dx++) { + int bit = !!(font->glyph[c].buffer[dy * stride + dx / 8] & + (0x80 >> (dx % 8))); + /* A 13-character first-column header's final blank advance + * pixel shares x=105 with the next divider; no ink may do so. */ + if (x + dx >= right) { + assert(!text[1] && dx == width - 1 && !bit); + continue; + } + if (g15r_getPixel(canvas, x + dx, y + dy) != bit) { + fprintf(stderr, "Glyph %c pixel (%d,%d): expected %d, got %d\n", + c, x + dx, y + dy, bit, + g15r_getPixel(canvas, x + dx, y + dy)); + assert(0); + } + } + x += width; + } +} + +int main(int argc, char **argv) +{ + g15canvas canvas, before; + char tmp[MAX_LINES]; + int x, y, variant; + size_t i; + g15font *font = g15r_requestG15DefaultFont(G15_TEXT_SMALL); + assert(font); + assert(g15r_testG15FontWidth(font, "T 8192.0PiB") <= 52); + assert(g15r_testG15FontWidth(font, "UTL 100%") <= 52); + { + char lines[5][32]; + compact_ram_lines(lines, LLONG_MAX, LLONG_MAX); + assert(strstr(lines[1], "PiB") && strstr(lines[2], "PiB")); + assert(g15r_testG15FontWidth(font, lines[1]) <= 50); + assert(g15r_testG15FontWidth(font, lines[2]) <= 50); + compact_ram_lines(lines, 19 * 1048576, 17179869184LL); + assert(!strcmp(lines[1], "R 19.0MiB")); + assert(!strcmp(lines[2], "T 16.0GiB")); + } + memset(&canvas, 0xa5, sizeof(canvas)); + init_canvas(&canvas); + assert(!canvas.mode_xor && !canvas.mode_reverse && !canvas.mode_cache); + for (y = 0; y < 43; y++) for (x = 0; x < 160; x++) + assert(!g15r_getPixel(&canvas, x, y)); + /* Original full-width geometry must remain exact with the column off. */ + init_canvas(&before); + summary_gpu = 0; + drawBar_both(&canvas, 1, 8, 37, 100, 63, 100); + g15r_drawBar(&before, BAR_START, 1, BAR_END, 8, G15_COLOR_BLACK, 37, 100, 4); + drawBar_reversed(&before, BAR_START, 1, BAR_END, 8, G15_COLOR_BLACK, 63, 100, 5); + assert(!memcmp(canvas.buffer, before.buffer, sizeof(canvas.buffer))); + glibtop_init(); + /* No device fixtures: sample the actual selected GPU when requested. */ + assert(!gpu_detect(argc > 1 ? argv[1] : "off")); + if (gpu_count && gpu_devices[0].backend) { + long long start = gpu_milliseconds(); + assert(!gpu_start()); + do { + gpu_frame frame = gpu_snapshot(); + for (i = 0; i < frame.count; i++) if (frame.samples[i].total < 0) break; + if (i == frame.count) break; + poll(NULL, 0, 10); + } while (gpu_milliseconds() - start < 1800); + assert(i == gpu_count); + assert(!gpu_stop()); + } + /* No-argument make check retains the worst-width fixture. Explicit off + * must exercise the application's actual zero-GPU/full-width layout. + * Optional count/page/profile arguments are in-memory visual cases only. */ + if (!gpu_count && (argc == 1 || argc > 3)) { + gpu_count = argc > 3 ? (size_t)atoi(argv[3]) : 2; + assert(gpu_count >= 1 && gpu_count <= GPU_MAX); + gpu_devices[0].backend = 2; + gpu_devices[1].backend = 1; + strcpy(gpu_devices[0].bus, "0000:01:00.0"); + strcpy(gpu_devices[1].bus, "0000:11:00.0"); + gpu_publish(0, (gpu_sample){100, LLONG_MAX, LLONG_MAX, -273}); + gpu_publish(1, (gpu_sample){100, LLONG_MAX, LLONG_MAX, 1000}); + for (i = 2; i < gpu_count; i++) { + gpu_devices[i].backend = 1; + snprintf(gpu_devices[i].bus, 32, "0001:%02x:1f.7", (unsigned)i); + gpu_publish(i, i % 2 ? gpu_unknown : (gpu_sample){100, LLONG_MAX, LLONG_MAX, -273}); + } + if (gpu_count > 2) { + /* Exercise every pair at the maximum inventory, including the + * 13-character AMD64 header and an odd final card. */ + for (i = 0; i < (gpu_count + 1) / 2; i++) { + gpu_page = i; + gpu_page_due = LLONG_MAX; + g15r_clearScreen(&canvas, G15_COLOR_WHITE); + draw_gpu_column(&canvas); + for (y = 35; y < 43; y++) for (x = 0; x < 160; x++) + assert(!g15r_getPixel(&canvas, x, y)); + } + /* Keep the final pair for the exported preview and pixel checks. */ + } + } + if (argc > 4) { + gpu_page = (size_t)atoi(argv[4]); + assert(gpu_page < (gpu_count + 1) / 2); + gpu_page_due = LLONG_MAX; + } + if (argc > 5) { + assert(!strcmp(argv[5], "large") || !strcmp(argv[5], "unknown")); + for (i = 0; i < gpu_count; i++) + gpu_publish(i, !strcmp(argv[5], "unknown") ? gpu_unknown : + (gpu_sample){100, 100LL << 30, 192LL << 30, 1000}); + } + for (i = 0; i < gpu_count; i++) { + char lines[5][32]; + int row; + gpu_lines(i, gpu_cache[i], lines); + for (row = 0; row < 5; row++) { + assert(g15r_testG15FontWidth(font, lines[row]) <= 52); + printf("GPU %zu row %d: %s\n", i + 1, row, lines[row]); + } + } + for (variant = 0; variant < 2; variant++) { + cycle = SCREEN_SUMMARY; + mode[SCREEN_SUMMARY] = variant; + have_temp = have_fan = 0; /* Keep legacy generic hwmon probing out of QA. */ + summary_gpu = gpu_count != 0; + draw_cpu_screen_multicore(&canvas, tmp, 0); + before = canvas; + draw_gpu_column(&canvas); + for (y = 0; y < 43; y++) + for (x = 0; x < 160; x++) + if (x < (gpu_count > 1 ? 51 : 105) || y >= 35) + assert(g15r_getPixel(&canvas, x, y) == g15r_getPixel(&before, x, y)); + /* Re-render each column on an empty canvas: exact pixels prove neither + * neighboring column nor CPU text overlaps it, including edge glyphs. */ + { + gpu_frame frame = gpu_snapshot(); + size_t first = gpu_page_first(frame.count, gpu_page * 5000ULL); + for (i = first; i < frame.count && i < first + 2; i++) { + g15canvas isolated; + char lines[5][32]; + int row, left = frame.count == 1 ? 108 : 54 + (int)(i-first) * 54; + init_canvas(&isolated); + gpu_lines(i, frame.samples[i], lines); + for (row = 0; row < 5; row++) + g15r_renderString(&isolated, (unsigned char *)lines[row], 0, G15_TEXT_SMALL, left, 1+row*7); + for (y = 0; y < 35; y++) for (x = left; x < (left == 54 ? 105 : 160); x++) + assert(g15r_getPixel(&canvas, x, y) == g15r_getPixel(&isolated, x, y)); + } + } + summary_gpu = 0; + print_time_info(&canvas, tmp); + assert(g15r_testG15FontWidth(font, tmp) <= 160); + assert_text(&canvas, tmp, G15_TEXT_SMALL, + 80 - (int)strlen(tmp) * 4 / 2, INFO_ROW, 160); + if (gpu_count < 2) { + const char *label = "TOTAL"; + for (int row = 0; row < 5; row++) { + char letter[2] = {label[row], 0}; + assert_text(&canvas, letter, G15_TEXT_MED, + gpu_count ? 98 : TEXT_RIGHT, row * 7, 160); + } + } + { + gpu_frame frame = gpu_snapshot(); + size_t first = gpu_page_first(frame.count, gpu_page * 5000ULL); + for (i = first; i < frame.count && i < first + 2; i++) { + char lines[5][32]; + int left = frame.count == 1 ? 108 : 54 + (int)(i-first) * 54; + gpu_lines(i, frame.samples[i], lines); + for (int row = 0; row < 5; row++) + assert_text(&canvas, lines[row], G15_TEXT_SMALL, left, 1+row*7, + left == 54 ? 105 : 160); + } + } + if (argc > 2) { + char path[PATH_MAX]; + snprintf(path, sizeof(path), "%s-%d.bin", argv[2], variant); + FILE *raw = fopen(path, "wb"); + assert(raw); + assert(fwrite(canvas.buffer, 1, 160 * 43 / 8, raw) == 160 * 43 / 8); + assert(!fclose(raw)); + snprintf(path, sizeof(path), "%s-%d.pgm", argv[2], variant); + FILE *out = fopen(path, "w"); + assert(out); + fprintf(out, "P2\n160 43\n255\n"); + for (y = 0; y < 43; y++) { + for (x = 0; x < 160; x++) fprintf(out, "%d ", g15r_getPixel(&canvas, x, y) ? 0 : 255); + fputc('\n', out); + } + assert(!fclose(out)); + } + } + if (argc > 2) { + FILE *out = fopen(argv[2], "w"); + assert(out); + fprintf(out, "P2\n160 43\n255\n"); + for (y = 0; y < 43; y++) { + for (x = 0; x < 160; x++) + fprintf(out, "%d ", g15r_getPixel(&canvas, x, y) ? 0 : 255); + fputc('\n', out); + } + assert(!fclose(out)); + } + glibtop_close(); + puts("PASS real LCD buffer: initialization, original geometry, both summary variants, independent glyph bits, complete TOTAL/footer, column bounds"); + return 0; +} \ No newline at end of file diff --git a/tests/runtime-test.c b/tests/runtime-test.c new file mode 100644 index 0000000..39c7ba8 --- /dev/null +++ b/tests/runtime-test.c @@ -0,0 +1,77 @@ +/* SPDX-License-Identifier: GPL-2.0-or-later + * Full application startup/draw loop, real libgtop and bitmap renderer. + * Only daemon transport is replaced. No TCP, USB, or input-device access. + * Default: GPU off, three frames. Optional: runtime-test auto 21 verifies + * real read-only GPU samples over twenty seconds (not a physical LCD test). + */ +#define main g15stats_application_main +#include "../g15stats.c" +#undef main +#include + +static unsigned frames, target = 3; +static int peer = -1; + +/* Catch the ABI-unsafe initializer even with a non-TTF renderer in CI. + * Do not stub any bitmap drawing or host statistics functions. */ +void g15r_initCanvas(g15canvas *canvas) +{ + (void)canvas; + fputs("FAIL: bitmap client called ABI-dependent g15r_initCanvas\n", stderr); + abort(); +} + +int new_g15_screen(int type) +{ + int pair[2]; + assert(type == G15_G15RBUF); + assert(!socketpair(AF_UNIX, SOCK_STREAM, 0, pair)); + peer = pair[1]; /* No key events: actual keyboard thread blocks in recv. */ + return pair[0]; +} + +int g15_send(int fd, char *buffer, unsigned int length) +{ + unsigned ink = 0; + gpu_frame frame = gpu_snapshot(); + (void)fd; + assert(length == G15_BUFFER_LEN); + for (unsigned i = 0; i < 160 * 43 / 8; i++) ink += !!buffer[i]; + assert(ink); + fprintf(stderr, "runtime frame %u: %u ink bytes, %zu GPUs\n", + ++frames, ink, frame.count); + if (frames == target) { + glibtop_cpu cpu; + glibtop_mem mem; + glibtop_get_cpu(&cpu); + glibtop_get_mem(&mem); + assert(cpu.total > 0 && mem.total > 0 && mem.free <= mem.total); + fprintf(stderr, "host stats: CPU ticks=%llu RAM total=%llu free=%llu\n", + (unsigned long long)cpu.total, (unsigned long long)mem.total, + (unsigned long long)mem.free); + for (size_t i = 0; i < frame.count; i++) { + gpu_sample s = frame.samples[i]; + fprintf(stderr, "%s: busy=%lld used=%lld total=%lld temp=%lld\n", + gpu_devices[i].bus, s.busy, s.used, s.total, s.temp); + assert(s.busy >= 0 && s.used >= 0 && s.total > 0 && s.temp != LLONG_MIN); + } + assert(!gpu_stop()); + /* Full main is intentionally endless. Terminate without closing the + * peer first (which would wake the legacy keyboard loop with EOF). */ + fputs("PASS full main, isolated transport, real renderer/collectors\n", stderr); + _Exit(0); + } + return length; +} + +int main(int argc, char **argv) +{ + char *args[] = {"g15stats", "--gpu", argc > 1 ? argv[1] : "off", NULL}; + if (argc > 2) { + long n = gpu_number(argv[2], 3, 30, -1); + assert(n >= 3); + target = (unsigned)n; + } + alarm(target + 10); /* Failure bound, not a passing timeout. */ + return g15stats_application_main(3, args); +} \ No newline at end of file