Skip to content
This repository has been archived by the owner on Jul 12, 2024. It is now read-only.

Factor out ArrayBuffer.prototype.transfer algorithm for use by other specs? #3

Closed
domenic opened this issue Nov 22, 2022 · 12 comments
Closed

Comments

@domenic
Copy link
Member

domenic commented Nov 22, 2022

We have a couple of specs on the web platform that want to transfer ArrayBuffers:

(whatwg/streams#1224 contains some discussion of having Streams defer to Web IDL.)

It was raised in whatwg/streams#1248 that probably when we transfer ArrayBuffers on the web platform, we should preserve their resizability. And apparently this spec has an algorithm which does that.

Could this spec factor that algorithm out into an abstract operation, which web platform specs could call?

@MattiasBuelens
Copy link

MattiasBuelens commented Nov 22, 2022

Also add https://html.spec.whatwg.org/multipage/structured-data.html to that list. We'll want this to preserve resizability:

const arrayBuffer = new ArrayBuffer(1024, { maxByteLength: 2048 });
const transferredArrayBuffer = structuredClone(arrayBuffer, { transfer: [arrayBuffer] });
console.log(transferredArrayBuffer.resizable === true);

when we transfer ArrayBuffers on the web platform, we should preserve their resizability. And apparently this spec has an algorithm which does that.

Looks like I misread the explainer. 😅 As currently written, transfer() always returns a non-resizable ArrayBuffer.

Should we change transfer() to preserve resizability? Or should there be a different method (or option for transfer()) which does that? I suppose this answers one of the open questions on this proposal:

Should there be a transferResizable() that reallocs into another resizable ArrayBuffer?

Are there compelling use cases for this?

So yes, there are compelling use cases! 😁

  • Readable byte streams use transferring to take ownership of a ArrayBuffer while doing a BYOB read, and then returns ownership back to the user once it's filled the buffer. If the input buffer is resizable, we'll want to preserve that property in the returned buffer.
  • Transferring a resizable ArrayBuffer between realms (through postMessage()) should also preserve resizability. One possible use case could be making readable byte streams transferable, allowing the BYOB request to be transferred and filled by another realm. (At the moment, transferring a readable byte stream results in a "default" readable stream without BYOB support, but we should be able to extend the Streams spec to allow for BYOB.)

@syg
Copy link
Collaborator

syg commented Nov 22, 2022

There are two topics here: factoring out the transfer AO and "should transfer preserve resizability".

Re: factoring out the transfer AO, sounds like a good idea. Sounds like AO need the "preserve resizability" functionality, which the API currently doesn't do. And does it need the ability to create the new ArrayBuffer in a different realm?

Re: transfer preserving the resizability in the API, thanks for the use cases. Adding a resizability-preserving transfer is best done in a follow-up proposal as this proposal has been Stage 3 for a while.

A natural extension is to make the parameters mirror the constructor, i.e. transfer(newLen, { maxByteLength: newMaxLen }).

One hiccup is that by default, if transfer() is called without arguments, it would return a fixed-length ArrayBuffer moved from the source ArrayBuffer. To transfer a resizable buffer with the same length and max byte length you'd have to type ta.transfer(ta.byteLength, { maxByteLength: ta.maxByteLength }).

The current default behavior was motivated by the intuition that uses of transfer on resizable buffers would be used to "fix" resizable buffers after you're done with them. Like, the program uses an RAB to do some computation, at some point it's done and no longer needs to the buffer to be resizable, but it still wants the contents in the buffer. It can transfer to a fixed-length buffer of the same size, and then the implementation can release the extra reserved address space.

But is that the wrong default? If the options bag is not passed, should it always preserve the resizability and pass along the current max byte length? That has other unfortunate corner cases that I think are slightly worse: if ta is resizable and has byte length L and max byte length L+1, calling ta.transfer(L+2) would throw if we pass along the current max byte length.

Addressing the direct use cases:

Readable byte streams use transferring to take ownership of a ArrayBuffer while doing a BYOB read, and then returns ownership back to the user once it's filled the buffer. If the input buffer is resizable, we'll want to preserve that property in the returned buffer.

That approach allocates new ArrayBuffers for each BYOB read. Is that desirable? If the API isn't set in stone already, with resizable buffers, you can do this buffer locking by doing the following (which can reasonably be considered an abuse of the API, but is allowed by the spec):

  1. When the stream takes ownership, resize the ArrayBuffer to length 0 and flip a bit on it in the implementation such that calls to resize() would fail until the bit is unflipped. The spec allows hosts to intercept resizes (it's needed for WebAssembly).
  2. When the stream finishes writing into it, unflip the bit and resize back to the original size, making it usable by userland again.

Transferring a resizable ArrayBuffer between realms (through postMessage()) should also preserve resizability. One possible use case could be making readable byte streams transferable, allowing the BYOB request to be transferred and filled by another realm. (At the moment, transferring a readable byte stream results in a "default" readable stream without BYOB support, but we should be able to extend the Streams spec to allow for BYOB.)

Yes, I need to write the structured clone PR. But this particular use of transfer doesn't have realloc semantics, right? You can't say, "transfer, with this new size".

@syg
Copy link
Collaborator

syg commented Nov 22, 2022

cc @phoddie: Any opinions on transfer() preserving resizability by default?

Edit: Actually, upon re-reading what I wrote above, we can't fill in the max byte length by default when the options bag isn't present. That means there's no way to use transfer to "fix" resizable buffers: if not passing an options bag means preserving resizability, you will always end up transferring to a resizable buffer.

So I think that means we'd have to live with typing ta.transfer(ta.byteLength, { maxByteLength: ta.maxByteLength }) for transferring resizable buffers "as is". Maybe that can be aliased in the follow-on proposal as a 0-ary move() or transferAsIs() or something?

@MattiasBuelens
Copy link

That approach allocates new ArrayBuffers for each BYOB read. Is that desirable?

Yes, the API is a bit finicky, since we have to teach users to replace their "reusable" buffer after each call to read(view). See the note below this example.

But I think it's unavoidable. If the readable byte stream is implemented in userland (through new ReadableStream({ type: 'bytes' })), then the ownership of the buffer passed to the BYOB reader needs to be transferred to the BYOB request's buffer, so the userland byte source can fill it. When the source is done filling it, it passes ownership back to the stream, which finally transfers it back to the BYOB reader.

With the "flip a bit" approach, we would be able to prevent modifications to the BYOB reader's input buffer, but we wouldn't be able to describe how the BYOB request's buffer is constructed from that input buffer.

Also, readable byte streams are already in Chrome and Firefox, so I'm afraid that part of the API is already set in stone. 😅

Yes, I need to write the structured clone PR. But this particular use of transfer doesn't have realloc semantics, right? You can't say, "transfer, with this new size".

Correct, the size should stay the same during structured cloning.

Note that even for the readable byte stream use case, we're not actually interested in resizing the buffer anywhere in the Streams specification. We just want to preserve resizability.

Hmm, now I think about it: should a userland byte source be able to resize the BYOB request's buffer? With the current semantics, there's no need, because the byte source cannot respond with a view that's larger than the original request. But what if (for whatever reason) it did try to call byobRequest.view.buffer.resize()? 🤔

@phoddie
Copy link

phoddie commented Nov 22, 2022

cc @phoddie: Any opinions on transfer() preserving resizability by default?

Preserving resizability by default seems like it would match developer expectations. It also doesn't look too bad to implement in XS.

@syg
Copy link
Collaborator

syg commented Nov 22, 2022

Preserving resizability by default seems like it would match developer expectations. It also doesn't look too bad to implement in XS.

Sorry, please see my edit above. After I asked this I realized there's no way to transfer to a fixed-length buffer if the arguments were to mirror the constructor and use an options bag. I'll bring this up as a discussion item at the next plenary.

@syg
Copy link
Collaborator

syg commented Nov 23, 2022

I've split the API question into #4, let's keep discussion in this issue scoped to the AO refactoring @domenic originally brought up.

@syg

This comment was marked as resolved.

@ljharb ljharb transferred this issue from tc39/proposal-resizablearraybuffer Dec 3, 2022
@ljharb

This comment was marked as resolved.

@syg
Copy link
Collaborator

syg commented Jan 11, 2023

@domenic How does ArrayBufferCopyAndDetach look for your use cases?

@domenic
Copy link
Member Author

domenic commented Jan 11, 2023

Looks perfect, thanks so much!

@syg
Copy link
Collaborator

syg commented Jan 27, 2023

Closing issue as resolved.

@syg syg closed this as completed Jan 27, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants