From f767fd4aef0d3d181c3d8fb0aa52ffdb9cc5a225 Mon Sep 17 00:00:00 2001 From: Marc Sallin Date: Sun, 6 Sep 2026 21:04:18 +0200 Subject: [PATCH] perf: encode the Typst source straight into native memory Encoding.UTF8.GetBytes copied the whole source onto the managed heap for the duration of one native call and left it as garbage afterwards. The Rust side copies the bytes into an owned String, so the managed array was never more than a staging buffer. A 200 KB source measured 200,024 bytes of managed heap; large sources land on the large object heap, which is only reclaimed by a gen2 collection. Encoding into a native block instead removes that entirely, and also removes the one-byte placeholder array the empty-source case needed to avoid handing across a null pointer. The native allocations now happen inside the try whose finally releases them. They were made before it, so a throw from the caller-supplied font path sequence or from serializing the system inputs leaked everything allocated up to that point. --- src/typstsharp.tests/Tests.cs | 35 +++++++++++ src/typstsharp/TypstCompiler.cs | 100 +++++++++++++++++++++----------- 2 files changed, 100 insertions(+), 35 deletions(-) diff --git a/src/typstsharp.tests/Tests.cs b/src/typstsharp.tests/Tests.cs index 4a5b967..97184fb 100644 --- a/src/typstsharp.tests/Tests.cs +++ b/src/typstsharp.tests/Tests.cs @@ -343,6 +343,41 @@ public async Task ErrorWithNullByteIsHandledCorrectly() await Assert.That(ex!.Message).Contains("foo\0bar"); } + /// + /// A null pointer tells the native side there is no in-memory source at all, so an empty + /// document has to arrive as a real pointer with length 0 rather than as nothing. + /// + [Test] + public async Task EmptySourceIsDistinguishedFromNoSourceAtAll() + { + using var compiler = TypstCompiler.FromSource(""); + using var document = compiler.CompileToDocument(); + + await Assert.That(document.GetOutputLength()).IsGreaterThan(0); + } + + /// + /// The source crosses the boundary as UTF-8 bytes with an explicit length. A source whose UTF-8 + /// byte count differs from its char count, and one large enough that the encoded buffer is not a + /// trivial allocation, are where a mistake in that encoding would surface. + /// + [Test] + public async Task LargeSourceWithMultiByteCharactersIsCompiledInFull() + { + var builder = new StringBuilder("= Grüezi mitenand\n\n"); + for (int i = 0; i < 2000; i++) + { + builder.Append("Paragraph ").Append(i).Append(" über Zürich.\n\n"); + } + builder.Append("= Schluss\n"); + + using var compiler = TypstCompiler.FromSource(builder.ToString()); + var plainText = GetPlainText(compiler.CompilePdf()); + + await Assert.That(plainText).Contains("Grüezi mitenand"); + await Assert.That(plainText).Contains("Schluss"); + } + [Test] public async Task SourceAfterNullByteIsNotTruncated() { diff --git a/src/typstsharp/TypstCompiler.cs b/src/typstsharp/TypstCompiler.cs index bbf1b22..6096c42 100644 --- a/src/typstsharp/TypstCompiler.cs +++ b/src/typstsharp/TypstCompiler.cs @@ -235,54 +235,81 @@ private unsafe TypstCompiler(string? inputPath, string? inputSource, Fonts? font root = Path.GetDirectoryName(inputPath); } - var inputPathPtr = inputPath != null ? Marshal.StringToCoTaskMemUTF8(inputPath) : IntPtr.Zero; - - // The source goes over as raw UTF-8 bytes with an explicit length. A Typst - // document may contain NUL bytes, and a NUL-terminated string would be - // silently truncated at the first one. - byte[]? inputSourceBytes = null; + // Every one of these is native memory that the finally block below releases, so they are + // declared out here and allocated inside the try. Allocating them before it would leak + // whatever had been allocated already if a later step threw, and several of them can: + // fontPaths may be a lazy sequence supplied by the caller, and sysInputs is serialized. + IntPtr inputPathPtr = IntPtr.Zero; + IntPtr inputSourcePtr = IntPtr.Zero; nuint inputSourceLen = 0; - if (inputSource != null) - { - var encoded = Encoding.UTF8.GetBytes(inputSource); - inputSourceLen = (nuint)encoded.Length; - // `fixed` over an empty array yields a null pointer, which the native - // side reads as "no source at all". A one-byte placeholder keeps an - // empty document distinguishable; the length passed stays 0. - inputSourceBytes = encoded.Length == 0 ? new byte[1] : encoded; - } - IntPtr rootPtr = IntPtr.Zero; - if (!string.IsNullOrWhiteSpace(root)) - { - rootPtr = Marshal.StringToCoTaskMemUTF8(root); - } + IntPtr[] fontPathPtrs = []; + int fontPathCount = 0; + IntPtr packagePathPtr = IntPtr.Zero; + IntPtr sysInputsPtr = IntPtr.Zero; - var fontPathsList = fontPaths.ToList(); - var fontPathPtrs = new IntPtr[fontPathsList.Count]; - for (int i = 0; i < fontPathsList.Count; i++) + try { - fontPathPtrs[i] = Marshal.StringToCoTaskMemUTF8(fontPathsList[i]); - } + if (inputPath != null) + { + inputPathPtr = Marshal.StringToCoTaskMemUTF8(inputPath); + } - var packagePathPtr = packagePath != null ? Marshal.StringToCoTaskMemUTF8(packagePath) : IntPtr.Zero; + // The source goes over as raw UTF-8 bytes with an explicit length. A Typst + // document may contain NUL bytes, and a NUL-terminated string would be + // silently truncated at the first one. + if (inputSource != null) + { + // Encoding straight into native memory keeps a document-sized array off the managed + // heap; a source of any size would otherwise be copied there, and a large one would + // land on the large object heap, only to be garbage as soon as the call returns. + int byteCount = Encoding.UTF8.GetByteCount(inputSource); + + // A null pointer reads as "no source at all" on the native side, so an empty + // document still needs one real byte behind the pointer; the length stays 0. + inputSourcePtr = Marshal.AllocCoTaskMem(byteCount == 0 ? 1 : byteCount); + if (byteCount > 0) + { + fixed (char* chars = inputSource) + { + Encoding.UTF8.GetBytes(chars, inputSource.Length, (byte*)inputSourcePtr, byteCount); + } + } - var sysInputsJson = sysInputs == null ? "{}" : JsonSerializer.Serialize>(sysInputs, sourceGenOptions); - var sysInputsPtr = Marshal.StringToCoTaskMemUTF8(sysInputsJson); + inputSourceLen = (nuint)byteCount; + } + + if (!string.IsNullOrWhiteSpace(root)) + { + rootPtr = Marshal.StringToCoTaskMemUTF8(root); + } + + var fontPathsList = fontPaths.ToList(); + fontPathCount = fontPathsList.Count; + fontPathPtrs = new IntPtr[fontPathCount]; + for (int i = 0; i < fontPathCount; i++) + { + fontPathPtrs[i] = Marshal.StringToCoTaskMemUTF8(fontPathsList[i]); + } + + if (packagePath != null) + { + packagePathPtr = Marshal.StringToCoTaskMemUTF8(packagePath); + } + + var sysInputsJson = sysInputs == null ? "{}" : JsonSerializer.Serialize>(sysInputs, sourceGenOptions); + sysInputsPtr = Marshal.StringToCoTaskMemUTF8(sysInputsJson); - try - { fixed (IntPtr* fontPathsRawPtr = fontPathPtrs) - fixed (byte* inputSourcePtr = inputSourceBytes) { - IntPtr* fontPathsPtr = fontPathsList.Count == 0 ? null : fontPathsRawPtr; + IntPtr* fontPathsPtr = fontPathCount == 0 ? null : fontPathsRawPtr; _compiler = CsBindgen.NativeMethods.create_compiler( (byte*)rootPtr, (byte*)inputPathPtr, - inputSourcePtr, + (byte*)inputSourcePtr, inputSourceLen, (byte**)fontPathsPtr, - (nuint)fontPathsList.Count, + (nuint)fontPathCount, (byte*)packagePathPtr, (byte*)sysInputsPtr, ignoreSystemFonts, @@ -296,11 +323,14 @@ private unsafe TypstCompiler(string? inputPath, string? inputSource, Fonts? font } finally { + // FreeCoTaskMem ignores a null pointer, so the entries of a partly filled font path + // array need no guard of their own. if (rootPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(rootPtr); if (inputPathPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(inputPathPtr); + if (inputSourcePtr != IntPtr.Zero) Marshal.FreeCoTaskMem(inputSourcePtr); foreach (var ptr in fontPathPtrs) Marshal.FreeCoTaskMem(ptr); if (packagePathPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(packagePathPtr); - Marshal.FreeCoTaskMem(sysInputsPtr); + if (sysInputsPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(sysInputsPtr); } }