gabriel / muse public
test_core_transport.py python
690 lines 27.2 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 days ago
1 """Tests for muse.core.transport — HttpTransport and response parsers."""
2
3 from __future__ import annotations
4
5 import json
6 import signal
7 import socket
8 import threading
9 import time
10 import unittest.mock
11 import urllib.error
12 import urllib.request
13 from io import BytesIO
14
15 import msgpack
16 import pytest
17
18 from muse.core._types import MsgpackDict
19 from muse.core.pack import MPackBundle, RemoteInfo
20 from muse.core.msign import build_msign_header
21 from muse.core.transport import (
22 HttpTransport,
23 SigningIdentity,
24 TransportError,
25 _parse_bundle,
26 _parse_push_result,
27 _parse_remote_info,
28 )
29
30
31 # ---------------------------------------------------------------------------
32 # Helpers
33 # ---------------------------------------------------------------------------
34
35
36 def _make_signing() -> "SigningIdentity":
37 """Generate a fresh Ed25519 SigningIdentity for tests."""
38 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
39 from muse.core.transport import SigningIdentity
40 return SigningIdentity(handle="testuser", private_key=Ed25519PrivateKey.generate())
41
42
43 def _mock_response(
44 body: bytes,
45 status: int = 200,
46 content_type: str = "application/x-msgpack",
47 streaming: bool = False,
48 ) -> unittest.mock.MagicMock:
49 """Return a mock urllib response context manager.
50
51 When *streaming* is True, ``read()`` returns *body* on the first call and
52 ``b""`` on all subsequent calls — matching the chunked-read loop used by
53 :meth:`~muse.core.transport.HttpTransport._execute_fetch`.
54 """
55 resp = unittest.mock.MagicMock()
56 if streaming:
57 resp.read.side_effect = [body, b""]
58 else:
59 resp.read.return_value = body
60 resp.headers = {"Content-Type": content_type}
61 resp.__enter__ = lambda s: s
62 resp.__exit__ = unittest.mock.MagicMock(return_value=False)
63 return resp
64
65
66 def _mp(data: MsgpackDict) -> bytes:
67 """Encode data as msgpack."""
68 return msgpack.packb(data, use_bin_type=True)
69
70
71 def _http_error(code: int, body: bytes = b"") -> urllib.error.HTTPError:
72 return urllib.error.HTTPError(
73 url="https://example.com",
74 code=code,
75 msg=str(code),
76 hdrs=None,
77 fp=BytesIO(body),
78 )
79
80
81 # ---------------------------------------------------------------------------
82 # _parse_remote_info
83 # ---------------------------------------------------------------------------
84
85
86 class TestParseRemoteInfo:
87 def test_valid_response(self) -> None:
88 raw = _mp(
89 {
90 "repo_id": "r123",
91 "domain": "midi",
92 "default_branch": "main",
93 "branch_heads": {"main": "abc123", "dev": "def456"},
94 }
95 )
96 info = _parse_remote_info(raw)
97 assert info["repo_id"] == "r123"
98 assert info["domain"] == "midi"
99 assert info["default_branch"] == "main"
100 assert info["branch_heads"] == {"main": "abc123", "dev": "def456"}
101
102 def test_invalid_msgpack_raises_transport_error(self) -> None:
103 with pytest.raises(TransportError):
104 _parse_remote_info(b"\xff\xff\xff\xff\xff invalid")
105
106 def test_non_dict_response_returns_defaults(self) -> None:
107 raw = _mp([1, 2, 3])
108 info = _parse_remote_info(raw)
109 assert info["repo_id"] == ""
110 assert info["branch_heads"] == {}
111
112 def test_missing_fields_get_defaults(self) -> None:
113 raw = _mp({"repo_id": "x"})
114 info = _parse_remote_info(raw)
115 assert info["repo_id"] == "x"
116 assert info["domain"] == "midi"
117 assert info["default_branch"] == "main"
118 assert info["branch_heads"] == {}
119
120 def test_non_string_branch_heads_excluded(self) -> None:
121 raw = _mp({"branch_heads": {"main": "abc", "bad": 123}})
122 info = _parse_remote_info(raw)
123 assert "main" in info["branch_heads"]
124 assert "bad" not in info["branch_heads"]
125
126 def test_pack_origin_populated_when_present(self) -> None:
127 raw = _mp(
128 {
129 "repo_id": "r1",
130 "domain": "code",
131 "default_branch": "main",
132 "branch_heads": {},
133 "pack_origin": "https://worker.example.workers.dev",
134 }
135 )
136 info = _parse_remote_info(raw)
137 assert info.get("pack_origin") == "https://worker.example.workers.dev"
138
139 def test_pack_origin_absent_when_not_in_response(self) -> None:
140 raw = _mp({"repo_id": "r1", "branch_heads": {}})
141 info = _parse_remote_info(raw)
142 assert "pack_origin" not in info
143
144 def test_pack_origin_whitespace_stripped(self) -> None:
145 raw = _mp(
146 {"repo_id": "r1", "branch_heads": {}, "pack_origin": " https://w.example.dev "}
147 )
148 info = _parse_remote_info(raw)
149 assert info.get("pack_origin") == "https://w.example.dev"
150
151 def test_pack_origin_none_value_not_set(self) -> None:
152 raw = _mp({"repo_id": "r1", "branch_heads": {}, "pack_origin": None})
153 info = _parse_remote_info(raw)
154 assert "pack_origin" not in info
155
156 def test_pack_origin_empty_string_not_set(self) -> None:
157 raw = _mp({"repo_id": "r1", "branch_heads": {}, "pack_origin": ""})
158 info = _parse_remote_info(raw)
159 assert "pack_origin" not in info
160
161 def test_pack_origin_whitespace_only_not_set(self) -> None:
162 raw = _mp({"repo_id": "r1", "branch_heads": {}, "pack_origin": " "})
163 info = _parse_remote_info(raw)
164 assert "pack_origin" not in info
165
166
167 # ---------------------------------------------------------------------------
168 # build_msign_header — module-level signing utility
169 # ---------------------------------------------------------------------------
170
171
172 class TestBuildMsignHeader:
173 def _make_signing(self) -> SigningIdentity:
174 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
175
176 return SigningIdentity(handle="testuser", private_key=Ed25519PrivateKey.generate())
177
178 def test_header_format(self) -> None:
179 header = build_msign_header(self._make_signing(), "GET", "https://example.com/path", None)
180 assert header.startswith('MSign handle="testuser"')
181 assert " ts=" in header
182 assert " sig=" in header
183
184 def test_timestamp_is_recent(self) -> None:
185 import time
186
187 before = int(time.time())
188 header = build_msign_header(self._make_signing(), "GET", "https://example.com/p", None)
189 after = int(time.time())
190 ts_part = next(p for p in header.split() if p.startswith("ts="))
191 ts = int(ts_part[3:])
192 assert before <= ts <= after + 1
193
194 def test_signature_is_verifiable(self) -> None:
195 """The sig= value must verify against the Ed25519 public key for the canonical input."""
196 import base64
197 import hashlib
198
199 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
200
201 private_key = Ed25519PrivateKey.generate()
202 signing = SigningIdentity(handle="testuser", private_key=private_key)
203 method = "POST"
204 url = "https://hub.example.com/owner/repo/push"
205 body = b"some body data"
206
207 header = build_msign_header(signing, method, url, body)
208
209 parts: dict[str, str] = {}
210 for part in header[len("MSign "):].split():
211 k, _, v = part.partition("=")
212 parts[k] = v.strip('"')
213
214 ts = int(parts["ts"])
215 sig_bytes = base64.urlsafe_b64decode(parts["sig"] + "==")
216
217 body_hash = hashlib.sha256(body).hexdigest()
218 canonical = f"ed25519\n{method}\nhub.example.com\n/owner/repo/push\n{ts}\n{body_hash}".encode()
219
220 # raises cryptography.exceptions.InvalidSignature on failure
221 private_key.public_key().verify(sig_bytes, canonical)
222
223 def test_query_string_included_in_canonical(self) -> None:
224 """Query parameters must be part of the signed path, not dropped."""
225 import base64
226 import hashlib
227
228 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
229
230 private_key = Ed25519PrivateKey.generate()
231 signing = SigningIdentity(handle="u", private_key=private_key)
232 url = "https://hub.example.com/path?foo=bar&baz=1"
233
234 header = build_msign_header(signing, "GET", url, None)
235
236 parts: dict[str, str] = {}
237 for part in header[len("MSign "):].split():
238 k, _, v = part.partition("=")
239 parts[k] = v.strip('"')
240
241 ts = int(parts["ts"])
242 sig_bytes = base64.urlsafe_b64decode(parts["sig"] + "==")
243 body_hash = hashlib.sha256(b"").hexdigest()
244 canonical = f"ed25519\nGET\nhub.example.com\n/path?foo=bar&baz=1\n{ts}\n{body_hash}".encode()
245
246 private_key.public_key().verify(sig_bytes, canonical)
247
248 def test_none_body_treated_as_empty_bytes(self) -> None:
249 """None and b'' must produce the same SHA-256 body hash in the canonical form."""
250 import base64
251 import hashlib
252
253 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
254
255 private_key = Ed25519PrivateKey.generate()
256 signing = SigningIdentity(handle="u", private_key=private_key)
257 url = "https://hub.example.com/path"
258
259 header = build_msign_header(signing, "GET", url, None)
260
261 parts: dict[str, str] = {}
262 for part in header[len("MSign "):].split():
263 k, _, v = part.partition("=")
264 parts[k] = v.strip('"')
265
266 ts = int(parts["ts"])
267 sig_bytes = base64.urlsafe_b64decode(parts["sig"] + "==")
268 # body=None → b"" → sha256("") is the canonical body hash
269 body_hash = hashlib.sha256(b"").hexdigest()
270 canonical = f"ed25519\nGET\nhub.example.com\n/path\n{ts}\n{body_hash}".encode()
271
272 private_key.public_key().verify(sig_bytes, canonical)
273
274 def test_different_methods_produce_different_headers(self) -> None:
275 """Two calls with different methods on the same URL must differ (method is in canonical)."""
276
277 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
278
279 private_key = Ed25519PrivateKey.generate()
280 signing = SigningIdentity(handle="u", private_key=private_key)
281
282 with unittest.mock.patch("muse.core.msign.time") as mt:
283 mt.time.return_value = 1_700_000_000
284 h_get = build_msign_header(signing, "GET", "https://example.com/x", b"")
285 h_post = build_msign_header(signing, "POST", "https://example.com/x", b"")
286
287 assert h_get != h_post
288
289 def test_different_bodies_produce_different_sigs(self) -> None:
290 """Body content must influence the signature (body hash is in canonical)."""
291
292 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
293
294 private_key = Ed25519PrivateKey.generate()
295 signing = SigningIdentity(handle="u", private_key=private_key)
296
297 with unittest.mock.patch("muse.core.msign.time") as mt:
298 mt.time.return_value = 1_700_000_000
299 h1 = build_msign_header(signing, "POST", "https://example.com/x", b"body-a")
300 h2 = build_msign_header(signing, "POST", "https://example.com/x", b"body-b")
301
302 assert h1 != h2
303
304 def test_handle_embedded_in_header(self) -> None:
305 """The MSign header must carry the signing identity's handle."""
306
307 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
308
309 private_key = Ed25519PrivateKey.generate()
310 signing = SigningIdentity(handle="my-agent-42", private_key=private_key)
311 header = build_msign_header(signing, "GET", "https://example.com/x", None)
312 assert 'handle="my-agent-42"' in header
313
314
315 # ---------------------------------------------------------------------------
316 # _parse_bundle
317 # ---------------------------------------------------------------------------
318
319
320 class TestParseBundle:
321 def test_empty_msgpack_object_returns_empty_bundle(self) -> None:
322 bundle = _parse_bundle(_mp({}))
323 assert bundle == {}
324
325 def test_non_dict_returns_empty_bundle(self) -> None:
326 bundle = _parse_bundle(_mp([]))
327 assert bundle == {}
328
329 def test_commits_extracted(self) -> None:
330 raw = _mp(
331 {
332 "commits": [
333 {
334 "commit_id": "c1",
335 "repo_id": "r1",
336 "branch": "main",
337 "snapshot_id": "1" * 64,
338 "message": "test",
339 "committed_at": "2026-01-01T00:00:00+00:00",
340 "parent_commit_id": None,
341 "parent2_commit_id": None,
342 "author": "bob",
343 "metadata": {},
344 }
345 ]
346 }
347 )
348 bundle = _parse_bundle(raw)
349 commits = bundle.get("commits") or []
350 assert len(commits) == 1
351 assert commits[0]["commit_id"] == "c1"
352
353 def test_objects_extracted(self) -> None:
354 raw = _mp(
355 {
356 "objects": [
357 {
358 "object_id": "abc123",
359 "content": b"hello",
360 }
361 ]
362 }
363 )
364 bundle = _parse_bundle(raw)
365 objs = bundle.get("objects") or []
366 assert len(objs) == 1
367 assert objs[0]["object_id"] == "abc123"
368 assert objs[0]["content"] == b"hello"
369
370 def test_object_missing_content_excluded(self) -> None:
371 raw = _mp({"objects": [{"object_id": "abc"}]})
372 bundle = _parse_bundle(raw)
373 assert (bundle.get("objects") or []) == []
374
375 def test_branch_heads_extracted(self) -> None:
376 raw = _mp({"branch_heads": {"main": "abc123"}})
377 bundle = _parse_bundle(raw)
378 assert bundle.get("branch_heads") == {"main": "abc123"}
379
380
381 # ---------------------------------------------------------------------------
382 # _parse_push_result
383 # ---------------------------------------------------------------------------
384
385
386 class TestParsePushResult:
387 def test_success_response(self) -> None:
388 raw = _mp({"ok": True, "message": "pushed", "branch_heads": {"main": "abc"}})
389 result = _parse_push_result(raw)
390 assert result["ok"] is True
391 assert result["message"] == "pushed"
392 assert result["branch_heads"] == {"main": "abc"}
393
394 def test_failure_response(self) -> None:
395 raw = _mp({"ok": False, "message": "rejected", "branch_heads": {}})
396 result = _parse_push_result(raw)
397 assert result["ok"] is False
398 assert result["message"] == "rejected"
399
400 def test_non_msgpack_raises_transport_error(self) -> None:
401 with pytest.raises(TransportError):
402 _parse_push_result(b"\xff\xff invalid msgpack")
403
404 def test_missing_ok_defaults_false(self) -> None:
405 raw = _mp({"message": "hm", "branch_heads": {}})
406 result = _parse_push_result(raw)
407 assert result["ok"] is False
408
409
410 # ---------------------------------------------------------------------------
411 # HttpTransport — mocked urlopen
412 # ---------------------------------------------------------------------------
413
414
415 class TestHttpTransportFetchRemoteInfo:
416 def test_calls_correct_endpoint(self) -> None:
417 body = _mp(
418 {
419 "repo_id": "r1",
420 "domain": "midi",
421 "default_branch": "main",
422 "branch_heads": {"main": "abc"},
423 }
424 )
425 mock_resp = _mock_response(body)
426 with unittest.mock.patch("muse.core.transport._open_url", return_value=mock_resp) as m:
427 transport = HttpTransport()
428 info = transport.fetch_remote_info("https://hub.example.com/repos/r1", None)
429 req = m.call_args[0][0]
430 assert req.full_url == "https://hub.example.com/repos/r1/refs"
431 assert info["repo_id"] == "r1"
432
433 def test_msign_header_sent(self) -> None:
434 body = _mp(
435 {"repo_id": "r1", "domain": "midi", "default_branch": "main", "branch_heads": {}}
436 )
437 mock_resp = _mock_response(body)
438 signing = _make_signing()
439 with unittest.mock.patch("muse.core.transport._open_url", return_value=mock_resp) as m:
440 HttpTransport().fetch_remote_info("https://hub.example.com/repos/r1", signing)
441 req = m.call_args[0][0]
442 assert req.get_header("Authorization").startswith("MSign handle=\"testuser\"")
443
444 def test_no_token_no_auth_header(self) -> None:
445 body = _mp(
446 {"repo_id": "r1", "domain": "midi", "default_branch": "main", "branch_heads": {}}
447 )
448 mock_resp = _mock_response(body)
449 with unittest.mock.patch("muse.core.transport._open_url", return_value=mock_resp) as m:
450 HttpTransport().fetch_remote_info("https://hub.example.com/repos/r1", None)
451 req = m.call_args[0][0]
452 assert req.get_header("Authorization") is None
453
454 def test_http_401_raises_transport_error(self) -> None:
455 with unittest.mock.patch(
456 "muse.core.transport._open_url", side_effect=_http_error(401, b"Unauthorized")
457 ):
458 with pytest.raises(TransportError) as exc_info:
459 HttpTransport().fetch_remote_info("https://hub.example.com/repos/r1", None)
460 assert exc_info.value.status_code == 401
461
462 def test_http_404_raises_transport_error(self) -> None:
463 with unittest.mock.patch(
464 "muse.core.transport._open_url", side_effect=_http_error(404)
465 ):
466 with pytest.raises(TransportError) as exc_info:
467 HttpTransport().fetch_remote_info("https://hub.example.com/repos/r1", None)
468 assert exc_info.value.status_code == 404
469
470 def test_http_500_raises_transport_error(self) -> None:
471 with unittest.mock.patch(
472 "muse.core.transport._open_url", side_effect=_http_error(500, b"Internal Error")
473 ):
474 with pytest.raises(TransportError) as exc_info:
475 HttpTransport().fetch_remote_info("https://hub.example.com/repos/r1", None)
476 assert exc_info.value.status_code == 500
477
478 def test_url_error_raises_transport_error_with_code_0(self) -> None:
479 with unittest.mock.patch(
480 "muse.core.transport._open_url",
481 side_effect=urllib.error.URLError("Name or service not known"),
482 ):
483 with pytest.raises(TransportError) as exc_info:
484 HttpTransport().fetch_remote_info("https://bad.host/r", None)
485 assert exc_info.value.status_code == 0
486
487 def test_trailing_slash_stripped_from_url(self) -> None:
488 body = _mp(
489 {"repo_id": "r", "domain": "midi", "default_branch": "main", "branch_heads": {}}
490 )
491 mock_resp = _mock_response(body)
492 with unittest.mock.patch("muse.core.transport._open_url", return_value=mock_resp) as m:
493 HttpTransport().fetch_remote_info("https://hub.example.com/repos/r1/", None)
494 req = m.call_args[0][0]
495 assert req.full_url == "https://hub.example.com/repos/r1/refs"
496
497
498 # ---------------------------------------------------------------------------
499 # HttpTransport._build_request — credential security and loopback allowlist
500 # ---------------------------------------------------------------------------
501
502
503 class TestBuildRequest:
504 """_build_request enforces HTTPS for non-loopback URLs with signing identity."""
505
506 def _build(self, url: str, with_signing: bool = True) -> urllib.request.Request:
507 signing = _make_signing() if with_signing else None
508 # Stub TOFU so unit tests don't make real TLS connections to verify
509 # certificate fingerprints — that's network state, not request structure.
510 with unittest.mock.patch("muse.core.hub_trust.check_and_pin"):
511 return HttpTransport()._build_request("GET", url, signing)
512
513 # ── Loopback hosts allowed over plain HTTP ────────────────────────────
514
515 def test_localhost_http_with_token_allowed(self) -> None:
516 req = self._build("http://localhost:10003/repo/refs")
517 assert req.get_header("Authorization").startswith("MSign ")
518
519 def test_127_0_0_1_http_with_token_allowed(self) -> None:
520 req = self._build("http://127.0.0.1:10003/repo/refs")
521 assert req.get_header("Authorization").startswith("MSign ")
522
523 def test_ipv6_loopback_http_with_token_allowed(self) -> None:
524 req = self._build("http://[::1]:10003/repo/refs")
525 assert req.get_header("Authorization").startswith("MSign ")
526
527 def test_host_docker_internal_http_with_token_allowed(self) -> None:
528 """host.docker.internal is Docker Desktop's alias for the host loopback.
529
530 Agent swarms run inside Docker and use http://host.docker.internal:10003
531 to reach a local MuseHub instance. Credentials must be sent over this
532 plain-HTTP connection — the traffic never leaves the machine.
533 """
534 req = self._build("http://host.docker.internal:10003/gabriel/repo/refs")
535 assert req.get_header("Authorization").startswith("MSign ")
536
537 def test_https_any_host_with_token_allowed(self) -> None:
538 req = self._build("https://musehub.ai/gabriel/repo/refs")
539 assert req.get_header("Authorization").startswith("MSign ")
540
541 # ── Non-loopback HTTP with token must be rejected ─────────────────────
542
543 def test_non_loopback_http_token_raises_transport_error(self) -> None:
544 with pytest.raises(TransportError, match="non-HTTPS"):
545 self._build("http://musehub.ai/gabriel/repo/refs")
546
547 def test_arbitrary_hostname_http_token_raises(self) -> None:
548 with pytest.raises(TransportError, match="non-HTTPS"):
549 self._build("http://evil.example.com/steal")
550
551 def test_localhost_lookalike_http_token_raises(self) -> None:
552 """'localhost.evil.com' must NOT be mistaken for the loopback interface."""
553 with pytest.raises(TransportError, match="non-HTTPS"):
554 self._build("http://localhost.evil.com/repo/refs")
555
556 def test_host_docker_internal_lookalike_http_token_raises(self) -> None:
557 """'host.docker.internal.evil.com' must not bypass the check."""
558 with pytest.raises(TransportError, match="non-HTTPS"):
559 self._build("http://host.docker.internal.evil.com/repo")
560
561 # ── No token — scheme restriction does not apply ──────────────────────
562
563 def test_non_loopback_http_without_token_allowed(self) -> None:
564 req = self._build("http://musehub.ai/public/repo/refs", with_signing=False)
565 assert req.get_header("Authorization") is None
566
567 # ── Request structure ─────────────────────────────────────────────────
568
569 def test_accept_header_always_set(self) -> None:
570 req = self._build("https://musehub.ai/repo/refs")
571 assert "msgpack" in req.get_header("Accept")
572
573 def test_method_preserved(self) -> None:
574 req = HttpTransport()._build_request("POST", "https://musehub.ai/x", None)
575 assert req.get_method() == "POST"
576
577 def test_body_sets_content_type(self) -> None:
578 req = HttpTransport()._build_request(
579 "POST", "https://musehub.ai/x", None, body_bytes=b"data"
580 )
581 assert req.get_header("Content-type") == "application/x-msgpack"
582
583 def test_no_body_omits_content_type(self) -> None:
584 req = self._build("https://musehub.ai/x", with_signing=False)
585 assert req.get_header("Content-type") is None
586
587
588 # ---------------------------------------------------------------------------
589 # SIGPIPE regression — large push body with early-close server
590 # ---------------------------------------------------------------------------
591
592
593 def _early_close_server(
594 server_sock: socket.socket,
595 response_code: int,
596 resp_body: bytes,
597 ) -> None:
598 """Accept one connection, read HTTP headers, send response, close immediately.
599
600 Simulates the scenario where the server sends a 4xx/5xx response while
601 the client is still uploading a large request body. Without the
602 ``_ignore_sigpipe`` guard in ``_open_url``, the client process dies with
603 exit code 141 (SIGPIPE) instead of raising ``TransportError``.
604 """
605 try:
606 conn, _ = server_sock.accept()
607 conn.settimeout(5.0)
608 try:
609 buf = b""
610 deadline = time.time() + 5
611 while time.time() < deadline:
612 try:
613 chunk = conn.recv(4096)
614 if not chunk:
615 break
616 buf += chunk
617 if b"\r\n\r\n" in buf:
618 break
619 except socket.timeout:
620 break
621 status_line = f"HTTP/1.1 {response_code} Error\r\n"
622 resp_headers = (
623 "Content-Type: application/json\r\n"
624 f"Content-Length: {len(resp_body)}\r\n"
625 "Connection: close\r\n\r\n"
626 )
627 conn.sendall((status_line + resp_headers).encode() + resp_body)
628 finally:
629 conn.close()
630 except Exception:
631 pass
632 finally:
633 server_sock.close()
634
635
636 class TestSigpipeRegression:
637 """Regression tests for SIGPIPE on large push bodies.
638
639 ``muse/cli/app.py`` sets ``SIGPIPE = SIG_DFL`` so that piping output to
640 ``head``/``grep``/``jq`` exits cleanly. Without the ``_ignore_sigpipe``
641 guard in ``_open_url`` the push command dies with exit 141 when the server
642 closes the connection while the client is still uploading a large body.
643 """
644
645 def _run_early_close_scenario(self, payload_mb: float, response_code: int) -> None:
646 """Assert that TransportError is raised, not a process-killing SIGPIPE."""
647 # Reproduce app.py's startup action — SIG_DFL kills the process on SIGPIPE.
648 if hasattr(signal, "SIGPIPE"):
649 original = signal.signal(signal.SIGPIPE, signal.SIG_DFL)
650 else:
651 original = None
652
653 body = b"X" * int(payload_mb * 1024 * 1024)
654 resp_body = b'{"detail":"test error"}'
655
656 server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
657 server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
658 server_sock.bind(("127.0.0.1", 0))
659 server_sock.listen(1)
660 port = server_sock.getsockname()[1]
661
662 t = threading.Thread(
663 target=_early_close_server,
664 args=(server_sock, response_code, resp_body),
665 daemon=True,
666 )
667 t.start()
668
669 try:
670 url = f"http://127.0.0.1:{port}/owner/repo/push"
671 transport = HttpTransport()
672 req = transport._build_request("POST", url, None, body, "application/x-msgpack")
673 with pytest.raises(TransportError):
674 transport._execute(req)
675 finally:
676 t.join(timeout=2)
677 if original is not None and hasattr(signal, "SIGPIPE"):
678 signal.signal(signal.SIGPIPE, original)
679
680 def test_sigpipe_not_fatal_409_large_body(self) -> None:
681 """20 MB body + server closes after headers → TransportError, not exit 141."""
682 self._run_early_close_scenario(payload_mb=20.0, response_code=409)
683
684 def test_sigpipe_not_fatal_401_large_body(self) -> None:
685 """15 MB body + server sends 401 early → TransportError, not SIGPIPE crash."""
686 self._run_early_close_scenario(payload_mb=15.0, response_code=401)
687
688 def test_sigpipe_not_fatal_500_large_body(self) -> None:
689 """10 MB body + server crashes (500) → TransportError, not SIGPIPE crash."""
690 self._run_early_close_scenario(payload_mb=10.0, response_code=500)
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 143 days ago