Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,7 @@ and reconciler, collaborators, branches) and F2b (smart-HTTP git transport via `
`pre-receive` ref policy, admission control, threat-model pass #1) and F3a (content reads: refs, tags, commits,
trees, contents, archives) and F3b (contents writes: create, update, delete as server-made commits) and F4a (branch protection
enforced on push, contents API and branch delete; commit statuses) and F4b (pull requests, reviews, comments, retention
refs) and F4c (merge with a crash-safe journal, required-check gating, kill -9 and protection-matrix tests) are built. Hosted repos are
refs) and F4c (merge with a crash-safe journal, required-check gating, kill -9 and protection-matrix tests) and F5a (two-stage gc, merge-commit retention, runtime cleanup of stale locks and quarantine directories) are built. Hosted repos are
bare Git and get none of the `.sc/` features.

Other follow-up work is tracked as GitHub issues and listed under Deferred
Expand Down
10 changes: 10 additions & 0 deletions crates/forge/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,16 @@ pub fn admin_list_users(ctx: &mut Ctx) -> Resp {
}
}

/// `POST /admin/cron/{task}`: run a maintenance task now. Only `git_gc_repos` exists; it answers 204
/// at once and the pass runs on the maintenance thread (a pass already queued or running covers it).
pub fn admin_cron(ctx: &mut Ctx) -> Resp {
if ctx.param("task") != "git_gc_repos" {
return error(&ctx.app.base, 404, "unknown cron task");
}
ctx.app.maint.request();
Resp::no_content()
}

/// `POST /admin/users`: create a user; `must_change_password` defaults to true as in Gitea, and `admin` is ignored (set it with PATCH).
pub fn admin_create_user(ctx: &mut Ctx) -> Resp {
let b = match body(ctx) {
Expand Down
2 changes: 2 additions & 0 deletions crates/forge/src/cgi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,8 @@ fn launch(l: Launch) -> Result<Started, LaunchError> {
.registry
.register(
repo_id,
// Only a fetch is known to write no lock and no quarantine directory.
l.plan.service != Some(Service::Upload),
|| matches!(repos::find_by_id(&conn, repo_id), Ok(Some(r)) if r.state == "live"),
)
.ok_or_else(|| {
Expand Down
42 changes: 42 additions & 0 deletions crates/forge/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ use std::net::SocketAddr;
use std::path::PathBuf;
use std::time::Duration;

/// The largest duration flag accepted: ten years, far beyond any sensible window.
const MAX_DURATION_SECS: u64 = 10 * 365 * 86_400;

#[derive(Parser)]
#[command(name = "sc-forge", version, about = "A Gitea-compatible Git forge")]
struct Cli {
Expand Down Expand Up @@ -91,6 +94,16 @@ struct ServeArgs {
/// Memory limit of each git transport child, bytes, Linux only (0 = none).
#[arg(long, default_value_t = 8 << 30)]
git_memory_limit: u64,
/// Seconds between garbage-collection passes over all repositories (0 = only on request through
/// `POST /api/v1/admin/cron/git_gc_repos`).
#[arg(long, default_value_t = 86_400)]
maintenance_interval: u64,
/// Seconds an unreachable object survives garbage collection (at least twice `--git-timeout` and an hour).
#[arg(long, default_value_t = 14 * 86_400)]
gc_prune_expire: u64,
/// Wall-clock limit of the garbage collection of one repository, seconds.
#[arg(long, default_value_t = 3_600)]
gc_timeout: u64,
/// Concurrent password verifications (Argon2 is memory-hard).
#[arg(long, default_value_t = 4)]
argon2_concurrency: usize,
Expand Down Expand Up @@ -264,11 +277,30 @@ fn run_serve(a: ServeArgs) -> Result<()> {
("--git-idle-timeout", a.git_idle_timeout),
("--max-pack-size", a.max_pack_size),
("--max-request-bytes", a.max_request_bytes),
("--gc-timeout", a.gc_timeout),
] {
if v == 0 {
bail!("{flag} must be at least 1");
}
}
let min_expire = scl_forge::maintenance::min_prune_expire(Duration::from_secs(a.git_timeout));
if Duration::from_secs(a.gc_prune_expire) < min_expire {
bail!(
"--gc-prune-expire must be at least {} seconds (twice --git-timeout, and an hour): a shorter window can delete objects an operation in flight still needs",
min_expire.as_secs()
);
}
// Durations are added to `Instant::now()` and subtracted from a unix time: an absurd value
// would panic the runner or, worse, turn a long prune window into "prune now".
for (flag, v) in [
("--maintenance-interval", a.maintenance_interval),
("--gc-prune-expire", a.gc_prune_expire),
("--gc-timeout", a.gc_timeout),
] {
if v > MAX_DURATION_SECS {
bail!("{flag} must be at most {MAX_DURATION_SECS} seconds (ten years)");
}
}
bind_gate(&a.listen, a.behind_proxy, a.allow_plaintext).map_err(anyhow::Error::msg)?;
let cidrs = a
.trusted_proxies
Expand Down Expand Up @@ -335,6 +367,16 @@ fn run_serve(a: ServeArgs) -> Result<()> {
std::sync::Arc::get_mut(&mut app)
.expect("the app has no other owner yet")
.transports = scl_forge::cgi::Transports::new(tcfg);
std::sync::Arc::get_mut(&mut app)
.expect("the app has no other owner yet")
.maint = scl_forge::maintenance::Maintenance::new(scl_forge::maintenance::Cfg {
interval: Duration::from_secs(a.maintenance_interval),
prune_expire: Duration::from_secs(a.gc_prune_expire),
gc_timeout: Duration::from_secs(a.gc_timeout),
min_free: a.min_free_bytes,
..Default::default()
});
let _maintenance = scl_forge::maintenance::spawn(app.clone());
let limits = Limits {
max_body: a.max_body_bytes,
max_connections: a.max_connections,
Expand Down
54 changes: 54 additions & 0 deletions crates/forge/src/gitproc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
//! through [`Identity::new`]); this module never builds a revision from client text.

use crate::limits::Gate;
use crate::registry::Cancel;
use std::ffi::OsStr;
use std::io::{Read, Write};
use std::os::unix::process::CommandExt;
Expand All @@ -32,6 +33,9 @@ pub enum GitError {
/// More output than the cap; the process group was killed.
#[error("git produced too much output")]
OutputTooLarge,
/// The caller's [`Cancel`] was set; the process group was killed.
#[error("git was cancelled")]
Cancelled,
#[error("could not run git: {0}")]
Spawn(#[from] std::io::Error),
/// git ran and failed in a way that means "something is wrong", not "not found".
Expand Down Expand Up @@ -105,6 +109,9 @@ pub struct Spec<'a> {
/// Extra environment for this spawn, added after the pinned one (the `pre-receive` hook passes
/// the object directories of git's quarantine this way).
pub extra_env: &'a [(&'a str, &'a str)],
/// Ends the spawn (group killed, [`GitError::Cancelled`]) once set; checked before the spawn
/// and on every poll.
pub cancel: Option<&'a Cancel>,
}

/// The runner.
Expand Down Expand Up @@ -184,6 +191,9 @@ impl Git {
("core.fsyncMethod".into(), "fsync".into()),
("core.useReplaceRefs".into(), "false".into()),
("gc.auto".into(), "0".into()),
// A maintenance `pack-refs` holds `packed-refs.lock` for a moment; a ref delete waits
// for it (git's default is one second).
("core.packedRefsTimeout".into(), "5000".into()),
("receive.fsckObjects".into(), "true".into()),
];
config.extend(extra.iter().map(|(k, v)| (k.to_string(), v.clone())));
Expand Down Expand Up @@ -219,6 +229,9 @@ impl Git {
.gate
.acquire_timeout(self.admission)
.ok_or(GitError::Busy)?;
if spec.cancel.is_some_and(Cancel::is_set) {
return Err(GitError::Cancelled);
}
let mut cmd = Command::new(program);
cmd.env_clear()
.envs(self.env(spec.identity))
Expand Down Expand Up @@ -298,6 +311,9 @@ impl Git {
if over.load(Ordering::SeqCst) {
break Some(GitError::OutputTooLarge);
}
if spec.cancel.is_some_and(Cancel::is_set) {
break Some(GitError::Cancelled);
}
if Instant::now() >= deadline {
break Some(GitError::Timeout { stdout: Vec::new() });
}
Expand Down Expand Up @@ -679,6 +695,44 @@ mod tests {
let _ = std::fs::remove_dir_all(d);
}

#[test]
fn a_cancel_kills_the_group_and_a_set_one_prevents_the_spawn() {
let (g, d) = git("cancel");
let cancel = Cancel::default();
let setter = cancel.clone();
let t = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(300));
setter.cancel();
});
let started = Instant::now();
let err = g
.run_program(
OsStr::new("/bin/sh"),
&Spec {
args: &["-c", "sleep 60 & echo $!; wait"],
cancel: Some(&cancel),
..Default::default()
},
)
.unwrap_err();
t.join().unwrap();
assert!(matches!(err, GitError::Cancelled), "{err:?}");
assert!(started.elapsed() < Duration::from_secs(10));
// Already set: nothing starts (the marker file would exist if it had).
let marker = d.join("started");
let again = g.run_program(
OsStr::new("/bin/sh"),
&Spec {
args: &["-c", &format!("touch {}", marker.display())],
cancel: Some(&cancel),
..Default::default()
},
);
assert!(matches!(again, Err(GitError::Cancelled)));
assert!(!marker.exists());
let _ = std::fs::remove_dir_all(d);
}

#[test]
fn a_normal_exit_still_ends_a_lingering_background_child() {
let (g, d) = git("linger");
Expand Down
1 change: 1 addition & 0 deletions crates/forge/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub mod hook;
pub mod http;
pub mod lifecycle;
pub mod limits;
pub mod maintenance;
pub mod merges;
pub mod names;
pub mod orphans;
Expand Down
45 changes: 45 additions & 0 deletions crates/forge/src/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,51 @@ impl Lifecycle {
})
}

/// Run `f` as the only server-side writer of repository `id`: the write gate only, not the
/// lifecycle lock, so a `delete` is never held up behind it (maintenance cancels itself through
/// the registry instead). `None`: the gate stayed taken for `wait`.
pub fn with_gate<T>(&self, id: i64, wait: Duration, f: impl FnOnce() -> T) -> Option<T> {
let gate = self
.gates
.lock()
.unwrap_or_else(|e| e.into_inner())
.entry(id)
.or_insert_with(|| Arc::new(crate::limits::Gate::new(1)))
.clone();
let _writer = gate.acquire_timeout(wait)?;
Some(f())
}

/// Run `f` (a server-made write) under the shared lifecycle lock if it can be had right now,
/// never waiting: a delete queued for the exclusive lock would otherwise wait for a caller that
/// is itself waiting for the lock. Take the gate first. `None`: the lock was not free.
pub fn try_shared<T>(&self, id: i64, f: impl FnOnce() -> T) -> Option<T> {
let lock = self.lock_for(id);
let held = match lock.try_read() {
Ok(g) => g,
Err(std::sync::TryLockError::Poisoned(e)) => e.into_inner(),
Err(std::sync::TryLockError::WouldBlock) => return None,
};
let r = f();
drop(held);
Some(r)
}

/// Run `f` while holding the repository's exclusive lifecycle lock, if it is free right now
/// (never waits): default-branch changes and branch deletes write refs under that lock alone,
/// so a cleanup that unlinks lock files needs it too. Take the gate first (see the lock order).
pub fn try_exclusive<T>(&self, id: i64, f: impl FnOnce() -> T) -> Option<T> {
let lock = self.lock_for(id);
let held = match lock.try_write() {
Ok(g) => g,
Err(std::sync::TryLockError::Poisoned(e)) => e.into_inner(),
Err(std::sync::TryLockError::WouldBlock) => return None,
};
let r = f();
drop(held);
Some(r)
}

/// `repos/<id>.git`: always built from the integer id.
pub fn repo_dir(&self, id: i64) -> PathBuf {
self.data_dir.join("repos").join(format!("{id}.git"))
Expand Down
Loading
Loading