kicks_liveness 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/docs/LIMITATIONS.md CHANGED
@@ -39,25 +39,17 @@ aware that this probe no longer covers that failure mode.
39
39
 
40
40
  ## Broker unavailability is invisible by design
41
41
 
42
- While Bunny is recovering from a network failure, the probe reports healthy. This
43
- is deliberate: the alternative is that a ten-second broker hiccup restarts every
44
- replica simultaneously and reconnects them all at once, which is what a
45
- struggling broker least needs. Bunny reconnects and re-subscribes its consumers
46
- by itself, so a restart at that moment cures nothing.
47
-
48
- The consequence, plainly: a pod whose broker is unreachable looks healthy to
49
- this probe. Cover it with the alert.
42
+ The probe reports healthy while Bunny recovers an established connection and
43
+ re-subscribes consumers. This avoids restarting every replica during a broker
44
+ outage, but also means an unreachable broker is invisible to this probe. Cover
45
+ that case with the external consumer alert.
50
46
 
51
47
  ## The recovery exemption covers an established connection, not a failed subscribe
52
48
 
53
- While Bunny is recovering from a network failure the probe reports healthy, so
54
- that a broker hiccup does not restart every replica at once. That exemption is
55
- asked of a `connection` object, which has to exist before it can be asked.
56
-
57
- If the broker is unreachable at the moment a worker subscribes, there is no
58
- connection at all: the registry stays empty and no mark is ever written. So the
59
- cascade the exemption avoids for a live connection is still possible at startup
60
- — broker down, every replica restarting.
49
+ The exemption requires a connection object. If the initial subscription fails,
50
+ the worker never registers and no mark is written. A broker outage at startup
51
+ can therefore restart every replica even though an established connection would
52
+ be exempt.
61
53
 
62
54
  The budget here is the **startup** one, not the steady-state one:
63
55
 
@@ -78,6 +70,30 @@ flapping pod rather than a storm. And the supervisor respawning failing forks
78
70
  inside the container — which is *not* throttled by anything — is now reported:
79
71
  see the respawn escalation below.
80
72
 
73
+ ## Graceful shutdown is safe only when something else owns the deadline
74
+
75
+ `Registry.stopping!` suspends the consumer predicate before `WorkerGroup#stop`
76
+ unsubscribes workers and drains their pools. The monitor keeps the heartbeat
77
+ fresh for the rest of the process so liveness does not interrupt an in-flight
78
+ job. During pod deletion the kubelet enforces `terminationGracePeriodSeconds`,
79
+ which bounds this exemption externally.
80
+
81
+ A SIGTERM from an operator, sidecar, or internal supervisor enters the same
82
+ path without deleting the pod. If Sneakers or Bunny then drains or reconnects
83
+ indefinitely, the heartbeat stays fresh with zero consumers and the pod can
84
+ remain Ready forever. The process cannot infer an external deadline from the
85
+ signal.
86
+
87
+ This is existing shutdown behaviour, not a 0.1.3 regression. Until the exemption
88
+ is bounded — planned for 0.1.4 — send SIGTERM only through a termination path
89
+ that has its own enforced deadline. Use pod deletion for Kubernetes operations.
90
+ An internal supervisor must eventually make PID 1 exit itself, or ask the
91
+ container runtime to stop it, if graceful shutdown does not complete; an
92
+ in-namespace `kill -9 1` is not a reliable escalation even when `kill` reports
93
+ success. Keep the external consumer alert enabled as well: it still sees the
94
+ zero-consumer state that the process-local predicate has intentionally stopped
95
+ evaluating.
96
+
81
97
  ## Changing the worker count at runtime is not supported
82
98
 
83
99
  `ServerEngine` can re-read its configuration on SIGHUP and scale the fork set,
@@ -92,65 +108,133 @@ count and the retired slots eventually go stale. Neither direction is safe.
92
108
 
93
109
  If you change `workers`, restart the runner. Runtime scaling is not supported.
94
110
 
95
- ## Container generations require Linux procfs and container-owned PID 1
111
+ ## Container generations need Linux procfs and a private PID namespace
96
112
 
97
113
  Kubernetes keeps an `emptyDir` across restarts of a container in the same pod.
98
- To prevent the next container from inheriting a fresh heartbeat, the worker and
99
- exec probe independently identify their shared incarnation from Linux procfs:
100
- the mount namespace plus PID 1 start time. No application cache is removed.
101
-
102
- The identifier also assumes that the container owns PID 1. With
103
- `shareProcessNamespace: true`, PID 1 is the pod sandbox, and with
104
- `hostPID: true` it is the node's init process; neither restarts when the worker
105
- container does, so the start-time half of the identifier stays constant. The
106
- mount namespace inode is then the only remaining signal, and the kernel
107
- normally hands the just-released inode back to the replacement container in an
108
- otherwise quiet pod. Under either setting the guard silently degrades to the
109
- 0.1.1 freshness-only behavior and can accept a fresh mark from the previous
110
- container. Do not enable either setting on a pod whose `startupProbe` relies on
111
- this guarantee.
112
-
113
- If procfs is unavailable — for example, when using the gem outside a Linux
114
- container — generation detection falls back to the original freshness-only
115
- check. The worker and probe still function, but they cannot distinguish a fresh
116
- mark left by a previous process from one written by the current process. Linux
117
- Kubernetes, Docker, and Nomad containers expose the required procfs entries
118
- under their normal configuration.
114
+ To stop the next container inheriting a fresh heartbeat, the worker and its exec
115
+ probe independently identify their shared incarnation from Linux procfs: the
116
+ mount namespace plus PID 1 start time. No application cache is removed.
117
+
118
+ That is an incarnation only where the container owns PID 1. With
119
+ `shareProcessNamespace: true` PID 1 is the pod sandbox; with `hostPID: true` it
120
+ is the node's init process. Neither restarts when the worker container does.
121
+ If the replacement container also receives the same mount namespace inode,
122
+ the complete identifier repeats and an inherited fresh mark can satisfy the
123
+ 0.1.2 startup probe.
124
+
125
+ Rejection in 0.1.3 depends on procfs access. On the measured Kubernetes node,
126
+ default capabilities left shared PID `unverifiable` and host PID `unavailable`;
127
+ node init reported start time zero. Adding `CAP_SYS_PTRACE` made shared PID
128
+ provably `unsafe`. This is not a recommendation to add the capability.
129
+ The full restart reproduction and Kubernetes measurement are in
130
+ [VERIFYING.md](VERIFYING.md#12-shared-and-host-pid-topology-outcomes).
131
+
132
+ ### Proven unsafe: the probe fails, and keeps failing
133
+
134
+ Both links readable and different is proof that PID 1 belongs to something
135
+ outside this container. The probe exits 1 with
136
+
137
+ ```
138
+ unsupported PID topology: pod must not set shareProcessNamespace or hostPID
139
+ ```
140
+
141
+ Procfs access can differ between the worker and probe. A worker that proves the
142
+ topology unsafe publishes `topology-unsafe` instead of a heartbeat; a probe
143
+ rejects either that file or its own unsafe result before checking marks.
144
+ Inherited generations and another fork's marks cannot override the verdict.
145
+
146
+ The worker logs the reason and recovery at ERROR once for the uninterrupted
147
+ unsafe state, across forks and container restarts sharing the volume. Later
148
+ containers in that pod therefore do not repeat the ERROR; read the verdict from
149
+ the kubelet's `Unhealthy` event or `topology-unsafe`.
150
+
151
+ This state is terminal for the marks volume. Neither PID setting can change on
152
+ a live pod, and its `emptyDir` dies with it. **Recovery is recreating the pod
153
+ without those settings.** Until then probes fail and the container restarts.
154
+ Unreadable namespace links do not establish this verdict; they take a degraded
155
+ branch below.
156
+
157
+ ### Unverifiable: the 0.1.2 guarantee, and one warning
158
+
159
+ Reading `/proc/1/ns/mnt` is a ptrace-level operation and may be denied, including
160
+ when PID 1 runs as a different user. Denial cannot distinguish a valid private
161
+ PID namespace from an unsupported topology. If the other procfs entries yield a
162
+ generation, the probe retains its 0.1.2 check and output; the worker logs:
163
+
164
+ ```
165
+ WARN [liveness] slot 0: PID topology unverified (cannot read /proc/1/ns/mnt (Errno::EACCES)):
166
+ the container generation still scopes the heartbeat, but shareProcessNamespace and hostPID
167
+ cannot be ruled out
168
+ ```
169
+
170
+ Read this as unverifiable, not safe: an unsupported topology still has the
171
+ 0.1.2 inherited-heartbeat weakness. The WARN is once per derived generation,
172
+ across fork respawns. The measured Kubernetes shared-PID case reached this state;
173
+ its credentials and capability comparison are recorded in VERIFYING.md above.
174
+
175
+ There is a narrower startup window when the worker and probe have asymmetric
176
+ access: the worker can prove the topology unsafe, but the probe cannot. Before
177
+ that worker reaches the monitor and publishes `topology-unsafe`, the probe can
178
+ accept inherited marks only if the volume already contains a fresh heartbeat
179
+ from an older worker, or from one that also could not prove the topology. Both
180
+ conditions are required. A normal Kubernetes exec probe inherits the
181
+ container's credentials and reaches the same procfs verdict itself; after a
182
+ 0.1.3 worker has proved the topology, `topology-unsafe` also survives every
183
+ later restart in the same documented `emptyDir`. The residual window therefore
184
+ requires an in-place restart that also changes the image or effective access,
185
+ or a marks volume that outlives the pod contrary to this setup. In that case the
186
+ probe retains the explicitly degraded 0.1.2 guarantee until the current worker
187
+ publishes its verdict.
188
+
189
+ ### No generation at all
190
+
191
+ If `/proc/self/ns/mnt` or `/proc/1/stat` cannot yield a generation, the probe
192
+ falls back to the 0.1.1 freshness-only check and may accept fresh inherited
193
+ marks. The worker warns once for the uninterrupted unavailable state. A verified
194
+ topology clears the report flag so a later degradation is reported again:
195
+
196
+ ```
197
+ WARN [liveness] slot 0: no container generation (cannot read /proc/1/stat (Errno::EACCES)):
198
+ falling back to freshness only, which cannot reject a heartbeat inherited from a previous
199
+ container
200
+ ```
201
+
202
+ A zero start time uses the detail `PID 1 start time is zero; possible hostPID or
203
+ virtualized procfs` in that warning. The field counts clock ticks after boot,
204
+ so zero is valid, not a parse error. It was observed for node init in a host-PID
205
+ pod, but may also be exposed for a legitimate PID 1 by virtualized procfs.
206
+ Without measurements of those runtimes, zero alone remains `unavailable`.
207
+
208
+ Off Linux the same freshness-only fallback is **silent**, an intentional
209
+ compatibility mode. Degraded Linux warnings also stay off the probe's stdout:
210
+ they belong in the worker log, not repeated beside unrelated failures in
211
+ `Unhealthy` events. A proven unsafe topology is different: it is the probe's
212
+ verdict.
213
+
214
+ ### The probe must run in the worker's container
119
215
 
120
216
  The worker and its exec probe must run in the **same container**. A neighbouring
121
- sidecar can mount the same `emptyDir`, but it has a different mount namespace
122
- and therefore treats the worker's marks as belonging to another container.
217
+ sidecar can mount the same `emptyDir`, but it has a different mount namespace and
218
+ therefore treats the worker's marks as belonging to another container.
123
219
  Kubernetes exec probes already run inside the container they check; do not move
124
220
  `kicks-liveness` into a separate health sidecar.
125
221
 
126
222
  ## A respawn loop is reported once per grace window, not once per respawn
127
223
 
128
- When a fork cannot subscribe at all, the supervisor brings it back after
129
- `start_worker_delay` — a fraction of a second — and the monitor in each
130
- incarnation is a brand-new object. Any counter it holds is destroyed before it
131
- can reach a threshold, which is how a real outage used to produce thousands of
132
- identical INFO lines and not a single ERROR.
133
-
134
- The count is therefore kept in the marks directory, which survives the fork.
135
- The start line is logged once, repeated starts within the grace window
136
- (`startup_grace_ticks × tick`) are silent, and after that one ERROR per window
137
- reports the elapsed time and the number of starts:
224
+ The supervisor respawns a fork that cannot subscribe after `start_worker_delay`,
225
+ potentially before its monitor reaches startup grace. `attempt-<slot>` therefore
226
+ keeps the count and window start outside process memory. The first incarnation
227
+ logs startup, further starts within `startup_grace_ticks × tick` are silent, and
228
+ each expired window produces one ERROR with elapsed time and cumulative starts:
138
229
 
139
230
  ```
140
231
  [liveness] slot 0: not healthy 62s over 305 starts, expected 5 consumers
141
232
  ```
142
233
 
143
- The number of starts is the diagnosis: it separates a slow start from a respawn
144
- loop at a glance.
145
-
146
- This unhealthy-run counter is scoped to the marks volume, not to the container
147
- generation. If a container is restarted before its slot has ever become
148
- healthy, the replacement continues the same count: its repeated `started:` line
149
- is suppressed, and an expired grace window may immediately report an ERROR that
150
- includes starts from the previous container. This is diagnostic state only; it
151
- does not participate in the probe result, and the first healthy tick removes
152
- it. Treat the count as "starts since this slot was last healthy in this pod",
153
- not "starts in this container".
234
+ The generation in the file rejects an earlier container's count. A replacement
235
+ logs its own `started:` and cannot escalate over starts it did not take; the
236
+ first healthy tick clears the counter. Without a generation, the old volume-wide
237
+ scope remains: read it as starts since this slot was last healthy in the pod.
154
238
 
155
239
  ## Do not install both `kicks` and `sneakers`
156
240
 
data/docs/SETUP.md CHANGED
@@ -184,3 +184,14 @@ One `worker-<slot>` file per fork, plus `expected` and the current container
184
184
  have not finished subscribing. A slot that keeps restarting without ever
185
185
  subscribing also leaves an `attempt-<slot>` file, which is removed as soon as
186
186
  that slot becomes healthy.
187
+
188
+ The `generation` file is authoritative only while procfs generation detection is
189
+ available: where it is not, the worker writes no generation and a file left by
190
+ an earlier container stays visible, so `kubectl exec ... cat <dir>/generation`
191
+ can show a stale incarnation. It is not deleted in that state on purpose —
192
+ removing it could fail a concurrently running probe that does have a valid
193
+ generation, for a reason that has nothing to do with the worker's health. Two
194
+ further files appear only when something is wrong: `topology-unsafe`, which
195
+ fails every probe until the pod is recreated, and `topology-report`, which is
196
+ how the worker keeps what it says about the topology to one line. Both are explained in
197
+ [LIMITATIONS.md](LIMITATIONS.md#container-generations-need-linux-procfs-and-a-private-pid-namespace).
data/docs/VERIFYING.md CHANGED
@@ -35,6 +35,10 @@ spec/integration/verify.sh up # namespace, broker, worker
35
35
  spec/integration/verify.sh down # deletes the namespace
36
36
  ```
37
37
 
38
+ Scenarios 11 and 12 are the exception: they need the image and Docker, but no
39
+ cluster and no broker, because both are about the container itself rather than
40
+ about consuming.
41
+
38
42
  Every `kubectl` call names its context and namespace explicitly, so the script
39
43
  never falls back to `current-context`. It will still operate on whichever target
40
44
  you configure: run `status` and inspect both values before `up` or `down`. The
@@ -101,11 +105,11 @@ expected generation worker-0 # in the marks directory
101
105
  1 process(es) healthy # probe, exit 0
102
106
  ```
103
107
 
104
- The hooks fired inside a real ServerEngine fork, which no double can show. On a
105
- fresh fixture the first attempt also prints `started:`. Do not use that line as
106
- the sole proof after a same-pod container restart: an inherited unhealthy-run
107
- counter deliberately suppresses repeated start lines, as described in
108
- [LIMITATIONS.md](LIMITATIONS.md#a-respawn-loop-is-reported-once-per-grace-window-not-once-per-respawn).
108
+ The hooks fired inside a real ServerEngine fork, which no double can show. The
109
+ `started:` line is also valid proof after a same-pod container restart: since
110
+ 0.1.3 the respawn counter carries the container generation, so a replacement
111
+ container reports its own first start rather than continuing the previous
112
+ container's unhealthy run.
109
113
 
110
114
  ### 2. A stale mark
111
115
 
@@ -362,10 +366,103 @@ The namespace inode is allowed to be reused, as in the example above; the PID 1
362
366
  start time still distinguishes the two incarnations. The test removes its
363
367
  container and named volume on exit.
364
368
 
369
+ The command also prints the topology it ran under, which for an ordinary
370
+ container is the fully verified one:
371
+
372
+ ```
373
+ generation-restart: topology=verified
374
+ ```
375
+
365
376
  This scenario covers the default container-private PID namespace. With
366
377
  `shareProcessNamespace: true` or `hostPID: true`, PID 1 survives the worker
367
- container restart and this guarantee does not hold; see
368
- [LIMITATIONS.md](LIMITATIONS.md#container-generations-require-linux-procfs-and-container-owned-pid-1).
378
+ container restart and this guarantee cannot hold — scenario 12 is that case,
379
+ showing both fail-closed rejection when the topology can be proved and the
380
+ degraded outcomes when procfs access cannot prove it.
381
+
382
+ ### 12. Shared and host PID topology outcomes
383
+
384
+ The four PID topologies, in one command. It needs the fixture image but neither
385
+ Kubernetes nor RabbitMQ, because everything here turns on which PID namespace
386
+ the worker container is in:
387
+
388
+ ```bash
389
+ spec/integration/verify.sh build
390
+ spec/integration/verify.sh topology
391
+ ```
392
+
393
+ Recorded on Linux 6.12 (arm64), Docker 27.4.1, ruby 3.4.10:
394
+
395
+ ```
396
+ shared-pid: topology=unsafe
397
+ shared-pid: 0.1.2 generation before=mnt:[4026532693]:17207878 after=mnt:[4026532693]:17207878
398
+ shared-pid inherited heartbeat: exit=1 unsupported PID topology: pod must not set shareProcessNamespace or hostPID
399
+ shared-pid: marks directory holds expected generation topology-unsafe worker-0
400
+ shared-pid after the worker published: exit=1 unsupported PID topology: pod must not set shareProcessNamespace or hostPID
401
+ host-pid: topology=unsafe
402
+ host-pid: 0.1.2 generation before=mnt:[4026532699]:105 after=mnt:[4026532699]:105
403
+ host-pid inherited heartbeat: exit=1 unsupported PID topology: pod must not set shareProcessNamespace or hostPID
404
+ host-pid: marks directory holds expected generation topology-unsafe worker-0
405
+ host-pid after the worker published: exit=1 unsupported PID topology: pod must not set shareProcessNamespace or hostPID
406
+ private-pid: topology=verified
407
+ private-pid: exit=0 1 process(es) healthy
408
+ unverifiable: topology=unverifiable
409
+ unverifiable: warnings across three fork respawns=1
410
+ unverifiable inherited heartbeat: exit=1 heartbeat belongs to a previous container: worker has not started yet
411
+ unverifiable own heartbeat: exit=0 1 process(es) healthy
412
+ ```
413
+
414
+ Four cases, and each line is the point of one of them.
415
+
416
+ A separate run on a real Kubernetes v1.25.0 node on Docker Desktop used
417
+ containerd, no `securityContext`, a root worker and the default capability set.
418
+ It produced a different and operationally important table:
419
+
420
+ | pod topology | PID 1 | PID 1 UID | `/proc/1/ns/mnt` | start time | 0.1.3 state |
421
+ |---|---|---:|---|---:|---|
422
+ | `hostPID: true` | node init | 0 | denied | 0 | `unavailable` |
423
+ | `shareProcessNamespace: true` | pod sandbox | 65535 | denied | 19031096 | `unverifiable` |
424
+ | private PID | container process | 0 | readable | 19031243 | `verified` |
425
+ | shared PID plus `CAP_SYS_PTRACE` | pod sandbox | 65535 | readable | — | `unsafe` |
426
+
427
+ The Docker matrix proves the fail-closed branch by granting the access it needs;
428
+ the Kubernetes measurement shows that this is not the default result for either
429
+ unsupported setting. The degraded states still warn, but they retain the weaker
430
+ generation or freshness-only guarantees described in LIMITATIONS.md.
431
+
432
+ **`shared-pid`** joins another container's PID namespace, which is what
433
+ Kubernetes `shareProcessNamespace: true` does — there PID 1 is the pod sandbox.
434
+ **`host-pid`** uses the node's PID namespace, which is `hostPID: true`. In both,
435
+ the run publishes a complete, fresh heartbeat in the **0.1.2 format**, restarts
436
+ only the worker container, and recomputes the 0.1.2 identifier by the 0.1.2
437
+ rule. The `before`/`after` pair is the defect being reproduced rather than
438
+ argued about: both halves repeat, including the mount namespace inode, which the
439
+ kernel handed straight back to the replacement container. A 0.1.2 probe would
440
+ have accepted those inherited marks. The revised probe rejects the topology
441
+ before it ever looks at them, and the worker then adds `topology-unsafe` beside
442
+ the inherited files rather than a heartbeat of its own.
443
+
444
+ Reading PID 1's namespace link is a ptrace-level operation, so the `host-pid`
445
+ case runs with `CAP_SYS_PTRACE`; without it the link is unreadable and the
446
+ outcome depends on whether a generation can still be derived. Node init on the
447
+ measured Kubernetes node reported start time zero, so that pod was
448
+ `unavailable`. A readable positive start time would instead leave the topology
449
+ `unverifiable`. The fourth case below constructs that latter branch explicitly;
450
+ both are honest limits on what a container can prove about a topology it is not
451
+ allowed to inspect.
452
+
453
+ **`private-pid`** is the fully verified positive control: an ordinary container,
454
+ owning PID 1, probe green.
455
+
456
+ **`unverifiable`** is the second positive control, and the one that has to stay
457
+ green: a private-PID container whose checker cannot read `/proc/1/ns/mnt`. The
458
+ 0.1.2 generation check is kept in full — a heartbeat from another generation is
459
+ still rejected, its own is still accepted — and the cost is exactly one worker
460
+ warning across three fork respawns, not a probe that is permanently red.
461
+
462
+ That case is constructed by running the checker as a user other than PID 1's.
463
+ It is **not** a model of a Kubernetes exec probe, which has no user setting of
464
+ its own and normally inherits the container process credentials; it is simply
465
+ the one way to make a namespace link unreadable without a custom procfs mount.
369
466
 
370
467
  ## What to do with a disagreement
371
468
 
@@ -3,23 +3,18 @@ require 'fileutils'
3
3
  module KicksLiveness
4
4
  # Counts starts of a supervisor slot that have not reached healthy yet.
5
5
  #
6
- # Deliberately separate from {Heartbeat}. Heartbeat is the contract shared
7
- # with the probe and is loaded by it alone, so nothing the probe does not need
8
- # belongs there — including the +fileutils+ this class requires freely.
6
+ # Persistent state survives fork respawns; its generation separates container
7
+ # incarnations when available. Kept outside {Heartbeat} because the probe does
8
+ # not read these counters.
9
9
  #
10
- # The count lives in the marks directory rather than in memory because a
11
- # respawn storm destroys process state every few hundred milliseconds: the
12
- # supervisor brings a failing fork back after +start_worker_delay+, and every
13
- # incarnation of the monitor believes it is the first. That is how an outage
14
- # can produce thousands of identical INFO lines and not one ERROR. The
15
- # directory is the only state that survives the fork.
16
- #
17
- # @see file:docs/DESIGN.md#logging-events-not-the-pulse
10
+ # @see file:docs/LIMITATIONS.md#a-respawn-loop-is-reported-once-per-grace-window-not-once-per-respawn
18
11
  # @api private
19
12
  class Attempts
20
13
  # @param dir [String] marks directory
21
- def initialize(dir)
14
+ # @param generation [String, nil] container incarnation the count belongs to
15
+ def initialize(dir, generation: nil)
22
16
  @dir = dir
17
+ @generation = generation
23
18
  end
24
19
 
25
20
  # Records a start of this slot.
@@ -62,22 +57,32 @@ module KicksLiveness
62
57
  File.join(@dir, "attempt-#{slot}")
63
58
  end
64
59
 
65
- # A missing or unreadable counter means "no previous start", never an
66
- # exception: this is instrumentation, and it may not break a worker.
60
+ # Missing, unreadable, or foreign-generation counts start a new run.
61
+ # Counter failures must not prevent worker startup.
67
62
  def read(slot)
68
- count, epoch = File.read(path(slot)).split
63
+ count, epoch, generation = File.read(path(slot)).split
64
+ return [0, nil] unless same_generation?(generation)
65
+
69
66
  [Integer(count), Time.at(Integer(epoch)).utc]
70
67
  rescue StandardError
71
68
  [0, nil]
72
69
  end
73
70
 
71
+ def same_generation?(declared)
72
+ return true unless @generation
73
+
74
+ declared == "generation=#{@generation}"
75
+ end
76
+
74
77
  # Failing to write degrades the reporting back to one line per respawn,
75
78
  # which is noisy but harmless — so a read-only directory is swallowed here
76
79
  # rather than escalated.
77
80
  def write(slot, count, first)
78
81
  FileUtils.mkdir_p(@dir)
82
+ contents = "#{count} #{first.to_i}"
83
+ contents = "#{contents} generation=#{@generation}" if @generation
79
84
  tmp = "#{path(slot)}.#{Process.pid}"
80
- File.write(tmp, "#{count} #{first.to_i}\n")
85
+ File.write(tmp, "#{contents}\n")
81
86
  File.rename(tmp, path(slot))
82
87
  rescue StandardError
83
88
  nil
@@ -3,11 +3,8 @@ require_relative 'heartbeat'
3
3
  module KicksLiveness
4
4
  # What the application may configure, and what comes from the environment only.
5
5
  #
6
- # +dir+ and +max_age+ are deliberately read-only here: the probe runs as a
7
- # separate process launched by the container runtime and cannot read the
8
- # application's initializer. Were they settable in both places, a mismatch
9
- # between what the worker writes and what the probe expects would pass
10
- # unnoticed.
6
+ # dir and max_age are environment-only: the separate exec probe cannot read
7
+ # an application initializer and must resolve the same values as the worker.
11
8
  #
12
9
  # @see file:docs/SETUP.md#configuration
13
10
  # @see file:docs/DESIGN.md#what-is-configurable-and-where
@@ -44,10 +41,8 @@ module KicksLiveness
44
41
  @enabled
45
42
  end
46
43
 
47
- # A tick is what the monitor thread sleeps on, so a non-positive value is
48
- # not a setting but a broken monitor. An initializer is code, and code
49
- # should fail loudly at boot, where the developer is looking. Environment
50
- # input is resolved separately by {#environment_tick}, with a safe fallback.
44
+ # Initializer values fail at boot; environment input uses a safe fallback
45
+ # through {#environment_tick}.
51
46
  #
52
47
  # @param seconds [Numeric]
53
48
  # @raise [ArgumentError] if not a positive number