Skip to content

Memory, thread and lifecycle safety in the P/Invoke layer - #9

Open
henrikottesorensen wants to merge 14 commits into
Notalib:mainfrom
henrikottesorensen:fix/pinvoke-audit
Open

Memory, thread and lifecycle safety in the P/Invoke layer#9
henrikottesorensen wants to merge 14 commits into
Notalib:mainfrom
henrikottesorensen:fix/pinvoke-audit

Conversation

@henrikottesorensen

Copy link
Copy Markdown
Collaborator

Fixes eight memory-safety defects in the P/Invoke layer, makes the native calls thread safe, and corrects two API contracts that were silently wrong. Every fix has a test that failed first for the right reason; the ones that could not be tested in-process were reproduced out-of-process and the evidence is in the commit messages.

⚠️ Breaking changes

Two, both requiring a one-line change in consuming code:

  1. LibLouis no longer implements IDisposable. Replace LibLouis.Instance.Dispose() with LibLouis.Shutdown(), or — more likely — delete the call. Surfaces as CS1674.
  2. TranslatedString.OutputPosition / InputPosition are sized to the strings they index and are no longer the arrays passed in. Slicing by Output.Length still works.

A consumer audit found exactly one affected call site (Translator.cs:218), and a fix for it is queued.

What was wrong

Four of these did not merely misbehave — they killed the process, which is why they had gone unnoticed rather than being reported as bugs.

Defect What actually happened
typeform write-back overran the caller's array liblouis writes one entry per output cell into an input-sized array. The existing SingleMode test triggered it: 15 characters in, 20 cells out, 10 bytes past the end of a pinned managed array. It passed because the corrupted memory happened not to matter.
lou_indexTables array not NULL-terminated liblouis walks until it reads a null pointer. A string[] marshals to exactly Length pointers, so it read past the end and handed garbage to _lou_logMessage as a char*. Stack sample: lou_indexTables → _lou_logMessage. Hung the process.
lou_hyphenate output buffer marshalled as ref string Passed a pointer-to-pointer; liblouis wrote its results over the marshalling stub's stack. Hyphenate could never have worked. Left the test host unkillable in uninterruptible exit.
Returned strings were freed lou_version, lou_getDataPath and lou_setDataPath return pointers into liblouis's static storage. Setting DataPath aborted the process: ___BUG_IN_CLIENT_OF_LIBMALLOC_POINTER_BEING_FREED_WAS_NOT_ALLOCATED.
Log callback delegate not rooted Nothing held the delegate; the interop stub keeps it alive only for the registration call. After a collection: "A callback was made on a garbage collected delegate."
Lengths counted in UTF-16 code units Every binary is built --enable-ucs4, so a widechar is a whole character. CharactersToDots("𝄞𝄞") walked 4 widechars through a 3-widechar buffer and returned 4 cells for 2 characters.
lou_free ran outside the lock Freed the shared table chains while other threads were translating. Eight threads plus one Dispose produced no mapping for dot pattern in display table — liblouis reading a freed display table.
Version and Logging bypassed the lock Logging.SetCallback and the LogLevel setter wrote liblouis's callback pointer and log level with no synchronisation at all.

Plus two contract corrections:

  • Positions were reported in widechars but consumed as string indices — correct for BMP text, silently misaligned on the first emoji. The audit found this feeding line-breaking and hyphenation in production with no BMP validation.
  • Disposal meant nothing. disposedValue was set and never read, so the singleton kept working after Dispose() by lazily recompiling the tables it had just freed. The only lasting effect was silently discarding every compiled table in the process.

One thing the audit disproved

The inlen argument passes input.Length + 1, which looks like an off-by-one that counts the NUL terminator as translatable input. Reading liblouis shows it is not: the length is clamped at the first NUL (lou_translateString.c:1191) and then overwritten with the count actually consumed (:1354) before any position mapping. It was safe — but resting on two undocumented internals, so it now passes the documented count and there are tests pinning why.

Testing

61 tests, up from 4. They document the native contracts in prose, so the next person does not have to re-derive them from the C.

Independently corroborated: a separate harness ran the upstream Danish YAML specs through this branch — 2,100 forward and 1,609 back-translation cases, zero mismatches — confirming the length and typeform changes did not perturb output.

Tests now run serially. liblouis has process-global state and is not thread safe, but xUnit parallelises across test classes; the existing suite passed only because all four tests lived in one class.

Also included

TranslatedString.OutputDots78 surfaces the per-cell dot 7/8 information liblouis reports through the typeform parameter, which the overrun fix would otherwise have discarded. It is safe to expose because the danger was where liblouis wrote it, not the data itself.

henrikosorensen and others added 14 commits August 5, 2026 12:16
liblouis treats typeform as in/out: it reads one entry per input
character, but on a successful translation it writes one entry per
*output* cell (lou_translateString.c:1329). The public API sizes
typeform to the input, so any translation that grows the text - which
the da-dk marker tables do routinely - had native code writing past the
end of a pinned managed array, corrupting the GC heap.

The existing SingleMode test triggered this: 15 input characters, 20
output cells, 10 bytes written past the array. It passed only because
the overwritten memory happened not to matter.

Hand liblouis a scratch buffer sized for both directions and treat the
caller's array as input only.

Tests are run serially from now on: liblouis has process-global state
and is not thread safe, but xunit parallelises across test classes, so
a second test class made unsynchronised native calls race.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An audit flagged inputLength = input.Length + 1 as counting the NUL
terminator as translatable input, and as widening the outputPos write
past the array size the argument checks demand. Reading liblouis shows
neither happens:

  * lou_translateString clamps the length at the first NUL
    (lou_translateString.c:1191), so the terminator is never translated.
  * It then overwrites *inlen with the number of characters actually
    consumed (lou_translateString.c:1354) before computing outputPos, so
    the inflated value never reaches the position loops.

Both properties rely on the buffer really being NUL terminated, and
neither is visible at the call site, so add tests and a comment rather
than a change. The tests use int.MinValue as the sentinel: liblouis
pre-fills outputPos with -1, which would mask an out-of-bounds write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lou_indexTables walks its argument until it reads a null pointer
(metadata.c:905), but a managed string[] marshals to exactly Length
pointers with no terminator. liblouis therefore read whatever managed
memory followed the array and passed it to _lou_logMessage as a char*.

This is not theoretical: calling IndexTables hangs the process. A stack
sample of the wedged test host shows it parked in

    lou_indexTables -> _lou_logMessage

formatting %s against a garbage pointer until it runs out of readable
memory.

Append the terminator and let the signature say so (string?[]).

The regression test asserts liblouis analyzed exactly the tables it was
given. Note that a regression does not fail it, it hangs it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hyphens was declared "ref string". With source-generated interop that
passes a byte**, a pointer to the stub's own pointer slot. liblouis
takes it as the char* output buffer and writes inlen + 1 bytes of
'0'/'1' flags through it, over the stub's stack, and the stub then
marshals a result string back from the clobbered pointer.

Hyphenate could never have worked; it was simply untested. Calling it
wedges the process: the test host is left unkillable in uninterruptible
exit, which is what memory corruption looks like from the outside.

Also:

  * inlen must not count the NUL terminator here. lou_hyphenate memcpy's
    exactly inlen characters instead of stopping at a NUL the way the
    translate functions do, so the old input.Length + 1 hyphenated the
    terminator as if it were a letter.
  * Reject input of 100 characters or more up front. liblouis hyphenates
    through a fixed HYPHSTRING buffer and refuses longer input, which
    surfaced as an unexplained hyphenation failure.
  * ArgumentNullException.ThrowIfNullOrEmpty(nameof(input)) validated the
    literal "input", so it could never fire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lou_version, lou_getDataPath and lou_setDataPath return a pointer into
static storage inside liblouis. With StringMarshalling.Utf8 on the
return value the generated stub frees whatever came back, so setting
DataPath aborted the process outright:

    ___BUG_IN_CLIENT_OF_LIBMALLOC_POINTER_BEING_FREED_WAS_NOT_ALLOCATED
      malloc_report -> malloc_vreport -> abort

UTF8StringNoFreeMarshaller already existed for this, but was only wired
up to lou_version, and it could not be applied more widely as written:

  * ConvertToUnmanaged reassigned its Span local to a fresh managed array
    instead of encoding into the buffer it had just allocated, so it
    returned uninitialised native memory and leaked the allocation. It
    also threw on empty strings. Nothing exercised it, because only the
    return path was ever used - but lou_setDataPath takes a string
    parameter, so applying the marshaller would have started feeding
    liblouis garbage paths.
  * MarshalMode.Default offered it for parameters too, where never
    freeing is a leak rather than a fix.

So restrict it to ManagedToUnmanagedOut, drop ConvertToUnmanaged
entirely, and let parameters keep the built-in Utf8StringMarshaller.
ConvertToManaged becomes Marshal.PtrToStringUTF8, which also drops the
int.MaxValue span that threw when no terminator was found.

lou_findTable is documented as caller-frees, but our Windows binaries are
mingw-w64 and allocate from msvcrt.dll while .NET frees through
ucrtbase.dll. Freeing across those heaps corrupts them, so it uses the
same marshaller and leaks a bounded number of small strings instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lou_registerLogCallback was handed a delegate created from a method
group that nothing kept a reference to. The interop stub only keeps it
alive for the duration of the registration call, but liblouis holds the
function pointer for the rest of the process's life, so the first native
log message after a collection killed the process:

    Process terminated. A callback was made on a garbage collected
    delegate of type 'LibLouis.NET.NativeMethods+LoggingCallback::Invoke'

Root it in a field, in LibLouis and in the static Logging helper, which
had the same problem for callbacks supplied by callers.

LogCallback also indexed the level map directly, so a level liblouis
does not currently define would have thrown KeyNotFoundException out of
a native callback - undefined behaviour rather than an error. Map
unknown levels to Information and let nothing escape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On a UCS-4 build a liblouis widechar holds a whole Unicode character, so
a non-BMP character is one widechar but two chars of a .NET string. The
input buffer was sized by encoding the string, but the length passed
alongside it was string.Length, which overstates it.

lou_dotsToChar and lou_charToDots read and write exactly the count they
are given, with no NUL clamping (lou_translateString.c:4142), so
CharactersToDots on a two character non-BMP string had liblouis walk
four widechars through a three widechar buffer - eight bytes past the
end - and return four cells for two characters. Our shipped binaries are
UCS-4, so this was live.

The translate functions were not affected, because they clamp at the
terminator, but they now count in the same unit so the two cannot drift
apart again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two related corrections, both about the unit lengths are measured in.

Hyphenate still passed input.Length. lou_hyphenate memcpy's exactly
inlen widechars with no NUL to stop at, so on a UCS-4 build - which is
every binary we ship, both build scripts configure --enable-ucs4 - a
word with two non-BMP characters read past the end of the input buffer,
and any non-BMP character produced one flag too many. Count widechars,
size the flag buffer from that, and check the HYPHSTRING limit against
it too.

The translate functions now pass inlen excluding the NUL terminator,
which is what the header documents and what upstream callers pass. The
previous input.Length + 1 was safe, but only because liblouis clamps at
the first NUL and then overwrites inlen with the count it consumed -
correctness rested on two undocumented internals rather than on the
contract. Behaviour is unchanged; the value now equals what liblouis
computed for itself anyway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dispose called lou_free outside _lock, alone among the native calls.
lou_free walks and frees the translation and display table chains, which
are shared by every caller, so disposing while another thread translates
is a use-after-free. With eight threads translating and a single
Dispose landing mid-flight, that surfaced as

    LibLouisException: ... no mapping for dot pattern  in display table

liblouis reading a display table that had just been freed under it. A
crash is equally available; this run happened to degrade into nonsense
instead.

Disposal also meant nothing: disposedValue was set and never read, so
Instance kept handing out the object and liblouis lazily recompiled the
tables that had just been thrown away. The only lasting effect was
silently discarding every compiled table in the process, for every other
consumer, with no error.

So take the lock in Dispose, make it idempotent, and guard every native
entry point with ObjectDisposedException. The guards sit inside the
lock, immediately before the native call: checking on the way in would
leave a window for Dispose to free the tables in between. Same run now
gives eight clean ObjectDisposedExceptions and no corruption.

The finalizer is gone. lou_free is process-global teardown while a
finalizer runs per managed instance, so under a collectible
AssemblyLoadContext it would have freed the tables of every other
context still using liblouis, from the finalizer thread, outside the
lock. Nothing here owns a handle that leaks if the caller never
disposes.

Setting a logger stays legal afterwards - it touches nothing lou_free
released, and attaching a logger while shutting down is worth more than
the symmetry.

Whether IDisposable is the right shape at all is still open, pending the
audit of the wrapper's consumers.

Also pass liblouis log messages as an argument rather than as the
message template, so a brace in a table path or rule is not parsed as a
placeholder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Version called lou_version outside the lock, and the whole static
Logging class bypassed it: SetCallback and the LogLevel setter wrote
liblouis's callback pointer and log level with no synchronisation at
all, while another thread could be inside a translation that reads them
as it logs.

The lock protects state that belongs to the native library rather than
to the instance, so it is now static and shared between LibLouis and
Logging. Monitor is reentrant, so a logger that calls back in while
liblouis is logging still does not deadlock.

lou_version returns a compile-time constant, so it was not racy in
practice - but "every native call takes the lock" is worth more as a
rule without exceptions than as one that has to be re-derived per
function. Version and SetLogger stay usable after disposal: neither
touches anything lou_free released.

Holding the lock is not observable at runtime, so the regression test is
source based: every NativeMethods call must sit inside a lock or carry
an "unlocked:" comment giving the reason. The two type-initializer calls
are the only exemptions. Verified the test fails when a lock is removed,
rather than passing vacuously.

Also clean up warnings in the test project: the raw shim is now
SafeNativeMethods with explicit search paths and pre-encoded UTF-8
arguments, and the concurrency test awaits instead of blocking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BREAKING: LibLouis no longer implements IDisposable. Replace
LibLouis.Instance.Dispose() with LibLouis.Shutdown(), and delete any
using statement over the singleton - those now fail to compile, which is
the point.

IDisposable promises something this type cannot honour. It says "I own a
resource, dispose me when you are done", but Instance is a process-wide
singleton over process-global native state: no caller is ever done with
it, and lou_free is teardown for the whole application rather than the
release of anything one caller owns.

The cost was a footgun that read as good practice. This compiled:

    using var louis = LibLouis.Instance;

and left every other consumer in the process unable to translate.
CA2000 and IDE0063 actively suggest writing it. A static Shutdown() is
not called by accident, no analyzer proposes it, and the name says what
it does.

Its one legitimate use is releasing table memory before the process
exits, for leak checking. Normal applications should not call it:
liblouis caches compiled tables per table list rather than per call, so
nothing accumulates, and process exit reclaims it anyway.

The guard throws InvalidOperationException rather than
ObjectDisposedException. "Cannot access a disposed object" would send a
reader looking for a Dispose call that no longer exists; the message now
says liblouis was shut down and that it cannot be undone.

Version and the Logger setter still work afterwards - neither touches
anything lou_free released, and diagnostics are worth most during
shutdown.

Verified out of process: 6562 translations, Shutdown mid-flight, all
eight workers refused cleanly with zero corruption, a second Shutdown
is a no-op, and "using var louis = LibLouis.Instance" now fails with
CS1674.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The typeform overrun fix routed liblouis's write-back into an internal
scratch buffer and then discarded it, which silently dropped the one
piece of output the parameter produces: per output cell, whether the
cell contains dot 7 or dot 8 (lou_translateString.c:1330). Consumers
want that information.

Exposing it does not reopen the memory-safety problem, because the
danger was never the data - it was where liblouis wrote it. The scratch
buffer is output-sized, so the write-back lands safely there; the
wrapper now copies it out as TranslatedString.OutputDots78, a bool per
output cell. The caller's input-sized formtype array stays untouched.

Booleans rather than the raw buffer because the values are the ASCII
characters '0' and '8' smuggled through a formtype array, not TypeForm
flags - handing those out as TypeForm would invite bitwise tests that
can never be true.

Null when no formtype array was passed: liblouis only computes the
information when one is supplied, and an all-false array would be
indistinguishable from a real report of "no dots 7/8 anywhere".

Forward translation only. Back-translation zero-fills the buffer and
reports nothing (lou_backTranslateString.c:228).

Verified against da-dk-g08.ctb, where a capital is marked with dot 7 on
its own cell: "Abc" reports the flag on cell 0 and nowhere else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Silences CA1861 in the tests added for OutputDots78.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BREAKING: TranslatedString.OutputPosition and InputPosition are now
sized to the strings they index - one entry per char of the input and of
Output respectively - and are no longer the arrays passed in. Code that
slices them by Output.Length still works; code that relied on getting
its own array instance back does not.

liblouis indexes these arrays in widechars, which on a UCS-4 build - all
our binaries - is a whole Unicode character. A .NET string counts UTF-16
code units. The two agree for BMP text and diverge from the first
non-BMP character on, and the arrays exist for nothing except indexing
strings, so every consumer treated them as UTF-16 indices and was
silently misaligned on any input containing an emoji or a musical
symbol.

An audit of the consuming code found this reaching production: positions
are sliced by Output.Length, indexed directly against both strings, and
re-based with cross-node offset arithmetic through line-breaking and
hyphenation, with no validation that the input is BMP-only. A single
emoji corrupts hyphen placement.

So translate the values rather than document the hazard. Both halves of
a surrogate pair report the same position, values always address the
start of a character, and the arrays are sized to their strings so the
Output.Length slice that callers write is now exactly the whole array.
The cursor is converted in both directions for the same reason.

The caller's arrays stay as liblouis wrote them: they are the native
scratch buffers, and sizing the results from them is what made the
Output.Length slice necessary in the first place.

TestPositionResults asserted that the returned array was the same
instance as the one passed in. That was describing the implementation,
not the contract; it now compares the values over the output's length.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants