carlos / platform Public

Merge pull request #336 from carlosframework/reliability

Memory pressure containment: edge wall, per-app caps, memwatch, module alarm
Paul Campbell pushed by paul@keymail.dev c2054152b0b292b3e51b108486810222ab3b7454 parent 2ca7d60parent b041b8f
16 files changed, +806 −17
  • .gitignore +1 −0
  • infra/modules/README.md +1 −0
  • infra/modules/region-host/main.tf +66 −0
  • infra/modules/region-host/variables.tf +6 −0
  • internal/activator/backend_exec.go +17 −3
  • internal/activator/backend_exec_test.go +23 −0
  • internal/cgroup/cgroup.go +100 −3
  • internal/cgroup/cgroup_linux_test.go +7 −7
  • internal/cgroup/cgroup_test.go +120 −2
  • internal/host/agent.go +7 −0
  • internal/host/host_test.go +8 −0
  • internal/host/memtier.go +26 −0
  • internal/host/memtier_test.go +38 −0
  • internal/host/memwatch.go +208 −0
  • internal/host/memwatch_test.go +155 −0
  • internal/host/units/carlos-edge.service +23 −2
diff --git a/.gitignore b/.gitignore
index 6b209b585..42d71f76e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ platform
# amadan reuses one workspace per repo across jobs, so an untracked
# directory in the checkout is that step's cache — see the step for why.
.amadan-cache/
+infra/modules/**/.terraform.lock.hcl
diff --git a/infra/modules/README.md b/infra/modules/README.md
index f0c68eea7..1765debb2 100644
--- a/infra/modules/README.md
+++ b/infra/modules/README.md
@@ -70,6 +70,7 @@ bucket, so a wrong-region wake fails loudly at the IAM layer.
| `steering_record_names` | Name bound on that grant, e.g. `["*.oncarlos.com"]`. Empty (default) grants A-record UPSERT/DELETE on the WHOLE zone, apex included — set it. |
| `ami_name_pattern` | AMI name filter (default `debian-13-arm64-*`). |
| `ami_owner` | AMI owner account (default `136693071363`, Debian). |
+| `alerts_email` | Email for the host's `MemAvailablePercent < 15% for 3 min` alarm (memory-pressure spec §2.4). The module creates a same-region SNS topic + subscription per host (an alarm cannot notify a topic in another region). Empty (default): no topic, no alarm. Set it only once the box is publishing — missing data is treated as breaching. The `cloudwatch:PutMetricData` grant on namespace `Carlos/Host` is unconditional, so the agent's memwatch duty publishes either way. |
| `enable_auto_recovery` | Arm the EC2 auto-recovery alarm (HA ladder rung 0) — a system-status-check failure recovers the instance onto healthy hardware, same instance id, EIP and volumes. Default `true`; set `false` only where an external supervisor owns recovery. |
Outputs: `eip_public_ip` (point your DNS here), `instance_id`,
diff --git a/infra/modules/region-host/main.tf b/infra/modules/region-host/main.tf
index 407565c29..ae5afb52f 100644
--- a/infra/modules/region-host/main.tf
+++ b/infra/modules/region-host/main.tf
@@ -288,6 +288,28 @@ resource "aws_iam_role_policy" "host_costexplorer" {
})
}
+# The agent's memwatch duty (internal/host/memwatch.go) publishes
+# MemAvailablePercent and MemoryPressureSome60 once a minute. PutMetricData
+# has no resource ARN; the namespace condition is the whole bound — this
+# role can write Carlos/Host and nothing else.
+resource "aws_iam_role_policy" "host_metrics" {
+ name = "${var.iam_name}-metrics"
+ role = aws_iam_role.host.id
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Effect = "Allow"
+ Action = ["cloudwatch:PutMetricData"]
+ Resource = "*"
+ Condition = {
+ StringEquals = { "cloudwatch:namespace" = "Carlos/Host" }
+ }
+ },
+ ]
+ })
+}
+
resource "aws_iam_role_policy_attachment" "host_ssm" {
role = aws_iam_role.host.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
@@ -347,3 +369,47 @@ resource "aws_cloudwatch_metric_alarm" "host_auto_recover" {
}
tags = { Name = "${var.name}-auto-recover" }
}
+
+# Memory-pressure spec §2.4: the early warning the status checks can't give.
+# On 2026-08-28 the flagship thrashed for 15 minutes with EC2 status ok/ok;
+# MemAvailable would have crossed 15% minutes earlier. Missing data is
+# BREACHING: a box that stops reporting is a box in trouble (or an agent
+# rolled without CARLOS_HOST_METRICS — either way, look). Which is also
+# why the caller should set alerts_email only once the box is publishing
+# (spec §4): wiring it first buys days of ALARM mail about nothing.
+#
+# The topic lives here, in the host's region, because an alarm can only
+# notify a same-region topic — one account-wide topic passed to four
+# regional hosts would apply and then never deliver for three of them.
+resource "aws_sns_topic" "alerts" {
+ count = var.alerts_email != "" ? 1 : 0
+ name = "${var.name}-alerts"
+}
+
+resource "aws_sns_topic_subscription" "alerts_email" {
+ count = var.alerts_email != "" ? 1 : 0
+ topic_arn = aws_sns_topic.alerts[0].arn
+ protocol = "email"
+ endpoint = var.alerts_email
+}
+
+resource "aws_cloudwatch_metric_alarm" "host_memory_low" {
+ count = var.alerts_email != "" ? 1 : 0
+ alarm_name = "${var.name}-memory-low"
+ alarm_description = "MemAvailable below 15% for 3 minutes on ${var.name} — a tenant is eating the box; see the memory-pressure spec §4 for what to look at."
+ namespace = "Carlos/Host"
+ metric_name = "MemAvailablePercent"
+ statistic = "Minimum"
+ period = 60
+ evaluation_periods = 3
+ datapoints_to_alarm = 3
+ threshold = 15
+ comparison_operator = "LessThanThreshold"
+ treat_missing_data = "breaching"
+ alarm_actions = [aws_sns_topic.alerts[0].arn]
+ ok_actions = [aws_sns_topic.alerts[0].arn]
+ dimensions = {
+ InstanceId = aws_instance.host.id
+ }
+ tags = { Name = "${var.name}-memory-low" }
+}
diff --git a/infra/modules/region-host/variables.tf b/infra/modules/region-host/variables.tf
index e46144582..3f0a898c0 100644
--- a/infra/modules/region-host/variables.tf
+++ b/infra/modules/region-host/variables.tf
@@ -108,3 +108,9 @@ variable "enable_cost_explorer" {
type = bool
default = false
}
+
+variable "alerts_email" {
+ description = "Email that receives this host's memory alarm (memory-pressure spec §2.4). A CloudWatch alarm can only notify an SNS topic in its own region, so the module creates a regional topic + subscription per host rather than taking a topic ARN. Empty: no topic, no alarm; the PutMetricData grant is unconditional so the agent's memwatch duty can publish either way."
+ type = string
+ default = ""
+}
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index ab062ede4..580e63fe9 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -71,6 +71,12 @@ type ExecBackend struct {
// metrics sampler can account it (exec-child accounting spec). Nil — no
// delegation: old unit file, tests, darwin — spawns exactly as before.
Cgroups *cgroup.Manager
+ // Tier, when non-nil, picks the memory cap for a route's carlos-exec@
+ // cgroup (memory-pressure spec §2.3). The box wiring reads the staged
+ // manifest of the live version; tests leave it nil and get the default
+ // tier. Instance, seed and sidecar share the cgroup, so they share the
+ // tier — a seed's first-boot cost is that instance's work.
+ Tier func(registry.Route) cgroup.Limit
// Logs, when non-nil, gets each instance and seed child's stdout/stderr
// teed in line-wise for `carlos logs` (spec §5) — journald keeps every
// byte it got before. Nil (tests, a box without the shipping loop)
@@ -116,6 +122,14 @@ func (b *ExecBackend) teeStd(cmd *exec.Cmd, rt registry.Route) {
var _ Backend = (*ExecBackend)(nil)
+// tier is Tier with the nil default applied.
+func (b *ExecBackend) tier(rt registry.Route) cgroup.Limit {
+ if b.Tier == nil {
+ return cgroup.TierDefault
+ }
+ return b.Tier(rt)
+}
+
func (b *ExecBackend) liveDir() string { return filepath.Join(b.OptDir, "live") }
func (b *ExecBackend) liveLink(host string) string { return filepath.Join(b.liveDir(), host) }
func (b *ExecBackend) defaultDBPath(host string) string {
@@ -311,7 +325,7 @@ func (b *ExecBackend) Seed(ctx context.Context, rt registry.Route) error {
// cost is that instance's work. No CleanupHost here — the instance spawn
// reuses the dir moments later, and its exit path (or the next agent
// start's sweep) removes it.
- cmd, err := b.Cgroups.Start(host, func() *exec.Cmd {
+ cmd, err := b.Cgroups.Start(host, b.tier(rt), func() *exec.Cmd {
cmd := exec.CommandContext(ctx, b.liveLink(host), argv...)
// The control-channel vars are agent-owned in BOTH directions here too
// (Start's discipline, see withoutVars), and here they are always
@@ -619,7 +633,7 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin
// carlos-exec@<host> and the sampler can account it. build may run
// twice — the manager's clone3-refusal fallback — so everything about
// the Cmd is configured inside it.
- cmd, err := b.Cgroups.Start(host, func() *exec.Cmd {
+ cmd, err := b.Cgroups.Start(host, b.tier(rt), func() *exec.Cmd {
cmd := exec.Command(bin, "--socket", rt.Socket, "--db", dbPath)
cmd.Env = childEnv(childVars)
// The child logs through the agent's own stderr/stdout (journald
@@ -822,7 +836,7 @@ func (b *ExecBackend) StartSidecar(_ context.Context, rt registry.Route) (Proc,
// Same host cgroup as the instance: a sidecar's load is that instance's
// work, and attributing it elsewhere would be a lie in the other
// direction.
- cmd, err := b.Cgroups.Start(rt.Host, func() *exec.Cmd {
+ cmd, err := b.Cgroups.Start(rt.Host, b.tier(rt), func() *exec.Cmd {
cmd := exec.Command(b.liveLink(rt.Host), "sidecar", "run")
cmd.Env = childEnv(fileEnv)
// Like the instance child: logs through the agent's own streams
diff --git a/internal/activator/backend_exec_test.go b/internal/activator/backend_exec_test.go
new file mode 100644
index 000000000..b1f165250
--- /dev/null
+++ b/internal/activator/backend_exec_test.go
@@ -0,0 +1,23 @@
+package activator
+
+import (
+ "testing"
+
+ "github.com/carlosframework/platform/internal/cgroup"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// TestExecBackendTierSeam: a nil Tier means the default tier, and a set
+// Tier is consulted with the route. The cgroup manager is nil here (no
+// delegation in a test), so this pins the seam's contract, not the write.
+func TestExecBackendTierSeam(t *testing.T) {
+ var b ExecBackend
+ rt := registry.Route{Host: "h.example.com"}
+ if got := b.tier(rt); got != cgroup.TierDefault {
+ t.Fatalf("nil Tier: %v, want TierDefault", got)
+ }
+ b.Tier = func(registry.Route) cgroup.Limit { return cgroup.TierChildren }
+ if got := b.tier(rt); got != cgroup.TierChildren {
+ t.Fatalf("set Tier: %v, want TierChildren", got)
+ }
+}
diff --git a/internal/cgroup/cgroup.go b/internal/cgroup/cgroup.go
index 9752a0cf0..58f5491df 100644
--- a/internal/cgroup/cgroup.go
+++ b/internal/cgroup/cgroup.go
@@ -3,7 +3,9 @@
// file), so the metrics sampler can report per-instance figures for routes
// that run as exec children of the agent — today those live inside the
// agent's own cgroup and can never show readings. Design:
-// docs/superpowers/specs/2026-08-07-exec-child-accounting-design.md.
+// docs/superpowers/specs/2026-08-07-exec-child-accounting-design.md. Since
+// the memory-pressure spec (2026-08-28) the same per-host cgroup also
+// carries the instance's memory cap (Limit, the tiers).
//
// The agent is NOT root: everything here works through ownership of the
// delegated subtree, never privilege. When delegation is absent (old unit
@@ -40,6 +42,50 @@ func IsExecName(name string) bool {
return strings.HasPrefix(name, execPrefix) && len(name) > len(execPrefix)
}
+// Limit is what Start writes into a host's cgroup before the child lands
+// in it. MemoryMax is bytes; zero means "write nothing" — the pre-spec
+// behaviour for a caller with no tier. memory.high is derived (90% of
+// max) rather than a second field: a tenant that is throttled before it is
+// killed is the whole point, and there is no honest reason for a caller to
+// pick a different band.
+type Limit struct {
+ MemoryMax int64
+}
+
+// The tiers are PLATFORM-chosen (memory-pressure spec §2.3), mirroring
+// what caps/render.go already writes for unit-backed apps: the carlos-app@
+// template's 512M, and the 1G a forking family gets when its manifest
+// declares `children`. No number comes from a manifest.
+var (
+ TierDefault = Limit{MemoryMax: 512 << 20}
+ TierChildren = Limit{MemoryMax: 1 << 30}
+)
+
+// highOf is memory.high for a given memory.max: 90%, so reclaim and
+// throttling start well before the kill.
+func highOf(max int64) int64 { return max / 10 * 9 }
+
+// ExecOOMScoreAdj is written to every exec child's /proc/<pid>/oom_score_adj
+// right after it starts. The kernel's OOM killer — the memcg one that fires
+// when carlos-edge.service's own MemoryMax is hit included — scores per
+// PROCESS by RSS, and the agent (~110 MB) outweighs any single tenant
+// process on the flagship today, so without this the outer wall's victim
+// would be the agent, and Restart=always would then cycle every awake
+// instance: precisely what OOMPolicy=continue exists to prevent. +500 adds
+// half the box's RAM to a child's score; the agent stays at 0 and is never
+// the first pick. Raising one's own-uid process's score needs no privilege
+// (lowering does), which is why this is done here rather than by a unit
+// directive. Grandchildren inherit the value at fork; a family that forks
+// in the microseconds between exec and this write keeps 0 for those early
+// forks — the safe direction is still "a tenant process before the agent"
+// for everything forked after.
+const ExecOOMScoreAdj = "500"
+
+// oomScoreAdjPath is a seam so a test can point the write at a temp dir.
+var oomScoreAdjPath = func(pid int) string {
+ return filepath.Join("/proc", strconv.Itoa(pid), "oom_score_adj")
+}
+
// ParseSelfCgroup extracts the cgroup v2 path from /proc/self/cgroup
// content — the "0::" unified-hierarchy line — relative (no leading slash),
// ready to join under /sys/fs/cgroup. Not-ok when there is no v2 line (a
@@ -172,7 +218,8 @@ var startCmd = func(cmd *exec.Cmd) error { return cmd.Start() }
// a seed's first-boot cost and a sidecar's load ARE that instance's work.
func (m *Manager) hostDir(host string) string { return filepath.Join(m.root, ExecName(host)) }
-// Start runs build() and starts the result inside host's cgroup. Nil-safe:
+// Start runs build() and starts the result inside host's cgroup, with
+// limit applied to that cgroup first (memory-pressure spec §2.3). Nil-safe:
// with no manager (or any accounting failure) the child starts exactly as
// today — a spawn must never fail because accounting failed. build must
// return a fully configured, unstarted Cmd; it may be called twice, because
@@ -192,7 +239,7 @@ func (m *Manager) hostDir(host string) string { return filepath.Join(m.root, Exe
// mis-accounting in the safe direction. Note the PID write migrates only
// the child process itself — any grandchildren it has already forked by
// then stay charged to the agent's cgroup, not the child's.
-func (m *Manager) Start(host string, build func() *exec.Cmd) (*exec.Cmd, error) {
+func (m *Manager) Start(host string, limit Limit, build func() *exec.Cmd) (*exec.Cmd, error) {
if m == nil {
cmd := build()
return cmd, startCmd(cmd)
@@ -203,6 +250,7 @@ func (m *Manager) Start(host string, build func() *exec.Cmd) (*exec.Cmd, error)
cmd := build()
return cmd, startCmd(cmd)
}
+ m.applyLimit(host, dir, limit)
if intoCgroupSupported && !m.fallback.Load() {
f, err := os.Open(dir)
if err != nil {
@@ -214,6 +262,7 @@ func (m *Manager) Start(host string, build func() *exec.Cmd) (*exec.Cmd, error)
err = startCmd(cmd)
f.Close()
if err == nil {
+ m.biasOOM(host, cmd)
return cmd, nil
}
firstErr := err
@@ -235,6 +284,38 @@ func (m *Manager) Start(host string, build func() *exec.Cmd) (*exec.Cmd, error)
return m.startAndMove(host, build)
}
+// applyLimit writes limit into dir. Best-effort in the package's usual
+// sense: a refused write (a kernel without the memory controller here, a
+// control file that isn't ours) is one logged line and an uncapped child.
+// Written BEFORE the child is in the cgroup, so the cap holds from its
+// first page. memory.max first: if that one fails, high is pointless.
+//
+// The cap bounds the cgroup's TOTAL charge — anonymous memory plus the page
+// cache its reads pull in — not RSS. A tenant whose cache alone exceeds
+// memory.high lives under constant reclaim: slow, and nothing in the
+// journal says why. Hence the log line: the tier a host landed on is the
+// first thing to read when an app "got slow after the roll".
+//
+// memory.oom.group=1 makes an OOM kill inside this cgroup take the whole
+// family: a hub whose children outlive it would otherwise leave orphans
+// holding the socket while the activator respawns a fresh hub beside them.
+func (m *Manager) applyLimit(host, dir string, limit Limit) {
+ if limit.MemoryMax <= 0 {
+ return
+ }
+ if err := os.WriteFile(filepath.Join(dir, "memory.max"), []byte(strconv.FormatInt(limit.MemoryMax, 10)), 0o644); err != nil {
+ m.logf("cgroup: %s: memory.max: %v — child runs uncapped", host, err)
+ return
+ }
+ if err := os.WriteFile(filepath.Join(dir, "memory.high"), []byte(strconv.FormatInt(highOf(limit.MemoryMax), 10)), 0o644); err != nil {
+ m.logf("cgroup: %s: memory.high: %v — child is capped but not throttled first", host, err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "memory.oom.group"), []byte("1"), 0o644); err != nil {
+ m.logf("cgroup: %s: memory.oom.group: %v — an OOM kill here takes one process, not the family", host, err)
+ }
+ m.logf("cgroup: %s: memory.max=%dM memory.high=%dM", host, limit.MemoryMax>>20, highOf(limit.MemoryMax)>>20)
+}
+
// startAndMove is the fallback spawn: plain start, then write the child's
// PID into the host cgroup. A failed move is logged and tolerated — the
// child runs (unaccounted), which beats not running.
@@ -259,13 +340,29 @@ func (m *Manager) startAndMove(host string, build func() *exec.Cmd) (*exec.Cmd,
if err := os.WriteFile(procs, []byte(strconv.Itoa(cmd.Process.Pid)), 0o644); err != nil {
m.logf("cgroup: %s: move pid %d: %v — child stays unaccounted", host, cmd.Process.Pid, err)
}
+ m.biasOOM(host, cmd)
return cmd, nil
}
+// biasOOM writes ExecOOMScoreAdj for a just-started child. Best-effort: a
+// refused write is one line, and the child runs with the agent's score.
+func (m *Manager) biasOOM(host string, cmd *exec.Cmd) {
+ if cmd == nil || cmd.Process == nil {
+ return
+ }
+ if err := os.WriteFile(oomScoreAdjPath(cmd.Process.Pid), []byte(ExecOOMScoreAdj), 0o644); err != nil {
+ m.logf("cgroup: %s: oom_score_adj for pid %d: %v — the agent and this child score alike at an edge-tree OOM", host, cmd.Process.Pid, err)
+ }
+}
+
// CleanupHost removes host's per-host cgroup dir after its children exit.
// Nil-safe and rmdir-tolerant: while a sibling (instance vs sidecar) is
// still alive the dir is non-empty and the remove fails — the LAST exiter's
// call succeeds, and the next agent start's sweep catches anything missed.
+// (On real cgroupfs the control files applyLimit wrote don't count: rmdir
+// ignores interface files. In the temp-dir test fixture they are ordinary
+// files, so a test that caps a host and then expects CleanupHost to remove
+// the dir must delete them first — that failure has no production analogue.)
func (m *Manager) CleanupHost(host string) {
if m == nil {
return
diff --git a/internal/cgroup/cgroup_linux_test.go b/internal/cgroup/cgroup_linux_test.go
index 05d902b58..5abaa2665 100644
--- a/internal/cgroup/cgroup_linux_test.go
+++ b/internal/cgroup/cgroup_linux_test.go
@@ -21,7 +21,7 @@ func TestStartUsesCgroupFDFirst(t *testing.T) {
t.Fatal(err)
}
calls := fakeStart(t, func(*exec.Cmd) error { return nil })
- if _, err := m.Start("h.example.com", buildTrue); err != nil {
+ if _, err := m.Start("h.example.com", Limit{}, buildTrue); err != nil {
t.Fatal(err)
}
if len(*calls) != 1 {
@@ -56,7 +56,7 @@ func TestStartFallsBackAndLatches(t *testing.T) {
}
return nil
})
- if _, err := m.Start("h.example.com", buildTrue); err != nil {
+ if _, err := m.Start("h.example.com", Limit{}, buildTrue); err != nil {
t.Fatal(err)
}
if len(*calls) != 2 {
@@ -70,7 +70,7 @@ func TestStartFallsBackAndLatches(t *testing.T) {
t.Fatal("refusal did not latch the fallback")
}
- if _, err := m.Start("h2.example.com", buildTrue); err != nil {
+ if _, err := m.Start("h2.example.com", Limit{}, buildTrue); err != nil {
t.Fatal(err)
}
if len(*calls) != 3 {
@@ -97,7 +97,7 @@ func TestStartMergesIntoBuilderSysProcAttr(t *testing.T) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
return cmd
}
- if _, err := m.Start("h.example.com", build); err != nil {
+ if _, err := m.Start("h.example.com", Limit{}, build); err != nil {
t.Fatal(err)
}
attr := (*calls)[0].SysProcAttr
@@ -126,7 +126,7 @@ func TestStartTransientClone3FailureDoesNotLatch(t *testing.T) {
}
return nil
})
- if _, err := m.Start("h.example.com", buildTrue); err != nil {
+ if _, err := m.Start("h.example.com", Limit{}, buildTrue); err != nil {
t.Fatal(err)
}
if len(*calls) != 2 {
@@ -136,7 +136,7 @@ func TestStartTransientClone3FailureDoesNotLatch(t *testing.T) {
t.Fatal("a transient clone3 failure latched the fallback")
}
- if _, err := m.Start("h2.example.com", buildTrue); err != nil {
+ if _, err := m.Start("h2.example.com", Limit{}, buildTrue); err != nil {
t.Fatal(err)
}
// The fake keeps refusing clone3 with ENODEV for h2 too, so the second
@@ -172,7 +172,7 @@ func TestStartJoinsBothErrorsOnTotalFailure(t *testing.T) {
}
return errRetry
})
- _, err = m.Start("h.example.com", buildTrue)
+ _, err = m.Start("h.example.com", Limit{}, buildTrue)
if err == nil {
t.Fatal("Start with two failing spawns succeeded, want error")
}
diff --git a/internal/cgroup/cgroup_test.go b/internal/cgroup/cgroup_test.go
index ee4ffba26..b7858b343 100644
--- a/internal/cgroup/cgroup_test.go
+++ b/internal/cgroup/cgroup_test.go
@@ -168,7 +168,7 @@ func buildTrue() *exec.Cmd { return exec.Command("/bin/true") }
func TestStartNilManagerSpawnsPlainly(t *testing.T) {
calls := fakeStart(t, func(*exec.Cmd) error { return nil })
var m *Manager
- cmd, err := m.Start("h.example.com", buildTrue)
+ cmd, err := m.Start("h.example.com", Limit{}, buildTrue)
if err != nil || cmd == nil {
t.Fatalf("Start = (%v, %v), want started cmd", cmd, err)
}
@@ -187,7 +187,7 @@ func TestStartGenuineSpawnFailureDoesNotLatch(t *testing.T) {
t.Fatal(err)
}
fakeStart(t, func(*exec.Cmd) error { return errors.New("no such binary") })
- if _, err := m.Start("h.example.com", buildTrue); err == nil {
+ if _, err := m.Start("h.example.com", Limit{}, buildTrue); err == nil {
t.Fatal("Start with a failing spawn succeeded, want error")
}
if m.fallback.Load() {
@@ -267,3 +267,121 @@ func TestStartAndMoveToleratesBrokenSeam(t *testing.T) {
t.Fatal(err)
}
}
+
+// TestStartWritesMemoryLimit: a non-zero Limit lands in the host cgroup as
+// memory.max and memory.high (high = 90% of max, so a tenant is throttled
+// and reclaimed before it is killed). The temp-dir fixture has no kernel
+// behind it, so the assertion is on the bytes written.
+func TestStartWritesMemoryLimit(t *testing.T) {
+ root := newDelegatedRoot(t)
+ m, err := SetupAt(root, t.Logf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fakeStart(t, func(*exec.Cmd) error { return nil })
+ if _, err := m.Start("h.example.com", Limit{MemoryMax: 1 << 30}, buildTrue); err != nil {
+ t.Fatal(err)
+ }
+ dir := m.hostDir("h.example.com")
+ for file, want := range map[string]string{
+ "memory.max": "1073741824",
+ "memory.high": "966367638", // max/10*9, integer arithmetic
+ "memory.oom.group": "1",
+ } {
+ got, err := os.ReadFile(filepath.Join(dir, file))
+ if err != nil {
+ t.Fatalf("%s not written: %v", file, err)
+ }
+ if string(got) != want {
+ t.Errorf("%s = %q, want %q", file, got, want)
+ }
+ }
+}
+
+// TestStartZeroLimitWritesNothing: the zero Limit is "no cap" — the
+// pre-spec behaviour, byte for byte, for any caller that has no tier.
+func TestStartZeroLimitWritesNothing(t *testing.T) {
+ root := newDelegatedRoot(t)
+ m, err := SetupAt(root, t.Logf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fakeStart(t, func(*exec.Cmd) error { return nil })
+ if _, err := m.Start("h.example.com", Limit{}, buildTrue); err != nil {
+ t.Fatal(err)
+ }
+ for _, file := range []string{"memory.max", "memory.high"} {
+ if _, err := os.Stat(filepath.Join(m.hostDir("h.example.com"), file)); !os.IsNotExist(err) {
+ t.Errorf("%s exists for a zero limit", file)
+ }
+ }
+}
+
+// TestStartLimitWriteFailureStillSpawns: a cgroup that refuses the limit
+// (read-only control file, an old kernel) costs one log line and an
+// uncapped child — never a failed spawn. Same discipline as every other
+// accounting failure in this package.
+func TestStartLimitWriteFailureStillSpawns(t *testing.T) {
+ root := newDelegatedRoot(t)
+ m, err := SetupAt(root, t.Logf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dir := m.hostDir("h.example.com")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ // A directory where the file should go: WriteFile fails with EISDIR.
+ if err := os.Mkdir(filepath.Join(dir, "memory.max"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ fakeStart(t, func(*exec.Cmd) error { return nil })
+ cmd, err := m.Start("h.example.com", TierDefault, buildTrue)
+ if err != nil || cmd == nil {
+ t.Fatalf("Start = (%v, %v), want a started cmd despite the limit failure", cmd, err)
+ }
+ // max failed, so high must NOT have been written: a high with no max
+ // is a throttle with no wall, and the doc comment promises the short
+ // circuit.
+ if _, err := os.Stat(filepath.Join(dir, "memory.high")); !os.IsNotExist(err) {
+ t.Errorf("memory.high written after memory.max failed: %v", err)
+ }
+}
+
+// TestStartBiasesChildOOMScore: a started child gets ExecOOMScoreAdj
+// written to its oom_score_adj (redirected into a temp dir here), on both
+// spawn paths. Without it the agent — the largest process in the edge
+// tree — is the memcg OOM killer's first pick when the outer wall fires.
+func TestStartBiasesChildOOMScore(t *testing.T) {
+ root := newDelegatedRoot(t)
+ m, err := SetupAt(root, t.Logf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ adj := t.TempDir()
+ orig := oomScoreAdjPath
+ oomScoreAdjPath = func(pid int) string { return filepath.Join(adj, strconv.Itoa(pid)) }
+ t.Cleanup(func() { oomScoreAdjPath = orig })
+ // A real start: /bin/true gives the child a pid to write for.
+ cmd, err := m.startAndMove("h.example.com", buildTrue)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = cmd.Wait()
+ got, err := os.ReadFile(filepath.Join(adj, strconv.Itoa(cmd.Process.Pid)))
+ if err != nil {
+ t.Fatalf("oom_score_adj not written: %v", err)
+ }
+ if string(got) != ExecOOMScoreAdj {
+ t.Errorf("oom_score_adj = %q, want %q", got, ExecOOMScoreAdj)
+ }
+}
+
+func TestTiers(t *testing.T) {
+ if TierDefault.MemoryMax != 512<<20 {
+ t.Errorf("TierDefault = %d, want 512M — the carlos-app@ template's number", TierDefault.MemoryMax)
+ }
+ if TierChildren.MemoryMax != 1<<30 {
+ t.Errorf("TierChildren = %d, want 1G — caps/render.go's children tier", TierChildren.MemoryMax)
+ }
+}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 24d55b52f..294f0ff27 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -276,6 +276,10 @@ func RunAgent(args []string) {
Cache: cache,
Materialize: materializeHook(reg, dd),
Cgroups: cgm,
+ // The memory tier for each spawned child's cgroup, read off the
+ // staged manifest of the route's live version (memory-pressure
+ // spec §2.3): 512M, or 1G for a family that declares children.
+ Tier: memTier(stagedManifestOfLiveVersion(release.OptDir())),
// The scheduled-work verbs on every spawned instance's control
// socket write to the SAME registry this agent's converger and
// runner read (schedule spec §7.2): an app that asks for a
@@ -359,6 +363,9 @@ func RunAgent(args []string) {
go logshipLoop(logSink, status.Label(readMachineID()))
}
go statusLoop(reg)
+ // The memory early-warning (memory-pressure spec §2.4): per box, gated
+ // by CARLOS_HOST_METRICS — one log line and nothing else where unset.
+ go memwatchLoop()
go domainsLoop(reg, peekAcmeCache(args))
go instancesLoop(reg)
// The deployment-global election, armed BEFORE the loops it gates so a
diff --git a/internal/host/host_test.go b/internal/host/host_test.go
index 2be92e3c5..a4d664c12 100644
--- a/internal/host/host_test.go
+++ b/internal/host/host_test.go
@@ -123,6 +123,14 @@ func TestBootstrapWritesUnitsToStagingDir(t *testing.T) {
// cgroup manager degrades to nil and every exec-backed tenant goes
// blind on the Instances page.
"Delegate=cpu memory io pids",
+ // The outer wall (memory-pressure spec §2.2): the edge tree is
+ // where every exec-backed tenant lives, so this is the one place a
+ // runaway tenant meets a ceiling before the kernel starts thrashing.
+ // OOMPolicy=continue is the line that keeps one tenant's OOM kill
+ // from becoming a whole-edge restart (systemd's default is stop).
+ "MemoryHigh=70%",
+ "MemoryMax=80%",
+ "OOMPolicy=continue",
} {
if !strings.Contains(string(edge), want) {
t.Errorf("carlos-edge.service: missing %q", want)
diff --git a/internal/host/memtier.go b/internal/host/memtier.go
new file mode 100644
index 000000000..5c877b777
--- /dev/null
+++ b/internal/host/memtier.go
@@ -0,0 +1,26 @@
+package host
+
+import (
+ "github.com/carlosframework/platform/internal/cgroup"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// memTier builds the exec backend's Tier seam from a manifest reader —
+// on a box, stagedManifestOfLiveVersion, the same reader the caps pass
+// uses, so "what does this app declare" has one answer on the box.
+//
+// One bit decides it: capabilities.children → the 1G tier a forking family
+// gets on the unit path (caps/render.go); anything else → 512M, the
+// carlos-app@ template's number. A missing or unreadable sibling is the
+// default tier, not an error: the smaller cap is the safe direction, and
+// the caps pass already reports the unreadable sibling in its own advice.
+func memTier(load func(registry.Route) (*release.Manifest, error)) func(registry.Route) cgroup.Limit {
+ return func(rt registry.Route) cgroup.Limit {
+ m, err := load(rt)
+ if err != nil || m == nil || m.Capabilities == nil || !m.Capabilities.Children {
+ return cgroup.TierDefault
+ }
+ return cgroup.TierChildren
+ }
+}
diff --git a/internal/host/memtier_test.go b/internal/host/memtier_test.go
new file mode 100644
index 000000000..d193bb7fd
--- /dev/null
+++ b/internal/host/memtier_test.go
@@ -0,0 +1,38 @@
+package host
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/caps"
+ "github.com/carlosframework/platform/internal/cgroup"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// TestMemTier: the tier is a platform choice keyed on ONE manifest bit.
+// Everything that isn't "declares children" — no manifest, no
+// capabilities block, an unreadable sibling — is the default tier: the
+// safe direction is the smaller cap, and the caps pass will say why the
+// sibling is unreadable in its own advice.
+func TestMemTier(t *testing.T) {
+ rt := registry.Route{Host: "h.example.com", App: "app"}
+ cases := []struct {
+ name string
+ m *release.Manifest
+ err error
+ want cgroup.Limit
+ }{
+ {"no manifest", nil, nil, cgroup.TierDefault},
+ {"unreadable", nil, errors.New("boom"), cgroup.TierDefault},
+ {"no capabilities", &release.Manifest{}, nil, cgroup.TierDefault},
+ {"capabilities, no children", &release.Manifest{Capabilities: &caps.Set{V: caps.Version}}, nil, cgroup.TierDefault},
+ {"children", &release.Manifest{Capabilities: &caps.Set{V: caps.Version, Children: true}}, nil, cgroup.TierChildren},
+ }
+ for _, c := range cases {
+ tier := memTier(func(registry.Route) (*release.Manifest, error) { return c.m, c.err })
+ if got := tier(rt); got != c.want {
+ t.Errorf("%s: tier = %v, want %v", c.name, got, c.want)
+ }
+ }
+}
diff --git a/internal/host/memwatch.go b/internal/host/memwatch.go
new file mode 100644
index 000000000..54781fad8
--- /dev/null
+++ b/internal/host/memwatch.go
@@ -0,0 +1,208 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "log"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ awsconfig "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/service/cloudwatch"
+ "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types"
+)
+
+// memwatch (memory-pressure spec §2.4) publishes two CloudWatch metrics a
+// minute so a box that is running out of memory pages someone BEFORE the
+// kernel starts thrashing — the state the flagship sat in for fifteen
+// minutes on 2026-08-28 with every AWS-side check green. The status
+// snapshot already carries memory to the bucket every five minutes, but a
+// bucket write alarms nobody; CloudWatch does.
+//
+// Carlos/Host MemAvailablePercent MemAvailable / MemTotal, /proc/meminfo
+// Carlos/Host MemoryPressureSome60 some avg60 from /proc/pressure/memory
+//
+// Both keyed by InstanceId (IMDS). The region-host module's alarm reads the
+// first; the second is the early warning — it climbs minutes before
+// MemAvailable hits the floor.
+//
+// Per-box, not a housekeeper duty: every box reports itself. Armed only by
+// CARLOS_HOST_METRICS=cloudwatch in host.env, so a Pi or a customer fleet
+// box never tries to reach an API it has no credentials for.
+
+const (
+ memwatchNamespace = "Carlos/Host"
+ memwatchPeriod = time.Minute
+)
+
+// metricPutter is the one CloudWatch call this file makes.
+type metricPutter interface {
+ PutMetricData(context.Context, *cloudwatch.PutMetricDataInput, ...func(*cloudwatch.Options)) (*cloudwatch.PutMetricDataOutput, error)
+}
+
+type memwatch struct {
+ put metricPutter
+ instanceID string
+ mem func() (*int64, *int64) // MemTotal, MemAvailable in bytes
+ pressure func() (string, error) // /proc/pressure/memory content
+ logf func(string, ...any)
+ // lastPutErr is the text of the put failure last logged, "" while
+ // puts succeed. Failures log on CHANGE (and recovery logs once), not
+ // once a minute: a box rolled before its IAM grant lands would
+ // otherwise write 1,440 identical lines a day.
+ lastPutErr string
+}
+
+// memwatchArmed is the host.env gate, split out so the three answers it
+// can give are table-testable: armed, off (unset), or off with a value
+// this duty doesn't understand — the last is the one an operator wants to
+// see in the journal, because they typed it.
+func memwatchArmed(getenv func(string) string) (armed bool, why string) {
+ switch v := strings.TrimSpace(getenv("CARLOS_HOST_METRICS")); v {
+ case "cloudwatch":
+ return true, ""
+ case "":
+ return false, "CARLOS_HOST_METRICS unset"
+ default:
+ return false, "CARLOS_HOST_METRICS=" + strconv.Quote(v) + ` (the only value is "cloudwatch")`
+ }
+}
+
+// memwatchLoop arms the duty when host.env asks for it. One log line either
+// way; a box that can't resolve its instance id or build a client logs why
+// and stays quiet rather than retrying — the operator set the env, so a
+// silent duty is a bug they'd want to see at arm time, in the journal.
+// Process-lifetime, like statusLoop: nothing stops the ticker.
+func memwatchLoop() {
+ if armed, why := memwatchArmed(os.Getenv); !armed {
+ log.Printf("agent: memwatch disabled (%s)", why)
+ return
+ }
+ ctx := context.Background()
+ // Region comes from the default chain — AWS_REGION in host.env, else
+ // IMDS. A compatible-store deployment that points AWS_REGION at its
+ // own object store (internal/lease/endpoint.go's note) would aim the
+ // CloudWatch client at nonsense, which is one more reason this duty
+ // is opt-in per box rather than on wherever credentials resolve.
+ cfg, err := awsconfig.LoadDefaultConfig(ctx)
+ if err != nil {
+ log.Printf("agent: memwatch disabled: load aws config: %v", err)
+ return
+ }
+ id, err := imdsInstanceID()
+ if err != nil {
+ log.Printf("agent: memwatch disabled: instance id: %v", err)
+ return
+ }
+ w := memwatch{
+ put: cloudwatch.NewFromConfig(cfg),
+ instanceID: id,
+ mem: readMemBytes,
+ pressure: readPressureFile,
+ logf: log.Printf,
+ }
+ log.Printf("agent: memwatch armed (%s, every %s, instance %s)", memwatchNamespace, memwatchPeriod, id)
+ for now := range time.Tick(memwatchPeriod) {
+ w.tick(ctx, now)
+ }
+}
+
+// tick reads, builds, puts. A failed read skips the tick (a zero would look
+// like a full box); a failed put is logged when it first happens or changes,
+// and the next tick tries again.
+func (w *memwatch) tick(ctx context.Context, now time.Time) {
+ total, avail := w.mem()
+ if total == nil || avail == nil || *total <= 0 {
+ w.logf("memwatch: /proc/meminfo unreadable this tick — skipped")
+ return
+ }
+ availPct := float64(*avail) / float64(*total) * 100
+ psi, hasPSI := 0.0, false
+ if s, err := w.pressure(); err == nil {
+ psi, hasPSI = parsePressureSome60(s)
+ }
+ ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
+ defer cancel()
+ _, err := w.put.PutMetricData(ctx, memwatchData(w.instanceID, availPct, psi, hasPSI, now))
+ switch {
+ case err != nil && err.Error() != w.lastPutErr:
+ w.lastPutErr = err.Error()
+ w.logf("memwatch: put: %v (logged once until it changes)", err)
+ case err == nil && w.lastPutErr != "":
+ w.lastPutErr = ""
+ w.logf("memwatch: put recovered")
+ }
+}
+
+// memwatchData is the pure half: the exact PutMetricData input for one
+// tick. Standard resolution — a one-minute period at standard resolution
+// is free-tier territory; high resolution is not and buys nothing for a
+// three-minute alarm.
+func memwatchData(instanceID string, availPct, psi float64, hasPSI bool, now time.Time) *cloudwatch.PutMetricDataInput {
+ dims := []types.Dimension{{Name: aws.String("InstanceId"), Value: aws.String(instanceID)}}
+ data := []types.MetricDatum{{
+ MetricName: aws.String("MemAvailablePercent"),
+ Dimensions: dims,
+ Timestamp: aws.Time(now),
+ Unit: types.StandardUnitPercent,
+ Value: aws.Float64(availPct),
+ }}
+ if hasPSI {
+ data = append(data, types.MetricDatum{
+ MetricName: aws.String("MemoryPressureSome60"),
+ Dimensions: dims,
+ Timestamp: aws.Time(now),
+ Unit: types.StandardUnitPercent,
+ Value: aws.Float64(psi),
+ })
+ }
+ return &cloudwatch.PutMetricDataInput{Namespace: aws.String(memwatchNamespace), MetricData: data}
+}
+
+// imdsInstanceID asks the metadata service which instance this is — the
+// dimension the alarm keys on — through the same IMDSv2 token dance
+// readPlacementRegion uses (imdsToken/imdsLeaf). The IMDS uid-gate
+// (harden-host) lets the carlos user through by construction.
+func imdsInstanceID() (string, error) {
+ client := &http.Client{Timeout: 2 * time.Second}
+ token := imdsToken(client)
+ if token == "" {
+ return "", errors.New("no IMDSv2 token (not on EC2, or IMDS unreachable)")
+ }
+ id, code := imdsLeaf(client, token, "/latest/meta-data/instance-id")
+ if code != http.StatusOK || id == "" {
+ return "", errors.New("instance-id leaf did not answer 200")
+ }
+ return id, nil
+}
+
+// readPressureFile is the /proc/pressure/memory read; absent on a kernel
+// without PSI (CONFIG_PSI) — the tick then publishes MemAvailable alone.
+func readPressureFile() (string, error) {
+ b, err := os.ReadFile("/proc/pressure/memory")
+ return string(b), err
+}
+
+// parsePressureSome60 pulls "some ... avg60=N" out of /proc/pressure/memory.
+// Not-ok for any shape it doesn't recognise: a parse bug must not report a
+// calm box.
+func parsePressureSome60(s string) (float64, bool) {
+ for _, line := range strings.Split(s, "\n") {
+ fields := strings.Fields(line)
+ if len(fields) == 0 || fields[0] != "some" {
+ continue
+ }
+ for _, f := range fields[1:] {
+ if v, ok := strings.CutPrefix(f, "avg60="); ok {
+ n, err := strconv.ParseFloat(v, 64)
+ return n, err == nil
+ }
+ }
+ return 0, false
+ }
+ return 0, false
+}
diff --git a/internal/host/memwatch_test.go b/internal/host/memwatch_test.go
new file mode 100644
index 000000000..9068a4730
--- /dev/null
+++ b/internal/host/memwatch_test.go
@@ -0,0 +1,155 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/service/cloudwatch"
+)
+
+// TestParsePressureSome60 pins /proc/pressure/memory's shape: two lines,
+// "some" then "full", each with avg10/avg60/avg300/total. Only some/avg60
+// is wanted. A kernel without PSI has no file at all — the caller handles
+// that — but a file with an unexpected shape reports not-ok rather than 0,
+// so a parse bug can't masquerade as a healthy box.
+func TestParsePressureSome60(t *testing.T) {
+ good := "some avg10=0.00 avg60=12.50 avg300=3.10 total=171234\nfull avg10=0.00 avg60=0.00 avg300=0.00 total=153521\n"
+ if got, ok := parsePressureSome60(good); !ok || got != 12.5 {
+ t.Fatalf("parse = (%v, %v), want (12.5, true)", got, ok)
+ }
+ for name, s := range map[string]string{
+ "empty": "",
+ "full only": "full avg10=0.00 avg60=0.00 avg300=0.00 total=1\n",
+ "no avg60": "some avg10=0.00 avg300=0.00 total=1\n",
+ "not number": "some avg10=0.00 avg60=x avg300=0.00 total=1\n",
+ } {
+ if _, ok := parsePressureSome60(s); ok {
+ t.Errorf("%s: parsed ok, want not-ok", name)
+ }
+ }
+}
+
+// TestMemwatchData: the tick's PutMetricData input. Two data points when
+// PSI is readable, one when it is not; namespace, dimension and units are
+// what the region-host module's alarm and IAM condition name — a drift
+// here is an alarm that never fires, silently.
+func TestMemwatchData(t *testing.T) {
+ now := time.Date(2026, 8, 28, 11, 4, 0, 0, time.UTC)
+ in := memwatchData("i-0123", 14.2, 30.5, true, now)
+ if *in.Namespace != "Carlos/Host" {
+ t.Fatalf("namespace %q", *in.Namespace)
+ }
+ if len(in.MetricData) != 2 {
+ t.Fatalf("%d data points, want 2", len(in.MetricData))
+ }
+ names := map[string]float64{}
+ for _, d := range in.MetricData {
+ names[*d.MetricName] = *d.Value
+ if len(d.Dimensions) != 1 || *d.Dimensions[0].Name != "InstanceId" || *d.Dimensions[0].Value != "i-0123" {
+ t.Errorf("%s: dimensions %+v", *d.MetricName, d.Dimensions)
+ }
+ if !d.Timestamp.Equal(now) {
+ t.Errorf("%s: timestamp %v", *d.MetricName, d.Timestamp)
+ }
+ }
+ if names["MemAvailablePercent"] != 14.2 || names["MemoryPressureSome60"] != 30.5 {
+ t.Errorf("values %v", names)
+ }
+ if in := memwatchData("i-0123", 14.2, 0, false, now); len(in.MetricData) != 1 || *in.MetricData[0].MetricName != "MemAvailablePercent" {
+ t.Errorf("without PSI: %+v", in.MetricData)
+ }
+}
+
+type fakePutter struct {
+ calls []*cloudwatch.PutMetricDataInput
+}
+
+func (f *fakePutter) PutMetricData(_ context.Context, in *cloudwatch.PutMetricDataInput, _ ...func(*cloudwatch.Options)) (*cloudwatch.PutMetricDataOutput, error) {
+ f.calls = append(f.calls, in)
+ return &cloudwatch.PutMetricDataOutput{}, nil
+}
+
+// TestMemwatchTick: one tick, one call, built from the readers it is
+// handed. An unreadable meminfo is a skipped tick, not a zero.
+func TestMemwatchTick(t *testing.T) {
+ f := &fakePutter{}
+ w := memwatch{
+ put: f,
+ instanceID: "i-0123",
+ mem: func() (*int64, *int64) { tot, av := int64(4000), int64(1000); return &tot, &av },
+ pressure: func() (string, error) {
+ return "some avg10=0 avg60=7.5 avg300=0 total=0\nfull avg10=0 avg60=0 avg300=0 total=0\n", nil
+ },
+ logf: t.Logf,
+ }
+ w.tick(context.Background(), time.Unix(0, 0))
+ if len(f.calls) != 1 || len(f.calls[0].MetricData) != 2 {
+ t.Fatalf("calls %+v", f.calls)
+ }
+ if v := *f.calls[0].MetricData[0].Value; v != 25 {
+ t.Errorf("MemAvailablePercent = %v, want 25", v)
+ }
+ w.mem = func() (*int64, *int64) { return nil, nil }
+ w.tick(context.Background(), time.Unix(0, 0))
+ if len(f.calls) != 1 {
+ t.Errorf("a tick with unreadable meminfo must not put: %d calls", len(f.calls))
+ }
+}
+
+// TestMemwatchArmed: the gate's three answers. The unknown-value case is
+// the one that matters — an operator who typed CARLOS_HOST_METRICS=true
+// gets told what the only value is, in the journal, at arm time.
+func TestMemwatchArmed(t *testing.T) {
+ for v, want := range map[string]bool{"cloudwatch": true, " cloudwatch ": true, "": false, "true": false} {
+ armed, why := memwatchArmed(func(string) string { return v })
+ if armed != want {
+ t.Errorf("%q: armed=%v, want %v", v, armed, want)
+ }
+ if !armed && why == "" {
+ t.Errorf("%q: disabled with no reason", v)
+ }
+ }
+ if _, why := memwatchArmed(func(string) string { return "true" }); !strings.Contains(why, `"cloudwatch"`) {
+ t.Errorf("unknown value's reason %q does not name the only valid value", why)
+ }
+}
+
+type failPutter struct{ err error }
+
+func (f *failPutter) PutMetricData(context.Context, *cloudwatch.PutMetricDataInput, ...func(*cloudwatch.Options)) (*cloudwatch.PutMetricDataOutput, error) {
+ return nil, f.err
+}
+
+// TestMemwatchPutFailureLogsOnChange: the same failure minute after minute
+// is ONE line; a different failure is another; recovery is one more.
+func TestMemwatchPutFailureLogsOnChange(t *testing.T) {
+ var lines []string
+ fp := &failPutter{err: errors.New("AccessDenied")}
+ w := memwatch{
+ put: fp,
+ instanceID: "i-0123",
+ mem: func() (*int64, *int64) { tot, av := int64(4000), int64(1000); return &tot, &av },
+ pressure: func() (string, error) { return "", errors.New("no psi") },
+ logf: func(f string, a ...any) { lines = append(lines, f) },
+ }
+ for i := 0; i < 3; i++ {
+ w.tick(context.Background(), time.Unix(0, 0))
+ }
+ if len(lines) != 1 {
+ t.Fatalf("3 identical failures logged %d lines, want 1: %v", len(lines), lines)
+ }
+ fp.err = errors.New("Throttling")
+ w.tick(context.Background(), time.Unix(0, 0))
+ if len(lines) != 2 {
+ t.Fatalf("a changed failure did not log: %v", lines)
+ }
+ w.put = &fakePutter{}
+ w.tick(context.Background(), time.Unix(0, 0))
+ w.tick(context.Background(), time.Unix(0, 0))
+ if len(lines) != 3 || !strings.Contains(lines[2], "recovered") {
+ t.Fatalf("recovery logged wrong: %v", lines)
+ }
+}
diff --git a/internal/host/units/carlos-edge.service b/internal/host/units/carlos-edge.service
index ee57b7f40..018304cec 100644
--- a/internal/host/units/carlos-edge.service
+++ b/internal/host/units/carlos-edge.service
@@ -54,8 +54,29 @@ MemoryDenyWriteExecute=yes
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
SystemCallArchitectures=native
-# No MemoryMax: hibernating instances are this unit's children, so a cap here
-# would cap the whole set of awake instances at once.
+# The outer wall (memory-pressure spec 2026-08-28 §2.2). Exec-backed
+# instances are this unit's children, so these DO cap the whole set of
+# awake instances at once — that is now the point: on 2026-08-28 the
+# flagship thrashed for 15 minutes with no swap, no ceiling and an OOM
+# killer that never fired, because nothing bounded the tree. Percentages
+# so one file fits a t4g.micro and a t4g.medium; 80% leaves what the
+# box's other units (keymail, console, ssm, litestream, journald) use.
+# Past High the kernel reclaims the tree (page cache first, then zram);
+# at Max the memcg OOM killer picks the highest-scoring PROCESS in the
+# tree. By RSS alone that would be the agent itself (~110 MB against
+# 20-60 MB tenant processes), so the agent writes oom_score_adj=+500 on
+# every exec child it starts (internal/cgroup ExecOOMScoreAdj) — a tenant
+# process is always the first pick. Per-instance caps below this
+# (carlos-exec@<host> memory.max, internal/cgroup) are the inner walls.
+MemoryHigh=70%
+MemoryMax=80%
+# systemd's default OOMPolicy=stop STOPS the whole service when the kernel
+# OOM-kills ANY process in the unit's cgroup tree (memory.events is
+# hierarchical, delegation or not). Restart=always would bring the edge
+# back a second later and cycle every awake instance on the box. With
+# continue, one tenant's kill is one tenant's kill; the activator recycles
+# it on its next request.
+OOMPolicy=continue
TasksMax=1024
# Exec-backed instances run as this unit's children. Delegation hands the
# unit its own cgroup subtree — chowned to User=carlos, unmanaged by systemd