langfun 0.1.2.dev202509120804__py3-none-any.whl → 0.1.2.dev202512040805__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. langfun/__init__.py +1 -1
  2. langfun/core/__init__.py +7 -1
  3. langfun/core/agentic/__init__.py +8 -1
  4. langfun/core/agentic/action.py +740 -112
  5. langfun/core/agentic/action_eval.py +9 -2
  6. langfun/core/agentic/action_test.py +189 -24
  7. langfun/core/async_support.py +104 -5
  8. langfun/core/async_support_test.py +23 -0
  9. langfun/core/coding/python/correction.py +19 -9
  10. langfun/core/coding/python/execution.py +14 -12
  11. langfun/core/coding/python/generation.py +21 -16
  12. langfun/core/coding/python/sandboxing.py +23 -3
  13. langfun/core/component.py +42 -3
  14. langfun/core/concurrent.py +70 -6
  15. langfun/core/concurrent_test.py +9 -2
  16. langfun/core/console.py +1 -1
  17. langfun/core/data/conversion/anthropic.py +12 -3
  18. langfun/core/data/conversion/anthropic_test.py +8 -6
  19. langfun/core/data/conversion/gemini.py +11 -2
  20. langfun/core/data/conversion/gemini_test.py +48 -9
  21. langfun/core/data/conversion/openai.py +145 -31
  22. langfun/core/data/conversion/openai_test.py +161 -17
  23. langfun/core/eval/base.py +48 -44
  24. langfun/core/eval/base_test.py +5 -5
  25. langfun/core/eval/matching.py +5 -2
  26. langfun/core/eval/patching.py +3 -3
  27. langfun/core/eval/scoring.py +4 -3
  28. langfun/core/eval/v2/__init__.py +2 -0
  29. langfun/core/eval/v2/checkpointing.py +76 -7
  30. langfun/core/eval/v2/checkpointing_test.py +9 -2
  31. langfun/core/eval/v2/config_saver.py +37 -0
  32. langfun/core/eval/v2/config_saver_test.py +36 -0
  33. langfun/core/eval/v2/eval_test_helper.py +104 -3
  34. langfun/core/eval/v2/evaluation.py +92 -17
  35. langfun/core/eval/v2/evaluation_test.py +9 -3
  36. langfun/core/eval/v2/example.py +50 -40
  37. langfun/core/eval/v2/example_test.py +16 -8
  38. langfun/core/eval/v2/experiment.py +84 -15
  39. langfun/core/eval/v2/experiment_test.py +19 -0
  40. langfun/core/eval/v2/metric_values.py +31 -3
  41. langfun/core/eval/v2/metric_values_test.py +32 -0
  42. langfun/core/eval/v2/metrics.py +157 -44
  43. langfun/core/eval/v2/metrics_test.py +39 -18
  44. langfun/core/eval/v2/progress.py +31 -1
  45. langfun/core/eval/v2/progress_test.py +27 -0
  46. langfun/core/eval/v2/progress_tracking.py +13 -5
  47. langfun/core/eval/v2/progress_tracking_test.py +9 -1
  48. langfun/core/eval/v2/reporting.py +90 -71
  49. langfun/core/eval/v2/reporting_test.py +24 -6
  50. langfun/core/eval/v2/runners/__init__.py +30 -0
  51. langfun/core/eval/v2/{runners.py → runners/base.py} +72 -180
  52. langfun/core/eval/v2/runners/beam.py +354 -0
  53. langfun/core/eval/v2/runners/beam_test.py +153 -0
  54. langfun/core/eval/v2/runners/ckpt_monitor.py +294 -0
  55. langfun/core/eval/v2/runners/ckpt_monitor_test.py +162 -0
  56. langfun/core/eval/v2/runners/debug.py +40 -0
  57. langfun/core/eval/v2/runners/debug_test.py +76 -0
  58. langfun/core/eval/v2/runners/parallel.py +243 -0
  59. langfun/core/eval/v2/runners/parallel_test.py +182 -0
  60. langfun/core/eval/v2/runners/sequential.py +47 -0
  61. langfun/core/eval/v2/runners/sequential_test.py +169 -0
  62. langfun/core/langfunc.py +45 -130
  63. langfun/core/langfunc_test.py +7 -5
  64. langfun/core/language_model.py +189 -36
  65. langfun/core/language_model_test.py +54 -3
  66. langfun/core/llms/__init__.py +12 -1
  67. langfun/core/llms/anthropic.py +157 -2
  68. langfun/core/llms/azure_openai.py +29 -17
  69. langfun/core/llms/cache/base.py +25 -3
  70. langfun/core/llms/cache/in_memory.py +48 -7
  71. langfun/core/llms/cache/in_memory_test.py +14 -4
  72. langfun/core/llms/compositional.py +25 -1
  73. langfun/core/llms/deepseek.py +30 -2
  74. langfun/core/llms/fake.py +32 -1
  75. langfun/core/llms/gemini.py +64 -12
  76. langfun/core/llms/gemini_test.py +110 -0
  77. langfun/core/llms/google_genai.py +34 -1
  78. langfun/core/llms/groq.py +28 -3
  79. langfun/core/llms/llama_cpp.py +23 -4
  80. langfun/core/llms/openai.py +120 -3
  81. langfun/core/llms/openai_compatible.py +148 -27
  82. langfun/core/llms/openai_compatible_test.py +207 -20
  83. langfun/core/llms/openai_test.py +0 -2
  84. langfun/core/llms/rest.py +16 -1
  85. langfun/core/llms/vertexai.py +58 -8
  86. langfun/core/logging.py +1 -1
  87. langfun/core/mcp/__init__.py +10 -0
  88. langfun/core/mcp/client.py +177 -0
  89. langfun/core/mcp/client_test.py +71 -0
  90. langfun/core/mcp/session.py +241 -0
  91. langfun/core/mcp/session_test.py +54 -0
  92. langfun/core/mcp/testing/simple_mcp_client.py +33 -0
  93. langfun/core/mcp/testing/simple_mcp_server.py +33 -0
  94. langfun/core/mcp/tool.py +254 -0
  95. langfun/core/mcp/tool_test.py +197 -0
  96. langfun/core/memory.py +1 -0
  97. langfun/core/message.py +160 -55
  98. langfun/core/message_test.py +65 -81
  99. langfun/core/modalities/__init__.py +8 -0
  100. langfun/core/modalities/audio.py +21 -1
  101. langfun/core/modalities/image.py +73 -3
  102. langfun/core/modalities/image_test.py +116 -0
  103. langfun/core/modalities/mime.py +64 -3
  104. langfun/core/modalities/mime_test.py +11 -0
  105. langfun/core/modalities/pdf.py +19 -1
  106. langfun/core/modalities/video.py +21 -1
  107. langfun/core/modality.py +167 -29
  108. langfun/core/modality_test.py +42 -12
  109. langfun/core/natural_language.py +1 -1
  110. langfun/core/sampling.py +4 -4
  111. langfun/core/sampling_test.py +20 -4
  112. langfun/core/structured/__init__.py +2 -24
  113. langfun/core/structured/completion.py +34 -44
  114. langfun/core/structured/completion_test.py +23 -43
  115. langfun/core/structured/description.py +54 -50
  116. langfun/core/structured/function_generation.py +29 -12
  117. langfun/core/structured/mapping.py +81 -37
  118. langfun/core/structured/parsing.py +95 -79
  119. langfun/core/structured/parsing_test.py +0 -3
  120. langfun/core/structured/querying.py +230 -154
  121. langfun/core/structured/querying_test.py +69 -33
  122. langfun/core/structured/schema/__init__.py +49 -0
  123. langfun/core/structured/schema/base.py +664 -0
  124. langfun/core/structured/schema/base_test.py +531 -0
  125. langfun/core/structured/schema/json.py +174 -0
  126. langfun/core/structured/schema/json_test.py +121 -0
  127. langfun/core/structured/schema/python.py +316 -0
  128. langfun/core/structured/schema/python_test.py +410 -0
  129. langfun/core/structured/schema_generation.py +33 -14
  130. langfun/core/structured/scoring.py +47 -36
  131. langfun/core/structured/tokenization.py +26 -11
  132. langfun/core/subscription.py +2 -2
  133. langfun/core/template.py +175 -50
  134. langfun/core/template_test.py +123 -17
  135. langfun/env/__init__.py +43 -0
  136. langfun/env/base_environment.py +827 -0
  137. langfun/env/base_environment_test.py +473 -0
  138. langfun/env/base_feature.py +304 -0
  139. langfun/env/base_feature_test.py +228 -0
  140. langfun/env/base_sandbox.py +842 -0
  141. langfun/env/base_sandbox_test.py +1235 -0
  142. langfun/env/event_handlers/__init__.py +14 -0
  143. langfun/env/event_handlers/chain.py +233 -0
  144. langfun/env/event_handlers/chain_test.py +253 -0
  145. langfun/env/event_handlers/event_logger.py +472 -0
  146. langfun/env/event_handlers/event_logger_test.py +304 -0
  147. langfun/env/event_handlers/metric_writer.py +726 -0
  148. langfun/env/event_handlers/metric_writer_test.py +214 -0
  149. langfun/env/interface.py +1640 -0
  150. langfun/env/interface_test.py +153 -0
  151. langfun/env/load_balancers.py +59 -0
  152. langfun/env/load_balancers_test.py +141 -0
  153. langfun/env/test_utils.py +507 -0
  154. {langfun-0.1.2.dev202509120804.dist-info → langfun-0.1.2.dev202512040805.dist-info}/METADATA +7 -3
  155. langfun-0.1.2.dev202512040805.dist-info/RECORD +217 -0
  156. langfun/core/eval/v2/runners_test.py +0 -343
  157. langfun/core/structured/schema.py +0 -987
  158. langfun/core/structured/schema_test.py +0 -982
  159. langfun-0.1.2.dev202509120804.dist-info/RECORD +0 -172
  160. {langfun-0.1.2.dev202509120804.dist-info → langfun-0.1.2.dev202512040805.dist-info}/WHEEL +0 -0
  161. {langfun-0.1.2.dev202509120804.dist-info → langfun-0.1.2.dev202512040805.dist-info}/licenses/LICENSE +0 -0
  162. {langfun-0.1.2.dev202509120804.dist-info → langfun-0.1.2.dev202512040805.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,842 @@
1
+ # Copyright 2025 The Langfun Authors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Common base class for sandboxes.
15
+
16
+ This module provides an base class `BaseSandbox` for sandboxes, which takes care
17
+ of the sandbox lifecycle and housekeeping. It also provides a decorator for
18
+ sandbox service methods to handle errors and trigger shutdowns. Please note that
19
+ this base class is intended to provide a convenient way to implement sandboxes,
20
+ and not all sandbox implementations need to subclass it. Also `BaseSandbox`
21
+ is not coupled with `BaseEnvironment` and `BaseFeature`, and is expected to
22
+ work with the `Environment` and `Feature` interfaces directly.
23
+ """
24
+
25
+ import contextlib
26
+ import functools
27
+ import threading
28
+ import time
29
+ from typing import Annotated, Any, Iterator
30
+
31
+ from langfun.env import interface
32
+ import pyglove as pg
33
+
34
+
35
+ class BaseSandbox(interface.Sandbox):
36
+ """Base class for a sandbox."""
37
+
38
+ id: Annotated[
39
+ interface.Sandbox.Id,
40
+ 'The identifier for the sandbox.'
41
+ ]
42
+
43
+ image_id: Annotated[
44
+ str,
45
+ 'The image id for the sandbox.'
46
+ ]
47
+
48
+ environment: Annotated[
49
+ pg.Ref[interface.Environment],
50
+ 'The parent environment.'
51
+ ]
52
+
53
+ reusable: Annotated[
54
+ bool,
55
+ (
56
+ 'If True, the sandbox can be reused for multiple user sessions at '
57
+ 'different times.'
58
+ )
59
+ ] = False
60
+
61
+ keepalive_interval: Annotated[
62
+ float | None,
63
+ 'Interval to ping the sandbox for keeping it alive..'
64
+ ] = 60.0
65
+
66
+ proactive_session_setup: Annotated[
67
+ bool,
68
+ (
69
+ 'If True, the sandbox will perform setup work before a user session '
70
+ 'is started. This is useful for sandboxes that need to perform '
71
+ 'heavy setup work, which could block the user thread for a long '
72
+ 'time. Applicable only when `reusable` is True.'
73
+ )
74
+ ] = True
75
+
76
+ #
77
+ # There is no required methods that subclasses must implement.
78
+ # Subclasses can override the following methods:
79
+ #
80
+
81
+ def _start(self) -> None:
82
+ """Implementation of start(). Subclasses can override.
83
+
84
+ Raises:
85
+ interface.SandboxStateError: If the sandbox is in a bad state.
86
+ """
87
+
88
+ def _shutdown(self) -> None:
89
+ """Implementation of shutdown(). Subclasses can override.
90
+
91
+ Raises:
92
+ interface.SandboxStateError: If the sandbox is in a bad state.
93
+ """
94
+
95
+ def _set_status(self, status: interface.Sandbox.Status) -> None:
96
+ """Sets the status of the sandbox."""
97
+ assert self._status != status, (self._status, status)
98
+ self.on_status_change(self._status, status)
99
+ self._status = status
100
+ self._status_start_time = time.time()
101
+
102
+ def report_state_error(self, e: interface.SandboxStateError) -> None:
103
+ """Reports sandbox state errors."""
104
+ if e not in self._state_errors:
105
+ self._state_errors.append(e)
106
+
107
+ def _setup_features(self) -> None:
108
+ """Starts the features in the sandbox."""
109
+ # We keep track of the features that have setup called so we can teardown
110
+ # the features when the sandbox is shutdown.
111
+ self._features_with_setup_called.clear()
112
+
113
+ for feature in self._features.values():
114
+ self._features_with_setup_called.add(feature.name)
115
+ feature.setup(self)
116
+
117
+ def _setup_session(self) -> None:
118
+ """Sets up a new session for the sandbox."""
119
+ # We keep track of the features that have setup_session called so we can
120
+ # teardown the session for them when the session ends.
121
+ self._features_with_setup_session_called.clear()
122
+
123
+ for feature in self._features.values():
124
+ self._features_with_setup_session_called.add(feature.name)
125
+ feature.setup_session()
126
+
127
+ def _teardown_features(self) -> interface.FeatureTeardownError | None:
128
+ """Tears down the features in the sandbox.
129
+
130
+ IMPORTANT: This method shall not raise any exceptions.
131
+
132
+ Returns:
133
+ FeatureTeardownError: If feature teardown failed with errors.
134
+ Otherwise None.
135
+ """
136
+ errors = {}
137
+ for feature in self._features.values():
138
+ if feature.name in self._features_with_setup_called:
139
+ try:
140
+ feature.teardown()
141
+ except BaseException as e: # pylint: disable=broad-except
142
+ if isinstance(e, interface.SandboxStateError):
143
+ self.report_state_error(e)
144
+ errors[feature.name] = e
145
+ if errors:
146
+ return interface.FeatureTeardownError(sandbox=self, errors=errors)
147
+ return None
148
+
149
+ def _start_session(self) -> None:
150
+ """Starts a user session.
151
+
152
+ Raises:
153
+ BaseException: If feature setup failed with user-defined errors.
154
+ SandboxStateError: If sandbox or feature setup fail due sandbox state
155
+ errors.
156
+ """
157
+ # When pre-session setup is enabled, the session setup is performed
158
+ # before the session is started. Otherwise we setup the session here.
159
+ if not self._enable_pre_session_setup:
160
+ self._setup_session()
161
+
162
+ def _end_session(self) -> interface.SessionTeardownError | None:
163
+ """Ends a user session.
164
+
165
+ IMPORTANT: This method shall not raise any exceptions.
166
+
167
+ Returns:
168
+ SessionTeardownError: If session teardown failed with errors.
169
+ Otherwise None.
170
+ """
171
+ feature_teardown_errors = {}
172
+ for name, feature in self._features.items():
173
+ if name in self._features_with_setup_session_called:
174
+ try:
175
+ feature.teardown_session()
176
+ except BaseException as e: # pylint: disable=broad-except
177
+ if isinstance(e, interface.SandboxStateError):
178
+ self.report_state_error(e)
179
+ feature_teardown_errors[name] = e
180
+
181
+ return interface.SessionTeardownError(
182
+ sandbox=self, errors=feature_teardown_errors
183
+ ) if feature_teardown_errors else None
184
+
185
+ def _ping(self) -> None:
186
+ """Implementation of ping for health checking."""
187
+
188
+ #
189
+ # Sandbox basics.
190
+ #
191
+
192
+ def _on_bound(self) -> None:
193
+ """Called when the sandbox is bound."""
194
+ super()._on_bound()
195
+ self._features = pg.Dict({
196
+ name: pg.clone(feature)
197
+ for name, feature in self.environment.features.items()
198
+ if feature.is_applicable(self.image_id)
199
+ })
200
+ self._event_handler = self.environment.event_handler
201
+ self._enable_pre_session_setup = (
202
+ self.reusable and self.proactive_session_setup
203
+ )
204
+ self._enables_housekeep = (
205
+ self.keepalive_interval is not None
206
+ or any(
207
+ feature.housekeep_interval is not None
208
+ for feature in self._features.values()
209
+ )
210
+ )
211
+ self._housekeep_thread = None
212
+ self._housekeep_counter = 0
213
+
214
+ # Runtime state.
215
+ self._status = self.Status.CREATED
216
+ self._status_start_time = time.time()
217
+
218
+ self._start_time = None
219
+ self._state_errors = []
220
+
221
+ self._features_with_setup_called = set()
222
+ self._features_with_setup_session_called = set()
223
+
224
+ self._session_id = None
225
+ self._session_start_time = None
226
+
227
+ # Thread local state for this sandbox.
228
+ self._tls_state = threading.local()
229
+
230
+ @functools.cached_property
231
+ def working_dir(self) -> str | None:
232
+ """Returns the working directory for the sandbox."""
233
+ return self.id.working_dir(self.environment.root_dir)
234
+
235
+ @property
236
+ def status(self) -> interface.Sandbox.Status:
237
+ """Returns the state of the sandbox."""
238
+ return self._status
239
+
240
+ def set_acquired(self) -> None:
241
+ """Marks the sandbox as acquired."""
242
+ self._set_status(self.Status.ACQUIRED)
243
+
244
+ @property
245
+ def housekeep_counter(self) -> int:
246
+ """Returns the housekeeping counter."""
247
+ return self._housekeep_counter
248
+
249
+ @property
250
+ def state_errors(self) -> list[interface.SandboxStateError]:
251
+ """Returns all errors encountered during sandbox lifecycle."""
252
+ return self._state_errors
253
+
254
+ @property
255
+ def is_shutting_down(self) -> bool:
256
+ """Returns True if the sandbox is shutting down."""
257
+ return self._status == self.Status.SHUTTING_DOWN or (
258
+ self._state_errors and self._status == self.Status.EXITING_SESSION
259
+ )
260
+
261
+ @property
262
+ def features(self) -> dict[str, interface.Feature]:
263
+ """Returns the features in the sandbox."""
264
+ return self._features
265
+
266
+ #
267
+ # Sandbox start/shutdown.
268
+ #
269
+
270
+ def start(self) -> None:
271
+ """Starts the sandbox.
272
+
273
+ State transitions:
274
+ CREATED -> SETTING_UP -> READY: When all sandbox and feature setup
275
+ succeeds.
276
+ CREATED -> SETTING_UP -> SHUTTING_DOWN -> OFFLINE: When sandbox or feature
277
+ setup fails.
278
+
279
+ `start` and `shutdown` should be called in pairs, even when the sandbox
280
+ fails to start. This ensures proper cleanup.
281
+
282
+ Start may fail with two sources of errors:
283
+
284
+ 1. SandboxStateError: If sandbox or feature setup fail due to enviroment
285
+ outage or sandbox state errors.
286
+ 2. BaseException: If feature setup failed with user-defined errors, this
287
+ could happen when there is bug in the user code or non-environment code
288
+ failure.
289
+
290
+ In both cases, the sandbox will be shutdown automatically, and the error
291
+ will be added to `errors`. The sandbox is considered dead and will not be
292
+ further used.
293
+
294
+ Raises:
295
+ SandboxStateError: If the sandbox is in a bad state.
296
+ BaseException: If feature setup failed with user-defined errors.
297
+ """
298
+ assert self._status == self.Status.CREATED, (
299
+ f'Sandbox {self.id} cannot be started because '
300
+ f'it is in {self._status} status.'
301
+ )
302
+
303
+ starting_time = time.time()
304
+ self._state = self.Status.SETTING_UP
305
+
306
+ try:
307
+ # Start the sandbox.
308
+ self._start()
309
+
310
+ # Setup the features.
311
+ self._setup_features()
312
+
313
+ # Setup the first session if pre-session setup is enabled.
314
+ if self._enable_pre_session_setup:
315
+ self._setup_session()
316
+
317
+ if self._enables_housekeep:
318
+ self._housekeep_thread = threading.Thread(
319
+ target=self._housekeep_loop, daemon=True
320
+ )
321
+ self._housekeep_thread.start()
322
+
323
+ self._start_time = time.time()
324
+
325
+ # Mark the sandbox as ready when all setup succeeds.
326
+ self._set_status(self.Status.READY)
327
+
328
+ duration = time.time() - starting_time
329
+ self.on_start(duration)
330
+ except BaseException as e: # pylint: disable=broad-except
331
+ duration = time.time() - starting_time
332
+ pg.logging.error(
333
+ '[%s]: Sandbox failed to start in %.2f seconds: %s',
334
+ self.id, duration, e
335
+ )
336
+ if isinstance(e, interface.SandboxStateError):
337
+ self.report_state_error(e)
338
+ self.on_start(duration, e)
339
+ self.shutdown()
340
+ raise e
341
+
342
+ def shutdown(self) -> None:
343
+ """Shuts down the sandbox.
344
+
345
+ State transitions:
346
+ SHUTTING_DOWN -> SHUTTING_DOWN: No operation.
347
+ OFFLINE -> OFFLINE: No operation.
348
+ SETTING_UP -> SHUTTING_DOWN -> OFFLINE: When sandbox and feature
349
+ setup fails.
350
+ IN_SESSION -> SHUTTING_DOWN -> OFFLINE: When user session exits while
351
+ sandbox is set not to reuse, or session teardown fails.
352
+ FREE -> SHUTTING_DOWN -> OFFLINE: When sandbox is shutdown when the
353
+ environment is shutting down, or housekeeping loop shuts down the
354
+ sandbox due to housekeeping failures.
355
+
356
+ Please be aware that `shutdown` will be called whenever an operation on the
357
+ sandbox encounters a critical error. This means, `shutdown` should not make
358
+ the assumption that the sandbox is in a healthy state, even `start` could
359
+ fail. As a result, `shutdown` must allow re-entry and be thread-safe with
360
+ other sandbox operations.
361
+
362
+ Shutdown may fail with two sources of errors:
363
+
364
+ 1. SandboxStateError: If the sandbox is in a bad state, and feature teardown
365
+ logic depending on a healthy sandbox may fail. In such case, we do not
366
+ raise error to the user as the user session is considered completed. The
367
+ sandbox is abandoned and new user sessions will be served on other
368
+ sandboxes.
369
+
370
+ 2. BaseException: The sandbox is in good state, but user code raises error
371
+ due to bug or non-environment code failure. In such case, errors will be
372
+ raised to the user so the error could be surfaced and handled properly.
373
+ The sandbox is treated as shutdown and will not be further used.
374
+
375
+ Raises:
376
+ BaseException: If feature teardown failed with user-defined errors.
377
+ """
378
+
379
+ # Allow re-entry.
380
+ if self._status in (
381
+ interface.Sandbox.Status.SHUTTING_DOWN,
382
+ interface.Sandbox.Status.OFFLINE
383
+ ):
384
+ return
385
+
386
+ # End current session and shutdown the sandbox if the sandbox is in session.
387
+ if self._status == self.Status.IN_SESSION:
388
+ self.end_session(shutdown_sandbox=True)
389
+ return
390
+
391
+ shutting_down_time = time.time()
392
+ self._set_status(interface.Sandbox.Status.SHUTTING_DOWN)
393
+
394
+ if (self._housekeep_thread is not None
395
+ and threading.current_thread() is not self._housekeep_thread):
396
+ self._housekeep_thread.join()
397
+ self._housekeep_thread = None
398
+
399
+ teardown_error = self._teardown_features()
400
+ try:
401
+ self._shutdown()
402
+ self._set_status(interface.Sandbox.Status.OFFLINE)
403
+ self.on_shutdown(
404
+ duration=time.time() - shutting_down_time,
405
+ error=teardown_error
406
+ )
407
+ shutdown_error = None
408
+ except BaseException as e: # pylint: disable=broad-except
409
+ shutdown_error = e
410
+ if isinstance(e, interface.SandboxStateError):
411
+ self.report_state_error(e)
412
+ self._set_status(interface.Sandbox.Status.OFFLINE)
413
+ pg.logging.error(
414
+ '[%s]: Sandbox shutdown with error: %s',
415
+ self.id, e
416
+ )
417
+ self.on_shutdown(
418
+ duration=time.time() - shutting_down_time,
419
+ error=teardown_error or shutdown_error
420
+ )
421
+
422
+ # We raise non-state errors to the user following timely order, so the user
423
+ # code could be surfaced and handled properly.
424
+ if (teardown_error is not None
425
+ and teardown_error.has_non_sandbox_state_error):
426
+ raise teardown_error
427
+
428
+ if shutdown_error is not None and not isinstance(
429
+ shutdown_error, interface.SandboxStateError
430
+ ):
431
+ raise shutdown_error
432
+
433
+ def ping(self) -> None:
434
+ """Pings the sandbox to check if it is alive."""
435
+ self._ping()
436
+
437
+ #
438
+ # API related to a user session.
439
+ # A sandbox could be reused across different user sessions.
440
+ # A user session is a sequence of stateful interactions with the sandbox,
441
+ # Across different sessions the sandbox are considered stateless.
442
+ #
443
+
444
+ @property
445
+ def session_id(self) -> str | None:
446
+ """Returns the current user session identifier.
447
+
448
+ session_id is set to None when the sandbox is free, and set to 'pending'
449
+ when the sandbox is acquired by a thread, before a user session is started.
450
+
451
+ Returns:
452
+ The current user session identifier or None if the sandbox is not busy.
453
+ """
454
+ return self._session_id
455
+
456
+ def start_session(
457
+ self,
458
+ session_id: str,
459
+ ) -> None:
460
+ """Begins a user session with the sandbox.
461
+
462
+ State transitions:
463
+ ACQUIRED -> SETTING_UP -> IN_SESSION: When session setup succeeds.
464
+ ACQUIRED -> SETTING_UP -> SHUTTING_DOWN -> OFFLINE: When session setup
465
+ fails.
466
+
467
+ A session is a sequence of stateful interactions with the sandbox.
468
+ Across different sessions the sandbox are considered stateless.
469
+ `start_session` and `end_session` should always be called in pairs, even
470
+ when the session fails to start. `Sandbox.new_session` context manager is
471
+ the recommended way to use `start_session` and `end_session` in pairs.
472
+
473
+ Starting a session may fail with two sources of errors:
474
+
475
+ 1. SandboxStateError: If the sandbox is in a bad state or session setup
476
+ failed.
477
+
478
+ 2. BaseException: If session setup failed with user-defined errors.
479
+
480
+ In both cases, the sandbox will be shutdown automatically and the
481
+ session will be considered ended. The error will be added to `errors`.
482
+ Future session will be served on other sandboxes.
483
+
484
+ Args:
485
+ session_id: The identifier for the user session.
486
+
487
+ Raises:
488
+ SandboxStateError: If the sandbox is already in a bad state or session
489
+ setup failed.
490
+ BaseException: If session setup failed with user-defined errors.
491
+ """
492
+ assert self._status == self.Status.ACQUIRED, (
493
+ f'Sandbox {self.id} is not in acquired state (status={self._status}).'
494
+ )
495
+ assert self._session_id is None, (
496
+ f'A user session {self._session_id} is already active '
497
+ f'for sandbox {self.id}.'
498
+ )
499
+ self._set_status(self.Status.SETTING_UP)
500
+
501
+ self._session_id = session_id
502
+ self._session_start_time = time.time()
503
+
504
+ try:
505
+ self._start_session()
506
+ self._set_status(self.Status.IN_SESSION)
507
+ self.on_session_start(session_id, time.time() - self._session_start_time)
508
+ except BaseException as e: # pylint: disable=broad-except
509
+ if isinstance(e, interface.SandboxStateError):
510
+ self.report_state_error(e)
511
+ self.on_session_start(
512
+ session_id, time.time() - self._session_start_time, e
513
+ )
514
+ self.shutdown()
515
+ raise e
516
+
517
+ def end_session(self, shutdown_sandbox: bool = False) -> None:
518
+ """Ends the user session with the sandbox.
519
+
520
+ State transitions:
521
+ IN_SESSION -> EXITING_SESSION -> READY: When user session exits normally,
522
+ and sandbox is set to reuse.
523
+ IN_SESSION -> EXITING_SESSION -> SHUTTING_DOWN -> OFFLINE: When user
524
+ session exits while
525
+ sandbox is set not to reuse, or session teardown fails.
526
+ IN_SESSION -> EXITING_SESSION -> SETTING_UP -> READY: When user session
527
+ exits normally, and sandbox is set to reuse, and proactive session setup
528
+ is enabled.
529
+ IN_SESSION -> EXITING_SESSION -> SETTING_UP -> SHUTTING_DOWN -> OFFLINE:
530
+ When user session exits normally, and proactive session setup is enabled
531
+ but fails.
532
+ EXITING_SESSION -> EXITING_SESSION: No operation.
533
+ not IN_SESSION -> same state: No operation
534
+
535
+ `end_session` should always be called for each `start_session` call, even
536
+ when the session fails to start, to ensure proper cleanup.
537
+
538
+ `end_session` may fail with two sources of errors:
539
+
540
+ 1. SandboxStateError: If the sandbox is in a bad state or session teardown
541
+ failed.
542
+
543
+ 2. BaseException: If session teardown failed with user-defined errors.
544
+
545
+ In both cases, the sandbox will be shutdown automatically and the
546
+ session will be considered ended. The error will be added to `errors`.
547
+ Future session will be served on other sandboxes.
548
+
549
+ However, SandboxStateError encountered during `end_session` will NOT be
550
+ raised to the user as the user session is considered completed.
551
+
552
+ Args:
553
+ shutdown_sandbox: If True, the sandbox will be shutdown after session
554
+ teardown.
555
+
556
+ Raises:
557
+ BaseException: If session teardown failed with user-defined errors.
558
+ """
559
+ if self._status == self.Status.EXITING_SESSION:
560
+ return
561
+
562
+ if self._status not in (
563
+ self.Status.IN_SESSION,
564
+ ):
565
+ return
566
+
567
+ assert self._session_id is not None, (
568
+ 'No user session is active for this sandbox'
569
+ )
570
+ # Set sandbox status to EXITING_SESSION to avoid re-entry.
571
+ self._set_status(self.Status.EXITING_SESSION)
572
+ shutdown_sandbox = shutdown_sandbox or not self.reusable
573
+ ending_time = time.time()
574
+
575
+ # Teardown features for the current session.
576
+ end_session_error = self._end_session()
577
+ previous_session_id = self._session_id
578
+ self._session_id = None
579
+ self._features_with_setup_session_called.clear()
580
+
581
+ # If there is no state error, and proactive session setup is enabled,
582
+ # set up the next session proactively.
583
+ if not self.state_errors:
584
+ if not shutdown_sandbox and self._enable_pre_session_setup:
585
+ def _setup_next_session():
586
+ try:
587
+ self._setup_session()
588
+ self._set_status(interface.Sandbox.Status.READY)
589
+ except BaseException as e: # pylint: disable=broad-except
590
+ pg.logging.error(
591
+ '[%s]: Shutting down sandbox due to practively setting up '
592
+ 'next session failed: %s',
593
+ self.id,
594
+ e
595
+ )
596
+ if isinstance(e, interface.SandboxStateError):
597
+ self.report_state_error(e)
598
+ self.shutdown()
599
+
600
+ # End session before setting up the next session.
601
+ self.on_session_end(
602
+ previous_session_id, duration=time.time() - ending_time
603
+ )
604
+
605
+ # Mark the sandbox as setting up to prevent it from being acquired by
606
+ # other threads.
607
+ self._set_status(interface.Sandbox.Status.SETTING_UP)
608
+
609
+ # TODO(daiyip): Consider using a thread pool to perform next session
610
+ # setup.
611
+ threading.Thread(target=_setup_next_session).start()
612
+ else:
613
+ # End session before reporting sandbox status change.
614
+ self.on_session_end(
615
+ previous_session_id, duration=time.time() - ending_time
616
+ )
617
+
618
+ # If shutdown is requested, mark the sandbox as acquired to prevent it
619
+ # from being acquired by other threads.
620
+ self._set_status(
621
+ interface.Sandbox.Status.ACQUIRED if shutdown_sandbox else
622
+ interface.Sandbox.Status.READY
623
+ )
624
+
625
+ # Otherwise, shutdown the sandbox.
626
+ else:
627
+ self.on_session_end(
628
+ previous_session_id,
629
+ duration=time.time() - ending_time,
630
+ error=self.state_errors[0]
631
+ )
632
+ self._set_status(interface.Sandbox.Status.ACQUIRED)
633
+ shutdown_sandbox = True
634
+
635
+ self._session_start_time = None
636
+
637
+ if shutdown_sandbox:
638
+ self.shutdown()
639
+
640
+ # We only raise errors if teardown error contains non-sandbox-state error,
641
+ # meaning that the user code may have bug or other non-environment
642
+ # failures.
643
+ if (end_session_error is not None
644
+ and end_session_error.has_non_sandbox_state_error):
645
+ raise end_session_error # pylint: disable=raising-bad-type
646
+
647
+ @contextlib.contextmanager
648
+ def track_activity(
649
+ self,
650
+ name: str,
651
+ **kwargs: Any
652
+ ) -> Iterator[None]:
653
+ """Tracks an activity for the sandbox."""
654
+ start_time = time.time()
655
+ error = None
656
+ try:
657
+ yield None
658
+ except BaseException as e: # pylint: disable=broad-except
659
+ error = e
660
+ raise
661
+ finally:
662
+ self.on_activity(
663
+ name=name,
664
+ duration=time.time() - start_time,
665
+ error=error,
666
+ **kwargs
667
+ )
668
+
669
+ #
670
+ # Housekeeping.
671
+ #
672
+
673
+ def _housekeep_loop(self) -> None:
674
+ """Sandbox housekeeping loop."""
675
+ now = time.time()
676
+ last_ping = now
677
+ last_housekeep_time = {name: now for name in self._features.keys()}
678
+
679
+ def _next_housekeep_wait_time() -> float:
680
+ # Decide how long to sleep for the next housekeeping.
681
+ next_housekeep_time = None
682
+ if self.keepalive_interval is not None:
683
+ next_housekeep_time = last_ping + self.keepalive_interval
684
+
685
+ for name, feature in self._features.items():
686
+ if feature.housekeep_interval is None:
687
+ continue
688
+ next_feature_housekeep_time = (
689
+ last_housekeep_time[name] + feature.housekeep_interval
690
+ )
691
+ if (next_housekeep_time is None
692
+ or next_housekeep_time > next_feature_housekeep_time):
693
+ next_housekeep_time = next_feature_housekeep_time
694
+
695
+ # Housekeep loop is installed when at least one feature requires
696
+ # housekeeping or the sandbox has a keepalive interval.
697
+ assert next_housekeep_time is not None
698
+ return max(0, next_housekeep_time - time.time())
699
+
700
+ while self._status not in (self.Status.SHUTTING_DOWN, self.Status.OFFLINE):
701
+ housekeep_start = time.time()
702
+ if self.keepalive_interval is not None:
703
+ if time.time() - last_ping > self.keepalive_interval:
704
+ try:
705
+ self.ping()
706
+ except interface.SandboxStateError as e:
707
+ pg.logging.error(
708
+ '[%s]: Shutting down sandbox because ping failed '
709
+ 'with error: %s.',
710
+ self.id,
711
+ str(e)
712
+ )
713
+ self._housekeep_counter += 1
714
+ self.report_state_error(e)
715
+ self.on_housekeep(time.time() - housekeep_start, e)
716
+ self.shutdown()
717
+ break
718
+ last_ping = time.time()
719
+
720
+ for name, feature in self._features.items():
721
+ if feature.housekeep_interval is not None and (
722
+ time.time() - last_housekeep_time[name]
723
+ > feature.housekeep_interval
724
+ ):
725
+ try:
726
+ feature.housekeep()
727
+ last_housekeep_time[name] = time.time()
728
+ except interface.SandboxStateError as e:
729
+ pg.logging.error(
730
+ '[%s/%s]: Feature housekeeping failed with error: %s. '
731
+ 'Shutting down sandbox...',
732
+ self.id,
733
+ feature.name,
734
+ e,
735
+ )
736
+ self.report_state_error(e)
737
+ self._housekeep_counter += 1
738
+ self.on_housekeep(time.time() - housekeep_start, e)
739
+ self.shutdown()
740
+ break
741
+
742
+ self._housekeep_counter += 1
743
+ self.on_housekeep(time.time() - housekeep_start)
744
+ time.sleep(_next_housekeep_wait_time())
745
+
746
+ #
747
+ # Event handlers subclasses can override.
748
+ #
749
+
750
+ def on_start(
751
+ self,
752
+ duration: float,
753
+ error: BaseException | None = None
754
+ ) -> None:
755
+ """Called when the sandbox is started."""
756
+ self._event_handler.on_sandbox_start(self, duration, error)
757
+
758
+ def on_status_change(
759
+ self,
760
+ old_status: interface.Sandbox.Status,
761
+ new_status: interface.Sandbox.Status,
762
+ ) -> None:
763
+ """Called when the sandbox status changes."""
764
+ status_duration = time.time() - self._status_start_time
765
+ self._event_handler.on_sandbox_status_change(
766
+ self, old_status, new_status, status_duration
767
+ )
768
+
769
+ def on_shutdown(
770
+ self,
771
+ duration: float,
772
+ error: BaseException | None = None
773
+ ) -> None:
774
+ """Called when the sandbox is shutdown."""
775
+ self._event_handler.on_sandbox_shutdown(
776
+ sandbox=self,
777
+ duration=duration,
778
+ lifetime=(0.0 if self._start_time is None
779
+ else (time.time() - self._start_time)),
780
+ error=error
781
+ )
782
+
783
+ def on_housekeep(
784
+ self,
785
+ duration: float,
786
+ error: BaseException | None = None,
787
+ **kwargs
788
+ ) -> None:
789
+ """Called when the sandbox finishes a round of housekeeping."""
790
+ self._event_handler.on_sandbox_housekeep(
791
+ sandbox=self,
792
+ counter=self._housekeep_counter,
793
+ duration=duration,
794
+ error=error,
795
+ **kwargs
796
+ )
797
+
798
+ def on_session_start(
799
+ self,
800
+ session_id: str,
801
+ duration: float,
802
+ error: BaseException | None = None
803
+ ) -> None:
804
+ """Called when the user session starts."""
805
+ self._event_handler.on_sandbox_session_start(
806
+ sandbox=self,
807
+ session_id=session_id,
808
+ duration=duration,
809
+ error=error
810
+ )
811
+
812
+ def on_activity(
813
+ self,
814
+ name: str,
815
+ duration: float,
816
+ error: BaseException | None = None,
817
+ **kwargs
818
+ ) -> None:
819
+ """Called when a sandbox activity is performed."""
820
+ self._event_handler.on_sandbox_activity(
821
+ name=name,
822
+ sandbox=self,
823
+ session_id=self.session_id,
824
+ duration=duration,
825
+ error=error,
826
+ **kwargs
827
+ )
828
+
829
+ def on_session_end(
830
+ self,
831
+ session_id: str,
832
+ duration: float,
833
+ error: BaseException | None = None
834
+ ) -> None:
835
+ """Called when the user session ends."""
836
+ self._event_handler.on_sandbox_session_end(
837
+ sandbox=self,
838
+ session_id=session_id,
839
+ duration=duration,
840
+ lifetime=time.time() - self._session_start_time,
841
+ error=error
842
+ )