Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"sync/atomic"
"time"
"unsafe"
)

Expand All @@ -25,6 +26,11 @@ type Context struct {

const defaultJobQueueSize = 1024

// awaitPollInterval is the duration the Await loop sleeps when no JS or Go
// jobs are pending. Keeps CPU usage low while ensuring Go-scheduled work
// (e.g., resolved Promises from goroutines) is picked up promptly.
const awaitPollInterval = time.Millisecond

// awaitPromiseStateHook and awaitExecutePendingJobHook are used only in tests to
// force specific Await code paths; they must remain nil in production.
var (
Expand Down Expand Up @@ -985,7 +991,13 @@ func (ctx *Context) Loop() {
ctx.ProcessJobs()
}

// Wait for a promise and execute pending jobs while waiting for it. Return the promise result or JS_EXCEPTION in case of promise rejection.
// Wait for a promise and execute pending jobs while waiting for it.
// Return the promise result or JS_EXCEPTION in case of promise rejection.
//
// This implementation uses a polling loop instead of blocking in js_std_loop.
// This allows Go-scheduled work (via ctx.Schedule) to be processed between
// iterations, enabling async Go bridge functions (fetch, storage, etc.) to
// resolve Promises from goroutines without blocking the event loop.
func (ctx *Context) Await(v *Value) *Value {
if v == nil || !v.IsPromise() {
return v
Expand All @@ -1002,7 +1014,9 @@ func (ctx *Context) Await(v *Value) *Value {
runtimeRef := ctx.runtime.ref

for {
// Drain Go-scheduled work (resolve/reject from goroutines)
ctx.ProcessJobs()

state := C.JS_PromiseState(ctx.ref, promise.ref)
if hook := awaitPromiseStateHook; hook != nil {
if override, ok := hook(ctx, promise, int(state)); ok {
Expand All @@ -1016,6 +1030,7 @@ func (ctx *Context) Await(v *Value) *Value {
reason := C.JS_PromiseResult(ctx.ref, promise.ref)
return &Value{ctx: ctx, ref: C.JS_Throw(ctx.ref, reason)}
case pendingState:
// Process JS microtasks (Promise.then callbacks, queueMicrotask)
executed := C.JS_ExecutePendingJob(runtimeRef, nil)
if hook := awaitExecutePendingJobHook; hook != nil {
if override, ok := hook(ctx, promise, int(executed)); ok {
Expand All @@ -1026,7 +1041,22 @@ func (ctx *Context) Await(v *Value) *Value {
return ctx.ThrowInternalError("failed to execute pending job")
}
if executed == 0 {
C.js_std_loop(ctx.ref)
// No JS microtasks pending. Check for Go-scheduled work first.
ctx.ProcessJobs()

// Re-check promise state — Go jobs may have resolved it.
newState := C.JS_PromiseState(ctx.ref, promise.ref)
if newState != pendingState {
continue // resolved — loop back to handle it
}

// Still pending. Check if there are pending Go jobs in the queue.
// If so, keep polling. If not, yield briefly — a goroutine will
// Schedule work soon (HTTP response, storage result, etc.)
if len(ctx.jobQueue) > 0 {
continue // more Go jobs to process
}
time.Sleep(awaitPollInterval)
}
default:
return v
Expand Down
122 changes: 119 additions & 3 deletions context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1002,17 +1002,52 @@ func TestContextInternalsCoverage(t *testing.T) {
require.Nil(t, ctx.Await(nilPromise))
})

t.Run("AwaitDrivesStdLoopForTimeout", func(t *testing.T) {
t.Run("AwaitDrivesScheduleForResolve", func(t *testing.T) {
rt := NewRuntime()
defer rt.Close()
ctx := rt.NewContext()
defer ctx.Close()

promise := ctx.Eval(`new Promise((resolve) => { setTimeout(() => resolve("timer result"), 0); })`)
// Test that Await processes Go-scheduled work (via ctx.Schedule)
// instead of relying on js_std_loop for C-level timers.
promise := ctx.NewPromise(func(resolve, reject func(*Value)) {
go func() {
ctx.Schedule(func(ctx *Context) {
val := ctx.NewString("scheduled result")
defer val.Free()
resolve(val)
})
}()
})
defer promise.Free()
result := ctx.Await(promise)
defer result.Free()
require.Equal(t, "scheduled result", result.ToString())
})

t.Run("AwaitPollsUntilDelayedResolve", func(t *testing.T) {
rt := NewRuntime()
defer rt.Close()
ctx := rt.NewContext()
defer ctx.Close()

// Test that the Await polling loop correctly yields and re-checks
// when the Promise is resolved after a delay from a goroutine.
// This exercises the time.Sleep path in Await's pending case.
promise := ctx.NewPromise(func(resolve, reject func(*Value)) {
go func() {
time.Sleep(5 * time.Millisecond) // force Await to poll a few times
ctx.Schedule(func(ctx *Context) {
val := ctx.NewString("delayed result")
defer val.Free()
resolve(val)
})
}()
})
defer promise.Free()
result := ctx.Await(promise)
defer result.Free()
require.Equal(t, "timer result", result.ToString())
require.Equal(t, "delayed result", result.ToString())
})

t.Run("AwaitHandlesPendingJobFailure", func(t *testing.T) {
Expand Down Expand Up @@ -1043,6 +1078,87 @@ func TestContextInternalsCoverage(t *testing.T) {
require.Contains(t, err.Error(), "failed to execute pending job")
})

// Cover line 1049-1050: executed==0, ProcessJobs resolves the promise,
// re-check sees non-pending state → continue.
t.Run("AwaitReCheckResolvesAfterProcessJobs", func(t *testing.T) {
rt := NewRuntime()
defer rt.Close()
ctx := rt.NewContext()
defer ctx.Close()

var resolvePromise func(*Value)
promise := ctx.NewPromise(func(resolve, reject func(*Value)) {
resolvePromise = resolve
})
defer promise.Free()

firstCall := true
awaitExecutePendingJobHook = func(hookCtx *Context, _ *Value, current int) (int, bool) {
if hookCtx != ctx {
return current, false
}
if firstCall {
firstCall = false
// Schedule a job that resolves the promise. ProcessJobs() at
// line 1045 will pick it up, so the re-check at line 1048
// sees fulfilled state.
ctx.Schedule(func(inner *Context) {
val := inner.NewString("resolved-via-recheck")
defer val.Free()
resolvePromise(val)
})
return 0, true // force executed=0
}
return current, false
}
t.Cleanup(func() { awaitExecutePendingJobHook = nil })

result := ctx.Await(promise)
defer result.Free()
require.Equal(t, "resolved-via-recheck", result.ToString())
})

// Cover line 1056-1057: executed==0, ProcessJobs drains but promise
// stays pending, yet another Go job is already queued → continue.
t.Run("AwaitContinuesWhenJobQueueNonEmpty", func(t *testing.T) {
rt := NewRuntime()
defer rt.Close()
ctx := rt.NewContext()
defer ctx.Close()

var resolvePromise func(*Value)
promise := ctx.NewPromise(func(resolve, reject func(*Value)) {
resolvePromise = resolve
})
defer promise.Free()

callCount := 0
awaitExecutePendingJobHook = func(hookCtx *Context, _ *Value, current int) (int, bool) {
if hookCtx != ctx {
return current, false
}
callCount++
if callCount == 1 {
// First iteration: force executed=0, and enqueue two jobs.
// ProcessJobs at line 1045 drains the first, but the second
// remains → len(jobQueue) > 0 → continue (line 1056-1057).
ctx.Schedule(func(*Context) {}) // drained by ProcessJobs
ctx.Schedule(func(inner *Context) { // stays in queue → triggers continue
val := inner.NewString("after-queue-check")
defer val.Free()
resolvePromise(val)
})
return 0, true
}
return current, false
}
t.Cleanup(func() { awaitExecutePendingJobHook = nil })

result := ctx.Await(promise)
defer result.Free()
require.Equal(t, "after-queue-check", result.ToString())
})

t.Run("AwaitFallsBackOnUnexpectedState", func(t *testing.T) {
rt := NewRuntime()
defer rt.Close()
Expand Down
Loading