cc6211fd41
## 背景 这是一个独立的 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>
1807 lines
66 KiB
Rust
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));
|
|
}
|
|
}
|