New serializer for child-to-parent JS actor messages

43 views
Skip to first unread message

Andrew McCreight

unread,
Dec 18, 2025, 3:37:00 PM (13 days ago) Dec 18
to Firefox Dev

# Introduction


As part of my long-running project to enable type checking of child-to-parent JS actor messages in bug 1885221, I have enabled a new IPDL-based serializer for the JS values being sent over IPC, in bug 1999397. Mostly this should behave the same, but in some corner cases the behavior is different. In this email, I'll try to lay out the behavioral differences. It is also possible that this may cause performance issues. The new serializer is enabled via the pref dom.jsipc.send_typed, so it should be easy to revert to the old behavior to check if it is causing a problem you see. Let me know if you encounter problems.


# When is this used?


The new serializer does not apply to all JS actor messages, to reduce compatibility risks. It is only used for JS actor messages sent from a child process to the parent process, not from the parent to the child or within a single process. It also, for various compatibility reasons, does not apply to any messages from the Conduits, ProcessConduits, SpecialPowers or MarionetteCommands actors. This set of actors is defined in the C++ function JSActorSupportsTypedSend.


# General differences


Instead of using structuredClone to send messages, it uses a new IPDL type, JSIPCValue. There's a comment at the top of JSIPCValue.ipdlh describing some of the differences, but here is a quote from there giving some of them:

1. Cyclic data structures can't be serialized.

2. DAGs won't be preserved.

2. Non-indexed properties on Arrays will be dropped.

3. Any holes in an Array will be filled with undefined.


No existing code seemed to depend on these things, but not preserving DAGs might cause issues that don’t show up in tests.


# Current fallback behavior for non-structured-clonable values for sendAsyncMessage


A more impactful difference in behavior comes into play with JS values that contain values that can't be structured cloned, like functions. This only applies to values sent using sendAsyncMessage, not query replies or resolved reply promises. This oddity dates back to the Boot2Gecko era of Firefox. The basic idea is that if you try to send a value over JS IPC that contains something that isn't structured clonable, then the unserializable part should be dropped, rather than throwing an error. This sounds like a reasonable idea, but the actual implementation can have some peculiar behavior.


JS values to be sent over IPC via sendAsyncMessage are serialized via the C++ method nsFrameMessageManager::GetParamsForMessage(). This first tries to do a structuredClone. If that fails, it calls JSON.stringify() on the value, then calls JSON.parse() on the resulting string. Problems may arise because JSON doesn't represent the same subset of JS as structuredClone. Functions aren’t serializable and aren’t representable in JSON, which is fine, but undefined and NaN are serializable but aren’t representable in JSON.


In other words, the JS equivalent of GetParamsForMessage() would be this:


function getParamsForMessage(v) {

  try {

    return structuredClone(v);

  } catch (e) {

    let vString = JSON.stringify(v);

    if (vString == undefined) {

      throw new Error("not valid JSON");

    }

    return structuredClone(JSON.parse(vString));

  }

}


This is taken from the test browser_jsat_serialize.js I added.


# New fallback behavior


The new IPDL based serializer can't handle all of JS either. It can't even handle everything that structuredClone can handle. However, when it encounters a subvalue that it can't deal with, it falls back to structuredClone for that subvalue. If the structuredClone succeeds, everything is fine. However, if structuredClone fails, then that subvalue is replaced with undefined. If the subvalue that can’t be cloned is a property value, the property is instead dropped. (This “fallback for the fallback” behavior only kicks in for sendAsyncMessage. In the other cases mentioned above, the serialization will fail if the structuredClone fallback fails.)


I believe that this new behavior is better than the old behavior, but it is different, and at least a few places inadvertently depended on it. (This behavior is also exposed to WebExtensions in a few places, which is why we’re currently not using it for the Conduits actors yet: see bug 1960449.


Next I will give a few examples taken from browser_jsat_serialize.js to compare the old and new fallback behaviors.


# Array example


If we have an array, array1, that is [undefined, NaN, -1], the result of serialization is an equivalent array, whether you use the old way or the new way to serialize. Great.


Now imagine that you have a new array, array2, that is [undefined, NaN, -1, () => true]. This is the same as array1 except that it has a non-structured-clonable function value added as a new element to the end. The entire value is now no longer structured clonable, so the old serializer uses the JSON fallback.


With getParamsForMessage(), array2 serializes to [null, null, -1, null]. What? The function at the end turned into null, which maybe is a little weird but fine. However, undefined and NaN also turn into null, because they can't be represented in JSON either. Not great!


With the new IPDL serializer, array2 serializes to [undefined, NaN, -1, undefined]. The function at the end turned into undefined, but everything else was left alone.


# Object example


Now I’ll go through an example that is very similar, except that it involves an object instead of an array. At a high level, the main difference is that properties with unserializable values get dropped.


If we have an object, obj1, that is { x: undefined, y: NaN }, the result of serialization is an equivalent object, whether you use the old way or the new way to serialize. Great.


Now imagine that you have a new object, obj2, that is { x: undefined, y: NaN, z: () => true }. This is the same as obj1, except we’ve added a new property z with an unserializable value.


With getParamsForMessage(), obj2 serializes to {y: null}. Both x and z are dropped, while the NaN property is changed to null. You’ll notice that in the array case undefined and NaN were treated the same way, but in an object they are treated differently, due to the behavior of JSON.stringify(). I suppose that’s fine, because a property with the value undefined doesn’t make much sense in the first place.


With the new IPDL serializer, obj2 serializes to { x: undefined, y: NaN }. In other words, x and y are preserved, but z is dropped, so we end up with something equivalent to obj1.


# Conclusion


Like I said, if you see some issues with the new serializer or have some trouble figuring out what is going wrong, let me know. I’ve filed bug 1898068 about fixing places that rely on the JSON fallback, but I haven’t spent too much time on it because I didn’t want to fall down too many rabbit holes. I could probably dig up the logging I created to track down these issues if somebody wants to look into it. The basic idea is to not send functions or other non-structuredClonable objects over IPC.


- Andrew


Reply all
Reply to author
Forward
0 new messages