Skip to content

Proposal: Removing requirement for call-site annotations #141

@omus

Description

@omus

The most common complaint about Mocking.jl has been that it requires modification of the source code in order to apply patches. As of Julia 1.12 additions such as Base.delete_method provide us with new options to apply function patches in new ways. This proposal documents an approach inspired by @tz-lom's work on https://github.com/tz-lom/Mock.jl for an approach to mocking without using call-site annotations.

Apply patches via eval

We can use eval to add/override a function method. By using eval we can inject a user defined patch into a function's method table. Adding a method in this way will cause calling functions to be invalidated and call our patch. Restoring the original function method can be done by simply using Base.delete_method to remove the patch function.

Unfortunately, applying patches this way is not thread safe which can lead to some unexpected behavior when a patch is called outside of an apply block. To address this problem we can utilize a runtime check within the patched method which checks for a ScopedValue and conditionally calls the patched method when within the scope of an apply block otherwise they will call the original method.

Calling the original method

With the removal of the call-site macro @mock we need a new approach for calling the original method from within the patch itself. To do this we can create the macro @original which utilize Base.invoke_in_world which would allow us to call the original method while a patch as been applied.

One tricky part here is that since patches can be reused in different apply blocks we probably want to utilize the world age number from the start of the apply block rather than the world age from when the patch was created. The advantage of this is that interative workflows using both Mocking.jl and Revise.jl should work better. If we took the world age from point at which the patch was created then user function changes updated via Revise would not be picked up when calling @original from within a patch in an apply block.

Backwards compatibility with Base.delete_method

For removing a patch we can utilize Base.delete_method to remove the added method. As this is only available in Julia 1.12 we can need a solution which also works at least with the Julia LTS (1.10). Once again we can utilize Base.invoke_in_world to restore calling the original method:

f(x) = Base.invoke_in_world($prev_world_age, f, x)

We would generate these methods for each patch we applied. These methods would result in us calling the function using the world age from just before the apply block such that nested apply blocks should work correctly. As we leave each apply block we'll be defining a new "restore" method which will be hardcoded with the earlier world age.

When considering this approach I became a bit concerned about performance. Would it be possible multiple apply/restore iterations result in "restore" methods calling "restore" methods such that it would have a performance impact? I don't think think we'll have a problem here since as we leave each apply block the "restore" method would be calling an earlier world age such that these methods would typically call the original method directly. In scenarios where nested apply blocks have more and more specific patches then we could end up having a "restore" method calling another "restore" method with a more generic signature. However, this scenario seems rare enough that it won't be an issue.

The primary downside of this backwards compatible approach is that we end up polluting the method table with additional methods if a patch uses a different argument types than what was defined in the original function. This isn't a big problem as Mocking.jl is expected to be use primarily for testing purposes. There could be some packages which are testing results from methods which could see test failures. In those scenarios the package maintainers would just need to be careful to only create patches which match the signatures of the original methods.

Backwards compatibility with Base.invoke_in_world

We plan on using Base.invoke_in_world in the @original macro as well as for some backwards compatibility when Base.delete_method isn't available. This function has been available since Julia 1.6 which allows us to at least support the Julia LTS (1.10).

Backwards compatibility with ScopedValue

We are already using ScopedValues in Mocking.jl but it's still worth mentioning this requirement here as this feature requires Julia 1.11 and we now rely on it in different way.

Previously, when we didn't have ScopedValue support we ended up falling back on a global value that wasn't thread-safe. This global was used by the @mock at the call site to determine whether to call the patch or the original function. With the new approach we have the same thread-safety limitations.

Repercussions

In looking into applying this proposal I suspect the following will also occur as part of these changes:

  • Removal of @mock: If there isn't any performance downside to this approach then we can remove this
  • Removal of Mocking.activate: Was used to trigger invalidation at @mock call-sites
  • Removal of Mocking.nullify: Only was needed for @mock call-sites
  • Removal of Mocking.dispatch: Used for performing dispatch as if two functions had the same method table. Shouldn't be necessary as we can no just use Julia's method dispatch with world age.
  • Nesting patch environments will no longer require them to be merged: We should be able to extend a function and rely on world age to temporarily apply patches
  • @patch will need to construct the function which will be added (eval'd) later: The user will define there patch like they did before but we'll also create an function definition expression which perform the runtime check which determines if the patch or the original function is called. This function cannot use args... since this would impact method dispatch so we'll need to extract the actual args names when we call the original/patch functions. Probably want to add additional tests for this as I can foresee scenarios we pass in too many args into the original function.

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