Skip to content

Commit f767fd4

Browse files
committed
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.
1 parent f9332a4 commit f767fd4

2 files changed

Lines changed: 100 additions & 35 deletions

File tree

‎src/typstsharp.tests/Tests.cs‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,41 @@ public async Task ErrorWithNullByteIsHandledCorrectly()
343343
await Assert.That(ex!.Message).Contains("foo\0bar");
344344
}
345345

346+
/// <summary>
347+
/// A null pointer tells the native side there is no in-memory source at all, so an empty
348+
/// document has to arrive as a real pointer with length 0 rather than as nothing.
349+
/// </summary>
350+
[Test]
351+
public async Task EmptySourceIsDistinguishedFromNoSourceAtAll()
352+
{
353+
using var compiler = TypstCompiler.FromSource("");
354+
using var document = compiler.CompileToDocument();
355+
356+
await Assert.That(document.GetOutputLength()).IsGreaterThan(0);
357+
}
358+
359+
/// <summary>
360+
/// The source crosses the boundary as UTF-8 bytes with an explicit length. A source whose UTF-8
361+
/// byte count differs from its char count, and one large enough that the encoded buffer is not a
362+
/// trivial allocation, are where a mistake in that encoding would surface.
363+
/// </summary>
364+
[Test]
365+
public async Task LargeSourceWithMultiByteCharactersIsCompiledInFull()
366+
{
367+
var builder = new StringBuilder("= Grüezi mitenand\n\n");
368+
for (int i = 0; i < 2000; i++)
369+
{
370+
builder.Append("Paragraph ").Append(i).Append(" über Zürich.\n\n");
371+
}
372+
builder.Append("= Schluss\n");
373+
374+
using var compiler = TypstCompiler.FromSource(builder.ToString());
375+
var plainText = GetPlainText(compiler.CompilePdf());
376+
377+
await Assert.That(plainText).Contains("Grüezi mitenand");
378+
await Assert.That(plainText).Contains("Schluss");
379+
}
380+
346381
[Test]
347382
public async Task SourceAfterNullByteIsNotTruncated()
348383
{

‎src/typstsharp/TypstCompiler.cs‎

Lines changed: 65 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -235,54 +235,81 @@ private unsafe TypstCompiler(string? inputPath, string? inputSource, Fonts? font
235235
root = Path.GetDirectoryName(inputPath);
236236
}
237237

238-
var inputPathPtr = inputPath != null ? Marshal.StringToCoTaskMemUTF8(inputPath) : IntPtr.Zero;
239-
240-
// The source goes over as raw UTF-8 bytes with an explicit length. A Typst
241-
// document may contain NUL bytes, and a NUL-terminated string would be
242-
// silently truncated at the first one.
243-
byte[]? inputSourceBytes = null;
238+
// Every one of these is native memory that the finally block below releases, so they are
239+
// declared out here and allocated inside the try. Allocating them before it would leak
240+
// whatever had been allocated already if a later step threw, and several of them can:
241+
// fontPaths may be a lazy sequence supplied by the caller, and sysInputs is serialized.
242+
IntPtr inputPathPtr = IntPtr.Zero;
243+
IntPtr inputSourcePtr = IntPtr.Zero;
244244
nuint inputSourceLen = 0;
245-
if (inputSource != null)
246-
{
247-
var encoded = Encoding.UTF8.GetBytes(inputSource);
248-
inputSourceLen = (nuint)encoded.Length;
249-
// `fixed` over an empty array yields a null pointer, which the native
250-
// side reads as "no source at all". A one-byte placeholder keeps an
251-
// empty document distinguishable; the length passed stays 0.
252-
inputSourceBytes = encoded.Length == 0 ? new byte[1] : encoded;
253-
}
254-
255245
IntPtr rootPtr = IntPtr.Zero;
256-
if (!string.IsNullOrWhiteSpace(root))
257-
{
258-
rootPtr = Marshal.StringToCoTaskMemUTF8(root);
259-
}
246+
IntPtr[] fontPathPtrs = [];
247+
int fontPathCount = 0;
248+
IntPtr packagePathPtr = IntPtr.Zero;
249+
IntPtr sysInputsPtr = IntPtr.Zero;
260250

261-
var fontPathsList = fontPaths.ToList();
262-
var fontPathPtrs = new IntPtr[fontPathsList.Count];
263-
for (int i = 0; i < fontPathsList.Count; i++)
251+
try
264252
{
265-
fontPathPtrs[i] = Marshal.StringToCoTaskMemUTF8(fontPathsList[i]);
266-
}
253+
if (inputPath != null)
254+
{
255+
inputPathPtr = Marshal.StringToCoTaskMemUTF8(inputPath);
256+
}
267257

268-
var packagePathPtr = packagePath != null ? Marshal.StringToCoTaskMemUTF8(packagePath) : IntPtr.Zero;
258+
// The source goes over as raw UTF-8 bytes with an explicit length. A Typst
259+
// document may contain NUL bytes, and a NUL-terminated string would be
260+
// silently truncated at the first one.
261+
if (inputSource != null)
262+
{
263+
// Encoding straight into native memory keeps a document-sized array off the managed
264+
// heap; a source of any size would otherwise be copied there, and a large one would
265+
// land on the large object heap, only to be garbage as soon as the call returns.
266+
int byteCount = Encoding.UTF8.GetByteCount(inputSource);
267+
268+
// A null pointer reads as "no source at all" on the native side, so an empty
269+
// document still needs one real byte behind the pointer; the length stays 0.
270+
inputSourcePtr = Marshal.AllocCoTaskMem(byteCount == 0 ? 1 : byteCount);
271+
if (byteCount > 0)
272+
{
273+
fixed (char* chars = inputSource)
274+
{
275+
Encoding.UTF8.GetBytes(chars, inputSource.Length, (byte*)inputSourcePtr, byteCount);
276+
}
277+
}
269278

270-
var sysInputsJson = sysInputs == null ? "{}" : JsonSerializer.Serialize<Dictionary<string, string>>(sysInputs, sourceGenOptions);
271-
var sysInputsPtr = Marshal.StringToCoTaskMemUTF8(sysInputsJson);
279+
inputSourceLen = (nuint)byteCount;
280+
}
281+
282+
if (!string.IsNullOrWhiteSpace(root))
283+
{
284+
rootPtr = Marshal.StringToCoTaskMemUTF8(root);
285+
}
286+
287+
var fontPathsList = fontPaths.ToList();
288+
fontPathCount = fontPathsList.Count;
289+
fontPathPtrs = new IntPtr[fontPathCount];
290+
for (int i = 0; i < fontPathCount; i++)
291+
{
292+
fontPathPtrs[i] = Marshal.StringToCoTaskMemUTF8(fontPathsList[i]);
293+
}
294+
295+
if (packagePath != null)
296+
{
297+
packagePathPtr = Marshal.StringToCoTaskMemUTF8(packagePath);
298+
}
299+
300+
var sysInputsJson = sysInputs == null ? "{}" : JsonSerializer.Serialize<Dictionary<string, string>>(sysInputs, sourceGenOptions);
301+
sysInputsPtr = Marshal.StringToCoTaskMemUTF8(sysInputsJson);
272302

273-
try
274-
{
275303
fixed (IntPtr* fontPathsRawPtr = fontPathPtrs)
276-
fixed (byte* inputSourcePtr = inputSourceBytes)
277304
{
278-
IntPtr* fontPathsPtr = fontPathsList.Count == 0 ? null : fontPathsRawPtr;
305+
IntPtr* fontPathsPtr = fontPathCount == 0 ? null : fontPathsRawPtr;
279306
_compiler = CsBindgen.NativeMethods.create_compiler(
280307
(byte*)rootPtr,
281308
(byte*)inputPathPtr,
282-
inputSourcePtr,
309+
(byte*)inputSourcePtr,
283310
inputSourceLen,
284311
(byte**)fontPathsPtr,
285-
(nuint)fontPathsList.Count,
312+
(nuint)fontPathCount,
286313
(byte*)packagePathPtr,
287314
(byte*)sysInputsPtr,
288315
ignoreSystemFonts,
@@ -296,11 +323,14 @@ private unsafe TypstCompiler(string? inputPath, string? inputSource, Fonts? font
296323
}
297324
finally
298325
{
326+
// FreeCoTaskMem ignores a null pointer, so the entries of a partly filled font path
327+
// array need no guard of their own.
299328
if (rootPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(rootPtr);
300329
if (inputPathPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(inputPathPtr);
330+
if (inputSourcePtr != IntPtr.Zero) Marshal.FreeCoTaskMem(inputSourcePtr);
301331
foreach (var ptr in fontPathPtrs) Marshal.FreeCoTaskMem(ptr);
302332
if (packagePathPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(packagePathPtr);
303-
Marshal.FreeCoTaskMem(sysInputsPtr);
333+
if (sysInputsPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(sysInputsPtr);
304334
}
305335
}
306336

0 commit comments

Comments
 (0)