current commit
261015.15 · stream: share the once option for stream/iter abort listeners
'use strict';

// New Streams API - Duplex Channel
//
// Creates a pair of connected channels where data written to one
// channel's writer appears in the other channel's readable.

const {
  SafePromiseAllReturnVoid,
  SymbolAsyncDispose,
  SymbolAsyncIterator,
} = primordials;

const {
  isConsumerReturnError,
  push,
} = require('internal/streams/iter/push');
const {
  converters,
} = require('internal/streams/iter/webidl');
const { kNullOnceOption } = require('internal/streams/iter/utils');

/**
 * Create a pair of connected duplex channels for bidirectional communication.
 * @param {{ budget?: number, backpressure?: string, signal?: AbortSignal,
 *           a?: object, b?: object }} [options]
 * @returns {[DuplexChannel, DuplexChannel]}
 */
function duplex(options = { __proto__: null }) {
  options = converters.DuplexOptions(options, {
    __proto__: null,
    context: 'options',
  });
  const { budget, backpressure, signal, a, b } = options;

  // Channel A writes to B's readable (A->B direction).
  // Signal is NOT passed to push() -- we handle abort via close() below.
  const { writer: aWriter, readable: bReadable } = push({
    budget: a?.budget ?? budget,
    backpressure: a?.backpressure ?? backpressure,
  });

  // Channel B writes to A's readable (B->A direction)
  const { writer: bWriter, readable: aReadable } = push({
    budget: b?.budget ?? budget,
    backpressure: b?.backpressure ?? backpressure,
  });

  let cleanupSignal;
  const onClose = () => cleanupSignal?.();
  const channelA = createDuplexChannel(aWriter, aReadable, onClose);
  const channelB = createDuplexChannel(bWriter, bReadable, onClose);

  // Signal handler: fail both writers with the abort reason so consumers
  // see the error. This is an error-path shutdown, not a clean close.
  if (signal) {
    const abortBoth = () => {
      const reason = signal.reason;
      aWriter.fail(reason);
      bWriter.fail(reason);
      cleanupSignal?.();
    };
    cleanupSignal = () => {
      signal.removeEventListener('abort', abortBoth);
      cleanupSignal = undefined;
    };
    if (signal.aborted) {
      abortBoth();
    } else {
      signal.addEventListener('abort', abortBoth, kNullOnceOption);
    }
  }

  return [channelA, channelB];
}

function createDuplexChannel(writer, readable, onClose) {
  // A push readable has one shared consumer state. Keeping an iterator from
  // creation lets close() terminate that state even if no caller has iterated.
  const closeIterator = readable[SymbolAsyncIterator]();
  let closePromise;

  return {
    __proto__: null,
    get writer() { return writer; },
    get readable() { return readable; },
    close() {
      closePromise ??= closeDuplexChannel(writer, closeIterator, onClose);
      return closePromise;
    },
    [SymbolAsyncDispose]() {
      return this.close();
    },
  };
}

async function closeDuplexChannel(writer, closeIterator, onClose) {
  try {
    const result = writer.endSync();
    const endPromise = result < 0 ? writer.end() : undefined;
    const returnPromise = closeIterator.return();

    if (endPromise !== undefined) {
      try {
        await SafePromiseAllReturnVoid([endPromise, returnPromise]);
      } catch (error) {
        if (!isConsumerReturnError(error)) throw error;
      }
    } else {
      await returnPromise;
    }
  } finally {
    onClose();
  }
}

module.exports = {
  duplex,
};