portacode 0.3.19.dev4__py3-none-any.whl → 1.4.11.dev1__py3-none-any.whl

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.

Potentially problematic release.


This version of portacode might be problematic. Click here for more details.

Files changed (92) hide show
  1. portacode/_version.py +16 -3
  2. portacode/cli.py +143 -17
  3. portacode/connection/client.py +149 -10
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +824 -21
  5. portacode/connection/handlers/__init__.py +28 -1
  6. portacode/connection/handlers/base.py +78 -16
  7. portacode/connection/handlers/chunked_content.py +244 -0
  8. portacode/connection/handlers/diff_handlers.py +603 -0
  9. portacode/connection/handlers/file_handlers.py +902 -17
  10. portacode/connection/handlers/project_aware_file_handlers.py +226 -0
  11. portacode/connection/handlers/project_state/README.md +312 -0
  12. portacode/connection/handlers/project_state/__init__.py +92 -0
  13. portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
  14. portacode/connection/handlers/project_state/git_manager.py +1502 -0
  15. portacode/connection/handlers/project_state/handlers.py +875 -0
  16. portacode/connection/handlers/project_state/manager.py +1331 -0
  17. portacode/connection/handlers/project_state/models.py +108 -0
  18. portacode/connection/handlers/project_state/utils.py +50 -0
  19. portacode/connection/handlers/project_state_handlers.py +45 -2185
  20. portacode/connection/handlers/proxmox_infra.py +361 -0
  21. portacode/connection/handlers/registry.py +15 -4
  22. portacode/connection/handlers/session.py +483 -32
  23. portacode/connection/handlers/system_handlers.py +147 -8
  24. portacode/connection/handlers/tab_factory.py +53 -46
  25. portacode/connection/handlers/terminal_handlers.py +21 -8
  26. portacode/connection/handlers/update_handler.py +61 -0
  27. portacode/connection/multiplex.py +60 -2
  28. portacode/connection/terminal.py +214 -24
  29. portacode/keypair.py +63 -1
  30. portacode/link_capture/__init__.py +38 -0
  31. portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
  32. portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
  33. portacode/link_capture/bin/elinks +3 -0
  34. portacode/link_capture/bin/gio-open +3 -0
  35. portacode/link_capture/bin/gnome-open +3 -0
  36. portacode/link_capture/bin/gvfs-open +3 -0
  37. portacode/link_capture/bin/kde-open +3 -0
  38. portacode/link_capture/bin/kfmclient +3 -0
  39. portacode/link_capture/bin/link_capture_exec.sh +11 -0
  40. portacode/link_capture/bin/link_capture_wrapper.py +75 -0
  41. portacode/link_capture/bin/links +3 -0
  42. portacode/link_capture/bin/links2 +3 -0
  43. portacode/link_capture/bin/lynx +3 -0
  44. portacode/link_capture/bin/mate-open +3 -0
  45. portacode/link_capture/bin/netsurf +3 -0
  46. portacode/link_capture/bin/sensible-browser +3 -0
  47. portacode/link_capture/bin/w3m +3 -0
  48. portacode/link_capture/bin/x-www-browser +3 -0
  49. portacode/link_capture/bin/xdg-open +3 -0
  50. portacode/logging_categories.py +140 -0
  51. portacode/pairing.py +103 -0
  52. portacode/static/js/test-ntp-clock.html +63 -0
  53. portacode/static/js/utils/ntp-clock.js +232 -0
  54. portacode/utils/NTP_ARCHITECTURE.md +136 -0
  55. portacode/utils/__init__.py +1 -0
  56. portacode/utils/diff_apply.py +456 -0
  57. portacode/utils/diff_renderer.py +371 -0
  58. portacode/utils/ntp_clock.py +65 -0
  59. portacode-1.4.11.dev1.dist-info/METADATA +298 -0
  60. portacode-1.4.11.dev1.dist-info/RECORD +97 -0
  61. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/WHEEL +1 -1
  62. portacode-1.4.11.dev1.dist-info/top_level.txt +3 -0
  63. test_modules/README.md +296 -0
  64. test_modules/__init__.py +1 -0
  65. test_modules/test_device_online.py +44 -0
  66. test_modules/test_file_operations.py +743 -0
  67. test_modules/test_git_status_ui.py +370 -0
  68. test_modules/test_login_flow.py +50 -0
  69. test_modules/test_navigate_testing_folder.py +361 -0
  70. test_modules/test_play_store_screenshots.py +294 -0
  71. test_modules/test_terminal_buffer_performance.py +261 -0
  72. test_modules/test_terminal_interaction.py +80 -0
  73. test_modules/test_terminal_loading_race_condition.py +95 -0
  74. test_modules/test_terminal_start.py +56 -0
  75. testing_framework/.env.example +21 -0
  76. testing_framework/README.md +334 -0
  77. testing_framework/__init__.py +17 -0
  78. testing_framework/cli.py +326 -0
  79. testing_framework/core/__init__.py +1 -0
  80. testing_framework/core/base_test.py +336 -0
  81. testing_framework/core/cli_manager.py +177 -0
  82. testing_framework/core/hierarchical_runner.py +577 -0
  83. testing_framework/core/playwright_manager.py +520 -0
  84. testing_framework/core/runner.py +447 -0
  85. testing_framework/core/shared_cli_manager.py +234 -0
  86. testing_framework/core/test_discovery.py +112 -0
  87. testing_framework/requirements.txt +12 -0
  88. portacode-0.3.19.dev4.dist-info/METADATA +0 -241
  89. portacode-0.3.19.dev4.dist-info/RECORD +0 -30
  90. portacode-0.3.19.dev4.dist-info/top_level.txt +0 -1
  91. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/entry_points.txt +0 -0
  92. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info/licenses}/LICENSE +0 -0
portacode/_version.py CHANGED
@@ -1,7 +1,14 @@
1
1
  # file generated by setuptools-scm
2
2
  # don't change, don't track in version control
3
3
 
4
- __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
5
12
 
6
13
  TYPE_CHECKING = False
7
14
  if TYPE_CHECKING:
@@ -9,13 +16,19 @@ if TYPE_CHECKING:
9
16
  from typing import Union
10
17
 
11
18
  VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
12
20
  else:
13
21
  VERSION_TUPLE = object
22
+ COMMIT_ID = object
14
23
 
15
24
  version: str
16
25
  __version__: str
17
26
  __version_tuple__: VERSION_TUPLE
18
27
  version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
19
30
 
20
- __version__ = version = '0.3.19.dev4'
21
- __version_tuple__ = version_tuple = (0, 3, 19, 'dev4')
31
+ __version__ = version = '1.4.11.dev1'
32
+ __version_tuple__ = version_tuple = (1, 4, 11, 'dev1')
33
+
34
+ __commit_id__ = commit_id = None
portacode/cli.py CHANGED
@@ -7,17 +7,25 @@ from multiprocessing import Process
7
7
  from pathlib import Path
8
8
  import signal
9
9
  import json
10
+ import socket
10
11
 
11
12
  import click
12
13
  import pyperclip
13
14
 
14
15
  from . import __version__
15
16
  from .data import get_pid_file, is_process_running
16
- from .keypair import get_or_create_keypair, fingerprint_public_key
17
+ from .keypair import (
18
+ get_or_create_keypair,
19
+ fingerprint_public_key,
20
+ generate_in_memory_keypair,
21
+ keypair_files_exist,
22
+ )
23
+ from .pairing import PairingError, pair_device_with_code
17
24
  from .connection.client import ConnectionManager, run_until_interrupt
18
25
 
19
- GATEWAY_URL = "wss://device.portacode.com/gateway"
26
+ GATEWAY_URL = "wss://portacode.com/gateway"
20
27
  GATEWAY_ENV = "PORTACODE_GATEWAY"
28
+ MAX_PROJECT_PATHS = 10
21
29
 
22
30
 
23
31
  @click.group()
@@ -30,19 +38,57 @@ def cli() -> None:
30
38
  @click.option("--gateway", "gateway", "-g", help="Gateway websocket URL (overrides env/ default)")
31
39
  @click.option("--detach", "detach", "-d", is_flag=True, help="Run connection in background")
32
40
  @click.option("--debug", "debug", is_flag=True, help="Enable debug logging")
41
+ @click.option("--log-categories", "log_categories", help="Comma-separated list of log categories to show (e.g., 'connection,auth,git'). Use 'list' to see available categories.")
33
42
  @click.option("--non-interactive", "non_interactive", is_flag=True, envvar="PORTACODE_NON_INTERACTIVE", hidden=True,
34
43
  help="Skip interactive prompts (used by background service)")
35
- def connect(gateway: str | None, detach: bool, debug: bool, non_interactive: bool) -> None: # noqa: D401 Click callback
44
+ @click.option("--pairing-code", "pairing_code_opt", envvar="PORTACODE_PAIRING_CODE", help="Provide a temporary pairing code for zero-copy onboarding")
45
+ @click.option("--device-name", "device_name_opt", envvar="PORTACODE_DEVICE_NAME", help="Custom device name to display during pairing")
46
+ @click.option(
47
+ "--project-path",
48
+ "project_paths_opt",
49
+ multiple=True,
50
+ help="Project folder to register during pairing (repeat for multiple paths)",
51
+ )
52
+ def connect(
53
+ gateway: str | None,
54
+ detach: bool,
55
+ debug: bool,
56
+ log_categories: str | None,
57
+ non_interactive: bool,
58
+ pairing_code_opt: str | None,
59
+ device_name_opt: str | None,
60
+ project_paths_opt: tuple[str, ...],
61
+ ) -> None: # noqa: D401 – Click callback
36
62
  """Connect this machine to Portacode gateway."""
37
63
 
38
64
  # Set up debug logging if requested
39
65
  if debug:
40
66
  import logging
67
+ from .logging_categories import configure_logging_categories, parse_category_string, list_available_categories
68
+
69
+ # Handle log categories
70
+ if log_categories == "list":
71
+ click.echo(click.style("Available log categories:", fg="cyan"))
72
+ for cat in list_available_categories():
73
+ click.echo(f" • {cat}")
74
+ return
75
+
76
+ enabled_categories = set()
77
+ if log_categories:
78
+ try:
79
+ enabled_categories = parse_category_string(log_categories)
80
+ configure_logging_categories(enabled_categories)
81
+ click.echo(click.style(f"🔍 Debug logging enabled for categories: {', '.join(sorted(enabled_categories))}", fg="yellow"))
82
+ except ValueError as e:
83
+ click.echo(click.style(f"Error: {e}", fg="red"))
84
+ return
85
+ else:
86
+ click.echo(click.style("🔍 Debug logging enabled (all categories)", fg="yellow"))
87
+
41
88
  logging.basicConfig(
42
89
  level=logging.DEBUG,
43
90
  format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
44
91
  )
45
- click.echo(click.style("🔍 Debug logging enabled", fg="yellow"))
46
92
 
47
93
  # 1. Ensure only a single connection per user
48
94
  pid_file = get_pid_file()
@@ -53,17 +99,31 @@ def connect(gateway: str | None, detach: bool, debug: bool, non_interactive: boo
53
99
  other_pid = None
54
100
 
55
101
  if other_pid and is_process_running(other_pid):
56
- click.echo(
57
- click.style(
58
- f"Another portacode connection (PID {other_pid}) is active.", fg="yellow"
102
+ if other_pid == os.getpid():
103
+ # We just wrote our own PID (stale pidfile from previous run) – ignore safely.
104
+ click.echo(
105
+ click.style(
106
+ "Detected stale portacode pid file referencing the current process; ignoring.",
107
+ fg="bright_black",
108
+ )
59
109
  )
60
- )
61
- if click.confirm("Terminate the existing connection?", default=False):
62
- _terminate_process(other_pid)
63
110
  pid_file.unlink(missing_ok=True)
64
111
  else:
65
- click.echo("Aborting.")
66
- sys.exit(1)
112
+ click.echo(
113
+ click.style(
114
+ f"Another portacode connection (PID {other_pid}) is active.", fg="yellow"
115
+ )
116
+ )
117
+ if non_interactive:
118
+ click.echo(click.style("Non-interactive mode: terminating the existing connection automatically.", fg="bright_black"))
119
+ _terminate_process(other_pid)
120
+ pid_file.unlink(missing_ok=True)
121
+ elif click.confirm("Terminate the existing connection?", default=False):
122
+ _terminate_process(other_pid)
123
+ pid_file.unlink(missing_ok=True)
124
+ else:
125
+ click.echo("Aborting.")
126
+ sys.exit(1)
67
127
  else:
68
128
  # Stale pidfile
69
129
  pid_file.unlink(missing_ok=True)
@@ -71,12 +131,78 @@ def connect(gateway: str | None, detach: bool, debug: bool, non_interactive: boo
71
131
  # Determine gateway URL
72
132
  target_gateway = gateway or os.getenv(GATEWAY_ENV) or GATEWAY_URL
73
133
 
74
- # 2. Load or create keypair
75
- keypair = get_or_create_keypair()
134
+ pairing_code = pairing_code_opt.strip() if pairing_code_opt else None
135
+ device_name = device_name_opt.strip() if device_name_opt else None
136
+ normalized_project_paths: list[str] = []
137
+ for raw in project_paths_opt:
138
+ if not raw:
139
+ continue
140
+ cleaned = raw.strip()
141
+ if not cleaned:
142
+ continue
143
+ if len(cleaned) > 500:
144
+ click.echo(click.style(f"Project path too long (max 500 chars): {cleaned[:60]}…", fg="red"))
145
+ sys.exit(1)
146
+ if cleaned not in normalized_project_paths:
147
+ normalized_project_paths.append(cleaned)
148
+ if len(normalized_project_paths) > MAX_PROJECT_PATHS:
149
+ click.echo(
150
+ click.style(
151
+ f"Too many project paths provided ({len(normalized_project_paths)}). "
152
+ f"Maximum allowed is {MAX_PROJECT_PATHS}.",
153
+ fg="red",
154
+ )
155
+ )
156
+ sys.exit(1)
157
+
158
+ pairing_requested = bool(pairing_code)
159
+ existing_identity = keypair_files_exist()
160
+ should_pair = pairing_requested and not existing_identity
161
+
162
+ if normalized_project_paths and not pairing_requested:
163
+ click.echo(
164
+ click.style(
165
+ "⚠ Project paths were provided but no pairing code was supplied; "
166
+ "they will be ignored.",
167
+ fg="yellow",
168
+ )
169
+ )
170
+
171
+ # 2. Load or create keypair (in memory if pairing)
172
+ if should_pair:
173
+ keypair = generate_in_memory_keypair()
174
+ else:
175
+ keypair = get_or_create_keypair()
176
+
177
+ if should_pair:
178
+ if not device_name:
179
+ device_name = socket.gethostname() or "Portacode Device"
180
+ click.echo(click.style(f"🔐 Pairing code detected; pairing as '{device_name}'...", fg="cyan"))
181
+ try:
182
+ pair_device_with_code(
183
+ keypair,
184
+ pairing_code=pairing_code,
185
+ device_name=device_name,
186
+ project_paths=normalized_project_paths or None,
187
+ )
188
+ except PairingError as exc:
189
+ click.echo(click.style(f"Pairing failed: {exc}", fg="red"))
190
+ sys.exit(1)
191
+ # Persist keypair after successful approval
192
+ keypair = keypair.persist()
193
+ click.echo(click.style("✅ Pairing approved. Continuing with connection…", fg="green"))
194
+ elif pairing_requested and existing_identity:
195
+ click.echo(
196
+ click.style(
197
+ "ℹ Pairing code provided but an existing device identity was found; skipping pairing step.",
198
+ fg="yellow",
199
+ )
200
+ )
201
+
76
202
  fingerprint = fingerprint_public_key(keypair.public_key_pem)
77
203
 
78
204
  pubkey_b64 = keypair.public_key_der_b64()
79
- if not non_interactive:
205
+ if not non_interactive and not pairing_requested:
80
206
  # Show key generation status
81
207
  if getattr(keypair, '_is_new', False):
82
208
  click.echo()
@@ -94,7 +220,7 @@ def connect(gateway: str | None, detach: bool, debug: bool, non_interactive: boo
94
220
  click.echo(click.style("🚀 Welcome to Portacode!", fg="bright_blue", bold=True))
95
221
  click.echo()
96
222
  click.echo(click.style("📱 Next steps:", fg="bright_cyan", bold=True))
97
- click.echo(click.style(" 1. Visit ", fg="white") + click.style("https://remote.portacode.com", fg="bright_blue", underline=True))
223
+ click.echo(click.style(" 1. Visit ", fg="white") + click.style("https://portacode.com", fg="bright_blue", underline=True))
98
224
  click.echo(click.style(" 2. Create your free account or sign in", fg="white"))
99
225
  click.echo(click.style(" 3. Add this device using the key below", fg="white"))
100
226
  click.echo()
@@ -378,4 +504,4 @@ def service_status(verbose: bool) -> None: # noqa: D401
378
504
  click.echo("\n--- system output ---")
379
505
  click.echo(mgr.status_verbose())
380
506
  except Exception as exc:
381
- click.echo(click.style(f"Failed: {exc}", fg="red"))
507
+ click.echo(click.style(f"Failed: {exc}", fg="red"))
@@ -8,14 +8,17 @@ from typing import Optional
8
8
  import json
9
9
  import base64
10
10
  import sys
11
+ import secrets
12
+ import time
11
13
 
12
14
  import websockets
13
15
  from websockets import WebSocketClientProtocol
14
16
 
15
17
  from ..keypair import KeyPair
16
18
  from .multiplex import Multiplexer
19
+ from ..logging_categories import get_categorized_logger, LogCategory
17
20
 
18
- logger = logging.getLogger(__name__)
21
+ logger = get_categorized_logger(__name__)
19
22
 
20
23
 
21
24
  class ConnectionManager:
@@ -24,7 +27,7 @@ class ConnectionManager:
24
27
  Parameters
25
28
  ----------
26
29
  gateway_url: str
27
- WebSocket URL, e.g. ``wss://device.portacode.com/gateway``
30
+ WebSocket URL, e.g. ``wss://portacode.com/gateway``
28
31
  keypair: KeyPair
29
32
  User's public/private keypair used for authentication.
30
33
  reconnect_delay: float
@@ -35,6 +38,12 @@ class ConnectionManager:
35
38
  service manager restart.
36
39
  """
37
40
 
41
+ CLOCK_SYNC_INTERVAL = 60.0
42
+ CLOCK_SYNC_FAST_INTERVAL = 1.0
43
+ CLOCK_SYNC_INITIAL_REQUESTS = 5
44
+ CLOCK_SYNC_TIMEOUT = 20.0
45
+ CLOCK_SYNC_MAX_FAILURES = 3
46
+
38
47
  def __init__(self, gateway_url: str, keypair: KeyPair, reconnect_delay: float = 1.0, max_retries: int = None, debug: bool = False):
39
48
  self.gateway_url = gateway_url
40
49
  self.keypair = keypair
@@ -48,6 +57,12 @@ class ConnectionManager:
48
57
 
49
58
  self.websocket: Optional[WebSocketClientProtocol] = None
50
59
  self.mux: Optional[Multiplexer] = None
60
+ self._clock_sync_task: Optional[asyncio.Task[None]] = None
61
+ self._clock_sync_future: Optional[asyncio.Future] = None
62
+ self._clock_sync_request_id: Optional[str] = None
63
+ self._clock_sync_sent_at: Optional[float] = None
64
+ self._clock_sync_failures = 0
65
+ self._remaining_initial_syncs = self.CLOCK_SYNC_INITIAL_REQUESTS
51
66
 
52
67
  async def start(self) -> None:
53
68
  """Start the background task that maintains the connection."""
@@ -67,9 +82,9 @@ class ConnectionManager:
67
82
  try:
68
83
  if attempt:
69
84
  delay = min(self.reconnect_delay * 2 ** (attempt - 1), 30)
70
- logger.warning("Reconnecting in %.1f s (attempt %d)…", delay, attempt)
85
+ logger.warning("Reconnecting in %.1f s (attempt %d)…", LogCategory.CONNECTION, delay, attempt)
71
86
  await asyncio.sleep(delay)
72
- logger.info("Connecting to gateway at %s", self.gateway_url)
87
+ logger.info("Connecting to gateway at %s", LogCategory.CONNECTION, self.gateway_url)
73
88
  async with websockets.connect(self.gateway_url) as ws:
74
89
  # Reset attempt counter after successful connection
75
90
  attempt = 0
@@ -90,19 +105,23 @@ class ConnectionManager:
90
105
  else:
91
106
  self._terminal_manager = TerminalManager(self.mux, debug=self.debug) # noqa: pylint=attribute-defined-outside-init
92
107
  except Exception as exc:
93
- logger.warning("TerminalManager unavailable: %s", exc)
108
+ logger.warning("TerminalManager unavailable: %s", LogCategory.TERMINAL, exc)
94
109
 
95
- # Start main receive loop until closed or stop requested
96
- await self._listen()
110
+ self._start_clock_sync_task()
111
+ try:
112
+ # Start main receive loop until closed or stop requested
113
+ await self._listen()
114
+ finally:
115
+ await self._stop_clock_sync_task()
97
116
  except (OSError, websockets.WebSocketException, asyncio.TimeoutError) as exc:
98
117
  attempt += 1
99
- logger.warning("Connection error: %s", exc)
118
+ logger.warning("Connection error: %s", LogCategory.CONNECTION, exc)
100
119
  # Remove the max_retries limit - keep trying indefinitely
101
120
  # The service manager (systemd) will handle any necessary restarts
102
121
  except Exception as exc:
103
122
  # For truly fatal errors (like authentication failures),
104
123
  # log and exit cleanly so systemd can restart the service
105
- logger.exception("Fatal error in connection manager: %s", exc)
124
+ logger.exception("Fatal error in connection manager: %s", LogCategory.CONNECTION, exc)
106
125
  # Exit cleanly to allow systemd restart
107
126
  sys.exit(1)
108
127
 
@@ -141,12 +160,35 @@ class ConnectionManager:
141
160
  print("Press Cmd+C to close the connection.")
142
161
  else:
143
162
  print("Press Ctrl+C to close the connection.")
163
+ # Finished authentication flow.
144
164
 
145
165
  async def _listen(self) -> None:
146
166
  assert self.websocket is not None, "WebSocket not ready"
147
167
  while not self._stop_event.is_set():
148
168
  try:
149
169
  message = await asyncio.wait_for(self.websocket.recv(), timeout=1.0)
170
+
171
+ try:
172
+ data = json.loads(message)
173
+ except Exception:
174
+ data = None
175
+
176
+ if isinstance(data, dict):
177
+ payload = data.get("payload", {})
178
+ if isinstance(payload, dict) and payload.get("event") == "clock_sync_response":
179
+ receive_time_ms = int(time.time() * 1000)
180
+ await self._handle_clock_sync_response(payload, receive_time_ms)
181
+ continue
182
+ if isinstance(payload, dict) and "trace" in payload and "request_id" in payload:
183
+ from portacode.utils.ntp_clock import ntp_clock
184
+ device_receive_time = ntp_clock.now_ms()
185
+ if device_receive_time is not None:
186
+ payload["trace"]["device_receive"] = device_receive_time
187
+ if "client_send" in payload["trace"]:
188
+ payload["trace"]["ping"] = device_receive_time - payload["trace"]["client_send"]
189
+ message = json.dumps(data)
190
+ logger.info(f"📥 Device received traced message: {payload['request_id']}")
191
+
150
192
  if self.mux:
151
193
  await self.mux.on_raw_message(message)
152
194
  except asyncio.TimeoutError:
@@ -159,6 +201,103 @@ class ConnectionManager:
159
201
  except Exception:
160
202
  pass
161
203
 
204
+ def _start_clock_sync_task(self) -> None:
205
+ if self._clock_sync_task:
206
+ self._clock_sync_task.cancel()
207
+ self._clock_sync_task = asyncio.create_task(self._clock_sync_loop())
208
+ self._remaining_initial_syncs = self.CLOCK_SYNC_INITIAL_REQUESTS
209
+
210
+ async def _stop_clock_sync_task(self) -> None:
211
+ if self._clock_sync_task:
212
+ self._clock_sync_task.cancel()
213
+ try:
214
+ await self._clock_sync_task
215
+ except asyncio.CancelledError:
216
+ pass
217
+ self._clock_sync_task = None
218
+ if self._clock_sync_future and not self._clock_sync_future.done():
219
+ self._clock_sync_future.cancel()
220
+ self._clock_sync_future = None
221
+ self._clock_sync_request_id = None
222
+ self._clock_sync_sent_at = None
223
+
224
+ async def _clock_sync_loop(self) -> None:
225
+ try:
226
+ while self._remaining_initial_syncs > 0 and not self._stop_event.is_set():
227
+ await self._perform_clock_sync()
228
+ self._remaining_initial_syncs -= 1
229
+ if self._remaining_initial_syncs > 0:
230
+ await asyncio.sleep(self.CLOCK_SYNC_FAST_INTERVAL)
231
+ except asyncio.CancelledError:
232
+ pass
233
+
234
+ async def _perform_clock_sync(self) -> None:
235
+ if not self.websocket or getattr(self.websocket, "closed", False):
236
+ return
237
+ request_id = f"clock_sync:{secrets.token_urlsafe(6)}"
238
+ payload = {
239
+ "event": "clock_sync_request",
240
+ "request_id": request_id,
241
+ }
242
+ message = json.dumps({"channel": 0, "payload": payload})
243
+ self._clock_sync_request_id = request_id
244
+ self._clock_sync_sent_at = int(time.time() * 1000)
245
+ loop = asyncio.get_running_loop()
246
+ self._clock_sync_future = loop.create_future()
247
+ try:
248
+ await self.websocket.send(message)
249
+ except Exception as exc:
250
+ logger.warning("Clock sync send failed: %s", exc)
251
+ await self._handle_clock_sync_timeout()
252
+ return
253
+
254
+ try:
255
+ await asyncio.wait_for(self._clock_sync_future, timeout=self.CLOCK_SYNC_TIMEOUT)
256
+ self._clock_sync_failures = 0
257
+ except asyncio.TimeoutError:
258
+ await self._handle_clock_sync_timeout()
259
+ finally:
260
+ self._clock_sync_future = None
261
+ self._clock_sync_request_id = None
262
+ self._clock_sync_sent_at = None
263
+
264
+ async def _handle_clock_sync_response(self, payload: dict, receive_time_ms: int) -> None:
265
+ if payload.get("request_id") != self._clock_sync_request_id:
266
+ return
267
+ if self._clock_sync_future and not self._clock_sync_future.done():
268
+ self._clock_sync_future.set_result(payload)
269
+ server_receive_time = payload.get("server_receive_time")
270
+ server_send_time = payload.get("server_send_time")
271
+ if server_receive_time is not None and server_send_time is not None:
272
+ processing_latency = max(server_send_time - server_receive_time, 0)
273
+ server_time = round(server_receive_time + processing_latency / 2)
274
+ elif server_receive_time is not None:
275
+ server_time = server_receive_time
276
+ elif server_send_time is not None:
277
+ server_time = server_send_time
278
+ else:
279
+ server_time = payload.get("server_time")
280
+ sent_at = self._clock_sync_sent_at
281
+ if server_time is None or sent_at is None:
282
+ return
283
+ round_trip_ms = max(receive_time_ms - sent_at, 0)
284
+ from portacode.utils.ntp_clock import ntp_clock
285
+ ntp_clock.update_from_server(server_time, round_trip_ms)
286
+ self._clock_sync_failures = 0
287
+
288
+ async def _handle_clock_sync_timeout(self) -> None:
289
+ self._clock_sync_failures += 1
290
+ logger.warning(
291
+ "Clock sync timeout (%d/%d), scheduling reconnect...",
292
+ self._clock_sync_failures,
293
+ self.CLOCK_SYNC_MAX_FAILURES,
294
+ )
295
+ if self._clock_sync_failures >= self.CLOCK_SYNC_MAX_FAILURES and self.websocket:
296
+ try:
297
+ await self.websocket.close()
298
+ except Exception:
299
+ pass
300
+
162
301
 
163
302
  async def run_until_interrupt(manager: ConnectionManager) -> None:
164
303
  stop_event = asyncio.Event()
@@ -186,4 +325,4 @@ async def run_until_interrupt(manager: ConnectionManager) -> None:
186
325
  # TODO: Add cleanup logic here (e.g., close sockets, remove PID files, flush logs)
187
326
  pass
188
327
  await manager.stop()
189
- # TODO: Add any final cleanup logic here (e.g., remove PID files, flush logs)
328
+ # TODO: Add any final cleanup logic here (e.g., remove PID files, flush logs)