letify 2.8.5__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.5 → letify-2.8.6}/PKG-INFO +1 -1
- {letify-2.8.5 → letify-2.8.6}/PROJECT.md +1 -0
- {letify-2.8.5 → letify-2.8.6}/docs/SPEC.md +10 -6
- {letify-2.8.5 → letify-2.8.6}/letify/__init__.py +19 -3
- {letify-2.8.5 → letify-2.8.6}/letify/protocol/worker.py +8 -2
- {letify-2.8.5 → letify-2.8.6}/letify/runtime/channel.py +81 -5
- {letify-2.8.5 → letify-2.8.6}/letify/store/backends/drive.py +35 -7
- {letify-2.8.5 → letify-2.8.6}/letify/store/handoff.py +243 -18
- {letify-2.8.5 → letify-2.8.6}/letify/transport/nat.py +96 -19
- {letify-2.8.5 → letify-2.8.6}/letify/transport/pipeline.py +9 -2
- {letify-2.8.5 → letify-2.8.6}/letify/transport/rendezvous.py +31 -0
- {letify-2.8.5 → letify-2.8.6}/letify/transport/strategies.py +29 -19
- {letify-2.8.5 → letify-2.8.6}/letify-core/Cargo.lock +3 -3
- {letify-2.8.5 → letify-2.8.6}/letify-core/Cargo.toml +1 -1
- {letify-2.8.5 → letify-2.8.6}/letify-ext/package-lock.json +2 -2
- {letify-2.8.5 → letify-2.8.6}/letify-ext/package.json +1 -1
- {letify-2.8.5 → letify-2.8.6}/pyproject.toml +1 -1
- {letify-2.8.5 → letify-2.8.6}/tests/conftest.py +12 -1
- {letify-2.8.5 → letify-2.8.6}/tests/test_drive_handoff.py +335 -0
- letify-2.8.6/tests/test_quic.py +370 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_runtime.py +92 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_strategies.py +64 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_transport.py +63 -0
- letify-2.8.5/tests/test_quic.py +0 -187
- {letify-2.8.5 → letify-2.8.6}/.github/workflows/ci.yml +0 -0
- {letify-2.8.5 → letify-2.8.6}/.github/workflows/docs.yml +0 -0
- {letify-2.8.5 → letify-2.8.6}/.github/workflows/publish.yml +0 -0
- {letify-2.8.5 → letify-2.8.6}/.gitignore +0 -0
- {letify-2.8.5 → letify-2.8.6}/.researchtree +0 -0
- {letify-2.8.5 → letify-2.8.6}/CLAUDE.md +0 -0
- {letify-2.8.5 → letify-2.8.6}/LICENSE +0 -0
- {letify-2.8.5 → letify-2.8.6}/README.md +0 -0
- {letify-2.8.5 → letify-2.8.6}/docs/COMPONENT.md +0 -0
- {letify-2.8.5 → letify-2.8.6}/docs/INTENT.md +0 -0
- {letify-2.8.5 → letify-2.8.6}/docs/NETWORK.md +0 -0
- {letify-2.8.5 → letify-2.8.6}/docs/guide/01-getting-started.md +0 -0
- {letify-2.8.5 → letify-2.8.6}/docs/guide/02-providers.md +0 -0
- {letify-2.8.5 → letify-2.8.6}/docs/guide/03-execution-modes.md +0 -0
- {letify-2.8.5 → letify-2.8.6}/docs/guide/04-environments-and-data.md +0 -0
- {letify-2.8.5 → letify-2.8.6}/docs/guide/05-concurrency.md +0 -0
- {letify-2.8.5 → letify-2.8.6}/docs/guide/06-cost.md +0 -0
- {letify-2.8.5 → letify-2.8.6}/docs/guide/07-troubleshooting.md +0 -0
- {letify-2.8.5 → letify-2.8.6}/docs/guide/08-releasing.md +0 -0
- {letify-2.8.5 → letify-2.8.6}/docs/guide/README.md +0 -0
- {letify-2.8.5 → 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.5 → 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.5 → 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.5 → 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.5 → 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.5 → letify-2.8.6}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-2.8.5 → 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.5 → letify-2.8.6}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-2.8.5 → letify-2.8.6}/docs/guide/ko/README.md +0 -0
- {letify-2.8.5 → letify-2.8.6}/docs/locales/README_ko.md +0 -0
- {letify-2.8.5 → letify-2.8.6}/docs/mkdocs.yml +0 -0
- {letify-2.8.5 → letify-2.8.6}/examples/README.md +0 -0
- {letify-2.8.5 → letify-2.8.6}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-2.8.5 → letify-2.8.6}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/examples/nvfp4-lora/README.md +0 -0
- {letify-2.8.5 → letify-2.8.6}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-2.8.5 → letify-2.8.6}/examples/nvfp4-lora/common.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/cli.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/config/__init__.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/config/inventory.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/config/login.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/config/schema.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/config/secrets.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/config/writer.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/declare/__init__.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/declare/cache.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/declare/env.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/declare/function.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/declare/instance.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/declare/reduce.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/declare/replicas.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/errors.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/install.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/launcher.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/paths.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/protocol/__init__.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/protocol/codec.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/protocol/driver.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/protocol/handle.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/protocol/wire.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/providers/__init__.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/providers/base.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/providers/colab.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/providers/colab_files.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/providers/colab_keepalive.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/providers/elice.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/providers/kaggle.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/providers/kaggle_adapter.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/providers/local.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/providers/modal.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/providers/modal_adapter.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/providers/naming.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/providers/shell.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/providers/tunnel.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/providers/usage.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/remoting/__init__.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/remoting/capability.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/remoting/device/__init__.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/remoting/device/client.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/remoting/device/cuda.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/remoting/device/executor.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/remoting/device/frames.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/remoting/device/guard.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/remoting/device/tensor.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/remoting/device/trace.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/remoting/device/value.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/remoting/loader.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/remoting/probe.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/render.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/runtime/__init__.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/runtime/bootstrap.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/runtime/broker.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/runtime/lease.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/runtime/pool.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/runtime/session.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/runtime/telemetry.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/store/__init__.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/store/backends/__init__.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/store/backends/filesystem.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/store/backends/google_auth.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/store/backends/layout.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/store/backends/objects.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/store/cas.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/store/pathdata.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/store/sendorder.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/store/volume.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/stubs.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/tools.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/transport/__init__.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/transport/agent.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/transport/announce.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/transport/link.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/transport/portmap.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/transport/probe.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/transport/quic.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/transport/setup.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify/transport/sshopts.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-core/.gitignore +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-core/build.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-core/letify-quic/Cargo.toml +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-core/letify-quic/src/main.rs +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-core/rust-toolchain.toml +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-ext/.vscodeignore +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-ext/LICENSE +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-ext/README.md +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-ext/media/icon.svg +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-ext/src/cli.ts +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-ext/src/extension.ts +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-ext/src/history.ts +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-ext/src/model.ts +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-ext/src/poll.ts +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-ext/src/render.ts +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-ext/test/cli.test.ts +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-ext/test/manifest.test.ts +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-ext/test/model.test.ts +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-ext/test/regressions.test.ts +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify-ext/tsconfig.json +0 -0
- {letify-2.8.5 → letify-2.8.6}/letify_providers.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/scripts/sync-main.sh +0 -0
- {letify-2.8.5 → letify-2.8.6}/scripts/version.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/fake_kaggle_adapter.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/fake_modal_adapter.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_cli.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_cli_output.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_colab_keepalive.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_config.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_core.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_data.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_data_streaming.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_declare.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_device.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_distributed.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_drive_store.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_editor_sessions.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_elice.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_frames.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_install.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_kaggle.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_kaggle_cookie.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_kaggle_link.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_link_log.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_notebook.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_parallel_placement.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_paths.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_portmap.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_protocol.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_providers.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_remoting.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_render.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_ship.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_store.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_stubs.py +0 -0
- {letify-2.8.5 → letify-2.8.6}/tests/test_tunnel_setup.py +0 -0
- {letify-2.8.5 → 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.
|
|
@@ -818,6 +818,8 @@ A sandbox that reaches that lifetime takes its worker with it, so the channel se
|
|
|
818
818
|
|
|
819
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
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
|
+
|
|
821
823
|
### Modal data channel <!-- id: modal-data-channel -->
|
|
822
824
|
|
|
823
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.
|
|
@@ -1560,10 +1562,12 @@ A token is reused until 60 s before it expires. A service account key file is re
|
|
|
1560
1562
|
|
|
1561
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.
|
|
1562
1564
|
|
|
1563
|
-
`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.
|
|
1564
1566
|
|
|
1565
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.
|
|
1566
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
|
+
|
|
1567
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.
|
|
1568
1572
|
|
|
1569
1573
|
The name `unstash` is not `fetch`, because `letify.fetch(tensor)` already reads a tensor back in `host=local`.
|
|
@@ -1571,7 +1575,7 @@ The name `unstash` is not `fetch`, because `letify.fetch(tensor)` already reads
|
|
|
1571
1575
|
Both run in two places with the same result:
|
|
1572
1576
|
|
|
1573
1577
|
- **In the local process**, the client moves the bytes itself with the `DriveBackend` of Google Drive is a store, not a volume.
|
|
1574
|
-
- **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.
|
|
1575
1579
|
|
|
1576
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.
|
|
1577
1581
|
|
|
@@ -1727,7 +1731,7 @@ A `Shell` subclass differs from its parent only in its `Rendezvous` and its stra
|
|
|
1727
1731
|
|
|
1728
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.
|
|
1729
1733
|
|
|
1730
|
-
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.
|
|
1731
1735
|
|
|
1732
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.
|
|
1733
1737
|
|
|
@@ -1807,7 +1811,7 @@ A connection to the agent is told apart by its first bytes: `SSH-` is spliced to
|
|
|
1807
1811
|
|
|
1808
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.
|
|
1809
1813
|
|
|
1810
|
-
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.
|
|
1811
1815
|
|
|
1812
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.
|
|
1813
1817
|
|
|
@@ -1821,7 +1825,7 @@ A punch that ends with nothing connected names what happened to its dials, becau
|
|
|
1821
1825
|
|
|
1822
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.
|
|
1823
1827
|
|
|
1824
|
-
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.
|
|
1825
1829
|
|
|
1826
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.
|
|
1827
1831
|
|
|
@@ -1957,7 +1961,7 @@ Colab limits outbound UDP to roughly 200 packets per second, so rank 3 is expect
|
|
|
1957
1961
|
|
|
1958
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.
|
|
1959
1963
|
|
|
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.
|
|
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.
|
|
1961
1965
|
|
|
1962
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.
|
|
1963
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):
|
|
@@ -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
|
|
@@ -779,6 +780,27 @@ class FramedChannel(Channel):
|
|
|
779
780
|
return connection.pipeline(window=window, timeout=timeout, kill=self._kill)
|
|
780
781
|
|
|
781
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
|
+
|
|
782
804
|
class PersistentChannel(FramedChannel):
|
|
783
805
|
"""One worker process, kept alive behind a pipe pair.
|
|
784
806
|
|
|
@@ -798,6 +820,8 @@ class PersistentChannel(FramedChannel):
|
|
|
798
820
|
self._start_lock = threading.Lock()
|
|
799
821
|
self._stderr = _Tail()
|
|
800
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
|
|
801
825
|
|
|
802
826
|
# -- lifecycle -----------------------------------------------------------
|
|
803
827
|
|
|
@@ -805,9 +829,10 @@ class PersistentChannel(FramedChannel):
|
|
|
805
829
|
with self._start_lock:
|
|
806
830
|
if self._process is not None:
|
|
807
831
|
return
|
|
832
|
+
command = self._with_ssh_log()
|
|
808
833
|
try:
|
|
809
834
|
process = subprocess.Popen(
|
|
810
|
-
|
|
835
|
+
command,
|
|
811
836
|
stdin=subprocess.PIPE,
|
|
812
837
|
stdout=subprocess.PIPE,
|
|
813
838
|
stderr=subprocess.PIPE,
|
|
@@ -815,6 +840,7 @@ class PersistentChannel(FramedChannel):
|
|
|
815
840
|
env=self.env,
|
|
816
841
|
)
|
|
817
842
|
except OSError as exc:
|
|
843
|
+
self._drop_ssh_log()
|
|
818
844
|
raise RuntimeFailure(
|
|
819
845
|
f"{self.name}: could not start the worker: {exc}",
|
|
820
846
|
command=" ".join(self.command),
|
|
@@ -834,7 +860,8 @@ class PersistentChannel(FramedChannel):
|
|
|
834
860
|
wire.fd_writer(process.stdin.fileno()),
|
|
835
861
|
process.stdout.readinto, # type: ignore[attr-defined]
|
|
836
862
|
self._emit,
|
|
837
|
-
death_detail=self._stderr_text,
|
|
863
|
+
death_detail=lambda: self._stderr_text() + self._ssh_log_section(),
|
|
864
|
+
death_cause=self._exit_cause,
|
|
838
865
|
poll_fd=process.stdout.fileno(),
|
|
839
866
|
)
|
|
840
867
|
self._send_worker()
|
|
@@ -844,6 +871,48 @@ class PersistentChannel(FramedChannel):
|
|
|
844
871
|
if self._process is not None:
|
|
845
872
|
self._process.kill()
|
|
846
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
|
+
|
|
847
916
|
def _stderr_text(self) -> str:
|
|
848
917
|
"""The process's own standard error, complete once the process has exited."""
|
|
849
918
|
process = self._process
|
|
@@ -859,10 +928,15 @@ class PersistentChannel(FramedChannel):
|
|
|
859
928
|
def _startup_failure(self, expired: bool, cause: Exception) -> Exception:
|
|
860
929
|
if expired:
|
|
861
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 ""
|
|
862
934
|
return RuntimeFailure(
|
|
863
|
-
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()}",
|
|
864
938
|
command=" ".join(self.command),
|
|
865
|
-
stderr=
|
|
939
|
+
stderr=stderr,
|
|
866
940
|
)
|
|
867
941
|
|
|
868
942
|
def close(self) -> None:
|
|
@@ -885,6 +959,7 @@ class PersistentChannel(FramedChannel):
|
|
|
885
959
|
finally:
|
|
886
960
|
if self._stderr_reader is not None:
|
|
887
961
|
self._stderr_reader.join(1)
|
|
962
|
+
self._drop_ssh_log()
|
|
888
963
|
|
|
889
964
|
@property
|
|
890
965
|
def alive(self) -> bool:
|
|
@@ -899,7 +974,8 @@ class PersistentChannel(FramedChannel):
|
|
|
899
974
|
if process is None or connection is None:
|
|
900
975
|
raise RuntimeLost(f"{self.name}: the channel is closed")
|
|
901
976
|
if process.poll() is not None:
|
|
902
|
-
|
|
977
|
+
code = exit_text(process.returncode)
|
|
978
|
+
raise RuntimeLost(f"{self.name}: the worker exited with {code}")
|
|
903
979
|
return connection.request(payload, timeout=timeout, kill=self._kill)
|
|
904
980
|
|
|
905
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
|
|