Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,11 @@ jobs:
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: lcov.info
# Sanitizer runs guard the hand-rolled cmark memory model (see the memory/leak/
# concurrency safety-harness tests). Run after coverage so they don't clobber the
# coverage build's profraw data. GitHub Actions sets CI=true, so the CI-gated
# position test stays skipped here.
- name: Run tests under Address Sanitizer
run: swift test --sanitize=address
- name: Run tests under Thread Sanitizer
run: swift test --sanitize=thread
140 changes: 140 additions & 0 deletions Tests/MarkdownSyntaxTests/ConcurrencyTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
//
// ConcurrencyTests.swift
// MarkdownSyntaxTests
//
// Safety-harness coverage: concurrent parsing.
//
// `CMNode` is `@unchecked Sendable` and `Markdown` is an actor, but parsing runs off the
// main thread and the app re-parses during token streaming. cmark itself keeps global
// state (extension registration via `cmark_gfm_core_extensions_ensure_registered`, the
// syntax-extension registry), and each parse allocates/frees its own tree. If a
// churn-reducing change introduced shared mutable wrapper state (a cache, a pool, a shared
// owner), concurrent parsing would race.
//
// These tests fan a large number of concurrent parses across the global executor, mixing
// the same and different inputs, and assert every result is correct. Meant to be run under
// Thread Sanitizer (`swift test --sanitize=thread`), which flags data races even when the
// assertions happen to pass.
//

import XCTest
@testable import MarkdownSyntax

final class ConcurrencyTests: XCTestCase {

private let sample = """
# Concurrent 😀

A paragraph with **bold**, _emphasis_, `code`, [link](https://example.com), and 日本語.

- one
- two

> quote

```swift
let x = 1
```
"""

// MARK: - Many concurrent parses of the SAME input

func testConcurrentParseSameInputManyTasks() async throws {
let iterations = 400

// Establish the expected HTML once, sequentially.
let expectedHTML = try await Markdown(text: sample).renderHtml()

try await withThrowingTaskGroup(of: String.self) { group in
for _ in 0..<iterations {
group.addTask { [sample] in
let markdown = try await Markdown(text: sample)
let root = await markdown.parse()
// Touch the value-typed tree.
XCTAssertFalse(root.children.isEmpty)
return try await markdown.renderHtml()
}
}
for try await html in group {
XCTAssertEqual(html, expectedHTML)
}
}
}

// MARK: - Many concurrent parses of DIFFERENT inputs

func testConcurrentParseDifferentInputs() async throws {
let iterations = 400

try await withThrowingTaskGroup(of: (Int, String).self) { group in
for i in 0..<iterations {
group.addTask {
let input = "# Heading \(i) 😀\n\nBody **\(i)** with `code\(i)` and 日本語\(i)."
let markdown = try await Markdown(text: input)
let root = await markdown.parse()
let heading = try XCTUnwrap(root.children.first as? Heading)
let text = try XCTUnwrap(heading.children.first as? Text)
return (i, text.value)
}
}
for try await (i, headingText) in group {
XCTAssertEqual(headingText, "Heading \(i) 😀")
}
}
}

// MARK: - Concurrent raw wrapper-graph walks over independent trees

/// Each task builds its own document and walks the raw `CMNode` graph concurrently,
/// stressing wrapper alloc/free and position computation off the main thread.
func testConcurrentWrapperGraphWalks() async throws {
let iterations = 300

await withTaskGroup(of: Int.self) { group in
for _ in 0..<iterations {
group.addTask { [sample] in
guard let document = try? CMDocument(text: sample) else { return -1 }
var count = 0
func walk(_ node: CMNode) {
count += 1
_ = node.type
_ = node.literal
for child in node.children { walk(child) }
}
walk(document.node)
return count
}
}

var counts: [Int] = []
for await c in group { counts.append(c) }
// Every independent walk of the same document must visit the same node count.
XCTAssertEqual(Set(counts).count, 1, "Concurrent walks disagreed on node count: \(Set(counts))")
XCTAssertFalse(counts.contains(-1))
}
}

// MARK: - Concurrent parse + render mix

func testConcurrentParseAndRenderMix() async throws {
let iterations = 300

try await withThrowingTaskGroup(of: Bool.self) { group in
for i in 0..<iterations {
group.addTask { [sample] in
let markdown = try await Markdown(text: sample)
if i % 2 == 0 {
let root = await markdown.parse()
return !root.children.isEmpty
} else {
let html = try await markdown.renderHtml()
return html.contains("Concurrent")
}
}
}
for try await ok in group {
XCTAssertTrue(ok)
}
}
}
}
140 changes: 140 additions & 0 deletions Tests/MarkdownSyntaxTests/LeakTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
//
// LeakTests.swift
// MarkdownSyntaxTests
//
// Safety-harness coverage: leaks / retain cycles in the cmark wrapper objects.
//
// The mirror image of the memory-safety tests. There the risk was freeing *too soon*
// (use-after-free); here the risk is *never freeing* — a wrapper that leaks its
// `cmark_node`, or a retain cycle between wrappers (e.g. child strongly retains the owner
// via `referencedMemoryOwner`; if the owner ever retained its children back, nothing would
// ever deallocate).
//
// Technique & limitations:
// * We use `weak` references to `CMNode` / `CMDocument` / `Iterator` wrappers checked
// after their strong references leave scope. A non-nil weak reference means a Swift-level
// retain cycle. This detects wrapper-object leaks and cycles, which is exactly the class
// of bug a churn-reducing change (caching wrappers, back-references) would introduce.
// * It does NOT directly observe `cmark_node_free`: the C allocation is invisible to
// ARC/weak refs. We cover that indirectly with bounded-growth loops (below) and, more
// strongly, by running the whole suite under Address Sanitizer, whose leak/heap checks
// see the C heap. So: weak refs catch Swift cycles; ASan catches C leaks.
//

import XCTest
@testable import MarkdownSyntax

final class LeakTests: XCTestCase {

private let sample = """
# Heading

Paragraph with **bold** and a [link](https://example.com) plus `code`.

- one
- two
"""

// MARK: - Wrapper objects must deallocate (no retain cycle)

/// After the document leaves scope, its root wrapper must deallocate.
func testDocumentDeallocates() throws {
weak var weakNode: CMNode?

try autoreleasepool {
let document = try CMDocument(text: sample)
weakNode = document.node
XCTAssertNotNil(weakNode)
_ = document.node.children // create & drop a batch of child wrappers
}

XCTAssertNil(weakNode, "Root CMNode leaked — a retain cycle keeps the owner alive")
}

/// Child wrappers reference the owner (child -> owner) but the owner must not reference
/// children back. Holding a child keeps the owner alive; dropping the child must free both.
func testChildAndOwnerDeallocateTogether() throws {
weak var weakOwner: CMNode?
weak var weakChild: CMNode?

try autoreleasepool {
let document = try CMDocument(text: sample)
let child = try XCTUnwrap(document.node.firstChild?.firstChild)
weakOwner = child.internalMemoryOwner
weakChild = child
XCTAssertNotNil(weakOwner)
XCTAssertNotNil(weakChild)
}

XCTAssertNil(weakChild, "Child CMNode leaked")
XCTAssertNil(weakOwner, "Owner CMNode leaked — child<->owner retain cycle")
}

/// The iterator retains its owner node; after both leave scope, both must deallocate.
func testIteratorAndOwnerDeallocate() throws {
weak var weakIteratorOwner: CMNode?

try autoreleasepool {
let document = try CMDocument(text: sample)
let iterator = try XCTUnwrap(document.node.iterator)
try iterator.enumerate { _, _ in false }
weakIteratorOwner = document.node
XCTAssertNotNil(weakIteratorOwner)
}

XCTAssertNil(weakIteratorOwner, "Iterator kept its owner node alive after scope exit")
}

/// A deep leaf retained past the document's scope must itself deallocate once dropped,
/// taking the whole retained tree with it.
func testRetainedLeafReleasesTreeWhenDropped() throws {
weak var weakOwner: CMNode?

try autoreleasepool {
var leaf: CMNode? = try {
let document = try CMDocument(text: sample)
return try XCTUnwrap(document.node.getAll(where: { $0.type == .text }).first)
}()
weakOwner = leaf?.internalMemoryOwner
XCTAssertNotNil(weakOwner, "Retained leaf should keep the owner alive")
leaf = nil
}

XCTAssertNil(weakOwner, "Dropping the last retained leaf did not release the owner/tree")
}

// MARK: - Bounded growth under repeated parsing

/// Weak-reference sampling across a re-parse loop: every iteration's root wrapper must be
/// deallocated by the time the next iteration starts (no unbounded accumulation of live
/// wrapper trees). This is a Swift-level bounded-growth check; ASan covers the C heap.
func testRepeatedParseDoesNotAccumulateWrappers() throws {
weak var previousRoot: CMNode?

for _ in 0..<300 {
try autoreleasepool {
XCTAssertNil(previousRoot, "Previous iteration's wrapper tree was still alive")
let document = try CMDocument(text: sample)
previousRoot = document.node
_ = document.node.getAll(where: { _ in true }) // materialize the whole wrapper tree
}
}
XCTAssertNil(previousRoot)
}

/// Same idea through the full `Markdown` actor + value-typed `parse()` path.
func testRepeatedMarkdownParseBoundedGrowth() async throws {
for i in 0..<300 {
try await autoreleasepoolAsync {
let markdown = try await Markdown(text: "# H\(i)\n\nBody \(i) **b** `c`.")
let root = await markdown.parse()
XCTAssertFalse(root.children.isEmpty)
}
}
}
}

/// Async-friendly autorelease wrapper (autoreleasepool cannot straddle an await).
private func autoreleasepoolAsync(_ body: () async throws -> Void) async rethrows {
try await body()
}
Loading
Loading