Skip to content

fix(metadata): normalize backslashes in virtual paths to prevent FUSE open() deadlock#685

Open
firestaerter3 wants to merge 2 commits into
javi11:mainfrom
firestaerter3:fix/660-backslash-metadata-deadlock
Open

fix(metadata): normalize backslashes in virtual paths to prevent FUSE open() deadlock#685
firestaerter3 wants to merge 2 commits into
javi11:mainfrom
firestaerter3:fix/660-backslash-metadata-deadlock

Conversation

@firestaerter3

Copy link
Copy Markdown
Contributor

Closes #660.

A file whose name contains a backslash deadlocks the FUSE page-cache layer on open() — the kernel hangs in invalidate_inode_pages2 / folio_wait_bit_common and the reader is stuck in uninterruptible D-state until the host is rebooted.

Root cause

The metadata service persisted and served paths verbatim. WriteFileMetadata runs filepath.Base on the raw virtualPath, so a path like Release/A.mkv\A.mkv lands on disk as a .meta file literally named A.mkv\A.mkv.meta, and ListDirectory/ListDirectoryAll serve that name over WebDAV. Database lookups (nzbfilesystem.normalizePath) already convert \ to /, so writes and lookups disagreed. #74 normalized the RAR/7zip/filesystem extraction layers, but not the metadata-service chokepoint every import ultimately writes through.

Fix

Normalize \/ (matching nzbfilesystem.normalizePath, so writes and lookups resolve to the same key) at the metadata-service path entry points, so no backslash path is ever persisted or served. Adds a regression test that writes a backslash path and asserts no persisted .meta name contains a backslash and that a read via the original backslash path still resolves.

Note

This prevents new backslash paths. Existing .meta files from before this change still carry the backslash and need a one-off rename (or re-import) to recover.

)

A backslash (0x5C) is a legal Linux filename byte but deadlocks the FUSE page-cache layer on open(): the kernel hangs in invalidate_inode_pages2 (folio_wait_bit_common) and the caller goes uninterruptible D-state until the host is rebooted.

WriteFileMetadata ran filepath.Base on the raw virtualPath, so a path like Release/A.mkv\A.mkv was written as a .meta file named A.mkv\A.mkv.meta and served verbatim by ListDirectory over WebDAV, while DB lookups (nzbfilesystem.normalizePath) already converted backslashes. PR javi11#74 normalized the RAR/7zip/filesystem extraction layers but not the metadata-service chokepoint every import writes through.

Normalize backslash to forward slash at the metadata path entry points so no backslash path is ever persisted or served. Adds a regression test. Existing .meta files predating this change need a one-off rename or re-import.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3fe3d5628c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


// WriteFileMetadata writes file metadata to disk
func (ms *MetadataService) WriteFileMetadata(virtualPath string, metadata *metapb.FileMetadata) error {
virtualPath = normalizeVirtualPath(virtualPath)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Normalize the shared deletion entry point

When a single-file import uses a name like Release/A.mkv\A.mkv, this line persists it as Release/A.mkv/A.mkv, but callers keep the original string in writtenPaths/health records (ScheduleHealthCheck stores p as the FilePath). I checked the cleanup/API paths and they call DeleteFileMetadataWithSourceNzb directly rather than this normalized wrapper, so deleting the health item or zombie record with the stored backslash path builds the old literal-backslash .meta path, silently removes nothing, and leaves the normalized metadata visible. Normalize inside DeleteFileMetadataWithSourceNzb itself (including the cache key) so all direct callers delete the same path that writes now create.

Useful? React with 👍 / 👎.

… chokepoint

Replace the per-method normalizeVirtualPath calls with two private helpers
(metaFilePath/metaDirPath) that every on-disk path flows through, so no method —
current or future — can persist or serve a backslash (javi11#660).

This also covers the methods the per-method approach missed:
- RenameFileMetadata (renaming into a backslash path re-persisted a backslash
  .meta name, re-triggering javi11#660)
- DeleteFileMetadataWithSourceNzb when called directly
- MoveToCorrupted

Side effect: ReadFileMetadata and GetMetadataFilePath now apply truncateFilename
like WriteFileMetadata, fixing a latent read/write mismatch for >250-char names.

Adds regression tests for the rename and direct-delete paths.
@javi11

javi11 commented Jun 15, 2026

Copy link
Copy Markdown
Owner

Thanks for tracking this down and writing it up so clearly @firestaerter3 — the root-cause analysis (metadata service being the chokepoint every import writes through, and the write/lookup disagreement vs nzbfilesystem.normalizePath) is spot on, and the regression test is great.

I pushed a follow-up commit (fa243c60) on top of your work that keeps your exact fix and diagnosis but reworks where the normalization lives:

  • Single chokepoint instead of per-method. I moved disk-path construction into two private helpers, metaFilePath / metaDirPath, that normalize (and truncate) internally. Every method now routes through them, so it's structurally impossible for any method — current or future — to persist or serve a backslash, rather than relying on remembering to call normalizeVirtualPath at each entry point.
  • Covers three methods the per-method approach didn't reach. RenameFileMetadata built its .meta paths from the raw old/new paths, so renaming into a backslash path would re-persist a backslash name and re-trigger FUSE open() on filename containing backslash deadlocks indefinitely in invalidate_inode_pages2 #660. DeleteFileMetadataWithSourceNzb (when called directly) and MoveToCorrupted were also unnormalized.
  • Side benefit: ReadFileMetadata / GetMetadataFilePath previously skipped truncateFilename while WriteFileMetadata applied it — a latent read/write mismatch for >250-char names. Routing both through metaFilePath aligns them.

Added regression tests for the rename and direct-delete paths. gofmt, go build ./..., go vet, and go test -race ./internal/metadata/... all pass.

One thing neither commit handles: existing .meta files written with a backslash before this change still need a one-off rename or re-import — could be a follow-up. Happy to adjust anything here.

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.

FUSE open() on filename containing backslash deadlocks indefinitely in invalidate_inode_pages2

2 participants