letify 1.2.2__tar.gz → 1.2.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-1.2.2 → letify-1.2.4}/PKG-INFO +1 -1
- {letify-1.2.2 → letify-1.2.4}/letify/__init__.py +1 -1
- {letify-1.2.2 → letify-1.2.4}/letify/store/volume.py +6 -2
- {letify-1.2.2 → letify-1.2.4}/letify/transport/nat.py +42 -10
- {letify-1.2.2 → letify-1.2.4}/letify/transport/pipeline.py +90 -35
- {letify-1.2.2 → letify-1.2.4}/letify-core/Cargo.lock +3 -3
- {letify-1.2.2 → letify-1.2.4}/letify-core/Cargo.toml +1 -1
- {letify-1.2.2 → letify-1.2.4}/letify-ext/package-lock.json +2 -2
- {letify-1.2.2 → letify-1.2.4}/letify-ext/package.json +1 -1
- {letify-1.2.2 → letify-1.2.4}/pyproject.toml +1 -1
- {letify-1.2.2 → letify-1.2.4}/tests/conftest.py +7 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_link_log.py +31 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_paths.py +35 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_strategies.py +37 -0
- {letify-1.2.2 → letify-1.2.4}/.github/workflows/ci.yml +0 -0
- {letify-1.2.2 → letify-1.2.4}/.github/workflows/docs.yml +0 -0
- {letify-1.2.2 → letify-1.2.4}/.github/workflows/publish.yml +0 -0
- {letify-1.2.2 → letify-1.2.4}/.gitignore +0 -0
- {letify-1.2.2 → letify-1.2.4}/.researchtree +0 -0
- {letify-1.2.2 → letify-1.2.4}/LICENSE +0 -0
- {letify-1.2.2 → letify-1.2.4}/README.md +0 -0
- {letify-1.2.2 → letify-1.2.4}/docs/guide/01-getting-started.md +0 -0
- {letify-1.2.2 → letify-1.2.4}/docs/guide/02-providers.md +0 -0
- {letify-1.2.2 → letify-1.2.4}/docs/guide/03-execution-modes.md +0 -0
- {letify-1.2.2 → letify-1.2.4}/docs/guide/04-environments-and-data.md +0 -0
- {letify-1.2.2 → letify-1.2.4}/docs/guide/05-concurrency.md +0 -0
- {letify-1.2.2 → letify-1.2.4}/docs/guide/06-cost.md +0 -0
- {letify-1.2.2 → letify-1.2.4}/docs/guide/07-troubleshooting.md +0 -0
- {letify-1.2.2 → letify-1.2.4}/docs/guide/08-releasing.md +0 -0
- {letify-1.2.2 → letify-1.2.4}/docs/guide/README.md +0 -0
- {letify-1.2.2 → letify-1.2.4}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
- {letify-1.2.2 → letify-1.2.4}/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.2 → letify-1.2.4}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
- {letify-1.2.2 → letify-1.2.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-1.2.2 → letify-1.2.4}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
- {letify-1.2.2 → letify-1.2.4}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-1.2.2 → letify-1.2.4}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
- {letify-1.2.2 → letify-1.2.4}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-1.2.2 → letify-1.2.4}/docs/guide/ko/README.md +0 -0
- {letify-1.2.2 → letify-1.2.4}/docs/locales/README_ko.md +0 -0
- {letify-1.2.2 → letify-1.2.4}/docs/mkdocs.yml +0 -0
- {letify-1.2.2 → letify-1.2.4}/examples/README.md +0 -0
- {letify-1.2.2 → letify-1.2.4}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-1.2.2 → letify-1.2.4}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/examples/nvfp4-lora/README.md +0 -0
- {letify-1.2.2 → letify-1.2.4}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-1.2.2 → letify-1.2.4}/examples/nvfp4-lora/common.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/cli.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/config/__init__.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/config/inventory.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/config/login.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/config/schema.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/config/secrets.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/config/writer.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/declare/__init__.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/declare/cache.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/declare/env.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/declare/function.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/declare/instance.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/errors.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/install.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/launcher.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/paths.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/protocol/__init__.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/protocol/codec.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/protocol/driver.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/protocol/handle.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/protocol/wire.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/protocol/worker.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/providers/__init__.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/providers/base.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/providers/colab.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/providers/colab_files.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/providers/colab_keepalive.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/providers/elice.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/providers/kaggle.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/providers/kaggle_adapter.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/providers/local.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/providers/modal.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/providers/modal_adapter.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/providers/naming.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/providers/shell.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/providers/tunnel.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/providers/usage.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/remoting/__init__.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/remoting/capability.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/remoting/device/__init__.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/remoting/device/client.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/remoting/device/cuda.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/remoting/device/executor.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/remoting/device/frames.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/remoting/device/guard.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/remoting/device/tensor.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/remoting/device/trace.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/remoting/device/value.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/remoting/loader.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/remoting/probe.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/render.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/runtime/__init__.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/runtime/bootstrap.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/runtime/channel.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/runtime/lease.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/runtime/pool.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/runtime/session.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/runtime/telemetry.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/store/__init__.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/store/backends/__init__.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/store/backends/filesystem.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/store/backends/google_auth.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/store/backends/layout.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/store/backends/objects.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/store/cas.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/store/pathdata.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/store/sendorder.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/stubs.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/tools.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/transport/__init__.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/transport/agent.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/transport/announce.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/transport/link.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/transport/probe.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/transport/rendezvous.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/transport/setup.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/transport/sshopts.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify/transport/strategies.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-core/.gitignore +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-core/build.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-core/rust-toolchain.toml +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-ext/.vscodeignore +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-ext/LICENSE +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-ext/README.md +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-ext/media/icon.svg +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-ext/src/cli.ts +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-ext/src/extension.ts +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-ext/src/history.ts +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-ext/src/model.ts +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-ext/src/poll.ts +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-ext/src/render.ts +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-ext/test/cli.test.ts +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-ext/test/manifest.test.ts +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-ext/test/model.test.ts +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-ext/test/regressions.test.ts +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify-ext/tsconfig.json +0 -0
- {letify-1.2.2 → letify-1.2.4}/letify_providers.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/scripts/sync-main.sh +0 -0
- {letify-1.2.2 → letify-1.2.4}/scripts/version.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/fake_kaggle_adapter.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/fake_modal_adapter.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_cli.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_cli_output.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_colab_keepalive.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_config.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_core.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_data.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_data_streaming.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_declare.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_device.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_editor_sessions.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_elice.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_frames.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_install.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_kaggle.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_kaggle_cookie.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_notebook.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_protocol.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_providers.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_remoting.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_render.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_runtime.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_ship.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_store.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_stubs.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_transport.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_tunnel_setup.py +0 -0
- {letify-1.2.2 → letify-1.2.4}/tests/test_version.py +0 -0
|
@@ -89,14 +89,18 @@ def environment_volume(provider: Provider) -> Volume:
|
|
|
89
89
|
"""The automatic environment store when an ephemeral declaration names no volumes.
|
|
90
90
|
|
|
91
91
|
Spec "Materializing into a runtime": reuse the account's data bucket, or keep the
|
|
92
|
-
archive on the client without requiring a bucket or a volume declaration.
|
|
92
|
+
archive on the client without requiring a bucket or a volume declaration. The client
|
|
93
|
+
store is the declared ``~/.letify/cache/storage/<name>`` root with the name
|
|
94
|
+
``environments``, so it stays under the single local root while still reading archives
|
|
95
|
+
an earlier install left under ``~/.cache/letify/environments``.
|
|
93
96
|
"""
|
|
97
|
+
from ..paths import storage_cache_directory
|
|
94
98
|
from .backends.filesystem import FilesystemBackend
|
|
95
99
|
from .pathdata import data_bucket
|
|
96
100
|
|
|
97
101
|
backend = data_bucket(provider)
|
|
98
102
|
if backend is None:
|
|
99
|
-
root =
|
|
103
|
+
root = storage_cache_directory("environments") / provider.kind / provider.alias
|
|
100
104
|
backend = FilesystemBackend(root)
|
|
101
105
|
return Volume(provider, "environments", _store=Store(backend))
|
|
102
106
|
|
|
@@ -382,11 +382,10 @@ def begin(request: dict):
|
|
|
382
382
|
stderr=subprocess.STDOUT,
|
|
383
383
|
text=True,
|
|
384
384
|
)
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
raise RuntimeError("tailcat serve exited without printing its address")
|
|
385
|
+
address = _match_then_drain(process, process.stdout, r"address:\s*(tc\S+)")
|
|
386
|
+
if address is None:
|
|
387
|
+
raise RuntimeError("tailcat serve exited without printing its address")
|
|
388
|
+
return {"address": address}, process.wait
|
|
390
389
|
if kind == "reverse_ssh":
|
|
391
390
|
# The directory is under the account's workspace root, which may start with ~.
|
|
392
391
|
key = Path(os.path.expanduser(request["key_directory"])) / "letify_reverse"
|
|
@@ -405,14 +404,47 @@ def begin(request: dict):
|
|
|
405
404
|
stderr=subprocess.PIPE,
|
|
406
405
|
text=True,
|
|
407
406
|
) # fmt: skip
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
raise RuntimeError("ssh -R exited without allocating a port")
|
|
407
|
+
allocated = _match_then_drain(process, process.stderr, r"Allocated port (\d+)")
|
|
408
|
+
if allocated is None:
|
|
409
|
+
raise RuntimeError("ssh -R exited without allocating a port")
|
|
410
|
+
return {"port": int(allocated)}, process.wait
|
|
413
411
|
raise ValueError(f"unknown rendezvous request {kind!r}")
|
|
414
412
|
|
|
415
413
|
|
|
414
|
+
def _match_then_drain(process, stream, pattern: str) -> str | None:
|
|
415
|
+
"""First capture of ``pattern`` in ``stream``, then keep draining it in the background.
|
|
416
|
+
|
|
417
|
+
A helper letify starts and reads one line from keeps writing afterwards. Its pipe holds
|
|
418
|
+
about 64 KiB, so once nobody reads it the helper blocks on its next write and stops
|
|
419
|
+
moving bytes. ``tailcat serve`` carries the SSH connection itself, so that stall shows
|
|
420
|
+
up as a link that connects and then measures far below the link underneath it. Reading
|
|
421
|
+
on and discarding keeps the writer running, and keeps ``process.wait`` from deadlocking
|
|
422
|
+
against a full pipe.
|
|
423
|
+
|
|
424
|
+
Returns None when the stream ends before the pattern appears.
|
|
425
|
+
"""
|
|
426
|
+
expression = re.compile(pattern)
|
|
427
|
+
found = None
|
|
428
|
+
for line in stream:
|
|
429
|
+
match = expression.search(line)
|
|
430
|
+
if match:
|
|
431
|
+
found = match.group(1)
|
|
432
|
+
break
|
|
433
|
+
if found is None:
|
|
434
|
+
return None
|
|
435
|
+
|
|
436
|
+
def drain() -> None:
|
|
437
|
+
try:
|
|
438
|
+
for _ in stream:
|
|
439
|
+
pass
|
|
440
|
+
except (OSError, ValueError):
|
|
441
|
+
# The process ended and the stream was closed under us, which is the normal end.
|
|
442
|
+
pass
|
|
443
|
+
|
|
444
|
+
threading.Thread(target=drain, name="letify-drain", daemon=True).start()
|
|
445
|
+
return found
|
|
446
|
+
|
|
447
|
+
|
|
416
448
|
def _authorize(public_key: str) -> None:
|
|
417
449
|
path = Path.home() / ".ssh" / "authorized_keys"
|
|
418
450
|
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
@@ -40,6 +40,15 @@ DEFAULT_MIN_MIB_PER_S = 10.0
|
|
|
40
40
|
DEFAULT_MAX_RTT_MS = 300.0
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
class _BelowFloor(ProviderUnavailable):
|
|
44
|
+
"""Every connected strategy measured below the floor.
|
|
45
|
+
|
|
46
|
+
Spec "Choosing a link": this does not end the race, because a strategy still
|
|
47
|
+
connecting may clear the floor. The race raises it only once nothing is left to wait
|
|
48
|
+
for, and it reaches the caller as the ``ProviderUnavailable`` it is.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
43
52
|
@dataclass(frozen=True)
|
|
44
53
|
class LinkFloor:
|
|
45
54
|
"""The slowest link an account accepts. A probe below this fails the connection."""
|
|
@@ -402,43 +411,86 @@ class Pipeline:
|
|
|
402
411
|
|
|
403
412
|
probed = {i for i, s in enumerate(applicable) if getattr(s, "probed", True)}
|
|
404
413
|
deadline = time.monotonic() + self.timeout
|
|
414
|
+
held = {s.name for s in applicable if not getattr(s, "probed", True)}
|
|
415
|
+
#: Strategies already offered to _choose, so a later round considers only new ones.
|
|
416
|
+
seen: set[int] = set()
|
|
417
|
+
rejected: _BelowFloor | None = None
|
|
418
|
+
|
|
419
|
+
# Spec "Choosing a link": the race is not over until a link is accepted. A round
|
|
420
|
+
# that ends with every connected strategy below the floor drops the grace period
|
|
421
|
+
# and waits out the rest of the timeout for the strategies still connecting.
|
|
422
|
+
while True:
|
|
423
|
+
with condition:
|
|
424
|
+
while len(outcomes) < len(applicable):
|
|
425
|
+
fresh = [
|
|
426
|
+
index
|
|
427
|
+
for index, outcome in outcomes.items()
|
|
428
|
+
if index not in seen and not isinstance(outcome, Exception)
|
|
429
|
+
]
|
|
430
|
+
if state["first"] is None and probed <= outcomes.keys() and fresh:
|
|
431
|
+
# Every probed strategy failed, so an unprobed one is all that is left.
|
|
432
|
+
break
|
|
433
|
+
now = time.monotonic()
|
|
434
|
+
limit = deadline if state["first"] is None else state["first"] + self.grace
|
|
435
|
+
if now >= limit:
|
|
436
|
+
break
|
|
437
|
+
condition.wait(limit - now)
|
|
438
|
+
settled = dict(outcomes)
|
|
439
|
+
|
|
440
|
+
connected = []
|
|
441
|
+
for index, strategy in enumerate(applicable):
|
|
442
|
+
if index in seen or index not in settled:
|
|
443
|
+
continue
|
|
444
|
+
seen.add(index)
|
|
445
|
+
outcome = settled[index]
|
|
446
|
+
if isinstance(outcome, Exception):
|
|
447
|
+
reasons.append(f"{strategy.name}: {outcome}")
|
|
448
|
+
else:
|
|
449
|
+
connected.append(outcome)
|
|
450
|
+
|
|
451
|
+
if connected:
|
|
452
|
+
connected.sort(key=lambda link: link.rank)
|
|
453
|
+
try:
|
|
454
|
+
chosen, measured = self._choose(connected, reasons, held)
|
|
455
|
+
except _BelowFloor as below:
|
|
456
|
+
rejected = below
|
|
457
|
+
else:
|
|
458
|
+
with condition:
|
|
459
|
+
state["decided"] = True
|
|
460
|
+
for index, event in enumerate(cancels):
|
|
461
|
+
if index not in seen:
|
|
462
|
+
event.set()
|
|
463
|
+
self._report_unsettled(applicable, seen, reasons)
|
|
464
|
+
first = state["first_name"]
|
|
465
|
+
if first is not None and first != chosen.strategy:
|
|
466
|
+
self._say(f"switching from {first} to {chosen.strategy}")
|
|
467
|
+
return chosen, measured
|
|
468
|
+
|
|
469
|
+
if len(seen) == len(applicable) or time.monotonic() >= deadline:
|
|
470
|
+
break
|
|
471
|
+
# Nothing acceptable yet, so the strategies still connecting get the rest of
|
|
472
|
+
# the timeout rather than the grace period that a rejected link started.
|
|
473
|
+
with condition:
|
|
474
|
+
state["first"] = None
|
|
475
|
+
state["first_name"] = None
|
|
476
|
+
|
|
405
477
|
with condition:
|
|
406
|
-
while len(outcomes) < len(applicable):
|
|
407
|
-
connected = [i for i, o in outcomes.items() if not isinstance(o, Exception)]
|
|
408
|
-
if state["first"] is None and probed <= outcomes.keys() and connected:
|
|
409
|
-
# Every probed strategy failed, so an unprobed one is all that is left.
|
|
410
|
-
break
|
|
411
|
-
now = time.monotonic()
|
|
412
|
-
limit = deadline if state["first"] is None else state["first"] + self.grace
|
|
413
|
-
if now >= limit:
|
|
414
|
-
break
|
|
415
|
-
condition.wait(limit - now)
|
|
416
478
|
state["decided"] = True
|
|
417
|
-
settled = dict(outcomes)
|
|
418
479
|
for index, event in enumerate(cancels):
|
|
419
|
-
if index not in
|
|
480
|
+
if index not in seen:
|
|
420
481
|
event.set()
|
|
421
|
-
|
|
422
|
-
|
|
482
|
+
self._report_unsettled(applicable, seen, reasons)
|
|
483
|
+
if rejected is not None:
|
|
484
|
+
raise rejected
|
|
485
|
+
self._say("no strategy connected")
|
|
486
|
+
raise ProviderUnavailable("shell", self._explain(reasons))
|
|
487
|
+
|
|
488
|
+
def _report_unsettled(self, applicable: list[Any], seen: set[int], reasons: list[str]) -> None:
|
|
489
|
+
"""Name every strategy that had not connected when the choice was made."""
|
|
423
490
|
for index, strategy in enumerate(applicable):
|
|
424
|
-
|
|
425
|
-
if outcome is None:
|
|
491
|
+
if index not in seen:
|
|
426
492
|
reasons.append(f"{strategy.name}: did not connect in time")
|
|
427
493
|
self._say(f"{strategy.name} timed out: not connected when the choice was made")
|
|
428
|
-
elif isinstance(outcome, Exception):
|
|
429
|
-
reasons.append(f"{strategy.name}: {outcome}")
|
|
430
|
-
else:
|
|
431
|
-
connected.append(outcome)
|
|
432
|
-
if not connected:
|
|
433
|
-
self._say("no strategy connected")
|
|
434
|
-
raise ProviderUnavailable("shell", self._explain(reasons))
|
|
435
|
-
connected.sort(key=lambda link: link.rank)
|
|
436
|
-
held = {s.name for s in applicable if not getattr(s, "probed", True)}
|
|
437
|
-
chosen, measured = self._choose(connected, reasons, held)
|
|
438
|
-
first = state["first_name"]
|
|
439
|
-
if first is not None and first != chosen.strategy:
|
|
440
|
-
self._say(f"switching from {first} to {chosen.strategy}")
|
|
441
|
-
return chosen, measured
|
|
442
494
|
|
|
443
495
|
def _measure(self, link: Any) -> ProbeResult | None:
|
|
444
496
|
"""Probe a link and print the numbers. None when the link cannot carry the probe."""
|
|
@@ -470,7 +522,7 @@ class Pipeline:
|
|
|
470
522
|
measured = None
|
|
471
523
|
if measured is None and floored_out:
|
|
472
524
|
_close(link)
|
|
473
|
-
raise
|
|
525
|
+
raise _BelowFloor("shell", self._explain(reasons))
|
|
474
526
|
if link.strategy in held:
|
|
475
527
|
self._fall_back(link)
|
|
476
528
|
else:
|
|
@@ -480,7 +532,8 @@ class Pipeline:
|
|
|
480
532
|
below = f"{link.strategy}: below the floor: {', '.join(floored)}"
|
|
481
533
|
self._say(f"rejected {below}")
|
|
482
534
|
_close(link)
|
|
483
|
-
|
|
535
|
+
reasons.append(below)
|
|
536
|
+
raise _BelowFloor("shell", self._explain(reasons))
|
|
484
537
|
self._say(f"chose {link.strategy}: only one connected")
|
|
485
538
|
return link, measured
|
|
486
539
|
|
|
@@ -502,7 +555,9 @@ class Pipeline:
|
|
|
502
555
|
floored = self.floor.violations(measured)
|
|
503
556
|
if floored:
|
|
504
557
|
self._say(f"rejected {link.strategy}: below the floor: {', '.join(floored)}")
|
|
505
|
-
|
|
558
|
+
below = f"{link.strategy}: below the floor: {', '.join(floored)}"
|
|
559
|
+
floored_out.append(below)
|
|
560
|
+
reasons.append(below)
|
|
506
561
|
_close(link)
|
|
507
562
|
else:
|
|
508
563
|
above_floor.append((link, measured))
|
|
@@ -534,8 +589,8 @@ class Pipeline:
|
|
|
534
589
|
for link in unprobed:
|
|
535
590
|
_close(link)
|
|
536
591
|
self._say("every probe was below the floor")
|
|
537
|
-
raise
|
|
538
|
-
"shell", self._explain([*reasons, "every probe was below the floor"
|
|
592
|
+
raise _BelowFloor(
|
|
593
|
+
"shell", self._explain([*reasons, "every probe was below the floor"])
|
|
539
594
|
)
|
|
540
595
|
elif unprobed:
|
|
541
596
|
chosen, measured = unprobed[0], None
|
|
@@ -10,7 +10,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
|
|
10
10
|
|
|
11
11
|
[[package]]
|
|
12
12
|
name = "letify-agent"
|
|
13
|
-
version = "1.2.
|
|
13
|
+
version = "1.2.4"
|
|
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.4"
|
|
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.4"
|
|
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.4",
|
|
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.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": "1.2.
|
|
5
|
+
"version": "1.2.4",
|
|
6
6
|
"publisher": "darkpyonix",
|
|
7
7
|
"license": "SEE LICENSE IN LICENSE",
|
|
8
8
|
"repository": {
|
|
@@ -158,6 +158,13 @@ def uv_project(tmp_path: Path, monkeypatch) -> Path:
|
|
|
158
158
|
subprocess.run([uv, "lock"], cwd=project, capture_output=True, check=True)
|
|
159
159
|
monkeypatch.chdir(project)
|
|
160
160
|
monkeypatch.setattr(bootstrap, "DEFAULT_WORKSPACE_ROOT", str(tmp_path / "runtime-workspace"))
|
|
161
|
+
# The automatic environment archive store lives under the user's own ~/.letify, so
|
|
162
|
+
# without this an archive a previous run published is found and a test that expects a
|
|
163
|
+
# sync sees "archive" instead. Spec "Materializing into a runtime".
|
|
164
|
+
from letify import paths
|
|
165
|
+
|
|
166
|
+
store = tmp_path / "storage"
|
|
167
|
+
monkeypatch.setattr(paths, "storage_cache_directory", lambda name: store / name)
|
|
161
168
|
return project
|
|
162
169
|
|
|
163
170
|
|
|
@@ -6,9 +6,11 @@ Strategies and probes are the fakes from conftest, so delays and numbers are con
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import pytest
|
|
9
10
|
from conftest import FakeProbe, FakeStrategy
|
|
10
11
|
|
|
11
12
|
import letify
|
|
13
|
+
from letify.errors import ProviderUnavailable
|
|
12
14
|
from letify.transport.pipeline import Fingerprint, LinkCache, LinkFloor, Pipeline
|
|
13
15
|
from letify.transport.probe import ProbeResult
|
|
14
16
|
|
|
@@ -192,3 +194,32 @@ def test_a_quiet_launcher_prints_no_connection_lines(config_file, capsys) -> Non
|
|
|
192
194
|
letify.Launcher(config_file(body), home=False, announce=False).provider("lab").connect()
|
|
193
195
|
captured = capsys.readouterr()
|
|
194
196
|
assert (captured.out, captured.err) == ("", "")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# -- Spec: Choosing a link, the race is not over until a link is accepted ----------
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_a_strategy_rejected_by_the_floor_does_not_end_the_race() -> None:
|
|
203
|
+
# The reported Colab run: tcp_punch connected at 10.7 s and was rejected by the floor,
|
|
204
|
+
# and tailcat connected 2 s later and was closed unprobed, so the connection failed
|
|
205
|
+
# although tailcat was acceptable. Spec "Choosing a link".
|
|
206
|
+
floor = LinkFloor(min_bps=10 * MIB, max_rtt_ms=300.0)
|
|
207
|
+
punch = FakeStrategy("tcp_punch", 2, delay=0.05, result=result(8.1, 0.2, rtt=138.0))
|
|
208
|
+
tailcat = FakeStrategy("tailcat", 3, delay=0.45, result=result(24.0, 20.0))
|
|
209
|
+
|
|
210
|
+
chosen = pipeline([punch, tailcat], grace=0.2, floor=floor).connect()
|
|
211
|
+
|
|
212
|
+
assert chosen.strategy == "tailcat"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_the_connection_still_fails_when_the_later_strategy_is_also_below_the_floor() -> None:
|
|
216
|
+
# Spec "Choosing a link": waiting longer is not accepting less. Every strategy settles
|
|
217
|
+
# below the floor, so the connection fails and names both rejections.
|
|
218
|
+
floor = LinkFloor(min_bps=10 * MIB, max_rtt_ms=300.0)
|
|
219
|
+
punch = FakeStrategy("tcp_punch", 2, delay=0.05, result=result(8.1, 0.2, rtt=138.0))
|
|
220
|
+
tailcat = FakeStrategy("tailcat", 3, delay=0.45, result=result(4.0, 0.1))
|
|
221
|
+
|
|
222
|
+
with pytest.raises(ProviderUnavailable) as raised:
|
|
223
|
+
pipeline([punch, tailcat], grace=0.2, floor=floor).connect()
|
|
224
|
+
|
|
225
|
+
assert "tcp_punch" in str(raised.value) and "tailcat" in str(raised.value)
|
|
@@ -302,3 +302,38 @@ def test_compatibility_fallbacks_for_migrated_paths(
|
|
|
302
302
|
Colab(ProviderConfig("colab_compat", "colab", {}, 0))
|
|
303
303
|
# It should find the token via fallback
|
|
304
304
|
assert (acct / ".config" / "colab-cli" / "token.json").is_file()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# -- Spec: Local machine, the filesystem storage backend root ----------------------
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_the_automatic_environment_store_sits_under_the_declared_cache(monkeypatch, tmp_path):
|
|
311
|
+
# Spec "Materializing into a runtime": the automatic archive store is the
|
|
312
|
+
# ~/.letify/cache/storage/<name> row with the name "environments", not a directory of
|
|
313
|
+
# its own under ~/.cache.
|
|
314
|
+
from letify.config.schema import ProviderConfig
|
|
315
|
+
from letify.providers.local import Local
|
|
316
|
+
from letify.store.volume import environment_volume
|
|
317
|
+
|
|
318
|
+
monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path))
|
|
319
|
+
provider = Local(ProviderConfig("lab", "local", {}, 0))
|
|
320
|
+
volume = environment_volume(provider)
|
|
321
|
+
root = volume._store.backend.root
|
|
322
|
+
|
|
323
|
+
assert root == tmp_path / ".letify" / "cache" / "storage" / "environments" / "local" / "lab"
|
|
324
|
+
assert ".cache" not in root.parts
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def test_an_existing_environment_store_outside_the_root_is_still_read(monkeypatch, tmp_path):
|
|
328
|
+
# Spec "Materializing into a runtime": archives an earlier install left under
|
|
329
|
+
# ~/.cache/letify/environments are read rather than abandoned.
|
|
330
|
+
from letify.config.schema import ProviderConfig
|
|
331
|
+
from letify.providers.local import Local
|
|
332
|
+
from letify.store.volume import environment_volume
|
|
333
|
+
|
|
334
|
+
legacy = tmp_path / ".cache" / "letify" / "environments"
|
|
335
|
+
legacy.mkdir(parents=True)
|
|
336
|
+
monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path))
|
|
337
|
+
provider = Local(ProviderConfig("lab", "local", {}, 0))
|
|
338
|
+
|
|
339
|
+
assert environment_volume(provider)._store.backend.root == legacy / "local" / "lab"
|
|
@@ -592,3 +592,40 @@ def test_the_remote_half_starts_no_sshd_when_an_ssh_server_already_answers(
|
|
|
592
592
|
finally:
|
|
593
593
|
server.close()
|
|
594
594
|
assert not any(str(binary) in command for command in recorder.commands)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _flooding_binary(directory, address: str, bytes_after: int) -> str:
|
|
598
|
+
"""An executable that prints ``address`` the way tailcat does, then floods its output."""
|
|
599
|
+
import os
|
|
600
|
+
import sys
|
|
601
|
+
from pathlib import Path
|
|
602
|
+
|
|
603
|
+
path = Path(directory) / "flooding_tailcat"
|
|
604
|
+
path.write_text(
|
|
605
|
+
f"#!{sys.executable}\n"
|
|
606
|
+
"import sys\n"
|
|
607
|
+
f"print('Server listening with new address: {address}', flush=True)\n"
|
|
608
|
+
f"sys.stdout.write('x' * {bytes_after})\n"
|
|
609
|
+
"sys.stdout.flush()\n"
|
|
610
|
+
)
|
|
611
|
+
path.chmod(0o755)
|
|
612
|
+
return os.fspath(path)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def test_the_remote_half_keeps_reading_what_tailcat_serve_prints_after_the_address(
|
|
616
|
+
tmp_path,
|
|
617
|
+
) -> None:
|
|
618
|
+
# A pipe holds about 64 KiB, so a megabyte is far past the point where an unread
|
|
619
|
+
# pipe blocks the writer and the tunnel stops moving bytes.
|
|
620
|
+
binary = _flooding_binary(tmp_path, "tcReal1", 1 << 20)
|
|
621
|
+
answer, serve = nat.begin({"kind": "tailcat", "ssh_port": 22, "binary": binary})
|
|
622
|
+
assert answer == {"address": "tcReal1"}
|
|
623
|
+
|
|
624
|
+
import threading
|
|
625
|
+
|
|
626
|
+
waiter = threading.Thread(target=serve, daemon=True)
|
|
627
|
+
waiter.start()
|
|
628
|
+
waiter.join(30.0)
|
|
629
|
+
assert not waiter.is_alive(), (
|
|
630
|
+
"tailcat serve never finished, so it blocked writing to a pipe nobody reads"
|
|
631
|
+
)
|
|
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
|
|
File without changes
|
|
File without changes
|
{letify-1.2.2 → letify-1.2.4}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md"
RENAMED
|
File without changes
|
|
File without changes
|
{letify-1.2.2 → letify-1.2.4}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md"
RENAMED
|
File without changes
|
|
File without changes
|
{letify-1.2.2 → letify-1.2.4}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md"
RENAMED
|
File without changes
|
|
File without changes
|
{letify-1.2.2 → letify-1.2.4}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md"
RENAMED
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|