diff --git a/CHANGELOG.md b/CHANGELOG.md index 017405813..9fdfbf038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,22 @@ differs, and no reader depends on it (a cache written by the previous release gi answers). The Python import gate checks fresh/warm output and, when `RIPWIRE_BASE_BIN` is supplied, acceptance of a pristine-main cache without rewriting it. +### Fixed — on Linux the memory guard read only the resident set, while the cgroup limit kills on the cgroup's charge (#387) + +The guard's default limit already came from the cgroup v2 `memory.max` of ripwire's own leaf, but its readings came +from `/proc/self/statm`: this process's resident pages. The OOM killer acts on what the leaf is charged for, which also +counts every other process in the leaf. When the leaf has a finite `memory.max`, the guard now measures the larger of +the resident set and the leaf's unreclaimable charge: `memory.current` less reclaimable page cache (`memory.stat` +`file` minus `shmem`). Page cache is left out because the kernel drops it before it kills; #387's reporter saw about +4.2 GB of it under `--quality-delta`, from the two extracted commit trees. A leaf with no limit, a limit on an ancestor +cgroup, and cgroup v1 are unchanged: the resident set alone. Gate: `memguardcheck` (B24), which needs a leaf with a +`memory.max` of its own: it runs in a 1 GiB child of a delegated parent cgroup, or in a transient systemd user scope, +so no other gate's charge decides it and its 320 MiB counts against no other gate; it skips by name elsewhere. A sibling holding 320 MiB of anonymous +memory puts the leaf over `--max-memory=256M` (exit 5; the 0.6.7 rule answers, exit 0), and 320 MiB of page cache +does not (exit 0). `--help` says what is measured, and the MCP refusal at the hard line names the cgroup, not this +server, when the cgroup's charge is the larger reading: restarting the server does not help there. + + ## [0.6.7] — 2026-10-11 ### Fixed — `--impact`: a selector matching several definitions keeps the `via="name"` hedge in its per-file rollup diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index b4217969e..a5ab7628a 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -4847,7 +4847,7 @@ $ ./build/ripwire . --max-file-size=8K --top-k=3 ### `--max-memory=N[K|M|G]` -**Answers:** the memory guard's limit (default 65% of this machine's memory, env RIPWIRE_MAX_MEMORY) the memory guard is on for every run and silent on normal ones: it measures this process's footprint at most once per 5 s, from 5 s into an ingest. +**Answers:** the memory guard's limit (default 65% of this machine's memory, env RIPWIRE_MAX_MEMORY) the memory guard is on for every run and silent on normal ones: it measures this process's footprint (on Linux, in a cgroup v2 leaf with a memory.max, the leaf's unreclaimable charge when that is larger) at most once per 5 s, from 5 s into an ingest. Past its lines the crawl (growth of limit/8) or the parse (half the limit) stops, and the default map answers from what was built, disclosed in its header (memory_stop=, memory_parsed=, memory_limit=; a parse stop keeps the first K slots it claimed of its work order — uncached files first, largest first within a tier, or path order when every grammar-bearing file was an ingest-cache hit — so a partial map repeats for a given memory_parsed=K); critical OS pressure stops them too. Every other verb (--html, --mermaid, --expand, --outline and, after a crawl stop, --in included) refuses a partial index, and so does any verb whose own internal ingest was cut; at the limit itself ripwire exits 5 with one line naming it. Nothing derived from a partial ingest is cached. The default is 65% of physical RAM (or of the cgroup's memory.max when lower); this flag, or RIPWIRE_MAX_MEMORY when the flag is absent, replaces it; below 64M is refused. @@ -4855,9 +4855,9 @@ Past its lines the crawl (growth of limit/8) or the parse (half the limit) stops **Caveats (stated by the binary):** -- the memory guard's limit (default 65% of this machine's memory, env RIPWIRE_MAX_MEMORY) the memory guard is on for every run and silent on normal ones: it measures this process's footprint at most once per 5 s, from 5 s into an ingest. - Past its lines the crawl (growth of limit/8) or the parse (half the limit) stops, and the default map answers from what was built, disclosed in its header (memory_stop=, memory_parsed=, memory_limit=; - Every other verb (--html, --mermaid, --expand, --outline and, after a crawl stop, --in included) refuses a partial index, and so does any verb whose own internal ingest was cut; +- at the limit itself ripwire exits 5 with one line naming it. ### `--refetch` diff --git a/src/cli.h b/src/cli.h index cb6c9acbc..8dd92e8ee 100644 --- a/src/cli.h +++ b/src/cli.h @@ -2765,7 +2765,9 @@ inline constexpr char kHelpTail[] = " table); files it drops are counted in the header's skipped_oversize=\n" " --max-memory=N[K|M|G] the memory guard's limit (default 65% of this machine's memory, env RIPWIRE_MAX_MEMORY)\n" " the memory guard is on for every run and silent on normal ones: it measures this\n" - " process's footprint at most once per 5 s, from 5 s into an ingest. Past its lines\n" + " process's footprint (on Linux, in a cgroup v2 leaf with a memory.max, the leaf's\n" + " unreclaimable charge when that is larger) at most once per 5 s, from 5 s into an\n" + " ingest. Past its lines\n" " the crawl (growth of limit/8) or the parse (half the limit) stops, and the default\n" " map answers from what was built, disclosed in its header (memory_stop=, memory_parsed=,\n" " memory_limit=; a parse stop keeps the first K slots it claimed of its work order —\n" diff --git a/src/infra/os.h b/src/infra/os.h index efbd3b82f..b5077e458 100644 --- a/src/infra/os.h +++ b/src/infra/os.h @@ -499,13 +499,19 @@ struct dirwatch_event // ── memory: the process footprint, the machine's RAM, and the pressure signal (#350's memory guard) ─────────── // No POSIX call answers any of these, so each is a lowercase helper returning 0 for "this platform cannot say // cheaply" — the guard (src/memguard.h) then falls back to what it can read, and says nothing on a normal run. -// Every body is one syscall or one small /proc read, and the guard calls them at most once per five seconds. +// Every body is one syscall or at most four small /proc and /sys reads, and the guard calls them at most once per five +// seconds, plus once at each hard-line check between phases. // mem_footprint: bytes this process holds. macOS: TASK_VM_INFO phys_footprint (what Activity Monitor and the // jetsam killer count — compressed and swapped pages included). Linux: resident pages from /proc/self/statm. // mem_physical: bytes of physical RAM. macOS hw.memsize; Linux _SC_PHYS_PAGES x _SC_PAGESIZE. // mem_cgroup_max: the cgroup v2 memory.max of this process's OWN (leaf) cgroup (Linux; 0 = none, "max", or unreadable). // Known floor: a limit set on an ANCESTOR cgroup (a systemd slice's MemoryMax) and cgroup v1's memory.limit_in_bytes // are not read, so there the default falls back to physical RAM. +// mem_cgroup_unreclaimable: what that same leaf is charged for and the kernel cannot reclaim (#387) — memory.current less +// its reclaimable page cache, memory.stat's `file` minus `shmem` (tmpfs and shared memory are counted in `file` and +// cannot be dropped). Read only when the leaf has a finite memory.max, because there the OOM killer acts on the +// leaf's charge: it counts every process in the leaf, and anonymous memory the kernel charged to the leaf that +// /proc/self/statm does not show. 0 = no such leaf, no limit on it, or unreadable (Linux only; 0 elsewhere). // mem_pressure: 0 unknown, 1 normal, 2 warning, 3 critical. macOS kern.memorystatus_vm_pressure_level (1/2/4); // Linux /proc/pressure/memory, critical when "full avg10" (every non-idle task stalled on memory, 10 s mean) // reaches 20%, warning when "some avg10" does. Integer percent only — no float parse, no locale. @@ -528,6 +534,7 @@ inline std::uint64_t mem_physical() return ::sysctlbyname( "hw.memsize", &bytes, &len, nullptr, 0 ) == 0 && len == sizeof( bytes ) ? bytes : 0; } inline std::uint64_t mem_cgroup_max() { return 0; } +inline std::uint64_t mem_cgroup_unreclaimable() { return 0; } inline int mem_pressure() { int level = 0; @@ -579,6 +586,42 @@ struct procfs const std::size_t at = text.find( key ); return at == std::string_view::npos ? -1 : leading_int( text.substr( at + key.size() ) ); } + // this process's own cgroup v2 leaf as a /sys/fs/cgroup directory ending in '/', or "" when /proc/self/cgroup has no + // cgroup v2 unified line (v1 hierarchies are not read) + static std::string cgroup_leaf_dir() + { + char buf[ 4096 ]; // a v1 host lists one line per controller before the "0::" line; 4 KiB holds every layout seen + if( read_small( "/proc/self/cgroup", buf, sizeof( buf ) ) == 0 ) + { + return std::string(); + } + const std::string_view text( buf ); + const std::size_t at = text.find( "0::" ); // the cgroup v2 unified line + if( at == std::string_view::npos || ( at != 0 && text[ at - 1 ] != '\n' ) ) + { + return std::string(); + } + std::string_view group = text.substr( at + 3 ); + group = group.substr( 0, group.find( '\n' ) ); + std::string path = "/sys/fs/cgroup"; + path.append( group ); + if( path.back() != '/' ) + { + path.push_back( '/' ); + } + return path; + } + // the number at the start of the small file `dir` + `name`; 0 when absent, unreadable, or not a number ("max") + static std::uint64_t cgroup_leaf_number( const std::string& dir, const char* name ) + { + char value[ 32 ]; + if( read_small( ( dir + name ).c_str(), value, sizeof( value ) ) == 0 ) + { + return 0; + } + const long long number = leading_int( std::string_view( value ) ); + return number > 0 ? std::uint64_t( number ) : 0; + } }; inline std::uint64_t mem_footprint() { @@ -599,33 +642,31 @@ inline std::uint64_t mem_physical() } inline std::uint64_t mem_cgroup_max() { - char buf[ 4096 ]; // a v1 host lists one line per controller before the "0::" line; 4 KiB holds every layout seen - if( procfs::read_small( "/proc/self/cgroup", buf, sizeof( buf ) ) == 0 ) + const std::string dir = procfs::cgroup_leaf_dir(); + return dir.empty() ? 0 : procfs::cgroup_leaf_number( dir, "memory.max" ); // "max\n" reads as 0: no limit +} +inline std::uint64_t mem_cgroup_unreclaimable() +{ + const std::string dir = procfs::cgroup_leaf_dir(); + if( dir.empty() || procfs::cgroup_leaf_number( dir, "memory.max" ) == 0 ) { return 0; } - const std::string_view text( buf ); - const std::size_t at = text.find( "0::" ); // the cgroup v2 unified line; v1 hierarchies are not read - if( at == std::string_view::npos || ( at != 0 && text[ at - 1 ] != '\n' ) ) + const std::uint64_t charged = procfs::cgroup_leaf_number( dir, "memory.current" ); + char stat[ 8192 ]; // memory.stat is ~40-60 short lines; `file` and `shmem` are both in its first dozen on 5.x and 6.x kernels + if( charged == 0 || procfs::read_small( ( dir + "memory.stat" ).c_str(), stat, sizeof( stat ) ) == 0 ) { return 0; } - std::string_view group = text.substr( at + 3 ); - group = group.substr( 0, group.find( '\n' ) ); - std::string path = "/sys/fs/cgroup"; - path.append( group ); - if( path.back() != '/' ) - { - path.push_back( '/' ); - } - path += "memory.max"; - char limit[ 32 ]; - if( procfs::read_small( path.c_str(), limit, sizeof( limit ) ) == 0 ) + const std::string_view text( stat ); + const long long file = procfs::field_after( text, "\nfile " ); // the leading newline keeps file_mapped, file_dirty… out + const long long shmem = procfs::field_after( text, "\nshmem " ); + if( file < 0 || shmem < 0 ) { return 0; } - const long long bytes = procfs::leading_int( std::string_view( limit ) ); // "max\n" reads as -1: no limit - return bytes > 0 ? std::uint64_t( bytes ) : 0; + const std::uint64_t reclaimable = file > shmem ? std::uint64_t( file - shmem ) : 0; + return charged > reclaimable ? charged - reclaimable : 0; } inline int mem_pressure() { @@ -648,6 +689,7 @@ inline int mem_pressure() inline std::uint64_t mem_footprint() { return 0; } inline std::uint64_t mem_physical() { return 0; } inline std::uint64_t mem_cgroup_max() { return 0; } +inline std::uint64_t mem_cgroup_unreclaimable() { return 0; } inline int mem_pressure() { return 0; } #endif @@ -1041,11 +1083,12 @@ int dirwatch_add( int watchFd, int dirFd, dirwatch_event* change ); int dirwatch_poll( int watchFd, dirwatch_event* events, int eventCount, const ::timespec* timeout ); // ── memory (see the POSIX branch): PrivateUsage from GetProcessMemoryInfo, ullTotalPhys from GlobalMemoryStatusEx, -// no cgroup (0), and QueryMemoryResourceNotification on a low-memory notification object created once (3 when the -// system signals low memory, else 1). +// no cgroup (0, both cgroup helpers), and QueryMemoryResourceNotification on a low-memory notification object created +// once (3 when the system signals low memory, else 1). std::uint64_t mem_footprint(); std::uint64_t mem_physical(); [[gnu::always_inline]] inline std::uint64_t mem_cgroup_max() { return 0; } +[[gnu::always_inline]] inline std::uint64_t mem_cgroup_unreclaimable() { return 0; } int mem_pressure(); } // namespace rw::os diff --git a/src/mcp.h b/src/mcp.h index 481af44a2..70f88fc62 100644 --- a/src/mcp.h +++ b/src/mcp.h @@ -2085,14 +2085,20 @@ inline McpDispatchResult dispatchMcpLine( const std::string& line, int topK, boo // reading per tool call; nothing is measured on any other method. // Over the line, the resident index is released first (it is usually most of the footprint) and the // footprint read once more: under the line again, the call proceeds and rebuilds for its own root; still - // over, it is refused and the sentence says the server itself must be restarted. + // over, it is refused, and the sentence names what is over: this server (restart it), or the cgroup leaf + // it runs in when the leaf's charge is the larger reading (memguard::leafChargeDecides). if( const bool seamOver = memguard::requestSeamTrips(); !pathsUsageError && ( seamOver || memguard::overHardLimit() ) ) { releaseMcpIndexMemory(); if( seamOver || memguard::overHardLimit() ) { DISCLOSE( Diagnostics::answerRefused, "mcp: a tool call over the memory guard's hard limit, even with the index released, is refused with an MCP error naming the limit" ); - resp = errResultMsg( -32000, memguard::hardStopLine( "session (this server's footprint)" ) + // #387: on a Linux cgroup leaf with a memory.max the reading may be the leaf's charge, which counts + // every process in it; restarting this server does not bring that back under the line, so say which + resp = memguard::leafChargeDecides() + ? errResultMsg( -32000, memguard::hardStopLine( "session (the charge of the cgroup this server runs in, every process in it counted)" ) + + "; the resident index was released and the cgroup is still over it — free memory held by the other processes in this server's cgroup, or raise the limit" ) + : errResultMsg( -32000, memguard::hardStopLine( "session (this server's footprint)" ) + "; the resident index was released and the server is still over it — restart the MCP server" ); pathsUsageError = true; // the skip-flag: no dispatch, no getIndex() } diff --git a/src/memguard.h b/src/memguard.h index 514a517da..1cc0c0ea6 100644 --- a/src/memguard.h +++ b/src/memguard.h @@ -12,7 +12,8 @@ // never obeyed. When the machine cannot say how much RAM it has the footprint cap is off and only the pressure signal // remains. // -// THE LINES, measured on the process footprint (os::mem_footprint): +// THE LINES, measured on the process footprint (measuredFootprint: os::mem_footprint, or the cgroup leaf's unreclaimable +// charge when the leaf has a memory.max and that is larger): // crawl stops when the footprint has GROWN by limit/8 since this ingest began, or reaches half the limit. The // crawl's own cost is ~1-3 KB per file (#350's measurements), so a crawl that alone reaches an eighth of the // limit is a tree whose parse could never fit; stopping there is what leaves room to answer. @@ -62,7 +63,7 @@ #include #include "infra/Diagnostics.h" // VALIDATE — the environment is external input -#include "infra/os.h" // rw::os::mem_footprint / mem_physical / mem_cgroup_max / mem_pressure +#include "infra/os.h" // rw::os::mem_footprint / mem_physical / mem_cgroup_max / mem_cgroup_unreclaimable / mem_pressure #include "model.h" // MemoryStop — the disclosure sink namespace rw::memguard @@ -107,6 +108,22 @@ inline std::uint64_t defaultLimitBytes() return machine / 100 * 65; } +// the footprint every line is measured on: this process's own (os::mem_footprint), or, when it is larger, the +// unreclaimable charge of the cgroup v2 leaf this process runs in, read only when that leaf has a finite memory.max (#387). +// There the OOM killer acts on the leaf's charge, which counts every process in the leaf and anonymous memory statm +// does not show; the leaf's reclaimable page cache (the extracted commit trees --quality-delta=A..B writes) is left out, +// because the kernel drops it before it kills. An unlimited or unreadable leaf reads 0, so the process footprint decides. +inline std::uint64_t measuredFootprint() +{ + return std::max( os::mem_footprint(), os::mem_cgroup_unreclaimable() ); +} +// true when the cgroup leaf's charge, not this process's own footprint, is the larger figure: a message about the hard line +// then names the leaf, because freeing this process's memory may not bring the leaf back under it +inline bool leafChargeDecides() +{ + return os::mem_cgroup_unreclaimable() > os::mem_footprint(); +} + // the limit as a --max-memory value ("10854M"): what the reader would type to raise it — so always in whole MiB, // rounded up, the spelling every disclosure and message uses inline std::string limitSpelling( std::uint64_t bytes ) @@ -207,7 +224,7 @@ class Watch : trip_( testTrip() ) , hardBytes_( limits().hardBytes ) { - baseFootprint_ = os::mem_footprint(); + baseFootprint_ = measuredFootprint(); nextCheckTick_.store( GateClock::now().time_since_epoch().count() + kCheckIntervalTicks, std::memory_order_relaxed ); } Watch( const Watch& ) = delete; @@ -228,7 +245,7 @@ class Watch { return false; } - const std::uint64_t footprint = os::mem_footprint(); + const std::uint64_t footprint = measuredFootprint(); const bool overLine = hardBytes_ != 0 && footprint != 0 && ( footprint >= softLine() || ( footprint > baseFootprint_ && footprint - baseFootprint_ >= hardBytes_ / 8 ) ); return ( overLine && tripSoft( false ) ) || ( underPressure( footprint ) && tripSoft( true ) ); @@ -251,7 +268,7 @@ class Watch { return; } - const std::uint64_t footprint = os::mem_footprint(); + const std::uint64_t footprint = measuredFootprint(); if( hardBytes_ != 0 && footprint != 0 && footprint >= softLine() ) { (void)tripSoft( false ); @@ -335,7 +352,7 @@ inline bool overHardLimit() return true; } } - const std::uint64_t footprint = os::mem_footprint(); + const std::uint64_t footprint = measuredFootprint(); return footprint != 0 && footprint >= l.hardBytes; } diff --git a/test/memguardcheck.sh b/test/memguardcheck.sh index e57642528..590325810 100755 --- a/test/memguardcheck.sh +++ b/test/memguardcheck.sh @@ -708,6 +708,90 @@ else no "(B12b) stderr: $( grep '^ripwire:' "$TMP/b12.err" | head -c 250 )" fi +# (B24) #387: in a cgroup v2 leaf with a finite memory.max, the guard reads the leaf's unreclaimable charge as well as this +# process's resident set, because the OOM killer acts on the leaf. A sibling process in the same leaf holds 320 MiB +# of anonymous memory, so the leaf is past a 256M limit while ripwire's own resident set is far under it: the hard +# line stops the run (exit 5). Then the same leaf holds 320 MiB of page cache instead, which the kernel can drop: +# the run answers (exit 0). RED on 0.6.7 for the first half (statm only: exit 0). +# ISOLATION. The arm runs in a cgroup of its own, a 1 GiB leaf holding nothing but this arm's processes, so another +# gate's charge cannot decide it and its 320 MiB cannot count against another gate's leaf. Two ways to get one: +# a child of this shell's parent cgroup, when that parent is delegated to this user (cgroup.procs writable, the +# memory controller in its cgroup.subtree_control), or a `systemd-run --user --scope` with MemoryMax. Anywhere +# else (most CI runners, a container without a delegated cgroup, macOS) the arm SKIPs by name. +b24_body="$TMP/b24body.sh" +cat >"$b24_body" <<'B24' +set -u +BIN="$1"; FX="$2"; W="$3"; fail=0 +ok(){ printf ' PASS %s\n' "$*"; } +no(){ printf ' FAIL %s\n' "$*"; fail=1; } +cg="/sys/fs/cgroup$( sed -n 's/^0:://p' /proc/self/cgroup | head -1 )"; cg="${cg%/}" +max="$( cat "$cg/memory.max" 2>/dev/null )"; cur0="$( cat "$cg/memory.current" 2>/dev/null )" +if ! printf '%s' "$max" | grep -qE '^[0-9]+$' || ! printf '%s' "$cur0" | grep -qE '^[0-9]+$' || [ "$cur0" -ge $(( 64 * 1024 * 1024 )) ]; then + no "(B24) premise: the isolated leaf $cg has no finite memory.max ($max) or is not empty enough (memory.current=$cur0)" + exit 1 +fi +cg_stat(){ awk -v k="$1" '$1==k { print $2 }' "$cg/memory.stat"; } +python3 -c 'import sys, time +b = bytes( range( 256 ) ) * ( 320 * 4096 ) +sys.stdout.write( "held\n" ); sys.stdout.flush(); time.sleep( 120 )' >"$W/b24.hold" & +holder=$! +trap 'kill "$holder" 2>/dev/null' EXIT +for _ in $( seq 1 100 ); do grep -q held "$W/b24.hold" 2>/dev/null && break; sleep 0.1; done +cur1="$( cat "$cg/memory.current" )" +"$BIN" "$FX" --no-cache --max-memory=256M >"$W/b24a.out" 2>"$W/b24a.err"; rc=$? +kill "$holder" 2>/dev/null; wait "$holder" 2>/dev/null +if [ $(( cur1 - cur0 )) -lt $(( 300 * 1024 * 1024 )) ]; then + no "(B24) premise: the sibling's 320 MiB did not reach the leaf's memory.current (grew $(( ( cur1 - cur0 ) / 1048576 )) MiB) — the arm proves nothing" +elif [ "$rc" = 5 ] && [ ! -s "$W/b24a.out" ] && grep -q '^ripwire: memory limit reached during the ingest (limit 256M, from --max-memory)' "$W/b24a.err"; then + ok "(B24) an isolated leaf holding 320 MiB of anonymous memory beside ripwire is over a 256M limit: exit 5 at the hard line" +else + no "(B24) leaf +$(( ( cur1 - cur0 ) / 1048576 )) MiB anonymous, --max-memory=256M: rc=$rc stderr: $( grep '^ripwire:' "$W/b24a.err" | head -c 200 )" +fi +reclaim0=$(( $( cg_stat file ) - $( cg_stat shmem ) )) +dd if=/dev/zero of="$W/b24.cache" bs=1M count=320 2>/dev/null && cat "$W/b24.cache" >/dev/null +reclaim1=$(( $( cg_stat file ) - $( cg_stat shmem ) )) +cur2="$( cat "$cg/memory.current" )" +"$BIN" "$FX" --no-cache --max-memory=256M >"$W/b24b.out" 2>"$W/b24b.err"; rc=$? +rm -f "$W/b24.cache" +if [ $(( reclaim1 - reclaim0 )) -lt $(( 200 * 1024 * 1024 )) ]; then + printf ' SKIP (B24) the 320 MiB file did not stay in the leaf'"'"'s reclaimable page cache (file - shmem grew %s MiB): its filesystem is not charged here\n' "$(( ( reclaim1 - reclaim0 ) / 1048576 ))" +elif [ "$cur2" -le $(( 256 * 1024 * 1024 )) ]; then + printf ' SKIP (B24) the leaf charge with the page cache held is %s MiB, not above the 256M limit: a guard counting page cache would not be caught\n' "$(( cur2 / 1048576 ))" +elif [ "$rc" = 0 ] && [ -s "$W/b24b.out" ] && ! grep -q '^ripwire: memory' "$W/b24b.err"; then + ok "(B24) the same leaf holding $(( ( reclaim1 - reclaim0 ) / 1048576 )) MiB of reclaimable page cache is not over the limit: the run answers" +else + no "(B24) reclaimable page cache counted against the limit: rc=$rc stderr: $( grep '^ripwire:' "$W/b24b.err" | head -c 200 )" +fi +exit "$fail" +B24 +b24_how="" b24_cg="" +b24_own="$( sed -n 's/^0:://p' /proc/self/cgroup 2>/dev/null | head -1 )" +if [ -n "$b24_own" ] && [ "$b24_own" != "/" ] && command -v python3 >/dev/null 2>&1; then + b24_parent="/sys/fs/cgroup${b24_own%/*}" + if [ -w "$b24_parent/cgroup.procs" ] && grep -qw memory "$b24_parent/cgroup.subtree_control" 2>/dev/null \ + && mkdir "$b24_parent/ripwire-memguardcheck-$$" 2>/dev/null; then + b24_cg="$b24_parent/ripwire-memguardcheck-$$" + if echo $(( 1024 * 1024 * 1024 )) >"$b24_cg/memory.max" 2>/dev/null; then + b24_how="delegated child cgroup $b24_cg" + else + rmdir "$b24_cg" 2>/dev/null; b24_cg="" + fi + fi + if [ -z "$b24_how" ] && command -v systemd-run >/dev/null 2>&1 && systemd-run --user --scope --quiet true >/dev/null 2>&1; then + b24_how="systemd-run --user --scope" + fi +fi +mkdir -p "$TMP/b24" +if [ -n "$b24_cg" ]; then + ( echo "$BASHPID" >"$b24_cg/cgroup.procs" 2>/dev/null || { printf ' FAIL (B24) could not move into %s\n' "$b24_cg"; exit 1; } + exec bash "$b24_body" "$BIN" "$FX" "$TMP/b24" ) || fail=1 + rmdir "$b24_cg" 2>/dev/null || no "(B24) the arm's cgroup $b24_cg was left behind" +elif [ -n "$b24_how" ]; then + systemd-run --user --scope --quiet -p MemoryMax=1G -p MemorySwapMax=0 -- bash "$b24_body" "$BIN" "$FX" "$TMP/b24" || fail=1 +else + printf ' SKIP (B24) no isolated cgroup v2 leaf to run in: neither a delegated parent cgroup (cgroup.procs writable, memory in cgroup.subtree_control) nor systemd-run --user, or no python3 (own leaf=%s)\n' "${b24_own:-none}" +fi + echo "=== (C) a default run is byte-identical to the guard made huge ===" for tree in "$FX" "$ROOT/test/fixture"; do "$BIN" "$tree" --no-cache >"$TMP/c_def.out" 2>&1 diff --git a/test/printf_parity.manifest b/test/printf_parity.manifest index 50e5eb684..573236c98 100644 --- a/test/printf_parity.manifest +++ b/test/printf_parity.manifest @@ -38,5 +38,5 @@ safe_delete 0 e3535cdf695ad1d3e9ffbec44d529478059317b226fb60b2dfffaebc90b44218 e verify_layer 1 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 5288c345d7d6e335f88b9c1daa8935db22e1dcf89c0c8bc1f6140d4cb5af0b48 graph_query 1 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 bfa4319feb9dee09cfbd3991cf6fbd752297e99d14de9e75768820d2a9c8832f callers_limit 0 3a8337704c1a55c31816e1bd43c5389186062503f35f1ecac548ca52ff37e84c e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -help_all 0 97b63e568bc6d363654e88d00200d91cd31caaf0a600a004ebe15b12e2ecdc09 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +help_all 0 de8c0030151f952e432e193a0979f3fb49c3703fadc09e0764647a29bff68ffb e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 help_one 0 938ff4f8802a8f5d9cb10f9a1f3804ac755be44c8ab00e24e45bfe8255224a21 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855