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

Implement async/await syntax #460

Closed
mstoykov opened this issue Dec 5, 2022 · 19 comments · Fixed by #464
Closed

Implement async/await syntax #460

mstoykov opened this issue Dec 5, 2022 · 19 comments · Fixed by #464

Comments

@mstoykov
Copy link
Contributor

mstoykov commented Dec 5, 2022

async/await syntax was added in ES2017 and makes the asynchrnous and non-blocking Promises from ES2015 more syncrhnous in syntax while still being non-blocking.

The issue is about adding basic async/await syntax:

  1. async marking async functions in which you can use
  2. await to signal to the VM that it should unwind the stack and come back to this particular point after the provided promise is resolved or rejected.
  3. The handling of restoring the stack and continuing the execution from that point after the promise is resolved or rejected. Including throwing exceptions on rejects.
  4. Being able to interact with them from go code.

This like #436 is blocked on the ability of the goja runtime save and restore the execution context.

Things that should (likely) not be part of this issue:

This also blocks #430 as mentioned in it.

@mstoykov
Copy link
Contributor Author

mstoykov commented Dec 5, 2022

Having played a little bit with async/await being transpiled by babel:

It will be nice if goja let you more easily:

  1. find out a given value is async function. Just because it gets back a promise is not really ... good and does not help with how it should wor.
  2. getting to know you are currently inside an async function(from go code).

@dop251
Copy link
Owner

dop251 commented Dec 5, 2022

  1. find out a given value is async function. Just because it gets back a promise is not really ... good and does not help with how it should wor.
  2. getting to know you are currently inside an async function(from go code).

What do you need it for? Which problem are you trying to solve?

@mstoykov
Copy link
Contributor Author

mstoykov commented Dec 6, 2022

I will try to give you the concreate big example - k6 has a particular call group, which has two things it does:

  1. it will measure and then report how long it took to run as a particular metric
  2. it will make other measurements started from within it to have a tag/label group with a name the thing provide (+any parent group name, which is corner case I am not going to go in).

Preface: I did try to differintiate between functions that are called asyncrhnously and js async functions, but I might have not done it correctly.

As part of us going toward more async APIs I did start grafana/k6#2728 which outlines a lot of the assumptions that are made in the group implementation. The tl;dr is that it expects that everything is syncrhnous as that is how it was in k6 (and arguably is still mostly so).

Unfortunately (as mentioned in that issue) it is also used ... quite a lot. So the blank "this just doesn't work with asynchronous calls" isn't really on the table.

Our current working variant is trying to make await call() within a group to actually be tagged and if it took an async function (which is a requirement for using await either way) it will also measure how long the whole async function took and emit that as the group duration.

This means that we do not handle the cases of

group("something", async () => {
  somePromise.then(() => {
    // this here is withing group and will not make the group_duration go up
  }) 
 apiWithCallback(() => {
   // same as above
 })
})

This is under ... discussion and trying to figure out if things are even possible to be done.

The other thing is that k6 does have predominantly sync APIs that we can not and will not break, which means that using them within async function will be possible and likely a thing that happens fairly often especially in the early days. So at least for that case, async function directly calling a call we do know is both synchronous and taking a long time, we would like to be able to at least warn users.

While for group we have a (WIP) workaround as transpiled async functions - they do return Promises, after all, so we just measure .then(()=>{measure}) and return the resulting promise back. Yes - group also does return whatever it gets and this change means that if we take a Promise we will not return the same Promise. This is okay for us at this point. But if we can distinguish between a funciton returning a Promise and one that is actually async it will be better IMO.

I guess big parts of this also asks the question "how is goja going to restore the execution context if part of it is go code called by js that calls js?". And I guess you have more inside into this or am I just overthinking the problem.

dop251 added a commit that referenced this issue Dec 19, 2022
@dop251
Copy link
Owner

dop251 commented Dec 19, 2022

As far as I understand you need some kind of global context for async functions, something like this, but accessible from a Go function.

I have now pushed an initial implementation of async/await into a branch and to solve your issue I propose the following:

  • Add an exported field to Runtime, i.e. AsyncContext interface{}.
  • Whenever asyncRunner starts (i.e. when an async function is called) this AsyncContext is copied to a new field, asyncRunner.asyncContext
  • Whenever asyncRunner is performing a step, the Runtime.AsyncContext is set to asyncRunner.asyncContext and then is restored to its original value after the step is finished.

This way you can access the same AsyncContext value from any Go function that is called from an async function. Would that be enough?

@mstoykov
Copy link
Contributor Author

This solution seems sufficient to me at this time.

I have tried the branch and some basic functionality seems to work 🎉 - I will try to report issues once there is open PR as otherwise I think you probably expect that there are some things that won't work.

On the AsyncContext proposal I have some comments:

  • asyncContext is a thing in the specification, and I am not certain if it won't be ... confusing. Especially as it won't provide access to the execution context ;)
  • the proposal is stage 0 for 2 years so it seems it won't go anywhere ... soon.

None of the above are IMO a thing to not do it this way but just things I noticed.

I also was wondering if instead of getting something like that we can't get HostMakeJobCallback and if that won't give us enough info to basically implement what you propose but on the "host" side while keeping goja more spec compliant.

I then went down a rabbit hole based on

ECMAScript hosts that are not web browsers must use the default implementation of HostMakeJobCallback.

Which seems pretty strange. I guess we can argue k6 is a very ... bad web browser and goja just needs to let us define this just in case somebody makes a browser with goja 🤷 .

But my idea was that implementing more of the host layering points connected with jobs might be a better way to implement this while keeping spec compliant and not adding ambiguous fields.

In particular with HostMakeJobCallback I should be able to (when it's called) check what "context" I am currently in and wrap the new callback in it. Practically propagating downwards. And then un set it once the callback returns.

I would guess this is close to what:

import { Counter } from "k6/metrics";
import { group } from "k6";
const delay = () => { return Promise.resolve(); }
let l = new Counter("my coutner")

let originalThen = Promise.prototype.then;
Promise.prototype.then = function(onResolved, onRejected) {
    let o = onResolved
    onResolved = function(res) {
        group("coolgroup", () => { // this in practice will need to get hte group name
            o(res);
        })
    }
    // https://stackoverflow.com/questions/55413715/cant-i-overwrite-the-then-function-of-promise
    return originalThen.call(originalThen.call(this, onResolved, onRejected), undefined, error => {
        console.log(`upload the error to server: ${error}`);
        throw error;
    });
}

export default () => {
    group("coolgroup", () => {
        l.add(1) // nice

        delay(1).then(() => {
            l.add(1)// not so nice
        })
    })
}

does 🤔

@dop251
Copy link
Owner

dop251 commented Dec 19, 2022

This solution seems sufficient to me at this time.

I have tried the branch and some basic functionality seems to work 🎉 - I will try to report issues once there is open PR as otherwise I think you probably expect that there are some things that won't work.

Please do report them (maybe just as comments here rather than separate issues). It passes the current (and the most recent) tc39 tests, and I have no better means of testing it.

On the AsyncContext proposal I have some comments:

  • asyncContext is a thing in the specification, and I am not certain if it won't be ... confusing. Especially as it won't provide access to the execution context ;)

It's just a variable name, not a formal definition, I see no problem with that, but if you have a better name in mind I'm open to suggestions.

I also was wondering if instead of getting something like that we can't get HostMakeJobCallback and if that won't give us enough info to basically implement what you propose but on the "host" side while keeping goja more spec compliant.

I then went down a rabbit hole based on

ECMAScript hosts that are not web browsers must use the default implementation of HostMakeJobCallback.

Which seems pretty strange. I guess we can argue k6 is a very ... bad web browser and goja just needs to let us define this just in case somebody makes a browser with goja 🤷 .

But my idea was that implementing more of the host layering points connected with jobs might be a better way to implement this while keeping spec compliant and not adding ambiguous fields.

In particular with HostMakeJobCallback I should be able to (when it's called) check what "context" I am currently in and wrap the new callback in it. Practically propagating downwards. And then un set it once the callback returns.

I would guess this is close to what:

I don't mind that in general (and I don't really care about what the specification says in this case), but I'm struggling to see how this would help you. The key point here is to get some reference to the group (at least its name), how would you do that assuming the hook is implemented?

@mstoykov
Copy link
Contributor Author

but I'm struggling to see how this would help you.

HostMakeJobCallback will be called whenever .then is called which in practice should include await somePromise as well (from my understanding of the specification). And it will be called at this time.

So I will have access to the current state including whether or not I am currently in a group. At this point I can:

  1. return a Job which Call wraps the original one to set the group info accordingly
  2. set the HostDefined if JobCallback record and also use HostCallJobCallback to then do 1. just at that time.

We already need to keep some global state as the Runtime has no place for that, so that already is a solved problem.

@dop251
Copy link
Owner

dop251 commented Dec 20, 2022

but I'm struggling to see how this would help you.

HostMakeJobCallback will be called whenever .then is called which in practice should include await somePromise as well (from my understanding of the specification). And it will be called at this time.

Yes, but that also means it will be called on every .then(), even if it doesn't have anything to do with await. And there is no way (at least not within the spec) to tell the difference. This, in particular, means you can not rely on the async function's returned Promise to determine whether the group has finished or not, i.e.:

async function sleep(timeout) {
    await new Promise((resolve, reject) => {
        setTimeout(resolve, timeout);
    })
}

async function runInGroup() {
    sleep(2000).then(() => {
        console.log("surprise, I'm still in the group!");
    })
    await sleep(1000);
}

let startTime = new Date();
runInGroup().then(() => {
    console.log("group function finished, took " + (new Date() - startTime) + " ms");
});

I tried looking up the story behind HostMakeJobCallback to see what it was for, couldn't find anything except this: https://html.spec.whatwg.org/multipage/webappapis.html#hostmakejobcallback. But I've got an impression it was not designed for our purpose.

How about this:

type AsyncContextTracker interface {
    Suspended() (ctx interface{}) // called whenever an async function is suspended due to await. The returned `ctx` will be supplied to Resumed() when the function resumes.
    Resumed(ctx interface{})
}

runtime.SetAsyncContextTracker(/* an instance of AsyncContextTracker*/)

I believe this solves your problem and is very cheap to implement (both from the complexity and performance impact point of view).

@mstoykov
Copy link
Contributor Author

Yes, but that also means it will be called on every .then(), even if it doesn't have anything to do with await. And there is no way (at least not within the spec) to tell the difference. This, in particular, means you can not rely on the async function's returned Promise to determine whether the group has finished or not, i.e.:

I see what you mean there. Thinking about it:

  • yes it is beneficial, and likely what most people will want, to know is when an await is being called specifically.
  • I would argue that it will be beneficial to know when .then is called as well.
  • there are cases where you would call .then and then use await and in those cases it seems like it will be impossible to do anything for the .then cases with the current proposals
async function inGroup() {
  await Promise.All([
    doSomethingReturningPromise.then(() => { /* here we don't know we are in the group */ })
    doSomethingElseReturningAPromise.then(() => { /* here we don't know we are in the group */ })
  ])
}

like in this case the .then calls will have no idea about this. I am also not certain if handling this is worth it.

  • I would argue that the .then being able to finish outside of the group is fine.
  • I also can not at this point tell you whether we would ever want to be able to handle the above case - having the group information within the .then inside an async function. But it seems like a thing that maybe will be useful for others and certainly a thing that we can experiment with in k6.
  • as our code isn't a normal function definition but a built-in:
group("name", async () => {}).then(()=> {console.log("group ended")})

I can see how we can return a promise from group that we only resolve after all the in .thens are finished.

All of this is getting a bit too messy though, and I feel like I am blocking this on things we can add or enhance later.

From the very beginning of looking at this I have repeated to others that "what we can do depends on what we have as information". And working with the babellified async/await gave us really ... no additional information so we scaled down what group will even try to do with async functions.

Looking at this now it seems to me I will be able to do stuff even without any additional support, but just by rewriting Promise.prototype.then. I do prefer to not have to that - it both seems the most hackish way, but I also know that for some types that make the execution slower (regexp come to mind), no idea if applies for promises.

But I can also see how Both the AsyncContext* ideas and the hostmakejobcallback can be beneficial. So I am 👍 on whichever one you decide to implement.

I would just come back with more questions and ideas once we have something that we couldn't figure out how to implement. I don't expect we will figure out every possible case for everybody right from the start.

@mstoykov
Copy link
Contributor Author

I just tried to get what tc39 test fail, but ran into some problems where goja returns an error and then babel transpiles, and we get a different error 🤦.

But while I fixed that I also noticed that you still skip a bunch of the async tests

goja/tc39_test.go

Lines 277 to 281 in a008913

"test/language/eval-code/direct/async-",
"test/language/expressions/async-",
"test/language/expressions/await/",
"test/language/statements/async-function/",
"test/built-ins/Async",

Is this on purpose or did you just miss it? (maybe locally you have them enabled 🤷 )

@dop251
Copy link
Owner

dop251 commented Dec 20, 2022

Yes, but that also means it will be called on every .then(), even if it doesn't have anything to do with await. And there is no way (at least not within the spec) to tell the difference. This, in particular, means you can not rely on the async function's returned Promise to determine whether the group has finished or not, i.e.:

I see what you mean there. Thinking about it:

  • yes it is beneficial, and likely what most people will want, to know is when an await is being called specifically.
  • I would argue that it will be beneficial to know when .then is called as well.
  • there are cases where you would call .then and then use await and in those cases it seems like it will be impossible to do anything for the .then cases with the current proposals
async function inGroup() {
  await Promise.All([
    doSomethingReturningPromise.then(() => { /* here we don't know we are in the group */ })
    doSomethingElseReturningAPromise.then(() => { /* here we don't know we are in the group */ })
  ])
}

like in this case the .then calls will have no idea about this. I am also not certain if handling this is worth it.

I think you should document that then callbacks will run outside the group and discourage their usage in favour of async/await (in the end, that's what it was designed for). Otherwise the execution flow breaks and you can never be sure when the group execution terminates. You would have to have a specific callback and rely on users calling it when they are done. And that, of course poses another issue: what to do if they don't call it?

Looking at this now it seems to me I will be able to do stuff even without any additional support, but just by rewriting Promise.prototype.then. I do prefer to not have to that - it both seems the most hackish way, but I also know that for some types that make the execution slower (regexp come to mind), no idea if applies for promises.

Rewriting Promise.prototype.then won't work for await, because await calls PerformPromiseThen which does not call Promise.prototype.then (it's the other way round in fact). It will, however, help you with .then being called directly from the code.

But I can also see how Both the AsyncContext* ideas and the hostmakejobcallback can be beneficial. So I am 👍 on whichever one you decide to implement.

For now I'm leaning towards implementing the AsyncContextTracker. I think I'm also going to implement async stack traces using a similar approach to this. I think this will be useful.

I would just come back with more questions and ideas once we have something that we couldn't figure out how to implement. I don't expect we will figure out every possible case for everybody right from the start.

@dop251
Copy link
Owner

dop251 commented Dec 20, 2022

I just tried to get what tc39 test fail, but ran into some problems where goja returns an error and then babel transpiles, and we get a different error 🤦.

But while I fixed that I also noticed that you still skip a bunch of the async tests

goja/tc39_test.go

Lines 277 to 281 in a008913

"test/language/eval-code/direct/async-",
"test/language/expressions/async-",
"test/language/expressions/await/",
"test/language/statements/async-function/",
"test/built-ins/Async",

Is this on purpose or did you just miss it? (maybe locally you have them enabled 🤷 )

No, I've missed this section completely. Thanks for pointing out.

@mstoykov
Copy link
Contributor Author

I currently experience a panic that happens if a constructor calls a function that was AssertFunction. My case being using a go function that goes to a Value and then back to a function (for ... none relevant reasons). But it turned out that if you just use RunScript it will also panic after that and that is true even before the current changes.

package goja

import (
	"testing"
)

func TestConstructorPanic(t *testing.T) {
	// this panics with async-await branch
	r := New()
	c := func(call ConstructorCall, rt *Runtime) *Object {
		c, ok := AssertFunction(rt.ToValue(func() {}))
		if !ok {
			panic("wat")
		}
		if _, err := c(Undefined()); err != nil {
			panic(err)
		}
		return nil
	}
	if err := r.Set("C", c); err != nil {
		panic(err)
	}
	if _, err := r.RunString("new C()"); err != nil {
		panic(err)
	}
}

func TestConstructorPanic2(t *testing.T) {
	// this panics before async-await as well
	r := New()
	c := func(call ConstructorCall, rt *Runtime) *Object {
		if _, err := rt.RunString("(5)"); err != nil {
			panic(err)
		}
		return nil
	}
	if err := r.Set("C", c); err != nil {
		panic(err)
	}
	if _, err := r.RunString("new C()"); err != nil {
		panic(err)
	}
}

The panic I get is

=== RUN   TestConstructorPanic
--- FAIL: TestConstructorPanic (0.00s)
panic: runtime error: index out of range [0] with length 0 [recovered]
        panic: runtime error: index out of range [0] with length 0 [recovered]
        panic: runtime error: index out of range [0] with length 0 [recovered]
        panic: runtime error: index out of range [0] with length 0

goroutine 4 [running]:
testing.tRunner.func1.2({0x8b4820, 0xc000028288})
        testing/testing.go:1396 +0x24e
testing.tRunner.func1()
        testing/testing.go:1399 +0x39f
panic({0x8b4820, 0xc000028288})
        runtime/panic.go:884 +0x212
github.com/dop251/goja.(*Runtime).RunProgram.func1()
        github.com/dop251/goja/runtime.go:1424 +0xdc
panic({0x8b4820, 0xc000028288})
        runtime/panic.go:884 +0x212
github.com/dop251/goja.(*vm).handleThrow(0xc000160240, {0x8b4820, 0xc000028288})
        github.com/dop251/goja/vm.go:694 +0x418
github.com/dop251/goja.(*vm).runTryInner.func1()
        github.com/dop251/goja/vm.go:736 +0x45
panic({0x8b4820, 0xc000028288})
        runtime/panic.go:884 +0x212
github.com/dop251/goja._new.exec(0x1?, 0xc000160240)
        github.com/dop251/goja/vm.go:4358 +0x11b
github.com/dop251/goja.(*vm).run(0xc000160240)
        github.com/dop251/goja/vm.go:566 +0x38
github.com/dop251/goja.(*vm).runTryInner(0x0?)
        github.com/dop251/goja/vm.go:740 +0x70
github.com/dop251/goja.(*vm).runTry(0xc000160240)
        github.com/dop251/goja/vm.go:726 +0x1d3
github.com/dop251/goja.(*Runtime).RunProgram(0xc000115180, 0x0?)
        github.com/dop251/goja/runtime.go:1439 +0x198
github.com/dop251/goja.(*Runtime).RunScript(0xc000115180?, {0x0?, 0x0?}, {0x8e5bdc?, 0x9354a8?})
        github.com/dop251/goja/runtime.go:1411 +0x5a
github.com/dop251/goja.(*Runtime).RunString(...)
        github.com/dop251/goja/runtime.go:1400
github.com/dop251/goja.TestConstructorPanic(0x0?)
        github.com/dop251/goja/my_test.go:23 +0x76
testing.tRunner(0xc000007a00, 0x9354c0)
        testing/testing.go:1446 +0x10b
created by testing.(*T).Run
        testing/testing.go:1493 +0x35f
exit status 2
FAIL    github.com/dop251/goja  0.009s

both of those work if this is a FunctionCall, returning a Value and is you drop the new.

Given the panic it seems like the stack doesn't get restored after the function call inside the constructor, but it does if it was inside a function

@dop251
Copy link
Owner

dop251 commented Dec 21, 2022

I've pushed a fix to the branch. This actually has nothing to do with async, but the change won't apply clearly to master so I did not do a separate fix in master hoping this branch will be merged soon. Let me know if there is any problem.

@mstoykov
Copy link
Contributor Author

This actually has nothing to do with async,

TestConstructorPanic2 was always breaking, but the current changes made TestConstructorPanic also panic as well, while it did work on master

Let me know if there is any problem.

Everything seems to work, and I have now opened a WIP PR for k6 using it. So this seems okay.

But I will be on PTO for the rest of the year, so I might not be available to give you any more specific feedback :(

I would argue you can merge this as is, unless you have some concerns.

And then expand with the AsyncContext in a later PR 🤷

@dop251
Copy link
Owner

dop251 commented Dec 21, 2022

If you mean AsyncContextTracker, it's also in. I'm going to add some documentation and support for stack traces and then will merge.

@dop251
Copy link
Owner

dop251 commented Dec 23, 2022

Ok, I think it is now ready to be merged. There may be some bugs, but in order for me to fix them they need to be found first :)

Unless you have any objections @mstoykov I'll merge it shortly.

@mstoykov
Copy link
Contributor Author

mstoykov commented Dec 23, 2022

I am on PTO until the end of the year, but I did bump the version and re ran the test.

I needed to fix some asserts around stacktraces, by deleting the last/first file which ... seems to be superficial and is now gone, so seems good to me.

But I will only be able to look into using AsyncContextTracker next year ;)

So I guess 👍 and happy holidays 🎉 !

@dop251
Copy link
Owner

dop251 commented Dec 23, 2022

Cheers, to you too!

dop251 added a commit that referenced this issue Dec 24, 2022
* Implemented async/await. See #460.
Gabri3l pushed a commit to mongodb-forks/goja that referenced this issue Mar 17, 2023
* Implemented async/await. See dop251#460.

(cherry picked from commit 33bff8f)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants