fef0420c
Resolve an issue prefix when renewing or releasing a lease
a73x 2026-09-06 09:15
Commit message
src/lease.rs
| Old | New | ||
|---|---|---|---|
| @@ -155,7 +155,16 @@ pub fn unclaim(repo: &Repository, remote_name: &str, id: &str, json: bool) -> Re | |||
| 155 | if is_conflict { | 155 | if is_conflict { |
| 156 | return Err(conflict(&value, id, json)); | 156 | return Err(conflict(&value, id, json)); |
| 157 | } | 157 | } |
| 158 | emit(&value, json, || format!("Released the claim on {}", id)); | 158 | emit(&value, json, || { |
| 159 | // "released" and "not-held" are both exit 0, and saying "released" | ||
| 160 | // for either is what hid a bug where a claim made by prefix could | ||
| 161 | // not be released by that prefix at all (2026-09-06). | ||
| 162 | if value["status"] == "not-held" { | ||
| 163 | format!("No claim on {} to release", short(repo, &value, id)) | ||
| 164 | } else { | ||
| 165 | format!("Released the claim on {}", short(repo, &value, id)) | ||
| 166 | } | ||
| 167 | }); | ||
| 159 | Ok(()) | 168 | Ok(()) |
| 160 | } | 169 | } |
| 161 | 170 | ||
src/server/leases.rs
| Old | New | ||
|---|---|---|---|
| @@ -86,8 +86,13 @@ pub enum Renew { | |||
| 86 | 86 | ||
| 87 | #[derive(Debug, PartialEq)] | 87 | #[derive(Debug, PartialEq)] |
| 88 | pub enum Release { | 88 | pub enum Release { |
| 89 | /// Row deleted, or there was nothing to release (idempotent success). | 89 | /// The lease was held and is now free. |
| 90 | Released, | 90 | Released, |
| 91 | /// There was nothing to release. Still a success — a client retrying after | ||
| 92 | /// a dropped connection must not fail — but reported distinctly, because | ||
| 93 | /// answering "released" to a release that freed nothing is what hid the | ||
| 94 | /// prefix bug of 2026-09-06 for a whole demo. | ||
| 95 | NotHeld, | ||
| 91 | /// A different holder has a live lease; refuse. | 96 | /// A different holder has a live lease; refuse. |
| 92 | NotHolder { holder: String }, | 97 | NotHolder { holder: String }, |
| 93 | } | 98 | } |
| @@ -109,6 +114,57 @@ pub fn repo_key(repos_dir: &Path, repo_path: &Path) -> String { | |||
| 109 | .to_string() | 114 | .to_string() |
| 110 | } | 115 | } |
| 111 | 116 | ||
| 117 | /// What an id or prefix names among a repository's lease rows. | ||
| 118 | #[derive(Debug, PartialEq)] | ||
| 119 | pub enum Resolved { | ||
| 120 | /// Exactly one row. Its full `issue_id`. | ||
| 121 | One(String), | ||
| 122 | /// No row — nothing is leased under that id. | ||
| 123 | None, | ||
| 124 | /// Several rows share the prefix; the caller must be more specific. | ||
| 125 | Ambiguous(usize), | ||
| 126 | } | ||
| 127 | |||
| 128 | /// Resolve an issue id or prefix against the rows this repository actually | ||
| 129 | /// holds leases for. | ||
| 130 | /// | ||
| 131 | /// `acquire` resolves a prefix against the repository's issue refs, because it | ||
| 132 | /// has to know the issue exists and is open. `renew` and `release` cannot: the | ||
| 133 | /// issue may have been closed, or its ref deleted, while a lease is still | ||
| 134 | /// held, and a lease must stay releasable either way. So they resolve against | ||
| 135 | /// the lease table, which is the only thing that can answer "which lease did | ||
| 136 | /// you mean". | ||
| 137 | /// | ||
| 138 | /// Without this, releasing by prefix silently touched a key no row used and | ||
| 139 | /// reported success, leaving the lease held until its TTL ran out — found by | ||
| 140 | /// running two real workers against a real server on 2026-09-06, not by any | ||
| 141 | /// test, because every test used a full id on both sides. | ||
| 142 | pub fn resolve(conn: &Connection, repo: &str, id_or_prefix: &str) -> rusqlite::Result<Resolved> { | ||
| 143 | // An exact hit wins without a scan, and cannot be ambiguous. | ||
| 144 | let exact: Option<String> = conn | ||
| 145 | .query_row( | ||
| 146 | "SELECT issue_id FROM leases WHERE repo = ?1 AND issue_id = ?2", | ||
| 147 | (repo, id_or_prefix), | ||
| 148 | |row| row.get(0), | ||
| 149 | ) | ||
| 150 | .optional()?; | ||
| 151 | if let Some(id) = exact { | ||
| 152 | return Ok(Resolved::One(id)); | ||
| 153 | } | ||
| 154 | |||
| 155 | let mut stmt = conn.prepare( | ||
| 156 | "SELECT issue_id FROM leases WHERE repo = ?1 AND issue_id LIKE ?2 || '%' LIMIT 2", | ||
| 157 | )?; | ||
| 158 | let matches: Vec<String> = stmt | ||
| 159 | .query_map((repo, id_or_prefix), |row| row.get(0))? | ||
| 160 | .collect::<rusqlite::Result<_>>()?; | ||
| 161 | Ok(match matches.len() { | ||
| 162 | 0 => Resolved::None, | ||
| 163 | 1 => Resolved::One(matches.into_iter().next().unwrap_or_default()), | ||
| 164 | n => Resolved::Ambiguous(n), | ||
| 165 | }) | ||
| 166 | } | ||
| 167 | |||
| 112 | /// Open (creating if needed) the lease database and ensure the schema. | 168 | /// Open (creating if needed) the lease database and ensure the schema. |
| 113 | pub fn open(path: &Path) -> rusqlite::Result<Connection> { | 169 | pub fn open(path: &Path) -> rusqlite::Result<Connection> { |
| 114 | let conn = Connection::open(path)?; | 170 | let conn = Connection::open(path)?; |
| @@ -249,6 +305,7 @@ pub fn release( | |||
| 249 | Some(row) if row.held(now) && row.holder.as_deref() != Some(holder) => Release::NotHolder { | 305 | Some(row) if row.held(now) && row.holder.as_deref() != Some(holder) => Release::NotHolder { |
| 250 | holder: row.holder.unwrap_or_default(), | 306 | holder: row.holder.unwrap_or_default(), |
| 251 | }, | 307 | }, |
| 308 | Some(row) if row.holder.is_none() => Release::NotHeld, | ||
| 252 | Some(_) => { | 309 | Some(_) => { |
| 253 | // Free the row, keep the token. Deleting would reset the tenure | 310 | // Free the row, keep the token. Deleting would reset the tenure |
| 254 | // counter — see the module docs. | 311 | // counter — see the module docs. |
| @@ -259,9 +316,7 @@ pub fn release( | |||
| 259 | )?; | 316 | )?; |
| 260 | Release::Released | 317 | Release::Released |
| 261 | } | 318 | } |
| 262 | // Releasing nothing succeeds: a client retrying after a dropped | 319 | None => Release::NotHeld, |
| 263 | // connection must not fail. | ||
| 264 | None => Release::Released, | ||
| 265 | }; | 320 | }; |
| 266 | tx.commit()?; | 321 | tx.commit()?; |
| 267 | Ok(outcome) | 322 | Ok(outcome) |
| @@ -439,11 +494,51 @@ mod tests { | |||
| 439 | } | 494 | } |
| 440 | 495 | ||
| 441 | #[test] | 496 | #[test] |
| 442 | fn release_idempotent_when_absent() { | 497 | fn release_of_nothing_succeeds_but_says_so() { |
| 443 | let mut c = mem(); | 498 | let mut c = mem(); |
| 499 | // Still a success for a retrying client, but not called "released": | ||
| 500 | // reporting a freed lease when nothing was freed hid a real bug. | ||
| 444 | assert_eq!( | 501 | assert_eq!( |
| 445 | release(&mut c, "r", "i", "alice", 200).unwrap(), | 502 | release(&mut c, "r", "i", "alice", 200).unwrap(), |
| 446 | Release::Released | 503 | Release::NotHeld |
| 504 | ); | ||
| 505 | } | ||
| 506 | |||
| 507 | #[test] | ||
| 508 | fn resolve_finds_an_exact_id_and_a_unique_prefix() { | ||
| 509 | let mut c = mem(); | ||
| 510 | acquire(&mut c, "r", "abcdef123456", "alice", None, 100).unwrap(); | ||
| 511 | assert_eq!( | ||
| 512 | resolve(&c, "r", "abcdef123456").unwrap(), | ||
| 513 | Resolved::One("abcdef123456".into()) | ||
| 514 | ); | ||
| 515 | assert_eq!( | ||
| 516 | resolve(&c, "r", "abcd").unwrap(), | ||
| 517 | Resolved::One("abcdef123456".into()) | ||
| 518 | ); | ||
| 519 | } | ||
| 520 | |||
| 521 | #[test] | ||
| 522 | fn resolve_reports_nothing_and_ambiguity() { | ||
| 523 | let mut c = mem(); | ||
| 524 | acquire(&mut c, "r", "abcdef", "alice", None, 100).unwrap(); | ||
| 525 | acquire(&mut c, "r", "abcxyz", "bob", None, 100).unwrap(); | ||
| 526 | assert_eq!(resolve(&c, "r", "zz").unwrap(), Resolved::None); | ||
| 527 | assert_eq!(resolve(&c, "r", "abc").unwrap(), Resolved::Ambiguous(2)); | ||
| 528 | // Another repo's rows are not candidates. | ||
| 529 | assert_eq!(resolve(&c, "other", "abc").unwrap(), Resolved::None); | ||
| 530 | } | ||
| 531 | |||
| 532 | #[test] | ||
| 533 | fn resolve_still_finds_a_freed_row_so_a_stale_id_is_answerable() { | ||
| 534 | let mut c = mem(); | ||
| 535 | acquire(&mut c, "r", "abcdef", "alice", None, 100).unwrap(); | ||
| 536 | release(&mut c, "r", "abcdef", "alice", 200).unwrap(); | ||
| 537 | // The row survives to carry the token, so the id still resolves — and | ||
| 538 | // releasing it again answers NotHeld rather than inventing a lease. | ||
| 539 | assert_eq!( | ||
| 540 | resolve(&c, "r", "abc").unwrap(), | ||
| 541 | Resolved::One("abcdef".into()) | ||
| 447 | ); | 542 | ); |
| 448 | } | 543 | } |
| 449 | 544 | ||
src/server/ssh/session.rs
| Old | New | ||
|---|---|---|---|
| @@ -441,10 +441,21 @@ impl SshHandler { | |||
| 441 | // UI uses, so a claim made here is the claim shown there. | 441 | // UI uses, so a claim made here is the claim shown there. |
| 442 | let repo_key = crate::leases::repo_key(&self.config.repos_dir, resolved_path); | 442 | let repo_key = crate::leases::repo_key(&self.config.repos_dir, resolved_path); |
| 443 | 443 | ||
| 444 | let mut conn = match crate::leases::open(&self.config.collab_db) { | ||
| 445 | Ok(c) => c, | ||
| 446 | Err(e) => { | ||
| 447 | error!("Failed to open the lease database: {}", e); | ||
| 448 | return reply_and_close(session, channel, "error: lease store unavailable\n", 1); | ||
| 449 | } | ||
| 450 | }; | ||
| 451 | |||
| 444 | // Acquiring points at a specific issue, so the issue has to exist and | 452 | // Acquiring points at a specific issue, so the issue has to exist and |
| 445 | // be open: leasing work nobody can do is a bug we should refuse, not | 453 | // be open: leasing work nobody can do is a bug we should refuse, not |
| 446 | // record. Renew/release/list act on lease rows alone — an issue closed | 454 | // record. Renew/release act on lease rows alone — an issue closed |
| 447 | // mid-tenure must still be releasable. | 455 | // mid-tenure must still be releasable — so they resolve a prefix |
| 456 | // against the lease table instead of the issue refs. Both must resolve | ||
| 457 | // it *somehow*: taking the argument literally meant `release <prefix>` | ||
| 458 | // touched a key no row used and reported success (fixed 2026-09-06). | ||
| 448 | let issue_id = match &cmd { | 459 | let issue_id = match &cmd { |
| 449 | LeaseCmd::Acquire { issue, .. } => { | 460 | LeaseCmd::Acquire { issue, .. } => { |
| 450 | let repo = match git2::Repository::open(resolved_path) { | 461 | let repo = match git2::Repository::open(resolved_path) { |
| @@ -474,17 +485,30 @@ impl SshHandler { | |||
| 474 | } | 485 | } |
| 475 | } | 486 | } |
| 476 | } | 487 | } |
| 477 | LeaseCmd::Renew { issue, .. } | LeaseCmd::Release { issue, .. } => issue.clone(), | 488 | LeaseCmd::Renew { issue, .. } | LeaseCmd::Release { issue, .. } => { |
| 489 | match crate::leases::resolve(&conn, &repo_key, issue) { | ||
| 490 | Ok(crate::leases::Resolved::One(id)) => id, | ||
| 491 | // Nothing is leased under this id. Keep the argument so | ||
| 492 | // the operation still answers for it: release says | ||
| 493 | // "not-held", renew says "not-holder". | ||
| 494 | Ok(crate::leases::Resolved::None) => issue.clone(), | ||
| 495 | Ok(crate::leases::Resolved::Ambiguous(n)) => { | ||
| 496 | return reply_and_close( | ||
| 497 | session, | ||
| 498 | channel, | ||
| 499 | &format!("error: '{issue}' matches {n} leases; use a longer id\n"), | ||
| 500 | 1, | ||
| 501 | ); | ||
| 502 | } | ||
| 503 | Err(e) => { | ||
| 504 | error!("Failed to resolve {} in {}: {}", issue, repo_key, e); | ||
| 505 | return reply_and_close(session, channel, "error: lease store failed\n", 1); | ||
| 506 | } | ||
| 507 | } | ||
| 508 | } | ||
| 478 | LeaseCmd::List { .. } => String::new(), | 509 | LeaseCmd::List { .. } => String::new(), |
| 479 | }; | 510 | }; |
| 480 | 511 | ||
| 481 | let mut conn = match crate::leases::open(&self.config.collab_db) { | ||
| 482 | Ok(c) => c, | ||
| 483 | Err(e) => { | ||
| 484 | error!("Failed to open the lease database: {}", e); | ||
| 485 | return reply_and_close(session, channel, "error: lease store unavailable\n", 1); | ||
| 486 | } | ||
| 487 | }; | ||
| 488 | let now = unix_now() as i64; | 512 | let now = unix_now() as i64; |
| 489 | 513 | ||
| 490 | let (reply, code) = match lease_outcome(&mut conn, &cmd, &repo_key, &issue_id, &holder, now) | 514 | let (reply, code) = match lease_outcome(&mut conn, &cmd, &repo_key, &issue_id, &holder, now) |
| @@ -579,6 +603,15 @@ fn lease_outcome( | |||
| 579 | }), | 603 | }), |
| 580 | 0, | 604 | 0, |
| 581 | ), | 605 | ), |
| 606 | // Success, but honest: nothing was freed. | ||
| 607 | leases::Release::NotHeld => ( | ||
| 608 | serde_json::json!({ | ||
| 609 | "status": "not-held", | ||
| 610 | "repo": repo_key, | ||
| 611 | "issue": issue_id, | ||
| 612 | }), | ||
| 613 | 0, | ||
| 614 | ), | ||
| 582 | leases::Release::NotHolder { holder } => ( | 615 | leases::Release::NotHolder { holder } => ( |
| 583 | serde_json::json!({ | 616 | serde_json::json!({ |
| 584 | "status": "not-holder", | 617 | "status": "not-holder", |
tests/lease_server_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -210,7 +210,7 @@ fn release_frees_the_issue_and_bumps_the_next_tenure() { | |||
| 210 | } | 210 | } |
| 211 | 211 | ||
| 212 | #[test] | 212 | #[test] |
| 213 | fn release_without_a_lease_succeeds() { | 213 | fn release_without_a_lease_succeeds_and_says_not_held() { |
| 214 | let (harness, id) = harness_with_issue("lease-release-noop"); | 214 | let (harness, id) = harness_with_issue("lease-release-noop"); |
| 215 | 215 | ||
| 216 | // Idempotent: a client retrying after a dropped connection must not fail. | 216 | // Idempotent: a client retrying after a dropped connection must not fail. |
| @@ -219,7 +219,10 @@ fn release_without_a_lease_succeeds() { | |||
| 219 | id | 219 | id |
| 220 | )); | 220 | )); |
| 221 | assert_eq!(code(&out), 0); | 221 | assert_eq!(code(&out), 0); |
| 222 | assert_eq!(stdout_json(&out)["status"], "released"); | 222 | // This assertion used to read "released", which is what let the prefix bug |
| 223 | // of 2026-09-06 pass every test: a release that freed nothing reported the | ||
| 224 | // same status as one that freed a lease, so nothing could tell them apart. | ||
| 225 | assert_eq!(stdout_json(&out)["status"], "not-held"); | ||
| 223 | } | 226 | } |
| 224 | 227 | ||
| 225 | #[test] | 228 | #[test] |
| @@ -317,3 +320,98 @@ fn read_only_principal_may_list_but_not_acquire() { | |||
| 317 | )); | 320 | )); |
| 318 | assert_eq!(code(&acquire), 1, "acquiring needs write"); | 321 | assert_eq!(code(&acquire), 1, "acquiring needs write"); |
| 319 | } | 322 | } |
| 323 | |||
| 324 | /// A claim made by prefix must be releasable by that prefix. | ||
| 325 | /// | ||
| 326 | /// The regression test for the bug two real workers found on 2026-09-06: | ||
| 327 | /// lease rows are keyed by the full issue id, `acquire` resolved a prefix to | ||
| 328 | /// one, and `release` took its argument literally — so releasing by prefix | ||
| 329 | /// updated a key no row used and reported `released` anyway. The lease stayed | ||
| 330 | /// held until its TTL, blocking every other worker, and the CLI abbreviates | ||
| 331 | /// ids everywhere, so this was the ordinary path rather than a corner. | ||
| 332 | #[test] | ||
| 333 | fn a_claim_made_by_prefix_is_released_by_that_prefix() { | ||
| 334 | let (harness, id) = harness_with_issue("lease-prefix-release"); | ||
| 335 | let first = harness.ssh_client_key(); | ||
| 336 | let second = harness.second_authorized_key(); | ||
| 337 | let prefix = &id[..8]; | ||
| 338 | |||
| 339 | let claimed = harness.ssh_exec_as( | ||
| 340 | &first, | ||
| 341 | &format!("collab-lease acquire 'lease-prefix-release.git' '{prefix}' --ttl 300"), | ||
| 342 | b"", | ||
| 343 | ); | ||
| 344 | assert_eq!(code(&claimed), 0); | ||
| 345 | |||
| 346 | let released = harness.ssh_exec_as( | ||
| 347 | &first, | ||
| 348 | &format!("collab-lease release 'lease-prefix-release.git' '{prefix}'"), | ||
| 349 | b"", | ||
| 350 | ); | ||
| 351 | assert_eq!(code(&released), 0); | ||
| 352 | let json = stdout_json(&released); | ||
| 353 | assert_eq!(json["status"], "released", "a held lease must be freed"); | ||
| 354 | // The reply names the full id, so a caller learns what it actually freed. | ||
| 355 | assert_eq!(json["issue"], id); | ||
| 356 | |||
| 357 | // The proof is not the reply: another worker must be able to take it. | ||
| 358 | let next = harness.ssh_exec_as( | ||
| 359 | &second, | ||
| 360 | &format!("collab-lease acquire 'lease-prefix-release.git' '{prefix}' --ttl 300"), | ||
| 361 | b"", | ||
| 362 | ); | ||
| 363 | assert_eq!( | ||
| 364 | code(&next), | ||
| 365 | 0, | ||
| 366 | "the issue must really be free, not merely reported free: {}", | ||
| 367 | String::from_utf8_lossy(&next.stdout) | ||
| 368 | ); | ||
| 369 | assert_eq!(stdout_json(&next)["token"], 2, "a new tenure"); | ||
| 370 | } | ||
| 371 | |||
| 372 | /// A prefix that matches two leases is refused rather than picking one. | ||
| 373 | #[test] | ||
| 374 | fn an_ambiguous_prefix_is_refused() { | ||
| 375 | let harness = ServerHarness::new("lease-ambiguous"); | ||
| 376 | harness.push_head(); | ||
| 377 | let repo = harness.work_repo_git2(); | ||
| 378 | |||
| 379 | // Issue ids are content-derived, so collide them in the lease table | ||
| 380 | // directly by claiming two whole ids that share a prefix is not possible; | ||
| 381 | // instead claim two real issues and use a prefix short enough to match | ||
| 382 | // both only if they happen to share one. Find such a pair, or skip. | ||
| 383 | let mut ids = Vec::new(); | ||
| 384 | for n in 0..12 { | ||
| 385 | let (_r, id) = common::open_issue(&repo, &common::alice(), &format!("Issue {n}")); | ||
| 386 | ids.push(id); | ||
| 387 | } | ||
| 388 | harness.push_collab_refs(); | ||
| 389 | |||
| 390 | let shared = ids.iter().find_map(|a| { | ||
| 391 | ids.iter() | ||
| 392 | .find(|b| *b != a && b.as_bytes()[0] == a.as_bytes()[0]) | ||
| 393 | .map(|b| (a.clone(), b.clone())) | ||
| 394 | }); | ||
| 395 | let Some((a, b)) = shared else { | ||
| 396 | eprintln!("no two of 12 issue ids shared a first hex character; nothing to assert"); | ||
| 397 | return; | ||
| 398 | }; | ||
| 399 | |||
| 400 | for id in [&a, &b] { | ||
| 401 | let out = harness.ssh_exec(&format!( | ||
| 402 | "collab-lease acquire 'lease-ambiguous.git' '{id}' --ttl 300" | ||
| 403 | )); | ||
| 404 | assert_eq!(code(&out), 0); | ||
| 405 | } | ||
| 406 | |||
| 407 | let out = harness.ssh_exec(&format!( | ||
| 408 | "collab-lease release 'lease-ambiguous.git' '{}'", | ||
| 409 | &a[..1] | ||
| 410 | )); | ||
| 411 | assert_eq!(code(&out), 1, "an ambiguous prefix must not pick one"); | ||
| 412 | assert!( | ||
| 413 | String::from_utf8_lossy(&out.stdout).contains("matches 2 leases"), | ||
| 414 | "stdout: {}", | ||
| 415 | String::from_utf8_lossy(&out.stdout) | ||
| 416 | ); | ||
| 417 | } | ||