letify 2.8.2__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.2 → letify-2.8.4}/PKG-INFO +1 -1
- {letify-2.8.2 → letify-2.8.4}/docs/SPEC.md +8 -4
- {letify-2.8.2 → letify-2.8.4}/letify/__init__.py +1 -1
- {letify-2.8.2 → letify-2.8.4}/letify/protocol/codec.py +28 -6
- {letify-2.8.2 → letify-2.8.4}/letify/protocol/worker.py +42 -24
- {letify-2.8.2 → letify-2.8.4}/letify/providers/colab.py +39 -5
- {letify-2.8.2 → letify-2.8.4}/letify/runtime/bootstrap.py +4 -2
- {letify-2.8.2 → letify-2.8.4}/letify/runtime/session.py +21 -4
- {letify-2.8.2 → letify-2.8.4}/letify/store/handoff.py +7 -1
- {letify-2.8.2 → letify-2.8.4}/letify/store/pathdata.py +9 -7
- {letify-2.8.2 → letify-2.8.4}/letify/transport/nat.py +30 -1
- {letify-2.8.2 → letify-2.8.4}/letify-core/Cargo.lock +3 -3
- {letify-2.8.2 → letify-2.8.4}/letify-core/Cargo.toml +1 -1
- {letify-2.8.2 → letify-2.8.4}/letify-ext/package-lock.json +2 -2
- {letify-2.8.2 → letify-2.8.4}/letify-ext/package.json +1 -1
- {letify-2.8.2 → letify-2.8.4}/pyproject.toml +1 -1
- {letify-2.8.2 → letify-2.8.4}/tests/conftest.py +18 -1
- {letify-2.8.2 → letify-2.8.4}/tests/test_drive_store.py +26 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_protocol.py +3 -1
- {letify-2.8.2 → letify-2.8.4}/tests/test_providers.py +72 -3
- {letify-2.8.2 → letify-2.8.4}/tests/test_runtime.py +109 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_store.py +54 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_strategies.py +32 -2
- {letify-2.8.2 → letify-2.8.4}/.github/workflows/ci.yml +0 -0
- {letify-2.8.2 → letify-2.8.4}/.github/workflows/docs.yml +0 -0
- {letify-2.8.2 → letify-2.8.4}/.github/workflows/publish.yml +0 -0
- {letify-2.8.2 → letify-2.8.4}/.gitignore +0 -0
- {letify-2.8.2 → letify-2.8.4}/.researchtree +0 -0
- {letify-2.8.2 → letify-2.8.4}/CLAUDE.md +0 -0
- {letify-2.8.2 → letify-2.8.4}/LICENSE +0 -0
- {letify-2.8.2 → letify-2.8.4}/PROJECT.md +0 -0
- {letify-2.8.2 → letify-2.8.4}/README.md +0 -0
- {letify-2.8.2 → letify-2.8.4}/docs/COMPONENT.md +0 -0
- {letify-2.8.2 → letify-2.8.4}/docs/INTENT.md +0 -0
- {letify-2.8.2 → letify-2.8.4}/docs/NETWORK.md +0 -0
- {letify-2.8.2 → letify-2.8.4}/docs/guide/01-getting-started.md +0 -0
- {letify-2.8.2 → letify-2.8.4}/docs/guide/02-providers.md +0 -0
- {letify-2.8.2 → letify-2.8.4}/docs/guide/03-execution-modes.md +0 -0
- {letify-2.8.2 → letify-2.8.4}/docs/guide/04-environments-and-data.md +0 -0
- {letify-2.8.2 → letify-2.8.4}/docs/guide/05-concurrency.md +0 -0
- {letify-2.8.2 → letify-2.8.4}/docs/guide/06-cost.md +0 -0
- {letify-2.8.2 → letify-2.8.4}/docs/guide/07-troubleshooting.md +0 -0
- {letify-2.8.2 → letify-2.8.4}/docs/guide/08-releasing.md +0 -0
- {letify-2.8.2 → letify-2.8.4}/docs/guide/README.md +0 -0
- {letify-2.8.2 → 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.2 → 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.2 → 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.2 → 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.2 → 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.2 → letify-2.8.4}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-2.8.2 → 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.2 → letify-2.8.4}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-2.8.2 → letify-2.8.4}/docs/guide/ko/README.md +0 -0
- {letify-2.8.2 → letify-2.8.4}/docs/locales/README_ko.md +0 -0
- {letify-2.8.2 → letify-2.8.4}/docs/mkdocs.yml +0 -0
- {letify-2.8.2 → letify-2.8.4}/examples/README.md +0 -0
- {letify-2.8.2 → letify-2.8.4}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-2.8.2 → letify-2.8.4}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/examples/nvfp4-lora/README.md +0 -0
- {letify-2.8.2 → letify-2.8.4}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-2.8.2 → letify-2.8.4}/examples/nvfp4-lora/common.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/cli.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/config/__init__.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/config/inventory.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/config/login.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/config/schema.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/config/secrets.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/config/writer.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/declare/__init__.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/declare/cache.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/declare/env.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/declare/function.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/declare/instance.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/declare/reduce.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/declare/replicas.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/errors.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/install.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/launcher.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/paths.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/protocol/__init__.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/protocol/driver.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/protocol/handle.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/protocol/wire.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/providers/__init__.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/providers/base.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/providers/colab_files.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/providers/colab_keepalive.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/providers/elice.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/providers/kaggle.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/providers/kaggle_adapter.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/providers/local.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/providers/modal.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/providers/modal_adapter.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/providers/naming.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/providers/shell.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/providers/tunnel.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/providers/usage.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/remoting/__init__.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/remoting/capability.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/remoting/device/__init__.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/remoting/device/client.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/remoting/device/cuda.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/remoting/device/executor.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/remoting/device/frames.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/remoting/device/guard.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/remoting/device/tensor.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/remoting/device/trace.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/remoting/device/value.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/remoting/loader.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/remoting/probe.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/render.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/runtime/__init__.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/runtime/broker.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/runtime/channel.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/runtime/lease.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/runtime/pool.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/runtime/telemetry.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/store/__init__.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/store/backends/__init__.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/store/backends/drive.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/store/backends/filesystem.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/store/backends/google_auth.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/store/backends/layout.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/store/backends/objects.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/store/cas.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/store/sendorder.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/store/volume.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/stubs.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/tools.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/transport/__init__.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/transport/agent.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/transport/announce.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/transport/link.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/transport/pipeline.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/transport/portmap.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/transport/probe.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/transport/quic.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/transport/rendezvous.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/transport/setup.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/transport/sshopts.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify/transport/strategies.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-core/.gitignore +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-core/build.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-core/letify-quic/Cargo.toml +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-core/letify-quic/src/main.rs +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-core/rust-toolchain.toml +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-ext/.vscodeignore +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-ext/LICENSE +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-ext/README.md +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-ext/media/icon.svg +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-ext/src/cli.ts +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-ext/src/extension.ts +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-ext/src/history.ts +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-ext/src/model.ts +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-ext/src/poll.ts +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-ext/src/render.ts +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-ext/test/cli.test.ts +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-ext/test/manifest.test.ts +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-ext/test/model.test.ts +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-ext/test/regressions.test.ts +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify-ext/tsconfig.json +0 -0
- {letify-2.8.2 → letify-2.8.4}/letify_providers.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/scripts/sync-main.sh +0 -0
- {letify-2.8.2 → letify-2.8.4}/scripts/version.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/fake_kaggle_adapter.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/fake_modal_adapter.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_cli.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_cli_output.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_colab_keepalive.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_config.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_core.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_data.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_data_streaming.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_declare.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_device.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_distributed.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_drive_handoff.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_editor_sessions.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_elice.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_frames.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_install.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_kaggle.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_kaggle_cookie.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_kaggle_link.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_link_log.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_notebook.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_parallel_placement.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_paths.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_portmap.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_quic.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_remoting.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_render.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_ship.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_stubs.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_transport.py +0 -0
- {letify-2.8.2 → letify-2.8.4}/tests/test_tunnel_setup.py +0 -0
- {letify-2.8.2 → 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
|
|
@@ -1162,7 +1166,7 @@ The archive is `<workspace root>/env-archives/<env key>-<platform>.tar`, uncompr
|
|
|
1162
1166
|
|
|
1163
1167
|
The client's inbound link is the weak one, so the store path is slower than the build in both directions. Compression costs more than it saves on the volume, so the archive is not compressed.
|
|
1164
1168
|
|
|
1165
|
-
After a successful sync and interpreter check, the runtime says `caching the environment on the workspace root, <size>`, runs tar from the project directory's parent into a temporary name beside the destination, and renames it into place, so a concurrent session never reads a half written archive. Nothing is sent to the client, and no measured rate gates the pack, because the transfer stays inside the provider.
|
|
1169
|
+
After a successful sync and interpreter check, the runtime says `caching the environment on the workspace root, <size>`, runs tar from the project directory's parent into a temporary name beside the destination, and renames it into place, so a concurrent session never reads a half written archive. The temporary name carries 16 random hex digits, `<archive>.<hex>.partial`, not the process id, because every Modal sandbox runs its worker under the same small pid, so two sessions packing the same environment at once wrote one file and one of them failed with tar exit status 2. A pack that fails for any reason removes its temporary file, says `letify: <runtime>: not caching the environment on the workspace root (<reason>)`, and the session goes on: the environment is already built and checked, so a failed cache never fails the session or its call. Nothing is sent to the client, and no measured rate gates the pack, because the transfer stays inside the provider.
|
|
1166
1170
|
|
|
1167
1171
|
At the start of `install_env`, when that archive exists on the runtime, the runtime says `restoring the environment from the workspace root`, extracts it into the project directory's parent, and validates the interpreter with the same check an archive restore uses. The restore refuses a member whose path escapes the destination and allows the absolute symlinks a `.venv` needs. A directory an earlier session left under the same name is removed first. When validation fails, the runtime removes the extracted tree, syncs, and replaces the archive. `Runtime.env_source` is `"workspace"` after this restore. This path is taken before and instead of the store path, and such a provider never uses the client store or the bucket store for its environment.
|
|
1168
1172
|
|
|
@@ -1931,7 +1935,7 @@ A jump host that letify cannot log in to, or whose `authorized_keys` it cannot e
|
|
|
1931
1935
|
|
|
1932
1936
|
> Colab is a `Shell` whose rendezvous is `colab exec`. It has no forward SSH, and its fallback is `colab exec` with the Colab file API.
|
|
1933
1937
|
|
|
1934
|
-
The Colab CLI runs as `uv tool run --from google-colab-cli colab`, with `jupyter-kernel-client<1` pinned, because release 0.6.0 of the CLI calls an API that jupyter-kernel-client 1.0 removed. `colab new` and `colab stop` manage the session. `Colab.sessions()` reads `colab sessions` and returns the first word of each listing line. The CLI prints a session as `[<name>] <id> | Hardware: <hardware> | Variant: <variant>`, so a first word in square brackets gives the name inside them. A line starting with `[colab]` is a message from the CLI, such as `[colab] No active sessions found on server.`, and names no session, so an account with no session returns an empty list.
|
|
1938
|
+
The Colab CLI runs as `uv tool run --from google-colab-cli colab`, with `jupyter-kernel-client<1` pinned, because release 0.6.0 of the CLI calls an API that jupyter-kernel-client 1.0 removed. `colab new` and `colab stop` manage the session. `Colab.sessions()` reads `colab sessions` and returns the first word of each listing line. The CLI prints a session as `[<name>] <id> | Hardware: <hardware> | Variant: <variant>`, so a first word in square brackets gives the name inside them. A line starting with `[colab]` is a message from the CLI, such as `[colab] No active sessions found on server.`, and names no session, so an account with no session returns an empty list. `colab new` runs through the CLI's own Python with a request timeout of 600 s instead of google-auth's 120 s. google-auth binds `google.auth.transport.requests._DEFAULT_TIMEOUT` into `AuthorizedSession.request`'s signature when the module loads, so setting the module value alone changes nothing: the script sets it to 600 and wraps `AuthorizedSession.request` to default `timeout` to it, then calls the CLI's entry point `colab_cli.cli:main`. That is needed because the assignment request for an A100 can take longer than 120 s to answer and the CLI then fails with `ReadTimeout` while the server goes on assigning the machine. letify's own limit on `colab new` stays 900 s.
|
|
1935
1939
|
|
|
1936
1940
|
**An assignment with no session.** `colab new` can fail after Colab has already assigned a machine, for example with `ReadTimeout` after 120 s, and the machine then bills (14.2 compute units per hour for an A100) with no local session state. `colab sessions` lists it as `[?] <endpoint> | ...`, and `colab stop -s <name>` cannot reach it. `Colab.create_session` therefore lists the account's assignments before `colab new`. When `colab new` raises, for any reason, it lists them again, releases every `?` assignment whose endpoint was not in the first list, and re-raises the original error. An assignment that existed before the attempt is never released. Each release prints one line on standard error naming the endpoint. When the first or the second listing fails, one line on standard error names the endpoint check and the error, and the original error is still the one raised. An assignment is released by endpoint with `Client.unassign` of the Colab CLI, run as `uv tool run --from google-colab-cli python -c <script> <endpoint>` with the account's environment, because the CLI has no command for it. `Colab.orphans()` returns the endpoints of the `?` rows of `colab sessions` and `Colab.stop_orphan(endpoint)` releases one, so `letify stop --orphans` covers Colab as it covers Kaggle.
|
|
1937
1941
|
|
|
@@ -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)
|
|
@@ -131,6 +131,26 @@ UNASSIGN_SCRIPT = (
|
|
|
131
131
|
)
|
|
132
132
|
|
|
133
133
|
|
|
134
|
+
#: Run in the CLI's own Python: ``colab`` with google-auth's request timeout raised to 600 s.
|
|
135
|
+
#: The CLI passes no timeout, so a slow A100 assignment fails at the 120 s default while the
|
|
136
|
+
#: server goes on assigning. google-auth binds the 120 s default into the signature of
|
|
137
|
+
#: ``AuthorizedSession.request`` when it is defined, so setting the module value alone changes
|
|
138
|
+
#: nothing and the method is wrapped as well. Spec "Colab", the Colab CLI paragraph.
|
|
139
|
+
NEW_SCRIPT = (
|
|
140
|
+
"import sys\n"
|
|
141
|
+
"import google.auth.transport.requests as requests\n"
|
|
142
|
+
"requests._DEFAULT_TIMEOUT = 600\n"
|
|
143
|
+
"_request = requests.AuthorizedSession.request\n"
|
|
144
|
+
"def _slow_request(self, *args, **kwargs):\n"
|
|
145
|
+
" kwargs.setdefault('timeout', requests._DEFAULT_TIMEOUT)\n"
|
|
146
|
+
" return _request(self, *args, **kwargs)\n"
|
|
147
|
+
"requests.AuthorizedSession.request = _slow_request\n"
|
|
148
|
+
"from colab_cli.cli import main\n"
|
|
149
|
+
"sys.argv = ['colab', *sys.argv[1:]]\n"
|
|
150
|
+
"sys.exit(main())\n"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
134
154
|
def _parse_assignments(listing: str) -> list[tuple[str, str]]:
|
|
135
155
|
"""The ``(name, endpoint)`` pairs in ``colab sessions`` output, ``?`` for no local state."""
|
|
136
156
|
found = []
|
|
@@ -275,8 +295,21 @@ class Colab(Shell):
|
|
|
275
295
|
|
|
276
296
|
# -- the CLI -------------------------------------------------------------
|
|
277
297
|
|
|
278
|
-
def _cli(
|
|
279
|
-
|
|
298
|
+
def _cli(
|
|
299
|
+
self,
|
|
300
|
+
*args: str,
|
|
301
|
+
timeout: float | None = None,
|
|
302
|
+
stdin: str | None = None,
|
|
303
|
+
script: str | None = None,
|
|
304
|
+
) -> str:
|
|
305
|
+
"""Run the CLI; with ``script``, run that code in the CLI's Python with ``args`` as argv."""
|
|
306
|
+
if script is None:
|
|
307
|
+
command = [*self._colab(), *args]
|
|
308
|
+
else:
|
|
309
|
+
uv = tools.find_uv()
|
|
310
|
+
if uv is None:
|
|
311
|
+
raise ProviderUnavailable(self.kind, tools.missing_uv_message())
|
|
312
|
+
command = [*tools.python_command(tools.COLAB, uv), "-c", script, *args]
|
|
280
313
|
result = subprocess.run(
|
|
281
314
|
command,
|
|
282
315
|
input=stdin,
|
|
@@ -286,9 +319,10 @@ class Colab(Shell):
|
|
|
286
319
|
env=self._env(),
|
|
287
320
|
)
|
|
288
321
|
if result.returncode != 0:
|
|
322
|
+
shown = ["<script>" if part == script else part for part in command]
|
|
289
323
|
raise RuntimeFailure(
|
|
290
|
-
f"`{' '.join(
|
|
291
|
-
command=" ".join(
|
|
324
|
+
f"`{' '.join(shown)}` exited {result.returncode}",
|
|
325
|
+
command=" ".join(shown),
|
|
292
326
|
stderr=(result.stderr or "").strip(),
|
|
293
327
|
)
|
|
294
328
|
return result.stdout
|
|
@@ -482,7 +516,7 @@ class Colab(Shell):
|
|
|
482
516
|
file=sys.stderr,
|
|
483
517
|
)
|
|
484
518
|
try:
|
|
485
|
-
self._cli(*args, timeout=900)
|
|
519
|
+
self._cli(*args, timeout=900, script=NEW_SCRIPT)
|
|
486
520
|
except BaseException:
|
|
487
521
|
# A timed out `colab new` can still leave a server-side assignment behind.
|
|
488
522
|
if before is not None:
|
|
@@ -281,13 +281,15 @@ def workspace_pack_source(root: str, archive: str) -> str:
|
|
|
281
281
|
Spec "Volumes on a persistent runtime": runs ``tar`` from the directory's parent into a
|
|
282
282
|
temporary name and renames it into place, so a concurrent session never reads a half
|
|
283
283
|
written archive. Uncompressed, because gzip costs more than it saves on a local volume.
|
|
284
|
+
The temporary name carries random hex, not the process id, because every Modal sandbox
|
|
285
|
+
runs its worker under the same pid. A failed pack removes its temporary file.
|
|
284
286
|
"""
|
|
285
287
|
return (
|
|
286
|
-
"import os, subprocess\n"
|
|
288
|
+
"import os, secrets, subprocess\n"
|
|
287
289
|
f"_root = os.path.expanduser({root!r}).rstrip('/')\n"
|
|
288
290
|
f"_archive = os.path.expanduser({archive!r})\n"
|
|
289
291
|
"os.makedirs(os.path.dirname(_archive), exist_ok=True)\n"
|
|
290
|
-
"_partial = _archive + '.' +
|
|
292
|
+
"_partial = _archive + '.' + secrets.token_hex(8) + '.partial'\n"
|
|
291
293
|
"try:\n"
|
|
292
294
|
" subprocess.run(['tar', '-cf', _partial, '-C', os.path.dirname(_root),\n"
|
|
293
295
|
" os.path.basename(_root)], check=True, capture_output=True)\n"
|
|
@@ -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)
|
|
@@ -826,7 +838,12 @@ class Runtime:
|
|
|
826
838
|
"caching the environment on the workspace root"
|
|
827
839
|
+ (f", {size / 1_000_000_000:.1f} GB" if size else "")
|
|
828
840
|
)
|
|
829
|
-
|
|
841
|
+
source = bootstrap.workspace_pack_source(root, self._workspace_archive())
|
|
842
|
+
try:
|
|
843
|
+
self.eval(source, timeout=3600)
|
|
844
|
+
except Exception as error:
|
|
845
|
+
# The environment is built and checked, so a failed cache must not fail the call.
|
|
846
|
+
self._say(f"not caching the environment on the workspace root ({_reason_of(error)})")
|
|
830
847
|
|
|
831
848
|
def _environment_volumes(self) -> tuple[Volume, ...]:
|
|
832
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": {
|