@@ -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