letify 2.10.0__tar.gz → 2.10.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {letify-2.10.0 → letify-2.10.2}/PKG-INFO +1 -1
- {letify-2.10.0 → letify-2.10.2}/docs/SPEC.md +7 -2
- {letify-2.10.0 → letify-2.10.2}/letify/__init__.py +1 -1
- {letify-2.10.0 → letify-2.10.2}/letify/protocol/worker.py +12 -9
- {letify-2.10.0 → letify-2.10.2}/letify/remoting/device/client.py +29 -4
- {letify-2.10.0 → letify-2.10.2}/letify/remoting/device/executor.py +31 -11
- {letify-2.10.0 → letify-2.10.2}/letify/store/pathdata.py +15 -2
- {letify-2.10.0 → letify-2.10.2}/letify/transport/link.py +9 -0
- {letify-2.10.0 → letify-2.10.2}/letify-core/Cargo.lock +2 -2
- {letify-2.10.0 → letify-2.10.2}/letify-core/Cargo.toml +1 -1
- {letify-2.10.0 → letify-2.10.2}/letify-ext/package-lock.json +2 -2
- {letify-2.10.0 → letify-2.10.2}/letify-ext/package.json +1 -1
- {letify-2.10.0 → letify-2.10.2}/pyproject.toml +1 -1
- {letify-2.10.0 → letify-2.10.2}/tests/test_device.py +111 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_parallel_placement.py +79 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_run_takeover.py +110 -0
- {letify-2.10.0 → letify-2.10.2}/.github/workflows/ci.yml +0 -0
- {letify-2.10.0 → letify-2.10.2}/.github/workflows/docs.yml +0 -0
- {letify-2.10.0 → letify-2.10.2}/.github/workflows/publish-crates.yml +0 -0
- {letify-2.10.0 → letify-2.10.2}/.github/workflows/publish.yml +0 -0
- {letify-2.10.0 → letify-2.10.2}/.github/workflows/reserve-crates.yml +0 -0
- {letify-2.10.0 → letify-2.10.2}/.gitignore +0 -0
- {letify-2.10.0 → letify-2.10.2}/.researchtree +0 -0
- {letify-2.10.0 → letify-2.10.2}/CLAUDE.md +0 -0
- {letify-2.10.0 → letify-2.10.2}/LICENSE +0 -0
- {letify-2.10.0 → letify-2.10.2}/PROJECT.md +0 -0
- {letify-2.10.0 → letify-2.10.2}/README.md +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/COMPONENT.md +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/INTENT.md +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/NETWORK.md +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/guide/01-getting-started.md +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/guide/02-providers.md +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/guide/03-execution-modes.md +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/guide/04-environments-and-data.md +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/guide/05-concurrency.md +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/guide/06-cost.md +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/guide/07-troubleshooting.md +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/guide/08-releasing.md +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/guide/README.md +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/guide/ko/04-/355/231/230/352/262/275/352/263/274-/353/215/260/354/235/264/355/204/260.md" +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/guide/ko/README.md +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/locales/README_ko.md +0 -0
- {letify-2.10.0 → letify-2.10.2}/docs/mkdocs.yml +0 -0
- {letify-2.10.0 → letify-2.10.2}/examples/README.md +0 -0
- {letify-2.10.0 → letify-2.10.2}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-2.10.0 → letify-2.10.2}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/examples/nvfp4-lora/README.md +0 -0
- {letify-2.10.0 → letify-2.10.2}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-2.10.0 → letify-2.10.2}/examples/nvfp4-lora/common.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/cli.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/config/__init__.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/config/inventory.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/config/login.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/config/schema.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/config/secrets.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/config/writer.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/declare/__init__.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/declare/cache.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/declare/env.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/declare/exit.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/declare/function.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/declare/instance.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/declare/reduce.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/declare/replicas.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/errors.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/install.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/launcher.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/paths.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/protocol/__init__.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/protocol/codec.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/protocol/driver.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/protocol/handle.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/protocol/wire.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/providers/__init__.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/providers/base.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/providers/colab.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/providers/colab_files.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/providers/colab_keepalive.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/providers/elice.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/providers/kaggle.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/providers/kaggle_adapter.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/providers/local.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/providers/modal.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/providers/modal_adapter.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/providers/naming.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/providers/shell.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/providers/tunnel.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/providers/usage.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/remoting/__init__.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/remoting/device/__init__.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/remoting/device/cuda.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/remoting/device/frames.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/remoting/device/guard.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/remoting/device/tensor.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/remoting/device/trace.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/remoting/device/value.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/render.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/runtime/__init__.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/runtime/bootstrap.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/runtime/broker.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/runtime/channel.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/runtime/control.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/runtime/lease.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/runtime/pool.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/runtime/session.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/runtime/takeover.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/runtime/telemetry.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/runtime/workers.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/store/__init__.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/store/backends/__init__.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/store/backends/drive.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/store/backends/filesystem.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/store/backends/google_auth.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/store/backends/layout.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/store/backends/objects.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/store/cas.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/store/handoff.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/store/sendorder.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/store/volume.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/stubs.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/tools.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/transport/__init__.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/transport/agent.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/transport/announce.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/transport/nat.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/transport/pipeline.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/transport/portmap.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/transport/probe.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/transport/quic.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/transport/rendezvous.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/transport/setup.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/transport/sshopts.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify/transport/strategies.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-core/.gitignore +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-core/build.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-core/letify/Cargo.toml +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-core/letify/LICENSE +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-core/letify/README.md +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-core/letify/src/lib.rs +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-core/letify-quic/Cargo.toml +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-core/letify-quic/src/main.rs +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-core/rust-toolchain.toml +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-ext/.vscodeignore +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-ext/LICENSE +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-ext/README.md +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-ext/media/icon.svg +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-ext/src/cli.ts +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-ext/src/extension.ts +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-ext/src/history.ts +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-ext/src/model.ts +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-ext/src/poll.ts +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-ext/src/render.ts +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-ext/test/cli.test.ts +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-ext/test/manifest.test.ts +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-ext/test/model.test.ts +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-ext/test/regressions.test.ts +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify-ext/tsconfig.json +0 -0
- {letify-2.10.0 → letify-2.10.2}/letify_providers.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/scripts/sync-main.sh +0 -0
- {letify-2.10.0 → letify-2.10.2}/scripts/version.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/conftest.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/fake_kaggle_adapter.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/fake_modal_adapter.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_cli.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_cli_output.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_colab_keepalive.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_config.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_core.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_data.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_data_streaming.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_declare.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_device_compile.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_distributed.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_drive_handoff.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_drive_store.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_editor_sessions.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_elice.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_frames.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_install.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_kaggle.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_kaggle_cookie.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_kaggle_link.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_link_log.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_notebook.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_paths.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_portmap.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_protocol.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_providers.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_punch_sockets.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_quic.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_remoting.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_render.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_run_control.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_runtime.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_shell_workers.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_ship.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_store.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_strategies.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_stubs.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_transport.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_tunnel_setup.py +0 -0
- {letify-2.10.0 → letify-2.10.2}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: letify
|
|
3
|
-
Version: 2.10.
|
|
3
|
+
Version: 2.10.2
|
|
4
4
|
Summary: Declarations that become infrastructure.
|
|
5
5
|
Project-URL: Homepage, https://github.com/DarkPyonix/letify
|
|
6
6
|
Project-URL: Documentation, https://github.com/DarkPyonix/letify/tree/main/docs
|
|
@@ -1089,7 +1089,7 @@ A call has exactly one owner at a time, and the session still ends with the call
|
|
|
1089
1089
|
|
|
1090
1090
|
1. **The registry keeps an orphan.** The record also holds `project`, the project root the call was sent from, and `lease_deadline`, the Unix time at which the runtime's lease ends unless renewed, rewritten at each renewal. It also holds what a second process needs to reach the call and finish it: `door`, the path of the worker's socket below; `config`, the project `config.toml` that names the account; and `call_dir`, `blobs` and `outputs`, the runtime paths and the output locations to write back, each location stored relative to `project` when it lies inside it. No secret is among them. A record whose owner pid is gone is no longer removed when read while its `lease_deadline` is in the future: `letify runs` lists it with `owner` `gone` and the seconds left, and it can be taken over. After the deadline it is removed as stale.
|
|
1091
1091
|
2. **The worker keeps a second door.** While a call runs, the worker listens on a Unix socket on the runtime, `<workspace root>/.letify/run-<run id>.sock`, mode 0600. When that path is longer than 100 bytes, the limit of a Unix socket address, the worker and the relay reach the socket through `/proc/self/fd/<n>`, a descriptor of its directory, so it stays at that path; where `/proc` is missing the worker reports that the run has no second door. A channel that closes while a call runs, with no shutdown frame before it, does not end the worker: the body keeps running until the lease deadline passes. A shutdown frame is different: it is the owner ending the run on purpose, as [A deliberate shutdown](#run-shutdown) describes. Output the body makes while no channel is open is kept, the last 256 KiB per stream, and is sent to the next owner first. A body that returns while no channel is open keeps its result for a new owner until the lease deadline; on a provider with no lease the worker ends then, as before.
|
|
1092
|
-
3. **The new owner connects.** `letify takeover <run>` reads the record, opens a channel to the same runtime through the record's provider and runtime name, and starts a relay to that socket instead of a new worker. It then sends `adopt` with the run id. The worker answers once with the call's state, which is `running` or `finished`, and from then on sends the call's output and its reply on the new channel, the reply as the last piece of the same answer. The old channel, if still open, is told `moved` on the call's stream
|
|
1092
|
+
3. **The new owner connects.** `letify takeover <run>` reads the record, opens a channel to the same runtime through the record's provider and runtime name, and starts a relay to that socket instead of a new worker. It then sends `adopt` with the run id. The worker answers once with the call's state, which is `running` or `finished`, and from then on sends the call's output and its reply on the new channel, the reply as the last piece of the same answer. The old channel, if still open, is told `moved` on the call's stream, after the worker has switched the call's output to the new channel, so everything the body prints once the old owner knows of the move reaches the new owner. It answers every later request on it with `moved`. A door takes one connection at a time and no request but `adopt` before it, and a second owner can adopt after the first one's connection has closed. The adoption is atomic with respect to the old channel: if the old channel closes while the adoption is under way, the worker decides what that close means only after the adoption has finished, sees that the call has moved, and does nothing. The new owner's channel is then never marked as having lost its input, and no exit hook runs for the close.
|
|
1093
1093
|
4. **The old owner lets go.** When the old owner is alive, takeover first asks it through its control socket to release the run. Its waiting call raises `OwnershipMoved` naming the run and the new owner's pid, it stops renewing that runtime's lease, and it drops the runtime from its pool without stopping it. A `keep_alive` block in the old owner no longer holds that session. An old owner that does not answer within 5 s is treated as gone.
|
|
1094
1094
|
5. **The call ends at the new owner.** `letify takeover` prints the body's output as it arrives. When the call returns it prints the `repr` of the value, or with `--result PATH` writes the value with pickle to `PATH`, and exits 0. When the body raises it prints the traceback and exits 1. With `--json` the last line is one object with `run`, `ok`, `value_repr` or `error`, and `result_path`. The call's outputs are written back under the record's `project`, not the current directory. The session is then ended.
|
|
1095
1095
|
6. **The same from Python.** `letify.takeover(run)` does the same in the calling process and returns the value or raises the body's exception, as a call does. It holds no handle: it returns once the call has ended and the session is gone. A darkpyonix kernel uses this to adopt a run started elsewhere.
|
|
@@ -1333,7 +1333,7 @@ connection, but a call's second large blob is split whenever splitting is faster
|
|
|
1333
1333
|
|
|
1334
1334
|
A connection that costs something to open has to pay for itself, so a provider reports `transfer_connection_cost_s`, the seconds one more connection takes. It is 0 for a provider that dials an address, and about 10 for one reached by a hole punch, because that is what a punch measures. The streams are opened only when the transfer is expected to finish sooner with them: the estimate uses the probe's upload rate, assumes the streams scale and that their connections are opened at the same time, and keeps one stream when `size / (rate x streams) + cost` is not less than `size / rate`. A measured case: a Kaggle runtime at a 44.7 ms round trip carried 128 MiB at 21.0 MiB/s on one stream and 9.5 MiB/s on four, 2.2 times worse, because three more punches cost more than the transfer saved. The same account at a 193 ms round trip carried 1.57 MiB/s on one raw SSH connection and 6.10 MiB/s on four, so the gain is real where the link is slow enough and the blob large enough for it to be worth 10 s.
|
|
1335
1335
|
|
|
1336
|
-
The connections have to be real ones. Every SSH command letify builds carries `ControlMaster=auto`, which multiplexes further sessions onto the first connection, so opening more channels that way would add streams inside one TCP connection and gain nothing. A transfer channel therefore sets `ControlPath=none` and `ControlMaster=no`, and a punched link gives each its own socket, so each transfer channel is its own kernel connection. A punched link holds the spare sockets of its punch, `PUNCH_SOCKETS` of them in all, [Rendezvous](#rendezvous), and hands one to each SSH command before it redials. The worker channel and the first transfer channels therefore cost no rendezvous, and `transfer_connection_cost_s` applies only to the streams beyond the spares.
|
|
1336
|
+
The connections have to be real ones. Every SSH command letify builds carries `ControlMaster=auto`, which multiplexes further sessions onto the first connection, so opening more channels that way would add streams inside one TCP connection and gain nothing. A transfer channel therefore sets `ControlPath=none` and `ControlMaster=no`, and a punched link gives each its own socket, so each transfer channel is its own kernel connection. A punched link holds the spare sockets of its punch, `PUNCH_SOCKETS` of them in all, [Rendezvous](#rendezvous), and hands one to each SSH command before it redials. The worker channel and the first transfer channels therefore cost no rendezvous, and `transfer_connection_cost_s` applies only to the streams beyond the spares: with `k` spares still unused and `s` streams wanted, `s - 1 - k` punches are needed, and when that is above 0 the estimate adds the cost once (the punches run at the same time), otherwise it adds 0. The link reports `k` as `spare_connections()`, which is 0 for every link but a punched one.
|
|
1337
1337
|
|
|
1338
1338
|
The worker's `data_put` appends to one handle per digest and hashes as it goes, which assumes the pieces arrive in order on one stream. Parallel senders cannot use it, so they use two other requests:
|
|
1339
1339
|
|
|
@@ -2683,6 +2683,11 @@ The worker records the first failure and skips the operators after it until a sy
|
|
|
2683
2683
|
|
|
2684
2684
|
A lost worker process or link raises `RuntimeLost`, and the session is discarded.
|
|
2685
2685
|
|
|
2686
|
+
An executor that ends because of an exception sends one last message, a failure naming the exception, before it stops, so the client stops waiting at once instead of waiting for a reply that cannot come. This holds at startup and in the middle of a session, on the call worker's channel and as a process of its own. The client discards the executor and raises:
|
|
2687
|
+
|
|
2688
|
+
- `ConfigError` when the executor could not start because a module is missing from the runtime's environment, such as `torch`. The message names the exception, for example `ModuleNotFoundError: No module named 'torch'`, and for `torch` says to add it to the project's dependencies. It is not retried, because a fresh runtime builds the same environment from the same lock.
|
|
2689
|
+
- `RuntimeLost`, carrying the executor's exception and traceback, for any other exception.
|
|
2690
|
+
|
|
2686
2691
|
### Version guards <!-- id: forwarding-versions -->
|
|
2687
2692
|
|
|
2688
2693
|
> PyTorch 2.1 or newer is required locally and on the runtime.
|
|
@@ -3549,11 +3549,12 @@ def _reader_ended(door, shutdown=False):
|
|
|
3549
3549
|
Spec "A deliberate shutdown": a shutdown frame on the channel that owns a running call
|
|
3550
3550
|
ends the body and the worker. End of input alone leaves the body for a takeover.
|
|
3551
3551
|
"""
|
|
3552
|
-
if door is None and _TAKE["moved"]:
|
|
3553
|
-
# The call was adopted away; this channel closing is the old owner leaving.
|
|
3554
|
-
return
|
|
3555
3552
|
during = False
|
|
3556
3553
|
with _TAKE_COND:
|
|
3554
|
+
if door is None and _TAKE["moved"]:
|
|
3555
|
+
# The call was adopted away; this channel closing is the old owner leaving. Read
|
|
3556
|
+
# under the lock, because an adoption in progress holds it until it has moved.
|
|
3557
|
+
return
|
|
3557
3558
|
if door is None or (_TAKE["owner"] is door and _TAKE["live"]):
|
|
3558
3559
|
during = _TAKE["run"] is not None
|
|
3559
3560
|
if door is not None or during:
|
|
@@ -3702,12 +3703,6 @@ def _door_adopt(door, stream, request):
|
|
|
3702
3703
|
moved = _moved_outcome()
|
|
3703
3704
|
if _TAKE["owner"] is not None:
|
|
3704
3705
|
moved["last"] = True
|
|
3705
|
-
# Told on a thread, because a former owner that has stopped reading can block the write.
|
|
3706
|
-
told = threading.Thread(
|
|
3707
|
-
target=_answer, args=(former, former_stream, moved), daemon=True
|
|
3708
|
-
)
|
|
3709
|
-
told.start()
|
|
3710
|
-
told.join(2.0)
|
|
3711
3706
|
state = {
|
|
3712
3707
|
"run": run,
|
|
3713
3708
|
"state": "finished" if _TAKE["finished"] else "running",
|
|
@@ -3728,6 +3723,14 @@ def _door_adopt(door, stream, request):
|
|
|
3728
3723
|
)
|
|
3729
3724
|
_SENDER = door.sender
|
|
3730
3725
|
_TAKE_COND.notify_all()
|
|
3726
|
+
# Told last, after the output has been switched, so that nothing the body prints from
|
|
3727
|
+
# the moment the former owner learns of the move is sent to a channel that has stopped
|
|
3728
|
+
# reading. A thread, because a former owner that has stopped reading can block the write.
|
|
3729
|
+
told = threading.Thread(
|
|
3730
|
+
target=_answer, args=(former, former_stream, moved), daemon=True
|
|
3731
|
+
)
|
|
3732
|
+
told.start()
|
|
3733
|
+
told.join(2.0)
|
|
3731
3734
|
|
|
3732
3735
|
|
|
3733
3736
|
def _deliver_call(stream, outcome):
|
|
@@ -29,7 +29,7 @@ from typing import Any
|
|
|
29
29
|
import torch
|
|
30
30
|
from blake3 import blake3
|
|
31
31
|
|
|
32
|
-
from ...errors import RemoteError, RuntimeLost
|
|
32
|
+
from ...errors import ConfigError, RemoteError, RuntimeLost
|
|
33
33
|
from .executor import E_DEFINE, E_OP, E_REQUEST, E_STEP, E_STEP_DEFINE, UPLOAD_CACHE_BYTES
|
|
34
34
|
from .frames import ChannelTransport, StreamTransport, Transport, TransportClosed
|
|
35
35
|
from .guard import check_torch_version, check_worker_version
|
|
@@ -279,6 +279,7 @@ class Client:
|
|
|
279
279
|
"""Read the executor's first message, check its PyTorch, and start the threads."""
|
|
280
280
|
head, _buffers = self._recv()
|
|
281
281
|
self.hello = pickle.loads(head)
|
|
282
|
+
self._raise_fatal(self.hello)
|
|
282
283
|
check_worker_version(local=torch.__version__, remote=str(self.hello["torch"]))
|
|
283
284
|
self._sender = threading.Thread(
|
|
284
285
|
target=self._send_loop, name=f"{self.name}-send", daemon=True
|
|
@@ -811,6 +812,7 @@ class Client:
|
|
|
811
812
|
head, buffers = self._recv()
|
|
812
813
|
consumers = self._replies.popleft()
|
|
813
814
|
reply = pickle.loads(head)
|
|
815
|
+
self._raise_fatal(reply)
|
|
814
816
|
results = reply["results"]
|
|
815
817
|
failure = reply.get("failure")
|
|
816
818
|
for note in reply.get("notes", ()):
|
|
@@ -1016,6 +1018,25 @@ class Client:
|
|
|
1016
1018
|
detail = f"\n--- device worker stderr ---\n{tail}" if tail else ""
|
|
1017
1019
|
return RuntimeLost(f"{self.name}: the device worker was lost: {reason}{detail}")
|
|
1018
1020
|
|
|
1021
|
+
def _raise_fatal(self, message: dict) -> None:
|
|
1022
|
+
"""Raise the failure an executor sent as its last message, discarding the executor."""
|
|
1023
|
+
fatal = message.get("fatal")
|
|
1024
|
+
if fatal is None:
|
|
1025
|
+
return
|
|
1026
|
+
error = fatal["error"]
|
|
1027
|
+
if fatal["started"] or fatal["missing"] is None:
|
|
1028
|
+
where = "the device executor stopped" if fatal["started"] else "could not start"
|
|
1029
|
+
lost = self._lose(f"{where}: {error}\n{fatal['traceback']}")
|
|
1030
|
+
raise lost
|
|
1031
|
+
self._lose(error)
|
|
1032
|
+
hint = (
|
|
1033
|
+
"; add torch to the project's dependencies (uv add torch) so the runtime's "
|
|
1034
|
+
"environment has it"
|
|
1035
|
+
if fatal["missing"] == "torch"
|
|
1036
|
+
else "; add it to the project's dependencies"
|
|
1037
|
+
)
|
|
1038
|
+
raise ConfigError(f"{self.name}: the device executor could not start: {error}{hint}")
|
|
1039
|
+
|
|
1019
1040
|
def _check_open(self) -> None:
|
|
1020
1041
|
if os.getpid() != self._pid:
|
|
1021
1042
|
raise RuntimeLost(
|
|
@@ -1113,10 +1134,10 @@ def connect(
|
|
|
1113
1134
|
|
|
1114
1135
|
transport.raw(count_line(source) + source)
|
|
1115
1136
|
client.start()
|
|
1116
|
-
except (TransportClosed, RuntimeLost) as exc:
|
|
1137
|
+
except (TransportClosed, RuntimeLost, ConfigError) as exc:
|
|
1117
1138
|
process.kill()
|
|
1118
1139
|
process.wait()
|
|
1119
|
-
if isinstance(exc, RuntimeLost):
|
|
1140
|
+
if isinstance(exc, (RuntimeLost, ConfigError)):
|
|
1120
1141
|
raise
|
|
1121
1142
|
raise client._lose(str(exc)) from exc
|
|
1122
1143
|
return client
|
|
@@ -1132,7 +1153,11 @@ def attach(
|
|
|
1132
1153
|
channel.request(
|
|
1133
1154
|
{"op": "device", "device": device, "source": worker_source(None).decode()}, timeout=600
|
|
1134
1155
|
)
|
|
1135
|
-
|
|
1156
|
+
try:
|
|
1157
|
+
client.start()
|
|
1158
|
+
except (RuntimeLost, ConfigError):
|
|
1159
|
+
transport.close()
|
|
1160
|
+
raise
|
|
1136
1161
|
return client
|
|
1137
1162
|
|
|
1138
1163
|
|
|
@@ -946,14 +946,37 @@ class Executor:
|
|
|
946
946
|
|
|
947
947
|
|
|
948
948
|
def serve_transport(device: str, transport) -> None:
|
|
949
|
-
"""Announce the executor on ``transport`` and answer its messages until it closes.
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
949
|
+
"""Announce the executor on ``transport`` and answer its messages until it closes.
|
|
950
|
+
|
|
951
|
+
An exception that ends the executor is sent as a last message, so the client stops
|
|
952
|
+
waiting for a reply that cannot come, as spec "Failure semantics" describes.
|
|
953
|
+
"""
|
|
954
|
+
started = False
|
|
955
|
+
try:
|
|
956
|
+
executor = Executor(device)
|
|
957
|
+
transport.send(pickle.dumps(executor.hello(), protocol=5), [])
|
|
958
|
+
started = True
|
|
959
|
+
_answer(executor, transport)
|
|
960
|
+
except TransportClosed:
|
|
961
|
+
return
|
|
962
|
+
except BaseException as exc:
|
|
963
|
+
detail = traceback.format_exc()
|
|
964
|
+
print(detail, file=sys.stderr, flush=True)
|
|
965
|
+
fatal = {
|
|
966
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
967
|
+
"traceback": detail,
|
|
968
|
+
"started": started,
|
|
969
|
+
"missing": exc.name if isinstance(exc, ImportError) else None,
|
|
970
|
+
}
|
|
953
971
|
try:
|
|
954
|
-
|
|
955
|
-
except
|
|
956
|
-
|
|
972
|
+
transport.send(pickle.dumps({"fatal": fatal}, protocol=5), [])
|
|
973
|
+
except Exception: # pragma: no cover - the link is gone as well
|
|
974
|
+
pass
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def _answer(executor: Executor, transport) -> None:
|
|
978
|
+
while True:
|
|
979
|
+
head, buffers = transport.recv()
|
|
957
980
|
message = pickle.loads(head)
|
|
958
981
|
head = None
|
|
959
982
|
if message.get("close"):
|
|
@@ -962,10 +985,7 @@ def serve_transport(device: str, transport) -> None:
|
|
|
962
985
|
buffers = None
|
|
963
986
|
if reply is not None:
|
|
964
987
|
reply_head, views, keep = reply
|
|
965
|
-
|
|
966
|
-
transport.send(reply_head, views)
|
|
967
|
-
except TransportClosed:
|
|
968
|
-
return
|
|
988
|
+
transport.send(reply_head, views)
|
|
969
989
|
del keep
|
|
970
990
|
|
|
971
991
|
|
|
@@ -1096,6 +1096,7 @@ def streams_for(
|
|
|
1096
1096
|
size: int,
|
|
1097
1097
|
rate_bps: float | None = None,
|
|
1098
1098
|
cost_s: float = 0.0,
|
|
1099
|
+
spares: int = 0,
|
|
1099
1100
|
) -> int:
|
|
1100
1101
|
"""How many connections this blob is worth splitting across.
|
|
1101
1102
|
|
|
@@ -1103,14 +1104,16 @@ def streams_for(
|
|
|
1103
1104
|
connections to send a few megabytes costs more than it saves; one on a short link,
|
|
1104
1105
|
because there a single stream already carries everything the link will carry; never
|
|
1105
1106
|
more streams than there are chunks to send; and, when a connection costs something to
|
|
1106
|
-
open, only as many as finish the transfer sooner than one would.
|
|
1107
|
+
open, only as many as finish the transfer sooner than one would. The extra streams
|
|
1108
|
+
(all but one) that the link's ``spares`` cover cost nothing, so the cost applies only
|
|
1109
|
+
when ``count - 1`` exceeds them.
|
|
1107
1110
|
"""
|
|
1108
1111
|
if streams <= 1 or size < threshold_mib * (1 << 20):
|
|
1109
1112
|
return 1
|
|
1110
1113
|
if rtt_ms is None or rtt_ms < PARALLEL_RTT_MS:
|
|
1111
1114
|
return 1
|
|
1112
1115
|
count = max(1, min(streams, (size + CHUNK - 1) // CHUNK))
|
|
1113
|
-
if cost_s > 0 and count >
|
|
1116
|
+
if cost_s > 0 and count - 1 > max(0, spares):
|
|
1114
1117
|
if not rate_bps or rate_bps <= 0:
|
|
1115
1118
|
# Nothing to weigh the cost against, and the cost is real, so do not pay it.
|
|
1116
1119
|
return 1
|
|
@@ -1147,6 +1150,15 @@ def _measured_rtt(provider: Any, runtime: Any) -> float | None:
|
|
|
1147
1150
|
return getattr(_measured_link(provider, runtime), "rtt_ms", None)
|
|
1148
1151
|
|
|
1149
1152
|
|
|
1153
|
+
def _spares_of(link: Any) -> int:
|
|
1154
|
+
"""The spare connections ``link`` still holds, 0 where it has no such query."""
|
|
1155
|
+
query = getattr(link, "spare_connections", None)
|
|
1156
|
+
try:
|
|
1157
|
+
return max(0, int(query())) if callable(query) else 0
|
|
1158
|
+
except Exception:
|
|
1159
|
+
return 0
|
|
1160
|
+
|
|
1161
|
+
|
|
1150
1162
|
def _streams_for(runtime: Any, size: int, *, warm: bool = False) -> int:
|
|
1151
1163
|
"""``streams_for`` with the account's settings and the link's measured round trip.
|
|
1152
1164
|
|
|
@@ -1167,6 +1179,7 @@ def _streams_for(runtime: Any, size: int, *, warm: bool = False) -> int:
|
|
|
1167
1179
|
threshold_mib=int(getattr(provider, "transfer_parallel_mib", PARALLEL_MIB) or 0),
|
|
1168
1180
|
rate_bps=rate,
|
|
1169
1181
|
cost_s=cost_s,
|
|
1182
|
+
spares=_spares_of(link),
|
|
1170
1183
|
rtt_ms=rtt,
|
|
1171
1184
|
size=size,
|
|
1172
1185
|
)
|
|
@@ -44,6 +44,10 @@ class Link:
|
|
|
44
44
|
"""A stream the probe can run over, or None when this link cannot carry it."""
|
|
45
45
|
return None
|
|
46
46
|
|
|
47
|
+
def spare_connections(self) -> int:
|
|
48
|
+
"""Connections handed out with no rendezvous. Spec "Several connections at once"."""
|
|
49
|
+
return 0
|
|
50
|
+
|
|
47
51
|
def ssh_command(self, remote_command: str | None = None) -> list[str]:
|
|
48
52
|
raise RuntimeFailure(f"the {self.strategy} link carries no SSH session")
|
|
49
53
|
|
|
@@ -150,6 +154,11 @@ class PunchedLink(Link):
|
|
|
150
154
|
sock = self._spares.pop(0)
|
|
151
155
|
return sock if sock is not None else self._redial()
|
|
152
156
|
|
|
157
|
+
def spare_connections(self) -> int:
|
|
158
|
+
"""The spare sockets of the punch that no SSH command has taken yet."""
|
|
159
|
+
with self._lock:
|
|
160
|
+
return len(self._spares)
|
|
161
|
+
|
|
153
162
|
def ssh_command(self, remote_command: str | None = None) -> list[str]:
|
|
154
163
|
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
155
164
|
# Port 0: the operating system chooses, because Windows reserves ranges per machine.
|
|
@@ -138,11 +138,11 @@ dependencies = [
|
|
|
138
138
|
|
|
139
139
|
[[package]]
|
|
140
140
|
name = "letify"
|
|
141
|
-
version = "2.10.
|
|
141
|
+
version = "2.10.2"
|
|
142
142
|
|
|
143
143
|
[[package]]
|
|
144
144
|
name = "letify-quic"
|
|
145
|
-
version = "2.10.
|
|
145
|
+
version = "2.10.2"
|
|
146
146
|
dependencies = [
|
|
147
147
|
"quinn",
|
|
148
148
|
"rcgen",
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letify-status",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.2",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "letify-status",
|
|
9
|
-
"version": "2.10.
|
|
9
|
+
"version": "2.10.2",
|
|
10
10
|
"license": "SEE LICENSE IN LICENSE",
|
|
11
11
|
"devDependencies": {
|
|
12
12
|
"@types/node": "^18.19.0",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "letify-status",
|
|
3
3
|
"displayName": "letify Status",
|
|
4
4
|
"description": "letify quota and GPU activity in the status bar, read from the letify CLI.",
|
|
5
|
-
"version": "2.10.
|
|
5
|
+
"version": "2.10.2",
|
|
6
6
|
"publisher": "darkpyonix",
|
|
7
7
|
"license": "SEE LICENSE IN LICENSE",
|
|
8
8
|
"repository": {
|
|
@@ -2027,3 +2027,114 @@ def test_the_environment_variable_turns_the_automatic_behaviour_off(monkeypatch)
|
|
|
2027
2027
|
monkeypatch.delenv("LETIFY_AUTO_FETCH")
|
|
2028
2028
|
assert auto_fetch_enabled(False) is False
|
|
2029
2029
|
assert auto_fetch_enabled(None) is True
|
|
2030
|
+
|
|
2031
|
+
|
|
2032
|
+
# -- Spec: Failure semantics ---------------------------------------------------------
|
|
2033
|
+
|
|
2034
|
+
|
|
2035
|
+
class _Loopback:
|
|
2036
|
+
"""The client's end of a pair of queues whose other end is ``serve_transport``."""
|
|
2037
|
+
|
|
2038
|
+
def __init__(self, incoming, outgoing):
|
|
2039
|
+
self.incoming = incoming
|
|
2040
|
+
self.outgoing = outgoing
|
|
2041
|
+
|
|
2042
|
+
def send(self, head, buffers) -> None:
|
|
2043
|
+
self.outgoing.put((bytes(head), [bytes(b) for b in buffers]))
|
|
2044
|
+
|
|
2045
|
+
def recv(self):
|
|
2046
|
+
message = self.incoming.get(timeout=20)
|
|
2047
|
+
if message is None:
|
|
2048
|
+
raise frames.TransportClosed("the loopback closed")
|
|
2049
|
+
return message
|
|
2050
|
+
|
|
2051
|
+
def close(self) -> None:
|
|
2052
|
+
self.outgoing.put(None)
|
|
2053
|
+
|
|
2054
|
+
|
|
2055
|
+
def _serve_in_thread(monkeypatch, make_executor):
|
|
2056
|
+
"""Run the real ``serve_transport`` in a thread and return the client on its other end."""
|
|
2057
|
+
import queue
|
|
2058
|
+
import threading
|
|
2059
|
+
|
|
2060
|
+
from letify.remoting.device import executor as executor_module
|
|
2061
|
+
|
|
2062
|
+
requests, replies = queue.Queue(), queue.Queue()
|
|
2063
|
+
monkeypatch.setattr(executor_module, "Executor", make_executor)
|
|
2064
|
+
server = _Loopback(requests, replies)
|
|
2065
|
+
thread = threading.Thread(
|
|
2066
|
+
target=executor_module.serve_transport,
|
|
2067
|
+
args=("cpu", server),
|
|
2068
|
+
daemon=True,
|
|
2069
|
+
)
|
|
2070
|
+
thread.start()
|
|
2071
|
+
return forwarding.Client(_Loopback(replies, requests), name="loopback"), thread
|
|
2072
|
+
|
|
2073
|
+
|
|
2074
|
+
def test_a_device_executor_that_dies_mid_session_fails_the_waiting_read(monkeypatch) -> None:
|
|
2075
|
+
from letify.remoting.device import executor as executor_module
|
|
2076
|
+
|
|
2077
|
+
real = executor_module.Executor
|
|
2078
|
+
|
|
2079
|
+
class Dying(real):
|
|
2080
|
+
def run(self, message, buffers):
|
|
2081
|
+
raise ValueError("the executor broke")
|
|
2082
|
+
|
|
2083
|
+
client, thread = _serve_in_thread(monkeypatch, Dying)
|
|
2084
|
+
client.start()
|
|
2085
|
+
with client.activate(), pytest.raises(letify.RuntimeLost, match="the executor broke"):
|
|
2086
|
+
torch.ones(2, device="cuda").cpu()
|
|
2087
|
+
thread.join(5)
|
|
2088
|
+
assert not thread.is_alive()
|
|
2089
|
+
|
|
2090
|
+
|
|
2091
|
+
def test_a_device_executor_that_cannot_start_fails_the_hello_naming_the_cause(
|
|
2092
|
+
monkeypatch,
|
|
2093
|
+
) -> None:
|
|
2094
|
+
class Broken:
|
|
2095
|
+
def __init__(self, device):
|
|
2096
|
+
raise RuntimeError("no device to start on")
|
|
2097
|
+
|
|
2098
|
+
client, _thread = _serve_in_thread(monkeypatch, Broken)
|
|
2099
|
+
with pytest.raises(letify.RuntimeLost, match="no device to start on"):
|
|
2100
|
+
client.start()
|
|
2101
|
+
|
|
2102
|
+
|
|
2103
|
+
_WITHOUT_TORCH = (
|
|
2104
|
+
"import sys\n"
|
|
2105
|
+
"class _NoTorch:\n"
|
|
2106
|
+
" def find_spec(self, name, path=None, target=None):\n"
|
|
2107
|
+
" if name == 'torch':\n"
|
|
2108
|
+
" raise ModuleNotFoundError(\"No module named 'torch'\", name='torch')\n"
|
|
2109
|
+
"sys.meta_path.insert(0, _NoTorch())\n"
|
|
2110
|
+
)
|
|
2111
|
+
|
|
2112
|
+
|
|
2113
|
+
def test_a_device_worker_that_cannot_import_torch_fails_the_call_naming_it(
|
|
2114
|
+
let, cpu, monkeypatch
|
|
2115
|
+
) -> None:
|
|
2116
|
+
from letify.remoting.device import client as client_module
|
|
2117
|
+
|
|
2118
|
+
real = client_module.worker_source
|
|
2119
|
+
future_line = b"from __future__ import annotations\n"
|
|
2120
|
+
|
|
2121
|
+
def without_torch(device):
|
|
2122
|
+
return real(device).replace(future_line, future_line + _WITHOUT_TORCH.encode(), 1)
|
|
2123
|
+
|
|
2124
|
+
monkeypatch.setattr(client_module, "worker_source", without_torch)
|
|
2125
|
+
|
|
2126
|
+
@let.function(device=cpu, host="local")
|
|
2127
|
+
def step() -> float:
|
|
2128
|
+
return float(torch.ones(2, device="cuda").sum().item())
|
|
2129
|
+
|
|
2130
|
+
# The call must fail at once instead of waiting for a reply that never comes.
|
|
2131
|
+
import concurrent.futures
|
|
2132
|
+
|
|
2133
|
+
pool = concurrent.futures.ThreadPoolExecutor(1)
|
|
2134
|
+
try:
|
|
2135
|
+
future = pool.submit(step)
|
|
2136
|
+
with pytest.raises(letify.ConfigError, match="No module named 'torch'") as caught:
|
|
2137
|
+
future.result(timeout=60)
|
|
2138
|
+
finally:
|
|
2139
|
+
pool.shutdown(wait=False)
|
|
2140
|
+
assert "add torch" in str(caught.value).lower() or "dependencies" in str(caught.value)
|
|
@@ -548,3 +548,82 @@ class _Measured:
|
|
|
548
548
|
def __init__(self, rtt_ms: float, upload_bps: float) -> None:
|
|
549
549
|
self.rtt_ms = rtt_ms
|
|
550
550
|
self.upload_bps = upload_bps
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def test_spare_sockets_let_a_transfer_open_streams_without_paying_a_punch() -> None:
|
|
554
|
+
"""Spec "Several connections at once": streams covered by spares cost no rendezvous.
|
|
555
|
+
|
|
556
|
+
128 MiB at 56.8 MiB/s takes 2.3 s, so a 10 s punch keeps one stream. With three
|
|
557
|
+
spares the three extra streams of a four stream split need no punch and are kept.
|
|
558
|
+
"""
|
|
559
|
+
from letify.store.pathdata import streams_for
|
|
560
|
+
|
|
561
|
+
MIB = 1 << 20
|
|
562
|
+
near = dict(streams=4, threshold_mib=64, rtt_ms=44.7, size=128 * MIB, rate_bps=56.8 * MIB)
|
|
563
|
+
assert streams_for(**near, cost_s=10.0, spares=0) == 1
|
|
564
|
+
assert streams_for(**near, cost_s=10.0, spares=3) == 4
|
|
565
|
+
assert streams_for(**near, cost_s=10.0, spares=4) == 4
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def test_streams_beyond_the_spares_pay_the_connection_cost() -> None:
|
|
569
|
+
"""Spec: with fewer spares than extra streams, one punch is paid, once, for all of them."""
|
|
570
|
+
from letify.store.pathdata import streams_for
|
|
571
|
+
|
|
572
|
+
MIB = 1 << 20
|
|
573
|
+
near = dict(streams=4, threshold_mib=64, rtt_ms=44.7, size=128 * MIB, rate_bps=56.8 * MIB)
|
|
574
|
+
assert streams_for(**near, cost_s=10.0, spares=2) == 1
|
|
575
|
+
far = dict(streams=4, threshold_mib=64, rtt_ms=193.0, size=1024 * MIB, rate_bps=12.8 * MIB)
|
|
576
|
+
assert streams_for(**far, cost_s=10.0, spares=2) == 4
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def test_a_punched_link_reports_the_spare_sockets_it_still_holds() -> None:
|
|
580
|
+
"""Spec: a punched link holds the spares of its punch; any other link holds none."""
|
|
581
|
+
import socket
|
|
582
|
+
|
|
583
|
+
from letify.transport.link import Link, PunchedLink
|
|
584
|
+
|
|
585
|
+
assert Link("x", 0).spare_connections() == 0
|
|
586
|
+
pairs = [socket.socketpair() for _ in range(4)]
|
|
587
|
+
socks = [a for a, _ in pairs]
|
|
588
|
+
link = PunchedLink("tcp_punch", 0, socks[0], lambda port, cmd: [], lambda: socks[0], socks[1:])
|
|
589
|
+
try:
|
|
590
|
+
assert link.spare_connections() == 3
|
|
591
|
+
link._take() # the punched connection itself
|
|
592
|
+
assert link.spare_connections() == 3
|
|
593
|
+
link._take() # first spare
|
|
594
|
+
assert link.spare_connections() == 2
|
|
595
|
+
finally:
|
|
596
|
+
link.close()
|
|
597
|
+
for _, b in pairs:
|
|
598
|
+
b.close()
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def test_the_placement_reads_the_spares_from_the_link(monkeypatch) -> None:
|
|
602
|
+
"""Spec: `_streams_for` passes the link's spare count, so a Kaggle shard splits for free."""
|
|
603
|
+
from conftest import provider_of
|
|
604
|
+
|
|
605
|
+
from letify.providers import Kaggle
|
|
606
|
+
from letify.store.pathdata import _streams_for
|
|
607
|
+
|
|
608
|
+
MIB = 1 << 20
|
|
609
|
+
provider = provider_of(Kaggle, "kaggle_a")
|
|
610
|
+
|
|
611
|
+
class Runtime:
|
|
612
|
+
name = "letify-t4-cd14ab"
|
|
613
|
+
|
|
614
|
+
def make(spares: int):
|
|
615
|
+
class Link:
|
|
616
|
+
strategy = "tcp_punch"
|
|
617
|
+
rtt_ms = 44.7
|
|
618
|
+
upload_bps = 56.8 * MIB
|
|
619
|
+
|
|
620
|
+
def spare_connections(self) -> int:
|
|
621
|
+
return spares
|
|
622
|
+
|
|
623
|
+
return Link()
|
|
624
|
+
|
|
625
|
+
Runtime.provider = provider
|
|
626
|
+
provider._links[Runtime.name] = make(0)
|
|
627
|
+
assert _streams_for(Runtime(), 128 * MIB) == 1
|
|
628
|
+
provider._links[Runtime.name] = make(3)
|
|
629
|
+
assert _streams_for(Runtime(), 128 * MIB) == 4
|