| | @@ -677,18 +677,45 @@ fn parse_latest_tag(stdout: &[u8]) -> Option<TagSummary> { |
| 677 | 677 | /// Only ever used for commands whose subject has already been confirmed to exist, so a |
| 678 | 678 | /// failure really is a failure. Built from [`git_command`] so the host isolation 0006 |
| 679 | 679 | /// insists on cannot drift out of this module. |
| 680 | +/// How long one read-side `git` process may run before it is killed. |
| 681 | +/// |
| 682 | +/// Every read here is a subprocess on a request path, and nothing bounded it before |
| 683 | +/// blame and grep arrived — either can run for a long time on a large repository, and |
| 684 | +/// a request that never finishes holds a worker for as long as the client waits. |
| 685 | +/// Twenty seconds is far above any read a page should make and far below "hung". |
| 686 | +const GIT_TIMEOUT: Duration = Duration::from_secs(20); |
| 687 | + |
| 680 | 688 | async fn run<I, S>(repo: &Path, args: I) -> Result<Output, GitQueryError> |
| 681 | 689 | where |
| 682 | 690 | I: IntoIterator<Item = S>, |
| 683 | 691 | S: AsRef<OsStr>, |
| 684 | 692 | { |
| 685 | | − let mut command = git_command(); |
| 686 | | − command.arg("-C").arg(repo).args(args).stdin(Stdio::null()); |
| 693 | + run_within(repo, args, GIT_TIMEOUT).await |
| 694 | +} |
| 687 | 695 | |
| 688 | | − let output = command |
| 689 | | − .output() |
| 690 | | − .await |
| 691 | | − .map_err(|error| GitQueryError::new(format!("could not run git: {error}")))?; |
| 696 | +/// [`run`] with an explicit limit, so the timeout path can be tested without waiting |
| 697 | +/// twenty seconds for it. |
| 698 | +async fn run_within<I, S>(repo: &Path, args: I, limit: Duration) -> Result<Output, GitQueryError> |
| 699 | +where |
| 700 | + I: IntoIterator<Item = S>, |
| 701 | + S: AsRef<OsStr>, |
| 702 | +{ |
| 703 | + let mut command = git_command(); |
| 704 | + command |
| 705 | + .arg("-C") |
| 706 | + .arg(repo) |
| 707 | + .args(args) |
| 708 | + .stdin(Stdio::null()) |
| 709 | + // Dropping the future on timeout must take the process with it, or a killed |
| 710 | + // request leaves git running to completion for nobody. |
| 711 | + .kill_on_drop(true); |
| 712 | + |
| 713 | + let output = match tokio::time::timeout(limit, command.output()).await { |
| 714 | + Ok(result) => { |
| 715 | + result.map_err(|error| GitQueryError::new(format!("could not run git: {error}")))? |
| 716 | + } |
| 717 | + Err(_elapsed) => return Err(GitQueryError::timed_out(limit)), |
| 718 | + }; |
| 692 | 719 | |
| 693 | 720 | if !output.status.success() { |
| 694 | 721 | return Err(GitQueryError::new(format!( |
| | @@ -1777,4 +1804,28 @@ mod tests { |
| 1777 | 1804 | assert!(unix_time(-1) < UNIX_EPOCH); |
| 1778 | 1805 | assert_eq!(unix_time(0), UNIX_EPOCH); |
| 1779 | 1806 | } |
| 1807 | + |
| 1808 | + #[tokio::test] |
| 1809 | + async fn a_read_that_exceeds_its_limit_is_a_timeout_not_a_fault() { |
| 1810 | + let (_dir, repo) = fixture_repo_for_timeout().await; |
| 1811 | + let error = run_within(&repo, ["rev-parse", "HEAD"], Duration::ZERO) |
| 1812 | + .await |
| 1813 | + .expect_err("a zero limit cannot be met"); |
| 1814 | + assert!(error.is_timeout(), "{error}"); |
| 1815 | + } |
| 1816 | + |
| 1817 | + /// A bare repository with nothing in it: `rev-parse` failing is not the point, the |
| 1818 | + /// process being cut off before it can answer is. |
| 1819 | + async fn fixture_repo_for_timeout() -> (TempDir, std::path::PathBuf) { |
| 1820 | + let dir = TempDir::new().unwrap(); |
| 1821 | + let repo = dir.path().join("t.git"); |
| 1822 | + let status = git_command() |
| 1823 | + .args(["init", "--bare", "-q"]) |
| 1824 | + .arg(&repo) |
| 1825 | + .status() |
| 1826 | + .await |
| 1827 | + .unwrap(); |
| 1828 | + assert!(status.success()); |
| 1829 | + (dir, repo) |
| 1830 | + } |
| 1780 | 1831 | } |