Skip to content

🐛 PUT-1589: Shared file fs events seem to be lost - #3645

Merged
jfcastro92 merged 6 commits into
mainfrom
juancastro/put-1589-shared-file-fs-events-seem-to-be-lost
Aug 26, 2026
Merged

🐛 PUT-1589: Shared file fs events seem to be lost#3645
jfcastro92 merged 6 commits into
mainfrom
juancastro/put-1589-shared-file-fs-events-seem-to-be-lost

Conversation

@jfcastro92

@jfcastro92 jfcastro92 commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

The bug

A share recipient's client keeps its cache fresh from fs events pushed over their socket. ShareService already fans those out to holders — but only for three of them: fs.write.file, fs.move.node, fs.remove.node.

A new entry does not emit fs.write.file. It emits fs.create.file / .directory / .shortcut / .symlinkfs.write.file is the overwrite case. An in-place rename emits fs.rename. Neither had a listener, so a recipient watching a shared folder never learned that a file appeared in it or was renamed, while deletes, moves and overwrites worked. That asymmetry is why this reads as "events seem to be lost" rather than "sharing doesn't update".

Part of why it was never wired: fs.create.*, fs.rename and outer.gui.item.renamed were absent from the typed event map, so tsc rejects a listener for them.

The disappearance half of the bug

Delivering the events that were wired turned out not to be enough either. Both mechanisms that make a file vanish were silent for anyone holding the folder above it:

  • Delete. The audience came from the permission rows the delete removed (#flushRetire). A file inside a shared folder has no grant of its own — only the folder does — so the audience was empty. Deleting the folder, or a directly shared file, worked, which is what made this look like an unrelated quirk.
  • Move out. The audience was resolved from the entry's new path. The GUI's Delete is a move to the owner's Trash, where no recipient has a share, so the lookup found nobody. item.moved cannot express a disappearance anyway.

Both now resolve the audience from where the entry was, not from the grants that went with it:

  • A delete fans item.removed out to holders reaching the entry through an ancestor, on top of the roots #flushRetire already reports. A holder covered by both is told once.
  • A move resolves both ends. Reaching both is item.moved, only the destination is item.added, only the origin is item.removed. Each is masked through the share that holder saw that end through, which need not be the same share at both ends.

Recipients are named by the path they knew — /owner/<sharedRootUuid>/Docs/file.txt — never the owner's real path or a Trash path they cannot see. That is not cosmetic: the desktop's item.removed handler finds the row by data-path, so a Trash path matches nothing and leaves the item on screen.

The top-level share was worse

For a share on the entry itself, the grant is keyed on uuid, so it follows the entry into Trash. Both ends of the move resolved, and the owner's Delete reached the recipient as item.movedrenaming their shared item to the GUID Trash had given it. shared-with-me has always omitted trashed entries, so the listing and the event disagreed; only the event was wrong. Trashing a top-level share is now item.removed, and restoring one is item.added.

The mirror case: a move that leaves the recipient's masked address unchanged is now silent. A share masks its own root, so the owner shuffling it around their own tree is invisible to the recipient, and the event carried a from_path identical to path.

Revoked recipients kept receiving pushes (PUT-1611)

Rolled in here because it lives in the same function this PR rewrites, and this PR widens the fan-out — deletes and moves now reach holders they previously missed, which makes the leak worse.

The audience came straight from the share index with no live-grant check. /auth/revoke-user-user deletes the permission and leaves the index row, so a revoked recipient's socket kept receiving name, size, masked path and mtime for every write and move under that folder, indefinitely.

The service already solves this elsewhere — #reachingHolders returns exactly the holder/entry pairs whose grant still stands, and listSharedWithMe was moved onto it during PUT-1412 for this reason. The realtime path never got the same treatment; it does now.

Cost: none on the unshared path. With no share rows reaching the entry there are no holders to check, so the write path every user takes is unchanged — pinned by a test that counts permission reads and asserts zero.

The path half of the bug

The payload masked paths against the entry itself (/owner/<file-uuid>/<name>), and:

  • puter-js handles item.added with a targeted eviction — puter._cache.del('readdir:' + dirname(item.path)) (FileSystem/index.js:149). Against a self-masked path the parent is /owner/<file-uuid>, which is not a key anything cached.
  • The desktop finds the window to render into by dirpath (apply_item_added_to_containers.js:40) — a field the payload didn't carry at all, so the helper returned early.

Both would have produced an event that arrives and changes nothing. Paths are now masked at the share the holder reached the entry through, and the payload carries dirpath, plus the type and immutable the item renderer reads.

Behavior changes worth calling out

  • Moving an item into a shared folder used to send that folder's holders item.moved. They had never seen the item, so there was nothing for the GUI to move, and from_path was dropped as outside their share — the event arrived and did nothing. It is now item.added.
  • A move that does not change the recipient's masked address is now silent instead of sending item.moved with from_path === path.
  • Trashing a top-level share is now item.removed rather than item.moved.

Blast radius

Creates. fs.create.* fires on every touch, mkdir, mkshortcut, completed upload and batch-write item, for every user, shared or not. Measured: +1 indexed share query per create (25 creates → 25 extra reads out of 102 total; ancestor lookups are served from the entry cache).

Creates are therefore coalesced by parent folder, the same setImmediate buffering the delete path has used for subtree deletes. Siblings share every ancestor, so one lookup serves the whole burst. Measured on 25 concurrent creates into one folder: 25 lookups before, 1 after.

Deletes. The ancestor pass is new work on every delete. Coalesced by parent the same way and reusing the existing retire batch, so a subtree costs one lookup per folder rather than one per entry — pinned by a test: 8 concurrent deletes from one folder settle in ≤2 lookups, not 8.

Moves. A move now resolves the audience twice instead of once — +1 indexed share query per move where the path actually changed. A rename in place is unaffected; it goes through fs.rename, which still resolves once.

Live-grant check. Two batched, holder-keyed reads per distinct holder of a reaching share. Zero when nothing is shared, which is the overwhelming majority of writes.

Also in here

  • from_path on a move and old_path on a rename are masked the same way, and dropped when the move started outside the share. A directly shared entry masks its former path by uuid, which still resolves after the entry has moved.
  • A holder with a share on both a folder and something inside it used to get one event per share; the nearest share wins now.

Verification

Twenty-two cases in keeping recipients in sync, driving the real fs.touch / fs.rename / fs.remove paths rather than hand-emitted events, so they cover whichever event those paths actually emit. Every behavioral fix has a test that fails without it — confirmed by reverting the change and re-running each time. They pin the audience, the exact masked path, dirpath, from_path/old_path, the one-lookup-per-folder property on both creates and deletes, that a move inside a share is still a move, that a holder of both a file and its folder is told once, that a trashed top-level share is named as the recipient knew it, that a revoked recipient hears nothing, and that the unshared write path does zero permission reads.

Full backend suite: 6428 passing. Typecheck and lint clean.

Against a running local Puter

Also run end to end against npm start, since socket delivery to a second user was the one thing the unit tests can't reach.

Event matrix, two real socket.io clients — one socket per user, owner mutates through the API, the recipient's socket recorded:

Owner does Recipient gets
create a file in the shared folder item.added
mkdir inside the shared folder item.added
create a file one level deeper item.added, dirpath = the subfolder
rename a file item.renamed + old_path
overwrite a file (/fs/write) item.updated
move a file within the share item.moved + from_path
create outside the share nothing ✅ silent
10 concurrent creates 10 × item.added ✅ all 10, one share query
delete the shared folder itself item.removed
delete a directly shared file item.removed
delete a file inside a shared folder item.removed

Every delivered path is masked at the share and never carries the owner's real path.

Three users on one shared folder — A owns /A/shared-x, B holds it write, C holds it read, all three on live sockets. B (not the owner) deletes doomed.txt inside it. Run on this branch and on the same build with only ShareService reverted:

before after
B permanently deletes
A (owner) item.removed item.removed
B (actor) silent item.removed
C (other recipient) silent item.removed
B deletes via the GUI (move to A's Trash)
A (owner) item.moved → their Trash item.moved → their Trash
B (actor) silent item.removed
C (other recipient) silent item.removed

The owner keeps getting item.moved for the trash case, which is correct — the file really did move, into a Trash they can still open. Everyone else gets item.removed, named at the masked path under the folder they hold, not the owner's Trash path. That is the case where a third party's window used to keep showing a file that was gone and 404 on click.

The shared root itself — a folder and a file each shared directly with a recipient, owner then acts on that very item. Run on this branch and on the same build with only ShareService reverted:

Owner does before after
rename the shared folder item.renamed item.renamed
move the shared folder item.moved, from_path == path silent — their address is unchanged
trash the shared folder item.moved/owner/<uuid>/79fc5f26-2110-… item.removed/owner/<uuid>/top-yoe6p0
delete the shared folder item.removed item.removed
rename the shared file item.renamed item.renamed
move the shared file item.moved, from_path == path silent
trash the shared file item.moved…/5998f0ad-….txt item.removed…/topfile-2qnyqa.txt
delete the shared file item.removed item.removed

The two bolded rows are the reported bug: the recipient's shared item was renamed to a Trash GUID instead of disappearing.

Real browser (Playwright) — the recipient signs into the desktop and lists the shared folder, the owner then adds a file from the API, nothing is clicked in the browser:

main (pre-fix) this branch
item.added reaches the page's socket never arrives arrives, masked path
readdir(dir, { consistency: 'eventual' }) after 1 item — stale 2 items

That is the chain the ticket describes — socket → puter.fs cache invalidation — reproduced broken on main and working here.

Caveat worth recording: item.added evicts one specific cache key, so it lands only when the recipient's cached key is the masked path — i.e. when they navigated the normal way. A first run that read the owner's real path missed the eviction. Not an issue for the GUI; an API consumer holding a real path won't be invalidated by item.added. The other events flush wholesale and are immune.

Known, not fixed

A recipient with write access is notified of their own create, and the actor of a delete is now told about their own delete. The fs events carry no acting socket, so the client's "don't update if this is the original client" guard can't fire; removing an item already gone is a no-op, so this is cosmetic. Pre-existing for updates, moves and deletes. Fixing it means threading the actor through the fs event payloads, which is a bigger change than this bug warrants.

A non-owner cannot delete a file shared directly with them at all — delete authority comes from the parent folder, which is the owner's, so it 403s. Unchanged here, and worth a separate look if that is not the intended permission model.

@jfcastro92 jfcastro92 changed the title Juancastro/put 1589 shared file fs events seem to be lost 🐛 PUT-1589: Shared file fs events seem to be lost Aug 25, 2026
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 93.68%
⬆️ +0.03%
24642 / 26304
🔵 Statements 91.93%
🟰 ±0%
26620 / 28955
🔵 Functions 89.88%
⬆️ +0.03%
4300 / 4784
🔵 Branches 80.54%
⬇️ -0.01%
17971 / 22311
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/backend/clients/event/types.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/services/share/ShareService.ts 92.36%
⬇️ -0.16%
80.55%
⬇️ -0.38%
94.92%
⬆️ +0.05%
96.12%
⬆️ +0.75%
40, 75, 97, 99, 109, 111, 120-124, 129, 136-140, 154, 163, 168, 170, 225, 257, 258, 288, 347, 385, 386, 459-461, 493-498, 547, 552, 562, 651, 674, 710, 780, 981, 1031, 1039-1040, 1050-1051, 1066-1067, 1076, 1089-1090, 1098-1102, 1159-1160, 1192-1194, 1215-1217, 1246, 1380-1381
Generated in workflow #843 for commit 5739f4e by the Vitest Coverage Report Action

A recipient's client keeps its cache fresh from fs events pushed over
their socket, and ShareService fans those out to holders — but only for
write, move and delete. A new entry emits fs.create.<flavor>, not
fs.write.file, and an in-place rename emits fs.rename; neither had a
listener, so a recipient watching a shared folder never learned that a
file appeared in it or was renamed. Part of why: those keys and
outer.gui.item.renamed were missing from the typed event map, so a
listener for them did not compile.

Delivering the event is only half of it. Paths were masked against the
entry itself, so item.added named a parent no cached listing was keyed
on, and the payload carried no dirpath, which is how the desktop finds
the container to render into — the event would have arrived and changed
nothing. Paths are now masked at the share the holder reached the entry
through, which is the address their own reads returned, and from_path on
a move and old_path on a rename travel the same way (dropped when the
move started outside the share, self-masked when the share is on the
entry itself, where the root already carries the new path).

Creates fire per entry, so an upload would have cost one share lookup
per file; they are coalesced by parent folder the way subtree deletes
already are. Measured on a 25-file burst into one folder: 25 lookups
before, 1 after. A holder with a share on both a folder and something
inside it is told once, by the nearer of the two.
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1589-shared-file-fs-events-seem-to-be-lost branch from 8850a8b to ee2b5ad Compare August 25, 2026 23:38
@jfcastro92
jfcastro92 requested a review from Salazareo August 25, 2026 23:44
@jfcastro92
jfcastro92 marked this pull request as ready for review August 25, 2026 23:44

@Salazareo Salazareo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

Comment thread src/backend/services/share/ShareService.ts Outdated
Raised in review: could an event from another node re-trigger the
fan-out? Not today — broadcast carries outer.* and pubsub.* only, so
fs.* never crosses a node boundary, and the emitted outer.gui.* is
consumed on the peer by SocketService while ShareService listens to
fs.* alone, so nothing re-enters.

That safety is a property of what broadcast happens to replicate, which
is not this service's to rely on. The handlers now skip anything tagged
from_outside: the node that did the write has already told the audience,
and a second fan-out would only duplicate it.
Both ways a file can vanish were silent for anyone holding the folder
above it, so a third party's window kept showing a file that was gone
and 404'd on click.

A delete built its audience from the permission rows it removed, and a
file inside a shared folder has no grant of its own — only the folder
does — so the audience was empty. A move resolved its audience from the
entry's new path, and the GUI's Delete is a move to the owner's Trash,
where no recipient has a share.

Resolve the audience from where the entry was rather than from the
grants that went with it:

- Deletes also fan out to holders reaching the entry through an
  ancestor, coalesced by parent so a subtree stays a couple of queries.
  A holder covered by both passes is told once.
- Moves resolve both ends. Reaching both is item.moved, only the
  destination item.added, only the origin item.removed.

Recipients are named by the path they knew, masked through their own
share rather than the owner's tree.
A grant on the entry itself is keyed on uuid, so it follows the entry
into the owner's Trash. Both ends of the move then resolved, and the
recipient was told the shared item had moved — to the GUID name Trash
gave it. Their own copy got renamed to a GUID and stayed on screen.

`shared-with-me` has always omitted trashed entries, so the listing and
the event disagreed; only the event was wrong. Trashing now reports
item.removed at the path the recipient knew, which is also what the
desktop's data-path selector needs to find the row. Restoring out of
Trash reports item.added.

A move that leaves the recipient's masked address unchanged now stays
quiet — a share masks its own root, so the owner shuffling it around
their tree is invisible to the recipient and the event carried nothing.
The realtime fan-out resolved its audience straight from the `share`
index, which has no live-grant check. `/auth/revoke-user-user` deletes
the permission and leaves the index row, so a revoked recipient's socket
kept receiving name, size, masked path and mtime for every write and
move under the folder, with no expiry.

The service already solves this elsewhere — `#reachingHolders` returns
exactly the holder/entry pairs whose grant still stands, and
`listSharedWithMe` was moved onto it for the same reason. The realtime
path never got the same treatment; it does now.

Free on the unshared path: with no share rows reaching the entry there
are no holders to check, so the write path every user takes is unchanged.
Pinned by a test that counts permission reads.
@jfcastro92
jfcastro92 merged commit 14ac654 into main Aug 26, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants