From 86b598b431c33391e6a149c8acae90a02a5e13a2 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 21 Apr 2026 23:34:25 +0300 Subject: [PATCH] ipc: support pakker.json; fall back to dir-path hash when parentLockHash absent Signed-off-by: NotAShelf Change-Id: I48d58165d306901dfaeb233310ae93846a6a6964 --- src/ipc.rs | 207 ++++++++++++++++++++++++----------------------------- 1 file changed, 95 insertions(+), 112 deletions(-) diff --git a/src/ipc.rs b/src/ipc.rs index f2c80d9..646e1b9 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -106,42 +106,51 @@ impl IpcCoordinator { } /// Extract modpack hash from pakku.json's parentLockHash field. - /// This is the authoritative content hash for the modpack (Nix-style). + /// This is the authoritative content hash for the modpack. If you've used Nix + /// the model might seem familiar. fn get_modpack_hash(working_dir: &Path) -> Result { + use sha2::{Digest, Sha256}; + + let pakker_path = working_dir.join("pakker.json"); let pakku_path = working_dir.join("pakku.json"); - if !pakku_path.exists() { + let config_path = if pakker_path.exists() { + pakker_path + } else if pakku_path.exists() { + pakku_path + } else { return Err(IpcError::PakkuJsonReadFailed( "pakku.json not found in working directory".to_string(), )); - } + }; - let content = fs::read_to_string(&pakku_path) + let content = fs::read_to_string(&config_path) .map_err(|e| IpcError::PakkuJsonReadFailed(e.to_string()))?; - // Parse pakku.json and extract parentLockHash + // Parse config and try to extract parentLockHash (fork modpacks have this) let pakku: serde_json::Value = serde_json::from_str(&content) .map_err(|e| IpcError::PakkuJsonReadFailed(e.to_string()))?; - let hash = pakku + let candidate = pakku .get("pakku") .and_then(|p| p.get("parentLockHash")) .and_then(|v| v.as_str()) - .ok_or_else(|| { - IpcError::PakkuJsonReadFailed( - "parentLockHash not found in pakku.json".to_string(), - ) - })? - .to_string(); + .map(std::string::ToString::to_string); - // Validate it's a valid hex string (SHA256 = 64 chars) - if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) { - return Err(IpcError::PakkuJsonReadFailed( - "parentLockHash is not a valid SHA256 hash".to_string(), - )); + if let Some(hash) = candidate { + // Validate it's a valid hex string (SHA256 = 64 chars) + if hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) { + return Ok(hash); + } } - Ok(hash) + // Hash the working directory path for non-fork modpacks as a fallback. + let dir_str = working_dir.to_string_lossy(); + let mut hasher = Sha256::new(); + hasher.update(dir_str.as_bytes()); + Ok(crate::utils::hash::hash_to_hex( + hasher.finalize().as_slice(), + )) } /// Create a new IPC coordinator for the given modpack directory. @@ -470,6 +479,7 @@ impl Drop for OperationGuard { #[cfg(test)] mod tests { + #![expect(clippy::expect_used, reason = "Fine in tests")] use std::fs; use tempfile::TempDir; @@ -495,7 +505,7 @@ mod tests { // Write pakku.json with unique parentLockHash (nested under "pakku" key) let pakku_content = - format!(r#"{{"pakku": {{"parentLockHash": "{}"}}}}"#, unique_hash); + format!(r#"{{"pakku": {{"parentLockHash": "{unique_hash}"}}}}"#); fs::write(temp_dir.path().join("pakku.json"), pakku_content).unwrap(); temp_dir @@ -520,7 +530,7 @@ mod tests { // Write pakku.json with specified parentLockHash (nested under "pakku" key) let pakku_content = - format!(r#"{{"pakku": {{"parentLockHash": "{}"}}}}"#, hash); + format!(r#"{{"pakku": {{"parentLockHash": "{hash}"}}}}"#); fs::write(temp_dir.path().join("pakku.json"), pakku_content).unwrap(); temp_dir @@ -539,8 +549,7 @@ mod tests { "cfe85e0e7e7aa0922d30d8faad071e3a4126cb78b5f0f792f191e90a295aa2c7", ); - let hash = - IpcCoordinator::get_modpack_hash(&temp_dir.path().to_path_buf()).unwrap(); + let hash = IpcCoordinator::get_modpack_hash(temp_dir.path()).unwrap(); assert_eq!( hash, "cfe85e0e7e7aa0922d30d8faad071e3a4126cb78b5f0f792f191e90a295aa2c7" @@ -554,13 +563,13 @@ mod tests { .tempdir() .unwrap(); - let result = - IpcCoordinator::get_modpack_hash(&temp_dir.path().to_path_buf()); + let result = IpcCoordinator::get_modpack_hash(temp_dir.path()); assert!(matches!(result, Err(IpcError::PakkuJsonReadFailed(_)))); } #[test] fn test_get_modpack_hash_missing_parent_lock_hash() { + // Non-fork modpacks have no parentLockHash; should fall back to dir hash let temp_dir = tempfile::Builder::new() .prefix("pakker-ipc-test-") .tempdir() @@ -569,13 +578,15 @@ mod tests { fs::write(temp_dir.path().join("pakku.json"), r#"{"other": "field"}"#) .unwrap(); - let result = - IpcCoordinator::get_modpack_hash(&temp_dir.path().to_path_buf()); - assert!(matches!(result, Err(IpcError::PakkuJsonReadFailed(_)))); + let result = IpcCoordinator::get_modpack_hash(temp_dir.path()); + let hash = result.unwrap(); + assert_eq!(hash.len(), 64); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn test_get_modpack_hash_invalid_hash() { + // Root-level parentLockHash (wrong location) falls back to dir hash let temp_dir = tempfile::Builder::new() .prefix("pakker-ipc-test-") .tempdir() @@ -587,9 +598,10 @@ mod tests { ) .unwrap(); - let result = - IpcCoordinator::get_modpack_hash(&temp_dir.path().to_path_buf()); - assert!(matches!(result, Err(IpcError::PakkuJsonReadFailed(_)))); + let result = IpcCoordinator::get_modpack_hash(temp_dir.path()); + let hash = result.unwrap(); + assert_eq!(hash.len(), 64); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); } #[test] @@ -607,8 +619,8 @@ mod tests { shared_hash, ); - let coord1 = IpcCoordinator::new(&temp_dir1.path().to_path_buf()).unwrap(); - let coord2 = IpcCoordinator::new(&temp_dir2.path().to_path_buf()).unwrap(); + let coord1 = IpcCoordinator::new(temp_dir1.path()).unwrap(); + let coord2 = IpcCoordinator::new(temp_dir2.path()).unwrap(); assert_eq!( coord1.ops_file, coord2.ops_file, @@ -624,8 +636,8 @@ mod tests { let temp_dir1 = create_test_modpack(&[("mod1.jar", "content")]); let temp_dir2 = create_test_modpack(&[("mod1.jar", "content")]); - let coord1 = IpcCoordinator::new(&temp_dir1.path().to_path_buf()).unwrap(); - let coord2 = IpcCoordinator::new(&temp_dir2.path().to_path_buf()).unwrap(); + let coord1 = IpcCoordinator::new(temp_dir1.path()).unwrap(); + let coord2 = IpcCoordinator::new(temp_dir2.path()).unwrap(); assert_ne!( coord1.ops_file, coord2.ops_file, @@ -636,8 +648,7 @@ mod tests { #[test] fn test_ipc_coordinator_new_creates_dir() { let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); // Check that the parent directory (ipc_dir) exists assert!(coordinator.ops_file.parent().unwrap().exists()); @@ -646,8 +657,7 @@ mod tests { #[test] fn test_load_operations_empty() { let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); let operations = coordinator.load_operations().unwrap(); assert!(operations.is_empty()); @@ -656,8 +666,7 @@ mod tests { #[test] fn test_register_operation() { let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); let id = coordinator .register_operation(OperationType::Fetch) @@ -670,8 +679,7 @@ mod tests { #[test] fn test_register_multiple_operations_different_types() { let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); let fetch_id = coordinator .register_operation(OperationType::Fetch) @@ -689,8 +697,7 @@ mod tests { #[test] fn test_register_conflicting_operation_same_type() { let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); let _id1 = coordinator .register_operation(OperationType::Fetch) @@ -703,8 +710,7 @@ mod tests { #[test] fn test_complete_operation() { let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); let id = coordinator .register_operation(OperationType::Fetch) @@ -719,8 +725,7 @@ mod tests { #[test] fn test_complete_nonexistent_operation() { let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); let result = coordinator.complete_operation("nonexistent-id"); assert!(matches!(result, Err(IpcError::OperationNotFound(_)))); @@ -729,8 +734,7 @@ mod tests { #[test] fn test_has_running_operation() { let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); assert!(!coordinator.has_running_operation(OperationType::Fetch)); @@ -747,8 +751,7 @@ mod tests { #[test] fn test_get_running_operations() { let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); let operations = coordinator.get_running_operations(OperationType::Fetch); assert!(operations.is_empty()); @@ -764,8 +767,7 @@ mod tests { #[tokio::test] async fn test_wait_for_conflicts_no_conflicts() { let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); let result = coordinator .wait_for_conflicts(OperationType::Fetch, Duration::from_secs(1)) @@ -776,8 +778,7 @@ mod tests { #[tokio::test] async fn test_wait_for_conflicts_with_completion() { let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); // Register an operation let id = coordinator @@ -801,8 +802,7 @@ mod tests { #[tokio::test] async fn test_wait_for_conflicts_timeout() { let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); // Register a long-running operation (we won't complete it) let _id = coordinator @@ -819,8 +819,7 @@ mod tests { #[test] fn test_operation_guard_completes_on_drop() { let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); let id = coordinator .register_operation(OperationType::Fetch) @@ -842,8 +841,7 @@ mod tests { #[test] fn test_operation_guard_manual_complete() { let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); let id = coordinator .register_operation(OperationType::Export) @@ -862,8 +860,7 @@ mod tests { #[test] fn test_stale_operation_cleanup() { let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); // Manually add a stale operation (started 15 minutes ago) let now = SystemTime::now() @@ -896,8 +893,7 @@ mod tests { #[test] fn test_multiple_operations_same_process() { let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); let id1 = coordinator .register_operation(OperationType::Fetch) @@ -929,8 +925,7 @@ mod tests { #[test] fn test_operation_id_format() { let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); let id = coordinator .register_operation(OperationType::Export) @@ -971,8 +966,8 @@ mod tests { fs::write(temp_dir1.path().join("file1.txt"), "content1").unwrap(); fs::write(temp_dir2.path().join("file2.txt"), "content2").unwrap(); - let coord1 = IpcCoordinator::new(&temp_dir1.path().to_path_buf()).unwrap(); - let coord2 = IpcCoordinator::new(&temp_dir2.path().to_path_buf()).unwrap(); + let coord1 = IpcCoordinator::new(temp_dir1.path()).unwrap(); + let coord2 = IpcCoordinator::new(temp_dir2.path()).unwrap(); // Both should point to SAME ops file despite different paths assert_eq!(coord1.ops_file, coord2.ops_file); @@ -982,8 +977,7 @@ mod tests { fn test_corrupted_ops_json_trailing_bracket() { // Test handling of corrupted ops.json with trailing characters let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); // Register an operation to create ops.json let _id = coordinator @@ -992,7 +986,7 @@ mod tests { // Manually corrupt the ops.json by appending extra bracket let ops_content = fs::read_to_string(&coordinator.ops_file).unwrap(); - fs::write(&coordinator.ops_file, format!("{}]", ops_content)).unwrap(); + fs::write(&coordinator.ops_file, format!("{ops_content}]")).unwrap(); // Loading should fail with InvalidFormat error let result = coordinator.load_operations(); @@ -1003,8 +997,7 @@ mod tests { fn test_corrupted_ops_json_invalid_json() { // Test handling of completely invalid JSON let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); // Write invalid JSON to ops.json fs::write(&coordinator.ops_file, "not valid json {[}").unwrap(); @@ -1017,8 +1010,7 @@ mod tests { fn test_corrupted_ops_json_missing_fields() { // Test handling of JSON with missing required fields let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); // Write JSON with missing fields fs::write(&coordinator.ops_file, r#"[{"id": "test"}]"#).unwrap(); @@ -1031,8 +1023,7 @@ mod tests { fn test_empty_ops_file() { // Test handling of empty ops.json file let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); // Create empty ops.json fs::write(&coordinator.ops_file, "").unwrap(); @@ -1045,8 +1036,7 @@ mod tests { fn test_whitespace_only_ops_file() { // Test handling of whitespace-only ops.json let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); fs::write(&coordinator.ops_file, " \n\t \n ").unwrap(); @@ -1074,8 +1064,7 @@ mod tests { }"#; fs::write(temp_dir.path().join("pakku.json"), pakku_content).unwrap(); - let hash = - IpcCoordinator::get_modpack_hash(&temp_dir.path().to_path_buf()).unwrap(); + let hash = IpcCoordinator::get_modpack_hash(temp_dir.path()).unwrap(); assert_eq!( hash, "abc123def456789012345678901234567890abcd123456789012345678901234" @@ -1096,12 +1085,11 @@ mod tests { }"#; fs::write(temp_dir.path().join("pakku.json"), old_format).unwrap(); - let result = - IpcCoordinator::get_modpack_hash(&temp_dir.path().to_path_buf()); - assert!( - matches!(result, Err(IpcError::PakkuJsonReadFailed(_))), - "Old format should be rejected" - ); + // Old format (root-level parentLockHash, not in pakku section) falls back + // to dir-path hash + let hash = IpcCoordinator::get_modpack_hash(temp_dir.path()).unwrap(); + assert_eq!(hash.len(), 64); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); } #[test] @@ -1111,12 +1099,13 @@ mod tests { .tempdir() .unwrap(); + // Too-short hash falls back to dir-path hash let pakku_content = r#"{"pakku": {"parentLockHash": "tooshort"}}"#; fs::write(temp_dir.path().join("pakku.json"), pakku_content).unwrap(); - let result = - IpcCoordinator::get_modpack_hash(&temp_dir.path().to_path_buf()); - assert!(matches!(result, Err(IpcError::PakkuJsonReadFailed(_)))); + let hash = IpcCoordinator::get_modpack_hash(temp_dir.path()).unwrap(); + assert_eq!(hash.len(), 64); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); } #[test] @@ -1126,13 +1115,13 @@ mod tests { .tempdir() .unwrap(); - // 64 chars but not all hex + // 64 chars but not all hex falls back to dir-path hash let pakku_content = r#"{"pakku": {"parentLockHash": "gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg"}}"#; fs::write(temp_dir.path().join("pakku.json"), pakku_content).unwrap(); - let result = - IpcCoordinator::get_modpack_hash(&temp_dir.path().to_path_buf()); - assert!(matches!(result, Err(IpcError::PakkuJsonReadFailed(_)))); + let hash = IpcCoordinator::get_modpack_hash(temp_dir.path()).unwrap(); + assert_eq!(hash.len(), 64); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); } #[test] @@ -1146,8 +1135,7 @@ mod tests { let pakku_content = r#"{"pakku": {"parentLockHash": "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789"}}"#; fs::write(temp_dir.path().join("pakku.json"), pakku_content).unwrap(); - let hash = - IpcCoordinator::get_modpack_hash(&temp_dir.path().to_path_buf()).unwrap(); + let hash = IpcCoordinator::get_modpack_hash(temp_dir.path()).unwrap(); assert_eq!( hash, "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789" @@ -1158,8 +1146,7 @@ mod tests { fn test_concurrent_registration_race_condition() { // Test that file locking prevents race conditions let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); // First registration should succeed let id1 = coordinator @@ -1182,8 +1169,7 @@ mod tests { fn test_file_permissions_ipc_dir() { // Test that IPC directory has correct permissions (700 - owner only) let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); let ipc_dir = coordinator.ops_file.parent().unwrap(); let metadata = fs::metadata(ipc_dir).unwrap(); @@ -1195,8 +1181,7 @@ mod tests { fn test_file_permissions_ops_file() { // Test that ops.json has correct permissions (600 - owner read/write only) let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); // Register operation to create ops.json let _id = coordinator @@ -1215,21 +1200,21 @@ mod tests { let unique_hash = format!( "{:064x}", rand::random::() - ^ (std::time::SystemTime::now() + ^ std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() - .as_nanos() as u128) + .as_nanos() ); let temp_dir = create_test_modpack_with_hash( &[("test.txt", "test")], &unique_hash[..64], ); - let coord1 = IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coord1 = IpcCoordinator::new(temp_dir.path()).unwrap(); let id = coord1.register_operation(OperationType::Fetch).unwrap(); // Create new coordinator instance - let coord2 = IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coord2 = IpcCoordinator::new(temp_dir.path()).unwrap(); let operations = coord2.load_operations().unwrap(); assert_eq!(operations.len(), 1); @@ -1245,8 +1230,7 @@ mod tests { // Test that stale cleanup only removes running operations, not completed // ones let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); let now = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) @@ -1292,8 +1276,7 @@ mod tests { async fn test_wait_for_conflicts_multiple_types() { // Test that wait_for_conflicts only waits for matching operation types let temp_dir = create_test_modpack(&[("test.txt", "test")]); - let coordinator = - IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap(); + let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap(); // Register Export operation (different type) let _export_id = coordinator