pub mod actions;
use crate::llm::action_helper::call_llm;
use crate::llm::ollama_client::OllamaClient;
use crate::logging::emit::Log;
use crate::protocol::Event;
use crate::server::connection::ConnectionId;
use crate::state::app_state::AppState;
use actions::GOPHER_REQUEST_EVENT;
use anyhow::Result;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::{mpsc, Mutex};
pub const MAX_REQUEST_BYTES: usize = 8192;
const SELECTOR_READ_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
const CONNECTION_CAP_REFUSAL: &[u8] =
b"3Too many connections, try again later\t\terror.host\t1\r\n.\r\n";
pub struct GopherServer;
impl GopherServer {
pub async fn spawn_with_llm_actions(
listen_addr: SocketAddr,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
server_id: crate::state::ServerId,
) -> Result<SocketAddr> {
let listener = TcpListener::bind(listen_addr).await?;
let local_addr = listener.local_addr()?;
Log::new(Some(&status_tx)).info(format!("Gopher server listening on {}", local_addr));
let protocol = Arc::new(actions::GopherProtocol::new());
let task_registrar = app_state.clone();
let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
let accept_handle = tokio::spawn(async move {
loop {
match crate::server::accept_bounded::accept_bounded(
&listener,
&limiter,
CONNECTION_CAP_REFUSAL,
"Gopher",
Some(&status_tx),
)
.await
{
Ok((socket, peer_addr, permit)) => {
let connection_id =
ConnectionId::new(app_state.get_next_unified_id().await);
use crate::state::server::{
ConnectionState as ServerConnectionState, ConnectionStatus,
ProtocolConnectionInfo,
};
let now = crate::utils::clock::Instant::now();
let conn_state = ServerConnectionState {
id: connection_id,
remote_addr: peer_addr,
local_addr,
bytes_sent: 0,
bytes_received: 0,
packets_sent: 0,
packets_received: 0,
last_activity: now,
status: ConnectionStatus::Active,
status_changed_at: now,
protocol_info: ProtocolConnectionInfo::empty(),
};
app_state
.add_connection_to_server(server_id, conn_state)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
Log::new(Some(&status_tx))
.info(format!("Gopher client connected from {}", peer_addr));
let llm_clone = llm_client.clone();
let state_clone = app_state.clone();
let status_clone = status_tx.clone();
let protocol_clone = protocol.clone();
let conn_handle = tokio::spawn(async move {
let _permit = permit;
handle_gopher_connection(
socket,
peer_addr,
llm_clone,
state_clone,
status_clone,
server_id,
protocol_clone,
connection_id,
)
.await
});
app_state.register_server_task(server_id, conn_handle).await;
}
Err(e) => {
Log::new(Some(&status_tx)).error(format!("Gopher accept error: {}", e));
break;
}
}
}
});
task_registrar
.register_server_task(server_id, accept_handle)
.await;
Ok(local_addr)
}
}
async fn write_counted<W>(
write_half: &Arc<Mutex<W>>,
data: &[u8],
app_state: &AppState,
server_id: crate::state::ServerId,
connection_id: ConnectionId,
) -> std::io::Result<()>
where
W: tokio::io::AsyncWrite + Unpin,
{
{
let mut writer = write_half.lock().await;
writer.write_all(data).await?;
writer.flush().await?;
}
app_state
.update_connection_stats(
server_id,
connection_id,
None,
Some(data.len() as u64),
None,
Some(1),
)
.await;
Ok(())
}
fn wire_failure_item(text: &'static str) -> Vec<u8> {
format!("3{}\t\terror.host\t1\r\n.\r\n", text).into_bytes()
}
#[allow(clippy::too_many_arguments)]
async fn handle_gopher_connection(
socket: tokio::net::TcpStream,
peer_addr: SocketAddr,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
server_id: crate::state::ServerId,
protocol: Arc<actions::GopherProtocol>,
connection_id: ConnectionId,
) {
let (reader, write_half) = tokio::io::split(socket);
let write_half = Arc::new(Mutex::new(write_half));
let peer_rx = crate::server::peer_support::register_peer_channel(
&app_state,
server_id,
connection_id.as_u32(),
)
.await;
crate::server::peer_support::spawn_peer_command_task(
peer_rx,
protocol.clone(),
app_state.clone(),
server_id,
connection_id.as_u32(),
write_half.clone(),
status_tx.clone(),
);
run_gopher_session(
reader,
&write_half,
peer_addr,
&llm_client,
&app_state,
&status_tx,
server_id,
&protocol,
connection_id,
)
.await;
app_state
.remove_peer_handle(server_id, connection_id.as_u32())
.await;
let _ = write_half.lock().await.shutdown().await;
use crate::state::server::ConnectionStatus;
app_state
.update_connection_status(server_id, connection_id, ConnectionStatus::Closed)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
}
#[allow(clippy::too_many_arguments)]
async fn run_gopher_session<R, W>(
mut reader: R,
write_half: &Arc<Mutex<W>>,
peer_addr: SocketAddr,
llm_client: &OllamaClient,
app_state: &Arc<AppState>,
status_tx: &mpsc::UnboundedSender<String>,
server_id: crate::state::ServerId,
protocol: &Arc<actions::GopherProtocol>,
connection_id: ConnectionId,
) where
R: tokio::io::AsyncRead + Unpin,
W: tokio::io::AsyncWrite + Unpin,
{
let log = Log::new(Some(status_tx));
let mut request = Vec::new();
let mut chunk = vec![0u8; 1024];
let line_end = loop {
let read = match tokio::time::timeout(SELECTOR_READ_TIMEOUT, reader.read(&mut chunk)).await
{
Err(_) => {
log.info(format!(
"Gopher client {} sent no selector within {}s; closing",
peer_addr,
SELECTOR_READ_TIMEOUT.as_secs()
));
return;
}
Ok(read) => read,
};
match read {
Ok(0) => {
log.info(format!(
"Gopher client {} disconnected before sending a selector",
peer_addr
));
return;
}
Ok(n) => {
request.extend_from_slice(&chunk[..n]);
app_state
.update_connection_stats(
server_id,
connection_id,
Some(n as u64),
None,
Some(1),
None,
)
.await;
if let Some(pos) = request.iter().position(|b| *b == b'\n') {
break pos;
}
if request.len() > MAX_REQUEST_BYTES {
log.warn(format!(
"Gopher request from {} exceeded {} bytes with no line ending; closing \
decision=refused_body_too_large",
peer_addr, MAX_REQUEST_BYTES
));
let _ = write_counted(
write_half,
&wire_failure_item("netget: selector line too long"),
app_state,
server_id,
connection_id,
)
.await;
return;
}
}
Err(e) => {
log.error(format!("Gopher read error from {}: {}", peer_addr, e));
return;
}
}
};
let line = String::from_utf8_lossy(&request[..line_end])
.trim_end_matches('\r')
.to_string();
let (selector, search_query) = match line.split_once('\t') {
Some((sel, query)) => (sel.to_string(), Some(query.to_string())),
None => (line.clone(), None),
};
log.debug(format!(
"Gopher received {} bytes from {}",
request.len(),
peer_addr
));
log.trace(format!("Gopher request line: {:?}", line));
let mut event_data = serde_json::json!({ "selector": selector });
if let Some(query) = &search_query {
event_data["search_query"] = serde_json::Value::String(query.clone());
}
let event = Event::new(&GOPHER_REQUEST_EVENT, event_data);
match call_llm(
llm_client,
app_state,
server_id,
Some(connection_id),
&event,
protocol.as_ref(),
)
.await
{
Ok(execution_result) => {
for message in &execution_result.messages {
log.info(message);
}
let mut wrote_output = false;
let mut asked_to_close = false;
for protocol_result in execution_result.protocol_results {
match protocol_result {
crate::llm::actions::protocol_trait::ActionResult::Output(output_data) => {
if let Err(e) = write_counted(
write_half,
&output_data,
app_state,
server_id,
connection_id,
)
.await
{
log.error(format!("Gopher write error: {}", e));
return;
}
wrote_output = true;
log.debug(format!(
"Gopher sent {} bytes to {}",
output_data.len(),
peer_addr
));
log.trace(format!(
"Gopher response: {}",
String::from_utf8_lossy(&output_data)
));
log.info(format!(
"Gopher response to {} ({} bytes) decision=model_answer",
peer_addr,
output_data.len()
));
}
crate::llm::actions::protocol_trait::ActionResult::CloseConnection => {
asked_to_close = true;
log.debug("Gopher close_connection requested");
}
_ => {}
}
}
if asked_to_close && !wrote_output {
log.info(format!(
"Gopher closing on {} without a reply decision=model_reject",
peer_addr
));
}
if !wrote_output && !asked_to_close {
let failures = execution_result.failures.len();
let msg = format!(
"Gopher produced no response for {} ({} failed action(s)); \
answering with a type-3 error item decision={}",
peer_addr,
failures,
if failures == 0 {
"model_silent"
} else {
"fail_closed_bad_action"
}
);
if failures == 0 {
log.warn(msg);
} else {
log.error(msg);
}
let _ = write_counted(
write_half,
&wire_failure_item(crate::utils::WireFailure::Unavailable.prefixed_text()),
app_state,
server_id,
connection_id,
)
.await;
}
}
Err(e) => {
let token = match crate::utils::WireFailure::classify(&e) {
crate::utils::WireFailure::Overloaded => "fail_closed_llm_overloaded",
crate::utils::WireFailure::Unavailable => "fail_closed_llm_error",
};
log.error(format!(
"Gopher LLM call failed for {} decision={}: {}",
peer_addr, token, e
));
let _ = write_counted(
write_half,
&wire_failure_item(crate::utils::prefixed_wire_failure_text(&e)),
app_state,
server_id,
connection_id,
)
.await;
}
}
log.debug(format!(
"Gopher closing connection to {} after one reply (RFC 1436)",
peer_addr
));
}