use super::framing;
use super::notifications::{self, Batch};
use super::subscriptions::{Service, Subscriptions};
use serde_json::{Value, json};
use std::io::{self, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
struct State<O> {
output: O,
error: Option<String>,
handed: Vec<std::path::PathBuf>,
}
pub(super) struct Answers {
pub bytes: usize,
pub dir: Option<std::path::PathBuf>,
}
pub(super) struct Writer<O> {
state: Mutex<State<O>>,
limit: usize,
answers: Answers,
warning_only: Arc<AtomicBool>,
subscriptions: Arc<Subscriptions>,
listens: Arc<super::listen::Listens>,
}
impl<O: Write> Writer<O> {
pub const fn new(
output: O,
limit: usize,
answers: Answers,
warning_only: Arc<AtomicBool>,
subscriptions: Arc<Subscriptions>,
listens: Arc<super::listen::Listens>,
) -> Self {
Self {
state: Mutex::new(State {
output,
error: None,
handed: Vec::new(),
}),
limit,
answers,
warning_only,
subscriptions,
listens,
}
}
pub fn channel(&self, message: Value, subject: &super::listen::Subject) -> io::Result<()> {
let mut state = self
.state
.lock()
.map_err(|_| io::Error::other("the host writer lock is poisoned"))?;
if let Some(words) = &state.error {
return Err(io::Error::other(words.clone()));
}
if !subject.active.load(Ordering::SeqCst) {
return Ok(());
}
let result = self.put(&mut state, message);
if let Err(error) = &result {
state.error = Some(error.to_string());
}
drop(state);
self.finish(result)
}
pub fn check(&self) -> io::Result<()> {
let state = self
.state
.lock()
.map_err(|_| io::Error::other("the host writer lock is poisoned"))?;
state
.error
.as_ref()
.map_or(Ok(()), |words| Err(io::Error::other(words.clone())))
}
pub fn write(
&self,
message: Value,
service: Option<&Service>,
terminal: bool,
) -> io::Result<()> {
let mut state = self
.state
.lock()
.map_err(|_| io::Error::other("the host writer lock is poisoned"))?;
if let Some(words) = &state.error {
return Err(io::Error::other(words.clone()));
}
if let Some(service) = service {
if !terminal && !service.active.load(Ordering::SeqCst) {
return Ok(());
}
if self.warning_only.load(Ordering::SeqCst) && message["params"]["level"] == "info" {
return Ok(());
}
}
let result = if is_answer(&message) {
self.put_answer(&mut state, &message)
} else {
self.put(&mut state, message)
};
if let Err(error) = &result {
state.error = Some(error.to_string());
}
drop(state);
self.finish(result)
}
pub fn record(&self, service: &Service, subject: &str, batch: &Batch) -> io::Result<()> {
let mut state = self
.state
.lock()
.map_err(|_| io::Error::other("the host writer lock is poisoned"))?;
if let Some(words) = &state.error {
return Err(io::Error::other(words.clone()));
}
if !service.active.load(Ordering::SeqCst) {
return Ok(());
}
let result: io::Result<()> = (|| {
if !self.warning_only.load(Ordering::SeqCst) {
for message in &batch.messages {
self.put(&mut state, notifications::message(service, message))?;
}
}
if batch.dropped > 0 {
self.put(
&mut state,
notifications::warning(
service,
subject,
"dropped",
&format!("{} messages on {subject} were not kept", batch.dropped),
),
)?;
}
Ok(())
})();
if let Err(error) = &result {
state.error = Some(error.to_string());
}
drop(state);
self.finish(result)
}
fn put(&self, state: &mut State<O>, mut message: Value) -> io::Result<()> {
fit(&mut message, self.limit)?;
framing::write_message(&mut state.output, &message)
}
fn put_answer(&self, state: &mut State<O>, message: &Value) -> io::Result<()> {
let bytes = size(message)?;
if bytes <= self.answers.bytes {
return framing::write_message(&mut state.output, message);
}
let over = format!(
"the answer is {bytes} bytes, over this seat's inline bound of {}",
self.answers.bytes
);
let id = message["id"].clone();
let answer = match self.answers.dir.as_deref().map_or_else(
|| {
Err(io::Error::new(
io::ErrorKind::NotFound,
"HOME is not set, so this seat has no directory of its own to hand it over in",
))
},
|dir| hand_over(dir, message),
) {
Ok(handed) => {
state.handed.push(handed.path.clone());
handed.answer(&id, &over)
}
Err(error) => json!({"jsonrpc": "2.0", "id": id, "result": {
"content": [{"type": "text", "text": format!(
"{over}; ask for less (flow answer \"last\", keep false) or raise [limits] answer_bytes; \
it could not be handed over as a file: {error}"
)}],
"isError": true,
}}),
};
framing::write_message(&mut state.output, &answer)
}
fn finish(&self, result: io::Result<()>) -> io::Result<()> {
if let Err(error) = result {
let mut failures = vec![error.to_string()];
if let Err(words) = self.subscriptions.close_all() {
failures.push(words);
}
if let Err(words) = self.listens.close_all() {
failures.push(words);
}
return Err(io::Error::other(failures.join("; ")));
}
Ok(())
}
}
impl<O> Drop for Writer<O> {
fn drop(&mut self) {
if let Ok(state) = self.state.get_mut() {
for path in state.handed.drain(..) {
let _removed = std::fs::remove_file(path);
}
if let Some(dir) = &self.answers.dir {
let _removed = std::fs::remove_dir(dir);
}
}
}
}
fn is_answer(message: &Value) -> bool {
message.get("id").is_some() && message.get("method").is_none()
}
const PREVIEW_CHARS: usize = 4096;
struct Handed {
path: std::path::PathBuf,
bytes: usize,
sha256: String,
preview: String,
}
impl Handed {
fn answer(&self, id: &Value, over: &str) -> Value {
json!({"jsonrpc": "2.0", "id": id, "result": {
"content": [{"type": "text", "text": format!(
"{over}; it is handed over whole at {} ({} bytes, sha256 {}): read the file for the answer",
self.path.display(), self.bytes, self.sha256
)}],
"structuredContent": {
"handed_over": {
"path": self.path.display().to_string(),
"bytes": self.bytes,
"sha256": self.sha256,
"media_type": "application/json",
},
"preview": self.preview,
},
"isError": false,
}})
}
}
fn hand_over(dir: &std::path::Path, message: &Value) -> io::Result<Handed> {
use sha2::Digest as _;
use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _};
let body = message
.get("result")
.or_else(|| message.get("error"))
.unwrap_or(message);
let bytes = serde_json::to_vec(body)?;
let sha256 = hex::encode(sha2::Sha256::digest(&bytes));
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)?;
let path = dir.join(format!("{sha256}.json"));
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)?;
file.write_all(&bytes)?;
file.sync_all()?;
let text = body
.pointer("/content/0/text")
.and_then(Value::as_str)
.map_or_else(
|| String::from_utf8_lossy(&bytes).into_owned(),
str::to_owned,
);
Ok(Handed {
path,
bytes: bytes.len(),
sha256,
preview: text.chars().take(PREVIEW_CHARS).collect(),
})
}
fn size(message: &Value) -> io::Result<usize> {
Ok(serde_json::to_vec(message)?.len().saturating_add(1))
}
fn fit(message: &mut Value, limit: usize) -> io::Result<()> {
if size(message)? <= limit {
return Ok(());
}
let channel = message["method"] == "notifications/claude/channel";
let pointer = if channel {
"/params/content"
} else {
"/params/data/words"
};
let Some(words) = message
.pointer(pointer)
.and_then(Value::as_str)
.map(str::to_owned)
else {
return Err(io::Error::other(format!(
"the host answer exceeds frame_bytes {limit}; its fields were not cut"
)));
};
if channel {
message["params"]["meta"]["truncated"] = Value::String("true".to_owned());
}
*message
.pointer_mut(pointer)
.ok_or_else(|| io::Error::other("the notification text field disappeared"))? =
Value::String("…".to_owned());
if size(message)? > limit {
return Err(io::Error::other(format!(
"the notification fields exceed frame_bytes {limit}; its fields were not cut"
)));
}
let boundaries: Vec<_> = words
.char_indices()
.map(|(index, _)| index)
.chain(std::iter::once(words.len()))
.collect();
let mut low = 0;
let mut high = boundaries.len() - 1;
while low < high {
let mid = low + (high - low).div_ceil(2);
*message
.pointer_mut(pointer)
.ok_or_else(|| io::Error::other("the notification text field disappeared"))? =
Value::String(format!("{}…", &words[..boundaries[mid]]));
if size(message)? <= limit {
low = mid;
} else {
high = mid - 1;
}
}
*message
.pointer_mut(pointer)
.ok_or_else(|| io::Error::other("the notification text field disappeared"))? =
Value::String(format!("{}…", &words[..boundaries[low]]));
Ok(())
}
#[cfg(test)]
#[path = "writer_tests.rs"]
mod tests;