feat(extensions): 收到原生 CPU 压力通知时终止作业

This commit is contained in:
2026-09-09 05:37:37 +08:00
parent dfc2643d26
commit b33740d8aa
3 changed files with 263 additions and 3 deletions
@@ -542,3 +542,15 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
- 此回收机制不保证 Host 被强杀后的残留恢复,也尚未验证外部 ACL 修改竞争、清理系统调用故障及全部恶意文件攻击路径。第三方扩展执行能力仍禁用,生产化总目标未完成。
- 修正后 desktop 全目标测试累计 128 通过、7 ignored:库 113、Host 8,其余集成 7;ignored 包含显式长时验收和由父测试驱动的辅助进程入口。日志 `.build/extension-acl-release-full.log`。其中实际 Sync 服务的有序推送/重复提交及故障恢复集成也通过。
- 另行执行真实 MCP 60 秒工具超时验收通过,整项原生协议/网络探针测试耗时 76.77 秒,日志 `.build/extension-acl-release-sixty.log`desktop 全目标 Clippy -D warnings 通过,日志 `.build/extension-acl-release-clippy.log`。这不是完整沙箱攻击矩阵或远端部署验收通过的证据。
## 增量:CPU 超限原生通知与后台终止
- 在现有 Job 单核等效 HARD_CAP 上启用 NOTIFY,在进程分配/恢复前关联独立 I/O completion port,并配置 Windows 的短 10 秒通知窗口与 ToleranceHigh。原生探针在本机 16 逻辑处理器上观察到首次 CPU 通知约 10.028 秒、下一次约 24.029 秒,ViolationLimitFlags=262144;说明该通知可与硬限额共存,但不能把通知间隔假定为固定 10 秒。日志 `.build/extension-cpu-probe.log`
- Job 原始所有者持有独立 CPU 监视线程。线程收到通知后查询 JobObjectLimitViolationInformation,确认 CPU 标志才报告 EXTENSION_RESOURCE_CPU_EXCEEDED 并终止整个 Job;完成端口/查询失败或捕获到 panic 走失败终止,终止 API 失败有独立 EXTENSION_RESOURCE_TERMINATE_FAILED。观察与工具期限句柄克隆不拥有监视线程,原 Job 析构会通知线程停止并 join,线程仍持有 Job 句柄执行清理。
- 分配挂起进程、恢复执行及 Running.check_authorization 加入资源状态检查,因此既有 MCP 与实例后台路径可观察到 CPU 资源错误;CPU 终止不依赖 renderer 或调用方持续轮询。监视器初始化失败阻止 Job 返回成功。
- Windows [CPU rate control](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-jobobject_cpu_rate_control_information) 的硬上限按调度周期控制 CPU 周期;[notification limit](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-jobobject_notification_limit_information) 的 ToleranceHigh 指允许在窗口的 60% 时间超过预算。这是 Windows 的窗口阈值保护,并非精确测量“连续超限 10 秒”。当前实现不据此宣称规划中的连续时限已获完整证明,也没有修改或放宽原验收阈值。
- 新增显式真实 CPU 压力测试:Job 内 8 个忙线程、读取实际已配置 CPU 控制标志与比率、超限后确认 Job 清空、Host 在压力期间及清理后写入并重开验证笔记;另一个空闲 Job 应继续运行,持有观察句柄也不能阻止原 Job 所有者退出后的清理。该测试用原生 Job 测试进程,还不是已安装第三方 AppContainer 的完整 C-04 攻击验收。
- 默认扩展回归首次发现旧 CPU 控制标志测试仍只期望 ENABLE|HARD_CAP,已更新为同时核验新 NOTIFY 标志;未放宽资源限额值。scratch 硬配额、CPU 连续时限严格证明、全部限额清理矩阵、Host 实际产品路由及其他生产验收继续未完成,第三方执行能力仍禁用。
- 最终默认扩展回归 68 通过、7 ignored(长时验收与辅助进程入口),日志 `.build/extension-cpu-regression.log`;显式 CPU 压力/空闲对照测试通过,本次从恢复附近的计时点到检测为 15.7348341 秒,整个测试 15.90 秒,日志 `.build/extension-cpu-enforcement.log`。此前简化压力测试为 12.1788269 秒;这些差异进一步表明不能宣称精确 10 秒触发。记录中的“Job empty after”还包含后续 Host 保存/重开工作,不能作为纯终止 API 延迟的独立测量。
- desktop 全目标 Clippy -D warnings 通过,日志 `.build/extension-cpu-clippy.log`。本轮改动集中于原生 Job 与进程资源状态传播,未重新声称上一轮的 Rust 全目标、前端、Python 和远端验收均对当前版本完成。
+248 -3
View File
@@ -11,10 +11,14 @@ use windows_sys::Win32::System::{
pub struct Job {
handle: OwnedHandle,
state: std::sync::Arc<std::sync::atomic::AtomicU8>,
_cpu: Option<CpuMonitor>,
}
impl Job {
pub(crate) fn clone_for_deadline(&self) -> Result<Self> {
Ok(Self {
state: std::sync::Arc::clone(&self.state),
_cpu: None,
handle: self
.handle
.try_clone()
@@ -29,8 +33,10 @@ impl Job {
if raw.is_null() {
return Err(HostError::new("EXTENSION_RESOURCE_UNAVAILABLE"));
}
let job = Self {
let mut job = Self {
handle: unsafe { OwnedHandle::from_raw_handle(raw) },
state: std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)),
_cpu: None,
};
let limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
BasicLimitInformation: JOBOBJECT_BASIC_LIMIT_INFORMATION {
@@ -49,14 +55,26 @@ impl Job {
return Err(HostError::new("EXTENSION_RESOURCE_UNAVAILABLE"));
}
let cpu = JOBOBJECT_CPU_RATE_CONTROL_INFORMATION {
ControlFlags: JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP,
ControlFlags: JOB_OBJECT_CPU_RATE_CONTROL_ENABLE
| JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP
| JOB_OBJECT_CPU_RATE_CONTROL_NOTIFY,
Anonymous: JOBOBJECT_CPU_RATE_CONTROL_INFORMATION_0 {
CpuRate: (10000 / processors).max(1),
},
};
job.set(JobObjectCpuRateControlInformation, &cpu)?;
job._cpu = Some(CpuMonitor::arm(&job)?);
Ok(job)
}
pub fn check_resources(&self) -> Result<()> {
use std::sync::atomic::Ordering;
match self.state.load(Ordering::Acquire) {
0 => Ok(()),
1 => Err(HostError::new("EXTENSION_RESOURCE_CPU_EXCEEDED")),
3 => Err(HostError::new("EXTENSION_RESOURCE_TERMINATE_FAILED")),
_ => Err(HostError::new("EXTENSION_RESOURCE_MONITOR_FAILED")),
}
}
fn set<T>(&self, class: JOBOBJECTINFOCLASS, value: &T) -> Result<()> {
if unsafe {
SetInformationJobObject(
@@ -77,6 +95,7 @@ impl Job {
/// Caller must own an unresumed CREATE_SUSPENDED process and terminate it on
/// any error. Resume only after all AppContainer/handle/permission checks pass.
pub unsafe fn assign_suspended(&self, process: BorrowedHandle<'_>) -> Result<()> {
self.check_resources()?;
if unsafe { AssignProcessToJobObject(self.handle.as_raw_handle(), process.as_raw_handle()) }
== 0
{
@@ -108,6 +127,122 @@ impl Job {
}
}
/// The Windows notification uses a ten-second window and ToleranceHigh (60%
/// over budget). This is not a measurement of ten uninterrupted busy seconds.
/// Only the original Job owns this monitor; observation/deadline clones do not.
const JOB_NOTIFICATION_LIMIT: u32 = 11; // JOB_OBJECT_MSG_NOTIFICATION_LIMIT (Windows SDK)
struct CpuMonitor {
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
worker: Option<std::thread::JoinHandle<()>>,
}
impl CpuMonitor {
fn arm(job: &Job) -> Result<Self> {
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use windows_sys::Win32::{Foundation::INVALID_HANDLE_VALUE, System::IO::*};
let raw =
unsafe { CreateIoCompletionPort(INVALID_HANDLE_VALUE, std::ptr::null_mut(), 0, 1) };
if raw.is_null() {
return Err(HostError::new("EXTENSION_RESOURCE_MONITOR_UNAVAILABLE"));
}
let port = unsafe { OwnedHandle::from_raw_handle(raw) };
job.set(
JobObjectAssociateCompletionPortInformation,
&JOBOBJECT_ASSOCIATE_COMPLETION_PORT {
CompletionKey: std::ptr::dangling_mut::<u8>().cast(),
CompletionPort: port.as_raw_handle(),
},
)?;
job.set(
JobObjectNotificationLimitInformation,
&JOBOBJECT_NOTIFICATION_LIMIT_INFORMATION {
LimitFlags: JOB_OBJECT_LIMIT_RATE_CONTROL,
RateControlTolerance: ToleranceHigh,
RateControlToleranceInterval: ToleranceIntervalShort,
..Default::default()
},
)?;
let owned_job = job
.handle
.try_clone()
.map_err(|_| HostError::new("EXTENSION_RESOURCE_MONITOR_UNAVAILABLE"))?;
let state = Arc::clone(&job.state);
let stop = Arc::new(AtomicBool::new(false));
let thread_stop = Arc::clone(&stop);
let worker = std::thread::Builder::new()
.name("extension-cpu".into())
.spawn(move || {
// All exits, including a caught panic or completion-port failure,
// terminate the tree while this worker still owns a Job handle.
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
while !thread_stop.load(Ordering::Acquire) {
let (mut code, mut key, mut pointer) = (0, 0, std::ptr::null_mut());
let ok = unsafe {
GetQueuedCompletionStatus(
port.as_raw_handle(),
&mut code,
&mut key,
&mut pointer,
100,
)
};
if ok == 0 {
if unsafe { windows_sys::Win32::Foundation::GetLastError() }
== windows_sys::Win32::Foundation::WAIT_TIMEOUT
{
continue;
}
return 2;
}
if key != 1 {
return 2;
}
if code != JOB_NOTIFICATION_LIMIT {
continue;
}
let mut info = JOBOBJECT_LIMIT_VIOLATION_INFORMATION::default();
if unsafe {
QueryInformationJobObject(
owned_job.as_raw_handle(),
JobObjectLimitViolationInformation,
(&mut info as *mut JOBOBJECT_LIMIT_VIOLATION_INFORMATION).cast(),
size_of::<JOBOBJECT_LIMIT_VIOLATION_INFORMATION>() as u32,
std::ptr::null_mut(),
)
} == 0
{
return 2;
}
if info.ViolationLimitFlags & JOB_OBJECT_LIMIT_RATE_CONTROL != 0 {
return 1;
}
}
0
}))
.unwrap_or(2);
state.store(outcome, Ordering::Release);
if unsafe { TerminateJobObject(owned_job.as_raw_handle(), 1) } == 0 {
state.store(3, Ordering::Release);
}
})
.map_err(|_| HostError::new("EXTENSION_RESOURCE_MONITOR_UNAVAILABLE"))?;
Ok(Self {
stop,
worker: Some(worker),
})
}
}
impl Drop for CpuMonitor {
fn drop(&mut self) {
self.stop.store(true, std::sync::atomic::Ordering::Release);
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -199,6 +334,114 @@ mod tests {
assert!(excessive.try_reserve_exact(600 * 1024 * 1024).is_err());
}
#[test]
#[ignore = "helper consumes CPU inside the parent controlled job"]
fn worker_cpu() {
let end = std::time::Instant::now() + Duration::from_secs(40);
std::thread::scope(|scope| {
for _ in 0..8 {
scope.spawn(move || {
let mut n = 1u64;
while std::time::Instant::now() < end {
for _ in 0..10000 {
n = std::hint::black_box(
n.wrapping_mul(6364136223846793005).wrapping_add(1),
);
}
}
});
}
});
}
#[test]
#[ignore = "real ten-second CPU pressure and Host save acceptance; run explicitly"]
fn cpu_pressure_terminates_job_and_host_can_save() {
let idle_job = Job::new().unwrap();
let idle = worker();
unsafe {
idle_job.assign_suspended(idle.process.as_handle()).unwrap();
assert_ne!(ResumeThread(idle.thread.as_raw_handle()), u32::MAX);
}
let job = Job::new().unwrap();
let mut rate = JOBOBJECT_CPU_RATE_CONTROL_INFORMATION::default();
assert_ne!(
unsafe {
QueryInformationJobObject(
job.handle.as_raw_handle(),
JobObjectCpuRateControlInformation,
(&mut rate as *mut JOBOBJECT_CPU_RATE_CONTROL_INFORMATION).cast(),
size_of::<JOBOBJECT_CPU_RATE_CONTROL_INFORMATION>() as u32,
std::ptr::null_mut(),
)
},
0
);
assert_eq!(
rate.ControlFlags,
JOB_OBJECT_CPU_RATE_CONTROL_ENABLE
| JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP
| JOB_OBJECT_CPU_RATE_CONTROL_NOTIFY
);
assert_eq!(
unsafe { rate.Anonymous.CpuRate },
(10000 / unsafe { GetActiveProcessorCount(ALL_PROCESSOR_GROUPS) }).max(1)
);
let child = worker_named("worker_cpu");
let start = std::time::Instant::now();
unsafe {
job.assign_suspended(child.process.as_handle()).unwrap();
assert_ne!(ResumeThread(child.thread.as_raw_handle()), u32::MAX);
}
let vault = tempfile::tempdir().unwrap();
let mut workspace = crate::workspace::Workspace::open(vault.path()).unwrap();
workspace
.write("cpu.md", "", b"while CPU is busy", "local")
.unwrap();
while job.check_resources().is_ok() && start.elapsed() < Duration::from_secs(20) {
std::thread::sleep(Duration::from_millis(25));
}
assert_eq!(
job.check_resources().unwrap_err().code,
"EXTENSION_RESOURCE_CPU_EXCEEDED"
);
let detected = start.elapsed();
let cleanup = std::time::Instant::now();
while job.active_processes().unwrap() != 0 && cleanup.elapsed() < Duration::from_secs(5) {
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!(job.active_processes().unwrap(), 0);
let saved = workspace.read("cpu.md").unwrap();
workspace
.write("cpu.md", &saved.entry.hash, b"after CPU cleanup", "local")
.unwrap();
drop(workspace);
assert_eq!(
crate::workspace::Workspace::open(vault.path())
.unwrap()
.read("cpu.md")
.unwrap()
.content,
"after CPU cleanup"
);
eprintln!(
"CPU pressure detected at {detected:?}; Job empty after {:?}",
cleanup.elapsed()
);
idle_job.check_resources().unwrap();
assert!(idle_job.active_processes().unwrap() > 0);
// An observation handle must not keep the monitor alive after its owner
// is dropped, or keep the idle process running indefinitely.
let observation = idle_job.clone_for_deadline().unwrap();
let stop = std::time::Instant::now();
drop(idle_job);
assert!(stop.elapsed() < Duration::from_secs(2));
while observation.active_processes().unwrap() != 0
&& stop.elapsed() < Duration::from_secs(5)
{
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!(observation.active_processes().unwrap(), 0);
}
#[test]
fn actual_allocation_above_job_memory_budget_is_refused() {
let job = Job::new().unwrap();
let child = worker_named("worker_memory");
@@ -306,7 +549,9 @@ mod tests {
);
assert_eq!(
cpu.ControlFlags,
JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP
JOB_OBJECT_CPU_RATE_CONTROL_ENABLE
| JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP
| JOB_OBJECT_CPU_RATE_CONTROL_NOTIFY
);
let child = worker();
unsafe {
@@ -239,6 +239,7 @@ impl<'a> Suspended<'a> {
/// broker and all resource policy requirements immediately before this call.
/// None of those authorization checks is supplied by this low-level module.
pub unsafe fn resume(self) -> Result<Running<'a>> {
self.0.job.check_resources()?;
if unsafe { ResumeThread(self.0.handles.thread.as_raw_handle()) } != 1 {
return Err(HostError::new("EXTENSION_PROCESS_RESUME_FAILED"));
}
@@ -261,6 +262,7 @@ impl<'a> Suspended<'a> {
) -> Result<Running<'a>> {
let watch = crate::extension_revocation::Watch::arm(&self.0.job, lease)?;
watch.check()?;
self.0.job.check_resources()?;
if unsafe { ResumeThread(self.0.handles.thread.as_raw_handle()) } != 1 {
return Err(HostError::new("EXTENSION_PROCESS_RESUME_FAILED"));
}
@@ -291,6 +293,7 @@ impl Running<'_> {
}
pub fn check_authorization(&self) -> Result<()> {
self.process.job.check_resources()?;
#[cfg(feature = "desktop")]
if let Some(watch) = &self.revocation {
return watch.check();