langfun 0.1.2.dev202509150805__py3-none-any.whl → 0.1.2.dev202509170804__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.

Potentially problematic release.


This version of langfun might be problematic. Click here for more details.

@@ -0,0 +1,795 @@
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
+ import concurrent.futures
15
+ import contextlib
16
+ import time
17
+ from typing import Iterator
18
+ import unittest
19
+
20
+ from langfun.env import base_environment
21
+ from langfun.env import base_feature
22
+ from langfun.env import base_sandbox
23
+ from langfun.env import interface
24
+
25
+
26
+ class TestingEnvironment(base_environment.BaseEnvironment):
27
+
28
+ ping_simulate_error: bool = False
29
+ keepalive_interval: float | None = 60.0
30
+ offline: bool = False
31
+
32
+ @property
33
+ def id(self) -> interface.EnvironmentId:
34
+ return interface.EnvironmentId('testing-env')
35
+
36
+ def set_offline(self, offline: bool) -> None:
37
+ self.rebind(
38
+ offline=offline, skip_notification=True, raise_on_no_change=False
39
+ )
40
+ for sandbox in self._sandbox_pool:
41
+ sandbox.rebind(
42
+ ping_simulate_error=offline,
43
+ skip_notification=True,
44
+ raise_on_no_change=False
45
+ )
46
+
47
+ def _create_sandbox(
48
+ self,
49
+ sandbox_id: str,
50
+ reusable: bool
51
+ ) -> interface.Sandbox:
52
+ if self.offline:
53
+ raise interface.EnvironmentError(
54
+ 'Unknown environment error.',
55
+ environment=self,
56
+ )
57
+ return TestingSandbox(
58
+ environment=self,
59
+ id=interface.SandboxId(
60
+ environment_id=self.id,
61
+ sandbox_id=sandbox_id
62
+ ),
63
+ reusable=reusable,
64
+ ping_simulate_error=self.ping_simulate_error,
65
+ keepalive_interval=self.keepalive_interval,
66
+ )
67
+
68
+
69
+ class TestingSandbox(base_sandbox.BaseSandbox):
70
+
71
+ ping_simulate_error: bool = False
72
+
73
+ def _on_bound(self) -> None:
74
+ super()._on_bound()
75
+ self._shell_history = []
76
+ self._ping_history = []
77
+
78
+ @base_sandbox.sandbox_service(critical_errors=(RuntimeError,))
79
+ def shell(
80
+ self,
81
+ code: str,
82
+ must_succeed: bool = True,
83
+ ) -> str:
84
+ self._shell_history.append(code)
85
+ if 'bad' not in code:
86
+ return f'shell {len(self._shell_history)} succeeded'
87
+
88
+ message = f'shell {len(self._shell_history)} failed'
89
+ if must_succeed:
90
+ raise RuntimeError(message)
91
+ raise ValueError(message)
92
+
93
+ def _ping(self) -> None:
94
+ self._ping_history.append(not self.ping_simulate_error)
95
+ if self.ping_simulate_error:
96
+ raise interface.SandboxStateError(sandbox=self, code='ping')
97
+
98
+
99
+ class TestingFeature(base_feature.BaseFeature):
100
+ housekeep_interval = 0
101
+ simulate_housekeep_error: bool = False
102
+
103
+ class Service:
104
+ """Sandbox."""
105
+
106
+ def __init__(self, sandbox: interface.Sandbox):
107
+ self._sandbox = sandbox
108
+
109
+ def do(self, code: str):
110
+ self._sandbox.shell(code)
111
+
112
+ def _setup(self) -> None:
113
+ self.sandbox.shell(f'echo "setup {self.name}"')
114
+
115
+ def _teardown(self) -> None:
116
+ self.sandbox.shell(f'echo "teardown {self.name}"')
117
+
118
+ @base_sandbox.sandbox_service()
119
+ def num_shell_calls(self) -> None:
120
+ return len(self.sandbox._shell_history)
121
+
122
+ @base_sandbox.sandbox_service()
123
+ def bad_shell_call(self) -> None:
124
+ self.sandbox.shell('bad command')
125
+
126
+ @base_sandbox.sandbox_service()
127
+ def show_session_id(self):
128
+ return self.session_id
129
+
130
+ def _on_bound(self) -> None:
131
+ super()._on_bound()
132
+ self._service = None
133
+
134
+ @base_sandbox.sandbox_service()
135
+ @contextlib.contextmanager
136
+ def my_service(self) -> Iterator[Service]:
137
+ try:
138
+ self._service = TestingFeature.Service(sandbox=self.sandbox)
139
+ yield self._service
140
+ finally:
141
+ self._service = None
142
+
143
+ def _housekeep(self) -> None:
144
+ if self.simulate_housekeep_error:
145
+ raise interface.SandboxStateError(
146
+ 'House keeping error', sandbox=self.sandbox
147
+ )
148
+
149
+
150
+ class TestingEnvironmentEventHandler(interface.EnvironmentEventHandler):
151
+
152
+ def __init__(self):
153
+ self.history = []
154
+
155
+ def _add_message(self, message: str, error: Exception | None) -> None:
156
+ """Adds a message to the history."""
157
+ if error is None:
158
+ self.history.append(message)
159
+ else:
160
+ self.history.append(f'{message} with error: {error}')
161
+
162
+ def on_environment_start(
163
+ self,
164
+ environment: 'Environment',
165
+ error: Exception | None
166
+ ) -> None:
167
+ """Called when the environment is started."""
168
+ self._add_message(f'[{environment.id}] environment started', error)
169
+
170
+ def on_environment_shutdown(
171
+ self,
172
+ environment: 'Environment',
173
+ error: Exception | None
174
+ ) -> None:
175
+ """Called when the environment is shutdown."""
176
+ self._add_message(f'[{environment.id}] environment shutdown', error)
177
+
178
+ def on_sandbox_start(
179
+ self,
180
+ environment: interface.Environment,
181
+ sandbox: interface.Sandbox,
182
+ error: Exception | None
183
+ ) -> None:
184
+ del environment
185
+ self._add_message(f'[{sandbox.id}] sandbox started', error)
186
+
187
+ def on_sandbox_shutdown(
188
+ self,
189
+ environment: interface.Environment,
190
+ sandbox: interface.Sandbox,
191
+ error: Exception | None
192
+ ) -> None:
193
+ self._add_message(f'[{sandbox.id}] sandbox shutdown', error)
194
+
195
+ def on_sandbox_feature_setup(
196
+ self,
197
+ environment: interface.Environment,
198
+ sandbox: interface.Sandbox,
199
+ feature: interface.Feature,
200
+ error: Exception | None
201
+ ) -> None:
202
+ """Called when a sandbox feature is setup."""
203
+ self._add_message(
204
+ f'[{sandbox.id}/{feature.name}] feature setup', error
205
+ )
206
+
207
+ def on_sandbox_feature_teardown(
208
+ self,
209
+ environment: 'Environment',
210
+ sandbox: 'Sandbox',
211
+ feature: 'Feature',
212
+ error: Exception | None
213
+ ) -> None:
214
+ """Called when a sandbox feature is teardown."""
215
+ self._add_message(
216
+ f'[{sandbox.id}/{feature.name}] feature teardown', error
217
+ )
218
+
219
+ def on_sandbox_session_start(
220
+ self,
221
+ environment: 'Environment',
222
+ sandbox: 'Sandbox',
223
+ session_id: str,
224
+ error: Exception | None
225
+ ) -> None:
226
+ """Called when a sandbox session starts."""
227
+ self._add_message(
228
+ f'[{sandbox.id}] session {session_id!r} started', error
229
+ )
230
+
231
+ def on_sandbox_session_end(
232
+ self,
233
+ environment: 'Environment',
234
+ sandbox: 'Sandbox',
235
+ session_id: str,
236
+ error: Exception | None
237
+ ) -> None:
238
+ """Called when a sandbox session ends."""
239
+ self._add_message(
240
+ f'[{sandbox.id}] session {session_id!r} ended', error
241
+ )
242
+
243
+
244
+ class EnvironmentTest(unittest.TestCase):
245
+
246
+ def test_environment_no_pooling_normal(self):
247
+ event_handler = TestingEnvironmentEventHandler()
248
+ env = TestingEnvironment(
249
+ pool_size=0,
250
+ features={'test_feature': TestingFeature()},
251
+ outage_grace_period=1,
252
+ event_handler=event_handler,
253
+ outage_retry_interval=0,
254
+ )
255
+ self.assertFalse(env.is_alive)
256
+ self.assertEqual(env.min_pool_size, 0)
257
+ self.assertEqual(env.max_pool_size, 0)
258
+ self.assertEqual(env.sandbox_pool, [])
259
+ self.assertEqual(env.id, interface.EnvironmentId('testing-env'))
260
+ self.assertEqual(env.outage_grace_period, 1)
261
+ self.assertEqual(env.stats_report_interval, 60)
262
+ self.assertEqual(env.features['test_feature'].name, 'test_feature')
263
+
264
+ self.assertIsNone(env.start_time)
265
+
266
+ with env:
267
+ self.assertEqual(
268
+ event_handler.history,
269
+ [
270
+ '[testing-env] environment started',
271
+ ]
272
+ )
273
+ self.assertIs(interface.Environment.current(), env)
274
+ self.assertTrue(env.is_alive)
275
+ self.assertIsNotNone(env.start_time)
276
+ self.assertEqual(env.offline_duration, 0.0)
277
+ self.assertEqual(env.sandbox_pool, [])
278
+ self.assertIsNone(env.working_dir)
279
+
280
+ with env.sandbox('session1') as sb:
281
+ self.assertEqual(env.sandbox_pool, [])
282
+ self.assertTrue(sb.is_alive)
283
+ self.assertTrue(sb.is_busy)
284
+ self.assertEqual(sb.session_id, 'session1')
285
+ self.assertIsNone(sb.working_dir)
286
+ self.assertEqual(
287
+ sb.id, interface.SandboxId(environment_id=env.id, sandbox_id='0')
288
+ )
289
+ sb.shell('echo "foo"')
290
+ self.assertIsInstance(sb.test_feature, TestingFeature)
291
+ self.assertEqual(sb.test_feature.session_id, 'session1')
292
+ with self.assertRaises(AttributeError):
293
+ _ = sb.test_feature2
294
+ self.assertIsNone(sb.test_feature._service)
295
+ with sb.test_feature.my_service() as service:
296
+ self.assertIs(sb.test_feature._service, service)
297
+ self.assertIsNone(sb.test_feature._service)
298
+ self.assertEqual(sb.session_id, 'session1')
299
+
300
+ with env.sandbox('session2') as sb:
301
+ self.assertEqual(env.sandbox_pool, [])
302
+ self.assertTrue(sb.is_alive)
303
+ self.assertTrue(sb.is_busy)
304
+ self.assertEqual(sb.session_id, 'session2')
305
+ self.assertEqual(
306
+ sb.id, interface.SandboxId(environment_id=env.id, sandbox_id='1')
307
+ )
308
+ sb.shell('echo "bar"')
309
+
310
+ # Access the feature from the environment without obtaining a sandbox.
311
+ self.assertEqual(
312
+ env.test_feature.num_shell_calls(session_id='num_shell_session'), 1
313
+ )
314
+ with self.assertRaises(AttributeError):
315
+ _ = env.test_feature2
316
+ # Access the feature from the environment without obtaining a sandbox.
317
+ with env.test_feature.my_service(
318
+ session_id='my_service_session'
319
+ ) as service:
320
+ service.do('echo "baz"')
321
+
322
+ self.assertFalse(env.is_alive)
323
+ self.assertIsNone(interface.Environment.current())
324
+ self.assertEqual(
325
+ event_handler.history,
326
+ [
327
+ '[testing-env] environment started',
328
+ '[testing-env/0/test_feature] feature setup',
329
+ '[testing-env/0] sandbox started',
330
+ '[testing-env/0] session \'session1\' started',
331
+ '[testing-env/0] session \'session1\' ended',
332
+ '[testing-env/0/test_feature] feature teardown',
333
+ '[testing-env/0] sandbox shutdown',
334
+ '[testing-env/1/test_feature] feature setup',
335
+ '[testing-env/1] sandbox started',
336
+ '[testing-env/1] session \'session2\' started',
337
+ '[testing-env/1] session \'session2\' ended',
338
+ '[testing-env/1/test_feature] feature teardown',
339
+ '[testing-env/1] sandbox shutdown',
340
+ '[testing-env/2/test_feature] feature setup',
341
+ '[testing-env/2] sandbox started',
342
+ '[testing-env/2] session \'num_shell_session\' started',
343
+ '[testing-env/2] session \'num_shell_session\' ended',
344
+ '[testing-env/2/test_feature] feature teardown',
345
+ '[testing-env/2] sandbox shutdown',
346
+ '[testing-env/3/test_feature] feature setup',
347
+ '[testing-env/3] sandbox started',
348
+ '[testing-env/3] session \'my_service_session\' started',
349
+ '[testing-env/3] session \'my_service_session\' ended',
350
+ '[testing-env/3/test_feature] feature teardown',
351
+ '[testing-env/3] sandbox shutdown',
352
+ '[testing-env] environment shutdown',
353
+ ]
354
+ )
355
+
356
+ def test_environment_no_pooling_state_error(self):
357
+ event_handler = TestingEnvironmentEventHandler()
358
+ env = TestingEnvironment(
359
+ root_dir='/tmp',
360
+ pool_size=0,
361
+ features={'test_feature': TestingFeature()},
362
+ outage_grace_period=1,
363
+ outage_retry_interval=0,
364
+ event_handler=event_handler,
365
+ )
366
+ self.assertEqual(env.working_dir, '/tmp/testing-env')
367
+ with env:
368
+ with env.sandbox('session1') as sb:
369
+ self.assertEqual(env.sandbox_pool, [])
370
+ self.assertTrue(sb.is_alive)
371
+ self.assertTrue(sb.is_busy)
372
+ self.assertFalse(sb.is_pending)
373
+ self.assertEqual(sb.session_id, 'session1')
374
+ self.assertEqual(sb.working_dir, '/tmp/testing-env/0')
375
+ self.assertEqual(
376
+ sb.id, interface.SandboxId(environment_id=env.id, sandbox_id='0')
377
+ )
378
+ # Non-critical error.
379
+ with self.assertRaises(ValueError):
380
+ sb.shell('bad command', must_succeed=False)
381
+
382
+ # Critical error.
383
+ with sb.test_feature.my_service() as service:
384
+ with self.assertRaises(interface.SandboxStateError):
385
+ service.do('bad command')
386
+ self.assertFalse(sb.is_alive)
387
+
388
+ with self.assertRaises(interface.SandboxStateError):
389
+ env.test_feature.bad_shell_call(session_id='bad_shell_session')
390
+
391
+ # Access the feature from the environment without obtaining a sandbox.
392
+ with env.test_feature.my_service(
393
+ session_id='my_service_session'
394
+ ) as service:
395
+ with self.assertRaises(interface.SandboxStateError):
396
+ service.do('bad command')
397
+
398
+ self.assertFalse(env.is_alive)
399
+ self.assertIsNone(interface.Environment.current())
400
+ self.assertEqual(
401
+ event_handler.history,
402
+ [
403
+ '[testing-env] environment started',
404
+ '[testing-env/0/test_feature] feature setup',
405
+ '[testing-env/0] sandbox started',
406
+ '[testing-env/0] session \'session1\' started',
407
+ # Sandbox shutdown is triggered by the SandboxStateError before
408
+ # the session end event is triggered.
409
+ '[testing-env/0/test_feature] feature teardown',
410
+ '[testing-env/0] sandbox shutdown',
411
+ '[testing-env/0] session \'session1\' ended',
412
+ '[testing-env/1/test_feature] feature setup',
413
+ '[testing-env/1] sandbox started',
414
+ "[testing-env/1] session 'bad_shell_session' started",
415
+ '[testing-env/1/test_feature] feature teardown',
416
+ '[testing-env/1] sandbox shutdown',
417
+ "[testing-env/1] session 'bad_shell_session' ended",
418
+ '[testing-env/2/test_feature] feature setup',
419
+ '[testing-env/2] sandbox started',
420
+ "[testing-env/2] session 'my_service_session' started",
421
+ '[testing-env/2/test_feature] feature teardown',
422
+ '[testing-env/2] sandbox shutdown',
423
+ "[testing-env/2] session 'my_service_session' ended",
424
+ '[testing-env] environment shutdown',
425
+ ]
426
+ )
427
+
428
+ def test_environment_with_pooling_normal(self):
429
+ event_handler = TestingEnvironmentEventHandler()
430
+ env = TestingEnvironment(
431
+ pool_size=(1, 2),
432
+ features={'test_feature': TestingFeature()},
433
+ event_handler=event_handler,
434
+ # To make pool operation deterministic in the test.
435
+ pool_operation_max_parallelism=1,
436
+ outage_grace_period=1,
437
+ outage_retry_interval=0,
438
+ )
439
+ self.assertFalse(env.is_alive)
440
+ self.assertEqual(env.min_pool_size, 1)
441
+ self.assertEqual(env.max_pool_size, 2)
442
+ self.assertEqual(env.sandbox_pool, [])
443
+ self.assertEqual(env.id, interface.EnvironmentId('testing-env'))
444
+ self.assertEqual(env.outage_grace_period, 1)
445
+ self.assertEqual(env.stats_report_interval, 60)
446
+ self.assertEqual(env.features['test_feature'].name, 'test_feature')
447
+
448
+ self.assertIsNone(env.start_time)
449
+
450
+ with env:
451
+ self.assertEqual(
452
+ event_handler.history,
453
+ [
454
+ '[testing-env/0/test_feature] feature setup',
455
+ '[testing-env/0] sandbox started',
456
+ '[testing-env] environment started',
457
+ ]
458
+ )
459
+ self.assertIs(interface.Environment.current(), env)
460
+ self.assertEqual(len(env.sandbox_pool), 1)
461
+ self.assertTrue(env.sandbox_pool[0].is_alive)
462
+ self.assertFalse(env.sandbox_pool[0].is_busy)
463
+ self.assertFalse(env.sandbox_pool[0].is_pending)
464
+
465
+ # Use the pooled server.
466
+ with env.sandbox('session1') as sb:
467
+ self.assertEqual(len(env.sandbox_pool), 1)
468
+ self.assertTrue(sb.is_alive)
469
+ self.assertFalse(sb.is_pending)
470
+ self.assertTrue(sb.is_busy)
471
+ self.assertEqual(sb.session_id, 'session1')
472
+ self.assertEqual(
473
+ sb.id, interface.SandboxId(environment_id=env.id, sandbox_id='0')
474
+ )
475
+ sb.shell('echo "foo"')
476
+
477
+ self.assertEqual(len(env.sandbox_pool), 1)
478
+
479
+ # Reuse the pooled server.
480
+ with env.sandbox('session2') as sb2:
481
+ self.assertEqual(len(env.sandbox_pool), 1)
482
+ self.assertTrue(sb2.is_alive)
483
+ self.assertTrue(sb2.is_busy)
484
+ self.assertEqual(sb2.session_id, 'session2')
485
+ self.assertEqual(
486
+ sb2.id, interface.SandboxId(environment_id=env.id, sandbox_id='0')
487
+ )
488
+ sb2.shell('echo "bar"')
489
+
490
+ # Dynamically bring up a new server in the pool.
491
+ with env.sandbox('session3') as sb3:
492
+ self.assertEqual(len(env.sandbox_pool), 2)
493
+ self.assertTrue(sb3.is_alive)
494
+ self.assertFalse(sb3.is_pending)
495
+ self.assertTrue(sb3.is_busy)
496
+ self.assertEqual(sb3.session_id, 'session3')
497
+ self.assertEqual(
498
+ sb3.id, interface.SandboxId(environment_id=env.id, sandbox_id='1')
499
+ )
500
+ sb3.shell('echo "baz"')
501
+
502
+ self.assertEqual(
503
+ env.stats(),
504
+ dict(
505
+ sandbox=dict(
506
+ num_total=2,
507
+ num_busy=2,
508
+ num_free=0,
509
+ num_dead=0,
510
+ )
511
+ )
512
+ )
513
+
514
+ # Environment overloaded as all pooled servers are busy, and there
515
+ # is no more quota to bring up new servers.
516
+ with self.assertRaises(interface.EnvironmentOverloadError):
517
+ with env.sandbox('session4'):
518
+ pass
519
+
520
+ self.assertEqual(
521
+ env.stats(),
522
+ dict(
523
+ sandbox=dict(
524
+ num_total=2,
525
+ num_busy=1,
526
+ num_free=1,
527
+ num_dead=0,
528
+ )
529
+ )
530
+ )
531
+
532
+ # Access the feature from the environment without obtaining a sandbox.
533
+ self.assertEqual(
534
+ env.test_feature.num_shell_calls(session_id='num_shell_session'), 2
535
+ )
536
+
537
+ self.assertFalse(env.is_alive)
538
+ self.assertEqual(env.sandbox_pool, [])
539
+ self.assertIsNone(interface.Environment.current())
540
+ self.assertEqual(
541
+ event_handler.history,
542
+ [
543
+ '[testing-env/0/test_feature] feature setup',
544
+ '[testing-env/0] sandbox started',
545
+ # Environment ready is after the first sandbox is started.
546
+ '[testing-env] environment started',
547
+ '[testing-env/0] session \'session1\' started',
548
+ '[testing-env/0] session \'session1\' ended',
549
+ '[testing-env/0] session \'session2\' started',
550
+ '[testing-env/1/test_feature] feature setup',
551
+ '[testing-env/1] sandbox started',
552
+ '[testing-env/1] session \'session3\' started',
553
+ '[testing-env/1] session \'session3\' ended',
554
+ '[testing-env/1] session \'num_shell_session\' started',
555
+ '[testing-env/1] session \'num_shell_session\' ended',
556
+ '[testing-env/0] session \'session2\' ended',
557
+ '[testing-env/0/test_feature] feature teardown',
558
+ '[testing-env/0] sandbox shutdown',
559
+ '[testing-env/1/test_feature] feature teardown',
560
+ '[testing-env/1] sandbox shutdown',
561
+ '[testing-env] environment shutdown',
562
+ ]
563
+ )
564
+
565
+ def test_session_id(self):
566
+ event_handler = TestingEnvironmentEventHandler()
567
+ env = TestingEnvironment(
568
+ features={'test_feature': TestingFeature()},
569
+ event_handler=event_handler,
570
+ )
571
+
572
+ with env:
573
+ with env.sandbox() as sb:
574
+ self.assertRegex(sb.session_id, r'session-[0-9a-f]{7}')
575
+
576
+ self.assertEqual(
577
+ env.test_feature.show_session_id(session_id='session1'),
578
+ 'session1'
579
+ )
580
+ self.assertRegex(
581
+ env.test_feature.show_session_id(),
582
+ r'session-[0-9a-f]{7}'
583
+ )
584
+
585
+ with self.assertRaisesRegex(ValueError, '`session_id` should not be used'):
586
+ @base_sandbox.sandbox_service()
587
+ def foo(session_id: str):
588
+ del session_id
589
+
590
+ def test_environment_with_pooling_state_error(self):
591
+ event_handler = TestingEnvironmentEventHandler()
592
+ env = TestingEnvironment(
593
+ root_dir='/tmp',
594
+ pool_size=(1, 2),
595
+ outage_grace_period=1,
596
+ outage_retry_interval=0,
597
+ event_handler=event_handler,
598
+ )
599
+ self.assertEqual(env.working_dir, '/tmp/testing-env')
600
+ with env:
601
+ with env.sandbox('session1') as sb:
602
+ self.assertEqual(len(env.sandbox_pool), 1)
603
+ self.assertTrue(sb.is_alive)
604
+ self.assertTrue(sb.is_busy)
605
+ self.assertEqual(sb.session_id, 'session1')
606
+ self.assertEqual(
607
+ sb.id, interface.SandboxId(environment_id=env.id, sandbox_id='0')
608
+ )
609
+
610
+ # Non-critical error.
611
+ with self.assertRaises(ValueError):
612
+ sb.shell('bad command', must_succeed=False)
613
+
614
+ # Critical error.
615
+ with self.assertRaises(interface.SandboxStateError):
616
+ sb.shell('bad command')
617
+ maintenance_count = env._maintenance_count
618
+ self.assertFalse(sb.is_alive)
619
+
620
+ self.assertTrue(env.is_alive)
621
+
622
+ # Wait dead sandbox to be replaced.
623
+ while env._maintenance_count == maintenance_count:
624
+ time.sleep(0.5)
625
+
626
+ self.assertTrue(env.sandbox_pool[0].is_alive)
627
+ self.assertEqual(
628
+ env.stats(),
629
+ dict(
630
+ sandbox=dict(
631
+ num_total=1,
632
+ num_busy=0,
633
+ num_free=1,
634
+ num_dead=0,
635
+ )
636
+ )
637
+ )
638
+
639
+ self.assertFalse(env.is_alive)
640
+ self.assertIsNone(interface.Environment.current())
641
+ self.assertEqual(
642
+ event_handler.history,
643
+ [
644
+ '[testing-env/0] sandbox started',
645
+ '[testing-env] environment started',
646
+ '[testing-env/0] session \'session1\' started',
647
+ # Sandbox shutdown is triggered by the SandboxStateError before
648
+ # the session end event is triggered.
649
+ '[testing-env/0] sandbox shutdown',
650
+ '[testing-env/0] session \'session1\' ended',
651
+ # The maintenance loop will replace the dead sandbox and start a new
652
+ # sandbox.
653
+ '[testing-env/0] sandbox started',
654
+ '[testing-env/0] sandbox shutdown',
655
+ '[testing-env] environment shutdown',
656
+ ]
657
+ )
658
+ self.assertEqual(
659
+ env.stats(),
660
+ dict(
661
+ sandbox=dict(
662
+ num_total=0,
663
+ num_busy=0,
664
+ num_free=0,
665
+ num_dead=0,
666
+ )
667
+ )
668
+ )
669
+
670
+ def test_environment_outage_with_pooling(self):
671
+ event_handler = TestingEnvironmentEventHandler()
672
+ env = TestingEnvironment(
673
+ features={'test_feature': TestingFeature()},
674
+ pool_size=(1, 2),
675
+ outage_grace_period=0,
676
+ outage_retry_interval=0,
677
+ keepalive_interval=0,
678
+ event_handler=event_handler,
679
+ stats_report_interval=1,
680
+ )
681
+ with env:
682
+ with env.sandbox('session1') as sb:
683
+ self.assertEqual(len(env.sandbox_pool), 1)
684
+ self.assertTrue(sb.is_alive)
685
+ self.assertTrue(sb.is_busy)
686
+ self.assertEqual(sb.session_id, 'session1')
687
+ env.set_offline(True)
688
+ housekeep_count = sb._housekeep_count
689
+ while sb._housekeep_count == housekeep_count:
690
+ time.sleep(1.0)
691
+ self.assertFalse(sb.is_alive)
692
+ maintenance_count = env._maintenance_count
693
+ while env._maintenance_count == maintenance_count:
694
+ time.sleep(1.0)
695
+ self.assertFalse(env.is_alive)
696
+
697
+ with self.assertRaises(interface.EnvironmentOutageError):
698
+ with env.sandbox('session2'):
699
+ pass
700
+
701
+ with self.assertRaises(interface.EnvironmentOutageError):
702
+ with env.test_feature.my_service():
703
+ pass
704
+ self.assertGreater(env.offline_duration, 0)
705
+
706
+ def test_environment_outage_during_acquire(self):
707
+ event_handler = TestingEnvironmentEventHandler()
708
+ env = TestingEnvironment(
709
+ features={'test_feature': TestingFeature()},
710
+ pool_size=(2, 3),
711
+ outage_grace_period=0,
712
+ outage_retry_interval=0,
713
+ keepalive_interval=0,
714
+ event_handler=event_handler,
715
+ stats_report_interval=1,
716
+ )
717
+
718
+ def _thread_func(i) -> bool:
719
+ time.sleep(0.8 * i)
720
+ with env.sandbox(f'session{i}') as sb:
721
+ return sb.shell('echo "foo"')
722
+
723
+ with env:
724
+ with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
725
+ fs = [
726
+ executor.submit(_thread_func, i) for i in range(5)
727
+ ]
728
+ time.sleep(1.0)
729
+ env.set_offline(True)
730
+ vs = []
731
+ for f in fs:
732
+ try:
733
+ vs.append(f.result())
734
+ except interface.EnvironmentError as e:
735
+ vs.append(e)
736
+
737
+ def test_environment_outage_during_acquire_pool_not_full(self):
738
+ event_handler = TestingEnvironmentEventHandler()
739
+ env = TestingEnvironment(
740
+ features={
741
+ 'test_feature': TestingFeature(simulate_housekeep_error=True)
742
+ },
743
+ pool_size=(1, 3),
744
+ outage_grace_period=1,
745
+ outage_retry_interval=0,
746
+ keepalive_interval=0,
747
+ event_handler=event_handler,
748
+ stats_report_interval=1,
749
+ )
750
+
751
+ def _thread_func() -> bool:
752
+ with env.sandbox('session1') as sb:
753
+ return sb.shell('echo "foo"')
754
+
755
+ with env:
756
+ self.assertEqual(len(env.sandbox_pool), 1)
757
+ self.assertFalse(env.sandbox_pool[0].is_alive)
758
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
759
+ env.set_offline(True)
760
+ f = executor.submit(_thread_func)
761
+ with self.assertRaises(interface.EnvironmentOutageError):
762
+ f.result()
763
+
764
+ def test_housekeep_error(self):
765
+ event_handler = TestingEnvironmentEventHandler()
766
+ env = TestingEnvironment(
767
+ features={
768
+ 'test_feature': TestingFeature(
769
+ housekeep_interval=0
770
+ )
771
+ },
772
+ pool_size=1,
773
+ outage_grace_period=0,
774
+ outage_retry_interval=0,
775
+ keepalive_interval=0,
776
+ event_handler=event_handler,
777
+ stats_report_interval=1,
778
+ )
779
+ with env:
780
+ with env.sandbox('session1') as sb:
781
+ self.assertEqual(len(env.sandbox_pool), 1)
782
+ self.assertTrue(sb.is_alive)
783
+ self.assertTrue(sb.is_busy)
784
+ self.assertEqual(sb.session_id, 'session1')
785
+ housekeep_count = sb._housekeep_count
786
+ sb.test_feature.rebind(
787
+ simulate_housekeep_error=True, skip_notification=True
788
+ )
789
+ while sb._housekeep_count == housekeep_count or sb.is_alive:
790
+ time.sleep(1.0)
791
+ self.assertFalse(sb.is_alive)
792
+
793
+
794
+ if __name__ == '__main__':
795
+ unittest.main()