Commit Graph
217 Commits
Author SHA1 Message Date
SGavrl caf51975bb Merge remote-tracking branch 'origin/main' into maintainer/pr-508-review 2026-08-01 15:28:07 +00:00
SGavrl 3f5dd23778 Merge remote-tracking branch 'origin/main' into maintainer/pr-510-review 2026-08-01 15:28:02 +00:00
SGavrl d93ff517fc fix(js): keep performance clock bounded 2026-08-01 15:20:35 +00:00
SGavrl bb21452f90 Merge pull request #532 from alectimison-maker/fix/dynamic-script-data-urls
fix(js): execute dynamic data URL scripts
2026-07-31 17:43:45 +00:00
SGavrl 0ff1ba0ea9 fix(js): match event constructor semantics 2026-07-30 21:43:33 +00:00
SGavrl f84120591e fix(js): match Chromium data URL handling 2026-07-30 21:40:13 +00:00
alectimison-maker 0c4740a86a fix(js): execute dynamic data URL scripts
Decode JavaScript data URLs inside the serialized classic-script queue so each body runs before its load event advances a nested chain.

Fixes #520.
2026-07-31 04:44:29 +08:00
c.kai 776c915c8c fix(js): add global PromiseRejectionEvent and StorageEvent constructors
Obscura lacked these two standard event constructors on globalThis.
The missing PromiseRejectionEvent caused core-js to misdetect the
environment and override native Promise with a broken polyfill,
breaking Vue application rendering. StorageEvent completes the
localStorage/sessionStorage API surface.

Both are also registered in document.createEvent() for legacy callers.

Fixes #521, fixes #522.
2026-07-27 14:47:48 +08:00
hol"mo"fy 3d39e59c14 Update bootstrap.js 2026-07-26 20:57:02 +08:00
Andrey Mnatsakanov a663a15556 fix(js): case-insensitive insertAdjacentHTML that keeps table/select fragments
Two defects: the position keyword was matched case-sensitively with no default
(so 'BeforeEnd' inserted nothing and an invalid position silently no-oped
instead of throwing SyntaxError), and fragments were parsed inside a throwaway
<div>, so context-sensitive children (<tr>, <td>, <option>, …) were dropped.

Lowercase the position and throw SyntaxError on an unknown value; parse via a
table/select-aware wrapMap (chosen from the fragment's leading tag, falling back
to the insertion context) so rows/cells/options survive.

Fixes #495
2026-07-26 13:30:07 +02:00
Andrey Mnatsakanov 1fc5a24e98 fix(js): keep the current URL when pushState/replaceState omit url
resolveOrFallback(undefined) returned undefined, so applyVirtual set
__virtualUrl to null and location reverted to the document URL. Per the HTML
spec a missing url keeps the current document URL; return __currentUrl() so the
new history entry captures the current location instead of clearing it.

Fixes #496
2026-07-26 13:30:07 +02:00
Andrey Mnatsakanov 499a5ef983 fix(js): coerce non-string primitives in before/after/replaceWith
before(), after() and replaceWith() only converted string arguments to Text
nodes; other primitives (numbers, booleans, null) were passed straight to
insert_before with no _nid, corrupting the call. Route them through the shared
_convertNodes helper (the same path append/prepend use), so every non-node
argument becomes document.createTextNode(String(n)).

Fixes #498
2026-07-26 13:30:07 +02:00
Andrey Mnatsakanov cdab919599 fix(js): make performance.now() monotonic
The old sub-millisecond bump (_sub += 0.1) was uncapped, so after >10 calls in
one millisecond it exceeded 1.0; when the wall clock then ticked over and the
bump reset to 0, now() returned a value below the previous call. Track the last
returned value and never go below it (advance by a tiny epsilon when the clock
has not moved past it).

Fixes #497
2026-07-26 13:30:07 +02:00
Andrey Mnatsakanov 6314ecbf88 fix(js): make setAttributeNS/getAttributeNS/removeAttributeNS namespace-aware
The three NS methods ignored the namespace: setAttributeNS stored the qualified
name against the non-namespaced store and getAttributeNS/removeAttributeNS
looked up by local name only, so a namespaced attribute was not addressable by
(namespace, localName) — getAttributeNS(xlink, 'href') returned null.

Record the namespace on the attribute and add namespace-aware DOM ops that match
by (namespace, localName), where the local name is the attribute name after any
prefix (so both setAttributeNS-created 'xlink:href' and parser-created 'href'
resolve). The non-namespaced getAttribute/serialization paths are untouched, and
getAttributeNS(null, name) still reads plain attributes.

Fixes #503
2026-07-26 13:29:43 +02:00
SGavrl ab6fa0e39b fix(js): scope fetch clients to browser contexts 2026-07-26 10:20:25 +00:00
Andrey Mnatsakanov 491ecfb0f1 fix(js): clone nodes structurally instead of via div.innerHTML
cloneNode(true) round-tripped through a throwaway <div>'s innerHTML, so the
HTML fragment parser discarded elements that are not valid children of <div>
(<tr>, <td>, <option>, <tbody>, …) — createElement('tr').cloneNode(true)
returned null — and JS-set inline styles were lost. The shallow path also
re-derived attributes by regex-scraping serialized HTML.

Clone structurally with real DOM APIs: shallow-clone each node via
createElement/createElementNS plus a getAttributeNames() attribute copy (no
parsing context, every attribute preserved), then descend into children with an
explicit stack so a deep subtree cannot overflow the JS stack. Text, Comment,
DocumentFragment and <template> contents are handled.

Fixes #490
2026-07-26 11:09:42 +02:00
Andrey Mnatsakanov 80803cbec8 fix(js): sync el.style with the style attribute in both directions
CSSStyleDeclaration was backed by an in-memory _props map that was never
bridged to the element's style content attribute. Parsed inline styles were
invisible to el.style.*, setAttribute('style', …) did not update el.style, and
el.style.x = … never reached the attribute or serialization; getComputedStyle
read the same disconnected declaration.

Give the declaration its owner element: lazily pull the style attribute into
_props on read (skipping the reparse when unchanged) and push _props back to the
attribute on every mutation. Owner-less declarations (getComputedStyle fallback,
stylesheet rules) stay purely in-memory, so their behavior is unchanged.

Fixes #488
2026-07-26 11:04:02 +02:00
ulises2k 3a4e3f7d7d fix(js,dom): keep parsed SVG in the SVG namespace so href reflects an SVGAnimatedString
get namespaceURI() derived the namespace from the tag name and only ever
recognised the <svg> root, so every element parsed inside an <svg> subtree
reported the XHTML namespace. get href() gates the SVGAnimatedString
reflection on the SVG namespace, so for parsed content the gate never
opened: href returned a plain string and .animVal/.baseVal were undefined,
making new URL(el.href.animVal) throw. Only createElementNS-built elements
worked, because createElementNS records _ns explicitly.

The tree builder already applies the HTML5 foreign-content rules, so
QualName.ns is correct for parsed SVG; it just was not exposed to JS. Add a
namespace_uri DOM op and read it in get namespaceURI(), keeping _ns as the
override and the old tag-name heuristic as the fallback when no element name
is recorded. Namespaces are immutable, so the result is cached like _lname.

Fixes #484
2026-07-25 22:30:38 -03:00
SGavrl 0df158ec9d Merge remote-tracking branch 'origin/main' into pr471-push
# Conflicts:
#	crates/obscura-js/js/bootstrap.js
2026-07-24 15:19:29 +00:00
SGavrl 98f4efae1c Merge pull request #470 from mnaza/fix/window-scroll-468
fix(js): make window-level scrolling actually scroll (#468)
2026-07-24 14:31:00 +00:00
SGavrl 3f820c48c9 Merge pull request #458 from asap-cool/fix/scroll-event-on-assignment
fix(js): fire a scroll event when scrollTop/scrollLeft are assigned
2026-07-24 14:30:47 +00:00
SGavrl 4c8213a62c Merge pull request #479 from j00jun/fix/iframe-event-dispatch
fix(js): make iframe events reach registered listeners
2026-07-24 14:30:31 +00:00
SGavrl 85a6b7bc85 Merge pull request #477 from lisa0314/fix/474-dynamic-script-settle
fix(js): allow pending dynamic scripts to finish settling
2026-07-24 14:30:13 +00:00
SGavrl a9c25976b2 Merge pull request #476 from mnaza/fix/treewalker-parentnode-475
fix(js): implement DOM parentNode() traversal in TreeWalker (#475)
2026-07-24 14:29:57 +00:00
SGavrl 3c94d69870 Merge pull request #472 from mnaza/fix/node-iterator-467
fix(js): implement a real NodeIterator instead of aliasing TreeWalker (#467)
2026-07-24 14:29:41 +00:00
j00jun 2e3f5d84b1 fix(js): make iframe events reach registered listeners
_IframeDocument's addEventListener, removeEventListener, and dispatchEvent were
no-ops, so listeners registered on an iframe document never ran. Iframe load also
invoked the onload property directly, bypassing addEventListener("load", ...)
listeners.

Register and dispatch events on _IframeDocument, and fire the iframe load event
through the element so both the onload property and addEventListener listeners run.

Fixes #478.
2026-07-24 16:34:59 +09:00
lisa0314 a6bb7411b2 fix(js): let dynamic scripts finish settling
Keep pumping the event loop past the general 500ms settle limit only while the dynamic-script queue remains pending, with a 3s hard cap.

Add a deterministic CDP regression for direct and nested external scripts whose fetch crosses the original settle deadline.
2026-07-24 15:03:53 +08:00
Andrey Mnatsakanov 845abb96f7 fix(js): implement DOM parentNode() traversal in TreeWalker (#475)
parentNode() inspected only the immediate parent, which caused three
spec deviations:

- With currentNode at root, the `parent !== this.root` guard did not
  fire (parent is root's OWN parent), so a node OUTSIDE the walker's
  subtree was returned — createTreeWalker(root).parentNode() yielded
  <body>. A TreeWalker must never surface a node above its root.
- The same guard excluded root as a *result*, so when currentNode's
  accepted ancestor was root itself, parentNode() returned null instead
  of root.
- A skipped (or otherwise non-accepted) immediate parent ended the
  search rather than continuing up to the first accepted ancestor.

Replaced with the DOM 6.1 algorithm: climb from currentNode, returning
the first ancestor that filters to FILTER_ACCEPT, with the loop's
`node !== this.root` guard keeping the walk inside root while still
letting root be returned. Reuses the three-valued `_filter` from #461.

Last of the TreeWalker mover deviations, after nextNode (#461),
previousNode (#462) and the child/sibling movers (#469).
2026-07-24 08:59:36 +02:00
Andrey Mnatsakanov ab3ca26046 fix(js): implement a real NodeIterator instead of aliasing TreeWalker (#467)
createNodeIterator returned a TreeWalker with a flag on it. The two are
different interfaces, and the alias got two things wrong.

An iterator's pointer starts *before* its root, so the first nextNode()
returns the root itself; a TreeWalker starts *at* its root and never
returns it. So createNodeIterator(el, SHOW_ELEMENT) silently skipped
exactly the element it was rooted at:

    <div id=root><a></a></div>  ->  ["A"]  (Chrome: ["DIV","A"])

It also exposed the wrong surface: no referenceNode,
pointerBeforeReferenceNode or detach (older library code still calls
detach and hit "not a function"), while leaking currentNode, firstChild,
parentNode and the rest of TreeWalker's movers.

Implemented DOM 6.2 "traverse" directly. Keeping the pointer as
(referenceNode, pointerBeforeReferenceNode) rather than a single cursor
is what makes a direction change re-yield the current node instead of
stepping over it, and a failed traversal leaves both untouched so the
iterator stays resumable either way.

The backward step is a new `DomTree::prev_in_subtree`, mirroring
`next_in_subtree`. It can live entirely in the DOM layer because a
NodeIterator prunes nothing -- unlike the TreeWalker's backward walk
(#462), where FILTER_REJECT forces the filter to be consulted at every
step of the descent.

FILTER_REJECT still behaves as FILTER_SKIP here, which the existing test
keeps covering; its expectation gains the root node the alias used to
drop. The TreeWalker's now-dead `_rejectIsSkip` branch is removed.
2026-07-24 08:34:33 +02:00
Andrey Mnatsakanov 49d4b91348 fix(js): descend on FILTER_SKIP in TreeWalker child/sibling movers (#469)
firstChild/lastChild/nextSibling/previousSibling stepped straight to the
next sibling whenever a node was not accepted, so a FILTER_SKIP node hid
its children instead of exposing them:

    <div id=root><section><a></a></section></div>, SECTION -> FILTER_SKIP
    w.firstChild()   // null (Chrome: <a>)

They also used the boolean `_accept`, so FILTER_REJECT (which must not
descend) and FILTER_SKIP (which must) were indistinguishable — the same
root cause #461 fixed for nextNode().

Replaced all four with the spec's "traverse children" and "traverse
siblings" algorithms, parameterised by direction so forward and backward
share one implementation and cannot drift: `edge` is first/last child and
`step` is next/previous sibling. Both consult the three-valued `_filter`
from #461, so SKIP descends, REJECT prunes, and the climb back out stops
at root or the node the walk started from.

parentNode() is left alone here: its deviations are different (it checks
only the immediate parent, and excludes root, which the spec permits it
to return), so it belongs in its own change.
2026-07-24 08:33:01 +02:00
Andrey Mnatsakanov ae438e196b fix(dom): bridge <template> contents to JS so .content is not always empty (#463)
The parser already put template children in a separate contents document
per the HTML spec (tree_sink.rs), but nothing exposed that node, so
`get content()` fabricated an empty DocumentFragment and the parsed
markup was unreachable:

    document.body.innerHTML = '<template id=t><p>a</p></template>';
    t.content.childNodes.length   // 0 (Chrome: 1)
    t.innerHTML                   // "" (Chrome: '<p>a</p>')

Every <template>-driven UI — custom elements cloning
`template.content`, lit, Vue runtime templates, htmx fragments, Alpine
x-if/x-for — stamped nothing, with no error raised.

Three layers were involved:

- `DomTree::template_contents` returns the contents node, allocating one
  on demand so templates built via `createElement` work too. Exposed as
  the `template_contents` op; `get content()` wraps it as a
  DocumentFragment cached by node id, so `.content` keeps a stable
  identity across reads.

- `import_node_from` cloned the element data verbatim, carrying the
  *source* tree's contents NodeId into the destination tree, where it
  indexed whatever unrelated node occupied that slot. That is why a
  template injected via innerHTML resolved `.content` to a bogus node
  rather than merely an empty one. The contents are now imported into a
  fresh node and the reference remapped, queued onto the existing
  worklist so nested templates stay iterative.

- The serializer emitted a template's own (always empty) children, so
  contents vanished from outerHTML/innerHTML and `cloneNode(true)` —
  which round-trips through outer_html — produced an empty template.
  `content_source` resolves a template to its contents document for both
  inner_html and outer_html.

With serialization fixed on the Rust side, the `set innerHTML` template
branch that routed to `.content` now has a matching getter for free, so
no template special case is needed on the JS hot path.
2026-07-24 08:31:55 +02:00
Andrey Mnatsakanov f6ca133132 fix(js): make window-level scrolling actually scroll (#468)
window.scrollTo/scrollBy/scroll were empty stubs, scrollX/scrollY and
pageXOffset/pageYOffset were hard-coded 0 data properties, no scroll
event was ever fired at the window, and document.scrollingElement did
not exist.

that "scroll-driven lazy loaders advance instead of throwing". But the
dominant infinite-scroll idiom is window-level:

    window.scrollTo(0, document.body.scrollHeight);
    if (window.scrollY + innerHeight >= body.scrollHeight) loadMore();

all of which was inert -- the call succeeded, the offset never moved, the
sentinel never tripped -- so automation that scrolls a page to trigger
loading silently got one screenful.

The offset is stored on the scrolling element rather than in separate
window state, so window.scrollY and document.scrollingElement.scrollTop
are two views of one value in both directions, which is what pages
assume when they mix the two. scrollX/scrollY/pageXOffset/pageYOffset
become read-only accessors over it, matching a real Window, and
document.scrollingElement returns documentElement (standards mode; we
never parse in quirks mode).

The scroll event is dispatched at both the document and the window.
Chrome propagates a page scroll to both, but Document.dispatchEvent here
runs only its own listeners and does not propagate, so firing once would
strand half the listeners.

As with #431 there is no layout, so the offset still cannot be clamped to
a real maximum; negative values clamp to 0 exactly as they do for
elements.
2026-07-24 08:30:43 +02:00
SGavrl 485497393d Merge pull request #465 from mnaza/fix/treewalker-previousnode-462
fix(js): walk reverse document order in TreeWalker.previousNode() (#462)
2026-07-23 10:32:49 +00:00
lisa0314 a440b5646c test(js): initialize runtime fixtures before evaluation 2026-07-23 10:58:29 +08:00
Andrey Mnatsakanov a8c0a19f67 fix(js): walk reverse document order in TreeWalker.previousNode() (#462)
previousNode() considered exactly one candidate — the previous sibling's
deepest last child — and returned null as soon as the filter rejected it,
so a backward walk died mid-tree. This is the backward mirror of #432,
which was fixed forward-only in #447.

    <div id=root><a><b></b></a><c></c></div>, SHOW_ELEMENT, B -> FILTER_SKIP
    forward:  ["A","C"]   backward: [] (Chrome: ["A"])

Replaced with the DOM 6.1 algorithm verbatim: scan previous siblings,
descend to the deepest last descendant of each, then step up to the
parent, continuing until a node is accepted or root is reached. The
descent stops on FILTER_REJECT, which is what makes rejection prune
subtrees in the backward direction too.

Unlike nextNode this stays in JS rather than adding a DOM traversal op:
the descent has to consult the filter at every step to honour REJECT, so
there is no run of JS/native crossings for a native helper to collapse.

Depends on the three-valued `_filter` from #461.
2026-07-22 18:59:10 +02:00
Andrey Mnatsakanov 1a5c27a592 fix(js): honour NodeFilter.FILTER_REJECT in TreeWalker (#461)
`_accept` collapsed every non-ACCEPT filter result into a single boolean,
so FILTER_REJECT behaved exactly like FILTER_SKIP: the node was not
returned, but the walk still descended into its subtree. Per DOM 6.1,
FILTER_REJECT rejects a node *and* its descendants.

This was unreachable until #440 exposed the real NodeFilter constants,
which made the canonical prune idiom

    if (node.tagName === 'SCRIPT') return NodeFilter.FILTER_REJECT;

start yielding nodes from inside the pruned subtree instead of failing
loudly — sanitizers and text extractors built on TreeWalker silently got
extra content.

Split the filter into a three-valued `_filter` (keeping `_accept` as the
boolean wrapper the sibling/child movers use) and give nextNode a second
traversal step for the reject case. That step lives in the DOM layer as
`DomTree::next_after_subtree`, mirroring how #447 put `next_in_subtree`
there: both share the ancestor climb, and REJECT costs no more JS/native
crossings than SKIP.

NodeIterator shares this walker but has no subtree pruning (DOM 6.2:
FILTER_REJECT behaves as FILTER_SKIP), so `createNodeIterator` marks the
walker `_rejectIsSkip` and keeps the plain step. Covered by its own test
so the shared implementation cannot drift.
2026-07-22 18:57:00 +02:00
sycatle 1c7402dfac fix(js): fire a scroll event when scrollTop/scrollLeft are assigned
scrollTo/scrollBy dispatch a scroll event; direct assignment did not. So the
usual lazy-load idiom

    el.addEventListener('scroll', loadMore);
    el.scrollTop = el.scrollHeight;

never reached the listener, and scroll-driven feeds stalled after their first
batch with no error to show for it.

Checked against a real Chrome over CDP, same probe on the same page:

    Chrome            el.scrollTop = 100 -> 1 scroll event
    obscura (before)  el.scrollTop = 100 -> 0 scroll events
    obscura (after)   el.scrollTop = 100 -> 1 scroll event

The setters only fire when the offset actually changes, so a loader that
rewrites the same scrollTop each frame does not re-enter. scrollTo/scrollBy
suppress the per-axis events and emit one for the whole movement, the way a
browser reports one scroll per operation rather than one per axis.

The offset stays unclamped on purpose. Without layout, any maximum is a guess,
and a guess derived from the synthetic scroll box pins scrollTop at 0, which
deadlocks the very loaders this unblocks: no scroll, no content, no scroll.
Geometry is left alone here — this is event dispatch only.

Tests: 4 of the 5 new cases fail without this change (the scrollTo coalescing
one passes either way; it guards against this change turning one event into
three). nextest -p obscura-cdp 91/91, obstacle course 33/33.
2026-07-22 14:59:11 +02:00
SGavrl fc9f5248f7 Fix NetworkInformation event listeners 2026-07-22 10:29:51 +00:00
SGavrl 730bd01f57 Merge pull request #454 from lisa0314/fix/452-document-fragment-insertion
fix(js): flatten DocumentFragment during node insertion
2026-07-22 10:13:02 +00:00
lisa0314 c4f545e68b fix(js): flatten DocumentFragment during node insertion
Move fragment children into the target for appendChild, insertBefore, and replaceChild while preserving their order and emptying the fragment.

Add runtime regression coverage for the DOM attachment portion of #452.
2026-07-21 18:17:40 +08:00
sycatle 319c6034fe revert(js): drop the "cancellation-safe" event loop pumping
The premise was wrong. deno_core 0.350's run_event_loop is:

    pub async fn run_event_loop(&mut self, opts) -> Result<(), CoreError> {
        poll_fn(|cx| self.poll_event_loop(cx, opts)).await
    }

so replacing `timeout(budget, run_event_loop())` with a hand-rolled
`poll_fn(poll_event_loop)` loop is the same code with the same cancellation
semantics, not safer ones. The mechanism I described cannot happen either:
`poll` is a synchronous `&mut self` call, a future can only be dropped between
polls, and the HandleScope/ContextScope live inside poll_event_loop as stack
locals that are destroyed before it returns. The isolate is exited between
polls both before and after that change.

What actually fixes #430 is confining each connection's isolates to their own
OS thread, which the next commits do. The regression test still reproduces the
abort on the pre-thread-per-connection tree and still passes here without this
commit, so nothing depended on it.

It was not merely a no-op. Two regressions it introduced:

- resolve_promises_until inverted the meaning of tick_ms. It was "pump the
  event loop for up to tick_ms, returning early on idle"; it became one single
  poll followed by an unconditional sleep(tick_ms). With the 1->50ms backoff
  that settles at 20 event-loop turns per second, so an awaited promise chain
  needing many turns crawls, and past the budget the caller silently reads a
  stale __obscura_await_meta.
- drive_module_eval replaced `e?` with `let _ = poll_event_loop(...)`,
  discarding event-loop errors. A module stalled on a top-level await used to
  exit immediately with a logged error; it instead spun for the whole budget
  logging nothing -- the exact #374 starvation the surrounding comment claims
  to prevent.

Keep the drive_module_eval extraction (it genuinely dedupes load_module and
load_inline_module) but restore the original select!-based body.

Refs #430.
2026-07-20 18:41:00 +02:00
sycatle 1f5bffc4de refactor: drop dead code and refresh comments after the per-connection move
- Delete the now-unused CdpContext::new_full (its only caller switched to
  new_with_shared_context); have _new_inner build the shared BrowserContext and
  delegate, so the struct's field list lives in one place.
- Extract drive_module_eval, shared verbatim by load_module and
  load_inline_module.
- Update comments that still described the deleted process-wide V8 lock and the
  single-LocalSet model to the per-connection reality, drop the dangling
  reference to the removed obscura-js/src/v8_lock.rs, and soften the
  ISOLATE_CREATE_LOCK note now that the main-thread warmup is the real guard
  against the InitializeBuiltinJSDispatchTable segfault.

Refs #430.
2026-07-20 16:37:51 +02:00
sycatle 9d2f9f23ee fix(js): track one watchdog slot per connection
The per-command V8 watchdog kept a single global slot whose correctness relied
on the old process-wide lock (at most one command armed at a time). With
thread-per-connection several connections arm concurrently, so a second arm
overwrote the first's slot and left that command's isolate unbounded. Key the
armed slots by generation so each connection's command has its own; the worker
fires whichever have overrun and terminates each via its IsolateHandle.

Refs #430.
2026-07-20 16:37:50 +02:00
sycatle 9065f383de perf(cdp): scope the V8 lock per connection to run connections in parallel
With every connection on its own thread (previous commit), the process-wide V8
lock served no safety purpose across connections yet forced all of them to
serialize on one mutex, so throughput did not scale with concurrency. Move the
lock into CdpContext so each connection has its own: a connection's nav task and
command dispatch still coordinate on one page's isolate at a time on its thread,
but different connections no longer block each other. Delete the now-unused
process-wide obscura_js::v8_lock.

Measured on a local CPU-bound fixture, page throughput stays flat (~75/s from
concurrency 1 to 8) with the global lock and scales to about 2.5x at concurrency
8 with the per-connection lock, with no crash at any concurrency. Obstacle
course 33/33, obscura-cdp 76/76.

Refs #430.
2026-07-20 16:37:50 +02:00
sycatle 76fc3b9cae fix(cdp): run each connection on its own thread to confine V8 isolates
The CDP server ran every connection's pages on one LocalSet thread, so two
pages' V8 isolates shared an OS thread. Under concurrent navigation a cancelled
event-loop future could leave one isolate entered while another page's
execute_script ran, tripping V8's heap->isolate() == Isolate::TryGetCurrent()
check and aborting the process (#430). No single-thread serialization fixes
this reliably: the cancellation-leak pattern is pervasive and partly inside
deno_core.

Give each WebSocket connection its own OS thread with its own current_thread
runtime, LocalSet, and CdpContext, so every page's isolate is confined to one
thread. V8's invariant is per-thread, so two connections' isolates can never
collide. This is the "Option 2" fix named in obscura-js/src/v8_lock.rs, and it
also lets connections run in parallel instead of serializing on one thread.

- CdpContext::new_with_shared_context reuses one Arc<BrowserContext> across all
  connections, so they share one cookie jar and one HTTP client.
- A single signal watcher flips the accept flag and wakes every connection's
  processor via notify_waiters(); the accept side persists cookies once on
  shutdown.
- Serialize isolate construction with a process-wide lock: the leaptiering
  JSDispatchTable setup is not safe to run from several threads at once.
- Warm up V8 once on the main thread before any connection thread creates an
  isolate; creating the first isolate off the main thread segfaults in
  InitializeBuiltinJSDispatchTable.

obstacle course 33/33, obscura-cdp 75/75 including the concurrent-navigation and
control-plane tests. Concurrency 2 and 4 against heavy pages no longer abort.

Fixes #430.
2026-07-20 16:37:50 +02:00
sycatle 45df429d99 fix(js): drive the event loop cancellation-safely to avoid isolate leaks
Several event-loop operations bounded their runtime with
tokio::time::timeout(budget, run_event_loop()) or a select! against a
timer. When the timer wins, tokio drops the run_event_loop future
mid-poll; deno_core enters the isolate while polling, so the drop leaves
the isolate entered on the OS thread. Under concurrent CDP work a second
page's execute_script then trips heap->isolate() == TryGetCurrent() and
aborts the process (#430).

Drive the loop with poll_event_loop inside a poll_fn instead. It creates
and drops its own isolate scope per call, so stopping between ticks never
leaves the isolate entered. Convert resolve_promises_until,
run_event_loop_bounded, resolve_promises, the mod_evaluate settle in
load_module and load_inline_module, and the two navigation settle loops
in page.rs. Add poll_event_loop_once as the cancellation-safe single-tick
primitive the settle loops use.

Keeps the obstacle course at 33/33. This is hardening toward #430; the
residual concurrency abort still needs the thread-per-runtime refactor
that v8_lock.rs calls the "Option 2" fix.

Refs #430.
2026-07-20 16:37:50 +02:00
SGavrl c12915a1a6 fix(js): keep TreeWalker traversal in document order (#432) 2026-07-19 19:40:27 +00:00
Charlie /andsycatle 29e20ae970 fix(js): implement Element scrollBy/scrollTo/scroll and functional scrollTop/scrollLeft (#431)
Element.prototype.scrollBy, scrollTo and scroll were undefined; they only
existed as no-ops on the window object. Scroll-driven infinite-scroll code
therefore threw "scrollBy is not a function", and scrollTop/scrollLeft were
no-op stubs that always read back 0.

Track a per-element scroll offset so scrollTop/scrollLeft round-trip, add the
three methods accepting either (x, y) or a ScrollToOptions object, and fire a
scroll event so listeners that drive lazy loading can advance. There is no
layout engine, so the offset is not clamped to a real overflow maximum.

Register the new methods in the native-function list so their toString stays
masked.

Fixes #429.

Co-authored-by: sycatle <[email protected]>
2026-07-19 19:12:10 +00:00
Andrey Mnatsakanov c3ae054dde fix(js): give NodeFilter the full standard constant set (#439) (#440)
NodeFilter was defined twice: a partial live definition (SHOW_ELEMENT,
SHOW_TEXT, SHOW_ALL) and a complete one behind an `if (typeof NodeFilter
=== 'undefined')` guard that never fired because the partial one already
defined it. So NodeFilter.FILTER_ACCEPT/REJECT/SKIP and most SHOW_* values
were undefined at runtime, and the canonical filter idiom
`acceptNode() { return NodeFilter.FILTER_ACCEPT; }` returned undefined —
making TreeWalker/NodeIterator reject every node.

Make the live definition authoritative with the full standard constant set
and drop the dead duplicate. Adds tests asserting the constant values and
that a filter returning NodeFilter.FILTER_ACCEPT accepts the node.
2026-07-19 19:11:48 +00:00
Andrey MnatsakanovandSGavrl b2e4bb49a7 fix(js): structuredClone cycles through Error.cause and clones own __proto__ (#419, #420) (#421)
* fix(js): structuredClone cycles through Error.cause and clones own __proto__ (#419, #420)

The structuredClone polyfill had two correctness/parity gaps in _structuredClone:

- The Error branch recursed into `cause` before recording the clone in `seen`,
  so a cycle through the error (e.cause === e) recursed until the stack
  overflowed. Chrome clones such graphs and preserves identity. Fix: seen.set
  the clone before recursing into cause. (#419)

- The generic-object branch built the clone on the source's prototype and
  copied properties by assignment, so an own enumerable `__proto__` data
  property (exactly what JSON.parse('{"__proto__":…}') produces) went through
  the inherited __proto__ setter and reparented the clone instead of copying
  the property. Fix: clone plain objects onto Object.prototype (like Chrome)
  and define each property instead of assigning it. (#420)

Adds two parity tests to structured_clone_crypto_parity.rs; full obscura-cdp
suite (73) green.

* perf(js): keep structuredClone fast path for all keys but __proto__

The own-__proto__ fix copied every own property with Object.defineProperty
to avoid triggering the inherited prototype setter. That is ~3x slower
than plain assignment per key, so cloning a large object regressed
(~75ms -> ~228ms for a 1000-key object x200).

Only the __proto__ key needs defineProperty; every other key is safe to
assign. Special-case it so the fast path applies to all other keys.
Measured back to parity with main (85ms vs 85ms); the own-__proto__
correctness test still passes.

---------

Co-authored-by: SGavrl <[email protected]>
2026-07-16 12:56:10 +02:00