Compare commits

...
Author SHA1 Message Date
nvrl d2b5916b99 Merge remote-tracking branch 'origin/main' into fix/create-landing-counted 2026-11-01 11:28:03 +01:00
nvrl 4626ce17a7 Merge pull request #69 from narrrl/dependabot/cargo/cargo-f3dd2e6b0d
build(deps): bump the cargo group across 1 directory with 2 updates
2026-11-01 11:27:45 +01:00
nvrl 630f316fe6 Merge pull request #65 from narrrl/fix/clean-stop-runtime
fix(cli): leave the runtime running on a stop so stragglers cannot panic
2026-11-01 11:27:41 +01:00
nvrl 801681f747 Merge remote-tracking branch 'origin/main' into fix/create-landing-counted
# Conflicts:
#	docs/BUGS.md
2026-11-01 11:14:15 +01:00
nvrl e7382f2c7e test(core): name zip's options type the way zip 9 accepts 2026-11-01 11:12:17 +01:00
dependabot[bot] 7046f0f3ef build(deps): bump the cargo group across 1 directory with 2 updates
Bumps the cargo group with 2 updates in the / directory: [zip](https://github.com/zip-rs/zip2) and [rustls](https://github.com/rustls/rustls).


Updates `zip` from 8.6.0 to 9.0.5
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v8.6.0...v9.0.5)

Updates `rustls` from 0.23.45 to 0.23.46
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.45...v/0.23.46)

---
updated-dependencies:
- dependency-name: zip
  dependency-version: 9.0.5
  dependency-type: direct:production
  dependency-group: cargo
- dependency-name: rustls
  dependency-version: 0.23.46
  dependency-type: indirect
  dependency-group: cargo
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 23:39:08 +00:00
nvrl 37e8dae61b Merge pull request #54 from narrrl/docs/bugs-issue-audit
docs(bugs): record the audit of issues #53, #46, #40 and #38
2026-10-31 19:44:18 +01:00
nvrl c52f2098f2 Merge pull request #64 from narrrl/chore/sdk-0.10.1
chore(deps): bump proton-sdk and proton-drive-rs to 0.10.1
2026-10-31 19:40:34 +01:00
nvrl f4a2674b9c Merge pull request #63 from narrrl/fix/sim-flakes-and-open-race
fix(fuse): retry a write open whose queued revision landed under the copy
2026-10-31 19:40:24 +01:00
nvrl 347861bbc1 Merge pull request #62 from narrrl/fix/restore-outcome-and-walk-gaps
fix(fuse,cli): keep a pass going past a failed listing, report skipped restores
2026-10-31 19:40:14 +01:00
nvrl c57763b07f Merge pull request #61 from narrrl/fix/diagnose-coverage
fix(cli): have diagnose report failing writes, a pause and folders in error
2026-10-31 19:40:04 +01:00
nvrl 846dfd8d14 Merge pull request #60 from narrrl/fix/db-errors-not-offline
fix(core): read database errors handed to from_api as internal, not offline
2026-10-31 19:39:53 +01:00
nvrl 2ae99de844 Merge pull request #59 from narrrl/fix/mount-failure-surfaced
fix(cli,core,fuse,gui): say why the mount keeps failing, and back off
2026-10-31 19:39:44 +01:00
nvrl 69ff216e7d Merge pull request #58 from narrrl/fix/never-synced-label
fix(gui): say a folder is waiting for its first sync, not up to date
2026-10-31 19:39:34 +01:00
nvrl 639ca359fa fix(fuse,core): count a new file's landing until its bytes are cached
A create that carries a file's bytes retired its op, then cached the
bytes and gave the tree the real uid. Nothing counted that window, so
`pdfs status` read empty while it ran, which B119 promises means the
file reads without the network.

A delete in that window found the placeholder uid in the tree and
trashed the node by its real uid. `queue_trash` asked the tree whether
that uid was open, found no entry, and dropped the cached bytes the open
handle reads. If the cache store ran at the same moment, its tag was
written after the eviction and never removed. CI caught that as a
stray .meta file.

The create's landing is now counted like an upload's, from the op's
retirement until the tree has the real uid. The delete and the replace
pass whether they found the file open. Opening the cache removes a tag
whose bytes are gone, which also covers a crash between an eviction's
two removals. The simulations wait for `Daemon::drained`, which counts
the landing, before they go offline.

B255 and B256 move to fixed: the account run checked both.
2026-10-27 14:44:34 +01:00
10 changed files with 187 additions and 24 deletions

No files matched your search

Generated
+10 -7
View File
@@ -4380,9 +4380,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.45" version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"once_cell", "once_cell",
@@ -4933,7 +4933,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom 0.4.3", "getrandom 0.3.4",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -5249,9 +5249,9 @@ dependencies = [
[[package]] [[package]]
name = "typed-path" name = "typed-path"
version = "0.12.3" version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
[[package]] [[package]]
name = "typenum" name = "typenum"
@@ -6113,15 +6113,18 @@ dependencies = [
[[package]] [[package]]
name = "zip" name = "zip"
version = "8.6.0" version = "9.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" checksum = "3f5b58784007ff6e5f280ec82c8596686a3b9b5dd5836c63288d4479f84728b9"
dependencies = [ dependencies = [
"crc32fast", "crc32fast",
"flate2", "flate2",
"getrandom 0.4.3",
"indexmap", "indexmap",
"memchr", "memchr",
"nix",
"typed-path", "typed-path",
"windows-sys 0.61.2",
"zopfli", "zopfli",
] ]
+1 -1
View File
@@ -41,7 +41,7 @@ ignore = "0.4"
sha2 = "0.11" sha2 = "0.11"
# Google Takeout exports are zip sets; the importer reads their central # Google Takeout exports are zip sets; the importer reads their central
# directories and streams entries out without extracting to disk first. # directories and streams entries out without extracting to disk first.
zip = { version = "8", default-features = false, features = ["deflate"] } zip = { version = "9", default-features = false, features = ["deflate"] }
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an # Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
# import has to speak SHA-1 whatever its age. # import has to speak SHA-1 whatever its age.
sha1 = "0.11" sha1 = "0.11"
+33 -1
View File
@@ -621,10 +621,20 @@ impl ContentCache {
for entry in rd.flatten() { for entry in rd.flatten() {
let name = entry.file_name(); let name = entry.file_name();
let Some(name) = name.to_str() else { continue }; let Some(name) = name.to_str() else { continue };
// A `.meta` tags the blob named like it without the suffix. One
// whose blob is gone is what an eviction leaves between its two
// removals, by a crash or by a store of the same uid landing in
// between, and nothing else would ever remove it.
if let Some(blob) = name.strip_suffix(".meta") {
if !dir.join(blob).exists() {
let _ = std::fs::remove_file(entry.path());
}
continue;
}
// `.geom` is a block-geometry sidecar (see `GeometryMeta`), tens // `.geom` is a block-geometry sidecar (see `GeometryMeta`), tens
// of bytes and not a cached block; like `.meta` it is neither // of bytes and not a cached block; like `.meta` it is neither
// budgeted nor evictable on its own. // budgeted nor evictable on its own.
if name.ends_with(".meta") || name.ends_with(".geom") { if name.ends_with(".geom") {
continue; continue;
} }
// A `.tmp` is a staging file from a store that never completed // A `.tmp` is a staging file from a store that never completed
@@ -2250,6 +2260,28 @@ mod tests {
assert!(total <= 8, "budget enforced after reconcile, total={total}"); assert!(total <= 8, "budget enforced after reconcile, total={total}");
} }
/// An eviction removes the blob, then its tag. A crash in between, or a
/// store of the same uid landing in between, left the tag on its own, and
/// nothing removed it.
#[test]
fn a_tag_without_its_blob_is_swept_at_open() {
let dir = TempDir::new();
let content = dir.path().join("content");
let pins = dir.path().join("pins.json");
let db = Arc::new(Db::open_in_memory().unwrap());
{
let c = ContentCache::open(content.clone(), pins.clone(), 0, db.clone()).unwrap();
c.store(&uid("kept"), 1, 4, b"kept").unwrap();
c.store(&uid("gone"), 1, 4, b"gone").unwrap();
std::fs::remove_file(c.blob_path(&uid("gone"))).unwrap();
}
let c = ContentCache::open(content, pins, 0, db).unwrap();
assert!(!c.meta_path(&uid("gone")).exists());
assert!(c.meta_path(&uid("kept")).exists());
assert_eq!(c.read_range(&uid("kept"), 1, 4, 0, 4).unwrap(), b"kept");
}
#[test] #[test]
fn stale_metadata_is_a_miss() { fn stale_metadata_is_a_miss() {
let (c, _d) = cache(); let (c, _d) = cache();
+2 -2
View File
@@ -1332,8 +1332,8 @@ mod tests {
)); ));
let file = std::fs::File::create(&path).unwrap(); let file = std::fs::File::create(&path).unwrap();
let mut zip = zip::ZipWriter::new(file); let mut zip = zip::ZipWriter::new(file);
let options: zip::write::FileOptions<'_, ()> = let options = zip::write::SimpleFileOptions::default()
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored); .compression_method(zip::CompressionMethod::Stored);
for (name, body) in files { for (name, body) in files {
zip.start_file(*name, options).unwrap(); zip.start_file(*name, options).unwrap();
zip.write_all(body).unwrap(); zip.write_all(body).unwrap();
+7
View File
@@ -1281,6 +1281,13 @@ impl Core {
sent: (&sent.0, &sent.1), sent: (&sent.0, &sent.1),
landed: (&home.to_string(), &name), landed: (&home.to_string(), &name),
}; };
// Counted from the op's retirement until the bytes are cached and the
// tree has the real uid, as an upload is. Uncounted, the queue read
// empty while that still ran: a delete in between took an open file
// for closed and dropped what its handle reads, and a script that
// waited for an empty queue read the file before it was cached
// (`docs/BUGS.md` B257).
let _landing = LandingUpload::begin(&self.landing_uploads);
let newer = match self.retire_create(op, uploaded, &landing, &local, &real)? { let newer = match self.retire_create(op, uploaded, &landing, &local, &real)? {
CreateRetired::Withdrawn { trash } => { CreateRetired::Withdrawn { trash } => {
return self.trash_withdrawn_create(op, trash, &local, &real, &name); return self.trash_withdrawn_create(op, trash, &local, &real, &name);
+1 -1
View File
@@ -2376,7 +2376,7 @@ impl ProtonFs {
// regardless — the file is gone from their point of view the moment the // regardless — the file is gone from their point of view the moment the
// command returns. The same when the network goes // command returns. The same when the network goes
// away under the remote call below. // away under the remote call below.
let queue_it = |reply: ReplyEmpty| match self.core.queue_trash(&uid, name) { let queue_it = |reply: ReplyEmpty| match self.core.queue_trash(&uid, name, open_now) {
Ok(()) => reply.ok(), Ok(()) => reply.ok(),
Err(e) => reply.error(e), Err(e) => reply.error(e),
}; };
+14 -6
View File
@@ -3999,7 +3999,12 @@ impl Core {
/// the cached ones, as when the trash is sent at once: a partial write /// the cached ones, as when the trash is sent at once: a partial write
/// fills its gaps from them, offline too (`docs/BUGS.md` B186). The final /// fills its gaps from them, offline too (`docs/BUGS.md` B186). The final
/// close or the trash landing, whichever comes last, lets go of them. /// close or the trash landing, whichever comes last, lets go of them.
fn queue_trash(&self, uid: &NodeUid, name: &str) -> Result<(), Errno> { ///
/// `open` is whether the caller found the file open. A create that landed
/// under a delete is trashed by its real uid before the tree has it, so
/// asking the tree alone took an open file for closed (`docs/BUGS.md`
/// B257).
fn queue_trash(&self, uid: &NodeUid, name: &str, open: bool) -> Result<(), Errno> {
self.require_uid_writable(uid)?; self.require_uid_writable(uid)?;
// The bytes below are about to be discarded, and a drain worker may be // The bytes below are about to be discarded, and a drain worker may be
// reading them onto the wire right now. Stop it first: with several // reading them onto the wire right now. Stop it first: with several
@@ -4014,7 +4019,7 @@ impl Core {
error!(%uid, error = %e, "queueing trash failed"); error!(%uid, error = %e, "queueing trash failed");
Errno::EIO Errno::EIO
})?; })?;
let open_now = self.is_open_anywhere(uid); let open_now = open || self.is_open_anywhere(uid);
if open_now { if open_now {
let read_from = self.pending_blob(uid); let read_from = self.pending_blob(uid);
self.release_dropped_below( self.release_dropped_below(
@@ -5383,9 +5388,12 @@ impl Core {
/// `unlink` under an open handle. Its queued ops go now, or the drain would /// `unlink` under an open handle. Its queued ops go now, or the drain would
/// upload to the node after it was trashed (`docs/BUGS.md` B106). /// upload to the node after it was trashed (`docs/BUGS.md` B106).
fn remove_replaced(&self, ino: u64, uid: &NodeUid, name: &str) -> Result<(), Errno> { fn remove_replaced(&self, ino: u64, uid: &NodeUid, name: &str) -> Result<(), Errno> {
// Asked under the uid the tree has now: a landed create's real uid is
// not in the tree yet (`docs/BUGS.md` B257).
let open = self.is_open_anywhere(uid);
let landed; let landed;
let uid = if is_local_uid(uid) { let uid = if is_local_uid(uid) {
if self.is_open_anywhere(uid) { if open {
self.withdraw_queued_ops(uid)?; self.withdraw_queued_ops(uid)?;
} else { } else {
self.discard_queued_ops(uid)?; self.discard_queued_ops(uid)?;
@@ -5410,7 +5418,7 @@ impl Core {
// Local-first or offline, or the network goes away under the call: // Local-first or offline, or the network goes away under the call:
// queue the trash. The rename that follows queues as well, behind it. // queue the trash. The rename that follows queues as well, behind it.
if !self.sends_inline() { if !self.sends_inline() {
return self.queue_trash(uid, name); return self.queue_trash(uid, name, open);
} }
self.require_uid_writable(uid)?; self.require_uid_writable(uid)?;
if let Err(e) = self if let Err(e) = self
@@ -5418,7 +5426,7 @@ impl Core {
.and_then(batch::into_unit) .and_then(batch::into_unit)
{ {
if self.lost_link(&e, "trash a replaced node") { if self.lost_link(&e, "trash a replaced node") {
return self.queue_trash(uid, name); return self.queue_trash(uid, name, open);
} }
error!(%uid, name, error = %e, "trashing the node a rename replaces failed"); error!(%uid, name, error = %e, "trashing the node a rename replaces failed");
self.log_activity(ActivityKind::Trash, name, e.to_string(), false); self.log_activity(ActivityKind::Trash, name, e.to_string(), false);
@@ -9258,7 +9266,7 @@ mod tests {
assert!(!replaced.contains("st.forget(uid)")); assert!(!replaced.contains("st.forget(uid)"));
assert_before( assert_before(
replaced, replaced,
"if self.is_open_anywhere(uid)", "let open = self.is_open_anywhere(uid)",
"self.discard_queued_ops(uid)", "self.discard_queued_ops(uid)",
); );
assert_before(replaced, "if open_now", "self.cache.evict(uid)"); assert_before(replaced, "if open_now", "self.cache.evict(uid)");
+67 -2
View File
@@ -130,6 +130,22 @@ impl Daemon {
} }
} }
/// Whether nothing is queued and no upload is still landing, as `pdfs
/// status` counts it. An empty [`Self::pending`] is not enough before a
/// read that needs the cache: the op goes before the uploaded bytes are
/// cached, so a test that went offline at once raced the drain.
pub(crate) fn drained(&self) -> bool {
self.pending().is_ok_and(|items| items.is_empty())
&& matches!(
self.request(&Request::Status),
Ok(Response::Status {
pending_uploads: 0,
pending_changes: 0,
..
})
)
}
/// Whether the mount believes Proton is reachable, as `pdfs status` shows /// Whether the mount believes Proton is reachable, as `pdfs status` shows
/// it. /// it.
pub(crate) fn online(&self) -> io::Result<bool> { pub(crate) fn online(&self) -> io::Result<bool> {
@@ -565,7 +581,7 @@ mod tests {
std::fs::write(&path, b"0123456789").unwrap(); std::fs::write(&path, b"0123456789").unwrap();
assert!(wait_until(Duration::from_secs(30), || { assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) daemon.drained()
&& drive.tree().get("f.txt") == Some(&Entry::File(Arc::new(b"0123456789".to_vec()))) && drive.tree().get("f.txt") == Some(&Entry::File(Arc::new(b"0123456789".to_vec())))
})); }));
daemon.client.set_online(false); daemon.client.set_online(false);
@@ -692,6 +708,55 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_new_file_deleted_while_open_as_its_create_lands_still_reads() {
// The create's op was retired and the tree still had the placeholder.
// The queue read empty, and the delete trashed the node by its real
// uid, which the tree did not have yet: it took the open file for
// closed and dropped the bytes its handle reads (B257).
use std::io::{Read, Seek, SeekFrom, Write};
let drive = FakeDrive::new();
let dir = scratch("delete-open-as-create-lands");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let path = daemon.mountpoint.join("f.txt");
let landing = daemon.client.hold_next_landing_of_any();
std::fs::write(&path, b"0123456789").unwrap();
assert!(wait_until(Duration::from_secs(30), || landing.reached()));
assert!(!daemon.drained(), "the landing create is counted");
let mut file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
drop(landing);
assert!(wait_until(Duration::from_secs(30), || daemon.drained()));
daemon.client.set_online(false);
std::fs::remove_file(&path).unwrap();
file.seek(SeekFrom::End(0)).unwrap();
file.write_all(b"ab").unwrap();
let mut read = Vec::new();
file.seek(SeekFrom::Start(0)).unwrap();
file.read_to_end(&mut read).unwrap();
assert_eq!(read, b"0123456789ab");
drop(file);
daemon.client.set_online(true);
assert!(
wait_until(Duration::from_secs(30), || {
daemon.drained() && drive.tree().is_empty()
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test] #[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"] #[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_restart_does_not_bring_back_the_write_of_a_file_deleted_while_open() { fn a_restart_does_not_bring_back_the_write_of_a_file_deleted_while_open() {
@@ -706,7 +771,7 @@ mod tests {
std::fs::write(&path, b"0123456789").unwrap(); std::fs::write(&path, b"0123456789").unwrap();
assert!(wait_until(Duration::from_secs(30), || { assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) daemon.drained()
&& drive.tree().get("f.txt") == Some(&Entry::File(Arc::new(b"0123456789".to_vec()))) && drive.tree().get("f.txt") == Some(&Entry::File(Arc::new(b"0123456789".to_vec())))
})); }));
daemon.client.set_online(false); daemon.client.set_online(false);
+43 -4
View File
@@ -119,8 +119,7 @@ a live account since.
| [B261](#b261--a-folder-that-has-never-synced-reads-up-to-date) | A folder that has never synced reads "up to date" | | [B261](#b261--a-folder-that-has-never-synced-reads-up-to-date) | A folder that has never synced reads "up to date" |
| [B260](#b260--the-poll-pushes-a-new-folders-first-sync-behind-every-other-folder) | The poll pushes a new folder's first sync behind every other folder | | [B260](#b260--the-poll-pushes-a-new-folders-first-sync-behind-every-other-folder) | The poll pushes a new folder's first sync behind every other folder |
| [B258](#b258--journalctl--p-warning-shows-nothing-whatever-the-daemon-logged) | `journalctl -p warning` shows nothing, whatever the daemon logged | | [B258](#b258--journalctl--p-warning-shows-nothing-whatever-the-daemon-logged) | `journalctl -p warning` shows nothing, whatever the daemon logged |
| [B256](#b256--pdfs-copy-right-after-a-save-copies-the-old-version) | `pdfs copy` right after a save copies the old version | | [B257](#b257--deleting-a-new-file-while-it-is-open-can-drop-the-bytes-its-handle-reads) | Deleting a new file while it is open can drop the bytes its handle reads |
| [B255](#b255--a-synced-folder-whose-files-are-all-gone-here-is-stuck-for-good) | A synced folder whose files are all gone here is stuck for good |
| [B254](#b254--an-edit-in-the-same-second-as-a-new-upload-never-arrives) | An edit in the same second as a new upload never arrives | | [B254](#b254--an-edit-in-the-same-second-as-a-new-upload-never-arrives) | An edit in the same second as a new upload never arrives |
| [B253](#b253--the-app-shows-the-sign-in-page-when-the-keyring-is-locked) | The app shows the sign-in page when the keyring is locked | | [B253](#b253--the-app-shows-the-sign-in-page-when-the-keyring-is-locked) | The app shows the sign-in page when the keyring is locked |
| [B252](#b252--computers-sharing-a-device-overwrite-each-others-profile) | Computers sharing a device overwrite each other's profile | | [B252](#b252--computers-sharing-a-device-overwrite-each-others-profile) | Computers sharing a device overwrite each other's profile |
@@ -295,6 +294,8 @@ a live account since.
| Entry | Title | | Entry | Title |
|---|---| |---|---|
| [B256](#b256--pdfs-copy-right-after-a-save-copies-the-old-version) | `pdfs copy` right after a save copies the old version |
| [B255](#b255--a-synced-folder-whose-files-are-all-gone-here-is-stuck-for-good) | A synced folder whose files are all gone here is stuck for good |
| [B194](#b194--on-a-fresh-install-the-tray-exits-and-the-takeout-page-cannot-remember-its-archives) | On a fresh install the tray exits and the Takeout page cannot remember its archives | | [B194](#b194--on-a-fresh-install-the-tray-exits-and-the-takeout-page-cannot-remember-its-archives) | On a fresh install the tray exits and the Takeout page cannot remember its archives |
| [B192](#b192--a-new-file-closed-empty-reaches-drive-only-half-a-minute-later) | A new file closed empty reaches Drive only half a minute later | | [B192](#b192--a-new-file-closed-empty-reaches-drive-only-half-a-minute-later) | A new file closed empty reaches Drive only half a minute later |
| [B181](#b181--the-app-shows-a-queued-edit-kept-as-a-conflict-copy-in-english) | The app shows a queued edit kept as a conflict copy in English | | [B181](#b181--the-app-shows-a-queued-edit-kept-as-a-conflict-copy-in-english) | The app shows a queued edit kept as a conflict copy in English |
@@ -936,9 +937,46 @@ are off. A terminal that merely inherited `JOURNAL_STREAM` keeps the plain forma
--- ---
### B256 — `pdfs copy` right after a save copies the old version ### B257 — Deleting a new file while it is open can drop the bytes its handle reads
**Status:** Fixed, not verified live **Status:** Fixed, not verified live
**Found:** 2026-10-27, the simulation
`a_file_deleted_offline_while_open_still_reads_and_takes_writes` failing once on CI with a cache
tag left behind, then on every run with a 50 ms pause added to `ContentCache::store_file`.
**Where:** `retire_create` in `crates/pdfs-fuse/src/drain.rs`; `queue_trash` and
`remove_replaced` in `crates/pdfs-fuse/src/lib.rs`; `trash_child` in
`crates/pdfs-fuse/src/filesystem.rs`; `ContentCache::reconcile` in `crates/pdfs-core/src/cache.rs`
**Repro:** Create a file in the mount, wait until `pdfs status` shows nothing queued, open it,
and delete it while the link is down. If the create was still landing, a partial write or a read
through the open handle fails with "Input/output error", and the cache can keep a tag with no
bytes until the next start.
**Cause.** A create that carries the file's bytes retires its op, then reads the node back,
caches the bytes and only then gives the tree the real uid. Nothing counted that window, so the
queue read empty while it ran, which B119 promises means the file reads without the network. A
delete in that window found the placeholder uid in the tree, and trashed the node by its real
uid. `queue_trash` asked the tree whether that uid was open, found no entry for it, and dropped
the cached bytes the open handle reads, which B186 had kept. When the cache store was running at
the same moment, its tag was written after the eviction, and nothing removed it.
**Fix.** The create's landing is counted like an upload's, from the op's retirement until the
tree has the real uid. The delete and the replace pass whether they found the file open, so
`queue_trash` no longer depends on the tree having the new uid. Opening the cache removes a tag
whose bytes are gone, which also covers a crash between an eviction's two removals.
**Test:** The simulation `a_new_file_deleted_while_open_as_its_create_lands_still_reads` holds a
create's landing and fails without the count. The simulation above passes again with the 50 ms
pause, since it now waits for `Daemon::drained`, which counts the landing. Unit test
`a_tag_without_its_blob_is_swept_at_open` in `crates/pdfs-core/src/cache.rs`. No test hits the
delete inside the cache store itself: the window is too short to hold from the simulation.
---
### B256 — `pdfs copy` right after a save copies the old version
**Status:** Fixed, checked on 2026-10-27 by the acceptance case "regression B256" on a live
account
**Found:** 2026-10-26, the simulation `a_pdfs_copy_copies_on_drive_and_waits_for_unsynced_changes` **Found:** 2026-10-26, the simulation `a_pdfs_copy_copies_on_drive_and_waits_for_unsynced_changes`
failing on most runs. The feature was not released yet. failing on most runs. The feature was not released yet.
**Where:** `release` in `crates/pdfs-fuse/src/filesystem.rs`; `copy_to` in **Where:** `release` in `crates/pdfs-fuse/src/filesystem.rs`; `copy_to` in
@@ -962,7 +1000,8 @@ A move checks the same set.
### B255 — A synced folder whose files are all gone here is stuck for good ### B255 — A synced folder whose files are all gone here is stuck for good
**Status:** Fixed, not verified live **Status:** Fixed, checked on 2026-10-27 by the acceptance case "regression B255" on a live
account
**Found:** 2026-10-26, issue #46: a folder restored from another computer never downloaded. **Found:** 2026-10-26, issue #46: a folder restored from another computer never downloaded.
**Where:** `push_pass` in `crates/pdfs-fuse/src/sync.rs`; `reset_sync_folder` in **Where:** `push_pass` in `crates/pdfs-fuse/src/sync.rs`; `reset_sync_folder` in
`crates/pdfs-fuse/src/devices.rs` `crates/pdfs-fuse/src/devices.rs`
+9
View File
@@ -11,6 +11,15 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased] ## [Unreleased]
### Fixed
- **A new file reads without the network as soon as `pdfs status` shows nothing queued**
(B257). A new file's upload counted as done before its bytes were cached, so a script that
waited for an empty queue and then went offline could not read it. A new file deleted while
open in that moment could also lose the bytes its open handle reads, which made a write or
read through that handle fail with "Input/output error". The cache also removes leftover
tags of evicted files when it opens.
## [3.6.0] — 2026-10-27 ## [3.6.0] — 2026-10-27
Schema: **44**, unchanged. Schema: **44**, unchanged.