letify 2.8.3__tar.gz → 2.8.5__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.5}/PKG-INFO +1 -1
- {letify-2.8.3 → letify-2.8.5}/docs/SPEC.md +11 -3
- {letify-2.8.3 → letify-2.8.5}/letify/__init__.py +1 -1
- {letify-2.8.3 → letify-2.8.5}/letify/protocol/codec.py +28 -6
- {letify-2.8.3 → letify-2.8.5}/letify/protocol/worker.py +42 -24
- {letify-2.8.3 → letify-2.8.5}/letify/providers/modal.py +75 -11
- {letify-2.8.3 → letify-2.8.5}/letify/providers/modal_adapter.py +1 -0
- {letify-2.8.3 → letify-2.8.5}/letify/runtime/channel.py +11 -2
- {letify-2.8.3 → letify-2.8.5}/letify/runtime/session.py +16 -8
- {letify-2.8.3 → letify-2.8.5}/letify/store/handoff.py +7 -1
- {letify-2.8.3 → letify-2.8.5}/letify/store/pathdata.py +9 -7
- {letify-2.8.3 → letify-2.8.5}/letify/transport/nat.py +30 -1
- {letify-2.8.3 → letify-2.8.5}/letify-core/Cargo.lock +3 -3
- {letify-2.8.3 → letify-2.8.5}/letify-core/Cargo.toml +1 -1
- {letify-2.8.3 → letify-2.8.5}/letify-ext/package-lock.json +2 -2
- {letify-2.8.3 → letify-2.8.5}/letify-ext/package.json +1 -1
- {letify-2.8.3 → letify-2.8.5}/pyproject.toml +1 -1
- {letify-2.8.3 → letify-2.8.5}/tests/conftest.py +17 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_protocol.py +3 -1
- {letify-2.8.3 → letify-2.8.5}/tests/test_providers.py +103 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_runtime.py +34 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_store.py +54 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_strategies.py +32 -2
- {letify-2.8.3 → letify-2.8.5}/.github/workflows/ci.yml +0 -0
- {letify-2.8.3 → letify-2.8.5}/.github/workflows/docs.yml +0 -0
- {letify-2.8.3 → letify-2.8.5}/.github/workflows/publish.yml +0 -0
- {letify-2.8.3 → letify-2.8.5}/.gitignore +0 -0
- {letify-2.8.3 → letify-2.8.5}/.researchtree +0 -0
- {letify-2.8.3 → letify-2.8.5}/CLAUDE.md +0 -0
- {letify-2.8.3 → letify-2.8.5}/LICENSE +0 -0
- {letify-2.8.3 → letify-2.8.5}/PROJECT.md +0 -0
- {letify-2.8.3 → letify-2.8.5}/README.md +0 -0
- {letify-2.8.3 → letify-2.8.5}/docs/COMPONENT.md +0 -0
- {letify-2.8.3 → letify-2.8.5}/docs/INTENT.md +0 -0
- {letify-2.8.3 → letify-2.8.5}/docs/NETWORK.md +0 -0
- {letify-2.8.3 → letify-2.8.5}/docs/guide/01-getting-started.md +0 -0
- {letify-2.8.3 → letify-2.8.5}/docs/guide/02-providers.md +0 -0
- {letify-2.8.3 → letify-2.8.5}/docs/guide/03-execution-modes.md +0 -0
- {letify-2.8.3 → letify-2.8.5}/docs/guide/04-environments-and-data.md +0 -0
- {letify-2.8.3 → letify-2.8.5}/docs/guide/05-concurrency.md +0 -0
- {letify-2.8.3 → letify-2.8.5}/docs/guide/06-cost.md +0 -0
- {letify-2.8.3 → letify-2.8.5}/docs/guide/07-troubleshooting.md +0 -0
- {letify-2.8.3 → letify-2.8.5}/docs/guide/08-releasing.md +0 -0
- {letify-2.8.3 → letify-2.8.5}/docs/guide/README.md +0 -0
- {letify-2.8.3 → letify-2.8.5}/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.5}/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.5}/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.5}/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.5}/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.5}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-2.8.3 → letify-2.8.5}/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.5}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-2.8.3 → letify-2.8.5}/docs/guide/ko/README.md +0 -0
- {letify-2.8.3 → letify-2.8.5}/docs/locales/README_ko.md +0 -0
- {letify-2.8.3 → letify-2.8.5}/docs/mkdocs.yml +0 -0
- {letify-2.8.3 → letify-2.8.5}/examples/README.md +0 -0
- {letify-2.8.3 → letify-2.8.5}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-2.8.3 → letify-2.8.5}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/examples/nvfp4-lora/README.md +0 -0
- {letify-2.8.3 → letify-2.8.5}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-2.8.3 → letify-2.8.5}/examples/nvfp4-lora/common.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/cli.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/config/__init__.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/config/inventory.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/config/login.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/config/schema.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/config/secrets.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/config/writer.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/declare/__init__.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/declare/cache.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/declare/env.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/declare/function.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/declare/instance.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/declare/reduce.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/declare/replicas.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/errors.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/install.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/launcher.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/paths.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/protocol/__init__.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/protocol/driver.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/protocol/handle.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/protocol/wire.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/providers/__init__.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/providers/base.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/providers/colab.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/providers/colab_files.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/providers/colab_keepalive.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/providers/elice.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/providers/kaggle.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/providers/kaggle_adapter.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/providers/local.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/providers/naming.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/providers/shell.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/providers/tunnel.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/providers/usage.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/remoting/__init__.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/remoting/capability.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/remoting/device/__init__.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/remoting/device/client.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/remoting/device/cuda.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/remoting/device/executor.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/remoting/device/frames.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/remoting/device/guard.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/remoting/device/tensor.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/remoting/device/trace.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/remoting/device/value.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/remoting/loader.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/remoting/probe.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/render.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/runtime/__init__.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/runtime/bootstrap.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/runtime/broker.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/runtime/lease.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/runtime/pool.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/runtime/telemetry.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/store/__init__.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/store/backends/__init__.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/store/backends/drive.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/store/backends/filesystem.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/store/backends/google_auth.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/store/backends/layout.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/store/backends/objects.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/store/cas.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/store/sendorder.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/store/volume.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/stubs.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/tools.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/transport/__init__.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/transport/agent.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/transport/announce.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/transport/link.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/transport/pipeline.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/transport/portmap.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/transport/probe.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/transport/quic.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/transport/rendezvous.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/transport/setup.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/transport/sshopts.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify/transport/strategies.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-core/.gitignore +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-core/build.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-core/letify-quic/Cargo.toml +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-core/letify-quic/src/main.rs +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-core/rust-toolchain.toml +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-ext/.vscodeignore +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-ext/LICENSE +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-ext/README.md +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-ext/media/icon.svg +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-ext/src/cli.ts +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-ext/src/extension.ts +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-ext/src/history.ts +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-ext/src/model.ts +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-ext/src/poll.ts +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-ext/src/render.ts +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-ext/test/cli.test.ts +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-ext/test/manifest.test.ts +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-ext/test/model.test.ts +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-ext/test/regressions.test.ts +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify-ext/tsconfig.json +0 -0
- {letify-2.8.3 → letify-2.8.5}/letify_providers.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/scripts/sync-main.sh +0 -0
- {letify-2.8.3 → letify-2.8.5}/scripts/version.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/fake_kaggle_adapter.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/fake_modal_adapter.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_cli.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_cli_output.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_colab_keepalive.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_config.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_core.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_data.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_data_streaming.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_declare.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_device.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_distributed.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_drive_handoff.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_drive_store.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_editor_sessions.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_elice.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_frames.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_install.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_kaggle.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_kaggle_cookie.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_kaggle_link.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_link_log.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_notebook.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_parallel_placement.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_paths.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_portmap.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_quic.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_remoting.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_render.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_ship.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_stubs.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_transport.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_tunnel_setup.py +0 -0
- {letify-2.8.3 → letify-2.8.5}/tests/test_version.py +0 -0
|
@@ -777,7 +777,7 @@ The protocol is one JSON object per line. letify sends `{"id": <int>, "op": <nam
|
|
|
777
777
|
| Op | Fields | Value |
|
|
778
778
|
|---|---|---|
|
|
779
779
|
| `hello` | none | `{"modal": <installed Modal version>}` |
|
|
780
|
-
| `create` | `app`, `args`, `packages`, `gpu`, `timeout`, `idle_timeout`, `volumes`, `ports` | `{"sandbox": <id>}`. Runs `app` as an ephemeral app on first use, builds `debian_slim` with `packages` installed, and starts `args` in a sandbox. Each port in `ports` is exposed with Modal `encrypted_ports` |
|
|
780
|
+
| `create` | `app`, `args`, `packages`, `gpu`, `timeout`, `idle_timeout`, `memory`, `volumes`, `ports` | `{"sandbox": <id>}`. Runs `app` as an ephemeral app on first use, builds `debian_slim` with `packages` installed, and starts `args` in a sandbox. Each port in `ports` is exposed with Modal `encrypted_ports` |
|
|
781
781
|
| `tunnel` | `sandbox`, `port` | `{"host": <text>, "port": <int>, "tls": <bool>}`. The address that reaches `port` inside the sandbox, from `Sandbox.tunnels()`. `tls` is true when the connection has to be made with TLS, which an encrypted port needs |
|
|
782
782
|
| `write` | `sandbox`, `data` | `null`. `data` is base64 of at most 1 MiB. Writes the decoded bytes to the sandbox's standard input and drains it. Modal refuses a write that would buffer more than 2 MiB, so the channel splits a larger frame into `write` requests of 1 MiB, in order |
|
|
783
783
|
| `read_until` | `sandbox`, `prefixes` | `{"lines": [...], "eof": <bool>}`. The sandbox's stdout lines up to and including the first that starts with one of `prefixes`, or every line left when the stream ends |
|
|
@@ -812,8 +812,12 @@ Modal also bounds a sandbox that nothing terminates. `create` passes `idle_timeo
|
|
|
812
812
|
|
|
813
813
|
It passes `timeout`, the entry option `timeout`, as the sandbox's maximum lifetime, and the default is the longest Modal accepts, 86400 s, measured as accepted. It is not a shorter number letify picked, for the reason The declaration gives for `timeout` having no default: a deadline letify invented would end a two hour training run at whatever hour it guessed. The default was 3600 s, exactly one hour, and it ended two Modal sessions of a real training run at one hour each with nothing saying why, which is that sentence happening. An account that wants a hard cap sets `timeout` itself.
|
|
814
814
|
|
|
815
|
+
It passes `memory`, the entry option `memory_gib` converted to MiB, as the sandbox's memory request, and passes nothing when the account does not set it, so Modal's default applies. A training process whose RAM grows past what the sandbox gets is killed with SIGKILL, which the channel sees as a worker that stopped without replying; an account whose runs need more sets `memory_gib`, for example `memory_gib = 64`. A value that is not a positive number raises `ConfigError` naming `memory_gib`.
|
|
816
|
+
|
|
815
817
|
A sandbox that reaches that lifetime takes its worker with it, so the channel sees a worker that stopped without replying. Where the session's age is within a minute of the lifetime letify asked for, the failure says so instead of listing causes: `the Modal sandbox reached the <seconds> s lifetime letify asked for; raise timeout on the account to run longer`.
|
|
816
818
|
|
|
819
|
+
Otherwise a Modal worker that stopped without replying is followed by one `poll` of its sandbox from a second adapter process, waiting at most 30 s for the exit code, and the failure names what the poll found instead of listing causes: `the Modal sandbox exited with code <n>` for a sandbox that ended, with `, which is SIGKILL, most often the container running out of memory` added for 137, and `the Modal sandbox is still running, so the worker process died inside it` for one still running. A poll that fails or times out leaves the message as it was. The message is what tells an out-of-memory kill from a preemption, which a training run that died twice in one day could not.
|
|
820
|
+
|
|
817
821
|
### Modal data channel <!-- id: modal-data-channel -->
|
|
818
822
|
|
|
819
823
|
> Once the worker has said hello over the sandbox's standard input and output, the channel moves its frames to a TCP connection through a Modal encrypted port. Standard input and output stay the control path and the fallback.
|
|
@@ -924,6 +928,8 @@ A top-level argument whose serialized size is 64 KiB or more is replaced by a `B
|
|
|
924
928
|
|
|
925
929
|
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
930
|
|
|
931
|
+
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.
|
|
932
|
+
|
|
927
933
|
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
934
|
|
|
929
935
|
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 +1094,7 @@ neither side holds it whole:
|
|
|
1088
1094
|
returns `{"digest", "size", "dir"}`. The client receives the file with the stream a write-back
|
|
1089
1095
|
uses, checks the digest as the pieces arrive into a scratch file beside the store, and renames it
|
|
1090
1096
|
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
|
|
1097
|
+
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
1098
|
- Restore: a blob of more than one piece (8 MiB) that the store holds as a file is sent with
|
|
1093
1099
|
`data_put` pieces into a staging directory beside the destination, then moved into place and
|
|
1094
1100
|
unpacked by the worker op `place_blob`. Smaller blobs, and any blob from a store that does not
|
|
@@ -1114,6 +1120,8 @@ the kind this rule exists to catch, and the session that syncs instead both reco
|
|
|
1114
1120
|
decides afresh whether the archive is worth keeping. The cost of that is one sync; the cost of
|
|
1115
1121
|
trusting it is the transfer this rule was measured to avoid.
|
|
1116
1122
|
|
|
1123
|
+
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.
|
|
1124
|
+
|
|
1117
1125
|
Packing is said, because it is a step that can take minutes and had nothing reporting it:
|
|
1118
1126
|
`letify: <runtime>: caching the environment, 2.1 GB`. Skipping it is said too, because it is a
|
|
1119
1127
|
decision letify made rather than a thing that did not
|
|
@@ -1949,7 +1957,7 @@ Colab limits outbound UDP to roughly 200 packets per second, so rank 3 is expect
|
|
|
1949
1957
|
|
|
1950
1958
|
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
1959
|
|
|
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.
|
|
1960
|
+
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
1961
|
|
|
1954
1962
|
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
1963
|
|
|
@@ -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)
|
|
@@ -31,7 +31,7 @@ import tempfile
|
|
|
31
31
|
import threading
|
|
32
32
|
import time
|
|
33
33
|
import weakref
|
|
34
|
-
from collections.abc import Mapping
|
|
34
|
+
from collections.abc import Callable, Mapping
|
|
35
35
|
from typing import IO, TYPE_CHECKING, Any
|
|
36
36
|
|
|
37
37
|
from ..config import ProviderConfig
|
|
@@ -133,6 +133,22 @@ def lifetime_detail(*, age_s: float, lifetime_s: int) -> str | None:
|
|
|
133
133
|
)
|
|
134
134
|
|
|
135
135
|
|
|
136
|
+
def exit_cause(found: tuple[bool, int | None] | None) -> str | None:
|
|
137
|
+
"""What a poll of a sandbox whose worker stopped says, or None when it is unknown.
|
|
138
|
+
|
|
139
|
+
Spec "Modal": the exit code tells an out of memory kill from a preemption.
|
|
140
|
+
"""
|
|
141
|
+
if found is None:
|
|
142
|
+
return None
|
|
143
|
+
ended, code = found
|
|
144
|
+
if not ended:
|
|
145
|
+
return "the Modal sandbox is still running, so the worker process died inside it"
|
|
146
|
+
text = f"the Modal sandbox exited with code {code}"
|
|
147
|
+
if code == 137:
|
|
148
|
+
text += ", which is SIGKILL, most often the container running out of memory"
|
|
149
|
+
return text
|
|
150
|
+
|
|
151
|
+
|
|
136
152
|
class VolumePathMissing(RuntimeFailure):
|
|
137
153
|
"""The adapter answered that a volume path does not exist."""
|
|
138
154
|
|
|
@@ -419,6 +435,21 @@ class Modal(Provider):
|
|
|
419
435
|
value = self.config.option("idle_timeout", IDLE_TIMEOUT)
|
|
420
436
|
return max(1, int(value))
|
|
421
437
|
|
|
438
|
+
@property
|
|
439
|
+
def sandbox_memory_mib(self) -> int | None:
|
|
440
|
+
"""The sandbox's memory request in MiB from ``memory_gib``, or None for Modal's own.
|
|
441
|
+
|
|
442
|
+
Spec "Modal": a training process past what the sandbox gets is killed with SIGKILL.
|
|
443
|
+
"""
|
|
444
|
+
value = self.config.option("memory_gib")
|
|
445
|
+
if value is None:
|
|
446
|
+
return None
|
|
447
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
|
|
448
|
+
raise ConfigError(
|
|
449
|
+
f"{self.alias}: memory_gib has to be a positive number of GiB, not {value!r}"
|
|
450
|
+
)
|
|
451
|
+
return int(value * 1024)
|
|
452
|
+
|
|
422
453
|
def adapter(self) -> Adapter:
|
|
423
454
|
"""The adapter acting as this account, started on its first request."""
|
|
424
455
|
if self._adapter is None or self._adapter.closed:
|
|
@@ -484,6 +515,7 @@ class Modal(Provider):
|
|
|
484
515
|
gpu=self.wire_name(runtime.instance) or None,
|
|
485
516
|
timeout=self.sandbox_timeout_s,
|
|
486
517
|
idle_timeout=self.sandbox_idle_timeout_s,
|
|
518
|
+
memory=self.sandbox_memory_mib,
|
|
487
519
|
ports=[] if data_port is None else [data_port],
|
|
488
520
|
)
|
|
489
521
|
sandbox = str(created["sandbox"])
|
|
@@ -491,17 +523,14 @@ class Modal(Provider):
|
|
|
491
523
|
return SandboxChannel(
|
|
492
524
|
adapter, sandbox, name=runtime.name, data_port=data_port, streams=streams,
|
|
493
525
|
lifetime_s=self.sandbox_timeout_s,
|
|
526
|
+
probe=lambda: self.poll_sandbox(sandbox),
|
|
494
527
|
)
|
|
495
528
|
|
|
496
|
-
def
|
|
497
|
-
"""Poll
|
|
529
|
+
def poll_sandbox(self, sandbox: str) -> tuple[bool, int | None] | None:
|
|
530
|
+
"""Poll a sandbox once from a second adapter: ``(ended, exit code)``, or None if unknown.
|
|
498
531
|
|
|
499
|
-
|
|
500
|
-
request, so a short-lived one asks. Any failure answers unknown.
|
|
532
|
+
A short-lived adapter asks, because the one carrying the call may be blocked.
|
|
501
533
|
"""
|
|
502
|
-
sandbox = self._sandboxes.get(runtime.name)
|
|
503
|
-
if sandbox is None:
|
|
504
|
-
return RuntimeState()
|
|
505
534
|
other = Adapter.for_account(self.alias)
|
|
506
535
|
# A poll that hangs ends the second adapter, which makes the request raise.
|
|
507
536
|
limit = threading.Timer(POLL_LIMIT, other.abort)
|
|
@@ -510,11 +539,26 @@ class Modal(Provider):
|
|
|
510
539
|
try:
|
|
511
540
|
code = other.request("poll", sandbox=sandbox)
|
|
512
541
|
except (LetifyError, ValueError, OSError):
|
|
513
|
-
return
|
|
542
|
+
return None
|
|
514
543
|
finally:
|
|
515
544
|
limit.cancel()
|
|
516
545
|
other.close()
|
|
517
|
-
|
|
546
|
+
return (code is not None, code)
|
|
547
|
+
|
|
548
|
+
def runtime_state(self, runtime: Runtime) -> RuntimeState:
|
|
549
|
+
"""Poll the sandbox from a second adapter process, never the one carrying the call.
|
|
550
|
+
|
|
551
|
+
Spec "Lifetime", the lease. The adapter carrying the call may be blocked in a
|
|
552
|
+
request, so a short-lived one asks. Any failure answers unknown.
|
|
553
|
+
"""
|
|
554
|
+
sandbox = self._sandboxes.get(runtime.name)
|
|
555
|
+
if sandbox is None:
|
|
556
|
+
return RuntimeState()
|
|
557
|
+
found = self.poll_sandbox(sandbox)
|
|
558
|
+
if found is None:
|
|
559
|
+
return RuntimeState()
|
|
560
|
+
ended, code = found
|
|
561
|
+
if not ended:
|
|
518
562
|
return RuntimeState("running")
|
|
519
563
|
return RuntimeState(
|
|
520
564
|
"ended", f"the Modal sandbox ended (exit code {code}) while the call was running"
|
|
@@ -686,8 +730,11 @@ class SandboxChannel(FramedChannel):
|
|
|
686
730
|
data_port: int | None = None,
|
|
687
731
|
streams: int = 1,
|
|
688
732
|
lifetime_s: int = 0,
|
|
733
|
+
probe: Callable[[], tuple[bool, int | None] | None] | None = None,
|
|
689
734
|
):
|
|
690
735
|
self.adapter = adapter
|
|
736
|
+
#: Polls the sandbox from a second adapter, see ``Modal.poll_sandbox``.
|
|
737
|
+
self.probe = probe
|
|
691
738
|
self.sandbox = sandbox
|
|
692
739
|
self.name = name
|
|
693
740
|
self.data_port = data_port
|
|
@@ -716,6 +763,7 @@ class SandboxChannel(FramedChannel):
|
|
|
716
763
|
wire.chunks_readinto(self._read_chunks),
|
|
717
764
|
self._emit,
|
|
718
765
|
death_detail=self._raw_text,
|
|
766
|
+
death_cause=self._exit_cause,
|
|
719
767
|
)
|
|
720
768
|
self._connection = self._stdio
|
|
721
769
|
self._send_worker()
|
|
@@ -730,6 +778,17 @@ class SandboxChannel(FramedChannel):
|
|
|
730
778
|
)
|
|
731
779
|
return f"{said}\n{text}" if said else text
|
|
732
780
|
|
|
781
|
+
def _exit_cause(self) -> str | None:
|
|
782
|
+
"""What one poll of the sandbox found, or None to keep the usual causes.
|
|
783
|
+
|
|
784
|
+
Spec "Modal": a lifetime death is already named by ``_raw_text``, so it is not polled.
|
|
785
|
+
"""
|
|
786
|
+
if self.probe is None or lifetime_detail(
|
|
787
|
+
age_s=time.monotonic() - self.opened_at, lifetime_s=self.lifetime_s
|
|
788
|
+
):
|
|
789
|
+
return None
|
|
790
|
+
return exit_cause(self.probe())
|
|
791
|
+
|
|
733
792
|
def _send_worker(self) -> None:
|
|
734
793
|
# After a reexec the data connection is gone, so the source goes back over stdio.
|
|
735
794
|
if self._connection is not self._stdio:
|
|
@@ -821,7 +880,12 @@ class SandboxChannel(FramedChannel):
|
|
|
821
880
|
)
|
|
822
881
|
carrier.timeout = DATA_CONNECT_TIMEOUT
|
|
823
882
|
connection = Connection(
|
|
824
|
-
self.name,
|
|
883
|
+
self.name,
|
|
884
|
+
carrier.send,
|
|
885
|
+
carrier.recv_into,
|
|
886
|
+
self._emit,
|
|
887
|
+
death_detail=self._raw_text,
|
|
888
|
+
death_cause=self._exit_cause,
|
|
825
889
|
)
|
|
826
890
|
# The connect timeout still applies, so a missing hello fails here.
|
|
827
891
|
connection.wait_hello()
|
|
@@ -109,6 +109,7 @@ class Adapter:
|
|
|
109
109
|
gpu=request.get("gpu") or None,
|
|
110
110
|
timeout=int(request["timeout"]),
|
|
111
111
|
idle_timeout=int(request["idle_timeout"]) if request.get("idle_timeout") else None,
|
|
112
|
+
memory=int(request["memory"]) if request.get("memory") else None,
|
|
112
113
|
volumes={
|
|
113
114
|
str(path): modal.Volume.from_name(str(name), create_if_missing=True)
|
|
114
115
|
for path, name in (request.get("volumes") or {}).items()
|
|
@@ -258,6 +258,7 @@ class Connection:
|
|
|
258
258
|
emit: Callable[[str, bytes], None],
|
|
259
259
|
*,
|
|
260
260
|
death_detail: Callable[[], str] = lambda: "",
|
|
261
|
+
death_cause: Callable[[], str | None] = lambda: None,
|
|
261
262
|
poll_fd: int | None = None,
|
|
262
263
|
):
|
|
263
264
|
self.name = name
|
|
@@ -276,6 +277,8 @@ class Connection:
|
|
|
276
277
|
self._receiver = wire.Receiver(readinto)
|
|
277
278
|
self._emit = emit
|
|
278
279
|
self._death_detail = death_detail
|
|
280
|
+
#: Names what ended the worker when the provider can tell, replacing the usual causes.
|
|
281
|
+
self._death_cause = death_cause
|
|
279
282
|
#: Guards ``_reading`` and wakes waiters when a reply, a hello or a failure arrives.
|
|
280
283
|
self._turn = threading.Condition()
|
|
281
284
|
self._reading = False
|
|
@@ -383,10 +386,16 @@ class Connection:
|
|
|
383
386
|
return
|
|
384
387
|
if message is None:
|
|
385
388
|
detail = self._death_detail()
|
|
389
|
+
cause = self._death_cause()
|
|
390
|
+
causes = (
|
|
391
|
+
f": {cause}."
|
|
392
|
+
if cause
|
|
393
|
+
else ". The usual causes are an out of memory kill, a preempted session, "
|
|
394
|
+
"or a crash below Python."
|
|
395
|
+
)
|
|
386
396
|
message = (
|
|
387
397
|
f"{self.name}: the worker stopped without replying, so the process died "
|
|
388
|
-
f"before it finished
|
|
389
|
-
f"preempted session, or a crash below Python.\n--- last remote output ---\n"
|
|
398
|
+
f"before it finished{causes}\n--- last remote output ---\n"
|
|
390
399
|
f"{self._tail.text()[-2000:]}{detail[-2000:]}"
|
|
391
400
|
)
|
|
392
401
|
with self._turn:
|
|
@@ -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)
|