dsh-ops 0.0.0-stage → 0.2.2
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/CHANGELOG.md +202 -0
- package/LICENSE +30 -0
- package/NOTICE +106 -0
- package/PROVENANCE.md +435 -0
- package/README.en.md +126 -0
- package/README.md +115 -2
- package/README.zh.md +116 -0
- package/bin/dsh-ops.mjs +1216 -0
- package/cordis.patch.yml +160 -0
- package/docs/manual-validation.md +53 -0
- package/docs/release-0.2.1.md +72 -0
- package/docs/schema-baseline.json +64 -0
- package/docs/schema-current.json +84 -0
- package/docs/schema-measurement.md +17 -0
- package/dsh-plugin.json +88 -0
- package/icon.svg +12 -0
- package/lib/binary.js +409 -0
- package/lib/config.js +198 -0
- package/lib/handshake.js +252 -0
- package/lib/index.js +108 -0
- package/lib/jobs.js +42 -0
- package/lib/policy.js +64 -0
- package/lib/presentation.js +63 -0
- package/lib/profile-install.js +61 -0
- package/lib/rust.js +194 -0
- package/lib/session-shells.js +78 -0
- package/lib/shells.js +998 -0
- package/lib/tools.js +657 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +114 -4
- package/vendor/fastctx/Cargo.lock +3210 -0
- package/vendor/fastctx/Cargo.toml +94 -0
- package/vendor/fastctx/FORK.md +119 -0
- package/vendor/fastctx/LICENSE-APACHE +201 -0
- package/vendor/fastctx/NOTICE +40 -0
- package/vendor/fastctx/README.md +439 -0
- package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
- package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
- package/vendor/fastctx/UPSTREAM.md +49 -0
- package/vendor/fastctx/build.rs +413 -0
- package/vendor/fastctx/src/background_status.rs +403 -0
- package/vendor/fastctx/src/binary.rs +75 -0
- package/vendor/fastctx/src/bounded_sort.rs +500 -0
- package/vendor/fastctx/src/budget.rs +781 -0
- package/vendor/fastctx/src/cli/mod.rs +110 -0
- package/vendor/fastctx/src/context_guard.rs +289 -0
- package/vendor/fastctx/src/control/mod.rs +6 -0
- package/vendor/fastctx/src/control/paths.rs +49 -0
- package/vendor/fastctx/src/control/settings.rs +753 -0
- package/vendor/fastctx/src/control/transaction.rs +531 -0
- package/vendor/fastctx/src/edit/document.rs +535 -0
- package/vendor/fastctx/src/edit/locks.rs +371 -0
- package/vendor/fastctx/src/edit/mod.rs +213 -0
- package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
- package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
- package/vendor/fastctx/src/edit/private_storage.rs +234 -0
- package/vendor/fastctx/src/edit/replace.rs +1030 -0
- package/vendor/fastctx/src/edit_server.rs +53 -0
- package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
- package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
- package/vendor/fastctx/src/encoding.rs +1118 -0
- package/vendor/fastctx/src/file_executor.rs +1151 -0
- package/vendor/fastctx/src/file_snapshot.rs +1491 -0
- package/vendor/fastctx/src/glob_filter.rs +98 -0
- package/vendor/fastctx/src/glob_tool.rs +653 -0
- package/vendor/fastctx/src/grep_sink.rs +1162 -0
- package/vendor/fastctx/src/grep_tool.rs +2449 -0
- package/vendor/fastctx/src/lib.rs +45 -0
- package/vendor/fastctx/src/main.rs +15 -0
- package/vendor/fastctx/src/model.rs +51 -0
- package/vendor/fastctx/src/model_guidance.rs +62 -0
- package/vendor/fastctx/src/operation.rs +356 -0
- package/vendor/fastctx/src/ordered_window.rs +1235 -0
- package/vendor/fastctx/src/os_environment.rs +414 -0
- package/vendor/fastctx/src/path_codec.rs +850 -0
- package/vendor/fastctx/src/paths.rs +244 -0
- package/vendor/fastctx/src/process_identity.rs +763 -0
- package/vendor/fastctx/src/process_policy.rs +74 -0
- package/vendor/fastctx/src/read_tool/batch.rs +496 -0
- package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
- package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
- package/vendor/fastctx/src/read_tool/mod.rs +245 -0
- package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
- package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
- package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
- package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
- package/vendor/fastctx/src/render_plan.rs +468 -0
- package/vendor/fastctx/src/runtime/activity.rs +159 -0
- package/vendor/fastctx/src/runtime/hosts.rs +99 -0
- package/vendor/fastctx/src/runtime/journal.rs +556 -0
- package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
- package/vendor/fastctx/src/runtime/mod.rs +746 -0
- package/vendor/fastctx/src/runtime/protocol.rs +296 -0
- package/vendor/fastctx/src/runtime/session.rs +536 -0
- package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
- package/vendor/fastctx/src/search_parallelism.rs +106 -0
- package/vendor/fastctx/src/search_text.rs +227 -0
- package/vendor/fastctx/src/server.rs +359 -0
- package/vendor/fastctx/src/server_manifest.rs +468 -0
- package/vendor/fastctx/src/server_support.rs +826 -0
- package/vendor/fastctx/src/session.rs +629 -0
- package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
- package/vendor/fastctx/src/shell/bash.rs +263 -0
- package/vendor/fastctx/src/shell/buffer.rs +108 -0
- package/vendor/fastctx/src/shell/encoding.rs +403 -0
- package/vendor/fastctx/src/shell/foreground.rs +115 -0
- package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
- package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
- package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
- package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
- package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
- package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
- package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
- package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
- package/vendor/fastctx/src/shell/mod.rs +345 -0
- package/vendor/fastctx/src/shell/normalize.rs +389 -0
- package/vendor/fastctx/src/shell/output.rs +406 -0
- package/vendor/fastctx/src/shell/process.rs +493 -0
- package/vendor/fastctx/src/shell_server.rs +156 -0
- package/vendor/fastctx/src/skip_report.rs +83 -0
- package/vendor/fastctx/src/stdio_transport.rs +177 -0
- package/vendor/fastctx/src/tool_schema.rs +204 -0
- package/vendor/fastctx/src/traversal.rs +846 -0
- package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/zlib.txt +29 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
//! The host processes a control center keeps its shared runtime warm for.
|
|
2
|
+
//!
|
|
3
|
+
//! A control center is a cache in front of the host applications using it, so its life belongs to
|
|
4
|
+
//! them rather than to a clock. Every proxy names the host process it serves during the handshake;
|
|
5
|
+
//! the control center remembers those identities and refuses to shut down while any of them is
|
|
6
|
+
//! still running, because that host can open a new conversation — and therefore a new proxy — at
|
|
7
|
+
//! any moment, and a host whose stdio MCP server has died never gets it back.
|
|
8
|
+
|
|
9
|
+
use crate::process_identity::{ProcessIdentity, identity_is_alive};
|
|
10
|
+
use std::sync::Mutex;
|
|
11
|
+
|
|
12
|
+
/// Upper bound on remembered hosts. Dead entries are pruned on every liveness sweep, so this only
|
|
13
|
+
/// caps a pathological burst of short-lived hosts between two sweeps.
|
|
14
|
+
const MAX_TRACKED_HOSTS: usize = 256;
|
|
15
|
+
|
|
16
|
+
/// Host identities seen by one control center, with the exited ones pruned as they are noticed.
|
|
17
|
+
#[derive(Debug)]
|
|
18
|
+
pub(crate) struct HostRegistry {
|
|
19
|
+
hosts: Mutex<Vec<ProcessIdentity>>,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
impl HostRegistry {
|
|
23
|
+
pub(crate) fn new() -> Self {
|
|
24
|
+
Self {
|
|
25
|
+
hosts: Mutex::new(Vec::new()),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// Records the host a newly accepted session belongs to.
|
|
30
|
+
///
|
|
31
|
+
/// A proxy that could not name its host — the documented parent-watch opt-out, or a platform
|
|
32
|
+
/// where the identity is unavailable — passes `None`. Such a session pins nothing: the control
|
|
33
|
+
/// center could never observe that host exiting, so counting it would mean never exiting.
|
|
34
|
+
pub(crate) fn remember(&self, host: Option<ProcessIdentity>) {
|
|
35
|
+
let Some(host) = host else {
|
|
36
|
+
return;
|
|
37
|
+
};
|
|
38
|
+
let mut hosts = self.hosts.lock().unwrap();
|
|
39
|
+
if hosts.contains(&host) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if hosts.len() >= MAX_TRACKED_HOSTS {
|
|
43
|
+
hosts.retain(identity_is_alive);
|
|
44
|
+
}
|
|
45
|
+
if hosts.len() < MAX_TRACKED_HOSTS {
|
|
46
|
+
hosts.push(host);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// Prunes hosts that have exited and reports whether any remembered host is still running.
|
|
51
|
+
///
|
|
52
|
+
/// Inspects live processes, so callers run it off the async runtime.
|
|
53
|
+
pub(crate) fn prune_and_check(&self) -> bool {
|
|
54
|
+
let snapshot: Vec<ProcessIdentity> = self.hosts.lock().unwrap().clone();
|
|
55
|
+
let alive: Vec<ProcessIdentity> = snapshot.into_iter().filter(identity_is_alive).collect();
|
|
56
|
+
let mut hosts = self.hosts.lock().unwrap();
|
|
57
|
+
// Keep entries added while the snapshot was being probed; only drop the proven-dead ones.
|
|
58
|
+
hosts.retain(|host| alive.contains(host));
|
|
59
|
+
!hosts.is_empty()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#[cfg(test)]
|
|
64
|
+
mod tests {
|
|
65
|
+
use super::HostRegistry;
|
|
66
|
+
|
|
67
|
+
#[test]
|
|
68
|
+
fn a_session_without_a_host_identity_pins_nothing() {
|
|
69
|
+
let registry = HostRegistry::new();
|
|
70
|
+
|
|
71
|
+
registry.remember(None);
|
|
72
|
+
|
|
73
|
+
assert!(!registry.prune_and_check());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#[test]
|
|
77
|
+
fn a_live_host_is_remembered_once_and_keeps_the_control_center_open() {
|
|
78
|
+
let registry = HostRegistry::new();
|
|
79
|
+
let running = crate::process_identity::process_identity(std::process::id())
|
|
80
|
+
.expect("the running test process has an inspectable identity");
|
|
81
|
+
|
|
82
|
+
registry.remember(Some(running.clone()));
|
|
83
|
+
registry.remember(Some(running));
|
|
84
|
+
|
|
85
|
+
assert!(registry.prune_and_check());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#[test]
|
|
89
|
+
fn an_exited_host_is_pruned_and_stops_pinning() {
|
|
90
|
+
let registry = HostRegistry::new();
|
|
91
|
+
let mut recycled = crate::process_identity::process_identity(std::process::id())
|
|
92
|
+
.expect("the running test process has an inspectable identity");
|
|
93
|
+
recycled.started.push('x');
|
|
94
|
+
|
|
95
|
+
registry.remember(Some(recycled));
|
|
96
|
+
|
|
97
|
+
assert!(!registry.prune_and_check());
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
//! Session bookkeeping that lets one MCP session outlive the engine link carrying it.
|
|
2
|
+
//!
|
|
3
|
+
//! The proxy forwards bytes; it does not interpret them. This module adds the smallest amount of
|
|
4
|
+
//! interpretation that a resumable session needs: which requests are still unanswered, and what a
|
|
5
|
+
//! replacement engine must be told before it can take over. Everything here is a side channel —
|
|
6
|
+
//! the bytes on the wire are unchanged, so a session that never loses its link pays only the scan.
|
|
7
|
+
|
|
8
|
+
use serde_json::Value;
|
|
9
|
+
|
|
10
|
+
/// Bytes of a single message inspected for its JSON-RPC envelope fields.
|
|
11
|
+
///
|
|
12
|
+
/// `id` is the second field rmcp serialises, and a request carries it before its parameters, so
|
|
13
|
+
/// this window holds the envelope of every realistic message. A message whose id falls outside it
|
|
14
|
+
/// stays untracked, which costs at most one duplicate answer after a relink.
|
|
15
|
+
const ENVELOPE_WINDOW_BYTES: usize = 64 * 1024;
|
|
16
|
+
/// Upper bound on the incomplete request line retained for replay onto a replacement link.
|
|
17
|
+
const MAX_PARTIAL_REQUEST_BYTES: usize = 16 * 1024 * 1024;
|
|
18
|
+
/// Upper bound on the cached session prologue. A Codex `initialize` is roughly one kilobyte.
|
|
19
|
+
const MAX_PROLOGUE_BYTES: usize = 1024 * 1024;
|
|
20
|
+
/// Upper bound on tracked unanswered requests. Hosts keep a handful of tool calls in flight.
|
|
21
|
+
const MAX_UNANSWERED_REQUESTS: usize = 4096;
|
|
22
|
+
/// Capacity the line buffers return to once a message completes, so one large request or answer
|
|
23
|
+
/// does not permanently raise the proxy's resident size.
|
|
24
|
+
const LINE_BUFFER_IDLE_CAPACITY: usize = 8 * 1024;
|
|
25
|
+
|
|
26
|
+
const INITIALIZE_METHOD: &str = "initialize";
|
|
27
|
+
const INITIALIZED_NOTIFICATION: &str = "notifications/initialized";
|
|
28
|
+
|
|
29
|
+
/// A request the engine received or was about to receive, still waiting for its answer.
|
|
30
|
+
pub(super) struct UnansweredRequest {
|
|
31
|
+
id: Value,
|
|
32
|
+
/// Whether the bytes completing this request reached the engine before the link failed.
|
|
33
|
+
delivered: bool,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
impl UnansweredRequest {
|
|
37
|
+
/// Renders the JSON-RPC error line that closes out this request on the host's stdout.
|
|
38
|
+
///
|
|
39
|
+
/// A delivered request may have run to completion with its side effects intact, so the text
|
|
40
|
+
/// says so rather than inviting a blind retry; an undelivered one provably did not run.
|
|
41
|
+
pub(super) fn failure_line(&self) -> Vec<u8> {
|
|
42
|
+
let message = if self.delivered {
|
|
43
|
+
"The FastCtx control center stopped while this call was running, so it has no result. \
|
|
44
|
+
The call may or may not have finished; check the current state before running it \
|
|
45
|
+
again."
|
|
46
|
+
} else {
|
|
47
|
+
"The FastCtx control center stopped before this call reached it. Nothing ran; run it \
|
|
48
|
+
again."
|
|
49
|
+
};
|
|
50
|
+
let mut line = serde_json::json!({
|
|
51
|
+
"jsonrpc": "2.0",
|
|
52
|
+
"id": self.id,
|
|
53
|
+
"error": { "code": -32603, "message": message },
|
|
54
|
+
})
|
|
55
|
+
.to_string()
|
|
56
|
+
.into_bytes();
|
|
57
|
+
line.push(b'\n');
|
|
58
|
+
line
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// What one side of the session has seen so far on a newline-delimited JSON-RPC stream.
|
|
63
|
+
struct LineBuffer {
|
|
64
|
+
bytes: Vec<u8>,
|
|
65
|
+
/// Set once a message outgrows the envelope window: the rest is forwarded but not inspected.
|
|
66
|
+
truncated: bool,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
impl LineBuffer {
|
|
70
|
+
fn new() -> Self {
|
|
71
|
+
Self {
|
|
72
|
+
bytes: Vec::with_capacity(LINE_BUFFER_IDLE_CAPACITY),
|
|
73
|
+
truncated: false,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fn reset(&mut self) {
|
|
78
|
+
self.bytes.clear();
|
|
79
|
+
self.bytes.shrink_to(LINE_BUFFER_IDLE_CAPACITY);
|
|
80
|
+
self.truncated = false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Per-session state shared across every engine link that carries the session.
|
|
85
|
+
pub(super) struct SessionJournal {
|
|
86
|
+
/// Raw `initialize` request line, replayed so a replacement engine accepts the session.
|
|
87
|
+
initialize: Option<Vec<u8>>,
|
|
88
|
+
/// Canonical text of the `initialize` request id, used to route its replayed answer.
|
|
89
|
+
initialize_id: Option<String>,
|
|
90
|
+
/// Whether the host is still waiting for its `initialize` answer.
|
|
91
|
+
initialize_owed: bool,
|
|
92
|
+
/// Raw `notifications/initialized` line, replayed after `initialize`.
|
|
93
|
+
initialized: Option<Vec<u8>>,
|
|
94
|
+
/// Set when a prologue message could not be cached, which makes the session unresumable.
|
|
95
|
+
prologue_lost: Option<String>,
|
|
96
|
+
unanswered: Vec<(String, UnansweredRequest)>,
|
|
97
|
+
/// Set when the unanswered set overflowed and can no longer close out every request.
|
|
98
|
+
tracking_lost: bool,
|
|
99
|
+
request_line: LineBuffer,
|
|
100
|
+
answer_line: LineBuffer,
|
|
101
|
+
/// Bytes of the request line in progress, replayed so a replacement engine sees it whole.
|
|
102
|
+
partial_request: Vec<u8>,
|
|
103
|
+
/// Set when the in-progress request outgrew replay and its bytes must be resynchronised.
|
|
104
|
+
partial_request_lost: bool,
|
|
105
|
+
forwarded: bool,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
impl SessionJournal {
|
|
109
|
+
pub(super) fn new() -> Self {
|
|
110
|
+
Self {
|
|
111
|
+
initialize: None,
|
|
112
|
+
initialize_id: None,
|
|
113
|
+
initialize_owed: false,
|
|
114
|
+
initialized: None,
|
|
115
|
+
prologue_lost: None,
|
|
116
|
+
unanswered: Vec::new(),
|
|
117
|
+
tracking_lost: false,
|
|
118
|
+
request_line: LineBuffer::new(),
|
|
119
|
+
answer_line: LineBuffer::new(),
|
|
120
|
+
partial_request: Vec::new(),
|
|
121
|
+
partial_request_lost: false,
|
|
122
|
+
forwarded: false,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Whether the host has asked anything yet, which is how a closing session tells "answers are
|
|
127
|
+
/// owed" from "the client opened the transport and changed its mind".
|
|
128
|
+
pub(super) fn has_forwarded(&self) -> bool {
|
|
129
|
+
self.forwarded
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// Records request bytes on their way to the engine. Call before writing them.
|
|
133
|
+
pub(super) fn observe_request_bytes(&mut self, chunk: &[u8]) {
|
|
134
|
+
self.forwarded = true;
|
|
135
|
+
for &byte in chunk {
|
|
136
|
+
self.push_request_byte(byte);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
fn push_request_byte(&mut self, byte: u8) {
|
|
141
|
+
if self.partial_request.len() < MAX_PARTIAL_REQUEST_BYTES {
|
|
142
|
+
self.partial_request.push(byte);
|
|
143
|
+
} else {
|
|
144
|
+
self.partial_request_lost = true;
|
|
145
|
+
}
|
|
146
|
+
if byte != b'\n' {
|
|
147
|
+
if self.request_line.bytes.len() < ENVELOPE_WINDOW_BYTES {
|
|
148
|
+
self.request_line.bytes.push(byte);
|
|
149
|
+
} else {
|
|
150
|
+
self.request_line.truncated = true;
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
self.complete_request_line();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
fn complete_request_line(&mut self) {
|
|
158
|
+
let head = envelope_head(&self.request_line.bytes);
|
|
159
|
+
match (&head.method, &head.id) {
|
|
160
|
+
(Some(method), Some(id)) => {
|
|
161
|
+
let key = canonical_id(id);
|
|
162
|
+
if method == INITIALIZE_METHOD {
|
|
163
|
+
self.cache_initialize(key.clone());
|
|
164
|
+
}
|
|
165
|
+
self.track_request(key, id.clone());
|
|
166
|
+
}
|
|
167
|
+
(Some(method), None) if method == INITIALIZED_NOTIFICATION => {
|
|
168
|
+
self.cache_initialized();
|
|
169
|
+
}
|
|
170
|
+
_ => {}
|
|
171
|
+
}
|
|
172
|
+
self.request_line.reset();
|
|
173
|
+
self.partial_request.clear();
|
|
174
|
+
self.partial_request.shrink_to(LINE_BUFFER_IDLE_CAPACITY);
|
|
175
|
+
self.partial_request_lost = false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
fn cache_initialize(&mut self, key: String) {
|
|
179
|
+
if self.request_line.truncated || self.partial_request_lost {
|
|
180
|
+
self.prologue_lost = Some(
|
|
181
|
+
"the host's initialize request was too large to retain for a reconnect".to_string(),
|
|
182
|
+
);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if self.partial_request.len() > MAX_PROLOGUE_BYTES {
|
|
186
|
+
self.prologue_lost = Some(
|
|
187
|
+
"the host's initialize request exceeded the reconnect replay limit".to_string(),
|
|
188
|
+
);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
self.initialize = Some(self.partial_request.clone());
|
|
192
|
+
self.initialize_id = Some(key);
|
|
193
|
+
self.initialize_owed = true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
fn cache_initialized(&mut self) {
|
|
197
|
+
if self.request_line.truncated || self.partial_request_lost {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if self.partial_request.len() <= MAX_PROLOGUE_BYTES {
|
|
201
|
+
self.initialized = Some(self.partial_request.clone());
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
fn track_request(&mut self, key: String, id: Value) {
|
|
206
|
+
if self.unanswered.iter().any(|(tracked, _)| tracked == &key) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if self.unanswered.len() >= MAX_UNANSWERED_REQUESTS {
|
|
210
|
+
self.tracking_lost = true;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
self.unanswered.push((
|
|
214
|
+
key,
|
|
215
|
+
UnansweredRequest {
|
|
216
|
+
id,
|
|
217
|
+
delivered: false,
|
|
218
|
+
},
|
|
219
|
+
));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/// Marks every request observed so far as delivered. Call after the write that carried them
|
|
223
|
+
/// to the engine succeeded.
|
|
224
|
+
pub(super) fn mark_delivered(&mut self) {
|
|
225
|
+
for (_, request) in &mut self.unanswered {
|
|
226
|
+
request.delivered = true;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/// Records answer bytes on their way to the host.
|
|
231
|
+
pub(super) fn observe_answer_bytes(&mut self, chunk: &[u8]) {
|
|
232
|
+
for &byte in chunk {
|
|
233
|
+
if byte != b'\n' {
|
|
234
|
+
if self.answer_line.bytes.len() < ENVELOPE_WINDOW_BYTES {
|
|
235
|
+
self.answer_line.bytes.push(byte);
|
|
236
|
+
} else {
|
|
237
|
+
self.answer_line.truncated = true;
|
|
238
|
+
}
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
let head = envelope_head(&self.answer_line.bytes);
|
|
242
|
+
if head.method.is_none()
|
|
243
|
+
&& let Some(id) = head.id
|
|
244
|
+
{
|
|
245
|
+
self.resolve(&canonical_id(&id));
|
|
246
|
+
}
|
|
247
|
+
self.answer_line.reset();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
fn resolve(&mut self, key: &str) {
|
|
252
|
+
if self.initialize_id.as_deref() == Some(key) {
|
|
253
|
+
self.initialize_owed = false;
|
|
254
|
+
}
|
|
255
|
+
self.unanswered.retain(|(tracked, _)| tracked != key);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/// Removes and returns every request the failed link can no longer answer.
|
|
259
|
+
///
|
|
260
|
+
/// The `initialize` request stays behind: a replacement link replays it and delivers the real
|
|
261
|
+
/// answer, which is what the host is actually waiting for.
|
|
262
|
+
pub(super) fn take_unanswered(&mut self) -> Vec<UnansweredRequest> {
|
|
263
|
+
let initialize = self.initialize_id.clone();
|
|
264
|
+
let keep_initialize = self.initialize_owed;
|
|
265
|
+
let mut taken = Vec::new();
|
|
266
|
+
let mut retained = Vec::new();
|
|
267
|
+
for (key, request) in std::mem::take(&mut self.unanswered) {
|
|
268
|
+
if keep_initialize && initialize.as_deref() == Some(key.as_str()) {
|
|
269
|
+
retained.push((key, request));
|
|
270
|
+
} else {
|
|
271
|
+
taken.push(request);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
self.unanswered = retained;
|
|
275
|
+
taken
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/// Whether closing out unanswered requests can be complete, or silently missed some.
|
|
279
|
+
pub(super) fn tracking_is_complete(&self) -> bool {
|
|
280
|
+
!self.tracking_lost
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/// Why this session can never be moved to another engine, if that is the case.
|
|
284
|
+
///
|
|
285
|
+
/// Permanent by nature: no retry can recover a handshake that was never retained. Callers use
|
|
286
|
+
/// it to stop before entering a retry loop that can only fail the same way every round.
|
|
287
|
+
pub(super) fn resume_blocked(&self) -> Option<String> {
|
|
288
|
+
self.prologue_lost.as_ref().map(|reason| {
|
|
289
|
+
format!("The FastCtx session cannot be moved to a new control center because {reason}.")
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/// The bytes a replacement engine needs before it can carry this session.
|
|
294
|
+
pub(super) fn resume_plan(&self) -> Result<ResumePlan, String> {
|
|
295
|
+
if let Some(reason) = self.resume_blocked() {
|
|
296
|
+
return Err(reason);
|
|
297
|
+
}
|
|
298
|
+
Ok(ResumePlan {
|
|
299
|
+
initialize: self.initialize.clone(),
|
|
300
|
+
initialized: self.initialized.clone(),
|
|
301
|
+
initialize_owed: self.initialize_owed,
|
|
302
|
+
partial_request: if self.partial_request_lost {
|
|
303
|
+
None
|
|
304
|
+
} else {
|
|
305
|
+
Some(self.partial_request.clone())
|
|
306
|
+
},
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/// Accepts the answer a replacement engine gave to the replayed `initialize`.
|
|
311
|
+
pub(super) fn accept_replayed_initialize(&mut self) {
|
|
312
|
+
self.initialize_owed = false;
|
|
313
|
+
if let Some(key) = self.initialize_id.clone() {
|
|
314
|
+
self.unanswered.retain(|(tracked, _)| tracked != &key);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/// Drops the request bytes that a replacement engine will never receive whole, so the next
|
|
319
|
+
/// newline resynchronises the stream instead of handing it a spliced message.
|
|
320
|
+
pub(super) fn abandon_partial_request(&mut self) {
|
|
321
|
+
self.partial_request.clear();
|
|
322
|
+
self.partial_request.shrink_to(LINE_BUFFER_IDLE_CAPACITY);
|
|
323
|
+
self.partial_request_lost = false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/// What a replacement engine must be told before the session resumes on it.
|
|
328
|
+
pub(super) struct ResumePlan {
|
|
329
|
+
/// Raw `initialize` request line, absent when the link failed before the host sent one.
|
|
330
|
+
pub(super) initialize: Option<Vec<u8>>,
|
|
331
|
+
pub(super) initialized: Option<Vec<u8>>,
|
|
332
|
+
/// Whether the answer to the replayed `initialize` is still owed to the host.
|
|
333
|
+
pub(super) initialize_owed: bool,
|
|
334
|
+
/// Bytes of the request line in progress. `None` when it outgrew replay and the stream has to
|
|
335
|
+
/// resynchronise on the next newline instead.
|
|
336
|
+
pub(super) partial_request: Option<Vec<u8>>,
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/// The JSON-RPC envelope fields the session needs, read without materialising the payload.
|
|
340
|
+
struct EnvelopeHead {
|
|
341
|
+
id: Option<Value>,
|
|
342
|
+
method: Option<String>,
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/// Reads the top-level `id` and `method` of one JSON-RPC message.
|
|
346
|
+
///
|
|
347
|
+
/// A raw newline never appears inside a JSON string, so callers can split messages on newlines
|
|
348
|
+
/// before reaching here. Anything that is not a JSON object — a batch array, a partial message
|
|
349
|
+
/// past the envelope window — yields no fields and stays untracked.
|
|
350
|
+
fn envelope_head(message: &[u8]) -> EnvelopeHead {
|
|
351
|
+
let mut head = EnvelopeHead {
|
|
352
|
+
id: None,
|
|
353
|
+
method: None,
|
|
354
|
+
};
|
|
355
|
+
let mut cursor = skip_whitespace(message, 0);
|
|
356
|
+
if message.get(cursor) != Some(&b'{') {
|
|
357
|
+
return head;
|
|
358
|
+
}
|
|
359
|
+
cursor += 1;
|
|
360
|
+
loop {
|
|
361
|
+
cursor = skip_whitespace(message, cursor);
|
|
362
|
+
match message.get(cursor) {
|
|
363
|
+
Some(b'}') | None => return head,
|
|
364
|
+
Some(b',') => {
|
|
365
|
+
cursor += 1;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
Some(b'"') => {}
|
|
369
|
+
Some(_) => return head,
|
|
370
|
+
}
|
|
371
|
+
let Some((key, after_key)) = read_string(message, cursor) else {
|
|
372
|
+
return head;
|
|
373
|
+
};
|
|
374
|
+
cursor = skip_whitespace(message, after_key);
|
|
375
|
+
if message.get(cursor) != Some(&b':') {
|
|
376
|
+
return head;
|
|
377
|
+
}
|
|
378
|
+
cursor = skip_whitespace(message, cursor + 1);
|
|
379
|
+
let Some(after_value) = skip_value(message, cursor) else {
|
|
380
|
+
return head;
|
|
381
|
+
};
|
|
382
|
+
match key.as_str() {
|
|
383
|
+
"id" => head.id = serde_json::from_slice(&message[cursor..after_value]).ok(),
|
|
384
|
+
"method" => head.method = serde_json::from_slice(&message[cursor..after_value]).ok(),
|
|
385
|
+
_ => {}
|
|
386
|
+
}
|
|
387
|
+
if head.id.is_some() && head.method.is_some() {
|
|
388
|
+
return head;
|
|
389
|
+
}
|
|
390
|
+
cursor = after_value;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
fn skip_whitespace(message: &[u8], mut cursor: usize) -> usize {
|
|
395
|
+
while matches!(message.get(cursor), Some(b' ' | b'\t' | b'\r' | b'\n')) {
|
|
396
|
+
cursor += 1;
|
|
397
|
+
}
|
|
398
|
+
cursor
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/// Reads a JSON string starting at an opening quote, returning its value and the index after it.
|
|
402
|
+
fn read_string(message: &[u8], cursor: usize) -> Option<(String, usize)> {
|
|
403
|
+
let end = skip_string(message, cursor)?;
|
|
404
|
+
let value: String = serde_json::from_slice(&message[cursor..end]).ok()?;
|
|
405
|
+
Some((value, end))
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/// Returns the index just past a JSON string starting at an opening quote.
|
|
409
|
+
fn skip_string(message: &[u8], cursor: usize) -> Option<usize> {
|
|
410
|
+
if message.get(cursor) != Some(&b'"') {
|
|
411
|
+
return None;
|
|
412
|
+
}
|
|
413
|
+
let mut index = cursor + 1;
|
|
414
|
+
while let Some(&byte) = message.get(index) {
|
|
415
|
+
match byte {
|
|
416
|
+
b'\\' => index += 2,
|
|
417
|
+
b'"' => return Some(index + 1),
|
|
418
|
+
_ => index += 1,
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
None
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/// Returns the index just past one JSON value, skipping nested structures without decoding them.
|
|
425
|
+
fn skip_value(message: &[u8], cursor: usize) -> Option<usize> {
|
|
426
|
+
match *message.get(cursor)? {
|
|
427
|
+
b'"' => skip_string(message, cursor),
|
|
428
|
+
b'{' | b'[' => skip_container(message, cursor),
|
|
429
|
+
// A scalar runs until the next structural byte.
|
|
430
|
+
_ => {
|
|
431
|
+
let mut index = cursor;
|
|
432
|
+
while let Some(&byte) = message.get(index) {
|
|
433
|
+
if matches!(byte, b',' | b'}' | b']' | b' ' | b'\t' | b'\r' | b'\n') {
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
index += 1;
|
|
437
|
+
}
|
|
438
|
+
(index > cursor).then_some(index)
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/// Returns the index just past a JSON object or array starting at its opening bracket.
|
|
444
|
+
fn skip_container(message: &[u8], cursor: usize) -> Option<usize> {
|
|
445
|
+
let mut index = cursor;
|
|
446
|
+
let mut depth = 0_usize;
|
|
447
|
+
loop {
|
|
448
|
+
match *message.get(index)? {
|
|
449
|
+
b'"' => {
|
|
450
|
+
index = skip_string(message, index)?;
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
b'{' | b'[' => depth += 1,
|
|
454
|
+
b'}' | b']' => {
|
|
455
|
+
depth = depth.checked_sub(1)?;
|
|
456
|
+
index += 1;
|
|
457
|
+
if depth == 0 {
|
|
458
|
+
return Some(index);
|
|
459
|
+
}
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
_ => {}
|
|
463
|
+
}
|
|
464
|
+
index += 1;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/// Normalises a JSON-RPC id so the request side and the answer side agree on one key.
|
|
469
|
+
fn canonical_id(id: &Value) -> String {
|
|
470
|
+
id.to_string()
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
#[cfg(test)]
|
|
474
|
+
mod tests {
|
|
475
|
+
use super::{SessionJournal, envelope_head};
|
|
476
|
+
|
|
477
|
+
fn line(text: &str) -> Vec<u8> {
|
|
478
|
+
format!("{text}\n").into_bytes()
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
#[test]
|
|
482
|
+
fn envelope_fields_are_read_without_decoding_the_payload() {
|
|
483
|
+
let head = envelope_head(
|
|
484
|
+
br#"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"id":"nested"}}"#,
|
|
485
|
+
);
|
|
486
|
+
assert_eq!(head.id.unwrap(), serde_json::json!(7));
|
|
487
|
+
assert_eq!(head.method.unwrap(), "tools/call");
|
|
488
|
+
|
|
489
|
+
let answer = envelope_head(br#"{"jsonrpc":"2.0","id":"abc","result":{"content":[]}}"#);
|
|
490
|
+
assert_eq!(answer.id.unwrap(), serde_json::json!("abc"));
|
|
491
|
+
assert!(answer.method.is_none());
|
|
492
|
+
|
|
493
|
+
assert!(envelope_head(br#"[{"jsonrpc":"2.0"}]"#).id.is_none());
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
#[test]
|
|
497
|
+
fn an_answered_request_is_no_longer_owed_but_an_unanswered_one_is_closed_out() {
|
|
498
|
+
let mut journal = SessionJournal::new();
|
|
499
|
+
journal.observe_request_bytes(&line(r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#));
|
|
500
|
+
journal.observe_request_bytes(&line(r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#));
|
|
501
|
+
journal.observe_request_bytes(&line(r#"{"jsonrpc":"2.0","id":3,"method":"tools/call"}"#));
|
|
502
|
+
journal.mark_delivered();
|
|
503
|
+
journal.observe_answer_bytes(&line(r#"{"jsonrpc":"2.0","id":2,"result":{}}"#));
|
|
504
|
+
|
|
505
|
+
let unanswered = journal.take_unanswered();
|
|
506
|
+
|
|
507
|
+
assert_eq!(unanswered.len(), 1);
|
|
508
|
+
let failure = String::from_utf8(unanswered[0].failure_line()).unwrap();
|
|
509
|
+
assert!(failure.contains("\"id\":3"), "{failure}");
|
|
510
|
+
assert!(
|
|
511
|
+
failure.contains("may or may not have finished"),
|
|
512
|
+
"{failure}"
|
|
513
|
+
);
|
|
514
|
+
// initialize stays behind for the replacement engine to answer for real.
|
|
515
|
+
let plan = journal.resume_plan().unwrap();
|
|
516
|
+
assert!(plan.initialize_owed);
|
|
517
|
+
assert!(plan.initialize.is_some());
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
#[test]
|
|
521
|
+
fn an_undelivered_request_is_reported_as_never_having_run() {
|
|
522
|
+
let mut journal = SessionJournal::new();
|
|
523
|
+
journal.observe_request_bytes(&line(r#"{"jsonrpc":"2.0","id":9,"method":"tools/call"}"#));
|
|
524
|
+
|
|
525
|
+
let unanswered = journal.take_unanswered();
|
|
526
|
+
|
|
527
|
+
let failure = String::from_utf8(unanswered[0].failure_line()).unwrap();
|
|
528
|
+
assert!(failure.contains("Nothing ran"), "{failure}");
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
#[test]
|
|
532
|
+
fn an_incomplete_request_is_replayed_so_the_next_engine_sees_it_whole() {
|
|
533
|
+
let mut journal = SessionJournal::new();
|
|
534
|
+
journal.observe_request_bytes(&line(r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#));
|
|
535
|
+
journal.observe_request_bytes(br#"{"jsonrpc":"2.0","id":2,"met"#);
|
|
536
|
+
|
|
537
|
+
let plan = journal.resume_plan().unwrap();
|
|
538
|
+
|
|
539
|
+
assert_eq!(
|
|
540
|
+
plan.partial_request.unwrap(),
|
|
541
|
+
br#"{"jsonrpc":"2.0","id":2,"met"#.to_vec()
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
#[test]
|
|
546
|
+
fn a_session_without_an_initialize_answer_keeps_owing_it() {
|
|
547
|
+
let mut journal = SessionJournal::new();
|
|
548
|
+
journal.observe_request_bytes(&line(r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#));
|
|
549
|
+
journal.observe_answer_bytes(&line(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#));
|
|
550
|
+
|
|
551
|
+
let plan = journal.resume_plan().unwrap();
|
|
552
|
+
|
|
553
|
+
assert!(!plan.initialize_owed);
|
|
554
|
+
assert!(journal.take_unanswered().is_empty());
|
|
555
|
+
}
|
|
556
|
+
}
|