Matt Rajca

blog projects github twitter email

NSUndoManager Does Not Store Weak References to Targets

September 14, 2016

In Objective-C, messaging a nil reference is a no-op, so if we consider the following class:

    
    @interface Person : NSObject
    - (void)undoSomething;
    @end
    
    
    @implementation Person
    
    - (void)undoSomething {
        NSLog(@"Performing undo...");
    }
    
    @end
    

The following code snippet will not print anything, but it also won't crash:

    
    Person *steve = nil;
    [steve undoSomething];
    

Since Objective-C is a dynamic language, we can represent message invocations with objects and invoke them at runtime. NSInvocation makes that easy, and it also respects the convention that messaging a nil object is a no-op:

    
    Person *steve = nil;
    NSMethodSignature *undoSignature = [Person instanceMethodSignatureForSelector:@selector(undoSomething)];
    NSInvocation *undoCall = [NSInvocation invocationWithMethodSignature:undoSignature];
    [undoCall setTarget:steve];
    [undoCall setSelector:@selector(undoSomething)];
    [undoCall invoke];
    

Again, the code snippet above won't print anything, but it also won't crash.

This also holds for weak references. If steve were a weak reference that got nil-ified before the invocation was dispatched, this code would fail gracefully. Here's an example for completeness:

    
    Person *steve = [[Person alloc] init];
    Person *__weak steveWeak = steve;
    steve = nil;
    
    NSMethodSignature *undoSignature = [Person instanceMethodSignatureForSelector:@selector(undoSomething)];
    NSInvocation *undoCall = [NSInvocation invocationWithMethodSignature:undoSignature];
    [undoCall setTarget:steveWeak];
    [undoCall setSelector:@selector(undoSomething)];
    [undoCall invoke];
    

The code above also doesn't print anything, since steveWeak gets nil-ified when steve is assigned to nil.

Now onto NSUndoManager.

If you're not familiar with NSUndoManager, it is the primary class used when implementing undo support in Cocoa and Cocoa Touch apps. Every time you perform an action that could be un-done, you register it with an instance of NSUndoManager. The "action" is simply a method (or block) that gets invoked when the user presses "Undo". Here's a basic example that registers our -undoSomething method, which reverses whatever -doSomething: does:

    
    - (IBAction)doSomething:(id)sender {
        Person *steve = ...;
        [[self.window.undoManager prepareWithInvocationTarget:steve] undoSomething];
        // Do something.
 }
    

Actions are stored and invoked in LIFO (last-in first-out) order, so if we were to press "Undo" now, -[NSUndoManager undo] would end up invoking -undoSomething.

In fact, if you look at the stack trace at the time -undo is invoked, you'll notice NSUndoManager internally simply keeps around a stack of NSInvocations to dispatch with each undo operation.

    
    #1  0x00007fffca1d2369 in -[NSInvocation invokeWithTarget:] ()
    #2  0x00007fffcbc73f25 in -[_NSUndoStack popAndInvoke] ()
    #3  0x00007fffcbc73ccc in -[NSUndoManager undoNestedGroup] ()
    

If you take a look at the documentation for -prepareWithInvocationTarget:, you'll notice it claims the "undo manager maintains a weak reference to the target". As I learned the hard way with some new crashes in Pixen, that is actually not true.

Consider the following example:

    
    - (void)crashyUndo {
        Person *steve = [[Person alloc] init];
        [[self.window.undoManager prepareWithInvocationTarget:steve] undoSomething];
        
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            [self.window.undoManager undo];
        });
    }
    

Since steve falls out-of-scope before dispatch_after calls -[NSUndoManager undo], its pointee gets deallocated. When the call to undo is finally made, we get a crash, and if we turn on Zombie Objects in the Scheme Editor, we can confirm NSUndoManager is trying to talk to a deallocated object:

    
    -[Person retain]: message sent to deallocated instance 0x618000002350
    

This crash would not occur if NSUndoManager kept around a weak reference to our Person since it would have been nil-ified, and as we convinced ourselves above, sending an NSInovcation to a nil target is perfectly safe. Thus, contrary to the documentation, NSUndoManager does not actually store weak references to undo targets.