From dc84254effd6f0632976568f87292ee85df2537a Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Thu, 17 Sep 2026 23:17:09 -0500 Subject: [PATCH 1/2] tests: pin AudiobookFile.Size to the audio file, which fails today Registration records the size by stat'ing the lease's MetadataPath. On Linux and macOS that is a descriptor path, so the row gets the descriptor link's own 64 bytes instead of the audio file's length. Nothing in the suite noticed: AudioFileServiceTests asserted Size nowhere, and the one lease double it has sets MetadataPath equal to PublicPath, which is the Windows shape, where the two paths agree and the bug cannot appear. The new double gives MetadataPath its own 64-byte file, matching what stat reports for a /proc//fd/ link, and covers both lease shapes: one that serves a generation-bound read stream and one that does not. Both register a 12,345 byte file and both currently record 64. DivergentDescriptorFixture_ReportsDescriptorLengthRatherThanAudioLength is the control. Point the fixture's two paths at one file and the registration tests go green without exercising anything, so that test asserts the divergence the other two depend on. Co-Authored-By: Claude Opus 5 (1M context) --- .../Audiobooks/Files/AudioFileServiceTests.cs | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/tests/Features/Application/Audiobooks/Files/AudioFileServiceTests.cs b/tests/Features/Application/Audiobooks/Files/AudioFileServiceTests.cs index e965adb8a..ec47063d3 100644 --- a/tests/Features/Application/Audiobooks/Files/AudioFileServiceTests.cs +++ b/tests/Features/Application/Audiobooks/Files/AudioFileServiceTests.cs @@ -1003,6 +1003,138 @@ public async Task EnsureAudiobookFileAsync_PersistsMetadataFromMetadataService() Assert.Equal(1, file.Channels); } + private const long DescriptorLinkReportedLength = 64; + + [Fact] + public async Task DivergentDescriptorFixture_ReportsDescriptorLengthRatherThanAudioLength() + { + var fixture = await CreateDivergentDescriptorFixtureAsync(); + + // Control for the two registration tests below. They only prove + // anything while stat'ing the descriptor path gives a different + // answer from stat'ing the audio file, so assert the divergence + // directly: if the fixture ever pointed both paths at the same + // file, those tests would pass without exercising the fault. + Assert.Equal( + DescriptorLinkReportedLength, + new FileInfo(fixture.DescriptorPath).Length); + Assert.Equal( + fixture.AudioLength, + new FileInfo(fixture.AudioPath).Length); + Assert.NotEqual( + new FileInfo(fixture.DescriptorPath).Length, + new FileInfo(fixture.AudioPath).Length); + } + + [Fact] + public async Task EnsureAudiobookFileAsync_LeaseExposingGenerationBoundRead_RecordsAudioFileSize() + { + var fixture = await CreateDivergentDescriptorFixtureAsync(); + _audiobook.BasePath = Path.GetDirectoryName(fixture.AudioPath); + await _audiobookRepository.UpdateAsync(_audiobook); + using var registrationLease = new DescriptorMetadataPathRegistrationLease( + fixture.AudioPath, + fixture.DescriptorPath, + "descriptor-metadata-generation", + exposesGenerationBoundRead: true); + + Assert.True(await _provider + .GetRequiredService() + .EnsureAudiobookFileAsync(_audiobook, registrationLease, "test")); + + var file = Assert.Single( + await _audiobookFileRepository.GetByAudiobookIdAsync(_audiobook.Id)); + Assert.Equal(fixture.AudioLength, file.Size); + } + + [Fact] + public async Task EnsureAudiobookFileAsync_LeaseWithoutGenerationBoundRead_RecordsAudioFileSize() + { + var fixture = await CreateDivergentDescriptorFixtureAsync(); + _audiobook.BasePath = Path.GetDirectoryName(fixture.AudioPath); + await _audiobookRepository.UpdateAsync(_audiobook); + using var registrationLease = new DescriptorMetadataPathRegistrationLease( + fixture.AudioPath, + fixture.DescriptorPath, + "descriptor-metadata-generation", + exposesGenerationBoundRead: false); + + Assert.True(await _provider + .GetRequiredService() + .EnsureAudiobookFileAsync(_audiobook, registrationLease, "test")); + + var file = Assert.Single( + await _audiobookFileRepository.GetByAudiobookIdAsync(_audiobook.Id)); + Assert.Equal(fixture.AudioLength, file.Size); + } + + private sealed record DivergentDescriptorFixture( + string AudioPath, + string DescriptorPath, + long AudioLength); + + private static async Task + CreateDivergentDescriptorFixtureAsync() + { + var directory = Directory + .CreateTempSubdirectory($"afs-descriptor-{Guid.NewGuid():N}") + .FullName; + var audioPath = Path.Combine(directory, "book.m4b"); + var audioBytes = new byte[12_345]; + await File.WriteAllBytesAsync(audioPath, audioBytes); + + // Stands in for the lease's MetadataPath on Linux and macOS, which is + // a descriptor path (/proc//fd/ or /dev/fd/). Stat'ing one + // reports the descriptor link's own size, 64 bytes, rather than the + // length of the file the descriptor pins. + var descriptorPath = Path.Combine(directory, "descriptor-link-stand-in"); + await File.WriteAllBytesAsync( + descriptorPath, + new byte[DescriptorLinkReportedLength]); + + return new DivergentDescriptorFixture( + audioPath, + descriptorPath, + audioBytes.Length); + } + + private sealed class DescriptorMetadataPathRegistrationLease( + string publicPath, + string metadataPath, + string physicalObjectIdentity, + bool exposesGenerationBoundRead) : IAudiobookFileRegistrationLease + { + public string PublicPath { get; } = publicPath; + public string MetadataPath { get; } = metadataPath; + public string PhysicalObjectIdentity { get; } = physicalObjectIdentity; + public string? SourcePhysicalObjectIdentity => null; + + // The pinned lease serves this from the descriptor it holds, so the + // stream reports the pinned file's length. Leases without a + // generation-bound read reproduce the interface default, which throws. + public Stream OpenMetadataReadStream() => + exposesGenerationBoundRead + ? File.OpenRead(PublicPath) + : throw new NotSupportedException( + "This registration lease does not expose generation-bound metadata reads."); + + public bool MatchesCurrentPublication() => true; + + public bool PrepareCleanupRecovery(int audiobookId) => true; + + public RegistrationPublicationCompletion CompletePublication() => + RegistrationPublicationCompletion.Completed; + + public Task MatchesContentAsync( + Stream candidateStream, + CancellationToken cancellationToken = default) => + Task.FromResult(true); + + public void Dispose() + { + } + } + private sealed class SequencedRegistrationLease( string path, string physicalObjectIdentity, From 0255f9a20462976f5eaacce736dadb1922b1718d Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Thu, 17 Sep 2026 23:20:31 -0500 Subject: [PATCH 2/2] fix: record AudiobookFile.Size from the audio file, not the descriptor path EnsureAudiobookFileCoreAsync took the size by stat'ing metadataPath, which is the lease's MetadataPath whenever a lease is present. On Linux and macOS that is a descriptor path, /proc//fd/ or /dev/fd/, so stat reports the descriptor link's own size rather than the length of the file it pins. Every row created through this path on Linux recorded 64 bytes, which is what stat returns for a procfs fd link. Windows was unaffected, because the pinned lease sets MetadataPath to the canonical path there and the two agree. The value was already in scope: filePath is handed to ExtractMetadataAsync five lines above as the public half of the read. Size now comes from the lease's own generation-bound read stream, which is served from the pinned descriptor and reports the pinned file's length. That keeps the guarantee the lease exists to provide, since it never consults the visible path, and it matches how FileRegistrationRecoveryService already checks a published file against its journalled length. Leases exposing no generation-bound read, and registrations with no lease at all, fall back to the published path through IFileSystem. Reported and diagnosed by kevinroberts. Co-Authored-By: Claude Opus 5 (1M context) --- ...AudiobookFileService.MetadataExtraction.cs | 55 +++++++++++++++++++ .../Audiobooks/Files/AudiobookFileService.cs | 5 +- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataExtraction.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataExtraction.cs index aafb6dbcb..f1f55e68c 100644 --- a/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataExtraction.cs +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataExtraction.cs @@ -93,4 +93,59 @@ public partial class AudiobookFileService return metadata; } + + /// + /// Resolve the length of the audio file a registration is about to record. + /// + /// + /// A lease's MetadataPath is a descriptor path on Linux and macOS + /// (/proc/<pid>/fd/<n> or /dev/fd/<n>), so stat'ing it reports the size + /// of the descriptor link rather than the length of the file the descriptor + /// pins. The lease's own read stream is served from that descriptor and does + /// report the pinned file's length, which is also how + /// FileRegistrationRecoveryService compares a published file against its + /// journalled length. Leases that expose no generation-bound read fall back + /// to the published path. + /// + private long? ResolveRegisteredFileLength( + IAudiobookFileRegistrationLease? registrationLease, + string filePath) + { + if (registrationLease != null) + { + try + { + using var metadataStream = registrationLease.OpenMetadataReadStream(); + if (metadataStream.CanSeek) + { + return metadataStream.Length; + } + } + catch (Exception exception) when (exception is + NotSupportedException or IOException or UnauthorizedAccessException + or ObjectDisposedException) + { + logger.LogDebug( + exception, + "The registration lease exposed no generation-bound length; falling back to the published path for {Path}", + LogRedaction.SanitizeFilePath(filePath)); + } + } + + try + { + return fileSystem.FileExists(filePath) + ? fileSystem.GetFileLength(filePath) + : null; + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException) + { + logger.LogDebug( + exception, + "Could not read the length of the registered audiobook file {Path}", + LogRedaction.SanitizeFilePath(filePath)); + return null; + } + } } diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.cs index 823fe2f2c..04b3ccb9a 100644 --- a/listenarr.application/Audiobooks/Files/AudiobookFileService.cs +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.cs @@ -327,10 +327,11 @@ private async Task EnsureAudiobookFileCoreAsync( cacheIdentity, filePath); - var fi = new FileInfo(metadataPath); var fileRecord = AudiobookFile.CreateUnresolved(filePath); fileRecord.AudiobookId = audiobook.Id; - fileRecord.Size = fi.Exists ? fi.Length : null; + fileRecord.Size = ResolveRegisteredFileLength( + registrationLease, + filePath); fileRecord.Source = source; fileRecord.CreatedAt = DateTime.UtcNow; fileRecord.DurationSeconds = meta?.Duration.TotalSeconds;