Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Idea: synchronous promise-like token #32

Closed
dead-claudia opened this issue May 16, 2024 · 3 comments
Closed

Idea: synchronous promise-like token #32

dead-claudia opened this issue May 16, 2024 · 3 comments

Comments

@dead-claudia
Copy link

dead-claudia commented May 16, 2024

I have an idea for how this could be done: NotifyToken, working similarly to promises. This token could also be used as a general composable one-shot can't-fail sync notifier, not just as a cancellation token.

type Notify = (reason?: any) => void
type NotifyInit = (notify: Notify) => void

declare class NotifyError extends Error {}

interface NotifyTokenLike {
    then(onNotify: (reason?: any) => void): any
}

declare class NotifyToken implements NotifyTokenLike {
    constructor(init: NotifyInit)
    static all(tokens: Iterable<any | NotifyTokenLike>): NotifyToken
    static race(tokens: Iterable<any | NotifyTokenLike>): NotifyToken
    static resolve(reasonPromise: any | NotifyTokenLike): NotifyToken
    static withNotifier(): {
        token: NotifyToken
        notify: Notify
        // Convenience alias of `notify` for `using`
        [Symbol.dispose]: Notify
    }
    static readonly current: undefined | NotifyToken

    readonly notified: boolean
    readonly reason: any

    // The result notifies with either `reason` or the exception thrown from `onNotify`
    // `onNotify` may be called synchronously.
    then(onNotify: (reason: any) => any | NotifyTokenLike): NotifyToken

    // Run with this token as `NotifyToken.current`
    run<R>(body: () => R): R
}
  • The .then callback allows subscription and use in async/await. You can also chain notify tokens and compose them with promises this way.
  • The @@dispose property of the object returned from NotifyToken.withNotifier() exists so you can do using {token} = NotifyToken.withNotifier() instead of having to do a long try { ... } finally { notify() } block.
  • NotifyToken.current and token.run(() => ...) are based on async context.
  • token.notify and token.reason are mostly taken from the DOM standard (and I find myself using abortSignal.aborted a lot).
  • For NotifyToken.all, the result is an AggregateError of the inner reasons, and the reasons are deduplicated via SameValueZero.

The key differences from promises is that 1. notification is synchronous and 2. return values and exceptions share a channel. The second makes this not "just" a synchronous Promise.

@dead-claudia
Copy link
Author

dead-claudia commented May 16, 2024

Here's a rough polyfill sketch
export class NotifyError extends Error {}

const currentNotifier = new AsyncContext.Variable()

function withNotifier(C) {
    let notify
    const token = new C(n => {
        notify = n
    })
    if (typeof notify !== "function") {
        throw new TypeError("Initializer must be called with a function.")
    }
    return {token, notify}
}

function resolveNotify(source, reason, notify) {
    try {
        if (typeof reason?.then === "function") {
            let called = false
            reason.then(inner => {
                if (called) return
                called = true
                resolveNotify(source, inner, notify)
            })
            return
        }
    } catch (e) {
        reason = e
    }
    if (reason === undefined) reason = source
    try {
        notify(reason)
    } catch (e) {
        reportError(e)
    }
}

function performThen(onNotify, notify, reason) {
    try {
        reason = onNotify(reason)
    } catch (e) {
        reason = e
    }

    resolveNotify(source, reason, notify)
}

export class NotifyToken {
    #reason = undefined
    #listeners = new Set()

    static #check(token) {
        if (!(#listeners in token)) {
            throw new TypeError("this must be a NotifyToken object")
        }
    }

    constructor(init) {
        init((reason = new NotifyError()) => {
            const listeners = this.#listeners
            if (listeners === undefined) return

            this.#listeners = undefined
            this.#reason = reason

            for (const {notify, onNotify, remove, ref} of listeners) {
                ref.#listeners?.delete(remove)
                performThen(onNotify, reason, notify)
            }
        })
    }

    static all(tokens) {
        const {token, notify} = withNotifier(this)
        let initializing = true
        let expected = 0
        let reasons = new Set()
        const source = new NotifyError()

        const tryNotify = () => {
            const list = reasons
            if (list !== undefined && list.length >= expected) {
                reasons = undefined
                notify(new AggregateError(list, "", {cause: source}))
            }
        }

        try {
            for (const token of tokens) {
                let called = false
                expected++
                resolveNotify(source, token, reason => {
                    if (called || reasons === undefined) return
                    called = true
                    reasons.add(token)
                    if (!initializing) tryNotify()
                })
            }
        } catch (e) {
            reasons.add(e === undefined ? source : e)
            expected = reasons.length
        }

        initializing = false
        tryNotify()
        return token
    }

    static race(tokens) {
        const {token, notify} = withNotifier(this)
        let isEmpty = true
        let reason

        try {
            for (const token of tokens) {
                isEmpty = false
                resolveNotify(token, notify)
            }
        } catch (e) {
            reason = e
            isEmpty = true
        }

        if (isEmpty) notify()

        return token
    }

    static resolve(reason) {
        const {token, notify} = withNotifier(this)
        notify(reason)
        return token
    }

    static withNotifier() {
        const {token, notify} = withNotifier(this)
        return {
            token,
            notify,
            [Symbol.dispose]: notify,
        }
    }

    static get current() {
        NotifyToken.#check(this)
        return currentNotifier.get()
    }

    get notified() {
        NotifyToken.#check(this)
        return this.#listeners === undefined
    }

    get reason() {
        NotifyToken.#check(this)
        return this.#reason
    }

    then(onNotify) {
        NotifyToken.#check(this)

        const {token, notify} = withNotifier(this.constructor)
        const listeners = this.#listeners

        if (listeners !== undefined) {
            const current = currentNotifier.get()
            if (current !== undefined) {
                if (current.#listeners !== undefined) {
                    const listener = {onNotify, notify, remove: undefined, ref: current}
                    const remove = () => {
                        this.listeners?.delete(listener)
                    }
                    const other = {onNotify: remove, notify, remove: undefined, ref: current}
                    listener.other = other
                    listeners.push(listener)
                    current.#listeners.push(other)
                    return token
                }
            }
        }

        performThen(onNotify, this.#reason, notify)

        return token
    }

    run(body) {
        NotifyToken.#check(this)
        return currentNotifier.run(this, body)
    }
}

@dead-claudia
Copy link
Author

dead-claudia commented May 16, 2024

I could imagine some common idioms:

// From timeout
const token = new NotifyToken(notify => setTimeout(notify, 1000))

// From promise resolve
const token = new NotifyToken(notify => promise.then(notify))

// From promise reject (more common)
const token = new NotifyToken(notify => promise.catch(notify))

You could use atomics to avoid the DOM dependency for timeouts:

const scheduler = Promise.resolve()

function timeout(ms, reason) {
    return new NotifyToken(notify => {
        const buf = new Int32Array(new SharedArrayBuffer(4))
        const {async, value} = Atomics.waitAsync(buf, 0, 0, ms)
        // Waiter set up, safe to signal
        const current = NotifyToken.current
        if (current !== undefined) {
            try {
                current.then(() => {
                    Atomics.notify(buf, 0, 1)
                })
            } catch (e) {
                notify(e)
                return
            }
        }
        if (async) {
            value.then(result => {
                if (result !== "ok") notify(reason)
            })
        } else {
            if (result !== "ok") notify(reason)
        }
    })
}

@dead-claudia dead-claudia closed this as not planned Won't fix, can't repro, duplicate, stale May 18, 2024
@dead-claudia
Copy link
Author

Closed because #22's a better path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant