memwal 0.1.5.dev0__tar.gz → 0.1.5.dev2__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 (24) hide show
  1. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/PKG-INFO +1 -1
  2. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/memwal/__init__.py +1 -1
  3. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/memwal/client.py +17 -5
  4. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/memwal/middleware.py +132 -12
  5. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/memwal/types.py +11 -0
  6. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/pyproject.toml +1 -1
  7. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/tests/test_client.py +52 -0
  8. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/tests/test_middleware.py +124 -2
  9. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/.gitignore +0 -0
  10. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/CHANGELOG.md +0 -0
  11. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/README.md +0 -0
  12. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/examples/.env.example +0 -0
  13. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/examples/.gitignore +0 -0
  14. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/examples/async_remember_demo.py +0 -0
  15. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/examples/interactive_demo.py +0 -0
  16. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/examples/verify_credentials.py +0 -0
  17. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/memwal/compatibility.py +0 -0
  18. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/memwal/utils.py +0 -0
  19. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/notebooks/walrus_memory_python_sdk.ipynb +0 -0
  20. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/run_tests.py +0 -0
  21. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/tests/__init__.py +0 -0
  22. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/tests/test_env_presets.py +0 -0
  23. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/tests/test_integration.py +0 -0
  24. {memwal-0.1.5.dev0 → memwal-0.1.5.dev2}/tests/test_signing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memwal
3
- Version: 0.1.5.dev0
3
+ Version: 0.1.5.dev2
4
4
  Summary: Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing
5
5
  Project-URL: Homepage, https://memory.walrus.xyz
6
6
  Project-URL: Documentation, https://memory.walrus.xyz
@@ -116,4 +116,4 @@ __all__ = [
116
116
  "RecallManualResult",
117
117
  ]
118
118
 
119
- __version__ = "0.1.5.dev0"
119
+ __version__ = "0.1.5.dev2"
@@ -784,22 +784,30 @@ class MemWal:
784
784
  do **not** count as either restored or skipped.
785
785
  * ``total`` — count of on-chain blobs the relayer saw for
786
786
  ``(owner, namespace)`` before the limit was applied.
787
+ * ``truncated`` — True when this restore is known-incomplete (limit
788
+ truncation or an upstream on-chain candidate-fetch cap; see
789
+ :class:`~memwal.types.RestoreResult`). Defaults to ``False`` when
790
+ talking to a relayer older than WALM-319 that omits the field.
787
791
 
788
792
  Args:
789
793
  namespace: Namespace to restore. Exact match — no prefix or
790
794
  hierarchy semantics (see SKILL.md "Namespace Semantics").
791
795
  limit: Max blobs the relayer will *inspect* this call (default:
792
796
  10, matches the server-side default and the TypeScript SDK).
793
- The relayer fetches blobs newest-first and caps the work at
794
- this number; it does **not** cap ``restored`` separately.
797
+ The relayer selects up to this many missing blobs from the
798
+ candidates it sees, in unspecified order; it does **not** cap
799
+ ``restored`` separately.
795
800
 
796
801
  Returns:
797
802
  :class:`RestoreResult`.
798
803
 
799
804
  Notes:
800
- * **No pagination cursor** — restore is single-shot. To rebuild a
801
- large namespace, call repeatedly with a growing ``limit`` or
802
- prune the local index first.
805
+ * **No pagination cursor** — restore is single-shot. A larger
806
+ ``limit`` may help when ``truncated`` is caused by the request
807
+ limit, but candidate discovery also has a per-owner source cap
808
+ shared across namespaces. Hitting that cap requires cursor/
809
+ pagination support for guaranteed full recovery; increasing
810
+ ``limit`` or retrying cannot guarantee it.
803
811
  * **Performance** scales linearly in ``limit``: up to 10 Walrus
804
812
  downloads in parallel, then 3 SEAL decrypts in parallel, then
805
813
  embeddings. Expect seconds-per-blob on cold caches.
@@ -814,6 +822,10 @@ class MemWal:
814
822
  total=data["total"],
815
823
  namespace=data["namespace"],
816
824
  owner=data["owner"],
825
+ # Relayers older than WALM-319 omit `truncated` entirely --
826
+ # treat "not present" as "not known to be truncated" rather
827
+ # than require every relayer version to send it.
828
+ truncated=data.get("truncated", False),
817
829
  )
818
830
 
819
831
  async def health(self) -> HealthResult:
@@ -130,14 +130,124 @@ def _format_memories(
130
130
  ])
131
131
 
132
132
 
133
- def _fire_and_forget(coro: Any) -> None:
134
- """Schedule an async coroutine as fire-and-forget.
133
+ class _PendingSaves:
134
+ """Tracks in-flight fire-and-forget auto-save work (asyncio Tasks and
135
+ background Threads) so a caller can drain it deterministically via
136
+ :meth:`flush`/:meth:`flush_sync` instead of losing it silently when the
137
+ process exits before it completes. Tasks scheduled via
138
+ ``loop.create_task()`` with no other reference are only weakly held by
139
+ the event loop and can be cancelled or garbage-collected before they
140
+ run — this keeps a strong reference until each one is done.
141
+ """
142
+
143
+ def __init__(self) -> None:
144
+ self._tasks: "set[asyncio.Task[Any]]" = set()
145
+ self._threads: List[threading.Thread] = []
146
+ self._lock = threading.Lock()
147
+
148
+ def track_task(self, task: "asyncio.Task[Any]") -> None:
149
+ self._tasks.add(task)
150
+ task.add_done_callback(self._tasks.discard)
151
+
152
+ def spawn_thread(self, target: Callable[[], None]) -> None:
153
+ """Run `target` in a new daemon thread, tracked until it completes.
154
+ The thread untracks itself on completion -- a long-lived client
155
+ that keeps using fire-and-forget saves but never calls flush()
156
+ would otherwise accumulate one Thread object per save, forever.
157
+ """
158
+ def _run_and_untrack() -> None:
159
+ try:
160
+ target()
161
+ finally:
162
+ with self._lock:
163
+ if thread in self._threads:
164
+ self._threads.remove(thread)
165
+
166
+ thread = threading.Thread(target=_run_and_untrack, daemon=True)
167
+ with self._lock:
168
+ self._threads.append(thread)
169
+ thread.start()
170
+
171
+ async def flush(self) -> None:
172
+ """Await every pending task, then join every pending thread."""
173
+ if self._tasks:
174
+ await asyncio.gather(*list(self._tasks), return_exceptions=True)
175
+ self._join_threads()
176
+
177
+ def flush_sync(self) -> None:
178
+ """Join every pending thread. A pending Task belongs to the loop
179
+ that created it (e.g. an earlier `await llm.ainvoke(...)` call) and
180
+ cannot be awaited from a different one -- if any are still pending
181
+ here, the caller mixed sync and async entry points on the same
182
+ wrapped client, and this cleanup path can't safely drain them. Log
183
+ and continue draining threads rather than raise out of what's
184
+ usually shutdown code."""
185
+ if self._tasks:
186
+ async def _drain(tasks: "list[asyncio.Task[Any]]") -> None:
187
+ await asyncio.gather(*tasks, return_exceptions=True)
188
+
189
+ try:
190
+ asyncio.run(_drain(list(self._tasks)))
191
+ except RuntimeError:
192
+ logger.warning(
193
+ "Walrus Memory flush_sync() could not await %d pending "
194
+ "task(s) bound to a different event loop (mixing sync "
195
+ "and async calls on the same wrapped client?) — those "
196
+ "saves may be lost.",
197
+ len(self._tasks),
198
+ )
199
+ self._join_threads()
200
+
201
+ def _join_threads(self) -> None:
202
+ with self._lock:
203
+ threads, self._threads = self._threads, []
204
+ for thread in threads:
205
+ thread.join()
206
+
207
+
208
+ def _expose_memwal_controls(obj: Any, memwal: MemWal, pending: _PendingSaves) -> None:
209
+ """Attach the underlying client and a way to drain pending auto-saves —
210
+ without this, a short-lived process has no way to avoid silently
211
+ losing writes still in flight when it exits.
212
+
213
+ Uses object.__setattr__ rather than plain attribute assignment: a
214
+ LangChain BaseChatModel is a Pydantic model, and Pydantic's __setattr__
215
+ rejects assignment to any name that isn't a declared field (or a
216
+ leading-underscore private attribute) — memwal_flush/memwal_flush_sync
217
+ are neither, so plain `obj.memwal_flush = ...` raises ValueError on a
218
+ real LangChain model (masked by tests using MagicMock, which doesn't
219
+ enforce this). object.__setattr__ bypasses that check and writes
220
+ straight into the instance's __dict__, where normal attribute lookup
221
+ (obj.memwal_flush) still finds it — OpenAI clients aren't Pydantic
222
+ models and work the same way either way.
223
+ """
224
+ object.__setattr__(obj, "_memwal", memwal)
225
+ object.__setattr__(obj, "memwal_flush", pending.flush)
226
+ object.__setattr__(obj, "memwal_flush_sync", pending.flush_sync)
227
+
228
+
229
+ async def _warn_if_cancelled(coro: Any, label: str) -> None:
230
+ try:
231
+ await coro
232
+ except asyncio.CancelledError:
233
+ logger.warning(
234
+ "Walrus Memory %s was cancelled before it completed — the process "
235
+ "likely exited without draining pending saves. Call flush()/"
236
+ "flush_sync() before exiting to avoid silent data loss.",
237
+ label,
238
+ )
239
+ raise
240
+
241
+
242
+ def _fire_and_forget(coro: Any, pending: _PendingSaves, label: str = "auto-save") -> None:
243
+ """Schedule an async coroutine as fire-and-forget, tracked in `pending`.
135
244
 
136
245
  Works whether or not an event loop is already running.
137
246
  """
138
247
  try:
139
248
  loop = asyncio.get_running_loop()
140
- loop.create_task(coro)
249
+ task = loop.create_task(_warn_if_cancelled(coro, label))
250
+ pending.track_task(task)
141
251
  except RuntimeError:
142
252
  # No running loop -- run in a background thread
143
253
  def _run() -> None:
@@ -146,8 +256,7 @@ def _fire_and_forget(coro: Any) -> None:
146
256
  except Exception:
147
257
  logger.debug("Fire-and-forget analyze() failed", exc_info=True)
148
258
 
149
- thread = threading.Thread(target=_run, daemon=True)
150
- thread.start()
259
+ pending.spawn_thread(_run)
151
260
 
152
261
 
153
262
  def _run_blocking(coro_factory: Callable[[], Any]) -> Any:
@@ -226,6 +335,7 @@ def with_memwal_langchain(
226
335
  )
227
336
 
228
337
  log = logger.debug if not debug else logger.warning
338
+ pending = _PendingSaves()
229
339
 
230
340
  original_agenerate = llm._agenerate
231
341
  original_generate = llm._generate
@@ -289,7 +399,7 @@ def with_memwal_langchain(
289
399
  result = await original_agenerate(enriched, *args, **kwargs)
290
400
 
291
401
  for msg_list in messages:
292
- _fire_and_forget(_post_analyze(msg_list))
402
+ _fire_and_forget(_post_analyze(msg_list), pending)
293
403
 
294
404
  return result
295
405
 
@@ -315,7 +425,7 @@ def with_memwal_langchain(
315
425
  result = original_generate(enriched, *args, **kwargs)
316
426
 
317
427
  for msg_list in messages:
318
- _fire_and_forget(_post_analyze(msg_list))
428
+ _fire_and_forget(_post_analyze(msg_list), pending)
319
429
 
320
430
  return result
321
431
 
@@ -323,6 +433,8 @@ def with_memwal_langchain(
323
433
  llm._agenerate = patched_agenerate # type: ignore[assignment]
324
434
  llm._generate = patched_generate # type: ignore[assignment]
325
435
 
436
+ _expose_memwal_controls(llm, memwal, pending)
437
+
326
438
  return llm
327
439
 
328
440
 
@@ -379,13 +491,20 @@ def with_memwal_openai(
379
491
  )
380
492
 
381
493
  log = logger.debug if not debug else logger.warning
494
+ pending = _PendingSaves()
382
495
 
383
496
  is_async = hasattr(client, "_async_client") or type(client).__name__ == "AsyncOpenAI"
384
497
 
385
498
  if is_async:
386
- _wrap_async_openai(client, memwal, namespace, max_memories, auto_save, min_relevance, log)
499
+ _wrap_async_openai(
500
+ client, memwal, namespace, max_memories, auto_save, min_relevance, log, pending
501
+ )
387
502
  else:
388
- _wrap_sync_openai(client, memwal, namespace, max_memories, auto_save, min_relevance, log)
503
+ _wrap_sync_openai(
504
+ client, memwal, namespace, max_memories, auto_save, min_relevance, log, pending
505
+ )
506
+
507
+ _expose_memwal_controls(client, memwal, pending)
389
508
 
390
509
  return client
391
510
 
@@ -398,6 +517,7 @@ def _wrap_async_openai(
398
517
  auto_save: bool,
399
518
  min_relevance: float,
400
519
  log: Callable[..., Any],
520
+ pending: _PendingSaves,
401
521
  ) -> None:
402
522
  """Wrap an async OpenAI client's chat.completions.create."""
403
523
  original_create = client.chat.completions.create
@@ -437,7 +557,7 @@ def _wrap_async_openai(
437
557
  except Exception as e:
438
558
  log(f"[Walrus Memory] Auto-save failed: {e}")
439
559
 
440
- _fire_and_forget(_analyze())
560
+ _fire_and_forget(_analyze(), pending)
441
561
 
442
562
  return result
443
563
 
@@ -452,6 +572,7 @@ def _wrap_sync_openai(
452
572
  auto_save: bool,
453
573
  min_relevance: float,
454
574
  log: Callable[..., Any],
575
+ pending: _PendingSaves,
455
576
  ) -> None:
456
577
  """Wrap a sync OpenAI client's chat.completions.create."""
457
578
  original_create = client.chat.completions.create
@@ -498,8 +619,7 @@ def _wrap_sync_openai(
498
619
  except Exception as e:
499
620
  log(f"[Walrus Memory] Auto-save failed: {e}")
500
621
 
501
- thread = threading.Thread(target=_analyze, daemon=True)
502
- thread.start()
622
+ pending.spawn_thread(_analyze)
503
623
 
504
624
  return result
505
625
 
@@ -201,6 +201,17 @@ class RestoreResult:
201
201
  total: int
202
202
  namespace: str
203
203
  owner: str
204
+ #: True when this restore is known-incomplete: either more on-chain
205
+ #: blobs were missing locally than ``limit`` allowed this call to
206
+ #: restore, or the sidecar's raw on-chain candidate fetch hit its own
207
+ #: cap before this namespace's blobs were even filtered out of that
208
+ #: set (WALM-319) -- this can be ``True`` even when ``total == 0``,
209
+ #: since a cap hit elsewhere can starve this namespace's fetch
210
+ #: entirely. Raising ``limit`` only helps with the first case.
211
+ #:
212
+ #: Relayers older than WALM-319 don't send this field at all; the SDK
213
+ #: defaults it to ``False`` in that case rather than requiring it.
214
+ truncated: bool = False
204
215
 
205
216
 
206
217
  @dataclass
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memwal"
7
- version = "0.1.5.dev0"
7
+ version = "0.1.5.dev2"
8
8
  description = "Python SDK for Walrus Memory — Privacy-first AI memory with Ed25519 signing"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -630,6 +630,7 @@ class TestRestore:
630
630
  "total": 7,
631
631
  "namespace": "my-app",
632
632
  "owner": "0xowner",
633
+ "truncated": False,
633
634
  },
634
635
  )
635
636
  )
@@ -641,6 +642,57 @@ class TestRestore:
641
642
  assert body["limit"] == 100
642
643
  assert result.restored == 5
643
644
  assert result.skipped == 2
645
+ assert result.truncated is False
646
+
647
+ @respx.mock
648
+ async def test_restore_preserves_truncated_true(
649
+ self, memwal_client: MemWal
650
+ ) -> None:
651
+ """truncated=True from the relayer must survive into RestoreResult
652
+ instead of being dropped when the dataclass is reconstructed."""
653
+ mock_seal_session_prereqs()
654
+ respx.post(f"{_TEST_SERVER}/api/restore").mock(
655
+ return_value=httpx.Response(
656
+ 200,
657
+ json={
658
+ "restored": 5,
659
+ "skipped": 2,
660
+ "total": 7,
661
+ "namespace": "my-app",
662
+ "owner": "0xowner",
663
+ "truncated": True,
664
+ },
665
+ )
666
+ )
667
+
668
+ result = await memwal_client.restore("my-app", limit=100)
669
+
670
+ assert result.truncated is True
671
+
672
+ @respx.mock
673
+ async def test_restore_truncated_defaults_false_when_omitted(
674
+ self, memwal_client: MemWal
675
+ ) -> None:
676
+ """Relayers older than WALM-319 omit `truncated` entirely — the
677
+ SDK must default it to False rather than requiring the field."""
678
+ mock_seal_session_prereqs()
679
+ respx.post(f"{_TEST_SERVER}/api/restore").mock(
680
+ return_value=httpx.Response(
681
+ 200,
682
+ json={
683
+ "restored": 5,
684
+ "skipped": 2,
685
+ "total": 7,
686
+ "namespace": "my-app",
687
+ "owner": "0xowner",
688
+ # no "truncated" key — simulates an older relayer
689
+ },
690
+ )
691
+ )
692
+
693
+ result = await memwal_client.restore("my-app", limit=100)
694
+
695
+ assert result.truncated is False
644
696
 
645
697
 
646
698
  class TestHealth:
@@ -17,16 +17,19 @@ from __future__ import annotations
17
17
 
18
18
  import asyncio
19
19
  import json
20
- from unittest.mock import AsyncMock, MagicMock
20
+ import threading
21
+ from unittest.mock import AsyncMock, MagicMock, patch
21
22
 
22
23
  import httpx
23
24
  import nacl.signing
24
25
  import respx
25
26
 
27
+ from memwal.client import MemWal
26
28
  from memwal.middleware import (
27
29
  _find_last_user_message,
28
30
  _format_memories,
29
31
  _inject_openai_memory,
32
+ _PendingSaves,
30
33
  with_memwal_langchain,
31
34
  with_memwal_openai,
32
35
  )
@@ -444,6 +447,24 @@ class TestWithMemWalLangChain:
444
447
  await smart_llm._agenerate([[SystemMessage("only system")]])
445
448
  assert not recall_route.called
446
449
 
450
+ def test_wraps_a_real_pydantic_backed_chat_model(self) -> None:
451
+ """with_memwal_langchain must work on a real BaseChatModel, not just
452
+ a MagicMock. LangChain chat models are Pydantic models that reject
453
+ assignment of undeclared fields under normal attribute assignment
454
+ -- MagicMock doesn't enforce that, so it silently hides this."""
455
+ from langchain_core.language_models.fake_chat_models import FakeListChatModel
456
+
457
+ llm = FakeListChatModel(responses=["canned response"])
458
+
459
+ smart_llm = with_memwal_langchain(
460
+ llm, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=False
461
+ )
462
+
463
+ assert smart_llm is llm
464
+ assert smart_llm._memwal is not None
465
+ assert callable(smart_llm.memwal_flush)
466
+ assert callable(smart_llm.memwal_flush_sync)
467
+
447
468
 
448
469
  # ============================================================
449
470
  # OpenAI middleware tests
@@ -587,6 +608,41 @@ class TestWithMemWalOpenAI:
587
608
  await asyncio.sleep(0.05)
588
609
  assert analyze_route.called
589
610
 
611
+ @respx.mock
612
+ async def test_memwal_flush_awaits_pending_autosave(self) -> None:
613
+ """memwal_flush() deterministically waits for the fire-and-forget
614
+ analyze() call, instead of the caller having to guess a sleep
615
+ duration and hope the background task finished in time."""
616
+ _mock_seal_session_prereqs()
617
+ client = self._make_async_client()
618
+
619
+ respx.post(_RECALL_URL).mock(return_value=_mock_recall([]))
620
+
621
+ gate = asyncio.Event()
622
+ completed = {"value": False}
623
+
624
+ async def gated_analyze(self, *args, **kwargs):
625
+ await gate.wait()
626
+ completed["value"] = True
627
+
628
+ with patch.object(MemWal, "analyze", gated_analyze):
629
+ smart = with_memwal_openai(
630
+ client, key=_KEY_HEX, account_id=_ACCOUNT_ID, server_url=_SERVER, auto_save=True
631
+ )
632
+ await smart.chat.completions.create(
633
+ model="gpt-4o",
634
+ messages=[{"role": "user", "content": "remember this"}],
635
+ )
636
+
637
+ # Fire-and-forget: the response came back, but the gated
638
+ # analyze() call has not completed yet.
639
+ assert completed["value"] is False
640
+
641
+ gate.set()
642
+ await smart.memwal_flush()
643
+
644
+ assert completed["value"] is True
645
+
590
646
  @respx.mock
591
647
  async def test_min_relevance_filter(self) -> None:
592
648
  """Memories with relevance below min_relevance are not injected."""
@@ -671,4 +727,70 @@ class TestWithMemWalOpenAI:
671
727
  assert "TV support appointment is 9 AM to noon" not in system_msgs[0]["content"]
672
728
  user_msgs = [m for m in captured if isinstance(m, dict) and m.get("role") == "user"]
673
729
  assert len(user_msgs) == 2
674
- assert "TV support appointment is 9 AM to noon" in user_msgs[0]["content"]
730
+
731
+
732
+ # ============================================================
733
+ # _PendingSaves
734
+ # ============================================================
735
+
736
+
737
+ class TestPendingSaves:
738
+ """Tests for _PendingSaves, isolated from the middleware wrappers."""
739
+
740
+ def test_flush_sync_does_not_crash_on_a_task_from_a_different_loop(self) -> None:
741
+ """Reproduces the real bug: a caller that mixes an earlier async
742
+ call (which populates self._tasks on that call's event loop) with
743
+ flush_sync() from sync code afterward. asyncio.Tasks are bound to
744
+ the loop that created them and can't be awaited from a new one --
745
+ flush_sync() must not raise out of this, since it's typically
746
+ called during cleanup."""
747
+ pending = _PendingSaves()
748
+ task_holder: dict = {}
749
+ release = threading.Event()
750
+
751
+ def _run_other_loop() -> None:
752
+ async def _pending_forever() -> None:
753
+ await asyncio.get_event_loop().run_in_executor(None, release.wait)
754
+
755
+ loop = asyncio.new_event_loop()
756
+ asyncio.set_event_loop(loop)
757
+ task = loop.create_task(_pending_forever())
758
+ task_holder["task"] = task
759
+ pending.track_task(task)
760
+ # Keep this loop alive long enough for the main thread's
761
+ # flush_sync() to observe the still-pending, cross-loop task.
762
+ loop.run_until_complete(asyncio.sleep(0.3))
763
+
764
+ other_loop_thread = threading.Thread(target=_run_other_loop)
765
+ other_loop_thread.start()
766
+
767
+ # Give the other thread time to create its loop and schedule the task.
768
+ import time
769
+
770
+ time.sleep(0.05)
771
+ assert task_holder.get("task") is not None
772
+ assert not task_holder["task"].done()
773
+
774
+ try:
775
+ pending.flush_sync() # must not raise
776
+ finally:
777
+ release.set()
778
+ other_loop_thread.join(timeout=2)
779
+
780
+ def test_completed_threads_are_untracked_without_flushing(self) -> None:
781
+ """A long-lived client that keeps using fire-and-forget saves but
782
+ never calls flush()/flush_sync() must not accumulate one Thread
783
+ object per save forever."""
784
+ pending = _PendingSaves()
785
+ done = threading.Event()
786
+
787
+ pending.spawn_thread(done.set)
788
+
789
+ assert done.wait(timeout=2), "background thread never ran"
790
+ # Give the thread's own cleanup a moment to run after done.set()
791
+ # returns (the Event is set from inside the tracked callable,
792
+ # microseconds before the thread function itself returns).
793
+ import time
794
+
795
+ time.sleep(0.05)
796
+ assert len(pending._threads) == 0
File without changes
File without changes
File without changes
File without changes
File without changes