repld-tool 0.2.2__tar.gz → 0.2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {repld_tool-0.2.2 → repld_tool-0.2.4}/PKG-INFO +1 -1
- {repld_tool-0.2.2 → repld_tool-0.2.4}/pyproject.toml +1 -1
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/bridge.py +98 -1
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/tab.py +39 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/help.py +13 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/kernel.py +31 -1
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/paths.py +7 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/runtime.py +23 -7
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/state.py +39 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/tasks.py +6 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/LICENSE +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/README.md +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/.ruff_cache/.gitignore +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/.ruff_cache/0.15.22/14226964882126921391 +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/.ruff_cache/CACHEDIR.TAG +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/__init__.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/__main__.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/bg.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/bind.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/bridge_tools.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/__init__.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/browser.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/capture.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/cdp.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/har.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/observe.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/pin.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/png.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/pool.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/row.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/selector.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/session.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/tab_query.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser/target.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/browser_dispatch.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/channel.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/cli.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/cli_args.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/core_schemas.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/dashboard.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/dashboard_cmd.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/dashboard_html.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/display.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/eventlog.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/events.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/exec_cmd.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/gate_cmd.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/gates.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/gist_api.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/gist_cmd.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/gist_deps.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/gist_links.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/gist_lint.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/gists.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/ipc.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/kernel_context.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/lifecycle_cmd.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/log_cmd.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/protocol.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/relaunch.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/render.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/sessions.py +0 -0
- {repld_tool-0.2.2 → repld_tool-0.2.4}/src/repld/spawn.py +0 -0
|
@@ -34,10 +34,12 @@ Two rules that fall out of running arbitrary user code:
|
|
|
34
34
|
"""
|
|
35
35
|
|
|
36
36
|
import json
|
|
37
|
+
import os
|
|
37
38
|
import socket
|
|
38
39
|
import sys
|
|
39
40
|
import threading
|
|
40
41
|
import time
|
|
42
|
+
import uuid
|
|
41
43
|
from pathlib import Path
|
|
42
44
|
|
|
43
45
|
from . import (
|
|
@@ -83,6 +85,68 @@ BRIDGE_INIT_ID = "repld-bridge-init"
|
|
|
83
85
|
# intercept declines exactly the gist and browser URIs, which do need one.
|
|
84
86
|
_NEEDS_KERNEL = frozenset({"tools/call", "resources/read"})
|
|
85
87
|
|
|
88
|
+
|
|
89
|
+
def _ephemeral_socket_path() -> Path:
|
|
90
|
+
"""A private, single-use socket path for `repld bridge --ephemeral`.
|
|
91
|
+
|
|
92
|
+
Deliberately outside PROJECTS_DIR: that tree is addressed by cwd
|
|
93
|
+
(`paths.project_dir`) precisely so a second bridge for the same project
|
|
94
|
+
attaches to the *same* kernel — the opposite of what --ephemeral
|
|
95
|
+
promises. A per-invocation subdirectory under RUNTIME_DIR, keyed on this
|
|
96
|
+
bridge's own pid plus a random suffix, guarantees no other bridge —
|
|
97
|
+
ephemeral or not — ever resolves to the same path, and the whole
|
|
98
|
+
directory is one `rmtree` for `_teardown_ephemeral` to remove on the way
|
|
99
|
+
out. That matters because neither `state.sweep_dead_pid_files` nor
|
|
100
|
+
`state.sweep_dead_project_dirs` would ever see it: the first only reaches
|
|
101
|
+
RUNTIME_DIR's flat `{pid}-…` files, the second only PROJECTS_DIR.
|
|
102
|
+
|
|
103
|
+
Two separate mkdir calls, not one `parents=True`: RUNTIME_DIR itself is
|
|
104
|
+
already 0700 (`ensure_runtime_dir`), but `mkdir(parents=True, mode=...)`
|
|
105
|
+
applies the mode only to the leaf it creates — the exact footgun
|
|
106
|
+
`ensure_runtime_dir`'s own docstring warns about — so an `ephemeral/`
|
|
107
|
+
parent brought into being implicitly would come out at umask default.
|
|
108
|
+
Each call here has an already-existing direct parent, so its `mode` is
|
|
109
|
+
the one that actually lands.
|
|
110
|
+
"""
|
|
111
|
+
paths.ensure_runtime_dir()
|
|
112
|
+
root = paths.RUNTIME_DIR / "ephemeral"
|
|
113
|
+
root.mkdir(exist_ok=True, mode=0o700)
|
|
114
|
+
d = root / f"{os.getpid()}-{uuid.uuid4().hex[:8]}"
|
|
115
|
+
d.mkdir(mode=0o700)
|
|
116
|
+
return d / "kernel.sock"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _teardown_ephemeral(sock_path: Path, pid: int | None) -> None:
|
|
120
|
+
"""Kill the one-off kernel `--ephemeral` spawned, then remove its directory.
|
|
121
|
+
|
|
122
|
+
SIGTERM first, so `_shutdown`'s `@every`/`defer()` drain gets its usual
|
|
123
|
+
2s budget — the same courtesy `restart_kernel` extends the kernel it
|
|
124
|
+
replaces. SIGKILL only if it's still alive after `WAIT_STEPS` ticks: an
|
|
125
|
+
ephemeral kernel outliving the bridge that owns it is exactly the leak
|
|
126
|
+
this flag exists to prevent, so waiting indefinitely isn't an option the
|
|
127
|
+
way it is for a kernel meant to keep running for weeks.
|
|
128
|
+
"""
|
|
129
|
+
import shutil
|
|
130
|
+
import signal
|
|
131
|
+
|
|
132
|
+
if pid is not None:
|
|
133
|
+
try:
|
|
134
|
+
os.kill(pid, signal.SIGTERM)
|
|
135
|
+
except OSError:
|
|
136
|
+
pid = None
|
|
137
|
+
else:
|
|
138
|
+
for _ in range(WAIT_STEPS):
|
|
139
|
+
if not state.pid_alive(pid):
|
|
140
|
+
break
|
|
141
|
+
time.sleep(WAIT_STEP_SECONDS)
|
|
142
|
+
else:
|
|
143
|
+
try:
|
|
144
|
+
os.kill(pid, signal.SIGKILL)
|
|
145
|
+
except OSError:
|
|
146
|
+
pass
|
|
147
|
+
shutil.rmtree(sock_path.parent, ignore_errors=True)
|
|
148
|
+
|
|
149
|
+
|
|
86
150
|
_static_docs_cache: dict[str, str] | None = None
|
|
87
151
|
|
|
88
152
|
|
|
@@ -136,9 +200,13 @@ def _rid_of(line: str) -> object | None:
|
|
|
136
200
|
|
|
137
201
|
|
|
138
202
|
class Bridge:
|
|
139
|
-
def __init__(self, socket_path: Path) -> None:
|
|
203
|
+
def __init__(self, socket_path: Path, *, ephemeral: bool = False) -> None:
|
|
140
204
|
self.socket_path = socket_path
|
|
141
205
|
self.lock_path = paths.lock_for(socket_path)
|
|
206
|
+
# ephemeral spawns eagerly in run() rather than lazily on first real
|
|
207
|
+
# tool call, and tears the kernel down (_teardown_ephemeral) instead
|
|
208
|
+
# of leaving it running when stdin closes. See run_bridge's --ephemeral.
|
|
209
|
+
self._ephemeral = ephemeral
|
|
142
210
|
self._sock: socket.socket | None = None
|
|
143
211
|
self._kernel_pid: int | None = None
|
|
144
212
|
self._generation = 0
|
|
@@ -718,6 +786,16 @@ class Bridge:
|
|
|
718
786
|
# _try_bridge_intercept), and the first message that actually needs a
|
|
719
787
|
# kernel — a real tools/call, a gist/browser resources/read — reaches
|
|
720
788
|
# _ensure_kernel() in _handle_client_line and spawns one then.
|
|
789
|
+
#
|
|
790
|
+
# --ephemeral is the one exception, on purpose: its whole point is a
|
|
791
|
+
# live kernel for exactly this session, not a cache-backed discovery
|
|
792
|
+
# answer that defers spawning to the first real tool call. Spawning
|
|
793
|
+
# here also means every method — including `initialize` itself —
|
|
794
|
+
# forwards to a real kernel instead of the static/cached fallback,
|
|
795
|
+
# since _dispatch_client_line only takes that path while self._sock
|
|
796
|
+
# is still None.
|
|
797
|
+
if self._ephemeral:
|
|
798
|
+
self._ensure_kernel()
|
|
721
799
|
try:
|
|
722
800
|
for line in sys.stdin:
|
|
723
801
|
if not line.endswith("\n"):
|
|
@@ -732,12 +810,15 @@ class Bridge:
|
|
|
732
810
|
# those replies, and the reader thread writes them to stdout.
|
|
733
811
|
self._drain_inflight()
|
|
734
812
|
# The kernel keeps running — its state is meant to outlive the session.
|
|
813
|
+
# --ephemeral inverts that: it dies with the bridge that spawned it.
|
|
735
814
|
sock = self._sock
|
|
736
815
|
if sock is not None:
|
|
737
816
|
try:
|
|
738
817
|
sock.close()
|
|
739
818
|
except OSError:
|
|
740
819
|
pass
|
|
820
|
+
if self._ephemeral:
|
|
821
|
+
_teardown_ephemeral(self.socket_path, self._kernel_pid)
|
|
741
822
|
return 0
|
|
742
823
|
|
|
743
824
|
|
|
@@ -745,6 +826,12 @@ _BRIDGE_USAGE = (
|
|
|
745
826
|
"repld bridge — stdio MCP proxy (spawned by Claude Code, not run by hand)\n"
|
|
746
827
|
"\n"
|
|
747
828
|
" repld bridge [--socket PATH]\n"
|
|
829
|
+
" repld bridge --ephemeral\n"
|
|
830
|
+
"\n"
|
|
831
|
+
" --ephemeral spawns a private, single-use kernel for this bridge alone,\n"
|
|
832
|
+
" eagerly rather than on first tool call, and kills it when stdin closes\n"
|
|
833
|
+
" instead of leaving it running. Mutually exclusive with --socket: its\n"
|
|
834
|
+
" whole point is a path nothing else could already be attached to.\n"
|
|
748
835
|
"\n"
|
|
749
836
|
" Register with: claude mcp add repld -- repld bridge\n"
|
|
750
837
|
)
|
|
@@ -760,6 +847,16 @@ def run_bridge(argv: list[str]) -> int:
|
|
|
760
847
|
if cli_args.wants_help(argv):
|
|
761
848
|
print(_BRIDGE_USAGE)
|
|
762
849
|
return 0
|
|
850
|
+
if "--ephemeral" in argv:
|
|
851
|
+
rest = [a for a in argv if a != "--ephemeral"]
|
|
852
|
+
# No resolve_socket_path here: --ephemeral generates its own path and
|
|
853
|
+
# ignores REPLD_SOCKET too, so a bare --socket left in argv falls
|
|
854
|
+
# through to check_args as an unknown flag — which is exactly the
|
|
855
|
+
# mutual-exclusivity refusal, for free rather than as a special case.
|
|
856
|
+
bad = cli_args.check_args("repld bridge", rest, _BRIDGE_USAGE, positionals=0)
|
|
857
|
+
if bad is not None:
|
|
858
|
+
return bad
|
|
859
|
+
return Bridge(_ephemeral_socket_path(), ephemeral=True).run()
|
|
763
860
|
socket_path, rest = paths.resolve_socket_path(argv)
|
|
764
861
|
bad = cli_args.check_args("repld bridge", rest, _BRIDGE_USAGE, positionals=0)
|
|
765
862
|
if bad is not None:
|
|
@@ -481,6 +481,40 @@ class Tab(TabQueryMixin):
|
|
|
481
481
|
) from reattach_exc
|
|
482
482
|
return await self._session.execute(method, params, timeout)
|
|
483
483
|
|
|
484
|
+
async def _ensure_front(self) -> None:
|
|
485
|
+
"""Raise this tab if Chrome has it backgrounded, before dispatching input.
|
|
486
|
+
|
|
487
|
+
`click`/`tap`/`type_text`/`key` all ride `Input.dispatch*Event` —
|
|
488
|
+
the renderer's real input pipeline, which Chrome silently drops for
|
|
489
|
+
a tab whose window is occluded or backgrounded
|
|
490
|
+
(`document.visibilityState === "hidden"`): the CDP command still
|
|
491
|
+
acks, no exception is raised, and no handler on the page ever
|
|
492
|
+
fires. Resolving a selector doesn't hit this — `DOM.getContentQuads`
|
|
493
|
+
and `Runtime.evaluate` both work fine on a hidden tab, which is why
|
|
494
|
+
the failure looks like nothing happened rather than an error. Costs
|
|
495
|
+
one `Runtime.evaluate` round trip on every call and `Page.bringToFront`
|
|
496
|
+
only on the degraded path — the multi-window watch workflow this
|
|
497
|
+
kernel exists for means N-1 tabs are hidden at any given time, so
|
|
498
|
+
this can't be opt-in.
|
|
499
|
+
"""
|
|
500
|
+
result = await self._exec(
|
|
501
|
+
"Runtime.evaluate",
|
|
502
|
+
{"expression": "document.visibilityState", "returnByValue": True},
|
|
503
|
+
)
|
|
504
|
+
if result.get("result", {}).get("value") == "hidden":
|
|
505
|
+
await self._exec("Page.bringToFront")
|
|
506
|
+
|
|
507
|
+
async def front(self) -> None:
|
|
508
|
+
"""Bring this tab's window to the front (Page.bringToFront).
|
|
509
|
+
|
|
510
|
+
click()/tap()/type_text()/key() already do this automatically when
|
|
511
|
+
the tab is backgrounded — call this directly to raise a tab for its
|
|
512
|
+
own sake (e.g. before a screenshot a human is watching for), or to
|
|
513
|
+
avoid paying the visibility check on every call in a script that
|
|
514
|
+
knows it's about to do a lot of input against one tab.
|
|
515
|
+
"""
|
|
516
|
+
await self._exec("Page.bringToFront")
|
|
517
|
+
|
|
484
518
|
# ------------------------------------------------------------------
|
|
485
519
|
|
|
486
520
|
async def js(
|
|
@@ -643,6 +677,7 @@ class Tab(TabQueryMixin):
|
|
|
643
677
|
|
|
644
678
|
Selector: CSS, text=Label, role=button[name='OK'], or tag:has-text('...')
|
|
645
679
|
"""
|
|
680
|
+
await self._ensure_front()
|
|
646
681
|
x, y = await self._element_center(selector)
|
|
647
682
|
|
|
648
683
|
for event_type in ("mousePressed", "mouseReleased"):
|
|
@@ -670,6 +705,7 @@ class Tab(TabQueryMixin):
|
|
|
670
705
|
Selects all existing content then types over it.
|
|
671
706
|
Selector: CSS, text=Label, role=textbox, label=Name, or tag:has-text('...')
|
|
672
707
|
"""
|
|
708
|
+
await self._ensure_front()
|
|
673
709
|
node_id, js_expr = await self._wait_for_node(selector)
|
|
674
710
|
|
|
675
711
|
if node_id:
|
|
@@ -712,6 +748,7 @@ class Tab(TabQueryMixin):
|
|
|
712
748
|
|
|
713
749
|
async def key(self, key: str) -> None:
|
|
714
750
|
"""Dispatch a keyDown+keyUp pair for a named key (e.g. "Enter", "Escape")."""
|
|
751
|
+
await self._ensure_front()
|
|
715
752
|
for event_type in ("keyDown", "keyUp"):
|
|
716
753
|
await self._exec(
|
|
717
754
|
"Input.dispatchKeyEvent",
|
|
@@ -735,6 +772,7 @@ class Tab(TabQueryMixin):
|
|
|
735
772
|
Uses Input.dispatchTouchEvent — triggers touchstart/touchend listeners
|
|
736
773
|
that dispatchMouseEvent won't reach. Use for mobile Chrome via ADB.
|
|
737
774
|
"""
|
|
775
|
+
await self._ensure_front()
|
|
738
776
|
if y is not None:
|
|
739
777
|
x, y = float(selector_or_x), y
|
|
740
778
|
else:
|
|
@@ -759,6 +797,7 @@ class Tab(TabQueryMixin):
|
|
|
759
797
|
Dispatches touchStart → touchMove × steps → touchEnd.
|
|
760
798
|
For scrolling on mobile Chrome via ADB.
|
|
761
799
|
"""
|
|
800
|
+
await self._ensure_front()
|
|
762
801
|
await self._touch("touchStart", [{"x": x1, "y": y1}])
|
|
763
802
|
delay = duration_ms / steps / 1000
|
|
764
803
|
for i in range(1, steps + 1):
|
|
@@ -482,6 +482,14 @@ Convention: add data-testid to your root layout component.
|
|
|
482
482
|
3s timeout — raises TimeoutError if the page's touch handler blocks
|
|
483
483
|
(common on complex apps like Messenger/React).
|
|
484
484
|
|
|
485
|
+
tab.front() → None
|
|
486
|
+
Bring this tab's window to the front (Page.bringToFront).
|
|
487
|
+
click/tap/type_text/key already call this automatically when Chrome
|
|
488
|
+
has the tab backgrounded — coordinate/key input silently does nothing
|
|
489
|
+
on a hidden tab otherwise (Chrome drops it, no error). Call directly
|
|
490
|
+
to raise a tab for its own sake, or to skip the per-call visibility
|
|
491
|
+
check in a script doing a lot of input against one tab.
|
|
492
|
+
|
|
485
493
|
tab.swipe(x1, y1, x2, y2, *, steps=10, duration_ms=300) → None
|
|
486
494
|
Touch swipe: touchStart → touchMove × steps → touchEnd.
|
|
487
495
|
For scrolling on mobile Chrome via ADB.
|
|
@@ -1116,6 +1124,10 @@ defer(coro, label=None) → task_id
|
|
|
1116
1124
|
coro can also be a zero-arg callable that builds the awaitable —
|
|
1117
1125
|
defer(lambda: asyncio.gather(a(), b())) — so gather()/create_task()
|
|
1118
1126
|
construct on the kernel's loop instead of a sync cell's own thread.
|
|
1127
|
+
A non-None return value is printed (into the push) and recoverable
|
|
1128
|
+
afterward as get_task(task_id)["result"] (a bounded repr — there's no
|
|
1129
|
+
cell number to bind _/_N to instead). no_display() suppresses the print
|
|
1130
|
+
but not the get_task recovery, same as it does for an exec cell.
|
|
1119
1131
|
|
|
1120
1132
|
every(seconds, label=, delay=0)(fn) → fn periodic ticker; fn.cancel() stops
|
|
1121
1133
|
delay= defers the first tick (default: tick now)
|
|
@@ -1152,6 +1164,7 @@ Tab (async unless noted):
|
|
|
1152
1164
|
tab.tree() → list[str]
|
|
1153
1165
|
tab.click(selector, button=, click_count=) → None (auto-waits 2s, mouse event)
|
|
1154
1166
|
tab.tap(selector_or_x, y=) → None (touch event, 3s timeout)
|
|
1167
|
+
tab.front() → None (Page.bringToFront; click/tap/type_text/key auto-call this on a hidden tab)
|
|
1155
1168
|
tab.swipe(x1, y1, x2, y2, steps=, duration_ms=) → None (touch scroll)
|
|
1156
1169
|
tab.scroll(selector, dy=, dx=, steps=, duration_ms=) → None (touch-scroll container)
|
|
1157
1170
|
tab.type_text(selector, text, delay_ms=, press_enter=) → None (clears first, auto-waits)
|
|
@@ -487,6 +487,21 @@ async def _run_cell(task_id: str, src: str, n: int, *, wait_ready: bool = True)
|
|
|
487
487
|
task["exception"] = type(exc).__name__
|
|
488
488
|
|
|
489
489
|
|
|
490
|
+
_RESULT_REPR_LIMIT = 2000 # get_task's "result" field — a preview, not a payload
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _safe_repr(value: object) -> str:
|
|
494
|
+
"""repr() a deferred task's result, bounded so a huge or unrepr-able value
|
|
495
|
+
can't take the task down (or blow up get_task's `json.dumps(snap)`)."""
|
|
496
|
+
try:
|
|
497
|
+
text = repr(value)
|
|
498
|
+
except Exception as exc:
|
|
499
|
+
return f"<repr() failed: {type(exc).__name__}>"
|
|
500
|
+
if len(text) > _RESULT_REPR_LIMIT:
|
|
501
|
+
text = text[:_RESULT_REPR_LIMIT] + f"… ({len(text)} chars total)"
|
|
502
|
+
return text
|
|
503
|
+
|
|
504
|
+
|
|
490
505
|
async def _run_deferred(task_id: str, thunk) -> None:
|
|
491
506
|
"""Build and await a user-supplied awaitable within the task lifecycle.
|
|
492
507
|
|
|
@@ -500,7 +515,14 @@ async def _run_deferred(task_id: str, thunk) -> None:
|
|
|
500
515
|
and a sync cell's body runs off-loop (`asyncio.to_thread`) to keep the
|
|
501
516
|
kernel responsive — so building one directly in the cell raises "no
|
|
502
517
|
current event loop" before defer() ever gets a look at it.
|
|
518
|
+
|
|
519
|
+
An `exec` cell's trailing expression gets `_`/`_N` plus a printed repr()
|
|
520
|
+
(`runtime.run_cell`); a deferred task has no cell number to bind, so it
|
|
521
|
+
gets the printed half instead (into the spill, and so into the
|
|
522
|
+
task_done push) plus a bounded repr in `task["result"]` for `get_task`.
|
|
503
523
|
"""
|
|
524
|
+
from . import runtime
|
|
525
|
+
|
|
504
526
|
async with _task_scope(task_id) as task:
|
|
505
527
|
try:
|
|
506
528
|
awaitable = thunk()
|
|
@@ -509,7 +531,11 @@ async def _run_deferred(task_id: str, thunk) -> None:
|
|
|
509
531
|
"defer() factory must return an awaitable, got "
|
|
510
532
|
f"{type(awaitable).__name__}"
|
|
511
533
|
)
|
|
512
|
-
await awaitable
|
|
534
|
+
result, quiet = runtime.unwrap_display(await awaitable)
|
|
535
|
+
if result is not None:
|
|
536
|
+
task["result"] = _safe_repr(result)
|
|
537
|
+
if not quiet:
|
|
538
|
+
runtime.print_display(result)
|
|
513
539
|
except asyncio.CancelledError:
|
|
514
540
|
task["exception"] = "CancelledError"
|
|
515
541
|
except BaseException as exc:
|
|
@@ -966,6 +992,10 @@ def _boot_runtime(sock_path: Path, display: bool) -> None:
|
|
|
966
992
|
try:
|
|
967
993
|
paths.ensure_runtime_dir()
|
|
968
994
|
state.sweep_dead_pid_files(paths.RUNTIME_DIR)
|
|
995
|
+
# 2a-ii. Same idea, one level up: reclaim a dead kernel's whole
|
|
996
|
+
# PROJECTS_DIR/<slug>/ directory, not just the per-process scratch
|
|
997
|
+
# above — see state.sweep_dead_project_dirs' docstring.
|
|
998
|
+
state.sweep_dead_project_dirs(paths.PROJECTS_DIR)
|
|
969
999
|
except Exception as e:
|
|
970
1000
|
print(f"repld: could not sweep stale runtime files: {e}", file=sys.stderr)
|
|
971
1001
|
|
|
@@ -32,6 +32,13 @@ driven by ``tasks._sweep_orphans``), which is the half boot-time sweeping can
|
|
|
32
32
|
never cover for a process that stays up for weeks. Keep the prefix on anything
|
|
33
33
|
new written here or neither rule will find it.
|
|
34
34
|
|
|
35
|
+
A dead kernel's ``PROJECTS_DIR/<slug>/`` directory itself is a third case
|
|
36
|
+
those two rules don't reach — they sweep files sitting flat in RUNTIME_DIR,
|
|
37
|
+
never the per-project subdirectories one level up. ``state.sweep_dead_project_dirs``
|
|
38
|
+
is that third rule: same boot-time pass, same liveness check via
|
|
39
|
+
``state.read_lock``, applied to ``kernel.lock`` instead of a ``{pid}-…``
|
|
40
|
+
filename.
|
|
41
|
+
|
|
35
42
|
Without ``XDG_RUNTIME_DIR`` (macOS, most containers, any env-scrubbed spawn)
|
|
36
43
|
the tree falls back to ``/tmp/repld-{uid}``. The uid suffix is load-bearing:
|
|
37
44
|
``/tmp`` is world-writable, so a shared unsuffixed name could be pre-created
|
|
@@ -48,6 +48,27 @@ def no_display(value: Any) -> Any:
|
|
|
48
48
|
return _NoDisplay(value)
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
def unwrap_display(result: Any) -> tuple[Any, bool]:
|
|
52
|
+
"""Unwrap a `no_display()` wrapper, if `result` is one. Returns (value, quiet).
|
|
53
|
+
|
|
54
|
+
The one sanctioned way to inspect `_NoDisplay` from outside this module —
|
|
55
|
+
`defer()`'s deferred tasks have the same suppress-print-but-not-recovery
|
|
56
|
+
need as a cell's trailing expression, with no cell number to route
|
|
57
|
+
through `run_cell` for.
|
|
58
|
+
"""
|
|
59
|
+
if isinstance(result, _NoDisplay):
|
|
60
|
+
return result.value, True
|
|
61
|
+
return result, False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def print_display(result: Any) -> None:
|
|
65
|
+
"""Print `result` the way a cell's auto-displayed trailing expression does."""
|
|
66
|
+
if isinstance(result, str) and "\n" in result:
|
|
67
|
+
print(result)
|
|
68
|
+
else:
|
|
69
|
+
print(repr(result))
|
|
70
|
+
|
|
71
|
+
|
|
51
72
|
def _assign_target_names(stmts: list) -> set:
|
|
52
73
|
"""Collect top-level simple-assignment target names from a statement list.
|
|
53
74
|
|
|
@@ -157,14 +178,9 @@ async def run_cell(compiled: tuple, ns: dict, n: int) -> Any:
|
|
|
157
178
|
await _eval(code, ns)
|
|
158
179
|
_unwrap_assigned(ns, names)
|
|
159
180
|
if tag in ("eval", "exec_eval") and result is not None:
|
|
160
|
-
quiet =
|
|
161
|
-
if quiet:
|
|
162
|
-
result = result.value
|
|
181
|
+
result, quiet = unwrap_display(result)
|
|
163
182
|
if not quiet:
|
|
164
|
-
|
|
165
|
-
print(result)
|
|
166
|
-
else:
|
|
167
|
-
print(repr(result))
|
|
183
|
+
print_display(result)
|
|
168
184
|
# Shift history: _ → __, __ → ___. Matches IPython convention.
|
|
169
185
|
ns["___"] = ns.get("__")
|
|
170
186
|
ns["__"] = ns.get("_")
|
|
@@ -17,6 +17,7 @@ import io
|
|
|
17
17
|
import json
|
|
18
18
|
import os
|
|
19
19
|
import re
|
|
20
|
+
import shutil
|
|
20
21
|
import time
|
|
21
22
|
from pathlib import Path
|
|
22
23
|
from typing import IO, Any, Literal, overload
|
|
@@ -243,6 +244,44 @@ def sweep_own_stale_files(
|
|
|
243
244
|
return removed
|
|
244
245
|
|
|
245
246
|
|
|
247
|
+
def sweep_dead_project_dirs(projects_dir: Path) -> int:
|
|
248
|
+
"""Reclaim a dead kernel's whole per-project directory under PROJECTS_DIR.
|
|
249
|
+
|
|
250
|
+
The third boot-time sweep rule described in paths.py's module docstring —
|
|
251
|
+
applied one level up from `sweep_dead_pid_files`, to the
|
|
252
|
+
``PROJECTS_DIR/<slug>/`` directory itself rather than files inside it.
|
|
253
|
+
|
|
254
|
+
Only reclaims a directory whose ``kernel.lock`` exists *and* names a dead
|
|
255
|
+
pid — the same asymmetry `sweep_dead_pid_files` uses, in the same
|
|
256
|
+
direction. A directory with no lock yet may be another kernel mid-boot
|
|
257
|
+
(flock taken in `_claim_project`, lock not written until
|
|
258
|
+
`_write_lockfile` much later in `_start_services`); reclaiming it would
|
|
259
|
+
race that boot. A directory with no lock *ever* (a client resolved
|
|
260
|
+
`project_dir()` — `repld status`, `repld exec` — but no kernel started
|
|
261
|
+
there) is left alone too: there's nothing here to say it's abandoned
|
|
262
|
+
rather than simply unused, and it costs nothing to leave standing.
|
|
263
|
+
"""
|
|
264
|
+
removed = 0
|
|
265
|
+
try:
|
|
266
|
+
entries = list(projects_dir.iterdir())
|
|
267
|
+
except OSError:
|
|
268
|
+
return 0
|
|
269
|
+
for entry in entries:
|
|
270
|
+
if not entry.is_dir():
|
|
271
|
+
continue
|
|
272
|
+
lock_path = entry / "kernel.lock"
|
|
273
|
+
if not lock_path.exists():
|
|
274
|
+
continue
|
|
275
|
+
if isinstance(read_lock(lock_path), dict):
|
|
276
|
+
continue # pid alive
|
|
277
|
+
try:
|
|
278
|
+
shutil.rmtree(entry)
|
|
279
|
+
except OSError:
|
|
280
|
+
continue
|
|
281
|
+
removed += 1
|
|
282
|
+
return removed
|
|
283
|
+
|
|
284
|
+
|
|
246
285
|
def acquire_lock(flock_path: Path) -> int | None:
|
|
247
286
|
"""Take the single-kernel-per-project mutex.
|
|
248
287
|
|
|
@@ -135,6 +135,11 @@ def new_task(origin: object = None) -> tuple[str, dict]:
|
|
|
135
135
|
task: dict = {
|
|
136
136
|
"done_event": threading.Event(),
|
|
137
137
|
"exception": None,
|
|
138
|
+
# Bounded-repr'd, never the raw object: this dict feeds straight into
|
|
139
|
+
# get_task's `json.dumps(snap)`, and an arbitrary result (a DataFrame,
|
|
140
|
+
# a custom class) would break that RPC. Set only by `_run_deferred`;
|
|
141
|
+
# an `exec` cell's result goes to `_`/`_N` instead.
|
|
142
|
+
"result": None,
|
|
138
143
|
"spill_file": None,
|
|
139
144
|
"spill_path": None,
|
|
140
145
|
"nudged": False,
|
|
@@ -284,6 +289,7 @@ def snapshot(task_id: str) -> dict | None:
|
|
|
284
289
|
"spilled": task["spill_path"] is not None,
|
|
285
290
|
"spill_path": task["spill_path"],
|
|
286
291
|
"exception": task["exception"],
|
|
292
|
+
"result": task.get("result"),
|
|
287
293
|
"done": task["done_event"].is_set(),
|
|
288
294
|
"label": task.get("label"),
|
|
289
295
|
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|