···11+/** @module common/async */
22+33+/**
44+ * @typedef TimeoutSignal
55+ * @property {AbortSignal} signal the signal to pass down to blocking operations.
66+ * @property {function(): void} cleanup a function to call when the timeout is no longer necessary
77+ */
88+99+/**
1010+ * Create an abort signal tied to a timeout.
1111+ * Replaces `AbortSignal.timeout`, which doesn't consistently abort with a TimeoutError cross env.
1212+ *
1313+ * @param {number} ms - timeout in milliseconds
1414+ * @returns {TimeoutSignal} the timeout signal
1515+ */
1616+export function timeoutSignal(ms) {
1717+ const controller = new AbortController()
1818+ const timeout = setTimeout(() => {
1919+ controller.abort(new DOMException('Operation timed out', 'TimeoutError'))
2020+ }, ms)
2121+2222+ const cleanup = () => {
2323+ clearTimeout(timeout)
2424+ controller.signal.removeEventListener('abort', cleanup)
2525+ }
2626+2727+ controller.signal.addEventListener('abort', cleanup)
2828+ return { signal: controller.signal, cleanup }
2929+}
3030+3131+/**
3232+ * Create an abort signal that aborts if any of the passed signals aborts.
3333+ * Better than managing them manually because we do proper cleanup of non-triggered aborts.
3434+ *
3535+ * @param {...AbortSignal} signals - the signals to combine
3636+ * @returns {AbortSignal} the combined signal
3737+ */
3838+export function combineSignals(...signals) {
3939+ const controller = new AbortController()
4040+ /** @type { Array<function(): void> } */
4141+ const cleanups = []
4242+4343+ for (const signal of signals) {
4444+ if (signal.aborted) {
4545+ controller.abort(signal.reason)
4646+ return controller.signal
4747+ }
4848+4949+ const handler = () => {
5050+ if (!controller.signal.aborted) {
5151+ controller.abort(signal.reason)
5252+ }
5353+ }
5454+5555+ signal.addEventListener('abort', handler)
5656+ cleanups.push(() => signal.removeEventListener('abort', handler))
5757+ }
5858+5959+ controller.signal.addEventListener('abort', () => {
6060+ cleanups.forEach(cb => cb())
6161+ })
6262+6363+ return controller.signal
6464+}
+53
src/common/async/blocking-atom.js
···11+/** @module common/async */
22+33+import { Semaphore } from './semaphore.js'
44+55+/**
66+ * simple blocking atom, for waiting for a value.
77+ * cribbed mostly from {@link https://github.com/ComFreek/async-playground}
88+ *
99+ * @template T - the type we're holding
1010+ */
1111+export class BlockingAtom {
1212+1313+ /** @type {T | undefined} */
1414+ #item
1515+1616+ /** @type {Semaphore} */
1717+ #sema
1818+1919+ constructor() {
2020+ this.#sema = new Semaphore()
2121+ this.#item = undefined
2222+ }
2323+2424+ /**
2525+ * puts an item into the atom and unblocks an awaiter.
2626+ *
2727+ * @param {T} item the item to put into the atom
2828+ */
2929+ set(item) {
3030+ this.#item = item
3131+ this.#sema.free()
3232+ }
3333+3434+ /**
3535+ * tries to get the item from the atom, and blocks until available.
3636+ *
3737+ * @example
3838+ * if (await atom.take())
3939+ * console.log('got it!')
4040+ *
4141+ * @param {AbortSignal | undefined} signal - an abort signal to cancel the await
4242+ * @returns {Promise<T | undefined>} a promise for the item, or undefined if something aborted.
4343+ */
4444+ async get(signal) {
4545+ if (await this.#sema.take(signal)) {
4646+ return this.#item
4747+ }
4848+4949+ signal?.throwIfAborted()
5050+ return undefined
5151+ }
5252+5353+}
+67
src/common/async/blocking-queue.js
···11+/** @module common/async */
22+33+import { Semaphore } from './semaphore.js'
44+55+/**
66+ * simple blocking queue, for turning streams into async pulls.
77+ * cribbed mostly from {@link https://github.com/ComFreek/async-playground}
88+ *
99+ * @template T
1010+ */
1111+export class BlockingQueue {
1212+1313+ /** @type {Semaphore} */
1414+ #sema
1515+1616+ /** @type {T[]} */
1717+ #items
1818+1919+ /** @type {number | undefined} */
2020+ #maxsize
2121+2222+ constructor(maxsize = 1000) {
2323+ this.#sema = new Semaphore()
2424+ this.#items = []
2525+ this.#maxsize = maxsize ? maxsize : undefined
2626+ }
2727+2828+ /**
2929+ * place one or more items on the queue, to be picked up by awaiters.
3030+ *
3131+ * @param {...T} elements the items to place on the queue.
3232+ */
3333+ enqueue(...elements) {
3434+ for (const el of elements) {
3535+ if (this.#maxsize && this.#items.length >= this.#maxsize) {
3636+ throw Error('out of room')
3737+ }
3838+3939+ this.#items.push(el)
4040+ this.#sema.free()
4141+ }
4242+ }
4343+4444+ /**
4545+ * block while waiting for an item off the queue.
4646+ *
4747+ * @param {AbortSignal} [signal] a signal to use for aborting the block.
4848+ * @returns {Promise<T>} the item off the queue; rejects if aborted.
4949+ */
5050+ async dequeue(signal) {
5151+ if (await this.#sema.take(signal)) {
5252+ return this.#poll()
5353+ }
5454+5555+ signal?.throwIfAborted()
5656+ throw Error('canceled dequeue')
5757+ }
5858+5959+ #poll() {
6060+ const item = this.#items.length > 0 && this.#items.shift()
6161+ if (item)
6262+ return item
6363+6464+ throw Error('no elements')
6565+ }
6666+6767+}
+82
src/common/async/gate.js
···11+/** @module common/async */
22+33+/**
44+ * A Breaker, which allows creating wrapped functions which will only be executed before
55+ * the breaker is tripped.
66+ *
77+ * @example
88+ * const breaker = makeBreaker()
99+ *
1010+ * state.addEventHandler('finish', breaker.tripThen((e) => {
1111+ * // this will only be allowed to run once
1212+ * // the second time the event fired, the handler is a no-op
1313+ * })
1414+ *
1515+ * state.addEventHandler('error', breaker.tripThen((e) => {
1616+ * // all wrapped functions created by the same breaker share state
1717+ * // so if the above fired, this can never be called
1818+ * })
1919+ *
2020+ * state.addEventHandler('message', breaker.untilTripped((e) => {
2121+ * // this will only be allowed to run many times
2222+ * // but not *after* any of the _once_ wrappers has been called
2323+ * })
2424+ */
2525+export class Breaker {
2626+2727+ /** @type {undefined | VoidCallback} */
2828+ #onTripped
2929+3030+ /** @type {boolean} */
3131+ #tripped
3232+3333+ /**
3434+ * @param {VoidCallback} [onTripped]
3535+ * an optional callback, called when the breaker is tripped, /before/ any wrapped functions.
3636+ */
3737+ constructor(onTripped) {
3838+ this.#tripped = false
3939+ this.#onTripped = onTripped
4040+ }
4141+4242+ /** @returns {boolean} true if the breaker has already tripped */
4343+ tripped() {
4444+ return this.#tripped
4545+ }
4646+4747+ /**
4848+ * wrap the given callback in a function that will trip the breaker before it's called.
4949+ * any subsequent calls to the wrapped function will be no-ops.
5050+ *
5151+ * @param {Callback} fn the function to be wrapped in the breaker
5252+ * @returns {Callback} a wrapped function, controlled by the breaker
5353+ */
5454+ tripThen(fn) {
5555+ return (...args) => {
5656+ if (!this.#tripped) {
5757+ this.#tripped = true
5858+5959+ // TODO: if these throw, what to do?
6060+ this.#onTripped?.()
6161+ fn(...args)
6262+ }
6363+ }
6464+ }
6565+6666+ /**
6767+ * wrap the given callback in a function that check the breaker before it's called.
6868+ * once the breaker has been tripped, calls to the wrapped function will be no-ops.
6969+ *
7070+ * @param {Callback} fn the function to be wrapped in the breaker
7171+ * @returns {Callback} a wrapped function, controlled by the breaker
7272+ */
7373+ untilTripped(fn) {
7474+ return (...args) => {
7575+ if (!this.#tripped) {
7676+ // TODO: if these throw, what to do?
7777+ fn(...args)
7878+ }
7979+ }
8080+ }
8181+8282+}
+73
src/common/async/semaphore.js
···11+/** @module common/async */
22+33+/**
44+ * Simple counting semaphore, for blocking async ops.
55+ * cribbed mostly from {@link https://github.com/ComFreek/async-playground}
66+ */
77+export class Semaphore {
88+99+ /** @type { number } */
1010+ #counter = 0
1111+1212+ /** @type {Array<function(boolean): void>} */
1313+ #resolvers = []
1414+1515+ constructor(count = 0) {
1616+ this.#counter = count
1717+ }
1818+1919+ /**
2020+ * try to take from the semaphore, reducing it's count
2121+ * if the semaphore is empty, blocks until available, or the given signal aborts.
2222+ *
2323+ * @param {AbortSignal | undefined} signal a signal to use to abort the block
2424+ * @returns {Promise<boolean>} true if the semaphore was successfully taken, false if aborted.
2525+ */
2626+ take(signal) {
2727+ return new Promise((resolve) => {
2828+ if (signal?.aborted) return resolve(false)
2929+3030+ // if there's resources available, use them
3131+3232+ this.#counter--
3333+ if (this.#counter >= 0) return resolve(true)
3434+3535+ // otherwise add to pending
3636+ // and explicitly remove the resolver from the list on abort
3737+3838+ this.#resolvers.push(resolve)
3939+ signal?.addEventListener('abort', () => {
4040+ const index = this.#resolvers.indexOf(resolve)
4141+ if (index >= 0) {
4242+ this.#resolvers.splice(index, 1)
4343+ this.#counter++
4444+ }
4545+4646+ resolve(false)
4747+ })
4848+ })
4949+ }
5050+5151+ /**
5252+ * try to take from the semaphore, reducing it's count, *without blocking*.
5353+ *
5454+ * @returns {boolean} true if the semaphore was taken, false otherwise.
5555+ */
5656+ poll() {
5757+ if (this.#counter <= 0) return false
5858+5959+ this.#counter--
6060+ return true
6161+ }
6262+6363+ /** announce that the semaphore is free to be taken by another awaiter. */
6464+ free() {
6565+ this.#counter++
6666+6767+ if (this.#resolvers.length > 0) {
6868+ const resolver = this.#resolvers.shift()
6969+ resolver && queueMicrotask(() => resolver(true))
7070+ }
7171+ }
7272+7373+}