Merge remote-tracking branch 'origin' into msgpack-api

This commit is contained in:
Micah 2026-02-07 13:32:53 -08:00
commit 5c6836849a
37 changed files with 456 additions and 148 deletions

3
.gitignore vendored
View file

@ -23,3 +23,6 @@
# Macos file system junk # Macos file system junk
._* ._*
.DS_STORE .DS_STORE
# JetBrains IDEs
/.idea/

View file

@ -32,8 +32,18 @@ Making a new release? Simply add the new header with the version and date undern
## Unreleased ## Unreleased
* `inf` and `nan` values in properties are now synced ([#1176]) * `inf` and `nan` values in properties are now synced ([#1176])
* Fixed a bug caused by having reference properties (such as `ObjectValue.Value`) that point to an Instance not included in syncback. ([#1179])
* Fixed instance replacement fallback failing when too many instances needed to be replaced. ([#1192])
* Fixed a bug where MacOS paths weren't being handled correctly. ([#1201])
* Fixed a bug where the notification timeout thread would fail to cancel on unmount ([#1211])
* Added a "Forget" option to the sync reminder notification to avoid being reminded for that place in the future ([#1215])
[#1176]: https://github.com/rojo-rbx/rojo/pull/1176 [#1176]: https://github.com/rojo-rbx/rojo/pull/1176
[#1179]: https://github.com/rojo-rbx/rojo/pull/1179
[#1192]: https://github.com/rojo-rbx/rojo/pull/1192
[#1201]: https://github.com/rojo-rbx/rojo/pull/1201
[#1211]: https://github.com/rojo-rbx/rojo/pull/1211
[#1215]: https://github.com/rojo-rbx/rojo/pull/1215
## [7.7.0-rc.1] (November 27th, 2025) ## [7.7.0-rc.1] (November 27th, 2025)

1
Cargo.lock generated
View file

@ -1319,6 +1319,7 @@ dependencies = [
"fs-err", "fs-err",
"notify", "notify",
"serde", "serde",
"tempfile",
] ]
[[package]] [[package]]

View file

@ -1,6 +1,7 @@
# memofs Changelog # memofs Changelog
## Unreleased Changes ## Unreleased Changes
* Added `Vfs::canonicalize`. [#1201]
## 0.3.1 (2025-11-27) ## 0.3.1 (2025-11-27)
* Added `Vfs::exists`. [#1169] * Added `Vfs::exists`. [#1169]

View file

@ -19,3 +19,6 @@ crossbeam-channel = "0.5.12"
fs-err = "2.11.0" fs-err = "2.11.0"
notify = "4.0.17" notify = "4.0.17"
serde = { version = "1.0.197", features = ["derive"] } serde = { version = "1.0.197", features = ["derive"] }
[dev-dependencies]
tempfile = "3.10.1"

View file

@ -232,6 +232,33 @@ impl VfsBackend for InMemoryFs {
} }
} }
// TODO: We rely on Rojo to prepend cwd to any relative path before storing paths
// in MemoFS. The current implementation will error if no prepended absolute path
// is found. It really only normalizes paths within the provided path's context.
// Example: "/Users/username/project/../other/file.txt" ->
// "/Users/username/other/file.txt"
// Erroneous example: "/Users/../../other/file.txt" -> "/other/file.txt"
// This is not very robust. We should implement proper path normalization here or otherwise
// warn if we are missing context and can not fully canonicalize the path correctly.
fn canonicalize(&mut self, path: &Path) -> io::Result<PathBuf> {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
normalized.pop();
}
std::path::Component::CurDir => {}
_ => normalized.push(component),
}
}
let inner = self.inner.lock().unwrap();
match inner.entries.get(&normalized) {
Some(_) => Ok(normalized),
None => not_found(&normalized),
}
}
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> { fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
let inner = self.inner.lock().unwrap(); let inner = self.inner.lock().unwrap();

View file

@ -77,6 +77,7 @@ pub trait VfsBackend: sealed::Sealed + Send + 'static {
fn metadata(&mut self, path: &Path) -> io::Result<Metadata>; fn metadata(&mut self, path: &Path) -> io::Result<Metadata>;
fn remove_file(&mut self, path: &Path) -> io::Result<()>; fn remove_file(&mut self, path: &Path) -> io::Result<()>;
fn remove_dir_all(&mut self, path: &Path) -> io::Result<()>; fn remove_dir_all(&mut self, path: &Path) -> io::Result<()>;
fn canonicalize(&mut self, path: &Path) -> io::Result<PathBuf>;
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent>; fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent>;
fn watch(&mut self, path: &Path) -> io::Result<()>; fn watch(&mut self, path: &Path) -> io::Result<()>;
@ -225,6 +226,11 @@ impl VfsInner {
self.backend.metadata(path) self.backend.metadata(path)
} }
fn canonicalize<P: AsRef<Path>>(&mut self, path: P) -> io::Result<PathBuf> {
let path = path.as_ref();
self.backend.canonicalize(path)
}
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> { fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
self.backend.event_receiver() self.backend.event_receiver()
} }
@ -413,6 +419,19 @@ impl Vfs {
self.inner.lock().unwrap().metadata(path) self.inner.lock().unwrap().metadata(path)
} }
/// Normalize a path via the underlying backend.
///
/// Roughly equivalent to [`std::fs::canonicalize`][std::fs::canonicalize]. Relative paths are
/// resolved against the backend's current working directory (if applicable) and errors are
/// surfaced directly from the backend.
///
/// [std::fs::canonicalize]: https://doc.rust-lang.org/stable/std/fs/fn.canonicalize.html
#[inline]
pub fn canonicalize<P: AsRef<Path>>(&self, path: P) -> io::Result<PathBuf> {
let path = path.as_ref();
self.inner.lock().unwrap().canonicalize(path)
}
/// Retrieve a handle to the event receiver for this `Vfs`. /// Retrieve a handle to the event receiver for this `Vfs`.
#[inline] #[inline]
pub fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> { pub fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
@ -540,6 +559,13 @@ impl VfsLock<'_> {
self.inner.metadata(path) self.inner.metadata(path)
} }
/// Normalize a path via the underlying backend.
#[inline]
pub fn normalize<P: AsRef<Path>>(&mut self, path: P) -> io::Result<PathBuf> {
let path = path.as_ref();
self.inner.canonicalize(path)
}
/// Retrieve a handle to the event receiver for this `Vfs`. /// Retrieve a handle to the event receiver for this `Vfs`.
#[inline] #[inline]
pub fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> { pub fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
@ -555,7 +581,9 @@ impl VfsLock<'_> {
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use crate::{InMemoryFs, Vfs, VfsSnapshot}; use crate::{InMemoryFs, StdBackend, Vfs, VfsSnapshot};
use std::io;
use std::path::PathBuf;
/// https://github.com/rojo-rbx/rojo/issues/899 /// https://github.com/rojo-rbx/rojo/issues/899
#[test] #[test]
@ -571,4 +599,62 @@ mod test {
"bar\nfoo\n\n" "bar\nfoo\n\n"
); );
} }
/// https://github.com/rojo-rbx/rojo/issues/1200
#[test]
fn canonicalize_in_memory_success() {
let mut imfs = InMemoryFs::new();
let contents = "Lorem ipsum dolor sit amet.".to_string();
imfs.load_snapshot("/test/file.txt", VfsSnapshot::file(contents.to_string()))
.unwrap();
let vfs = Vfs::new(imfs);
assert_eq!(
vfs.canonicalize("/test/nested/../file.txt").unwrap(),
PathBuf::from("/test/file.txt")
);
assert_eq!(
vfs.read_to_string(vfs.canonicalize("/test/nested/../file.txt").unwrap())
.unwrap()
.to_string(),
contents.to_string()
);
}
#[test]
fn canonicalize_in_memory_missing_errors() {
let imfs = InMemoryFs::new();
let vfs = Vfs::new(imfs);
let err = vfs.canonicalize("test").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
}
#[test]
fn canonicalize_std_backend_success() {
let contents = "Lorem ipsum dolor sit amet.".to_string();
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("file.txt");
fs_err::write(&file_path, contents.to_string()).unwrap();
let vfs = Vfs::new(StdBackend::new());
let canonicalized = vfs.canonicalize(&file_path).unwrap();
assert_eq!(canonicalized, file_path.canonicalize().unwrap());
assert_eq!(
vfs.read_to_string(&canonicalized).unwrap().to_string(),
contents.to_string()
);
}
#[test]
fn canonicalize_std_backend_missing_errors() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test");
let vfs = Vfs::new(StdBackend::new());
let err = vfs.canonicalize(&file_path).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
}
} }

View file

@ -1,5 +1,5 @@
use std::io; use std::io;
use std::path::Path; use std::path::{Path, PathBuf};
use crate::{Metadata, ReadDir, VfsBackend, VfsEvent}; use crate::{Metadata, ReadDir, VfsBackend, VfsEvent};
@ -50,6 +50,10 @@ impl VfsBackend for NoopBackend {
Err(io::Error::other("NoopBackend doesn't do anything")) Err(io::Error::other("NoopBackend doesn't do anything"))
} }
fn canonicalize(&mut self, _path: &Path) -> io::Result<PathBuf> {
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> { fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
crossbeam_channel::never() crossbeam_channel::never()
} }

View file

@ -106,6 +106,10 @@ impl VfsBackend for StdBackend {
}) })
} }
fn canonicalize(&mut self, path: &Path) -> io::Result<PathBuf> {
fs_err::canonicalize(path)
}
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> { fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
self.watcher_receiver.clone() self.watcher_receiver.clone()
} }

View file

@ -293,30 +293,38 @@ function ApiContext:open(id)
end end
function ApiContext:serialize(ids: { string }) function ApiContext:serialize(ids: { string })
local url = ("%s/api/serialize/%s"):format(self.__baseUrl, table.concat(ids, ",")) local url = ("%s/api/serialize"):format(self.__baseUrl)
local request_body = Http.msgpackEncode({ sessionId = self.__sessionId, ids = ids })
return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.msgpack):andThen(function(body) return Http.post(url, request_body)
if body.sessionId ~= self.__sessionId then :andThen(rejectFailedRequests)
:andThen(Http.Response.msgpack)
:andThen(function(response_body)
if response_body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID") return Promise.reject("Server changed ID")
end end
assert(validateApiSerialize(body)) assert(validateApiSerialize(response_body))
return body return response_body
end) end)
end end
function ApiContext:refPatch(ids: { string }) function ApiContext:refPatch(ids: { string })
local url = ("%s/api/ref-patch/%s"):format(self.__baseUrl, table.concat(ids, ",")) local url = ("%s/api/ref-patch"):format(self.__baseUrl)
local request_body = Http.msgpackEncode({ sessionId = self.__sessionId, ids = ids })
return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.msgpack):andThen(function(body) return Http.post(url, request_body)
if body.sessionId ~= self.__sessionId then :andThen(rejectFailedRequests)
:andThen(Http.Response.msgpack)
:andThen(function(response_body)
if response_body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID") return Promise.reject("Server changed ID")
end end
assert(validateApiRefPatch(body)) assert(validateApiRefPatch(response_body))
return body return response_body
end) end)
end end

View file

@ -19,9 +19,15 @@ local FullscreenNotification = Roact.Component:extend("FullscreeFullscreenNotifi
function FullscreenNotification:init() function FullscreenNotification:init()
self.transparency, self.setTransparency = Roact.createBinding(0) self.transparency, self.setTransparency = Roact.createBinding(0)
self.lifetime = self.props.timeout self.lifetime = self.props.timeout
self.dismissed = false
end end
function FullscreenNotification:dismiss() function FullscreenNotification:dismiss()
if self.dismissed then
return
end
self.dismissed = true
if self.props.onClose then if self.props.onClose then
self.props.onClose() self.props.onClose()
end end
@ -59,7 +65,7 @@ function FullscreenNotification:didMount()
end end
function FullscreenNotification:willUnmount() function FullscreenNotification:willUnmount()
if self.timeout and coroutine.status(self.timeout) ~= "dead" then if self.timeout and coroutine.status(self.timeout) == "suspended" then
task.cancel(self.timeout) task.cancel(self.timeout)
end end
end end

View file

@ -25,6 +25,7 @@ function Notification:init()
self.binding = bindingUtil.fromMotor(self.motor) self.binding = bindingUtil.fromMotor(self.motor)
self.lifetime = self.props.timeout self.lifetime = self.props.timeout
self.dismissed = false
self.motor:onStep(function(value) self.motor:onStep(function(value)
if value <= 0 and self.props.onClose then if value <= 0 and self.props.onClose then
@ -34,6 +35,11 @@ function Notification:init()
end end
function Notification:dismiss() function Notification:dismiss()
if self.dismissed then
return
end
self.dismissed = true
self.motor:setGoal(Flipper.Spring.new(0, { self.motor:setGoal(Flipper.Spring.new(0, {
frequency = 5, frequency = 5,
dampingRatio = 1, dampingRatio = 1,
@ -75,7 +81,7 @@ function Notification:didMount()
end end
function Notification:willUnmount() function Notification:willUnmount()
if self.timeout and coroutine.status(self.timeout) ~= "dead" then if self.timeout and coroutine.status(self.timeout) == "suspended" then
task.cancel(self.timeout) task.cancel(self.timeout)
end end
end end

View file

@ -301,6 +301,19 @@ function App:setPriorSyncInfo(host: string, port: string, projectName: string)
Settings:set("priorEndpoints", priorSyncInfos) Settings:set("priorEndpoints", priorSyncInfos)
end end
function App:forgetPriorSyncInfo()
local priorSyncInfos = Settings:get("priorEndpoints")
if not priorSyncInfos then
priorSyncInfos = {}
end
local id = tostring(game.PlaceId)
priorSyncInfos[id] = nil
Log.trace("Erased last used endpoint for {}", game.PlaceId)
Settings:set("priorEndpoints", priorSyncInfos)
end
function App:getHostAndPort() function App:getHostAndPort()
local host = self.host:getValue() local host = self.host:getValue()
local port = self.port:getValue() local port = self.port:getValue()
@ -435,7 +448,8 @@ function App:checkSyncReminder()
self:findActiveServer() self:findActiveServer()
:andThen(function(serverInfo, host, port) :andThen(function(serverInfo, host, port)
self:sendSyncReminder( self:sendSyncReminder(
`Project '{serverInfo.projectName}' is serving at {host}:{port}.\nWould you like to connect?` `Project '{serverInfo.projectName}' is serving at {host}:{port}.\nWould you like to connect?`,
{ "Connect", "Dismiss" }
) )
end) end)
:catch(function() :catch(function()
@ -446,7 +460,8 @@ function App:checkSyncReminder()
local timeSinceSync = timeUtil.elapsedToText(os.time() - priorSyncInfo.timestamp) local timeSinceSync = timeUtil.elapsedToText(os.time() - priorSyncInfo.timestamp)
self:sendSyncReminder( self:sendSyncReminder(
`You synced project '{priorSyncInfo.projectName}' to this place {timeSinceSync}.\nDid you mean to run 'rojo serve' and then connect?` `You synced project '{priorSyncInfo.projectName}' to this place {timeSinceSync}.\nDid you mean to run 'rojo serve' and then connect?`,
{ "Connect", "Forget", "Dismiss" }
) )
end end
end) end)
@ -486,12 +501,16 @@ function App:stopSyncReminderPolling()
end end
end end
function App:sendSyncReminder(message: string) function App:sendSyncReminder(message: string, shownActions: { string })
local syncReminderMode = Settings:get("syncReminderMode") local syncReminderMode = Settings:get("syncReminderMode")
if syncReminderMode == "None" then if syncReminderMode == "None" then
return return
end end
local connectIndex = table.find(shownActions, "Connect")
local forgetIndex = table.find(shownActions, "Forget")
local dismissIndex = table.find(shownActions, "Dismiss")
self.dismissSyncReminder = self:addNotification({ self.dismissSyncReminder = self:addNotification({
text = message, text = message,
timeout = 120, timeout = 120,
@ -500,24 +519,39 @@ function App:sendSyncReminder(message: string)
self.dismissSyncReminder = nil self.dismissSyncReminder = nil
end, end,
actions = { actions = {
Connect = { Connect = if connectIndex
then {
text = "Connect", text = "Connect",
style = "Solid", style = "Solid",
layoutOrder = 1, layoutOrder = connectIndex,
onClick = function() onClick = function()
self:startSession() self:startSession()
end, end,
}, }
Dismiss = { else nil,
Forget = if forgetIndex
then {
text = "Forget",
style = "Bordered",
layoutOrder = forgetIndex,
onClick = function()
-- The user doesn't want to be reminded again about this sync
self:forgetPriorSyncInfo()
end,
}
else nil,
Dismiss = if dismissIndex
then {
text = "Dismiss", text = "Dismiss",
style = "Bordered", style = "Bordered",
layoutOrder = 2, layoutOrder = dismissIndex,
onClick = function() onClick = function()
-- If the user dismisses the reminder, -- If the user dismisses the reminder,
-- then we don't need to remind them again -- then we don't need to remind them again
self:stopSyncReminderPolling() self:stopSyncReminderPolling()
end, end,
}, }
else nil,
}, },
}) })
end end

View file

@ -0,0 +1,7 @@
---
source: tests/rojo_test/syncback_util.rs
expression: "String::from_utf8_lossy(&output.stdout)"
---
Writing src/Pointer1.model.json
Writing src/Pointer2.model.json
Writing src/Pointer3.model.json

View file

@ -0,0 +1,7 @@
---
source: tests/tests/syncback.rs
expression: src/Pointer1.model.json
---
{
"className": "ObjectValue"
}

View file

@ -0,0 +1,7 @@
---
source: tests/tests/syncback.rs
expression: src/Pointer2.model.json
---
{
"className": "ObjectValue"
}

View file

@ -0,0 +1,7 @@
---
source: tests/tests/syncback.rs
expression: src/Pointer3.model.json
---
{
"className": "ObjectValue"
}

View file

@ -0,0 +1,9 @@
{
"name": "ref_properties_pruned",
"tree": {
"$className": "DataModel",
"ServerScriptService": {
"$path": "src"
}
}
}

View file

@ -1,12 +1,12 @@
use std::{
fs,
sync::{Arc, Mutex},
};
use crossbeam_channel::{select, Receiver, RecvError, Sender}; use crossbeam_channel::{select, Receiver, RecvError, Sender};
use jod_thread::JoinHandle; use jod_thread::JoinHandle;
use memofs::{IoResultExt, Vfs, VfsEvent}; use memofs::{IoResultExt, Vfs, VfsEvent};
use rbx_dom_weak::types::{Ref, Variant}; use rbx_dom_weak::types::{Ref, Variant};
use std::path::PathBuf;
use std::{
fs,
sync::{Arc, Mutex},
};
use crate::{ use crate::{
message_queue::MessageQueue, message_queue::MessageQueue,
@ -114,18 +114,13 @@ struct JobThreadContext {
} }
impl JobThreadContext { impl JobThreadContext {
fn handle_vfs_event(&self, event: VfsEvent) { /// Computes and applies patches to the DOM for a given file path.
log::trace!("Vfs event: {:?}", event); ///
/// This function finds the nearest ancestor to the given path that has associated instances
// Update the VFS immediately with the event. /// in the tree.
self.vfs /// It then computes and applies changes for each affected instance ID and
.commit_event(&event) /// returns a vector of applied patch sets.
.expect("Error applying VFS change"); fn apply_patches(&self, path: PathBuf) -> Vec<AppliedPatchSet> {
// For a given VFS event, we might have many changes to different parts
// of the tree. Calculate and apply all of these changes.
let applied_patches = match event {
VfsEvent::Create(path) | VfsEvent::Remove(path) | VfsEvent::Write(path) => {
let mut tree = self.tree.lock().unwrap(); let mut tree = self.tree.lock().unwrap();
let mut applied_patches = Vec::new(); let mut applied_patches = Vec::new();
@ -161,6 +156,29 @@ impl JobThreadContext {
applied_patches applied_patches
} }
fn handle_vfs_event(&self, event: VfsEvent) {
log::trace!("Vfs event: {:?}", event);
// Update the VFS immediately with the event.
self.vfs
.commit_event(&event)
.expect("Error applying VFS change");
// For a given VFS event, we might have many changes to different parts
// of the tree. Calculate and apply all of these changes.
let applied_patches = match event {
VfsEvent::Create(path) | VfsEvent::Write(path) => {
self.apply_patches(self.vfs.canonicalize(&path).unwrap())
}
VfsEvent::Remove(path) => {
// MemoFS does not track parent removals yet, so we can canonicalize
// the parent path safely and then append the removed path's file name.
let parent = path.parent().unwrap();
let file_name = path.file_name().unwrap();
let parent_normalized = self.vfs.canonicalize(parent).unwrap();
self.apply_patches(parent_normalized.join(file_name))
}
_ => { _ => {
log::warn!("Unhandled VFS event: {:?}", event); log::warn!("Unhandled VFS event: {:?}", event);
Vec::new() Vec::new()

View file

@ -42,7 +42,7 @@ pub fn snapshot_csv(
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(path) .instigating_source(path)
.relevant_paths(vec![path.to_path_buf()]), .relevant_paths(vec![vfs.canonicalize(path)?]),
); );
AdjacentMetadata::read_and_apply_all(vfs, path, name, &mut snapshot)?; AdjacentMetadata::read_and_apply_all(vfs, path, name, &mut snapshot)?;

View file

@ -62,18 +62,19 @@ pub fn snapshot_dir_no_meta(
} }
} }
let normalized_path = vfs.canonicalize(path)?;
let relevant_paths = vec![ let relevant_paths = vec![
path.to_path_buf(), normalized_path.clone(),
// TODO: We shouldn't need to know about Lua existing in this // TODO: We shouldn't need to know about Lua existing in this
// middleware. Should we figure out a way for that function to add // middleware. Should we figure out a way for that function to add
// relevant paths to this middleware? // relevant paths to this middleware?
path.join("init.lua"), normalized_path.join("init.lua"),
path.join("init.luau"), normalized_path.join("init.luau"),
path.join("init.server.lua"), normalized_path.join("init.server.lua"),
path.join("init.server.luau"), normalized_path.join("init.server.luau"),
path.join("init.client.lua"), normalized_path.join("init.client.lua"),
path.join("init.client.luau"), normalized_path.join("init.client.luau"),
path.join("init.csv"), normalized_path.join("init.csv"),
]; ];
let snapshot = InstanceSnapshot::new() let snapshot = InstanceSnapshot::new()

View file

@ -32,7 +32,7 @@ pub fn snapshot_json(
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(path) .instigating_source(path)
.relevant_paths(vec![path.to_path_buf()]) .relevant_paths(vec![vfs.canonicalize(path)?])
.context(context), .context(context),
); );

View file

@ -59,7 +59,7 @@ pub fn snapshot_json_model(
snapshot.metadata = snapshot snapshot.metadata = snapshot
.metadata .metadata
.instigating_source(path) .instigating_source(path)
.relevant_paths(vec![path.to_path_buf()]) .relevant_paths(vec![vfs.canonicalize(path)?])
.context(context) .context(context)
.specified_id(id) .specified_id(id)
.schema(schema); .schema(schema);

View file

@ -88,7 +88,7 @@ pub fn snapshot_lua(
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(path) .instigating_source(path)
.relevant_paths(vec![path.to_path_buf()]) .relevant_paths(vec![vfs.canonicalize(path)?])
.context(context), .context(context),
); );

View file

@ -28,7 +28,7 @@ pub fn snapshot_rbxm(
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(path) .instigating_source(path)
.relevant_paths(vec![path.to_path_buf()]) .relevant_paths(vec![vfs.canonicalize(path)?])
.context(context), .context(context),
); );

View file

@ -31,7 +31,7 @@ pub fn snapshot_rbxmx(
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(path) .instigating_source(path)
.relevant_paths(vec![path.to_path_buf()]) .relevant_paths(vec![vfs.canonicalize(path)?])
.context(context), .context(context),
); );

View file

@ -31,7 +31,7 @@ pub fn snapshot_toml(
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(path) .instigating_source(path)
.relevant_paths(vec![path.to_path_buf()]) .relevant_paths(vec![vfs.canonicalize(path)?])
.context(context), .context(context),
); );

View file

@ -28,7 +28,7 @@ pub fn snapshot_txt(
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(path) .instigating_source(path)
.relevant_paths(vec![path.to_path_buf()]) .relevant_paths(vec![vfs.canonicalize(path)?])
.context(context), .context(context),
); );

View file

@ -37,7 +37,7 @@ pub fn snapshot_yaml(
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(path) .instigating_source(path)
.relevant_paths(vec![path.to_path_buf()]) .relevant_paths(vec![vfs.canonicalize(path)?])
.context(context), .context(context),
); );

View file

@ -8,7 +8,10 @@ use rbx_dom_weak::{
ustr, Instance, Ustr, WeakDom, ustr, Instance, Ustr, WeakDom,
}; };
use crate::{multimap::MultiMap, REF_ID_ATTRIBUTE_NAME, REF_POINTER_ATTRIBUTE_PREFIX}; use crate::{
multimap::MultiMap, syncback::snapshot::inst_path, REF_ID_ATTRIBUTE_NAME,
REF_POINTER_ATTRIBUTE_PREFIX,
};
pub struct RefLinks { pub struct RefLinks {
/// A map of referents to each of their Ref properties. /// A map of referents to each of their Ref properties.
@ -50,6 +53,22 @@ pub fn collect_referents(dom: &WeakDom) -> RefLinks {
continue; continue;
} }
let target = match dom.get_by_ref(*prop_value) {
Some(inst) => inst,
None => {
// Properties that are Some but point to nothing may as
// well be `nil`. Roblox and us never produce these values
// but syncback prunes trees without adjusting ref
// properties for performance reasons.
log::warn!(
"Property {}.{} will be `nil` on the disk because the actual value is not being included in syncback",
inst_path(dom, inst_ref),
prop_name
);
continue;
}
};
links.insert( links.insert(
inst_ref, inst_ref,
RefLink { RefLink {
@ -58,10 +77,6 @@ pub fn collect_referents(dom: &WeakDom) -> RefLinks {
}, },
); );
let target = dom
.get_by_ref(*prop_value)
.expect("Refs in DOM should point to valid Instances");
// 1. Check if target has an ID // 1. Check if target has an ID
if let Some(id) = get_existing_id(target) { if let Some(id) = get_existing_id(target) {
// If it does, we need to check whether that ID is a duplicate // If it does, we need to check whether that ID is a duplicate

View file

@ -1,13 +1,7 @@
//! Defines Rojo's HTTP API, all under /api. These endpoints generally return //! Defines Rojo's HTTP API, all under /api. These endpoints generally return
//! JSON. //! JSON.
use std::{ use std::{collections::HashMap, fs, path::PathBuf, str::FromStr, sync::Arc};
collections::{HashMap, HashSet},
fs,
path::PathBuf,
str::FromStr,
sync::Arc,
};
use futures::{sink::SinkExt, stream::StreamExt}; use futures::{sink::SinkExt, stream::StreamExt};
use hyper::{body, Body, Method, Request, Response, StatusCode}; use hyper::{body, Body, Method, Request, Response, StatusCode};
@ -29,7 +23,9 @@ use crate::{
}, },
util::{deserialize_msgpack, msgpack, msgpack_ok, serialize_msgpack}, util::{deserialize_msgpack, msgpack, msgpack_ok, serialize_msgpack},
}, },
web_api::{InstanceUpdate, RefPatchResponse, SerializeResponse}, web_api::{
InstanceUpdate, RefPatchRequest, RefPatchResponse, SerializeRequest, SerializeResponse,
},
}; };
pub async fn call(serve_session: Arc<ServeSession>, mut request: Request<Body>) -> Response<Body> { pub async fn call(serve_session: Arc<ServeSession>, mut request: Request<Body>) -> Response<Body> {
@ -52,12 +48,8 @@ pub async fn call(serve_session: Arc<ServeSession>, mut request: Request<Body>)
) )
} }
} }
(&Method::GET, path) if path.starts_with("/api/serialize/") => { (&Method::POST, "/api/serialize") => service.handle_api_serialize(request).await,
service.handle_api_serialize(request).await (&Method::POST, "/api/ref-patch") => service.handle_api_ref_patch(request).await,
}
(&Method::GET, path) if path.starts_with("/api/ref-patch/") => {
service.handle_api_ref_patch(request).await
}
(&Method::POST, path) if path.starts_with("/api/open/") => { (&Method::POST, path) if path.starts_with("/api/open/") => {
service.handle_api_open(request).await service.handle_api_open(request).await
@ -228,22 +220,30 @@ impl ApiService {
/// that correspond to the requested Instances. These values have their /// that correspond to the requested Instances. These values have their
/// `Value` property set to point to the requested Instance. /// `Value` property set to point to the requested Instance.
async fn handle_api_serialize(&self, request: Request<Body>) -> Response<Body> { async fn handle_api_serialize(&self, request: Request<Body>) -> Response<Body> {
let argument = &request.uri().path()["/api/serialize/".len()..]; let session_id = self.serve_session.session_id();
let requested_ids: Result<Vec<Ref>, _> = argument.split(',').map(Ref::from_str).collect(); let body = body::to_bytes(request.into_body()).await.unwrap();
let requested_ids = match requested_ids { let request: SerializeRequest = match deserialize_msgpack(&body) {
Ok(ids) => ids, Ok(request) => request,
Err(_) => { Err(err) => {
return msgpack( return msgpack(
ErrorResponse::bad_request("Malformed ID list"), ErrorResponse::bad_request(format!("Invalid body: {}", err)),
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
); );
} }
}; };
if request.session_id != session_id {
return msgpack(
ErrorResponse::bad_request("Wrong session ID"),
StatusCode::BAD_REQUEST,
);
}
let mut response_dom = WeakDom::new(InstanceBuilder::new("Folder")); let mut response_dom = WeakDom::new(InstanceBuilder::new("Folder"));
let tree = self.serve_session.tree(); let tree = self.serve_session.tree();
for id in &requested_ids { for id in &request.ids {
if let Some(instance) = tree.get_instance(*id) { if let Some(instance) = tree.get_instance(*id) {
let clone = response_dom.insert( let clone = response_dom.insert(
Ref::none(), Ref::none(),
@ -289,20 +289,26 @@ impl ApiService {
/// and referent properties need to be updated after the serialize /// and referent properties need to be updated after the serialize
/// endpoint is used. /// endpoint is used.
async fn handle_api_ref_patch(self, request: Request<Body>) -> Response<Body> { async fn handle_api_ref_patch(self, request: Request<Body>) -> Response<Body> {
let argument = &request.uri().path()["/api/ref-patch/".len()..]; let session_id = self.serve_session.session_id();
let requested_ids: Result<HashSet<Ref>, _> = let body = body::to_bytes(request.into_body()).await.unwrap();
argument.split(',').map(Ref::from_str).collect();
let requested_ids = match requested_ids { let request: RefPatchRequest = match deserialize_msgpack(&body) {
Ok(ids) => ids, Ok(request) => request,
Err(_) => { Err(err) => {
return msgpack( return msgpack(
ErrorResponse::bad_request("Malformed ID list"), ErrorResponse::bad_request(format!("Invalid body: {}", err)),
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
); );
} }
}; };
if request.session_id != session_id {
return msgpack(
ErrorResponse::bad_request("Wrong session ID"),
StatusCode::BAD_REQUEST,
);
}
let mut instance_updates: HashMap<Ref, InstanceUpdate> = HashMap::new(); let mut instance_updates: HashMap<Ref, InstanceUpdate> = HashMap::new();
let tree = self.serve_session.tree(); let tree = self.serve_session.tree();
@ -311,7 +317,7 @@ impl ApiService {
let Variant::Ref(prop_value) = prop_value else { let Variant::Ref(prop_value) = prop_value else {
continue; continue;
}; };
if let Some(target_id) = requested_ids.get(prop_value) { if let Some(target_id) = request.ids.get(prop_value) {
let instance_id = instance.id(); let instance_id = instance.id();
let update = let update =
instance_updates instance_updates

View file

@ -238,6 +238,13 @@ pub struct OpenResponse {
pub session_id: SessionId, pub session_id: SessionId,
} }
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SerializeRequest {
pub session_id: SessionId,
pub ids: Vec<Ref>,
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SerializeResponse { pub struct SerializeResponse {
@ -246,6 +253,13 @@ pub struct SerializeResponse {
pub model_contents: Vec<u8>, pub model_contents: Vec<u8>,
} }
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RefPatchRequest {
pub session_id: SessionId,
pub ids: HashSet<Ref>,
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RefPatchResponse<'a> { pub struct RefPatchResponse<'a> {

View file

@ -1,5 +1,4 @@
use std::{ use std::{
fmt::Write as _,
fs, fs,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::Command, process::Command,
@ -11,11 +10,15 @@ use std::{
use hyper_tungstenite::tungstenite::{connect, Message}; use hyper_tungstenite::tungstenite::{connect, Message};
use rbx_dom_weak::types::Ref; use rbx_dom_weak::types::Ref;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use tempfile::{tempdir, TempDir}; use tempfile::{tempdir, TempDir};
use librojo::web_api::{ use librojo::{
ReadResponse, SerializeResponse, ServerInfoResponse, SocketPacket, SocketPacketType, web_api::{
ReadResponse, SerializeRequest, SerializeResponse, ServerInfoResponse, SocketPacket,
SocketPacketType,
},
SessionId,
}; };
use rojo_insta_ext::RedactionMap; use rojo_insta_ext::RedactionMap;
@ -221,21 +224,36 @@ impl TestServeSession {
} }
} }
pub fn get_api_serialize(&self, ids: &[Ref]) -> Result<SerializeResponse, reqwest::Error> { pub fn get_api_serialize(
let mut id_list = String::with_capacity(ids.len() * 33); &self,
for id in ids { ids: &[Ref],
write!(id_list, "{id},").unwrap(); session_id: SessionId,
} ) -> Result<SerializeResponse, reqwest::Error> {
id_list.pop(); let client = reqwest::blocking::Client::new();
let url = format!("http://localhost:{}/api/serialize", self.port);
let body = serialize_msgpack(&SerializeRequest {
session_id,
ids: ids.to_vec(),
})
.unwrap();
let url = format!("http://localhost:{}/api/serialize/{}", self.port, id_list); let body = client.post(url).body(body).send()?.bytes()?;
let body = reqwest::blocking::get(url)?.bytes()?;
Ok(deserialize_msgpack(&body).expect("Server returned malformed response")) Ok(deserialize_msgpack(&body).expect("Server returned malformed response"))
} }
} }
fn serialize_msgpack<T: Serialize>(value: T) -> Result<Vec<u8>, rmp_serde::encode::Error> {
let mut serialized = Vec::new();
let mut serializer = rmp_serde::Serializer::new(&mut serialized)
.with_human_readable()
.with_struct_map();
value.serialize(&mut serializer)?;
Ok(serialized)
}
fn deserialize_msgpack<'a, T: Deserialize<'a>>( fn deserialize_msgpack<'a, T: Deserialize<'a>>(
input: &'a [u8], input: &'a [u8],
) -> Result<T, rmp_serde::decode::Error> { ) -> Result<T, rmp_serde::decode::Error> {

View file

@ -646,7 +646,7 @@ fn meshpart_with_id() {
.unwrap(); .unwrap();
let serialize_response = session let serialize_response = session
.get_api_serialize(&[*meshpart, *objectvalue]) .get_api_serialize(&[*meshpart, *objectvalue], info.session_id)
.unwrap(); .unwrap();
// We don't assert a snapshot on the SerializeResponse because the model includes the // We don't assert a snapshot on the SerializeResponse because the model includes the
@ -673,7 +673,9 @@ fn forced_parent() {
read_response.intern_and_redact(&mut redactions, root_id) read_response.intern_and_redact(&mut redactions, root_id)
); );
let serialize_response = session.get_api_serialize(&[root_id]).unwrap(); let serialize_response = session
.get_api_serialize(&[root_id], info.session_id)
.unwrap();
assert_eq!(serialize_response.session_id, info.session_id); assert_eq!(serialize_response.session_id, info.session_id);

View file

@ -71,8 +71,13 @@ syncback_tests! {
ref_properties_conflict => ["src/Pointer_2.model.json", "src/Target_2.model.json"], ref_properties_conflict => ["src/Pointer_2.model.json", "src/Target_2.model.json"],
// Ensures that having multiple pointers that are aimed at the same target doesn't trigger ref rewrites. // Ensures that having multiple pointers that are aimed at the same target doesn't trigger ref rewrites.
ref_properties_duplicate => [], ref_properties_duplicate => [],
// Ensures that ref properties that point to nothing after the prune both
// do not leave any trace of themselves
ref_properties_pruned => ["src/Pointer1.model.json", "src/Pointer2.model.json", "src/Pointer3.model.json"],
// Ensures that the old middleware is respected during syncback // Ensures that the old middleware is respected during syncback
respect_old_middleware => ["default.project.json", "src/model_json.model.json", "src/rbxm.rbxm", "src/rbxmx.rbxmx"], respect_old_middleware => ["default.project.json", "src/model_json.model.json", "src/rbxm.rbxm", "src/rbxmx.rbxmx"],
// Ensures that the `$schema` field roundtrips with syncback
schema_roundtrip => ["default.project.json", "src/model.model.json", "src/init/init.meta.json", "src/adjacent.meta.json"],
// Ensures that StringValues inside project files are written to the // Ensures that StringValues inside project files are written to the
// project file, but only if they don't have `$path` set // project file, but only if they don't have `$path` set
string_value_project => ["default.project.json"], string_value_project => ["default.project.json"],
@ -81,5 +86,4 @@ syncback_tests! {
sync_rules => ["src/module.modulescript", "src/text.text"], sync_rules => ["src/module.modulescript", "src/text.text"],
// Ensures that the `syncUnscriptable` setting works // Ensures that the `syncUnscriptable` setting works
unscriptable_properties => ["default.project.json"], unscriptable_properties => ["default.project.json"],
schema_roundtrip => ["default.project.json", "src/model.model.json", "src/init/init.meta.json", "src/adjacent.meta.json"]
} }