letify 2.8.4__tar.gz → 2.8.6__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.4 → letify-2.8.6}/PKG-INFO +1 -1
- {letify-2.8.4 → letify-2.8.6}/PROJECT.md +1 -0
- {letify-2.8.4 → letify-2.8.6}/docs/SPEC.md +15 -7
- {letify-2.8.4 → letify-2.8.6}/letify/__init__.py +19 -3
- {letify-2.8.4 → letify-2.8.6}/letify/protocol/worker.py +8 -2
- {letify-2.8.4 → letify-2.8.6}/letify/providers/modal.py +75 -11
- {letify-2.8.4 → letify-2.8.6}/letify/providers/modal_adapter.py +1 -0
- {letify-2.8.4 → letify-2.8.6}/letify/runtime/channel.py +92 -7
- {letify-2.8.4 → letify-2.8.6}/letify/store/backends/drive.py +35 -7
- {letify-2.8.4 → letify-2.8.6}/letify/store/handoff.py +243 -18
- {letify-2.8.4 → letify-2.8.6}/letify/transport/nat.py +96 -19
- {letify-2.8.4 → letify-2.8.6}/letify/transport/pipeline.py +9 -2
- {letify-2.8.4 → letify-2.8.6}/letify/transport/rendezvous.py +31 -0
- {letify-2.8.4 → letify-2.8.6}/letify/transport/strategies.py +29 -19
- {letify-2.8.4 → letify-2.8.6}/letify-core/Cargo.lock +3 -3
- {letify-2.8.4 → letify-2.8.6}/letify-core/Cargo.toml +1 -1
- {letify-2.8.4 → letify-2.8.6}/letify-ext/package-lock.json +2 -2
- {letify-2.8.4 → letify-2.8.6}/letify-ext/package.json +1 -1
- {letify-2.8.4 → letify-2.8.6}/pyproject.toml +1 -1
- {letify-2.8.4 → letify-2.8.6}/tests/conftest.py +12 -1
- {letify-2.8.4 → letify-2.8.6}/tests/test_drive_handoff.py +335 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_providers.py +103 -0
- letify-2.8.6/tests/test_quic.py +370 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_runtime.py +92 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_strategies.py +64 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_transport.py +63 -0
- letify-2.8.4/tests/test_quic.py +0 -187
- {letify-2.8.4 → letify-2.8.6}/.github/workflows/ci.yml +0 -0
- {letify-2.8.4 → letify-2.8.6}/.github/workflows/docs.yml +0 -0
- {letify-2.8.4 → letify-2.8.6}/.github/workflows/publish.yml +0 -0
- {letify-2.8.4 → letify-2.8.6}/.gitignore +0 -0
- {letify-2.8.4 → letify-2.8.6}/.researchtree +0 -0
- {letify-2.8.4 → letify-2.8.6}/CLAUDE.md +0 -0
- {letify-2.8.4 → letify-2.8.6}/LICENSE +0 -0
- {letify-2.8.4 → letify-2.8.6}/README.md +0 -0
- {letify-2.8.4 → letify-2.8.6}/docs/COMPONENT.md +0 -0
- {letify-2.8.4 → letify-2.8.6}/docs/INTENT.md +0 -0
- {letify-2.8.4 → letify-2.8.6}/docs/NETWORK.md +0 -0
- {letify-2.8.4 → letify-2.8.6}/docs/guide/01-getting-started.md +0 -0
- {letify-2.8.4 → letify-2.8.6}/docs/guide/02-providers.md +0 -0
- {letify-2.8.4 → letify-2.8.6}/docs/guide/03-execution-modes.md +0 -0
- {letify-2.8.4 → letify-2.8.6}/docs/guide/04-environments-and-data.md +0 -0
- {letify-2.8.4 → letify-2.8.6}/docs/guide/05-concurrency.md +0 -0
- {letify-2.8.4 → letify-2.8.6}/docs/guide/06-cost.md +0 -0
- {letify-2.8.4 → letify-2.8.6}/docs/guide/07-troubleshooting.md +0 -0
- {letify-2.8.4 → letify-2.8.6}/docs/guide/08-releasing.md +0 -0
- {letify-2.8.4 → letify-2.8.6}/docs/guide/README.md +0 -0
- {letify-2.8.4 → letify-2.8.6}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
- {letify-2.8.4 → letify-2.8.6}/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.4 → letify-2.8.6}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
- {letify-2.8.4 → letify-2.8.6}/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.4 → letify-2.8.6}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
- {letify-2.8.4 → letify-2.8.6}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-2.8.4 → letify-2.8.6}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
- {letify-2.8.4 → letify-2.8.6}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-2.8.4 → letify-2.8.6}/docs/guide/ko/README.md +0 -0
- {letify-2.8.4 → letify-2.8.6}/docs/locales/README_ko.md +0 -0
- {letify-2.8.4 → letify-2.8.6}/docs/mkdocs.yml +0 -0
- {letify-2.8.4 → letify-2.8.6}/examples/README.md +0 -0
- {letify-2.8.4 → letify-2.8.6}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-2.8.4 → letify-2.8.6}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/examples/nvfp4-lora/README.md +0 -0
- {letify-2.8.4 → letify-2.8.6}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-2.8.4 → letify-2.8.6}/examples/nvfp4-lora/common.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/cli.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/config/__init__.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/config/inventory.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/config/login.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/config/schema.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/config/secrets.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/config/writer.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/declare/__init__.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/declare/cache.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/declare/env.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/declare/function.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/declare/instance.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/declare/reduce.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/declare/replicas.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/errors.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/install.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/launcher.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/paths.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/protocol/__init__.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/protocol/codec.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/protocol/driver.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/protocol/handle.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/protocol/wire.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/providers/__init__.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/providers/base.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/providers/colab.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/providers/colab_files.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/providers/colab_keepalive.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/providers/elice.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/providers/kaggle.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/providers/kaggle_adapter.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/providers/local.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/providers/naming.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/providers/shell.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/providers/tunnel.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/providers/usage.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/remoting/__init__.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/remoting/capability.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/remoting/device/__init__.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/remoting/device/client.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/remoting/device/cuda.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/remoting/device/executor.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/remoting/device/frames.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/remoting/device/guard.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/remoting/device/tensor.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/remoting/device/trace.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/remoting/device/value.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/remoting/loader.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/remoting/probe.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/render.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/runtime/__init__.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/runtime/bootstrap.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/runtime/broker.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/runtime/lease.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/runtime/pool.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/runtime/session.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/runtime/telemetry.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/store/__init__.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/store/backends/__init__.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/store/backends/filesystem.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/store/backends/google_auth.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/store/backends/layout.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/store/backends/objects.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/store/cas.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/store/pathdata.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/store/sendorder.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/store/volume.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/stubs.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/tools.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/transport/__init__.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/transport/agent.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/transport/announce.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/transport/link.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/transport/portmap.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/transport/probe.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/transport/quic.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/transport/setup.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify/transport/sshopts.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-core/.gitignore +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-core/build.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-core/letify-quic/Cargo.toml +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-core/letify-quic/src/main.rs +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-core/rust-toolchain.toml +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-ext/.vscodeignore +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-ext/LICENSE +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-ext/README.md +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-ext/media/icon.svg +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-ext/src/cli.ts +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-ext/src/extension.ts +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-ext/src/history.ts +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-ext/src/model.ts +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-ext/src/poll.ts +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-ext/src/render.ts +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-ext/test/cli.test.ts +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-ext/test/manifest.test.ts +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-ext/test/model.test.ts +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-ext/test/regressions.test.ts +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify-ext/tsconfig.json +0 -0
- {letify-2.8.4 → letify-2.8.6}/letify_providers.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/scripts/sync-main.sh +0 -0
- {letify-2.8.4 → letify-2.8.6}/scripts/version.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/fake_kaggle_adapter.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/fake_modal_adapter.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_cli.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_cli_output.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_colab_keepalive.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_config.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_core.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_data.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_data_streaming.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_declare.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_device.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_distributed.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_drive_store.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_editor_sessions.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_elice.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_frames.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_install.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_kaggle.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_kaggle_cookie.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_kaggle_link.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_link_log.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_notebook.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_parallel_placement.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_paths.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_portmap.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_protocol.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_remoting.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_render.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_ship.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_store.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_stubs.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_tunnel_setup.py +0 -0
- {letify-2.8.4 → letify-2.8.6}/tests/test_version.py +0 -0
|
@@ -195,6 +195,7 @@ cache.resume(runtime, "run-1", path) # put the newest one inside a session
|
|
|
195
195
|
```python
|
|
196
196
|
letify.stash("ckpt", ckpt_dir) # put a file or directory on the machine's Drive store
|
|
197
197
|
letify.unstash("ckpt", ckpt_dir) # put it back, or None when no handoff has that name
|
|
198
|
+
letify.unstash("ckpt") # no path: a dict of file path to bytes, nothing written to disk
|
|
198
199
|
```
|
|
199
200
|
|
|
200
201
|
Both work in the local process and inside a shipped function, on any provider. They need `drive_login` and `drive_project` in the machine configuration and raise `ConfigError` without them. On a one-shot channel, which has no stream back to the local process, they raise `UnsupportedMode` naming the provider. `letify.fetch(tensor)` is a different call and still reads a tensor back.
|
|
@@ -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,14 @@ 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
|
+
|
|
821
|
+
A worker reached through a subprocess (`PersistentChannel`, for example `ssh` to a Colab or Kaggle machine) that exits before it says hello, or while a call is running, has its exit code named in the message text, so a user can tell a lost connection from a kill from a crash. A signal death is reported as the shell does, 128 plus the signal number. The message reads `the worker exited with code <n>` followed by a short meaning: `255` adds `: ssh could not reach or lost the machine`, `128+N` adds `: killed by signal N`, with `137` adding `, which is SIGKILL, most often out of memory`, and any other nonzero code adds nothing. A worker that never said hello reads `the worker exited before it was ready, with code <n>` followed by the same meaning. The last 2000 characters of the process's standard error follow under `--- last remote output ---`, and the startup failure also keeps the whole text as its `stderr` attribute. A channel whose provider can name a cause itself, such as Modal, keeps that cause. When the command's program is `ssh` or `ssh.exe`, `PersistentChannel` starts it as `ssh -E <file> ...`, with a fresh temporary file per channel placed right after the program. `ControlPersist` forks a master whose standard error is /dev/null, so its errors (`Timeout, server not responding`, `Connection reset`, `closed by remote host`, `Broken pipe`) reach only that file. A death message, both before hello and during a call, appends the last 2000 characters of the file under `--- ssh log ---` when it is not empty, after the standard error of the ssh client. Other commands are not changed. The file is deleted when the channel closes.
|
|
822
|
+
|
|
817
823
|
### Modal data channel <!-- id: modal-data-channel -->
|
|
818
824
|
|
|
819
825
|
> 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.
|
|
@@ -1556,10 +1562,12 @@ A token is reused until 60 s before it expires. A service account key file is re
|
|
|
1556
1562
|
|
|
1557
1563
|
A handoff is what a volume cannot be: a value written on one provider and read on another, such as a checkpoint written on Kaggle and resumed on `dept_gpu`. A volume belongs to one provider and stays attached to it, so letify keeps the two apart. `stash` and `unstash` take no provider and no volume.
|
|
1558
1564
|
|
|
1559
|
-
`stash(name, path)` packs `path`, a file or a directory, into an uncompressed tar whose members are relative to `path`'s parent, so the tar holds the one entry `path`'s base name. Its digest, letify's content hash as the blob store uses it, names the blob in the `letify` folder, and the ref `ref stash/<name>` holds that digest. It returns the digest once the upload has finished and the ref is written. Stashing a name again moves the ref, and the blob the ref named before is deleted unless another `stash/` ref still names it. An upload of the same digest that Drive already holds sends nothing. The tar keeps each member's modification time, so a file rewritten with the same bytes gives a new digest.
|
|
1565
|
+
`stash(name, path)` packs `path`, a file or a directory, into an uncompressed tar whose members are relative to `path`'s parent, so the tar holds the one entry `path`'s base name. Its digest, letify's content hash as the blob store uses it, names the blob in the `letify` folder, and the ref `ref stash/<name>` holds that digest. It returns the digest once the upload has finished and the ref is written. Stashing a name again moves the ref, and the blob the ref named before is deleted unless another `stash/` ref still names it. Whether another ref names it is decided from one listing of the `letify` folder: a ref holds the digest text and nothing else, so its Drive `md5Checksum` names its content, and no ref is downloaded. An upload of the same digest that Drive already holds sends nothing. The tar keeps each member's modification time, so a file rewritten with the same bytes gives a new digest.
|
|
1560
1566
|
|
|
1561
1567
|
`unstash(name, path)` reads the ref, downloads the blob, checks its digest, and unpacks it so that the tar's one entry lands at `path`, replacing what `path` held. It returns `path` as a `pathlib.Path`. When no ref `stash/<name>` exists it returns `None` and changes nothing, so `if letify.unstash("ckpt", ckpt_dir) is None:` starts from scratch. A digest that does not match raises `RuntimeFailure` and leaves `path` untouched.
|
|
1562
1568
|
|
|
1569
|
+
`unstash(name)` with no path returns the handoff in memory and writes nothing to disk: a `dict[str, bytes]` that maps the path of each regular file in the tar, as stored (`ckpt/model.safetensors` for a directory handoff, `checkpoint.safetensors` for a one-file handoff), to that file's bytes. Directories are not entries. It returns `None` when no ref `stash/<name>` exists. Every byte of the download is hashed as it is read, and the dict is returned only after the digest matches; a digest that does not match raises `RuntimeFailure` and returns no dict. Peak memory is about the handoff's size, not twice it, because the download is read sequentially and each member's bytes are read from the response with one `read(size)` call into the bytes object the caller receives: no chunk list is joined, no archive is held whole, and tar headers (GNU long name records and pax records) are parsed as they arrive. It works in both places below with the same result.
|
|
1570
|
+
|
|
1563
1571
|
A name is 1 to 128 characters of letters, digits, `.`, `_` and `-`, and does not start with `.`; anything else raises `ValueError` naming the rule. Names are shared by every project on the machine's Drive store, so a project that wants its own space puts its name in the handoff name.
|
|
1564
1572
|
|
|
1565
1573
|
The name `unstash` is not `fetch`, because `letify.fetch(tensor)` already reads a tensor back in `host=local`.
|
|
@@ -1567,7 +1575,7 @@ The name `unstash` is not `fetch`, because `letify.fetch(tensor)` already reads
|
|
|
1567
1575
|
Both run in two places with the same result:
|
|
1568
1576
|
|
|
1569
1577
|
- **In the local process**, the client moves the bytes itself with the `DriveBackend` of Google Drive is a store, not a volume.
|
|
1570
|
-
- **Inside a shipped function**, the worker moves the bytes between the runtime and Drive and they never cross the client's link. The worker asks the local process for a grant with a one-way request on a reserved stream, as `all_reduce` asks for a peer table, and the local process answers with the light op `drive_grant`: for `stash`, the existing digest or a resumable upload session URL for the archive's size, which the local process creates as Google Drive is a store, not a volume describes; for `unstash`, the blob's media URL with the short-lived request headers, or a reply that the name has no ref. After a `stash` upload the worker reports the Drive file id and the local process writes the ref and
|
|
1578
|
+
- **Inside a shipped function**, the worker moves the bytes between the runtime and Drive and they never cross the client's link. The worker asks the local process for a grant with a one-way request on a reserved stream, as `all_reduce` asks for a peer table, and the local process answers with the light op `drive_grant`: for `stash`, the existing digest or a resumable upload session URL for the archive's size, which the local process creates as Google Drive is a store, not a volume describes; for `unstash`, the blob's media URL with the short-lived request headers, or a reply that the name has no ref. After a `stash` upload the worker reports the Drive file id and the local process writes the ref and answers. The replaced blob is deleted after that, on a thread of the local process, so the answer never waits for the deletion and the worker's grant timeout counts only the ref write. A deletion that fails prints `letify: drive handoff <name>: the replaced blob <digest> was not deleted: <error>` on stderr and leaves the blob; the worker's `stash` has already returned. A request the worker cannot send prints `letify: drive request <kind> <name> was not sent: <error>` on the worker's stderr, so a grant timeout that follows names its cause. The worker holds no refresh token and writes no header to disk. The local process prints one line on stderr for every grant it answers, `letify: drive grant <kind> <name>: answered in <s> s`, and when sending the answer fails, `letify: drive grant <kind> <name>: the answer was not delivered after <s> s: <error>`. A worker that waits out its grant timeout can then be matched to the local side: either no request arrived, or the answer did not reach it. When the call ends while a grant is still being made, for example a slow Drive call, the watcher waits 30 s for it and then prints `letify: drive grant <kind> <name>: still running after <s> s when the call ended; its result will not be reported`, so the missing `answered in` line has a stated cause.
|
|
1571
1579
|
|
|
1572
1580
|
A body run on a one-shot channel, which has no stream back to the local process, cannot ask for a grant, so `stash` and `unstash` there raise `UnsupportedMode` naming the provider rather than reading the runtime's own configuration.
|
|
1573
1581
|
|
|
@@ -1723,7 +1731,7 @@ A `Shell` subclass differs from its parent only in its `Rendezvous` and its stra
|
|
|
1723
1731
|
|
|
1724
1732
|
Strategies are raced rather than tried in turn, so a strategy that times out does not delay the others. Once the first strategy that can carry the probe connects, the pipeline waits a grace period of 2 s for lower ranked strategies before choosing.
|
|
1725
1733
|
|
|
1726
|
-
A strategy that cannot carry the probe, such as the provider fallback, does not start the grace period when it connects. It is held back until every strategy that can carry the probe has failed, or until the race timeout of
|
|
1734
|
+
A strategy that cannot carry the probe, such as the provider fallback, does not start the grace period when it connects. It is held back until every strategy that can carry the probe has failed, or until the race timeout of 180 s passes with none of them connected. A rendezvous that takes seconds, such as `colab exec` before a TCP punch, therefore runs inside its strategy's own attempt time, not inside the grace period.
|
|
1727
1735
|
|
|
1728
1736
|
Every connected strategy is probed: 30 round trips, then a warm-up transfer that is discarded, then 2 s of transfer in each direction. The warm-up is `min(2.0, max(0.5, 8 x round trip))` seconds, long enough for TCP to leave slow start, and it runs as a transfer of its own on the same connection so the congestion window it opens carries into the measured one. Without it the probe charges a far link for its own ramp: the window is fixed in seconds while the ramp is fixed in round trips, so at a 190 ms round trip more than half the 2 s is spent ramping and the reported throughput is the ramp average, while at 47 ms it is a seventh. A live Kaggle runtime measured up 5.0 MiB/s at 190 ms and up 46.1 MiB/s at 47 ms on the same account, and the throughput times the round trip is 0.95 MiB against 2.18 MiB, which is a window that never finished opening rather than a path that cannot carry more. A strategy whose probe is below the account's floor, [Link floor](#link-floor), is rejected outright, before any comparison between strategies. Of what remains, a strategy whose throughput in either direction is below 25% of the fastest connected strategy in that direction is rejected. The lowest ranked strategy that remains is chosen. When only one strategy connects, it is chosen without comparison, but it is still held to the floor: a single connected strategy below the floor fails the connection rather than being used because nothing else was available.
|
|
1729
1737
|
|
|
@@ -1803,7 +1811,7 @@ A connection to the agent is told apart by its first bytes: `SSH-` is spliced to
|
|
|
1803
1811
|
|
|
1804
1812
|
Both sides learn their public mapping from STUN servers reached over TCP on port 443, because networks that restrict outbound ports usually still allow 443. A punch starts at a time both sides agree on through the rendezvous. Each side connects from its bound port to the other's mapping and listens on the same port, so whichever direction's SYN arrives first completes the connection.
|
|
1805
1813
|
|
|
1806
|
-
The window has to cover the rendezvous, not just the agreed start. The start time is fixed before the rendezvous is sent, and the rendezvous takes as long as it takes: for a Colab runtime it is a `colab exec` that also starts an SSH server, bounded only by the
|
|
1814
|
+
The window has to cover the rendezvous, not just the agreed start. The start time is fixed before the rendezvous is sent, and the rendezvous takes as long as it takes: for a Colab runtime it is a `colab exec` that also starts an SSH server, bounded only by the 120 s connect timeout. The remote side reaches the agreed time while the answer is still travelling back, so the user's side begins later by the return trip. A window of 15 s therefore closed on the remote side before the user's side had begun whenever the rendezvous was slow, which is why the same account punched in 10.7 s one day and reported `did not connect in time` the next. The default window is 150 s, longer than the 120 s connect timeout, so the two sides overlap however long the rendezvous takes.
|
|
1807
1815
|
|
|
1808
1816
|
The remote side also reports `punch_until`, the time on its own clock at which it stops, and the user's side stops then too rather than dialing alone for a window of its own. A punch that is already over fails at once and names that, instead of spending the window finding out.
|
|
1809
1817
|
|
|
@@ -1817,7 +1825,7 @@ A punch that ends with nothing connected names what happened to its dials, becau
|
|
|
1817
1825
|
|
|
1818
1826
|
The dial is still repeated until the window closes even when it is refused, because the listener may yet receive the connection the peer is completing from its own side.
|
|
1819
1827
|
|
|
1820
|
-
The remote side's punch window is
|
|
1828
|
+
The remote side's punch window is 150 s, or the request's `window` in seconds when it sets one. A punch that does not connect within it, or fails with a refused or unreachable connection, is expected, because the Tailcat link is already carrying the session. The agent then writes one line to stderr, `letify agent: TCP punch with <host>:<port> did not connect within <window> s; the Tailcat link is used instead`, or for a refused or unreachable peer `letify agent: TCP punch with <host>:<port> failed (<reason>); the Tailcat link is used instead`, and no traceback. Any other exception in the punch or the splice keeps its traceback.
|
|
1821
1829
|
|
|
1822
1830
|
Every SSH command letify builds sets `HostKeyAlias=letify-<alias>`, `StrictHostKeyChecking=accept-new` and `UserKnownHostsFile=~/.letify/accounts/<alias>/known_hosts`. Building the command creates that account directory with mode 0700 when it is missing, because `ssh` creates only `~/.ssh` and otherwise cannot record the key: it prints `Failed to add the host to the list of known hosts` on every connection and never pins the key. A host key that differs from the recorded one still fails the connection.
|
|
1823
1831
|
|
|
@@ -1953,7 +1961,7 @@ Colab limits outbound UDP to roughly 200 packets per second, so rank 3 is expect
|
|
|
1953
1961
|
|
|
1954
1962
|
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.
|
|
1955
1963
|
|
|
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.
|
|
1964
|
+
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. Two strategies rendezvous at once on a Colab VM, so the check, the file write and the start run under an exclusive `flock` on `/run/letify-sshd.lock`, and the check is repeated once the lock is held, so a caller that waited finds the first caller's server and returns. The file is written to a temporary file in `/etc/ssh` and moved onto `sshd_config.letify` with `os.replace`, so a server reading it sees the old file or the whole new one. 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.
|
|
1957
1965
|
|
|
1958
1966
|
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.
|
|
1959
1967
|
|
|
@@ -29,7 +29,7 @@ from __future__ import annotations
|
|
|
29
29
|
|
|
30
30
|
import os
|
|
31
31
|
from pathlib import Path
|
|
32
|
-
from typing import Final
|
|
32
|
+
from typing import Final, overload
|
|
33
33
|
|
|
34
34
|
from .declare.cache import session_cache
|
|
35
35
|
from .declare.env import Env
|
|
@@ -68,7 +68,7 @@ distributed: Final = Host.distributed
|
|
|
68
68
|
# The enum class stays importable from letify.declare.instance for letify's own use.
|
|
69
69
|
del Host
|
|
70
70
|
|
|
71
|
-
__version__ = "2.8.
|
|
71
|
+
__version__ = "2.8.6"
|
|
72
72
|
|
|
73
73
|
|
|
74
74
|
def fetch(tensor): # type: ignore[no-untyped-def]
|
|
@@ -93,13 +93,29 @@ def stash(name: str, path: str | os.PathLike[str]) -> str:
|
|
|
93
93
|
return stash_handoff(name, path)
|
|
94
94
|
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
@overload
|
|
97
|
+
def unstash(name: str) -> dict[str, bytes] | None: ...
|
|
98
|
+
@overload
|
|
99
|
+
def unstash(name: str, path: str | os.PathLike[str]) -> Path | None: ...
|
|
100
|
+
def unstash(
|
|
101
|
+
name: str, path: str | os.PathLike[str] | None = None
|
|
102
|
+
) -> Path | dict[str, bytes] | None:
|
|
97
103
|
"""Put the handoff ``name`` at ``path``, replacing what was there.
|
|
98
104
|
|
|
99
105
|
Returns ``path``, or None when no handoff has that name, in which case nothing changed.
|
|
106
|
+
|
|
107
|
+
With no ``path`` the handoff is returned in memory and nothing is written to disk: a
|
|
108
|
+
``dict[str, bytes]`` from the path of each regular file in the handoff, as stored
|
|
109
|
+
(``ckpt/model.safetensors``, or ``checkpoint.safetensors`` for a one-file handoff), to
|
|
110
|
+
its bytes. Directories are not entries, and None means no handoff has that name. The
|
|
111
|
+
dict is returned only after the digest matches; a mismatch raises ``RuntimeFailure``.
|
|
112
|
+
Peak memory is about the handoff's size, because members are read straight from the
|
|
113
|
+
download.
|
|
100
114
|
"""
|
|
101
115
|
from .store.handoff import unstash as unstash_handoff
|
|
102
116
|
|
|
117
|
+
if path is None:
|
|
118
|
+
return unstash_handoff(name)
|
|
103
119
|
return unstash_handoff(name, path)
|
|
104
120
|
|
|
105
121
|
|
|
@@ -1380,8 +1380,14 @@ def _drive_ask(message):
|
|
|
1380
1380
|
"""
|
|
1381
1381
|
try:
|
|
1382
1382
|
_SENDER.message(REQUEST, DRIVE_STREAM, message)
|
|
1383
|
-
except OSError:
|
|
1384
|
-
|
|
1383
|
+
except OSError as exc:
|
|
1384
|
+
# The stash or unstash then waits out its grant timeout; this line names why.
|
|
1385
|
+
print(
|
|
1386
|
+
f"letify: drive request {message.get('kind')} {message.get('name')} "
|
|
1387
|
+
f"was not sent: {exc}",
|
|
1388
|
+
file=sys.stderr,
|
|
1389
|
+
flush=True,
|
|
1390
|
+
)
|
|
1385
1391
|
|
|
1386
1392
|
|
|
1387
1393
|
def _op_drive_grant(request):
|
|
@@ -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()
|
|
@@ -27,6 +27,7 @@ import os
|
|
|
27
27
|
import select
|
|
28
28
|
import subprocess
|
|
29
29
|
import sys
|
|
30
|
+
import tempfile
|
|
30
31
|
import threading
|
|
31
32
|
import time
|
|
32
33
|
from collections.abc import Callable, Iterator, Sequence
|
|
@@ -258,6 +259,7 @@ class Connection:
|
|
|
258
259
|
emit: Callable[[str, bytes], None],
|
|
259
260
|
*,
|
|
260
261
|
death_detail: Callable[[], str] = lambda: "",
|
|
262
|
+
death_cause: Callable[[], str | None] = lambda: None,
|
|
261
263
|
poll_fd: int | None = None,
|
|
262
264
|
):
|
|
263
265
|
self.name = name
|
|
@@ -276,6 +278,8 @@ class Connection:
|
|
|
276
278
|
self._receiver = wire.Receiver(readinto)
|
|
277
279
|
self._emit = emit
|
|
278
280
|
self._death_detail = death_detail
|
|
281
|
+
#: Names what ended the worker when the provider can tell, replacing the usual causes.
|
|
282
|
+
self._death_cause = death_cause
|
|
279
283
|
#: Guards ``_reading`` and wakes waiters when a reply, a hello or a failure arrives.
|
|
280
284
|
self._turn = threading.Condition()
|
|
281
285
|
self._reading = False
|
|
@@ -383,10 +387,16 @@ class Connection:
|
|
|
383
387
|
return
|
|
384
388
|
if message is None:
|
|
385
389
|
detail = self._death_detail()
|
|
390
|
+
cause = self._death_cause()
|
|
391
|
+
causes = (
|
|
392
|
+
f": {cause}."
|
|
393
|
+
if cause
|
|
394
|
+
else ". The usual causes are an out of memory kill, a preempted session, "
|
|
395
|
+
"or a crash below Python."
|
|
396
|
+
)
|
|
386
397
|
message = (
|
|
387
398
|
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"
|
|
399
|
+
f"before it finished{causes}\n--- last remote output ---\n"
|
|
390
400
|
f"{self._tail.text()[-2000:]}{detail[-2000:]}"
|
|
391
401
|
)
|
|
392
402
|
with self._turn:
|
|
@@ -770,6 +780,27 @@ class FramedChannel(Channel):
|
|
|
770
780
|
return connection.pipeline(window=window, timeout=timeout, kill=self._kill)
|
|
771
781
|
|
|
772
782
|
|
|
783
|
+
def exit_text(code: int | None) -> str | None:
|
|
784
|
+
"""Name a subprocess exit code with its short meaning, or None while it still runs.
|
|
785
|
+
|
|
786
|
+
The text reads ``code <n>`` plus a meaning, to follow ``the worker exited with``.
|
|
787
|
+
|
|
788
|
+
Spec "Modal": a negative code is a signal, reported as the shell's 128 plus N.
|
|
789
|
+
"""
|
|
790
|
+
if code is None:
|
|
791
|
+
return None
|
|
792
|
+
if code < 0:
|
|
793
|
+
code = 128 - code
|
|
794
|
+
text = f"code {code}"
|
|
795
|
+
if code == 255:
|
|
796
|
+
text += ": ssh could not reach or lost the machine"
|
|
797
|
+
elif code > 128:
|
|
798
|
+
text += f": killed by signal {code - 128}"
|
|
799
|
+
if code == 137:
|
|
800
|
+
text += ", which is SIGKILL, most often out of memory"
|
|
801
|
+
return text
|
|
802
|
+
|
|
803
|
+
|
|
773
804
|
class PersistentChannel(FramedChannel):
|
|
774
805
|
"""One worker process, kept alive behind a pipe pair.
|
|
775
806
|
|
|
@@ -789,6 +820,8 @@ class PersistentChannel(FramedChannel):
|
|
|
789
820
|
self._start_lock = threading.Lock()
|
|
790
821
|
self._stderr = _Tail()
|
|
791
822
|
self._stderr_reader: threading.Thread | None = None
|
|
823
|
+
#: Where ``ssh -E`` appends the log of the client and its forked master.
|
|
824
|
+
self._ssh_log: str | None = None
|
|
792
825
|
|
|
793
826
|
# -- lifecycle -----------------------------------------------------------
|
|
794
827
|
|
|
@@ -796,9 +829,10 @@ class PersistentChannel(FramedChannel):
|
|
|
796
829
|
with self._start_lock:
|
|
797
830
|
if self._process is not None:
|
|
798
831
|
return
|
|
832
|
+
command = self._with_ssh_log()
|
|
799
833
|
try:
|
|
800
834
|
process = subprocess.Popen(
|
|
801
|
-
|
|
835
|
+
command,
|
|
802
836
|
stdin=subprocess.PIPE,
|
|
803
837
|
stdout=subprocess.PIPE,
|
|
804
838
|
stderr=subprocess.PIPE,
|
|
@@ -806,6 +840,7 @@ class PersistentChannel(FramedChannel):
|
|
|
806
840
|
env=self.env,
|
|
807
841
|
)
|
|
808
842
|
except OSError as exc:
|
|
843
|
+
self._drop_ssh_log()
|
|
809
844
|
raise RuntimeFailure(
|
|
810
845
|
f"{self.name}: could not start the worker: {exc}",
|
|
811
846
|
command=" ".join(self.command),
|
|
@@ -825,7 +860,8 @@ class PersistentChannel(FramedChannel):
|
|
|
825
860
|
wire.fd_writer(process.stdin.fileno()),
|
|
826
861
|
process.stdout.readinto, # type: ignore[attr-defined]
|
|
827
862
|
self._emit,
|
|
828
|
-
death_detail=self._stderr_text,
|
|
863
|
+
death_detail=lambda: self._stderr_text() + self._ssh_log_section(),
|
|
864
|
+
death_cause=self._exit_cause,
|
|
829
865
|
poll_fd=process.stdout.fileno(),
|
|
830
866
|
)
|
|
831
867
|
self._send_worker()
|
|
@@ -835,6 +871,48 @@ class PersistentChannel(FramedChannel):
|
|
|
835
871
|
if self._process is not None:
|
|
836
872
|
self._process.kill()
|
|
837
873
|
|
|
874
|
+
def _with_ssh_log(self) -> list[str]:
|
|
875
|
+
"""The command, with ``-E <file>`` after the program when that program is ssh.
|
|
876
|
+
|
|
877
|
+
Spec "Modal": ControlPersist forks a master whose standard error is /dev/null, so
|
|
878
|
+
the transport's own errors reach only this file.
|
|
879
|
+
"""
|
|
880
|
+
self._drop_ssh_log()
|
|
881
|
+
if os.path.basename(self.command[0]) not in ("ssh", "ssh.exe"):
|
|
882
|
+
return self.command
|
|
883
|
+
handle, self._ssh_log = tempfile.mkstemp(prefix="letify-ssh-", suffix=".log")
|
|
884
|
+
os.close(handle)
|
|
885
|
+
return [self.command[0], "-E", self._ssh_log, *self.command[1:]]
|
|
886
|
+
|
|
887
|
+
def _drop_ssh_log(self) -> None:
|
|
888
|
+
log, self._ssh_log = self._ssh_log, None
|
|
889
|
+
if log is not None:
|
|
890
|
+
with contextlib.suppress(OSError):
|
|
891
|
+
os.unlink(log)
|
|
892
|
+
|
|
893
|
+
def _ssh_log_section(self) -> str:
|
|
894
|
+
"""The last 2000 characters of the ssh log under a heading, or empty when it is empty."""
|
|
895
|
+
if self._ssh_log is None:
|
|
896
|
+
return ""
|
|
897
|
+
try:
|
|
898
|
+
with open(self._ssh_log, "rb") as handle:
|
|
899
|
+
text = handle.read().decode("utf-8", "replace").strip()
|
|
900
|
+
except OSError:
|
|
901
|
+
return ""
|
|
902
|
+
return f"\n--- ssh log ---\n{text[-2000:]}" if text else ""
|
|
903
|
+
|
|
904
|
+
def _exit_cause(self) -> str | None:
|
|
905
|
+
"""What the exit code of the worker process says, once the process has exited."""
|
|
906
|
+
process = self._process
|
|
907
|
+
if process is None:
|
|
908
|
+
return None
|
|
909
|
+
try:
|
|
910
|
+
process.wait(timeout=2)
|
|
911
|
+
except subprocess.TimeoutExpired:
|
|
912
|
+
return None
|
|
913
|
+
code = exit_text(process.returncode)
|
|
914
|
+
return f"the worker exited with {code}" if code else None
|
|
915
|
+
|
|
838
916
|
def _stderr_text(self) -> str:
|
|
839
917
|
"""The process's own standard error, complete once the process has exited."""
|
|
840
918
|
process = self._process
|
|
@@ -850,10 +928,15 @@ class PersistentChannel(FramedChannel):
|
|
|
850
928
|
def _startup_failure(self, expired: bool, cause: Exception) -> Exception:
|
|
851
929
|
if expired:
|
|
852
930
|
return super()._startup_failure(expired, cause)
|
|
931
|
+
stderr = self._stderr_text().strip()
|
|
932
|
+
code = exit_text(self._process.returncode) if self._process else None
|
|
933
|
+
reason = f", with {code}" if code else ""
|
|
853
934
|
return RuntimeFailure(
|
|
854
|
-
f"{self.name}: the worker exited before it was ready"
|
|
935
|
+
f"{self.name}: the worker exited before it was ready{reason}\n"
|
|
936
|
+
"--- last remote output ---\n"
|
|
937
|
+
f"{stderr[-2000:]}{self._ssh_log_section()}",
|
|
855
938
|
command=" ".join(self.command),
|
|
856
|
-
stderr=
|
|
939
|
+
stderr=stderr,
|
|
857
940
|
)
|
|
858
941
|
|
|
859
942
|
def close(self) -> None:
|
|
@@ -876,6 +959,7 @@ class PersistentChannel(FramedChannel):
|
|
|
876
959
|
finally:
|
|
877
960
|
if self._stderr_reader is not None:
|
|
878
961
|
self._stderr_reader.join(1)
|
|
962
|
+
self._drop_ssh_log()
|
|
879
963
|
|
|
880
964
|
@property
|
|
881
965
|
def alive(self) -> bool:
|
|
@@ -890,7 +974,8 @@ class PersistentChannel(FramedChannel):
|
|
|
890
974
|
if process is None or connection is None:
|
|
891
975
|
raise RuntimeLost(f"{self.name}: the channel is closed")
|
|
892
976
|
if process.poll() is not None:
|
|
893
|
-
|
|
977
|
+
code = exit_text(process.returncode)
|
|
978
|
+
raise RuntimeLost(f"{self.name}: the worker exited with {code}")
|
|
894
979
|
return connection.request(payload, timeout=timeout, kill=self._kill)
|
|
895
980
|
|
|
896
981
|
|
|
@@ -17,6 +17,7 @@ than one request per digest.
|
|
|
17
17
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
|
+
import hashlib
|
|
20
21
|
import json
|
|
21
22
|
import os
|
|
22
23
|
import shutil
|
|
@@ -105,6 +106,24 @@ def download_to(url: str, headers: dict[str, str], destination: str | os.PathLik
|
|
|
105
106
|
return os.path.getsize(destination)
|
|
106
107
|
|
|
107
108
|
|
|
109
|
+
def open_stream(url: str, headers: dict[str, str]) -> Any:
|
|
110
|
+
"""Open one Drive file for sequential reads and return the response.
|
|
111
|
+
|
|
112
|
+
The caller reads it and closes it. A failure to open raises ``RuntimeFailure`` as
|
|
113
|
+
``download_to`` does.
|
|
114
|
+
"""
|
|
115
|
+
request = urllib.request.Request(url, headers=headers)
|
|
116
|
+
try:
|
|
117
|
+
return urllib.request.urlopen(request, timeout=3600)
|
|
118
|
+
except urllib.error.HTTPError as exc:
|
|
119
|
+
detail = exc.read()[:300].decode(errors="replace")
|
|
120
|
+
raise RuntimeFailure(f"Drive download returned {exc.code}: {detail}") from exc
|
|
121
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
122
|
+
raise RuntimeFailure(f"Drive download failed: {exc}") from exc
|
|
123
|
+
finally:
|
|
124
|
+
request = None # type: ignore[assignment]
|
|
125
|
+
|
|
126
|
+
|
|
108
127
|
class AccountTokenSource(TokenSource):
|
|
109
128
|
"""Access tokens minted from the refresh token in one account's ``token.json``."""
|
|
110
129
|
|
|
@@ -354,22 +373,31 @@ class DriveBackend(Backend):
|
|
|
354
373
|
self._call("DELETE", url, what=f"deleting {digest}")
|
|
355
374
|
self._ids.pop(digest, None)
|
|
356
375
|
|
|
357
|
-
def
|
|
358
|
-
"""
|
|
359
|
-
|
|
376
|
+
def ref_holds(self, prefix: str, digest: str) -> bool:
|
|
377
|
+
"""Whether a ref whose name starts with ``prefix`` holds ``digest``, from one listing.
|
|
378
|
+
|
|
379
|
+
A ref holds the digest and nothing else, so the listing's ``md5Checksum`` of each ref
|
|
380
|
+
names its content and no ref is downloaded. An entry listed without a checksum is read.
|
|
381
|
+
"""
|
|
360
382
|
wanted = REF_PREFIX + prefix
|
|
383
|
+
checksum = hashlib.md5(digest.encode()).hexdigest()
|
|
361
384
|
for entry in self._list(
|
|
362
|
-
f"{_quoted(self.folder())} in parents and trashed = false", "id,name"
|
|
385
|
+
f"{_quoted(self.folder())} in parents and trashed = false", "id,name,md5Checksum"
|
|
363
386
|
):
|
|
364
387
|
name = str(entry["name"])
|
|
365
388
|
if not name.startswith(wanted):
|
|
366
389
|
continue
|
|
390
|
+
listed = entry.get("md5Checksum")
|
|
391
|
+
if listed is not None:
|
|
392
|
+
if listed == checksum:
|
|
393
|
+
return True
|
|
394
|
+
continue
|
|
367
395
|
payload = self._call(
|
|
368
396
|
"GET", self._media_url(str(entry["id"])), what=f"reading ref {name[4:]}"
|
|
369
397
|
)
|
|
370
|
-
if payload is not None:
|
|
371
|
-
|
|
372
|
-
return
|
|
398
|
+
if payload is not None and payload.decode().strip() == digest:
|
|
399
|
+
return True
|
|
400
|
+
return False
|
|
373
401
|
|
|
374
402
|
# -- the backend -----------------------------------------------------------
|
|
375
403
|
|