Skip to content

[Pseudo-ObjC] Reconstruct @autoreleasepool and @synchronized(...) blocks - #8525

Open
AngeloD2022 wants to merge 2 commits into
Vector35:devfrom
AngeloD2022:pseudo-objc-autoreleasepool
Open

AngeloD2022 wants to merge 2 commits into
Vector35:devfrom
AngeloD2022:pseudo-objc-autoreleasepool

Conversation

@AngeloD2022

@AngeloD2022 AngeloD2022 commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

Translate autorelease pool runtime function calls to @autoreleasepool {...} blocks.

Original Objective-C snippet:

for (NSUInteger itemNo = 1; itemNo <= 3; itemNo++) {
    @autoreleasepool {
        NSString *item = [NSString stringWithFormat:@"batch %lu, item %lu",
                            (unsigned long)batchNo,
                            (unsigned long)itemNo];
        NSString *displayItem = [item uppercaseString];
        [items addObject:displayItem];
    }
}

Pseudo-C snippet:

for (int64_t i = 1; i <= 3; i += 1)
{
    void* context = _objc_autoreleasePoolPush();
    int64_t x0;
    int64_t var_80_1 = x0;
    i_1 = i;
    id location_2 = _objc_retainAutoreleasedReturnValue(_objc_msgSend(
        _OBJC_CLASS_$_NSString, "stringWithFormat:", &cfstr_batch_%lu,_item_%lu));
    int64_t location_1 = _objc_retainAutoreleasedReturnValue(_objc_msgSend(
        location_2, "uppercaseString"));
    _objc_msgSend(var_20, "addObject:", location_1);
    _objc_storeStrong(&location_1, nullptr);
    _objc_storeStrong(&location_2, nullptr);
    _objc_autoreleasePoolPop(context);
}

Equivalent Pseudo-Objective-C:

for (int64_t i = 1; i <= 3; i += 1)
{
    @autoreleasepool
    {
        int64_t x0;
        int64_t var_80_1 = x0;
        i_1 = i;
        id location_2 =
            [[NSString stringWithFormat:@"batch %lu, item %lu"] retain];
        int64_t location_1 = [[location_2 uppercaseString] retain];
        [var_20 addObject:location_1];
        _objc_storeStrong(&location_1, nullptr);
        _objc_storeStrong(&location_2, nullptr);
    }
}

@AngeloD2022
AngeloD2022 marked this pull request as ready for review September 6, 2026 22:58
@bdash
bdash self-requested a review September 7, 2026 00:13
@AngeloD2022
AngeloD2022 force-pushed the pseudo-objc-autoreleasepool branch from 4c9e11b to 47e0fd0 Compare September 7, 2026 08:04

@bdash bdash left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the PR! The @autoreleasepool rendering is a nice addition. Factoring the handling of HLIL_BLOCK so it can be overridden by Obj-C rendering seems like generally the right shape.

The autorelease region matching needs some work, though.

The fundamental issue is that TryEmitBlockRegion only ever looks at the immediate children of a single block. Nothing requires that a pool's pops are all siblings of the push. An autorelease pool containing a conditional return, or a conditional break or continue when the pool is inside a loop, will result in multiple pops for a single push, with at least one at a deeper level. Those nested pops are missed by the current approach, so they'll survive into the output despite referencing a variable that is no longer visible:

while (true)
{
    @autoreleasepool
    {
        ...
        if (cond)
        {
            // What is `context` that this refers to?
            _objc_autoreleasePoolPop(context);
            break;
        }
    }
    ...
}

I think the shape you want is to pair pops with pushes using the pool handle rather than by position, and then elide any pop of a handle whose region is currently open, wherever it appears inside that region.

Comment thread lang/c/pseudoc.cpp Outdated
Comment thread lang/c/pseudoc.cpp Outdated
@bdash

bdash commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

This snippet, compiled with cc -fobjc-arc -Os -o test test.m, demonstrates the issue I described with pops that are not siblings of the push:

#import <Foundation/Foundation.h>

int main(int argc, char** argv)
{
    for (id obj in NSProcessInfo.processInfo.environment)
    {
        @autoreleasepool {
            if (!obj)
                break;
            NSLog(@"%@", obj);
        }
    }
}

@AngeloD2022

Copy link
Copy Markdown
Contributor Author

Appreciate the review, Mark! I'll investigate these tomorrow as soon as I can.

@AngeloD2022
AngeloD2022 force-pushed the pseudo-objc-autoreleasepool branch from 47e0fd0 to e2698d1 Compare September 11, 2026 17:16
@AngeloD2022

Copy link
Copy Markdown
Contributor Author

I made EmitBlockStatements focus solely on the span of statements provided to it, and moved the void return check up from EmitStandardBlockStatement to the loop inside of EmitBlockStatements. Part of the test happens in GetExpr_BLOCK, where the block instruction is in scope, and is passed through the EmitBlockStatements isBlockRoot parameter.

@AngeloD2022
AngeloD2022 force-pushed the pseudo-objc-autoreleasepool branch from 9a8a910 to 4f93515 Compare September 11, 2026 17:50
@AngeloD2022
AngeloD2022 marked this pull request as draft September 18, 2026 07:54
@AngeloD2022 AngeloD2022 changed the title [Pseudo-ObjC] Render @autoreleasepool blocks [Pseudo-ObjC] Reconstruct @autoreleasepool and @synchronized(...) blocks Sep 18, 2026
@AngeloD2022
AngeloD2022 marked this pull request as ready for review September 18, 2026 19:21
@bdash

bdash commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

Thanks for taking the time to update the PR, and sorry it has taken me so long to get back to reviewing it.

It looks like there's still a number of cases where the processing results in a rendering that is inconsistent with the code. Some are contrived and probably rare in real binaries. Others are more likely to show up.

Wrong output

1. Declarations inside the block are used after it

The matched range becomes a real { } scope, but HLIL declares a variable just before its first use, and that
is often inside the range. Any use after the closing brace is out of scope. Every -O0/-O1 region with an
early exit hits this, because clang routes those exits through a flag variable declared inside the range.

// clang -arch arm64 -fobjc-arc -O0 -framework Foundation sync_early.m -o sync_early_O0
#import <Foundation/Foundation.h>

__attribute__((noinline)) void work(id x) { NSLog(@"%@", x); }
__attribute__((noinline)) int check(id x) { return [x length] > 3; }

__attribute__((noinline)) int sync_early(id a)
{
	@synchronized(a) {
		work(a);
		if (check(a)) {
			work(@"exit");
			return 1;
		}
		work(@"in lock");
	}
	work(@"after lock");
	return 0;
}

int main(void) { return sync_early(@"hello"); }
// Pseudo Objective-C
{
    id var_20 = nullptr;
    _objc_storeStrong(&var_20, arg1);
    id obj = [var_20 retain];

    @synchronized(obj)
    {
        _work(var_20);
        int32_t var_30;
        int32_t var_14;

        if (!_check(var_20))
        {
            _work(@"in lock");
            var_30 = 0;
        }
        else
        {
            _work(@"exit");
            var_14 = 1;
            var_30 = 1;
        }
    }

    [obj release];

    if (!var_30)                     // out of scope
    {
        _work(@"after lock");
        var_14 = 0;                  // out of scope
        int32_t var_30_1 = 1;
    }

    _objc_storeStrong(&var_20, nullptr);
    return (uint64_t)var_14;         // out of scope
}

The same happens with @autoreleasepool:

// clang -arch arm64 -fobjc-arc -O1 -framework Foundation two_exits.m -o two_exits_O1
#import <Foundation/Foundation.h>

__attribute__((noinline)) void work(id x) { NSLog(@"%@", x); }
__attribute__((noinline)) int check(id x) { return [x length] > 3; }

__attribute__((noinline)) int two_exits(id a, id b)
{
	@autoreleasepool {
		if (check(a)) {
			work(@"exit a");
			return 1;
		}
		if (check(b)) {
			work(@"exit b");
			return 2;
		}
		work(@"body");
	}
	work(@"after pool");
	return 0;
}

int main(void) { return two_exits(@"hello", @"c"); }
// Pseudo Objective-C
{
    [arg1 retain];
    [arg2 retain];

    @autoreleasepool
    {
        struct __NSConstantString* const x0_3;
        int64_t result;
        int32_t x23;

        if (!_check(arg1))
        {
            int32_t x0_5 = _check(arg2);
            x23 = !x0_5 ? 1 : 0;

            x0_3 = !x0_5 ? @"body" : @"exit b";

            result = 2;
        }
        else
        {
            x23 = 0;
            result = 1;
            x0_3 = @"exit a";
        }

        _work(x0_3);
    }

    if (x23)                         // out of scope
    {
        _work(@"after pool");
        result = 0;                  // out of scope
    }

    [arg2 release];
    [arg1 release];
    return result;                   // out of scope
}

Also reproduces with early_computed (x0_2 at -O1; var_2c, result at -O0), early_distinct_tail
(var_24, var_14 at -O0), and loop_break (x22_2 at -O1). Sources for those are in cases 7 and 8.

2. Nested @synchronized on the same object is paired wrongly

The scan for the closing objc_sync_exit stops at the first match, with no depth counting. At -O1 both
enters use the same SSA variable (arg1#0), so SSA-based matching would not fix this either.

// clang -arch arm64 -fobjc-arc -O1 -framework Foundation nested_sync.m -o nested_sync_O1
#import <Foundation/Foundation.h>

__attribute__((noinline)) void work(id x) { NSLog(@"%@", x); }

__attribute__((noinline)) void nested_sync(id a)
{
	@synchronized(a) {
		work(@"outer-before");
		@synchronized(a) {
			work(@"inner");
		}
		work(@"outer-after");
	}
}

int main(void) { nested_sync(@"a"); return 0; }
// Pseudo C
{
    _objc_retain(arg1);
    _objc_retain(arg1);
    _objc_sync_enter(arg1);
    _work(&cfstr_outer-before);
    _objc_retain(arg1);
    _objc_sync_enter(arg1);
    _work(&cfstr_inner);
    _objc_sync_exit(arg1);
    _objc_release(arg1);
    _work(&cfstr_outer-after);
    _objc_sync_exit(arg1);
    _objc_release(arg1);
    /* tailcall */
    return _objc_release(arg1);
}
// Pseudo Objective-C
{
    [arg1 retain];
    [arg1 retain];

    @synchronized(arg1)
    {
        _work(@"outer-before");
        [arg1 retain];
        _objc_sync_enter(arg1);      // inner enter left raw
        _work(@"inner");
    }                                // closed by the inner exit

    [arg1 release];
    _work(@"outer-after");           // actually still under the outer lock
    _objc_sync_exit(arg1);           // outer exit left raw
    [arg1 release];
    /* tailcall */
    return [arg1 release];
}

At -O0 each @synchronized gets its own temporary (obj, obj_1), and the nesting renders correctly.

3. Locked variable changes between enter and exit

Enter and exit are paired by Variable identity alone.

// clang -arch arm64 -fobjc-arc -O0 -framework Foundation sync_reassigned.m -o sync_reassigned_O0
#import <Foundation/Foundation.h>
#include <objc/objc-sync.h>

__attribute__((noinline)) void work(id x) { NSLog(@"%@", x); }

__attribute__((noinline)) void sync_reassigned(id a, id b)
{
	id x = a;
	objc_sync_enter(x);
	work(x);
	x = b;
	objc_sync_exit(x);
	work(x);
}

int main(void) { sync_reassigned(@"a", @"b"); return 0; }
// Pseudo C
{
    id var_18 = nullptr;
    _objc_storeStrong(&var_18, arg1);
    id var_20 = nullptr;
    _objc_storeStrong(&var_20, arg2);
    id obj;
    id* location = &obj;
    obj = _objc_retain(var_18);
    _objc_sync_enter(obj);
    _work(obj);
    _objc_storeStrong(location, var_20);
    _objc_sync_exit(obj);
    _work(obj);
    _objc_storeStrong(location, nullptr);
    _objc_storeStrong(&var_20, nullptr);
    return _objc_storeStrong(&var_18, nullptr);
}
// Pseudo Objective-C
{
    id var_18 = nullptr;
    _objc_storeStrong(&var_18, arg1);
    id var_20 = nullptr;
    _objc_storeStrong(&var_20, arg2);
    id obj;
    id* location = &obj;
    obj = [var_18 retain];

    @synchronized(obj)               // locks arg1
    {
        _work(obj);
        _objc_storeStrong(location, var_20);
    }                                // unlocks arg2

    _work(obj);
    _objc_storeStrong(location, nullptr);
    _objc_storeStrong(&var_20, nullptr);
    return _objc_storeStrong(&var_18, nullptr);
}

obj has its address taken, so in HLIL SSA both calls read it through memory versions
(_objc_sync_enter(obj) @ mem#6 -> mem#7, _objc_sync_exit(obj) @ mem#9 -> mem#10), with no variable version
to compare. SSA-based matching would catch a direct reassignment. For an address-taken variable the matcher has
to give up. At -O1 the optimizer uses separate variables (arg1, arg2), so no block is formed.

4. Nested pop hidden when control does not leave the pool

ShouldSkipStatement hides every pop of an active handle at any depth, without checking that the pop is
followed by leaving the block.

// clang -arch arm64 -fobjc-arc -O1 -framework Foundation nested_pop_no_exit.m -o nested_pop_no_exit_O1
#import <Foundation/Foundation.h>

extern void* objc_autoreleasePoolPush(void);
extern void objc_autoreleasePoolPop(void*);

__attribute__((noinline)) void work(id x) { NSLog(@"%@", x); }

__attribute__((noinline)) void nested_pop_no_exit(id a, int c)
{
	void* ctx = objc_autoreleasePoolPush();
	if (c) {
		objc_autoreleasePoolPop(ctx);
		work(@"after nested pop");
		ctx = objc_autoreleasePoolPush();
	}
	work(a);
	objc_autoreleasePoolPop(ctx);
}

int main(int argc, char** argv) { nested_pop_no_exit(@"a", argc > 1); return 0; }
// Pseudo C
{
    _objc_retain(arg1);
    void* context = _objc_autoreleasePoolPush();

    if (arg2)
    {
        _objc_autoreleasePoolPop(context);
        _work(&cfstr_after_nested_pop);
        context = _objc_autoreleasePoolPush();
    }

    _work(arg1);
    _objc_autoreleasePoolPop(context);
    /* tailcall */
    return _objc_release(arg1);
}
// Pseudo Objective-C
{
    [arg1 retain];

    @autoreleasepool
    {
        if (arg2)
        {
            // pop hidden
            _work(@"after nested pop");
            context = _objc_autoreleasePoolPush();
        }

        _work(arg1);
    }

    /* tailcall */
    return [arg1 release];
}

Missed matches

These fall back to the plain calls, so the output is still correct.

5. A pop that ends the function is never matched

DoesCallTerminateAutoreleasePool only accepts HLIL_CALL. When the pop is the function's final statement it is
an HLIL_TAILCALL at -O1 and an HLIL_RET wrapping the call at -O0.

// clang -arch arm64 -fobjc-arc -O1 -framework Foundation pool_tail.m -o pool_tail_O1
#import <Foundation/Foundation.h>

__attribute__((noinline)) void work(id x) { NSLog(@"%@", x); }

__attribute__((noinline)) void pool_tail(void)
{
	@autoreleasepool {
		work(@"x");
	}
}

int main(void) { pool_tail(); return 0; }
// Pseudo Objective-C
{
    void* context = _objc_autoreleasePoolPush();
    _work(@"x");
    /* tailcall */
    return _objc_autoreleasePoolPop(context);
}
// Pseudo Objective-C
{
    void* context = _objc_autoreleasePoolPush();
    _work(@"x");
    return _objc_autoreleasePoolPop(context);
}

HLIL SSA: HLIL_TAILCALL return _objc_autoreleasePoolPop(context#1) __tailcall (-O1) and
HLIL_RET return _objc_autoreleasePoolPop(context#1) (-O0).

@synchronized at the end of a function still matched, because ARC's trailing release becomes the tail call
instead:

// clang -arch arm64 -fobjc-arc -O1 -framework Foundation sync_tail.m -o sync_tail_O1
#import <Foundation/Foundation.h>
#include <objc/objc-sync.h>

__attribute__((noinline)) void work(id x) { NSLog(@"%@", x); }

__attribute__((noinline)) void sync_tail(id a)
{
	objc_sync_enter(a);
	work(a);
	objc_sync_exit(a);
}

int main(void) { sync_tail(@"a"); return 0; }

6. Early returns at -O2

clang duplicates the pop or exit into each branch, so none is left at the block's own level.

// clang -arch arm64 -fobjc-arc -O2 -framework Foundation early_distinct_tail.m -o early_distinct_tail_O2
#import <Foundation/Foundation.h>

__attribute__((noinline)) void work(id x) { NSLog(@"%@", x); }
__attribute__((noinline)) int check(id x) { return [x length] > 3; }

__attribute__((noinline)) int early_distinct_tail(id a)
{
	@autoreleasepool {
		work(a);
		if (check(a))
			return 1;
		work(@"in pool");
	}
	work(@"after pool");
	return 0;
}

int main(void) { return early_distinct_tail(@"hello"); }
// Pseudo Objective-C
{
    [arg1 retain];
    void* context = _objc_autoreleasePoolPush();
    _work(arg1);
    int64_t result;

    if (!_check(arg1))
    {
        _work(@"in pool");
        _objc_autoreleasePoolPop(context);
        _work(@"after pool");
        result = 0;
    }
    else
    {
        _objc_autoreleasePoolPop(context);
        result = 1;
    }

    [arg1 release];
    return result;
}

Also missed at -O2: two_exits and sync_early (sources in case 1), and early_computed:

// clang -arch arm64 -fobjc-arc -O2 -framework Foundation early_computed.m -o early_computed_O2
#import <Foundation/Foundation.h>

__attribute__((noinline)) void work(id x) { NSLog(@"%@", x); }

__attribute__((noinline)) NSUInteger early_computed(id a)
{
	@autoreleasepool {
		NSString* s = [a description];
		if ([s length] > 10)
			return [s hash];
		work(s);
	}
	work(@"after pool");
	return 0;
}

int main(void) { return (int)early_computed(@"hello"); }

Cases that are handled correctly

7. break out of a pool (-O2)

This is the only shape found that reaches the nested-pop hiding path legitimately.

// clang -arch arm64 -fobjc-arc -O2 -framework Foundation loop_break.m -o loop_break_O2
#import <Foundation/Foundation.h>

__attribute__((noinline)) void work(id x) { NSLog(@"%@", x); }
__attribute__((noinline)) int check(id x) { return [x length] > 3; }

__attribute__((noinline)) void loop_break(NSArray* items)
{
	for (id item in items) {
		@autoreleasepool {
			if (check(item))
				break;
			if ([item hash] & 1)
				continue;
			work(item);
		}
	}
	work(@"done");
}

int main(void) { loop_break(@[ @"hello", @"b" ]); return 0; }
// Pseudo C (excerpt)
void* context = _objc_autoreleasePoolPush();

if (_check(x22_1))
{
    _objc_autoreleasePoolPop(context);
    goto label_10000092c;
}

if (!(_objc_msgSend(x22_1, "hash") & 1))
    _work(x22_1);
// Pseudo Objective-C (excerpt)
@autoreleasepool
{
    if (_check(x22_1))
    {
        goto label_10000092c;
    }

    if (!([x22_1 hash] & 1))
        _work(x22_1);
}

Hiding the pop is correct, because leaving an @autoreleasepool pops it. The only oddity is cosmetic: the brace
style for the if body is chosen before the pop is hidden, so the lone goto keeps { }.

At -O1 the same function hits case 1 instead (x22_2 declared inside the pool and tested after it).

8. Baseline and nested pools

// clang -arch arm64 -fobjc-arc -O1 -framework Foundation baseline.m -o baseline_O1
#import <Foundation/Foundation.h>

__attribute__((noinline)) void work(id x) { NSLog(@"%@", x); }

__attribute__((noinline)) void baseline(id a)
{
	@autoreleasepool {
		work(a);
	}
	@synchronized(a) {
		work(a);
	}
}

__attribute__((noinline)) void nested_pool(id a)
{
	@autoreleasepool {
		work(@"outer-before");
		@autoreleasepool {
			work(@"inner");
		}
		work(@"outer-after");
	}
}

int main(void)
{
	baseline(@"a");
	nested_pool(@"a");
	return 0;
}
// Pseudo Objective-C: baseline
{
    [arg1 retain];

    @autoreleasepool
    {
        _work(arg1);
    }

    [arg1 retain];

    @synchronized(arg1)
    {
        _work(arg1);
    }

    [arg1 release];
    /* tailcall */
    return [arg1 release];
}
// Pseudo Objective-C: nested_pool
{
    [arg1 retain];

    @autoreleasepool
    {
        _work(@"outer-before");

        @autoreleasepool
        {
            _work(@"inner");
        }

        _work(@"outer-after");
    }

    /* tailcall */
    return [arg1 release];
}

@AngeloD2022

Copy link
Copy Markdown
Contributor Author

Lots of good catches here. Thanks for the review. I'll fix these ASAP!

This branch has not been deployed

No deployments
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