M0: compilable skeleton — Kigi 0.1.0 fork surgery
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).
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
//! Shared subprocess plumbing: spawn a child, optionally feed it stdin, wait up
|
||||
//! to a wall-clock budget, and reap the whole process group on a breach.
|
||||
//!
|
||||
//! Used by both the optional [`crate::MmdcEngine`] (which shells out to
|
||||
//! `mmdc`/headless-Chromium) and the pager's out-of-process render child (a
|
||||
//! short-lived re-exec of the pager that renders one diagram in isolation). The
|
||||
//! timeout is a *real* process kill, not a soft signal: a panic under
|
||||
//! `panic = "abort"` or a runaway render in the child is contained because the
|
||||
//! parent kills and reaps it.
|
||||
//!
|
||||
//! The caller builds the [`Command`] (stdio, env, and the sanctioned
|
||||
//! TTY/console detach via `kigi_tty_utils::detach_std_command`); this module only
|
||||
//! owns the spawn → feed-stdin → wait → reap lifecycle so neither call site
|
||||
//! re-implements process-group teardown.
|
||||
|
||||
use std::process::{Child, Command};
|
||||
use std::time::Duration;
|
||||
|
||||
use wait_timeout::ChildExt;
|
||||
|
||||
/// Why a child subprocess run did not complete successfully.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum SubprocessError {
|
||||
/// The child could not be spawned (binary missing, fork failure, …).
|
||||
#[error("could not spawn child process: {0}")]
|
||||
Spawn(std::io::Error),
|
||||
/// The child exceeded its wall-clock budget and was killed and reaped.
|
||||
#[error("child process timed out")]
|
||||
Timeout,
|
||||
/// The child ran to completion but exited non-zero.
|
||||
#[error("child process exited with {0}")]
|
||||
NonZeroExit(std::process::ExitStatus),
|
||||
/// Waiting on the child itself failed; the child was reaped defensively.
|
||||
#[error("waiting on child process failed: {0}")]
|
||||
Wait(std::io::Error),
|
||||
}
|
||||
|
||||
/// Spawn `cmd`, optionally write `stdin_payload` to its stdin, wait up to
|
||||
/// `timeout`, and reap the process group on a breach.
|
||||
///
|
||||
/// The caller must have configured `cmd` (stdio, env, detach). To pass
|
||||
/// `stdin_payload`, the caller must set `cmd.stdin(Stdio::piped())`; the payload
|
||||
/// is written from a scoped thread so a full pipe buffer can never deadlock the
|
||||
/// wait. When `stdin_payload` is `None` (or stdin is not piped), no writer runs.
|
||||
///
|
||||
/// Returns `Ok(())` only on a zero-exit run; otherwise the matching
|
||||
/// [`SubprocessError`]. On timeout or a failed wait the child is killed and
|
||||
/// reaped: on Unix the whole process group is SIGKILLed, so grandchildren (e.g.
|
||||
/// an [`crate::MmdcEngine`]'s headless Chromium) are reaped too; on Windows only
|
||||
/// the direct child is killed — sufficient for the pager's render child (no
|
||||
/// grandchildren), but a Windows `MmdcEngine` could leak Chromium grandchildren
|
||||
/// (a Job Object is the follow-up there).
|
||||
pub fn run_with_timeout(
|
||||
mut cmd: Command,
|
||||
stdin_payload: Option<&[u8]>,
|
||||
timeout: Duration,
|
||||
) -> Result<(), SubprocessError> {
|
||||
let mut child = spawn_with_etxtbsy_retry(&mut cmd).map_err(SubprocessError::Spawn)?;
|
||||
|
||||
// Feed stdin from a scoped thread so a child that stops reading can't wedge
|
||||
// a `write_all` of a large (up to the source-size cap) payload and deadlock
|
||||
// the wait below. On timeout we kill the child, which EOF/EPIPEs the writer.
|
||||
let stdin = child.stdin.take();
|
||||
|
||||
// A payload with no piped stdin would be silently dropped (the caller forgot
|
||||
// `cmd.stdin(Stdio::piped())`). Both in-tree callers pipe correctly; flag the
|
||||
// foot-gun loudly in debug and at least log it in release.
|
||||
if stdin_payload.is_some() && stdin.is_none() {
|
||||
tracing::warn!(
|
||||
target: "mermaid",
|
||||
"run_with_timeout: stdin payload supplied but stdin is not piped; payload dropped"
|
||||
);
|
||||
debug_assert!(
|
||||
false,
|
||||
"run_with_timeout: stdin_payload supplied but cmd.stdin is not piped (payload dropped)"
|
||||
);
|
||||
}
|
||||
std::thread::scope(|scope| {
|
||||
if let (Some(mut sink), Some(payload)) = (stdin, stdin_payload) {
|
||||
scope.spawn(move || {
|
||||
use std::io::Write as _;
|
||||
// Errors are expected if the child exits/dies first; ignore them.
|
||||
let _ = sink.write_all(payload);
|
||||
// Dropping `sink` closes the pipe so the child observes EOF.
|
||||
});
|
||||
}
|
||||
wait_and_reap(&mut child, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn `cmd`, retrying briefly on `ETXTBSY` ("Text file busy").
|
||||
///
|
||||
/// On Linux, exec'ing a binary that another thread/process still holds open for
|
||||
/// writing fails with `ExecutableFileBusy`. A concurrent `Command::spawn` on
|
||||
/// another thread forks and inherits any write fd open at that instant; the fd
|
||||
/// is close-on-exec but only closes at the child's own `execve`, leaving a
|
||||
/// fork→execve window during which our `execve` of a freshly-written binary can
|
||||
/// race. It is transient and clears within milliseconds, so retry a few times
|
||||
/// with a short backoff. (No-op on the steady-state path; only the failing
|
||||
/// transient case changes behaviour.)
|
||||
fn spawn_with_etxtbsy_retry(cmd: &mut Command) -> std::io::Result<Child> {
|
||||
const MAX_ATTEMPTS: u32 = 5;
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
match cmd.spawn() {
|
||||
Ok(child) => return Ok(child),
|
||||
Err(e)
|
||||
if e.kind() == std::io::ErrorKind::ExecutableFileBusy
|
||||
&& attempt + 1 < MAX_ATTEMPTS =>
|
||||
{
|
||||
attempt += 1;
|
||||
std::thread::sleep(Duration::from_millis(20 * attempt as u64));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for `child` up to `timeout`, tearing down its detached process group on
|
||||
/// every exit path (success, non-zero exit, timeout, wait failure) so a child
|
||||
/// that spawned grandchildren can't orphan them.
|
||||
fn wait_and_reap(child: &mut Child, timeout: Duration) -> Result<(), SubprocessError> {
|
||||
match child.wait_timeout(timeout) {
|
||||
// `wait_timeout` already reaped the direct child on these two branches,
|
||||
// but it was its own detached group leader: SIGKILL the group's pgid so
|
||||
// any grandchildren (e.g. an opt-in MmdcEngine's headless Chromium) are
|
||||
// torn down regardless of exit code. The pgid stays valid while a
|
||||
// grandchild is alive (the case that matters); for the grandchild-less
|
||||
// render child the leader is already gone, so killpg is a harmless no-op
|
||||
// (ESRCH). The full `reap()` is unneeded — the direct child is already
|
||||
// reaped, so `child.kill()`/`wait()` would be redundant.
|
||||
Ok(Some(status)) if status.success() => {
|
||||
reap_process_group(child);
|
||||
Ok(())
|
||||
}
|
||||
Ok(Some(status)) => {
|
||||
reap_process_group(child);
|
||||
Err(SubprocessError::NonZeroExit(status))
|
||||
}
|
||||
Ok(None) => {
|
||||
reap(child);
|
||||
Err(SubprocessError::Timeout)
|
||||
}
|
||||
// waitpid failed; the child may still be running, so reap it too — the
|
||||
// same teardown as the timeout branch (don't leak the child tree).
|
||||
Err(e) => {
|
||||
reap(child);
|
||||
Err(SubprocessError::Wait(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort teardown of a spawned child: SIGKILL its process group (to reach
|
||||
/// any grandchildren, e.g. headless Chromium), then kill and reap the child.
|
||||
fn reap(child: &mut Child) {
|
||||
reap_process_group(child);
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// SIGKILL the child's process group so grandchildren are reaped, not just the
|
||||
/// direct child.
|
||||
///
|
||||
/// `kigi_tty_utils::detach_std_command` runs `setsid` (EPERM fallback
|
||||
/// `setpgid(0,0)`), so the child is its own group leader and its pgid equals its
|
||||
/// pid. We send the signal directly because `kigi_tty_utils::ProcessGroup` only
|
||||
/// wraps tokio children.
|
||||
#[cfg(unix)]
|
||||
fn reap_process_group(child: &Child) {
|
||||
let pid = child.id() as libc::pid_t;
|
||||
// SAFETY: killpg with a valid pid + standard signal has no memory effects.
|
||||
unsafe {
|
||||
libc::killpg(pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn reap_process_group(_child: &Child) {
|
||||
// Group teardown via Job Objects is tokio-only here; the caller's
|
||||
// `child.kill()` still terminates the direct child process.
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Stdio;
|
||||
use std::time::Instant;
|
||||
|
||||
fn detached(mut cmd: Command) -> Command {
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
kigi_tty_utils::detach_std_command(&mut cmd);
|
||||
cmd
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn zero_exit_is_ok() {
|
||||
let cmd = detached(Command::new("true"));
|
||||
assert!(run_with_timeout(cmd, None, Duration::from_secs(5)).is_ok());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn nonzero_exit_is_reported() {
|
||||
let cmd = detached(Command::new("false"));
|
||||
let r = run_with_timeout(cmd, None, Duration::from_secs(5));
|
||||
assert!(
|
||||
matches!(r, Err(SubprocessError::NonZeroExit(_))),
|
||||
"got {r:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn slow_command_times_out_quickly() {
|
||||
let mut cmd = Command::new("sleep");
|
||||
cmd.arg("5");
|
||||
let cmd = detached(cmd);
|
||||
let start = Instant::now();
|
||||
let r = run_with_timeout(cmd, None, Duration::from_millis(150));
|
||||
assert!(matches!(r, Err(SubprocessError::Timeout)));
|
||||
assert!(
|
||||
start.elapsed() < Duration::from_secs(2),
|
||||
"should return at the deadline, not wait the full 5s",
|
||||
);
|
||||
}
|
||||
|
||||
/// A large stdin payload (bigger than any OS pipe buffer) must be delivered
|
||||
/// through `run_with_timeout`'s own scoped writer without deadlocking the
|
||||
/// wait — the whole reason the writer is a scoped thread. We point `cat` at a
|
||||
/// file (its stdout) so we can prove every byte was consumed, and assert the
|
||||
/// call returns `Ok(())` promptly rather than hitting the timeout.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn large_stdin_payload_is_delivered_without_deadlock() {
|
||||
let payload = vec![b'x'; 256 * 1024];
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let sink = dir.path().join("drained");
|
||||
let sink_file = std::fs::File::create(&sink).expect("create sink");
|
||||
|
||||
let mut cmd = Command::new("cat");
|
||||
cmd.stdin(Stdio::piped())
|
||||
.stdout(Stdio::from(sink_file))
|
||||
.stderr(Stdio::null());
|
||||
kigi_tty_utils::detach_std_command(&mut cmd);
|
||||
|
||||
let start = Instant::now();
|
||||
let r = run_with_timeout(cmd, Some(&payload), Duration::from_secs(10));
|
||||
assert!(
|
||||
r.is_ok(),
|
||||
"draining a large stdin payload must succeed: {r:?}"
|
||||
);
|
||||
assert!(
|
||||
start.elapsed() < Duration::from_secs(5),
|
||||
"must return after the drain, not after the full timeout",
|
||||
);
|
||||
// `cat` copies all of stdin to the file → proves the whole payload was
|
||||
// both delivered and consumed via the real scoped writer.
|
||||
let drained = std::fs::metadata(&sink).expect("sink metadata").len();
|
||||
assert_eq!(
|
||||
drained,
|
||||
payload.len() as u64,
|
||||
"all stdin bytes round-tripped through cat"
|
||||
);
|
||||
}
|
||||
|
||||
/// `reap` actually terminates the spawned process group.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn reap_terminates_the_process() {
|
||||
let mut cmd = Command::new("sleep");
|
||||
cmd.arg("30");
|
||||
let mut cmd = detached(cmd);
|
||||
let mut child = cmd.spawn().expect("spawn sleep");
|
||||
let pid = child.id() as libc::pid_t;
|
||||
|
||||
reap(&mut child);
|
||||
|
||||
// After SIGKILL + wait, the pid no longer names a live process.
|
||||
assert_eq!(unsafe { libc::kill(pid, 0) }, -1);
|
||||
assert_eq!(
|
||||
std::io::Error::last_os_error().raw_os_error(),
|
||||
Some(libc::ESRCH),
|
||||
"process {pid} should be gone after reap",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_binary_is_spawn_error() {
|
||||
let cmd = Command::new("definitely-not-a-real-binary-9f8a7b6c5d4e");
|
||||
let r = run_with_timeout(cmd, None, Duration::from_secs(5));
|
||||
assert!(matches!(r, Err(SubprocessError::Spawn(_))), "got {r:?}");
|
||||
}
|
||||
|
||||
/// The dropped-stdin-payload guard fires when a caller passes a payload but
|
||||
/// forgets `cmd.stdin(Stdio::piped())`: the `debug_assert!` turns that silent
|
||||
/// foot-gun into a hard failure. Gated on `debug_assertions` because that is
|
||||
/// exactly when the assert is active (release keeps only the `warn`). `true`
|
||||
/// exits at once; `detached` sets stdin to null (not piped), so the payload
|
||||
/// would be dropped and the guard must catch it.
|
||||
#[cfg(all(unix, debug_assertions))]
|
||||
#[test]
|
||||
#[should_panic(expected = "stdin_payload supplied but cmd.stdin is not piped")]
|
||||
fn stdin_payload_without_piped_stdin_is_flagged() {
|
||||
let cmd = detached(Command::new("true"));
|
||||
let _ = run_with_timeout(cmd, Some(b"payload"), Duration::from_secs(5));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user