diff --git a/crates/cli/src/yas_fs.rs b/crates/cli/src/yas_fs.rs index c4f73088..91aaadde 100644 --- a/crates/cli/src/yas_fs.rs +++ b/crates/cli/src/yas_fs.rs @@ -462,6 +462,7 @@ async fn cmd_write( create_parents: parents, mode, content, + in_place: false, }, json, ) @@ -1241,6 +1242,7 @@ mod tests { create_parents: false, mode: 0, content: b"contents".to_vec(), + in_place: false, }, ApplyItem::Mkdir { path: wire_path("directory").unwrap(), diff --git a/crates/cli/tests/client_host.rs b/crates/cli/tests/client_host.rs index f5befd7e..021a4fb1 100644 --- a/crates/cli/tests/client_host.rs +++ b/crates/cli/tests/client_host.rs @@ -888,28 +888,38 @@ async fn files_answer_as_the_os_does() { pair("ENOENT", "lstat") ); - // In place through a symlink: the same inode, the new bytes. + // In place through a symlink: the same inode, the new bytes. Inline-sized content goes in + // one APPLY (CAPABILITY_APPLY_IN_PLACE), bigger content is staged: both answer alike. use std::os::unix::fs::MetadataExt; let inode = std::fs::metadata(base.join("file")).unwrap().ino(); - root.write_in_place("to-file", b"new content") - .await - .unwrap(); - assert_eq!(std::fs::read(base.join("file")).unwrap(), b"new content"); - assert_eq!(std::fs::metadata(base.join("file")).unwrap().ino(), inode); - assert!( - std::fs::symlink_metadata(base.join("to-file")) - .unwrap() - .file_type() - .is_symlink() - ); - assert_eq!( - named(root.write_in_place("dir", b"x").await.unwrap_err()), - pair("EISDIR", "open") - ); - assert_eq!( - named(root.write_in_place("missing/file", b"x").await.unwrap_err()), - pair("ENOENT", "open") - ); + let staged = vec![b's'; yas_client::wire::fs::MAX_INLINE_BYTES + 1]; + for content in [&b"new content"[..], &staged] { + let written = root.write_in_place("to-file", content).await.unwrap(); + assert_eq!(written.hash, *blake3::hash(content).as_bytes()); + assert_eq!(std::fs::read(base.join("file")).unwrap(), content); + assert_eq!(std::fs::metadata(base.join("file")).unwrap().ino(), inode); + assert!( + std::fs::symlink_metadata(base.join("to-file")) + .unwrap() + .file_type() + .is_symlink() + ); + let error = root.write_in_place("dir", content).await.unwrap_err(); + assert!(error.is_conflict(), "{error:?}"); + assert_eq!(named(error), pair("EISDIR", "open")); + assert_eq!( + named( + root.write_in_place("missing/file", content) + .await + .unwrap_err() + ), + pair("ENOENT", "open") + ); + // A new file is made where it is named. + let _ = std::fs::remove_file(base.join("fresh")); + root.write_in_place("fresh", content).await.unwrap(); + assert_eq!(std::fs::read(base.join("fresh")).unwrap(), content); + } assert_eq!( named(root.read("missing").await.unwrap_err()), @@ -1465,12 +1475,17 @@ exec sleep 600"#; std::fs::canonicalize(String::from_utf8(cwd).unwrap()).unwrap(), std::fs::canonicalize(directory.path()).unwrap() ); - // Nothing else starts: waiting for the next command finds none. + // Nothing else starts: waiting for the next command times out (TIMEOUT + // since #54; NOT_FOUND is for an exited terminal or an evicted index). let waited = client .wait_terminal_command(id, None, Duration::from_millis(200)) .await .unwrap_err(); - assert!(waited.is_not_found(), "{waited:?}"); + assert_eq!( + waited.status(), + Some(yas_client::wire::core::Status::Timeout), + "{waited:?}" + ); client.close_terminal(id).await.unwrap(); } diff --git a/crates/client/src/fs.rs b/crates/client/src/fs.rs index 3d579944..2062ccc1 100644 --- a/crates/client/src/fs.rs +++ b/crates/client/src/fs.rs @@ -208,6 +208,16 @@ impl Client { .and_then(|limits| wire::Limits::from_extensions(&limits).ok()) .map_or(0, |limits| limits.capabilities) } + + /// The most content one inline FS write carries on this server (`MAX_INLINE_BYTES`, as it + /// advertises it): 0 before the FS family is negotiated. + fn fs_inline_bytes(&self) -> usize { + self.family_limits(family::FS) + .and_then(|limits| wire::Limits::from_extensions(&limits).ok()) + .map_or(0, |limits| { + (limits.max_inline_bytes as usize).min(wire::MAX_INLINE_BYTES) + }) + } } /// File content read by [`FsRoot::read`] and friends. @@ -797,6 +807,7 @@ impl FsRoot { create_parents: options.create_parents, mode: options.mode, content: content.to_vec(), + in_place: false, }) .await; } @@ -819,11 +830,13 @@ impl FsRoot { .await } - /// Write a whole file as `open(2)` with `O_WRONLY|O_CREAT|O_TRUNC` and `write(2)` would - /// (`STAGE_IN_PLACE`), as Node's `writeFile` does: through a final symlink, an existing - /// file keeping its inode, owner and mode, a new one mode 0666 less the server's umask. Not - /// atomic: a failure can leave the file truncated. Needs `CAPABILITY_STAGE_IN_PLACE`; a - /// failure carries its [`os_error`]. + /// Write a whole file as `open(2)` with `O_WRONLY|O_CREAT|O_TRUNC` and `write(2)` would, + /// as Node's `writeFile` does: through a final symlink, an existing file keeping its inode, + /// owner and mode, a new one mode 0666 less the server's umask. Not atomic: a failure can + /// leave the file truncated. Content up to the server's inline limit goes in one `APPLY` + /// (one round trip) where it offers `CAPABILITY_APPLY_IN_PLACE`; other content is staged + /// (`STAGE_IN_PLACE`, needing `CAPABILITY_STAGE_IN_PLACE`; two round trips). A failure + /// carries its [`os_error`] either way. pub async fn write_in_place(&self, path: &str, content: &[u8]) -> Result { if content.len() as u64 > MAX_FILE_BYTES { return Err(Error::invalid(format!( @@ -831,6 +844,20 @@ impl FsRoot { content.len() ))); } + if content.len() <= self.client.fs_inline_bytes() + && self.client.fs_capabilities() & schema::CAPABILITY_APPLY_IN_PLACE as u32 != 0 + { + return self + .apply_one(ApplyItem::WriteInline { + path: wire_path(path)?, + precondition: Precondition::Any, + create_parents: false, + mode: 0, + content: content.to_vec(), + in_place: true, + }) + .await; + } self.stage_and_commit( wire_path(path)?, content, diff --git a/crates/client/src/terminal.rs b/crates/client/src/terminal.rs index 72bca5d1..d0317995 100644 --- a/crates/client/src/terminal.rs +++ b/crates/client/src/terminal.rs @@ -381,7 +381,8 @@ impl Client { /// one with this journal `index`, or the one running now (else the next /// one to start). Answers its journal record (exit code, times, command /// line). When it still runs at the timeout: a `Timeout` error; when - /// none started by then (or it left the backlog): a `NotFound` status. + /// none started by then: a `Timeout` status; when it left the backlog, + /// or the terminal exited: a `NotFound` status. /// Needs shell integration. pub async fn wait_terminal_command( &self, diff --git a/crates/server/src/yas_fs.rs b/crates/server/src/yas_fs.rs index e5ccfdaa..cfc3a0e8 100644 --- a/crates/server/src/yas_fs.rs +++ b/crates/server/src/yas_fs.rs @@ -2536,18 +2536,67 @@ fn in_place_destination(root: &Root, target: &OsPath) -> Result })) } -/// COMMIT of a `STAGE_IN_PLACE` stage: open(2) the target write-only with -/// O_CREAT|O_TRUNC through a final symlink and write the bytes, as Node's -/// writeFile does. The file keeps its inode, owner and mode; no temporary -/// file, no rename, so a failure can leave it truncated or partly written. +/// COMMIT of a `STAGE_IN_PLACE` stage: its staged bytes written in place. fn commit_in_place( stage: &Stage, target: &OsPath, operation_id: [u8; 16], flags: u16, ) -> Result { - let destination = in_place_destination(&stage.root, target)?; - let is_directory = || synthetic_os(conflict_for(&stage.root, &stage.path), EISDIR, "open"); + let mut source = File::open(&stage.temp_path).map_err(os_io("open"))?; + let written = write_in_place( + &stage.root, + &stage.path, + target, + InPlace { + mode: stage.mode, + content_hash: stage.content_hash, + operation_id, + flags, + }, + |file| io::copy(&mut source, file).map(|_| ()), + )?; + Ok(wire::CommitResult { + root_revision: stage.root.revision.load(Ordering::Acquire).max(1), + entry_revision: written.entry_revision, + modified_unix_ns: written.modified_unix_ns, + content_hash: stage.content_hash, + }) +} + +/// How an in-place write goes: the mode a file it creates gets (0: 0o666), +/// the content's hash, the operation marking the entry, and COMMIT flags +/// (`COMMIT_SYNC_DATA`, `COMMIT_SYNC_DIRECTORY`; APPLY gives none). +struct InPlace { + /// Read on Unix alone: a file Windows creates takes its directory's ACL. + #[cfg_attr(not(unix), allow(dead_code))] + mode: u32, + content_hash: [u8; 32], + operation_id: [u8; 16], + flags: u16, +} + +/// What an in-place write reports: the entry written (through a symlink +/// inside the root, the link's destination) and the file's modification time. +struct WrittenInPlace { + entry_revision: u64, + modified_unix_ns: i64, +} + +/// An in-place write (COMMIT of a `STAGE_IN_PLACE` stage, APPLY WRITE_INLINE +/// with `APPLY_ITEM_IN_PLACE`): open(2) `target` write-only with +/// O_CREAT|O_TRUNC through a final symlink and `write` the bytes, as Node's +/// writeFile does. The file keeps its inode, owner and mode; no temporary +/// file, no rename, so a failure can leave it truncated or partly written. +fn write_in_place( + root: &Root, + path: &wire::Path, + target: &OsPath, + how: InPlace, + write: impl FnOnce(&mut File) -> io::Result<()>, +) -> Result { + let destination = in_place_destination(root, target)?; + let is_directory = || synthetic_os(conflict_for(root, path), EISDIR, "open"); if fs::metadata(&destination).is_ok_and(|metadata| metadata.is_dir()) { return Err(is_directory()); } @@ -2556,7 +2605,7 @@ fn commit_in_place( #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; - options.mode(if stage.mode != 0 { stage.mode } else { 0o666 }); + options.mode(if how.mode != 0 { how.mode } else { 0o666 }); } let mut file = match options.open(target) { Ok(file) => file, @@ -2567,34 +2616,32 @@ fn commit_in_place( } Err(error) => return Err(os_io("open")(error)), }; - let mut source = File::open(&stage.temp_path).map_err(os_io("open"))?; - io::copy(&mut source, &mut file).map_err(os_io("write"))?; + write(&mut file).map_err(os_io("write"))?; file.flush().map_err(os_io("write"))?; - if flags & schema::fs::COMMIT_SYNC_DATA as u16 != 0 { + if how.flags & schema::fs::COMMIT_SYNC_DATA as u16 != 0 { file.sync_all().map_err(os_io("fsync"))?; } drop(file); - if flags & schema::fs::COMMIT_SYNC_DIRECTORY as u16 != 0 + if how.flags & schema::fs::COMMIT_SYNC_DIRECTORY as u16 != 0 && let Some(parent) = destination.parent() { sync_directory(parent)?; } // Report the file written: through a symlink inside the root that is // the link's destination entry, not the link. - let written = if stage.root.single_file { - stage.path.clone() + let written = if root.single_file { + path.clone() } else { - relative_path(&stage.root, &destination).unwrap_or_else(|_| stage.path.clone()) + relative_path(root, &destination).unwrap_or_else(|_| path.clone()) }; - mark_operation(&stage.root, &written, operation_id); + mark_operation(root, &written, how.operation_id); // The bytes are known and verified, so nothing is read back: a // write-only file answers as Node's writeFile would. let metadata = fs::metadata(&destination).map_err(os_io("stat"))?; - let named = - fs::symlink_metadata(joined_path(&stage.root, &written)?).map_err(os_io("lstat"))?; + let named = fs::symlink_metadata(joined_path(root, &written)?).map_err(os_io("lstat"))?; let entry_revision = if named.is_file() { observe_entry( - &stage.root, + root, wire::EntryRecord { path: written.clone(), entry_revision: 1, @@ -2603,7 +2650,7 @@ fn commit_in_place( modified_unix_ns: metadata_time(&named), body: wire::EntryBody::File { byte_len: named.len(), - content_hash: stage.content_hash, + content_hash: how.content_hash, inline_content: None, }, extensions: Extensions::default(), @@ -2612,13 +2659,11 @@ fn commit_in_place( .entry_revision } else { // A single-file root that is itself a symlink: the link's entry. - stat_entry(&stage.root, &written, 0, false)?.entry_revision + stat_entry(root, &written, 0, false)?.entry_revision }; - Ok(wire::CommitResult { - root_revision: stage.root.revision.load(Ordering::Acquire).max(1), + Ok(WrittenInPlace { entry_revision, modified_unix_ns: metadata_time(&metadata), - content_hash: stage.content_hash, }) } @@ -2632,8 +2677,9 @@ fn apply_items( let mut os_errors = Vec::new(); for (index, item) in items.iter().enumerate() { let outcome = apply_one(root, operation_id, item); + // A conflict carries one only where a check stood for a system call + // (an in-place write onto a directory is EISDIR, as COMMIT's). if let Err(error) = &outcome - && !matches!(error.kind(), Error::Conflict(_)) && let Some(os) = error.os_error() { os_errors.push((index as u16, os.clone())); @@ -2686,12 +2732,53 @@ fn apply_one( item: &wire::ApplyItem, ) -> Result { match item { + wire::ApplyItem::WriteInline { + path, + precondition, + create_parents: false, + mode, + content, + in_place: true, + } => { + check_precondition(root, path, precondition)?; + let target = mutation_target(root, path, false, "open")?; + let content_hash = *blake3::hash(content).as_bytes(); + let written = write_in_place( + root, + path, + &target, + InPlace { + mode: *mode, + content_hash, + operation_id, + flags: 0, + }, + |file| file.write_all(content), + )?; + Ok(wire::EntryRecord { + path: path.clone(), + entry_revision: written.entry_revision, + flags: 0, + mode: 0, + modified_unix_ns: written.modified_unix_ns, + body: wire::EntryBody::File { + byte_len: content.len() as u64, + content_hash, + inline_content: None, + }, + extensions: Extensions::default(), + }) + } + wire::ApplyItem::WriteInline { in_place: true, .. } => { + Err(Error::Invalid("FS APPLY in-place write creating parents")) + } wire::ApplyItem::WriteInline { path, precondition, create_parents, mode, content, + in_place: false, } => { check_precondition(root, path, precondition)?; atomic_write(root, path, *create_parents, *mode, content)?; @@ -3287,6 +3374,7 @@ mod tests { create_parents: false, mode: 0, content: b"one".to_vec(), + in_place: false, }, wire::ApplyItem::Rename { from: path(&[b"a"]), @@ -3826,9 +3914,108 @@ mod tests { } } + /// An in-place write of `bytes` to `components` with `mode` under + /// `operation_id`, answering the content hash it reports. + #[cfg(unix)] + type WriteInPlace = + dyn Fn(&Arc, &[&[u8]], u32, &[u8], [u8; 16]) -> Result<[u8; 32], Error>; + #[cfg(unix)] #[test] fn stage_in_place_keeps_the_inode_and_writes_through_symlinks() { + in_place_keeps_the_inode_and_writes_through_symlinks( + &|root, components, mode, bytes, id| { + let stage = sealed_stage(root, components, mode, bytes); + commit_stage(stage, id, schema::fs::COMMIT_SYNC_DATA as u16) + .map(|result| result.content_hash) + }, + ); + } + + #[cfg(unix)] + #[test] + fn apply_in_place_writes_as_commit_in_place_does() { + in_place_keeps_the_inode_and_writes_through_symlinks( + &|root, components, mode, bytes, id| { + let item = wire::ApplyItem::WriteInline { + path: path(components), + precondition: wire::Precondition::Any, + create_parents: false, + mode, + content: bytes.to_vec(), + in_place: true, + }; + let entry = apply_one(root, id, &item)?; + assert_eq!(entry.path, path(components)); + Ok(entry_hash(&entry).unwrap()) + }, + ); + } + + #[cfg(unix)] + #[test] + fn an_apply_in_place_onto_a_directory_is_a_conflict_saying_eisdir() { + let directory = TestDir::new(); + let root = test_root(&directory); + fs::create_dir(directory.0.join("dir")).unwrap(); + fs::write(directory.0.join("file"), b"old").unwrap(); + let write = |name: &[u8], in_place| wire::ApplyItem::WriteInline { + path: path(&[name]), + precondition: wire::Precondition::Any, + create_parents: false, + mode: 0, + content: b"x".to_vec(), + in_place, + }; + let result = apply_items( + &root, + [9; 16], + &[ + write(b"file", true), + write(b"dir", true), + write(b"dir", false), + ], + ) + .unwrap(); + let statuses: Vec = result.items.iter().map(|item| item.status).collect(); + assert_eq!( + statuses, + [ + schema::core::status::OK, + schema::core::status::CONFLICT, + schema::core::status::CONFLICT + ] + ); + assert_eq!(fs::read(directory.0.join("file")).unwrap(), b"x"); + // The in-place write says EISDIR, as COMMIT's; the replacing write + // keeps its bare conflict. + let os_errors = result.os_errors().unwrap(); + assert_eq!(os_errors.len(), 1); + assert_eq!(os_errors[0].0, 1); + assert_eq!( + ( + os_errors[0].1.name.as_str(), + os_errors[0].1.operation.as_str() + ), + ("EISDIR", "open") + ); + // Creating parents is never in place. + let item = wire::ApplyItem::WriteInline { + path: path(&[b"new", b"file"]), + precondition: wire::Precondition::Any, + create_parents: true, + mode: 0, + content: b"x".to_vec(), + in_place: true, + }; + assert!(matches!( + apply_one(&root, [10; 16], &item).unwrap_err().kind(), + Error::Invalid(_) + )); + } + + #[cfg(unix)] + fn in_place_keeps_the_inode_and_writes_through_symlinks(write: &WriteInPlace) { use std::os::unix::fs::{MetadataExt, PermissionsExt, symlink}; let directory = TestDir::new(); let root = Arc::new(test_root(&directory)); @@ -3838,8 +4025,7 @@ mod tests { let inode = fs::metadata(&target).unwrap().ino(); symlink("target.txt", directory.0.join("link")).unwrap(); - let stage = sealed_stage(&root, &[b"link"], 0o600, b"new"); - let result = commit_stage(stage, [4; 16], schema::fs::COMMIT_SYNC_DATA as u16).unwrap(); + let hash = write(&root, &[b"link"], 0o600, b"new", [4; 16]).unwrap(); assert_eq!(fs::read(&target).unwrap(), b"new"); let metadata = fs::metadata(&target).unwrap(); assert_eq!(metadata.ino(), inode); @@ -3850,7 +4036,7 @@ mod tests { .file_type() .is_symlink() ); - assert_eq!(result.content_hash, *blake3::hash(b"new").as_bytes()); + assert_eq!(hash, *blake3::hash(b"new").as_bytes()); assert_eq!( root.operation_echoes .lock() @@ -3860,13 +4046,11 @@ mod tests { ); // A new file is created where it is named, without a temporary. - let stage = sealed_stage(&root, &[b"fresh"], 0, b"fresh bytes"); - commit_stage(stage, [5; 16], 0).unwrap(); + write(&root, &[b"fresh"], 0, b"fresh bytes", [5; 16]).unwrap(); assert_eq!(fs::read(directory.0.join("fresh")).unwrap(), b"fresh bytes"); // Through a dangling symlink, the file it names is created. symlink("made-by-link", directory.0.join("pending")).unwrap(); - let stage = sealed_stage(&root, &[b"pending"], 0, b"via link"); - commit_stage(stage, [6; 16], 0).unwrap(); + write(&root, &[b"pending"], 0, b"via link", [6; 16]).unwrap(); assert_eq!( fs::read(directory.0.join("made-by-link")).unwrap(), b"via link" @@ -3876,13 +4060,8 @@ mod tests { let write_only = directory.0.join("write-only"); fs::write(&write_only, b"previous").unwrap(); fs::set_permissions(&write_only, fs::Permissions::from_mode(0o200)).unwrap(); - let result = commit_stage( - sealed_stage(&root, &[b"write-only"], 0, b"blind"), - [11; 16], - 0, - ) - .unwrap(); - assert_eq!(result.content_hash, *blake3::hash(b"blind").as_bytes()); + let hash = write(&root, &[b"write-only"], 0, b"blind", [11; 16]).unwrap(); + assert_eq!(hash, *blake3::hash(b"blind").as_bytes()); let metadata = fs::metadata(&write_only).unwrap(); assert_eq!( (metadata.permissions().mode() & 0o7777, metadata.len()), @@ -3894,17 +4073,14 @@ mod tests { fs::create_dir(directory.0.join("dir")).unwrap(); symlink("dir", directory.0.join("dir-link")).unwrap(); for components in [&[&b"dir"[..]][..], &[b"dir-link"]] { - let stage = sealed_stage(&root, components, 0, b"x"); - let error = commit_stage(stage, [7; 16], 0).unwrap_err(); + let error = write(&root, components, 0, b"x", [7; 16]).unwrap_err(); assert!(matches!(error.kind(), Error::Conflict(_))); assert_eq!(os_of(&error), (libc::EISDIR, "EISDIR", "open")); } - let stage = sealed_stage(&root, &[b"nope", b"file"], 0, b"x"); - let error = commit_stage(stage, [8; 16], 0).unwrap_err(); + let error = write(&root, &[b"nope", b"file"], 0, b"x", [8; 16]).unwrap_err(); assert_eq!(*error.kind(), Error::NotFound); assert_eq!(os_of(&error), (libc::ENOENT, "ENOENT", "open")); - let stage = sealed_stage(&root, &[b"target.txt", b"file"], 0, b"x"); - let error = commit_stage(stage, [10; 16], 0).unwrap_err(); + let error = write(&root, &[b"target.txt", b"file"], 0, b"x", [10; 16]).unwrap_err(); assert_eq!(os_of(&error), (libc::ENOTDIR, "ENOTDIR", "open")); } } diff --git a/crates/yas/schema_codegen.rs b/crates/yas/schema_codegen.rs index 0a952e24..13527f14 100644 --- a/crates/yas/schema_codegen.rs +++ b/crates/yas/schema_codegen.rs @@ -2511,6 +2511,28 @@ fn vectors(artifact: &Artifact) -> VectorArtifact { push_u32(&mut fs_apply, 0); push_vector(&mut vectors, "fs.apply.payload", &fs_apply); + let mut fs_apply_in_place = Vec::new(); + push_u64(&mut fs_apply_in_place, 1); + fs_apply_in_place.extend_from_slice(&[5; 16]); + push_u16(&mut fs_apply_in_place, 0); + push_u16(&mut fs_apply_in_place, 1); + push_u32(&mut fs_apply_in_place, 4 + fs_apply_item_body.len() as u32); + push_u16( + &mut fs_apply_in_place, + family_constant(artifact, "yas.fs", "APPLY_WRITE_INLINE") as u16, + ); + push_u16( + &mut fs_apply_in_place, + family_constant(artifact, "yas.fs", "APPLY_ITEM_IN_PLACE") as u16, + ); + fs_apply_in_place.extend_from_slice(&fs_apply_item_body); + push_u32(&mut fs_apply_in_place, 0); + push_vector( + &mut vectors, + "fs.apply.in_place.payload", + &fs_apply_in_place, + ); + let mut fs_entry = Vec::new(); push_bytes_u32(&mut fs_entry, &fs_path_a); push_u64(&mut fs_entry, 1); diff --git a/crates/yas/src/fs.rs b/crates/yas/src/fs.rs index c942e0cb..5c31887f 100644 --- a/crates/yas/src/fs.rs +++ b/crates/yas/src/fs.rs @@ -2528,6 +2528,10 @@ pub enum ApplyItem { create_parents: bool, mode: u32, content: Vec, + /// `APPLY_ITEM_IN_PLACE` (with `CAPABILITY_APPLY_IN_PLACE`): write as `open(2)` with + /// `O_WRONLY|O_CREAT|O_TRUNC` would, through a final symlink, rather than replace the + /// file; never with `create_parents`. + in_place: bool, }, Mkdir { path: Path, @@ -2569,8 +2573,12 @@ impl ApplyItem { precondition, mode, content, - .. + create_parents, + in_place, } => { + if *in_place && *create_parents { + return Err(Error::Invalid("FS APPLY in-place write creating parents")); + } if content.len() > MAX_INLINE_BYTES { return Err(limit( "FS inline apply bytes", @@ -2668,16 +2676,16 @@ impl ApplyItem { let len = 4usize .checked_add(body.len()) .ok_or(Error::LengthOverflow)?; + let mut item_flags = 0; + if create_parents { + item_flags |= crate::schema::fs::APPLY_ITEM_CREATE_PARENTS as u16; + } + if matches!(self, Self::WriteInline { in_place: true, .. }) { + item_flags |= crate::schema::fs::APPLY_ITEM_IN_PLACE as u16; + } put_len_u32(out, len)?; put_u16(out, kind); - put_u16( - out, - if create_parents { - crate::schema::fs::APPLY_ITEM_CREATE_PARENTS as u16 - } else { - 0 - }, - ); + put_u16(out, item_flags); out.extend_from_slice(&body); Ok(()) } @@ -2687,10 +2695,18 @@ impl ApplyItem { let mut item = Decoder::new(bytes); let kind = item.u16()?; let item_flags = item.u16()?; - if item_flags & !(crate::schema::fs::APPLY_ITEM_FLAGS as u16) != 0 { + let known = + crate::schema::fs::APPLY_ITEM_FLAGS | crate::schema::fs::APPLY_ITEM_EXTENDED_FLAGS; + if item_flags & !(known as u16) != 0 { return Err(Error::Invalid("FS APPLY item flags")); } let create_parents = item_flags & crate::schema::fs::APPLY_ITEM_CREATE_PARENTS as u16 != 0; + let in_place = item_flags & crate::schema::fs::APPLY_ITEM_IN_PLACE as u16 != 0; + if in_place && kind != crate::schema::fs::APPLY_WRITE_INLINE as u16 { + return Err(Error::Invalid( + "FS APPLY in-place flag on an item that writes no file", + )); + } let value = match kind { value if value == crate::schema::fs::APPLY_WRITE_INLINE as u16 => Self::WriteInline { path: Path::decode(item.len_bytes_u32()?)?, @@ -2698,6 +2714,7 @@ impl ApplyItem { create_parents, mode: item.u32()?, content: item.len_bytes_u32()?.to_vec(), + in_place, }, value if value == crate::schema::fs::APPLY_MKDIR as u16 => Self::Mkdir { path: Path::decode(item.len_bytes_u32()?)?, @@ -3269,12 +3286,59 @@ mod tests { create_parents: true, mode: 0o644, content: b"yas".to_vec(), + in_place: false, }], extensions: Extensions::default(), }; truncations::(&apply.encode().unwrap()); } + #[test] + fn an_in_place_apply_write_is_a_write_inline_flag_without_create_parents() { + let write = |create_parents, in_place| Apply { + root_handle: 1, + operation_id: [3; 16], + flags: 0, + items: vec![ApplyItem::WriteInline { + path: path(b"file"), + precondition: Precondition::Any, + create_parents, + mode: 0, + content: b"yas".to_vec(), + in_place, + }], + extensions: Extensions::default(), + }; + let apply = write(false, true); + let bytes = apply.encode().unwrap(); + assert_eq!(Apply::decode(&bytes).unwrap(), apply); + truncations::(&bytes); + // The item's flags follow the root handle, operation ID, flags, count and item length. + let flags_at = 8 + 16 + 2 + 2 + 4 + 2; + assert_eq!( + u16::from_le_bytes([bytes[flags_at], bytes[flags_at + 1]]), + crate::schema::fs::APPLY_ITEM_IN_PLACE as u16 + ); + assert!(write(true, true).encode().is_err()); + let mut both = bytes.clone(); + both[flags_at] |= crate::schema::fs::APPLY_ITEM_CREATE_PARENTS as u8; + assert!(Apply::decode(&both).is_err()); + // Only a write may be in place. + let mkdir = Apply { + items: vec![ApplyItem::Mkdir { + path: path(b"dir"), + precondition: Precondition::Any, + create_parents: false, + mode: 0, + }], + ..apply + }; + let mut bytes = mkdir.encode().unwrap(); + assert_eq!(Apply::decode(&bytes).unwrap(), mkdir); + bytes[flags_at] |= crate::schema::fs::APPLY_ITEM_IN_PLACE as u8; + assert!(Apply::decode(&bytes).is_err()); + } + #[test] fn capabilities_are_an_optional_forward_compatible_limit() { assert_eq!( diff --git a/crates/yas/src/generated.rs b/crates/yas/src/generated.rs index 97a3e8bd..a3b9caf4 100644 --- a/crates/yas/src/generated.rs +++ b/crates/yas/src/generated.rs @@ -2346,6 +2346,8 @@ pub const APPLY_SYMLINK: u64 = 4; pub const APPLY_HARDLINK: u64 = 5; pub const APPLY_ITEM_CREATE_PARENTS: u64 = 1; pub const APPLY_ITEM_FLAGS: u64 = 1; +pub const APPLY_ITEM_IN_PLACE: u64 = 2; +pub const APPLY_ITEM_EXTENDED_FLAGS: u64 = 2; pub const REMOVE_RECURSIVE: u64 = 1; pub const REMOVE_FLAGS: u64 = 1; pub const FILE_CONTENT_KIND: u64 = 0; @@ -2388,7 +2390,8 @@ pub const CAPABILITY_READ_LIST: u64 = 2; pub const CAPABILITY_READ_REALPATH: u64 = 4; pub const CAPABILITY_READ_STAT_ONLY: u64 = 8; pub const CAPABILITY_STAGE_IN_PLACE: u64 = 16; -pub const CAPABILITY_FLAGS: u64 = 31; +pub const CAPABILITY_APPLY_IN_PLACE: u64 = 32; +pub const CAPABILITY_FLAGS: u64 = 63; pub static OPERATIONS: &[super::OperationMetadata] = &[ super::OperationMetadata { name: "OPEN", class: 1, kind: 0, direction: 0, sensitive: 1, compression: 0, datagram: 0, layout: "flags:u16,reserved:u16=0,source:bytes_u32 containing RootSource,Extensions; ResultPrefix + root_handle:u64,root_revision:u64,path_model:u8,case_behavior:u8,reserved:u16=0,canonical_path:bytes_u32,Extensions" }, super::OperationMetadata { name: "CLOSE", class: 1, kind: 1, direction: 0, sensitive: 1, compression: 0, datagram: 0, layout: "root_handle:u64,Extensions; ResultPrefix; idempotent and invalidates root-scoped watches/stages" }, @@ -2432,6 +2435,7 @@ super::TypeMetadata { name: "query_read_extended_record", layout: "QueryReadReco super::TypeMetadata { name: "query_list_entries", layout: "repeated kind:u8,name:bytes_u16; one directory level without dot and dot-dot, hidden names included, in no defined order; kind ENTRY_FILE, ENTRY_DIRECTORY, ENTRY_SYMLINK or ENTRY_OTHER describes the entry itself, so a symlink to a directory is ENTRY_SYMLINK; name is one nonempty raw platform-name component without NUL or slash" }, super::TypeMetadata { name: "query_stat_only", layout: "kind:u8,reserved:u8=0,reserved:u16=0,mode:u32,size:u64,modified_unix_ns:i64; kind ENTRY_FILE, ENTRY_DIRECTORY, ENTRY_SYMLINK or ENTRY_OTHER; follows the final symlink unless READ_NO_FOLLOW; no content is read or hashed" }, super::TypeMetadata { name: "stage_in_place", layout: "STAGE_WRITE flag STAGE_IN_PLACE: COMMIT opens the target write-only with create and truncate, following a final symlink, writes the staged bytes and optionally syncs them; an existing file keeps its inode, owner and mode; a new file gets mode, or 0o666 when mode is zero, less the server umask; no temporary file and no rename; STAGE_CREATE_PARENTS with STAGE_IN_PLACE is INVALID" }, +super::TypeMetadata { name: "apply_in_place", layout: "APPLY WRITE_INLINE item flag APPLY_ITEM_IN_PLACE, offered with CAPABILITY_APPLY_IN_PLACE: the item writes its content as COMMIT of a STAGE_IN_PLACE stage writes, opening the target write-only with create and truncate, following a final symlink, with no temporary file and no rename; an existing file keeps its inode, owner and mode; a new file gets mode, or 0o666 when mode is zero, less the server umask; the item result describes the file written; a directory at the destination is CONFLICT whose ApplyOsErrors entry is EISDIR open; APPLY_ITEM_CREATE_PARENTS with APPLY_ITEM_IN_PLACE is INVALID" }, ]; pub static LIMITS: &[super::LimitMetadata] = &[ super::LimitMetadata { name: "MAX_ROOTS_PER_SESSION", tag: 1, value_type: super::LimitValueType::U32, required: true, hard_min: 1, hard_max: 64 }, @@ -2550,6 +2554,8 @@ super::ConstantMetadata { name: "APPLY_SYMLINK", value: 4 }, super::ConstantMetadata { name: "APPLY_HARDLINK", value: 5 }, super::ConstantMetadata { name: "APPLY_ITEM_CREATE_PARENTS", value: 1 }, super::ConstantMetadata { name: "APPLY_ITEM_FLAGS", value: 1 }, +super::ConstantMetadata { name: "APPLY_ITEM_IN_PLACE", value: 2 }, +super::ConstantMetadata { name: "APPLY_ITEM_EXTENDED_FLAGS", value: 2 }, super::ConstantMetadata { name: "REMOVE_RECURSIVE", value: 1 }, super::ConstantMetadata { name: "REMOVE_FLAGS", value: 1 }, super::ConstantMetadata { name: "FILE_CONTENT_KIND", value: 0 }, @@ -2592,7 +2598,8 @@ super::ConstantMetadata { name: "CAPABILITY_READ_LIST", value: 2 }, super::ConstantMetadata { name: "CAPABILITY_READ_REALPATH", value: 4 }, super::ConstantMetadata { name: "CAPABILITY_READ_STAT_ONLY", value: 8 }, super::ConstantMetadata { name: "CAPABILITY_STAGE_IN_PLACE", value: 16 }, -super::ConstantMetadata { name: "CAPABILITY_FLAGS", value: 31 }, +super::ConstantMetadata { name: "CAPABILITY_APPLY_IN_PLACE", value: 32 }, +super::ConstantMetadata { name: "CAPABILITY_FLAGS", value: 63 }, ]; } pub mod git { @@ -5160,6 +5167,7 @@ GoldenVector { name: "fs.grep.payload", hex: "0100000000000000020014000500000009 GoldenVector { name: "fs.stage_write.payload", hex: "0100000000000000050000000100010061040000000100000001000000a401000003000000000000000303030303030303030303030303030303030303030303030303030303030303000400000000000000000000" }, GoldenVector { name: "fs.commit.payload", hex: "0200000000000000040404040404040404040404040404040100000000000000" }, GoldenVector { name: "fs.apply.payload", hex: "0100000000000000050505050505050505050505050505050100010020000000000000000500000001000100610400000001000000a40100000300000079617300000000" }, +GoldenVector { name: "fs.apply.in_place.payload", hex: "0100000000000000050505050505050505050505050505050000010020000000000002000500000001000100610400000001000000a40100000300000079617300000000" }, GoldenVector { name: "fs.entry.inline.payload", hex: "050000000100010061010000000000000000000000a4010000010000000000000003000000000000000606060606060606060606060606060606060606060606060606060606060606010000000300000079617300000000" }, GoldenVector { name: "fs.query.path_record.payload", hex: "05000000010001006100000000" }, GoldenVector { name: "fs.query.read_record.payload", hex: "000000000100000005000000010001006103000000796173" }, diff --git a/crates/yas/src/lib.rs b/crates/yas/src/lib.rs index 3e5925dc..18ede38f 100644 --- a/crates/yas/src/lib.rs +++ b/crates/yas/src/lib.rs @@ -541,6 +541,9 @@ mod generated_artifact_tests { ("fs.apply.payload", true, |bytes| { fs::Apply::decode(bytes).map(|_| ()) }), + ("fs.apply.in_place.payload", true, |bytes| { + fs::Apply::decode(bytes).map(|_| ()) + }), ("fs.entry.inline.payload", true, |bytes| { fs::EntryRecord::decode(bytes).map(|_| ()) }), diff --git a/docs/design/fs-write.md b/docs/design/fs-write.md index 80e6adab..f55f2fab 100644 --- a/docs/design/fs-write.md +++ b/docs/design/fs-write.md @@ -142,6 +142,16 @@ destination is `CONFLICT` carrying OsError `{EISDIR, open}`. Other failures carry OsError with operation `open` (resolving or opening the target) or `write`. `STAGE_CREATE_PARENTS` with `STAGE_IN_PLACE` is `INVALID`. +Content that fits inline can be written in place in one round trip rather than +two: an APPLY `WRITE_INLINE` item with item flag `APPLY_ITEM_IN_PLACE` (2), +advertised by `CAPABILITY_APPLY_IN_PLACE`, writes exactly as COMMIT of an +in-place stage does, with no sync flags. Its item result describes the file +written, and a directory at the destination is `CONFLICT` whose +ApplyOsErrors entry is `{EISDIR, open}`. The flag is `INVALID` on other +item kinds and with `APPLY_ITEM_CREATE_PARENTS`. yas-client's +`write_in_place` sends it for content within the server's inline limit and +stages the rest. + ## OS error detail Every failed top-level FS Result caused by an OS error carries the optional diff --git a/docs/design/yas.md b/docs/design/yas.md index 8ce0bc6e..0ac6e4f1 100644 --- a/docs/design/yas.md +++ b/docs/design/yas.md @@ -2861,11 +2861,11 @@ extensions remain forbidden by Core. Optional family limit `CAPABILITIES` (tag `LIMIT_CAPABILITIES` = 14, `u32`) advertises the opt-in values a server implements: `CAPABILITY_OS_ERROR` (1), `CAPABILITY_READ_LIST` (2), `CAPABILITY_READ_REALPATH` (4), -`CAPABILITY_READ_STAT_ONLY` (8), and `CAPABILITY_STAGE_IN_PLACE` (16); -`CAPABILITY_FLAGS` (31) is their union. Absent means zero and receivers ignore -unknown bits. A client sends an opt-in question kind or flag only when its bit -is set; an older server rejects them as INVALID. Nothing changes for a client -that sends none of them. +`CAPABILITY_READ_STAT_ONLY` (8), `CAPABILITY_STAGE_IN_PLACE` (16), and +`CAPABILITY_APPLY_IN_PLACE` (32); `CAPABILITY_FLAGS` (63) is their union. +Absent means zero and receivers ignore unknown bits. A client sends an opt-in +question kind or flag only when its bit is set; an older server rejects them as +INVALID. Nothing changes for a client that sends none of them. A failed top-level FS Result whose failure came from an OS error adds optional ResultPrefix `detail` tag `RESULT_OS_ERROR_EXTENSION` (2), whose exact value is @@ -2924,6 +2924,15 @@ destination entry when that lies inside the root. STAGE_CREATE_PARENTS with STAGE_IN_PLACE is INVALID. `STAGE_EXTENDED_FLAGS` (2) lists the stage flags added after the v1 baseline `STAGE_FLAGS`. +APPLY item flag `APPLY_ITEM_IN_PLACE` (2) on a `WRITE_INLINE` item writes +its content in place in the same way, in one round trip: open with create and +truncate through a final symlink, and no temporary file or rename. APPLY +carries no sync flags. The item result's revision, time, and hash describe the +file written. A directory at the destination is CONFLICT, and its +ApplyOsErrors entry is `{EISDIR, open}`. The flag on another item kind, or +with `APPLY_ITEM_CREATE_PARENTS`, is INVALID. `APPLY_ITEM_EXTENDED_FLAGS` +(2) lists the item flags added after the v1 baseline `APPLY_ITEM_FLAGS`. + ## Git family Git is family `0x0031`, version 1. It preserves the useful split between small diff --git a/js/core/src/__tests__/yas.test.ts b/js/core/src/__tests__/yas.test.ts index d088a185..1735fa6b 100644 --- a/js/core/src/__tests__/yas.test.ts +++ b/js/core/src/__tests__/yas.test.ts @@ -1752,6 +1752,15 @@ describe("YAS v1", () => { matches("fs.conflict_detail.payload", encodeFsConflictDetail(fsConflict)); const fsApply = decodeFsApply(fromHex(vector("fs.apply.payload"))); matches("fs.apply.payload", encodeFsApply(fsApply)); + const fsApplyInPlace = decodeFsApply( + fromHex(vector("fs.apply.in_place.payload")), + ); + expect(fsApplyInPlace.items[0]).toMatchObject({ + kind: "write-inline", + inPlace: true, + createParents: false, + }); + matches("fs.apply.in_place.payload", encodeFsApply(fsApplyInPlace)); const fsApplyResult = decodeFsApplyResult( fromHex(vector("fs.apply_result.payload")), ); diff --git a/js/core/src/__tests__/yasFs.test.ts b/js/core/src/__tests__/yasFs.test.ts index 0b14afec..b1c9d599 100644 --- a/js/core/src/__tests__/yasFs.test.ts +++ b/js/core/src/__tests__/yasFs.test.ts @@ -133,6 +133,10 @@ const cases: readonly [string, (payload: Uint8Array) => Uint8Array][] = [ (payload) => encodeFsConflictDetail(decodeFsConflictDetail(payload)), ], ["fs.apply.payload", (payload) => encodeFsApply(decodeFsApply(payload))], + [ + "fs.apply.in_place.payload", + (payload) => encodeFsApply(decodeFsApply(payload)), + ], [ "fs.apply_result.payload", (payload) => encodeFsApplyResult(decodeFsApplyResult(payload)), diff --git a/js/core/src/yas/fs.ts b/js/core/src/yas/fs.ts index 29350a69..29b2ee24 100644 --- a/js/core/src/yas/fs.ts +++ b/js/core/src/yas/fs.ts @@ -244,6 +244,11 @@ export type YasFsApplyItem = createParents?: boolean; mode: number; content: Uint8Array; + /** + * APPLY_ITEM_IN_PLACE (offered with CAPABILITY_APPLY_IN_PLACE): write + * through the file as open(2) would; never with createParents. + */ + inPlace?: boolean; } | { kind: "mkdir"; @@ -1637,11 +1642,18 @@ export function decodeFsCommitResult(bytes: Uint8Array): YasFsCommitResult { function encodeFsApplyItem(value: YasFsApplyItem): Uint8Array { const body = new YasWriter(); let kind: number; - const itemFlags = + let itemFlags = value.kind !== "remove" && value.createParents ? g.YAS_FS_APPLY_ITEM_CREATE_PARENTS : 0; if (value.kind === "write-inline") { + if (value.inPlace) { + if (value.createParents) + throw new YasProtocolError( + "FS APPLY in-place write cannot create parents", + ); + itemFlags |= g.YAS_FS_APPLY_ITEM_IN_PLACE; + } if (value.content.length > g.YAS_FS_MAX_INLINE_BYTES) throw new YasProtocolError("FS inline apply content exceeds its limit"); kind = g.YAS_FS_APPLY_WRITE_INLINE; @@ -1708,9 +1720,17 @@ function decodeFsApplyItem(cursor: YasCursor): YasFsApplyItem { const item = new YasCursor(bytes); const kind = item.u16("FS APPLY item kind"); const itemFlags = item.u16("FS APPLY item flags"); - if (itemFlags & ~g.YAS_FS_APPLY_ITEM_FLAGS) + if ( + itemFlags & + ~(g.YAS_FS_APPLY_ITEM_FLAGS | g.YAS_FS_APPLY_ITEM_EXTENDED_FLAGS) + ) throw new YasProtocolError("FS APPLY item flags are invalid"); const createParents = Boolean(itemFlags & g.YAS_FS_APPLY_ITEM_CREATE_PARENTS); + const inPlace = Boolean(itemFlags & g.YAS_FS_APPLY_ITEM_IN_PLACE); + if (inPlace && kind !== g.YAS_FS_APPLY_WRITE_INLINE) + throw new YasProtocolError( + "FS APPLY in-place flag on an item that writes no file", + ); let value: YasFsApplyItem; if (kind === g.YAS_FS_APPLY_WRITE_INLINE) value = { @@ -1720,6 +1740,7 @@ function decodeFsApplyItem(cursor: YasCursor): YasFsApplyItem { createParents, mode: item.u32("FS mode"), content: new Uint8Array(item.bytesU32("FS inline content")), + ...(inPlace ? { inPlace } : {}), }; else if (kind === g.YAS_FS_APPLY_MKDIR) value = { diff --git a/js/core/src/yas/generated.ts b/js/core/src/yas/generated.ts index fc834f0a..1fe93767 100644 --- a/js/core/src/yas/generated.ts +++ b/js/core/src/yas/generated.ts @@ -1076,6 +1076,8 @@ export const YAS_FS_APPLY_SYMLINK = 4 as const; export const YAS_FS_APPLY_HARDLINK = 5 as const; export const YAS_FS_APPLY_ITEM_CREATE_PARENTS = 1 as const; export const YAS_FS_APPLY_ITEM_FLAGS = 1 as const; +export const YAS_FS_APPLY_ITEM_IN_PLACE = 2 as const; +export const YAS_FS_APPLY_ITEM_EXTENDED_FLAGS = 2 as const; export const YAS_FS_REMOVE_RECURSIVE = 1 as const; export const YAS_FS_REMOVE_FLAGS = 1 as const; export const YAS_FS_FILE_CONTENT_KIND = 0 as const; @@ -1118,7 +1120,8 @@ export const YAS_FS_CAPABILITY_READ_LIST = 2 as const; export const YAS_FS_CAPABILITY_READ_REALPATH = 4 as const; export const YAS_FS_CAPABILITY_READ_STAT_ONLY = 8 as const; export const YAS_FS_CAPABILITY_STAGE_IN_PLACE = 16 as const; -export const YAS_FS_CAPABILITY_FLAGS = 31 as const; +export const YAS_FS_CAPABILITY_APPLY_IN_PLACE = 32 as const; +export const YAS_FS_CAPABILITY_FLAGS = 63 as const; export const YAS_FAMILY_GIT = 49 as const; export const YAS_GIT_VERSION = 1 as const; export const YAS_GIT_OPEN = 0 as const; @@ -8145,6 +8148,10 @@ export const YAS_SCHEMA = { { "name": "stage_in_place", "layout": "STAGE_WRITE flag STAGE_IN_PLACE: COMMIT opens the target write-only with create and truncate, following a final symlink, writes the staged bytes and optionally syncs them; an existing file keeps its inode, owner and mode; a new file gets mode, or 0o666 when mode is zero, less the server umask; no temporary file and no rename; STAGE_CREATE_PARENTS with STAGE_IN_PLACE is INVALID" + }, + { + "name": "apply_in_place", + "layout": "APPLY WRITE_INLINE item flag APPLY_ITEM_IN_PLACE, offered with CAPABILITY_APPLY_IN_PLACE: the item writes its content as COMMIT of a STAGE_IN_PLACE stage writes, opening the target write-only with create and truncate, following a final symlink, with no temporary file and no rename; an existing file keeps its inode, owner and mode; a new file gets mode, or 0o666 when mode is zero, less the server umask; the item result describes the file written; a directory at the destination is CONFLICT whose ApplyOsErrors entry is EISDIR open; APPLY_ITEM_CREATE_PARENTS with APPLY_ITEM_IN_PLACE is INVALID" } ], "constants": [ @@ -8548,6 +8555,14 @@ export const YAS_SCHEMA = { "name": "APPLY_ITEM_FLAGS", "value": 1 }, + { + "name": "APPLY_ITEM_IN_PLACE", + "value": 2 + }, + { + "name": "APPLY_ITEM_EXTENDED_FLAGS", + "value": 2 + }, { "name": "REMOVE_RECURSIVE", "value": 1 @@ -8716,9 +8731,13 @@ export const YAS_SCHEMA = { "name": "CAPABILITY_STAGE_IN_PLACE", "value": 16 }, + { + "name": "CAPABILITY_APPLY_IN_PLACE", + "value": 32 + }, { "name": "CAPABILITY_FLAGS", - "value": 31 + "value": 63 } ] }, @@ -15910,6 +15929,10 @@ export const YAS_GOLDEN_VECTORS = { "name": "fs.apply.payload", "hex": "0100000000000000050505050505050505050505050505050100010020000000000000000500000001000100610400000001000000a40100000300000079617300000000" }, + { + "name": "fs.apply.in_place.payload", + "hex": "0100000000000000050505050505050505050505050505050000010020000000000002000500000001000100610400000001000000a40100000300000079617300000000" + }, { "name": "fs.entry.inline.payload", "hex": "050000000100010061010000000000000000000000a4010000010000000000000003000000000000000606060606060606060606060606060606060606060606060606060606060606010000000300000079617300000000" diff --git a/protocol/yas/families/fs.toml b/protocol/yas/families/fs.toml index 4ea289f6..7551b019 100644 --- a/protocol/yas/families/fs.toml +++ b/protocol/yas/families/fs.toml @@ -210,6 +210,9 @@ layout = "kind:u8,reserved:u8=0,reserved:u16=0,mode:u32,size:u64,modified_unix_n [[type]] name = "stage_in_place" layout = "STAGE_WRITE flag STAGE_IN_PLACE: COMMIT opens the target write-only with create and truncate, following a final symlink, writes the staged bytes and optionally syncs them; an existing file keeps its inode, owner and mode; a new file gets mode, or 0o666 when mode is zero, less the server umask; no temporary file and no rename; STAGE_CREATE_PARENTS with STAGE_IN_PLACE is INVALID" +[[type]] +name = "apply_in_place" +layout = "APPLY WRITE_INLINE item flag APPLY_ITEM_IN_PLACE, offered with CAPABILITY_APPLY_IN_PLACE: the item writes its content as COMMIT of a STAGE_IN_PLACE stage writes, opening the target write-only with create and truncate, following a final symlink, with no temporary file and no rename; an existing file keeps its inode, owner and mode; a new file gets mode, or 0o666 when mode is zero, less the server umask; the item result describes the file written; a directory at the destination is CONFLICT whose ApplyOsErrors entry is EISDIR open; APPLY_ITEM_CREATE_PARENTS with APPLY_ITEM_IN_PLACE is INVALID" [[constant]] name = "SOURCE_PLATFORM_PATH" @@ -516,6 +519,12 @@ value = 1 name = "APPLY_ITEM_FLAGS" value = 1 [[constant]] +name = "APPLY_ITEM_IN_PLACE" +value = 2 +[[constant]] +name = "APPLY_ITEM_EXTENDED_FLAGS" +value = 2 +[[constant]] name = "REMOVE_RECURSIVE" value = 1 [[constant]] @@ -646,5 +655,8 @@ value = 8 name = "CAPABILITY_STAGE_IN_PLACE" value = 16 [[constant]] +name = "CAPABILITY_APPLY_IN_PLACE" +value = 32 +[[constant]] name = "CAPABILITY_FLAGS" -value = 31 +value = 63 diff --git a/protocol/yas/schema.json b/protocol/yas/schema.json index decf6fa2..1cea9b7b 100644 --- a/protocol/yas/schema.json +++ b/protocol/yas/schema.json @@ -5232,6 +5232,10 @@ { "name": "stage_in_place", "layout": "STAGE_WRITE flag STAGE_IN_PLACE: COMMIT opens the target write-only with create and truncate, following a final symlink, writes the staged bytes and optionally syncs them; an existing file keeps its inode, owner and mode; a new file gets mode, or 0o666 when mode is zero, less the server umask; no temporary file and no rename; STAGE_CREATE_PARENTS with STAGE_IN_PLACE is INVALID" + }, + { + "name": "apply_in_place", + "layout": "APPLY WRITE_INLINE item flag APPLY_ITEM_IN_PLACE, offered with CAPABILITY_APPLY_IN_PLACE: the item writes its content as COMMIT of a STAGE_IN_PLACE stage writes, opening the target write-only with create and truncate, following a final symlink, with no temporary file and no rename; an existing file keeps its inode, owner and mode; a new file gets mode, or 0o666 when mode is zero, less the server umask; the item result describes the file written; a directory at the destination is CONFLICT whose ApplyOsErrors entry is EISDIR open; APPLY_ITEM_CREATE_PARENTS with APPLY_ITEM_IN_PLACE is INVALID" } ], "constants": [ @@ -5635,6 +5639,14 @@ "name": "APPLY_ITEM_FLAGS", "value": 1 }, + { + "name": "APPLY_ITEM_IN_PLACE", + "value": 2 + }, + { + "name": "APPLY_ITEM_EXTENDED_FLAGS", + "value": 2 + }, { "name": "REMOVE_RECURSIVE", "value": 1 @@ -5803,9 +5815,13 @@ "name": "CAPABILITY_STAGE_IN_PLACE", "value": 16 }, + { + "name": "CAPABILITY_APPLY_IN_PLACE", + "value": 32 + }, { "name": "CAPABILITY_FLAGS", - "value": 31 + "value": 63 } ] }, diff --git a/protocol/yas/vectors.json b/protocol/yas/vectors.json index 749ecac0..27f1e0fb 100644 --- a/protocol/yas/vectors.json +++ b/protocol/yas/vectors.json @@ -1289,6 +1289,10 @@ "name": "fs.apply.payload", "hex": "0100000000000000050505050505050505050505050505050100010020000000000000000500000001000100610400000001000000a40100000300000079617300000000" }, + { + "name": "fs.apply.in_place.payload", + "hex": "0100000000000000050505050505050505050505050505050000010020000000000002000500000001000100610400000001000000a40100000300000079617300000000" + }, { "name": "fs.entry.inline.payload", "hex": "050000000100010061010000000000000000000000a4010000010000000000000003000000000000000606060606060606060606060606060606060606060606060606060606060606010000000300000079617300000000" diff --git a/protocol/yas/wire.md b/protocol/yas/wire.md index e65809e7..1d2f075f 100644 --- a/protocol/yas/wire.md +++ b/protocol/yas/wire.md @@ -654,6 +654,7 @@ Every Request kind has a correlated Result with the same family and kind. | `query_list_entries` | repeated kind:u8,name:bytes_u16; one directory level without dot and dot-dot, hidden names included, in no defined order; kind ENTRY_FILE, ENTRY_DIRECTORY, ENTRY_SYMLINK or ENTRY_OTHER describes the entry itself, so a symlink to a directory is ENTRY_SYMLINK; name is one nonempty raw platform-name component without NUL or slash | | `query_stat_only` | kind:u8,reserved:u8=0,reserved:u16=0,mode:u32,size:u64,modified_unix_ns:i64; kind ENTRY_FILE, ENTRY_DIRECTORY, ENTRY_SYMLINK or ENTRY_OTHER; follows the final symlink unless READ_NO_FOLLOW; no content is read or hashed | | `stage_in_place` | STAGE_WRITE flag STAGE_IN_PLACE: COMMIT opens the target write-only with create and truncate, following a final symlink, writes the staged bytes and optionally syncs them; an existing file keeps its inode, owner and mode; a new file gets mode, or 0o666 when mode is zero, less the server umask; no temporary file and no rename; STAGE_CREATE_PARENTS with STAGE_IN_PLACE is INVALID | +| `apply_in_place` | APPLY WRITE_INLINE item flag APPLY_ITEM_IN_PLACE, offered with CAPABILITY_APPLY_IN_PLACE: the item writes its content as COMMIT of a STAGE_IN_PLACE stage writes, opening the target write-only with create and truncate, following a final symlink, with no temporary file and no rename; an existing file keeps its inode, owner and mode; a new file gets mode, or 0o666 when mode is zero, less the server umask; the item result describes the file written; a directory at the destination is CONFLICT whose ApplyOsErrors entry is EISDIR open; APPLY_ITEM_CREATE_PARENTS with APPLY_ITEM_IN_PLACE is INVALID | ## `yas.git` (`0x0031`/v1)