Skip to content
Closed
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
6 changes: 5 additions & 1 deletion scripts/build/deps/webkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
* for local mode. Override via `--webkit-version=<hash>` to test a branch.
* From https://github.com/oven-sh/WebKit releases.
*/
export const WEBKIT_VERSION = "c4ddc0cf5255ec9cc209e6369292739b78e3ca80";
// Preview of oven-sh/WebKit#487: DeferredWorkTimer keeps the embedder's tickets in
// m_pendingTickets, so the end of a collection cancels the tickets of a dead realm before the
// embedder runs their tasks. It sits on top of c4ddc0cf, the pin here before this change. Swap
// in the merged sha once that PR lands.
export const WEBKIT_VERSION = "autobuild-preview-pr-487-b5c71022";
Comment thread
robobun marked this conversation as resolved.
Resolved

/**
* WebKit (JavaScriptCore) — the JS engine.
Expand Down
15 changes: 9 additions & 6 deletions src/jsc/JSCScheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ bun_opaque::opaque_ffi! {
impl Taskable for JSCDeferredWorkTask {
const TAG: TaskTag = task_tag::JSCDeferredWorkTask;
/// A cross-thread Atomics.notify / Wasm / FinalizationRegistry completion:
/// delete the C++ job (its `Ref<Ticket>` drops before ~VM).
/// release the C++ job unrun (it takes its ticket out of the DeferredWorkTimer
/// and gives the event loop ref back; its `Ref<Ticket>` drops before ~VM).
Comment thread
robobun marked this conversation as resolved.
Resolved
unsafe fn release_unrun(this: *mut Self) {
// SAFETY: fn contract; heap-allocated by JSCTaskScheduler::onScheduleWorkSoon.
unsafe { Bun__deleteDeferredWorkTask(this) }
Expand All @@ -24,17 +25,19 @@ unsafe extern "C" {
// C++ side consuming it is interior to the opaque cell.
safe fn Bun__runDeferredWork(task: &mut JSCDeferredWorkTask);
fn Bun__deleteDeferredWorkTask(task: *mut JSCDeferredWorkTask);
fn Bun__discardDeferredWorkTask(task: *mut JSCDeferredWorkTask);
}

impl JSCDeferredWorkTask {
/// Delete the C++ job without running it (its ticket is cancelled with the
/// DeferredWorkTimer at VM teardown).
/// Delete the C++ job the closed VM handle refused, on whatever thread posted
/// it. Nothing is balanced here: the ticket stays pending and is cancelled
/// with the VM.
Comment thread
robobun marked this conversation as resolved.
Resolved
///
/// # Safety
/// `this` is the job handed over by `onScheduleWorkSoon`, not yet run.
pub unsafe fn destroy(this: *mut Self) {
// SAFETY: fn contract.
unsafe { Bun__deleteDeferredWorkTask(this) };
unsafe { Bun__discardDeferredWorkTask(this) };
}

/// The C++ side reports what a job throws at its own boundary; what can
Expand All @@ -61,8 +64,8 @@ unsafe extern "C" fn Bun__queueJSCDeferredWorkTaskConcurrently(
let ct = ConcurrentTask::create_from(task);
if let crate::vm_handle::Posted::Refused(ct) = handle.post(kind, ct) {
// SAFETY: refused ⇒ we own the ConcurrentTask box; the C++ job's ticket
// was already cancelled by the VM teardown (DeferredWorkTimer is shut
// down before ~VM), so dropping the job pointer here loses nothing.
// stays pending in the DeferredWorkTimer, which cancels it with the VM,
// so dropping the job pointer here loses nothing.
Comment thread
robobun marked this conversation as resolved.
Resolved
drop(unsafe { bun_core::heap::take(ct.as_ptr()) });
// SAFETY: `task` is the C++ job handed over for exactly one run/destroy.
unsafe { JSCDeferredWorkTask::destroy(task) };
Expand Down
7 changes: 7 additions & 0 deletions src/jsc/VmHandle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,13 @@ pub extern "C" fn Bun__VM__currentLoopKind(vm: &VirtualMachine) -> LoopKind {
vm.current_loop_kind()
}

/// Any thread: whether the calling thread is the one `vm` runs on. Compares
/// the thread's own VM slot to `vm`, which is not dereferenced.
Comment thread
robobun marked this conversation as resolved.
Resolved
#[unsafe(no_mangle)]
pub extern "C" fn Bun__VM__isCurrentThread(vm: *const VirtualMachine) -> bool {
VirtualMachine::get_or_null().is_some_and(|current| core::ptr::eq(current, vm))
}

// ── Test suite only: deterministic late completions ───────────────────────
//
// `BUN_DEBUG_TEST_WORKER_TEARDOWN_GATE` (first-level worker VMs; builds with
Expand Down
7 changes: 4 additions & 3 deletions src/jsc/bindings/BunClientData.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ JSHeapData::~JSHeapData() = default;
#define CLIENT_ISO_SUBSPACE_INIT(subspace) subspace(m_heapData->subspace)

JSVMClientData::JSVMClientData(VM& vm, RefPtr<JSC::SourceProvider> sourceProvider)
: m_builtinNames(vm)
: deferredWorkTimer(vm)
, m_builtinNames(vm)
, m_builtinFunctions(makeUnique<JSBuiltinFunctions>(vm, sourceProvider, m_builtinNames))
, m_heapData(JSHeapData::ensureHeapData(vm.heap))
, CLIENT_ISO_SUBSPACE_INIT(m_domConstructorSpace)
Expand Down Expand Up @@ -115,8 +116,8 @@ void JSVMClientData::create(VM* vm, void* bunVM, WorkerMessagingProxy* worker)
clientData->m_isNodeWorkerVM = worker && worker->options().kind == WorkerOptions::Kind::Node;
clientData->vmHandle = Bun__VmHandle__retain(bunVM);
clientData->vmHandleState = Bun__VmHandle__stateAddress(clientData->vmHandle);
vm->deferredWorkTimer->onAddPendingWork = [clientData](Ref<JSC::DeferredWorkTimer::Ticket>&& ticket, JSC::DeferredWorkTimer::WorkType kind) -> void {
Bun::JSCTaskScheduler::onAddPendingWork(clientData, WTF::move(ticket), kind);
vm->deferredWorkTimer->onAddPendingWork = [clientData](JSC::DeferredWorkTimer::Ticket& ticket) -> void {
Bun::JSCTaskScheduler::onAddPendingWork(clientData, ticket);
};
vm->deferredWorkTimer->onScheduleWorkSoon = [clientData](Ref<JSC::DeferredWorkTimer::Ticket>&& ticket, JSC::DeferredWorkTimer::Task&& task) -> void {
Bun::JSCTaskScheduler::onScheduleWorkSoon(clientData, WTF::move(ticket), WTF::move(task));
Expand Down
2 changes: 2 additions & 0 deletions src/jsc/bindings/BunClientData.h
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ inline bool Bun__VmHandle__scriptAllowedInline(const unsigned char* state)
}
// JS thread only: adjust the keep-alive of the VM this thread runs.
extern "C" void Bun__eventLoop__refKeepAlive(void* bunVM, int delta);
// Any thread: whether the calling thread is the one `bunVM` runs on.
extern "C" bool Bun__VM__isCurrentThread(const void* bunVM);

namespace WebCore {

Expand Down
182 changes: 81 additions & 101 deletions src/jsc/bindings/JSCTaskScheduler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,116 +16,98 @@ extern "C" void Bun__queueJSCDeferredWorkTaskConcurrently(const ::BunVmHandleRef

class JSCDeferredWorkTask {
public:
JSCDeferredWorkTask(Ref<Ticket> ticket, Task&& task)
: ticket(WTF::move(ticket))
JSCDeferredWorkTask(WebCore::JSVMClientData* clientData, Ref<Ticket> ticket, Task&& task)
: clientData(clientData)
, ticket(WTF::move(ticket))
, task(WTF::move(task))
{
}

// The ticket's realm can be dead by the time the job runs; the VM comes from
// here, not from the ticket.
Comment thread
robobun marked this conversation as resolved.
Resolved
WebCore::JSVMClientData* clientData;
Ref<Ticket> ticket;
Task task;
~JSCDeferredWorkTask()
{
}

JSC::VM& vm() const { return ticket->scriptExecutionOwner()->vm(); }

WTF_MAKE_TZONE_ALLOCATED(JSCDeferredWorkTask);
};

// Drop `ticket` from whichever pending set holds it. Caller holds m_lock; the
// event-loop ref is balanced after the caller releases the lock.
static bool dropPendingTicketLocked(Bun::JSCTaskScheduler& scheduler, Ticket* ticket) WTF_REQUIRES_LOCK(scheduler.m_lock)
// Ticket::embedderData(): the loop that was current when JSC registered the work,
// which its completion is posted to, and whether the ticket took an event loop ref.
Comment thread
robobun marked this conversation as resolved.
Resolved
static constexpr uint8_t loopKindMask = 0x1;
static constexpr uint8_t holdsEventLoopRef = 0x2;
static_assert(static_cast<uint8_t>(BunLoopKind::Macro) <= loopKindMask);

static BunLoopKind loopKindOf(Ticket& ticket)
{
bool isKeepingEventLoopAlive = scheduler.m_pendingTicketsKeepingEventLoopAlive.removeIf([ticket](auto& pendingTicket) {
return pendingTicket.key.ptr() == ticket;
});
// -- At this point, ticket may be an invalid pointer.
if (!isKeepingEventLoopAlive) {
scheduler.m_pendingTicketsOther.removeIf([ticket](auto& pendingTicket) {
return pendingTicket.key.ptr() == ticket;
});
}
return isKeepingEventLoopAlive;
return static_cast<BunLoopKind>(ticket.embedderData() & loopKindMask);
}

static bool tookEventLoopRef(Ticket& ticket)
{
return ticket.embedderData() & holdsEventLoopRef;
}

void JSCTaskScheduler::onAddPendingWork(WebCore::JSVMClientData* clientData, Ref<Ticket>&& ticket, JSC::DeferredWorkTimer::WorkType kind)
// Any thread. The VM's own thread gives the ref back the way onAddPendingWork
// took it, which lands in any state of the VM handle (the teardown cancels
// what is still pending after the handle has closed). Another thread, such as
// a collector thread at the end of a collection, goes through the handle.
Comment thread
robobun marked this conversation as resolved.
Resolved
static void releaseEventLoopRef(WebCore::JSVMClientData* clientData, Ticket& ticket)
{
auto& scheduler = clientData->deferredWorkTimer;
BunLoopKind loopKind = Bun__VM__currentLoopKind(clientData->bunVM);
Locker<Lock> holder { scheduler.m_lock };
if (scheduler.m_isShuttingDown) [[unlikely]]
if (!tookEventLoopRef(ticket))
return;
if (kind == DeferredWorkTimer::WorkType::ImminentlyScheduled) {
Bun__eventLoop__refKeepAlive(clientData->bunVM, 1);
scheduler.m_pendingTicketsKeepingEventLoopAlive.add(WTF::move(ticket), loopKind);
} else {
scheduler.m_pendingTicketsOther.add(WTF::move(ticket), loopKind);
}
if (Bun__VM__isCurrentThread(clientData->bunVM))
Bun__eventLoop__refKeepAlive(clientData->bunVM, -1);
else
Bun__VmHandle__refKeepAlive(clientData->vmHandle, loopKindOf(ticket), -1);
Comment thread
robobun marked this conversation as resolved.
Resolved
}
void JSCTaskScheduler::onScheduleWorkSoon(WebCore::JSVMClientData* clientData, Ref<Ticket>&& ticket, Task&& task)

void JSCTaskScheduler::onAddPendingWork(WebCore::JSVMClientData* clientData, Ticket& ticket)
{
auto& scheduler = clientData->deferredWorkTimer;
BunLoopKind loopKind = BunLoopKind::Regular;
{
Locker<Lock> holder { scheduler.m_lock };
// The event loop is past its last tick: don't bother posting. Reached from
// ~VM -> WaiterListManager::unregister -> Waiter::cancelAndClear for every
// outstanding Atomics.waitAsync on a terminating worker, and from
// collectNow -> JSFinalizationRegistry::finalizeUnconditionally. Balance
// onAddPendingWork so the ticket-set entry and event-loop ref are released.
if (scheduler.m_isShuttingDown) [[unlikely]] {
bool wasKeepingAlive = dropPendingTicketLocked(scheduler, ticket.ptr());
holder.unlockEarly();
if (wasKeepingAlive)
Bun__VmHandle__refKeepAlive(clientData->vmHandle, BunLoopKind::Regular, -1);
return;
}
auto it = scheduler.m_pendingTicketsKeepingEventLoopAlive.find(ticket.ptr());
loopKind = it != scheduler.m_pendingTicketsKeepingEventLoopAlive.end() ? it->value : scheduler.m_pendingTicketsOther.get(ticket.ptr());
uint8_t data = static_cast<uint8_t>(Bun__VM__currentLoopKind(clientData->bunVM));
if (ticket.type() == DeferredWorkTimer::WorkType::ImminentlyScheduled) {
Bun__eventLoop__refKeepAlive(clientData->bunVM, 1);
data |= holdsEventLoopRef;
}
// Outside m_lock (markShuttingDown, on the VM's thread, needs it): a post that
// still races the shutdown lands on the VM handle, which either queues it for
// the teardown to release unrun or refuses it and runs the job's release path.
auto* job = new JSCDeferredWorkTask(WTF::move(ticket), WTF::move(task));
Bun__queueJSCDeferredWorkTaskConcurrently(clientData->vmHandle, job, loopKind);
ticket.setEmbedderData(data);
}
Comment thread
claude[bot] marked this conversation as resolved.
Resolved

// The ticket stops being pending here or in runPendingWork, never both.
void JSCTaskScheduler::onCancelPendingWork(WebCore::JSVMClientData* clientData, Ticket& ticket)
{
auto* vmHandle = clientData->vmHandle;
auto& scheduler = clientData->deferredWorkTimer;

Locker<Lock> holder { scheduler.m_lock };
bool wasKeepingAlive = dropPendingTicketLocked(scheduler, &ticket);
holder.unlockEarly();
if (wasKeepingAlive)
Bun__VmHandle__refKeepAlive(vmHandle, BunLoopKind::Regular, -1);
releaseEventLoopRef(clientData, ticket);
}

static void runPendingWork(const ::BunVmHandleRef* vmHandle, Bun::JSCTaskScheduler& scheduler, JSCDeferredWorkTask* job)
void JSCTaskScheduler::onScheduleWorkSoon(WebCore::JSVMClientData* clientData, Ref<Ticket>&& ticket, Task&& task)
{
Locker<Lock> holder { scheduler.m_lock };
bool wasPending = scheduler.m_pendingTicketsKeepingEventLoopAlive.remove(job->ticket.ptr());
if (!wasPending) {
wasPending = scheduler.m_pendingTicketsOther.remove(job->ticket.ptr());
} else {
Bun__VmHandle__refKeepAlive(vmHandle, BunLoopKind::Regular, -1);
}
holder.unlockEarly();

// Deferred work runs script (FinalizationRegistry callbacks, wasm
// completions); not once the VM's stop was requested. Like any other
// event-loop callback boundary, an exception a task lets escape is
// reported as uncaught here rather than left on the VM for the next entry.
if (wasPending && !job->ticket->isCancelled() && Bun__VmHandle__scriptAllowed(vmHandle)) {
auto& vm = job->vm();
auto* globalObject = job->ticket->target()->globalObject();
auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm);
job->task(job->ticket.get());
if (auto* exception = scope.exception(); exception && !vm.hasPendingTerminationException()) {
scope.clearException();
Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception);
BunLoopKind loopKind = loopKindOf(ticket.get());
auto* job = new JSCDeferredWorkTask(clientData, WTF::move(ticket), WTF::move(task));
Bun__queueJSCDeferredWorkTaskConcurrently(clientData->vmHandle, job, loopKind);
}

static void runPendingWork(JSCDeferredWorkTask* job)
{
auto* clientData = job->clientData;
auto& vm = clientData->deferredWorkTimer.vm();
Ticket& ticket = job->ticket.get();

// False once a collection found the ticket's realm dead, or once an earlier job
// for the same ticket ran. Nothing the ticket points at may be read then.
Comment thread
robobun marked this conversation as resolved.
Resolved
if (vm.deferredWorkTimer->takePendingWork(ticket)) {
releaseEventLoopRef(clientData, ticket);

// Deferred work runs script (FinalizationRegistry callbacks, wasm
// completions); not once the VM's stop was requested. Like any other
// event-loop callback boundary, an exception a task lets escape is
// reported as uncaught here rather than left on the VM for the next entry.
Comment thread
robobun marked this conversation as resolved.
Resolved
if (Bun__VmHandle__scriptAllowed(clientData->vmHandle)) {
auto* globalObject = ticket.target()->globalObject();
auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm);
job->task(ticket);
if (auto* exception = scope.exception(); exception && !vm.hasPendingTerminationException()) {
scope.clearException();
Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception);
}
}
}

Expand All @@ -134,26 +116,24 @@ static void runPendingWork(const ::BunVmHandleRef* vmHandle, Bun::JSCTaskSchedul

extern "C" void Bun__runDeferredWork(Bun::JSCDeferredWorkTask* job)
{
auto& vm = job->vm();
auto clientData = WebCore::clientData(vm);

runPendingWork(clientData->vmHandle, clientData->deferredWorkTimer, job);
runPendingWork(job);
}

// Reclaim a queued-but-never-dispatched job during shutdown. Called while the
// JSC VM is still alive, so ~Ref<Ticket> and the captured Task lambda may
// safely touch TZone-allocated / JSC-owned state. Mirrors runPendingWork's
// ticket take() so the pending set and event-loop ref stay balanced.
// Release a queued job unrun during the teardown, on the VM's thread while the
// JSC VM and its event loop are alive. The job is consumed like a run one.
Comment thread
robobun marked this conversation as resolved.
Resolved
extern "C" void Bun__deleteDeferredWorkTask(Bun::JSCDeferredWorkTask* job)
{
if (auto* clientData = WebCore::clientData(job->vm())) {
auto& scheduler = clientData->deferredWorkTimer;
Locker<Lock> holder { scheduler.m_lock };
bool wasKeepingAlive = dropPendingTicketLocked(scheduler, job->ticket.ptr());
holder.unlockEarly();
if (wasKeepingAlive)
Bun__VmHandle__refKeepAlive(clientData->vmHandle, BunLoopKind::Regular, -1);
}
auto* clientData = job->clientData;
Ticket& ticket = job->ticket.get();
if (clientData->deferredWorkTimer.vm().deferredWorkTimer->takePendingWork(ticket))
releaseEventLoopRef(clientData, ticket);
delete job;
}
Comment thread
robobun marked this conversation as resolved.
Resolved

// Drop a job the VM handle refused because it had already closed. Any thread.
// The ticket stays pending, and the teardown cancels it with the VM.
Comment thread
robobun marked this conversation as resolved.
Resolved
extern "C" void Bun__discardDeferredWorkTask(Bun::JSCDeferredWorkTask* job)
{
delete job;
}

Expand Down
36 changes: 15 additions & 21 deletions src/jsc/bindings/JSCTaskScheduler.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,29 @@ class JSVMClientData;

namespace Bun {

// Bun's side of JSC's DeferredWorkTimer (the hook contract is described in
// DeferredWorkTimer.h): each task runs from the event loop that was current
// when its work was registered, and a pending ImminentlyScheduled ticket holds
// the event loop open. The VM handle decides what becomes of a posted task
// (run, released unrun by the teardown, or refused once the VM has closed), so
// there is no state here.
Comment thread
robobun marked this conversation as resolved.
Resolved
class JSCTaskScheduler {
WTF_MAKE_NONCOPYABLE(JSCTaskScheduler);

public:
JSCTaskScheduler()
: m_pendingTicketsKeepingEventLoopAlive()
, m_pendingTicketsOther()
explicit JSCTaskScheduler(JSC::VM& vm)
: m_vm(vm)
{
}

static void onAddPendingWork(WebCore::JSVMClientData* clientData, Ref<JSC::DeferredWorkTimer::Ticket>&& ticket, JSC::DeferredWorkTimer::WorkType kind);
JSC::VM& vm() const { return m_vm; }

static void onAddPendingWork(WebCore::JSVMClientData* clientData, JSC::DeferredWorkTimer::Ticket& ticket);
static void onScheduleWorkSoon(WebCore::JSVMClientData* clientData, Ref<JSC::DeferredWorkTimer::Ticket>&& ticket, JSC::DeferredWorkTimer::Task&& task);
static void onCancelPendingWork(WebCore::JSVMClientData* clientData, JSC::DeferredWorkTimer::Ticket& ticket);

// Set once the owning VM's event loop has taken its last tick. After this,
// onScheduleWorkSoon drops the task up front instead of posting it (~VM ->
// WaiterListManager::unregister reaches it for every still-pending
// Atomics.waitAsync ticket). An early-out, not a fence: a post that races
// this is handled by the VM handle (released unrun by the teardown, or refused).
void markShuttingDown()
{
Locker<Lock> holder { m_lock };
m_isShuttingDown = true;
}

public:
Lock m_lock;
bool m_isShuttingDown WTF_GUARDED_BY_LOCK(m_lock) { false };
// Value: the loop that was current when JSC registered the work; its completion is posted there.
UncheckedKeyHashMap<Ref<JSC::DeferredWorkTimer::Ticket>, BunLoopKind> m_pendingTicketsKeepingEventLoopAlive;
UncheckedKeyHashMap<Ref<JSC::DeferredWorkTimer::Ticket>, BunLoopKind> m_pendingTicketsOther;
private:
JSC::VM& m_vm;
};

}
4 changes: 1 addition & 3 deletions src/jsc/bindings/ZigGlobalObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4296,10 +4296,8 @@ void GlobalObject::prepareForDestruction()
nextTickQueue->discard(vm);

// Tell cross-thread posters not to bother from here (what still lands is queued and released
// unrun by the teardown, or refused once the VM handle closes). DeferredWorkTimer is fenced
// separately because finalizers during the final collection and ~VM both reach scheduleWorkSoon().
// unrun by the teardown, or refused once the VM handle closes).
context->markTerminating();
WebCore::clientData(vm)->deferredWorkTimer.markShuttingDown();

// WorkerOrWorkletGlobalScope::prepareForDestruction(): stop every ActiveDOMObject (workers are
// asked to terminate, ports/channels/sockets close without dispatching) and strip listeners,
Expand Down
Loading