seamless-workflow 0.2.0__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.
Files changed (49) hide show
  1. seamless_workflow/__init__.py +40 -0
  2. seamless_workflow/adapters.py +50 -0
  3. seamless_workflow/assignment.py +8 -0
  4. seamless_workflow/attachments/__init__.py +3 -0
  5. seamless_workflow/attachments/api.py +217 -0
  6. seamless_workflow/attachments/fs/__init__.py +1 -0
  7. seamless_workflow/attachments/fs/native.py +63 -0
  8. seamless_workflow/attachments/fs/service.py +601 -0
  9. seamless_workflow/attachments/manual.py +65 -0
  10. seamless_workflow/attachments/output_widget.py +62 -0
  11. seamless_workflow/attachments/policy.py +41 -0
  12. seamless_workflow/attachments/runtime.py +594 -0
  13. seamless_workflow/attachments/session.py +93 -0
  14. seamless_workflow/attachments/share/__init__.py +2 -0
  15. seamless_workflow/attachments/share/api.py +300 -0
  16. seamless_workflow/attachments/share/archive.py +60 -0
  17. seamless_workflow/attachments/share/driver.py +110 -0
  18. seamless_workflow/attachments/share/mime.py +180 -0
  19. seamless_workflow/attachments/share/openapi.py +358 -0
  20. seamless_workflow/attachments/share/server.py +1660 -0
  21. seamless_workflow/attachments/share/spec.py +139 -0
  22. seamless_workflow/attachments/share/static/seamless-client.js +538 -0
  23. seamless_workflow/attachments/spec.py +42 -0
  24. seamless_workflow/attachments/traitlet.py +344 -0
  25. seamless_workflow/attachments/widget.py +229 -0
  26. seamless_workflow/builder_state.py +1107 -0
  27. seamless_workflow/configuration.py +103 -0
  28. seamless_workflow/context.py +3287 -0
  29. seamless_workflow/controller.py +160 -0
  30. seamless_workflow/diagnostics.py +69 -0
  31. seamless_workflow/endpoints.py +33 -0
  32. seamless_workflow/errors.py +69 -0
  33. seamless_workflow/graph.py +636 -0
  34. seamless_workflow/ingress.py +411 -0
  35. seamless_workflow/jupyter.py +49 -0
  36. seamless_workflow/lifecycle.py +26 -0
  37. seamless_workflow/reactive.py +386 -0
  38. seamless_workflow/runtime_api.py +227 -0
  39. seamless_workflow/scheduler.py +106 -0
  40. seamless_workflow/serialization.py +317 -0
  41. seamless_workflow/serve_graph.py +55 -0
  42. seamless_workflow/shareserver.py +36 -0
  43. seamless_workflow/sidework.py +102 -0
  44. seamless_workflow/views.py +62 -0
  45. seamless_workflow-0.2.0.dist-info/METADATA +157 -0
  46. seamless_workflow-0.2.0.dist-info/RECORD +49 -0
  47. seamless_workflow-0.2.0.dist-info/WHEEL +5 -0
  48. seamless_workflow-0.2.0.dist-info/entry_points.txt +2 -0
  49. seamless_workflow-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,601 @@
1
+ """Filesystem transport. No graph or Context objects cross this boundary.
2
+
3
+ Each registration has a serial operation queue. The broker submits polls; the
4
+ bounded daemon pool performs all filesystem operations, including stat walks.
5
+ """
6
+ from __future__ import annotations
7
+ import gzip
8
+ import os
9
+ import stat
10
+ import time
11
+ import secrets
12
+ from collections import deque
13
+ from concurrent.futures import Future
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from queue import Queue
17
+ from threading import RLock, Thread, Event
18
+
19
+ from seamless import Buffer, Checksum
20
+ from seamless.checksum.canonical import canon_T, DIRECTORY_CELLTYPES
21
+ from ..policy import ABSENT, INVALID
22
+ from ..session import Observation, DeliveryAck, MountLease
23
+
24
+
25
+ class FileSystem:
26
+ """Injectable filesystem boundary for deterministic race/failure tests."""
27
+ stat = staticmethod(os.stat)
28
+ read = staticmethod(lambda path: Path(path).read_bytes())
29
+ replace = staticmethod(os.replace)
30
+ unlink = staticmethod(os.unlink)
31
+
32
+
33
+ @dataclass(eq=False)
34
+ class Registration:
35
+ path: str
36
+ session_id: str
37
+ spec: object
38
+ celltype: str
39
+ sink: object
40
+ active: bool = False
41
+ closed: bool = False
42
+ ws: int = 0
43
+ baseline: object = None
44
+ lock: object = field(default_factory=RLock)
45
+ queue: object = field(default_factory=deque)
46
+ busy: bool = False
47
+ poll_queued: bool = False
48
+ closing: bool = False
49
+ replaces: tuple = ()
50
+
51
+ @property
52
+ def directory(self): return self.celltype in DIRECTORY_CELLTYPES
53
+
54
+
55
+ class FileSystemService:
56
+ poll_interval = .2
57
+ racy_window = 2.
58
+ delivery_timeout = 60.
59
+ max_file_size = 1024**3
60
+ max_files = 100_000
61
+ max_tree_size = 10 * 1024**3
62
+ scan_timeout = 60.
63
+
64
+ def __init__(self, *, filesystem=None, workers=4, native=False):
65
+ self.fs = filesystem or FileSystem()
66
+ from .native import NativeNotifications
67
+ self.native = NativeNotifications() if native else None
68
+ self.lock = RLock()
69
+ self.registrations = set()
70
+ self._directory_source_owners = {}
71
+ self.read_locks = {}
72
+ self.work = Queue()
73
+ self.stopping = Event()
74
+ self.workers = [Thread(target=self._worker, name=f'Mount-IO-{n}', daemon=True) for n in range(workers)]
75
+ for worker in self.workers: worker.start()
76
+ self.broker = Thread(target=self._broker, name='Mount-broker', daemon=True)
77
+ self.broker.start()
78
+
79
+ def _worker(self):
80
+ while True:
81
+ task = self.work.get()
82
+ if task is None: return
83
+ reg, function, future = task
84
+ try:
85
+ with reg.io_lock:
86
+ result = function()
87
+ except BaseException as exc:
88
+ future.set_exception(exc)
89
+ else:
90
+ future.set_result(result)
91
+ finally:
92
+ with reg.lock:
93
+ if reg.queue:
94
+ self.work.put((reg, *reg.queue.popleft()))
95
+ else: reg.busy = False
96
+
97
+ def _submit(self, reg, function):
98
+ future = Future()
99
+ with reg.lock:
100
+ if reg.busy: reg.queue.append((function, future))
101
+ else:
102
+ reg.busy = True
103
+ self.work.put((reg, function, future))
104
+ return future
105
+
106
+ def reserve(self, spec, celltype, session_id, sink, *, replaces=()):
107
+ path = os.path.realpath(os.path.abspath(spec.path))
108
+ reg = Registration(path, session_id, spec, celltype, sink, replaces=tuple(replaces))
109
+ reg.service = self
110
+ with self.lock:
111
+ reg.io_lock = self.read_locks.setdefault(path, RLock())
112
+ if self.stopping.is_set(): raise RuntimeError('Filesystem service closed')
113
+ for other in self.registrations:
114
+ if other.closed or other.closing or other in replaces: continue
115
+ overlap = path == other.path or path.startswith(other.path + os.sep) or other.path.startswith(path + os.sep)
116
+ cleanup = reg.directory and not spec.persistent
117
+ other_cleanup = other.directory and not other.spec.persistent
118
+ if overlap and ('w' in spec.mode or 'w' in other.spec.mode or cleanup or other_cleanup):
119
+ raise ValueError(f'Mount path overlaps existing registration: {path!r}, {other.path!r}')
120
+ self.registrations.add(reg)
121
+ return reg
122
+
123
+ def activate(self, reg):
124
+ reg.active = True
125
+
126
+ def initial_read(self, reg):
127
+ def initial():
128
+ reg.ws += 1
129
+ observation = self._read(reg, reg.ws, initial=True)
130
+ reg.baseline = observation.fingerprint
131
+ return observation
132
+ return self._submit(reg, initial)
133
+
134
+ @staticmethod
135
+ def _temporary(name):
136
+ return name.startswith('.') and '.seamless-' in name and name.endswith('.tmp')
137
+
138
+ def _stat(self, path):
139
+ try:
140
+ s = self.fs.stat(path)
141
+ return (s.st_dev, s.st_ino, s.st_size, s.st_mtime_ns, s.st_ctime_ns)
142
+ except FileNotFoundError: return None
143
+
144
+ def _fingerprint(self, reg):
145
+ root = self._stat(reg.path)
146
+ if root is None or not reg.directory: return root
147
+ if not stat.S_ISDIR(self.fs.stat(reg.path).st_mode): raise NotADirectoryError(reg.path)
148
+ start, entries = time.monotonic(), []
149
+ for parent, dirs, files in os.walk(reg.path, followlinks=False):
150
+ dirs.sort()
151
+ for name in sorted(files):
152
+ if self._temporary(name): continue
153
+ path = os.path.join(parent, name)
154
+ if os.path.islink(path):
155
+ raise ValueError('Directory mounts do not follow leaf symlinks')
156
+ entries.append((os.path.relpath(path, reg.path), self._stat(path)))
157
+ if len(entries) > self.max_files or time.monotonic() - start > self.scan_timeout:
158
+ raise ValueError('Directory scan limit exceeded')
159
+ return root, tuple(entries)
160
+
161
+ def _decode(self, path, data):
162
+ if not data: return b""
163
+ if path.endswith('.gz'):
164
+ import io
165
+ with gzip.GzipFile(fileobj=io.BytesIO(data)) as stream:
166
+ return stream.read(self.max_file_size + 1)
167
+ if path.endswith('.zst'):
168
+ import zstandard
169
+ with zstandard.ZstdDecompressor().stream_reader(data) as stream:
170
+ return stream.read(self.max_file_size + 1)
171
+ return data
172
+
173
+ def _encode(self, path, data):
174
+ if not data: return b""
175
+ if path.endswith('.gz'): return gzip.compress(data, compresslevel=6, mtime=0)
176
+ if path.endswith('.zst'):
177
+ import zstandard
178
+ return zstandard.ZstdCompressor(level=3).compress(data)
179
+ return data
180
+
181
+ def _read_bytes(self, path):
182
+ if self.fs.stat(path).st_size > self.max_file_size: raise ValueError('File size limit exceeded')
183
+ content = self._decode(path, self.fs.read(path))
184
+ if len(content) > self.max_file_size: raise ValueError('Decompressed file size limit exceeded')
185
+ return content
186
+
187
+ def _read(self, reg, ws, *, initial=False):
188
+ leases = []
189
+ fingerprint = None
190
+ try:
191
+ for attempt in range(5):
192
+ for lease in leases: lease._release_refholds()
193
+ leases = []
194
+ fingerprint = self._fingerprint(reg)
195
+ if fingerprint is None:
196
+ return Observation(reg.session_id, ws, None, ABSENT, no_value=True)
197
+ no_value = False
198
+ needs_canonical_write = False
199
+ if reg.directory:
200
+ index, size = {}, 0
201
+ started = time.monotonic()
202
+ for relative, _ in fingerprint[1]:
203
+ content = self._read_bytes(os.path.join(reg.path, relative))
204
+ size += len(content)
205
+ if size > self.max_tree_size or time.monotonic() - started > self.scan_timeout:
206
+ raise ValueError('Directory content limit exceeded')
207
+ key = relative[:-3] if relative.endswith('.gz') else relative[:-4] if relative.endswith('.zst') else relative
208
+ if key in index: raise ValueError(f'Duplicate decompressed directory path: {key}')
209
+ buf = Buffer(content)
210
+ cs = buf.get_checksum()
211
+ buf.tempref()
212
+ leases.append(MountLease(cs, f'mount:{reg.session_id}:leaf'))
213
+ index[key] = cs.hex()
214
+ no_value = initial and not index
215
+ if no_value:
216
+ from seamless.checksum.null import NULL_BUFFER
217
+ buf = Buffer(NULL_BUFFER)
218
+ else:
219
+ buf = Buffer(index, 'plain')
220
+ else:
221
+ content = self._read_bytes(reg.path)
222
+ no_value = not content
223
+ if no_value:
224
+ from seamless.checksum.null import NULL_BUFFER
225
+ buf = Buffer(NULL_BUFFER)
226
+ else:
227
+ canonical = canon_T(content, reg.celltype)
228
+ needs_canonical_write = canonical != content
229
+ buf = Buffer(canonical)
230
+ after = self._fingerprint(reg)
231
+ if fingerprint == after: break
232
+ time.sleep(.01 * (attempt + 1))
233
+ else: raise OSError('File changed throughout stable read')
234
+ cs = buf.get_checksum()
235
+ buf.tempref()
236
+ leases.insert(0, MountLease(cs, f'mount:{reg.session_id}:observation'))
237
+ return Observation(reg.session_id, ws, fingerprint, cs.hex(), buf, tuple(leases),
238
+ no_value=no_value, needs_canonical_write=needs_canonical_write)
239
+ except Exception as exc:
240
+ for lease in leases: lease._release_refholds()
241
+ return Observation(reg.session_id, ws, fingerprint, INVALID, reason=f'{type(exc).__name__}: {exc}')
242
+
243
+ def _send(self, reg, operation, payload):
244
+ if reg.closed and operation == '_mount_observed':
245
+ payload.release()
246
+ return
247
+ reg.sink(operation, payload)
248
+
249
+ def _poll(self, reg, *, force=False):
250
+ with reg.io_lock: return self._poll_locked(reg, force=force)
251
+
252
+ def _poll_locked(self, reg, *, force=False):
253
+ if reg.closed or not reg.active: return
254
+ try:
255
+ fingerprint = self._fingerprint(reg)
256
+ root = fingerprint[0] if reg.directory and fingerprint else fingerprint
257
+ stamps = [root] if not reg.directory or not fingerprint else [root, *(fp for _, fp in fingerprint[1])]
258
+ racy = any(fp is not None and abs(time.time() - fp[3] / 1e9) <= self.racy_window for fp in stamps)
259
+ except Exception:
260
+ fingerprint, racy = INVALID, True
261
+ if force or fingerprint != reg.baseline or racy:
262
+ reg.ws += 1
263
+ reg.baseline = fingerprint
264
+ observation = self._read(reg, reg.ws)
265
+ if reg.spec.mode == 'r':
266
+ with self.lock:
267
+ peers = tuple(r for r in self.registrations if r is not reg and r.active and not r.closed
268
+ and r.spec.mode == 'r' and r.path == reg.path and r.celltype == reg.celltype)
269
+ for peer in peers:
270
+ with peer.lock:
271
+ peer.ws += 1
272
+ peer.baseline = fingerprint
273
+ leases = tuple(MountLease(lease.checksum, f'mount:{peer.session_id}:shared-read')
274
+ for lease in observation.leases)
275
+ shared = Observation(peer.session_id, peer.ws, observation.fingerprint,
276
+ observation.checksum, observation.buffer, leases, observation.reason)
277
+ self._send(peer, '_mount_observed', shared)
278
+ self._send(reg, '_mount_observed', observation)
279
+
280
+ def poll(self, reg, *, force=False):
281
+ return self._submit(reg, lambda: self._poll(reg, force=force))
282
+
283
+ def request_cut(self, reg, cut_id):
284
+ def cut():
285
+ self._poll(reg, force=True)
286
+ self._send(reg, '_mount_cut', (reg.session_id, cut_id, reg.ws))
287
+ return self._submit(reg, cut)
288
+
289
+ def _broker(self):
290
+ while not self.stopping.wait(self.poll_interval):
291
+ with self.lock: registrations = tuple(self.registrations)
292
+ changed = self.native.poll(registrations) if self.native else set()
293
+ shared = set()
294
+ for reg in registrations:
295
+ if reg.spec.mode == "r":
296
+ key = (reg.path, reg.celltype)
297
+ if key in shared: continue
298
+ if reg.active and not reg.closed: shared.add(key)
299
+ with reg.lock:
300
+ if reg.closed or not reg.active or reg.poll_queued: continue
301
+ reg.poll_queued = True
302
+ def poll(reg=reg, force=reg.path in changed):
303
+ try:
304
+ self._poll(reg, force=force)
305
+ self._send(reg, '_mount_tick', reg.session_id)
306
+ finally: reg.poll_queued = False
307
+ self._submit(reg, poll)
308
+
309
+ def _atomic_write(self, path, content, expected):
310
+ parent, name = os.path.split(path)
311
+ temporary = os.path.join(parent, f'.{name}.seamless-{secrets.token_hex(8)}.tmp')
312
+ try:
313
+ with open(temporary, 'xb') as handle:
314
+ handle.write(self._encode(path, content))
315
+ if expected is not None:
316
+ os.chmod(temporary, stat.S_IMODE(self.fs.stat(path).st_mode))
317
+ if self._stat(path) != expected: return False
318
+ self.fs.replace(temporary, path)
319
+ return True
320
+ finally:
321
+ try: self.fs.unlink(temporary)
322
+ except FileNotFoundError: pass
323
+
324
+ def _resolve(self, checksum):
325
+ import asyncio
326
+ async def resolve():
327
+ return await asyncio.wait_for(checksum.resolution(), self.delivery_timeout)
328
+ return asyncio.run(resolve())
329
+
330
+ def _directory_leaf_loader(self, reg, relative_paths, checksum):
331
+ from seamless.checksum.calculate_checksum import calculate_checksum
332
+
333
+ def load():
334
+ if reg.closing or reg.closed:
335
+ return None
336
+ for relative in relative_paths:
337
+ try:
338
+ path = os.path.join(reg.path, relative)
339
+ absolute_path = os.path.abspath(path)
340
+ real_path = os.path.realpath(path)
341
+ if (os.path.islink(path) or real_path != absolute_path
342
+ or os.path.commonpath((real_path, reg.path)) != reg.path):
343
+ continue
344
+ content = self._read_bytes(path)
345
+ except (OSError, ValueError):
346
+ continue
347
+ if calculate_checksum(content) == checksum.hex():
348
+ return Buffer(content, checksum=checksum)
349
+ return None
350
+ return load
351
+
352
+ def _directory_leaf_metadata(self, reg, relative_paths):
353
+ if reg.closing or reg.closed:
354
+ return ()
355
+ result = []
356
+ for relative in relative_paths:
357
+ try:
358
+ path = os.path.join(reg.path, relative)
359
+ absolute_path = os.path.abspath(path)
360
+ real_path = os.path.realpath(path)
361
+ if (os.path.islink(path) or real_path != absolute_path
362
+ or os.path.commonpath((real_path, reg.path)) != reg.path):
363
+ continue
364
+ metadata = self.fs.stat(path)
365
+ except (OSError, ValueError):
366
+ continue
367
+ if stat.S_ISREG(metadata.st_mode):
368
+ result.append(metadata)
369
+ return tuple(result)
370
+
371
+ def _directory_buffer_source(self, reg, leaves):
372
+ from seamless.caching.buffer_source import BufferSource
373
+
374
+ loaders = {}
375
+ for checksum, paths in leaves.items():
376
+ checksum = Checksum(checksum)
377
+ paths = tuple(paths)
378
+ loaders[checksum] = (
379
+ self._directory_leaf_loader(reg, paths, checksum),
380
+ paths,
381
+ )
382
+
383
+ def get_buffer(checksum):
384
+ entry = loaders.get(Checksum(checksum))
385
+ return None if entry is None else entry[0]()
386
+
387
+ def metadata(checksum):
388
+ entry = loaders.get(Checksum(checksum))
389
+ if entry is None:
390
+ return ()
391
+ return self._directory_leaf_metadata(reg, entry[1])
392
+
393
+ def has_buffer(checksum):
394
+ return bool(metadata(checksum))
395
+
396
+ def get_buffer_length(checksum):
397
+ entry = loaders.get(Checksum(checksum))
398
+ if entry is None:
399
+ return None
400
+ # Mount paths can be transparently compressed. Their physical
401
+ # size is not the size of the checksum-addressed buffer.
402
+ for relative in entry[1]:
403
+ if relative.endswith((".gz", ".zst")):
404
+ continue
405
+ files = self._directory_leaf_metadata(reg, (relative,))
406
+ if files:
407
+ return files[0].st_size
408
+ return None
409
+
410
+ return BufferSource(
411
+ get_buffer=get_buffer,
412
+ has_buffer=has_buffer,
413
+ get_buffer_length=get_buffer_length,
414
+ returns_verified_buffer=True,
415
+ )
416
+
417
+ def _replace_directory_buffer_sources(self, reg, leaves):
418
+ from seamless.caching.buffer_source import replace_buffer_sources
419
+
420
+ source = self._directory_buffer_source(reg, leaves) if leaves else ()
421
+ replace_buffer_sources(reg, source)
422
+
423
+ def _unregister_directory_buffer_sources(self, reg):
424
+ from seamless.caching.buffer_source import unregister_buffer_sources
425
+
426
+ unregister_buffer_sources(reg)
427
+ owner = self._directory_source_owners.pop(reg, None)
428
+ if owner is not None:
429
+ unregister_buffer_sources(owner)
430
+
431
+ def _stage_directory_buffer_sources(self, reg, leaves):
432
+ from seamless.caching.buffer_source import replace_buffer_sources
433
+
434
+ owner = self._directory_source_owners.setdefault(reg, object())
435
+ source = self._directory_buffer_source(reg, leaves)
436
+ replace_buffer_sources(owner, source)
437
+ return owner
438
+
439
+ def _clear_staged_directory_buffer_sources(self, reg):
440
+ from seamless.caching.buffer_source import unregister_buffer_sources
441
+
442
+ owner = self._directory_source_owners.pop(reg, None)
443
+ if owner is not None:
444
+ unregister_buffer_sources(owner)
445
+
446
+ def _write_directory(self, reg, delivery, content):
447
+ import json
448
+ index = json.loads(content)
449
+ if index is None:
450
+ if self._fingerprint(reg) != delivery.expected_fingerprint: return False
451
+ self._replace_directory_buffer_sources(reg, {})
452
+ self._clear_staged_directory_buffer_sources(reg)
453
+ return True
454
+ if not isinstance(index, dict): raise ValueError('Directory index must be a mapping')
455
+ if len(index) > self.max_files: raise ValueError('Directory file-count limit exceeded')
456
+ for name in index:
457
+ if (not isinstance(name, str) or not name or os.path.isabs(name)
458
+ or any(part in {'', '.', '..'} for part in name.split('/'))):
459
+ raise ValueError(f'Unsafe directory path: {name!r}')
460
+ if self._fingerprint(reg) != delivery.expected_fingerprint: return False
461
+ physical_names = {}
462
+ if delivery.expected_fingerprint is not None:
463
+ for relative, _ in delivery.expected_fingerprint[1]:
464
+ key = relative[:-3] if relative.endswith('.gz') else relative[:-4] if relative.endswith('.zst') else relative
465
+ if key in physical_names: raise ValueError(f'Duplicate decompressed directory path: {key}')
466
+ physical_names[key] = relative
467
+ leaf_sources = {}
468
+ for name, checksum in sorted(index.items()):
469
+ default_name = name + ('.gz' if name.endswith('.gz') else '.zst' if name.endswith('.zst') else '')
470
+ path = os.path.join(reg.path, physical_names.get(name, default_name))
471
+ if os.path.commonpath((os.path.realpath(path), reg.path)) != reg.path:
472
+ raise ValueError('Directory path escapes mount through a symlink')
473
+ leaf_sources.setdefault(checksum, []).append(os.path.relpath(path, reg.path))
474
+
475
+ os.makedirs(reg.path, exist_ok=True)
476
+ # Keep the previous source owner available while writing a replacement
477
+ # index. Staged paths become usable as each leaf is written; the source
478
+ # callbacks validate content before returning it.
479
+ self._stage_directory_buffer_sources(reg, leaf_sources)
480
+ total, started = 0, time.monotonic()
481
+ for name, checksum in sorted(index.items()):
482
+ if reg.closed: raise RuntimeError('Directory mount closed during write')
483
+ default_name = name + ('.gz' if name.endswith('.gz') else '.zst' if name.endswith('.zst') else '')
484
+ path = os.path.join(reg.path, physical_names.get(name, default_name))
485
+ absolute_path = os.path.abspath(path)
486
+ real_path = os.path.realpath(path)
487
+ if (os.path.islink(path) or real_path != absolute_path
488
+ or os.path.commonpath((real_path, reg.path)) != reg.path):
489
+ raise ValueError('Directory path escapes mount through a symlink')
490
+ buf = self._resolve(Checksum(checksum))
491
+ data = buf.content if hasattr(buf, 'content') else bytes(buf)
492
+ total += len(data)
493
+ if total > self.max_tree_size or time.monotonic() - started > self.scan_timeout:
494
+ raise ValueError('Directory delivery limit exceeded')
495
+ os.makedirs(os.path.dirname(path), exist_ok=True)
496
+ expected = self._stat(path)
497
+ if expected is None or self._read_bytes(path) != data:
498
+ if not self._atomic_write(path, data, expected):
499
+ raise OSError(f'Directory leaf changed: {name}')
500
+ if reg.spec.mode == 'w' or reg.spec.authority == 'cell':
501
+ for parent, dirs, files in os.walk(reg.path, topdown=False):
502
+ for name in files:
503
+ path = os.path.join(parent, name)
504
+ if self._temporary(name): continue
505
+ relative = os.path.relpath(path, reg.path)
506
+ key = relative[:-3] if relative.endswith('.gz') else relative[:-4] if relative.endswith('.zst') else relative
507
+ if key not in index: self.fs.unlink(path)
508
+ for name in dirs:
509
+ try: os.rmdir(os.path.join(parent, name))
510
+ except OSError: pass
511
+ self._replace_directory_buffer_sources(reg, leaf_sources)
512
+ self._clear_staged_directory_buffer_sources(reg)
513
+ return True
514
+
515
+ def deliver(self, reg, delivery):
516
+ # The controller may release its delivery claim on unmount while this
517
+ # queued operation is still resolving. Retain a separate worker claim.
518
+ transport_lease = MountLease(delivery.lease.checksum, f'mount:{reg.session_id}:io:{delivery.seq}')
519
+ def write():
520
+ try:
521
+ if reg.closed: raise RuntimeError('Mount was unregistered')
522
+ buf = self._resolve(transport_lease.checksum)
523
+ content = buf.content if hasattr(buf, 'content') else bytes(buf)
524
+ disk_checksum = delivery.checksum
525
+ if not reg.directory:
526
+ content = canon_T(content, reg.celltype)
527
+ from seamless import Buffer
528
+ disk_checksum = Buffer(content).get_checksum().hex()
529
+ from seamless.checksum.null import NULL_BUFFER
530
+ if not reg.directory and content == NULL_BUFFER:
531
+ content = b''
532
+ written = (self._write_directory(reg, delivery, content) if reg.directory else
533
+ self._atomic_write(reg.path, content, delivery.expected_fingerprint))
534
+ if not written:
535
+ ack = DeliveryAck(reg.session_id, delivery.seq, reg.ws, 'conflict')
536
+ else:
537
+ reg.ws += 1
538
+ reg.baseline = self._fingerprint(reg)
539
+ ack = DeliveryAck(reg.session_id, delivery.seq, reg.ws, 'written', disk_checksum, reg.baseline)
540
+ except Exception as exc:
541
+ ack = DeliveryAck(reg.session_id, delivery.seq, reg.ws, 'error', reason=f'{type(exc).__name__}: {exc}')
542
+ transport_lease._release_refholds()
543
+ self._send(reg, '_mount_delivered', ack)
544
+ if ack.outcome == 'conflict': self._poll(reg, force=True)
545
+ return self._submit(reg, write)
546
+
547
+ def unregister(self, reg, *, delete=False, expected=None):
548
+ reg.active = False
549
+ reg.closing = True
550
+ self._unregister_directory_buffer_sources(reg)
551
+ def cleanup():
552
+ try:
553
+ with self.lock:
554
+ remounted = any(r is not reg and not r.closed and not r.closing and r.path == reg.path
555
+ for r in self.registrations)
556
+ if delete and not remounted and self._fingerprint(reg) == expected and expected is not None:
557
+ if reg.directory:
558
+ import shutil
559
+ shutil.rmtree(reg.path)
560
+ else: self.fs.unlink(reg.path)
561
+ finally:
562
+ reg.closed = True
563
+ try:
564
+ # An in-flight delivery may have staged new sources after
565
+ # unregister() started. The cleanup is serialized behind it,
566
+ # so remove its final source map here as well.
567
+ self._unregister_directory_buffer_sources(reg)
568
+ finally:
569
+ with self.lock:
570
+ self.registrations.discard(reg)
571
+ if not any(r.path == reg.path for r in self.registrations):
572
+ self.read_locks.pop(reg.path, None)
573
+ return self._submit(reg, cleanup)
574
+
575
+ def close(self, timeout=60):
576
+ self.stopping.set()
577
+ self.broker.join(timeout)
578
+ if self.native is not None: self.native.close()
579
+ with self.lock: regs = tuple(self.registrations)
580
+ deadline = time.monotonic() + timeout
581
+ for reg in regs:
582
+ try: self.unregister(reg).result(max(0, deadline - time.monotonic()))
583
+ except Exception: pass
584
+ for _ in self.workers: self.work.put(None)
585
+ for worker in self.workers: worker.join(max(0, deadline - time.monotonic()))
586
+
587
+
588
+ _service = None
589
+ _service_lock = RLock()
590
+
591
+
592
+ def get_service():
593
+ global _service
594
+ with _service_lock:
595
+ if _service is None or _service.stopping.is_set(): _service = FileSystemService(native=os.environ.get("SEAMLESS_MOUNT_NATIVE") == "1")
596
+ return _service
597
+
598
+
599
+ def close_service():
600
+ global _service
601
+ if _service is not None: _service.close()
@@ -0,0 +1,65 @@
1
+ """Deterministic file-policy transport for tests; never serialized."""
2
+ from collections import deque
3
+ from concurrent.futures import Future
4
+ from types import SimpleNamespace
5
+ from uuid import uuid4
6
+ from seamless import Buffer
7
+ from .api import make_sink
8
+ from .spec import AttachmentSpec
9
+ from .session import Observation, DeliveryAck, MountLease
10
+ from .policy import ABSENT, INVALID
11
+
12
+
13
+ class ManualDriver:
14
+ delivery_timeout = 60
15
+
16
+ def __init__(self):
17
+ self.deliveries = deque()
18
+ self.cuts = deque()
19
+ self.registration = None
20
+
21
+ def attach(self, cell, value=None, *, mode='rw', authority='file', rejected=None):
22
+ backend = cell._workflow_backend
23
+ ctx, path = backend.context, backend.node_path
24
+ spec = AttachmentSpec('manual-' + uuid4().hex, mode, authority, driver='manual')
25
+ celltype = ctx._controller.call('_mount_validate', path, spec, klass=4)
26
+ reg = self.registration = SimpleNamespace(session_id=uuid4().hex, celltype=celltype,
27
+ service=self, sink=make_sink(ctx._controller), active=False, closed=False, ws=0, spec=spec)
28
+ observation = self.observation(value, rejected=rejected)
29
+ ctx._controller.call('_mount_attach', path, spec, reg, observation, klass=2)
30
+ return self
31
+
32
+ def observation(self, value=None, *, ws=None, rejected=None):
33
+ reg = self.registration
34
+ if ws is None: reg.ws += 1; ws = reg.ws
35
+ if rejected is not None: return Observation(reg.session_id, ws, ws, INVALID, reason=rejected)
36
+ if value is None: return Observation(reg.session_id, ws, ws, ABSENT)
37
+ buf = Buffer(value, reg.celltype)
38
+ cs = buf.get_checksum(); buf.tempref()
39
+ return Observation(reg.session_id, ws, ws, cs.hex(), buf, (MountLease(cs, 'manual:observation'),))
40
+
41
+ def observe(self, value=None, **kwargs):
42
+ observation = self.observation(value, **kwargs)
43
+ self.registration.sink('_mount_observed', observation)
44
+ return observation
45
+
46
+ def activate(self, reg): reg.active = True
47
+ def deliver(self, reg, delivery): self.deliveries.append(delivery)
48
+ def request_cut(self, reg, cut_id): self.cuts.append(cut_id)
49
+ def poll(self, reg, *, force=False): pass
50
+
51
+ def ack(self, delivery=None, *, outcome='written', ws=None, reason='injected write failure'):
52
+ if delivery is None: delivery = self.deliveries.popleft()
53
+ reg = self.registration
54
+ if ws is None: reg.ws += 1; ws = reg.ws
55
+ reg.sink('_mount_delivered', DeliveryAck(reg.session_id, delivery.seq, ws, outcome,
56
+ delivery.checksum, ws, reason))
57
+
58
+ def cut(self):
59
+ reg = self.registration
60
+ reg.sink('_mount_cut', (reg.session_id, self.cuts.popleft(), reg.ws))
61
+
62
+ def unregister(self, reg, **kwargs):
63
+ reg.closed = True
64
+ future = Future(); future.set_result(None)
65
+ return future