From a18566eee0c498709fe22aaa591aa77fbf8c5aa8 Mon Sep 17 00:00:00 2001 From: Jerome Kelleher Date: Tue, 5 May 2026 14:00:56 +0100 Subject: [PATCH 1/6] Udpate vcztools version --- uv.lock | 120 +++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 76 insertions(+), 44 deletions(-) diff --git a/uv.lock b/uv.lock index fad95be..d9c8563 100644 --- a/uv.lock +++ b/uv.lock @@ -43,7 +43,8 @@ dependencies = [ { name = "pandas" }, { name = "tabulate" }, { name = "tqdm" }, - { name = "zarr" }, + { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "zarr", version = "3.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d0/59/a4dbe512b4969b47bf3266f121b5ee5e98c100b3d55aa2c43278e3c766db/bio2zarr-0.2.0.tar.gz", hash = "sha256:1663236ad05d0f4a99de12185b78f3b8e66a91eb6a8a77ed902a54e20d01baab", size = 404036, upload-time = "2026-04-15T08:24:31.12Z" } wheels = [ @@ -434,44 +435,44 @@ toml = [ [[package]] name = "cryptography" -version = "47.0.0" +version = "48.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, - { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, - { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, - { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, - { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, - { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, - { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, - { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, - { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, - { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, - { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, - { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, - { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, - { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, - { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, - { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, - { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, - { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, - { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, - { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, - { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, - { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, - { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, - { url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" }, - { url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" }, - { url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" }, - { url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, ] [[package]] @@ -1736,8 +1737,8 @@ all = [ [[package]] name = "vcztools" -version = "0.1.3.dev182" -source = { git = "https://github.com/sgkit-dev/vcztools.git?rev=main#dbd4f877f6bbd51bdb368f4f81c8427c11cf5006" } +version = "0.1.3.dev184" +source = { git = "https://github.com/sgkit-dev/vcztools.git?rev=main#bf77b1bb92a0725b8a62a7eb95188a70e7da349b" } dependencies = [ { name = "click" }, { name = "numpy" }, @@ -1745,26 +1746,57 @@ dependencies = [ { name = "pyparsing" }, { name = "pyranges", marker = "python_full_version < '3.12'" }, { name = "ruranges-py", marker = "python_full_version >= '3.12'" }, - { name = "zarr" }, + { name = "zarr", version = "3.1.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "zarr", version = "3.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] [[package]] name = "zarr" version = "3.1.6" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] dependencies = [ - { name = "donfig" }, - { name = "google-crc32c" }, - { name = "numcodecs" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "typing-extensions" }, + { name = "donfig", marker = "python_full_version < '3.12'" }, + { name = "google-crc32c", marker = "python_full_version < '3.12'" }, + { name = "numcodecs", marker = "python_full_version < '3.12'" }, + { name = "numpy", marker = "python_full_version < '3.12'" }, + { name = "packaging", marker = "python_full_version < '3.12'" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/31/5a/b8a0cf39a14c770c30bd1f2d120c54000c8cd9e84e8e79f38d9a7ce58071/zarr-3.1.6.tar.gz", hash = "sha256:d95e72cbea4b90e9a70679468b8266400331756232576ae2b43400ac5108d0eb", size = 386531, upload-time = "2026-03-23T17:25:18.748Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/de/7c/ba8ca8cbe9dbef8e83a95fc208fed8e6686c98b4719aaa0aa7f3d31fe390/zarr-3.1.6-py3-none-any.whl", hash = "sha256:b5a82c5079d1c3d4ee8f06746fa3b9a98a7d804300fa3f4be154362a33e1207e", size = 295655, upload-time = "2026-03-23T17:25:17.189Z" }, ] +[[package]] +name = "zarr" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "donfig", marker = "python_full_version >= '3.12'" }, + { name = "google-crc32c", marker = "python_full_version >= '3.12'" }, + { name = "numcodecs", marker = "python_full_version >= '3.12'" }, + { name = "numpy", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/8d/aeb164004f87543b06ef54f885d02c342c31ceb274e2bbec470a98927621/zarr-3.2.1.tar.gz", hash = "sha256:71565b738a0e7e8ed226f0516eba8c6bb53440ad7669a8c48ebb3534a161d035", size = 675161, upload-time = "2026-05-05T12:37:22.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/0a/469e2bd01be1490336e6c8707386845655d59261543315778a3ccc7e8019/zarr-3.2.1-py3-none-any.whl", hash = "sha256:f78cdd3d9687ad0e9f9cba2c5683b64f0c52589c19f685eeabe872e93cc0d2c7", size = 319617, upload-time = "2026-05-05T12:37:20.66Z" }, +] + [[package]] name = "zipp" version = "3.23.1" From 73258ceda8798f3e5be0cf319e8dd76ff04c0e95 Mon Sep 17 00:00:00 2001 From: Jerome Kelleher Date: Tue, 5 May 2026 14:50:15 +0100 Subject: [PATCH 2/6] Update to use new vcztools API --- biofuse/plink_server.py | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/biofuse/plink_server.py b/biofuse/plink_server.py index 811c8f7..fa00a6c 100644 --- a/biofuse/plink_server.py +++ b/biofuse/plink_server.py @@ -184,14 +184,6 @@ def serve_forever( pass -# Per-encoder readahead worker cap. ``vcztools`` defaults to -# ``min(32, cpu_count())`` workers in each encoder's -# ``ReadaheadPipeline``; with N concurrent ``.bed`` connections that -# multiplies into hundreds of threads in this single subprocess and can -# exhaust the per-process thread limit. We cap conservatively. -_PER_ENCODER_READAHEAD_WORKERS = 2 - - def _server_main( listener_sock: socket.socket, stop_sock: socket.socket, @@ -201,15 +193,17 @@ def _server_main( """Subprocess entry point invoked via ``multiprocessing.Process``. The two sockets are handle-passed by ``multiprocessing`` (the - reduction machinery dups the fds into the child). + reduction machinery dups the fds into the child). The reader is + used as a context manager so its shared ``ThreadPoolExecutor`` + (one pool per reader, drawn on by every ``BedEncoder`` / + ``ReadaheadPipeline``) is drained on the way out. """ - reader = vcztools_cli.make_reader(vcz_url, backend_storage=backend_storage) - reader.readahead_workers = _PER_ENCODER_READAHEAD_WORKERS - session = _ServerSession(reader) - try: - serve_forever(listener_sock, stop_sock, session) - finally: + with vcztools_cli.make_reader(vcz_url, backend_storage=backend_storage) as reader: + session = _ServerSession(reader) try: - stop_sock.close() - except OSError: - pass + serve_forever(listener_sock, stop_sock, session) + finally: + try: + stop_sock.close() + except OSError: + pass From ef20c61d9bb0fe309a6bb2b75d72a3266926d433 Mon Sep 17 00:00:00 2001 From: Jerome Kelleher Date: Tue, 5 May 2026 16:22:28 +0100 Subject: [PATCH 3/6] Bound every FUSE-side wait on the plink-server with a deadline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FUSE handler used to await indefinitely on any operation against the plink-server subprocess: a slow or wedged worker pinned the consumer's syscall in uninterruptible D-state, fusermount3 -u couldn't unmount, and the mount was unrecoverable without SIGKILL. Three concentric timeouts close that gap: - BedConnection.read wraps the lock-and-I/O block in ``trio.move_on_after(_REQUEST_TIMEOUT_S)``. On expiry the connection is marked closed (so queued readers short-circuit immediately), the stream is best-effort closed inside a shielded scope, and ``OSError(EIO)`` is raised — already mapped to ``FUSEError(EIO)`` by PlinkOps.read. - BedConnection.aclose runs its send_eof + aclose under shielded ``trio.move_on_after(_ACLOSE_TIMEOUT_S)``, so release can never outlive its deadline. PlinkOps.release already swallows aclose exceptions; with the deadline in place it can no longer hang. - PlinkClient._connect_stream wraps each ``connect()`` with ``trio.fail_after(_OPEN_TIMEOUT_S)`` so a stuck accept queue can't lock the parent. - PlinkOps.open wraps the limiter acquire with ``trio.move_on_after(_LIMITER_TIMEOUT_S)`` and surfaces ``FUSEError(EAGAIN)`` on expiry, guarding against a leaked slot permanently wedging FUSE_OPEN. Verified by killing the plink-server mid-mount: dd surfaces ECONNREFUSED in 12ms and fusermount3 -u completes in 10ms. fs_tests fio runner goes from a 1314s hang-with-SIGKILL to a 161s clean fail-fast where every job either succeeds or returns an errno within its configured runtime. --- biofuse/plink_client.py | 78 +++++++++++++++++++++++++++++--------- biofuse/plink_ops.py | 11 +++++- tests/test_plink_client.py | 78 ++++++++++++++++++++++++++++++++++++++ tests/test_plink_ops.py | 17 +++++++++ 4 files changed, 166 insertions(+), 18 deletions(-) diff --git a/biofuse/plink_client.py b/biofuse/plink_client.py index c09ce11..2a4c1f6 100644 --- a/biofuse/plink_client.py +++ b/biofuse/plink_client.py @@ -25,6 +25,15 @@ _CONNECT_RETRY_SLEEP_S = 0.05 _CONNECT_DEADLINE_S = 10.0 +# Per-operation deadlines for the parent → server protocol. The FUSE +# handler must never await indefinitely on the worker; on expiry we +# surface an ``OSError`` to the FUSE layer so the kernel sees a real +# I/O error and unblocks the consumer's syscall instead of pinning it +# in uninterruptible sleep. +_REQUEST_TIMEOUT_S = 30.0 +_OPEN_TIMEOUT_S = 5.0 +_ACLOSE_TIMEOUT_S = 2.0 + class BedConnection: """One ``.bed`` reader: a dedicated socket to the plink-server. @@ -45,27 +54,55 @@ async def read(self, off: int, size: int) -> bytes: if self._closed: raise OSError(errno.EIO, "bed connection is closed") request = plink_protocol.pack_read_request(off, size) - async with self._lock: - await self._stream.send_all(request) - status_buf = await _recv_exact( - self._stream, plink_protocol.REPLY_STATUS_SIZE - ) - status = plink_protocol.parse_status(status_buf) - if status < 0: - raise plink_protocol.status_to_error(status) - if status == 0: - return b"" - return await _recv_exact(self._stream, status) + with trio.move_on_after(_REQUEST_TIMEOUT_S) as cs: + async with self._lock: + if self._closed: + raise OSError(errno.EIO, "bed connection is closed") + await self._stream.send_all(request) + status_buf = await _recv_exact( + self._stream, plink_protocol.REPLY_STATUS_SIZE + ) + status = plink_protocol.parse_status(status_buf) + if status < 0: + raise plink_protocol.status_to_error(status) + if status == 0: + return b"" + return await _recv_exact(self._stream, status) + # Reached only if move_on_after caught a Cancelled — the + # inner block returns successfully or raises through. + assert cs.cancelled_caught + # Mark the connection dead so other tasks queued on + # ``self._lock`` wake up to an immediate EIO instead of + # repeating the wait against a known-broken socket. + self._closed = True + with trio.CancelScope(shield=True): + with trio.move_on_after(_ACLOSE_TIMEOUT_S): + try: + await self._stream.aclose() + except (trio.BrokenResourceError, OSError) as exc: + logger.debug("aclose after timeout raised: %s", exc) + raise OSError(errno.EIO, "plink-server request timed out") async def aclose(self) -> None: if self._closed: return self._closed = True - try: - await self._stream.send_eof() - except (trio.ClosedResourceError, trio.BrokenResourceError, OSError) as exc: - logger.debug("send_eof on bed connection raised: %s", exc) - await self._stream.aclose() + with trio.CancelScope(shield=True): + with trio.move_on_after(_ACLOSE_TIMEOUT_S) as cs: + try: + await self._stream.send_eof() + except ( + trio.ClosedResourceError, + trio.BrokenResourceError, + OSError, + ) as exc: + logger.debug("send_eof on bed connection raised: %s", exc) + await self._stream.aclose() + if cs.cancelled_caught: + logger.debug( + "bed connection aclose timed out after %.1fs", + _ACLOSE_TIMEOUT_S, + ) class PlinkClient: @@ -214,8 +251,15 @@ async def _connect_stream( sock.setblocking(False) trio_sock = trio.socket.from_stdlib_socket(sock) try: - await trio_sock.connect(path) + with trio.fail_after(_OPEN_TIMEOUT_S): + await trio_sock.connect(path) return trio.SocketStream(trio_sock) + except trio.TooSlowError as exc: + trio_sock.close() + raise OSError( + errno.EIO, + f"plink-server connect timed out after {_OPEN_TIMEOUT_S:.1f}s", + ) from exc except (FileNotFoundError, ConnectionRefusedError, OSError) as exc: trio_sock.close() last_exc = exc diff --git a/biofuse/plink_ops.py b/biofuse/plink_ops.py index a8712a3..a084e54 100644 --- a/biofuse/plink_ops.py +++ b/biofuse/plink_ops.py @@ -32,6 +32,12 @@ _FILE_MODE = stat.S_IFREG | 0o444 _DEFAULT_MAX_OPEN_BED = 16 +# Maximum time a FUSE_OPEN may wait at the per-mount ``.bed`` capacity +# limiter. On expiry the open returns ``EAGAIN`` to the kernel rather +# than blocking forever — this guards against a leaked limiter slot +# permanently wedging open(). +_LIMITER_TIMEOUT_S = 30.0 + class _BedConnectionProto(Protocol): async def read(self, off: int, size: int) -> bytes: ... @@ -185,7 +191,10 @@ async def open(self, inode, flags, ctx=None): # distinct logical owner, even when several share the same # trio task (true under direct PlinkOps tests, and cheap # under pyfuse3 where each request is its own task). - await self._bed_limiter.acquire_on_behalf_of(fh) + with trio.move_on_after(_LIMITER_TIMEOUT_S) as cs: + await self._bed_limiter.acquire_on_behalf_of(fh) + if cs.cancelled_caught: + raise pyfuse3.FUSEError(errno.EAGAIN) try: conn = await self._client.open_bed() except OSError as exc: diff --git a/tests/test_plink_client.py b/tests/test_plink_client.py index 074de86..aa252b4 100644 --- a/tests/test_plink_client.py +++ b/tests/test_plink_client.py @@ -240,6 +240,84 @@ async def test_aclose_terminates_server_thread(self, fx_reader, tmp_path): assert not proc.is_alive() +class TestTimeouts: + """The FUSE handler must never block forever on the worker. These + tests pin that property by pointing the client at a deliberately + unresponsive server and asserting that ``read`` and ``aclose`` + surface ``OSError(EIO)`` within a deadline.""" + + @staticmethod + def _bind_stall_listener(sock_path): + """Bind+listen a UNIX socket that will accept exactly one + connection inside the test's nursery and then go silent.""" + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(str(sock_path)) + listener.listen(1) + listener.setblocking(False) + return listener + + async def _stall_server(self, listener): + trio_listener = trio.socket.from_stdlib_socket(listener) + conn, _ = await trio_listener.accept() + # Hold the connection open with no reads or writes. The test + # nursery cancels us at teardown. + try: + await trio.sleep_forever() + finally: + conn.close() + + async def _make_stalled_connection( + self, sock_path, nursery + ) -> plink_client.BedConnection: + listener = self._bind_stall_listener(sock_path) + nursery.start_soon(self._stall_server, listener) + stream = await trio.open_unix_socket(str(sock_path)) + return plink_client.BedConnection(stream) + + async def test_read_times_out_to_eio(self, monkeypatch, tmp_path): + monkeypatch.setattr(plink_client, "_REQUEST_TIMEOUT_S", 0.2) + sock_path = tmp_path / "stall.sock" + async with trio.open_nursery() as nursery: + conn = await self._make_stalled_connection(sock_path, nursery) + t0 = trio.current_time() + with pytest.raises(OSError, match="plink-server") as excinfo: + await conn.read(0, 1024) + elapsed = trio.current_time() - t0 + assert excinfo.value.errno == errno.EIO + assert elapsed < 1.0, f"read should fail fast, took {elapsed:.2f}s" + nursery.cancel_scope.cancel() + + async def test_read_after_timeout_is_immediate(self, monkeypatch, tmp_path): + monkeypatch.setattr(plink_client, "_REQUEST_TIMEOUT_S", 0.2) + sock_path = tmp_path / "stall.sock" + async with trio.open_nursery() as nursery: + conn = await self._make_stalled_connection(sock_path, nursery) + with pytest.raises(OSError, match="plink-server"): + await conn.read(0, 1024) + t0 = trio.current_time() + with pytest.raises(OSError, match="bed connection is closed") as excinfo: + await conn.read(0, 1024) + elapsed = trio.current_time() - t0 + assert excinfo.value.errno == errno.EIO + assert elapsed < 0.05, ( + f"second read should be immediate, took {elapsed:.3f}s" + ) + nursery.cancel_scope.cancel() + + async def test_aclose_does_not_hang_on_unresponsive_peer( + self, monkeypatch, tmp_path + ): + monkeypatch.setattr(plink_client, "_ACLOSE_TIMEOUT_S", 0.2) + sock_path = tmp_path / "stall.sock" + async with trio.open_nursery() as nursery: + conn = await self._make_stalled_connection(sock_path, nursery) + t0 = trio.current_time() + await conn.aclose() + elapsed = trio.current_time() - t0 + assert elapsed < 1.0, f"aclose should not hang, took {elapsed:.2f}s" + nursery.cancel_scope.cancel() + + class TestRealSubprocess: """End-to-end test against a real ``multiprocessing.Process`` worker.""" diff --git a/tests/test_plink_ops.py b/tests/test_plink_ops.py index 41bce6a..3abe70a 100644 --- a/tests/test_plink_ops.py +++ b/tests/test_plink_ops.py @@ -426,6 +426,23 @@ async def test_failed_open_releases_slot(self, fx_client): info = await ops.open(bed_inode, os.O_RDONLY) await ops.release(info.fh) + async def test_open_returns_eagain_when_limiter_starved( + self, fx_client, monkeypatch + ): + """A leaked limiter slot must not pin FUSE_OPEN forever. + + With the cap held by an existing open and the slot never + released, a competing open must surface ``EAGAIN`` once the + per-mount limiter deadline expires.""" + monkeypatch.setattr(plink_ops, "_LIMITER_TIMEOUT_S", 0.2) + ops = plink_ops.PlinkOps(fx_client, "small", max_open_bed=1) + bed_inode = ops._name_to_inode["small.bed"] + held = await ops.open(bed_inode, os.O_RDONLY) + try: + await _expect_fuse_error(ops.open(bed_inode, os.O_RDONLY), errno.EAGAIN) + finally: + await ops.release(held.fh) + class TestReadOnly: async def test_access_write_denied(self, fx_ops): From 218c28c064e145b38a4b0f141b9f2a42e3c8912f Mon Sep 17 00:00:00 2001 From: Jerome Kelleher Date: Tue, 5 May 2026 16:39:51 +0100 Subject: [PATCH 4/6] Trace open/limiter/release/aclose lifecycle events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The robustness layer made the mount durable, but EAGAIN was still firing under repeated fio churn and we couldn't tell whether the slow path was parent-side limiter contention, parent-side aclose, or server-side encoder shutdown. Extend the access log with a ``kind`` field (default ``read``) and emit lifecycle events alongside reads: - ``open`` — PlinkOps.open entry → return. - ``limiter_wait`` — only for .bed; the time spent on the CapacityLimiter acquire. - ``release`` — PlinkOps.release entry → return. - ``aclose`` — BedConnection.aclose entry → return, via a new ``on_aclose`` callback hook the FUSE layer wires in at open time. Server-side: add DEBUG-level logs around encoder construction, each encoder.read, and encoder.close so a -vv run records the per-connection timing on the worker. Add ``fs_tests/harness/trace_summary.py`` to print per-kind duration distributions and the slowest events from an access.jsonl; running it on a real fio trace pinpointed the bottleneck as read latency itself (max ~1 s) rather than anything FUSE-side (every lifecycle op is sub-5 ms). Two existing tests adapted: ``test_records_per_read`` filters to ``kind="read"`` now that lifecycle events share the log; the in-test fakes accept ``on_aclose`` and propagate it. --- biofuse/access_log.py | 44 ++++++++-- biofuse/plink_client.py | 24 +++++- biofuse/plink_ops.py | 28 +++++- biofuse/plink_server.py | 25 ++++++ fs_tests/harness/trace_summary.py | 137 ++++++++++++++++++++++++++++++ tests/test_access_log.py | 40 +++++++++ tests/test_plink_ops.py | 52 +++++++++++- 7 files changed, 334 insertions(+), 16 deletions(-) create mode 100644 fs_tests/harness/trace_summary.py diff --git a/biofuse/access_log.py b/biofuse/access_log.py index c8bea85..ebc9983 100644 --- a/biofuse/access_log.py +++ b/biofuse/access_log.py @@ -30,6 +30,7 @@ class AccessRecord: size: int t_start: float t_end: float + kind: str = "read" class AccessLogger: @@ -73,14 +74,43 @@ def record( difference is the read's wall-clock duration; overlap between records with distinct ``fh`` indicates concurrent execution. """ - rec = AccessRecord( - path=path, - fh=fh, - offset=offset, - size=size, - t_start=t_start, - t_end=time.monotonic(), + self._write( + AccessRecord( + path=path, + fh=fh, + offset=offset, + size=size, + t_start=t_start, + t_end=time.monotonic(), + ) ) + + def record_event( + self, + kind: str, + path: str, + fh: int, + t_start: float, + t_end: float | None = None, + ) -> None: + """Record a non-read lifecycle event (open / release / limiter_wait / + aclose). ``offset`` and ``size`` are zero for these events; the + ``[t_start, t_end]`` interval is what matters.""" + if t_end is None: + t_end = time.monotonic() + self._write( + AccessRecord( + path=path, + fh=fh, + offset=0, + size=0, + t_start=t_start, + t_end=t_end, + kind=kind, + ) + ) + + def _write(self, rec: AccessRecord) -> None: with self._lock: if self._fh is not None: self._fh.write(json.dumps(asdict(rec)) + "\n") diff --git a/biofuse/plink_client.py b/biofuse/plink_client.py index 2a4c1f6..6198096 100644 --- a/biofuse/plink_client.py +++ b/biofuse/plink_client.py @@ -13,6 +13,8 @@ import multiprocessing as mp import pathlib import socket +import time +from collections.abc import Callable import trio @@ -45,10 +47,16 @@ class BedConnection: threads and do not contend with each other. """ - def __init__(self, stream: trio.SocketStream) -> None: + def __init__( + self, + stream: trio.SocketStream, + *, + on_aclose: Callable[[float, float], None] | None = None, + ) -> None: self._stream = stream self._lock = trio.Lock() self._closed = False + self._on_aclose = on_aclose async def read(self, off: int, size: int) -> bytes: if self._closed: @@ -87,6 +95,7 @@ async def aclose(self) -> None: if self._closed: return self._closed = True + t_start = time.monotonic() with trio.CancelScope(shield=True): with trio.move_on_after(_ACLOSE_TIMEOUT_S) as cs: try: @@ -103,6 +112,11 @@ async def aclose(self) -> None: "bed connection aclose timed out after %.1fs", _ACLOSE_TIMEOUT_S, ) + if self._on_aclose is not None: + try: + self._on_aclose(t_start, time.monotonic()) + except Exception as exc: # noqa: BLE001 - never let logging blow up cleanup + logger.debug("on_aclose hook raised: %s", exc) class PlinkClient: @@ -177,10 +191,14 @@ async def __aenter__(self) -> "PlinkClient": async def __aexit__(self, exc_type, exc, tb) -> None: await self.aclose() - async def open_bed(self) -> BedConnection: + async def open_bed( + self, + *, + on_aclose: Callable[[float, float], None] | None = None, + ) -> BedConnection: """Open a new dedicated socket for one ``.bed`` reader.""" stream = await self._connect_stream() - return BedConnection(stream) + return BedConnection(stream, on_aclose=on_aclose) async def aclose(self) -> None: """Tear down the server. Idempotent. diff --git a/biofuse/plink_ops.py b/biofuse/plink_ops.py index a084e54..773370a 100644 --- a/biofuse/plink_ops.py +++ b/biofuse/plink_ops.py @@ -186,17 +186,22 @@ async def open(self, inode, flags, ctx=None): kind = self._name_to_kind[name] fh = self._next_fh self._next_fh += 1 + t_open_start = time.monotonic() if kind == "bed": # Use the fh itself as the limiter borrower: each open is a # distinct logical owner, even when several share the same # trio task (true under direct PlinkOps tests, and cheap # under pyfuse3 where each request is its own task). + t_limiter_start = time.monotonic() with trio.move_on_after(_LIMITER_TIMEOUT_S) as cs: await self._bed_limiter.acquire_on_behalf_of(fh) + t_limiter_end = time.monotonic() if cs.cancelled_caught: raise pyfuse3.FUSEError(errno.EAGAIN) + self._record_event("limiter_wait", name, fh, t_limiter_start, t_limiter_end) try: - conn = await self._client.open_bed() + on_aclose = self._make_aclose_recorder(name, fh) + conn = await self._client.open_bed(on_aclose=on_aclose) except OSError as exc: self._bed_limiter.release_on_behalf_of(fh) raise pyfuse3.FUSEError(exc.errno or errno.EIO) from exc @@ -206,8 +211,25 @@ async def open(self, inode, flags, ctx=None): self._fh_to_conn[fh] = conn self._fh_to_kind[fh] = kind self._fh_to_name[fh] = name + self._record_event("open", name, fh, t_open_start) return pyfuse3.FileInfo(fh=fh) + def _record_event( + self, kind: str, name: str, fh: int, t_start: float, t_end=None + ) -> None: + if self._access_logger is not None: + self._access_logger.record_event(kind, name, fh, t_start, t_end) + + def _make_aclose_recorder(self, name, fh): + if self._access_logger is None: + return None + access_logger = self._access_logger + + def hook(t_start: float, t_end: float) -> None: + access_logger.record_event("aclose", name, fh, t_start, t_end) + + return hook + async def read(self, fh, off, size): kind = self._fh_to_kind.get(fh) name = self._fh_to_name.get(fh) @@ -240,10 +262,11 @@ def _read_static(data: bytes, off: int, size: int) -> bytes: async def release(self, fh): kind = self._fh_to_kind.pop(fh, None) - self._fh_to_name.pop(fh, None) + name = self._fh_to_name.pop(fh, None) conn = self._fh_to_conn.pop(fh, None) if kind is None: return + t_release_start = time.monotonic() try: if conn is not None: try: @@ -253,6 +276,7 @@ async def release(self, fh): finally: if kind == "bed": self._bed_limiter.release_on_behalf_of(fh) + self._record_event("release", name, fh, t_release_start) async def forget(self, inode_list): return diff --git a/biofuse/plink_server.py b/biofuse/plink_server.py index fa00a6c..fa986f9 100644 --- a/biofuse/plink_server.py +++ b/biofuse/plink_server.py @@ -16,6 +16,7 @@ import select import socket import threading +import time from vcztools import cli as vcztools_cli from vcztools import plink as vcztools_plink @@ -72,6 +73,8 @@ def _handle_connection(conn_sock: socket.socket, session: _ServerSession) -> Non The connection's ``BedEncoder`` is allocated lazily on the first ``READ`` so the metadata-handshake socket pays no encoder cost. """ + tname = threading.current_thread().name + logger.debug("%s: conn accepted", tname) encoder: vcztools_plink.BedEncoder | None = None try: while True: @@ -96,8 +99,22 @@ def _handle_connection(conn_sock: socket.socket, session: _ServerSession) -> Non return off, size = plink_protocol.parse_read_payload(payload) if encoder is None: + t_enc = time.monotonic() encoder = vcztools_plink.BedEncoder(session.reader) + logger.debug( + "%s: encoder created in %.3fs", + tname, + time.monotonic() - t_enc, + ) + t_read = time.monotonic() data = encoder.read(off, size) + logger.debug( + "%s: encoder.read off=%d size=%d in %.3fs", + tname, + off, + size, + time.monotonic() - t_read, + ) reply = plink_protocol.pack_read_reply(data) else: logger.warning( @@ -116,14 +133,22 @@ def _handle_connection(conn_sock: socket.socket, session: _ServerSession) -> Non return finally: if encoder is not None: + t_close = time.monotonic() + logger.debug("%s: eof; encoder.close ...", tname) try: encoder.close() except Exception as exc: # noqa: BLE001 - best-effort cleanup logger.debug("encoder close raised: %s", exc) + logger.debug( + "%s: encoder.close done in %.3fs", + tname, + time.monotonic() - t_close, + ) try: conn_sock.close() except OSError: pass + logger.debug("%s: conn thread exit", tname) def serve_forever( diff --git a/fs_tests/harness/trace_summary.py b/fs_tests/harness/trace_summary.py new file mode 100644 index 0000000..6475466 --- /dev/null +++ b/fs_tests/harness/trace_summary.py @@ -0,0 +1,137 @@ +"""Tiny trace summariser for an access.jsonl produced under -vv. + +Reads a JSONL file produced by :class:`biofuse.access_log.AccessLogger` +(reads + lifecycle events with a ``kind`` field) and prints +per-event-kind duration distributions plus the top-N slowest events. + +Run as:: + + uv run python -m fs_tests.harness.trace_summary +""" + +import argparse +import json +import pathlib +import sys +from collections import defaultdict + + +def _load(path: pathlib.Path) -> list[dict]: + rows: list[dict] = [] + with path.open() as fh: + for line in fh: + line = line.rstrip("\n") + if line == "": + continue + try: + rows.append(json.loads(line)) + except json.JSONDecodeError: + continue + return rows + + +def _percentiles(values: list[float], pcts: list[int]) -> list[float]: + if len(values) == 0: + return [0.0] * len(pcts) + sorted_vals = sorted(values) + out: list[float] = [] + for p in pcts: + # Nearest-rank inclusive percentile. + idx = max(0, min(len(sorted_vals) - 1, (p * len(sorted_vals) - 1) // 100)) + out.append(sorted_vals[idx]) + return out + + +def _print_kind_table(rows: list[dict]) -> None: + by_kind: dict[str, list[float]] = defaultdict(list) + for r in rows: + kind = r.get("kind", "read") + try: + duration = float(r["t_end"]) - float(r["t_start"]) + except (KeyError, TypeError, ValueError): + continue + by_kind[kind].append(duration) + + print(f"{'kind':<16} {'n':>6} {'p50':>8} {'p90':>8} {'p99':>8} {'max':>8}") + print("-" * 60) + for kind in sorted(by_kind): + vs = by_kind[kind] + p50, p90, p99 = _percentiles(vs, [50, 90, 99]) + print( + f"{kind:<16} {len(vs):>6} " + f"{p50:>8.3f} {p90:>8.3f} {p99:>8.3f} {max(vs):>8.3f}" + ) + + +def _print_topn_slowest(rows: list[dict], kind: str, n: int = 5) -> None: + of_kind = [r for r in rows if r.get("kind", "read") == kind] + if len(of_kind) == 0: + return + sized = [] + for r in of_kind: + try: + d = float(r["t_end"]) - float(r["t_start"]) + except (KeyError, TypeError, ValueError): + continue + sized.append((d, r)) + sized.sort(reverse=True) + print(f"\nslowest {n} '{kind}' events:") + for d, r in sized[:n]: + print( + f" {d:>7.3f}s fh={r.get('fh', '?'):<5} " + f"path={r.get('path', '?')} t_start={r.get('t_start', 0):.3f}" + ) + + +def _print_per_fh_summary(rows: list[dict]) -> None: + by_fh: dict[int, dict[str, list[float]]] = defaultdict(lambda: defaultdict(list)) + for r in rows: + try: + fh = int(r.get("fh", -1)) + kind = r.get("kind", "read") + d = float(r["t_end"]) - float(r["t_start"]) + except (KeyError, TypeError, ValueError): + continue + by_fh[fh][kind].append(d) + + if len(by_fh) == 0: + return + print("\nper-fh totals (s):") + print( + f" {'fh':>4} {'open':>8} {'limiter':>8} " + f"{'reads':>8} {'release':>8} {'aclose':>8}" + ) + for fh in sorted(by_fh): + kinds = by_fh[fh] + opn = sum(kinds.get("open", [])) + lim = sum(kinds.get("limiter_wait", [])) + rds = sum(kinds.get("read", [])) + rel = sum(kinds.get("release", [])) + acl = sum(kinds.get("aclose", [])) + print(f" {fh:>4} {opn:>8.3f} {lim:>8.3f} {rds:>8.3f} {rel:>8.3f} {acl:>8.3f}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("path", type=pathlib.Path, help="path to access.jsonl") + parser.add_argument( + "--top", type=int, default=5, help="how many slowest events to print" + ) + args = parser.parse_args(argv) + + rows = _load(args.path) + if len(rows) == 0: + print(f"no records in {args.path}", file=sys.stderr) + return 1 + + print(f"loaded {len(rows)} records from {args.path}") + print() + _print_kind_table(rows) + for kind in ("aclose", "release", "limiter_wait", "open", "read"): + _print_topn_slowest(rows, kind, args.top) + _print_per_fh_summary(rows) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_access_log.py b/tests/test_access_log.py index 4c883af..951c5fc 100644 --- a/tests/test_access_log.py +++ b/tests/test_access_log.py @@ -139,6 +139,46 @@ def worker(tid: int): json.loads(line) +class TestEventKinds: + """Lifecycle events (open / release / aclose / limiter_wait) round-trip + through the same JSONL writer with a ``kind`` field; existing reads + keep ``kind="read"``.""" + + def test_read_default_kind(self): + logger = access_log.AccessLogger() + _record(logger, "a.bed", 1, 0, 100) + recs = logger.records + assert len(recs) == 1 + assert recs[0].kind == "read" + + def test_record_event_in_memory(self): + logger = access_log.AccessLogger() + t0 = time.monotonic() + logger.record_event("aclose", "a.bed", 7, t0) + recs = logger.records + assert len(recs) == 1 + assert recs[0].kind == "aclose" + assert recs[0].path == "a.bed" + assert recs[0].fh == 7 + assert recs[0].offset == 0 + assert recs[0].size == 0 + assert recs[0].t_end >= recs[0].t_start + + def test_record_event_jsonl(self, tmp_path): + out = tmp_path / "trace.jsonl" + with access_log.AccessLogger(out) as logger: + t0 = time.monotonic() + logger.record_event("open", "a.bed", 1, t0) + logger.record_event("release", "a.bed", 1, t0, t_end=t0 + 0.01) + _record(logger, "a.bed", 1, 0, 100) + lines = out.read_text().splitlines() + assert len(lines) == 3 + kinds = [json.loads(line)["kind"] for line in lines] + assert kinds == ["open", "release", "read"] + release = json.loads(lines[1]) + assert release["t_end"] - release["t_start"] == pytest.approx(0.01) + + class TestRecordValidation: @pytest.mark.parametrize( ("offset", "size"), [(0, 0), (0, 1), (10, 5), (1 << 40, 4096)] diff --git a/tests/test_plink_ops.py b/tests/test_plink_ops.py index 3abe70a..72b77f0 100644 --- a/tests/test_plink_ops.py +++ b/tests/test_plink_ops.py @@ -10,6 +10,7 @@ import errno import os import stat +import time import pyfuse3 import pytest @@ -28,11 +29,17 @@ class _FakeBedConnection: ``(offset, size)``. """ - def __init__(self, conn_id: int, calls: list[tuple]) -> None: + def __init__( + self, + conn_id: int, + calls: list[tuple], + on_aclose=None, + ) -> None: self.conn_id = conn_id self._calls = calls self._closed = False self._next_error: OSError | None = None + self._on_aclose = on_aclose def raise_on_next_read(self, exc: OSError) -> None: self._next_error = exc @@ -48,8 +55,11 @@ async def read(self, offset: int, size: int) -> bytes: return bytes(((offset + i) & 0xFF) for i in range(size)) async def aclose(self) -> None: + t0 = time.monotonic() self._calls.append(("aclose", self.conn_id)) self._closed = True + if self._on_aclose is not None: + self._on_aclose(t0, time.monotonic()) class _FakeClient: @@ -76,13 +86,13 @@ def __init__( def raise_on_next_open(self, exc: OSError) -> None: self._next_open_error = exc - async def open_bed(self) -> _FakeBedConnection: + async def open_bed(self, *, on_aclose=None) -> _FakeBedConnection: self.calls.append(("open_bed",)) if self._next_open_error is not None: exc = self._next_open_error self._next_open_error = None raise exc - conn = _FakeBedConnection(self._next_conn_id, self.calls) + conn = _FakeBedConnection(self._next_conn_id, self.calls, on_aclose=on_aclose) self._next_conn_id += 1 self.connections.append(conn) return conn @@ -371,7 +381,7 @@ async def test_records_per_read(self, fx_client): finally: await ops.release(bed_info.fh) await ops.release(bim_info.fh) - records = log.records + records = [r for r in log.records if r.kind == "read"] bed_records = [r for r in records if r.path.endswith(".bed")] bim_records = [r for r in records if r.path.endswith(".bim")] assert len(bed_records) == 1 @@ -444,6 +454,40 @@ async def test_open_returns_eagain_when_limiter_starved( await ops.release(held.fh) +class TestLifecycleEvents: + """``open`` / ``release`` / ``aclose`` / ``limiter_wait`` events + are emitted on the access logger so we can localise where time is + spent in the lifecycle without changing the read trace.""" + + async def test_bed_emits_full_lifecycle(self, fx_client): + log = access_log.AccessLogger() + ops = plink_ops.PlinkOps(fx_client, "small", access_logger=log) + bed_inode = ops._name_to_inode["small.bed"] + info = await ops.open(bed_inode, os.O_RDONLY) + await ops.read(info.fh, 0, 8) + await ops.release(info.fh) + kinds = [r.kind for r in log.records] + assert "limiter_wait" in kinds + assert "open" in kinds + assert "read" in kinds + assert "release" in kinds + assert "aclose" in kinds + # `release` and `aclose` should both be tied to the same fh. + rel = next(r for r in log.records if r.kind == "release") + acl = next(r for r in log.records if r.kind == "aclose") + assert rel.fh == info.fh + assert acl.fh == info.fh + + async def test_static_emits_open_and_release_only(self, fx_client): + log = access_log.AccessLogger() + ops = plink_ops.PlinkOps(fx_client, "small", access_logger=log) + bim_inode = ops._name_to_inode["small.bim"] + info = await ops.open(bim_inode, os.O_RDONLY) + await ops.release(info.fh) + kinds = [r.kind for r in log.records] + assert kinds == ["open", "release"] + + class TestReadOnly: async def test_access_write_denied(self, fx_ops): inode = fx_ops._name_to_inode["small.bed"] From 994ac04a1535d4bdcc7a2afd3638f882db5ef1cb Mon Sep 17 00:00:00 2001 From: Jerome Kelleher Date: Tue, 5 May 2026 16:55:14 +0100 Subject: [PATCH 5/6] Forward parent log level into the plink-server subprocess The plink-server's ``logger.debug``/``info`` messages were going nowhere: the subprocess never called ``logging.basicConfig``, so the default ``WARNING`` level dropped them. Pass the parent's effective level into ``_server_main`` and configure logging at entry, so a ``-vv`` parent run now also shows the per-connection encoder lifecycle traces (``encoder created in N.NNNs``, ``encoder.read off=O size=S in N.NNNs``, ``encoder.close done in N.NNNs``). Default of ``WARNING`` keeps subprocess logs quiet under normal operation. --- biofuse/plink_client.py | 10 +++++++++- biofuse/plink_server.py | 10 ++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/biofuse/plink_client.py b/biofuse/plink_client.py index 6198096..cd728ad 100644 --- a/biofuse/plink_client.py +++ b/biofuse/plink_client.py @@ -146,6 +146,7 @@ async def start( socket_path: pathlib.Path, *, backend_storage: str | None = None, + log_level: int | None = None, ) -> "PlinkClient": """Spawn the server, run the metadata handshake, return client. @@ -153,6 +154,11 @@ async def start( itself, then hands both to the child. Multiprocessing's socket reduction dups the fds across the spawn boundary; the parent closes its own copies once the child has started. + + ``log_level`` is forwarded to the subprocess so its + ``logger.debug`` / ``info`` output appears in the parent's + log sink. If ``None``, the subprocess uses its default + (WARNING). """ socket_path = pathlib.Path(socket_path) socket_path.parent.mkdir(parents=True, exist_ok=True) @@ -163,9 +169,11 @@ async def start( listener.listen(64) parent_stop, child_stop = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) ctx = mp.get_context("spawn") + if log_level is None: + log_level = logging.getLogger().getEffectiveLevel() proc: mp.process.BaseProcess = ctx.Process( target=plink_server._server_main, - args=(listener, child_stop, vcz_url, backend_storage), + args=(listener, child_stop, vcz_url, backend_storage, log_level), name="biofuse-plink-server", ) try: diff --git a/biofuse/plink_server.py b/biofuse/plink_server.py index fa986f9..0cae59a 100644 --- a/biofuse/plink_server.py +++ b/biofuse/plink_server.py @@ -214,6 +214,7 @@ def _server_main( stop_sock: socket.socket, vcz_url: str, backend_storage: str | None, + log_level: int = logging.WARNING, ) -> None: """Subprocess entry point invoked via ``multiprocessing.Process``. @@ -222,7 +223,16 @@ def _server_main( used as a context manager so its shared ``ThreadPoolExecutor`` (one pool per reader, drawn on by every ``BedEncoder`` / ``ReadaheadPipeline``) is drained on the way out. + + ``log_level`` matches the parent's verbosity so the subprocess's + own ``logger.debug`` / ``logger.info`` output reaches the same + sink as the parent. """ + logging.basicConfig( + level=log_level, + format="%(asctime)s %(name)s %(levelname)s: %(message)s", + datefmt="%H:%M:%S", + ) with vcztools_cli.make_reader(vcz_url, backend_storage=backend_storage) as reader: session = _ServerSession(reader) try: From 3dff5e753941c08da89dace0afadd356012f3275 Mon Sep 17 00:00:00 2001 From: Jerome Kelleher Date: Wed, 6 May 2026 11:31:27 +0100 Subject: [PATCH 6/6] Address review fallout: drop bare assert, force basicConfig override - BedConnection.read: replace ``assert cs.cancelled_caught`` with an explicit ``if not ... raise RuntimeError`` so the timeout EIO path stays correct under ``python -O``. The branch is unreachable in normal operation; tagged with a coverage pragma. - _server_main: pass ``force=True`` to ``logging.basicConfig`` so the explicitly-forwarded log level wins even if an upstream import (or the parent's logging state replayed via ``spawn``) has already configured the root logger. --- biofuse/plink_client.py | 13 +++++++------ biofuse/plink_server.py | 6 ++++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/biofuse/plink_client.py b/biofuse/plink_client.py index cd728ad..aa6b856 100644 --- a/biofuse/plink_client.py +++ b/biofuse/plink_client.py @@ -76,12 +76,13 @@ async def read(self, off: int, size: int) -> bytes: if status == 0: return b"" return await _recv_exact(self._stream, status) - # Reached only if move_on_after caught a Cancelled — the - # inner block returns successfully or raises through. - assert cs.cancelled_caught - # Mark the connection dead so other tasks queued on - # ``self._lock`` wake up to an immediate EIO instead of - # repeating the wait against a known-broken socket. + # Reached only if ``move_on_after`` caught a Cancelled — the + # inner block always returns or raises through. Mark the + # connection dead so other tasks queued on ``self._lock`` wake + # to an immediate EIO instead of repeating the wait against a + # known-broken socket. + if not cs.cancelled_caught: # pragma: no cover - defensive + raise RuntimeError("plink-server read fall-through") self._closed = True with trio.CancelScope(shield=True): with trio.move_on_after(_ACLOSE_TIMEOUT_S): diff --git a/biofuse/plink_server.py b/biofuse/plink_server.py index 0cae59a..85223a7 100644 --- a/biofuse/plink_server.py +++ b/biofuse/plink_server.py @@ -228,10 +228,16 @@ def _server_main( own ``logger.debug`` / ``logger.info`` output reaches the same sink as the parent. """ + # ``force=True`` so the explicitly-passed ``log_level`` wins even + # if some upstream import in this subprocess (or the parent's + # logging state, replayed via ``spawn``) has already configured + # the root logger. Without it, ``basicConfig`` is a no-op and the + # subprocess silently keeps the wrong level. logging.basicConfig( level=log_level, format="%(asctime)s %(name)s %(levelname)s: %(message)s", datefmt="%H:%M:%S", + force=True, ) with vcztools_cli.make_reader(vcz_url, backend_storage=backend_storage) as reader: session = _ServerSession(reader)