mfup-fastapi 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.
mfup_fastapi/engine.py ADDED
@@ -0,0 +1,1118 @@
1
+ """MfupEngine — the FastAPI-facing shell around the mfup-core engine.
2
+
3
+ Embedding into an existing application:
4
+
5
+ from fastapi import FastAPI
6
+ from mfup_fastapi import MfupConfig, MfupEngine
7
+
8
+ engine = MfupEngine(MfupConfig(
9
+ base_dir=Path("/srv/uploads"),
10
+ redis_url="redis://localhost:6379/0",
11
+ authorize=my_authorize, # a callable — or "pkg.mod:func"
12
+ ))
13
+ app = FastAPI(lifespan=engine.lifespan)
14
+ app.include_router(engine.router, prefix="/api/uploads")
15
+
16
+ The router keeps the protocol's fixed "/mfup/*" namespace under whatever
17
+ prefix it is mounted at; point the browser client's serverUrl at the same
18
+ prefix ("https://host/api/uploads") and every path lines up. No module-level
19
+ state: two engines in one process are two independent instances.
20
+
21
+ The standalone server (``python -m mfup_fastapi`` / mfup_fastapi.app:app)
22
+ is a thin wrapper: ``create_app(MfupConfig.from_env())``.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import logging
29
+ import json
30
+ import secrets
31
+ import shutil
32
+ import time
33
+ from contextlib import asynccontextmanager
34
+ from datetime import datetime, timedelta, timezone
35
+ from pathlib import Path
36
+ from typing import Any, AsyncIterator, Optional
37
+
38
+ from fastapi import APIRouter, FastAPI, Request, WebSocket, WebSocketDisconnect, status
39
+ from fastapi.responses import JSONResponse
40
+
41
+ from mfup_core.hooks import (
42
+ AuthRequest,
43
+ AuthorizeHook,
44
+ CommitEvent,
45
+ FileMapRequest,
46
+ MapFileHook,
47
+ OnCommittedHook,
48
+ load_authorize_hook,
49
+ resolve_hook,
50
+ )
51
+ from mfup_core.protocol import CRC32C_IMPL, PROTOCOL_VERSION, FrameReader, SessionEndFrame, SessionState
52
+ from mfup_core.session_manager import SessionRegistry, LiveSession
53
+ from mfup_core.publish import (
54
+ ConflictError,
55
+ MappingError,
56
+ list_payload_files,
57
+ publish_session,
58
+ publish_session_mapped,
59
+ )
60
+ from mfup_core.redis_index import SessionIndex
61
+ from mfup_core.storage import staging_dir
62
+
63
+ from .config import MfupConfig
64
+
65
+ logger = logging.getLogger("mfup.engine")
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Typed publish errors — raised by MfupEngine.publish(), mapped to HTTP by
70
+ # the endpoint; consumer backends calling publish() directly catch them.
71
+ # ---------------------------------------------------------------------------
72
+
73
+ class PublishError(Exception):
74
+ pass
75
+
76
+
77
+ class SessionNotFound(PublishError):
78
+ pass
79
+
80
+
81
+ class NotCommitted(PublishError):
82
+ def __init__(self, state: str) -> None:
83
+ super().__init__(f"cannot publish session in state {state}")
84
+ self.state = state
85
+
86
+
87
+ class TargetEscapes(PublishError):
88
+ pass
89
+
90
+
91
+ class MapFileHookError(PublishError):
92
+ def __init__(self, path: str) -> None:
93
+ super().__init__(f"map_file hook raised for {path}")
94
+ self.path = path
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Retention safety net (kept as a free function — tests exercise it directly)
99
+ # ---------------------------------------------------------------------------
100
+
101
+ async def reconcile_orphans(
102
+ base: Path,
103
+ registry: SessionRegistry,
104
+ index: SessionIndex,
105
+ prefix: str,
106
+ grace_seconds: int = 600,
107
+ ) -> list[str]:
108
+ """Remove staging dirs that no live session and no Redis entry reference.
109
+
110
+ The zset-driven sweeper can only clean sessions Redis still knows about.
111
+ A staging dir becomes an unreachable orphan when cleanup is interrupted
112
+ (crash between rmtree and ZREM), when rmtree silently failed
113
+ (ignore_errors), or when Redis lost the entry (flush/eviction). Such a
114
+ dir can never be resumed (resume needs the session in the registry, which
115
+ at startup is populated only from Redis) nor swept — so it would
116
+ accumulate forever. This scan is the retention safety net.
117
+
118
+ An orphan is removed only when ALL hold, to avoid racing a live upload:
119
+ - not in the in-memory registry,
120
+ - not scored in the Redis session zset,
121
+ - last modified at least `grace_seconds` ago.
122
+ """
123
+ removed: list[str] = []
124
+ if not base.exists():
125
+ return removed
126
+
127
+ now = time.time()
128
+ for entry in base.iterdir():
129
+ name = entry.name
130
+ if not entry.is_dir() or not name.startswith(f"{prefix}."):
131
+ continue
132
+ sid = name[len(prefix) + 1:]
133
+ if registry.get(sid) is not None:
134
+ continue # live in this process
135
+ try:
136
+ if await index.is_registered(sid):
137
+ continue # Redis still tracks it — sweeper owns it
138
+ except Exception:
139
+ # Redis unreachable — do not delete anything we cannot verify.
140
+ logger.warning("Reconcile: cannot verify session %s in Redis, skipping", sid)
141
+ continue
142
+ try:
143
+ age = now - entry.stat().st_mtime
144
+ except OSError:
145
+ continue
146
+ if age < grace_seconds:
147
+ continue # too fresh — might be a session mid-registration
148
+
149
+ shutil.rmtree(str(entry), ignore_errors=True)
150
+ # Belt and suspenders: drop any half-written Redis entry too.
151
+ try:
152
+ await index.remove(sid)
153
+ except Exception:
154
+ pass
155
+ removed.append(sid)
156
+
157
+ return removed
158
+
159
+
160
+ def _is_safe_target(base_dir: Path, target_dir: str) -> bool:
161
+ """Check that target_dir resolves within base_dir (no path traversal)."""
162
+ target = Path(target_dir)
163
+ if target.is_absolute():
164
+ resolved = target.resolve()
165
+ else:
166
+ resolved = (base_dir / target).resolve()
167
+ try:
168
+ resolved.relative_to(base_dir.resolve())
169
+ return True
170
+ except ValueError:
171
+ return False
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Engine
176
+ # ---------------------------------------------------------------------------
177
+
178
+ class MfupEngine:
179
+ """Owns the registry, the Redis index, the sweeper, the hooks, and an
180
+ APIRouter with every MFUP endpoint. All state is instance state."""
181
+
182
+ def __init__(self, config: MfupConfig) -> None:
183
+ self.cfg = config
184
+
185
+ # Hooks: callables pass through, dotted paths import loudly at
186
+ # construction time — a misconfigured hook must not boot.
187
+ if config.authorize is None:
188
+ self.authorize: AuthorizeHook | None = load_authorize_hook(None) # logs the allow-all warning
189
+ else:
190
+ self.authorize = resolve_hook(config.authorize) # type: ignore[assignment]
191
+ self.map_file: MapFileHook | None = resolve_hook(config.map_file) # type: ignore[assignment]
192
+ self.on_committed: OnCommittedHook | None = resolve_hook(config.on_committed) # type: ignore[assignment]
193
+
194
+ self.registry: SessionRegistry | None = None
195
+ self.index: SessionIndex | None = None
196
+ self._sweep_task: asyncio.Task | None = None
197
+
198
+ self.router: APIRouter = self._build_router()
199
+
200
+ # -- lifecycle -----------------------------------------------------------
201
+
202
+ async def startup(self) -> None:
203
+ cfg = self.cfg
204
+ cfg.base_dir.mkdir(parents=True, exist_ok=True)
205
+
206
+ self.index = SessionIndex(cfg.redis_url)
207
+ self.registry = SessionRegistry(
208
+ cfg.base_dir,
209
+ staging_prefix=cfg.staging_prefix,
210
+ session_resume_ttl=cfg.session_resume_ttl,
211
+ leg_idle_timeout=cfg.leg_idle_timeout,
212
+ max_chunk_bytes=cfg.max_chunk_bytes,
213
+ # With a map_file hook the client's layout no longer predicts
214
+ # final paths — the ingest-time conflict ASK would be noise;
215
+ # publish-time conflict handling (409 → action) takes over.
216
+ conflict_check=(self.map_file is None),
217
+ )
218
+
219
+ # Recover live sessions from Redis index → SQLite DBs
220
+ try:
221
+ alive_ids = await self.index.get_not_expired()
222
+ expired_ids = await self.index.get_expired()
223
+ logger.info(
224
+ "Startup: Redis has %d alive + %d expired session(s)",
225
+ len(alive_ids), len(expired_ids),
226
+ )
227
+ recovered = 0
228
+ for sid in alive_ids:
229
+ meta = await self.index.get_meta(sid)
230
+ if not meta or not meta.staging_dir:
231
+ logger.warning("Startup: no meta for session %s, skipping", sid)
232
+ continue
233
+ session = await self.registry.recover_session(sid, Path(meta.staging_dir))
234
+ if session:
235
+ idx = self.index
236
+ session._on_expiry_change = lambda s, e, _idx=idx: _idx.update_expiry(s, e)
237
+ recovered += 1
238
+ logger.info("Startup: recovered %d session(s) from disk", recovered)
239
+ except Exception:
240
+ logger.exception("Startup: session recovery failed")
241
+
242
+ # One filesystem reconciliation at startup: catches staging dirs
243
+ # orphaned by a crash between rmtree and Redis-remove, a silently-
244
+ # failed rmtree, or a Redis flush — none of which the zset-driven
245
+ # sweeper can ever find.
246
+ try:
247
+ removed = await reconcile_orphans(
248
+ cfg.base_dir, self.registry, self.index, cfg.staging_prefix,
249
+ cfg.orphan_grace_seconds,
250
+ )
251
+ if removed:
252
+ logger.warning("Startup: reconciled %d orphaned staging dir(s): %s", len(removed), removed)
253
+ except Exception:
254
+ logger.exception("Startup: orphan reconciliation failed")
255
+
256
+ self._sweep_task = asyncio.create_task(self._sweeper())
257
+
258
+ async def shutdown(self) -> None:
259
+ if self._sweep_task is not None:
260
+ self._sweep_task.cancel()
261
+ try:
262
+ await self._sweep_task
263
+ except asyncio.CancelledError:
264
+ pass
265
+ self._sweep_task = None
266
+ if self.index is not None:
267
+ await self.index.close()
268
+
269
+ @asynccontextmanager
270
+ async def lifespan(self, app: FastAPI) -> AsyncIterator[None]:
271
+ """Drop-in FastAPI lifespan: ``FastAPI(lifespan=engine.lifespan)``.
272
+
273
+ Consumers with their own lifespan call startup()/shutdown() from it
274
+ instead.
275
+ """
276
+ await self.startup()
277
+ try:
278
+ yield
279
+ finally:
280
+ await self.shutdown()
281
+
282
+ def _require_registry(self) -> SessionRegistry:
283
+ assert self.registry is not None, "MfupEngine.startup() has not run"
284
+ return self.registry
285
+
286
+ def _require_index(self) -> SessionIndex:
287
+ assert self.index is not None, "MfupEngine.startup() has not run"
288
+ return self.index
289
+
290
+ # -- periodic sweeper ------------------------------------------------------
291
+
292
+ async def _sweeper(self) -> None:
293
+ cfg = self.cfg
294
+ sweeps = 0
295
+ while True:
296
+ await asyncio.sleep(cfg.sweep_interval)
297
+ try:
298
+ registry = self._require_registry()
299
+ index = self._require_index()
300
+ expired_ids = await index.get_expired()
301
+ for sid in expired_ids:
302
+ await registry.remove(sid)
303
+ meta = await index.get_meta(sid)
304
+ if meta and meta.staging_dir:
305
+ sd = Path(meta.staging_dir)
306
+ else:
307
+ sd = staging_dir(cfg.base_dir, sid, cfg.staging_prefix)
308
+ if sd.exists():
309
+ shutil.rmtree(str(sd), ignore_errors=True)
310
+ await index.remove(sid)
311
+ logger.info("Sweeper cleaned session %s (staging=%s)", sid, sd)
312
+ if expired_ids:
313
+ logger.info("Sweeper removed %d sessions", len(expired_ids))
314
+
315
+ sweeps += 1
316
+ if sweeps % cfg.reconcile_every == 0:
317
+ orphans = await reconcile_orphans(
318
+ cfg.base_dir, registry, index, cfg.staging_prefix,
319
+ cfg.orphan_grace_seconds,
320
+ )
321
+ if orphans:
322
+ logger.warning("Sweeper reconciled %d orphaned staging dir(s): %s", len(orphans), orphans)
323
+ except Exception:
324
+ logger.exception("Sweeper error")
325
+
326
+ # -- programmatic publish ---------------------------------------------------
327
+
328
+ async def publish(self, session_id: str) -> list[str]:
329
+ """Publish a committed session server-side (consumer backends and the
330
+ on_committed auto-publish path). Raises typed PublishError subclasses;
331
+ ConflictError/MappingError from mfup-core pass through."""
332
+ registry = self._require_registry()
333
+ session = registry.get(session_id)
334
+ if session is None:
335
+ raise SessionNotFound(session_id)
336
+ if session.state != SessionState.COMMITTED:
337
+ raise NotCommitted(session.state.value)
338
+
339
+ target = Path(session.target_dir)
340
+ if not target.is_absolute():
341
+ target = session.base_dir / target
342
+
343
+ # Defense in depth: verify target stays within the session's base dir
344
+ if not _is_safe_target(session.base_dir, session.target_dir):
345
+ raise TargetEscapes(session.target_dir)
346
+
347
+ if self.map_file is not None:
348
+ # Per-file layout is the consumer's: run the (async) hook per
349
+ # file first, then hand the precomputed plan to the sync mover.
350
+ files = await asyncio.to_thread(
351
+ list_payload_files, session.base_dir, session_id, self.cfg.staging_prefix,
352
+ )
353
+ mapping: dict[str, str] = {}
354
+ for rel, size in files:
355
+ try:
356
+ mapped = await self.map_file(FileMapRequest(
357
+ session_id=session_id,
358
+ path=rel,
359
+ name=rel.rsplit("/", 1)[-1],
360
+ size=size,
361
+ target_dir=session.target_dir,
362
+ meta=session.client_meta,
363
+ context=session.auth_context,
364
+ ))
365
+ except Exception:
366
+ logger.exception("map_file hook raised for %s (%s)", session_id, rel)
367
+ raise MapFileHookError(rel) from None
368
+ if mapped is not None:
369
+ mapping[rel] = mapped
370
+ published = await asyncio.to_thread(
371
+ publish_session_mapped,
372
+ session.base_dir, session_id, target, mapping, self.cfg.staging_prefix,
373
+ session.publish_action,
374
+ )
375
+ else:
376
+ # publish_session is synchronous filesystem work (renames / merge
377
+ # walks) — keep it off the event loop.
378
+ published = await asyncio.to_thread(
379
+ publish_session,
380
+ session.base_dir, session_id, target, self.cfg.staging_prefix,
381
+ session.publish_action,
382
+ )
383
+
384
+ await registry.remove(session_id)
385
+ await self._require_index().remove(session_id)
386
+ return published
387
+
388
+ # -- on_committed ----------------------------------------------------------
389
+
390
+ async def _after_commit(self, session: LiveSession, result: dict) -> None:
391
+ """Fire the consumer's on_committed hook once per commit. A hook error
392
+ must never damage the session; 'publish' return triggers server-side
393
+ publish immediately."""
394
+ if self.on_committed is None:
395
+ return
396
+ sd = staging_dir(session.base_dir, session.session_id, self.cfg.staging_prefix)
397
+ try:
398
+ verdict = await self.on_committed(CommitEvent(
399
+ session_id=session.session_id,
400
+ target_dir=session.target_dir,
401
+ base_dir=str(session.base_dir),
402
+ staging_dir=str(sd),
403
+ files=result["files"],
404
+ bytes=result["bytes"],
405
+ meta=session.client_meta,
406
+ context=session.auth_context,
407
+ ))
408
+ except Exception:
409
+ logger.exception("on_committed hook raised for session %s", session.session_id)
410
+ return
411
+ if verdict == "publish":
412
+ try:
413
+ published = await self.publish(session.session_id)
414
+ logger.info(
415
+ "on_committed auto-published session %s: %s",
416
+ session.session_id, published,
417
+ )
418
+ except Exception:
419
+ logger.exception("on_committed auto-publish failed for session %s", session.session_id)
420
+
421
+ async def _try_commit(self, session: LiveSession) -> Optional[dict]:
422
+ """try_commit + on_committed, so no call site can forget the hook."""
423
+ result = await session.try_commit()
424
+ if result is not None and session.state == SessionState.COMMITTED:
425
+ await self._after_commit(session, result)
426
+ return result
427
+
428
+ # -- router ----------------------------------------------------------------
429
+
430
+ def _build_router(self) -> APIRouter: # noqa: C901 — one closure per endpoint
431
+ router = APIRouter()
432
+ engine = self
433
+ cfg = self.cfg
434
+
435
+ # -------------------------------------------------------------------
436
+ # WebSocket control endpoint: /mfup/control
437
+ # -------------------------------------------------------------------
438
+
439
+ @router.websocket("/mfup/control")
440
+ async def control_endpoint(ws: WebSocket) -> None:
441
+ await ws.accept()
442
+ registry = engine._require_registry()
443
+ session: LiveSession | None = None
444
+
445
+ try:
446
+ # First message must be HELLO or RESUME
447
+ msg = await ws.receive_json()
448
+ t = msg.get("t")
449
+
450
+ if t == "HELLO":
451
+ if msg.get("v") != PROTOCOL_VERSION:
452
+ await ws.send_json({
453
+ "t": "SESSION_ABORT",
454
+ "code": "bad_version",
455
+ "reason": f"expected {PROTOCOL_VERSION}",
456
+ })
457
+ await ws.close()
458
+ return
459
+
460
+ session_id = msg["session_id"]
461
+ leg_id = msg["leg_id"]
462
+ target_dir = msg.get("target_dir", ".")
463
+ # The resume token is SERVER-issued (returned in HELLO_OK)
464
+ # — a client-chosen token would be a self-signed
465
+ # credential, useless as an authenticator for the
466
+ # data/publish endpoints.
467
+ resume_token = secrets.token_urlsafe(32)
468
+
469
+ # Client-attached session metadata (scope/purpose/ids).
470
+ # Untrusted; size-capped; handed to authorize + map_file.
471
+ client_meta = msg.get("meta")
472
+ meta_json: str | None = None
473
+ if client_meta is not None:
474
+ meta_json = json.dumps(client_meta, ensure_ascii=False)
475
+ if len(meta_json.encode()) > cfg.max_meta_bytes:
476
+ await ws.send_json({
477
+ "t": "SESSION_ABORT",
478
+ "code": "protocol_error",
479
+ "reason": f"meta exceeds {cfg.max_meta_bytes} bytes",
480
+ })
481
+ await ws.close()
482
+ return
483
+
484
+ # Consumer authorization (see mfup_core.hooks). Runs
485
+ # BEFORE anything is created. Deny → auth_failed.
486
+ auth_result = None
487
+ if engine.authorize is not None:
488
+ try:
489
+ auth_result = await engine.authorize(AuthRequest(
490
+ session_id=session_id,
491
+ target_dir=target_dir,
492
+ headers=dict(ws.headers),
493
+ client=f"{ws.client.host}:{ws.client.port}" if ws.client else "",
494
+ query=dict(ws.query_params),
495
+ meta=client_meta,
496
+ ))
497
+ except Exception:
498
+ logger.exception("Authorize hook raised for session %s — denying", session_id)
499
+ auth_result = None
500
+ if auth_result is None:
501
+ await ws.send_json({
502
+ "t": "SESSION_ABORT",
503
+ "code": "auth_failed",
504
+ "reason": "authorization denied",
505
+ })
506
+ await ws.close()
507
+ return
508
+ if auth_result.target_dir is not None:
509
+ # The hook may pin or MAP the target (it received
510
+ # the client-requested value in req.target_dir);
511
+ # still subject to the containment check below.
512
+ target_dir = auth_result.target_dir
513
+
514
+ # Per-session base directory (e.g. the user's home).
515
+ # Staging is created INSIDE it so publish remains a
516
+ # same-filesystem rename even on separate mounts.
517
+ session_base = registry.base_dir
518
+ if auth_result is not None and auth_result.base_dir is not None:
519
+ candidate = Path(auth_result.base_dir)
520
+ if not candidate.is_absolute():
521
+ logger.error(
522
+ "Authorize hook returned a relative base_dir %r for session %s — denying",
523
+ auth_result.base_dir, session_id,
524
+ )
525
+ await ws.send_json({
526
+ "t": "SESSION_ABORT",
527
+ "code": "auth_failed",
528
+ "reason": "authorization misconfigured (relative base_dir)",
529
+ })
530
+ await ws.close()
531
+ return
532
+ session_base = candidate
533
+ session_base.mkdir(parents=True, exist_ok=True)
534
+
535
+ # Validate target_dir stays within the session's base dir
536
+ if not _is_safe_target(session_base, target_dir):
537
+ await ws.send_json({
538
+ "t": "SESSION_ABORT",
539
+ "code": "bad_target_dir",
540
+ "reason": "target_dir escapes base directory",
541
+ })
542
+ await ws.close()
543
+ return
544
+
545
+ expires = datetime.now(timezone.utc) + timedelta(seconds=cfg.session_resume_ttl)
546
+ try:
547
+ session = await registry.create(
548
+ session_id, resume_token, leg_id, expires.isoformat(),
549
+ target_dir=target_dir,
550
+ base_dir=session_base if session_base is not registry.base_dir else None,
551
+ meta_json=meta_json,
552
+ )
553
+ except ValueError:
554
+ # Session already exists — treat as conflict
555
+ await ws.send_json({
556
+ "t": "SESSION_ABORT",
557
+ "code": "conflict",
558
+ "reason": "session already exists",
559
+ })
560
+ await ws.close()
561
+ return
562
+
563
+ # Register in Redis index for TTL-based cleanup (with
564
+ # paths). A Redis failure here must not leave an orphaned
565
+ # in-memory session behind (a retried HELLO would then hit
566
+ # "conflict"), so roll back and abort explicitly.
567
+ sd = staging_dir(session_base, session_id, cfg.staging_prefix)
568
+ try:
569
+ idx = engine._require_index()
570
+ # Absolute staging path in Redis meta is what makes
571
+ # sweeper / lazy-resume work for per-user base dirs.
572
+ await idx.register(session_id, expires, target_dir, str(sd))
573
+ session._on_expiry_change = lambda sid, exp: idx.update_expiry(sid, exp)
574
+ except Exception:
575
+ logger.exception("Redis register failed for session %s — rolling back", session_id)
576
+ await registry.remove(session_id)
577
+ # Without a Redis entry the sweeper would never find
578
+ # this staging dir — remove it now.
579
+ shutil.rmtree(str(sd), ignore_errors=True)
580
+ session = None
581
+ await ws.send_json({
582
+ "t": "SESSION_ABORT",
583
+ "code": "server_error",
584
+ "reason": "session index unavailable, retry later",
585
+ })
586
+ await ws.close()
587
+ return
588
+
589
+ # Apply authorize-hook constraints to the live session
590
+ # (persisted — they must survive restarts / lazy-resume).
591
+ if auth_result is not None:
592
+ session.apply_auth(
593
+ auth_result.max_total_bytes,
594
+ auth_result.max_files,
595
+ auth_result.context,
596
+ )
597
+
598
+ session.ws = ws
599
+ await ws.send_json({
600
+ "t": "HELLO_OK",
601
+ "epoch": session.epoch,
602
+ "expires_at": session.expires_at,
603
+ # Server-issued bearer token for data/probe/publish/RESUME.
604
+ "resume_token": resume_token,
605
+ "limits": {
606
+ "max_chunk_bytes": cfg.max_chunk_bytes,
607
+ "max_open_files": cfg.max_open_files,
608
+ "max_pending_files": cfg.max_pending_files,
609
+ },
610
+ })
611
+
612
+ elif t == "RESUME":
613
+ session_id = msg["session_id"]
614
+ resume_token = msg["resume_token"]
615
+ leg_id = msg["leg_id"]
616
+
617
+ try:
618
+ try:
619
+ session = await registry.resume(session_id, resume_token, leg_id)
620
+ except KeyError:
621
+ # Lazy recovery: the session is not in THIS
622
+ # process's memory (different worker after a
623
+ # deploy/failover), but its durable state may
624
+ # still exist — Redis meta points at the staging
625
+ # dir. Ownership transfers to whichever worker
626
+ # holds the WS.
627
+ meta = await engine._require_index().get_meta(session_id)
628
+ if not meta or not meta.staging_dir:
629
+ raise
630
+ recovered = await registry.recover_session(session_id, Path(meta.staging_dir))
631
+ if recovered is None:
632
+ raise
633
+ logger.info("Lazy-recovered session %s for RESUME", session_id)
634
+ session = await registry.resume(session_id, resume_token, leg_id)
635
+ except KeyError:
636
+ await ws.send_json({
637
+ "t": "SESSION_ABORT",
638
+ "code": "not_found",
639
+ "reason": "session not found or expired",
640
+ })
641
+ await ws.close()
642
+ return
643
+ except PermissionError:
644
+ await ws.send_json({
645
+ "t": "SESSION_ABORT",
646
+ "code": "auth_failed",
647
+ "reason": "invalid resume token",
648
+ })
649
+ await ws.close()
650
+ return
651
+ except ValueError as exc:
652
+ await ws.send_json({
653
+ "t": "SESSION_ABORT",
654
+ "code": "invalid_state",
655
+ "reason": str(exc),
656
+ })
657
+ await ws.close()
658
+ return
659
+
660
+ session.ws = ws
661
+ # Ensure expiry callback is wired for resumed sessions
662
+ idx = engine._require_index()
663
+ session._on_expiry_change = lambda sid, exp: idx.update_expiry(sid, exp)
664
+ resume_ok = session.build_resume_ok()
665
+ await ws.send_json(resume_ok)
666
+
667
+ else:
668
+ await ws.send_json({
669
+ "t": "SESSION_ABORT",
670
+ "code": "protocol_error",
671
+ "reason": f"expected HELLO or RESUME, got {t}",
672
+ })
673
+ await ws.close()
674
+ return
675
+
676
+ # Main control loop — CLIENT_ABORT / ACTION
677
+ while True:
678
+ msg = await ws.receive_json()
679
+ t = msg.get("t")
680
+
681
+ if t == "CLIENT_ABORT":
682
+ if session:
683
+ session.db.set_state(SessionState.ABORTED)
684
+ session.detach_leg()
685
+ break
686
+
687
+ if t == "ACTION":
688
+ action = msg.get("action")
689
+ if session and action in ("merge_overwrite", "cancel"):
690
+ session.publish_action = action
691
+ logger.info("Session %s: ACTION=%s", session.session_id, action)
692
+ if action == "cancel":
693
+ session.db.set_state(SessionState.ABORTED)
694
+ session.detach_leg()
695
+ break
696
+
697
+ except WebSocketDisconnect:
698
+ logger.info("Control WS disconnected for session %s (state=%s)",
699
+ session.session_id if session else "unknown",
700
+ session.state.value if session else "n/a")
701
+ except Exception:
702
+ logger.exception("Control WS error for session %s",
703
+ session.session_id if session else "unknown")
704
+ finally:
705
+ if session:
706
+ # If in COMMITTING state, attempt commit before detaching
707
+ if session.state == SessionState.COMMITTING:
708
+ await engine._try_commit(session)
709
+ if session.ws is ws:
710
+ session.ws = None
711
+ if session.leg_id and session.state not in (
712
+ SessionState.COMMITTED, SessionState.ABORTED,
713
+ ):
714
+ session.detach_leg()
715
+ # Clean up aborted sessions immediately: staging + Redis
716
+ if session.state == SessionState.ABORTED:
717
+ sid = session.session_id
718
+ await registry.remove(sid)
719
+ idx = engine._require_index()
720
+ meta = await idx.get_meta(sid)
721
+ if meta and meta.staging_dir:
722
+ sd = Path(meta.staging_dir)
723
+ else:
724
+ # session.base_dir, not the global one: per-user homes.
725
+ sd = staging_dir(session.base_dir, sid, cfg.staging_prefix)
726
+ if sd.exists():
727
+ shutil.rmtree(str(sd), ignore_errors=True)
728
+ await idx.remove(sid)
729
+ logger.info("Cleaned up aborted session %s (staging=%s)", sid, sd)
730
+
731
+ # -------------------------------------------------------------------
732
+ # HTTP data endpoint: POST /mfup/data/{session_id}/{leg_id}
733
+ # -------------------------------------------------------------------
734
+
735
+ @router.post("/mfup/data/{session_id}/{leg_id}")
736
+ async def data_endpoint(session_id: str, leg_id: str, request: Request, seq: int, final: int = 0, epoch: int = -1):
737
+ registry = engine._require_registry()
738
+ session = registry.get(session_id)
739
+
740
+ if session is None:
741
+ return JSONResponse(
742
+ {"error": "session not found"},
743
+ status_code=status.HTTP_410_GONE,
744
+ )
745
+
746
+ # Verify data channel auth token
747
+ token = request.headers.get("x-mfup-token")
748
+ if not token or token != session.resume_token:
749
+ return JSONResponse(
750
+ {"error": "invalid token"},
751
+ status_code=status.HTTP_403_FORBIDDEN,
752
+ )
753
+
754
+ if session.leg_id != leg_id:
755
+ return JSONResponse(
756
+ {"error": "stale leg", "expected": session.leg_id},
757
+ status_code=status.HTTP_409_CONFLICT,
758
+ )
759
+
760
+ # Epoch is mandatory: an old client omitting ?epoch= must not
761
+ # silently bypass stale-POST fencing.
762
+ if epoch < 0:
763
+ return JSONResponse(
764
+ {"error": "epoch_required"},
765
+ status_code=status.HTTP_400_BAD_REQUEST,
766
+ )
767
+
768
+ # Reject requests from stale epochs (old reconnects / retries)
769
+ if session.epoch != epoch:
770
+ logger.warning(
771
+ "Rejected stale epoch for session %s: got %d, current %d",
772
+ session_id, epoch, session.epoch,
773
+ )
774
+ return JSONResponse(
775
+ {"error": "stale_epoch", "got": epoch, "expected": session.epoch},
776
+ status_code=status.HTTP_409_CONFLICT,
777
+ )
778
+
779
+ state = session.state
780
+ if state not in (SessionState.ACTIVE, SessionState.PAUSED_BY_SERVER):
781
+ return JSONResponse(
782
+ {"error": f"session in state {state.value}"},
783
+ status_code=status.HTTP_409_CONFLICT,
784
+ )
785
+
786
+ # Reject POSTs after final=1 has already been received for this leg
787
+ if session.final_seq_seen:
788
+ logger.warning(
789
+ "Rejected POST after final for session %s leg %s seq=%d",
790
+ session_id, leg_id, seq,
791
+ )
792
+ return JSONResponse(
793
+ {"error": "data_after_final", "detail": "final POST already received"},
794
+ status_code=status.HTTP_409_CONFLICT,
795
+ )
796
+
797
+ # Validate sequence number. Advancing is deferred: for buffered
798
+ # (batch) POSTs seq only advances after the body was FULLY
799
+ # processed, so a client may retry a failed POST with the same
800
+ # seq. A duplicate of an already processed POST then gets
801
+ # seq_mismatch with expected == seq + 1, which the client
802
+ # interprets as "already delivered".
803
+ if not session.validate_seq(seq):
804
+ logger.warning(
805
+ "Seq gap/duplicate for session %s leg %s: got seq=%d, expected=%d",
806
+ session_id, leg_id, seq, session.last_data_seq + 1,
807
+ )
808
+ return JSONResponse(
809
+ {"error": "seq_mismatch", "got": seq, "expected": session.last_data_seq + 1},
810
+ status_code=status.HTTP_409_CONFLICT,
811
+ )
812
+
813
+ # Two body-handling strategies:
814
+ # - Buffered (atomic): Content-Length known and small enough —
815
+ # read the whole body first, process after. Either the entire
816
+ # POST is applied (and seq advances) or none of it is.
817
+ # - Streaming: chunked/oversized body (the duplex:"half" long
818
+ # POST). Frames apply as they arrive; a broken stream recovers
819
+ # through RESUME (new leg, new epoch), never retried by seq.
820
+ content_length = request.headers.get("content-length")
821
+ buffered = content_length is not None and int(content_length) <= cfg.max_buffered_body
822
+
823
+ reader = FrameReader()
824
+ body_received = 0
825
+ frame_count = 0
826
+ session_end_seen = False
827
+ error_detail: str | None = None
828
+
829
+ if buffered:
830
+ body = bytearray()
831
+ try:
832
+ async for chunk in request.stream():
833
+ body.extend(chunk)
834
+ if len(body) > cfg.max_buffered_body:
835
+ return JSONResponse(
836
+ {"error": "body_too_large"},
837
+ status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
838
+ )
839
+ except Exception as exc:
840
+ # Body never fully arrived — nothing was applied, seq not
841
+ # advanced, the client may retry this POST verbatim.
842
+ logger.warning(
843
+ "Buffered body read failed for session %s leg %s seq=%d: %s",
844
+ session_id, leg_id, seq, exc,
845
+ )
846
+ return JSONResponse(
847
+ {"error": "body_read_failed"},
848
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
849
+ )
850
+
851
+ # Re-check freshness after the (awaited) body read.
852
+ if session.leg_id != leg_id or session.epoch != epoch:
853
+ return JSONResponse(
854
+ {"error": "stale_epoch", "got": epoch, "expected": session.epoch},
855
+ status_code=status.HTTP_409_CONFLICT,
856
+ )
857
+
858
+ session.db.begin_batch()
859
+ try:
860
+ reader.feed(bytes(body))
861
+ frames = reader.drain()
862
+ for frame in frames:
863
+ await session.process_frame(frame, leg_id)
864
+ frame_count += 1
865
+ if isinstance(frame, SessionEndFrame):
866
+ session_end_seen = True
867
+ body_received = len(body)
868
+ except Exception as exc:
869
+ error_detail = f"{type(exc).__name__}: {exc}"
870
+ logger.exception("Frame processing error for session %s leg %s seq=%d", session_id, leg_id, seq)
871
+ finally:
872
+ session.db.end_batch()
873
+
874
+ if error_detail is None:
875
+ session.advance_seq(seq)
876
+ if final == 1:
877
+ session.final_seq_seen = True
878
+ else:
879
+ # Streaming path: advance immediately (no retry-by-seq here).
880
+ session.advance_seq(seq)
881
+ if final == 1:
882
+ session.final_seq_seen = True
883
+
884
+ session.db.begin_batch()
885
+ frames_since_flush = 0
886
+ try:
887
+ async for chunk in request.stream():
888
+ if session.leg_id != leg_id or session.epoch != epoch:
889
+ logger.warning("Data stream for stale leg/epoch %s seq=%d, aborting read", leg_id, seq)
890
+ break
891
+
892
+ reader.feed(chunk)
893
+ frames = reader.drain()
894
+ for frame in frames:
895
+ await session.process_frame(frame, leg_id)
896
+ frame_count += 1
897
+ frames_since_flush += 1
898
+ if isinstance(frame, SessionEndFrame):
899
+ session_end_seen = True
900
+ if frames_since_flush >= 500:
901
+ session.db.flush()
902
+ frames_since_flush = 0
903
+ body_received += len(chunk)
904
+
905
+ except Exception as exc:
906
+ error_detail = f"{type(exc).__name__}: {exc}"
907
+ logger.exception("Data stream error for session %s leg %s seq=%d", session_id, leg_id, seq)
908
+ finally:
909
+ session.db.end_batch()
910
+
911
+ logger.info(
912
+ "Data stream ended for session %s leg %s seq=%d: %d bytes, %d frames, final=%d%s",
913
+ session_id, leg_id, seq, body_received, frame_count, final,
914
+ f", error: {error_detail}" if error_detail else "",
915
+ )
916
+
917
+ # Only attempt commit when final=1 or SESSION_END was in frames
918
+ commit_result = None
919
+ if (final == 1 or session_end_seen) and session.state == SessionState.COMMITTING:
920
+ commit_result = await engine._try_commit(session)
921
+
922
+ if error_detail:
923
+ return JSONResponse(
924
+ {"error": "data_stream_error", "detail": error_detail, "bytes_received": body_received},
925
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
926
+ )
927
+
928
+ resp: dict = {"ok": True, "bytes_received": body_received, "frames": frame_count}
929
+ if commit_result is not None:
930
+ resp["commit"] = {"files": commit_result["files"], "bytes": commit_result["bytes"]}
931
+ return JSONResponse(resp, status_code=status.HTTP_200_OK)
932
+
933
+ # -------------------------------------------------------------------
934
+ # HTTP probe endpoint: POST /mfup/probe/{session_id}
935
+ # -------------------------------------------------------------------
936
+
937
+ @router.post("/mfup/probe/{session_id}")
938
+ async def probe_endpoint(session_id: str, request: Request):
939
+ registry = engine._require_registry()
940
+ session = registry.get(session_id)
941
+
942
+ if session is None:
943
+ return JSONResponse(
944
+ {"error": "session not found"},
945
+ status_code=status.HTTP_410_GONE,
946
+ )
947
+
948
+ token = request.headers.get("x-mfup-token")
949
+ if not token or token != session.resume_token:
950
+ return JSONResponse(
951
+ {"error": "invalid token"},
952
+ status_code=status.HTTP_403_FORBIDDEN,
953
+ )
954
+
955
+ state = session.state
956
+ if state in (SessionState.COMMITTED, SessionState.ABORTED, SessionState.EXPIRED):
957
+ return JSONResponse(
958
+ {"error": f"session in terminal state {state.value}"},
959
+ status_code=status.HTTP_410_GONE,
960
+ )
961
+
962
+ # Send PROBE_ACK after the first chunk arrives, with its size so
963
+ # the client can verify real binary data arrived (Firefox
964
+ # stringifies ReadableStream to 23 bytes of "[object ...]").
965
+ probe_ack_sent = False
966
+ total_bytes = 0
967
+ async for chunk in request.stream():
968
+ total_bytes += len(chunk)
969
+ if not probe_ack_sent:
970
+ if session.ws:
971
+ try:
972
+ await session.ws.send_json({
973
+ "t": "PROBE_ACK",
974
+ "first_chunk_bytes": len(chunk),
975
+ })
976
+ except Exception as exc:
977
+ logger.error("Failed to send PROBE_ACK for session %s: %s", session_id, exc)
978
+ probe_ack_sent = True
979
+
980
+ return JSONResponse(
981
+ {"ok": True, "total_bytes": total_bytes},
982
+ status_code=status.HTTP_200_OK,
983
+ )
984
+
985
+ # -------------------------------------------------------------------
986
+ # Admin / status endpoints
987
+ # -------------------------------------------------------------------
988
+
989
+ def _admin_denied(request: Request) -> JSONResponse | None:
990
+ """Admin routes require the token to be configured AND presented."""
991
+ if not cfg.admin_token:
992
+ return JSONResponse({"error": "admin routes disabled"}, status_code=status.HTTP_403_FORBIDDEN)
993
+ if request.headers.get("x-mfup-admin-token") != cfg.admin_token:
994
+ return JSONResponse({"error": "forbidden"}, status_code=status.HTTP_403_FORBIDDEN)
995
+ return None
996
+
997
+ @router.get("/mfup/sessions")
998
+ async def list_sessions(request: Request):
999
+ """List all active sessions (admin/debug)."""
1000
+ denied = _admin_denied(request)
1001
+ if denied:
1002
+ return denied
1003
+ registry = engine._require_registry()
1004
+ result = []
1005
+ for sid, session in registry.all_sessions().items():
1006
+ result.append({
1007
+ "session_id": sid,
1008
+ "state": session.state.value,
1009
+ "epoch": session.epoch,
1010
+ "leg_id": session.leg_id,
1011
+ "expires_at": session.expires_at,
1012
+ })
1013
+ return result
1014
+
1015
+ @router.get("/mfup/sessions/{session_id}")
1016
+ async def get_session_status(session_id: str, request: Request):
1017
+ """Get detailed status for a single session."""
1018
+ denied = _admin_denied(request)
1019
+ if denied:
1020
+ return denied
1021
+ registry = engine._require_registry()
1022
+ session = registry.get(session_id)
1023
+ if session is None:
1024
+ return JSONResponse({"error": "not found"}, status_code=404)
1025
+
1026
+ summary = session.db.get_root_summary()
1027
+ return {
1028
+ "session_id": session_id,
1029
+ "state": session.state.value,
1030
+ "epoch": session.epoch,
1031
+ "leg_id": session.leg_id,
1032
+ "expires_at": session.expires_at,
1033
+ "root_summary": summary,
1034
+ }
1035
+
1036
+ @router.post("/mfup/sessions/{session_id}/publish")
1037
+ async def publish_endpoint(session_id: str, request: Request):
1038
+ """Publish a committed session — atomic renames into target_dir."""
1039
+ registry = engine._require_registry()
1040
+ session = registry.get(session_id)
1041
+ if session is None:
1042
+ return JSONResponse({"error": "not found"}, status_code=404)
1043
+
1044
+ # Publish moves files and destroys staging — same bearer auth as
1045
+ # the data plane: the session's resume token.
1046
+ token = request.headers.get("x-mfup-token")
1047
+ if not token or token != session.resume_token:
1048
+ return JSONResponse({"error": "invalid token"}, status_code=status.HTTP_403_FORBIDDEN)
1049
+
1050
+ try:
1051
+ published = await engine.publish(session_id)
1052
+ except SessionNotFound:
1053
+ return JSONResponse({"error": "not found"}, status_code=404)
1054
+ except NotCommitted as exc:
1055
+ return JSONResponse(
1056
+ {"error": f"cannot publish session in state {exc.state}"},
1057
+ status_code=status.HTTP_409_CONFLICT,
1058
+ )
1059
+ except TargetEscapes:
1060
+ return JSONResponse(
1061
+ {"error": "target_dir escapes base directory"},
1062
+ status_code=status.HTTP_403_FORBIDDEN,
1063
+ )
1064
+ except MapFileHookError as exc:
1065
+ return JSONResponse(
1066
+ {"error": "map_file_hook_error", "path": exc.path},
1067
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1068
+ )
1069
+ except MappingError as exc:
1070
+ # Consumer-hook bug (escape / duplicate destination) — nothing
1071
+ # was moved.
1072
+ logger.error("Mapping error for session %s: %s", session_id, exc)
1073
+ return JSONResponse(
1074
+ {"error": "mapping_error", "detail": str(exc)},
1075
+ status_code=status.HTTP_409_CONFLICT,
1076
+ )
1077
+ except ConflictError as exc:
1078
+ return JSONResponse(
1079
+ {"error": "conflict_files", "conflicting_files": exc.conflicting_files},
1080
+ status_code=status.HTTP_409_CONFLICT,
1081
+ )
1082
+ except FileNotFoundError as exc:
1083
+ return JSONResponse({"error": str(exc)}, status_code=404)
1084
+
1085
+ return {"published": published}
1086
+
1087
+ @router.post("/mfup/sweep")
1088
+ async def sweep_endpoint(request: Request):
1089
+ """Manually trigger a sweep — queries Redis, no iterdir."""
1090
+ denied = _admin_denied(request)
1091
+ if denied:
1092
+ return denied
1093
+ registry = engine._require_registry()
1094
+ idx = engine._require_index()
1095
+ expired_ids = await idx.get_expired()
1096
+ removed = []
1097
+ for sid in expired_ids:
1098
+ await registry.remove(sid)
1099
+ meta = await idx.get_meta(sid)
1100
+ if meta and meta.staging_dir:
1101
+ sd = Path(meta.staging_dir)
1102
+ else:
1103
+ sd = staging_dir(registry.base_dir, sid, cfg.staging_prefix)
1104
+ if sd.exists():
1105
+ shutil.rmtree(str(sd), ignore_errors=True)
1106
+ await idx.remove(sid)
1107
+ removed.append(sid)
1108
+ return {"removed": removed}
1109
+
1110
+ # -------------------------------------------------------------------
1111
+ # Health check
1112
+ # -------------------------------------------------------------------
1113
+
1114
+ @router.get("/health")
1115
+ async def health():
1116
+ return {"status": "ok", "protocol": PROTOCOL_VERSION, "crc32c": CRC32C_IMPL}
1117
+
1118
+ return router