browserwright 0.7.0__tar.gz → 0.7.2__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.
Files changed (104) hide show
  1. {browserwright-0.7.0 → browserwright-0.7.2}/PKG-INFO +1 -1
  2. {browserwright-0.7.0 → browserwright-0.7.2}/pyproject.toml +1 -1
  3. browserwright-0.7.2/src/browserwright/daemon/_stale.py +174 -0
  4. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/cli.py +30 -2
  5. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/server/listener.py +92 -1
  6. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/skill_runtime.md +8 -0
  7. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright.egg-info/PKG-INFO +1 -1
  8. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright.egg-info/SOURCES.txt +1 -0
  9. {browserwright-0.7.0 → browserwright-0.7.2}/README.md +0 -0
  10. {browserwright-0.7.0 → browserwright-0.7.2}/setup.cfg +0 -0
  11. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/__init__.py +0 -0
  12. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/__main__.py +0 -0
  13. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/_executor/__init__.py +0 -0
  14. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/_executor/__main__.py +0 -0
  15. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/_executor/client.py +0 -0
  16. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/_executor/process.py +0 -0
  17. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/_executor/protocol.py +0 -0
  18. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/api.py +0 -0
  19. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/cdp.py +0 -0
  20. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/cli.py +0 -0
  21. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/__init__.py +0 -0
  22. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/_ipc.py +0 -0
  23. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/active_tab.py +0 -0
  24. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/auth.py +0 -0
  25. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/backends/__init__.py +0 -0
  26. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/backends/base.py +0 -0
  27. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/backends/cloud.py +0 -0
  28. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/backends/env.py +0 -0
  29. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/backends/extension.py +0 -0
  30. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/backends/rdp.py +0 -0
  31. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/config.py +0 -0
  32. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/doctor.py +0 -0
  33. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/errors.py +0 -0
  34. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/launch_chrome.py +0 -0
  35. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/observability.py +0 -0
  36. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/platforms.py +0 -0
  37. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/resolver.py +0 -0
  38. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/server/__init__.py +0 -0
  39. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/server/daemon.py +0 -0
  40. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/server/executor_registry.py +0 -0
  41. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/server/extension_upstream.py +0 -0
  42. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/server/facade.py +0 -0
  43. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/server/facade_extension.py +0 -0
  44. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/server/proxy.py +0 -0
  45. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/server/relay.py +0 -0
  46. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/server/state.py +0 -0
  47. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/server/upstream.py +0 -0
  48. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/daemon/userscripts.py +0 -0
  49. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/discovery.py +0 -0
  50. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/errors.py +0 -0
  51. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/health.py +0 -0
  52. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/install.py +0 -0
  53. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/memory/__init__.py +0 -0
  54. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/memory/_md.py +0 -0
  55. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/memory/_yaml.py +0 -0
  56. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/memory/global_mem.py +0 -0
  57. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/memory/repl_mem.py +0 -0
  58. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/memory/session_decisions.py +0 -0
  59. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/memory/site_mem.py +0 -0
  60. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/mode_b_client.py +0 -0
  61. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/multitask.py +0 -0
  62. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/output_schema.py +0 -0
  63. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/primitives/__init__.py +0 -0
  64. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/primitives/discovery_api.py +0 -0
  65. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/primitives/http.py +0 -0
  66. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/primitives/inspect.py +0 -0
  67. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/primitives/interact.py +0 -0
  68. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/primitives/page.py +0 -0
  69. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/primitives/site.py +0 -0
  70. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/release_install.py +0 -0
  71. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/repl/__init__.py +0 -0
  72. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/repl/_namespace.py +0 -0
  73. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/repl/_smart_goto.py +0 -0
  74. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/repl/inline.py +0 -0
  75. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/repl/playwright_handle.py +0 -0
  76. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/repl/snapshot.py +0 -0
  77. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/session.py +0 -0
  78. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/session_create.py +0 -0
  79. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/session_ctx.py +0 -0
  80. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/session_registry.py +0 -0
  81. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/session_runtime.py +0 -0
  82. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/site_skills_starter/github.com/SKILL.md +0 -0
  83. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/site_skills_starter/github.com/memory.md +0 -0
  84. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/site_skills_starter/github.com/tasks/list_issues.py +0 -0
  85. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/site_skills_starter/google.com/SKILL.md +0 -0
  86. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/site_skills_starter/google.com/memory.md +0 -0
  87. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/site_skills_starter/google.com/tasks/search.py +0 -0
  88. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/site_skills_starter/producthunt.com/SKILL.md +0 -0
  89. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/site_skills_starter/producthunt.com/memory.md +0 -0
  90. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/site_skills_starter/producthunt.com/tasks/today.py +0 -0
  91. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/site_skills_starter/wikipedia.org/SKILL.md +0 -0
  92. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/site_skills_starter/wikipedia.org/memory.md +0 -0
  93. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +0 -0
  94. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/site_skills_starter/ycombinator.com/SKILL.md +0 -0
  95. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/site_skills_starter/ycombinator.com/memory.md +0 -0
  96. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +0 -0
  97. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/skill_doc.py +0 -0
  98. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/subscriptions.py +0 -0
  99. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/task_runner.py +0 -0
  100. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright/version.py +0 -0
  101. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright.egg-info/dependency_links.txt +0 -0
  102. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright.egg-info/entry_points.txt +0 -0
  103. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright.egg-info/requires.txt +0 -0
  104. {browserwright-0.7.0 → browserwright-0.7.2}/src/browserwright.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: browserwright
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Browserwright — let AI/code agents drive a real or isolated browser and author userscripts. Single package: the agent-facing REPL/site-skills/memory layer plus the bundled browser-resolving daemon (CDP proxy + extension/cloud backends).
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: cdp-use==1.4.5
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "browserwright"
7
- version = "0.7.0"
7
+ version = "0.7.2"
8
8
  description = "Browserwright — let AI/code agents drive a real or isolated browser and author userscripts. Single package: the agent-facing REPL/site-skills/memory layer plus the bundled browser-resolving daemon (CDP proxy + extension/cloud backends)."
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
@@ -0,0 +1,174 @@
1
+ """Detect + reclaim a stale / half-alive browserwright daemon (issue #15 P2).
2
+
3
+ A daemon killed mid-life — or one whose serve loop failed *after* binding its
4
+ relay/facade TCP ports — can linger holding those ports while its control
5
+ socket is dead. Then:
6
+
7
+ - `browserwright-daemon status` lies (`not_running`) though a process holds
8
+ the ports (issue #15, 2.1), and
9
+ - a fresh `serve` crash-loops on `EADDRINUSE` because nothing reclaims the
10
+ ports from the zombie (issue #15, 2.2).
11
+
12
+ These helpers give `status` a truthful third state and let `serve` take over
13
+ the ports from a *confirmed* browserwright daemon. The safety rule for any
14
+ kill: only ever signal a pid that lsof confirms is holding the port AND whose
15
+ command line confirms it is a browserwright daemon. When lsof is unavailable we
16
+ never auto-kill — we surface the pid-file pid for the user to handle.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import signal
22
+ import socket
23
+ import subprocess
24
+ import sys
25
+ import time
26
+
27
+
28
+ def daemon_tcp_ports(cfg) -> list[int]:
29
+ """The relay + facade TCP ports a daemon binds, derived from ``cfg``.
30
+
31
+ Best-effort + deduped — used by both `status` (to detect a port-holding
32
+ zombie) and `serve` (to reclaim those ports before binding). Kept here so
33
+ the two paths can never disagree on which ports matter."""
34
+ ports: list[int] = []
35
+ try:
36
+ _, relay_port = cfg.backends.extension.resolved_host_port()
37
+ if relay_port:
38
+ ports.append(int(relay_port))
39
+ except Exception: # noqa: BLE001 - best-effort
40
+ pass
41
+ try:
42
+ fp = cfg.resolved_facade_port()
43
+ if fp:
44
+ ports.append(int(fp))
45
+ except Exception: # noqa: BLE001
46
+ pass
47
+ return list(dict.fromkeys(ports))
48
+
49
+
50
+ def port_is_listening(host: str, port: int) -> bool:
51
+ """True if something already holds ``host:port`` (a fresh bind fails).
52
+
53
+ Uses a plain bind attempt (no ``SO_REUSEADDR``) so a live listener reliably
54
+ trips ``EADDRINUSE``. Cheap, cross-platform, no external process."""
55
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
56
+ try:
57
+ s.bind((host, port))
58
+ return False # we could bind → nobody holds it
59
+ except OSError:
60
+ return True
61
+ finally:
62
+ s.close()
63
+
64
+
65
+ def port_holder_pids(port: int) -> list[int]:
66
+ """PIDs listening on TCP ``port`` (best-effort via ``lsof``).
67
+
68
+ Returns ``[]`` when lsof is missing / errors / finds nothing — callers must
69
+ treat an empty list as "unknown", never as "nobody"."""
70
+ if sys.platform == "win32":
71
+ return []
72
+ try:
73
+ out = subprocess.run(
74
+ ["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
75
+ capture_output=True, text=True, timeout=3,
76
+ )
77
+ except (FileNotFoundError, OSError, subprocess.SubprocessError):
78
+ return []
79
+ pids: list[int] = []
80
+ for tok in out.stdout.split():
81
+ try:
82
+ pid = int(tok)
83
+ except ValueError:
84
+ continue
85
+ if pid > 0 and pid not in pids:
86
+ pids.append(pid)
87
+ return pids
88
+
89
+
90
+ def pid_is_browserwright_daemon(pid: int) -> bool:
91
+ """True iff ``pid``'s command line looks like a browserwright daemon.
92
+
93
+ The safety gate before any SIGTERM — we must never signal a stranger that
94
+ happens to hold the port. Matches both the installed console-script
95
+ (``browserwright-daemon serve``) and the dev module invocation
96
+ (``python -m browserwright.daemon.cli serve``)."""
97
+ if pid <= 0 or sys.platform == "win32":
98
+ return False
99
+ try:
100
+ out = subprocess.run(
101
+ ["ps", "-p", str(pid), "-o", "command="],
102
+ capture_output=True, text=True, timeout=3,
103
+ )
104
+ except (FileNotFoundError, OSError, subprocess.SubprocessError):
105
+ return False
106
+ cmd = out.stdout.strip()
107
+ if not cmd:
108
+ return False
109
+ return "browserwright-daemon" in cmd or "browserwright.daemon" in cmd
110
+
111
+
112
+ def pid_alive(pid: int) -> bool:
113
+ """True if ``pid`` exists (signal 0 probe). POSIX only."""
114
+ if pid <= 0 or sys.platform == "win32":
115
+ return False
116
+ try:
117
+ os.kill(pid, 0)
118
+ except ProcessLookupError:
119
+ return False
120
+ except PermissionError:
121
+ return True # exists, owned by someone else
122
+ except OSError:
123
+ return False
124
+ return True
125
+
126
+
127
+ def confirmed_stale_holder(ports: list[int]) -> int | None:
128
+ """Return the pid of a *confirmed browserwright* daemon holding any of
129
+ ``ports`` (excluding ourselves), or None.
130
+
131
+ Confirmation = lsof says it holds the port AND its command line is a
132
+ browserwright daemon. Callers must have already established that the control
133
+ socket is dead (ping negative) before treating this as "stale". lsof
134
+ unavailable → None (we won't guess a kill target)."""
135
+ me = os.getpid()
136
+ for port in ports:
137
+ for pid in port_holder_pids(port):
138
+ if pid != me and pid_is_browserwright_daemon(pid):
139
+ return pid
140
+ return None
141
+
142
+
143
+ def reclaim_ports(pid: int, ports: list[int], *, timeout: float = 5.0) -> bool:
144
+ """SIGTERM ``pid`` and wait until every port in ``ports`` frees; escalate to
145
+ SIGKILL as a last resort. Returns True once the ports are free.
146
+
147
+ The caller MUST have confirmed ``pid`` via :func:`confirmed_stale_holder`
148
+ (or an equivalent lsof+cmdline check) — this function does not re-verify."""
149
+ def _all_free() -> bool:
150
+ return all(not port_is_listening("127.0.0.1", p) for p in ports)
151
+
152
+ try:
153
+ os.kill(pid, signal.SIGTERM)
154
+ except ProcessLookupError:
155
+ return _all_free()
156
+ except OSError:
157
+ return False
158
+ if _wait(_all_free, timeout):
159
+ return True
160
+ # Escalate — a wedged daemon may never handle SIGTERM.
161
+ try:
162
+ os.kill(pid, signal.SIGKILL)
163
+ except OSError:
164
+ pass
165
+ return _wait(_all_free, 2.0)
166
+
167
+
168
+ def _wait(pred, timeout: float) -> bool:
169
+ deadline = time.monotonic() + timeout
170
+ while time.monotonic() < deadline:
171
+ if pred():
172
+ return True
173
+ time.sleep(0.15)
174
+ return pred()
@@ -615,9 +615,12 @@ async def _run_stats(args, cfg: Config) -> int:
615
615
 
616
616
  def _cmd_status(args, cfg: Config) -> int:
617
617
  """Report endpoint + liveness. JSON shape used by Skill for status pings."""
618
- from . import _ipc
618
+ from . import _ipc, _stale
619
619
  pid, version = _ipc.ping_status_sync(timeout=1.0)
620
620
  probe_state = "ok" if pid is not None else "not_running"
621
+ # issue #15 (2.1): pid of the process holding the relay/facade ports when
622
+ # the control socket is dead but its file lingers — a half-alive daemon.
623
+ port_holder_pid = None
621
624
  if pid is None and _ipc.sock_path().exists():
622
625
  deadline = time.monotonic() + 0.6
623
626
  while time.monotonic() < deadline:
@@ -628,6 +631,20 @@ def _cmd_status(args, cfg: Config) -> int:
628
631
  break
629
632
  else:
630
633
  probe_state = "transient_probe_failed"
634
+ # Still unresponsive, but its socket file is present — a half-alive
635
+ # daemon may be holding the relay/facade ports. Report the truth
636
+ # instead of a bare "not_running" that loops the user through
637
+ # restarts that crash on EADDRINUSE.
638
+ ports = _stale.daemon_tcp_ports(cfg)
639
+ if any(_stale.port_is_listening("127.0.0.1", p) for p in ports):
640
+ probe_state = "port_held_by_unresponsive_process"
641
+ port_holder_pid = _stale.confirmed_stale_holder(ports)
642
+ if port_holder_pid is None:
643
+ # lsof unavailable / non-daemon holder — surface the
644
+ # pid-file pid as a best-effort hint (never a kill target).
645
+ fp = _ipc.read_pid()
646
+ if fp and _stale.pid_alive(fp):
647
+ port_holder_pid = fp
631
648
  ep = _ipc.endpoint_describe()
632
649
  facade_ws, facade_port = _ipc.read_facade_file()
633
650
  status = {
@@ -635,6 +652,10 @@ def _cmd_status(args, cfg: Config) -> int:
635
652
  "alive": pid is not None,
636
653
  "probe_state": probe_state,
637
654
  "pid": pid,
655
+ # issue #15 (2.1): pid of the process holding the relay/facade ports
656
+ # when the daemon is unresponsive. Actionable: `browserwright-daemon
657
+ # restart` reclaims it, or kill it manually.
658
+ "port_holder_pid": port_holder_pid,
638
659
  "version": version,
639
660
  "endpoint": ep,
640
661
  # Playwright facade discovery (Phase C). None when the facade is
@@ -649,7 +670,14 @@ def _cmd_status(args, cfg: Config) -> int:
649
670
  print(json.dumps(status, sort_keys=True))
650
671
  else:
651
672
  if pid is None:
652
- print("daemon not running")
673
+ if probe_state == "port_held_by_unresponsive_process":
674
+ held = f" (pid {port_holder_pid})" if port_holder_pid else ""
675
+ print("daemon unresponsive but holding its ports"
676
+ f"{held} — a half-alive daemon.")
677
+ print(" fix: `browserwright-daemon restart` reclaims the "
678
+ "ports; or kill the pid above manually.")
679
+ else:
680
+ print("daemon not running")
653
681
  else:
654
682
  print(f"daemon alive (pid {pid})")
655
683
  if ep["transport"] == "unix":
@@ -75,6 +75,61 @@ def make_context(*, backend: str, cfg: Config,
75
75
  # ---- top-level entry -------------------------------------------------------
76
76
 
77
77
 
78
+ def _reclaim_stale_daemon_ports(cfg: Config) -> None:
79
+ """Reclaim the relay/facade ports from a *confirmed* stale browserwright
80
+ daemon (issue #15, 2.2). No-op when the ports are free or held by an
81
+ unconfirmed process (we never SIGTERM a stranger)."""
82
+ from .. import _stale
83
+ ports = _stale.daemon_tcp_ports(cfg)
84
+ held = [p for p in ports if _stale.port_is_listening("127.0.0.1", p)]
85
+ if not held:
86
+ return
87
+ pid = _stale.confirmed_stale_holder(held)
88
+ if pid is None:
89
+ logger.warning(
90
+ "ports %s are in use but no confirmed browserwright daemon holds "
91
+ "them; leaving them alone (bind will surface a clear error)", held)
92
+ return
93
+ logger.warning(
94
+ "reclaiming ports %s from stale browserwright daemon pid %d (issue #15)",
95
+ held, pid)
96
+ if _stale.reclaim_ports(pid, held):
97
+ logger.info("reclaimed ports %s from pid %d", held, pid)
98
+ else:
99
+ logger.warning("could not free ports %s from pid %d; bind may fail",
100
+ held, pid)
101
+
102
+
103
+ async def _control_socket_watchdog(sock_ident: tuple, stop: asyncio.Event,
104
+ *, interval: float = 2.0) -> None:
105
+ """Self-exit when our control socket disappears or is replaced by a
106
+ different inode — i.e. another daemon took over, or the socket was removed
107
+ (issue #15, 2.4). Setting ``stop`` runs the normal graceful shutdown, which
108
+ releases the relay/facade ports instead of lingering as a port-holding
109
+ zombie. A single stat error is ignored (transient FS hiccup); only a
110
+ definitive gone/replaced verdict self-exits."""
111
+ while not stop.is_set():
112
+ try:
113
+ await asyncio.wait_for(stop.wait(), timeout=interval)
114
+ return # stop was set elsewhere (signal / another watchdog)
115
+ except asyncio.TimeoutError:
116
+ pass
117
+ try:
118
+ st = os.stat(_ipc.sock_path())
119
+ except FileNotFoundError:
120
+ logger.warning("control socket removed; self-exiting to release "
121
+ "ports (issue #15)")
122
+ stop.set()
123
+ return
124
+ except OSError:
125
+ continue # transient — don't self-exit on one error
126
+ if (st.st_dev, st.st_ino) != sock_ident:
127
+ logger.warning("control socket replaced (another daemon took over); "
128
+ "self-exiting to release ports (issue #15)")
129
+ stop.set()
130
+ return
131
+
132
+
78
133
  async def run_serve(cfg: Config) -> int:
79
134
  """Run a Mode B daemon until SIGTERM / Ctrl-C / shutdown. Returns exit code.
80
135
 
@@ -100,6 +155,12 @@ async def run_serve(cfg: Config) -> int:
100
155
  )
101
156
  return 1
102
157
  _ipc.cleanup_endpoint()
158
+ # issue #15 (2.2): the control-socket ping above was negative, but a
159
+ # half-alive prior daemon can still hold the relay/facade TCP ports and
160
+ # crash-loop us on EADDRINUSE. Reclaim them before binding — but only from a
161
+ # process lsof confirms holds the port AND whose cmdline is a browserwright
162
+ # daemon (never a stranger).
163
+ _reclaim_stale_daemon_ports(cfg)
103
164
 
104
165
  # Phase 3 (C2 ephemeral): rdp Chrome processes are daemon children and die
105
166
  # with us — but a hard crash / SIGKILL can leave orphan Chrome processes
@@ -159,6 +220,19 @@ async def run_serve(cfg: Config) -> int:
159
220
  handler = _ClientHandler(daemon, cfg)
160
221
  server = await _open_server(handler)
161
222
 
223
+ # issue #15 (2.4): watch our own control socket. If another `serve` (or a
224
+ # manual `rm`) removes/replaces it, self-exit so we release the relay/facade
225
+ # ports instead of lingering as a port-holding zombie. POSIX only — Windows
226
+ # uses a port file, not a unix socket inode.
227
+ sock_watch_task: asyncio.Task | None = None
228
+ if not _ipc.IS_WINDOWS:
229
+ try:
230
+ _st = os.stat(_ipc.sock_path())
231
+ sock_watch_task = asyncio.create_task(
232
+ _control_socket_watchdog((_st.st_dev, _st.st_ino), stop))
233
+ except OSError:
234
+ pass
235
+
162
236
  # v0.4: for the extension shared context, start the relay ws server eagerly
163
237
  # so `browserwright-daemon doctor` can probe `__status__` even before any
164
238
  # Skill client connects. The relay belongs to the shared context's holder
@@ -173,8 +247,21 @@ async def run_serve(cfg: Config) -> int:
173
247
  port = await shared_context.holder.relay.start()
174
248
  logger.info("extension relay started on port %d", port)
175
249
  except OSError as e:
250
+ # issue #15 (2.2): if we still can't bind after the reclaim pass, the
251
+ # port is held by something we couldn't confirm as our daemon (lsof
252
+ # missing, or a genuine stranger). Point the user at the exact port +
253
+ # how to find the holder rather than a bare errno.
254
+ hint = ""
255
+ with contextlib.suppress(Exception):
256
+ _, rport = cfg.backends.extension.resolved_host_port()
257
+ hint = (
258
+ f" — port {rport} is held by another process. Run "
259
+ f"`lsof -nP -iTCP:{rport} -sTCP:LISTEN` to find it, then "
260
+ f"`browserwright-daemon restart` (reclaims a stale "
261
+ f"browserwright daemon) or kill that pid."
262
+ )
176
263
  print(
177
- f"browserwright-daemon failed to bind extension relay: {e}",
264
+ f"browserwright-daemon failed to bind extension relay: {e}{hint}",
178
265
  file=sys.stderr,
179
266
  )
180
267
  server.close()
@@ -235,6 +322,10 @@ async def run_serve(cfg: Config) -> int:
235
322
  logger.info("browserwright-daemon shutdown requested")
236
323
  await _graceful_shutdown(daemon)
237
324
  finally:
325
+ if sock_watch_task is not None:
326
+ sock_watch_task.cancel()
327
+ with contextlib.suppress(Exception):
328
+ await sock_watch_task
238
329
  if idle_task is not None:
239
330
  idle_task.cancel()
240
331
  with contextlib.suppress(Exception):
@@ -205,6 +205,14 @@ browserwright userscript toggle <id> --enabled=false
205
205
  browserwright userscript remove <id>
206
206
  ```
207
207
 
208
+ ## Reporting browserwright bugs
209
+
210
+ If browserwright itself misbehaves — a primitive, CLI verb, the daemon, the extension backend, or the Playwright facade crashes, hangs, times out, or breaks a contract this guide documents — file a bug against the upstream repo (`broven/browserwright`) on the user's behalf, so the user does not have to. This is *only* for browserwright defects; a site that changed, blocks automation, or shows a captcha is a site note (`remember(host, ...)`), and hostile page content is a trust-boundary matter, not a bug.
211
+
212
+ A report is only useful if a maintainer can reproduce it. Before filing: reproduce it twice, shrink it to the smallest runnable script against a page the maintainer can reach (prefer `https://example.com` or a `data:text/html,...` fixture, never an auth-gated/private page), capture `browserwright version check` + `browserwright-daemon status --json`, record expected vs. actual, and redact secrets and private content. A GitHub issue is public and filed under the user's identity: draft it, show the user, and submit with `gh issue create --repo broven/browserwright` only after they approve.
213
+
214
+ Read the installed skill's `reporting-issues.md` for the exact scope gate, reproducibility checklist, issue template, and the `gh` filing + fallback commands before you file.
215
+
208
216
  ## Memory
209
217
 
210
218
  Read the installed skill's `memory.md` for backend preferences and scenario decisions. When the user expresses a stable browser preference, record it there or with the memory helpers so future tasks do not re-ask.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: browserwright
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Browserwright — let AI/code agents drive a real or isolated browser and author userscripts. Single package: the agent-facing REPL/site-skills/memory layer plus the bundled browser-resolving daemon (CDP proxy + extension/cloud backends).
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: cdp-use==1.4.5
@@ -36,6 +36,7 @@ src/browserwright/_executor/process.py
36
36
  src/browserwright/_executor/protocol.py
37
37
  src/browserwright/daemon/__init__.py
38
38
  src/browserwright/daemon/_ipc.py
39
+ src/browserwright/daemon/_stale.py
39
40
  src/browserwright/daemon/active_tab.py
40
41
  src/browserwright/daemon/auth.py
41
42
  src/browserwright/daemon/cli.py
File without changes
File without changes