1
use super::*;
2
use rpc::ScriptCtx;
3
use rpc::ScriptLimits;
4
use sqlx::types::Uuid;
5

            
6
#[derive(Debug)]
7
enum Sent {
8
    Data(String),
9
    Finished,
10
}
11

            
12
struct MpscSink {
13
    tx: mpsc::UnboundedSender<Sent>,
14
}
15

            
16
#[async_trait::async_trait]
17
impl ResponseSink for MpscSink {
18
7
    async fn send(&self, bytes: Vec<u8>) {
19
        let _ = self.tx.send(Sent::Data(String::from_utf8(bytes).unwrap()));
20
7
    }
21

            
22
3
    async fn finish(&self) {
23
        let _ = self.tx.send(Sent::Finished);
24
3
    }
25
}
26

            
27
8
fn new_state() -> (EvalChannelState, mpsc::UnboundedReceiver<Sent>) {
28
8
    state_with_ctx(ScriptCtx::new(Uuid::new_v4()))
29
8
}
30

            
31
9
fn state_with_ctx(ctx: ScriptCtx) -> (EvalChannelState, mpsc::UnboundedReceiver<Sent>) {
32
9
    let (tx, rx) = mpsc::unbounded_channel();
33
9
    let sink = Box::new(MpscSink { tx });
34
9
    let state = EvalChannelState::with_session(Session::new(ctx).expect("session"), sink);
35
9
    (state, rx)
36
9
}
37

            
38
/// Collects `expected` responses, awaiting each rather than
39
/// busy-polling a fixed budget so a slow CI runner never spuriously
40
/// yields fewer (a genuine hang still fails via the per-response
41
/// timeout). After the expected count arrives it keeps draining on a
42
/// short grace window, so an over-emission regression (an extra
43
/// frame) is collected and trips the caller's count assertion. The
44
/// state is still alive here, so a finish is always a regression.
45
4
async fn drain_count(rx: &mut mpsc::UnboundedReceiver<Sent>, expected: usize) -> Vec<String> {
46
4
    let mut out = Vec::new();
47
    loop {
48
9
        let budget = if out.len() < expected {
49
5
            std::time::Duration::from_secs(10)
50
        } else {
51
4
            std::time::Duration::from_millis(100)
52
        };
53
9
        match tokio::time::timeout(budget, rx.recv()).await {
54
5
            Ok(Some(Sent::Data(s))) => out.push(s),
55
            Ok(Some(Sent::Finished)) => panic!("channel finished while its state is alive"),
56
4
            Ok(None) | Err(_) => break,
57
        }
58
    }
59
4
    out
60
4
}
61

            
62
/// Everything the worker emits until it exits and drops the sink.
63
2
async fn until_worker_exits(rx: &mut mpsc::UnboundedReceiver<Sent>) -> Vec<Sent> {
64
2
    let mut out = Vec::new();
65
4
    while let Ok(Some(event)) =
66
6
        tokio::time::timeout(std::time::Duration::from_secs(10), rx.recv()).await
67
4
    {
68
4
        out.push(event);
69
4
    }
70
2
    out
71
2
}
72

            
73
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
74
1
async fn empty_feed_produces_no_responses() {
75
1
    let (mut state, mut rx) = new_state();
76
1
    state.feed(b"").unwrap();
77
1
    tokio::time::sleep(std::time::Duration::from_millis(20)).await;
78
1
    assert!(rx.try_recv().is_err());
79
1
}
80

            
81
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
82
1
async fn complete_frame_yields_response_with_trailing_newline() {
83
1
    let (mut state, mut rx) = new_state();
84
1
    state
85
1
        .feed(b"(:id 1 :form (rpc-protocol-version))\n")
86
1
        .unwrap();
87
1
    let responses = drain_count(&mut rx, 1).await;
88
1
    assert_eq!(responses.len(), 1, "got {responses:?}");
89
1
    let r = &responses[0];
90
1
    assert!(r.starts_with("(:id 1"), "{r:?}");
91
1
    assert!(r.ends_with('\n'), "{r:?}");
92
1
}
93

            
94
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
95
1
async fn two_concatenated_frames_yield_two_responses() {
96
1
    let (mut state, mut rx) = new_state();
97
1
    state
98
1
        .feed(b"(:id 3 :form (rpc-protocol-version))\n(:id 4 :form (rpc-protocol-version))\n")
99
1
        .unwrap();
100
1
    let responses = drain_count(&mut rx, 2).await;
101
1
    assert_eq!(responses.len(), 2);
102
1
    assert!(responses[0].contains(":id 3"));
103
1
    assert!(responses[1].contains(":id 4"));
104
1
}
105

            
106
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
107
1
async fn dropping_the_state_answers_queued_forms_then_finishes() {
108
1
    let (mut state, mut rx) = new_state();
109
1
    state
110
1
        .feed(b"(:id 1 :form (rpc-protocol-version))\n(:id 2 :form (rpc-protocol-version))\n")
111
1
        .unwrap();
112
1
    drop(state);
113
1
    let events = until_worker_exits(&mut rx).await;
114
1
    assert!(
115
1
        matches!(
116
1
            &events[..],
117
1
            [Sent::Data(first), Sent::Data(second), Sent::Finished]
118
1
                if first.starts_with("(:id 1") && second.starts_with("(:id 2")
119
1
        ),
120
1
        "{events:?}"
121
1
    );
122
1
}
123

            
124
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
125
1
async fn dropping_the_state_mid_frame_only_finishes() {
126
1
    let (mut state, mut rx) = new_state();
127
1
    state.feed(b"(:id 1 :form (rpc-protocol").unwrap();
128
1
    drop(state);
129
1
    let events = until_worker_exits(&mut rx).await;
130
1
    assert!(matches!(&events[..], [Sent::Finished]), "{events:?}");
131
1
}
132

            
133
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
134
1
async fn etx_byte_in_stream_is_stripped() {
135
    // Send `(form)<ETX>` — the ETX must NOT pollute the decoder
136
    // (which would refuse the byte as non-paren content) and must
137
    // NOT prevent the frame from completing.
138
1
    let (mut state, mut rx) = new_state();
139
1
    let mut bytes = b"(:id 5 :form (rpc-protocol-version))\n".to_vec();
140
1
    bytes.push(ETX);
141
1
    state.feed(&bytes).unwrap();
142
1
    let responses = drain_count(&mut rx, 1).await;
143
1
    assert_eq!(responses.len(), 1, "got {responses:?}");
144
1
    assert!(responses[0].contains(":id 5"));
145
1
}
146

            
147
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
148
1
async fn etx_byte_alone_bumps_epoch_without_decoding_a_frame() {
149
    // Just an ETX with no surrounding form should not produce
150
    // any output — the bumper fires, but there's nothing to
151
    // cancel since no eval is in flight.
152
1
    let (mut state, mut rx) = new_state();
153
1
    state.feed(&[ETX]).unwrap();
154
1
    tokio::time::sleep(std::time::Duration::from_millis(20)).await;
155
1
    assert!(rx.try_recv().is_err());
156
1
}
157

            
158
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
159
1
async fn etx_byte_after_form_cancels_inflight_eval() {
160
    // End-to-end cancel scenario through the eval-channel layer:
161
    // feed a long-running form, then feed an ETX byte on the same
162
    // channel. `feed` both bumps the epoch and advances the
163
    // interrupt generation, so the cancel is observed whether the
164
    // eval is already inside Wasm (epoch trap) or still in the
165
    // worker-pickup / compile window (pre-start check_interrupt).
166
    //
167
    // Fuel is set effectively unbounded and the loop runs billions
168
    // of iterations (the bound is just under i32::MAX, since the
169
    // loop counter is an Index / i32), so the form can NEVER
170
    // terminate on its own (neither a natural value nor a
171
    // fuel-exhaustion `:code runtime`) within the test window.
172
    // `:code interrupted` is therefore the only possible outcome —
173
    // the assertion pins exactly that, which is what makes this a
174
    // real regression guard for the cancel path rather than a test
175
    // the fuel cap can satisfy on its own.
176
1
    let ctx = ScriptCtx::new(Uuid::new_v4()).with_limits(ScriptLimits {
177
1
        fuel: u64::MAX,
178
1
        ..ScriptLimits::default()
179
1
    });
180
1
    let (mut state, mut rx) = state_with_ctx(ctx);
181
1
    state
182
1
        .feed(b"(:id 9 :form (do ((i 0 (+ i 1))) ((>= i 2000000000) i)))\n")
183
1
        .unwrap();
184
    // Yield so the worker picks the form up and starts running.
185
1
    tokio::time::sleep(std::time::Duration::from_millis(20)).await;
186
1
    state.feed(&[ETX]).unwrap();
187
1
    let responses = drain_count(&mut rx, 1).await;
188
1
    assert_eq!(responses.len(), 1, "got {responses:?}");
189
1
    let r = &responses[0];
190
1
    assert!(r.contains(":id 9"), "{r:?}");
191
1
    assert!(r.contains(":code interrupted"), "{r:?}");
192
1
}
193

            
194
#[tokio::test(flavor = "current_thread")]
195
1
async fn feed_rejects_non_utf8() {
196
1
    let (mut state, _rx) = new_state();
197
1
    let invalid: [u8; 2] = [0xC0, 0xC1];
198
1
    assert!(state.feed(&invalid).is_err());
199
1
}