{"id":986,"date":"2026-11-14T09:52:38","date_gmt":"2026-11-14T09:52:38","guid":{"rendered":"https:\/\/reversea.me\/?p=986"},"modified":"2026-11-14T09:54:13","modified_gmt":"2026-11-14T09:54:13","slug":"watch-out-for-your-watchers-how-a-file-watch-bug-froze-our-encrypted-folders-and-a-one-line-bug-in-macfuse","status":"publish","type":"post","link":"https:\/\/reversea.me\/index.php\/watch-out-for-your-watchers-how-a-file-watch-bug-froze-our-encrypted-folders-and-a-one-line-bug-in-macfuse\/","title":{"rendered":"Watch Out for Your Watchers: How a File-Watch Bug Froze Our Encrypted Folders (and a One-Line Bug in macFUSE)"},"content":{"rendered":"<span class=\"span-reading-time rt-reading-time\" style=\"display: block;\"><span class=\"rt-label rt-prefix\">Reading Time: <\/span> <span class=\"rt-time\"> 11<\/span> <span class=\"rt-label rt-postfix\">minutes<\/span><\/span>\n<p class=\"wp-block-paragraph\"><strong>TL;DR<\/strong> We write our papers in LaTeX, inside folders encrypted with <a href=\"https:\/\/github.com\/vgough\/encfs\">EncFS<\/a> on top of <a href=\"https:\/\/macfuse.github.io\/\">macFUSE<\/a>, with VS Code rebuilding and refreshing the PDF preview on every save. One evening the encrypted volume froze so hard that even shutting down the Mac stalled, twice in fifteen minutes. In this post we walk through how we went from the macOS shutdown-stall reports to the root cause: since macFUSE 5.1.0, the <code>libfuse<\/code> handler for the new <code>FUSE_MONITOR<\/code> message takes a path lock and never releases it. A normal attach\/detach cycle for a per-file kqueue watch generates a <code>MONITOR<\/code> BEGIN\/END pair, and the affected handler leaks one read lock for each message. A later <code>FORGET<\/code> that drops the last lookup reference to a leaked node can then park a worker thread forever. An old accounting shortcut in <code>libfuse<\/code> leaves blocked <code>FORGET<\/code> workers counted as available, so enough such requests can exhaust the real worker pool without starting replacements. We show the bug in the source, confirm its affected release window in the binaries and source history, and reproduce the full freeze with a toy file system of a few hundred lines of C. The mechanism is demonstrated end to end; the shutdown-stall reports do not, however, identify which application created the original watches. The bug was already reported upstream (<a href=\"https:\/\/github.com\/macfuse\/macfuse\/issues\/1168\">macfuse\/macfuse#1168<\/a>) and is fixed since macFUSE 5.3.1, so the short version is: <em>update macFUSE<\/em>. The long version is below :).<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">How It Started: a <code>.tex<\/code>, a PDF Preview, and a Frozen Mac<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Our day-to-day paper writing is pretty boring, which is how we like it: <code>.tex<\/code> sources, VS Code with the <a href=\"https:\/\/github.com\/James-Yu\/LaTeX-Workshop\">LaTeX Workshop<\/a> extension, <code>latexmk<\/code> building on every save, and the PDF preview tab refreshing itself a second later. The only slightly unusual part is <em>where<\/em> the sources live. Our research folders are encrypted with EncFS, and the ciphertext is synced to our own Nextcloud server (when it works). EncFS is a FUSE file system, so on macOS it runs on top of macFUSE: the kernel extension forwards operations that require user-space handling to the <code>encfs<\/code> process, which decrypts on the fly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">On November 5, 2026, in the middle of an evening of writing, everything that touched the encrypted folder stopped responding (this was happening us few weeks ago, but we did not have enough time to dig into it until now&#8230;). The editor hung, terminals hung on <code>ls<\/code>, and <code>diskutil unmount<\/code> did not help. We rebooted. Eleven minutes after remounting the folder and getting back to work, <em>it happened again<\/em>. Second reboot.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two freezes in fifteen minutes is not bad luck, it is a bug. And since the Mac kept forensic breadcrumbs of both incidents, we went digging (well, <em>spoiler here<\/em>: <strong>the culprit is a missing <code>free_path()<\/code> call, otherwise you would not be reading this<\/strong>).<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">First Clues: Reading the Shutdown-Stall Reports<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">When macOS takes too long to shut down, <code>launchd<\/code> records a <em>shutdown stall<\/em> report in <code>\/Library\/Logs\/DiagnosticReports\/<\/code>. These reports are binary spindumps, but <code>spindump<\/code> itself can turn them into text:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">spindump -i \/Library\/Logs\/DiagnosticReports\/shutdown_stall_2026-11-05-215306_atropos.shutdownStall -o stall1.txt<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Each report contains the stack of every thread of every process still alive while the system was trying to shut down. Of course, the <code>encfs<\/code> processes were there. Figure 1 shows the process serving our research folder: the main thread waits in the <code>libfuse<\/code> session loop, as expected, while every worker thread sits on exactly the same stack. Note the <code>last ran<\/code> column: one worker had not run for 38 hours, another for 21 hours, and the rest had stopped at the same instant, less than three minutes before the shutdown.<\/p>\n\n\n\n<div class=\"wp-block-cover\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"596\" class=\"wp-block-cover__image-background wp-image-987\" alt=\"Excerpt of the decoded shutdown-stall report: the encfs process serving our research folder.\" src=\"https:\/\/reversea.me\/wp-content\/uploads\/2026\/11\/fig1_spindump-1024x596.png\" data-object-fit=\"cover\" srcset=\"https:\/\/reversea.me\/wp-content\/uploads\/2026\/11\/fig1_spindump-1024x596.png 1024w, https:\/\/reversea.me\/wp-content\/uploads\/2026\/11\/fig1_spindump-300x175.png 300w, https:\/\/reversea.me\/wp-content\/uploads\/2026\/11\/fig1_spindump-768x447.png 768w, https:\/\/reversea.me\/wp-content\/uploads\/2026\/11\/fig1_spindump-1536x894.png 1536w, https:\/\/reversea.me\/wp-content\/uploads\/2026\/11\/fig1_spindump-1440x838.png 1440w, https:\/\/reversea.me\/wp-content\/uploads\/2026\/11\/fig1_spindump.png 1800w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><span aria-hidden=\"true\" class=\"wp-block-cover__background has-background-dim\"><\/span><div class=\"wp-block-cover__inner-container is-layout-flow wp-block-cover-is-layout-flow\">\n<p class=\"has-text-align-center has-large-font-size wp-block-paragraph\"><\/p>\n<\/div><\/div>\n\n\n\n<p class=\"has-text-align-center wp-block-paragraph\"><em>Figure 1: Excerpt of the decoded shutdown-stall report: the <code>encfs<\/code> process serving our research folder (annotations ours).<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The library is stripped, but the arm64 slice of <code>libfuse.2.dylib<\/code> disassembles nicely with <code>otool -arch arm64 -tV<\/code>. The four <code>libfuse<\/code> offsets in the worker stack (59684, 69888, 26228 and 48904, that is, <code>0xe924<\/code>, <code>0x11100<\/code>, <code>0x6674<\/code> and <code>0xbf08<\/code>) are, respectively, the worker loop right after <code>fuse_session_process_buf()<\/code>, the request dispatcher, a small handler that prints <code>\"FORGET %llu\/%llu\\n\"<\/code> in debug mode, and a <code>pthread_cond_wait()<\/code> inside the function that handler calls. In other words: <strong>every worker is processing a <code>FORGET<\/code> request and waiting on a condition variable<\/strong>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">And it was not alone. Counting threads across both reports:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Report<\/th><th><code>encfs<\/code> process<\/th><th>Workers waiting at <code>0xbf08<\/code><\/th><th>Workers reading <code>\/dev\/macfuse<\/code><\/th><th>Notes<\/th><\/tr><\/thead><tbody><tr><td>21:52 (first freeze)<\/td><td>research folder<\/td><td><strong>9 \/ 9<\/strong><\/td><td>0<\/td><td>2 of them had been parked for 38 h and 21 h; the other 7 since 21:50:01<\/td><\/tr><tr><td>21:52 (first freeze)<\/td><td>second encrypted folder<\/td><td>3 \/ 9<\/td><td>6<\/td><td>2 parked for ~37 h, 1 since 21:49: quietly heading the same way<\/td><\/tr><tr><td>22:09 (second freeze)<\/td><td>research folder, remounted<\/td><td><strong>10 \/ 10<\/strong><\/td><td>0<\/td><td>all parked within 11 minutes of mounting<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">So the workers were not stuck on I\/O, on decryption, or on Nextcloud. They were waiting, one by one, for something that never came, and once the last one got in line, nobody was left to read requests from the kernel. Time to read some source code.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Into the Source: Path Locks in High-Level <code>libfuse<\/code><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">EncFS uses the <em>high-level<\/em> FUSE API, where the file system receives paths (<code>\/papers\/draft\/paper.pdf<\/code>) instead of inode numbers. To keep paths consistent while requests run concurrently, <code>libfuse<\/code> maintains an in-memory tree of nodes and a per-node lock counter, <code>treelock<\/code>: positive values count readers, <code>-1<\/code> (<code>TREELOCK_WRITE<\/code>) marks a writer. macFUSE maintains its own fork of <code>libfuse<\/code> 2.9 in <a href=\"https:\/\/github.com\/macfuse\/library\">macfuse\/library<\/a> (branch <code>support\/libfuse-2.9<\/code>), and the read-locking part of <code>try_get_path()<\/code> in <code>lib\/fuse.c<\/code> looks like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">for (node = get_node(f, nodeid); node-&gt;nodeid != FUSE_ROOT_ID;\n     node = node-&gt;parent) {\n\t...\n\tif (need_lock) {\n\t\terr = -EAGAIN;\n\t\tif (node-&gt;treelock &lt; 0)\n\t\t\tgoto out_unlock;\n\n\t\tnode-&gt;treelock++;\n\t}\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note the loop: a read lock on a path increments <code>treelock<\/code> on the node <strong>and on every ancestor directory<\/strong> up to (but not including) the root. Every <code>get_path()<\/code> must be paired with a <code>free_path()<\/code>, which walks the same chain decrementing the counters.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now, the function our threads were stuck in. The <code>FORGET<\/code> handler calls <code>forget_node()<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">static void forget_node(struct fuse *f, fuse_ino_t nodeid, uint64_t nlookup)\n{\n\t...\n\t\/*\n\t * Node may still be locked due to interrupt idiocy in open,\n\t * create and opendir\n\t *\/\n\twhile (node-&gt;nlookup == nlookup &amp;&amp; node-&gt;treelock) {\n\t\tstruct lock_queue_element qe = {\n\t\t\t.nodeid1 = nodeid,\n\t\t};\n\n\t\tdebug_path(f, \"QUEUE PATH (forget)\", nodeid, NULL, false);\n\t\tqueue_path(f, &amp;qe);\n\n\t\tdo {\n\t\t\tpthread_cond_wait(&amp;qe.cond, &amp;f-&gt;lock);\n\t\t} while (node-&gt;nlookup == nlookup &amp;&amp; node-&gt;treelock);\n\t\t...<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">When the kernel asks the file system to drop the <em>last lookup reference<\/em> known to <code>libfuse<\/code> (<code>node-&gt;nlookup == nlookup<\/code>), <code>forget_node()<\/code> waits until nobody holds a path lock on that node. That <code>pthread_cond_wait()<\/code> is our <code>0xbf08<\/code>. If <code>treelock<\/code> never goes back to zero, the thread never comes back. So the question becomes: <strong>who is leaking path locks?<\/strong><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The Bug: a <code>MONITOR<\/code> That Never Lets Go<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">macFUSE 5.1.0 (October 30, 2025) added support for a new macOS-specific message, <code>FUSE_MONITOR<\/code> (opcode 60), in commit <a href=\"https:\/\/github.com\/macfuse\/library\/commit\/67aa41ee6c8456180526e1023217a1c6a3eff921\"><code>67aa41ee<\/code><\/a>. The kernel sends it to tell the file system that the number of watchers of a file has changed (<code>FUSE_MONITOR_BEGIN<\/code> \/ <code>FUSE_MONITOR_END<\/code>). Here is the high-level handler as it shipped:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">static void fuse_lib_monitor(fuse_req_t req, fuse_ino_t ino, uint32_t flags)\n{\n\tstruct fuse *f = req_fuse_prepare(req);\n\tchar *path;\n\tint err;\n\n\terr = get_path(f, ino, &amp;path);   \/* read lock on the node and its ancestors *\/\n\tif (err) {\n\t\tfuse_reply_none(req);\n\t\treturn;\n\t}\n\n\tfuse_fs_monitor(f-&gt;fs, path, flags);\n\tfuse_reply_none(req);            \/* ...and no free_path(). Ever. *\/\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">There is a <code>get_path()<\/code> but no <code>free_path()<\/code>. Every <code>MONITOR<\/code> message leaves <code>treelock<\/code> incremented by one on the watched node and on each parent directory up to, but not including, the root. Two details make this worse than it looks:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>It does not matter whether the file system cares about monitoring.<\/strong> EncFS does not implement the <code>.monitor<\/code> operation (<code>fuse_fs_monitor()<\/code> simply does nothing), but <code>fuse_lib_monitor()<\/code> is registered unconditionally in <code>fuse_path_ops<\/code>, so the lock is taken anyway.<\/li>\n\n\n\n<li><strong>A normal per-file watch lifecycle costs two leaks.<\/strong> To know where <code>VNOP_MONITOR<\/code> comes from, we looked at XNU. In <a href=\"https:\/\/github.com\/apple-oss-distributions\/xnu\/blob\/main\/bsd\/vfs\/vfs_vnops.c\"><code>bsd\/vfs\/vfs_vnops.c<\/code><\/a>, the vnode kqueue attach path calls <code>VNOP_MONITOR(vp, 0, VNODE_MONITOR_BEGIN, ...)<\/code> after a filter is attached successfully, and <code>filt_vndetach()<\/code> normally calls it again with <code>VNODE_MONITOR_END<\/code> when the filter goes away. macFUSE forwards both as <code>FUSE_MONITOR<\/code>. Thus a successful attach plus detach leaks two locks; either message by itself leaks one.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Who generates those per-file kqueue filters? <a href=\"https:\/\/nodejs.org\/api\/fs.html#availability\">Node.js documents<\/a> that <code>fs.watch()<\/code> uses kqueue for files and FSEvents for directories on macOS. Against a test mount, <code>fs.watch()<\/code> on a single file produced exactly one <code>MONITOR<\/code> BEGIN\/END pair, while <code>fs.watch()<\/code> on a directory and <code>fs.watchFile()<\/code> (polling) produced none. This proves a concrete trigger, but not the identity of the process that triggered our incident.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Our LaTeX workflow initially looked like the obvious suspect, but a closer check sets an important limit on that claim. The installed LaTeX Workshop 10.20.0 creates VS Code <code>FileSystemWatcher<\/code>s from <a href=\"https:\/\/github.com\/James-Yu\/LaTeX-Workshop\/blob\/2f5a60a11a99f33013c1235f0499546cc831cfcb\/src\/core\/watcher.ts\">directory glob patterns<\/a>, and normally <a href=\"https:\/\/github.com\/James-Yu\/LaTeX-Workshop\/blob\/2f5a60a11a99f33013c1235f0499546cc831cfcb\/src\/compile\/executor.ts\">refreshes the project PDF directly after a successful build<\/a>. The shutdown-stall reports preserve thread stacks, not the process that originally installed the watches. We therefore cannot name the PDF preview\u2014or even LaTeX Workshop\u2014as the culprit from the surviving evidence. Another VS Code component or any other application using a per-file kqueue watch could have supplied the <code>MONITOR<\/code> traffic. What the reports, source analysis and PoC establish together is the failure chain once those stale path locks exist.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fix, in commit <a href=\"https:\/\/github.com\/macfuse\/library\/commit\/c3ff8d98e06c7194591dbf8076da4ada1c97f519\"><code>c3ff8d98<\/code><\/a> (June 8, 2026), is literally one line:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\"> <code>\tfuse_fs_monitor(f-&gt;fs, path, flags);\n+\tfree_path(f, ino, path);\n \tfuse_reply_none(req);<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">From a Leaked Lock to a Frozen Volume<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A leaked read lock has two visible effects. The first one is the symptom described in issue <a href=\"https:\/\/github.com\/macfuse\/macfuse\/issues\/1168\">#1168<\/a>: an operation that needs a <em>write<\/em> lock on the leaked node or path (<code>unlink<\/code>, <code>rmdir<\/code>, <code>rename<\/code>) finds <code>treelock &gt; 0<\/code>, queues up, and waits forever. That is bad, but it consumes one thread per stuck request, and ordinary requests decrement <code>numavail<\/code>, allowing <code>libfuse<\/code> to start replacement workers.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The second effect matches the terminal mechanism in our shutdown-stall reports. When the kernel reclaims a vnode and sends a <code>FORGET<\/code> that drops its final lookup count (for example after cache reclamation, deletion or during unmount), <code>forget_node()<\/code> can park the worker forever on the leaked <code>treelock<\/code>. And here comes the second ingredient, which has been in upstream <code>libfuse<\/code> for years. In <code>lib\/fuse_loop_mt.c<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">\/*\n * This disgusting hack is needed so that zillions of threads\n * are not created on a burst of FORGET messages\n *\/\nif (!(fbuf.flags &amp; FUSE_BUF_IS_FD)) {\n\tstruct fuse_in_header *in = fbuf.mem;\n\n\tif (in-&gt;opcode == FUSE_FORGET ||\n\t    in-&gt;opcode == FUSE_BATCH_FORGET)\n\t\tisforget = 1;\n}\n\nif (!isforget)\n\tmt-&gt;numavail--;\nif (mt-&gt;numavail == 0)\n\tfuse_loop_start_thread(mt);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A worker handling a <code>FORGET<\/code> does <em>not<\/em> decrement <code>numavail<\/code>, an optimisation intended to avoid spawning many threads for a burst of one-way <code>FORGET<\/code> requests. With the leak, the request is no longer quick: each final <code>FORGET<\/code> on a leaked node can park one worker while the accounting still treats it as available. As long as the stale counter remains nonzero, no replacement is started. Once enough workers are parked, nobody reads <code>\/dev\/macfuse<\/code> anymore, and every operation that requires a request to the file system daemon\u2014including unmount and shutdown work\u2014waits forever. Figure 2 puts the whole chain together.<\/p>\n\n\n\n<div class=\"wp-block-cover\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"546\" class=\"wp-block-cover__image-background wp-image-988\" alt=\"From a kqueue watch to a frozen FUSE volume.\" src=\"https:\/\/reversea.me\/wp-content\/uploads\/2026\/11\/fig2_deadlock-1024x546.png\" data-object-fit=\"cover\" srcset=\"https:\/\/reversea.me\/wp-content\/uploads\/2026\/11\/fig2_deadlock-1024x546.png 1024w, https:\/\/reversea.me\/wp-content\/uploads\/2026\/11\/fig2_deadlock-300x160.png 300w, https:\/\/reversea.me\/wp-content\/uploads\/2026\/11\/fig2_deadlock-768x410.png 768w, https:\/\/reversea.me\/wp-content\/uploads\/2026\/11\/fig2_deadlock-1536x819.png 1536w, https:\/\/reversea.me\/wp-content\/uploads\/2026\/11\/fig2_deadlock-1440x768.png 1440w, https:\/\/reversea.me\/wp-content\/uploads\/2026\/11\/fig2_deadlock.png 1800w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><span aria-hidden=\"true\" class=\"wp-block-cover__background has-background-dim\"><\/span><div class=\"wp-block-cover__inner-container is-layout-flow wp-block-cover-is-layout-flow\">\n<p class=\"has-text-align-center has-large-font-size wp-block-paragraph\"><\/p>\n<\/div><\/div>\n\n\n\n<p class=\"has-text-align-center alignwide wp-block-paragraph\"><em>Figure 2: From a kqueue watch to a frozen FUSE volume.<\/em><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This mechanism is also consistent with the timeline in the stall reports: a couple of workers had been parked over a day and a half of normal use, and then the remaining seven stopped within the same second during a busy writing session. The stacks prove where the workers ended up; the source history and PoC explain how the <code>MONITOR<\/code> leak can put them there.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Confirming It in the Binaries<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Reading source is nice, but what runs on the machine is the binary. We downloaded the official packages from the <a href=\"https:\/\/github.com\/macfuse\/macfuse\/releases\">macFUSE releases page<\/a>, extracted <code>libfuse.2.dylib<\/code> with <code>pkgutil --expand-full<\/code>, and located <code>fuse_lib_monitor()<\/code> in the arm64 slice by following the calls to the exported <code>fuse_fs_monitor()<\/code> (the other callers are the stacking modules, such as <code>iconv<\/code> or <code>volicon<\/code>). This is the official version 5.1.3 binary\u2014the version installed on the affected Mac\u2014and its worker-stack offsets match those in the shutdown-stall reports (only the calls are shown):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">abc8  sub  sp, sp, #0x40\nabe8  bl   0xb9f0                   ; req_fuse_prepare()\nac00  bl   0xc068                   ; get_path()  -&gt; read lock taken\nac14  bl   _fuse_fs_monitor\nac1c  bl   _fuse_reply_none         ; ...and that's it<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">And this is version 5.5.0, where <code>free_path()<\/code> has been inlined after the call:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">b058  bl   0xbea4                   ; req_fuse_prepare()\nb070  bl   0xc528                   ; get_path()\nb084  bl   _fuse_fs_monitor\nb094  bl   _pthread_mutex_lock      ; free_path(), inlined:\nb0a8  bl   0xcb08                   ;   unlock_path()\nb0b8  bl   0xcbe0                   ;   wake_up_queued()\nb0c0  bl   _pthread_mutex_unlock\nb0c8  bl   _free\nb0d0  bl   _fuse_reply_none<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Repeating the check on other releases gives the window of affected versions:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>macFUSE<\/th><th>Release date<\/th><th><code>FUSE_MONITOR<\/code> handler<\/th><th>Lock released<\/th><\/tr><\/thead><tbody><tr><td>5.0.7<\/td><td>2025-09-30<\/td><td>no<\/td><td>n\/a<\/td><\/tr><tr><td>5.1.0<\/td><td>2025-10-30<\/td><td>yes<\/td><td><strong>no<\/strong><\/td><\/tr><tr><td>5.3.0<\/td><td>2026-06-07<\/td><td>yes<\/td><td><strong>no<\/strong><\/td><\/tr><tr><td>5.3.1<\/td><td>2026-06-13<\/td><td>yes<\/td><td>yes<\/td><\/tr><tr><td>5.5.0<\/td><td>2026-10-17<\/td><td>yes<\/td><td>yes<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">We checked the binaries listed in the table directly. The introducing code landed before 5.1.0 and remained unchanged until the fix released in 5.3.1, which brackets the intermediate 5.1.1\u20135.2.0 releases as affected; a commenter on issue #1168 also independently reported the bug on 5.2.0. We confirmed in the 5.1.3 binary that the <code>FORGET<\/code> shortcut is present too: the worker loop compares the opcode against <code>0x2<\/code> (<code>FUSE_FORGET<\/code>) and <code>0x2a<\/code> (<code>FUSE_BATCH_FORGET<\/code>) and skips the <code>numavail<\/code> decrement for both.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Reproducing the Freeze (Without Rebooting This Time)<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The bug lives entirely in user space, inside <code>libfuse.2.dylib<\/code>, so there is no need to downgrade the kernel extension to reproduce it: a test file system linked against the old library runs fine on top of the current, fixed macFUSE. We wrote three small pieces:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>memfs.c<\/code>, a minimal in-memory high-level FUSE file system that, like EncFS, does not implement <code>.monitor<\/code>;<\/li>\n\n\n\n<li><code>kqwatch.c<\/code>, which attaches and detaches an <code>EVFILT_VNODE<\/code> kqueue filter on each path it receives (i.e., it is a very boring file watcher);<\/li>\n\n\n\n<li><code>repro.sh<\/code>, which mounts <code>memfs<\/code> with <code>libfuse<\/code> debug output (<code>-d<\/code>), runs each step under a hard timeout, and kills the file system with <code>SIGKILL<\/code> at the end, so a frozen mount cannot outlive the experiment.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The core of <code>kqwatch<\/code> is just this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">int fd = open(path, O_EVTONLY);\nint kq = kqueue();\nstruct kevent ev;\nEV_SET(&amp;ev, fd, EVFILT_VNODE, EV_ADD | EV_CLEAR,\n       NOTE_WRITE | NOTE_DELETE | NOTE_RENAME, 0, NULL);\nkevent(kq, &amp;ev, 1, NULL, 0, NULL);   \/* attach -&gt; MONITOR(BEGIN) *\/\nclose(kq);                           \/* detach -&gt; MONITOR(END)   *\/\nclose(fd);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Each scenario was run twice: once linked against <code>libfuse<\/code> 5.1.3 (affected) and once against the installed 5.5.0 (fixed). The <code>leak<\/code> scenario watches <code>d\/x<\/code> three times and then deletes it; the <code>wedge<\/code> scenario watches 24 files once each and then unmounts the volume, which makes the kernel send one <code>FORGET<\/code> per file:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Scenario<\/th><th><code>libfuse<\/code><\/th><th><code>MONITOR<\/code><\/th><th><code>FORGET<\/code><\/th><th><code>QUEUE PATH<\/code> \/ <code>DEQUEUE<\/code><\/th><th>Outcome<\/th><\/tr><\/thead><tbody><tr><td>leak<\/td><td>5.5.0<\/td><td>6<\/td><td>0<\/td><td>0 \/ 0<\/td><td><code>rm<\/code> completes<\/td><\/tr><tr><td>leak<\/td><td>5.1.3<\/td><td>6<\/td><td>0<\/td><td>1 \/ 0<\/td><td><code>rm<\/code> hangs (killed after 10 s)<\/td><\/tr><tr><td>wedge<\/td><td>5.5.0<\/td><td>48<\/td><td>24<\/td><td>0 \/ 0<\/td><td>unmount completes<\/td><\/tr><tr><td>wedge<\/td><td>5.1.3<\/td><td>48<\/td><td>4<\/td><td>4 \/ 0<\/td><td>unmount hangs, all 4 workers parked, 0 reading <code>\/dev\/macfuse<\/code><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The debug log of the <code>leak<\/code> run tells the story in a few lines (flags <code>1<\/code> and <code>2<\/code> are <code>BEGIN<\/code> and <code>END<\/code>):<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">unique: 4, opcode: MONITOR (60), nodeid: 3, insize: 48, pid: 90437\nmonitor \/d\/x 2\n...\nQUEUE PATH 3 (w)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">There is no matching <code>DEQUEUE PATH<\/code>. In the <code>wedge<\/code> run, the file system had four workers alive, and the first four <code>FORGET<\/code>s took all of them:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code class=\"\">unique: 50, opcode: FORGET (2), nodeid: 25, insize: 48, pid: 90614\nFORGET 25\/1\nQUEUE PATH (forget) 25\n...<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The other 20 <code>FORGET<\/code>s and the final <code>DESTROY<\/code> were never read. The stacks of the parked workers, taken with <code>sample<\/code>, show the same return addresses we had found in the shutdown-stall reports of our real <code>encfs<\/code>: <code>0xe924<\/code> \u2192 <code>0x11100<\/code> \u2192 <code>0x6674<\/code> \u2192 <code>0xbf08<\/code>. The PoC therefore reproduces the terminal worker-pool state on demand, although it cannot retrospectively identify the application that created the original watches. Good news for our blood pressure: after the driver timed out and killed the blocked <code>rm<\/code> or <code>umount<\/code>, killing the test file system with <code>SIGKILL<\/code> and force-unmounting recovered the mount point in every run; no reboot was needed.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Is This a Vulnerability?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The impact is a local denial of service. The most specific CWE mapping is <a href=\"https:\/\/cwe.mitre.org\/data\/definitions\/764.html\">CWE-764<\/a>, <em>Multiple Locks of a Critical Resource<\/em>: every <code>MONITOR<\/code> takes the path lock again without the matching unlock. <a href=\"https:\/\/cwe.mitre.org\/data\/definitions\/772.html\">CWE-772<\/a>, <em>Missing Release of Resource after Effective Lifetime<\/em>, also describes the missing release. We avoid CWE-833 here because its definition requires threads or executable segments waiting for <em>each other<\/em>; this failure is an indefinite wait on an abandoned lock count, not a circular deadlock. Creating a leaked lock requires only read access to a file on the volume: <code>open(O_EVTONLY)<\/code> plus a kqueue filter, with no special entitlement. One watched node is enough to wedge a later write-locking operation on that path; driving the whole volume into the worker-pool failure requires enough leaked nodes to reach final <code>FORGET<\/code>s. By default, macFUSE limits a volume to the user who mounted it, so in most setups the triggering software runs as that same user. With <code>allow_other<\/code>, any local user who can open suitable files on the volume can trigger the same denial of service for all its users. The bug was reported publicly in May 2026 and fixed in June 2026; we are not aware of a CVE assigned to it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Takeaways<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Update macFUSE to 5.3.1 or later.<\/strong> If you installed it with Homebrew, note that the cask is marked <code>auto_updates<\/code>, so a plain <code>brew upgrade<\/code> skips it, and Homebrew may even report an outdated version. Use <code>brew upgrade --cask --greedy macfuse<\/code>, or check the real version with <code>defaults read \/Library\/Filesystems\/macfuse.fs\/Contents\/Info.plist CFBundleShortVersionString<\/code>.<\/li>\n\n\n\n<li><strong>If a FUSE volume freezes,<\/strong> look at the threads of the file system process (<code>sample &lt;pid&gt; 1<\/code>) before rebooting. Workers parked in <code>pthread_cond_wait<\/code> under the <code>FORGET<\/code> handler are consistent with an unreleased high-level path lock; check the macFUSE version and debug log for <code>MONITOR<\/code> plus <code>QUEUE PATH (forget)<\/code> before attributing it to this specific bug. <code>kill -9<\/code> on the file system process followed by <code>umount -f<\/code> was enough to recover in our tests.<\/li>\n\n\n\n<li><strong>Watchers are not free.<\/strong> A per-file kqueue watcher looks like a passive observer, but on macOS a successful attach and normal detach on a macFUSE vnode each become a round trip to the file system daemon\u2014and, in the affected releases, a leaked path lock.<\/li>\n\n\n\n<li><strong>The <code>FORGET<\/code> shortcut is still there.<\/strong> The fix removes this leak, but <code>fuse_loop_mt.c<\/code> still does not count <code>FORGET<\/code> workers as busy. Another bug that leaves high-level path locks held could again escalate into a frozen volume if enough final <code>FORGET<\/code>s block. A defensive fix (for instance, counting a <code>FORGET<\/code> worker as busy while it blocks in <code>forget_node()<\/code>) would make this failure mode much less dramatic.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p class=\"wp-block-paragraph\"><em>And that&#8217;s all, folks! The PoC (toy file system, watcher, test driver, a script to check macFUSE releases at the binary level, and the debug logs of the runs above) is <a href=\"https:\/\/github.com\/reverseame\/macfuse-monitor-repro\">available on GitHub<\/a>. Many thanks to the macFUSE maintainer and to the reporters of <a href=\"https:\/\/github.com\/macfuse\/macfuse\/issues\/1168\">#1168<\/a>, who had already done the hard part. If you are into file system internals, crash-report forensics, or reverse engineering stripped libraries, <a href=\"mailto:reverseame@unizar.es\">reach out to us<\/a>. Thanks for reading, and stay secure (and keep your macFUSE updated)!<\/em><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Declaration of Generative AI Technologies in the Writing Process<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">During the preparation of this post, the author used Claude (Claude Opus 5.5 model) to analyze the shutdown-stall reports, disassemble the <code>libfuse<\/code> binaries, build and run the reproduction, and improve readability and language. After using this tool, the author reviewed and edited the content as necessary and takes full responsibility for the content of this publication.<\/p>\n","protected":false},"excerpt":{"rendered":"<p><span class=\"span-reading-time rt-reading-time\" style=\"display: block;\"><span class=\"rt-label rt-prefix\">Reading Time: <\/span> <span class=\"rt-time\"> 11<\/span> <span class=\"rt-label rt-postfix\">minutes<\/span><\/span>TL;DR We write our papers in LaTeX, inside folders encrypted with EncFS on top of macFUSE, with VS Code rebuilding and refreshing the PDF preview on every save. One evening the encrypted volume froze so hard that even shutting down the Mac stalled, twice in fifteen minutes. In this post we walk through how we [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[16,27,42],"tags":[86,85,83,87,82,84],"class_list":["post-986","post","type-post","status-publish","format-standard","hentry","category-forensics","category-reverse-engineering","category-vulnerability-analysis","tag-crash-report-analysis","tag-denial-of-service","tag-encfs","tag-file-watchers","tag-macfuse","tag-macos","no-featured-image"],"_links":{"self":[{"href":"https:\/\/reversea.me\/index.php\/wp-json\/wp\/v2\/posts\/986","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/reversea.me\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/reversea.me\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/reversea.me\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/reversea.me\/index.php\/wp-json\/wp\/v2\/comments?post=986"}],"version-history":[{"count":2,"href":"https:\/\/reversea.me\/index.php\/wp-json\/wp\/v2\/posts\/986\/revisions"}],"predecessor-version":[{"id":990,"href":"https:\/\/reversea.me\/index.php\/wp-json\/wp\/v2\/posts\/986\/revisions\/990"}],"wp:attachment":[{"href":"https:\/\/reversea.me\/index.php\/wp-json\/wp\/v2\/media?parent=986"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/reversea.me\/index.php\/wp-json\/wp\/v2\/categories?post=986"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/reversea.me\/index.php\/wp-json\/wp\/v2\/tags?post=986"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}