1use liminal_server::server::runtime::{Deployment, run_with_deployment};
4use signal_hook::{
5 consts::signal::{SIGINT, SIGTERM},
6 iterator::Signals,
7};
8use std::io::Write as _;
9use std::net::{SocketAddr, TcpListener};
10use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _, PermissionsExt as _};
11use std::path::{Path, PathBuf};
12use std::sync::{
13 Arc,
14 atomic::{AtomicU8, Ordering},
15};
16
17pub(crate) mod assistant;
18mod console;
19
20#[cfg(test)]
21#[path = "up_tests.rs"]
22mod tests;
23
24pub fn run(path: Option<&Path>) -> anyhow::Result<()> {
29 let explicit = path.is_some();
30 let path = match path {
31 Some(path) => path.to_path_buf(),
32 None => default_path()?,
33 };
34 if !explicit && !path.exists() {
35 let directory = path
36 .parent()
37 .ok_or_else(|| anyhow::anyhow!("configuration has no parent"))?;
38 let text = configuration(directory)?;
39 let reserved = [
41 reserve("127.0.0.1:7400".parse()?, "listen_address")?,
42 reserve("127.0.0.1:7401".parse()?, "health_listen_address")?,
43 reserve("127.0.0.1:7480".parse()?, "console.listen")?,
44 ];
45 write_configuration(&path, &text)?;
46 drop(reserved);
47 }
48 let mut table: toml::Table = toml::from_str(&std::fs::read_to_string(&path)?)?;
49 let Some(console) = console_configuration(&table)? else {
50 liminal_server::run(&path)?;
51 return Ok(());
52 };
53 let server = server_configuration(&path, &mut table)?;
54 anyhow::ensure!(
55 console.listen.ip().is_loopback() && console.listen.port() != 0,
56 "console.listen must name a non-zero loopback port"
57 );
58 anyhow::ensure!(
59 server.auth.is_none(),
60 "console: Lys sign-in is not served by liminal up; keep the configured sign-in console server"
61 );
62 if console.assistant {
63 anyhow::ensure!(
64 server.agents_config().is_some() && server.services.profile == "full",
65 "console.assistant requires [agents] and services.profile = 'full'"
66 );
67 }
68 let mut reserved = Vec::new();
69 reserved.push(reserve(server.listen_address, "listen_address")?);
70 reserved.push(reserve(
71 server.health_listen_address,
72 "health_listen_address",
73 )?);
74 for address in &server.extra_listen_addresses {
75 reserved.push(reserve(*address, "extra_listen_addresses")?);
76 }
77 if let Some(websocket) = &server.websocket {
78 reserved.push(reserve(
79 websocket.listen_address,
80 "websocket.listen_address",
81 )?);
82 }
83 if let Some(cluster) = &server.cluster {
84 reserved.push(reserve(cluster.listen_address, "cluster.listen_address")?);
85 }
86 let listener = reserve(console.listen, "console.listen")?;
87 drop(reserved);
88 let shutdown = liminal_server::server::ShutdownHandle::new();
89 let phase = Arc::new(AtomicU8::new(0));
90 let signals = StopSignals::new(shutdown.clone(), Arc::clone(&phase))?;
91 let mut deployment = ConsoleDeployment {
92 listener: Some(listener),
93 console,
94 shutdown,
95 phase: Arc::clone(&phase),
96 path: path.clone(),
97 server: server.listen_address,
98 };
99 run_with_deployment(&path, &server, &mut deployment)?;
100 phase.store(4, Ordering::Release);
101 println!("exchange stopped");
102 drop(signals);
103 Ok(())
104}
105
106pub fn load_configuration(path: &Path) -> anyhow::Result<liminal_server::config::ServerConfig> {
111 let mut table: toml::Table = toml::from_str(&std::fs::read_to_string(path)?)?;
112 console_configuration(&table)?;
113 server_configuration(path, &mut table)
114}
115
116fn server_configuration(
117 path: &Path,
118 table: &mut toml::Table,
119) -> anyhow::Result<liminal_server::config::ServerConfig> {
120 table.remove("console");
121 let server: liminal_server::config::ServerConfig = std::mem::take(table).try_into()?;
122 let mut server = liminal_server::config::apply_env_overrides(server)?;
123 liminal_server::config::validate(&mut server, path.parent())?;
124 Ok(server)
125}
126
127fn console_configuration(
128 table: &toml::Table,
129) -> anyhow::Result<Option<liminal_server::config::types::ConsoleConfig>> {
130 table
131 .get("console")
132 .cloned()
133 .map(toml::Value::try_into)
134 .transpose()
135 .map_err(Into::into)
136}
137
138fn default_path() -> anyhow::Result<PathBuf> {
139 let home = std::env::var_os("HOME")
140 .ok_or_else(|| anyhow::anyhow!("HOME is absent: use liminal up --config <file>"))?;
141 anyhow::ensure!(
142 !home.is_empty(),
143 "HOME is empty: use liminal up --config <file>"
144 );
145 Ok(PathBuf::from(home).join(".liminal/liminal.toml"))
146}
147
148fn write_configuration(path: &Path, text: &str) -> anyhow::Result<()> {
149 let directory = path
150 .parent()
151 .ok_or_else(|| anyhow::anyhow!("configuration has no parent"))?;
152 if !directory.exists() {
153 std::fs::DirBuilder::new().mode(0o700).create(directory)?;
154 }
155 std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))?;
156 let store = directory.join("store");
157 if !store.exists() {
158 std::fs::DirBuilder::new().mode(0o700).create(store)?;
159 }
160 let mut file = std::fs::OpenOptions::new()
161 .write(true)
162 .create_new(true)
163 .mode(0o600)
164 .open(path)?;
165 file.write_all(text.as_bytes())?;
166 file.sync_all()?;
167 Ok(())
168}
169
170fn reserve(address: SocketAddr, key: &str) -> anyhow::Result<TcpListener> {
171 TcpListener::bind(address).map_err(|error| {
172 let holder = std::process::Command::new("lsof")
173 .args(["-nP", &format!("-iTCP:{}", address.port()), "-sTCP:LISTEN"])
174 .output()
175 .map_or_else(
176 |_| String::new(),
177 |output| String::from_utf8_lossy(&output.stdout).trim().to_owned(),
178 );
179 let holder = if holder.is_empty() {
180 String::new()
181 } else {
182 format!("; held by {holder}")
183 };
184 anyhow::anyhow!(
185 "port {} ({address}) refused: {error}{holder}; move it with {key} in the configuration",
186 address.port()
187 )
188 })
189}
190
191fn configuration(directory: &Path) -> anyhow::Result<String> {
192 let store = directory.join("store");
193 let store = toml::Value::String(
194 store
195 .to_str()
196 .ok_or_else(|| anyhow::anyhow!("HOME is not Unicode"))?
197 .to_owned(),
198 );
199 let mut text = format!(
200 r#"# Created by liminal up. Disabled optional sections are named explicitly:
201# auth = absent (open exchange); cluster = absent; websocket = absent
202# templates = absent; open.construction = absent; channel schemas = absent
203listen_address = "127.0.0.1:7400"
204extra_listen_addresses = []
205health_listen_address = "127.0.0.1:7401"
206drain_timeout_ms = 0
207channels = []
208routing_rules = []
209persistence_path = {store}
210
211[open]
212reach = "loopback"
213
214[services]
215profile = "full"
216[services.self_streams]
217cost_rows = 10000
218events_rows = 100000
219
220[limits]
221max_connections = 256
222max_subscriptions_per_connection = 1024
223max_conversations_per_connection = 32
224max_pending_pushes_per_connection = 32
225max_pending_conversation_replies_per_connection = 32
226max_pending_replies_per_conversation = 8
227max_connection_inbox_bytes = 4194304
228max_connection_outbound_bytes = 4194304
229max_subscription_inbox_depth = 4096
230delivery_slice_budget = 32
231max_channels = 512
232reply_timeout_ms = 5000
233
234[agents]
235max_per_owner = 64
236max_per_exchange = 64
237max_field_bytes = 512
238max_event_bytes = 16384
239replay_page_rows = 64
240max_watchers = 256
241watcher_depth = 16
242watcher_bytes = 262144
243max_listened_subjects = 1024
244max_subject_bytes = 4096
245
246[participant]
247wire_frame_limit = 65536
248attach_receipt_ttl_ms = 60000
249receipt_provenance_ttl_ms = 600000
250live_receipt_server_report_threshold = 1024
251max_live_attach_receipts_per_participant = 8
252receipt_provenance_server_report_threshold = 4096
253receipt_provenance_per_conversation_report_threshold = 256
254max_receipt_provenance_per_participant = 64
255max_retired_identity_slots_server = 1024
256identity_slots = 4
257observer_recovery_max_entries = 64
258max_semantic_conversations_per_connection = 32
259max_ordinary_record_entries = 1
260max_ordinary_record_bytes = 131072
261max_generated_marker_entries = 1
262max_generated_marker_bytes = 4096
263mandatory_transaction_bound_entries = 4
264mandatory_transaction_bound_bytes = 16384
265full_recovery_claim_entries = 4
266full_recovery_claim_bytes = 16384
267retained_capacity_entries = 2048
268retained_capacity_bytes = 16777216
269max_retained_record_rows = 1024
270closure_episode_churn_limit = 1024
271[participant.pruning]
272keep_acknowledged_behind_checkpoint = 512
273
274[console]
275listen = "127.0.0.1:7480"
276assistant = true
277"#
278 );
279 text.push_str("\n[console.harness]\n");
280 text.push_str(assistant::HARNESS);
281 Ok(text)
282}
283
284struct ConsoleDeployment {
285 listener: Option<TcpListener>,
286 console: liminal_server::config::types::ConsoleConfig,
287 shutdown: liminal_server::server::ShutdownHandle,
288 phase: Arc<AtomicU8>,
289 path: PathBuf,
290 server: SocketAddr,
291}
292
293impl Deployment for ConsoleDeployment {
294 fn shutdown(&self) -> liminal_server::server::ShutdownHandle {
295 self.shutdown.clone()
296 }
297
298 fn serve(
299 &mut self,
300 supervisor: liminal_server::server::ConnectionSupervisor,
301 services: Option<
302 Arc<liminal_server::server::connection::services::LiminalConnectionServices>,
303 >,
304 shutdown: liminal_server::server::ShutdownHandle,
305 ) -> Result<(), liminal_server::ServerError> {
306 let listener =
307 self.listener
308 .take()
309 .ok_or_else(|| liminal_server::ServerError::ListenerAccept {
310 message: "console listener already started".to_owned(),
311 })?;
312 let console = console::start(listener, supervisor)?;
313 let mut assistant = if self.console.assistant {
314 Some(
315 assistant::Assistant::start(&self.console, self.server, &self.path, services)
316 .map_err(|error| lifecycle_error(&error))?,
317 )
318 } else {
319 None
320 };
321 let url = format!("http://{}/", self.console.listen);
322 self.phase.store(1, Ordering::Release);
323 println!("{url}");
324 open_browser(&url);
325 self.shutdown.wait();
326 self.phase.store(1, Ordering::Release);
327 let assistant_stop = assistant
328 .as_mut()
329 .map_or(Ok(()), assistant::Assistant::stop);
330 if assistant_stop.is_ok() {
331 println!("assistant stopped");
332 } else if let Err(error) = &assistant_stop {
333 eprintln!("assistant stop: {error:#}");
334 }
335 self.phase.store(2, Ordering::Release);
336 let stopped = console.shutdown();
337 if stopped.is_ok() {
338 println!("console stopped");
339 }
340 self.phase.store(3, Ordering::Release);
341 shutdown.initiate();
342 stopped.and_then(|()| assistant_stop.map_err(|error| lifecycle_error(&error)))
343 }
344}
345
346struct StopSignals {
347 handle: signal_hook::iterator::Handle,
348 worker: Option<std::thread::JoinHandle<()>>,
349}
350
351impl StopSignals {
352 fn new(
353 shutdown: liminal_server::server::ShutdownHandle,
354 phase: Arc<AtomicU8>,
355 ) -> anyhow::Result<Self> {
356 let mut signals = Signals::new([SIGINT, SIGTERM])?;
357 let handle = signals.handle();
358 let worker = std::thread::spawn(move || {
359 for _signal in signals.forever() {
360 if !shutdown.initiate() {
361 let waiting = match phase.load(Ordering::Acquire) {
362 0 => "startup",
363 1 => "assistant",
364 2 => "console listener",
365 3 => "exchange drain and store flush",
366 _ => "process exit",
367 };
368 eprintln!("still waiting for {waiting}");
369 }
370 }
371 });
372 Ok(Self {
373 handle,
374 worker: Some(worker),
375 })
376 }
377}
378
379impl Drop for StopSignals {
380 fn drop(&mut self) {
381 self.handle.close();
382 if let Some(worker) = self.worker.take() {
383 if worker.join().is_err() {
384 eprintln!("up signal worker panicked");
385 }
386 }
387 }
388}
389
390fn open_browser(url: &str) {
391 let opener = if cfg!(target_os = "macos") {
392 "open"
393 } else {
394 "xdg-open"
395 };
396 match std::process::Command::new(opener)
397 .arg(url)
398 .stdin(std::process::Stdio::null())
399 .stdout(std::process::Stdio::null())
400 .stderr(std::process::Stdio::null())
401 .spawn()
402 {
403 Ok(mut child) => {
404 let url = url.to_owned();
405 std::thread::spawn(move || match child.wait() {
406 Ok(status) if status.success() => {}
407 Ok(status) => eprintln!("browser opener {opener} exited {status}; open {url}"),
408 Err(error) => eprintln!("browser opener {opener}: {error}"),
409 });
410 }
411 Err(error) => eprintln!("browser opener {opener} unavailable: {error}; open {url}"),
412 }
413}
414
415fn lifecycle_error(error: &anyhow::Error) -> liminal_server::ServerError {
416 liminal_server::ServerError::ListenerAccept {
417 message: error.to_string(),
418 }
419}
420
421pub fn run_tools(path: &Path) -> anyhow::Result<bool> {
426 let mut table: toml::Table = toml::from_str(&std::fs::read_to_string(path)?)?;
427 if console_configuration(&table)?.is_none() {
428 return Ok(false);
429 }
430 let server = server_configuration(path, &mut table)?;
431 let ticket = assistant::ticket(server.listen_address);
432 let live = crate::Live::new(&ticket).map_err(anyhow::Error::msg)?;
433 let mut tools = crate::Server::configured(live, &ticket).map_err(anyhow::Error::msg)?;
434 tools.serve(&mut std::io::stdin().lock(), &mut std::io::stdout())?;
435 Ok(true)
436}