letify 2.6.2__tar.gz → 2.6.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {letify-2.6.2 → letify-2.6.4}/PKG-INFO +1 -1
- {letify-2.6.2 → letify-2.6.4}/docs/INTENT.md +23 -1
- {letify-2.6.2 → letify-2.6.4}/docs/SPEC.md +31 -1
- {letify-2.6.2 → letify-2.6.4}/letify/__init__.py +1 -1
- {letify-2.6.2 → letify-2.6.4}/letify/declare/reduce.py +164 -18
- {letify-2.6.2 → letify-2.6.4}/letify/providers/modal.py +47 -4
- {letify-2.6.2 → letify-2.6.4}/letify/runtime/broker.py +24 -3
- {letify-2.6.2 → letify-2.6.4}/letify/runtime/session.py +22 -3
- {letify-2.6.2 → letify-2.6.4}/letify-core/Cargo.lock +3 -3
- {letify-2.6.2 → letify-2.6.4}/letify-core/Cargo.toml +1 -1
- {letify-2.6.2 → letify-2.6.4}/letify-ext/package-lock.json +2 -2
- {letify-2.6.2 → letify-2.6.4}/letify-ext/package.json +1 -1
- {letify-2.6.2 → letify-2.6.4}/pyproject.toml +1 -1
- {letify-2.6.2 → letify-2.6.4}/tests/test_distributed.py +75 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_providers.py +32 -1
- {letify-2.6.2 → letify-2.6.4}/tests/test_runtime.py +29 -0
- {letify-2.6.2 → letify-2.6.4}/.github/workflows/ci.yml +0 -0
- {letify-2.6.2 → letify-2.6.4}/.github/workflows/docs.yml +0 -0
- {letify-2.6.2 → letify-2.6.4}/.github/workflows/publish.yml +0 -0
- {letify-2.6.2 → letify-2.6.4}/.gitignore +0 -0
- {letify-2.6.2 → letify-2.6.4}/.researchtree +0 -0
- {letify-2.6.2 → letify-2.6.4}/CLAUDE.md +0 -0
- {letify-2.6.2 → letify-2.6.4}/LICENSE +0 -0
- {letify-2.6.2 → letify-2.6.4}/PROJECT.md +0 -0
- {letify-2.6.2 → letify-2.6.4}/README.md +0 -0
- {letify-2.6.2 → letify-2.6.4}/docs/COMPONENT.md +0 -0
- {letify-2.6.2 → letify-2.6.4}/docs/NETWORK.md +0 -0
- {letify-2.6.2 → letify-2.6.4}/docs/guide/01-getting-started.md +0 -0
- {letify-2.6.2 → letify-2.6.4}/docs/guide/02-providers.md +0 -0
- {letify-2.6.2 → letify-2.6.4}/docs/guide/03-execution-modes.md +0 -0
- {letify-2.6.2 → letify-2.6.4}/docs/guide/04-environments-and-data.md +0 -0
- {letify-2.6.2 → letify-2.6.4}/docs/guide/05-concurrency.md +0 -0
- {letify-2.6.2 → letify-2.6.4}/docs/guide/06-cost.md +0 -0
- {letify-2.6.2 → letify-2.6.4}/docs/guide/07-troubleshooting.md +0 -0
- {letify-2.6.2 → letify-2.6.4}/docs/guide/08-releasing.md +0 -0
- {letify-2.6.2 → letify-2.6.4}/docs/guide/README.md +0 -0
- {letify-2.6.2 → letify-2.6.4}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
- {letify-2.6.2 → letify-2.6.4}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
- {letify-2.6.2 → letify-2.6.4}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
- {letify-2.6.2 → letify-2.6.4}/docs/guide/ko/04-/355/231/230/352/262/275/352/263/274-/353/215/260/354/235/264/355/204/260.md" +0 -0
- {letify-2.6.2 → letify-2.6.4}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
- {letify-2.6.2 → letify-2.6.4}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-2.6.2 → letify-2.6.4}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
- {letify-2.6.2 → letify-2.6.4}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-2.6.2 → letify-2.6.4}/docs/guide/ko/README.md +0 -0
- {letify-2.6.2 → letify-2.6.4}/docs/locales/README_ko.md +0 -0
- {letify-2.6.2 → letify-2.6.4}/docs/mkdocs.yml +0 -0
- {letify-2.6.2 → letify-2.6.4}/examples/README.md +0 -0
- {letify-2.6.2 → letify-2.6.4}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-2.6.2 → letify-2.6.4}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/examples/nvfp4-lora/README.md +0 -0
- {letify-2.6.2 → letify-2.6.4}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-2.6.2 → letify-2.6.4}/examples/nvfp4-lora/common.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/cli.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/config/__init__.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/config/inventory.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/config/login.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/config/schema.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/config/secrets.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/config/writer.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/declare/__init__.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/declare/cache.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/declare/env.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/declare/function.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/declare/instance.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/declare/replicas.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/errors.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/install.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/launcher.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/paths.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/protocol/__init__.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/protocol/codec.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/protocol/driver.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/protocol/handle.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/protocol/wire.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/protocol/worker.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/providers/__init__.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/providers/base.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/providers/colab.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/providers/colab_files.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/providers/colab_keepalive.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/providers/elice.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/providers/kaggle.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/providers/kaggle_adapter.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/providers/local.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/providers/modal_adapter.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/providers/naming.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/providers/shell.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/providers/tunnel.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/providers/usage.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/remoting/__init__.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/remoting/capability.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/remoting/device/__init__.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/remoting/device/client.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/remoting/device/cuda.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/remoting/device/executor.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/remoting/device/frames.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/remoting/device/guard.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/remoting/device/tensor.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/remoting/device/trace.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/remoting/device/value.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/remoting/loader.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/remoting/probe.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/render.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/runtime/__init__.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/runtime/bootstrap.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/runtime/channel.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/runtime/lease.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/runtime/pool.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/runtime/telemetry.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/store/__init__.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/store/backends/__init__.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/store/backends/filesystem.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/store/backends/google_auth.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/store/backends/layout.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/store/backends/objects.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/store/cas.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/store/pathdata.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/store/sendorder.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/store/volume.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/stubs.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/tools.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/transport/__init__.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/transport/agent.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/transport/announce.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/transport/link.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/transport/nat.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/transport/pipeline.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/transport/portmap.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/transport/probe.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/transport/quic.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/transport/rendezvous.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/transport/setup.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/transport/sshopts.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify/transport/strategies.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-core/.gitignore +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-core/build.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-core/letify-quic/Cargo.toml +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-core/letify-quic/src/main.rs +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-core/rust-toolchain.toml +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-ext/.vscodeignore +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-ext/LICENSE +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-ext/README.md +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-ext/media/icon.svg +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-ext/src/cli.ts +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-ext/src/extension.ts +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-ext/src/history.ts +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-ext/src/model.ts +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-ext/src/poll.ts +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-ext/src/render.ts +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-ext/test/cli.test.ts +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-ext/test/manifest.test.ts +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-ext/test/model.test.ts +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-ext/test/regressions.test.ts +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify-ext/tsconfig.json +0 -0
- {letify-2.6.2 → letify-2.6.4}/letify_providers.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/scripts/sync-main.sh +0 -0
- {letify-2.6.2 → letify-2.6.4}/scripts/version.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/conftest.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/fake_kaggle_adapter.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/fake_modal_adapter.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_cli.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_cli_output.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_colab_keepalive.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_config.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_core.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_data.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_data_streaming.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_declare.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_device.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_editor_sessions.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_elice.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_frames.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_install.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_kaggle.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_kaggle_cookie.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_kaggle_link.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_link_log.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_notebook.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_parallel_placement.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_paths.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_portmap.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_protocol.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_quic.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_remoting.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_render.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_ship.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_store.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_strategies.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_stubs.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_transport.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_tunnel_setup.py +0 -0
- {letify-2.6.2 → letify-2.6.4}/tests/test_version.py +0 -0
|
@@ -74,6 +74,28 @@ cross cloud to cloud and the client carries the mappings and nothing else. Measu
|
|
|
74
74
|
inbound on the network this was designed on is capped near 0.25 MiB/s, against 21 MiB/s
|
|
75
75
|
outbound to a near runtime, which is why this is a claim rather than an optimization.
|
|
76
76
|
|
|
77
|
+
**TCP hole punching does not reach between two Kaggle runtimes.** Measured on 2026-10-13 between two
|
|
78
|
+
Google Cloud VMs on two Kaggle accounts: both NATs preserved the port, 45117 on each, so the
|
|
79
|
+
signalling and the mappings were right, and every SYN was dropped for the whole 45 second window from
|
|
80
|
+
both sides at once. So the mechanism this claim names works client to runtime, where it was measured,
|
|
81
|
+
and not runtime to runtime on that network. The claim is not refuted, because what it asserts is that
|
|
82
|
+
the reduction must not pass through the client, and that still holds. What is refuted is that a TCP
|
|
83
|
+
punch is the way to arrange it on Kaggle.
|
|
84
|
+
|
|
85
|
+
**A UDP punch does not reach either.** Measured the same way on the same pair of accounts: both NATs
|
|
86
|
+
preserved the port, 45223 on each, both sides sent datagrams for 30 seconds, and neither received
|
|
87
|
+
one. So neither protocol reaches between two Kaggle runtimes with a mapping learned from a third
|
|
88
|
+
party, and the distributed placement has no runtime to runtime transport on that pair today. A pair
|
|
89
|
+
that cannot connect refuses the reduction, which is what letify does, so the behaviour is right and
|
|
90
|
+
the placement is unusable there rather than slow.
|
|
91
|
+
|
|
92
|
+
Two paths remain, and which one letify takes is an open decision rather than something to assume.
|
|
93
|
+
Either both runtimes dial outward to a third host that is not the client, which is a relay and has to
|
|
94
|
+
be argued against the constraint that a slow link is a failure, or they exchange parameters through
|
|
95
|
+
object storage in one of their clouds, which letify already reaches with the `bucket` account option
|
|
96
|
+
and the GCS backend. The second needs no inbound anywhere, which is the property the measurements
|
|
97
|
+
above say is missing.
|
|
98
|
+
|
|
77
99
|
## How an experiment reports efficiency
|
|
78
100
|
|
|
79
101
|
This standard sits here rather than in `docs/SPEC.md` because it governs how a claim is tested, not what the system does. The spec records the design; this records what a report has to show before a claim above may be called supported. Every experiment pull request follows it.
|
|
@@ -108,7 +130,7 @@ Each of these would change a claim or a default. Answering one is a good first e
|
|
|
108
130
|
|
|
109
131
|
1. **How many concurrent sessions does one Colab account allow?** Undocumented, and it moves with tier, credit balance and demand. Until it is measured, a `devices` count in the provider entry is where the answer goes, so a user who has measured their own account is not overruled by a number letify guessed.
|
|
110
132
|
2. **What is the real host synchronization count per step, `k`, for the target workload?** Measurable with `torch.cuda.set_sync_debug_mode("warn")`. This sets whether PyTorch forwarding is worth using for that workload.
|
|
111
|
-
3. **How often does TCP hole punching succeed on the networks researchers actually use?** It succeeded between a Colab VM and a university network in Korea, where both NATs preserved the port. Home routers, office networks and mobile tethering are unmeasured. This decides how often the pipeline falls to UDP or to the provider's own path.
|
|
133
|
+
3. **How often does TCP hole punching succeed on the networks researchers actually use?** It succeeded between a Colab VM and a university network in Korea, where both NATs preserved the port. It failed between two Google Cloud VMs on two Kaggle accounts, where both NATs also preserved the port and every SYN was dropped, so a preserved port is not sufficient. Home routers, office networks and mobile tethering are unmeasured. This decides how often the pipeline falls to UDP or to the provider's own path, and runtime to runtime it decides whether the distributed placement has a transport on a given pair of providers.
|
|
112
134
|
4. **Is NVFP4 reachable in a stock Colab runtime?** Needs the CUDA version, the compute capability and whether the quantization stack installs.
|
|
113
135
|
5. **Is the Elice SSH port stable across a restart?** If it is not, the configuration needs a command that resolves the current port.
|
|
114
136
|
6. **What does Elice spot pricing cost?** The API exposes a pricing id, which suggests preemptible instances are available. This is a direct cost lever.
|
|
@@ -111,6 +111,30 @@ parameters into the weak direction every round. A pair of replicas that cannot p
|
|
|
111
111
|
fails the reduction and says so; it does not fall back to the client, for the reason
|
|
112
112
|
[Link floor](#link-floor) gives.
|
|
113
113
|
|
|
114
|
+
A replica joins a round with two addresses for the listener it opened: the one its own interface
|
|
115
|
+
reports, and the one a STUN server says its NAT gives that port. The broker passes both to every
|
|
116
|
+
other joined replica, with a token the pair agrees a connection on and the time both start dialing,
|
|
117
|
+
which is what [TCP hole punching](#tcp-punch) already needs from the client when the client is one
|
|
118
|
+
of the two ends. A replica then tries the address its peer reported for itself first, because two
|
|
119
|
+
replicas on one machine or one network reach each other without a punch, and punches to the mapped
|
|
120
|
+
address when that connection is refused. The punch is the same `punch` call a strategy makes. The
|
|
121
|
+
replica that would have dialed anyway, the one that is not the leader, is the initiator, so the two
|
|
122
|
+
sides never both take that role whatever their ranks are.
|
|
123
|
+
|
|
124
|
+
The distributed placement is supported on a pair of runtimes that can reach each other, and nowhere
|
|
125
|
+
else. Two Kaggle runtimes cannot: measured on 2026-10-13, both NATs preserved the port and every TCP
|
|
126
|
+
SYN was dropped for 45 s, and a UDP punch on the same pair sent for 30 s each way without a datagram
|
|
127
|
+
arriving. letify therefore refuses such a pair rather than routing the reduction through the client
|
|
128
|
+
or a relay, which is the decision `docs/INTENT.md` records against claim N10. A declaration naming
|
|
129
|
+
two runtimes that cannot connect fails, and the measurement to make before using a provider pair is
|
|
130
|
+
whether they punch at all.
|
|
131
|
+
|
|
132
|
+
Punching is between a pair, because one punched connection takes one local port on each side and
|
|
133
|
+
the two sides have to agree which pair that port belongs to. So a round of more than two replicas
|
|
134
|
+
whose members cannot reach each other directly is refused, naming the ranks that could not connect,
|
|
135
|
+
rather than punching a mesh letify has not measured. Two replicas is the case claim N9's boundary is
|
|
136
|
+
measured on, and more than two over direct addresses already works.
|
|
137
|
+
|
|
114
138
|
### How often to synchronize <!-- id: distributed-local-steps -->
|
|
115
139
|
|
|
116
140
|
> `letify.local_steps(param_bytes, step_seconds)` answers how many steps to take between
|
|
@@ -783,7 +807,11 @@ A `read_until` blocks until the sandbox prints a line, and the adapter answers o
|
|
|
783
807
|
- `Modal.stop` calls `abort()` when the adapter is out of step or its lock is still held after 5 s. The call timeout watchdog calls it through `SandboxChannel._kill`, so a call past its `timeout` ends its sandbox too.
|
|
784
808
|
- The adapter's `terminate` for an id it did not create finds the sandbox with `modal.Sandbox.from_id` and terminates it. A sandbox that is already gone is not an error.
|
|
785
809
|
|
|
786
|
-
Modal also bounds a sandbox that nothing terminates. `create` passes `
|
|
810
|
+
Modal also bounds a sandbox that nothing terminates. `create` passes `idle_timeout`, the entry option `idle_timeout`, 600 s by default, after which Modal terminates a sandbox that is idle. That is the bound that stops an abandoned sandbox billing, because an abandoned one is idle by definition once its worker has no one to answer.
|
|
811
|
+
|
|
812
|
+
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.
|
|
813
|
+
|
|
814
|
+
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`.
|
|
787
815
|
|
|
788
816
|
### Modal data channel <!-- id: modal-data-channel -->
|
|
789
817
|
|
|
@@ -1078,6 +1106,8 @@ Before a blob is written to a destination that is not unpacked, the local proces
|
|
|
1078
1106
|
|
|
1079
1107
|
A persistent provider builds its environment with `uv sync` in `<workspace root>/project/<env key>` every session, and never packs the project directory into a volume or unpacks an archive from one. A sync over an existing `.venv` checks it and installs nothing, so it is faster than any archive transfer, and the `.venv` is already on the disk the archive would be unpacked to.
|
|
1080
1108
|
|
|
1109
|
+
That holds only while the project directory is under the workspace root. A provider that keeps its workspace root and still builds the `.venv` elsewhere archives like an ephemeral one, because the directory the sync writes does not survive the session however durable the workspace root is. Modal is that provider: its workspace root is a Modal volume and its `env_root` is the sandbox's own disk, chosen because a cold import from the volume reads thousands of small files over the network. Without this, Modal rebuilt 230 packages on every session, measured at 428 s and 534 s on an A10, and had neither the persistence its workspace root offers nor the archive an ephemeral provider gets. Whether the archive is then worth packing is the measurement Materializing into a runtime already makes.
|
|
1110
|
+
|
|
1081
1111
|
An ephemeral provider keeps the behaviour of Materializing into a runtime: every file is sent each session, and the environment archive is packed and restored.
|
|
1082
1112
|
|
|
1083
1113
|
Nothing hands a session to the caller. There is no call that returns one, no argument that takes one, and no way to hold the wrong one, because which session serves a call is the pool's answer to work out from the declaration.
|
|
@@ -68,6 +68,7 @@ def all_reduce(
|
|
|
68
68
|
op: Callable[[list], Any] | None = None,
|
|
69
69
|
timeout: float = DEFAULT_TIMEOUT,
|
|
70
70
|
min_peers: int | None = None,
|
|
71
|
+
_watch: dict | None = None,
|
|
71
72
|
) -> int:
|
|
72
73
|
"""Replace ``tensor``'s contents with the reduction of every replica's, in place.
|
|
73
74
|
|
|
@@ -111,19 +112,16 @@ def all_reduce(
|
|
|
111
112
|
round_id = _ROUND
|
|
112
113
|
|
|
113
114
|
deadline = time.monotonic() + timeout
|
|
114
|
-
listener =
|
|
115
|
+
listener, port, mapped = _listener(world)
|
|
116
|
+
host = _local_address()
|
|
115
117
|
try:
|
|
116
|
-
listener.bind(("0.0.0.0", 0))
|
|
117
|
-
listener.listen(world)
|
|
118
|
-
port = listener.getsockname()[1]
|
|
119
|
-
host = _local_address()
|
|
120
|
-
|
|
121
118
|
main._reduce_join(
|
|
122
119
|
{
|
|
123
120
|
"op": "reduce_join",
|
|
124
121
|
"round": round_id,
|
|
125
122
|
"rank": my_rank,
|
|
126
123
|
"address": [host, port],
|
|
124
|
+
"mapped": list(mapped) if mapped is not None else None,
|
|
127
125
|
"timeout": timeout,
|
|
128
126
|
"min_peers": needed,
|
|
129
127
|
}
|
|
@@ -138,22 +136,58 @@ def all_reduce(
|
|
|
138
136
|
|
|
139
137
|
leader = int(table["leader"])
|
|
140
138
|
count = int(table["count"])
|
|
141
|
-
|
|
142
|
-
|
|
139
|
+
token = str(table.get("token", ""))
|
|
140
|
+
start_at = float(table.get("start_at", time.time()))
|
|
141
|
+
peers = {
|
|
142
|
+
int(r): {
|
|
143
|
+
"address": tuple(entry["address"]),
|
|
144
|
+
"mapped": tuple(entry["mapped"]) if entry.get("mapped") else None,
|
|
145
|
+
}
|
|
146
|
+
for r, entry in table["peers"].items()
|
|
147
|
+
}
|
|
148
|
+
header = {"rank": my_rank, "round": round_id, "nbytes": len(raw)}
|
|
149
|
+
|
|
150
|
+
if _watch is not None:
|
|
151
|
+
_watch["peers"] = table.get("peers")
|
|
152
|
+
_watch["token"] = token
|
|
153
|
+
_watch["start_at"] = start_at
|
|
143
154
|
|
|
144
155
|
if my_rank == leader:
|
|
145
|
-
result = _lead(
|
|
156
|
+
result = _lead(
|
|
157
|
+
listener, deadline, my_rank, tensor, header, raw, count, op, peers, token,
|
|
158
|
+
start_at, port, round_id,
|
|
159
|
+
)
|
|
146
160
|
else:
|
|
147
|
-
result = _follow(
|
|
161
|
+
result = _follow(leader, peers, deadline, header, raw, token, start_at, port, count)
|
|
148
162
|
except OSError as exc:
|
|
149
163
|
raise ReductionFailed(f"all_reduce round {round_id} failed: {exc}") from exc
|
|
150
|
-
finally:
|
|
151
|
-
listener.close()
|
|
152
164
|
|
|
153
165
|
_write_back(tensor, result)
|
|
154
166
|
return count
|
|
155
167
|
|
|
156
168
|
|
|
169
|
+
#: The replica's listener, its port and its STUN mapping, opened once and kept.
|
|
170
|
+
#: Spec "Reducing between replicas": a loop that reduces every H steps would otherwise
|
|
171
|
+
#: pay a STUN round trip and a fresh punch on every round.
|
|
172
|
+
_LISTENER: list = []
|
|
173
|
+
_LISTENER_LOCK = threading.Lock()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _listener(world: int) -> tuple[Any, int, tuple[str, int] | None]:
|
|
177
|
+
"""This replica's listening socket, its port and its mapped address, made once."""
|
|
178
|
+
with _LISTENER_LOCK:
|
|
179
|
+
if not _LISTENER:
|
|
180
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
181
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
182
|
+
if hasattr(socket, "SO_REUSEPORT"):
|
|
183
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
|
184
|
+
sock.bind(("0.0.0.0", 0))
|
|
185
|
+
sock.listen(max(1, world))
|
|
186
|
+
port = sock.getsockname()[1]
|
|
187
|
+
_LISTENER.append((sock, port, _mapped_address(port)))
|
|
188
|
+
return _LISTENER[0]
|
|
189
|
+
|
|
190
|
+
|
|
157
191
|
def _local_address() -> str:
|
|
158
192
|
"""The host peers can reach this one on. No configuration knob: it is the machine's
|
|
159
193
|
own address, as spec "Reducing between replicas" scopes the local transport."""
|
|
@@ -163,6 +197,40 @@ def _local_address() -> str:
|
|
|
163
197
|
return "127.0.0.1"
|
|
164
198
|
|
|
165
199
|
|
|
200
|
+
def _mapped_address(port: int) -> tuple[str, int] | None:
|
|
201
|
+
"""The STUN mapping of the listener's own ``port``, or None when it cannot be had.
|
|
202
|
+
|
|
203
|
+
Spec "Reducing between replicas": a replica joins a round with two addresses, its
|
|
204
|
+
own and this one. Never raises: a replica with no mapping still joins, with
|
|
205
|
+
``mapped`` absent, and is still reachable on its own address.
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
from ..transport import nat
|
|
209
|
+
except ImportError:
|
|
210
|
+
return None
|
|
211
|
+
try:
|
|
212
|
+
return nat.stun_mapping(port)
|
|
213
|
+
except Exception:
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _pair_for_punching(*, count: int, ranks: list[int]) -> tuple[int, int]:
|
|
218
|
+
"""The two ranks a punch is between, or a refusal naming every rank in the round.
|
|
219
|
+
|
|
220
|
+
Spec "Reducing between replicas": one punched connection takes one local port on
|
|
221
|
+
each side, so punching a round of more than two replicas is refused rather than
|
|
222
|
+
attempted as a mesh letify has not measured.
|
|
223
|
+
"""
|
|
224
|
+
ordered = sorted(ranks)
|
|
225
|
+
if count == 2 and len(ordered) == 2:
|
|
226
|
+
return ordered[0], ordered[1]
|
|
227
|
+
named = ", ".join(str(r) for r in ordered)
|
|
228
|
+
raise ReductionFailed(
|
|
229
|
+
f"a round of {count} replicas over ranks {named} cannot punch to reach each "
|
|
230
|
+
"other directly: punching is between a pair, not a mesh of more than two"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
166
234
|
def _await_table(main: Any, round_id: int, deadline: float) -> dict:
|
|
167
235
|
"""Block until the client answers this round's join, or the deadline passes."""
|
|
168
236
|
cond = main._REDUCE_LOCK
|
|
@@ -187,18 +255,62 @@ def _lead(
|
|
|
187
255
|
raw: bytes,
|
|
188
256
|
count: int,
|
|
189
257
|
op: Callable[[list], Any],
|
|
258
|
+
peers: dict[int, dict],
|
|
259
|
+
token: str,
|
|
260
|
+
start_at: float,
|
|
261
|
+
port: int,
|
|
262
|
+
round_id: int,
|
|
190
263
|
) -> Any:
|
|
191
|
-
"""Accept every other joined peer,
|
|
264
|
+
"""Accept every other joined peer, punching for one that never connects, then reduce
|
|
265
|
+
and send the result back to each.
|
|
266
|
+
|
|
267
|
+
Spec "Reducing between replicas": the leader accepts on its own listener first,
|
|
268
|
+
because two replicas on one machine or one network reach each other without a
|
|
269
|
+
punch, and punches with ``initiator=False`` only for a peer that never arrives.
|
|
270
|
+
"""
|
|
192
271
|
collected: dict[int, Any] = {my_rank: tensor}
|
|
193
272
|
conns: list[socket.socket] = []
|
|
194
273
|
try:
|
|
274
|
+
window_deadline = min(deadline, start_at + _window())
|
|
195
275
|
while len(collected) < count:
|
|
196
|
-
|
|
197
|
-
|
|
276
|
+
remaining_window = window_deadline - time.monotonic()
|
|
277
|
+
if remaining_window <= 0:
|
|
278
|
+
break
|
|
279
|
+
listener.settimeout(max(0.01, min(remaining_window, _remaining(deadline))))
|
|
280
|
+
try:
|
|
281
|
+
conn, _addr = listener.accept()
|
|
282
|
+
except TimeoutError:
|
|
283
|
+
break
|
|
198
284
|
conn.settimeout(_remaining(deadline))
|
|
199
285
|
peer_header, payload = _recv_message(conn)
|
|
286
|
+
if int(peer_header.get("round", round_id)) != round_id:
|
|
287
|
+
# The listener outlives a round, so a connection a previous one left
|
|
288
|
+
# behind can be accepted here. Spec "Reducing between replicas".
|
|
289
|
+
conn.close()
|
|
290
|
+
continue
|
|
200
291
|
collected[int(peer_header["rank"])] = _rebuild(tensor, payload)
|
|
201
292
|
conns.append(conn)
|
|
293
|
+
|
|
294
|
+
missing = sorted(r for r in peers if r not in collected)
|
|
295
|
+
if missing:
|
|
296
|
+
_pair_for_punching(count=count, ranks=sorted(peers))
|
|
297
|
+
from ..transport import nat
|
|
298
|
+
|
|
299
|
+
for peer_rank in missing:
|
|
300
|
+
mapped = peers[peer_rank]["mapped"]
|
|
301
|
+
if mapped is None:
|
|
302
|
+
raise ReductionFailed(
|
|
303
|
+
f"replica {peer_rank} never connected directly and reported no "
|
|
304
|
+
"mapped address to punch to"
|
|
305
|
+
)
|
|
306
|
+
conn = nat.punch(
|
|
307
|
+
port, mapped, bytes.fromhex(token), initiator=False, start_at=start_at
|
|
308
|
+
)
|
|
309
|
+
conn.settimeout(_remaining(deadline))
|
|
310
|
+
peer_header, payload = _recv_message(conn)
|
|
311
|
+
collected[int(peer_header["rank"])] = _rebuild(tensor, payload)
|
|
312
|
+
conns.append(conn)
|
|
313
|
+
|
|
202
314
|
arrays = [collected[r] for r in sorted(collected)]
|
|
203
315
|
result = op(arrays)
|
|
204
316
|
result_header, result_raw = _encode(result)
|
|
@@ -211,9 +323,34 @@ def _lead(
|
|
|
211
323
|
conn.close()
|
|
212
324
|
|
|
213
325
|
|
|
214
|
-
def _follow(
|
|
215
|
-
|
|
216
|
-
|
|
326
|
+
def _follow(
|
|
327
|
+
leader_rank: int,
|
|
328
|
+
peers: dict[int, dict],
|
|
329
|
+
deadline: float,
|
|
330
|
+
header: dict,
|
|
331
|
+
raw: bytes,
|
|
332
|
+
token: str,
|
|
333
|
+
start_at: float,
|
|
334
|
+
port: int,
|
|
335
|
+
count: int,
|
|
336
|
+
) -> bytes:
|
|
337
|
+
"""Send this replica's array to the leader, and wait for the reduced result's bytes.
|
|
338
|
+
|
|
339
|
+
Spec "Reducing between replicas": the address the leader reported for itself is
|
|
340
|
+
tried first, and the mapped address is punched to with ``initiator=True`` only when
|
|
341
|
+
that direct connection fails.
|
|
342
|
+
"""
|
|
343
|
+
peer = peers[leader_rank]
|
|
344
|
+
try:
|
|
345
|
+
conn = socket.create_connection(peer["address"], timeout=_remaining(deadline))
|
|
346
|
+
except OSError:
|
|
347
|
+
_pair_for_punching(count=count, ranks=sorted(peers))
|
|
348
|
+
mapped = peer["mapped"]
|
|
349
|
+
if mapped is None:
|
|
350
|
+
raise
|
|
351
|
+
from ..transport import nat
|
|
352
|
+
|
|
353
|
+
conn = nat.punch(port, mapped, bytes.fromhex(token), initiator=True, start_at=start_at)
|
|
217
354
|
try:
|
|
218
355
|
conn.settimeout(_remaining(deadline))
|
|
219
356
|
_send_message(conn, header, raw)
|
|
@@ -224,6 +361,15 @@ def _follow(leader_address: tuple, deadline: float, header: dict, raw: bytes) ->
|
|
|
224
361
|
conn.close()
|
|
225
362
|
|
|
226
363
|
|
|
364
|
+
def _window() -> float:
|
|
365
|
+
"""The punch window, from ``letify.transport.nat`` when it can be imported."""
|
|
366
|
+
try:
|
|
367
|
+
from ..transport import nat
|
|
368
|
+
except ImportError:
|
|
369
|
+
return 45.0
|
|
370
|
+
return nat.DEFAULT_WINDOW
|
|
371
|
+
|
|
372
|
+
|
|
227
373
|
def _remaining(deadline: float) -> float:
|
|
228
374
|
left = deadline - time.monotonic()
|
|
229
375
|
if left <= 0:
|
|
@@ -102,6 +102,26 @@ STOP_WAIT = 5.0
|
|
|
102
102
|
#: ``idle_timeout``.
|
|
103
103
|
IDLE_TIMEOUT = 600
|
|
104
104
|
|
|
105
|
+
#: The sandbox's maximum lifetime, the longest Modal accepts, measured as accepted.
|
|
106
|
+
#: Spec "Modal": not a shorter number letify picked, for the reason The declaration gives
|
|
107
|
+
#: for ``timeout`` having no default. The previous 3600 s ended two sessions of a real
|
|
108
|
+
#: training run at one hour each with nothing saying why.
|
|
109
|
+
SANDBOX_TIMEOUT = 86400
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def lifetime_detail(*, age_s: float, lifetime_s: int) -> str | None:
|
|
113
|
+
"""What to say when a sandbox died at the lifetime letify asked for, or None.
|
|
114
|
+
|
|
115
|
+
Spec "Modal": the channel otherwise reports a worker that stopped without replying and
|
|
116
|
+
lists causes, none of which is the deadline letify set itself.
|
|
117
|
+
"""
|
|
118
|
+
if lifetime_s <= 0 or age_s < lifetime_s - 60.0:
|
|
119
|
+
return None
|
|
120
|
+
return (
|
|
121
|
+
f"the Modal sandbox reached the {lifetime_s} s lifetime letify asked for; "
|
|
122
|
+
f"raise timeout on the account to run longer"
|
|
123
|
+
)
|
|
124
|
+
|
|
105
125
|
|
|
106
126
|
class VolumePathMissing(RuntimeFailure):
|
|
107
127
|
"""The adapter answered that a volume path does not exist."""
|
|
@@ -377,6 +397,18 @@ class Modal(Provider):
|
|
|
377
397
|
self._sandboxes: dict[str, str] = {}
|
|
378
398
|
self._adapter: Adapter | None = None
|
|
379
399
|
|
|
400
|
+
@property
|
|
401
|
+
def sandbox_timeout_s(self) -> int:
|
|
402
|
+
"""The sandbox's maximum lifetime, from the account or the longest Modal accepts."""
|
|
403
|
+
value = self.config.option("timeout", SANDBOX_TIMEOUT)
|
|
404
|
+
return max(1, int(value))
|
|
405
|
+
|
|
406
|
+
@property
|
|
407
|
+
def sandbox_idle_timeout_s(self) -> int:
|
|
408
|
+
"""Seconds a sandbox may be idle before Modal terminates it. Spec "Modal"."""
|
|
409
|
+
value = self.config.option("idle_timeout", IDLE_TIMEOUT)
|
|
410
|
+
return max(1, int(value))
|
|
411
|
+
|
|
380
412
|
def adapter(self) -> Adapter:
|
|
381
413
|
"""The adapter acting as this account, started on its first request."""
|
|
382
414
|
if self._adapter is None or self._adapter.closed:
|
|
@@ -440,14 +472,15 @@ class Modal(Provider):
|
|
|
440
472
|
args=["python3", "-u", "-c", BOOTSTRAP],
|
|
441
473
|
packages=list(WORKER_PACKAGES),
|
|
442
474
|
gpu=self.wire_name(runtime.instance) or None,
|
|
443
|
-
timeout=
|
|
444
|
-
idle_timeout=
|
|
475
|
+
timeout=self.sandbox_timeout_s,
|
|
476
|
+
idle_timeout=self.sandbox_idle_timeout_s,
|
|
445
477
|
ports=[] if data_port is None else [data_port],
|
|
446
478
|
)
|
|
447
479
|
sandbox = str(created["sandbox"])
|
|
448
480
|
self._sandboxes[runtime.name] = sandbox
|
|
449
481
|
return SandboxChannel(
|
|
450
|
-
adapter, sandbox, name=runtime.name, data_port=data_port, streams=streams
|
|
482
|
+
adapter, sandbox, name=runtime.name, data_port=data_port, streams=streams,
|
|
483
|
+
lifetime_s=self.sandbox_timeout_s,
|
|
451
484
|
)
|
|
452
485
|
|
|
453
486
|
def stop(self, runtime: Runtime) -> None:
|
|
@@ -598,11 +631,15 @@ class SandboxChannel(FramedChannel):
|
|
|
598
631
|
name: str,
|
|
599
632
|
data_port: int | None = None,
|
|
600
633
|
streams: int = 1,
|
|
634
|
+
lifetime_s: int = 0,
|
|
601
635
|
):
|
|
602
636
|
self.adapter = adapter
|
|
603
637
|
self.sandbox = sandbox
|
|
604
638
|
self.name = name
|
|
605
639
|
self.data_port = data_port
|
|
640
|
+
#: The sandbox's maximum lifetime, so a death at it is named rather than guessed.
|
|
641
|
+
self.lifetime_s = lifetime_s
|
|
642
|
+
self.opened_at = time.monotonic()
|
|
606
643
|
#: TCP connections the data channel carries its frames over.
|
|
607
644
|
self.streams = streams
|
|
608
645
|
self._sockets: list[socket.socket] = []
|
|
@@ -628,7 +665,13 @@ class SandboxChannel(FramedChannel):
|
|
|
628
665
|
self._await_ready()
|
|
629
666
|
|
|
630
667
|
def _raw_text(self) -> str:
|
|
631
|
-
|
|
668
|
+
text = bytes(self._raw[-2000:]).decode("utf-8", "replace")
|
|
669
|
+
# Spec "Modal": a sandbox that reached the lifetime letify asked for takes its
|
|
670
|
+
# worker with it, and the channel otherwise lists causes, none of them this one.
|
|
671
|
+
said = lifetime_detail(
|
|
672
|
+
age_s=time.monotonic() - self.opened_at, lifetime_s=self.lifetime_s
|
|
673
|
+
)
|
|
674
|
+
return f"{said}\n{text}" if said else text
|
|
632
675
|
|
|
633
676
|
def _send_worker(self) -> None:
|
|
634
677
|
# After a reexec the data connection is gone, so the source goes back over stdio.
|
|
@@ -10,6 +10,7 @@ claim N10.
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import json
|
|
13
|
+
import secrets
|
|
13
14
|
import threading
|
|
14
15
|
import time
|
|
15
16
|
from typing import Any
|
|
@@ -24,6 +25,10 @@ _TOTAL_BYTES = 0
|
|
|
24
25
|
#: Seconds between polls of a replica's joins, and between sweeps for an expired round.
|
|
25
26
|
_POLL_S = 0.02
|
|
26
27
|
|
|
28
|
+
#: Seconds ahead of now a round's agreed start moment is set, so every replica's
|
|
29
|
+
#: answer arrives before dialing is meant to begin.
|
|
30
|
+
_START_AHEAD_S = 1.0
|
|
31
|
+
|
|
27
32
|
|
|
28
33
|
def _account(payload: dict) -> None:
|
|
29
34
|
"""Add the size of one brokered payload to the running total."""
|
|
@@ -134,10 +139,26 @@ class Broker:
|
|
|
134
139
|
self._fail(round_id, joins)
|
|
135
140
|
|
|
136
141
|
def _answer(self, round_id: int, joins: dict[int, dict]) -> None:
|
|
137
|
-
"""Send every joined replica the peer table, naming the lowest rank leader.
|
|
142
|
+
"""Send every joined replica the peer table, naming the lowest rank leader.
|
|
143
|
+
|
|
144
|
+
Spec "Reducing between replicas": each entry carries both addresses a replica
|
|
145
|
+
joined with, and the table carries one token and one start moment, the same
|
|
146
|
+
for every replica of this round, so a pair can agree a punch without the client
|
|
147
|
+
relaying anything more than this.
|
|
148
|
+
"""
|
|
138
149
|
leader = min(joins)
|
|
139
|
-
peers = {
|
|
140
|
-
|
|
150
|
+
peers = {
|
|
151
|
+
str(r): {"address": list(join["address"]), "mapped": join.get("mapped")}
|
|
152
|
+
for r, join in joins.items()
|
|
153
|
+
}
|
|
154
|
+
payload = {
|
|
155
|
+
"round": round_id,
|
|
156
|
+
"peers": peers,
|
|
157
|
+
"leader": leader,
|
|
158
|
+
"count": len(joins),
|
|
159
|
+
"token": secrets.token_hex(16),
|
|
160
|
+
"start_at": time.time() + _START_AHEAD_S,
|
|
161
|
+
}
|
|
141
162
|
self._send_to(joins, payload)
|
|
142
163
|
|
|
143
164
|
def _fail(self, round_id: int, joins: dict[int, dict]) -> None:
|
|
@@ -174,7 +174,11 @@ class Runtime:
|
|
|
174
174
|
# Not retried, so nothing else would end this session.
|
|
175
175
|
self.shutdown(reason="interpreter_mismatch")
|
|
176
176
|
raise
|
|
177
|
-
|
|
177
|
+
# The same rule as _environment_volumes: a project directory that does not
|
|
178
|
+
# survive the session is worth archiving whatever the provider calls itself.
|
|
179
|
+
if self.env_source == "sync" and not (
|
|
180
|
+
self.provider.persistent and self._project_survives()
|
|
181
|
+
):
|
|
178
182
|
from . import bootstrap
|
|
179
183
|
|
|
180
184
|
root = bootstrap.project_dir(
|
|
@@ -643,8 +647,15 @@ class Runtime:
|
|
|
643
647
|
self.workspace = self.eval(bootstrap.workspace_source(root), timeout=120)
|
|
644
648
|
|
|
645
649
|
def _environment_volumes(self) -> tuple[Volume, ...]:
|
|
646
|
-
"""Explicit archive volumes, or the automatic cache
|
|
647
|
-
|
|
650
|
+
"""Explicit archive volumes, or the automatic cache where the project does not survive.
|
|
651
|
+
|
|
652
|
+
Spec "Volumes on a persistent runtime": a persistent provider needs no archive
|
|
653
|
+
because its project directory is on a disk that lasts, which holds only while that
|
|
654
|
+
directory is under the workspace root. One that keeps its workspace and builds the
|
|
655
|
+
.venv elsewhere archives like an ephemeral one, because the sync's output dies with
|
|
656
|
+
the session however durable the root is.
|
|
657
|
+
"""
|
|
658
|
+
if self.provider.persistent and self._project_survives():
|
|
648
659
|
return ()
|
|
649
660
|
if self.volumes:
|
|
650
661
|
return self.volumes
|
|
@@ -652,6 +663,14 @@ class Runtime:
|
|
|
652
663
|
|
|
653
664
|
return (environment_volume(self.provider),)
|
|
654
665
|
|
|
666
|
+
def _project_survives(self) -> bool:
|
|
667
|
+
"""Whether the directory the sync writes lives on the disk the workspace root is on."""
|
|
668
|
+
root = (self.provider.env_root or "").rstrip("/")
|
|
669
|
+
if not root:
|
|
670
|
+
return True
|
|
671
|
+
workspace = (self.workspace or self.provider.workspace_root or "").rstrip("/")
|
|
672
|
+
return bool(workspace) and (root == workspace or root.startswith(workspace + "/"))
|
|
673
|
+
|
|
655
674
|
def _measured_rate(self) -> float | None:
|
|
656
675
|
"""This session's measured upload rate in bytes per second, or None where none was.
|
|
657
676
|
|
|
@@ -138,7 +138,7 @@ dependencies = [
|
|
|
138
138
|
|
|
139
139
|
[[package]]
|
|
140
140
|
name = "letify-agent"
|
|
141
|
-
version = "2.6.
|
|
141
|
+
version = "2.6.4"
|
|
142
142
|
dependencies = [
|
|
143
143
|
"letify-wire",
|
|
144
144
|
"libloading",
|
|
@@ -146,7 +146,7 @@ dependencies = [
|
|
|
146
146
|
|
|
147
147
|
[[package]]
|
|
148
148
|
name = "letify-driver"
|
|
149
|
-
version = "2.6.
|
|
149
|
+
version = "2.6.4"
|
|
150
150
|
dependencies = [
|
|
151
151
|
"letify-wire",
|
|
152
152
|
]
|
|
@@ -164,7 +164,7 @@ dependencies = [
|
|
|
164
164
|
|
|
165
165
|
[[package]]
|
|
166
166
|
name = "letify-wire"
|
|
167
|
-
version = "2.6.
|
|
167
|
+
version = "2.6.4"
|
|
168
168
|
|
|
169
169
|
[[package]]
|
|
170
170
|
name = "libc"
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letify-status",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.4",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "letify-status",
|
|
9
|
-
"version": "2.6.
|
|
9
|
+
"version": "2.6.4",
|
|
10
10
|
"license": "SEE LICENSE IN LICENSE",
|
|
11
11
|
"devDependencies": {
|
|
12
12
|
"@types/node": "^18.19.0",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "letify-status",
|
|
3
3
|
"displayName": "letify Status",
|
|
4
4
|
"description": "letify quota and GPU activity in the status bar, read from the letify CLI.",
|
|
5
|
-
"version": "2.6.
|
|
5
|
+
"version": "2.6.4",
|
|
6
6
|
"publisher": "darkpyonix",
|
|
7
7
|
"license": "SEE LICENSE IN LICENSE",
|
|
8
8
|
"repository": {
|