diff --git a/logger.py b/logger.py index 415e535..869ccd7 100755 --- a/logger.py +++ b/logger.py @@ -20,6 +20,7 @@ header -- see capture.py. import argparse import csv +import os import queue import sys import threading @@ -51,6 +52,53 @@ FLUSH_INTERVAL_S = 0.5 # inside the bracket budget. GIL_SWITCH_INTERVAL_S = 0.0005 +# Thread priority. The sampling loop runs on the main thread and is the only +# latency-sensitive part; the writer merely formats a few fields per sample. +# +# Lowering the writer needs no privilege and is always done: only the *relative* +# priority matters, and on Linux nice is per-thread, so it does not touch the +# sampler. Raising the sampler needs CAP_SYS_NICE and is opt-in via +# --high-priority, which fails loudly rather than degrading silently -- asking +# for it and not getting it is worth knowing about. +SAMPLER_NICE = -10 +WRITER_NICE = 10 + + +def restore_ownership(path): + """Hand a file created under sudo back to the invoking user. + + --high-priority needs root, and anything root writes stays root-owned -- + which earlier in this project produced captures the normal user could not + rewrite. Undo that here so privilege is needed for scheduling and nothing + else leaks from it. + """ + if os.geteuid() != 0: + return + uid, gid = os.environ.get("SUDO_UID"), os.environ.get("SUDO_GID") + if uid is None: + return + try: + os.chown(path, int(uid), int(gid) if gid else -1) + except OSError as exc: + print(f"WARNING: could not hand {path} back to uid {uid}: {exc}", + file=sys.stderr) + + +def set_thread_nice(value): + """Renice the calling thread. Returns the nice actually in effect, or None. + + Linux threads are tasks, so PRIO_PROCESS with a native thread id applies to + just this thread. Elsewhere this may be a no-op or affect the process, which + is why failure is tolerated rather than fatal -- priority is an optimisation, + not a correctness requirement. + """ + try: + tid = threading.get_native_id() + os.setpriority(os.PRIO_PROCESS, tid, value) + return os.getpriority(os.PRIO_PROCESS, tid) + except (AttributeError, OSError): + return None + # Miss detection uses the DRDY bracket -- the span between the last poll showing # DRDY clear and the poll showing it set -- rather than the read-to-read # interval. @@ -142,12 +190,17 @@ def parse_args(): help="free-text label recorded in the capture header, e.g. " "the supply under test. Keeps the configuration with the " "data instead of only in the filename") + p.add_argument("--high-priority", action="store_true", + help=f"raise the sampling thread to nice {SAMPLER_NICE}. " + "Needs CAP_SYS_NICE, so run under sudo; the run aborts " + "if the priority cannot be set. Output files are handed " + "back to the invoking user afterwards") p.add_argument("--scan-only", action="store_true", help="scan the bus, report what responded, and exit") return p.parse_args() -def print_plan(cfg, bus_speed, requested_rate): +def print_plan(cfg, bus_speed, requested_rate, sampler_nice=None): """Show how the configuration was derived, so it can be checked not trusted.""" lsb = rm3100.tesla_per_count(cfg.cycle_count) * rm3100.NT_PER_TESLA dither = rm3100.expected_noise_nt(cfg.cycle_count) / lsb @@ -172,6 +225,12 @@ def print_plan(cfg, bus_speed, requested_rate): print(f" duty {cfg.duty:.1%} integration / period") print(f" bus {bus_speed} kHz {bus * 1e3:.3f} ms/sample " f"{share:.1%} of the period") + if sampler_nice is not None: + detail = (f"nice {sampler_nice}, writer at {WRITER_NICE}" + if sampler_nice < 0 else + f"nice {sampler_nice}, writer at {WRITER_NICE} " + "(--high-priority raises the sampler, needs sudo)") + print(f" priority {detail}") for note in cfg.notes: print(f" note: {note}") # Bus speed is validated but never changed silently -- swapping it would @@ -249,8 +308,11 @@ def write_header(handle, meta): def writer_thread(q, path, meta, stats): """Drain samples to CSV and drive the console, off the sampling thread.""" + # Stand aside for the sampler: this thread is not latency-sensitive. + set_thread_nice(WRITER_NICE) lsb_nt = float(meta["tesla_per_count"]) * rm3100.NT_PER_TESLA with open(path, "w", newline="") as handle: + restore_ownership(path) write_header(handle, meta) out = csv.writer(handle) out.writerow(CSV_FIELDS) @@ -504,6 +566,24 @@ def sample_loop(sensor, q, duration, dt_nominal, stats): def main(): args = parse_args() sys.setswitchinterval(GIL_SWITCH_INTERVAL_S) + sampler_nice = None + if args.high_priority: + sampler_nice = set_thread_nice(SAMPLER_NICE) + if sampler_nice is None: + sys.exit( + f"--high-priority needs CAP_SYS_NICE to set nice " + f"{SAMPLER_NICE}, and this process does not have it.\n" + "Re-run under sudo, using the venv interpreter by absolute " + "path:\n" + f" sudo {sys.executable} {' '.join(sys.argv)}\n" + "Or drop the flag -- the writer thread already steps aside, " + f"which is the half that needs no privilege.") + else: + try: + sampler_nice = os.getpriority(os.PRIO_PROCESS, + threading.get_native_id()) + except (AttributeError, OSError): + pass if args.cycle_count is not None and not 1 <= args.cycle_count <= 0xFFFF: sys.exit(f"--cycle-count {args.cycle_count} outside 1..65535") @@ -538,7 +618,7 @@ def main(): sys.exit(str(exc)) print() - print_plan(cfg, args.bus_speed, args.rate) + print_plan(cfg, args.bus_speed, args.rate, sampler_nice) print() nominal_rate = cfg.predicted_hz