DEV Community

Avery Lin
Avery Lin

Posted on

Validate the Failure Assumptions in an AI-Generated systemd Service Before a Free Server Exhausts Its Memory

A generated systemd unit can pass systemd-analyze verify and still push a small virtual machine into an out-of-memory kill. The core failure is not syntax; it is that the unit file encodes assumptions about memory, swap, and restart behavior that were never declared. On free-tier hosts with limited RAM, those assumptions should be checked before the service is enabled.

Because this workflow starts from the free model access and free server option available through MonkeyCode, every generated directive is treated as a claim rather than a known fact. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The acceptance gate described below uses static inspection and a constrained transient run to test the claims that matter for a small host.

The two checks that run in under a minute

Start with the built-in verifier. It catches dangling references, invalid options, and unit graph problems before the service can be loaded.

systemd-analyze verify /etc/systemd/system/worker.service
Enter fullscreen mode Exit fullscreen mode

A clean result only means the file is structurally valid. It does not tell you whether the process will stay inside the free host's memory envelope, how long it may hang during stop, or whether an automatic restart can loop. Those risks live in the [Service] section, and a small parser can make them explicit.

The script below reads the unit file and reports the failure-relevant fields as JSON. It intentionally does not try to fix anything; it surfaces the assumptions for review.

#!/usr/bin/env python3
import json
import sys

def parse_service(path):
    service = {}
    section = None
    with open(path, encoding='utf-8') as handle:
        for raw in handle:
            line = raw.strip()
            if line.startswith('[') and line.endswith(']'):
                section = line[1:-1]
                continue
            if section != 'Service' or not line or line.startswith(('#', ';')):
                continue
            if '=' in line:
                key, value = line.split('=', 1)
                service.setdefault(key.strip(), []).append(value.strip())
    return service

def build_report(path):
    service = parse_service(path)
    report = {
        'has_memory_max': 'MemoryMax' in service,
        'has_memory_swap_max': 'MemorySwapMax' in service,
        'has_timeout_start_sec': 'TimeoutStartSec' in service,
        'has_timeout_stop_sec': 'TimeoutStopSec' in service,
        'restart_on_failure': any('on-failure' in v for v in service.get('Restart', [])),
        'has_restart_sec': 'RestartSec' in service,
        'runs_as_root': any(v.strip() == 'root' for v in service.get('User', [])),
        'exec_start_uses_absolute_path': all(
            v.lstrip('-+!@:').split()[0].startswith('/')
            for v in service.get('ExecStart', [])
            if v.strip()
        ),
    }
    return report

if __name__ == '__main__':
    print(json.dumps(build_report(sys.argv[1]), indent=2))
Enter fullscreen mode Exit fullscreen mode

Run it with a unit path:

python3 check_unit_failure_surface.py /etc/systemd/system/worker.service
Enter fullscreen mode Exit fullscreen mode

A typical generated draft produces something like the following, where the missing memory fields are the reason to stop and add explicit limits before enabling the unit. The output is a risk inventory, not a verdict.

{
  "has_memory_max": false,
  "has_memory_swap_max": false,
  "has_timeout_start_sec": true,
  "has_timeout_stop_sec": false,
  "restart_on_failure": true,
  "has_restart_sec": true,
  "runs_as_root": true,
  "exec_start_uses_absolute_path": true
}
Enter fullscreen mode Exit fullscreen mode

Why missing memory fields matter on a free server

A service without MemoryMax can grow until the kernel's out-of-memory handler kills it or another process on the same host. Free-tier servers commonly provide a small RAM allocation, and the generated unit rarely guesses that limit correctly. Adding MemoryMax and MemorySwapMax creates a predictable boundary that the transient probe can verify before the real service runs.

The same reasoning applies to TimeoutStopSec. If the generated command ignores SIGTERM and the unit has no stop timeout, a deploy or reboot can hang for the service manager's default. A bounded stop timeout turns an unknown delay into a measurable failure.

Probe under a real memory cap

Static review confirms the fields exist; the next step confirms the binary can operate within them. The following command runs the generated worker in a transient unit with a 256 MB cap and no swap.

sudo systemd-run --unit=memory-probe --wait \
  -p MemoryMax=256M \
  -p MemorySwapMax=0 \
  -p TimeoutStopSec=15 \
  -- ./run_worker.py
Enter fullscreen mode Exit fullscreen mode

After the run, inspect the transient result and the service manager's recorded status.

systemctl show memory-probe -p Result -p ExecMainStatus
journalctl -u memory-probe --since '10 minutes ago' | grep -i -E 'oom|killed process|memory'
Enter fullscreen mode Exit fullscreen mode

A healthy run typically exits zero and leaves no OOM event in the journal. A process pushed past the cap will usually terminate with a nonzero status, often ExecMainStatus=137, which signals that the kernel had to kill it. That distinction is the minimum signal you need before enabling the unit on a host with limited memory.

Make this an acceptance gate

The checks can run in sequence as a pre-merge task for a repository that stores generated or hand-written unit files. The gate rejects a unit when memory, swap, or stop-timeout fields are absent, then runs the actual command for a few seconds under the declared cap.

#!/usr/bin/env bash
set -euo pipefail

UNIT="$1"
PROBE="$2"

systemd-analyze verify "$UNIT"

python3 check_unit_failure_surface.py "$UNIT" > unit_report.json

for field in has_memory_max has_memory_swap_max has_timeout_stop_sec; do
  grep -q "$field: true" unit_report.json || {
    echo "reject: $field is missing"
    exit 1
  }
done

sudo systemd-run --unit=memory-probe --wait \
  -p MemoryMax=256M \
  -p MemorySwapMax=0 \
  -p TimeoutStopSec=15 \
  -- "$PROBE"
Enter fullscreen mode Exit fullscreen mode

Replace the probe path with a smoke command for the actual service, such as a short-lived worker invocation or a dry-run mode. This gate is deliberately shallow: it proves the service can start and stay within limits, not that it will never fail under production traffic.

Limitations and when to skip the workflow

The static parser sees only the unit file. It does not inspect child commands, shared libraries, language runtimes, or dynamic memory growth inside the application. The transient run uses a different unit name and cgroup than the installed service, so timing and scheduler behavior can differ from the real deployment. The memory cap also cannot catch a leak that appears only after hours of traffic.

Skip this workflow if the host does not use systemd, if cgroup configuration is unavailable, or if the application is already managed by an orchestrator with its own limits. It is most useful for small VPS deployments where a generated service is about to share a small memory pool with other processes.

If the next draft comes from MonkeyCode, run the same checks before enabling it. The generated unit becomes a candidate, and the static report plus constrained run decide whether it is safe enough to promote.

Top comments (0)