Merge pull request #256 from flagon-io/255-edge-fail-safe
A broken answer from the git edge never reaches a client: the store answers instead
2 files+125−160/2 viewed
| 52 | 52 | use std::process::{Command, Stdio}; | |
| 53 | 53 | use std::sync::mpsc::{Receiver, SyncSender, sync_channel}; | |
| 54 | 54 | use std::sync::atomic::{AtomicU64, Ordering}; | |
| 55 | − | use std::sync::{Arc, Condvar, Mutex, MutexGuard}; | |
| 55 | + | use std::sync::{Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError}; | |
| 56 | 56 | use std::time::Instant; | |
| 57 | 57 | ||
| 58 | 58 | use anyhow::{Context, Result, bail}; | |
| ⋯ | |||
| 117 | 117 | root: PathBuf, | |
| 118 | 118 | state: Mutex<State>, | |
| 119 | 119 | wake: Condvar, | |
| 120 | − | /// Held while the mirror's files change or are read whole: a sync, a | |
| 121 | − | /// snapshot being made, one being restored. Never two at once. | |
| 122 | − | disk: Mutex<()>, | |
| 120 | + | /// The mirror's files: read (shared) while a git request is answered or | |
| 121 | + | /// a snapshot made from them, written (alone) while a sync or a restore | |
| 122 | + | /// changes them. A sync's fetch and repack replace the packs an | |
| 123 | + | /// upload-pack under way reads, which then answers nothing (git says | |
| 124 | + | /// `expected 'packfile'`): so a sync waits for the answers under way, | |
| 125 | + | /// and a git request that comes while one runs is behind. | |
| 126 | + | disk: RwLock<()>, | |
| 123 | 127 | } | |
| 124 | 128 | ||
| 125 | 129 | impl Edge { | |
| ⋯ | |||
| 315 | 319 | } | |
| 316 | 320 | }; | |
| 317 | 321 | crate::abuse::touch(); | |
| 318 | − | let disk = edge.disk.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 322 | + | let disk = edge.disk.write().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 319 | 323 | let started = Instant::now(); | |
| 320 | 324 | let result = sync(&edge, &job, || edge.state().changing = true); | |
| 321 | 325 | drop(disk); | |
| ⋯ | |||
| 464 | 468 | let name = format!("snapshot-{}.tar", SNAPSHOTS.fetch_add(1, Ordering::Relaxed)); | |
| 465 | 469 | let file = edge.root.join(&name); | |
| 466 | 470 | let made = { | |
| 467 | − | let _disk = edge.disk.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 471 | + | let _disk = edge.disk.read().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 468 | 472 | let version = { | |
| 469 | 473 | let state = edge.state(); | |
| 470 | 474 | if state.changing { None } else { state.version } | |
| ⋯ | |||
| 523 | 527 | respond(request, Response::from_string("x-g1t-edge-version says the snapshot's version").with_status_code(400)); | |
| 524 | 528 | return 400; | |
| 525 | 529 | }; | |
| 526 | − | let _disk = edge.disk.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 530 | + | let _disk = edge.disk.write().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 527 | 531 | let mirror = edge.mirror(); | |
| 528 | 532 | { | |
| 529 | 533 | let state = edge.state(); | |
| ⋯ | |||
| 628 | 632 | /// and the body's bytes. | |
| 629 | 633 | fn serve_git(edge: &Edge, request: Request, endpoint: &str, query: &str) -> (u16, u64) { | |
| 630 | 634 | crate::abuse::touch(); | |
| 631 | − | let expected = header(&request, VERSION_HEADER).and_then(|value| value.trim().parse::<u64>().ok()); | |
| 632 | − | let current = { | |
| 633 | − | let state = edge.state(); | |
| 634 | − | if state.changing { None } else { state.version } | |
| 635 | − | }; | |
| 636 | 635 | let behind = |request: Request| { | |
| 637 | 636 | let behind = Header::from_bytes(EDGE_HEADER.as_bytes(), &b"behind"[..]).expect("a valid header"); | |
| 638 | 637 | respond(request, Response::empty(409).with_header(behind)); | |
| 639 | 638 | (409, 0) | |
| 640 | 639 | }; | |
| 640 | + | // Held until the answer has gone, so no sync replaces the mirror's | |
| 641 | + | // packs under it; while a sync holds the mirror, the request is behind. | |
| 642 | + | let _disk = match edge.disk.try_read() { | |
| 643 | + | Ok(guard) => guard, | |
| 644 | + | Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(), | |
| 645 | + | Err(TryLockError::WouldBlock) => return behind(request), | |
| 646 | + | }; | |
| 647 | + | let expected = header(&request, VERSION_HEADER).and_then(|value| value.trim().parse::<u64>().ok()); | |
| 648 | + | let current = { | |
| 649 | + | let state = edge.state(); | |
| 650 | + | if state.changing { None } else { state.version } | |
| 651 | + | }; | |
| 641 | 652 | if expected.is_none() || expected != current { | |
| 642 | 653 | return behind(request); | |
| 643 | 654 | } | |
| ⋯ | |||
| 876 | 887 | }; | |
| 877 | 888 | let listening = server.server_addr().to_ip().map_or_else(|| format!("port {port}"), |address| address.to_string()); | |
| 878 | 889 | eprintln!("g1t-runner: gitedge: listening on {listening}, the mirror in {}", root.join(MIRROR).display()); | |
| 879 | − | let edge = Arc::new(Edge { root, state: Mutex::new(State::default()), wake: Condvar::new(), disk: Mutex::new(()) }); | |
| 890 | + | let edge = Arc::new(Edge { root, state: Mutex::new(State::default()), wake: Condvar::new(), disk: RwLock::new(()) }); | |
| 880 | 891 | let worker = edge.clone(); | |
| 881 | 892 | std::thread::spawn(move || work(worker)); | |
| 882 | 893 | for request in server.incoming_requests() { | |
| 122 | 122 | let url = format!("https://runner/gitedge/{repo_id}/{}{query}", git.endpoint); | |
| 123 | 123 | self.runner.fetch_request(Request::new_with_init(&url, &init)?).await | |
| 124 | 124 | }; | |
| 125 | − | match asked.await { | |
| 126 | − | Ok(response) if response.status_code() == 200 => Some(response), | |
| 127 | − | Ok(_) => None, | |
| 125 | + | let response = match asked.await { | |
| 126 | + | Ok(response) if response.status_code() == 200 => response, | |
| 127 | + | Ok(_) => return None, | |
| 128 | 128 | Err(error) => { | |
| 129 | 129 | worker::console_warn!("git edge for {repo_id} not asked: {error}"); | |
| 130 | + | return None; | |
| 131 | + | } | |
| 132 | + | }; | |
| 133 | + | // A fetch's answer is looked at before it is given: one that is | |
| 134 | + | // empty or starts as no fetch's answer does goes to the store too. | |
| 135 | + | if git.endpoint != "git-upload-pack" || body.is_some_and(lists_refs) { | |
| 136 | + | return Some(response); | |
| 137 | + | } | |
| 138 | + | let length = response.headers().get("content-length").ok().flatten(); | |
| 139 | + | match sound(response).await { | |
| 140 | + | Ok(Ok(response)) => Some(response), | |
| 141 | + | Ok(Err(head)) => { | |
| 142 | + | // What it sent, so the log says why (the edge's own log is not kept). | |
| 143 | + | let shown: String = head.iter().take(160).map(|&byte| if byte.is_ascii_graphic() || byte == b' ' { byte as char } else { '.' }).collect(); | |
| 144 | + | worker::console_warn!( | |
| 145 | + | "git edge for {repo_id} answered a fetch with nothing it could use: {} bytes before it ended or went wrong (content-length {}): {shown}", | |
| 146 | + | head.len(), | |
| 147 | + | length.as_deref().unwrap_or("none") | |
| 148 | + | ); | |
| 149 | + | None | |
| 150 | + | } | |
| 151 | + | Err(error) => { | |
| 152 | + | worker::console_warn!("git edge for {repo_id} answer not read: {error}"); | |
| 130 | 153 | None | |
| 131 | 154 | } | |
| 132 | 155 | } | |
| ⋯ | |||
| 160 | 183 | } | |
| 161 | 184 | } | |
| 162 | 185 | ||
| 186 | + | /// Whether an upload-pack request is protocol v2's `ls-refs`, whose answer | |
| 187 | + | /// may be a lone flush (a repository with no refs). | |
| 188 | + | fn lists_refs(body: &[u8]) -> bool { | |
| 189 | + | body.windows(b"command=ls-refs".len()).any(|window| window == b"command=ls-refs") | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | /// Whether the start of a fetch's answer is one: its first pkt-line carries | |
| 193 | + | /// data (a section, `NAK`, an `ACK`, `shallow`), not an error, and is not a | |
| 194 | + | /// flush, a delimiter or the end of the response. `None` until enough of | |
| 195 | + | /// it has come to tell. | |
| 196 | + | pub(crate) fn starts_soundly(head: &[u8]) -> Option<bool> { | |
| 197 | + | let length = usize::from_str_radix(std::str::from_utf8(head.get(..4)?).ok()?, 16).ok(); | |
| 198 | + | let Some(length) = length else { return Some(false) }; | |
| 199 | + | if length < 4 { | |
| 200 | + | return Some(false); | |
| 201 | + | } | |
| 202 | + | let payload = &head[4..head.len().min(length)]; | |
| 203 | + | if payload.len() < 4.min(length - 4) { | |
| 204 | + | return None; | |
| 205 | + | } | |
| 206 | + | Some(!payload.starts_with(b"ERR ")) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | /// The edge's answer to a fetch, when it starts as one (`starts_soundly`): | |
| 210 | + | /// only its first bytes are read, and the rest streams on behind them. | |
| 211 | + | /// What was read of it, when it is empty or starts otherwise. An upload-pack | |
| 212 | + | /// that died after `git http-backend` had sent its headers leaves a 200 | |
| 213 | + | /// with nothing in it, which git takes for `expected 'packfile'`; the | |
| 214 | + | /// store answers. | |
| 215 | + | async fn sound(mut response: Response) -> worker::Result<std::result::Result<Response, Vec<u8>>> { | |
| 216 | + | let headers = response.headers().clone(); | |
| 217 | + | headers.delete("content-length")?; | |
| 218 | + | let mut stream = response.stream()?; | |
| 219 | + | let mut head = Vec::new(); | |
| 220 | + | loop { | |
| 221 | + | match starts_soundly(&head) { | |
| 222 | + | Some(true) => break, | |
| 223 | + | Some(false) => return Ok(Err(head)), | |
| 224 | + | None => match futures_util::StreamExt::next(&mut stream).await { | |
| 225 | + | Some(chunk) => head.extend_from_slice(&chunk?), | |
| 226 | + | // All of it has come: it is sound or not as it stands. | |
| 227 | + | None => { | |
| 228 | + | return Ok(match starts_soundly(&head) { | |
| 229 | + | Some(true) => Ok(Response::from_bytes(head)?.with_headers(headers)), | |
| 230 | + | _ => Err(head), | |
| 231 | + | }); | |
| 232 | + | } | |
| 233 | + | }, | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | let rest = futures_util::StreamExt::chain(futures_util::stream::once(async move { Ok::<Vec<u8>, worker::Error>(head) }), stream); | |
| 237 | + | Ok(Ok(Response::from_stream(rest)?.with_headers(headers))) | |
| 238 | + | } | |
| 239 | + | ||
| 163 | 240 | #[cfg(test)] | |
| 164 | 241 | mod tests { | |
| 165 | 242 | use super::*; | |
| 166 | 243 | ||
| 244 | + | #[test] | |
| 245 | + | fn a_fetchs_answer_starts_with_data() { | |
| 246 | + | // A section, an acknowledgment, NAK, a shallow line: sound. | |
| 247 | + | assert_eq!(starts_soundly(b"000dpackfile\n0027\x02Enumerating"), Some(true)); | |
| 248 | + | assert_eq!(starts_soundly(b"0011shallow-info\n"), Some(true)); | |
| 249 | + | assert_eq!(starts_soundly(b"0008NAK\n"), Some(true)); | |
| 250 | + | assert_eq!(starts_soundly(b"0014acknowledgments\n"), Some(true)); | |
| 251 | + | // Nothing, the end of the response, a flush, an error: not. | |
| 252 | + | assert_eq!(starts_soundly(b"0002"), Some(false)); | |
| 253 | + | assert_eq!(starts_soundly(b"0000"), Some(false)); | |
| 254 | + | assert_eq!(starts_soundly(b"0001"), Some(false)); | |
| 255 | + | assert_eq!(starts_soundly(b"0021ERR upload-pack: not our ref\n"), Some(false)); | |
| 256 | + | assert_eq!(starts_soundly(b"zzzzpackfile"), Some(false)); | |
| 257 | + | // Too little yet to tell. | |
| 258 | + | assert_eq!(starts_soundly(b""), None); | |
| 259 | + | assert_eq!(starts_soundly(b"000d"), None); | |
| 260 | + | assert_eq!(starts_soundly(b"000dpa"), None); | |
| 261 | + | assert!(lists_refs(b"0014command=ls-refs\n0000")); | |
| 262 | + | assert!(!lists_refs(b"0011command=fetch\n0000")); | |
| 263 | + | } | |
| 264 | + | ||
| 167 | 265 | fn git(endpoint: &'static str, service: GitService) -> GitRequest { | |
| 168 | 266 | GitRequest { | |
| 169 | 267 | path: g1t_contracts::repos::RepoPath { namespace: "acme".into(), name: "rocket".into() }, | |
Comments
Sign in to comment.