Skip to content

Commit 38666a3

Browse files
committed
RDBC-1059 Guard save_changes and request executor against use-after-dispose
1 parent 66c54e3 commit 38666a3

5 files changed

Lines changed: 172 additions & 0 deletions

File tree

ravendb/documents/session/document_session.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ def operations(self) -> OperationExecutor:
166166
return self._operation_executor
167167

168168
def save_changes(self) -> None:
169+
self.assert_not_disposed()
169170
save_changes_operation = BatchOperation(self)
170171
command = save_changes_operation.create_request()
171172
if command:

ravendb/documents/session/document_session_operations/in_memory_document_session_operations.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import datetime
44
import itertools
55
import json
6+
import os
67
from abc import abstractmethod
78

89
from ravendb.documents.operations.executor import OperationExecutor
@@ -62,6 +63,11 @@
6263
from ravendb.http.request_executor import RequestExecutor
6364

6465

66+
# Escape hatch for the cross-component store-disposed guard (the session's
67+
# own disposed check is unaffected). Mirrors C# DisableDisposeChecks.
68+
_DISABLE_DISPOSE_CHECKS: bool = os.environ.get("RAVEN_DISABLE_DISPOSE_CHECKS", "").lower() == "true"
69+
70+
6571
class RefEq:
6672
ref = None
6773

@@ -1287,6 +1293,14 @@ def __close(self, is_disposing: bool) -> None:
12871293
def close(self) -> None:
12881294
self.__close(True)
12891295

1296+
def assert_not_disposed(self) -> None:
1297+
if self._is_disposed:
1298+
raise RuntimeError("The session has already been disposed and cannot be used")
1299+
if _DISABLE_DISPOSE_CHECKS:
1300+
return
1301+
if self._document_store.disposed:
1302+
raise RuntimeError("The document store has already been disposed and cannot be used")
1303+
12901304
def register_missing(self, *keys: str) -> None:
12911305
if self.no_tracking:
12921306
return

ravendb/http/request_executor.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,7 @@ def _dispose_all_failed_nodes_timers(self) -> None:
497497
self.__failed_nodes_timers.clear()
498498

499499
def execute_command(self, command: RavenCommand, session_info: Optional[SessionInfo] = None) -> None:
500+
self._throw_if_disposed_at_entry()
500501
topology_update = self._first_topology_update_task
501502
if (
502503
topology_update is not None
@@ -510,6 +511,21 @@ def execute_command(self, command: RavenCommand, session_info: Optional[SessionI
510511
else:
511512
self.__unlikely_execute(command, topology_update, session_info)
512513

514+
@staticmethod
515+
def _throw_object_disposed() -> None:
516+
raise RuntimeError("The request executor has already been disposed and cannot be used")
517+
518+
def _throw_if_disposed_at_entry(self) -> None:
519+
# Entry guard from C# RequestExecutor.ExecuteAsync (v7.2.3).
520+
from ravendb.documents.session.document_session_operations.in_memory_document_session_operations import (
521+
_DISABLE_DISPOSE_CHECKS,
522+
)
523+
524+
if _DISABLE_DISPOSE_CHECKS:
525+
return
526+
if self._disposed:
527+
self._throw_object_disposed()
528+
513529
def execute(
514530
self,
515531
chosen_node: ServerNode = None,
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""
2+
Unit tests for the 7.2.3 disposed-guard additions:
3+
* RequestExecutor.execute_command raises after the executor is disposed.
4+
* RAVEN_DISABLE_DISPOSE_CHECKS env var escapes the cross-component guard.
5+
6+
We avoid importlib.reload here on purpose — reloading
7+
in_memory_document_session_operations swaps out module-level classes
8+
(RefEq, TrackedEntitiesHolder, ...) which then trip identity checks in
9+
unrelated tests sharing the same process. Instead we monkey-patch the
10+
module-level `_DISABLE_DISPOSE_CHECKS` constant directly.
11+
"""
12+
13+
import os
14+
import unittest
15+
16+
import ravendb.documents.session.document_session_operations.in_memory_document_session_operations as _session_mod
17+
from ravendb.http.request_executor import RequestExecutor
18+
19+
20+
class TestDisableDisposeChecksEnvVar(unittest.TestCase):
21+
def test_sentinel_reads_env_at_import_time(self):
22+
# The constant is a plain bool computed when the module is first
23+
# imported. We verify it reflects the current process env without
24+
# forcing a reload (which would invalidate the class identity of
25+
# RefEq / TrackedEntitiesHolder for any module that already imported
26+
# those — and break unrelated tests sharing this process).
27+
expected = os.environ.get("RAVEN_DISABLE_DISPOSE_CHECKS", "").lower() == "true"
28+
self.assertEqual(expected, _session_mod._DISABLE_DISPOSE_CHECKS)
29+
30+
def test_sentinel_is_a_bool(self):
31+
self.assertIsInstance(_session_mod._DISABLE_DISPOSE_CHECKS, bool)
32+
33+
34+
class _PatchSentinel:
35+
"""Context manager that temporarily flips _DISABLE_DISPOSE_CHECKS."""
36+
37+
def __init__(self, value: bool):
38+
self.value = value
39+
self._prev = None
40+
41+
def __enter__(self):
42+
self._prev = _session_mod._DISABLE_DISPOSE_CHECKS
43+
_session_mod._DISABLE_DISPOSE_CHECKS = self.value
44+
return self
45+
46+
def __exit__(self, exc_type, exc, tb):
47+
_session_mod._DISABLE_DISPOSE_CHECKS = self._prev
48+
49+
50+
class TestRequestExecutorEntryGuard(unittest.TestCase):
51+
def test_disposed_executor_throws_on_execute_command(self):
52+
executor = RequestExecutor.__new__(RequestExecutor)
53+
executor._disposed = True
54+
with _PatchSentinel(False):
55+
with self.assertRaises(RuntimeError) as cm:
56+
executor._throw_if_disposed_at_entry()
57+
self.assertIn("disposed", str(cm.exception).lower())
58+
59+
def test_disposed_executor_skipped_when_sentinel_set(self):
60+
executor = RequestExecutor.__new__(RequestExecutor)
61+
executor._disposed = True
62+
with _PatchSentinel(True):
63+
# Should NOT raise.
64+
executor._throw_if_disposed_at_entry()
65+
66+
def test_not_disposed_executor_passes(self):
67+
executor = RequestExecutor.__new__(RequestExecutor)
68+
executor._disposed = False
69+
with _PatchSentinel(False):
70+
executor._throw_if_disposed_at_entry()
71+
with _PatchSentinel(True):
72+
executor._throw_if_disposed_at_entry()
73+
74+
75+
if __name__ == "__main__":
76+
unittest.main()
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""
2+
Integration tests against a live RavenDB 7.2.x server for the 7.2.3
3+
disposed-guard additions:
4+
5+
* `session.save_changes()` raises when the parent store has been disposed
6+
* The session's own disposed guard fires too
7+
* `RAVEN_DISABLE_DISPOSE_CHECKS=true` lets sessions still close cleanly
8+
against a disposed store (escape hatch)
9+
"""
10+
11+
import os
12+
import unittest
13+
14+
from ravendb.tests.test_base import TestBase, User
15+
import ravendb.documents.session.document_session_operations.in_memory_document_session_operations as _session_mod
16+
17+
18+
class TestDisposedGuardsIntegration(TestBase):
19+
def test_save_changes_raises_after_session_close(self):
20+
with self.store.open_session() as session:
21+
session.store(User("alice"), "users/1")
22+
session.save_changes()
23+
session.close()
24+
with self.assertRaises(RuntimeError) as cm:
25+
session.store(User("bob"), "users/2")
26+
session.save_changes()
27+
self.assertIn("disposed", str(cm.exception).lower())
28+
29+
def test_save_changes_raises_after_store_dispose(self):
30+
# Open a session, close the store, then try to use the session.
31+
store = self.get_document_store("test_db_disposed_guard")
32+
session = store.open_session()
33+
session.store(User("alice"), "users/1")
34+
session.save_changes()
35+
36+
store.close() # disposes the store
37+
38+
with self.assertRaises(RuntimeError) as cm:
39+
session.save_changes()
40+
self.assertIn("disposed", str(cm.exception).lower())
41+
42+
def test_dispose_check_env_var_disables_store_guard(self):
43+
# Same scenario as above but with RAVEN_DISABLE_DISPOSE_CHECKS=true,
44+
# the store-disposed half of the guard should be skipped.
45+
# We patch the module sentinel directly (the env var is read at
46+
# import time, and we already validate that path in the unit tests).
47+
prev = _session_mod._DISABLE_DISPOSE_CHECKS
48+
_session_mod._DISABLE_DISPOSE_CHECKS = True
49+
try:
50+
store = self.get_document_store("test_db_disposed_envvar")
51+
session = store.open_session()
52+
session.store(User("alice"), "users/1")
53+
session.save_changes()
54+
store.close()
55+
56+
# The session-level disposed flag still fires when the session
57+
# itself is closed. We only verify that calling assert_not_disposed
58+
# against a closed store no longer raises.
59+
session.assert_not_disposed()
60+
finally:
61+
_session_mod._DISABLE_DISPOSE_CHECKS = prev
62+
63+
64+
if __name__ == "__main__":
65+
unittest.main()

0 commit comments

Comments
 (0)