CalCalist/tests/caldav.rs

500 lines
17 KiB
Rust
Raw Normal View History

Close the remaining M1 gaps Eight items were still open at the end of M1: two integration tests that had only been run by hand, and six known gaps. Recurrence overrides now reach Google. Google addresses an exception through the series rather than as an event of its own, so the master is sent first and each override is then matched to its instance by original start time and patched. Matching needs the two sides' spellings reduced to one key: iCalendar writes a zoned local time, Google an absolute offset. An override matching no occurrence is counted rather than forced — that means a stale RECURRENCE-ID left behind by an edited RRULE, and inventing an event for it would put something in the calendar the series does not contain. Reading one component apart from another needed a view `properties` cannot give: it flattens every VEVENT together, which is right for the UID a series shares and wrong for an override, whose SUMMARY and the master's are then indistinguishable. `Calendar::events` splits them. A TZID now travels with the VTIMEZONE that defines it, derived from the zone's own transition table as the yearly rule it implies. This changes the content hash of every zoned recurring event, so the first cycle after this re-pushes them. A Google authorisation is filed under the account it was granted for rather than the endpoint that asked for it, so two endpoints on one account no longer need a login each. The account is read from the primary calendar's id, which needs no scope beyond the calendar one already granted. Authorisations written by the previous scheme are still honoured, and move across at the next login. An unreachable Google endpoint no longer ends the cycle — one lapsed token used to stop the CalDAV side too. It is named, only the aggregates depending on it stand down, and the run exits non-zero so a partial cycle cannot pass for success. The CalDAV leg cannot be narrowed the same way: pimsync is one process covering every pair, so a failure does not say which pair it belongs to. `--dry-run` now pulls for real, into a throwaway copy of the local mirrors and through a pimsync configuration that only ever reads from a server. What it reports is measured against the calendars as they are now rather than against whatever the last real cycle left behind. `calcalist prune` reports local mirrors of endpoints the configuration no longer names, and removes them under --force. The integration tests run against a real Radicale server and a real iCal feed: convergence and idempotence, the dry run, prune, and the scheduling rule asserted on the bytes that actually reached the server. The plan asked for an SMTP sink for that last one; Radicale implements no RFC 6638 scheduling, so a quiet SMTP port would have proved nothing about the transform. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:49:38 +03:00
//! End-to-end tests against a real CalDAV server and a real iCal feed.
//!
//! The unit tests establish that the reconciler decides correctly. These
//! establish that the decisions survive the round trip through pimsync and a
//! server that rewrites what it stores — which is where every bug found by hand
//! during M1 actually lived.
//!
//! Radicale and pimsync both come from devbox, so `devbox run check` has them.
//! Outside that shell the tests report what is missing and pass, rather than
//! failing for a reason that has nothing to do with the code.
mod support;
use support::{Calcalist, Feed, Radicale};
/// An event with guests, an organiser and an alarm — the combination the
/// scheduling rules are about.
const MEETING: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n\
BEGIN:VEVENT\r\nUID:meeting@work\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART:20260910T090000Z\r\nDTEND:20260910T100000Z\r\nSUMMARY:Planning\r\n\
ORGANIZER;CN=Chair:mailto:chair@example.com\r\n\
ATTENDEE;CN=Guest;PARTSTAT=ACCEPTED:mailto:guest@example.com\r\n\
BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nDESCRIPTION:Soon\r\n\
END:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
/// An event written straight into the aggregate, belonging to no source.
const HAND_WRITTEN: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//phone//EN\r\n\
BEGIN:VEVENT\r\nUID:hand-written@phone\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART:20260911T140000Z\r\nDTEND:20260911T150000Z\r\nSUMMARY:Dentist\r\n\
END:VEVENT\r\nEND:VCALENDAR\r\n";
Make an aggregate's own events a first-class mode An aggregate with no default_sink already left events created in it alone, but treated doing so as a failure: it reported a skip per event per cycle saying no sink was configured, as though something had gone wrong. Nothing had. An aggregate is also a calendar, and holding events of its own is a legitimate way to use one. Skipped::NoSink is replaced by a kept_local count, reported plainly. `@local` joins the routing markers, so the mode also works per-event where a default_sink is configured — which was not previously expressible. Unlike every other marker it is deliberately not stripped: the others have done their job once the event reaches its source, whereas this one never leaves, so it has to stay legible for the next cycle to reach the same decision. `local` is therefore a reserved endpoint id, and configuring one is refused. This also fixes a real defect. `retarget` rebuilds the new target from the recorded links, which cover derived events only, so an event belonging to the aggregate itself did not follow the move — it stayed on the calendar being left behind while everything around it moved on, quietly. It is now carried across, since there is nothing to re-derive it from, and the new target's scheduling rule is applied on the way: this is a write to an aggregate like any other, and a guest list carried live onto a server that schedules would mail everyone on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 14:30:25 +03:00
/// The same event, asking to be left where it was written.
const KEPT_LOCAL: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//phone//EN\r\n\
BEGIN:VEVENT\r\nUID:kept@phone\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART:20260912T140000Z\r\nDTEND:20260912T150000Z\r\nSUMMARY:Haircut\r\n\
DESCRIPTION:Around the corner\\n\\n@local\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
Close the remaining M1 gaps Eight items were still open at the end of M1: two integration tests that had only been run by hand, and six known gaps. Recurrence overrides now reach Google. Google addresses an exception through the series rather than as an event of its own, so the master is sent first and each override is then matched to its instance by original start time and patched. Matching needs the two sides' spellings reduced to one key: iCalendar writes a zoned local time, Google an absolute offset. An override matching no occurrence is counted rather than forced — that means a stale RECURRENCE-ID left behind by an edited RRULE, and inventing an event for it would put something in the calendar the series does not contain. Reading one component apart from another needed a view `properties` cannot give: it flattens every VEVENT together, which is right for the UID a series shares and wrong for an override, whose SUMMARY and the master's are then indistinguishable. `Calendar::events` splits them. A TZID now travels with the VTIMEZONE that defines it, derived from the zone's own transition table as the yearly rule it implies. This changes the content hash of every zoned recurring event, so the first cycle after this re-pushes them. A Google authorisation is filed under the account it was granted for rather than the endpoint that asked for it, so two endpoints on one account no longer need a login each. The account is read from the primary calendar's id, which needs no scope beyond the calendar one already granted. Authorisations written by the previous scheme are still honoured, and move across at the next login. An unreachable Google endpoint no longer ends the cycle — one lapsed token used to stop the CalDAV side too. It is named, only the aggregates depending on it stand down, and the run exits non-zero so a partial cycle cannot pass for success. The CalDAV leg cannot be narrowed the same way: pimsync is one process covering every pair, so a failure does not say which pair it belongs to. `--dry-run` now pulls for real, into a throwaway copy of the local mirrors and through a pimsync configuration that only ever reads from a server. What it reports is measured against the calendars as they are now rather than against whatever the last real cycle left behind. `calcalist prune` reports local mirrors of endpoints the configuration no longer names, and removes them under --force. The integration tests run against a real Radicale server and a real iCal feed: convergence and idempotence, the dry run, prune, and the scheduling rule asserted on the bytes that actually reached the server. The plan asked for an SMTP sink for that last one; Radicale implements no RFC 6638 scheduling, so a quiet SMTP port would have proved nothing about the transform. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:49:38 +03:00
const HOLIDAY_FEED: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//feed//EN\r\n\
BEGIN:VEVENT\r\nUID:newyear@feed\r\nDTSTAMP:20260101T000000Z\r\n\
DTSTART;VALUE=DATE:20260101\r\nDTEND;VALUE=DATE:20260102\r\n\
SUMMARY:New Year\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
struct Fixture {
_root: tempfile::TempDir,
server: Radicale,
_feed: Feed,
calcalist: Calcalist,
}
/// A CalDAV source, a read-only feed, and a CalDAV target to aggregate into.
fn fixture() -> Option<Fixture> {
let missing = support::missing_binaries();
if !missing.is_empty() {
eprintln!(
"skipped: {} not on PATH; run under devbox",
missing.join(", ")
);
return None;
}
let root = tempfile::tempdir().expect("temp");
let server = Radicale::start(root.path());
server.create_calendar("work");
server.create_calendar("unified");
server.put_event("work", "meeting.ics", MEETING);
let feed = Feed::start(HOLIDAY_FEED.to_string());
let config = format!(
r#"
version = 1
[[endpoint]]
id = "work"
type = "caldav"
url = "{work}"
username = "{user}"
secret_command = "printf password"
[[endpoint]]
id = "published"
type = "caldav"
url = "{unified}"
username = "{user}"
secret_command = "printf password"
[[endpoint]]
id = "holidays"
type = "webcal"
url = "{feed}"
[[aggregate]]
id = "everything"
target = "published"
sources = ["work", "holidays"]
default_sink = "work"
"#,
work = server.url("work"),
unified = server.url("unified"),
user = support::USER,
feed = feed.url(),
);
let calcalist = Calcalist::new(root.path(), &config);
Some(Fixture {
_root: root,
server,
_feed: feed,
calcalist,
})
}
/// Both sources reach the target, and running again changes nothing.
///
/// Idempotence is the property that matters most: a cycle that is not a no-op
/// against unchanged input would rewrite every event on every run, and each
/// rewrite is a chance for the server to hand back something slightly different.
#[test]
fn a_cycle_converges_and_the_next_one_does_nothing() {
let Some(fixture) = fixture() else { return };
let first = fixture.calcalist.run(&["sync"]);
assert!(
first.succeeded(),
"first sync failed\n{}\n{}",
first.stdout,
first.stderr
);
let published = fixture.server.stored("unified");
assert_eq!(
published.len(),
2,
"both sources should arrive: {published:?}"
);
let all = published.join("\n");
assert!(all.contains("Planning"), "{all}");
assert!(all.contains("New Year"), "{all}");
// Provenance travels with each mirror, so the aggregate knows where its
// events came from without consulting the state file.
assert!(all.contains("X-CALCALIST-SOURCE:work"), "{all}");
assert!(all.contains("X-CALCALIST-SOURCE:holidays"), "{all}");
let second = fixture.calcalist.run(&["sync"]);
assert!(
second.succeeded(),
"second sync failed\n{}\n{}",
second.stdout,
second.stderr
);
assert!(
second.stdout.contains("everything: 0 mirrored"),
"the second cycle should be a no-op:\n{}",
second.stdout
);
assert_eq!(
fixture.server.stored("unified"),
published,
"the second cycle rewrote the target"
);
}
/// The scheduling rule, checked against what actually reached the server.
///
/// A CalDAV server has no portable way to be told not to send invitations, so
/// inertness is structural: the mirror carries the guest list as data and not as
/// live scheduling properties. The alarm is the deliberate exception — stripping
/// it would destroy every reminder in the one calendar the user subscribes to.
///
/// Radicale implements no scheduling of its own, so this asserts on the bytes
/// stored rather than on a mail sink: with nothing to send mail, a quiet SMTP
/// port would prove nothing about the transform.
#[test]
fn a_mirror_carries_no_live_scheduling_properties() {
let Some(fixture) = fixture() else { return };
let run = fixture.calcalist.run(&["sync"]);
assert!(
run.succeeded(),
"sync failed\n{}\n{}",
run.stdout,
run.stderr
);
let mirror = fixture
.server
.stored("unified")
.into_iter()
.find(|item| item.contains("Planning"))
.expect("the meeting should have been mirrored");
for property in ["ATTENDEE;", "ATTENDEE:", "ORGANIZER;", "ORGANIZER:"] {
assert!(
!mirror
.lines()
.any(|line| line.trim_start().starts_with(property)),
"a live {property} reached the aggregate:\n{mirror}"
);
}
// The guests are still there, as something nothing will act on.
assert!(
mirror.contains("X-CALCALIST-ATTENDEES"),
"the guest list was lost:\n{mirror}"
);
assert!(
mirror.contains("guest@example.com"),
"the guest list was lost:\n{mirror}"
);
assert!(
mirror.contains("BEGIN:VALARM") && mirror.contains("TRIGGER:-PT15M"),
"the alarm did not survive:\n{mirror}"
);
// The source keeps its scheduling properties: only the aggregate is inert.
let source = fixture
.server
.stored("work")
.into_iter()
.find(|item| item.contains("Planning"))
.expect("the meeting should still be in its source");
assert!(source.contains("ATTENDEE"), "{source}");
assert!(source.contains("ORGANIZER"), "{source}");
}
/// A dry run must reach the servers to be worth anything, and change nothing.
#[test]
fn a_dry_run_reports_without_touching_anything() {
let Some(fixture) = fixture() else { return };
let dry = fixture.calcalist.run(&["sync", "--dry-run"]);
assert!(
dry.succeeded(),
"dry run failed\n{}\n{}",
dry.stdout,
dry.stderr
);
assert!(
dry.stdout.contains("everything: 2 mirrored"),
"the dry run should have seen both sources:\n{}",
dry.stdout
);
assert!(
fixture.server.stored("unified").is_empty(),
"the dry run published events"
);
// And the real cycle that follows is not confused by it.
let real = fixture.calcalist.run(&["sync"]);
assert!(
real.succeeded(),
"sync after a dry run failed\n{}\n{}",
real.stdout,
real.stderr
);
assert_eq!(fixture.server.stored("unified").len(), 2);
}
/// Removing an endpoint used to leave its events sitting in the state directory
/// with nothing managing them. They are now reported, and removed on request.
#[test]
fn a_retired_endpoints_mirror_is_reported_and_then_removed() {
let Some(fixture) = fixture() else { return };
assert!(fixture.calcalist.run(&["sync"]).succeeded());
let mirror = fixture.calcalist.state.join("calcalist/vdir/holidays");
assert!(mirror.is_dir(), "the feed should have been mirrored");
// The feed is dropped from the configuration, as a user would drop it.
std::fs::write(
&fixture.calcalist.config,
fixture.calcalist.config_without_feed(),
)
.expect("rewrite config");
let listed = fixture.calcalist.run(&["prune"]);
assert!(listed.succeeded(), "{}", listed.stderr);
assert!(
listed.stdout.contains("would remove") && listed.stdout.contains("holidays"),
"prune should say what it found:\n{}",
listed.stdout
);
assert!(mirror.is_dir(), "listing must not delete anything");
let removed = fixture.calcalist.run(&["prune", "--force"]);
assert!(removed.succeeded(), "{}", removed.stderr);
assert!(!mirror.exists(), "the orphaned mirror should be gone");
}
/// An aggregate with no `default_sink` leaves events created in it alone.
///
/// This is how a target calendar keeps events of its own: with nowhere
/// configured to file a new event, calcalist refuses to guess rather than
/// picking a source, so the event simply stays where it was written. Worth
/// pinning, because "left alone" has to hold on every subsequent cycle too —
/// an event that survived the first one and was swept up by the second would
/// be worse than never having worked.
#[test]
fn an_event_created_in_a_sinkless_aggregate_stays_where_it_is() {
let Some(fixture) = fixture() else { return };
// The fixture's aggregate has a default_sink; take it away.
let sinkless = std::fs::read_to_string(&fixture.calcalist.config)
.expect("read config")
.replace("default_sink = \"work\"\n", "");
std::fs::write(&fixture.calcalist.config, sinkless).expect("rewrite config");
assert!(fixture.calcalist.run(&["sync"]).succeeded());
// Now add an event by hand, as a calendar app would.
fixture
.server
.put_event("unified", "dentist.ics", HAND_WRITTEN);
for cycle in 1..=3 {
let run = fixture.calcalist.run(&["sync"]);
assert!(
run.succeeded(),
"cycle {cycle} failed\n{}\n{}",
run.stdout,
run.stderr
);
let published = fixture.server.stored("unified");
assert!(
published.iter().any(|item| item.contains("Dentist")),
"cycle {cycle}: the hand-written event was removed:\n{published:?}"
);
// It never reaches a source: there is nowhere it was told to go.
assert!(
!fixture
.server
.stored("work")
.iter()
.any(|item| item.contains("Dentist")),
"cycle {cycle}: the hand-written event leaked into the source"
);
}
}
Make an aggregate's own events a first-class mode An aggregate with no default_sink already left events created in it alone, but treated doing so as a failure: it reported a skip per event per cycle saying no sink was configured, as though something had gone wrong. Nothing had. An aggregate is also a calendar, and holding events of its own is a legitimate way to use one. Skipped::NoSink is replaced by a kept_local count, reported plainly. `@local` joins the routing markers, so the mode also works per-event where a default_sink is configured — which was not previously expressible. Unlike every other marker it is deliberately not stripped: the others have done their job once the event reaches its source, whereas this one never leaves, so it has to stay legible for the next cycle to reach the same decision. `local` is therefore a reserved endpoint id, and configuring one is refused. This also fixes a real defect. `retarget` rebuilds the new target from the recorded links, which cover derived events only, so an event belonging to the aggregate itself did not follow the move — it stayed on the calendar being left behind while everything around it moved on, quietly. It is now carried across, since there is nothing to re-derive it from, and the new target's scheduling rule is applied on the way: this is a write to an aggregate like any other, and a guest list carried live onto a server that schedules would mail everyone on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 14:30:25 +03:00
/// `@local` opts one event out of a default sink that would otherwise take it.
///
/// The marker has to survive, unlike every other one: it is stripped markers
/// that have done their job on arrival, whereas this event never leaves, so the
/// next cycle has to be able to reach the same decision.
#[test]
fn the_local_marker_keeps_an_event_out_of_a_configured_sink() {
let Some(fixture) = fixture() else { return };
assert!(fixture.calcalist.run(&["sync"]).succeeded());
fixture
.server
.put_event("unified", "haircut.ics", KEPT_LOCAL);
for cycle in 1..=3 {
let run = fixture.calcalist.run(&["sync"]);
assert!(
run.succeeded(),
"cycle {cycle} failed\n{}\n{}",
run.stdout,
run.stderr
);
assert!(
run.stdout.contains("1 event(s) kept local"),
"cycle {cycle} should report it as kept, not skipped:\n{}",
run.stdout
);
let kept = fixture
.server
.stored("unified")
.into_iter()
.find(|item| item.contains("Haircut"))
.expect("the event should still be in the aggregate");
// Stripping it would let the default sink claim the event next cycle.
assert!(kept.contains("@local"), "cycle {cycle}: {kept}");
assert!(
!fixture
.server
.stored("work")
.iter()
.any(|item| item.contains("Haircut")),
"cycle {cycle}: it reached the default sink anyway"
);
}
}
Finish M2: run lock, systemd units, discover; drop the web UI Designing the configuration UI in full made the case against building it. Its audience would be people who find TOML hard, but with bring-your-own OAuth client settled, every user must first create a Google Cloud project, configure a consent screen and put a secret in a keyring — a far higher bar than editing thirty lines of config. Anyone who clears it can edit the file; anyone who cannot never reaches the file. Against that stood three dependencies, five modules, an auth.rs refactor and a security surface guarding something that reads the config and touches the keyring. `doctor` and `status` had already absorbed most of what it was for. The reasoning is recorded in TODO.md and SPECS.md rather than left as an apparent oversight. The run lock is not a UI feature and closes a gap that already existed: nothing stopped a timer firing into a hand-run cycle, and two cycles interleaving writes over the same vdirs is what the design otherwise avoids. flock is used rather than a pid file because the kernel releases it however the process ends, so a crash cannot leave a lock to clear by hand — which also means a lock we failed to take is held by a live process, so the pid in it is worth reporting. The one idea worth keeping from the UI design was collection discovery, which needed no web layer. `calcalist discover` prints a ready-to-paste endpoint block per calendar a server offers, removing the most error-prone field in the config. pimsync's discovery output is undocumented, so the format was established against a real server first. Two things it teaches: everything arrives on stdout including failures, and a pair has two storages, so pimsync reports the scratch vdir's contents too — parsing anchors on the heading naming the server, or a probe directory's leftovers would be offered as the user's calendars. Verified against Posteo as well as Radicale: all four calendars found, the first matching the URL already configured. Also fixes a real defect in the test harness rather than its symptom. Ports were chosen by binding one and letting go, so two tests could pick the same number — and the loser's readiness check then succeeded against the winner's server, silently sharing it. Startup now confirms the child we spawned is the one alive, retries on another port if not, and waits for a real HTTP response rather than an open socket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:17:34 +03:00
/// Discovery, against a server that really has two calendars.
///
/// The command exists because the CalDAV `url` must name the collection exactly
/// and providers rarely show it. What it prints therefore has to be usable
/// as-is, which is what the parse-back below actually checks.
#[test]
fn discover_lists_the_servers_calendars_as_pasteable_config() {
let Some(fixture) = fixture() else { return };
let run = fixture.calcalist.run(&[
"discover",
&format!(
"http://127.0.0.1:{}/{}/",
fixture.server.port,
support::USER
),
"--username",
support::USER,
"--secret-command",
"printf password",
]);
assert!(
run.succeeded(),
"discover failed\n{}\n{}",
run.stdout,
run.stderr
);
assert!(run.stdout.contains("id = \"work\""), "{}", run.stdout);
assert!(run.stdout.contains("id = \"unified\""), "{}", run.stdout);
// The URL has to come back absolute, not as the bare path pimsync reports.
assert!(
run.stdout
.contains(&format!("http://127.0.0.1:{}/", fixture.server.port)),
"{}",
run.stdout
);
// The point of the command: what it prints is valid configuration.
let config = format!("version = 1\n{}", run.stdout);
let parsed: toml::Value = toml::from_str(&config).expect("the output should be valid TOML");
let endpoints = parsed["endpoint"].as_array().expect("endpoints");
assert_eq!(endpoints.len(), 2, "{}", run.stdout);
assert_eq!(endpoints[0]["type"].as_str(), Some("caldav"));
assert_eq!(
endpoints[0]["secret_command"].as_str(),
Some("printf password")
);
}
/// An unreachable server has to say so rather than print an empty result that
/// looks like "this server has no calendars".
#[test]
fn discover_reports_a_server_it_cannot_reach() {
if !support::missing_binaries().is_empty() {
eprintln!("skipped: run under devbox");
return;
}
let root = tempfile::tempdir().expect("temp");
let calcalist = Calcalist::new(root.path(), "version = 1\n");
// Port 9 is discard: it refuses or blackholes, and nothing listens for DAV.
let run = calcalist.run(&[
"discover",
"http://127.0.0.1:9/calendars/me/",
"--username",
"me",
]);
assert!(
!run.succeeded(),
"it should not claim success\n{}",
run.stdout
);
assert!(
!run.stderr.is_empty(),
"the failure should be explained: {run:?}",
run = run.stderr
);
}
/// The lock is what stops a timer firing into a hand-run cycle.
///
/// The lock is taken here rather than by racing two real syncs, which would be
/// timing-dependent and would pass by accident most of the time.
#[test]
fn a_second_cycle_is_refused_while_one_is_running() {
let Some(fixture) = fixture() else { return };
assert!(fixture.calcalist.run(&["sync"]).succeeded());
let lock_path = fixture.calcalist.state.join("calcalist/lock");
let held = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&lock_path)
.expect("the lock file should exist after a cycle");
rustix::fs::flock(&held, rustix::fs::FlockOperation::NonBlockingLockExclusive)
.expect("the test should be able to take the lock");
let refused = fixture.calcalist.run(&["sync"]);
assert!(!refused.succeeded(), "{}", refused.stdout);
assert!(
refused.stderr.contains("already running"),
"the refusal should say why: {}",
refused.stderr
);
drop(held);
assert!(
fixture.calcalist.run(&["sync"]).succeeded(),
"the lock should be free again"
);
}