weed-cli 1.6.0__tar.gz → 1.6.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: weed-cli
3
- Version: 1.6.0
3
+ Version: 1.6.2
4
4
  Summary: Censorship-resistant video PoC — discovery/hosting/download over signed relay events, a real Kademlia DHT, or a TLS-capable NAT-traversal tunnel
5
5
  License: MIT
6
6
  Keywords: p2p,video,censorship-resistant,discovery,dht,kademlia,nat-traversal
@@ -180,6 +180,8 @@ somewhere other than a laptop.
180
180
  make node # build + run, http://127.0.0.1:8080
181
181
  make node-down # stop it — data persists
182
182
  WEED_SHARE_DIR=~/Movies make node # mount a real directory of .ott archives at /share
183
+ docker compose -f docker-compose.node.yml exec node python3 shell.py # interactive shell, same container
184
+ docker compose -f docker-compose.node.yml exec node python3 weed.py discover # or a one-off CLI command
183
185
  ```
184
186
 
185
187
  - Identity key, reputation store, and library manifest all persist in a
@@ -150,6 +150,8 @@ somewhere other than a laptop.
150
150
  make node # build + run, http://127.0.0.1:8080
151
151
  make node-down # stop it — data persists
152
152
  WEED_SHARE_DIR=~/Movies make node # mount a real directory of .ott archives at /share
153
+ docker compose -f docker-compose.node.yml exec node python3 shell.py # interactive shell, same container
154
+ docker compose -f docker-compose.node.yml exec node python3 weed.py discover # or a one-off CLI command
153
155
  ```
154
156
 
155
157
  - Identity key, reputation store, and library manifest all persist in a
@@ -19,6 +19,7 @@ every previous script verified chunks locally or over a network, none of
19
19
  them reassembled a full file from a remote peer onto disk before this.
20
20
  """
21
21
  import base64
22
+ import concurrent.futures
22
23
  import hashlib
23
24
  import json
24
25
  import os
@@ -44,8 +45,12 @@ TAGLINE = 'we do in 4 what others do in 5'
44
45
 
45
46
 
46
47
  def weed_version():
47
- """Installed package version (pyproject.toml's source of truth), with a
48
- fallback for running straight from source without `pip install -e .`."""
48
+ """Installed package version (pyproject.toml's source of truth), with
49
+ fallbacks for the two other real ways this runs: straight from a git
50
+ checkout without `pip install -e .` (read pyproject.toml directly —
51
+ Dockerfile.node copies it in for exactly this, since it copies loose
52
+ .py files rather than pip-installing the package), or neither file
53
+ present at all (genuinely no version info available)."""
49
54
  try:
50
55
  from importlib.metadata import version, PackageNotFoundError
51
56
  try:
@@ -54,6 +59,15 @@ def weed_version():
54
59
  pass
55
60
  except ImportError:
56
61
  pass
62
+ pyproject_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'pyproject.toml')
63
+ try:
64
+ with open(pyproject_path) as f:
65
+ for line in f:
66
+ line = line.strip()
67
+ if line.startswith('version'):
68
+ return line.split('=', 1)[1].strip().strip('"\'')
69
+ except (OSError, IndexError):
70
+ pass
57
71
  return '0.0.0-dev'
58
72
 
59
73
 
@@ -65,9 +79,36 @@ def _git_commit_hash():
65
79
  behind a real debugging session: two checkouts reporting the same
66
80
  version, one of them missing a fix the other had. None for anything
67
81
  that isn't a git checkout at all (installed from a built wheel/sdist,
68
- no .git present) — a version number is all there is to go on there."""
82
+ no .git present) — a version number is all there is to go on there.
83
+
84
+ $WEED_GIT_COMMIT checked first — Dockerfile.node has no .git directory
85
+ at all (never copied in, on purpose: bloats the image and ships the
86
+ full history for no reason), so a container build has no way to
87
+ answer this live. docker-compose.node.yml passes the *host's* commit
88
+ hash in as a build arg at image-build time instead, baked in as this
89
+ env var, same intent as the live git lookup below just computed once,
90
+ earlier, somewhere that actually has the repo.
91
+
92
+ `git rev-parse` walks *up* parent directories looking for a .git —
93
+ a real `pip install .` (not `-e`) copies this file into site-packages,
94
+ which usually isn't a checkout at all, but if it happens to be nested
95
+ anywhere under some unrelated git-tracked ancestor directory (a
96
+ dotfiles repo, a pyenv install tracked in git, anything above it),
97
+ this would otherwise silently report *that* repo's own unrelated
98
+ commit — worse than showing nothing, since it looks plausible. Only
99
+ trust the hash once --show-toplevel confirms this file's own
100
+ directory really is that repo's root."""
101
+ env_commit = os.environ.get('WEED_GIT_COMMIT')
102
+ if env_commit:
103
+ return env_commit
69
104
  repo_dir = os.path.dirname(os.path.abspath(__file__))
70
105
  try:
106
+ toplevel = subprocess.run(['git', 'rev-parse', '--show-toplevel'], cwd=repo_dir,
107
+ capture_output=True, text=True, timeout=2)
108
+ if toplevel.returncode != 0:
109
+ return None
110
+ if os.path.realpath(toplevel.stdout.strip()) != os.path.realpath(repo_dir):
111
+ return None
71
112
  result = subprocess.run(['git', 'rev-parse', '--short', 'HEAD'], cwd=repo_dir,
72
113
  capture_output=True, text=True, timeout=2)
73
114
  if result.returncode == 0:
@@ -337,8 +378,24 @@ def serve_session(conn, entries_by_hash, default_hash, price, ln_node=None):
337
378
  _graceful_close(conn)
338
379
 
339
380
 
381
+ def bind_host_port(port, bind_host='0.0.0.0'):
382
+ """Split out of run_host_server so a caller (see web_ui.py's
383
+ _run_host_job) can bind the real, permanent listening socket early —
384
+ right where a port-collision needs to fail fast, before announcing
385
+ anything — and hand that exact socket to run_host_server later
386
+ instead of it binding a second, separate one. A bind-then-close probe
387
+ followed by a *second*, later bind of the same port isn't atomic
388
+ across two hosts starting concurrently: both can pass the probe
389
+ before either does the real bind. Binding once, early, and reusing
390
+ the same socket removes that race entirely instead of narrowing it."""
391
+ srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
392
+ srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
393
+ srv.bind((bind_host, port))
394
+ return srv
395
+
396
+
340
397
  def run_host_server(archive_dir, file_name, port, bind_host='0.0.0.0', quiet=False, price=0,
341
- ln_node=None):
398
+ ln_node=None, sock=None):
342
399
  archive_dir = os.path.expanduser(archive_dir)
343
400
  entries = load_manifest_entries(archive_dir, file_name)
344
401
  entries_by_hash = {}
@@ -350,9 +407,7 @@ def run_host_server(archive_dir, file_name, port, bind_host='0.0.0.0', quiet=Fal
350
407
  entries_by_hash[entry['sha256']] = (entry, leaves, file_path)
351
408
  default_hash = next(iter(entries_by_hash)) if len(entries_by_hash) == 1 else None
352
409
 
353
- srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
354
- srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
355
- srv.bind((bind_host, port))
410
+ srv = sock if sock is not None else bind_host_port(port, bind_host)
356
411
  srv.listen(8)
357
412
  if not quiet:
358
413
  # a background thread's print() races with cmd.Cmd's input()-driven
@@ -979,6 +1034,27 @@ def _relay_url_hint(relay_url):
979
1034
  return None
980
1035
 
981
1036
 
1037
+ _dns_bound_pool = concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_prefix='urlopen')
1038
+
1039
+
1040
+ def _urlopen_bounded(req_or_url, timeout=5):
1041
+ """urlopen's own timeout= only bounds the connect/read phases — the
1042
+ getaddrinfo() call underneath (DNS resolution) is a blocking libc call
1043
+ with no timeout of its own, so a relay hostname that's slow or
1044
+ unreachable to resolve can hang here indefinitely no matter what
1045
+ timeout= says. That's exactly the situation right after a container
1046
+ boots and its network/DNS isn't fully settled yet: _resume_persisted_hosts
1047
+ (web_ui.py) resuming a host at that moment used to get stuck at
1048
+ "(starting…)" forever, since nothing downstream ever times out either.
1049
+ Running the call in a worker thread and bounding *that* with
1050
+ .result(timeout=) catches DNS hangs the same as connect/read hangs —
1051
+ the stuck worker thread leaks (a blocking C call can't be cancelled),
1052
+ but it's a daemon-pool thread and this process was going to give up and
1053
+ move on regardless."""
1054
+ future = _dns_bound_pool.submit(urllib.request.urlopen, req_or_url, timeout=timeout)
1055
+ return future.result(timeout=timeout + 1)
1056
+
1057
+
982
1058
  def post_event(relay_url, event):
983
1059
  hint = _relay_url_hint(relay_url)
984
1060
  if hint:
@@ -987,11 +1063,11 @@ def post_event(relay_url, event):
987
1063
  f'{relay_url}/event', data=json.dumps(event).encode(),
988
1064
  headers={'Content-Type': 'application/json'}, method='POST')
989
1065
  try:
990
- with urllib.request.urlopen(req, timeout=5) as resp:
1066
+ with _urlopen_bounded(req, timeout=5) as resp:
991
1067
  return json.loads(resp.read())
992
1068
  except urllib.error.HTTPError as e:
993
1069
  return json.loads(e.read())
994
- except (urllib.error.URLError, ConnectionRefusedError) as e:
1070
+ except (urllib.error.URLError, ConnectionRefusedError, concurrent.futures.TimeoutError) as e:
995
1071
  # download_with_auction posts the post-download attestation to every
996
1072
  # relay in relay_urls, which by default includes 127.0.0.1:9101
997
1073
  # whether or not anything is actually listening there (same default
@@ -1008,9 +1084,9 @@ def fetch_events(relay_url, event_type=None):
1008
1084
  return None
1009
1085
  url = f'{relay_url}/events' + (f'?type={event_type}' if event_type else '')
1010
1086
  try:
1011
- with urllib.request.urlopen(url, timeout=5) as resp:
1087
+ with _urlopen_bounded(url, timeout=5) as resp:
1012
1088
  return json.loads(resp.read())
1013
- except (urllib.error.URLError, ConnectionRefusedError):
1089
+ except (urllib.error.URLError, ConnectionRefusedError, concurrent.futures.TimeoutError):
1014
1090
  return None
1015
1091
 
1016
1092
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "weed-cli"
7
- version = "1.6.0"
7
+ version = "1.6.2"
8
8
  description = "Censorship-resistant video PoC — discovery/hosting/download over signed relay events, a real Kademlia DHT, or a TLS-capable NAT-traversal tunnel"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -187,9 +187,35 @@ def _ott_status(archive_dir):
187
187
 
188
188
 
189
189
  def _resume_persisted_hosts():
190
+ """Sequential on purpose, not fire-and-forget-them-all-at-once: two
191
+ persisted configs sharing a port (the default in the Host form is
192
+ always 9201, so this is the common case, not an edge case) could
193
+ both bind successfully if started concurrently, since node.bind_host_port
194
+ for the second one wouldn't even run until the first's already had time
195
+ to occupy the port — but nothing serializes *when* each thread gets
196
+ there. Waiting for each host to reach 'bound' (or 'error', if the bind
197
+ itself failed) before starting the next one closes that window: by the
198
+ time the next one binds, the previous one already has the port or
199
+ already knows it can't.
200
+
201
+ Only waits for the bind, not all the way to 'running' — 'running'
202
+ doesn't happen until after each host's full publish loop (real network
203
+ I/O, up to 5s timeout per relay), which several persisted hosts
204
+ resuming in sequence have no reason to serialize on. That was a real
205
+ regression the first version of this function had: booting with a
206
+ handful of persisted hosts got noticeably slower, for zero extra
207
+ correctness, since the publish delay doesn't affect port ownership at
208
+ all."""
190
209
  for cfg in _persisted_hosts.values():
191
- _start_host_job(cfg['archive_dir'], cfg['file_name'], cfg['port'], cfg['price'],
192
- cfg['relay'], cfg['advertise_host'], cfg['tunnel'], cfg.get('lightning_node'))
210
+ host_id = _start_host_job(cfg['archive_dir'], cfg['file_name'], cfg['port'], cfg['price'],
211
+ cfg['relay'], cfg['advertise_host'], cfg['tunnel'],
212
+ cfg.get('lightning_node'))
213
+ for _ in range(100): # up to ~10s per host before moving on regardless
214
+ with _lock:
215
+ status = _hosts[host_id]['status']
216
+ if status in ('bound', 'running', 'error'):
217
+ break
218
+ time.sleep(0.1)
193
219
 
194
220
 
195
221
  def _unpublish_all_hosts():
@@ -284,29 +310,6 @@ def _detect_lan_ip():
284
310
  s.close()
285
311
 
286
312
 
287
- def _probe_port_free(port, bind_host='0.0.0.0'):
288
- """Bind-and-immediately-release, purely to fail fast on a port
289
- collision *before* announcing anything — see _run_host_job's real
290
- incident: the web UI's Host form always defaults to port 9201, so
291
- hosting more than one file (including multiple entries auto-resumed
292
- at once on startup, see _resume_persisted_hosts) without picking a
293
- distinct port each time meant every loser published a real, signed
294
- announcement and only discovered it couldn't actually bind *after*
295
- — a permanent, unreachable zombie listing nothing could ever clean
296
- up, since a host that never reaches 'running' never gets files/
297
- announced_on populated for the graceful-shutdown unpublish to use
298
- either. Still has a small window (this socket closes before
299
- run_host_server's own real bind), not a hard guarantee — but it
300
- catches the common case (concurrent startup, the actual incident)
301
- instead of the current zero attempt to catch it at all."""
302
- probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
303
- probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
304
- try:
305
- probe.bind((bind_host, port))
306
- finally:
307
- probe.close()
308
-
309
-
310
313
  def _run_host_job(host_id, archive_dir, file_name, port, price, relay_urls, advertise_host, tunnel,
311
314
  ln_node=None):
312
315
  captured = io.StringIO()
@@ -325,10 +328,24 @@ def _run_host_job(host_id, archive_dir, file_name, port, price, relay_urls, adve
325
328
  # and only fail later, deep in a background thread with no way
326
329
  # for the UI to ever find out
327
330
  all_leaves = {e['sha256']: node.load_leaves(archive_dir, e['sha256']) for e in entries}
328
- # also fail fast on a port collision, before announcing anything
329
- # — see _probe_port_free's docstring for the real incident this
330
- # prevents
331
- _probe_port_free(port)
331
+ # bind the real, permanent listening socket now — before
332
+ # announcing anything — and hand it to run_host_server below
333
+ # instead of it binding a second one later. A separate
334
+ # bind-then-close probe here, followed by a *later* real bind,
335
+ # isn't atomic across two hosts starting concurrently (see
336
+ # _resume_persisted_hosts): both could pass the probe before
337
+ # either did the real bind. One bind, kept alive and reused,
338
+ # has no such window — the port either really is free right
339
+ # now or this raises immediately, before any publish.
340
+ bound_sock = node.bind_host_port(port)
341
+ # a distinct status the instant the bind (the only part
342
+ # _resume_persisted_hosts actually needs to wait for) is done
343
+ # — 'running' doesn't happen until after the publish loop
344
+ # below, which is real network I/O (up to 5s timeout *per*
345
+ # relay, see post_event) that resuming several hosts
346
+ # sequentially has no reason to serialize on
347
+ with _lock:
348
+ _hosts[host_id]['status'] = 'bound'
332
349
  ott_status = node.ott_commit_status(archive_dir)
333
350
  announced = []
334
351
  for entry in entries:
@@ -354,7 +371,8 @@ def _run_host_job(host_id, archive_dir, file_name, port, price, relay_urls, adve
354
371
  all_leaves[entry['sha256']], file_path, price),
355
372
  kwargs={'use_tls': use_tls, 'quiet': True, 'ln_node': ln_node},
356
373
  daemon=True).start()
357
- node.run_host_server(archive_dir, file_name, port, quiet=True, price=price, ln_node=ln_node)
374
+ node.run_host_server(archive_dir, file_name, port, quiet=True, price=price, ln_node=ln_node,
375
+ sock=bound_sock)
358
376
  except SystemExit as e:
359
377
  with _lock:
360
378
  _hosts[host_id].update(status='error', error=_with_captured_detail(str(e), captured))
@@ -374,6 +392,11 @@ def _run_download_job(job_id, content_hash, relay_urls, out_path, k, use_lightni
374
392
  try:
375
393
  t0 = time.time()
376
394
  with _quiet() as captured:
395
+ # registered as soon as it's the real live buffer (not the
396
+ # placeholder above) so /api/download/<job_id> can read
397
+ # node.py's real prints as they happen, not just after the
398
+ # job finishes — see the GET handler's use of _job_logs
399
+ _job_logs[job_id] = captured
377
400
  path = node.download_with_auction(content_hash, relay_urls, out_path=out_path, k=k,
378
401
  use_lightning=use_lightning, lightning_node=lightning_node,
379
402
  on_progress=on_progress)
@@ -458,9 +481,16 @@ class Handler(BaseHTTPRequestHandler):
458
481
  'subscriptions': list(_library['subscriptions']),
459
482
  })
460
483
  if path.startswith('/api/download/'):
484
+ job_id = path[len('/api/download/'):]
461
485
  with _lock:
462
- job = _jobs.get(path[len('/api/download/'):])
463
- return self._json(job) if job else self._json({'error': 'no such job'}, status=404)
486
+ job = _jobs.get(job_id)
487
+ job = dict(job) if job else None
488
+ if not job:
489
+ return self._json({'error': 'no such job'}, status=404)
490
+ log_buf = _job_logs.get(job_id)
491
+ if log_buf is not None:
492
+ job['log'] = log_buf.getvalue()
493
+ return self._json(job)
464
494
  if path.startswith('/api/reputation/'):
465
495
  from poc_reputation import ReputationStore
466
496
  pubkey = path[len('/api/reputation/'):]
@@ -755,6 +785,13 @@ def run_web_ui(port=8080, bind_host='127.0.0.1', quiet=False, advertise_host=Non
755
785
  _lan_url = f'http://{reachable_host}:{port}/'
756
786
 
757
787
  if not quiet:
788
+ # answers "is this container actually running the code I think it
789
+ # is" directly in `docker compose logs`/`make node`'s own output —
790
+ # see node.weed_banner()'s own docstring for exactly the debugging
791
+ # session that motivated adding a commit hash to it in the first
792
+ # place; printing it here means the same question doesn't need a
793
+ # docker exec + grep to answer for the web UI specifically
794
+ print(f"[web:{port}] {node.weed_banner()}", flush=True)
758
795
  print(f"[web:{port}] weed control UI at http://{bind_host}:{port}/", flush=True)
759
796
  if bind_host == '127.0.0.1':
760
797
  print(" bound to localhost only -- pass --bind 0.0.0.0 to reach this from your "
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: weed-cli
3
- Version: 1.6.0
3
+ Version: 1.6.2
4
4
  Summary: Censorship-resistant video PoC — discovery/hosting/download over signed relay events, a real Kademlia DHT, or a TLS-capable NAT-traversal tunnel
5
5
  License: MIT
6
6
  Keywords: p2p,video,censorship-resistant,discovery,dht,kademlia,nat-traversal
@@ -180,6 +180,8 @@ somewhere other than a laptop.
180
180
  make node # build + run, http://127.0.0.1:8080
181
181
  make node-down # stop it — data persists
182
182
  WEED_SHARE_DIR=~/Movies make node # mount a real directory of .ott archives at /share
183
+ docker compose -f docker-compose.node.yml exec node python3 shell.py # interactive shell, same container
184
+ docker compose -f docker-compose.node.yml exec node python3 weed.py discover # or a one-off CLI command
183
185
  ```
184
186
 
185
187
  - Identity key, reputation store, and library manifest all persist in a
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