Skip to content

Fixed concurrency issue in ReorderingBuffer that corrupted Normalizer2 output#122

Merged
NightOwl888 merged 2 commits into
NightOwl888:mainfrom
paulirwin:fix/reordering-buffer-concurrency
Apr 20, 2026
Merged

Fixed concurrency issue in ReorderingBuffer that corrupted Normalizer2 output#122
NightOwl888 merged 2 commits into
NightOwl888:mainfrom
paulirwin:fix/reordering-buffer-concurrency

Conversation

@paulirwin

Copy link
Copy Markdown
Collaborator

Summary

Fixes a thread-safety bug in ReorderingBuffer that corrupted Normalizer2 output under contention.

ReorderingBuffer had an internal constructor that took a ref ValueStringBuilder and assigned it by value into the str field. Because ValueStringBuilder is a ref struct that rents a char[] from ArrayPool<char>.Shared (stored in _arrayToReturnToPool), the struct copy duplicated ownership of the rental between the outer caller's ValueStringBuilder and ReorderingBuffer.str. When Grow() fired on str, the old array was returned to the pool while the outer caller's copy still pointed at it and would return it again on Dispose(). Under concurrency two threads could rent the same array simultaneously and corrupt each other's normalization output — reproducible at >10% mismatch rates with 8 threads running NormalizeSecondAndAppend on NFKC_CF input that forces Grow() via ligature expansion.

Fix

Removed the unsafe ReorderingBuffer(Normalizer2Impl, ref ValueStringBuilder, int) ctor entirely. The only way to safely avoid the copy would be a ref field, but C# forbids ref fields that reference ref struct types (CS9050) — so a ref-field approach is not viable for ValueStringBuilder.

ReorderingBuffer now always owns its inner ValueStringBuilder. To match that invariant without double-buffering, Normalizer2Impl.Decompose(..., ref ValueStringBuilder, ...) is gone; call sites that previously decomposed through an intermediate ValueStringBuilder now allocate a ReorderingBuffer directly and call the low-level Decompose(ReadOnlySpan<char>, scoped ref ReorderingBuffer) overload:

  • RuleBasedCollator.WriteIdenticalLevel
  • RuleBasedCollator.MakeFCD (NFD identical-level-run branch)
  • FCDUTF16CollationIterator.Normalize
  • FCDIterCollationIterator.Normalize
  • Norm2AllModes.NormalizeSecondAndAppend(StringBuilder, ReadOnlySpan<char>, bool)
  • Norm2AllModes.NormalizeSecondAndAppend(scoped ref ValueStringBuilder, ReadOnlySpan<char>, bool)
  • Normalizer2Impl.Decompose(ReadOnlySpan<char>, StringBuilder, int)

The hazard rationale lives in a <remarks> block on the ReorderingBuffer XML doc, documenting why the type owns its own ValueStringBuilder and never aliases a caller's.

Regression test

Added TestNormalizeSecondAndAppendConcurrency in NormalizerRegressionTests. It runs 8 threads × 2000 iterations of NormalizeSecondAndAppend against NFKC_CF with an input that forces Grow() via FB03 "ffi" ligature expansion, and fails loudly if any thread's output diverges from the single-threaded expected result.

  • Before this fix: ~2200 / 16000 mismatches on net9.0 (≈14%).
  • After: 0 mismatches.

The test embeds nfkc_cf.nrm as a resource in the test assembly so it runs without requiring the ICU4N.resources satellite assembly (which depends on tooling that isn't available on every dev machine — e.g. the al assembly linker on non-Windows without Mono).

Test plan

  • TestNormalizeSecondAndAppendConcurrency passes on net9.0.
  • Full ICU4N.Tests / ICU4N.Tests.Collation / ICU4N.Tests.Transliterator pass/fail counts match main on net9.0 (all pre-existing failures are the ICU4N.resources satellite missing; delta is exactly +1 passing test — the new regression).
  • CI across all TFMs (net9.0 / net8.0 / net6.0 / net48 / net472 / net47).

This PR was prepared with assistance from Claude Code (Opus 4.7).

…2 output under contention

ReorderingBuffer had an internal constructor that took a `ref ValueStringBuilder` and
assigned it by value into the `str` field. Because ValueStringBuilder is a ref struct
that rents a char[] from ArrayPool<char>.Shared (stored in _arrayToReturnToPool), the
struct copy aliased the rental between the outer caller and the ReorderingBuffer. When
Grow() fired on `str`, the old array was returned to the pool while the outer caller's
ValueStringBuilder still held a reference to it and would return it again on Dispose().
Under concurrency two threads could rent the same array simultaneously and corrupt each
other's normalization output — reproducible at >10% mismatch rates with 8 threads running
NormalizeSecondAndAppend on NFKC_CF input that forces Grow() via ligature expansion.

Removed the unsafe ReorderingBuffer(Normalizer2Impl, ref ValueStringBuilder, int) ctor
and rewrote the three call sites to use the existing safe constructors that take an
initial ReadOnlySpan<char> value plus an owned buffer:

  - Normalizer2Impl.Decompose(scoped ReadOnlySpan<char>, scoped ref ValueStringBuilder, int)
  - Norm2AllModes.NormalizeSecondAndAppend(StringBuilder, ReadOnlySpan<char>, bool)
  - Norm2AllModes.NormalizeSecondAndAppend(scoped ref ValueStringBuilder, ReadOnlySpan<char>, bool)
  - RuleBasedCollator.MakeFCD call site in the NFD identical-level-run branch

Also removes a now-unsound "HACK" line in Decompose that manually transferred the length
from the ReorderingBuffer's aliased ValueStringBuilder back to `dest`; the fixed version
owns its own buffer and copies the result into `dest` explicitly.

Added a regression test (TestNormalizeSecondAndAppendConcurrency) that runs 8 threads x
2000 iterations of NormalizeSecondAndAppend against NFKC_CF with an input that forces
Grow() via FB03 "ffi" ligature expansion, and fails loudly if any thread's output
diverges from the single-threaded expected result. Before this fix: ~2200/16000
mismatches. After: 0. The test embeds nfkc_cf.nrm as a resource in the test assembly so
it runs without requiring the ICU4N.resources satellite assembly (which depends on
tooling that isn't available on every dev machine).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/ICU4N/Impl/Normalizer2Impl.cs Outdated
Comment thread src/ICU4N/Impl/Normalizer2Impl.cs
Comment thread tests/ICU4N.Tests/ICU4N.Tests.csproj Outdated
Comment thread tests/ICU4N.Tests/Dev/Test/Normalizers/NormalizerRegressionTests.cs Outdated
Comment thread src/ICU4N/Impl/Norm2AllModes.cs Outdated
Comment thread src/ICU4N.Collation/Text/RuleBasedCollator.cs Outdated
Comment thread src/ICU4N.Collation/Text/RuleBasedCollator.cs Outdated
Comment thread src/ICU4N.Collation/Text/RuleBasedCollator.cs Outdated
Comment thread src/ICU4N.Collation/Text/RuleBasedCollator.cs
Comment thread src/ICU4N.Collation/Impl/Coll/FCDUTF16CollationIterator.cs Outdated
@paulirwin paulirwin force-pushed the fix/reordering-buffer-concurrency branch from b85c7c1 to fcefc19 Compare April 20, 2026 15:10
ReorderingBuffer:
- Rewrote <remarks> so the pool-backing mention reflects that
  ValueStringBuilder is only conditionally backed by an ArrayPool rental
  (either via the initialCapacity ctor or by outgrowing a stackalloc'd
  initialBuffer), not unconditionally.
- Removed the stale "ICU4N TODO: Evaluate whether this approach makes
  sense and if not, remove" comment above the
  (ReadOnlySpan<char>, int) ctor — this PR's fix relies on that ctor
  shape, so the design question is settled.
- Added internal ReorderingBuffer(Normalizer2Impl, StringBuilder?,
  Span<char>) and (..., int) ctors that Append the StringBuilder
  directly into the inner ValueStringBuilder, eliminating the outer
  stackalloc/pool dance that the previous fix used to materialize
  `first` into a span before handing it to ReorderingBuffer.

Normalizer2Impl.Decompose(ReadOnlySpan<char>, StringBuilder, int):
- Restored the "ICU4N TODO: Make public TryDecompose()..." reminder
  above the method (it previously lived above the removed
  (ref ValueStringBuilder, int) overload; the reviewer wants it kept
  as a standing note).

Norm2AllModes.NormalizeSecondAndAppend(StringBuilder, ...):
- Collapsed to the single-allocation form enabled by the new
  StringBuilder? ctors. No more intermediate char[]/stackalloc to
  copy `first` into before building the ReorderingBuffer.

FCDUTF16CollationIterator.Normalize:
- Reworked per reviewer's suggestion: hoist estimatedLength/value
  above the OpenStringBuilder allocation, size OpenStringBuilder with
  capacity up front when creating it fresh (avoids internal growths),
  and restore the "ICU4N: Corrected 2nd parameter" translation marker
  on the slice expression.

Restored ICU4N translation-marker comments on call sites I rewrote:
- FCDUTF16CollationIterator.cs:496 — "Corrected 2nd parameter"
- RuleBasedCollator.cs:1208 — "value is sliced prior to passing to
  this method, nfdQCYesLimit is based on this slice"
- RuleBasedCollator.cs:1226 — "Corrected 2nd parameter"
- RuleBasedCollator.cs:1485 — "Corrected 2nd parameter" (was "3rd",
  now matches the revised slice)
- RuleBasedCollator.cs:1502 — new "Corrected 2nd parameter" on the
  initial-value slice
- RuleBasedCollator.cs:1508 — "Corrected 2nd parameter" (was "3rd")

Regression test TestNormalizeSecondAndAppendConcurrency:
- Dropped the nfkc_cf.nrm embedded resource from ICU4N.Tests.csproj
  (it was duplicating production data; will break once NightOwl888#85 removes
  the source file from the repo).
- Switched to Normalizer2.GetInstance(null, "nfkc_cf", Compose), the
  same null-stream pattern every other Normalizer2 test uses. The
  test now inherits the same runtime dependency on the
  ICU4N.resources satellite as the rest of the suite.
- Tried the reviewer's suggestion of basing the test on testnorm.nrm
  but confirmed via TDD it cannot reproduce the bug: testnorm's
  maximum expansion is ~17% (one case goes 6→7 UTF-16 units), well
  under the pool-bucket rounding (~2×) that determines whether Grow
  fires. Without Grow the aliasing hazard never manifests. Documented
  this rationale inline in the test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@NightOwl888 NightOwl888 merged commit 198c2dd into NightOwl888:main Apr 20, 2026
1 check passed
@NightOwl888 NightOwl888 changed the title Fixed concurrency issue in ReorderingBuffer that corrupted Normalizer2 output Fixed concurrency issue in ReorderingBuffer that corrupted Normalizer2 output Jun 16, 2026
@NightOwl888 NightOwl888 added the notes:bug-fix Contains a fix for a bug label Jun 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

notes:bug-fix Contains a fix for a bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants