Skip to content

Strip HTML notes without leaking mach ports - #953

Open
jamtur01 wants to merge 6 commits into
leits:masterfrom
jamtur01:fix-html-strip-port-leak
Open

Strip HTML notes without leaking mach ports#953
jamtur01 wants to merge 6 commits into
leits:masterfrom
jamtur01:fix-html-strip-port-leak

Conversation

@jamtur01

@jamtur01 jamtur01 commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Status

READY

Description

Fixes a mach-port leak that gets MeetingBar force-killed after long uptime.

htmlTagsStrippedForMeetingLinks (link detection) and String.htmlTagsStripped() (notes display) both turned HTML notes into plain text with NSAttributedString(html:). That initializer spins up TextKit's com.apple.textkit.nsattributedstringagent XPC service and leaks ~2 mach ports per call that are never reclaimed. Link detection runs it for every event with HTML notes on every calendar refresh, so the process port table grows unbounded until the kernel kills the app with EXC_GUARD — "allocating too many mach ports" (I caught one at 305,834 ports; unified-log shows the nsattributedstringagent connection flood right after each EventKit CADEventPredicate).

The HTML strip dates back to #144 and still exists in the 5.0 codebase, so the 4.11 rewrite didn't remove it.

Replaced both call sites with HTMLPlainText, a pure-Foundation strip (in MeetingLinkDetector.swift so the logic target and app share it): block-level tags become newlines, other tags are removed, and entities are decoded — all numeric forms ({/{) plus a named table covering the five XML names, common symbols, and the full Latin-1 accented-letter set (café, München). No XPC, nothing to leak. Named entities outside the table are left as written (documented in the code); numeric coverage is total.

Provenance:

  • d646f98 — replace NSAttributedString(html:) with HTMLPlainText; add the regression + behavioral tests.
  • a35af58 — flesh out the named-entity table (the first cut narrowed the old full decoding); also swap mach_task_self_task_self_trap() in the port test so it builds under -strict-concurrency=complete.

Likely the cause behind #872 (same nsattributedstringagent signature), and probably #716 / #721 / #888.

No breaking changes to external interfaces. One behavior note: the notes-display path now decodes numeric + Latin-1/common named entities rather than the full HTML named set; anything rarer shows as written instead of decoded.

Steps to Test or Reproduce

  1. Observe the XPC churn (before): with NSAttributedString(html:) in the strip, add a calendar event whose notes contain HTML (Google/Outlook invites do) and let it refresh over time. log stream --predicate 'process == "MeetingBar"' shows repeated activating connection … com.apple.textkit.nsattributedstringagent right after each EventKit CADEventPredicate.
  2. Measure the leak (before): sample the process mach-port count (mach_port_names on the task, or the harness in testHTMLStripDoesNotLeakMachPorts) across many strip calls — it climbs ~2 ports/call and never drops. Left running, the port table fills and the kernel kills the app with EXC_GUARD / port-space exhaustion (seen at 305,834 ports).
  3. The fix (after): same scenario — no nsattributedstringagent connections in the log, and the mach-port count stays flat across calls.

Summary by CodeRabbit

  • Bug Fixes

    • Improved meeting link detection for HTML-formatted content with more deterministic HTML-to-plain-text conversion.
    • Decodes common HTML entities (numeric, named, and Latin-1/typographic) for cleaner text.
    • Removes script/style, converts block breaks to new lines, and normalizes whitespace for readability.
    • Reduced risk of resource growth during repeated HTML stripping.
  • Tests

    • Added unit coverage for entity decoding, line-break behavior, and a regression check for potential Mach port leaks.
    • Updated a notes-cleanup expectation to remove an extra trailing newline.

jamtur01 added 2 commits July 14, 2026 13:22
htmlTagsStrippedForMeetingLinks and String.htmlTagsStripped both used
NSAttributedString(html:) to turn HTML notes into plain text. That
initializer spins up TextKit's nsattributedstringagent XPC service and
leaks ~2 mach ports every call that never come back. Link detection runs
it for every event with HTML notes on every calendar refresh, so over a
day the port table fills up and the kernel kills the app with EXC_GUARD
("allocating too many mach ports" — I caught one at 305,834 ports). I
think this is what's behind leits#872, and probably leits#716/leits#721 too.

Replaced it with HTMLPlainText, a pure-Foundation strip: block tags
become newlines, everything else is dropped, and entities are decoded
including decimal and hex numeric forms. Lives in MeetingLinkDetector so
both the logic target and the app share it. No XPC, nothing to leak.

Measured it to be sure — a little harness reading mach_port_names shows
NSAttributedString(html:) climbing ~2 ports/call and never releasing,
while the regex strip stays flat at zero across hundreds of calls. That
became the regression test: it hammers the strip 400 times and fails if
the port table grows, so a future NSAttributedString(html:) can't sneak
back in. Also added tests for & URL reconnection, entity decoding,
and block-tag newlines.
The first cut of HTMLPlainText only knew a handful of named entities, so
NSAttributedString(html:)'s full decoding got narrowed — a note written
with named accents like café or München would show the raw
entity instead of café / München. Filled out the table with the Latin-1
accented letters (upper and lower) and the common symbols (euro, pound,
frac12, times, …). Numeric entities were already total, so this closes
the named-side gap; anything still outside the table is left as written,
which the doc comment now spells out.

Also swapped mach_task_self_ for task_self_trap() in the port-leak test —
the raw global tripped -strict-concurrency=complete on CI (shared mutable
var); the trap returns the same task port as a plain function. Added a
test for the accented names.
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 5369cda4-3d97-43b0-b78c-553a7fe68f8d

📥 Commits

Reviewing files that changed from the base of the PR and between 3dc716e and b713dc3.

📒 Files selected for processing (1)
  • MeetingBarLogicTests/MeetingLinkDetectorTests.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • MeetingBarLogicTests/MeetingLinkDetectorTests.swift

Walkthrough

MeetingBar replaces NSAttributedString HTML parsing with a pure-Foundation HTMLPlainText converter. The converter strips tags, decodes entities, normalizes whitespace, and is covered by output, cleanup, and Mach-port regression tests.

Changes

HTML plain-text conversion

Layer / File(s) Summary
HTML parser and integration
MeetingBar/Meetings/MeetingLinkDetector.swift, MeetingBar/Extensions/String.swift
HTMLPlainText strips tags, removes script/style bodies, decodes named and numeric entities, normalizes whitespace, and replaces the previous attributed-string parsing paths.
Conversion and leak regression tests
MeetingBarLogicTests/MeetingLinkDetectorTests.swift, MeetingBarTests/HelpersTests.swift
Tests cover entity decoding, block-tag line breaks, script/style removal, bounded Mach-port usage, and cleanup output without a trailing newline.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant StringExtensions
  participant MeetingLinkDetector
  participant HTMLPlainText
  StringExtensions->>HTMLPlainText: convert HTML fragments
  MeetingLinkDetector->>HTMLPlainText: convert detected HTML
  HTMLPlainText-->>StringExtensions: normalized plain text
  HTMLPlainText-->>MeetingLinkDetector: normalized plain text
Loading

Poem

A rabbit found tags in a thicket of text,
And turned them to lines with no parser perplexed.
“&” became “&,” clean and bright,
While ports stayed bounded through day and night.
Hop, hop—plain words now take flight!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: replacing HTML note stripping to avoid Mach port leaks.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. bug Something isn't working labels Jul 14, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
MeetingBar/Meetings/MeetingLinkDetector.swift (1)

317-328: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider precompiling the regexes used in from(_:).

Each call recompiles four regex patterns via replacingOccurrences(options: .regularExpression). Given the stated call frequency ("every event with HTML notes on every calendar refresh"), hoisting these to precompiled static let NSRegularExpression instances would cut redundant compilation overhead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@MeetingBar/Meetings/MeetingLinkDetector.swift` around lines 317 - 328,
Precompile the four regular-expression patterns used by
MeetingLinkDetector.from(_:) as static let NSRegularExpression instances, then
reuse them for HTML tag replacement, whitespace cleanup, and newline collapsing
instead of invoking replacingOccurrences with .regularExpression on each call.
Preserve the current replacement order and output behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@MeetingBar/Meetings/MeetingLinkDetector.swift`:
- Around line 317-328: Update MeetingLinkDetector.from(_:) to remove complete
script and style blocks, including their contents, before the generic HTML tag
stripping step. Preserve the existing visible-text conversions, entity decoding,
whitespace normalization, and trimming for all non-script/style content.
- Around line 330-349: Update decodeEntities(in:) so the semicolon lookup is
limited to the maximum entity length from the current ampersand, rather than
scanning the entire remaining string. Preserve the existing 12-character
validation, decode(entity:) behavior, and fallback character-by-character
handling while ensuring malformed input is processed linearly.

---

Nitpick comments:
In `@MeetingBar/Meetings/MeetingLinkDetector.swift`:
- Around line 317-328: Precompile the four regular-expression patterns used by
MeetingLinkDetector.from(_:) as static let NSRegularExpression instances, then
reuse them for HTML tag replacement, whitespace cleanup, and newline collapsing
instead of invoking replacingOccurrences with .regularExpression on each call.
Preserve the current replacement order and output behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 233738a7-6e8f-45fb-b457-4479572cc38b

📥 Commits

Reviewing files that changed from the base of the PR and between 26eef52 and a35af58.

📒 Files selected for processing (3)
  • MeetingBar/Extensions/String.swift
  • MeetingBar/Meetings/MeetingLinkDetector.swift
  • MeetingBarLogicTests/MeetingLinkDetectorTests.swift

Comment thread MeetingBar/Meetings/MeetingLinkDetector.swift
Comment thread MeetingBar/Meetings/MeetingLinkDetector.swift
jamtur01 added 3 commits July 14, 2026 15:12
from(_:) recompiled five regex patterns on every call, and it runs per
event with HTML notes on every calendar refresh. Hoisted them to static
let NSRegularExpression compiled once and reused via a small helper.
Same patterns, order and output — just no per-call compilation. Closes
the last CodeRabbit note on leits#953.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
MeetingBarLogicTests/MeetingLinkDetectorTests.swift (1)

613-621: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Deallocate memory returned by mach_port_names.

The mach_port_names kernel call allocates out-of-line memory for the names and types arrays. Since this memory is not automatically freed by Swift, it leaves behind a small memory leak each time the test runs. While negligible in a test process, it is best practice to clean it up—especially within a test designed to prevent resource exhaustion.

Consider using vm_deallocate in a defer block to free the allocated arrays.

♻️ Proposed refactor
     private func machPortCount() -> Int {
         var names: mach_port_name_array_t?
         var namesCount: mach_msg_type_number_t = 0
         var types: mach_port_type_array_t?
         var typesCount: mach_msg_type_number_t = 0
-        guard mach_port_names(task_self_trap(), &names, &namesCount, &types, &typesCount) == KERN_SUCCESS
+        let task = task_self_trap()
+        guard mach_port_names(task, &names, &namesCount, &types, &typesCount) == KERN_SUCCESS
         else { return -1 }
+        
+        defer {
+            if let names = names {
+                vm_deallocate(task, vm_address_t(bitPattern: names), vm_size_t(namesCount) * vm_size_t(MemoryLayout<mach_port_name_t>.stride))
+            }
+            if let types = types {
+                vm_deallocate(task, vm_address_t(bitPattern: types), vm_size_t(typesCount) * vm_size_t(MemoryLayout<mach_port_type_t>.stride))
+            }
+        }
         return Int(namesCount)
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@MeetingBarLogicTests/MeetingLinkDetectorTests.swift` around lines 613 - 621,
Update machPortCount() to deallocate the memory returned in names and types by
mach_port_names using vm_deallocate in a defer block. Ensure cleanup runs after
both successful and failed calls while preserving the existing -1 failure result
and count return behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@MeetingBarLogicTests/MeetingLinkDetectorTests.swift`:
- Around line 613-621: Update machPortCount() to deallocate the memory returned
in names and types by mach_port_names using vm_deallocate in a defer block.
Ensure cleanup runs after both successful and failed calls while preserving the
existing -1 failure result and count return behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 64aa5e42-216c-486d-a1c9-cd6d66b794a5

📥 Commits

Reviewing files that changed from the base of the PR and between a35af58 and 3dc716e.

📒 Files selected for processing (3)
  • MeetingBar/Meetings/MeetingLinkDetector.swift
  • MeetingBarLogicTests/MeetingLinkDetectorTests.swift
  • MeetingBarTests/HelpersTests.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • MeetingBar/Meetings/MeetingLinkDetector.swift

mach_port_names hands back out-of-line memory for its names/types
arrays that Swift won't reclaim, so the leak-detection helper was
itself leaking a little each call. Deallocate both in a defer. Reading
the count happens first, so the measurement is unchanged. Addresses the
last CodeRabbit nit on leits#953.
@jamtur01

Copy link
Copy Markdown
Contributor Author

From what I can see, the test failure is an unrelated flaky test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant