Files
to-do/FINDINGS.md
T
Connor JohnstoneandClaude Opus 5 727813b5e9 Record that the NAS crystal is sound, the clock was just never set
chrony measures the hardware at 2.6 ppm, so the 124s was about eighteen
months of accumulation on a machine with no time daemon, not drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
2026-09-09 18:20:53 -04:00

6.2 KiB

Findings

Empirical results that settle open questions in PLAN.md. Each entry records how it was measured, so it can be re-checked when something changes.

SFTPGo WebDAV does not honor If-Match on PUT

PLAN.md section 7 flagged this as "to verify in phase 1". Verified 2026-09-09 against the live endpoint.

PUT (create)                        -> 201
HEAD                                -> etag "18d3c65559dd89a43"
PUT with correct If-Match           -> 201
PUT with If-Match "deadbeef0000..." -> 201   <- should have been 412
GET                                 -> body from the bogus-If-Match write

The precondition is ignored, not evaluated. The client must use HEAD-compare-then-PUT, which is the fallback PLAN.md already anticipates. The remaining race window is milliseconds wide for a single user with one phone.

ETag is exactly hex(mtime_nanos) + hex(size)

Not a hash. Measured by comparing the WebDAV ETag against stat over ssh on the same file:

HEAD etag decoded   1788991618.521424162
disk mtime          1788991618.521424162   identical to the nanosecond

Two consequences:

  • Rewriting identical content still produces a new ETag. Content equality has to be checked by comparing bytes, never by comparing ETags.
  • An ETag says only "the file was written at time T and is N bytes". That is sufficient for change detection, which is all the sync design asks of it.

The ETag returned by PUT is never the file's actual ETag

Reproduced 3/3 before the clock fix, and 3/3 after it. Do not cache the PUT response ETag as the new base. HEAD after PUT to learn the authoritative value, or the next sync will diagnose a phantom remote change on every save.

SFTPGo computes the response ETag roughly a millisecond before the file's final mtime settles, and ETag is derived from mtime, so the two can never agree.

after the clock fix:
trial 1: PUT="18d3c711836b5e748"  HEAD="18d3c7118361820b8"   gap 0.646 ms
trial 2: PUT="18d3c711b9297f978"  HEAD="18d3c711b91648318"   gap 1.259 ms
trial 3: PUT="18d3c711cb79bac48"  HEAD="18d3c711cb62efba8"   gap 1.494 ms

Worth recording how this was nearly misdiagnosed. Before the NAS clock was fixed the same gap measured about 113 seconds, which looked like a serious protocol defect. It was the same one-millisecond write-path race the whole time, with a broken clock amplifying it by five orders of magnitude. Fixing the clock shrank the symptom by 100000x without changing the underlying cause, and without changing what the client has to do about it.

The NAS clock was ~124 seconds fast, now fixed

/home/connor/docs on mainframe is nas.rcjohnstone.com:/docs over NFSv4. NFS stamps mtimes with the server's clock, so every file written to that tree, by any means, was landing with an mtime about two minutes in the future.

Isolated to the filesystem, with SFTPGo entirely out of the path:

$ ssh mainframe
now:             1788991551.498123146
touch .probe-ssh.md
ssh-touch mtime: 1788991665.036420629    <- +113.5s, written locally over ssh
fs type:         nfs

The offset was measured at a stable 124.125s over a 193s window. It was not drifting at a measurable rate; the apparent growth between two early spot checks was inside the ssh round-trip noise of those checks.

nas is Arch, and had no time daemon at all: systemd-timesyncd was installed but disabled, and nothing else was present. Fixed by installing chrony and enabling it. The shipped Arch chrony.conf already carries the two settings this case needed, so it was left alone rather than rewritten:

  • makestep 1.0 3, which steps a large initial offset instead of spending 25 minutes slewing it away.
  • rtcsync, which disciplines the hardware clock. This mattered here, since the RTC was fast by the same amount and would otherwise have reintroduced the offset on the next boot.

systemd-timesyncd was masked so it cannot later race chronyd.

Sep 09 18:18:29 nas chronyd[1588]: System clock was stepped by -123.824615 seconds

The step matches the independently measured 124.125s offset to within the measurement noise, which is the confirmation that the two figures describe the same thing. Verified afterwards, end to end:

mainframe now : 1788992343.649697683
NFS file mtime: 1788992343.949308287
mtime minus clock: +0.300 s   (was +113.5, and the 0.3 is the gap
                               between `date` and `touch` in one command)

Once disciplined, chrony measured the hardware clock at 2.643 ppm, about 0.23 s/day, which is a perfectly good crystal. So this was never a drift problem. At that rate 124 seconds is roughly a year and a half of accumulation, which is simply how long the machine had been running with nothing to set its clock.

The NAS was serving NFS but running no containers and no databases, so stepping the clock backward by two minutes was safe. One harmless after-effect: files written to that tree before the fix carry mtimes up to ~124s in the future, and stay that way until they are next rewritten.

This project never depended on the fix. Rollover selects the previous note by filename sort, never by mtime, and the sync design compares ETags for inequality only, never against a clock. It is recorded because the drift silently affected everything else on that mount, rsync --update and incremental backups being the obvious victims.

Reconciliation, phase 0, completed 2026-09-09

File Resolution
2024-10-21.md conflict copy was a strict superset, taken wholesale
2024-11-12.md differed only in one checkbox, kept the [x]
2025-01-17.md local was the superset, kept as-is
2025-01-16.md genuine divergence, union merged

Mainframe's copy was byte-identical to the local conflict file in both divergent cases, so each was a two-way merge, not a three-way one.

All four .sync-conflict- files deleted. 228 files remain, all YYYY-MM-DD.md. Laptop pushed to mainframe; both sides now hash identically:

laptop     228 files  b9a39c9cdf0e0ba49080c6ba03629679
mainframe  228 files  b9a39c9cdf0e0ba49080c6ba03629679

Backups taken first: ~/todo-notes-mainframe-backup-20260909.tar.gz on mainframe, and a matching archive in the session scratchpad on the laptop.