Files
jakub 6a667d8deb Capture current mission, AI, map and administration development
Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
2026-10-13 22:52:53 +02:00

90 lines
3.8 KiB
Python

#!/usr/bin/env python3
"""Read-only Linux Reforger memory sampler; one bounded JSONL file, no game RPCs.
Run on the server host: python3 sample_server_memory.py --output /tmp/escape-memory.jsonl
Records all Reforger processes so a host OOM can be distinguished from a container
limit. Stops after 24 hours by default. Never reads command arguments or secrets.
"""
import argparse
import datetime
import json
from pathlib import Path
import time
def fields(text):
result = {}
for line in text.splitlines():
key, sep, value = line.partition(':')
if sep and value.split() and value.split()[0].isdigit():
result[key] = int(value.split()[0])
return result
def snapshot(proc=Path('/proc'), cgroups=Path('/sys/fs/cgroup')):
memory = fields((proc / 'meminfo').read_text())
result = {'utc': datetime.datetime.now(datetime.timezone.utc).isoformat(),
'host_kb': {k: memory.get(k) for k in ('MemTotal', 'MemAvailable', 'SwapTotal', 'SwapFree')},
'processes': []}
for directory in proc.iterdir():
if not directory.name.isdigit():
continue
try:
if 'ArmaReforgerServer' not in (directory / 'exe').readlink().name:
continue
status = fields((directory / 'status').read_text())
# PID + kernel start tick distinguish process restarts/PID reuse.
start = (directory / 'stat').read_text().rsplit(')', 1)[1].split()[19]
row = {'pid': int(directory.name), 'start_ticks': int(start),
'kb': {k: status.get(k) for k in ('VmRSS', 'VmHWM', 'VmSize', 'RssAnon', 'RssFile', 'VmSwap')}}
for line in (directory / 'cgroup').read_text().splitlines():
if not line.startswith('0::'):
continue
group = cgroups / line[3:].lstrip('/')
row['cgroup'] = line[3:]
for name in ('memory.current', 'memory.max', 'memory.peak', 'memory.events', 'memory.stat'):
path = group / name
if not path.exists():
continue
text = path.read_text().strip()
if name in ('memory.events', 'memory.stat'):
stats = dict(part.split() for part in text.splitlines())
if name == 'memory.stat':
stats = {k: v for k, v in stats.items() if k in ('anon', 'file', 'kernel', 'sock')}
row[name] = {k: int(v) for k, v in stats.items()}
else:
row[name] = int(text) if text.isdigit() else text
result['processes'].append(row)
except (OSError, ValueError, IndexError):
# Processes may exit at any point while /proc is being read.
continue
return result
def append(path, record, limit=2 * 1024 * 1024):
if path.exists() and path.stat().st_size >= limit:
path.replace(path.with_name(path.name + '.1'))
with path.open('a') as stream:
stream.write(json.dumps(record, separators=(',', ':')) + '\n')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--interval', type=int, default=60)
parser.add_argument('--duration', type=int, default=86400)
args = parser.parse_args()
if args.interval < 30 or not 1 <= args.duration <= 172800:
parser.error('interval must be >=30 seconds; duration must be 1..172800 seconds')
deadline = time.monotonic() + args.duration
while True:
append(args.output, snapshot())
remaining = deadline - time.monotonic()
if remaining <= 0:
break
time.sleep(min(args.interval, remaining))
if __name__ == '__main__':
main()