diff --git a/CHANGELOG.md b/CHANGELOG.md index 852f9fb..364f8c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +## 3.2.0 + +**BREAKING CHANGES:** + +- **BinaryWriterPool**: renamed `_defaultBufferSize` → `_initialBufferSizer` (and parameter `defaultBufferSize` → `initialBufferSizer` in `acquire()` and `withWriter()`) + +**New Features:** + +- **BinaryWriterPool**: added `_discardedPoolFull` counter — tracks writers discarded due to pool full (max 32) +- **BinaryWriter**: added `_varIntSize(int value)` — helper function for VarInt size calculation (switch expression) +- **BinaryReader**: added `peekByte()` — returns byte at current position without advancing offset +- **BinaryReader**: added `BinaryReader.fromList(List)` — convenient constructor for `List` + +**Fixes:** + +- **BinaryWriterPool**: added validation for `initialBufferSizer` in `acquire()` — throws `RangeError` for invalid size +- **BinaryWriterPool**: `_initializeBuffer()` now resets `_isInPool = false`, correct `takeBytes()` → `release()` flow for pooled writers +- **BinaryWriterPool**: `clear()` now resets `_isInPool` for pooled writers +- **BinaryReader/BinaryWriter**: removed unnecessary `late` from `_ReaderState` and `_WriterState` (offset, capacity, list) +- **BinaryReader**: removed redundant bounds check in `peekBytes()` (already guarded by `_checkBounds`) + +**Refactoring:** + +- **_WriterState**: renamed `_validated` → `_fromSize` +- **string_utils.dart**: replaced JSDoc tags `@param`/`@return` with Dart style (`Parameters:`/`Returns:`) + +**Tests:** + +- Added tests for pool statistics, edge cases takeBytes/reset/release + ## 3.1.0 - **feat**: Added `BinaryWriterPool.withWriter()` for safer and more concise object pool usage. diff --git a/lib/src/binary_reader.dart b/lib/src/binary_reader.dart index e076dfe..0d2b4d2 100644 --- a/lib/src/binary_reader.dart +++ b/lib/src/binary_reader.dart @@ -31,6 +31,20 @@ extension type const BinaryReader._(_ReaderState _rs) { /// bytes. BinaryReader(Uint8List buffer) : this._(_ReaderState(buffer)); + /// Creates a new [BinaryReader] from the given byte list. + /// + /// The input list will be copied into a [Uint8List] buffer. + /// This is useful when you have a [List] instead of [Uint8List]. + /// + /// Example: + /// ```dart + /// final bytes = [0x01, 0x02, 0x03, 0x04]; + /// final reader = BinaryReader.fromList(bytes); + /// final value = reader.readUint32(); + /// ``` + factory BinaryReader.fromList(List buffer) => + BinaryReader(Uint8List.fromList(buffer)); + /// Returns the number of bytes remaining to be read. @pragma('vm:prefer-inline') @pragma('dart2js:tryInline') @@ -667,6 +681,23 @@ extension type const BinaryReader._(_ReaderState _rs) { @pragma('dart2js:tryInline') int operator [](int index) => _rs.list[index]; + /// Returns the byte at the current read position without advancing the + /// offset. + /// + /// This is a convenience method for peeking at the next byte to be read. + /// + /// Example: + /// ```dart + /// final nextByte = reader.peekByte(); + /// if (nextByte == 0x42) { + /// // Handle type 0x42 + /// } + /// final actualByte = reader.readUint8(); // Now read it + /// ``` + @pragma('vm:prefer-inline') + @pragma('dart2js:tryInline') + int peekByte() => _rs.list[_rs.offset]; + /// Reads [length] bytes from the current position. /// /// This is a concise alias for [readBytes]. @@ -685,10 +716,6 @@ extension type const BinaryReader._(_ReaderState _rs) { @pragma('vm:prefer-inline') @pragma('dart2js:tryInline') void _checkBounds(int bytes, String type, [int? offset]) { - if (bytes < 0) { - throw RangeError.value(bytes, 'bytes', 'Bytes must be non-negative'); - } - final start = offset ?? _rs.offset; final end = start + bytes; @@ -732,7 +759,7 @@ final class _ReaderState { final int length; /// Current read position in the buffer. - late int offset; + int offset; /// Offset of the buffer view within its underlying [ByteBuffer]. /// Necessary for creating accurate subviews. diff --git a/lib/src/binary_writer.dart b/lib/src/binary_writer.dart index fa2564d..df90e39 100644 --- a/lib/src/binary_writer.dart +++ b/lib/src/binary_writer.dart @@ -4,6 +4,9 @@ import 'dart:typed_data'; // for explanation of max safe integer in JavaScript. import 'constants_native.dart' if (dart.library.js_util) 'constants_web.dart'; +part 'binary_writer_pool.dart'; +part 'string_utils.dart'; + /// A high-performance binary writer for encoding data into a byte buffer. /// /// Provides methods for writing various data types including: @@ -590,18 +593,7 @@ extension type BinaryWriter._(_WriterState _ws) { // Step 1: Optimistic estimation of VarInt size based on string length. // Most strings are ASCII, where byte length == character length. - int estimatedVarIntSize; - if (len < 0x80) { - estimatedVarIntSize = 1; - } else if (len < 0x4000) { - estimatedVarIntSize = 2; - } else if (len < 0x200000) { - estimatedVarIntSize = 3; - } else if (len < 0x10000000) { - estimatedVarIntSize = 4; - } else { - estimatedVarIntSize = 5; - } + final estimatedVarIntSize = _varIntSize(len); // Ensure enough space for the worst-case scenario (3 bytes per UTF-16 unit) _ws.ensureSize(estimatedVarIntSize + len * 3); @@ -616,18 +608,7 @@ extension type BinaryWriter._(_WriterState _ws) { final byteLength = _ws.offset - (startOffset + estimatedVarIntSize); // Step 4: Check if our estimate was correct for the actual byte length - int actualVarIntSize; - if (byteLength < 0x80) { - actualVarIntSize = 1; - } else if (byteLength < 0x4000) { - actualVarIntSize = 2; - } else if (byteLength < 0x200000) { - actualVarIntSize = 3; - } else if (byteLength < 0x10000000) { - actualVarIntSize = 4; - } else { - actualVarIntSize = 5; - } + final actualVarIntSize = _varIntSize(byteLength); // Step 5: If the estimate was wrong, shift the string data if (actualVarIntSize != estimatedVarIntSize) { @@ -653,6 +634,17 @@ extension type BinaryWriter._(_WriterState _ws) { _ws.offset = finalOffset; } + /// Returns the number of bytes needed to encode [value] as a VarInt. + @pragma('vm:prefer-inline') + @pragma('dart2js:tryInline') + int _varIntSize(int value) => switch (value) { + < 0x80 => 1, + < 0x4000 => 2, + < 0x200000 => 3, + < 0x10000000 => 4, + _ => 5, + }; + /// Writes a boolean value as a single byte. /// /// `true` is written as `1` and `false` as `0`. @@ -765,10 +757,11 @@ extension type BinaryWriter._(_WriterState _ws) { /// Separated from the extension type to allow efficient inline operations. final class _WriterState { _WriterState(int initialBufferSize) - : this._validated(_validateInitialBufferSize(initialBufferSize)); + : this._fromSize(_validateInitialBufferSize(initialBufferSize)); - _WriterState._validated(int size) + _WriterState._fromSize(int size) : _size = size, + _isInPool = false, capacity = (size + 63) & ~63, offset = 0, list = Uint8List((size + 63) & ~63) { @@ -787,13 +780,13 @@ final class _WriterState { } /// Current write position in the buffer. - late int offset; + int offset; /// Cached buffer capacity to avoid repeated length checks. - late int capacity; + int capacity; /// Underlying byte buffer. - late Uint8List list; + Uint8List list; /// ByteData view of the underlying buffer for efficient writes. late ByteData data; @@ -801,7 +794,9 @@ final class _WriterState { /// Initial buffer size. final int _size; - var _isInPool = false; + /// Whether this writer is currently in the pool (not available for direct + /// use). + bool _isInPool; @pragma('vm:prefer-inline') @pragma('dart2js:tryInline') @@ -811,6 +806,7 @@ final class _WriterState { data = list.buffer.asByteData(); capacity = alignedSize; offset = 0; + _isInPool = false; } @pragma('vm:prefer-inline') @@ -881,375 +877,3 @@ final class _WriterState { capacity = newCapacity; } } - -/// Calculates the UTF-8 byte length of the given string without encoding it. -/// -/// This function efficiently computes the number of bytes required to -/// encode the string in UTF-8, taking into account multi-byte characters -/// and surrogate pairs. It's optimized with an ASCII fast path that processes -/// up to 4 ASCII characters at once. -/// -/// Useful for: -/// - Pre-allocating buffers of the correct size -/// - Calculating message sizes before serialization -/// - Validating string length constraints -/// -/// Performance: -/// - ASCII strings: ~4 bytes per loop iteration -/// - Mixed content: Falls back to character-by-character analysis -/// -/// Example: -/// ```dart -/// final text = 'Hello, 世界! 🌍'; -/// final byteLength = getUtf8Length(text); // 20 bytes -/// // vs text.length would be 15 characters -/// ``` -/// -/// @param s The input string. -/// @return The number of bytes needed for UTF-8 encoding. -int getUtf8Length(String value) { - if (value.isEmpty) { - return 0; - } - - final len = value.length; - var bytes = 0; - var i = 0; - - while (i < len) { - final char = value.codeUnitAt(i); - - // ASCII fast path - if (char < 0x80) { - // Process 4 ASCII characters at a time - final end = len - 4; - while (i <= end) { - final mask = - value.codeUnitAt(i) | - value.codeUnitAt(i + 1) | - value.codeUnitAt(i + 2) | - value.codeUnitAt(i + 3); - - if (mask >= 0x80) { - break; - } - - i += 4; - bytes += 4; - } - - // Handle remaining ASCII characters - while (i < len && value.codeUnitAt(i) < 0x80) { - i++; - bytes++; - } - if (i >= len) { - return bytes; - } - continue; - } - - // 2-byte sequence - if (char < 0x800) { - bytes += 2; - i++; - } - // 3-byte sequence - else if (char >= 0xD800 && char <= 0xDBFF && i + 1 < len) { - final next = value.codeUnitAt(i + 1); - if (next >= 0xDC00 && next <= 0xDFFF) { - bytes += 4; - i += 2; - continue; - } - // Malformed surrogate pair - bytes += 3; - i++; - } - // 3-byte sequence - else { - bytes += 3; - i++; - } - } - - return bytes; -} - -// Disable lint to allow static-only class for pooling -/// Object pool for reusing [BinaryWriter] instances to reduce GC pressure. -/// -/// This pool maintains a cache of [BinaryWriter] instances with their -/// internal buffers, allowing efficient reuse without allocating new memory -/// for each write operation. -/// -/// ## Features -/// - **Automatic reuse:** [acquire] gets a pooled writer or creates a new one -/// - **Memory bounds:** Only reuses writers with buffers ≤ 64 KiB -/// - **Size limits:** Maintains max 32 pooled instances -/// - **Safe:** Prevents double-release and handles edge cases -/// -/// ## Usage Pattern -/// Use `acquire()` and `release()` for short-lived write operations: -/// -/// ```dart -/// final writer = BinaryWriterPool.acquire(); -/// try { -/// writer.writeUint32(42); -/// writer.writeString('Hello'); -/// final bytes = writer.toBytes(); -/// // Use bytes... -/// } finally { -/// BinaryWriterPool.release(writer); // Return to pool -/// } -/// ``` -/// -/// ## Thread Safety -/// This pool is isolate-local. Each Dart isolate maintains its own -/// static pool instance. -/// -/// Avoid sharing [BinaryWriter] instances between different isolates. -/// For concurrent operations within the same isolate, ensure writers -/// are acquired and released synchronously or protected by logic -/// to prevent interleaved usage. -/// -/// ## Performance Considerations -/// - Pooling is beneficial for high-frequency write operations -/// - Overhead is minimal for single-use writers (use regular constructor) -/// - Large buffers (>64 KiB) are discarded to avoid memory waste -/// -/// ## Memory Management -/// - Pool max size: 32 writers -/// - Max reusable buffer: 64 KiB -/// - Default buffer size: 1 KiB -/// - Use [clear] to free pooled memory explicitly -/// -/// See also: [BinaryWriter], [stats] for pool monitoring -// ignore: avoid_classes_with_only_static_members -abstract final class BinaryWriterPool { - // The internal pool of reusable writer states. - static final _pool = <_WriterState>[]; - - /// Maximum number of writers to keep in the pool. - static const _maxPoolSize = 32; - - /// Default initial buffer size for new writers (1 KiB). - static const _defaultBufferSize = 1024; - - /// Maximum buffer capacity allowed for pooling (64 KiB). - /// Writers that exceed this size are discarded to free up system memory - static const int _maxReusableCapacity = 64 * 1024; - - // Performance counters - static var _acquireHit = 0; - static var _acquireMiss = 0; - static var _peakPoolSize = 0; - static var _discardedLargeBuffers = 0; - - /// Acquires a [BinaryWriter] from the pool or creates a new one. - /// - /// Returns a pooled writer if available, otherwise creates a fresh instance - /// with the default buffer size (1 KiB). - /// - /// The returned writer is ready to use and should be returned to the pool - /// via [release] when no longer needed. - /// - /// **Best Practice:** Always use a `try-finally` block. - /// - /// There are two ways to get the data: - /// 1. Use [BinaryWriter.toBytes] if you consume data **inside** the try - /// block (zero-copy view). - /// 2. Use [BinaryWriter.takeBytes] if you need to **return** the data - /// (transfers buffer ownership). - /// - /// ```dart - /// final writer = BinaryWriterPool.acquire(); - /// try { - /// writer.writeUint32(123); - /// return writer.takeBytes(); - /// } finally { - /// BinaryWriterPool.release(writer); - /// } - /// ``` - /// - /// Returns: A [BinaryWriter] ready for use. - static BinaryWriter acquire([int defaultBufferSize = _defaultBufferSize]) { - if (_pool.isNotEmpty) { - _acquireHit++; - final state = _pool.removeLast().._isInPool = false; - - if (state.capacity < defaultBufferSize) { - state.ensureSize(defaultBufferSize); - } - - return BinaryWriter._(state); - } - - _acquireMiss++; - - return BinaryWriter(initialBufferSize: defaultBufferSize); - } - - /// Acquires a writer, executes the given [action], and automatically - /// releases the writer back to the pool. - /// - /// This is the recommended way to use the pool as it ensures the writer - /// is always released even if an exception occurs. - /// - /// Example: - /// ```dart - /// final bytes = BinaryWriterPool.withWriter((writer) { - /// writer.writeUint32(42); - /// return writer.takeBytes(); - /// }); - /// ``` - static T withWriter( - T Function(BinaryWriter writer) action, [ - int defaultBufferSize = _defaultBufferSize, - ]) { - final writer = acquire(defaultBufferSize); - try { - return action(writer); - } finally { - release(writer); - } - } - - /// Returns a [BinaryWriter] to the pool for future reuse. - /// - /// The writer is reset (offset cleared) and stored for future [acquire] - /// calls. Writers with buffers larger than 64 KiB are not pooled to avoid - /// long-term memory retention. - /// - /// **Safe to call multiple times** (duplicate releases are ignored). - /// - /// Only writers with capacity ≤ 64 KiB are pooled. Writers exceeding this - /// limit are discarded, allowing the buffer to be garbage collected. - /// - /// **Do NOT use the writer after releasing it:** - /// - /// ```dart - /// final writer = BinaryWriterPool.acquire(); - /// writer.writeUint32(42); - /// final bytes = writer.toBytes(); - /// BinaryWriterPool.release(writer); - /// // DON'T USE writer here - it's returned to the pool and may be reused! - /// ``` - /// - /// Parameters: - /// - [writer]: The [BinaryWriter] to return to the pool - static void release(BinaryWriter writer) { - final state = writer._ws; - - // Prevent double-release and state corruption - if (state._isInPool) { - return; - } - - // Only pool writers with reasonable buffer sizes - // Prevents memory bloat from occasional large allocations - if (state.capacity <= _maxReusableCapacity && _pool.length < _maxPoolSize) { - state - ..offset = 0 - .._isInPool = true; - _pool.add(state); - - // Track peak pool size - if (_pool.length > _peakPoolSize) { - _peakPoolSize = _pool.length; - } - } else if (state.capacity > _maxReusableCapacity) { - _discardedLargeBuffers++; - } - } - - /// Returns pool statistics for monitoring and debugging. - /// - /// Useful for performance analysis and detecting pool inefficiencies. - /// - /// Returns a map with keys: - /// - `'pooled'`: Number of writers currently in the pool - /// - `'maxPoolSize'`: Maximum pool capacity - /// - `'defaultBufferSize'`: Initial buffer size for new writers - /// - `'maxReusableCapacity'`: Maximum buffer size for pooling - /// - `'acquireHit'`: Number of successful reuses from pool - /// - `'acquireMiss'`: Number of new writer allocations - /// - `'peakPoolSize'`: Maximum pool size reached - /// - `'discardedLargeBuffers'`: Number of oversized buffers discarded - /// - /// Example: - /// ```dart - /// final stats = BinaryWriterPool.stats; - /// print('Pooled writers: ${stats.pooled}'); // 5 - /// print('Hit rate: ${stats.acquireHit / (stats.acquireHit + stats.acquireMiss)}'); - /// ``` - static PoolStatistics get stats => PoolStatistics({ - 'pooled': _pool.length, - 'maxPoolSize': _maxPoolSize, - 'defaultBufferSize': _defaultBufferSize, - 'maxReusableCapacity': _maxReusableCapacity, - 'acquireHit': _acquireHit, - 'acquireMiss': _acquireMiss, - 'peakPoolSize': _peakPoolSize, - 'discardedLargeBuffers': _discardedLargeBuffers, - }); - - /// Clears the pool, releasing all cached writers. - /// - /// Use this to: - /// - Free memory during low-activity periods - /// - Reset pool state in tests - /// - Handle memory pressure - /// - /// After clearing, subsequent [acquire] calls will create new writers. - /// - /// Example: - /// ```dart - /// BinaryWriterPool.clear(); // All pooled writers discarded - /// ``` - static void clear() { - // Assist GC by breaking links to potentially large byte buffers - for (var i = 0; i < _pool.length; i++) { - _pool[i] - ..list = Uint8List(0) - ..data = ByteData(0); - } - _pool.clear(); - _acquireHit = 0; - _acquireMiss = 0; - _peakPoolSize = 0; - _discardedLargeBuffers = 0; - } -} - -extension type PoolStatistics(Map _stats) { - /// Number of writers currently in the pool. - int get pooled => _stats['pooled']!; - - /// Maximum pool capacity. - int get maxPoolSize => _stats['maxPoolSize']!; - - /// Initial buffer size for new writers. - int get defaultBufferSize => _stats['defaultBufferSize']!; - - /// Maximum buffer size for pooling. - int get maxReusableCapacity => _stats['maxReusableCapacity']!; - - /// Number of successful reuses from pool (cache hits). - int get acquireHit => _stats['acquireHit']!; - - /// Number of new writer allocations (cache misses). - int get acquireMiss => _stats['acquireMiss']!; - - /// Maximum pool size reached during runtime. - int get peakPoolSize => _stats['peakPoolSize']!; - - /// Number of oversized buffers discarded to prevent memory bloat. - int get discardedLargeBuffers => _stats['discardedLargeBuffers']!; - - /// Total number of acquire operations. - int get totalAcquires => acquireHit + acquireMiss; - - /// Cache hit rate (0.0 to 1.0). - double get hitRate => totalAcquires > 0 ? acquireHit / totalAcquires : 0.0; -} diff --git a/lib/src/binary_writer_pool.dart b/lib/src/binary_writer_pool.dart new file mode 100644 index 0000000..39f4ce3 --- /dev/null +++ b/lib/src/binary_writer_pool.dart @@ -0,0 +1,302 @@ +part of 'binary_writer.dart'; + +// Disable lint to allow static-only class for pooling +/// Object pool for reusing [BinaryWriter] instances to reduce GC pressure. +/// +/// This pool maintains a cache of [BinaryWriter] instances with their +/// internal buffers, allowing efficient reuse without allocating new memory +/// for each write operation. +/// +/// ## Features +/// - **Automatic reuse:** [acquire] gets a pooled writer or creates a new one +/// - **Memory bounds:** Only reuses writers with buffers ≤ 64 KiB +/// - **Size limits:** Maintains max 32 pooled instances +/// - **Safe:** Prevents double-release and handles edge cases +/// +/// ## Usage Pattern +/// Use `acquire()` and `release()` for short-lived write operations: +/// +/// ```dart +/// final writer = BinaryWriterPool.acquire(); +/// try { +/// writer.writeUint32(42); +/// writer.writeString('Hello'); +/// final bytes = writer.toBytes(); +/// // Use bytes... +/// } finally { +/// BinaryWriterPool.release(writer); // Return to pool +/// } +/// ``` +/// +/// ## Thread Safety +/// This pool is isolate-local. Each Dart isolate maintains its own +/// static pool instance. +/// +/// Avoid sharing [BinaryWriter] instances between different isolates. +/// For concurrent operations within the same isolate, ensure writers +/// are acquired and released synchronously or protected by logic +/// to prevent interleaved usage. +/// +/// ## Performance Considerations +/// - Pooling is beneficial for high-frequency write operations +/// - Overhead is minimal for single-use writers (use regular constructor) +/// - Large buffers (>64 KiB) are discarded to avoid memory waste +/// +/// ## Memory Management +/// - Pool max size: 32 writers +/// - Max reusable buffer: 64 KiB +/// - Default buffer size: 1 KiB +/// - Use [clear] to free pooled memory explicitly +/// +/// See also: [BinaryWriter], [stats] for pool monitoring +// ignore: avoid_classes_with_only_static_members +abstract final class BinaryWriterPool { + // The internal pool of reusable writer states. + static final _pool = <_WriterState>[]; + + /// Maximum number of writers to keep in the pool. + static const _maxPoolSize = 32; + + /// Default initial buffer size for new writers (1 KiB). + static const _initialBufferSizer = 1024; + + /// Maximum buffer capacity allowed for pooling (64 KiB). + /// Writers that exceed this size are discarded to free up system memory + static const int _maxReusableCapacity = 64 * 1024; + + // Performance counters + static var _acquireHit = 0; + static var _acquireMiss = 0; + static var _peakPoolSize = 0; + static var _discardedLargeBuffers = 0; + static var _discardedPoolFull = 0; + + /// Acquires a [BinaryWriter] from the pool or creates a new one. + /// + /// Returns a pooled writer if available, otherwise creates a fresh instance + /// with the default buffer size (1 KiB). + /// + /// The returned writer is ready to use and should be returned to the pool + /// via [release] when no longer needed. + /// + /// **Best Practice:** Always use a `try-finally` block. + /// + /// There are two ways to get the data: + /// 1. Use [BinaryWriter.toBytes] if you consume data **inside** the try + /// block (zero-copy view). + /// 2. Use [BinaryWriter.takeBytes] if you need to **return** the data + /// (transfers buffer ownership). + /// + /// ```dart + /// final writer = BinaryWriterPool.acquire(); + /// try { + /// writer.writeUint32(123); + /// return writer.takeBytes(); + /// } finally { + /// BinaryWriterPool.release(writer); + /// } + /// ``` + /// + /// Returns: A [BinaryWriter] ready for use. + static BinaryWriter acquire([int initialBufferSizer = _initialBufferSizer]) { + if (initialBufferSizer <= 0) { + throw RangeError.value( + initialBufferSizer, + 'initialBufferSizer', + 'Must be positive', + ); + } + + if (_pool.isNotEmpty) { + _acquireHit++; + final state = _pool.removeLast().._isInPool = false; + + if (state.capacity < initialBufferSizer) { + state.ensureSize(initialBufferSizer); + } + + return BinaryWriter._(state); + } + + _acquireMiss++; + + return BinaryWriter(initialBufferSize: initialBufferSizer); + } + + /// Acquires a writer, executes the given [action], and automatically + /// releases the writer back to the pool. + /// + /// This is the recommended way to use the pool as it ensures the writer + /// is always released even if an exception occurs. + /// + /// Parameters: + /// - [action]: The function to execute with the acquired writer + /// - [initialBufferSizer]: Initial buffer size for new writers + /// (defaults to 1 KiB) + /// + /// Example: + /// ```dart + /// final bytes = BinaryWriterPool.withWriter((writer) { + /// writer.writeUint32(42); + /// return writer.takeBytes(); + /// }); + /// ``` + static T withWriter( + T Function(BinaryWriter writer) action, [ + int initialBufferSizer = _initialBufferSizer, + ]) { + final writer = acquire(initialBufferSizer); + try { + return action(writer); + } finally { + release(writer); + } + } + + /// Returns a [BinaryWriter] to the pool for future reuse. + /// + /// The writer is reset (offset cleared) and stored for future [acquire] + /// calls. Writers with buffers larger than 64 KiB are not pooled to avoid + /// long-term memory retention. + /// + /// **Safe to call multiple times** (duplicate releases are ignored). + /// + /// Only writers with capacity ≤ 64 KiB are pooled. Writers exceeding this + /// limit are discarded, allowing the buffer to be garbage collected. + /// + /// **Do NOT use the writer after releasing it:** + /// + /// ```dart + /// final writer = BinaryWriterPool.acquire(); + /// writer.writeUint32(42); + /// final bytes = writer.toBytes(); + /// BinaryWriterPool.release(writer); + /// // DON'T USE writer here - it's returned to the pool and may be reused! + /// ``` + /// + /// Parameters: + /// - [writer]: The [BinaryWriter] to return to the pool + static void release(BinaryWriter writer) { + final state = writer._ws; + + // Prevent double-release and state corruption + if (state._isInPool) { + return; + } + + // Only pool writers with reasonable buffer sizes + // Prevents memory bloat from occasional large allocations + if (state.capacity <= _maxReusableCapacity && _pool.length < _maxPoolSize) { + state + ..offset = 0 + .._isInPool = true; + _pool.add(state); + + // Track peak pool size + if (_pool.length > _peakPoolSize) { + _peakPoolSize = _pool.length; + } + } else if (state.capacity > _maxReusableCapacity) { + _discardedLargeBuffers++; + } else { + _discardedPoolFull++; + } + } + + /// Returns pool statistics for monitoring and debugging. + /// + /// Useful for performance analysis and detecting pool inefficiencies. + /// + /// Returns a map with keys: + /// - `'pooled'`: Number of writers currently in the pool + /// - `'maxPoolSize'`: Maximum pool capacity + /// - `'initialBufferSizer'`: Initial buffer size for new writers + /// - `'maxReusableCapacity'`: Maximum buffer size for pooling + /// - `'acquireHit'`: Number of successful reuses from pool + /// - `'acquireMiss'`: Number of new writer allocations + /// - `'peakPoolSize'`: Maximum pool size reached + /// - `'discardedLargeBuffers'`: Number of oversized buffers discarded + /// - `'discardedPoolFull'`: Number of writers discarded when pool is full + /// + /// Example: + /// ```dart + /// final stats = BinaryWriterPool.stats; + /// print('Pooled writers: ${stats.pooled}'); // 5 + /// print('Hit rate: ${stats.acquireHit / (stats.acquireHit + stats.acquireMiss)}'); + /// ``` + static PoolStatistics get stats => PoolStatistics({ + 'pooled': _pool.length, + 'maxPoolSize': _maxPoolSize, + 'initialBufferSizer': _initialBufferSizer, + 'maxReusableCapacity': _maxReusableCapacity, + 'acquireHit': _acquireHit, + 'acquireMiss': _acquireMiss, + 'peakPoolSize': _peakPoolSize, + 'discardedLargeBuffers': _discardedLargeBuffers, + 'discardedPoolFull': _discardedPoolFull, + }); + + /// Clears the pool, releasing all cached writers. + /// + /// Use this to: + /// - Free memory during low-activity periods + /// - Reset pool state in tests + /// - Handle memory pressure + /// + /// After clearing, subsequent [acquire] calls will create new writers. + /// + /// Example: + /// ```dart + /// BinaryWriterPool.clear(); // All pooled writers discarded + /// ``` + static void clear() { + // Assist GC by breaking links to potentially large byte buffers + for (var i = 0; i < _pool.length; i++) { + _pool[i] + ..list = Uint8List(0) + ..data = ByteData(0) + .._isInPool = false; + } + _pool.clear(); + _acquireHit = 0; + _acquireMiss = 0; + _peakPoolSize = 0; + _discardedLargeBuffers = 0; + _discardedPoolFull = 0; + } +} + +extension type PoolStatistics(Map _stats) { + /// Number of writers currently in the pool. + int get pooled => _stats['pooled']!; + + /// Maximum pool capacity. + int get maxPoolSize => _stats['maxPoolSize']!; + + /// Initial buffer size for new writers. + int get initialBufferSizer => _stats['initialBufferSizer']!; + + /// Maximum buffer size for pooling. + int get maxReusableCapacity => _stats['maxReusableCapacity']!; + + /// Number of successful reuses from pool (cache hits). + int get acquireHit => _stats['acquireHit']!; + + /// Number of new writer allocations (cache misses). + int get acquireMiss => _stats['acquireMiss']!; + + /// Maximum pool size reached during runtime. + int get peakPoolSize => _stats['peakPoolSize']!; + + /// Number of oversized buffers discarded to prevent memory bloat. + int get discardedLargeBuffers => _stats['discardedLargeBuffers']!; + + /// Number of writers discarded because the pool was full. + int get discardedPoolFull => _stats['discardedPoolFull']!; + + /// Total number of acquire operations. + int get totalAcquires => acquireHit + acquireMiss; + + /// Cache hit rate (0.0 to 1.0). + double get hitRate => totalAcquires > 0 ? acquireHit / totalAcquires : 0.0; +} diff --git a/lib/src/string_utils.dart b/lib/src/string_utils.dart new file mode 100644 index 0000000..a6eef8e --- /dev/null +++ b/lib/src/string_utils.dart @@ -0,0 +1,97 @@ +part of 'binary_writer.dart'; + +/// Calculates the UTF-8 byte length of the given string without encoding it. +/// +/// This function efficiently computes the number of bytes required to +/// encode the string in UTF-8, taking into account multi-byte characters +/// and surrogate pairs. It's optimized with an ASCII fast path that processes +/// up to 4 ASCII characters at once. +/// +/// Useful for: +/// - Pre-allocating buffers of the correct size +/// - Calculating message sizes before serialization +/// - Validating string length constraints +/// +/// Performance: +/// - ASCII strings: ~4 bytes per loop iteration +/// - Mixed content: Falls back to character-by-character analysis +/// +/// Example: +/// ```dart +/// final text = 'Hello, 世界! 🌍'; +/// final byteLength = getUtf8Length(text); // 20 bytes +/// // vs text.length would be 15 characters +/// ``` +/// +/// Parameters: +/// - [value]: The input string. +/// +/// Returns: The number of bytes needed for UTF-8 encoding. +int getUtf8Length(String value) { + if (value.isEmpty) { + return 0; + } + + final len = value.length; + var bytes = 0; + var i = 0; + + while (i < len) { + final char = value.codeUnitAt(i); + + // ASCII fast path + if (char < 0x80) { + // Process 4 ASCII characters at a time + final end = len - 4; + while (i <= end) { + final mask = + value.codeUnitAt(i) | + value.codeUnitAt(i + 1) | + value.codeUnitAt(i + 2) | + value.codeUnitAt(i + 3); + + if (mask >= 0x80) { + break; + } + + i += 4; + bytes += 4; + } + + // Handle remaining ASCII characters + while (i < len && value.codeUnitAt(i) < 0x80) { + i++; + bytes++; + } + if (i >= len) { + return bytes; + } + continue; + } + + // 2-byte sequence + if (char < 0x800) { + bytes += 2; + i++; + } + // 3-byte sequence + else if (char >= 0xD800 && char <= 0xDBFF && i + 1 < len) { + final next = value.codeUnitAt(i + 1); + if (next >= 0xDC00 && next <= 0xDFFF) { + bytes += 4; + i += 2; + continue; + } + // Malformed surrogate pair + bytes += 3; + i++; + } + // 3-byte sequence + else { + bytes += 3; + i++; + } + } + + return bytes; +} diff --git a/pubspec.yaml b/pubspec.yaml index c85300d..03a3cb2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pro_binary description: Efficient binary serialization library for Dart. Encodes and decodes various data types. -version: 3.1.0 +version: 3.2.0 repository: https://github.com/pro100andrey/pro_binary issue_tracker: https://github.com/pro100andrey/pro_binary/issues diff --git a/test/unit/binary_reader_test.dart b/test/unit/binary_reader_test.dart index 91579ad..46de277 100644 --- a/test/unit/binary_reader_test.dart +++ b/test/unit/binary_reader_test.dart @@ -2092,6 +2092,63 @@ void main() { expect(() => reader.peekBytes(1, 11), throwsA(isA())); }, ); + + test('peekByte returns byte at current position without advancing', () { + final reader = BinaryReader(Uint8List.fromList([0x42, 0x43, 0x44])); + expect(reader.peekByte(), equals(0x42)); + expect(reader.offset, equals(0)); + }); + + test('peekByte after read returns next byte', () { + final reader = BinaryReader(Uint8List.fromList([0x42, 0x43, 0x44])) + ..readUint8(); + expect(reader.peekByte(), equals(0x43)); + expect(reader.offset, equals(1)); + }); + + test('peekByte at end returns last byte', () { + final reader = BinaryReader(Uint8List.fromList([0x42])); + expect(reader.peekByte(), equals(0x42)); + expect(reader.offset, equals(0)); + }); + + test('peekByte multiple times returns same value', () { + final reader = BinaryReader(Uint8List.fromList([0x42, 0x43])); + expect(reader.peekByte(), equals(0x42)); + expect(reader.peekByte(), equals(0x42)); + expect(reader.peekByte(), equals(0x42)); + }); + + test('fromList creates reader from List', () { + final bytes = [0x01, 0x02, 0x03, 0x04]; + final reader = BinaryReader.fromList(bytes); + expect(reader.readUint8(), equals(1)); + expect(reader.readUint8(), equals(2)); + expect(reader.readUint8(), equals(3)); + expect(reader.readUint8(), equals(4)); + }); + + test('fromList copies data, original list can be modified', () { + final bytes = [0x01, 0x02, 0x03, 0x04]; + final reader = BinaryReader.fromList(bytes); + expect(reader.readUint8(), equals(1)); + bytes[0] = 0xFF; + reader.reset(); + expect(reader.readUint8(), equals(1)); + }); + + test('fromList works with Uint8List', () { + final bytes = Uint8List.fromList([0x01, 0x02, 0x03, 0x04]); + final reader = BinaryReader.fromList(bytes); + expect(reader.readUint32(), equals(0x01020304)); + }); + + test('fromList with empty list', () { + final reader = BinaryReader.fromList([]); + expect(reader.length, equals(0)); + expect(reader.availableBytes, equals(0)); + expect(reader.readUint8, throwsA(isA())); + }); }); }); } diff --git a/test/unit/binary_writer_test.dart b/test/unit/binary_writer_test.dart index 7db4397..50e37ff 100644 --- a/test/unit/binary_writer_test.dart +++ b/test/unit/binary_writer_test.dart @@ -2143,7 +2143,7 @@ void main() { expect(stats.pooled, equals(0)); expect(stats.maxPoolSize, equals(32)); - expect(stats.defaultBufferSize, equals(1024)); + expect(stats.initialBufferSizer, equals(1024)); expect(stats.maxReusableCapacity, equals(64 * 1024)); }); @@ -2242,6 +2242,70 @@ void main() { expect(BinaryWriterPool.stats.discardedLargeBuffers, equals(2)); }); + test('discardedLargeBuffers does not increment when pool is full', () { + expect(BinaryWriterPool.stats.discardedLargeBuffers, equals(0)); + + // Create 33 writers and release them all at once + // The pool can only hold 32, so the 33rd should be discarded + final writers = []; + for (var i = 0; i < 33; i++) { + writers.add(BinaryWriterPool.acquire()..writeUint32(i)); + } + for (final writer in writers) { + BinaryWriterPool.release(writer); + } + + expect(BinaryWriterPool.stats.pooled, equals(32)); + // discardedLargeBuffers only counts large buffers, not pool-full discards + expect(BinaryWriterPool.stats.discardedLargeBuffers, equals(0)); + }); + + test('discardedPoolFull increments when pool is full', () { + expect(BinaryWriterPool.stats.discardedPoolFull, equals(0)); + + // Create 33 writers and release them all at once + // The pool can only hold 32, so the 33rd should be discarded + final writers = []; + for (var i = 0; i < 33; i++) { + writers.add(BinaryWriterPool.acquire()..writeUint32(i)); + } + for (final writer in writers) { + BinaryWriterPool.release(writer); + } + + expect(BinaryWriterPool.stats.pooled, equals(32)); + expect(BinaryWriterPool.stats.discardedPoolFull, equals(1)); + }); + + test('takeBytes() then release() works correctly', () { + final writer = BinaryWriterPool.acquire()..writeUint32(42); + final bytes = writer.takeBytes(); + expect(bytes.length, equals(4)); + expect(BinaryWriterPool.stats.pooled, equals(0)); + + BinaryWriterPool.release(writer); + expect(BinaryWriterPool.stats.pooled, equals(1)); + + final writer2 = BinaryWriterPool.acquire(); + expect(writer2, isNotNull); + BinaryWriterPool.release(writer2); + }); + + test('reset() then release() works correctly', () { + final writer = BinaryWriterPool.acquire() + ..writeUint32(42) + ..reset(); + expect(writer.bytesWritten, equals(0)); + expect(BinaryWriterPool.stats.pooled, equals(0)); + + BinaryWriterPool.release(writer); + expect(BinaryWriterPool.stats.pooled, equals(1)); + + final writer2 = BinaryWriterPool.acquire(); + expect(writer2, isNotNull); + BinaryWriterPool.release(writer2); + }); + test('totalAcquires returns sum of hits and misses', () { expect(BinaryWriterPool.stats.totalAcquires, equals(0));