瀏覽代碼

Release o6h-memory-guard v1.0.2

master
psarria 4 小時之前
當前提交
c7e7b6775e
共有 28 個文件被更改,包括 3142 次插入0 次删除
  1. +26
    -0
      .gitignore
  2. +50
    -0
      CHANGELOG.md
  3. +758
    -0
      CHECKPOINT.md
  4. +42
    -0
      Makefile
  5. +353
    -0
      README.md
  6. +27
    -0
      SHA256SUMS
  7. +99
    -0
      VALIDATION.md
  8. +38
    -0
      etc/o6h-memory-guard.conf
  9. +39
    -0
      install.sh
  10. +10
    -0
      logs/example.log
  11. +9
    -0
      logs/policy-tests.log
  12. +7
    -0
      logs/portability.log
  13. +5
    -0
      logs/runtime-tests.log
  14. +5
    -0
      logs/sanitizers.log
  15. +25
    -0
      smf/o6h-memory-guard.xml
  16. +154
    -0
      src/config.c
  17. +180
    -0
      src/core.c
  18. +93
    -0
      src/guard.h
  19. +277
    -0
      src/illumos.c
  20. +492
    -0
      src/main.c
  21. +56
    -0
      src/support.c
  22. +17
    -0
      tests/api-sketch/kstat.h
  23. +13
    -0
      tests/api-sketch/procfs.h
  24. +7
    -0
      tests/api-sketch/sys/swap.h
  25. +2
    -0
      tests/api-sketch/vm/anon.h
  26. +9
    -0
      tests/api-sketch/zone.h
  27. +216
    -0
      tests/test_guard.c
  28. +133
    -0
      tests/test_runtime.c

+ 26
- 0
.gitignore 查看文件

@@ -0,0 +1,26 @@
# Native and test build products
/o6h-memory-guard
/test-guard
/test-runtime
/test-sanitize-guard
/test-sanitize-runtime
*.o
*.a
*.so

# Crash, temporary and editor files
/core
/core.*
*.core
*.tmp
*.swp
*~

# Release archives are generated outside the repository tree
*.tar
*.tar.gz

# Runtime logs and backups must never be committed
guard.log
guard.log.*
*.bak

+ 50
- 0
CHANGELOG.md 查看文件

@@ -0,0 +1,50 @@
# Changelog

## 1.0.2 — 2026-09-18

- Revisión exclusivamente documental del paquete: añadido `CHECKPOINT.md` con
la evidencia operativa posterior de v1.0.2 en SmartOS real y enlazado desde
README/VALIDATION. No cambia código, configuración, versión ni comportamiento.

- Corregido el contrato de telemetría del daemon continuo: cada `event=sample`
incluye compromiso virtual, ocupación física y pageout anónimo con flags
inequívocos, y `event=delta` emite sus ventanas 5/15/60 s.
- Eliminado `swap_used_pages` del runtime. Los auxiliares `swap_alloc_pages`,
`swap_reserved_pages` y `swap_available_pages` quedan documentados como
componentes virtuales de `SC_AINFO`, no como swap físico.
- Centralizada la inserción de todas las historias antes de evaluar presión y
registrar la muestra, evitando rutas distintas entre política y logging.
- Añadidas pruebas del contrato continuo que exigen los tres pares muestra/flag,
los nueve deltas 5/15/60 exactos y la ausencia del nombre ambiguo.
- Ampliadas las pruebas de política: swap físico solo, pageout solo o ambas
señales por encima de `lotsfree` son inertes. Sólo corroboran presión bajo
`lotsfree`; nunca constituyen un trigger independiente.
- Documentada la validación real de v1.0.1 bajo SMF: cadencia de 1 s, estado
NORMAL correcto, `nscan=0` y deltas 5/15 s, junto con la carencia de las
nuevas métricas en el logging continuo que motiva esta revisión.
- Se mantienen `ACTION_MODE dry-run`, `--force-dry-run`, cero UUID autorizados e
instancia SMF deshabilitada de fábrica.

## 1.0.1 — 2026-09-18

- Renombrada la antigua métrica ambigua `swap_used_pages` a
`swap_committed_pages`, equivalente al `allocated + reserved` de `swap -s`.
- Añadida `swap_physical_used_bytes`, equivalente a la ocupación de `swap -l`
calculada con bloques de 512 bytes y agregada para todos los dispositivos.
- Añadidos deltas 5/15/60 s para ambas métricas y para `anon_pageout_pages`.
- La ocupación física creciente sólo corrobora presión si coincide con pageout
anónimo creciente y `freemem < lotsfree`; nunca actúa como trigger único.
- Añadidas pruebas de conversión con el fixture real
`67024888 - 58931824 = 8093064` bloques = `4143648768` bytes, separación de
métricas/logs y lógica diagnóstica sin trigger físico aislado.
- Corregido el manifiesto SMF a `<stability value="Unstable"/>` tras el rechazo
real de `Uncommitted` en SmartOS.
- Conservados `ACTION_MODE dry-run`, `--force-dry-run`, instancia deshabilitada
y ausencia de zonas `allow` por defecto.
- Documentada la validación real del probe v1.0.0 y los puntos de v1.0.1 que aún
deben comprobarse en SmartOS.

## 1.0.0 — 2026-09-18

- Primera entrega del guard con máquina de estados, atribución por zona/proceso,
protecciones, límites de víctimas, dry-run forzado y servicio SMF.

+ 758
- 0
CHECKPOINT.md 查看文件

@@ -0,0 +1,758 @@
# Checkpoint del proyecto o6h-memory-guard

**Fecha de corte:** 18 de septiembre de 2026
**Nodo de validación principal:** `o6hsmartos10`
**Versión validada en ejecución:** `v1.0.2`
**Modo operativo:** `dry-run`
**Estado de activación:** `armed` no activado y no autorizado todavía

## 1. Propósito y conclusión ejecutiva

`o6h-memory-guard` es una protección específica para la global zone de SmartOS. Su objetivo es detectar con antelación una trayectoria de agotamiento de páginas físicas que pueda terminar en un panic `pageout_deadman`, identificar de forma conservadora qué zona y qué proceso están contribuyendo a la presión y, sólo después de una validación suficiente, permitir una acción correctiva limitada.

El problema que motivó el proyecto no es una alarma genérica de porcentaje de RAM. En dos hosts SmartOS de unos 32 GiB se observaron panics donde `pageout` quedó 90 segundos intentando evacuar la misma página con `freemem` prácticamente a cero. En ambos dumps, el camino de escritura de swap atravesaba un zvol de ZFS y necesitaba asignar memoria adicional dentro de DMU, ARC y ABD. Esa dependencia puede impedir que `pageout` progrese precisamente cuando el host ya no dispone de páginas.

La conclusión operativa hasta este corte es:

- La telemetría base y continua de `v1.0.2` está validada en SmartOS real.
- El daemon compila, funciona bajo SMF, clasifica correctamente el estado normal y mantiene los deltas de 5, 15 y 60 segundos.
- La política de elegibilidad por UUID funciona y las zonas no autorizadas quedan fuera.
- La atribución por zona ya produce datos útiles.
- La selección final de un PID por tamaño y crecimiento, y el evento `WOULD_ACTION` bajo presión real, todavía no están validados de extremo a extremo.
- No se debe activar `armed` todavía.

## 2. Problema raíz observado en SmartOS

### 2.1 Qué significa el panic

El mensaje común fue:

```text
pageout_deadman: stuck pushing the same page for 90 seconds
```

El panic indica que el hilo `pageout` no consiguió avanzar durante 90 segundos al intentar expulsar una página. No identifica por sí solo al proceso que creó la presión ni demuestra por sí solo que el disco estuviera bloqueado.

### 2.2 Cadena técnica demostrada en los dumps

En los dos casos analizados apareció esencialmente esta ruta:

```text
pageout
-> swap_putpage
-> swap_putapage
-> zvol_strategy
-> dmu_write
-> arc_alloc_buf / arc_hdr_alloc
-> arc_get_data_abd
-> abd_alloc
-> kmem_cache_alloc / vmem_alloc
-> segkmem_zio_alloc
-> page_create_va
```

Interpretación:

1. El host se queda casi sin páginas físicas libres.
2. `pageout` intenta liberar RAM escribiendo memoria anónima a swap.
3. El swap reside en un zvol.
4. La escritura atraviesa ZFS, DMU, ARC y ABD.
5. Ese camino necesita páginas físicas o memoria de kernel para completar la operación.
6. Ya no existen páginas suficientes.
7. `pageout` no progresa y finalmente dispara `pageout_deadman`.

También se observó un camino de `swapout` que alcanzaba `zvol_strategy`, `dmu_tx_assign`, `dmu_tx_wait` y `txg_wait_synced`. No se encontró un stack real detenido en `zio_wait`; por ello, la evidencia encaja mejor con inanición de páginas y dependencia de memoria que con una simple escritura de disco bloqueada durante 90 segundos.

## 3. Evidencia de los dos panics

| Evidencia | `o6hsmartos20` | `o6hsmartos10` |
|---|---:|---:|
| RAM física | ~32 GiB | ~32 GiB |
| Panic | `pageout_deadman` | `pageout_deadman` |
| `freemem` en el panic | 22 páginas | 57 páginas |
| Equivalente con páginas de 4 KiB | ~88 KiB | ~228 KiB |
| Anon en `::memstat` | ~23,6 GiB, 72 % | 21.486 MiB, 66 % |
| Kernel en `::memstat` | ~6,8 GiB, 21 % | 8.899 MiB, 27 % |
| ZFS File Data | ~1,8 GiB | 1.896 MiB, 6 % |
| Ruta `pageout -> swap -> zvol` | sí | sí |
| Ruta DMU/ARC/ABD que necesita páginas | sí | sí |
| `zio_wait` evidente | no | no |

En `o6hsmartos10`, `::memstat` mostró:

```text
Page Summary Pages MB %Tot
Kernel 2278204 8899 27%
Boot pages 87855 343 1%
ZFS File Data 485621 1896 6%
Anon 5500542 21486 66%
Exec and libs 9849 38 0%
Page cache 13864 54 0%
Free (cachelist) 66 0 0%
Free (freelist) 2 0 0%
Total 8376003 32718
Physical 8376002 32718
```

La diferencia entre las 57 páginas del mensaje del panic y las 68 páginas libres contabilizadas después por `::memstat` no cambia la conclusión: el sistema estaba prácticamente a cero.

## 4. Causa de la presión y mecanismo del panic no son lo mismo

Es una decisión fundamental del proyecto mantener separadas estas dos preguntas.

### 4.1 Causa de la presión

Es aquello que consume o compromete la memoria hasta atravesar `lotsfree`, `desfree` y `minfree`. En los dumps, la mayor categoría era memoria `Anon`; los consumidores probables incluían Apache, PHP-FPM, PHP-CGI, MySQL y otros procesos de zonas. También había una cantidad importante de memoria de kernel, que debe estudiarse por separado y no atribuirse automáticamente a una zona.

### 4.2 Mecanismo que convierte el agotamiento en panic

Es la dependencia circular del camino de pageout con swap sobre ZFS:

```text
presión extrema de RAM
-> pageout intenta liberar memoria
-> escritura a swap en zvol
-> ZFS/DMU/ARC/ABD necesita memoria
-> no quedan páginas
-> pageout no avanza
-> pageout_deadman
```

Apache `prefork` puede explicar parte de por qué se llegó al agotamiento. No explica por sí solo por qué el agotamiento terminó en `pageout_deadman`. Del mismo modo, mejorar el zvol de swap reduce el riesgo del mecanismo final, pero no elimina la necesidad de controlar a los consumidores de memoria.

## 5. Hallazgos de Apache y migración de prefork a event

En el momento de los panics, los dos servidores relevantes usaban `mpm_prefork`. En un dump se contaron 301 procesos `apache2`:

- 152 en la zona que entonces tenía `zone_id=7`.
- 124 en la zona que entonces tenía `zone_id=9`.
- El resto distribuido entre otras zonas.

Los `zone_id` eran válidos sólo en el instante del crash y cambiaron después del reinicio. El análisis resolvió la antigua zona 9 al UUID estable `bc806d9e-fca0-e333-aee6-93883689efe5`.

Un Apache de ejemplo, PID 47998 en el dump, tenía:

```text
a_size ~= 514,9 MiB
a_resvsize ~= 523,8 MiB
segments = 664
```

Estos valores describen el espacio de direcciones y la reserva contable; no son RSS y no deben multiplicarse por el número de workers como si fueran memoria física. Aun así, una población de cientos de procesos `prefork` es compatible con muchos GiB agregados de memoria anónima privada.

Después se migraron ambos servidores a `mpm_event`. En `web5` quedó demostrado:

```text
Server MPM: event
mpm_event_module (shared)
```

Configuración activa:

```text
ThreadLimit 64
ThreadsPerChild 25
MaxRequestWorkers 150
```

Esto permite aproximadamente seis procesos worker para 150 workers lógicos, en lugar de hasta 150 procesos independientes con `prefork`.

Medición posterior en `web5`:

```text
Procesos Apache: 5
RSS total: 139,4 MiB
RSS medio: 27,9 MiB
```

El conteo fiable de threads desde `/proc/<pid>/task` mostró tres hijos con 27 threads y procesos padre o transitorios con un thread. `ps -o nlwp` dentro de la LX zone mostraba incorrectamente `1`, por lo que no debe usarse allí como prueba definitiva.

Conclusión: la migración `prefork -> event` reduce de forma convincente una fuente importante de presión de memoria, pero no demuestra por sí sola qué fracción exacta de `Anon` pertenecía a Apache. Al evaluar el consumo web actual hay que sumar Apache y PHP-FPM, porque con `event` PHP reside en los pools FPM.

## 6. Diseño de o6h-memory-guard

### 6.1 Arquitectura

- Daemon nativo en C.
- Lecturas con `libkstat` y `/proc`.
- Servicio persistente SMF en la global zone.
- Instalación bajo `/opt/custom`.
- Muestreo del estado de memoria cada segundo.
- Escaneo periódico de procesos y mantenimiento anticipado del candidato.
- Sin `fork()` ni comandos externos en la ruta crítica.

Rutas instaladas observadas:

```text
/opt/custom/bin/o6h-memory-guard
/opt/custom/etc/o6h-memory-guard.conf
/opt/custom/smf/o6h-memory-guard.xml
/opt/custom/var/log/o6h-memory-guard/guard.log
```

La razón para evitar shell y procesos externos durante la emergencia es que, cerca de `minfree`, incluso `ps`, `awk`, `sort`, `prstat`, `vmadm` o `mdb` podrían no poder hacer `fork()` o reservar memoria.

### 6.2 Modos de seguridad

- `dry-run`: modo actual y predeterminado. Puede observar, puntuar candidatos y registrar lo que haría, pero no envía señales.
- `--force-dry-run`: salvaguarda de línea de comandos usada en `--probe`.
- `armed`: implementado, pero no activado ni aceptado para producción.
- La política exige dos compuertas de armado; una zona `allow` sólo podría ser señalizada si ambas están abiertas.
- `protect`, `observe`, una zona ausente de la configuración y `unmapped` nunca son elegibles, ni siquiera al alcanzar el floor.

### 6.3 Máquina de estados

Estados implementados o definidos:

```text
NORMAL
PRE-PRESSURE
PRESSURE
CRITICAL
EMERGENCY
RECOVERY
```

La lógica se apoya en los umbrales que ofrece el propio kernel:

- `NORMAL`: memoria por encima de `lotsfree` y sin señales de presión.
- `PRE-PRESSURE` y `PRESSURE`: cruce persistente de `lotsfree`; se intensifica diagnóstico y se prepara candidato, sin concluir por una sola muestra.
- `CRITICAL`: entorno de `desfree`, persistente y corroborado por tendencia, scanner o pageout.
- `EMERGENCY`: entorno de `minfree`, con acción mucho más urgente si el modo estuviera armado.
- `RECOVERY`: histéresis y cooldown; no se considera recuperación por un rebote momentáneo.

El floor absoluto es `minfree / 2`. Es un último umbral de protección, no un permiso para ignorar la política: una zona no autorizada sigue sin ser elegible.

### 6.4 Histéresis y ventanas

El guard evita reaccionar a una sola caída rápida de `freemem`. Mantiene deltas de 5, 15 y 60 segundos para distinguir:

- un pico breve ya terminado;
- una tendencia sostenida;
- un evento aún visible en la ventana larga pero inactivo en la corta.

La recuperación requiere estabilidad por encima del umbral, no un único sample. Al reiniciar o releer la configuración, el histórico se reinicia y aparece temporalmente como `NA`; esto es esperado.

### 6.5 Selección de zona y PID

El identificador persistente es el UUID de la zona, nunca `zone_id`. Los números de zona cambian tras un reinicio.

La selección no debe ser:

```text
zona con mayor RSS -> matar su proceso mayor
```

ni:

```text
PID con mayor RSS global -> matar
```

Debe combinar:

1. Presión real del host.
2. Zona explícitamente `allow`.
3. RSS y crecimiento de la zona.
4. RSS actual y crecimiento `d5/d15/d60` del PID.
5. Capacidad probable de liberar memoria rápidamente.
6. Exclusión de procesos y zonas protegidos.

Procesos o clases que no deben tocarse por defecto incluyen la global zone, PID 0/1, `pageout`, `fsflush`, `zsched`, `zoneadmd`, `svc.startd`, `svc.configd`, procesos `zpool-*`, `vmadmd` y VMs como `qemu-system-x86` o bhyve salvo autorización explícita extraordinaria.

## 7. Métricas validadas y su semántica

| Métrica | Fuente/uso | Interpretación correcta |
|---|---|---|
| `freemem` / `free_pages` | `unix:0:system_pages` | páginas físicas libres; señal principal de margen inmediato |
| `availrmem` | `system_pages` | memoria disponible para ciertos compromisos; no equivale a RAM libre |
| `lotsfree` | `system_pages` | frontera de reclamación activa y comienzo del diagnóstico intensivo |
| `desfree` | `system_pages` | presión seria |
| `minfree` | `system_pages` | situación extrema |
| `pp_kernel` | `system_pages` | páginas atribuidas al kernel; útil como serie y delta, no como trigger directo de kill |
| `pageslocked` | `system_pages` | páginas bloqueadas; se registra y sigue su delta |
| `nscan` | `system_pages` | actividad del page scanner; corrobora presión real |
| ARC | kstat ARC | tamaño/variación del ARC; no fue el consumidor dominante en los dumps |
| `swap_committed_pages` | equivalente a `swap -s` usado | compromiso virtual: allocated + reserved; no significa bytes escritos al zvol |
| `swap_physical_used_bytes` | equivalente a `swap -l` | ocupación física: `(blocks - free) * 512` |
| `anon_pageout_pages` | suma de `cpu:*:vm:anonpgout` | páginas anónimas realmente expulsadas; no duplicar con `cpu_stat:*` |

El swap físico y `anonpgout` corroboran presión sólo junto con el estado de memoria; no disparan acciones por sí solos.

## 8. Valores reales de o6hsmartos10

Página del sistema: 4096 bytes.

Valores estables observados de `system_pages`:

| Métrica | Páginas | Aproximación |
|---|---:|---:|
| `physmem` | 8.376.003 | 32.718,8 MiB |
| `lotsfree` | 130.875 | 511,2 MiB |
| `desfree` | 65.437 | 255,6 MiB |
| `minfree` | 49.077 | 191,7 MiB |
| floor `minfree/2` | ~24.538 | ~95,8 MiB |

Ejemplos de situación normal:

```text
freemem=255449 pages ~= 997,8 MiB
availrmem=5099973 pages ~= 19.921,8 MiB
pp_kernel=3251495 pages ~= 12.701,2 MiB
```

Otro sample del probe `v1.0.1`:

```text
free=274591 pages ~= 1.072,6 MiB
nscan=0
pp_kernel=3108718 pages ~= 11,86 GiB
pageslocked=3133407 pages ~= 11,95 GiB
arc_bytes=7001795280 ~= 6,52 GiB
swap_committed_pages=7621435 ~= 29,07 GiB
swap_physical_used_bytes=4061863936 ~= 3,78 GiB
anon_pageout_pages=6553284
```

Interpretaciones que deben conservarse:

- `availrmem` de unos 20 GiB no significa que haya 20 GiB físicamente libres.
- `pp_kernel` puede ser mayor que la categoría `Kernel` de un `::memstat`; no se deben tratar como cifras equivalentes ni usar `pp_kernel > X` como trigger sin más análisis.
- Un crecimiento rápido de `swap_committed_pages` puede ocurrir sin crecimiento de swap físico, sin `anonpgout` y sin `nscan`; ese caso no es presión de pageout.

### 8.1 Validaciones cruzadas exactas

En una lectura casi simultánea:

```text
swap_committed_pages = 7.632.011
7.632.011 * 4 KiB = 30.528.044 KiB
swap -s: 30.525.928 KiB used
diferencia ~= 2,07 MiB
```

Swap físico:

```text
swap -l blocks = 67.024.888
swap -l free = 59.100.824
used_blocks = 7.924.064
physical_used = 7.924.064 * 512 = 4.057.120.768 bytes
```

El probe cercano dio `4.061.863.936` bytes; la diferencia de unos 4,5 MiB era compatible con actividad entre lecturas.

`anonpgout`:

```text
cpu:0..7:vm:anonpgout sumados = 6.553.284
guard anon_pageout_pages = 6.553.284
```

La coincidencia fue exacta. Las entradas `cpu_stat:*:anonpgout` duplican los mismos contadores y no deben sumarse otra vez.

## 9. Evolución de versiones

### 9.1 v1.0.0

Primera entrega con daemon C, configuración, Makefile, SMF, instalador, pruebas, checksums, dry-run y modo armed implementado. Se validó inicialmente en Linux, pero no estaba aún certificada en SmartOS.

Primer defecto descubierto al instalar en SmartOS:

```text
<stability value="Uncommitted"/>
```

no era válido para el DTD de SMF usado. La forma correcta fue:

```text
<stability value="Unstable"/>
```

### 9.2 v1.0.1

Cambios principales:

- Corrección SMF `Uncommitted -> Unstable`.
- Sustitución semántica de `swap_used_pages` por `swap_committed_pages`.
- Incorporación de `swap_physical_used_bytes`.
- Incorporación de `anon_pageout_pages`.
- Deltas 5/15/60 para compromiso virtual, swap físico y pageout anónimo.

La lectura puntual mediante `--probe` quedó validada en SmartOS. Sin embargo, el daemon continuo seguía registrando la nomenclatura antigua `swap_used_pages` y no exponía de forma continua el swap físico ni `anon_pageout`. Esto fue un FAIL del contrato de logging, no de la lectura puntual.

### 9.3 v1.0.2

Corrigió el runtime continuo:

- `event=sample` incluye `swap_committed_pages`, `swap_physical_used_bytes`, `anon_pageout_pages` y sus flags `*_ok`.
- `event=delta` incluye d5/d15/d60 para las tres.
- `swap_used_pages` desaparece del runtime nuevo.
- `swap_alloc_pages`, `swap_reserved_pages` y `swap_available_pages` permanecen sólo como auxiliares de semántica explícita.
- La corroboración por swap físico/pageout sólo se usa bajo presión de memoria.

En el paquete se ejecutaron 113 pruebas de política y 50 de runtime, ASan, UBSan, `-fanalyzer`, C99 estricto y validaciones shell/XML. Después se compiló y probó nativamente en SmartOS.

El README de `v1.0.2` no traía una sección de upgrade. Se realizó una actualización controlada sustituyendo sólo el binario, preservando configuración, manifiesto y logs, con copia de rollback de `v1.0.1`.

## 10. Validación real de v1.0.2 en SmartOS

### 10.1 Resultado aceptado

| Componente | Estado |
|---|---|
| Compilación nativa SmartOS | PASS |
| `--probe --force-dry-run` | PASS |
| `system_pages` | PASS |
| ARC | PASS |
| swap comprometido | PASS |
| swap físico | PASS |
| `anonpgout` | PASS |
| SMF online | PASS |
| muestreo 1 s | PASS |
| logging continuo v1.0.2 | PASS |
| delta 5 s | PASS |
| delta 15 s | PASS |
| delta 60 s | PASS |
| clasificación `NORMAL` | PASS |
| `proc_scan` | PASS |
| `dry-run` | PASS |
| política UUID/eligible inicial | PASS |
| selección final zona/PID bajo presión | PENDIENTE |
| `WOULD_ACTION` real y razonable | PENDIENTE |
| `armed` | NO GO |

### 10.2 Comportamiento conservador demostrado

En un intervalo real:

```text
free_pages d5 = -110448 pages ~= -431 MiB
swap_committed_pages d5 = +113850 pages ~= +445 MiB
swap_physical_used_bytes d5 = -204800 bytes
anon_pageout_pages d5 = 0
nscan_pages d5 = 0
```

Aunque el compromiso virtual creció con rapidez, no aumentaron el swap físico, `anonpgout` ni `nscan`, y `freemem` seguía por encima de `lotsfree`. El guard mantuvo correctamente:

```text
state=NORMAL action_pressure=0 floor=0
```

### 10.3 Ventanas de tiempo demostradas

A las 17:19:06 se observó:

```text
swap_committed_pages: d5=+45584 d15=-187569 d60=-155145 pages
swap_physical_used_bytes: d5=-40960 d15=-30179328 d60=+75329536 bytes
anon_pageout_pages: d5=0 d15=0 d60=+27274 pages
```

La ventana de 60 s conservaba un episodio de unos 106,5 MiB de pageout anónimo y +75,3 MiB de swap físico, pero las ventanas de 5 y 15 s mostraban que ya no seguía activo. Es exactamente el contexto temporal que se quería obtener.

## 11. Política de zonas

Gramática confirmada:

```text
zone <lowercase-UUID> <alias-sin-espacios> <allow|protect|observe>
```

Semántica:

- `allow`: elegible para simulación; en `armed`, sólo con las dos compuertas abiertas.
- `protect`: visible y nunca señalizable.
- `observe`: visible y nunca señalizable.
- ausente o `unmapped`: observada pero nunca elegible.
- `global`: nunca elegible.

### 11.1 Inventario conocido a este corte

| UUID | Alias | Estado que puede afirmarse |
|---|---|---|
| `c82f1d64-fc20-4a80-9b72-b0940af83249` | `web5.open6hosting.com` / alias configurado `web5` | **`allow` confirmado explícitamente** |
| `fac1f403-e42c-ee4f-c0f6-cbaf7a6d7def` | `webscorp.unita.es` | aparentemente autorizada/visible con alias; no consta aquí la línea exacta de política |
| `344df936-1577-e8d7-b6d0-d9159fb15c33` | `servidor.correccionencatala.cat` | aparentemente autorizada/visible con alias; no consta aquí la línea exacta de política |
| `54e0531f-da35-c877-9fc8-92454565c9c5` | `03.base0.es` | aparentemente autorizada/visible con alias; no consta aquí la línea exacta de política |
| `81790093-33cb-45b5-d91e-8d38e9b4902a` | `servidor.consultaerte.com` | aparentemente autorizada/visible con alias; no consta aquí la línea exacta de política |
| `be9a7015-33ae-461b-d4e7-dab81ffc3c26` | `sailandfun.bz2` | añadida y visible con alias; aparentemente autorizada, pero la línea exacta `allow` no está reproducida |
| `bc806d9e-fca0-e333-aee6-93883689efe5` | sin alias en la política actual | deliberadamente no elegible, `unmapped` |
| `d0a34514-aa7f-c6ad-fee5-b6c7489787a6` | sin alias en la política actual | deliberadamente no elegible, `unmapped` |
| `global` | `unmapped` | deliberadamente no elegible y siempre protegida |

Nota histórica: antes de añadir `sailandfun.bz2`, las tres entradas deliberadamente fuera eran `bc806d9e-...`, `d0a34514-...` y `be9a7015-...`, además de `global`. Tras añadir `be9a7015-...` con alias `sailandfun.bz2`, las que siguen inequívocamente fuera son `bc806d9e-...`, `d0a34514-...` y `global`.

Que un UUID aparezca con alias demuestra que el daemon ha leído inventario/política para él, pero no demuestra por sí solo si la regla exacta es `allow`, `protect` u `observe`. Antes de `armed` debe revisarse el fichero de configuración literal.

## 12. Comportamiento observado por zona y proceso

### 12.1 web5 es grande y volátil, pero no siempre el mejor candidato

`web5.open6hosting.com` se ha observado aproximadamente entre 7 y 9 GiB de suma de RSS. En distintas ventanas pasó por:

```text
~8,58 GiB con d60 ~= +1,23 GiB
~7,49 GiB con d60 ~= -734 MiB
~7,73 GiB con d60 ~= +241 MiB
```

Por tanto mueve mucha memoria y es volátil, pero puede estar liberándola. Su tamaño total no demuestra que sea el culpable de cada episodio.

Además, distribuye el consumo entre muchos procesos. Los primeros `top_process` autorizados mostraron PHP-FPM individuales de aproximadamente 185 y 212 MiB. Un proceso único de otra zona con 800 MiB o 1 GiB puede ser un mejor objetivo de rescate aunque su zona sea menor.

### 12.2 Otras zonas

- `webscorp.unita.es`: aproximadamente 2,0-2,5 GiB; ventanas observadas desde crecimiento modesto hasta descenso.
- `servidor.consultaerte.com`: aproximadamente 1,8-2,0 GiB; en general estable, con algún intervalo cercano a +85 MiB/min.
- `sailandfun.bz2`: aproximadamente 2,9-3,6 GiB en las primeras muestras; candidata importante para comparar procesos individuales pesados.
- Las zonas `unmapped` se siguen midiendo, pero `eligible=0` evita que entren en cualquier acción.

## 13. Ejemplos de logs reales

Los backslashes que aparecían al copiar algunos logs eran escapes del chat; en el fichero real los nombres son `free_pages`, `swap_committed_pages`, etc.

### 13.1 event=sample

```text
2026-09-18T17:16:49Z mono=296136.349 event=sample state=NORMAL action_pressure=0 floor=0 free_pages=348087 lotsfree=130875 desfree=65437 minfree=49077 nscan=0 pp_kernel=2993989 pageslocked=3018668 arc_ok=1 arc_bytes=6152011944 swap_committed_ok=1 swap_committed_pages=7752388 swap_physical_used_ok=1 swap_physical_used_bytes=4136677376 anon_pageout_ok=1 anon_pageout_pages=6576887 swap_alloc_pages=5889225 swap_reserved_pages=1863163 swap_available_pages=5851985
```

### 13.2 event=delta

```text
2026-09-18T17:19:06Z mono=296273.346 event=delta metric=swap_committed_pages d5=+45584@5.00s d15=-187569@15.00s d60=-155145@60.00s
2026-09-18T17:19:06Z mono=296273.346 event=delta metric=swap_physical_used_bytes d5=-40960@5.00s d15=-30179328@15.00s d60=+75329536@60.00s
2026-09-18T17:19:06Z mono=296273.346 event=delta metric=anon_pageout_pages d5=+0@5.00s d15=+0@15.00s d60=+27274@60.00s
```

### 13.3 event=zone

```text
2026-09-18T17:48:43Z mono=298050.579 event=zone UUID=c82f1d64-fc20-4a80-9b72-b0940af83249 alias=web5.open6hosting.com generation=6 rss_sum_kib=8377060 complete=1 d5_kib=+52884@6.00s d15_kib=NA d60_kib=+649088@61.00s
2026-09-18T17:53:33Z mono=298340.974 event=zone UUID=be9a7015-33ae-461b-d4e7-dab81ffc3c26 alias=sailandfun.bz2 generation=1 rss_sum_kib=3380020 complete=1 d5_kib=-11692@6.00s d15_kib=NA d60_kib=+548@61.00s
```

### 13.4 event=top_process

En el primer escaneo después de autorizar `web5` se observaron, entre otros, PHP-FPM de aproximadamente 212 MiB y 185 MiB con:

```text
event=top_process UUID=c82f1d64-fc20-4a80-9b72-b0940af83249 alias=web5.open6hosting.com zone_id=6 pid=57890 comm=php-fpm rss_kib~=217000 eligible=1 d5_kib=NA d15_kib=NA d60_kib=NA
event=top_process UUID=c82f1d64-fc20-4a80-9b72-b0940af83249 alias=web5.open6hosting.com zone_id=6 pid=60832 comm=php-fpm rss_kib~=189000 eligible=1 d5_kib=NA d15_kib=NA d60_kib=NA
```

Estas dos líneas son una normalización de los campos y valores descritos en el hilo, no una copia byte a byte del fichero original. En la misma salida, procesos de `be9a7015-...` y `bc806d9e-...` aparecían como `alias=unmapped eligible=0`.

### 13.5 proc_scan

```text
2026-09-18T17:16:50Z mono=296137.359 event=proc_scan complete=1 duration_ms=12.7 capacity=4096
```

También se observó otro scan completo en unos 14,0 ms. El coste en estado normal parece aceptable.

## 14. Estado de aceptación y pendientes

### PASS

- Diagnóstico reproducido en dos dumps independientes.
- Distinción conceptual entre consumidor y mecanismo final del panic.
- Compilación nativa de `v1.0.2`.
- Probe y salvaguarda `--force-dry-run`.
- Lectura de `system_pages`, ARC, swap comprometido, swap físico y `anonpgout`.
- Correlación exacta o prácticamente exacta con `kstat`, `swap -s` y `swap -l`.
- Servicio SMF y logging continuo.
- Clasificación `NORMAL` coherente.
- Deltas 5/15/60 del host.
- `proc_scan` completo.
- Resolución UUID/alias y elegibilidad básica.
- Deltas por zona.
- Exclusión de zonas deliberadamente no autorizadas.

### PENDIENTE

- Confirmar `d5/d15/d60` por PID de forma sostenida en `event=top_process`.
- Comparar el ranking de procesos entre todas las zonas autorizadas.
- Confirmar que tamaño y crecimiento producen un candidato razonable, no sólo el mayor RSS.
- Observar una presión real con `WOULD_ACTION`, todavía en dry-run.
- Validar cooldown, reelección y límites de víctimas en un episodio representativo.
- Revisar literalmente cada regla `allow/protect/observe` antes de armado.
- Mantener seguimiento separado de `pp_kernel/pageslocked`; si crecen mientras las zonas se mantienen estables, no acusar automáticamente a una zona.
- No se ha aceptado el envío real de SIGTERM/SIGKILL.

## 15. Próximos pasos recomendados

1. Dejar el servicio en `dry-run` y acumular varios minutos o episodios reales.
2. Extraer `event=top_process` para todas las zonas con alias y comprobar que los PID persistentes adquieren `d5`, `d15` y `d60`.
3. Correlacionar cada `event=zone` con los `top_process` del mismo intervalo.
4. Comparar especialmente `web5` y `sailandfun.bz2`: una zona enorme con procesos moderados frente a una zona menor que puede contener uno o dos procesos pesados.
5. Esperar un `WOULD_ACTION` provocado por presión real; no provocar artificialmente un estado peligroso en producción sólo para probarlo.
6. Revisar que el candidato:
- pertenece a una zona `allow` confirmada;
- no coincide con ningún proceso protegido;
- está realmente creciendo o es capaz de liberar una cantidad relevante;
- explica una parte significativa del crecimiento de su zona;
- se selecciona sólo cuando el host presenta presión real.
7. Repetir la observación en más de un episodio antes de considerar `armed`.

### Evidencia mínima exigible antes de armed

- Varias transiciones de estado observadas y explicables.
- Al menos un `WOULD_ACTION` coherente en presión real.
- Deltas por proceso estables y sin confundir reinicios/reutilización de PID.
- Política completa revisada por UUID, con zonas críticas como `protect` o fuera del inventario de acción.
- Confirmación de que `global` y las UUID no autorizadas siempre producen `eligible=0`.
- Límites de víctimas, cooldown e inhibición inicial probados.
- Procedimiento de rollback ensayado.
- Aceptación explícita del operador para pasar de simulación a señales reales.

## 16. Comandos operativos útiles

### Estado y configuración del servicio

```bash
svcs svc:/site/o6h-memory-guard:default
svcs -xv svc:/site/o6h-memory-guard:default
svcs -l svc:/site/o6h-memory-guard:default
pgrep -fl o6h-memory-guard
grep -Ei 'mode|armed|dry' /opt/custom/etc/o6h-memory-guard.conf
grep '^zone ' /opt/custom/etc/o6h-memory-guard.conf
```

### Probe seguro

```bash
/opt/custom/bin/o6h-memory-guard --probe --force-dry-run
```

### Logs

```bash
tail -f /opt/custom/var/log/o6h-memory-guard/guard.log

grep 'event=sample' /opt/custom/var/log/o6h-memory-guard/guard.log | tail -20
grep 'event=delta' /opt/custom/var/log/o6h-memory-guard/guard.log | tail -50
grep 'event=zone ' /opt/custom/var/log/o6h-memory-guard/guard.log | tail -50
grep 'event=top_process' /opt/custom/var/log/o6h-memory-guard/guard.log | tail -100
grep 'event=proc_scan' /opt/custom/var/log/o6h-memory-guard/guard.log | tail -20

tail -1000 /opt/custom/var/log/o6h-memory-guard/guard.log |
egrep 'metric=(swap_committed_pages|swap_physical_used_bytes|anon_pageout_pages)' |
tail -30
```

### Comprobar que la nomenclatura antigua no reaparece

```bash
grep 'event=delta metric=swap_used_pages' \
/opt/custom/var/log/o6h-memory-guard/guard.log | tail
```

Las coincidencias históricas de `v1.0.1` son posibles; no debe haber ninguna nueva después del arranque de `v1.0.2`.

### Memoria y kstat

```bash
pagesize
kstat -p unix:0:system_pages
kstat -p | egrep -i 'anon.*pageout|pageout.*anon|anonpgout'
vmstat 1 20
```

Vista compacta:

```bash
PAGESIZE=$(pagesize)
kstat -p unix:0:system_pages |
nawk -v p="$PAGESIZE" '
$1 ~ /:(freemem|availrmem|lotsfree|desfree|minfree|physmem|pp_kernel|pageslocked|nscan)$/ {
split($1,a,":")
printf "%-12s %12d pages %10.1f MiB\n", a[4], $2, ($2*p)/(1024*1024)
}'
```

### Swap virtual y físico

```bash
swap -s
swap -l

swap -l | nawk '
NR > 1 { used_blocks += ($4 - $5) }
END {
printf "used_blocks=%d\n", used_blocks
printf "physical_used_bytes=%.0f\n", used_blocks * 512
printf "physical_used_MiB=%.1f\n", used_blocks * 512 / 1024 / 1024
}'
```

### Zonas y UUID

```bash
zoneadm list -cp
vmadm get <UUID> | json alias zonename state
zoneadm list -cp | grep '<UUID>'
```

No usar `zone_id` como identidad persistente.

### Apache event y threads en LX

```bash
apache2ctl -V | grep -i 'Server MPM'
apache2ctl -M | egrep 'mpm|php|proxy_fcgi'

for p in $(pgrep apache2); do
printf "%-7s threads=%s\n" "$p" "$(ls /proc/$p/task 2>/dev/null | wc -l)"
done

ps -C apache2 -o rss= | awk '{sum+=$1; n++} END {
printf "Procesos: %d\nRSS total: %.1f MB\nRSS medio: %.1f MB\n",
n, sum/1024, n ? sum/n/1024 : 0
}'
```

## 17. Reglas para continuar en un hilo nuevo

1. Empezar desde este checkpoint y no rehacer como hipótesis lo que ya está demostrado por dos dumps.
2. Mantener siempre la distinción entre causa de presión y mecanismo del panic.
3. Tratar `freemem` como métrica física crítica; no sustituirla por `availrmem` ni por porcentaje genérico de RAM.
4. Usar los umbrales dinámicos `lotsfree`, `desfree` y `minfree` del host; no codificar porcentajes arbitrarios.
5. Conservar el floor `minfree/2`, pero nunca usarlo para saltarse la política de elegibilidad.
6. No confundir swap comprometido con swap escrito físicamente. Mantener las métricas separadas.
7. No sumar dos veces `anonpgout` desde `cpu:*` y `cpu_stat:*`.
8. Identificar zonas por UUID estable y alias. `zone_id` sólo sirve para el instante observado.
9. Una zona visible con alias no se considera `allow` sin revisar la regla exacta.
10. `global`, las zonas `unmapped`, `observe`, `protect` o ausentes nunca son sacrificables.
11. No elegir por RSS absoluto solamente; combinar tamaño, crecimiento y presión real.
12. No asumir que `web5` es siempre culpable porque sea la zona mayor. Es grande y volátil, y sus procesos individuales observados son moderados.
13. Seguir comparando procesos pesados de otras zonas, especialmente `sailandfun.bz2`.
14. No interpretar `a_size` o `a_resvsize` de un dump como RSS físico.
15. No atribuir la categoría `Kernel` de `::memstat` a una zona sin evidencia. Seguir `pp_kernel` como diagnóstico, no como trigger directo.
16. En LX, contar threads Apache mediante `/proc/<pid>/task`; `ps -o nlwp` resultó engañoso.
17. No ejecutar comandos externos en la ruta crítica del guard.
18. Mantener `dry-run` hasta observar y revisar `WOULD_ACTION` bajo presión real.
19. No activar `armed` por el mero hecho de que compilación, probe y logging estén en PASS.
20. Antes de cualquier señal real, exigir revisión humana explícita de configuración, candidatos, cooldown, límites y rollback.

## 18. Punto exacto para reanudar

La próxima sesión debería comenzar obteniendo histórico por proceso:

```bash
grep 'event=top_process' \
/opt/custom/var/log/o6h-memory-guard/guard.log |
tail -100
```

Después debe correlacionarlo con:

```bash
grep 'event=zone ' \
/opt/custom/var/log/o6h-memory-guard/guard.log |
tail -50
```

La pregunta inmediata no es si el daemon mide memoria correctamente; eso ya está aceptado. La pregunta pendiente es si el ranking por PID identifica de manera repetible al proceso que mejor explica el crecimiento y que más memoria podría liberar, respetando la política de zonas, antes de que se autorice cualquier acción real.

+ 42
- 0
Makefile 查看文件

@@ -0,0 +1,42 @@
# GNU make / gmake. Native target requires an illumos 64-bit compiler + headers.
CC = gcc
CPPFLAGS = -D__EXTENSIONS__ -D_POSIX_C_SOURCE=200809L -Isrc
CFLAGS = -std=c99 -m64 -O2 -Wall -Wextra -Werror -Wformat=2
LDLIBS = -lkstat -lrt
SOURCES = src/main.c src/core.c src/config.c src/support.c src/illumos.c
TESTFLAGS = -std=c99 -O1 -g -D_POSIX_C_SOURCE=200809L -D_XOPEN_SOURCE=700 -Isrc -Wall -Wextra -Werror -Wformat=2 -pedantic

.PHONY: all native-check test test-sanitize portability clean
all: o6h-memory-guard

native-check:
@test "`uname -s`" = SunOS || { echo "Native build requires SmartOS/illumos; use make test and make portability here."; exit 1; }

o6h-memory-guard: $(SOURCES) src/guard.h | native-check
$(CC) $(CPPFLAGS) $(CFLAGS) $(LDFLAGS) -o $@ $(SOURCES) $(LDLIBS)

test-guard: tests/test_guard.c src/core.c src/config.c src/guard.h
$(CC) $(TESTFLAGS) -o $@ tests/test_guard.c src/core.c src/config.c

test-runtime: tests/test_runtime.c src/main.c src/core.c src/config.c src/guard.h
$(CC) $(TESTFLAGS) -o $@ tests/test_runtime.c src/core.c src/config.c

test: test-guard test-runtime
./test-guard
./test-runtime

test-sanitize:
$(CC) $(TESTFLAGS) -fsanitize=address,undefined -fno-omit-frame-pointer -o test-sanitize-guard tests/test_guard.c src/core.c src/config.c
./test-sanitize-guard
$(CC) $(TESTFLAGS) -fsanitize=address,undefined -fno-omit-frame-pointer -o test-sanitize-runtime tests/test_runtime.c src/core.c src/config.c
./test-sanitize-runtime

# Local API sketches only: NOT real illumos headers, ABI, linking or runtime validation.
portability:
$(CC) $(TESTFLAGS) -Itests/api-sketch -fsyntax-only $(SOURCES)
@for source in $(SOURCES); do $(CC) $(TESTFLAGS) -Itests/api-sketch -fanalyzer -c $$source -o /dev/null || exit 1; done
sh -n install.sh
xmllint --nonet --noout smf/o6h-memory-guard.xml

clean:
rm -f o6h-memory-guard test-guard test-runtime test-sanitize-guard test-sanitize-runtime

+ 353
- 0
README.md 查看文件

@@ -0,0 +1,353 @@
# o6h-memory-guard 1.0.2

Primera implementación para SmartOS global zone: daemon C de 64 bits, libkstat,
/proc, identidad UUID + generación de zona + PID + instante de creación,
configuración separada y servicio SMF. Entrega de código fuente; **no contiene
un binario nativo validado en SmartOS**. Véase `VALIDATION.md`.

Arranca en `dry-run`. El manifiesto añade `--force-dry-run`, por lo que cambiar
sólo `ACTION_MODE` no arma el servicio. No se entrega ninguna zona autorizada.
No se ha desplegado ni se han enviado señales reales durante la preparación.

## Documentación de la entrega

- `README.md`: instalación, semántica, política, operación y rollback.
- `VALIDATION.md`: validación local realizada al construir esta entrega y
comprobaciones nativas que en ese momento quedaban pendientes.
- `CHECKPOINT.md`: checkpoint operativo posterior, incorporado sin cambios,
con la evidencia acumulada de compilación, ejecución y logging de v1.0.2 en
SmartOS real, además del estado exacto y los requisitos antes de `armed`.
- `CHANGELOG.md`: evolución de versiones y revisión documental del paquete.

El checkpoint es cronológicamente posterior a `VALIDATION.md`; por ello amplía
sus resultados de campo sin cambiar el código ni autorizar el modo `armed`.

## Instalación inicial y activación en dry-run

Los comandos siguientes se ejecutan por el operador, como root en la global
zone. Requieren `gcc` de 64 bits, cabeceras nativas y GNU make (`gmake`) en PATH.
Si no hay herramientas de desarrollo, compilar en una **zona nativa SmartOS**
de la misma plataforma (no LX), ejecutar allí `gmake test`, y copiar el binario
junto a este árbol al host. No instalar un compilador en un nodo bajo presión.

Copiar el archivo distribuido a `/var/tmp/o6h-memory-guard-1.0.2.tar.gz`:

```sh
mkdir -p /opt/custom/src
cd /opt/custom/src
gzip -dc /var/tmp/o6h-memory-guard-1.0.2.tar.gz | tar -xf -
cd /opt/custom/src/o6h-memory-guard
gmake CC=gcc
gmake test CC=gcc
file ./o6h-memory-guard
ldd ./o6h-memory-guard
sh ./install.sh
```

`file` debe identificar un ELF nativo de 64 bits. `ldd` no debe mostrar
dependencias ausentes. El instalador sólo copia archivos y valida el manifiesto;
no importa ni inicia servicios, y rechaza sobrescribir una instalación existente.
Si ya se copió un binario compilado en una zona nativa, omitir `gmake CC=gcc` en
la global zone; conservar los resultados de compilación y pruebas.

Inventariar UUID y alias fuera del daemon:

```sh
vmadm list -o uuid,alias
vi /opt/custom/etc/o6h-memory-guard.conf
```

Añadir una línea por zona, con UUID real y alias sin espacios:

```text
zone <UUID-real-de-web5> web5 allow
zone <UUID-real-de-base-de-datos> database protect
zone <UUID-real-de-monitorizacion> monitoring protect
```

Esos marcadores **no son valores válidos**: sustituirlos, incluidos los signos
`< >`. `allow` hace elegible una zona para las simulaciones; no habilita señales
mientras el servicio esté en dry-run. `protect`, `observe` o la ausencia de una
regla excluyen la zona de toda acción. Sin reglas `allow` el monitor funciona y
registra métricas/top de procesos, pero informa `no_authorized_candidate`.

```sh
/opt/custom/bin/o6h-memory-guard --check-config --force-dry-run
/opt/custom/bin/o6h-memory-guard --probe --force-dry-run
svccfg validate /opt/custom/smf/o6h-memory-guard.xml
svccfg import /opt/custom/smf/o6h-memory-guard.xml
svcadm enable -s svc:/site/o6h-memory-guard:default
svcs -xv svc:/site/o6h-memory-guard:default
svcprop -p start/exec svc:/site/o6h-memory-guard:default
tail -f /opt/custom/var/log/o6h-memory-guard/guard.log
```

Verificar `mode=dry-run` en `event=start`, `--force-dry-run` en `start/exec`,
`arc_ok=1`, `swap_committed_ok=1`, `swap_physical_used_ok=1`,
`anon_pageout_ok=1` y
`proc_scan complete=1`. No generar presión artificial
en producción. Para comprobar selección y UUID, revisar `candidate`, `zone` y
`top_process` durante carga natural; `WOULD_ACTION` sólo aparece si se alcanzan
los umbrales y hay candidato autorizado. `--probe` lee una muestra y sale sin
locks, logs ni acciones; `--check-config` sólo valida configuración y permisos.

Para que se active automáticamente tras un reinicio de SmartOS, después de
validar este arranque editar **únicamente** `create_default_instance` a
`enabled="true"` en el manifiesto persistente y volver a importarlo:

```sh
vi /opt/custom/smf/o6h-memory-guard.xml
svccfg validate /opt/custom/smf/o6h-memory-guard.xml
svccfg import /opt/custom/smf/o6h-memory-guard.xml
```

Mantener `--force-dry-run`. La ubicación `/opt/custom/smf` se utiliza para
servicios personalizados persistentes de SmartOS; no depender exclusivamente
del estado habilitado de la instancia en el repositorio del arranque actual.

## Métricas y ventanas

Cada segundo se leen `unix:0:system_pages:{freemem,lotsfree,desfree,minfree,nscan,
pp_kernel,pageslocked}`, `cpu:*:vm:anonpgout` y `zfs:0:arcstats:size` mediante
libkstat. `swapctl(SC_AINFO)` proporciona asignación, reserva y disponibilidad
de swap virtual, equivalente a `swap -s`. La métrica inequívoca es
`swap_committed_pages = allocated + reserved`, e incluye compromiso anónimo que
puede seguir residente en RAM; **no representa pageout ni bytes escritos en el
zvol**. La reserva aún no asignada es
`ani_resv - (ani_max - ani_free)`.

Por separado, `swapctl(SC_LIST)` obtiene las mismas entradas que `swap -l`.
`swap_physical_used_bytes` suma `(blocks - free) * 512` de todos los dispositivos;
los campos nativos de la API están en páginas y se convierten primero a los
bloques de 512 bytes que muestra la utilidad. Esta cifra sí representa ocupación
física del swap configurado. Con la validación observada: `swap -s` indicó unos
28,5 GiB comprometidos, mientras `swap -l` indicó sólo 8.093.064 bloques usados,
es decir 4.143.648.768 bytes (unos 3,86 GiB). No deben compararse como si fueran
la misma magnitud.

El log conserva unidades: páginas para VM/compromiso virtual/pageout, bytes para
ARC y swap físico, KiB para RSS.
`page_bytes` se obtiene de `sysconf`, nunca se presupone 4096. `nscan` es una
medida del scanner, no un contador acumulativo; su delta se registra como
cambio de la medida y no se interpreta como tasa de pageout.

Historias circulares con timestamps monotónicos, deltas 5/15/60 s para libre,
kernel, locked, ARC, compromiso/disponibilidad virtual, ocupación física de swap,
pageout anónimo y nscan. Los deltas incluyen la edad
real (`-100@5.02s`). Se exige una muestra a ±1,25 s de la ventana; si falta,
sale `NA`, sin inventar ceros. /proc se recorre cada 5 s en NORMAL y cada 1 s
bajo `lotsfree`; RSS y crecimiento de procesos/zonas usan las mismas ventanas.

El contrato de cada `event=sample` expone los tres pares inequívocos
`swap_committed_ok/swap_committed_pages`,
`swap_physical_used_ok/swap_physical_used_bytes` y
`anon_pageout_ok/anon_pageout_pages`. Cada muestra va seguida por líneas
`event=delta` con esos tres nombres exactos y `d5`, `d15` y `d60`.
`swap_used_pages` no es un nombre válido. Se conservan como diagnóstico auxiliar
`swap_alloc_pages`, `swap_reserved_pages` y `swap_available_pages`: son los
componentes de compromiso virtual de `SC_AINFO`, todos en páginas, y ninguno
representa bytes escritos en el dispositivo swap.

En los datos originales, `425950 > 130875` páginas y `nscan=0` corresponden a
NORMAL. Los valores de 130875/65437/49077 páginas no están codificados como
umbrales: se vuelven a leer del kernel. ARC y `pp_kernel` son diagnóstico, nunca
una prueba de que un proceso concreto causó la presión.

## Estados y acción

| Estado/condición de entrada | Comportamiento predeterminado |
| --- | --- |
| NORMAL | Sin intervención; muestreo VM de 1 s. |
| PRE-PRESSURE | `free < lotsfree` continuo durante 3 s; atribución de 1 s. |
| PRESSURE | Bajo `lotsfree` durante 10 s y scanner activo o libre descendiendo. |
| CRITICAL | Bajo `desfree` con presión corroborada continua durante 2 s; TERM. |
| EMERGENCY | `free < minfree`; entrada en la misma muestra, sin confirmación. TERM a nuevo candidato; KILL tras 1 s al pendiente. |
| Floor | `free < minfree/2`; KILL directo o escalada inmediata al pendiente. |
| RECOVERY | Al recuperar `lotsfree` se cancela la escalada. NORMAL requiere 15 s con `free >= lotsfree*1.10` y `nscan=0`. |

La presión se corrobora con `nscan > 0`, caída respecto a la muestra anterior o
delta libre de 5 s negativo. Además, bajo `lotsfree`, el crecimiento simultáneo
en 5 s de `swap_physical_used_bytes` y `anon_pageout_pages` corrobora presión.
La ocupación física jamás es un trigger único: sin pageout creciente y memoria
bajo `lotsfree` no cambia el estado. El compromiso virtual tampoco se interpreta
como pageout.
Los tiempos son segundos monotónicos transcurridos, no número de filas leídas.
Un hueco de más de 2,5 s reinicia las confirmaciones. La gravedad se retiene
hasta recuperación; `action_pressure` indica si **la condición actual** permite
actuar. Por eso un estado retenido CRITICAL no autoriza una señal por sí solo.

TERM tiene 5 s de gracia en CRITICAL. Sólo se escala si la condición crítica
sigue confirmada y la lectura fresca sigue bajo `desfree`. Por encima de
`desfree` se suspenden señales; alcanzar `lotsfree` cancela el pendiente. Tras
KILL se puede elegir otro candidato si sigue la presión. EMERGENCY evita la
espera normal entre víctimas de 10 s. Se mantiene como máximo una decisión de
señal por tick; un pendiente desaparecido puede sustituirse en ese mismo tick.

Hay un máximo predeterminado de 4 víctimas por episodio y 4 por ventana móvil
de 60 s, tanto en simulación como en armed. TERM+KILL del mismo proceso cuentan
como una víctima. El presupuesto del episodio sólo se reinicia al volver a
NORMAL; el de 60 s sobrevive a ese cambio. En armed, un arranque o reinicio tiene
60 s de inhibición obligatoria de señales (también en EMERGENCY), para evitar
eludir el límite por minuto mediante reinicios repetidos. Los contadores son
en memoria; el presupuesto por episodio sí se reinicia al reiniciar el daemon.
Revisar esta elección
antes de usarlo como mecanismo de rescate. No hay garantía de evitar un panic.

## Atribución y protecciones

Se suma RSS por UUID/generación de zona y se puntúa como
`RSS + 2*max(crecimiento,0)`, usando preferentemente 60 s, después 15 s y 5 s.
Se elige la zona elegible con mayor puntuación y dentro de ella el proceso
elegible con mayor puntuación. Sin historia se usa RSS. Empates conservan el
primer candidato encontrado. **La suma de RSS puede contar memoria compartida
varias veces**: es una aproximación de atribución, no memoria física exclusiva
ni memoria garantizada que se recuperará al terminar un proceso.

El UUID es el nombre de zona devuelto por el kernel, validado como UUID canónico
en minúscula (convención SmartOS). Se conserva además `ZONE_ATTR_UNIQID`; se
consulta `ZONE_ATTR_INITPID`. Los alias son etiquetas del catálogo del operador,
no se obtienen mediante comandos ni se usan para autorizar señales. Un alias
renombrado debe actualizarse manualmente. En illumos con nombres de zona que no
sean UUID, el muestreo funciona pero esas zonas quedan excluidas de acciones.

Protecciones obligatorias: global zone completa; PID 0/1 y proceso propio;
padre 0; procesos sin LWPs; init de cada zona; identidades no resueltas o sin
generación; zonas sin regla `allow`; RSS menor de 64 MiB por defecto. También
se excluyen los nombres `init`, `systemd`, `svc.startd`, `svc.configd`, `sshd`,
`zoneadmd`, `vminfod`, `vmadmd`, `qemu`, `qemu-system-x86`, `bhyve` y el guard.
Las protecciones se aplican incluso al floor y prevalecen sobre toda autorización.

`protect_process` añade nombres exactos `pr_fname` de hasta 15 caracteres,
sin comodines ni argumentos; `protect_pid` añade PID global, no PID visto
dentro de LX. La config entregada añade PostgreSQL, MySQL/MariaDB, Redis y
Zabbix. Las zonas de base de datos/infraestructura deben figurar como `protect`.
Los nombres pueden cambiar/truncarse y no constituyen una frontera contra un
proceso malicioso: la protección por UUID completo es la más robusta.

Antes de actuar se comprueban UUID, generación, PID, inicio, nombre, UID/EUID,
init, RSS y reglas. Se abren psinfo/ctl respecto al mismo descriptor de directorio
/proc y se envía `PCKILL` al descriptor, no a un número PID mediante `kill(2)`.
El inicio de un PID reutilizado invalida el candidato. Cambios de nombre/UID ya
visibles también lo invalidan. Queda la carrera inherente de un exec ordinario
entre la última lectura de psinfo y la escritura: no se detiene el proceso para
eliminarla. No se tocan flags de tracing, no se usa kill-on-close ni señales a
grupos. La integración de esta ruta todavía necesita validación nativa de laboratorio.

En dry-run nunca se abre `ctl`: se revalida con lecturas, se escribe
`WOULD_ACTION=SIGTERM/SIGKILL` y se actualiza sólo el estado simulado. Los
presupuestos evitan repetir infinitamente el mismo sacrificio hipotético.
En armed se escribe `ACTION_INTENT` antes y `action_result=...` después
(el campo real es `event=action_result result=sent|failed`). Los errores de
escritura de señal consumen presupuesto para evitar reintentos ambiguos.

## Recursos y fallos

Almacenamiento de trabajo fijo para 4096 procesos y 256 zonas, prefaulted por
inicialización; el tamaño se registra al arrancar. No hay malloc de la aplicación
en la ruta de acción, ni `system`, `popen`, `exec`, `vmadm`, `ps` o `vmstat` en
el daemon. libkstat/libc sí pueden reservar memoria internamente al actualizar
su cadena. No se promete cero asignaciones ni tiempo real estricto.

El recorrido /proc tiene presupuesto cooperativo de 200 ms (hasta 500 ms por
configuración). Si faltan permisos, se excede capacidad/plazo o hay errores,
el scan se marca incompleto y bloquea acciones. La desaparición normal de un
proceso no se trata como fallo total. Se usa caché completa de como máximo 2 s
en emergencias y siempre se revalida. Si no hay caché válida se intenta el
recorrido antes de actuar. Una llamada del kernel bloqueada puede exceder el
presupuesto; no es un límite duro. Los overruns quedan registrados.

Muestras obligatorias ausentes/invalidas o demasiado lentas inhiben señales;
ARC/swap/pageout opcionales ausentes se marcan con `*_ok=0` y no bloquean por sí
solos. Un fallo de
log inhibe las acciones hasta reiniciar y también se anuncia en stderr/SMF.
Los logs se escriben síncronamente al archivo sin fsync por muestra; el I/O o
ZFS bloqueado puede retrasar este daemon. Tampoco garantiza que las últimas
líneas sobrevivan a pérdida de alimentación o panic. Es persistencia en disco,
no un journal de transacciones de señales. No se cambia swap, ARC ni caps de zona.
La enumeración física admite hasta 64 dispositivos swap con almacenamiento fijo;
si el nodo supera ese límite, `swap_physical_used_ok=0` y esa señal diagnóstica se
omite sin autorizar acciones.

Un lock fcntl evita dos instancias (incluso una manual y otra SMF); el PID guardado
es diagnóstico, nunca se usa para señalizar. Config/locks/logs exigen propietario
root, archivo regular de un enlace, destino sin symlink y directorios resueltos
sin escritura para grupo/otros. Directorios /opt con symlinks legítimos de root
se admiten al resolver su destino. SIGHUP sólo reabre el log; la configuración se
recarga reiniciando el servicio. SIGTERM/SIGINT al daemon solicitan salida limpia.

## Logging y mantenimiento

```text
/opt/custom/bin/o6h-memory-guard
/opt/custom/etc/o6h-memory-guard.conf
/opt/custom/smf/o6h-memory-guard.xml
/opt/custom/var/run/o6h-memory-guard.lock
/opt/custom/var/log/o6h-memory-guard/guard.log
/opt/custom/var/log/o6h-memory-guard/guard.log.1 ... guard.log.5
```

Rotación interna: 10 MiB por archivo y 5 copias, aproximadamente 60 MiB máximos.
El volumen de líneas por segundo puede hacer que la retención sea inferior a
un día; aumentar LOG_MAX_MIB/LOG_KEEP o recoger logs externamente si se necesita
un estudio de varios días. No configurar copytruncate/logadm simultáneamente.
Snapshots de todas las zonas y top 20 procesos cada 60 s normal / 5 s en presión.
Las muestras de 1 s posteriores a las acciones permiten revisar la recuperación
en 1/2/5/10 s, siempre que no haya overruns. No se registran argumentos de procesos.
`logs/policy-tests.log` y `logs/runtime-tests.log` proceden de pruebas locales;
no son datos del nodo. `logs/example.log` es un ejemplo sintético de formato.

## Preparación de armed (no ejecutar durante la evaluación inicial)

Se necesitan deliberadamente dos cambios: `ACTION_MODE armed` en la config y
retirar `--force-dry-run` del método start del manifiesto. Mantener las reglas
UUID `allow` mínimas y verificar antes las protecciones y el laboratorio nativo.
Después de esos cambios, validar config/manifiesto, importar, refrescar y
reiniciar el servicio. Verificar `event=start mode=armed`; comienza la inhibición
de 60 s. No se proporciona un script que arme automáticamente el servicio.

Para volver a dry-run, restaurar `ACTION_MODE dry-run` y `--force-dry-run` en
el manifiesto; importar, `svcadm refresh` y `svcadm restart` la instancia.

## Parada, rollback y actualización

Parar el guard sin borrar evidencia:

```sh
svcadm disable -s svc:/site/o6h-memory-guard:default
svcs -p svc:/site/o6h-memory-guard:default
```

Si se habilitó el arranque persistente, volver a `enabled="false"` e importar
el manifiesto para mantenerlo deshabilitado en próximos arranques. Para retirar
el servicio conservando los archivos y logs:

```sh
mkdir -p /opt/custom/var/backups/o6h-memory-guard
mv /opt/custom/smf/o6h-memory-guard.xml /opt/custom/var/backups/o6h-memory-guard/o6h-memory-guard.xml.disabled
svccfg delete svc:/site/o6h-memory-guard:default
```

Comprobar antes que el destino de backup no exista y elegir un nombre distinto
si ya se utilizó. El manifiesto sale del directorio de importación automática;
binario/config/logs permanecen recuperables. No eliminar ni truncar el lock de
una instancia viva. Restaurar el manifiesto e importarlo permite reinstalar SMF.

Para actualizar: deshabilitar primero, conservar copias del binario, config y
manifiesto en un directorio de backup nuevo, reemplazar el binario estando parado
y validar los tres archivos. No reemplazar automáticamente la config ni perder
las protecciones. El instalador inicial se niega a hacerlo por ese motivo.

## Referencias revisadas

- [SmartOS: servicios personalizados y /opt/custom/smf](https://docs.smartos.org/smf-quick-reference-gz/).
- [illumos: libkstat](https://smartos.org/man/3KSTAT/kstat).
- [illumos: definiciones nativas de psinfo](https://github.com/illumos/illumos-gate/blob/master/usr/src/uts/common/sys/procfs.h).
- [OmniOS/illumos: control /proc y PCKILL](https://man.omnios.org/man5/proc.5).
- [illumos: atributos de zona](https://github.com/illumos/illumos-gate/blob/master/usr/src/uts/common/sys/zone.h).
- [OmniOS/illumos: resolución del nombre de zona](https://man.omnios.org/man3c/getzoneid).
- [illumos: cálculo de swap -s](https://github.com/illumos/illumos-gate/blob/master/usr/src/cmd/swap/swap.c).
- [illumos: listado swap -l y conversión a bloques](https://github.com/illumos/illumos-gate/blob/master/usr/src/cmd/swap/swap.c#L350-L474).

Las interfaces de atributos de zona dependen de la plataforma: confirmar con
la versión exacta de SmartOS. La documentación revisada no sustituye la prueba
de compilación/ejecución en ese host.

+ 27
- 0
SHA256SUMS 查看文件

@@ -0,0 +1,27 @@
887569535302f7deba4d7c6ffb2103f1fb90bd96a40280240ff238eddc2e3a6f ./.gitignore
45e39d4bc8c8e3ef59e99167ef87fcc2864c0307693eeb5cb03664a0f6a7a722 ./CHANGELOG.md
ebfca7d4b5eaf0c0cc9910cf49d8c85f6aa7ff69972d7f21d17e61d1a2e23bc2 ./CHECKPOINT.md
ff7a0c102a9ce90b0eab3ec3e4f9fc6c37a186e2852c740a5a3433e605da750b ./Makefile
8bd0f7d52b9cfbba4c8b5076bbe58f790982a6adf6b594f9cdbf717984ff6c70 ./README.md
cf519f00a0eb382faf214bf050009fd6391887d1bf4c78e901ff466108f0cdfc ./VALIDATION.md
e5f98d46a5908f640709c262f9c64ceffbad825ac413059e8b3c7aa4358afc90 ./etc/o6h-memory-guard.conf
f6565d0e1ece0a08721c9893f9ed071d2ab0a2818fa93ed0a14e0050d358ce99 ./install.sh
059e1842336384e9364e763a998ca7a18ad8f330dab0f7c46cc4cf1664f0cc50 ./logs/example.log
77f710beb230b7f0285cf071dd8e59cef204f1d87aafec0ed36249f182e2481d ./logs/policy-tests.log
abafc237427b63f38f81d0d1b76ba64eefc64d9f8074fdaa6ed0f1573b5d0a3b ./logs/portability.log
b1b51e63d266d70eabde686d904451640c1088d44d36412bb6bcc756d7b82928 ./logs/runtime-tests.log
7be49939ef5450c0f3a2be609c2a63c67dc4f90e1f2294b74d5b17d549a54e37 ./logs/sanitizers.log
b55afd45a72947efdda79d593255c36def9679bcb7a33ab14d55170ebaf9bef7 ./smf/o6h-memory-guard.xml
ada10059e26d94d796218108c031f7591d6e346d931da304643a74dd934b6656 ./src/config.c
f3b5bc0404a00f38bf2ae60a6aa924a9bc7ba080e1e9a9e72b2ae49d53835d41 ./src/core.c
5c741525f6b7ea7771adacb8aa40ca1a1eefbaba0f2c37f2eda53a7cf9a11402 ./src/guard.h
32ab75bb6a1ff2bbbeb6a1ea658abcb96a03fcc06246f38d019faec7fdc184b8 ./src/illumos.c
2a9c54014272a78060b73b1863606e87e643cd27359bb77e12b8224b45baf18a ./src/main.c
2cb2b4c50fd42051fde4a702e8955e07d63979d5e3216e02420ed9b54fb67578 ./src/support.c
f754e71d91d65403ab8e84e9701cfbb89bdd91e13766e668445e689e6890b369 ./tests/api-sketch/kstat.h
f0d06353d2e326c7bdce776bee6f0a8606cda50d5de957dfdd586b6848fca8da ./tests/api-sketch/procfs.h
1c4fcb7bc48ec0a2774b1ecd73346dd45feb969a9250eb7056809d5d11ad40fe ./tests/api-sketch/sys/swap.h
340600b282a0c813323c1a09d5d6635ed4c56aa49a6d04132636851c8e714170 ./tests/api-sketch/vm/anon.h
9b99e272008ad00ad0abb366e1e8720dd6c51fc050821c5d1d878eae5719c22b ./tests/api-sketch/zone.h
af6e89c7058394a67f24e8cd7c5ab3e106bfa36c564901af001b7e781ea15ba6 ./tests/test_guard.c
217001c36c4e878ad365f127a12d03f714476d6ff32580407f8f0522fe564d83 ./tests/test_runtime.c

+ 99
- 0
VALIDATION.md 查看文件

@@ -0,0 +1,99 @@
# Validación de la entrega

Fecha: 2026-09-18. Esta entrega es v1.0.2. Se ha validado localmente su código,
pero **no se ha desplegado v1.0.2 en SmartOS**, no se ha activado `armed` y no se
han enviado señales reales. El paquete conserva dos barreras de fábrica:
`ACTION_MODE dry-run` y `--force-dry-run` en SMF; además no contiene reglas de
zona `allow` y la instancia SMF está inicialmente deshabilitada.

Nota cronológica: `CHECKPOINT.md`, añadido posteriormente al mismo paquete
v1.0.2, conserva la evidencia de la validación nativa y operativa que se realizó
después de este corte local. Esa evidencia posterior completa los pendientes de
compilación, probe y logging continuo descritos aquí, pero mantiene `armed` como
NO GO y no modifica el código de la entrega.

## Evidencia real recibida de SmartOS

En el nodo real `o6hsmartos10`, v1.0.1 validó correctamente mediante
`--probe --force-dry-run` las fuentes nuevas:

- `swap_committed_pages` coincidió con `allocated + reserved` de `swap -s`.
- `swap_physical_used_bytes` coincidió con la suma de `(blocks-free)*512` de
`swap -l`: `67024888 - 58931824 = 8093064` bloques = 4.143.648.768 bytes.
- `anon_pageout_pages` coincidió exactamente con la suma de
`cpu:*:vm:anonpgout`: 6.553.284 páginas. No se sumaron también los duplicados
`cpu_stat:*:cpu_stat*:anonpgout`.

La ejecución persistente de v1.0.1 bajo SMF permaneció estable, muestreó cada
segundo y clasificó correctamente `state=NORMAL`, `action_pressure=0`, `floor=0`
con `nscan=0` y entre unas 589.193 y 602.818 páginas libres, muy por encima de
`lotsfree=130875`. Sus deltas de 5 y 15 segundos fueron coherentes; por ejemplo,
una caída rápida de libre y aumento de compromiso virtual no se confundieron
con presión real mientras el scanner permaneció inactivo.

Esa prueba descubrió el defecto corregido aquí: el daemon continuo v1.0.1 aún
mostraba la nomenclatura antigua (`swap_ok`, `swap_alloc_pages`,
`swap_reserved_pages`, `swap_available_pages`, `metric=swap_used_pages`) y no
exponía `swap_physical_used_bytes`, `anon_pageout_pages` ni sus deltas. Por tanto,
la evidencia real valida las lecturas nativas y la estabilidad básica, pero no
el nuevo contrato de logging v1.0.2. Antes de armar nada debe repetirse la prueba
continua en SmartOS.

## Comprobaciones de v1.0.2

- `make test` ejecuta pruebas de configuración, política y la ruta real del
daemon con backend simulado. La prueba de logging genera 61 muestras a cadencia
de 1 s y exige los tres pares de valor/flag, los deltas exactos 5/15/60 s de
`swap_committed_pages`, `swap_physical_used_bytes` y `anon_pageout_pages`, y
que no aparezca `swap_used_pages`.
- La política cubre los seis estados, histéresis y huecos. Verifica que el
crecimiento de swap físico por sí solo, el pageout por sí solo, o ambas
señales con `freemem >= lotsfree` no corroboran presión. Juntas sólo pueden
corroborarla bajo `lotsfree`; no autorizan acciones por sí mismas.
- La ruta simulada confirma que dry-run registra `WOULD_ACTION` y realiza cero
solicitudes de señal real. También cubre protecciones, revalidación, warmup,
caché, fallos de muestra/log/identidad, límites y parada.
- `make test-sanitize` ejecuta ambas suites con AddressSanitizer y
UndefinedBehaviorSanitizer. En entornos donde LeakSanitizer no pueda operar
por restricciones de ptrace se usa `ASAN_OPTIONS=detect_leaks=0` y se deja
constancia explícita; esto no equivale a una comprobación de fugas.
- `make portability` aplica C99 estricto, warnings como errores, `-fanalyzer` a
cada unidad, sintaxis shell y XML bien formado. Los `tests/api-sketch` sólo
comprueban consistencia C en Linux; no sustituyen cabeceras, ABI, enlace ni
ejecución nativos de illumos.
- Se inspecciona que producción no invoque `system`, `popen`, `exec*` ni
`kill(2)`. Las pruebas no enlazan el backend nativo y no pueden enviar señales.

Los resultados exactos de esta ejecución se guardan en `logs/policy-tests.log`,
`logs/runtime-tests.log`, `logs/sanitizers.log` y `logs/portability.log`.

## Validación obligatoria pendiente en SmartOS

1. Compilar con cabeceras nativas (`gmake CC=gcc`), ejecutar `gmake test CC=gcc`
y comprobar ELF de 64 bits y dependencias con `file`/`ldd`.
2. Ejecutar `svccfg validate` nativo. Instalar/importar sólo siguiendo el
procedimiento del README y mantener `--force-dry-run`; no añadir UUID `allow`.
3. Repetir `--probe --force-dry-run` y contrastar las tres fuentes con `swap -s`,
`swap -l` y `kstat cpu::vm:anonpgout`.
4. Bajo SMF, verificar en cada `event=sample`:
`swap_committed_ok/swap_committed_pages`,
`swap_physical_used_ok/swap_physical_used_bytes` y
`anon_pageout_ok/anon_pageout_pages`. Confirmar líneas `event=delta` con los
tres nombres exactos y ventanas 5/15/60; buscar y exigir cero apariciones de
`swap_used_pages`.
5. Confirmar cadencia, recorrido `/proc`, identidades de zona, lock, rotación y
recuperación del servicio durante carga natural. No generar presión artificial
en producción.
6. La ruta PCKILL sólo debe ensayarse posteriormente en un laboratorio nativo
desechable, con procesos y zonas expresamente creados para esa prueba. Esta
entrega no autoriza ni realiza esa fase.

## Límites conocidos

La atribución por RSS es heurística y sobrecuenta memoria compartida. Una señal
puede no liberar memoria de inmediato y un supervisor puede recrear procesos.
El presupuesto `/proc` es cooperativo; libkstat, libc, ZFS y el logging pueden
bloquear. No hay `fsync` por muestra, por lo que un panic puede perder las últimas
líneas. Las protecciones y presupuestos prevalecen incluso al floor. Este guard
no constituye una garantía de evitar `pageout_deadman` ni un certificado para
activar acciones en producción.

+ 38
- 0
etc/o6h-memory-guard.conf 查看文件

@@ -0,0 +1,38 @@
# o6h-memory-guard 1.0.2 -- whitespace-separated, no shell evaluation.
# Restart required for configuration changes. Duplicate scalar keys are errors.
ACTION_MODE dry-run

PRE_SECONDS 3
PRESSURE_SECONDS 10
CRITICAL_SECONDS 2
RECOVERY_SECONDS 15
TERM_GRACE_SECONDS 5
NORMAL_SCAN_SECONDS 5
SCAN_BUDGET_MS 200
COOLDOWN_SECONDS 10
MAX_VICTIMS_EPISODE 4
MAX_VICTIMS_MINUTE 4
MIN_RSS_KIB 65536
LOG_MAX_MIB 10
LOG_KEEP 5

# Explicit additional protected processes: exact pr_fname (max 15 characters).
# The global zone, zone init, essential names, missing/unknown UUIDs and all
# zones without an allow rule are ALWAYS excluded. See README.
protect_process sshd
protect_process postgres
protect_process mysqld
protect_process mariadbd
protect_process redis-server
protect_process zabbix_agentd
# protect_pid 12345

# Inventory supplied by the operator, from vmadm list (outside the daemon).
# Add REAL UUIDs and aliases. No active allow rules are shipped.
# Grammar: zone <lowercase-UUID> <safe-alias-without-spaces> <allow|protect|observe>
# allow: eligible in simulations, and in armed only when both arming gates open.
# protect / observe / absent: never eligible for signals, including floor.
# Examples (replace the entire UUID; these are comments, not authorizations):
# zone YOUR-REAL-WEB-ZONE-UUID web5 allow
# zone YOUR-REAL-DB-ZONE-UUID database protect
# zone YOUR-REAL-MONITOR-ZONE-UUID monitoring protect

+ 39
- 0
install.sh 查看文件

@@ -0,0 +1,39 @@
#!/bin/sh
# First install only. Does not import, enable or signal any service.
set -eu
PATH=/usr/bin:/usr/sbin:/sbin
export PATH
umask 077
cd "$(dirname "$0")"
if [ "$(uname -s)" != SunOS ] || [ "$(id -u)" != 0 ] || [ "$(zonename)" != global ]; then
echo 'Run as root in the SmartOS global zone.' >&2
exit 1
fi
if [ ! -f ./o6h-memory-guard ] || [ ! -x ./o6h-memory-guard ]; then
echo 'Build the native 64-bit binary first (gmake).' >&2
exit 1
fi
for target in /opt/custom/bin/o6h-memory-guard /opt/custom/etc/o6h-memory-guard.conf /opt/custom/smf/o6h-memory-guard.xml; do
if [ -e "$target" ] || [ -L "$target" ]; then
echo "Refusing to overwrite $target; see README upgrade/rollback." >&2
exit 1
fi
done
# Do not change existing /opt/custom permissions or follow destination symlinks.
for dir in /opt/custom /opt/custom/bin /opt/custom/etc /opt/custom/smf /opt/custom/var /opt/custom/var/log /opt/custom/var/log/o6h-memory-guard /opt/custom/var/run; do
if [ ! -d "$dir" ]; then mkdir "$dir"; chmod 0755 "$dir"; fi
done
./o6h-memory-guard --check-install-dirs
cp ./o6h-memory-guard /opt/custom/bin/o6h-memory-guard
chmod 0755 /opt/custom/bin/o6h-memory-guard
cp ./etc/o6h-memory-guard.conf /opt/custom/etc/o6h-memory-guard.conf
chmod 0600 /opt/custom/etc/o6h-memory-guard.conf
# The binary validates root ownership and all resolved parent permissions.
/opt/custom/bin/o6h-memory-guard --check-config --config /opt/custom/etc/o6h-memory-guard.conf
cp ./smf/o6h-memory-guard.xml /opt/custom/smf/o6h-memory-guard.xml
chmod 0644 /opt/custom/smf/o6h-memory-guard.xml
svccfg validate /opt/custom/smf/o6h-memory-guard.xml
echo 'Installed; service has NOT been imported or enabled. Next:'
echo '/opt/custom/bin/o6h-memory-guard --probe --force-dry-run'
echo 'svccfg import /opt/custom/smf/o6h-memory-guard.xml'
echo 'svcadm enable -s svc:/site/o6h-memory-guard:default'

+ 10
- 0
logs/example.log 查看文件

@@ -0,0 +1,10 @@
# SYNTHETIC FORMAT EXAMPLE -- NOT HOST TELEMETRY. Fields abridged for readability.
2026-09-18T15:00:00Z mono=100.000 event=start version=1.0.2 pid=999 mode=dry-run page_bytes=4096 armed_warmup=60
2026-09-18T15:00:01Z mono=101.000 event=sample state=NORMAL action_pressure=0 floor=0 free_pages=425950 lotsfree=130875 desfree=65437 minfree=49077 nscan=0 pp_kernel=2976170 pageslocked=3002379 arc_ok=1 arc_bytes=1073741824 swap_committed_ok=1 swap_committed_pages=7480128 swap_physical_used_ok=1 swap_physical_used_bytes=4143648768 anon_pageout_ok=1 anon_pageout_pages=1000 swap_alloc_pages=7000000 swap_reserved_pages=480128 swap_available_pages=6135486
2026-09-18T15:00:01Z mono=101.000 event=delta metric=swap_committed_pages d5=NA d15=NA d60=NA
2026-09-18T15:00:01Z mono=101.000 event=delta metric=swap_physical_used_bytes d5=NA d15=NA d60=NA
2026-09-18T15:00:01Z mono=101.000 event=delta metric=anon_pageout_pages d5=NA d15=NA d60=NA
2026-09-18T15:01:00Z mono=160.000 event=transition from=PRESSURE to=CRITICAL
2026-09-18T15:01:00Z mono=160.020 event=candidate UUID=11111111-1111-4111-8111-111111111111 alias=fixture-web zone_id=9 zone_generation=20 pid=123 start=1000.000000123 comm=php-fpm rss_kib=1048576 d5_kib=+100000@5.00s d15_kib=+200000@15.00s d60_kib=+500000@60.00s eligible=1
2026-09-18T15:01:00Z mono=160.030 event=action WOULD_ACTION=SIGTERM reason=critical UUID=11111111-1111-4111-8111-111111111111 alias=fixture-web zone_generation=20 pid=123 start=1000.000000123 comm=php-fpm rss_kib=1048576 free_pages=60000
2026-09-18T15:01:05Z mono=165.030 event=action WOULD_ACTION=SIGKILL reason=memory_not_recovered UUID=11111111-1111-4111-8111-111111111111 alias=fixture-web zone_generation=20 pid=123 start=1000.000000123 comm=php-fpm rss_kib=1048576 free_pages=57000

+ 9
- 0
logs/policy-tests.log 查看文件

@@ -0,0 +1,9 @@
PASS strict configuration, safe defaults, explicit UUID authorization
PASS mandatory protections, PID reuse and zone incarnation identity
PASS timestamped 5/15/60s windows, missing data, signed growth, overflow
PASS all six states, corroboration, hysteresis, recovery and sampling gaps
SIMULATED t=100 state=CRITICAL UUID=11111111-1111-4111-8111-111111111111 pid=123 WOULD_ACTION=SIGTERM
SIMULATED t=105 state=CRITICAL pid=123 WOULD_ACTION=SIGKILL reason=memory_not_recovered
SIMULATED state=EMERGENCY floor=1 WOULD_ACTION=SIGKILL reason=floor
PASS TERM/KILL sequencing, emergency grace, floor, cooldown, budgets and no-action protections
PASS 113 checks; no native backend linked, no signals sent

+ 7
- 0
logs/portability.log 查看文件

@@ -0,0 +1,7 @@
make portability
PASS C99 syntax with -Wall -Wextra -Werror -Wformat=2 -pedantic
PASS GCC -fanalyzer for every production translation unit
PASS sh -n install.sh
PASS xmllint --nonet --noout smf/o6h-memory-guard.xml
NOTE tests/api-sketch are local declarations, not native illumos headers or ABI validation.
NOTE clang, clang-tidy, cppcheck, scan-build and shellcheck were not installed.

+ 5
- 0
logs/runtime-tests.log 查看文件

@@ -0,0 +1,5 @@
PASS actual daemon dry-run path records WOULD_ACTION=SIGTERM/SIGKILL; zero real requests
PASS continuous sample contract and 5/15/60 deltas for commitment, physical swap and pageout
PASS armed-path mock requests, warmup, stale cache, logging/sampling failures, stop and fresh floor
PASS actual daemon ranking by zone/PID RSS and growth; protected zones and global zone excluded
PASS 50 runtime checks; all platform calls mocked, no signals sent

+ 5
- 0
logs/sanitizers.log 查看文件

@@ -0,0 +1,5 @@
ASAN_OPTIONS=detect_leaks=0 make test-sanitize
GCC 11.4.0; AddressSanitizer + UndefinedBehaviorSanitizer
PASS test-sanitize-guard: 113 checks; no native backend linked, no signals sent
PASS test-sanitize-runtime: 50 checks; all platform calls mocked, no signals sent
LeakSanitizer was intentionally disabled because the supervised environment can reject ptrace; no leak-check result is claimed.

+ 25
- 0
smf/o6h-memory-guard.xml 查看文件

@@ -0,0 +1,25 @@
<?xml version="1.0"?>
<!DOCTYPE service_bundle SYSTEM "/usr/share/lib/xml/dtd/service_bundle.dtd.1">
<service_bundle type="manifest" name="o6h-memory-guard">
<service name="site/o6h-memory-guard" type="service" version="1">
<create_default_instance enabled="false"/>
<single_instance/>
<dependency name="local_filesystems" grouping="require_all" restart_on="none" type="service">
<service_fmri value="svc:/system/filesystem/local:default"/>
</dependency>
<method_context>
<method_credential user="root" group="root"/>
</method_context>
<exec_method type="method" name="start"
exec="/opt/custom/bin/o6h-memory-guard --daemon --force-dry-run --config /opt/custom/etc/o6h-memory-guard.conf"
timeout_seconds="30"/>
<exec_method type="method" name="stop" exec=":kill" timeout_seconds="30"/>
<property_group name="startd" type="framework">
<propval name="duration" type="astring" value="contract"/>
</property_group>
<stability value="Unstable"/>
<template>
<common_name><loctext xml:lang="C">o6h memory pressure guard (initially forced dry-run)</loctext></common_name>
</template>
</service>
</service_bundle>

+ 154
- 0
src/config.c 查看文件

@@ -0,0 +1,154 @@
#include "guard.h"
#include <string.h>
#include <stdlib.h>
#include <ctype.h>
#include <errno.h>

void defaults(config_t *c)
{
memset(c, 0, sizeof (*c));
c->pre_s = 3; c->pressure_s = 10; c->critical_s = 2;
c->recovery_s = 15; c->term_s = 5; c->normal_scan_s = 5;
c->scan_budget_ms = 200; c->cooldown_s = 10;
c->max_victims = 4; c->max_per_minute = 4;
c->min_rss_kib = 65536; c->log_mib = 10; c->log_keep = 5;
}

int uuid_valid(const char *s)
{
size_t i;
if (strlen(s) != 36) return 0;
for (i = 0; i < 36; i++) {
if (i == 8 || i == 13 || i == 18 || i == 23) {
if (s[i] != '-') return 0;
} else if (!isdigit((unsigned char)s[i]) && !(s[i] >= 'a' && s[i] <= 'f'))
return 0;
}
return strcmp(s, "00000000-0000-0000-0000-000000000000") != 0;
}

const zone_rule_t *zone_rule(const config_t *c, const char *uuid)
{
size_t i;
for (i = 0; i < c->nzones; i++)
if (!strcmp(c->zones[i].uuid, uuid)) return &c->zones[i];
return NULL;
}

int protected_process(const config_t *c, const process_t *p)
{
static const char *const builtin[] = {"init", "systemd", "svc.startd",
"svc.configd", "sshd", "zoneadmd", "vminfod", "vmadmd", "qemu",
"qemu-system-x86", "bhyve", "o6h-memory-guar", "o6h-memory-guard"};
const zone_rule_t *z = zone_rule(c, p->uuid);
size_t i;
if (p->pid <= 1 || p->ppid == 0 || p->zid <= 0 || p->initpid <= 0 ||
p->pid == p->initpid || p->nlwp == 0 || !p->zone_generation ||
!uuid_valid(p->uuid) || !z || z->policy != ALLOW ||
p->rss_kib < c->min_rss_kib) return 1;
for (i = 0; i < sizeof (builtin) / sizeof (builtin[0]); i++)
if (!strcmp(p->name, builtin[i])) return 1;
for (i = 0; i < c->nnames; i++)
if (!strcmp(p->name, c->protected_names[i])) return 1;
for (i = 0; i < c->npids; i++)
if (p->pid == c->protected_pids[i]) return 1;
return 0;
}

static int token(const char *s, size_t max)
{
size_t i, n = strlen(s);
if (!n || n >= max) return 0;
for (i = 0; i < n; i++)
if (!isalnum((unsigned char)s[i]) && !strchr("._-", s[i])) return 0;
return 1;
}

static int number(const char *s, unsigned long lo, unsigned long hi, unsigned *v)
{
char *end;
unsigned long n;
if (!*s || *s == '-' || *s == '+') return -1;
errno = 0; n = strtoul(s, &end, 10);
if (errno || *end || n < lo || n > hi) return -1;
*v = (unsigned)n; return 0;
}

int config_read(FILE *f, config_t *c, char *error, size_t cap)
{
char line[512], *key, *value, *extra, *ctx;
unsigned lineno = 0, n, seen = 0;
defaults(c);
while (fgets(line, sizeof (line), f)) {
char *comment;
lineno++;
if (!strchr(line, '\n') && !feof(f)) goto invalid;
comment = strchr(line, '#'); if (comment) *comment = 0;
ctx = NULL; key = strtok_r(line, " \t\r\n", &ctx);
if (!key) continue;
value = strtok_r(NULL, " \t\r\n", &ctx);
if (!value) goto invalid;
if (!strcmp(key, "zone")) {
zone_rule_t *z;
char *alias = strtok_r(NULL, " \t\r\n", &ctx);
char *policy = strtok_r(NULL, " \t\r\n", &ctx);
if (!alias || !policy || strtok_r(NULL, " \t\r\n", &ctx) ||
!uuid_valid(value) || !token(alias, 64) ||
c->nzones == MAX_ZONES || zone_rule(c, value)) goto invalid;
z = &c->zones[c->nzones++];
strcpy(z->uuid, value); strcpy(z->alias, alias);
if (!strcmp(policy, "allow")) z->policy = ALLOW;
else if (!strcmp(policy, "protect")) z->policy = PROTECT;
else if (!strcmp(policy, "observe")) z->policy = OBSERVE;
else goto invalid;
continue;
}
extra = strtok_r(NULL, " \t\r\n", &ctx);
if (extra) goto invalid;
if (!strcmp(key, "protect_process")) {
if (!token(value, 16) || c->nnames == MAX_RULES) goto invalid;
strcpy(c->protected_names[c->nnames++], value); continue;
}
if (!strcmp(key, "protect_pid")) {
if (number(value, 1, 2147483647UL, &n) || c->npids == MAX_RULES)
goto invalid;
c->protected_pids[c->npids++] = (int)n; continue;
}
if (!strcmp(key, "ACTION_MODE")) {
if (seen & 1u) goto invalid;
seen |= 1u;
if (!strcmp(value, "armed")) c->armed = 1;
else if (strcmp(value, "dry-run")) goto invalid;
continue;
}
#define OPT(name, field, low, high, bit) \
if (!strcmp(key, name)) { \
if ((seen & (1u << bit)) || number(value, low, high, &n)) goto invalid; \
seen |= 1u << bit; c->field = n; continue; }
OPT("PRE_SECONDS", pre_s, 1, 60, 1)
OPT("PRESSURE_SECONDS", pressure_s, 2, 120, 2)
OPT("CRITICAL_SECONDS", critical_s, 1, 10, 3)
OPT("RECOVERY_SECONDS", recovery_s, 5, 300, 4)
OPT("TERM_GRACE_SECONDS", term_s, 1, 30, 5)
OPT("NORMAL_SCAN_SECONDS", normal_scan_s, 1, 5, 6)
OPT("SCAN_BUDGET_MS", scan_budget_ms, 10, 500, 7)
OPT("COOLDOWN_SECONDS", cooldown_s, 1, 120, 8)
OPT("MAX_VICTIMS_EPISODE", max_victims, 1, MAX_VICTIMS, 9)
OPT("MAX_VICTIMS_MINUTE", max_per_minute, 1, 32, 10)
OPT("MIN_RSS_KIB", min_rss_kib, 1024, 1073741824UL, 11)
OPT("LOG_MAX_MIB", log_mib, 1, 100, 12)
OPT("LOG_KEEP", log_keep, 1, 20, 13)
#undef OPT
goto invalid;
}
if (ferror(f) || c->pressure_s < c->pre_s) goto invalid;
if (c->armed) {
size_t i; int allow = 0;
for (i = 0; i < c->nzones; i++) allow |= c->zones[i].policy == ALLOW;
if (!allow) { snprintf(error, cap, "armed requires at least one allow UUID"); return -1; }
}
return 0;
invalid:
snprintf(error, cap, "invalid/duplicate/unknown configuration at line %u", lineno);
return -1;
}

+ 180
- 0
src/core.c 查看文件

@@ -0,0 +1,180 @@
#include "guard.h"
#include <string.h>
#include <limits.h>

void history_add(history_t *h, double at, uint64_t value)
{
h->p[h->next].at = at; h->p[h->next].value = value;
h->next = (h->next + 1) % HIST;
if (h->count < HIST) h->count++;
}

delta_t history_delta(const history_t *h, double now, unsigned seconds)
{
delta_t d = {0, 0, 0};
unsigned i, latest;
double best = 1.25;
if (h->count < 2) return d;
latest = (h->next + HIST - 1) % HIST;
if (now - h->p[latest].at > 1.25) return d;
for (i = 0; i < h->count; i++) {
double age = h->p[latest].at - h->p[i].at;
double error = age - seconds;
if (error < 0) error = -error;
if (age > 0 && error < best && h->p[i].value <= INT64_MAX &&
h->p[latest].value <= INT64_MAX) {
best = error; d.valid = 1; d.age = age;
d.value = (int64_t)h->p[latest].value - (int64_t)h->p[i].value;
}
}
return d;
}

uint64_t rank_score(uint64_t rss, delta_t growth)
{
uint64_t add = growth.valid && growth.value > 0 ? (uint64_t)growth.value : 0;
if (add > (UINT64_MAX - rss) / 2) return UINT64_MAX;
return rss + 2 * add;
}

int swap_physical_bytes_from_blocks(uint64_t total_blocks,
uint64_t free_blocks, uint64_t *bytes)
{
uint64_t used;
if (!bytes || free_blocks > total_blocks) return -1;
used = total_blocks - free_blocks;
if (used > UINT64_MAX / 512) return -1;
*bytes = used * 512;
return 0;
}

const char *state_name(enum state s)
{
static const char *names[] = {"NORMAL", "PRE-PRESSURE", "PRESSURE",
"CRITICAL", "EMERGENCY", "RECOVERY"};
return names[s];
}

void engine_init(engine_t *e)
{
memset(e, 0, sizeof (*e));
engine_gap(e);
}

void engine_gap(engine_t *e)
{
e->low_since = e->critical_since = e->recovery_since = -1;
e->have_last = 0; e->danger = 0; e->floor = 0;
}

void engine_step(engine_t *e, const config_t *c, const sample_t *s,
delta_t free_d5, delta_t physical_swap_d5, delta_t pageout_d5)
{
int stress;
enum state desired = NORMAL;
if (e->have_last && (s->at - e->last_at > 2.5 || s->at <= e->last_at))
engine_gap(e);
/* Physical swap growth is corroboration only, never an independent trigger. */
stress = s->scan > 0 || (free_d5.valid && free_d5.value < 0) ||
(e->have_last && s->free < e->last_free) ||
(physical_swap_d5.valid && physical_swap_d5.value > 0 &&
pageout_d5.valid && pageout_d5.value > 0 && s->free < s->lots);
e->floor = s->free < s->min / 2;
if (s->free < s->lots) {
if (e->low_since < 0) e->low_since = s->at;
} else e->low_since = -1;
if (s->free < s->des && stress) {
if (e->critical_since < 0) e->critical_since = s->at;
} else e->critical_since = -1;
if (e->low_since >= 0 && s->at - e->low_since >= c->pre_s)
desired = PRE_PRESSURE;
if (e->low_since >= 0 && s->at - e->low_since >= c->pressure_s && stress)
desired = PRESSURE;
if (e->critical_since >= 0 && s->at - e->critical_since >= c->critical_s)
desired = CRITICAL;
if (s->free < s->min) desired = EMERGENCY;
e->danger = desired == CRITICAL || desired == EMERGENCY;
/* State hysteresis never authorizes an action on its own. */
if (desired >= PRE_PRESSURE && desired <= EMERGENCY) {
e->recovery_since = -1;
if (e->state == RECOVERY || desired > e->state) e->state = desired;
}
if (s->free >= s->lots && e->state != NORMAL) {
e->state = RECOVERY;
/* 10% exit margin, and no active scanner, for a full recovery. */
if (s->free - s->lots >= s->lots / 10 && s->scan == 0) {
if (e->recovery_since < 0) e->recovery_since = s->at;
if (s->at - e->recovery_since >= c->recovery_s) e->state = NORMAL;
} else e->recovery_since = -1;
} else if (s->free < s->lots) {
e->recovery_since = -1;
if (e->state == RECOVERY) e->state = PRE_PRESSURE;
}
e->last_at = s->at; e->last_free = s->free; e->have_last = 1;
}

int same_process(const process_t *a, const process_t *b)
{
return a->pid == b->pid && a->start_sec == b->start_sec &&
a->start_nsec == b->start_nsec && a->zid == b->zid &&
a->zone_generation == b->zone_generation && !strcmp(a->uuid, b->uuid);
}

int was_victim(const controller_t *c, const process_t *p)
{
unsigned i;
for (i = 0; i < c->nvictims; i++)
if (same_process(&c->victims[i], p)) return 1;
return 0;
}

plan_t action_plan(controller_t *a, const config_t *c, const engine_t *e,
const sample_t *s, const process_t *candidate)
{
plan_t p;
unsigned i, n = 0;
memset(&p, 0, sizeof (p)); p.reason = "no_pressure";
for (i = 0; i < a->nrate; i++)
if (s->at - a->rate[i] < 60) a->rate[n++] = a->rate[i];
a->nrate = n;
if (e->state == NORMAL) a->nvictims = 0;
if (s->free >= s->lots) a->has_pending = 0;
if (!e->danger) return p;
if (a->has_pending) {
p.reason = "term_grace";
if (e->floor || (s->free < s->min && s->at - a->term_at >= 1) ||
s->at - a->term_at >= c->term_s) {
p.target = a->pending; p.action = KILL;
p.reason = e->floor ? "floor" : "memory_not_recovered";
}
return p;
}
if (a->nvictims >= c->max_victims || a->nrate >= c->max_per_minute) {
p.reason = "victim_budget_exhausted"; return p;
}
/* Emergency bypasses the normal cooldown, not protection or budgets. */
if (s->free >= s->min && s->at < a->next_at) {
p.reason = "cooldown"; return p;
}
if (!candidate || protected_process(c, candidate) || was_victim(a, candidate)) {
p.reason = "no_authorized_candidate"; return p;
}
p.target = *candidate;
p.action = e->floor ? KILL : TERM;
p.reason = e->floor ? "floor" : s->free < s->min ? "minfree" : "critical";
return p;
}

void action_commit(controller_t *a, const config_t *c, const sample_t *s,
const plan_t *p)
{
if (p->action == NONE) return;
if (!was_victim(a, &p->target)) {
if (a->nvictims < MAX_VICTIMS) a->victims[a->nvictims++] = p->target;
if (a->nrate < 64) a->rate[a->nrate++] = s->at;
}
a->next_at = s->at + c->cooldown_s;
if (p->action == TERM) {
a->pending = p->target; a->term_at = s->at; a->has_pending = 1;
} else a->has_pending = 0;
}

+ 93
- 0
src/guard.h 查看文件

@@ -0,0 +1,93 @@
#ifndef O6H_GUARD_H
#define O6H_GUARD_H
#include <stdint.h>
#include <stddef.h>
#include <stdio.h>

#define HIST 64
#define MAX_ZONES 256
#define MAX_PROCS 4096
#define MAX_RULES 128
#define MAX_VICTIMS 32
#define MAX_SWAP_DEVICES 64
#define CONF_PATH "/opt/custom/etc/o6h-memory-guard.conf"
#define LOG_DIR "/opt/custom/var/log/o6h-memory-guard"
#define RUN_DIR "/opt/custom/var/run"
#define VERSION "1.0.2"

enum state { NORMAL, PRE_PRESSURE, PRESSURE, CRITICAL, EMERGENCY, RECOVERY };
enum policy { OBSERVE, ALLOW, PROTECT };
enum action { NONE, TERM, KILL };
typedef struct { double at; uint64_t value; } point_t;
typedef struct { point_t p[HIST]; unsigned next, count; } history_t;
typedef struct { int valid; double age; int64_t value; } delta_t;
typedef struct {
double at;
uint64_t free, lots, des, min, scan, kernel, locked;
uint64_t arc, swap_alloc, swap_reserved, swap_available;
uint64_t swap_physical_used_bytes, anon_pageout_pages;
int arc_ok, swap_virtual_ok, swap_physical_ok, pageout_ok;
} sample_t;
typedef struct { char uuid[37], alias[64]; enum policy policy; } zone_rule_t;
typedef struct {
int armed;
unsigned pre_s, pressure_s, critical_s, recovery_s, term_s;
unsigned normal_scan_s, scan_budget_ms, cooldown_s, max_victims;
unsigned max_per_minute, log_mib, log_keep;
uint64_t min_rss_kib;
zone_rule_t zones[MAX_ZONES]; size_t nzones;
char protected_names[MAX_RULES][32]; size_t nnames;
int protected_pids[MAX_RULES]; size_t npids;
} config_t;
typedef struct {
int pid, ppid, zid, initpid, nlwp;
uint32_t uid, euid;
int64_t start_sec, start_nsec;
uint64_t zone_generation, rss_kib;
char uuid[64], name[32];
} process_t;
typedef struct {
enum state state;
double low_since, critical_since, recovery_since, last_at;
uint64_t last_free;
int have_last, danger, floor;
} engine_t;
typedef struct {
process_t pending;
int has_pending;
double term_at, next_at;
process_t victims[MAX_VICTIMS]; unsigned nvictims;
double rate[64]; unsigned nrate;
} controller_t;
typedef struct { enum action action; process_t target; const char *reason; } plan_t;

void defaults(config_t *);
int config_read(FILE *, config_t *, char *, size_t);
int uuid_valid(const char *);
const zone_rule_t *zone_rule(const config_t *, const char *);
int protected_process(const config_t *, const process_t *);
int same_process(const process_t *, const process_t *);
void history_add(history_t *, double, uint64_t);
delta_t history_delta(const history_t *, double, unsigned);
uint64_t rank_score(uint64_t, delta_t);
int swap_physical_bytes_from_blocks(uint64_t, uint64_t, uint64_t *);
const char *state_name(enum state);
void engine_init(engine_t *);
void engine_gap(engine_t *);
void engine_step(engine_t *, const config_t *, const sample_t *, delta_t,
delta_t, delta_t);
int was_victim(const controller_t *, const process_t *);
plan_t action_plan(controller_t *, const config_t *, const engine_t *,
const sample_t *, const process_t *);
void action_commit(controller_t *, const config_t *, const sample_t *, const plan_t *);

/* Native backend. No backend is linked into the portable policy tests. */
int platform_init(void);
void platform_fini(void);
int platform_sample(sample_t *);
int platform_walk(int (*)(const process_t *, void *), void *, unsigned);
int platform_signal(const config_t *, const process_t *, enum action, int, uint64_t);
double monotime(void);
int secure_file(const char *, int, unsigned);
int secure_directory(const char *);
#endif

+ 277
- 0
src/illumos.c 查看文件

@@ -0,0 +1,277 @@
/* Native illumos/SmartOS backend; deliberately no Linux fallback. */
#include "guard.h"
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/swap.h>
#include <sys/param.h>
#include <vm/anon.h>
#include <procfs.h>
#include <zone.h>
#include <kstat.h>
#include <unistd.h>
#include <fcntl.h>
#include <dirent.h>
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <ctype.h>

static kstat_ctl_t *kc;
static DIR *procdir;
typedef struct { int zid, initpid; uint64_t generation; char uuid[64]; } zone_cache_t;
static zone_cache_t zones[MAX_ZONES];
static size_t nzones;
static int named(kstat_t *, const char *, uint64_t *);

typedef struct {
int swt_n;
swapent_t swt_ent[MAX_SWAP_DEVICES];
} fixed_swaptable_t;

static int physical_swap(uint64_t *bytes)
{
fixed_swaptable_t table;
char paths[MAX_SWAP_DEVICES][MAXPATHLEN];
uint64_t total_blocks = 0, free_blocks = 0;
long page_bytes = sysconf(_SC_PAGESIZE);
uint64_t blocks_per_page;
int count, listed, i;
if (page_bytes <= 0 || page_bytes % 512 != 0) { errno = EDOM; return -1; }
count = swapctl(SC_GETNSWP, NULL);
if (count < 0 || count > MAX_SWAP_DEVICES) { errno = count < 0 ? errno : EOVERFLOW; return -1; }
if (count == 0) { *bytes = 0; return 0; }
memset(&table, 0, sizeof (table));
table.swt_n = count;
for (i = 0; i < count; i++) table.swt_ent[i].ste_path = paths[i];
listed = swapctl(SC_LIST, (struct swaptable *)&table);
if (listed < 0 || listed > count) { if (listed > count) errno = EAGAIN; return -1; }
blocks_per_page = (uint64_t)page_bytes / 512;
for (i = 0; i < listed; i++) {
uint64_t pages = table.swt_ent[i].ste_pages;
uint64_t free_pages = table.swt_ent[i].ste_free;
if (free_pages > pages || pages > UINT64_MAX / blocks_per_page ||
free_pages > UINT64_MAX / blocks_per_page ||
total_blocks > UINT64_MAX - pages * blocks_per_page ||
free_blocks > UINT64_MAX - free_pages * blocks_per_page) {
errno = EOVERFLOW; return -1;
}
total_blocks += pages * blocks_per_page;
free_blocks += free_pages * blocks_per_page;
}
if (swap_physical_bytes_from_blocks(total_blocks, free_blocks, bytes)) {
errno = EOVERFLOW; return -1;
}
return 0;
}

static int anon_pageouts(uint64_t *pages)
{
kstat_t *k;
uint64_t total = 0, value;
int found = 0;
for (k = kc->kc_chain; k; k = k->ks_next) {
if (strcmp(k->ks_module, "cpu") || strcmp(k->ks_name, "vm") ||
k->ks_type != KSTAT_TYPE_NAMED) continue;
if (kstat_read(kc, k, NULL) == -1 || named(k, "anonpgout", &value))
return -1;
if (total > UINT64_MAX - value) { errno = EOVERFLOW; return -1; }
total += value; found = 1;
}
if (!found) { errno = ENOENT; return -1; }
*pages = total;
return 0;
}

int platform_init(void)
{
if (getzoneid() != GLOBAL_ZONEID || geteuid() != 0) { errno = EPERM; return -1; }
kc = kstat_open();
if (!kc) return -1;
procdir = opendir("/proc");
return procdir ? 0 : -1;
}

void platform_fini(void)
{
if (procdir) closedir(procdir);
if (kc) kstat_close(kc);
}

static int named(kstat_t *k, const char *name, uint64_t *out)
{
kstat_named_t *v = kstat_data_lookup(k, (char *)name);
if (!v) return -1;
switch (v->data_type) {
case KSTAT_DATA_UINT64: *out = v->value.ui64; break;
case KSTAT_DATA_INT64:
if (v->value.i64 < 0) return -1;
*out = (uint64_t)v->value.i64; break;
case KSTAT_DATA_UINT32: *out = v->value.ui32; break;
case KSTAT_DATA_INT32:
if (v->value.i32 < 0) return -1;
*out = (uint64_t)v->value.i32; break;
default: return -1;
}
return 0;
}

int platform_sample(sample_t *s)
{
kstat_t *k;
struct anoninfo ai;
memset(s, 0, sizeof (*s)); s->at = monotime();
if (kstat_chain_update(kc) == -1) return -1;
k = kstat_lookup(kc, "unix", 0, "system_pages");
if (!k || k->ks_type != KSTAT_TYPE_NAMED || kstat_read(kc, k, NULL) == -1)
return -1;
if (named(k, "freemem", &s->free) || named(k, "lotsfree", &s->lots) ||
named(k, "desfree", &s->des) || named(k, "minfree", &s->min) ||
named(k, "nscan", &s->scan) || named(k, "pp_kernel", &s->kernel) ||
named(k, "pageslocked", &s->locked)) return -1;
if (!s->min || s->min >= s->des || s->des >= s->lots) { errno = EDOM; return -1; }
k = kstat_lookup(kc, "zfs", 0, "arcstats");
if (k && k->ks_type == KSTAT_TYPE_NAMED && kstat_read(kc, k, NULL) != -1 &&
named(k, "size", &s->arc) == 0) s->arc_ok = 1;
if (swapctl(SC_AINFO, &ai) != -1 && ai.ani_free <= ai.ani_max &&
ai.ani_resv <= ai.ani_max && ai.ani_resv >= ai.ani_max - ai.ani_free) {
s->swap_alloc = ai.ani_max - ai.ani_free;
s->swap_reserved = ai.ani_resv - s->swap_alloc;
s->swap_available = ai.ani_max - ai.ani_resv;
s->swap_virtual_ok = 1;
}
if (physical_swap(&s->swap_physical_used_bytes) == 0)
s->swap_physical_ok = 1;
if (anon_pageouts(&s->anon_pageout_pages) == 0)
s->pageout_ok = 1;
return 0;
}

static int zone_read(int zid, zone_cache_t *z)
{
pid_t init;
memset(z, 0, sizeof (*z)); z->zid = zid;
if (getzonenamebyid((zoneid_t)zid, z->uuid, sizeof (z->uuid)) < 0 ||
zone_getattr((zoneid_t)zid, ZONE_ATTR_UNIQID, &z->generation,
sizeof (z->generation)) < 0 ||
zone_getattr((zoneid_t)zid, ZONE_ATTR_INITPID, &init, sizeof (init)) < 0)
return -1;
z->initpid = (int)init;
z->uuid[sizeof (z->uuid) - 1] = 0;
return 0;
}

static void convert(const psinfo_t *info, const zone_cache_t *z, process_t *p)
{
size_t i;
memset(p, 0, sizeof (*p));
p->pid = (int)info->pr_pid; p->ppid = (int)info->pr_ppid;
p->zid = (int)info->pr_zoneid; p->nlwp = info->pr_nlwp;
p->uid = info->pr_uid; p->euid = info->pr_euid;
p->start_sec = info->pr_start.tv_sec; p->start_nsec = info->pr_start.tv_nsec;
p->rss_kib = info->pr_rssize;
memcpy(p->name, info->pr_fname, sizeof (info->pr_fname));
p->name[sizeof (info->pr_fname)] = 0;
/* No arguments or uncontrolled control characters in persistent logs. */
for (i = 0; p->name[i]; i++)
if (!isalnum((unsigned char)p->name[i]) && !strchr("._-", p->name[i]))
p->name[i] = '_';
if (z) {
p->initpid = z->initpid; p->zone_generation = z->generation;
memcpy(p->uuid, z->uuid, sizeof (p->uuid));
} else strcpy(p->uuid, "unresolved");
}

int platform_walk(int (*visit)(const process_t *, void *), void *arg, unsigned budget_ms)
{
struct dirent *de;
double deadline = monotime() + (double)budget_ms / 1000;
int partial = 0;
nzones = 0; rewinddir(procdir);
for (;;) {
char path[96], *end;
long pid;
int fd;
ssize_t nr;
psinfo_t info;
process_t p;
zone_cache_t *z = NULL;
size_t i;
errno = 0; de = readdir(procdir);
if (!de) { if (errno) partial = 1; break; }
if (monotime() >= deadline) { partial = 1; break; }
if (!isdigit((unsigned char)de->d_name[0])) continue;
errno = 0; pid = strtol(de->d_name, &end, 10);
if (errno || *end || pid <= 0 || pid > 2147483647L) continue;
snprintf(path, sizeof (path), "/proc/%ld/psinfo", pid);
fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd < 0) { if (errno != ENOENT && errno != ESRCH) partial = 1; continue; }
nr = pread(fd, &info, sizeof (info), 0); close(fd);
if (nr != sizeof (info)) {
if (nr >= 0 || (errno != ENOENT && errno != ESRCH)) partial = 1;
continue;
}
for (i = 0; i < nzones; i++) if (zones[i].zid == info.pr_zoneid) { z = &zones[i]; break; }
if (!z && nzones < MAX_ZONES) {
z = &zones[nzones++];
if (zone_read((int)info.pr_zoneid, z)) {
z->zid = (int)info.pr_zoneid; z->initpid = 0;
strcpy(z->uuid, "unresolved"); partial = 1;
}
}
if (!z) partial = 1;
convert(&info, z, &p);
if (visit(&p, arg)) { partial = 1; break; }
}
return partial ? -1 : 0;
}

int platform_signal(const config_t *c, const process_t *expected, enum action action, int real,
uint64_t ceiling)
{
char path[64];
int dir = -1, infofd = -1, ctl = -1, result = -1, saved;
psinfo_t info;
process_t current;
zone_cache_t z;
long command[2];
sample_t latest;
if (action != TERM && action != KILL) { errno = EINVAL; return -1; }
if (expected->pid == getpid() || protected_process(c, expected) || (real && !c->armed)) {
errno = EPERM; return -1;
}
snprintf(path, sizeof (path), "/proc/%d", expected->pid);
dir = open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (dir < 0) goto done;
/* Both descriptors are relative to the same proc directory vnode. */
infofd = openat(dir, "psinfo", O_RDONLY | O_CLOEXEC);
if (infofd < 0) goto done;
if (real) {
ctl = openat(dir, "ctl", O_WRONLY | O_EXCL | O_CLOEXEC);
if (ctl < 0) goto done;
}
if (pread(infofd, &info, sizeof (info), 0) != sizeof (info) ||
zone_read((int)info.pr_zoneid, &z)) goto done;
convert(&info, &z, &current);
if (!same_process(expected, &current) || strcmp(expected->name, current.name) ||
expected->uid != current.uid || expected->euid != current.euid ||
protected_process(c, &current)) { errno = ESTALE; goto done; }
if (!real) { result = 0; goto done; }
if (platform_sample(&latest) || monotime() - latest.at > 2 || latest.free >= ceiling) {
errno = EAGAIN; goto done;
}
command[0] = PCKILL; command[1] = action == TERM ? SIGTERM : SIGKILL;
/* Single atomic proc control message; never retry an ambiguous write. */
{
ssize_t written = write(ctl, command, sizeof (command));
if (written == sizeof (command)) result = 0;
else if (written >= 0) errno = EIO;
}
done:
saved = errno;
if (ctl >= 0) close(ctl);
if (infofd >= 0) close(infofd);
if (dir >= 0) close(dir);
errno = saved;
return result;
}

+ 492
- 0
src/main.c 查看文件

@@ -0,0 +1,492 @@
#include "guard.h"
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
#include <time.h>
#include <stdarg.h>
#include <inttypes.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>

typedef struct {
process_t p; history_t rss; unsigned seen; double last;
} tracked_t;
typedef struct {
char uuid[64]; uint64_t generation, rss;
history_t history; unsigned seen; double last; int best;
} zone_t;
static config_t cfg;
static tracked_t processes[MAX_PROCS];
static zone_t zone_stats[MAX_ZONES];
enum metric_index {
METRIC_FREE,
METRIC_KERNEL,
METRIC_LOCKED,
METRIC_ARC,
METRIC_SWAP_COMMITTED,
METRIC_SWAP_PHYSICAL_USED,
METRIC_ANON_PAGEOUT,
METRIC_NSCAN,
METRIC_SWAP_AVAILABLE,
METRIC_COUNT
};
static history_t metrics[METRIC_COUNT];
static const char *const metric_names[METRIC_COUNT] = {
"free_pages", "kernel_pages", "locked_pages", "arc_bytes",
"swap_committed_pages", "swap_physical_used_bytes",
"anon_pageout_pages", "nscan_pages", "swap_available_pages"
};
static engine_t engine;
static controller_t controller;
static unsigned scan_generation;
static double scan_at, started;
static int scan_complete;
static int logfd = -1, log_ok = 1, lockfd = -1;
static uint64_t log_bytes;
static volatile sig_atomic_t stopping, reopen_log;

static void handler(int sig)
{
if (sig == SIGHUP) reopen_log = 1;
else stopping = 1;
}

static int log_open(void)
{
struct stat st;
logfd = secure_file(LOG_DIR "/guard.log", O_WRONLY | O_CREAT | O_APPEND, 0600);
if (logfd < 0 || fstat(logfd, &st)) return -1;
log_bytes = (uint64_t)st.st_size; return 0;
}

static int log_rotate(void)
{
char from[256], to[256]; unsigned i;
close(logfd); logfd = -1;
for (i = cfg.log_keep; i > 1; i--) {
snprintf(from, sizeof (from), LOG_DIR "/guard.log.%u", i - 1);
snprintf(to, sizeof (to), LOG_DIR "/guard.log.%u", i);
if (rename(from, to) && errno != ENOENT) return -1;
}
if (rename(LOG_DIR "/guard.log", LOG_DIR "/guard.log.1") && errno != ENOENT)
return -1;
return log_open();
}

static int emit(const char *fmt, ...)
{
char line[2048], stamp[40]; struct tm tm; time_t now = time(NULL);
size_t len; int n; va_list ap;
if (!log_ok) return -1;
gmtime_r(&now, &tm); strftime(stamp, sizeof (stamp), "%Y-%m-%dT%H:%M:%SZ", &tm);
n = snprintf(line, sizeof (line), "%s mono=%.3f ", stamp, monotime());
if (n < 0 || (size_t)n >= sizeof (line)) goto failed;
len = (size_t)n;
va_start(ap, fmt); n = vsnprintf(line + len, sizeof (line) - len, fmt, ap); va_end(ap);
if (n < 0 || (size_t)n >= sizeof (line) - len - 1) goto failed;
len += (size_t)n; line[len++] = '\n';
if (log_bytes + len > (uint64_t)cfg.log_mib * 1024 * 1024 && log_rotate()) goto failed;
if (write(logfd, line, len) != (ssize_t)len) goto failed;
log_bytes += len; return 0;
failed:
log_ok = 0;
fprintf(stderr, "o6h-memory-guard: persistent logging failed; actions inhibited until restart\n");
return -1;
}

static delta_t growth(const history_t *h)
{
delta_t d = history_delta(h, scan_at, 60);
if (!d.valid) d = history_delta(h, scan_at, 15);
if (!d.valid) d = history_delta(h, scan_at, 5);
return d;
}

static int visit(const process_t *p, void *unused)
{
unsigned i, slot = MAX_PROCS, zslot = MAX_ZONES;
tracked_t *t;
zone_t *z;
(void)unused;
for (i = 0; i < MAX_PROCS; i++) {
unsigned j = ((unsigned)p->pid + i) % MAX_PROCS;
if (processes[j].p.pid == p->pid) { slot = j; break; }
if (!processes[j].p.pid) {
if (slot == MAX_PROCS) slot = j;
break;
}
if (scan_at - processes[j].last > 65) {
if (slot == MAX_PROCS) slot = j;
}
}
if (slot == MAX_PROCS) return -1;
t = &processes[slot];
if (!same_process(&t->p, p)) memset(t, 0, sizeof (*t));
t->p = *p; t->seen = scan_generation; t->last = scan_at;
history_add(&t->rss, scan_at, p->rss_kib);
for (i = 0; i < MAX_ZONES; i++) {
if (!strcmp(zone_stats[i].uuid, p->uuid) && zone_stats[i].generation == p->zone_generation) {
zslot = i; break;
}
if (!zone_stats[i].uuid[0] || scan_at - zone_stats[i].last > 65)
if (zslot == MAX_ZONES) zslot = i;
}
if (zslot == MAX_ZONES) return -1;
z = &zone_stats[zslot];
if (strcmp(z->uuid, p->uuid) || z->generation != p->zone_generation) {
memset(z, 0, sizeof (*z)); strcpy(z->uuid, p->uuid); z->generation = p->zone_generation;
}
if (z->seen != scan_generation) { z->rss = 0; z->best = -1; }
z->seen = scan_generation; z->last = scan_at;
if (UINT64_MAX - z->rss < p->rss_kib) return -1;
z->rss += p->rss_kib;
if (!protected_process(&cfg, p) && !was_victim(&controller, p)) {
if (z->best < 0 || rank_score(p->rss_kib, growth(&t->rss)) >
rank_score(processes[z->best].p.rss_kib, growth(&processes[z->best].rss)))
z->best = (int)slot;
}
return 0;
}

static const char *alias_for(const char *uuid)
{
const zone_rule_t *r = zone_rule(&cfg, uuid);
return r ? r->alias : "unmapped";
}

static void delta_text(char *out, size_t n, delta_t d)
{
if (d.valid) snprintf(out, n, "%+" PRId64 "@%.2fs", d.value, d.age);
else snprintf(out, n, "NA");
}

static void log_process(const char *event, const tracked_t *t)
{
char d5[64], d15[64], d60[64];
delta_text(d5, sizeof (d5), history_delta(&t->rss, scan_at, 5));
delta_text(d15, sizeof (d15), history_delta(&t->rss, scan_at, 15));
delta_text(d60, sizeof (d60), history_delta(&t->rss, scan_at, 60));
emit("event=%s UUID=%s alias=%s zone_id=%d zone_generation=%" PRIu64
" pid=%d start=%" PRId64 ".%09" PRId64 " comm=%s rss_kib=%" PRIu64
" d5_kib=%s d15_kib=%s d60_kib=%s eligible=%d", event,
t->p.uuid, alias_for(t->p.uuid), t->p.zid, t->p.zone_generation, t->p.pid,
t->p.start_sec, t->p.start_nsec, t->p.name, t->p.rss_kib,
d5, d15, d60, !protected_process(&cfg, &t->p));
}

static void scan_processes(int snapshot)
{
unsigned i, j, ntop = 0;
int top[20];
scan_at = monotime(); scan_generation++;
if (!scan_generation) scan_generation++; /* practically unreachable */
scan_complete = platform_walk(visit, NULL, cfg.scan_budget_ms) == 0;
emit("event=proc_scan complete=%d duration_ms=%.1f capacity=%d", scan_complete,
1000 * (monotime() - scan_at), MAX_PROCS);
for (i = 0; i < MAX_ZONES; i++) {
zone_t *z = &zone_stats[i];
char d5[64], d15[64], d60[64];
if (z->seen != scan_generation) continue;
/* Incomplete totals must never become valid zone-growth baselines. */
if (scan_complete) history_add(&z->history, scan_at, z->rss);
if (!snapshot) continue;
delta_text(d5, sizeof (d5), history_delta(&z->history, scan_at, 5));
delta_text(d15, sizeof (d15), history_delta(&z->history, scan_at, 15));
delta_text(d60, sizeof (d60), history_delta(&z->history, scan_at, 60));
emit("event=zone UUID=%s alias=%s generation=%" PRIu64 " rss_sum_kib=%" PRIu64
" complete=%d d5_kib=%s d15_kib=%s d60_kib=%s", z->uuid, alias_for(z->uuid),
z->generation, z->rss, scan_complete, d5, d15, d60);
}
if (!snapshot) return;
for (i = 0; i < MAX_PROCS; i++) {
if (processes[i].seen != scan_generation || !processes[i].p.pid) continue;
for (j = 0; j < ntop; j++) if (processes[i].p.rss_kib > processes[top[j]].p.rss_kib) break;
if (j < 20) {
unsigned k = ntop < 20 ? ntop++ : 19;
for (; k > j; k--) top[k] = top[k-1];
top[j] = (int)i;
}
}
for (i = 0; i < ntop; i++) log_process("top_process", &processes[top[i]]);
}

static const process_t *candidate(void)
{
unsigned i; zone_t *best = NULL;
if (!scan_complete || monotime() - scan_at > 2) return NULL;
for (i = 0; i < MAX_ZONES; i++) {
zone_t *z = &zone_stats[i];
if (z->seen != scan_generation || z->best < 0) continue;
if (was_victim(&controller, &processes[z->best].p)) continue;
if (!best || rank_score(z->rss, growth(&z->history)) > rank_score(best->rss, growth(&best->history)))
best = z;
}
if (!best) return NULL;
log_process("candidate", &processes[best->best]);
return &processes[best->best].p;
}

static int metric_valid(enum metric_index metric, const sample_t *s)
{
if (metric == METRIC_ARC) return s->arc_ok;
if (metric == METRIC_SWAP_COMMITTED || metric == METRIC_SWAP_AVAILABLE)
return s->swap_virtual_ok;
if (metric == METRIC_SWAP_PHYSICAL_USED) return s->swap_physical_ok;
if (metric == METRIC_ANON_PAGEOUT) return s->pageout_ok;
return 1;
}

static void record_metrics(const sample_t *s)
{
uint64_t values[] = {s->free, s->kernel, s->locked, s->arc,
s->swap_alloc + s->swap_reserved, s->swap_physical_used_bytes,
s->anon_pageout_pages, s->scan, s->swap_available};
unsigned i;
for (i = 0; i < METRIC_COUNT; i++) {
if (metric_valid((enum metric_index)i, s))
history_add(&metrics[i], s->at, values[i]);
else
memset(&metrics[i], 0, sizeof (metrics[i]));
}
}

static void log_sample(const sample_t *s)
{
unsigned i;
emit("event=sample state=%s action_pressure=%d floor=%d free_pages=%" PRIu64
" lotsfree=%" PRIu64 " desfree=%" PRIu64 " minfree=%" PRIu64
" nscan=%" PRIu64 " pp_kernel=%" PRIu64 " pageslocked=%" PRIu64
" arc_ok=%d arc_bytes=%" PRIu64
" swap_committed_ok=%d swap_committed_pages=%" PRIu64
" swap_physical_used_ok=%d swap_physical_used_bytes=%" PRIu64
" anon_pageout_ok=%d anon_pageout_pages=%" PRIu64
" swap_alloc_pages=%" PRIu64 " swap_reserved_pages=%" PRIu64
" swap_available_pages=%" PRIu64,
state_name(engine.state), engine.danger, engine.floor, s->free, s->lots, s->des, s->min,
s->scan, s->kernel, s->locked, s->arc_ok, s->arc,
s->swap_virtual_ok, s->swap_alloc + s->swap_reserved,
s->swap_physical_ok, s->swap_physical_used_bytes,
s->pageout_ok, s->anon_pageout_pages, s->swap_alloc,
s->swap_reserved, s->swap_available);
for (i = 0; i < METRIC_COUNT; i++) {
char d5[64], d15[64], d60[64];
delta_text(d5, sizeof (d5), history_delta(&metrics[i], s->at, 5));
delta_text(d15, sizeof (d15), history_delta(&metrics[i], s->at, 15));
delta_text(d60, sizeof (d60), history_delta(&metrics[i], s->at, 60));
emit("event=delta metric=%s d5=%s d15=%s d60=%s", metric_names[i], d5, d15, d60);
}
}

static void act(sample_t *sample)
{
plan_t plan;
sample_t fresh;
engine_t current_engine;
const process_t *p;
int err, rc;
if (stopping) return;
if (!engine.danger) {
(void)action_plan(&controller, &cfg, &engine, sample, NULL);
return;
}
if (!log_ok || !scan_complete) {
emit("event=action_blocked reason=log_or_incomplete_scan"); return;
}
if (cfg.armed && monotime() - started < 60) {
emit("event=action_blocked reason=armed_startup_60s"); return;
}
/* Refresh after /proc traversal and logging, before authorizing a signal. */
if (platform_sample(&fresh) || monotime() - fresh.at > 2 || fresh.free >= fresh.des) {
emit("event=action_blocked reason=refresh_failed_or_above_desfree"); return;
}
p = candidate();
current_engine = engine; current_engine.floor = fresh.free < fresh.min / 2;
plan = action_plan(&controller, &cfg, &current_engine, &fresh, p);
if (plan.action == NONE) { emit("event=action_blocked reason=%s", plan.reason); return; }
/* A fresh floor crossing must not wait for the next tick. */
if (fresh.free < fresh.min / 2) { plan.action = KILL; plan.reason = "floor"; }
if (platform_signal(&cfg, &plan.target, plan.action, 0, 0)) {
err = errno;
emit("event=action_rejected reason=identity_or_protection pid=%d errno=%d", plan.target.pid, err);
if (controller.has_pending && same_process(&controller.pending, &plan.target)) {
controller.has_pending = 0;
/* A vanished pending victim must not consume another emergency tick. */
if (fresh.free < fresh.min) act(&fresh);
}
return;
}
if (emit("event=action %s=%s reason=%s UUID=%s alias=%s zone_generation=%" PRIu64
" pid=%d start=%" PRId64 ".%09" PRId64 " comm=%s rss_kib=%" PRIu64 " free_pages=%" PRIu64,
cfg.armed ? "ACTION_INTENT" : "WOULD_ACTION", plan.action == TERM ? "SIGTERM" : "SIGKILL",
plan.reason, plan.target.uuid, alias_for(plan.target.uuid), plan.target.zone_generation,
plan.target.pid, plan.target.start_sec, plan.target.start_nsec, plan.target.name,
plan.target.rss_kib, fresh.free)) return;
/* Consume a budget slot even if the subsequent signal write is ambiguous. */
action_commit(&controller, &cfg, &fresh, &plan);
if (cfg.armed && !stopping) {
rc = platform_signal(&cfg, &plan.target, plan.action, 1,
!strcmp(plan.reason, "floor") ? fresh.min / 2 : fresh.des); err = errno;
emit("event=action_result pid=%d signal=%s result=%s errno=%d", plan.target.pid,
plan.action == TERM ? "SIGTERM" : "SIGKILL", rc ? "failed" : "sent", rc ? err : 0);
if (rc) controller.has_pending = 0;
}
}

static int acquire_lock(void)
{
struct flock lock;
char pid[32]; int n;
lockfd = secure_file(RUN_DIR "/o6h-memory-guard.lock", O_RDWR | O_CREAT, 0600);
if (lockfd < 0) return -1;
memset(&lock, 0, sizeof (lock)); lock.l_type = F_WRLCK; lock.l_whence = SEEK_SET;
if (fcntl(lockfd, F_SETLK, &lock)) return -1;
n = snprintf(pid, sizeof (pid), "%ld\n", (long)getpid());
if (ftruncate(lockfd, 0) || write(lockfd, pid, (size_t)n) != n) return -1;
return 0;
}

static int detach(void)
{
int channel[2]; pid_t pid; char ready; ssize_t n;
if (pipe(channel)) return -1;
pid = fork();
if (pid < 0) return -1;
if (pid > 0) {
close(channel[1]);
do { n = read(channel[0], &ready, 1); } while (n < 0 && errno == EINTR);
close(channel[0]);
if (n == 1 && ready == 'R') exit(0);
(void)waitpid(pid, NULL, 0); exit(1);
}
close(channel[0]);
if (setsid() < 0 || chdir("/")) { close(channel[1]); return -1; }
return channel[1];
}

int main(int argc, char **argv)
{
const char *config = CONF_PATH;
int i, fd, daemon = 0, check = 0, probe = 0, force_dry = 0, readyfd = -1;
FILE *f; char error[160]; sample_t sample; struct sigaction sa;
double next, last_scan = -10, last_snapshot = -60;
if (argc == 2 && !strcmp(argv[1], "--check-install-dirs")) {
const char *dirs[] = {"/opt/custom/bin", "/opt/custom/etc", "/opt/custom/smf", LOG_DIR, RUN_DIR};
size_t j;
if (geteuid() != 0) return 1;
for (j = 0; j < sizeof (dirs) / sizeof (dirs[0]); j++)
if (secure_directory(dirs[j])) { perror(dirs[j]); return 1; }
puts("installation directories OK"); return 0;
}
for (i = 1; i < argc; i++) {
if (!strcmp(argv[i], "--config") && i + 1 < argc) config = argv[++i];
else if (!strcmp(argv[i], "--daemon")) daemon = 1;
else if (!strcmp(argv[i], "--check-config")) check = 1;
else if (!strcmp(argv[i], "--probe")) probe = 1;
else if (!strcmp(argv[i], "--force-dry-run")) force_dry = 1;
else if (!strcmp(argv[i], "--version")) { puts(VERSION); return 0; }
else { fprintf(stderr, "usage: %s [--config /absolute/file] [--check-config|--probe|--daemon] [--force-dry-run]\n", argv[0]); return 2; }
}
if (check + probe + daemon > 1) return 2;
umask(077);
fd = secure_file(config, O_RDONLY, 0);
if (fd < 0) { perror(config); return 1; }
f = fdopen(fd, "r");
if (!f) { close(fd); return 1; }
if (config_read(f, &cfg, error, sizeof (error))) { fprintf(stderr, "%s\n", error); fclose(f); return 1; }
fclose(f);
if (force_dry) cfg.armed = 0;
if (check) { printf("config OK mode=%s zones=%zu\n", cfg.armed ? "armed" : "dry-run", cfg.nzones); return 0; }
if (daemon && (readyfd = detach()) < 0) { perror("daemonize"); return 1; }
if (platform_init() || platform_sample(&sample)) { perror("global zone/kstat initialization"); return 1; }
if (probe) {
printf("probe OK page_bytes=%ld free=%" PRIu64 " lotsfree=%" PRIu64 " desfree=%" PRIu64
" minfree=%" PRIu64 " nscan=%" PRIu64 " pp_kernel=%" PRIu64 " pageslocked=%" PRIu64
" arc_ok=%d arc_bytes=%" PRIu64
" swap_committed_ok=%d swap_committed_pages=%" PRIu64
" swap_physical_used_ok=%d swap_physical_used_bytes=%" PRIu64
" anon_pageout_ok=%d anon_pageout_pages=%" PRIu64
" swap_alloc_pages=%" PRIu64 " swap_reserved_pages=%" PRIu64
" swap_available_pages=%" PRIu64 "\n",
sysconf(_SC_PAGESIZE), sample.free, sample.lots, sample.des, sample.min,
sample.scan, sample.kernel, sample.locked, sample.arc_ok, sample.arc,
sample.swap_virtual_ok, sample.swap_alloc + sample.swap_reserved,
sample.swap_physical_ok, sample.swap_physical_used_bytes,
sample.pageout_ok, sample.anon_pageout_pages, sample.swap_alloc,
sample.swap_reserved, sample.swap_available);
platform_fini(); return 0;
}
if (acquire_lock() || secure_directory(LOG_DIR) || log_open()) { perror("lock/log initialization"); return 1; }
memset(&sa, 0, sizeof (sa)); sa.sa_handler = handler; sigemptyset(&sa.sa_mask);
if (sigaction(SIGTERM, &sa, NULL) || sigaction(SIGINT, &sa, NULL) || sigaction(SIGHUP, &sa, NULL)) return 1;
engine_init(&engine); started = monotime(); next = started;
/* Touch all fixed working storage before allowing armed operation. */
memset(processes, 0, sizeof (processes)); memset(zone_stats, 0, sizeof (zone_stats));
if (emit("event=start version=%s pid=%ld mode=%s page_bytes=%ld fixed_storage_bytes=%zu armed_warmup=60",
VERSION, (long)getpid(), cfg.armed ? "armed" : "dry-run", sysconf(_SC_PAGESIZE),
sizeof (processes) + sizeof (zone_stats))) return 1;
if (readyfd >= 0) {
if (write(readyfd, "R", 1) != 1) return 1;
close(readyfd);
fd = open("/dev/null", O_RDWR);
if (fd >= 0) { (void)dup2(fd, STDIN_FILENO); (void)dup2(fd, STDOUT_FILENO); if (fd > 2) close(fd); }
}
while (!stopping) {
if (reopen_log) {
reopen_log = 0;
if (logfd >= 0) close(logfd);
if (log_open()) log_ok = 0;
emit("event=log_reopen configuration=unchanged");
}
if (platform_sample(&sample) || monotime() - sample.at > 2) {
int err = errno;
engine_gap(&engine); controller.has_pending = 0; scan_complete = 0;
emit("event=sample_error errno=%d actions=inhibited", err);
} else {
enum state previous = engine.state;
record_metrics(&sample);
engine_step(&engine, &cfg, &sample,
history_delta(&metrics[METRIC_FREE], sample.at, 5),
history_delta(&metrics[METRIC_SWAP_PHYSICAL_USED], sample.at, 5),
history_delta(&metrics[METRIC_ANON_PAGEOUT], sample.at, 5));
if (engine.state != previous)
emit("event=transition from=%s to=%s", state_name(previous), state_name(engine.state));
log_sample(&sample);
/* Use a recent complete cache first in emergencies. Revalidation is mandatory. */
if (sample.free < sample.min && scan_complete && sample.at - scan_at <= 2) {
act(&sample);
} else {
if (sample.free < sample.lots || sample.at - last_scan >= cfg.normal_scan_s) {
int snapshot = sample.at - last_snapshot >= (sample.free < sample.lots ? 5 : 60);
scan_processes(snapshot); last_scan = sample.at;
if (snapshot) last_snapshot = sample.at;
if (sample.free < sample.lots && !engine.danger) (void)candidate();
}
act(&sample);
}
/* Refresh cache even during an ongoing emergency. */
if (sample.free < sample.min && sample.at - last_scan >= 0.9) {
scan_processes(0); last_scan = sample.at;
}
}
next += 1;
if (monotime() > next) {
emit("event=sampling_overrun late_seconds=%.3f", monotime() - next);
next = monotime() + 1;
}
while (!stopping && !reopen_log && monotime() < next) {
double remain = next - monotime(); struct timespec delay;
if (remain <= 0) break;
delay.tv_sec = (time_t)remain; delay.tv_nsec = (long)((remain - delay.tv_sec) * 1e9);
if (nanosleep(&delay, NULL) && errno != EINTR) break;
}
}
emit("event=stop");
platform_fini();
if (logfd >= 0) close(logfd);
if (lockfd >= 0) close(lockfd);
return 0;
}

+ 56
- 0
src/support.c 查看文件

@@ -0,0 +1,56 @@
#include "guard.h"
#include <sys/types.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
#include <stdlib.h>
#include <errno.h>
#include <limits.h>

double monotime(void)
{
struct timespec t;
if (clock_gettime(CLOCK_MONOTONIC, &t)) abort();
return (double)t.tv_sec + (double)t.tv_nsec / 1e9;
}

int secure_directory(const char *path)
{
char resolved[PATH_MAX], *p;
struct stat st;
if (!realpath(path, resolved)) return -1;
/* SmartOS may have root-owned symlinks in /opt. Validate resolved parents. */
for (p = resolved + 1; ; p++) {
if (*p == '/' || !*p) {
char save = *p; *p = 0;
if (stat(resolved, &st) || !S_ISDIR(st.st_mode) || st.st_uid != 0 ||
(st.st_mode & 022)) { errno = EPERM; return -1; }
*p = save; if (!save) break;
}
}
return 0;
}

int secure_file(const char *path, int flags, unsigned mode)
{
char parent[PATH_MAX], resolved[PATH_MAX], target[PATH_MAX], *last;
struct stat st;
int fd;
if (strlen(path) >= sizeof (parent) || path[0] != '/') { errno = EINVAL; return -1; }
strcpy(parent, path); last = strrchr(parent, '/');
if (!last || last == parent) { errno = EINVAL; return -1; }
*last = 0;
if (!realpath(parent, resolved) || secure_directory(resolved)) return -1;
if (snprintf(target, sizeof (target), "%s/%s", resolved, last + 1) >= (int)sizeof (target)) {
errno = ENAMETOOLONG; return -1;
}
fd = open(target, flags | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK, (mode_t)mode);
if (fd < 0) return -1;
if (fstat(fd, &st) || !S_ISREG(st.st_mode) || st.st_nlink != 1 ||
st.st_uid != 0 || (st.st_mode & 022)) {
close(fd); errno = EPERM; return -1;
}
return fd;
}

+ 17
- 0
tests/api-sketch/kstat.h 查看文件

@@ -0,0 +1,17 @@
/* TEST ONLY: declaration sketch, NOT an illumos ABI/header implementation. */
#include <stdint.h>
typedef struct kstat_ctl kstat_ctl_t;
typedef struct kstat { int ks_type; struct kstat *ks_next; char ks_module[31]; char ks_name[31]; } kstat_t;
struct kstat_ctl { kstat_t *kc_chain; };
typedef struct { int data_type; union { uint64_t ui64; int64_t i64; uint32_t ui32; int32_t i32; } value; } kstat_named_t;
#define KSTAT_DATA_INT32 1
#define KSTAT_DATA_UINT32 2
#define KSTAT_DATA_INT64 3
#define KSTAT_DATA_UINT64 4
#define KSTAT_TYPE_NAMED 1
kstat_ctl_t *kstat_open(void);
int kstat_close(kstat_ctl_t *);
int kstat_chain_update(kstat_ctl_t *);
kstat_t *kstat_lookup(kstat_ctl_t *, char *, int, char *);
int kstat_read(kstat_ctl_t *, kstat_t *, void *);
void *kstat_data_lookup(kstat_t *, char *);

+ 13
- 0
tests/api-sketch/procfs.h 查看文件

@@ -0,0 +1,13 @@
/* TEST ONLY. Field names/types; layout intentionally NOT ABI compatible. */
#include <sys/types.h>
#include <time.h>
typedef struct {
int pr_nlwp;
pid_t pr_pid, pr_ppid;
uid_t pr_uid, pr_euid;
size_t pr_rssize;
struct timespec pr_start;
char pr_fname[16];
int pr_zoneid;
} psinfo_t;
#define PCKILL 8

+ 7
- 0
tests/api-sketch/sys/swap.h 查看文件

@@ -0,0 +1,7 @@
/* TEST ONLY. */
#define SC_AINFO 5
#define SC_GETNSWP 1
#define SC_LIST 2
typedef struct swapent { char *ste_path; unsigned long ste_start, ste_pages, ste_free; int ste_flags; } swapent_t;
struct swaptable { int swt_n; swapent_t swt_ent[1]; };
int swapctl(int, void *);

+ 2
- 0
tests/api-sketch/vm/anon.h 查看文件

@@ -0,0 +1,2 @@
/* TEST ONLY. */
struct anoninfo { unsigned long ani_max, ani_free, ani_resv; };

+ 9
- 0
tests/api-sketch/zone.h 查看文件

@@ -0,0 +1,9 @@
/* TEST ONLY: declaration sketch. */
#include <sys/types.h>
typedef int zoneid_t;
#define GLOBAL_ZONEID 0
#define ZONE_ATTR_UNIQID 5
#define ZONE_ATTR_INITPID 7
zoneid_t getzoneid(void);
ssize_t getzonenamebyid(zoneid_t, char *, size_t);
ssize_t zone_getattr(zoneid_t, int, void *, size_t);

+ 216
- 0
tests/test_guard.c 查看文件

@@ -0,0 +1,216 @@
#include "guard.h"
#include <assert.h>
#include <string.h>
#include <stdlib.h>
#include <inttypes.h>

static unsigned checks;
#define CHECK(x) do { if (!(x)) { fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #x); exit(1); } checks++; } while (0)
static const char *uuid = "11111111-1111-4111-8111-111111111111";

static config_t allowed(void)
{
config_t c; defaults(&c);
c.nzones = 1; strcpy(c.zones[0].uuid, uuid);
strcpy(c.zones[0].alias, "fixture-web"); c.zones[0].policy = ALLOW;
return c;
}
static process_t process(int pid)
{
process_t p; memset(&p, 0, sizeof (p));
p.pid = pid; p.ppid = 100; p.zid = 9; p.initpid = 99; p.nlwp = 1;
p.zone_generation = 20; p.start_sec = 1000; p.start_nsec = 123;
p.rss_kib = 1048576; strcpy(p.uuid, uuid); strcpy(p.name, "php-fpm");
return p;
}
static sample_t sample(double at, uint64_t free_pages, uint64_t scanner)
{
sample_t s; memset(&s, 0, sizeof (s)); s.at = at; s.free = free_pages;
s.lots = 130875; s.des = 65437; s.min = 49077; s.scan = scanner;
return s;
}
static void step(engine_t *e, config_t *c, double at, uint64_t free_pages, uint64_t scanner)
{
sample_t s = sample(at, free_pages, scanner); delta_t d = {0, 0, 0};
engine_step(e, c, &s, d, d, d);
}
static int parse(const char *text, config_t *c)
{
FILE *f = tmpfile(); int result; char error[128];
CHECK(f != NULL); CHECK(fputs(text, f) >= 0); rewind(f);
result = config_read(f, c, error, sizeof (error)); fclose(f); return result;
}
static void configs(void)
{
config_t c;
CHECK(parse("# defaults\n", &c) == 0 && !c.armed);
CHECK(parse("ACTION_MODE dry-run\nprotect_process postgres\n", &c) == 0);
CHECK(parse("ACTION_MODE armd\n", &c) != 0);
CHECK(parse("ACTION_MODE armed\n", &c) != 0);
CHECK(parse("ACTION_MODE dry-run\nACTION_MODE armed\n", &c) != 0);
CHECK(parse("LOG_KEEP 0\n", &c) != 0);
CHECK(parse("TERM_GRACE_SECONDS -1\n", &c) != 0);
CHECK(parse("TERM_GRACE_SECONDS 1 garbage\n", &c) != 0);
CHECK(parse("UNKNOWN foo\n", &c) != 0);
CHECK(parse("PRE_SECONDS 20\nPRESSURE_SECONDS 10\n", &c) != 0);
CHECK(parse("zone global global allow\n", &c) != 0);
CHECK(parse("zone 11111111-1111-4111-8111-111111111111 web allow\nACTION_MODE armed\n", &c) == 0 && c.armed);
CHECK(parse("zone 11111111-1111-4111-8111-111111111111 web allow\nzone 11111111-1111-4111-8111-111111111111 db protect\n", &c) != 0);
CHECK(!uuid_valid("11111111-1111-4111-8111-11111111111g"));
CHECK(!uuid_valid("00000000-0000-0000-0000-000000000000"));
puts("PASS strict configuration, safe defaults, explicit UUID authorization");
}
static void protections(void)
{
config_t c = allowed(); process_t p = process(123), q;
CHECK(!protected_process(&c, &p));
q = p; q.zid = 0; CHECK(protected_process(&c, &q));
q = p; q.pid = 1; CHECK(protected_process(&c, &q));
q = p; q.pid = q.initpid; CHECK(protected_process(&c, &q));
q = p; q.initpid = 0; CHECK(protected_process(&c, &q));
q = p; q.zone_generation = 0; CHECK(protected_process(&c, &q));
q = p; q.nlwp = 0; CHECK(protected_process(&c, &q));
q = p; q.ppid = 0; CHECK(protected_process(&c, &q));
q = p; strcpy(q.name, "sshd"); CHECK(protected_process(&c, &q));
q = p; q.rss_kib = 100; CHECK(protected_process(&c, &q));
q = p; q.uuid[0] = '2'; CHECK(protected_process(&c, &q));
c.zones[0].policy = PROTECT; CHECK(protected_process(&c, &p));
c.zones[0].policy = OBSERVE; CHECK(protected_process(&c, &p));
c.zones[0].policy = ALLOW;
strcpy(c.protected_names[c.nnames++], "php-fpm"); CHECK(protected_process(&c, &p));
c.nnames = 0; c.protected_pids[c.npids++] = p.pid; CHECK(protected_process(&c, &p));
q = p; CHECK(same_process(&p, &q));
q.start_nsec++; CHECK(!same_process(&p, &q));
q = p; q.zone_generation++; CHECK(!same_process(&p, &q));
q = p; q.uuid[0] = '2'; CHECK(!same_process(&p, &q));
puts("PASS mandatory protections, PID reuse and zone incarnation identity");
}
static void histories(void)
{
history_t h = {0}; delta_t d; unsigned i;
for (i = 0; i <= 70; i++) history_add(&h, i, 10000 - 10 * i);
d = history_delta(&h, 70, 5); CHECK(d.valid && d.value == -50 && d.age == 5);
d = history_delta(&h, 70, 15); CHECK(d.valid && d.value == -150);
d = history_delta(&h, 70, 60); CHECK(d.valid && d.value == -600);
CHECK(!history_delta(&h, 90, 5).valid);
memset(&h, 0, sizeof (h));
history_add(&h, 0, 123); history_add(&h, 15, 100);
CHECK(!history_delta(&h, 15, 5).valid);
CHECK(history_delta(&h, 15, 15).value == -23);
d.valid = 1; d.value = 100;
CHECK(rank_score(1000, d) == 1200);
CHECK(rank_score(UINT64_MAX - 10, d) == UINT64_MAX);
d.value = -100; CHECK(rank_score(1000, d) == 1000);
{
uint64_t bytes = 0;
CHECK(swap_physical_bytes_from_blocks(67024888, 58931824, &bytes) == 0);
CHECK(bytes == UINT64_C(4143648768));
CHECK(swap_physical_bytes_from_blocks(10, 11, &bytes) != 0);
CHECK(swap_physical_bytes_from_blocks(UINT64_MAX, 0, &bytes) != 0);
}
puts("PASS timestamped 5/15/60s windows, missing data, signed growth, overflow");
}
static void states(void)
{
config_t c = allowed(); engine_t e; unsigned i;
engine_init(&e);
for (i = 0; i < 10; i++) step(&e, &c, i, 425950, 0);
CHECK(e.state == NORMAL && !e.danger);
for (i = 10; i <= 12; i++) step(&e, &c, i, 100000, 1);
CHECK(e.state == NORMAL);
step(&e, &c, 13, 100000, 1); CHECK(e.state == PRE_PRESSURE);
for (i = 14; i <= 20; i++) step(&e, &c, i, 100000, 1);
CHECK(e.state == PRESSURE && !e.danger);
step(&e, &c, 21, 60000, 1); step(&e, &c, 22, 59000, 1);
CHECK(!e.danger);
step(&e, &c, 23, 58000, 1); CHECK(e.state == CRITICAL && e.danger);
step(&e, &c, 24, 140000, 0); CHECK(e.state == RECOVERY && !e.danger);
for (i = 25; i <= 40; i++) step(&e, &c, i, 140000, 0);
CHECK(e.state == RECOVERY); /* below 10% margin */
for (i = 41; i <= 56; i++) step(&e, &c, i, 150000, 0);
CHECK(e.state == NORMAL);
step(&e, &c, 57, 40000, 0); CHECK(e.state == EMERGENCY && e.danger && !e.floor);
step(&e, &c, 58, 20000, 0); CHECK(e.floor);
engine_init(&e);
for (i = 0; i < 20; i++) step(&e, &c, i, 60000, 0);
CHECK(!e.danger); /* low but stable, no scanner */
engine_init(&e);
step(&e, &c, 0, 60000, 1); step(&e, &c, 20, 59000, 1);
CHECK(!e.danger); /* gaps do not count as sustained samples */
engine_gap(&e); step(&e, &c, 21, 58000, 1); CHECK(!e.danger);
step(&e, &c, 22, 57000, 1); step(&e, &c, 23, 56000, 1); CHECK(e.danger);
{
sample_t s = sample(1, 60000, 0);
delta_t none = {0, 0, 0}, rising = {1, 5, 4096};
engine_init(&e); engine_step(&e, &c, &s, none, rising, none);
CHECK(!e.danger && e.critical_since < 0); /* physical swap alone is inert */
engine_init(&e); engine_step(&e, &c, &s, none, none, rising);
CHECK(!e.danger && e.critical_since < 0); /* pageout alone is inert */
s.free = 150000;
engine_init(&e); engine_step(&e, &c, &s, none, rising, rising);
CHECK(e.state == NORMAL && e.low_since < 0); /* corroboration needs low memory */
s.free = 60000;
engine_init(&e);
for (i = 0; i <= 3; i++) {
s.at = i; engine_step(&e, &c, &s, none, rising, rising);
}
CHECK(e.state == CRITICAL && e.danger);
}
puts("PASS all six states, corroboration, hysteresis, recovery and sampling gaps");
}
static void actions(void)
{
config_t c = allowed(); engine_t e; controller_t a = {0};
process_t p = process(123); sample_t s; plan_t plan; unsigned i;
engine_init(&e); e.state = CRITICAL; e.danger = 1;
s = sample(100, 60000, 1);
plan = action_plan(&a, &c, &e, &s, &p); CHECK(plan.action == TERM);
action_commit(&a, &c, &s, &plan); CHECK(a.has_pending && a.nvictims == 1);
printf("SIMULATED t=100 state=CRITICAL UUID=%s pid=123 WOULD_ACTION=SIGTERM\n", uuid);
s.at = 104; plan = action_plan(&a, &c, &e, &s, &p); CHECK(plan.action == NONE);
s.at = 105; plan = action_plan(&a, &c, &e, &s, &p); CHECK(plan.action == KILL);
action_commit(&a, &c, &s, &plan); CHECK(!a.has_pending && a.nvictims == 1);
puts("SIMULATED t=105 state=CRITICAL pid=123 WOULD_ACTION=SIGKILL reason=memory_not_recovered");
p.pid++; s.at = 106; CHECK(action_plan(&a, &c, &e, &s, &p).action == NONE);
e.state = EMERGENCY; s.free = 40000;
plan = action_plan(&a, &c, &e, &s, &p); CHECK(plan.action == TERM);
action_commit(&a, &c, &s, &plan);
s.at = 106.5; CHECK(action_plan(&a, &c, &e, &s, &p).action == NONE);
s.at = 107; CHECK(action_plan(&a, &c, &e, &s, &p).action == KILL);
s.free = 20000; e.floor = 1;
plan = action_plan(&a, &c, &e, &s, &p); CHECK(plan.action == KILL);
action_commit(&a, &c, &s, &plan);
p.pid++; s.at++;
plan = action_plan(&a, &c, &e, &s, &p); CHECK(plan.action == KILL);
action_commit(&a, &c, &s, &plan);
puts("SIMULATED state=EMERGENCY floor=1 WOULD_ACTION=SIGKILL reason=floor");
p.pid++; s.at++;
plan = action_plan(&a, &c, &e, &s, &p); CHECK(plan.action == KILL);
action_commit(&a, &c, &s, &plan);
p.pid++; s.at++;
CHECK(action_plan(&a, &c, &e, &s, &p).action == NONE); /* even at floor */
e.state = NORMAL; e.danger = 0; s.free = 160000;
(void)action_plan(&a, &c, &e, &s, &p); CHECK(a.nvictims == 0);
e.state = EMERGENCY; e.danger = 1; s.free = 20000;
CHECK(action_plan(&a, &c, &e, &s, &p).action == NONE); /* minute budget persists */
s.at = 180; CHECK(action_plan(&a, &c, &e, &s, &p).action == KILL);
memset(&a, 0, sizeof (a)); e.floor = 0; e.state = CRITICAL; s.free = 60000;
plan = action_plan(&a, &c, &e, &s, &p); action_commit(&a, &c, &s, &plan);
s.free = 140000; e.danger = 0; e.state = RECOVERY;
CHECK(action_plan(&a, &c, &e, &s, &p).action == NONE && !a.has_pending);
for (i = 0; i < 2; i++) {
c.armed = (int)i; memset(&a, 0, sizeof (a));
e.danger = 1; e.state = EMERGENCY; e.floor = 1; s.free = 1000;
p.zid = 0; CHECK(action_plan(&a, &c, &e, &s, &p).action == NONE);
p.zid = 9; c.zones[0].policy = PROTECT;
CHECK(action_plan(&a, &c, &e, &s, &p).action == NONE);
c.zones[0].policy = ALLOW;
}
puts("PASS TERM/KILL sequencing, emergency grace, floor, cooldown, budgets and no-action protections");
}
int main(void)
{
configs(); protections(); histories(); states(); actions();
printf("PASS %u checks; no native backend linked, no signals sent\n", checks);
return 0;
}

+ 133
- 0
tests/test_runtime.c 查看文件

@@ -0,0 +1,133 @@
/* Exercise the actual daemon selection/action path. Native backend NOT linked. */
#define main guard_program_main
#include "../src/main.c"
#undef main

static double fake_now = 100;
static sample_t fake_sample;
static process_t fixtures[8];
static unsigned nfixtures, real_requests, validation_requests, checks;
static int fail_validation, fail_sample, partial;
#define CHECK(x) do { if (!(x)) { fprintf(stderr, "FAIL runtime line %d: %s\n", __LINE__, #x); exit(1); } checks++; } while (0)

double monotime(void) { return fake_now; }
int secure_file(const char *p, int flags, unsigned mode)
{ (void)p; (void)flags; (void)mode; errno = EPERM; return -1; }
int secure_directory(const char *p) { (void)p; return -1; }
int platform_init(void) { return 0; }
void platform_fini(void) { }
int platform_sample(sample_t *s)
{ *s = fake_sample; s->at = fake_now; return fail_sample ? -1 : 0; }
int platform_walk(int (*fn)(const process_t *, void *), void *arg, unsigned budget)
{
unsigned j; (void)budget;
for (j = 0; j < nfixtures; j++) if (fn(&fixtures[j], arg)) return -1;
return partial ? -1 : 0;
}
int platform_signal(const config_t *c, const process_t *p, enum action action,
int real, uint64_t ceiling)
{
CHECK(action == TERM || action == KILL);
CHECK(!protected_process(c, p));
if (real) { CHECK(c->armed && fake_sample.free < ceiling); real_requests++; }
else validation_requests++;
if (fail_validation) { errno = ESTALE; return -1; }
return 0; /* This test backend cannot send any signal. */
}
static void reset(void)
{
defaults(&cfg); cfg.log_mib = 100;
cfg.nzones = 2;
strcpy(cfg.zones[0].uuid, "11111111-1111-4111-8111-111111111111");
strcpy(cfg.zones[0].alias, "web-a"); cfg.zones[0].policy = ALLOW;
strcpy(cfg.zones[1].uuid, "22222222-2222-4222-8222-222222222222");
strcpy(cfg.zones[1].alias, "web-b"); cfg.zones[1].policy = ALLOW;
memset(processes, 0, sizeof (processes)); memset(zone_stats, 0, sizeof (zone_stats));
memset(metrics, 0, sizeof (metrics));
memset(&controller, 0, sizeof (controller)); memset(fixtures, 0, sizeof (fixtures));
engine_init(&engine); engine.state = CRITICAL; engine.danger = 1;
stopping = 0; started = 0; log_ok = 1; log_bytes = 0;
scan_generation = 0; fake_now = 100; partial = fail_sample = fail_validation = 0;
real_requests = validation_requests = 0;
memset(&fake_sample, 0, sizeof (fake_sample)); fake_sample.at = fake_now;
fake_sample.free = 60000; fake_sample.lots = 130875;
fake_sample.des = 65437; fake_sample.min = 49077;
fixtures[0].pid = 123; fixtures[0].ppid = 100; fixtures[0].zid = 9;
fixtures[0].initpid = 99; fixtures[0].nlwp = 1; fixtures[0].start_sec = 1000;
fixtures[0].zone_generation = 20; fixtures[0].rss_kib = 100000;
strcpy(fixtures[0].uuid, cfg.zones[0].uuid); strcpy(fixtures[0].name, "php-fpm");
nfixtures = 1;
scan_processes(0);
}

int main(void)
{
FILE *log = tmpfile(); char contents[65536]; size_t n;
const process_t *chosen;
unsigned second;
CHECK(log != NULL); logfd = fileno(log);
reset(); act(&fake_sample);
CHECK(real_requests == 0 && validation_requests == 1 && controller.has_pending);
fake_now += 5; scan_processes(0); act(&fake_sample);
CHECK(real_requests == 0 && !controller.has_pending);
rewind(log); n = fread(contents, 1, sizeof (contents) - 1, log); contents[n] = 0;
CHECK(strstr(contents, "WOULD_ACTION=SIGTERM") != NULL);
CHECK(strstr(contents, "WOULD_ACTION=SIGKILL") != NULL);
CHECK(strstr(contents, "ACTION_INTENT=") == NULL);
CHECK(fseek(log, 0, SEEK_END) == 0);
puts("PASS actual daemon dry-run path records WOULD_ACTION=SIGTERM/SIGKILL; zero real requests");
reset();
fake_sample.arc_ok = 1;
fake_sample.swap_virtual_ok = fake_sample.swap_physical_ok = fake_sample.pageout_ok = 1;
for (second = 0; second <= 60; second++) {
fake_sample.at = 100 + second;
fake_sample.arc = UINT64_C(6000000000) + 1000 * second;
fake_sample.swap_alloc = 7000000 + 3 * second;
fake_sample.swap_reserved = 480000 + 2 * second;
fake_sample.swap_available = 6200000 - second;
fake_sample.swap_physical_used_bytes = UINT64_C(4143648768) + 4096 * second;
fake_sample.anon_pageout_pages = 1000 + 2 * second;
record_metrics(&fake_sample);
}
log_sample(&fake_sample);
CHECK(fflush(log) == 0 && fseek(log, 0, SEEK_SET) == 0);
n = fread(contents, 1, sizeof (contents) - 1, log); contents[n] = 0;
CHECK(strstr(contents, "swap_committed_ok=1 swap_committed_pages=7480300") != NULL);
CHECK(strstr(contents, "swap_physical_used_ok=1 swap_physical_used_bytes=4143894528") != NULL);
CHECK(strstr(contents, "anon_pageout_ok=1 anon_pageout_pages=1120") != NULL);
CHECK(strstr(contents, "swap_alloc_pages=7000180 swap_reserved_pages=480120 swap_available_pages=6199940") != NULL);
CHECK(strstr(contents, "metric=swap_committed_pages d5=+25@5.00s d15=+75@15.00s d60=+300@60.00s") != NULL);
CHECK(strstr(contents, "metric=swap_physical_used_bytes d5=+20480@5.00s d15=+61440@15.00s d60=+245760@60.00s") != NULL);
CHECK(strstr(contents, "metric=anon_pageout_pages d5=+10@5.00s d15=+30@15.00s d60=+120@60.00s") != NULL);
CHECK(strstr(contents, "swap_used_pages") == NULL);
CHECK(fseek(log, 0, SEEK_END) == 0);
puts("PASS continuous sample contract and 5/15/60 deltas for commitment, physical swap and pageout");
reset(); cfg.armed = 1; act(&fake_sample);
CHECK(real_requests == 1);
fake_now += 5; scan_processes(0); act(&fake_sample); CHECK(real_requests == 2);
reset(); cfg.armed = 1; started = 80; act(&fake_sample); CHECK(real_requests == 0);
reset(); cfg.armed = 1; log_ok = 0; act(&fake_sample); CHECK(real_requests == 0);
reset(); cfg.armed = 1; partial = 1; scan_processes(0); act(&fake_sample); CHECK(real_requests == 0);
reset(); cfg.armed = 1; fail_sample = 1; act(&fake_sample); CHECK(real_requests == 0);
reset(); cfg.armed = 1; fail_validation = 1; act(&fake_sample); CHECK(real_requests == 0);
reset(); cfg.armed = 1; fake_sample.free = 150000; act(&fake_sample); CHECK(real_requests == 0);
reset(); cfg.armed = 1; fake_now += 3; act(&fake_sample); CHECK(real_requests == 0);
reset(); cfg.armed = 1; stopping = 1; act(&fake_sample); CHECK(real_requests == 0);
reset(); fake_sample.free = 20000; act(&fake_sample);
CHECK(!controller.has_pending && controller.nvictims == 1);
puts("PASS armed-path mock requests, warmup, stale cache, logging/sampling failures, stop and fresh floor");
reset(); fixtures[1] = fixtures[0]; fixtures[1].pid = 124;
fixtures[1].rss_kib = 900000; fixtures[1].zid = 10;
strcpy(fixtures[1].uuid, cfg.zones[1].uuid); nfixtures = 2;
fake_now = 101; scan_processes(0); chosen = candidate(); CHECK(chosen && chosen->pid == 124);
/* Small zone grows fast enough to outweigh a larger steady zone. */
fixtures[0].rss_kib = 600000; fake_now = 161; scan_processes(0);
chosen = candidate(); CHECK(chosen && chosen->pid == 123);
cfg.zones[0].policy = PROTECT; fake_now = 162; scan_processes(0);
chosen = candidate(); CHECK(chosen && chosen->pid == 124);
fixtures[1].zid = 0; fake_now = 163; scan_processes(0); CHECK(candidate() == NULL);
puts("PASS actual daemon ranking by zone/PID RSS and growth; protected zones and global zone excluded");
fclose(log);
printf("PASS %u runtime checks; all platform calls mocked, no signals sent\n", checks);
return 0;
}

Loading…
取消
儲存