use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use frame_authority::{AuthorityConfig, DocumentId};
use frame_core::component::ComponentId;
use frame_doc_service::{DocumentBinding, DocumentService, FeedSink, SinkError};
use frame_editor_wire::snapshot::PresentationInputs;
use frame_state::{ComponentSchema, ComponentStateStore, ComponentStoreHandle};
use liminal_sdk::remote::{PushClient, SubscriptionStream};
use crate::config::{DocumentSection, FrameConfig};
use crate::embedded::EmbeddedLiminal;
use crate::error::HostError;
use crate::syntax_theme::default_syntax_theme;
const PUMP_QUANTUM: Duration = Duration::from_millis(500);
pub struct DocBinding {
service: Arc<DocumentService>,
pump: JoinHandle<()>,
stop: Arc<AtomicBool>,
}
struct LiminalFeedSink {
client: std::sync::Mutex<PushClient>,
channel: String,
}
impl FeedSink for LiminalFeedSink {
fn publish(&self, frame: &str) -> Result<(), SinkError> {
self.client
.lock()
.map_err(|_| SinkError::new("feed publisher mutex poisoned"))?
.publish(&self.channel, frame.as_bytes().to_vec())
.map_err(|error| SinkError::new(format!("feed publish failed: {error}")))
}
}
impl DocBinding {
pub fn boot(
liminal: &EmbeddedLiminal,
config: &FrameConfig,
) -> Result<Option<Self>, HostError> {
let Some(section) = &config.document else {
return Ok(None);
};
let initial_content = std::fs::read_to_string(§ion.content_path).map_err(|source| {
HostError::DocumentContent {
path: section.content_path.clone(),
source,
}
})?;
let handle = open_store(section)?;
let authority_config = AuthorityConfig::declare(
Duration::from_millis(section.lease_expiry_ms),
section.journal_length_bound,
Duration::from_millis(section.quiesce_window_ms),
)
.map_err(|source| HostError::DocumentAuthorityConfig { source })?;
let document =
DocumentId::new(section.id.clone()).map_err(|error| HostError::ConfigContract {
detail: format!("[document].id rejected by the model: {error}"),
})?;
let binding = DocumentBinding {
document,
language: section.language.clone(),
initial_content,
component_id: section.component_id.clone(),
presentation: PresentationInputs {
dark_theme: section.dark_theme,
syntax_theme: Some(
section
.syntax_theme
.clone()
.unwrap_or_else(default_syntax_theme),
),
},
};
let address = liminal.tcp_addr().to_string();
let auth_token = config
.bus
.auth
.as_ref()
.map(|auth| auth.token.clone().into_bytes());
let sink = LiminalFeedSink {
client: std::sync::Mutex::new(connect_publisher(&address, auth_token.as_deref())?),
channel: section.feed_channel.clone(),
};
let service = Arc::new(
DocumentService::start(handle, authority_config, binding, Arc::new(sink))
.map_err(|source| HostError::DocumentService { source })?,
);
let stream = SubscriptionStream::open(&address, §ion.authoring_channel, Vec::new())
.map_err(|error| HostError::DocumentBusClient {
component: "authoring subscriber",
detail: error.to_string(),
})?;
let stop = Arc::new(AtomicBool::new(false));
let pump = spawn_authoring_pump(stream, Arc::clone(&service), Arc::clone(&stop))?;
tracing::info!(
document = %section.id,
feed_channel = %section.feed_channel,
authoring_channel = %section.authoring_channel,
"document service live behind the embedded bus"
);
Ok(Some(Self {
service,
pump,
stop,
}))
}
pub fn shutdown(self) -> Result<(), HostError> {
self.stop.store(true, Ordering::SeqCst);
self.pump.join().map_err(|_| HostError::AuthoringPump {
detail: "the authoring pump thread panicked".to_owned(),
})?;
let service = Arc::try_unwrap(self.service).map_err(|_| HostError::AuthoringPump {
detail: "the authoring pump still holds the service after join".to_owned(),
})?;
service
.shutdown()
.map_err(|source| HostError::DocumentService { source })
}
}
fn connect_publisher(address: &str, auth_token: Option<&[u8]>) -> Result<PushClient, HostError> {
let connected = match auth_token {
Some(token) => PushClient::connect_with_auth(address, token),
None => PushClient::connect(address),
};
connected.map_err(|error| HostError::DocumentBusClient {
component: "feed publisher",
detail: error.to_string(),
})
}
fn open_store(section: &DocumentSection) -> Result<ComponentStoreHandle, HostError> {
let store = ComponentStateStore::open(§ion.state_dir)
.map_err(|source| HostError::DocumentState { source })?;
let component = ComponentId::derive("frame-doc-service", §ion.id);
if let Ok(handle) = store.component_handle(component) {
return Ok(handle);
}
store
.declare_schema(component, ComponentSchema::lww())
.map_err(|source| HostError::DocumentState { source })?;
store
.install_storage(component)
.map_err(|source| HostError::DocumentState { source })
}
fn spawn_authoring_pump(
stream: SubscriptionStream,
service: Arc<DocumentService>,
stop: Arc<AtomicBool>,
) -> Result<JoinHandle<()>, HostError> {
std::thread::Builder::new()
.name("frame-doc-service-authoring-pump".to_owned())
.spawn(move || {
loop {
if stop.load(Ordering::SeqCst) {
tracing::info!("authoring pump exiting on shutdown");
return;
}
let waited = Instant::now();
match stream.recv_timeout(PUMP_QUANTUM) {
Ok(delivered) => match String::from_utf8(delivered.payload().to_vec()) {
Ok(bytes) => match service.receive_command(&bytes) {
Ok(outcome) => {
tracing::info!(?outcome, "authoring command admitted");
}
Err(refusal) => {
tracing::warn!(%refusal, "authoring command REFUSED (loud, nothing applied)");
}
},
Err(error) => {
tracing::warn!(%error, "authoring frame is not UTF-8; refused loudly");
}
},
Err(error) => {
if waited.elapsed() < PUMP_QUANTUM / 2 && !stop.load(Ordering::SeqCst) {
tracing::error!(
%error,
"authoring subscription DIED; the pump exits loudly — commands no longer reach the service"
);
return;
}
}
}
}
})
.map_err(|source| HostError::AuthoringPump {
detail: format!("failed to start the authoring pump: {source}"),
})
}