solstone-linux 0.4.3__tar.gz → 0.4.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.
Files changed (98) hide show
  1. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/CHANGELOG.md +5 -0
  2. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/PKG-INFO +1 -1
  3. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/pyproject.toml +1 -1
  4. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/__init__.py +1 -1
  5. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/sync.py +3 -1
  6. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/upload.py +24 -7
  7. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_sync.py +59 -9
  8. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_upload.py +128 -1
  9. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/.gitignore +0 -0
  10. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/AGENTS.md +0 -0
  11. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/CLAUDE.md +0 -0
  12. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/INSTALL.md +0 -0
  13. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/LICENSE +0 -0
  14. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/Makefile +0 -0
  15. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/README.md +0 -0
  16. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/contrib/icons/hicolor/128x128/apps/solstone-observer.png +0 -0
  17. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/contrib/icons/hicolor/16x16/apps/solstone-observer.png +0 -0
  18. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/contrib/icons/hicolor/24x24/apps/solstone-observer.png +0 -0
  19. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/contrib/icons/hicolor/256x256/apps/solstone-observer.png +0 -0
  20. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/contrib/icons/hicolor/32x32/apps/solstone-observer.png +0 -0
  21. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/contrib/icons/hicolor/48x48/apps/solstone-observer.png +0 -0
  22. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/contrib/icons/hicolor/512x512/apps/solstone-observer.png +0 -0
  23. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/contrib/icons/hicolor/64x64/apps/solstone-observer.png +0 -0
  24. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/contrib/icons/hicolor/scalable/apps/solstone-observer.svg +0 -0
  25. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/contrib/icons/hicolor/scalable/status/solstone-error.svg +0 -0
  26. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/contrib/icons/hicolor/scalable/status/solstone-paused.svg +0 -0
  27. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/contrib/icons/hicolor/scalable/status/solstone-recording.svg +0 -0
  28. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/contrib/icons/hicolor/scalable/status/solstone-syncing.svg +0 -0
  29. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/scripts/extract_changelog.sh +0 -0
  30. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/scripts/release.sh +0 -0
  31. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/activity.py +0 -0
  32. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/audio_detect.py +0 -0
  33. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/audio_mute.py +0 -0
  34. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/audio_recorder.py +0 -0
  35. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/capture_stats.py +0 -0
  36. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/chat_bridge.py +0 -0
  37. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/cli.py +0 -0
  38. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/config.py +0 -0
  39. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/dbus_service.py +0 -0
  40. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/dbusmenu.py +0 -0
  41. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/doctor.py +0 -0
  42. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/event_sender.py +0 -0
  43. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/icons/hicolor/128x128/apps/solstone-observer.png +0 -0
  44. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/icons/hicolor/16x16/apps/solstone-observer.png +0 -0
  45. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/icons/hicolor/24x24/apps/solstone-observer.png +0 -0
  46. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/icons/hicolor/256x256/apps/solstone-observer.png +0 -0
  47. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/icons/hicolor/32x32/apps/solstone-observer.png +0 -0
  48. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/icons/hicolor/48x48/apps/solstone-observer.png +0 -0
  49. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/icons/hicolor/512x512/apps/solstone-observer.png +0 -0
  50. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/icons/hicolor/64x64/apps/solstone-observer.png +0 -0
  51. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/icons/hicolor/scalable/apps/solstone-observer.svg +0 -0
  52. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/icons/hicolor/scalable/status/solstone-error.svg +0 -0
  53. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/icons/hicolor/scalable/status/solstone-paused.svg +0 -0
  54. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/icons/hicolor/scalable/status/solstone-recording.svg +0 -0
  55. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/icons/hicolor/scalable/status/solstone-syncing.svg +0 -0
  56. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/install_guard.py +0 -0
  57. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/monitor_positions.py +0 -0
  58. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/observer.py +0 -0
  59. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/recovery.py +0 -0
  60. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/screencast.py +0 -0
  61. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/session_env.py +0 -0
  62. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/sni.py +0 -0
  63. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/solstone-linux.service.in +0 -0
  64. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/streams.py +0 -0
  65. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/sync_health.py +0 -0
  66. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/src/solstone_linux/tray.py +0 -0
  67. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/__init__.py +0 -0
  68. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/conftest.py +0 -0
  69. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/fixtures/introspection/dbusmenu.xml +0 -0
  70. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/fixtures/introspection/observer1.xml +0 -0
  71. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/fixtures/introspection/status_notifier_item.xml +0 -0
  72. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_activity.py +0 -0
  73. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_audio_detect.py +0 -0
  74. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_audio_recorder.py +0 -0
  75. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_capture_stats.py +0 -0
  76. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_chat_bridge.py +0 -0
  77. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_cli.py +0 -0
  78. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_config.py +0 -0
  79. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_dbus_introspection.py +0 -0
  80. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_dbus_service.py +0 -0
  81. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_dbusmenu.py +0 -0
  82. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_docs_mirror.py +0 -0
  83. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_doctor.py +0 -0
  84. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_event_sender.py +0 -0
  85. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_extract_changelog.py +0 -0
  86. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_install_guard.py +0 -0
  87. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_monitor_positions.py +0 -0
  88. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_observer.py +0 -0
  89. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_observer_emits_stream_silent_event.py +0 -0
  90. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_observer_health_beacon.py +0 -0
  91. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_screencast.py +0 -0
  92. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_screencast_stop_filters_silent_streams.py +0 -0
  93. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_session_env.py +0 -0
  94. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_streams.py +0 -0
  95. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_sync_health.py +0 -0
  96. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_sync_health_surfaces.py +0 -0
  97. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_tray.py +0 -0
  98. {solstone_linux-0.4.3 → solstone_linux-0.4.4}/tests/test_version_match.py +0 -0
@@ -4,6 +4,11 @@ All notable changes to solstone-linux are documented here.
4
4
  The format is based on Keep a Changelog (https://keepachangelog.com/),
5
5
  and this project adheres to Semantic Versioning.
6
6
 
7
+ ## [0.4.4] - 2026-07-04
8
+
9
+ ### Fixed
10
+ - stopping or restarting sol now shuts down promptly, even with an upload mid-retry. that upload used to wait out its full retry delay first; now it ends the moment you quit sol or the machine powers off. a bad retry setting in your config no longer leaves the uploader stuck, either.
11
+
7
12
  ## [0.4.3] - 2026-07-03
8
13
 
9
14
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solstone-linux
3
- Version: 0.4.3
3
+ Version: 0.4.4
4
4
  Summary: Standalone Linux desktop observer for solstone
5
5
  License-Expression: AGPL-3.0-only
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "solstone-linux"
3
- version = "0.4.3"
3
+ version = "0.4.4"
4
4
  description = "Standalone Linux desktop observer for solstone"
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-only"
@@ -3,4 +3,4 @@
3
3
 
4
4
  """Standalone Linux desktop observer for solstone."""
5
5
 
6
- __version__ = "0.4.3"
6
+ __version__ = "0.4.4"
@@ -8,7 +8,8 @@ background task in the same event loop as capture. Walks cache days
8
8
  newest-to-oldest, queries server for existing segments, uploads missing ones.
9
9
 
10
10
  Refinements over tmux baseline:
11
- - Respects configured sync_max_retries (no hard min(config,3) cap)
11
+ - Owns long retry/backoff and the circuit breaker; per-upload immediate
12
+ retries are bounded and interruptible in UploadClient
12
13
  - Circuit breaker tuned by error type: auth=immediate, transient=5-10
13
14
  - Transient circuit breaker recovers via half-open probe with exponential backoff
14
15
  - Auth/revoked circuit breaker is permanent (requires restart)
@@ -527,6 +528,7 @@ class SyncService:
527
528
  """Stop the sync service."""
528
529
  self._running = False
529
530
  self._trigger.set()
531
+ self._client.request_stop()
530
532
 
531
533
  async def run(self) -> None:
532
534
  """Main sync loop — waits for triggers, then syncs."""
@@ -7,7 +7,8 @@ Extracted from solstone's observe/remote_client.py. Accepts Config
7
7
  as constructor parameter instead of reading config internally.
8
8
 
9
9
  Refinements over tmux baseline:
10
- - Respects configured sync_max_retries without hard cap
10
+ - Bounded immediate in-call retries (MAX_IMMEDIATE_ATTEMPTS); long retry is
11
+ owned by the sync loop + circuit breaker
11
12
  - Error classification: auth (401/403) vs transient (5xx/network)
12
13
  """
13
14
 
@@ -16,6 +17,7 @@ from __future__ import annotations
16
17
  import logging
17
18
  import platform
18
19
  import socket
20
+ import threading
19
21
  import time
20
22
  from pathlib import Path
21
23
  from typing import Any, NamedTuple
@@ -35,6 +37,11 @@ EVENT_DRAIN_TIMEOUT = 3.0
35
37
  STREAM_TYPE = "desktop"
36
38
  OBSERVER_PROTOCOL_VERSION = 2
37
39
  OBSERVER_PROTOCOL_VERSION_HEADER = "X-Solstone-Protocol-Version"
40
+
41
+ # Immediate in-call upload attempts before deferring to the sync loop.
42
+ # Long retry/backoff is owned by SyncService + the circuit breaker, not here.
43
+ MAX_IMMEDIATE_ATTEMPTS = 2
44
+
38
45
  _CONTENT_TYPES = {".flac": "audio/flac", ".webm": "video/webm"}
39
46
 
40
47
 
@@ -65,12 +72,16 @@ class UploadClient:
65
72
  self._key = config.key
66
73
  self._stream = config.stream
67
74
  self._revoked = False
75
+ self._stop_event = threading.Event()
68
76
  self._session = requests.Session()
69
77
  self._event_session = requests.Session()
70
78
  self._event_sender = EventSender(self.relay_event)
71
79
  self._retry_backoff = config.sync_retry_delays or [5, 30, 120, 300]
72
- # Respect configured retry cap no hard min(config, 3)
73
- self._max_retries = config.sync_max_retries
80
+ # Immediate in-call attempts: floor at 1, honor a low cap, bound a high one.
81
+ # Long retry is owned by SyncService + circuit breaker (see upload_segment).
82
+ self._immediate_attempts = max(
83
+ 1, min(config.sync_max_retries, MAX_IMMEDIATE_ATTEMPTS)
84
+ )
74
85
 
75
86
  @property
76
87
  def is_revoked(self) -> bool:
@@ -80,6 +91,10 @@ class UploadClient:
80
91
  def is_registered(self) -> bool:
81
92
  return bool(self._key)
82
93
 
94
+ def request_stop(self) -> None:
95
+ """Signal any in-flight upload retry wait to return promptly (transient)."""
96
+ self._stop_event.set()
97
+
83
98
  def _persist_registration(self, config: Config, key: str, stream: str) -> None:
84
99
  """Persist the server-issued handle and locked stream back to config."""
85
100
  from .config import save_config
@@ -171,7 +186,7 @@ class UploadClient:
171
186
 
172
187
  url = f"{self._url}/app/observer/ingest"
173
188
 
174
- for attempt in range(self._max_retries):
189
+ for attempt in range(self._immediate_attempts):
175
190
  file_handles = []
176
191
  files_data = []
177
192
  error_type = None
@@ -243,12 +258,13 @@ class UploadClient:
243
258
  except Exception:
244
259
  pass
245
260
 
246
- if attempt < self._max_retries - 1:
261
+ if attempt < self._immediate_attempts - 1:
247
262
  delay = self._retry_backoff[min(attempt, len(self._retry_backoff) - 1)]
248
- time.sleep(delay)
263
+ if self._stop_event.wait(delay):
264
+ return UploadResult(False, error_type=ErrorType.TRANSIENT)
249
265
 
250
266
  logger.error(
251
- f"Upload failed after {self._max_retries} attempts: {day}/{segment}"
267
+ f"Upload failed after {self._immediate_attempts} attempts: {day}/{segment}"
252
268
  )
253
269
  return UploadResult(False, error_type=error_type)
254
270
 
@@ -328,6 +344,7 @@ class UploadClient:
328
344
  self._event_sender.start()
329
345
 
330
346
  def stop(self) -> None:
347
+ self._stop_event.set()
331
348
  self._event_sender.stop(EVENT_DRAIN_TIMEOUT)
332
349
  self._event_session.close()
333
350
  self._session.close()
@@ -34,7 +34,12 @@ from solstone_linux.sync_health import (
34
34
  load_facts,
35
35
  save_facts,
36
36
  )
37
- from solstone_linux.upload import QueryResult, UploadClient, UploadResult
37
+ from solstone_linux.upload import (
38
+ MAX_IMMEDIATE_ATTEMPTS,
39
+ QueryResult,
40
+ UploadClient,
41
+ UploadResult,
42
+ )
38
43
 
39
44
 
40
45
  def _sha256(path: Path) -> str:
@@ -568,20 +573,65 @@ class TestCircuitBreakerRecovery:
568
573
 
569
574
 
570
575
  class TestRetryCapRespected:
571
- """Test that upload respects configured retry cap (no hard min(config,3))."""
576
+ """Test that upload bounds immediate attempts while honoring low caps."""
572
577
 
573
- def test_respects_configured_max_retries(self):
574
- """Upload client should use the configured max_retries, not cap at 3."""
575
- config = Config()
578
+ def test_high_config_bounded_to_immediate_cap(self, tmp_path: Path):
579
+ config = Config(
580
+ base_dir=tmp_path,
581
+ server_url="http://localhost:9999",
582
+ key="K",
583
+ )
576
584
  config.sync_max_retries = 10
585
+ config.sync_retry_delays = [0]
577
586
  client = UploadClient(config)
578
- assert client._max_retries == 10
587
+ client._session = MagicMock()
588
+ client._session.post.return_value = MagicMock(
589
+ status_code=500,
590
+ text="boom",
591
+ json=lambda: {},
592
+ )
593
+ media = tmp_path / "audio.flac"
594
+ media.write_bytes(b"audio")
595
+
596
+ result = client.upload_segment("20260101", "120000_005", [media])
597
+
598
+ assert client._session.post.call_count == MAX_IMMEDIATE_ATTEMPTS
599
+ assert result.error_type == ErrorType.TRANSIENT
579
600
 
580
- def test_low_max_retries_respected(self):
581
- config = Config()
601
+ def test_low_config_single_attempt(self, tmp_path: Path):
602
+ config = Config(
603
+ base_dir=tmp_path,
604
+ server_url="http://localhost:9999",
605
+ key="K",
606
+ )
582
607
  config.sync_max_retries = 1
583
608
  client = UploadClient(config)
584
- assert client._max_retries == 1
609
+ client._session = MagicMock()
610
+ client._session.post.return_value = MagicMock(
611
+ status_code=500,
612
+ text="boom",
613
+ json=lambda: {},
614
+ )
615
+ media = tmp_path / "audio.flac"
616
+ media.write_bytes(b"audio")
617
+
618
+ result = client.upload_segment("20260101", "120000_005", [media])
619
+
620
+ assert client._session.post.call_count == 1
621
+ assert result.error_type == ErrorType.TRANSIENT
622
+
623
+ def test_sync_stop_signals_client_interrupt(self, tmp_path: Path):
624
+ config = Config(base_dir=tmp_path)
625
+ config.ensure_dirs()
626
+ client = UploadClient(config)
627
+ sync = SyncService(config, client)
628
+
629
+ assert client._stop_event.is_set() is False
630
+
631
+ sync.stop()
632
+
633
+ assert client._stop_event.is_set() is True
634
+ assert sync._running is False
585
635
 
586
636
 
587
637
  class TestSyncHealthFacts:
@@ -8,7 +8,11 @@ import pytest
8
8
 
9
9
  from solstone_linux.config import Config, load_config
10
10
  from solstone_linux.sync_health import ErrorType
11
- from solstone_linux.upload import OBSERVER_PROTOCOL_VERSION_HEADER, UploadClient
11
+ from solstone_linux.upload import (
12
+ MAX_IMMEDIATE_ATTEMPTS,
13
+ OBSERVER_PROTOCOL_VERSION_HEADER,
14
+ UploadClient,
15
+ )
12
16
 
13
17
 
14
18
  def test_ensure_registered_posts_descriptor_and_persists(tmp_path: Path):
@@ -149,6 +153,129 @@ def test_upload_segment_returns_stored_key(
149
153
  assert result.stored_key == expected_key
150
154
 
151
155
 
156
+ def test_upload_bounds_immediate_attempts(tmp_path: Path):
157
+ config = Config(
158
+ base_dir=tmp_path,
159
+ server_url="http://localhost:9999",
160
+ key="K",
161
+ )
162
+ config.sync_max_retries = 10
163
+ config.sync_retry_delays = [0]
164
+ client = UploadClient(config)
165
+ client._session = MagicMock()
166
+ client._session.post.return_value = MagicMock(
167
+ status_code=500,
168
+ text="boom",
169
+ json=lambda: {},
170
+ )
171
+ media = tmp_path / "audio.flac"
172
+ media.write_bytes(b"audio")
173
+
174
+ result = client.upload_segment("20260101", "120000_005", [media])
175
+
176
+ assert client._session.post.call_count == MAX_IMMEDIATE_ATTEMPTS
177
+ assert result.success is False
178
+ assert result.error_type == ErrorType.TRANSIENT
179
+
180
+
181
+ def test_upload_low_cap_makes_single_attempt(tmp_path: Path):
182
+ config = Config(
183
+ base_dir=tmp_path,
184
+ server_url="http://localhost:9999",
185
+ key="K",
186
+ )
187
+ config.sync_max_retries = 1
188
+ client = UploadClient(config)
189
+ client._session = MagicMock()
190
+ client._session.post.return_value = MagicMock(
191
+ status_code=500,
192
+ text="boom",
193
+ json=lambda: {},
194
+ )
195
+ media = tmp_path / "audio.flac"
196
+ media.write_bytes(b"audio")
197
+
198
+ result = client.upload_segment("20260101", "120000_005", [media])
199
+
200
+ assert client._session.post.call_count == 1
201
+ assert result.success is False
202
+ assert result.error_type == ErrorType.TRANSIENT
203
+
204
+
205
+ def test_upload_zero_retries_makes_single_attempt(tmp_path: Path):
206
+ config = Config(
207
+ base_dir=tmp_path,
208
+ server_url="http://localhost:9999",
209
+ key="K",
210
+ )
211
+ config.sync_max_retries = 0
212
+ client = UploadClient(config)
213
+ client._session = MagicMock()
214
+ client._session.post.return_value = MagicMock(
215
+ status_code=500,
216
+ text="boom",
217
+ json=lambda: {},
218
+ )
219
+ media = tmp_path / "audio.flac"
220
+ media.write_bytes(b"audio")
221
+
222
+ result = client.upload_segment("20260101", "120000_005", [media])
223
+
224
+ assert client._session.post.call_count == 1
225
+ assert result.success is False
226
+ assert result.error_type == ErrorType.TRANSIENT
227
+
228
+
229
+ def test_upload_negative_retries_makes_single_attempt(tmp_path: Path):
230
+ config = Config(
231
+ base_dir=tmp_path,
232
+ server_url="http://localhost:9999",
233
+ key="K",
234
+ )
235
+ config.sync_max_retries = -1
236
+ client = UploadClient(config)
237
+ client._session = MagicMock()
238
+ client._session.post.return_value = MagicMock(
239
+ status_code=500,
240
+ text="boom",
241
+ json=lambda: {},
242
+ )
243
+ media = tmp_path / "audio.flac"
244
+ media.write_bytes(b"audio")
245
+
246
+ result = client.upload_segment("20260101", "120000_005", [media])
247
+
248
+ assert client._session.post.call_count == 1
249
+ assert result.success is False
250
+ assert result.error_type == ErrorType.TRANSIENT
251
+
252
+
253
+ def test_upload_interrupt_during_wait_returns_transient(tmp_path: Path):
254
+ config = Config(
255
+ base_dir=tmp_path,
256
+ server_url="http://localhost:9999",
257
+ key="K",
258
+ )
259
+ config.sync_max_retries = 10
260
+ client = UploadClient(config)
261
+ client._session = MagicMock()
262
+ client._session.post.return_value = MagicMock(
263
+ status_code=500,
264
+ text="boom",
265
+ json=lambda: {},
266
+ )
267
+ media = tmp_path / "audio.flac"
268
+ media.write_bytes(b"audio")
269
+ client.request_stop()
270
+
271
+ result = client.upload_segment("20260101", "120000_005", [media])
272
+
273
+ assert client._session.post.call_count == 1
274
+ assert result.success is False
275
+ assert result.error_type == ErrorType.TRANSIENT
276
+ assert client.is_revoked is False
277
+
278
+
152
279
  def test_relay_event_uses_bearer_and_keyless_route(tmp_path: Path):
153
280
  config = Config(base_dir=tmp_path, server_url="http://localhost:9999", key="K")
154
281
  client = UploadClient(config)
File without changes
File without changes
File without changes
File without changes
File without changes