forked from goodboy/tractor
Compare commits
554 Commits
246_facepa
...
master
Author | SHA1 | Date |
---|---|---|
goodboy | e5ee2e3de8 | |
Tyler Goodlet | 41aa91c8eb | |
Tyler Goodlet | 6758e4487c | |
Tyler Goodlet | 1c3893a383 | |
Tyler Goodlet | 73befac9bc | |
Tyler Goodlet | 79622bbeea | |
Tyler Goodlet | 95535b2226 | |
Tyler Goodlet | 87c6e09d6b | |
Tyler Goodlet | 9ccd3a74b6 | |
Tyler Goodlet | ae4ff5dc8d | |
Tyler Goodlet | 705538398f | |
Tyler Goodlet | 86aef5238d | |
Tyler Goodlet | cc82447db6 | |
Tyler Goodlet | 23cffbd940 | |
Tyler Goodlet | 3d202272c4 | |
Tyler Goodlet | 63cdb0891f | |
goodboy | 0f7db27b68 | |
Tyler Goodlet | c53d62d2f7 | |
Tyler Goodlet | f667d16d66 | |
Tyler Goodlet | 24a062341e | |
goodboy | e714bec8db | |
Igor | 009cd6552e | |
goodboy | 649c5e7504 | |
Tyler Goodlet | 203f95615c | |
Tyler Goodlet | efb8bec828 | |
Tyler Goodlet | 8637778739 | |
Tyler Goodlet | 47166e45f0 | |
Tyler Goodlet | 4ce2dcd12b | |
Tyler Goodlet | 80f983818f | |
Tyler Goodlet | 6ba29f8d56 | |
Tyler Goodlet | 2707a0e971 | |
Tyler Goodlet | c8efcdd0d3 | |
Tyler Goodlet | 9f9907271b | |
Tyler Goodlet | c2367c1c5e | |
goodboy | a777217674 | |
Tyler Goodlet | 13c9eadc8f | |
Tyler Goodlet | af6c325072 | |
Tyler Goodlet | 195d2f0ed4 | |
Tyler Goodlet | aa4871b13d | |
Tyler Goodlet | 556f4626db | |
Tyler Goodlet | 3967c0ed9e | |
Tyler Goodlet | e34823aab4 | |
Tyler Goodlet | 6c35ba2cb6 | |
Tyler Goodlet | 3a0817ff55 | |
Tyler Goodlet | 7fddb4416b | |
Tyler Goodlet | 1d92f2552a | |
Tyler Goodlet | 4f8586a928 | |
Tyler Goodlet | fb9ff45745 | |
Tyler Goodlet | 36a83cb306 | |
Tyler Goodlet | 7394a187e0 | |
Tyler Goodlet | df01294bb2 | |
Tyler Goodlet | ddf3d0d1b3 | |
Tyler Goodlet | 158569adae | |
Tyler Goodlet | 97d5f7233b | |
Tyler Goodlet | d27c081a15 | |
Tyler Goodlet | a4874a3227 | |
Tyler Goodlet | de04bbb2bb | |
Tyler Goodlet | 4f977189c0 | |
goodboy | 9fd62cf71f | |
Tyler Goodlet | 606efa5bb7 | |
Tyler Goodlet | 121a8cc891 | |
Tyler Goodlet | c54b8ca4ba | |
goodboy | de93c8257c | |
Tyler Goodlet | 5b8a87d0f6 | |
Tyler Goodlet | 9e5c8ce6f6 | |
Tyler Goodlet | 965cd406a2 | |
Tyler Goodlet | 2e278ceb74 | |
Tyler Goodlet | 6d124db7c9 | |
Tyler Goodlet | dba8118553 | |
Tyler Goodlet | fca2e7c10e | |
Tyler Goodlet | 5ed62c5c54 | |
goodboy | 588b7ca7bf | |
Tyler Goodlet | d8214735b9 | |
Tyler Goodlet | 48f6d514ef | |
Tyler Goodlet | 6c8cacc9d1 | |
Tyler Goodlet | 38326e8c15 | |
Tyler Goodlet | b5192cca8e | |
Tyler Goodlet | c606be8c64 | |
Tyler Goodlet | d8e48e29ba | |
goodboy | a0f6668ce8 | |
Tyler Goodlet | 274c66cf9d | |
Tyler Goodlet | f2641c8964 | |
Tyler Goodlet | c47575997a | |
Tyler Goodlet | f39414ce12 | |
Tyler Goodlet | 0a1bf8e57d | |
Tyler Goodlet | e298b70edf | |
Tyler Goodlet | c0dd5d7ffc | |
Tyler Goodlet | 347591c348 | |
Tyler Goodlet | 38f9d35dee | |
Tyler Goodlet | 88448f7281 | |
Tyler Goodlet | 0956d5f461 | |
Tyler Goodlet | c646c79a82 | |
Tyler Goodlet | 33f2234baf | |
Tyler Goodlet | 7521bded3d | |
Tyler Goodlet | 0f523b65fb | |
Tyler Goodlet | 50fe098e06 | |
Tyler Goodlet | d87d6af7e1 | |
Tyler Goodlet | df69aedcd5 | |
Tyler Goodlet | b15e4ed9ce | |
Tyler Goodlet | 98056f6ed7 | |
goodboy | 247d3448ae | |
Tyler Goodlet | fc17f6790e | |
Tyler Goodlet | b81b6be98a | |
Tyler Goodlet | 72fbda4cef | |
Tyler Goodlet | fb721f36ef | |
Tyler Goodlet | 734d8dd663 | |
Tyler Goodlet | 30ea7a06b0 | |
Tyler Goodlet | 3398153c52 | |
Tyler Goodlet | 1c480e6c92 | |
goodboy | dfdad4d1fa | |
Tyler Goodlet | b892bc74f6 | |
Tyler Goodlet | 44b59f3338 | |
Tyler Goodlet | 7a719ac2a7 | |
goodboy | 9e6266dda3 | |
Tyler Goodlet | b1abec543f | |
Tyler Goodlet | 93b9d2dc2d | |
Tyler Goodlet | 4d808757a6 | |
Tyler Goodlet | 7e5bb0437e | |
Tyler Goodlet | b19f08d9f0 | |
Tyler Goodlet | 2c20b2d64f | |
Tyler Goodlet | 023b6fc845 | |
Tyler Goodlet | d24fae8381 | |
Tyler Goodlet | 5ab98513b7 | |
Tyler Goodlet | 90f4912580 | |
goodboy | 6e24e16068 | |
Tyler Goodlet | 15047341bd | |
Tyler Goodlet | dc295ab227 | |
goodboy | 6a0337b69d | |
Tyler Goodlet | e609183242 | |
goodboy | 368e9f3f7c | |
Tyler Goodlet | 10eeda2d2b | |
Tyler Goodlet | a113e22bb9 | |
Tyler Goodlet | ad19bf2cf1 | |
Tyler Goodlet | 9aef03772a | |
Tyler Goodlet | 7548dba8f2 | |
Tyler Goodlet | ba4d4e9af3 | |
Tyler Goodlet | 208d56af2c | |
Tyler Goodlet | a3a5bc267e | |
Tyler Goodlet | d4084b2032 | |
Tyler Goodlet | 1e6b4d5dd4 | |
Tyler Goodlet | c613acfe5c | |
goodboy | fea9dc7065 | |
goodboy | e558c427de | |
Tyler Goodlet | f07c3aa4a1 | |
Tyler Goodlet | bafd10a260 | |
Tyler Goodlet | 5ad540c417 | |
Tyler Goodlet | 83b44cf469 | |
Tyler Goodlet | 1f2001020e | |
Tyler Goodlet | 71f9881a60 | |
Tyler Goodlet | e24645eec8 | |
Tyler Goodlet | c3cdeeb3ba | |
Tyler Goodlet | 9bd534df83 | |
goodboy | c1d700f257 | |
Tyler Goodlet | 14c6e34658 | |
Tyler Goodlet | 3393bc23e4 | |
Tyler Goodlet | 171f1bc243 | |
Tyler Goodlet | ee02cd2496 | |
Tyler Goodlet | 4c5d435aac | |
Tyler Goodlet | a9b4a61620 | |
goodboy | 641ed7a32a | |
Tyler Goodlet | cc5f60bba0 | |
Tyler Goodlet | 8f1fe2376a | |
Tyler Goodlet | 65540f3e2a | |
Tyler Goodlet | 650313dfef | |
Tyler Goodlet | e4006da6f4 | |
Tyler Goodlet | 7f6169a050 | |
Tyler Goodlet | 2d387f2610 | |
Tyler Goodlet | 8115759984 | |
Tyler Goodlet | 02c3b9a672 | |
Tyler Goodlet | fa4388835c | |
Tyler Goodlet | 54de72d8df | |
Tyler Goodlet | c5c7a9027c | |
Tyler Goodlet | e4771eec16 | |
Tyler Goodlet | a9aaee9dbd | |
Tyler Goodlet | acfbae4b95 | |
Tyler Goodlet | aca9a6b99a | |
Tyler Goodlet | 8896ba2bf8 | |
Tyler Goodlet | 87b2ccb86a | |
Tyler Goodlet | 937ed99e39 | |
Tyler Goodlet | 91f034a136 | |
Tyler Goodlet | 08cf03cd9e | |
Tyler Goodlet | 5e23b3ca0d | |
Tyler Goodlet | 6f01c78122 | |
Tyler Goodlet | 457499bc2e | |
Tyler Goodlet | a4bac135d9 | |
Tyler Goodlet | 20c660faa7 | |
Tyler Goodlet | 1d4d55f5cd | |
Tyler Goodlet | c0cd99e374 | |
Tyler Goodlet | a4538a3d84 | |
Tyler Goodlet | b01daa5319 | |
Tyler Goodlet | bd362a05f0 | |
Tyler Goodlet | cb0c47c42a | |
Tyler Goodlet | 808d7ae2c6 | |
Tyler Goodlet | b21f2e16ad | |
Tyler Goodlet | 4779badd96 | |
Tyler Goodlet | 6bdcbdb96f | |
Tyler Goodlet | adbebd3f06 | |
Tyler Goodlet | a2e90194bc | |
Tyler Goodlet | ba7b355d9c | |
Tyler Goodlet | 617d57dc35 | |
Tyler Goodlet | dadd5e6148 | |
Tyler Goodlet | a72350118c | |
Tyler Goodlet | ef8dc0204c | |
Tyler Goodlet | a101971027 | |
Tyler Goodlet | 835836123b | |
Tyler Goodlet | 70ad0f6b8e | |
Tyler Goodlet | 56b30a9a53 | |
Tyler Goodlet | 925d5c1ceb | |
Tyler Goodlet | b9eb601265 | |
Tyler Goodlet | 4dcc21234e | |
Tyler Goodlet | 64909e676e | |
Tyler Goodlet | 19fb77f698 | |
Tyler Goodlet | 8b9f342eef | |
Tyler Goodlet | bd7d507153 | |
Tyler Goodlet | 9bc38cbf04 | |
Tyler Goodlet | a90ca4b384 | |
Tyler Goodlet | d0dcd55f47 | |
Tyler Goodlet | 4e08605b0d | |
Tyler Goodlet | 519f4c300b | |
Tyler Goodlet | 56c19093bb | |
Tyler Goodlet | ff3f5959e9 | |
Tyler Goodlet | abb00531d3 | |
Tyler Goodlet | 439d320a25 | |
Tyler Goodlet | 18c525d2f1 | |
Tyler Goodlet | 201c026284 | |
Tyler Goodlet | 2a61aa099b | |
Tyler Goodlet | e2453fd3da | |
Tyler Goodlet | b29def8b5d | |
Tyler Goodlet | f07e9dbb2f | |
Tyler Goodlet | 2f5a6049a4 | |
Tyler Goodlet | 418e74eee7 | |
Tyler Goodlet | c7035be2fc | |
Tyler Goodlet | deaca7d6cc | |
Tyler Goodlet | d47d0e7c37 | |
Tyler Goodlet | 0062c96a3c | |
Tyler Goodlet | 4be13b7387 | |
Tyler Goodlet | 7bb5addd4c | |
Tyler Goodlet | 4fd924cfd2 | |
Tyler Goodlet | fe0fd1a1c1 | |
Tyler Goodlet | dd23e78de1 | |
Tyler Goodlet | 89b44f8163 | |
Tyler Goodlet | 2819b6a5b2 | |
Tyler Goodlet | f2671ed026 | |
Tyler Goodlet | 41924c86a6 | |
Tyler Goodlet | 206c7c0720 | |
Tyler Goodlet | bf0ac3116c | |
Tyler Goodlet | bb732cefd0 | |
Tyler Goodlet | 74b819a857 | |
Tyler Goodlet | 8892204c84 | |
Tyler Goodlet | 8f4bbf1cbf | |
Tyler Goodlet | 21dccb2e79 | |
Tyler Goodlet | aea8f63bae | |
Tyler Goodlet | 7964a9f6f8 | |
Tyler Goodlet | 99c4319940 | |
Tyler Goodlet | e5195264a1 | |
Tyler Goodlet | 42f9d10252 | |
Tyler Goodlet | 345573e602 | |
Tyler Goodlet | 4e60c17375 | |
Tyler Goodlet | 6b7b58346f | |
Tyler Goodlet | 3cac323421 | |
goodboy | 4902e184e9 | |
Tyler Goodlet | 05790a20c1 | |
Tyler Goodlet | 565c603300 | |
Tyler Goodlet | f0d78e1a6e | |
Tyler Goodlet | ce01f6b21c | |
Tyler Goodlet | 0906559ed9 | |
Tyler Goodlet | 38d03858d7 | |
Tyler Goodlet | 98de2fab31 | |
goodboy | 80121ed211 | |
Tyler Goodlet | 41983edc43 | |
Tyler Goodlet | 5168700fbf | |
Tyler Goodlet | 673c4a8c66 | |
Tyler Goodlet | 932b841176 | |
Tyler Goodlet | f594f1bdda | |
Tyler Goodlet | 53e3648eca | |
Tyler Goodlet | fc36503f4f | |
Tyler Goodlet | 4e7ab54452 | |
goodboy | 86d020d309 | |
Tyler Goodlet | bb3f35cdd0 | |
Tyler Goodlet | f94b7cd991 | |
Tyler Goodlet | f6af5c7bf8 | |
Tyler Goodlet | 9740a585d3 | |
Tyler Goodlet | b700dc34a8 | |
Tyler Goodlet | 9bc1c6f385 | |
goodboy | f4973e90e9 | |
Tyler Goodlet | 780e3dd13d | |
Tyler Goodlet | e0419b24ec | |
goodboy | 71f19f217d | |
Tyler Goodlet | 8901272854 | |
Tyler Goodlet | 7c151bed48 | |
Tyler Goodlet | 80897a8f2b | |
goodboy | 62983684d1 | |
Tyler Goodlet | 1c63bb6130 | |
goodboy | bfe99f29b8 | |
Tyler Goodlet | 9c27858aaf | |
Tyler Goodlet | 597ae4b690 | |
Tyler Goodlet | fa354ffe2b | |
Tyler Goodlet | 333fad8819 | |
Tyler Goodlet | 90593611bb | |
Tyler Goodlet | 9c43bb28f1 | |
Tyler Goodlet | e45251db56 | |
Tyler Goodlet | faf751acac | |
Tyler Goodlet | 20d281f619 | |
Tyler Goodlet | f3606d5bd8 | |
Tyler Goodlet | 032e14e326 | |
Tyler Goodlet | c322a193f2 | |
Tyler Goodlet | 46963c2e63 | |
Tyler Goodlet | 9b77b8c9ee | |
Tyler Goodlet | 13c8300226 | |
goodboy | 1109d96263 | |
Tyler Goodlet | 65b4bc8888 | |
Tyler Goodlet | bef9946f91 | |
Tyler Goodlet | c30cece37a | |
Tyler Goodlet | 509082c935 | |
Tyler Goodlet | 75bb1added | |
goodboy | 6e5590dad6 | |
Tyler Goodlet | 76a0492028 | |
Tyler Goodlet | 4eab4a0213 | |
Tyler Goodlet | 0edc6a26bc | |
Tyler Goodlet | c5acc3b969 | |
Tyler Goodlet | 17e195aacf | |
Tyler Goodlet | c65756ed80 | |
Tyler Goodlet | 927decc88d | |
Tyler Goodlet | 17bfa120cc | |
Tyler Goodlet | 77ddc073e8 | |
goodboy | 26bebc42b7 | |
Tyler Goodlet | 87de28fd88 | |
Tyler Goodlet | 56b29c27de | |
Tyler Goodlet | adf9a1d0aa | |
Tyler Goodlet | 25a27e780d | |
Tyler Goodlet | c265f3f94e | |
Tyler Goodlet | 2900ceb003 | |
Tyler Goodlet | b6ae77b5ac | |
Tyler Goodlet | 949cb2c9fe | |
goodboy | 094206ee9d | |
Tyler Goodlet | debbf64d58 | |
Tyler Goodlet | 070e6ba459 | |
Tyler Goodlet | 7e004c0688 | |
Tyler Goodlet | ffe88de53b | |
Tyler Goodlet | d29a915d48 | |
Tyler Goodlet | be87caa99b | |
goodboy | 0b51ebfe11 | |
Tyler Goodlet | 4bf7992200 | |
Tyler Goodlet | 41296448e8 | |
Tyler Goodlet | 9650055519 | |
Tyler Goodlet | 532974fb90 | |
Tyler Goodlet | b1d72b77c9 | |
Tyler Goodlet | a2171c7e71 | |
goodboy | 30986d6b64 | |
Tyler Goodlet | 9ab04b1f6b | |
Tyler Goodlet | b3ff4b7804 | |
goodboy | d27bdbd40e | |
Tyler Goodlet | a95b0dc05e | |
goodboy | 909c996346 | |
Giacomo Camporini | adc8e5c009 | |
Giacomo Camporini | 5eed85d5dd | |
Giacomo Camporini | 137fed790f | |
goodboy | 96123f21d2 | |
overclockworked64 | 338aa2b74a | |
goodboy | 89551ef371 | |
Tyler Goodlet | 884bdf2d57 | |
goodboy | 343b2803b5 | |
Tyler Goodlet | cdef579d22 | |
Tyler Goodlet | f9400b4beb | |
Tyler Goodlet | 45cdf25f14 | |
Tyler Goodlet | 0a5818fe05 | |
Tyler Goodlet | fd6e18eba4 | |
goodboy | bbcdbaaba4 | |
Tyler Goodlet | 9b4cdb00e6 | |
Tyler Goodlet | 9b14d82086 | |
Tyler Goodlet | 4d1a48a47b | |
Tyler Goodlet | 4c0cfa68ac | |
Tyler Goodlet | 73d252e09e | |
Tyler Goodlet | 1fdcaf36f3 | |
Tyler Goodlet | 6952c7defa | |
Tyler Goodlet | 7237d696ce | |
Tyler Goodlet | b463841019 | |
Tyler Goodlet | d65912e1ae | |
Tyler Goodlet | 24078f2d6e | |
Tyler Goodlet | 56cc98375e | |
Tyler Goodlet | 9a2de90de6 | |
Tyler Goodlet | 2b9b29eb71 | |
Tyler Goodlet | b69412a903 | |
Tyler Goodlet | c4b3bb354e | |
Tyler Goodlet | 6803891bd7 | |
Tyler Goodlet | 5f4094691d | |
Tyler Goodlet | c48c68c0bc | |
Tyler Goodlet | ad2567dd73 | |
Tyler Goodlet | 44d0e9fc32 | |
Tyler Goodlet | d27ddb7bbb | |
Tyler Goodlet | 9bc94b5ccc | |
Tyler Goodlet | e6687bcdc4 | |
Tyler Goodlet | e815f766f6 | |
Tyler Goodlet | c19123b588 | |
Tyler Goodlet | 8704664719 | |
Tyler Goodlet | 04c0eda69d | |
Tyler Goodlet | 1114b6980e | |
Tyler Goodlet | 56357242e9 | |
Tyler Goodlet | 0ab5e5cadd | |
Tyler Goodlet | 06fa650ed0 | |
Tyler Goodlet | 446feff172 | |
Tyler Goodlet | 299e4192b0 | |
Tyler Goodlet | 41eddffc2c | |
Tyler Goodlet | 7a65165279 | |
Tyler Goodlet | b376b7cd32 | |
Tyler Goodlet | c262b1a3e8 | |
Tyler Goodlet | d9dac3f36c | |
Tyler Goodlet | 325c0cdb1b | |
Tyler Goodlet | 55e210fec6 | |
Tyler Goodlet | aa24bbc11c | |
Tyler Goodlet | 793bcfb7d4 | |
Tyler Goodlet | d80f8d7a39 | |
Tyler Goodlet | 340effae11 | |
Tyler Goodlet | 509ae132ec | |
Tyler Goodlet | 80f47dece2 | |
Tyler Goodlet | 2cf87146a3 | |
Tyler Goodlet | 8070b16bd0 | |
Tyler Goodlet | 1406ddc5ee | |
Tyler Goodlet | 055788cf16 | |
Tyler Goodlet | 1825b21d2c | |
Tyler Goodlet | acd63d0c89 | |
goodboy | cdf1f8c2f7 | |
Tyler Goodlet | 8eff788d2d | |
Tyler Goodlet | 916e27eedc | |
Tyler Goodlet | 98a830ccba | |
Tyler Goodlet | 8c004c1f36 | |
Tyler Goodlet | e2139c2bf0 | |
Tyler Goodlet | 9650b010de | |
Tyler Goodlet | 5d424e3703 | |
Tyler Goodlet | c38d0f826e | |
goodboy | 4001d2c3fc | |
Tyler Goodlet | 953d15b67d | |
Tyler Goodlet | da5e36bf0c | |
Tyler Goodlet | 21a9c47496 | |
Tyler Goodlet | 67dc0d014c | |
Tyler Goodlet | 9b1d8bf7b0 | |
Tyler Goodlet | 26394dd8df | |
Tyler Goodlet | 11e64426f6 | |
Tyler Goodlet | 213447008b | |
Tyler Goodlet | f617da6ff1 | |
Tyler Goodlet | 52627a6326 | |
Tyler Goodlet | 3826bc9972 | |
Tyler Goodlet | b210278e2f | |
Tyler Goodlet | 4a0252baf2 | |
Tyler Goodlet | ac22b4a875 | |
Tyler Goodlet | 5f41dbf34f | |
goodboy | 2d6fbd5437 | |
Tyler Goodlet | 325e550ff3 | |
Tyler Goodlet | b5d62909ff | |
Tyler Goodlet | 57f2aca18c | |
Tyler Goodlet | 1652716574 | |
Tyler Goodlet | f2ba961e81 | |
Tyler Goodlet | 79d63585b0 | |
Tyler Goodlet | 3deb1b91e6 | |
Tyler Goodlet | 61e134dc5d | |
goodboy | cfdc95fe7f | |
Tyler Goodlet | 6f94ffc304 | |
goodboy | 56297cf25c | |
Tyler Goodlet | 94f098e5f7 | |
Tyler Goodlet | 949aa9c405 | |
Tyler Goodlet | a38a983225 | |
Tyler Goodlet | 4f411d6926 | |
Tyler Goodlet | 9bee513136 | |
Tyler Goodlet | 5d9e3d1163 | |
Tyler Goodlet | 95c52436e5 | |
Tyler Goodlet | e51c0e17a2 | |
Tyler Goodlet | 92c6ec1882 | |
Tyler Goodlet | 72eef2a4a1 | |
Tyler Goodlet | 205e254072 | |
Tyler Goodlet | 9bd5226e76 | |
Tyler Goodlet | e899cc42bf | |
goodboy | f7c9056419 | |
Tyler Goodlet | faaecbf810 | |
Tyler Goodlet | 703dee8a59 | |
Tyler Goodlet | df59071747 | |
Tyler Goodlet | 4856285dee | |
Tyler Goodlet | efba5229fc | |
Tyler Goodlet | fd6f4574ce | |
Tyler Goodlet | 52a2b7a5ed | |
Tyler Goodlet | 63ecae70c4 | |
Tyler Goodlet | 4b40599c48 | |
Tyler Goodlet | a79cdc7b44 | |
Tyler Goodlet | c9132de7dc | |
Tyler Goodlet | 1f8e1cccbb | |
Tyler Goodlet | 58805a0430 | |
Tyler Goodlet | 142083d81b | |
Tyler Goodlet | 318027ebd1 | |
Tyler Goodlet | b826ec8103 | |
Tyler Goodlet | 4ea5c9b5db | |
Tyler Goodlet | f3432bd8fb | |
Tyler Goodlet | 41a3e6a9ca | |
Tyler Goodlet | 7b9d410c4d | |
Tyler Goodlet | 2b05ffcc23 | |
Tyler Goodlet | 185dbc7e3f | |
Tyler Goodlet | 2680a9473d | |
Tyler Goodlet | 92b540d518 | |
Tyler Goodlet | 6751349987 | |
Tyler Goodlet | d307eab118 | |
Tyler Goodlet | 872b24aedd | |
Tyler Goodlet | c5c3f7e789 | |
Tyler Goodlet | 3f6099f161 | |
Tyler Goodlet | 568902a5a9 | |
Tyler Goodlet | f4793af2b9 | |
goodboy | ae6d751d71 | |
Tyler Goodlet | 94a3cc532c | |
Tyler Goodlet | 08e9593306 | |
Tyler Goodlet | 14f84571fb | |
Tyler Goodlet | e561a4908f | |
Tyler Goodlet | a29924f330 | |
Tyler Goodlet | 46070f99de | |
Tyler Goodlet | d81eb1a51e | |
Tyler Goodlet | d817f1a658 | |
Tyler Goodlet | a23afb0bb8 | |
Tyler Goodlet | 1976e61d1a | |
Tyler Goodlet | 0ac3397dbb | |
Tyler Goodlet | 62b2867e07 | |
Tyler Goodlet | bf6958cdbe | |
goodboy | d05885d650 | |
Tyler Goodlet | 77fc705b1f | |
Tyler Goodlet | 16a3321a38 | |
Tyler Goodlet | 7eb465a699 | |
Tyler Goodlet | 121f7fd844 | |
goodboy | ac821bdd94 | |
Tyler Goodlet | f6de7e0afd | |
Tyler Goodlet | 0e7234aa68 | |
Tyler Goodlet | 83da92d4cb | |
Tyler Goodlet | 57e98b25e7 | |
Tyler Goodlet | 095c94b1d2 | |
Tyler Goodlet | f32ccd76aa | |
goodboy | b527fdbe1a | |
Tyler Goodlet | 6b0366fe04 | |
Tyler Goodlet | dbe5d96d66 | |
goodboy | 08fa55a8c3 | |
Tyler Goodlet | 546e1b2fa3 | |
Tyler Goodlet | 94a6fefede | |
Tyler Goodlet | 74f460eba7 | |
Tyler Goodlet | 4cbb8641de | |
Tyler Goodlet | 7efb7da300 | |
goodboy | 2c12d39617 | |
Tyler Goodlet | 6a063b3814 | |
Tyler Goodlet | 9da1abeecd | |
Tyler Goodlet | 3452e18e6d | |
Tyler Goodlet | 8fdc548676 | |
goodboy | 5dbe8e4b14 | |
goodboy | 9c13827a14 | |
overclockworked64 | 6da76949fd | |
overclockworked64 | 49dd230b4f | |
overclockworked64 | c7f59bd483 | |
Tyler Goodlet | 083b73ad4a | |
goodboy | 925af28092 | |
Tyler Goodlet | d0f5c7a5e2 | |
goodboy | ebf080b8a2 | |
overclockworked64 | 50400359b8 | |
goodboy | 71b8f9f1ea |
|
@ -1,6 +1,11 @@
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on: push
|
on:
|
||||||
|
# any time someone pushes a new branch to origin
|
||||||
|
push:
|
||||||
|
|
||||||
|
# Allows you to run this workflow manually from the Actions tab
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
|
@ -15,26 +20,51 @@ jobs:
|
||||||
- name: Setup python
|
- name: Setup python
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v2
|
||||||
with:
|
with:
|
||||||
python-version: '3.9'
|
python-version: '3.10'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip install -U . --upgrade-strategy eager -r requirements-test.txt
|
run: pip install -U . --upgrade-strategy eager -r requirements-test.txt
|
||||||
|
|
||||||
- name: Run MyPy check
|
- name: Run MyPy check
|
||||||
run: mypy tractor/ --ignore-missing-imports
|
run: mypy tractor/ --ignore-missing-imports --show-traceback
|
||||||
|
|
||||||
testing:
|
# test that we can generate a software distribution and install it
|
||||||
|
# thus avoid missing file issues after packaging.
|
||||||
|
sdist-linux:
|
||||||
|
name: 'sdist'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Setup python
|
||||||
|
uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: '3.10'
|
||||||
|
|
||||||
|
- name: Build sdist
|
||||||
|
run: python setup.py sdist --formats=zip
|
||||||
|
|
||||||
|
- name: Install sdist from .zips
|
||||||
|
run: python -m pip install dist/*.zip
|
||||||
|
|
||||||
|
|
||||||
|
testing-linux:
|
||||||
name: '${{ matrix.os }} Python ${{ matrix.python }} - ${{ matrix.spawn_backend }}'
|
name: '${{ matrix.os }} Python ${{ matrix.python }} - ${{ matrix.spawn_backend }}'
|
||||||
timeout-minutes: 9
|
timeout-minutes: 10
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, windows-latest]
|
os: [ubuntu-latest]
|
||||||
python: ['3.9']
|
python: ['3.10']
|
||||||
spawn_backend: ['trio', 'mp']
|
spawn_backend: [
|
||||||
|
'trio',
|
||||||
|
'mp_spawn',
|
||||||
|
'mp_forkserver',
|
||||||
|
]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
|
@ -49,34 +79,53 @@ jobs:
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip install -U . -r requirements-test.txt -r requirements-docs.txt --upgrade-strategy eager
|
run: pip install -U . -r requirements-test.txt -r requirements-docs.txt --upgrade-strategy eager
|
||||||
|
|
||||||
- name: Run tests
|
- name: List dependencies
|
||||||
run: pytest tests/ --spawn-backend=${{ matrix.spawn_backend }} -rs
|
run: pip list
|
||||||
|
|
||||||
testing-msgspec:
|
|
||||||
# runs py3.9 jobs on all OS's but with optional `msgspec` dep installed
|
|
||||||
name: '${{ matrix.os }} Python ${{ matrix.python }} - ${{ matrix.spawn_backend }} - msgspec'
|
|
||||||
timeout-minutes: 10
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
os: [ubuntu-latest, windows-latest]
|
|
||||||
python: ['3.9']
|
|
||||||
spawn_backend: ['trio', 'mp']
|
|
||||||
|
|
||||||
steps:
|
|
||||||
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Setup python
|
|
||||||
uses: actions/setup-python@v2
|
|
||||||
with:
|
|
||||||
python-version: '${{ matrix.python }}'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pip install -U .[msgspec] -r requirements-test.txt -r requirements-docs.txt --upgrade-strategy eager
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: pytest tests/ --spawn-backend=${{ matrix.spawn_backend }} -rs
|
run: pytest tests/ --spawn-backend=${{ matrix.spawn_backend }} -rsx
|
||||||
|
|
||||||
|
# We skip 3.10 on windows for now due to not having any collabs to
|
||||||
|
# debug the CI failures. Anyone wanting to hack and solve them is very
|
||||||
|
# welcome, but our primary user base is not using that OS.
|
||||||
|
|
||||||
|
# TODO: use job filtering to accomplish instead of repeated
|
||||||
|
# boilerplate as is above XD:
|
||||||
|
# - https://docs.github.com/en/actions/learn-github-actions/managing-complex-workflows
|
||||||
|
# - https://docs.github.com/en/actions/learn-github-actions/managing-complex-workflows#using-a-build-matrix
|
||||||
|
# - https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#jobsjob_idif
|
||||||
|
# testing-windows:
|
||||||
|
# name: '${{ matrix.os }} Python ${{ matrix.python }} - ${{ matrix.spawn_backend }}'
|
||||||
|
# timeout-minutes: 12
|
||||||
|
# runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
# strategy:
|
||||||
|
# fail-fast: false
|
||||||
|
# matrix:
|
||||||
|
# os: [windows-latest]
|
||||||
|
# python: ['3.10']
|
||||||
|
# spawn_backend: ['trio', 'mp']
|
||||||
|
|
||||||
|
# steps:
|
||||||
|
|
||||||
|
# - name: Checkout
|
||||||
|
# uses: actions/checkout@v2
|
||||||
|
|
||||||
|
# - name: Setup python
|
||||||
|
# uses: actions/setup-python@v2
|
||||||
|
# with:
|
||||||
|
# python-version: '${{ matrix.python }}'
|
||||||
|
|
||||||
|
# - name: Install dependencies
|
||||||
|
# run: pip install -U . -r requirements-test.txt -r requirements-docs.txt --upgrade-strategy eager
|
||||||
|
|
||||||
|
# # TODO: pretty sure this solves debugger deps-issues on windows, but it needs to
|
||||||
|
# # be verified by someone with a native setup.
|
||||||
|
# # - name: Force pyreadline3
|
||||||
|
# # run: pip uninstall pyreadline; pip install -U pyreadline3
|
||||||
|
|
||||||
|
# - name: List dependencies
|
||||||
|
# run: pip list
|
||||||
|
|
||||||
|
# - name: Run tests
|
||||||
|
# run: pytest tests/ --spawn-backend=${{ matrix.spawn_backend }} -rsx
|
||||||
|
|
147
LICENSE
147
LICENSE
|
@ -1,23 +1,21 @@
|
||||||
GNU GENERAL PUBLIC LICENSE
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 29 June 2007
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
of this license document, but changing it is not allowed.
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
Preamble
|
Preamble
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
software and other kinds of works.
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
The licenses for most software and other practical works are designed
|
||||||
to take away your freedom to share and change the works. By contrast,
|
to take away your freedom to share and change the works. By contrast,
|
||||||
the GNU General Public License is intended to guarantee your freedom to
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
share and change all versions of a program--to make sure it remains free
|
share and change all versions of a program--to make sure it remains free
|
||||||
software for all its users. We, the Free Software Foundation, use the
|
software for all its users.
|
||||||
GNU General Public License for most of our software; it applies also to
|
|
||||||
any other work released this way by its authors. You can apply it to
|
|
||||||
your programs, too.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
When we speak of free software, we are referring to freedom, not
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
||||||
want it, that you can change the software or use pieces of it in new
|
want it, that you can change the software or use pieces of it in new
|
||||||
free programs, and that you know you can do these things.
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
To protect your rights, we need to prevent others from denying you
|
Developers that use our General Public Licenses protect your rights
|
||||||
these rights or asking you to surrender the rights. Therefore, you have
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
certain responsibilities if you distribute copies of the software, or if
|
you this License which gives you legal permission to copy, distribute
|
||||||
you modify it: responsibilities to respect the freedom of others.
|
and/or modify the software.
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether
|
A secondary benefit of defending all users' freedom is that
|
||||||
gratis or for a fee, you must pass on to the recipients the same
|
improvements made in alternate versions of the program, if they
|
||||||
freedoms that you received. You must make sure that they, too, receive
|
receive widespread use, become available for other developers to
|
||||||
or can get the source code. And you must show them these terms so they
|
incorporate. Many developers of free software are heartened and
|
||||||
know their rights.
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
Developers that use the GNU GPL protect your rights with two steps:
|
The GNU Affero General Public License is designed specifically to
|
||||||
(1) assert copyright on the software, and (2) offer you this License
|
ensure that, in such cases, the modified source code becomes available
|
||||||
giving you legal permission to copy, distribute and/or modify it.
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
For the developers' and authors' protection, the GPL clearly explains
|
An older license, called the Affero General Public License and
|
||||||
that there is no warranty for this free software. For both users' and
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
authors' sake, the GPL requires that modified versions be marked as
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
changed, so that their problems will not be attributed erroneously to
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
authors of previous versions.
|
this license.
|
||||||
|
|
||||||
Some devices are designed to deny users access to install or run
|
|
||||||
modified versions of the software inside them, although the manufacturer
|
|
||||||
can do so. This is fundamentally incompatible with the aim of
|
|
||||||
protecting users' freedom to change the software. The systematic
|
|
||||||
pattern of such abuse occurs in the area of products for individuals to
|
|
||||||
use, which is precisely where it is most unacceptable. Therefore, we
|
|
||||||
have designed this version of the GPL to prohibit the practice for those
|
|
||||||
products. If such problems arise substantially in other domains, we
|
|
||||||
stand ready to extend this provision to those domains in future versions
|
|
||||||
of the GPL, as needed to protect the freedom of users.
|
|
||||||
|
|
||||||
Finally, every program is threatened constantly by software patents.
|
|
||||||
States should not allow patents to restrict development and use of
|
|
||||||
software on general-purpose computers, but in those that do, we wish to
|
|
||||||
avoid the special danger that patents applied to a free program could
|
|
||||||
make it effectively proprietary. To prevent this, the GPL assures that
|
|
||||||
patents cannot be used to render the program non-free.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
The precise terms and conditions for copying, distribution and
|
||||||
modification follow.
|
modification follow.
|
||||||
|
@ -72,7 +60,7 @@ modification follow.
|
||||||
|
|
||||||
0. Definitions.
|
0. Definitions.
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU General Public License.
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
works, such as semiconductor masks.
|
works, such as semiconductor masks.
|
||||||
|
@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
||||||
the Program, the only way you could satisfy both those terms and this
|
the Program, the only way you could satisfy both those terms and this
|
||||||
License would be to refrain entirely from conveying the Program.
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
13. Use with the GNU Affero General Public License.
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
Notwithstanding any other provision of this License, you have
|
||||||
permission to link or combine any covered work with a work licensed
|
permission to link or combine any covered work with a work licensed
|
||||||
under version 3 of the GNU Affero General Public License into a single
|
under version 3 of the GNU General Public License into a single
|
||||||
combined work, and to convey the resulting work. The terms of this
|
combined work, and to convey the resulting work. The terms of this
|
||||||
License will continue to apply to the part which is the covered work,
|
License will continue to apply to the part which is the covered work,
|
||||||
but the special requirements of the GNU Affero General Public License,
|
but the work with which it is combined will remain governed by version
|
||||||
section 13, concerning interaction through a network will apply to the
|
3 of the GNU General Public License.
|
||||||
combination as such.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
the GNU General Public License from time to time. Such new versions will
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
be similar in spirit to the present version, but may differ in detail to
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
address new problems or concerns.
|
address new problems or concerns.
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
Each version is given a distinguishing version number. If the
|
||||||
Program specifies that a certain numbered version of the GNU General
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
Public License "or any later version" applies to it, you have the
|
Public License "or any later version" applies to it, you have the
|
||||||
option of following the terms and conditions either of that numbered
|
option of following the terms and conditions either of that numbered
|
||||||
version or of any later version published by the Free Software
|
version or of any later version published by the Free Software
|
||||||
Foundation. If the Program does not specify a version number of the
|
Foundation. If the Program does not specify a version number of the
|
||||||
GNU General Public License, you may choose any version ever published
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
by the Free Software Foundation.
|
by the Free Software Foundation.
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
If the Program specifies that a proxy can decide which future
|
||||||
versions of the GNU General Public License can be used, that proxy's
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
public statement of acceptance of a version permanently authorizes you
|
public statement of acceptance of a version permanently authorizes you
|
||||||
to choose that version for the Program.
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||||
Copyright (C) <year> <name of author>
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
(at your option) any later version.
|
(at your option) any later version.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
GNU General Public License for more details.
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU Affero General Public License
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short
|
If your software can interact with users remotely through a computer
|
||||||
notice like this when it starts in an interactive mode:
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
<program> Copyright (C) <year> <name of author>
|
interface could display a "Source" link that leads users to an archive
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
of the code. There are many ways you could offer source, and different
|
||||||
This is free software, and you are welcome to redistribute it
|
solutions will be better for different programs; see section 13 for the
|
||||||
under certain conditions; type `show c' for details.
|
specific requirements.
|
||||||
|
|
||||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
|
||||||
parts of the General Public License. Of course, your program's commands
|
|
||||||
might be different; for a GUI interface, you would use an "about box".
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
For more information on this, and how to apply and follow the GNU GPL, see
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
<http://www.gnu.org/licenses/>.
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
The GNU General Public License does not permit incorporating your program
|
|
||||||
into proprietary programs. If your program is a subroutine library, you
|
|
||||||
may consider it more useful to permit linking proprietary applications with
|
|
||||||
the library. If this is what you want to do, use the GNU Lesser General
|
|
||||||
Public License instead of this License. But first, please read
|
|
||||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
# https://packaging.python.org/en/latest/guides/using-manifest-in/#using-manifest-in
|
||||||
|
include docs/README.rst
|
375
NEWS.rst
375
NEWS.rst
|
@ -4,6 +4,381 @@ Changelog
|
||||||
|
|
||||||
.. towncrier release notes start
|
.. towncrier release notes start
|
||||||
|
|
||||||
|
tractor 0.1.0a5 (2022-08-03)
|
||||||
|
============================
|
||||||
|
|
||||||
|
This is our final release supporting Python 3.9 since we will be moving
|
||||||
|
internals to the new `match:` syntax from 3.10 going forward and
|
||||||
|
further, we have officially dropped usage of the `msgpack` library and
|
||||||
|
happily adopted `msgspec`.
|
||||||
|
|
||||||
|
Features
|
||||||
|
--------
|
||||||
|
|
||||||
|
- `#165 <https://github.com/goodboy/tractor/issues/165>`_: Add SIGINT
|
||||||
|
protection to our `pdbpp` based debugger subystem such that for
|
||||||
|
(single-depth) actor trees in debug mode we ignore interrupts in any
|
||||||
|
actor currently holding the TTY lock thus avoiding clobbering IPC
|
||||||
|
connections and/or task and process state when working in the REPL.
|
||||||
|
|
||||||
|
As a big note currently so called "nested" actor trees (trees with
|
||||||
|
actors having more then one parent/ancestor) are not fully supported
|
||||||
|
since we don't yet have a mechanism to relay the debug mode knowledge
|
||||||
|
"up" the actor tree (for eg. when handling a crash in a leaf actor).
|
||||||
|
As such currently there is a set of tests and known scenarios which will
|
||||||
|
result in process cloberring by the zombie repaing machinery and these
|
||||||
|
have been documented in https://github.com/goodboy/tractor/issues/320.
|
||||||
|
|
||||||
|
The implementation details include:
|
||||||
|
|
||||||
|
- utilizing a custom SIGINT handler which we apply whenever an actor's
|
||||||
|
runtime enters the debug machinery, which we also make sure the
|
||||||
|
stdlib's `pdb` configuration doesn't override (which it does by
|
||||||
|
default without special instance config).
|
||||||
|
- litter the runtime with `maybe_wait_for_debugger()` mostly in spots
|
||||||
|
where the root actor should block before doing embedded nursery
|
||||||
|
teardown ops which both cancel potential-children-in-deubg as well
|
||||||
|
as eventually trigger zombie reaping machinery.
|
||||||
|
- hardening of the TTY locking semantics/API both in terms of IPC
|
||||||
|
terminations and cancellation and lock release determinism from
|
||||||
|
sync debugger instance methods.
|
||||||
|
- factoring of locking infrastructure into a new `._debug.Lock` global
|
||||||
|
which encapsulates all details of the ``trio`` sync primitives and
|
||||||
|
task/actor uid management and tracking.
|
||||||
|
|
||||||
|
We also add `ctrl-c` cases throughout the test suite though these are
|
||||||
|
disabled for py3.9 (`pdbpp` UX differences that don't seem worth
|
||||||
|
compensating for, especially since this will be our last 3.9 supported
|
||||||
|
release) and there are a slew of marked cases that aren't expected to
|
||||||
|
work in CI more generally (as mentioned in the "nested" tree note
|
||||||
|
above) despite seemingly working when run manually on linux.
|
||||||
|
|
||||||
|
- `#304 <https://github.com/goodboy/tractor/issues/304>`_: Add a new
|
||||||
|
``to_asyncio.LinkedTaskChannel.subscribe()`` which gives task-oriented
|
||||||
|
broadcast functionality semantically equivalent to
|
||||||
|
``tractor.MsgStream.subscribe()`` this makes it possible for multiple
|
||||||
|
``trio``-side tasks to consume ``asyncio``-side task msgs in tandem.
|
||||||
|
|
||||||
|
Further Improvements to the test suite were added in this patch set
|
||||||
|
including a new scenario test for a sub-actor managed "service nursery"
|
||||||
|
(implementing the basics of a "service manager") including use of
|
||||||
|
*infected asyncio* mode. Further we added a lower level
|
||||||
|
``test_trioisms.py`` to start to track issues we need to work around in
|
||||||
|
``trio`` itself which in this case included a bug we were trying to
|
||||||
|
solve related to https://github.com/python-trio/trio/issues/2258.
|
||||||
|
|
||||||
|
|
||||||
|
Bug Fixes
|
||||||
|
---------
|
||||||
|
|
||||||
|
- `#318 <https://github.com/goodboy/tractor/issues/318>`_: Fix
|
||||||
|
a previously undetected ``trio``-``asyncio`` task lifetime linking
|
||||||
|
issue with the ``to_asyncio.open_channel_from()`` api where both sides
|
||||||
|
where not properly waiting/signalling termination and it was possible
|
||||||
|
for ``asyncio``-side errors to not propagate due to a race condition.
|
||||||
|
|
||||||
|
The implementation fix summary is:
|
||||||
|
- add state to signal the end of the ``trio`` side task to be
|
||||||
|
read by the ``asyncio`` side and always cancel any ongoing
|
||||||
|
task in such cases.
|
||||||
|
- always wait on the ``asyncio`` task termination from the ``trio``
|
||||||
|
side on error before maybe raising said error.
|
||||||
|
- always close the ``trio`` mem chan on exit to ensure the other
|
||||||
|
side can detect it and follow.
|
||||||
|
|
||||||
|
|
||||||
|
Trivial/Internal Changes
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
- `#248 <https://github.com/goodboy/tractor/issues/248>`_: Adjust the
|
||||||
|
`tractor._spawn.soft_wait()` strategy to avoid sending an actor cancel
|
||||||
|
request (via `Portal.cancel_actor()`) if either the child process is
|
||||||
|
detected as having terminated or the IPC channel is detected to be
|
||||||
|
closed.
|
||||||
|
|
||||||
|
This ensures (even) more deterministic inter-actor cancellation by
|
||||||
|
avoiding the timeout condition where possible when a whild never
|
||||||
|
sucessfully spawned, crashed, or became un-contactable over IPC.
|
||||||
|
|
||||||
|
- `#295 <https://github.com/goodboy/tractor/issues/295>`_: Add an
|
||||||
|
experimental ``tractor.msg.NamespacePath`` type for passing Python
|
||||||
|
objects by "reference" through a ``str``-subtype message and using the
|
||||||
|
new ``pkgutil.resolve_name()`` for reference loading.
|
||||||
|
|
||||||
|
- `#298 <https://github.com/goodboy/tractor/issues/298>`_: Add a new
|
||||||
|
`tractor.experimental` subpackage for staging new high level APIs and
|
||||||
|
subystems that we might eventually make built-ins.
|
||||||
|
|
||||||
|
- `#300 <https://github.com/goodboy/tractor/issues/300>`_: Update to and
|
||||||
|
pin latest ``msgpack`` (1.0.3) and ``msgspec`` (0.4.0) both of which
|
||||||
|
required adjustments for backwards imcompatible API tweaks.
|
||||||
|
|
||||||
|
- `#303 <https://github.com/goodboy/tractor/issues/303>`_: Fence off
|
||||||
|
``multiprocessing`` imports until absolutely necessary in an effort to
|
||||||
|
avoid "resource tracker" spawning side effects that seem to have
|
||||||
|
varying degrees of unreliability per Python release. Port to new
|
||||||
|
``msgspec.DecodeError``.
|
||||||
|
|
||||||
|
- `#305 <https://github.com/goodboy/tractor/issues/305>`_: Add
|
||||||
|
``tractor.query_actor()`` an addr looker-upper which doesn't deliver
|
||||||
|
a ``Portal`` instance and instead just a socket address ``tuple``.
|
||||||
|
|
||||||
|
Sometimes it's handy to just have a simple way to figure out if
|
||||||
|
a "service" actor is up, so add this discovery helper for that. We'll
|
||||||
|
prolly just leave it undocumented for now until we figure out
|
||||||
|
a longer-term/better discovery system.
|
||||||
|
|
||||||
|
- `#316 <https://github.com/goodboy/tractor/issues/316>`_: Run windows
|
||||||
|
CI jobs on python 3.10 after some hacks for ``pdbpp`` dependency
|
||||||
|
issues.
|
||||||
|
|
||||||
|
Issue was to do with the now deprecated `pyreadline` project which
|
||||||
|
should be changed over to `pyreadline3`.
|
||||||
|
|
||||||
|
- `#317 <https://github.com/goodboy/tractor/issues/317>`_: Drop use of
|
||||||
|
the ``msgpack`` package and instead move fully to the ``msgspec``
|
||||||
|
codec library.
|
||||||
|
|
||||||
|
We've now used ``msgspec`` extensively in production and there's no
|
||||||
|
reason to not use it as default. Further this change preps us for the up
|
||||||
|
and coming typed messaging semantics (#196), dialog-unprotocol system
|
||||||
|
(#297), and caps-based messaging-protocols (#299) planned before our
|
||||||
|
first beta.
|
||||||
|
|
||||||
|
|
||||||
|
tractor 0.1.0a4 (2021-12-18)
|
||||||
|
============================
|
||||||
|
|
||||||
|
Features
|
||||||
|
--------
|
||||||
|
- `#275 <https://github.com/goodboy/tractor/issues/275>`_: Re-license
|
||||||
|
code base under AGPLv3. Also see `#274
|
||||||
|
<https://github.com/goodboy/tractor/pull/274>`_ for majority
|
||||||
|
contributor consensus on this decision.
|
||||||
|
|
||||||
|
- `#121 <https://github.com/goodboy/tractor/issues/121>`_: Add
|
||||||
|
"infected ``asyncio`` mode; a sub-system to spawn and control
|
||||||
|
``asyncio`` actors using ``trio``'s guest-mode.
|
||||||
|
|
||||||
|
This gets us the following very interesting functionality:
|
||||||
|
|
||||||
|
- ability to spawn an actor that has a process entry point of
|
||||||
|
``asyncio.run()`` by passing ``infect_asyncio=True`` to
|
||||||
|
``Portal.start_actor()`` (and friends).
|
||||||
|
- the ``asyncio`` actor embeds ``trio`` using guest-mode and starts
|
||||||
|
a main ``trio`` task which runs the ``tractor.Actor._async_main()``
|
||||||
|
entry point engages all the normal ``tractor`` runtime IPC/messaging
|
||||||
|
machinery; for all purposes the actor is now running normally on
|
||||||
|
a ``trio.run()``.
|
||||||
|
- the actor can now make one-to-one task spawning requests to the
|
||||||
|
underlying ``asyncio`` event loop using either of:
|
||||||
|
|
||||||
|
* ``to_asyncio.run_task()`` to spawn and run an ``asyncio`` task to
|
||||||
|
completion and block until a return value is delivered.
|
||||||
|
* ``async with to_asyncio.open_channel_from():`` which spawns a task
|
||||||
|
and hands it a pair of "memory channels" to allow for bi-directional
|
||||||
|
streaming between the now SC-linked ``trio`` and ``asyncio`` tasks.
|
||||||
|
|
||||||
|
The output from any call(s) to ``asyncio`` can be handled as normal in
|
||||||
|
``trio``/``tractor`` task operation with the caveat of the overhead due
|
||||||
|
to guest-mode use.
|
||||||
|
|
||||||
|
For more details see the `original PR
|
||||||
|
<https://github.com/goodboy/tractor/pull/121>`_ and `issue
|
||||||
|
<https://github.com/goodboy/tractor/issues/120>`_.
|
||||||
|
|
||||||
|
- `#257 <https://github.com/goodboy/tractor/issues/257>`_: Add
|
||||||
|
``trionics.maybe_open_context()`` an actor-scoped async multi-task
|
||||||
|
context manager resource caching API.
|
||||||
|
|
||||||
|
Adds an SC-safe cacheing async context manager api that only enters on
|
||||||
|
the *first* task entry and only exits on the *last* task exit while in
|
||||||
|
between delivering the same cached value per input key. Keys can be
|
||||||
|
either an explicit ``key`` named arg provided by the user or a
|
||||||
|
hashable ``kwargs`` dict (will be converted to a ``list[tuple]``) which
|
||||||
|
is passed to the underlying manager function as input.
|
||||||
|
|
||||||
|
- `#261 <https://github.com/goodboy/tractor/issues/261>`_: Add
|
||||||
|
cross-actor-task ``Context`` oriented error relay, a new stream
|
||||||
|
overrun error-signal ``StreamOverrun``, and support disabling
|
||||||
|
``MsgStream`` backpressure as the default before a stream is opened or
|
||||||
|
by choice of the user.
|
||||||
|
|
||||||
|
We added stricter semantics around ``tractor.Context.open_stream():``
|
||||||
|
particularly to do with streams which are only opened at one end.
|
||||||
|
Previously, if only one end opened a stream there was no way for that
|
||||||
|
sender to know if msgs are being received until first, the feeder mem
|
||||||
|
chan on the receiver side hit a backpressure state and then that
|
||||||
|
condition delayed its msg loop processing task to eventually create
|
||||||
|
backpressure on the associated IPC transport. This is non-ideal in the
|
||||||
|
case where the receiver side never opened a stream by mistake since it
|
||||||
|
results in silent block of the sender and no adherence to the underlying
|
||||||
|
mem chan buffer size settings (which is still unsolved btw).
|
||||||
|
|
||||||
|
To solve this we add non-backpressure style message pushing inside
|
||||||
|
``Actor._push_result()`` by default and only use the backpressure
|
||||||
|
``trio.MemorySendChannel.send()`` call **iff** the local end of the
|
||||||
|
context has entered ``Context.open_stream():``. This way if the stream
|
||||||
|
was never opened but the mem chan is overrun, we relay back to the
|
||||||
|
sender a (new exception) ``SteamOverrun`` error which is raised in the
|
||||||
|
sender's scope with a special error message about the stream never
|
||||||
|
having been opened. Further, this behaviour (non-backpressure style
|
||||||
|
where senders can expect an error on overruns) can now be enabled with
|
||||||
|
``.open_stream(backpressure=False)`` and the underlying mem chan size
|
||||||
|
can be specified with a kwarg ``msg_buffer_size: int``.
|
||||||
|
|
||||||
|
Further bug fixes and enhancements in this changeset include:
|
||||||
|
|
||||||
|
- fix a race we were ignoring where if the callee task opened a context
|
||||||
|
it could enter ``Context.open_stream()`` before calling
|
||||||
|
``.started()``.
|
||||||
|
- Disallow calling ``Context.started()`` more then once.
|
||||||
|
- Enable ``Context`` linked tasks error relaying via the new
|
||||||
|
``Context._maybe_raise_from_remote_msg()`` which (for now) uses
|
||||||
|
a simple ``trio.Nursery.start_soon()`` to raise the error via closure
|
||||||
|
in the local scope.
|
||||||
|
|
||||||
|
- `#267 <https://github.com/goodboy/tractor/issues/267>`_: This
|
||||||
|
(finally) adds fully acknowledged remote cancellation messaging
|
||||||
|
support for both explicit ``Portal.cancel_actor()`` calls as well as
|
||||||
|
when there is a "runtime-wide" cancellations (eg. during KBI or
|
||||||
|
general actor nursery exception handling which causes a full actor
|
||||||
|
"crash"/termination).
|
||||||
|
|
||||||
|
You can think of this as the most ideal case in 2-generals where the
|
||||||
|
actor requesting the cancel of its child is able to always receive back
|
||||||
|
the ACK to that request. This leads to a more deterministic shutdown of
|
||||||
|
the child where the parent is able to wait for the child to fully
|
||||||
|
respond to the request. On a localhost setup, where the parent can
|
||||||
|
monitor the state of the child through process or other OS APIs instead
|
||||||
|
of solely through IPC messaging, the parent can know whether or not the
|
||||||
|
child decided to cancel with more certainty. In the case of separate
|
||||||
|
hosts, we still rely on a simple timeout approach until such a time
|
||||||
|
where we prefer to get "fancier".
|
||||||
|
|
||||||
|
- `#271 <https://github.com/goodboy/tractor/issues/271>`_: Add a per
|
||||||
|
actor ``debug_mode: bool`` control to our nursery.
|
||||||
|
|
||||||
|
This allows spawning actors via ``ActorNursery.start_actor()`` (and
|
||||||
|
other dependent methods) with a ``debug_mode=True`` flag much like
|
||||||
|
``tractor.open_nursery():`` such that per process crash handling
|
||||||
|
can be toggled for cases where a user does not need/want all child actors
|
||||||
|
to drop into the debugger on error. This is often useful when you have
|
||||||
|
actor-tasks which are expected to error often (and be re-run) but want
|
||||||
|
to specifically interact with some (problematic) child.
|
||||||
|
|
||||||
|
|
||||||
|
Bugfixes
|
||||||
|
--------
|
||||||
|
|
||||||
|
- `#239 <https://github.com/goodboy/tractor/issues/239>`_: Fix
|
||||||
|
keyboard interrupt handling in ``Portal.open_context()`` blocks.
|
||||||
|
|
||||||
|
Previously this was not triggering cancellation of the remote task
|
||||||
|
context and could result in hangs if a stream was also opened. This
|
||||||
|
fix is to accept `BaseException` since it is likely any other top
|
||||||
|
level exception other then KBI (even though not expected) should also
|
||||||
|
get this result.
|
||||||
|
|
||||||
|
- `#264 <https://github.com/goodboy/tractor/issues/264>`_: Fix
|
||||||
|
``Portal.run_in_actor()`` returns ``None`` result.
|
||||||
|
|
||||||
|
``None`` was being used as the cached result flag and obviously breaks
|
||||||
|
on a ``None`` returned from the remote target task. This would cause an
|
||||||
|
infinite hang if user code ever called ``Portal.result()`` *before* the
|
||||||
|
nursery exit. The simple fix is to use the *return message* as the
|
||||||
|
initial "no-result-received-yet" flag value and, once received, the
|
||||||
|
return value is read from the message to avoid the cache logic error.
|
||||||
|
|
||||||
|
- `#266 <https://github.com/goodboy/tractor/issues/266>`_: Fix
|
||||||
|
graceful cancellation of daemon actors
|
||||||
|
|
||||||
|
Previously, his was a bug where if the soft wait on a sub-process (the
|
||||||
|
``await .proc.wait()``) in the reaper task teardown was cancelled we
|
||||||
|
would fail over to the hard reaping sequence (meant for culling off any
|
||||||
|
potential zombies via system kill signals). The hard reap has a timeout
|
||||||
|
of 3s (currently though in theory we could make it shorter?) before
|
||||||
|
system signalling kicks in. This means that any daemon actor still
|
||||||
|
running during nursery exit would get hard reaped (3s later) instead of
|
||||||
|
cancelled via IPC message. Now we catch the ``trio.Cancelled``, call
|
||||||
|
``Portal.cancel_actor()`` on the daemon and expect the child to
|
||||||
|
self-terminate after the runtime cancels and shuts down the process.
|
||||||
|
|
||||||
|
- `#278 <https://github.com/goodboy/tractor/issues/278>`_: Repair
|
||||||
|
inter-actor stream closure semantics to work correctly with
|
||||||
|
``tractor.trionics.BroadcastReceiver`` task fan out usage.
|
||||||
|
|
||||||
|
A set of previously unknown bugs discovered in `#257
|
||||||
|
<https://github.com/goodboy/tractor/pull/257>`_ let graceful stream
|
||||||
|
closure result in hanging consumer tasks that use the broadcast APIs.
|
||||||
|
This adds better internal closure state tracking to the broadcast
|
||||||
|
receiver and message stream APIs and in particular ensures that when an
|
||||||
|
underlying stream/receive-channel (a broadcast receiver is receiving
|
||||||
|
from) is closed, all consumer tasks waiting on that underlying channel
|
||||||
|
are woken so they can receive the ``trio.EndOfChannel`` signal and
|
||||||
|
promptly terminate.
|
||||||
|
|
||||||
|
|
||||||
|
tractor 0.1.0a3 (2021-11-02)
|
||||||
|
============================
|
||||||
|
|
||||||
|
Features
|
||||||
|
--------
|
||||||
|
|
||||||
|
- Switch to using the ``trio`` process spawner by default on windows. (#166)
|
||||||
|
|
||||||
|
This gets windows users debugger support (manually tested) and in
|
||||||
|
general a more resilient (nested) actor tree implementation.
|
||||||
|
|
||||||
|
- Add optional `msgspec <https://jcristharif.com/msgspec/>`_ support
|
||||||
|
as an alernative, faster MessagePack codec. (#214)
|
||||||
|
|
||||||
|
Provides us with a path toward supporting typed IPC message contracts. Further,
|
||||||
|
``msgspec`` structs may be a valid tool to start for formalizing our
|
||||||
|
"SC dialog un-protocol" messages as described in `#36
|
||||||
|
<https://github.com/goodboy/tractor/issues/36>`_.
|
||||||
|
|
||||||
|
- Introduce a new ``tractor.trionics`` `sub-package`_ that exposes
|
||||||
|
a selection of our relevant high(er) level trio primitives and
|
||||||
|
goodies. (#241)
|
||||||
|
|
||||||
|
At outset we offer a ``gather_contexts()`` context manager for
|
||||||
|
concurrently entering a sequence of async context managers (much like
|
||||||
|
a version of ``asyncio.gather()`` but for context managers) and use it
|
||||||
|
in a new ``tractor.open_actor_cluster()`` manager-helper that can be
|
||||||
|
entered to concurrently spawn a flat actor pool. We also now publicly
|
||||||
|
expose our "broadcast channel" APIs (``open_broadcast_receiver()``)
|
||||||
|
from here.
|
||||||
|
|
||||||
|
.. _sub-package: ../tractor/trionics
|
||||||
|
|
||||||
|
- Change the core message loop to handle task and actor-runtime cancel
|
||||||
|
requests immediately instead of scheduling them as is done for rpc-task
|
||||||
|
requests. (#245)
|
||||||
|
|
||||||
|
In order to obtain more reliable teardown mechanics for (complex) actor
|
||||||
|
trees it's important that we specially treat cancel requests as having
|
||||||
|
higher priority. Previously, it was possible that task cancel requests
|
||||||
|
could actually also themselves be cancelled if a "actor-runtime" cancel
|
||||||
|
request was received (can happen during messy multi actor crashes that
|
||||||
|
propagate). Instead cancels now block the msg loop until serviced and
|
||||||
|
a response is relayed back to the requester. This also allows for
|
||||||
|
improved debugger support since we have determinism guarantees about
|
||||||
|
which processes must wait before hard killing their children.
|
||||||
|
|
||||||
|
- (`#248 <https://github.com/goodboy/tractor/pull/248>`_) Drop Python
|
||||||
|
3.8 support in favour of rolling with two latest releases for the time
|
||||||
|
being.
|
||||||
|
|
||||||
|
|
||||||
|
Misc
|
||||||
|
----
|
||||||
|
|
||||||
|
- (`#243 <https://github.com/goodboy/tractor/pull/243>`_) add a distinct
|
||||||
|
``'CANCEL'`` log level to allow the runtime to emit details about
|
||||||
|
cancellation machinery statuses.
|
||||||
|
|
||||||
|
|
||||||
tractor 0.1.0a2 (2021-09-07)
|
tractor 0.1.0a2 (2021-09-07)
|
||||||
============================
|
============================
|
||||||
|
|
||||||
|
|
230
docs/README.rst
230
docs/README.rst
|
@ -3,13 +3,20 @@
|
||||||
|gh_actions|
|
|gh_actions|
|
||||||
|docs|
|
|docs|
|
||||||
|
|
||||||
``tractor`` is a `structured concurrent`_, multi-processing_ runtime built on trio_.
|
``tractor`` is a `structured concurrent`_, multi-processing_ runtime
|
||||||
|
built on trio_.
|
||||||
|
|
||||||
Fundamentally ``tractor`` gives you parallelism via ``trio``-"*actors*":
|
Fundamentally, ``tractor`` gives you parallelism via
|
||||||
our nurseries_ let you spawn new Python processes which each run a ``trio``
|
``trio``-"*actors*": independent Python processes (aka
|
||||||
|
non-shared-memory threads) which maintain structured
|
||||||
|
concurrency (SC) *end-to-end* inside a *supervision tree*.
|
||||||
|
|
||||||
|
Cross-process (and thus cross-host) SC is accomplished through the
|
||||||
|
combined use of our "actor nurseries_" and an "SC-transitive IPC
|
||||||
|
protocol" constructed on top of multiple Pythons each running a ``trio``
|
||||||
scheduled runtime - a call to ``trio.run()``.
|
scheduled runtime - a call to ``trio.run()``.
|
||||||
|
|
||||||
We believe the system adhere's to the `3 axioms`_ of an "`actor model`_"
|
We believe the system adheres to the `3 axioms`_ of an "`actor model`_"
|
||||||
but likely *does not* look like what *you* probably think an "actor
|
but likely *does not* look like what *you* probably think an "actor
|
||||||
model" looks like, and that's *intentional*.
|
model" looks like, and that's *intentional*.
|
||||||
|
|
||||||
|
@ -22,12 +29,15 @@ Features
|
||||||
- **It's just** a ``trio`` API
|
- **It's just** a ``trio`` API
|
||||||
- *Infinitely nesteable* process trees
|
- *Infinitely nesteable* process trees
|
||||||
- Builtin IPC streaming APIs with task fan-out broadcasting
|
- Builtin IPC streaming APIs with task fan-out broadcasting
|
||||||
- A (first ever?) "native" multi-core debugger UX for Python using `pdb++`_
|
- A "native" multi-core debugger REPL using `pdbp`_ (a fork & fix of
|
||||||
|
`pdb++`_ thanks to @mdmintz!)
|
||||||
- Support for a swappable, OS specific, process spawning layer
|
- Support for a swappable, OS specific, process spawning layer
|
||||||
- A modular transport stack, allowing for custom serialization (eg.
|
- A modular transport stack, allowing for custom serialization (eg. with
|
||||||
`msgspec`_), communications protocols, and environment specific IPC
|
`msgspec`_), communications protocols, and environment specific IPC
|
||||||
primitives
|
primitives
|
||||||
- `structured concurrency`_ from the ground up
|
- Support for spawning process-level-SC, inter-loop one-to-one-task oriented
|
||||||
|
``asyncio`` actors via "infected ``asyncio``" mode
|
||||||
|
- `structured chadcurrency`_ from the ground up
|
||||||
|
|
||||||
|
|
||||||
Run a func in a process
|
Run a func in a process
|
||||||
|
@ -115,7 +125,7 @@ Zombie safe: self-destruct a process tree
|
||||||
f"running in pid {os.getpid()}"
|
f"running in pid {os.getpid()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
await trio.sleep_forever()
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
|
@ -146,7 +156,7 @@ it **is a bug**.
|
||||||
|
|
||||||
"Native" multi-process debugging
|
"Native" multi-process debugging
|
||||||
--------------------------------
|
--------------------------------
|
||||||
Using the magic of `pdb++`_ and our internal IPC, we've
|
Using the magic of `pdbp`_ and our internal IPC, we've
|
||||||
been able to create a native feeling debugging experience for
|
been able to create a native feeling debugging experience for
|
||||||
any (sub-)process in your ``tractor`` tree.
|
any (sub-)process in your ``tractor`` tree.
|
||||||
|
|
||||||
|
@ -313,9 +323,173 @@ real time::
|
||||||
This uses no extra threads, fancy semaphores or futures; all we need
|
This uses no extra threads, fancy semaphores or futures; all we need
|
||||||
is ``tractor``'s IPC!
|
is ``tractor``'s IPC!
|
||||||
|
|
||||||
|
"Infected ``asyncio``" mode
|
||||||
|
---------------------------
|
||||||
|
Have a bunch of ``asyncio`` code you want to force to be SC at the process level?
|
||||||
|
|
||||||
|
Check out our experimental system for `guest-mode`_ controlled
|
||||||
|
``asyncio`` actors:
|
||||||
|
|
||||||
|
.. code:: python
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from statistics import mean
|
||||||
|
import time
|
||||||
|
|
||||||
|
import trio
|
||||||
|
import tractor
|
||||||
|
|
||||||
|
|
||||||
|
async def aio_echo_server(
|
||||||
|
to_trio: trio.MemorySendChannel,
|
||||||
|
from_trio: asyncio.Queue,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
# a first message must be sent **from** this ``asyncio``
|
||||||
|
# task or the ``trio`` side will never unblock from
|
||||||
|
# ``tractor.to_asyncio.open_channel_from():``
|
||||||
|
to_trio.send_nowait('start')
|
||||||
|
|
||||||
|
# XXX: this uses an ``from_trio: asyncio.Queue`` currently but we
|
||||||
|
# should probably offer something better.
|
||||||
|
while True:
|
||||||
|
# echo the msg back
|
||||||
|
to_trio.send_nowait(await from_trio.get())
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def trio_to_aio_echo_server(
|
||||||
|
ctx: tractor.Context,
|
||||||
|
):
|
||||||
|
# this will block until the ``asyncio`` task sends a "first"
|
||||||
|
# message.
|
||||||
|
async with tractor.to_asyncio.open_channel_from(
|
||||||
|
aio_echo_server,
|
||||||
|
) as (first, chan):
|
||||||
|
|
||||||
|
assert first == 'start'
|
||||||
|
await ctx.started(first)
|
||||||
|
|
||||||
|
async with ctx.open_stream() as stream:
|
||||||
|
|
||||||
|
async for msg in stream:
|
||||||
|
await chan.send(msg)
|
||||||
|
|
||||||
|
out = await chan.receive()
|
||||||
|
# echo back to parent actor-task
|
||||||
|
await stream.send(out)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
p = await n.start_actor(
|
||||||
|
'aio_server',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
infect_asyncio=True,
|
||||||
|
)
|
||||||
|
async with p.open_context(
|
||||||
|
trio_to_aio_echo_server,
|
||||||
|
) as (ctx, first):
|
||||||
|
|
||||||
|
assert first == 'start'
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
async with ctx.open_stream() as stream:
|
||||||
|
|
||||||
|
delays = []
|
||||||
|
send = time.time()
|
||||||
|
|
||||||
|
await stream.send(count)
|
||||||
|
async for msg in stream:
|
||||||
|
recv = time.time()
|
||||||
|
delays.append(recv - send)
|
||||||
|
assert msg == count
|
||||||
|
count += 1
|
||||||
|
send = time.time()
|
||||||
|
await stream.send(count)
|
||||||
|
|
||||||
|
if count >= 1e3:
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f'mean round trip rate (Hz): {1/mean(delays)}')
|
||||||
|
await p.cancel_actor()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
Yes, we spawn a python process, run ``asyncio``, start ``trio`` on the
|
||||||
|
``asyncio`` loop, then send commands to the ``trio`` scheduled tasks to
|
||||||
|
tell ``asyncio`` tasks what to do XD
|
||||||
|
|
||||||
|
We need help refining the `asyncio`-side channel API to be more
|
||||||
|
`trio`-like. Feel free to sling your opinion in `#273`_!
|
||||||
|
|
||||||
|
|
||||||
|
.. _#273: https://github.com/goodboy/tractor/issues/273
|
||||||
|
|
||||||
|
|
||||||
|
Higher level "cluster" APIs
|
||||||
|
---------------------------
|
||||||
|
To be extra terse the ``tractor`` devs have started hacking some "higher
|
||||||
|
level" APIs for managing actor trees/clusters. These interfaces should
|
||||||
|
generally be condsidered provisional for now but we encourage you to try
|
||||||
|
them and provide feedback. Here's a new API that let's you quickly
|
||||||
|
spawn a flat cluster:
|
||||||
|
|
||||||
|
.. code:: python
|
||||||
|
|
||||||
|
import trio
|
||||||
|
import tractor
|
||||||
|
|
||||||
|
|
||||||
|
async def sleepy_jane():
|
||||||
|
uid = tractor.current_actor().uid
|
||||||
|
print(f'Yo i am actor {uid}')
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
'''
|
||||||
|
Spawn a flat actor cluster, with one process per
|
||||||
|
detected core.
|
||||||
|
|
||||||
|
'''
|
||||||
|
portal_map: dict[str, tractor.Portal]
|
||||||
|
results: dict[str, str]
|
||||||
|
|
||||||
|
# look at this hip new syntax!
|
||||||
|
async with (
|
||||||
|
|
||||||
|
tractor.open_actor_cluster(
|
||||||
|
modules=[__name__]
|
||||||
|
) as portal_map,
|
||||||
|
|
||||||
|
trio.open_nursery() as n,
|
||||||
|
):
|
||||||
|
|
||||||
|
for (name, portal) in portal_map.items():
|
||||||
|
n.start_soon(portal.run, sleepy_jane)
|
||||||
|
|
||||||
|
await trio.sleep(0.5)
|
||||||
|
|
||||||
|
# kill the cluster with a cancel
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
trio.run(main)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
.. _full worker pool re-implementation: https://github.com/goodboy/tractor/blob/master/examples/parallelism/concurrent_actors_primes.py
|
.. _full worker pool re-implementation: https://github.com/goodboy/tractor/blob/master/examples/parallelism/concurrent_actors_primes.py
|
||||||
|
|
||||||
|
|
||||||
Install
|
Install
|
||||||
-------
|
-------
|
||||||
From PyPi::
|
From PyPi::
|
||||||
|
@ -323,12 +497,6 @@ From PyPi::
|
||||||
pip install tractor
|
pip install tractor
|
||||||
|
|
||||||
|
|
||||||
To try out the (optionally) faster `msgspec`_ codec instead of the
|
|
||||||
default ``msgpack`` lib::
|
|
||||||
|
|
||||||
pip install tractor[msgspec]
|
|
||||||
|
|
||||||
|
|
||||||
From git::
|
From git::
|
||||||
|
|
||||||
pip install git+git://github.com/goodboy/tractor.git
|
pip install git+git://github.com/goodboy/tractor.git
|
||||||
|
@ -397,13 +565,22 @@ properties of the system.
|
||||||
|
|
||||||
What's on the TODO:
|
What's on the TODO:
|
||||||
-------------------
|
-------------------
|
||||||
Help us push toward the future.
|
Help us push toward the future of distributed `Python`.
|
||||||
|
|
||||||
- (Soon to land) ``asyncio`` support allowing for "infected" actors where
|
- Erlang-style supervisors via composed context managers (see `#22
|
||||||
`trio` drives the `asyncio` scheduler via the astounding "`guest mode`_"
|
<https://github.com/goodboy/tractor/issues/22>`_)
|
||||||
- Typed messaging protocols (ex. via ``msgspec``, see `#36
|
- Typed messaging protocols (ex. via ``msgspec.Struct``, see `#36
|
||||||
<https://github.com/goodboy/tractor/issues/36>`_)
|
<https://github.com/goodboy/tractor/issues/36>`_)
|
||||||
- Erlang-style supervisors via composed context managers
|
- Typed capability-based (dialog) protocols ( see `#196
|
||||||
|
<https://github.com/goodboy/tractor/issues/196>`_ with draft work
|
||||||
|
started in `#311 <https://github.com/goodboy/tractor/pull/311>`_)
|
||||||
|
- We **recently disabled CI-testing on windows** and need help getting
|
||||||
|
it running again! (see `#327
|
||||||
|
<https://github.com/goodboy/tractor/pull/327>`_). **We do have windows
|
||||||
|
support** (and have for quite a while) but since no active hacker
|
||||||
|
exists in the user-base to help test on that OS, for now we're not
|
||||||
|
actively maintaining testing due to the added hassle and general
|
||||||
|
latency..
|
||||||
|
|
||||||
|
|
||||||
Feel like saying hi?
|
Feel like saying hi?
|
||||||
|
@ -415,27 +592,32 @@ say hi, please feel free to reach us in our `matrix channel`_. If
|
||||||
matrix seems too hip, we're also mostly all in the the `trio gitter
|
matrix seems too hip, we're also mostly all in the the `trio gitter
|
||||||
channel`_!
|
channel`_!
|
||||||
|
|
||||||
|
.. _structured concurrent: https://trio.discourse.group/t/concise-definition-of-structured-concurrency/228
|
||||||
|
.. _multi-processing: https://en.wikipedia.org/wiki/Multiprocessing
|
||||||
|
.. _trio: https://github.com/python-trio/trio
|
||||||
.. _nurseries: https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/#nurseries-a-structured-replacement-for-go-statements
|
.. _nurseries: https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/#nurseries-a-structured-replacement-for-go-statements
|
||||||
.. _actor model: https://en.wikipedia.org/wiki/Actor_model
|
.. _actor model: https://en.wikipedia.org/wiki/Actor_model
|
||||||
.. _trio: https://github.com/python-trio/trio
|
|
||||||
.. _multi-processing: https://en.wikipedia.org/wiki/Multiprocessing
|
|
||||||
.. _trionic: https://trio.readthedocs.io/en/latest/design.html#high-level-design-principles
|
.. _trionic: https://trio.readthedocs.io/en/latest/design.html#high-level-design-principles
|
||||||
.. _async sandwich: https://trio.readthedocs.io/en/latest/tutorial.html#async-sandwich
|
.. _async sandwich: https://trio.readthedocs.io/en/latest/tutorial.html#async-sandwich
|
||||||
.. _structured concurrent: https://trio.discourse.group/t/concise-definition-of-structured-concurrency/228
|
|
||||||
.. _3 axioms: https://www.youtube.com/watch?v=7erJ1DV_Tlo&t=162s
|
.. _3 axioms: https://www.youtube.com/watch?v=7erJ1DV_Tlo&t=162s
|
||||||
|
.. .. _3 axioms: https://en.wikipedia.org/wiki/Actor_model#Fundamental_concepts
|
||||||
.. _adherance to: https://www.youtube.com/watch?v=7erJ1DV_Tlo&t=1821s
|
.. _adherance to: https://www.youtube.com/watch?v=7erJ1DV_Tlo&t=1821s
|
||||||
.. _trio gitter channel: https://gitter.im/python-trio/general
|
.. _trio gitter channel: https://gitter.im/python-trio/general
|
||||||
.. _matrix channel: https://matrix.to/#/!tractor:matrix.org
|
.. _matrix channel: https://matrix.to/#/!tractor:matrix.org
|
||||||
|
.. _pdbp: https://github.com/mdmintz/pdbp
|
||||||
.. _pdb++: https://github.com/pdbpp/pdbpp
|
.. _pdb++: https://github.com/pdbpp/pdbpp
|
||||||
.. _guest mode: https://trio.readthedocs.io/en/stable/reference-lowlevel.html?highlight=guest%20mode#using-guest-mode-to-run-trio-on-top-of-other-event-loops
|
.. _guest mode: https://trio.readthedocs.io/en/stable/reference-lowlevel.html?highlight=guest%20mode#using-guest-mode-to-run-trio-on-top-of-other-event-loops
|
||||||
.. _messages: https://en.wikipedia.org/wiki/Message_passing
|
.. _messages: https://en.wikipedia.org/wiki/Message_passing
|
||||||
.. _trio docs: https://trio.readthedocs.io/en/latest/
|
.. _trio docs: https://trio.readthedocs.io/en/latest/
|
||||||
.. _blog post: https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/
|
.. _blog post: https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/
|
||||||
.. _structured concurrency: https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/
|
.. _structured concurrency: https://en.wikipedia.org/wiki/Structured_concurrency
|
||||||
|
.. _structured chadcurrency: https://en.wikipedia.org/wiki/Structured_concurrency
|
||||||
|
.. _structured concurrency: https://en.wikipedia.org/wiki/Structured_concurrency
|
||||||
.. _unrequirements: https://en.wikipedia.org/wiki/Actor_model#Direct_communication_and_asynchrony
|
.. _unrequirements: https://en.wikipedia.org/wiki/Actor_model#Direct_communication_and_asynchrony
|
||||||
.. _async generators: https://www.python.org/dev/peps/pep-0525/
|
.. _async generators: https://www.python.org/dev/peps/pep-0525/
|
||||||
.. _trio-parallel: https://github.com/richardsheridan/trio-parallel
|
.. _trio-parallel: https://github.com/richardsheridan/trio-parallel
|
||||||
.. _msgspec: https://jcristharif.com/msgspec/
|
.. _msgspec: https://jcristharif.com/msgspec/
|
||||||
|
.. _guest-mode: https://trio.readthedocs.io/en/stable/reference-lowlevel.html?highlight=guest%20mode#using-guest-mode-to-run-trio-on-top-of-other-event-loops
|
||||||
|
|
||||||
|
|
||||||
.. |gh_actions| image:: https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fgoodboy%2Ftractor%2Fbadge&style=popout-square
|
.. |gh_actions| image:: https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fgoodboy%2Ftractor%2Fbadge&style=popout-square
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
Hot tips for ``tractor`` hackers
|
||||||
|
================================
|
||||||
|
|
||||||
|
This is a WIP guide for newcomers to the project mostly to do with
|
||||||
|
dev, testing, CI and release gotchas, reminders and best practises.
|
||||||
|
|
||||||
|
``tractor`` is a fairly novel project compared to most since it is
|
||||||
|
effectively a new way of doing distributed computing in Python and is
|
||||||
|
much closer to working with an "application level runtime" (like erlang
|
||||||
|
OTP or scala's akka project) then it is a traditional Python library.
|
||||||
|
As such, having an arsenal of tools and recipes for figuring out the
|
||||||
|
right way to debug problems when they do arise is somewhat of
|
||||||
|
a necessity.
|
||||||
|
|
||||||
|
|
||||||
|
Making a Release
|
||||||
|
----------------
|
||||||
|
We currently do nothing special here except the traditional
|
||||||
|
PyPa release recipe as in `documented by twine`_. I personally
|
||||||
|
create sub-dirs within the generated `dist/` with an explicit
|
||||||
|
release name such as `alpha3/` when there's been a sequence of
|
||||||
|
releases I've made, but it really is up to you how you like to
|
||||||
|
organize generated sdists locally.
|
||||||
|
|
||||||
|
The resulting build cmds are approximately:
|
||||||
|
|
||||||
|
.. code:: bash
|
||||||
|
|
||||||
|
python setup.py sdist -d ./dist/XXX.X/
|
||||||
|
|
||||||
|
twine upload -r testpypi dist/XXX.X/*
|
||||||
|
|
||||||
|
twine upload dist/XXX.X/*
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.. _documented by twine: https://twine.readthedocs.io/en/latest/#using-twine
|
||||||
|
|
||||||
|
|
||||||
|
Debugging and monitoring actor trees
|
||||||
|
------------------------------------
|
||||||
|
TODO: but there are tips in the readme for some terminal commands
|
||||||
|
which can be used to see the process trees easily on Linux.
|
||||||
|
|
||||||
|
|
||||||
|
Using the log system to trace `trio` task flow
|
||||||
|
----------------------------------------------
|
||||||
|
TODO: the logging system is meant to be oriented around
|
||||||
|
stack "layers" of the runtime such that you can track
|
||||||
|
"logical abstraction layers" in the code such as errors, cancellation,
|
||||||
|
IPC and streaming, and the low level transport and wire protocols.
|
|
@ -396,7 +396,7 @@ tasks spawned via multiple RPC calls to an actor can modify
|
||||||
|
|
||||||
|
|
||||||
# a per process cache
|
# a per process cache
|
||||||
_actor_cache: Dict[str, bool] = {}
|
_actor_cache: dict[str, bool] = {}
|
||||||
|
|
||||||
|
|
||||||
def ping_endpoints(endpoints: List[str]):
|
def ping_endpoints(endpoints: List[str]):
|
||||||
|
|
|
@ -0,0 +1,151 @@
|
||||||
|
'''
|
||||||
|
Complex edge case where during real-time streaming the IPC tranport
|
||||||
|
channels are wiped out (purposely in this example though it could have
|
||||||
|
been an outage) and we want to ensure that despite being in debug mode
|
||||||
|
(or not) the user can sent SIGINT once they notice the hang and the
|
||||||
|
actor tree will eventually be cancelled without leaving any zombies.
|
||||||
|
|
||||||
|
'''
|
||||||
|
import trio
|
||||||
|
from tractor import (
|
||||||
|
open_nursery,
|
||||||
|
context,
|
||||||
|
Context,
|
||||||
|
MsgStream,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def break_channel_silently_then_error(
|
||||||
|
stream: MsgStream,
|
||||||
|
):
|
||||||
|
async for msg in stream:
|
||||||
|
await stream.send(msg)
|
||||||
|
|
||||||
|
# XXX: close the channel right after an error is raised
|
||||||
|
# purposely breaking the IPC transport to make sure the parent
|
||||||
|
# doesn't get stuck in debug or hang on the connection join.
|
||||||
|
# this more or less simulates an infinite msg-receive hang on
|
||||||
|
# the other end.
|
||||||
|
await stream._ctx.chan.send(None)
|
||||||
|
assert 0
|
||||||
|
|
||||||
|
|
||||||
|
async def close_stream_and_error(
|
||||||
|
stream: MsgStream,
|
||||||
|
):
|
||||||
|
async for msg in stream:
|
||||||
|
await stream.send(msg)
|
||||||
|
|
||||||
|
# wipe out channel right before raising
|
||||||
|
await stream._ctx.chan.send(None)
|
||||||
|
await stream.aclose()
|
||||||
|
assert 0
|
||||||
|
|
||||||
|
|
||||||
|
@context
|
||||||
|
async def recv_and_spawn_net_killers(
|
||||||
|
|
||||||
|
ctx: Context,
|
||||||
|
break_ipc_after: bool | int = False,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Receive stream msgs and spawn some IPC killers mid-stream.
|
||||||
|
|
||||||
|
'''
|
||||||
|
await ctx.started()
|
||||||
|
async with (
|
||||||
|
ctx.open_stream() as stream,
|
||||||
|
trio.open_nursery() as n,
|
||||||
|
):
|
||||||
|
async for i in stream:
|
||||||
|
print(f'child echoing {i}')
|
||||||
|
await stream.send(i)
|
||||||
|
if (
|
||||||
|
break_ipc_after
|
||||||
|
and i > break_ipc_after
|
||||||
|
):
|
||||||
|
'#################################\n'
|
||||||
|
'Simulating child-side IPC BREAK!\n'
|
||||||
|
'#################################'
|
||||||
|
n.start_soon(break_channel_silently_then_error, stream)
|
||||||
|
n.start_soon(close_stream_and_error, stream)
|
||||||
|
|
||||||
|
|
||||||
|
async def main(
|
||||||
|
debug_mode: bool = False,
|
||||||
|
start_method: str = 'trio',
|
||||||
|
|
||||||
|
# by default we break the parent IPC first (if configured to break
|
||||||
|
# at all), but this can be changed so the child does first (even if
|
||||||
|
# both are set to break).
|
||||||
|
break_parent_ipc_after: int | bool = False,
|
||||||
|
break_child_ipc_after: int | bool = False,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
async with (
|
||||||
|
open_nursery(
|
||||||
|
start_method=start_method,
|
||||||
|
|
||||||
|
# NOTE: even debugger is used we shouldn't get
|
||||||
|
# a hang since it never engages due to broken IPC
|
||||||
|
debug_mode=debug_mode,
|
||||||
|
loglevel='warning',
|
||||||
|
|
||||||
|
) as an,
|
||||||
|
):
|
||||||
|
portal = await an.start_actor(
|
||||||
|
'chitty_hijo',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
|
||||||
|
async with portal.open_context(
|
||||||
|
recv_and_spawn_net_killers,
|
||||||
|
break_ipc_after=break_child_ipc_after,
|
||||||
|
|
||||||
|
) as (ctx, sent):
|
||||||
|
async with ctx.open_stream() as stream:
|
||||||
|
for i in range(1000):
|
||||||
|
|
||||||
|
if (
|
||||||
|
break_parent_ipc_after
|
||||||
|
and i > break_parent_ipc_after
|
||||||
|
):
|
||||||
|
print(
|
||||||
|
'#################################\n'
|
||||||
|
'Simulating parent-side IPC BREAK!\n'
|
||||||
|
'#################################'
|
||||||
|
)
|
||||||
|
await stream._ctx.chan.send(None)
|
||||||
|
|
||||||
|
# it actually breaks right here in the
|
||||||
|
# mp_spawn/forkserver backends and thus the zombie
|
||||||
|
# reaper never even kicks in?
|
||||||
|
print(f'parent sending {i}')
|
||||||
|
await stream.send(i)
|
||||||
|
|
||||||
|
with trio.move_on_after(2) as cs:
|
||||||
|
|
||||||
|
# NOTE: in the parent side IPC failure case this
|
||||||
|
# will raise an ``EndOfChannel`` after the child
|
||||||
|
# is killed and sends a stop msg back to it's
|
||||||
|
# caller/this-parent.
|
||||||
|
rx = await stream.receive()
|
||||||
|
|
||||||
|
print(f"I'm a happy user and echoed to me is {rx}")
|
||||||
|
|
||||||
|
if cs.cancelled_caught:
|
||||||
|
# pretend to be a user seeing no streaming action
|
||||||
|
# thinking it's a hang, and then hitting ctl-c..
|
||||||
|
print("YOO i'm a user anddd thingz hangin..")
|
||||||
|
|
||||||
|
print(
|
||||||
|
"YOO i'm mad send side dun but thingz hangin..\n"
|
||||||
|
'MASHING CTlR-C Ctl-c..'
|
||||||
|
)
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
trio.run(main)
|
|
@ -27,7 +27,18 @@ async def main():
|
||||||
|
|
||||||
# retreive results
|
# retreive results
|
||||||
async with p0.open_stream_from(breakpoint_forever) as stream:
|
async with p0.open_stream_from(breakpoint_forever) as stream:
|
||||||
await p1.run(name_error)
|
|
||||||
|
# triggers the first name error
|
||||||
|
try:
|
||||||
|
await p1.run(name_error)
|
||||||
|
except tractor.RemoteActorError as rae:
|
||||||
|
assert rae.type is NameError
|
||||||
|
|
||||||
|
async for i in stream:
|
||||||
|
|
||||||
|
# a second time try the failing subactor and this tie
|
||||||
|
# let error propagate up to the parent/nursery.
|
||||||
|
await p1.run(name_error)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
@ -12,18 +12,31 @@ async def breakpoint_forever():
|
||||||
while True:
|
while True:
|
||||||
await tractor.breakpoint()
|
await tractor.breakpoint()
|
||||||
|
|
||||||
|
# NOTE: if the test never sent 'q'/'quit' commands
|
||||||
|
# on the pdb repl, without this checkpoint line the
|
||||||
|
# repl would spin in this actor forever.
|
||||||
|
# await trio.sleep(0)
|
||||||
|
|
||||||
|
|
||||||
async def spawn_until(depth=0):
|
async def spawn_until(depth=0):
|
||||||
""""A nested nursery that triggers another ``NameError``.
|
""""A nested nursery that triggers another ``NameError``.
|
||||||
"""
|
"""
|
||||||
async with tractor.open_nursery() as n:
|
async with tractor.open_nursery() as n:
|
||||||
if depth < 1:
|
if depth < 1:
|
||||||
# await n.run_in_actor('breakpoint_forever', breakpoint_forever)
|
|
||||||
await n.run_in_actor(
|
await n.run_in_actor(breakpoint_forever)
|
||||||
|
|
||||||
|
p = await n.run_in_actor(
|
||||||
name_error,
|
name_error,
|
||||||
name='name_error'
|
name='name_error'
|
||||||
)
|
)
|
||||||
|
await trio.sleep(0.5)
|
||||||
|
# rx and propagate error from child
|
||||||
|
await p.result()
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
# recusrive call to spawn another process branching layer of
|
||||||
|
# the tree
|
||||||
depth -= 1
|
depth -= 1
|
||||||
await n.run_in_actor(
|
await n.run_in_actor(
|
||||||
spawn_until,
|
spawn_until,
|
||||||
|
@ -53,6 +66,7 @@ async def main():
|
||||||
"""
|
"""
|
||||||
async with tractor.open_nursery(
|
async with tractor.open_nursery(
|
||||||
debug_mode=True,
|
debug_mode=True,
|
||||||
|
# loglevel='cancel',
|
||||||
) as n:
|
) as n:
|
||||||
|
|
||||||
# spawn both actors
|
# spawn both actors
|
||||||
|
@ -67,8 +81,16 @@ async def main():
|
||||||
name='spawner1',
|
name='spawner1',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# TODO: test this case as well where the parent don't see
|
||||||
|
# the sub-actor errors by default and instead expect a user
|
||||||
|
# ctrl-c to kill the root.
|
||||||
|
with trio.move_on_after(3):
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
# gah still an issue here.
|
# gah still an issue here.
|
||||||
await portal.result()
|
await portal.result()
|
||||||
|
|
||||||
|
# should never get here
|
||||||
await portal1.result()
|
await portal1.result()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
import trio
|
||||||
|
import tractor
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def just_sleep(
|
||||||
|
|
||||||
|
ctx: tractor.Context,
|
||||||
|
**kwargs,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Start and sleep.
|
||||||
|
|
||||||
|
'''
|
||||||
|
await ctx.started()
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
|
||||||
|
async with tractor.open_nursery(
|
||||||
|
debug_mode=True,
|
||||||
|
) as n:
|
||||||
|
portal = await n.start_actor(
|
||||||
|
'ctx_child',
|
||||||
|
|
||||||
|
# XXX: we don't enable the current module in order
|
||||||
|
# to trigger `ModuleNotFound`.
|
||||||
|
enable_modules=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
async with portal.open_context(
|
||||||
|
just_sleep, # taken from pytest parameterization
|
||||||
|
) as (ctx, sent):
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
trio.run(main)
|
|
@ -0,0 +1,27 @@
|
||||||
|
import trio
|
||||||
|
import tractor
|
||||||
|
|
||||||
|
async def die():
|
||||||
|
raise RuntimeError
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
async with tractor.open_nursery() as tn:
|
||||||
|
|
||||||
|
debug_actor = await tn.start_actor(
|
||||||
|
'debugged_boi',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
debug_mode=True,
|
||||||
|
)
|
||||||
|
crash_boi = await tn.start_actor(
|
||||||
|
'crash_boi',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
# debug_mode=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with trio.open_nursery() as n:
|
||||||
|
n.start_soon(debug_actor.run, die)
|
||||||
|
n.start_soon(crash_boi.run, die)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
trio.run(main)
|
|
@ -0,0 +1,24 @@
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import trio
|
||||||
|
import tractor
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with tractor.open_nursery(debug_mode=True) as an:
|
||||||
|
|
||||||
|
assert os.environ['PYTHONBREAKPOINT'] == 'tractor._debug._set_trace'
|
||||||
|
|
||||||
|
# TODO: an assert that verifies the hook has indeed been, hooked
|
||||||
|
# XD
|
||||||
|
assert sys.breakpointhook is not tractor._debug._set_trace
|
||||||
|
|
||||||
|
breakpoint()
|
||||||
|
|
||||||
|
# TODO: an assert that verifies the hook is unhooked..
|
||||||
|
assert sys.breakpointhook
|
||||||
|
breakpoint()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
trio.run(main)
|
|
@ -0,0 +1,50 @@
|
||||||
|
import tractor
|
||||||
|
import trio
|
||||||
|
|
||||||
|
|
||||||
|
async def gen():
|
||||||
|
yield 'yo'
|
||||||
|
await tractor.breakpoint()
|
||||||
|
yield 'yo'
|
||||||
|
await tractor.breakpoint()
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def just_bp(
|
||||||
|
ctx: tractor.Context,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
await ctx.started()
|
||||||
|
await tractor.breakpoint()
|
||||||
|
|
||||||
|
# TODO: bps and errors in this call..
|
||||||
|
async for val in gen():
|
||||||
|
print(val)
|
||||||
|
|
||||||
|
# await trio.sleep(0.5)
|
||||||
|
|
||||||
|
# prematurely destroy the connection
|
||||||
|
await ctx.chan.aclose()
|
||||||
|
|
||||||
|
# THIS CAUSES AN UNRECOVERABLE HANG
|
||||||
|
# without latest ``pdbpp``:
|
||||||
|
assert 0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
async with tractor.open_nursery(
|
||||||
|
debug_mode=True,
|
||||||
|
) as n:
|
||||||
|
p = await n.start_actor(
|
||||||
|
'bp_boi',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
async with p.open_context(
|
||||||
|
just_bp,
|
||||||
|
) as (ctx, first):
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
trio.run(main)
|
|
@ -7,7 +7,7 @@ import tractor
|
||||||
async def stream_data(seed):
|
async def stream_data(seed):
|
||||||
for i in range(seed):
|
for i in range(seed):
|
||||||
yield i
|
yield i
|
||||||
await trio.sleep(0) # trigger scheduler
|
await trio.sleep(0.0001) # trigger scheduler
|
||||||
|
|
||||||
|
|
||||||
# this is the third actor; the aggregator
|
# this is the third actor; the aggregator
|
||||||
|
|
|
@ -0,0 +1,92 @@
|
||||||
|
'''
|
||||||
|
An SC compliant infected ``asyncio`` echo server.
|
||||||
|
|
||||||
|
'''
|
||||||
|
import asyncio
|
||||||
|
from statistics import mean
|
||||||
|
import time
|
||||||
|
|
||||||
|
import trio
|
||||||
|
import tractor
|
||||||
|
|
||||||
|
|
||||||
|
async def aio_echo_server(
|
||||||
|
to_trio: trio.MemorySendChannel,
|
||||||
|
from_trio: asyncio.Queue,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
# a first message must be sent **from** this ``asyncio``
|
||||||
|
# task or the ``trio`` side will never unblock from
|
||||||
|
# ``tractor.to_asyncio.open_channel_from():``
|
||||||
|
to_trio.send_nowait('start')
|
||||||
|
|
||||||
|
# XXX: this uses an ``from_trio: asyncio.Queue`` currently but we
|
||||||
|
# should probably offer something better.
|
||||||
|
while True:
|
||||||
|
# echo the msg back
|
||||||
|
to_trio.send_nowait(await from_trio.get())
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def trio_to_aio_echo_server(
|
||||||
|
ctx: tractor.Context,
|
||||||
|
):
|
||||||
|
# this will block until the ``asyncio`` task sends a "first"
|
||||||
|
# message.
|
||||||
|
async with tractor.to_asyncio.open_channel_from(
|
||||||
|
aio_echo_server,
|
||||||
|
) as (first, chan):
|
||||||
|
|
||||||
|
assert first == 'start'
|
||||||
|
await ctx.started(first)
|
||||||
|
|
||||||
|
async with ctx.open_stream() as stream:
|
||||||
|
|
||||||
|
async for msg in stream:
|
||||||
|
await chan.send(msg)
|
||||||
|
|
||||||
|
out = await chan.receive()
|
||||||
|
# echo back to parent actor-task
|
||||||
|
await stream.send(out)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
p = await n.start_actor(
|
||||||
|
'aio_server',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
infect_asyncio=True,
|
||||||
|
)
|
||||||
|
async with p.open_context(
|
||||||
|
trio_to_aio_echo_server,
|
||||||
|
) as (ctx, first):
|
||||||
|
|
||||||
|
assert first == 'start'
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
async with ctx.open_stream() as stream:
|
||||||
|
|
||||||
|
delays = []
|
||||||
|
send = time.time()
|
||||||
|
|
||||||
|
await stream.send(count)
|
||||||
|
async for msg in stream:
|
||||||
|
recv = time.time()
|
||||||
|
delays.append(recv - send)
|
||||||
|
assert msg == count
|
||||||
|
count += 1
|
||||||
|
send = time.time()
|
||||||
|
await stream.send(count)
|
||||||
|
|
||||||
|
if count >= 1e3:
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f'mean round trip rate (Hz): {1/mean(delays)}')
|
||||||
|
await p.cancel_actor()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
trio.run(main)
|
|
@ -0,0 +1,49 @@
|
||||||
|
import trio
|
||||||
|
import click
|
||||||
|
import tractor
|
||||||
|
import pydantic
|
||||||
|
# from multiprocessing import shared_memory
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def just_sleep(
|
||||||
|
|
||||||
|
ctx: tractor.Context,
|
||||||
|
**kwargs,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Test a small ping-pong 2-way streaming server.
|
||||||
|
|
||||||
|
'''
|
||||||
|
await ctx.started()
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
|
||||||
|
proc = await trio.open_process( (
|
||||||
|
'python',
|
||||||
|
'-c',
|
||||||
|
'import trio; trio.run(trio.sleep_forever)',
|
||||||
|
))
|
||||||
|
await proc.wait()
|
||||||
|
# await trio.sleep_forever()
|
||||||
|
# async with tractor.open_nursery() as n:
|
||||||
|
|
||||||
|
# portal = await n.start_actor(
|
||||||
|
# 'rpc_server',
|
||||||
|
# enable_modules=[__name__],
|
||||||
|
# )
|
||||||
|
|
||||||
|
# async with portal.open_context(
|
||||||
|
# just_sleep, # taken from pytest parameterization
|
||||||
|
# ) as (ctx, sent):
|
||||||
|
# await trio.sleep_forever()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import time
|
||||||
|
# time.sleep(999)
|
||||||
|
trio.run(main)
|
|
@ -9,7 +9,7 @@ is ``tractor``'s channels.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import List, Callable
|
from typing import Callable
|
||||||
import itertools
|
import itertools
|
||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
|
@ -71,8 +71,8 @@ async def worker_pool(workers=4):
|
||||||
|
|
||||||
async def _map(
|
async def _map(
|
||||||
worker_func: Callable[[int], bool],
|
worker_func: Callable[[int], bool],
|
||||||
sequence: List[int]
|
sequence: list[int]
|
||||||
) -> List[bool]:
|
) -> list[bool]:
|
||||||
|
|
||||||
# define an async (local) task to collect results from workers
|
# define an async (local) task to collect results from workers
|
||||||
async def send_result(func, value, portal):
|
async def send_result(func, value, portal):
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
|
||||||
|
import trio
|
||||||
|
import tractor
|
||||||
|
|
||||||
|
|
||||||
|
async def sleepy_jane():
|
||||||
|
uid = tractor.current_actor().uid
|
||||||
|
print(f'Yo i am actor {uid}')
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
'''
|
||||||
|
Spawn a flat actor cluster, with one process per
|
||||||
|
detected core.
|
||||||
|
|
||||||
|
'''
|
||||||
|
portal_map: dict[str, tractor.Portal]
|
||||||
|
results: dict[str, str]
|
||||||
|
|
||||||
|
# look at this hip new syntax!
|
||||||
|
async with (
|
||||||
|
|
||||||
|
tractor.open_actor_cluster(
|
||||||
|
modules=[__name__]
|
||||||
|
) as portal_map,
|
||||||
|
|
||||||
|
trio.open_nursery() as n,
|
||||||
|
):
|
||||||
|
|
||||||
|
for (name, portal) in portal_map.items():
|
||||||
|
n.start_soon(portal.run, sleepy_jane)
|
||||||
|
|
||||||
|
await trio.sleep(0.5)
|
||||||
|
|
||||||
|
# kill the cluster with a cancel
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
trio.run(main)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
|
@ -1,4 +0,0 @@
|
||||||
Switch to using the ``trio`` process spawner by default on windows.
|
|
||||||
|
|
||||||
This gets windows users debugger support (manually tested) and in
|
|
||||||
general a more resilient (nested) actor tree implementation.
|
|
|
@ -1,9 +0,0 @@
|
||||||
Add optional `msgspec <https://jcristharif.com/msgspec/>`_ support over
|
|
||||||
TCP streams as an alernative, faster MessagePack codec.
|
|
||||||
|
|
||||||
This get's us moving toward typed messaging/IPC protocols. Further,
|
|
||||||
``msgspec`` structs may be a valid tool to start for formalizing our "SC
|
|
||||||
dialog un-protocol" messages as described in `#36
|
|
||||||
<https://github.com/goodboy/tractor/issues/36>`_`.
|
|
||||||
|
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
Fix keyboard interrupt handling in ``Portal.open_context()`` blocks.
|
|
||||||
|
|
||||||
Previously this not triggering cancellation of the remote task context
|
|
||||||
and could result in hangs if a stream was also opened. This fix is to
|
|
||||||
accept `BaseException` since it is likely any other top level exception
|
|
||||||
other then kbi (even though not expected) should also get this result.
|
|
|
@ -1,9 +0,0 @@
|
||||||
Add a custom 'CANCEL' log level and use through runtime.
|
|
||||||
|
|
||||||
In order to reduce log messages and also start toying with the idea of
|
|
||||||
"application layer" oriented tracing, we added this new level just above
|
|
||||||
'runtime' but just below 'info'. It is intended to be used solely for
|
|
||||||
cancellation and teardown related messages. Included are some small
|
|
||||||
overrides to the stdlib's ``logging.LoggerAdapter`` to passthrough the
|
|
||||||
correct stack frame to show when one of the custom level methods is
|
|
||||||
used.
|
|
|
@ -1,13 +0,0 @@
|
||||||
Change the core message loop to handle task and actor-runtime cancel
|
|
||||||
requests immediately instead of scheduling them as is done for rpc-task
|
|
||||||
requests.
|
|
||||||
|
|
||||||
In order to obtain more reliable teardown mechanics for (complex) actor
|
|
||||||
trees it's important that we specially treat cancel requests as having
|
|
||||||
higher priority. Previously, it was possible that task cancel requests
|
|
||||||
could actually also themselves be cancelled if a "actor-runtime" cancel
|
|
||||||
request was received (can happen during messy multi actor crashes that
|
|
||||||
propagate). Instead cancels now block the msg loop until serviced and
|
|
||||||
a response is relayed back to the requester. This also allows for
|
|
||||||
improved debugger support since we have determinism guarantees about
|
|
||||||
which processes must wait before hard killing their children.
|
|
|
@ -1 +0,0 @@
|
||||||
Drop Python 3.8 support in favor of rolling with two latest releases for the time being.
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
Strictly support Python 3.10+, start runtime machinery reorg
|
||||||
|
|
||||||
|
Since we want to push forward using the new `match:` syntax for our
|
||||||
|
internal RPC-msg loops, we officially drop 3.9 support for the next
|
||||||
|
release which should coincide well with the first release of 3.11.
|
||||||
|
|
||||||
|
This patch set also officially removes the ``tractor.run()`` API (which
|
||||||
|
has been deprecated for some time) as well as starts an initial re-org
|
||||||
|
of the internal runtime core by:
|
||||||
|
- renaming ``tractor._actor`` -> ``._runtime``
|
||||||
|
- moving the ``._runtime.ActorActor._process_messages()`` and
|
||||||
|
``._async_main()`` to be module level singleton-task-functions since
|
||||||
|
they are only started once for each connection and actor spawn
|
||||||
|
respectively; this internal API thus looks more similar to (at the
|
||||||
|
time of writing) the ``trio``-internals in ``trio._core._run``.
|
||||||
|
- officially remove ``tractor.run()``, now deprecated for some time.
|
|
@ -0,0 +1,4 @@
|
||||||
|
Only set `._debug.Lock.local_pdb_complete` if has been created.
|
||||||
|
|
||||||
|
This can be triggered by a very rare race condition (and thus we have no
|
||||||
|
working test yet) but it is known to exist in (a) consumer project(s).
|
|
@ -0,0 +1,25 @@
|
||||||
|
Add support for ``trio >= 0.22`` and support for the new Python 3.11
|
||||||
|
``[Base]ExceptionGroup`` from `pep 654`_ via the backported
|
||||||
|
`exceptiongroup`_ package and some final fixes to the debug mode
|
||||||
|
subsystem.
|
||||||
|
|
||||||
|
This port ended up driving some (hopefully) final fixes to our debugger
|
||||||
|
subsystem including the solution to all lingering stdstreams locking
|
||||||
|
race-conditions and deadlock scenarios. This includes extending the
|
||||||
|
debugger tests suite as well as cancellation and ``asyncio`` mode cases.
|
||||||
|
Some of the notable details:
|
||||||
|
|
||||||
|
- always reverting to the ``trio`` SIGINT handler when leaving debug
|
||||||
|
mode.
|
||||||
|
- bypassing child attempts to acquire the debug lock when detected
|
||||||
|
to be amdist actor-runtime-cancellation.
|
||||||
|
- allowing the root actor to cancel local but IPC-stale subactor
|
||||||
|
requests-tasks for the debug lock when in a "no IPC peers" state.
|
||||||
|
|
||||||
|
Further we refined our ``ActorNursery`` semantics to be more similar to
|
||||||
|
``trio`` in the sense that parent task errors are always packed into the
|
||||||
|
actor-nursery emitted exception group and adjusted all tests and
|
||||||
|
examples accordingly.
|
||||||
|
|
||||||
|
.. _pep 654: https://peps.python.org/pep-0654/#handling-exception-groups
|
||||||
|
.. _exceptiongroup: https://github.com/python-trio/exceptiongroup
|
|
@ -0,0 +1,5 @@
|
||||||
|
Establish an explicit "backend spawning" method table; use it from CI
|
||||||
|
|
||||||
|
More clearly lays out the current set of (3) backends: ``['trio',
|
||||||
|
'mp_spawn', 'mp_forkserver']`` and adjusts the ``._spawn.py`` internals
|
||||||
|
as well as the test suite to accommodate.
|
|
@ -0,0 +1,4 @@
|
||||||
|
Add ``key: Callable[..., Hashable]`` support to ``.trionics.maybe_open_context()``
|
||||||
|
|
||||||
|
Gives users finer grained control over cache hit behaviour using
|
||||||
|
a callable which receives the input ``kwargs: dict``.
|
|
@ -0,0 +1,41 @@
|
||||||
|
Add support for debug-lock blocking using a ``._debug.Lock._blocked:
|
||||||
|
set[tuple]`` and add ids when no-more IPC connections with the
|
||||||
|
root actor are detected.
|
||||||
|
|
||||||
|
This is an enhancement which (mostly) solves a lingering debugger
|
||||||
|
locking race case we needed to handle:
|
||||||
|
|
||||||
|
- child crashes acquires TTY lock in root and attaches to ``pdb``
|
||||||
|
- child IPC goes down such that all channels to the root are broken
|
||||||
|
/ non-functional.
|
||||||
|
- root is stuck thinking the child is still in debug even though it
|
||||||
|
can't be contacted and the child actor machinery hasn't been
|
||||||
|
cancelled by its parent.
|
||||||
|
- root get's stuck in deadlock with child since it won't send a cancel
|
||||||
|
request until the child is finished debugging (to avoid clobbering
|
||||||
|
a child that is actually using the debugger), but the child can't
|
||||||
|
unlock the debugger bc IPC is down and it can't contact the root.
|
||||||
|
|
||||||
|
To avoid this scenario add debug lock blocking list via
|
||||||
|
`._debug.Lock._blocked: set[tuple]` which holds actor uids for any actor
|
||||||
|
that is detected by the root as having no transport channel connections
|
||||||
|
(of which at least one should exist if this sub-actor at some point
|
||||||
|
acquired the debug lock). The root consequently checks this list for any
|
||||||
|
actor that tries to (re)acquire the lock and blocks with
|
||||||
|
a ``ContextCancelled``. Further, when a debug condition is tested in
|
||||||
|
``._runtime._invoke``, the context's ``._enter_debugger_on_cancel`` is
|
||||||
|
set to `False` if the actor was put on the block list then all
|
||||||
|
post-mortem / crash handling will be bypassed for that task.
|
||||||
|
|
||||||
|
In theory this approach to block list management may cause problems
|
||||||
|
where some nested child actor acquires and releases the lock multiple
|
||||||
|
times and it gets stuck on the block list after the first use? If this
|
||||||
|
turns out to be an issue we can try changing the strat so blocks are
|
||||||
|
only added when the root has zero IPC peers left?
|
||||||
|
|
||||||
|
Further, this adds a root-locking-task side cancel scope,
|
||||||
|
``Lock._root_local_task_cs_in_debug``, which can be ``.cancel()``-ed by the root
|
||||||
|
runtime when a stale lock is detected during the IPC channel testing.
|
||||||
|
However, right now we're NOT using this since it seems to cause test
|
||||||
|
failures likely due to causing pre-mature cancellation and maybe needs
|
||||||
|
a bit more experimenting?
|
|
@ -0,0 +1,19 @@
|
||||||
|
Rework our ``.trionics.BroadcastReceiver`` internals to avoid method
|
||||||
|
recursion and approach a design and interface closer to ``trio``'s
|
||||||
|
``MemoryReceiveChannel``.
|
||||||
|
|
||||||
|
The details of the internal changes include:
|
||||||
|
|
||||||
|
- implementing a ``BroadcastReceiver.receive_nowait()`` and using it
|
||||||
|
within the async ``.receive()`` thus avoiding recursion from
|
||||||
|
``.receive()``.
|
||||||
|
- failing over to an internal ``._receive_from_underlying()`` when the
|
||||||
|
``_nowait()`` call raises ``trio.WouldBlock``
|
||||||
|
- adding ``BroadcastState.statistics()`` for debugging and testing both
|
||||||
|
internals and by users.
|
||||||
|
- add an internal ``BroadcastReceiver._raise_on_lag: bool`` which can be
|
||||||
|
set to avoid ``Lagged`` raising for possible use cases where a user
|
||||||
|
wants to choose between a [cheap or nasty
|
||||||
|
pattern](https://zguide.zeromq.org/docs/chapter7/#The-Cheap-or-Nasty-Pattern)
|
||||||
|
the the particular stream (we use this in ``piker``'s dark clearing
|
||||||
|
engine to avoid fast feeds breaking during HFT periods).
|
|
@ -0,0 +1,11 @@
|
||||||
|
Always ``list``-cast the ``mngrs`` input to
|
||||||
|
``.trionics.gather_contexts()`` and ensure its size otherwise raise
|
||||||
|
a ``ValueError``.
|
||||||
|
|
||||||
|
Turns out that trying to pass an inline-style generator comprehension
|
||||||
|
doesn't seem to work inside the ``async with`` expression? Further, in
|
||||||
|
such a case we can get a hang waiting on the all-entered event
|
||||||
|
completion when the internal mngrs iteration is a noop. Instead we
|
||||||
|
always greedily check a size and error on empty input; the lazy
|
||||||
|
iteration of a generator input is not beneficial anyway since we're
|
||||||
|
entering all manager instances in concurrent tasks.
|
|
@ -0,0 +1,15 @@
|
||||||
|
Fixes to ensure IPC (channel) breakage doesn't result in hung actor
|
||||||
|
trees; the zombie reaping and general supervision machinery will always
|
||||||
|
clean up and terminate.
|
||||||
|
|
||||||
|
This includes not only the (mostly minor) fixes to solve these cases but
|
||||||
|
also a new extensive test suite in `test_advanced_faults.py` with an
|
||||||
|
accompanying highly configurable example module-script in
|
||||||
|
`examples/advanced_faults/ipc_failure_during_stream.py`. Tests ensure we
|
||||||
|
never get hang or zombies despite operating in debug mode and attempt to
|
||||||
|
simulate all possible IPC transport failure cases for a local-host actor
|
||||||
|
tree.
|
||||||
|
|
||||||
|
Further we simplify `Context.open_stream.__aexit__()` to just call
|
||||||
|
`MsgStream.aclose()` directly more or less avoiding a pure duplicate
|
||||||
|
code path.
|
|
@ -0,0 +1,10 @@
|
||||||
|
Always redraw the `pdbpp` prompt on `SIGINT` during REPL use.
|
||||||
|
|
||||||
|
There was recent changes todo with Python 3.10 that required us to pin
|
||||||
|
to a specific commit in `pdbpp` which have recently been fixed minus
|
||||||
|
this last issue with `SIGINT` shielding: not clobbering or not
|
||||||
|
showing the `(Pdb++)` prompt on ctlr-c by the user. This repairs all
|
||||||
|
that by firstly removing the standard KBI intercepting of the std lib's
|
||||||
|
`pdb.Pdb._cmdloop()` as well as ensuring that only the actor with REPL
|
||||||
|
control ever reports `SIGINT` handler log msgs and prompt redraws. With
|
||||||
|
this we move back to using pypi `pdbpp` release.
|
|
@ -0,0 +1,7 @@
|
||||||
|
Drop `trio.Process.aclose()` usage, copy into our spawning code.
|
||||||
|
|
||||||
|
The details are laid out in https://github.com/goodboy/tractor/issues/330.
|
||||||
|
`trio` changed is process running quite some time ago, this just copies
|
||||||
|
out the small bit we needed (from the old `.aclose()`) for hard kills
|
||||||
|
where a soft runtime cancel request fails and our "zombie killer"
|
||||||
|
implementation kicks in.
|
|
@ -0,0 +1,15 @@
|
||||||
|
Switch to using the fork & fix of `pdb++`, `pdbp`:
|
||||||
|
https://github.com/mdmintz/pdbp
|
||||||
|
|
||||||
|
Allows us to sidestep a variety of issues that aren't being maintained
|
||||||
|
in the upstream project thanks to the hard work of @mdmintz!
|
||||||
|
|
||||||
|
We also include some default settings adjustments as per recent
|
||||||
|
development on the fork:
|
||||||
|
|
||||||
|
- sticky mode is still turned on by default but now activates when
|
||||||
|
a using the `ll` repl command.
|
||||||
|
- turn off line truncation by default to avoid inter-line gaps when
|
||||||
|
resizing the terimnal during use.
|
||||||
|
- when using the backtrace cmd either by `w` or `bt`, the config
|
||||||
|
automatically switches to non-sticky mode.
|
|
@ -0,0 +1,37 @@
|
||||||
|
{% for section in sections %}
|
||||||
|
{% set underline = "-" %}
|
||||||
|
{% if section %}
|
||||||
|
{{section}}
|
||||||
|
{{ underline * section|length }}{% set underline = "~" %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% if sections[section] %}
|
||||||
|
{% for category, val in definitions.items() if category in sections[section] %}
|
||||||
|
|
||||||
|
{{ definitions[category]['name'] }}
|
||||||
|
{{ underline * definitions[category]['name']|length }}
|
||||||
|
|
||||||
|
{% if definitions[category]['showcontent'] %}
|
||||||
|
{% for text, values in sections[section][category]|dictsort(by='value') %}
|
||||||
|
{% set issue_joiner = joiner(', ') %}
|
||||||
|
- {% for value in values|sort %}{{ issue_joiner() }}`{{ value }} <https://github.com/goodboy/tractor/issues/{{ value[1:] }}>`_{% endfor %}: {{ text }}
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
- {{ sections[section][category]['']|sort|join(', ') }}
|
||||||
|
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% if sections[section][category]|length == 0 %}
|
||||||
|
|
||||||
|
No significant changes.
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
No significant changes.
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
|
@ -0,0 +1,28 @@
|
||||||
|
[tool.towncrier]
|
||||||
|
package = "tractor"
|
||||||
|
filename = "NEWS.rst"
|
||||||
|
directory = "nooz/"
|
||||||
|
version = "0.1.0a6"
|
||||||
|
title_format = "tractor {version} ({project_date})"
|
||||||
|
template = "nooz/_template.rst"
|
||||||
|
all_bullets = true
|
||||||
|
|
||||||
|
[[tool.towncrier.type]]
|
||||||
|
directory = "feature"
|
||||||
|
name = "Features"
|
||||||
|
showcontent = true
|
||||||
|
|
||||||
|
[[tool.towncrier.type]]
|
||||||
|
directory = "bugfix"
|
||||||
|
name = "Bug Fixes"
|
||||||
|
showcontent = true
|
||||||
|
|
||||||
|
[[tool.towncrier.type]]
|
||||||
|
directory = "doc"
|
||||||
|
name = "Improved Documentation"
|
||||||
|
showcontent = true
|
||||||
|
|
||||||
|
[[tool.towncrier.type]]
|
||||||
|
directory = "trivial"
|
||||||
|
name = "Trivial/Internal Changes"
|
||||||
|
showcontent = true
|
|
@ -1,6 +1,7 @@
|
||||||
pytest
|
pytest
|
||||||
pytest-trio
|
pytest-trio
|
||||||
pdbpp
|
pytest-timeout
|
||||||
|
pdbp
|
||||||
mypy
|
mypy
|
||||||
trio_typing
|
trio_typing
|
||||||
pexpect
|
pexpect
|
||||||
|
|
70
setup.py
70
setup.py
|
@ -1,21 +1,22 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
#
|
#
|
||||||
# tractor: a trionic actor model built on `multiprocessing` and `trio`
|
# tractor: structured concurrent "actors".
|
||||||
#
|
#
|
||||||
# Copyright (C) 2018-2020 Tyler Goodlet
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU General Public License as published by
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
# (at your option) any later version.
|
# (at your option) any later version.
|
||||||
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
# This program is distributed in the hope that it will be useful,
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
# GNU General Public License for more details.
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
from setuptools import setup
|
from setuptools import setup
|
||||||
|
|
||||||
with open('docs/README.rst', encoding='utf-8') as f:
|
with open('docs/README.rst', encoding='utf-8') as f:
|
||||||
|
@ -24,54 +25,62 @@ with open('docs/README.rst', encoding='utf-8') as f:
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="tractor",
|
name="tractor",
|
||||||
version='0.1.0a3.dev0', # alpha zone
|
version='0.1.0a6dev0', # alpha zone
|
||||||
description='structured concurrrent "actors"',
|
description='structured concurrrent `trio`-"actors"',
|
||||||
long_description=readme,
|
long_description=readme,
|
||||||
license='GPLv3',
|
license='AGPLv3',
|
||||||
author='Tyler Goodlet',
|
author='Tyler Goodlet',
|
||||||
maintainer='Tyler Goodlet',
|
maintainer='Tyler Goodlet',
|
||||||
maintainer_email='jgbt@protonmail.com',
|
maintainer_email='goodboy_foss@protonmail.com',
|
||||||
url='https://github.com/goodboy/tractor',
|
url='https://github.com/goodboy/tractor',
|
||||||
platforms=['linux', 'windows'],
|
platforms=['linux', 'windows'],
|
||||||
packages=[
|
packages=[
|
||||||
'tractor',
|
'tractor',
|
||||||
|
'tractor.experimental',
|
||||||
'tractor.trionics',
|
'tractor.trionics',
|
||||||
'tractor.testing',
|
|
||||||
],
|
],
|
||||||
install_requires=[
|
install_requires=[
|
||||||
|
|
||||||
# trio related
|
# trio related
|
||||||
'trio>0.8',
|
# proper range spec:
|
||||||
|
# https://packaging.python.org/en/latest/discussions/install-requires-vs-requirements/#id5
|
||||||
|
'trio >= 0.22',
|
||||||
'async_generator',
|
'async_generator',
|
||||||
'trio_typing',
|
'trio_typing',
|
||||||
|
'exceptiongroup',
|
||||||
|
|
||||||
# tooling
|
# tooling
|
||||||
'tricycle',
|
'tricycle',
|
||||||
'trio_typing',
|
'trio_typing',
|
||||||
|
|
||||||
# tooling
|
|
||||||
'colorlog',
|
'colorlog',
|
||||||
'wrapt',
|
'wrapt',
|
||||||
'pdbpp',
|
|
||||||
|
|
||||||
# serialization
|
# IPC serialization
|
||||||
'msgpack',
|
'msgspec',
|
||||||
|
|
||||||
|
# debug mode REPL
|
||||||
|
'pdbp',
|
||||||
|
|
||||||
|
# pip ref docs on these specs:
|
||||||
|
# https://pip.pypa.io/en/stable/reference/requirement-specifiers/#examples
|
||||||
|
# and pep:
|
||||||
|
# https://peps.python.org/pep-0440/#version-specifiers
|
||||||
|
|
||||||
|
# windows deps workaround for ``pdbpp``
|
||||||
|
# https://github.com/pdbpp/pdbpp/issues/498
|
||||||
|
# https://github.com/pdbpp/fancycompleter/issues/37
|
||||||
|
'pyreadline3 ; platform_system == "Windows"',
|
||||||
|
|
||||||
],
|
],
|
||||||
extras_require={
|
|
||||||
|
|
||||||
# serialization
|
|
||||||
'msgspec': ["msgspec >= 0.3.2'; python_version >= '3.9'"],
|
|
||||||
|
|
||||||
},
|
|
||||||
tests_require=['pytest'],
|
tests_require=['pytest'],
|
||||||
python_requires=">=3.8",
|
python_requires=">=3.10",
|
||||||
keywords=[
|
keywords=[
|
||||||
'trio',
|
'trio',
|
||||||
"async",
|
'async',
|
||||||
"concurrency",
|
'concurrency',
|
||||||
"actor model",
|
'structured concurrency',
|
||||||
"distributed",
|
'actor model',
|
||||||
|
'distributed',
|
||||||
'multiprocessing'
|
'multiprocessing'
|
||||||
],
|
],
|
||||||
classifiers=[
|
classifiers=[
|
||||||
|
@ -79,11 +88,10 @@ setup(
|
||||||
"Operating System :: POSIX :: Linux",
|
"Operating System :: POSIX :: Linux",
|
||||||
"Operating System :: Microsoft :: Windows",
|
"Operating System :: Microsoft :: Windows",
|
||||||
"Framework :: Trio",
|
"Framework :: Trio",
|
||||||
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
|
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
|
||||||
"Programming Language :: Python :: Implementation :: CPython",
|
"Programming Language :: Python :: Implementation :: CPython",
|
||||||
"Programming Language :: Python :: 3 :: Only",
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
"Programming Language :: Python :: 3.8",
|
"Programming Language :: Python :: 3.10",
|
||||||
"Programming Language :: Python :: 3.9",
|
|
||||||
"Intended Audience :: Science/Research",
|
"Intended Audience :: Science/Research",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"Topic :: System :: Distributed Computing",
|
"Topic :: System :: Distributed Computing",
|
||||||
|
|
|
@ -7,16 +7,91 @@ import os
|
||||||
import random
|
import random
|
||||||
import signal
|
import signal
|
||||||
import platform
|
import platform
|
||||||
|
import pathlib
|
||||||
import time
|
import time
|
||||||
|
import inspect
|
||||||
|
from functools import partial, wraps
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
|
|
||||||
# export for tests
|
|
||||||
from tractor.testing import tractor_test # noqa
|
|
||||||
|
|
||||||
|
|
||||||
pytest_plugins = ['pytester']
|
pytest_plugins = ['pytester']
|
||||||
|
|
||||||
|
|
||||||
|
def tractor_test(fn):
|
||||||
|
"""
|
||||||
|
Use:
|
||||||
|
|
||||||
|
@tractor_test
|
||||||
|
async def test_whatever():
|
||||||
|
await ...
|
||||||
|
|
||||||
|
If fixtures:
|
||||||
|
|
||||||
|
- ``arb_addr`` (a socket addr tuple where arbiter is listening)
|
||||||
|
- ``loglevel`` (logging level passed to tractor internals)
|
||||||
|
- ``start_method`` (subprocess spawning backend)
|
||||||
|
|
||||||
|
are defined in the `pytest` fixture space they will be automatically
|
||||||
|
injected to tests declaring these funcargs.
|
||||||
|
"""
|
||||||
|
@wraps(fn)
|
||||||
|
def wrapper(
|
||||||
|
*args,
|
||||||
|
loglevel=None,
|
||||||
|
arb_addr=None,
|
||||||
|
start_method=None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
# __tracebackhide__ = True
|
||||||
|
|
||||||
|
if 'arb_addr' in inspect.signature(fn).parameters:
|
||||||
|
# injects test suite fixture value to test as well
|
||||||
|
# as `run()`
|
||||||
|
kwargs['arb_addr'] = arb_addr
|
||||||
|
|
||||||
|
if 'loglevel' in inspect.signature(fn).parameters:
|
||||||
|
# allows test suites to define a 'loglevel' fixture
|
||||||
|
# that activates the internal logging
|
||||||
|
kwargs['loglevel'] = loglevel
|
||||||
|
|
||||||
|
if start_method is None:
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
start_method = 'trio'
|
||||||
|
|
||||||
|
if 'start_method' in inspect.signature(fn).parameters:
|
||||||
|
# set of subprocess spawning backends
|
||||||
|
kwargs['start_method'] = start_method
|
||||||
|
|
||||||
|
if kwargs:
|
||||||
|
|
||||||
|
# use explicit root actor start
|
||||||
|
|
||||||
|
async def _main():
|
||||||
|
async with tractor.open_root_actor(
|
||||||
|
# **kwargs,
|
||||||
|
arbiter_addr=arb_addr,
|
||||||
|
loglevel=loglevel,
|
||||||
|
start_method=start_method,
|
||||||
|
|
||||||
|
# TODO: only enable when pytest is passed --pdb
|
||||||
|
# debug_mode=True,
|
||||||
|
|
||||||
|
):
|
||||||
|
await fn(*args, **kwargs)
|
||||||
|
|
||||||
|
main = _main
|
||||||
|
|
||||||
|
else:
|
||||||
|
# use implicit root actor start
|
||||||
|
main = partial(fn, *args, **kwargs)
|
||||||
|
|
||||||
|
return trio.run(main)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
_arb_addr = '127.0.0.1', random.randint(1000, 9999)
|
_arb_addr = '127.0.0.1', random.randint(1000, 9999)
|
||||||
|
|
||||||
|
|
||||||
|
@ -39,20 +114,27 @@ no_windows = pytest.mark.skipif(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def repodir():
|
def repodir() -> pathlib.Path:
|
||||||
"""Return the abspath to the repo directory.
|
'''
|
||||||
"""
|
Return the abspath to the repo directory.
|
||||||
dirname = os.path.dirname
|
|
||||||
dirpath = os.path.abspath(
|
'''
|
||||||
dirname(dirname(os.path.realpath(__file__)))
|
# 2 parents up to step up through tests/<repo_dir>
|
||||||
)
|
return pathlib.Path(__file__).parent.parent.absolute()
|
||||||
return dirpath
|
|
||||||
|
|
||||||
|
def examples_dir() -> pathlib.Path:
|
||||||
|
'''
|
||||||
|
Return the abspath to the examples directory as `pathlib.Path`.
|
||||||
|
|
||||||
|
'''
|
||||||
|
return repodir() / 'examples'
|
||||||
|
|
||||||
|
|
||||||
def pytest_addoption(parser):
|
def pytest_addoption(parser):
|
||||||
parser.addoption(
|
parser.addoption(
|
||||||
"--ll", action="store", dest='loglevel',
|
"--ll", action="store", dest='loglevel',
|
||||||
default=None, help="logging level to set when testing"
|
default='ERROR', help="logging level to set when testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.addoption(
|
parser.addoption(
|
||||||
|
@ -64,31 +146,31 @@ def pytest_addoption(parser):
|
||||||
|
|
||||||
def pytest_configure(config):
|
def pytest_configure(config):
|
||||||
backend = config.option.spawn_backend
|
backend = config.option.spawn_backend
|
||||||
|
tractor._spawn.try_set_start_method(backend)
|
||||||
if backend == 'mp':
|
|
||||||
tractor._spawn.try_set_start_method('spawn')
|
|
||||||
elif backend == 'trio':
|
|
||||||
tractor._spawn.try_set_start_method(backend)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='session', autouse=True)
|
@pytest.fixture(scope='session', autouse=True)
|
||||||
def loglevel(request):
|
def loglevel(request):
|
||||||
orig = tractor.log._default_loglevel
|
orig = tractor.log._default_loglevel
|
||||||
level = tractor.log._default_loglevel = request.config.option.loglevel
|
level = tractor.log._default_loglevel = request.config.option.loglevel
|
||||||
|
tractor.log.get_console_log(level)
|
||||||
yield level
|
yield level
|
||||||
tractor.log._default_loglevel = orig
|
tractor.log._default_loglevel = orig
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='session')
|
@pytest.fixture(scope='session')
|
||||||
def spawn_backend(request):
|
def spawn_backend(request) -> str:
|
||||||
return request.config.option.spawn_backend
|
return request.config.option.spawn_backend
|
||||||
|
|
||||||
|
|
||||||
|
_ci_env: bool = os.environ.get('CI', False)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='session')
|
@pytest.fixture(scope='session')
|
||||||
def ci_env() -> bool:
|
def ci_env() -> bool:
|
||||||
"""Detect CI envoirment.
|
"""Detect CI envoirment.
|
||||||
"""
|
"""
|
||||||
return os.environ.get('TRAVIS', False) or os.environ.get('CI', False)
|
return _ci_env
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='session')
|
@pytest.fixture(scope='session')
|
||||||
|
@ -98,24 +180,24 @@ def arb_addr():
|
||||||
|
|
||||||
def pytest_generate_tests(metafunc):
|
def pytest_generate_tests(metafunc):
|
||||||
spawn_backend = metafunc.config.option.spawn_backend
|
spawn_backend = metafunc.config.option.spawn_backend
|
||||||
|
|
||||||
if not spawn_backend:
|
if not spawn_backend:
|
||||||
# XXX some weird windows bug with `pytest`?
|
# XXX some weird windows bug with `pytest`?
|
||||||
spawn_backend = 'mp'
|
spawn_backend = 'trio'
|
||||||
assert spawn_backend in ('mp', 'trio')
|
|
||||||
|
|
||||||
|
# TODO: maybe just use the literal `._spawn.SpawnMethodKey`?
|
||||||
|
assert spawn_backend in (
|
||||||
|
'mp_spawn',
|
||||||
|
'mp_forkserver',
|
||||||
|
'trio',
|
||||||
|
)
|
||||||
|
|
||||||
|
# NOTE: used to be used to dyanmically parametrize tests for when
|
||||||
|
# you just passed --spawn-backend=`mp` on the cli, but now we expect
|
||||||
|
# that cli input to be manually specified, BUT, maybe we'll do
|
||||||
|
# something like this again in the future?
|
||||||
if 'start_method' in metafunc.fixturenames:
|
if 'start_method' in metafunc.fixturenames:
|
||||||
if spawn_backend == 'mp':
|
metafunc.parametrize("start_method", [spawn_backend], scope='module')
|
||||||
from multiprocessing import get_all_start_methods
|
|
||||||
methods = get_all_start_methods()
|
|
||||||
if 'fork' in methods:
|
|
||||||
# fork not available on windows, so check before
|
|
||||||
# removing XXX: the fork method is in general
|
|
||||||
# incompatible with trio's global scheduler state
|
|
||||||
methods.remove('fork')
|
|
||||||
elif spawn_backend == 'trio':
|
|
||||||
methods = ['trio']
|
|
||||||
|
|
||||||
metafunc.parametrize("start_method", methods, scope='module')
|
|
||||||
|
|
||||||
|
|
||||||
def sig_prog(proc, sig):
|
def sig_prog(proc, sig):
|
||||||
|
@ -131,16 +213,22 @@ def sig_prog(proc, sig):
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def daemon(loglevel, testdir, arb_addr):
|
def daemon(
|
||||||
"""Run a daemon actor as a "remote arbiter".
|
loglevel: str,
|
||||||
"""
|
testdir,
|
||||||
|
arb_addr: tuple[str, int],
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Run a daemon actor as a "remote arbiter".
|
||||||
|
|
||||||
|
'''
|
||||||
if loglevel in ('trace', 'debug'):
|
if loglevel in ('trace', 'debug'):
|
||||||
# too much logging will lock up the subproc (smh)
|
# too much logging will lock up the subproc (smh)
|
||||||
loglevel = 'info'
|
loglevel = 'info'
|
||||||
|
|
||||||
cmdargs = [
|
cmdargs = [
|
||||||
sys.executable, '-c',
|
sys.executable, '-c',
|
||||||
"import tractor; tractor.run_daemon([], arbiter_addr={}, loglevel={})"
|
"import tractor; tractor.run_daemon([], registry_addr={}, loglevel={})"
|
||||||
.format(
|
.format(
|
||||||
arb_addr,
|
arb_addr,
|
||||||
"'{}'".format(loglevel) if loglevel else None)
|
"'{}'".format(loglevel) if loglevel else None)
|
||||||
|
|
|
@ -1,417 +1,11 @@
|
||||||
"""
|
"""
|
||||||
Bidirectional streaming and context API.
|
Bidirectional streaming.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import platform
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import trio
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
|
|
||||||
from conftest import tractor_test
|
|
||||||
|
|
||||||
# the general stream semantics are
|
|
||||||
# - normal termination: far end relays a stop message which
|
|
||||||
# terminates an ongoing ``MsgStream`` iteration
|
|
||||||
# - cancel termination: context is cancelled on either side cancelling
|
|
||||||
# the "linked" inter-actor task context
|
|
||||||
|
|
||||||
|
|
||||||
_state: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@tractor.context
|
|
||||||
async def simple_setup_teardown(
|
|
||||||
|
|
||||||
ctx: tractor.Context,
|
|
||||||
data: int,
|
|
||||||
block_forever: bool = False,
|
|
||||||
|
|
||||||
) -> None:
|
|
||||||
|
|
||||||
# startup phase
|
|
||||||
global _state
|
|
||||||
_state = True
|
|
||||||
|
|
||||||
# signal to parent that we're up
|
|
||||||
await ctx.started(data + 1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if block_forever:
|
|
||||||
# block until cancelled
|
|
||||||
await trio.sleep_forever()
|
|
||||||
else:
|
|
||||||
return 'yo'
|
|
||||||
finally:
|
|
||||||
_state = False
|
|
||||||
|
|
||||||
|
|
||||||
async def assert_state(value: bool):
|
|
||||||
global _state
|
|
||||||
assert _state == value
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
'error_parent',
|
|
||||||
[False, ValueError, KeyboardInterrupt],
|
|
||||||
)
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
'callee_blocks_forever',
|
|
||||||
[False, True],
|
|
||||||
ids=lambda item: f'callee_blocks_forever={item}'
|
|
||||||
)
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
'pointlessly_open_stream',
|
|
||||||
[False, True],
|
|
||||||
ids=lambda item: f'open_stream={item}'
|
|
||||||
)
|
|
||||||
def test_simple_context(
|
|
||||||
error_parent,
|
|
||||||
callee_blocks_forever,
|
|
||||||
pointlessly_open_stream,
|
|
||||||
):
|
|
||||||
|
|
||||||
timeout = 1.5 if not platform.system() == 'Windows' else 3
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
|
|
||||||
with trio.fail_after(timeout):
|
|
||||||
async with tractor.open_nursery() as nursery:
|
|
||||||
|
|
||||||
portal = await nursery.start_actor(
|
|
||||||
'simple_context',
|
|
||||||
enable_modules=[__name__],
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with portal.open_context(
|
|
||||||
simple_setup_teardown,
|
|
||||||
data=10,
|
|
||||||
block_forever=callee_blocks_forever,
|
|
||||||
) as (ctx, sent):
|
|
||||||
|
|
||||||
assert sent == 11
|
|
||||||
|
|
||||||
if callee_blocks_forever:
|
|
||||||
await portal.run(assert_state, value=True)
|
|
||||||
else:
|
|
||||||
assert await ctx.result() == 'yo'
|
|
||||||
|
|
||||||
if not error_parent:
|
|
||||||
await ctx.cancel()
|
|
||||||
|
|
||||||
if pointlessly_open_stream:
|
|
||||||
async with ctx.open_stream():
|
|
||||||
if error_parent:
|
|
||||||
raise error_parent
|
|
||||||
|
|
||||||
if callee_blocks_forever:
|
|
||||||
await ctx.cancel()
|
|
||||||
else:
|
|
||||||
# in this case the stream will send a
|
|
||||||
# 'stop' msg to the far end which needs
|
|
||||||
# to be ignored
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
if error_parent:
|
|
||||||
raise error_parent
|
|
||||||
|
|
||||||
finally:
|
|
||||||
|
|
||||||
# after cancellation
|
|
||||||
if not error_parent:
|
|
||||||
await portal.run(assert_state, value=False)
|
|
||||||
|
|
||||||
# shut down daemon
|
|
||||||
await portal.cancel_actor()
|
|
||||||
|
|
||||||
if error_parent:
|
|
||||||
try:
|
|
||||||
trio.run(main)
|
|
||||||
except error_parent:
|
|
||||||
pass
|
|
||||||
except trio.MultiError as me:
|
|
||||||
# XXX: on windows it seems we may have to expect the group error
|
|
||||||
from tractor._exceptions import is_multi_cancelled
|
|
||||||
assert is_multi_cancelled(me)
|
|
||||||
else:
|
|
||||||
trio.run(main)
|
|
||||||
|
|
||||||
|
|
||||||
# basic stream terminations:
|
|
||||||
# - callee context closes without using stream
|
|
||||||
# - caller context closes without using stream
|
|
||||||
# - caller context calls `Context.cancel()` while streaming
|
|
||||||
# is ongoing resulting in callee being cancelled
|
|
||||||
# - callee calls `Context.cancel()` while streaming and caller
|
|
||||||
# sees stream terminated in `RemoteActorError`
|
|
||||||
|
|
||||||
# TODO: future possible features
|
|
||||||
# - restart request: far end raises `ContextRestart`
|
|
||||||
|
|
||||||
|
|
||||||
@tractor.context
|
|
||||||
async def close_ctx_immediately(
|
|
||||||
|
|
||||||
ctx: tractor.Context,
|
|
||||||
|
|
||||||
) -> None:
|
|
||||||
|
|
||||||
await ctx.started()
|
|
||||||
global _state
|
|
||||||
|
|
||||||
async with ctx.open_stream():
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@tractor_test
|
|
||||||
async def test_callee_closes_ctx_after_stream_open():
|
|
||||||
'callee context closes without using stream'
|
|
||||||
|
|
||||||
async with tractor.open_nursery() as n:
|
|
||||||
|
|
||||||
portal = await n.start_actor(
|
|
||||||
'fast_stream_closer',
|
|
||||||
enable_modules=[__name__],
|
|
||||||
)
|
|
||||||
|
|
||||||
async with portal.open_context(
|
|
||||||
close_ctx_immediately,
|
|
||||||
|
|
||||||
# flag to avoid waiting the final result
|
|
||||||
# cancel_on_exit=True,
|
|
||||||
|
|
||||||
) as (ctx, sent):
|
|
||||||
|
|
||||||
assert sent is None
|
|
||||||
|
|
||||||
with trio.fail_after(0.5):
|
|
||||||
async with ctx.open_stream() as stream:
|
|
||||||
|
|
||||||
# should fall through since ``StopAsyncIteration``
|
|
||||||
# should be raised through translation of
|
|
||||||
# a ``trio.EndOfChannel`` by
|
|
||||||
# ``trio.abc.ReceiveChannel.__anext__()``
|
|
||||||
async for _ in stream:
|
|
||||||
assert 0
|
|
||||||
else:
|
|
||||||
|
|
||||||
# verify stream is now closed
|
|
||||||
try:
|
|
||||||
await stream.receive()
|
|
||||||
except trio.EndOfChannel:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# TODO: should be just raise the closed resource err
|
|
||||||
# directly here to enforce not allowing a re-open
|
|
||||||
# of a stream to the context (at least until a time of
|
|
||||||
# if/when we decide that's a good idea?)
|
|
||||||
try:
|
|
||||||
async with ctx.open_stream() as stream:
|
|
||||||
pass
|
|
||||||
except trio.ClosedResourceError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
await portal.cancel_actor()
|
|
||||||
|
|
||||||
|
|
||||||
@tractor.context
|
|
||||||
async def expect_cancelled(
|
|
||||||
|
|
||||||
ctx: tractor.Context,
|
|
||||||
|
|
||||||
) -> None:
|
|
||||||
global _state
|
|
||||||
_state = True
|
|
||||||
|
|
||||||
await ctx.started()
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with ctx.open_stream() as stream:
|
|
||||||
async for msg in stream:
|
|
||||||
await stream.send(msg) # echo server
|
|
||||||
|
|
||||||
except trio.Cancelled:
|
|
||||||
# expected case
|
|
||||||
_state = False
|
|
||||||
raise
|
|
||||||
|
|
||||||
else:
|
|
||||||
assert 0, "Wasn't cancelled!?"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
'use_ctx_cancel_method',
|
|
||||||
[False, True],
|
|
||||||
)
|
|
||||||
@tractor_test
|
|
||||||
async def test_caller_closes_ctx_after_callee_opens_stream(
|
|
||||||
use_ctx_cancel_method: bool,
|
|
||||||
):
|
|
||||||
'caller context closes without using stream'
|
|
||||||
|
|
||||||
async with tractor.open_nursery() as n:
|
|
||||||
|
|
||||||
portal = await n.start_actor(
|
|
||||||
'ctx_cancelled',
|
|
||||||
enable_modules=[__name__],
|
|
||||||
)
|
|
||||||
|
|
||||||
async with portal.open_context(
|
|
||||||
expect_cancelled,
|
|
||||||
) as (ctx, sent):
|
|
||||||
await portal.run(assert_state, value=True)
|
|
||||||
|
|
||||||
assert sent is None
|
|
||||||
|
|
||||||
# call cancel explicitly
|
|
||||||
if use_ctx_cancel_method:
|
|
||||||
|
|
||||||
await ctx.cancel()
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with ctx.open_stream() as stream:
|
|
||||||
async for msg in stream:
|
|
||||||
pass
|
|
||||||
|
|
||||||
except tractor.ContextCancelled:
|
|
||||||
raise # XXX: must be propagated to __aexit__
|
|
||||||
|
|
||||||
else:
|
|
||||||
assert 0, "Should have context cancelled?"
|
|
||||||
|
|
||||||
# channel should still be up
|
|
||||||
assert portal.channel.connected()
|
|
||||||
|
|
||||||
# ctx is closed here
|
|
||||||
await portal.run(assert_state, value=False)
|
|
||||||
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
with trio.fail_after(0.2):
|
|
||||||
await ctx.result()
|
|
||||||
assert 0, "Callee should have blocked!?"
|
|
||||||
except trio.TooSlowError:
|
|
||||||
await ctx.cancel()
|
|
||||||
try:
|
|
||||||
async with ctx.open_stream() as stream:
|
|
||||||
async for msg in stream:
|
|
||||||
pass
|
|
||||||
except tractor.ContextCancelled:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
assert 0, "Should have received closed resource error?"
|
|
||||||
|
|
||||||
# ctx is closed here
|
|
||||||
await portal.run(assert_state, value=False)
|
|
||||||
|
|
||||||
# channel should not have been destroyed yet, only the
|
|
||||||
# inter-actor-task context
|
|
||||||
assert portal.channel.connected()
|
|
||||||
|
|
||||||
# teardown the actor
|
|
||||||
await portal.cancel_actor()
|
|
||||||
|
|
||||||
|
|
||||||
@tractor_test
|
|
||||||
async def test_multitask_caller_cancels_from_nonroot_task():
|
|
||||||
|
|
||||||
async with tractor.open_nursery() as n:
|
|
||||||
|
|
||||||
portal = await n.start_actor(
|
|
||||||
'ctx_cancelled',
|
|
||||||
enable_modules=[__name__],
|
|
||||||
)
|
|
||||||
|
|
||||||
async with portal.open_context(
|
|
||||||
expect_cancelled,
|
|
||||||
) as (ctx, sent):
|
|
||||||
|
|
||||||
await portal.run(assert_state, value=True)
|
|
||||||
assert sent is None
|
|
||||||
|
|
||||||
async with ctx.open_stream() as stream:
|
|
||||||
|
|
||||||
async def send_msg_then_cancel():
|
|
||||||
await stream.send('yo')
|
|
||||||
await portal.run(assert_state, value=True)
|
|
||||||
await ctx.cancel()
|
|
||||||
await portal.run(assert_state, value=False)
|
|
||||||
|
|
||||||
async with trio.open_nursery() as n:
|
|
||||||
n.start_soon(send_msg_then_cancel)
|
|
||||||
|
|
||||||
try:
|
|
||||||
async for msg in stream:
|
|
||||||
assert msg == 'yo'
|
|
||||||
|
|
||||||
except tractor.ContextCancelled:
|
|
||||||
raise # XXX: must be propagated to __aexit__
|
|
||||||
|
|
||||||
# channel should still be up
|
|
||||||
assert portal.channel.connected()
|
|
||||||
|
|
||||||
# ctx is closed here
|
|
||||||
await portal.run(assert_state, value=False)
|
|
||||||
|
|
||||||
# channel should not have been destroyed yet, only the
|
|
||||||
# inter-actor-task context
|
|
||||||
assert portal.channel.connected()
|
|
||||||
|
|
||||||
# teardown the actor
|
|
||||||
await portal.cancel_actor()
|
|
||||||
|
|
||||||
|
|
||||||
@tractor.context
|
|
||||||
async def cancel_self(
|
|
||||||
|
|
||||||
ctx: tractor.Context,
|
|
||||||
|
|
||||||
) -> None:
|
|
||||||
global _state
|
|
||||||
_state = True
|
|
||||||
|
|
||||||
await ctx.cancel()
|
|
||||||
try:
|
|
||||||
with trio.fail_after(0.1):
|
|
||||||
await trio.sleep_forever()
|
|
||||||
|
|
||||||
except trio.Cancelled:
|
|
||||||
raise
|
|
||||||
|
|
||||||
except trio.TooSlowError:
|
|
||||||
# should never get here
|
|
||||||
assert 0
|
|
||||||
|
|
||||||
|
|
||||||
@tractor_test
|
|
||||||
async def test_callee_cancels_before_started():
|
|
||||||
'''callee calls `Context.cancel()` while streaming and caller
|
|
||||||
sees stream terminated in `ContextCancelled`.
|
|
||||||
|
|
||||||
'''
|
|
||||||
async with tractor.open_nursery() as n:
|
|
||||||
|
|
||||||
portal = await n.start_actor(
|
|
||||||
'cancels_self',
|
|
||||||
enable_modules=[__name__],
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
|
|
||||||
async with portal.open_context(
|
|
||||||
cancel_self,
|
|
||||||
) as (ctx, sent):
|
|
||||||
async with ctx.open_stream():
|
|
||||||
|
|
||||||
await trio.sleep_forever()
|
|
||||||
|
|
||||||
# raises a special cancel signal
|
|
||||||
except tractor.ContextCancelled as ce:
|
|
||||||
ce.type == trio.Cancelled
|
|
||||||
|
|
||||||
# teardown the actor
|
|
||||||
await portal.cancel_actor()
|
|
||||||
|
|
||||||
|
|
||||||
@tractor.context
|
@tractor.context
|
||||||
async def simple_rpc(
|
async def simple_rpc(
|
||||||
|
@ -420,9 +14,10 @@ async def simple_rpc(
|
||||||
data: int,
|
data: int,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test a small ping-pong server.
|
'''
|
||||||
|
Test a small ping-pong server.
|
||||||
|
|
||||||
"""
|
'''
|
||||||
# signal to parent that we're up
|
# signal to parent that we're up
|
||||||
await ctx.started(data + 1)
|
await ctx.started(data + 1)
|
||||||
|
|
||||||
|
@ -480,9 +75,10 @@ async def simple_rpc_with_forloop(
|
||||||
[simple_rpc, simple_rpc_with_forloop],
|
[simple_rpc, simple_rpc_with_forloop],
|
||||||
)
|
)
|
||||||
def test_simple_rpc(server_func, use_async_for):
|
def test_simple_rpc(server_func, use_async_for):
|
||||||
"""The simplest request response pattern.
|
'''
|
||||||
|
The simplest request response pattern.
|
||||||
|
|
||||||
"""
|
'''
|
||||||
async def main():
|
async def main():
|
||||||
async with tractor.open_nursery() as n:
|
async with tractor.open_nursery() as n:
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,193 @@
|
||||||
|
'''
|
||||||
|
Sketchy network blackoutz, ugly byzantine gens, puedes eschuchar la
|
||||||
|
cancelacion?..
|
||||||
|
|
||||||
|
'''
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from _pytest.pathlib import import_path
|
||||||
|
import trio
|
||||||
|
import tractor
|
||||||
|
|
||||||
|
from conftest import (
|
||||||
|
examples_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'debug_mode',
|
||||||
|
[False, True],
|
||||||
|
ids=['no_debug_mode', 'debug_mode'],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'ipc_break',
|
||||||
|
[
|
||||||
|
# no breaks
|
||||||
|
{
|
||||||
|
'break_parent_ipc_after': False,
|
||||||
|
'break_child_ipc_after': False,
|
||||||
|
},
|
||||||
|
|
||||||
|
# only parent breaks
|
||||||
|
{
|
||||||
|
'break_parent_ipc_after': 500,
|
||||||
|
'break_child_ipc_after': False,
|
||||||
|
},
|
||||||
|
|
||||||
|
# only child breaks
|
||||||
|
{
|
||||||
|
'break_parent_ipc_after': False,
|
||||||
|
'break_child_ipc_after': 500,
|
||||||
|
},
|
||||||
|
|
||||||
|
# both: break parent first
|
||||||
|
{
|
||||||
|
'break_parent_ipc_after': 500,
|
||||||
|
'break_child_ipc_after': 800,
|
||||||
|
},
|
||||||
|
# both: break child first
|
||||||
|
{
|
||||||
|
'break_parent_ipc_after': 800,
|
||||||
|
'break_child_ipc_after': 500,
|
||||||
|
},
|
||||||
|
|
||||||
|
],
|
||||||
|
ids=[
|
||||||
|
'no_break',
|
||||||
|
'break_parent',
|
||||||
|
'break_child',
|
||||||
|
'break_both_parent_first',
|
||||||
|
'break_both_child_first',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ipc_channel_break_during_stream(
|
||||||
|
debug_mode: bool,
|
||||||
|
spawn_backend: str,
|
||||||
|
ipc_break: dict | None,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Ensure we can have an IPC channel break its connection during
|
||||||
|
streaming and it's still possible for the (simulated) user to kill
|
||||||
|
the actor tree using SIGINT.
|
||||||
|
|
||||||
|
We also verify the type of connection error expected in the parent
|
||||||
|
depending on which side if the IPC breaks first.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if spawn_backend != 'trio':
|
||||||
|
if debug_mode:
|
||||||
|
pytest.skip('`debug_mode` only supported on `trio` spawner')
|
||||||
|
|
||||||
|
# non-`trio` spawners should never hit the hang condition that
|
||||||
|
# requires the user to do ctl-c to cancel the actor tree.
|
||||||
|
expect_final_exc = trio.ClosedResourceError
|
||||||
|
|
||||||
|
mod = import_path(
|
||||||
|
examples_dir() / 'advanced_faults' / 'ipc_failure_during_stream.py',
|
||||||
|
root=examples_dir(),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect_final_exc = KeyboardInterrupt
|
||||||
|
|
||||||
|
# when ONLY the child breaks we expect the parent to get a closed
|
||||||
|
# resource error on the next `MsgStream.receive()` and then fail out
|
||||||
|
# and cancel the child from there.
|
||||||
|
if (
|
||||||
|
|
||||||
|
# only child breaks
|
||||||
|
(
|
||||||
|
ipc_break['break_child_ipc_after']
|
||||||
|
and ipc_break['break_parent_ipc_after'] is False
|
||||||
|
)
|
||||||
|
|
||||||
|
# both break but, parent breaks first
|
||||||
|
or (
|
||||||
|
ipc_break['break_child_ipc_after'] is not False
|
||||||
|
and (
|
||||||
|
ipc_break['break_parent_ipc_after']
|
||||||
|
> ipc_break['break_child_ipc_after']
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
):
|
||||||
|
expect_final_exc = trio.ClosedResourceError
|
||||||
|
|
||||||
|
# when the parent IPC side dies (even if the child's does as well
|
||||||
|
# but the child fails BEFORE the parent) we expect the channel to be
|
||||||
|
# sent a stop msg from the child at some point which will signal the
|
||||||
|
# parent that the stream has been terminated.
|
||||||
|
# NOTE: when the parent breaks "after" the child you get this same
|
||||||
|
# case as well, the child breaks the IPC channel with a stop msg
|
||||||
|
# before any closure takes place.
|
||||||
|
elif (
|
||||||
|
# only parent breaks
|
||||||
|
(
|
||||||
|
ipc_break['break_parent_ipc_after']
|
||||||
|
and ipc_break['break_child_ipc_after'] is False
|
||||||
|
)
|
||||||
|
|
||||||
|
# both break but, child breaks first
|
||||||
|
or (
|
||||||
|
ipc_break['break_parent_ipc_after'] is not False
|
||||||
|
and (
|
||||||
|
ipc_break['break_child_ipc_after']
|
||||||
|
> ipc_break['break_parent_ipc_after']
|
||||||
|
)
|
||||||
|
)
|
||||||
|
):
|
||||||
|
expect_final_exc = trio.EndOfChannel
|
||||||
|
|
||||||
|
with pytest.raises(expect_final_exc):
|
||||||
|
trio.run(
|
||||||
|
partial(
|
||||||
|
mod.main,
|
||||||
|
debug_mode=debug_mode,
|
||||||
|
start_method=spawn_backend,
|
||||||
|
**ipc_break,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def break_ipc_after_started(
|
||||||
|
ctx: tractor.Context,
|
||||||
|
) -> None:
|
||||||
|
await ctx.started()
|
||||||
|
async with ctx.open_stream() as stream:
|
||||||
|
await stream.aclose()
|
||||||
|
await trio.sleep(0.2)
|
||||||
|
await ctx.chan.send(None)
|
||||||
|
print('child broke IPC and terminating')
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_closed_right_after_ipc_break_and_zombie_lord_engages():
|
||||||
|
'''
|
||||||
|
Verify that is a subactor's IPC goes down just after bringing up a stream
|
||||||
|
the parent can trigger a SIGINT and the child will be reaped out-of-IPC by
|
||||||
|
the localhost process supervision machinery: aka "zombie lord".
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main():
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
portal = await n.start_actor(
|
||||||
|
'ipc_breaker',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
|
||||||
|
with trio.move_on_after(1):
|
||||||
|
async with (
|
||||||
|
portal.open_context(
|
||||||
|
break_ipc_after_started
|
||||||
|
) as (ctx, sent),
|
||||||
|
):
|
||||||
|
async with ctx.open_stream():
|
||||||
|
await trio.sleep(0.5)
|
||||||
|
|
||||||
|
print('parent waiting on context')
|
||||||
|
|
||||||
|
print('parent exited context')
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
with pytest.raises(KeyboardInterrupt):
|
||||||
|
trio.run(main)
|
|
@ -1,15 +1,20 @@
|
||||||
"""
|
'''
|
||||||
Advanced streaming patterns using bidirectional streams and contexts.
|
Advanced streaming patterns using bidirectional streams and contexts.
|
||||||
|
|
||||||
"""
|
'''
|
||||||
|
from collections import Counter
|
||||||
import itertools
|
import itertools
|
||||||
from typing import Set, Dict, List
|
import platform
|
||||||
|
|
||||||
import trio
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
|
|
||||||
|
|
||||||
_registry: Dict[str, Set[tractor.ReceiveMsgStream]] = {
|
def is_win():
|
||||||
|
return platform.system() == 'Windows'
|
||||||
|
|
||||||
|
|
||||||
|
_registry: dict[str, set[tractor.MsgStream]] = {
|
||||||
'even': set(),
|
'even': set(),
|
||||||
'odd': set(),
|
'odd': set(),
|
||||||
}
|
}
|
||||||
|
@ -71,7 +76,7 @@ async def subscribe(
|
||||||
|
|
||||||
async def consumer(
|
async def consumer(
|
||||||
|
|
||||||
subs: List[str],
|
subs: list[str],
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
|
@ -172,14 +177,22 @@ async def one_task_streams_and_one_handles_reqresp(
|
||||||
|
|
||||||
|
|
||||||
def test_reqresp_ontopof_streaming():
|
def test_reqresp_ontopof_streaming():
|
||||||
'''Test a subactor that both streams with one task and
|
'''
|
||||||
|
Test a subactor that both streams with one task and
|
||||||
spawns another which handles a small requests-response
|
spawns another which handles a small requests-response
|
||||||
dialogue over the same bidir-stream.
|
dialogue over the same bidir-stream.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
async def main():
|
async def main():
|
||||||
|
|
||||||
with trio.move_on_after(2):
|
# flat to make sure we get at least one pong
|
||||||
|
got_pong: bool = False
|
||||||
|
timeout: int = 2
|
||||||
|
|
||||||
|
if is_win(): # smh
|
||||||
|
timeout = 4
|
||||||
|
|
||||||
|
with trio.move_on_after(timeout):
|
||||||
async with tractor.open_nursery() as n:
|
async with tractor.open_nursery() as n:
|
||||||
|
|
||||||
# name of this actor will be same as target func
|
# name of this actor will be same as target func
|
||||||
|
@ -188,9 +201,6 @@ def test_reqresp_ontopof_streaming():
|
||||||
enable_modules=[__name__]
|
enable_modules=[__name__]
|
||||||
)
|
)
|
||||||
|
|
||||||
# flat to make sure we get at least one pong
|
|
||||||
got_pong: bool = False
|
|
||||||
|
|
||||||
async with portal.open_context(
|
async with portal.open_context(
|
||||||
one_task_streams_and_one_handles_reqresp,
|
one_task_streams_and_one_handles_reqresp,
|
||||||
|
|
||||||
|
@ -242,8 +252,12 @@ def test_sigint_both_stream_types():
|
||||||
side-by-side will cancel correctly from SIGINT.
|
side-by-side will cancel correctly from SIGINT.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
timeout: float = 2
|
||||||
|
if is_win(): # smh
|
||||||
|
timeout += 1
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
with trio.fail_after(2):
|
with trio.fail_after(timeout):
|
||||||
async with tractor.open_nursery() as n:
|
async with tractor.open_nursery() as n:
|
||||||
# name of this actor will be same as target func
|
# name of this actor will be same as target func
|
||||||
portal = await n.start_actor(
|
portal = await n.start_actor(
|
||||||
|
@ -269,3 +283,98 @@ def test_sigint_both_stream_types():
|
||||||
assert 0, "Didn't receive KBI!?"
|
assert 0, "Didn't receive KBI!?"
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def inf_streamer(
|
||||||
|
ctx: tractor.Context,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Stream increasing ints until terminated with a 'done' msg.
|
||||||
|
|
||||||
|
'''
|
||||||
|
await ctx.started()
|
||||||
|
|
||||||
|
async with (
|
||||||
|
ctx.open_stream() as stream,
|
||||||
|
trio.open_nursery() as n,
|
||||||
|
):
|
||||||
|
async def bail_on_sentinel():
|
||||||
|
async for msg in stream:
|
||||||
|
if msg == 'done':
|
||||||
|
await stream.aclose()
|
||||||
|
else:
|
||||||
|
print(f'streamer received {msg}')
|
||||||
|
|
||||||
|
# start termination detector
|
||||||
|
n.start_soon(bail_on_sentinel)
|
||||||
|
|
||||||
|
for val in itertools.count():
|
||||||
|
try:
|
||||||
|
await stream.send(val)
|
||||||
|
except trio.ClosedResourceError:
|
||||||
|
# close out the stream gracefully
|
||||||
|
break
|
||||||
|
|
||||||
|
print('terminating streamer')
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_task_fanout_from_stream():
|
||||||
|
'''
|
||||||
|
Single stream with multiple local consumer tasks using the
|
||||||
|
``MsgStream.subscribe()` api.
|
||||||
|
|
||||||
|
Ensure all tasks receive all values after stream completes sending.
|
||||||
|
|
||||||
|
'''
|
||||||
|
consumers = 22
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
counts = Counter()
|
||||||
|
|
||||||
|
async with tractor.open_nursery() as tn:
|
||||||
|
p = await tn.start_actor(
|
||||||
|
'inf_streamer',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
async with (
|
||||||
|
p.open_context(inf_streamer) as (ctx, _),
|
||||||
|
ctx.open_stream() as stream,
|
||||||
|
):
|
||||||
|
|
||||||
|
async def pull_and_count(name: str):
|
||||||
|
# name = trio.lowlevel.current_task().name
|
||||||
|
async with stream.subscribe() as recver:
|
||||||
|
assert isinstance(
|
||||||
|
recver,
|
||||||
|
tractor.trionics.BroadcastReceiver
|
||||||
|
)
|
||||||
|
async for val in recver:
|
||||||
|
# print(f'{name}: {val}')
|
||||||
|
counts[name] += 1
|
||||||
|
|
||||||
|
print(f'{name} bcaster ended')
|
||||||
|
|
||||||
|
print(f'{name} completed')
|
||||||
|
|
||||||
|
with trio.fail_after(3):
|
||||||
|
async with trio.open_nursery() as nurse:
|
||||||
|
for i in range(consumers):
|
||||||
|
nurse.start_soon(pull_and_count, i)
|
||||||
|
|
||||||
|
await trio.sleep(0.5)
|
||||||
|
print('\nterminating')
|
||||||
|
await stream.send('done')
|
||||||
|
|
||||||
|
print('closed stream connection')
|
||||||
|
|
||||||
|
assert len(counts) == consumers
|
||||||
|
mx = max(counts.values())
|
||||||
|
# make sure each task received all stream values
|
||||||
|
assert all(val == mx for val in counts.values())
|
||||||
|
|
||||||
|
await p.cancel_actor()
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
|
@ -8,6 +8,10 @@ import platform
|
||||||
import time
|
import time
|
||||||
from itertools import repeat
|
from itertools import repeat
|
||||||
|
|
||||||
|
from exceptiongroup import (
|
||||||
|
BaseExceptionGroup,
|
||||||
|
ExceptionGroup,
|
||||||
|
)
|
||||||
import pytest
|
import pytest
|
||||||
import trio
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
|
@ -15,6 +19,10 @@ import tractor
|
||||||
from conftest import tractor_test, no_windows
|
from conftest import tractor_test, no_windows
|
||||||
|
|
||||||
|
|
||||||
|
def is_win():
|
||||||
|
return platform.system() == 'Windows'
|
||||||
|
|
||||||
|
|
||||||
async def assert_err(delay=0):
|
async def assert_err(delay=0):
|
||||||
await trio.sleep(delay)
|
await trio.sleep(delay)
|
||||||
assert 0
|
assert 0
|
||||||
|
@ -52,29 +60,49 @@ def test_remote_error(arb_addr, args_err):
|
||||||
arbiter_addr=arb_addr,
|
arbiter_addr=arb_addr,
|
||||||
) as nursery:
|
) as nursery:
|
||||||
|
|
||||||
|
# on a remote type error caused by bad input args
|
||||||
|
# this should raise directly which means we **don't** get
|
||||||
|
# an exception group outside the nursery since the error
|
||||||
|
# here and the far end task error are one in the same?
|
||||||
portal = await nursery.run_in_actor(
|
portal = await nursery.run_in_actor(
|
||||||
assert_err, name='errorer', **args
|
assert_err, name='errorer', **args
|
||||||
)
|
)
|
||||||
|
|
||||||
# get result(s) from main task
|
# get result(s) from main task
|
||||||
try:
|
try:
|
||||||
|
# this means the root actor will also raise a local
|
||||||
|
# parent task error and thus an eg will propagate out
|
||||||
|
# of this actor nursery.
|
||||||
await portal.result()
|
await portal.result()
|
||||||
except tractor.RemoteActorError as err:
|
except tractor.RemoteActorError as err:
|
||||||
assert err.type == errtype
|
assert err.type == errtype
|
||||||
print("Look Maa that actor failed hard, hehh")
|
print("Look Maa that actor failed hard, hehh")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
with pytest.raises(tractor.RemoteActorError) as excinfo:
|
# ensure boxed errors
|
||||||
trio.run(main)
|
if args:
|
||||||
|
with pytest.raises(tractor.RemoteActorError) as excinfo:
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
# ensure boxed error is correct
|
assert excinfo.value.type == errtype
|
||||||
assert excinfo.value.type == errtype
|
|
||||||
|
else:
|
||||||
|
# the root task will also error on the `.result()` call
|
||||||
|
# so we expect an error from there AND the child.
|
||||||
|
with pytest.raises(BaseExceptionGroup) as excinfo:
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
# ensure boxed errors
|
||||||
|
for exc in excinfo.value.exceptions:
|
||||||
|
assert exc.type == errtype
|
||||||
|
|
||||||
|
|
||||||
def test_multierror(arb_addr):
|
def test_multierror(arb_addr):
|
||||||
"""Verify we raise a ``trio.MultiError`` out of a nursery where
|
'''
|
||||||
|
Verify we raise a ``BaseExceptionGroup`` out of a nursery where
|
||||||
more then one actor errors.
|
more then one actor errors.
|
||||||
"""
|
|
||||||
|
'''
|
||||||
async def main():
|
async def main():
|
||||||
async with tractor.open_nursery(
|
async with tractor.open_nursery(
|
||||||
arbiter_addr=arb_addr,
|
arbiter_addr=arb_addr,
|
||||||
|
@ -91,10 +119,10 @@ def test_multierror(arb_addr):
|
||||||
print("Look Maa that first actor failed hard, hehh")
|
print("Look Maa that first actor failed hard, hehh")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# here we should get a `trio.MultiError` containing exceptions
|
# here we should get a ``BaseExceptionGroup`` containing exceptions
|
||||||
# from both subactors
|
# from both subactors
|
||||||
|
|
||||||
with pytest.raises(trio.MultiError):
|
with pytest.raises(BaseExceptionGroup):
|
||||||
trio.run(main)
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
@ -103,7 +131,7 @@ def test_multierror(arb_addr):
|
||||||
'num_subactors', range(25, 26),
|
'num_subactors', range(25, 26),
|
||||||
)
|
)
|
||||||
def test_multierror_fast_nursery(arb_addr, start_method, num_subactors, delay):
|
def test_multierror_fast_nursery(arb_addr, start_method, num_subactors, delay):
|
||||||
"""Verify we raise a ``trio.MultiError`` out of a nursery where
|
"""Verify we raise a ``BaseExceptionGroup`` out of a nursery where
|
||||||
more then one actor errors and also with a delay before failure
|
more then one actor errors and also with a delay before failure
|
||||||
to test failure during an ongoing spawning.
|
to test failure during an ongoing spawning.
|
||||||
"""
|
"""
|
||||||
|
@ -119,16 +147,21 @@ def test_multierror_fast_nursery(arb_addr, start_method, num_subactors, delay):
|
||||||
delay=delay
|
delay=delay
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(trio.MultiError) as exc_info:
|
# with pytest.raises(trio.MultiError) as exc_info:
|
||||||
|
with pytest.raises(BaseExceptionGroup) as exc_info:
|
||||||
trio.run(main)
|
trio.run(main)
|
||||||
|
|
||||||
assert exc_info.type == tractor.MultiError
|
assert exc_info.type == ExceptionGroup
|
||||||
err = exc_info.value
|
err = exc_info.value
|
||||||
exceptions = err.exceptions
|
exceptions = err.exceptions
|
||||||
|
|
||||||
if len(exceptions) == 2:
|
if len(exceptions) == 2:
|
||||||
# sometimes oddly now there's an embedded BrokenResourceError ?
|
# sometimes oddly now there's an embedded BrokenResourceError ?
|
||||||
exceptions = exceptions[1].exceptions
|
for exc in exceptions:
|
||||||
|
excs = getattr(exc, 'exceptions', None)
|
||||||
|
if excs:
|
||||||
|
exceptions = excs
|
||||||
|
break
|
||||||
|
|
||||||
assert len(exceptions) == num_subactors
|
assert len(exceptions) == num_subactors
|
||||||
|
|
||||||
|
@ -206,8 +239,8 @@ async def test_cancel_infinite_streamer(start_method):
|
||||||
[
|
[
|
||||||
# daemon actors sit idle while single task actors error out
|
# daemon actors sit idle while single task actors error out
|
||||||
(1, tractor.RemoteActorError, AssertionError, (assert_err, {}), None),
|
(1, tractor.RemoteActorError, AssertionError, (assert_err, {}), None),
|
||||||
(2, tractor.MultiError, AssertionError, (assert_err, {}), None),
|
(2, BaseExceptionGroup, AssertionError, (assert_err, {}), None),
|
||||||
(3, tractor.MultiError, AssertionError, (assert_err, {}), None),
|
(3, BaseExceptionGroup, AssertionError, (assert_err, {}), None),
|
||||||
|
|
||||||
# 1 daemon actor errors out while single task actors sleep forever
|
# 1 daemon actor errors out while single task actors sleep forever
|
||||||
(3, tractor.RemoteActorError, AssertionError, (sleep_forever, {}),
|
(3, tractor.RemoteActorError, AssertionError, (sleep_forever, {}),
|
||||||
|
@ -218,7 +251,7 @@ async def test_cancel_infinite_streamer(start_method):
|
||||||
(do_nuthin, {}), (assert_err, {'delay': 1}, True)),
|
(do_nuthin, {}), (assert_err, {'delay': 1}, True)),
|
||||||
# daemon complete quickly delay while single task
|
# daemon complete quickly delay while single task
|
||||||
# actors error after brief delay
|
# actors error after brief delay
|
||||||
(3, tractor.MultiError, AssertionError,
|
(3, BaseExceptionGroup, AssertionError,
|
||||||
(assert_err, {'delay': 1}), (do_nuthin, {}, False)),
|
(assert_err, {'delay': 1}), (do_nuthin, {}, False)),
|
||||||
],
|
],
|
||||||
ids=[
|
ids=[
|
||||||
|
@ -285,7 +318,7 @@ async def test_some_cancels_all(num_actors_and_errs, start_method, loglevel):
|
||||||
# should error here with a ``RemoteActorError`` or ``MultiError``
|
# should error here with a ``RemoteActorError`` or ``MultiError``
|
||||||
|
|
||||||
except first_err as err:
|
except first_err as err:
|
||||||
if isinstance(err, tractor.MultiError):
|
if isinstance(err, BaseExceptionGroup):
|
||||||
assert len(err.exceptions) == num_actors
|
assert len(err.exceptions) == num_actors
|
||||||
for exc in err.exceptions:
|
for exc in err.exceptions:
|
||||||
if isinstance(exc, tractor.RemoteActorError):
|
if isinstance(exc, tractor.RemoteActorError):
|
||||||
|
@ -328,10 +361,12 @@ async def spawn_and_error(breadth, depth) -> None:
|
||||||
|
|
||||||
@tractor_test
|
@tractor_test
|
||||||
async def test_nested_multierrors(loglevel, start_method):
|
async def test_nested_multierrors(loglevel, start_method):
|
||||||
"""Test that failed actor sets are wrapped in `trio.MultiError`s.
|
'''
|
||||||
This test goes only 2 nurseries deep but we should eventually have tests
|
Test that failed actor sets are wrapped in `BaseExceptionGroup`s. This
|
||||||
|
test goes only 2 nurseries deep but we should eventually have tests
|
||||||
for arbitrary n-depth actor trees.
|
for arbitrary n-depth actor trees.
|
||||||
"""
|
|
||||||
|
'''
|
||||||
if start_method == 'trio':
|
if start_method == 'trio':
|
||||||
depth = 3
|
depth = 3
|
||||||
subactor_breadth = 2
|
subactor_breadth = 2
|
||||||
|
@ -355,12 +390,12 @@ async def test_nested_multierrors(loglevel, start_method):
|
||||||
breadth=subactor_breadth,
|
breadth=subactor_breadth,
|
||||||
depth=depth,
|
depth=depth,
|
||||||
)
|
)
|
||||||
except trio.MultiError as err:
|
except BaseExceptionGroup as err:
|
||||||
assert len(err.exceptions) == subactor_breadth
|
assert len(err.exceptions) == subactor_breadth
|
||||||
for subexc in err.exceptions:
|
for subexc in err.exceptions:
|
||||||
|
|
||||||
# verify first level actor errors are wrapped as remote
|
# verify first level actor errors are wrapped as remote
|
||||||
if platform.system() == 'Windows':
|
if is_win():
|
||||||
|
|
||||||
# windows is often too slow and cancellation seems
|
# windows is often too slow and cancellation seems
|
||||||
# to happen before an actor is spawned
|
# to happen before an actor is spawned
|
||||||
|
@ -373,10 +408,10 @@ async def test_nested_multierrors(loglevel, start_method):
|
||||||
assert subexc.type in (
|
assert subexc.type in (
|
||||||
tractor.RemoteActorError,
|
tractor.RemoteActorError,
|
||||||
trio.Cancelled,
|
trio.Cancelled,
|
||||||
trio.MultiError
|
BaseExceptionGroup,
|
||||||
)
|
)
|
||||||
|
|
||||||
elif isinstance(subexc, trio.MultiError):
|
elif isinstance(subexc, BaseExceptionGroup):
|
||||||
for subsub in subexc.exceptions:
|
for subsub in subexc.exceptions:
|
||||||
|
|
||||||
if subsub in (tractor.RemoteActorError,):
|
if subsub in (tractor.RemoteActorError,):
|
||||||
|
@ -384,7 +419,7 @@ async def test_nested_multierrors(loglevel, start_method):
|
||||||
|
|
||||||
assert type(subsub) in (
|
assert type(subsub) in (
|
||||||
trio.Cancelled,
|
trio.Cancelled,
|
||||||
trio.MultiError,
|
BaseExceptionGroup,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
assert isinstance(subexc, tractor.RemoteActorError)
|
assert isinstance(subexc, tractor.RemoteActorError)
|
||||||
|
@ -393,15 +428,21 @@ async def test_nested_multierrors(loglevel, start_method):
|
||||||
# XXX not sure what's up with this..
|
# XXX not sure what's up with this..
|
||||||
# on windows sometimes spawning is just too slow and
|
# on windows sometimes spawning is just too slow and
|
||||||
# we get back the (sent) cancel signal instead
|
# we get back the (sent) cancel signal instead
|
||||||
if platform.system() == 'Windows':
|
if is_win():
|
||||||
if isinstance(subexc, tractor.RemoteActorError):
|
if isinstance(subexc, tractor.RemoteActorError):
|
||||||
assert subexc.type in (trio.MultiError, tractor.RemoteActorError)
|
assert subexc.type in (
|
||||||
|
BaseExceptionGroup,
|
||||||
|
tractor.RemoteActorError
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
assert isinstance(subexc, trio.MultiError)
|
assert isinstance(subexc, BaseExceptionGroup)
|
||||||
else:
|
else:
|
||||||
assert subexc.type is trio.MultiError
|
assert subexc.type is ExceptionGroup
|
||||||
else:
|
else:
|
||||||
assert subexc.type in (tractor.RemoteActorError, trio.Cancelled)
|
assert subexc.type in (
|
||||||
|
tractor.RemoteActorError,
|
||||||
|
trio.Cancelled
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@no_windows
|
@no_windows
|
||||||
|
@ -419,7 +460,7 @@ def test_cancel_via_SIGINT(
|
||||||
with trio.fail_after(2):
|
with trio.fail_after(2):
|
||||||
async with tractor.open_nursery() as tn:
|
async with tractor.open_nursery() as tn:
|
||||||
await tn.start_actor('sucka')
|
await tn.start_actor('sucka')
|
||||||
if spawn_backend == 'mp':
|
if 'mp' in spawn_backend:
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
os.kill(pid, signal.SIGINT)
|
os.kill(pid, signal.SIGINT)
|
||||||
await trio.sleep_forever()
|
await trio.sleep_forever()
|
||||||
|
@ -439,6 +480,9 @@ def test_cancel_via_SIGINT_other_task(
|
||||||
from a seperate ``trio`` child task.
|
from a seperate ``trio`` child task.
|
||||||
"""
|
"""
|
||||||
pid = os.getpid()
|
pid = os.getpid()
|
||||||
|
timeout: float = 2
|
||||||
|
if is_win(): # smh
|
||||||
|
timeout += 1
|
||||||
|
|
||||||
async def spawn_and_sleep_forever(task_status=trio.TASK_STATUS_IGNORED):
|
async def spawn_and_sleep_forever(task_status=trio.TASK_STATUS_IGNORED):
|
||||||
async with tractor.open_nursery() as tn:
|
async with tractor.open_nursery() as tn:
|
||||||
|
@ -452,10 +496,10 @@ def test_cancel_via_SIGINT_other_task(
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
# should never timeout since SIGINT should cancel the current program
|
# should never timeout since SIGINT should cancel the current program
|
||||||
with trio.fail_after(2):
|
with trio.fail_after(timeout):
|
||||||
async with trio.open_nursery() as n:
|
async with trio.open_nursery() as n:
|
||||||
await n.start(spawn_and_sleep_forever)
|
await n.start(spawn_and_sleep_forever)
|
||||||
if spawn_backend == 'mp':
|
if 'mp' in spawn_backend:
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
os.kill(pid, signal.SIGINT)
|
os.kill(pid, signal.SIGINT)
|
||||||
|
|
||||||
|
@ -501,3 +545,57 @@ def test_cancel_while_childs_child_in_sync_sleep(
|
||||||
|
|
||||||
with pytest.raises(AssertionError):
|
with pytest.raises(AssertionError):
|
||||||
trio.run(main)
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fast_graceful_cancel_when_spawn_task_in_soft_proc_wait_for_daemon(
|
||||||
|
start_method,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
This is a very subtle test which demonstrates how cancellation
|
||||||
|
during process collection can result in non-optimal teardown
|
||||||
|
performance on daemon actors. The fix for this test was to handle
|
||||||
|
``trio.Cancelled`` specially in the spawn task waiting in
|
||||||
|
`proc.wait()` such that ``Portal.cancel_actor()`` is called before
|
||||||
|
executing the "hard reap" sequence (which has an up to 3 second
|
||||||
|
delay currently).
|
||||||
|
|
||||||
|
In other words, if we can cancel the actor using a graceful remote
|
||||||
|
cancellation, and it's faster, we might as well do it.
|
||||||
|
|
||||||
|
'''
|
||||||
|
kbi_delay = 0.5
|
||||||
|
timeout: float = 2.9
|
||||||
|
|
||||||
|
if is_win(): # smh
|
||||||
|
timeout += 1
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
start = time.time()
|
||||||
|
try:
|
||||||
|
async with trio.open_nursery() as nurse:
|
||||||
|
async with tractor.open_nursery() as tn:
|
||||||
|
p = await tn.start_actor(
|
||||||
|
'fast_boi',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delayed_kbi():
|
||||||
|
await trio.sleep(kbi_delay)
|
||||||
|
print(f'RAISING KBI after {kbi_delay} s')
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
# start task which raises a kbi **after**
|
||||||
|
# the actor nursery ``__aexit__()`` has
|
||||||
|
# been run.
|
||||||
|
nurse.start_soon(delayed_kbi)
|
||||||
|
|
||||||
|
await p.run(do_nuthin)
|
||||||
|
finally:
|
||||||
|
duration = time.time() - start
|
||||||
|
if duration > timeout:
|
||||||
|
raise trio.TooSlowError(
|
||||||
|
'daemon cancel was slower then necessary..'
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(KeyboardInterrupt):
|
||||||
|
trio.run(main)
|
||||||
|
|
|
@ -0,0 +1,173 @@
|
||||||
|
'''
|
||||||
|
Test a service style daemon that maintains a nursery for spawning
|
||||||
|
"remote async tasks" including both spawning other long living
|
||||||
|
sub-sub-actor daemons.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from typing import Optional
|
||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager as acm
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import trio
|
||||||
|
from trio_typing import TaskStatus
|
||||||
|
import tractor
|
||||||
|
from tractor import RemoteActorError
|
||||||
|
from async_generator import aclosing
|
||||||
|
|
||||||
|
|
||||||
|
async def aio_streamer(
|
||||||
|
from_trio: asyncio.Queue,
|
||||||
|
to_trio: trio.abc.SendChannel,
|
||||||
|
) -> trio.abc.ReceiveChannel:
|
||||||
|
|
||||||
|
# required first msg to sync caller
|
||||||
|
to_trio.send_nowait(None)
|
||||||
|
|
||||||
|
from itertools import cycle
|
||||||
|
for i in cycle(range(10)):
|
||||||
|
to_trio.send_nowait(i)
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
|
async def trio_streamer():
|
||||||
|
from itertools import cycle
|
||||||
|
for i in cycle(range(10)):
|
||||||
|
yield i
|
||||||
|
await trio.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
|
async def trio_sleep_and_err(delay: float = 0.5):
|
||||||
|
await trio.sleep(delay)
|
||||||
|
# name error
|
||||||
|
doggy() # noqa
|
||||||
|
|
||||||
|
|
||||||
|
_cached_stream: Optional[
|
||||||
|
trio.abc.ReceiveChannel
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def wrapper_mngr(
|
||||||
|
):
|
||||||
|
from tractor.trionics import broadcast_receiver
|
||||||
|
global _cached_stream
|
||||||
|
in_aio = tractor.current_actor().is_infected_aio()
|
||||||
|
|
||||||
|
if in_aio:
|
||||||
|
if _cached_stream:
|
||||||
|
|
||||||
|
from_aio = _cached_stream
|
||||||
|
|
||||||
|
# if we already have a cached feed deliver a rx side clone
|
||||||
|
# to consumer
|
||||||
|
async with broadcast_receiver(from_aio, 6) as from_aio:
|
||||||
|
yield from_aio
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
async with tractor.to_asyncio.open_channel_from(
|
||||||
|
aio_streamer,
|
||||||
|
) as (first, from_aio):
|
||||||
|
assert not first
|
||||||
|
|
||||||
|
# cache it so next task uses broadcast receiver
|
||||||
|
_cached_stream = from_aio
|
||||||
|
|
||||||
|
yield from_aio
|
||||||
|
else:
|
||||||
|
async with aclosing(trio_streamer()) as stream:
|
||||||
|
# cache it so next task uses broadcast receiver
|
||||||
|
_cached_stream = stream
|
||||||
|
yield stream
|
||||||
|
|
||||||
|
|
||||||
|
_nursery: trio.Nursery = None
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def trio_main(
|
||||||
|
ctx: tractor.Context,
|
||||||
|
):
|
||||||
|
# sync
|
||||||
|
await ctx.started()
|
||||||
|
|
||||||
|
# stash a "service nursery" as "actor local" (aka a Python global)
|
||||||
|
global _nursery
|
||||||
|
n = _nursery
|
||||||
|
assert n
|
||||||
|
|
||||||
|
async def consume_stream():
|
||||||
|
async with wrapper_mngr() as stream:
|
||||||
|
async for msg in stream:
|
||||||
|
print(msg)
|
||||||
|
|
||||||
|
# run 2 tasks to ensure broadcaster chan use
|
||||||
|
n.start_soon(consume_stream)
|
||||||
|
n.start_soon(consume_stream)
|
||||||
|
|
||||||
|
n.start_soon(trio_sleep_and_err)
|
||||||
|
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def open_actor_local_nursery(
|
||||||
|
ctx: tractor.Context,
|
||||||
|
):
|
||||||
|
global _nursery
|
||||||
|
async with trio.open_nursery() as n:
|
||||||
|
_nursery = n
|
||||||
|
await ctx.started()
|
||||||
|
await trio.sleep(10)
|
||||||
|
# await trio.sleep(1)
|
||||||
|
|
||||||
|
# XXX: this causes the hang since
|
||||||
|
# the caller does not unblock from its own
|
||||||
|
# ``trio.sleep_forever()``.
|
||||||
|
|
||||||
|
# TODO: we need to test a simple ctx task starting remote tasks
|
||||||
|
# that error and then blocking on a ``Nursery.start()`` which
|
||||||
|
# never yields back.. aka a scenario where the
|
||||||
|
# ``tractor.context`` task IS NOT in the service n's cancel
|
||||||
|
# scope.
|
||||||
|
n.cancel_scope.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'asyncio_mode',
|
||||||
|
[True, False],
|
||||||
|
ids='asyncio_mode={}'.format,
|
||||||
|
)
|
||||||
|
def test_actor_managed_trio_nursery_task_error_cancels_aio(
|
||||||
|
asyncio_mode: bool,
|
||||||
|
arb_addr
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Verify that a ``trio`` nursery created managed in a child actor
|
||||||
|
correctly relays errors to the parent actor when one of its spawned
|
||||||
|
tasks errors even when running in infected asyncio mode and using
|
||||||
|
broadcast receivers for multi-task-per-actor subscription.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
# cancel the nursery shortly after boot
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
p = await n.start_actor(
|
||||||
|
'nursery_mngr',
|
||||||
|
infect_asyncio=asyncio_mode,
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
async with (
|
||||||
|
p.open_context(open_actor_local_nursery) as (ctx, first),
|
||||||
|
p.open_context(trio_main) as (ctx, first),
|
||||||
|
):
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
with pytest.raises(RemoteActorError) as excinfo:
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
# verify boxed error
|
||||||
|
err = excinfo.value
|
||||||
|
assert isinstance(err.type(), NameError)
|
|
@ -1,9 +1,10 @@
|
||||||
import itertools
|
import itertools
|
||||||
|
|
||||||
|
import pytest
|
||||||
import trio
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
from tractor import open_actor_cluster
|
from tractor import open_actor_cluster
|
||||||
from tractor.trionics import async_enter_all
|
from tractor.trionics import gather_contexts
|
||||||
|
|
||||||
from conftest import tractor_test
|
from conftest import tractor_test
|
||||||
|
|
||||||
|
@ -11,26 +12,72 @@ from conftest import tractor_test
|
||||||
MESSAGE = 'tractoring at full speed'
|
MESSAGE = 'tractoring at full speed'
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_mngrs_input_raises() -> None:
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
with trio.fail_after(1):
|
||||||
|
async with (
|
||||||
|
open_actor_cluster(
|
||||||
|
modules=[__name__],
|
||||||
|
|
||||||
|
# NOTE: ensure we can passthrough runtime opts
|
||||||
|
loglevel='info',
|
||||||
|
# debug_mode=True,
|
||||||
|
|
||||||
|
) as portals,
|
||||||
|
|
||||||
|
gather_contexts(
|
||||||
|
# NOTE: it's the use of inline-generator syntax
|
||||||
|
# here that causes the empty input.
|
||||||
|
mngrs=(
|
||||||
|
p.open_context(worker) for p in portals.values()
|
||||||
|
),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
assert 0
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
@tractor.context
|
@tractor.context
|
||||||
async def worker(ctx: tractor.Context) -> None:
|
async def worker(
|
||||||
|
ctx: tractor.Context,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
|
||||||
await ctx.started()
|
await ctx.started()
|
||||||
async with ctx.open_stream() as stream:
|
|
||||||
|
async with ctx.open_stream(
|
||||||
|
backpressure=True,
|
||||||
|
) as stream:
|
||||||
|
|
||||||
|
# TODO: this with the below assert causes a hang bug?
|
||||||
|
# with trio.move_on_after(1):
|
||||||
|
|
||||||
async for msg in stream:
|
async for msg in stream:
|
||||||
# do something with msg
|
# do something with msg
|
||||||
print(msg)
|
print(msg)
|
||||||
assert msg == MESSAGE
|
assert msg == MESSAGE
|
||||||
|
|
||||||
|
# TODO: does this ever cause a hang
|
||||||
|
# assert 0
|
||||||
|
|
||||||
|
|
||||||
@tractor_test
|
@tractor_test
|
||||||
async def test_streaming_to_actor_cluster() -> None:
|
async def test_streaming_to_actor_cluster() -> None:
|
||||||
|
|
||||||
async with (
|
async with (
|
||||||
open_actor_cluster(modules=[__name__]) as portals,
|
open_actor_cluster(modules=[__name__]) as portals,
|
||||||
async_enter_all(
|
|
||||||
|
gather_contexts(
|
||||||
mngrs=[p.open_context(worker) for p in portals.values()],
|
mngrs=[p.open_context(worker) for p in portals.values()],
|
||||||
) as contexts,
|
) as contexts,
|
||||||
async_enter_all(
|
|
||||||
|
gather_contexts(
|
||||||
mngrs=[ctx[0].open_stream() for ctx in contexts],
|
mngrs=[ctx[0].open_stream() for ctx in contexts],
|
||||||
) as streams,
|
) as streams,
|
||||||
|
|
||||||
):
|
):
|
||||||
with trio.move_on_after(1):
|
with trio.move_on_after(1):
|
||||||
for stream in itertools.cycle(streams):
|
for stream in itertools.cycle(streams):
|
||||||
|
|
|
@ -0,0 +1,798 @@
|
||||||
|
'''
|
||||||
|
``async with ():`` inlined context-stream cancellation testing.
|
||||||
|
|
||||||
|
Verify the we raise errors when streams are opened prior to sync-opening
|
||||||
|
a ``tractor.Context`` beforehand.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from contextlib import asynccontextmanager as acm
|
||||||
|
from itertools import count
|
||||||
|
import platform
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import trio
|
||||||
|
import tractor
|
||||||
|
from tractor._exceptions import StreamOverrun
|
||||||
|
|
||||||
|
from conftest import tractor_test
|
||||||
|
|
||||||
|
# ``Context`` semantics are as follows,
|
||||||
|
# ------------------------------------
|
||||||
|
|
||||||
|
# - standard setup/teardown:
|
||||||
|
# ``Portal.open_context()`` starts a new
|
||||||
|
# remote task context in another actor. The target actor's task must
|
||||||
|
# call ``Context.started()`` to unblock this entry on the caller side.
|
||||||
|
# the callee task executes until complete and returns a final value
|
||||||
|
# which is delivered to the caller side and retreived via
|
||||||
|
# ``Context.result()``.
|
||||||
|
|
||||||
|
# - cancel termination:
|
||||||
|
# context can be cancelled on either side where either end's task can
|
||||||
|
# call ``Context.cancel()`` which raises a local ``trio.Cancelled``
|
||||||
|
# and sends a task cancel request to the remote task which in turn
|
||||||
|
# raises a ``trio.Cancelled`` in that scope, catches it, and re-raises
|
||||||
|
# as ``ContextCancelled``. This is then caught by
|
||||||
|
# ``Portal.open_context()``'s exit and we get a graceful termination
|
||||||
|
# of the linked tasks.
|
||||||
|
|
||||||
|
# - error termination:
|
||||||
|
# error is caught after all context-cancel-scope tasks are cancelled
|
||||||
|
# via regular ``trio`` cancel scope semantics, error is sent to other
|
||||||
|
# side and unpacked as a `RemoteActorError`.
|
||||||
|
|
||||||
|
|
||||||
|
# ``Context.open_stream() as stream: MsgStream:`` msg semantics are:
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
|
||||||
|
# - either side can ``.send()`` which emits a 'yield' msgs and delivers
|
||||||
|
# a value to the a ``MsgStream.receive()`` call.
|
||||||
|
|
||||||
|
# - stream closure: one end relays a 'stop' message which terminates an
|
||||||
|
# ongoing ``MsgStream`` iteration.
|
||||||
|
|
||||||
|
# - cancel/error termination: as per the context semantics above but
|
||||||
|
# with implicit stream closure on the cancelling end.
|
||||||
|
|
||||||
|
|
||||||
|
_state: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def too_many_starteds(
|
||||||
|
ctx: tractor.Context,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Call ``Context.started()`` more then once (an error).
|
||||||
|
|
||||||
|
'''
|
||||||
|
await ctx.started()
|
||||||
|
try:
|
||||||
|
await ctx.started()
|
||||||
|
except RuntimeError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def not_started_but_stream_opened(
|
||||||
|
ctx: tractor.Context,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Enter ``Context.open_stream()`` without calling ``.started()``.
|
||||||
|
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
async with ctx.open_stream():
|
||||||
|
assert 0
|
||||||
|
except RuntimeError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'target',
|
||||||
|
[too_many_starteds, not_started_but_stream_opened],
|
||||||
|
ids='misuse_type={}'.format,
|
||||||
|
)
|
||||||
|
def test_started_misuse(target):
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
portal = await n.start_actor(
|
||||||
|
target.__name__,
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
|
||||||
|
async with portal.open_context(target) as (ctx, sent):
|
||||||
|
await trio.sleep(1)
|
||||||
|
|
||||||
|
with pytest.raises(tractor.RemoteActorError):
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def simple_setup_teardown(
|
||||||
|
|
||||||
|
ctx: tractor.Context,
|
||||||
|
data: int,
|
||||||
|
block_forever: bool = False,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
# startup phase
|
||||||
|
global _state
|
||||||
|
_state = True
|
||||||
|
|
||||||
|
# signal to parent that we're up
|
||||||
|
await ctx.started(data + 1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if block_forever:
|
||||||
|
# block until cancelled
|
||||||
|
await trio.sleep_forever()
|
||||||
|
else:
|
||||||
|
return 'yo'
|
||||||
|
finally:
|
||||||
|
_state = False
|
||||||
|
|
||||||
|
|
||||||
|
async def assert_state(value: bool):
|
||||||
|
global _state
|
||||||
|
assert _state == value
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'error_parent',
|
||||||
|
[False, ValueError, KeyboardInterrupt],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'callee_blocks_forever',
|
||||||
|
[False, True],
|
||||||
|
ids=lambda item: f'callee_blocks_forever={item}'
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'pointlessly_open_stream',
|
||||||
|
[False, True],
|
||||||
|
ids=lambda item: f'open_stream={item}'
|
||||||
|
)
|
||||||
|
def test_simple_context(
|
||||||
|
error_parent,
|
||||||
|
callee_blocks_forever,
|
||||||
|
pointlessly_open_stream,
|
||||||
|
):
|
||||||
|
|
||||||
|
timeout = 1.5 if not platform.system() == 'Windows' else 4
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
with trio.fail_after(timeout):
|
||||||
|
async with tractor.open_nursery() as nursery:
|
||||||
|
|
||||||
|
portal = await nursery.start_actor(
|
||||||
|
'simple_context',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with portal.open_context(
|
||||||
|
simple_setup_teardown,
|
||||||
|
data=10,
|
||||||
|
block_forever=callee_blocks_forever,
|
||||||
|
) as (ctx, sent):
|
||||||
|
|
||||||
|
assert sent == 11
|
||||||
|
|
||||||
|
if callee_blocks_forever:
|
||||||
|
await portal.run(assert_state, value=True)
|
||||||
|
else:
|
||||||
|
assert await ctx.result() == 'yo'
|
||||||
|
|
||||||
|
if not error_parent:
|
||||||
|
await ctx.cancel()
|
||||||
|
|
||||||
|
if pointlessly_open_stream:
|
||||||
|
async with ctx.open_stream():
|
||||||
|
if error_parent:
|
||||||
|
raise error_parent
|
||||||
|
|
||||||
|
if callee_blocks_forever:
|
||||||
|
await ctx.cancel()
|
||||||
|
else:
|
||||||
|
# in this case the stream will send a
|
||||||
|
# 'stop' msg to the far end which needs
|
||||||
|
# to be ignored
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
if error_parent:
|
||||||
|
raise error_parent
|
||||||
|
|
||||||
|
finally:
|
||||||
|
|
||||||
|
# after cancellation
|
||||||
|
if not error_parent:
|
||||||
|
await portal.run(assert_state, value=False)
|
||||||
|
|
||||||
|
# shut down daemon
|
||||||
|
await portal.cancel_actor()
|
||||||
|
|
||||||
|
if error_parent:
|
||||||
|
try:
|
||||||
|
trio.run(main)
|
||||||
|
except error_parent:
|
||||||
|
pass
|
||||||
|
except trio.MultiError as me:
|
||||||
|
# XXX: on windows it seems we may have to expect the group error
|
||||||
|
from tractor._exceptions import is_multi_cancelled
|
||||||
|
assert is_multi_cancelled(me)
|
||||||
|
else:
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
# basic stream terminations:
|
||||||
|
# - callee context closes without using stream
|
||||||
|
# - caller context closes without using stream
|
||||||
|
# - caller context calls `Context.cancel()` while streaming
|
||||||
|
# is ongoing resulting in callee being cancelled
|
||||||
|
# - callee calls `Context.cancel()` while streaming and caller
|
||||||
|
# sees stream terminated in `RemoteActorError`
|
||||||
|
|
||||||
|
# TODO: future possible features
|
||||||
|
# - restart request: far end raises `ContextRestart`
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def close_ctx_immediately(
|
||||||
|
|
||||||
|
ctx: tractor.Context,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
await ctx.started()
|
||||||
|
global _state
|
||||||
|
|
||||||
|
async with ctx.open_stream():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@tractor_test
|
||||||
|
async def test_callee_closes_ctx_after_stream_open():
|
||||||
|
'callee context closes without using stream'
|
||||||
|
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
|
||||||
|
portal = await n.start_actor(
|
||||||
|
'fast_stream_closer',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
|
||||||
|
with trio.fail_after(2):
|
||||||
|
async with portal.open_context(
|
||||||
|
close_ctx_immediately,
|
||||||
|
|
||||||
|
# flag to avoid waiting the final result
|
||||||
|
# cancel_on_exit=True,
|
||||||
|
|
||||||
|
) as (ctx, sent):
|
||||||
|
|
||||||
|
assert sent is None
|
||||||
|
|
||||||
|
with trio.fail_after(0.5):
|
||||||
|
async with ctx.open_stream() as stream:
|
||||||
|
|
||||||
|
# should fall through since ``StopAsyncIteration``
|
||||||
|
# should be raised through translation of
|
||||||
|
# a ``trio.EndOfChannel`` by
|
||||||
|
# ``trio.abc.ReceiveChannel.__anext__()``
|
||||||
|
async for _ in stream:
|
||||||
|
assert 0
|
||||||
|
else:
|
||||||
|
|
||||||
|
# verify stream is now closed
|
||||||
|
try:
|
||||||
|
await stream.receive()
|
||||||
|
except trio.EndOfChannel:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# TODO: should be just raise the closed resource err
|
||||||
|
# directly here to enforce not allowing a re-open
|
||||||
|
# of a stream to the context (at least until a time of
|
||||||
|
# if/when we decide that's a good idea?)
|
||||||
|
try:
|
||||||
|
with trio.fail_after(0.5):
|
||||||
|
async with ctx.open_stream() as stream:
|
||||||
|
pass
|
||||||
|
except trio.ClosedResourceError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await portal.cancel_actor()
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def expect_cancelled(
|
||||||
|
|
||||||
|
ctx: tractor.Context,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
global _state
|
||||||
|
_state = True
|
||||||
|
|
||||||
|
await ctx.started()
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with ctx.open_stream() as stream:
|
||||||
|
async for msg in stream:
|
||||||
|
await stream.send(msg) # echo server
|
||||||
|
|
||||||
|
except trio.Cancelled:
|
||||||
|
# expected case
|
||||||
|
_state = False
|
||||||
|
raise
|
||||||
|
|
||||||
|
else:
|
||||||
|
assert 0, "Wasn't cancelled!?"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'use_ctx_cancel_method',
|
||||||
|
[False, True],
|
||||||
|
)
|
||||||
|
@tractor_test
|
||||||
|
async def test_caller_closes_ctx_after_callee_opens_stream(
|
||||||
|
use_ctx_cancel_method: bool,
|
||||||
|
):
|
||||||
|
'caller context closes without using stream'
|
||||||
|
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
|
||||||
|
portal = await n.start_actor(
|
||||||
|
'ctx_cancelled',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
|
||||||
|
async with portal.open_context(
|
||||||
|
expect_cancelled,
|
||||||
|
) as (ctx, sent):
|
||||||
|
await portal.run(assert_state, value=True)
|
||||||
|
|
||||||
|
assert sent is None
|
||||||
|
|
||||||
|
# call cancel explicitly
|
||||||
|
if use_ctx_cancel_method:
|
||||||
|
|
||||||
|
await ctx.cancel()
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with ctx.open_stream() as stream:
|
||||||
|
async for msg in stream:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except tractor.ContextCancelled:
|
||||||
|
raise # XXX: must be propagated to __aexit__
|
||||||
|
|
||||||
|
else:
|
||||||
|
assert 0, "Should have context cancelled?"
|
||||||
|
|
||||||
|
# channel should still be up
|
||||||
|
assert portal.channel.connected()
|
||||||
|
|
||||||
|
# ctx is closed here
|
||||||
|
await portal.run(assert_state, value=False)
|
||||||
|
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
with trio.fail_after(0.2):
|
||||||
|
await ctx.result()
|
||||||
|
assert 0, "Callee should have blocked!?"
|
||||||
|
except trio.TooSlowError:
|
||||||
|
await ctx.cancel()
|
||||||
|
try:
|
||||||
|
async with ctx.open_stream() as stream:
|
||||||
|
async for msg in stream:
|
||||||
|
pass
|
||||||
|
except tractor.ContextCancelled:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
assert 0, "Should have received closed resource error?"
|
||||||
|
|
||||||
|
# ctx is closed here
|
||||||
|
await portal.run(assert_state, value=False)
|
||||||
|
|
||||||
|
# channel should not have been destroyed yet, only the
|
||||||
|
# inter-actor-task context
|
||||||
|
assert portal.channel.connected()
|
||||||
|
|
||||||
|
# teardown the actor
|
||||||
|
await portal.cancel_actor()
|
||||||
|
|
||||||
|
|
||||||
|
@tractor_test
|
||||||
|
async def test_multitask_caller_cancels_from_nonroot_task():
|
||||||
|
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
|
||||||
|
portal = await n.start_actor(
|
||||||
|
'ctx_cancelled',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
|
||||||
|
async with portal.open_context(
|
||||||
|
expect_cancelled,
|
||||||
|
) as (ctx, sent):
|
||||||
|
|
||||||
|
await portal.run(assert_state, value=True)
|
||||||
|
assert sent is None
|
||||||
|
|
||||||
|
async with ctx.open_stream() as stream:
|
||||||
|
|
||||||
|
async def send_msg_then_cancel():
|
||||||
|
await stream.send('yo')
|
||||||
|
await portal.run(assert_state, value=True)
|
||||||
|
await ctx.cancel()
|
||||||
|
await portal.run(assert_state, value=False)
|
||||||
|
|
||||||
|
async with trio.open_nursery() as n:
|
||||||
|
n.start_soon(send_msg_then_cancel)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for msg in stream:
|
||||||
|
assert msg == 'yo'
|
||||||
|
|
||||||
|
except tractor.ContextCancelled:
|
||||||
|
raise # XXX: must be propagated to __aexit__
|
||||||
|
|
||||||
|
# channel should still be up
|
||||||
|
assert portal.channel.connected()
|
||||||
|
|
||||||
|
# ctx is closed here
|
||||||
|
await portal.run(assert_state, value=False)
|
||||||
|
|
||||||
|
# channel should not have been destroyed yet, only the
|
||||||
|
# inter-actor-task context
|
||||||
|
assert portal.channel.connected()
|
||||||
|
|
||||||
|
# teardown the actor
|
||||||
|
await portal.cancel_actor()
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def cancel_self(
|
||||||
|
|
||||||
|
ctx: tractor.Context,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
global _state
|
||||||
|
_state = True
|
||||||
|
|
||||||
|
await ctx.cancel()
|
||||||
|
|
||||||
|
# should inline raise immediately
|
||||||
|
try:
|
||||||
|
async with ctx.open_stream():
|
||||||
|
pass
|
||||||
|
except tractor.ContextCancelled:
|
||||||
|
# suppress for now so we can do checkpoint tests below
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise RuntimeError('Context didnt cancel itself?!')
|
||||||
|
|
||||||
|
# check a real ``trio.Cancelled`` is raised on a checkpoint
|
||||||
|
try:
|
||||||
|
with trio.fail_after(0.1):
|
||||||
|
await trio.sleep_forever()
|
||||||
|
except trio.Cancelled:
|
||||||
|
raise
|
||||||
|
|
||||||
|
except trio.TooSlowError:
|
||||||
|
# should never get here
|
||||||
|
assert 0
|
||||||
|
|
||||||
|
|
||||||
|
@tractor_test
|
||||||
|
async def test_callee_cancels_before_started():
|
||||||
|
'''
|
||||||
|
Callee calls `Context.cancel()` while streaming and caller
|
||||||
|
sees stream terminated in `ContextCancelled`.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
|
||||||
|
portal = await n.start_actor(
|
||||||
|
'cancels_self',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
|
||||||
|
async with portal.open_context(
|
||||||
|
cancel_self,
|
||||||
|
) as (ctx, sent):
|
||||||
|
async with ctx.open_stream():
|
||||||
|
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
# raises a special cancel signal
|
||||||
|
except tractor.ContextCancelled as ce:
|
||||||
|
ce.type == trio.Cancelled
|
||||||
|
|
||||||
|
# the traceback should be informative
|
||||||
|
assert 'cancelled itself' in ce.msgdata['tb_str']
|
||||||
|
|
||||||
|
# teardown the actor
|
||||||
|
await portal.cancel_actor()
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def never_open_stream(
|
||||||
|
|
||||||
|
ctx: tractor.Context,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Context which never opens a stream and blocks.
|
||||||
|
|
||||||
|
'''
|
||||||
|
await ctx.started()
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def keep_sending_from_callee(
|
||||||
|
|
||||||
|
ctx: tractor.Context,
|
||||||
|
msg_buffer_size: Optional[int] = None,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Send endlessly on the calleee stream.
|
||||||
|
|
||||||
|
'''
|
||||||
|
await ctx.started()
|
||||||
|
async with ctx.open_stream(
|
||||||
|
msg_buffer_size=msg_buffer_size,
|
||||||
|
) as stream:
|
||||||
|
for msg in count():
|
||||||
|
print(f'callee sending {msg}')
|
||||||
|
await stream.send(msg)
|
||||||
|
await trio.sleep(0.01)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'overrun_by',
|
||||||
|
[
|
||||||
|
('caller', 1, never_open_stream),
|
||||||
|
('cancel_caller_during_overrun', 1, never_open_stream),
|
||||||
|
('callee', 0, keep_sending_from_callee),
|
||||||
|
],
|
||||||
|
ids='overrun_condition={}'.format,
|
||||||
|
)
|
||||||
|
def test_one_end_stream_not_opened(overrun_by):
|
||||||
|
'''
|
||||||
|
This should exemplify the bug from:
|
||||||
|
https://github.com/goodboy/tractor/issues/265
|
||||||
|
|
||||||
|
'''
|
||||||
|
overrunner, buf_size_increase, entrypoint = overrun_by
|
||||||
|
from tractor._runtime import Actor
|
||||||
|
buf_size = buf_size_increase + Actor.msg_buffer_size
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
portal = await n.start_actor(
|
||||||
|
entrypoint.__name__,
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
|
||||||
|
async with portal.open_context(
|
||||||
|
entrypoint,
|
||||||
|
) as (ctx, sent):
|
||||||
|
assert sent is None
|
||||||
|
|
||||||
|
if 'caller' in overrunner:
|
||||||
|
|
||||||
|
async with ctx.open_stream() as stream:
|
||||||
|
for i in range(buf_size):
|
||||||
|
print(f'sending {i}')
|
||||||
|
await stream.send(i)
|
||||||
|
|
||||||
|
if 'cancel' in overrunner:
|
||||||
|
# without this we block waiting on the child side
|
||||||
|
await ctx.cancel()
|
||||||
|
|
||||||
|
else:
|
||||||
|
# expect overrun error to be relayed back
|
||||||
|
# and this sleep interrupted
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
else:
|
||||||
|
# callee overruns caller case so we do nothing here
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
await portal.cancel_actor()
|
||||||
|
|
||||||
|
# 2 overrun cases and the no overrun case (which pushes right up to
|
||||||
|
# the msg limit)
|
||||||
|
if overrunner == 'caller' or 'cance' in overrunner:
|
||||||
|
with pytest.raises(tractor.RemoteActorError) as excinfo:
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
assert excinfo.value.type == StreamOverrun
|
||||||
|
|
||||||
|
elif overrunner == 'callee':
|
||||||
|
with pytest.raises(tractor.RemoteActorError) as excinfo:
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
# TODO: embedded remote errors so that we can verify the source
|
||||||
|
# error? the callee delivers an error which is an overrun
|
||||||
|
# wrapped in a remote actor error.
|
||||||
|
assert excinfo.value.type == tractor.RemoteActorError
|
||||||
|
|
||||||
|
else:
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def echo_back_sequence(
|
||||||
|
|
||||||
|
ctx: tractor.Context,
|
||||||
|
seq: list[int],
|
||||||
|
msg_buffer_size: Optional[int] = None,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Send endlessly on the calleee stream.
|
||||||
|
|
||||||
|
'''
|
||||||
|
await ctx.started()
|
||||||
|
async with ctx.open_stream(
|
||||||
|
msg_buffer_size=msg_buffer_size,
|
||||||
|
) as stream:
|
||||||
|
|
||||||
|
seq = list(seq) # bleh, `msgpack`...
|
||||||
|
count = 0
|
||||||
|
while count < 3:
|
||||||
|
batch = []
|
||||||
|
async for msg in stream:
|
||||||
|
batch.append(msg)
|
||||||
|
if batch == seq:
|
||||||
|
break
|
||||||
|
|
||||||
|
for msg in batch:
|
||||||
|
print(f'callee sending {msg}')
|
||||||
|
await stream.send(msg)
|
||||||
|
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
return 'yo'
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_backpressure():
|
||||||
|
'''
|
||||||
|
Demonstrate small overruns of each task back and forth
|
||||||
|
on a stream not raising any errors by default.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main():
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
portal = await n.start_actor(
|
||||||
|
'callee_sends_forever',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
seq = list(range(3))
|
||||||
|
async with portal.open_context(
|
||||||
|
echo_back_sequence,
|
||||||
|
seq=seq,
|
||||||
|
msg_buffer_size=1,
|
||||||
|
) as (ctx, sent):
|
||||||
|
assert sent is None
|
||||||
|
|
||||||
|
async with ctx.open_stream(msg_buffer_size=1) as stream:
|
||||||
|
count = 0
|
||||||
|
while count < 3:
|
||||||
|
for msg in seq:
|
||||||
|
print(f'caller sending {msg}')
|
||||||
|
await stream.send(msg)
|
||||||
|
await trio.sleep(0.1)
|
||||||
|
|
||||||
|
batch = []
|
||||||
|
async for msg in stream:
|
||||||
|
batch.append(msg)
|
||||||
|
if batch == seq:
|
||||||
|
break
|
||||||
|
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
# here the context should return
|
||||||
|
assert await ctx.result() == 'yo'
|
||||||
|
|
||||||
|
# cancel the daemon
|
||||||
|
await portal.cancel_actor()
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def sleep_forever(
|
||||||
|
ctx: tractor.Context,
|
||||||
|
) -> None:
|
||||||
|
await ctx.started()
|
||||||
|
async with ctx.open_stream():
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def attach_to_sleep_forever():
|
||||||
|
'''
|
||||||
|
Cancel a context **before** any underlying error is raised in order
|
||||||
|
to trigger a local reception of a ``ContextCancelled`` which **should not**
|
||||||
|
be re-raised in the local surrounding ``Context`` *iff* the cancel was
|
||||||
|
requested by **this** side of the context.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async with tractor.wait_for_actor('sleeper') as p2:
|
||||||
|
async with (
|
||||||
|
p2.open_context(sleep_forever) as (peer_ctx, first),
|
||||||
|
peer_ctx.open_stream(),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
# XXX: previously this would trigger local
|
||||||
|
# ``ContextCancelled`` to be received and raised in the
|
||||||
|
# local context overriding any local error due to
|
||||||
|
# logic inside ``_invoke()`` which checked for
|
||||||
|
# an error set on ``Context._error`` and raised it in
|
||||||
|
# under a cancellation scenario.
|
||||||
|
|
||||||
|
# The problem is you can have a remote cancellation
|
||||||
|
# that is part of a local error and we shouldn't raise
|
||||||
|
# ``ContextCancelled`` **iff** we weren't the side of
|
||||||
|
# the context to initiate it, i.e.
|
||||||
|
# ``Context._cancel_called`` should **NOT** have been
|
||||||
|
# set. The special logic to handle this case is now
|
||||||
|
# inside ``Context._may_raise_from_remote_msg()`` XD
|
||||||
|
await peer_ctx.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def error_before_started(
|
||||||
|
ctx: tractor.Context,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
This simulates exactly an original bug discovered in:
|
||||||
|
https://github.com/pikers/piker/issues/244
|
||||||
|
|
||||||
|
'''
|
||||||
|
async with attach_to_sleep_forever():
|
||||||
|
# send an unserializable type which should raise a type error
|
||||||
|
# here and **NOT BE SWALLOWED** by the surrounding acm!!?!
|
||||||
|
await ctx.started(object())
|
||||||
|
|
||||||
|
|
||||||
|
def test_do_not_swallow_error_before_started_by_remote_contextcancelled():
|
||||||
|
'''
|
||||||
|
Verify that an error raised in a remote context which itself opens another
|
||||||
|
remote context, which it cancels, does not ovverride the original error that
|
||||||
|
caused the cancellation of the secondardy context.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main():
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
portal = await n.start_actor(
|
||||||
|
'errorer',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
await n.start_actor(
|
||||||
|
'sleeper',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
)
|
||||||
|
|
||||||
|
async with (
|
||||||
|
portal.open_context(
|
||||||
|
error_before_started
|
||||||
|
) as (ctx, sent),
|
||||||
|
):
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
with pytest.raises(tractor.RemoteActorError) as excinfo:
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
assert excinfo.value.type == TypeError
|
File diff suppressed because it is too large
Load Diff
|
@ -116,11 +116,26 @@ async def stream_from(portal):
|
||||||
print(value)
|
print(value)
|
||||||
|
|
||||||
|
|
||||||
|
async def unpack_reg(actor_or_portal):
|
||||||
|
'''
|
||||||
|
Get and unpack a "registry" RPC request from the "arbiter" registry
|
||||||
|
system.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if getattr(actor_or_portal, 'get_registry', None):
|
||||||
|
msg = await actor_or_portal.get_registry()
|
||||||
|
else:
|
||||||
|
msg = await actor_or_portal.run_from_ns('self', 'get_registry')
|
||||||
|
|
||||||
|
return {tuple(key.split('.')): val for key, val in msg.items()}
|
||||||
|
|
||||||
|
|
||||||
async def spawn_and_check_registry(
|
async def spawn_and_check_registry(
|
||||||
arb_addr: tuple,
|
arb_addr: tuple,
|
||||||
use_signal: bool,
|
use_signal: bool,
|
||||||
remote_arbiter: bool = False,
|
remote_arbiter: bool = False,
|
||||||
with_streaming: bool = False,
|
with_streaming: bool = False,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
async with tractor.open_root_actor(
|
async with tractor.open_root_actor(
|
||||||
|
@ -134,13 +149,11 @@ async def spawn_and_check_registry(
|
||||||
assert not actor.is_arbiter
|
assert not actor.is_arbiter
|
||||||
|
|
||||||
if actor.is_arbiter:
|
if actor.is_arbiter:
|
||||||
|
|
||||||
async def get_reg():
|
|
||||||
return await actor.get_registry()
|
|
||||||
|
|
||||||
extra = 1 # arbiter is local root actor
|
extra = 1 # arbiter is local root actor
|
||||||
|
get_reg = partial(unpack_reg, actor)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
get_reg = partial(portal.run_from_ns, 'self', 'get_registry')
|
get_reg = partial(unpack_reg, portal)
|
||||||
extra = 2 # local root actor + remote arbiter
|
extra = 2 # local root actor + remote arbiter
|
||||||
|
|
||||||
# ensure current actor is registered
|
# ensure current actor is registered
|
||||||
|
@ -266,7 +279,7 @@ async def close_chans_before_nursery(
|
||||||
):
|
):
|
||||||
async with tractor.get_arbiter(*arb_addr) as aportal:
|
async with tractor.get_arbiter(*arb_addr) as aportal:
|
||||||
try:
|
try:
|
||||||
get_reg = partial(aportal.run_from_ns, 'self', 'get_registry')
|
get_reg = partial(unpack_reg, aportal)
|
||||||
|
|
||||||
async with tractor.open_nursery() as tn:
|
async with tractor.open_nursery() as tn:
|
||||||
portal1 = await tn.start_actor(
|
portal1 = await tn.start_actor(
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
"""
|
'''
|
||||||
Let's make sure them docs work yah?
|
Let's make sure them docs work yah?
|
||||||
"""
|
|
||||||
|
'''
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
import itertools
|
import itertools
|
||||||
import os
|
import os
|
||||||
|
@ -11,17 +12,17 @@ import shutil
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from conftest import repodir
|
from conftest import (
|
||||||
|
examples_dir,
|
||||||
|
)
|
||||||
def examples_dir():
|
|
||||||
"""Return the abspath to the examples directory.
|
|
||||||
"""
|
|
||||||
return os.path.join(repodir(), 'examples')
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def run_example_in_subproc(loglevel, testdir, arb_addr):
|
def run_example_in_subproc(
|
||||||
|
loglevel: str,
|
||||||
|
testdir,
|
||||||
|
arb_addr: tuple[str, int],
|
||||||
|
):
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def run(script_code):
|
def run(script_code):
|
||||||
|
@ -31,8 +32,8 @@ def run_example_in_subproc(loglevel, testdir, arb_addr):
|
||||||
# on windows we need to create a special __main__.py which will
|
# on windows we need to create a special __main__.py which will
|
||||||
# be executed with ``python -m <modulename>`` on windows..
|
# be executed with ``python -m <modulename>`` on windows..
|
||||||
shutil.copyfile(
|
shutil.copyfile(
|
||||||
os.path.join(examples_dir(), '__main__.py'),
|
examples_dir() / '__main__.py',
|
||||||
os.path.join(str(testdir), '__main__.py')
|
str(testdir / '__main__.py'),
|
||||||
)
|
)
|
||||||
|
|
||||||
# drop the ``if __name__ == '__main__'`` guard onwards from
|
# drop the ``if __name__ == '__main__'`` guard onwards from
|
||||||
|
@ -80,11 +81,15 @@ def run_example_in_subproc(loglevel, testdir, arb_addr):
|
||||||
'example_script',
|
'example_script',
|
||||||
|
|
||||||
# walk yields: (dirpath, dirnames, filenames)
|
# walk yields: (dirpath, dirnames, filenames)
|
||||||
[(p[0], f) for p in os.walk(examples_dir()) for f in p[2]
|
[
|
||||||
|
(p[0], f) for p in os.walk(examples_dir()) for f in p[2]
|
||||||
|
|
||||||
if '__' not in f
|
if '__' not in f
|
||||||
and f[0] != '_'
|
and f[0] != '_'
|
||||||
and 'debugging' not in p[0]],
|
and 'debugging' not in p[0]
|
||||||
|
and 'integration' not in p[0]
|
||||||
|
and 'advanced_faults' not in p[0]
|
||||||
|
],
|
||||||
|
|
||||||
ids=lambda t: t[1],
|
ids=lambda t: t[1],
|
||||||
)
|
)
|
||||||
|
@ -112,9 +117,19 @@ def test_example(run_example_in_subproc, example_script):
|
||||||
# print(f'STDOUT: {out}')
|
# print(f'STDOUT: {out}')
|
||||||
|
|
||||||
# if we get some gnarly output let's aggregate and raise
|
# if we get some gnarly output let's aggregate and raise
|
||||||
errmsg = err.decode()
|
if err:
|
||||||
errlines = errmsg.splitlines()
|
errmsg = err.decode()
|
||||||
if err and 'Error' in errlines[-1]:
|
errlines = errmsg.splitlines()
|
||||||
raise Exception(errmsg)
|
last_error = errlines[-1]
|
||||||
|
if (
|
||||||
|
'Error' in last_error
|
||||||
|
|
||||||
|
# XXX: currently we print this to console, but maybe
|
||||||
|
# shouldn't eventually once we figure out what's
|
||||||
|
# a better way to be explicit about aio side
|
||||||
|
# cancels?
|
||||||
|
and 'asyncio.exceptions.CancelledError' not in last_error
|
||||||
|
):
|
||||||
|
raise Exception(errmsg)
|
||||||
|
|
||||||
assert proc.returncode == 0
|
assert proc.returncode == 0
|
||||||
|
|
|
@ -0,0 +1,564 @@
|
||||||
|
'''
|
||||||
|
The hipster way to force SC onto the stdlib's "async": 'infection mode'.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from typing import Optional, Iterable, Union
|
||||||
|
import asyncio
|
||||||
|
import builtins
|
||||||
|
import itertools
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
from exceptiongroup import BaseExceptionGroup
|
||||||
|
import pytest
|
||||||
|
import trio
|
||||||
|
import tractor
|
||||||
|
from tractor import (
|
||||||
|
to_asyncio,
|
||||||
|
RemoteActorError,
|
||||||
|
)
|
||||||
|
from tractor.trionics import BroadcastReceiver
|
||||||
|
|
||||||
|
|
||||||
|
async def sleep_and_err(
|
||||||
|
sleep_for: float = 0.1,
|
||||||
|
|
||||||
|
# just signature placeholders for compat with
|
||||||
|
# ``to_asyncio.open_channel_from()``
|
||||||
|
to_trio: Optional[trio.MemorySendChannel] = None,
|
||||||
|
from_trio: Optional[asyncio.Queue] = None,
|
||||||
|
|
||||||
|
):
|
||||||
|
if to_trio:
|
||||||
|
to_trio.send_nowait('start')
|
||||||
|
|
||||||
|
await asyncio.sleep(sleep_for)
|
||||||
|
assert 0
|
||||||
|
|
||||||
|
|
||||||
|
async def sleep_forever():
|
||||||
|
await asyncio.sleep(float('inf'))
|
||||||
|
|
||||||
|
|
||||||
|
async def trio_cancels_single_aio_task():
|
||||||
|
|
||||||
|
# spawn an ``asyncio`` task to run a func and return result
|
||||||
|
with trio.move_on_after(.2):
|
||||||
|
await tractor.to_asyncio.run_task(sleep_forever)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trio_cancels_aio_on_actor_side(arb_addr):
|
||||||
|
'''
|
||||||
|
Spawn an infected actor that is cancelled by the ``trio`` side
|
||||||
|
task using std cancel scope apis.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main():
|
||||||
|
async with tractor.open_nursery(
|
||||||
|
arbiter_addr=arb_addr
|
||||||
|
) as n:
|
||||||
|
await n.run_in_actor(
|
||||||
|
trio_cancels_single_aio_task,
|
||||||
|
infect_asyncio=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
async def asyncio_actor(
|
||||||
|
|
||||||
|
target: str,
|
||||||
|
expect_err: Optional[Exception] = None
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
assert tractor.current_actor().is_infected_aio()
|
||||||
|
target = globals()[target]
|
||||||
|
|
||||||
|
if '.' in expect_err:
|
||||||
|
modpath, _, name = expect_err.rpartition('.')
|
||||||
|
mod = importlib.import_module(modpath)
|
||||||
|
error_type = getattr(mod, name)
|
||||||
|
|
||||||
|
else: # toplevel builtin error type
|
||||||
|
error_type = builtins.__dict__.get(expect_err)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# spawn an ``asyncio`` task to run a func and return result
|
||||||
|
await tractor.to_asyncio.run_task(target)
|
||||||
|
|
||||||
|
except BaseException as err:
|
||||||
|
if expect_err:
|
||||||
|
assert isinstance(err, error_type)
|
||||||
|
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_aio_simple_error(arb_addr):
|
||||||
|
'''
|
||||||
|
Verify a simple remote asyncio error propagates back through trio
|
||||||
|
to the parent actor.
|
||||||
|
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main():
|
||||||
|
async with tractor.open_nursery(
|
||||||
|
arbiter_addr=arb_addr
|
||||||
|
) as n:
|
||||||
|
await n.run_in_actor(
|
||||||
|
asyncio_actor,
|
||||||
|
target='sleep_and_err',
|
||||||
|
expect_err='AssertionError',
|
||||||
|
infect_asyncio=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RemoteActorError) as excinfo:
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
err = excinfo.value
|
||||||
|
assert isinstance(err, RemoteActorError)
|
||||||
|
assert err.type == AssertionError
|
||||||
|
|
||||||
|
|
||||||
|
def test_tractor_cancels_aio(arb_addr):
|
||||||
|
'''
|
||||||
|
Verify we can cancel a spawned asyncio task gracefully.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main():
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
portal = await n.run_in_actor(
|
||||||
|
asyncio_actor,
|
||||||
|
target='sleep_forever',
|
||||||
|
expect_err='trio.Cancelled',
|
||||||
|
infect_asyncio=True,
|
||||||
|
)
|
||||||
|
# cancel the entire remote runtime
|
||||||
|
await portal.cancel_actor()
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trio_cancels_aio(arb_addr):
|
||||||
|
'''
|
||||||
|
Much like the above test with ``tractor.Portal.cancel_actor()``
|
||||||
|
except we just use a standard ``trio`` cancellation api.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
with trio.move_on_after(1):
|
||||||
|
# cancel the nursery shortly after boot
|
||||||
|
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
await n.run_in_actor(
|
||||||
|
asyncio_actor,
|
||||||
|
target='sleep_forever',
|
||||||
|
expect_err='trio.Cancelled',
|
||||||
|
infect_asyncio=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def trio_ctx(
|
||||||
|
ctx: tractor.Context,
|
||||||
|
):
|
||||||
|
|
||||||
|
await ctx.started('start')
|
||||||
|
|
||||||
|
# this will block until the ``asyncio`` task sends a "first"
|
||||||
|
# message.
|
||||||
|
with trio.fail_after(2):
|
||||||
|
async with (
|
||||||
|
trio.open_nursery() as n,
|
||||||
|
|
||||||
|
tractor.to_asyncio.open_channel_from(
|
||||||
|
sleep_and_err,
|
||||||
|
) as (first, chan),
|
||||||
|
):
|
||||||
|
|
||||||
|
assert first == 'start'
|
||||||
|
|
||||||
|
# spawn another asyncio task for the cuck of it.
|
||||||
|
n.start_soon(
|
||||||
|
tractor.to_asyncio.run_task,
|
||||||
|
sleep_forever,
|
||||||
|
)
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'parent_cancels', [False, True],
|
||||||
|
ids='parent_actor_cancels_child={}'.format
|
||||||
|
)
|
||||||
|
def test_context_spawns_aio_task_that_errors(
|
||||||
|
arb_addr,
|
||||||
|
parent_cancels: bool,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Verify that spawning a task via an intertask channel ctx mngr that
|
||||||
|
errors correctly propagates the error back from the `asyncio`-side
|
||||||
|
task.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
with trio.fail_after(2):
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
p = await n.start_actor(
|
||||||
|
'aio_daemon',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
infect_asyncio=True,
|
||||||
|
# debug_mode=True,
|
||||||
|
loglevel='cancel',
|
||||||
|
)
|
||||||
|
async with p.open_context(
|
||||||
|
trio_ctx,
|
||||||
|
) as (ctx, first):
|
||||||
|
|
||||||
|
assert first == 'start'
|
||||||
|
|
||||||
|
if parent_cancels:
|
||||||
|
await p.cancel_actor()
|
||||||
|
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
with pytest.raises(RemoteActorError) as excinfo:
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
err = excinfo.value
|
||||||
|
assert isinstance(err, RemoteActorError)
|
||||||
|
if parent_cancels:
|
||||||
|
assert err.type == trio.Cancelled
|
||||||
|
else:
|
||||||
|
assert err.type == AssertionError
|
||||||
|
|
||||||
|
|
||||||
|
async def aio_cancel():
|
||||||
|
''''
|
||||||
|
Cancel urself boi.
|
||||||
|
|
||||||
|
'''
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
task = asyncio.current_task()
|
||||||
|
|
||||||
|
# cancel and enter sleep
|
||||||
|
task.cancel()
|
||||||
|
await sleep_forever()
|
||||||
|
|
||||||
|
|
||||||
|
def test_aio_cancelled_from_aio_causes_trio_cancelled(arb_addr):
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
await n.run_in_actor(
|
||||||
|
asyncio_actor,
|
||||||
|
target='aio_cancel',
|
||||||
|
expect_err='tractor.to_asyncio.AsyncioCancelled',
|
||||||
|
infect_asyncio=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RemoteActorError) as excinfo:
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
# ensure boxed error is correct
|
||||||
|
assert excinfo.value.type == to_asyncio.AsyncioCancelled
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: verify open_channel_from will fail on this..
|
||||||
|
async def no_to_trio_in_args():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def push_from_aio_task(
|
||||||
|
|
||||||
|
sequence: Iterable,
|
||||||
|
to_trio: trio.abc.SendChannel,
|
||||||
|
expect_cancel: False,
|
||||||
|
fail_early: bool,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
try:
|
||||||
|
# sync caller ctx manager
|
||||||
|
to_trio.send_nowait(True)
|
||||||
|
|
||||||
|
for i in sequence:
|
||||||
|
print(f'asyncio sending {i}')
|
||||||
|
to_trio.send_nowait(i)
|
||||||
|
await asyncio.sleep(0.001)
|
||||||
|
|
||||||
|
if i == 50 and fail_early:
|
||||||
|
raise Exception
|
||||||
|
|
||||||
|
print('asyncio streamer complete!')
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if not expect_cancel:
|
||||||
|
pytest.fail("aio task was cancelled unexpectedly")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
if expect_cancel:
|
||||||
|
pytest.fail("aio task wasn't cancelled as expected!?")
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_from_aio(
|
||||||
|
|
||||||
|
exit_early: bool = False,
|
||||||
|
raise_err: bool = False,
|
||||||
|
aio_raise_err: bool = False,
|
||||||
|
fan_out: bool = False,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
seq = range(100)
|
||||||
|
expect = list(seq)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pulled = []
|
||||||
|
|
||||||
|
async with to_asyncio.open_channel_from(
|
||||||
|
push_from_aio_task,
|
||||||
|
sequence=seq,
|
||||||
|
expect_cancel=raise_err or exit_early,
|
||||||
|
fail_early=aio_raise_err,
|
||||||
|
) as (first, chan):
|
||||||
|
|
||||||
|
assert first is True
|
||||||
|
|
||||||
|
async def consume(
|
||||||
|
chan: Union[
|
||||||
|
to_asyncio.LinkedTaskChannel,
|
||||||
|
BroadcastReceiver,
|
||||||
|
],
|
||||||
|
):
|
||||||
|
async for value in chan:
|
||||||
|
print(f'trio received {value}')
|
||||||
|
pulled.append(value)
|
||||||
|
|
||||||
|
if value == 50:
|
||||||
|
if raise_err:
|
||||||
|
raise Exception
|
||||||
|
elif exit_early:
|
||||||
|
break
|
||||||
|
|
||||||
|
if fan_out:
|
||||||
|
# start second task that get's the same stream value set.
|
||||||
|
async with (
|
||||||
|
|
||||||
|
# NOTE: this has to come first to avoid
|
||||||
|
# the channel being closed before the nursery
|
||||||
|
# tasks are joined..
|
||||||
|
chan.subscribe() as br,
|
||||||
|
|
||||||
|
trio.open_nursery() as n,
|
||||||
|
):
|
||||||
|
n.start_soon(consume, br)
|
||||||
|
await consume(chan)
|
||||||
|
|
||||||
|
else:
|
||||||
|
await consume(chan)
|
||||||
|
finally:
|
||||||
|
|
||||||
|
if (
|
||||||
|
not raise_err and
|
||||||
|
not exit_early and
|
||||||
|
not aio_raise_err
|
||||||
|
):
|
||||||
|
if fan_out:
|
||||||
|
# we get double the pulled values in the
|
||||||
|
# ``.subscribe()`` fan out case.
|
||||||
|
doubled = list(itertools.chain(*zip(expect, expect)))
|
||||||
|
expect = doubled[:len(pulled)]
|
||||||
|
assert list(sorted(pulled)) == expect
|
||||||
|
|
||||||
|
else:
|
||||||
|
assert pulled == expect
|
||||||
|
else:
|
||||||
|
assert not fan_out
|
||||||
|
assert pulled == expect[:51]
|
||||||
|
|
||||||
|
print('trio guest mode task completed!')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'fan_out', [False, True],
|
||||||
|
ids='fan_out_w_chan_subscribe={}'.format
|
||||||
|
)
|
||||||
|
def test_basic_interloop_channel_stream(arb_addr, fan_out):
|
||||||
|
async def main():
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
portal = await n.run_in_actor(
|
||||||
|
stream_from_aio,
|
||||||
|
infect_asyncio=True,
|
||||||
|
fan_out=fan_out,
|
||||||
|
)
|
||||||
|
await portal.result()
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: parametrize the above test and avoid the duplication here?
|
||||||
|
def test_trio_error_cancels_intertask_chan(arb_addr):
|
||||||
|
async def main():
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
portal = await n.run_in_actor(
|
||||||
|
stream_from_aio,
|
||||||
|
raise_err=True,
|
||||||
|
infect_asyncio=True,
|
||||||
|
)
|
||||||
|
# should trigger remote actor error
|
||||||
|
await portal.result()
|
||||||
|
|
||||||
|
with pytest.raises(BaseExceptionGroup) as excinfo:
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
# ensure boxed errors
|
||||||
|
for exc in excinfo.value.exceptions:
|
||||||
|
assert exc.type == Exception
|
||||||
|
|
||||||
|
|
||||||
|
def test_trio_closes_early_and_channel_exits(arb_addr):
|
||||||
|
async def main():
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
portal = await n.run_in_actor(
|
||||||
|
stream_from_aio,
|
||||||
|
exit_early=True,
|
||||||
|
infect_asyncio=True,
|
||||||
|
)
|
||||||
|
# should trigger remote actor error
|
||||||
|
await portal.result()
|
||||||
|
|
||||||
|
# should be a quiet exit on a simple channel exit
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
def test_aio_errors_and_channel_propagates_and_closes(arb_addr):
|
||||||
|
async def main():
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
portal = await n.run_in_actor(
|
||||||
|
stream_from_aio,
|
||||||
|
aio_raise_err=True,
|
||||||
|
infect_asyncio=True,
|
||||||
|
)
|
||||||
|
# should trigger remote actor error
|
||||||
|
await portal.result()
|
||||||
|
|
||||||
|
with pytest.raises(BaseExceptionGroup) as excinfo:
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
# ensure boxed errors
|
||||||
|
for exc in excinfo.value.exceptions:
|
||||||
|
assert exc.type == Exception
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def trio_to_aio_echo_server(
|
||||||
|
ctx: tractor.Context,
|
||||||
|
):
|
||||||
|
|
||||||
|
async def aio_echo_server(
|
||||||
|
to_trio: trio.MemorySendChannel,
|
||||||
|
from_trio: asyncio.Queue,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
to_trio.send_nowait('start')
|
||||||
|
|
||||||
|
while True:
|
||||||
|
msg = await from_trio.get()
|
||||||
|
|
||||||
|
# echo the msg back
|
||||||
|
to_trio.send_nowait(msg)
|
||||||
|
|
||||||
|
# if we get the terminate sentinel
|
||||||
|
# break the echo loop
|
||||||
|
if msg is None:
|
||||||
|
print('breaking aio echo loop')
|
||||||
|
break
|
||||||
|
|
||||||
|
print('exiting asyncio task')
|
||||||
|
|
||||||
|
async with to_asyncio.open_channel_from(
|
||||||
|
aio_echo_server,
|
||||||
|
) as (first, chan):
|
||||||
|
|
||||||
|
assert first == 'start'
|
||||||
|
await ctx.started(first)
|
||||||
|
|
||||||
|
async with ctx.open_stream() as stream:
|
||||||
|
|
||||||
|
async for msg in stream:
|
||||||
|
print(f'asyncio echoing {msg}')
|
||||||
|
await chan.send(msg)
|
||||||
|
|
||||||
|
out = await chan.receive()
|
||||||
|
# echo back to parent actor-task
|
||||||
|
await stream.send(out)
|
||||||
|
|
||||||
|
if out is None:
|
||||||
|
try:
|
||||||
|
out = await chan.receive()
|
||||||
|
except trio.EndOfChannel:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise RuntimeError('aio channel never stopped?')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'raise_error_mid_stream',
|
||||||
|
[False, Exception, KeyboardInterrupt],
|
||||||
|
ids='raise_error={}'.format,
|
||||||
|
)
|
||||||
|
def test_echoserver_detailed_mechanics(
|
||||||
|
arb_addr,
|
||||||
|
raise_error_mid_stream,
|
||||||
|
):
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
p = await n.start_actor(
|
||||||
|
'aio_server',
|
||||||
|
enable_modules=[__name__],
|
||||||
|
infect_asyncio=True,
|
||||||
|
)
|
||||||
|
async with p.open_context(
|
||||||
|
trio_to_aio_echo_server,
|
||||||
|
) as (ctx, first):
|
||||||
|
|
||||||
|
assert first == 'start'
|
||||||
|
|
||||||
|
async with ctx.open_stream() as stream:
|
||||||
|
for i in range(100):
|
||||||
|
await stream.send(i)
|
||||||
|
out = await stream.receive()
|
||||||
|
assert i == out
|
||||||
|
|
||||||
|
if raise_error_mid_stream and i == 50:
|
||||||
|
raise raise_error_mid_stream
|
||||||
|
|
||||||
|
# send terminate msg
|
||||||
|
await stream.send(None)
|
||||||
|
out = await stream.receive()
|
||||||
|
assert out is None
|
||||||
|
|
||||||
|
if out is None:
|
||||||
|
# ensure the stream is stopped
|
||||||
|
# with trio.fail_after(0.1):
|
||||||
|
try:
|
||||||
|
await stream.receive()
|
||||||
|
except trio.EndOfChannel:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
pytest.fail(
|
||||||
|
"stream wasn't stopped after sentinel?!")
|
||||||
|
|
||||||
|
# TODO: the case where this blocks and
|
||||||
|
# is cancelled by kbi or out of task cancellation
|
||||||
|
await p.cancel_actor()
|
||||||
|
|
||||||
|
if raise_error_mid_stream:
|
||||||
|
with pytest.raises(raise_error_mid_stream):
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
else:
|
||||||
|
trio.run(main)
|
|
@ -7,9 +7,10 @@ import platform
|
||||||
|
|
||||||
import trio
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
from tractor.testing import tractor_test
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from conftest import tractor_test
|
||||||
|
|
||||||
|
|
||||||
def test_must_define_ctx():
|
def test_must_define_ctx():
|
||||||
|
|
||||||
|
@ -79,33 +80,36 @@ async def stream_from_single_subactor(
|
||||||
|
|
||||||
seq = range(10)
|
seq = range(10)
|
||||||
|
|
||||||
async with portal.open_stream_from(
|
with trio.fail_after(5):
|
||||||
stream_func,
|
async with portal.open_stream_from(
|
||||||
sequence=list(seq), # has to be msgpack serializable
|
stream_func,
|
||||||
) as stream:
|
sequence=list(seq), # has to be msgpack serializable
|
||||||
|
) as stream:
|
||||||
|
|
||||||
# it'd sure be nice to have an asyncitertools here...
|
# it'd sure be nice to have an asyncitertools here...
|
||||||
iseq = iter(seq)
|
iseq = iter(seq)
|
||||||
ival = next(iseq)
|
ival = next(iseq)
|
||||||
|
|
||||||
async for val in stream:
|
async for val in stream:
|
||||||
assert val == ival
|
assert val == ival
|
||||||
|
|
||||||
|
try:
|
||||||
|
ival = next(iseq)
|
||||||
|
except StopIteration:
|
||||||
|
# should cancel far end task which will be
|
||||||
|
# caught and no error is raised
|
||||||
|
await stream.aclose()
|
||||||
|
|
||||||
|
await trio.sleep(0.3)
|
||||||
|
|
||||||
|
# ensure EOC signalled-state translates
|
||||||
|
# XXX: not really sure this is correct,
|
||||||
|
# shouldn't it be a `ClosedResourceError`?
|
||||||
try:
|
try:
|
||||||
ival = next(iseq)
|
await stream.__anext__()
|
||||||
except StopIteration:
|
except StopAsyncIteration:
|
||||||
# should cancel far end task which will be
|
# stop all spawned subactors
|
||||||
# caught and no error is raised
|
await portal.cancel_actor()
|
||||||
await stream.aclose()
|
|
||||||
|
|
||||||
await trio.sleep(0.3)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await stream.__anext__()
|
|
||||||
except StopAsyncIteration:
|
|
||||||
# stop all spawned subactors
|
|
||||||
await portal.cancel_actor()
|
|
||||||
# await nursery.cancel()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
@ -132,7 +136,7 @@ async def stream_data(seed):
|
||||||
yield i
|
yield i
|
||||||
|
|
||||||
# trigger scheduler to simulate practical usage
|
# trigger scheduler to simulate practical usage
|
||||||
await trio.sleep(0)
|
await trio.sleep(0.0001)
|
||||||
|
|
||||||
|
|
||||||
# this is the third actor; the aggregator
|
# this is the third actor; the aggregator
|
||||||
|
@ -247,7 +251,7 @@ def test_a_quadruple_example(time_quad_ex, ci_env, spawn_backend):
|
||||||
|
|
||||||
results, diff = time_quad_ex
|
results, diff = time_quad_ex
|
||||||
assert results
|
assert results
|
||||||
this_fast = 6 if platform.system() in ('Windows', 'Darwin') else 2.5
|
this_fast = 6 if platform.system() in ('Windows', 'Darwin') else 3
|
||||||
assert diff < this_fast
|
assert diff < this_fast
|
||||||
|
|
||||||
|
|
|
@ -11,25 +11,18 @@ from conftest import tractor_test
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.trio
|
@pytest.mark.trio
|
||||||
async def test_no_arbitter():
|
async def test_no_runtime():
|
||||||
"""An arbitter must be established before any nurseries
|
"""An arbitter must be established before any nurseries
|
||||||
can be created.
|
can be created.
|
||||||
|
|
||||||
(In other words ``tractor.open_root_actor()`` must be engaged at
|
(In other words ``tractor.open_root_actor()`` must be engaged at
|
||||||
some point?)
|
some point?)
|
||||||
"""
|
"""
|
||||||
with pytest.raises(RuntimeError):
|
with pytest.raises(RuntimeError) :
|
||||||
with tractor.open_nursery():
|
async with tractor.find_actor('doggy'):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def test_no_main():
|
|
||||||
"""An async function **must** be passed to ``tractor.run()``.
|
|
||||||
"""
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
tractor.run(None)
|
|
||||||
|
|
||||||
|
|
||||||
@tractor_test
|
@tractor_test
|
||||||
async def test_self_is_registered(arb_addr):
|
async def test_self_is_registered(arb_addr):
|
||||||
"Verify waiting on the arbiter to register itself using the standard api."
|
"Verify waiting on the arbiter to register itself using the standard api."
|
||||||
|
|
|
@ -4,20 +4,22 @@ from itertools import cycle
|
||||||
import pytest
|
import pytest
|
||||||
import trio
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
from tractor.testing import tractor_test
|
from tractor.experimental import msgpub
|
||||||
|
|
||||||
|
from conftest import tractor_test
|
||||||
|
|
||||||
|
|
||||||
def test_type_checks():
|
def test_type_checks():
|
||||||
|
|
||||||
with pytest.raises(TypeError) as err:
|
with pytest.raises(TypeError) as err:
|
||||||
@tractor.msg.pub
|
@msgpub
|
||||||
async def no_get_topics(yo):
|
async def no_get_topics(yo):
|
||||||
yield
|
yield
|
||||||
|
|
||||||
assert "must define a `get_topics`" in str(err.value)
|
assert "must define a `get_topics`" in str(err.value)
|
||||||
|
|
||||||
with pytest.raises(TypeError) as err:
|
with pytest.raises(TypeError) as err:
|
||||||
@tractor.msg.pub
|
@msgpub
|
||||||
def not_async_gen(yo):
|
def not_async_gen(yo):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -32,7 +34,7 @@ def is_even(i):
|
||||||
_get_topics = None
|
_get_topics = None
|
||||||
|
|
||||||
|
|
||||||
@tractor.msg.pub
|
@msgpub
|
||||||
async def pubber(get_topics, seed=10):
|
async def pubber(get_topics, seed=10):
|
||||||
|
|
||||||
# ensure topic subscriptions are as expected
|
# ensure topic subscriptions are as expected
|
||||||
|
@ -103,7 +105,7 @@ async def subs(
|
||||||
await stream.aclose()
|
await stream.aclose()
|
||||||
|
|
||||||
|
|
||||||
@tractor.msg.pub(tasks=['one', 'two'])
|
@msgpub(tasks=['one', 'two'])
|
||||||
async def multilock_pubber(get_topics):
|
async def multilock_pubber(get_topics):
|
||||||
yield {'doggy': 10}
|
yield {'doggy': 10}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,182 @@
|
||||||
|
'''
|
||||||
|
Async context manager cache api testing: ``trionics.maybe_open_context():``
|
||||||
|
|
||||||
|
'''
|
||||||
|
from contextlib import asynccontextmanager as acm
|
||||||
|
import platform
|
||||||
|
from typing import Awaitable
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import trio
|
||||||
|
import tractor
|
||||||
|
|
||||||
|
|
||||||
|
_resource: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def maybe_increment_counter(task_name: str):
|
||||||
|
global _resource
|
||||||
|
|
||||||
|
_resource += 1
|
||||||
|
await trio.lowlevel.checkpoint()
|
||||||
|
yield _resource
|
||||||
|
await trio.lowlevel.checkpoint()
|
||||||
|
_resource -= 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'key_on',
|
||||||
|
['key_value', 'kwargs'],
|
||||||
|
ids="key_on={}".format,
|
||||||
|
)
|
||||||
|
def test_resource_only_entered_once(key_on):
|
||||||
|
global _resource
|
||||||
|
_resource = 0
|
||||||
|
|
||||||
|
kwargs = {}
|
||||||
|
key = None
|
||||||
|
if key_on == 'key_value':
|
||||||
|
key = 'some_common_key'
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
cache_active: bool = False
|
||||||
|
|
||||||
|
async def enter_cached_mngr(name: str):
|
||||||
|
nonlocal cache_active
|
||||||
|
|
||||||
|
if key_on == 'kwargs':
|
||||||
|
# make a common kwargs input to key on it
|
||||||
|
kwargs = {'task_name': 'same_task_name'}
|
||||||
|
assert key is None
|
||||||
|
else:
|
||||||
|
# different task names per task will be used
|
||||||
|
kwargs = {'task_name': name}
|
||||||
|
|
||||||
|
async with tractor.trionics.maybe_open_context(
|
||||||
|
maybe_increment_counter,
|
||||||
|
kwargs=kwargs,
|
||||||
|
key=key,
|
||||||
|
|
||||||
|
) as (cache_hit, resource):
|
||||||
|
if cache_hit:
|
||||||
|
try:
|
||||||
|
cache_active = True
|
||||||
|
assert resource == 1
|
||||||
|
await trio.sleep_forever()
|
||||||
|
finally:
|
||||||
|
cache_active = False
|
||||||
|
else:
|
||||||
|
assert resource == 1
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
with trio.move_on_after(0.5):
|
||||||
|
async with (
|
||||||
|
tractor.open_root_actor(),
|
||||||
|
trio.open_nursery() as n,
|
||||||
|
):
|
||||||
|
|
||||||
|
for i in range(10):
|
||||||
|
n.start_soon(enter_cached_mngr, f'task_{i}')
|
||||||
|
await trio.sleep(0.001)
|
||||||
|
|
||||||
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def streamer(
|
||||||
|
ctx: tractor.Context,
|
||||||
|
seq: list[int] = list(range(1000)),
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
await ctx.started()
|
||||||
|
async with ctx.open_stream() as stream:
|
||||||
|
for val in seq:
|
||||||
|
await stream.send(val)
|
||||||
|
await trio.sleep(0.001)
|
||||||
|
|
||||||
|
print('producer finished')
|
||||||
|
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def open_stream() -> Awaitable[tractor.MsgStream]:
|
||||||
|
|
||||||
|
async with tractor.open_nursery() as tn:
|
||||||
|
portal = await tn.start_actor('streamer', enable_modules=[__name__])
|
||||||
|
async with (
|
||||||
|
portal.open_context(streamer) as (ctx, first),
|
||||||
|
ctx.open_stream() as stream,
|
||||||
|
):
|
||||||
|
yield stream
|
||||||
|
|
||||||
|
await portal.cancel_actor()
|
||||||
|
print('CANCELLED STREAMER')
|
||||||
|
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def maybe_open_stream(taskname: str):
|
||||||
|
async with tractor.trionics.maybe_open_context(
|
||||||
|
# NOTE: all secondary tasks should cache hit on the same key
|
||||||
|
acm_func=open_stream,
|
||||||
|
) as (cache_hit, stream):
|
||||||
|
|
||||||
|
if cache_hit:
|
||||||
|
print(f'{taskname} loaded from cache')
|
||||||
|
|
||||||
|
# add a new broadcast subscription for the quote stream
|
||||||
|
# if this feed is already allocated by the first
|
||||||
|
# task that entereed
|
||||||
|
async with stream.subscribe() as bstream:
|
||||||
|
yield bstream
|
||||||
|
else:
|
||||||
|
# yield the actual stream
|
||||||
|
yield stream
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_local_sub_to_stream():
|
||||||
|
'''
|
||||||
|
Verify a single inter-actor stream can can be fanned-out shared to
|
||||||
|
N local tasks using ``trionics.maybe_open_context():``.
|
||||||
|
|
||||||
|
'''
|
||||||
|
timeout = 3 if platform.system() != "Windows" else 10
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
full = list(range(1000))
|
||||||
|
|
||||||
|
async def get_sub_and_pull(taskname: str):
|
||||||
|
async with (
|
||||||
|
maybe_open_stream(taskname) as stream,
|
||||||
|
):
|
||||||
|
if '0' in taskname:
|
||||||
|
assert isinstance(stream, tractor.MsgStream)
|
||||||
|
else:
|
||||||
|
assert isinstance(
|
||||||
|
stream,
|
||||||
|
tractor.trionics.BroadcastReceiver
|
||||||
|
)
|
||||||
|
|
||||||
|
first = await stream.receive()
|
||||||
|
print(f'{taskname} started with value {first}')
|
||||||
|
seq = []
|
||||||
|
async for msg in stream:
|
||||||
|
seq.append(msg)
|
||||||
|
|
||||||
|
assert set(seq).issubset(set(full))
|
||||||
|
print(f'{taskname} finished')
|
||||||
|
|
||||||
|
with trio.fail_after(timeout):
|
||||||
|
# TODO: turns out this isn't multi-task entrant XD
|
||||||
|
# We probably need an indepotent entry semantic?
|
||||||
|
async with tractor.open_root_actor():
|
||||||
|
async with (
|
||||||
|
trio.open_nursery() as nurse,
|
||||||
|
):
|
||||||
|
for i in range(10):
|
||||||
|
nurse.start_soon(get_sub_and_pull, f'task_{i}')
|
||||||
|
await trio.sleep(0.001)
|
||||||
|
|
||||||
|
print('all consumer tasks finished')
|
||||||
|
|
||||||
|
trio.run(main)
|
|
@ -0,0 +1,73 @@
|
||||||
|
"""
|
||||||
|
Verifying internal runtime state and undocumented extras.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import trio
|
||||||
|
import tractor
|
||||||
|
|
||||||
|
from conftest import tractor_test
|
||||||
|
|
||||||
|
|
||||||
|
_file_path: str = ''
|
||||||
|
|
||||||
|
|
||||||
|
def unlink_file():
|
||||||
|
print('Removing tmp file!')
|
||||||
|
os.remove(_file_path)
|
||||||
|
|
||||||
|
|
||||||
|
async def crash_and_clean_tmpdir(
|
||||||
|
tmp_file_path: str,
|
||||||
|
error: bool = True,
|
||||||
|
):
|
||||||
|
global _file_path
|
||||||
|
_file_path = tmp_file_path
|
||||||
|
|
||||||
|
actor = tractor.current_actor()
|
||||||
|
actor.lifetime_stack.callback(unlink_file)
|
||||||
|
|
||||||
|
assert os.path.isfile(tmp_file_path)
|
||||||
|
await trio.sleep(0.1)
|
||||||
|
if error:
|
||||||
|
assert 0
|
||||||
|
else:
|
||||||
|
actor.cancel_soon()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'error_in_child',
|
||||||
|
[True, False],
|
||||||
|
)
|
||||||
|
@tractor_test
|
||||||
|
async def test_lifetime_stack_wipes_tmpfile(
|
||||||
|
tmp_path,
|
||||||
|
error_in_child: bool,
|
||||||
|
):
|
||||||
|
child_tmp_file = tmp_path / "child.txt"
|
||||||
|
child_tmp_file.touch()
|
||||||
|
assert child_tmp_file.exists()
|
||||||
|
path = str(child_tmp_file)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with trio.move_on_after(0.5):
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
await ( # inlined portal
|
||||||
|
await n.run_in_actor(
|
||||||
|
crash_and_clean_tmpdir,
|
||||||
|
tmp_file_path=path,
|
||||||
|
error=error_in_child,
|
||||||
|
)
|
||||||
|
).result()
|
||||||
|
|
||||||
|
except (
|
||||||
|
tractor.RemoteActorError,
|
||||||
|
tractor.BaseExceptionGroup,
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# tmp file should have been wiped by
|
||||||
|
# teardown stack.
|
||||||
|
assert not child_tmp_file.exists()
|
|
@ -1,7 +1,8 @@
|
||||||
"""
|
"""
|
||||||
Spawning basics
|
Spawning basics
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from typing import Dict, Tuple
|
from typing import Optional
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import trio
|
import trio
|
||||||
|
@ -14,8 +15,8 @@ data_to_pass_down = {'doggy': 10, 'kitty': 4}
|
||||||
|
|
||||||
async def spawn(
|
async def spawn(
|
||||||
is_arbiter: bool,
|
is_arbiter: bool,
|
||||||
data: Dict,
|
data: dict,
|
||||||
arb_addr: Tuple[str, int],
|
arb_addr: tuple[str, int],
|
||||||
):
|
):
|
||||||
namespaces = [__name__]
|
namespaces = [__name__]
|
||||||
|
|
||||||
|
@ -93,24 +94,38 @@ async def test_movie_theatre_convo(start_method):
|
||||||
await portal.cancel_actor()
|
await portal.cancel_actor()
|
||||||
|
|
||||||
|
|
||||||
async def cellar_door():
|
async def cellar_door(return_value: Optional[str]):
|
||||||
return "Dang that's beautiful"
|
return return_value
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'return_value', ["Dang that's beautiful", None],
|
||||||
|
ids=['return_str', 'return_None'],
|
||||||
|
)
|
||||||
@tractor_test
|
@tractor_test
|
||||||
async def test_most_beautiful_word(start_method):
|
async def test_most_beautiful_word(
|
||||||
"""The main ``tractor`` routine.
|
start_method,
|
||||||
"""
|
return_value
|
||||||
async with tractor.open_nursery() as n:
|
):
|
||||||
|
'''
|
||||||
|
The main ``tractor`` routine.
|
||||||
|
|
||||||
portal = await n.run_in_actor(
|
'''
|
||||||
cellar_door,
|
with trio.fail_after(1):
|
||||||
name='some_linguist',
|
async with tractor.open_nursery() as n:
|
||||||
)
|
|
||||||
|
|
||||||
|
portal = await n.run_in_actor(
|
||||||
|
cellar_door,
|
||||||
|
return_value=return_value,
|
||||||
|
name='some_linguist',
|
||||||
|
)
|
||||||
|
|
||||||
|
print(await portal.result())
|
||||||
# The ``async with`` will unblock here since the 'some_linguist'
|
# The ``async with`` will unblock here since the 'some_linguist'
|
||||||
# actor has completed its main task ``cellar_door``.
|
# actor has completed its main task ``cellar_door``.
|
||||||
|
|
||||||
|
# this should pull the cached final result already captured during
|
||||||
|
# the nursery block exit.
|
||||||
print(await portal.result())
|
print(await portal.result())
|
||||||
|
|
||||||
|
|
||||||
|
@ -127,7 +142,7 @@ def test_loglevel_propagated_to_subactor(
|
||||||
capfd,
|
capfd,
|
||||||
arb_addr,
|
arb_addr,
|
||||||
):
|
):
|
||||||
if start_method == 'forkserver':
|
if start_method == 'mp_forkserver':
|
||||||
pytest.skip(
|
pytest.skip(
|
||||||
"a bug with `capfd` seems to make forkserver capture not work?")
|
"a bug with `capfd` seems to make forkserver capture not work?")
|
||||||
|
|
||||||
|
@ -136,13 +151,13 @@ def test_loglevel_propagated_to_subactor(
|
||||||
async def main():
|
async def main():
|
||||||
async with tractor.open_nursery(
|
async with tractor.open_nursery(
|
||||||
name='arbiter',
|
name='arbiter',
|
||||||
loglevel=level,
|
|
||||||
start_method=start_method,
|
start_method=start_method,
|
||||||
arbiter_addr=arb_addr,
|
arbiter_addr=arb_addr,
|
||||||
|
|
||||||
) as tn:
|
) as tn:
|
||||||
await tn.run_in_actor(
|
await tn.run_in_actor(
|
||||||
check_loglevel,
|
check_loglevel,
|
||||||
|
loglevel=level,
|
||||||
level=level,
|
level=level,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -6,13 +6,16 @@ from contextlib import asynccontextmanager
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from itertools import cycle
|
from itertools import cycle
|
||||||
import time
|
import time
|
||||||
from typing import Optional, List, Tuple
|
from typing import Optional
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import trio
|
import trio
|
||||||
from trio.lowlevel import current_task
|
from trio.lowlevel import current_task
|
||||||
import tractor
|
import tractor
|
||||||
from tractor.trionics import broadcast_receiver, Lagged
|
from tractor.trionics import (
|
||||||
|
broadcast_receiver,
|
||||||
|
Lagged,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@tractor.context
|
@tractor.context
|
||||||
|
@ -37,7 +40,7 @@ async def echo_sequences(
|
||||||
|
|
||||||
async def ensure_sequence(
|
async def ensure_sequence(
|
||||||
|
|
||||||
stream: tractor.ReceiveMsgStream,
|
stream: tractor.MsgStream,
|
||||||
sequence: list,
|
sequence: list,
|
||||||
delay: Optional[float] = None,
|
delay: Optional[float] = None,
|
||||||
|
|
||||||
|
@ -62,8 +65,8 @@ async def ensure_sequence(
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def open_sequence_streamer(
|
async def open_sequence_streamer(
|
||||||
|
|
||||||
sequence: List[int],
|
sequence: list[int],
|
||||||
arb_addr: Tuple[str, int],
|
arb_addr: tuple[str, int],
|
||||||
start_method: str,
|
start_method: str,
|
||||||
|
|
||||||
) -> tractor.MsgStream:
|
) -> tractor.MsgStream:
|
||||||
|
@ -83,7 +86,7 @@ async def open_sequence_streamer(
|
||||||
) as (ctx, first):
|
) as (ctx, first):
|
||||||
|
|
||||||
assert first is None
|
assert first is None
|
||||||
async with ctx.open_stream() as stream:
|
async with ctx.open_stream(backpressure=True) as stream:
|
||||||
yield stream
|
yield stream
|
||||||
|
|
||||||
await portal.cancel_actor()
|
await portal.cancel_actor()
|
||||||
|
@ -211,7 +214,8 @@ def test_faster_task_to_recv_is_cancelled_by_slower(
|
||||||
arb_addr,
|
arb_addr,
|
||||||
start_method,
|
start_method,
|
||||||
):
|
):
|
||||||
'''Ensure that if a faster task consuming from a stream is cancelled
|
'''
|
||||||
|
Ensure that if a faster task consuming from a stream is cancelled
|
||||||
the slower task can continue to receive all expected values.
|
the slower task can continue to receive all expected values.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
@ -334,7 +338,7 @@ def test_ensure_slow_consumers_lag_out(
|
||||||
|
|
||||||
if task.name == 'sub_1':
|
if task.name == 'sub_1':
|
||||||
# trigger checkpoint to clean out other subs
|
# trigger checkpoint to clean out other subs
|
||||||
await trio.sleep(0)
|
await trio.sleep(0.01)
|
||||||
|
|
||||||
# the non-lagger got
|
# the non-lagger got
|
||||||
# a ``trio.EndOfChannel``
|
# a ``trio.EndOfChannel``
|
||||||
|
@ -401,7 +405,7 @@ def test_ensure_slow_consumers_lag_out(
|
||||||
assert not tx._state.open_send_channels
|
assert not tx._state.open_send_channels
|
||||||
|
|
||||||
# check that "first" bcaster that we created
|
# check that "first" bcaster that we created
|
||||||
# above, never wass iterated and is thus overrun
|
# above, never was iterated and is thus overrun
|
||||||
try:
|
try:
|
||||||
await brx.receive()
|
await brx.receive()
|
||||||
except Lagged:
|
except Lagged:
|
||||||
|
@ -460,3 +464,51 @@ def test_first_recver_is_cancelled():
|
||||||
assert value == 1
|
assert value == 1
|
||||||
|
|
||||||
trio.run(main)
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_raise_on_lag():
|
||||||
|
'''
|
||||||
|
Run a simple 2-task broadcast where one task is slow but configured
|
||||||
|
so that it does not raise `Lagged` on overruns using
|
||||||
|
`raise_on_lasg=False` and verify that the task does not raise.
|
||||||
|
|
||||||
|
'''
|
||||||
|
size = 100
|
||||||
|
tx, rx = trio.open_memory_channel(size)
|
||||||
|
brx = broadcast_receiver(rx, size)
|
||||||
|
|
||||||
|
async def slow():
|
||||||
|
async with brx.subscribe(
|
||||||
|
raise_on_lag=False,
|
||||||
|
) as br:
|
||||||
|
async for msg in br:
|
||||||
|
print(f'slow task got: {msg}')
|
||||||
|
await trio.sleep(0.1)
|
||||||
|
|
||||||
|
async def fast():
|
||||||
|
async with brx.subscribe() as br:
|
||||||
|
async for msg in br:
|
||||||
|
print(f'fast task got: {msg}')
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
async with (
|
||||||
|
tractor.open_root_actor(
|
||||||
|
# NOTE: so we see the warning msg emitted by the bcaster
|
||||||
|
# internals when the no raise flag is set.
|
||||||
|
loglevel='warning',
|
||||||
|
),
|
||||||
|
trio.open_nursery() as n,
|
||||||
|
):
|
||||||
|
n.start_soon(slow)
|
||||||
|
n.start_soon(fast)
|
||||||
|
|
||||||
|
for i in range(1000):
|
||||||
|
await tx.send(i)
|
||||||
|
|
||||||
|
# simulate user nailing ctl-c after realizing
|
||||||
|
# there's a lag in the slow task.
|
||||||
|
await trio.sleep(1)
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
with pytest.raises(KeyboardInterrupt):
|
||||||
|
trio.run(main)
|
||||||
|
|
|
@ -0,0 +1,82 @@
|
||||||
|
'''
|
||||||
|
Reminders for oddities in `trio` that we need to stay aware of and/or
|
||||||
|
want to see changed.
|
||||||
|
|
||||||
|
'''
|
||||||
|
import pytest
|
||||||
|
import trio
|
||||||
|
from trio_typing import TaskStatus
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'use_start_soon', [
|
||||||
|
pytest.param(
|
||||||
|
True,
|
||||||
|
marks=pytest.mark.xfail(reason="see python-trio/trio#2258")
|
||||||
|
),
|
||||||
|
False,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
def test_stashed_child_nursery(use_start_soon):
|
||||||
|
|
||||||
|
_child_nursery = None
|
||||||
|
|
||||||
|
async def waits_on_signal(
|
||||||
|
ev: trio.Event(),
|
||||||
|
task_status: TaskStatus[trio.Nursery] = trio.TASK_STATUS_IGNORED,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Do some stuf, then signal other tasks, then yield back to "starter".
|
||||||
|
|
||||||
|
'''
|
||||||
|
await ev.wait()
|
||||||
|
task_status.started()
|
||||||
|
|
||||||
|
async def mk_child_nursery(
|
||||||
|
task_status: TaskStatus = trio.TASK_STATUS_IGNORED,
|
||||||
|
):
|
||||||
|
'''
|
||||||
|
Allocate a child sub-nursery and stash it as a global.
|
||||||
|
|
||||||
|
'''
|
||||||
|
nonlocal _child_nursery
|
||||||
|
|
||||||
|
async with trio.open_nursery() as cn:
|
||||||
|
_child_nursery = cn
|
||||||
|
task_status.started(cn)
|
||||||
|
|
||||||
|
# block until cancelled by parent.
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
async def sleep_and_err(
|
||||||
|
ev: trio.Event,
|
||||||
|
task_status: TaskStatus = trio.TASK_STATUS_IGNORED,
|
||||||
|
):
|
||||||
|
await trio.sleep(0.5)
|
||||||
|
doggy() # noqa
|
||||||
|
ev.set()
|
||||||
|
task_status.started()
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
async with (
|
||||||
|
trio.open_nursery() as pn,
|
||||||
|
):
|
||||||
|
cn = await pn.start(mk_child_nursery)
|
||||||
|
assert cn
|
||||||
|
|
||||||
|
ev = trio.Event()
|
||||||
|
|
||||||
|
if use_start_soon:
|
||||||
|
# this causes inf hang
|
||||||
|
cn.start_soon(sleep_and_err, ev)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# this does not.
|
||||||
|
await cn.start(sleep_and_err, ev)
|
||||||
|
|
||||||
|
with trio.fail_after(1):
|
||||||
|
await cn.start(waits_on_signal, ev)
|
||||||
|
|
||||||
|
with pytest.raises(NameError):
|
||||||
|
trio.run(main)
|
|
@ -1,7 +0,0 @@
|
||||||
[tool.towncrier]
|
|
||||||
package = "tractor"
|
|
||||||
filename = "NEWS.rst"
|
|
||||||
directory = "newsfragments/"
|
|
||||||
title_format = "tractor {version} ({project_date})"
|
|
||||||
version = "0.1.0a2"
|
|
||||||
#template = "changelog/_template.rst"
|
|
|
@ -1,40 +1,74 @@
|
||||||
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
tractor: An actor model micro-framework built on
|
tractor: structured concurrent "actors".
|
||||||
``trio`` and ``multiprocessing``.
|
|
||||||
"""
|
"""
|
||||||
from trio import MultiError
|
from exceptiongroup import BaseExceptionGroup
|
||||||
|
|
||||||
from ._clustering import open_actor_cluster
|
from ._clustering import open_actor_cluster
|
||||||
from ._ipc import Channel
|
from ._ipc import Channel
|
||||||
from ._streaming import (
|
from ._streaming import (
|
||||||
Context,
|
Context,
|
||||||
ReceiveMsgStream,
|
|
||||||
MsgStream,
|
MsgStream,
|
||||||
stream,
|
stream,
|
||||||
context,
|
context,
|
||||||
)
|
)
|
||||||
from ._discovery import get_arbiter, find_actor, wait_for_actor
|
from ._discovery import (
|
||||||
|
get_arbiter,
|
||||||
|
find_actor,
|
||||||
|
wait_for_actor,
|
||||||
|
query_actor,
|
||||||
|
)
|
||||||
from ._supervise import open_nursery
|
from ._supervise import open_nursery
|
||||||
from ._state import current_actor, is_root_process
|
from ._state import (
|
||||||
|
current_actor,
|
||||||
|
is_root_process,
|
||||||
|
)
|
||||||
from ._exceptions import (
|
from ._exceptions import (
|
||||||
RemoteActorError,
|
RemoteActorError,
|
||||||
ModuleNotExposed,
|
ModuleNotExposed,
|
||||||
ContextCancelled,
|
ContextCancelled,
|
||||||
)
|
)
|
||||||
from ._debug import breakpoint, post_mortem
|
from ._debug import (
|
||||||
|
breakpoint,
|
||||||
|
post_mortem,
|
||||||
|
)
|
||||||
from . import msg
|
from . import msg
|
||||||
from ._root import run, run_daemon, open_root_actor
|
from ._root import (
|
||||||
|
run_daemon,
|
||||||
|
open_root_actor,
|
||||||
|
)
|
||||||
from ._portal import Portal
|
from ._portal import Portal
|
||||||
|
from ._runtime import Actor
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
'Actor',
|
||||||
'Channel',
|
'Channel',
|
||||||
'Context',
|
'Context',
|
||||||
'ModuleNotExposed',
|
|
||||||
'MultiError',
|
|
||||||
'RemoteActorError',
|
|
||||||
'ContextCancelled',
|
'ContextCancelled',
|
||||||
|
'ModuleNotExposed',
|
||||||
|
'MsgStream',
|
||||||
|
'BaseExceptionGroup',
|
||||||
|
'Portal',
|
||||||
|
'RemoteActorError',
|
||||||
'breakpoint',
|
'breakpoint',
|
||||||
|
'context',
|
||||||
'current_actor',
|
'current_actor',
|
||||||
'find_actor',
|
'find_actor',
|
||||||
'get_arbiter',
|
'get_arbiter',
|
||||||
|
@ -43,14 +77,10 @@ __all__ = [
|
||||||
'open_actor_cluster',
|
'open_actor_cluster',
|
||||||
'open_nursery',
|
'open_nursery',
|
||||||
'open_root_actor',
|
'open_root_actor',
|
||||||
'Portal',
|
|
||||||
'post_mortem',
|
'post_mortem',
|
||||||
'run',
|
'query_actor',
|
||||||
'run_daemon',
|
'run_daemon',
|
||||||
'stream',
|
'stream',
|
||||||
'context',
|
|
||||||
'ReceiveMsgStream',
|
|
||||||
'MsgStream',
|
|
||||||
'to_asyncio',
|
'to_asyncio',
|
||||||
'wait_for_actor',
|
'wait_for_actor',
|
||||||
]
|
]
|
||||||
|
|
1338
tractor/_actor.py
1338
tractor/_actor.py
File diff suppressed because it is too large
Load Diff
|
@ -1,4 +1,22 @@
|
||||||
"""This is the "bootloader" for actors started using the native trio backend.
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""
|
||||||
|
This is the "bootloader" for actors started using the native trio backend.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
import trio
|
import trio
|
||||||
|
@ -6,7 +24,7 @@ import argparse
|
||||||
|
|
||||||
from ast import literal_eval
|
from ast import literal_eval
|
||||||
|
|
||||||
from ._actor import Actor
|
from ._runtime import Actor
|
||||||
from ._entry import _trio_main
|
from ._entry import _trio_main
|
||||||
|
|
||||||
|
|
||||||
|
@ -19,12 +37,15 @@ def parse_ipaddr(arg):
|
||||||
return (str(host), int(port))
|
return (str(host), int(port))
|
||||||
|
|
||||||
|
|
||||||
|
from ._entry import _trio_main
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--uid", type=parse_uid)
|
parser.add_argument("--uid", type=parse_uid)
|
||||||
parser.add_argument("--loglevel", type=str)
|
parser.add_argument("--loglevel", type=str)
|
||||||
parser.add_argument("--parent_addr", type=parse_ipaddr)
|
parser.add_argument("--parent_addr", type=parse_ipaddr)
|
||||||
|
parser.add_argument("--asyncio", action='store_true')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
subactor = Actor(
|
subactor = Actor(
|
||||||
|
@ -36,5 +57,6 @@ if __name__ == "__main__":
|
||||||
|
|
||||||
_trio_main(
|
_trio_main(
|
||||||
subactor,
|
subactor,
|
||||||
parent_addr=args.parent_addr
|
parent_addr=args.parent_addr,
|
||||||
|
infect_asyncio=args.asyncio,
|
||||||
)
|
)
|
|
@ -1,3 +1,19 @@
|
||||||
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
Actor cluster helpers.
|
Actor cluster helpers.
|
||||||
|
|
||||||
|
@ -16,12 +32,15 @@ import tractor
|
||||||
async def open_actor_cluster(
|
async def open_actor_cluster(
|
||||||
modules: list[str],
|
modules: list[str],
|
||||||
count: int = cpu_count(),
|
count: int = cpu_count(),
|
||||||
names: Optional[list[str]] = None,
|
names: list[str] | None = None,
|
||||||
start_method: Optional[str] = None,
|
|
||||||
hard_kill: bool = False,
|
hard_kill: bool = False,
|
||||||
|
|
||||||
|
# passed through verbatim to ``open_root_actor()``
|
||||||
|
**runtime_kwargs,
|
||||||
|
|
||||||
) -> AsyncGenerator[
|
) -> AsyncGenerator[
|
||||||
list[str],
|
dict[str, tractor.Portal],
|
||||||
dict[str, tractor.Portal]
|
None,
|
||||||
]:
|
]:
|
||||||
|
|
||||||
portals: dict[str, tractor.Portal] = {}
|
portals: dict[str, tractor.Portal] = {}
|
||||||
|
@ -33,12 +52,14 @@ async def open_actor_cluster(
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
'Number of names is {len(names)} but count it {count}')
|
'Number of names is {len(names)} but count it {count}')
|
||||||
|
|
||||||
async with tractor.open_nursery(start_method=start_method) as an:
|
async with tractor.open_nursery(
|
||||||
|
**runtime_kwargs,
|
||||||
|
) as an:
|
||||||
async with trio.open_nursery() as n:
|
async with trio.open_nursery() as n:
|
||||||
uid = tractor.current_actor().uid
|
uid = tractor.current_actor().uid
|
||||||
|
|
||||||
async def _start(name: str) -> None:
|
async def _start(name: str) -> None:
|
||||||
name = f'{name}.{uid}'
|
name = f'{uid[0]}.{name}'
|
||||||
portals[name] = await an.start_actor(
|
portals[name] = await an.start_actor(
|
||||||
enable_modules=modules,
|
enable_modules=modules,
|
||||||
name=name,
|
name=name,
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,9 +1,29 @@
|
||||||
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Actor discovery API.
|
Actor discovery API.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import typing
|
from typing import (
|
||||||
from typing import Tuple, Optional, Union
|
Optional,
|
||||||
from async_generator import asynccontextmanager
|
Union,
|
||||||
|
AsyncGenerator,
|
||||||
|
)
|
||||||
|
from contextlib import asynccontextmanager as acm
|
||||||
|
|
||||||
from ._ipc import _connect_chan, Channel
|
from ._ipc import _connect_chan, Channel
|
||||||
from ._portal import (
|
from ._portal import (
|
||||||
|
@ -14,13 +34,13 @@ from ._portal import (
|
||||||
from ._state import current_actor, _runtime_vars
|
from ._state import current_actor, _runtime_vars
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@acm
|
||||||
async def get_arbiter(
|
async def get_arbiter(
|
||||||
|
|
||||||
host: str,
|
host: str,
|
||||||
port: int,
|
port: int,
|
||||||
|
|
||||||
) -> typing.AsyncGenerator[Union[Portal, LocalPortal], None]:
|
) -> AsyncGenerator[Union[Portal, LocalPortal], None]:
|
||||||
'''Return a portal instance connected to a local or remote
|
'''Return a portal instance connected to a local or remote
|
||||||
arbiter.
|
arbiter.
|
||||||
'''
|
'''
|
||||||
|
@ -41,10 +61,10 @@ async def get_arbiter(
|
||||||
yield arb_portal
|
yield arb_portal
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@acm
|
||||||
async def get_root(
|
async def get_root(
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> typing.AsyncGenerator[Portal, None]:
|
) -> AsyncGenerator[Portal, None]:
|
||||||
|
|
||||||
host, port = _runtime_vars['_root_mailbox']
|
host, port = _runtime_vars['_root_mailbox']
|
||||||
assert host is not None
|
assert host is not None
|
||||||
|
@ -54,28 +74,56 @@ async def get_root(
|
||||||
yield portal
|
yield portal
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@acm
|
||||||
async def find_actor(
|
async def query_actor(
|
||||||
name: str,
|
name: str,
|
||||||
arbiter_sockaddr: Tuple[str, int] = None
|
arbiter_sockaddr: Optional[tuple[str, int]] = None,
|
||||||
) -> typing.AsyncGenerator[Optional[Portal], None]:
|
|
||||||
"""Ask the arbiter to find actor(s) by name.
|
|
||||||
|
|
||||||
Returns a connected portal to the last registered matching actor
|
) -> AsyncGenerator[tuple[str, int], None]:
|
||||||
known to the arbiter.
|
'''
|
||||||
"""
|
Simple address lookup for a given actor name.
|
||||||
|
|
||||||
|
Returns the (socket) address or ``None``.
|
||||||
|
|
||||||
|
'''
|
||||||
actor = current_actor()
|
actor = current_actor()
|
||||||
async with get_arbiter(*arbiter_sockaddr or actor._arb_addr) as arb_portal:
|
async with get_arbiter(
|
||||||
|
*arbiter_sockaddr or actor._arb_addr
|
||||||
|
) as arb_portal:
|
||||||
|
|
||||||
sockaddr = await arb_portal.run_from_ns('self', 'find_actor', name=name)
|
sockaddr = await arb_portal.run_from_ns(
|
||||||
|
'self',
|
||||||
|
'find_actor',
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
|
||||||
# TODO: return portals to all available actors - for now just
|
# TODO: return portals to all available actors - for now just
|
||||||
# the last one that registered
|
# the last one that registered
|
||||||
if name == 'arbiter' and actor.is_arbiter:
|
if name == 'arbiter' and actor.is_arbiter:
|
||||||
raise RuntimeError("The current actor is the arbiter")
|
raise RuntimeError("The current actor is the arbiter")
|
||||||
|
|
||||||
elif sockaddr:
|
yield sockaddr if sockaddr else None
|
||||||
|
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def find_actor(
|
||||||
|
name: str,
|
||||||
|
arbiter_sockaddr: tuple[str, int] | None = None
|
||||||
|
|
||||||
|
) -> AsyncGenerator[Optional[Portal], None]:
|
||||||
|
'''
|
||||||
|
Ask the arbiter to find actor(s) by name.
|
||||||
|
|
||||||
|
Returns a connected portal to the last registered matching actor
|
||||||
|
known to the arbiter.
|
||||||
|
|
||||||
|
'''
|
||||||
|
async with query_actor(
|
||||||
|
name=name,
|
||||||
|
arbiter_sockaddr=arbiter_sockaddr,
|
||||||
|
) as sockaddr:
|
||||||
|
|
||||||
|
if sockaddr:
|
||||||
async with _connect_chan(*sockaddr) as chan:
|
async with _connect_chan(*sockaddr) as chan:
|
||||||
async with open_portal(chan) as portal:
|
async with open_portal(chan) as portal:
|
||||||
yield portal
|
yield portal
|
||||||
|
@ -83,20 +131,25 @@ async def find_actor(
|
||||||
yield None
|
yield None
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@acm
|
||||||
async def wait_for_actor(
|
async def wait_for_actor(
|
||||||
name: str,
|
name: str,
|
||||||
arbiter_sockaddr: Tuple[str, int] = None
|
arbiter_sockaddr: tuple[str, int] | None = None
|
||||||
) -> typing.AsyncGenerator[Portal, None]:
|
) -> AsyncGenerator[Portal, None]:
|
||||||
"""Wait on an actor to register with the arbiter.
|
"""Wait on an actor to register with the arbiter.
|
||||||
|
|
||||||
A portal to the first registered actor is returned.
|
A portal to the first registered actor is returned.
|
||||||
"""
|
"""
|
||||||
actor = current_actor()
|
actor = current_actor()
|
||||||
|
|
||||||
async with get_arbiter(*arbiter_sockaddr or actor._arb_addr) as arb_portal:
|
async with get_arbiter(
|
||||||
|
*arbiter_sockaddr or actor._arb_addr,
|
||||||
sockaddrs = await arb_portal.run_from_ns('self', 'wait_for_actor', name=name)
|
) as arb_portal:
|
||||||
|
sockaddrs = await arb_portal.run_from_ns(
|
||||||
|
'self',
|
||||||
|
'wait_for_actor',
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
sockaddr = sockaddrs[-1]
|
sockaddr = sockaddrs[-1]
|
||||||
|
|
||||||
async with _connect_chan(*sockaddr) as chan:
|
async with _connect_chan(*sockaddr) as chan:
|
||||||
|
|
|
@ -1,28 +1,64 @@
|
||||||
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Sub-process entry points.
|
Sub-process entry points.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import Tuple, Any
|
from typing import (
|
||||||
import signal
|
Any,
|
||||||
|
TYPE_CHECKING,
|
||||||
|
)
|
||||||
|
|
||||||
import trio # type: ignore
|
import trio # type: ignore
|
||||||
|
|
||||||
from .log import get_console_log, get_logger
|
from .log import (
|
||||||
|
get_console_log,
|
||||||
|
get_logger,
|
||||||
|
)
|
||||||
from . import _state
|
from . import _state
|
||||||
|
from .to_asyncio import run_as_asyncio_guest
|
||||||
|
from ._runtime import (
|
||||||
|
async_main,
|
||||||
|
Actor,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ._spawn import SpawnMethodKey
|
||||||
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _mp_main(
|
def _mp_main(
|
||||||
actor: 'Actor', # type: ignore
|
|
||||||
accept_addr: Tuple[str, int],
|
actor: Actor, # type: ignore
|
||||||
forkserver_info: Tuple[Any, Any, Any, Any, Any],
|
accept_addr: tuple[str, int],
|
||||||
start_method: str,
|
forkserver_info: tuple[Any, Any, Any, Any, Any],
|
||||||
parent_addr: Tuple[str, int] = None,
|
start_method: SpawnMethodKey,
|
||||||
|
parent_addr: tuple[str, int] | None = None,
|
||||||
|
infect_asyncio: bool = False,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""The routine called *after fork* which invokes a fresh ``trio.run``
|
'''
|
||||||
"""
|
The routine called *after fork* which invokes a fresh ``trio.run``
|
||||||
|
|
||||||
|
'''
|
||||||
actor._forkserver_info = forkserver_info
|
actor._forkserver_info = forkserver_info
|
||||||
from ._spawn import try_set_start_method
|
from ._spawn import try_set_start_method
|
||||||
spawn_ctx = try_set_start_method(start_method)
|
spawn_ctx = try_set_start_method(start_method)
|
||||||
|
@ -40,12 +76,17 @@ def _mp_main(
|
||||||
|
|
||||||
log.debug(f"parent_addr is {parent_addr}")
|
log.debug(f"parent_addr is {parent_addr}")
|
||||||
trio_main = partial(
|
trio_main = partial(
|
||||||
actor._async_main,
|
async_main,
|
||||||
|
actor,
|
||||||
accept_addr,
|
accept_addr,
|
||||||
parent_addr=parent_addr
|
parent_addr=parent_addr
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
trio.run(trio_main)
|
if infect_asyncio:
|
||||||
|
actor._infected_aio = True
|
||||||
|
run_as_asyncio_guest(trio_main)
|
||||||
|
else:
|
||||||
|
trio.run(trio_main)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass # handle it the same way trio does?
|
pass # handle it the same way trio does?
|
||||||
|
|
||||||
|
@ -54,16 +95,17 @@ def _mp_main(
|
||||||
|
|
||||||
|
|
||||||
def _trio_main(
|
def _trio_main(
|
||||||
actor: 'Actor', # type: ignore
|
|
||||||
*,
|
|
||||||
parent_addr: Tuple[str, int] = None,
|
|
||||||
) -> None:
|
|
||||||
"""Entry point for a `trio_run_in_process` subactor.
|
|
||||||
"""
|
|
||||||
# Disable sigint handling in children;
|
|
||||||
# we don't need it thanks to our cancellation machinery.
|
|
||||||
# signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
||||||
|
|
||||||
|
actor: Actor, # type: ignore
|
||||||
|
*,
|
||||||
|
parent_addr: tuple[str, int] | None = None,
|
||||||
|
infect_asyncio: bool = False,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Entry point for a `trio_run_in_process` subactor.
|
||||||
|
|
||||||
|
'''
|
||||||
log.info(f"Started new trio process for {actor.uid}")
|
log.info(f"Started new trio process for {actor.uid}")
|
||||||
|
|
||||||
if actor.loglevel is not None:
|
if actor.loglevel is not None:
|
||||||
|
@ -78,12 +120,17 @@ def _trio_main(
|
||||||
|
|
||||||
log.debug(f"parent_addr is {parent_addr}")
|
log.debug(f"parent_addr is {parent_addr}")
|
||||||
trio_main = partial(
|
trio_main = partial(
|
||||||
actor._async_main,
|
async_main,
|
||||||
|
actor,
|
||||||
parent_addr=parent_addr
|
parent_addr=parent_addr
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
trio.run(trio_main)
|
if infect_asyncio:
|
||||||
|
actor._infected_aio = True
|
||||||
|
run_as_asyncio_guest(trio_main)
|
||||||
|
else:
|
||||||
|
trio.run(trio_main)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
log.warning(f"Actor {actor.uid} received KBI")
|
log.warning(f"Actor {actor.uid} received KBI")
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,33 @@
|
||||||
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Our classy exception set.
|
Our classy exception set.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from typing import Dict, Any, Optional, Type
|
from typing import (
|
||||||
|
Any,
|
||||||
|
Optional,
|
||||||
|
Type,
|
||||||
|
)
|
||||||
import importlib
|
import importlib
|
||||||
import builtins
|
import builtins
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
import exceptiongroup as eg
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
|
|
||||||
|
@ -31,9 +53,6 @@ class RemoteActorError(Exception):
|
||||||
self.type = suberror_type
|
self.type = suberror_type
|
||||||
self.msgdata = msgdata
|
self.msgdata = msgdata
|
||||||
|
|
||||||
# TODO: a trio.MultiError.catch like context manager
|
|
||||||
# for catching underlying remote errors of a particular type
|
|
||||||
|
|
||||||
|
|
||||||
class InternalActorError(RemoteActorError):
|
class InternalActorError(RemoteActorError):
|
||||||
"""Remote internal ``tractor`` error indicating
|
"""Remote internal ``tractor`` error indicating
|
||||||
|
@ -61,11 +80,24 @@ class NoRuntime(RuntimeError):
|
||||||
"The root actor has not been initialized yet"
|
"The root actor has not been initialized yet"
|
||||||
|
|
||||||
|
|
||||||
|
class StreamOverrun(trio.TooSlowError):
|
||||||
|
"This stream was overrun by sender"
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncioCancelled(Exception):
|
||||||
|
'''
|
||||||
|
Asyncio cancelled translation (non-base) error
|
||||||
|
for use with the ``to_asyncio`` module
|
||||||
|
to be raised in the ``trio`` side task
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
def pack_error(
|
def pack_error(
|
||||||
exc: BaseException,
|
exc: BaseException,
|
||||||
tb=None,
|
tb=None,
|
||||||
|
|
||||||
) -> Dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Create an "error message" for tranmission over
|
"""Create an "error message" for tranmission over
|
||||||
a channel (aka the wire).
|
a channel (aka the wire).
|
||||||
"""
|
"""
|
||||||
|
@ -84,15 +116,17 @@ def pack_error(
|
||||||
|
|
||||||
def unpack_error(
|
def unpack_error(
|
||||||
|
|
||||||
msg: Dict[str, Any],
|
msg: dict[str, Any],
|
||||||
chan=None,
|
chan=None,
|
||||||
err_type=RemoteActorError
|
err_type=RemoteActorError
|
||||||
|
|
||||||
) -> Exception:
|
) -> Exception:
|
||||||
"""Unpack an 'error' message from the wire
|
'''
|
||||||
|
Unpack an 'error' message from the wire
|
||||||
into a local ``RemoteActorError``.
|
into a local ``RemoteActorError``.
|
||||||
|
|
||||||
"""
|
'''
|
||||||
|
__tracebackhide__ = True
|
||||||
error = msg['error']
|
error = msg['error']
|
||||||
|
|
||||||
tb_str = error.get('tb_str', '')
|
tb_str = error.get('tb_str', '')
|
||||||
|
@ -105,7 +139,12 @@ def unpack_error(
|
||||||
suberror_type = trio.Cancelled
|
suberror_type = trio.Cancelled
|
||||||
|
|
||||||
else: # try to lookup a suitable local error type
|
else: # try to lookup a suitable local error type
|
||||||
for ns in [builtins, _this_mod, trio]:
|
for ns in [
|
||||||
|
builtins,
|
||||||
|
_this_mod,
|
||||||
|
eg,
|
||||||
|
trio,
|
||||||
|
]:
|
||||||
try:
|
try:
|
||||||
suberror_type = getattr(ns, type_name)
|
suberror_type = getattr(ns, type_name)
|
||||||
break
|
break
|
||||||
|
@ -124,12 +163,15 @@ def unpack_error(
|
||||||
|
|
||||||
|
|
||||||
def is_multi_cancelled(exc: BaseException) -> bool:
|
def is_multi_cancelled(exc: BaseException) -> bool:
|
||||||
"""Predicate to determine if a ``trio.MultiError`` contains only
|
'''
|
||||||
``trio.Cancelled`` sub-exceptions (and is likely the result of
|
Predicate to determine if a possible ``eg.BaseExceptionGroup`` contains
|
||||||
|
only ``trio.Cancelled`` sub-exceptions (and is likely the result of
|
||||||
cancelling a collection of subtasks.
|
cancelling a collection of subtasks.
|
||||||
|
|
||||||
"""
|
'''
|
||||||
return not trio.MultiError.filter(
|
if isinstance(exc, eg.BaseExceptionGroup):
|
||||||
lambda exc: exc if not isinstance(exc, trio.Cancelled) else None,
|
return exc.subgroup(
|
||||||
exc,
|
lambda exc: isinstance(exc, trio.Cancelled)
|
||||||
)
|
) is not None
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
|
@ -1,3 +1,19 @@
|
||||||
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
This is near-copy of the 3.8 stdlib's ``multiprocessing.forkserver.py``
|
This is near-copy of the 3.8 stdlib's ``multiprocessing.forkserver.py``
|
||||||
with some hackery to prevent any more then a single forkserver and
|
with some hackery to prevent any more then a single forkserver and
|
||||||
|
|
252
tractor/_ipc.py
252
tractor/_ipc.py
|
@ -1,3 +1,19 @@
|
||||||
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Inter-process comms abstractions
|
Inter-process comms abstractions
|
||||||
|
|
||||||
|
@ -6,13 +22,21 @@ from __future__ import annotations
|
||||||
import platform
|
import platform
|
||||||
import struct
|
import struct
|
||||||
import typing
|
import typing
|
||||||
|
from collections.abc import (
|
||||||
|
AsyncGenerator,
|
||||||
|
AsyncIterator,
|
||||||
|
)
|
||||||
from typing import (
|
from typing import (
|
||||||
Any, Tuple, Optional,
|
Any,
|
||||||
Type, Protocol, TypeVar
|
runtime_checkable,
|
||||||
|
Optional,
|
||||||
|
Protocol,
|
||||||
|
Type,
|
||||||
|
TypeVar,
|
||||||
)
|
)
|
||||||
|
|
||||||
from tricycle import BufferedReceiveStream
|
from tricycle import BufferedReceiveStream
|
||||||
import msgpack
|
import msgspec
|
||||||
import trio
|
import trio
|
||||||
from async_generator import asynccontextmanager
|
from async_generator import asynccontextmanager
|
||||||
|
|
||||||
|
@ -25,7 +49,7 @@ _is_windows = platform.system() == 'Windows'
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_stream_addrs(stream: trio.SocketStream) -> Tuple:
|
def get_stream_addrs(stream: trio.SocketStream) -> tuple:
|
||||||
# should both be IP sockets
|
# should both be IP sockets
|
||||||
lsockname = stream.socket.getsockname()
|
lsockname = stream.socket.getsockname()
|
||||||
rsockname = stream.socket.getpeername()
|
rsockname = stream.socket.getpeername()
|
||||||
|
@ -43,9 +67,11 @@ MsgType = TypeVar("MsgType")
|
||||||
# - https://jcristharif.com/msgspec/usage.html#structs
|
# - https://jcristharif.com/msgspec/usage.html#structs
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
class MsgTransport(Protocol[MsgType]):
|
class MsgTransport(Protocol[MsgType]):
|
||||||
|
|
||||||
stream: trio.SocketStream
|
stream: trio.SocketStream
|
||||||
|
drained: list[MsgType]
|
||||||
|
|
||||||
def __init__(self, stream: trio.SocketStream) -> None:
|
def __init__(self, stream: trio.SocketStream) -> None:
|
||||||
...
|
...
|
||||||
|
@ -63,23 +89,33 @@ class MsgTransport(Protocol[MsgType]):
|
||||||
def connected(self) -> bool:
|
def connected(self) -> bool:
|
||||||
...
|
...
|
||||||
|
|
||||||
@property
|
# defining this sync otherwise it causes a mypy error because it
|
||||||
def laddr(self) -> Tuple[str, int]:
|
# can't figure out it's a generator i guess?..?
|
||||||
|
def drain(self) -> AsyncIterator[dict]:
|
||||||
...
|
...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def raddr(self) -> Tuple[str, int]:
|
def laddr(self) -> tuple[str, int]:
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def raddr(self) -> tuple[str, int]:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
class MsgpackTCPStream:
|
# TODO: not sure why we have to inherit here, but it seems to be an
|
||||||
'''A ``trio.SocketStream`` delivering ``msgpack`` formatted data
|
# issue with ``get_msg_transport()`` returning a ``Type[Protocol]``;
|
||||||
using ``msgpack-python``.
|
# probably should make a `mypy` issue?
|
||||||
|
class MsgpackTCPStream(MsgTransport):
|
||||||
|
'''
|
||||||
|
A ``trio.SocketStream`` delivering ``msgpack`` formatted data
|
||||||
|
using the ``msgspec`` codec lib.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
stream: trio.SocketStream,
|
stream: trio.SocketStream,
|
||||||
|
prefix_size: int = 4,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
|
@ -93,103 +129,22 @@ class MsgpackTCPStream:
|
||||||
self._agen = self._iter_packets()
|
self._agen = self._iter_packets()
|
||||||
self._send_lock = trio.StrictFIFOLock()
|
self._send_lock = trio.StrictFIFOLock()
|
||||||
|
|
||||||
async def _iter_packets(self) -> typing.AsyncGenerator[dict, None]:
|
# public i guess?
|
||||||
"""Yield packets from the underlying stream.
|
self.drained: list[dict] = []
|
||||||
"""
|
|
||||||
unpacker = msgpack.Unpacker(
|
|
||||||
raw=False,
|
|
||||||
use_list=False,
|
|
||||||
strict_map_key=False
|
|
||||||
)
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
data = await self.stream.receive_some(2**10)
|
|
||||||
|
|
||||||
except trio.BrokenResourceError as err:
|
|
||||||
msg = err.args[0]
|
|
||||||
|
|
||||||
# XXX: handle connection-reset-by-peer the same as a EOF.
|
|
||||||
# we're currently remapping this since we allow
|
|
||||||
# a quick connect then drop for root actors when
|
|
||||||
# checking to see if there exists an "arbiter"
|
|
||||||
# on the chosen sockaddr (``_root.py:108`` or thereabouts)
|
|
||||||
if (
|
|
||||||
# nix
|
|
||||||
'[Errno 104]' in msg or
|
|
||||||
|
|
||||||
# on windows it seems there are a variety of errors
|
|
||||||
# to handle..
|
|
||||||
_is_windows
|
|
||||||
):
|
|
||||||
raise TransportClosed(
|
|
||||||
f'{self} was broken with {msg}'
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
log.transport(f"received {data}") # type: ignore
|
|
||||||
|
|
||||||
if data == b'':
|
|
||||||
raise TransportClosed(
|
|
||||||
f'transport {self} was already closed prior ro read'
|
|
||||||
)
|
|
||||||
|
|
||||||
unpacker.feed(data)
|
|
||||||
for packet in unpacker:
|
|
||||||
yield packet
|
|
||||||
|
|
||||||
@property
|
|
||||||
def laddr(self) -> Tuple[Any, ...]:
|
|
||||||
return self._laddr
|
|
||||||
|
|
||||||
@property
|
|
||||||
def raddr(self) -> Tuple[Any, ...]:
|
|
||||||
return self._raddr
|
|
||||||
|
|
||||||
async def send(self, msg: Any) -> None:
|
|
||||||
async with self._send_lock:
|
|
||||||
return await self.stream.send_all(
|
|
||||||
msgpack.dumps(msg, use_bin_type=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def recv(self) -> Any:
|
|
||||||
return await self._agen.asend(None)
|
|
||||||
|
|
||||||
def __aiter__(self):
|
|
||||||
return self._agen
|
|
||||||
|
|
||||||
def connected(self) -> bool:
|
|
||||||
return self.stream.socket.fileno() != -1
|
|
||||||
|
|
||||||
|
|
||||||
class MsgspecTCPStream(MsgpackTCPStream):
|
|
||||||
'''A ``trio.SocketStream`` delivering ``msgpack`` formatted data
|
|
||||||
using ``msgspec``.
|
|
||||||
|
|
||||||
'''
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
stream: trio.SocketStream,
|
|
||||||
prefix_size: int = 4,
|
|
||||||
|
|
||||||
) -> None:
|
|
||||||
import msgspec
|
|
||||||
|
|
||||||
super().__init__(stream)
|
|
||||||
self.recv_stream = BufferedReceiveStream(transport_stream=stream)
|
self.recv_stream = BufferedReceiveStream(transport_stream=stream)
|
||||||
self.prefix_size = prefix_size
|
self.prefix_size = prefix_size
|
||||||
|
|
||||||
# TODO: struct aware messaging coders
|
# TODO: struct aware messaging coders
|
||||||
self.encode = msgspec.Encoder().encode
|
self.encode = msgspec.msgpack.Encoder().encode
|
||||||
self.decode = msgspec.Decoder().decode # dict[str, Any])
|
self.decode = msgspec.msgpack.Decoder().decode # dict[str, Any])
|
||||||
|
|
||||||
async def _iter_packets(self) -> typing.AsyncGenerator[dict, None]:
|
async def _iter_packets(self) -> AsyncGenerator[dict, None]:
|
||||||
'''Yield packets from the underlying stream.
|
'''Yield packets from the underlying stream.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
import msgspec # noqa
|
import msgspec # noqa
|
||||||
last_decode_failed: bool = False
|
decodes_failed: int = 0
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
@ -197,6 +152,7 @@ class MsgspecTCPStream(MsgpackTCPStream):
|
||||||
|
|
||||||
except (
|
except (
|
||||||
ValueError,
|
ValueError,
|
||||||
|
ConnectionResetError,
|
||||||
|
|
||||||
# not sure entirely why we need this but without it we
|
# not sure entirely why we need this but without it we
|
||||||
# seem to be getting racy failures here on
|
# seem to be getting racy failures here on
|
||||||
|
@ -222,15 +178,24 @@ class MsgspecTCPStream(MsgpackTCPStream):
|
||||||
try:
|
try:
|
||||||
yield self.decode(msg_bytes)
|
yield self.decode(msg_bytes)
|
||||||
except (
|
except (
|
||||||
msgspec.DecodingError,
|
msgspec.DecodeError,
|
||||||
UnicodeDecodeError,
|
UnicodeDecodeError,
|
||||||
):
|
):
|
||||||
if not last_decode_failed:
|
if decodes_failed < 4:
|
||||||
# ignore decoding errors for now and assume they have to
|
# ignore decoding errors for now and assume they have to
|
||||||
# do with a channel drop - hope that receiving from the
|
# do with a channel drop - hope that receiving from the
|
||||||
# channel will raise an expected error and bubble up.
|
# channel will raise an expected error and bubble up.
|
||||||
log.error('`msgspec` failed to decode!?')
|
try:
|
||||||
last_decode_failed = True
|
msg_str: str | bytes = msg_bytes.decode()
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
msg_str = msg_bytes
|
||||||
|
|
||||||
|
log.error(
|
||||||
|
'`msgspec` failed to decode!?\n'
|
||||||
|
'dumping bytes:\n'
|
||||||
|
f'{msg_str!r}'
|
||||||
|
)
|
||||||
|
decodes_failed += 1
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
@ -245,31 +210,66 @@ class MsgspecTCPStream(MsgpackTCPStream):
|
||||||
|
|
||||||
return await self.stream.send_all(size + bytes_data)
|
return await self.stream.send_all(size + bytes_data)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def laddr(self) -> tuple[str, int]:
|
||||||
|
return self._laddr
|
||||||
|
|
||||||
|
@property
|
||||||
|
def raddr(self) -> tuple[str, int]:
|
||||||
|
return self._raddr
|
||||||
|
|
||||||
|
async def recv(self) -> Any:
|
||||||
|
return await self._agen.asend(None)
|
||||||
|
|
||||||
|
async def drain(self) -> AsyncIterator[dict]:
|
||||||
|
'''
|
||||||
|
Drain the stream's remaining messages sent from
|
||||||
|
the far end until the connection is closed by
|
||||||
|
the peer.
|
||||||
|
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
async for msg in self._iter_packets():
|
||||||
|
self.drained.append(msg)
|
||||||
|
except TransportClosed:
|
||||||
|
for msg in self.drained:
|
||||||
|
yield msg
|
||||||
|
|
||||||
|
def __aiter__(self):
|
||||||
|
return self._agen
|
||||||
|
|
||||||
|
def connected(self) -> bool:
|
||||||
|
return self.stream.socket.fileno() != -1
|
||||||
|
|
||||||
|
|
||||||
def get_msg_transport(
|
def get_msg_transport(
|
||||||
|
|
||||||
key: Tuple[str, str],
|
key: tuple[str, str],
|
||||||
|
|
||||||
) -> Type[MsgTransport]:
|
) -> Type[MsgTransport]:
|
||||||
|
|
||||||
return {
|
return {
|
||||||
('msgpack', 'tcp'): MsgpackTCPStream,
|
('msgpack', 'tcp'): MsgpackTCPStream,
|
||||||
('msgspec', 'tcp'): MsgspecTCPStream,
|
|
||||||
}[key]
|
}[key]
|
||||||
|
|
||||||
|
|
||||||
class Channel:
|
class Channel:
|
||||||
'''An inter-process channel for communication between (remote) actors.
|
'''
|
||||||
|
An inter-process channel for communication between (remote) actors.
|
||||||
|
|
||||||
Currently the only supported transport is a ``trio.SocketStream``.
|
Wraps a ``MsgStream``: transport + encoding IPC connection.
|
||||||
|
|
||||||
|
Currently we only support ``trio.SocketStream`` for transport
|
||||||
|
(aka TCP) and the ``msgpack`` interchange format via the ``msgspec``
|
||||||
|
codec libary.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
||||||
self,
|
self,
|
||||||
destaddr: Optional[Tuple[str, int]],
|
destaddr: Optional[tuple[str, int]],
|
||||||
|
|
||||||
msg_transport_type_key: Tuple[str, str] = ('msgpack', 'tcp'),
|
msg_transport_type_key: tuple[str, str] = ('msgpack', 'tcp'),
|
||||||
|
|
||||||
# TODO: optional reconnection support?
|
# TODO: optional reconnection support?
|
||||||
# auto_reconnect: bool = False,
|
# auto_reconnect: bool = False,
|
||||||
|
@ -280,14 +280,6 @@ class Channel:
|
||||||
# self._recon_seq = on_reconnect
|
# self._recon_seq = on_reconnect
|
||||||
# self._autorecon = auto_reconnect
|
# self._autorecon = auto_reconnect
|
||||||
|
|
||||||
# TODO: maybe expose this through the nursery api?
|
|
||||||
try:
|
|
||||||
# if installed load the msgspec transport since it's faster
|
|
||||||
import msgspec # noqa
|
|
||||||
msg_transport_type_key = ('msgspec', 'tcp')
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
self._destaddr = destaddr
|
self._destaddr = destaddr
|
||||||
self._transport_key = msg_transport_type_key
|
self._transport_key = msg_transport_type_key
|
||||||
|
|
||||||
|
@ -297,12 +289,14 @@ class Channel:
|
||||||
self.msgstream: Optional[MsgTransport] = None
|
self.msgstream: Optional[MsgTransport] = None
|
||||||
|
|
||||||
# set after handshake - always uid of far end
|
# set after handshake - always uid of far end
|
||||||
self.uid: Optional[Tuple[str, str]] = None
|
self.uid: Optional[tuple[str, str]] = None
|
||||||
|
|
||||||
# set if far end actor errors internally
|
|
||||||
self._exc: Optional[Exception] = None
|
|
||||||
self._agen = self._aiter_recv()
|
self._agen = self._aiter_recv()
|
||||||
|
self._exc: Optional[Exception] = None # set if far end actor errors
|
||||||
self._closed: bool = False
|
self._closed: bool = False
|
||||||
|
# flag set on ``Portal.cancel_actor()`` indicating
|
||||||
|
# remote (peer) cancellation of the far end actor runtime.
|
||||||
|
self._cancel_called: bool = False # set on ``Portal.cancel_actor()``
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_stream(
|
def from_stream(
|
||||||
|
@ -323,7 +317,7 @@ class Channel:
|
||||||
def set_msg_transport(
|
def set_msg_transport(
|
||||||
self,
|
self,
|
||||||
stream: trio.SocketStream,
|
stream: trio.SocketStream,
|
||||||
type_key: Optional[Tuple[str, str]] = None,
|
type_key: Optional[tuple[str, str]] = None,
|
||||||
|
|
||||||
) -> MsgTransport:
|
) -> MsgTransport:
|
||||||
type_key = type_key or self._transport_key
|
type_key = type_key or self._transport_key
|
||||||
|
@ -338,16 +332,16 @@ class Channel:
|
||||||
return object.__repr__(self)
|
return object.__repr__(self)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def laddr(self) -> Optional[Tuple[str, int]]:
|
def laddr(self) -> Optional[tuple[str, int]]:
|
||||||
return self.msgstream.laddr if self.msgstream else None
|
return self.msgstream.laddr if self.msgstream else None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def raddr(self) -> Optional[Tuple[str, int]]:
|
def raddr(self) -> Optional[tuple[str, int]]:
|
||||||
return self.msgstream.raddr if self.msgstream else None
|
return self.msgstream.raddr if self.msgstream else None
|
||||||
|
|
||||||
async def connect(
|
async def connect(
|
||||||
self,
|
self,
|
||||||
destaddr: Tuple[Any, ...] = None,
|
destaddr: tuple[Any, ...] | None = None,
|
||||||
**kwargs
|
**kwargs
|
||||||
|
|
||||||
) -> MsgTransport:
|
) -> MsgTransport:
|
||||||
|
@ -441,9 +435,11 @@ class Channel:
|
||||||
|
|
||||||
async def _aiter_recv(
|
async def _aiter_recv(
|
||||||
self
|
self
|
||||||
) -> typing.AsyncGenerator[Any, None]:
|
) -> AsyncGenerator[Any, None]:
|
||||||
"""Async iterate items from underlying stream.
|
'''
|
||||||
"""
|
Async iterate items from underlying stream.
|
||||||
|
|
||||||
|
'''
|
||||||
assert self.msgstream
|
assert self.msgstream
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
@ -473,9 +469,11 @@ class Channel:
|
||||||
async def _connect_chan(
|
async def _connect_chan(
|
||||||
host: str, port: int
|
host: str, port: int
|
||||||
) -> typing.AsyncGenerator[Channel, None]:
|
) -> typing.AsyncGenerator[Channel, None]:
|
||||||
"""Create and connect a channel with disconnect on context manager
|
'''
|
||||||
|
Create and connect a channel with disconnect on context manager
|
||||||
teardown.
|
teardown.
|
||||||
"""
|
|
||||||
|
'''
|
||||||
chan = Channel((host, port))
|
chan = Channel((host, port))
|
||||||
await chan.connect()
|
await chan.connect()
|
||||||
yield chan
|
yield chan
|
||||||
|
|
|
@ -1,23 +1,39 @@
|
||||||
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Helpers pulled mostly verbatim from ``multiprocessing.spawn``
|
Helpers pulled mostly verbatim from ``multiprocessing.spawn``
|
||||||
to aid with "fixing up" the ``__main__`` module in subprocesses.
|
to aid with "fixing up" the ``__main__`` module in subprocesses.
|
||||||
|
|
||||||
These helpers are needed for any spawing backend that doesn't already handle this.
|
These helpers are needed for any spawing backend that doesn't already
|
||||||
For example when using ``trio_run_in_process`` it is needed but obviously not when
|
handle this. For example when using ``trio_run_in_process`` it is needed
|
||||||
we're already using ``multiprocessing``.
|
but obviously not when we're already using ``multiprocessing``.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import platform
|
import platform
|
||||||
import types
|
import types
|
||||||
import runpy
|
import runpy
|
||||||
from typing import Dict
|
|
||||||
|
|
||||||
|
|
||||||
ORIGINAL_DIR = os.path.abspath(os.getcwd())
|
ORIGINAL_DIR = os.path.abspath(os.getcwd())
|
||||||
|
|
||||||
|
|
||||||
def _mp_figure_out_main() -> Dict[str, str]:
|
def _mp_figure_out_main() -> dict[str, str]:
|
||||||
"""Taken from ``multiprocessing.spawn.get_preparation_data()``.
|
"""Taken from ``multiprocessing.spawn.get_preparation_data()``.
|
||||||
|
|
||||||
Retrieve parent actor `__main__` module data.
|
Retrieve parent actor `__main__` module data.
|
||||||
|
|
|
@ -1,61 +1,80 @@
|
||||||
"""
|
# tractor: structured concurrent "actors".
|
||||||
Portal api
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
"""
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
'''
|
||||||
|
Memory boundary "Portals": an API for structured
|
||||||
|
concurrency linked tasks running in disparate memory domains.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
import importlib
|
import importlib
|
||||||
import inspect
|
import inspect
|
||||||
from typing import (
|
from typing import (
|
||||||
Tuple, Any, Dict, Optional, Set,
|
Any, Optional,
|
||||||
Callable, AsyncGenerator
|
Callable, AsyncGenerator,
|
||||||
|
Type,
|
||||||
)
|
)
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pprint import pformat
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
import trio
|
import trio
|
||||||
from async_generator import asynccontextmanager
|
from async_generator import asynccontextmanager
|
||||||
|
|
||||||
|
from .trionics import maybe_open_nursery
|
||||||
from ._state import current_actor
|
from ._state import current_actor
|
||||||
from ._ipc import Channel
|
from ._ipc import Channel
|
||||||
from .log import get_logger
|
from .log import get_logger
|
||||||
|
from .msg import NamespacePath
|
||||||
from ._exceptions import (
|
from ._exceptions import (
|
||||||
unpack_error,
|
unpack_error,
|
||||||
NoResult,
|
NoResult,
|
||||||
RemoteActorError,
|
|
||||||
ContextCancelled,
|
ContextCancelled,
|
||||||
)
|
)
|
||||||
from ._streaming import Context, ReceiveMsgStream
|
from ._streaming import (
|
||||||
|
Context,
|
||||||
|
MsgStream,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
def _unwrap_msg(
|
||||||
async def maybe_open_nursery(
|
msg: dict[str, Any],
|
||||||
nursery: trio.Nursery = None,
|
channel: Channel
|
||||||
shield: bool = False,
|
|
||||||
) -> AsyncGenerator[trio.Nursery, Any]:
|
|
||||||
"""Create a new nursery if None provided.
|
|
||||||
|
|
||||||
Blocks on exit as expected if no input nursery is provided.
|
) -> Any:
|
||||||
"""
|
__tracebackhide__ = True
|
||||||
if nursery is not None:
|
try:
|
||||||
yield nursery
|
return msg['return']
|
||||||
else:
|
except KeyError:
|
||||||
async with trio.open_nursery() as nursery:
|
# internal error should never get here
|
||||||
nursery.cancel_scope.shield = shield
|
assert msg.get('cid'), "Received internal error at portal?"
|
||||||
yield nursery
|
raise unpack_error(msg, channel) from None
|
||||||
|
|
||||||
|
|
||||||
def func_deats(func: Callable) -> Tuple[str, str]:
|
class MessagingError(Exception):
|
||||||
return (
|
'Some kind of unexpected SC messaging dialog issue'
|
||||||
func.__module__,
|
|
||||||
func.__name__,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Portal:
|
class Portal:
|
||||||
"""A 'portal' to a(n) (remote) ``Actor``.
|
'''
|
||||||
|
A 'portal' to a(n) (remote) ``Actor``.
|
||||||
|
|
||||||
A portal is "opened" (and eventually closed) by one side of an
|
A portal is "opened" (and eventually closed) by one side of an
|
||||||
inter-actor communication context. The side which opens the portal
|
inter-actor communication context. The side which opens the portal
|
||||||
|
@ -71,77 +90,56 @@ class Portal:
|
||||||
function calling semantics are supported transparently; hence it is
|
function calling semantics are supported transparently; hence it is
|
||||||
like having a "portal" between the seperate actor memory spaces.
|
like having a "portal" between the seperate actor memory spaces.
|
||||||
|
|
||||||
"""
|
'''
|
||||||
|
# the timeout for a remote cancel request sent to
|
||||||
|
# a(n) (peer) actor.
|
||||||
|
cancel_timeout = 0.5
|
||||||
|
|
||||||
def __init__(self, channel: Channel) -> None:
|
def __init__(self, channel: Channel) -> None:
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
# when this is set to a tuple returned from ``_submit()`` then
|
|
||||||
# it is expected that ``result()`` will be awaited at some point
|
|
||||||
# during the portal's lifetime
|
# during the portal's lifetime
|
||||||
self._result: Optional[Any] = None
|
self._result_msg: Optional[dict] = None
|
||||||
# set when _submit_for_result is called
|
|
||||||
self._expect_result: Optional[
|
# When set to a ``Context`` (when _submit_for_result is called)
|
||||||
Tuple[str, Any, str, Dict[str, Any]]
|
# it is expected that ``result()`` will be awaited at some
|
||||||
] = None
|
# point.
|
||||||
self._streams: Set[ReceiveMsgStream] = set()
|
self._expect_result: Optional[Context] = None
|
||||||
|
self._streams: set[MsgStream] = set()
|
||||||
self.actor = current_actor()
|
self.actor = current_actor()
|
||||||
|
|
||||||
async def _submit(
|
async def _submit_for_result(
|
||||||
self,
|
self,
|
||||||
ns: str,
|
ns: str,
|
||||||
func: str,
|
func: str,
|
||||||
kwargs,
|
**kwargs
|
||||||
) -> Tuple[str, trio.MemoryReceiveChannel, str, Dict[str, Any]]:
|
) -> None:
|
||||||
"""Submit a function to be scheduled and run by actor, return the
|
|
||||||
associated caller id, response queue, response type str,
|
|
||||||
first message packet as a tuple.
|
|
||||||
|
|
||||||
This is an async call.
|
|
||||||
"""
|
|
||||||
# ship a function call request to the remote actor
|
|
||||||
cid, recv_chan = await self.actor.send_cmd(
|
|
||||||
self.channel, ns, func, kwargs)
|
|
||||||
|
|
||||||
# wait on first response msg and handle (this should be
|
|
||||||
# in an immediate response)
|
|
||||||
|
|
||||||
first_msg = await recv_chan.receive()
|
|
||||||
functype = first_msg.get('functype')
|
|
||||||
|
|
||||||
if 'error' in first_msg:
|
|
||||||
raise unpack_error(first_msg, self.channel)
|
|
||||||
|
|
||||||
elif functype not in ('asyncfunc', 'asyncgen', 'context'):
|
|
||||||
raise ValueError(f"{first_msg} is an invalid response packet?")
|
|
||||||
|
|
||||||
return cid, recv_chan, functype, first_msg
|
|
||||||
|
|
||||||
async def _submit_for_result(self, ns: str, func: str, **kwargs) -> None:
|
|
||||||
|
|
||||||
assert self._expect_result is None, \
|
assert self._expect_result is None, \
|
||||||
"A pending main result has already been submitted"
|
"A pending main result has already been submitted"
|
||||||
|
|
||||||
self._expect_result = await self._submit(ns, func, kwargs)
|
self._expect_result = await self.actor.start_remote_task(
|
||||||
|
self.channel,
|
||||||
|
ns,
|
||||||
|
func,
|
||||||
|
kwargs
|
||||||
|
)
|
||||||
|
|
||||||
async def _return_once(
|
async def _return_once(
|
||||||
self,
|
self,
|
||||||
cid: str,
|
ctx: Context,
|
||||||
recv_chan: trio.abc.ReceiveChannel,
|
|
||||||
resptype: str,
|
|
||||||
first_msg: dict
|
|
||||||
) -> Any:
|
|
||||||
assert resptype == 'asyncfunc' # single response
|
|
||||||
|
|
||||||
msg = await recv_chan.receive()
|
) -> dict[str, Any]:
|
||||||
try:
|
|
||||||
return msg['return']
|
assert ctx._remote_func_type == 'asyncfunc' # single response
|
||||||
except KeyError:
|
msg = await ctx._recv_chan.receive()
|
||||||
# internal error should never get here
|
return msg
|
||||||
assert msg.get('cid'), "Received internal error at portal?"
|
|
||||||
raise unpack_error(msg, self.channel)
|
|
||||||
|
|
||||||
async def result(self) -> Any:
|
async def result(self) -> Any:
|
||||||
"""Return the result(s) from the remote actor's "main" task.
|
'''
|
||||||
"""
|
Return the result(s) from the remote actor's "main" task.
|
||||||
|
|
||||||
|
'''
|
||||||
|
# __tracebackhide__ = True
|
||||||
# Check for non-rpc errors slapped on the
|
# Check for non-rpc errors slapped on the
|
||||||
# channel for which we always raise
|
# channel for which we always raise
|
||||||
exc = self.channel._exc
|
exc = self.channel._exc
|
||||||
|
@ -158,17 +156,13 @@ class Portal:
|
||||||
|
|
||||||
# expecting a "main" result
|
# expecting a "main" result
|
||||||
assert self._expect_result
|
assert self._expect_result
|
||||||
if self._result is None:
|
|
||||||
try:
|
|
||||||
self._result = await self._return_once(*self._expect_result)
|
|
||||||
except RemoteActorError as err:
|
|
||||||
self._result = err
|
|
||||||
|
|
||||||
# re-raise error on every call
|
if self._result_msg is None:
|
||||||
if isinstance(self._result, RemoteActorError):
|
self._result_msg = await self._return_once(
|
||||||
raise self._result
|
self._expect_result
|
||||||
|
)
|
||||||
|
|
||||||
return self._result
|
return _unwrap_msg(self._result_msg, self.channel)
|
||||||
|
|
||||||
async def _cancel_streams(self):
|
async def _cancel_streams(self):
|
||||||
# terminate all locally running async generator
|
# terminate all locally running async generator
|
||||||
|
@ -193,36 +187,47 @@ class Portal:
|
||||||
# we'll need to .aclose all those channels here
|
# we'll need to .aclose all those channels here
|
||||||
await self._cancel_streams()
|
await self._cancel_streams()
|
||||||
|
|
||||||
async def cancel_actor(self):
|
async def cancel_actor(
|
||||||
"""Cancel the actor on the other end of this portal.
|
self,
|
||||||
"""
|
timeout: float | None = None,
|
||||||
if not self.channel.connected():
|
|
||||||
log.cancel("This portal is already closed can't cancel")
|
|
||||||
return False
|
|
||||||
|
|
||||||
await self._cancel_streams()
|
) -> bool:
|
||||||
|
'''
|
||||||
|
Cancel the actor on the other end of this portal.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if not self.channel.connected():
|
||||||
|
log.cancel("This channel is already closed can't cancel")
|
||||||
|
return False
|
||||||
|
|
||||||
log.cancel(
|
log.cancel(
|
||||||
f"Sending actor cancel request to {self.channel.uid} on "
|
f"Sending actor cancel request to {self.channel.uid} on "
|
||||||
f"{self.channel}")
|
f"{self.channel}")
|
||||||
|
|
||||||
|
self.channel._cancel_called = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# send cancel cmd - might not get response
|
# send cancel cmd - might not get response
|
||||||
# XXX: sure would be nice to make this work with a proper shield
|
# XXX: sure would be nice to make this work with a proper shield
|
||||||
with trio.move_on_after(0.5) as cancel_scope:
|
with trio.move_on_after(timeout or self.cancel_timeout) as cs:
|
||||||
cancel_scope.shield = True
|
cs.shield = True
|
||||||
|
|
||||||
await self.run_from_ns('self', 'cancel')
|
await self.run_from_ns('self', 'cancel')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if cancel_scope.cancelled_caught:
|
if cs.cancelled_caught:
|
||||||
log.cancel(f"May have failed to cancel {self.channel.uid}")
|
log.cancel(f"May have failed to cancel {self.channel.uid}")
|
||||||
|
|
||||||
# if we get here some weird cancellation case happened
|
# if we get here some weird cancellation case happened
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except trio.ClosedResourceError:
|
except (
|
||||||
|
trio.ClosedResourceError,
|
||||||
|
trio.BrokenResourceError,
|
||||||
|
):
|
||||||
log.cancel(
|
log.cancel(
|
||||||
f"{self.channel} for {self.channel.uid} was already closed?")
|
f"{self.channel} for {self.channel.uid} was already "
|
||||||
|
"closed or broken?")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def run_from_ns(
|
async def run_from_ns(
|
||||||
|
@ -231,7 +236,9 @@ class Portal:
|
||||||
function_name: str,
|
function_name: str,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Run a function from a (remote) namespace in a new task on the far-end actor.
|
'''
|
||||||
|
Run a function from a (remote) namespace in a new task on the
|
||||||
|
far-end actor.
|
||||||
|
|
||||||
This is a more explitcit way to run tasks in a remote-process
|
This is a more explitcit way to run tasks in a remote-process
|
||||||
actor using explicit object-path syntax. Hint: this is how
|
actor using explicit object-path syntax. Hint: this is how
|
||||||
|
@ -240,12 +247,20 @@ class Portal:
|
||||||
Note::
|
Note::
|
||||||
|
|
||||||
A special namespace `self` can be used to invoke `Actor`
|
A special namespace `self` can be used to invoke `Actor`
|
||||||
instance methods in the remote runtime. Currently this should only
|
instance methods in the remote runtime. Currently this
|
||||||
be used for `tractor` internals.
|
should only be used solely for ``tractor`` runtime
|
||||||
"""
|
internals.
|
||||||
return await self._return_once(
|
|
||||||
*(await self._submit(namespace_path, function_name, kwargs))
|
'''
|
||||||
|
ctx = await self.actor.start_remote_task(
|
||||||
|
self.channel,
|
||||||
|
namespace_path,
|
||||||
|
function_name,
|
||||||
|
kwargs,
|
||||||
)
|
)
|
||||||
|
ctx._portal = self
|
||||||
|
msg = await self._return_once(ctx)
|
||||||
|
return _unwrap_msg(msg, self.channel)
|
||||||
|
|
||||||
async def run(
|
async def run(
|
||||||
self,
|
self,
|
||||||
|
@ -253,12 +268,14 @@ class Portal:
|
||||||
fn_name: Optional[str] = None,
|
fn_name: Optional[str] = None,
|
||||||
**kwargs
|
**kwargs
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Submit a remote function to be scheduled and run by actor, in
|
'''
|
||||||
|
Submit a remote function to be scheduled and run by actor, in
|
||||||
a new task, wrap and return its (stream of) result(s).
|
a new task, wrap and return its (stream of) result(s).
|
||||||
|
|
||||||
This is a blocking call and returns either a value from the
|
This is a blocking call and returns either a value from the
|
||||||
remote rpc task or a local async generator instance.
|
remote rpc task or a local async generator instance.
|
||||||
"""
|
|
||||||
|
'''
|
||||||
if isinstance(func, str):
|
if isinstance(func, str):
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
"`Portal.run(namespace: str, funcname: str)` is now"
|
"`Portal.run(namespace: str, funcname: str)` is now"
|
||||||
|
@ -282,10 +299,18 @@ class Portal:
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
f'{func} must be a non-streaming async function!')
|
f'{func} must be a non-streaming async function!')
|
||||||
|
|
||||||
fn_mod_path, fn_name = func_deats(func)
|
fn_mod_path, fn_name = NamespacePath.from_ref(func).to_tuple()
|
||||||
|
|
||||||
return await self._return_once(
|
ctx = await self.actor.start_remote_task(
|
||||||
*(await self._submit(fn_mod_path, fn_name, kwargs))
|
self.channel,
|
||||||
|
fn_mod_path,
|
||||||
|
fn_name,
|
||||||
|
kwargs,
|
||||||
|
)
|
||||||
|
ctx._portal = self
|
||||||
|
return _unwrap_msg(
|
||||||
|
await self._return_once(ctx),
|
||||||
|
self.channel,
|
||||||
)
|
)
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
@ -294,7 +319,7 @@ class Portal:
|
||||||
async_gen_func: Callable, # typing: ignore
|
async_gen_func: Callable, # typing: ignore
|
||||||
**kwargs,
|
**kwargs,
|
||||||
|
|
||||||
) -> AsyncGenerator[ReceiveMsgStream, None]:
|
) -> AsyncGenerator[MsgStream, None]:
|
||||||
|
|
||||||
if not inspect.isasyncgenfunction(async_gen_func):
|
if not inspect.isasyncgenfunction(async_gen_func):
|
||||||
if not (
|
if not (
|
||||||
|
@ -304,28 +329,23 @@ class Portal:
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
f'{async_gen_func} must be an async generator function!')
|
f'{async_gen_func} must be an async generator function!')
|
||||||
|
|
||||||
fn_mod_path, fn_name = func_deats(async_gen_func)
|
fn_mod_path, fn_name = NamespacePath.from_ref(
|
||||||
(
|
async_gen_func).to_tuple()
|
||||||
cid,
|
ctx = await self.actor.start_remote_task(
|
||||||
recv_chan,
|
|
||||||
functype,
|
|
||||||
first_msg
|
|
||||||
) = await self._submit(fn_mod_path, fn_name, kwargs)
|
|
||||||
|
|
||||||
# receive only stream
|
|
||||||
assert functype == 'asyncgen'
|
|
||||||
|
|
||||||
ctx = Context(
|
|
||||||
self.channel,
|
self.channel,
|
||||||
cid,
|
fn_mod_path,
|
||||||
# do we need this to be closed implicitly?
|
fn_name,
|
||||||
# _recv_chan=recv_chan,
|
kwargs
|
||||||
_portal=self
|
|
||||||
)
|
)
|
||||||
|
ctx._portal = self
|
||||||
|
|
||||||
|
# ensure receive-only stream entrypoint
|
||||||
|
assert ctx._remote_func_type == 'asyncgen'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# deliver receive only stream
|
# deliver receive only stream
|
||||||
async with ReceiveMsgStream(
|
async with MsgStream(
|
||||||
ctx, recv_chan,
|
ctx, ctx._recv_chan,
|
||||||
) as rchan:
|
) as rchan:
|
||||||
self._streams.add(rchan)
|
self._streams.add(rchan)
|
||||||
yield rchan
|
yield rchan
|
||||||
|
@ -359,8 +379,9 @@ class Portal:
|
||||||
func: Callable,
|
func: Callable,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
|
|
||||||
) -> AsyncGenerator[Tuple[Context, Any], None]:
|
) -> AsyncGenerator[tuple[Context, Any], None]:
|
||||||
'''Open an inter-actor task context.
|
'''
|
||||||
|
Open an inter-actor task context.
|
||||||
|
|
||||||
This is a synchronous API which allows for deterministic
|
This is a synchronous API which allows for deterministic
|
||||||
setup/teardown of a remote task. The yielded ``Context`` further
|
setup/teardown of a remote task. The yielded ``Context`` further
|
||||||
|
@ -368,7 +389,6 @@ class Portal:
|
||||||
and synchronized final result collection. See ``tractor.Context``.
|
and synchronized final result collection. See ``tractor.Context``.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
|
||||||
# conduct target func method structural checks
|
# conduct target func method structural checks
|
||||||
if not inspect.iscoroutinefunction(func) and (
|
if not inspect.iscoroutinefunction(func) and (
|
||||||
getattr(func, '_tractor_contex_function', False)
|
getattr(func, '_tractor_contex_function', False)
|
||||||
|
@ -376,44 +396,49 @@ class Portal:
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
f'{func} must be an async generator function!')
|
f'{func} must be an async generator function!')
|
||||||
|
|
||||||
fn_mod_path, fn_name = func_deats(func)
|
fn_mod_path, fn_name = NamespacePath.from_ref(func).to_tuple()
|
||||||
|
|
||||||
recv_chan: Optional[trio.MemoryReceiveChannel] = None
|
ctx = await self.actor.start_remote_task(
|
||||||
|
self.channel,
|
||||||
|
fn_mod_path,
|
||||||
|
fn_name,
|
||||||
|
kwargs
|
||||||
|
)
|
||||||
|
|
||||||
cid, recv_chan, functype, first_msg = await self._submit(
|
assert ctx._remote_func_type == 'context'
|
||||||
fn_mod_path, fn_name, kwargs)
|
msg = await ctx._recv_chan.receive()
|
||||||
|
|
||||||
assert functype == 'context'
|
|
||||||
msg = await recv_chan.receive()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# the "first" value here is delivered by the callee's
|
# the "first" value here is delivered by the callee's
|
||||||
# ``Context.started()`` call.
|
# ``Context.started()`` call.
|
||||||
first = msg['started']
|
first = msg['started']
|
||||||
|
ctx._started_called = True
|
||||||
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
assert msg.get('cid'), ("Received internal error at context?")
|
assert msg.get('cid'), ("Received internal error at context?")
|
||||||
|
|
||||||
if msg.get('error'):
|
if msg.get('error'):
|
||||||
# raise the error message
|
# raise kerr from unpack_error(msg, self.channel)
|
||||||
raise unpack_error(msg, self.channel)
|
raise unpack_error(msg, self.channel) from None
|
||||||
else:
|
else:
|
||||||
raise
|
raise MessagingError(
|
||||||
|
f'Context for {ctx.cid} was expecting a `started` message'
|
||||||
|
f' but received a non-error msg:\n{pformat(msg)}'
|
||||||
|
)
|
||||||
|
|
||||||
_err: Optional[BaseException] = None
|
_err: Optional[BaseException] = None
|
||||||
|
ctx._portal = self
|
||||||
|
|
||||||
|
uid = self.channel.uid
|
||||||
|
cid = ctx.cid
|
||||||
|
etype: Optional[Type[BaseException]] = None
|
||||||
|
|
||||||
# deliver context instance and .started() msg value in open tuple.
|
# deliver context instance and .started() msg value in open tuple.
|
||||||
try:
|
try:
|
||||||
async with trio.open_nursery() as scope_nursery:
|
async with trio.open_nursery() as scope_nursery:
|
||||||
ctx = Context(
|
ctx._scope_nursery = scope_nursery
|
||||||
self.channel,
|
|
||||||
cid,
|
|
||||||
_portal=self,
|
|
||||||
_recv_chan=recv_chan,
|
|
||||||
_scope_nursery=scope_nursery,
|
|
||||||
)
|
|
||||||
|
|
||||||
# pairs with handling in ``Actor._push_result()``
|
# do we need this?
|
||||||
# recv_chan._ctx = ctx
|
|
||||||
# await trio.lowlevel.checkpoint()
|
# await trio.lowlevel.checkpoint()
|
||||||
|
|
||||||
yield ctx, first
|
yield ctx, first
|
||||||
|
@ -435,19 +460,31 @@ class Portal:
|
||||||
except (
|
except (
|
||||||
BaseException,
|
BaseException,
|
||||||
|
|
||||||
# more specifically, we need to handle:
|
# more specifically, we need to handle these but not
|
||||||
|
# sure it's worth being pedantic:
|
||||||
# Exception,
|
# Exception,
|
||||||
# trio.Cancelled,
|
# trio.Cancelled,
|
||||||
# trio.MultiError,
|
|
||||||
# KeyboardInterrupt,
|
# KeyboardInterrupt,
|
||||||
|
|
||||||
) as err:
|
) as err:
|
||||||
_err = err
|
etype = type(err)
|
||||||
# the context cancels itself on any cancel
|
# the context cancels itself on any cancel
|
||||||
# causing error.
|
# causing error.
|
||||||
log.cancel(f'Context {ctx} sending cancel to far end')
|
|
||||||
with trio.CancelScope(shield=True):
|
if ctx.chan.connected():
|
||||||
|
log.cancel(
|
||||||
|
'Context cancelled for task, sending cancel request..\n'
|
||||||
|
f'task:{cid}\n'
|
||||||
|
f'actor:{uid}'
|
||||||
|
)
|
||||||
await ctx.cancel()
|
await ctx.cancel()
|
||||||
|
else:
|
||||||
|
log.warning(
|
||||||
|
'IPC connection for context is broken?\n'
|
||||||
|
f'task:{cid}\n'
|
||||||
|
f'actor:{uid}'
|
||||||
|
)
|
||||||
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
@ -456,46 +493,74 @@ class Portal:
|
||||||
# sure we get the error the underlying feeder mem chan.
|
# sure we get the error the underlying feeder mem chan.
|
||||||
# if it's not raised here it *should* be raised from the
|
# if it's not raised here it *should* be raised from the
|
||||||
# msg loop nursery right?
|
# msg loop nursery right?
|
||||||
result = await ctx.result()
|
if ctx.chan.connected():
|
||||||
|
log.info(
|
||||||
# though it should be impossible for any tasks
|
'Waiting on final context-task result for\n'
|
||||||
# operating *in* this scope to have survived
|
f'task: {cid}\n'
|
||||||
# we tear down the runtime feeder chan last
|
f'actor: {uid}'
|
||||||
# to avoid premature stream clobbers.
|
)
|
||||||
if recv_chan is not None:
|
result = await ctx.result()
|
||||||
await recv_chan.aclose()
|
|
||||||
|
|
||||||
if _err:
|
|
||||||
if ctx._cancel_called:
|
|
||||||
log.cancel(
|
|
||||||
f'Context {fn_name} cancelled by caller with\n{_err}'
|
|
||||||
)
|
|
||||||
elif _err is not None:
|
|
||||||
log.cancel(
|
|
||||||
f'Context {fn_name} cancelled by callee with\n{_err}'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
log.runtime(
|
log.runtime(
|
||||||
f'Context {fn_name} returned '
|
f'Context {fn_name} returned '
|
||||||
f'value from callee `{result}`'
|
f'value from callee `{result}`'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# though it should be impossible for any tasks
|
||||||
|
# operating *in* this scope to have survived
|
||||||
|
# we tear down the runtime feeder chan last
|
||||||
|
# to avoid premature stream clobbers.
|
||||||
|
if ctx._recv_chan is not None:
|
||||||
|
# should we encapsulate this in the context api?
|
||||||
|
await ctx._recv_chan.aclose()
|
||||||
|
|
||||||
|
if etype:
|
||||||
|
if ctx._cancel_called:
|
||||||
|
log.cancel(
|
||||||
|
f'Context {fn_name} cancelled by caller with\n{etype}'
|
||||||
|
)
|
||||||
|
elif _err is not None:
|
||||||
|
log.cancel(
|
||||||
|
f'Context for task cancelled by callee with {etype}\n'
|
||||||
|
f'target: `{fn_name}`\n'
|
||||||
|
f'task:{cid}\n'
|
||||||
|
f'actor:{uid}'
|
||||||
|
)
|
||||||
|
# XXX: (MEGA IMPORTANT) if this is a root opened process we
|
||||||
|
# wait for any immediate child in debug before popping the
|
||||||
|
# context from the runtime msg loop otherwise inside
|
||||||
|
# ``Actor._push_result()`` the msg will be discarded and in
|
||||||
|
# the case where that msg is global debugger unlock (via
|
||||||
|
# a "stop" msg for a stream), this can result in a deadlock
|
||||||
|
# where the root is waiting on the lock to clear but the
|
||||||
|
# child has already cleared it and clobbered IPC.
|
||||||
|
from ._debug import maybe_wait_for_debugger
|
||||||
|
await maybe_wait_for_debugger()
|
||||||
|
|
||||||
|
# remove the context from runtime tracking
|
||||||
|
self.actor._contexts.pop(
|
||||||
|
(self.channel.uid, ctx.cid),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LocalPortal:
|
class LocalPortal:
|
||||||
"""A 'portal' to a local ``Actor``.
|
'''
|
||||||
|
A 'portal' to a local ``Actor``.
|
||||||
|
|
||||||
A compatibility shim for normal portals but for invoking functions
|
A compatibility shim for normal portals but for invoking functions
|
||||||
using an in process actor instance.
|
using an in process actor instance.
|
||||||
"""
|
|
||||||
|
'''
|
||||||
actor: 'Actor' # type: ignore # noqa
|
actor: 'Actor' # type: ignore # noqa
|
||||||
channel: Channel
|
channel: Channel
|
||||||
|
|
||||||
async def run_from_ns(self, ns: str, func_name: str, **kwargs) -> Any:
|
async def run_from_ns(self, ns: str, func_name: str, **kwargs) -> Any:
|
||||||
"""Run a requested local function from a namespace path and
|
'''
|
||||||
|
Run a requested local function from a namespace path and
|
||||||
return it's result.
|
return it's result.
|
||||||
|
|
||||||
"""
|
'''
|
||||||
obj = self.actor if ns == 'self' else importlib.import_module(ns)
|
obj = self.actor if ns == 'self' else importlib.import_module(ns)
|
||||||
func = getattr(obj, func_name)
|
func = getattr(obj, func_name)
|
||||||
return await func(**kwargs)
|
return await func(**kwargs)
|
||||||
|
@ -510,10 +575,13 @@ async def open_portal(
|
||||||
shield: bool = False,
|
shield: bool = False,
|
||||||
|
|
||||||
) -> AsyncGenerator[Portal, None]:
|
) -> AsyncGenerator[Portal, None]:
|
||||||
"""Open a ``Portal`` through the provided ``channel``.
|
'''
|
||||||
|
Open a ``Portal`` through the provided ``channel``.
|
||||||
|
|
||||||
Spawns a background task to handle message processing.
|
Spawns a background task to handle message processing (normally
|
||||||
"""
|
done by the actor-runtime implicitly).
|
||||||
|
|
||||||
|
'''
|
||||||
actor = current_actor()
|
actor = current_actor()
|
||||||
assert actor
|
assert actor
|
||||||
was_connected = False
|
was_connected = False
|
||||||
|
@ -529,9 +597,11 @@ async def open_portal(
|
||||||
|
|
||||||
msg_loop_cs: Optional[trio.CancelScope] = None
|
msg_loop_cs: Optional[trio.CancelScope] = None
|
||||||
if start_msg_loop:
|
if start_msg_loop:
|
||||||
|
from ._runtime import process_messages
|
||||||
msg_loop_cs = await nursery.start(
|
msg_loop_cs = await nursery.start(
|
||||||
partial(
|
partial(
|
||||||
actor._process_messages,
|
process_messages,
|
||||||
|
actor,
|
||||||
channel,
|
channel,
|
||||||
# if the local task is cancelled we want to keep
|
# if the local task is cancelled we want to keep
|
||||||
# the msg loop running until our block ends
|
# the msg loop running until our block ends
|
||||||
|
@ -541,7 +611,6 @@ async def open_portal(
|
||||||
portal = Portal(channel)
|
portal = Portal(channel)
|
||||||
try:
|
try:
|
||||||
yield portal
|
yield portal
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
await portal.aclose()
|
await portal.aclose()
|
||||||
|
|
||||||
|
|
217
tractor/_root.py
217
tractor/_root.py
|
@ -1,17 +1,42 @@
|
||||||
"""
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
'''
|
||||||
Root actor runtime ignition(s).
|
Root actor runtime ignition(s).
|
||||||
"""
|
|
||||||
|
'''
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from functools import partial
|
from functools import partial
|
||||||
import importlib
|
import importlib
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
import os
|
import os
|
||||||
from typing import Tuple, Optional, List, Any
|
|
||||||
import typing
|
import typing
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
|
|
||||||
|
from exceptiongroup import BaseExceptionGroup
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
from ._actor import Actor, Arbiter
|
from ._runtime import (
|
||||||
|
Actor,
|
||||||
|
Arbiter,
|
||||||
|
async_main,
|
||||||
|
)
|
||||||
from . import _debug
|
from . import _debug
|
||||||
from . import _spawn
|
from . import _spawn
|
||||||
from . import _state
|
from . import _state
|
||||||
|
@ -31,37 +56,45 @@ logger = log.get_logger('tractor')
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def open_root_actor(
|
async def open_root_actor(
|
||||||
|
|
||||||
|
*,
|
||||||
# defaults are above
|
# defaults are above
|
||||||
arbiter_addr: Optional[Tuple[str, int]] = (
|
arbiter_addr: tuple[str, int] | None = None,
|
||||||
_default_arbiter_host,
|
|
||||||
_default_arbiter_port,
|
|
||||||
),
|
|
||||||
|
|
||||||
name: Optional[str] = 'root',
|
# defaults are above
|
||||||
|
registry_addr: tuple[str, int] | None = None,
|
||||||
|
|
||||||
|
name: str | None = 'root',
|
||||||
|
|
||||||
# either the `multiprocessing` start method:
|
# either the `multiprocessing` start method:
|
||||||
# https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods
|
# https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods
|
||||||
# OR `trio` (the new default).
|
# OR `trio` (the new default).
|
||||||
start_method: Optional[str] = None,
|
start_method: _spawn.SpawnMethodKey | None = None,
|
||||||
|
|
||||||
# enables the multi-process debugger support
|
# enables the multi-process debugger support
|
||||||
debug_mode: bool = False,
|
debug_mode: bool = False,
|
||||||
|
|
||||||
# internal logging
|
# internal logging
|
||||||
loglevel: Optional[str] = None,
|
loglevel: str | None = None,
|
||||||
|
|
||||||
enable_modules: Optional[List] = None,
|
enable_modules: list | None = None,
|
||||||
rpc_module_paths: Optional[List] = None,
|
rpc_module_paths: list | None = None,
|
||||||
|
|
||||||
) -> typing.Any:
|
) -> typing.Any:
|
||||||
"""Async entry point for ``tractor``.
|
'''
|
||||||
|
Runtime init entry point for ``tractor``.
|
||||||
|
|
||||||
"""
|
'''
|
||||||
# Override the global debugger hook to make it play nice with
|
# Override the global debugger hook to make it play nice with
|
||||||
# ``trio``, see:
|
# ``trio``, see much discussion in:
|
||||||
# https://github.com/python-trio/trio/issues/1155#issuecomment-742964018
|
# https://github.com/python-trio/trio/issues/1155#issuecomment-742964018
|
||||||
|
builtin_bp_handler = sys.breakpointhook
|
||||||
|
orig_bp_path: str | None = os.environ.get('PYTHONBREAKPOINT', None)
|
||||||
os.environ['PYTHONBREAKPOINT'] = 'tractor._debug._set_trace'
|
os.environ['PYTHONBREAKPOINT'] = 'tractor._debug._set_trace'
|
||||||
|
|
||||||
|
# attempt to retreive ``trio``'s sigint handler and stash it
|
||||||
|
# on our debugger lock state.
|
||||||
|
_debug.Lock._trio_handler = signal.getsignal(signal.SIGINT)
|
||||||
|
|
||||||
# mark top most level process as root actor
|
# mark top most level process as root actor
|
||||||
_state._runtime_vars['_is_root'] = True
|
_state._runtime_vars['_is_root'] = True
|
||||||
|
|
||||||
|
@ -80,6 +113,25 @@ async def open_root_actor(
|
||||||
if start_method is not None:
|
if start_method is not None:
|
||||||
_spawn.try_set_start_method(start_method)
|
_spawn.try_set_start_method(start_method)
|
||||||
|
|
||||||
|
if arbiter_addr is not None:
|
||||||
|
warnings.warn(
|
||||||
|
'`arbiter_addr` is now deprecated and has been renamed to'
|
||||||
|
'`registry_addr`.\nUse that instead..',
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
registry_addr = (host, port) = (
|
||||||
|
registry_addr
|
||||||
|
or arbiter_addr
|
||||||
|
or (
|
||||||
|
_default_arbiter_host,
|
||||||
|
_default_arbiter_port,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
loglevel = (loglevel or log._default_loglevel).upper()
|
||||||
|
|
||||||
if debug_mode and _spawn._spawn_method == 'trio':
|
if debug_mode and _spawn._spawn_method == 'trio':
|
||||||
_state._runtime_vars['_debug_mode'] = True
|
_state._runtime_vars['_debug_mode'] = True
|
||||||
|
|
||||||
|
@ -87,38 +139,41 @@ async def open_root_actor(
|
||||||
# for use of ``await tractor.breakpoint()``
|
# for use of ``await tractor.breakpoint()``
|
||||||
enable_modules.append('tractor._debug')
|
enable_modules.append('tractor._debug')
|
||||||
|
|
||||||
if loglevel is None:
|
# if debug mode get's enabled *at least* use that level of
|
||||||
loglevel = 'pdb'
|
# logging for some informative console prompts.
|
||||||
|
if (
|
||||||
|
logging.getLevelName(
|
||||||
|
# lul, need the upper case for the -> int map?
|
||||||
|
# sweet "dynamic function behaviour" stdlib...
|
||||||
|
loglevel,
|
||||||
|
) > logging.getLevelName('PDB')
|
||||||
|
):
|
||||||
|
loglevel = 'PDB'
|
||||||
|
|
||||||
elif debug_mode:
|
elif debug_mode:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Debug mode is only supported for the `trio` backend!"
|
"Debug mode is only supported for the `trio` backend!"
|
||||||
)
|
)
|
||||||
|
|
||||||
arbiter_addr = (host, port) = arbiter_addr or (
|
log.get_console_log(loglevel)
|
||||||
_default_arbiter_host,
|
|
||||||
_default_arbiter_port,
|
|
||||||
)
|
|
||||||
|
|
||||||
loglevel = loglevel or log.get_loglevel()
|
|
||||||
if loglevel is not None:
|
|
||||||
log._default_loglevel = loglevel
|
|
||||||
log.get_console_log(loglevel)
|
|
||||||
|
|
||||||
# make a temporary connection to see if an arbiter exists
|
|
||||||
arbiter_found = False
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# make a temporary connection to see if an arbiter exists,
|
||||||
|
# if one can't be made quickly we assume none exists.
|
||||||
|
arbiter_found = False
|
||||||
|
|
||||||
# TODO: this connect-and-bail forces us to have to carefully
|
# TODO: this connect-and-bail forces us to have to carefully
|
||||||
# rewrap TCP 104-connection-reset errors as EOF so as to avoid
|
# rewrap TCP 104-connection-reset errors as EOF so as to avoid
|
||||||
# propagating cancel-causing errors to the channel-msg loop
|
# propagating cancel-causing errors to the channel-msg loop
|
||||||
# machinery. Likely it would be better to eventually have
|
# machinery. Likely it would be better to eventually have
|
||||||
# a "discovery" protocol with basic handshake instead.
|
# a "discovery" protocol with basic handshake instead.
|
||||||
async with _connect_chan(host, port):
|
with trio.move_on_after(1):
|
||||||
arbiter_found = True
|
async with _connect_chan(host, port):
|
||||||
|
arbiter_found = True
|
||||||
|
|
||||||
except OSError:
|
except OSError:
|
||||||
logger.warning(f"No actor could be found @ {host}:{port}")
|
# TODO: make this a "discovery" log level?
|
||||||
|
logger.warning(f"No actor registry found @ {host}:{port}")
|
||||||
|
|
||||||
# create a local actor and start up its main routine/task
|
# create a local actor and start up its main routine/task
|
||||||
if arbiter_found:
|
if arbiter_found:
|
||||||
|
@ -128,7 +183,7 @@ async def open_root_actor(
|
||||||
|
|
||||||
actor = Actor(
|
actor = Actor(
|
||||||
name or 'anonymous',
|
name or 'anonymous',
|
||||||
arbiter_addr=arbiter_addr,
|
arbiter_addr=registry_addr,
|
||||||
loglevel=loglevel,
|
loglevel=loglevel,
|
||||||
enable_modules=enable_modules,
|
enable_modules=enable_modules,
|
||||||
)
|
)
|
||||||
|
@ -144,7 +199,7 @@ async def open_root_actor(
|
||||||
|
|
||||||
actor = Arbiter(
|
actor = Arbiter(
|
||||||
name or 'arbiter',
|
name or 'arbiter',
|
||||||
arbiter_addr=arbiter_addr,
|
arbiter_addr=registry_addr,
|
||||||
loglevel=loglevel,
|
loglevel=loglevel,
|
||||||
enable_modules=enable_modules,
|
enable_modules=enable_modules,
|
||||||
)
|
)
|
||||||
|
@ -160,13 +215,14 @@ async def open_root_actor(
|
||||||
# start the actor runtime in a new task
|
# start the actor runtime in a new task
|
||||||
async with trio.open_nursery() as nursery:
|
async with trio.open_nursery() as nursery:
|
||||||
|
|
||||||
# ``Actor._async_main()`` creates an internal nursery and
|
# ``_runtime.async_main()`` creates an internal nursery and
|
||||||
# thus blocks here until the entire underlying actor tree has
|
# thus blocks here until the entire underlying actor tree has
|
||||||
# terminated thereby conducting structured concurrency.
|
# terminated thereby conducting structured concurrency.
|
||||||
|
|
||||||
await nursery.start(
|
await nursery.start(
|
||||||
partial(
|
partial(
|
||||||
actor._async_main,
|
async_main,
|
||||||
|
actor,
|
||||||
accept_addr=(host, port),
|
accept_addr=(host, port),
|
||||||
parent_addr=None
|
parent_addr=None
|
||||||
)
|
)
|
||||||
|
@ -174,7 +230,10 @@ async def open_root_actor(
|
||||||
try:
|
try:
|
||||||
yield actor
|
yield actor
|
||||||
|
|
||||||
except (Exception, trio.MultiError) as err:
|
except (
|
||||||
|
Exception,
|
||||||
|
BaseExceptionGroup,
|
||||||
|
) as err:
|
||||||
|
|
||||||
entered = await _debug._maybe_enter_pm(err)
|
entered = await _debug._maybe_enter_pm(err)
|
||||||
|
|
||||||
|
@ -185,71 +244,69 @@ async def open_root_actor(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
# NOTE: not sure if we'll ever need this but it's
|
||||||
|
# possibly better for even more determinism?
|
||||||
|
# logger.cancel(
|
||||||
|
# f'Waiting on {len(nurseries)} nurseries in root..')
|
||||||
|
# nurseries = actor._actoruid2nursery.values()
|
||||||
|
# async with trio.open_nursery() as tempn:
|
||||||
|
# for an in nurseries:
|
||||||
|
# tempn.start_soon(an.exited.wait)
|
||||||
|
|
||||||
logger.cancel("Shutting down root actor")
|
logger.cancel("Shutting down root actor")
|
||||||
await actor.cancel()
|
await actor.cancel()
|
||||||
finally:
|
finally:
|
||||||
_state._current_actor = None
|
_state._current_actor = None
|
||||||
|
|
||||||
|
# restore breakpoint hook state
|
||||||
|
sys.breakpointhook = builtin_bp_handler
|
||||||
|
if orig_bp_path is not None:
|
||||||
|
os.environ['PYTHONBREAKPOINT'] = orig_bp_path
|
||||||
|
else:
|
||||||
|
# clear env back to having no entry
|
||||||
|
os.environ.pop('PYTHONBREAKPOINT')
|
||||||
|
|
||||||
logger.runtime("Root actor terminated")
|
logger.runtime("Root actor terminated")
|
||||||
|
|
||||||
|
|
||||||
def run(
|
def run_daemon(
|
||||||
|
enable_modules: list[str],
|
||||||
# target
|
|
||||||
async_fn: typing.Callable[..., typing.Awaitable],
|
|
||||||
*args,
|
|
||||||
|
|
||||||
# runtime kwargs
|
# runtime kwargs
|
||||||
name: Optional[str] = 'root',
|
name: str | None = 'root',
|
||||||
arbiter_addr: Tuple[str, int] = (
|
registry_addr: tuple[str, int] = (
|
||||||
_default_arbiter_host,
|
_default_arbiter_host,
|
||||||
_default_arbiter_port,
|
_default_arbiter_port,
|
||||||
),
|
),
|
||||||
|
|
||||||
start_method: Optional[str] = None,
|
start_method: str | None = None,
|
||||||
debug_mode: bool = False,
|
debug_mode: bool = False,
|
||||||
**kwargs,
|
**kwargs
|
||||||
|
|
||||||
) -> Any:
|
) -> None:
|
||||||
"""Run a trio-actor async function in process.
|
'''
|
||||||
|
Spawn daemon actor which will respond to RPC; the main task simply
|
||||||
|
starts the runtime and then sleeps forever.
|
||||||
|
|
||||||
|
This is a very minimal convenience wrapper around starting
|
||||||
|
a "run-until-cancelled" root actor which can be started with a set
|
||||||
|
of enabled modules for RPC request handling.
|
||||||
|
|
||||||
|
'''
|
||||||
|
kwargs['enable_modules'] = list(enable_modules)
|
||||||
|
|
||||||
|
for path in enable_modules:
|
||||||
|
importlib.import_module(path)
|
||||||
|
|
||||||
This is tractor's main entry and the start point for any async actor.
|
|
||||||
"""
|
|
||||||
async def _main():
|
async def _main():
|
||||||
|
|
||||||
async with open_root_actor(
|
async with open_root_actor(
|
||||||
arbiter_addr=arbiter_addr,
|
registry_addr=registry_addr,
|
||||||
name=name,
|
name=name,
|
||||||
start_method=start_method,
|
start_method=start_method,
|
||||||
debug_mode=debug_mode,
|
debug_mode=debug_mode,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
|
return await trio.sleep_forever()
|
||||||
|
|
||||||
return await async_fn(*args)
|
|
||||||
|
|
||||||
warnings.warn(
|
|
||||||
"`tractor.run()` is now deprecated. `tractor` now"
|
|
||||||
" implicitly starts the root actor on first actor nursery"
|
|
||||||
" use. If you want to start the root actor manually, use"
|
|
||||||
" `tractor.open_root_actor()`.",
|
|
||||||
DeprecationWarning,
|
|
||||||
stacklevel=2,
|
|
||||||
)
|
|
||||||
return trio.run(_main)
|
return trio.run(_main)
|
||||||
|
|
||||||
|
|
||||||
def run_daemon(
|
|
||||||
rpc_module_paths: List[str],
|
|
||||||
**kwargs
|
|
||||||
) -> None:
|
|
||||||
"""Spawn daemon actor which will respond to RPC.
|
|
||||||
|
|
||||||
This is a convenience wrapper around
|
|
||||||
``tractor.run(trio.sleep(float('inf')))`` such that the first actor spawned
|
|
||||||
is meant to run forever responding to RPC requests.
|
|
||||||
"""
|
|
||||||
kwargs['rpc_module_paths'] = list(rpc_module_paths)
|
|
||||||
|
|
||||||
for path in rpc_module_paths:
|
|
||||||
importlib.import_module(path)
|
|
||||||
|
|
||||||
return run(partial(trio.sleep, float('inf')), **kwargs)
|
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,27 +1,39 @@
|
||||||
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Machinery for actor process spawning using multiple backends.
|
Machinery for actor process spawning using multiple backends.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
import sys
|
import sys
|
||||||
import multiprocessing as mp
|
|
||||||
import platform
|
import platform
|
||||||
from typing import Any, Dict, Optional
|
from typing import (
|
||||||
|
Any,
|
||||||
|
Awaitable,
|
||||||
|
Literal,
|
||||||
|
Callable,
|
||||||
|
TypeVar,
|
||||||
|
TYPE_CHECKING,
|
||||||
|
)
|
||||||
|
|
||||||
|
from exceptiongroup import BaseExceptionGroup
|
||||||
import trio
|
import trio
|
||||||
from trio_typing import TaskStatus
|
from trio_typing import TaskStatus
|
||||||
|
|
||||||
try:
|
|
||||||
from multiprocessing import semaphore_tracker # type: ignore
|
|
||||||
resource_tracker = semaphore_tracker
|
|
||||||
resource_tracker._resource_tracker = resource_tracker._semaphore_tracker
|
|
||||||
except ImportError:
|
|
||||||
# 3.8 introduces a more general version that also tracks shared mems
|
|
||||||
from multiprocessing import resource_tracker # type: ignore
|
|
||||||
|
|
||||||
from multiprocessing import forkserver # type: ignore
|
|
||||||
from typing import Tuple
|
|
||||||
|
|
||||||
from . import _forkserver_override
|
|
||||||
from ._debug import (
|
from ._debug import (
|
||||||
maybe_wait_for_debugger,
|
maybe_wait_for_debugger,
|
||||||
acquire_debug_lock,
|
acquire_debug_lock,
|
||||||
|
@ -32,23 +44,33 @@ from ._state import (
|
||||||
is_root_process,
|
is_root_process,
|
||||||
debug_mode,
|
debug_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .log import get_logger
|
from .log import get_logger
|
||||||
from ._portal import Portal
|
from ._portal import Portal
|
||||||
from ._actor import Actor
|
from ._runtime import Actor
|
||||||
from ._entry import _mp_main
|
from ._entry import _mp_main
|
||||||
from ._exceptions import ActorFailure
|
from ._exceptions import ActorFailure
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ._supervise import ActorNursery
|
||||||
|
import multiprocessing as mp
|
||||||
|
ProcessType = TypeVar('ProcessType', mp.Process, trio.Process)
|
||||||
|
|
||||||
log = get_logger('tractor')
|
log = get_logger('tractor')
|
||||||
|
|
||||||
# placeholder for an mp start context if so using that backend
|
# placeholder for an mp start context if so using that backend
|
||||||
_ctx: Optional[mp.context.BaseContext] = None
|
_ctx: mp.context.BaseContext | None = None
|
||||||
_spawn_method: str = "trio"
|
SpawnMethodKey = Literal[
|
||||||
|
'trio', # supported on all platforms
|
||||||
|
'mp_spawn',
|
||||||
|
'mp_forkserver', # posix only
|
||||||
|
]
|
||||||
|
_spawn_method: SpawnMethodKey = 'trio'
|
||||||
|
|
||||||
|
|
||||||
if platform.system() == 'Windows':
|
if platform.system() == 'Windows':
|
||||||
|
|
||||||
|
import multiprocessing as mp
|
||||||
_ctx = mp.get_context("spawn")
|
_ctx = mp.get_context("spawn")
|
||||||
|
|
||||||
async def proc_waiter(proc: mp.Process) -> None:
|
async def proc_waiter(proc: mp.Process) -> None:
|
||||||
|
@ -60,51 +82,64 @@ else:
|
||||||
await trio.lowlevel.wait_readable(proc.sentinel)
|
await trio.lowlevel.wait_readable(proc.sentinel)
|
||||||
|
|
||||||
|
|
||||||
def try_set_start_method(name: str) -> Optional[mp.context.BaseContext]:
|
def try_set_start_method(
|
||||||
"""Attempt to set the method for process starting, aka the "actor
|
key: SpawnMethodKey
|
||||||
|
|
||||||
|
) -> mp.context.BaseContext | None:
|
||||||
|
'''
|
||||||
|
Attempt to set the method for process starting, aka the "actor
|
||||||
spawning backend".
|
spawning backend".
|
||||||
|
|
||||||
If the desired method is not supported this function will error.
|
If the desired method is not supported this function will error.
|
||||||
On Windows only the ``multiprocessing`` "spawn" method is offered
|
On Windows only the ``multiprocessing`` "spawn" method is offered
|
||||||
besides the default ``trio`` which uses async wrapping around
|
besides the default ``trio`` which uses async wrapping around
|
||||||
``subprocess.Popen``.
|
``subprocess.Popen``.
|
||||||
"""
|
|
||||||
|
'''
|
||||||
|
import multiprocessing as mp
|
||||||
global _ctx
|
global _ctx
|
||||||
global _spawn_method
|
global _spawn_method
|
||||||
|
|
||||||
methods = mp.get_all_start_methods()
|
mp_methods = mp.get_all_start_methods()
|
||||||
if 'fork' in methods:
|
if 'fork' in mp_methods:
|
||||||
# forking is incompatible with ``trio``s global task tree
|
# forking is incompatible with ``trio``s global task tree
|
||||||
methods.remove('fork')
|
mp_methods.remove('fork')
|
||||||
|
|
||||||
# supported on all platforms
|
match key:
|
||||||
methods += ['trio']
|
case 'mp_forkserver':
|
||||||
|
from . import _forkserver_override
|
||||||
|
_forkserver_override.override_stdlib()
|
||||||
|
_ctx = mp.get_context('forkserver')
|
||||||
|
|
||||||
if name not in methods:
|
case 'mp_spawn':
|
||||||
raise ValueError(
|
_ctx = mp.get_context('spawn')
|
||||||
f"Spawn method `{name}` is invalid please choose one of {methods}"
|
|
||||||
)
|
|
||||||
elif name == 'forkserver':
|
|
||||||
_forkserver_override.override_stdlib()
|
|
||||||
_ctx = mp.get_context(name)
|
|
||||||
elif name == 'trio':
|
|
||||||
_ctx = None
|
|
||||||
else:
|
|
||||||
_ctx = mp.get_context(name)
|
|
||||||
|
|
||||||
_spawn_method = name
|
case 'trio':
|
||||||
|
_ctx = None
|
||||||
|
|
||||||
|
case _:
|
||||||
|
raise ValueError(
|
||||||
|
f'Spawn method `{key}` is invalid!\n'
|
||||||
|
f'Please choose one of {SpawnMethodKey}'
|
||||||
|
)
|
||||||
|
|
||||||
|
_spawn_method = key
|
||||||
return _ctx
|
return _ctx
|
||||||
|
|
||||||
|
|
||||||
async def exhaust_portal(
|
async def exhaust_portal(
|
||||||
|
|
||||||
portal: Portal,
|
portal: Portal,
|
||||||
actor: Actor
|
actor: Actor
|
||||||
|
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Pull final result from portal (assuming it has one).
|
'''
|
||||||
|
Pull final result from portal (assuming it has one).
|
||||||
|
|
||||||
If the main task is an async generator do our best to consume
|
If the main task is an async generator do our best to consume
|
||||||
what's left of it.
|
what's left of it.
|
||||||
"""
|
'''
|
||||||
|
__tracebackhide__ = True
|
||||||
try:
|
try:
|
||||||
log.debug(f"Waiting on final result from {actor.uid}")
|
log.debug(f"Waiting on final result from {actor.uid}")
|
||||||
|
|
||||||
|
@ -112,8 +147,11 @@ async def exhaust_portal(
|
||||||
# always be established and shutdown using a context manager api
|
# always be established and shutdown using a context manager api
|
||||||
final = await portal.result()
|
final = await portal.result()
|
||||||
|
|
||||||
except (Exception, trio.MultiError) as err:
|
except (
|
||||||
# we reraise in the parent task via a ``trio.MultiError``
|
Exception,
|
||||||
|
BaseExceptionGroup,
|
||||||
|
) as err:
|
||||||
|
# we reraise in the parent task via a ``BaseExceptionGroup``
|
||||||
return err
|
return err
|
||||||
except trio.Cancelled as err:
|
except trio.Cancelled as err:
|
||||||
# lol, of course we need this too ;P
|
# lol, of course we need this too ;P
|
||||||
|
@ -126,21 +164,22 @@ async def exhaust_portal(
|
||||||
|
|
||||||
|
|
||||||
async def cancel_on_completion(
|
async def cancel_on_completion(
|
||||||
|
|
||||||
portal: Portal,
|
portal: Portal,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
errors: Dict[Tuple[str, str], Exception],
|
errors: dict[tuple[str, str], Exception],
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
'''
|
||||||
Cancel actor gracefully once it's "main" portal's
|
Cancel actor gracefully once it's "main" portal's
|
||||||
result arrives.
|
result arrives.
|
||||||
|
|
||||||
Should only be called for actors spawned with `run_in_actor()`.
|
Should only be called for actors spawned with `run_in_actor()`.
|
||||||
|
|
||||||
"""
|
'''
|
||||||
# if this call errors we store the exception for later
|
# if this call errors we store the exception for later
|
||||||
# in ``errors`` which will be reraised inside
|
# in ``errors`` which will be reraised inside
|
||||||
# a MultiError and we still send out a cancel request
|
# an exception group and we still send out a cancel request
|
||||||
result = await exhaust_portal(portal, actor)
|
result = await exhaust_portal(portal, actor)
|
||||||
if isinstance(result, Exception):
|
if isinstance(result, Exception):
|
||||||
errors[actor.uid] = result
|
errors[actor.uid] = result
|
||||||
|
@ -160,213 +199,340 @@ async def cancel_on_completion(
|
||||||
async def do_hard_kill(
|
async def do_hard_kill(
|
||||||
proc: trio.Process,
|
proc: trio.Process,
|
||||||
terminate_after: int = 3,
|
terminate_after: int = 3,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
# NOTE: this timeout used to do nothing since we were shielding
|
# NOTE: this timeout used to do nothing since we were shielding
|
||||||
# the ``.wait()`` inside ``new_proc()`` which will pretty much
|
# the ``.wait()`` inside ``new_proc()`` which will pretty much
|
||||||
# never release until the process exits, now it acts as
|
# never release until the process exits, now it acts as
|
||||||
# a hard-kill time ultimatum.
|
# a hard-kill time ultimatum.
|
||||||
|
log.debug(f"Terminating {proc}")
|
||||||
with trio.move_on_after(terminate_after) as cs:
|
with trio.move_on_after(terminate_after) as cs:
|
||||||
|
|
||||||
# NOTE: This ``__aexit__()`` shields internally.
|
# NOTE: code below was copied verbatim from the now deprecated
|
||||||
async with proc: # calls ``trio.Process.aclose()``
|
# (in 0.20.0) ``trio._subrocess.Process.aclose()``, orig doc
|
||||||
log.debug(f"Terminating {proc}")
|
# string:
|
||||||
|
#
|
||||||
|
# Close any pipes we have to the process (both input and output)
|
||||||
|
# and wait for it to exit. If cancelled, kills the process and
|
||||||
|
# waits for it to finish exiting before propagating the
|
||||||
|
# cancellation.
|
||||||
|
with trio.CancelScope(shield=True):
|
||||||
|
if proc.stdin is not None:
|
||||||
|
await proc.stdin.aclose()
|
||||||
|
if proc.stdout is not None:
|
||||||
|
await proc.stdout.aclose()
|
||||||
|
if proc.stderr is not None:
|
||||||
|
await proc.stderr.aclose()
|
||||||
|
try:
|
||||||
|
await proc.wait()
|
||||||
|
finally:
|
||||||
|
if proc.returncode is None:
|
||||||
|
proc.kill()
|
||||||
|
with trio.CancelScope(shield=True):
|
||||||
|
await proc.wait()
|
||||||
|
|
||||||
if cs.cancelled_caught:
|
if cs.cancelled_caught:
|
||||||
# XXX: should pretty much never get here unless we have
|
# XXX: should pretty much never get here unless we have
|
||||||
# to move the bits from ``proc.__aexit__()`` out and
|
# to move the bits from ``proc.__aexit__()`` out and
|
||||||
# into here.
|
# into here.
|
||||||
log.critical(f"HARD KILLING {proc}")
|
log.critical(f"#ZOMBIE_LORD_IS_HERE: {proc}")
|
||||||
proc.kill()
|
proc.kill()
|
||||||
|
|
||||||
|
|
||||||
async def new_proc(
|
async def soft_wait(
|
||||||
|
|
||||||
|
proc: ProcessType,
|
||||||
|
wait_func: Callable[
|
||||||
|
[ProcessType],
|
||||||
|
Awaitable,
|
||||||
|
],
|
||||||
|
portal: Portal,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
# Wait for proc termination but **dont' yet** call
|
||||||
|
# ``trio.Process.__aexit__()`` (it tears down stdio
|
||||||
|
# which will kill any waiting remote pdb trace).
|
||||||
|
# This is a "soft" (cancellable) join/reap.
|
||||||
|
uid = portal.channel.uid
|
||||||
|
try:
|
||||||
|
log.cancel(f'Soft waiting on actor:\n{uid}')
|
||||||
|
await wait_func(proc)
|
||||||
|
except trio.Cancelled:
|
||||||
|
# if cancelled during a soft wait, cancel the child
|
||||||
|
# actor before entering the hard reap sequence
|
||||||
|
# below. This means we try to do a graceful teardown
|
||||||
|
# via sending a cancel message before getting out
|
||||||
|
# zombie killing tools.
|
||||||
|
async with trio.open_nursery() as n:
|
||||||
|
n.cancel_scope.shield = True
|
||||||
|
|
||||||
|
async def cancel_on_proc_deth():
|
||||||
|
'''
|
||||||
|
Cancel the actor cancel request if we detect that
|
||||||
|
that the process terminated.
|
||||||
|
|
||||||
|
'''
|
||||||
|
await wait_func(proc)
|
||||||
|
n.cancel_scope.cancel()
|
||||||
|
|
||||||
|
n.start_soon(cancel_on_proc_deth)
|
||||||
|
await portal.cancel_actor()
|
||||||
|
|
||||||
|
if proc.poll() is None: # type: ignore
|
||||||
|
log.warning(
|
||||||
|
'Actor still alive after cancel request:\n'
|
||||||
|
f'{uid}'
|
||||||
|
)
|
||||||
|
|
||||||
|
n.cancel_scope.cancel()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def new_proc(
|
||||||
name: str,
|
name: str,
|
||||||
actor_nursery: 'ActorNursery', # type: ignore # noqa
|
actor_nursery: ActorNursery,
|
||||||
subactor: Actor,
|
subactor: Actor,
|
||||||
errors: Dict[Tuple[str, str], Exception],
|
errors: dict[tuple[str, str], Exception],
|
||||||
|
|
||||||
# passed through to actor main
|
# passed through to actor main
|
||||||
bind_addr: Tuple[str, int],
|
bind_addr: tuple[str, int],
|
||||||
parent_addr: Tuple[str, int],
|
parent_addr: tuple[str, int],
|
||||||
_runtime_vars: Dict[str, Any], # serialized and sent to _child
|
_runtime_vars: dict[str, Any], # serialized and sent to _child
|
||||||
|
|
||||||
*,
|
*,
|
||||||
|
|
||||||
|
infect_asyncio: bool = False,
|
||||||
task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED
|
task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
|
||||||
Create a new ``multiprocessing.Process`` using the
|
|
||||||
spawn method as configured using ``try_set_start_method()``.
|
|
||||||
|
|
||||||
"""
|
# lookup backend spawning target
|
||||||
|
target = _methods[_spawn_method]
|
||||||
|
|
||||||
# mark the new actor with the global spawn method
|
# mark the new actor with the global spawn method
|
||||||
subactor._spawn_method = _spawn_method
|
subactor._spawn_method = _spawn_method
|
||||||
uid = subactor.uid
|
|
||||||
|
|
||||||
if _spawn_method == 'trio':
|
await target(
|
||||||
|
name,
|
||||||
|
actor_nursery,
|
||||||
|
subactor,
|
||||||
|
errors,
|
||||||
|
bind_addr,
|
||||||
|
parent_addr,
|
||||||
|
_runtime_vars, # run time vars
|
||||||
|
infect_asyncio=infect_asyncio,
|
||||||
|
task_status=task_status,
|
||||||
|
)
|
||||||
|
|
||||||
spawn_cmd = [
|
|
||||||
sys.executable,
|
async def trio_proc(
|
||||||
"-m",
|
name: str,
|
||||||
# Hardcode this (instead of using ``_child.__name__`` to avoid a
|
actor_nursery: ActorNursery,
|
||||||
# double import warning: https://stackoverflow.com/a/45070583
|
subactor: Actor,
|
||||||
"tractor._child",
|
errors: dict[tuple[str, str], Exception],
|
||||||
# We provide the child's unique identifier on this exec/spawn
|
|
||||||
# line for debugging purposes when viewing the process tree from
|
# passed through to actor main
|
||||||
# the OS; it otherwise can be passed via the parent channel if
|
bind_addr: tuple[str, int],
|
||||||
# we prefer in the future (for privacy).
|
parent_addr: tuple[str, int],
|
||||||
"--uid",
|
_runtime_vars: dict[str, Any], # serialized and sent to _child
|
||||||
str(subactor.uid),
|
*,
|
||||||
# Address the child must connect to on startup
|
infect_asyncio: bool = False,
|
||||||
"--parent_addr",
|
task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED
|
||||||
str(parent_addr)
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Create a new ``Process`` using a "spawn method" as (configured using
|
||||||
|
``try_set_start_method()``).
|
||||||
|
|
||||||
|
This routine should be started in a actor runtime task and the logic
|
||||||
|
here is to be considered the core supervision strategy.
|
||||||
|
|
||||||
|
'''
|
||||||
|
spawn_cmd = [
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
# Hardcode this (instead of using ``_child.__name__`` to avoid a
|
||||||
|
# double import warning: https://stackoverflow.com/a/45070583
|
||||||
|
"tractor._child",
|
||||||
|
# We provide the child's unique identifier on this exec/spawn
|
||||||
|
# line for debugging purposes when viewing the process tree from
|
||||||
|
# the OS; it otherwise can be passed via the parent channel if
|
||||||
|
# we prefer in the future (for privacy).
|
||||||
|
"--uid",
|
||||||
|
str(subactor.uid),
|
||||||
|
# Address the child must connect to on startup
|
||||||
|
"--parent_addr",
|
||||||
|
str(parent_addr)
|
||||||
|
]
|
||||||
|
|
||||||
|
if subactor.loglevel:
|
||||||
|
spawn_cmd += [
|
||||||
|
"--loglevel",
|
||||||
|
subactor.loglevel
|
||||||
]
|
]
|
||||||
|
# Tell child to run in guest mode on top of ``asyncio`` loop
|
||||||
|
if infect_asyncio:
|
||||||
|
spawn_cmd.append("--asyncio")
|
||||||
|
|
||||||
if subactor.loglevel:
|
cancelled_during_spawn: bool = False
|
||||||
spawn_cmd += [
|
proc: trio.Process | None = None
|
||||||
"--loglevel",
|
try:
|
||||||
subactor.loglevel
|
|
||||||
]
|
|
||||||
|
|
||||||
cancelled_during_spawn: bool = False
|
|
||||||
try:
|
try:
|
||||||
proc = await trio.open_process(spawn_cmd)
|
# TODO: needs ``trio_typing`` patch?
|
||||||
|
proc = await trio.lowlevel.open_process(spawn_cmd)
|
||||||
|
|
||||||
log.runtime(f"Started {proc}")
|
log.runtime(f"Started {proc}")
|
||||||
|
|
||||||
# wait for actor to spawn and connect back to us
|
# wait for actor to spawn and connect back to us
|
||||||
# channel should have handshake completed by the
|
# channel should have handshake completed by the
|
||||||
# local actor by the time we get a ref to it
|
# local actor by the time we get a ref to it
|
||||||
try:
|
event, chan = await actor_nursery._actor.wait_for_peer(
|
||||||
event, chan = await actor_nursery._actor.wait_for_peer(
|
subactor.uid)
|
||||||
subactor.uid)
|
|
||||||
except trio.Cancelled:
|
|
||||||
cancelled_during_spawn = True
|
|
||||||
# we may cancel before the child connects back in which
|
|
||||||
# case avoid clobbering the pdb tty.
|
|
||||||
if debug_mode():
|
|
||||||
with trio.CancelScope(shield=True):
|
|
||||||
# don't clobber an ongoing pdb
|
|
||||||
if is_root_process():
|
|
||||||
await maybe_wait_for_debugger()
|
|
||||||
else:
|
|
||||||
async with acquire_debug_lock(uid):
|
|
||||||
# soft wait on the proc to terminate
|
|
||||||
with trio.move_on_after(0.5):
|
|
||||||
await proc.wait()
|
|
||||||
raise
|
|
||||||
|
|
||||||
portal = Portal(chan)
|
except trio.Cancelled:
|
||||||
actor_nursery._children[subactor.uid] = (
|
cancelled_during_spawn = True
|
||||||
subactor, proc, portal)
|
# we may cancel before the child connects back in which
|
||||||
|
# case avoid clobbering the pdb tty.
|
||||||
|
if debug_mode():
|
||||||
|
with trio.CancelScope(shield=True):
|
||||||
|
# don't clobber an ongoing pdb
|
||||||
|
if is_root_process():
|
||||||
|
await maybe_wait_for_debugger()
|
||||||
|
|
||||||
# send additional init params
|
elif proc is not None:
|
||||||
await chan.send({
|
async with acquire_debug_lock(subactor.uid):
|
||||||
"_parent_main_data": subactor._parent_main_data,
|
# soft wait on the proc to terminate
|
||||||
"enable_modules": subactor.enable_modules,
|
with trio.move_on_after(0.5):
|
||||||
"_arb_addr": subactor._arb_addr,
|
await proc.wait()
|
||||||
"bind_host": bind_addr[0],
|
raise
|
||||||
"bind_port": bind_addr[1],
|
|
||||||
"_runtime_vars": _runtime_vars,
|
|
||||||
})
|
|
||||||
|
|
||||||
# track subactor in current nursery
|
# a sub-proc ref **must** exist now
|
||||||
curr_actor = current_actor()
|
assert proc
|
||||||
curr_actor._actoruid2nursery[subactor.uid] = actor_nursery
|
|
||||||
|
|
||||||
# resume caller at next checkpoint now that child is up
|
portal = Portal(chan)
|
||||||
task_status.started(portal)
|
actor_nursery._children[subactor.uid] = (
|
||||||
|
subactor,
|
||||||
|
proc,
|
||||||
|
portal,
|
||||||
|
)
|
||||||
|
|
||||||
# wait for ActorNursery.wait() to be called
|
# send additional init params
|
||||||
with trio.CancelScope(shield=True):
|
await chan.send({
|
||||||
await actor_nursery._join_procs.wait()
|
"_parent_main_data": subactor._parent_main_data,
|
||||||
|
"enable_modules": subactor.enable_modules,
|
||||||
|
"_arb_addr": subactor._arb_addr,
|
||||||
|
"bind_host": bind_addr[0],
|
||||||
|
"bind_port": bind_addr[1],
|
||||||
|
"_runtime_vars": _runtime_vars,
|
||||||
|
})
|
||||||
|
|
||||||
async with trio.open_nursery() as nursery:
|
# track subactor in current nursery
|
||||||
if portal in actor_nursery._cancel_after_result_on_exit:
|
curr_actor = current_actor()
|
||||||
nursery.start_soon(
|
curr_actor._actoruid2nursery[subactor.uid] = actor_nursery
|
||||||
cancel_on_completion,
|
|
||||||
portal,
|
|
||||||
subactor,
|
|
||||||
errors
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait for proc termination but **dont' yet** call
|
# resume caller at next checkpoint now that child is up
|
||||||
# ``trio.Process.__aexit__()`` (it tears down stdio
|
task_status.started(portal)
|
||||||
# which will kill any waiting remote pdb trace).
|
|
||||||
# This is a "soft" (cancellable) join/reap.
|
|
||||||
await proc.wait()
|
|
||||||
|
|
||||||
# cancel result waiter that may have been spawned in
|
# wait for ActorNursery.wait() to be called
|
||||||
# tandem if not done already
|
with trio.CancelScope(shield=True):
|
||||||
log.warning(
|
await actor_nursery._join_procs.wait()
|
||||||
"Cancelling existing result waiter task for "
|
|
||||||
f"{subactor.uid}")
|
|
||||||
nursery.cancel_scope.cancel()
|
|
||||||
|
|
||||||
finally:
|
async with trio.open_nursery() as nursery:
|
||||||
# The "hard" reap since no actor zombies are allowed!
|
if portal in actor_nursery._cancel_after_result_on_exit:
|
||||||
# XXX: do this **after** cancellation/tearfown to avoid
|
nursery.start_soon(
|
||||||
# killing the process too early.
|
cancel_on_completion,
|
||||||
log.cancel(f'Hard reap sequence starting for {uid}')
|
portal,
|
||||||
|
subactor,
|
||||||
|
errors
|
||||||
|
)
|
||||||
|
|
||||||
|
# This is a "soft" (cancellable) join/reap which
|
||||||
|
# will remote cancel the actor on a ``trio.Cancelled``
|
||||||
|
# condition.
|
||||||
|
await soft_wait(
|
||||||
|
proc,
|
||||||
|
trio.Process.wait,
|
||||||
|
portal
|
||||||
|
)
|
||||||
|
|
||||||
|
# cancel result waiter that may have been spawned in
|
||||||
|
# tandem if not done already
|
||||||
|
log.warning(
|
||||||
|
"Cancelling existing result waiter task for "
|
||||||
|
f"{subactor.uid}")
|
||||||
|
nursery.cancel_scope.cancel()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# XXX NOTE XXX: The "hard" reap since no actor zombies are
|
||||||
|
# allowed! Do this **after** cancellation/teardown to avoid
|
||||||
|
# killing the process too early.
|
||||||
|
if proc:
|
||||||
|
log.cancel(f'Hard reap sequence starting for {subactor.uid}')
|
||||||
with trio.CancelScope(shield=True):
|
with trio.CancelScope(shield=True):
|
||||||
|
|
||||||
# don't clobber an ongoing pdb
|
# don't clobber an ongoing pdb
|
||||||
if cancelled_during_spawn:
|
if cancelled_during_spawn:
|
||||||
# Try again to avoid TTY clobbering.
|
# Try again to avoid TTY clobbering.
|
||||||
async with acquire_debug_lock(uid):
|
async with acquire_debug_lock(subactor.uid):
|
||||||
with trio.move_on_after(0.5):
|
with trio.move_on_after(0.5):
|
||||||
await proc.wait()
|
await proc.wait()
|
||||||
|
|
||||||
if is_root_process():
|
if is_root_process():
|
||||||
await maybe_wait_for_debugger()
|
# TODO: solve the following issue where we need
|
||||||
|
# to do a similar wait like this but in an
|
||||||
|
# "intermediary" parent actor that itself isn't
|
||||||
|
# in debug but has a child that is, and we need
|
||||||
|
# to hold off on relaying SIGINT until that child
|
||||||
|
# is complete.
|
||||||
|
# https://github.com/goodboy/tractor/issues/320
|
||||||
|
await maybe_wait_for_debugger(
|
||||||
|
child_in_debug=_runtime_vars.get(
|
||||||
|
'_debug_mode', False),
|
||||||
|
)
|
||||||
|
|
||||||
if proc.poll() is None:
|
if proc.poll() is None:
|
||||||
log.cancel(f"Attempting to hard kill {proc}")
|
log.cancel(f"Attempting to hard kill {proc}")
|
||||||
await do_hard_kill(proc)
|
await do_hard_kill(proc)
|
||||||
|
|
||||||
log.debug(f"Joined {proc}")
|
log.debug(f"Joined {proc}")
|
||||||
|
else:
|
||||||
|
log.warning('Nursery cancelled before sub-proc started')
|
||||||
|
|
||||||
if not cancelled_during_spawn:
|
if not cancelled_during_spawn:
|
||||||
# pop child entry to indicate we no longer managing this
|
# pop child entry to indicate we no longer managing this
|
||||||
# subactor
|
# subactor
|
||||||
actor_nursery._children.pop(subactor.uid)
|
actor_nursery._children.pop(subactor.uid)
|
||||||
|
|
||||||
else:
|
|
||||||
# `multiprocessing`
|
|
||||||
# async with trio.open_nursery() as nursery:
|
|
||||||
await mp_new_proc(
|
|
||||||
name=name,
|
|
||||||
actor_nursery=actor_nursery,
|
|
||||||
subactor=subactor,
|
|
||||||
errors=errors,
|
|
||||||
# passed through to actor main
|
|
||||||
bind_addr=bind_addr,
|
|
||||||
parent_addr=parent_addr,
|
|
||||||
_runtime_vars=_runtime_vars,
|
|
||||||
task_status=task_status,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def mp_new_proc(
|
async def mp_proc(
|
||||||
|
|
||||||
name: str,
|
name: str,
|
||||||
actor_nursery: 'ActorNursery', # type: ignore # noqa
|
actor_nursery: ActorNursery, # type: ignore # noqa
|
||||||
subactor: Actor,
|
subactor: Actor,
|
||||||
errors: Dict[Tuple[str, str], Exception],
|
errors: dict[tuple[str, str], Exception],
|
||||||
# passed through to actor main
|
# passed through to actor main
|
||||||
bind_addr: Tuple[str, int],
|
bind_addr: tuple[str, int],
|
||||||
parent_addr: Tuple[str, int],
|
parent_addr: tuple[str, int],
|
||||||
_runtime_vars: Dict[str, Any], # serialized and sent to _child
|
_runtime_vars: dict[str, Any], # serialized and sent to _child
|
||||||
*,
|
*,
|
||||||
|
infect_asyncio: bool = False,
|
||||||
task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED
|
task_status: TaskStatus[Portal] = trio.TASK_STATUS_IGNORED
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
|
# uggh zone
|
||||||
|
try:
|
||||||
|
from multiprocessing import semaphore_tracker # type: ignore
|
||||||
|
resource_tracker = semaphore_tracker
|
||||||
|
resource_tracker._resource_tracker = resource_tracker._semaphore_tracker # noqa
|
||||||
|
except ImportError:
|
||||||
|
# 3.8 introduces a more general version that also tracks shared mems
|
||||||
|
from multiprocessing import resource_tracker # type: ignore
|
||||||
|
|
||||||
assert _ctx
|
assert _ctx
|
||||||
start_method = _ctx.get_start_method()
|
start_method = _ctx.get_start_method()
|
||||||
if start_method == 'forkserver':
|
if start_method == 'forkserver':
|
||||||
|
|
||||||
|
from multiprocessing import forkserver # type: ignore
|
||||||
# XXX do our hackery on the stdlib to avoid multiple
|
# XXX do our hackery on the stdlib to avoid multiple
|
||||||
# forkservers (one at each subproc layer).
|
# forkservers (one at each subproc layer).
|
||||||
fs = forkserver._forkserver
|
fs = forkserver._forkserver
|
||||||
|
@ -378,23 +544,24 @@ async def mp_new_proc(
|
||||||
# forkserver.set_forkserver_preload(enable_modules)
|
# forkserver.set_forkserver_preload(enable_modules)
|
||||||
forkserver.ensure_running()
|
forkserver.ensure_running()
|
||||||
fs_info = (
|
fs_info = (
|
||||||
fs._forkserver_address,
|
fs._forkserver_address, # type: ignore # noqa
|
||||||
fs._forkserver_alive_fd,
|
fs._forkserver_alive_fd, # type: ignore # noqa
|
||||||
getattr(fs, '_forkserver_pid', None),
|
getattr(fs, '_forkserver_pid', None),
|
||||||
getattr(
|
getattr(
|
||||||
resource_tracker._resource_tracker, '_pid', None),
|
resource_tracker._resource_tracker, '_pid', None),
|
||||||
resource_tracker._resource_tracker._fd,
|
resource_tracker._resource_tracker._fd,
|
||||||
)
|
)
|
||||||
else:
|
else: # request to forkerserver to fork a new child
|
||||||
assert curr_actor._forkserver_info
|
assert curr_actor._forkserver_info
|
||||||
fs_info = (
|
fs_info = (
|
||||||
fs._forkserver_address,
|
fs._forkserver_address, # type: ignore # noqa
|
||||||
fs._forkserver_alive_fd,
|
fs._forkserver_alive_fd, # type: ignore # noqa
|
||||||
fs._forkserver_pid,
|
fs._forkserver_pid, # type: ignore # noqa
|
||||||
resource_tracker._resource_tracker._pid,
|
resource_tracker._resource_tracker._pid,
|
||||||
resource_tracker._resource_tracker._fd,
|
resource_tracker._resource_tracker._fd,
|
||||||
) = curr_actor._forkserver_info
|
) = curr_actor._forkserver_info
|
||||||
else:
|
else:
|
||||||
|
# spawn method
|
||||||
fs_info = (None, None, None, None, None)
|
fs_info = (None, None, None, None, None)
|
||||||
|
|
||||||
proc: mp.Process = _ctx.Process( # type: ignore
|
proc: mp.Process = _ctx.Process( # type: ignore
|
||||||
|
@ -403,12 +570,14 @@ async def mp_new_proc(
|
||||||
subactor,
|
subactor,
|
||||||
bind_addr,
|
bind_addr,
|
||||||
fs_info,
|
fs_info,
|
||||||
start_method,
|
_spawn_method,
|
||||||
parent_addr,
|
parent_addr,
|
||||||
|
infect_asyncio,
|
||||||
),
|
),
|
||||||
# daemon=True,
|
# daemon=True,
|
||||||
name=name,
|
name=name,
|
||||||
)
|
)
|
||||||
|
|
||||||
# `multiprocessing` only (since no async interface):
|
# `multiprocessing` only (since no async interface):
|
||||||
# register the process before start in case we get a cancel
|
# register the process before start in case we get a cancel
|
||||||
# request before the actor has fully spawned - then we can wait
|
# request before the actor has fully spawned - then we can wait
|
||||||
|
@ -427,6 +596,11 @@ async def mp_new_proc(
|
||||||
# local actor by the time we get a ref to it
|
# local actor by the time we get a ref to it
|
||||||
event, chan = await actor_nursery._actor.wait_for_peer(
|
event, chan = await actor_nursery._actor.wait_for_peer(
|
||||||
subactor.uid)
|
subactor.uid)
|
||||||
|
|
||||||
|
# XXX: monkey patch poll API to match the ``subprocess`` API..
|
||||||
|
# not sure why they don't expose this but kk.
|
||||||
|
proc.poll = lambda: proc.exitcode # type: ignore
|
||||||
|
|
||||||
# except:
|
# except:
|
||||||
# TODO: in the case we were cancelled before the sub-proc
|
# TODO: in the case we were cancelled before the sub-proc
|
||||||
# registered itself back we must be sure to try and clean
|
# registered itself back we must be sure to try and clean
|
||||||
|
@ -459,7 +633,14 @@ async def mp_new_proc(
|
||||||
errors
|
errors
|
||||||
)
|
)
|
||||||
|
|
||||||
await proc_waiter(proc)
|
# This is a "soft" (cancellable) join/reap which
|
||||||
|
# will remote cancel the actor on a ``trio.Cancelled``
|
||||||
|
# condition.
|
||||||
|
await soft_wait(
|
||||||
|
proc,
|
||||||
|
proc_waiter,
|
||||||
|
portal
|
||||||
|
)
|
||||||
|
|
||||||
# cancel result waiter that may have been spawned in
|
# cancel result waiter that may have been spawned in
|
||||||
# tandem if not done already
|
# tandem if not done already
|
||||||
|
@ -483,4 +664,16 @@ async def mp_new_proc(
|
||||||
log.debug(f"Joined {proc}")
|
log.debug(f"Joined {proc}")
|
||||||
|
|
||||||
# pop child entry to indicate we are no longer managing subactor
|
# pop child entry to indicate we are no longer managing subactor
|
||||||
subactor, proc, portal = actor_nursery._children.pop(subactor.uid)
|
actor_nursery._children.pop(subactor.uid)
|
||||||
|
|
||||||
|
# TODO: prolly report to ``mypy`` how this causes all sorts of
|
||||||
|
# false errors..
|
||||||
|
# subactor, proc, portal = actor_nursery._children.pop(subactor.uid)
|
||||||
|
|
||||||
|
|
||||||
|
# proc spawning backend target map
|
||||||
|
_methods: dict[SpawnMethodKey, Callable] = {
|
||||||
|
'trio': trio_proc,
|
||||||
|
'mp_spawn': mp_proc,
|
||||||
|
'mp_forkserver': mp_proc,
|
||||||
|
}
|
||||||
|
|
|
@ -1,9 +1,27 @@
|
||||||
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Per process state
|
Per process state
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from typing import Optional, Dict, Any
|
from typing import (
|
||||||
from collections.abc import Mapping
|
Optional,
|
||||||
import multiprocessing as mp
|
Any,
|
||||||
|
)
|
||||||
|
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
|
@ -11,7 +29,7 @@ from ._exceptions import NoRuntime
|
||||||
|
|
||||||
|
|
||||||
_current_actor: Optional['Actor'] = None # type: ignore # noqa
|
_current_actor: Optional['Actor'] = None # type: ignore # noqa
|
||||||
_runtime_vars: Dict[str, Any] = {
|
_runtime_vars: dict[str, Any] = {
|
||||||
'_debug_mode': False,
|
'_debug_mode': False,
|
||||||
'_is_root': False,
|
'_is_root': False,
|
||||||
'_root_mailbox': (None, None)
|
'_root_mailbox': (None, None)
|
||||||
|
@ -27,33 +45,10 @@ def current_actor(err_on_no_runtime: bool = True) -> 'Actor': # type: ignore #
|
||||||
return _current_actor
|
return _current_actor
|
||||||
|
|
||||||
|
|
||||||
_conc_name_getters = {
|
|
||||||
'task': trio.lowlevel.current_task,
|
|
||||||
'actor': current_actor
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ActorContextInfo(Mapping):
|
|
||||||
"Dyanmic lookup for local actor and task names"
|
|
||||||
_context_keys = ('task', 'actor')
|
|
||||||
|
|
||||||
def __len__(self):
|
|
||||||
return len(self._context_keys)
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
return iter(self._context_keys)
|
|
||||||
|
|
||||||
def __getitem__(self, key: str) -> str:
|
|
||||||
try:
|
|
||||||
return _conc_name_getters[key]().name # type: ignore
|
|
||||||
except RuntimeError:
|
|
||||||
# no local actor/task context initialized yet
|
|
||||||
return f'no {key} context'
|
|
||||||
|
|
||||||
|
|
||||||
def is_main_process() -> bool:
|
def is_main_process() -> bool:
|
||||||
"""Bool determining if this actor is running in the top-most process.
|
"""Bool determining if this actor is running in the top-most process.
|
||||||
"""
|
"""
|
||||||
|
import multiprocessing as mp
|
||||||
return mp.current_process().name == 'MainProcess'
|
return mp.current_process().name == 'MainProcess'
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,19 @@
|
||||||
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Message stream types and APIs.
|
Message stream types and APIs.
|
||||||
|
|
||||||
|
@ -7,8 +23,10 @@ import inspect
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import (
|
from typing import (
|
||||||
Any, Optional, Callable,
|
Any,
|
||||||
AsyncGenerator, Dict,
|
Optional,
|
||||||
|
Callable,
|
||||||
|
AsyncGenerator,
|
||||||
AsyncIterator
|
AsyncIterator
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -26,16 +44,19 @@ from .trionics import broadcast_receiver, BroadcastReceiver
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# TODO: generic typing like trio's receive channel
|
# TODO: the list
|
||||||
# but with msgspec messages?
|
# - generic typing like trio's receive channel but with msgspec
|
||||||
# class ReceiveChannel(AsyncResource, Generic[ReceiveType]):
|
# messages? class ReceiveChannel(AsyncResource, Generic[ReceiveType]):
|
||||||
|
# - use __slots__ on ``Context``?
|
||||||
|
|
||||||
|
|
||||||
class ReceiveMsgStream(trio.abc.ReceiveChannel):
|
class MsgStream(trio.abc.Channel):
|
||||||
'''A IPC message stream for receiving logically sequenced values
|
'''
|
||||||
over an inter-actor ``Channel``. This is the type returned to
|
A bidirectional message stream for receiving logically sequenced
|
||||||
a local task which entered either ``Portal.open_stream_from()`` or
|
values over an inter-actor IPC ``Channel``.
|
||||||
``Context.open_stream()``.
|
|
||||||
|
This is the type returned to a local task which entered either
|
||||||
|
``Portal.open_stream_from()`` or ``Context.open_stream()``.
|
||||||
|
|
||||||
Termination rules:
|
Termination rules:
|
||||||
|
|
||||||
|
@ -60,6 +81,7 @@ class ReceiveMsgStream(trio.abc.ReceiveChannel):
|
||||||
|
|
||||||
# flag to denote end of stream
|
# flag to denote end of stream
|
||||||
self._eoc: bool = False
|
self._eoc: bool = False
|
||||||
|
self._closed: bool = False
|
||||||
|
|
||||||
# delegate directly to underlying mem channel
|
# delegate directly to underlying mem channel
|
||||||
def receive_nowait(self):
|
def receive_nowait(self):
|
||||||
|
@ -76,11 +98,14 @@ class ReceiveMsgStream(trio.abc.ReceiveChannel):
|
||||||
if self._eoc:
|
if self._eoc:
|
||||||
raise trio.EndOfChannel
|
raise trio.EndOfChannel
|
||||||
|
|
||||||
|
if self._closed:
|
||||||
|
raise trio.ClosedResourceError('This stream was closed')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
msg = await self._rx_chan.receive()
|
msg = await self._rx_chan.receive()
|
||||||
return msg['yield']
|
return msg['yield']
|
||||||
|
|
||||||
except KeyError:
|
except KeyError as err:
|
||||||
# internal error should never get here
|
# internal error should never get here
|
||||||
assert msg.get('cid'), ("Received internal error at portal?")
|
assert msg.get('cid'), ("Received internal error at portal?")
|
||||||
|
|
||||||
|
@ -89,9 +114,18 @@ class ReceiveMsgStream(trio.abc.ReceiveChannel):
|
||||||
# - 'error'
|
# - 'error'
|
||||||
# possibly just handle msg['stop'] here!
|
# possibly just handle msg['stop'] here!
|
||||||
|
|
||||||
if msg.get('stop'):
|
if self._closed:
|
||||||
|
raise trio.ClosedResourceError('This stream was closed')
|
||||||
|
|
||||||
|
if msg.get('stop') or self._eoc:
|
||||||
log.debug(f"{self} was stopped at remote end")
|
log.debug(f"{self} was stopped at remote end")
|
||||||
|
|
||||||
|
# XXX: important to set so that a new ``.receive()``
|
||||||
|
# call (likely by another task using a broadcast receiver)
|
||||||
|
# doesn't accidentally pull the ``return`` message
|
||||||
|
# value out of the underlying feed mem chan!
|
||||||
|
self._eoc = True
|
||||||
|
|
||||||
# # when the send is closed we assume the stream has
|
# # when the send is closed we assume the stream has
|
||||||
# # terminated and signal this local iterator to stop
|
# # terminated and signal this local iterator to stop
|
||||||
# await self.aclose()
|
# await self.aclose()
|
||||||
|
@ -99,7 +133,7 @@ class ReceiveMsgStream(trio.abc.ReceiveChannel):
|
||||||
# XXX: this causes ``ReceiveChannel.__anext__()`` to
|
# XXX: this causes ``ReceiveChannel.__anext__()`` to
|
||||||
# raise a ``StopAsyncIteration`` **and** in our catch
|
# raise a ``StopAsyncIteration`` **and** in our catch
|
||||||
# block below it will trigger ``.aclose()``.
|
# block below it will trigger ``.aclose()``.
|
||||||
raise trio.EndOfChannel
|
raise trio.EndOfChannel from err
|
||||||
|
|
||||||
# TODO: test that shows stream raising an expected error!!!
|
# TODO: test that shows stream raising an expected error!!!
|
||||||
elif msg.get('error'):
|
elif msg.get('error'):
|
||||||
|
@ -144,10 +178,11 @@ class ReceiveMsgStream(trio.abc.ReceiveChannel):
|
||||||
raise # propagate
|
raise # propagate
|
||||||
|
|
||||||
async def aclose(self):
|
async def aclose(self):
|
||||||
"""Cancel associated remote actor task and local memory channel
|
'''
|
||||||
on close.
|
Cancel associated remote actor task and local memory channel on
|
||||||
|
close.
|
||||||
|
|
||||||
"""
|
'''
|
||||||
# XXX: keep proper adherance to trio's `.aclose()` semantics:
|
# XXX: keep proper adherance to trio's `.aclose()` semantics:
|
||||||
# https://trio.readthedocs.io/en/stable/reference-io.html#trio.abc.AsyncResource.aclose
|
# https://trio.readthedocs.io/en/stable/reference-io.html#trio.abc.AsyncResource.aclose
|
||||||
rx_chan = self._rx_chan
|
rx_chan = self._rx_chan
|
||||||
|
@ -177,28 +212,32 @@ class ReceiveMsgStream(trio.abc.ReceiveChannel):
|
||||||
|
|
||||||
# In the bidirectional case, `Context.open_stream()` will create
|
# In the bidirectional case, `Context.open_stream()` will create
|
||||||
# the `Actor._cids2qs` entry from a call to
|
# the `Actor._cids2qs` entry from a call to
|
||||||
# `Actor.get_memchans()` and will send the stop message in
|
# `Actor.get_context()` and will call us here to send the stop
|
||||||
# ``__aexit__()`` on teardown so it **does not** need to be
|
# msg in ``__aexit__()`` on teardown.
|
||||||
# called here.
|
try:
|
||||||
if not self._ctx._portal:
|
# NOTE: if this call is cancelled we expect this end to
|
||||||
# Only for 2 way streams can we can send stop from the
|
# handle as though the stop was never sent (though if it
|
||||||
# caller side.
|
# was it shouldn't matter since it's unlikely a user
|
||||||
try:
|
# will try to re-use a stream after attemping to close
|
||||||
# NOTE: if this call is cancelled we expect this end to
|
# it).
|
||||||
# handle as though the stop was never sent (though if it
|
with trio.CancelScope(shield=True):
|
||||||
# was it shouldn't matter since it's unlikely a user
|
|
||||||
# will try to re-use a stream after attemping to close
|
|
||||||
# it).
|
|
||||||
await self._ctx.send_stop()
|
await self._ctx.send_stop()
|
||||||
|
|
||||||
except (
|
except (
|
||||||
trio.BrokenResourceError,
|
trio.BrokenResourceError,
|
||||||
trio.ClosedResourceError
|
trio.ClosedResourceError
|
||||||
):
|
):
|
||||||
# the underlying channel may already have been pulled
|
# the underlying channel may already have been pulled
|
||||||
# in which case our stop message is meaningless since
|
# in which case our stop message is meaningless since
|
||||||
# it can't traverse the transport.
|
# it can't traverse the transport.
|
||||||
log.debug(f'Channel for {self} was already closed')
|
ctx = self._ctx
|
||||||
|
log.warning(
|
||||||
|
f'Stream was already destroyed?\n'
|
||||||
|
f'actor: {ctx.chan.uid}\n'
|
||||||
|
f'ctx id: {ctx.cid}'
|
||||||
|
)
|
||||||
|
|
||||||
|
self._closed = True
|
||||||
|
|
||||||
# Do we close the local mem chan ``self._rx_chan`` ??!?
|
# Do we close the local mem chan ``self._rx_chan`` ??!?
|
||||||
|
|
||||||
|
@ -241,7 +280,8 @@ class ReceiveMsgStream(trio.abc.ReceiveChannel):
|
||||||
self,
|
self,
|
||||||
|
|
||||||
) -> AsyncIterator[BroadcastReceiver]:
|
) -> AsyncIterator[BroadcastReceiver]:
|
||||||
'''Allocate and return a ``BroadcastReceiver`` which delegates
|
'''
|
||||||
|
Allocate and return a ``BroadcastReceiver`` which delegates
|
||||||
to this message stream.
|
to this message stream.
|
||||||
|
|
||||||
This allows multiple local tasks to receive each their own copy
|
This allows multiple local tasks to receive each their own copy
|
||||||
|
@ -278,55 +318,80 @@ class ReceiveMsgStream(trio.abc.ReceiveChannel):
|
||||||
async with self._broadcaster.subscribe() as bstream:
|
async with self._broadcaster.subscribe() as bstream:
|
||||||
assert bstream.key != self._broadcaster.key
|
assert bstream.key != self._broadcaster.key
|
||||||
assert bstream._recv == self._broadcaster._recv
|
assert bstream._recv == self._broadcaster._recv
|
||||||
|
|
||||||
|
# NOTE: we patch on a `.send()` to the bcaster so that the
|
||||||
|
# caller can still conduct 2-way streaming using this
|
||||||
|
# ``bstream`` handle transparently as though it was the msg
|
||||||
|
# stream instance.
|
||||||
|
bstream.send = self.send # type: ignore
|
||||||
|
|
||||||
yield bstream
|
yield bstream
|
||||||
|
|
||||||
|
|
||||||
class MsgStream(ReceiveMsgStream, trio.abc.Channel):
|
|
||||||
"""
|
|
||||||
Bidirectional message stream for use within an inter-actor actor
|
|
||||||
``Context```.
|
|
||||||
|
|
||||||
"""
|
|
||||||
async def send(
|
async def send(
|
||||||
self,
|
self,
|
||||||
data: Any
|
data: Any
|
||||||
) -> None:
|
) -> None:
|
||||||
'''Send a message over this stream to the far end.
|
'''
|
||||||
|
Send a message over this stream to the far end.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
# if self._eoc:
|
if self._ctx._error:
|
||||||
# raise trio.ClosedResourceError('This stream is already ded')
|
raise self._ctx._error # from None
|
||||||
|
|
||||||
|
if self._closed:
|
||||||
|
raise trio.ClosedResourceError('This stream was already closed')
|
||||||
|
|
||||||
await self._ctx.chan.send({'yield': data, 'cid': self._ctx.cid})
|
await self._ctx.chan.send({'yield': data, 'cid': self._ctx.cid})
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Context:
|
class Context:
|
||||||
'''An inter-actor task communication context.
|
'''
|
||||||
|
An inter-actor, ``trio`` task communication context.
|
||||||
|
|
||||||
|
NB: This class should never be instatiated directly, it is delivered
|
||||||
|
by either runtime machinery to a remotely started task or by entering
|
||||||
|
``Portal.open_context()``.
|
||||||
|
|
||||||
Allows maintaining task or protocol specific state between
|
Allows maintaining task or protocol specific state between
|
||||||
2 communicating actor tasks. A unique context is created on the
|
2 communicating actor tasks. A unique context is created on the
|
||||||
callee side/end for every request to a remote actor from a portal.
|
callee side/end for every request to a remote actor from a portal.
|
||||||
|
|
||||||
A context can be cancelled and (possibly eventually restarted) from
|
A context can be cancelled and (possibly eventually restarted) from
|
||||||
either side of the underlying IPC channel.
|
either side of the underlying IPC channel, open task oriented
|
||||||
|
message streams and acts as an IPC aware inter-actor-task cancel
|
||||||
A context can be used to open task oriented message streams and can
|
scope.
|
||||||
be thought of as an IPC aware inter-actor cancel scope.
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
chan: Channel
|
chan: Channel
|
||||||
cid: str
|
cid: str
|
||||||
|
|
||||||
|
# these are the "feeder" channels for delivering
|
||||||
|
# message values to the local task from the runtime
|
||||||
|
# msg processing loop.
|
||||||
|
_recv_chan: trio.MemoryReceiveChannel
|
||||||
|
_send_chan: trio.MemorySendChannel
|
||||||
|
|
||||||
|
_remote_func_type: Optional[str] = None
|
||||||
|
|
||||||
# only set on the caller side
|
# only set on the caller side
|
||||||
_portal: Optional['Portal'] = None # type: ignore # noqa
|
_portal: Optional['Portal'] = None # type: ignore # noqa
|
||||||
_recv_chan: Optional[trio.MemoryReceiveChannel] = None
|
|
||||||
_result: Optional[Any] = False
|
_result: Optional[Any] = False
|
||||||
|
_error: Optional[BaseException] = None
|
||||||
|
|
||||||
|
# status flags
|
||||||
_cancel_called: bool = False
|
_cancel_called: bool = False
|
||||||
|
_cancel_msg: Optional[str] = None
|
||||||
|
_enter_debugger_on_cancel: bool = True
|
||||||
|
_started_called: bool = False
|
||||||
|
_started_received: bool = False
|
||||||
|
_stream_opened: bool = False
|
||||||
|
|
||||||
# only set on the callee side
|
# only set on the callee side
|
||||||
_scope_nursery: Optional[trio.Nursery] = None
|
_scope_nursery: Optional[trio.Nursery] = None
|
||||||
|
|
||||||
|
_backpressure: bool = False
|
||||||
|
|
||||||
async def send_yield(self, data: Any) -> None:
|
async def send_yield(self, data: Any) -> None:
|
||||||
|
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
|
@ -340,32 +405,80 @@ class Context:
|
||||||
async def send_stop(self) -> None:
|
async def send_stop(self) -> None:
|
||||||
await self.chan.send({'stop': True, 'cid': self.cid})
|
await self.chan.send({'stop': True, 'cid': self.cid})
|
||||||
|
|
||||||
def _error_from_remote_msg(
|
async def _maybe_raise_from_remote_msg(
|
||||||
self,
|
self,
|
||||||
msg: Dict[str, Any],
|
msg: dict[str, Any],
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''Unpack and raise a msg error into the local scope
|
'''
|
||||||
|
(Maybe) unpack and raise a msg error into the local scope
|
||||||
nursery for this context.
|
nursery for this context.
|
||||||
|
|
||||||
Acts as a form of "relay" for a remote error raised
|
Acts as a form of "relay" for a remote error raised
|
||||||
in the corresponding remote callee task.
|
in the corresponding remote callee task.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
assert self._scope_nursery
|
error = msg.get('error')
|
||||||
|
if error:
|
||||||
|
# If this is an error message from a context opened by
|
||||||
|
# ``Portal.open_context()`` we want to interrupt any ongoing
|
||||||
|
# (child) tasks within that context to be notified of the remote
|
||||||
|
# error relayed here.
|
||||||
|
#
|
||||||
|
# The reason we may want to raise the remote error immediately
|
||||||
|
# is that there is no guarantee the associated local task(s)
|
||||||
|
# will attempt to read from any locally opened stream any time
|
||||||
|
# soon.
|
||||||
|
#
|
||||||
|
# NOTE: this only applies when
|
||||||
|
# ``Portal.open_context()`` has been called since it is assumed
|
||||||
|
# (currently) that other portal APIs (``Portal.run()``,
|
||||||
|
# ``.run_in_actor()``) do their own error checking at the point
|
||||||
|
# of the call and result processing.
|
||||||
|
log.error(
|
||||||
|
f'Remote context error for {self.chan.uid}:{self.cid}:\n'
|
||||||
|
f'{msg["error"]["tb_str"]}'
|
||||||
|
)
|
||||||
|
error = unpack_error(msg, self.chan)
|
||||||
|
if (
|
||||||
|
isinstance(error, ContextCancelled) and
|
||||||
|
self._cancel_called
|
||||||
|
):
|
||||||
|
# this is an expected cancel request response message
|
||||||
|
# and we don't need to raise it in scope since it will
|
||||||
|
# potentially override a real error
|
||||||
|
return
|
||||||
|
|
||||||
async def raiser():
|
self._error = error
|
||||||
raise unpack_error(msg, self.chan)
|
|
||||||
|
|
||||||
self._scope_nursery.start_soon(raiser)
|
# TODO: tempted to **not** do this by-reraising in a
|
||||||
|
# nursery and instead cancel a surrounding scope, detect
|
||||||
|
# the cancellation, then lookup the error that was set?
|
||||||
|
if self._scope_nursery:
|
||||||
|
|
||||||
async def cancel(self) -> None:
|
async def raiser():
|
||||||
'''Cancel this inter-actor-task context.
|
raise self._error from None
|
||||||
|
|
||||||
|
# from trio.testing import wait_all_tasks_blocked
|
||||||
|
# await wait_all_tasks_blocked()
|
||||||
|
if not self._scope_nursery._closed: # type: ignore
|
||||||
|
self._scope_nursery.start_soon(raiser)
|
||||||
|
|
||||||
|
async def cancel(
|
||||||
|
self,
|
||||||
|
msg: Optional[str] = None,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Cancel this inter-actor-task context.
|
||||||
|
|
||||||
Request that the far side cancel it's current linked context,
|
Request that the far side cancel it's current linked context,
|
||||||
Timeout quickly in an attempt to sidestep 2-generals...
|
Timeout quickly in an attempt to sidestep 2-generals...
|
||||||
|
|
||||||
'''
|
'''
|
||||||
side = 'caller' if self._portal else 'callee'
|
side = 'caller' if self._portal else 'callee'
|
||||||
|
if msg:
|
||||||
|
assert side == 'callee', 'Only callee side can provide cancel msg'
|
||||||
|
|
||||||
log.cancel(f'Cancelling {side} side of context to {self.chan.uid}')
|
log.cancel(f'Cancelling {side} side of context to {self.chan.uid}')
|
||||||
|
|
||||||
|
@ -402,8 +515,10 @@ class Context:
|
||||||
log.cancel(
|
log.cancel(
|
||||||
"Timed out on cancelling remote task "
|
"Timed out on cancelling remote task "
|
||||||
f"{cid} for {self._portal.channel.uid}")
|
f"{cid} for {self._portal.channel.uid}")
|
||||||
|
|
||||||
|
# callee side remote task
|
||||||
else:
|
else:
|
||||||
# callee side remote task
|
self._cancel_msg = msg
|
||||||
|
|
||||||
# TODO: should we have an explicit cancel message
|
# TODO: should we have an explicit cancel message
|
||||||
# or is relaying the local `trio.Cancelled` as an
|
# or is relaying the local `trio.Cancelled` as an
|
||||||
|
@ -420,9 +535,12 @@ class Context:
|
||||||
async def open_stream(
|
async def open_stream(
|
||||||
|
|
||||||
self,
|
self,
|
||||||
|
backpressure: Optional[bool] = True,
|
||||||
|
msg_buffer_size: Optional[int] = None,
|
||||||
|
|
||||||
) -> AsyncGenerator[MsgStream, None]:
|
) -> AsyncGenerator[MsgStream, None]:
|
||||||
'''Open a ``MsgStream``, a bi-directional stream connected to the
|
'''
|
||||||
|
Open a ``MsgStream``, a bi-directional stream connected to the
|
||||||
cross-actor (far end) task for this ``Context``.
|
cross-actor (far end) task for this ``Context``.
|
||||||
|
|
||||||
This context manager must be entered on both the caller and
|
This context manager must be entered on both the caller and
|
||||||
|
@ -455,50 +573,68 @@ class Context:
|
||||||
f'Context around {actor.uid[0]}:{task} was already cancelled!'
|
f'Context around {actor.uid[0]}:{task} was already cancelled!'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not self._portal and not self._started_called:
|
||||||
|
raise RuntimeError(
|
||||||
|
'Context.started()` must be called before opening a stream'
|
||||||
|
)
|
||||||
|
|
||||||
# NOTE: in one way streaming this only happens on the
|
# NOTE: in one way streaming this only happens on the
|
||||||
# caller side inside `Actor.send_cmd()` so if you try
|
# caller side inside `Actor.start_remote_task()` so if you try
|
||||||
# to send a stop from the caller to the callee in the
|
# to send a stop from the caller to the callee in the
|
||||||
# single-direction-stream case you'll get a lookup error
|
# single-direction-stream case you'll get a lookup error
|
||||||
# currently.
|
# currently.
|
||||||
_, recv_chan = actor.get_memchans(
|
ctx = actor.get_context(
|
||||||
self.chan.uid,
|
self.chan,
|
||||||
self.cid
|
self.cid,
|
||||||
|
msg_buffer_size=msg_buffer_size,
|
||||||
)
|
)
|
||||||
|
ctx._backpressure = backpressure
|
||||||
|
assert ctx is self
|
||||||
|
|
||||||
# XXX: If the underlying channel feeder receive mem chan has
|
# XXX: If the underlying channel feeder receive mem chan has
|
||||||
# been closed then likely client code has already exited
|
# been closed then likely client code has already exited
|
||||||
# a ``.open_stream()`` block prior or there was some other
|
# a ``.open_stream()`` block prior or there was some other
|
||||||
# unanticipated error or cancellation from ``trio``.
|
# unanticipated error or cancellation from ``trio``.
|
||||||
|
|
||||||
if recv_chan._closed:
|
if ctx._recv_chan._closed:
|
||||||
raise trio.ClosedResourceError(
|
raise trio.ClosedResourceError(
|
||||||
'The underlying channel for this stream was already closed!?')
|
'The underlying channel for this stream was already closed!?')
|
||||||
|
|
||||||
async with MsgStream(
|
async with MsgStream(
|
||||||
ctx=self,
|
ctx=self,
|
||||||
rx_chan=recv_chan,
|
rx_chan=ctx._recv_chan,
|
||||||
) as rchan:
|
) as stream:
|
||||||
|
|
||||||
if self._portal:
|
if self._portal:
|
||||||
self._portal._streams.add(rchan)
|
self._portal._streams.add(stream)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# ensure we aren't cancelled before delivering
|
self._stream_opened = True
|
||||||
# the stream
|
|
||||||
# await trio.lowlevel.checkpoint()
|
|
||||||
yield rchan
|
|
||||||
|
|
||||||
# XXX: Make the stream "one-shot use". On exit, signal
|
# XXX: do we need this?
|
||||||
|
# ensure we aren't cancelled before yielding the stream
|
||||||
|
# await trio.lowlevel.checkpoint()
|
||||||
|
yield stream
|
||||||
|
|
||||||
|
# NOTE: Make the stream "one-shot use". On exit, signal
|
||||||
# ``trio.EndOfChannel``/``StopAsyncIteration`` to the
|
# ``trio.EndOfChannel``/``StopAsyncIteration`` to the
|
||||||
# far end.
|
# far end.
|
||||||
await self.send_stop()
|
await stream.aclose()
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
if self._portal:
|
if self._portal:
|
||||||
self._portal._streams.remove(rchan)
|
try:
|
||||||
|
self._portal._streams.remove(stream)
|
||||||
|
except KeyError:
|
||||||
|
log.warning(
|
||||||
|
f'Stream was already destroyed?\n'
|
||||||
|
f'actor: {self.chan.uid}\n'
|
||||||
|
f'ctx id: {self.cid}'
|
||||||
|
)
|
||||||
|
|
||||||
async def result(self) -> Any:
|
async def result(self) -> Any:
|
||||||
'''From a caller side, wait for and return the final result from
|
'''
|
||||||
|
From a caller side, wait for and return the final result from
|
||||||
the callee side task.
|
the callee side task.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
@ -518,7 +654,7 @@ class Context:
|
||||||
try:
|
try:
|
||||||
self._result = msg['return']
|
self._result = msg['return']
|
||||||
break
|
break
|
||||||
except KeyError:
|
except KeyError as msgerr:
|
||||||
|
|
||||||
if 'yield' in msg:
|
if 'yield' in msg:
|
||||||
# far end task is still streaming to us so discard
|
# far end task is still streaming to us so discard
|
||||||
|
@ -532,17 +668,36 @@ class Context:
|
||||||
# internal error should never get here
|
# internal error should never get here
|
||||||
assert msg.get('cid'), (
|
assert msg.get('cid'), (
|
||||||
"Received internal error at portal?")
|
"Received internal error at portal?")
|
||||||
raise unpack_error(msg, self._portal.channel)
|
|
||||||
|
raise unpack_error(
|
||||||
|
msg, self._portal.channel
|
||||||
|
) from msgerr
|
||||||
|
|
||||||
return self._result
|
return self._result
|
||||||
|
|
||||||
async def started(self, value: Optional[Any] = None) -> None:
|
async def started(
|
||||||
|
self,
|
||||||
|
value: Optional[Any] = None
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Indicate to calling actor's task that this linked context
|
||||||
|
has started and send ``value`` to the other side.
|
||||||
|
|
||||||
|
On the calling side ``value`` is the second item delivered
|
||||||
|
in the tuple returned by ``Portal.open_context()``.
|
||||||
|
|
||||||
|
'''
|
||||||
if self._portal:
|
if self._portal:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Caller side context {self} can not call started!")
|
f"Caller side context {self} can not call started!")
|
||||||
|
|
||||||
|
elif self._started_called:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"called 'started' twice on context with {self.chan.uid}")
|
||||||
|
|
||||||
await self.chan.send({'started': value, 'cid': self.cid})
|
await self.chan.send({'started': value, 'cid': self.cid})
|
||||||
|
self._started_called = True
|
||||||
|
|
||||||
# TODO: do we need a restart api?
|
# TODO: do we need a restart api?
|
||||||
# async def restart(self) -> None:
|
# async def restart(self) -> None:
|
||||||
|
|
|
@ -1,21 +1,40 @@
|
||||||
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
``trio`` inspired apis and helpers
|
``trio`` inspired apis and helpers
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
from contextlib import asynccontextmanager as acm
|
||||||
from functools import partial
|
from functools import partial
|
||||||
import inspect
|
import inspect
|
||||||
import multiprocessing as mp
|
from typing import (
|
||||||
from typing import Tuple, List, Dict, Optional
|
Optional,
|
||||||
|
TYPE_CHECKING,
|
||||||
|
)
|
||||||
import typing
|
import typing
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
|
from exceptiongroup import BaseExceptionGroup
|
||||||
import trio
|
import trio
|
||||||
from async_generator import asynccontextmanager
|
|
||||||
|
|
||||||
from . import _debug
|
|
||||||
from ._debug import maybe_wait_for_debugger
|
from ._debug import maybe_wait_for_debugger
|
||||||
from ._state import current_actor, is_main_process, is_root_process
|
from ._state import current_actor, is_main_process
|
||||||
from .log import get_logger, get_loglevel
|
from .log import get_logger, get_loglevel
|
||||||
from ._actor import Actor
|
from ._runtime import Actor
|
||||||
from ._portal import Portal
|
from ._portal import Portal
|
||||||
from ._exceptions import is_multi_cancelled
|
from ._exceptions import is_multi_cancelled
|
||||||
from ._root import open_root_actor
|
from ._root import open_root_actor
|
||||||
|
@ -23,52 +42,98 @@ from . import _state
|
||||||
from . import _spawn
|
from . import _spawn
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import multiprocessing as mp
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
_default_bind_addr: Tuple[str, int] = ('127.0.0.1', 0)
|
_default_bind_addr: tuple[str, int] = ('127.0.0.1', 0)
|
||||||
|
|
||||||
|
|
||||||
class ActorNursery:
|
class ActorNursery:
|
||||||
"""Spawn scoped subprocess actors.
|
'''
|
||||||
"""
|
The fundamental actor supervision construct: spawn and manage
|
||||||
|
explicit lifetime and capability restricted, bootstrapped,
|
||||||
|
``trio.run()`` scheduled sub-processes.
|
||||||
|
|
||||||
|
Though the concept of a "process nursery" is different in complexity
|
||||||
|
and slightly different in semantics then a tradtional single
|
||||||
|
threaded task nursery, much of the interface is the same. New
|
||||||
|
processes each require a top level "parent" or "root" task which is
|
||||||
|
itself no different then any task started by a tradtional
|
||||||
|
``trio.Nursery``. The main difference is that each "actor" (a
|
||||||
|
process + ``trio.run()``) contains a full, paralell executing
|
||||||
|
``trio``-task-tree. The following super powers ensue:
|
||||||
|
|
||||||
|
- starting tasks in a child actor are completely independent of
|
||||||
|
tasks started in the current process. They execute in *parallel*
|
||||||
|
relative to tasks in the current process and are scheduled by their
|
||||||
|
own actor's ``trio`` run loop.
|
||||||
|
- tasks scheduled in a remote process still maintain an SC protocol
|
||||||
|
across memory boundaries using a so called "structured concurrency
|
||||||
|
dialogue protocol" which ensures task-hierarchy-lifetimes are linked.
|
||||||
|
- remote tasks (in another actor) can fail and relay failure back to
|
||||||
|
the caller task (in some other actor) via a seralized
|
||||||
|
``RemoteActorError`` which means no zombie process or RPC
|
||||||
|
initiated task can ever go off on its own.
|
||||||
|
|
||||||
|
'''
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
ria_nursery: trio.Nursery,
|
ria_nursery: trio.Nursery,
|
||||||
da_nursery: trio.Nursery,
|
da_nursery: trio.Nursery,
|
||||||
errors: Dict[Tuple[str, str], Exception],
|
errors: dict[tuple[str, str], BaseException],
|
||||||
) -> None:
|
) -> None:
|
||||||
# self.supervisor = supervisor # TODO
|
# self.supervisor = supervisor # TODO
|
||||||
self._actor: Actor = actor
|
self._actor: Actor = actor
|
||||||
self._ria_nursery = ria_nursery
|
self._ria_nursery = ria_nursery
|
||||||
self._da_nursery = da_nursery
|
self._da_nursery = da_nursery
|
||||||
self._children: Dict[
|
self._children: dict[
|
||||||
Tuple[str, str],
|
tuple[str, str],
|
||||||
Tuple[Actor, mp.Process, Optional[Portal]]
|
tuple[
|
||||||
|
Actor,
|
||||||
|
trio.Process | mp.Process,
|
||||||
|
Optional[Portal],
|
||||||
|
]
|
||||||
] = {}
|
] = {}
|
||||||
# portals spawned with ``run_in_actor()`` are
|
# portals spawned with ``run_in_actor()`` are
|
||||||
# cancelled when their "main" result arrives
|
# cancelled when their "main" result arrives
|
||||||
self._cancel_after_result_on_exit: set = set()
|
self._cancel_after_result_on_exit: set = set()
|
||||||
self.cancelled: bool = False
|
self.cancelled: bool = False
|
||||||
self._join_procs = trio.Event()
|
self._join_procs = trio.Event()
|
||||||
|
self._at_least_one_child_in_debug: bool = False
|
||||||
self.errors = errors
|
self.errors = errors
|
||||||
|
self.exited = trio.Event()
|
||||||
|
|
||||||
async def start_actor(
|
async def start_actor(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
*,
|
*,
|
||||||
bind_addr: Tuple[str, int] = _default_bind_addr,
|
bind_addr: tuple[str, int] = _default_bind_addr,
|
||||||
rpc_module_paths: List[str] = None,
|
rpc_module_paths: list[str] | None = None,
|
||||||
enable_modules: List[str] = None,
|
enable_modules: list[str] | None = None,
|
||||||
loglevel: str = None, # set log level per subactor
|
loglevel: str | None = None, # set log level per subactor
|
||||||
nursery: trio.Nursery = None,
|
nursery: trio.Nursery | None = None,
|
||||||
|
debug_mode: Optional[bool] | None = None,
|
||||||
|
infect_asyncio: bool = False,
|
||||||
) -> Portal:
|
) -> Portal:
|
||||||
|
'''
|
||||||
|
Start a (daemon) actor: an process that has no designated
|
||||||
|
"main task" besides the runtime.
|
||||||
|
|
||||||
|
'''
|
||||||
loglevel = loglevel or self._actor.loglevel or get_loglevel()
|
loglevel = loglevel or self._actor.loglevel or get_loglevel()
|
||||||
|
|
||||||
# configure and pass runtime state
|
# configure and pass runtime state
|
||||||
_rtv = _state._runtime_vars.copy()
|
_rtv = _state._runtime_vars.copy()
|
||||||
_rtv['_is_root'] = False
|
_rtv['_is_root'] = False
|
||||||
|
|
||||||
|
# allow setting debug policy per actor
|
||||||
|
if debug_mode is not None:
|
||||||
|
_rtv['_debug_mode'] = debug_mode
|
||||||
|
self._at_least_one_child_in_debug = True
|
||||||
|
|
||||||
enable_modules = enable_modules or []
|
enable_modules = enable_modules or []
|
||||||
|
|
||||||
if rpc_module_paths:
|
if rpc_module_paths:
|
||||||
|
@ -105,19 +170,25 @@ class ActorNursery:
|
||||||
bind_addr,
|
bind_addr,
|
||||||
parent_addr,
|
parent_addr,
|
||||||
_rtv, # run time vars
|
_rtv, # run time vars
|
||||||
|
infect_asyncio=infect_asyncio,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def run_in_actor(
|
async def run_in_actor(
|
||||||
self,
|
self,
|
||||||
|
|
||||||
fn: typing.Callable,
|
fn: typing.Callable,
|
||||||
*,
|
*,
|
||||||
|
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
bind_addr: Tuple[str, int] = _default_bind_addr,
|
bind_addr: tuple[str, int] = _default_bind_addr,
|
||||||
rpc_module_paths: Optional[List[str]] = None,
|
rpc_module_paths: list[str] | None = None,
|
||||||
enable_modules: List[str] = None,
|
enable_modules: list[str] | None = None,
|
||||||
loglevel: str = None, # set log level per subactor
|
loglevel: str | None = None, # set log level per subactor
|
||||||
|
infect_asyncio: bool = False,
|
||||||
|
|
||||||
**kwargs, # explicit args to ``fn``
|
**kwargs, # explicit args to ``fn``
|
||||||
|
|
||||||
) -> Portal:
|
) -> Portal:
|
||||||
"""Spawn a new actor, run a lone task, then terminate the actor and
|
"""Spawn a new actor, run a lone task, then terminate the actor and
|
||||||
return its result.
|
return its result.
|
||||||
|
@ -141,6 +212,7 @@ class ActorNursery:
|
||||||
loglevel=loglevel,
|
loglevel=loglevel,
|
||||||
# use the run_in_actor nursery
|
# use the run_in_actor nursery
|
||||||
nursery=self._ria_nursery,
|
nursery=self._ria_nursery,
|
||||||
|
infect_asyncio=infect_asyncio,
|
||||||
)
|
)
|
||||||
|
|
||||||
# XXX: don't allow stream funcs
|
# XXX: don't allow stream funcs
|
||||||
|
@ -207,7 +279,8 @@ class ActorNursery:
|
||||||
|
|
||||||
# spawn cancel tasks for each sub-actor
|
# spawn cancel tasks for each sub-actor
|
||||||
assert portal
|
assert portal
|
||||||
nursery.start_soon(portal.cancel_actor)
|
if portal.channel.connected():
|
||||||
|
nursery.start_soon(portal.cancel_actor)
|
||||||
|
|
||||||
# if we cancelled the cancel (we hung cancelling remote actors)
|
# if we cancelled the cancel (we hung cancelling remote actors)
|
||||||
# then hard kill all sub-processes
|
# then hard kill all sub-processes
|
||||||
|
@ -222,13 +295,17 @@ class ActorNursery:
|
||||||
self._join_procs.set()
|
self._join_procs.set()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@acm
|
||||||
async def _open_and_supervise_one_cancels_all_nursery(
|
async def _open_and_supervise_one_cancels_all_nursery(
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
|
|
||||||
) -> typing.AsyncGenerator[ActorNursery, None]:
|
) -> typing.AsyncGenerator[ActorNursery, None]:
|
||||||
|
|
||||||
|
# TODO: yay or nay?
|
||||||
|
__tracebackhide__ = True
|
||||||
|
|
||||||
# the collection of errors retreived from spawned sub-actors
|
# the collection of errors retreived from spawned sub-actors
|
||||||
errors: Dict[Tuple[str, str], Exception] = {}
|
errors: dict[tuple[str, str], BaseException] = {}
|
||||||
|
|
||||||
# This is the outermost level "deamon actor" nursery. It is awaited
|
# This is the outermost level "deamon actor" nursery. It is awaited
|
||||||
# **after** the below inner "run in actor nursery". This allows for
|
# **after** the below inner "run in actor nursery". This allows for
|
||||||
|
@ -261,19 +338,17 @@ async def _open_and_supervise_one_cancels_all_nursery(
|
||||||
# after we yield upwards
|
# after we yield upwards
|
||||||
yield anursery
|
yield anursery
|
||||||
|
|
||||||
|
# When we didn't error in the caller's scope,
|
||||||
|
# signal all process-monitor-tasks to conduct
|
||||||
|
# the "hard join phase".
|
||||||
log.runtime(
|
log.runtime(
|
||||||
f"Waiting on subactors {anursery._children} "
|
f"Waiting on subactors {anursery._children} "
|
||||||
"to complete"
|
"to complete"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Last bit before first nursery block ends in the case
|
|
||||||
# where we didn't error in the caller's scope
|
|
||||||
|
|
||||||
# signal all process monitor tasks to conduct
|
|
||||||
# hard join phase.
|
|
||||||
anursery._join_procs.set()
|
anursery._join_procs.set()
|
||||||
|
|
||||||
except BaseException as err:
|
except BaseException as inner_err:
|
||||||
|
errors[actor.uid] = inner_err
|
||||||
|
|
||||||
# If we error in the root but the debugger is
|
# If we error in the root but the debugger is
|
||||||
# engaged we don't want to prematurely kill (and
|
# engaged we don't want to prematurely kill (and
|
||||||
|
@ -281,60 +356,61 @@ async def _open_and_supervise_one_cancels_all_nursery(
|
||||||
# will make the pdb repl unusable.
|
# will make the pdb repl unusable.
|
||||||
# Instead try to wait for pdb to be released before
|
# Instead try to wait for pdb to be released before
|
||||||
# tearing down.
|
# tearing down.
|
||||||
await maybe_wait_for_debugger()
|
await maybe_wait_for_debugger(
|
||||||
|
child_in_debug=anursery._at_least_one_child_in_debug
|
||||||
|
)
|
||||||
|
|
||||||
# if the caller's scope errored then we activate our
|
# if the caller's scope errored then we activate our
|
||||||
# one-cancels-all supervisor strategy (don't
|
# one-cancels-all supervisor strategy (don't
|
||||||
# worry more are coming).
|
# worry more are coming).
|
||||||
anursery._join_procs.set()
|
anursery._join_procs.set()
|
||||||
|
|
||||||
try:
|
# XXX: hypothetically an error could be
|
||||||
# XXX: hypothetically an error could be
|
# raised and then a cancel signal shows up
|
||||||
# raised and then a cancel signal shows up
|
# slightly after in which case the `else:`
|
||||||
# slightly after in which case the `else:`
|
# block here might not complete? For now,
|
||||||
# block here might not complete? For now,
|
# shield both.
|
||||||
# shield both.
|
with trio.CancelScope(shield=True):
|
||||||
with trio.CancelScope(shield=True):
|
etype = type(inner_err)
|
||||||
etype = type(err)
|
if etype in (
|
||||||
if etype in (
|
trio.Cancelled,
|
||||||
trio.Cancelled,
|
KeyboardInterrupt
|
||||||
KeyboardInterrupt
|
) or (
|
||||||
) or (
|
is_multi_cancelled(inner_err)
|
||||||
is_multi_cancelled(err)
|
):
|
||||||
):
|
log.cancel(
|
||||||
log.cancel(
|
f"Nursery for {current_actor().uid} "
|
||||||
f"Nursery for {current_actor().uid} "
|
f"was cancelled with {etype}")
|
||||||
f"was cancelled with {etype}")
|
else:
|
||||||
else:
|
log.exception(
|
||||||
log.exception(
|
f"Nursery for {current_actor().uid} "
|
||||||
f"Nursery for {current_actor().uid} "
|
f"errored with")
|
||||||
f"errored with {err}, ")
|
|
||||||
|
|
||||||
# cancel all subactors
|
# cancel all subactors
|
||||||
await anursery.cancel()
|
await anursery.cancel()
|
||||||
|
|
||||||
except trio.MultiError as merr:
|
# ria_nursery scope end
|
||||||
# If we receive additional errors while waiting on
|
|
||||||
# remaining subactors that were cancelled,
|
|
||||||
# aggregate those errors with the original error
|
|
||||||
# that triggered this teardown.
|
|
||||||
if err not in merr.exceptions:
|
|
||||||
raise trio.MultiError(merr.exceptions + [err])
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
# ria_nursery scope end
|
# TODO: this is the handler around the ``.run_in_actor()``
|
||||||
|
# nursery. Ideally we can drop this entirely in the future as
|
||||||
# XXX: do we need a `trio.Cancelled` catch here as well?
|
# the whole ``.run_in_actor()`` API should be built "on top of"
|
||||||
# this is the catch around the ``.run_in_actor()`` nursery
|
# this lower level spawn-request-cancel "daemon actor" API where
|
||||||
|
# a local in-actor task nursery is used with one-to-one task
|
||||||
|
# + `await Portal.run()` calls and the results/errors are
|
||||||
|
# handled directly (inline) and errors by the local nursery.
|
||||||
except (
|
except (
|
||||||
|
|
||||||
Exception,
|
Exception,
|
||||||
trio.MultiError,
|
BaseExceptionGroup,
|
||||||
trio.Cancelled
|
trio.Cancelled
|
||||||
|
|
||||||
) as err:
|
) as err:
|
||||||
|
|
||||||
|
# XXX: yet another guard before allowing the cancel
|
||||||
|
# sequence in case a (single) child is in debug.
|
||||||
|
await maybe_wait_for_debugger(
|
||||||
|
child_in_debug=anursery._at_least_one_child_in_debug
|
||||||
|
)
|
||||||
|
|
||||||
# If actor-local error was raised while waiting on
|
# If actor-local error was raised while waiting on
|
||||||
# ".run_in_actor()" actors then we also want to cancel all
|
# ".run_in_actor()" actors then we also want to cancel all
|
||||||
# remaining sub-actors (due to our lone strategy:
|
# remaining sub-actors (due to our lone strategy:
|
||||||
|
@ -356,22 +432,26 @@ async def _open_and_supervise_one_cancels_all_nursery(
|
||||||
with trio.CancelScope(shield=True):
|
with trio.CancelScope(shield=True):
|
||||||
await anursery.cancel()
|
await anursery.cancel()
|
||||||
|
|
||||||
# use `MultiError` as needed
|
# use `BaseExceptionGroup` as needed
|
||||||
if len(errors) > 1:
|
if len(errors) > 1:
|
||||||
raise trio.MultiError(tuple(errors.values()))
|
raise BaseExceptionGroup(
|
||||||
|
'tractor.ActorNursery errored with',
|
||||||
|
tuple(errors.values()),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
raise list(errors.values())[0]
|
raise list(errors.values())[0]
|
||||||
|
|
||||||
# ria_nursery scope end - nursery checkpoint
|
# da_nursery scope end - nursery checkpoint
|
||||||
|
# final exit
|
||||||
# after nursery exit
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@acm
|
||||||
async def open_nursery(
|
async def open_nursery(
|
||||||
**kwargs,
|
**kwargs,
|
||||||
|
|
||||||
) -> typing.AsyncGenerator[ActorNursery, None]:
|
) -> typing.AsyncGenerator[ActorNursery, None]:
|
||||||
"""Create and yield a new ``ActorNursery`` to be used for spawning
|
'''
|
||||||
|
Create and yield a new ``ActorNursery`` to be used for spawning
|
||||||
structured concurrent subactors.
|
structured concurrent subactors.
|
||||||
|
|
||||||
When an actor is spawned a new trio task is started which
|
When an actor is spawned a new trio task is started which
|
||||||
|
@ -383,7 +463,8 @@ async def open_nursery(
|
||||||
close it. It turns out this approach is probably more correct
|
close it. It turns out this approach is probably more correct
|
||||||
anyway since it is more clear from the following nested nurseries
|
anyway since it is more clear from the following nested nurseries
|
||||||
which cancellation scopes correspond to each spawned subactor set.
|
which cancellation scopes correspond to each spawned subactor set.
|
||||||
"""
|
|
||||||
|
'''
|
||||||
implicit_runtime = False
|
implicit_runtime = False
|
||||||
|
|
||||||
actor = current_actor(err_on_no_runtime=False)
|
actor = current_actor(err_on_no_runtime=False)
|
||||||
|
@ -401,18 +482,23 @@ async def open_nursery(
|
||||||
async with open_root_actor(**kwargs) as actor:
|
async with open_root_actor(**kwargs) as actor:
|
||||||
assert actor is current_actor()
|
assert actor is current_actor()
|
||||||
|
|
||||||
# try:
|
try:
|
||||||
|
async with _open_and_supervise_one_cancels_all_nursery(
|
||||||
|
actor
|
||||||
|
) as anursery:
|
||||||
|
yield anursery
|
||||||
|
finally:
|
||||||
|
anursery.exited.set()
|
||||||
|
|
||||||
|
else: # sub-nursery case
|
||||||
|
|
||||||
|
try:
|
||||||
async with _open_and_supervise_one_cancels_all_nursery(
|
async with _open_and_supervise_one_cancels_all_nursery(
|
||||||
actor
|
actor
|
||||||
) as anursery:
|
) as anursery:
|
||||||
yield anursery
|
yield anursery
|
||||||
|
finally:
|
||||||
else: # sub-nursery case
|
anursery.exited.set()
|
||||||
|
|
||||||
async with _open_and_supervise_one_cancels_all_nursery(
|
|
||||||
actor
|
|
||||||
) as anursery:
|
|
||||||
yield anursery
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
log.debug("Nursery teardown complete")
|
log.debug("Nursery teardown complete")
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
'''
|
||||||
|
Experimental APIs and subsystems not yet validated to be included as
|
||||||
|
built-ins.
|
||||||
|
|
||||||
|
This is a staging area for ``tractor.builtin``.
|
||||||
|
|
||||||
|
'''
|
||||||
|
from ._pubsub import pub as msgpub
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'msgpub',
|
||||||
|
]
|
|
@ -0,0 +1,332 @@
|
||||||
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Single target entrypoint, remote-task, dynamic (no push if no consumer)
|
||||||
|
pubsub API using async an generator which muli-plexes to consumers by
|
||||||
|
key.
|
||||||
|
|
||||||
|
NOTE: this module is likely deprecated by the new bi-directional streaming
|
||||||
|
support provided by ``tractor.Context.open_stream()`` and friends.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import inspect
|
||||||
|
import typing
|
||||||
|
from typing import (
|
||||||
|
Any,
|
||||||
|
Callable,
|
||||||
|
)
|
||||||
|
from functools import partial
|
||||||
|
from async_generator import aclosing
|
||||||
|
|
||||||
|
import trio
|
||||||
|
import wrapt
|
||||||
|
|
||||||
|
from ..log import get_logger
|
||||||
|
from .._streaming import Context
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['pub']
|
||||||
|
|
||||||
|
log = get_logger('messaging')
|
||||||
|
|
||||||
|
|
||||||
|
async def fan_out_to_ctxs(
|
||||||
|
pub_async_gen_func: typing.Callable, # it's an async gen ... gd mypy
|
||||||
|
topics2ctxs: dict[str, list],
|
||||||
|
packetizer: typing.Callable | None = None,
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Request and fan out quotes to each subscribed actor channel.
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
def get_topics():
|
||||||
|
return tuple(topics2ctxs.keys())
|
||||||
|
|
||||||
|
agen = pub_async_gen_func(get_topics=get_topics)
|
||||||
|
|
||||||
|
async with aclosing(agen) as pub_gen:
|
||||||
|
|
||||||
|
async for published in pub_gen:
|
||||||
|
|
||||||
|
ctx_payloads: list[tuple[Context, Any]] = []
|
||||||
|
|
||||||
|
for topic, data in published.items():
|
||||||
|
log.debug(f"publishing {topic, data}")
|
||||||
|
|
||||||
|
# build a new dict packet or invoke provided packetizer
|
||||||
|
if packetizer is None:
|
||||||
|
packet = {topic: data}
|
||||||
|
|
||||||
|
else:
|
||||||
|
packet = packetizer(topic, data)
|
||||||
|
|
||||||
|
for ctx in topics2ctxs.get(topic, list()):
|
||||||
|
ctx_payloads.append((ctx, packet))
|
||||||
|
|
||||||
|
if not ctx_payloads:
|
||||||
|
log.debug(f"Unconsumed values:\n{published}")
|
||||||
|
|
||||||
|
# deliver to each subscriber (fan out)
|
||||||
|
if ctx_payloads:
|
||||||
|
for ctx, payload in ctx_payloads:
|
||||||
|
try:
|
||||||
|
await ctx.send_yield(payload)
|
||||||
|
except (
|
||||||
|
# That's right, anything you can think of...
|
||||||
|
trio.ClosedResourceError, ConnectionResetError,
|
||||||
|
ConnectionRefusedError,
|
||||||
|
):
|
||||||
|
log.warning(f"{ctx.chan} went down?")
|
||||||
|
for ctx_list in topics2ctxs.values():
|
||||||
|
try:
|
||||||
|
ctx_list.remove(ctx)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not get_topics():
|
||||||
|
log.warning(f"No subscribers left for {pub_gen}")
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def modify_subs(
|
||||||
|
|
||||||
|
topics2ctxs: dict[str, list[Context]],
|
||||||
|
topics: set[str],
|
||||||
|
ctx: Context,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
"""Absolute symbol subscription list for each quote stream.
|
||||||
|
|
||||||
|
Effectively a symbol subscription api.
|
||||||
|
"""
|
||||||
|
log.info(f"{ctx.chan.uid} changed subscription to {topics}")
|
||||||
|
|
||||||
|
# update map from each symbol to requesting client's chan
|
||||||
|
for topic in topics:
|
||||||
|
topics2ctxs.setdefault(topic, list()).append(ctx)
|
||||||
|
|
||||||
|
# remove any existing symbol subscriptions if symbol is not
|
||||||
|
# found in ``symbols``
|
||||||
|
# TODO: this can likely be factored out into the pub-sub api
|
||||||
|
for topic in filter(
|
||||||
|
lambda topic: topic not in topics, topics2ctxs.copy()
|
||||||
|
):
|
||||||
|
ctx_list = topics2ctxs.get(topic)
|
||||||
|
if ctx_list:
|
||||||
|
try:
|
||||||
|
ctx_list.remove(ctx)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not ctx_list:
|
||||||
|
# pop empty sets which will trigger bg quoter task termination
|
||||||
|
topics2ctxs.pop(topic)
|
||||||
|
|
||||||
|
|
||||||
|
_pub_state: dict[str, dict] = {}
|
||||||
|
_pubtask2lock: dict[str, trio.StrictFIFOLock] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def pub(
|
||||||
|
wrapped: typing.Callable | None = None,
|
||||||
|
*,
|
||||||
|
tasks: set[str] = set(),
|
||||||
|
):
|
||||||
|
"""Publisher async generator decorator.
|
||||||
|
|
||||||
|
A publisher can be called multiple times from different actors but
|
||||||
|
will only spawn a finite set of internal tasks to stream values to
|
||||||
|
each caller. The ``tasks: set[str]`` argument to the decorator
|
||||||
|
specifies the names of the mutex set of publisher tasks. When the
|
||||||
|
publisher function is called, an argument ``task_name`` must be
|
||||||
|
passed to specify which task (of the set named in ``tasks``) should
|
||||||
|
be used. This allows for using the same publisher with different
|
||||||
|
input (arguments) without allowing more concurrent tasks then
|
||||||
|
necessary.
|
||||||
|
|
||||||
|
Values yielded from the decorated async generator must be
|
||||||
|
``dict[str, dict[str, Any]]`` where the fist level key is the topic
|
||||||
|
string and determines which subscription the packet will be
|
||||||
|
delivered to and the value is a packet ``dict[str, Any]`` by default
|
||||||
|
of the form:
|
||||||
|
|
||||||
|
.. ::python
|
||||||
|
|
||||||
|
{topic: str: value: Any}
|
||||||
|
|
||||||
|
The caller can instead opt to pass a ``packetizer`` callback who's
|
||||||
|
return value will be delivered as the published response.
|
||||||
|
|
||||||
|
The decorated async generator function must accept an argument
|
||||||
|
:func:`get_topics` which dynamically returns the tuple of current
|
||||||
|
subscriber topics:
|
||||||
|
|
||||||
|
.. code:: python
|
||||||
|
|
||||||
|
from tractor.msg import pub
|
||||||
|
|
||||||
|
@pub(tasks={'source_1', 'source_2'})
|
||||||
|
async def pub_service(get_topics):
|
||||||
|
data = await web_request(endpoints=get_topics())
|
||||||
|
for item in data:
|
||||||
|
yield data['key'], data
|
||||||
|
|
||||||
|
|
||||||
|
The publisher must be called passing in the following arguments:
|
||||||
|
- ``topics: set[str]`` the topic sequence or "subscriptions"
|
||||||
|
- ``task_name: str`` the task to use (if ``tasks`` was passed)
|
||||||
|
- ``ctx: Context`` the tractor context (only needed if calling the
|
||||||
|
pub func without a nursery, otherwise this is provided implicitly)
|
||||||
|
- packetizer: ``Callable[[str, Any], Any]`` a callback who receives
|
||||||
|
the topic and value from the publisher function each ``yield`` such that
|
||||||
|
whatever is returned is sent as the published value to subscribers of
|
||||||
|
that topic. By default this is a dict ``{topic: str: value: Any}``.
|
||||||
|
|
||||||
|
As an example, to make a subscriber call the above function:
|
||||||
|
|
||||||
|
.. code:: python
|
||||||
|
|
||||||
|
from functools import partial
|
||||||
|
import tractor
|
||||||
|
|
||||||
|
async with tractor.open_nursery() as n:
|
||||||
|
portal = n.run_in_actor(
|
||||||
|
'publisher', # actor name
|
||||||
|
partial( # func to execute in it
|
||||||
|
pub_service,
|
||||||
|
topics=('clicks', 'users'),
|
||||||
|
task_name='source1',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async for value in await portal.result():
|
||||||
|
print(f"Subscriber received {value}")
|
||||||
|
|
||||||
|
|
||||||
|
Here, you don't need to provide the ``ctx`` argument since the
|
||||||
|
remote actor provides it automatically to the spawned task. If you
|
||||||
|
were to call ``pub_service()`` directly from a wrapping function you
|
||||||
|
would need to provide this explicitly.
|
||||||
|
|
||||||
|
Remember you only need this if you need *a finite set of tasks*
|
||||||
|
running in a single actor to stream data to an arbitrary number of
|
||||||
|
subscribers. If you are ok to have a new task running for every call
|
||||||
|
to ``pub_service()`` then probably don't need this.
|
||||||
|
"""
|
||||||
|
global _pubtask2lock
|
||||||
|
|
||||||
|
# handle the decorator not called with () case
|
||||||
|
if wrapped is None:
|
||||||
|
return partial(pub, tasks=tasks)
|
||||||
|
|
||||||
|
task2lock: dict[str, trio.StrictFIFOLock] = {}
|
||||||
|
|
||||||
|
for name in tasks:
|
||||||
|
task2lock[name] = trio.StrictFIFOLock()
|
||||||
|
|
||||||
|
@wrapt.decorator
|
||||||
|
async def wrapper(agen, instance, args, kwargs):
|
||||||
|
|
||||||
|
# XXX: this is used to extract arguments properly as per the
|
||||||
|
# `wrapt` docs
|
||||||
|
async def _execute(
|
||||||
|
ctx: Context,
|
||||||
|
topics: set[str],
|
||||||
|
*args,
|
||||||
|
# *,
|
||||||
|
task_name: str | None = None, # default: only one task allocated
|
||||||
|
packetizer: Callable | None = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
if task_name is None:
|
||||||
|
task_name = trio.lowlevel.current_task().name
|
||||||
|
|
||||||
|
if tasks and task_name not in tasks:
|
||||||
|
raise TypeError(
|
||||||
|
f"{agen} must be called with a `task_name` named "
|
||||||
|
f"argument with a value from {tasks}")
|
||||||
|
|
||||||
|
elif not tasks and not task2lock:
|
||||||
|
# add a default root-task lock if none defined
|
||||||
|
task2lock[task_name] = trio.StrictFIFOLock()
|
||||||
|
|
||||||
|
_pubtask2lock.update(task2lock)
|
||||||
|
|
||||||
|
topics = set(topics)
|
||||||
|
lock = _pubtask2lock[task_name]
|
||||||
|
|
||||||
|
all_subs = _pub_state.setdefault('_subs', {})
|
||||||
|
topics2ctxs = all_subs.setdefault(task_name, {})
|
||||||
|
|
||||||
|
try:
|
||||||
|
modify_subs(topics2ctxs, topics, ctx)
|
||||||
|
# block and let existing feed task deliver
|
||||||
|
# stream data until it is cancelled in which case
|
||||||
|
# the next waiting task will take over and spawn it again
|
||||||
|
async with lock:
|
||||||
|
# no data feeder task yet; so start one
|
||||||
|
respawn = True
|
||||||
|
while respawn:
|
||||||
|
respawn = False
|
||||||
|
log.info(
|
||||||
|
f"Spawning data feed task for {funcname}")
|
||||||
|
try:
|
||||||
|
# unblocks when no more symbols subscriptions exist
|
||||||
|
# and the streamer task terminates
|
||||||
|
await fan_out_to_ctxs(
|
||||||
|
pub_async_gen_func=partial(
|
||||||
|
agen, *args, **kwargs),
|
||||||
|
topics2ctxs=topics2ctxs,
|
||||||
|
packetizer=packetizer,
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
f"Terminating stream task {task_name or ''}"
|
||||||
|
f" for {agen.__name__}")
|
||||||
|
except trio.BrokenResourceError:
|
||||||
|
log.exception("Respawning failed data feed task")
|
||||||
|
respawn = True
|
||||||
|
finally:
|
||||||
|
# remove all subs for this context
|
||||||
|
modify_subs(topics2ctxs, set(), ctx)
|
||||||
|
|
||||||
|
# if there are truly no more subscriptions with this broker
|
||||||
|
# drop from broker subs dict
|
||||||
|
if not any(topics2ctxs.values()):
|
||||||
|
log.info(
|
||||||
|
f"No more subscriptions for publisher {task_name}")
|
||||||
|
|
||||||
|
# invoke it
|
||||||
|
await _execute(*args, **kwargs)
|
||||||
|
|
||||||
|
funcname = wrapped.__name__
|
||||||
|
if not inspect.isasyncgenfunction(wrapped):
|
||||||
|
raise TypeError(
|
||||||
|
f"Publisher {funcname} must be an async generator function"
|
||||||
|
)
|
||||||
|
if 'get_topics' not in inspect.signature(wrapped).parameters:
|
||||||
|
raise TypeError(
|
||||||
|
f"Publisher async gen {funcname} must define a "
|
||||||
|
"`get_topics` argument"
|
||||||
|
)
|
||||||
|
|
||||||
|
# XXX: manually monkey the wrapped function since
|
||||||
|
# ``wrapt.decorator`` doesn't seem to want to play nice with its
|
||||||
|
# whole "adapter" thing...
|
||||||
|
wrapped._tractor_stream_function = True # type: ignore
|
||||||
|
|
||||||
|
return wrapper(wrapped)
|
|
@ -1,16 +1,35 @@
|
||||||
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Log like a forester!
|
Log like a forester!
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
from collections.abc import Mapping
|
||||||
import sys
|
import sys
|
||||||
import logging
|
import logging
|
||||||
import colorlog # type: ignore
|
import colorlog # type: ignore
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from ._state import ActorContextInfo
|
import trio
|
||||||
|
|
||||||
|
from ._state import current_actor
|
||||||
|
|
||||||
|
|
||||||
_proj_name = 'tractor'
|
_proj_name: str = 'tractor'
|
||||||
_default_loglevel = 'ERROR'
|
_default_loglevel: str = 'ERROR'
|
||||||
|
|
||||||
# Super sexy formatting thanks to ``colorlog``.
|
# Super sexy formatting thanks to ``colorlog``.
|
||||||
# (NOTE: we use the '{' format style)
|
# (NOTE: we use the '{' format style)
|
||||||
|
@ -19,7 +38,8 @@ LOG_FORMAT = (
|
||||||
# "{bold_white}{log_color}{asctime}{reset}"
|
# "{bold_white}{log_color}{asctime}{reset}"
|
||||||
"{log_color}{asctime}{reset}"
|
"{log_color}{asctime}{reset}"
|
||||||
" {bold_white}{thin_white}({reset}"
|
" {bold_white}{thin_white}({reset}"
|
||||||
"{thin_white}{actor}, {process}, {task}){reset}{bold_white}{thin_white})"
|
"{thin_white}{actor_name}[{actor_uid}], "
|
||||||
|
"{process}, {task}){reset}{bold_white}{thin_white})"
|
||||||
" {reset}{log_color}[{reset}{bold_log_color}{levelname}{reset}{log_color}]"
|
" {reset}{log_color}[{reset}{bold_log_color}{levelname}{reset}{log_color}]"
|
||||||
" {log_color}{name}"
|
" {log_color}{name}"
|
||||||
" {thin_white}{filename}{log_color}:{reset}{thin_white}{lineno}{log_color}"
|
" {thin_white}{filename}{log_color}:{reset}{thin_white}{lineno}{log_color}"
|
||||||
|
@ -119,9 +139,40 @@ class StackLevelAdapter(logging.LoggerAdapter):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_conc_name_getters = {
|
||||||
|
'task': lambda: trio.lowlevel.current_task().name,
|
||||||
|
'actor': lambda: current_actor(),
|
||||||
|
'actor_name': lambda: current_actor().name,
|
||||||
|
'actor_uid': lambda: current_actor().uid[1][:6],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ActorContextInfo(Mapping):
|
||||||
|
"Dyanmic lookup for local actor and task names"
|
||||||
|
_context_keys = (
|
||||||
|
'task',
|
||||||
|
'actor',
|
||||||
|
'actor_name',
|
||||||
|
'actor_uid',
|
||||||
|
)
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self._context_keys)
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return iter(self._context_keys)
|
||||||
|
|
||||||
|
def __getitem__(self, key: str) -> str:
|
||||||
|
try:
|
||||||
|
return _conc_name_getters[key]()
|
||||||
|
except RuntimeError:
|
||||||
|
# no local actor/task context initialized yet
|
||||||
|
return f'no {key} context'
|
||||||
|
|
||||||
|
|
||||||
def get_logger(
|
def get_logger(
|
||||||
|
|
||||||
name: str = None,
|
name: str | None = None,
|
||||||
_root_name: str = _proj_name,
|
_root_name: str = _proj_name,
|
||||||
|
|
||||||
) -> StackLevelAdapter:
|
) -> StackLevelAdapter:
|
||||||
|
@ -156,7 +207,7 @@ def get_logger(
|
||||||
|
|
||||||
|
|
||||||
def get_console_log(
|
def get_console_log(
|
||||||
level: str = None,
|
level: str | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> logging.LoggerAdapter:
|
) -> logging.LoggerAdapter:
|
||||||
'''Get the package logger and enable a handler which writes to stderr.
|
'''Get the package logger and enable a handler which writes to stderr.
|
||||||
|
@ -189,5 +240,5 @@ def get_console_log(
|
||||||
return log
|
return log
|
||||||
|
|
||||||
|
|
||||||
def get_loglevel() -> Optional[str]:
|
def get_loglevel() -> str:
|
||||||
return _default_loglevel
|
return _default_loglevel
|
||||||
|
|
386
tractor/msg.py
386
tractor/msg.py
|
@ -1,306 +1,80 @@
|
||||||
"""
|
# tractor: structured concurrent "actors".
|
||||||
Messaging pattern APIs and helpers.
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
NOTE: this module is likely deprecated by the new bi-directional streaming
|
# This program is free software: you can redistribute it and/or modify
|
||||||
support provided by ``tractor.Context.open_stream()`` and friends.
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
"""
|
# (at your option) any later version.
|
||||||
import inspect
|
|
||||||
import typing
|
# This program is distributed in the hope that it will be useful,
|
||||||
from typing import Dict, Any, Set, Callable, List, Tuple
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
from functools import partial
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
from async_generator import aclosing
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
import trio
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
import wrapt
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from .log import get_logger
|
'''
|
||||||
from ._streaming import Context
|
Built-in messaging patterns, types, APIs and helpers.
|
||||||
|
|
||||||
__all__ = ['pub']
|
'''
|
||||||
|
|
||||||
log = get_logger('messaging')
|
# TODO: integration with our ``enable_modules: list[str]`` caps sys.
|
||||||
|
|
||||||
|
# ``pkgutil.resolve_name()`` internally uses
|
||||||
async def fan_out_to_ctxs(
|
# ``importlib.import_module()`` which can be filtered by inserting
|
||||||
pub_async_gen_func: typing.Callable, # it's an async gen ... gd mypy
|
# a ``MetaPathFinder`` into ``sys.meta_path`` (which we could do before
|
||||||
topics2ctxs: Dict[str, list],
|
# entering the ``_runtime.process_messages()`` loop).
|
||||||
packetizer: typing.Callable = None,
|
# - https://github.com/python/cpython/blob/main/Lib/pkgutil.py#L645
|
||||||
) -> None:
|
# - https://stackoverflow.com/questions/1350466/preventing-python-code-from-importing-certain-modules
|
||||||
"""Request and fan out quotes to each subscribed actor channel.
|
# - https://stackoverflow.com/a/63320902
|
||||||
"""
|
# - https://docs.python.org/3/library/sys.html#sys.meta_path
|
||||||
def get_topics():
|
|
||||||
return tuple(topics2ctxs.keys())
|
# the new "Implicit Namespace Packages" might be relevant?
|
||||||
|
# - https://www.python.org/dev/peps/pep-0420/
|
||||||
agen = pub_async_gen_func(get_topics=get_topics)
|
|
||||||
|
# add implicit serialized message type support so that paths can be
|
||||||
async with aclosing(agen) as pub_gen:
|
# handed directly to IPC primitives such as streams and `Portal.run()`
|
||||||
|
# calls:
|
||||||
async for published in pub_gen:
|
# - via ``msgspec``:
|
||||||
|
# - https://jcristharif.com/msgspec/api.html#struct
|
||||||
ctx_payloads: List[Tuple[Context, Any]] = []
|
# - https://jcristharif.com/msgspec/extending.html
|
||||||
|
# via ``msgpack-python``:
|
||||||
for topic, data in published.items():
|
# - https://github.com/msgpack/msgpack-python#packingunpacking-of-custom-data-type
|
||||||
log.debug(f"publishing {topic, data}")
|
|
||||||
|
from __future__ import annotations
|
||||||
# build a new dict packet or invoke provided packetizer
|
from pkgutil import resolve_name
|
||||||
if packetizer is None:
|
|
||||||
packet = {topic: data}
|
|
||||||
|
class NamespacePath(str):
|
||||||
else:
|
'''
|
||||||
packet = packetizer(topic, data)
|
A serializeable description of a (function) Python object location
|
||||||
|
described by the target's module path and namespace key meant as
|
||||||
for ctx in topics2ctxs.get(topic, list()):
|
a message-native "packet" to allows actors to point-and-load objects
|
||||||
ctx_payloads.append((ctx, packet))
|
by absolute reference.
|
||||||
|
|
||||||
if not ctx_payloads:
|
'''
|
||||||
log.debug(f"Unconsumed values:\n{published}")
|
_ref: object = None
|
||||||
|
|
||||||
# deliver to each subscriber (fan out)
|
def load_ref(self) -> object:
|
||||||
if ctx_payloads:
|
if self._ref is None:
|
||||||
for ctx, payload in ctx_payloads:
|
self._ref = resolve_name(self)
|
||||||
try:
|
return self._ref
|
||||||
await ctx.send_yield(payload)
|
|
||||||
except (
|
def to_tuple(
|
||||||
# That's right, anything you can think of...
|
self,
|
||||||
trio.ClosedResourceError, ConnectionResetError,
|
|
||||||
ConnectionRefusedError,
|
) -> tuple[str, str]:
|
||||||
):
|
ref = self.load_ref()
|
||||||
log.warning(f"{ctx.chan} went down?")
|
return ref.__module__, getattr(ref, '__name__', '')
|
||||||
for ctx_list in topics2ctxs.values():
|
|
||||||
try:
|
@classmethod
|
||||||
ctx_list.remove(ctx)
|
def from_ref(
|
||||||
except ValueError:
|
cls,
|
||||||
continue
|
ref,
|
||||||
|
|
||||||
if not get_topics():
|
) -> NamespacePath:
|
||||||
log.warning(f"No subscribers left for {pub_gen}")
|
return cls(':'.join(
|
||||||
break
|
(ref.__module__,
|
||||||
|
getattr(ref, '__name__', ''))
|
||||||
|
))
|
||||||
def modify_subs(
|
|
||||||
|
|
||||||
topics2ctxs: Dict[str, List[Context]],
|
|
||||||
topics: Set[str],
|
|
||||||
ctx: Context,
|
|
||||||
|
|
||||||
) -> None:
|
|
||||||
"""Absolute symbol subscription list for each quote stream.
|
|
||||||
|
|
||||||
Effectively a symbol subscription api.
|
|
||||||
"""
|
|
||||||
log.info(f"{ctx.chan.uid} changed subscription to {topics}")
|
|
||||||
|
|
||||||
# update map from each symbol to requesting client's chan
|
|
||||||
for topic in topics:
|
|
||||||
topics2ctxs.setdefault(topic, list()).append(ctx)
|
|
||||||
|
|
||||||
# remove any existing symbol subscriptions if symbol is not
|
|
||||||
# found in ``symbols``
|
|
||||||
# TODO: this can likely be factored out into the pub-sub api
|
|
||||||
for topic in filter(
|
|
||||||
lambda topic: topic not in topics, topics2ctxs.copy()
|
|
||||||
):
|
|
||||||
ctx_list = topics2ctxs.get(topic)
|
|
||||||
if ctx_list:
|
|
||||||
try:
|
|
||||||
ctx_list.remove(ctx)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not ctx_list:
|
|
||||||
# pop empty sets which will trigger bg quoter task termination
|
|
||||||
topics2ctxs.pop(topic)
|
|
||||||
|
|
||||||
|
|
||||||
_pub_state: Dict[str, dict] = {}
|
|
||||||
_pubtask2lock: Dict[str, trio.StrictFIFOLock] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def pub(
|
|
||||||
wrapped: typing.Callable = None,
|
|
||||||
*,
|
|
||||||
tasks: Set[str] = set(),
|
|
||||||
):
|
|
||||||
"""Publisher async generator decorator.
|
|
||||||
|
|
||||||
A publisher can be called multiple times from different actors but
|
|
||||||
will only spawn a finite set of internal tasks to stream values to
|
|
||||||
each caller. The ``tasks: Set[str]`` argument to the decorator
|
|
||||||
specifies the names of the mutex set of publisher tasks. When the
|
|
||||||
publisher function is called, an argument ``task_name`` must be
|
|
||||||
passed to specify which task (of the set named in ``tasks``) should
|
|
||||||
be used. This allows for using the same publisher with different
|
|
||||||
input (arguments) without allowing more concurrent tasks then
|
|
||||||
necessary.
|
|
||||||
|
|
||||||
Values yielded from the decorated async generator must be
|
|
||||||
``Dict[str, Dict[str, Any]]`` where the fist level key is the topic
|
|
||||||
string and determines which subscription the packet will be
|
|
||||||
delivered to and the value is a packet ``Dict[str, Any]`` by default
|
|
||||||
of the form:
|
|
||||||
|
|
||||||
.. ::python
|
|
||||||
|
|
||||||
{topic: str: value: Any}
|
|
||||||
|
|
||||||
The caller can instead opt to pass a ``packetizer`` callback who's
|
|
||||||
return value will be delivered as the published response.
|
|
||||||
|
|
||||||
The decorated async generator function must accept an argument
|
|
||||||
:func:`get_topics` which dynamically returns the tuple of current
|
|
||||||
subscriber topics:
|
|
||||||
|
|
||||||
.. code:: python
|
|
||||||
|
|
||||||
from tractor.msg import pub
|
|
||||||
|
|
||||||
@pub(tasks={'source_1', 'source_2'})
|
|
||||||
async def pub_service(get_topics):
|
|
||||||
data = await web_request(endpoints=get_topics())
|
|
||||||
for item in data:
|
|
||||||
yield data['key'], data
|
|
||||||
|
|
||||||
|
|
||||||
The publisher must be called passing in the following arguments:
|
|
||||||
- ``topics: Set[str]`` the topic sequence or "subscriptions"
|
|
||||||
- ``task_name: str`` the task to use (if ``tasks`` was passed)
|
|
||||||
- ``ctx: Context`` the tractor context (only needed if calling the
|
|
||||||
pub func without a nursery, otherwise this is provided implicitly)
|
|
||||||
- packetizer: ``Callable[[str, Any], Any]`` a callback who receives
|
|
||||||
the topic and value from the publisher function each ``yield`` such that
|
|
||||||
whatever is returned is sent as the published value to subscribers of
|
|
||||||
that topic. By default this is a dict ``{topic: str: value: Any}``.
|
|
||||||
|
|
||||||
As an example, to make a subscriber call the above function:
|
|
||||||
|
|
||||||
.. code:: python
|
|
||||||
|
|
||||||
from functools import partial
|
|
||||||
import tractor
|
|
||||||
|
|
||||||
async with tractor.open_nursery() as n:
|
|
||||||
portal = n.run_in_actor(
|
|
||||||
'publisher', # actor name
|
|
||||||
partial( # func to execute in it
|
|
||||||
pub_service,
|
|
||||||
topics=('clicks', 'users'),
|
|
||||||
task_name='source1',
|
|
||||||
)
|
|
||||||
)
|
|
||||||
async for value in await portal.result():
|
|
||||||
print(f"Subscriber received {value}")
|
|
||||||
|
|
||||||
|
|
||||||
Here, you don't need to provide the ``ctx`` argument since the
|
|
||||||
remote actor provides it automatically to the spawned task. If you
|
|
||||||
were to call ``pub_service()`` directly from a wrapping function you
|
|
||||||
would need to provide this explicitly.
|
|
||||||
|
|
||||||
Remember you only need this if you need *a finite set of tasks*
|
|
||||||
running in a single actor to stream data to an arbitrary number of
|
|
||||||
subscribers. If you are ok to have a new task running for every call
|
|
||||||
to ``pub_service()`` then probably don't need this.
|
|
||||||
"""
|
|
||||||
global _pubtask2lock
|
|
||||||
|
|
||||||
# handle the decorator not called with () case
|
|
||||||
if wrapped is None:
|
|
||||||
return partial(pub, tasks=tasks)
|
|
||||||
|
|
||||||
task2lock: Dict[str, trio.StrictFIFOLock] = {}
|
|
||||||
|
|
||||||
for name in tasks:
|
|
||||||
task2lock[name] = trio.StrictFIFOLock()
|
|
||||||
|
|
||||||
@wrapt.decorator
|
|
||||||
async def wrapper(agen, instance, args, kwargs):
|
|
||||||
|
|
||||||
# XXX: this is used to extract arguments properly as per the
|
|
||||||
# `wrapt` docs
|
|
||||||
async def _execute(
|
|
||||||
ctx: Context,
|
|
||||||
topics: Set[str],
|
|
||||||
*args,
|
|
||||||
# *,
|
|
||||||
task_name: str = None, # default: only one task allocated
|
|
||||||
packetizer: Callable = None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
if task_name is None:
|
|
||||||
task_name = trio.lowlevel.current_task().name
|
|
||||||
|
|
||||||
if tasks and task_name not in tasks:
|
|
||||||
raise TypeError(
|
|
||||||
f"{agen} must be called with a `task_name` named "
|
|
||||||
f"argument with a value from {tasks}")
|
|
||||||
|
|
||||||
elif not tasks and not task2lock:
|
|
||||||
# add a default root-task lock if none defined
|
|
||||||
task2lock[task_name] = trio.StrictFIFOLock()
|
|
||||||
|
|
||||||
_pubtask2lock.update(task2lock)
|
|
||||||
|
|
||||||
topics = set(topics)
|
|
||||||
lock = _pubtask2lock[task_name]
|
|
||||||
|
|
||||||
all_subs = _pub_state.setdefault('_subs', {})
|
|
||||||
topics2ctxs = all_subs.setdefault(task_name, {})
|
|
||||||
|
|
||||||
try:
|
|
||||||
modify_subs(topics2ctxs, topics, ctx)
|
|
||||||
# block and let existing feed task deliver
|
|
||||||
# stream data until it is cancelled in which case
|
|
||||||
# the next waiting task will take over and spawn it again
|
|
||||||
async with lock:
|
|
||||||
# no data feeder task yet; so start one
|
|
||||||
respawn = True
|
|
||||||
while respawn:
|
|
||||||
respawn = False
|
|
||||||
log.info(
|
|
||||||
f"Spawning data feed task for {funcname}")
|
|
||||||
try:
|
|
||||||
# unblocks when no more symbols subscriptions exist
|
|
||||||
# and the streamer task terminates
|
|
||||||
await fan_out_to_ctxs(
|
|
||||||
pub_async_gen_func=partial(
|
|
||||||
agen, *args, **kwargs),
|
|
||||||
topics2ctxs=topics2ctxs,
|
|
||||||
packetizer=packetizer,
|
|
||||||
)
|
|
||||||
log.info(
|
|
||||||
f"Terminating stream task {task_name or ''}"
|
|
||||||
f" for {agen.__name__}")
|
|
||||||
except trio.BrokenResourceError:
|
|
||||||
log.exception("Respawning failed data feed task")
|
|
||||||
respawn = True
|
|
||||||
finally:
|
|
||||||
# remove all subs for this context
|
|
||||||
modify_subs(topics2ctxs, set(), ctx)
|
|
||||||
|
|
||||||
# if there are truly no more subscriptions with this broker
|
|
||||||
# drop from broker subs dict
|
|
||||||
if not any(topics2ctxs.values()):
|
|
||||||
log.info(
|
|
||||||
f"No more subscriptions for publisher {task_name}")
|
|
||||||
|
|
||||||
# invoke it
|
|
||||||
await _execute(*args, **kwargs)
|
|
||||||
|
|
||||||
funcname = wrapped.__name__
|
|
||||||
if not inspect.isasyncgenfunction(wrapped):
|
|
||||||
raise TypeError(
|
|
||||||
f"Publisher {funcname} must be an async generator function"
|
|
||||||
)
|
|
||||||
if 'get_topics' not in inspect.signature(wrapped).parameters:
|
|
||||||
raise TypeError(
|
|
||||||
f"Publisher async gen {funcname} must define a "
|
|
||||||
"`get_topics` argument"
|
|
||||||
)
|
|
||||||
|
|
||||||
# XXX: manually monkey the wrapped function since
|
|
||||||
# ``wrapt.decorator`` doesn't seem to want to play nice with its
|
|
||||||
# whole "adapter" thing...
|
|
||||||
wrapped._tractor_stream_function = True # type: ignore
|
|
||||||
|
|
||||||
return wrapper(wrapped)
|
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
from ._tractor_test import tractor_test
|
|
|
@ -1,89 +0,0 @@
|
||||||
import inspect
|
|
||||||
import platform
|
|
||||||
from functools import partial, wraps
|
|
||||||
|
|
||||||
import trio
|
|
||||||
import tractor
|
|
||||||
# from tractor import run
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ['tractor_test']
|
|
||||||
|
|
||||||
|
|
||||||
def tractor_test(fn):
|
|
||||||
"""
|
|
||||||
Use:
|
|
||||||
|
|
||||||
@tractor_test
|
|
||||||
async def test_whatever():
|
|
||||||
await ...
|
|
||||||
|
|
||||||
If fixtures:
|
|
||||||
|
|
||||||
- ``arb_addr`` (a socket addr tuple where arbiter is listening)
|
|
||||||
- ``loglevel`` (logging level passed to tractor internals)
|
|
||||||
- ``start_method`` (subprocess spawning backend)
|
|
||||||
|
|
||||||
are defined in the `pytest` fixture space they will be automatically
|
|
||||||
injected to tests declaring these funcargs.
|
|
||||||
"""
|
|
||||||
@wraps(fn)
|
|
||||||
def wrapper(
|
|
||||||
*args,
|
|
||||||
loglevel=None,
|
|
||||||
arb_addr=None,
|
|
||||||
start_method=None,
|
|
||||||
**kwargs
|
|
||||||
):
|
|
||||||
# __tracebackhide__ = True
|
|
||||||
|
|
||||||
if 'arb_addr' in inspect.signature(fn).parameters:
|
|
||||||
# injects test suite fixture value to test as well
|
|
||||||
# as `run()`
|
|
||||||
kwargs['arb_addr'] = arb_addr
|
|
||||||
|
|
||||||
if 'loglevel' in inspect.signature(fn).parameters:
|
|
||||||
# allows test suites to define a 'loglevel' fixture
|
|
||||||
# that activates the internal logging
|
|
||||||
kwargs['loglevel'] = loglevel
|
|
||||||
|
|
||||||
if start_method is None:
|
|
||||||
if platform.system() == "Windows":
|
|
||||||
start_method = 'spawn'
|
|
||||||
else:
|
|
||||||
start_method = 'trio'
|
|
||||||
|
|
||||||
if 'start_method' in inspect.signature(fn).parameters:
|
|
||||||
# set of subprocess spawning backends
|
|
||||||
kwargs['start_method'] = start_method
|
|
||||||
|
|
||||||
if kwargs:
|
|
||||||
|
|
||||||
# use explicit root actor start
|
|
||||||
|
|
||||||
async def _main():
|
|
||||||
async with tractor.open_root_actor(
|
|
||||||
# **kwargs,
|
|
||||||
arbiter_addr=arb_addr,
|
|
||||||
loglevel=loglevel,
|
|
||||||
start_method=start_method,
|
|
||||||
|
|
||||||
# TODO: only enable when pytest is passed --pdb
|
|
||||||
# debug_mode=True,
|
|
||||||
|
|
||||||
) as actor:
|
|
||||||
await fn(*args, **kwargs)
|
|
||||||
|
|
||||||
main = _main
|
|
||||||
|
|
||||||
else:
|
|
||||||
# use implicit root actor start
|
|
||||||
main = partial(fn, *args, **kwargs)
|
|
||||||
|
|
||||||
return trio.run(main)
|
|
||||||
# arbiter_addr=arb_addr,
|
|
||||||
# loglevel=loglevel,
|
|
||||||
# start_method=start_method,
|
|
||||||
# )
|
|
||||||
|
|
||||||
return wrapper
|
|
|
@ -0,0 +1,550 @@
|
||||||
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
'''
|
||||||
|
Infection apis for ``asyncio`` loops running ``trio`` using guest mode.
|
||||||
|
|
||||||
|
'''
|
||||||
|
import asyncio
|
||||||
|
from asyncio.exceptions import CancelledError
|
||||||
|
from contextlib import asynccontextmanager as acm
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import inspect
|
||||||
|
from typing import (
|
||||||
|
Any,
|
||||||
|
Callable,
|
||||||
|
AsyncIterator,
|
||||||
|
Awaitable,
|
||||||
|
Optional,
|
||||||
|
)
|
||||||
|
|
||||||
|
import trio
|
||||||
|
from outcome import Error
|
||||||
|
|
||||||
|
from .log import get_logger
|
||||||
|
from ._state import current_actor
|
||||||
|
from ._exceptions import AsyncioCancelled
|
||||||
|
from .trionics._broadcast import (
|
||||||
|
broadcast_receiver,
|
||||||
|
BroadcastReceiver,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['run_task', 'run_as_asyncio_guest']
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LinkedTaskChannel(trio.abc.Channel):
|
||||||
|
'''
|
||||||
|
A "linked task channel" which allows for two-way synchronized msg
|
||||||
|
passing between a ``trio``-in-guest-mode task and an ``asyncio``
|
||||||
|
task scheduled in the host loop.
|
||||||
|
|
||||||
|
'''
|
||||||
|
_to_aio: asyncio.Queue
|
||||||
|
_from_aio: trio.MemoryReceiveChannel
|
||||||
|
_to_trio: trio.MemorySendChannel
|
||||||
|
|
||||||
|
_trio_cs: trio.CancelScope
|
||||||
|
_aio_task_complete: trio.Event
|
||||||
|
_trio_exited: bool = False
|
||||||
|
|
||||||
|
# set after ``asyncio.create_task()``
|
||||||
|
_aio_task: Optional[asyncio.Task] = None
|
||||||
|
_aio_err: Optional[BaseException] = None
|
||||||
|
_broadcaster: Optional[BroadcastReceiver] = None
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
await self._from_aio.aclose()
|
||||||
|
|
||||||
|
async def receive(self) -> Any:
|
||||||
|
async with translate_aio_errors(
|
||||||
|
self,
|
||||||
|
|
||||||
|
# XXX: obviously this will deadlock if an on-going stream is
|
||||||
|
# being procesed.
|
||||||
|
# wait_on_aio_task=False,
|
||||||
|
):
|
||||||
|
|
||||||
|
# TODO: do we need this to guarantee asyncio code get's
|
||||||
|
# cancelled in the case where the trio side somehow creates
|
||||||
|
# a state where the asyncio cycle-task isn't getting the
|
||||||
|
# cancel request sent by (in theory) the last checkpoint
|
||||||
|
# cycle on the trio side?
|
||||||
|
# await trio.lowlevel.checkpoint()
|
||||||
|
|
||||||
|
return await self._from_aio.receive()
|
||||||
|
|
||||||
|
async def wait_asyncio_complete(self) -> None:
|
||||||
|
await self._aio_task_complete.wait()
|
||||||
|
|
||||||
|
# def cancel_asyncio_task(self) -> None:
|
||||||
|
# self._aio_task.cancel()
|
||||||
|
|
||||||
|
async def send(self, item: Any) -> None:
|
||||||
|
'''
|
||||||
|
Send a value through to the asyncio task presuming
|
||||||
|
it defines a ``from_trio`` argument, if it does not
|
||||||
|
this method will raise an error.
|
||||||
|
|
||||||
|
'''
|
||||||
|
self._to_aio.put_nowait(item)
|
||||||
|
|
||||||
|
def closed(self) -> bool:
|
||||||
|
return self._from_aio._closed # type: ignore
|
||||||
|
|
||||||
|
# TODO: shoud we consider some kind of "decorator" system
|
||||||
|
# that checks for structural-typing compatibliity and then
|
||||||
|
# automatically adds this ctx-mngr-as-method machinery?
|
||||||
|
@acm
|
||||||
|
async def subscribe(
|
||||||
|
self,
|
||||||
|
|
||||||
|
) -> AsyncIterator[BroadcastReceiver]:
|
||||||
|
'''
|
||||||
|
Allocate and return a ``BroadcastReceiver`` which delegates
|
||||||
|
to this inter-task channel.
|
||||||
|
|
||||||
|
This allows multiple local tasks to receive each their own copy
|
||||||
|
of this message stream.
|
||||||
|
|
||||||
|
See ``tractor._streaming.MsgStream.subscribe()`` for further
|
||||||
|
similar details.
|
||||||
|
'''
|
||||||
|
if self._broadcaster is None:
|
||||||
|
|
||||||
|
bcast = self._broadcaster = broadcast_receiver(
|
||||||
|
self,
|
||||||
|
# use memory channel size by default
|
||||||
|
self._from_aio._state.max_buffer_size, # type: ignore
|
||||||
|
receive_afunc=self.receive,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.receive = bcast.receive # type: ignore
|
||||||
|
|
||||||
|
async with self._broadcaster.subscribe() as bstream:
|
||||||
|
assert bstream.key != self._broadcaster.key
|
||||||
|
assert bstream._recv == self._broadcaster._recv
|
||||||
|
yield bstream
|
||||||
|
|
||||||
|
|
||||||
|
def _run_asyncio_task(
|
||||||
|
|
||||||
|
func: Callable,
|
||||||
|
*,
|
||||||
|
qsize: int = 1,
|
||||||
|
provide_channels: bool = False,
|
||||||
|
**kwargs,
|
||||||
|
|
||||||
|
) -> LinkedTaskChannel:
|
||||||
|
'''
|
||||||
|
Run an ``asyncio`` async function or generator in a task, return
|
||||||
|
or stream the result back to ``trio``.
|
||||||
|
|
||||||
|
'''
|
||||||
|
__tracebackhide__ = True
|
||||||
|
if not current_actor().is_infected_aio():
|
||||||
|
raise RuntimeError("`infect_asyncio` mode is not enabled!?")
|
||||||
|
|
||||||
|
# ITC (inter task comms), these channel/queue names are mostly from
|
||||||
|
# ``asyncio``'s perspective.
|
||||||
|
aio_q = from_trio = asyncio.Queue(qsize) # type: ignore
|
||||||
|
to_trio, from_aio = trio.open_memory_channel(qsize) # type: ignore
|
||||||
|
|
||||||
|
args = tuple(inspect.getfullargspec(func).args)
|
||||||
|
|
||||||
|
if getattr(func, '_tractor_steam_function', None):
|
||||||
|
# the assumption is that the target async routine accepts the
|
||||||
|
# send channel then it intends to yield more then one return
|
||||||
|
# value otherwise it would just return ;P
|
||||||
|
assert qsize > 1
|
||||||
|
|
||||||
|
if provide_channels:
|
||||||
|
assert 'to_trio' in args
|
||||||
|
|
||||||
|
# allow target func to accept/stream results manually by name
|
||||||
|
if 'to_trio' in args:
|
||||||
|
kwargs['to_trio'] = to_trio
|
||||||
|
|
||||||
|
if 'from_trio' in args:
|
||||||
|
kwargs['from_trio'] = from_trio
|
||||||
|
|
||||||
|
coro = func(**kwargs)
|
||||||
|
|
||||||
|
cancel_scope = trio.CancelScope()
|
||||||
|
aio_task_complete = trio.Event()
|
||||||
|
aio_err: Optional[BaseException] = None
|
||||||
|
|
||||||
|
chan = LinkedTaskChannel(
|
||||||
|
aio_q, # asyncio.Queue
|
||||||
|
from_aio, # recv chan
|
||||||
|
to_trio, # send chan
|
||||||
|
|
||||||
|
cancel_scope,
|
||||||
|
aio_task_complete,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def wait_on_coro_final_result(
|
||||||
|
|
||||||
|
to_trio: trio.MemorySendChannel,
|
||||||
|
coro: Awaitable,
|
||||||
|
aio_task_complete: trio.Event,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Await ``coro`` and relay result back to ``trio``.
|
||||||
|
|
||||||
|
'''
|
||||||
|
nonlocal aio_err
|
||||||
|
nonlocal chan
|
||||||
|
|
||||||
|
orig = result = id(coro)
|
||||||
|
try:
|
||||||
|
result = await coro
|
||||||
|
except BaseException as aio_err:
|
||||||
|
log.exception('asyncio task errored')
|
||||||
|
chan._aio_err = aio_err
|
||||||
|
raise
|
||||||
|
|
||||||
|
else:
|
||||||
|
if (
|
||||||
|
result != orig and
|
||||||
|
aio_err is None and
|
||||||
|
|
||||||
|
# in the ``open_channel_from()`` case we don't
|
||||||
|
# relay through the "return value".
|
||||||
|
not provide_channels
|
||||||
|
):
|
||||||
|
to_trio.send_nowait(result)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# if the task was spawned using ``open_channel_from()``
|
||||||
|
# then we close the channels on exit.
|
||||||
|
if provide_channels:
|
||||||
|
# only close the sender side which will relay
|
||||||
|
# a ``trio.EndOfChannel`` to the trio (consumer) side.
|
||||||
|
to_trio.close()
|
||||||
|
|
||||||
|
aio_task_complete.set()
|
||||||
|
log.runtime(f'`asyncio` task: {task.get_name()} is complete')
|
||||||
|
|
||||||
|
# start the asyncio task we submitted from trio
|
||||||
|
if not inspect.isawaitable(coro):
|
||||||
|
raise TypeError(f"No support for invoking {coro}")
|
||||||
|
|
||||||
|
task = asyncio.create_task(
|
||||||
|
wait_on_coro_final_result(
|
||||||
|
to_trio,
|
||||||
|
coro,
|
||||||
|
aio_task_complete
|
||||||
|
)
|
||||||
|
)
|
||||||
|
chan._aio_task = task
|
||||||
|
|
||||||
|
def cancel_trio(task: asyncio.Task) -> None:
|
||||||
|
'''
|
||||||
|
Cancel the calling ``trio`` task on error.
|
||||||
|
|
||||||
|
'''
|
||||||
|
nonlocal chan
|
||||||
|
aio_err = chan._aio_err
|
||||||
|
task_err: Optional[BaseException] = None
|
||||||
|
|
||||||
|
# only to avoid ``asyncio`` complaining about uncaptured
|
||||||
|
# task exceptions
|
||||||
|
try:
|
||||||
|
task.exception()
|
||||||
|
except BaseException as terr:
|
||||||
|
task_err = terr
|
||||||
|
|
||||||
|
if isinstance(terr, CancelledError):
|
||||||
|
log.cancel(f'`asyncio` task cancelled: {task.get_name()}')
|
||||||
|
else:
|
||||||
|
log.exception(f'`asyncio` task: {task.get_name()} errored')
|
||||||
|
|
||||||
|
assert type(terr) is type(aio_err), 'Asyncio task error mismatch?'
|
||||||
|
|
||||||
|
if aio_err is not None:
|
||||||
|
# XXX: uhh is this true?
|
||||||
|
# assert task_err, f'Asyncio task {task.get_name()} discrepancy!?'
|
||||||
|
|
||||||
|
# NOTE: currently mem chan closure may act as a form
|
||||||
|
# of error relay (at least in the ``asyncio.CancelledError``
|
||||||
|
# case) since we have no way to directly trigger a ``trio``
|
||||||
|
# task error without creating a nursery to throw one.
|
||||||
|
# We might want to change this in the future though.
|
||||||
|
from_aio.close()
|
||||||
|
|
||||||
|
if type(aio_err) is CancelledError:
|
||||||
|
log.cancel("infected task was cancelled")
|
||||||
|
|
||||||
|
# TODO: show that the cancellation originated
|
||||||
|
# from the ``trio`` side? right?
|
||||||
|
# if cancel_scope.cancelled:
|
||||||
|
# raise aio_err from err
|
||||||
|
|
||||||
|
elif task_err is None:
|
||||||
|
assert aio_err
|
||||||
|
aio_err.with_traceback(aio_err.__traceback__)
|
||||||
|
log.error('infected task errorred')
|
||||||
|
|
||||||
|
# XXX: alway cancel the scope on error
|
||||||
|
# in case the trio task is blocking
|
||||||
|
# on a checkpoint.
|
||||||
|
cancel_scope.cancel()
|
||||||
|
|
||||||
|
# raise any ``asyncio`` side error.
|
||||||
|
raise aio_err
|
||||||
|
|
||||||
|
task.add_done_callback(cancel_trio)
|
||||||
|
return chan
|
||||||
|
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def translate_aio_errors(
|
||||||
|
|
||||||
|
chan: LinkedTaskChannel,
|
||||||
|
wait_on_aio_task: bool = False,
|
||||||
|
|
||||||
|
) -> AsyncIterator[None]:
|
||||||
|
'''
|
||||||
|
Error handling context around ``asyncio`` task spawns which
|
||||||
|
appropriately translates errors and cancels into ``trio`` land.
|
||||||
|
|
||||||
|
'''
|
||||||
|
trio_task = trio.lowlevel.current_task()
|
||||||
|
|
||||||
|
aio_err: Optional[BaseException] = None
|
||||||
|
|
||||||
|
# TODO: make thisi a channel method?
|
||||||
|
def maybe_raise_aio_err(
|
||||||
|
err: Optional[Exception] = None
|
||||||
|
) -> None:
|
||||||
|
aio_err = chan._aio_err
|
||||||
|
if (
|
||||||
|
aio_err is not None and
|
||||||
|
type(aio_err) != CancelledError
|
||||||
|
):
|
||||||
|
# always raise from any captured asyncio error
|
||||||
|
if err:
|
||||||
|
raise aio_err from err
|
||||||
|
else:
|
||||||
|
raise aio_err
|
||||||
|
|
||||||
|
task = chan._aio_task
|
||||||
|
assert task
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
|
||||||
|
except (
|
||||||
|
trio.Cancelled,
|
||||||
|
):
|
||||||
|
# relay cancel through to called ``asyncio`` task
|
||||||
|
assert chan._aio_task
|
||||||
|
chan._aio_task.cancel(
|
||||||
|
msg=f'the `trio` caller task was cancelled: {trio_task.name}'
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
except (
|
||||||
|
# NOTE: see the note in the ``cancel_trio()`` asyncio task
|
||||||
|
# termination callback
|
||||||
|
trio.ClosedResourceError,
|
||||||
|
# trio.BrokenResourceError,
|
||||||
|
):
|
||||||
|
aio_err = chan._aio_err
|
||||||
|
if (
|
||||||
|
task.cancelled() and
|
||||||
|
type(aio_err) is CancelledError
|
||||||
|
):
|
||||||
|
# if an underlying ``asyncio.CancelledError`` triggered this
|
||||||
|
# channel close, raise our (non-``BaseException``) wrapper
|
||||||
|
# error: ``AsyncioCancelled`` from that source error.
|
||||||
|
raise AsyncioCancelled from aio_err
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if (
|
||||||
|
# NOTE: always cancel the ``asyncio`` task if we've made it
|
||||||
|
# this far and it's not done.
|
||||||
|
not task.done() and aio_err
|
||||||
|
|
||||||
|
# or the trio side has exited it's surrounding cancel scope
|
||||||
|
# indicating the lifetime of the ``asyncio``-side task
|
||||||
|
# should also be terminated.
|
||||||
|
or chan._trio_exited
|
||||||
|
):
|
||||||
|
log.runtime(
|
||||||
|
f'Cancelling `asyncio`-task: {task.get_name()}'
|
||||||
|
)
|
||||||
|
# assert not aio_err, 'WTF how did asyncio do this?!'
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
# Required to sync with the far end ``asyncio``-task to ensure
|
||||||
|
# any error is captured (via monkeypatching the
|
||||||
|
# ``channel._aio_err``) before calling ``maybe_raise_aio_err()``
|
||||||
|
# below!
|
||||||
|
if wait_on_aio_task:
|
||||||
|
await chan._aio_task_complete.wait()
|
||||||
|
|
||||||
|
# NOTE: if any ``asyncio`` error was caught, raise it here inline
|
||||||
|
# here in the ``trio`` task
|
||||||
|
maybe_raise_aio_err()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_task(
|
||||||
|
func: Callable,
|
||||||
|
*,
|
||||||
|
|
||||||
|
qsize: int = 2**10,
|
||||||
|
**kwargs,
|
||||||
|
|
||||||
|
) -> Any:
|
||||||
|
'''
|
||||||
|
Run an ``asyncio`` async function or generator in a task, return
|
||||||
|
or stream the result back to ``trio``.
|
||||||
|
|
||||||
|
'''
|
||||||
|
# simple async func
|
||||||
|
chan = _run_asyncio_task(
|
||||||
|
func,
|
||||||
|
qsize=1,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
with chan._from_aio:
|
||||||
|
async with translate_aio_errors(
|
||||||
|
chan,
|
||||||
|
wait_on_aio_task=True,
|
||||||
|
):
|
||||||
|
# return single value that is the output from the
|
||||||
|
# ``asyncio`` function-as-task. Expect the mem chan api to
|
||||||
|
# do the job of handling cross-framework cancellations
|
||||||
|
# / errors via closure and translation in the
|
||||||
|
# ``translate_aio_errors()`` in the above ctx mngr.
|
||||||
|
return await chan.receive()
|
||||||
|
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def open_channel_from(
|
||||||
|
|
||||||
|
target: Callable[..., Any],
|
||||||
|
**kwargs,
|
||||||
|
|
||||||
|
) -> AsyncIterator[Any]:
|
||||||
|
'''
|
||||||
|
Open an inter-loop linked task channel for streaming between a target
|
||||||
|
spawned ``asyncio`` task and ``trio``.
|
||||||
|
|
||||||
|
'''
|
||||||
|
chan = _run_asyncio_task(
|
||||||
|
target,
|
||||||
|
qsize=2**8,
|
||||||
|
provide_channels=True,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
async with chan._from_aio:
|
||||||
|
async with translate_aio_errors(
|
||||||
|
chan,
|
||||||
|
wait_on_aio_task=True,
|
||||||
|
):
|
||||||
|
# sync to a "started()"-like first delivered value from the
|
||||||
|
# ``asyncio`` task.
|
||||||
|
try:
|
||||||
|
with chan._trio_cs:
|
||||||
|
first = await chan.receive()
|
||||||
|
|
||||||
|
# deliver stream handle upward
|
||||||
|
yield first, chan
|
||||||
|
finally:
|
||||||
|
chan._trio_exited = True
|
||||||
|
chan._to_trio.close()
|
||||||
|
|
||||||
|
|
||||||
|
def run_as_asyncio_guest(
|
||||||
|
|
||||||
|
trio_main: Callable,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
Entry for an "infected ``asyncio`` actor".
|
||||||
|
|
||||||
|
Entrypoint for a Python process which starts the ``asyncio`` event
|
||||||
|
loop and runs ``trio`` in guest mode resulting in a system where
|
||||||
|
``trio`` tasks can control ``asyncio`` tasks whilst maintaining
|
||||||
|
SC semantics.
|
||||||
|
|
||||||
|
'''
|
||||||
|
# Uh, oh.
|
||||||
|
#
|
||||||
|
# :o
|
||||||
|
|
||||||
|
# It looks like your event loop has caught a case of the ``trio``s.
|
||||||
|
|
||||||
|
# :()
|
||||||
|
|
||||||
|
# Don't worry, we've heard you'll barely notice. You might
|
||||||
|
# hallucinate a few more propagating errors and feel like your
|
||||||
|
# digestion has slowed but if anything get's too bad your parents
|
||||||
|
# will know about it.
|
||||||
|
|
||||||
|
# :)
|
||||||
|
|
||||||
|
async def aio_main(trio_main):
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
trio_done_fut = asyncio.Future()
|
||||||
|
|
||||||
|
def trio_done_callback(main_outcome):
|
||||||
|
|
||||||
|
if isinstance(main_outcome, Error):
|
||||||
|
error = main_outcome.error
|
||||||
|
trio_done_fut.set_exception(error)
|
||||||
|
|
||||||
|
# TODO: explicit asyncio tb?
|
||||||
|
# traceback.print_exception(error)
|
||||||
|
|
||||||
|
# XXX: do we need this?
|
||||||
|
# actor.cancel_soon()
|
||||||
|
|
||||||
|
main_outcome.unwrap()
|
||||||
|
else:
|
||||||
|
trio_done_fut.set_result(main_outcome)
|
||||||
|
log.runtime(f"trio_main finished: {main_outcome!r}")
|
||||||
|
|
||||||
|
# start the infection: run trio on the asyncio loop in "guest mode"
|
||||||
|
log.info(f"Infecting asyncio process with {trio_main}")
|
||||||
|
|
||||||
|
trio.lowlevel.start_guest_run(
|
||||||
|
trio_main,
|
||||||
|
run_sync_soon_threadsafe=loop.call_soon_threadsafe,
|
||||||
|
done_callback=trio_done_callback,
|
||||||
|
)
|
||||||
|
# ``.unwrap()`` will raise here on error
|
||||||
|
return (await trio_done_fut).unwrap()
|
||||||
|
|
||||||
|
# might as well if it's installed.
|
||||||
|
try:
|
||||||
|
import uvloop
|
||||||
|
loop = uvloop.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return asyncio.run(aio_main(trio_main))
|
|
@ -1,14 +1,40 @@
|
||||||
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
Sugary patterns for trio + tractor designs.
|
Sugary patterns for trio + tractor designs.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from ._mngrs import async_enter_all
|
from ._mngrs import (
|
||||||
from ._broadcast import broadcast_receiver, BroadcastReceiver, Lagged
|
gather_contexts,
|
||||||
|
maybe_open_context,
|
||||||
|
maybe_open_nursery,
|
||||||
|
)
|
||||||
|
from ._broadcast import (
|
||||||
|
broadcast_receiver,
|
||||||
|
BroadcastReceiver,
|
||||||
|
Lagged,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'async_enter_all',
|
'gather_contexts',
|
||||||
'broadcast_receiver',
|
'broadcast_receiver',
|
||||||
'BroadcastReceiver',
|
'BroadcastReceiver',
|
||||||
'Lagged',
|
'Lagged',
|
||||||
|
'maybe_open_context',
|
||||||
|
'maybe_open_nursery',
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,3 +1,19 @@
|
||||||
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
``tokio`` style broadcast channel.
|
``tokio`` style broadcast channel.
|
||||||
https://docs.rs/tokio/1.11.0/tokio/sync/broadcast/index.html
|
https://docs.rs/tokio/1.11.0/tokio/sync/broadcast/index.html
|
||||||
|
@ -7,7 +23,6 @@ from __future__ import annotations
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass
|
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from operator import ne
|
from operator import ne
|
||||||
from typing import Optional, Callable, Awaitable, Any, AsyncIterator, Protocol
|
from typing import Optional, Callable, Awaitable, Any, AsyncIterator, Protocol
|
||||||
|
@ -17,7 +32,10 @@ import trio
|
||||||
from trio._core._run import Task
|
from trio._core._run import Task
|
||||||
from trio.abc import ReceiveChannel
|
from trio.abc import ReceiveChannel
|
||||||
from trio.lowlevel import current_task
|
from trio.lowlevel import current_task
|
||||||
|
from msgspec import Struct
|
||||||
|
from tractor.log import get_logger
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
# A regular invariant generic type
|
# A regular invariant generic type
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
@ -31,8 +49,9 @@ class AsyncReceiver(
|
||||||
Protocol,
|
Protocol,
|
||||||
Generic[ReceiveType],
|
Generic[ReceiveType],
|
||||||
):
|
):
|
||||||
'''An async receivable duck-type that quacks much like trio's
|
'''
|
||||||
``trio.abc.ReceieveChannel``.
|
An async receivable duck-type that quacks much like trio's
|
||||||
|
``trio.abc.ReceiveChannel``.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
@ -62,15 +81,16 @@ class AsyncReceiver(
|
||||||
|
|
||||||
|
|
||||||
class Lagged(trio.TooSlowError):
|
class Lagged(trio.TooSlowError):
|
||||||
'''Subscribed consumer task was too slow and was overrun
|
'''
|
||||||
|
Subscribed consumer task was too slow and was overrun
|
||||||
by the fastest consumer-producer pair.
|
by the fastest consumer-producer pair.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
class BroadcastState(Struct):
|
||||||
class BroadcastState:
|
'''
|
||||||
'''Common state to all receivers of a broadcast.
|
Common state to all receivers of a broadcast.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
queue: deque
|
queue: deque
|
||||||
|
@ -84,9 +104,47 @@ class BroadcastState:
|
||||||
# on a newly produced value from the sender.
|
# on a newly produced value from the sender.
|
||||||
recv_ready: Optional[tuple[int, trio.Event]] = None
|
recv_ready: Optional[tuple[int, trio.Event]] = None
|
||||||
|
|
||||||
|
# if a ``trio.EndOfChannel`` is received on any
|
||||||
|
# consumer all consumers should be placed in this state
|
||||||
|
# such that the group is notified of the end-of-broadcast.
|
||||||
|
# For now, this is solely for testing/debugging purposes.
|
||||||
|
eoc: bool = False
|
||||||
|
|
||||||
|
# If the broadcaster was cancelled, we might as well track it
|
||||||
|
cancelled: dict[int, Task] = {}
|
||||||
|
|
||||||
|
def statistics(self) -> dict[str, Any]:
|
||||||
|
'''
|
||||||
|
Return broadcast receiver group "statistics" like many of
|
||||||
|
``trio``'s internal task-sync primitives.
|
||||||
|
|
||||||
|
'''
|
||||||
|
key: int | None
|
||||||
|
ev: trio.Event | None
|
||||||
|
|
||||||
|
subs = self.subs
|
||||||
|
if self.recv_ready is not None:
|
||||||
|
key, ev = self.recv_ready
|
||||||
|
else:
|
||||||
|
key = ev = None
|
||||||
|
|
||||||
|
qlens: dict[int, int] = {}
|
||||||
|
for tid, sz in subs.items():
|
||||||
|
qlens[tid] = sz if sz != -1 else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
'open_consumers': len(subs),
|
||||||
|
'queued_len_by_task': qlens,
|
||||||
|
'max_buffer_size': self.maxlen,
|
||||||
|
'tasks_waiting': ev.statistics().tasks_waiting if ev else 0,
|
||||||
|
'tasks_cancelled': self.cancelled,
|
||||||
|
'next_value_receiver_id': key,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class BroadcastReceiver(ReceiveChannel):
|
class BroadcastReceiver(ReceiveChannel):
|
||||||
'''A memory receive channel broadcaster which is non-lossy for the
|
'''
|
||||||
|
A memory receive channel broadcaster which is non-lossy for the
|
||||||
fastest consumer.
|
fastest consumer.
|
||||||
|
|
||||||
Additional consumer tasks can receive all produced values by registering
|
Additional consumer tasks can receive all produced values by registering
|
||||||
|
@ -99,23 +157,40 @@ class BroadcastReceiver(ReceiveChannel):
|
||||||
rx_chan: AsyncReceiver,
|
rx_chan: AsyncReceiver,
|
||||||
state: BroadcastState,
|
state: BroadcastState,
|
||||||
receive_afunc: Optional[Callable[[], Awaitable[Any]]] = None,
|
receive_afunc: Optional[Callable[[], Awaitable[Any]]] = None,
|
||||||
|
raise_on_lag: bool = True,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
# register the original underlying (clone)
|
# register the original underlying (clone)
|
||||||
self.key = id(self)
|
self.key = id(self)
|
||||||
self._state = state
|
self._state = state
|
||||||
|
|
||||||
|
# each consumer has an int count which indicates
|
||||||
|
# which index contains the next value that the task has not yet
|
||||||
|
# consumed and thus should read. In the "up-to-date" case the
|
||||||
|
# consumer task must wait for a new value from the underlying
|
||||||
|
# receiver and we use ``-1`` as the sentinel for this state.
|
||||||
state.subs[self.key] = -1
|
state.subs[self.key] = -1
|
||||||
|
|
||||||
# underlying for this receiver
|
# underlying for this receiver
|
||||||
self._rx = rx_chan
|
self._rx = rx_chan
|
||||||
self._recv = receive_afunc or rx_chan.receive
|
self._recv = receive_afunc or rx_chan.receive
|
||||||
self._closed: bool = False
|
self._closed: bool = False
|
||||||
|
self._raise_on_lag = raise_on_lag
|
||||||
|
|
||||||
async def receive(self) -> ReceiveType:
|
def receive_nowait(
|
||||||
|
self,
|
||||||
|
_key: int | None = None,
|
||||||
|
_state: BroadcastState | None = None,
|
||||||
|
|
||||||
key = self.key
|
) -> Any:
|
||||||
state = self._state
|
'''
|
||||||
|
Sync version of `.receive()` which does all the low level work
|
||||||
|
of receiving from the underlying/wrapped receive channel.
|
||||||
|
|
||||||
|
'''
|
||||||
|
key = _key or self.key
|
||||||
|
state = _state or self._state
|
||||||
|
|
||||||
# TODO: ideally we can make some way to "lock out" the
|
# TODO: ideally we can make some way to "lock out" the
|
||||||
# underlying receive channel in some way such that if some task
|
# underlying receive channel in some way such that if some task
|
||||||
|
@ -148,121 +223,180 @@ class BroadcastReceiver(ReceiveChannel):
|
||||||
# return this value."
|
# return this value."
|
||||||
# https://docs.rs/tokio/1.11.0/tokio/sync/broadcast/index.html#lagging
|
# https://docs.rs/tokio/1.11.0/tokio/sync/broadcast/index.html#lagging
|
||||||
|
|
||||||
|
mxln = state.maxlen
|
||||||
|
lost = seq - mxln
|
||||||
|
|
||||||
# decrement to the last value and expect
|
# decrement to the last value and expect
|
||||||
# consumer to either handle the ``Lagged`` and come back
|
# consumer to either handle the ``Lagged`` and come back
|
||||||
# or bail out on its own (thus un-subscribing)
|
# or bail out on its own (thus un-subscribing)
|
||||||
state.subs[key] = state.maxlen - 1
|
state.subs[key] = mxln - 1
|
||||||
|
|
||||||
# this task was overrun by the producer side
|
# this task was overrun by the producer side
|
||||||
task: Task = current_task()
|
task: Task = current_task()
|
||||||
raise Lagged(f'Task {task.name} was overrun')
|
msg = f'Task `{task.name}` overrun and dropped `{lost}` values'
|
||||||
|
|
||||||
|
if self._raise_on_lag:
|
||||||
|
raise Lagged(msg)
|
||||||
|
else:
|
||||||
|
log.warning(msg)
|
||||||
|
return self.receive_nowait(_key, _state)
|
||||||
|
|
||||||
state.subs[key] -= 1
|
state.subs[key] -= 1
|
||||||
return value
|
return value
|
||||||
|
|
||||||
# current task already has the latest value **and** is the
|
raise trio.WouldBlock
|
||||||
# first task to begin waiting for a new one
|
|
||||||
if state.recv_ready is None:
|
|
||||||
|
|
||||||
if self._closed:
|
async def _receive_from_underlying(
|
||||||
raise trio.ClosedResourceError
|
self,
|
||||||
|
key: int,
|
||||||
|
state: BroadcastState,
|
||||||
|
|
||||||
event = trio.Event()
|
) -> ReceiveType:
|
||||||
state.recv_ready = key, event
|
|
||||||
|
|
||||||
|
if self._closed:
|
||||||
|
raise trio.ClosedResourceError
|
||||||
|
|
||||||
|
event = trio.Event()
|
||||||
|
assert state.recv_ready is None
|
||||||
|
state.recv_ready = key, event
|
||||||
|
|
||||||
|
try:
|
||||||
# if we're cancelled here it should be
|
# if we're cancelled here it should be
|
||||||
# fine to bail without affecting any other consumers
|
# fine to bail without affecting any other consumers
|
||||||
# right?
|
# right?
|
||||||
try:
|
value = await self._recv()
|
||||||
value = await self._recv()
|
|
||||||
|
|
||||||
# items with lower indices are "newer"
|
# items with lower indices are "newer"
|
||||||
# NOTE: ``collections.deque`` implicitly takes care of
|
# NOTE: ``collections.deque`` implicitly takes care of
|
||||||
# trucating values outside our ``state.maxlen``. In the
|
# trucating values outside our ``state.maxlen``. In the
|
||||||
# alt-backend-array-case we'll need to make sure this is
|
# alt-backend-array-case we'll need to make sure this is
|
||||||
# implemented in similar ringer-buffer-ish style.
|
# implemented in similar ringer-buffer-ish style.
|
||||||
state.queue.appendleft(value)
|
state.queue.appendleft(value)
|
||||||
|
|
||||||
# broadcast new value to all subscribers by increasing
|
# broadcast new value to all subscribers by increasing
|
||||||
# all sequence numbers that will point in the queue to
|
# all sequence numbers that will point in the queue to
|
||||||
# their latest available value.
|
# their latest available value.
|
||||||
|
|
||||||
# don't decrement the sequence for this task since we
|
# don't decrement the sequence for this task since we
|
||||||
# already retreived the last value
|
# already retreived the last value
|
||||||
|
|
||||||
# XXX: which of these impls is fastest?
|
# XXX: which of these impls is fastest?
|
||||||
|
# subs = state.subs.copy()
|
||||||
|
# subs.pop(key)
|
||||||
|
|
||||||
# subs = state.subs.copy()
|
for sub_key in filter(
|
||||||
# subs.pop(key)
|
# lambda k: k != key, state.subs,
|
||||||
|
partial(ne, key), state.subs,
|
||||||
|
):
|
||||||
|
state.subs[sub_key] += 1
|
||||||
|
|
||||||
for sub_key in filter(
|
# NOTE: this should ONLY be set if the above task was *NOT*
|
||||||
# lambda k: k != key, state.subs,
|
# cancelled on the `._recv()` call.
|
||||||
partial(ne, key), state.subs,
|
event.set()
|
||||||
):
|
return value
|
||||||
state.subs[sub_key] += 1
|
|
||||||
|
|
||||||
# NOTE: this should ONLY be set if the above task was *NOT*
|
except trio.EndOfChannel:
|
||||||
# cancelled on the `._recv()` call.
|
# if any one consumer gets an EOC from the underlying
|
||||||
|
# receiver we need to unblock and send that signal to
|
||||||
|
# all other consumers.
|
||||||
|
self._state.eoc = True
|
||||||
|
if event.statistics().tasks_waiting:
|
||||||
event.set()
|
event.set()
|
||||||
return value
|
raise
|
||||||
|
|
||||||
except trio.Cancelled:
|
except (
|
||||||
# handle cancelled specially otherwise sibling
|
trio.Cancelled,
|
||||||
# consumers will be awoken with a sequence of -1
|
):
|
||||||
# state.recv_ready = trio.Cancelled
|
# handle cancelled specially otherwise sibling
|
||||||
if event.statistics().tasks_waiting:
|
# consumers will be awoken with a sequence of -1
|
||||||
event.set()
|
# and will potentially try to rewait the underlying
|
||||||
raise
|
# receiver instead of just cancelling immediately.
|
||||||
|
self._state.cancelled[key] = current_task()
|
||||||
|
if event.statistics().tasks_waiting:
|
||||||
|
event.set()
|
||||||
|
raise
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
# Reset receiver waiter task event for next blocking condition.
|
||||||
|
# this MUST be reset even if the above ``.recv()`` call
|
||||||
|
# was cancelled to avoid the next consumer from blocking on
|
||||||
|
# an event that won't be set!
|
||||||
|
state.recv_ready = None
|
||||||
|
|
||||||
# Reset receiver waiter task event for next blocking condition.
|
async def receive(self) -> ReceiveType:
|
||||||
# this MUST be reset even if the above ``.recv()`` call
|
key = self.key
|
||||||
# was cancelled to avoid the next consumer from blocking on
|
state = self._state
|
||||||
# an event that won't be set!
|
|
||||||
state.recv_ready = None
|
try:
|
||||||
|
return self.receive_nowait(
|
||||||
|
_key=key,
|
||||||
|
_state=state,
|
||||||
|
)
|
||||||
|
except trio.WouldBlock:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# current task already has the latest value **and** is the
|
||||||
|
# first task to begin waiting for a new one so we begin blocking
|
||||||
|
# until rescheduled with the a new value from the underlying.
|
||||||
|
if state.recv_ready is None:
|
||||||
|
return await self._receive_from_underlying(key, state)
|
||||||
|
|
||||||
# This task is all caught up and ready to receive the latest
|
# This task is all caught up and ready to receive the latest
|
||||||
# value, so queue sched it on the internal event.
|
# value, so queue/schedule it to be woken on the next internal
|
||||||
|
# event.
|
||||||
else:
|
else:
|
||||||
seq = state.subs[key]
|
while state.recv_ready is not None:
|
||||||
assert seq == -1 # sanity
|
# seq = state.subs[key]
|
||||||
_, ev = state.recv_ready
|
# assert seq == -1 # sanity
|
||||||
await ev.wait()
|
_, ev = state.recv_ready
|
||||||
|
await ev.wait()
|
||||||
|
try:
|
||||||
|
return self.receive_nowait(
|
||||||
|
_key=key,
|
||||||
|
_state=state,
|
||||||
|
)
|
||||||
|
except trio.WouldBlock:
|
||||||
|
if self._closed:
|
||||||
|
raise trio.ClosedResourceError
|
||||||
|
|
||||||
# NOTE: if we ever would like the behaviour where if the
|
subs = state.subs
|
||||||
# first task to recv on the underlying is cancelled but it
|
if (
|
||||||
# still DOES trigger the ``.recv_ready``, event we'll likely need
|
len(subs) == 1
|
||||||
# this logic:
|
and key in subs
|
||||||
|
# or cancelled
|
||||||
|
):
|
||||||
|
# XXX: we are the last and only user of this BR so
|
||||||
|
# likely it makes sense to unwind back to the
|
||||||
|
# underlying?
|
||||||
|
# import tractor
|
||||||
|
# await tractor.breakpoint()
|
||||||
|
log.warning(
|
||||||
|
f'Only one sub left for {self}?\n'
|
||||||
|
'We can probably unwind from breceiver?'
|
||||||
|
)
|
||||||
|
|
||||||
if seq > -1:
|
# XXX: In the case where the first task to allocate the
|
||||||
# stuff from above..
|
# ``.recv_ready`` event is cancelled we will be woken
|
||||||
seq = state.subs[key]
|
# with a non-incremented sequence number (the ``-1``
|
||||||
|
# sentinel) and thus will read the oldest value if we
|
||||||
|
# use that. Instead we need to detect if we have not
|
||||||
|
# been incremented and then receive again.
|
||||||
|
# return await self.receive()
|
||||||
|
|
||||||
value = state.queue[seq]
|
return await self._receive_from_underlying(key, state)
|
||||||
state.subs[key] -= 1
|
|
||||||
return value
|
|
||||||
|
|
||||||
elif seq == -1:
|
|
||||||
# XXX: In the case where the first task to allocate the
|
|
||||||
# ``.recv_ready`` event is cancelled we will be woken with
|
|
||||||
# a non-incremented sequence number and thus will read the
|
|
||||||
# oldest value if we use that. Instead we need to detect if
|
|
||||||
# we have not been incremented and then receive again.
|
|
||||||
return await self.receive()
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise ValueError(f'Invalid sequence {seq}!?')
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def subscribe(
|
async def subscribe(
|
||||||
self,
|
self,
|
||||||
|
raise_on_lag: bool = True,
|
||||||
|
|
||||||
) -> AsyncIterator[BroadcastReceiver]:
|
) -> AsyncIterator[BroadcastReceiver]:
|
||||||
'''Subscribe for values from this broadcast receiver.
|
'''
|
||||||
|
Subscribe for values from this broadcast receiver.
|
||||||
|
|
||||||
Returns a new ``BroadCastReceiver`` which is registered for and
|
Returns a new ``BroadCastReceiver`` which is registered for and
|
||||||
pulls data from a clone of the original ``trio.abc.ReceiveChannel``
|
pulls data from a clone of the original
|
||||||
provided at creation.
|
``trio.abc.ReceiveChannel`` provided at creation.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
if self._closed:
|
if self._closed:
|
||||||
|
@ -273,6 +407,7 @@ class BroadcastReceiver(ReceiveChannel):
|
||||||
rx_chan=self._rx,
|
rx_chan=self._rx,
|
||||||
state=state,
|
state=state,
|
||||||
receive_afunc=self._recv,
|
receive_afunc=self._recv,
|
||||||
|
raise_on_lag=raise_on_lag,
|
||||||
)
|
)
|
||||||
# assert clone in state.subs
|
# assert clone in state.subs
|
||||||
assert br.key in state.subs
|
assert br.key in state.subs
|
||||||
|
@ -285,14 +420,23 @@ class BroadcastReceiver(ReceiveChannel):
|
||||||
async def aclose(
|
async def aclose(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
'''
|
||||||
|
Close this receiver without affecting other consumers.
|
||||||
|
|
||||||
|
'''
|
||||||
if self._closed:
|
if self._closed:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# if there are sleeping consumers wake
|
||||||
|
# them on closure.
|
||||||
|
rr = self._state.recv_ready
|
||||||
|
if rr:
|
||||||
|
_, event = rr
|
||||||
|
event.set()
|
||||||
|
|
||||||
# XXX: leaving it like this consumers can still get values
|
# XXX: leaving it like this consumers can still get values
|
||||||
# up to the last received that still reside in the queue.
|
# up to the last received that still reside in the queue.
|
||||||
self._state.subs.pop(self.key)
|
self._state.subs.pop(self.key)
|
||||||
|
|
||||||
self._closed = True
|
self._closed = True
|
||||||
|
|
||||||
|
|
||||||
|
@ -300,7 +444,8 @@ def broadcast_receiver(
|
||||||
|
|
||||||
recv_chan: AsyncReceiver,
|
recv_chan: AsyncReceiver,
|
||||||
max_buffer_size: int,
|
max_buffer_size: int,
|
||||||
**kwargs,
|
receive_afunc: Optional[Callable[[], Awaitable[Any]]] = None,
|
||||||
|
raise_on_lag: bool = True,
|
||||||
|
|
||||||
) -> BroadcastReceiver:
|
) -> BroadcastReceiver:
|
||||||
|
|
||||||
|
@ -311,5 +456,6 @@ def broadcast_receiver(
|
||||||
maxlen=max_buffer_size,
|
maxlen=max_buffer_size,
|
||||||
subs={},
|
subs={},
|
||||||
),
|
),
|
||||||
**kwargs,
|
receive_afunc=receive_afunc,
|
||||||
|
raise_on_lag=raise_on_lag,
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,24 +1,79 @@
|
||||||
|
# tractor: structured concurrent "actors".
|
||||||
|
# Copyright 2018-eternity Tyler Goodlet.
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
Async context manager primitives with hard ``trio``-aware semantics
|
Async context manager primitives with hard ``trio``-aware semantics
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from typing import AsyncContextManager, AsyncGenerator
|
|
||||||
from typing import TypeVar, Sequence
|
|
||||||
from contextlib import asynccontextmanager as acm
|
from contextlib import asynccontextmanager as acm
|
||||||
|
import inspect
|
||||||
|
from typing import (
|
||||||
|
Any,
|
||||||
|
AsyncContextManager,
|
||||||
|
AsyncGenerator,
|
||||||
|
AsyncIterator,
|
||||||
|
Callable,
|
||||||
|
Hashable,
|
||||||
|
Optional,
|
||||||
|
Sequence,
|
||||||
|
TypeVar,
|
||||||
|
)
|
||||||
|
|
||||||
import trio
|
import trio
|
||||||
|
from trio_typing import TaskStatus
|
||||||
|
|
||||||
|
from .._state import current_actor
|
||||||
|
from ..log import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
# A regular invariant generic type
|
# A regular invariant generic type
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def maybe_open_nursery(
|
||||||
|
nursery: trio.Nursery | None = None,
|
||||||
|
shield: bool = False,
|
||||||
|
) -> AsyncGenerator[trio.Nursery, Any]:
|
||||||
|
'''
|
||||||
|
Create a new nursery if None provided.
|
||||||
|
|
||||||
|
Blocks on exit as expected if no input nursery is provided.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if nursery is not None:
|
||||||
|
yield nursery
|
||||||
|
else:
|
||||||
|
async with trio.open_nursery() as nursery:
|
||||||
|
nursery.cancel_scope.shield = shield
|
||||||
|
yield nursery
|
||||||
|
|
||||||
|
|
||||||
async def _enter_and_wait(
|
async def _enter_and_wait(
|
||||||
|
|
||||||
mngr: AsyncContextManager[T],
|
mngr: AsyncContextManager[T],
|
||||||
unwrapped: dict[int, T],
|
unwrapped: dict[int, T],
|
||||||
all_entered: trio.Event,
|
all_entered: trio.Event,
|
||||||
|
parent_exit: trio.Event,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''Open the async context manager deliver it's value
|
'''
|
||||||
|
Open the async context manager deliver it's value
|
||||||
to this task's spawner and sleep until cancelled.
|
to this task's spawner and sleep until cancelled.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
@ -28,16 +83,42 @@ async def _enter_and_wait(
|
||||||
if all(unwrapped.values()):
|
if all(unwrapped.values()):
|
||||||
all_entered.set()
|
all_entered.set()
|
||||||
|
|
||||||
await trio.sleep_forever()
|
await parent_exit.wait()
|
||||||
|
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
async def async_enter_all(
|
async def gather_contexts(
|
||||||
|
|
||||||
mngrs: Sequence[AsyncContextManager[T]],
|
mngrs: Sequence[AsyncContextManager[T]],
|
||||||
) -> AsyncGenerator[tuple[T, ...], None]:
|
|
||||||
unwrapped = {}.fromkeys(id(mngr) for mngr in mngrs)
|
) -> AsyncGenerator[tuple[Optional[T], ...], None]:
|
||||||
|
'''
|
||||||
|
Concurrently enter a sequence of async context managers, each in
|
||||||
|
a separate ``trio`` task and deliver the unwrapped values in the
|
||||||
|
same order once all managers have entered. On exit all contexts are
|
||||||
|
subsequently and concurrently exited.
|
||||||
|
|
||||||
|
This function is somewhat similar to common usage of
|
||||||
|
``contextlib.AsyncExitStack.enter_async_context()`` (in a loop) in
|
||||||
|
combo with ``asyncio.gather()`` except the managers are concurrently
|
||||||
|
entered and exited, and cancellation just works.
|
||||||
|
|
||||||
|
'''
|
||||||
|
unwrapped: dict[int, Optional[T]] = {}.fromkeys(id(mngr) for mngr in mngrs)
|
||||||
|
|
||||||
all_entered = trio.Event()
|
all_entered = trio.Event()
|
||||||
|
parent_exit = trio.Event()
|
||||||
|
|
||||||
|
# XXX: ensure greedy sequence of manager instances
|
||||||
|
# since a lazy inline generator doesn't seem to work
|
||||||
|
# with `async with` syntax.
|
||||||
|
mngrs = list(mngrs)
|
||||||
|
|
||||||
|
if not mngrs:
|
||||||
|
raise ValueError(
|
||||||
|
'input mngrs is empty?\n'
|
||||||
|
'Did try to use inline generator syntax?'
|
||||||
|
)
|
||||||
|
|
||||||
async with trio.open_nursery() as n:
|
async with trio.open_nursery() as n:
|
||||||
for mngr in mngrs:
|
for mngr in mngrs:
|
||||||
|
@ -46,11 +127,152 @@ async def async_enter_all(
|
||||||
mngr,
|
mngr,
|
||||||
unwrapped,
|
unwrapped,
|
||||||
all_entered,
|
all_entered,
|
||||||
|
parent_exit,
|
||||||
)
|
)
|
||||||
|
|
||||||
# deliver control once all managers have started up
|
# deliver control once all managers have started up
|
||||||
await all_entered.wait()
|
await all_entered.wait()
|
||||||
|
|
||||||
yield tuple(unwrapped.values())
|
try:
|
||||||
|
yield tuple(unwrapped.values())
|
||||||
|
finally:
|
||||||
|
# NOTE: this is ABSOLUTELY REQUIRED to avoid
|
||||||
|
# the following wacky bug:
|
||||||
|
# <tractorbugurlhere>
|
||||||
|
parent_exit.set()
|
||||||
|
|
||||||
n.cancel_scope.cancel()
|
|
||||||
|
# Per actor task caching helpers.
|
||||||
|
# Further potential examples of interest:
|
||||||
|
# https://gist.github.com/njsmith/cf6fc0a97f53865f2c671659c88c1798#file-cache-py-L8
|
||||||
|
|
||||||
|
class _Cache:
|
||||||
|
'''
|
||||||
|
Globally (actor-processs scoped) cached, task access to
|
||||||
|
a kept-alive-while-in-use async resource.
|
||||||
|
|
||||||
|
'''
|
||||||
|
service_n: Optional[trio.Nursery] = None
|
||||||
|
locks: dict[Hashable, trio.Lock] = {}
|
||||||
|
users: int = 0
|
||||||
|
values: dict[Any, Any] = {}
|
||||||
|
resources: dict[
|
||||||
|
Hashable,
|
||||||
|
tuple[trio.Nursery, trio.Event]
|
||||||
|
] = {}
|
||||||
|
# nurseries: dict[int, trio.Nursery] = {}
|
||||||
|
no_more_users: Optional[trio.Event] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def run_ctx(
|
||||||
|
cls,
|
||||||
|
mng,
|
||||||
|
ctx_key: tuple,
|
||||||
|
task_status: TaskStatus[T] = trio.TASK_STATUS_IGNORED,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
async with mng as value:
|
||||||
|
_, no_more_users = cls.resources[ctx_key]
|
||||||
|
cls.values[ctx_key] = value
|
||||||
|
task_status.started(value)
|
||||||
|
try:
|
||||||
|
await no_more_users.wait()
|
||||||
|
finally:
|
||||||
|
# discard nursery ref so it won't be re-used (an error)?
|
||||||
|
value = cls.values.pop(ctx_key)
|
||||||
|
cls.resources.pop(ctx_key)
|
||||||
|
|
||||||
|
|
||||||
|
@acm
|
||||||
|
async def maybe_open_context(
|
||||||
|
|
||||||
|
acm_func: Callable[..., AsyncContextManager[T]],
|
||||||
|
|
||||||
|
# XXX: used as cache key after conversion to tuple
|
||||||
|
# and all embedded values must also be hashable
|
||||||
|
kwargs: dict = {},
|
||||||
|
key: Hashable | Callable[..., Hashable] = None,
|
||||||
|
|
||||||
|
) -> AsyncIterator[tuple[bool, T]]:
|
||||||
|
'''
|
||||||
|
Maybe open a context manager if there is not already a _Cached
|
||||||
|
version for the provided ``key`` for *this* actor. Return the
|
||||||
|
_Cached instance on a _Cache hit.
|
||||||
|
|
||||||
|
'''
|
||||||
|
fid = id(acm_func)
|
||||||
|
|
||||||
|
if inspect.isfunction(key):
|
||||||
|
ctx_key = (fid, key(**kwargs))
|
||||||
|
else:
|
||||||
|
ctx_key = (fid, key or tuple(kwargs.items()))
|
||||||
|
|
||||||
|
# yielded output
|
||||||
|
yielded: Any = None
|
||||||
|
|
||||||
|
# Lock resource acquisition around task racing / ``trio``'s
|
||||||
|
# scheduler protocol.
|
||||||
|
# NOTE: the lock is target context manager func specific in order
|
||||||
|
# to allow re-entrant use cases where one `maybe_open_context()`
|
||||||
|
# wrapped factor may want to call into another.
|
||||||
|
lock = _Cache.locks.setdefault(fid, trio.Lock())
|
||||||
|
await lock.acquire()
|
||||||
|
|
||||||
|
# XXX: one singleton nursery per actor and we want to
|
||||||
|
# have it not be closed until all consumers have exited (which is
|
||||||
|
# currently difficult to implement any other way besides using our
|
||||||
|
# pre-allocated runtime instance..)
|
||||||
|
service_n: trio.Nursery = current_actor()._service_n
|
||||||
|
|
||||||
|
# TODO: is there any way to allocate
|
||||||
|
# a 'stays-open-till-last-task-finshed nursery?
|
||||||
|
# service_n: trio.Nursery
|
||||||
|
# async with maybe_open_nursery(_Cache.service_n) as service_n:
|
||||||
|
# _Cache.service_n = service_n
|
||||||
|
|
||||||
|
try:
|
||||||
|
# **critical section** that should prevent other tasks from
|
||||||
|
# checking the _Cache until complete otherwise the scheduler
|
||||||
|
# may switch and by accident we create more then one resource.
|
||||||
|
yielded = _Cache.values[ctx_key]
|
||||||
|
|
||||||
|
except KeyError:
|
||||||
|
log.info(f'Allocating new {acm_func} for {ctx_key}')
|
||||||
|
mngr = acm_func(**kwargs)
|
||||||
|
resources = _Cache.resources
|
||||||
|
assert not resources.get(ctx_key), f'Resource exists? {ctx_key}'
|
||||||
|
resources[ctx_key] = (service_n, trio.Event())
|
||||||
|
|
||||||
|
# sync up to the mngr's yielded value
|
||||||
|
yielded = await service_n.start(
|
||||||
|
_Cache.run_ctx,
|
||||||
|
mngr,
|
||||||
|
ctx_key,
|
||||||
|
)
|
||||||
|
_Cache.users += 1
|
||||||
|
lock.release()
|
||||||
|
yield False, yielded
|
||||||
|
|
||||||
|
else:
|
||||||
|
log.info(f'Reusing _Cached resource for {ctx_key}')
|
||||||
|
_Cache.users += 1
|
||||||
|
lock.release()
|
||||||
|
yield True, yielded
|
||||||
|
|
||||||
|
finally:
|
||||||
|
_Cache.users -= 1
|
||||||
|
|
||||||
|
if yielded is not None:
|
||||||
|
# if no more consumers, teardown the client
|
||||||
|
if _Cache.users <= 0:
|
||||||
|
log.info(f'De-allocating resource for {ctx_key}')
|
||||||
|
|
||||||
|
# XXX: if we're cancelled we the entry may have never
|
||||||
|
# been entered since the nursery task was killed.
|
||||||
|
# _, no_more_users = _Cache.resources[ctx_key]
|
||||||
|
entry = _Cache.resources.get(ctx_key)
|
||||||
|
if entry:
|
||||||
|
_, no_more_users = entry
|
||||||
|
no_more_users.set()
|
||||||
|
|
||||||
|
_Cache.locks.pop(fid)
|
||||||
|
|
Loading…
Reference in New Issue