Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs
T
suzmii cc6211fd41
Project CI / Repository checks (push) Successful in 7m49s
Project CI / Frontend tests (push) Successful in 8m24s
Project CI / Backend tests (push) Successful in 9m56s
Project CI / Native shell tests (push) Successful in 19m18s
修复 Backend 与 Native shell CI 回归并恢复完整测试门禁 (#209)
## 背景

这是一个独立的 CI 修复 PR,针对 Backend 与 Native shell 测试失败进行修复,不包含资源画布功能变更。

此前 CI 失败主要集中在:

- 后台精选素材契约测试初始化缺少来源素材字段。
- `spacetime-module` host 测试因缺少 SpacetimeDB WASM 宿主 ABI 符号而无法链接。
- Native shell 自主创作模式、runtime action、provider batch 和测试初始化之间存在状态机断言不一致。
- 部分失败测试曾被临时绕过,本 PR 恢复这些测试门禁。

## 变更内容

### Backend

- 补齐后台精选素材 payload 测试所需的来源素材字段。
- 增加 camelCase 序列化断言,防止 snake_case 字段泄漏。
- 调整 server-rs workspace 测试拆分:
  - 普通 workspace host 测试继续排除 `spacetime-module`,避免其 `spacetime-types` feature 污染其他领域 crate 的 host 链接。
  - 新增独立的 `spacetime-module` 单元测试步骤,不再跳过该模块自身测试。
- 为 host 测试构建提供仅测试期的 SpacetimeDB ABI 链接支持。
- 同步更新 CI workflow 断言、开发流程文档和运维验证文档。

### Native shell

- 恢复被临时跳过的 Native shell 失败测试门禁。
- 修复自主创作模式下的完成条件、runtime action 执行、provider batch 和 finalization 状态流转。
- 修正 Tauri/native 测试初始化、恢复流程及断言,使测试与实际运行时协议保持一致。
- 保持自主创作模式的宽松行为,不通过忽略测试规避失败。

## 验证

当前提交已完成以下本地验证:

- `cargo test --locked -p spacetime-module --no-fail-fast`
  - 259 个测试
  - 258 个通过
  - 0 个失败
  - 1 个已有的外部 fixture 测试 ignored
- `cargo check --locked -p spacetime-module --target wasm32-unknown-unknown`
- `cargo build --locked -p spacetime-module --target wasm32-unknown-unknown`
- `cargo check --locked -p api-server --all-targets`
- `npm run check:server-rs-ddd`
- `npm run check:spacetime-schema`
- `npm run check:encoding`
- `npm run test -- scripts/project-ci-workflow.test.ts`
- `cargo fmt --manifest-path server-rs/Cargo.toml --all -- --check`
- `git diff --check`

没有新增 `#[ignore]` 来规避失败。

## 测试边界说明

`spacetime-module` 的 host ABI 链接支持仅用于执行纯单元测试,不是 SpacetimeDB runtime 替身。Reducer、procedure 和真实数据库事务行为仍需要通过真实 SpacetimeDB runtime/integration harness 验证。

本机 Windows 上完整 workspace 测试中的 `wallet_refund_outbox` 测试仍有 11 项因 `os error 5(拒绝访问)`失败,表现为临时文件权限/锁定问题;该问题与本 PR 的 SpacetimeDB 模块测试改动无关,也没有通过忽略这些测试来掩盖。

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/209
Co-authored-by: suzmii <suzmii@qq.com>
Co-committed-by: suzmii <suzmii@qq.com>
2026-08-31 10:53:30 +08:00

1807 lines
66 KiB
Rust

use super::*;
#[cfg(target_os = "linux")]
use std::process::Stdio;
static PROCESS_SESSION_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn process_session_test_guard() -> std::sync::MutexGuard<'static, ()> {
PROCESS_SESSION_TEST_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn process_identity(project_id: &str) -> ProcessSessionIdentity {
ProcessSessionIdentity {
project_id: project_id.to_string(),
agent_id: "code-prototype".to_string(),
task_id: "code-prototype".to_string(),
conversation_session_id: "session-process-test".to_string(),
run_id: "run-process-test".to_string(),
start_action_id: "action-process-start-test".to_string(),
start_action_fingerprint: "a".repeat(64),
}
}
fn process_test_command_spec(root: &Path) -> ProjectCommandSpec {
fs::write(
root.join("package.json"),
r#"{"scripts":{"dev":"node fixture.js"}}"#,
)
.expect("write process test package.json");
resolve_project_command_spec_at(
root,
"npm",
&["run".to_string(), "dev".to_string()],
".",
30,
)
.expect("resolve process test command")
}
#[test]
fn process_session_cursor_preserves_unicode_boundaries() {
let process_id = "proc-0123456789abcdef0123456789abcdef";
let state = ProcessOutputState {
text: "甲乙abc".to_string(),
status: "running".to_string(),
exit_code: None,
signal: None,
stdin_open: true,
reader_finished: false,
output_limit_exceeded: false,
source_fingerprint_after: None,
source_changed: None,
needs_reconciliation: false,
launch_failure_kind: None,
};
let first = poll_result_from_output(
process_id,
&state.text,
&state,
"test",
"test",
"test",
"test-v1",
"established",
"established",
None,
None,
2,
)
.expect("first unicode page");
assert_eq!(first.output, "甲乙");
assert!(first.has_more);
let second = poll_result_from_output(
process_id,
&state.text,
&state,
"test",
"test",
"test",
"test-v1",
"established",
"established",
None,
Some(&first.next_cursor),
3,
)
.expect("second unicode page");
assert_eq!(second.output, "abc");
assert!(!second.has_more);
}
fn write_legacy_process_session_record(
root: &Path,
record: &ProcessSessionRecord,
schema_version: &str,
) {
let mut value = serde_json::to_value(record).expect("serialize legacy record");
let object = value.as_object_mut().expect("legacy record object");
object.insert(
"schemaVersion".to_string(),
serde_json::Value::String(schema_version.to_string()),
);
for field in [
"sandboxEstablishment",
"targetExec",
"launchFailureKind",
"sandboxReadyAt",
"execEstablishedAt",
] {
object.remove(field);
}
if schema_version == "1" {
for field in [
"sandboxBackend",
"sandboxMode",
"networkAccess",
"sandboxProfileVersion",
] {
object.remove(field);
}
}
write_agent_runtime_json_sidecar_with_max_bytes(
root,
&process_session_record_relative_path(&record.process_id),
"legacy process session",
&value,
PROCESS_SESSION_RECORD_MAX_BYTES,
)
.expect("write legacy process record");
}
#[test]
fn process_session_v1_v2_active_records_migrate_to_v3_reconciliation() {
let _guard = process_session_test_guard();
clear_process_session_registry_for_tests();
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "legacy-active-project", "Legacy Active Project")
.expect("initialize project");
for (index, schema_version) in ["1", "2"].into_iter().enumerate() {
let mut identity = process_identity("legacy-active-project");
identity.start_action_id = format!("legacy-active-action-{index}");
identity.start_action_fingerprint = format!("{}", index + 1).repeat(64);
let process_id = format!("proc-{:032x}", index + 1);
let record = initial_process_session_record(
&identity,
&process_id,
&format!("cmd-legacy-active-{index}"),
&process_test_command_spec(root),
None,
&"a".repeat(64),
"running",
);
write_legacy_process_session_record(root, &record, schema_version);
let migrated = read_process_session_record(root, &process_id)
.expect("read migrated active record")
.expect("active record exists");
assert_eq!(migrated.schema_version, "3");
assert_eq!(migrated.status, "needs-reconciliation");
assert_eq!(migrated.sandbox_establishment, "unknown");
assert_eq!(migrated.target_exec, "unknown");
assert_eq!(
migrated.launch_failure_kind.as_deref(),
Some("legacy-active-record")
);
assert!(migrated.needs_reconciliation);
assert!(migrated.terminal_at.is_some());
let repeated = read_process_session_record(root, &process_id)
.expect("read migrated active record again")
.expect("active record remains");
assert_eq!(repeated, migrated);
}
clear_process_session_registry_for_tests();
}
#[test]
fn process_session_v1_v2_terminal_records_remain_readable_without_reconciliation() {
let _guard = process_session_test_guard();
clear_process_session_registry_for_tests();
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "legacy-terminal-project", "Legacy Terminal Project")
.expect("initialize project");
for (index, schema_version) in ["1", "2"].into_iter().enumerate() {
let mut identity = process_identity("legacy-terminal-project");
identity.start_action_id = format!("legacy-terminal-action-{index}");
identity.start_action_fingerprint = format!("{}", index + 3).repeat(64);
let process_id = format!("proc-{:032x}", index + 16);
let mut record = initial_process_session_record(
&identity,
&process_id,
&format!("cmd-legacy-terminal-{index}"),
&process_test_command_spec(root),
None,
&"b".repeat(64),
"exited",
);
record.exit_code = Some(index as i32);
record.terminal_at = Some(record.started_at);
let output = format!("LEGACY-TERMINAL-{schema_version}");
record.output_bytes = output.len();
record.output_sha256 = format!("{:x}", Sha256::digest(output.as_bytes()));
record.output_ref = Some(process_session_transcript_relative_path(&process_id));
let transcript = ProcessSessionTranscript {
schema_version: schema_version.to_string(),
project_id: identity.project_id.clone(),
agent_id: identity.agent_id.clone(),
task_id: identity.task_id.clone(),
conversation_session_id: identity.conversation_session_id.clone(),
run_id: identity.run_id.clone(),
start_action_id: identity.start_action_id.clone(),
start_action_fingerprint: identity.start_action_fingerprint.clone(),
process_id: process_id.clone(),
output: output.clone(),
output_sha256: record.output_sha256.clone(),
output_bytes: output.len(),
updated_at: record.updated_at,
};
write_agent_runtime_json_sidecar_with_max_bytes(
root,
record.output_ref.as_deref().expect("legacy output ref"),
"legacy process transcript",
&transcript,
PROCESS_SESSION_TRANSCRIPT_MAX_BYTES,
)
.expect("write legacy process transcript");
write_legacy_process_session_record(root, &record, schema_version);
let migrated = read_process_session_record(root, &process_id)
.expect("read migrated terminal record")
.expect("terminal record exists");
assert_eq!(migrated.schema_version, "3");
assert_eq!(migrated.status, "exited");
assert_eq!(migrated.sandbox_establishment, "unknown");
assert_eq!(migrated.target_exec, "unknown");
assert_eq!(
migrated.launch_failure_kind.as_deref(),
Some("legacy-record")
);
assert!(!migrated.needs_reconciliation);
let poll =
poll_process_session_at(root, &identity, &process_id, None, Some(8_000), Some(0))
.expect("poll migrated terminal record");
assert_eq!(poll.status, "exited");
assert_eq!(poll.exit_code, Some(index as i32));
assert_eq!(poll.output, output);
}
clear_process_session_registry_for_tests();
}
#[test]
fn process_session_v3_rejects_untrusted_state_combinations_and_timestamps() {
let _guard = process_session_test_guard();
clear_process_session_registry_for_tests();
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "v3-validation-project", "V3 Validation Project")
.expect("initialize project");
let identity = process_identity("v3-validation-project");
let process_id = "proc-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
let spec = process_test_command_spec(root);
let mut record = initial_process_session_record(
&identity,
process_id,
"cmd-v3-validation",
&spec,
None,
&"c".repeat(64),
"running",
);
assert!(validate_process_session_record(root, &record, process_id).is_err());
record.sandbox_establishment = "established".to_string();
record.target_exec = "established".to_string();
record.sandbox_ready_at = Some(record.started_at);
record.exec_established_at = Some(record.started_at);
record.launch_failure_kind = Some("arbitrary".to_string());
assert!(validate_process_session_record(root, &record, process_id).is_err());
record.launch_failure_kind = None;
record.sandbox_ready_at = Some(record.started_at.saturating_add(2));
record.exec_established_at = Some(record.started_at.saturating_add(1));
assert!(validate_process_session_record(root, &record, process_id).is_err());
record.status = "failed".to_string();
record.sandbox_establishment = "unknown".to_string();
record.target_exec = "unknown".to_string();
record.launch_failure_kind = Some("launch-unknown".to_string());
record.sandbox_ready_at = None;
record.exec_established_at = None;
record.terminal_at = Some(record.started_at);
record.needs_reconciliation = false;
assert!(validate_process_session_record(root, &record, process_id).is_err());
record.status = "needs-reconciliation".to_string();
record.needs_reconciliation = true;
assert!(validate_process_session_record(root, &record, process_id).is_ok());
record.needs_reconciliation = false;
assert!(validate_process_session_record(root, &record, process_id).is_err());
record.needs_reconciliation = true;
record.sandbox_establishment = "established".to_string();
record.target_exec = "established".to_string();
record.launch_failure_kind = Some("start-audit-failed".to_string());
record.sandbox_ready_at = Some(record.started_at);
record.exec_established_at = Some(record.started_at);
assert!(validate_process_session_record(root, &record, process_id).is_ok());
record.status = "failed".to_string();
assert!(validate_process_session_record(root, &record, process_id).is_err());
record.status = "launching".to_string();
record.needs_reconciliation = false;
record.target_exec = "not-attempted".to_string();
record.launch_failure_kind = None;
record.exec_established_at = None;
record.terminal_at = None;
assert!(validate_process_session_record(root, &record, process_id).is_ok());
record.sandbox_ready_at = None;
assert!(validate_process_session_record(root, &record, process_id).is_err());
clear_process_session_registry_for_tests();
}
#[cfg(target_os = "linux")]
#[test]
fn pending_process_launch_blocks_idle_until_guard_is_dropped() {
let _guard = process_session_test_guard();
clear_process_session_registry_for_tests();
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "pending-launch-project", "Pending Launch Project")
.expect("initialize project");
let pending = reserve_pending_process_launch(
root,
"code-prototype",
"proc-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
.expect("register pending launch");
assert!(has_active_process_sessions_at(root).expect("pending launch is active"));
shutdown_all_process_sessions();
assert!(
activate_pending_process_launch("proc-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", i32::MAX,)
.expect_err("shutdown cancels a launch before pid activation")
.contains("shutdown")
);
drop(pending);
assert!(!has_active_process_sessions_at(root).expect("pending launch removed"));
clear_process_session_registry_for_tests();
}
#[test]
fn process_session_ansi_stripper_handles_split_csi_and_osc() {
let mut stripper = AnsiStripper::default();
let mut visible = Vec::new();
for chunk in [
b"A\x1b[3".as_slice(),
b"1mB\x1b]52;c;secret".as_slice(),
b"\x07C\x1b[0m\n".as_slice(),
] {
for byte in chunk {
stripper.push(*byte, &mut visible);
}
}
assert_eq!(String::from_utf8(visible).expect("utf8"), "ABC\n");
}
#[cfg(windows)]
#[test]
fn process_session_terminal_reposition_detector_ignores_color_sequences() {
let mut detector = AnsiTerminalRepositionDetector::default();
let color = b"\x1b[31m";
assert!(!color.iter().any(|byte| detector.push(*byte)));
let mut reposition = AnsiTerminalRepositionDetector::default();
let sequence = b"\x1b[5;1H";
assert_eq!(
sequence
.iter()
.filter(|byte| reposition.push(**byte))
.count(),
1
);
}
#[cfg(windows)]
#[test]
fn process_session_windows_npm_builder_uses_node_cli_and_native_script_shell() {
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "npm-builder-project", "Npm Builder Project")
.expect("initialize project");
fs::write(
root.join("package.json"),
r#"{"scripts":{"dev":"node fixture.js"}}"#,
)
.expect("write package.json");
let spec = resolve_project_command_spec_at(
root,
"npm",
&["run".to_string(), "dev".to_string()],
".",
30,
)
.expect("resolve npm command");
let launch = prepare_project_command_launch_spec(root, &spec).expect("prepare npm launch");
let command = process_session_command_builder(&launch).expect("build npm PTY command");
let argv = command.get_argv();
assert!(argv[0].to_string_lossy().ends_with("node.exe"));
assert!(argv[1]
.to_string_lossy()
.replace('\\', "/")
.ends_with("node_modules/npm/bin/npm-cli.js"));
assert!(argv
.iter()
.all(|argument| !argument.to_string_lossy().starts_with(r"\\?\")));
assert_eq!(
command.get_env("npm_config_script_shell"),
Some(std::ffi::OsStr::new(r"C:\Windows\System32\cmd.exe"))
);
assert_eq!(
command.get_env("NODE"),
command.get_env("npm_node_execpath")
);
assert!(command
.get_cwd()
.is_some_and(|cwd| !cwd.to_string_lossy().starts_with(r"\\?\")));
}
#[test]
fn process_session_real_pty_streams_stdin_and_terminates() {
let _guard = process_session_test_guard();
clear_process_session_registry_for_tests();
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "process-project", "Process Project")
.expect("initialize project");
fs::write(
root.join("package.json"),
r#"{"scripts":{"dev":"node fixture.js"}}"#,
)
.expect("write package.json");
fs::write(
root.join("fixture.js"),
r#"
process.stdin.setEncoding('utf8');
console.log('\u001b[31mREADY\u001b[0m');
console.log(`BRIDGE_ENV:${Object.keys(globalThis['process']['env']).filter((name) => name.includes('PROCESS_SESSION_BRIDGE')).join(',')}`);
console.log(`TARGET_ARGV:${process.argv.join('|')}`);
process.stdin.on('data', (chunk) => console.log(`ECHO:${chunk.trim()}`));
process.on('SIGTERM', () => { console.log('STOPPED'); process.exit(0); });
setInterval(() => {}, 1000);
"#,
)
.expect("write fixture");
let spec = resolve_project_command_spec_at(
root,
"npm",
&["run".to_string(), "dev".to_string()],
".",
30,
)
.expect("resolve npm command");
let identity = process_identity("process-project");
let source_fingerprint = project_command_source_fingerprint(root).expect("source fingerprint");
let mut poll = start_process_session_at(root, identity.clone(), &spec, source_fingerprint)
.expect("start process session");
assert!(poll.output.is_empty());
assert_eq!(poll.cursor, poll.next_cursor);
#[cfg(target_os = "linux")]
{
assert_eq!(poll.sandbox_backend, "bubblewrap");
assert_eq!(poll.sandbox_mode, "workspace-write");
assert_eq!(poll.network_access, "disabled");
assert_eq!(poll.sandbox_profile_version, "workspace-v1");
assert_eq!(poll.sandbox_establishment, "established");
assert_eq!(poll.target_exec, "established");
assert_eq!(poll.launch_failure_kind, None);
}
assert!(has_active_process_sessions_at(root).expect("active process probe"));
let mut combined = poll.output.clone();
for _ in 0..20 {
if combined.contains("READY") {
break;
}
poll = poll_process_session_at(
root,
&identity,
&poll.process_id,
Some(&poll.next_cursor),
Some(8_000),
Some(500),
)
.expect("poll ready");
combined.push_str(&poll.output);
}
assert!(combined.contains("READY"), "output: {combined}");
assert!(!combined.contains("[31m"), "output: {combined}");
let mut foreign_identity = identity.clone();
foreign_identity.agent_id = "art-director".to_string();
assert!(poll_process_session_at(
root,
&foreign_identity,
&poll.process_id,
None,
Some(10),
Some(0),
)
.is_err());
assert!(write_process_session_stdin_at(
root,
&foreign_identity,
&poll.process_id,
"blocked",
true,
false,
)
.is_err());
let stdin =
write_process_session_stdin_at(root, &identity, &poll.process_id, "你好", true, false)
.expect("write stdin");
assert_eq!(stdin.bytes_written, "你好\n".len());
let mut echo = String::new();
for _ in 0..20 {
poll = poll_process_session_at(
root,
&identity,
&poll.process_id,
Some(&poll.next_cursor),
Some(8_000),
Some(500),
)
.expect("poll echo");
echo.push_str(&poll.output);
if echo.contains("ECHO:你好") {
break;
}
}
assert!(echo.contains("ECHO:你好"), "output: {echo}");
let terminal =
terminate_process_session_at(root, &identity, &poll.process_id, Some(&poll.next_cursor))
.expect("terminate process session");
assert_ne!(terminal.status, "running");
let record = read_process_session_record(root, &poll.process_id)
.expect("read record")
.expect("record exists");
assert_eq!(record.status, "terminated");
assert!(record.output_ref.is_some());
#[cfg(target_os = "linux")]
{
assert_eq!(record.sandbox_backend, "bubblewrap");
assert_eq!(record.sandbox_mode, "workspace-write");
assert_eq!(record.network_access, "disabled");
assert_eq!(record.sandbox_profile_version, "workspace-v1");
}
let transcript = read_agent_runtime_json_sidecar_with_max_bytes::<ProcessSessionTranscript>(
root,
record.output_ref.as_deref().expect("transcript ref"),
"Agent Runtime process transcript",
PROCESS_SESSION_TRANSCRIPT_MAX_BYTES,
)
.expect("read transcript")
.expect("transcript exists");
let transcript_lines = transcript.output.lines().collect::<Vec<_>>();
assert!(
transcript_lines.contains(&"READY"),
"{:?}",
transcript.output
);
assert!(
transcript_lines.contains(&"ECHO:你好"),
"{:?}",
transcript.output
);
#[cfg(unix)]
assert!(
transcript_lines.contains(&"STOPPED"),
"{:?}",
transcript.output
);
assert!(
transcript_lines
.iter()
.any(|line| line.ends_with("BRIDGE_ENV:")),
"{:?}",
transcript.output
);
let record_json =
fs::read_to_string(root.join(process_session_record_relative_path(&record.process_id)))
.expect("read process record json");
for private_marker in [
"GENARRATIVE_PROCESS_SESSION_BRIDGE_ENDPOINT",
"GENARRATIVE_PROCESS_SESSION_BRIDGE_NONCE",
"genarrative-ps-",
"sandbox_ready",
"commit_exec",
"exec_established",
] {
assert!(
!transcript.output.contains(private_marker),
"private marker leaked: {private_marker}: {:?}",
transcript.output
);
assert!(
!record_json.contains(private_marker),
"private marker leaked to record: {private_marker}: {record_json}"
);
}
assert!(!has_active_process_sessions_at(root).expect("terminal process probe"));
clear_process_session_registry_for_tests();
}
#[cfg(target_os = "linux")]
#[test]
fn process_session_graceful_terminate_keeps_wrapper_alive_for_target_cleanup() {
let _guard = process_session_test_guard();
clear_process_session_registry_for_tests();
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "graceful-process-project", "Graceful Process Project")
.expect("initialize project");
let spec = resolve_project_command_spec_at(
root,
"bash",
&[
"-lc".to_string(),
"(trap 'sleep 0.4; printf done > graceful-marker.txt; exit 0' TERM; while :; do sleep 1; done) & printf 'READY\\n'; exit 0".to_string(),
],
".",
30,
)
.expect("resolve graceful command");
let identity = process_identity("graceful-process-project");
let fingerprint = project_command_source_fingerprint(root).expect("fingerprint");
let mut poll =
start_process_session_at(root, identity.clone(), &spec, fingerprint).expect("start");
for _ in 0..20 {
if poll.output.contains("READY") {
break;
}
poll = poll_process_session_at(
root,
&identity,
&poll.process_id,
Some(&poll.next_cursor),
Some(8_000),
Some(250),
)
.expect("poll graceful ready");
}
assert!(poll.output.contains("READY"));
let terminated =
terminate_process_session_at(root, &identity, &poll.process_id, Some(&poll.next_cursor))
.expect("graceful terminate");
assert_eq!(terminated.status, "terminated");
assert!(!terminated.needs_reconciliation);
assert_eq!(
fs::read_to_string(root.join("graceful-marker.txt"))
.expect("target completed delayed SIGTERM cleanup"),
"done"
);
clear_process_session_registry_for_tests();
}
#[cfg(target_os = "linux")]
#[test]
fn process_session_live_registry_blocks_when_durable_record_is_missing() {
let _guard = process_session_test_guard();
clear_process_session_registry_for_tests();
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "live-record-project", "Live Record Project")
.expect("initialize project");
let spec = resolve_project_command_spec_at(
root,
"bash",
&[
"-lc".to_string(),
"printf 'READY\\n'; while :; do sleep 1; done".to_string(),
],
".",
30,
)
.expect("resolve live record command");
let identity = process_identity("live-record-project");
let fingerprint = project_command_source_fingerprint(root).expect("fingerprint");
let started =
start_process_session_at(root, identity.clone(), &spec, fingerprint).expect("start");
fs::remove_file(root.join(process_session_record_relative_path(&started.process_id)))
.expect("remove durable process record");
let active = active_process_session_records_at(
root,
Some(identity.agent_id.as_str()),
Some(identity.run_id.as_str()),
)
.expect("live registry remains authoritative blocker");
assert!(active
.iter()
.any(|record| record.process_id == started.process_id));
assert!(has_active_process_sessions_at(root).expect("live registry blocks idle"));
terminate_process_session_at(root, &identity, &started.process_id, None)
.expect("terminate live record fixture");
clear_process_session_registry_for_tests();
}
#[cfg(target_os = "linux")]
#[test]
fn process_session_fast_exit_zero_and_seven_keep_same_process_id() {
let _guard = process_session_test_guard();
clear_process_session_registry_for_tests();
for exit_code in [0, 7] {
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
let project_id = format!("fast-exit-{exit_code}-project");
init_local_game_project_at(root, &project_id, "Fast Exit Project")
.expect("initialize project");
let spec = resolve_project_command_spec_at(
root,
"bash",
&[
"-lc".to_string(),
format!("printf 'FAST-{exit_code}\\n'; exit {exit_code}"),
],
".",
30,
)
.expect("resolve fast exit command");
let mut identity = process_identity(&project_id);
identity.start_action_id = format!("fast-exit-action-{exit_code}");
identity.start_action_fingerprint = format!("{}", exit_code + 1).repeat(64);
let fingerprint = project_command_source_fingerprint(root).expect("fingerprint");
let started =
start_process_session_at(root, identity.clone(), &spec, fingerprint).expect("start");
let process_id = started.process_id.clone();
assert!(started.output.is_empty());
assert_eq!(started.cursor, started.next_cursor);
assert_eq!(started.sandbox_establishment, "established");
assert_eq!(started.target_exec, "established");
let mut poll = started;
let mut output = String::new();
for _ in 0..30 {
poll = poll_process_session_at(
root,
&identity,
&process_id,
Some(&poll.next_cursor),
Some(8_000),
Some(250),
)
.expect("poll fast exit");
assert_eq!(poll.process_id, process_id);
output.push_str(&poll.output);
if poll.status != "running" && !poll.has_more {
break;
}
}
assert_eq!(poll.status, "exited", "{output}");
assert_eq!(poll.exit_code, Some(exit_code), "{output}");
assert!(output.contains(&format!("FAST-{exit_code}")), "{output}");
let record = read_process_session_record(root, &process_id)
.expect("read fast exit record")
.expect("fast exit record exists");
assert_eq!(record.process_id, process_id);
assert_eq!(record.target_exec, "established");
assert!(record.exec_established_at.is_some());
clear_process_session_registry_for_tests();
}
}
#[cfg(target_os = "linux")]
#[test]
fn process_session_durable_commit_failure_runs_no_target_and_writes_no_record() {
let _guard = process_session_test_guard();
clear_process_session_registry_for_tests();
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "durable-failure-project", "Durable Failure Project")
.expect("initialize project");
let spec = resolve_project_command_spec_at(
root,
"bash",
&[
"-lc".to_string(),
"printf ran > durable-target-ran.txt".to_string(),
],
".",
30,
)
.expect("resolve durable failure command");
let launch = prepare_project_command_launch_spec(root, &spec).expect("prepare launch");
let identity = process_identity("durable-failure-project");
let fingerprint = project_command_source_fingerprint(root).expect("fingerprint");
let error = start_prepared_process_session_at(
root,
identity.clone(),
&spec,
&launch,
fingerprint,
|| {
assert!(!root.join("durable-target-ran.txt").exists());
assert!(find_existing_start_action_record(root, &identity)
.expect("record absent inside durable callback")
.is_none());
Err("forced durable commit failure".to_string())
},
)
.expect_err("durable commit must fail");
assert_eq!(error.stage(), ProjectCommandErrorStage::DurableCommit);
assert!(!root.join("durable-target-ran.txt").exists());
assert!(find_existing_start_action_record(root, &identity)
.expect("search process record")
.is_none());
assert!(!has_active_process_sessions_at(root).expect("no pending launch"));
clear_process_session_registry_for_tests();
}
#[cfg(target_os = "linux")]
#[test]
fn process_session_slow_durable_commit_keeps_target_blocked_until_commit() {
let _guard = process_session_test_guard();
clear_process_session_registry_for_tests();
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "slow-commit-project", "Slow Commit Project")
.expect("initialize project");
let spec = resolve_project_command_spec_at(
root,
"bash",
&[
"-lc".to_string(),
"printf committed > slow-commit-target.txt".to_string(),
],
".",
30,
)
.expect("resolve slow commit command");
let launch = prepare_project_command_launch_spec(root, &spec).expect("prepare launch");
let identity = process_identity("slow-commit-project");
let fingerprint = project_command_source_fingerprint(root).expect("fingerprint");
let started_at = std::time::Instant::now();
let result = start_prepared_process_session_at(
root,
identity.clone(),
&spec,
&launch,
fingerprint,
|| {
thread::sleep(Duration::from_millis(3_200));
assert!(!root.join("slow-commit-target.txt").exists());
Ok(())
},
)
.expect("slow durable commit must not time out in child");
assert!(started_at.elapsed() >= Duration::from_millis(3_200));
assert_eq!(result.sandbox_establishment, "established");
assert_eq!(result.target_exec, "established");
let mut poll = result;
for _ in 0..20 {
poll = poll_process_session_at(
root,
&identity,
&poll.process_id,
Some(&poll.next_cursor),
Some(8_000),
Some(250),
)
.expect("poll slow commit target");
if poll.status != "running" {
break;
}
}
assert_eq!(poll.status, "exited");
assert_eq!(
fs::read_to_string(root.join("slow-commit-target.txt")).expect("read slow commit marker"),
"committed"
);
clear_process_session_registry_for_tests();
}
#[cfg(windows)]
#[test]
fn process_session_windows_replay_runs_durable_commit_only_once() {
let _guard = process_session_test_guard();
clear_process_session_registry_for_tests();
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "windows-replay-project", "Windows Replay Project")
.expect("initialize project");
fs::write(
root.join("package.json"),
r#"{"scripts":{"dev":"node fixture.js"}}"#,
)
.expect("write package.json");
fs::write(root.join("fixture.js"), "setInterval(() => {}, 1000);\n").expect("write fixture");
let spec = resolve_project_command_spec_at(
root,
"npm",
&["run".to_string(), "dev".to_string()],
".",
30,
)
.expect("resolve npm command");
let launch = prepare_project_command_launch_spec(root, &spec).expect("prepare launch");
let identity = process_identity("windows-replay-project");
let fingerprint = project_command_source_fingerprint(root).expect("fingerprint");
let commit_count = std::sync::atomic::AtomicUsize::new(0);
let first = start_prepared_process_session_at(
root,
identity.clone(),
&spec,
&launch,
fingerprint.clone(),
|| {
commit_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(())
},
)
.expect("first start");
let second = start_prepared_process_session_at(
root,
identity.clone(),
&spec,
&launch,
fingerprint,
|| {
commit_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(())
},
)
.expect("idempotent replay");
assert_eq!(first.process_id, second.process_id);
assert_eq!(commit_count.load(std::sync::atomic::Ordering::SeqCst), 1);
let record = read_process_session_record(root, &first.process_id)
.expect("read Windows replay record")
.expect("Windows replay record exists");
assert_eq!(record.sandbox_ready_at, Some(record.started_at));
assert_eq!(record.exec_established_at, Some(record.started_at));
terminate_process_session_at(root, &identity, &first.process_id, None)
.expect("terminate replay fixture");
clear_process_session_registry_for_tests();
}
#[cfg(target_os = "linux")]
#[test]
fn process_session_target_exec_failure_is_known_terminal_record() {
let _guard = process_session_test_guard();
clear_process_session_registry_for_tests();
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "target-exec-failure-project", "Target Exec Failure")
.expect("initialize project");
let mut spec = resolve_project_command_spec_at(
root,
"bash",
&["-lc".to_string(), "exit 0".to_string()],
".",
30,
)
.expect("resolve command");
let launch = prepare_project_command_launch_spec(root, &spec).expect("prepare launch");
spec.executable = PathBuf::from("/definitely-missing-genarrative-target");
let identity = process_identity("target-exec-failure-project");
let fingerprint = project_command_source_fingerprint(root).expect("fingerprint");
let committed = std::sync::atomic::AtomicBool::new(false);
let result =
start_prepared_process_session_at(root, identity, &spec, &launch, fingerprint, || {
committed.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(())
})
.expect("target exec failure is a known result");
assert!(committed.load(std::sync::atomic::Ordering::SeqCst));
assert_eq!(result.status, "failed");
assert_eq!(result.sandbox_establishment, "established");
assert_eq!(result.target_exec, "failed");
assert_eq!(
result.launch_failure_kind.as_deref(),
Some("target-exec-failed")
);
assert!(!result.needs_reconciliation);
assert_eq!(result.cursor, result.next_cursor);
assert!(!has_active_process_sessions_at(root).expect("known target failure is terminal"));
clear_process_session_registry_for_tests();
}
#[cfg(target_os = "linux")]
#[test]
fn process_session_start_audit_failure_terminates_and_persists_reconciliation() {
let _guard = process_session_test_guard();
clear_process_session_registry_for_tests();
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "start-audit-project", "Start Audit Project")
.expect("initialize project");
let spec = resolve_project_command_spec_at(
root,
"bash",
&["-lc".to_string(), "cat >/dev/null".to_string()],
".",
30,
)
.expect("resolve command");
let identity = process_identity("start-audit-project");
let fingerprint = project_command_source_fingerprint(root).expect("fingerprint");
let started = start_process_session_at(root, identity, &spec, fingerprint).expect("start");
mark_process_session_start_audit_failure_at(
root,
&started.process_id,
"forced agent db failure",
)
.expect("mark start audit reconciliation");
let mut record = None;
for _ in 0..30 {
let current = read_process_session_record(root, &started.process_id)
.expect("read audit failure record")
.expect("audit failure record exists");
if current.status == "needs-reconciliation"
&& current.launch_failure_kind.as_deref() == Some("start-audit-failed")
{
record = Some(current);
break;
}
thread::sleep(Duration::from_millis(50));
}
let record = record.expect("audit failure reconciliation persisted");
assert!(record.needs_reconciliation);
assert!(!record.stdin_open);
assert_eq!(record.target_exec, "established");
assert!(active_process_session_records_at(root, None, None)
.expect("audit failure blocks completion")
.iter()
.any(|value| value.process_id == started.process_id));
clear_process_session_registry_for_tests();
}
#[cfg(target_os = "linux")]
#[test]
fn process_session_descendants_inherit_workspace_sandbox() {
let _guard = process_session_test_guard();
clear_process_session_registry_for_tests();
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path().join("workspace");
let outside = directory.path().join("outside-secret.txt");
init_local_game_project_at(&root, "process-sandbox-project", "Process Sandbox Project")
.expect("initialize project");
fs::write(&outside, "OUTSIDE_SECRET").expect("write outside secret");
fs::create_dir_all(root.join(".git")).expect("create git control directory");
fs::write(root.join(".git/marker"), "git").expect("write git marker");
let outside_literal = outside.to_string_lossy().replace('"', "\\\"");
let script = format!(
r#"set -u
if cat "{outside_literal}" >/dev/null 2>&1; then echo OUTSIDE_VISIBLE; else echo OUTSIDE_BLOCKED; fi
if (printf no > .git/blocked-write) 2>/dev/null; then echo GIT_WRITABLE; else echo GIT_BLOCKED; fi
if cat .agent/manifest.json >/dev/null 2>&1; then echo AGENT_VISIBLE; else echo AGENT_HIDDEN; fi
/usr/bin/setsid /bin/bash -lc 'cd /tmp; if test -e "{outside_literal}"; then echo DESCENDANT_VISIBLE; else echo DESCENDANT_BLOCKED; fi'
/usr/bin/python3 - <<'PY'
import socket
s = socket.socket()
s.settimeout(0.2)
try:
s.connect(("1.1.1.1", 53))
print("NETWORK_VISIBLE")
except OSError:
print("NETWORK_BLOCKED")
finally:
s.close()
PY
"#
);
fs::write(root.join("sandbox-probe.sh"), script).expect("write sandbox probe");
let spec =
resolve_project_command_spec_at(&root, "bash", &["sandbox-probe.sh".to_string()], ".", 30)
.expect("resolve sandbox probe");
let identity = process_identity("process-sandbox-project");
let fingerprint = project_command_source_fingerprint(&root).expect("source fingerprint");
let mut poll = start_process_session_at(&root, identity.clone(), &spec, fingerprint)
.expect("start sandbox probe");
let mut output = poll.output.clone();
for _ in 0..30 {
if poll.status != "running" && !poll.has_more {
break;
}
poll = poll_process_session_at(
&root,
&identity,
&poll.process_id,
Some(&poll.next_cursor),
Some(8_000),
Some(250),
)
.expect("poll sandbox probe");
output.push_str(&poll.output);
}
assert_eq!(poll.status, "exited", "{output}");
for marker in [
"OUTSIDE_BLOCKED",
"GIT_BLOCKED",
"AGENT_HIDDEN",
"DESCENDANT_BLOCKED",
"NETWORK_BLOCKED",
] {
assert!(output.contains(marker), "missing {marker}: {output}");
}
for marker in [
"OUTSIDE_VISIBLE",
"GIT_WRITABLE",
"AGENT_VISIBLE",
"DESCENDANT_VISIBLE",
"NETWORK_VISIBLE",
] {
assert!(!output.contains(marker), "unexpected {marker}: {output}");
}
assert_eq!(poll.sandbox_backend, "bubblewrap");
assert_eq!(poll.sandbox_mode, "workspace-write");
assert_eq!(poll.network_access, "disabled");
clear_process_session_registry_for_tests();
}
#[test]
fn process_session_runner_shutdown_reaps_active_session() {
let _guard = process_session_test_guard();
clear_process_session_registry_for_tests();
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "process-shutdown-project", "Process Shutdown Project")
.expect("initialize project");
fs::write(
root.join("package.json"),
r#"{"scripts":{"dev":"node fixture.js"}}"#,
)
.expect("write package.json");
fs::write(
root.join("fixture.js"),
r#"
console.log('READY');
setInterval(() => {}, 1000);
"#,
)
.expect("write fixture");
let spec = resolve_project_command_spec_at(
root,
"npm",
&["run".to_string(), "dev".to_string()],
".",
30,
)
.expect("resolve npm command");
let identity = process_identity("process-shutdown-project");
let source_fingerprint = project_command_source_fingerprint(root).expect("source fingerprint");
let started = start_process_session_at(root, identity.clone(), &spec, source_fingerprint)
.expect("start process session");
assert!(has_active_process_sessions_at(root).expect("active process probe"));
let mut cursor = None;
let mut output = String::new();
for _ in 0..40 {
let poll = poll_process_session_at(
root,
&identity,
&started.process_id,
cursor.as_deref(),
Some(2_000),
Some(500),
)
.expect("observe shutdown fixture");
output.push_str(&poll.output);
cursor = Some(poll.next_cursor);
if output.contains("READY") {
break;
}
}
assert!(output.contains("READY"), "{output}");
shutdown_all_process_sessions_and_wait(Duration::from_secs(3))
.expect("shutdown active process sessions");
let terminal = poll_process_session_at(
root,
&identity,
&started.process_id,
None,
Some(8_000),
Some(0),
)
.expect("poll shutdown terminal state");
assert_eq!(terminal.status, "terminated");
assert_eq!(terminal.signal.as_deref(), Some("runner-shutdown"));
assert!(live_process_session(&started.process_id)
.expect("inspect terminal registry")
.is_none());
assert!(!has_active_process_sessions_at(root).expect("terminal process probe"));
clear_process_session_registry_for_tests();
}
#[test]
fn process_session_terminal_reconciliation_still_blocks_completion() {
let _guard = process_session_test_guard();
clear_process_session_registry_for_tests();
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "process-reconciliation-project", "Process Project")
.expect("initialize project");
fs::write(
root.join("package.json"),
r#"{"scripts":{"dev":"node fixture.js"}}"#,
)
.expect("write package.json");
fs::write(root.join("fixture.js"), "setInterval(() => {}, 1000);\n").expect("write fixture");
let process_id = "proc-0123456789abcdef0123456789abcdef";
let now = unix_timestamp();
let mut record = ProcessSessionRecord {
schema_version: PROCESS_SESSION_SCHEMA_VERSION.to_string(),
project_id: "process-reconciliation-project".to_string(),
agent_id: "code-prototype".to_string(),
task_id: "code-prototype".to_string(),
conversation_session_id: "session-process-test".to_string(),
run_id: "run-process-test".to_string(),
start_action_id: "action-process-start-test".to_string(),
start_action_fingerprint: "a".repeat(64),
process_id: process_id.to_string(),
owner_boot_id: process_session_boot_id().to_string(),
command_id: "cmd-process-test".to_string(),
program: "npm".to_string(),
cwd: ".".to_string(),
sandbox_backend: "test-unknown".to_string(),
sandbox_mode: "unknown".to_string(),
network_access: "unknown".to_string(),
sandbox_profile_version: "test-v1".to_string(),
sandbox_establishment: "unknown".to_string(),
target_exec: "unknown".to_string(),
launch_failure_kind: Some("launch-unknown".to_string()),
sandbox_ready_at: None,
exec_established_at: None,
status: "needs-reconciliation".to_string(),
exit_code: None,
signal: Some("output-read-failed".to_string()),
stdin_open: false,
output_bytes: 0,
output_sha256: format!("{:x}", Sha256::digest([])),
output_ref: None,
source_fingerprint_before: "b".repeat(64),
source_fingerprint_after: None,
source_changed: None,
needs_reconciliation: true,
started_at: now,
terminal_at: Some(now),
updated_at: now,
};
write_process_session_record(root, &record).expect("write reconciliation record");
let active =
active_process_session_records_at(root, Some("code-prototype"), Some("run-process-test"))
.expect("read reconciliation blockers");
assert_eq!(active.len(), 1);
assert_eq!(active[0].process_id, process_id);
let spec = resolve_project_command_spec_at(
root,
"npm",
&["run".to_string(), "dev".to_string()],
".",
30,
)
.expect("resolve blocked command");
let mut blocked_identity = process_identity("process-reconciliation-project");
blocked_identity.run_id = "run-process-blocked-test".to_string();
blocked_identity.start_action_id = "action-process-blocked-start".to_string();
let blocked = validate_process_session_start_preflight_at(root, &blocked_identity, &spec)
.expect_err("reconciliation must block a new process session");
assert!(blocked.contains(process_id));
record.needs_reconciliation = false;
write_process_session_record(root, &record).expect("write invalid reconciliation record");
assert!(active_process_session_records_at(
root,
Some("code-prototype"),
Some("run-process-test")
)
.expect_err("invalid reconciliation record must fail closed")
.contains("可信 launch 状态组合无效"));
clear_process_session_registry_for_tests();
}
#[test]
fn process_session_capacity_preflight_counts_durable_records() {
let _guard = process_session_test_guard();
clear_process_session_registry_for_tests();
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "process-capacity-project", "Process Capacity Project")
.expect("initialize project");
fs::write(
root.join("package.json"),
r#"{"scripts":{"dev":"node fixture.js"}}"#,
)
.expect("write package.json");
fs::write(root.join("fixture.js"), "setInterval(() => {}, 1000);\n").expect("write fixture");
let spec = resolve_project_command_spec_at(
root,
"npm",
&["run".to_string(), "dev".to_string()],
".",
30,
)
.expect("resolve command");
for (index, process_id) in [
"proc-11111111111111111111111111111111",
"proc-22222222222222222222222222222222",
]
.into_iter()
.enumerate()
{
let mut identity = process_identity("process-capacity-project");
identity.run_id = format!("run-process-capacity-{index}");
identity.start_action_id = format!("action-process-capacity-{index}");
identity.start_action_fingerprint = format!("{}", index + 1).repeat(64);
let mut record = initial_process_session_record(
&identity,
process_id,
&format!("cmd-process-capacity-{index}"),
&spec,
None,
&"f".repeat(64),
"running",
);
record.sandbox_establishment = "established".to_string();
record.target_exec = "established".to_string();
record.sandbox_ready_at = Some(record.started_at);
record.exec_established_at = Some(record.started_at);
write_process_session_record(root, &record).expect("write durable running record");
}
let mut blocked_identity = process_identity("process-capacity-project");
blocked_identity.run_id = "run-process-capacity-blocked".to_string();
blocked_identity.start_action_id = "action-process-capacity-blocked".to_string();
let error = validate_process_session_start_preflight_at(root, &blocked_identity, &spec)
.expect_err("durable records must count toward Agent capacity");
assert!(error.contains("最多同时运行 2 个"), "{error}");
clear_process_session_registry_for_tests();
}
#[test]
fn process_session_real_pty_eof_reaches_terminal() {
let _guard = process_session_test_guard();
clear_process_session_registry_for_tests();
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "process-eof-project", "Process EOF Project")
.expect("initialize project");
fs::write(
root.join("package.json"),
r#"{"scripts":{"dev":"node fixture.js"}}"#,
)
.expect("write package.json");
fs::write(
root.join("fixture.js"),
r#"
process.stdin.setEncoding('utf8');
console.log('READY');
process.stdin.on('end', () => { console.log('EOF'); process.exit(0); });
process.stdin.resume();
"#,
)
.expect("write fixture");
let spec = resolve_project_command_spec_at(
root,
"npm",
&["run".to_string(), "dev".to_string()],
".",
30,
)
.expect("resolve npm command");
let mut identity = process_identity("process-eof-project");
identity.run_id = "run-process-eof-test".to_string();
identity.start_action_id = "action-process-eof-start".to_string();
identity.start_action_fingerprint = "c".repeat(64);
let fingerprint = project_command_source_fingerprint(root).expect("source fingerprint");
let mut poll =
start_process_session_at(root, identity.clone(), &spec, fingerprint).expect("start");
for _ in 0..20 {
if poll.output.contains("READY") {
break;
}
poll = poll_process_session_at(
root,
&identity,
&poll.process_id,
Some(&poll.next_cursor),
Some(8_000),
Some(500),
)
.expect("poll ready");
}
let eof = write_process_session_stdin_at(root, &identity, &poll.process_id, "", false, true)
.expect("close stdin");
assert!(eof.eof);
assert!(!eof.stdin_open);
let mut tail = String::new();
for _ in 0..20 {
poll = poll_process_session_at(
root,
&identity,
&poll.process_id,
Some(&poll.next_cursor),
Some(8_000),
Some(500),
)
.expect("poll eof");
tail.push_str(&poll.output);
if poll.status != "running" {
break;
}
}
assert_eq!(poll.status, "exited", "tail: {tail}");
#[cfg(not(windows))]
assert!(tail.contains("EOF"), "tail: {tail}");
// Closing a ConPTY input pipe closes the attached Windows console. Unlike a Unix PTY,
// Node's console stdin does not emit its stream-level `end` callback before that terminal
// close, so the portable contract here is the trusted `exited` terminal state above.
clear_process_session_registry_for_tests();
}
#[test]
fn process_session_stdin_accepts_trusted_terminal_race_after_successful_eof() {
let _guard = process_session_test_guard();
clear_process_session_registry_for_tests();
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "stdin-race-project", "Stdin Race Project")
.expect("initialize project");
fs::write(
root.join("package.json"),
r#"{"scripts":{"dev":"node fixture.js"}}"#,
)
.expect("write package.json");
fs::write(
root.join("fixture.js"),
"process.stdout.write('READY\\n'); setInterval(() => {}, 1000);\n",
)
.expect("write fixture");
let spec = resolve_project_command_spec_at(
root,
"npm",
&["run".to_string(), "dev".to_string()],
".",
30,
)
.expect("resolve stdin race command");
let identity = process_identity("stdin-race-project");
let fingerprint = project_command_source_fingerprint(root).expect("fingerprint");
let started =
start_process_session_at(root, identity.clone(), &spec, fingerprint).expect("start");
let result = write_process_session_stdin_at_with_after_write(
root,
&identity,
&started.process_id,
"",
false,
true,
|live| {
let mut output = live.output.lock().expect("lock terminal race output");
output.status = "exited".to_string();
output.exit_code = Some(0);
output.stdin_open = false;
},
)
.expect("successful EOF remains successful after trusted terminal wins race");
assert!(result.eof);
assert!(!result.stdin_open);
let record = read_process_session_record(root, &started.process_id)
.expect("read terminal race record")
.expect("terminal race record exists");
assert_eq!(record.status, "exited");
assert!(!record.needs_reconciliation);
clear_process_session_registry_for_tests();
}
#[test]
fn process_session_overlong_unterminated_line_is_stopped() {
let _guard = process_session_test_guard();
clear_process_session_registry_for_tests();
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "process-output-project", "Process Output Project")
.expect("initialize project");
fs::write(
root.join("package.json"),
r#"{"scripts":{"dev":"node fixture.js"}}"#,
)
.expect("write package.json");
fs::write(
root.join("fixture.js"),
"process.stdout.write('x'.repeat(20000)); setInterval(() => {}, 1000);\n",
)
.expect("write fixture");
let spec = resolve_project_command_spec_at(
root,
"npm",
&["run".to_string(), "dev".to_string()],
".",
30,
)
.expect("resolve npm command");
let mut identity = process_identity("process-output-project");
identity.run_id = "run-process-output-test".to_string();
identity.start_action_id = "action-process-output-start".to_string();
identity.start_action_fingerprint = "d".repeat(64);
let fingerprint = project_command_source_fingerprint(root).expect("source fingerprint");
let mut poll =
start_process_session_at(root, identity.clone(), &spec, fingerprint).expect("start");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while std::time::Instant::now() < deadline {
if poll.status != "running" {
break;
}
poll = poll_process_session_at(
root,
&identity,
&poll.process_id,
Some(&poll.next_cursor),
Some(8_000),
Some(100),
)
.expect("poll output limit");
}
assert_eq!(poll.status, "output-limit-exceeded");
clear_process_session_registry_for_tests();
}
#[test]
fn process_session_old_boot_becomes_reconciliation_without_relaunch() {
let _guard = process_session_test_guard();
clear_process_session_registry_for_tests();
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "stale-process-project", "Stale Process Project")
.expect("initialize project");
let identity = process_identity("stale-process-project");
let process_id = "proc-fedcba9876543210fedcba9876543210";
let mut record = ProcessSessionRecord {
schema_version: PROCESS_SESSION_SCHEMA_VERSION.to_string(),
project_id: identity.project_id.clone(),
agent_id: identity.agent_id.clone(),
task_id: identity.task_id.clone(),
conversation_session_id: identity.conversation_session_id.clone(),
run_id: identity.run_id.clone(),
start_action_id: identity.start_action_id.clone(),
start_action_fingerprint: identity.start_action_fingerprint.clone(),
process_id: process_id.to_string(),
owner_boot_id: "old-runner-boot".to_string(),
command_id: "cmd-stale".to_string(),
program: "npm".to_string(),
cwd: ".".to_string(),
sandbox_backend: "bubblewrap".to_string(),
sandbox_mode: "workspace-write".to_string(),
network_access: "disabled".to_string(),
sandbox_profile_version: "workspace-v1".to_string(),
sandbox_establishment: "established".to_string(),
target_exec: "established".to_string(),
launch_failure_kind: None,
sandbox_ready_at: Some(unix_timestamp()),
exec_established_at: Some(unix_timestamp()),
status: "running".to_string(),
exit_code: None,
signal: None,
stdin_open: true,
output_bytes: 0,
output_sha256: format!("{:x}", Sha256::digest([])),
output_ref: None,
source_fingerprint_before: "b".repeat(64),
source_fingerprint_after: None,
source_changed: None,
needs_reconciliation: false,
started_at: unix_timestamp(),
terminal_at: None,
updated_at: unix_timestamp(),
};
write_process_session_record(root, &record).expect("write stale record");
let poll = poll_process_session_at(root, &identity, process_id, None, Some(10), Some(0))
.expect("reconcile stale record");
assert_eq!(poll.status, "needs-reconciliation");
assert!(poll.needs_reconciliation);
record = read_process_session_record(root, process_id)
.expect("read reconciled record")
.expect("record exists");
assert_eq!(record.status, "needs-reconciliation");
assert!(record.needs_reconciliation);
let launching_process_id = "proc-abcdefabcdefabcdefabcdefabcdefab";
let spec = resolve_project_command_spec_at(
root,
"npm",
&["run".to_string(), "dev".to_string()],
".",
30,
)
.expect("resolve stale launching command");
let mut launching = initial_process_session_record(
&identity,
launching_process_id,
"cmd-stale-launching",
&spec,
None,
&"d".repeat(64),
"launching",
);
launching.owner_boot_id = "old-launching-boot".to_string();
launching.sandbox_establishment = "established".to_string();
launching.sandbox_ready_at = Some(launching.started_at);
write_process_session_record(root, &launching).expect("write stale launching record");
let launching_poll = poll_process_session_at(
root,
&identity,
launching_process_id,
None,
Some(10),
Some(0),
)
.expect("reconcile stale launching record");
assert_eq!(launching_poll.status, "needs-reconciliation");
assert_eq!(launching_poll.target_exec, "unknown");
assert_eq!(
launching_poll.launch_failure_kind.as_deref(),
Some("launch-unknown")
);
assert!(launching_poll.needs_reconciliation);
}
#[cfg(target_os = "linux")]
#[test]
fn process_session_start_replay_reconciles_old_launching_record_once() {
let _guard = process_session_test_guard();
clear_process_session_registry_for_tests();
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "stale-replay-project", "Stale Replay Project")
.expect("initialize project");
let identity = process_identity("stale-replay-project");
let spec = resolve_project_command_spec_at(
root,
"bash",
&["-lc".to_string(), "exit 0".to_string()],
".",
30,
)
.expect("resolve stale replay command");
let launch = prepare_project_command_launch_spec(root, &spec).expect("prepare launch");
let process_id = process_session_id(&identity);
let mut record = initial_process_session_record(
&identity,
&process_id,
"cmd-stale-replay",
&spec,
Some(&launch),
&"e".repeat(64),
"launching",
);
record.owner_boot_id = "old-replay-boot".to_string();
record.sandbox_establishment = "established".to_string();
record.sandbox_ready_at = Some(record.started_at);
write_process_session_record(root, &record).expect("write stale replay record");
let callback_called = std::sync::atomic::AtomicBool::new(false);
let error =
start_prepared_process_session_at(root, identity, &spec, &launch, "e".repeat(64), || {
callback_called.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(())
})
.expect_err("old launching replay must reconcile without relaunch");
assert_eq!(error.stage(), ProjectCommandErrorStage::LaunchUnknown);
assert!(!callback_called.load(std::sync::atomic::Ordering::SeqCst));
let reconciled = read_process_session_record(root, &process_id)
.expect("read replay reconciliation")
.expect("replay record exists");
assert_eq!(reconciled.status, "needs-reconciliation");
assert_eq!(reconciled.target_exec, "unknown");
assert!(reconciled.exec_established_at.is_none());
assert_eq!(
reconciled.launch_failure_kind.as_deref(),
Some("launch-unknown")
);
assert!(reconciled.needs_reconciliation);
clear_process_session_registry_for_tests();
}
#[cfg(target_os = "linux")]
#[test]
fn process_session_child_wrapper_fixture() {
if std::env::var_os(PROCESS_SESSION_BRIDGE_ENDPOINT_ENV).is_none() {
return;
}
let args = vec![PROCESS_SESSION_CHILD_MODE.to_string()];
match run_process_session_child(&args) {
Ok(exit_code) => std::process::exit(exit_code),
Err(error) => panic!("process session child wrapper failed: {error}"),
}
}
#[test]
fn process_session_runner_owner_fixture() {
let Some(root) = std::env::var_os("GENARRATIVE_PROCESS_SESSION_OWNER_FIXTURE_ROOT") else {
return;
};
let root = PathBuf::from(root);
let spec = resolve_project_command_spec_at(
&root,
"npm",
&["run".to_string(), "dev".to_string()],
".",
300,
)
.expect("resolve owner fixture command");
let identity = process_identity("owner-process-project");
let source_fingerprint =
project_command_source_fingerprint(&root).expect("owner fixture fingerprint");
let poll = start_process_session_at(&root, identity, &spec, source_fingerprint)
.expect("start owner fixture process");
fs::write(root.join("owner-ready"), poll.process_id).expect("write owner ready");
loop {
thread::sleep(Duration::from_secs(1));
}
}
#[cfg(target_os = "linux")]
#[test]
fn process_session_owner_sigkill_leaves_no_child_process() {
fn project_processes(root: &Path) -> Vec<i32> {
let canonical_root = fs::canonicalize(root).expect("canonical test project");
fs::read_dir("/proc")
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| {
let process_id = entry.file_name().to_string_lossy().parse::<i32>().ok()?;
let cwd = fs::read_link(entry.path().join("cwd")).ok()?;
(cwd == canonical_root).then_some(process_id)
})
.collect()
}
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "owner-process-project", "Owner Process Project")
.expect("initialize project");
fs::write(
root.join("package.json"),
r#"{"scripts":{"dev":"node owner-fixture.js"}}"#,
)
.expect("write package.json");
fs::write(
root.join("owner-fixture.js"),
r#"
process.on('SIGHUP', () => {});
require('fs').writeFileSync('child.pid', String(process.pid));
setInterval(() => {}, 1000);
"#,
)
.expect("write fixture");
let current_exe = std::env::current_exe().expect("current test binary");
let mut owner = std::process::Command::new(current_exe)
.arg("--exact")
.arg("process_session::tests::process_session_runner_owner_fixture")
.arg("--nocapture")
.env("GENARRATIVE_PROCESS_SESSION_OWNER_FIXTURE_ROOT", root)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn owner fixture test process");
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while (!root.join("owner-ready").is_file() || project_processes(root).is_empty())
&& std::time::Instant::now() < deadline
{
thread::sleep(Duration::from_millis(25));
}
let sandbox_processes = project_processes(root);
assert!(
!sandbox_processes.is_empty(),
"sandbox child should be visible from host /proc"
);
let owner_pid = i32::try_from(owner.id()).expect("owner pid");
assert_eq!(unsafe { libc::kill(owner_pid, libc::SIGKILL) }, 0);
owner.wait().expect("reap owner fixture");
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let remaining = project_processes(root);
if remaining.is_empty() {
break;
}
assert!(
std::time::Instant::now() < deadline,
"Runner owner SIGKILL 后 sandbox 子进程仍存在:pids={remaining:?}"
);
thread::sleep(Duration::from_millis(25));
}
}