1
use super::*;
2

            
3
use std::time::Duration;
4
use tokio::runtime::Handle;
5
use tokio::time::sleep;
6

            
7
/// Polls `drain` until it returns at least one item or the budget
8
/// expires, yielding to let the worker run between polls.
9
6
async fn drain_one(eval: &mut ConsoleEval) -> Vec<DrainItem> {
10
6
    for _ in 0..200 {
11
286
        let items = eval.drain();
12
286
        if !items.is_empty() {
13
6
            return items;
14
280
        }
15
280
        sleep(Duration::from_millis(20)).await;
16
    }
17
    eval.drain()
18
6
}
19

            
20
/// Extract the wire string from a `DrainItem`, panicking if it's a Notice.
21
5
fn wire_of(item: &DrainItem) -> &str {
22
5
    match item {
23
4
        DrainItem::Routed { wire, .. } => wire,
24
1
        DrainItem::Unroutable(w) => w,
25
        DrainItem::Notice(n) => panic!("expected wire, got Notice({n:?})"),
26
    }
27
5
}
28

            
29
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
30
1
async fn real_session_evaluates_bare_form_via_envelope_wrapping() {
31
1
    let mut eval = ConsoleEval::spawn(&Handle::current(), Uuid::nil()).expect("spawn session");
32
1
    eval.submit("(+ 1 2)".to_string());
33
1
    let items = drain_one(&mut eval).await;
34
1
    assert_eq!(items.len(), 1, "got {items:?}");
35
1
    let wire = wire_of(&items[0]);
36
1
    assert!(wire.contains(":value 3"), "{wire:?}");
37
1
    assert!(wire.contains(":id 1"), "{wire:?}");
38
1
}
39

            
40
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
41
1
async fn submit_increments_the_envelope_id() {
42
1
    let mut eval = ConsoleEval::spawn(&Handle::current(), Uuid::nil()).expect("spawn session");
43
1
    eval.submit("(+ 1 2)".to_string());
44
1
    let first = drain_one(&mut eval).await;
45
1
    let w0 = wire_of(&first[0]);
46
1
    assert!(w0.contains(":id 1"), "{w0:?}");
47
1
    eval.submit("(+ 2 2)".to_string());
48
1
    let second = drain_one(&mut eval).await;
49
1
    let w1 = wire_of(&second[0]);
50
1
    assert!(w1.contains(":id 2"), "{w1:?}");
51
1
    assert!(w1.contains(":value 4"), "{w1:?}");
52
1
}
53

            
54
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
55
1
async fn echo_actor_round_trips_the_submitted_form() {
56
1
    let mut eval = ConsoleEval::echo(&Handle::current());
57
1
    eval.submit("(foo)".to_string());
58
1
    let items = drain_one(&mut eval).await;
59
1
    assert_eq!(items.len(), 1, "got {items:?}");
60
1
    let wire = wire_of(&items[0]);
61
1
    assert_eq!(wire, "(:id 1 :form (foo))");
62
1
}
63

            
64
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
65
1
async fn interrupt_cancels_an_inflight_eval() {
66
1
    let ctx = ScriptCtx::new(Uuid::nil()).with_limits(ScriptLimits {
67
1
        fuel: u64::MAX,
68
1
        ..ScriptLimits::default()
69
1
    });
70
1
    let mut eval = ConsoleEval::spawn_with_ctx(&Handle::current(), ctx).expect("spawn session");
71
1
    eval.submit("(do ((i 0 (+ i 1))) ((>= i 2000000000) i))".to_string());
72
    // Deliberately path-agnostic: this asserts the USER-VISIBLE contract, that
73
    // an interrupt cancels the eval, whichever mechanism gets there — the
74
    // in-Wasm epoch trap if the worker already started, the pre-start latch if
75
    // it has not. Which one wins depends on host speed, so pinning either here
76
    // would be a flake. The epoch trap itself is pinned deterministically by
77
    // rpc's epoch_bumper_cancels_inflight_long_eval, which makes the pre-start
78
    // path structurally unreachable instead of racing it.
79
1
    sleep(Duration::from_millis(40)).await;
80
1
    eval.interrupt();
81
1
    let items = drain_one(&mut eval).await;
82
1
    assert_eq!(items.len(), 1, "got {items:?}");
83
1
    let wire = wire_of(&items[0]);
84
1
    assert!(wire.contains(":code interrupted"), "{wire:?}");
85
1
}
86

            
87
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
88
1
async fn interrupt_without_inflight_eval_does_not_panic() {
89
1
    let eval = ConsoleEval::echo(&Handle::current());
90
1
    eval.interrupt();
91
1
}
92

            
93
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
94
1
async fn submit_after_worker_stops_reports_failure() {
95
1
    let mut eval = ConsoleEval::echo(&Handle::current());
96
1
    let handle = eval.worker_handle();
97
1
    handle.abort();
98
1
    for _ in 0..200 {
99
2
        if handle.is_finished() {
100
1
            break;
101
1
        }
102
1
        sleep(Duration::from_millis(10)).await;
103
1
    }
104
1
    assert!(
105
1
        eval.submit("(x)".to_string()).is_none(),
106
1
        "submit must return None once the worker has stopped"
107
1
    );
108
1
}
109

            
110
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
111
1
async fn drain_surfaces_worker_stopped_notice_exactly_once() {
112
1
    let mut eval = ConsoleEval::echo(&Handle::current());
113
1
    let handle = eval.worker_handle();
114
1
    handle.abort();
115
1
    for _ in 0..200 {
116
2
        if handle.is_finished() {
117
1
            break;
118
1
        }
119
1
        sleep(Duration::from_millis(10)).await;
120
    }
121
1
    let first = eval.drain();
122
1
    assert_eq!(first.len(), 1, "expected one item, got {first:?}");
123
1
    assert!(
124
1
        matches!(&first[0], DrainItem::Notice(n) if n == WORKER_STOPPED_NOTICE),
125
        "expected Notice, got {first:?}"
126
    );
127
1
    assert!(
128
1
        eval.drain().is_empty(),
129
1
        "the notice must not repeat on later drains"
130
1
    );
131
1
}
132

            
133
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
134
1
async fn worker_breaks_when_receiver_is_dropped_mid_eval() {
135
1
    let eval = ConsoleEval::echo(&Handle::current());
136
1
    let handle = eval.worker_handle();
137
    let ConsoleEval {
138
1
        forms_tx,
139
1
        results_rx,
140
        ..
141
1
    } = eval;
142
    // Dropping the receiver before any frame exists makes the send-failure
143
    // break the only way out; forms_tx lives to the end of the test, so recv
144
    // never returns None.
145
1
    drop(results_rx);
146
1
    forms_tx.send("(a)".to_string()).expect("queue a frame");
147
1
    for _ in 0..200 {
148
2
        if handle.is_finished() {
149
1
            break;
150
1
        }
151
1
        sleep(Duration::from_millis(10)).await;
152
1
    }
153
1
    assert!(
154
1
        handle.is_finished(),
155
1
        "worker must break out of its loop once the receiver is gone"
156
1
    );
157
1
}
158

            
159
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
160
1
async fn dropping_the_eval_ends_the_worker() {
161
1
    let eval = ConsoleEval::echo(&Handle::current());
162
1
    let handle = eval.worker_handle();
163
1
    drop(eval);
164
1
    for _ in 0..200 {
165
2
        if handle.is_finished() {
166
1
            return;
167
1
        }
168
1
        sleep(Duration::from_millis(10)).await;
169
1
    }
170
1
    assert!(handle.is_finished(), "worker did not exit after drop");
171
1
}
172

            
173
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
174
1
async fn submit_returns_monotonic_ids() {
175
1
    let mut eval = ConsoleEval::echo(&Handle::current());
176
1
    let id0 = eval.submit("(a)".to_string()).expect("id0");
177
1
    let id1 = eval.submit("(b)".to_string()).expect("id1");
178
1
    let id2 = eval.submit("(c)".to_string()).expect("id2");
179
1
    assert_eq!(id0, 1, "ids start at 1; 0 is reserved for parse errors");
180
1
    assert_eq!(id1, 2);
181
1
    assert_eq!(id2, 3);
182
1
}
183

            
184
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
185
1
async fn drain_classifies_echo_request_frames_as_unroutable() {
186
1
    let mut eval = ConsoleEval::echo(&Handle::current());
187
1
    eval.submit("(x)".to_string());
188
1
    let items = drain_one(&mut eval).await;
189
1
    assert_eq!(items.len(), 1);
190
    // Echo sends back "(:id 1 :form (x))" — a request envelope, not a
191
    // response; parse_response rejects it (no :value/:error) → Unroutable.
192
1
    assert!(
193
1
        matches!(&items[0], DrainItem::Unroutable(_)),
194
1
        "echo frame must be Unroutable, got {items:?}"
195
1
    );
196
1
}
197

            
198
#[test]
199
1
fn classify_response_positive_id_is_routed() {
200
1
    let wire = "(:id 5 :value 42)".to_string();
201
1
    let item = classify_response(wire.clone());
202
1
    assert!(
203
1
        matches!(item, DrainItem::Routed { id: 5, .. }),
204
        "expected Routed{{id:5}}, got {item:?}"
205
    );
206
1
}
207

            
208
#[test]
209
1
fn classify_response_id_zero_is_unroutable() {
210
1
    let wire = "(:id 0 :value 42)".to_string();
211
1
    let item = classify_response(wire);
212
1
    assert!(
213
1
        matches!(item, DrainItem::Unroutable(_)),
214
        "id 0 must be Unroutable"
215
    );
216
1
}
217

            
218
#[test]
219
1
fn classify_response_parse_error_is_unroutable() {
220
1
    let wire = "((((".to_string();
221
1
    let item = classify_response(wire);
222
1
    assert!(
223
1
        matches!(item, DrainItem::Unroutable(_)),
224
        "parse error must be Unroutable"
225
    );
226
1
}
227

            
228
#[test]
229
1
fn classify_response_string_id_is_unroutable() {
230
1
    let wire = r#"(:id "abc" :value 1)"#.to_string();
231
1
    let item = classify_response(wire);
232
1
    assert!(
233
1
        matches!(item, DrainItem::Unroutable(_)),
234
        "string id must be Unroutable"
235
    );
236
1
}