Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4847,17 +4847,17 @@ $ ./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.

**Shaped by:** `--mcp`

**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`

Expand Down
4 changes: 3 additions & 1 deletion src/cli.h
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
85 changes: 64 additions & 21 deletions src/infra/os.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -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()
{
Expand All @@ -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()
{
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions src/mcp.h
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
29 changes: 23 additions & 6 deletions src/memguard.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -62,7 +63,7 @@
#include <string_view>

#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
Expand Down Expand Up @@ -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 )
Expand Down Expand Up @@ -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;
Expand All @@ -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 ) );
Expand All @@ -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 );
Expand Down Expand Up @@ -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;
}

Expand Down
Loading
Loading