dsh-ops 0.0.0-stage → 0.2.1
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 +189 -0
- package/LICENSE +30 -0
- package/NOTICE +106 -0
- package/PROVENANCE.md +417 -0
- package/README.en.md +121 -0
- package/README.md +113 -2
- package/README.zh.md +114 -0
- package/bin/dsh-ops.mjs +1216 -0
- package/cordis.patch.yml +160 -0
- package/docs/manual-validation.md +53 -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 +993 -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,536 @@
|
|
|
1
|
+
//! One MCP session, and the engine links that come and go underneath it.
|
|
2
|
+
//!
|
|
3
|
+
//! The session is the thing the host owns: it starts when the host spawns `fastctx serve` and ends
|
|
4
|
+
//! when the host stops needing it. The engine carrying that session — a shared control center, or
|
|
5
|
+
//! a control-center session running inside this process — is replaceable, because a host never
|
|
6
|
+
//! recovers from a stdio MCP server that exits. Codex does not restart one, so a session that
|
|
7
|
+
//! ended itself over a failed engine link costs that conversation every FastCtx tool until the
|
|
8
|
+
//! user notices and starts a new one. Nothing below ends the session on an engine failure.
|
|
9
|
+
|
|
10
|
+
use super::journal::SessionJournal;
|
|
11
|
+
use super::local_ipc::BoxedStream;
|
|
12
|
+
use super::{
|
|
13
|
+
RESPONSE_DRAIN_TIMEOUT, STARTUP_TIMEOUT, connect_or_start, protocol, start_in_process,
|
|
14
|
+
};
|
|
15
|
+
use crate::process_identity::ProcessIdentity;
|
|
16
|
+
use crate::server::ServerOptions;
|
|
17
|
+
use crate::session::SessionEnvironment;
|
|
18
|
+
use crate::stdio_transport::DetachedStdin;
|
|
19
|
+
use std::future::Future;
|
|
20
|
+
use std::pin::Pin;
|
|
21
|
+
use std::process::ExitCode;
|
|
22
|
+
use std::sync::Arc;
|
|
23
|
+
use std::sync::atomic::{AtomicBool, Ordering};
|
|
24
|
+
use std::time::Duration;
|
|
25
|
+
use tokio::io::{AsyncReadExt, AsyncWriteExt, ReadHalf, WriteHalf, split};
|
|
26
|
+
|
|
27
|
+
/// Gap between engine acquisition rounds once both the shared control center and the in-process
|
|
28
|
+
/// engine have failed. Reaching a second round means the machine cannot host either, which no
|
|
29
|
+
/// ordinary failure produces.
|
|
30
|
+
const RELINK_RETRY_GAP: Duration = Duration::from_secs(1);
|
|
31
|
+
/// How many retry rounds pass between repeats of the same failure on the host's log.
|
|
32
|
+
const RELINK_REPORT_INTERVAL: u32 = 60;
|
|
33
|
+
/// Bytes accepted while reading the answer to a replayed `initialize`. A handshake answer is a few
|
|
34
|
+
/// kilobytes; the bound only stops a broken engine from streaming forever into the replay.
|
|
35
|
+
const REPLAY_ANSWER_LIMIT_BYTES: usize = 1024 * 1024;
|
|
36
|
+
const ANSWER_CHUNK_BYTES: usize = 64 * 1024;
|
|
37
|
+
|
|
38
|
+
/// Runs the whole stdio MCP session: acquire an engine, pump bytes, replace the engine on failure.
|
|
39
|
+
pub(crate) async fn run_proxy_session(
|
|
40
|
+
options: ServerOptions,
|
|
41
|
+
environment: SessionEnvironment,
|
|
42
|
+
parent: Option<Option<ProcessIdentity>>,
|
|
43
|
+
) -> Result<ExitCode, String> {
|
|
44
|
+
let host = parent.clone().flatten();
|
|
45
|
+
// The first engine is acquired before stdin is touched, so a control center that cannot serve
|
|
46
|
+
// this session degrades without ever consuming an MCP request.
|
|
47
|
+
let stream = match connect_or_start(options, &environment, host.clone()).await {
|
|
48
|
+
Ok(stream) => stream,
|
|
49
|
+
Err(error) => {
|
|
50
|
+
eprintln!(
|
|
51
|
+
"fastctx: control center unavailable ({error}); falling back to a full standalone MCP server."
|
|
52
|
+
);
|
|
53
|
+
start_in_process(options, &environment, host.clone()).await?
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
let session = ProxySession {
|
|
57
|
+
stdin: DetachedStdin::start()?,
|
|
58
|
+
stdout: tokio::io::stdout(),
|
|
59
|
+
journal: SessionJournal::new(),
|
|
60
|
+
options,
|
|
61
|
+
environment,
|
|
62
|
+
host,
|
|
63
|
+
input_closed: false,
|
|
64
|
+
resynchronising: false,
|
|
65
|
+
};
|
|
66
|
+
session.run(EngineLink::new(stream), parent).await
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// The engine currently carrying the session, split so both directions can be awaited at once.
|
|
70
|
+
struct EngineLink {
|
|
71
|
+
reader: ReadHalf<BoxedStream>,
|
|
72
|
+
writer: WriteHalf<BoxedStream>,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
impl EngineLink {
|
|
76
|
+
fn new(stream: BoxedStream) -> Self {
|
|
77
|
+
let (reader, writer) = split(stream);
|
|
78
|
+
Self { reader, writer }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/// Why the byte pump stopped.
|
|
83
|
+
enum LinkOutcome {
|
|
84
|
+
/// The session itself is over and the process should exit.
|
|
85
|
+
SessionEnded(Result<(), String>),
|
|
86
|
+
/// The engine link failed while the session is still live.
|
|
87
|
+
LinkLost(String),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// The result of replacing a failed engine link.
|
|
91
|
+
enum Recovery {
|
|
92
|
+
Linked(EngineLink),
|
|
93
|
+
SessionEnded(Result<(), String>),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// Everything that outlives an individual engine link.
|
|
97
|
+
struct ProxySession {
|
|
98
|
+
stdin: DetachedStdin,
|
|
99
|
+
stdout: tokio::io::Stdout,
|
|
100
|
+
journal: SessionJournal,
|
|
101
|
+
options: ServerOptions,
|
|
102
|
+
environment: SessionEnvironment,
|
|
103
|
+
host: Option<ProcessIdentity>,
|
|
104
|
+
input_closed: bool,
|
|
105
|
+
/// Set after an in-progress request grew past what can be replayed. Request bytes are then
|
|
106
|
+
/// discarded until the next newline, so a replacement engine never receives a spliced message.
|
|
107
|
+
resynchronising: bool,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
impl ProxySession {
|
|
111
|
+
async fn run(
|
|
112
|
+
mut self,
|
|
113
|
+
mut link: EngineLink,
|
|
114
|
+
parent: Option<Option<ProcessIdentity>>,
|
|
115
|
+
) -> Result<ExitCode, String> {
|
|
116
|
+
let mut signals = SessionSignals::new(parent, &self.stdin);
|
|
117
|
+
let result = loop {
|
|
118
|
+
match self.pump(&mut link, &mut signals).await {
|
|
119
|
+
LinkOutcome::SessionEnded(result) => break result,
|
|
120
|
+
LinkOutcome::LinkLost(reason) => match self.recover(reason, &mut signals).await {
|
|
121
|
+
Recovery::Linked(replacement) => link = replacement,
|
|
122
|
+
Recovery::SessionEnded(result) => break result,
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
signals.stop().await;
|
|
127
|
+
result?;
|
|
128
|
+
Ok(ExitCode::SUCCESS)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// Moves bytes between the host and one engine link until either the session or the link ends.
|
|
132
|
+
async fn pump(&mut self, link: &mut EngineLink, signals: &mut SessionSignals) -> LinkOutcome {
|
|
133
|
+
let mut request_buffer = vec![0_u8; protocol::MAX_REQUEST_FRAME_BYTES];
|
|
134
|
+
let mut answer_buffer = vec![0_u8; ANSWER_CHUNK_BYTES];
|
|
135
|
+
let mut drain_deadline: Option<tokio::time::Instant> = None;
|
|
136
|
+
loop {
|
|
137
|
+
let Self {
|
|
138
|
+
stdin,
|
|
139
|
+
stdout,
|
|
140
|
+
journal,
|
|
141
|
+
input_closed,
|
|
142
|
+
resynchronising,
|
|
143
|
+
..
|
|
144
|
+
} = &mut *self;
|
|
145
|
+
tokio::select! {
|
|
146
|
+
biased;
|
|
147
|
+
error = &mut signals.stdin_error => {
|
|
148
|
+
return LinkOutcome::SessionEnded(Err(error));
|
|
149
|
+
}
|
|
150
|
+
() = &mut signals.parent_exit => return LinkOutcome::SessionEnded(Ok(())),
|
|
151
|
+
() = &mut signals.termination => return LinkOutcome::SessionEnded(Ok(())),
|
|
152
|
+
() = drain_backstop(drain_deadline) => return LinkOutcome::SessionEnded(Ok(())),
|
|
153
|
+
read = stdin.read(&mut request_buffer), if !*input_closed => match read {
|
|
154
|
+
Err(error) => {
|
|
155
|
+
return LinkOutcome::SessionEnded(
|
|
156
|
+
Err(format!("Cannot read MCP stdin: {error}")),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
Ok(0) => {
|
|
160
|
+
*input_closed = true;
|
|
161
|
+
if let Err(error) = protocol::write_end_of_input(&mut link.writer).await {
|
|
162
|
+
return LinkOutcome::LinkLost(format!(
|
|
163
|
+
"The FastCtx engine connection failed while closing the session: {error}"
|
|
164
|
+
));
|
|
165
|
+
}
|
|
166
|
+
// Nothing was ever asked, so nothing can be owed; waiting would only stall
|
|
167
|
+
// a client that opened the transport and changed its mind.
|
|
168
|
+
if !journal.has_forwarded() {
|
|
169
|
+
return LinkOutcome::SessionEnded(Ok(()));
|
|
170
|
+
}
|
|
171
|
+
drain_deadline =
|
|
172
|
+
Some(tokio::time::Instant::now() + RESPONSE_DRAIN_TIMEOUT);
|
|
173
|
+
}
|
|
174
|
+
Ok(read) => {
|
|
175
|
+
let mut chunk = &request_buffer[..read];
|
|
176
|
+
if *resynchronising {
|
|
177
|
+
match chunk.iter().position(|&byte| byte == b'\n') {
|
|
178
|
+
Some(index) => {
|
|
179
|
+
*resynchronising = false;
|
|
180
|
+
chunk = &chunk[index + 1..];
|
|
181
|
+
}
|
|
182
|
+
None => continue,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if chunk.is_empty() {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
journal.observe_request_bytes(chunk);
|
|
189
|
+
match protocol::write_request_frame(&mut link.writer, chunk).await {
|
|
190
|
+
Ok(()) => journal.mark_delivered(),
|
|
191
|
+
Err(failure) => {
|
|
192
|
+
if failure.reached_engine {
|
|
193
|
+
journal.mark_delivered();
|
|
194
|
+
}
|
|
195
|
+
return LinkOutcome::LinkLost(format!(
|
|
196
|
+
"The FastCtx engine stopped accepting requests: {}",
|
|
197
|
+
failure.error
|
|
198
|
+
));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
read = link.reader.read(&mut answer_buffer) => match read {
|
|
204
|
+
Ok(0) => {
|
|
205
|
+
return LinkOutcome::LinkLost(
|
|
206
|
+
"The FastCtx engine connection closed".to_string(),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
Err(error) => {
|
|
210
|
+
return LinkOutcome::LinkLost(format!(
|
|
211
|
+
"The FastCtx engine connection failed: {error}"
|
|
212
|
+
));
|
|
213
|
+
}
|
|
214
|
+
Ok(read) => {
|
|
215
|
+
let chunk = &answer_buffer[..read];
|
|
216
|
+
journal.observe_answer_bytes(chunk);
|
|
217
|
+
if let Err(error) = write_out(stdout, chunk).await {
|
|
218
|
+
return LinkOutcome::SessionEnded(Err(error));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/// Closes out the calls a failed engine can no longer answer, then links a replacement.
|
|
227
|
+
async fn recover(&mut self, reason: String, signals: &mut SessionSignals) -> Recovery {
|
|
228
|
+
if self.input_closed {
|
|
229
|
+
// After the host closed stdin, an engine that stops answering is the end of a session
|
|
230
|
+
// that was already ending. There is nothing left to reconnect for.
|
|
231
|
+
let _ = self.close_out_unanswered().await;
|
|
232
|
+
return Recovery::SessionEnded(Ok(()));
|
|
233
|
+
}
|
|
234
|
+
eprintln!("fastctx: {reason}. Reconnecting; this MCP session stays open.");
|
|
235
|
+
if let Err(error) = self.close_out_unanswered().await {
|
|
236
|
+
return Recovery::SessionEnded(Err(error));
|
|
237
|
+
}
|
|
238
|
+
// No engine can take over a session whose opening handshake was never retained, so this
|
|
239
|
+
// ends the session honestly instead of retrying something that can only fail again.
|
|
240
|
+
if let Some(blocked) = self.journal.resume_blocked() {
|
|
241
|
+
return Recovery::SessionEnded(Err(blocked));
|
|
242
|
+
}
|
|
243
|
+
let mut attempts: u32 = 0;
|
|
244
|
+
loop {
|
|
245
|
+
// The first failure of a round explains the situation; repeating it every second
|
|
246
|
+
// would only bury the host's log while the retry loop does its work.
|
|
247
|
+
let report = attempts.is_multiple_of(RELINK_REPORT_INTERVAL);
|
|
248
|
+
tokio::select! {
|
|
249
|
+
biased;
|
|
250
|
+
error = &mut signals.stdin_error => {
|
|
251
|
+
return Recovery::SessionEnded(Err(error));
|
|
252
|
+
}
|
|
253
|
+
() = &mut signals.parent_exit => return Recovery::SessionEnded(Ok(())),
|
|
254
|
+
() = &mut signals.termination => return Recovery::SessionEnded(Ok(())),
|
|
255
|
+
acquired = self.acquire_engine(report) => match acquired {
|
|
256
|
+
Ok(link) => {
|
|
257
|
+
eprintln!("fastctx: reconnected; FastCtx tools are available again.");
|
|
258
|
+
return Recovery::Linked(link);
|
|
259
|
+
}
|
|
260
|
+
Err(error) => {
|
|
261
|
+
if report {
|
|
262
|
+
eprintln!("fastctx: {error} Retrying every second.");
|
|
263
|
+
}
|
|
264
|
+
attempts = attempts.saturating_add(1);
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
tokio::select! {
|
|
269
|
+
biased;
|
|
270
|
+
error = &mut signals.stdin_error => {
|
|
271
|
+
return Recovery::SessionEnded(Err(error));
|
|
272
|
+
}
|
|
273
|
+
() = &mut signals.parent_exit => return Recovery::SessionEnded(Ok(())),
|
|
274
|
+
() = &mut signals.termination => return Recovery::SessionEnded(Ok(())),
|
|
275
|
+
() = tokio::time::sleep(RELINK_RETRY_GAP) => {}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/// Prefers the shared control center and falls back to an engine inside this process.
|
|
281
|
+
///
|
|
282
|
+
/// `report` gates the explanation of why the shared runtime was skipped, so a retry loop does
|
|
283
|
+
/// not repeat it once a second.
|
|
284
|
+
async fn acquire_engine(&mut self, report: bool) -> Result<EngineLink, String> {
|
|
285
|
+
let stream = match connect_or_start(self.options, &self.environment, self.host.clone())
|
|
286
|
+
.await
|
|
287
|
+
{
|
|
288
|
+
Ok(stream) => stream,
|
|
289
|
+
Err(shared) => {
|
|
290
|
+
if report {
|
|
291
|
+
eprintln!(
|
|
292
|
+
"fastctx: {shared} Running the FastCtx engine inside this session instead."
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
start_in_process(self.options, &self.environment, self.host.clone())
|
|
296
|
+
.await
|
|
297
|
+
.map_err(|error| {
|
|
298
|
+
format!("Cannot start an in-process FastCtx engine either: {error}")
|
|
299
|
+
})?
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
let mut link = EngineLink::new(stream);
|
|
303
|
+
self.resume_on(&mut link).await?;
|
|
304
|
+
Ok(link)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/// Tells a replacement engine everything it needs to carry the session already in progress.
|
|
308
|
+
async fn resume_on(&mut self, link: &mut EngineLink) -> Result<(), String> {
|
|
309
|
+
let plan = self.journal.resume_plan()?;
|
|
310
|
+
let Some(initialize) = plan.initialize else {
|
|
311
|
+
// The engine failed before the host initialised the session; its `initialize` is still
|
|
312
|
+
// on the way and needs no replay.
|
|
313
|
+
return self.replay_partial(link, plan.partial_request).await;
|
|
314
|
+
};
|
|
315
|
+
protocol::write_request_frame(&mut link.writer, &initialize)
|
|
316
|
+
.await
|
|
317
|
+
.map_err(|failure| {
|
|
318
|
+
format!(
|
|
319
|
+
"Cannot replay the MCP handshake onto a new engine: {}",
|
|
320
|
+
failure.error
|
|
321
|
+
)
|
|
322
|
+
})?;
|
|
323
|
+
let answer = tokio::time::timeout(STARTUP_TIMEOUT, read_answer_line(&mut link.reader))
|
|
324
|
+
.await
|
|
325
|
+
.map_err(|_| {
|
|
326
|
+
"Timed out replaying the MCP handshake onto a new FastCtx engine.".to_string()
|
|
327
|
+
})??;
|
|
328
|
+
// The host already holds an answer to this request unless the engine died before
|
|
329
|
+
// delivering it, in which case the replayed answer is the one it has been waiting for.
|
|
330
|
+
if plan.initialize_owed {
|
|
331
|
+
write_out(&mut self.stdout, &answer).await?;
|
|
332
|
+
}
|
|
333
|
+
self.journal.accept_replayed_initialize();
|
|
334
|
+
if let Some(initialized) = plan.initialized {
|
|
335
|
+
protocol::write_request_frame(&mut link.writer, &initialized)
|
|
336
|
+
.await
|
|
337
|
+
.map_err(|failure| {
|
|
338
|
+
format!(
|
|
339
|
+
"Cannot replay the MCP handshake onto a new engine: {}",
|
|
340
|
+
failure.error
|
|
341
|
+
)
|
|
342
|
+
})?;
|
|
343
|
+
}
|
|
344
|
+
self.replay_partial(link, plan.partial_request).await
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async fn replay_partial(
|
|
348
|
+
&mut self,
|
|
349
|
+
link: &mut EngineLink,
|
|
350
|
+
partial: Option<Vec<u8>>,
|
|
351
|
+
) -> Result<(), String> {
|
|
352
|
+
match partial {
|
|
353
|
+
Some(bytes) if !bytes.is_empty() => {
|
|
354
|
+
protocol::write_request_frame(&mut link.writer, &bytes)
|
|
355
|
+
.await
|
|
356
|
+
.map_err(|failure| {
|
|
357
|
+
format!(
|
|
358
|
+
"Cannot replay a request in progress onto a new engine: {}",
|
|
359
|
+
failure.error
|
|
360
|
+
)
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
Some(_) => Ok(()),
|
|
364
|
+
None => {
|
|
365
|
+
self.journal.abandon_partial_request();
|
|
366
|
+
self.resynchronising = true;
|
|
367
|
+
Ok(())
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/// Answers every call the failed engine left hanging, so the model is told rather than left
|
|
373
|
+
/// waiting for a result that can no longer arrive.
|
|
374
|
+
async fn close_out_unanswered(&mut self) -> Result<(), String> {
|
|
375
|
+
if !self.journal.tracking_is_complete() {
|
|
376
|
+
eprintln!(
|
|
377
|
+
"fastctx: this session had more calls in flight than FastCtx tracks, so some of them cannot be closed out and will time out on the host instead."
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
let unanswered = self.journal.take_unanswered();
|
|
381
|
+
if unanswered.is_empty() {
|
|
382
|
+
return Ok(());
|
|
383
|
+
}
|
|
384
|
+
for request in &unanswered {
|
|
385
|
+
write_out(&mut self.stdout, &request.failure_line()).await?;
|
|
386
|
+
}
|
|
387
|
+
Ok(())
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/// Writes one message to the host and flushes it, because a buffered answer is an unanswered one.
|
|
392
|
+
async fn write_out(stdout: &mut tokio::io::Stdout, bytes: &[u8]) -> Result<(), String> {
|
|
393
|
+
stdout
|
|
394
|
+
.write_all(bytes)
|
|
395
|
+
.await
|
|
396
|
+
.map_err(|error| format!("Cannot write the MCP answer stream: {error}"))?;
|
|
397
|
+
stdout
|
|
398
|
+
.flush()
|
|
399
|
+
.await
|
|
400
|
+
.map_err(|error| format!("Cannot flush the MCP answer stream: {error}"))
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/// Reads one newline-delimited message. A raw newline never appears inside a JSON string, so the
|
|
404
|
+
/// first one always ends the message.
|
|
405
|
+
async fn read_answer_line(reader: &mut ReadHalf<BoxedStream>) -> Result<Vec<u8>, String> {
|
|
406
|
+
let mut line = Vec::new();
|
|
407
|
+
let mut byte = [0_u8; 1];
|
|
408
|
+
loop {
|
|
409
|
+
match reader.read(&mut byte).await {
|
|
410
|
+
Ok(0) => {
|
|
411
|
+
return Err(
|
|
412
|
+
"The new FastCtx engine closed before answering the replayed MCP handshake."
|
|
413
|
+
.to_string(),
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
Ok(_) => {
|
|
417
|
+
line.push(byte[0]);
|
|
418
|
+
if byte[0] == b'\n' {
|
|
419
|
+
return Ok(line);
|
|
420
|
+
}
|
|
421
|
+
if line.len() > REPLAY_ANSWER_LIMIT_BYTES {
|
|
422
|
+
return Err(
|
|
423
|
+
"The new FastCtx engine answered the replayed MCP handshake with an unbounded message."
|
|
424
|
+
.to_string(),
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
Err(error) => {
|
|
429
|
+
return Err(format!(
|
|
430
|
+
"Cannot read the replayed MCP handshake answer: {error}"
|
|
431
|
+
));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/// Bounds how long a closing session waits for answers the engine still owes.
|
|
438
|
+
async fn drain_backstop(deadline: Option<tokio::time::Instant>) {
|
|
439
|
+
match deadline {
|
|
440
|
+
Some(deadline) => tokio::time::sleep_until(deadline).await,
|
|
441
|
+
None => std::future::pending().await,
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/// The events that end a session no matter which engine is carrying it.
|
|
446
|
+
struct SessionSignals {
|
|
447
|
+
parent_exit: Pin<Box<dyn Future<Output = ()> + Send>>,
|
|
448
|
+
termination: Pin<Box<dyn Future<Output = ()> + Send>>,
|
|
449
|
+
stdin_error: Pin<Box<dyn Future<Output = String> + Send>>,
|
|
450
|
+
monitor: Option<tokio::task::JoinHandle<()>>,
|
|
451
|
+
monitor_stop: Arc<AtomicBool>,
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
impl SessionSignals {
|
|
455
|
+
fn new(parent: Option<Option<ProcessIdentity>>, stdin: &DetachedStdin) -> Self {
|
|
456
|
+
let monitor_stop = Arc::new(AtomicBool::new(false));
|
|
457
|
+
let (parent_exit, monitor) = parent_exit_monitor(parent, Arc::clone(&monitor_stop));
|
|
458
|
+
Self {
|
|
459
|
+
parent_exit,
|
|
460
|
+
termination: Box::pin(wait_for_termination_signal()),
|
|
461
|
+
stdin_error: Box::pin(wait_for_stdin_error(stdin.read_error_receiver())),
|
|
462
|
+
monitor,
|
|
463
|
+
monitor_stop,
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async fn stop(self) {
|
|
468
|
+
self.monitor_stop.store(true, Ordering::Release);
|
|
469
|
+
if let Some(monitor) = self.monitor {
|
|
470
|
+
let _ = monitor.await;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
type ParentExitFuture = Pin<Box<dyn Future<Output = ()> + Send>>;
|
|
476
|
+
|
|
477
|
+
fn parent_exit_monitor(
|
|
478
|
+
parent: Option<Option<ProcessIdentity>>,
|
|
479
|
+
stop: Arc<AtomicBool>,
|
|
480
|
+
) -> (ParentExitFuture, Option<tokio::task::JoinHandle<()>>) {
|
|
481
|
+
let (sender, receiver) = tokio::sync::oneshot::channel();
|
|
482
|
+
let monitor = match parent {
|
|
483
|
+
None => None,
|
|
484
|
+
Some(None) => {
|
|
485
|
+
let _ = sender.send(());
|
|
486
|
+
return (Box::pin(async {}), None);
|
|
487
|
+
}
|
|
488
|
+
Some(Some(identity)) => Some(tokio::task::spawn_blocking(move || {
|
|
489
|
+
if crate::process_identity::wait_for_identity_exit_until(&identity, &stop) {
|
|
490
|
+
let _ = sender.send(());
|
|
491
|
+
}
|
|
492
|
+
})),
|
|
493
|
+
};
|
|
494
|
+
let future = async move {
|
|
495
|
+
match receiver.await {
|
|
496
|
+
Ok(()) => {}
|
|
497
|
+
// Monitor failure is not proof that the host exited. Keep the session alive until an
|
|
498
|
+
// explicit end instead of killing a live conversation.
|
|
499
|
+
Err(_) => std::future::pending::<()>().await,
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
(Box::pin(future), monitor)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async fn wait_for_stdin_error(
|
|
506
|
+
mut receiver: tokio::sync::watch::Receiver<Option<String>>,
|
|
507
|
+
) -> String {
|
|
508
|
+
loop {
|
|
509
|
+
if let Some(error) = receiver.borrow().clone() {
|
|
510
|
+
return error;
|
|
511
|
+
}
|
|
512
|
+
if receiver.changed().await.is_err() {
|
|
513
|
+
return std::future::pending::<String>().await;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
#[cfg(unix)]
|
|
519
|
+
async fn wait_for_termination_signal() {
|
|
520
|
+
use tokio::signal::unix::{SignalKind, signal};
|
|
521
|
+
let Ok(mut terminate) = signal(SignalKind::terminate()) else {
|
|
522
|
+
return std::future::pending::<()>().await;
|
|
523
|
+
};
|
|
524
|
+
let Ok(mut interrupt) = signal(SignalKind::interrupt()) else {
|
|
525
|
+
return std::future::pending::<()>().await;
|
|
526
|
+
};
|
|
527
|
+
tokio::select! {
|
|
528
|
+
_ = terminate.recv() => {}
|
|
529
|
+
_ = interrupt.recv() => {}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
#[cfg(not(unix))]
|
|
534
|
+
async fn wait_for_termination_signal() {
|
|
535
|
+
std::future::pending::<()>().await
|
|
536
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
//! Windows-only launch primitive for a detached control center.
|
|
2
|
+
|
|
3
|
+
use std::ffi::OsStr;
|
|
4
|
+
use std::io;
|
|
5
|
+
use std::os::windows::ffi::OsStrExt;
|
|
6
|
+
use std::path::Path;
|
|
7
|
+
use windows_sys::Win32::Foundation::CloseHandle;
|
|
8
|
+
use windows_sys::Win32::System::Threading::{
|
|
9
|
+
CREATE_NO_WINDOW, CreateProcessW, PROCESS_CREATION_FLAGS, PROCESS_INFORMATION, STARTUPINFOW,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/// Starts `runtime-host` without inheriting any of the proxy's stdio capture pipes.
|
|
13
|
+
///
|
|
14
|
+
/// `std::process::Command` must enable handle inheritance to install redirected standard handles.
|
|
15
|
+
/// A bootstrap launched beneath `Command::output` can therefore receive unrelated inheritable pipe
|
|
16
|
+
/// handles and pass them to its detached child, keeping the caller blocked on EOF indefinitely.
|
|
17
|
+
pub(super) fn spawn_without_inherited_handles(
|
|
18
|
+
executable: &Path,
|
|
19
|
+
cwd: &Path,
|
|
20
|
+
creation_flags: PROCESS_CREATION_FLAGS,
|
|
21
|
+
) -> io::Result<()> {
|
|
22
|
+
let application = nul_terminated(executable.as_os_str());
|
|
23
|
+
let current_directory = nul_terminated(cwd.as_os_str());
|
|
24
|
+
let mut command_line = Vec::with_capacity(application.len() + 16);
|
|
25
|
+
command_line.push(b'"' as u16);
|
|
26
|
+
command_line.extend(executable.as_os_str().encode_wide());
|
|
27
|
+
command_line.extend(['"' as u16, ' ' as u16]);
|
|
28
|
+
command_line.extend(OsStr::new("runtime-host").encode_wide());
|
|
29
|
+
command_line.push(0);
|
|
30
|
+
|
|
31
|
+
// SAFETY: all pointers reference live, NUL-terminated buffers for the duration of the call;
|
|
32
|
+
// the output structure is initialized by CreateProcessW on success. No handles are inherited.
|
|
33
|
+
let mut startup: STARTUPINFOW = unsafe { std::mem::zeroed() };
|
|
34
|
+
startup.cb = size_of::<STARTUPINFOW>() as u32;
|
|
35
|
+
// SAFETY: PROCESS_INFORMATION is a plain output structure whose null handles are valid before
|
|
36
|
+
// CreateProcessW initializes it.
|
|
37
|
+
let mut process: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
|
|
38
|
+
let created = unsafe {
|
|
39
|
+
CreateProcessW(
|
|
40
|
+
application.as_ptr(),
|
|
41
|
+
command_line.as_mut_ptr(),
|
|
42
|
+
std::ptr::null(),
|
|
43
|
+
std::ptr::null(),
|
|
44
|
+
0,
|
|
45
|
+
creation_flags | CREATE_NO_WINDOW,
|
|
46
|
+
std::ptr::null(),
|
|
47
|
+
current_directory.as_ptr(),
|
|
48
|
+
&startup,
|
|
49
|
+
&mut process,
|
|
50
|
+
)
|
|
51
|
+
};
|
|
52
|
+
if created == 0 {
|
|
53
|
+
return Err(io::Error::last_os_error());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// SAFETY: CreateProcessW returned ownership of two valid handles on success.
|
|
57
|
+
unsafe {
|
|
58
|
+
CloseHandle(process.hThread);
|
|
59
|
+
CloseHandle(process.hProcess);
|
|
60
|
+
}
|
|
61
|
+
Ok(())
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fn nul_terminated(value: &OsStr) -> Vec<u16> {
|
|
65
|
+
value.encode_wide().chain(std::iter::once(0)).collect()
|
|
66
|
+
}
|