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
341
        let items = eval.drain();
12
341
        if !items.is_empty() {
13
6
            return items;
14
335
        }
15
335
        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 (forms_tx, mut forms_rx) = mpsc::unbounded_channel::<String>();
136
1
    let (results_tx, results_rx) = mpsc::unbounded_channel::<String>();
137
1
    let worker = Handle::current().spawn(async move {
138
1
        while let Some(frame) = forms_rx.recv().await {
139
1
            if results_tx.send(frame).is_err() {
140
1
                break;
141
            }
142
        }
143
1
    });
144
1
    forms_tx.send("(a)".to_string()).expect("queue first frame");
145
1
    forms_tx
146
1
        .send("(b)".to_string())
147
1
        .expect("queue second frame");
148
1
    drop(results_rx);
149
1
    for _ in 0..200 {
150
2
        if worker.is_finished() {
151
1
            return;
152
1
        }
153
1
        sleep(Duration::from_millis(10)).await;
154
1
    }
155
1
    assert!(
156
1
        worker.is_finished(),
157
1
        "worker must break out of its loop once the receiver is gone"
158
1
    );
159
1
}
160

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

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

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

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

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

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

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