Skip to content

Commit c300ef0

Browse files
authored
fix(state-writer): skip atomic rename when content unchanged (#94)
* fix(state-writer): skip atomic rename when content unchanged Wrappers under Restart=always with a sleep+exit-0 disabled branch (dump978-fa's no_hardware loop, airplanes-mlat's mlat_enabled_false loop) re-classify on every cycle and call airplanes_write_state with the same decision but a fresh decided_at timestamp. The atomic rename was firing every ~1200 cycles/day on a typical no-978-dongle feeder, waking any inotify consumer (e.g. systemd .path units watching the file). Compare the proposed content against the current target ignoring decided_at= lines and skip the rename when they match. Existing on-disk decided_at stays, so consumers see the time of the last material decision rather than the time of the last wrapper restart. AIRPLANES_WRITE_STATE_FORCE=1 keeps the previous always-rename behavior for callers that need it. * fix(state-writer): include trailing line in dedupe canonicaliser A target left behind without a final newline (interrupted previous write, external tampering) would lose its last line in the bash while-read loop, so a proposed write that removed a tail field could falsely dedupe against the truncated remainder. The `|| [[ -n line ]]` tail picks up the unterminated last line. Regression coverage includes the no-trailing-newline case and a backslashes-plus-trailing-spaces round-trip.
1 parent 01ffe62 commit c300ef0

2 files changed

Lines changed: 249 additions & 1 deletion

File tree

scripts/lib/state-writer.sh

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@
2020
# connected) is observable via systemd liveness, journal output, and
2121
# external probes.
2222
#
23+
# Dedupe: when an activation re-runs the wrapper and produces the same
24+
# decision (the common case for wrappers that sleep + exit 0 under
25+
# Restart=always — dump978-fa's no_hardware loop, airplanes-mlat's
26+
# disabled loop), the writer skips the atomic-rename if the proposed
27+
# content equals the current file content, ignoring decided_at= (which
28+
# is by convention a per-activation timestamp every caller refreshes).
29+
# Net effects: mtime stays at the time of the last *material* change,
30+
# inotify consumers (e.g. systemd .path units) don't fire on no-op
31+
# re-runs. AIRPLANES_WRITE_STATE_FORCE=1 disables this and always
32+
# atomically replaces the target.
33+
#
2334
# Readers MUST NOT `source` this file — values are unquoted; arbitrary
2435
# string content (e.g. user-supplied MLAT_USER) could contain shell
2536
# metacharacters. Parse line-by-line with KEY=VALUE split on first `=`.
@@ -34,7 +45,27 @@
3445
# mlat_user=
3546
#
3647
# Returns 0 on success, 1 on validation failure or write error
37-
# (target file is left unchanged on either).
48+
# (target file is left unchanged on either). Returns 0 with target
49+
# unchanged when the dedupe path skips the rename.
50+
51+
# Print $1's content with decided_at= lines filtered out. Used by the
52+
# dedupe path so a fresh timestamp on an otherwise-identical decision
53+
# doesn't force a rename. The `|| [[ -n "$line" ]]` tail catches an
54+
# existing target that's missing its final newline — without it the
55+
# loop would silently drop the last line and a proposed write that
56+
# removed a tail field could falsely dedupe against the truncated
57+
# remainder. (The writer's own output always ends in \n, so this only
58+
# matters for files left behind by an interrupted write or external
59+
# tampering.)
60+
_airplanes_write_state_canonical() {
61+
local line
62+
while IFS= read -r line || [[ -n "$line" ]]; do
63+
case "$line" in
64+
decided_at=*) continue ;;
65+
esac
66+
printf '%s\n' "$line"
67+
done < "$1"
68+
}
3869

3970
airplanes_write_state() {
4071
local target="$1"; shift
@@ -81,6 +112,17 @@ airplanes_write_state() {
81112
return 1
82113
fi
83114

115+
# Dedupe path: same decision as already on disk → skip the rename.
116+
# The mode-preserving chmod is also skipped — the existing file's
117+
# mode reflects an earlier successful write that already set 0644.
118+
if [[ "${AIRPLANES_WRITE_STATE_FORCE:-}" != "1" ]] \
119+
&& [[ -f "$target" && -r "$target" ]] \
120+
&& [[ "$(_airplanes_write_state_canonical "$tmp")" \
121+
== "$(_airplanes_write_state_canonical "$target")" ]]; then
122+
rm -f "$tmp"
123+
return 0
124+
fi
125+
84126
chmod 0644 "$tmp" || { rm -f "$tmp"; return 1; }
85127
mv -f "$tmp" "$target" || { rm -f "$tmp"; return 1; }
86128
return 0

test/test_state_writer.bats

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,209 @@ bar"
143143
grep -qx 'service=second' "$TARGET"
144144
! grep -qx 'service=first' "$TARGET"
145145
}
146+
147+
# --- Dedupe path ---
148+
# The writer skips the atomic rename when the proposed content matches
149+
# the current target ignoring decided_at= lines. This kills inotify
150+
# wakeups on no-op wrapper re-runs (Restart=always + sleep + exit 0)
151+
# without changing the on-disk content readers see.
152+
153+
@test "dedupe: identical content with same decided_at → no rename, mtime preserved" {
154+
airplanes_write_state "$TARGET" \
155+
service=airplanes-mlat \
156+
state=enabled \
157+
reason=ok \
158+
decided_at=2026-05-18T10:00:00Z
159+
local mtime_before
160+
mtime_before="$(stat -c %Y "$TARGET")"
161+
sleep 1 # widen mtime resolution window
162+
airplanes_write_state "$TARGET" \
163+
service=airplanes-mlat \
164+
state=enabled \
165+
reason=ok \
166+
decided_at=2026-05-18T10:00:00Z
167+
local mtime_after
168+
mtime_after="$(stat -c %Y "$TARGET")"
169+
[ "$mtime_after" -eq "$mtime_before" ]
170+
}
171+
172+
@test "dedupe: identical content with fresh decided_at → no rename" {
173+
# Every wrapper restart cycle re-computes decided_at. Without
174+
# ignoring it the file would be atomically replaced ~1200 times/day
175+
# on a feeder where the daemon's classify produces the same result
176+
# cycle after cycle.
177+
airplanes_write_state "$TARGET" \
178+
service=airplanes-mlat \
179+
state=disabled \
180+
reason=mlat_enabled_false \
181+
decided_at=2026-05-18T10:00:00Z
182+
local mtime_before
183+
mtime_before="$(stat -c %Y "$TARGET")"
184+
sleep 1
185+
airplanes_write_state "$TARGET" \
186+
service=airplanes-mlat \
187+
state=disabled \
188+
reason=mlat_enabled_false \
189+
decided_at=2026-05-18T10:00:30Z
190+
local mtime_after
191+
mtime_after="$(stat -c %Y "$TARGET")"
192+
[ "$mtime_after" -eq "$mtime_before" ]
193+
# Old decided_at stays — readers see the time of the last material
194+
# decision, not the time of the last wrapper restart.
195+
grep -qx 'decided_at=2026-05-18T10:00:00Z' "$TARGET"
196+
}
197+
198+
@test "dedupe: changed non-timestamp field → full rename happens" {
199+
airplanes_write_state "$TARGET" \
200+
service=airplanes-mlat \
201+
state=enabled \
202+
reason=ok \
203+
decided_at=2026-05-18T10:00:00Z
204+
local mtime_before
205+
mtime_before="$(stat -c %Y "$TARGET")"
206+
sleep 1
207+
airplanes_write_state "$TARGET" \
208+
service=airplanes-mlat \
209+
state=disabled \
210+
reason=mlat_enabled_false \
211+
decided_at=2026-05-18T10:00:30Z
212+
local mtime_after
213+
mtime_after="$(stat -c %Y "$TARGET")"
214+
[ "$mtime_after" -gt "$mtime_before" ]
215+
grep -qx 'state=disabled' "$TARGET"
216+
grep -qx 'reason=mlat_enabled_false' "$TARGET"
217+
grep -qx 'decided_at=2026-05-18T10:00:30Z' "$TARGET"
218+
}
219+
220+
@test "dedupe: added field → full rename happens" {
221+
airplanes_write_state "$TARGET" \
222+
service=airplanes-mlat \
223+
state=disabled \
224+
decided_at=2026-05-18T10:00:00Z
225+
local mtime_before
226+
mtime_before="$(stat -c %Y "$TARGET")"
227+
sleep 1
228+
airplanes_write_state "$TARGET" \
229+
service=airplanes-mlat \
230+
state=disabled \
231+
decided_at=2026-05-18T10:00:30Z \
232+
new_field=v1
233+
local mtime_after
234+
mtime_after="$(stat -c %Y "$TARGET")"
235+
[ "$mtime_after" -gt "$mtime_before" ]
236+
grep -qx 'new_field=v1' "$TARGET"
237+
}
238+
239+
@test "dedupe: removed field → full rename happens" {
240+
airplanes_write_state "$TARGET" \
241+
service=airplanes-mlat \
242+
state=enabled \
243+
reason=ok \
244+
decided_at=2026-05-18T10:00:00Z \
245+
extra=v
246+
local mtime_before
247+
mtime_before="$(stat -c %Y "$TARGET")"
248+
sleep 1
249+
airplanes_write_state "$TARGET" \
250+
service=airplanes-mlat \
251+
state=enabled \
252+
reason=ok \
253+
decided_at=2026-05-18T10:00:30Z
254+
local mtime_after
255+
mtime_after="$(stat -c %Y "$TARGET")"
256+
[ "$mtime_after" -gt "$mtime_before" ]
257+
! grep -qx 'extra=v' "$TARGET"
258+
}
259+
260+
@test "dedupe: reordered fields → full rename happens (order is semantic)" {
261+
# KEY=VALUE order is part of the writer's contract (caller-provided
262+
# order). A reorder is a content change.
263+
airplanes_write_state "$TARGET" \
264+
service=foo \
265+
state=enabled \
266+
decided_at=2026-05-18T10:00:00Z
267+
local mtime_before
268+
mtime_before="$(stat -c %Y "$TARGET")"
269+
sleep 1
270+
airplanes_write_state "$TARGET" \
271+
state=enabled \
272+
service=foo \
273+
decided_at=2026-05-18T10:00:30Z
274+
local mtime_after
275+
mtime_after="$(stat -c %Y "$TARGET")"
276+
[ "$mtime_after" -gt "$mtime_before" ]
277+
}
278+
279+
@test "dedupe: AIRPLANES_WRITE_STATE_FORCE=1 always renames" {
280+
airplanes_write_state "$TARGET" \
281+
service=foo \
282+
state=enabled \
283+
decided_at=2026-05-18T10:00:00Z
284+
local mtime_before
285+
mtime_before="$(stat -c %Y "$TARGET")"
286+
sleep 1
287+
AIRPLANES_WRITE_STATE_FORCE=1 airplanes_write_state "$TARGET" \
288+
service=foo \
289+
state=enabled \
290+
decided_at=2026-05-18T10:00:30Z
291+
local mtime_after
292+
mtime_after="$(stat -c %Y "$TARGET")"
293+
[ "$mtime_after" -gt "$mtime_before" ]
294+
grep -qx 'decided_at=2026-05-18T10:00:30Z' "$TARGET"
295+
}
296+
297+
@test "dedupe: target missing → write proceeds (no dedupe path engaged)" {
298+
[ ! -e "$TARGET" ]
299+
airplanes_write_state "$TARGET" service=foo state=enabled
300+
[ -f "$TARGET" ]
301+
grep -qx 'service=foo' "$TARGET"
302+
}
303+
304+
@test "dedupe: validation failure short-circuits before dedupe check" {
305+
airplanes_write_state "$TARGET" service=before
306+
run airplanes_write_state "$TARGET" service=after 'bad-key=value'
307+
[ "$status" -eq 1 ]
308+
grep -qx 'service=before' "$TARGET"
309+
}
310+
311+
@test "dedupe: existing target without trailing newline → tail field still compared" {
312+
# An external writer or an interrupted previous run could leave a
313+
# target with no final newline. Without the `|| [[ -n line ]]` tail
314+
# in the canonicaliser the last line silently disappears from the
315+
# comparison, and a proposed write that removed the trailing field
316+
# would falsely dedupe against the truncated remainder, preserving
317+
# stale content. This case asserts the canonicaliser includes the
318+
# final line so the writer detects the difference and renames.
319+
printf 'schema_version=1\nservice=mlat\nstate=disabled\nextra=v' \
320+
> "$TARGET"
321+
local mtime_before
322+
mtime_before="$(stat -c %Y "$TARGET")"
323+
sleep 1
324+
airplanes_write_state "$TARGET" \
325+
service=mlat \
326+
state=disabled
327+
local mtime_after
328+
mtime_after="$(stat -c %Y "$TARGET")"
329+
[ "$mtime_after" -gt "$mtime_before" ]
330+
! grep -qx 'extra=v' "$TARGET"
331+
}
332+
333+
@test "dedupe: values with backslashes + trailing spaces preserved verbatim" {
334+
# `IFS= read -r` + `printf '%s\n'` round-trips backslashes and
335+
# trailing whitespace; canonicalisation must not mangle them, or
336+
# else two semantically-identical writes would compare unequal and
337+
# break dedupe.
338+
airplanes_write_state "$TARGET" \
339+
"feed_bin=/usr/bin/x\\trailing " \
340+
"decided_at=2026-05-18T10:00:00Z"
341+
local mtime_before
342+
mtime_before="$(stat -c %Y "$TARGET")"
343+
sleep 1
344+
airplanes_write_state "$TARGET" \
345+
"feed_bin=/usr/bin/x\\trailing " \
346+
"decided_at=2026-05-18T10:00:30Z"
347+
local mtime_after
348+
mtime_after="$(stat -c %Y "$TARGET")"
349+
[ "$mtime_after" -eq "$mtime_before" ]
350+
grep -qFx 'feed_bin=/usr/bin/x\trailing ' "$TARGET"
351+
}

0 commit comments

Comments
 (0)