Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
589 lines
22 KiB
Rust
589 lines
22 KiB
Rust
//! Shared helpers for `gix` status scans.
|
|
//!
|
|
//! `gix-features` `in_parallel` does `spawn_scoped(...).expect("valid name")`.
|
|
//! Under `panic=abort` and a tight `RLIMIT_NPROC`, a failed spawn aborts the
|
|
//! whole process instead of becoming a recoverable `JoinError`. Cap
|
|
//! `index_worktree_options.thread_limit` so produce workers stay within
|
|
//! headroom. `Some(0)` means unlimited in gix — never pass 0.
|
|
|
|
/// Past 8 produce workers a status scan gains no speed, only spawn pressure.
|
|
const HARD_CAP: usize = 8;
|
|
/// Reserve for non-gix threads; nproc tests use `used + OUTER_RESERVE - 2`.
|
|
pub(crate) const OUTER_RESERVE: usize = 8;
|
|
|
|
const ENV_THREADS: &str = "KIGI_GIX_STATUS_THREADS";
|
|
|
|
/// Pure produce-worker budget. Always `n >= 1`. Caps at 8; shrinks under tight
|
|
/// soft nproc headroom (`headroom < 2` → 1).
|
|
pub fn compute_gix_status_thread_limit_from(
|
|
cores: usize,
|
|
soft_nproc: Option<usize>,
|
|
threads_used: usize,
|
|
) -> usize {
|
|
let cores = cores.max(1);
|
|
let mut limit = cores.min(HARD_CAP);
|
|
if let Some(soft) = soft_nproc {
|
|
let headroom = soft
|
|
.saturating_sub(threads_used)
|
|
.saturating_sub(OUTER_RESERVE);
|
|
if headroom < 2 {
|
|
limit = 1;
|
|
} else {
|
|
limit = limit.min(headroom);
|
|
}
|
|
}
|
|
limit.max(1)
|
|
}
|
|
|
|
/// Production budget (`n >= 1`). Honours `KIGI_GIX_STATUS_THREADS=N` for `N >= 1`
|
|
/// (forced dial; bypasses nproc). Else cores + soft nproc + thread usage.
|
|
pub fn compute_gix_status_thread_limit() -> usize {
|
|
if let Ok(raw) = std::env::var(ENV_THREADS)
|
|
&& let Some(n) = parse_env_thread_override(&raw)
|
|
{
|
|
return n;
|
|
}
|
|
let cores = std::thread::available_parallelism()
|
|
.map(usize::from)
|
|
.unwrap_or(1);
|
|
compute_gix_status_thread_limit_from(cores, soft_nproc_limit(), threads_used())
|
|
}
|
|
|
|
/// `N >= 1` only; reject `0` and garbage.
|
|
fn parse_env_thread_override(raw: &str) -> Option<usize> {
|
|
raw.parse::<usize>().ok().filter(|&n| n >= 1)
|
|
}
|
|
|
|
/// Test helper: `None` = uncapped, `Some(n)` with `n >= 1`. Never `Some(0)`.
|
|
fn apply_thread_limit<'repo, P>(
|
|
platform: gix::status::Platform<'repo, P>,
|
|
limit: Option<usize>,
|
|
) -> gix::status::Platform<'repo, P>
|
|
where
|
|
P: gix::Progress + 'static,
|
|
{
|
|
debug_assert!(
|
|
!matches!(limit, Some(0)),
|
|
"Some(0) is unlimited in gix — never pass 0"
|
|
);
|
|
platform.index_worktree_options_mut(|opts| {
|
|
opts.thread_limit = limit;
|
|
})
|
|
}
|
|
|
|
/// Apply [`compute_gix_status_thread_limit`] as `Some(n)` on the status platform.
|
|
pub fn with_budgeted_thread_limit<'repo, P>(
|
|
platform: gix::status::Platform<'repo, P>,
|
|
) -> gix::status::Platform<'repo, P>
|
|
where
|
|
P: gix::Progress + 'static,
|
|
{
|
|
apply_thread_limit(platform, Some(compute_gix_status_thread_limit()))
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn soft_nproc_limit() -> Option<usize> {
|
|
let mut lim = libc::rlimit {
|
|
rlim_cur: 0,
|
|
rlim_max: 0,
|
|
};
|
|
// SAFETY: getrlimit writes only into local `lim`.
|
|
if unsafe { libc::getrlimit(libc::RLIMIT_NPROC, &mut lim) } != 0 {
|
|
return None;
|
|
}
|
|
if lim.rlim_cur == libc::RLIM_INFINITY {
|
|
return None;
|
|
}
|
|
Some(
|
|
lim.rlim_cur
|
|
.min(usize::MAX as libc::rlim_t)
|
|
.try_into()
|
|
.unwrap_or(usize::MAX),
|
|
)
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn soft_nproc_limit() -> Option<usize> {
|
|
None
|
|
}
|
|
|
|
fn threads_used() -> usize {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
let status = std::fs::read_to_string("/proc/self/status").unwrap_or_default();
|
|
status
|
|
.lines()
|
|
.find_map(|line| {
|
|
line.strip_prefix("Threads:")
|
|
.and_then(|rest| rest.trim().parse().ok())
|
|
})
|
|
.unwrap_or(1)
|
|
}
|
|
#[cfg(not(target_os = "linux"))]
|
|
{
|
|
1
|
|
}
|
|
}
|
|
|
|
/// Test scan: `Ok(true)` iff a dirty entry ending in `want_suffix` is seen.
|
|
/// Errors carry the gix `Debug` form so a `SpawnThread` failure stays
|
|
/// distinguishable from a genuinely missed dirty file.
|
|
#[cfg(test)]
|
|
fn status_finds_suffix(
|
|
repo_path: &std::path::Path,
|
|
thread_limit: Option<usize>,
|
|
want_suffix: &str,
|
|
) -> Result<bool, String> {
|
|
use gix::bstr::BString;
|
|
|
|
let repo = gix::discover(repo_path).map_err(|e| format!("discover: {e:?}"))?;
|
|
let index_path = repo.git_dir().join("index");
|
|
if index_path.metadata().map_or(true, |m| m.len() == 0) {
|
|
return Err("missing or empty index".into());
|
|
}
|
|
let status = repo
|
|
.status(gix::progress::Discard)
|
|
.map_err(|e| format!("status platform: {e:?}"))?;
|
|
let status = apply_thread_limit(status, thread_limit);
|
|
let iter = status
|
|
.into_index_worktree_iter(Vec::<BString>::new())
|
|
.map_err(|e| format!("into_index_worktree_iter: {e:?}"))?;
|
|
for item in iter {
|
|
let item = item.map_err(|e| format!("status item: {e:?}"))?;
|
|
let path = match &item {
|
|
gix::status::index_worktree::Item::Modification { rela_path, .. } => {
|
|
rela_path.to_string()
|
|
}
|
|
gix::status::index_worktree::Item::DirectoryContents { entry, .. } => {
|
|
entry.rela_path.to_string()
|
|
}
|
|
gix::status::index_worktree::Item::Rewrite { dirwalk_entry, .. } => {
|
|
dirwalk_entry.rela_path.to_string()
|
|
}
|
|
};
|
|
if std::path::Path::new(&path).ends_with(want_suffix) {
|
|
return Ok(true);
|
|
}
|
|
}
|
|
Ok(false)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use kigi_test_utils::git::run_git;
|
|
use std::path::PathBuf;
|
|
|
|
#[test]
|
|
fn compute_from_table() {
|
|
// (cores, soft_nproc, used, expected)
|
|
let cases: &[(usize, Option<usize>, usize, usize)] = &[
|
|
(16, None, 1, 8),
|
|
(4, None, 1, 4),
|
|
(1, None, 1, 1),
|
|
(0, None, 1, 1),
|
|
// headroom = soft - used - OUTER_RESERVE
|
|
(16, Some(100), 1, 8),
|
|
(16, Some(20), 10, 2),
|
|
(16, Some(19), 10, 1),
|
|
(16, Some(18), 10, 1),
|
|
(16, Some(10), 10, 1),
|
|
(16, Some(8), 0, 1),
|
|
(16, Some(9), 0, 1),
|
|
(16, Some(10), 0, 2),
|
|
(32, Some(25), 5, 8),
|
|
(3, Some(25), 5, 3),
|
|
];
|
|
for &(cores, soft, used, want) in cases {
|
|
let got = compute_gix_status_thread_limit_from(cores, soft, used);
|
|
assert_eq!(got, want, "cores={cores} soft={soft:?} used={used}");
|
|
assert!(got >= 1);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_env_thread_override_table() {
|
|
let cases: &[(&str, Option<usize>)] = &[
|
|
("", None),
|
|
("0", None),
|
|
("00", None),
|
|
("1", Some(1)),
|
|
("8", Some(8)),
|
|
("16", Some(16)),
|
|
("abc", None),
|
|
("-1", None),
|
|
("1.5", None),
|
|
(" 1", None),
|
|
("1 ", None),
|
|
];
|
|
for &(raw, want) in cases {
|
|
assert_eq!(parse_env_thread_override(raw), want, "raw={raw:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn production_compute_always_ge_one() {
|
|
assert!(compute_gix_status_thread_limit() >= 1);
|
|
}
|
|
|
|
pub(super) fn temp_repo_with_dirty_file() -> (tempfile::TempDir, PathBuf) {
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
|
let root = tmp.path().to_path_buf();
|
|
run_git(&root, &["init"]);
|
|
// Sequential index decode: with threads gix-index hard-expects an
|
|
// extension-load spawn, which would skew the nproc children's slot budget.
|
|
run_git(&root, &["config", "index.threads", "1"]);
|
|
// 64 entries keeps gix's computed status worker count >= 2 on any
|
|
// 2+ core host, so the uncapped child reaches the in_parallel workers.
|
|
for i in 0..64 {
|
|
let rel = format!("f{i:02}.txt");
|
|
std::fs::write(root.join(&rel), format!("base {i}\n")).unwrap();
|
|
}
|
|
run_git(&root, &["add", "."]);
|
|
run_git(&root, &["commit", "-m", "seed"]);
|
|
std::fs::write(root.join("f00.txt"), "dirty\n").unwrap();
|
|
(tmp, root)
|
|
}
|
|
|
|
#[test]
|
|
fn serial_status_finds_dirty() {
|
|
let (_tmp, root) = temp_repo_with_dirty_file();
|
|
assert!(
|
|
status_finds_suffix(&root, Some(1), "f00.txt").expect("serial scan"),
|
|
"serial status should find dirty f00.txt"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn uncapped_status_finds_dirty_on_healthy_host() {
|
|
let (_tmp, root) = temp_repo_with_dirty_file();
|
|
assert!(
|
|
status_finds_suffix(&root, None, "f00.txt").expect("uncapped scan"),
|
|
"uncapped status should find dirty f00.txt on a healthy host"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Unix subprocess tests that lower `RLIMIT_NPROC` in a **child** process.
|
|
/// Parent never touches rlimits (would flake parallel cargo test).
|
|
/// Linux is the fidelity target; abort assert is Linux-only.
|
|
#[cfg(all(test, unix))]
|
|
mod nproc_tests {
|
|
use super::tests::temp_repo_with_dirty_file;
|
|
use super::*;
|
|
use std::os::unix::process::ExitStatusExt;
|
|
use std::path::Path;
|
|
use std::process::{Command, Stdio};
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
const CHILD_ENV: &str = "XAI_GIX_STATUS_NPROC_CHILD";
|
|
const REPO_ENV: &str = "XAI_GIX_STATUS_NPROC_REPO";
|
|
|
|
/// Child exit protocol; 0 means the scan survived and saw the dirty file.
|
|
const EXIT_SKIP: i32 = 2;
|
|
const EXIT_BAD_MODE: i32 = 3;
|
|
const EXIT_MISSED_DIRTY: i32 = 4;
|
|
const EXIT_SCAN_ERROR: i32 = 5;
|
|
/// Child stderr markers the parent matches on.
|
|
const SKIP_MARK: &str = "skip-child:";
|
|
const NPROC_HIT_MARK: &str = "nproc-hit:";
|
|
const SCAN_ERROR_MARK: &str = "scan-error:";
|
|
/// Ceiling headroom above the child's own threads; must exceed
|
|
/// `SCAFFOLD_SLOTS` so the fill proves enforcement before refunding.
|
|
const HOLDER_SLACK: u64 = 32;
|
|
/// Fill-loop bound; filling this far past `HOLDER_SLACK` without EAGAIN
|
|
/// means the rlimit is not enforced (CAP_SYS_RESOURCE, macOS semantics).
|
|
const MAX_HOLDERS: usize = 512;
|
|
/// Soft `map_err(SpawnThread)` scaffolding spawns between the fill and the
|
|
/// first hard `expect("valid name")` spawn; see refund comments below.
|
|
/// Verified against vendored gix 0.77.0 / gix-status 0.24.0.
|
|
const SCAFFOLD_SLOTS: usize = 3;
|
|
|
|
/// Parked thread pinning one RLIMIT_NPROC slot until released.
|
|
struct Holder {
|
|
release: Arc<AtomicBool>,
|
|
handle: std::thread::JoinHandle<()>,
|
|
}
|
|
|
|
impl Holder {
|
|
fn spawn() -> std::io::Result<Holder> {
|
|
let release = Arc::new(AtomicBool::new(false));
|
|
let flag = Arc::clone(&release);
|
|
let handle = std::thread::Builder::new().spawn(move || {
|
|
// Spurious unparks are fine; only the flag ends the hold.
|
|
while !flag.load(Ordering::Acquire) {
|
|
std::thread::park();
|
|
}
|
|
})?;
|
|
Ok(Holder { release, handle })
|
|
}
|
|
|
|
/// Free this holder's slot; join guarantees the kernel task is gone.
|
|
fn release(self) {
|
|
self.release.store(true, Ordering::Release);
|
|
self.handle.thread().unpark();
|
|
let _ = self.handle.join();
|
|
}
|
|
}
|
|
|
|
fn set_nproc_limit(max_threads: u64) -> Result<(), String> {
|
|
let mut lim = libc::rlimit {
|
|
rlim_cur: max_threads,
|
|
rlim_max: max_threads,
|
|
};
|
|
// SAFETY: setrlimit only touches the local rlimit for this process.
|
|
let rc = unsafe { libc::setrlimit(libc::RLIMIT_NPROC, &lim) };
|
|
if rc != 0 {
|
|
return Err(std::io::Error::last_os_error().to_string());
|
|
}
|
|
// SAFETY: getrlimit writes into local lim only.
|
|
let rc = unsafe { libc::getrlimit(libc::RLIMIT_NPROC, &mut lim) };
|
|
if rc != 0 {
|
|
return Err(std::io::Error::last_os_error().to_string());
|
|
}
|
|
if lim.rlim_cur != max_threads {
|
|
return Err(format!(
|
|
"setrlimit soft={} want={max_threads}",
|
|
lim.rlim_cur
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Child entry: tighten nproc, run status, exit 0 if survived with dirty found.
|
|
fn run_child(mode: &str, repo: &Path) -> ! {
|
|
let cores = std::thread::available_parallelism()
|
|
.map(usize::from)
|
|
.unwrap_or(1);
|
|
if cores < 2 {
|
|
eprintln!("{SKIP_MARK} available_parallelism={cores} < 2");
|
|
std::process::exit(EXIT_SKIP);
|
|
}
|
|
|
|
let used = threads_used() as u64;
|
|
// Holders must stay alive through the status call.
|
|
let (thread_limit, _holders) = match mode {
|
|
// Fill to the nproc ceiling, refund only the scan's scaffolding,
|
|
// then run uncapped status so a produce-worker spawn hits the
|
|
// hard expect("valid name") — the regression RCA.
|
|
"uncapped" => {
|
|
let limit = used.saturating_add(HOLDER_SLACK);
|
|
if let Err(e) = set_nproc_limit(limit) {
|
|
eprintln!("{SKIP_MARK} setrlimit failed: {e}");
|
|
std::process::exit(EXIT_SKIP);
|
|
}
|
|
// Fill empirically: the kernel checks RLIMIT_NPROC against the
|
|
// real UID's TOTAL task count across all processes, so no
|
|
// arithmetic on this process's own thread count can find the
|
|
// ceiling (that assumption exit-0'd this test twice on CI).
|
|
let mut holders = Vec::new();
|
|
let mut hit_limit = false;
|
|
for _ in 0..MAX_HOLDERS {
|
|
match Holder::spawn() {
|
|
Ok(h) => holders.push(h),
|
|
Err(e) => {
|
|
eprintln!(
|
|
"{NPROC_HIT_MARK} holders={} threads={} limit={limit} err={e}",
|
|
holders.len(),
|
|
threads_used()
|
|
);
|
|
hit_limit = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if !hit_limit {
|
|
eprintln!(
|
|
"{SKIP_MARK} RLIMIT_NPROC not enforced (spawned {} holders under limit {limit})",
|
|
holders.len()
|
|
);
|
|
std::process::exit(EXIT_SKIP);
|
|
}
|
|
// Refund one slot per soft scaffolding spawn on the
|
|
// into_index_worktree_iter path so those succeed and the NEXT
|
|
// spawn to fail is `gitoxide.in_parallel.produce.N`, whose
|
|
// expect("valid name") panics on `gix_status::index_as_worktree`:
|
|
// slot 1 funds gix::status::index_worktree::producer (soft map_err(SpawnThread))
|
|
// slot 2 funds gix_status::dirwalk (soft map_err(SpawnThread))
|
|
// slot 3 funds gix_status::index_as_worktree (soft map_err(SpawnThread))
|
|
// Same-UID churn may eat refunds first; the parent then insists
|
|
// the resulting scan error is itself a spawn failure.
|
|
for _ in 0..SCAFFOLD_SLOTS {
|
|
if let Some(holder) = holders.pop() {
|
|
holder.release();
|
|
}
|
|
}
|
|
(None, holders)
|
|
}
|
|
// soft = used + OUTER_RESERVE - 2 ⇒ headroom < 2 ⇒ budgeted shrinks to 1.
|
|
"serial" | "budgeted" => {
|
|
let limit = used.saturating_add((OUTER_RESERVE as u64).saturating_sub(2));
|
|
if let Err(e) = set_nproc_limit(limit) {
|
|
eprintln!("{SKIP_MARK} setrlimit failed: {e}");
|
|
std::process::exit(EXIT_SKIP);
|
|
}
|
|
// Same-UID tasks may already exceed the new ceiling; probe one
|
|
// spawn and skip (not fail), mirroring the uncapped fill probe.
|
|
match std::thread::Builder::new().spawn(|| {}) {
|
|
Ok(probe) => {
|
|
let _ = probe.join();
|
|
}
|
|
Err(e) => {
|
|
eprintln!("{SKIP_MARK} no spawn headroom under limit {limit}: {e}");
|
|
std::process::exit(EXIT_SKIP);
|
|
}
|
|
}
|
|
let limit_opt = if mode == "serial" {
|
|
Some(1usize)
|
|
} else {
|
|
Some(compute_gix_status_thread_limit())
|
|
};
|
|
(limit_opt, Vec::new())
|
|
}
|
|
other => {
|
|
eprintln!("unknown child mode {other}");
|
|
std::process::exit(EXIT_BAD_MODE);
|
|
}
|
|
};
|
|
|
|
match status_finds_suffix(repo, thread_limit, "f00.txt") {
|
|
Ok(true) => std::process::exit(0),
|
|
Ok(false) => {
|
|
eprintln!("scan completed but missed the dirty file");
|
|
std::process::exit(EXIT_MISSED_DIRTY);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("{SCAN_ERROR_MARK} {e}");
|
|
std::process::exit(EXIT_SCAN_ERROR);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn spawn_child(mode: &str, repo: &Path) -> std::process::Output {
|
|
let exe = std::env::current_exe().expect("current_exe");
|
|
Command::new(&exe)
|
|
.env(CHILD_ENV, mode)
|
|
.env(REPO_ENV, repo)
|
|
// An inherited forced dial would bypass the budget under test.
|
|
.env_remove(ENV_THREADS)
|
|
.args(["--exact", "nproc_tests::child_entry", "--nocapture"])
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.output()
|
|
.expect("spawn child")
|
|
}
|
|
|
|
/// Parent-side boilerplate: guard re-entry, require 2+ cores, build the
|
|
/// repo, run the child, and turn child-side skips into parent-side skips.
|
|
fn spawn_child_or_skip(mode: &str) -> Option<std::process::Output> {
|
|
if std::env::var_os(CHILD_ENV).is_some() {
|
|
// Never fork further children from inside a child.
|
|
return None;
|
|
}
|
|
let cores = std::thread::available_parallelism()
|
|
.map(usize::from)
|
|
.unwrap_or(1);
|
|
if cores < 2 {
|
|
eprintln!("skip: available_parallelism={cores} < 2");
|
|
return None;
|
|
}
|
|
let (_tmp, root) = temp_repo_with_dirty_file();
|
|
let out = spawn_child(mode, &root);
|
|
if String::from_utf8_lossy(&out.stderr).contains(SKIP_MARK) {
|
|
eprintln!("skip: {}", String::from_utf8_lossy(&out.stderr));
|
|
return None;
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
#[test]
|
|
fn child_entry() {
|
|
let Some(mode) = std::env::var_os(CHILD_ENV) else {
|
|
return;
|
|
};
|
|
let mode = mode.to_string_lossy();
|
|
let repo = std::env::var_os(REPO_ENV).expect("repo env in child");
|
|
run_child(&mode, Path::new(&repo));
|
|
}
|
|
|
|
#[test]
|
|
fn uncapped_gix_status_aborts_under_tight_nproc() {
|
|
// Linux RLIMIT_NPROC counts threads (LWPs); macOS counts processes only.
|
|
if !cfg!(target_os = "linux") {
|
|
eprintln!("skip: abort repro is Linux-only (RLIMIT_NPROC semantics)");
|
|
return;
|
|
}
|
|
let Some(out) = spawn_child_or_skip("uncapped") else {
|
|
return;
|
|
};
|
|
let code = out.status.code();
|
|
let signal = out.status.signal();
|
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
// Precondition, separate from the outcome: the child must have proven
|
|
// the ceiling is enforced before it scanned.
|
|
assert!(
|
|
stderr.contains(NPROC_HIT_MARK),
|
|
"child never hit the nproc ceiling; \
|
|
code={code:?} signal={signal:?}\nstdout={stdout}\nstderr={stderr}"
|
|
);
|
|
assert_ne!(
|
|
code,
|
|
Some(0),
|
|
"uncapped status must not complete under tight nproc; \
|
|
code={code:?} signal={signal:?}\nstdout={stdout}\nstderr={stderr}"
|
|
);
|
|
// cargo builds test targets with panic=unwind (the workspace
|
|
// panic=abort applies to non-test profiles), so the expect("valid
|
|
// name") panic surfaces as stderr markers plus a nonzero exit; the
|
|
// SIGABRT arm covers abort-configured harnesses.
|
|
let aborted = signal == Some(libc::SIGABRT)
|
|
|| stderr.contains("gix_status::index_as_worktree")
|
|
|| stderr.contains("valid name");
|
|
// Refund race: same-UID processes may consume the refunded slots, so a
|
|
// soft scaffolding spawn fails first — accept only if the printed gix
|
|
// error is itself a thread-spawn failure.
|
|
let soft_spawn_failure = code == Some(EXIT_SCAN_ERROR)
|
|
&& stderr.contains(SCAN_ERROR_MARK)
|
|
&& (stderr.contains("SpawnThread") || stderr.contains("Failed to spawn"));
|
|
assert!(
|
|
aborted || soft_spawn_failure,
|
|
"expected the in_parallel produce-worker expect(\"valid name\") \
|
|
or a spawn-failure scan error after the refund race; \
|
|
code={code:?} signal={signal:?}\nstdout={stdout}\nstderr={stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn production_budgeted_gix_status_survives_tight_nproc() {
|
|
let Some(out) = spawn_child_or_skip("budgeted") else {
|
|
return;
|
|
};
|
|
assert!(
|
|
out.status.success(),
|
|
"budgeted status must survive tight nproc; code={:?} signal={:?}\nstdout={}\nstderr={}",
|
|
out.status.code(),
|
|
out.status.signal(),
|
|
String::from_utf8_lossy(&out.stdout),
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn serial_gix_status_survives_tight_nproc() {
|
|
let Some(out) = spawn_child_or_skip("serial") else {
|
|
return;
|
|
};
|
|
assert!(
|
|
out.status.success(),
|
|
"serial (thread_limit=1) status must survive tight nproc; \
|
|
code={:?} signal={:?}\nstdout={}\nstderr={}",
|
|
out.status.code(),
|
|
out.status.signal(),
|
|
String::from_utf8_lossy(&out.stdout),
|
|
String::from_utf8_lossy(&out.stderr),
|
|
);
|
|
}
|
|
}
|