Skip to content

macOS: every obs-websocket GetStats leaks ~200 autoreleased objects (os_get_free_space has no autorelease pool on the websocket thread) — ~2 MB/min at one poll per second #13918

Description

@sgaldeano

Operating System Info

macOS 15

Other OS

macOS 15.7.9 (24G830), Intel (x86_64 build)

OBS Studio Version

32.2.2

OBS Studio Version (Other)

No response

OBS Studio Log URL

Not needed for this one: nothing is logged; the reproduction is a websocket client and the process's resident size. Happy to attach a log on request.

OBS Studio Crash Log URL

No response

Expected Behavior

An obs-websocket client that polls GetStats (a stats dashboard, a controller, any client that watches fps/skipped frames) does not make OBS's memory grow over time.

Current Behavior

On macOS, every GetStats answer leaves about 200 Foundation objects in memory that are never released. At one GetStats per second OBS grows by roughly 2 MB per minute for as long as the client is connected — linear, never plateaus. Two hours of polling: +280 MB resident.

Cause, from the source: GetStats calls os_get_free_disk_space() for availableDiskSpace; on macOS that is os_get_free_space() in libobs/util/platform-cocoa.m, which builds an NSURL, an NSArray of keys and the NSDictionary from -[NSURL resourceValuesForKeys:error:] (and everything that dictionary holds). All of it is autoreleased. obs-websocket answers the request on its own thread, which has no autorelease pool, so the objects are never released ("autoreleased with no pool in place - just leaking", in the runtime's words). The main thread has a pool and is unaffected, which is why the UI never shows this.

The function exists in the same form on master today.

Steps to Reproduce

  1. Launch OBS with the runtime's diagnostic for this exact condition: OBJC_DEBUG_MISSING_POOLS=YES /Applications/OBS.app/Contents/MacOS/OBS 2> /tmp/obs.txt (any profile whose recording path exists — the default ~/Movies does; with a non-existent path the lookup fails early and allocates almost nothing).
  2. Send ten GetStats requests over obs-websocket.
  3. grep -c 'no pool in place' /tmp/obs.txt → 1 980 new lines (198 per request). Ten GetVersion, GetStreamStatus, GetRecordStatus or GetRecordDirectory add 0.
  4. Send 3 000 GetStats (one at a time): resident size 228 → 340 MB, and it stays there; heap on the process shows +594 000 objects, mostly __NSArrayI_Transfer, __NSCFString, NSConcreteData, NSPathStore2, __NSDictionaryM; vmmap --summary puts the growth in MALLOC_NANO.

Class breakdown per ten requests, from the diagnostic:

 880 __NSArrayI_Transfer
 532 __NSCFString
 411 NSConcreteData
 118 NSPathStore2
  20 __NSDictionaryM

Anything else we should know?

The fix is an @autoreleasepool around the body of os_get_free_space (the other Cocoa helpers in that file already do this, e.g. os_request_high_performance). With it, the same ten requests add 0 diagnostic lines and 3 000 requests add about 1 MB. Patch against 32.2.2 (applies to master):

--- a/libobs/util/platform-cocoa.m
+++ b/libobs/util/platform-cocoa.m
@@ -361,22 +361,30 @@ uint64_t os_get_sys_free_size(void)
 int64_t os_get_free_space(const char *path)
 {
     if (path) {
-        NSURL *fileURL = [NSURL fileURLWithPath:@(path)];
-
-        NSArray *availableCapacityKeys = @[
-            NSURLVolumeAvailableCapacityKey, NSURLVolumeAvailableCapacityForImportantUsageKey,
-            NSURLVolumeAvailableCapacityForOpportunisticUsageKey
-        ];
-
-        NSDictionary *values = [fileURL resourceValuesForKeys:availableCapacityKeys error:nil];
-
-        NSNumber *availableImportantSpace = values[NSURLVolumeAvailableCapacityForImportantUsageKey];
-        NSNumber *availableSpace = values[NSURLVolumeAvailableCapacityKey];
-
-        if (availableImportantSpace.longValue > 0) {
-            return availableImportantSpace.longValue;
-        } else {
-            return availableSpace.longValue;
+        /* Callers are not only the main thread: obs-websocket answers
+         * GetStats on its own thread, which has no autorelease pool, and
+         * every object autoreleased here (the URL, the key array, the
+         * resource dictionary and all it holds) would then live until that
+         * thread exits — about 200 objects per call, 2 MB a minute at one
+         * poll a second. */
+        @autoreleasepool {
+            NSURL *fileURL = [NSURL fileURLWithPath:@(path)];
+
+            NSArray *availableCapacityKeys = @[
+                NSURLVolumeAvailableCapacityKey, NSURLVolumeAvailableCapacityForImportantUsageKey,
+                NSURLVolumeAvailableCapacityForOpportunisticUsageKey
+            ];
+
+            NSDictionary *values = [fileURL resourceValuesForKeys:availableCapacityKeys error:nil];
+
+            NSNumber *availableImportantSpace = values[NSURLVolumeAvailableCapacityForImportantUsageKey];
+            NSNumber *availableSpace = values[NSURLVolumeAvailableCapacityKey];
+
+            if (availableImportantSpace.longValue > 0) {
+                return availableImportantSpace.longValue;
+            } else {
+                return availableSpace.longValue;
+            }
         }
     }
 

The same applies to any other Cocoa code reached from obs-websocket's thread; os_get_free_space is the one GetStats hits every time. Glad to open this as a PR if that is preferred.

— filed by the Rigger project (an application built on OBS)

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions