castrum 0.9.4 → 0.9.6
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.
- package/Cargo.lock +25 -42
- package/Cargo.toml +21 -4
- package/README.md +8 -6
- package/castrum.darwin-arm64.node +0 -0
- package/castrum.darwin-x64.node +0 -0
- package/castrum.linux-arm64-gnu.node +0 -0
- package/castrum.linux-arm64-musl.node +0 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/castrum.linux-x64-musl.node +0 -0
- package/castrum.linux-x64-v3-gnu.node +0 -0
- package/castrum.win32-x64-msvc.node +0 -0
- package/dist/index.d.ts +164 -0
- package/dist/index.js +4432 -3641
- package/docs/API.md +1 -0
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CASE_STUDY.md +4 -2
- package/docs/ENVIRONMENT.md +9 -2
- package/docs/FFI_BUN_GUIDE.md +154 -9
- package/docs/HOW_TO_ADD_AN_OP.md +10 -0
- package/docs/REPO_MAP.md +16 -5
- package/docs/RND-CONCURRENCY.md +772 -0
- package/docs/bun-builtins-decision-matrix.md +4 -1
- package/index.ts +5 -0
- package/package.json +172 -163
- package/rust/crypto/pbkdf2.rs +9 -0
- package/rust/ffi/mod.rs +1 -0
- package/rust/ffi/task.rs +172 -0
- package/rust/http/form.rs +6 -2
- package/rust/http/query_parser.rs +53 -22
- package/rust/ingress/native_route.rs +131 -140
- package/rust/ingress/pipeline.rs +3 -3
- package/rust/ingress/tests.rs +5 -4
- package/rust/json/json_ser.rs +13 -11
- package/rust/lib.rs +1 -0
- package/rust/task/completion.rs +119 -0
- package/rust/task/mod.rs +65 -0
- package/rust/task/ops.rs +503 -0
- package/rust/task/runtime.rs +220 -0
- package/rust/task/tests.rs +231 -0
- package/rust/util/bytes.rs +238 -51
- package/src/ingress/health.ts +1 -2
- package/src/native/ffi/build/codecs.ts +29 -7
- package/src/native/ffi/build/compress.ts +8 -0
- package/src/native/ffi/build/instances.ts +7 -2
- package/src/native/ffi/build/metrics.ts +23 -0
- package/src/native/ffi/build/task.ts +79 -0
- package/src/native/ffi/build/util.ts +18 -0
- package/src/native/ffi/build.ts +2 -0
- package/src/native/ffi/selftest.ts +3 -1
- package/src/native/ffi/types.ts +40 -0
- package/src/native/ffi.ts +73 -14
- package/src/native/loader.ts +53 -3
- package/src/rust-ffi/scalar/http.ts +54 -16
- package/src/rust-ffi/text.ts +25 -3
- package/src/shared/bytes.ts +19 -0
- package/src/task/index.ts +24 -0
- package/src/task/op.ts +171 -0
- package/src/task/runtime.ts +568 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// rust/task/completion.rs — the completion ring + batched doorbell.
|
|
2
|
+
//
|
|
3
|
+
// THE performance-critical piece. A pooled native thread never returns to JS
|
|
4
|
+
// directly; it pushes a `Completion` here and (at most once per batch) calls a
|
|
5
|
+
// single thread-safe trampoline so the JS thread can drain everything in ONE
|
|
6
|
+
// `castrum_task_drain` call. That amortizes the ~666 ns/crossing threadsafe
|
|
7
|
+
// callback over a whole batch instead of paying it per task.
|
|
8
|
+
//
|
|
9
|
+
// PACKED DRAIN LAYOUT (`out`):
|
|
10
|
+
// [u32 count LE]
|
|
11
|
+
// repeat count: [u64 id LE][u32 status][u32 len][len bytes]
|
|
12
|
+
//
|
|
13
|
+
// Needed-size convention (repo-wide): when `out` is too small the EXACT
|
|
14
|
+
// required byte count is returned and nothing is consumed, so JS allocates
|
|
15
|
+
// once and retries once. `0` means "nothing pending".
|
|
16
|
+
//
|
|
17
|
+
// NOTE (2026-09-11): a pointer-based zero-copy variant was prototyped and
|
|
18
|
+
// REVERTED — Bun 1.4.2's `toArrayBuffer` deallocator hook segfaults (see
|
|
19
|
+
// docs/RND-CONCURRENCY.md §7a). Zero-copy will instead pre-allocate the
|
|
20
|
+
// destination in JS and have the pool thread write into it directly.
|
|
21
|
+
|
|
22
|
+
use parking_lot::Mutex;
|
|
23
|
+
use std::collections::VecDeque;
|
|
24
|
+
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
|
25
|
+
use std::sync::OnceLock;
|
|
26
|
+
|
|
27
|
+
/// One finished task. `status`: 0 = ok, 1 = error, 2 = cancelled. `body` is
|
|
28
|
+
/// the result bytes, or the UTF-8 error message when `status != 0`.
|
|
29
|
+
pub struct Completion {
|
|
30
|
+
pub id: u64,
|
|
31
|
+
pub status: u32,
|
|
32
|
+
pub body: Vec<u8>,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// Completions waiting to be drained. Lazily initialised WITH capacity so a
|
|
36
|
+
/// burst (hundreds of tasks finishing together) does not pay repeated VecDeque
|
|
37
|
+
/// growth reallocations on the worker threads.
|
|
38
|
+
static RING: OnceLock<Mutex<VecDeque<Completion>>> = OnceLock::new();
|
|
39
|
+
|
|
40
|
+
/// One burst's worth of entries before the first realloc.
|
|
41
|
+
const RING_CAPACITY: usize = 256;
|
|
42
|
+
|
|
43
|
+
fn ring() -> &'static Mutex<VecDeque<Completion>> {
|
|
44
|
+
RING.get_or_init(|| Mutex::new(VecDeque::with_capacity(RING_CAPACITY)))
|
|
45
|
+
}
|
|
46
|
+
/// `true` while a doorbell call is outstanding, so N completions in a burst
|
|
47
|
+
/// wake JS once, not N times.
|
|
48
|
+
static ARMED: AtomicBool = AtomicBool::new(false);
|
|
49
|
+
/// Address of the thread-safe trampoline (`JSCallback({threadsafe:true}).ptr`).
|
|
50
|
+
static DOORBELL: AtomicUsize = AtomicUsize::new(0);
|
|
51
|
+
|
|
52
|
+
/// Register the doorbell trampoline. `0` disables it (poll-only mode).
|
|
53
|
+
pub fn set_doorbell(cb: usize) {
|
|
54
|
+
DOORBELL.store(cb, Ordering::Release);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// Push a completion and ring the doorbell at most once per armed window.
|
|
58
|
+
///
|
|
59
|
+
/// The ring lock is released BEFORE the trampoline is called: the JS callback
|
|
60
|
+
/// re-enters `drain`, which locks the ring again — holding it here would
|
|
61
|
+
/// deadlock the process.
|
|
62
|
+
pub fn push(c: Completion) {
|
|
63
|
+
let should_ring = {
|
|
64
|
+
let mut q = ring().lock();
|
|
65
|
+
q.push_back(c);
|
|
66
|
+
!ARMED.swap(true, Ordering::AcqRel)
|
|
67
|
+
};
|
|
68
|
+
if should_ring {
|
|
69
|
+
let p = DOORBELL.load(Ordering::Acquire);
|
|
70
|
+
if p != 0 {
|
|
71
|
+
// SAFETY: `p` is a live `JSCallback` thread-safe trampoline whose
|
|
72
|
+
// JS owner is held for the process lifetime by the task runtime.
|
|
73
|
+
let f: extern "C" fn() = unsafe { std::mem::transmute(p) };
|
|
74
|
+
f();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// Drain every pending completion into `out`. Returns bytes written, `0` when
|
|
80
|
+
/// nothing is pending, or the exact required size when `out` is too small.
|
|
81
|
+
pub fn drain(out: &mut [u8]) -> usize {
|
|
82
|
+
// Disarm FIRST so a completion pushed while we drain re-arms and rings the
|
|
83
|
+
// bell instead of being stranded (see the module note).
|
|
84
|
+
ARMED.store(false, Ordering::Release);
|
|
85
|
+
let mut q = ring().lock();
|
|
86
|
+
if q.is_empty() {
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
let mut needed = 4usize;
|
|
90
|
+
for c in q.iter() {
|
|
91
|
+
needed += 16 + c.body.len();
|
|
92
|
+
}
|
|
93
|
+
if out.len() < needed {
|
|
94
|
+
return needed;
|
|
95
|
+
}
|
|
96
|
+
out[0..4].copy_from_slice(&(q.len() as u32).to_le_bytes());
|
|
97
|
+
let mut off = 4usize;
|
|
98
|
+
for c in q.iter() {
|
|
99
|
+
out[off..off + 8].copy_from_slice(&c.id.to_le_bytes());
|
|
100
|
+
out[off + 8..off + 12].copy_from_slice(&c.status.to_le_bytes());
|
|
101
|
+
out[off + 12..off + 16].copy_from_slice(&(c.body.len() as u32).to_le_bytes());
|
|
102
|
+
out[off + 16..off + 16 + c.body.len()].copy_from_slice(&c.body);
|
|
103
|
+
off += 16 + c.body.len();
|
|
104
|
+
}
|
|
105
|
+
q.clear();
|
|
106
|
+
needed
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// Completions waiting to be drained.
|
|
110
|
+
pub fn pending() -> u32 {
|
|
111
|
+
ring().lock().len() as u32
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// Drop every pending completion (shutdown / test isolation). Bodies already
|
|
115
|
+
/// handed to JS by `drain_ptrs` are owned by JS and are NOT freed here.
|
|
116
|
+
pub fn clear() {
|
|
117
|
+
ring().lock().clear();
|
|
118
|
+
ARMED.store(false, Ordering::Release);
|
|
119
|
+
}
|
package/rust/task/mod.rs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// rust/task — off-thread task runtime ("castrum Tasks").
|
|
2
|
+
//
|
|
3
|
+
// A Bun-first answer to synchronous FFI: any CPU-bound native op that would
|
|
4
|
+
// otherwise freeze the JS thread is submitted as a task, runs on a dedicated
|
|
5
|
+
// pool thread (NOT the rayon batch pool), and resolves a JS promise through a
|
|
6
|
+
// BATCHED completion doorbell. See docs/RND-CONCURRENCY.md for the design and
|
|
7
|
+
// the measured Bun concurrency costs.
|
|
8
|
+
//
|
|
9
|
+
// runtime.rs pool lifecycle (once-per-process) + admission
|
|
10
|
+
// completion.rs result ring + batched thread-safe doorbell (the fast path)
|
|
11
|
+
// ops.rs op dispatch table + cancellation + panic containment
|
|
12
|
+
// tests.rs pool/ring/cancel/panic/doorbell unit tests
|
|
13
|
+
//
|
|
14
|
+
// Safety contract (identical to the rest of the C-ABI surface): every task body
|
|
15
|
+
// runs under `catch_unwind`; no `Mutex` is held across the doorbell call; the
|
|
16
|
+
// pool owns every input buffer after `submit` returns.
|
|
17
|
+
|
|
18
|
+
pub mod completion;
|
|
19
|
+
pub mod ops;
|
|
20
|
+
pub mod runtime;
|
|
21
|
+
|
|
22
|
+
#[cfg(test)]
|
|
23
|
+
mod tests;
|
|
24
|
+
|
|
25
|
+
pub use completion::{drain, pending, set_doorbell};
|
|
26
|
+
pub use ops::{mark_cancelled, STATUS_CANCELLED, STATUS_ERROR, STATUS_OK};
|
|
27
|
+
pub use runtime::{init, shutdown, threads};
|
|
28
|
+
|
|
29
|
+
/// Submit one task op with packed `args`, completing under `id`.
|
|
30
|
+
///
|
|
31
|
+
/// The pool auto-starts (default worker count) on first use, so callers do not
|
|
32
|
+
/// have to sequence `init` before `submit`. `args` is moved onto the worker.
|
|
33
|
+
pub fn submit_op(op: u32, args: Vec<u8>, id: u64) -> bool {
|
|
34
|
+
runtime::submit(move || ops::execute_guarded(op, &args, id))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Submit one zero-copy task op: the pool thread writes its result DIRECTLY
|
|
38
|
+
/// into the caller's `out` buffer, and the completion body is the 8-byte LE
|
|
39
|
+
/// written length instead of the data. This removes the last full-size copy and
|
|
40
|
+
/// its allocation from the JS thread (see docs/RND-CONCURRENCY.md §7a).
|
|
41
|
+
///
|
|
42
|
+
/// # Safety
|
|
43
|
+
/// `out` must point to a buffer of at least `out_cap` bytes that stays alive
|
|
44
|
+
/// and is not touched by JS until the completion for `id` is drained.
|
|
45
|
+
pub fn submit_op_into(op: u32, args: Vec<u8>, id: u64, out: usize, out_cap: usize) -> bool {
|
|
46
|
+
runtime::submit(move || unsafe {
|
|
47
|
+
ops::execute_into_guarded(op, &args, id, out as *mut u8, out_cap)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Submit one task whose PAYLOAD is read IN PLACE from the caller's buffer
|
|
52
|
+
/// (zero-copy INPUT): only the `hdr` header bytes are copied, so a large
|
|
53
|
+
/// argument never crosses the boundary. Without this, offloading a large
|
|
54
|
+
/// op pays a full-size copy on the JS thread — measured to make
|
|
55
|
+
/// `gzipCompress` of 24 MiB SLOWER than the synchronous built-in it replaces
|
|
56
|
+
/// (18.6 ms / 11.7 ms stall vs 5.6 ms), which is the opposite of the point.
|
|
57
|
+
///
|
|
58
|
+
/// # Safety
|
|
59
|
+
/// `data`/`data_len` must describe a readable region that stays alive and
|
|
60
|
+
/// unmodified until the completion for `id` is drained.
|
|
61
|
+
pub fn submit_slice_op(op: u32, hdr: Vec<u8>, data: usize, data_len: usize, id: u64) -> bool {
|
|
62
|
+
runtime::submit(move || unsafe {
|
|
63
|
+
ops::execute_slice_guarded(op, &hdr, data as *const u8, data_len, id)
|
|
64
|
+
})
|
|
65
|
+
}
|
package/rust/task/ops.rs
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
// rust/task/ops.rs — the task op dispatch table + cancellation.
|
|
2
|
+
//
|
|
3
|
+
// v1 ships ONE op (`gzip.decompress`) because it is the clearest possible
|
|
4
|
+
// story: castrum deliberately keeps gzip decompression native (Bun's
|
|
5
|
+
// `Bun.gunzipSync` has no decompression-bomb cap) and it is the op that
|
|
6
|
+
// measured a 28.5 ms event-loop stall — so offloading it is a real win, not a
|
|
7
|
+
// benchmark contrivance. New ops are one arm each; the packed arg convention
|
|
8
|
+
// is `[u32 op-specific header][payload]`.
|
|
9
|
+
|
|
10
|
+
use parking_lot::Mutex;
|
|
11
|
+
use std::collections::HashSet;
|
|
12
|
+
|
|
13
|
+
use crate::crypto::argon2::verify_password;
|
|
14
|
+
use crate::payload::compress::{
|
|
15
|
+
brotli_decompress_bytes, brotli_decompress_into, gzip_compress_bytes, gzip_decompress_bytes,
|
|
16
|
+
gzip_decompress_into, StreamError, DEFAULT_MAX_DECOMPRESSED,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
use super::completion::{self, Completion};
|
|
20
|
+
|
|
21
|
+
/// `gzip.decompress` — args `[u32 maxDecompressed LE][gzip bytes]`
|
|
22
|
+
/// (0 = {@link DEFAULT_MAX_DECOMPRESSED}).
|
|
23
|
+
pub const OP_GZIP_DECOMPRESS: u32 = 1;
|
|
24
|
+
|
|
25
|
+
/// `pbkdf2.sha256` — args `[u32 rounds][u32 dkLen][u32 saltLen][salt][password]`.
|
|
26
|
+
/// The ideal offload shape: 10-200 ms of pure CPU, 1-64 bytes of output.
|
|
27
|
+
pub const OP_PBKDF2_SHA256: u32 = 2;
|
|
28
|
+
|
|
29
|
+
/// `gzip.decompress` writing directly into a caller-provided buffer
|
|
30
|
+
/// (zero-copy output): args `[u32 maxDecompressed LE][gzip bytes]`. The
|
|
31
|
+
/// completion body is the 8-byte LE written length, not the data.
|
|
32
|
+
pub const OP_GZIP_DECOMPRESS_INTO: u32 = 3;
|
|
33
|
+
|
|
34
|
+
/// `argon2id.verify` — args `[u32 pwLen][u32 phcLen][password][phc]`; the body
|
|
35
|
+
/// is ONE byte (`1` = match). The other textbook offload shape: the PHC string
|
|
36
|
+
/// pins the cost parameters, so this is tens to hundreds of ms of CPU for a
|
|
37
|
+
/// one-bit answer.
|
|
38
|
+
pub const OP_ARGON2_VERIFY: u32 = 4;
|
|
39
|
+
|
|
40
|
+
/// `brotli.decompress` — args `[u32 maxDecompressed LE][brotli bytes]`.
|
|
41
|
+
pub const OP_BROTLI_DECOMPRESS: u32 = 5;
|
|
42
|
+
|
|
43
|
+
/// `brotli.decompress` into a caller buffer (zero-copy), same args. Brotli has
|
|
44
|
+
/// no ISIZE trailer, so the caller guesses a capacity and retries once on
|
|
45
|
+
/// {@link STATUS_TOO_SMALL} — the needed-size convention covers it.
|
|
46
|
+
pub const OP_BROTLI_DECOMPRESS_INTO: u32 = 6;
|
|
47
|
+
|
|
48
|
+
/// `gzip.compress` — args `[u32 level][gzip bytes]`. Worth offloading even on
|
|
49
|
+
/// Bun: `Bun.gzipSync` measured a 7.6 ms loop stall on 24 MiB. Deliberately has
|
|
50
|
+
/// NO zero-copy (`_into`) form: a compressed result is small relative to its
|
|
51
|
+
/// input, so pre-sizing a destination from the worst-case bound would allocate
|
|
52
|
+
/// far more than the copy it saves. The copy path is the right shape here.
|
|
53
|
+
pub const OP_GZIP_COMPRESS: u32 = 7;
|
|
54
|
+
|
|
55
|
+
/// Test-only op whose body panics — pins the `catch_unwind` containment.
|
|
56
|
+
#[cfg(test)]
|
|
57
|
+
pub const OP_TEST_PANIC: u32 = 0xFFFF;
|
|
58
|
+
|
|
59
|
+
pub const STATUS_OK: u32 = 0;
|
|
60
|
+
pub const STATUS_ERROR: u32 = 1;
|
|
61
|
+
pub const STATUS_CANCELLED: u32 = 2;
|
|
62
|
+
/// The caller's output buffer was too small; the body carries the exact
|
|
63
|
+
/// required size as 8 bytes LE so the caller can retry once.
|
|
64
|
+
pub const STATUS_TOO_SMALL: u32 = 3;
|
|
65
|
+
/// The op has no zero-copy (`_into`) form; the caller falls back to the copy
|
|
66
|
+
/// path. Keeps an older/stale addon from breaking the caller.
|
|
67
|
+
pub const STATUS_UNSUPPORTED: u32 = 4;
|
|
68
|
+
|
|
69
|
+
static CANCELLED: Mutex<Option<HashSet<u64>>> = Mutex::new(None);
|
|
70
|
+
|
|
71
|
+
/// Mark a task id cancelled. Returns `true` the first time, `false` if the id
|
|
72
|
+
/// was already marked (or unknown — the task may have finished).
|
|
73
|
+
pub fn mark_cancelled(id: u64) -> bool {
|
|
74
|
+
CANCELLED.lock().get_or_insert_with(HashSet::new).insert(id)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fn is_cancelled(id: u64) -> bool {
|
|
78
|
+
CANCELLED.lock().as_ref().is_some_and(|s| s.contains(&id))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fn clear_cancelled(id: u64) {
|
|
82
|
+
if let Some(s) = CANCELLED.lock().as_mut() {
|
|
83
|
+
s.remove(&id);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// Run one task and publish its completion. Never unwinds outward.
|
|
88
|
+
pub fn execute_guarded(op: u32, args: &[u8], id: u64) {
|
|
89
|
+
let n = header_len(op).min(args.len());
|
|
90
|
+
let (hdr, data) = args.split_at(n);
|
|
91
|
+
let panicked =
|
|
92
|
+
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| execute(op, hdr, data, id)))
|
|
93
|
+
.is_err();
|
|
94
|
+
if panicked {
|
|
95
|
+
clear_cancelled(id);
|
|
96
|
+
completion::push(Completion {
|
|
97
|
+
id,
|
|
98
|
+
status: STATUS_ERROR,
|
|
99
|
+
body: b"task panicked (contained)".to_vec(),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Ops whose args are ONE packed blob read out of the caller's slice. The
|
|
105
|
+
/// payload is read in place (zero-copy INPUT), so a large argument never gets
|
|
106
|
+
/// copied on the JS thread — which is what made offloading a big compression a
|
|
107
|
+
/// net loss (see docs/RND-CONCURRENCY.md §7b).
|
|
108
|
+
///
|
|
109
|
+
/// # Safety
|
|
110
|
+
/// `data`/`data_len` must describe a readable region that stays alive and
|
|
111
|
+
/// unmodified until the completion for `id` is drained.
|
|
112
|
+
pub unsafe fn execute_slice_guarded(
|
|
113
|
+
op: u32,
|
|
114
|
+
hdr: &[u8],
|
|
115
|
+
data: *const u8,
|
|
116
|
+
data_len: usize,
|
|
117
|
+
id: u64,
|
|
118
|
+
) {
|
|
119
|
+
let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
120
|
+
let data: &[u8] = if data.is_null() {
|
|
121
|
+
&[]
|
|
122
|
+
} else {
|
|
123
|
+
std::slice::from_raw_parts(data, data_len)
|
|
124
|
+
};
|
|
125
|
+
execute(op, hdr, data, id);
|
|
126
|
+
}))
|
|
127
|
+
.is_err();
|
|
128
|
+
if panicked {
|
|
129
|
+
clear_cancelled(id);
|
|
130
|
+
completion::push(Completion {
|
|
131
|
+
id,
|
|
132
|
+
status: STATUS_ERROR,
|
|
133
|
+
body: b"task panicked (contained)".to_vec(),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/// Bytes of op-specific header that precede the payload in a packed args blob.
|
|
139
|
+
/// The `_INTO` (zero-copy output) and copy forms of an op share a header.
|
|
140
|
+
const fn header_len(op: u32) -> usize {
|
|
141
|
+
match op {
|
|
142
|
+
OP_GZIP_DECOMPRESS
|
|
143
|
+
| OP_GZIP_DECOMPRESS_INTO
|
|
144
|
+
| OP_BROTLI_DECOMPRESS
|
|
145
|
+
| OP_BROTLI_DECOMPRESS_INTO
|
|
146
|
+
| OP_GZIP_COMPRESS => 4,
|
|
147
|
+
OP_ARGON2_VERIFY => 8,
|
|
148
|
+
OP_PBKDF2_SHA256 => 12,
|
|
149
|
+
_ => 0,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// Map a streaming-codec outcome onto a task result, sharing the needed-size
|
|
154
|
+
/// convention: a too-small destination yields the EXACT required size as an
|
|
155
|
+
/// 8-byte LE body so the caller can allocate once and retry.
|
|
156
|
+
fn stream_result(
|
|
157
|
+
r: std::result::Result<usize, StreamError>,
|
|
158
|
+
what: &str,
|
|
159
|
+
) -> std::result::Result<usize, (u32, Vec<u8>)> {
|
|
160
|
+
match r {
|
|
161
|
+
Ok(written) => Ok(written),
|
|
162
|
+
Err(StreamError::TooSmall { needed }) => {
|
|
163
|
+
Err((STATUS_TOO_SMALL, (needed as u64).to_le_bytes().to_vec()))
|
|
164
|
+
}
|
|
165
|
+
Err(StreamError::ExceedsMax(cap)) => Err((
|
|
166
|
+
STATUS_ERROR,
|
|
167
|
+
format!("{what}: exceeded the {cap}-byte cap").into_bytes(),
|
|
168
|
+
)),
|
|
169
|
+
Err(StreamError::Io(e)) => Err((STATUS_ERROR, format!("{what}: {e}").into_bytes())),
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/// Read one little-endian u32 out of an op header, or `None` when short.
|
|
174
|
+
fn hdr_u32(hdr: &[u8], off: usize) -> Option<u32> {
|
|
175
|
+
let b = hdr.get(off..off + 4)?;
|
|
176
|
+
Some(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/// The cap header shared by the decompress ops: `0` means the native default.
|
|
180
|
+
fn cap_of(hdr: &[u8]) -> Option<usize> {
|
|
181
|
+
let raw = hdr_u32(hdr, 0)?;
|
|
182
|
+
Some(if raw == 0 {
|
|
183
|
+
DEFAULT_MAX_DECOMPRESSED
|
|
184
|
+
} else {
|
|
185
|
+
raw as usize
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/// Run an op that writes into `out` (zero-copy output). `Ok(written)`, or
|
|
190
|
+
/// `Err((status, body))` — a too-small buffer yields `STATUS_TOO_SMALL` with
|
|
191
|
+
/// the exact required size as the 8-byte body.
|
|
192
|
+
fn run_into(
|
|
193
|
+
op: u32,
|
|
194
|
+
hdr: &[u8],
|
|
195
|
+
data: &[u8],
|
|
196
|
+
out: &mut [u8],
|
|
197
|
+
) -> std::result::Result<usize, (u32, Vec<u8>)> {
|
|
198
|
+
match op {
|
|
199
|
+
OP_GZIP_DECOMPRESS_INTO => match cap_of(hdr) {
|
|
200
|
+
Some(max) => stream_result(gzip_decompress_into(data, max, out), "gzip decompress"),
|
|
201
|
+
None => Err((STATUS_ERROR, b"gzip.decompress: header too short".to_vec())),
|
|
202
|
+
},
|
|
203
|
+
OP_BROTLI_DECOMPRESS_INTO => match cap_of(hdr) {
|
|
204
|
+
Some(max) => stream_result(brotli_decompress_into(data, max, out), "brotli decompress"),
|
|
205
|
+
None => Err((
|
|
206
|
+
STATUS_ERROR,
|
|
207
|
+
b"brotli.decompress: header too short".to_vec(),
|
|
208
|
+
)),
|
|
209
|
+
},
|
|
210
|
+
_ => Err((STATUS_UNSUPPORTED, Vec::new())),
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/// Execute a zero-copy op into the caller's buffer and publish its completion.
|
|
215
|
+
/// The completion body is the 8-byte LE written length (not the data), so the
|
|
216
|
+
/// JS side hands back a view of the buffer it already owns — no copy at all.
|
|
217
|
+
///
|
|
218
|
+
/// # Safety
|
|
219
|
+
/// `out` must be valid for writes of `out_cap` bytes and must stay alive (and
|
|
220
|
+
/// untouched by JS) until the completion for `id` is drained.
|
|
221
|
+
pub unsafe fn execute_into_guarded(op: u32, args: &[u8], id: u64, out: *mut u8, out_cap: usize) {
|
|
222
|
+
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
223
|
+
if is_cancelled(id) {
|
|
224
|
+
return Err((STATUS_CANCELLED, Vec::new()));
|
|
225
|
+
}
|
|
226
|
+
if out.is_null() || out_cap == 0 {
|
|
227
|
+
return Err((STATUS_ERROR, b"task: null output buffer".to_vec()));
|
|
228
|
+
}
|
|
229
|
+
let slice = std::slice::from_raw_parts_mut(out, out_cap);
|
|
230
|
+
let n = header_len(op).min(args.len());
|
|
231
|
+
let (hdr, data) = args.split_at(n);
|
|
232
|
+
run_into(op, hdr, data, slice)
|
|
233
|
+
}));
|
|
234
|
+
clear_cancelled(id);
|
|
235
|
+
match outcome {
|
|
236
|
+
Ok(Ok(written)) => completion::push(Completion {
|
|
237
|
+
id,
|
|
238
|
+
status: STATUS_OK,
|
|
239
|
+
body: (written as u64).to_le_bytes().to_vec(),
|
|
240
|
+
}),
|
|
241
|
+
Ok(Err((status, body))) => completion::push(Completion { id, status, body }),
|
|
242
|
+
Err(_) => completion::push(Completion {
|
|
243
|
+
id,
|
|
244
|
+
status: STATUS_ERROR,
|
|
245
|
+
body: b"task panicked (contained)".to_vec(),
|
|
246
|
+
}),
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
fn execute(op: u32, hdr: &[u8], data: &[u8], id: u64) {
|
|
251
|
+
if is_cancelled(id) {
|
|
252
|
+
clear_cancelled(id);
|
|
253
|
+
completion::push(Completion {
|
|
254
|
+
id,
|
|
255
|
+
status: STATUS_CANCELLED,
|
|
256
|
+
body: Vec::new(),
|
|
257
|
+
});
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
let (status, body) = match run_op(op, hdr, data) {
|
|
261
|
+
Ok(b) => (STATUS_OK, b),
|
|
262
|
+
Err(e) => (STATUS_ERROR, e.into_bytes()),
|
|
263
|
+
};
|
|
264
|
+
clear_cancelled(id);
|
|
265
|
+
completion::push(Completion { id, status, body });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
fn run_op(op: u32, hdr: &[u8], data: &[u8]) -> Result<Vec<u8>, String> {
|
|
269
|
+
match op {
|
|
270
|
+
OP_GZIP_DECOMPRESS => match cap_of(hdr) {
|
|
271
|
+
Some(max) => gzip_decompress_bytes(data, max).map_err(|e| e.to_string()),
|
|
272
|
+
None => Err("gzip.decompress: header too short".to_string()),
|
|
273
|
+
},
|
|
274
|
+
OP_BROTLI_DECOMPRESS => match cap_of(hdr) {
|
|
275
|
+
Some(max) => brotli_decompress_bytes(data, max).map_err(|e| e.to_string()),
|
|
276
|
+
None => Err("brotli.decompress: header too short".to_string()),
|
|
277
|
+
},
|
|
278
|
+
OP_GZIP_COMPRESS => match hdr_u32(hdr, 0) {
|
|
279
|
+
Some(level) => gzip_compress_bytes(data, level.min(9)).map_err(|e| e.to_string()),
|
|
280
|
+
None => Err("gzip.compress: header too short".to_string()),
|
|
281
|
+
},
|
|
282
|
+
OP_ARGON2_VERIFY => {
|
|
283
|
+
let (Some(pw_len), Some(phc_len)) = (hdr_u32(hdr, 0), hdr_u32(hdr, 4)) else {
|
|
284
|
+
return Err("argon2id.verify: header too short".to_string());
|
|
285
|
+
};
|
|
286
|
+
let (pw_len, phc_len) = (pw_len as usize, phc_len as usize);
|
|
287
|
+
if data.len() < pw_len + phc_len {
|
|
288
|
+
return Err("argon2id.verify: truncated password/phc".to_string());
|
|
289
|
+
}
|
|
290
|
+
let (password, phc) = data.split_at(pw_len);
|
|
291
|
+
// A malformed PHC string is a NON-MATCH, never an error — the
|
|
292
|
+
// synchronous `rust.passwordVerify` behaves the same way.
|
|
293
|
+
Ok(vec![u8::from(verify_password(password, &phc[..phc_len]))])
|
|
294
|
+
}
|
|
295
|
+
OP_PBKDF2_SHA256 => {
|
|
296
|
+
let (Some(rounds), Some(dk_len), Some(salt_len)) =
|
|
297
|
+
(hdr_u32(hdr, 0), hdr_u32(hdr, 4), hdr_u32(hdr, 8))
|
|
298
|
+
else {
|
|
299
|
+
return Err("pbkdf2.sha256: header too short".to_string());
|
|
300
|
+
};
|
|
301
|
+
let salt_len = salt_len as usize;
|
|
302
|
+
if data.len() < salt_len {
|
|
303
|
+
return Err("pbkdf2.sha256: truncated salt".to_string());
|
|
304
|
+
}
|
|
305
|
+
let (salt, password) = data.split_at(salt_len);
|
|
306
|
+
Ok(crate::crypto::pbkdf2::pbkdf2_sha256_into(
|
|
307
|
+
password, salt, rounds, dk_len,
|
|
308
|
+
))
|
|
309
|
+
}
|
|
310
|
+
#[cfg(test)]
|
|
311
|
+
OP_TEST_PANIC => panic!("intentional test panic"),
|
|
312
|
+
_ => Err(format!("unknown task op {op}")),
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
#[cfg(test)]
|
|
317
|
+
mod tests {
|
|
318
|
+
use super::*;
|
|
319
|
+
|
|
320
|
+
/// Mirror the packed-args split so tests exercise the same boundary the
|
|
321
|
+
/// C-ABI `submit` does (header first, payload after).
|
|
322
|
+
fn run_packed(op: u32, args: &[u8]) -> Result<Vec<u8>, String> {
|
|
323
|
+
let n = header_len(op).min(args.len());
|
|
324
|
+
run_op(op, &args[..n], &args[n..])
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
fn run_into_packed(
|
|
328
|
+
op: u32,
|
|
329
|
+
args: &[u8],
|
|
330
|
+
out: &mut [u8],
|
|
331
|
+
) -> std::result::Result<usize, (u32, Vec<u8>)> {
|
|
332
|
+
let n = header_len(op).min(args.len());
|
|
333
|
+
run_into(op, &args[..n], &args[n..], out)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
#[test]
|
|
337
|
+
fn run_op_round_trips_gzip() {
|
|
338
|
+
let payload = b"task op payload".repeat(32);
|
|
339
|
+
let compressed = crate::payload::compress::gzip_compress_bytes(&payload, 6).unwrap();
|
|
340
|
+
let mut args = Vec::with_capacity(4 + compressed.len());
|
|
341
|
+
args.extend_from_slice(&0u32.to_le_bytes());
|
|
342
|
+
args.extend_from_slice(&compressed);
|
|
343
|
+
assert_eq!(run_packed(OP_GZIP_DECOMPRESS, &args).unwrap(), payload);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
#[test]
|
|
347
|
+
fn unknown_op_is_an_error() {
|
|
348
|
+
assert!(run_packed(42, &[]).is_err());
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
#[test]
|
|
352
|
+
fn pbkdf2_op_matches_the_known_vector() {
|
|
353
|
+
let mut args = Vec::new();
|
|
354
|
+
args.extend_from_slice(&1u32.to_le_bytes()); // rounds
|
|
355
|
+
args.extend_from_slice(&32u32.to_le_bytes()); // dk_len
|
|
356
|
+
args.extend_from_slice(&4u32.to_le_bytes()); // salt len
|
|
357
|
+
args.extend_from_slice(b"salt");
|
|
358
|
+
args.extend_from_slice(b"password");
|
|
359
|
+
let out = run_packed(OP_PBKDF2_SHA256, &args).unwrap();
|
|
360
|
+
let hex: String = out.iter().map(|b| format!("{b:02x}")).collect();
|
|
361
|
+
assert_eq!(
|
|
362
|
+
hex,
|
|
363
|
+
"120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b"
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#[test]
|
|
368
|
+
fn gzip_into_reports_the_exact_needed_size_and_then_succeeds() {
|
|
369
|
+
let payload = b"zero-copy output".repeat(64);
|
|
370
|
+
let compressed = crate::payload::compress::gzip_compress_bytes(&payload, 6).unwrap();
|
|
371
|
+
let mut args = Vec::new();
|
|
372
|
+
args.extend_from_slice(&0u32.to_le_bytes());
|
|
373
|
+
args.extend_from_slice(&compressed);
|
|
374
|
+
|
|
375
|
+
// Too small → TOO_SMALL with the exact required size.
|
|
376
|
+
let mut tiny = [0u8; 8];
|
|
377
|
+
match run_into_packed(OP_GZIP_DECOMPRESS_INTO, &args, &mut tiny) {
|
|
378
|
+
Err((STATUS_TOO_SMALL, body)) => {
|
|
379
|
+
let needed = u64::from_le_bytes(body.try_into().unwrap());
|
|
380
|
+
assert_eq!(needed, payload.len() as u64);
|
|
381
|
+
}
|
|
382
|
+
Ok(w) => panic!("expected TooSmall, got Ok({w})"),
|
|
383
|
+
Err((s, _)) => panic!("expected TooSmall, got status {s}"),
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Exact size → writes the payload, no copy on the JS side.
|
|
387
|
+
let mut exact = vec![0u8; payload.len()];
|
|
388
|
+
assert_eq!(
|
|
389
|
+
run_into_packed(OP_GZIP_DECOMPRESS_INTO, &args, &mut exact).unwrap(),
|
|
390
|
+
payload.len()
|
|
391
|
+
);
|
|
392
|
+
assert_eq!(exact, payload);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
#[test]
|
|
396
|
+
fn an_op_without_an_into_form_is_unsupported() {
|
|
397
|
+
let mut out = [0u8; 16];
|
|
398
|
+
assert!(matches!(
|
|
399
|
+
run_into_packed(OP_PBKDF2_SHA256, &[], &mut out),
|
|
400
|
+
Err((STATUS_UNSUPPORTED, _))
|
|
401
|
+
));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
#[test]
|
|
405
|
+
fn gzip_compress_round_trips_through_the_task_op() {
|
|
406
|
+
let payload = b"compress me".repeat(128);
|
|
407
|
+
let mut args = Vec::new();
|
|
408
|
+
args.extend_from_slice(&6u32.to_le_bytes());
|
|
409
|
+
args.extend_from_slice(&payload);
|
|
410
|
+
|
|
411
|
+
let compressed = run_packed(OP_GZIP_COMPRESS, &args).unwrap();
|
|
412
|
+
assert!(compressed.len() < payload.len());
|
|
413
|
+
assert_eq!(
|
|
414
|
+
crate::payload::compress::gzip_decompress_bytes(&compressed, payload.len() + 1)
|
|
415
|
+
.unwrap(),
|
|
416
|
+
payload
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
#[test]
|
|
421
|
+
fn brotli_into_reports_the_exact_needed_size_and_then_succeeds() {
|
|
422
|
+
let payload = b"brotli zero-copy output".repeat(64);
|
|
423
|
+
let compressed = crate::payload::compress::brotli_compress_bytes(&payload, 5).unwrap();
|
|
424
|
+
let mut args = Vec::new();
|
|
425
|
+
args.extend_from_slice(&0u32.to_le_bytes());
|
|
426
|
+
args.extend_from_slice(&compressed);
|
|
427
|
+
|
|
428
|
+
// Too small (a capacity GUESS, since brotli has no ISIZE trailer) →
|
|
429
|
+
// TOO_SMALL with the exact required size.
|
|
430
|
+
let mut tiny = [0u8; 16];
|
|
431
|
+
match run_into_packed(OP_BROTLI_DECOMPRESS_INTO, &args, &mut tiny) {
|
|
432
|
+
Err((STATUS_TOO_SMALL, body)) => {
|
|
433
|
+
assert_eq!(
|
|
434
|
+
u64::from_le_bytes(body.try_into().unwrap()),
|
|
435
|
+
payload.len() as u64
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
Ok(w) => panic!("expected TooSmall, got Ok({w})"),
|
|
439
|
+
Err((s, _)) => panic!("expected TooSmall, got status {s}"),
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
let mut exact = vec![0u8; payload.len()];
|
|
443
|
+
assert_eq!(
|
|
444
|
+
run_into_packed(OP_BROTLI_DECOMPRESS_INTO, &args, &mut exact).unwrap(),
|
|
445
|
+
payload.len()
|
|
446
|
+
);
|
|
447
|
+
assert_eq!(exact, payload);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
#[test]
|
|
451
|
+
fn argon2_verify_answers_in_one_byte() {
|
|
452
|
+
let phc = crate::crypto::argon2::hash_password(b"hunter2", b"saltysalt", 4096, 1, 1, 32)
|
|
453
|
+
.expect("hash");
|
|
454
|
+
|
|
455
|
+
let mut args = Vec::new();
|
|
456
|
+
let pw = b"hunter2";
|
|
457
|
+
let phc_bytes = phc.as_bytes();
|
|
458
|
+
args.extend_from_slice(&(pw.len() as u32).to_le_bytes());
|
|
459
|
+
args.extend_from_slice(&(phc_bytes.len() as u32).to_le_bytes());
|
|
460
|
+
args.extend_from_slice(pw);
|
|
461
|
+
args.extend_from_slice(phc_bytes);
|
|
462
|
+
|
|
463
|
+
assert_eq!(run_packed(OP_ARGON2_VERIFY, &args).unwrap(), vec![1]);
|
|
464
|
+
|
|
465
|
+
// Wrong password → 0, and a malformed PHC string is a NON-MATCH, never
|
|
466
|
+
// an error (parity with the synchronous `rust.passwordVerify`).
|
|
467
|
+
let mut wrong = Vec::new();
|
|
468
|
+
let bad = b"nope";
|
|
469
|
+
wrong.extend_from_slice(&(bad.len() as u32).to_le_bytes());
|
|
470
|
+
wrong.extend_from_slice(&(phc_bytes.len() as u32).to_le_bytes());
|
|
471
|
+
wrong.extend_from_slice(bad);
|
|
472
|
+
wrong.extend_from_slice(phc_bytes);
|
|
473
|
+
assert_eq!(run_packed(OP_ARGON2_VERIFY, &wrong).unwrap(), vec![0]);
|
|
474
|
+
|
|
475
|
+
let mut malformed = Vec::new();
|
|
476
|
+
let junk = b"not-a-phc";
|
|
477
|
+
malformed.extend_from_slice(&(bad.len() as u32).to_le_bytes());
|
|
478
|
+
malformed.extend_from_slice(&(junk.len() as u32).to_le_bytes());
|
|
479
|
+
malformed.extend_from_slice(bad);
|
|
480
|
+
malformed.extend_from_slice(junk);
|
|
481
|
+
assert_eq!(run_packed(OP_ARGON2_VERIFY, &malformed).unwrap(), vec![0]);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
#[test]
|
|
485
|
+
fn a_truncated_argon2_arg_header_is_an_error() {
|
|
486
|
+
let mut args = Vec::new();
|
|
487
|
+
args.extend_from_slice(&64u32.to_le_bytes());
|
|
488
|
+
args.extend_from_slice(&64u32.to_le_bytes());
|
|
489
|
+
args.extend_from_slice(b"short");
|
|
490
|
+
assert!(run_packed(OP_ARGON2_VERIFY, &args).is_err());
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
#[test]
|
|
494
|
+
fn gzip_cap_is_enforced() {
|
|
495
|
+
// 1 MiB of zeros compresses tiny; a 16-byte cap must reject it.
|
|
496
|
+
let payload = vec![0u8; 1 << 20];
|
|
497
|
+
let compressed = crate::payload::compress::gzip_compress_bytes(&payload, 6).unwrap();
|
|
498
|
+
let mut args = Vec::new();
|
|
499
|
+
args.extend_from_slice(&16u32.to_le_bytes());
|
|
500
|
+
args.extend_from_slice(&compressed);
|
|
501
|
+
assert!(run_packed(OP_GZIP_DECOMPRESS, &args).is_err());
|
|
502
|
+
}
|
|
503
|
+
}
|