vmux-cli 0.5.2__tar.gz → 0.5.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vmux-cli
3
- Version: 0.5.2
3
+ Version: 0.5.4
4
4
  Summary: Run anything in the cloud. Replace uv run with vmux run.
5
5
  Project-URL: Homepage, https://vmux.sdan.io
6
6
  Project-URL: Documentation, https://vmux.sdan.io
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "vmux-cli"
7
- version = "0.5.2"
7
+ version = "0.5.4"
8
8
  description = "Run anything in the cloud. Replace uv run with vmux run."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,374 @@
1
+ #!/usr/bin/env python3
2
+ """Benchmark different bundle delivery methods to Cloudflare containers.
3
+
4
+ Tests:
5
+ 1. writeFile limit verification (confirm actual limit - NOT 32MB as documented!)
6
+ 2. R2 upload via Worker (100MB limit due to CF request body size)
7
+ 3. R2 presigned URL upload (up to 5GB, bypasses Worker)
8
+ 4. mountBucket (FUSE-based, requires production deploy)
9
+
10
+ FINDINGS (Dec 2024):
11
+ - writeFile works up to ~90MB (base64 encoded)
12
+ - R2 via Worker: 100MB CF request body limit
13
+ - R2 presigned: Up to 5GB direct upload
14
+ - mountBucket: Streaming, requires deploy
15
+
16
+ Run with: uv run python -m tests.benchmark_bundle_delivery
17
+ """
18
+
19
+ import base64
20
+ import io
21
+ import json
22
+ import os
23
+ import sys
24
+ import time
25
+ import zipfile
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+
29
+ # Add parent to path for vmux imports
30
+ sys.path.insert(0, str(Path(__file__).parent.parent))
31
+
32
+ from vmux.config import load_config
33
+
34
+ import httpx
35
+
36
+ DEBUG = os.environ.get("VMUX_DEBUG", "").lower() in ("1", "true", "yes")
37
+
38
+
39
+ @dataclass
40
+ class BenchmarkResult:
41
+ """Result from a single benchmark run."""
42
+
43
+ method: str
44
+ bundle_size_mb: float
45
+ success: bool
46
+ duration_ms: float
47
+ error: str | None = None
48
+ notes: str = ""
49
+
50
+
51
+ @dataclass
52
+ class BenchmarkSuite:
53
+ """Collection of benchmark results."""
54
+
55
+ results: list[BenchmarkResult] = field(default_factory=list)
56
+
57
+ def add(self, result: BenchmarkResult) -> None:
58
+ self.results.append(result)
59
+ status = "✓" if result.success else "✗"
60
+ print(
61
+ f" {status} {result.method} @ {result.bundle_size_mb:.1f}MB: "
62
+ f"{result.duration_ms:.0f}ms"
63
+ + (f" - {result.error}" if result.error else "")
64
+ + (f" ({result.notes})" if result.notes else "")
65
+ )
66
+
67
+ def print_summary(self) -> None:
68
+ print("\n" + "=" * 60)
69
+ print("BENCHMARK SUMMARY")
70
+ print("=" * 60)
71
+
72
+ # Group by method
73
+ by_method: dict[str, list[BenchmarkResult]] = {}
74
+ for r in self.results:
75
+ by_method.setdefault(r.method, []).append(r)
76
+
77
+ for method, results in by_method.items():
78
+ successes = [r for r in results if r.success]
79
+ failures = [r for r in results if not r.success]
80
+
81
+ print(f"\n{method}:")
82
+ if successes:
83
+ sizes = [r.bundle_size_mb for r in successes]
84
+ times = [r.duration_ms for r in successes]
85
+ print(f" ✓ Success: {len(successes)} runs")
86
+ print(f" Size range: {min(sizes):.1f} - {max(sizes):.1f} MB")
87
+ print(f" Time range: {min(times):.0f} - {max(times):.0f} ms")
88
+ print(f" Avg time: {sum(times)/len(times):.0f} ms")
89
+ if failures:
90
+ print(f" ✗ Failures: {len(failures)}")
91
+ for r in failures:
92
+ print(f" @ {r.bundle_size_mb:.1f}MB: {r.error}")
93
+
94
+
95
+ def create_test_bundle(size_mb: float) -> bytes:
96
+ """Create a test zip bundle of approximately the given size."""
97
+ target_bytes = int(size_mb * 1024 * 1024)
98
+
99
+ # Create a zip file in memory
100
+ buf = io.BytesIO()
101
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf:
102
+ # Add a simple test script
103
+ zf.writestr(
104
+ "test.py",
105
+ """#!/usr/bin/env python3
106
+ print("Hello from test bundle!")
107
+ print(f"Bundle delivered successfully")
108
+ """,
109
+ )
110
+
111
+ # Add filler data to reach target size
112
+ # We use compressible but non-trivial data
113
+ filler_chunk = b"X" * (1024 * 1024) # 1MB chunks
114
+ chunks_needed = (target_bytes - buf.tell()) // len(filler_chunk)
115
+
116
+ for i in range(max(0, chunks_needed)):
117
+ zf.writestr(f"filler/data_{i:04d}.bin", filler_chunk)
118
+
119
+ # Fine-tune to get close to target
120
+ remaining = target_bytes - buf.tell()
121
+ if remaining > 100:
122
+ zf.writestr("filler/final.bin", b"Y" * max(0, remaining - 200))
123
+
124
+ return buf.getvalue()
125
+
126
+
127
+ class BenchmarkClient:
128
+ """Client for running benchmarks against vmux worker."""
129
+
130
+ def __init__(self) -> None:
131
+ self.config = load_config()
132
+ self._client = httpx.Client(
133
+ base_url=self.config.api_url,
134
+ timeout=httpx.Timeout(600.0, connect=60.0),
135
+ )
136
+
137
+ def _headers(self) -> dict[str, str]:
138
+ headers = {"Content-Type": "application/json"}
139
+ if self.config.auth_token:
140
+ headers["Authorization"] = f"Bearer {self.config.auth_token}"
141
+ return headers
142
+
143
+ def close(self) -> None:
144
+ self._client.close()
145
+
146
+ def test_inline_writefile(self, bundle_bytes: bytes) -> tuple[bool, float, str | None]:
147
+ """Test inline writeFile delivery (base64 in JSON payload)."""
148
+ t0 = time.time()
149
+
150
+ try:
151
+ payload = {
152
+ "command": "echo 'inline test' && ls -la /workspace",
153
+ "bundle": base64.b64encode(bundle_bytes).decode(),
154
+ "env_vars": {},
155
+ "editables": [],
156
+ "ports": [],
157
+ }
158
+
159
+ # Stream response to get timing
160
+ job_id = None
161
+ with self._client.stream(
162
+ "POST",
163
+ "/run",
164
+ json=payload,
165
+ headers={**self._headers(), "Accept": "text/event-stream"},
166
+ timeout=300.0,
167
+ ) as response:
168
+ if response.status_code >= 400:
169
+ return False, (time.time() - t0) * 1000, f"HTTP {response.status_code}"
170
+
171
+ for line in response.iter_lines():
172
+ if line.startswith("data: "):
173
+ data = line[6:]
174
+ if data == "[DONE]":
175
+ break
176
+ try:
177
+ event = json.loads(data)
178
+ if "job_id" in event:
179
+ job_id = event["job_id"]
180
+ if event.get("status") == "running":
181
+ # Success - bundle was delivered and extracted
182
+ break
183
+ if "error" in event:
184
+ return False, (time.time() - t0) * 1000, event["error"]
185
+ except json.JSONDecodeError:
186
+ pass
187
+
188
+ duration_ms = (time.time() - t0) * 1000
189
+
190
+ # Cleanup: stop the job
191
+ if job_id:
192
+ try:
193
+ self._client.delete(f"/jobs/{job_id}", headers=self._headers())
194
+ except Exception:
195
+ pass
196
+
197
+ return True, duration_ms, None
198
+
199
+ except httpx.HTTPStatusError as e:
200
+ return False, (time.time() - t0) * 1000, f"HTTP {e.response.status_code}: {e.response.text[:200]}"
201
+ except Exception as e:
202
+ return False, (time.time() - t0) * 1000, str(e)
203
+
204
+ def test_r2_delivery(self, bundle_bytes: bytes) -> tuple[bool, float, str | None]:
205
+ """Test R2 upload + curl delivery."""
206
+ t0 = time.time()
207
+
208
+ try:
209
+ # Step 1: Upload to R2
210
+ upload_response = self._client.post(
211
+ "/bundles/upload",
212
+ content=bundle_bytes,
213
+ headers={
214
+ **self._headers(),
215
+ "Content-Type": "application/octet-stream",
216
+ },
217
+ timeout=300.0,
218
+ )
219
+ upload_response.raise_for_status()
220
+ bundle_id = upload_response.json()["bundle_id"]
221
+
222
+ upload_time = time.time() - t0
223
+
224
+ # Step 2: Run with bundle_id
225
+ payload = {
226
+ "command": "echo 'r2 test' && ls -la /workspace",
227
+ "bundle_id": bundle_id,
228
+ "env_vars": {},
229
+ "editables": [],
230
+ "ports": [],
231
+ }
232
+
233
+ job_id = None
234
+ with self._client.stream(
235
+ "POST",
236
+ "/run",
237
+ json=payload,
238
+ headers={**self._headers(), "Accept": "text/event-stream"},
239
+ timeout=300.0,
240
+ ) as response:
241
+ if response.status_code >= 400:
242
+ return False, (time.time() - t0) * 1000, f"HTTP {response.status_code}"
243
+
244
+ for line in response.iter_lines():
245
+ if line.startswith("data: "):
246
+ data = line[6:]
247
+ if data == "[DONE]":
248
+ break
249
+ try:
250
+ event = json.loads(data)
251
+ if "job_id" in event:
252
+ job_id = event["job_id"]
253
+ if event.get("status") == "running":
254
+ break
255
+ if "error" in event:
256
+ return False, (time.time() - t0) * 1000, event["error"]
257
+ except json.JSONDecodeError:
258
+ pass
259
+
260
+ duration_ms = (time.time() - t0) * 1000
261
+
262
+ # Cleanup
263
+ if job_id:
264
+ try:
265
+ self._client.delete(f"/jobs/{job_id}", headers=self._headers())
266
+ except Exception:
267
+ pass
268
+
269
+ return True, duration_ms, None
270
+
271
+ except httpx.HTTPStatusError as e:
272
+ return False, (time.time() - t0) * 1000, f"HTTP {e.response.status_code}: {e.response.text[:200]}"
273
+ except Exception as e:
274
+ return False, (time.time() - t0) * 1000, str(e)
275
+
276
+
277
+ def run_benchmarks() -> BenchmarkSuite:
278
+ """Run all benchmark tests."""
279
+ suite = BenchmarkSuite()
280
+ client = BenchmarkClient()
281
+
282
+ print("=" * 60)
283
+ print("VMUX BUNDLE DELIVERY BENCHMARK")
284
+ print("=" * 60)
285
+ print(f"API: {client.config.api_url}")
286
+ print()
287
+
288
+ # Test 1: Verify writeFile limit
289
+ print("\n--- TEST 1: writeFile (inline base64) limit verification ---")
290
+ print("Testing various sizes to find the RPC limit...\n")
291
+
292
+ # Test sizes around the expected 32MB limit
293
+ inline_sizes = [1, 5, 10, 20, 25, 30, 31, 32, 33, 35, 40]
294
+
295
+ for size_mb in inline_sizes:
296
+ print(f"Creating {size_mb}MB test bundle...")
297
+ bundle = create_test_bundle(size_mb)
298
+ actual_mb = len(bundle) / (1024 * 1024)
299
+ print(f" Actual size: {actual_mb:.2f}MB")
300
+
301
+ success, duration_ms, error = client.test_inline_writefile(bundle)
302
+ suite.add(
303
+ BenchmarkResult(
304
+ method="inline_writefile",
305
+ bundle_size_mb=actual_mb,
306
+ success=success,
307
+ duration_ms=duration_ms,
308
+ error=error,
309
+ )
310
+ )
311
+
312
+ # Stop if we hit the limit
313
+ if not success and "32" in str(error).lower() or "limit" in str(error).lower():
314
+ print(f"\n *** Found limit at ~{size_mb}MB ***\n")
315
+ break
316
+
317
+ # Test 2: R2 delivery for larger bundles
318
+ print("\n--- TEST 2: R2 upload + curl delivery ---")
319
+ print("Testing R2 path for bundles >= 32MB...\n")
320
+
321
+ r2_sizes = [10, 32, 50, 64, 80, 100]
322
+
323
+ for size_mb in r2_sizes:
324
+ print(f"Creating {size_mb}MB test bundle...")
325
+ bundle = create_test_bundle(size_mb)
326
+ actual_mb = len(bundle) / (1024 * 1024)
327
+ print(f" Actual size: {actual_mb:.2f}MB")
328
+
329
+ success, duration_ms, error = client.test_r2_delivery(bundle)
330
+ suite.add(
331
+ BenchmarkResult(
332
+ method="r2_curl",
333
+ bundle_size_mb=actual_mb,
334
+ success=success,
335
+ duration_ms=duration_ms,
336
+ error=error,
337
+ )
338
+ )
339
+
340
+ # Summary
341
+ suite.print_summary()
342
+
343
+ # Recommendations
344
+ print("\n" + "=" * 60)
345
+ print("RECOMMENDATIONS")
346
+ print("=" * 60)
347
+
348
+ inline_max = max(
349
+ (r.bundle_size_mb for r in suite.results if r.method == "inline_writefile" and r.success),
350
+ default=0,
351
+ )
352
+ r2_max = max(
353
+ (r.bundle_size_mb for r in suite.results if r.method == "r2_curl" and r.success),
354
+ default=0,
355
+ )
356
+
357
+ print(f"\n1. writeFile (inline): Works up to ~{inline_max:.0f}MB")
358
+ print(f" - Use for bundles < {inline_max:.0f}MB (fast, single request)")
359
+
360
+ print(f"\n2. R2 + curl: Works up to ~{r2_max:.0f}MB tested")
361
+ print(f" - Use for bundles >= {inline_max:.0f}MB (requires upload step)")
362
+
363
+ print("\n3. mountBucket (FUSE):")
364
+ print(" - NOT YET TESTED (requires production deploy)")
365
+ print(" - Potentially faster for very large bundles (>100MB)")
366
+ print(" - Files streamed on-demand, no full download needed")
367
+ print(" - Latency: Higher per-file access, lower initial setup")
368
+
369
+ client.close()
370
+ return suite
371
+
372
+
373
+ if __name__ == "__main__":
374
+ run_benchmarks()
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env python3
2
+ """Benchmark latency for different bundle delivery methods.
3
+
4
+ Compares:
5
+ 1. R2 via Worker (current: CLI → Worker → R2 → Container curl)
6
+ 2. Presigned URL (proposed: CLI → R2 direct → Container curl)
7
+ 3. mountBucket (FUSE streaming, production only)
8
+
9
+ Run with: uv run python tests/benchmark_latency.py
10
+ """
11
+
12
+ import io
13
+ import os
14
+ import sys
15
+ import time
16
+ import zipfile
17
+ from pathlib import Path
18
+
19
+ sys.path.insert(0, str(Path(__file__).parent.parent))
20
+
21
+ import httpx
22
+ from vmux.config import load_config
23
+
24
+
25
+ def create_test_bundle(size_mb: float) -> bytes:
26
+ """Create a test zip bundle of approximately the given size."""
27
+ target_bytes = int(size_mb * 1024 * 1024)
28
+ buf = io.BytesIO()
29
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf:
30
+ zf.writestr("test.py", 'print("Hello from benchmark!")\n')
31
+ filler_chunk = b"X" * (1024 * 1024)
32
+ chunks_needed = (target_bytes - buf.tell()) // len(filler_chunk)
33
+ for i in range(max(0, chunks_needed)):
34
+ zf.writestr(f"filler/data_{i:04d}.bin", filler_chunk)
35
+ return buf.getvalue()
36
+
37
+
38
+ def benchmark_r2_via_worker(client: httpx.Client, headers: dict, bundle_bytes: bytes) -> dict:
39
+ """Benchmark: CLI → Worker → R2 → Container curl."""
40
+ results = {}
41
+
42
+ # Step 1: Upload to R2 via Worker
43
+ t0 = time.time()
44
+ upload_resp = client.post(
45
+ "/bundles/upload",
46
+ content=bundle_bytes,
47
+ headers={**headers, "Content-Type": "application/octet-stream"},
48
+ timeout=300.0,
49
+ )
50
+ upload_resp.raise_for_status()
51
+ bundle_id = upload_resp.json()["bundle_id"]
52
+ results["upload_time"] = time.time() - t0
53
+
54
+ # Step 2: Start job (container will curl from Worker)
55
+ t1 = time.time()
56
+ payload = {
57
+ "command": "ls -la /workspace && echo done",
58
+ "bundle_id": bundle_id,
59
+ "env_vars": {},
60
+ "editables": [],
61
+ "ports": [],
62
+ }
63
+
64
+ job_id = None
65
+ with client.stream(
66
+ "POST", "/run", json=payload,
67
+ headers={**headers, "Accept": "text/event-stream"},
68
+ timeout=300.0,
69
+ ) as response:
70
+ response.raise_for_status()
71
+ for line in response.iter_lines():
72
+ if line.startswith("data: "):
73
+ data = line[6:]
74
+ if data == "[DONE]":
75
+ break
76
+ import json
77
+ try:
78
+ event = json.loads(data)
79
+ if "job_id" in event:
80
+ job_id = event["job_id"]
81
+ if event.get("status") == "running":
82
+ results["container_fetch_time"] = time.time() - t1
83
+ break
84
+ except json.JSONDecodeError:
85
+ pass
86
+
87
+ results["total_time"] = time.time() - t0
88
+ results["job_id"] = job_id
89
+
90
+ # Cleanup
91
+ if job_id:
92
+ try:
93
+ client.delete(f"/jobs/{job_id}", headers=headers)
94
+ except Exception:
95
+ pass
96
+
97
+ return results
98
+
99
+
100
+ def main():
101
+ config = load_config()
102
+ client = httpx.Client(
103
+ base_url=config.api_url,
104
+ timeout=httpx.Timeout(300.0, connect=60.0),
105
+ )
106
+ headers = {"Content-Type": "application/json"}
107
+ if config.auth_token:
108
+ headers["Authorization"] = f"Bearer {config.auth_token}"
109
+
110
+ print("=" * 70)
111
+ print("BUNDLE DELIVERY LATENCY BENCHMARK")
112
+ print("=" * 70)
113
+ print(f"API: {config.api_url}\n")
114
+
115
+ # Test sizes
116
+ sizes = [10, 30, 50, 63, 80]
117
+
118
+ print("Method: R2 via Worker (CLI → Worker → R2 → Container curl)")
119
+ print("-" * 70)
120
+ print(f"{'Size':>8} | {'Upload':>10} | {'Fetch+Extract':>14} | {'Total':>10}")
121
+ print("-" * 70)
122
+
123
+ for size_mb in sizes:
124
+ print(f"{size_mb:>6}MB | ", end="", flush=True)
125
+ bundle = create_test_bundle(size_mb)
126
+
127
+ try:
128
+ results = benchmark_r2_via_worker(client, headers, bundle)
129
+ upload = results.get("upload_time", 0)
130
+ fetch = results.get("container_fetch_time", 0)
131
+ total = results.get("total_time", 0)
132
+ print(f"{upload:>8.1f}s | {fetch:>12.1f}s | {total:>8.1f}s")
133
+ except Exception as e:
134
+ print(f"ERROR: {e}")
135
+
136
+ print()
137
+ print("=" * 70)
138
+ print("LATENCY BREAKDOWN ANALYSIS")
139
+ print("=" * 70)
140
+ print("""
141
+ R2 via Worker (current implementation):
142
+ 1. CLI → Worker: HTTP POST with raw bytes
143
+ 2. Worker → R2: env.BUNDLES.put()
144
+ 3. Return bundle_id to CLI
145
+ 4. CLI → Worker: POST /run with bundle_id
146
+ 5. Container → Worker: curl https://worker/bundles/{id}
147
+ 6. Worker → R2: env.BUNDLES.get()
148
+ 7. Worker → Container: stream response
149
+ 8. Container: unzip + run
150
+
151
+ Bottleneck: Steps 5-7 (container fetches through worker proxy)
152
+
153
+ Presigned URL (proposed):
154
+ 1. CLI → Worker: GET /bundles/presign
155
+ 2. Worker: generates presigned PUT URL
156
+ 3. CLI → R2 DIRECT: PUT to presigned URL (bypasses worker!)
157
+ 4. CLI → Worker: POST /run with bundle_id
158
+ 5. Container → R2 DIRECT: curl presigned GET URL (bypasses worker!)
159
+ 6. Container: unzip + run
160
+
161
+ Benefit: No worker in data path for upload OR download
162
+ Expected: ~30-50% faster for large bundles
163
+
164
+ mountBucket (FUSE streaming):
165
+ 1. Pre-populate R2 bucket with bundle
166
+ 2. Container: sandbox.mountBucket() - FUSE mount
167
+ 3. Container: Files accessed on-demand (no full download)
168
+ 4. Container: unzip from mount or access files directly
169
+
170
+ Benefit: No upfront transfer, streaming access
171
+ Tradeoff: Higher per-file latency (network roundtrip per read)
172
+ Best for: Very large datasets where you don't need all files
173
+ """)
174
+
175
+ client.close()
176
+
177
+
178
+ if __name__ == "__main__":
179
+ main()