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

Where would be an appropriate place to propose a related type? #198

Open
benlesh opened this issue Aug 2, 2023 · 5 comments
Open

Where would be an appropriate place to propose a related type? #198

benlesh opened this issue Aug 2, 2023 · 5 comments

Comments

@benlesh
Copy link

benlesh commented Aug 2, 2023

There's a need for observables, fetch, et al, to have some sort of token-type disposal mechanism. Further, the language could generally benefit from some sort of one-time explicit "flag" if you will.

Honestly, a Promise is almost ideal for a cancellation token with two problems:

  1. Promise schedules, which means you can't deterministically know when it's disposed of something.
  2. Promise requires a resolution or rejection, which means you can't really just "let it go" unless you want a leak.

That aside, a simple interface like .then(fn) is really nice for situations where you just want to notify that it's time to cleanup.

What I'd love to see is basically something that:

  1. Created a Disposable/Token pair of some sort
  2. Where the Token was a synchronous, multicast push type (possibly thennable)
  3. Where adding a callback to the Token after it's already flagged would cause the callback to be called immediately.
  4. Where the state/value of the Token would be synchronously readable

All of the above would make an ideal cancellation mechanism for a wide variety of use cases, and might even make an interesting mechanism that could be used similar to a "semaphore" type thing in async/await.

This is something I discussed with @ljharb and he seemed interested. @bakkot @rbuckton @littledan ... is there any interest here? What is the process?

@mhofman
Copy link
Member

mhofman commented Aug 2, 2023

2. Promise requires a resolution or rejection, which means you can't really just "let it go" unless you want a leak.

That is a deficiency of all current implementations, rooted in spec text that if followed to the letter makes it difficult for a gc to collect the reactions and instance of an unresolved promise without reachable resolvers. I do want to update the spec text to make it clearer that an engine can and should collect these things.

Edit: Actually I think I got Moddable to fix XS for this specific case (maybe not released), but yeah all promise implementations are currently leaky for this and other cases. See https://gist.github.com/mhofman/e4031aa4cd2375d0f151f62691724475 for some tests I wrote around this.

Edit 2: it looks like an unresolved promise with reactions and dropped resolvers is not a case I tested for. If the promise is kept, it's currently a subset of the existing test for "kept promise with dropped resolvers should collect resolvers", which no engine does correctly. If the both the promise and resolvers are dropped, I should add a test to see if reactions are collected, but I suspect they would be given that's the behavior that would naturally fall from the spec text.

@rbuckton
Copy link
Collaborator

rbuckton commented Aug 2, 2023

The appropriate place to discuss this is probably https://github.com/tc39/proposal-cancellation, though that proposal hasn't seen much activity since WHATWG has been adamant that there shouldn't be two native cancellation mechanisms and AbortController already exists.

@benlesh
Copy link
Author

benlesh commented Aug 2, 2023

though that proposal hasn't seen much activity since WHATWG has been adamant that there shouldn't be two native cancellation mechanisms and AbortController already exists.

AbortController/AbortSignal looked promising, but RxJS is walking away from it for the time being because it's really unergonomic and it lacks some safeties that our Subscription provides. (namely number 3 above)

@rbuckton
Copy link
Collaborator

rbuckton commented Aug 3, 2023

I'd suggest reading over the cancellation proposal and taking a look at https://www.npmjs.com/package/@esfx/canceltoken, which is based on an early implementation of the proposed API. I'd appreciate any feedback.

@rixtox
Copy link

rixtox commented Aug 4, 2023

Not exactly a solution, but you can combine a synchronous thenable and an async runner like co.js to achieve similar use case. This however exploits the fact that co.js resumes the generator using thenable.then() instead of using async scheduling.

/**
 * A {@link https://promisesaplus.com/ | Promises/A+} conforming Promise, but
 * relaxing {@link https://promisesaplus.com/#point-34 | rule 2.2.4}. So that
 * `onFulfilled` and `onRejected` callbacks added to the `then()` clause will be
 * called synchronously either:
 *
 * 1. when calling `then()` after the promise was resolved, or
 * 2. when resolving the promise.
 *
 * This relaxation is useful for ensuring state consistency between promise
 * resolution and callback invocation.
 * @param executor execute the Promise with resolve and reject callbacks
 * @returns a {@link PromiseLike | Thenable Promise}
 */
export class SameTickPromise<T> implements PromiseLike<T> {
    private status = Status.Pending;
    private value: any = undefined;
    private waiters: Array<Omit<PromiseResolvers<any>, "promise">> = [];
    private onfulfilled: ((value: T) => any) | undefined | null = undefined;
    private onrejected: ((reason: any) => any) | undefined | null = undefined;
    private resolve!: (value: T | PromiseLike<T>) => void;
    private reject!: (err: any) => void;

    constructor(
        executor?: (
            resolve: (value: T | PromiseLike<T>) => void,
            reject: (reason?: any) => void
        ) => void
    ) {
        this.resolve = (SameTickPromise.prototype.fulfill).bind(
            this,
            Status.Fulfilled
        );
        this.reject = (SameTickPromise.prototype.fulfill).bind(
                this,
                Status.Rejected
            );
        if (!executor) return;
        try {
            executor(this.resolve, this.reject);
        } catch (err) {
            this.reject(err);
        }
    }

    public static withResolvers<T>(): PromiseResolvers<T, SameTickPromise<T>> {
        const promise = new SameTickPromise<T>();
        const { resolve, reject } = promise;
        return { promise, resolve, reject };
    }

    public static resolve(): SameTickPromise<void>;
    public static resolve<T>(value: T): SameTickPromise<Awaited<T>>;
    public static resolve<T>(value?: T): SameTickPromise<Awaited<T>> {
        return new SameTickPromise<Awaited<T>>((resolve, reject) => {
            if (isPromiseLike(value)) {
                return value.then(resolve, reject);
            }
            resolve(value as any);
        });
    }

    public static reject(reason: any): SameTickPromise<never> {
        return new SameTickPromise<never>((_, reject) => reject(reason));
    }

    public then<TResult1 = T, TResult2 = never>(
        onfulfilled?:
            | ((value: T) => TResult1 | PromiseLike<TResult1>)
            | null
            | undefined,
        onrejected?:
            | ((reason: any) => TResult2 | PromiseLike<TResult2>)
            | null
            | undefined
    ): PromiseLike<TResult1 | TResult2> {
        const { promise, resolve, reject } =
            SameTickPromise.withResolvers<any>();
        promise.onfulfilled = onfulfilled;
        promise.onrejected = onrejected;
        if (this.status === Status.Pending) {
            this.waiters.push({ resolve, reject });
        } else {
            (this.status === Status.Fulfilled ? resolve : reject)(this.value);
        }
        return promise;
    }

    private fulfill(
        status: Status.Fulfilled | Status.Rejected,
        value: any
    ): void {
        if (this.status !== Status.Pending) return;
        const cont =
            status === Status.Fulfilled ? this.onfulfilled : this.onrejected;
        if (cont) {
            this.onfulfilled = this.onrejected = undefined;
            try {
                return this.resolve(cont(value));
            } catch (err) {
                return this.reject(err);
            }
        } else if (isPromiseLike(value)) {
            return void value.then(this.resolve, this.reject);
        }
        this.value = value;
        this.status = status;
        while (this.waiters.length) {
            const { resolve, reject } = this.waiters.shift()!;
            (this.status === Status.Fulfilled ? resolve : reject)(this.value);
        }
    }
}

export type PromiseResolvers<T, P extends PromiseLike<T> = PromiseLike<T>> = {
    promise: P;
    resolve: (value: T | PromiseLike<T>) => void;
    reject: (err: any) => void;
};

const enum Status {
    Pending,
    Fulfilled,
    Rejected,
}

describe("SameTickPromise", () => {
    test("co() should resume generator synchronously", async () => {
        const log = jest.fn();
        function* run(promise: PromiseLike<number>) {
            try {
                log(yield promise);
            } catch (err) {
                log("error", err);
                throw err;
            }
        }
        const { promise, resolve } = SameTickPromise.withResolvers<number>();
        const result = await Promise.allSettled([
            co(run, promise),
            (async () => {
                resolve(1);
                expect(log.mock.calls).toStrictEqual([[1]]);
            })(),
        ]);
        expect(result).toStrictEqual([
            { status: "fulfilled", value: undefined },
            { status: "fulfilled", value: undefined },
        ]);
    });
});

You can then wrap AbortSignal like this:

export function throwOnAbort(signal: AbortSignal | undefined): Disposable & PromiseLike<never> {
    const { promise, reject } = SameTickPromise.withResolvers<never>();
    let abort: undefined | (() => void);
    function dispose() {
        if (abort) {
            signal.removeEventListener("abort", abort, { once:true });
            abort = undefined;
        }
    }
    if (signal) {
        abort = function abort() {
            dispose();
            reject(signal.reason);
        }
        if (signal.aborted) {
            abort();
        } else {
            signal.addEventListener("abort", abort, { once:true });
        }
    }
    return Object.assign(promise, { [Symbol.dispose]: dispose });
}

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

4 participants