fix(portal): prevent security view loading stalls

This commit is contained in:
igor04091968
2026-06-04 23:20:53 +03:00
parent 6a6c69e803
commit afc40fd3ca
3 changed files with 77 additions and 6 deletions
+53 -5
View File
@@ -7,6 +7,9 @@ use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use anyhow::{Context, Result, anyhow};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
@@ -27,6 +30,15 @@ const UEBA_BASELINE_MIN_SAMPLES: usize = 3;
const SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(5);
const DEFAULT_DEPARTMENT_LABEL: &str = "Без подразделения";
#[cfg(unix)]
const SIGKILL: i32 = 9;
#[cfg(unix)]
unsafe extern "C" {
fn setpgid(pid: i32, pgid: i32) -> i32;
fn kill(pid: i32, sig: i32) -> i32;
}
type SnapshotCache = Arc<Mutex<Option<CachedSnapshot>>>;
#[derive(Clone, Debug)]
@@ -2570,13 +2582,25 @@ fn security_events_block(summary: &SecurityEventsSummary) -> SummaryBlock {
}
fn run_shell(command: &str, timeout: Duration) -> Result<(String, String, bool)> {
let mut child = Command::new("/bin/sh")
let mut shell = Command::new("/bin/sh");
shell
.arg("-lc")
.arg(command)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("spawn {command}"))?;
.stderr(Stdio::piped());
#[cfg(unix)]
unsafe {
shell.pre_exec(|| {
// Isolate portal probes so a timeout can kill helper grandchildren
// such as detmir-check, not only the shell wrapper.
if setpgid(0, 0) == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
});
}
let mut child = shell.spawn().with_context(|| format!("spawn {command}"))?;
let started = Instant::now();
loop {
if let Some(status) = child.try_wait()? {
@@ -2591,7 +2615,7 @@ fn run_shell(command: &str, timeout: Duration) -> Result<(String, String, bool)>
return Ok((stdout, stderr, status.success()));
}
if started.elapsed() > timeout {
let _ = child.kill();
kill_shell_tree(&mut child);
let _ = child.wait();
return Err(anyhow!(
"command timed out after {}s: {command}",
@@ -2602,6 +2626,21 @@ fn run_shell(command: &str, timeout: Duration) -> Result<(String, String, bool)>
}
}
#[cfg(unix)]
fn kill_shell_tree(child: &mut std::process::Child) {
let pgid = child.id() as i32;
// Negative pid targets the process group created in pre_exec above.
// Best-effort cleanup: the caller still waits on the direct child.
unsafe {
let _ = kill(-pgid, SIGKILL);
}
}
#[cfg(not(unix))]
fn kill_shell_tree(child: &mut std::process::Child) {
let _ = child.kill();
}
fn build_health(snapshot: &Snapshot) -> HealthResponse {
let mut sources = BTreeMap::new();
sources.insert("detmir_status".to_string(), snapshot.detmir_status.ok);
@@ -9304,6 +9343,15 @@ mod tests {
assert!(!serde_json::to_string(&value).unwrap().contains("\\uFFFD"));
}
#[cfg(unix)]
#[test]
fn run_shell_timeout_kills_grandchildren_without_blocking_stdout() {
let started = Instant::now();
let err = run_shell("sh -c 'sleep 5 & wait'", Duration::from_millis(200)).unwrap_err();
assert!(started.elapsed() < Duration::from_secs(2));
assert!(err.to_string().contains("command timed out"));
}
#[test]
fn agent_quality_history_counts_seven_ok_days() {
let (_dir, path) = write_history(
@@ -4,6 +4,7 @@ const state = {
period: "today",
links: null,
readiness: null,
operatorData: null,
reports: null,
cases: null,
pendingScrollSelector: null,
@@ -205,6 +206,27 @@ function setViewMode(mode) {
setTab("operator");
return;
}
if (state.operatorData && state.reports) {
const content = document.getElementById("content");
if (content) {
content.innerHTML = renderOperator(state.operatorData, state.reports, { cases: state.cases });
}
setLoadStatus("READY", "Данные готовы", 100);
consumePendingScroll();
if (mode === "security" && !state.cases) {
loadJson("/cases")
.then(cases => {
state.cases = cases;
if (currentViewMode() === "security" && state.tab === "operator" && content) {
content.innerHTML = renderOperator(state.operatorData, state.reports, { cases: state.cases });
}
})
.catch(() => {
state.cases = { ok: false, cases: [] };
});
}
return;
}
refresh({ stage: VIEW_MODES[mode].stage });
}
@@ -2800,6 +2822,7 @@ async function refresh(options = {}) {
async function loadCurrentTab() {
if (state.tab === "operator") {
const data = await loadJson("/operator");
state.operatorData = data;
state.reports = await loadJson("/reports").catch(() => state.reports);
if (currentViewMode() === "security") {
state.cases = await loadJson("/cases").catch(error => ({ ok: false, error: error.message, cases: [] }));
@@ -82,6 +82,6 @@
<section id="summary" class="summary-grid service-summary"></section>
</main>
</div>
<script src="app.js"></script>
<script src="app.js?v=20260604-security-cache"></script>
</body>
</html>