ghosttrap-cli 0.3.19__tar.gz → 0.3.21__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: ghosttrap-cli
3
- Version: 0.3.19
3
+ Version: 0.3.21
4
4
  Summary: Watch for errors streaming from ghosttrap.io
5
5
  Project-URL: Homepage, https://github.com/alex-rowley/ghosttrap-cli
6
6
  Requires-Python: >=3.10
@@ -4,6 +4,7 @@ import argparse
4
4
  import asyncio
5
5
  import json
6
6
  import os
7
+ import signal
7
8
  import subprocess
8
9
  import sys
9
10
  import tempfile
@@ -13,7 +14,20 @@ import urllib.request
13
14
 
14
15
  import websockets
15
16
 
16
- __version__ = "0.3.19"
17
+
18
+ def _harden_signals():
19
+ """Explicitly ignore SIGURG so no process supervisor can nudge peek out
20
+ with an out-of-band signal. POSIX default is already ignore; this makes
21
+ it defensive against layers that change the disposition.
22
+ """
23
+ s = getattr(signal, "SIGURG", None)
24
+ if s is not None:
25
+ try:
26
+ signal.signal(s, signal.SIG_IGN)
27
+ except (OSError, ValueError):
28
+ pass
29
+
30
+ __version__ = "0.3.21"
17
31
 
18
32
  GHOSTTRAP_SERVER = "wss://ghosttrap.io/stream/"
19
33
  CONFIG_DIR = os.path.expanduser("~/.ghosttrap")
@@ -231,7 +245,10 @@ def _get_repo_token(config, requested=None):
231
245
 
232
246
 
233
247
  async def _connect_and_handle(server_url, token, config, once=False):
234
- """Core WebSocket loop. If once=True, exit after the first error."""
248
+ """Core WebSocket loop. If once=True, returns True after the first error event.
249
+ Returns False if the server closed the socket without sending an error
250
+ (e.g. idle timeout) so callers can distinguish 'job done' from 'reconnect me'.
251
+ """
235
252
  since = config.get("cursor")
236
253
  url = f"{server_url}?token={token}"
237
254
  if since is not None:
@@ -293,7 +310,8 @@ async def _connect_and_handle(server_url, token, config, once=False):
293
310
  print(f"{'='*60}", file=sys.stderr)
294
311
 
295
312
  if once:
296
- return
313
+ return True
314
+ return False
297
315
 
298
316
 
299
317
  def _require_setup():
@@ -412,6 +430,31 @@ async def setup(server_url, token):
412
430
  sys.exit(1)
413
431
 
414
432
 
433
+ # Exception types we knowingly retry on. All represent a transient network/transport
434
+ # blip rather than a semantic error from the server:
435
+ # - ConnectionClosed: peer closed the WebSocket (either side of the handshake)
436
+ # - InvalidStatus: non-101 HTTP response during upgrade (e.g. 502 from a proxy)
437
+ # - ConnectionError: builtin — refused, reset, unreachable
438
+ # - OSError: DNS failure, transient socket errors (gaierror is a subclass)
439
+ # Anything else escapes and prints a diagnostic line first so we can add it here
440
+ # in the next release. Semantic rejections from the server ({"type": "rejected"})
441
+ # raise SystemExit, which we deliberately do NOT catch — those are real errors.
442
+ _RETRYABLE = (
443
+ websockets.ConnectionClosed,
444
+ websockets.InvalidStatus,
445
+ ConnectionError,
446
+ OSError,
447
+ )
448
+
449
+
450
+ def _log_unexpected(e):
451
+ print(
452
+ f"unexpected {type(e).__module__}.{type(e).__name__}: {e} — "
453
+ f"not currently in the retry list; please report so we can add it.",
454
+ file=sys.stderr,
455
+ )
456
+
457
+
415
458
  async def watch(server_url, token):
416
459
  config = _load_config()
417
460
  print(f"connecting to {server_url}...", file=sys.stderr)
@@ -419,9 +462,13 @@ async def watch(server_url, token):
419
462
  while True:
420
463
  try:
421
464
  await _connect_and_handle(server_url, token, config, once=False)
422
- except (websockets.ConnectionClosed, websockets.InvalidStatus, ConnectionError, OSError):
465
+ print("connection closed by server, reconnecting...", file=sys.stderr)
466
+ except _RETRYABLE:
423
467
  print("connection lost, reconnecting...", file=sys.stderr)
424
- await asyncio.sleep(60)
468
+ except Exception as e:
469
+ _log_unexpected(e)
470
+ raise
471
+ await asyncio.sleep(60)
425
472
 
426
473
 
427
474
  async def peek(server_url, token):
@@ -429,11 +476,16 @@ async def peek(server_url, token):
429
476
  _check_cli_version(config)
430
477
  while True:
431
478
  try:
432
- await _connect_and_handle(server_url, token, config, once=True)
433
- return # got an error, printed it, done
434
- except (websockets.ConnectionClosed, websockets.InvalidStatus, ConnectionError, OSError):
479
+ got_error = await _connect_and_handle(server_url, token, config, once=True)
480
+ if got_error:
481
+ return
482
+ print("connection closed by server, reconnecting...", file=sys.stderr)
483
+ except _RETRYABLE:
435
484
  print("connection lost, reconnecting...", file=sys.stderr)
436
- await asyncio.sleep(60)
485
+ except Exception as e:
486
+ _log_unexpected(e)
487
+ raise
488
+ await asyncio.sleep(60)
437
489
 
438
490
 
439
491
  def _advance_cursor(config, token):
@@ -669,6 +721,7 @@ def last(do_clear=False, requested=None):
669
721
 
670
722
 
671
723
  def main():
724
+ _harden_signals()
672
725
  parser = argparse.ArgumentParser(prog="ghosttrap", description="Watch for errors from ghosttrap.io")
673
726
  sub = parser.add_subparsers(dest="command")
674
727
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ghosttrap-cli
3
- Version: 0.3.19
3
+ Version: 0.3.21
4
4
  Summary: Watch for errors streaming from ghosttrap.io
5
5
  Project-URL: Homepage, https://github.com/alex-rowley/ghosttrap-cli
6
6
  Requires-Python: >=3.10
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ghosttrap-cli"
7
- version = "0.3.19"
7
+ version = "0.3.21"
8
8
  description = "Watch for errors streaming from ghosttrap.io"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
File without changes
File without changes