ppad-censor-0.5.1: lib/Censor/Runner/Env.hs
{-# OPTIONS_HADDOCK prune #-}
-- |
-- Module: Censor.Runner.Env
-- Copyright: (c) 2026 Jared Tobin
-- License: MIT
-- Maintainer: Jared Tobin <jared@ppad.tech>
--
-- Host-environment capture.
--
-- An t'Env' is a point-in-time snapshot of the machine a censor run
-- executed on: platform identity (OS, architecture, kernel release,
-- CPU model, core count), the timing-relevant tuning knobs visible
-- under Linux sysfs (cpufreq governor, turbo\/boost state, SMT
-- control, @perf_event_paranoid@), the load average, and a UTC
-- timestamp.
--
-- Capture is best-effort: fields whose sources are absent on the
-- host (e.g. the sysfs knobs on Darwin) are 'Nothing'. Conditions
-- affect the /power/ of a run, not its validity — the paired,
-- order-randomised design cancels common-mode drift — so the
-- snapshot exists to make reports interpretable and provenanced,
-- not to gate execution.
module Censor.Runner.Env (
-- * Environment snapshot
Env(..)
, captureEnv
) where
import Control.Exception (try, IOException)
import qualified Data.ByteString.Char8 as B8
import Data.Char (isSpace)
import Data.List (dropWhileEnd, isPrefixOf)
import Foreign.C.String (peekCString)
import Foreign.C.Types (CChar, CDouble, CInt(..), CSize(..))
import Foreign.Marshal.Alloc (allocaBytes)
import Foreign.Marshal.Array (allocaArray, peekArray)
import Foreign.Ptr (Ptr)
import GHC.Conc (getNumProcessors)
import qualified System.Info as Info
foreign import ccall unsafe "censor_env_loadavg"
c_env_loadavg :: Ptr CDouble -> IO CInt
foreign import ccall unsafe "censor_env_kernel"
c_env_kernel :: Ptr CChar -> CSize -> IO CInt
foreign import ccall unsafe "censor_env_time"
c_env_time :: Ptr CChar -> CSize -> IO CInt
foreign import ccall unsafe "censor_env_cpu"
c_env_cpu :: Ptr CChar -> CSize -> IO CInt
foreign import ccall unsafe "censor_env_cores"
c_env_cores :: IO CInt
-- | A host-environment snapshot, taken once at run start.
--
-- The Linux knob fields ('envGovernor', 'envBoost', 'envSmt',
-- 'envParanoid') hold the raw sysfs\/procfs file contents; they
-- are 'Nothing' wherever the file does not exist (in particular,
-- everywhere but Linux).
data Env = Env
{ envOs :: !String
-- ^ operating system, per @System.Info.os@.
, envArch :: !String
-- ^ architecture, per @System.Info.arch@.
, envKernel :: !(Maybe String)
-- ^ kernel release, per @uname(2)@.
, envCpu :: !(Maybe String)
-- ^ CPU model string (Darwin @sysctl@, or the first @model name@
-- row of Linux @\/proc\/cpuinfo@; absent on most aarch64 Linux
-- kernels).
, envCores :: !Int
-- ^ logical core count.
, envGovernor :: !(Maybe String)
-- ^ Linux cpufreq scaling governor of cpu0.
, envBoost :: !(Maybe String)
-- ^ Linux turbo\/boost state, recorded verbatim as @key=value@
-- (@boost=1@ is boost-enabled; @no_turbo=1@ is boost-disabled)
-- to avoid interpreting the two opposing conventions.
, envSmt :: !(Maybe String)
-- ^ Linux SMT control state (@on@ \/ @off@ \/ ...).
, envParanoid :: !(Maybe String)
-- ^ Linux @kernel.perf_event_paranoid@ level.
, envLoad :: !(Maybe (Double, Double, Double))
-- ^ 1\/5\/15-minute load averages at capture time.
, envTime :: !(Maybe String)
-- ^ capture time, UTC ISO-8601.
} deriving (Eq, Show)
-- | Snapshot the host environment. Cheap (a few syscalls and file
-- reads); call once at run start.
captureEnv :: IO Env
captureEnv = do
cores <- captureCores
kern <- cString 256 c_env_kernel
cpu <- captureCpu
gov <- readKnob
"/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor"
boost <- captureBoost
smt <- readKnob "/sys/devices/system/cpu/smt/control"
para <- readKnob "/proc/sys/kernel/perf_event_paranoid"
load <- captureLoad
time <- cString 64 c_env_time
pure Env
{ envOs = Info.os
, envArch = Info.arch
, envKernel = kern
, envCpu = cpu
, envCores = cores
, envGovernor = gov
, envBoost = boost
, envSmt = smt
, envParanoid = para
, envLoad = load
, envTime = time
}
-- run a C fill-buffer shim, returning the trimmed NUL-terminated
-- contents on success and Nothing on failure.
cString :: Int -> (Ptr CChar -> CSize -> IO CInt) -> IO (Maybe String)
cString n fill = allocaBytes n $ \buf -> do
rc <- fill buf (fromIntegral n)
if rc == 0
then fmap nonEmpty (peekCString buf)
else pure Nothing
-- sysconf via the shim, because GHC's getNumProcessors reports 1 on
-- the non-threaded RTS; that stays as the fallback.
captureCores :: IO Int
captureCores = do
rc <- c_env_cores
if rc >= 1
then pure (fromIntegral rc)
else getNumProcessors
captureLoad :: IO (Maybe (Double, Double, Double))
captureLoad = allocaArray 3 $ \p -> do
rc <- c_env_loadavg p
if rc == 3
then do
vals <- peekArray 3 p
case map realToFrac vals of
[l1, l5, l15] -> pure (Just (l1, l5, l15))
_ -> pure Nothing
else pure Nothing
captureCpu :: IO (Maybe String)
captureCpu = do
s <- cString 256 c_env_cpu
case s of
Just _ -> pure s
Nothing -> cpuFromProcinfo
-- first "model name" row of /proc/cpuinfo (x86 Linux; most aarch64
-- kernels have no such row and the field stays Nothing).
cpuFromProcinfo :: IO (Maybe String)
cpuFromProcinfo = do
mc <- readKnob "/proc/cpuinfo"
pure $ case mc of
Nothing -> Nothing
Just s ->
case filter ("model name" `isPrefixOf`) (lines s) of
(l : _) -> nonEmpty (drop 1 (dropWhile (/= ':') l))
[] -> Nothing
captureBoost :: IO (Maybe String)
captureBoost = do
b <- readKnob "/sys/devices/system/cpu/cpufreq/boost"
case b of
Just v -> pure (Just ("boost=" ++ v))
Nothing -> do
t <- readKnob "/sys/devices/system/cpu/intel_pstate/no_turbo"
pure (fmap ("no_turbo=" ++) t)
-- read a small sysfs/procfs file, trimmed; Nothing when absent,
-- unreadable, or empty. Strict read so no handle outlives the call.
readKnob :: FilePath -> IO (Maybe String)
readKnob p = do
er <- try (B8.readFile p) :: IO (Either IOException B8.ByteString)
pure $ case er of
Left _ -> Nothing
Right b -> nonEmpty (B8.unpack b)
nonEmpty :: String -> Maybe String
nonEmpty s =
let t = dropWhileEnd isSpace (dropWhile isSpace s)
in if null t then Nothing else Just t