Skip to content

Commit 6f2e01a

Browse files
committed
Surface genuine pipeline copy-pump read failures
2 parents d774cf1 + 9c07e11 commit 6f2e01a

5 files changed

Lines changed: 141 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
- `ProcessKit.Testing.FaultInjectingRunner` adds scripted, first-N, and seeded deterministic failure scenarios with virtual-time latency.
1818
- A performance and scalability guide documents event-driven waits, buffer tuning, fleet sizing, and benchmark interpretation.
1919

20+
### Changed
21+
- `Supervisor.LivenessMemory` now documents its intentional monotonic peak-memory contract: a transient peak is not forgiven by later lower usage, and `LivenessFailures` only delays the restart after the crossing.
22+
2023
### Fixed
2124
- Pipeline relays now surface genuine upstream read failures instead of treating truncated downstream input as successful, while keeping expected broken-pipe and teardown races quiet.
2225
- A supervision session stopped before any incarnation produced a result no longer starts one more child just to report one, and ends with the failure that kept the child from starting — or `ProcessError.Cancelled` when no incarnation was ever started.

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,10 @@ of hammering restarts at backoff speed. Supervision runs through the `IProcessRu
523523
`.WithRunner(group)` to keep every incarnation in one shared kill-on-dispose group, or a
524524
`ScriptedRunner` to test supervision logic hermetically.
525525

526+
The optional `LivenessMemory` probe intentionally samples attributable **peak** tree memory for each
527+
incarnation. A transient peak remains a violation after current usage falls, so choose a threshold above
528+
expected startup spikes when they should not cause a restart; unsupported backends return a typed error.
529+
526530
*Deeper: [Supervision](docs/supervision.md).*
527531

528532
## Waiting for a child to be ready

docs/supervision.md

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -296,9 +296,12 @@ var byMemory = new Supervisor(new Command("worker"))
296296
`LivenessMemory(maxBytes)` uses the current liveness interval; the two-argument overload sets it in
297297
the same call. It samples whole-tree peak resident memory from the run's private Job Object or cgroup,
298298
so descendants count and a value that crosses the threshold remains over it for that incarnation.
299-
ProcessKit never substitutes leader-only or shared-group memory: when the active backend cannot
300-
provide an attributable tree metric, supervision stops the live child and returns a typed
301-
`ProcessError.Unsupported`.
299+
This is deliberately a peak contract, not a current-working-set contract: a transient spike is still a
300+
memory-liveness violation after current usage falls, and `LivenessFailures` only delays the restart
301+
after that crossing. Set `maxBytes` above expected startup/transient peaks when those peaks should not
302+
restart the child. ProcessKit never substitutes leader-only or shared-group memory: when the active
303+
backend cannot provide an attributable tree metric, supervision stops the live child and returns a
304+
typed `ProcessError.Unsupported` (including the POSIX process-group fallback).
302305

303306
Every `LivenessHttp` form also accepts a caller-owned `HttpClient` immediately after the URI. Use it
304307
for authentication headers, custom certificate validation, proxies, or a custom transport such as HTTP
@@ -335,10 +338,13 @@ How it behaves:
335338

336339
- **When it restarts.** After `LivenessFailures` **consecutive** failed attempts, the supervisor
337340
gracefully stops the child (a `LivenessGrace` soft-stop window, then a hard kill) and restarts it
338-
through the **ordinary** restart paththe same `RestartPolicy`, backoff, jitter, `MaxRestarts`
339-
budget, and storm guard apply. It is not a second, parallel restart mechanism. A single healthy
340-
attempt resets the run, so a brief blip that recovers does not restart the child. The first attempt
341-
runs one `LivenessInterval` after the child starts, a natural startup window.
341+
through the **ordinary** restart paththe same `RestartPolicy`, backoff, jitter, `MaxRestarts`
342+
budget, and storm guard apply. It is not a second, parallel restart mechanism. For HTTP and
343+
predicate probes, a single healthy attempt resets the run, so a brief blip that recovers does not
344+
restart the child. Memory uses the monotonic peak described above: healthy samples reset the run
345+
only before the peak crosses `maxBytes`, and lower current usage afterward cannot make the sample
346+
healthy again. The first attempt runs one `LivenessInterval` after the child starts, a natural
347+
startup window.
342348

343349
The soft phase is the supervised command's `Command.StopSignal` (default `Signal.Term`). The same
344350
setting is therefore honored by an explicit supervision-session `StopAsync`, a liveness restart, and

src/ProcessKit/Supervisor.fs

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,8 @@ type internal LivenessProbe =
207207
/// Evaluate an arbitrary async predicate each attempt; the child is healthy when it returns `true`.
208208
| Custom of probe: (unit -> Task<bool>)
209209

210-
/// Sample attributable whole-tree peak memory; healthy while it is at or below `maxBytes`.
210+
/// Sample attributable whole-tree peak memory since the incarnation started; healthy while the
211+
/// peak is at or below `maxBytes`. This is intentionally not a current-working-set sample.
211212
| Memory of maxBytes: int64
212213

213214
/// The immutable configuration behind a `Supervisor`. Internal — built through the `Supervisor`
@@ -552,6 +553,9 @@ type SupervisionSession internal (config: SupervisorConfig, cancellationToken: C
552553
check, None
553554
| LivenessProbe.Memory maxBytes ->
554555
let check (_probeToken: CancellationToken) =
556+
// ProcessGroupStats.PeakMemoryBytes is monotonic for a private Job/cgroup
557+
// incarnation. A later lower working set therefore cannot forgive a peak that
558+
// already crossed the limit; this is the explicit memory-liveness contract.
555559
match running.TreePeakMemoryBytes() with
556560
| Ok bytes when bytes <= maxBytes -> Task.FromResult(Ok())
557561
| Ok _ -> Task.FromResult(Error(ProcessError.NotReady(program, probeTimeout)))
@@ -1384,9 +1388,11 @@ type Supervisor internal (config: SupervisorConfig) =
13841388
)
13851389

13861390
/// Enable a whole-process-tree **memory liveness probe**. The supervisor samples attributable
1387-
/// peak resident memory every configured liveness interval and treats a value above `maxBytes`
1388-
/// as a failed attempt. After `LivenessFailures` consecutive failures it gracefully stops and
1389-
/// restarts the child through the ordinary liveness path. `maxBytes` must be positive.
1391+
/// peak resident memory since the incarnation started every configured liveness interval and
1392+
/// treats a value above `maxBytes` as a failed attempt. The peak is monotonic for that
1393+
/// incarnation: once it crosses the limit, later lower current usage does not produce a healthy
1394+
/// memory attempt. `LivenessFailures` therefore controls how many observations precede the
1395+
/// restart, but cannot forgive an already-crossed peak. `maxBytes` must be positive.
13901396
///
13911397
/// Whole-tree accounting requires a private Job Object or cgroup. If the active backend cannot
13921398
/// provide an attributable metric, supervision ends with a typed `ProcessError.Unsupported`
@@ -1410,10 +1416,12 @@ type Supervisor internal (config: SupervisorConfig) =
14101416
LivenessInterval = Liveness.clampInterval interval }
14111417
)
14121418

1413-
/// How many **consecutive** failed liveness attempts trip a restart (default `3`). A single healthy
1414-
/// attempt resets the run, so a flaky endpoint that recovers does not restart the child. `count`
1415-
/// must be at least `1`. No effect unless a liveness probe (`LivenessHttp`/`LivenessCheck`/
1416-
/// `LivenessMemory`) is set.
1419+
/// How many **consecutive** failed liveness attempts trip a restart (default `3`). For HTTP and
1420+
/// predicate probes, a single healthy attempt resets the run, so a flaky endpoint that recovers
1421+
/// does not restart the child. For `LivenessMemory`, a healthy attempt resets the run only while
1422+
/// the incarnation's peak is still at or below the limit; after the monotonic peak crosses it,
1423+
/// later lower current usage remains failed. `count` must be at least `1`. No effect unless a
1424+
/// liveness probe (`LivenessHttp`/`LivenessCheck`/`LivenessMemory`) is set.
14171425
member _.LivenessFailures(count: int) =
14181426
ArgumentOutOfRangeException.ThrowIfLessThan(count, 1, nameof count)
14191427
Supervisor({ config with LivenessFailures = count })

tests/ProcessKit.Tests/SupervisorTests.fs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1969,6 +1969,69 @@ type SupervisorTests() =
19691969
}
19701970
:> Task
19711971

1972+
[<Test>]
1973+
member _.``healthy attributable memory samples do not restart before a peak crossing``() : Task =
1974+
task {
1975+
let maxBytes = 1024L
1976+
let healthySamples = [| 256L; 768L; 1024L |]
1977+
let observedSamples = ResizeArray<int64>()
1978+
1979+
let sampledEnough =
1980+
TaskCompletionSource<unit>(TaskCreationOptions.RunContinuationsAsynchronously)
1981+
1982+
let mutable sampleIndex = 0
1983+
let mutable delayCalls = 0
1984+
1985+
let treeStats () =
1986+
let index = Interlocked.Increment(&sampleIndex) - 1
1987+
let bytes = healthySamples[min index (healthySamples.Length - 1)]
1988+
observedSamples.Add bytes
1989+
1990+
if index + 1 = healthySamples.Length then
1991+
sampledEnough.TrySetResult() |> ignore
1992+
1993+
Some(ProcessGroupStats(1, None, Some bytes, None))
1994+
1995+
let deterministicDelay (_: TimeSpan) (cancellationToken: CancellationToken) : Task =
1996+
if Interlocked.Increment(&delayCalls) <= healthySamples.Length then
1997+
Task.CompletedTask
1998+
else
1999+
Task.Delay(Timeout.Infinite, cancellationToken)
2000+
2001+
let runner = MemoryLivenessRunner(Some treeStats)
2002+
let events = ResizeArray<SupervisorRestartEvent>()
2003+
2004+
let supervisor =
2005+
Supervisor(Command.create "memory-healthy")
2006+
.WithRunner(runner)
2007+
.Backoff(TimeSpan.Zero, 1.0)
2008+
.Jitter(false)
2009+
.MaxRestarts(1)
2010+
.LivenessMemory(maxBytes, TimeSpan.FromMilliseconds 1.0)
2011+
.LivenessFailures(1)
2012+
.LivenessGrace(TimeSpan.Zero)
2013+
.OnRestart(fun event -> events.Add event)
2014+
.WithLivenessDelay
2015+
deterministicDelay
2016+
2017+
let! session = supervisor.StartAsync()
2018+
do! sampledEnough.Task.WaitAsync(TimeSpan.FromSeconds 10.0)
2019+
2020+
Assert.That(observedSamples |> Seq.toArray, Is.EqualTo<int64> healthySamples)
2021+
Assert.That(session.Status.Restarts, Is.EqualTo 0, "healthy memory samples do not restart the child")
2022+
Assert.That(events.Count, Is.EqualTo 0, "healthy memory samples do not emit liveness restart events")
2023+
Assert.That(runner.Spawns, Is.EqualTo 1, "the healthy incarnation remains the only spawn")
2024+
2025+
let! outcome = session.StopAsync(TimeSpan.FromMilliseconds 100.0)
2026+
2027+
match outcome with
2028+
| Ok result ->
2029+
Assert.That(result.Stopped, Is.EqualTo StopReason.Stopped)
2030+
Assert.That(result.Restarts, Is.EqualTo 0)
2031+
| Error error -> Assert.Fail $"expected a clean stop after healthy memory samples, got {error}"
2032+
}
2033+
:> Task
2034+
19722035
[<Test>]
19732036
member _.``whole-tree memory above the threshold uses the ordinary liveness restart path``() : Task =
19742037
task {
@@ -2005,6 +2068,48 @@ type SupervisorTests() =
20052068
}
20062069
:> Task
20072070

2071+
[<Test>]
2072+
member _.``memory liveness keeps a recovered current sample failed after a peak crossing``() : Task =
2073+
task {
2074+
let mutable samples = 0
2075+
let mutable peakBytes = 0L
2076+
2077+
let treeStats () =
2078+
let sample = Interlocked.Increment(&samples)
2079+
// Model a transient current-use spike followed by recovery. The backend exposes the
2080+
// accumulated peak, so the second sample remains over the limit even though current
2081+
// usage has fallen back below it.
2082+
let currentBytes = if sample = 1 then 4096L else 512L
2083+
peakBytes <- max peakBytes currentBytes
2084+
Some(ProcessGroupStats(1, None, Some peakBytes, None))
2085+
2086+
let runner = MemoryLivenessRunner(Some treeStats)
2087+
let events = ResizeArray<SupervisorRestartEvent>()
2088+
2089+
let supervisor =
2090+
Supervisor(Command.create "memory-spike")
2091+
.WithRunner(runner)
2092+
.Backoff(TimeSpan.Zero, 1.0)
2093+
.Jitter(false)
2094+
.MaxRestarts(1)
2095+
.LivenessMemory(1024L, TimeSpan.FromMilliseconds 1.0)
2096+
.LivenessFailures(2)
2097+
.LivenessGrace(TimeSpan.Zero)
2098+
.OnRestart(fun event -> events.Add event)
2099+
2100+
match! supervisor.RunAsync() with
2101+
| Ok outcome ->
2102+
Assert.That(outcome.Restarts, Is.EqualTo 1)
2103+
Assert.That(outcome.Stopped, Is.EqualTo StopReason.RestartsExhausted)
2104+
Assert.That(runner.Spawns, Is.EqualTo 2)
2105+
Assert.That(runner.GracefulStops, Is.EqualTo 2)
2106+
Assert.That(Volatile.Read(&samples), Is.GreaterThanOrEqualTo 4)
2107+
Assert.That(events.Count, Is.EqualTo 1)
2108+
Assert.That(events[0].Cause, Is.EqualTo RestartCause.Liveness)
2109+
| Error error -> Assert.Fail $"expected the peak violation to restart after recovery, got {error}"
2110+
}
2111+
:> Task
2112+
20082113
[<Test>]
20092114
member _.``memory liveness fails honestly when whole-tree accounting is unavailable``() : Task =
20102115
task {

0 commit comments

Comments
 (0)