Skip to content

Commit ca8c21a

Browse files
authored
Respect autosave setting in RTC backend (#479)
* initial-try * prevent-always-autosave * fix-unused-import * check_for_manual_save * force-autosave-by-default * remove-force-autosave * fix-type-hints * fix-autosave-in-tests * add-tests * follow-y-protocol * add-update-value * refactor-code * use-decoder
1 parent 9059310 commit ca8c21a

File tree

7 files changed

+128
-11
lines changed

7 files changed

+128
-11
lines changed

packages/docprovider-extension/src/filebrowser.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,13 @@ export const rtcContentProvider: JupyterFrontEndPlugin<ICollaborativeContentProv
5555
description: 'The RTC content provider',
5656
provides: ICollaborativeContentProvider,
5757
requires: [ITranslator],
58-
optional: [IGlobalAwareness],
59-
activate: (
58+
optional: [IGlobalAwareness, ISettingRegistry],
59+
activate: async (
6060
app: JupyterFrontEnd,
6161
translator: ITranslator,
62-
globalAwareness: Awareness | null
63-
): ICollaborativeContentProvider => {
62+
globalAwareness: Awareness | null,
63+
settingRegistry: ISettingRegistry | null
64+
): Promise<ICollaborativeContentProvider> => {
6465
const trans = translator.load('jupyter_collaboration');
6566
const defaultDrive = (app.serviceManager.contents as ContentsManager)
6667
.defaultDrive;
@@ -75,12 +76,17 @@ export const rtcContentProvider: JupyterFrontEndPlugin<ICollaborativeContentProv
7576
'Cannot initialize content provider: no content provider registry.'
7677
);
7778
}
79+
const docmanagerSettings = settingRegistry
80+
? await settingRegistry.load('@jupyterlab/docmanager-extension:plugin')
81+
: null;
82+
7883
const rtcContentProvider = new RtcContentProvider({
7984
apiEndpoint: '/api/contents',
8085
serverSettings: defaultDrive.serverSettings,
8186
user: app.serviceManager.user,
8287
trans,
83-
globalAwareness
88+
globalAwareness,
89+
docmanagerSettings
8490
});
8591
registry.register('rtc', rtcContentProvider);
8692
return rtcContentProvider;

packages/docprovider/src/ydrive.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,21 @@ import {
2121
ISharedModelFactory
2222
} from '@jupyter/collaborative-drive';
2323
import { Awareness } from 'y-protocols/awareness';
24+
import { ISettingRegistry } from '@jupyterlab/settingregistry';
25+
import * as encoding from 'lib0/encoding';
2426

2527
const DISABLE_RTC =
2628
PageConfig.getOption('disableRTC') === 'true' ? true : false;
2729

30+
const RAW_MESSAGE_TYPE = 2;
31+
32+
const SAVE_MESSAGE = (() => {
33+
const encoder = encoding.createEncoder();
34+
encoding.writeVarUint(encoder, RAW_MESSAGE_TYPE);
35+
encoding.writeVarString(encoder, 'save');
36+
return encoding.toUint8Array(encoder);
37+
})();
38+
2839
/**
2940
* The url for the default drive service.
3041
*/
@@ -42,6 +53,7 @@ namespace RtcContentProvider {
4253
user: User.IManager;
4354
trans: TranslationBundle;
4455
globalAwareness: Awareness | null;
56+
docmanagerSettings: ISettingRegistry.ISettings | null;
4557
}
4658
}
4759

@@ -57,6 +69,7 @@ export class RtcContentProvider
5769
this._serverSettings = options.serverSettings;
5870
this.sharedModelFactory = new SharedModelFactory(this._onCreate);
5971
this._providers = new Map<string, WebSocketProvider>();
72+
this._docmanagerSettings = options.docmanagerSettings;
6073
}
6174

6275
/**
@@ -123,7 +136,7 @@ export class RtcContentProvider
123136
const provider = this._providers.get(key);
124137

125138
if (provider) {
126-
// Save is done from the backend
139+
provider.wsProvider?.ws?.send(SAVE_MESSAGE);
127140
const fetchOptions: Contents.IFetchOptions = {
128141
type: options.type,
129142
format: options.format,
@@ -150,6 +163,19 @@ export class RtcContentProvider
150163
if (typeof options.format !== 'string') {
151164
return;
152165
}
166+
// Set initial autosave value, used to determine backend autosave (default: true)
167+
const autosave =
168+
(this._docmanagerSettings?.composite?.['autosave'] as boolean) ?? true;
169+
170+
sharedModel.awareness.setLocalStateField('autosave', autosave);
171+
172+
// Watch for changes in settings
173+
this._docmanagerSettings?.changed.connect(() => {
174+
const newAutosave =
175+
(this._docmanagerSettings?.composite?.['autosave'] as boolean) ?? true;
176+
sharedModel.awareness.setLocalStateField('autosave', newAutosave);
177+
});
178+
153179
try {
154180
const provider = new WebSocketProvider({
155181
url: URLExt.join(this._serverSettings.wsUrl, DOCUMENT_PROVIDER_URL),
@@ -235,6 +261,7 @@ export class RtcContentProvider
235261
private _providers: Map<string, WebSocketProvider>;
236262
private _ydriveFileChanged = new Signal<this, Contents.IChangedArgs>(this);
237263
private _serverSettings: ServerConnection.ISettings;
264+
private _docmanagerSettings: ISettingRegistry.ISettings | null;
238265
}
239266

240267
/**

projects/jupyter-server-ydoc/jupyter_server_ydoc/handlers.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from logging import Logger
1010
from typing import Any
1111
from uuid import uuid4
12+
from typing import cast
1213

1314
from jupyter_server.auth import authorized
1415
from jupyter_server.base.handlers import APIHandler, JupyterHandler
@@ -32,6 +33,8 @@
3233
room_id_from_encoded_path,
3334
)
3435
from .websocketserver import JupyterWebsocketServer, RoomNotFound
36+
from .utils import MessageType
37+
from pycrdt import Decoder
3538

3639
YFILE = YDOCS["file"]
3740

@@ -291,6 +294,18 @@ async def on_message(self, message):
291294
"""
292295
On message receive.
293296
"""
297+
decoder = Decoder(message)
298+
header = decoder.read_var_uint()
299+
if header == MessageType.RAW:
300+
msg = decoder.read_var_string()
301+
if msg == "save":
302+
try:
303+
room = cast(DocumentRoom, self.room)
304+
room._save_to_disc()
305+
except Exception:
306+
self.log.error("Couldn't save content from room: %s", self._room_id)
307+
return
308+
294309
self._message_queue.put_nowait(message)
295310
self._websocket_server.ypatch_nb += 1
296311

projects/jupyter-server-ydoc/jupyter_server_ydoc/pytest_plugin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ def _inner(
276276
last_modified: datetime | None = None,
277277
save_delay: float | None = None,
278278
store: SQLiteYStore | None = None,
279-
writable: bool = False,
279+
writable: bool = True,
280280
) -> tuple[FakeContentsManager, FileLoader, DocumentRoom]:
281281
paths = {id: path}
282282

projects/jupyter-server-ydoc/jupyter_server_ydoc/rooms.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,33 @@ def _on_document_change(self, target: str, event: Any) -> None:
247247
document. This tasks are debounced (60 seconds by default) so we
248248
need to cancel previous tasks before creating a new one.
249249
"""
250+
# Collect autosave values from all clients
251+
autosave_states = [
252+
state.get("autosave", True)
253+
for state in self.awareness.states.values()
254+
if state # skip empty states
255+
]
256+
257+
# If no states exist (e.g., during tests), force autosave to be True
258+
if not autosave_states:
259+
autosave_states = [True]
260+
261+
# Enable autosave if at least one client has it turned on
262+
autosave = any(autosave_states)
263+
264+
if not autosave:
265+
return
266+
if self._update_lock.locked():
267+
return
268+
269+
self._saving_document = asyncio.create_task(
270+
self._maybe_save_document(self._saving_document)
271+
)
272+
273+
def _save_to_disc(self):
274+
"""
275+
Called when manual save is triggered. Helpful when autosave is turned off.
276+
"""
250277
if self._update_lock.locked():
251278
return
252279

@@ -265,7 +292,6 @@ async def _maybe_save_document(self, saving_document: asyncio.Task | None) -> No
265292
"""
266293
if self._save_delay is None:
267294
return
268-
269295
if saving_document is not None and not saving_document.done():
270296
# the document is being saved, cancel that
271297
saving_document.cancel()

projects/jupyter-server-ydoc/jupyter_server_ydoc/utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
class MessageType(IntEnum):
2020
SYNC = 0
2121
AWARENESS = 1
22+
RAW = 2
2223
CHAT = 125
2324

2425

tests/test_rooms.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,7 @@ async def test_defined_save_delay_should_save_content_after_document_change(
5151
rtc_create_mock_document_room,
5252
):
5353
content = "test"
54-
cm, _, room = rtc_create_mock_document_room(
55-
"test-id", "test.txt", content, save_delay=0.01, writable=True
56-
)
54+
cm, _, room = rtc_create_mock_document_room("test-id", "test.txt", content, save_delay=0.01)
5755

5856
await room.initialize()
5957
room._document.source = "Test 2"
@@ -79,6 +77,50 @@ async def test_undefined_save_delay_should_not_save_content_after_document_chang
7977
assert "save" not in cm.actions
8078

8179

80+
async def test_should_not_save_content_when_all_clients_have_autosave_disabled(
81+
rtc_create_mock_document_room,
82+
):
83+
content = "test"
84+
cm, _, room = rtc_create_mock_document_room("test-id", "test.txt", content, save_delay=0.01)
85+
86+
# Disable autosave for all existing clients
87+
for state in room.awareness._states.values():
88+
if state is not None:
89+
state["autosave"] = False
90+
91+
# Inject a dummy client with autosave disabled
92+
room.awareness._states[9999] = {"autosave": False}
93+
94+
await room.initialize()
95+
room._document.source = "Test 2"
96+
97+
await asyncio.sleep(0.15)
98+
99+
assert "save" not in cm.actions
100+
101+
102+
async def test_should_save_content_when_at_least_one_client_has_autosave_enabled(
103+
rtc_create_mock_document_room,
104+
):
105+
content = "test"
106+
cm, _, room = rtc_create_mock_document_room("test-id", "test.txt", content, save_delay=0.01)
107+
108+
# Disable autosave for all existing clients
109+
for state in room.awareness._states.values():
110+
if state is not None:
111+
state["autosave"] = False
112+
113+
# Inject a dummy client with autosave enabled
114+
room.awareness._states[10000] = {"autosave": True}
115+
116+
await room.initialize()
117+
room._document.source = "Test 2"
118+
119+
await asyncio.sleep(0.15)
120+
121+
assert "save" in cm.actions
122+
123+
82124
# The following test should be restored when package versions are fixed.
83125

84126
# async def test_document_path(rtc_create_mock_document_room):

0 commit comments

Comments
 (0)