Skip to content

Commit c2cea46

Browse files
author
Vincent Wilms
committed
Merge commit '5f0a23c3d95e5c441142430c77968fcefe68b497'
2 parents bf80f6d + 5f0a23c commit c2cea46

29 files changed

Lines changed: 1267 additions & 125 deletions

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
## v2.1.4 - 2026-07-29
22

3+
- [fix(writing): emit the group info message](https://github.com/Apollo3zehn/PureHDF/pull/178)
4+
- [fix(writing): zero the padding of a fixed-length string](https://github.com/Apollo3zehn/PureHDF/pull/177)
5+
- [fix(writing): key the datatype cache by everything the message depends on](https://github.com/Apollo3zehn/PureHDF/pull/173)
6+
- [fix(writing): encode datatype names as UTF-8](https://github.com/Apollo3zehn/PureHDF/pull/171)
7+
8+
- [fix(reading): partial stream reads, cache thread safety, and group header re-decode](https://github.com/Apollo3zehn/PureHDF/pull/175)
9+
- [fix(reading): never decode strings as ASCII](https://github.com/Apollo3zehn/PureHDF/pull/170)
10+
11+
Thanks @Blackclaws for your contributions!
12+
13+
## v2.2.0 - 2026-08-16
14+
15+
## v2.1.4 - 2026-07-29
16+
317
- [Make tests runnable on Windows and with newer h5dump, with related fixes](https://github.com/Apollo3zehn/PureHDF/pull/160)
418
- Add SharedHdf5StateCollection to serialize tests using HDF5/global state, preventing failures from sharing problems.
519
- Update xUnit dependencies and add Xunit.SkippableFact for conditional test skipping.

src/PureHDF/Utils/ReadUtils.cs

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -170,29 +170,32 @@ public static object DecodeUnmanagedArray<TElement>(IH5ReadStream source, int[]
170170
return array;
171171
}
172172

173-
public static string ReadFixedLengthString(Span<byte> data, CharacterSetEncoding encoding = CharacterSetEncoding.ASCII)
173+
/* Strings are always decoded as UTF-8, never as ASCII.
174+
*
175+
* H5T_cset_t defines only H5T_CSET_ASCII and H5T_CSET_UTF8, and ASCII is a strict
176+
* subset of UTF-8 — all 128 ASCII byte values decode identically under both — so a
177+
* UTF-8 decoder is correct for every conformant payload, including the fields the
178+
* format specification fixes as ASCII (filter names, driver identifiers, dates).
179+
*
180+
* Where the two differ is a payload that holds UTF-8 while being declared, or defaulted
181+
* to, ASCII — which is what any writer that does not set a character set produces.
182+
* There, Encoding.ASCII replaces every byte >= 0x80 with '?' silently and
183+
* irrecoverably, and leaves the result indistinguishable from a literal '?'. UTF-8
184+
* recovers such a payload, and marks genuinely malformed bytes U+FFFD.
185+
*/
186+
public static string ReadFixedLengthString(Span<byte> data)
174187
{
175-
return encoding switch
176-
{
177-
CharacterSetEncoding.ASCII => Encoding.ASCII.GetString(data),
178-
CharacterSetEncoding.UTF8 => Encoding.UTF8.GetString(data),
179-
_ => throw new FormatException($"The character set encoding '{encoding}' is not supported.")
180-
};
188+
return Encoding.UTF8.GetString(data);
181189
}
182190

183-
public static string ReadFixedLengthString(H5DriverBase driver, int length, CharacterSetEncoding encoding = CharacterSetEncoding.ASCII)
191+
public static string ReadFixedLengthString(H5DriverBase driver, int length)
184192
{
185193
var data = driver.ReadBytes(length);
186194

187-
return encoding switch
188-
{
189-
CharacterSetEncoding.ASCII => Encoding.ASCII.GetString(data),
190-
CharacterSetEncoding.UTF8 => Encoding.UTF8.GetString(data),
191-
_ => throw new FormatException($"The character set encoding '{encoding}' is not supported.")
192-
};
195+
return Encoding.UTF8.GetString(data);
193196
}
194197

195-
public static string ReadNullTerminatedString(H5DriverBase driver, bool pad, int padSize = 8, CharacterSetEncoding encoding = CharacterSetEncoding.ASCII)
198+
public static string ReadNullTerminatedString(H5DriverBase driver, bool pad, int padSize = 8)
196199
{
197200
var data = new List<byte>();
198201
var byteValue = driver.ReadByte();
@@ -203,17 +206,15 @@ public static string ReadNullTerminatedString(H5DriverBase driver, bool pad, int
203206
byteValue = driver.ReadByte();
204207
}
205208

206-
var destination = encoding switch
207-
{
208-
CharacterSetEncoding.ASCII => Encoding.ASCII.GetString(data.ToArray()),
209-
CharacterSetEncoding.UTF8 => Encoding.UTF8.GetString(data.ToArray()),
210-
_ => throw new FormatException($"The character set encoding '{encoding}' is not supported.")
211-
};
209+
var destination = Encoding.UTF8.GetString(data.ToArray());
212210

213211
if (pad)
214212
{
213+
// The padding is measured from the bytes on disk, not from the decoded string:
214+
// a multi-byte character makes the string shorter than the data it came from,
215+
// which would seek to the wrong offset and desynchronise the driver.
215216
// https://stackoverflow.com/questions/20844983/what-is-the-best-way-to-calculate-number-of-padding-bytes
216-
var paddingCount = (padSize - (destination.Length + 1) % padSize) % padSize;
217+
var paddingCount = (padSize - (data.Count + 1) % padSize) % padSize;
217218
driver.Seek(paddingCount, SeekOrigin.Current);
218219
}
219220

src/PureHDF/Utils/StreamExtensions.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ public static void ReadExactly(this Stream stream, Span<byte> buffer)
1111
while (slicedBuffer.Length > 0)
1212
{
1313
var readBytes = stream.Read(slicedBuffer);
14+
15+
// Read returns 0 only at end of stream, so without this a truncated file spins here
16+
// forever instead of failing. Matches what Stream.ReadExactly does on net8.0+, which is
17+
// what this shim stands in for.
18+
if (readBytes == 0)
19+
throw new EndOfStreamException();
20+
1421
slicedBuffer = slicedBuffer[readBytes..];
1522
};
1623
}

src/PureHDF/VFD/H5StreamDriver.Reading.cs

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,20 +44,10 @@ public override void Seek(long offset, SeekOrigin seekOrigin)
4444
public override void ReadDataset(Span<byte> buffer)
4545
{
4646
if (_stream is IDatasetStream datasetStream)
47-
{
4847
datasetStream.ReadDataset(buffer);
49-
}
5048

5149
else
52-
{
53-
var remainingBuffer = buffer;
54-
55-
while (remainingBuffer.Length > 0)
56-
{
57-
var count = _stream.Read(buffer);
58-
remainingBuffer = remainingBuffer[count..];
59-
}
60-
}
50+
_stream.ReadExactly(buffer);
6151
}
6252

6353
public override void Read(Span<byte> buffer)
@@ -103,7 +93,8 @@ private T Read<T>() where T : unmanaged
10393
{
10494
var size = Unsafe.SizeOf<T>();
10595
Span<byte> buffer = stackalloc byte[size];
106-
_stream.Read(buffer);
96+
97+
_stream.ReadExactly(buffer);
10798

10899
return MemoryMarshal.Cast<byte, T>(buffer)[0];
109100
}
@@ -125,4 +116,4 @@ protected override void Dispose(bool disposing)
125116
_disposedValue = true;
126117
}
127118
}
128-
}
119+
}

src/PureHDF/VOL/Native/API.Reading/NativeGroup.cs

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,15 +140,25 @@ private bool InternalLinkExists(string path, H5LinkAccess linkAccess)
140140
var segments = isRooted ? path.Split('/').Skip(1).ToArray() : path.Split('/');
141141
var current = isRooted ? Context.File.Reference : Reference;
142142

143+
// Only the first iteration can reuse a group we already hold; every later segment names an
144+
// object not yet resolved, so this is cleared at the end of each pass.
145+
var group = isRooted ? null : this;
146+
143147
for (int i = 0; i < segments.Length; i++)
144148
{
145-
if (current.Dereference() is not NativeGroup group)
146-
return false;
149+
if (group is null)
150+
{
151+
if (current.Dereference() is not NativeGroup dereferenced)
152+
return false;
153+
154+
group = dereferenced;
155+
}
147156

148157
if (!group.TryGetReference(segments[i], linkAccess, out var reference))
149158
return false;
150159

151160
current = reference;
161+
group = null;
152162
}
153163

154164
return true;
@@ -163,16 +173,28 @@ internal NativeNamedReference InternalGet(string path, H5LinkAccess linkAccess)
163173
var segments = isRooted ? path.Split('/').Skip(1).ToArray() : path.Split('/');
164174
var current = isRooted ? Context.File.Reference : Reference;
165175

176+
// Only the first iteration can reuse a group we already hold; every later segment names an
177+
// object not yet resolved, so this is cleared at the end of each pass.
178+
var group = isRooted ? null : this;
179+
166180
for (int i = 0; i < segments.Length; i++)
167181
{
168-
// TODO: Use cache to store dereferenced objects (as it is done in HsdsGroup.cs)
169-
if (current.Dereference() is not NativeGroup group)
170-
throw new Exception($"Path segment '{segments[i - 1]}' is not a group.");
182+
if (group is null)
183+
{
184+
// TODO: Use cache to store dereferenced objects (as it is done in HsdsGroup.cs). That
185+
// would cover the remaining case - the intermediate segments of a deep path, and the
186+
// root of a rooted one - which still re-decode a header per lookup.
187+
if (current.Dereference() is not NativeGroup dereferenced)
188+
throw new Exception($"Path segment '{segments[i - 1]}' is not a group.");
189+
190+
group = dereferenced;
191+
}
171192

172193
if (!group.TryGetReference(segments[i], linkAccess, out var reference))
173194
throw new Exception($"Could not find part of the path '{path}'.");
174195

175196
current = reference;
197+
group = null;
176198
}
177199

178200
return current;

src/PureHDF/VOL/Native/API.Reading/SimpleReadingChunkCache.cs

Lines changed: 68 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@ private record ReadingChunkInfo(Memory<byte> Chunk)
1616

1717
private readonly Dictionary<ulong, ReadingChunkInfo> _chunkInfoMap = new();
1818

19+
// A caller may share one cache across concurrent reads by passing it via
20+
// H5DatasetAccess.ChunkCache, and _chunkInfoMap plus the ConsumedBytes accounting were otherwise
21+
// mutated with no synchronization. The default path never shares a cache - the default factory
22+
// builds one per read - so this only ever bit callers who opted in, and it bit them silently.
23+
//
24+
// A lock is affordable here because of what it guards: a miss costs a chunk read and usually
25+
// decompression, orders of magnitude more than the lock itself. It is deliberately NOT held
26+
// across chunkReader() - see GetChunk.
27+
private readonly object _lock = new();
28+
1929
/// <summary>
2030
/// Initializes a new instance of the <see cref="SimpleReadingChunkCache"/> class.
2131
/// </summary>
@@ -41,7 +51,18 @@ public SimpleReadingChunkCache(int chunkSlotCount = 521, ulong byteCount = 1 * 1
4151
/// <summary>
4252
/// Gets the number of chunk slots that have already been consumed.
4353
/// </summary>
44-
public int ConsumedSlots => _chunkInfoMap.Count;
54+
public int ConsumedSlots
55+
{
56+
get
57+
{
58+
// Reading Dictionary.Count while another reader mutates the dictionary is not safe, so
59+
// this observation is synchronized too - it is a diagnostic, never on the read path.
60+
lock (_lock)
61+
{
62+
return _chunkInfoMap.Count;
63+
}
64+
}
65+
}
4566

4667
/// <summary>
4768
/// Gets the maximum size of the chunk cache in bytes.
@@ -54,34 +75,70 @@ public SimpleReadingChunkCache(int chunkSlotCount = 521, ulong byteCount = 1 * 1
5475
public ulong ConsumedBytes { get; private set; }
5576

5677
/// <inheritdoc />
78+
/// <remarks>
79+
/// Safe to call concurrently. The lock is released around <paramref name="chunkReader" />:
80+
/// holding it across a chunk read (I/O plus decompression) would serialize every reader
81+
/// sharing this cache and so defeat the point of reading in parallel. The cost is that two
82+
/// readers missing on the same chunk at the same time both decode it and one result is
83+
/// discarded - wasted work, never incorrect.
84+
/// <para>
85+
/// Evicting a chunk another reader is still decoding from is likewise safe, but only
86+
/// because cached chunks are plain GC-allocated arrays (see H5D_Chunk.ReadChunk and
87+
/// H5Filter.ExecutePipeline): eviction drops a reference, and the holder's Memory keeps
88+
/// the array alive. Pooling cached chunks would break that, and a lock would then no
89+
/// longer be sufficient.
90+
/// </para>
91+
/// </remarks>
5792
public Memory<byte> GetChunk(ulong chunkIndex, Func<Memory<byte>> chunkReader)
5893
{
59-
if (_chunkInfoMap.TryGetValue(chunkIndex, out var chunkInfo))
94+
lock (_lock)
6095
{
61-
chunkInfo.LastAccess = Environment.TickCount64;
96+
if (_chunkInfoMap.TryGetValue(chunkIndex, out var cached))
97+
{
98+
cached.LastAccess = Environment.TickCount64;
99+
100+
return cached.Chunk;
101+
}
62102
}
63103

64-
else
104+
var buffer = chunkReader();
105+
106+
lock (_lock)
65107
{
66-
var buffer = chunkReader();
108+
// Another reader may have installed this chunk while we were decoding it. Prefer the
109+
// installed one, so every reader observes the same buffer for a given index.
110+
if (_chunkInfoMap.TryGetValue(chunkIndex, out var installed))
111+
{
112+
installed.LastAccess = Environment.TickCount64;
67113

68-
chunkInfo = new ReadingChunkInfo(buffer) { LastAccess = Environment.TickCount64 };
114+
return installed.Chunk;
115+
}
69116

117+
var chunkInfo = new ReadingChunkInfo(buffer) { LastAccess = Environment.TickCount64 };
70118
var chunk = chunkInfo.Chunk;
71119

72120
if ((ulong)chunk.Length <= ByteCount)
73121
{
74-
while (_chunkInfoMap.Count >= ChunkSlotCount || ByteCount - ConsumedBytes < (ulong)chunk.Length)
122+
// Nothing to preempt once the map is empty. Without that guard a cache constructed
123+
// with zero slots - which the constructor allows, and which reads as "do not cache" -
124+
// preempted an empty map and dereferenced the default KeyValuePair.
125+
while (_chunkInfoMap.Count > 0 &&
126+
(_chunkInfoMap.Count >= ChunkSlotCount || ByteCount - ConsumedBytes < (ulong)chunk.Length))
75127
{
76128
Preempt();
77129
}
78130

79-
ConsumedBytes += (ulong)chunk.Length;
80-
_chunkInfoMap[chunkIndex] = chunkInfo;
131+
// Re-checked rather than assumed: with slots available the loop above has already made
132+
// room, but with none it exits on the emptiness guard and this chunk is not cacheable.
133+
if (_chunkInfoMap.Count < ChunkSlotCount)
134+
{
135+
ConsumedBytes += (ulong)chunk.Length;
136+
_chunkInfoMap[chunkIndex] = chunkInfo;
137+
}
81138
}
82-
}
83139

84-
return chunkInfo.Chunk;
140+
return chunk;
141+
}
85142
}
86143

87144
private void Preempt()

0 commit comments

Comments
 (0)