feat(storage): route Git traffic through identifier families

Motivation: owner repositories and GRASP-06 contributor routes currently store or copy the same Git objects independently, forcing clients to upload data the relay already has.

Approach: create new repositories as thin ref views, direct receive-pack and proactive fetch writes into the shared identifier family, retain accepted tips, and expose family bases as anonymous receive negotiation haves. Existing legacy repositories deliberately remain self-contained until the startup migration layer lands.

Correctness assumptions: repositories only share objects when their validated identifier and object format match. A process-wide per-family lease serializes object-producing operations, while the terminal receive-pack flush remains behind ref validation and post-push processing.

Excluded scope: remote S3 durability, cache eviction, garbage collection, and conversion of existing repositories are separate stack layers.

Validation: cargo check --all-targets; cargo test --test git_response_streaming; cargo test --test grasp06_pr_hosting; plus repository_creation, git_clone, and storage/purgatory targeted suites from the preceding review.
This commit is contained in:
DanConwayDev
2026-08-17 15:11:27 +00:00
parent 27f9eaaf98
commit f084f7e973
10 changed files with 341 additions and 108 deletions
+2 -2
View File
@@ -711,8 +711,8 @@ Optional endpoint at `/prs/<npub>/<identifier>.git`, gated on `NGIT_GRASP06_ENAB
- [`src/grasp06/endpoint.rs`](../../src/grasp06/endpoint.rs) — URL parsing. - [`src/grasp06/endpoint.rs`](../../src/grasp06/endpoint.rs) — URL parsing.
- [`src/grasp06/paths.rs`](../../src/grasp06/paths.rs) — on-disk path conventions under `<git_data_path>/prs/<hex>/<identifier>.git`. - [`src/grasp06/paths.rs`](../../src/grasp06/paths.rs) — on-disk path conventions under `<git_data_path>/prs/<hex>/<identifier>.git`.
- [`src/grasp06/fetch.rs`](../../src/grasp06/fetch.rs) — empty-repo synthesis for `info/refs` and `git-upload-pack` against repos that don't yet exist on disk. - [`src/grasp06/fetch.rs`](../../src/grasp06/fetch.rs) — empty thin-view synthesis for `info/refs` and `git-upload-pack` against routes that do not yet exist on disk. Named refs stay empty while an existing identifier family supplies anonymous receive negotiation bases.
- [`src/grasp06/receive.rs`](../../src/grasp06/receive.rs) — `git-receive-pack` with init-on-push, strict `refs/nostr/<event-id>` ref-name validation, and per-ref post-push validation against the database and purgatory. Uses a per-`(submitter, identifier)` `PrsPathState` (mutex + `in_flight` counter) so concurrent pushes to the same path proceed in parallel: the mutex is held only for init+register and decrement+end-of-push cleanup, not across `git-receive-pack` itself. The same state is consulted (with `try_lock` in synchronous contexts) by the PR-event policy and the purgatory expiry sweep, which only remove a `/prs/` ref or zero-ref bare repo when `in_flight == 0`. - [`src/grasp06/receive.rs`](../../src/grasp06/receive.rs) — `git-receive-pack` with thin-view init-on-push, strict `refs/nostr/<event-id>` ref-name validation, and per-ref post-push validation against the database and purgatory. A per-`(submitter, identifier)` `PrsPathState` protects view creation/removal; a per-family lease serializes object-producing work across related views. The same path state is consulted by PR-event policy and purgatory expiry, which only remove a `/prs/` ref or zero-ref view when `in_flight == 0`.
- [`src/grasp06/policy.rs`](../../src/grasp06/policy.rs) — strict clone-tag URL comparator used by the PR-event acceptance relaxation. - [`src/grasp06/policy.rs`](../../src/grasp06/policy.rs) — strict clone-tag URL comparator used by the PR-event acceptance relaxation.
- [`src/grasp06/cleanup.rs`](../../src/grasp06/cleanup.rs) — one-shot startup scan over `<git_data_path>/prs/` that removes zero-ref bare repos left behind by a previous run (crash mid-push, crash mid-cleanup, or shutdown with unresolved scoped placeholders). Runs before the HTTP server starts accepting requests; no locking is needed because nothing else is touching `/prs/` yet. - [`src/grasp06/cleanup.rs`](../../src/grasp06/cleanup.rs) — one-shot startup scan over `<git_data_path>/prs/` that removes zero-ref bare repos left behind by a previous run (crash mid-push, crash mid-cleanup, or shutdown with unresolved scoped placeholders). Runs before the HTTP server starts accepting requests; no locking is needed because nothing else is touching `/prs/` yet.
@@ -55,7 +55,9 @@ Two reasons:
The contributor chose to publish to `/prs/`. Mirroring into any accepted repository on this relay makes the PR visible at the expected location for clients browsing that repo. Not mirroring the reverse direction preserves the maintainer's declared `clone` intent on their own pushes — we do not invent new hosting locations for their events. The contributor chose to publish to `/prs/`. Mirroring into any accepted repository on this relay makes the PR visible at the expected location for clients browsing that repo. Not mirroring the reverse direction preserves the maintainer's declared `clone` intent on their own pushes — we do not invent new hosting locations for their events.
Until object-pool dedup lands, the mirror doubles storage for affected refs. We accept this cost as the simplest correct implementation; dedup lands later and makes the mirror effectively free. Identifier-family object storage makes the mirror a ref-only operation. Owner
and contributor views for the same identifier resolve one shared object
inventory, so accepting or mirroring a PR does not copy its object graph.
## Architecture ## Architecture
@@ -94,7 +96,7 @@ POST /prs/<npub>/<id>.git/git-receive-pack
signer ≠ URL npub → reject ref signer ≠ URL npub → reject ref
identifier (d-tag) ≠ URL identifier → reject ref identifier (d-tag) ≠ URL identifier → reject ref
commit ≠ event's c tag → delete ref commit ≠ event's c tag → delete ref
all match → ref locked; release event from purgatory; all match → ref locked; retain family tip; release event from purgatory;
mirror to matching standard repos mirror to matching standard repos
event not yet seen: event not yet seen:
accept ref; create or update PR placeholder in purgatory accept ref; create or update PR placeholder in purgatory
@@ -107,7 +109,14 @@ The flow mirrors the existing `refs/nostr/<event-id>` path at the standard endpo
#### On-demand bare repo creation #### On-demand bare repo creation
The first push to `/prs/<submitter>/<identifier>.git` creates the bare repo on disk. A per-`(submitter, identifier)` [`PrsPathState`](../../src/grasp06/receive.rs) (kept in a `DashMap` on the `HttpService`, see [`crate::grasp06::receive::RepoInitLocks`](../../src/grasp06/receive.rs)) provides a `tokio::sync::Mutex` and an `in_flight: AtomicUsize` counter. The mutex is held only briefly — for `git init --bare` plus the `in_flight` increment at the start of a push, and for the `in_flight` decrement plus end-of-push zero-ref cleanup at the end. The pack upload and per-ref validation run *without* the mutex held, so concurrent pushes to the same path proceed in parallel; git's own ref locking handles intra-push concurrency. Off-push cleanup paths (PR-event policy, purgatory expiry) take the same mutex briefly and only `rm -rf` the bare repo when they see `in_flight == 0` and `list_refs` is empty — so no off-push code path can delete the bare repo while a push is in flight, and no push is serialised behind another push to the same identity. The first push to `/prs/<submitter>/<identifier>.git` creates a thin bare view
whose alternate is the identifier family. A per-`(submitter, identifier)`
[`PrsPathState`](../../src/grasp06/receive.rs) still protects view creation and
cleanup. A separate per-family write lease serializes object-producing work
across all owner and contributor views for that identifier. `git-receive-pack`
writes objects to the family while updating only the selected view's refs.
Off-push cleanup may remove an empty contributor view, but never removes family
objects or retained roots.
### Event acceptance relaxation ### Event acceptance relaxation
@@ -130,9 +139,10 @@ When a PR or PR Update's purgatory entry is released via a `/prs/` push:
1. Save event to DB and remove from purgatory (as today). 1. Save event to DB and remove from purgatory (as today).
2. For each `a` tag in the event of the form `30617:<pubkey>:<d-tag>`: 2. For each `a` tag in the event of the form `30617:<pubkey>:<d-tag>`:
- Resolve to a local repo path `<git_data_path>/<pubkey-npub>/<d-tag>.git`. - Resolve to a local repo path `<git_data_path>/<pubkey-npub>/<d-tag>.git`.
- If that repo has an active (non-purgatory) announcement, copy objects + install `refs/nostr/<event-id>` into it (same mechanism as existing cross-owner sync in [`src/git/sync.rs`](../../src/git/sync.rs)). - If that repo has an active (non-purgatory) announcement, install `refs/nostr/<event-id>` in its view after checking the commit resolves through the shared family.
The mirror copies the same ref, same commits. No separate object store. Dedup can be added transparently later via git alternates keyed on d-tag. The mirror installs the same ref to the same commit. Git alternates keyed by
identifier make the objects immediately available without a copy.
The mirror is **one-directional**: pushes to `/<maintainer>/<id>.git` are not mirrored into `/prs/*`. Only the `/prs/` → `<maintainer>/` direction fires, and only when the source repo path is under `prs_base_path`. The mirror is **one-directional**: pushes to `/<maintainer>/<id>.git` are not mirrored into `/prs/*`. Only the `/prs/` → `<maintainer>/` direction fires, and only when the source repo path is under `prs_base_path`.
@@ -250,9 +260,10 @@ This is the design rationale for the mirror being one-directional and the `/prs/
Deletion of the contributor's own PR event (NIP-09) → the deletion-request branch will decide and implement the ref-lifecycle behaviour. GRASP-06 v1 does not add hooks or no-op scaffolding for this. Deletion of the contributor's own PR event (NIP-09) → the deletion-request branch will decide and implement the ref-lifecycle behaviour. GRASP-06 v1 does not add hooks or no-op scaffolding for this.
### Object-pool deduplication (not yet specified) ### Object-pool deduplication
The mirror copies objects today. When object-pool dedup exists — likely as git alternates keyed on `(identifier, a-tag-coord-set)` — the mirror becomes a pointer operation with near-zero storage cost. No URL or spec change is required. The identifier family is the shared object inventory. Mirroring installs a view
ref and does not copy objects; no URL or spec change is required.
## Anticipated failure modes and mitigations ## Anticipated failure modes and mitigations
+78 -4
View File
@@ -17,6 +17,7 @@ use tokio::time::MissedTickBehavior;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use super::protocol::{GitService, PktLine}; use super::protocol::{GitService, PktLine};
use super::storage::{FamilyKey, FamilyWriteLease, LocalGitStorage};
use super::subprocess::GitSubprocess; use super::subprocess::GitSubprocess;
use super::{full_body, GitResponseBody}; use super::{full_body, GitResponseBody};
@@ -343,7 +344,7 @@ where
/// caller finish GRASP post-push processing before making success visible, /// caller finish GRASP post-push processing before making success visible,
/// without buffering the progress stream that keeps clients alive during /// without buffering the progress stream that keeps clients alive during
/// expensive pack processing. /// expensive pack processing.
async fn pump_receive_pack_stdout_to_channel<R>( pub(crate) async fn pump_receive_pack_stdout_to_channel<R>(
mut stdout: R, mut stdout: R,
tx: &mpsc::Sender<Result<Frame<Bytes>, io::Error>>, tx: &mpsc::Sender<Result<Frame<Bytes>, io::Error>>,
) -> (PumpResult, Option<Vec<u8>>) ) -> (PumpResult, Option<Vec<u8>>)
@@ -701,8 +702,33 @@ pub async fn handle_receive_pack(
} }
}; };
// Spawn git receive-pack let storage = LocalGitStorage::new(git_data_path);
let mut git = GitSubprocess::spawn(GitService::ReceivePack, &repo_path, false, git_protocol) let family_key = FamilyKey::sha1(identifier).map_err(|e| GitError::Storage(e.to_string()))?;
let family_lease = if storage.is_thin_view(&family_key, &repo_path) {
Some(
storage
.write_lease(&family_key)
.await
.map_err(|e| GitError::Storage(e.to_string()))?,
)
} else {
// Legacy repositories remain self-contained until startup migration.
// This fallback also keeps direct library callers safe: never point a
// ref at a family object database the view cannot read.
None
};
// Ref updates remain in the selected view; receive-pack's quarantine and
// final objects are installed directly in the shared family inventory.
let mut git = GitSubprocess::spawn_with_object_directory(
GitService::ReceivePack,
&repo_path,
false,
git_protocol,
family_lease
.as_ref()
.map(|lease| lease.family_objects_path.as_path()),
)
.map_err(GitError::ProcessSpawnFailed)?; .map_err(GitError::ProcessSpawnFailed)?;
// Write request to git's stdin // Write request to git's stdin
@@ -754,6 +780,10 @@ pub async fn handle_receive_pack(
repo_lifecycle_guard, repo_lifecycle_guard,
promotion_hooks, promotion_hooks,
metrics, metrics,
storage,
family_key,
family_lease,
pushed_refs,
) )
.await; .await;
}); });
@@ -778,6 +808,10 @@ async fn stream_receive_pack_output<S, E>(
repo_lifecycle_guard: Option<LifecycleReadGuard>, repo_lifecycle_guard: Option<LifecycleReadGuard>,
promotion_hooks: Option<Arc<dyn PurgatoryPromotionHooks>>, promotion_hooks: Option<Arc<dyn PurgatoryPromotionHooks>>,
metrics: Option<Arc<Metrics>>, metrics: Option<Arc<Metrics>>,
storage: LocalGitStorage,
family_key: FamilyKey,
family_lease: Option<FamilyWriteLease>,
pushed_refs: Vec<(String, String, String)>,
) where ) where
S: tokio::io::AsyncRead + Unpin + Send + 'static, S: tokio::io::AsyncRead + Unpin + Send + 'static,
E: tokio::io::AsyncRead + Unpin + Send + 'static, E: tokio::io::AsyncRead + Unpin + Send + 'static,
@@ -863,6 +897,10 @@ async fn stream_receive_pack_output<S, E>(
debug!("Git receive-pack stream completed successfully"); debug!("Git receive-pack stream completed successfully");
if family_lease.is_some() {
retain_accepted_tips(&storage, &family_key, &repo_path, &pushed_refs);
}
// Release the repository lifecycle read lock once git-receive-pack itself // Release the repository lifecycle read lock once git-receive-pack itself
// has finished. The lock's purpose is to keep deletion/archive/restore from // has finished. The lock's purpose is to keep deletion/archive/restore from
// removing or replacing the bare repository while Git is actively reading or // removing or replacing the bare repository while Git is actively reading or
@@ -948,7 +986,7 @@ async fn stream_receive_pack_output<S, E>(
record_git_operation(&metrics, "push", "success"); record_git_operation(&metrics, "push", "success");
} }
async fn send_body_bytes( pub(crate) async fn send_body_bytes(
tx: &mpsc::Sender<Result<Frame<Bytes>, io::Error>>, tx: &mpsc::Sender<Result<Frame<Bytes>, io::Error>>,
bytes: Vec<u8>, bytes: Vec<u8>,
) -> Result<(), mpsc::error::SendError<Result<Frame<Bytes>, io::Error>>> { ) -> Result<(), mpsc::error::SendError<Result<Frame<Bytes>, io::Error>>> {
@@ -966,6 +1004,7 @@ pub enum GitError {
ProcessSpawnFailed(std::io::Error), ProcessSpawnFailed(std::io::Error),
IoError(std::io::Error), IoError(std::io::Error),
GitFailed(Option<i32>), GitFailed(Option<i32>),
Storage(String),
} }
impl std::fmt::Display for GitError { impl std::fmt::Display for GitError {
@@ -975,6 +1014,41 @@ impl std::fmt::Display for GitError {
Self::ProcessSpawnFailed(e) => write!(f, "failed to spawn git process: {}", e), Self::ProcessSpawnFailed(e) => write!(f, "failed to spawn git process: {}", e),
Self::IoError(e) => write!(f, "IO error: {}", e), Self::IoError(e) => write!(f, "IO error: {}", e),
Self::GitFailed(code) => write!(f, "git process failed with code: {:?}", code), Self::GitFailed(code) => write!(f, "git process failed with code: {:?}", code),
Self::Storage(error) => write!(f, "Git storage error: {error}"),
}
}
}
pub(crate) fn retain_accepted_tips(
storage: &LocalGitStorage,
family_key: &FamilyKey,
repo_path: &std::path::Path,
pushed_refs: &[(String, String, String)],
) {
const ZERO_OID: &str = "0000000000000000000000000000000000000000";
for (_, new_oid, ref_name) in pushed_refs {
if new_oid == ZERO_OID
|| super::get_ref_commit(repo_path, ref_name).as_deref() != Some(new_oid.as_str())
{
continue;
}
if let Err(error) = storage.retain_tip(family_key, ref_name, new_oid) {
warn!(
family = %family_key.identifier,
%ref_name,
%new_oid,
%error,
"Failed to install family retention root"
);
}
if let Err(error) = storage.advertise_base_tip(family_key, ref_name, new_oid) {
warn!(
family = %family_key.identifier,
%ref_name,
%new_oid,
%error,
"Failed to install family negotiation root"
);
} }
} }
} }
+31 -9
View File
@@ -8,7 +8,7 @@
use std::fmt; use std::fmt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::sync::Arc; use std::sync::{Arc, LazyLock};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use bitcoin_hashes::{sha256, Hash}; use bitcoin_hashes::{sha256, Hash};
@@ -26,6 +26,9 @@ const FAMILY_DIR: &str = "families";
const BASE_REFS_PREFIX: &str = "refs/grasp/bases/"; const BASE_REFS_PREFIX: &str = "refs/grasp/bases/";
const RETAINED_REFS_PREFIX: &str = "refs/grasp/retained/"; const RETAINED_REFS_PREFIX: &str = "refs/grasp/retained/";
type FamilyLockId = (PathBuf, FamilyKey);
static FAMILY_LOCKS: LazyLock<DashMap<FamilyLockId, Arc<Mutex<()>>>> = LazyLock::new(DashMap::new);
/// Git object hash algorithms are separate family namespaces. /// Git object hash algorithms are separate family namespaces.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ObjectFormat { pub enum ObjectFormat {
@@ -100,15 +103,19 @@ impl FamilyKey {
#[derive(Clone)] #[derive(Clone)]
pub struct LocalGitStorage { pub struct LocalGitStorage {
git_data_path: PathBuf, git_data_path: PathBuf,
family_locks: Arc<DashMap<FamilyKey, Arc<Mutex<()>>>>,
} }
impl LocalGitStorage { impl LocalGitStorage {
pub fn new(git_data_path: impl Into<PathBuf>) -> Self { pub fn new(git_data_path: impl Into<PathBuf>) -> Self {
Self { let git_data_path = git_data_path.into();
git_data_path: git_data_path.into(), let git_data_path = if git_data_path.is_absolute() {
family_locks: Arc::new(DashMap::new()), git_data_path
} } else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(git_data_path)
};
Self { git_data_path }
} }
pub fn git_data_path(&self) -> &Path { pub fn git_data_path(&self) -> &Path {
@@ -134,11 +141,26 @@ impl LocalGitStorage {
self.family_repo_path(key).join("objects") self.family_repo_path(key).join("objects")
} }
pub fn family_exists(&self, key: &FamilyKey) -> bool {
self.family_repo_path(key).is_dir()
}
pub fn is_thin_view(&self, key: &FamilyKey, view_path: &Path) -> bool {
let alternate = view_path.join("objects/info/alternates");
let Ok(configured) = std::fs::read_to_string(alternate) else {
return false;
};
let Ok(expected) = std::fs::canonicalize(self.family_objects_path(key)) else {
return false;
};
configured.lines().any(|line| Path::new(line) == expected)
}
/// Serialize mutations to one identifier family. /// Serialize mutations to one identifier family.
pub async fn write_lease(&self, key: &FamilyKey) -> Result<FamilyWriteLease> { pub async fn write_lease(&self, key: &FamilyKey) -> Result<FamilyWriteLease> {
let lock = self let lock_id = (self.git_data_path.clone(), key.clone());
.family_locks let lock = FAMILY_LOCKS
.entry(key.clone()) .entry(lock_id)
.or_insert_with(|| Arc::new(Mutex::new(()))) .or_insert_with(|| Arc::new(Mutex::new(())))
.clone(); .clone();
let guard = lock.lock_owned().await; let guard = lock.lock_owned().await;
+24
View File
@@ -28,6 +28,27 @@ impl GitSubprocess {
repo_path: impl AsRef<Path>, repo_path: impl AsRef<Path>,
advertise: bool, advertise: bool,
git_protocol: Option<&str>, git_protocol: Option<&str>,
) -> std::io::Result<Self> {
Self::spawn_with_object_directory(
service,
repo_path,
advertise,
git_protocol,
None::<&Path>,
)
}
/// Spawn Git with an optional writable object database override.
///
/// Thin views normally read through `objects/info/alternates`. Commands
/// that create objects use this override so receive-pack quarantine output
/// and fetched objects land directly in the identifier family.
pub fn spawn_with_object_directory(
service: GitService,
repo_path: impl AsRef<Path>,
advertise: bool,
git_protocol: Option<&str>,
object_directory: Option<impl AsRef<Path>>,
) -> std::io::Result<Self> { ) -> std::io::Result<Self> {
let repo_path = repo_path.as_ref(); let repo_path = repo_path.as_ref();
@@ -62,6 +83,9 @@ impl GitSubprocess {
if let Some(protocol) = git_protocol { if let Some(protocol) = git_protocol {
cmd.env("GIT_PROTOCOL", protocol); cmd.env("GIT_PROTOCOL", protocol);
} }
if let Some(object_directory) = object_directory {
cmd.env("GIT_OBJECT_DIRECTORY", object_directory.as_ref());
}
let child = cmd.spawn()?; let child = cmd.spawn()?;
+22 -8
View File
@@ -5,10 +5,11 @@
//! > MUST respond to upload-pack requests for any well-formed path as if //! > MUST respond to upload-pack requests for any well-formed path as if
//! > serving an empty bare repository. //! > serving an empty bare repository.
//! //!
//! When a real `/prs/<submitter>/<identifier>.git` repo exists on disk //! When a real `/prs/<submitter>/<identifier>.git` repo exists on disk we
//! we delegate to the standard handlers in [`crate::git::handlers`]. //! delegate to the standard handlers in [`crate::git::handlers`]. Otherwise we
//! Otherwise we synthesise a brand-new empty bare repo in a per-request //! synthesize an empty thin view in a per-request temporary directory. If the
//! temporary directory and run the standard upload-pack against that. //! identifier family exists, receive-pack discovery can advertise its bases as
//! anonymous `.have` entries without exposing any named refs.
//! //!
//! Receive-pack lives in [`crate::grasp06::receive`]. //! Receive-pack lives in [`crate::grasp06::receive`].
@@ -22,6 +23,7 @@ use tracing::{debug, warn};
use crate::git::handlers::{handle_info_refs, handle_upload_pack, GitError}; use crate::git::handlers::{handle_info_refs, handle_upload_pack, GitError};
use crate::git::protocol::GitService; use crate::git::protocol::GitService;
use crate::git::storage::{FamilyKey, LocalGitStorage};
use crate::git::GitResponseBody; use crate::git::GitResponseBody;
use crate::grasp06::endpoint::PrsUrl; use crate::grasp06::endpoint::PrsUrl;
use crate::grasp06::paths::prs_repo_path; use crate::grasp06::paths::prs_repo_path;
@@ -60,7 +62,9 @@ pub async fn handle_prs_info_refs(
prs.submitter.to_hex(), prs.submitter.to_hex(),
prs.identifier prs.identifier
); );
let temp = init_empty_bare_repo()?; let storage = LocalGitStorage::new(git_data_path);
let key = FamilyKey::sha1(&prs.identifier).map_err(|e| GitError::Storage(e.to_string()))?;
let temp = init_empty_bare_repo(&storage, &key)?;
let repo_path = temp.path().to_path_buf(); let repo_path = temp.path().to_path_buf();
let response = handle_info_refs(repo_path, service, git_protocol).await; let response = handle_info_refs(repo_path, service, git_protocol).await;
// `temp` is dropped here, deleting the directory. Git has already // `temp` is dropped here, deleting the directory. Git has already
@@ -98,7 +102,9 @@ pub async fn handle_prs_upload_pack(
prs.submitter.to_hex(), prs.submitter.to_hex(),
prs.identifier prs.identifier
); );
let temp = init_empty_bare_repo()?; let storage = LocalGitStorage::new(git_data_path);
let key = FamilyKey::sha1(&prs.identifier).map_err(|e| GitError::Storage(e.to_string()))?;
let temp = init_empty_bare_repo(&storage, &key)?;
handle_prs_upload_pack_buffered(temp, body, git_protocol).await handle_prs_upload_pack_buffered(temp, body, git_protocol).await
} }
@@ -169,7 +175,7 @@ async fn handle_prs_upload_pack_buffered(
.unwrap()) .unwrap())
} }
/// Create a fresh empty bare repo in a temp directory. /// Create a fresh empty thin view in a temp directory.
/// ///
/// Per-request temp dirs are deliberate. They are cheap on /// Per-request temp dirs are deliberate. They are cheap on
/// any reasonable filesystem (one `mkdir`, one `git init --bare`) and /// any reasonable filesystem (one `mkdir`, one `git init --bare`) and
@@ -177,7 +183,10 @@ async fn handle_prs_upload_pack_buffered(
/// profiling shows this is a bottleneck we can switch to a shared /// profiling shows this is a bottleneck we can switch to a shared
/// `<git_data_path>/prs/.empty-template.git`; do not optimise until /// `<git_data_path>/prs/.empty-template.git`; do not optimise until
/// measurable. /// measurable.
fn init_empty_bare_repo() -> Result<TempDir, GitError> { fn init_empty_bare_repo(
storage: &LocalGitStorage,
family_key: &FamilyKey,
) -> Result<TempDir, GitError> {
let temp = TempDir::new().map_err(GitError::IoError)?; let temp = TempDir::new().map_err(GitError::IoError)?;
let path = temp.path(); let path = temp.path();
let output = Command::new("git") let output = Command::new("git")
@@ -194,5 +203,10 @@ fn init_empty_bare_repo() -> Result<TempDir, GitError> {
); );
return Err(GitError::GitFailed(output.status.code())); return Err(GitError::GitFailed(output.status.code()));
} }
if storage.family_exists(family_key) {
storage
.configure_thin_view(family_key, path)
.map_err(|error| GitError::Storage(error.to_string()))?;
}
Ok(temp) Ok(temp)
} }
+73 -44
View File
@@ -19,19 +19,21 @@
//! reject the whole push with an `ERR` pkt-line — matching the //! reject the whole push with an `ERR` pkt-line — matching the
//! standard-endpoint UX. Nothing on disk has been touched yet so failed //! standard-endpoint UX. Nothing on disk has been touched yet so failed
//! probes leave no state. //! probes leave no state.
//! 3. Acquires the per-path coordination state from [`RepoInitLocks`] //! 3. Acquires the identifier-family write lease and the per-path coordination
//! briefly: under its mutex it runs the on-demand `git init --bare` //! state from [`RepoInitLocks`] briefly: under the path mutex it creates an
//! on-demand thin view
//! and increments the `in_flight` counter, then releases the mutex. //! and increments the `in_flight` counter, then releases the mutex.
//! Steps 4 and 5 run *without* the per-path lock so concurrent pushes //! Steps 4 and 5 run *without* the per-path lock. The family write lease
//! to the same `(submitter, identifier)` proceed in parallel — git's //! serializes object-producing operations for the identifier, while the
//! own ref locking handles intra-push concurrency, and the //! `in_flight` counter is what off-push cleanup paths consult to know a push
//! `in_flight` counter is what off-push cleanup paths consult to know //! is active.
//! a push is active. //! 4. Starts `git-receive-pack` with the family as its writable object database,
//! 4. Starts `git-receive-pack`, writes the full request body to the child, and //! writes the full request body to the child, and
//! immediately returns an HTTP response whose body is backed by a bounded //! immediately returns an HTTP response whose body is backed by a bounded
//! channel. From this point on Hyper can stream stdout to the client while a //! channel. From this point on Hyper can stream stdout to the client while a
//! detached task owns the subprocess, stderr, and all post-push work. //! detached task owns the subprocess, stderr, and all post-push work.
//! 5. In the detached task, each stdout chunk is forwarded as it is read. If Git //! 5. In the detached task, progress is forwarded while the terminal flush is
//! retained until family roots and GRASP post-processing are complete. If Git
//! exits with a protocol-level error before writing stdout, the task sends a //! exits with a protocol-level error before writing stdout, the task sends a
//! Git `ERR` pkt-line through the same stream so clients still see a normal //! Git `ERR` pkt-line through the same stream so clients still see a normal
//! receive-pack failure. If stdout has already been sent, the task cannot //! receive-pack failure. If stdout has already been sent, the task cannot
@@ -76,10 +78,12 @@ use crate::git::authorization::{
}; };
use crate::git::handlers::{ use crate::git::handlers::{
build_git_protocol_error_response, err_pktline_frame, is_git_protocol_error, build_git_protocol_error_response, err_pktline_frame, is_git_protocol_error,
pump_stdout_to_channel, read_stderr_to_end, record_git_operation, streaming_response, GitError, pump_receive_pack_stdout_to_channel, read_stderr_to_end, record_git_operation,
PumpResult, STREAM_CHANNEL_DEPTH, retain_accepted_tips, send_body_bytes, streaming_response, GitError, PumpResult,
STREAM_CHANNEL_DEPTH,
}; };
use crate::git::protocol::GitService; use crate::git::protocol::GitService;
use crate::git::storage::{FamilyKey, FamilyWriteLease, LocalGitStorage};
use crate::git::subprocess::GitSubprocess; use crate::git::subprocess::GitSubprocess;
use crate::git::sync::process_newly_available_git_data; use crate::git::sync::process_newly_available_git_data;
use crate::git::{delete_ref, list_refs, GitResponseBody}; use crate::git::{delete_ref, list_refs, GitResponseBody};
@@ -107,9 +111,9 @@ use crate::sync::rejected_index::RejectedEventsIndex;
/// expiry) for the duration of one `delete_ref` + optional /// expiry) for the duration of one `delete_ref` + optional
/// `remove_dir_all`. /// `remove_dir_all`.
/// ///
/// `git-receive-pack` itself and per-ref validation run *without* the /// `git-receive-pack` itself and per-ref validation run *without* the path
/// mutex held, so two pushes to the same path proceed in parallel; git's /// mutex held. A separate identifier-family lease serializes their shared
/// own ref locking handles intra-push concurrency. /// object inventory.
/// ///
/// Off-push cleanup paths only `rm -rf` the bare repo when both /// Off-push cleanup paths only `rm -rf` the bare repo when both
/// `in_flight.load() == 0` *and* `list_refs` returns empty while they /// `in_flight.load() == 0` *and* `list_refs` returns empty while they
@@ -258,20 +262,27 @@ pub async fn handle_prs_receive_pack(
// 3. Acquire the per-path coordination state and, under its mutex, // 3. Acquire the per-path coordination state and, under its mutex,
// initialise the bare repo on demand and register this request as // initialise the bare repo on demand and register this request as
// in-flight. The mutex is then released — `git-receive-pack` and // in-flight. The mutex is then released — `git-receive-pack` and
// per-ref validation run WITHOUT the lock so concurrent pushes to // per-ref validation run WITHOUT the path lock. The family lease above
// the same `(submitter, identifier)` proceed in parallel. Cleanup // serializes object-producing work for the identifier. Cleanup paths
// paths consult `in_flight` (under the same mutex) before // consult `in_flight` (under the same mutex) before
// deleting the bare repo, so a repo can never vanish mid-receive. // deleting the bare repo, so a repo can never vanish mid-receive.
let repo_path = prs_repo_path( let repo_path = prs_repo_path(
Path::new(git_data_path), Path::new(git_data_path),
&prs.submitter.to_hex(), &prs.submitter.to_hex(),
&prs.identifier, &prs.identifier,
); );
let storage = LocalGitStorage::new(git_data_path);
let family_key =
FamilyKey::sha1(&prs.identifier).map_err(|e| GitError::Storage(e.to_string()))?;
let family_lease = storage
.write_lease(&family_key)
.await
.map_err(|e| GitError::Storage(e.to_string()))?;
let state = path_state(&repo_init_locks, &repo_path); let state = path_state(&repo_init_locks, &repo_path);
{ {
let _g = state.mu.lock().expect("prs path mutex poisoned"); let _g = state.mu.lock().expect("prs path mutex poisoned");
if let Err(e) = ensure_repo_initialised(&repo_path) { if let Err(e) = ensure_repo_initialised(&storage, &family_key, &repo_path) {
error!( error!(
"/prs/ receive-pack: failed to initialise repo at {}: {}", "/prs/ receive-pack: failed to initialise repo at {}: {}",
repo_path.display(), repo_path.display(),
@@ -289,8 +300,14 @@ pub async fn handle_prs_receive_pack(
// to the same streaming shape as the standard receive-pack endpoint: // to the same streaming shape as the standard receive-pack endpoint:
// write stdin here, then hand stdout/stderr and all follow-up state to a // write stdin here, then hand stdout/stderr and all follow-up state to a
// detached task that feeds the response body channel. // detached task that feeds the response body channel.
let mut git = let use_family = storage.is_thin_view(&family_key, &repo_path);
match GitSubprocess::spawn(GitService::ReceivePack, &repo_path, false, git_protocol) let mut git = match GitSubprocess::spawn_with_object_directory(
GitService::ReceivePack,
&repo_path,
false,
git_protocol,
use_family.then_some(&family_lease.family_objects_path),
)
.map_err(GitError::ProcessSpawnFailed) .map_err(GitError::ProcessSpawnFailed)
{ {
Ok(git) => git, Ok(git) => git,
@@ -350,6 +367,9 @@ pub async fn handle_prs_receive_pack(
prs.identifier.clone(), prs.identifier.clone(),
domain.to_string(), domain.to_string(),
metrics, metrics,
storage,
family_key,
use_family.then_some(family_lease),
)); ));
Ok(streaming_response(GitService::ReceivePack, rx)) Ok(streaming_response(GitService::ReceivePack, rx))
@@ -389,30 +409,18 @@ fn invalid_ref_reason(ref_name: &str) -> Option<String> {
/// ///
/// The caller must hold the per-path mutex from [`PrsPathState`] for /// The caller must hold the per-path mutex from [`PrsPathState`] for
/// `repo_path` before invoking this function. /// `repo_path` before invoking this function.
fn ensure_repo_initialised(repo_path: &Path) -> Result<(), GitError> { fn ensure_repo_initialised(
storage: &LocalGitStorage,
family_key: &FamilyKey,
repo_path: &Path,
) -> Result<(), GitError> {
if repo_path.exists() { if repo_path.exists() {
return Ok(()); return Ok(());
} }
if let Some(parent) = repo_path.parent() { storage
std::fs::create_dir_all(parent).map_err(GitError::IoError)?; .create_thin_view(family_key, repo_path)
} .map_err(|error| GitError::Storage(error.to_string()))?;
let output = std::process::Command::new("git")
.args(["init", "--bare", "--initial-branch=main", "--quiet"])
.arg(repo_path)
.output()
.map_err(GitError::ProcessSpawnFailed)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
error!(
"/prs/ git init --bare failed at {}: {}",
repo_path.display(),
stderr.trim()
);
return Err(GitError::GitFailed(output.status.code()));
}
info!( info!(
"/prs/ initialised bare repo at {} on demand", "/prs/ initialised bare repo at {} on demand",
@@ -482,6 +490,9 @@ async fn stream_prs_receive_pack_output<S, E>(
identifier: String, identifier: String,
domain: String, domain: String,
metrics: Option<Arc<Metrics>>, metrics: Option<Arc<Metrics>>,
storage: LocalGitStorage,
family_key: FamilyKey,
family_lease: Option<FamilyWriteLease>,
) where ) where
S: tokio::io::AsyncRead + Unpin + Send + 'static, S: tokio::io::AsyncRead + Unpin + Send + 'static,
E: tokio::io::AsyncRead + Unpin + Send + 'static, E: tokio::io::AsyncRead + Unpin + Send + 'static,
@@ -490,7 +501,7 @@ async fn stream_prs_receive_pack_output<S, E>(
// fill its stderr pipe while still producing stdout; draining both prevents // fill its stderr pipe while still producing stdout; draining both prevents
// child-process deadlock and preserves stderr for protocol-error reporting. // child-process deadlock and preserves stderr for protocol-error reporting.
let stderr_task = stderr.map(|stderr| tokio::spawn(read_stderr_to_end(stderr))); let stderr_task = stderr.map(|stderr| tokio::spawn(read_stderr_to_end(stderr)));
let pump_result = pump_stdout_to_channel(stdout, &tx).await; let (pump_result, terminal_flush) = pump_receive_pack_stdout_to_channel(stdout, &tx).await;
// If the client goes away or stdout read fails, stop Git rather than letting // If the client goes away or stdout read fails, stop Git rather than letting
// it continue writing into a response nobody can receive. Cleanup below will // it continue writing into a response nobody can receive. Cleanup below will
@@ -514,7 +525,7 @@ async fn stream_prs_receive_pack_output<S, E>(
None => Vec::new(), None => Vec::new(),
}; };
let sent_stdout = match pump_result { let mut sent_stdout = match pump_result {
PumpResult::Eof { sent_stdout } => sent_stdout, PumpResult::Eof { sent_stdout } => sent_stdout,
PumpResult::ClientDisconnected | PumpResult::ReadError => { PumpResult::ClientDisconnected | PumpResult::ReadError => {
finish_prs_receive_pack(&state, &repo_path); finish_prs_receive_pack(&state, &repo_path);
@@ -524,6 +535,14 @@ async fn stream_prs_receive_pack_output<S, E>(
}; };
if !status.success() { if !status.success() {
if let Some(flush) = terminal_flush {
sent_stdout = true;
if send_body_bytes(&tx, flush).await.is_err() {
finish_prs_receive_pack(&state, &repo_path);
record_git_operation(&metrics, "push", "error");
return;
}
}
record_git_operation(&metrics, "push", "error"); record_git_operation(&metrics, "push", "error");
let stderr_str = String::from_utf8_lossy(&stderr_output); let stderr_str = String::from_utf8_lossy(&stderr_output);
if is_git_protocol_error(status.code(), &stderr_output) { if is_git_protocol_error(status.code(), &stderr_output) {
@@ -568,8 +587,6 @@ async fn stream_prs_receive_pack_output<S, E>(
} }
debug!("/prs/ git-receive-pack stream completed successfully"); debug!("/prs/ git-receive-pack stream completed successfully");
record_git_operation(&metrics, "push", "success");
// Race safety net. The pre-validation in `handle_prs_receive_pack` was // Race safety net. The pre-validation in `handle_prs_receive_pack` was
// performed before `git-receive-pack` ran, so an event with one of the // performed before `git-receive-pack` ran, so an event with one of the
// pushed ids may have arrived via WebSocket during the receive-pack window. // pushed ids may have arrived via WebSocket during the receive-pack window.
@@ -595,6 +612,10 @@ async fn stream_prs_receive_pack_output<S, E>(
.await; .await;
} }
if family_lease.is_some() {
retain_accepted_tips(&storage, &family_key, &repo_path, &pushed_refs);
}
finish_prs_receive_pack(&state, &repo_path); finish_prs_receive_pack(&state, &repo_path);
// Drive the standard purgatory-release pipeline so PR events already // Drive the standard purgatory-release pipeline so PR events already
@@ -633,6 +654,14 @@ async fn stream_prs_receive_pack_output<S, E>(
); );
} }
} }
if let Some(flush) = terminal_flush {
if send_body_bytes(&tx, flush).await.is_err() {
record_git_operation(&metrics, "push", "error");
return;
}
}
record_git_operation(&metrics, "push", "success");
} }
/// Race safety net for the `/prs/` receive-pack post-push phase. /// Race safety net for the `/prs/` receive-pack post-push phase.
+7 -19
View File
@@ -411,26 +411,14 @@ impl AnnouncementPolicy {
return Ok(()); return Ok(());
} }
// Create parent directory (npub directory) let storage = crate::git::storage::LocalGitStorage::new(&self.ctx.git_data_path);
let parent = repo_path let key = crate::git::storage::FamilyKey::sha1(&announcement.identifier)
.parent() .map_err(|error| error.to_string())?;
.ok_or_else(|| format!("Invalid repository path: {}", repo_path.display()))?; storage
.create_thin_view(&key, &repo_path)
.map_err(|error| error.to_string())?;
std::fs::create_dir_all(parent) tracing::info!("Created thin repository view at {}", repo_path.display());
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
// Initialize bare repository using git command
let output = std::process::Command::new("git")
.args(["init", "--bare", repo_path.to_str().unwrap()])
.output()
.map_err(|e| format!("Failed to execute git init: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git init failed: {}", stderr));
}
tracing::info!("Created bare repository at {}", repo_path.display());
Ok(()) Ok(())
} }
+46
View File
@@ -229,6 +229,7 @@ use tokio::io::{AsyncRead, AsyncReadExt};
use tokio::process::Command; use tokio::process::Command;
use tracing::debug; use tracing::debug;
use crate::git::storage::{FamilyKey, LocalGitStorage};
use crate::nostr::builder::Nip34WritePolicy; use crate::nostr::builder::Nip34WritePolicy;
use crate::nostr::events::RepositoryState; use crate::nostr::events::RepositoryState;
use crate::nostr::SharedDatabase; use crate::nostr::SharedDatabase;
@@ -436,6 +437,7 @@ fn hardened_git_command(
repo_path: &Path, repo_path: &Path,
resolve_pin: Option<&str>, resolve_pin: Option<&str>,
auth_header: Option<&str>, auth_header: Option<&str>,
object_directory: Option<&Path>,
args: &[String], args: &[String],
) -> Command { ) -> Command {
let mut command = Command::new("git"); let mut command = Command::new("git");
@@ -469,6 +471,9 @@ fn hardened_git_command(
.stderr(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped())
.kill_on_drop(true) .kill_on_drop(true)
.current_dir(repo_path); .current_dir(repo_path);
if let Some(object_directory) = object_directory {
command.env("GIT_OBJECT_DIRECTORY", object_directory);
}
command command
} }
@@ -974,6 +979,23 @@ impl SyncContext for RealSyncContext {
}; };
let resolve_pin = resolve_pin_entry(&resolved); let resolve_pin = resolve_pin_entry(&resolved);
let storage = LocalGitStorage::new(&self.git_data_path);
let family_key =
crate::git::sync::extract_identifier_from_repo_path(repo_path, &self.git_data_path)
.and_then(|identifier| FamilyKey::sha1(identifier).ok());
let family_lease = match family_key.as_ref() {
Some(key) if storage.is_thin_view(key, repo_path) => Some(
storage
.write_lease(key)
.await
.map_err(|error| anyhow::anyhow!("open family for git fetch: {error}"))?,
),
_ => None,
};
let family_objects = family_lease
.as_ref()
.map(|lease| lease.family_objects_path.as_path());
let repo_path = repo_path.to_path_buf(); let repo_path = repo_path.to_path_buf();
let url = url.to_string(); let url = url.to_string();
let domain = extract_domain(&url).unwrap_or_else(|| "unknown".to_string()); let domain = extract_domain(&url).unwrap_or_else(|| "unknown".to_string());
@@ -1028,6 +1050,7 @@ impl SyncContext for RealSyncContext {
&repo_path, &repo_path,
resolve_pin.as_deref(), resolve_pin.as_deref(),
fresh_auth_header().as_deref(), fresh_auth_header().as_deref(),
None,
&ls_remote_args, &ls_remote_args,
), ),
&domain, &domain,
@@ -1098,6 +1121,7 @@ impl SyncContext for RealSyncContext {
&repo_path, &repo_path,
resolve_pin.as_deref(), resolve_pin.as_deref(),
fresh_auth_header().as_deref(), fresh_auth_header().as_deref(),
family_objects,
&args, &args,
), ),
&domain, &domain,
@@ -1168,6 +1192,7 @@ impl SyncContext for RealSyncContext {
&repo_path, &repo_path,
resolve_pin.as_deref(), resolve_pin.as_deref(),
fresh_auth_header().as_deref(), fresh_auth_header().as_deref(),
family_objects,
&args, &args,
), ),
&domain, &domain,
@@ -1219,6 +1244,27 @@ impl SyncContext for RealSyncContext {
.cloned() .cloned()
.collect(); .collect();
if let Some(key) = family_key.as_ref().filter(|_| family_lease.is_some()) {
for oid in &fetched {
if let Err(error) = storage.retain_tip(key, "purgatory-fetch", oid) {
tracing::warn!(
identifier = %key.identifier,
%oid,
%error,
"Failed to retain proactively fetched family tip"
);
}
if let Err(error) = storage.advertise_base_tip(key, "purgatory-fetch", oid) {
tracing::warn!(
identifier = %key.identifier,
%oid,
%error,
"Failed to advertise proactively fetched family tip"
);
}
}
}
// One summary line per outbound fetch pass: this is the // One summary line per outbound fetch pass: this is the
// client-side visibility the drop-one-and-retry loop lacked // client-side visibility the drop-one-and-retry loop lacked
// (it logged only at debug level, so a relay crawling a remote // (it logged only at debug level, so a relay crawling a remote
+30 -5
View File
@@ -300,7 +300,7 @@ async fn prs_receive_pack_streams_stdout_before_cleanup_removes_empty_repo() {
let _env_lock = PATH_ENV_LOCK.lock().await; let _env_lock = PATH_ENV_LOCK.lock().await;
let fake_bin = tempfile::tempdir().expect("fake git bin tempdir"); let fake_bin = tempfile::tempdir().expect("fake git bin tempdir");
write_fake_git(fake_bin.path()); write_fake_git_with_terminal_flush(fake_bin.path());
let _path = PathOverride::prepend(fake_bin.path()); let _path = PathOverride::prepend(fake_bin.path());
let keys = Keys::generate(); let keys = Keys::generate();
@@ -355,7 +355,11 @@ async fn prs_receive_pack_streams_stdout_before_cleanup_removes_empty_repo() {
.expect("first /prs/ stdout chunk should arrive before fake git exits") .expect("first /prs/ stdout chunk should arrive before fake git exits")
.expect("/prs/ body should still be open") .expect("/prs/ body should still be open")
.expect("first /prs/ frame should not be an HTTP body error"); .expect("first /prs/ frame should not be an HTTP body error");
assert_eq!(frame_data(first), Bytes::from_static(b"first-progress\n")); let mut streamed = frame_data(first).to_vec();
assert!(
b"first-progress\n".starts_with(&streamed),
"the four-byte terminal look-behind may split the first progress chunk"
);
assert!( assert!(
repo_path.exists(), repo_path.exists(),
@@ -374,7 +378,15 @@ async fn prs_receive_pack_streams_stdout_before_cleanup_removes_empty_repo() {
.expect("second /prs/ stdout chunk should arrive after fake git wakes") .expect("second /prs/ stdout chunk should arrive after fake git wakes")
.expect("/prs/ body should still be open for second chunk") .expect("/prs/ body should still be open for second chunk")
.expect("second /prs/ frame should not be an HTTP body error"); .expect("second /prs/ frame should not be an HTTP body error");
assert_eq!(frame_data(second), Bytes::from_static(b"second-progress\n")); streamed.extend_from_slice(&frame_data(second));
let terminal = timeout(Duration::from_secs(1), body.frame())
.await
.expect("/prs/ terminal flush should arrive after cleanup")
.expect("/prs/ body should contain the terminal flush")
.expect("/prs/ terminal frame should not be an HTTP body error");
streamed.extend_from_slice(&frame_data(terminal));
assert_eq!(streamed, b"first-progress\nsecond-progress\n0000");
let eof = timeout(Duration::from_secs(1), body.frame()) let eof = timeout(Duration::from_secs(1), body.frame())
.await .await
@@ -474,15 +486,28 @@ fi
if [ "$1" = "init" ]; then if [ "$1" = "init" ]; then
repo="${@: -1}" repo="${@: -1}"
mkdir -p "$repo" mkdir -p "$repo/objects/info"
exit 0 exit 0
fi fi
if [ "$1" = "config" ]; then
exit 0
fi
if [ "$1" = "rev-parse" ] && [ "${2:-}" = "--show-object-format" ]; then
printf 'sha1\n'
exit 0
fi
if [ "$1" = "cat-file" ]; then
exit 1
fi
if [ "$1" = "for-each-ref" ]; then if [ "$1" = "for-each-ref" ]; then
exit 0 exit 0
fi fi
echo "fake git only supports init, for-each-ref, and receive-pack" >&2 echo "unsupported fake git invocation: $*" >&2
exit 1 exit 1
"# "#
.replace("__TERMINAL_FLUSH__", terminal_flush), .replace("__TERMINAL_FLUSH__", terminal_flush),