deepanalysts 0.9.2__tar.gz → 0.9.3__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 (69) hide show
  1. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/PKG-INFO +1 -1
  2. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/backends/e2b.py +129 -33
  3. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts.egg-info/PKG-INFO +1 -1
  4. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts.egg-info/SOURCES.txt +1 -0
  5. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/pyproject.toml +1 -1
  6. deepanalysts-0.9.3/tests/test_e2b_batch_upload.py +268 -0
  7. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_e2b_integration.py +65 -0
  8. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/README.md +0 -0
  9. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/__init__.py +0 -0
  10. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/_messages_reducer.py +0 -0
  11. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/backends/__init__.py +0 -0
  12. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/backends/basement.py +0 -0
  13. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/backends/composite.py +0 -0
  14. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/backends/filesystem.py +0 -0
  15. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/backends/protocol.py +0 -0
  16. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/backends/sandbox.py +0 -0
  17. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/backends/state.py +0 -0
  18. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/backends/store.py +0 -0
  19. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/backends/supabase_storage.py +0 -0
  20. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/backends/utils.py +0 -0
  21. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/clients/__init__.py +0 -0
  22. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/clients/basement.py +0 -0
  23. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/middleware/__init__.py +0 -0
  24. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/middleware/_state.py +0 -0
  25. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/middleware/_utils.py +0 -0
  26. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/middleware/filesystem.py +0 -0
  27. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/middleware/memory.py +0 -0
  28. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/middleware/patch_tool_calls.py +0 -0
  29. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/middleware/skills.py +0 -0
  30. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/middleware/subagents.py +0 -0
  31. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/middleware/summarization.py +0 -0
  32. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/middleware/tool_errors.py +0 -0
  33. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/state.py +0 -0
  34. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/streaming.py +0 -0
  35. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/utils/__init__.py +0 -0
  36. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts/utils/retry.py +0 -0
  37. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts.egg-info/dependency_links.txt +0 -0
  38. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts.egg-info/requires.txt +0 -0
  39. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/deepanalysts.egg-info/top_level.txt +0 -0
  40. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/setup.cfg +0 -0
  41. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_basement.py +0 -0
  42. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_composite_backend.py +0 -0
  43. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_e2b_backend.py +0 -0
  44. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_e2b_watch.py +0 -0
  45. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_e2b_watch_live.py +0 -0
  46. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_filesystem_middleware.py +0 -0
  47. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_messages_reducer.py +0 -0
  48. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_prompt_sections.py +0 -0
  49. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_protocol_bridge.py +0 -0
  50. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_regression_files_delta.py +0 -0
  51. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_regression_fs_backport.py +0 -0
  52. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_regression_middleware_tools.py +0 -0
  53. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_regression_new_api.py +0 -0
  54. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_regression_prompt_contracts.py +0 -0
  55. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_regression_storage_stale_reads.py +0 -0
  56. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_regression_subagents.py +0 -0
  57. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_regression_tier1_backports.py +0 -0
  58. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_sandbox_backend.py +0 -0
  59. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_sandbox_hardening.py +0 -0
  60. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_sandbox_hardening_integration.py +0 -0
  61. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_sandbox_scan_bounds.py +0 -0
  62. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_skills_middleware.py +0 -0
  63. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_store_backend.py +0 -0
  64. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_streaming_transformer.py +0 -0
  65. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_summarization_middleware.py +0 -0
  66. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_supabase_storage_backend.py +0 -0
  67. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_supabase_storage_integration.py +0 -0
  68. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_tool_errors.py +0 -0
  69. {deepanalysts-0.9.2 → deepanalysts-0.9.3}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepanalysts
3
- Version: 0.9.2
3
+ Version: 0.9.3
4
4
  Summary: LangChain/LangGraph middleware for building AI agents with memory, skills, and filesystem support
5
5
  Author-email: Ganchuluun Narantsatsralt <tsatsralt@swifttech.cloud>
6
6
  License: MIT
@@ -25,6 +25,7 @@ from __future__ import annotations
25
25
 
26
26
  import inspect
27
27
  import logging
28
+ import threading
28
29
  import time
29
30
  from collections.abc import Callable, Sequence
30
31
  from dataclasses import dataclass
@@ -71,6 +72,15 @@ Verified against e2b 2.31.0 (`e2b/envd/rpc.py`, `e2b/exceptions.py`)."""
71
72
  _DEFAULT_WORKDIR = "/workspace"
72
73
  """Where the workspace bridge mounts agent files inside the sandbox."""
73
74
 
75
+ _UPLOAD_BATCH_MAX_FILES = 100
76
+ """Files per `files.write_files` multipart request in `upload_files`."""
77
+
78
+ _UPLOAD_BATCH_MAX_BYTES = 8 * 1024 * 1024
79
+ """Payload ceiling per `files.write_files` request. The SDK buffers a
80
+ multipart body in memory and applies one request timeout to it, so a chunk
81
+ stays small enough to finish well inside that timeout. A single file larger
82
+ than this still goes alone in its own request."""
83
+
74
84
 
75
85
  _EGRESS_FLAG = "allow_internet_access"
76
86
 
@@ -114,6 +124,23 @@ def _ctor_accepts_egress_flag(ctor: Callable[..., Any]) -> bool:
114
124
  return False
115
125
 
116
126
 
127
+ def _upload_chunks(files: Sequence[tuple[str, bytes]]) -> list[list[tuple[str, bytes]]]:
128
+ """Split uploads into request-sized chunks (by count and by bytes), in order."""
129
+ chunks: list[list[tuple[str, bytes]]] = []
130
+ current: list[tuple[str, bytes]] = []
131
+ current_bytes = 0
132
+ for path, content in files:
133
+ size = len(content)
134
+ if current and (len(current) >= _UPLOAD_BATCH_MAX_FILES or current_bytes + size > _UPLOAD_BATCH_MAX_BYTES):
135
+ chunks.append(current)
136
+ current, current_bytes = [], 0
137
+ current.append((path, content))
138
+ current_bytes += size
139
+ if current:
140
+ chunks.append(current)
141
+ return chunks
142
+
143
+
117
144
  def _classify_file_error(error: Exception) -> FileOperationError:
118
145
  """Map an E2B SDK exception to a structured per-file error code.
119
146
 
@@ -225,6 +252,12 @@ class E2BSandboxBackend(BaseSandbox):
225
252
  self._sandbox_class = sandbox_class
226
253
  self._closed = False
227
254
  self._expired = False
255
+ # Serializes lazy creation: two threads hitting an unstarted session
256
+ # (a host's background upload + a tool call) must not boot two VMs.
257
+ self._create_lock = threading.Lock()
258
+ # Makes "publish the new VM" and close() atomic with respect to each
259
+ # other, so a boot that finishes after close() is killed, not leaked.
260
+ self._state_lock = threading.Lock()
228
261
  # glob/grep scans are clamped to these trees (BaseSandbox scan safety);
229
262
  # a full-VM walk from "/" would run until the execute timeout kills it.
230
263
  # Default: the workdir. Pass additional read-only trees (e.g. baked
@@ -388,28 +421,31 @@ class E2BSandboxBackend(BaseSandbox):
388
421
  if self._expired:
389
422
  raise E2BSandboxError(self._expired_output())
390
423
  if self._sandbox is None:
391
- self._sandbox = self._create_sandbox()
392
- self._ttl_refreshed_at = time.monotonic()
393
- # Materialize the workdir so cwd exists from command one. Root-
394
- # level dirs like /workspace need root on the base template
395
- # (commands run as `user` — live-verified: plain mkdir gets
396
- # "Permission denied" and every later cwd= call then fails);
397
- # chown hands the dir back to the command user.
424
+ self._boot_sandbox()
425
+ sandbox = self._sandbox
426
+ if sandbox is None: # closed by another thread right after the boot
427
+ raise E2BSandboxError("This E2B sandbox session is closed.")
428
+ self._maybe_refresh_ttl()
429
+ return sandbox
430
+
431
+ def _bootstrap_workdir(self, sandbox: Any) -> None:
432
+ """Materialize the workdir so cwd exists from command one. Root-level
433
+ dirs like /workspace need root on the base template (commands run as
434
+ `user` — live-verified: plain mkdir gets "Permission denied" and every
435
+ later cwd= call then fails); chown hands the dir back to the user."""
436
+ try:
437
+ sandbox.commands.run(
438
+ f"mkdir -p {self._workdir} && chown user:user {self._workdir}",
439
+ user="root",
440
+ )
441
+ except TypeError:
442
+ # SDK without the `user` kwarg — best effort as default user.
398
443
  try:
399
- self._sandbox.commands.run(
400
- f"mkdir -p {self._workdir} && chown user:user {self._workdir}",
401
- user="root",
402
- )
403
- except TypeError:
404
- # SDK without the `user` kwarg — best effort as default user.
405
- try:
406
- self._sandbox.commands.run(f"mkdir -p {self._workdir}")
407
- except Exception:
408
- logger.warning("e2b workdir bootstrap failed", exc_info=True)
444
+ sandbox.commands.run(f"mkdir -p {self._workdir}")
409
445
  except Exception:
410
446
  logger.warning("e2b workdir bootstrap failed", exc_info=True)
411
- self._maybe_refresh_ttl()
412
- return self._sandbox
447
+ except Exception:
448
+ logger.warning("e2b workdir bootstrap failed", exc_info=True)
413
449
 
414
450
  @property
415
451
  def started(self) -> bool:
@@ -417,19 +453,42 @@ class E2BSandboxBackend(BaseSandbox):
417
453
  return self._sandbox is not None and not self._closed
418
454
 
419
455
  def close(self) -> None:
420
- """Kill the microVM. Idempotent; safe when never started."""
421
- if self._sandbox is not None and not self._closed:
422
- try:
423
- self._sandbox.kill()
424
- logger.info(
425
- "e2b sandbox killed id=%s",
426
- getattr(self._sandbox, "sandbox_id", "?"),
427
- )
428
- except Exception:
429
- # the session_ttl reaps the VM server-side regardless.
430
- logger.warning("e2b sandbox kill failed", exc_info=True)
431
- self._closed = True
432
- self._sandbox = None
456
+ """Kill the microVM. Idempotent; safe when never started, and safe
457
+ while another thread is still booting it (that boot kills its own VM
458
+ when it sees the session closed)."""
459
+ with self._state_lock:
460
+ sandbox, self._sandbox = self._sandbox, None
461
+ self._closed = True
462
+ if sandbox is not None:
463
+ self._kill(sandbox)
464
+
465
+ @staticmethod
466
+ def _kill(sandbox: Any) -> None:
467
+ try:
468
+ sandbox.kill()
469
+ logger.info("e2b sandbox killed id=%s", getattr(sandbox, "sandbox_id", "?"))
470
+ except Exception:
471
+ # the session_ttl reaps the VM server-side regardless.
472
+ logger.warning("e2b sandbox kill failed", exc_info=True)
473
+
474
+ def _boot_sandbox(self) -> None:
475
+ """Create the session's VM once, even under concurrent first use."""
476
+ with self._create_lock:
477
+ if self._sandbox is not None:
478
+ return # another thread booted it while we waited
479
+ if self._closed:
480
+ raise E2BSandboxError("This E2B sandbox session is closed.")
481
+ sandbox = self._create_sandbox()
482
+ if not self._closed:
483
+ self._bootstrap_workdir(sandbox)
484
+ with self._state_lock:
485
+ closed_meanwhile = self._closed
486
+ if not closed_meanwhile:
487
+ self._sandbox = sandbox
488
+ self._ttl_refreshed_at = time.monotonic()
489
+ if closed_meanwhile:
490
+ self._kill(sandbox)
491
+ raise E2BSandboxError("This E2B sandbox session is closed.")
433
492
 
434
493
  # ------------------------------------------------------------------
435
494
  # SandboxBackendProtocol
@@ -507,7 +566,18 @@ class E2BSandboxBackend(BaseSandbox):
507
566
  return _classify_file_error(exc)
508
567
 
509
568
  def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
510
- """Write files into the sandbox filesystem (workspace bridge down-sync)."""
569
+ """Write files into the sandbox filesystem (workspace bridge down-sync).
570
+
571
+ Files go up in chunks through the SDK's multipart `files.write_files`
572
+ (one HTTP request per chunk, not per file). Chunks are sent one after
573
+ another, never concurrently: concurrent writes against one live
574
+ microVM failed per file in prod (2026-09-15). `write_files` is
575
+ all-or-nothing, so a chunk that fails is retried file by file to keep
576
+ the per-file partial-success contract, unless the sandbox itself is
577
+ gone, which latches and fails the rest fast.
578
+ """
579
+ if not files:
580
+ return []
511
581
  try:
512
582
  sbx = self._ensure_sandbox()
513
583
  except E2BSandboxError:
@@ -515,6 +585,32 @@ class E2BSandboxBackend(BaseSandbox):
515
585
  code: FileOperationError = "sandbox_unavailable" if self._expired else "permission_denied"
516
586
  return [FileUploadResponse(path=p, error=code) for p, _ in files]
517
587
 
588
+ write_files = getattr(sbx.files, "write_files", None)
589
+ if not callable(write_files):
590
+ return self._upload_one_by_one(sbx, files)
591
+
592
+ responses: list[FileUploadResponse] = []
593
+ for chunk in _upload_chunks(files):
594
+ if self._expired:
595
+ responses.extend(FileUploadResponse(path=p, error="sandbox_unavailable") for p, _ in chunk)
596
+ continue
597
+ try:
598
+ write_files([{"path": path, "data": content} for path, content in chunk])
599
+ responses.extend(FileUploadResponse(path=p) for p, _ in chunk)
600
+ except Exception as exc:
601
+ if self._is_sandbox_gone_error(exc) and self._confirm_gone(exc):
602
+ self._mark_expired()
603
+ responses.extend(FileUploadResponse(path=p, error="sandbox_unavailable") for p, _ in chunk)
604
+ continue
605
+ logger.warning(
606
+ "e2b batched upload of %d file(s) failed; retrying one by one: %s",
607
+ len(chunk),
608
+ exc,
609
+ )
610
+ responses.extend(self._upload_one_by_one(sbx, chunk))
611
+ return responses
612
+
613
+ def _upload_one_by_one(self, sbx: Any, files: Sequence[tuple[str, bytes]]) -> list[FileUploadResponse]:
518
614
  responses: list[FileUploadResponse] = []
519
615
  for path, content in files:
520
616
  if self._expired:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepanalysts
3
- Version: 0.9.2
3
+ Version: 0.9.3
4
4
  Summary: LangChain/LangGraph middleware for building AI agents with memory, skills, and filesystem support
5
5
  Author-email: Ganchuluun Narantsatsralt <tsatsralt@swifttech.cloud>
6
6
  License: MIT
@@ -37,6 +37,7 @@ deepanalysts/utils/retry.py
37
37
  tests/test_basement.py
38
38
  tests/test_composite_backend.py
39
39
  tests/test_e2b_backend.py
40
+ tests/test_e2b_batch_upload.py
40
41
  tests/test_e2b_integration.py
41
42
  tests/test_e2b_watch.py
42
43
  tests/test_e2b_watch_live.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepanalysts"
3
- version = "0.9.2"
3
+ version = "0.9.3"
4
4
  description = "LangChain/LangGraph middleware for building AI agents with memory, skills, and filesystem support"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -0,0 +1,268 @@
1
+ """Regression tests: E2BSandboxBackend.upload_files batches its writes.
2
+
3
+ Prod 2026-10-09: an agent run's workspace checkout uploaded 548 files with
4
+ one `sbx.files.write` HTTP request EACH, sequentially (~0.31 s per file), and
5
+ the run sat ~170 s before its first model call. The SDK's
6
+ `files.write_files([...])` sends many files in one multipart request; these
7
+ tests pin that upload_files uses it in bounded chunks while keeping the
8
+ per-file partial-success contract callers rely on.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from deepanalysts.backends.e2b import (
14
+ _UPLOAD_BATCH_MAX_BYTES,
15
+ _UPLOAD_BATCH_MAX_FILES,
16
+ E2BSandboxBackend,
17
+ )
18
+
19
+
20
+ class TimeoutException(Exception):
21
+ """Name-matched like e2b's TimeoutException."""
22
+
23
+
24
+ _DEAD_SANDBOX_MSG = (
25
+ "sandbox is probably not running anymore: This error is likely due to sandbox "
26
+ "timeout. You can modify the sandbox timeout by passing 'timeout' when starting "
27
+ "the sandbox or calling '.set_timeout' on the sandbox with the desired timeout."
28
+ )
29
+
30
+
31
+ class _Commands:
32
+ def run(self, command, timeout=None, cwd=None, user=None):
33
+ class _R:
34
+ stdout, stderr, exit_code = "", "", 0
35
+
36
+ return _R()
37
+
38
+
39
+ class BatchingFakeSandbox:
40
+ """SDK-shaped fake exposing BOTH `files.write` and `files.write_files`.
41
+
42
+ Like the real SDK, `write_files` is all-or-nothing: if any entry would
43
+ fail, the whole request raises and nothing from that request lands.
44
+ Every call is recorded so tests can count HTTP requests.
45
+ """
46
+
47
+ def __init__(self):
48
+ self.sandbox_id = "fake-batch"
49
+ self.store: dict[str, bytes] = {}
50
+ self.write_errors: dict[str, Exception] = {}
51
+ self.batch_calls: list[list[str]] = []
52
+ self.single_calls: list[str] = []
53
+ self.alive = True
54
+ self.commands = _Commands()
55
+ self.files = self._Files(self)
56
+
57
+ class _Files:
58
+ def __init__(self, outer):
59
+ self._outer = outer
60
+
61
+ def write(self, path, data):
62
+ self._outer.single_calls.append(path)
63
+ if path in self._outer.write_errors:
64
+ raise self._outer.write_errors[path]
65
+ self._outer.store[path] = data
66
+
67
+ def write_files(self, files):
68
+ paths = [entry["path"] for entry in files]
69
+ self._outer.batch_calls.append(paths)
70
+ for path in paths:
71
+ if path in self._outer.write_errors:
72
+ raise self._outer.write_errors[path]
73
+ for entry in files:
74
+ self._outer.store[entry["path"]] = entry["data"]
75
+ return [{"path": p} for p in paths]
76
+
77
+ def read(self, path, format="text"):
78
+ if path not in self._outer.store:
79
+ raise FileNotFoundError(f"file not found: {path}")
80
+ return self._outer.store[path]
81
+
82
+ def is_running(self, request_timeout=None):
83
+ return self.alive
84
+
85
+ def kill(self):
86
+ pass
87
+
88
+
89
+ def _backend() -> tuple[E2BSandboxBackend, BatchingFakeSandbox]:
90
+ fake = BatchingFakeSandbox()
91
+ return E2BSandboxBackend(sandbox=fake), fake
92
+
93
+
94
+ def _files(n: int, size: int = 10) -> list[tuple[str, bytes]]:
95
+ return [(f"/workspace/shared/f{i:04d}.md", bytes([65 + i % 26]) * size) for i in range(n)]
96
+
97
+
98
+ class TestBatchedUpload:
99
+ def test_many_files_use_few_requests_not_one_per_file(self):
100
+ """The prod repro: 548 files must not cost 548 sequential requests."""
101
+ backend, fake = _backend()
102
+ files = _files(548)
103
+
104
+ responses = backend.upload_files(files)
105
+
106
+ assert [r.error for r in responses] == [None] * 548
107
+ assert fake.single_calls == []
108
+ expected_requests = -(-548 // _UPLOAD_BATCH_MAX_FILES) # ceil
109
+ assert len(fake.batch_calls) == expected_requests
110
+ assert all(fake.store[p] == c for p, c in files)
111
+
112
+ def test_chunks_are_bounded_by_bytes(self):
113
+ backend, fake = _backend()
114
+ big = _UPLOAD_BATCH_MAX_BYTES // 2 + 1 # two of these never share a request
115
+ files = _files(3, size=big)
116
+
117
+ responses = backend.upload_files(files)
118
+
119
+ assert [r.error for r in responses] == [None, None, None]
120
+ assert [len(call) for call in fake.batch_calls] == [1, 1, 1]
121
+
122
+ def test_single_oversized_file_still_uploads_alone(self):
123
+ backend, fake = _backend()
124
+ files = _files(1, size=_UPLOAD_BATCH_MAX_BYTES + 5)
125
+
126
+ responses = backend.upload_files(files)
127
+
128
+ assert responses[0].error is None
129
+ assert fake.batch_calls == [[files[0][0]]]
130
+
131
+ def test_responses_keep_input_order(self):
132
+ backend, _fake = _backend()
133
+ files = _files(_UPLOAD_BATCH_MAX_FILES + 7)
134
+
135
+ responses = backend.upload_files(files)
136
+
137
+ assert [r.path for r in responses] == [p for p, _ in files]
138
+
139
+ def test_empty_upload_makes_no_request(self):
140
+ backend, fake = _backend()
141
+ assert backend.upload_files([]) == []
142
+ assert fake.batch_calls == [] and fake.single_calls == []
143
+
144
+
145
+ class TestBatchFailureKeepsPerFileContract:
146
+ def test_one_bad_path_fails_only_that_file(self):
147
+ """write_files is all-or-nothing; a failed chunk is retried per file
148
+ so one EACCES path doesn't fail its 99 neighbours."""
149
+ backend, fake = _backend()
150
+ files = _files(5)
151
+ bad = files[2][0]
152
+ fake.write_errors[bad] = PermissionError("permission denied")
153
+
154
+ responses = backend.upload_files(files)
155
+
156
+ assert [r.error for r in responses] == [None, None, "permission_denied", None, None]
157
+ assert bad not in fake.store
158
+ assert all(fake.store[p] == c for p, c in files if p != bad)
159
+
160
+ def test_failure_in_one_chunk_does_not_degrade_other_chunks(self):
161
+ backend, fake = _backend()
162
+ files = _files(_UPLOAD_BATCH_MAX_FILES * 2)
163
+ bad = files[_UPLOAD_BATCH_MAX_FILES + 3][0] # second chunk
164
+ fake.write_errors[bad] = PermissionError("permission denied")
165
+
166
+ backend.upload_files(files)
167
+
168
+ # chunk 1 and chunk 2 batched; only chunk 2 falls back per file
169
+ assert len(fake.batch_calls) == 2
170
+ assert len(fake.single_calls) == _UPLOAD_BATCH_MAX_FILES
171
+ assert set(fake.single_calls) == {p for p, _ in files[_UPLOAD_BATCH_MAX_FILES:]}
172
+
173
+ def test_dead_sandbox_latches_without_per_file_retries(self):
174
+ backend, fake = _backend()
175
+ fake.alive = False
176
+ files = _files(_UPLOAD_BATCH_MAX_FILES + 3)
177
+ fake.write_errors[files[0][0]] = TimeoutException(_DEAD_SANDBOX_MSG)
178
+
179
+ responses = backend.upload_files(files)
180
+
181
+ assert {r.error for r in responses} == {"sandbox_unavailable"}
182
+ assert backend.expired is True
183
+ assert len(fake.batch_calls) == 1 # later chunks short-circuited
184
+ assert fake.single_calls == [] # no 100 doomed per-file retries
185
+
186
+
187
+ class TestSdkWithoutBatchWrite:
188
+ def test_falls_back_to_per_file_writes(self):
189
+ """Older SDKs / fakes exposing only files.write keep working."""
190
+ backend, fake = _backend()
191
+
192
+ class _OnlyWrite:
193
+ def write(self, path, data):
194
+ fake.single_calls.append(path)
195
+ fake.store[path] = data
196
+
197
+ fake.files = _OnlyWrite()
198
+ files = _files(3)
199
+
200
+ responses = backend.upload_files(files)
201
+
202
+ assert [r.error for r in responses] == [None, None, None]
203
+ assert all(fake.store[p] == c for p, c in files)
204
+
205
+
206
+ class TestCloseDuringBoot:
207
+ """Regression (code review 2026-10-09): park now boots the VM in a
208
+ background preparation thread. If the run ends while that boot is still
209
+ in flight, close() runs first and finds no sandbox to kill; the boot then
210
+ completes and the microVM ran unowned until its TTL reaped it."""
211
+
212
+ def test_vm_finishing_boot_after_close_is_killed(self):
213
+ booted: list[BatchingFakeSandbox] = []
214
+ backend_ref: list[E2BSandboxBackend] = []
215
+
216
+ def factory():
217
+ vm = BatchingFakeSandbox()
218
+ vm.killed = False
219
+ vm.kill = lambda: setattr(vm, "killed", True)
220
+ booted.append(vm)
221
+ backend_ref[0].close() # the run ended while the VM was booting
222
+ return vm
223
+
224
+ backend = E2BSandboxBackend(sandbox_factory=factory)
225
+ backend_ref.append(backend)
226
+
227
+ responses = backend.upload_files([("/workspace/a.md", b"x")])
228
+
229
+ assert len(booted) == 1
230
+ assert booted[0].killed is True
231
+ assert responses[0].error is not None
232
+ assert backend.started is False
233
+
234
+ def test_concurrent_first_use_boots_one_vm(self):
235
+ import threading
236
+ import time
237
+
238
+ booted: list[BatchingFakeSandbox] = []
239
+
240
+ def factory():
241
+ time.sleep(0.05) # a boot takes a while; let the other thread arrive
242
+ vm = BatchingFakeSandbox()
243
+ booted.append(vm)
244
+ return vm
245
+
246
+ backend = E2BSandboxBackend(sandbox_factory=factory)
247
+ barrier = threading.Barrier(4)
248
+
249
+ def use():
250
+ barrier.wait()
251
+ backend.upload_files([(f"/workspace/{threading.get_ident()}.md", b"x")])
252
+
253
+ threads = [threading.Thread(target=use) for _ in range(4)]
254
+ for t in threads:
255
+ t.start()
256
+ for t in threads:
257
+ t.join()
258
+
259
+ assert len(booted) == 1
260
+
261
+ def test_close_after_boot_kills_once(self):
262
+ backend, fake = _backend()
263
+ kills: list[bool] = []
264
+ fake.kill = lambda: kills.append(True)
265
+ backend.upload_files([("/workspace/a.md", b"x")])
266
+ backend.close()
267
+ backend.close()
268
+ assert kills == [True]
@@ -59,6 +59,43 @@ def test_files_roundtrip(backend):
59
59
  assert missing[0].error == "file_not_found"
60
60
 
61
61
 
62
+ def test_batched_upload_many_nested_files(backend):
63
+ """Regression (prod 2026-10-09): a workspace checkout of hundreds of files
64
+ goes up in a few multipart requests and every byte lands, nested dirs
65
+ included (write_files creates parents)."""
66
+ import time
67
+
68
+ from deepanalysts.backends.e2b import _UPLOAD_BATCH_MAX_FILES
69
+
70
+ files = [(f"/workspace/batch/d{i % 7}/sub/f{i:03d}.md", f"file {i}\n".encode()) for i in range(250)]
71
+ started = time.monotonic()
72
+ up = backend.upload_files(files)
73
+ elapsed = time.monotonic() - started
74
+
75
+ assert [r.error for r in up] == [None] * len(files)
76
+ down = backend.download_files([p for p, _ in files[:: _UPLOAD_BATCH_MAX_FILES // 2]])
77
+ assert [d.content for d in down] == [c for _, c in files[:: _UPLOAD_BATCH_MAX_FILES // 2]]
78
+ count = backend.execute("find /workspace/batch -type f | wc -l")
79
+ assert count.output.strip() == "250"
80
+ # One request per file took ~0.3 s each in prod (75 s here); batched is a few requests.
81
+ assert elapsed < 20, f"250-file upload took {elapsed:.1f}s — batching regressed?"
82
+
83
+
84
+ def test_batched_upload_failure_isolated_to_bad_path(backend):
85
+ """write_files is all-or-nothing; one root-owned path must fail alone."""
86
+ files = [
87
+ ("/workspace/iso/ok1.md", b"one"),
88
+ ("/proc/forbidden/x.md", b"nope"),
89
+ ("/workspace/iso/ok2.md", b"two"),
90
+ ]
91
+ up = backend.upload_files(files)
92
+ assert up[0].error is None
93
+ assert up[1].error is not None
94
+ assert up[2].error is None
95
+ down = backend.download_files(["/workspace/iso/ok1.md", "/workspace/iso/ok2.md"])
96
+ assert [d.content for d in down] == [b"one", b"two"]
97
+
98
+
62
99
  def test_base_sandbox_file_helpers(backend):
63
100
  """ls/read derive from execute() — prove they work inside the microVM."""
64
101
  backend.upload_files([("/workspace/notes/hello.md", b"# hi\nline2\n")])
@@ -162,3 +199,31 @@ def test_reaped_sandbox_classified_expired_and_fails_fast():
162
199
  assert down[0].error == "sandbox_unavailable"
163
200
  finally:
164
201
  b.close()
202
+
203
+
204
+ def test_close_during_boot_kills_the_late_vm():
205
+ """Regression (code review 2026-10-09): a host closing the session while
206
+ a background thread is still booting it must not leave a running VM."""
207
+ import threading
208
+
209
+ from e2b import Sandbox
210
+
211
+ from deepanalysts.backends.e2b import E2BSandboxBackend
212
+
213
+ booted: list = []
214
+ backend_ref: list = []
215
+
216
+ def factory():
217
+ vm = Sandbox.create(api_key=os.environ["E2B_API_KEY"], timeout=120)
218
+ booted.append(vm)
219
+ backend_ref[0].close() # the run ended while this VM was booting
220
+ return vm
221
+
222
+ backend = E2BSandboxBackend(sandbox_factory=factory)
223
+ backend_ref.append(backend)
224
+ worker = threading.Thread(target=backend.upload_files, args=([("/workspace/a.md", b"x")],))
225
+ worker.start()
226
+ worker.join(timeout=120)
227
+
228
+ assert len(booted) == 1
229
+ assert booted[0].is_running() is False
File without changes
File without changes