shuttlebay 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Cargo.lock +416 -0
- data/Cargo.toml +15 -0
- data/LICENSE.txt +21 -0
- data/README.md +191 -0
- data/exe/shuttlebay +12 -0
- data/ext/shuttlebay/Cargo.toml +31 -0
- data/ext/shuttlebay/extconf.rb +22 -0
- data/ext/shuttlebay/src/codec.rs +477 -0
- data/ext/shuttlebay/src/headers.rs +34 -0
- data/ext/shuttlebay/src/lib.rs +94 -0
- data/ext/shuttlebay/src/link.rs +78 -0
- data/lib/rackup/handler/shuttlebay.rb +26 -0
- data/lib/shuttlebay/attached.rb +139 -0
- data/lib/shuttlebay/capybara.rb +8 -0
- data/lib/shuttlebay/engine.rb +23 -0
- data/lib/shuttlebay/handler.rb +152 -0
- data/lib/shuttlebay/logger.rb +28 -0
- data/lib/shuttlebay/process_control.rb +33 -0
- data/lib/shuttlebay/railtie.rb +11 -0
- data/lib/shuttlebay/runtime.rb +83 -0
- data/lib/shuttlebay/server.rb +199 -0
- data/lib/shuttlebay/stream.rb +56 -0
- data/lib/shuttlebay/tunnel.rb +95 -0
- data/lib/shuttlebay/version.rb +5 -0
- data/lib/shuttlebay/worker.rb +166 -0
- data/lib/shuttlebay.rb +4 -0
- metadata +187 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mkmf'
|
|
4
|
+
|
|
5
|
+
abort "shuttlebay: the native engine needs CRuby (running #{RUBY_ENGINE})" unless RUBY_ENGINE == 'ruby'
|
|
6
|
+
abort 'shuttlebay: cargo not found; install Rust (mise use rust) or use a platform gem' unless find_executable('cargo')
|
|
7
|
+
|
|
8
|
+
begin
|
|
9
|
+
require 'rb_sys/mkmf'
|
|
10
|
+
rescue LoadError => e
|
|
11
|
+
abort "shuttlebay: rb_sys is required to build the native engine (#{e.message})"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
create_rust_makefile('shuttlebay/shuttlebay') do |r|
|
|
15
|
+
ext_dir = Pathname(__dir__)
|
|
16
|
+
r.ext_dir = begin
|
|
17
|
+
ext_dir.relative_path_from(Pathname(Dir.pwd)).to_s
|
|
18
|
+
rescue ArgumentError
|
|
19
|
+
ext_dir.expand_path.to_s
|
|
20
|
+
end
|
|
21
|
+
r.profile = ENV.fetch('RB_SYS_CARGO_PROFILE', :release).to_sym
|
|
22
|
+
end
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
use std::{cell::RefCell, fmt::Display};
|
|
2
|
+
|
|
3
|
+
use magnus::{Error, RHash, RString, Ruby, Symbol, Value, prelude::*, scan_args::scan_args};
|
|
4
|
+
use mothership_docking_protocol::{
|
|
5
|
+
Dock, HAUL_CHUNK_LEN, Hail, MAX_FRAME_PAYLOAD, MessageType, Moored, ProtocolError, Reply,
|
|
6
|
+
ReplyBody, VERSION, decode_hail, decode_haul, decode_header, encode_dock, encode_ready,
|
|
7
|
+
encode_reply, push_haul,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
use crate::{
|
|
11
|
+
OrProtocolError, headers,
|
|
12
|
+
link::{self, LinkEvent, LinkState},
|
|
13
|
+
protocol_error, spool_file, string_io,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const READ_CHUNK: usize = 64 * 1024;
|
|
17
|
+
const FLUSH_THRESHOLD: usize = 64 * 1024;
|
|
18
|
+
pub const SPOOL_THRESHOLD: u64 = 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
const MAX_BODY: u64 = 4 * 1024 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
23
|
+
enum Kind {
|
|
24
|
+
Empty,
|
|
25
|
+
Stream,
|
|
26
|
+
File,
|
|
27
|
+
Tunnel,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
struct State {
|
|
31
|
+
inbox: Vec<u8>,
|
|
32
|
+
outbox: Vec<u8>,
|
|
33
|
+
link: link::Machine,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
impl Default for State {
|
|
37
|
+
fn default() -> Self {
|
|
38
|
+
Self { inbox: Vec::new(), outbox: Vec::new(), link: link::new() }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#[magnus::wrap(class = "Shuttlebay::Engine::Codec", free_immediately, size)]
|
|
43
|
+
pub struct Codec {
|
|
44
|
+
state: RefCell<State>,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
impl Codec {
|
|
48
|
+
pub fn new() -> Self {
|
|
49
|
+
Self { state: RefCell::new(State::default()) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
pub fn dock(
|
|
53
|
+
ruby: &Ruby,
|
|
54
|
+
rb_self: &Self,
|
|
55
|
+
io: Value,
|
|
56
|
+
ship_name: String,
|
|
57
|
+
threads: u16,
|
|
58
|
+
) -> Result<RHash, Error> {
|
|
59
|
+
let dock = Dock::new(ship_name).with_pid(std::process::id()).with_threads(threads);
|
|
60
|
+
let frame = encode_dock(&dock);
|
|
61
|
+
rb_self.with_state(ruby, |state| state.outbox.extend_from_slice(&frame))?;
|
|
62
|
+
rb_self.flush_to(ruby, io)?;
|
|
63
|
+
|
|
64
|
+
let payload = rb_self
|
|
65
|
+
.read_frame(ruby, io, MessageType::Moored)?
|
|
66
|
+
.ok_or("Mothership closed the link before MOORED")
|
|
67
|
+
.or_protocol_error(ruby)?;
|
|
68
|
+
let moored: Moored = serde_json::from_slice(&payload)
|
|
69
|
+
.map_err(|error| protocol_error(ruby, format!("Invalid MOORED payload: {error}")))?;
|
|
70
|
+
ensure!(
|
|
71
|
+
ruby,
|
|
72
|
+
moored.version >= VERSION,
|
|
73
|
+
"Mothership speaks docking protocol v{}, shuttlebay needs v{VERSION}",
|
|
74
|
+
moored.version
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
let config = ruby.hash_new_capa(moored.config.len());
|
|
78
|
+
for (key, value) in moored.config {
|
|
79
|
+
config.aset(key, value)?;
|
|
80
|
+
}
|
|
81
|
+
Ok(config)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
pub fn read_request(
|
|
85
|
+
ruby: &Ruby,
|
|
86
|
+
rb_self: &Self,
|
|
87
|
+
io: Value,
|
|
88
|
+
template: RHash,
|
|
89
|
+
) -> Result<Option<RHash>, Error> {
|
|
90
|
+
ensure!(
|
|
91
|
+
ruby,
|
|
92
|
+
rb_self.with_state(ruby, |state| state.link.current_state() == LinkState::Idle)?,
|
|
93
|
+
"read_request called before the previous ready"
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
let Some(payload) = rb_self.read_frame(ruby, io, MessageType::Hail)? else {
|
|
97
|
+
return Ok(None);
|
|
98
|
+
};
|
|
99
|
+
let hail = decode_hail(&payload).or_protocol_error(ruby)?;
|
|
100
|
+
let input = rb_self.read_body(ruby, io, &hail)?;
|
|
101
|
+
|
|
102
|
+
let env: RHash = template.funcall("dup", ())?;
|
|
103
|
+
for (name, value) in &hail.params {
|
|
104
|
+
let name = std::str::from_utf8(name)
|
|
105
|
+
.map_or_else(|_| ruby.str_from_slice(name), |name| ruby.str_new(name));
|
|
106
|
+
env.aset(name, ruby.str_from_slice(value))?;
|
|
107
|
+
}
|
|
108
|
+
let secure = hail.params.iter().any(|(name, value)| name == b"HTTPS" && value == b"on");
|
|
109
|
+
env.aset("rack.url_scheme", if secure { "https" } else { "http" })?;
|
|
110
|
+
env.aset("rack.input", input)?;
|
|
111
|
+
|
|
112
|
+
rb_self.try_with_state(ruby, |state| link::begin(&mut state.link, hail.request_id))?;
|
|
113
|
+
Ok(Some(env))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
pub fn reply(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result<(), Error> {
|
|
117
|
+
let args =
|
|
118
|
+
scan_args::<(Value, i64, RHash, Symbol), (Option<Option<RString>>,), (), (), (), ()>(
|
|
119
|
+
args,
|
|
120
|
+
)?;
|
|
121
|
+
let (_io, status, headers, kind) = args.required;
|
|
122
|
+
let (path,) = args.optional;
|
|
123
|
+
let path = path.flatten();
|
|
124
|
+
|
|
125
|
+
let status =
|
|
126
|
+
u16::try_from(status).ok().filter(|status| (100..=599).contains(status)).ok_or_else(
|
|
127
|
+
|| Error::new(ruby.exception_arg_error(), format!("invalid HTTP status: {status}")),
|
|
128
|
+
)?;
|
|
129
|
+
let kind = match kind.name()?.as_ref() {
|
|
130
|
+
"empty" => Kind::Empty,
|
|
131
|
+
"stream" => Kind::Stream,
|
|
132
|
+
"file" => Kind::File,
|
|
133
|
+
"tunnel" => Kind::Tunnel,
|
|
134
|
+
other => {
|
|
135
|
+
return Err(Error::new(
|
|
136
|
+
ruby.exception_arg_error(),
|
|
137
|
+
format!("reply kind must be :empty, :stream, :file or :tunnel, got :{other}"),
|
|
138
|
+
));
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
let body = match (kind, path) {
|
|
142
|
+
(Kind::Empty, None) => ReplyBody::Empty,
|
|
143
|
+
(Kind::Stream, None) => ReplyBody::Stream,
|
|
144
|
+
(Kind::Tunnel, None) => ReplyBody::Tunnel,
|
|
145
|
+
(Kind::File, Some(path)) => ReplyBody::File(path.to_bytes().to_vec()),
|
|
146
|
+
(Kind::File, None) => {
|
|
147
|
+
return Err(Error::new(ruby.exception_arg_error(), "file reply needs a path"));
|
|
148
|
+
}
|
|
149
|
+
(Kind::Empty | Kind::Stream | Kind::Tunnel, Some(_)) => {
|
|
150
|
+
return Err(Error::new(
|
|
151
|
+
ruby.exception_arg_error(),
|
|
152
|
+
"only file replies take a path",
|
|
153
|
+
));
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
let headers = headers::flatten(ruby, headers)?;
|
|
157
|
+
|
|
158
|
+
let event = match kind {
|
|
159
|
+
Kind::Stream => LinkEvent::ReplyStream,
|
|
160
|
+
Kind::Empty | Kind::File => LinkEvent::ReplyWhole,
|
|
161
|
+
Kind::Tunnel => LinkEvent::ReplyTunnel,
|
|
162
|
+
};
|
|
163
|
+
rb_self.try_with_state(ruby, |state| -> Result<(), &str> {
|
|
164
|
+
let (request_id, _) = link::advance(&mut state.link, event, |from| {
|
|
165
|
+
if from == LinkState::Idle {
|
|
166
|
+
"reply called without a request"
|
|
167
|
+
} else {
|
|
168
|
+
"reply already sent for this request"
|
|
169
|
+
}
|
|
170
|
+
})?;
|
|
171
|
+
let frame = encode_reply(&Reply { request_id, status, headers, body })
|
|
172
|
+
.map_err(|_| "reply head exceeds the frame size limit")?;
|
|
173
|
+
state.outbox.extend_from_slice(&frame);
|
|
174
|
+
Ok(())
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
pub fn write(ruby: &Ruby, rb_self: &Self, io: Value, chunk: RString) -> Result<(), Error> {
|
|
179
|
+
let chunk = chunk.to_bytes();
|
|
180
|
+
let pending = rb_self.try_with_state(ruby, |state| -> Result<_, &str> {
|
|
181
|
+
let request_id = link::request_in(
|
|
182
|
+
&state.link,
|
|
183
|
+
LinkState::Streaming,
|
|
184
|
+
"write called without a request",
|
|
185
|
+
"write needs a :stream reply first",
|
|
186
|
+
)?;
|
|
187
|
+
push_body(&mut state.outbox, request_id, &chunk, false)
|
|
188
|
+
.map_err(|_| "body chunk exceeds the frame size limit")?;
|
|
189
|
+
Ok(state.outbox.len())
|
|
190
|
+
})?;
|
|
191
|
+
if pending >= FLUSH_THRESHOLD {
|
|
192
|
+
rb_self.flush_to(ruby, io)?;
|
|
193
|
+
}
|
|
194
|
+
Ok(())
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
pub fn tunnel_write(
|
|
198
|
+
ruby: &Ruby,
|
|
199
|
+
rb_self: &Self,
|
|
200
|
+
io: Value,
|
|
201
|
+
data: RString,
|
|
202
|
+
) -> Result<(), Error> {
|
|
203
|
+
let data = data.to_bytes();
|
|
204
|
+
rb_self.push_tunnel(ruby, &data, false)?;
|
|
205
|
+
rb_self.flush_to(ruby, io)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
pub fn tunnel_close(ruby: &Ruby, rb_self: &Self, io: Value) -> Result<(), Error> {
|
|
209
|
+
rb_self.push_tunnel(ruby, &[], true)?;
|
|
210
|
+
rb_self.flush_to(ruby, io)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
pub fn tunnel_read(ruby: &Ruby, rb_self: &Self, io: Value) -> Result<Option<RString>, Error> {
|
|
214
|
+
let request_id = rb_self.try_with_state(ruby, |state| {
|
|
215
|
+
link::request_in(
|
|
216
|
+
&state.link,
|
|
217
|
+
LinkState::Tunnelling,
|
|
218
|
+
"tunnel_read needs a :tunnel reply",
|
|
219
|
+
"tunnel_read needs a :tunnel reply",
|
|
220
|
+
)
|
|
221
|
+
})?;
|
|
222
|
+
loop {
|
|
223
|
+
let Some(payload) = rb_self.read_frame(ruby, io, MessageType::Haul)? else {
|
|
224
|
+
return Ok(None);
|
|
225
|
+
};
|
|
226
|
+
let haul = decode_haul(&payload).or_protocol_error(ruby)?;
|
|
227
|
+
ensure!(ruby, haul.request_id == request_id, "tunnel frame for another request");
|
|
228
|
+
if haul.fin {
|
|
229
|
+
return Ok(None);
|
|
230
|
+
}
|
|
231
|
+
if !haul.data.is_empty() {
|
|
232
|
+
return Ok(Some(ruby.str_from_slice(haul.data)));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
fn push_tunnel(&self, ruby: &Ruby, data: &[u8], fin: bool) -> Result<(), Error> {
|
|
238
|
+
self.try_with_state(ruby, |state| {
|
|
239
|
+
let request_id = link::request_in(
|
|
240
|
+
&state.link,
|
|
241
|
+
LinkState::Tunnelling,
|
|
242
|
+
"tunnel used without a request",
|
|
243
|
+
"tunnel used without a :tunnel reply",
|
|
244
|
+
)?;
|
|
245
|
+
let pushed = if data.is_empty() {
|
|
246
|
+
push_haul(&mut state.outbox, request_id, fin, &[])
|
|
247
|
+
} else {
|
|
248
|
+
push_body(&mut state.outbox, request_id, data, fin)
|
|
249
|
+
};
|
|
250
|
+
pushed.map_err(|_| "tunnel frame could not be encoded")
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
pub fn flush(ruby: &Ruby, rb_self: &Self, io: Value) -> Result<(), Error> {
|
|
255
|
+
rb_self.flush_to(ruby, io)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
pub fn finish(ruby: &Ruby, rb_self: &Self, io: Value) -> Result<(), Error> {
|
|
259
|
+
rb_self.try_with_state(ruby, |state| -> Result<(), &str> {
|
|
260
|
+
let (request_id, from) =
|
|
261
|
+
link::advance(&mut state.link, LinkEvent::Finish, |from| match from {
|
|
262
|
+
LinkState::Idle => "finish called without a request",
|
|
263
|
+
LinkState::Requested => "finish called before reply",
|
|
264
|
+
LinkState::Tunnelling => "a tunnel has no finish; close it instead",
|
|
265
|
+
LinkState::Finished | LinkState::Streaming | LinkState::Replied => {
|
|
266
|
+
"finish called twice"
|
|
267
|
+
}
|
|
268
|
+
})?;
|
|
269
|
+
if from == LinkState::Streaming {
|
|
270
|
+
push_haul(&mut state.outbox, request_id, true, &[])
|
|
271
|
+
.map_err(|_| "FIN frame could not be encoded")?;
|
|
272
|
+
}
|
|
273
|
+
Ok(())
|
|
274
|
+
})?;
|
|
275
|
+
rb_self.flush_to(ruby, io)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
pub fn ready(ruby: &Ruby, rb_self: &Self, io: Value) -> Result<(), Error> {
|
|
279
|
+
rb_self.try_with_state(ruby, |state| -> Result<(), &str> {
|
|
280
|
+
let (request_id, _) = link::advance(&mut state.link, LinkEvent::Ready, |from| {
|
|
281
|
+
if from == LinkState::Idle {
|
|
282
|
+
"ready called without a request"
|
|
283
|
+
} else {
|
|
284
|
+
"ready called before finish"
|
|
285
|
+
}
|
|
286
|
+
})?;
|
|
287
|
+
state.outbox.extend_from_slice(&encode_ready(request_id));
|
|
288
|
+
Ok(())
|
|
289
|
+
})?;
|
|
290
|
+
rb_self.flush_to(ruby, io)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
pub fn request_id(ruby: &Ruby, rb_self: &Self) -> Result<Option<u32>, Error> {
|
|
294
|
+
rb_self.with_state(ruby, |state| link::request_id(&state.link))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
fn with_state<T>(&self, ruby: &Ruby, f: impl FnOnce(&mut State) -> T) -> Result<T, Error> {
|
|
298
|
+
let mut state = self
|
|
299
|
+
.state
|
|
300
|
+
.try_borrow_mut()
|
|
301
|
+
.map_err(|_| protocol_error(ruby, "codec is busy in another thread"))?;
|
|
302
|
+
Ok(f(&mut state))
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
fn try_with_state<T, E: Display>(
|
|
306
|
+
&self,
|
|
307
|
+
ruby: &Ruby,
|
|
308
|
+
f: impl FnOnce(&mut State) -> Result<T, E>,
|
|
309
|
+
) -> Result<T, Error> {
|
|
310
|
+
self.with_state(ruby, f)?.or_protocol_error(ruby)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
fn flush_to(&self, ruby: &Ruby, io: Value) -> Result<(), Error> {
|
|
314
|
+
let outbox = self.with_state(ruby, |state| std::mem::take(&mut state.outbox))?;
|
|
315
|
+
if outbox.is_empty() {
|
|
316
|
+
return Ok(());
|
|
317
|
+
}
|
|
318
|
+
let _: Value = io.funcall("write", (ruby.str_from_slice(&outbox),))?;
|
|
319
|
+
Ok(())
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
fn read_body(&self, ruby: &Ruby, io: Value, hail: &Hail) -> Result<Value, Error> {
|
|
323
|
+
ensure!(
|
|
324
|
+
ruby,
|
|
325
|
+
hail.content_length <= MAX_BODY,
|
|
326
|
+
"request body of {} bytes exceeds the engine limit",
|
|
327
|
+
hail.content_length
|
|
328
|
+
);
|
|
329
|
+
let expected = usize::try_from(hail.content_length)
|
|
330
|
+
.map_err(|_| protocol_error(ruby, "request body does not fit in memory"))?;
|
|
331
|
+
let mut sink = if hail.content_length > SPOOL_THRESHOLD {
|
|
332
|
+
BodySink::Spool { file: spool_file(ruby)?, written: 0 }
|
|
333
|
+
} else {
|
|
334
|
+
BodySink::Memory(Vec::with_capacity(expected))
|
|
335
|
+
};
|
|
336
|
+
if expected > 0 {
|
|
337
|
+
self.receive_body(ruby, io, hail, expected, &mut sink)?;
|
|
338
|
+
}
|
|
339
|
+
sink.into_input(ruby)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
fn receive_body(
|
|
343
|
+
&self,
|
|
344
|
+
ruby: &Ruby,
|
|
345
|
+
io: Value,
|
|
346
|
+
hail: &Hail,
|
|
347
|
+
expected: usize,
|
|
348
|
+
body: &mut BodySink,
|
|
349
|
+
) -> Result<(), Error> {
|
|
350
|
+
loop {
|
|
351
|
+
let payload = self
|
|
352
|
+
.read_frame(ruby, io, MessageType::Haul)?
|
|
353
|
+
.ok_or("Mothership closed the link mid-body")
|
|
354
|
+
.or_protocol_error(ruby)?;
|
|
355
|
+
let chunk = decode_haul(&payload).or_protocol_error(ruby)?;
|
|
356
|
+
ensure!(
|
|
357
|
+
ruby,
|
|
358
|
+
chunk.request_id == hail.request_id,
|
|
359
|
+
"HAUL for request {} while reading request {}",
|
|
360
|
+
chunk.request_id,
|
|
361
|
+
hail.request_id
|
|
362
|
+
);
|
|
363
|
+
ensure!(
|
|
364
|
+
ruby,
|
|
365
|
+
body.len() + chunk.data.len() <= expected,
|
|
366
|
+
"request body longer than CONTENT_LENGTH"
|
|
367
|
+
);
|
|
368
|
+
body.push(ruby, chunk.data)?;
|
|
369
|
+
if chunk.fin {
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
ensure!(
|
|
375
|
+
ruby,
|
|
376
|
+
body.len() == expected,
|
|
377
|
+
"request body ended after {} of {expected} bytes",
|
|
378
|
+
body.len()
|
|
379
|
+
);
|
|
380
|
+
Ok(())
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
fn read_frame(
|
|
384
|
+
&self,
|
|
385
|
+
ruby: &Ruby,
|
|
386
|
+
io: Value,
|
|
387
|
+
expected: MessageType,
|
|
388
|
+
) -> Result<Option<Vec<u8>>, Error> {
|
|
389
|
+
loop {
|
|
390
|
+
if let Some((msg_type, payload)) =
|
|
391
|
+
self.try_with_state(ruby, |state| take_frame(&mut state.inbox))?
|
|
392
|
+
{
|
|
393
|
+
ensure!(ruby, msg_type == expected, "Expected {expected:?}, got {msg_type:?}");
|
|
394
|
+
return Ok(Some(payload));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
let chunk = match io.funcall::<_, _, RString>("readpartial", (READ_CHUNK,)) {
|
|
398
|
+
Ok(chunk) => chunk.to_bytes(),
|
|
399
|
+
Err(error) if error.is_kind_of(ruby.exception_eof_error()) => {
|
|
400
|
+
let buffered = self.with_state(ruby, |state| state.inbox.len())?;
|
|
401
|
+
ensure!(
|
|
402
|
+
ruby,
|
|
403
|
+
buffered == 0,
|
|
404
|
+
"link closed with {buffered} bytes of a partial frame"
|
|
405
|
+
);
|
|
406
|
+
return Ok(None);
|
|
407
|
+
}
|
|
408
|
+
Err(error) => return Err(error),
|
|
409
|
+
};
|
|
410
|
+
self.with_state(ruby, |state| state.inbox.extend_from_slice(&chunk))?;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
enum BodySink {
|
|
416
|
+
Memory(Vec<u8>),
|
|
417
|
+
Spool { file: Value, written: usize },
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
impl BodySink {
|
|
421
|
+
const fn len(&self) -> usize {
|
|
422
|
+
match self {
|
|
423
|
+
Self::Memory(bytes) => bytes.len(),
|
|
424
|
+
Self::Spool { written, .. } => *written,
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
fn push(&mut self, ruby: &Ruby, data: &[u8]) -> Result<(), Error> {
|
|
429
|
+
match self {
|
|
430
|
+
Self::Memory(bytes) => bytes.extend_from_slice(data),
|
|
431
|
+
Self::Spool { file, written } => {
|
|
432
|
+
let _: Value = file.funcall("write", (ruby.str_from_slice(data),))?;
|
|
433
|
+
*written += data.len();
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
Ok(())
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
fn into_input(self, ruby: &Ruby) -> Result<Value, Error> {
|
|
440
|
+
match self {
|
|
441
|
+
Self::Memory(bytes) => string_io(ruby)?.funcall("new", (ruby.str_from_slice(&bytes),)),
|
|
442
|
+
Self::Spool { file, .. } => {
|
|
443
|
+
let _: Value = file.funcall("rewind", ())?;
|
|
444
|
+
Ok(file)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
fn push_body(
|
|
451
|
+
outbox: &mut Vec<u8>,
|
|
452
|
+
request_id: u32,
|
|
453
|
+
data: &[u8],
|
|
454
|
+
fin: bool,
|
|
455
|
+
) -> Result<(), ProtocolError> {
|
|
456
|
+
let mut pieces = data.chunks(HAUL_CHUNK_LEN).peekable();
|
|
457
|
+
while let Some(piece) = pieces.next() {
|
|
458
|
+
push_haul(outbox, request_id, fin && pieces.peek().is_none(), piece)?;
|
|
459
|
+
}
|
|
460
|
+
Ok(())
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
fn take_frame(inbox: &mut Vec<u8>) -> Result<Option<(MessageType, Vec<u8>)>, String> {
|
|
464
|
+
if inbox.len() < 5 {
|
|
465
|
+
return Ok(None);
|
|
466
|
+
}
|
|
467
|
+
let (msg_type, len) = decode_header(inbox).map_err(|error| error.to_string())?;
|
|
468
|
+
if len > MAX_FRAME_PAYLOAD {
|
|
469
|
+
return Err(format!("{msg_type:?} frame of {len} bytes exceeds the frame size limit"));
|
|
470
|
+
}
|
|
471
|
+
if inbox.len() < 5 + len {
|
|
472
|
+
return Ok(None);
|
|
473
|
+
}
|
|
474
|
+
let payload = inbox[5..5 + len].to_vec();
|
|
475
|
+
inbox.drain(..5 + len);
|
|
476
|
+
Ok(Some((msg_type, payload)))
|
|
477
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
use magnus::{Error, RArray, RHash, RString, Ruby, Value, prelude::*, r_hash::ForEach};
|
|
2
|
+
use mothership_docking_protocol::Pair;
|
|
3
|
+
|
|
4
|
+
pub fn flatten(ruby: &Ruby, headers: RHash) -> Result<Vec<Pair>, Error> {
|
|
5
|
+
let mut pairs = Vec::with_capacity(headers.len());
|
|
6
|
+
headers.foreach(|name: Value, value: Value| {
|
|
7
|
+
let name = header_string(ruby, name, "header name")?.to_bytes().to_vec();
|
|
8
|
+
if let Some(values) = RArray::from_value(value) {
|
|
9
|
+
for index in 0..values.len() {
|
|
10
|
+
let offset = isize::try_from(index).map_err(|_| {
|
|
11
|
+
Error::new(ruby.exception_range_error(), "header array too long")
|
|
12
|
+
})?;
|
|
13
|
+
let item = header_string(ruby, values.entry(offset)?, "header value")?;
|
|
14
|
+
pairs.push((name.clone(), item.to_bytes().to_vec()));
|
|
15
|
+
}
|
|
16
|
+
} else {
|
|
17
|
+
let value = header_string(ruby, value, "header value")?.to_bytes();
|
|
18
|
+
for line in value.split(|byte| *byte == b'\n') {
|
|
19
|
+
pairs.push((name.clone(), line.to_vec()));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
Ok(ForEach::Continue)
|
|
23
|
+
})?;
|
|
24
|
+
Ok(pairs)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
fn header_string(ruby: &Ruby, value: Value, what: &str) -> Result<RString, Error> {
|
|
28
|
+
RString::from_value(value).ok_or_else(|| {
|
|
29
|
+
Error::new(
|
|
30
|
+
ruby.exception_type_error(),
|
|
31
|
+
format!("{what} must be a String, got {}", value.class().inspect()),
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
use std::{fmt::Display, sync::OnceLock};
|
|
2
|
+
|
|
3
|
+
use magnus::{
|
|
4
|
+
Error, ExceptionClass, Module, Object, RClass, RModule, Ruby, Value, function, method,
|
|
5
|
+
value::{Opaque, ReprValue},
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
macro_rules! ensure {
|
|
9
|
+
($ruby:expr, $cond:expr, $($message:tt)+) => {
|
|
10
|
+
if !$cond {
|
|
11
|
+
return Err($crate::protocol_error($ruby, format!($($message)+)));
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
mod codec;
|
|
17
|
+
mod headers;
|
|
18
|
+
mod link;
|
|
19
|
+
|
|
20
|
+
use codec::Codec;
|
|
21
|
+
|
|
22
|
+
static PROTOCOL_ERROR: OnceLock<Opaque<ExceptionClass>> = OnceLock::new();
|
|
23
|
+
static STRING_IO: OnceLock<Opaque<RClass>> = OnceLock::new();
|
|
24
|
+
static ENGINE: OnceLock<Opaque<RModule>> = OnceLock::new();
|
|
25
|
+
|
|
26
|
+
pub(crate) fn protocol_error(ruby: &Ruby, message: impl Into<String>) -> Error {
|
|
27
|
+
let class = cached(ruby, &PROTOCOL_ERROR).unwrap_or_else(|| ruby.exception_runtime_error());
|
|
28
|
+
Error::new(class, message.into())
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
pub(crate) trait OrProtocolError<T> {
|
|
32
|
+
fn or_protocol_error(self, ruby: &Ruby) -> Result<T, Error>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
impl<T, E: Display> OrProtocolError<T> for Result<T, E> {
|
|
36
|
+
fn or_protocol_error(self, ruby: &Ruby) -> Result<T, Error> {
|
|
37
|
+
self.map_err(|error| protocol_error(ruby, error.to_string()))
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
pub(crate) fn spool_file(ruby: &Ruby) -> Result<Value, Error> {
|
|
42
|
+
initialised(ruby, &ENGINE, "Shuttlebay::Engine was not initialised")?.funcall("spool_file", ())
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
pub(crate) fn string_io(ruby: &Ruby) -> Result<RClass, Error> {
|
|
46
|
+
initialised(ruby, &STRING_IO, "StringIO was not resolved at load time")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fn cache<T: ReprValue>(cell: &OnceLock<Opaque<T>>, value: T) {
|
|
50
|
+
let _ = cell.set(Opaque::from(value));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
fn cached<T: ReprValue>(ruby: &Ruby, cell: &OnceLock<Opaque<T>>) -> Option<T> {
|
|
54
|
+
cell.get().map(|value| ruby.get_inner(*value))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
fn initialised<T: ReprValue>(
|
|
58
|
+
ruby: &Ruby,
|
|
59
|
+
cell: &OnceLock<Opaque<T>>,
|
|
60
|
+
missing: &'static str,
|
|
61
|
+
) -> Result<T, Error> {
|
|
62
|
+
cached(ruby, cell).ok_or(missing).or_protocol_error(ruby)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#[magnus::init(name = "shuttlebay")]
|
|
66
|
+
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
67
|
+
ruby.require("stringio")?;
|
|
68
|
+
cache(&STRING_IO, ruby.class_object().const_get::<_, RClass>("StringIO")?);
|
|
69
|
+
|
|
70
|
+
let shuttlebay = ruby.define_module("Shuttlebay")?;
|
|
71
|
+
let engine = shuttlebay.define_module("Engine")?;
|
|
72
|
+
cache(&ENGINE, engine);
|
|
73
|
+
engine.const_set("VERSION", env!("CARGO_PKG_VERSION"))?;
|
|
74
|
+
engine.const_set("SPOOL_THRESHOLD", codec::SPOOL_THRESHOLD)?;
|
|
75
|
+
engine.const_set("PROTOCOL_VERSION", mothership_docking_protocol::VERSION)?;
|
|
76
|
+
engine.const_set("HAUL_CHUNK_LEN", mothership_docking_protocol::HAUL_CHUNK_LEN)?;
|
|
77
|
+
|
|
78
|
+
cache(&PROTOCOL_ERROR, engine.define_error("ProtocolError", ruby.exception_standard_error())?);
|
|
79
|
+
|
|
80
|
+
let codec = engine.define_class("Codec", ruby.class_object())?;
|
|
81
|
+
codec.define_singleton_method("new", function!(Codec::new, 0))?;
|
|
82
|
+
codec.define_method("dock", method!(Codec::dock, 3))?;
|
|
83
|
+
codec.define_method("read_request", method!(Codec::read_request, 2))?;
|
|
84
|
+
codec.define_method("reply", method!(Codec::reply, -1))?;
|
|
85
|
+
codec.define_method("write", method!(Codec::write, 2))?;
|
|
86
|
+
codec.define_method("flush", method!(Codec::flush, 1))?;
|
|
87
|
+
codec.define_method("finish", method!(Codec::finish, 1))?;
|
|
88
|
+
codec.define_method("ready", method!(Codec::ready, 1))?;
|
|
89
|
+
codec.define_method("tunnel_read", method!(Codec::tunnel_read, 1))?;
|
|
90
|
+
codec.define_method("tunnel_write", method!(Codec::tunnel_write, 2))?;
|
|
91
|
+
codec.define_method("tunnel_close", method!(Codec::tunnel_close, 1))?;
|
|
92
|
+
codec.define_method("request_id", method!(Codec::request_id, 0))?;
|
|
93
|
+
Ok(())
|
|
94
|
+
}
|