Fix tasks reverting after a client marks them done
Some checks failed
CI / test (push) Failing after 6s
CI / image (push) Has been skipped

Completing a task in a CalDAV client wrote through to the markdown
correctly, then the client silently un-completed it moments later.

`to_ics` stamped DTSTAMP and LAST-MODIFIED with the wall clock on every
serialization, and Radicale derives an item's ETag by hashing that
serialization. So an unchanged task minted a fresh ETag on every read, at
one-second granularity. A client that read the task, waited for the user
to tick the checkbox, then PUT with `If-Match: <the etag it holds>` always
got 412 Precondition Failed — and resolved that apparent conflict by
re-downloading the server copy, discarding the completion.

Persist a per-task `last_modified` in the sidecar instead, stamped only
when the task's content or status actually changes, and serve DTSTAMP,
LAST-MODIFIED and the Item's own last_modified from it. The ETag is now
stable while the task is, and changes exactly when the task does.

Schema goes to v2, migrated in place with ALTER TABLE: rebuilding the
database would regenerate every UID and replace clients' whole task list.

Also fixes a plain GET of an item returning 500 — Radicale asserts on
`Item.last_modified`, which was never passed. Only REPORT was covered by
tests, so nothing caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 14:01:33 -04:00
parent 8b5cd370ea
commit 2552545265
4 changed files with 171 additions and 10 deletions

View File

@@ -8,6 +8,7 @@ from __future__ import annotations
import threading
from pathlib import Path
from urllib.parse import urlparse
from wsgiref.simple_server import WSGIRequestHandler, make_server
import pytest
@@ -216,3 +217,102 @@ def test_external_vim_edit_becomes_visible(server):
for t in daily.todos(include_completed=True)
}
assert "Order more compost" in summaries
def _etag(url: str, href: str) -> str:
"""The ETag a client would cache for one task."""
import re
import urllib.request
body = (
b'<?xml version="1.0"?><propfind xmlns="DAV:">'
b"<prop><getetag/></prop></propfind>"
)
request = urllib.request.Request(
url + href,
data=body,
method="PROPFIND",
headers={"Depth": "0", "Content-Type": "application/xml"},
)
payload = urllib.request.urlopen(request, timeout=10).read().decode()
match = re.search(r"<[^>]*getetag[^>]*>([^<]+)<", payload)
assert match, payload
return match.group(1)
def test_etag_is_stable_while_the_task_is_unchanged(server):
"""Regression: an unchanged task must keep its ETag across reads.
DTSTAMP/LAST-MODIFIED were stamped with the wall clock on every
serialization, and Radicale hashes the serialization to make the ETag. Every
read therefore minted a new ETag, so a client's conditional PUT always hit
412 and the client reverted its own edit — a task ticked off in the app
would silently un-tick itself moments later.
"""
import time
url, _ = server
daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily"))
todo = next(
t
for t in daily.todos()
if str(t.icalendar_component.get("SUMMARY")) == "Call the arborist"
)
href = urlparse(str(todo.url)).path
first = _etag(url, href)
time.sleep(1.1) # cross a second boundary — the old bug's granularity
assert _etag(url, href) == first
def test_conditional_completion_survives_a_delay(server):
"""The end-to-end symptom: tick a task off, and have it stay ticked off."""
import time
import urllib.request
url, vault = server
daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily"))
todo = next(
t
for t in daily.todos()
if str(t.icalendar_component.get("SUMMARY")) == "Call the arborist"
)
href = urlparse(str(todo.url)).path
uid = str(todo.icalendar_component.get("UID"))
held = _etag(url, href)
time.sleep(1.1) # the user takes a moment before hitting the checkbox
payload = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\nBEGIN:VTODO\r\n"
f"UID:{uid}\r\nDTSTAMP:20260802T120000Z\r\nSUMMARY:Call the arborist\r\n"
"STATUS:COMPLETED\r\nPERCENT-COMPLETE:100\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
)
request = urllib.request.Request(
url + href,
data=payload.encode(),
method="PUT",
headers={"Content-Type": "text/calendar", "If-Match": held},
)
response = urllib.request.urlopen(request, timeout=10)
assert response.status in (200, 201, 204)
assert "- [x] [#B] Call the arborist" in (vault / "daily/2026-01-07.md").read_text()
assert _etag(url, href) != held # the client must see the change
def test_item_get_returns_the_task(server):
"""Regression: Item was built without `last_modified`, so GET asserted (500)."""
import urllib.request
url, _ = server
daily = next(c for c in _calendars(url) if c.url.path.rstrip("/").endswith("daily"))
todo = next(
t
for t in daily.todos()
if str(t.icalendar_component.get("SUMMARY")) == "Call the arborist"
)
href = urlparse(str(todo.url)).path
response = urllib.request.urlopen(url + href, timeout=10)
assert response.status == 200
assert "SUMMARY:Call the arborist" in response.read().decode()