2 Commits
Author SHA1 Message Date
ShubhamandCloakHQ 9fb05e0fc8 fix(download): make concurrent first-run downloads safe (#555)
* fix(download): make concurrent first-run downloads safe

_extract_archive deleted the install dir and then extracted into it in
place. When several processes or threads start on an empty cache, a
second caller can delete the first caller's install mid-extraction, or
pass the "binary exists and is executable" check on a binary that is
still being written. On Linux that surfaces as ETXTBSY on spawn; on
macOS as dlopen failures and "Directory not empty", and the cache can
be left permanently incomplete.

Extract into a uniquely named sibling staging dir and rename it into
place. If the destination already holds a complete install, another
caller won and the staged copy is discarded; an incomplete leftover is
still replaced as before.

* fix(js): make concurrent first-run downloads safe

Same race as the Python wrapper: extractArchive deleted the install dir
and extracted into it in place, so concurrent ensureBinary() calls on an
empty cache could delete each other's install or return a binary that
was still being written (spawn ETXTBSY on Linux).

Extract into a uniquely named sibling staging dir and rename it into
place, keeping an existing complete install if another caller won.
Also name the temporary archive with randomUUID() instead of
Date.now(), which collided for calls made in the same millisecond.

* fix(dotnet): make concurrent first-run downloads safe

Same race as the Python and JS wrappers: ExtractArchive deleted the
install dir and extracted into it in place, so concurrent
EnsureBinaryAsync() calls on an empty cache failed with "already
exists" / DirectoryNotFoundException or saw a half-written install.

Extract into a uniquely named sibling staging dir and move it into
place, keeping an existing complete install if another caller won.

* fix(download): replace a partial install safely under concurrent callers

When an incomplete install dir was left over from an interrupted run,
every concurrent caller saw it as incomplete and deleted it in place.
The first to finish renamed its staging dir into place; the others'
unguarded second rename then failed (ENOTEMPTY, FileNotFoundError,
DirectoryNotFoundException), and a late delete could remove files from
an install another caller had just moved in. With 8 concurrent callers,
7 raised in each round.

Rename the leftover aside to a unique <install>.stale-<id> dir and
delete it from there, then retry the staging rename a bounded number
of times, keeping a complete install if another caller got there
first. Applies to the Python, JS and .NET clients, each with a
regression test that starts several concurrent callers on a partial
install. The JS test uses separate processes, since callers in one
process cannot interleave inside the synchronous rename block.

* fix(download): retry directory renames Windows transiently denies

Antivirus and indexers on Windows briefly hold freshly extracted files
open, which fails the directory rename with access denied. Retry that
rename for about 2.5 seconds while the destination does not exist, in
all three clients. Cleanup of the staging and stale dirs is now best
effort so a failed delete never masks the error being raised.

---------

Co-authored-by: CloakHQ <cloakhq@pm.me>
2026-10-08 00:53:57 +02:00
Shubham 65b23cac51 fix(humanize): scroll the x axis in scroll-into-view (#521) (#554)
* fix(humanize): scroll the x axis in scroll-into-view (#521)

humanScrollIntoView only measured and scrolled the y axis. A target past
the right edge of a horizontally overflowing page counted as in view, so
no wheel events were sent, the click point landed offscreen and the
pointer check failed with "element is covered by <none>" after the full
timeout.

After the vertical pass, a horizontal pass now checks that the box fits
inside the viewport width and, if not, centres it with the same
humanized wheel burst on the x axis. It skips the scroll when the page
is already pinned at the needed horizontal boundary, mirroring the y
pinned check. Applied to Python (sync and async), JS Playwright,
Puppeteer and .NET.

.NET: IRawScrollPage.GetScrollStateAsync now also returns X and MaxX.

Fixes #521

* fix(humanize): handle RTL scrollX in the horizontal pinned check (#521)

In right-to-left documents Chrome's window.scrollX runs from 0 down to
-(scrollWidth - clientWidth). The x pass treated "scrollX <= 0" as pinned
at the left edge, so on an RTL page at its start a target in the left
overflow was never scrolled into view and the click failed with "covered
by <none>".

The scroll-state script now also reads the viewport direction (taken from
<body> when present, as Chrome does) and returns an RTL-aware minX and
maxX. Python, JS Playwright and .NET compare against those. The .NET
adapter shifts X and MaxX by minX so IRawScrollPage keeps its tuple shape
and X still runs 0..MaxX.

Puppeteer has no pinned check on the x axis and is unchanged.

* fix(humanize): skip the x pass for sub-pixel distances

A box wider than the viewport never passes the containment check, so
every action on it ran the x pass even when it was already centred: a
mouse move to the scroll area and a 0 or 1px wheel. Skip the pass when
the distance to centre is under 1px. Applied to Python (sync and async),
JS Playwright, Puppeteer and .NET.

* test(humanize): reword the set_content note on the #521 browser tests

The comment stated as fact that set_content never settles in
CloakBrowser. What I observed is narrower: it timed out in local runs of
these tests, so say that instead.
2026-09-29 22:43:02 +02:00