How can I close a Web Serial port that I've piped through a TransformStream?
Asked Answered
S

2

6

I have a Web Serial port that I want to read some data from. I'd like to use TransformStreams to do some processing (e.g. decode bytes to strings, separate out logical messages, etc) by using pipeThrough to transform the data. However, once I do this, I can no longer release the lock on the port by calling reader.releaseLock(). What am I doing wrong here?

This code works how I expect (running without dependencies in the browser in a secure context):

async serialTestWorking() {
  const port = await navigator.serial.requestPort();

  await port.open({baudRate: 9600});
  console.log("Is locked after open?", port.readable.locked);
  // Prints "Is locked after open? false"

  let reader = port.readable.getReader();
  console.log("Is locked after getReader?", port.readable.locked);
  // Prints "Is locked after getReader? true"

  reader.releaseLock();
  console.log("Is locked after releaseLock?", port.readable.locked);
  // Prints "Is locked after releaseLock? false"

  await port.close();
  console.log("Port closed");
  // Prints "Port closed"
}

However, if I use pipeThrough to send the output through a do-nothing TransformStream, it all falls apart. The lock isn't released at releaseLock and the final close fails to work.

async serialTestBroken() {
  const port = await navigator.serial.requestPort();

  await port.open({baudRate: 9600});
  console.log("Is locked after open?", port.readable.locked);
  // Prints "Is locked after open? false"

  let reader = port.readable.pipeThrough(new TransformStream()).getReader();
  console.log("Is locked after getReader?", port.readable.locked);
  // Prints "Is locked after getReader? true"

  reader.releaseLock();
  console.log("Is locked after releaseLock?", port.readable.locked);
  // Prints "Is locked after releaseLock? true"

  await port.close();
  console.log("Port closed");
  // Doesn't make it to the log line
  //throws "TypeError: Failed to execute 'close' on 'SerialPort': Cannot cancel a locked stream"
}

What am I doing wrong here? Does releasing the lock on the TransformStream really not propagate upstream? Do I have to keep track of an instance of every transformer in my pipeline so I can be sure to unlock them all?

The streams spec says that piping locks the readable and writable streams for the duration of the pipe operation.

Piping locks the readable and writable streams, preventing them from being manipulated for the duration of the pipe operation. This allows the implementation to perform important optimizations, such as directly shuttling data from the underlying source to the underlying sink while bypassing many of the intermediate queues.

Is there some other way I have to indicate that the "piping operation" is completed?

Stipendiary answered 25/2, 2022 at 7:28 Comment(2)
Does web.dev/serial/#transforming-streams help?Metabolize
It helps a ton. Thanks!Stipendiary
A
9

As explained in https://web.dev/serial/#close-port, port.close() closes the serial port if its readable and writable members are unlocked, meaning releaseLock() has been called for their respective reader and writer.

Closing a serial port is a bit more complicated when using transform streams though. Call reader.cancel(), then call writer.close() and port.close(). This propagates errors through the transform streams to the underlying serial port. Because error propagation doesn't happen immediately, you need to use the readableStreamClosed and writableStreamClosed promises created earlier to detect when port.readable and port.writable have been unlocked. Cancelling the reader causes the stream to be aborted; this is why you must catch and ignore the resulting error.

const textDecoder = new TextDecoderStream();
const readableStreamClosed = port.readable.pipeTo(textDecoder.writable);
const reader = textDecoder.readable.getReader();

// Listen to data coming from the serial device.
while (true) {
  const { value, done } = await reader.read();
  if (done) {
    reader.releaseLock();
    break;
  }
  // value is a string.
  console.log(value);
}
// Later...

const textEncoder = new TextEncoderStream();
const writer = textEncoder.writable.getWriter();
const writableStreamClosed = textEncoder.readable.pipeTo(port.writable);

reader.cancel();
await readableStreamClosed.catch(() => { /* Ignore the error */ });

writer.close();
await writableStreamClosed;

await port.close();
Ancilla answered 25/2, 2022 at 8:35 Comment(3)
I'm a little confused -- where does writer get defined?Stipendiary
I've added const writer = textEncoder.writable.getWriter(); to the example above. Sorry for that!Metabolize
Wanted to let you know that this absolutely worked! Thanks! As a brief follow-up, does this mean that there's no way to safely use pipeThrough because it doesn't give a promise that you can use to check and see if the error is done propagating?Stipendiary
G
0

To whom it might concern, I've also found this solution which worked until the point it didn't anymore. The await thing.catch(Object) as no-op was spot-on (and awkward at the same time) but in general, after 1 hour messing around I've ust came to the conclusion that the easiest way to close a port and free everything is by passing an AbortController.signal.

The pipeTo indeed accepts a second options parameter which in turns accepts a signal property so that this is what I ended up doing and finally freed properly and closed the port:

const writable = new WritableStream({ ... });

// pass { signal: aborter.signal } or just aborter
const aborter = new AbortController;
const readerClosed = port.readable.pipeTo(writable, aborter);

// later on ...
aborter.abort(); // that's it ...
// the rest of the dance
writer.close();
await writerClosed;
await readerClosed.catch(Object);
await port.close();

I hope this hint will help those that went as nuts as I did this morning.

Gazette answered 9/5, 2024 at 10:14 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.