Crafter.Review/interfaces/Crafter.Review-Memory.cppm
Jorijn van der Graaf 53b7d6de31
fix: three errors that ended with the wrong exit code
Found compiling the environment-variable table for AGENTS.md:
- A REVIEW_GATES_MEM_RESERVE_MB that is no number threw
  std::invalid_argument, which nothing caught: the process aborted where
  it should end with a ValueError, exit 1. nan and inf were an unchecked
  cast. They raise ValueError / OverflowError now; a value past int64
  saturates.
- An env file that is not UTF-8: `run` ended with exit 1 where its
  preflight should stop it (exit 3), and `seats` parsed the raw bytes. Both
  read it as strict UTF-8 text now.
- A broker address with a port past 65535 was cut to 16 bits; it is an
  OverflowError now, uncaught (exit 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:35:26 +01:00

89 lines
4.3 KiB
C++

// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module Crafter.Review:Memory;
import std;
import :Json;
// Memory (2026-10-22, Jorijn: "make the concurrency limit from free memory,
// instead of just guessing"): what the machine has free, what a pass costs,
// and the budget the pool admits passes against.
//
// - Free: the kernel's MemAvailable, read at the start of every gate and
// before every launch: the whole machine's, which is right for a budget and
// wrong for a cost.
// - Cost: the peak PSS of a pass's own process tree (claude, and for the
// browser environments the Playwright server and Chromium under it), sampled
// by the driver on its own passes and kept per repository and environment
// (Calibration); a pass is admitted at the highest of its environment's
// recent peaks, or at the measured default before any is recorded.
// - Reserve: what is left for the desktop and the page cache —
// REVIEW_GATES_MEM_RESERVE_MB, by default 10 % of MemTotal, at least 2 GB.
// A policy, not a measurement.
export namespace Crafter::Review::Memory {
// Recent peaks kept per repository and environment.
constexpr std::size_t Keep = 30;
// MemAvailable in MB; nullopt without /proc/meminfo.
std::optional<std::int64_t> Available(const std::filesystem::path& path = "/proc/meminfo");
// REVIEW_GATES_MEM_RESERVE_MB, else 10 % of MemTotal, at least 2048.
// Dyn::Error for a value that is no number (ValueError) or is infinite
// (OverflowError); a value past int64 saturates.
std::int64_t Reserve(const std::optional<std::string>& configured, const std::filesystem::path& path = "/proc/meminfo");
std::int64_t Reserve();
// The peak tree PSS of every watched pass, on one thread. A sweep makes one
// ppid map, then reads smaps_rollup for every process of every watched tree
// — the costly read, it walks the page tables. The pause after a sweep is
// `interval` or three times the sweep's own length, whichever is longer, so
// sampling holds at most a quarter of one core whatever the number of
// passes (2026-10-22). A pass shorter than one pause may go unmeasured.
class Sampler {
public:
using Measure = std::function<double(std::int64_t pid, const std::map<std::int64_t, std::vector<std::int64_t>>& kids)>;
using Scan = std::function<std::map<std::int64_t, std::vector<std::int64_t>>()>;
explicit Sampler(double interval = 3.0, Measure measure = {}, Scan scan = {});
~Sampler();
Sampler(const Sampler&) = delete;
Sampler& operator=(const Sampler&) = delete;
void Watch(const std::string& key, std::int64_t pid);
// One sweep over every watched pass; its length in seconds.
double Sample();
// Stop watching; the peak in MB, rounded to 0.1, or nullopt when
// nothing was measured.
std::optional<double> Done(const std::string& key);
static double Pause(double interval, double sweep);
private:
struct State;
std::shared_ptr<State> state;
};
// Recent peaks per repository and environment, in one JSON file next to the
// seat ledger: {repo: {env: [MB]}}. A record, never a dependency
// (2026-10-22): a damaged file reads as empty, a part of the wrong shape as
// absent, and a peak that cannot be written is dropped.
class Calibration {
public:
Calibration(std::filesystem::path path, const std::filesystem::path& repo, std::map<std::string, std::int64_t> defaults);
std::vector<Json> Peaks(std::string_view environment) const;
struct Cost {
// MB, an int or a float as the file holds it.
Json megabytes;
std::string source;
};
Cost CostOf(std::string_view environment) const;
// Keep a pass's peak; whether it was stored. The file is every
// driver's on this machine: the re-read and the write are one section
// under its lock (2026-10-31), so a peak another driver wrote between
// them is kept.
bool Record(std::string_view environment, double megabytes);
private:
std::filesystem::path path;
std::string repo;
std::map<std::string, std::int64_t> defaults;
mutable std::mutex lock;
Json Load() const;
};
}