letify 2.8.3__tar.gz → 2.8.4__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.8.3 → letify-2.8.4}/PKG-INFO +1 -1
- {letify-2.8.3 → letify-2.8.4}/docs/SPEC.md +6 -2
- {letify-2.8.3 → letify-2.8.4}/letify/__init__.py +1 -1
- {letify-2.8.3 → letify-2.8.4}/letify/protocol/codec.py +28 -6
- {letify-2.8.3 → letify-2.8.4}/letify/protocol/worker.py +42 -24
- {letify-2.8.3 → letify-2.8.4}/letify/runtime/session.py +16 -8
- {letify-2.8.3 → letify-2.8.4}/letify/store/handoff.py +7 -1
- {letify-2.8.3 → letify-2.8.4}/letify/store/pathdata.py +9 -7
- {letify-2.8.3 → letify-2.8.4}/letify/transport/nat.py +30 -1
- {letify-2.8.3 → letify-2.8.4}/letify-core/Cargo.lock +3 -3
- {letify-2.8.3 → letify-2.8.4}/letify-core/Cargo.toml +1 -1
- {letify-2.8.3 → letify-2.8.4}/letify-ext/package-lock.json +2 -2
- {letify-2.8.3 → letify-2.8.4}/letify-ext/package.json +1 -1
- {letify-2.8.3 → letify-2.8.4}/pyproject.toml +1 -1
- {letify-2.8.3 → letify-2.8.4}/tests/conftest.py +17 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_protocol.py +3 -1
- {letify-2.8.3 → letify-2.8.4}/tests/test_runtime.py +34 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_store.py +54 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_strategies.py +32 -2
- {letify-2.8.3 → letify-2.8.4}/.github/workflows/ci.yml +0 -0
- {letify-2.8.3 → letify-2.8.4}/.github/workflows/docs.yml +0 -0
- {letify-2.8.3 → letify-2.8.4}/.github/workflows/publish.yml +0 -0
- {letify-2.8.3 → letify-2.8.4}/.gitignore +0 -0
- {letify-2.8.3 → letify-2.8.4}/.researchtree +0 -0
- {letify-2.8.3 → letify-2.8.4}/CLAUDE.md +0 -0
- {letify-2.8.3 → letify-2.8.4}/LICENSE +0 -0
- {letify-2.8.3 → letify-2.8.4}/PROJECT.md +0 -0
- {letify-2.8.3 → letify-2.8.4}/README.md +0 -0
- {letify-2.8.3 → letify-2.8.4}/docs/COMPONENT.md +0 -0
- {letify-2.8.3 → letify-2.8.4}/docs/INTENT.md +0 -0
- {letify-2.8.3 → letify-2.8.4}/docs/NETWORK.md +0 -0
- {letify-2.8.3 → letify-2.8.4}/docs/guide/01-getting-started.md +0 -0
- {letify-2.8.3 → letify-2.8.4}/docs/guide/02-providers.md +0 -0
- {letify-2.8.3 → letify-2.8.4}/docs/guide/03-execution-modes.md +0 -0
- {letify-2.8.3 → letify-2.8.4}/docs/guide/04-environments-and-data.md +0 -0
- {letify-2.8.3 → letify-2.8.4}/docs/guide/05-concurrency.md +0 -0
- {letify-2.8.3 → letify-2.8.4}/docs/guide/06-cost.md +0 -0
- {letify-2.8.3 → letify-2.8.4}/docs/guide/07-troubleshooting.md +0 -0
- {letify-2.8.3 → letify-2.8.4}/docs/guide/08-releasing.md +0 -0
- {letify-2.8.3 → letify-2.8.4}/docs/guide/README.md +0 -0
- {letify-2.8.3 → letify-2.8.4}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
- {letify-2.8.3 → letify-2.8.4}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
- {letify-2.8.3 → letify-2.8.4}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
- {letify-2.8.3 → letify-2.8.4}/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.8.3 → letify-2.8.4}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
- {letify-2.8.3 → letify-2.8.4}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-2.8.3 → letify-2.8.4}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
- {letify-2.8.3 → letify-2.8.4}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-2.8.3 → letify-2.8.4}/docs/guide/ko/README.md +0 -0
- {letify-2.8.3 → letify-2.8.4}/docs/locales/README_ko.md +0 -0
- {letify-2.8.3 → letify-2.8.4}/docs/mkdocs.yml +0 -0
- {letify-2.8.3 → letify-2.8.4}/examples/README.md +0 -0
- {letify-2.8.3 → letify-2.8.4}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-2.8.3 → letify-2.8.4}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/examples/nvfp4-lora/README.md +0 -0
- {letify-2.8.3 → letify-2.8.4}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-2.8.3 → letify-2.8.4}/examples/nvfp4-lora/common.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/cli.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/config/__init__.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/config/inventory.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/config/login.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/config/schema.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/config/secrets.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/config/writer.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/declare/__init__.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/declare/cache.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/declare/env.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/declare/function.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/declare/instance.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/declare/reduce.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/declare/replicas.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/errors.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/install.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/launcher.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/paths.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/protocol/__init__.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/protocol/driver.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/protocol/handle.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/protocol/wire.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/providers/__init__.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/providers/base.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/providers/colab.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/providers/colab_files.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/providers/colab_keepalive.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/providers/elice.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/providers/kaggle.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/providers/kaggle_adapter.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/providers/local.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/providers/modal.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/providers/modal_adapter.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/providers/naming.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/providers/shell.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/providers/tunnel.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/providers/usage.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/remoting/__init__.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/remoting/capability.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/remoting/device/__init__.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/remoting/device/client.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/remoting/device/cuda.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/remoting/device/executor.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/remoting/device/frames.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/remoting/device/guard.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/remoting/device/tensor.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/remoting/device/trace.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/remoting/device/value.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/remoting/loader.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/remoting/probe.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/render.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/runtime/__init__.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/runtime/bootstrap.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/runtime/broker.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/runtime/channel.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/runtime/lease.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/runtime/pool.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/runtime/telemetry.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/store/__init__.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/store/backends/__init__.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/store/backends/drive.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/store/backends/filesystem.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/store/backends/google_auth.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/store/backends/layout.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/store/backends/objects.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/store/cas.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/store/sendorder.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/store/volume.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/stubs.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/tools.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/transport/__init__.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/transport/agent.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/transport/announce.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/transport/link.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/transport/pipeline.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/transport/portmap.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/transport/probe.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/transport/quic.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/transport/rendezvous.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/transport/setup.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/transport/sshopts.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify/transport/strategies.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-core/.gitignore +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-core/build.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-core/letify-quic/Cargo.toml +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-core/letify-quic/src/main.rs +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-core/rust-toolchain.toml +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-ext/.vscodeignore +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-ext/LICENSE +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-ext/README.md +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-ext/media/icon.svg +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-ext/src/cli.ts +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-ext/src/extension.ts +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-ext/src/history.ts +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-ext/src/model.ts +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-ext/src/poll.ts +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-ext/src/render.ts +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-ext/test/cli.test.ts +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-ext/test/manifest.test.ts +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-ext/test/model.test.ts +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-ext/test/regressions.test.ts +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify-ext/tsconfig.json +0 -0
- {letify-2.8.3 → letify-2.8.4}/letify_providers.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/scripts/sync-main.sh +0 -0
- {letify-2.8.3 → letify-2.8.4}/scripts/version.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/fake_kaggle_adapter.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/fake_modal_adapter.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_cli.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_cli_output.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_colab_keepalive.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_config.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_core.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_data.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_data_streaming.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_declare.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_device.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_distributed.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_drive_handoff.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_drive_store.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_editor_sessions.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_elice.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_frames.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_install.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_kaggle.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_kaggle_cookie.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_kaggle_link.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_link_log.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_notebook.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_parallel_placement.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_paths.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_portmap.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_providers.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_quic.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_remoting.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_render.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_ship.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_stubs.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_transport.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_tunnel_setup.py +0 -0
- {letify-2.8.3 → letify-2.8.4}/tests/test_version.py +0 -0
|
@@ -924,6 +924,8 @@ A top-level argument whose serialized size is 64 KiB or more is replaced by a `B
|
|
|
924
924
|
|
|
925
925
|
A `bytes` argument is hashed as it is. Any other argument is pickled with protocol 5 and a `buffer_callback` through `codec.pickle_value_parts`, cloudpickle underneath rather than the standard library's `pickle.Pickler`, and the digest is taken over the pickle and then each out-of-band buffer in order, with no joined copy. The digest is blake3 with 16 byte output, or blake2b from the standard library where blake3 is missing.
|
|
926
926
|
|
|
927
|
+
A digest names its own algorithm. A blake3 digest is the 32 hexadecimal characters of the 16 byte output, unchanged, so every digest already stored in a cache, a volume or Drive stays valid. A digest computed with blake2b because blake3 is missing is `b2-` followed by the 32 hexadecimal characters of the 16 byte blake2b output. Whoever checks a digest picks the algorithm from the digest, through `codec.hasher_for`, so a worker without blake3 and a client with it agree on every blob, archive, write-back file and handoff, and no handshake is needed. A side that cannot compute the algorithm of a digest it was given, a worker without blake3 handed a blake3 digest, does not check it and trusts the sender, and a name it computes itself uses its own algorithm. A placed file is compared by the algorithm of its recorded digest, so a worker without blake3 does not report an unchanged file as an output.
|
|
928
|
+
|
|
927
929
|
cloudpickle rather than plain `pickle` because a `pickle.Pickler` writes a class or function defined in the caller's own run script as a name reference, `__main__.<name>`, and the worker's own `__main__` is not that script, so unpickling such a blob fails with `AttributeError: Can't get attribute '<name>' on <module '__main__' ...>`. cloudpickle ships such an object by value instead, the same as it already does for the call itself in `codec.dumps_call_parts`, so an argument too large to inline behaves the same as one that is not. The cost of cloudpickle's extra dispatch over plain `pickle` is in the noise: it only intercepts function, class and module objects, not the bulk of a large value, so a 256 MiB buffer or a multi-megabyte list of plain values serializes within a few percent of the same time either way, measured on this machine. There is accordingly no branch between the two picklers.
|
|
928
930
|
|
|
929
931
|
The digest of an immutable argument is cached on the `Runtime` for the life of the session, so a repeated argument is not hashed again. Immutable means a `bytes` object, or an object that supports weak references and exposes a read-only buffer, such as a NumPy array with `writeable` set to `False`. A `bytes` entry holds a reference to its object so its id cannot be reused, and at most 16 such entries are kept, least recently used first out. Any other argument is pickled and hashed on every call, because it may have changed.
|
|
@@ -1088,7 +1090,7 @@ neither side holds it whole:
|
|
|
1088
1090
|
returns `{"digest", "size", "dir"}`. The client receives the file with the stream a write-back
|
|
1089
1091
|
uses, checks the digest as the pieces arrive into a scratch file beside the store, and renames it
|
|
1090
1092
|
into the store with `Store.adopt_file`. The staging directory is removed afterwards. The digest
|
|
1091
|
-
is the store's content digest (blake3 truncated to 16 bytes, blake2b
|
|
1093
|
+
is the store's content digest (blake3 truncated to 16 bytes, or `b2-` and blake2b where the worker has no blake3), and the client checks it with the algorithm the digest names.
|
|
1092
1094
|
- Restore: a blob of more than one piece (8 MiB) that the store holds as a file is sent with
|
|
1093
1095
|
`data_put` pieces into a staging directory beside the destination, then moved into place and
|
|
1094
1096
|
unpacked by the worker op `place_blob`. Smaller blobs, and any blob from a store that does not
|
|
@@ -1114,6 +1116,8 @@ the kind this rule exists to catch, and the session that syncs instead both reco
|
|
|
1114
1116
|
decides afresh whether the archive is worth keeping. The cost of that is one sync; the cost of
|
|
1115
1117
|
trusting it is the transfer this rule was measured to avoid.
|
|
1116
1118
|
|
|
1119
|
+
A failure to cache the environment through the client's own store at boot, the pack, the stream back or the store write, never fails the session: it says `letify: <runtime>: not caching the environment (<reason>)` and the session goes on, the same as a failed pack on the workspace root.
|
|
1120
|
+
|
|
1117
1121
|
Packing is said, because it is a step that can take minutes and had nothing reporting it:
|
|
1118
1122
|
`letify: <runtime>: caching the environment, 2.1 GB`. Skipping it is said too, because it is a
|
|
1119
1123
|
decision letify made rather than a thing that did not
|
|
@@ -1949,7 +1953,7 @@ Colab limits outbound UDP to roughly 200 packets per second, so rank 3 is expect
|
|
|
1949
1953
|
|
|
1950
1954
|
Kaggle reports 2.0 MiB/s, measured on a live account sending 64 MiB over the kernel bridge, and an account overrides it with `fallback_mib_per_s`. Without this a live session refused a punched link at down 8.1 MiB/s for being under the 10 MiB/s floor and then carried 26 GB over the 2 MiB/s bridge instead, which is the opposite of what the floor is for. Taking the fallback is also never silent: the reason is printed, because a session that quietly changed channel is one nobody can account for.
|
|
1951
1955
|
|
|
1952
|
-
A `channel = "exec"` entry skips the pipeline and uses the fallback directly. A Colab VM has no SSH server on the port letify splices to, so the rendezvous request asks the remote half to start one first. The remote half connects to `127.0.0.1:<ssh_port>`, and when no line starting with `SSH-` arrives within 3 s it installs `openssh-server` if `/usr/sbin/sshd` is missing, creates `/run/sshd`, runs `ssh-keygen -A` so the server has host keys, and starts `/usr/sbin/sshd -p <ssh_port> -o ListenAddress=127.0.0.1`. `ssh-keygen -A` creates only the keys that are missing, and it is needed because a Kaggle image ships `sshd` with no host key at all: the install step above is skipped since the package is already there, and a server with no host key accepts the connection and closes it before the banner, which the client reports as `Connection closed by 127.0.0.1 port <port>` with no reason. The port is explicit because a Colab image ships `sshd` configured for `127.0.0.1:2222`, so starting it with its own configuration leaves port 22 closed. An SSH server that already answers on the port is used as it is. The request carries the account's public key, `key` with `.pub` appended, which the remote side adds to `authorized_keys`. An account with no `key`, or whose `.pub` file is missing, has nothing the VM could authorize, so its Colab rendezvous is unavailable with the reason `no key`: `tcp_punch` and `tailcat` are skipped with that reason and the fallback carries the session. SSH over a punched or Tailcat link logs in as `root` unless `user` says otherwise.
|
|
1956
|
+
A `channel = "exec"` entry skips the pipeline and uses the fallback directly. A Colab VM has no SSH server on the port letify splices to, so the rendezvous request asks the remote half to start one first. The remote half connects to `127.0.0.1:<ssh_port>`, and when no line starting with `SSH-` arrives within 3 s it installs `openssh-server` if `/usr/sbin/sshd` is missing, creates `/run/sshd`, runs `ssh-keygen -A` so the server has host keys, and starts `/usr/sbin/sshd -f /etc/ssh/sshd_config.letify -p <ssh_port> -o ListenAddress=127.0.0.1`. That file is the image's `/etc/ssh/sshd_config` with every `MaxStartups` line removed and `MaxStartups 64:30:128` as its first line, or that line alone when the image has no file. A Colab image's file sets `MaxStartups 1`, so the server dropped every connection that arrived while another was still authenticating, and a command-line `-o MaxStartups` does not override the file. Over `reverse_ssh` the transfer streams of a session arrive at once, so every one after the first ended with `Connection closed by 127.0.0.1 port <port>` and the call failed with `the worker exited before it was ready`. With the file, one 190.7 MiB blob crossed a reverse SSH link from a Colab CPU runtime in 5.4 s, 35.5 MiB/s. `ssh-keygen -A` creates only the keys that are missing, and it is needed because a Kaggle image ships `sshd` with no host key at all: the install step above is skipped since the package is already there, and a server with no host key accepts the connection and closes it before the banner, which the client reports as `Connection closed by 127.0.0.1 port <port>` with no reason. The port is explicit because a Colab image ships `sshd` configured for `127.0.0.1:2222`, so starting it with its own configuration leaves port 22 closed. An SSH server that already answers on the port is used as it is. The request carries the account's public key, `key` with `.pub` appended, which the remote side adds to `authorized_keys`. An account with no `key`, or whose `.pub` file is missing, has nothing the VM could authorize, so its Colab rendezvous is unavailable with the reason `no key`: `tcp_punch` and `tailcat` are skipped with that reason and the fallback carries the session. SSH over a punched or Tailcat link logs in as `root` unless `user` says otherwise.
|
|
1953
1957
|
|
|
1954
1958
|
A Colab VM keeps its GPU driver libraries in `/usr/lib64-nvidia`, and only the notebook kernel's own environment names that directory in `LD_LIBRARY_PATH`. A login shell does not, so a worker started over SSH finds no `libnvidia-ml.so` and no `libcuda.so`: `nvidia-smi` fails and `torch.cuda.is_available()` is false on a session that holds a GPU. Every command letify runs over a Colab link, the worker and the `check` command included, is therefore prefixed with `LD_LIBRARY_PATH=/usr/lib64-nvidia${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}`, which keeps a path the machine already set. The `colab exec` fallback needs no prefix, because it runs inside the kernel.
|
|
1955
1959
|
|
|
@@ -130,6 +130,10 @@ class _CallPickler(cloudpickle.Pickler):
|
|
|
130
130
|
return super().reducer_override(obj)
|
|
131
131
|
|
|
132
132
|
|
|
133
|
+
#: Prefix of a digest computed with blake2b because blake3 was missing. A blake3 digest has none.
|
|
134
|
+
BLAKE2B_PREFIX = "b2-"
|
|
135
|
+
|
|
136
|
+
|
|
133
137
|
def _hasher() -> Any:
|
|
134
138
|
"""A fresh incremental hasher: blake3 on every core, or blake2b where blake3 is missing."""
|
|
135
139
|
try:
|
|
@@ -146,10 +150,28 @@ def new_hasher() -> Any:
|
|
|
146
150
|
return _hasher()
|
|
147
151
|
|
|
148
152
|
|
|
153
|
+
def hasher_for(digest: str) -> Any | None:
|
|
154
|
+
"""A fresh hasher of the algorithm ``digest`` names, or None where it cannot be computed.
|
|
155
|
+
|
|
156
|
+
Spec "Argument addressing": a digest names its own algorithm, so a side checking one
|
|
157
|
+
follows the side that computed it. None means blake3 is missing here and the digest is
|
|
158
|
+
blake3, so the check cannot be made.
|
|
159
|
+
"""
|
|
160
|
+
if digest.startswith(BLAKE2B_PREFIX):
|
|
161
|
+
import hashlib
|
|
162
|
+
|
|
163
|
+
return hashlib.blake2b(digest_size=16)
|
|
164
|
+
try:
|
|
165
|
+
import blake3
|
|
166
|
+
except ImportError:
|
|
167
|
+
return None
|
|
168
|
+
return blake3.blake3(max_threads=blake3.blake3.AUTO)
|
|
169
|
+
|
|
170
|
+
|
|
149
171
|
def finish_digest(hasher: Any) -> str:
|
|
150
|
-
"""The content address a hasher from ``new_hasher`` has accumulated."""
|
|
172
|
+
"""The content address a hasher from ``new_hasher`` or ``hasher_for`` has accumulated."""
|
|
151
173
|
if hasher.name == "blake2b":
|
|
152
|
-
return str(hasher.hexdigest())
|
|
174
|
+
return BLAKE2B_PREFIX + str(hasher.hexdigest())
|
|
153
175
|
return str(hasher.hexdigest(length=16))
|
|
154
176
|
|
|
155
177
|
|
|
@@ -158,14 +180,13 @@ def digest_parts(parts: Iterable[Any]) -> str:
|
|
|
158
180
|
|
|
159
181
|
blake3 is preferred because it hashes at several gigabytes per second, which
|
|
160
182
|
keeps hashing orders of magnitude away from being the bottleneck on any real
|
|
161
|
-
network link. blake2b from the standard library is the fallback
|
|
183
|
+
network link. blake2b from the standard library is the fallback, and its digests
|
|
184
|
+
carry ``BLAKE2B_PREFIX``.
|
|
162
185
|
"""
|
|
163
186
|
hasher = _hasher()
|
|
164
187
|
for part in parts:
|
|
165
188
|
hasher.update(part)
|
|
166
|
-
|
|
167
|
-
return hasher.hexdigest()
|
|
168
|
-
return hasher.hexdigest(length=16)
|
|
189
|
+
return finish_digest(hasher)
|
|
169
190
|
|
|
170
191
|
|
|
171
192
|
def digest_of(payload: bytes) -> str:
|
|
@@ -297,6 +318,7 @@ __all__ = [
|
|
|
297
318
|
"dumps_call",
|
|
298
319
|
"dumps_call_parts",
|
|
299
320
|
"finish_digest",
|
|
321
|
+
"hasher_for",
|
|
300
322
|
"new_hasher",
|
|
301
323
|
"parse",
|
|
302
324
|
"split_output",
|
|
@@ -247,11 +247,9 @@ def _release_output():
|
|
|
247
247
|
|
|
248
248
|
|
|
249
249
|
def _digest(payload):
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
except ImportError:
|
|
254
|
-
return hashlib.blake2b(payload, digest_size=16).hexdigest()
|
|
250
|
+
hasher = _data_new_hasher()
|
|
251
|
+
hasher.update(payload)
|
|
252
|
+
return _data_finish(hasher)
|
|
255
253
|
|
|
256
254
|
|
|
257
255
|
def _resolve(value):
|
|
@@ -394,20 +392,22 @@ def _letify_replica(replica):
|
|
|
394
392
|
_replicas._WHO.rate_bps = replica.get("rate_bps")
|
|
395
393
|
|
|
396
394
|
|
|
397
|
-
def _data_hash_file(path):
|
|
398
|
-
"""The file digest of a file on the runtime: blake3 when present, else blake2b.
|
|
399
|
-
|
|
395
|
+
def _data_hash_file(path, like=None):
|
|
396
|
+
"""The file digest of a file on the runtime: blake3 when present, else blake2b.
|
|
397
|
+
|
|
398
|
+
With ``like``, a digest the file may already be named by, the algorithm that digest
|
|
399
|
+
names is used when it can be, so the result is comparable with it.
|
|
400
|
+
"""
|
|
401
|
+
hasher = _data_hasher(like) if like is not None else None
|
|
400
402
|
if hasher is None:
|
|
401
|
-
hasher =
|
|
403
|
+
hasher = _data_new_hasher()
|
|
402
404
|
with open(path, "rb") as handle:
|
|
403
405
|
while True:
|
|
404
406
|
piece = handle.read(1 << 22)
|
|
405
407
|
if not piece:
|
|
406
408
|
break
|
|
407
409
|
hasher.update(piece)
|
|
408
|
-
|
|
409
|
-
return hasher.hexdigest()
|
|
410
|
-
return hasher.hexdigest(length=16)
|
|
410
|
+
return _data_finish(hasher)
|
|
411
411
|
|
|
412
412
|
|
|
413
413
|
def _data_same_bytes(path, digest):
|
|
@@ -418,7 +418,7 @@ def _data_same_bytes(path, digest):
|
|
|
418
418
|
Never raises; a file that cannot be read is not the file the digest promised.
|
|
419
419
|
"""
|
|
420
420
|
try:
|
|
421
|
-
return _data_hash_file(path) == digest
|
|
421
|
+
return _data_hash_file(path, digest) == digest
|
|
422
422
|
except OSError:
|
|
423
423
|
return False
|
|
424
424
|
|
|
@@ -453,7 +453,7 @@ def _data_collect(data, placed):
|
|
|
453
453
|
# The placed record ends with the cache stamp, which is not part of the comparison.
|
|
454
454
|
if known is not None and known[1:4] == (info.st_ino, info.st_size, info.st_mtime_ns):
|
|
455
455
|
continue
|
|
456
|
-
digest = _data_hash_file(full)
|
|
456
|
+
digest = _data_hash_file(full, known[0] if known is not None else None)
|
|
457
457
|
if known is not None and known[0] == digest:
|
|
458
458
|
continue
|
|
459
459
|
final = _data_file(data["blobs"], digest)
|
|
@@ -667,7 +667,14 @@ def _data_file(root, digest):
|
|
|
667
667
|
return os.path.join(root, digest[:2], digest)
|
|
668
668
|
|
|
669
669
|
|
|
670
|
-
def _data_hasher():
|
|
670
|
+
def _data_hasher(digest):
|
|
671
|
+
"""A hasher of the algorithm ``digest`` names, or None where it cannot be computed here.
|
|
672
|
+
|
|
673
|
+
A digest with the ``b2-`` prefix is blake2b and one without is blake3, so a runtime
|
|
674
|
+
without blake3 cannot check a blake3 digest and trusts the sender.
|
|
675
|
+
"""
|
|
676
|
+
if digest.startswith("b2-"):
|
|
677
|
+
return hashlib.blake2b(digest_size=16)
|
|
671
678
|
try:
|
|
672
679
|
import blake3
|
|
673
680
|
except ImportError:
|
|
@@ -675,10 +682,23 @@ def _data_hasher():
|
|
|
675
682
|
return blake3.blake3(max_threads=blake3.blake3.AUTO)
|
|
676
683
|
|
|
677
684
|
|
|
685
|
+
def _data_new_hasher():
|
|
686
|
+
"""The hasher this runtime names new content with: blake3 when present, else blake2b."""
|
|
687
|
+
hasher = _data_hasher("")
|
|
688
|
+
return hasher if hasher is not None else hashlib.blake2b(digest_size=16)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _data_finish(hasher):
|
|
692
|
+
"""The digest a hasher has accumulated, with the prefix a blake2b digest carries."""
|
|
693
|
+
if hasher.name == "blake2b":
|
|
694
|
+
return "b2-" + hasher.hexdigest()
|
|
695
|
+
return hasher.hexdigest(length=16)
|
|
696
|
+
|
|
697
|
+
|
|
678
698
|
def _data_commit(partial, final, digest, hasher):
|
|
679
699
|
"""Rename a received file into the cache once its digest matches, read-only."""
|
|
680
700
|
if hasher is not None:
|
|
681
|
-
found = hasher
|
|
701
|
+
found = _data_finish(hasher)
|
|
682
702
|
if found != digest:
|
|
683
703
|
os.remove(partial)
|
|
684
704
|
raise ValueError("file blob %s arrived with digest %s" % (digest, found))
|
|
@@ -838,7 +858,7 @@ def _op_data_put(request):
|
|
|
838
858
|
stale = _DATA_OPEN.pop(digest, None)
|
|
839
859
|
if stale is not None:
|
|
840
860
|
stale[0].close()
|
|
841
|
-
_DATA_OPEN[digest] = (open(partial, "wb"), _data_hasher())
|
|
861
|
+
_DATA_OPEN[digest] = (open(partial, "wb"), _data_hasher(digest))
|
|
842
862
|
handle, hasher = _DATA_OPEN[digest]
|
|
843
863
|
handle.write(chunk)
|
|
844
864
|
if hasher is not None:
|
|
@@ -938,7 +958,7 @@ def _op_data_seal(request):
|
|
|
938
958
|
raise ValueError(
|
|
939
959
|
"file blob %s is short: %d of %d bytes arrived" % (digest, found, int(size))
|
|
940
960
|
)
|
|
941
|
-
hasher = _data_hasher()
|
|
961
|
+
hasher = _data_hasher(digest)
|
|
942
962
|
if hasher is not None:
|
|
943
963
|
with open(partial, "rb") as handle:
|
|
944
964
|
while True:
|
|
@@ -946,7 +966,7 @@ def _op_data_seal(request):
|
|
|
946
966
|
if not piece:
|
|
947
967
|
break
|
|
948
968
|
hasher.update(piece)
|
|
949
|
-
got = hasher
|
|
969
|
+
got = _data_finish(hasher)
|
|
950
970
|
if got != digest:
|
|
951
971
|
os.remove(partial)
|
|
952
972
|
raise ValueError("file blob %s arrived with digest %s" % (digest, got))
|
|
@@ -979,7 +999,7 @@ def _op_data_pull(request):
|
|
|
979
999
|
partial = "%s.partial.%d.%d" % (final, os.getpid(), threading.get_ident())
|
|
980
1000
|
try:
|
|
981
1001
|
os.makedirs(os.path.dirname(final), exist_ok=True)
|
|
982
|
-
hasher = _data_hasher()
|
|
1002
|
+
hasher = _data_hasher(digest)
|
|
983
1003
|
wanted = urllib.request.Request(url, headers=headers)
|
|
984
1004
|
with urllib.request.urlopen(wanted, timeout=3600) as response, \
|
|
985
1005
|
open(partial, "wb") as out:
|
|
@@ -2334,16 +2354,14 @@ def _op_pack_blob(request):
|
|
|
2334
2354
|
directory = os.path.expanduser(request["dir"])
|
|
2335
2355
|
os.makedirs(directory, exist_ok=True)
|
|
2336
2356
|
partial = os.path.join(directory, ".pack.%d.partial" % os.getpid())
|
|
2337
|
-
hasher =
|
|
2338
|
-
if hasher is None:
|
|
2339
|
-
hasher = hashlib.blake2b(digest_size=16)
|
|
2357
|
+
hasher = _data_new_hasher()
|
|
2340
2358
|
try:
|
|
2341
2359
|
with open(partial, "wb") as handle:
|
|
2342
2360
|
hashed = _HashedFile(handle, hasher)
|
|
2343
2361
|
mode = "w:gz" if request.get("gzip", True) else "w"
|
|
2344
2362
|
with tarfile.open(fileobj=hashed, mode=mode) as archive:
|
|
2345
2363
|
archive.add(root, arcname=os.path.basename(root.rstrip("/")), recursive=True)
|
|
2346
|
-
digest =
|
|
2364
|
+
digest = _data_finish(hasher)
|
|
2347
2365
|
final = _data_file(directory, digest)
|
|
2348
2366
|
os.makedirs(os.path.dirname(final), exist_ok=True)
|
|
2349
2367
|
os.replace(partial, final)
|
|
@@ -86,6 +86,13 @@ def _against_the_build(size: int, rate: float, build_s: float) -> str:
|
|
|
86
86
|
)
|
|
87
87
|
|
|
88
88
|
|
|
89
|
+
def _reason_of(error: BaseException) -> str:
|
|
90
|
+
"""One line naming an error's type and first message line, for a status message."""
|
|
91
|
+
lines = str(error).strip().splitlines()
|
|
92
|
+
reason = f"{type(error).__name__}: {lines[0]}" if lines else type(error).__name__
|
|
93
|
+
return reason[:197] + "..." if len(reason) > 200 else reason
|
|
94
|
+
|
|
95
|
+
|
|
89
96
|
def _sync_rather_than_restore(archive_bytes: int, rate: float, build_s: float) -> str:
|
|
90
97
|
"""The line explaining why a session syncs instead of restoring a cached archive.
|
|
91
98
|
|
|
@@ -243,9 +250,14 @@ class Runtime:
|
|
|
243
250
|
if volume.store.backend.moves_on_runtime:
|
|
244
251
|
self._start_drive_pack(volume, root)
|
|
245
252
|
else:
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
253
|
+
try:
|
|
254
|
+
volume.cache_env_from(
|
|
255
|
+
self, self.env, root, platform=self.platform, build_s=self.env_build_s
|
|
256
|
+
)
|
|
257
|
+
except Exception as error:
|
|
258
|
+
# The environment is built and checked, so a failed cache must not
|
|
259
|
+
# fail the session. Spec "Materializing into a runtime".
|
|
260
|
+
self._say(f"not caching the environment ({_reason_of(error)})")
|
|
249
261
|
self.keep_blobs_on_disk()
|
|
250
262
|
for volume in self.volumes:
|
|
251
263
|
self.attach(volume)
|
|
@@ -831,11 +843,7 @@ class Runtime:
|
|
|
831
843
|
self.eval(source, timeout=3600)
|
|
832
844
|
except Exception as error:
|
|
833
845
|
# The environment is built and checked, so a failed cache must not fail the call.
|
|
834
|
-
|
|
835
|
-
reason = f"{type(error).__name__}: {lines[0]}" if lines else type(error).__name__
|
|
836
|
-
if len(reason) > 200:
|
|
837
|
-
reason = reason[:197] + "..."
|
|
838
|
-
self._say(f"not caching the environment on the workspace root ({reason})")
|
|
846
|
+
self._say(f"not caching the environment on the workspace root ({_reason_of(error)})")
|
|
839
847
|
|
|
840
848
|
def _environment_volumes(self) -> tuple[Volume, ...]:
|
|
841
849
|
"""Explicit archive volumes, or the automatic cache where the project does not survive.
|
|
@@ -248,7 +248,13 @@ def unstash(name: str, path: str | os.PathLike[str]) -> Path | None:
|
|
|
248
248
|
|
|
249
249
|
|
|
250
250
|
def _verify(name: str, archive: Path, digest: str) -> None:
|
|
251
|
-
|
|
251
|
+
hasher = codec.hasher_for(digest)
|
|
252
|
+
if hasher is None:
|
|
253
|
+
return
|
|
254
|
+
with open(archive, "rb") as handle:
|
|
255
|
+
while chunk := handle.read(1 << 20):
|
|
256
|
+
hasher.update(chunk)
|
|
257
|
+
found = codec.finish_digest(hasher)
|
|
252
258
|
if found != digest:
|
|
253
259
|
raise RuntimeFailure(
|
|
254
260
|
f"handoff {name!r}: the blob downloaded from Drive has digest {found}, "
|
|
@@ -139,9 +139,9 @@ def hash_file(path: Path, size: int) -> str:
|
|
|
139
139
|
else:
|
|
140
140
|
while chunk := handle.read(1 << 20):
|
|
141
141
|
hasher.update(chunk)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return hasher
|
|
142
|
+
from ..protocol.codec import finish_digest
|
|
143
|
+
|
|
144
|
+
return finish_digest(hasher)
|
|
145
145
|
|
|
146
146
|
|
|
147
147
|
class DigestCache:
|
|
@@ -1608,7 +1608,7 @@ def _get(
|
|
|
1608
1608
|
Spec "Writing back". One writing thread takes the pieces, so receiving the next one
|
|
1609
1609
|
overlaps writing and hashing the one before it and the link does not idle.
|
|
1610
1610
|
"""
|
|
1611
|
-
from ..protocol.codec import
|
|
1611
|
+
from ..protocol.codec import finish_digest, hasher_for
|
|
1612
1612
|
|
|
1613
1613
|
# Spec "Writing back": a stream that fails after these were created would leave them
|
|
1614
1614
|
# empty, and a resume that picks the newest directory by name then trusts a checkpoint
|
|
@@ -1617,7 +1617,8 @@ def _get(
|
|
|
1617
1617
|
partial = target.with_name(
|
|
1618
1618
|
f".{target.name}.letify-partial.{os.getpid()}.{threading.get_ident()}"
|
|
1619
1619
|
)
|
|
1620
|
-
|
|
1620
|
+
# The algorithm the digest names, which is the worker's when its venv has no blake3.
|
|
1621
|
+
hasher = hasher_for(digest)
|
|
1621
1622
|
pieces: queue.Queue = queue.Queue(maxsize=WRITE_QUEUE)
|
|
1622
1623
|
failure: list[BaseException] = []
|
|
1623
1624
|
|
|
@@ -1630,7 +1631,8 @@ def _get(
|
|
|
1630
1631
|
if piece is None:
|
|
1631
1632
|
return
|
|
1632
1633
|
handle.write(piece)
|
|
1633
|
-
hasher
|
|
1634
|
+
if hasher is not None:
|
|
1635
|
+
hasher.update(piece)
|
|
1634
1636
|
except BaseException as exc: # pragma: no cover - a local disk failure
|
|
1635
1637
|
failure.append(exc)
|
|
1636
1638
|
# Drained, so the receiving thread is never left waiting on a full queue.
|
|
@@ -1657,7 +1659,7 @@ def _get(
|
|
|
1657
1659
|
raise RuntimeFailure(
|
|
1658
1660
|
f"{runtime.name}: file blob {digest} arrived with {received} bytes, not {size}"
|
|
1659
1661
|
)
|
|
1660
|
-
found = hasher
|
|
1662
|
+
found = finish_digest(hasher) if hasher is not None else digest
|
|
1661
1663
|
if found != digest:
|
|
1662
1664
|
raise RuntimeFailure(f"{runtime.name}: file blob {digest} arrived with digest {found}")
|
|
1663
1665
|
os.replace(partial, target)
|
|
@@ -659,6 +659,31 @@ def _authorize(public_key: str) -> None:
|
|
|
659
659
|
#: The SSH server binary the remote half starts on a machine that has none on the port.
|
|
660
660
|
SSHD = "/usr/sbin/sshd"
|
|
661
661
|
|
|
662
|
+
#: The image's own server configuration, which the started server's is copied from.
|
|
663
|
+
SSHD_CONFIG = "/etc/ssh/sshd_config"
|
|
664
|
+
|
|
665
|
+
#: The configuration the remote half starts the server with.
|
|
666
|
+
LETIFY_SSHD_CONFIG = "/etc/ssh/sshd_config.letify"
|
|
667
|
+
|
|
668
|
+
#: Unauthenticated connections the server takes at once. Spec "Colab": a Colab image sets 1,
|
|
669
|
+
#: and the transfer streams of one session arrive together.
|
|
670
|
+
SSHD_MAX_STARTUPS = "64:30:128"
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _write_sshd_config(source: str, target: str) -> None:
|
|
674
|
+
"""Copy ``source`` to ``target`` with its MaxStartups replaced by ``SSHD_MAX_STARTUPS``.
|
|
675
|
+
|
|
676
|
+
A command-line ``-o MaxStartups`` does not override the file's value, so the file the
|
|
677
|
+
server reads has to carry it.
|
|
678
|
+
"""
|
|
679
|
+
try:
|
|
680
|
+
lines = Path(source).read_text().splitlines()
|
|
681
|
+
except OSError:
|
|
682
|
+
lines = []
|
|
683
|
+
kept = [line for line in lines if not line.strip().lower().startswith("maxstartups")]
|
|
684
|
+
# First, ahead of any Match block, so it applies to the whole server.
|
|
685
|
+
Path(target).write_text("\n".join([f"MaxStartups {SSHD_MAX_STARTUPS}", *kept]) + "\n")
|
|
686
|
+
|
|
662
687
|
|
|
663
688
|
def _ssh_answers(port: int, timeout: float = 3.0) -> bool:
|
|
664
689
|
"""Whether a line starting with ``SSH-`` arrives from ``127.0.0.1:<port>``."""
|
|
@@ -691,7 +716,11 @@ def _start_sshd(port: int = 22) -> None:
|
|
|
691
716
|
# server closes every connection before the banner. ssh-keygen -A makes only what is
|
|
692
717
|
# missing, so an image that already has its keys is untouched.
|
|
693
718
|
subprocess.run(["ssh-keygen", "-A"], check=False)
|
|
694
|
-
|
|
719
|
+
_write_sshd_config(SSHD_CONFIG, LETIFY_SSHD_CONFIG)
|
|
720
|
+
subprocess.run(
|
|
721
|
+
[SSHD, "-f", LETIFY_SSHD_CONFIG, "-p", str(port), "-o", "ListenAddress=127.0.0.1"],
|
|
722
|
+
check=False,
|
|
723
|
+
)
|
|
695
724
|
|
|
696
725
|
|
|
697
726
|
def run_detached(request_json: str, source: str) -> None: # pragma: no cover - remote entry
|
|
@@ -138,7 +138,7 @@ dependencies = [
|
|
|
138
138
|
|
|
139
139
|
[[package]]
|
|
140
140
|
name = "letify-agent"
|
|
141
|
-
version = "2.8.
|
|
141
|
+
version = "2.8.4"
|
|
142
142
|
dependencies = [
|
|
143
143
|
"letify-wire",
|
|
144
144
|
"libloading",
|
|
@@ -146,7 +146,7 @@ dependencies = [
|
|
|
146
146
|
|
|
147
147
|
[[package]]
|
|
148
148
|
name = "letify-driver"
|
|
149
|
-
version = "2.8.
|
|
149
|
+
version = "2.8.4"
|
|
150
150
|
dependencies = [
|
|
151
151
|
"letify-wire",
|
|
152
152
|
]
|
|
@@ -164,7 +164,7 @@ dependencies = [
|
|
|
164
164
|
|
|
165
165
|
[[package]]
|
|
166
166
|
name = "letify-wire"
|
|
167
|
-
version = "2.8.
|
|
167
|
+
version = "2.8.4"
|
|
168
168
|
|
|
169
169
|
[[package]]
|
|
170
170
|
name = "libc"
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letify-status",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.4",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "letify-status",
|
|
9
|
-
"version": "2.8.
|
|
9
|
+
"version": "2.8.4",
|
|
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.8.
|
|
5
|
+
"version": "2.8.4",
|
|
6
6
|
"publisher": "darkpyonix",
|
|
7
7
|
"license": "SEE LICENSE IN LICENSE",
|
|
8
8
|
"repository": {
|
|
@@ -124,6 +124,23 @@ class PreparingLocal(Local):
|
|
|
124
124
|
remote_env = True
|
|
125
125
|
|
|
126
126
|
|
|
127
|
+
class NoBlake3Local(Local):
|
|
128
|
+
"""A local provider whose worker cannot import blake3, like a runtime venv without letify.
|
|
129
|
+
|
|
130
|
+
The worker is the real one behind the real framed protocol. Hiding the module before
|
|
131
|
+
the bootstrap runs makes it hash with blake2b, which the client then has to follow.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def open_channel(self, runtime: Any) -> Any:
|
|
135
|
+
from letify.protocol.worker import BOOTSTRAP
|
|
136
|
+
from letify.runtime.channel import PersistentChannel
|
|
137
|
+
|
|
138
|
+
hide = "import sys; sys.modules['blake3'] = None\n"
|
|
139
|
+
return PersistentChannel(
|
|
140
|
+
[sys.executable, "-u", "-c", hide + BOOTSTRAP], name=runtime.name
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
127
144
|
@pytest.fixture
|
|
128
145
|
def uv_project(tmp_path: Path, monkeypatch) -> Path:
|
|
129
146
|
"""A tiny uv project that depends on this checkout of letify, locked with the real uv.
|
|
@@ -60,8 +60,10 @@ def test_content_addressing_falls_back_to_the_standard_library(no_module) -> Non
|
|
|
60
60
|
# blake3 is preferred for speed, but it is a wheel, so blake2b has to carry the same
|
|
61
61
|
# guarantee when it is absent.
|
|
62
62
|
no_module("blake3")
|
|
63
|
+
# Its digests carry the b2- prefix, so a side with blake3 does not mistake them for its own.
|
|
63
64
|
digest = codec.digest_of(b"same contents")
|
|
64
|
-
assert
|
|
65
|
+
assert digest.startswith("b2-")
|
|
66
|
+
assert len(digest) == 35
|
|
65
67
|
assert digest == codec.digest_of(b"same contents")
|
|
66
68
|
|
|
67
69
|
|
|
@@ -2982,3 +2982,37 @@ def test_a_small_environment_is_still_packed_and_says_so(
|
|
|
2982
2982
|
runtime.shutdown()
|
|
2983
2983
|
assert volume.cached_env(env, platform)
|
|
2984
2984
|
assert "caching the environment" in capsys.readouterr().err
|
|
2985
|
+
|
|
2986
|
+
|
|
2987
|
+
def test_a_failure_to_cache_the_environment_through_the_client_store_does_not_fail_the_session(
|
|
2988
|
+
uv_project: Path, tmp_path: Path, monkeypatch, capsys
|
|
2989
|
+
) -> None:
|
|
2990
|
+
# Spec "Materializing into a runtime": a failed cache at boot is said and the
|
|
2991
|
+
# session goes on, the same as a failed pack on the workspace root.
|
|
2992
|
+
from letify.runtime.session import Runtime
|
|
2993
|
+
from letify.store.volume import Volume
|
|
2994
|
+
|
|
2995
|
+
provider = provider_of(PreparingLocal, "lab", persistent=False)
|
|
2996
|
+
volume = provider.volume(
|
|
2997
|
+
"cache", backend="filesystem", root=str(tmp_path / "store"), mount=str(tmp_path / "mount")
|
|
2998
|
+
)
|
|
2999
|
+
env = Env()
|
|
3000
|
+
instance = Instance(provider, gpu=None)._placed("remote")
|
|
3001
|
+
real = Runtime.install_env
|
|
3002
|
+
|
|
3003
|
+
def slow_build(self: Runtime) -> None:
|
|
3004
|
+
real(self)
|
|
3005
|
+
self.env_build_s = 10_000.0
|
|
3006
|
+
|
|
3007
|
+
def refuse(*args: object, **kwargs: object) -> str:
|
|
3008
|
+
raise RuntimeFailure("lab-1: file blob 9870 arrived with digest d0aa")
|
|
3009
|
+
|
|
3010
|
+
monkeypatch.setattr(Runtime, "install_env", slow_build)
|
|
3011
|
+
monkeypatch.setattr(Volume, "cache_env_from", refuse)
|
|
3012
|
+
runtime = provider.start(instance, env, name="lab-1", volumes=(volume,))
|
|
3013
|
+
try:
|
|
3014
|
+
assert runtime.ready
|
|
3015
|
+
finally:
|
|
3016
|
+
runtime.shutdown()
|
|
3017
|
+
err = capsys.readouterr().err
|
|
3018
|
+
assert "not caching the environment (RuntimeFailure: lab-1: file blob 9870" in err, err
|
|
@@ -875,3 +875,57 @@ def test_a_one_shot_runtime_pulls_an_archive_without_relaying_it_through_the_cli
|
|
|
875
875
|
blobs = [r for r in fake_gcs.downloads() if "/blobs/" in unquote(r["path"])]
|
|
876
876
|
assert [r["authorization"] for r in blobs] == ["Bearer down-token-1"]
|
|
877
877
|
let.pool.shutdown()
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
# -- Spec: Argument addressing, a digest names its own algorithm -----------------
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def test_an_environment_packed_by_a_worker_without_blake3_is_checked_with_blake2b(
|
|
884
|
+
tmp_path: Path,
|
|
885
|
+
) -> None:
|
|
886
|
+
# The worker hashes the archive with blake2b because its venv has no blake3. The
|
|
887
|
+
# client has blake3, so a client that checked with its own algorithm called the same
|
|
888
|
+
# bytes corrupted: "file blob ... arrived with digest ...".
|
|
889
|
+
from conftest import NoBlake3Local, provider_of
|
|
890
|
+
|
|
891
|
+
from letify.declare.env import Env
|
|
892
|
+
from letify.declare.instance import Instance
|
|
893
|
+
|
|
894
|
+
provider = provider_of(NoBlake3Local, "nob3")
|
|
895
|
+
volume = provider.volume("cache", backend="filesystem", root=str(tmp_path / "store"))
|
|
896
|
+
runtime = provider.start(
|
|
897
|
+
Instance(provider, gpu=None)._placed("remote"), Env(lock=str(tmp_path / "x.lock")),
|
|
898
|
+
name="nob3-1",
|
|
899
|
+
)
|
|
900
|
+
try:
|
|
901
|
+
installed = tmp_path / "site"
|
|
902
|
+
installed.mkdir()
|
|
903
|
+
(installed / "marker.txt").write_text("installed", encoding="utf-8")
|
|
904
|
+
digest = volume.cache_env_from(runtime, Env(lock=str(tmp_path / "x.lock")), str(installed))
|
|
905
|
+
finally:
|
|
906
|
+
runtime.shutdown()
|
|
907
|
+
assert digest.startswith("b2-")
|
|
908
|
+
assert volume.store.backend.has(digest)
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def test_a_digest_is_checked_with_the_algorithm_it_names() -> None:
|
|
912
|
+
from letify.protocol import codec
|
|
913
|
+
|
|
914
|
+
hasher = codec.hasher_for("b2-" + "0" * 32)
|
|
915
|
+
hasher.update(b"x")
|
|
916
|
+
assert codec.finish_digest(hasher).startswith("b2-")
|
|
917
|
+
other = codec.hasher_for("0" * 32)
|
|
918
|
+
other.update(b"x")
|
|
919
|
+
assert not codec.finish_digest(other).startswith("b2-")
|
|
920
|
+
assert codec.digest_of(b"x") == codec.finish_digest(other)
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def test_blake2b_is_named_by_a_prefix_when_blake3_is_missing(no_module) -> None:
|
|
924
|
+
from letify.protocol import codec
|
|
925
|
+
|
|
926
|
+
no_module("blake3")
|
|
927
|
+
digest = codec.digest_of(b"same contents")
|
|
928
|
+
assert digest.startswith("b2-")
|
|
929
|
+
assert len(digest) == 35
|
|
930
|
+
assert codec.hasher_for("0" * 32) is None
|
|
931
|
+
assert codec.hasher_for(digest).name == "blake2b"
|