Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
90 lines
3.8 KiB
Python
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()
|