Skip to content

Commit

Permalink
Add support for marshaling IDisposable objects over RPC
Browse files Browse the repository at this point in the history
  • Loading branch information
AArnott committed Aug 22, 2020
1 parent 892ba14 commit 326744f
Show file tree
Hide file tree
Showing 23 changed files with 1,613 additions and 102 deletions.
158 changes: 158 additions & 0 deletions doc/disposable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# `IDisposable` support

StreamJsonRpc allows marshaling `IDisposable` objects in arguments and return values.

An `IDisposable` object may also implement `INotifyDisposable` on the sender side
so the sender can terminate the lifetime of the marshalled object to release resources.

## Use cases

In all cases, the special handling of an `IDisposable` value only occurs if the container of that value is typed as `IDisposable`.
This means that an object that implements `IDisposable` will not necessarily be marshaled instead of serialized.
Consider each of the below use cases to see how the value can be *typed* as `IDisposable`.

For each use case, assume `DisposeAction` is a class defined for demonstration purposes like this:

```cs
class DisposeAction : IDisposable
{
private readonly Action disposeAction;

internal DisposeAction(Action disposeAction)
{
this.disposeAction = disposeAction;
}

public void Dispose() => this.disposeAction();
}
```

### Method return value

In the simplest case, the RPC server returns an `IDisposable` object that gets disposed on the server
when the client disposes it.

```cs
class RpcServer
{
public IDisposable GetDisposable() => new DisposeAction(() => { /* The client disposed us */ });
}
```

### Method argument

In this use case the RPC *client* provides the `IDisposable` value to the server:

```cs
interface IRpcContract
{
Task ProvideDisposableAsync(IDisposable value);
}

IRpcContract client = jsonRpc.Attach<IRpcContract>();
IDisposable arg = new DisposeAction(() => { /* the RPC server called Dispose() on the argument */});
await client.ProvideDisposableAsync(arg);
```

### Value within a single argument's object graph

In this use case the RPC client again provides the `IDisposable` value to the server,
but this time it passes it as a property of an object used as the argument.

```cs
class SomeClass
{
public IDisposable DisposableValue { get; set; }
}

interface IRpcContract
{
Task ProvideClassAsync(SomeClass value);
}

IRpcContract client = jsonRpc.Attach<IRpcContract>();
var arg = new SomeClass
{
DisposableValue = new DisposeAction(() => { /* the RPC server called Dispose() on the argument */}),
};
await client.ProvideClassAsync(arg);
```

While this use case is supported, be very wary of this pattern because it becomes less obvious to the receiver that an `IDisposable` value is tucked into the object tree of an argument somewhere that *must* be disposed to avoid a resource leak.

### As an argument without a proxy for an RPC interface

When you are not using an RPC interface and dynamically generated proxy that implements it, you can still pass a marshaled `IDisposable` value as an argument by explicitly passing in the declared parameter types to the `InvokeWithCancellationAsync` call:

```cs
IDisposable arg = new DisposeAction(() => { /* the RPC server called Dispose() on the argument */});
await jsonRpc.InvokeWithCancellationAsync(
"methodName",
new object?[] { arg },
new Type[] { typeof(IDisposable) },
cancellationToken);
```

### Invalid cases

Here are some examples of where an object that implements `IDisposable` is serialized (i.e. by value) instead of being marshaled (i.e. by reference).

In this example, although `Data` implements `IDisposable`, its declared parameter type is `Data`:

```cs
class Data : IDisposable { /* ... */ }

IDisposable arg = new Data();
await jsonRpc.InvokeWithCancellationAsync(
"methodName",
new object?[] { arg },
new Type[] { typeof(Data) },
cancellationToken);
```

Here is the same situation with an RPC interface:

```cs
class Data : IDisposable { /* ... */ }

interface IRpcContract
{
Task ProvideDisposableAsync(Data value);
}

IRpcContract client = jsonRpc.Attach<IRpcContract>();
Data arg = new Data();
await client.ProvideDisposableAsync(arg);
```

Or similar for an object returned from an RPC server method:

```cs
class Data : IDisposable { /* ... */ }

class RpcServer
{
public Data GetDisposableData() => new Data();
}
```

In each of these cases, the receiving part will get a `Data` object that implements `IDisposable`, but calling `Dispose` on that object will be a local call to that object rather than being remoted back to the original object.

## Resource leaks concerns

When an `IDisposable` instance is sent over RPC, resources are held by both parties to marshal interactions
with that object.

These resources are released when any of these occur:

1. The receiver calls `IDisposable.Dispose()` on the object.
1. The JSON-RPC connection is closed.

To enable the sender to terminate the connection with the proxy to release resources, the sender should call `JsonRpc.MarshalWithControlledLifetime<T>` to wrap the `IDisposable` before sending it.

## Protocol

The protocol for proxying a disposable object is based on [general marshaled objects](general_marshaled_objects.md) with:

1. A camel-cased method name transform applied.
1. Lifetime of the proxy controlled by the receiver.
2 changes: 2 additions & 0 deletions doc/exotic_types.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Some types are not serializable but are specially recognized and marshaled by St
* [`IProgress<T>`](progresssupport.md)
* [`Stream`, `IDuplexPipe`, `PipeReader`, `PipeWriter`](oob_streams.md)
* [`IAsyncEnumerable<T>`](asyncenumerable.md)
* [`IDisposable`](disposable.md)
* [General marshalable object support](general_marshaled_objects.md)

The `CancellationToken` support is built into the `JsonRpc` class itself so that it works in any configuration, provided the remote side also supports it.

Expand Down
137 changes: 137 additions & 0 deletions doc/general_marshaled_objects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# General marshaled objects

Passing objects over RPC by value is the default and preferred means of exchanging information.
Objects passed by value have no relationship between sender and receiver.
Any methods defined on the object execute locally in the process on which they were executed and do not result in an RPC call.
Objects passed by value are *serialized* by the sender with all their member data and deserialized by the receiver.

When objects do not have a serialized representation (e.g. they contain no data) but do expose methods that should be invokable by a remote party in an RPC connection, that object may be _marshaled_ instead of serialized.
When an object is marshaled, a handle to that object is transmitted that allows the receiver to direct RPC calls to that particular object.
A marshaled object's lifetime is connected between sender and receiver, and will only be released when either sender or receiver dispose the handle or the overall RPC connection.

To the receiver, a marshaled object is represented by an instance of the marshaled interface.
The implementation of that interface is an RPC proxy that is *very* similar to ordinary [dynamic proxies](dynamicproxy.md) that may be generated for the primary RPC connection.
This interface has the same restrictions as documented for these dynamic proxies.

**CONSIDER**: Will interfaces with events defined on them behave as expected, or should we disallow events on marshaled interfaces?

Marshaled objects may _not_ be sent in arguments passed in a notification.
An attempt to do so will result in an exception being thrown at the client instead of transmitting the message.
This is because notification senders have no guarantee the server accepted and processed the message successfully, making lifetime control of the marshaled object impossible.

## Use cases

When preparing an object to be marshaled, only methods defined on the given interface are exposed for invocation by RPC.
Other methods on the target object cannot be invoked.

Every marshaled object's proxy implements `IDisposable`.
Invoking `IDisposable.Dispose` on a proxy transmits a `dispose` RPC notification to the target object and releases the proxy.

See [additional use cases being considered](general_marshaled_objects_2.md) for general marshalling support.

## Protocol

Any marshaled object is encoded as a JSON object with the following properties:

- `__jsonrpc_marshaled` - required
- `handle` - required
- `lifetime` - optional

The `__jsonrpc_marshaled` property is a number that may be one of the following values:

Value | Explanation
--|--
`1` | Used when a real object is being marshaled. The receiver should generate a new proxy that directs all RPC requests back to the sender referencing the value of the `handle` property.
`0` | Used when a marshaled proxy is being sent *back* to its owner. The owner uses the `handle` property to look up the original object and use it as the provided value.

The `handle` property is a signed 64-bit number.
It SHOULD be unique within the scope and duration of the entire JSON-RPC connection.
A single object is assigned a new handle each time it gets marshaled and each handle's lifetime is distinct.

The `lifetime` property is a string that MAY be included and set to one of the following values:

Value | Explanation
--|--
`"call"` | The marshaled object may only be invoked until the containing RPC call completes. This value is only allowed when used within a JSON-RPC argument. No explicit release using `$/releaseMarshaledObject` is required.
`"explicit"` | The marshaled object may be invoked until `$/releaseMarshaledObject` releases it. **This is the default behavior when the `lifetime` property is omitted.**

### Marshaling an object

Consider this example where `SomeMethod(int a, ISomething b, int c)` is invoked with a marshaled object for the second parameter:

```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "SomeMethod",
"params": [
1,
{ "__jsonrpc_marshaled": 1, "handle": 5 },
3,
]
}
```

If the RPC server returns a JSON-RPC error response (for any reason), all objects marshalled in the arguments of the request are released immediately to mitigate memory leaks since the server cannot be expected to have recognized the arguments as requiring a special release notification to be sent from the server back to the client.

### Invoking a method on a marshaled object

The receiver of the above request can invoke `DoSomething` on the marshaled `ISomething` object with a request such as this:

```json
{
"jsonrpc": "2.0",
"id": 15,
"method": "$/invokeProxy/5/DoSomething",
"params": []
}
```

### Referencing a marshaled object

The receiver of the marshaled object may also "reference" the marshaled object by using `__jsonrpc_marshaled: 0`,
as in this example:

```json
{
"jsonrpc": "2.0",
"id": 16,
"method": "RememberWhenYouSentMe",
"params": [
1,
{ "__jsonrpc_marshaled": 0, "handle": 5 },
3,
]
}
```

### Releasing marshaled objects

Either side may terminate the marshalled connection in order to release resources by sending a notification to the `$/releaseMarshaledObject` method.
The `handle` named parameter (or first positional parameter) is set to the handle of the marshaled object to be released.
The `ownedBySender` named parameter (or second positional parameter) is set to a boolean value indicating whether the party sending this notification is also the party that sent the marshaled object.

```json
{
"jsonrpc": 20,
"method": "$/releaseMarshaledObject",
"params": {
"handle": 5,
"ownedBySender": true,
}
}
```

One or both parties may send the above notification.
If the notification is received after having sent one relating to the same handle, the notification may be discarded.

### Referencing of marshaled objects after release

When an RPC _request_ references a released marshaled object, the RPC server SHOULD respond with a JSON-RPC error message containing `error.code = -32001`.

When an RPC _result_ references a released marshaled object, the RPC client library MAY throw an exception to its local caller.

### No nesting of marshaled object lifetimes

Although object B may be marshaled in RPC messages that target marshaled object B, marshaled object B's lifetime is _not_ nested within A's.
Each marshaled object must be indepenently released.
87 changes: 87 additions & 0 deletions doc/general_marshaled_objects_2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Spec proposal: General marshaled objects

## Use cases

**The following content would belong to general_marshaled_objects.md, but StreamJsonRpc does not yet support these use cases.**

### Marshaling an object in an RPC argument with a lifetime scoped to the method call

Marshaled objects passed in arguments in _requests_ (not notifications) may be scoped to live only as long as that method call.
This is the simplest model and requires no special handling to avoid resource leaks.

To marshal an object instead of serialize its value, wrap the object with a marshalable wrapper using the
`T JsonRpc.MarshalLimitedArgument<T>(T marshaledObject)` static method.

```cs
ISomethingInteresting anObject;
await jsonRpc.InvokeAsync(
"DoSomething",
new object[]
{
1,
JsonRpc.MarshalLimitedArgument(anObject),
3,
}
)
```

The above will allow methods defined on the `ISomethingInteresting` interface to be invoked on `anObject` by the RPC server's `DoSomething` method.
After `DoSomething` returns, `anObject` can no longer be invoked by the RPC server.
No explicit release is required.

### Marshaling an object with an independently controlled lifetime

Marshaled objects passed in return values or in arguments where they must remain marshaled beyond the scope of the RPC request can do so.
This mode requires that the sender and/or receiver explicitly release the marshaled object from its RPC connection in order to reclaim resources.

Use the `IRpcMarshaledContext<T> JsonRpc.MarshalWithControlledLifetime<T>(T marshaledObject)` static method prepare an object to be marshaled.
The resulting `IRpcMarshaledContext<T>` contains both the object to transmit and a method the sender may use to later terminate the marshaling relationship.

In the following example, an `IDisposable` object is marshaled via a return value from an RPC method.
Notice how the server holds onto the `IRpcMarshaledContext<T>` object so it can release the marshaled object when the subscription is over.

```cs
class RpcServer
{
private readonly Dictionary<Subscription, IRpcMarshaledContext<IDisposable>> subscriptions = new Dictionary<Subscription, IRpcMarshaledContext<IDisposable>>();

public IDisposable Subscribe()
{
var subscription = new Subscription();
var marshaledContext = JsonRpc.MarshalWithControlledLifetime<IDisposable>(subscription);
lock (this.subscriptions)
{
this.subscriptions.Add(subscription, marshaledContext);
}

return marshaledContext.Proxy;
}

// Invoked by the RpcServer when the subscription is over and can be closed.
private void OnSubscriptionCompleted(Subscription subscription)
{
IRpcMarshaledContext<IDisposable> marshaledContext;
lock (this.subscriptions)
{
marshaledContext = this.subscriptions[subscription]
this.subscription.Remove(subscription);
}

marshaledContext.Dispose();
}
}
```

When an object that would otherwise be marshaled is returned from an RPC server method that was invoked by a notification, no marshaling occurs and no error is raised anywhere.

### Sending a marshaled object's proxy back to its owner

The receiver of a marshaled object *may* send the proxy they receive back to its originator.
This may be done within an argument or return value.
When this is done, the receiver of that message (i.e. the original marshaled object owner) will receive a reference to the original marshaled object.

### Invoking or referencing a discarded marshaled object

When a method on a marshaled proxy is invoked or the proxy is referenced in an argument of an outbound RPC request *after* it has been disposed of, an `ObjectDisposedException` is thrown.

When the local proxy has not been disposed of but the marshaled object owner has released the target object, a `RemoteInvocationException` may be thrown with the `ErrorCode` property set to `JsonRpcErrorCode.NoMarshaledObjectFound`.
Loading

0 comments on commit 326744f

Please sign in to comment.