-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Expand file tree
/
Copy pathcore_process_tests.rs
More file actions
737 lines (672 loc) · 26.5 KB
/
Copy pathcore_process_tests.rs
File metadata and controls
737 lines (672 loc) · 26.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
use super::{
current_rpc_token, default_core_port, generate_rpc_token, is_expected_port_clash,
is_openhuman_root_body, parse_lsof_pid, parse_netstat_pid, CoreProcessHandle, RecoveryOutcome,
};
use std::sync::{Mutex, MutexGuard, OnceLock};
fn env_lock() -> MutexGuard<'static, ()> {
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
// Recover from poison: when one test panics while holding this lock
// (e.g. an embedded-core readiness timeout under CI load), every
// subsequent test in the suite would otherwise cascade-fail with
// "env lock poisoned" — turning one real flake into three. The lock
// only serializes process-wide env-var mutation; the inner `()`
// carries no state that poisoning could corrupt.
ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
struct EnvGuard {
key: &'static str,
old: Option<String>,
}
impl EnvGuard {
fn set(key: &'static str, value: &str) -> Self {
let old = std::env::var(key).ok();
std::env::set_var(key, value);
Self { key, old }
}
fn unset(key: &'static str) -> Self {
let old = std::env::var(key).ok();
std::env::remove_var(key);
Self { key, old }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.old {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
#[test]
fn default_core_port_env_and_fallback() {
let _env_lock = env_lock();
let _unset = EnvGuard::unset("OPENHUMAN_CORE_PORT");
assert_eq!(default_core_port(), 7788);
let _set = EnvGuard::set("OPENHUMAN_CORE_PORT", "8899");
assert_eq!(default_core_port(), 8899);
}
#[test]
fn core_process_handle_new_creates_instance() {
let handle = CoreProcessHandle::new(9999);
assert_eq!(handle.port(), 9999);
assert_eq!(handle.rpc_url(), "http://127.0.0.1:9999/rpc");
}
#[test]
fn ready_signal_updates_runtime_port_and_fallback_notice() {
let handle = CoreProcessHandle::new(7788);
handle.apply_embedded_ready_signal(openhuman_core::core::jsonrpc::EmbeddedReadySignal {
port: 7789,
fallback_from: Some(7788),
});
assert_eq!(handle.port(), 7789);
assert_eq!(handle.rpc_url(), "http://127.0.0.1:7789/rpc");
let notice = handle
.take_last_port_fallback_notice()
.expect("fallback notice should be present");
assert_eq!(notice.preferred_port, 7788);
assert_eq!(notice.chosen_port, 7789);
assert!(
handle.take_last_port_fallback_notice().is_none(),
"fallback notice should be consumed once"
);
}
/// Regression: `ensure_running` must NOT publish the per-launch RPC bearer
/// to the `OPENHUMAN_CORE_TOKEN` environment variable.
///
/// The bearer is now handed to the in-process core in-memory via the
/// `rpc_token` argument of `run_server_embedded_with_ready`; setting it on
/// the process env would put it within reach of any same-UID process
/// reading `/proc/<pid>/environ` (Linux) or `sysctl KERN_PROCARGS2` /
/// `ps eww -p <pid>` (macOS).
#[test]
fn ensure_running_does_not_publish_token_to_env() {
let _env_lock = env_lock();
let _unset = EnvGuard::unset("OPENHUMAN_CORE_REUSE_EXISTING");
// Force a clean slate so we can assert on the post-spawn value.
let _wipe = EnvGuard::unset("OPENHUMAN_CORE_TOKEN");
let rt = tokio::runtime::Runtime::new().expect("runtime");
let (result, env_after, expected_token, env_during_spawn) = rt.block_on(async {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test listener");
let port = listener.local_addr().expect("local addr").port();
drop(listener);
// Brief yield to let the OS fully release the port.
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
let handle = CoreProcessHandle::new(port);
let expected_token = handle.rpc_token().to_string();
let result = handle.ensure_running().await;
// Capture env immediately after spawn returns Ok — before any
// tokio task could plausibly have set the var.
let env_after = std::env::var("OPENHUMAN_CORE_TOKEN").ok();
// Also peek midway via spawning a tiny check task in the same
// runtime — guards against the codepath setting+removing the var
// within the spawn window.
let env_during_spawn = std::env::var("OPENHUMAN_CORE_TOKEN").ok();
handle.shutdown().await;
(result, env_after, expected_token, env_during_spawn)
});
assert!(
result.is_ok(),
"ensure_running should succeed against a freed port: {result:?}"
);
assert!(
env_after.is_none(),
"ensure_running must NOT publish OPENHUMAN_CORE_TOKEN to the process env \
(sidecar-era leak channel removed). Found: {env_after:?} (handle token was {expected_token:?})"
);
assert!(
env_during_spawn.is_none(),
"OPENHUMAN_CORE_TOKEN must remain unset even momentarily during spawn. \
Found: {env_during_spawn:?}"
);
}
/// Issue #1613: when the preferred port is occupied by a non-OpenHuman
/// listener, startup should fall back to a nearby port instead of failing.
#[test]
fn ensure_running_falls_back_for_unknown_listener_on_port() {
let _env_lock = env_lock();
let _unset = EnvGuard::unset("OPENHUMAN_CORE_REUSE_EXISTING");
let rt = tokio::runtime::Runtime::new().expect("runtime");
let (result, chosen_port, notice) = rt.block_on(async {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test listener");
let port = listener.local_addr().expect("local addr").port();
let handle = CoreProcessHandle::new(port);
let result = handle.ensure_running().await;
let chosen_port = handle.port();
let notice = handle.take_last_port_fallback_notice();
handle.shutdown().await;
(result, chosen_port, notice)
});
assert!(
result.is_ok(),
"ensure_running should recover via fallback when preferred port is occupied: {result:?}"
);
assert!(
notice.is_some(),
"fallback notice should be set when preferred port is occupied"
);
let notice = notice.expect("notice set");
assert_ne!(
chosen_port, notice.preferred_port,
"fallback must choose a different port"
);
assert_eq!(
chosen_port, notice.chosen_port,
"chosen port should match fallback notice payload"
);
}
#[test]
fn ensure_running_falls_back_to_7789_when_7788_is_busy() {
let _env_lock = env_lock();
let _unset = EnvGuard::unset("OPENHUMAN_CORE_REUSE_EXISTING");
let rt = tokio::runtime::Runtime::new().expect("runtime");
rt.block_on(async {
let listener = match tokio::net::TcpListener::bind("127.0.0.1:7788").await {
Ok(listener) => listener,
Err(err) => {
eprintln!(
"[core_process tests] skipping fixed-port fallback test; 7788 unavailable: {err}"
);
return;
}
};
let handle = CoreProcessHandle::new(7788);
let result = handle.ensure_running().await;
assert!(
result.is_ok(),
"ensure_running should recover by binding a fallback port: {result:?}"
);
// Accept any port in the configured fallback range 7789..=7798 — a
// parallel test or environmental squatter on a single fallback port
// shouldn't fail the broader contract that fallback recovery works.
let chosen = handle.port();
assert!(
(7789..=7798).contains(&chosen),
"with 7788 occupied, core should bind to a fallback in 7789..=7798, got {chosen}"
);
let notice = handle
.take_last_port_fallback_notice()
.expect("fallback notice should be present");
assert_eq!(notice.preferred_port, 7788);
assert_eq!(
notice.chosen_port, chosen,
"fallback notice payload should match the bound port"
);
assert!(
(7789..=7798).contains(¬ice.chosen_port),
"fallback notice chosen_port should be in 7789..=7798, got {}",
notice.chosen_port
);
handle.shutdown().await;
drop(listener);
});
}
/// Escape hatch: setting `OPENHUMAN_CORE_REUSE_EXISTING=1` opts back into
/// the legacy attach-to-anything behavior for manual harnesses.
#[test]
fn ensure_running_reuses_unknown_listener_when_override_set() {
let _env_lock = env_lock();
let _override = EnvGuard::set("OPENHUMAN_CORE_REUSE_EXISTING", "1");
let rt = tokio::runtime::Runtime::new().expect("runtime");
let result = rt.block_on(async {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test listener");
let port = listener.local_addr().expect("local addr").port();
let handle = CoreProcessHandle::new(port);
handle.ensure_running().await
});
assert!(
result.is_ok(),
"override should restore legacy fast-path: {result:?}"
);
}
// ---------------------------------------------------------------------------
// Listener fingerprinting (issue #1130)
// ---------------------------------------------------------------------------
#[test]
fn is_openhuman_root_body_matches_canonical_root_response() {
// Mirrors the JSON shape produced by `core/jsonrpc.rs::root_handler`.
let body = r#"{
"name": "openhuman",
"ok": true,
"endpoints": {"health": "/health", "rpc": "/rpc"}
}"#;
assert!(is_openhuman_root_body(body));
}
#[test]
fn is_openhuman_root_body_rejects_other_services() {
assert!(!is_openhuman_root_body(r#"{"name": "something-else"}"#));
assert!(!is_openhuman_root_body(r#"{"ok": true}"#));
assert!(!is_openhuman_root_body("not json at all"));
assert!(!is_openhuman_root_body(""));
// Wrong type for `name`.
assert!(!is_openhuman_root_body(r#"{"name": 42}"#));
}
#[test]
fn expected_port_clash_classifier_matches_benign_probe_shapes() {
assert!(is_expected_port_clash(
"probe GET / failed: error sending request for url (http://127.0.0.1:7788/)"
));
assert!(is_expected_port_clash(
"probe GET / failed: connection refused"
));
assert!(is_expected_port_clash(
"probe GET / returned status 404 Not Found"
));
assert!(is_expected_port_clash("probe GET / returned status 200 OK"));
assert!(is_expected_port_clash(
"probe GET / body did not identify as openhuman (\"hello\")"
));
}
#[test]
fn expected_port_clash_classifier_matches_windows_acl_bind_shapes() {
assert!(is_expected_port_clash(
"Failed to bind to 127.0.0.1:7788: access denied (os error 10013)"
));
assert!(is_expected_port_clash(
"Failed to bind to 127.0.0.1:7788: WSAEACCES"
));
}
#[test]
fn expected_port_clash_classifier_rejects_unknown_probe_shapes() {
assert!(!is_expected_port_clash(
"probe GET / failed: TLS handshake failed: protocol error"
));
assert!(!is_expected_port_clash(
"probe GET / body read failed: unexpected eof"
));
}
#[test]
fn parse_lsof_pid_picks_first_pid() {
assert_eq!(parse_lsof_pid("12345\n"), Some(12345));
// Multiple pids — pick the first non-empty line. lsof can emit several
// when multiple sockets share the port (IPv4/IPv6).
assert_eq!(parse_lsof_pid("\n 9876 \n12345\n"), Some(9876));
assert_eq!(parse_lsof_pid(""), None);
assert_eq!(parse_lsof_pid("not-a-pid\n"), None);
}
#[test]
fn parse_netstat_pid_finds_listening_entry() {
// Sample shape from `netstat -ano -p TCP` on Windows.
let stdout = "\
Active Connections
Proto Local Address Foreign Address State PID
TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 1024
TCP 127.0.0.1:7788 0.0.0.0:0 LISTENING 4242
TCP 127.0.0.1:50000 127.0.0.1:7788 ESTABLISHED 5555
";
assert_eq!(parse_netstat_pid(stdout, 7788), Some(4242));
assert_eq!(parse_netstat_pid(stdout, 9999), None);
}
#[test]
fn parse_netstat_pid_skips_protected_kernel_pids() {
// HTTP.sys / driver-level reservations occasionally show as LISTENING
// under PID 4 (NT Kernel) or PID 0 (System Idle). Returning those pids
// would lead startup recovery to call taskkill on a process that cannot
// be signalled from user mode — aborting the entire takeover flow.
// The parser must treat these entries as "no owner" so callers fall
// back to the port-reroute path instead of trying to kill the kernel.
let stdout = "\
Active Connections
Proto Local Address Foreign Address State PID
TCP 127.0.0.1:7788 0.0.0.0:0 LISTENING 4
TCP 127.0.0.1:7789 0.0.0.0:0 LISTENING 0
TCP 127.0.0.1:7790 0.0.0.0:0 LISTENING 1234
";
assert_eq!(parse_netstat_pid(stdout, 7788), None);
assert_eq!(parse_netstat_pid(stdout, 7789), None);
assert_eq!(parse_netstat_pid(stdout, 7790), Some(1234));
}
#[test]
fn parse_netstat_pid_falls_through_protected_to_real_owner_on_dual_stack() {
// Real-world dual-stack listener: kernel-reserved entry sits ahead of
// the actual user-mode owner on the same port. The parser must keep
// scanning past the protected pid and return the genuine owner.
let stdout = "\
Proto Local Address Foreign Address State PID
TCP [::]:7788 [::]:0 LISTENING 4
TCP 127.0.0.1:7788 0.0.0.0:0 LISTENING 9999
";
assert_eq!(parse_netstat_pid(stdout, 7788), Some(9999));
}
// ---------------------------------------------------------------------------
// Windows end-to-end port-takeover test
//
// Spawns a real child process that occupies a TCP port, then walks the same
// path the Tauri host walks at startup (find_pid_on_port → kill_pid_force →
// is_port_open) and asserts the port is actually freed. This is the
// behavior the user reported broken — a unit-only parser test is not enough
// to catch netstat/taskkill drift on real Windows machines.
// ---------------------------------------------------------------------------
#[cfg(windows)]
#[test]
fn windows_port_takeover_finds_and_kills_listener() {
use crate::process_kill::kill_pid_force;
use std::net::TcpListener;
use std::os::windows::process::CommandExt;
use std::time::{Duration, Instant};
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
// Bind in this process first to claim an ephemeral free port the OS
// picks for us, capture the port, then drop the listener so the child
// can bind to the same port. There is a tiny TOCTOU window here but
// ephemeral ports on Windows are not aggressively recycled so it is
// robust enough for a single-shot test.
let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe");
let port = probe.local_addr().expect("probe addr").port();
drop(probe);
// Use PowerShell to spawn a listener that holds the port open for 60s.
// PowerShell ships with every supported Windows version.
let script = format!(
"$l = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, {port}); \
$l.Start(); Start-Sleep -Seconds 60; $l.Stop()"
);
let mut child = std::process::Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.creation_flags(CREATE_NO_WINDOW)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.stdin(std::process::Stdio::null())
.spawn()
.expect("spawn powershell listener");
// Wait until the listener is actually bound (PowerShell startup is slow).
let deadline = Instant::now() + Duration::from_secs(15);
let mut bound = false;
while Instant::now() < deadline {
if std::net::TcpStream::connect_timeout(
&format!("127.0.0.1:{port}").parse().unwrap(),
Duration::from_millis(100),
)
.is_ok()
{
bound = true;
break;
}
std::thread::sleep(Duration::from_millis(100));
}
if !bound {
let _ = child.kill();
let _ = child.wait();
panic!("child listener never bound to 127.0.0.1:{port}");
}
// Walk the production path: pid lookup via netstat, then force-kill.
let pid = match super::find_pid_on_port(port) {
Some(pid) => pid,
None => {
let _ = child.kill();
let _ = child.wait();
panic!("find_pid_on_port returned None for port {port}");
}
};
// The pid we discovered won't be `child.id()` directly — the powershell
// process is the listener, and on Windows `child.id()` IS that pid.
// Sanity-check they match so a future netstat parser regression is loud.
// Tear down the child *before* panicking so a 60s listener doesn't leak
// into the rest of the test suite.
if pid != child.id() {
let expected = child.id();
let _ = child.kill();
let _ = child.wait();
panic!("find_pid_on_port returned pid {pid}, expected child pid {expected}");
}
kill_pid_force(pid).expect("force-kill listener");
// Verify the port is actually free within a reasonable window — this is
// the assertion that fails when taskkill mis-reports success or when
// /T fails to take down the powershell subtree.
let deadline = Instant::now() + Duration::from_secs(5);
let mut freed = false;
while Instant::now() < deadline {
if std::net::TcpStream::connect_timeout(
&format!("127.0.0.1:{port}").parse().unwrap(),
Duration::from_millis(100),
)
.is_err()
{
freed = true;
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let _ = child.wait();
assert!(
freed,
"port {port} still bound after kill_pid_force(pid={pid})"
);
// Idempotency: kill the same pid again — must be Ok, not Err, because
// the process is already gone and recovery code calls force-kill after
// a re-validation that may race.
kill_pid_force(pid).expect("kill_pid_force on dead pid must be idempotent");
}
// ---------------------------------------------------------------------------
// Token generation tests
// ---------------------------------------------------------------------------
/// `generate_rpc_token` must produce a 64-character lowercase hex string
/// (32 bytes × 2 hex digits = 64 chars), matching the format expected by the
/// core's auth middleware.
#[test]
fn generate_rpc_token_produces_64_hex_chars() {
let token = generate_rpc_token();
assert_eq!(
token.len(),
64,
"256-bit token → 64 hex chars, got {token:?}"
);
assert!(
token.chars().all(|c| c.is_ascii_hexdigit()),
"token must be hex, got {token:?}"
);
assert!(
token.chars().all(|c| !c.is_uppercase()),
"token must be lowercase hex, got {token:?}"
);
}
/// Each call generates a different token (CSPRNG — not a constant).
#[test]
fn generate_rpc_token_is_not_constant() {
assert_ne!(
generate_rpc_token(),
generate_rpc_token(),
"two consecutive tokens must differ"
);
}
/// `CoreProcessHandle::new` must produce a non-empty, correctly-formatted
/// bearer token immediately — no file I/O or timing dependency.
#[test]
fn core_process_handle_new_token_is_valid() {
let handle = CoreProcessHandle::new(19001);
let token = handle.rpc_token();
assert_eq!(token.len(), 64, "handle token must be 64 hex chars");
assert!(
token.chars().all(|c| c.is_ascii_hexdigit()),
"handle token must be hex"
);
}
/// `CoreProcessHandle::new()` must NOT publish the token to the global
/// `CURRENT_RPC_TOKEN`. The global is set only after `ensure_running()`
/// successfully spawns the embedded server with `OPENHUMAN_CORE_TOKEN` in
/// scope. Advertising the token before spawn would 401 against any process
/// already listening on the port that never received this token.
#[test]
fn new_does_not_publish_global_token() {
let before = current_rpc_token();
let handle = CoreProcessHandle::new(19002);
let after = current_rpc_token();
assert_ne!(
after.as_deref(),
Some(handle.rpc_token()),
"new() must not publish its token to CURRENT_RPC_TOKEN before ensure_running() spawns"
);
assert_eq!(
before, after,
"new() must leave CURRENT_RPC_TOKEN unchanged"
);
}
/// Two handles constructed sequentially must each have a unique token.
#[test]
fn each_handle_has_unique_token() {
let h1 = CoreProcessHandle::new(19003);
let h2 = CoreProcessHandle::new(19004);
assert_ne!(
h1.rpc_token(),
h2.rpc_token(),
"each handle must have a unique token"
);
}
#[test]
fn send_terminate_signal_cancels_shutdown_token() {
let rt = tokio::runtime::Runtime::new().expect("runtime");
rt.block_on(async {
let handle = CoreProcessHandle::new(19005);
assert!(!handle.shutdown_token_is_cancelled().await);
handle.send_terminate_signal().await;
assert!(
handle.shutdown_token_is_cancelled().await,
"send_terminate_signal must cancel graceful Axum shutdown before aborting the task"
);
});
}
#[test]
fn startup_timeout_cleanup_aborts_task_and_clears_slot() {
let rt = tokio::runtime::Runtime::new().expect("runtime");
rt.block_on(async {
let handle = CoreProcessHandle::new(19006);
let task = tokio::spawn(async {
tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
Ok::<(), anyhow::Error>(())
});
{
let mut guard = handle.task.lock().await;
*guard = Some(task);
}
let message = handle.cleanup_startup_timeout(false, false, 2).await;
assert!(
message.contains("core process did not become ready within"),
"timeout message should include the readiness budget: {message}"
);
assert!(
message.contains("ready_signal=false"),
"timeout message should include ready signal state: {message}"
);
assert!(
message.contains("port=19006"),
"timeout message should include RPC port: {message}"
);
assert!(
message.contains("port_open=false"),
"timeout message should include final port state: {message}"
);
assert!(
message.contains("task_state=running"),
"timeout message should include task state: {message}"
);
assert!(
message.contains("attempt=2"),
"timeout message should include startup attempt: {message}"
);
assert!(
handle.task.lock().await.is_none(),
"cleanup must clear the managed task slot so retry can spawn fresh"
);
assert!(
handle.shutdown_token_is_cancelled().await,
"cleanup must cancel the startup token before aborting"
);
});
}
// ---------------------------------------------------------------------------
// RecoveryOutcome serialization tests
// ---------------------------------------------------------------------------
#[test]
fn recovery_outcome_serializes_correctly() {
let outcome = RecoveryOutcome {
success: true,
message: "Core recovered on port 7789".to_string(),
new_port: Some(7789),
};
let json = serde_json::to_value(&outcome).expect("serialize");
assert_eq!(json["success"], serde_json::json!(true));
assert_eq!(
json["message"],
serde_json::json!("Core recovered on port 7789")
);
assert_eq!(json["new_port"], serde_json::json!(7789));
}
#[test]
fn recovery_outcome_failure_serializes_with_null_port() {
let outcome = RecoveryOutcome {
success: false,
message: "Recovery failed: port still busy".to_string(),
new_port: None,
};
let json = serde_json::to_value(&outcome).expect("serialize");
assert_eq!(json["success"], serde_json::json!(false));
assert!(
json["new_port"].is_null(),
"new_port should be null when None"
);
}
#[test]
fn recover_port_conflict_succeeds_when_port_is_free() {
let _env_lock = env_lock();
let _unset = EnvGuard::unset("OPENHUMAN_CORE_REUSE_EXISTING");
let rt = tokio::runtime::Runtime::new().expect("runtime");
let outcome = rt.block_on(async {
// Bind a port, then release it so it's free when recover_port_conflict runs.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let port = listener.local_addr().expect("addr").port();
drop(listener);
// Brief yield to let the OS fully release the port.
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
let handle = CoreProcessHandle::new(port);
let outcome = handle.recover_port_conflict().await;
handle.shutdown().await;
outcome
});
assert!(
outcome.success,
"recovery should succeed when port is free: {}",
outcome.message
);
assert!(
outcome.new_port.is_some(),
"new_port should be set on success"
);
}
#[test]
fn recover_port_conflict_handles_stale_listener() {
let _env_lock = env_lock();
let _unset = EnvGuard::unset("OPENHUMAN_CORE_REUSE_EXISTING");
let rt = tokio::runtime::Runtime::new().expect("runtime");
// Bind a port, attempt recovery — the recovery must still succeed because
// ensure_running's fallback range kicks in when the preferred port is busy.
let outcome = rt.block_on(async {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let port = listener.local_addr().expect("addr").port();
let handle = CoreProcessHandle::new(port);
let outcome = handle.recover_port_conflict().await;
handle.shutdown().await;
drop(listener);
outcome
});
// Recovery may succeed via port fallback even with the listener held.
// We only assert that the outcome is well-formed.
assert!(
!outcome.message.is_empty(),
"outcome message must always be populated"
);
if outcome.success {
assert!(outcome.new_port.is_some());
} else {
assert!(outcome.new_port.is_none());
}
}