Skip to content

Release the thread store when its root scope ends - #72

Merged
zienag merged 1 commit into
yandex:mainfrom
zienag:thread-store-leak
Oct 26, 2026
Merged

zienag merged 1 commit into
yandex:mainfrom
zienag:thread-store-leak

Conversation

@zienag

@zienag zienag commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Every root scope that ended on a thread outside a task leaked its store. setToTSD keeps the store in thread-specific data with a retain, but clearTSD only set the slot to nil and never released it. The key's destructor would release it, but it runs only for a value still set when the thread exits, so it never did. Code that opens top-level scopes on the main thread lost a store, about 256 bytes, each time.

CI now runs the tests under LeakSanitizer, which catches this leak and any later one.

Changes

  • clearTSD() takes the store out of thread-specific data and releases it.
  • A failed pthread_setspecific stops the process with its error code, as a failed pthread_key_create already does: either error means a corrupted key or no memory, and continuing would leave the store's ownership unknown.
  • CI builds and runs the tests with --sanitize=address on Linux, once: the build step builds the tests too, and the test step skips the build. LeakSanitizer fails the run on memory nothing references at exit.
  • CI passes --disable-xctest: the tests use Swift Testing only, and the XCTest runner found no tests.
  • Tests/lsan-suppressions.txt suppresses what LeakSanitizer reports inside Swift's regex libraries, about 28 KB in every run, none of it from this package.
  • AGENTS.md says how CI checks for leaks.

Test plan

In a swift:6.2 Linux container, running the two CI steps as written:

  • with the fix, the build and the tests pass, and the test step compiles nothing;
  • without the fix, the tests pass but LeakSanitizer fails the run on 12 allocations, 1,184 bytes, all from RawStore.onRootScopeCreation. Existing tests that open root scopes on threads catch it.

Copilot AI balanced review requested due to automatic review settings October 26, 2026 16:05

Copilot AI 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.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

5 open issues
What changed in this PR

Fixes a leak where thread-specific RawStore instances were retained beyond the end of root scopes on non-task threads by ensuring clearTSD() releases the stored value.

Changes:

  • Update RawStore.clearTSD() to remove the TSD value and release the retained RawStore.
  • Add a Darwin-only regression test that checks memory growth after ending many root scopes on a separate thread.
File Description
Tests/​ImplicitsTests/​ImplicitTests.swift Adds a regression test that detects leaked per-thread stores via process memory statistics.
Sources/​Implicits/​RawStore.swift Ensures clearTSD() releases the RawStore that was retained in thread-specific data.

Review effort: Lite

Comment on lines 201 to 207
private static func clearTSD() {
guard let ptr = pthread_getspecific(key) else {
return
}
pthread_setspecific(key, nil)
Unmanaged<RawStore>.fromOpaque(ptr).release()
}
Comment on lines +266 to +267
// Byte counts are process-wide and tests run in parallel, so the smallest growth
// of several attempts is the one least disturbed by other tests.
for _ in 0..<10000 {
rootScope()
}
growth = min(growth, bytesInUse() - before)
Comment on lines +268 to +282
nonisolated(unsafe) var growth = Int.max
let finished = DispatchSemaphore(value: 0)
let thread = Thread {
for _ in 0..<5 {
let before = bytesInUse()
for _ in 0..<10000 {
rootScope()
}
growth = min(growth, bytesInUse() - before)
}
finished.signal()
}
thread.start()
finished.wait()

finished.wait()

// A store left behind is over 100 bytes: more than a megabyte for these scopes.
#expect(growth < 256 * 1024)
@zienag
zienag force-pushed the thread-store-leak branch 5 times, most recently from abae482 to 311c924 Compare October 26, 2026 17:02
@zienag
zienag force-pushed the thread-store-leak branch from 311c924 to c8d6ca0 Compare October 26, 2026 17:08
@zienag
zienag enabled auto-merge (squash) October 26, 2026 17:12
@zienag
zienag merged commit faee489 into yandex:main Oct 26, 2026
1 check passed
@zienag
zienag deleted the thread-store-leak branch October 27, 2026 22:40
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