use std::{ collections::HashSet, path::Path, sync::{Arc, Mutex, MutexGuard}, time::Instant, }; use crossbeam_channel::Sender; use crate::{ change_processor::ChangeProcessor, common_setup, message_queue::MessageQueue, project::Project, session_id::SessionId, snapshot::{AppliedPatchSet, PatchSet, RojoTree}, vfs::{Vfs, VfsFetcher}, }; /// Contains all of the state for a Rojo serve session. /// /// Nothing here is specific to any Rojo interface. Though the primary way to /// interact with a serve session is Rojo's HTTP right now, there's no reason /// why Rojo couldn't expose an IPC or channels-based API for embedding in the /// future. `ServeSession` would be roughly the right interface to expose for /// those cases. pub struct ServeSession { /// The object responsible for listening to changes from the in-memory /// filesystem, applying them, updating the Roblox instance tree, and /// routing messages through the session's message queue to any connected /// clients. /// /// SHOULD BE DROPPED FIRST! ServeSession and ChangeProcessor communicate /// with eachother via channels. If ServeSession hangs up those channels /// before dropping the ChangeProcessor, its thread will panic with a /// RecvError, causing the main thread to panic on drop. /// /// Allowed to be unused because it has side effects when dropped. #[allow(unused)] change_processor: ChangeProcessor, /// When the serve session was started. Used only for user-facing /// diagnostics. start_time: Instant, /// The root project for the serve session, if there was one defined. /// /// This will be defined if a folder with a `default.project.json` file was /// used for starting the serve session, or if the user specified a full /// path to a `.project.json` file. /// /// If `root_project` is None, values from the project should be treated as /// their defaults. root_project: Option, /// A randomly generated ID for this serve session. It's used to ensure that /// a client doesn't begin connecting to a different server part way through /// an operation that needs to be atomic. session_id: SessionId, /// The tree of Roblox instances associated with this session that will be /// updated in real-time. This is derived from the session's VFS and will /// eventually be mutable to connected clients. tree: Arc>, /// An in-memory filesystem containing all of the files relevant for this /// live session. /// /// The main use for accessing it from the session is for debugging issues /// with Rojo's live-sync protocol. vfs: Arc>, /// A queue of changes that have been applied to `tree` that affect clients. /// /// Clients to the serve session will subscribe to this queue either /// directly or through the HTTP API to be notified of mutations that need /// to be applied. message_queue: Arc>, /// A channel to send mutation requests on. These will be handled by the /// ChangeProcessor and trigger changes in the tree. tree_mutation_sender: Sender, } /// Methods that need thread-safety bounds on VfsFetcher are limited to this /// block to prevent needing to spread Send + Sync + 'static into everything /// that handles ServeSession. impl ServeSession { /// Start a new serve session from the given in-memory filesystem and start /// path. /// /// The project file is expected to be loaded out-of-band since it's /// currently loaded from the filesystem directly instead of through the /// in-memory filesystem layer. pub fn new>(vfs: Vfs, start_path: P) -> Self { let start_path = start_path.as_ref(); log::trace!("Starting new ServeSession at path {}", start_path.display(),); let start_time = Instant::now(); let (root_project, tree) = common_setup::start(start_path, &vfs); let session_id = SessionId::new(); let message_queue = MessageQueue::new(); let tree = Arc::new(Mutex::new(tree)); let message_queue = Arc::new(message_queue); let vfs = Arc::new(vfs); let (tree_mutation_sender, tree_mutation_receiver) = crossbeam_channel::unbounded(); log::trace!("Starting ChangeProcessor"); let change_processor = ChangeProcessor::start( Arc::clone(&tree), Arc::clone(&vfs), Arc::clone(&message_queue), tree_mutation_receiver, ); Self { change_processor, start_time, session_id, root_project, tree, message_queue, tree_mutation_sender, vfs, } } } impl ServeSession { pub fn tree_handle(&self) -> Arc> { Arc::clone(&self.tree) } pub fn tree(&self) -> MutexGuard<'_, RojoTree> { self.tree.lock().unwrap() } pub fn tree_mutation_sender(&self) -> Sender { self.tree_mutation_sender.clone() } pub fn vfs(&self) -> &Vfs { &self.vfs } pub fn message_queue(&self) -> &MessageQueue { &self.message_queue } pub fn session_id(&self) -> SessionId { self.session_id } pub fn project_name(&self) -> Option<&str> { self.root_project .as_ref() .map(|project| project.name.as_str()) } pub fn project_port(&self) -> Option { self.root_project .as_ref() .and_then(|project| project.serve_port) } pub fn start_time(&self) -> Instant { self.start_time } pub fn serve_place_ids(&self) -> Option<&HashSet> { self.root_project .as_ref() .and_then(|project| project.serve_place_ids.as_ref()) } } /// This module is named to trick Insta into naming the resulting snapshots /// correctly. /// /// See https://github.com/mitsuhiko/insta/issues/78 #[cfg(test)] mod serve_session { use super::*; use std::{path::PathBuf, time::Duration}; use insta::assert_yaml_snapshot; use maplit::hashmap; use rojo_insta_ext::RedactionMap; use tokio::{runtime::Runtime, timer::Timeout}; use crate::{ tree_view::view_tree, vfs::{NoopFetcher, TestFetcher, VfsDebug, VfsEvent, VfsSnapshot}, }; #[test] fn just_folder() { let vfs = Vfs::new(NoopFetcher); vfs.debug_load_snapshot("/foo", VfsSnapshot::empty_dir()); let session = ServeSession::new(vfs, "/foo"); let mut rm = RedactionMap::new(); assert_yaml_snapshot!(view_tree(&session.tree(), &mut rm)); } #[test] fn project_with_folder() { let vfs = Vfs::new(NoopFetcher); vfs.debug_load_snapshot( "/foo", VfsSnapshot::dir(hashmap! { "default.project.json" => VfsSnapshot::file(r#" { "name": "HelloWorld", "tree": { "$path": "src" } } "#), "src" => VfsSnapshot::dir(hashmap! { "hello.txt" => VfsSnapshot::file("Hello, world!"), }), }), ); let session = ServeSession::new(vfs, "/foo"); let mut rm = RedactionMap::new(); assert_yaml_snapshot!(view_tree(&session.tree(), &mut rm)); } #[test] fn script_with_meta() { let vfs = Vfs::new(NoopFetcher); vfs.debug_load_snapshot( "/root", VfsSnapshot::dir(hashmap! { "test.lua" => VfsSnapshot::file("This is a test."), "test.meta.json" => VfsSnapshot::file(r#"{ "ignoreUnknownInstances": true }"#), }), ); let session = ServeSession::new(vfs, "/root"); let mut rm = RedactionMap::new(); assert_yaml_snapshot!(view_tree(&session.tree(), &mut rm)); } #[test] fn change_script_meta() { let (state, fetcher) = TestFetcher::new(); state.load_snapshot( "/root", VfsSnapshot::dir(hashmap! { "test.lua" => VfsSnapshot::file("This is a test."), "test.meta.json" => VfsSnapshot::file(r#"{ "ignoreUnknownInstances": true }"#), }), ); let vfs = Vfs::new(fetcher); let session = ServeSession::new(vfs, "/root"); let mut redactions = RedactionMap::new(); assert_yaml_snapshot!( "change_script_meta_before", view_tree(&session.tree(), &mut redactions) ); state.load_snapshot( "/root/test.meta.json", VfsSnapshot::file(r#"{ "ignoreUnknownInstances": false }"#), ); let receiver = Timeout::new( session.message_queue().subscribe_any(), Duration::from_millis(200), ); state.raise_event(VfsEvent::Modified(PathBuf::from("/root/test.meta.json"))); let mut rt = Runtime::new().unwrap(); let changes = rt.block_on(receiver).unwrap(); assert_yaml_snapshot!( "change_script_meta_patch", redactions.redacted_yaml(changes) ); assert_yaml_snapshot!( "change_script_meta_after", view_tree(&session.tree(), &mut redactions) ); } #[test] fn change_txt_file() { let (state, fetcher) = TestFetcher::new(); state.load_snapshot("/foo.txt", VfsSnapshot::file("Hello!")); let vfs = Vfs::new(fetcher); let session = ServeSession::new(vfs, "/foo.txt"); let mut redactions = RedactionMap::new(); assert_yaml_snapshot!( "change_txt_file_before", view_tree(&session.tree(), &mut redactions) ); state.load_snapshot("/foo.txt", VfsSnapshot::file("World!")); let receiver = session.message_queue().subscribe_any(); state.raise_event(VfsEvent::Modified(PathBuf::from("/foo.txt"))); let receiver = Timeout::new(receiver, Duration::from_millis(200)); let mut rt = Runtime::new().unwrap(); let result = rt.block_on(receiver).unwrap(); assert_yaml_snapshot!("change_txt_file_patch", redactions.redacted_yaml(result)); assert_yaml_snapshot!( "change_txt_file_after", view_tree(&session.tree(), &mut redactions) ); } #[test] fn change_file_in_project() { let (state, fetcher) = TestFetcher::new(); state.load_snapshot( "/foo", VfsSnapshot::dir(hashmap! { "default.project.json" => VfsSnapshot::file(r#" { "name": "change_file_in_project", "tree": { "$className": "Folder", "Child": { "$path": "file.txt" } } } "#), "file.txt" => VfsSnapshot::file("initial content"), }), ); let vfs = Vfs::new(fetcher); let session = ServeSession::new(vfs, "/foo"); let mut redactions = RedactionMap::new(); assert_yaml_snapshot!( "change_file_in_project_before", view_tree(&session.tree(), &mut redactions) ); state.load_snapshot("/foo/file.txt", VfsSnapshot::file("Changed!")); let receiver = session.message_queue().subscribe_any(); state.raise_event(VfsEvent::Modified(PathBuf::from("/foo/file.txt"))); let receiver = Timeout::new(receiver, Duration::from_millis(200)); let mut rt = Runtime::new().unwrap(); let result = rt.block_on(receiver).unwrap(); assert_yaml_snapshot!( "change_file_in_project_patch", redactions.redacted_yaml(result) ); assert_yaml_snapshot!( "change_file_in_project_after", view_tree(&session.tree(), &mut redactions) ); } }