letify 1.2.0__tar.gz → 1.2.1__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-1.2.0 → letify-1.2.1}/PKG-INFO +4 -1
- {letify-1.2.0 → letify-1.2.1}/README.md +3 -0
- {letify-1.2.0 → letify-1.2.1}/docs/guide/07-troubleshooting.md +1 -1
- {letify-1.2.0 → letify-1.2.1}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md +1 -1
- {letify-1.2.0 → letify-1.2.1}/docs/locales/README_ko.md +3 -0
- {letify-1.2.0 → letify-1.2.1}/letify/__init__.py +1 -1
- {letify-1.2.0 → letify-1.2.1}/letify/providers/colab.py +21 -3
- {letify-1.2.0 → letify-1.2.1}/letify/providers/kaggle.py +18 -7
- {letify-1.2.0 → letify-1.2.1}/letify/tools.py +2 -2
- {letify-1.2.0 → letify-1.2.1}/letify/transport/pipeline.py +18 -11
- {letify-1.2.0 → letify-1.2.1}/letify-core/Cargo.lock +3 -3
- {letify-1.2.0 → letify-1.2.1}/letify-core/Cargo.toml +1 -1
- {letify-1.2.0 → letify-1.2.1}/letify-ext/package-lock.json +2 -2
- {letify-1.2.0 → letify-1.2.1}/letify-ext/package.json +1 -1
- {letify-1.2.0 → letify-1.2.1}/pyproject.toml +1 -1
- {letify-1.2.0 → letify-1.2.1}/tests/test_kaggle.py +82 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_providers.py +52 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_transport.py +69 -2
- {letify-1.2.0 → letify-1.2.1}/.github/workflows/ci.yml +0 -0
- {letify-1.2.0 → letify-1.2.1}/.github/workflows/docs.yml +0 -0
- {letify-1.2.0 → letify-1.2.1}/.github/workflows/publish.yml +0 -0
- {letify-1.2.0 → letify-1.2.1}/.gitignore +0 -0
- {letify-1.2.0 → letify-1.2.1}/.researchtree +0 -0
- {letify-1.2.0 → letify-1.2.1}/LICENSE +0 -0
- {letify-1.2.0 → letify-1.2.1}/docs/guide/01-getting-started.md +0 -0
- {letify-1.2.0 → letify-1.2.1}/docs/guide/02-providers.md +0 -0
- {letify-1.2.0 → letify-1.2.1}/docs/guide/03-execution-modes.md +0 -0
- {letify-1.2.0 → letify-1.2.1}/docs/guide/04-environments-and-data.md +0 -0
- {letify-1.2.0 → letify-1.2.1}/docs/guide/05-concurrency.md +0 -0
- {letify-1.2.0 → letify-1.2.1}/docs/guide/06-cost.md +0 -0
- {letify-1.2.0 → letify-1.2.1}/docs/guide/08-releasing.md +0 -0
- {letify-1.2.0 → letify-1.2.1}/docs/guide/README.md +0 -0
- {letify-1.2.0 → letify-1.2.1}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
- {letify-1.2.0 → letify-1.2.1}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
- {letify-1.2.0 → letify-1.2.1}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
- {letify-1.2.0 → letify-1.2.1}/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-1.2.0 → letify-1.2.1}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
- {letify-1.2.0 → letify-1.2.1}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-1.2.0 → letify-1.2.1}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-1.2.0 → letify-1.2.1}/docs/guide/ko/README.md +0 -0
- {letify-1.2.0 → letify-1.2.1}/docs/mkdocs.yml +0 -0
- {letify-1.2.0 → letify-1.2.1}/examples/README.md +0 -0
- {letify-1.2.0 → letify-1.2.1}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-1.2.0 → letify-1.2.1}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/examples/nvfp4-lora/README.md +0 -0
- {letify-1.2.0 → letify-1.2.1}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-1.2.0 → letify-1.2.1}/examples/nvfp4-lora/common.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/cli.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/config/__init__.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/config/inventory.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/config/login.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/config/schema.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/config/secrets.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/config/writer.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/declare/__init__.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/declare/cache.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/declare/env.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/declare/function.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/declare/instance.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/errors.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/install.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/launcher.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/paths.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/protocol/__init__.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/protocol/codec.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/protocol/driver.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/protocol/handle.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/protocol/wire.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/protocol/worker.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/providers/__init__.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/providers/base.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/providers/colab_files.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/providers/colab_keepalive.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/providers/elice.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/providers/kaggle_adapter.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/providers/local.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/providers/modal.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/providers/modal_adapter.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/providers/naming.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/providers/shell.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/providers/tunnel.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/providers/usage.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/remoting/__init__.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/remoting/capability.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/remoting/device/__init__.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/remoting/device/client.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/remoting/device/cuda.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/remoting/device/executor.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/remoting/device/frames.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/remoting/device/guard.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/remoting/device/tensor.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/remoting/device/trace.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/remoting/device/value.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/remoting/loader.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/remoting/probe.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/render.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/runtime/__init__.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/runtime/bootstrap.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/runtime/channel.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/runtime/lease.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/runtime/pool.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/runtime/session.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/runtime/telemetry.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/store/__init__.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/store/backends/__init__.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/store/backends/filesystem.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/store/backends/google_auth.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/store/backends/layout.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/store/backends/objects.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/store/cas.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/store/pathdata.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/store/sendorder.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/store/volume.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/stubs.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/transport/__init__.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/transport/agent.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/transport/announce.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/transport/link.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/transport/nat.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/transport/probe.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/transport/rendezvous.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/transport/setup.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/transport/sshopts.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify/transport/strategies.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-core/.gitignore +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-core/build.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-core/rust-toolchain.toml +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-ext/.vscodeignore +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-ext/LICENSE +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-ext/README.md +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-ext/media/icon.svg +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-ext/src/cli.ts +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-ext/src/extension.ts +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-ext/src/history.ts +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-ext/src/model.ts +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-ext/src/poll.ts +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-ext/src/render.ts +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-ext/test/cli.test.ts +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-ext/test/manifest.test.ts +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-ext/test/model.test.ts +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-ext/test/regressions.test.ts +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify-ext/tsconfig.json +0 -0
- {letify-1.2.0 → letify-1.2.1}/letify_providers.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/scripts/sync-main.sh +0 -0
- {letify-1.2.0 → letify-1.2.1}/scripts/version.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/conftest.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/fake_kaggle_adapter.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/fake_modal_adapter.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_cli.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_cli_output.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_colab_keepalive.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_config.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_core.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_data.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_data_streaming.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_declare.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_device.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_editor_sessions.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_elice.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_frames.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_install.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_kaggle_cookie.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_link_log.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_notebook.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_paths.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_protocol.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_remoting.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_render.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_runtime.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_store.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_strategies.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_stubs.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_tunnel_setup.py +0 -0
- {letify-1.2.0 → letify-1.2.1}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: letify
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.1
|
|
4
4
|
Summary: Declarations that become infrastructure.
|
|
5
5
|
Project-URL: Homepage, https://github.com/DarkPyonix/letify
|
|
6
6
|
Project-URL: Documentation, https://github.com/DarkPyonix/letify/tree/main/docs
|
|
@@ -192,6 +192,7 @@ Each command writes two files. The account goes to `~/.letify/config.toml`, whic
|
|
|
192
192
|
[colab_pro_plus]
|
|
193
193
|
kind = "colab"
|
|
194
194
|
account = "you@example.com"
|
|
195
|
+
min_mib_per_s = 0.05
|
|
195
196
|
|
|
196
197
|
[lab_a100]
|
|
197
198
|
kind = "shell"
|
|
@@ -201,6 +202,8 @@ key = "~/.ssh/id_ed25519"
|
|
|
201
202
|
persistent = true
|
|
202
203
|
```
|
|
203
204
|
|
|
205
|
+
The connection pipeline defaults to `10 MiB/s` in each direction and `300 ms` RTT. Colab's measured sustained best is `8.8 MiB/s` up and `10.7 MiB/s` down, so it fails the default floor. The account example explicitly permits a slow link with `min_mib_per_s = 0.05`. Set `min_mib_per_s` and `max_rtt_ms` for your measurements. When all measured links fail the floor, the connection is refused even if an unprobed fallback connected.
|
|
206
|
+
|
|
204
207
|
The project's `.letify/config.toml` gets only the alias, which is what makes the account usable in this project:
|
|
205
208
|
|
|
206
209
|
```toml
|
|
@@ -167,6 +167,7 @@ Each command writes two files. The account goes to `~/.letify/config.toml`, whic
|
|
|
167
167
|
[colab_pro_plus]
|
|
168
168
|
kind = "colab"
|
|
169
169
|
account = "you@example.com"
|
|
170
|
+
min_mib_per_s = 0.05
|
|
170
171
|
|
|
171
172
|
[lab_a100]
|
|
172
173
|
kind = "shell"
|
|
@@ -176,6 +177,8 @@ key = "~/.ssh/id_ed25519"
|
|
|
176
177
|
persistent = true
|
|
177
178
|
```
|
|
178
179
|
|
|
180
|
+
The connection pipeline defaults to `10 MiB/s` in each direction and `300 ms` RTT. Colab's measured sustained best is `8.8 MiB/s` up and `10.7 MiB/s` down, so it fails the default floor. The account example explicitly permits a slow link with `min_mib_per_s = 0.05`. Set `min_mib_per_s` and `max_rtt_ms` for your measurements. When all measured links fail the floor, the connection is refused even if an unprobed fallback connected.
|
|
181
|
+
|
|
179
182
|
The project's `.letify/config.toml` gets only the alias, which is what makes the account usable in this project:
|
|
180
183
|
|
|
181
184
|
```toml
|
|
@@ -264,7 +264,7 @@ kind = "tunnel"
|
|
|
264
264
|
mtu = 1280
|
|
265
265
|
```
|
|
266
266
|
|
|
267
|
-
letify holds every connection strategy, Tailcat's hole punch included, to a floor: a probe below
|
|
267
|
+
letify holds every connection strategy, Tailcat's hole punch included, to a floor: a probe below 10 MiB/s or above 300 ms round trip fails the connection instead of completing it, because a slow link is a failure, not a fallback. See [docs/SPEC.md, Link floor](../SPEC.md#link-floor). If the connection now fails with "below the floor", that is the diagnosis: the path is too slow to use, usually because Tailcat's own direct UDP punch did not succeed and it fell back to a relay. No relay path is offered as an alternative, so fix the underlying reachability instead: open the UDP port the punch needs, or set `address` to a direct SSH path if one exists on the same network. See [docs/NETWORK.md](../NETWORK.md).
|
|
268
268
|
|
|
269
269
|
### Gathered calls are not running in parallel
|
|
270
270
|
|
{letify-1.2.0 → letify-1.2.1}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md
RENAMED
|
@@ -264,7 +264,7 @@ kind = "tunnel"
|
|
|
264
264
|
mtu = 1280
|
|
265
265
|
```
|
|
266
266
|
|
|
267
|
-
letify는 Tailcat 홀펀칭을 포함한 모든 연결 전략을 바닥값에 묶어 둡니다: 측정값이
|
|
267
|
+
letify는 Tailcat 홀펀칭을 포함한 모든 연결 전략을 바닥값에 묶어 둡니다: 측정값이 10 MiB/s 미만이거나 왕복 300 ms를 넘으면 연결을 완료시키는 대신 실패시킵니다. 느린 링크는 폴백이 아니라 실패이기 때문입니다. [docs/SPEC.md의 Link floor](../../SPEC.md#link-floor)를 보세요. 이제 "below the floor"로 연결이 실패한다면 그것이 진단입니다: 경로가 너무 느려 쓸 수 없고, 보통 Tailcat의 직접 UDP 홀펀칭이 성공하지 못해 중계로 떨어졌기 때문입니다. 대안으로 중계 경로를 제공하지 않으므로, 도달성 자체를 고치세요: 홀펀칭에 필요한 UDP 포트를 열거나, 같은 망에 직접 SSH 경로가 있다면 `address`를 설정하세요. [docs/NETWORK.md](../../NETWORK.md)를 보세요.
|
|
268
268
|
|
|
269
269
|
### 모은 호출이 병렬로 돌지 않습니다
|
|
270
270
|
|
|
@@ -161,6 +161,7 @@ letify login shell lab_a100
|
|
|
161
161
|
[colab_pro_plus]
|
|
162
162
|
kind = "colab"
|
|
163
163
|
account = "you@example.com"
|
|
164
|
+
min_mib_per_s = 0.05
|
|
164
165
|
|
|
165
166
|
[lab_a100]
|
|
166
167
|
kind = "shell"
|
|
@@ -170,6 +171,8 @@ key = "~/.ssh/id_ed25519"
|
|
|
170
171
|
persistent = true
|
|
171
172
|
```
|
|
172
173
|
|
|
174
|
+
연결 파이프라인의 기본 기준은 양방향 `10 MiB/s`, RTT `300 ms`입니다. Colab의 측정된 지속 전송 최고값은 올리기 `8.8 MiB/s`, 내리기 `10.7 MiB/s`라서 기본 기준을 통과하지 못합니다. 위 계정의 `min_mib_per_s = 0.05`는 느린 연결을 명시적으로 허용하는 예시입니다. 실제 측정에 맞춰 `min_mib_per_s`와 `max_rtt_ms`를 설정하세요. 측정된 링크가 모두 기준 미달이면 측정하지 않은 fallback으로 바꾸지 않고 연결을 거부합니다.
|
|
175
|
+
|
|
173
176
|
프로젝트의 `.letify/config.toml`에는 alias만 들어갑니다. 이 줄이 있어야 그 계정을 이 프로젝트에서 쓸 수 있습니다.
|
|
174
177
|
|
|
175
178
|
```toml
|
|
@@ -281,7 +281,27 @@ class Colab(Shell):
|
|
|
281
281
|
from .colab_keepalive import account_lock
|
|
282
282
|
|
|
283
283
|
with account_lock(account_directory(self.alias) / ".config" / "colab-cli"):
|
|
284
|
-
|
|
284
|
+
try:
|
|
285
|
+
return self._cli("exec", "-s", session, stdin=source, timeout=timeout)
|
|
286
|
+
except subprocess.TimeoutExpired as exc:
|
|
287
|
+
stderr = exc.stderr or ""
|
|
288
|
+
if isinstance(stderr, bytes):
|
|
289
|
+
stderr = stderr.decode(errors="replace")
|
|
290
|
+
raise RuntimeFailure(
|
|
291
|
+
f"{session}: colab exec timed out after {timeout} s; "
|
|
292
|
+
"the notebook kernel may be busy or not answering",
|
|
293
|
+
command=" ".join([*self._colab(), "exec", "-s", session]),
|
|
294
|
+
stderr=stderr,
|
|
295
|
+
) from exc
|
|
296
|
+
except RuntimeFailure as exc:
|
|
297
|
+
if "Timeout waiting for reply" not in exc.stderr:
|
|
298
|
+
raise
|
|
299
|
+
raise RuntimeFailure(
|
|
300
|
+
f"{session}: colab exec did not receive a reply; "
|
|
301
|
+
"the notebook kernel may be busy or not answering",
|
|
302
|
+
command=exc.command,
|
|
303
|
+
stderr=exc.stderr,
|
|
304
|
+
) from exc
|
|
285
305
|
|
|
286
306
|
def sessions(self) -> list[str]:
|
|
287
307
|
"""Names of the sessions this account currently holds."""
|
|
@@ -507,5 +527,3 @@ __all__ = [
|
|
|
507
527
|
"Colab",
|
|
508
528
|
"ColabSessionReclaimed",
|
|
509
529
|
]
|
|
510
|
-
|
|
511
|
-
|
|
@@ -520,9 +520,9 @@ def cancel_run(cookie: str, run_id: int) -> None:
|
|
|
520
520
|
|
|
521
521
|
|
|
522
522
|
def read_api_token(alias: str) -> str | None:
|
|
523
|
-
"""Read the account's
|
|
523
|
+
"""Read the account's API token with file whitespace stripped, or None if unavailable."""
|
|
524
524
|
try:
|
|
525
|
-
token = (account_directory(alias) / "access_token").read_text(encoding="utf-8")
|
|
525
|
+
token = (account_directory(alias) / "access_token").read_text(encoding="utf-8").strip()
|
|
526
526
|
except (OSError, UnicodeError):
|
|
527
527
|
return None
|
|
528
528
|
return token or None
|
|
@@ -544,6 +544,15 @@ def require_api_token(alias: str) -> str:
|
|
|
544
544
|
return token
|
|
545
545
|
|
|
546
546
|
|
|
547
|
+
def read_notebook_owner(alias: str) -> str | None:
|
|
548
|
+
"""Read the public notebook owner saved by login, or None if unavailable."""
|
|
549
|
+
try:
|
|
550
|
+
owner = (account_directory(alias) / "username").read_text(encoding="utf-8").strip()
|
|
551
|
+
except (OSError, UnicodeError):
|
|
552
|
+
return None
|
|
553
|
+
return owner or None
|
|
554
|
+
|
|
555
|
+
|
|
547
556
|
def delete_notebook_via_cli(alias: str, slug: str | None) -> bool:
|
|
548
557
|
"""Delete the ephemeral notebook through the official CLI, the only way it is deleted.
|
|
549
558
|
|
|
@@ -558,11 +567,8 @@ def delete_notebook_via_cli(alias: str, slug: str | None) -> bool:
|
|
|
558
567
|
token = read_api_token(alias)
|
|
559
568
|
if token is None:
|
|
560
569
|
return False
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
except (OSError, UnicodeError):
|
|
564
|
-
return False
|
|
565
|
-
if not username:
|
|
570
|
+
username = read_notebook_owner(alias)
|
|
571
|
+
if username is None:
|
|
566
572
|
return False
|
|
567
573
|
uv = tools.find_uv()
|
|
568
574
|
if uv is None:
|
|
@@ -1036,6 +1042,11 @@ class Kaggle(Provider):
|
|
|
1036
1042
|
The notebook is created fresh for this runtime alone, never reused from an earlier
|
|
1037
1043
|
one: spec "Kaggle session token chain".
|
|
1038
1044
|
"""
|
|
1045
|
+
if read_notebook_owner(self.alias) is None:
|
|
1046
|
+
raise ConfigError(
|
|
1047
|
+
f"{self.alias} has no Kaggle notebook owner. Log in again with: "
|
|
1048
|
+
f"letify login kaggle {self.alias} --username <owner>"
|
|
1049
|
+
)
|
|
1039
1050
|
cookie = self._require_live_cookie()
|
|
1040
1051
|
instance = getattr(runtime, "instance", None)
|
|
1041
1052
|
gpu = getattr(instance, "gpu", None)
|
|
@@ -265,9 +265,9 @@ def kaggle_cli_command(uv: str) -> list[str]:
|
|
|
265
265
|
|
|
266
266
|
|
|
267
267
|
def kaggle_cli_environment(alias: str) -> dict[str, str]:
|
|
268
|
-
"""Pass this account's
|
|
268
|
+
"""Pass this account's token without file whitespace through the child environment."""
|
|
269
269
|
env = dict(os.environ)
|
|
270
|
-
token = (account_directory(alias) / "access_token").read_text(encoding="utf-8")
|
|
270
|
+
token = (account_directory(alias) / "access_token").read_text(encoding="utf-8").strip()
|
|
271
271
|
env["KAGGLE_API_TOKEN"] = token
|
|
272
272
|
return env
|
|
273
273
|
|
|
@@ -32,12 +32,11 @@ CACHE_RATIO = 0.5
|
|
|
32
32
|
CACHE_TTL_SECONDS = 3600.0
|
|
33
33
|
|
|
34
34
|
MIB = 1024 * 1024
|
|
35
|
-
#: The default floor: a link under
|
|
35
|
+
#: The default floor: a link under 10 MiB/s in either direction, or over 300 ms round trip,
|
|
36
36
|
#: is a failure rather than a connection. See docs/INTENT.md, "A slow link is a failure,
|
|
37
|
-
#: not a fallback"
|
|
38
|
-
#:
|
|
39
|
-
|
|
40
|
-
DEFAULT_MIN_MIB_PER_S = 5.0
|
|
37
|
+
#: not a fallback". Accounts can override the shared policy; providers cannot choose
|
|
38
|
+
#: a lower default.
|
|
39
|
+
DEFAULT_MIN_MIB_PER_S = 10.0
|
|
41
40
|
DEFAULT_MAX_RTT_MS = 300.0
|
|
42
41
|
|
|
43
42
|
|
|
@@ -66,6 +65,8 @@ class LinkFloor:
|
|
|
66
65
|
found.append(
|
|
67
66
|
f"round trip {result.rtt_ms:.1f} ms above the floor {self.max_rtt_ms:.0f} ms"
|
|
68
67
|
)
|
|
68
|
+
if found:
|
|
69
|
+
found.append("account overrides: min_mib_per_s and max_rtt_ms")
|
|
69
70
|
return found
|
|
70
71
|
|
|
71
72
|
|
|
@@ -270,7 +271,7 @@ class Pipeline:
|
|
|
270
271
|
|
|
271
272
|
network = self.fingerprint() if self.cache is not None else None
|
|
272
273
|
if self.cache is not None and network is not None:
|
|
273
|
-
kept = self._from_cache(applicable, network)
|
|
274
|
+
kept = self._from_cache(applicable, network, reasons)
|
|
274
275
|
if kept is not None:
|
|
275
276
|
return kept
|
|
276
277
|
|
|
@@ -292,7 +293,7 @@ class Pipeline:
|
|
|
292
293
|
|
|
293
294
|
# -- the cache -----------------------------------------------------------
|
|
294
295
|
|
|
295
|
-
def _from_cache(self, applicable: list[Any], network: Fingerprint) -> Any:
|
|
296
|
+
def _from_cache(self, applicable: list[Any], network: Fingerprint, reasons: list[str]) -> Any:
|
|
296
297
|
entry = self.cache.load() if self.cache else None
|
|
297
298
|
if entry is None:
|
|
298
299
|
return None
|
|
@@ -336,6 +337,7 @@ class Pipeline:
|
|
|
336
337
|
floored = self.floor.violations(measured)
|
|
337
338
|
if floored:
|
|
338
339
|
self._say(f"cached {name} rejected: below the floor: {', '.join(floored)}")
|
|
340
|
+
reasons.append(f"{name}: below the floor: {', '.join(floored)}")
|
|
339
341
|
_close(link)
|
|
340
342
|
return None
|
|
341
343
|
short = _below(
|
|
@@ -459,12 +461,16 @@ class Pipeline:
|
|
|
459
461
|
self, connected: list[Any], reasons: list[str], held: set[str]
|
|
460
462
|
) -> tuple[Any, ProbeResult | None]:
|
|
461
463
|
"""Pick among connected links. ``held`` names the strategies that cannot be probed."""
|
|
464
|
+
floored_out = [reason for reason in reasons if ": below the floor:" in reason]
|
|
462
465
|
if len(connected) == 1:
|
|
463
466
|
link = connected[0]
|
|
464
467
|
try:
|
|
465
468
|
measured = self._measure(link)
|
|
466
469
|
except Exception:
|
|
467
470
|
measured = None
|
|
471
|
+
if measured is None and floored_out:
|
|
472
|
+
_close(link)
|
|
473
|
+
raise ProviderUnavailable("shell", self._explain(reasons))
|
|
468
474
|
if link.strategy in held:
|
|
469
475
|
self._fall_back(link)
|
|
470
476
|
else:
|
|
@@ -491,7 +497,6 @@ class Pipeline:
|
|
|
491
497
|
else:
|
|
492
498
|
probed.append((link, measured))
|
|
493
499
|
|
|
494
|
-
floored_out: list[str] = []
|
|
495
500
|
above_floor: list[tuple[Any, ProbeResult]] = []
|
|
496
501
|
for link, measured in probed:
|
|
497
502
|
floored = self.floor.violations(measured)
|
|
@@ -525,14 +530,16 @@ class Pipeline:
|
|
|
525
530
|
self._say(f"chose {chosen.strategy}: only one connected and probed")
|
|
526
531
|
else:
|
|
527
532
|
self._say(f"chose {chosen.strategy}: lowest rank within 25% of the fastest")
|
|
528
|
-
elif unprobed:
|
|
529
|
-
chosen, measured = unprobed[0], None
|
|
530
|
-
self._fall_back(chosen)
|
|
531
533
|
elif floored_out:
|
|
534
|
+
for link in unprobed:
|
|
535
|
+
_close(link)
|
|
532
536
|
self._say("every probe was below the floor")
|
|
533
537
|
raise ProviderUnavailable(
|
|
534
538
|
"shell", self._explain([*reasons, "every probe was below the floor", *floored_out])
|
|
535
539
|
)
|
|
540
|
+
elif unprobed:
|
|
541
|
+
chosen, measured = unprobed[0], None
|
|
542
|
+
self._fall_back(chosen)
|
|
536
543
|
else:
|
|
537
544
|
self._say("every probe failed")
|
|
538
545
|
raise ProviderUnavailable("shell", self._explain([*reasons, "every probe failed"]))
|
|
@@ -10,7 +10,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|
|
10
10
|
|
|
11
11
|
[[package]]
|
|
12
12
|
name = "letify-agent"
|
|
13
|
-
version = "1.2.
|
|
13
|
+
version = "1.2.1"
|
|
14
14
|
dependencies = [
|
|
15
15
|
"letify-wire",
|
|
16
16
|
"libloading",
|
|
@@ -18,14 +18,14 @@ dependencies = [
|
|
|
18
18
|
|
|
19
19
|
[[package]]
|
|
20
20
|
name = "letify-driver"
|
|
21
|
-
version = "1.2.
|
|
21
|
+
version = "1.2.1"
|
|
22
22
|
dependencies = [
|
|
23
23
|
"letify-wire",
|
|
24
24
|
]
|
|
25
25
|
|
|
26
26
|
[[package]]
|
|
27
27
|
name = "letify-wire"
|
|
28
|
-
version = "1.2.
|
|
28
|
+
version = "1.2.1"
|
|
29
29
|
|
|
30
30
|
[[package]]
|
|
31
31
|
name = "libloading"
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letify-status",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "letify-status",
|
|
9
|
-
"version": "1.2.
|
|
9
|
+
"version": "1.2.1",
|
|
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": "1.2.
|
|
5
|
+
"version": "1.2.1",
|
|
6
6
|
"publisher": "darkpyonix",
|
|
7
7
|
"license": "SEE LICENSE IN LICENSE",
|
|
8
8
|
"repository": {
|
|
@@ -127,6 +127,20 @@ def test_a_login_missing_only_the_key_is_refused(isolated_home, accept_cookie, c
|
|
|
127
127
|
assert not (account("kaggle_a") / "cookie").exists()
|
|
128
128
|
|
|
129
129
|
|
|
130
|
+
@pytest.mark.parametrize("owner", [None, "", " "])
|
|
131
|
+
def test_login_refuses_a_missing_owner_before_declaring_the_account(
|
|
132
|
+
isolated_home, accept_cookie, capsys, owner
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Spec "Kaggle": a declared account must carry its notebook deletion owner."""
|
|
135
|
+
owner_args = [] if owner is None else ["--username", owner]
|
|
136
|
+
assert main(["login", "kaggle", "kaggle_a", "--cookie", make_cookie(),
|
|
137
|
+
"--key", API_TOKEN, *owner_args, "--no-input"]) == 1
|
|
138
|
+
assert "notebook owner" in capsys.readouterr().err
|
|
139
|
+
assert not account("kaggle_a").exists()
|
|
140
|
+
assert not (Path.home() / ".letify" / "config.toml").exists()
|
|
141
|
+
assert accept_cookie == []
|
|
142
|
+
|
|
143
|
+
|
|
130
144
|
def test_an_expired_cookie_is_refused_with_nothing_written(
|
|
131
145
|
isolated_home, accept_cookie, capsys
|
|
132
146
|
) -> None:
|
|
@@ -268,6 +282,7 @@ def test_a_run_refuses_to_start_with_under_an_hour_left_on_the_cookie(isolated_h
|
|
|
268
282
|
import letify
|
|
269
283
|
from letify.config.secrets import write_secret
|
|
270
284
|
|
|
285
|
+
write_secret("kaggle_a", "username", "irack000")
|
|
271
286
|
soon = datetime.now(UTC) + timedelta(minutes=30)
|
|
272
287
|
write_secret("kaggle_a", "cookie", make_cookie(soon.strftime("%Y-%m-%dT%H:%M:%SZ")))
|
|
273
288
|
runtime = type("R", (), {"name": "letify-t4-1"})()
|
|
@@ -786,6 +801,51 @@ def test_stopping_cancels_the_run_through_the_cookie_and_deletes_the_notebook_vi
|
|
|
786
801
|
assert "kernels" in deletes[0]
|
|
787
802
|
|
|
788
803
|
|
|
804
|
+
def test_stop_deletes_the_notebook_for_an_account_declared_through_login(
|
|
805
|
+
fake_kaggle, fake_kaggle_cli, capsys
|
|
806
|
+
) -> None:
|
|
807
|
+
"""Spec "Kaggle session token chain": login supplies the owner used by stop."""
|
|
808
|
+
for path in account("kaggle_a").iterdir():
|
|
809
|
+
path.unlink()
|
|
810
|
+
account("kaggle_a").rmdir()
|
|
811
|
+
assert main(["login", "kaggle", "kaggle_a", "--cookie", make_cookie(),
|
|
812
|
+
"--username", "login_owner", "--key", API_TOKEN, "--no-input"]) == 0
|
|
813
|
+
assert home_config()["kaggle_a"] == {"kind": "kaggle"}
|
|
814
|
+
assert (account("kaggle_a") / "username").read_text() == "login_owner"
|
|
815
|
+
assert (account("kaggle_a") / "access_token").read_text() == API_TOKEN
|
|
816
|
+
assert (account("kaggle_a") / "cookie").read_text() == make_cookie()
|
|
817
|
+
|
|
818
|
+
provider, runtime, _channel = session_channel(fake_kaggle)
|
|
819
|
+
provider.stop(runtime)
|
|
820
|
+
|
|
821
|
+
assert fake_kaggle.kernels == set()
|
|
822
|
+
assert fake_kaggle.cancelled == [fake_kaggle.RUN_ID]
|
|
823
|
+
assert len(fake_kaggle_cli.calls) == 1
|
|
824
|
+
assert fake_kaggle_cli.calls[0][-4:] == [
|
|
825
|
+
"kernels", "delete", "-y", "login_owner/letify-runtime"
|
|
826
|
+
]
|
|
827
|
+
assert fake_kaggle_cli.environments[0]["KAGGLE_API_TOKEN"] == API_TOKEN
|
|
828
|
+
assert "delete it by hand" not in capsys.readouterr().err
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
@pytest.mark.parametrize("contents", [None, b"", b" \n", b"\xff"])
|
|
832
|
+
def test_a_session_without_a_readable_owner_is_refused_before_creating_a_notebook(
|
|
833
|
+
fake_kaggle, contents
|
|
834
|
+
) -> None:
|
|
835
|
+
"""Spec "Kaggle": an incomplete account cannot create an undeletable notebook."""
|
|
836
|
+
from letify.errors import ConfigError
|
|
837
|
+
|
|
838
|
+
owner_path = account("kaggle_a") / "username"
|
|
839
|
+
if contents is None:
|
|
840
|
+
owner_path.unlink()
|
|
841
|
+
else:
|
|
842
|
+
owner_path.write_bytes(contents)
|
|
843
|
+
with pytest.raises(ConfigError, match=r"letify login kaggle kaggle_a.*--username"):
|
|
844
|
+
session_channel(fake_kaggle)
|
|
845
|
+
assert fake_kaggle.cloud_calls == []
|
|
846
|
+
assert fake_kaggle.kernels == set()
|
|
847
|
+
|
|
848
|
+
|
|
789
849
|
def test_a_notebook_is_warned_about_by_name_when_the_cli_delete_fails(
|
|
790
850
|
fake_kaggle, fake_kaggle_cli, capsys
|
|
791
851
|
) -> None:
|
|
@@ -949,3 +1009,25 @@ def test_a_september_access_token_file_authenticates_quota_without_login(
|
|
|
949
1009
|
assert fake_kaggle_cli.environments[0]["KAGGLE_API_TOKEN"] == API_TOKEN
|
|
950
1010
|
assert all(API_TOKEN not in argument and str(token_path) not in argument
|
|
951
1011
|
for argument in fake_kaggle_cli.calls[0])
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
@pytest.mark.parametrize("contents", [API_TOKEN + "\n", " " + API_TOKEN + "\r\n", " \n"])
|
|
1015
|
+
def test_reading_a_stored_api_token_strips_surrounding_whitespace(isolated_home, contents) -> None:
|
|
1016
|
+
"""Spec "Kaggle": stored token files may have trailing newlines."""
|
|
1017
|
+
from letify.config.secrets import write_secret
|
|
1018
|
+
from letify.providers.kaggle import read_api_token
|
|
1019
|
+
|
|
1020
|
+
write_secret("kaggle_a", "access_token", contents)
|
|
1021
|
+
assert read_api_token("kaggle_a") == (contents.strip() or None)
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def test_a_stored_token_with_a_newline_authenticates_cli_deletion(
|
|
1025
|
+
fake_kaggle_cli, kaggle_api_token
|
|
1026
|
+
) -> None:
|
|
1027
|
+
"""Spec "Kaggle": the CLI receives the token without file whitespace."""
|
|
1028
|
+
from letify.providers.kaggle import delete_notebook_via_cli
|
|
1029
|
+
|
|
1030
|
+
kaggle_api_token.write_text(API_TOKEN + "\n", encoding="utf-8")
|
|
1031
|
+
assert delete_notebook_via_cli("kaggle_a", "notebook63516d2758") is True
|
|
1032
|
+
assert fake_kaggle_cli.calls[0][-1] == "irack000/notebook63516d2758"
|
|
1033
|
+
assert fake_kaggle_cli.environments[0]["KAGGLE_API_TOKEN"] == API_TOKEN
|
|
@@ -2640,6 +2640,7 @@ def test_colab_prepares_tailcat_before_the_connection_race(
|
|
|
2640
2640
|
) -> None:
|
|
2641
2641
|
# Spec "Colab", Runtime tools: installation failure cannot silently lose the race.
|
|
2642
2642
|
from letify import install
|
|
2643
|
+
|
|
2643
2644
|
patch_which(tools_module, present=True)
|
|
2644
2645
|
found = FakeCompleted(stdout='LETIFY-TAILCAT "/remote/tailcat"\n')
|
|
2645
2646
|
recorder = patch_run(colab_module, result=found)
|
|
@@ -2739,3 +2740,54 @@ def test_colab_diagnose_leaves_other_failures_unchanged_when_session_remains(
|
|
|
2739
2740
|
diagnosed = provider.diagnose(runtime, failure)
|
|
2740
2741
|
assert diagnosed is failure
|
|
2741
2742
|
|
|
2743
|
+
|
|
2744
|
+
# Spec: Transport, Link floor and Colab.
|
|
2745
|
+
@pytest.mark.parametrize("provider_class", [Shell, Colab, Tunnel, Elice])
|
|
2746
|
+
def test_shell_accounts_share_the_floor_and_keep_account_overrides(provider_class):
|
|
2747
|
+
from letify.transport.pipeline import MIB
|
|
2748
|
+
|
|
2749
|
+
default = provider_of(provider_class, "floor_default").link_floor
|
|
2750
|
+
assert default.min_bps == 10 * MIB
|
|
2751
|
+
assert default.max_rtt_ms == 300
|
|
2752
|
+
custom = provider_of(provider_class, "floor_custom", min_mib_per_s=0.05, max_rtt_ms=500)
|
|
2753
|
+
assert custom.link_floor.min_bps == 0.05 * MIB
|
|
2754
|
+
assert custom.link_floor.max_rtt_ms == 500
|
|
2755
|
+
|
|
2756
|
+
|
|
2757
|
+
def test_colab_exec_reply_timeout_names_the_kernel_and_keeps_cli_evidence(
|
|
2758
|
+
isolated_home, patch_which, patch_run
|
|
2759
|
+
):
|
|
2760
|
+
patch_which(tools_module, present=True)
|
|
2761
|
+
patch_run(
|
|
2762
|
+
colab_module,
|
|
2763
|
+
result=FakeCompleted(returncode=1, stderr="TimeoutError: Timeout waiting for reply"),
|
|
2764
|
+
)
|
|
2765
|
+
provider = provider_of(Colab, "colab_a", channel="exec")
|
|
2766
|
+
runtime = type("R", (), {"name": "letify-a100-330e87"})()
|
|
2767
|
+
channel = provider.open_channel(runtime)
|
|
2768
|
+
with pytest.raises(letify.RuntimeFailure) as caught:
|
|
2769
|
+
channel.eval("1", timeout=120)
|
|
2770
|
+
assert "letify-a100-330e87" in str(caught.value)
|
|
2771
|
+
assert "notebook kernel" in str(caught.value)
|
|
2772
|
+
assert "busy or not answering" in str(caught.value)
|
|
2773
|
+
assert "colab exec" in str(caught.value)
|
|
2774
|
+
assert caught.value.stderr == "TimeoutError: Timeout waiting for reply"
|
|
2775
|
+
assert "exec -s letify-a100-330e87" in caught.value.command
|
|
2776
|
+
|
|
2777
|
+
|
|
2778
|
+
def test_colab_exec_command_timeout_names_its_limit_and_kernel(
|
|
2779
|
+
isolated_home, patch_which, patch_run
|
|
2780
|
+
):
|
|
2781
|
+
import subprocess
|
|
2782
|
+
|
|
2783
|
+
patch_which(tools_module, present=True)
|
|
2784
|
+
expired = subprocess.TimeoutExpired("colab exec", 120, stderr="kernel stalled")
|
|
2785
|
+
patch_run(colab_module, error=expired)
|
|
2786
|
+
provider = provider_of(Colab, "colab_a", channel="exec")
|
|
2787
|
+
runtime = type("R", (), {"name": "letify-a100-330e87"})()
|
|
2788
|
+
with pytest.raises(letify.RuntimeFailure) as caught:
|
|
2789
|
+
provider.open_channel(runtime).eval("1", timeout=120)
|
|
2790
|
+
assert "120 s" in str(caught.value)
|
|
2791
|
+
assert "notebook kernel" in str(caught.value)
|
|
2792
|
+
assert caught.value.stderr == "kernel stalled"
|
|
2793
|
+
assert caught.value.__cause__ is expired
|
|
@@ -520,9 +520,9 @@ def test_a_round_trip_above_the_floor_is_refused_even_with_fast_throughput(isola
|
|
|
520
520
|
pipeline([broken, slow_round_trip], floor=floor).connect()
|
|
521
521
|
|
|
522
522
|
|
|
523
|
-
def
|
|
523
|
+
def test_the_default_floor_is_ten_mib_per_second_and_three_hundred_ms() -> None:
|
|
524
524
|
floor = LinkFloor.default()
|
|
525
|
-
assert floor.min_bps ==
|
|
525
|
+
assert floor.min_bps == 10.0 * MIB
|
|
526
526
|
assert floor.max_rtt_ms == 300.0
|
|
527
527
|
|
|
528
528
|
|
|
@@ -589,3 +589,70 @@ def test_the_fingerprint_reads_the_public_address_from_stun(stun_server: StunSer
|
|
|
589
589
|
|
|
590
590
|
def test_a_fingerprint_with_no_stun_answer_has_no_public_address() -> None:
|
|
591
591
|
assert network_fingerprint(("127.0.0.1", 9), timeout=0.5).public_ip is None
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# Spec: Transport, Link floor.
|
|
595
|
+
@pytest.mark.parametrize("up,down,rtt", [(2.6, 0.1, 177.4), (8.8, 10.7, 175.0)])
|
|
596
|
+
def test_floor_rejection_cannot_select_an_unprobed_fallback(isolated_home, up, down, rtt):
|
|
597
|
+
slow = FakeStrategy("tailcat", 3, result=result(up, down, rtt))
|
|
598
|
+
fallback = FakeStrategy("fallback", 4, probed=False)
|
|
599
|
+
with pytest.raises(letify.ProviderUnavailable) as caught:
|
|
600
|
+
pipeline([slow, fallback], floor=LinkFloor.default()).connect()
|
|
601
|
+
message = str(caught.value)
|
|
602
|
+
assert "tailcat: below the floor" in message
|
|
603
|
+
assert f"up {up:.1f} MiB/s" in message
|
|
604
|
+
assert "10.0 MiB/s" in message
|
|
605
|
+
assert "min_mib_per_s" in message
|
|
606
|
+
assert "max_rtt_ms" in message
|
|
607
|
+
assert slow.links[0].closed
|
|
608
|
+
assert fallback.links[0].closed
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
@pytest.mark.parametrize("race_fails", [False, True])
|
|
612
|
+
def test_a_cached_floor_rejection_cannot_escape_to_an_unprobed_fallback(
|
|
613
|
+
isolated_home, monkeypatch, race_fails
|
|
614
|
+
):
|
|
615
|
+
cache = LinkCache("lab")
|
|
616
|
+
cache.save("tailcat", result(20, 20), Fingerprint("203.0.113.7", "eth0"))
|
|
617
|
+
slow = FakeStrategy("tailcat", 3, result=result(2.6, 0.1, 177.4))
|
|
618
|
+
fallback = FakeStrategy("fallback", 4, probed=False)
|
|
619
|
+
attempt = slow.attempt
|
|
620
|
+
|
|
621
|
+
def reconnect(target, cancel=None):
|
|
622
|
+
if race_fails and slow.attempts:
|
|
623
|
+
raise OSError("connection lost after cached probe")
|
|
624
|
+
return attempt(target, cancel=cancel)
|
|
625
|
+
|
|
626
|
+
monkeypatch.setattr(slow, "attempt", reconnect)
|
|
627
|
+
with pytest.raises(letify.ProviderUnavailable, match="tailcat: below the floor"):
|
|
628
|
+
pipeline([slow, fallback], cache=cache, floor=LinkFloor.default()).connect()
|
|
629
|
+
assert all(link.closed for link in slow.links + fallback.links)
|
|
630
|
+
assert cache.load().probe == result(20, 20)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def test_floor_rejections_name_both_account_overrides():
|
|
634
|
+
violations = LinkFloor.default().violations(result(2.6, 0.1, 500))
|
|
635
|
+
assert any("up 2.6 MiB/s" in text for text in violations)
|
|
636
|
+
assert any("down 0.1 MiB/s" in text for text in violations)
|
|
637
|
+
assert any("round trip 500.0 ms" in text for text in violations)
|
|
638
|
+
assert "min_mib_per_s" in ", ".join(violations)
|
|
639
|
+
assert "max_rtt_ms" in ", ".join(violations)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# Spec: Transport, Link floor.
|
|
643
|
+
def test_a_cached_floor_rejection_cannot_accept_a_lone_failed_probe(isolated_home, monkeypatch):
|
|
644
|
+
cache = LinkCache("lab")
|
|
645
|
+
cache.save("tailcat", result(20, 20), Fingerprint("203.0.113.7", "eth0"))
|
|
646
|
+
slow = FakeStrategy("tailcat", 3, result=result(2.6, 0.1, 177.4))
|
|
647
|
+
fallback = FakeStrategy("fallback", 4, probed=False, error="fallback unavailable")
|
|
648
|
+
attempt = slow.attempt
|
|
649
|
+
|
|
650
|
+
def reconnect(target, cancel=None):
|
|
651
|
+
if slow.attempts:
|
|
652
|
+
slow.probe_error = True
|
|
653
|
+
return attempt(target, cancel=cancel)
|
|
654
|
+
|
|
655
|
+
monkeypatch.setattr(slow, "attempt", reconnect)
|
|
656
|
+
with pytest.raises(letify.ProviderUnavailable, match="tailcat: below the floor"):
|
|
657
|
+
pipeline([slow, fallback], cache=cache, floor=LinkFloor.default()).connect()
|
|
658
|
+
assert all(link.closed for link in slow.links)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|