mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
Merge main into #1608
This commit is contained in:
+2
-1
File diff suppressed because one or more lines are too long
@@ -78,6 +78,7 @@ fn write_atomic_as(path: &Path, bytes: &[u8], private: bool) -> io::Result<()> {
|
||||
#[cfg(not(unix))]
|
||||
let _ = private;
|
||||
let mut f = o.open(&tmp)?;
|
||||
counted(true);
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
@@ -101,6 +102,7 @@ pub fn sync_dir(dir: Option<&Path>) {
|
||||
/// Read a file of at most `max` bytes (a longer one is refused without being read).
|
||||
pub fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>, String> {
|
||||
let f = File::open(path).map_err(|e| format!("can't open {}: {e}", name(path)))?;
|
||||
counted(false);
|
||||
let len = f.metadata().map_err(|e| e.to_string())?.len();
|
||||
if len > max {
|
||||
return Err(format!("{} is {} bytes, over the {max}-byte cap", name(path), len));
|
||||
@@ -110,6 +112,29 @@ pub fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>, String> {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Files read and written on this thread (tests: what an action costs, #1074).
|
||||
static IO: std::cell::Cell<(u32, u32)> = const { std::cell::Cell::new((0, 0)) };
|
||||
}
|
||||
|
||||
/// Count a file read or write (a log append counts as a write).
|
||||
pub(crate) fn counted(write: bool) {
|
||||
#[cfg(test)]
|
||||
IO.with(|io| {
|
||||
let (r, w) = io.get();
|
||||
io.set(if write { (r, w + 1) } else { (r + 1, w) });
|
||||
});
|
||||
#[cfg(not(test))]
|
||||
let _ = write;
|
||||
}
|
||||
|
||||
/// The files read and written on this thread since the last call: `(reads, writes)`.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn take_io() -> (u32, u32) {
|
||||
IO.with(|io| io.replace((0, 0)))
|
||||
}
|
||||
|
||||
fn name(path: &Path) -> String {
|
||||
path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Joining and leaving a file nobody is editing costs no disk I/O while it is kept loaded
|
||||
//! (#1074), and what changes is still saved as before.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use figcraft_collab::message::{Access, ClientMsg, Hello};
|
||||
use figcraft_collab::{Client, NoRecompute, Outgoing, Replica, Server};
|
||||
use figcraft_collab_net::hub::Backend;
|
||||
use figcraft_doc::Document;
|
||||
|
||||
use crate::disk::{self, META};
|
||||
use crate::store::{FILES, Store, StoreConfig};
|
||||
use crate::test_util::{TempDir, config};
|
||||
|
||||
fn hello() -> Hello {
|
||||
let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "#FF8800") else { panic!() };
|
||||
h
|
||||
}
|
||||
|
||||
/// Let `hello` into `server` and hand its messages to `client` (a new one when `None`); the
|
||||
/// client and its id there.
|
||||
fn enter(server: &mut Server, client: Option<Client>, hello: Hello) -> (Client, u32) {
|
||||
let (id, out) = server.join_as(hello, Access::Edit).unwrap();
|
||||
let mut msgs = out.into_iter().filter_map(|o| match o {
|
||||
Outgoing::To(to, m) if to == id => Some(m),
|
||||
_ => None,
|
||||
});
|
||||
let client = match client {
|
||||
Some(mut c) => {
|
||||
for m in msgs {
|
||||
c.receive(m, &NoRecompute).unwrap();
|
||||
}
|
||||
c
|
||||
}
|
||||
None => Client::join(msgs.next().unwrap()).unwrap(),
|
||||
};
|
||||
(client, id)
|
||||
}
|
||||
|
||||
/// A file opened, joined and left once: the store, the file's id and the client, ready to resume.
|
||||
fn published(dir: &TempDir) -> (Store, String, Client, StoreConfig) {
|
||||
let c = StoreConfig { compact_after: 1000, ..config(dir.path()).store() };
|
||||
let mut store = Store::open(dir.path(), c.clone()).unwrap();
|
||||
let d = Document::new("Idle");
|
||||
let (info, _, _) = store.publish("Idle", &Replica::new(d).snapshot().unwrap()).unwrap();
|
||||
let id = info.id;
|
||||
let mut server = store.open(&id).unwrap();
|
||||
let (client, cid) = enter(&mut server, None, hello());
|
||||
server.leave(cid);
|
||||
store.changed(&id, &mut server);
|
||||
store.flush();
|
||||
assert!(store.idle(&id, &mut server));
|
||||
(store, id, client, c)
|
||||
}
|
||||
|
||||
/// One rejoin and leave, as the hub drives it; `edit` renames the page on the way.
|
||||
fn cycle(store: &mut Store, id: &str, client: Client, edit: Option<&str>) -> Client {
|
||||
let mut server = store.open(id).unwrap();
|
||||
let (mut client, cid) = {
|
||||
let mut c = client;
|
||||
let ClientMsg::Hello(h) = c.reconnect(hello()) else { panic!() };
|
||||
enter(&mut server, Some(c), h)
|
||||
};
|
||||
if let Some(name) = edit {
|
||||
let mut d = client.document().clone();
|
||||
let page = d.first_page().unwrap();
|
||||
d.node_mut(page).unwrap().name = name.into();
|
||||
let m = client.edit(&d).unwrap().unwrap();
|
||||
for o in server.receive(cid, m) {
|
||||
if let Outgoing::All(m) | Outgoing::To(_, m) = o {
|
||||
client.receive(m, &NoRecompute).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
server.leave(cid);
|
||||
store.changed(id, &mut server);
|
||||
store.flush();
|
||||
assert!(store.idle(id, &mut server));
|
||||
client
|
||||
}
|
||||
|
||||
fn page_name(server: &Server) -> String {
|
||||
let d = server.replica().document();
|
||||
d.first_page().and_then(|p| d.node(p)).map(|n| n.name.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejoining_an_unchanged_file_reads_and_writes_nothing() {
|
||||
let dir = TempDir::new("idle-io");
|
||||
let (mut store, id, mut client, _) = published(&dir);
|
||||
let log = dir.path().join(FILES).join(&id).join(crate::disk::LOG);
|
||||
let log_len = fs::metadata(&log).unwrap().len();
|
||||
disk::take_io();
|
||||
for _ in 0..20 {
|
||||
client = cycle(&mut store, &id, client, None);
|
||||
}
|
||||
assert_eq!(disk::take_io(), (0, 0), "20 join/leave cycles: no file read or written");
|
||||
assert_eq!(fs::metadata(&log).unwrap().len(), log_len);
|
||||
assert_eq!(store.compactions(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_while_kept_loaded_are_still_saved() {
|
||||
let dir = TempDir::new("idle-edits");
|
||||
let (mut store, id, mut client, c) = published(&dir);
|
||||
for i in 0..3 {
|
||||
client = cycle(&mut store, &id, client, Some(&format!("Page {i}")));
|
||||
}
|
||||
// A cycle with an edit compacts it on leaving, as before; the next one does nothing again.
|
||||
assert!(store.compactions() > 0);
|
||||
disk::take_io();
|
||||
let _ = cycle(&mut store, &id, client, None);
|
||||
assert_eq!(disk::take_io(), (0, 0));
|
||||
// Saved: another store (a restart) reads it back.
|
||||
drop(store);
|
||||
let mut again = Store::open(dir.path(), c).unwrap();
|
||||
disk::take_io();
|
||||
let server = again.open(&id).unwrap();
|
||||
assert_eq!(page_name(&server), "Page 2");
|
||||
assert_eq!(disk::take_io().0, 2, "one load: the snapshot and the log");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_kept_loaded_longest_lets_go_first() {
|
||||
let dir = TempDir::new("idle-many");
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
let ids: Vec<String> =
|
||||
(0..17).map(|i| store.publish(&format!("F{i}"), &Replica::new(Document::new("F")).snapshot().unwrap()).unwrap().0.id).collect();
|
||||
for id in &ids {
|
||||
let mut server = store.open(id).unwrap();
|
||||
assert!(store.idle(id, &mut server));
|
||||
}
|
||||
disk::take_io();
|
||||
let _ = store.open(&ids[16]).unwrap();
|
||||
assert_eq!(disk::take_io(), (0, 0), "the latest is still loaded");
|
||||
let _ = store.open(&ids[0]).unwrap();
|
||||
assert_eq!(disk::take_io(), (2, 0), "past 16, the first let go: read again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_open_time_is_written_hourly_and_when_the_server_stops() {
|
||||
let dir = TempDir::new("idle-opened");
|
||||
let (mut store, id, client, _) = published(&dir);
|
||||
let opened = || -> u64 {
|
||||
let meta: serde_json::Value = serde_json::from_slice(&fs::read(dir.path().join(FILES).join(&id).join(META)).unwrap()).unwrap();
|
||||
meta["opened"].as_u64().unwrap()
|
||||
};
|
||||
// Last written half an hour ago: a join doesn't write it yet; stopping does.
|
||||
store.age(&id, 1800);
|
||||
let before = opened();
|
||||
disk::take_io();
|
||||
let client = cycle(&mut store, &id, client, None);
|
||||
assert_eq!(disk::take_io(), (0, 0));
|
||||
assert_eq!(opened(), before);
|
||||
store.stop(Vec::new());
|
||||
assert!(opened() >= before + 1800);
|
||||
// Written over an hour ago: the next join writes it.
|
||||
store.age(&id, 4000);
|
||||
disk::take_io();
|
||||
let _ = cycle(&mut store, &id, client, None);
|
||||
assert_eq!(disk::take_io(), (0, 1));
|
||||
}
|
||||
@@ -101,6 +101,8 @@ mod directory_tests;
|
||||
#[cfg(test)]
|
||||
mod hardening_tests;
|
||||
#[cfg(test)]
|
||||
mod idle_io_tests;
|
||||
#[cfg(test)]
|
||||
mod library_tests;
|
||||
#[cfg(test)]
|
||||
mod owned_tests;
|
||||
|
||||
@@ -241,8 +241,30 @@ struct Entry {
|
||||
bytes: u64,
|
||||
/// When it was last compacted to make room (seconds since 1970).
|
||||
forced: u64,
|
||||
/// Closed lately: its session kept loaded ([`PARK_FOR`]).
|
||||
parked: Option<Parked>,
|
||||
/// The `opened` time `meta.json` holds ([`TOUCH_EVERY`]).
|
||||
opened_written: u64,
|
||||
}
|
||||
|
||||
/// A file everyone left lately, kept loaded so a join soon after neither reads it nor rewrites it
|
||||
/// (#1074): its session and its log (synced, its state compacted as on any close).
|
||||
struct Parked {
|
||||
server: Server,
|
||||
log: Log,
|
||||
at: Instant,
|
||||
}
|
||||
|
||||
/// How long a file everyone left stays loaded.
|
||||
const PARK_FOR: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
/// Most files kept loaded after everyone left (the one parked longest goes first).
|
||||
const MAX_PARKED: usize = 16;
|
||||
/// Most bytes (on disk) of the files kept loaded after everyone left.
|
||||
const MAX_PARKED_BYTES: u64 = 256 << 20;
|
||||
/// How often, at most, a file's `opened` time is written while it keeps being opened (seconds):
|
||||
/// expiry counts in days, so it needn't be written at every join and leave.
|
||||
const TOUCH_EVERY: u64 = 3600;
|
||||
|
||||
/// A library's usage reports in memory, and whether (and when) they were written.
|
||||
struct UsageCache {
|
||||
list: Vec<FileUsage>,
|
||||
@@ -494,7 +516,8 @@ impl Store {
|
||||
log::info!("file at {}: its log isn't of its snapshot's generation; it is replayed over it when the file opens", path.display());
|
||||
}
|
||||
let bytes = [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(path.join(n)).ok()).map(|m| m.len()).sum();
|
||||
Ok(Entry { meta, links, log: None, bytes, forced: 0 })
|
||||
let opened_written = meta.opened;
|
||||
Ok(Entry { meta, links, log: None, bytes, forced: 0, parked: None, opened_written })
|
||||
}
|
||||
|
||||
/// The server key (for managing files).
|
||||
|
||||
@@ -62,12 +62,14 @@ impl Backend for Store {
|
||||
}
|
||||
|
||||
fn idle(&mut self, file: &str, server: &mut Server) -> bool {
|
||||
let mut synced = false;
|
||||
let compact = self.files.get_mut(file).is_some_and(|entry| {
|
||||
let snapshot_seq = entry.meta.seq;
|
||||
entry.log.as_mut().is_some_and(|log| {
|
||||
// Issued client ids and refusals are durable in the log; only edits need a
|
||||
// new image-bearing snapshot. Still try a snapshot if syncing the log failed.
|
||||
flush_log(file, log).is_err() || (log.dirty && server.seq() > snapshot_seq)
|
||||
synced = flush_log(file, log).is_ok();
|
||||
!synced || (log.dirty && server.seq() > snapshot_seq)
|
||||
})
|
||||
});
|
||||
if compact {
|
||||
@@ -75,8 +77,12 @@ impl Backend for Store {
|
||||
}
|
||||
// It closes anyway, with its state in the snapshot and durable log.
|
||||
self.cut_short.retain(|f| f != file);
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
e.log = None;
|
||||
let log = self.files.get_mut(file).and_then(|e| e.log.take());
|
||||
// Kept loaded a while, when its log was sound: a join soon after reads and rewrites
|
||||
// nothing (#1074). The hub drops its room as this returns: the session moves here.
|
||||
if let Some(log) = log.filter(|l| synced && l.pending.is_empty()) {
|
||||
let left = std::mem::replace(server, Server::new(figcraft_collab::Document::new(""), "", self.cfg.core));
|
||||
self.park(file, left, log);
|
||||
}
|
||||
// Everyone just left: idle time counts from now.
|
||||
self.touch(file);
|
||||
@@ -86,6 +92,11 @@ impl Backend for Store {
|
||||
fn stop(&mut self, open: Vec<(&str, &mut Server)>) {
|
||||
self.write_usage(true);
|
||||
self.flush_all();
|
||||
// The open times not written yet (they are written at most hourly while files are used).
|
||||
let unwritten: Vec<String> = self.files.iter().filter(|(_, e)| e.meta.opened > e.opened_written).map(|(id, _)| id.clone()).collect();
|
||||
for file in unwritten {
|
||||
self.write_opened(&file);
|
||||
}
|
||||
for (file, server) in open {
|
||||
if self.files.get(file).and_then(|e| e.log.as_ref()).is_some_and(|l| l.dirty) {
|
||||
self.compact(file, server);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use super::{
|
||||
Access, Action, AdminReply, BLOCKED, Change, Entry, FILES, FileInfo, FileRow, HostInfo, Instant, IpAddr, KEY_ONLY, LINKS, LOG, LOG_HEADER,
|
||||
MAX_BLOCKED, MAX_OWNED, MAX_REPORTS, META, Meta, NewLink, OpenFile, OwnedFile, REPORTS, Replica, ReportInfo, SNAPSHOT, SWEEP_EVERY, Server,
|
||||
Store, clean_name, clean_reason, disk, fs, hash, ip_blocks, ip_key, new_link, now, same_secret, token, write_json,
|
||||
MAX_BLOCKED, MAX_OWNED, MAX_PARKED, MAX_PARKED_BYTES, MAX_REPORTS, META, Meta, NewLink, OpenFile, OwnedFile, PARK_FOR, Parked, REPORTS, Replica,
|
||||
ReportInfo, SNAPSHOT, SWEEP_EVERY, Server, Store, TOUCH_EVERY, clean_name, clean_reason, disk, fs, hash, ip_blocks, ip_key, new_link, now,
|
||||
same_secret, token, write_json,
|
||||
};
|
||||
|
||||
impl Store {
|
||||
@@ -133,7 +134,9 @@ impl Store {
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
return Err(format!("couldn't keep the file: {e}"));
|
||||
}
|
||||
let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None, bytes: (bytes.len() + LOG_HEADER) as u64, forced: 0 };
|
||||
let opened_written = meta.opened;
|
||||
let (links, bytes_kept) = (vec![edit.clone(), view.clone()], (bytes.len() + LOG_HEADER) as u64);
|
||||
let entry = Entry { meta, links, log: None, bytes: bytes_kept, forced: 0, parked: None, opened_written };
|
||||
self.files.insert(id.clone(), entry);
|
||||
let info = self.files.get(&id).map(|e| self.info(&id, e, None)).ok_or("the file vanished")?;
|
||||
match ip {
|
||||
@@ -199,6 +202,12 @@ impl Store {
|
||||
pub fn sweep(&mut self) -> Vec<String> {
|
||||
let t = now();
|
||||
self.next_sweep = t.saturating_add(SWEEP_EVERY);
|
||||
// Files kept loaded after everyone left let go once their time is up.
|
||||
for e in self.files.values_mut() {
|
||||
if e.parked.as_ref().is_some_and(|p| p.at.elapsed() > PARK_FOR) {
|
||||
e.parked = None;
|
||||
}
|
||||
}
|
||||
let due: Vec<String> =
|
||||
self.files.iter().filter(|(_, e)| e.log.is_none() && self.expires(&e.meta).is_some_and(|x| x <= t)).map(|(id, _)| id.clone()).collect();
|
||||
for id in &due {
|
||||
@@ -217,6 +226,7 @@ impl Store {
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
let m = &mut e.meta;
|
||||
(m.created, m.updated, m.opened) = (m.created.saturating_sub(secs), m.updated.saturating_sub(secs), m.opened.saturating_sub(secs));
|
||||
e.opened_written = m.opened;
|
||||
let _ = write_json(&path, m);
|
||||
}
|
||||
}
|
||||
@@ -232,17 +242,65 @@ impl Store {
|
||||
}
|
||||
}
|
||||
|
||||
/// Note that a file was opened now (it is used: it doesn't expire).
|
||||
/// Note that a file was opened now (it is used: it doesn't expire). Expiry counts in days, so
|
||||
/// `meta.json` gets the time at most every [`TOUCH_EVERY`] seconds (and when the server stops),
|
||||
/// not at every join and leave (#1074).
|
||||
pub(super) fn touch(&mut self, file: &str) {
|
||||
let t = now();
|
||||
let due = self.files.get_mut(file).is_some_and(|e| {
|
||||
e.meta.opened = t;
|
||||
t >= e.opened_written.saturating_add(TOUCH_EVERY)
|
||||
});
|
||||
if due {
|
||||
self.write_opened(file);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `file`'s `opened` time to its `meta.json`.
|
||||
pub(super) fn write_opened(&mut self, file: &str) {
|
||||
let path = self.dir(file).join(META);
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
e.meta.opened = now();
|
||||
if let Err(err) = write_json(&path, &e.meta) {
|
||||
log::warn!("file {file}: couldn't update {META}: {err}");
|
||||
match write_json(&path, &e.meta) {
|
||||
Ok(()) => e.opened_written = e.meta.opened,
|
||||
Err(err) => log::warn!("file {file}: couldn't update {META}: {err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep `file`'s session loaded for a while after everyone left ([`PARK_FOR`]), at most
|
||||
/// [`MAX_PARKED`] files of at most [`MAX_PARKED_BYTES`] together (the ones parked longest let
|
||||
/// go first; a file larger than that isn't kept).
|
||||
pub(super) fn park(&mut self, file: &str, server: Server, log: super::Log) {
|
||||
let size = self.bytes(file);
|
||||
if size > MAX_PARKED_BYTES {
|
||||
return;
|
||||
}
|
||||
let mut parked: Vec<(String, Instant, u64)> =
|
||||
self.files.iter().filter_map(|(id, e)| e.parked.as_ref().map(|p| (id.clone(), p.at, e.bytes))).collect();
|
||||
parked.sort_by_key(|(_, at, _)| *at);
|
||||
let mut held: u64 = parked.iter().map(|(_, _, b)| b).sum();
|
||||
let mut n = parked.len();
|
||||
for (id, _, b) in &parked {
|
||||
if n < MAX_PARKED && held.saturating_add(size) <= MAX_PARKED_BYTES {
|
||||
break;
|
||||
}
|
||||
if let Some(e) = self.files.get_mut(id) {
|
||||
e.parked = None;
|
||||
}
|
||||
n -= 1;
|
||||
held = held.saturating_sub(*b);
|
||||
}
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
e.parked = Some(Parked { server, log, at: Instant::now() });
|
||||
}
|
||||
}
|
||||
|
||||
/// `file`'s session as it was left, while it is still kept loaded ([`PARK_FOR`]).
|
||||
pub(super) fn unpark(&mut self, file: &str) -> Option<(Server, super::Log)> {
|
||||
let p = self.files.get_mut(file)?.parked.take()?;
|
||||
(p.at.elapsed() <= PARK_FOR).then_some((p.server, p.log))
|
||||
}
|
||||
|
||||
/// What the server says about its hosting (with `file`'s expiry).
|
||||
pub(super) fn host_info(&self, file: Option<&str>) -> HostInfo {
|
||||
let h = &self.cfg.hosting;
|
||||
|
||||
@@ -44,8 +44,13 @@ impl Store {
|
||||
}
|
||||
|
||||
/// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported).
|
||||
/// One everyone left lately is still loaded: it comes back as it was, nothing read (#1074).
|
||||
pub(super) fn load(&mut self, file: &str) -> Result<Server, String> {
|
||||
self.touch(file);
|
||||
if let Some((server, log)) = self.unpark(file) {
|
||||
self.entry(file)?.log = Some(log);
|
||||
return Ok(server);
|
||||
}
|
||||
let dir = self.dir(file);
|
||||
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.snapshot_cap())?)?;
|
||||
let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?;
|
||||
@@ -258,6 +263,7 @@ pub(super) fn flush_log(id: &str, log: &mut Log) -> Result<u64, ()> {
|
||||
if log.pending.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
disk::counted(true);
|
||||
match log.file.write_all(&log.pending).and_then(|()| log.file.sync_data()) {
|
||||
Ok(()) => {
|
||||
let n = log.pending.len() as u64;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Collaboration parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
|
||||
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
|
||||
@@ -29,6 +29,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M8, #1074: `figcraft-server` keeps a file everyone left loaded for a minute (at most 16 files, 256 MiB together), so rejoining it reads nothing, and writes a file's open time at most hourly (and when it stops) instead of at every join and leave: join/leave cycles of an unchanged file do no disk I/O on the hub thread. Edits are saved as before. Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M9, #1415 (the rest): `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry each thread's mentions as `comment.list` does. Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M9, #1525: share links can comment. A third access level, `comment`, sits between `edit` and `view`: someone who joins with a comment link (or its join code) sees the file live, as with a view link, and adds comment threads, replies, reactions and resolves them; any other edit is refused, by their app and by the server, which accepts a comment link's batch only when every change in it touches comment threads. Sharing online makes a comment link and code beside the edit and view ones, and the Share popover offers Can edit, Can comment and Can view; the toolbar shows a "Can comment" badge with Move, Hand and Comment. `collab.share {access: "comment"}`, `collab.manageFile {action: createLink, access: "comment"}` and `figcraft-server links create FILE --access comment` make and pick it. Older apps are unaffected: one that joins with a comment link is welcomed as a viewer, links listed to it read as view links, and a level an app doesn't know reads as view; an older server makes no comment link and sharing goes on without one. Comment authorship is the name in Preferences, as before (no accounts), so a commenter can edit or remove other people's threads. Tests: `comment_access_tests.rs` (collab), `comment_link_tests.rs` (engine, server end to end), the Share popover and toolbar in `live_share_tests.rs` and `live_server_tests.rs`. |
|
||||
| 2026-10-30 | minor | M9, part of #1415: `comment.list {mentions}` lists only the threads where a message @mentions a name, combined with the other filters, so an agent tagged in comments reads just its threads. Numbers unchanged. |
|
||||
|
||||
+2
-1
File diff suppressed because one or more lines are too long
+4
-1
@@ -337,7 +337,8 @@ behaves as before (blocked addresses, report rates and the limits on
|
||||
```
|
||||
|
||||
A file published without the key also has, in its `meta.json`, the SHA-256 of its owner token,
|
||||
the address it came from and when it was last opened (for expiry).
|
||||
the address it came from and when it was last opened (for expiry; written at most hourly while the
|
||||
file is used, and when the server stops).
|
||||
|
||||
- **Writes are atomic**: a snapshot, a description or a link list is written to a temporary file
|
||||
beside it, flushed, then renamed over the old one. The log is only ever appended to, and synced
|
||||
@@ -347,6 +348,8 @@ the address it came from and when it was last opened (for expiry).
|
||||
edited (the ids of people who only joined stay in the log), and when the server stops. A log left over from a compaction cut short is replayed over the snapshot, which already
|
||||
holds it all, so nothing changes. If a damaged generation field made a current log look like such
|
||||
a leftover, its edits the snapshot lacks are recovered the same way and the damage is reported.
|
||||
- **A file everyone left stays loaded for a minute** (at most 16 such files, 256 MiB together),
|
||||
its log already synced: someone joining in that time gets it without anything being read again.
|
||||
- **Damage is skipped and reported, never deleted.** At startup a folder whose description, links
|
||||
or snapshot can't be read is left out and logged; a log that ends in a cut-short or damaged
|
||||
record loses only that end (logged when the file opens). Leftover temporary files are removed.
|
||||
|
||||
Reference in New Issue
Block a user