isolate 0.26.4__tar.gz → 0.26.6__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 (95) hide show
  1. {isolate-0.26.4/src/isolate.egg-info → isolate-0.26.6}/PKG-INFO +1 -1
  2. {isolate-0.26.4 → isolate-0.26.6}/pyproject.toml +5 -0
  3. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/_isolate_version.py +3 -3
  4. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/backends/common.py +6 -4
  5. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/connections/grpc/_base.py +15 -3
  6. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/logger.py +6 -1
  7. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/server/server.py +99 -16
  8. {isolate-0.26.4 → isolate-0.26.6/src/isolate.egg-info}/PKG-INFO +1 -1
  9. {isolate-0.26.4 → isolate-0.26.6}/src/isolate.egg-info/SOURCES.txt +1 -0
  10. {isolate-0.26.4 → isolate-0.26.6}/tests/test_connections_grpc_base.py +35 -11
  11. isolate-0.26.6/tests/test_logged_io_pipe.py +132 -0
  12. {isolate-0.26.4 → isolate-0.26.6}/.github/workflows/claude-code-review.yml +0 -0
  13. {isolate-0.26.4 → isolate-0.26.6}/.github/workflows/claude.yml +0 -0
  14. {isolate-0.26.4 → isolate-0.26.6}/.github/workflows/lint.yml +0 -0
  15. {isolate-0.26.4 → isolate-0.26.6}/.github/workflows/release.yml +0 -0
  16. {isolate-0.26.4 → isolate-0.26.6}/.github/workflows/test.yml +0 -0
  17. {isolate-0.26.4 → isolate-0.26.6}/.gitignore +0 -0
  18. {isolate-0.26.4 → isolate-0.26.6}/.pre-commit-config.yaml +0 -0
  19. {isolate-0.26.4 → isolate-0.26.6}/LICENSE +0 -0
  20. {isolate-0.26.4 → isolate-0.26.6}/README.md +0 -0
  21. {isolate-0.26.4 → isolate-0.26.6}/setup.cfg +0 -0
  22. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/__init__.py +0 -0
  23. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/_version.py +0 -0
  24. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/backends/__init__.py +0 -0
  25. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/backends/_base.py +0 -0
  26. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/backends/conda.py +0 -0
  27. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/backends/container.py +0 -0
  28. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/backends/local.py +0 -0
  29. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/backends/pyenv.py +0 -0
  30. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/backends/remote.py +0 -0
  31. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/backends/settings.py +0 -0
  32. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/backends/virtualenv.py +0 -0
  33. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/common/__init__.py +0 -0
  34. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/common/timestamp.py +0 -0
  35. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/connections/__init__.py +0 -0
  36. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/connections/_local/__init__.py +0 -0
  37. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/connections/_local/_base.py +0 -0
  38. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/connections/_local/agent_startup.py +0 -0
  39. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/connections/common.py +0 -0
  40. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/connections/grpc/__init__.py +0 -0
  41. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/connections/grpc/agent.py +0 -0
  42. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/connections/grpc/configuration.py +0 -0
  43. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/connections/grpc/definitions/__init__.py +0 -0
  44. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/connections/grpc/definitions/agent.proto +0 -0
  45. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/connections/grpc/definitions/agent_pb2.py +0 -0
  46. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/connections/grpc/definitions/agent_pb2.pyi +0 -0
  47. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/connections/grpc/definitions/agent_pb2_grpc.py +0 -0
  48. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/connections/grpc/definitions/common.proto +0 -0
  49. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/connections/grpc/definitions/common_pb2.py +0 -0
  50. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/connections/grpc/definitions/common_pb2.pyi +0 -0
  51. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/connections/grpc/definitions/common_pb2_grpc.py +0 -0
  52. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/connections/grpc/interface.py +0 -0
  53. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/connections/ipc/__init__.py +0 -0
  54. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/connections/ipc/_base.py +0 -0
  55. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/connections/ipc/agent.py +0 -0
  56. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/logs.py +0 -0
  57. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/py.typed +0 -0
  58. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/registry.py +0 -0
  59. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/server/__init__.py +0 -0
  60. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/server/definitions/__init__.py +0 -0
  61. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/server/definitions/server.proto +0 -0
  62. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/server/definitions/server_pb2.py +0 -0
  63. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/server/definitions/server_pb2.pyi +0 -0
  64. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/server/definitions/server_pb2_grpc.py +0 -0
  65. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/server/health/__init__.py +0 -0
  66. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/server/health/health.proto +0 -0
  67. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/server/health/health_pb2.py +0 -0
  68. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/server/health/health_pb2.pyi +0 -0
  69. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/server/health/health_pb2_grpc.py +0 -0
  70. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/server/health_server.py +0 -0
  71. {isolate-0.26.4 → isolate-0.26.6}/src/isolate/server/interface.py +0 -0
  72. {isolate-0.26.4 → isolate-0.26.6}/src/isolate.egg-info/dependency_links.txt +0 -0
  73. {isolate-0.26.4 → isolate-0.26.6}/src/isolate.egg-info/entry_points.txt +0 -0
  74. {isolate-0.26.4 → isolate-0.26.6}/src/isolate.egg-info/requires.txt +0 -0
  75. {isolate-0.26.4 → isolate-0.26.6}/src/isolate.egg-info/top_level.txt +0 -0
  76. {isolate-0.26.4 → isolate-0.26.6}/tests/__init__.py +0 -0
  77. {isolate-0.26.4 → isolate-0.26.6}/tests/conftest.py +0 -0
  78. {isolate-0.26.4 → isolate-0.26.6}/tests/test_agent_backward_compatibility.py +0 -0
  79. {isolate-0.26.4 → isolate-0.26.6}/tests/test_backends.py +0 -0
  80. {isolate-0.26.4 → isolate-0.26.6}/tests/test_concurrency.py +0 -0
  81. {isolate-0.26.4 → isolate-0.26.6}/tests/test_connections.py +0 -0
  82. {isolate-0.26.4 → isolate-0.26.6}/tests/test_isolate.py +0 -0
  83. {isolate-0.26.4 → isolate-0.26.6}/tests/test_log.py +0 -0
  84. {isolate-0.26.4 → isolate-0.26.6}/tests/test_log_masking.py +0 -0
  85. {isolate-0.26.4 → isolate-0.26.6}/tests/test_logger.py +0 -0
  86. {isolate-0.26.4 → isolate-0.26.6}/tests/test_serialization.py +0 -0
  87. {isolate-0.26.4 → isolate-0.26.6}/tests/test_server.py +0 -0
  88. {isolate-0.26.4 → isolate-0.26.6}/tests/test_shutdown.py +0 -0
  89. {isolate-0.26.4 → isolate-0.26.6}/tools/Dockerfile +0 -0
  90. {isolate-0.26.4 → isolate-0.26.6}/tools/agent_requirements.txt +0 -0
  91. {isolate-0.26.4 → isolate-0.26.6}/tools/isolate_client.py +0 -0
  92. {isolate-0.26.4 → isolate-0.26.6}/tools/protobuf-requirements.txt +0 -0
  93. {isolate-0.26.4 → isolate-0.26.6}/tools/regen_grpc.py +0 -0
  94. {isolate-0.26.4 → isolate-0.26.6}/tools/requirements.txt +0 -0
  95. {isolate-0.26.4 → isolate-0.26.6}/tools/test_agent_requirements.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: isolate
3
- Version: 0.26.4
3
+ Version: 0.26.6
4
4
  Summary: Managed isolated environments for Python
5
5
  Author-email: Features & Labels <hello@fal.ai>
6
6
  Project-URL: Issues, https://github.com/fal-ai/isolate/issues
@@ -76,3 +76,8 @@ keep-runtime-typing = true
76
76
 
77
77
  [tool.ruff.lint]
78
78
  select = ["E", "F", "W", "PLC", "PLE", "PLW", "I", "UP"]
79
+
80
+ [tool.pytest.ini_options]
81
+ addopts = "-ra --durations=50"
82
+ asyncio_mode = "auto"
83
+ testpaths = "tests"
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.26.4'
32
- __version_tuple__ = version_tuple = (0, 26, 4)
31
+ __version__ = version = '0.26.6'
32
+ __version_tuple__ = version_tuple = (0, 26, 6)
33
33
 
34
- __commit_id__ = commit_id = 'gce17302a5'
34
+ __commit_id__ = commit_id = 'g6d4ff5277'
@@ -166,13 +166,15 @@ def _close_fd(fd: int) -> None:
166
166
 
167
167
 
168
168
  def _unblocked_pipe() -> tuple[int, int]:
169
- """Create a pair of unblocked pipes. This is actually
170
- the same as os.pipe2(os.O_NONBLOCK), but that is not
171
- available in MacOS so we have to do it manually."""
169
+ """Create a pipe whose read end is non-blocking, so the reader thread in
170
+ `_io_observer` can `select()` + `read()` without risk of hanging on a
171
+ partial-line edge case. The write end stays blocking so the worker's
172
+ stdout/stderr applies backpressure (waits for the reader to drain the
173
+ kernel pipe buffer) instead of raising BlockingIOError when the buffer
174
+ fills up — which happens reliably with chunky tracebacks on Python 3.14."""
172
175
 
173
176
  read_fd, write_fd = os.pipe()
174
177
  os.set_blocking(read_fd, False)
175
- os.set_blocking(write_fd, False)
176
178
  return read_fd, write_fd
177
179
 
178
180
 
@@ -185,14 +185,26 @@ class LocalPythonGRPC(PythonExecutionBase[str], GRPCExecutionBase):
185
185
  # already finished
186
186
  return_code = self._process.returncode
187
187
  else:
188
- print("Terminating the agent process...")
188
+ self.log(
189
+ "Terminating the agent process...",
190
+ level=LogLevel.INFO,
191
+ source=LogSource.BRIDGE,
192
+ )
189
193
  self._process.terminate()
190
194
  return_code = self._process.wait(
191
195
  timeout=PROCESS_SHUTDOWN_TIMEOUT_SECONDS
192
196
  )
193
- print("Agent process shutdown gracefully")
197
+ self.log(
198
+ "Agent process shutdown gracefully",
199
+ level=LogLevel.INFO,
200
+ source=LogSource.BRIDGE,
201
+ )
194
202
  except Exception as exc:
195
- print(f"Failed to shutdown the agent process gracefully: {exc}")
203
+ self.log(
204
+ f"Failed to shutdown the agent process gracefully: {exc}",
205
+ level=LogLevel.ERROR,
206
+ source=LogSource.BRIDGE,
207
+ )
196
208
  self._process.kill()
197
209
  return_code = self._process.wait()
198
210
 
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import os
3
+ import sys
3
4
  from datetime import datetime, timezone
4
5
  from typing import Dict
5
6
 
@@ -55,6 +56,10 @@ class IsolateLogger:
55
56
  try:
56
57
  _labels = json.loads(raw)
57
58
  except json.JSONDecodeError:
58
- print("Failed to parse ISOLATE_LOG_LABELS")
59
+ # Write to stderr so a malformed env var does not become a
60
+ # constant first stdout line and break Vector's content-based
61
+ # file fingerprint (collision-prone first lines cause silent
62
+ # log skipping; see infra commit b9f0f2237).
63
+ sys.stderr.write("Failed to parse ISOLATE_LOG_LABELS\n")
59
64
 
60
65
  return cls.with_env_expanded(labels=_labels)
@@ -36,6 +36,15 @@ from isolate.server import definitions, health
36
36
  from isolate.server.health_server import HealthServicer
37
37
  from isolate.server.interface import from_grpc, to_grpc
38
38
 
39
+ # Server-level logger for events that aren't tied to a RunTask (startup,
40
+ # signals, auth interceptor, lifecycle). Routes to stdout as JSON via
41
+ # IsolateLogger.log -> print(json.dumps(record)). Critical: this makes the
42
+ # very first line written to isolate-server.stdout a JSON record with a
43
+ # microsecond-precision logged_at, so Vector's default content-based file
44
+ # fingerprint (checksum, lines: 1) is unique per alloc instead of colliding
45
+ # on the constant "Started listening at 0.0.0.0:NNNNN" prefix.
46
+ _SERVER_LOGGER = IsolateLogger.from_env()
47
+
39
48
  EMPTY_MESSAGE_INTERVAL = float(os.getenv("ISOLATE_EMPTY_MESSAGE_INTERVAL", "600"))
40
49
  SKIP_EMPTY_LOGS = os.getenv("ISOLATE_SKIP_EMPTY_LOGS") == "1"
41
50
  MAX_GRPC_WAIT_TIMEOUT = float(os.getenv("ISOLATE_MAX_GRPC_WAIT_TIMEOUT", "10.0"))
@@ -442,7 +451,12 @@ class IsolateServicer(definitions.IsolateServicer):
442
451
  task.future = self._thread_pool.submit(self._run_task_in_background, task)
443
452
  task_id = str(uuid.uuid4())
444
453
 
445
- print(f"Submitted a task {task_id}")
454
+ _SERVER_LOGGER.log(
455
+ LogLevel.INFO,
456
+ f"Submitted a task {task_id}",
457
+ source=LogSource.BRIDGE,
458
+ line_labels={},
459
+ )
446
460
 
447
461
  self.background_tasks[task_id] = task
448
462
 
@@ -452,7 +466,12 @@ class IsolateServicer(definitions.IsolateServicer):
452
466
  msg += f" error: {exc!r}"
453
467
  else:
454
468
  msg += f" result: {future.result()!r}"
455
- print(msg)
469
+ _SERVER_LOGGER.log(
470
+ LogLevel.INFO,
471
+ msg,
472
+ source=LogSource.BRIDGE,
473
+ line_labels={},
474
+ )
456
475
  self.background_tasks.pop(task_id, None)
457
476
 
458
477
  task.future.add_done_callback(_callback)
@@ -518,7 +537,12 @@ class IsolateServicer(definitions.IsolateServicer):
518
537
  ) -> definitions.CancelResponse:
519
538
  task_id = request.task_id
520
539
 
521
- print(f"Canceling task {task_id}")
540
+ _SERVER_LOGGER.log(
541
+ LogLevel.INFO,
542
+ f"Canceling task {task_id}",
543
+ source=LogSource.BRIDGE,
544
+ line_labels={},
545
+ )
522
546
  task = self.background_tasks.get(task_id)
523
547
  if task is not None:
524
548
  task.cancel()
@@ -527,14 +551,29 @@ class IsolateServicer(definitions.IsolateServicer):
527
551
 
528
552
  def shutdown(self) -> None:
529
553
  if self._shutting_down:
530
- print("Shutdown already in progress...")
554
+ _SERVER_LOGGER.log(
555
+ LogLevel.INFO,
556
+ "Shutdown already in progress...",
557
+ source=LogSource.BRIDGE,
558
+ line_labels={},
559
+ )
531
560
  return
532
561
 
533
562
  self._shutting_down = True
534
563
  task_count = len(self.background_tasks)
535
- print(f"Shutting down, canceling {task_count} tasks...")
564
+ _SERVER_LOGGER.log(
565
+ LogLevel.INFO,
566
+ f"Shutting down, canceling {task_count} tasks...",
567
+ source=LogSource.BRIDGE,
568
+ line_labels={},
569
+ )
536
570
  self.cancel_tasks()
537
- print("All tasks canceled.")
571
+ _SERVER_LOGGER.log(
572
+ LogLevel.INFO,
573
+ "All tasks canceled.",
574
+ source=LogSource.BRIDGE,
575
+ line_labels={},
576
+ )
538
577
 
539
578
  def watch_queue_until_completed(
540
579
  self, queue: Queue, is_completed: Callable[[], bool]
@@ -703,11 +742,21 @@ class SingleTaskInterceptor(ServerBoundInterceptor):
703
742
  def _wrapper(request: Any, context: grpc.ServicerContext) -> Any:
704
743
  def termination() -> None:
705
744
  if is_run:
706
- print("Stopping server since run is finished")
745
+ _SERVER_LOGGER.log(
746
+ LogLevel.INFO,
747
+ "Stopping server since run is finished",
748
+ source=LogSource.BRIDGE,
749
+ line_labels={},
750
+ )
707
751
  self.servicer.shutdown()
708
752
  # Stop the server after the Run task is finished
709
753
  self.server.stop(grace=0.1)
710
- print("Server stopped")
754
+ _SERVER_LOGGER.log(
755
+ LogLevel.INFO,
756
+ "Server stopped",
757
+ source=LogSource.BRIDGE,
758
+ line_labels={},
759
+ )
711
760
 
712
761
  elif is_submit:
713
762
  # Wait until the task_id is assigned
@@ -731,10 +780,20 @@ class SingleTaskInterceptor(ServerBoundInterceptor):
731
780
  def _stop(*args):
732
781
  # Small sleep to make sure the cancellation is processed
733
782
  time.sleep(0.3)
734
- print("Stopping server since the task is finished")
783
+ _SERVER_LOGGER.log(
784
+ LogLevel.INFO,
785
+ "Stopping server since the task is finished",
786
+ source=LogSource.BRIDGE,
787
+ line_labels={},
788
+ )
735
789
  self.servicer.shutdown()
736
790
  self.server.stop(grace=0.1)
737
- print("Server stopped")
791
+ _SERVER_LOGGER.log(
792
+ LogLevel.INFO,
793
+ "Server stopped",
794
+ source=LogSource.BRIDGE,
795
+ line_labels={},
796
+ )
738
797
 
739
798
  # Add a callback which will stop the server
740
799
  # after the task is finished
@@ -771,7 +830,12 @@ class ControllerAuthInterceptor(ServerBoundInterceptor):
771
830
  ]
772
831
 
773
832
  if handler_call_details.method in skipped_auth_methods:
774
- print(f"[debug] Skipping authentication for {handler_call_details.method}")
833
+ _SERVER_LOGGER.log(
834
+ LogLevel.DEBUG,
835
+ f"Skipping authentication for {handler_call_details.method}",
836
+ source=LogSource.BRIDGE,
837
+ line_labels={},
838
+ )
775
839
  # Let these requests pass through without authentication
776
840
  return continuation(handler_call_details)
777
841
 
@@ -816,9 +880,12 @@ def main(argv: list[str] | None = None) -> None:
816
880
  # Set an interceptor to only accept requests with the correct auth key
817
881
  interceptors.append(ControllerAuthInterceptor(controller_auth_key))
818
882
  else:
819
- print(
820
- "[WARN] ISOLATE_CONTROLLER_AUTH_KEY is not set, all requests will be "
821
- "accepted without authentication."
883
+ _SERVER_LOGGER.log(
884
+ LogLevel.WARNING,
885
+ "ISOLATE_CONTROLLER_AUTH_KEY is not set, all requests will be "
886
+ "accepted without authentication.",
887
+ source=LogSource.BRIDGE,
888
+ line_labels={},
822
889
  )
823
890
 
824
891
  server = grpc.server(
@@ -839,6 +906,12 @@ def main(argv: list[str] | None = None) -> None:
839
906
  definitions.register_isolate(servicer, server)
840
907
  health.register_health(HealthServicer(), server)
841
908
 
909
+ # Signal handlers intentionally use bare print(): IsolateLogger.log()
910
+ # calls datetime.now() and json.dumps() which allocate memory, and
911
+ # heap allocation in a Python signal handler is unsafe (can deadlock
912
+ # if the main thread is mid-allocation). These messages also fire at
913
+ # shutdown — well past the first stdout line — so they don't affect
914
+ # Vector's file fingerprint.
842
915
  def handle_termination(*args):
843
916
  print("Termination signal received, shutting down...")
844
917
  servicer.shutdown()
@@ -853,11 +926,21 @@ def main(argv: list[str] | None = None) -> None:
853
926
  signal.signal(signal.SIGCHLD, handle_child_termination)
854
927
 
855
928
  server.add_insecure_port(f"[::]:{options.port}")
856
- print(f"Started listening at {options.host}:{options.port}")
929
+ _SERVER_LOGGER.log(
930
+ LogLevel.INFO,
931
+ f"Started listening at {options.host}:{options.port}",
932
+ source=LogSource.BRIDGE,
933
+ line_labels={},
934
+ )
857
935
 
858
936
  server.start()
859
937
  server.wait_for_termination()
860
- print("Server shut down")
938
+ _SERVER_LOGGER.log(
939
+ LogLevel.INFO,
940
+ "Server shut down",
941
+ source=LogSource.BRIDGE,
942
+ line_labels={},
943
+ )
861
944
 
862
945
 
863
946
  if __name__ == "__main__":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: isolate
3
- Version: 0.26.4
3
+ Version: 0.26.6
4
4
  Summary: Managed isolated environments for Python
5
5
  Author-email: Features & Labels <hello@fal.ai>
6
6
  Project-URL: Issues, https://github.com/fal-ai/isolate/issues
@@ -79,6 +79,7 @@ tests/test_connections_grpc_base.py
79
79
  tests/test_isolate.py
80
80
  tests/test_log.py
81
81
  tests/test_log_masking.py
82
+ tests/test_logged_io_pipe.py
82
83
  tests/test_logger.py
83
84
  tests/test_serialization.py
84
85
  tests/test_server.py
@@ -1,7 +1,7 @@
1
1
  import asyncio
2
2
  import os
3
3
  from pathlib import Path
4
- from unittest.mock import Mock, patch
4
+ from unittest.mock import Mock, call, patch
5
5
 
6
6
  import grpc
7
7
  import pytest
@@ -51,11 +51,23 @@ def test_abort_agent_logs_return_code_for_graceful_termination(tmp_path: Path) -
51
51
  process.terminate.assert_called_once()
52
52
  process.wait.assert_called_once()
53
53
  process.kill.assert_not_called()
54
- connection.log.assert_called_once_with(
55
- "Isolate agent gracefully terminated",
56
- level=LogLevel.INFO,
57
- source=LogSource.BRIDGE,
58
- )
54
+ assert connection.log.call_args_list == [
55
+ call(
56
+ "Terminating the agent process...",
57
+ level=LogLevel.INFO,
58
+ source=LogSource.BRIDGE,
59
+ ),
60
+ call(
61
+ "Agent process shutdown gracefully",
62
+ level=LogLevel.INFO,
63
+ source=LogSource.BRIDGE,
64
+ ),
65
+ call(
66
+ "Isolate agent gracefully terminated",
67
+ level=LogLevel.INFO,
68
+ source=LogSource.BRIDGE,
69
+ ),
70
+ ]
59
71
  assert connection._process is None
60
72
 
61
73
 
@@ -73,11 +85,23 @@ def test_abort_agent_logs_return_code_after_kill_fallback(tmp_path: Path) -> Non
73
85
  process.terminate.assert_called_once()
74
86
  process.kill.assert_called_once()
75
87
  process.wait.assert_called_once()
76
- connection.log.assert_called_once_with(
77
- "Isolate agent forcefully killed",
78
- level=LogLevel.INFO,
79
- source=LogSource.BRIDGE,
80
- )
88
+ assert connection.log.call_args_list == [
89
+ call(
90
+ "Terminating the agent process...",
91
+ level=LogLevel.INFO,
92
+ source=LogSource.BRIDGE,
93
+ ),
94
+ call(
95
+ "Failed to shutdown the agent process gracefully: terminate failed",
96
+ level=LogLevel.ERROR,
97
+ source=LogSource.BRIDGE,
98
+ ),
99
+ call(
100
+ "Isolate agent forcefully killed",
101
+ level=LogLevel.INFO,
102
+ source=LogSource.BRIDGE,
103
+ ),
104
+ ]
81
105
  assert connection._process is None
82
106
 
83
107
 
@@ -0,0 +1,132 @@
1
+ """Regression tests for the pipe wiring used by `logged_io`.
2
+
3
+ Background: `_unblocked_pipe()` is consumed by `logged_io()`, where the
4
+ *read* end is drained from a reader thread (`_io_observer`) using
5
+ `select()` + `os.read()`. Only the read end needs to be non-blocking
6
+ (so the post-`select` read can never hang on a partial-line edge case).
7
+
8
+ Historically both ends were marked non-blocking. That makes the *write*
9
+ end raise `BlockingIOError` (errno EAGAIN) the moment the kernel pipe
10
+ buffer (~64 KB on Linux) fills up before the reader thread drains it —
11
+ which is exactly what happens when a worker emits a chunky traceback.
12
+ With a blocking write end, writers apply backpressure instead.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import subprocess
19
+ import sys
20
+ import threading
21
+
22
+ import pytest
23
+ from isolate.backends.common import _unblocked_pipe, logged_io
24
+
25
+
26
+ def test_unblocked_pipe_write_end_is_blocking():
27
+ """The write end must be blocking so that worker stdout/stderr applies
28
+ backpressure instead of raising BlockingIOError when the pipe buffer fills."""
29
+ read_fd, write_fd = _unblocked_pipe()
30
+ try:
31
+ assert not os.get_blocking(read_fd), "read end must be non-blocking"
32
+ assert os.get_blocking(write_fd), (
33
+ "write end must be blocking so the worker gets backpressure "
34
+ "instead of BlockingIOError when the pipe buffer fills up"
35
+ )
36
+ finally:
37
+ os.close(read_fd)
38
+ os.close(write_fd)
39
+
40
+
41
+ def test_writing_more_than_pipe_buffer_does_not_raise_blocking_io_error():
42
+ """Direct reproducer: write more than the kernel pipe buffer (64 KB on
43
+ Linux) through a Python BufferedWriter on top of the pipe's write end,
44
+ while a reader thread drains it slowly. With a non-blocking write end
45
+ this raises `BlockingIOError`; with a blocking write end the writer
46
+ transparently waits for the reader to make room.
47
+ """
48
+ read_fd, write_fd = _unblocked_pipe()
49
+ drained = bytearray()
50
+ drain_done = threading.Event()
51
+
52
+ def drain():
53
+ # Slow reader: read in small chunks with a `select` loop, mimicking
54
+ # `_io_observer`'s pattern but slower so the pipe buffer fills up.
55
+ import select as _select
56
+
57
+ while not drain_done.is_set():
58
+ ready, _, _ = _select.select([read_fd], [], [], 0.05)
59
+ if read_fd in ready:
60
+ try:
61
+ chunk = os.read(read_fd, 4096)
62
+ except BlockingIOError:
63
+ continue
64
+ if not chunk:
65
+ return
66
+ drained.extend(chunk)
67
+
68
+ reader = threading.Thread(target=drain)
69
+ reader.start()
70
+ try:
71
+ # 512 KB is well past the 64 KB pipe buffer on Linux.
72
+ payload = b"x" * (512 * 1024)
73
+ with open(write_fd, "wb", closefd=False) as stream:
74
+ stream.write(payload)
75
+ stream.flush()
76
+ # Give the reader a moment to catch up.
77
+ deadline_loops = 50
78
+ while len(drained) < len(payload) and deadline_loops > 0:
79
+ deadline_loops -= 1
80
+ drain_done.wait(0.05)
81
+ finally:
82
+ drain_done.set()
83
+ os.close(write_fd)
84
+ reader.join(timeout=2)
85
+ os.close(read_fd)
86
+
87
+ assert len(drained) == len(
88
+ payload
89
+ ), f"reader only drained {len(drained)} of {len(payload)} bytes"
90
+
91
+
92
+ @pytest.mark.skipif(sys.platform == "win32", reason="pipes / pass_fds are POSIX-y")
93
+ def test_logged_io_subprocess_emitting_large_output_does_not_crash():
94
+ """End-to-end reproducer mirroring how `logged_io` is used in
95
+ `isolate.connections._local._base`: a child process writes a chunky
96
+ payload (~256 KB) to stdout. With a non-blocking write end the child's
97
+ Python runtime raises `BlockingIOError` when flushing stdout; with
98
+ backpressure the child completes cleanly and the parent receives
99
+ every line."""
100
+
101
+ captured: list[str] = []
102
+ lock = threading.Lock()
103
+
104
+ def record(line: str) -> None:
105
+ with lock:
106
+ captured.append(line)
107
+
108
+ n_lines = 4000
109
+ line_template = "trace-{idx:08d}-" + ("y" * 50)
110
+ child_script = (
111
+ "import sys\n"
112
+ f"for i in range({n_lines}):\n"
113
+ f" sys.stdout.write({line_template!r}.format(idx=i) + '\\n')\n"
114
+ "sys.stdout.flush()\n"
115
+ )
116
+
117
+ with logged_io(record) as (stdout, stderr, log_fd):
118
+ proc = subprocess.run(
119
+ [sys.executable, "-u", "-c", child_script],
120
+ stdout=stdout,
121
+ stderr=stderr,
122
+ pass_fds=(log_fd,),
123
+ text=True,
124
+ check=False,
125
+ )
126
+
127
+ assert proc.returncode == 0, (
128
+ f"child exited with {proc.returncode}; with a non-blocking write end "
129
+ "Python's stdout buffer raises BlockingIOError on large flushes"
130
+ )
131
+ # All `n_lines` should have been captured by the observer thread.
132
+ assert len(captured) == n_lines, f"expected {n_lines} lines, got {len(captured)}"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes