From ef171de7c4519d40d49e47e20f61d5770f9918f9 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Sat, 3 Oct 2026 20:40:04 +0200 Subject: [PATCH 1/7] chore: upgrade Fable and BEAM runtimes --- examples/timeflies-beam/src/Timeflies.fsproj | 2 +- examples/timeflies-python/pyproject.toml | 2 +- examples/timeflies-python/uv.lock | 114 +++++++++---------- pyproject.toml | 2 +- src/Fable.Actor/Fable.Actor.fsproj | 2 +- uv.lock | 114 +++++++++---------- 6 files changed, 118 insertions(+), 118 deletions(-) diff --git a/examples/timeflies-beam/src/Timeflies.fsproj b/examples/timeflies-beam/src/Timeflies.fsproj index fb3f223..1917529 100644 --- a/examples/timeflies-beam/src/Timeflies.fsproj +++ b/examples/timeflies-beam/src/Timeflies.fsproj @@ -8,7 +8,7 @@ - + diff --git a/examples/timeflies-python/pyproject.toml b/examples/timeflies-python/pyproject.toml index 9bfe68c..7b96f71 100644 --- a/examples/timeflies-python/pyproject.toml +++ b/examples/timeflies-python/pyproject.toml @@ -3,6 +3,6 @@ name = "timeflies-python" version = "0.1.0" requires-python = ">= 3.12" dependencies = [ - "fable-library>=5.18.0", + "fable-library>=5.19.0", "tk>=0.1.0", ] diff --git a/examples/timeflies-python/uv.lock b/examples/timeflies-python/uv.lock index cb99c8b..78f51d1 100644 --- a/examples/timeflies-python/uv.lock +++ b/examples/timeflies-python/uv.lock @@ -4,64 +4,64 @@ requires-python = ">=3.12" [[package]] name = "fable-library" -version = "5.18.0" +version = "5.19.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/75/dca1fece690048d6eb9966a11a2fb754045391b9c5c0c0ee377291570c8e/fable_library-5.18.0.tar.gz", hash = "sha256:ebfbcd8227377f6afcb655a08e77940b2cd4c95d2c02b6c4f11845234481ea9e", size = 248999, upload-time = "2026-09-25T22:21:02.85Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/c9/13268bae63e9a7e0e302503f0c50b562a3671ce55baae27890c9771d26f3/fable_library-5.19.0.tar.gz", hash = "sha256:8b918f47aa3e48d8d69cbaae46dcc72a31b627b78f818e3fd8e1c3b3601d1993", size = 249385, upload-time = "2026-10-03T15:03:47.719Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/e7/2c2133b5e5eac20b4a6f1378156b5432f9c54578a9c9f5576938bb2819f3/fable_library-5.18.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9f1d4ab75003ef1d8682183496be77589048b575c9be6b68a3549b80d641ec48", size = 1406661, upload-time = "2026-09-25T22:19:38.003Z" }, - { url = "https://files.pythonhosted.org/packages/13/22/63b46cac277d98f39811a793865620f33d3ee2965288133cd089013c3d7e/fable_library-5.18.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f88fade2d69260c2ae993eeaff00715c392d908a71bee40f84abc6d7098c18fd", size = 1313857, upload-time = "2026-09-25T22:19:39.18Z" }, - { url = "https://files.pythonhosted.org/packages/49/d7/94edc48f44293ac4a9b9a7d35602b8e966fffd76d8f7e5585df66f11e571/fable_library-5.18.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61cbd060f0c7bba94898f601295d0f3610f299d63d0949a5b8ceb437e7ec7265", size = 1362145, upload-time = "2026-09-25T22:19:40.313Z" }, - { url = "https://files.pythonhosted.org/packages/64/e5/3a39202e8fee252291ab1e2c0480fc16cf93bd5c690ccdf64f1f1b6ea6bc/fable_library-5.18.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b05f1b887c13055e5b1c50744ecad06e332005f7e6a8fcf88ef476e34888fc07", size = 1343817, upload-time = "2026-09-25T22:19:41.531Z" }, - { url = "https://files.pythonhosted.org/packages/cf/65/8e4a96de89b9efa11ae6d14930b72f7e2ced22081724b29fb10cb9e6efab/fable_library-5.18.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e1b61065cb0869915250f70708ccb78252e5eddc9830edcede6287dced48f71", size = 1488557, upload-time = "2026-09-25T22:19:42.801Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/c4d26a5bbaf30ae6e164f250ff834d4a2eedc96fe19c4596f907949d98bb/fable_library-5.18.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:76656246db91d59971eb236cd2f3433533415de8ee859dedcaf12f9fc2688f75", size = 1502651, upload-time = "2026-09-25T22:19:44.045Z" }, - { url = "https://files.pythonhosted.org/packages/03/b1/c6257788f2b1eab86f8139cf99d6ec07e70b7554ef56aca5be3090fc57db/fable_library-5.18.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:179df74217a556a78a2df74832a3f6b7edd647d1031f222f46bc6ddb6b5a171f", size = 1433763, upload-time = "2026-09-25T22:19:45.346Z" }, - { url = "https://files.pythonhosted.org/packages/a0/8d/815750991b885334c8a463ebd5ab9b0c776d8965702924c26859d93c22f7/fable_library-5.18.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8df74c881ce63a1ce6f32cd802afe4353fd7e22483491bf67476077723ce282", size = 1463485, upload-time = "2026-09-25T22:19:47.19Z" }, - { url = "https://files.pythonhosted.org/packages/92/52/4885264e12703f6f3c4ae9e6e357c99cee1478a6b00be88dd490af351aba/fable_library-5.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5768d00e3b56c7003f6019d9434b4fc58ebd2f55125b7bc2325de8f76945f52a", size = 1540244, upload-time = "2026-09-25T22:19:48.381Z" }, - { url = "https://files.pythonhosted.org/packages/6b/68/a2aae35e946fe9489c0a6ecc2f1cebee91bf91ca2c954e8151328a753c1c/fable_library-5.18.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:2a49718bf9f1dc00c9e1afd5c75793db4d7b7ca4d9deb7fd5ab19e4383416cd8", size = 1621870, upload-time = "2026-09-25T22:19:49.786Z" }, - { url = "https://files.pythonhosted.org/packages/68/02/6501e35df2884cffd0ea14830ccbb24a534dfa80777bf102a2950a754e3b/fable_library-5.18.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cb3935fdfea373f44cdf1400898f921c105954536d221a999a6e41bf24f356f0", size = 1644512, upload-time = "2026-09-25T22:19:51.201Z" }, - { url = "https://files.pythonhosted.org/packages/68/37/97789b767c58474a0766d55e1fa1a06a2054f18be5db4b1bea85deb638ec/fable_library-5.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb94fb2a26d7b0e02c0d3eedc83c23faff690bb8ad4bfae7b4732be781a557d7", size = 1663576, upload-time = "2026-09-25T22:19:52.421Z" }, - { url = "https://files.pythonhosted.org/packages/75/76/ba0b307834699275eceb506dc2d0a8facdadd5c0697eacca5eca53084d3a/fable_library-5.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:edbfdb05a806f881b65268fdd3a6d4b24a8c18c83a758473761e155b6df5d748", size = 1419946, upload-time = "2026-09-25T22:19:53.661Z" }, - { url = "https://files.pythonhosted.org/packages/8e/58/e23918c62147a1b9642af2a5c0cd8d368fa87ece5b26d7c6cd8e59269b85/fable_library-5.18.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:bb2c21d1883101fe652fa19481da3bc508e4f3fbb113d388a0aff312145af8b2", size = 1407354, upload-time = "2026-09-25T22:19:54.924Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bb/3110f2e619a84c3afabe323eee2714d819023acfac11d74740304aaf89b0/fable_library-5.18.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:53005374d630852f1f4816c4ccc1e12ffe7a3e2a69eb5e73b82689a2a291ef8d", size = 1314021, upload-time = "2026-09-25T22:19:56.209Z" }, - { url = "https://files.pythonhosted.org/packages/ba/e7/0adf16c86c96138576b17f2f29d6112a636bc6d23e99cc093ee488b572f3/fable_library-5.18.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fffc653377d82ab6cc80c9c27525e622f7048ebac22e1f6704fbd252b29cca2", size = 1362104, upload-time = "2026-09-25T22:19:57.313Z" }, - { url = "https://files.pythonhosted.org/packages/07/5e/8f5f48a9bb7e585bee360524fe01dbb2d0b0517ce447dd485e5942e4f8cc/fable_library-5.18.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9889f71a72ae441e0cc2b3437420b07b58f660f3d2f251be9cc591bcad62cb54", size = 1343730, upload-time = "2026-09-25T22:19:58.793Z" }, - { url = "https://files.pythonhosted.org/packages/6e/57/ad14b7732f77899c3b503f852b6064e236f30beecc5e247c6778eaf5ea85/fable_library-5.18.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:83af5015dc364f61a3a0823662625d7bdd4b90cf12bba680bdf253e999ff958d", size = 1489192, upload-time = "2026-09-25T22:19:59.89Z" }, - { url = "https://files.pythonhosted.org/packages/5f/b3/fa1a80d78b2064a73b85848afdcc743da0cec4b0d76965d25fb7c7715923/fable_library-5.18.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98778e9bcbc82bed3d55dc30017521775c61827a6bc44bfb6e1518a64563c18b", size = 1502381, upload-time = "2026-09-25T22:20:01.471Z" }, - { url = "https://files.pythonhosted.org/packages/19/e1/577e34e914aaa56edee934af18ce2bf2fe5dd35fb028b6e581323c3d5a28/fable_library-5.18.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c2117be1aa90e59f3c36bcf53d6af5d796393df255a5395409cd3f2d28e7e46", size = 1434699, upload-time = "2026-09-25T22:20:02.713Z" }, - { url = "https://files.pythonhosted.org/packages/27/85/1c0cb0712751a28f109f6f1b6c4a80bf8dc96a12a467ed4b367620baf953/fable_library-5.18.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:85930132880eaab10fb33d509e9e26932bde289ff32de5480a343d2433a5d886", size = 1463586, upload-time = "2026-09-25T22:20:03.966Z" }, - { url = "https://files.pythonhosted.org/packages/98/a2/fc62785b2ce3cf39e7cc3231a71ef1521ddbc5d12aa708e4b256838df517/fable_library-5.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03adb2dc683a53a548f24b0c88e1052efbb86f2053f11c8430ee0e4e2f180245", size = 1539985, upload-time = "2026-09-25T22:20:05.215Z" }, - { url = "https://files.pythonhosted.org/packages/b5/95/36a49e1ec82950d5cef29d86a55fb84cf4db012a839f95d3cc09f7d7ed12/fable_library-5.18.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ad9909587eb869bc846e57fa6e4ed26e2154c8365af5dbcccf73dc0cc98d39bc", size = 1621795, upload-time = "2026-09-25T22:20:06.39Z" }, - { url = "https://files.pythonhosted.org/packages/e1/ac/cb3d2eb21228b4950cbc2688e7357fdd0bb4ef33b59e7baf9c7cff4b049e/fable_library-5.18.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:426d9097075899258d54d970b2153e04e1b84f3e302426b215f4f63f7dc2955f", size = 1647133, upload-time = "2026-09-25T22:20:07.516Z" }, - { url = "https://files.pythonhosted.org/packages/7a/a2/ea919885b3cbc1e82565b943ad5fc83fd7d9d2a4f0afbd4612e5c0605b05/fable_library-5.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f61c05a2dab0c03b0fd0fb51dc68e5d23e7ee877ccbb96eac257c1e1515babb", size = 1663937, upload-time = "2026-09-25T22:20:08.802Z" }, - { url = "https://files.pythonhosted.org/packages/63/d7/ff0f07f7daf644ea5d1a0df89005a7cb8bbd8c511d0e0a273a649a8de218/fable_library-5.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:88530451f943c5e3efb66c977a1e614580f4739f24772f0ad4ba9e1b5b2ded79", size = 1422818, upload-time = "2026-09-25T22:20:10.072Z" }, - { url = "https://files.pythonhosted.org/packages/52/cc/c144b5203e4f22b1fa7edc0f2afdb663afa484741620aa9a85aad6d9f735/fable_library-5.18.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1b7f829e1ec90d5c84545dc8b732f91f5b0b555d8ea462b0c5733d28867123ee", size = 1408259, upload-time = "2026-09-25T22:20:11.284Z" }, - { url = "https://files.pythonhosted.org/packages/f1/65/9c7449e03d96e1fc2cbcba1d55496cfd1ba3bf458e121bf8838092a200b5/fable_library-5.18.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fde09497f8b290b6be09214049424d618e46fd42a3ac1c649f15caf1f561d2ce", size = 1314163, upload-time = "2026-09-25T22:20:12.571Z" }, - { url = "https://files.pythonhosted.org/packages/a9/6a/9b441f9db31fbe152668b9ad46ae31dd31428872ea7d225cbbe020165fe5/fable_library-5.18.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:692e73f1c32ccc2bd7785960089f05e7a6587af25ed26f27602430abc0d672d4", size = 1362108, upload-time = "2026-09-25T22:20:13.731Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f1/b11995538a367730324dbaa202037ea39075a022e4c96965399d64868d69/fable_library-5.18.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c568bbbd1caa1ce8505303b82686c9e4bcf07b868179341f865daa7e5ea84fa", size = 1344973, upload-time = "2026-09-25T22:20:14.851Z" }, - { url = "https://files.pythonhosted.org/packages/14/fc/039679ad1589d455995353ac1a84cdd2dae88163c9003ff5d61ef64acaff/fable_library-5.18.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:315d605ebe71863434c6694e6b6a0d9d6b249bedf6bcd1d4ab6d9401e6fa9883", size = 1490119, upload-time = "2026-09-25T22:20:16.332Z" }, - { url = "https://files.pythonhosted.org/packages/56/84/407a361a32f9c2171b2bbd85011eb1a0ba20c7b4d7d8e78f4890b567c4a6/fable_library-5.18.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:829d67f3930f0da213b4ff2d01177b9b83ec49907cdd62a18fa15b5655c46e73", size = 1502943, upload-time = "2026-09-25T22:20:17.502Z" }, - { url = "https://files.pythonhosted.org/packages/8a/1c/f71504478e68ac718db6fb501ffa977c241cf3f18095e589cd27b08cb7a7/fable_library-5.18.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e5be22b3f8d9c38811aee83c2a0143b452afe391b8a41532b529570710fed3", size = 1435500, upload-time = "2026-09-25T22:20:18.871Z" }, - { url = "https://files.pythonhosted.org/packages/fb/d3/28f2b0a1e543b2ecb6c5cf09171d417511a5addaae7aeea768bce33bcabd/fable_library-5.18.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:846ce8d97d803d47501ca655962b2d29eae2401fe19228406a7783d23e8a2ad3", size = 1464516, upload-time = "2026-09-25T22:20:20.174Z" }, - { url = "https://files.pythonhosted.org/packages/f9/6d/15ed71022f03df6d997df1a5c161780e9962d1d8eea199efde2442e5cdf0/fable_library-5.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a26a9c33cdf36ee00c24746724e57b6b4ea21a9d952069376a766b7a2437ae15", size = 1540285, upload-time = "2026-09-25T22:20:21.469Z" }, - { url = "https://files.pythonhosted.org/packages/65/70/e69a0b18c9cd73e7019aeca9e88fa40529563e9053e48d7b9e68982f2b2c/fable_library-5.18.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9ba868cce58b0e734bd2970af80d41b9f5c025fe8cb5487ea1807efaf8fc88c8", size = 1622623, upload-time = "2026-09-25T22:20:22.831Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f1/d06381a90da8d348d90d164e8ce25f503e94a0a0b953fadf565f45eaa180/fable_library-5.18.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:325790db536e15ed533bb22a350c27a48b83f939ab37b9c38900ef1a199bb56a", size = 1647751, upload-time = "2026-09-25T22:20:24.128Z" }, - { url = "https://files.pythonhosted.org/packages/6a/61/f9758063d3b124fd9b1576dfd99a8517fab5018a01ae125a55ee1b8baf17/fable_library-5.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3d5925255f5312d7bda75eb339af62d501d606251be89c0fe0d747369bf72b3e", size = 1664854, upload-time = "2026-09-25T22:20:25.476Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d6/415d22b5ce9bdc38abe16845c5b6d70a2602f6bc21c421eb0227ef3130c3/fable_library-5.18.0-cp314-cp314-win32.whl", hash = "sha256:a325b87209ca66f10c1ed76c1a117c3111ec8599c6e6648cde1c998ba62d8b09", size = 1272042, upload-time = "2026-09-25T22:20:26.858Z" }, - { url = "https://files.pythonhosted.org/packages/82/72/fd94e359cc3d9676f74148faeb5771c6fc46532011954a3c225da9117677/fable_library-5.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:fbfc2a047d99a9b303a758c311ab47f6cadbce564df9c29fb337375b9aadf5f2", size = 1421817, upload-time = "2026-09-25T22:20:28.124Z" }, - { url = "https://files.pythonhosted.org/packages/c3/8c/5860294d8d08fdffae85a998476ba55d5d2c22906030f1b24ec0e99abcc2/fable_library-5.18.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec81cceb726027c19d14f240059f6aa83afa18cee178532641fbb1ba8ba207ee", size = 1367313, upload-time = "2026-09-25T22:20:29.5Z" }, - { url = "https://files.pythonhosted.org/packages/38/a8/2f38f0555a77bb2aeb113f7baab4f564b89665be85a1977757367a931b81/fable_library-5.18.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9cee353a4baffd7c9e7e657fb2a6fd251126e672469ba2408f23cf382aadcd5b", size = 1343361, upload-time = "2026-09-25T22:20:30.789Z" }, - { url = "https://files.pythonhosted.org/packages/8a/c8/a9b6745551b89af3a680850426f9c82b193ffeb1c811a0ba95f6494ecb82/fable_library-5.18.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02dab19862f5c02cef8c5d28790198afed950c31e17bbaa81143390d9ec1b9d5", size = 1497483, upload-time = "2026-09-25T22:20:32.018Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3d/50ac458ed93ca4acda42ff0c3b88e93657333bbc4a5917bf36cf0a4f8e00/fable_library-5.18.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61775d93cc7ddd7720622e4b6c3cf4a1d7f76dedd98750d40ffb6760cd48cb88", size = 1511004, upload-time = "2026-09-25T22:20:33.274Z" }, - { url = "https://files.pythonhosted.org/packages/35/52/a13e8db38a0e982ecec7ce34e565cde4d65757d2af992b0847428aba0c0f/fable_library-5.18.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:827d1abf9108b332b5817592456a049f7dd56aacb9a9add8860bc2ae57466716", size = 1444960, upload-time = "2026-09-25T22:20:34.683Z" }, - { url = "https://files.pythonhosted.org/packages/7a/e3/10efc4959970bee21f634e83451ef9b17e36ed6974fd762842a3204eb8eb/fable_library-5.18.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4e61bfc005b3f46e14b14a2fd82717d5b750a4e286fce5243f6eae57a3fd9750", size = 1468503, upload-time = "2026-09-25T22:20:35.953Z" }, - { url = "https://files.pythonhosted.org/packages/e1/63/cc831e087baff81d7847e9b526188e61247eeafaba3890a1516d1948048a/fable_library-5.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:56270f35791417e8e4cd7571da237e8c27d34b2c56d6f19a57c5ada8ac98430d", size = 1546347, upload-time = "2026-09-25T22:20:37.202Z" }, - { url = "https://files.pythonhosted.org/packages/35/8b/f0a3bac8663aff3d73e47e5a53c30dd18e0a7f6bda17ff4b828da2a8c039/fable_library-5.18.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:43e4dd793617fd21a8bc7feca641478f9d8b75b237cec2e96884bb76ab9a95a9", size = 1621796, upload-time = "2026-09-25T22:20:38.44Z" }, - { url = "https://files.pythonhosted.org/packages/18/26/d4cde7fad00d590ee1e9f66ccb9f062fd5588f63055bfd2defa0f287dadf/fable_library-5.18.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6fc10b49ff34425937683629b578498dfdbe494bf87772938e9c3e4d018c82ee", size = 1653636, upload-time = "2026-09-25T22:20:39.67Z" }, - { url = "https://files.pythonhosted.org/packages/79/c2/0e4690214db5e09d239a9f0272707a76f1152110f327680779542eb73313/fable_library-5.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fdd8008b9ce9c4d6ef15a04bc0c7da12b94031c947e918d473c8a18a9cf92efd", size = 1675518, upload-time = "2026-09-25T22:20:40.969Z" }, - { url = "https://files.pythonhosted.org/packages/75/61/56c5b06bdb3585284043c0439838aaa68f86033d8fa389e8da3b8a764169/fable_library-5.18.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb3efd6b22d36cd3c6872665bfcd9b8906dcc3e67a98bdf1bffcc18e95e55fd4", size = 1435549, upload-time = "2026-09-25T22:20:42.246Z" }, - { url = "https://files.pythonhosted.org/packages/0d/17/0e6d09faf9745013f926b6b3fc222956032f1acb95335e9b75a01b749f55/fable_library-5.18.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:685de1305b89f3967de238c32c8adaec268e49377dfdc7360c2ca31f0dc203cc", size = 1464775, upload-time = "2026-09-25T22:20:43.806Z" }, - { url = "https://files.pythonhosted.org/packages/b0/20/22c5162dbbfb501acbf809a1c671991beec5bcaf2225929940f2a08bba83/fable_library-5.18.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14c9ecdff1e925794885b52edf2a35e9dafff133ce70c0c525977d14a195e9e1", size = 1445051, upload-time = "2026-09-25T22:20:45.328Z" }, - { url = "https://files.pythonhosted.org/packages/7d/4a/d7772fa84fdcfd399ff4a2dfbc104563dde21fba32efb08403fade6a0aee/fable_library-5.18.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3952b230fbc04906118e654490e69d47d3b25841f82ac385487979f46e8612df", size = 1468650, upload-time = "2026-09-25T22:20:46.712Z" }, + { url = "https://files.pythonhosted.org/packages/26/aa/0c925fa7fab4489a01367948d0c543327335443943f98fa114f48a9c4819/fable_library-5.19.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3680c610b69c510c5f54db3fc7b3521e985bc2eb64ba276a857a750a361113e4", size = 1406666, upload-time = "2026-10-03T15:02:02.452Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a4/8159599ebc3ac74eb5b3dc4847108c527590e76b9c00edac09b6a7793b10/fable_library-5.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4fda71f581af2d0cc8bd3ac97ab4b67ef06a71f9290343be69fe46209024f3b9", size = 1313863, upload-time = "2026-10-03T15:02:04.052Z" }, + { url = "https://files.pythonhosted.org/packages/96/74/a45791fe4bd360351d0665c2ab1db86d2f85b8ab1402f2ecd9731a93c148/fable_library-5.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78820d378900b19aede76b7be2d4774fd0fb1f7fb66f0dbba406b931728c332a", size = 1351973, upload-time = "2026-10-03T15:02:05.434Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/231dbf9615509926acf146d59388c986d4c198e28f2d1fb31fa7431742a4/fable_library-5.19.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6eed22f2e89c55aab8044fc1d38e7f9f3a0f9a0e6c2fdee7c314210a00773dd", size = 1335299, upload-time = "2026-10-03T15:02:06.759Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ef/455dd0a3605b87fd729d4928d1f87e7b43c10f1b42bbd56248544572e452/fable_library-5.19.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:df70e223d0af5ab0bf53b83e0758f2053f45d828fe169ff42d441c53ff687fd2", size = 1476070, upload-time = "2026-10-03T15:02:08.265Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9e/447c48d1a4fded29d7e9b8756ab77a21946df02409b5ca1327bb5c87184b/fable_library-5.19.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5429337e9444c390be023201e82fe39d3b3f2d5512196aacbca05d2be80e5d7", size = 1501223, upload-time = "2026-10-03T15:02:09.735Z" }, + { url = "https://files.pythonhosted.org/packages/18/63/30351d76b109c7380de2002f55c93304393adefba0e8f72d6ee5771f2124/fable_library-5.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c994adb79cce4a43407ab57dc025f90dab94ee654e1bc364b4f69309d95ae288", size = 1424808, upload-time = "2026-10-03T15:02:11.289Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/1fd3b8119fb0ad596cb97cba873078c429cbd6c00901c01cb6c69be4e2af/fable_library-5.19.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:da8541df88f456edfc7b394077f50a57c3a455ab22a2f0144a2d0897031bf98a", size = 1453602, upload-time = "2026-10-03T15:02:12.742Z" }, + { url = "https://files.pythonhosted.org/packages/39/f8/5c3571c246f4852cf534616f4d56d29fa561eb34ffeec12e581beb254ae5/fable_library-5.19.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e2094ded91e57489f5cee99736350b73d250d35124aee6389f1109ba9c8f3337", size = 1531695, upload-time = "2026-10-03T15:02:14.41Z" }, + { url = "https://files.pythonhosted.org/packages/27/dc/eeeb4687401626ddf83b866f0fd265418b23c3218ee22c277789bcd7fe81/fable_library-5.19.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7e057956edb38c90b014a2b04becd9d07483ab23660a5d87482a7b61884c05c4", size = 1612045, upload-time = "2026-10-03T15:02:16.07Z" }, + { url = "https://files.pythonhosted.org/packages/89/26/00765fa18b173a3ea81ad47b5d1681f4ce8f6b71f1a774f1300b6b7abb81/fable_library-5.19.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2e18d2fcea6e3201c73450023d54893953d368f8692785a9f8b24143f04c4e76", size = 1636584, upload-time = "2026-10-03T15:02:17.385Z" }, + { url = "https://files.pythonhosted.org/packages/5a/66/e88166cfb62401e2c01068d43bdfc42b0dc508e8afd285eff4a21607b70a/fable_library-5.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a5ad12f9cf2ec841265815958cbcb3e78c7cba9557425e7d33d18d4effb9c6d0", size = 1651566, upload-time = "2026-10-03T15:02:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/f0/35/b1aedfa1f845a1aae3a9a5e64a953fd601201c30e83951406dfb4da77162/fable_library-5.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:591edefca445f350a4b1aeec272541eb522c4b27fef2cd41abf5b67b87575f2e", size = 1419953, upload-time = "2026-10-03T15:02:20.277Z" }, + { url = "https://files.pythonhosted.org/packages/08/59/8a74500a8ea29db647337d9e34ad373b0644f1a5a95ba11a649967072523/fable_library-5.19.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:18a324500466f94e316f1930d4d51b70d52bca0c554b7f14647fa983026b31f9", size = 1407360, upload-time = "2026-10-03T15:02:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/04/06/9a9f3874742c81b5732e1367792be17503a574a5d092fe9c4f3cedb2d1c0/fable_library-5.19.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f090eac1c12515275ca6fc613136470c23ef0acff52d0b2ef7f0000f99aa9035", size = 1314027, upload-time = "2026-10-03T15:02:22.943Z" }, + { url = "https://files.pythonhosted.org/packages/b7/34/de37d559522dd385513311008866e16a8b76e64db39fabd0331faa51c3b0/fable_library-5.19.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e89fef4a29d3bdd2a76c7ec044828b09cde29571a891acd348d4493d2bf80b77", size = 1352337, upload-time = "2026-10-03T15:02:24.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/c9/a5f8cc6bff20acdeaf839e8061902e54c8b81194c96c225823c159f97f12/fable_library-5.19.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9928e7ad7ec6c72e332ca59803a4e46dba6b32cad88484bbbcd65b1bb39ef416", size = 1335203, upload-time = "2026-10-03T15:02:25.783Z" }, + { url = "https://files.pythonhosted.org/packages/22/00/9026da0c5703195f6f0ef33a334642b266eda6994e6bea859799b920da6d/fable_library-5.19.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6acaa8f1362049c7294c8f4542dddb17609b935f61afebb25166bc1d5dcb7539", size = 1476410, upload-time = "2026-10-03T15:02:27.176Z" }, + { url = "https://files.pythonhosted.org/packages/57/96/5bb68cf6d23d0d1d6197d7c2fc86950b862e4de9d4d1ea7a76376a3431e9/fable_library-5.19.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aea95b79ce4db23211dd4fc574a1ac113f7de586b16b36c0673c897a2b3b3dfc", size = 1500926, upload-time = "2026-10-03T15:02:28.587Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2a/5af986781b0eababc6c695485f88d9cbbb453b13bc09a8a58e7be8ae040d/fable_library-5.19.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89b79345386a85fd1238eba540135420a080e21d0e5f4983e5dd132b736124e8", size = 1425743, upload-time = "2026-10-03T15:02:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/b5/5e/48a25037c2d38b95dd8c6fcecdecb7de30f40849df4c08b27a8f49e463bb/fable_library-5.19.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8496d94e81a3e5a1646adc44804cbd569c7007efd78dcb247305c4ea20b640bf", size = 1453797, upload-time = "2026-10-03T15:02:31.352Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5b/50220af8670cfc0fa8389665a928d75a056b06087e774bb04e49ce43b22b/fable_library-5.19.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6afbe74f1ac016243a36eeae281054108ee2a82e4cb85e78eb9f43c430b7f811", size = 1532049, upload-time = "2026-10-03T15:02:33.054Z" }, + { url = "https://files.pythonhosted.org/packages/af/56/603affe3e85f5a547f0ecd4f075d3027559f747f2e607b7b203eb3272403/fable_library-5.19.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:33906878a15144ff322657ad5f0871d5f193746daaf00d35247dfcce584e2387", size = 1611635, upload-time = "2026-10-03T15:02:34.775Z" }, + { url = "https://files.pythonhosted.org/packages/3a/87/95e247de1f6b99980bd66b069f6a1a38875112630d7f489f160cc778431e/fable_library-5.19.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4bc6dff68299405d37ded2642eba6fff7406edec0c05b1e2bc18a93a1e9621d5", size = 1638739, upload-time = "2026-10-03T15:02:36.307Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b8/884ff6f1ed252092d8d09bda2068c4a9f8e29ad9d250d067f9f6f45216b8/fable_library-5.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1994b0785bbade97c010c5b191d83c49b88dab158c2acf392895fd61fe3d55eb", size = 1652335, upload-time = "2026-10-03T15:02:37.795Z" }, + { url = "https://files.pythonhosted.org/packages/d1/27/754db80236945849554bfe198e0bd49e600299d33b43309acbd645fb9037/fable_library-5.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:999fa20470da58468effa59293f54b26139cf48713171b293752b6c08c7fee74", size = 1422823, upload-time = "2026-10-03T15:02:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/25/75/5083a52f76af8020d938cbbcf1c0b10269cae29bde5f0e788b2cbd8695cb/fable_library-5.19.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9273b40f1d58d89c5ed580a07d379d5bc650b439bb56cafa93f0a6049962eded", size = 1408266, upload-time = "2026-10-03T15:02:41.113Z" }, + { url = "https://files.pythonhosted.org/packages/ac/82/a67641ed6e3660119c82d55c8b1f26d7d0ff629b8d920e19dae041bf99eb/fable_library-5.19.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b41f94e462a193e12a67fea5b4ffd68d4b50958d20a75355fe0dcb47adb8113d", size = 1314170, upload-time = "2026-10-03T15:02:42.829Z" }, + { url = "https://files.pythonhosted.org/packages/27/71/7e929dfd163613d60ee493071cec64ced2e9ddcb4dd4935c6258f6b4cdf4/fable_library-5.19.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11aef7971316849b2de7706d0fea32399f14ed4459e3f72721a4aa3571d97392", size = 1352434, upload-time = "2026-10-03T15:02:44.899Z" }, + { url = "https://files.pythonhosted.org/packages/0d/57/01236b0c719d5a2759c4a30424ddd365be8404b5ded3efc96dd74723d8b1/fable_library-5.19.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49681bd14524a7573790b7d34436db3f540350972e3daa62c7b519904ec9e545", size = 1335313, upload-time = "2026-10-03T15:02:47.006Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ea/974130475b57a72f0eb49d9133e4236767a43535c2559ac5a358a18f74e5/fable_library-5.19.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a73e3edf15d805410faea964e666b06e673716573adc9de4f01efffbf735ce0", size = 1476810, upload-time = "2026-10-03T15:02:48.835Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/d754d7bb5d7b4637646d8579b48d6daf687989aaaf7591bc998a37e79c7f/fable_library-5.19.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5f4dbbdb6aa9757468083f34db530092e710a14f756dde3585ad4d3e9702de89", size = 1501445, upload-time = "2026-10-03T15:02:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/8b/28/455adc0e609c7e9e21559203f7886613a0a379a9b408d6428f7015d9ac74/fable_library-5.19.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:822dbe47fa63b1c700ddc521b0a9dae73d0232a88a5b2ff82cbc4f2e9cca70e8", size = 1426663, upload-time = "2026-10-03T15:02:52.44Z" }, + { url = "https://files.pythonhosted.org/packages/fd/94/2ee9f2bb709204ec97c2d426900cdd33ba91694221f389ca212853e70f61/fable_library-5.19.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1295dda6ebb5cf17a3bd9be74f383ab21f5e0d8f89caf1d4617dfff2f2d33adf", size = 1454555, upload-time = "2026-10-03T15:02:53.921Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cb/1f129c5c368f1ca87702892839f2cd642acc58b1f675b30a05e9056bb0a5/fable_library-5.19.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:985c1293ea3867897e185de350a483050e0ab985e24467c4f93a63e84324c3a3", size = 1532869, upload-time = "2026-10-03T15:02:55.547Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/9d32e07db06f6396611f412b3032c93493da54b4b760fc63f20db07611e3/fable_library-5.19.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1d96128c4e8d8dc99a8d5483a96231b1a356037b6d906e4b4fc9ed662719e803", size = 1612101, upload-time = "2026-10-03T15:02:57.154Z" }, + { url = "https://files.pythonhosted.org/packages/9e/35/78f812e0e7b0810c0d079d50174915a612290ceb830fdedccca9148ccb12/fable_library-5.19.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:23c1d77e5d76c5ebf62d9bcccff57f16726ef0f0715b28e97ea15c3632d19cdf", size = 1639222, upload-time = "2026-10-03T15:02:58.689Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f4/deda8192e2df1f01e85274df60f7454449d848944e8b32ff01f647590207/fable_library-5.19.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dd21f2a8e745128566f570d7049d1307a8bb58229970ba985e66550b96863a9b", size = 1653478, upload-time = "2026-10-03T15:03:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/eb/4d/9d69567bbee490276def5b4c120f9d7a17978a685466ae9c5db896c96f49/fable_library-5.19.0-cp314-cp314-win32.whl", hash = "sha256:906a5231583b544eb24bbe1dd9b471e39790d7790aecf00b70258c6c5eba6e2e", size = 1272048, upload-time = "2026-10-03T15:03:02.033Z" }, + { url = "https://files.pythonhosted.org/packages/0b/10/12107d8d4bb0340ec1c777813f8c1223a46217649dc634995c6a83b87c5f/fable_library-5.19.0-cp314-cp314-win_amd64.whl", hash = "sha256:8e34f1227afb41af900486f5ca82bcd11bdb0000d70d4189037028e96473c0ae", size = 1421818, upload-time = "2026-10-03T15:03:04.653Z" }, + { url = "https://files.pythonhosted.org/packages/d7/08/252993cf1c4010fe345301c7343c4a0bba01db5a392120f12d7ba611d5ec/fable_library-5.19.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f491e17527828c6da0b1bb51dc174eafe0ead69e36d3f90ec424189bd27cab5", size = 1356921, upload-time = "2026-10-03T15:03:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f0/d46f7e42d2fdc9fd3eafbb4375d585519ede7fa640cc98714e795c68330a/fable_library-5.19.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5494153051c0054ccdf3bd610e7d90a1363ffead19599f1435529e6c9c823536", size = 1335835, upload-time = "2026-10-03T15:03:08.089Z" }, + { url = "https://files.pythonhosted.org/packages/0e/04/97e318f2ddb9cb24560e89f99ed80788ded6758bdc966db8f1ff540bc77c/fable_library-5.19.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7494b5eef1927c084ede2a31e45be95ac1154dc725dd60219bb8e2591bbe9f33", size = 1483829, upload-time = "2026-10-03T15:03:09.715Z" }, + { url = "https://files.pythonhosted.org/packages/d0/61/b3d6faa467d82f831bb5c38c58e7a0253beead24fea67070ceb51b9ed948/fable_library-5.19.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4dcc086acebf53da66b35dc3132b7e77cc21ad11f5a17015230d092198afc9a", size = 1507820, upload-time = "2026-10-03T15:03:11.463Z" }, + { url = "https://files.pythonhosted.org/packages/bf/7b/613b170445fdd35f66ab9ede78f48e035a2223aa05cc6b4d39024b5cf699/fable_library-5.19.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:650e5705db835dbb6df5ddb717d4c14c65c2ded88b00ad099047d3aaa8fee419", size = 1432473, upload-time = "2026-10-03T15:03:13.068Z" }, + { url = "https://files.pythonhosted.org/packages/19/41/6ca7a3d2a49aec591819a4af982c397bb20c576cfc8ec5b03977d3998ab4/fable_library-5.19.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:591e424fc0d0ca951a9c7eeebbba09de6984a9e44589fd659e48c22d6356233b", size = 1458175, upload-time = "2026-10-03T15:03:14.563Z" }, + { url = "https://files.pythonhosted.org/packages/78/4f/f91e5b4b57126621436e3653f18ee775762325aa3677a91d8e7ded7791fe/fable_library-5.19.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6fec3a3521172486546cbe553307a55885e367d84212a94b56a34ebf924522d", size = 1535753, upload-time = "2026-10-03T15:03:16.185Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9a/b376089a81acac3fe3e32478663e3e3c65b88e600f52d124c69d8377cde0/fable_library-5.19.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0da0663936c3436bfc80bf90bb08a1ee74d4fe0fcb18a335a9c673c68e85a1ad", size = 1612903, upload-time = "2026-10-03T15:03:17.832Z" }, + { url = "https://files.pythonhosted.org/packages/23/cc/5bc768b411517a93be38d3fbba097dfc2fb5b6f3630e0499a72f0e8dd501/fable_library-5.19.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0a831b3506eb7c6860bfc9dc70a2b4c74810704a36b0335cccfbc2d3958dc484", size = 1647391, upload-time = "2026-10-03T15:03:19.563Z" }, + { url = "https://files.pythonhosted.org/packages/44/43/99ce0cdd25856e510e9bc7d6eedd5035051bbc8503c98460f935bc7ad407/fable_library-5.19.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5d4deeb4d308879f0cdfa4cd597d13cd3d8041cd1c9264e4c10888cdbd3e123", size = 1660953, upload-time = "2026-10-03T15:03:21.314Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6c/8f5c973a996315de7ee3d97431aafbc78afdb82c4630dd41fb6e43019d99/fable_library-5.19.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d69d1e94e84d9904651710a96050fe2cbe2d6efce71769c2356d7565ac8985a", size = 1426593, upload-time = "2026-10-03T15:03:22.899Z" }, + { url = "https://files.pythonhosted.org/packages/db/4d/b059cc8439e8ab609757e60fbd2e57f83f3020b0b02b664f55baac2e795d/fable_library-5.19.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:832c8ff9ffc6aef45985a057a21bb4ea0c7c262f7317f69a8f22bd20f29a00b6", size = 1454695, upload-time = "2026-10-03T15:03:24.52Z" }, + { url = "https://files.pythonhosted.org/packages/57/29/55fdfc6453843f3ff8ca76de712cb853fb17b87d110bbc2bf360d57a7cbe/fable_library-5.19.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:364f7413b4ba2638a35eb5ae038fe89f7216eb07d864a9afe3f9721ed2ea778a", size = 1432570, upload-time = "2026-10-03T15:03:26.168Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9d/456a5f27a6ad7193b85285832d2ea63ed7af058dd465482ed4f10f40596f/fable_library-5.19.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ed6ddce1e0f5f3317cd533ec41c2732a374aa0331496d5669f574051abcf1260", size = 1458357, upload-time = "2026-10-03T15:03:27.888Z" }, ] [[package]] @@ -75,7 +75,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "fable-library", specifier = ">=5.18.0" }, + { name = "fable-library", specifier = ">=5.19.0" }, { name = "tk", specifier = ">=0.1.0" }, ] diff --git a/pyproject.toml b/pyproject.toml index 8053ba7..f4d70a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,5 +3,5 @@ name = "fable-actor" version = "0.1.0" requires-python = ">= 3.12" dependencies = [ - "fable-library>=5.18.0", + "fable-library>=5.19.0", ] diff --git a/src/Fable.Actor/Fable.Actor.fsproj b/src/Fable.Actor/Fable.Actor.fsproj index 9f5442c..84ee80a 100644 --- a/src/Fable.Actor/Fable.Actor.fsproj +++ b/src/Fable.Actor/Fable.Actor.fsproj @@ -22,7 +22,7 @@ - + diff --git a/uv.lock b/uv.lock index 6871d70..49c56bf 100644 --- a/uv.lock +++ b/uv.lock @@ -11,66 +11,66 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "fable-library", specifier = ">=5.18.0" }] +requires-dist = [{ name = "fable-library", specifier = ">=5.19.0" }] [[package]] name = "fable-library" -version = "5.18.0" +version = "5.19.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/75/dca1fece690048d6eb9966a11a2fb754045391b9c5c0c0ee377291570c8e/fable_library-5.18.0.tar.gz", hash = "sha256:ebfbcd8227377f6afcb655a08e77940b2cd4c95d2c02b6c4f11845234481ea9e", size = 248999, upload-time = "2026-09-25T22:21:02.85Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/c9/13268bae63e9a7e0e302503f0c50b562a3671ce55baae27890c9771d26f3/fable_library-5.19.0.tar.gz", hash = "sha256:8b918f47aa3e48d8d69cbaae46dcc72a31b627b78f818e3fd8e1c3b3601d1993", size = 249385, upload-time = "2026-10-03T15:03:47.719Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/e7/2c2133b5e5eac20b4a6f1378156b5432f9c54578a9c9f5576938bb2819f3/fable_library-5.18.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9f1d4ab75003ef1d8682183496be77589048b575c9be6b68a3549b80d641ec48", size = 1406661, upload-time = "2026-09-25T22:19:38.003Z" }, - { url = "https://files.pythonhosted.org/packages/13/22/63b46cac277d98f39811a793865620f33d3ee2965288133cd089013c3d7e/fable_library-5.18.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f88fade2d69260c2ae993eeaff00715c392d908a71bee40f84abc6d7098c18fd", size = 1313857, upload-time = "2026-09-25T22:19:39.18Z" }, - { url = "https://files.pythonhosted.org/packages/49/d7/94edc48f44293ac4a9b9a7d35602b8e966fffd76d8f7e5585df66f11e571/fable_library-5.18.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61cbd060f0c7bba94898f601295d0f3610f299d63d0949a5b8ceb437e7ec7265", size = 1362145, upload-time = "2026-09-25T22:19:40.313Z" }, - { url = "https://files.pythonhosted.org/packages/64/e5/3a39202e8fee252291ab1e2c0480fc16cf93bd5c690ccdf64f1f1b6ea6bc/fable_library-5.18.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b05f1b887c13055e5b1c50744ecad06e332005f7e6a8fcf88ef476e34888fc07", size = 1343817, upload-time = "2026-09-25T22:19:41.531Z" }, - { url = "https://files.pythonhosted.org/packages/cf/65/8e4a96de89b9efa11ae6d14930b72f7e2ced22081724b29fb10cb9e6efab/fable_library-5.18.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e1b61065cb0869915250f70708ccb78252e5eddc9830edcede6287dced48f71", size = 1488557, upload-time = "2026-09-25T22:19:42.801Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/c4d26a5bbaf30ae6e164f250ff834d4a2eedc96fe19c4596f907949d98bb/fable_library-5.18.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:76656246db91d59971eb236cd2f3433533415de8ee859dedcaf12f9fc2688f75", size = 1502651, upload-time = "2026-09-25T22:19:44.045Z" }, - { url = "https://files.pythonhosted.org/packages/03/b1/c6257788f2b1eab86f8139cf99d6ec07e70b7554ef56aca5be3090fc57db/fable_library-5.18.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:179df74217a556a78a2df74832a3f6b7edd647d1031f222f46bc6ddb6b5a171f", size = 1433763, upload-time = "2026-09-25T22:19:45.346Z" }, - { url = "https://files.pythonhosted.org/packages/a0/8d/815750991b885334c8a463ebd5ab9b0c776d8965702924c26859d93c22f7/fable_library-5.18.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8df74c881ce63a1ce6f32cd802afe4353fd7e22483491bf67476077723ce282", size = 1463485, upload-time = "2026-09-25T22:19:47.19Z" }, - { url = "https://files.pythonhosted.org/packages/92/52/4885264e12703f6f3c4ae9e6e357c99cee1478a6b00be88dd490af351aba/fable_library-5.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5768d00e3b56c7003f6019d9434b4fc58ebd2f55125b7bc2325de8f76945f52a", size = 1540244, upload-time = "2026-09-25T22:19:48.381Z" }, - { url = "https://files.pythonhosted.org/packages/6b/68/a2aae35e946fe9489c0a6ecc2f1cebee91bf91ca2c954e8151328a753c1c/fable_library-5.18.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:2a49718bf9f1dc00c9e1afd5c75793db4d7b7ca4d9deb7fd5ab19e4383416cd8", size = 1621870, upload-time = "2026-09-25T22:19:49.786Z" }, - { url = "https://files.pythonhosted.org/packages/68/02/6501e35df2884cffd0ea14830ccbb24a534dfa80777bf102a2950a754e3b/fable_library-5.18.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cb3935fdfea373f44cdf1400898f921c105954536d221a999a6e41bf24f356f0", size = 1644512, upload-time = "2026-09-25T22:19:51.201Z" }, - { url = "https://files.pythonhosted.org/packages/68/37/97789b767c58474a0766d55e1fa1a06a2054f18be5db4b1bea85deb638ec/fable_library-5.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb94fb2a26d7b0e02c0d3eedc83c23faff690bb8ad4bfae7b4732be781a557d7", size = 1663576, upload-time = "2026-09-25T22:19:52.421Z" }, - { url = "https://files.pythonhosted.org/packages/75/76/ba0b307834699275eceb506dc2d0a8facdadd5c0697eacca5eca53084d3a/fable_library-5.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:edbfdb05a806f881b65268fdd3a6d4b24a8c18c83a758473761e155b6df5d748", size = 1419946, upload-time = "2026-09-25T22:19:53.661Z" }, - { url = "https://files.pythonhosted.org/packages/8e/58/e23918c62147a1b9642af2a5c0cd8d368fa87ece5b26d7c6cd8e59269b85/fable_library-5.18.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:bb2c21d1883101fe652fa19481da3bc508e4f3fbb113d388a0aff312145af8b2", size = 1407354, upload-time = "2026-09-25T22:19:54.924Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bb/3110f2e619a84c3afabe323eee2714d819023acfac11d74740304aaf89b0/fable_library-5.18.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:53005374d630852f1f4816c4ccc1e12ffe7a3e2a69eb5e73b82689a2a291ef8d", size = 1314021, upload-time = "2026-09-25T22:19:56.209Z" }, - { url = "https://files.pythonhosted.org/packages/ba/e7/0adf16c86c96138576b17f2f29d6112a636bc6d23e99cc093ee488b572f3/fable_library-5.18.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fffc653377d82ab6cc80c9c27525e622f7048ebac22e1f6704fbd252b29cca2", size = 1362104, upload-time = "2026-09-25T22:19:57.313Z" }, - { url = "https://files.pythonhosted.org/packages/07/5e/8f5f48a9bb7e585bee360524fe01dbb2d0b0517ce447dd485e5942e4f8cc/fable_library-5.18.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9889f71a72ae441e0cc2b3437420b07b58f660f3d2f251be9cc591bcad62cb54", size = 1343730, upload-time = "2026-09-25T22:19:58.793Z" }, - { url = "https://files.pythonhosted.org/packages/6e/57/ad14b7732f77899c3b503f852b6064e236f30beecc5e247c6778eaf5ea85/fable_library-5.18.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:83af5015dc364f61a3a0823662625d7bdd4b90cf12bba680bdf253e999ff958d", size = 1489192, upload-time = "2026-09-25T22:19:59.89Z" }, - { url = "https://files.pythonhosted.org/packages/5f/b3/fa1a80d78b2064a73b85848afdcc743da0cec4b0d76965d25fb7c7715923/fable_library-5.18.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98778e9bcbc82bed3d55dc30017521775c61827a6bc44bfb6e1518a64563c18b", size = 1502381, upload-time = "2026-09-25T22:20:01.471Z" }, - { url = "https://files.pythonhosted.org/packages/19/e1/577e34e914aaa56edee934af18ce2bf2fe5dd35fb028b6e581323c3d5a28/fable_library-5.18.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c2117be1aa90e59f3c36bcf53d6af5d796393df255a5395409cd3f2d28e7e46", size = 1434699, upload-time = "2026-09-25T22:20:02.713Z" }, - { url = "https://files.pythonhosted.org/packages/27/85/1c0cb0712751a28f109f6f1b6c4a80bf8dc96a12a467ed4b367620baf953/fable_library-5.18.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:85930132880eaab10fb33d509e9e26932bde289ff32de5480a343d2433a5d886", size = 1463586, upload-time = "2026-09-25T22:20:03.966Z" }, - { url = "https://files.pythonhosted.org/packages/98/a2/fc62785b2ce3cf39e7cc3231a71ef1521ddbc5d12aa708e4b256838df517/fable_library-5.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03adb2dc683a53a548f24b0c88e1052efbb86f2053f11c8430ee0e4e2f180245", size = 1539985, upload-time = "2026-09-25T22:20:05.215Z" }, - { url = "https://files.pythonhosted.org/packages/b5/95/36a49e1ec82950d5cef29d86a55fb84cf4db012a839f95d3cc09f7d7ed12/fable_library-5.18.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ad9909587eb869bc846e57fa6e4ed26e2154c8365af5dbcccf73dc0cc98d39bc", size = 1621795, upload-time = "2026-09-25T22:20:06.39Z" }, - { url = "https://files.pythonhosted.org/packages/e1/ac/cb3d2eb21228b4950cbc2688e7357fdd0bb4ef33b59e7baf9c7cff4b049e/fable_library-5.18.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:426d9097075899258d54d970b2153e04e1b84f3e302426b215f4f63f7dc2955f", size = 1647133, upload-time = "2026-09-25T22:20:07.516Z" }, - { url = "https://files.pythonhosted.org/packages/7a/a2/ea919885b3cbc1e82565b943ad5fc83fd7d9d2a4f0afbd4612e5c0605b05/fable_library-5.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f61c05a2dab0c03b0fd0fb51dc68e5d23e7ee877ccbb96eac257c1e1515babb", size = 1663937, upload-time = "2026-09-25T22:20:08.802Z" }, - { url = "https://files.pythonhosted.org/packages/63/d7/ff0f07f7daf644ea5d1a0df89005a7cb8bbd8c511d0e0a273a649a8de218/fable_library-5.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:88530451f943c5e3efb66c977a1e614580f4739f24772f0ad4ba9e1b5b2ded79", size = 1422818, upload-time = "2026-09-25T22:20:10.072Z" }, - { url = "https://files.pythonhosted.org/packages/52/cc/c144b5203e4f22b1fa7edc0f2afdb663afa484741620aa9a85aad6d9f735/fable_library-5.18.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1b7f829e1ec90d5c84545dc8b732f91f5b0b555d8ea462b0c5733d28867123ee", size = 1408259, upload-time = "2026-09-25T22:20:11.284Z" }, - { url = "https://files.pythonhosted.org/packages/f1/65/9c7449e03d96e1fc2cbcba1d55496cfd1ba3bf458e121bf8838092a200b5/fable_library-5.18.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fde09497f8b290b6be09214049424d618e46fd42a3ac1c649f15caf1f561d2ce", size = 1314163, upload-time = "2026-09-25T22:20:12.571Z" }, - { url = "https://files.pythonhosted.org/packages/a9/6a/9b441f9db31fbe152668b9ad46ae31dd31428872ea7d225cbbe020165fe5/fable_library-5.18.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:692e73f1c32ccc2bd7785960089f05e7a6587af25ed26f27602430abc0d672d4", size = 1362108, upload-time = "2026-09-25T22:20:13.731Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f1/b11995538a367730324dbaa202037ea39075a022e4c96965399d64868d69/fable_library-5.18.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c568bbbd1caa1ce8505303b82686c9e4bcf07b868179341f865daa7e5ea84fa", size = 1344973, upload-time = "2026-09-25T22:20:14.851Z" }, - { url = "https://files.pythonhosted.org/packages/14/fc/039679ad1589d455995353ac1a84cdd2dae88163c9003ff5d61ef64acaff/fable_library-5.18.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:315d605ebe71863434c6694e6b6a0d9d6b249bedf6bcd1d4ab6d9401e6fa9883", size = 1490119, upload-time = "2026-09-25T22:20:16.332Z" }, - { url = "https://files.pythonhosted.org/packages/56/84/407a361a32f9c2171b2bbd85011eb1a0ba20c7b4d7d8e78f4890b567c4a6/fable_library-5.18.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:829d67f3930f0da213b4ff2d01177b9b83ec49907cdd62a18fa15b5655c46e73", size = 1502943, upload-time = "2026-09-25T22:20:17.502Z" }, - { url = "https://files.pythonhosted.org/packages/8a/1c/f71504478e68ac718db6fb501ffa977c241cf3f18095e589cd27b08cb7a7/fable_library-5.18.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e5be22b3f8d9c38811aee83c2a0143b452afe391b8a41532b529570710fed3", size = 1435500, upload-time = "2026-09-25T22:20:18.871Z" }, - { url = "https://files.pythonhosted.org/packages/fb/d3/28f2b0a1e543b2ecb6c5cf09171d417511a5addaae7aeea768bce33bcabd/fable_library-5.18.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:846ce8d97d803d47501ca655962b2d29eae2401fe19228406a7783d23e8a2ad3", size = 1464516, upload-time = "2026-09-25T22:20:20.174Z" }, - { url = "https://files.pythonhosted.org/packages/f9/6d/15ed71022f03df6d997df1a5c161780e9962d1d8eea199efde2442e5cdf0/fable_library-5.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a26a9c33cdf36ee00c24746724e57b6b4ea21a9d952069376a766b7a2437ae15", size = 1540285, upload-time = "2026-09-25T22:20:21.469Z" }, - { url = "https://files.pythonhosted.org/packages/65/70/e69a0b18c9cd73e7019aeca9e88fa40529563e9053e48d7b9e68982f2b2c/fable_library-5.18.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9ba868cce58b0e734bd2970af80d41b9f5c025fe8cb5487ea1807efaf8fc88c8", size = 1622623, upload-time = "2026-09-25T22:20:22.831Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f1/d06381a90da8d348d90d164e8ce25f503e94a0a0b953fadf565f45eaa180/fable_library-5.18.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:325790db536e15ed533bb22a350c27a48b83f939ab37b9c38900ef1a199bb56a", size = 1647751, upload-time = "2026-09-25T22:20:24.128Z" }, - { url = "https://files.pythonhosted.org/packages/6a/61/f9758063d3b124fd9b1576dfd99a8517fab5018a01ae125a55ee1b8baf17/fable_library-5.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3d5925255f5312d7bda75eb339af62d501d606251be89c0fe0d747369bf72b3e", size = 1664854, upload-time = "2026-09-25T22:20:25.476Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d6/415d22b5ce9bdc38abe16845c5b6d70a2602f6bc21c421eb0227ef3130c3/fable_library-5.18.0-cp314-cp314-win32.whl", hash = "sha256:a325b87209ca66f10c1ed76c1a117c3111ec8599c6e6648cde1c998ba62d8b09", size = 1272042, upload-time = "2026-09-25T22:20:26.858Z" }, - { url = "https://files.pythonhosted.org/packages/82/72/fd94e359cc3d9676f74148faeb5771c6fc46532011954a3c225da9117677/fable_library-5.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:fbfc2a047d99a9b303a758c311ab47f6cadbce564df9c29fb337375b9aadf5f2", size = 1421817, upload-time = "2026-09-25T22:20:28.124Z" }, - { url = "https://files.pythonhosted.org/packages/c3/8c/5860294d8d08fdffae85a998476ba55d5d2c22906030f1b24ec0e99abcc2/fable_library-5.18.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec81cceb726027c19d14f240059f6aa83afa18cee178532641fbb1ba8ba207ee", size = 1367313, upload-time = "2026-09-25T22:20:29.5Z" }, - { url = "https://files.pythonhosted.org/packages/38/a8/2f38f0555a77bb2aeb113f7baab4f564b89665be85a1977757367a931b81/fable_library-5.18.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9cee353a4baffd7c9e7e657fb2a6fd251126e672469ba2408f23cf382aadcd5b", size = 1343361, upload-time = "2026-09-25T22:20:30.789Z" }, - { url = "https://files.pythonhosted.org/packages/8a/c8/a9b6745551b89af3a680850426f9c82b193ffeb1c811a0ba95f6494ecb82/fable_library-5.18.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02dab19862f5c02cef8c5d28790198afed950c31e17bbaa81143390d9ec1b9d5", size = 1497483, upload-time = "2026-09-25T22:20:32.018Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3d/50ac458ed93ca4acda42ff0c3b88e93657333bbc4a5917bf36cf0a4f8e00/fable_library-5.18.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61775d93cc7ddd7720622e4b6c3cf4a1d7f76dedd98750d40ffb6760cd48cb88", size = 1511004, upload-time = "2026-09-25T22:20:33.274Z" }, - { url = "https://files.pythonhosted.org/packages/35/52/a13e8db38a0e982ecec7ce34e565cde4d65757d2af992b0847428aba0c0f/fable_library-5.18.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:827d1abf9108b332b5817592456a049f7dd56aacb9a9add8860bc2ae57466716", size = 1444960, upload-time = "2026-09-25T22:20:34.683Z" }, - { url = "https://files.pythonhosted.org/packages/7a/e3/10efc4959970bee21f634e83451ef9b17e36ed6974fd762842a3204eb8eb/fable_library-5.18.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4e61bfc005b3f46e14b14a2fd82717d5b750a4e286fce5243f6eae57a3fd9750", size = 1468503, upload-time = "2026-09-25T22:20:35.953Z" }, - { url = "https://files.pythonhosted.org/packages/e1/63/cc831e087baff81d7847e9b526188e61247eeafaba3890a1516d1948048a/fable_library-5.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:56270f35791417e8e4cd7571da237e8c27d34b2c56d6f19a57c5ada8ac98430d", size = 1546347, upload-time = "2026-09-25T22:20:37.202Z" }, - { url = "https://files.pythonhosted.org/packages/35/8b/f0a3bac8663aff3d73e47e5a53c30dd18e0a7f6bda17ff4b828da2a8c039/fable_library-5.18.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:43e4dd793617fd21a8bc7feca641478f9d8b75b237cec2e96884bb76ab9a95a9", size = 1621796, upload-time = "2026-09-25T22:20:38.44Z" }, - { url = "https://files.pythonhosted.org/packages/18/26/d4cde7fad00d590ee1e9f66ccb9f062fd5588f63055bfd2defa0f287dadf/fable_library-5.18.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6fc10b49ff34425937683629b578498dfdbe494bf87772938e9c3e4d018c82ee", size = 1653636, upload-time = "2026-09-25T22:20:39.67Z" }, - { url = "https://files.pythonhosted.org/packages/79/c2/0e4690214db5e09d239a9f0272707a76f1152110f327680779542eb73313/fable_library-5.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fdd8008b9ce9c4d6ef15a04bc0c7da12b94031c947e918d473c8a18a9cf92efd", size = 1675518, upload-time = "2026-09-25T22:20:40.969Z" }, - { url = "https://files.pythonhosted.org/packages/75/61/56c5b06bdb3585284043c0439838aaa68f86033d8fa389e8da3b8a764169/fable_library-5.18.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb3efd6b22d36cd3c6872665bfcd9b8906dcc3e67a98bdf1bffcc18e95e55fd4", size = 1435549, upload-time = "2026-09-25T22:20:42.246Z" }, - { url = "https://files.pythonhosted.org/packages/0d/17/0e6d09faf9745013f926b6b3fc222956032f1acb95335e9b75a01b749f55/fable_library-5.18.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:685de1305b89f3967de238c32c8adaec268e49377dfdc7360c2ca31f0dc203cc", size = 1464775, upload-time = "2026-09-25T22:20:43.806Z" }, - { url = "https://files.pythonhosted.org/packages/b0/20/22c5162dbbfb501acbf809a1c671991beec5bcaf2225929940f2a08bba83/fable_library-5.18.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14c9ecdff1e925794885b52edf2a35e9dafff133ce70c0c525977d14a195e9e1", size = 1445051, upload-time = "2026-09-25T22:20:45.328Z" }, - { url = "https://files.pythonhosted.org/packages/7d/4a/d7772fa84fdcfd399ff4a2dfbc104563dde21fba32efb08403fade6a0aee/fable_library-5.18.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3952b230fbc04906118e654490e69d47d3b25841f82ac385487979f46e8612df", size = 1468650, upload-time = "2026-09-25T22:20:46.712Z" }, + { url = "https://files.pythonhosted.org/packages/26/aa/0c925fa7fab4489a01367948d0c543327335443943f98fa114f48a9c4819/fable_library-5.19.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3680c610b69c510c5f54db3fc7b3521e985bc2eb64ba276a857a750a361113e4", size = 1406666, upload-time = "2026-10-03T15:02:02.452Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a4/8159599ebc3ac74eb5b3dc4847108c527590e76b9c00edac09b6a7793b10/fable_library-5.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4fda71f581af2d0cc8bd3ac97ab4b67ef06a71f9290343be69fe46209024f3b9", size = 1313863, upload-time = "2026-10-03T15:02:04.052Z" }, + { url = "https://files.pythonhosted.org/packages/96/74/a45791fe4bd360351d0665c2ab1db86d2f85b8ab1402f2ecd9731a93c148/fable_library-5.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78820d378900b19aede76b7be2d4774fd0fb1f7fb66f0dbba406b931728c332a", size = 1351973, upload-time = "2026-10-03T15:02:05.434Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/231dbf9615509926acf146d59388c986d4c198e28f2d1fb31fa7431742a4/fable_library-5.19.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6eed22f2e89c55aab8044fc1d38e7f9f3a0f9a0e6c2fdee7c314210a00773dd", size = 1335299, upload-time = "2026-10-03T15:02:06.759Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ef/455dd0a3605b87fd729d4928d1f87e7b43c10f1b42bbd56248544572e452/fable_library-5.19.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:df70e223d0af5ab0bf53b83e0758f2053f45d828fe169ff42d441c53ff687fd2", size = 1476070, upload-time = "2026-10-03T15:02:08.265Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9e/447c48d1a4fded29d7e9b8756ab77a21946df02409b5ca1327bb5c87184b/fable_library-5.19.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5429337e9444c390be023201e82fe39d3b3f2d5512196aacbca05d2be80e5d7", size = 1501223, upload-time = "2026-10-03T15:02:09.735Z" }, + { url = "https://files.pythonhosted.org/packages/18/63/30351d76b109c7380de2002f55c93304393adefba0e8f72d6ee5771f2124/fable_library-5.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c994adb79cce4a43407ab57dc025f90dab94ee654e1bc364b4f69309d95ae288", size = 1424808, upload-time = "2026-10-03T15:02:11.289Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/1fd3b8119fb0ad596cb97cba873078c429cbd6c00901c01cb6c69be4e2af/fable_library-5.19.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:da8541df88f456edfc7b394077f50a57c3a455ab22a2f0144a2d0897031bf98a", size = 1453602, upload-time = "2026-10-03T15:02:12.742Z" }, + { url = "https://files.pythonhosted.org/packages/39/f8/5c3571c246f4852cf534616f4d56d29fa561eb34ffeec12e581beb254ae5/fable_library-5.19.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e2094ded91e57489f5cee99736350b73d250d35124aee6389f1109ba9c8f3337", size = 1531695, upload-time = "2026-10-03T15:02:14.41Z" }, + { url = "https://files.pythonhosted.org/packages/27/dc/eeeb4687401626ddf83b866f0fd265418b23c3218ee22c277789bcd7fe81/fable_library-5.19.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7e057956edb38c90b014a2b04becd9d07483ab23660a5d87482a7b61884c05c4", size = 1612045, upload-time = "2026-10-03T15:02:16.07Z" }, + { url = "https://files.pythonhosted.org/packages/89/26/00765fa18b173a3ea81ad47b5d1681f4ce8f6b71f1a774f1300b6b7abb81/fable_library-5.19.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2e18d2fcea6e3201c73450023d54893953d368f8692785a9f8b24143f04c4e76", size = 1636584, upload-time = "2026-10-03T15:02:17.385Z" }, + { url = "https://files.pythonhosted.org/packages/5a/66/e88166cfb62401e2c01068d43bdfc42b0dc508e8afd285eff4a21607b70a/fable_library-5.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a5ad12f9cf2ec841265815958cbcb3e78c7cba9557425e7d33d18d4effb9c6d0", size = 1651566, upload-time = "2026-10-03T15:02:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/f0/35/b1aedfa1f845a1aae3a9a5e64a953fd601201c30e83951406dfb4da77162/fable_library-5.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:591edefca445f350a4b1aeec272541eb522c4b27fef2cd41abf5b67b87575f2e", size = 1419953, upload-time = "2026-10-03T15:02:20.277Z" }, + { url = "https://files.pythonhosted.org/packages/08/59/8a74500a8ea29db647337d9e34ad373b0644f1a5a95ba11a649967072523/fable_library-5.19.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:18a324500466f94e316f1930d4d51b70d52bca0c554b7f14647fa983026b31f9", size = 1407360, upload-time = "2026-10-03T15:02:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/04/06/9a9f3874742c81b5732e1367792be17503a574a5d092fe9c4f3cedb2d1c0/fable_library-5.19.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f090eac1c12515275ca6fc613136470c23ef0acff52d0b2ef7f0000f99aa9035", size = 1314027, upload-time = "2026-10-03T15:02:22.943Z" }, + { url = "https://files.pythonhosted.org/packages/b7/34/de37d559522dd385513311008866e16a8b76e64db39fabd0331faa51c3b0/fable_library-5.19.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e89fef4a29d3bdd2a76c7ec044828b09cde29571a891acd348d4493d2bf80b77", size = 1352337, upload-time = "2026-10-03T15:02:24.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/c9/a5f8cc6bff20acdeaf839e8061902e54c8b81194c96c225823c159f97f12/fable_library-5.19.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9928e7ad7ec6c72e332ca59803a4e46dba6b32cad88484bbbcd65b1bb39ef416", size = 1335203, upload-time = "2026-10-03T15:02:25.783Z" }, + { url = "https://files.pythonhosted.org/packages/22/00/9026da0c5703195f6f0ef33a334642b266eda6994e6bea859799b920da6d/fable_library-5.19.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6acaa8f1362049c7294c8f4542dddb17609b935f61afebb25166bc1d5dcb7539", size = 1476410, upload-time = "2026-10-03T15:02:27.176Z" }, + { url = "https://files.pythonhosted.org/packages/57/96/5bb68cf6d23d0d1d6197d7c2fc86950b862e4de9d4d1ea7a76376a3431e9/fable_library-5.19.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aea95b79ce4db23211dd4fc574a1ac113f7de586b16b36c0673c897a2b3b3dfc", size = 1500926, upload-time = "2026-10-03T15:02:28.587Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2a/5af986781b0eababc6c695485f88d9cbbb453b13bc09a8a58e7be8ae040d/fable_library-5.19.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89b79345386a85fd1238eba540135420a080e21d0e5f4983e5dd132b736124e8", size = 1425743, upload-time = "2026-10-03T15:02:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/b5/5e/48a25037c2d38b95dd8c6fcecdecb7de30f40849df4c08b27a8f49e463bb/fable_library-5.19.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8496d94e81a3e5a1646adc44804cbd569c7007efd78dcb247305c4ea20b640bf", size = 1453797, upload-time = "2026-10-03T15:02:31.352Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5b/50220af8670cfc0fa8389665a928d75a056b06087e774bb04e49ce43b22b/fable_library-5.19.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6afbe74f1ac016243a36eeae281054108ee2a82e4cb85e78eb9f43c430b7f811", size = 1532049, upload-time = "2026-10-03T15:02:33.054Z" }, + { url = "https://files.pythonhosted.org/packages/af/56/603affe3e85f5a547f0ecd4f075d3027559f747f2e607b7b203eb3272403/fable_library-5.19.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:33906878a15144ff322657ad5f0871d5f193746daaf00d35247dfcce584e2387", size = 1611635, upload-time = "2026-10-03T15:02:34.775Z" }, + { url = "https://files.pythonhosted.org/packages/3a/87/95e247de1f6b99980bd66b069f6a1a38875112630d7f489f160cc778431e/fable_library-5.19.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4bc6dff68299405d37ded2642eba6fff7406edec0c05b1e2bc18a93a1e9621d5", size = 1638739, upload-time = "2026-10-03T15:02:36.307Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b8/884ff6f1ed252092d8d09bda2068c4a9f8e29ad9d250d067f9f6f45216b8/fable_library-5.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1994b0785bbade97c010c5b191d83c49b88dab158c2acf392895fd61fe3d55eb", size = 1652335, upload-time = "2026-10-03T15:02:37.795Z" }, + { url = "https://files.pythonhosted.org/packages/d1/27/754db80236945849554bfe198e0bd49e600299d33b43309acbd645fb9037/fable_library-5.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:999fa20470da58468effa59293f54b26139cf48713171b293752b6c08c7fee74", size = 1422823, upload-time = "2026-10-03T15:02:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/25/75/5083a52f76af8020d938cbbcf1c0b10269cae29bde5f0e788b2cbd8695cb/fable_library-5.19.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9273b40f1d58d89c5ed580a07d379d5bc650b439bb56cafa93f0a6049962eded", size = 1408266, upload-time = "2026-10-03T15:02:41.113Z" }, + { url = "https://files.pythonhosted.org/packages/ac/82/a67641ed6e3660119c82d55c8b1f26d7d0ff629b8d920e19dae041bf99eb/fable_library-5.19.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b41f94e462a193e12a67fea5b4ffd68d4b50958d20a75355fe0dcb47adb8113d", size = 1314170, upload-time = "2026-10-03T15:02:42.829Z" }, + { url = "https://files.pythonhosted.org/packages/27/71/7e929dfd163613d60ee493071cec64ced2e9ddcb4dd4935c6258f6b4cdf4/fable_library-5.19.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11aef7971316849b2de7706d0fea32399f14ed4459e3f72721a4aa3571d97392", size = 1352434, upload-time = "2026-10-03T15:02:44.899Z" }, + { url = "https://files.pythonhosted.org/packages/0d/57/01236b0c719d5a2759c4a30424ddd365be8404b5ded3efc96dd74723d8b1/fable_library-5.19.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49681bd14524a7573790b7d34436db3f540350972e3daa62c7b519904ec9e545", size = 1335313, upload-time = "2026-10-03T15:02:47.006Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ea/974130475b57a72f0eb49d9133e4236767a43535c2559ac5a358a18f74e5/fable_library-5.19.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a73e3edf15d805410faea964e666b06e673716573adc9de4f01efffbf735ce0", size = 1476810, upload-time = "2026-10-03T15:02:48.835Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/d754d7bb5d7b4637646d8579b48d6daf687989aaaf7591bc998a37e79c7f/fable_library-5.19.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5f4dbbdb6aa9757468083f34db530092e710a14f756dde3585ad4d3e9702de89", size = 1501445, upload-time = "2026-10-03T15:02:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/8b/28/455adc0e609c7e9e21559203f7886613a0a379a9b408d6428f7015d9ac74/fable_library-5.19.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:822dbe47fa63b1c700ddc521b0a9dae73d0232a88a5b2ff82cbc4f2e9cca70e8", size = 1426663, upload-time = "2026-10-03T15:02:52.44Z" }, + { url = "https://files.pythonhosted.org/packages/fd/94/2ee9f2bb709204ec97c2d426900cdd33ba91694221f389ca212853e70f61/fable_library-5.19.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1295dda6ebb5cf17a3bd9be74f383ab21f5e0d8f89caf1d4617dfff2f2d33adf", size = 1454555, upload-time = "2026-10-03T15:02:53.921Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cb/1f129c5c368f1ca87702892839f2cd642acc58b1f675b30a05e9056bb0a5/fable_library-5.19.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:985c1293ea3867897e185de350a483050e0ab985e24467c4f93a63e84324c3a3", size = 1532869, upload-time = "2026-10-03T15:02:55.547Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/9d32e07db06f6396611f412b3032c93493da54b4b760fc63f20db07611e3/fable_library-5.19.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1d96128c4e8d8dc99a8d5483a96231b1a356037b6d906e4b4fc9ed662719e803", size = 1612101, upload-time = "2026-10-03T15:02:57.154Z" }, + { url = "https://files.pythonhosted.org/packages/9e/35/78f812e0e7b0810c0d079d50174915a612290ceb830fdedccca9148ccb12/fable_library-5.19.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:23c1d77e5d76c5ebf62d9bcccff57f16726ef0f0715b28e97ea15c3632d19cdf", size = 1639222, upload-time = "2026-10-03T15:02:58.689Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f4/deda8192e2df1f01e85274df60f7454449d848944e8b32ff01f647590207/fable_library-5.19.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dd21f2a8e745128566f570d7049d1307a8bb58229970ba985e66550b96863a9b", size = 1653478, upload-time = "2026-10-03T15:03:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/eb/4d/9d69567bbee490276def5b4c120f9d7a17978a685466ae9c5db896c96f49/fable_library-5.19.0-cp314-cp314-win32.whl", hash = "sha256:906a5231583b544eb24bbe1dd9b471e39790d7790aecf00b70258c6c5eba6e2e", size = 1272048, upload-time = "2026-10-03T15:03:02.033Z" }, + { url = "https://files.pythonhosted.org/packages/0b/10/12107d8d4bb0340ec1c777813f8c1223a46217649dc634995c6a83b87c5f/fable_library-5.19.0-cp314-cp314-win_amd64.whl", hash = "sha256:8e34f1227afb41af900486f5ca82bcd11bdb0000d70d4189037028e96473c0ae", size = 1421818, upload-time = "2026-10-03T15:03:04.653Z" }, + { url = "https://files.pythonhosted.org/packages/d7/08/252993cf1c4010fe345301c7343c4a0bba01db5a392120f12d7ba611d5ec/fable_library-5.19.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f491e17527828c6da0b1bb51dc174eafe0ead69e36d3f90ec424189bd27cab5", size = 1356921, upload-time = "2026-10-03T15:03:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f0/d46f7e42d2fdc9fd3eafbb4375d585519ede7fa640cc98714e795c68330a/fable_library-5.19.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5494153051c0054ccdf3bd610e7d90a1363ffead19599f1435529e6c9c823536", size = 1335835, upload-time = "2026-10-03T15:03:08.089Z" }, + { url = "https://files.pythonhosted.org/packages/0e/04/97e318f2ddb9cb24560e89f99ed80788ded6758bdc966db8f1ff540bc77c/fable_library-5.19.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7494b5eef1927c084ede2a31e45be95ac1154dc725dd60219bb8e2591bbe9f33", size = 1483829, upload-time = "2026-10-03T15:03:09.715Z" }, + { url = "https://files.pythonhosted.org/packages/d0/61/b3d6faa467d82f831bb5c38c58e7a0253beead24fea67070ceb51b9ed948/fable_library-5.19.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4dcc086acebf53da66b35dc3132b7e77cc21ad11f5a17015230d092198afc9a", size = 1507820, upload-time = "2026-10-03T15:03:11.463Z" }, + { url = "https://files.pythonhosted.org/packages/bf/7b/613b170445fdd35f66ab9ede78f48e035a2223aa05cc6b4d39024b5cf699/fable_library-5.19.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:650e5705db835dbb6df5ddb717d4c14c65c2ded88b00ad099047d3aaa8fee419", size = 1432473, upload-time = "2026-10-03T15:03:13.068Z" }, + { url = "https://files.pythonhosted.org/packages/19/41/6ca7a3d2a49aec591819a4af982c397bb20c576cfc8ec5b03977d3998ab4/fable_library-5.19.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:591e424fc0d0ca951a9c7eeebbba09de6984a9e44589fd659e48c22d6356233b", size = 1458175, upload-time = "2026-10-03T15:03:14.563Z" }, + { url = "https://files.pythonhosted.org/packages/78/4f/f91e5b4b57126621436e3653f18ee775762325aa3677a91d8e7ded7791fe/fable_library-5.19.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6fec3a3521172486546cbe553307a55885e367d84212a94b56a34ebf924522d", size = 1535753, upload-time = "2026-10-03T15:03:16.185Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9a/b376089a81acac3fe3e32478663e3e3c65b88e600f52d124c69d8377cde0/fable_library-5.19.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0da0663936c3436bfc80bf90bb08a1ee74d4fe0fcb18a335a9c673c68e85a1ad", size = 1612903, upload-time = "2026-10-03T15:03:17.832Z" }, + { url = "https://files.pythonhosted.org/packages/23/cc/5bc768b411517a93be38d3fbba097dfc2fb5b6f3630e0499a72f0e8dd501/fable_library-5.19.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0a831b3506eb7c6860bfc9dc70a2b4c74810704a36b0335cccfbc2d3958dc484", size = 1647391, upload-time = "2026-10-03T15:03:19.563Z" }, + { url = "https://files.pythonhosted.org/packages/44/43/99ce0cdd25856e510e9bc7d6eedd5035051bbc8503c98460f935bc7ad407/fable_library-5.19.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5d4deeb4d308879f0cdfa4cd597d13cd3d8041cd1c9264e4c10888cdbd3e123", size = 1660953, upload-time = "2026-10-03T15:03:21.314Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6c/8f5c973a996315de7ee3d97431aafbc78afdb82c4630dd41fb6e43019d99/fable_library-5.19.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d69d1e94e84d9904651710a96050fe2cbe2d6efce71769c2356d7565ac8985a", size = 1426593, upload-time = "2026-10-03T15:03:22.899Z" }, + { url = "https://files.pythonhosted.org/packages/db/4d/b059cc8439e8ab609757e60fbd2e57f83f3020b0b02b664f55baac2e795d/fable_library-5.19.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:832c8ff9ffc6aef45985a057a21bb4ea0c7c262f7317f69a8f22bd20f29a00b6", size = 1454695, upload-time = "2026-10-03T15:03:24.52Z" }, + { url = "https://files.pythonhosted.org/packages/57/29/55fdfc6453843f3ff8ca76de712cb853fb17b87d110bbc2bf360d57a7cbe/fable_library-5.19.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:364f7413b4ba2638a35eb5ae038fe89f7216eb07d864a9afe3f9721ed2ea778a", size = 1432570, upload-time = "2026-10-03T15:03:26.168Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9d/456a5f27a6ad7193b85285832d2ea63ed7af058dd465482ed4f10f40596f/fable_library-5.19.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ed6ddce1e0f5f3317cd533ec41c2732a374aa0331496d5669f574051abcf1260", size = 1458357, upload-time = "2026-10-03T15:03:27.888Z" }, ] From 655d29402293520fdde266a10cc9c706c531a1b3 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Sat, 3 Oct 2026 20:53:55 +0200 Subject: [PATCH 2/7] fix: own actor shutdown and observe worker exit --- README.md | 39 +++ src/Fable.Actor/Actor.fs | 251 ++++++++++++------ src/Fable.Actor/Fable.Actor.fsproj | 1 + src/Fable.Actor/Lifecycle.fs | 264 ++++++++++++++++++ src/Fable.Actor/Platform.fs | 79 +++++- src/Fable.Actor/Types.fs | 13 + test/BuilderTests.fs | 6 +- test/Fable.Actor.Tests.fsproj | 1 + test/LifecycleTests.fs | 412 +++++++++++++++++++++++++++++ test/Main.fs | 7 +- 10 files changed, 990 insertions(+), 83 deletions(-) create mode 100644 src/Fable.Actor/Lifecycle.fs create mode 100644 test/LifecycleTests.fs diff --git a/README.md b/README.md index 8a6bae6..aeb1a3f 100644 --- a/README.md +++ b/README.md @@ -216,3 +216,42 @@ MIT - [FSharp.Control.AsyncRx](https://github.com/dbrattli/AsyncRx) — Async Reactive Extensions for F# - [Fable](https://github.com/fable-compiler/Fable) — F# to JS/Python/BEAM compiler - [Fable.Beam](https://github.com/fable-compiler/Fable.Beam) — F# bindings (FFI) for BEAM/Erlang + +## Owned shutdown + +`Actor.kill` requests shutdown. `Actor.stop cleanupDeadline actor` (or +`stopAsync`) requests it and returns `StopResult.Completed exit` or `TimedOut`. +The deadline must be a positive number of milliseconds and is validated before +shutdown begins. Repeated and concurrent requests share the same lifecycle. + +On .NET, JavaScript, and Python, spawn, spawnLinked, and spawnWithToken run with +an owned cancellation token. Idle receives wake, cooperative Async work cancels, +and actor-expression `finally`/`use` cleanup runs once. Completion observes the +workflow and its owned linked descendants exiting. Cleanup failures are returned +as `ActorExit.Failed`; a timeout leaves unfinished work owned and does not force +it to exit. Synchronous user code can block the target scheduler and delay both +cancellation and deadline observation. Finalizers inside separately supplied +Async computations retain that runtime's semantics. + +On BEAM, stop monitors native process death after kill. It does not promise +finalizers, graceful cleanup, or completed descendant shutdown at the instant +that the parent dies. An external token requests native kill. A process watcher +releases its registration after death. Use an application shutdown message for +graceful cleanup before native stop, or OTP supervision for shutdown escalation. + +`spawnLinked` now establishes parent ownership on every target: parent shutdown, +normal completion, and failure all shut down the child. Native BEAM links also +retain their usual symmetric abnormal-exit behavior: an abnormal or killed child +terminates a parent that is not trapping exits; a trapping parent receives +`ChildExited`. Normal child exits are suppressed by the actor receive protocol. +Emulated links preserve their supervision default: only abnormal child exit +emits `ChildExited`, and it does not automatically terminate the parent. Normal +and cooperative cancelled child exits are silent. The supplied BEAM parent is +now the link endpoint even when spawnLinked is invoked by another process. + +This ownership is stronger than a raw BEAM link, which alone does not terminate +children on normal parent exit. Keep replacement children returned by +`handleChildExit` (the rc.12 API) so later shutdown addresses the current +generation. Actor handles on emulated targets are now constructed by spawn APIs +rather than public record literals; Mb and Cts remain available for existing +interop, but lifecycle operations must go through Actor APIs. diff --git a/src/Fable.Actor/Actor.fs b/src/Fable.Actor/Actor.fs index f52681a..c4c5aef 100644 --- a/src/Fable.Actor/Actor.fs +++ b/src/Fable.Actor/Actor.fs @@ -119,21 +119,32 @@ type ActorBuilder() = type ActorOp<'T> = Async<'T> -type Actor<'Msg> = { - Mb: MailboxProcessor<'Msg> - Cts: System.Threading.CancellationTokenSource -} with - - member this.Pid: obj = box this.Mb - - member this.Receive() : Async<'Msg> = this.Mb.Receive() - - /// Queue a message unless this actor has already been killed. +type Actor<'Msg> internal (mb: MailboxProcessor<'Msg>, cts: System.Threading.CancellationTokenSource, lifetime: ActorLifetime) = + member _.Mb = mb + member _.Cts = cts + member internal _.Lifetime = lifetime + member _.Pid: obj = box mb + + member _.Receive() : Async<'Msg> = + Async.FromContinuations(fun (ok, error, cancelled) -> + Async.StartWithContinuations( + mb.Receive(), + (fun msg -> + if cts.IsCancellationRequested then + cancelled (System.OperationCanceledException()) + else + ok msg), + error, + cancelled, + cts.Token + )) + + /// Queue a message while the actor accepts work. /// - /// invariant: posting after cancellation is a no-op rather than an exception - member this.Post(msg: 'Msg) = - if not this.Cts.IsCancellationRequested then - this.Mb.Post(msg) + /// invariant: posting after cancellation or workflow exit is a no-op + member _.Post(msg: 'Msg) = + if not lifetime.IsStopping then + mb.Post msg type ActorBuilder() = member _.Bind(op: Async<'T>, f: 'T -> Async<'U>) : Async<'U> = async.Bind(op, f) @@ -147,9 +158,37 @@ type ActorBuilder() = member _.TryWith(body: Async<'T>, handler: exn -> Async<'T>) : Async<'T> = async.TryWith(body, handler) - member _.TryFinally(body: Async<'T>, compensation: unit -> unit) : Async<'T> = async.TryFinally(body, compensation) + /// Run cleanup once and preserve its failure, including during cancellation. + /// + /// decision: routes cleanup exceptions explicitly because standard Async finalizers can lose failures during cancellation + member _.TryFinally(body: Async<'T>, compensation: unit -> unit) : Async<'T> = + async { + let! token = Async.CancellationToken + + return! + Async.FromContinuations(fun (ok, error, cancelled) -> + let finish next value = + let failure = + try + compensation () + None + with ex -> + Some ex + + match failure with + | Some ex -> error ex + | None -> next value + + Async.StartWithContinuations(body, finish ok, finish error, finish cancelled, token)) + } - member _.Using(resource: 'a :> System.IDisposable, body: 'a -> Async<'T>) : Async<'T> = async.Using(resource, body) + member this.Using(resource: 'a :> System.IDisposable, body: 'a -> Async<'T>) : Async<'T> = + this.TryFinally( + body resource, + fun () -> + if not (isNull (box resource)) then + resource.Dispose() + ) member _.While(guard: unit -> bool, body: Async) : Async = async.While(guard, body) member _.For(items: seq<'T>, body: 'T -> Async) : Async = async.For(items, body) @@ -197,12 +236,30 @@ module Actor = { Pid = rawPid } + /// Spawn with external cancellation. BEAM cancellation requests native kill; it cannot unwind arbitrary ActorOp work. + /// + /// decision: releases the external registration in a watcher because kill cannot execute actor finalizers + let spawnWithToken (token: System.Threading.CancellationToken) (body: Actor<'Msg> -> ActorOp) : Actor<'Msg> = + let child = spawn body + let registration = token.Register(fun () -> killProcess child.Pid) + + if token.IsCancellationRequested then + killProcess child.Pid + + Erlang.spawn (fun () -> + let monitor = Erlang.monitor child.Pid + waitProcessDeath monitor child.Pid + registration.Dispose()) + |> ignore + + child + /// Spawn a linked child actor (parent gets EXIT signal on crash). /// - /// assumption: the caller is the supplied parent process — BEAM links the child to the calling process - let spawnLinked (_parent: Actor<'ParentMsg>) (body: Actor<'Msg> -> ActorOp) : Actor<'Msg> = + /// decision: links to the supplied parent and monitors normal parent death so ownership does not depend on the caller + let spawnLinked (parent: Actor<'ParentMsg>) (body: Actor<'Msg> -> ActorOp) : Actor<'Msg> = let rawPid = - Erlang.spawnLink (fun () -> + spawnOwnedProcess parent.Pid (fun () -> let me: Actor<'Msg> = { Pid = Erlang.self () } (body me).Run(fun () -> ())) @@ -214,6 +271,30 @@ module Actor = /// Kill an actor and its linked children. let kill (actor: Actor<'Msg>) : unit = killProcess actor.Pid + /// Kill and await native process death within a positive cleanup deadline. + /// Native kill does not execute actor finalizers. + let stop (cleanupDeadline: int) (target: Actor<'Msg>) : ActorOp = + if cleanupDeadline <= 0 then + invalidArg "cleanupDeadline" "Cleanup deadline must be positive" + + { + Run = + fun cont -> + cont ( + if stopProcess target.Pid cleanupDeadline then + StopResult.Completed ActorExit.Cancelled + else + StopResult.TimedOut + ) + } + + let stopAsync cleanupDeadline target : Async = + async { + let mutable result = StopResult.TimedOut + (stop cleanupDeadline target).Run(fun value -> result <- value) + return result + } + /// Enable supervision — child EXIT signals become messages. /// /// assumption: called inside the supervising actor because trap_exit affects only the current BEAM process @@ -279,76 +360,89 @@ module Actor = #else - /// Spawn an actor with an optional external cancellation token. - /// When the external token is cancelled, the actor's CTS is also cancelled. - /// - /// decision: links external cancellation to a private CTS instead of putting the token in the actor's Async context - /// invariant: cancelling the external token prevents subsequent posts through the returned actor handle - let spawnWithToken (cancellationToken: System.Threading.CancellationToken) (body: Actor<'Msg> -> Async) : Actor<'Msg> = + // decision: runs the body explicitly to observe all three Async exit continuations on every emulated target + let private spawnOwned token attach (body: Actor<'Msg> -> Async) = let cts = new System.Threading.CancellationTokenSource() - - cancellationToken.Register(fun () -> cts.Cancel()) - |> ignore - - let mutable inbox: Actor<'Msg> option = None + let lifetime = ActorLifetime cts let mb = - MailboxProcessor.Start(fun mb -> - let actor = { Mb = mb; Cts = cts } - inbox <- Some actor - body actor) + new MailboxProcessor<'Msg>((fun _ -> async { return () }), cancellationToken = cts.Token) + + let inbox = Actor(mb, cts, lifetime) + // decision: wakes the portable mailbox on cancellation because JS/Python Receive does not register a wakeup + // invariant: the synthetic wakeup never reaches user code because the workflow token is already cancelled + lifetime.AddResource(Lifetime.cancellation cts.Token (fun () -> mb.Post Unchecked.defaultof<'Msg>)) +#if !FABLE_COMPILER + lifetime.AddResource(mb :> System.IDisposable) +#endif + lifetime.AddResource(Lifetime.cancellation token lifetime.RequestStop) + attach inbox + + Async.StartWithContinuations( + async { do! body inbox }, + (fun () -> lifetime.Complete ActorExit.Normal), + (fun ex -> lifetime.Complete(ActorExit.Failed ex)), + (fun _ -> lifetime.Complete ActorExit.Cancelled), + cts.Token + ) - match inbox with - | Some a -> a - | None -> { Mb = mb; Cts = cts } + inbox - /// Spawn an actor. Body receives inbox (self-reference) for Receive/Post. + /// Spawn cooperative work with an external lifetime token. The registration is released on exit. /// - /// decision: owns a private CTS for explicit kill semantics without polluting operations in the actor body - let spawn (body: Actor<'Msg> -> Async) : Actor<'Msg> = - let cts = new System.Threading.CancellationTokenSource() - let mutable inbox: Actor<'Msg> option = None - - let mb = - MailboxProcessor.Start(fun mb -> - let actor = { Mb = mb; Cts = cts } - inbox <- Some actor - body actor) + /// decision: passes the owned token to the workflow so cancellation reaches receives and cooperative Async work + let spawnWithToken (cancellationToken: System.Threading.CancellationToken) (body: Actor<'Msg> -> Async) : Actor<'Msg> = + spawnOwned cancellationToken ignore body - match inbox with - | Some a -> a - | None -> { Mb = mb; Cts = cts } + /// Spawn an actor with an owned cooperative lifetime. + let spawn (body: Actor<'Msg> -> Async) : Actor<'Msg> = + spawnWithToken System.Threading.CancellationToken.None body - /// Spawn a linked child actor. On crash, delivers ChildExited to parent. + /// Own a child until exit. Parent exit cancels the child; abnormal child exit notifies the parent. /// - /// decision: emulates BEAM links by converting an unhandled body exception into a message to the parent - /// invariant: normal body completion does not emit ChildExited on non-BEAM targets + /// invariant: normal and cancelled child exits do not emit ChildExited on emulated targets + /// decision: abnormal child exits notify rather than cancel the parent so existing emulated supervision remains compatible let spawnLinked (parent: Actor<'ParentMsg>) (body: Actor<'Msg> -> Async) : Actor<'Msg> = - let cts = new System.Threading.CancellationTokenSource() - let mutable inbox: Actor<'Msg> option = None - - let mb = - MailboxProcessor.Start(fun mb -> - let actor = { Mb = mb; Cts = cts } - inbox <- Some actor - - async { - try - do! body actor - with ex -> - parent.Post(unbox { Pid = box mb; Reason = box ex }) - }) - - match inbox with - | Some a -> a - | None -> { Mb = mb; Cts = cts } - - /// Kill an actor by cancelling its lifecycle and disposing its mailbox. + spawnOwned + System.Threading.CancellationToken.None + (fun child -> + parent.Lifetime.Own child.Lifetime + + child.Lifetime.Observe(fun exit -> + match exit with + | ActorExit.Failed ex -> parent.Post(unbox { Pid = child.Pid; Reason = box ex }) + | _ -> ()) + |> ignore) + body + + /// Request cooperative cancellation. Use stop to observe actual exit. /// /// invariant: after kill returns, Post ignores new messages through this handle - let kill (actor: Actor<'Msg>) : unit = - actor.Cts.Cancel() - (actor.Mb :> System.IDisposable).Dispose() + let kill (actor: Actor<'Msg>) : unit = actor.Lifetime.RequestStop() + + /// Request shutdown and observe workflow and owned descendant exit within a positive deadline. + let stop (cleanupDeadline: int) (target: Actor<'Msg>) : ActorOp = + if cleanupDeadline <= 0 then + invalidArg "cleanupDeadline" "Cleanup deadline must be positive" + + async { + let! token = Async.CancellationToken + + return! + Async.FromContinuations(fun (ok, _, cancelled) -> + let pending = + Lifetime.Pending(fun result -> + match result with + | Choice1Of2 value -> ok value + | Choice2Of2 ex -> cancelled ex) + + pending.Add(Lifetime.cancellation token (fun () -> pending.Settle(Choice2Of2(System.OperationCanceledException())))) + pending.Add(Lifetime.deadline cleanupDeadline (fun () -> pending.Settle(Choice1Of2 StopResult.TimedOut))) + pending.Add(target.Lifetime.Observe(fun exit -> pending.Settle(Choice1Of2(StopResult.Completed exit)))) + target.Lifetime.RequestStop()) + } + + let stopAsync cleanupDeadline target : Async = stop cleanupDeadline target /// Enable supervision (stub on non-BEAM). /// @@ -358,7 +452,8 @@ module Actor = /// Send a message and await a reply (inside actor { }). let call (target: Actor<'Msg * ReplyChannel<'Reply>>) (msg: 'Msg) : ActorOp<'Reply> = actor { - let! reply = target.Mb.PostAndAsyncReply(fun rc -> (msg, { Reply = fun r -> rc.Reply(r) })) + let! reply = + target.Mb.PostAndAsyncReply(fun rc -> (msg, { Reply = fun r -> rc.Reply(r) })) return reply } diff --git a/src/Fable.Actor/Fable.Actor.fsproj b/src/Fable.Actor/Fable.Actor.fsproj index 84ee80a..93bef7d 100644 --- a/src/Fable.Actor/Fable.Actor.fsproj +++ b/src/Fable.Actor/Fable.Actor.fsproj @@ -12,6 +12,7 @@ + diff --git a/src/Fable.Actor/Lifecycle.fs b/src/Fable.Actor/Lifecycle.fs new file mode 100644 index 0000000..afac063 --- /dev/null +++ b/src/Fable.Actor/Lifecycle.fs @@ -0,0 +1,264 @@ +namespace Fable.Actor + +#if !FABLE_COMPILER_BEAM + +open System +open System.Threading +open Fable.Actor.Types + +module internal Lifetime = +#if FABLE_COMPILER_PYTHON + let newGate = Platform.newGate + // decision: owns a stable RLock because fable-library 5.19 util.lock creates a fresh lock for each invocation + let synchronize gate action = + Platform.enterGate gate + + try + action () + finally + Platform.leaveGate gate +#else + let newGate () = obj () + let synchronize gate action = lock gate action +#endif + + let disposable f = { + new IDisposable with + member _.Dispose() = f () + } + + /// A single settlement with race-safe resource acquisition. + /// + /// invariant: settlement detaches every acquired resource before invoking the continuation + /// decision: disposes resources outside the gate because token disposal can wait for another callback + type Pending<'T>(continuation: 'T -> unit) = + let gate = newGate () + let mutable completed = false + let resources = ResizeArray() + let mutable callback = Some continuation + + member _.Add(resource: IDisposable) = + let dispose = + synchronize gate (fun () -> + if completed then + true + else + resources.Add resource + false) + + if dispose then + resource.Dispose() + + member _.IsCompleted = synchronize gate (fun () -> completed) + + member _.Settle(value: 'T) = + let work = + synchronize gate (fun () -> + if completed then + None + else + completed <- true + let acquired = resources.ToArray() + resources.Clear() + let notify = callback + callback <- None + Some(acquired, notify)) + + match work with + | None -> () + | Some(acquired, notify) -> + for resource in acquired do + resource.Dispose() + + notify |> Option.iter (fun f -> f value) + + let cancellation (token: CancellationToken) callback = +#if FABLE_COMPILER_PYTHON + let id = Platform.addCancellationListener token callback + + let registration = + disposable (fun () -> synchronize (Platform.cancellationGate token) (fun () -> Platform.removeCancellationListener token id)) +#else + let registration = token.Register(Action callback) :> IDisposable +#endif + // Fable JS/Python Register does not invoke an already-cancelled token's callback. + // invariant: registration followed by inspection covers cancellation before and during acquisition + if token.IsCancellationRequested then + callback () + + registration + + let deadline (ms: int) callback = +#if FABLE_COMPILER_PYTHON + let handle = Platform.startDeadline ms callback + disposable (fun () -> Platform.cancelDeadline handle) +#else + let cts = new CancellationTokenSource() + + Async.StartWithContinuations( + async { do! Async.Sleep ms }, + (fun value -> + ignore value + callback ()), + (fun _ -> ()), + (fun _ -> ()), + cts.Token + ) + + disposable (fun () -> + cts.Cancel() + cts.Dispose()) +#endif + +/// Internal state shared by handles for one emulated actor generation. +/// +/// decision: separates workflow completion from stop requests so cancellation never masquerades as worker exit +/// invariant: terminal publication follows workflow exit and all owned child exits +/// invariant: cancellation and lifecycle cleanup run at most once per generation +/// tradeoff: cooperative user work can outlive a cleanup deadline and remains owned until it exits +[] +type ActorLifetime internal (cts: CancellationTokenSource) = + let gate = Lifetime.newGate () + let mutable stopping = false + let mutable cancelling = false + let mutable completing = false + let mutable bodyExit: ActorExit option = None + let mutable result: ActorExit option = None + let mutable failure: exn option = None + let observers = System.Collections.Generic.Dictionary unit>() + let children = System.Collections.Generic.Dictionary() + let resources = ResizeArray() + let mutable nextId = 0 + + member _.IsStopping = Lifetime.synchronize gate (fun () -> stopping) + member _.ObserverCount = Lifetime.synchronize gate (fun () -> observers.Count) + member _.ChildCount = Lifetime.synchronize gate (fun () -> children.Count) + + member private _.RecordFailure(ex: exn) = + Lifetime.synchronize gate (fun () -> failure <- Some ex) + + member private _.Publish() = + let notification = + Lifetime.synchronize gate (fun () -> + match bodyExit, result with + | Some exit, None when + not cancelling + && not completing + && children.Count = 0 + -> + let outcome = + match failure with + | Some ex -> ActorExit.Failed ex + | None -> exit + + result <- Some outcome + let callbacks = observers.Values |> Seq.toArray + observers.Clear() + Some(outcome, callbacks) + | _ -> None) + + match notification with + | None -> () + | Some(outcome, callbacks) -> + cts.Dispose() + + for callback in callbacks do + callback outcome + + member this.AddResource(resource: IDisposable) = + let dispose = + Lifetime.synchronize gate (fun () -> + if bodyExit.IsSome then + true + else + resources.Add resource + false) + + if dispose then + resource.Dispose() + + member _.Observe(callback: ActorExit -> unit) = + let id, terminal = + Lifetime.synchronize gate (fun () -> + nextId <- nextId + 1 + + match result with + | Some exit -> nextId, Some exit + | None -> + observers.Add(nextId, callback) + nextId, None) + + terminal |> Option.iter callback + Lifetime.disposable (fun () -> Lifetime.synchronize gate (fun () -> observers.Remove id |> ignore)) + + member this.RequestStop() = + let owned = + Lifetime.synchronize gate (fun () -> + if stopping then + None + else + stopping <- true + cancelling <- true + Some(children.Values |> Seq.toArray)) + + match owned with + | None -> () + | Some owned -> + for child in owned do + child.RequestStop() + + try + cts.Cancel() + with ex -> + this.RecordFailure ex + + Lifetime.synchronize gate (fun () -> cancelling <- false) + this.Publish() + + member this.Complete(exit: ActorExit) = + let acquired = + Lifetime.synchronize gate (fun () -> + if bodyExit.IsSome then + None + else + bodyExit <- Some exit + completing <- true + let acquired = resources.ToArray() + resources.Clear() + Some acquired) + + match acquired with + | None -> () + | Some acquired -> + // decision: stops descendants even on normal parent completion to prevent orphan workers + this.RequestStop() + + for resource in acquired do + try + resource.Dispose() + with ex -> + this.RecordFailure ex + + Lifetime.synchronize gate (fun () -> completing <- false) + this.Publish() + + member this.Own(child: ActorLifetime) = + let id, stop = + Lifetime.synchronize gate (fun () -> + nextId <- nextId + 1 + children.Add(nextId, child) + nextId, stopping) + + child.Observe(fun exit -> + match exit with + | ActorExit.Failed ex when this.IsStopping -> this.RecordFailure ex + | _ -> () + + Lifetime.synchronize gate (fun () -> children.Remove id |> ignore) + this.Publish()) + |> ignore + + if stop then + child.RequestStop() + +#endif diff --git a/src/Fable.Actor/Platform.fs b/src/Fable.Actor/Platform.fs index a6b4b80..3840398 100644 --- a/src/Fable.Actor/Platform.fs +++ b/src/Fable.Actor/Platform.fs @@ -61,7 +61,7 @@ let sendReply (pid: Pid<'Caller>) (ref: Ref<'Reply>) (value: 'Reply) : unit = /// decision: drops stale replies and normal exits because neither is an application message /// invariant: abnormal EXIT signals reach the actor body as ChildExited values let rec receiveMsg (cont: obj -> unit) : unit = - match Erlang.receive () with + match Erlang.receive() with | ActorMsg payload -> cont payload | Reply _ -> receiveMsg cont // stray reply (ref already timed out); drop and keep waiting | Exit(_, reason) when Erlang.exactEquals reason atomNormal -> receiveMsg cont @@ -111,3 +111,80 @@ let timerCancel (timer: obj) : unit = Erlang.send (unbox> timer) Cancel #endif + +#if FABLE_COMPILER_BEAM + +/// Link to the supplied parent and watch its normal exit as well as abnormal exit. +/// +/// decision: uses an ownership watcher because native links alone leave children alive after normal parent exit +/// invariant: the watcher exits when either endpoint dies and kills the child on every parent death +let spawnOwnedProcess (parent: Pid<'Parent>) (body: unit -> unit) : Pid<'Msg> = + emitErlExpr + (parent, body) + """ + (fun() -> + Parent = $0, + Child = spawn(fun() -> link(Parent), $1(ok) end), + spawn(fun() -> + PM = monitor(process, Parent), CM = monitor(process, Child), + receive + {'DOWN', PM, process, Parent, _} -> exit(Child, kill); + {'DOWN', CM, process, Child, _} -> ok + end + end), + Child + end)() + """ + +let waitProcessDeath (monitor: Ref>) (pid: Pid<'Msg>) : unit = + emitErlExpr (monitor, pid) "receive {'DOWN', $0, process, $1, _} -> ok end" + +/// Kill and observe process death. Monitoring precedes the exit signal to close the death race. +/// +/// tradeoff: native kill skips user finalizers but provides process death even for noncooperative work +let stopProcess (pid: Pid<'Msg>) (deadline: int) : bool = + emitErlExpr + (pid, deadline) + """ + (fun() -> + M = monitor(process, $0), exit($0, kill), + receive {'DOWN', M, process, $0, _} -> true + after $1 -> demonitor(M, [flush]), false end + end)() + """ + +#endif + +#if FABLE_COMPILER_PYTHON + +open Fable.Core + +// decision: uses native registration handles because fable-library 5.19 Register omits its return value +// decision: replaces the listener dictionary on removal so disposal during Cancel does not mutate its live iterator +// invariant: removing an actor registration leaves other token listeners installed +[] +let addCancellationListener (token: System.Threading.CancellationToken) (callback: unit -> unit) : int = nativeOnly + +[] +let removeCancellationListener (token: System.Threading.CancellationToken) (id: int) : unit = nativeOnly + +[] +let newGate () : obj = nativeOnly + +[] +let enterGate (gate: obj) : unit = nativeOnly + +[] +let leaveGate (gate: obj) : unit = nativeOnly + +[] +let cancellationGate (token: System.Threading.CancellationToken) : obj = nativeOnly + +// decision: retains the asyncio timer handle so settlement cancels the timer rather than only suppressing its callback +[] +let startDeadline (ms: int) (callback: unit -> unit) : obj = nativeOnly + +[] +let cancelDeadline (handle: obj) : unit = nativeOnly + +#endif diff --git a/src/Fable.Actor/Types.fs b/src/Fable.Actor/Types.fs index 223438c..da2328c 100644 --- a/src/Fable.Actor/Types.fs +++ b/src/Fable.Actor/Types.fs @@ -30,6 +30,19 @@ type Next<'State> = /// decision: keeps pid and reason opaque because their runtime representations differ across targets type ChildExited = { Pid: obj; Reason: obj } +/// Observed terminal state of an actor workflow or native process. +[] +type ActorExit = + | Normal + | Cancelled + | Failed of exn + +/// A stop deadline bounds observation, not arbitrary user work. +[] +type StopResult = + | Completed of ActorExit + | TimedOut + exception ProcessExitException of string /// What the supervisor should do when a child crashes. diff --git a/test/BuilderTests.fs b/test/BuilderTests.fs index 3af59ff..2d31559 100644 --- a/test/BuilderTests.fs +++ b/test/BuilderTests.fs @@ -62,10 +62,10 @@ let private controlFlowTests = do! actor { - use _r = - { new System.IDisposable with + use _r = { + new System.IDisposable with member _.Dispose() = disposed <- true - } + } do! sleep 1 } diff --git a/test/Fable.Actor.Tests.fsproj b/test/Fable.Actor.Tests.fsproj index 6eca6cf..379b2ce 100644 --- a/test/Fable.Actor.Tests.fsproj +++ b/test/Fable.Actor.Tests.fsproj @@ -11,6 +11,7 @@ + diff --git a/test/LifecycleTests.fs b/test/LifecycleTests.fs new file mode 100644 index 0000000..c9e1fa7 --- /dev/null +++ b/test/LifecycleTests.fs @@ -0,0 +1,412 @@ +module Fable.Actor.Tests.LifecycleTests + +open System.Threading +open Scriptorium.Quill +open Scriptorium.Nib.Assertion +open type Scriptorium.Quill.Test +open Fable.Actor +open Fable.Actor.Types + +// decision: observations cross process boundaries so BEAM tests never assert a copied mutable capture +// invariant: startup and cleanup assertions wait for an observed event rather than a scheduling delay +type BarrierMsg = + | Signal + | Await of int + | Read + +let barrier () = + Actor.start (0, []) (fun (count, waiters) (msg, rc) -> + match msg with + | Signal -> + let count = count + 1 + + let ready, pending = + waiters + |> List.partition (fun (n, _) -> n <= count) + + for _, reply in ready do + reply.Reply count + + Continue(count, pending) + | Await n when n > count -> Continue(count, (n, rc) :: waiters) + | _ -> + rc.Reply count + Continue(count, waiters)) + +let await n events = + actor { let! _ = Actor.callWithTimeout 2000 events (Await n) in return () } + +let completed result = + match result with + | StopResult.Completed _ -> true + | _ -> false + +let idle events cleanup : Actor = + Actor.spawn (fun inbox -> + actor { + try + Actor.cast events Signal + let! _ = inbox.Receive() + return () + finally + Actor.cast cleanup Signal + }) + +let tests = + testList ( + "Lifecycle", + [ + testAsync ( + "idle stop observes exit and repeated stop is safe", + fun _ -> + toAsync ( + actor { + let started, cleaned = barrier (), barrier () + let worker = idle started cleaned + do! await 1 started + let! stopped = Actor.stop 2000 worker + assertThat (completed stopped) isTrue +#if FABLE_COMPILER_BEAM + assertThat (Fable.Beam.Erlang.isProcessAlive worker.Pid) isFalse +#else + do! await 1 cleaned + let! count = Actor.call cleaned Read + assertThat count (isEqualTo 1) +#endif + let! again = Actor.stop 2000 worker + assertThat (completed again) isTrue + let! _ = Actor.stop 2000 started + let! _ = Actor.stop 2000 cleaned + return () + } + ) + ) + testAsync ( + "busy cooperative work exits on stop", + fun _ -> + toAsync ( + actor { + let started, cleaned = barrier (), barrier () + + let worker: Actor = + Actor.spawn (fun _ -> + actor { + try + Actor.cast started Signal + do! sleep 60000 + finally + Actor.cast cleaned Signal + }) + + do! await 1 started + let! stopped = Actor.stop 2000 worker + assertThat (completed stopped) isTrue +#if FABLE_COMPILER_BEAM + assertThat (Fable.Beam.Erlang.isProcessAlive worker.Pid) isFalse +#else + do! await 1 cleaned +#endif + let! _ = Actor.stop 2000 started + let! _ = Actor.stop 2000 cleaned + return () + } + ) + ) + testAsync ( + "external cancellation wakes an idle worker", + fun _ -> + toAsync ( + actor { + use cts = new CancellationTokenSource() + let started = barrier () + + let worker: Actor = + Actor.spawnWithToken cts.Token (fun inbox -> + actor { + Actor.cast started Signal + let! _ = inbox.Receive() + return () + }) + + do! await 1 started + cts.Cancel() + let! stopped = Actor.stop 2000 worker + assertThat (completed stopped) isTrue +#if FABLE_COMPILER_BEAM + assertThat (Fable.Beam.Erlang.isProcessAlive worker.Pid) isFalse +#endif + let! _ = Actor.stop 2000 started + return () + } + ) + ) + testAsync ( + "concurrent stop requests share one cleanup", + fun _ -> + toAsync ( + actor { + let started, cleaned, doneEvents = barrier (), barrier (), barrier () + let worker = idle started cleaned + do! await 1 started + + for _ in 1..8 do + let _: Actor = + Actor.spawn (fun _ -> + actor { + let! result = Actor.stop 2000 worker + + if completed result then + Actor.cast doneEvents Signal + }) + + () + + do! await 8 doneEvents +#if FABLE_COMPILER_BEAM + assertThat (Fable.Beam.Erlang.isProcessAlive worker.Pid) isFalse +#else + do! await 1 cleaned + let! count = Actor.call cleaned Read + assertThat count (isEqualTo 1) +#endif + let! _ = Actor.stop 2000 started + let! _ = Actor.stop 2000 cleaned + let! _ = Actor.stop 2000 doneEvents + return () + } + ) + ) + testAsync ( + "normal parent completion shuts down owned child", + fun _ -> + toAsync ( + actor { + let started, cleaned = barrier (), barrier () + let handle = reporter None + + let parent: Actor = + Actor.spawn (fun inbox -> + actor { + Actor.trapExits () + + let child = + Actor.spawnLinked inbox (fun child -> + actor { + try + Actor.cast started Signal + let! _ = child.Receive() + return () + finally + Actor.cast cleaned Signal + }) + + Actor.cast handle (Some(Some child)) + let! _ = inbox.Receive() + return () + }) + + do! await 1 started + let! child = Actor.call handle None + Actor.send parent 1 + // The child eventually rejects work even after normal parent exit. + match child with + | None -> failwith "Missing child handle" + | Some child -> +#if FABLE_COMPILER_BEAM + // Monitoring is the worker-exit barrier; this does not issue another kill. + let monitor = Fable.Beam.Erlang.monitor child.Pid + Fable.Actor.Platform.waitProcessDeath monitor child.Pid + assertThat (Fable.Beam.Erlang.isProcessAlive child.Pid) isFalse +#else + do! await 1 cleaned + let! stopped = Actor.stop 2000 parent + assertThat (completed stopped) isTrue + let! stoppedChild = Actor.stop 2000 child + assertThat (completed stoppedChild) isTrue +#endif + let! _ = Actor.stop 2000 parent + let! _ = Actor.stop 2000 handle + let! _ = Actor.stop 2000 started + let! _ = Actor.stop 2000 cleaned + return () + } + ) + ) + testAsync ( + "restart replacement remains owned during parent shutdown", + fun _ -> + toAsync ( + actor { + let generations, ready, cleaned = barrier (), barrier (), barrier () + let handle = reporter None + + let parent: Actor = + Actor.spawn (fun inbox -> + actor { + Actor.trapExits () + + let body (child: Actor) = + actor { + try + Actor.cast generations Signal + let! msg = child.Receive() + + if msg = "crash" then + failwith "restart me" + finally + Actor.cast cleaned Signal + } + + let first = Actor.spawnSupervised inbox (OneForOne(fun _ -> Directive.Restart)) body + Actor.cast handle (Some(Some first.Actor)) + Actor.cast ready Signal + let! msg = inbox.Receive() + + match Actor.tryAsChildExited msg with + | None -> failwith "Missing child exit" + | Some exit -> + match Actor.handleChildExit inbox first exit with + | ChildExitResult.Stopped -> failwith "Missing replacement" + | ChildExitResult.Restarted replacement -> + Actor.cast handle (Some(Some replacement.Actor)) + Actor.cast ready Signal + let! _ = inbox.Receive() + return () + }) + + do! await 1 ready + do! await 1 generations + let! first = Actor.call handle None + Actor.send (Option.get first) "crash" + do! await 2 ready + do! await 2 generations + let! replacement = Actor.call handle None + let! result = Actor.stop 2000 parent + assertThat (completed result) isTrue +#if FABLE_COMPILER_BEAM + let replacement = Option.get replacement + let monitor = Fable.Beam.Erlang.monitor replacement.Pid + Fable.Actor.Platform.waitProcessDeath monitor replacement.Pid + assertThat (Fable.Beam.Erlang.isProcessAlive replacement.Pid) isFalse +#else + do! await 2 cleaned +#endif + let! _ = Actor.stop 2000 handle + let! _ = Actor.stop 2000 generations + let! _ = Actor.stop 2000 ready + let! _ = Actor.stop 2000 cleaned + return () + } + ) + ) + testAsync ( + "invalid cleanup deadline does not stop the target", + fun _ -> + toAsync ( + actor { + let worker = reporter 7 + let mutable rejected = false + + try + Actor.stop 0 worker |> ignore + with _ -> + rejected <- true + + assertThat rejected isTrue + let! value = Actor.call worker None + assertThat value (isEqualTo 7) + let! _ = Actor.stop 2000 worker + return () + } + ) + ) +#if !FABLE_COMPILER_BEAM + testAsync ( + "noncooperative work times out and remains owned until release", + fun _ -> + toAsync ( + actor { + let started, cleaned = barrier (), barrier () + let releaseHandle = reporter None + + let worker: Actor = + Actor.spawn (fun _ -> + actor { + try + let! _ = + Async.FromContinuations(fun (ok, _, _) -> + Actor.cast releaseHandle (Some(Some(fun () -> ok 1))) + Actor.cast started Signal) + + return () + finally + Actor.cast cleaned Signal + }) + + do! await 1 started + let! result = Actor.stop 10 worker + assertThat result (isEqualTo StopResult.TimedOut) + let! cleanupCount = Actor.call cleaned Read + assertThat cleanupCount (isEqualTo 0) + let! release = Actor.call releaseHandle None + release |> Option.get |> fun f -> f () + let! stopped = Actor.stop 2000 worker + assertThat (completed stopped) isTrue + do! await 1 cleaned + let! _ = Actor.stop 2000 releaseHandle + let! _ = Actor.stop 2000 started + let! _ = Actor.stop 2000 cleaned + return () + } + ) + ) + testAsync ( + "external registration is released on normal exit", + fun _ -> + toAsync ( + actor { + use cts = new CancellationTokenSource() + + let worker: Actor = + Actor.spawnWithToken cts.Token (fun _ -> actor { return () }) + + let! stopped = Actor.stop 2000 worker + assertThat (completed stopped) isTrue + cts.Cancel() + return () + } + ) + ) + testAsync ( + "cleanup exception is reported", + fun _ -> + toAsync ( + actor { + let started = barrier () + + let worker: Actor = + Actor.spawn (fun inbox -> + actor { + try + Actor.cast started Signal + let! _ = inbox.Receive() + return () + finally + failwith "cleanup failed" + }) + + do! await 1 started + let! result = Actor.stop 2000 worker + + match result with + | StopResult.Completed(ActorExit.Failed ex) -> assertThat ex.Message (isEqualTo "cleanup failed") + | other -> failwithf "Cleanup failure was lost: %A" other + + let! _ = Actor.stop 2000 started + return () + } + ) + ) +#endif + ] + ) diff --git a/test/Main.fs b/test/Main.fs index 0a96b8d..6f6354c 100644 --- a/test/Main.fs +++ b/test/Main.fs @@ -13,4 +13,9 @@ open type Scriptorium.Quill.Runner // onto the resolved promise — the value returned here is ignored [] let main _ = - runTests [ ActorTests.tests; SupervisionTests.tests; BuilderTests.tests ] + runTests [ + ActorTests.tests + SupervisionTests.tests + BuilderTests.tests + LifecycleTests.tests + ] From c45170e214c507b1aff1b3009bf3d3b5ccb787c2 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Sat, 3 Oct 2026 21:40:06 +0200 Subject: [PATCH 3/7] fix: bound actor calls and settle on termination --- README.md | 54 ++++ src/Fable.Actor/Actor.fs | 364 ++++++++++++++++------- src/Fable.Actor/AssemblyInfo.fs | 4 + src/Fable.Actor/Fable.Actor.fsproj | 1 + src/Fable.Actor/Lifecycle.fs | 91 +++++- src/Fable.Actor/Platform.fs | 59 ++++ src/Fable.Actor/Types.fs | 10 + test/CallLivenessTests.fs | 453 +++++++++++++++++++++++++++++ test/Fable.Actor.Tests.fsproj | 1 + test/Helpers.fs | 47 +++ test/LifecycleTests.fs | 157 +++++++++- test/Main.fs | 1 + 12 files changed, 1123 insertions(+), 119 deletions(-) create mode 100644 src/Fable.Actor/AssemblyInfo.fs create mode 100644 test/CallLivenessTests.fs diff --git a/README.md b/README.md index aeb1a3f..65c6ba8 100644 --- a/README.md +++ b/README.md @@ -255,3 +255,57 @@ children on normal parent exit. Keep replacement children returned by generation. Actor handles on emulated targets are now constructed by spawn APIs rather than public record literals; Mb and Cts remain available for existing interop, but lifecycle operations must go through Actor APIs. + +## Bounded calls and subscription disposal + +`Actor.callResult replyDeadline cancellationToken actor message` returns +`CallResult.Reply value`, `TimedOut`, `TargetTerminated exit`, or `Cancelled`. +`callResultAsync` provides the same protocol from an Async expression. +`callWithTimeout` and the new `callAsyncWithTimeout` return the reply directly; +they raise `TimeoutException`, `TargetTerminatedException`, or cancellation on +failure. Deadlines are positive milliseconds, validated before request delivery. + +`call` and `callAsync` retain rc.11's unbounded reply deadline. They now settle +on target shutdown/death and ambient caller cancellation instead of hanging on +those paths. The timeout APIs now deliver requests when the operation runs, +rather than when an emulated call operation is merely constructed. + +On emulated targets, shutdown closes admission and settles pending calls at the +stop request, even if noncooperative work has not exited yet. The reported reason +is `Cancelled` in that case; normal workflow completion and crashes report their +observed reason when they close admission. This notification is not confirmation +of completed cleanup: await `stop` for that guarantee. On BEAM, monitors observe +actual process death. A call to an already dead PID reports the native `noproc` +reason, not its historical exit reason. Native stop likewise confirms death but +does not retain a previous exit reason; it reports `Completed Cancelled` for the +issued kill operation. + +A call settles once and detaches its timer, target observer/monitor, and caller +registrations. BEAM aliases revoke the reply destination and flush queued +matching replies; subsequent late replies never enter the mailbox. Emulated +reply callbacks become inert and retain no caller continuation or reply value. +Neither timeout nor cancellation retracts a delivered request or proves that +processing was undone. There is no automatic retry. Await each reply before +submitting the next item when request/reply pacing supplies your backpressure; +fire-and-forget sends retain their existing unbounded queue behavior. + +A downstream subscription can own a parent actor and create its workers with +`spawnLinked` or `spawnSupervised`. Keep each replacement returned by +`handleChildExit`. Give pending `callResultAsync` operations the subscription's +cancellation token. During disposal, close upstream admission, cancel that token +to settle the outstanding calls, then await `stopAsync` on the parent and check +for timeout or cleanup failure. On BEAM, explicitly await each current worker's +stop as well if disposal must confirm the entire tree is dead; parent death alone +does not confirm completed descendant shutdown. A synchronous IDisposable can +request cancellation; an async disposal path must own the await and report its +result. Notification suppression alone is not worker termination. + +Actor-owned Python cancellation registrations, locks, and deadline timers use +small platform adapters for gaps in fable-library 5.19.0. Actor binders check +cancellation before resuming user work, and actor-expression cleanup ignores +duplicate terminal callbacks. Separately supplied Async computations keep their +runtime behavior: in particular, Python Async.Sleep cancellation still leaves +its underlying scheduled callback until its due time. Actor-owned call/stop +**deadline timers** are cancelled physically; arbitrary user Async resources +cannot receive that same guarantee from this library. The upstream work is +captured in [the Fable runtime fix prompt](docs/fable-runtime-fix-prompt.md). diff --git a/src/Fable.Actor/Actor.fs b/src/Fable.Actor/Actor.fs index c4c5aef..84ce9a9 100644 --- a/src/Fable.Actor/Actor.fs +++ b/src/Fable.Actor/Actor.fs @@ -115,7 +115,7 @@ type ActorBuilder() = // === Non-BEAM: MailboxProcessor-based === // decision: aliases actor operations to Async so existing Fable runtimes provide scheduling and cancellation -// invariant: ActorBuilder delegates control-flow semantics to the standard async builder on non-BEAM targets +// decision: guards actor binders and finalizers where portable Async cancellation differs from the owned lifecycle contract type ActorOp<'T> = Async<'T> @@ -142,16 +142,21 @@ type Actor<'Msg> internal (mb: MailboxProcessor<'Msg>, cts: System.Threading.Can /// Queue a message while the actor accepts work. /// /// invariant: posting after cancellation or workflow exit is a no-op - member _.Post(msg: 'Msg) = - if not lifetime.IsStopping then - mb.Post msg + member _.Post(msg: 'Msg) = lifetime.Post(fun () -> mb.Post msg) type ActorBuilder() = - member _.Bind(op: Async<'T>, f: 'T -> Async<'U>) : Async<'U> = async.Bind(op, f) + // decision: delays user binders so a cancelled portable continuation cannot execute user code before its next token check + member _.Bind(op: Async<'T>, f: 'T -> Async<'U>) : Async<'U> = + async.Bind(op, fun value -> Lifetime.guard (fun () -> f value)) + member _.Return(value: 'T) : Async<'T> = async.Return(value) member _.ReturnFrom(op: Async<'T>) : Async<'T> = async.ReturnFrom(op) member _.Zero() : Async = async.Zero() - member _.Delay(f: unit -> Async<'T>) : Async<'T> = async.Delay(f) + + /// Guard the user expression even on runtimes whose cancellation callback falls through into computation code. + /// + /// invariant: an already cancelled actor continuation does not evaluate its delayed user expression + member _.Delay(f: unit -> Async<'T>) : Async<'T> = Lifetime.guard f member _.Combine(first: Async, second: Async<'T>) : Async<'T> = async.Combine(first, async.Delay(fun () -> second)) @@ -162,25 +167,36 @@ type ActorBuilder() = /// /// decision: routes cleanup exceptions explicitly because standard Async finalizers can lose failures during cancellation member _.TryFinally(body: Async<'T>, compensation: unit -> unit) : Async<'T> = - async { - let! token = Async.CancellationToken - - return! - Async.FromContinuations(fun (ok, error, cancelled) -> - let finish next value = - let failure = - try - compensation () - None - with ex -> - Some ex - - match failure with - | Some ex -> error ex - | None -> next value - - Async.StartWithContinuations(body, finish ok, finish error, finish cancelled, token)) - } + Lifetime.withContext (fun ((ok, error, cancelled), token) -> + let gate = Lifetime.newGate () + let mutable finished = false + + let finish next value = + // invariant: a duplicate terminal callback from a supplied Async cannot repeat actor cleanup + let run = + Lifetime.synchronize gate (fun () -> + if finished then + false + else + finished <- true + true) + + if run then + let failure = + try + compensation () + None + with ex -> + Some ex + + match failure with + | Some ex -> error ex + | None -> next value + + if token.IsCancellationRequested then + finish cancelled (System.OperationCanceledException()) + else + Async.StartWithContinuations(body, finish ok, finish error, finish cancelled, token)) member this.Using(resource: 'a :> System.IDisposable, body: 'a -> Async<'T>) : Async<'T> = this.TryFinally( @@ -303,55 +319,150 @@ module Actor = /// Format a crash reason as a string. let formatReason (reason: obj) : string = Platform.formatReason reason - /// Send a message and await a reply (inside actor { }). + /// Monitored call with a revocable reply destination. /// - /// decision: correlates every call with a fresh Erlang ref so concurrent replies cannot be confused - /// invariant: waiting for this call consumes only the reply carrying its ref - let call (actor: Actor<'Msg * ReplyChannel<'Reply>>) (msg: 'Msg) : ActorOp<'Reply> = { + /// decision: uses a process alias so late replies are dropped before entering the caller mailbox + /// invariant: every settlement removes its alias, monitor, and any already queued duplicate replies + /// tradeoff: checks explicit caller cancellation in receive slices of at most 5 ms without a per-call helper process + let private callCore + timeout + (token: System.Threading.CancellationToken) + (ambient: System.Threading.CancellationToken) + (target: Actor<'Msg * ReplyChannel<'Reply>>) + msg + : ActorOp> = { Run = fun cont -> - let ref = Erlang.makeRef () - let callerPid = Erlang.self () + let alias, monitor = beginCall target.Pid + + let result = + try + let expires = + timeout + |> Option.map (fun ms -> monotonicMilliseconds () + int64 ms) + + let rec wait () = + if + ambient.IsCancellationRequested + || token.IsCancellationRequested + then + CallResult.Cancelled + else + let remaining = + expires + |> Option.map (fun value -> value - monotonicMilliseconds ()) + + match remaining with + | Some ms when ms <= 0L -> CallResult.TimedOut + | _ -> + let slice = + remaining + |> Option.map (fun ms -> min 5 (int ms)) + |> Option.defaultValue 5 + + match recvCall alias monitor slice with + | 0, value -> CallResult.Reply(unbox value) + | 1, reason -> CallResult.TargetTerminated(exitReason reason) + | _ -> wait () + + if + ambient.IsCancellationRequested + || token.IsCancellationRequested + then + CallResult.Cancelled + else + sendMsg + target.Pid + (msg, + { + Reply = fun value -> sendAliasReply alias value + }) - let rc: ReplyChannel<'Reply> = { - Reply = fun reply -> sendReply callerPid ref reply - } + wait () + finally + endCall alias monitor - sendMsg actor.Pid (msg, rc) - cont (recvReply ref) + cont result } - /// Send a message and await a reply as an Async (usable from async expressions). - /// - /// decision: captures the synchronous CPS result to bridge ActorOp back into shared Async-based APIs - /// assumption: call invokes its continuation synchronously after the blocking BEAM receive completes - let callAsync (actor: Actor<'Msg * ReplyChannel<'Reply>>) (msg: 'Msg) : Async<'Reply> = + let private replyOrRaise = + function + | CallResult.Reply value -> value + | CallResult.TimedOut -> raise (System.TimeoutException("Actor call timed out")) + | CallResult.TargetTerminated exit -> raise (TargetTerminatedException exit) + | CallResult.Cancelled -> raise (System.OperationCanceledException()) + + /// Await one request with a positive reply deadline and explicit caller lifetime. + let callResult deadline token target msg : ActorOp> = + if deadline <= 0 then + invalidArg "deadline" "Reply deadline must be positive" + + callCore (Some deadline) token System.Threading.CancellationToken.None target msg + + let private callCoreAsync timeout token target msg : Async> = async { - let mutable result = Unchecked.defaultof<'Reply> - (call actor msg).Run(fun reply -> result <- reply) - return result + let! ambient = Async.CancellationToken + + return! + Async.FromContinuations(fun (ok, error, cancelled) -> + let mutable result = CallResult.TimedOut + + let failure = + try + (callCore timeout token ambient target msg).Run(fun value -> result <- value) + None + with ex -> + Some ex + + match failure with + | Some ex -> error ex + | None -> + match result with + | CallResult.Cancelled when ambient.IsCancellationRequested -> cancelled (System.OperationCanceledException()) + | _ -> ok result) } - /// Send a message and await a reply with a timeout in milliseconds. - /// Raises TimeoutException if no reply is received within the timeout. - /// - /// invariant: the selective receive leaves unrelated mailbox messages untouched - let callWithTimeout (timeout: int) (actor: Actor<'Msg * ReplyChannel<'Reply>>) (msg: 'Msg) : ActorOp<'Reply> = { + let callResultAsync deadline token target msg : Async> = + if deadline <= 0 then + invalidArg "deadline" "Reply deadline must be positive" + + callCoreAsync (Some deadline) token target msg + + /// Await a reply without a reply deadline, preserving rc.11's default. Native target death settles the wait. + let call target msg : ActorOp<'Reply> = { Run = fun cont -> - let ref = Erlang.makeRef () - let callerPid = Erlang.self () + (callCore None System.Threading.CancellationToken.None System.Threading.CancellationToken.None target msg) + .Run(fun result -> cont (replyOrRaise result)) + } - let rc: ReplyChannel<'Reply> = { - Reply = fun reply -> sendReply callerPid ref reply - } + let callAsync target msg : Async<'Reply> = + async { + let! result = callCoreAsync None System.Threading.CancellationToken.None target msg + return replyOrRaise result + } - sendMsg actor.Pid (msg, rc) + let callWithTimeout deadline target msg : ActorOp<'Reply> = + if deadline <= 0 then + invalidArg "deadline" "Reply deadline must be positive" - match recvReplyWithTimeout ref timeout with - | Some reply -> cont reply - | None -> raise (System.TimeoutException("Actor call timed out")) - } + { + Run = + fun cont -> + (callCore (Some deadline) System.Threading.CancellationToken.None System.Threading.CancellationToken.None target msg) + .Run(fun result -> cont (replyOrRaise result)) + } + + let callAsyncWithTimeout deadline target msg : Async<'Reply> = + if deadline <= 0 then + invalidArg "deadline" "Reply deadline must be positive" + + async { + let! result = + callCoreAsync (Some deadline) System.Threading.CancellationToken.None target msg + + return replyOrRaise result + } /// Receive next message (free function). let receive<'Msg> () : ActorOp<'Msg> = { @@ -379,7 +490,7 @@ module Actor = attach inbox Async.StartWithContinuations( - async { do! body inbox }, + Lifetime.guard (fun () -> body inbox), (fun () -> lifetime.Complete ActorExit.Normal), (fun ex -> lifetime.Complete(ActorExit.Failed ex)), (fun _ -> lifetime.Complete ActorExit.Cancelled), @@ -425,22 +536,18 @@ module Actor = if cleanupDeadline <= 0 then invalidArg "cleanupDeadline" "Cleanup deadline must be positive" - async { - let! token = Async.CancellationToken + Lifetime.guard (fun () -> + Lifetime.withContext (fun ((ok, _, cancelled), token) -> + let pending = + Lifetime.Pending(fun result -> + match result with + | Choice1Of2 value -> ok value + | Choice2Of2 ex -> cancelled ex) - return! - Async.FromContinuations(fun (ok, _, cancelled) -> - let pending = - Lifetime.Pending(fun result -> - match result with - | Choice1Of2 value -> ok value - | Choice2Of2 ex -> cancelled ex) - - pending.Add(Lifetime.cancellation token (fun () -> pending.Settle(Choice2Of2(System.OperationCanceledException())))) - pending.Add(Lifetime.deadline cleanupDeadline (fun () -> pending.Settle(Choice1Of2 StopResult.TimedOut))) - pending.Add(target.Lifetime.Observe(fun exit -> pending.Settle(Choice1Of2(StopResult.Completed exit)))) - target.Lifetime.RequestStop()) - } + pending.Add(Lifetime.cancellation token (fun () -> pending.Settle(Choice2Of2(System.OperationCanceledException())))) + pending.Add(Lifetime.deadline cleanupDeadline (fun () -> pending.Settle(Choice1Of2 StopResult.TimedOut))) + pending.Add(target.Lifetime.Observe(fun exit -> pending.Settle(Choice1Of2(StopResult.Completed exit)))) + target.Lifetime.RequestStop())) let stopAsync cleanupDeadline target : Async = stop cleanupDeadline target @@ -449,44 +556,81 @@ module Actor = /// decision: remains a no-op because spawnLinked already converts non-BEAM child crashes into messages let trapExits () : unit = () - /// Send a message and await a reply (inside actor { }). - let call (target: Actor<'Msg * ReplyChannel<'Reply>>) (msg: 'Msg) : ActorOp<'Reply> = - actor { - let! reply = - target.Mb.PostAndAsyncReply(fun rc -> (msg, { Reply = fun r -> rc.Reply(r) })) + /// One per-call settlement owns its reply callback, admission observer, timer, and caller registrations. + /// + /// invariant: each delivered request has one reply destination and settles at most once without retries + /// invariant: a settled late-reply callback retains no caller continuation or reply value + let private callCore + timeout + (token: System.Threading.CancellationToken) + (target: Actor<'Msg * ReplyChannel<'Reply>>) + msg + : Async> = + Lifetime.withContext (fun ((ok, _, cancelled), ambient) -> + let pending = + Lifetime.Pending(fun result -> + match result with + | Choice1Of2 value -> ok value + | Choice2Of2 ex -> cancelled ex) + + let settle result = pending.Settle(Choice1Of2 result) + pending.Add(Lifetime.cancellation ambient (fun () -> pending.Settle(Choice2Of2(System.OperationCanceledException())))) + pending.Add(Lifetime.cancellation token (fun () -> settle CallResult.Cancelled)) + + timeout + |> Option.iter (fun ms -> pending.Add(Lifetime.deadline ms (fun () -> settle CallResult.TimedOut))) + + pending.Add(target.Lifetime.ObserveTermination(fun exit -> settle (CallResult.TargetTerminated exit))) + + if not pending.IsCompleted then + target.Post( + msg, + { + Reply = fun value -> settle (CallResult.Reply value) + } + )) + + /// Await one request with a positive reply deadline and explicit caller lifetime. + /// Timeout or cancellation does not undo processing. Stop closes request admission and settles outstanding waits. + let callResult deadline token target msg : ActorOp> = + if deadline <= 0 then + invalidArg "deadline" "Reply deadline must be positive" + + callCore (Some deadline) token target msg + + let callResultAsync deadline token target msg : Async> = callResult deadline token target msg + + let private replyOrRaise = + function + | CallResult.Reply value -> value + | CallResult.TimedOut -> raise (System.TimeoutException("Actor call timed out")) + | CallResult.TargetTerminated exit -> raise (TargetTerminatedException exit) + | CallResult.Cancelled -> raise (System.OperationCanceledException()) + + /// Await a reply without a reply deadline, preserving rc.11's default. Target shutdown and caller cancellation settle the wait. + let call target msg : ActorOp<'Reply> = + Lifetime.guard (fun () -> + async { + let! result = callCore None System.Threading.CancellationToken.None target msg + return replyOrRaise result + }) - return reply - } + let callAsync target msg : Async<'Reply> = call target msg - /// Send a message and await a reply as an Async (usable from async { } contexts). - /// On non-BEAM targets ActorOp = Async, so this is a direct alias for call. - let callAsync (target: Actor<'Msg * ReplyChannel<'Reply>>) (msg: 'Msg) : Async<'Reply> = call target msg + /// Await a reply within a positive deadline; raises on timeout, target termination, or cancellation. + let callWithTimeout deadline target msg : ActorOp<'Reply> = + if deadline <= 0 then + invalidArg "deadline" "Reply deadline must be positive" - /// Send a message and await a reply with a timeout in milliseconds. - /// Raises TimeoutException if no reply is received within the timeout. - /// - /// decision: polls a ReplyChannel every 5 ms because the portable path cannot use MailboxProcessor timeout overloads - /// tradeoff: timeout detection can lag by one polling interval to keep one implementation for .NET, Python, and JS - let callWithTimeout (timeout: int) (target: Actor<'Msg * ReplyChannel<'Reply>>) (msg: 'Msg) : ActorOp<'Reply> = - let mutable result: 'Reply option = None - let rc: ReplyChannel<'Reply> = { Reply = fun r -> result <- Some r } - target.Post((msg, rc)) - - let step = 5 - - let rec wait elapsed = - actor { - match result with - | Some r -> return r - | None -> - if elapsed >= timeout then - raise (System.TimeoutException("Actor call timed out")) - - do! Async.Sleep step - return! wait (elapsed + step) - } - - wait 0 + Lifetime.guard (fun () -> + async { + let! result = + callCore (Some deadline) System.Threading.CancellationToken.None target msg + + return replyOrRaise result + }) + + let callAsyncWithTimeout deadline target msg : Async<'Reply> = callWithTimeout deadline target msg /// Receive next message (free function, for backwards compatibility). let receive<'Msg> (inbox: Actor<'Msg>) : Async<'Msg> = inbox.Receive() diff --git a/src/Fable.Actor/AssemblyInfo.fs b/src/Fable.Actor/AssemblyInfo.fs new file mode 100644 index 0000000..a051c80 --- /dev/null +++ b/src/Fable.Actor/AssemblyInfo.fs @@ -0,0 +1,4 @@ +namespace Fable.Actor + +[] +do () diff --git a/src/Fable.Actor/Fable.Actor.fsproj b/src/Fable.Actor/Fable.Actor.fsproj index 93bef7d..99ee578 100644 --- a/src/Fable.Actor/Fable.Actor.fsproj +++ b/src/Fable.Actor/Fable.Actor.fsproj @@ -10,6 +10,7 @@ Cross-platform actor library for F# compiled via Fable to BEAM, Python, and JavaScript + diff --git a/src/Fable.Actor/Lifecycle.fs b/src/Fable.Actor/Lifecycle.fs index afac063..e4aefc2 100644 --- a/src/Fable.Actor/Lifecycle.fs +++ b/src/Fable.Actor/Lifecycle.fs @@ -22,6 +22,50 @@ module internal Lifetime = let synchronize gate action = lock gate action #endif + let withContext body : Async<'T> = +#if FABLE_COMPILER_PYTHON + Platform.fromContext body +#else + async { + let! token = Async.CancellationToken + return! Async.FromContinuations(fun continuations -> body (continuations, token)) + } +#endif + + let guard (expression: unit -> Async<'T>) : Async<'T> = +#if FABLE_COMPILER_PYTHON + withContext (fun ((ok, error, cancelled), token) -> + let gate = newGate () + let mutable finished = false + + let finish next value = + let run = + synchronize gate (fun () -> + if finished then + false + else + finished <- true + true) + + if run then + next value + + if token.IsCancellationRequested then + finish cancelled (OperationCanceledException()) + else + let operation = + try + Choice1Of2(expression ()) + with ex -> + Choice2Of2 ex + + match operation with + | Choice2Of2 ex -> finish error ex + | Choice1Of2 op -> Async.StartWithContinuations(op, finish ok, finish error, finish cancelled, token)) +#else + async.Delay expression +#endif + let disposable f = { new IDisposable with member _.Dispose() = f () @@ -116,8 +160,7 @@ module internal Lifetime = /// invariant: terminal publication follows workflow exit and all owned child exits /// invariant: cancellation and lifecycle cleanup run at most once per generation /// tradeoff: cooperative user work can outlive a cleanup deadline and remains owned until it exits -[] -type ActorLifetime internal (cts: CancellationTokenSource) = +type internal ActorLifetime(cts: CancellationTokenSource) = let gate = Lifetime.newGate () let mutable stopping = false let mutable cancelling = false @@ -126,12 +169,20 @@ type ActorLifetime internal (cts: CancellationTokenSource) = let mutable result: ActorExit option = None let mutable failure: exn option = None let observers = System.Collections.Generic.Dictionary unit>() + + let terminationObservers = + System.Collections.Generic.Dictionary unit>() + + let mutable termination: ActorExit option = None let children = System.Collections.Generic.Dictionary() let resources = ResizeArray() let mutable nextId = 0 member _.IsStopping = Lifetime.synchronize gate (fun () -> stopping) - member _.ObserverCount = Lifetime.synchronize gate (fun () -> observers.Count) + + member _.ObserverCount = + Lifetime.synchronize gate (fun () -> observers.Count + terminationObservers.Count) + member _.ChildCount = Lifetime.synchronize gate (fun () -> children.Count) member private _.RecordFailure(ex: exn) = @@ -191,6 +242,29 @@ type ActorLifetime internal (cts: CancellationTokenSource) = terminal |> Option.iter callback Lifetime.disposable (fun () -> Lifetime.synchronize gate (fun () -> observers.Remove id |> ignore)) + // invariant: delivery and admission closure are serialized so Post cannot race mailbox disposal + member _.Post(deliver: unit -> unit) = + Lifetime.synchronize gate (fun () -> + if not stopping then + deliver ()) + + /// Observe closure of request admission, independently of descendant cleanup. + /// + /// decision: settles calls on stop request because cooperative work can otherwise retain them beyond shutdown + member _.ObserveTermination(callback: ActorExit -> unit) = + let id, terminal = + Lifetime.synchronize gate (fun () -> + nextId <- nextId + 1 + + match termination with + | Some exit -> nextId, Some exit + | None -> + terminationObservers.Add(nextId, callback) + nextId, None) + + terminal |> Option.iter callback + Lifetime.disposable (fun () -> Lifetime.synchronize gate (fun () -> terminationObservers.Remove id |> ignore)) + member this.RequestStop() = let owned = Lifetime.synchronize gate (fun () -> @@ -199,11 +273,18 @@ type ActorLifetime internal (cts: CancellationTokenSource) = else stopping <- true cancelling <- true - Some(children.Values |> Seq.toArray)) + let exit = defaultArg bodyExit ActorExit.Cancelled + termination <- Some exit + let callbacks = terminationObservers.Values |> Seq.toArray + terminationObservers.Clear() + Some(children.Values |> Seq.toArray, exit, callbacks)) match owned with | None -> () - | Some owned -> + | Some(owned, exit, callbacks) -> + for callback in callbacks do + callback exit + for child in owned do child.RequestStop() diff --git a/src/Fable.Actor/Platform.fs b/src/Fable.Actor/Platform.fs index 3840398..390cceb 100644 --- a/src/Fable.Actor/Platform.fs +++ b/src/Fable.Actor/Platform.fs @@ -188,3 +188,62 @@ let startDeadline (ms: int) (callback: unit -> unit) : obj = nativeOnly let cancelDeadline (handle: obj) : unit = nativeOnly #endif + +#if FABLE_COMPILER_BEAM + +let exitReason (reason: obj) : ActorExit = + if Erlang.exactEquals reason atomNormal then + ActorExit.Normal + else + ActorExit.Failed(ProcessExitException(formatReason reason)) + +let monotonicMilliseconds () : int64 = + emitErlExpr () "erlang:monotonic_time(millisecond)" + +let beginCall (pid: Pid<'Msg>) : Ref * Ref> = + emitErlExpr pid "{erlang:alias([explicit_unalias]), erlang:monitor(process, $0)}" + +let sendAliasReply (alias: Ref) (value: 'Reply) : unit = + emitErlExpr (alias, value) "$0 ! {fable_actor_reply, $0, $1}, ok" + +/// Receive only this request's reply or target monitor signal. +/// +/// invariant: unrelated application messages, links, and monitors remain untouched +let recvCall (alias: Ref) (monitor: Ref>) (slice: int) : int * obj = + emitErlExpr + (alias, monitor, slice) + """ + receive + {fable_actor_reply, $0, Value} -> {0, Value}; + {'DOWN', $1, process, _, Reason} -> {1, Reason} + after $2 -> {2, undefined} end + """ + +/// Revoke first, then flush, so no reply can arrive after the flush. +let endCall (alias: Ref) (monitor: Ref>) : unit = + emitErlExpr + (alias, monitor) + """ + (fun() -> + unalias($0), demonitor($1, [flush]), + Flush = fun Loop() -> + receive {fable_actor_reply, $0, _} -> Loop() after 0 -> ok end + end, + Flush() + end)() + """ + +#endif + +#if FABLE_COMPILER_PYTHON + +/// Build a context-aware Async without the upstream cancellation fall-through wrapper. +/// +/// decision: hands cancellation to the actor settlement gate because fable-library 5.19 protected_cont continues after on_cancel +[] +let fromContext + (body: (('T -> unit) * (exn -> unit) * (System.OperationCanceledException -> unit)) * System.Threading.CancellationToken -> unit) + : Async<'T> = + nativeOnly + +#endif diff --git a/src/Fable.Actor/Types.fs b/src/Fable.Actor/Types.fs index da2328c..b325062 100644 --- a/src/Fable.Actor/Types.fs +++ b/src/Fable.Actor/Types.fs @@ -43,6 +43,16 @@ type StopResult = | Completed of ActorExit | TimedOut +/// Settlement of one request. Timeout and cancellation do not retract a delivered message. +[] +type CallResult<'Reply> = + | Reply of 'Reply + | TimedOut + | TargetTerminated of ActorExit + | Cancelled + +exception TargetTerminatedException of ActorExit + exception ProcessExitException of string /// What the supervisor should do when a child crashes. diff --git a/test/CallLivenessTests.fs b/test/CallLivenessTests.fs new file mode 100644 index 0000000..2deb9ee --- /dev/null +++ b/test/CallLivenessTests.fs @@ -0,0 +1,453 @@ +module Fable.Actor.Tests.CallLivenessTests + +open System.Threading +open Fable.Core +open Scriptorium.Quill +open Scriptorium.Nib.Assertion +open type Scriptorium.Quill.Test +open Fable.Actor +open Fable.Actor.Types +open Fable.Actor.Tests.LifecycleTests + +type Request = + | Echo of int + | Hold + | Crash + | Finish + +let worker delivered held = + Actor.spawn (fun inbox -> + let rec loop () = + actor { + let! msg, rc = inbox.Receive() + Actor.cast delivered Signal + + match msg with + | Echo n -> + rc.Reply n + rc.Reply -1 + return! loop () + | Hold -> + Actor.cast held (Some(Some rc)) + return! loop () + | Crash -> failwith "handler crashed" + | Finish -> return () + } + + loop ()) + +let terminated = + function + | CallResult.TargetTerminated _ -> true + | _ -> false + +let tests = + testList ( + "Call liveness", + [ + testAsync ( + "reply settles once and duplicates cannot affect the next call", + fun _ -> + toAsync ( + actor { + let delivered, held = barrier (), reporter None + let target = worker delivered held + + for n in 1..10 do + let! result = Actor.callResult 2000 CancellationToken.None target (Echo n) + + match result with + | CallResult.Reply value -> assertThat value (isEqualTo n) + | _ -> failwith "Missing reply" +#if !FABLE_COMPILER_BEAM + assertThat target.Lifetime.ObserverCount (isEqualTo 0) +#endif + let! _ = Actor.stop 2000 target + let! _ = Actor.stop 2000 delivered + let! _ = Actor.stop 2000 held + return () + } + ) + ) + testAsync ( + "unit replies are successful replies", + fun _ -> + toAsync ( + actor { + let target = + Actor.start () (fun state (msg, rc) -> + rc.Reply() + Continue state) + + let! result = Actor.callResult 2000 CancellationToken.None target () + + match result with + | CallResult.Reply() -> () + | _ -> failwith "Unit reply lost" + + let! _ = Actor.stop 2000 target + return () + } + ) + ) + testAsync ( + "handler crashes settle rather than hanging", + fun _ -> + toAsync ( + actor { + let delivered, held = barrier (), reporter None + let target = worker delivered held + let! result = Actor.callResult 2000 CancellationToken.None target Crash + assertThat (terminated result) isTrue + do! await 1 delivered + let! _ = Actor.stop 2000 target + let! _ = Actor.stop 2000 delivered + let! _ = Actor.stop 2000 held + return () + } + ) + ) + testAsync ( + "unbounded callAsync reports handler death", + fun _ -> + toAsync ( + actor { + let delivered, held = barrier (), reporter None + let target = worker delivered held + let mutable failed = false + + try + let! _ = Actor.callAsync target Crash + () + with _ -> + failed <- true + + assertThat failed isTrue + do! await 1 delivered + let! _ = Actor.stop 2000 target + let! _ = Actor.stop 2000 delivered + let! _ = Actor.stop 2000 held + return () + } + ) + ) + testAsync ( + "normal exit without a reply settles the call", + fun _ -> + toAsync ( + actor { + let delivered, held = barrier (), reporter None + let target = worker delivered held + let! result = Actor.callResult 2000 CancellationToken.None target Finish + assertThat (terminated result) isTrue + do! await 1 delivered + let! _ = Actor.stop 2000 target + let! _ = Actor.stop 2000 delivered + let! _ = Actor.stop 2000 held + return () + } + ) + ) + testAsync ( + "a stopped target rejects calls promptly", + fun _ -> + toAsync ( + actor { + let target = reporter 1 + let! _ = Actor.stop 2000 target + let! result = Actor.callResult 2000 CancellationToken.None target None + assertThat (terminated result) isTrue + } + ) + ) + testAsync ( + "invalid reply deadlines do not deliver requests", + fun _ -> + toAsync ( + actor { + let delivered, held = barrier (), reporter None + let target = worker delivered held + + for ms in [ 0; -1 ] do + let mutable rejected = false + + try + Actor.callResult ms CancellationToken.None target (Echo 99) + |> ignore + with _ -> + rejected <- true + + assertThat rejected isTrue + + try + Actor.callWithTimeout ms target (Echo 99) + |> ignore + with _ -> + () + + let! n = Actor.call delivered Read + assertThat n (isEqualTo 0) + let! _ = Actor.stop 2000 target + let! _ = Actor.stop 2000 delivered + let! _ = Actor.stop 2000 held + return () + } + ) + ) + testAsync ( + "timeout does not undo delivery and late replies leave no abandoned mailbox entries", + fun _ -> + toAsync ( + actor { + let delivered, held = barrier (), reporter None + let target = worker delivered held + use timeoutCts = new CancellationTokenSource() +#if FABLE_COMPILER_BEAM + let beforeQueue = queueLength () + let beforeMonitors = monitorCount () +#endif + for n in 1..20 do + let! result = Actor.callResult 10 timeoutCts.Token target Hold + assertThat result (isEqualTo CallResult.TimedOut) + do! await (2 * n - 1) delivered + let! oldReply = Actor.call held None + Option.get oldReply |> fun rc -> rc.Reply 999 + let! fresh = Actor.callAsyncWithTimeout 2000 target (Echo n) + assertThat fresh (isEqualTo n) + Option.get oldReply |> fun rc -> rc.Reply 888 +#if FABLE_COMPILER_BEAM + assertThat (queueLength ()) (isEqualTo beforeQueue) + assertThat (monitorCount ()) (isEqualTo beforeMonitors) +#else + assertThat target.Lifetime.ObserverCount (isEqualTo 0) +#endif +#if FABLE_COMPILER_PYTHON || FABLE_COMPILER_JAVASCRIPT + assertThat (tokenListenerCount timeoutCts.Token) (isEqualTo 0) +#endif + let! _ = Actor.stop 2000 target + let! _ = Actor.stop 2000 delivered + let! _ = Actor.stop 2000 held + return () + } + ) + ) + testAsync ( + "pre-cancelled callers do not deliver", + fun _ -> + toAsync ( + actor { + use cts = new CancellationTokenSource() + cts.Cancel() + let delivered, held = barrier (), reporter None + let target = worker delivered held + let! result = Actor.callResult 2000 cts.Token target Hold + assertThat result (isEqualTo CallResult.Cancelled) + let! n = Actor.call delivered Read + assertThat n (isEqualTo 0) + let! _ = Actor.stop 2000 target + let! _ = Actor.stop 2000 delivered + let! _ = Actor.stop 2000 held + return () + } + ) + ) + testAsync ( + "subscription cancellation settles a delivered pending call and preserves later calls", + fun _ -> + toAsync ( + actor { + use cts = new CancellationTokenSource() + let delivered, held, doneEvents = barrier (), reporter None, barrier () + let outcome = reporter None + let target = worker delivered held + + let caller: Actor = + Actor.spawn (fun _ -> + actor { + let! result = Actor.callResult 2000 cts.Token target Hold + Actor.cast outcome (Some(Some result)) + Actor.cast doneEvents Signal + }) + + do! await 1 delivered + cts.Cancel() + do! await 1 doneEvents + let! result = Actor.call outcome None + assertThat (Option.get result) (isEqualTo CallResult.Cancelled) + let! oldReply = Actor.call held None + Option.get oldReply |> fun rc -> rc.Reply 42 + let! value = Actor.callWithTimeout 2000 target (Echo 7) + assertThat value (isEqualTo 7) + let! _ = Actor.stop 2000 caller + let! _ = Actor.stop 2000 target + let! _ = Actor.stop 2000 delivered + let! _ = Actor.stop 2000 held + let! _ = Actor.stop 2000 doneEvents + let! _ = Actor.stop 2000 outcome + return () + } + ) + ) + testAsync ( + "ambient pre-cancellation invokes one cancellation and delivers nothing", + fun _ -> + toAsync ( + actor { + use cts = new CancellationTokenSource() + cts.Cancel() + let delivered, held, cancelledEvents = barrier (), reporter None, barrier () + let target = worker delivered held + let computation = Actor.callAsync target Hold + + let start () = + Async.StartWithContinuations( + computation, + (fun value -> + ignore value + failwith "Unexpected reply"), + (fun ex -> + ignore ex + failwith "Expected cancellation"), + (fun ex -> + ignore ex + Actor.cast cancelledEvents Signal), + cts.Token + ) +#if FABLE_COMPILER_BEAM + Fable.Beam.Erlang.spawn start |> ignore +#else + start () +#endif + do! await 1 cancelledEvents + let! cancellations = Actor.call cancelledEvents Read + let! requests = Actor.call delivered Read + assertThat cancellations (isEqualTo 1) + assertThat requests (isEqualTo 0) + let! _ = Actor.stop 2000 target + let! _ = Actor.stop 2000 delivered + let! _ = Actor.stop 2000 held + let! _ = Actor.stop 2000 cancelledEvents + return () + } + ) + ) + testAsync ( + "ambient cancellation settles the unbounded Async call", + fun _ -> + toAsync ( + actor { + use cts = new CancellationTokenSource() + let delivered, held, doneEvents = barrier (), reporter None, barrier () + let outcome = reporter "waiting" + let target = worker delivered held + + let computation = + async { + let! _ = Actor.callAsync target Hold + return 1 + } + + let start () = + Async.StartWithContinuations( + computation, + (fun (value: int) -> + ignore value + Actor.cast outcome (Some "reply") + Actor.cast doneEvents Signal), + (fun ex -> + ignore ex + Actor.cast outcome (Some "error") + Actor.cast doneEvents Signal), + (fun ex -> + ignore ex + Actor.cast outcome (Some "cancelled") + Actor.cast doneEvents Signal), + cts.Token + ) +#if FABLE_COMPILER_BEAM + Fable.Beam.Erlang.spawn start |> ignore +#else + start () +#endif + do! await 1 delivered + cts.Cancel() + do! await 1 doneEvents + let! result = Actor.call outcome None + assertThat result (isEqualTo "cancelled") +#if FABLE_COMPILER_PYTHON || FABLE_COMPILER_JAVASCRIPT + assertThat (tokenListenerCount cts.Token) (isEqualTo 0) +#endif + let! oldReply = Actor.call held None + Option.get oldReply |> fun rc -> rc.Reply 88 + let! value = Actor.callAsyncWithTimeout 2000 target (Echo 3) + assertThat value (isEqualTo 3) + let! _ = Actor.stop 2000 target + let! _ = Actor.stop 2000 delivered + let! _ = Actor.stop 2000 held + let! _ = Actor.stop 2000 doneEvents + let! _ = Actor.stop 2000 outcome + return () + } + ) + ) + testAsync ( + "target stop settles already delivered pending calls", + fun _ -> + toAsync ( + actor { + let delivered, held, doneEvents = barrier (), reporter None, barrier () + let outcome = reporter None + let target = worker delivered held + + let caller: Actor = + Actor.spawn (fun _ -> + actor { + let! result = Actor.callResult 2000 CancellationToken.None target Hold + Actor.cast outcome (Some(Some result)) + Actor.cast doneEvents Signal + }) + + do! await 1 delivered + let! stopped = Actor.stop 2000 target + assertThat (completed stopped) isTrue + do! await 1 doneEvents + let! result = Actor.call outcome None + assertThat (terminated (Option.get result)) isTrue + let! _ = Actor.stop 2000 caller + let! _ = Actor.stop 2000 delivered + let! _ = Actor.stop 2000 held + let! _ = Actor.stop 2000 doneEvents + let! _ = Actor.stop 2000 outcome + return () + } + ) + ) +#if !FABLE_COMPILER_BEAM + testAsync ( + "settlement disposes resources once across concurrent results and late acquisition", + fun _ -> + async { + let gate = Lifetime.newGate () + let mutable notifications, disposed = 0, 0 + + let pending = + Lifetime.Pending(fun (_: int) -> Lifetime.synchronize gate (fun () -> notifications <- notifications + 1)) + + let resource () = + Lifetime.disposable (fun () -> Lifetime.synchronize gate (fun () -> disposed <- disposed + 1)) + + pending.Add(resource ()) + + do! + [| for n in 1..20 -> async { pending.Settle n } |] + |> Async.Parallel + |> Async.Ignore + + pending.Add(resource ()) + assertThat notifications (isEqualTo 1) + assertThat disposed (isEqualTo 2) + } + ) +#endif + ] + ) diff --git a/test/Fable.Actor.Tests.fsproj b/test/Fable.Actor.Tests.fsproj index 379b2ce..e7f8aef 100644 --- a/test/Fable.Actor.Tests.fsproj +++ b/test/Fable.Actor.Tests.fsproj @@ -12,6 +12,7 @@ + diff --git a/test/Helpers.fs b/test/Helpers.fs index 2980a95..7eecd5b 100644 --- a/test/Helpers.fs +++ b/test/Helpers.fs @@ -2,6 +2,7 @@ namespace Fable.Actor.Tests open Fable.Actor open Fable.Actor.Types +open Fable.Core // Shared plumbing for the cross-target behavioral suite. Assertions and the runner come from // Scriptorium (Nib + Quill); what is left here is the glue the suite needs to drive actors — @@ -25,6 +26,22 @@ module Helpers = [] let private sleepMs (ms: int) : unit = nativeOnly + [] + let queueLength () : int = nativeOnly + + [] + let monitorCount () : int = nativeOnly + + [ ObservedPid = $0, M = monitor(process, ObservedPid), receive {'DOWN', M, process, ObservedPid, _} -> true after 2000 -> demonitor(M, [flush]), false end end)()")>] + let private observedDeath (pid: Fable.Beam.Pid<'Msg>) : bool = nativeOnly + + /// Observe process death without issuing another kill. + let observeExit (target: Actor<'Msg>) : ActorOp = + actor { + if not (observedDeath target.Pid) then + failwith "Worker exit was not observed" + } + /// Suspend the current actor for `ms` milliseconds. let sleep (ms: int) : ActorOp = actor { @@ -40,6 +57,36 @@ module Helpers = #else +#if FABLE_COMPILER_PYTHON + [] + let tokenListenerCount (token: System.Threading.CancellationToken) : int = nativeOnly +#else +#if FABLE_COMPILER_JAVASCRIPT + [] + let tokenListenerCount (token: System.Threading.CancellationToken) : int = nativeOnly +#endif +#endif + + /// Observe workflow and descendant exit without requesting shutdown. + let observeExit (target: Actor<'Msg>) : ActorOp = + actor { + let! _ = + Lifetime.withContext (fun ((ok, error, _), _) -> + let pending = + Lifetime.Pending(fun result -> + match result with + | Some ex -> error ex + | None -> ok 1) + + pending.Add( + Lifetime.deadline 2000 (fun () -> pending.Settle(Some(System.TimeoutException "Worker exit was not observed"))) + ) + + pending.Add(target.Lifetime.Observe(fun _ -> pending.Settle None))) + + return () + } + /// Suspend the current actor for `ms` milliseconds (yields to the event loop). let sleep (ms: int) : ActorOp = Async.Sleep ms diff --git a/test/LifecycleTests.fs b/test/LifecycleTests.fs index c9e1fa7..ade6286 100644 --- a/test/LifecycleTests.fs +++ b/test/LifecycleTests.fs @@ -52,10 +52,83 @@ let idle events cleanup : Actor = Actor.cast cleanup Signal }) +// decision: checks link notifications only after an observed child exit and a parent-handled checkpoint +let linkExitCase cancelChild = + actor { + let started, noticed, checkedEvents = barrier (), barrier (), barrier () + let handle, count = reporter None, reporter 0 + + let parent: Actor = + Actor.spawn (fun inbox -> + actor { + Actor.trapExits () + + let child = + Actor.spawnLinked inbox (fun child -> + actor { + Actor.cast started Signal + let! _ = child.Receive() + return () + }) + + Actor.cast handle (Some(Some child)) + + let rec loop n = + actor { + let! msg = inbox.Receive() + + match Actor.tryAsChildExited msg with + | Some _ -> + Actor.cast count (Some(n + 1)) + Actor.cast noticed Signal + return! loop (n + 1) + | None -> + Actor.cast count (Some n) + Actor.cast checkedEvents Signal + return! loop n + } + + return! loop 0 + }) + + do! await 1 started + let! child = Actor.call handle None + let child = Option.get child + + if cancelChild then + let! stopped = Actor.stop 2000 child + assertThat (completed stopped) isTrue + else + Actor.send child 1 + + do! observeExit child +#if FABLE_COMPILER_BEAM + if cancelChild then + do! await 1 noticed +#endif + Actor.send parent (box "checkpoint") + do! await 1 checkedEvents + let! received = Actor.call count None +#if FABLE_COMPILER_BEAM + assertThat received (isEqualTo (if cancelChild then 1 else 0)) +#else + assertThat received (isEqualTo 0) +#endif + let! _ = Actor.stop 2000 parent + let! _ = Actor.stop 2000 started + let! _ = Actor.stop 2000 noticed + let! _ = Actor.stop 2000 checkedEvents + let! _ = Actor.stop 2000 handle + let! _ = Actor.stop 2000 count + return () + } + let tests = testList ( "Lifecycle", [ + testAsync ("normal linked child exit is silent", fun _ -> toAsync (linkExitCase false)) + testAsync ("cancelled child exit follows the target link contract", fun _ -> toAsync (linkExitCase true)) testAsync ( "idle stop observes exit and repeated stop is safe", fun _ -> @@ -214,8 +287,7 @@ let tests = | Some child -> #if FABLE_COMPILER_BEAM // Monitoring is the worker-exit barrier; this does not issue another kill. - let monitor = Fable.Beam.Erlang.monitor child.Pid - Fable.Actor.Platform.waitProcessDeath monitor child.Pid + do! observeExit child assertThat (Fable.Beam.Erlang.isProcessAlive child.Pid) isFalse #else do! await 1 cleaned @@ -285,8 +357,7 @@ let tests = assertThat (completed result) isTrue #if FABLE_COMPILER_BEAM let replacement = Option.get replacement - let monitor = Fable.Beam.Erlang.monitor replacement.Pid - Fable.Actor.Platform.waitProcessDeath monitor replacement.Pid + do! observeExit replacement assertThat (Fable.Beam.Erlang.isProcessAlive replacement.Pid) isFalse #else do! await 2 cleaned @@ -360,6 +431,29 @@ let tests = } ) ) + testAsync ( + "pre-cancelled external lifetime does not evaluate an emulated actor body", + fun _ -> + toAsync ( + actor { + use cts = new CancellationTokenSource() + cts.Cancel() + let called = reporter 0 + + let worker: Actor = + Actor.spawnWithToken cts.Token (fun _ -> + Actor.cast called (Some 1) + actor { return () }) + + let! stopped = Actor.stop 2000 worker + assertThat (completed stopped) isTrue + let! value = Actor.call called None + assertThat value (isEqualTo 0) + let! _ = Actor.stop 2000 called + return () + } + ) + ) testAsync ( "external registration is released on normal exit", fun _ -> @@ -377,6 +471,61 @@ let tests = } ) ) + testAsync ( + "late terminal callbacks cannot repeat cleanup or resume actor work", + fun _ -> + toAsync ( + actor { + let started, cleaned, resumed = barrier (), barrier (), barrier () + let callbacks = reporter None + + let worker: Actor = + Actor.spawn (fun _ -> + actor { + try + let! _ = + Async.FromContinuations(fun (ok, _, cancelled) -> + Actor.cast + callbacks + (Some( + Some( + (fun () -> cancelled (System.OperationCanceledException())), + (fun () -> ok 1) + ) + )) + + Actor.cast started Signal) + + Actor.cast resumed Signal + finally + Actor.cast cleaned Signal + }) + + do! await 1 started + let! pending = Actor.call callbacks None + let cancel, reply = Option.get pending + cancel () + let! stopped = Actor.stop 2000 worker + assertThat (completed stopped) isTrue + do! await 1 cleaned + // .NET FromContinuations rejects duplicate callbacks; portable runtimes may deliver them. + try + reply () + with _ -> + () + + let! cleanCount = Actor.call cleaned Read + let! resumedCount = Actor.call resumed Read + assertThat cleanCount (isEqualTo 1) + assertThat resumedCount (isEqualTo 0) + let! _ = Actor.stop 2000 callbacks + let! _ = Actor.stop 2000 started + let! _ = Actor.stop 2000 cleaned + let! _ = Actor.stop 2000 resumed + return () + } + ) + ) testAsync ( "cleanup exception is reported", fun _ -> diff --git a/test/Main.fs b/test/Main.fs index 6f6354c..7ac57ec 100644 --- a/test/Main.fs +++ b/test/Main.fs @@ -18,4 +18,5 @@ let main _ = SupervisionTests.tests BuilderTests.tests LifecycleTests.tests + CallLivenessTests.tests ] From ce09f510b72336271ce154129e57dd239d8d8067 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Wed, 7 Oct 2026 21:35:48 +0200 Subject: [PATCH 4/7] docs: mark temporary Fable runtime workarounds --- src/Fable.Actor/Actor.fs | 14 ++++++++++++++ src/Fable.Actor/Lifecycle.fs | 25 +++++++++++++++++++++++++ src/Fable.Actor/Platform.fs | 12 ++++++++++++ test/Helpers.fs | 3 +++ test/LifecycleTests.fs | 4 ++++ 5 files changed, 58 insertions(+) diff --git a/src/Fable.Actor/Actor.fs b/src/Fable.Actor/Actor.fs index 84ce9a9..7c6cd3a 100644 --- a/src/Fable.Actor/Actor.fs +++ b/src/Fable.Actor/Actor.fs @@ -125,6 +125,9 @@ type Actor<'Msg> internal (mb: MailboxProcessor<'Msg>, cts: System.Threading.Can member internal _.Lifetime = lifetime member _.Pid: obj = box mb + // TODO(upstream): this check pairs with spawnOwned's synthetic cancellation wakeup. + // Retire both only after published JS/Python Receive wakes and cancels correctly; + // https://github.com/fable-compiler/Fable/pull/5038 documents this remaining mailbox gap. member _.Receive() : Async<'Msg> = Async.FromContinuations(fun (ok, error, cancelled) -> Async.StartWithContinuations( @@ -145,6 +148,9 @@ type Actor<'Msg> internal (mb: MailboxProcessor<'Msg>, cts: System.Threading.Can member _.Post(msg: 'Msg) = lifetime.Post(fun () -> mb.Post msg) type ActorBuilder() = + // TODO(upstream): https://github.com/fable-compiler/Fable/pull/5038 + // Revisit the extra delayed binder and Lifetime.guard calls after portable Bind/Delay + // stop on cancellation. Keep actor startup/late-callback cancellation regressions. // decision: delays user binders so a cancelled portable continuation cannot execute user code before its next token check member _.Bind(op: Async<'T>, f: 'T -> Async<'U>) : Async<'U> = async.Bind(op, fun value -> Lifetime.guard (fun () -> f value)) @@ -165,6 +171,11 @@ type ActorBuilder() = /// Run cleanup once and preserve its failure, including during cancellation. /// + /// TODO(upstream): https://github.com/fable-compiler/Fable/pull/5038 + /// Revisit the duplicate-terminal guard after the delay fix, but retain cleanup failure + /// reporting: standard Async preserves cancellation even when its finalizer throws. + /// Actor.stop must still report that cleanup failure, including on .NET. + /// /// decision: routes cleanup exceptions explicitly because standard Async finalizers can lose failures during cancellation member _.TryFinally(body: Async<'T>, compensation: unit -> unit) : Async<'T> = Lifetime.withContext (fun ((ok, error, cancelled), token) -> @@ -480,6 +491,9 @@ module Actor = new MailboxProcessor<'Msg>((fun _ -> async { return () }), cancellationToken = cts.Token) let inbox = Actor(mb, cts, lifetime) + // TODO(upstream): mailbox wakeup is not fixed by https://github.com/fable-compiler/Fable/pull/5038. + // Retire this synthetic post and Receive's cancellation check only when a published + // JS/Python mailbox wakes idle Receive on cancellation without delivering user work. // decision: wakes the portable mailbox on cancellation because JS/Python Receive does not register a wakeup // invariant: the synthetic wakeup never reaches user code because the workflow token is already cancelled lifetime.AddResource(Lifetime.cancellation cts.Token (fun () -> mb.Post Unchecked.defaultof<'Msg>)) diff --git a/src/Fable.Actor/Lifecycle.fs b/src/Fable.Actor/Lifecycle.fs index e4aefc2..9566579 100644 --- a/src/Fable.Actor/Lifecycle.fs +++ b/src/Fable.Actor/Lifecycle.fs @@ -6,8 +6,14 @@ open System open System.Threading open Fable.Actor.Types +// TODO(upstream): retire the marked adapters only after the pinned compiler and runtime +// include the referenced fixes and the four-target suite passes without those adapters. +// Pending settlement and ActorLifetime ownership remain library responsibilities. module internal Lifetime = #if FABLE_COMPILER_PYTHON + // TODO(upstream): https://github.com/fable-compiler/Fable/pull/5035 + // Replace this Python gate branch with obj()/lock and remove Platform's gate helpers + // once the published Python runtime shares a stable lock per object. let newGate = Platform.newGate // decision: owns a stable RLock because fable-library 5.19 util.lock creates a fresh lock for each invocation let synchronize gate action = @@ -24,6 +30,10 @@ module internal Lifetime = let withContext body : Async<'T> = #if FABLE_COMPILER_PYTHON + // TODO(upstream): https://github.com/fable-compiler/Fable/pull/5037 + // and https://github.com/fable-compiler/Fable/pull/5038 + // Use the ordinary Async.CancellationToken/FromContinuations branch after unit + // continuations and terminal cancellation are fixed; then delete Platform.fromContext. Platform.fromContext body #else async { @@ -34,6 +44,9 @@ module internal Lifetime = let guard (expression: unit -> Async<'T>) : Async<'T> = #if FABLE_COMPILER_PYTHON + // TODO(upstream): https://github.com/fable-compiler/Fable/pull/5038 + // Collapse this Python guard to async.Delay after cancelled Delay/Bind no longer + // run user work or receive a second terminal callback from a cancelled delay. withContext (fun ((ok, error, cancelled), token) -> let gate = newGate () let mutable finished = false @@ -118,6 +131,9 @@ module internal Lifetime = let cancellation (token: CancellationToken) callback = #if FABLE_COMPILER_PYTHON + // TODO(upstream): https://github.com/fable-compiler/Fable/pull/5036 + // Use token.Register like the other targets once it returns a disposable handle + // and safely handles disposal during cancellation; delete the listener-field adapters. let id = Platform.addCancellationListener token callback let registration = @@ -125,6 +141,9 @@ module internal Lifetime = #else let registration = token.Register(Action callback) :> IDisposable #endif + // TODO(upstream): https://github.com/fable-compiler/Fable/pull/5036 + // Remove the post-registration inspection once published JS/Python Register invokes + // already-cancelled callbacks immediately; keep the pending operation's settlement gate. // Fable JS/Python Register does not invoke an already-cancelled token's callback. // invariant: registration followed by inspection covers cancellation before and during acquisition if token.IsCancellationRequested then @@ -134,6 +153,9 @@ module internal Lifetime = let deadline (ms: int) callback = #if FABLE_COMPILER_PYTHON + // TODO(upstream): https://github.com/fable-compiler/Fable/pull/5038 + // Reuse the CTS/Async.Sleep branch once cancellation retires the Python timer + // and listener exactly once; remove Platform.startDeadline/cancelDeadline with it. let handle = Platform.startDeadline ms callback disposable (fun () -> Platform.cancelDeadline handle) #else @@ -141,6 +163,9 @@ module internal Lifetime = Async.StartWithContinuations( async { do! Async.Sleep ms }, + // TODO(upstream): https://github.com/fable-compiler/Fable/pull/5037 + // This explicit value parameter avoids unit callbacks whose captured default + // gets overwritten by None in Python; simplify only after generated-code validation. (fun value -> ignore value callback ()), diff --git a/src/Fable.Actor/Platform.fs b/src/Fable.Actor/Platform.fs index 390cceb..20876e9 100644 --- a/src/Fable.Actor/Platform.fs +++ b/src/Fable.Actor/Platform.fs @@ -159,6 +159,9 @@ let stopProcess (pid: Pid<'Msg>) (deadline: int) : bool = open Fable.Core +// TODO(upstream): https://github.com/fable-compiler/Fable/pull/5036 +// Replace addCancellationListener/removeCancellationListener/cancellationGate with the +// public disposable token.Register API when Lifetime.cancellation retires its Python branch. // decision: uses native registration handles because fable-library 5.19 Register omits its return value // decision: replaces the listener dictionary on removal so disposal during Cancel does not mutate its live iterator // invariant: removing an actor registration leaves other token listeners installed @@ -168,6 +171,8 @@ let addCancellationListener (token: System.Threading.CancellationToken) (callbac [] let removeCancellationListener (token: System.Threading.CancellationToken) (id: int) : unit = nativeOnly +// TODO(upstream): https://github.com/fable-compiler/Fable/pull/5035 +// Delete newGate/enterGate/leaveGate when Lifetime.synchronize uses the fixed runtime lock. [] let newGate () : obj = nativeOnly @@ -180,6 +185,9 @@ let leaveGate (gate: obj) : unit = nativeOnly [] let cancellationGate (token: System.Threading.CancellationToken) : obj = nativeOnly +// TODO(upstream): https://github.com/fable-compiler/Fable/pull/5038 +// Delete startDeadline/cancelDeadline after Lifetime.deadline uses fixed Async.Sleep; +// retain positive deadlines and disposal of the actor-owned timer on every settlement. // decision: retains the asyncio timer handle so settlement cancels the timer rather than only suppressing its callback [] let startDeadline (ms: int) (callback: unit -> unit) : obj = nativeOnly @@ -239,6 +247,10 @@ let endCall (alias: Ref) (monitor: Ref>) : unit = /// Build a context-aware Async without the upstream cancellation fall-through wrapper. /// +/// TODO(upstream): https://github.com/fable-compiler/Fable/pull/5037 +/// and https://github.com/fable-compiler/Fable/pull/5038 +/// Delete this raw-context adapter when Lifetime.withContext uses standard Async on Python. +/// /// decision: hands cancellation to the actor settlement gate because fable-library 5.19 protected_cont continues after on_cancel [] let fromContext diff --git a/test/Helpers.fs b/test/Helpers.fs index 7eecd5b..cb8b52a 100644 --- a/test/Helpers.fs +++ b/test/Helpers.fs @@ -76,6 +76,9 @@ module Helpers = Lifetime.Pending(fun result -> match result with | Some ex -> error ex + // TODO(upstream): https://github.com/fable-compiler/Fable/pull/5037 + // Replace the dummy int with unit once Python unit continuations + // accept ok (); retain this independent observed-exit barrier. | None -> ok 1) pending.Add( diff --git a/test/LifecycleTests.fs b/test/LifecycleTests.fs index ade6286..8129926 100644 --- a/test/LifecycleTests.fs +++ b/test/LifecycleTests.fs @@ -406,6 +406,8 @@ let tests = try let! _ = Async.FromContinuations(fun (ok, _, _) -> + // TODO(upstream): https://github.com/fable-compiler/Fable/pull/5037 + // Use ok () after the unit-continuation fix; keep the release barrier. Actor.cast releaseHandle (Some(Some(fun () -> ok 1))) Actor.cast started Signal) @@ -485,6 +487,8 @@ let tests = try let! _ = Async.FromContinuations(fun (ok, _, cancelled) -> + // TODO(upstream): https://github.com/fable-compiler/Fable/pull/5037 + // Use a unit success payload after the fix; keep the duplicate-callback probe. Actor.cast callbacks (Some( From 832bc86ff8cab224e3b4c1e7a98de0d690037ca1 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Wed, 7 Oct 2026 21:54:09 +0200 Subject: [PATCH 5/7] docs: shorten actor lifecycle guide --- README.md | 155 +++++++++++++++++------------------------------------- 1 file changed, 49 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index 65c6ba8..6be91e5 100644 --- a/README.md +++ b/README.md @@ -82,10 +82,8 @@ MailboxProcessor.Start (fun inbox -> async { ... }) // MailboxProcessor spawn (fun inbox -> actor { ... }) // Fable.Actor ``` -- **`spawn`** *launches* an actor — it starts a long-running process with a mailbox and returns an `Actor<'Msg>` handle. It is the equivalent of `MailboxProcessor.Start`. On .NET/Python/JS it wraps `MailboxProcessor.Start`; on BEAM it wraps `Erlang.spawn`. -- **`actor { }`** *describes the body* — the receive loop you pass to `spawn`. It is the equivalent of `async { }`. On .NET/Python/JS it **is** `async` (`ActorOp<'T> = Async<'T>`, and every CE member delegates straight to the `async` builder); on BEAM it compiles to a CPS-based blocking receive instead, since the BEAM has no async runtime. - -In short: `actor` is to `spawn` what `async` is to `MailboxProcessor.Start`. The only reason `actor` exists rather than reusing `async` is BEAM — on the other three targets it's a transparent passthrough. +- **`spawn`** launches the actor and returns its handle: `MailboxProcessor` on .NET/Python/JS, a native process on BEAM. +- **`actor { }`** describes its body. Operations use `Async` on .NET/Python/JS, with actor cancellation and cleanup guards; BEAM uses a CPS computation with native blocking receive. ### Supervision @@ -145,11 +143,10 @@ schedule 1000 (fun () -> cast ticker "tick") |> ignore ```text src/Fable.Actor/ - Types.fs — ReplyChannel, Next<'State>, ChildExited, Directive, Strategy - Platform.fs — BEAM: IActorPlatform + [] - Non-BEAM: empty (uses MailboxProcessor directly) - Actor.fs — actor { }, spawn, spawnLinked, start, send, call, kill, schedule - erl/ — BEAM platform implementation (native processes) + Types.fs — Messages, supervision, call and shutdown results + Platform.fs — Native BEAM protocol primitives and Python runtime adapters + Lifecycle.fs — Emulated actor ownership and pending-operation settlement + Actor.fs — Actor computation expression and public APIs ``` ### Platform Strategy @@ -161,27 +158,66 @@ src/Fable.Actor/ | JS | `MailboxProcessor` (Fable) | Promises | | BEAM | Native process | Erlang processes + mailbox | -On non-BEAM targets, `Actor<'Msg>` is a thin wrapper around `MailboxProcessor<'Msg>`. No platform-specific runtime needed — Fable's built-in `MailboxProcessor` handles everything. On BEAM, actors map to real Erlang processes with native supervision. +Non-BEAM actors use `MailboxProcessor` with an owned cooperative lifetime. +BEAM actors use native processes, links, and monitors. ### API -| Function | Description | +| Function | Description | | ------------------------------------------ | ---------------------------------------------------- | | `spawn body` | Spawn an actor: `spawn (fun inbox -> actor { ... })` | +| `spawnWithToken token body` | Spawn with an external cancellation token | | `spawnLinked parent body` | Spawn a linked child actor (EXIT on crash) | | `spawnSupervised parent strategy body` | Spawn a child with supervision (auto-restart) | -| `handleChildExit parent supervised exited` | Return a replacement child or `Stopped` | +| `handleChildExit parent supervised exited` | Return a replacement child or `Stopped` | | `tryAsChildExited msg` | Check if a message is a `ChildExited` notification | | `start state handler` | Stateful actor with message handler loop | | `send actor msg` | Fire-and-forget message send | | `cast actor msg` | Fire-and-forget to a call-capable actor | | `call actor msg` | Async request-response (returns `ActorOp<'Reply>`) | +| `callAsync actor msg` | Request-response from an Async expression | +| `callResult ms token actor msg` | Bounded call with an explicit result | +| `callResultAsync ms token actor msg` | Bounded result from an Async expression | | `callWithTimeout ms actor msg` | Like `call` but raises `TimeoutException` on expiry | -| `kill actor` | Kill an actor immediately | +| `callAsyncWithTimeout ms actor msg` | Bounded reply from an Async expression | +| `kill actor` | Request actor shutdown | +| `stop ms actor` / `stopAsync ms actor` | Request shutdown and await observed exit | | `trapExits ()` | Enable supervision (EXIT signals become messages) | | `schedule ms callback` | Schedule a timer callback | | `cancelTimer timer` | Cancel a scheduled timer | +### Shutdown and bounded calls + +Deadlines are positive milliseconds, validated before sending a request or +starting shutdown. + +`stop`/`stopAsync` return `Completed exit` or `TimedOut`; cleanup failures appear +as `ActorExit.Failed`. On .NET, JS, and Python, cancellation wakes idle receives +and stops cooperative work. Completion observes workflow, actor-expression +cleanup, and owned-child exit. Repeated stops share cleanup; a timeout leaves +unfinished work owned. On BEAM, stop confirms native process death and skips +finalizers. Await each current worker if disposal must confirm the whole tree +has exited. + +Linked children stop on any parent exit, including normal completion. Child +crashes notify emulated parents; BEAM retains native link/trap-exit behavior. +Keep replacement children returned by `handleChildExit`. + +`callResult`/`callResultAsync` return `Reply`, `TimedOut`, `TargetTerminated`, or +`Cancelled`. Existing `call`/`callAsync` deadlines remain unbounded, but target +termination and caller cancellation now settle their waits. Calls settle once +and ignore late replies. Timeout and cancellation do not undo delivered work; +requests are never automatically retried. Await replies to preserve backpressure. + +For subscription disposal, close upstream admission, cancel the token used by +pending calls, then await stop and inspect its result. Emulated calls settle +when shutdown closes admission; that notification alone does not confirm exit. + +**Compatibility:** emulated handles now come from spawn APIs rather than record +literals. Timeout calls deliver when run. Arbitrary supplied Async keeps its +runtime semantics: Python 5.19 Async.Sleep can retain cancelled callbacks until +their deadline. Source `TODO(upstream)` comments identify removable adapters. + ### Design Principles - **Actor is the only abstraction** — no Observable, Observer, or Rx types @@ -216,96 +252,3 @@ MIT - [FSharp.Control.AsyncRx](https://github.com/dbrattli/AsyncRx) — Async Reactive Extensions for F# - [Fable](https://github.com/fable-compiler/Fable) — F# to JS/Python/BEAM compiler - [Fable.Beam](https://github.com/fable-compiler/Fable.Beam) — F# bindings (FFI) for BEAM/Erlang - -## Owned shutdown - -`Actor.kill` requests shutdown. `Actor.stop cleanupDeadline actor` (or -`stopAsync`) requests it and returns `StopResult.Completed exit` or `TimedOut`. -The deadline must be a positive number of milliseconds and is validated before -shutdown begins. Repeated and concurrent requests share the same lifecycle. - -On .NET, JavaScript, and Python, spawn, spawnLinked, and spawnWithToken run with -an owned cancellation token. Idle receives wake, cooperative Async work cancels, -and actor-expression `finally`/`use` cleanup runs once. Completion observes the -workflow and its owned linked descendants exiting. Cleanup failures are returned -as `ActorExit.Failed`; a timeout leaves unfinished work owned and does not force -it to exit. Synchronous user code can block the target scheduler and delay both -cancellation and deadline observation. Finalizers inside separately supplied -Async computations retain that runtime's semantics. - -On BEAM, stop monitors native process death after kill. It does not promise -finalizers, graceful cleanup, or completed descendant shutdown at the instant -that the parent dies. An external token requests native kill. A process watcher -releases its registration after death. Use an application shutdown message for -graceful cleanup before native stop, or OTP supervision for shutdown escalation. - -`spawnLinked` now establishes parent ownership on every target: parent shutdown, -normal completion, and failure all shut down the child. Native BEAM links also -retain their usual symmetric abnormal-exit behavior: an abnormal or killed child -terminates a parent that is not trapping exits; a trapping parent receives -`ChildExited`. Normal child exits are suppressed by the actor receive protocol. -Emulated links preserve their supervision default: only abnormal child exit -emits `ChildExited`, and it does not automatically terminate the parent. Normal -and cooperative cancelled child exits are silent. The supplied BEAM parent is -now the link endpoint even when spawnLinked is invoked by another process. - -This ownership is stronger than a raw BEAM link, which alone does not terminate -children on normal parent exit. Keep replacement children returned by -`handleChildExit` (the rc.12 API) so later shutdown addresses the current -generation. Actor handles on emulated targets are now constructed by spawn APIs -rather than public record literals; Mb and Cts remain available for existing -interop, but lifecycle operations must go through Actor APIs. - -## Bounded calls and subscription disposal - -`Actor.callResult replyDeadline cancellationToken actor message` returns -`CallResult.Reply value`, `TimedOut`, `TargetTerminated exit`, or `Cancelled`. -`callResultAsync` provides the same protocol from an Async expression. -`callWithTimeout` and the new `callAsyncWithTimeout` return the reply directly; -they raise `TimeoutException`, `TargetTerminatedException`, or cancellation on -failure. Deadlines are positive milliseconds, validated before request delivery. - -`call` and `callAsync` retain rc.11's unbounded reply deadline. They now settle -on target shutdown/death and ambient caller cancellation instead of hanging on -those paths. The timeout APIs now deliver requests when the operation runs, -rather than when an emulated call operation is merely constructed. - -On emulated targets, shutdown closes admission and settles pending calls at the -stop request, even if noncooperative work has not exited yet. The reported reason -is `Cancelled` in that case; normal workflow completion and crashes report their -observed reason when they close admission. This notification is not confirmation -of completed cleanup: await `stop` for that guarantee. On BEAM, monitors observe -actual process death. A call to an already dead PID reports the native `noproc` -reason, not its historical exit reason. Native stop likewise confirms death but -does not retain a previous exit reason; it reports `Completed Cancelled` for the -issued kill operation. - -A call settles once and detaches its timer, target observer/monitor, and caller -registrations. BEAM aliases revoke the reply destination and flush queued -matching replies; subsequent late replies never enter the mailbox. Emulated -reply callbacks become inert and retain no caller continuation or reply value. -Neither timeout nor cancellation retracts a delivered request or proves that -processing was undone. There is no automatic retry. Await each reply before -submitting the next item when request/reply pacing supplies your backpressure; -fire-and-forget sends retain their existing unbounded queue behavior. - -A downstream subscription can own a parent actor and create its workers with -`spawnLinked` or `spawnSupervised`. Keep each replacement returned by -`handleChildExit`. Give pending `callResultAsync` operations the subscription's -cancellation token. During disposal, close upstream admission, cancel that token -to settle the outstanding calls, then await `stopAsync` on the parent and check -for timeout or cleanup failure. On BEAM, explicitly await each current worker's -stop as well if disposal must confirm the entire tree is dead; parent death alone -does not confirm completed descendant shutdown. A synchronous IDisposable can -request cancellation; an async disposal path must own the await and report its -result. Notification suppression alone is not worker termination. - -Actor-owned Python cancellation registrations, locks, and deadline timers use -small platform adapters for gaps in fable-library 5.19.0. Actor binders check -cancellation before resuming user work, and actor-expression cleanup ignores -duplicate terminal callbacks. Separately supplied Async computations keep their -runtime behavior: in particular, Python Async.Sleep cancellation still leaves -its underlying scheduled callback until its due time. Actor-owned call/stop -**deadline timers** are cancelled physically; arbitrary user Async resources -cannot receive that same guarantee from this library. The upstream work is -captured in [the Fable runtime fix prompt](docs/fable-runtime-fix-prompt.md). From e1a6c961e2552331b0354e2ef4df2dd4dba3ace3 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Wed, 7 Oct 2026 22:32:00 +0200 Subject: [PATCH 6/7] fix: preserve actor startup and lifecycle semantics --- src/Fable.Actor/Actor.fs | 60 ++++++-- src/Fable.Actor/Lifecycle.fs | 60 +++++++- src/Fable.Actor/Platform.fs | 61 +++++++- test/Helpers.fs | 29 ++++ test/LifecycleTests.fs | 262 +++++++++++++++++++++++++++++++++++ 5 files changed, 450 insertions(+), 22 deletions(-) diff --git a/src/Fable.Actor/Actor.fs b/src/Fable.Actor/Actor.fs index 7c6cd3a..42d3473 100644 --- a/src/Fable.Actor/Actor.fs +++ b/src/Fable.Actor/Actor.fs @@ -129,6 +129,19 @@ type Actor<'Msg> internal (mb: MailboxProcessor<'Msg>, cts: System.Threading.Can // Retire both only after published JS/Python Receive wakes and cancels correctly; // https://github.com/fable-compiler/Fable/pull/5038 documents this remaining mailbox gap. member _.Receive() : Async<'Msg> = +#if FABLE_COMPILER_PYTHON || FABLE_COMPILER_JAVASCRIPT + // decision: reuses the workflow trampoline so recursive immediately completed receives remain stack safe + Lifetime.withContextRun (fun ((ok, error, cancelled), _) run -> + run + (mb.Receive()) + ((fun msg -> + if cts.IsCancellationRequested then + cancelled (System.OperationCanceledException()) + else + ok msg), + error, + cancelled)) +#else Async.FromContinuations(fun (ok, error, cancelled) -> Async.StartWithContinuations( mb.Receive(), @@ -141,6 +154,7 @@ type Actor<'Msg> internal (mb: MailboxProcessor<'Msg>, cts: System.Threading.Can cancelled, cts.Token )) +#endif /// Queue a message while the actor accepts work. /// @@ -151,9 +165,14 @@ type ActorBuilder() = // TODO(upstream): https://github.com/fable-compiler/Fable/pull/5038 // Revisit the extra delayed binder and Lifetime.guard calls after portable Bind/Delay // stop on cancellation. Keep actor startup/late-callback cancellation regressions. - // decision: delays user binders so a cancelled portable continuation cannot execute user code before its next token check + // decision: delays portable user binders so a cancelled continuation cannot execute user code before its next token check member _.Bind(op: Async<'T>, f: 'T -> Async<'U>) : Async<'U> = +#if FABLE_COMPILER async.Bind(op, fun value -> Lifetime.guard (fun () -> f value)) +#else + // invariant: resource-producing binders enter their finalizer before the next cancellation check + async.Bind(op, f) +#endif member _.Return(value: 'T) : Async<'T> = async.Return(value) member _.ReturnFrom(op: Async<'T>) : Async<'T> = async.ReturnFrom(op) @@ -178,7 +197,10 @@ type ActorBuilder() = /// /// decision: routes cleanup exceptions explicitly because standard Async finalizers can lose failures during cancellation member _.TryFinally(body: Async<'T>, compensation: unit -> unit) : Async<'T> = - Lifetime.withContext (fun ((ok, error, cancelled), token) -> +#if !FABLE_COMPILER + Lifetime.finallyNative body compensation +#else + Lifetime.withContextRun (fun ((ok, error, cancelled), token) run -> let gate = Lifetime.newGate () let mutable finished = false @@ -207,11 +229,12 @@ type ActorBuilder() = if token.IsCancellationRequested then finish cancelled (System.OperationCanceledException()) else - Async.StartWithContinuations(body, finish ok, finish error, finish cancelled, token)) + run body (finish ok, finish error, finish cancelled)) +#endif member this.Using(resource: 'a :> System.IDisposable, body: 'a -> Async<'T>) : Async<'T> = this.TryFinally( - body resource, + this.Delay(fun () -> body resource), fun () -> if not (isNull (box resource)) then resource.Dispose() @@ -503,13 +526,28 @@ module Actor = lifetime.AddResource(Lifetime.cancellation token lifetime.RequestStop) attach inbox - Async.StartWithContinuations( - Lifetime.guard (fun () -> body inbox), - (fun () -> lifetime.Complete ActorExit.Normal), - (fun ex -> lifetime.Complete(ActorExit.Failed ex)), - (fun _ -> lifetime.Complete ActorExit.Cancelled), - cts.Token - ) + let workflow = Lifetime.guard (fun () -> body inbox) + + let start () = + Async.StartWithContinuations( + workflow, + (fun () -> lifetime.Complete ActorExit.Normal), + (fun ex -> lifetime.Complete(ActorExit.Failed ex)), +#if FABLE_COMPILER + (fun _ -> lifetime.Complete ActorExit.Cancelled), +#else + (fun ex -> lifetime.Complete(Lifetime.cancelledExit ex)), +#endif + cts.Token + ) + +#if FABLE_COMPILER + start () +#else + // decision: queues startup independently so synchronous actor work cannot block the spawning thread + System.Threading.ThreadPool.QueueUserWorkItem(fun _ -> start ()) + |> ignore +#endif inbox diff --git a/src/Fable.Actor/Lifecycle.fs b/src/Fable.Actor/Lifecycle.fs index 9566579..42b571b 100644 --- a/src/Fable.Actor/Lifecycle.fs +++ b/src/Fable.Actor/Lifecycle.fs @@ -29,11 +29,12 @@ module internal Lifetime = #endif let withContext body : Async<'T> = -#if FABLE_COMPILER_PYTHON +#if FABLE_COMPILER_PYTHON || FABLE_COMPILER_JAVASCRIPT // TODO(upstream): https://github.com/fable-compiler/Fable/pull/5037 // and https://github.com/fable-compiler/Fable/pull/5038 - // Use the ordinary Async.CancellationToken/FromContinuations branch after unit - // continuations and terminal cancellation are fixed; then delete Platform.fromContext. + // Retire the Python adapter after unit continuations and terminal cancellation are fixed. + // TODO(upstream): native JS TryFinally also checks cancellation before installing + // compensation. Retire this adapter only when cancelled resource acquisition disposes. Platform.fromContext body #else async { @@ -42,12 +43,58 @@ module internal Lifetime = } #endif + let withContextRun body : Async<'T> = +#if FABLE_COMPILER_PYTHON || FABLE_COMPILER_JAVASCRIPT + Platform.fromContextWithRun body +#else + withContext (fun (continuations, token) -> + body (continuations, token) (fun op (ok, error, cancelled) -> Async.StartWithContinuations(op, ok, error, cancelled, token))) +#endif + +#if !FABLE_COMPILER + let private cleanupFailureKey = obj () + + // decision: carries cleanup failure on the cancellation signal because .NET TryCancelled preserves cancellation even when its callback throws + let private recordCleanupFailure (cancelled: OperationCanceledException) failure = + cancelled.Data[cleanupFailureKey] <- failure + + let cancelledExit (cancelled: OperationCanceledException) = + match cancelled.Data[cleanupFailureKey] with + | :? exn as failure -> ActorExit.Failed failure + | _ -> ActorExit.Cancelled + + // invariant: cancellation cleanup is installed before any ambient cancellation checkpoint + // decision: uses .NET's cancellation handler for cancellation and explicit continuations for other exits to preserve cleanup failures + let finallyNative (body: Async<'T>) compensation = + Async.TryCancelled( + withContext (fun ((ok, error, cancelled), token) -> + let finish next value = + let failure = + try + compensation () + None + with ex -> + Some ex + + match failure with + | Some ex -> error ex + | None -> next value + + Async.StartWithContinuations(body, finish ok, finish error, cancelled, token)), + fun cancelled -> + try + compensation () + with ex -> + recordCleanupFailure cancelled ex + ) +#endif + let guard (expression: unit -> Async<'T>) : Async<'T> = #if FABLE_COMPILER_PYTHON // TODO(upstream): https://github.com/fable-compiler/Fable/pull/5038 // Collapse this Python guard to async.Delay after cancelled Delay/Bind no longer // run user work or receive a second terminal callback from a cancelled delay. - withContext (fun ((ok, error, cancelled), token) -> + withContextRun (fun ((ok, error, cancelled), token) run -> let gate = newGate () let mutable finished = false @@ -74,7 +121,7 @@ module internal Lifetime = match operation with | Choice2Of2 ex -> finish error ex - | Choice1Of2 op -> Async.StartWithContinuations(op, finish ok, finish error, finish cancelled, token)) + | Choice1Of2 op -> run op (finish ok, finish error, finish cancelled)) #else async.Delay expression #endif @@ -270,7 +317,8 @@ type internal ActorLifetime(cts: CancellationTokenSource) = // invariant: delivery and admission closure are serialized so Post cannot race mailbox disposal member _.Post(deliver: unit -> unit) = Lifetime.synchronize gate (fun () -> - if not stopping then + // decision: checks the owned token because callers can cancel the public CTS without requesting lifecycle shutdown + if not stopping && not cts.IsCancellationRequested then deliver ()) /// Observe closure of request admission, independently of descendant cleanup. diff --git a/src/Fable.Actor/Platform.fs b/src/Fable.Actor/Platform.fs index 20876e9..1c576b4 100644 --- a/src/Fable.Actor/Platform.fs +++ b/src/Fable.Actor/Platform.fs @@ -118,13 +118,28 @@ let timerCancel (timer: obj) : unit = /// /// decision: uses an ownership watcher because native links alone leave children alive after normal parent exit /// invariant: the watcher exits when either endpoint dies and kills the child on every parent death +/// invariant: the supplied parent link exists before the child handle is returned while both endpoints remain alive +/// decision: uses atomic spawn_link for the caller and a monitored startup handshake for a different supplied parent let spawnOwnedProcess (parent: Pid<'Parent>) (body: unit -> unit) : Pid<'Msg> = emitErlExpr (parent, body) """ (fun() -> Parent = $0, - Child = spawn(fun() -> link(Parent), $1(ok) end), + Caller = self(), + Child = case Parent =:= Caller of + true -> spawn_link(fun() -> $1(ok) end); + false -> + Ready = make_ref(), + {Pid, M} = spawn_monitor(fun() -> + link(Parent), Caller ! {Ready, self()}, $1(ok) + end), + receive + {Ready, Pid} -> demonitor(M, [flush]); + {'DOWN', M, process, Pid, _} -> ok + end, + Pid + end, spawn(fun() -> PM = monitor(process, Parent), CM = monitor(process, Child), receive @@ -243,19 +258,55 @@ let endCall (alias: Ref) (monitor: Ref>) : unit = #endif -#if FABLE_COMPILER_PYTHON +#if FABLE_COMPILER_PYTHON || FABLE_COMPILER_JAVASCRIPT + +open Fable.Core -/// Build a context-aware Async without the upstream cancellation fall-through wrapper. +/// Build a context-aware Async without a preceding cancellation checkpoint. /// /// TODO(upstream): https://github.com/fable-compiler/Fable/pull/5037 /// and https://github.com/fable-compiler/Fable/pull/5038 -/// Delete this raw-context adapter when Lifetime.withContext uses standard Async on Python. +/// Delete the Python branch when Lifetime.withContext uses standard Async on Python. +/// TODO(upstream): retire the JS branch once native TryFinally installs compensation +/// before checking cancellation; the resource-acquisition regressions must still pass. /// -/// decision: hands cancellation to the actor settlement gate because fable-library 5.19 protected_cont continues after on_cancel +/// decision: hands cancellation to the actor settlement gate so acquired resources receive cleanup even when their token is already cancelled +#if FABLE_COMPILER_PYTHON [] +#else +[ $0([[ctx.onSuccess, ctx.onError, ctx.onCancel], ctx.cancelToken])")>] +#endif let fromContext (body: (('T -> unit) * (exn -> unit) * (System.OperationCanceledException -> unit)) * System.Threading.CancellationToken -> unit) : Async<'T> = nativeOnly #endif + +#if FABLE_COMPILER_PYTHON || FABLE_COMPILER_JAVASCRIPT + +/// Run a nested Async using the incoming trampoline and guarded continuations. +/// +/// TODO(upstream): https://github.com/fable-compiler/Fable/pull/5038 +/// Delete the Python branch with the guard/finalizer adapters once native Delay/Bind +/// and TryFinally preserve cancellation and exactly-once cleanup, and Receive uses +/// the native mailbox workflow without a separate async root. +/// TODO(upstream): retire the JS branch when native TryFinally installs cleanup before +/// cancellation and Receive honors owned cancellation without its current wrapper. +/// +/// decision: preserves the incoming trampoline so immediately completed actor steps still yield before exhausting the stack +/// invariant: terminal continuation chains also yield through the shared trampoline +#if FABLE_COMPILER_PYTHON +[] +#else +[ $0([[ctx.onSuccess, ctx.onError, ctx.onCancel], ctx.cancelToken], op => cs => { const callbacks = cs.map(next => value => { const resume = () => next(value); if (ctx.trampoline.incrementAndCheck()) ctx.trampoline.hijack(resume); else resume(); }); op({...ctx, onSuccess: callbacks[0], onError: callbacks[1], onCancel: callbacks[2]}); })")>] +#endif +let fromContextWithRun + (body: + (('T -> unit) * (exn -> unit) * (System.OperationCanceledException -> unit)) * System.Threading.CancellationToken + -> (Async<'T> -> (('T -> unit) * (exn -> unit) * (System.OperationCanceledException -> unit)) -> unit) + -> unit) + : Async<'T> = + nativeOnly + +#endif diff --git a/test/Helpers.fs b/test/Helpers.fs index cb8b52a..de0a895 100644 --- a/test/Helpers.fs +++ b/test/Helpers.fs @@ -32,6 +32,22 @@ module Helpers = [] let monitorCount () : int = nativeOnly + [] + let highPriority () : obj = nativeOnly + + [] + let restorePriority (priority: obj) : unit = nativeOnly + + [] + let private linkedPid (pid: Fable.Beam.Pid<'Msg>) : bool = nativeOnly + + let isLinked (target: Actor<'Msg>) = linkedPid target.Pid + + [] + let private linkedToPid (parent: Fable.Beam.Pid<'Parent>) (child: Fable.Beam.Pid<'Msg>) : bool = nativeOnly + + let isLinkedTo (parent: Actor<'Parent>) (child: Actor<'Msg>) = linkedToPid parent.Pid child.Pid + [ ObservedPid = $0, M = monitor(process, ObservedPid), receive {'DOWN', M, process, ObservedPid, _} -> true after 2000 -> demonitor(M, [flush]), false end end)()")>] let private observedDeath (pid: Fable.Beam.Pid<'Msg>) : bool = nativeOnly @@ -58,15 +74,28 @@ module Helpers = #else #if FABLE_COMPILER_PYTHON + [] + let private mailboxQueueLength (mailbox: MailboxProcessor<'Msg>) : int = nativeOnly + [] let tokenListenerCount (token: System.Threading.CancellationToken) : int = nativeOnly #else #if FABLE_COMPILER_JAVASCRIPT + [] + let private mailboxQueueLength (mailbox: MailboxProcessor<'Msg>) : int = nativeOnly + [] let tokenListenerCount (token: System.Threading.CancellationToken) : int = nativeOnly #endif #endif + let mailboxLength (target: Actor<'Msg>) = +#if FABLE_COMPILER + mailboxQueueLength target.Mb +#else + target.Mb.CurrentQueueLength +#endif + /// Observe workflow and descendant exit without requesting shutdown. let observeExit (target: Actor<'Msg>) : ActorOp = actor { diff --git a/test/LifecycleTests.fs b/test/LifecycleTests.fs index 8129926..ab148df 100644 --- a/test/LifecycleTests.fs +++ b/test/LifecycleTests.fs @@ -127,6 +127,170 @@ let tests = testList ( "Lifecycle", [ + testAsync ( + "immediate receives preserve a bounded message-loop stack", + fun _ -> + toAsync ( + actor { + let doneEvents = barrier () + + let worker: Actor = + Actor.spawn (fun inbox -> + let rec loop () = + actor { + let! remaining = inbox.Receive() + + if remaining = 0 then + Actor.cast doneEvents Signal + else + inbox.Post(remaining - 1) + return! loop () + } + + inbox.Post 10000 + loop ()) + + do! await 1 doneEvents + do! observeExit worker + let! result = Actor.stop 2000 worker +#if FABLE_COMPILER_BEAM + assertThat (completed result) isTrue +#else + assertThat result (isEqualTo (StopResult.Completed ActorExit.Normal)) +#endif + let! _ = Actor.stop 2000 doneEvents + return () + } + ) + ) +#if !FABLE_COMPILER + testAsync ( + "spawn returns while the child waits synchronously for its caller", + fun _ -> + async { + use release = new ManualResetEventSlim(false) + let mutable released = false + + let worker: Actor = + Actor.spawn (fun _ -> actor { released <- release.Wait 2000 }) + + release.Set() + do! observeExit worker + assertThat released isTrue + } + ) + testAsync ( + "a resource-producing bind installs disposal before cancellation", + fun _ -> + async { + let cleaned = barrier () + + let worker: Actor = + Actor.spawn (fun inbox -> + actor { + let! resource = + Async.FromContinuations(fun (ok, _, _) -> + let resource = { + new System.IDisposable with + member _.Dispose() = Actor.cast cleaned Signal + } + + Actor.kill inbox + ok resource) + + use _resource = resource + return () + }) + + do! observeExit worker + let! result = Actor.stop 2000 worker + assertThat result (isEqualTo (StopResult.Completed ActorExit.Cancelled)) + let! count = Actor.call cleaned Read + assertThat count (isEqualTo 1) + let! _ = Actor.stop 2000 cleaned + return () + } + ) +#endif +#if FABLE_COMPILER_BEAM + testAsync ( + "linked child is linked before its handle is returned", + fun _ -> + toAsync ( + actor { + let noticed = barrier () + + let parent: Actor = + Actor.spawn (fun inbox -> + actor { + Actor.trapExits () + let priority = highPriority () + + try + for _ in 1..100 do + let child: Actor = + Actor.spawnLinked inbox (fun child -> actor { let! _ = child.Receive() in return () }) + + assertThat (isLinked child) isTrue + Actor.kill child + let! msg = inbox.Receive() + + match Actor.tryAsChildExited msg with + | Some _ -> () + | None -> failwith "Immediate child kill did not notify its parent" + + Actor.cast noticed Signal + finally + restorePriority priority + }) + + do! await 1 noticed + do! observeExit parent + let! _ = Actor.stop 2000 noticed + return () + } + ) + ) + testAsync ( + "a child is linked to a supplied parent other than the spawning caller", + fun _ -> + toAsync ( + actor { + let started, noticed = barrier (), barrier () + + let parent: Actor = + Actor.spawn (fun inbox -> + actor { + Actor.trapExits () + Actor.cast started Signal + let! msg = inbox.Receive() + + match Actor.tryAsChildExited msg with + | Some _ -> Actor.cast noticed Signal + | None -> failwith "Missing supplied-parent notification" + }) + + do! await 1 started + let priority = highPriority () + + let child: Actor = + try + Actor.spawnLinked parent (fun inbox -> actor { let! _ = inbox.Receive() in return () }) + finally + restorePriority priority + + assertThat (isLinkedTo parent child) isTrue + Actor.kill child + do! await 1 noticed + do! observeExit parent + do! observeExit child + let! _ = Actor.stop 2000 started + let! _ = Actor.stop 2000 noticed + return () + } + ) + ) +#endif testAsync ("normal linked child exit is silent", fun _ -> toAsync (linkExitCase false)) testAsync ("cancelled child exit follows the target link contract", fun _ -> toAsync (linkExitCase true)) testAsync ( @@ -392,6 +556,104 @@ let tests = ) ) #if !FABLE_COMPILER_BEAM + testAsync ( + "cancellation during resource acquisition still disposes once", + fun _ -> + toAsync ( + actor { + let cleaned = barrier () + + let worker: Actor = + Actor.spawn (fun inbox -> + actor { + use _resource = + let resource = { + new System.IDisposable with + member _.Dispose() = Actor.cast cleaned Signal + } + + Actor.kill inbox + resource + + failwith "Cancelled resource body ran" + }) + + do! observeExit worker + let! result = Actor.stop 2000 worker + assertThat result (isEqualTo (StopResult.Completed ActorExit.Cancelled)) + let! count = Actor.call cleaned Read + assertThat count (isEqualTo 1) + let! _ = Actor.stop 2000 cleaned + return () + } + ) + ) + testAsync ( + "cleanup failure during cancelled acquisition is reported", + fun _ -> + toAsync ( + actor { + let worker: Actor = + Actor.spawn (fun inbox -> + actor { + use _resource = + let resource = { + new System.IDisposable with + member _.Dispose() = failwith "acquisition cleanup failed" + } + + Actor.kill inbox + resource + + return () + }) + + do! observeExit worker + let! result = Actor.stop 2000 worker + + match result with + | StopResult.Completed(ActorExit.Failed ex) -> assertThat ex.Message (isEqualTo "acquisition cleanup failed") + | other -> failwithf "Acquisition cleanup failure was lost: %A" other + } + ) + ) + testAsync ( + "direct owned cancellation rejects posts while work remains suspended", + fun _ -> + toAsync ( + actor { + let started = barrier () + let releaseHandle = reporter None + + let worker: Actor = + Actor.spawn (fun _ -> + actor { + let! _ = + Async.FromContinuations(fun (ok, _, _) -> + Actor.cast releaseHandle (Some(Some(fun () -> ok 1))) + Actor.cast started Signal) + + return () + }) + + do! await 1 started + worker.Cts.Cancel() + let before = mailboxLength worker + + for message in 1..100 do + worker.Post message + + assertThat (mailboxLength worker) (isEqualTo before) + let! release = Actor.call releaseHandle None + Option.get release () + do! observeExit worker + let! _ = Actor.stop 2000 worker + let! _ = Actor.stop 2000 releaseHandle + let! _ = Actor.stop 2000 started + return () + } + ) + ) testAsync ( "noncooperative work times out and remains owned until release", fun _ -> From a07d4b41ff8495648536bfa8bf5282af59699aa4 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Wed, 7 Oct 2026 22:37:57 +0200 Subject: [PATCH 7/7] test: await forwarded actor messages before asserting --- test/ActorTests.fs | 52 +++++++++++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/test/ActorTests.fs b/test/ActorTests.fs index ccd8f27..9b441d5 100644 --- a/test/ActorTests.fs +++ b/test/ActorTests.fs @@ -25,7 +25,28 @@ type Command = type CollectorMsg<'T> = | Collect of 'T - | GetResults + | GetResults of expectedCount: int + +// decision: holds collector replies until the expected messages arrive so independent senders do not rely on scheduler timing +let private collector () = + Actor.start ([], []) (fun (results, waiters) (msg, rc) -> + match msg with + | Collect x -> + let results = results @ [ x ] + let count = List.length results + + let ready, pending = + waiters + |> List.partition (fun (expected, _) -> expected <= count) + + for _, reply in ready do + reply.Reply results + + Continue(results, pending) + | GetResults expected when expected > List.length results -> Continue(results, (expected, rc) :: waiters) + | GetResults _ -> + rc.Reply results + Continue(results, waiters)) type TimerMsg = | Tick @@ -57,13 +78,7 @@ let private spawnTests = fun _ -> toAsync ( actor { - let collector = - Actor.start [] (fun results (msg, rc) -> - match msg with - | Collect x -> Continue(results @ [ x ]) - | GetResults -> - rc.Reply results - Continue results) + let collector = collector () let _worker: Actor = Actor.spawn (fun _inbox -> @@ -71,10 +86,10 @@ let private spawnTests = Actor.cast collector (Collect "world") actor { return () }) - do! sleep 50 - - let! results = Actor.call collector GetResults + let! results = Actor.callWithTimeout 2000 collector (GetResults 2) assertThat results (isEqualTo [ "hello"; "world" ]) + let! _ = Actor.stop 2000 collector + return () } ) ) @@ -84,13 +99,7 @@ let private spawnTests = fun _ -> toAsync ( actor { - let collector = - Actor.start [] (fun results (msg, rc) -> - match msg with - | Collect x -> Continue(results @ [ x ]) - | GetResults -> - rc.Reply results - Continue results) + let collector = collector () let doubler: Actor = Actor.spawn (fun inbox -> @@ -107,10 +116,11 @@ let private spawnTests = Actor.send doubler 2 Actor.send doubler 3 - do! sleep 50 - - let! results = Actor.call collector GetResults + let! results = Actor.callWithTimeout 2000 collector (GetResults 3) assertThat results (isEqualTo [ 2; 4; 6 ]) + let! _ = Actor.stop 2000 doubler + let! _ = Actor.stop 2000 collector + return () } ) )