Operator new/delete override only work for the first time for an iOS App on iOS16

Phenomenon

We've found operator new/delete override in iOS app, only works for the first time when the app launches on iOS16, operator override is not working in the second and subsequent launch of the same app.

Steps to reproduce

Development environment: XCode 16.2

Create a new iOS Objective-C project in XCode

In the project options page, choose the following settings:

  • Name the project: OverrideNew
  • Interface: Storyboard
  • Language: Objective-C
  • Testing System: None

Add test code

  • Change AppDelegate.m's file name to AppDelegate.mm to add the following C++ test code.

  • Add the following code after #import "AppDelegate.h"

#include <os/log.h>
#include <string>

static bool needLog = false;

void* operator new(size_t size) {
    void* ptr = malloc(size);
    if(needLog) {
        // Log to prove override new works
        os_log_error(OS_LOG_DEFAULT, "Overrided new called. ptr: %p\n", ptr);
    }
    return ptr;
}

void operator delete(void* ptr) noexcept {
    free(ptr);
    if(needLog) {
        // Log to prove override delete works
        os_log_error(OS_LOG_DEFAULT, "Overrided delete called. ptr: %p\n", ptr);
    }
}

void StringConstructTest(void) {
    needLog = true;
    os_log_error(OS_LOG_DEFAULT, "Enter StringConstructTest1\n");
    {
        std::string str;
        // a long string will trigger memory allocation on heap
        str = "Hello world and this is a long string.\n";
        os_log_error(OS_LOG_DEFAULT, "%{public}s\n", str.c_str());
    }
    os_log_error(OS_LOG_DEFAULT, "Exit StringConstructTest1\n");
    needLog = false;
}
  • Call StringConstructTest() in didFinishLaunchingWithOptions method:
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // Override point for customization after application launch.
    StringConstructTest();
    return YES;
}

Change build settings

Change Minimum Deployments: iOS 16.

Build and run the project on an iOS16 device, emulator can not reproduce the problem.

Observe logs in Console app on Mac

Use the following log filters:

  • message type: error
  • process: OverrideNew

First launch

First launch on device(not from a XCode debug launch), the log is:

Enter StringConstructTest1
Overrided new called. ptr: 0x281f2f450
Hello world and this is a long string.
Overrided delete called. ptr: 0x281f2f450
Exit StringConstructTest1

"Overrided new called" proved the override new operator is called.

Second and subsequence launch

Second and subsequence launch on device(not from a XCode debug launch), the log is:

Enter StringConstructTest1
Hello world and this is a long string.
Exit StringConstructTest1

No log for "Overrided new called", the subsequence launch, the override operator new is not called anymore.

Expected behavior

For every app launch, log "Overrided new called" will happen and operator override works.

On iOS16, operator override only works for the first launch.

I've also tested on iOS18, operator override works every time as expected.

Question

Is there a way to force operator override works every time on iOS16?

Written by zhaojun2022_kuro in 774805021
operator new/delete override … only works for the first time … on iOS 16

Oh, that’s fun. I suspect this is fallout from two separate obscure topics:

  • C++ relies on the One Definition Rule, which is an ongoing source of grief for linkers in general and dynamic linkers in particular.

  • Modern versions of iOS use a dynamic library closure to speed up launch. The first time your app launches, the dynamic linker starts it normally but, in the process, records the closure of libraries imported by your app. The system links those all together so that the next time the app launches the dynamic linker gets a head start by starting with that closure.

It seems that the latter has a problem with the former on iOS 16, which we then fixed in iOS 17.

The process you’ve described here reproduces the problem with a single Mach-O image, the app itself. Is that indicative of your real app? Or does your real app embed a bunch of different frameworks, where you want your operator overrides to apply to them all

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

First thanks for the reply.

"Or does your real app embed a bunch of different frameworks, where you want your operator overrides to apply to them all?"

Thanks for reminding, the problem is in libc++.1.dylib, the title should be "Operator new/delete override of libc++.1.dylib only works for the first time for an iOS app on iOS16".

I've changed the test code:


#include <os/log.h>

#include <string>



static bool needLog = false;



void* operator new(size_t size) {

    void* ptr = malloc(size);

    if(needLog) {

        // Log to prove override new works

        os_log_error(OS_LOG_DEFAULT, "Overrided new called. ptr: %p\n", ptr);

    }

    return ptr;

}



void operator delete(void* ptr) noexcept {

    free(ptr);

    if(needLog) {

        // Log to prove override delete works

        os_log_error(OS_LOG_DEFAULT, "Overrided delete called. ptr: %p\n", ptr);

    }

}



void StringConstructTest(void) {

    needLog = true;

    os_log_error(OS_LOG_DEFAULT, "Enter StringConstructTest1\n");

    {

        std::string str;

        // a long string will trigger memory allocation on heap

        str = "Hello world and this is a long string.\n";

        os_log_error(OS_LOG_DEFAULT, "%{public}s\n", str.c_str());

    }

    os_log_error(OS_LOG_DEFAULT, "Exit StringConstructTest1\n");



    // newly added code:

    os_log_error(OS_LOG_DEFAULT, "Enter new int test\n");

    {

        int* ptr = new int;

        delete ptr;

    }

    os_log_error(OS_LOG_DEFAULT, "Exit new int test\n");

    needLog = false;

}

First launch on device, ths log is:


Enter StringConstructTest1

Overrided new called. ptr: 0x281f2f450

Hello world and this is a long string.

Overrided delete called. ptr: 0x281f2f450

Exit StringConstructTest1

Enter new int test

Overrided new called. ptr: 0x281058070

Overrided delete called. ptr: 0x281058070

Exit new int test

Second and subsequent launches on device, ths log is:


Enter StringConstructTest1

Hello world and this is a long string.

Exit StringConstructTest1

Enter new int test

Overrided new called. ptr: 0x281058070

Overrided delete called. ptr: 0x281058070

Exit new int test

On both of the first and second launch, the operator override for new int always works, but the operator override for str = "Hellow world and this is a long string.", which is "operator=" of std::string only works for the first time.

The compiled code of new int is in the main executable, while the compiled code of "operator=" of std::string is in libc++.1.dylib.

So new/delete override for libc++.1.dylib only works for the first time for an iOS app on iOS16.

"Is that indicative of your real app?"

Yes, the problem happens in our real project, which is an Unreal Engine4 game. Unreal Engine overrides the operator new/delete by default.

The main Engine runtime code barely use std::string, but some third-party plugin code may use std::string, and we found that the memory allocated by std::string's "operator=" is not through the Unreal Engine's override of new(which will call the engine's memory allocator), on iOS16 device for the second and subsequent launches.

So I report the issue and want to ask if is there a way to make operator new/delete override of libc++.1.dylib works every time on iOS16?

Written by zhaojun2022_kuro in 825872022
Yes, the problem happens in our real project

OK. That limits your workaround options. If you only needed the overrides to run in a single Mach-O image then you could potentially work around this by linking your override directly into that image [1]. However, as you need this to work across a bunch of images, you have to do engage with the dynamic linker.

Unfortunately I don’t have any great options for you. AFAICT this is just a bug, one that we fixed in iOS 17. I don’t see an obvious way to work around it because you have very little control over how the dynamic linker operates.

The best option I can see right now is to simply raise your deployment target to iOS 17.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] I’m a little fuzzy on the details here, and it seems that I won’t have an excuse to dig into this to clarify them.

After read the dyld's doc: PrebuiltLoaderSet Policy, I've found a tricky work around:

  • Create a new framework, let's say we name it "Interposing", add it to project OverrideNew's "Frameworks, Libraries, and Embedded Content" in the "General" settings tab.

  • Add a cpp file in this framework, and add the following code to it:

#include <fcntl.h>

#define DYLD_INTERPOSE(_replacement,_replacee) \
__attribute__((used)) static struct{ const void* replacement; const void* replacee; } _interpose_##_replacee \
__attribute__ ((section ("__DATA,__interpose"))) = { (const void*)(unsigned long)&_replacement, (const void*)(unsigned long)&_replacee };

static int my_open(const char* path, int flags, mode_t mode)
{
    int value;
    value = open(path, flags, mode);
    return value;
}

DYLD_INTERPOSE(my_open, open)
  • Run project OverrideNew again on device, and everytime operator new override works.

I've read the post: An Apple Library Primer, and found: "Dynamic linker interposing is not documented as API. While it’s a useful technique for developer tools, do not use it in products you ship to end users.", so I guess this tricky way is still not recommended.

In the end, I think the solution is either to raise the deployment target to iOS 17, or totally remove the usage of the std stuff in libc++1.dylib.

Operator new/delete override only work for the first time for an iOS App on iOS16
 
 
Q