diff --git a/plain-dev/plain/dev/state.py b/plain-dev/plain/dev/state.py index 01f28dcc86..7dbab66b02 100644 --- a/plain-dev/plain/dev/state.py +++ b/plain-dev/plain/dev/state.py @@ -17,11 +17,21 @@ from __future__ import annotations import hashlib -from pathlib import Path -from plain.runtime import PLAIN_CACHE_PATH +# `find_project_root` and `checkout_state_path` live in `plain.runtime` now — +# every `.plain` consumer (not just plain-dev) needs to agree on the project +# root and on where a checkout's facts (as opposed to artifacts) are kept. +# Re-exported here so existing callers in this package don't all need to +# change their imports. +from plain.runtime import checkout_id, checkout_state_path, find_project_root -from .utils import has_pyproject_toml +__all__ = [ + "checkout_id", + "checkout_state_path", + "find_project_root", + "sanitize", + "short_digest", +] def sanitize(value: str) -> str: @@ -32,43 +42,3 @@ def sanitize(value: str) -> str: def short_digest(value: str) -> str: """A short, stable hash for disambiguating names built from `value`.""" return hashlib.sha256(value.encode()).hexdigest()[:8] - - -def find_project_root(start: Path) -> Path: - """The nearest directory at or above `start` holding a pyproject.toml. - - One definition, used by the CLI (which starts from the app), by `setup()` - (which starts from the working directory), and by the dev supervisors, - because they have to agree: the project root decides the database name, the - cluster identity, and where this checkout's state lives. Two answers means - two checkouts. - """ - for directory in [start, *start.parents]: - if has_pyproject_toml(directory): - return directory - return start - - -def checkout_id(project_root: Path) -> str: - """What "this checkout" means when we record or compare ownership. - - One definition because it's compared for exact equality against the - database metadata `plain.dev.postgres.guard` reads, and two sites - normalizing differently would silently disagree forever rather than fail. - """ - return str(project_root.resolve()) - - -def checkout_state_path(project_root: Path) -> Path: - """Where this checkout's facts about itself are kept. - - Keyed by the checkout's resolved path (see the module docstring for why), - with the readable checkout name in the directory too, so the cache stays - greppable when something needs explaining. - """ - resolved = project_root.resolve() - return ( - PLAIN_CACHE_PATH - / "checkouts" - / f"{sanitize(resolved.name)}-{short_digest(str(resolved))}" - ) diff --git a/plain-dev/pyproject.toml b/plain-dev/pyproject.toml index 522f523b0d..063dcca837 100644 --- a/plain-dev/pyproject.toml +++ b/plain-dev/pyproject.toml @@ -7,7 +7,7 @@ license = "BSD-3-Clause" readme = "README.md" requires-python = ">=3.13" dependencies = [ - "plain>=0.161.0,<1.0.0", + "plain>=0.164.0,<1.0.0", "plain.assets>=0.3.0,<1.0.0", "click>=8.0.0", "rich", diff --git a/plain-dev/tests/conftest.py b/plain-dev/tests/conftest.py index 10fd9caddd..f059ca09b4 100644 --- a/plain-dev/tests/conftest.py +++ b/plain-dev/tests/conftest.py @@ -12,5 +12,5 @@ def isolated_checkout_state(tmp_path, monkeypatch): entry in the developer's own cache — once per run, never collected. """ cache = tmp_path / "plain-cache" - monkeypatch.setattr("plain.dev.state.PLAIN_CACHE_PATH", cache) + monkeypatch.setattr("plain.runtime.PLAIN_CACHE_PATH", cache) return cache diff --git a/plain-portal/plain/portal/local.py b/plain-portal/plain/portal/local.py index 9456acc968..80acbed589 100644 --- a/plain-portal/plain/portal/local.py +++ b/plain-portal/plain/portal/local.py @@ -31,10 +31,34 @@ @functools.lru_cache def _portal_dir() -> str: - """Return .plain/portal/ in the project root, creating it if needed.""" - from plain.runtime import PLAIN_TEMP_PATH + """Return this checkout's portal state dir, creating it if needed. + + The socket and lock are facts about a live process, not artifacts, so + they're kept beside the rest of the checkout's state rather than in + `.plain/` — a working tree (and its `.plain/`) can be symlinked or copied + between checkouts, which two live sockets can't survive. + + Named by a hash of the checkout id alone, not `checkout_state_path`'s + readable directory name — a Unix socket path is capped at roughly 100 + bytes by the kernel, and `checkout_state_path` embeds the full checkout + directory name, which can push the socket path over that limit for a + long-named project. + + Rooted at the system temp dir rather than `PLAIN_CACHE_PATH` for the same + length-limit reason — `PLAIN_CACHE_PATH` is user-configurable (env var or + `XDG_CACHE_HOME`) and can itself be long enough to blow the budget even + with a fixed-length digest appended. + """ + import hashlib + import tempfile + from pathlib import Path + + from plain.runtime import checkout_id, find_project_root - d = os.path.join(PLAIN_TEMP_PATH, "portal") + digest = hashlib.sha256( + checkout_id(find_project_root(Path.cwd())).encode() + ).hexdigest()[:16] + d = os.path.join(tempfile.gettempdir(), "plain-portal", digest) os.makedirs(d, exist_ok=True) return d diff --git a/plain-portal/pyproject.toml b/plain-portal/pyproject.toml index 0603acd342..5069467c51 100644 --- a/plain-portal/pyproject.toml +++ b/plain-portal/pyproject.toml @@ -6,7 +6,7 @@ authors = [{ name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev" }] license = "BSD-3-Clause" readme = "README.md" requires-python = ">=3.13" -dependencies = ["plain<1.0.0", "websockets>=14.0", "spake2>=0.9", "pynacl>=1.5"] +dependencies = ["plain>=0.164.0,<1.0.0", "websockets>=14.0", "spake2>=0.9", "pynacl>=1.5"] [dependency-groups] dev = ["plain.pytest<1.0.0"] diff --git a/plain-portal/tests/public/test_portal.py b/plain-portal/tests/public/test_portal.py index 8ab9ec3801..37674a32db 100644 --- a/plain-portal/tests/public/test_portal.py +++ b/plain-portal/tests/public/test_portal.py @@ -12,7 +12,7 @@ import spake2 from plain.portal.codegen import WORDLIST, generate_code, validate_code from plain.portal.crypto import PortalEncryptor, channel_id -from plain.portal.local import _MAX_FRAME_SIZE, _recv_framed, _send_framed +from plain.portal.local import _MAX_FRAME_SIZE, _portal_dir, _recv_framed, _send_framed from plain.portal.protocol import ( DEFAULT_EXEC_TIMEOUT, FILE_CHUNK_SIZE, @@ -706,3 +706,47 @@ def test_ping_pong_roundtrip(self): assert enc_b.decrypt_message(ping_ct) == {"type": "ping"} pong_ct = enc_b.encrypt_message(make_pong()) assert enc_a.decrypt_message(pong_ct) == {"type": "pong"} + + +# --------------------------------------------------------------------------- +# 7. Portal state directory +# --------------------------------------------------------------------------- + + +class TestPortalDir: + """`_portal_dir()` backs a Unix socket path, which the kernel caps at + roughly 100 bytes — it can't grow with the checkout's directory name or + with a long `PLAIN_CACHE_PATH`.""" + + def _portal_dir_for( + self, base, monkeypatch, checkout_name, *, cache_name="plain-cache" + ): + cache = base / cache_name + monkeypatch.setattr("plain.runtime.PLAIN_CACHE_PATH", cache) + checkout = base / checkout_name + checkout.mkdir(parents=True) + (checkout / "pyproject.toml").touch() + monkeypatch.chdir(checkout) + _portal_dir.cache_clear() + return _portal_dir() + + def test_socket_path_length_does_not_grow_with_checkout_name( + self, tmp_path, monkeypatch + ): + short = self._portal_dir_for(tmp_path / "b1", monkeypatch, "a") + long = self._portal_dir_for(tmp_path / "b2", monkeypatch, "a" * 80) + assert len(long) == len(short) + + def test_socket_path_length_does_not_grow_with_cache_path( + self, tmp_path, monkeypatch + ): + short = self._portal_dir_for(tmp_path / "b1", monkeypatch, "a") + long = self._portal_dir_for( + tmp_path / "b2", monkeypatch, "a", cache_name="c" * 80 + ) + assert len(long) == len(short) + + def test_different_checkouts_get_different_dirs(self, tmp_path, monkeypatch): + d1 = self._portal_dir_for(tmp_path, monkeypatch, "checkout-one") + d2 = self._portal_dir_for(tmp_path, monkeypatch, "checkout-two") + assert d1 != d2 diff --git a/plain/plain/runtime/__init__.py b/plain/plain/runtime/__init__.py index 8fcb7978c7..c3e2ad51c8 100644 --- a/plain/plain/runtime/__init__.py +++ b/plain/plain/runtime/__init__.py @@ -1,3 +1,4 @@ +import hashlib import importlib.metadata import os import sys @@ -16,9 +17,24 @@ __version__ = "dev" +def find_project_root(start: Path) -> Path: + """The nearest directory at or above `start` holding a pyproject.toml. + + One definition, used by `setup()` (which starts from the working + directory), the CLI (which starts from the app), and the dev supervisors, + because they have to agree: the project root decides where a checkout's + `.plain` lives and where its state is keyed. Two answers means two + checkouts. + """ + for directory in [start, *start.parents]: + if (directory / "pyproject.toml").exists(): + return directory + return start + + # Made available without setup or settings APP_PATH = Path.cwd() / "app" -PLAIN_TEMP_PATH = Path.cwd() / ".plain" +PLAIN_TEMP_PATH = find_project_root(Path.cwd()) / ".plain" # Machine-level cache for downloaded binaries (Tailwind, Oxc, mkcert), # shared across projects and checkouts. @@ -29,6 +45,37 @@ else: PLAIN_CACHE_PATH = Path.home() / ".cache" / "plain" + +def checkout_id(project_root: Path) -> str: + """What "this checkout" means when we record or compare ownership. + + One definition because it's compared for exact equality against facts + recorded elsewhere, and two sites normalizing differently would silently + disagree forever rather than fail. + """ + return str(project_root.resolve()) + + +def checkout_state_path(project_root: Path) -> Path: + """Where this checkout's facts about itself are kept. + + A working tree is exactly what gets symlinked, copied, rsynced, and + mounted into containers, so state keyed by location (like `.plain`) is + eventually read by the wrong reader — two checkouts sharing one `.plain` + would quietly share a database, or one would refuse to start because the + other's dev server holds the pidfile. So a checkout's facts (as opposed to + artifacts like logs or compiled assets, which do belong in `.plain`) are + kept here instead, keyed by the checkout's resolved path, with the + readable checkout name in the directory too so the cache stays greppable. + """ + resolved = project_root.resolve() + sanitized_name = "".join( + c if c.isalnum() else "_" for c in resolved.name.lower() + ).strip("_") + digest = hashlib.sha256(str(resolved).encode()).hexdigest()[:8] + return PLAIN_CACHE_PATH / "checkouts" / f"{sanitized_name}-{digest}" + + # from plain.runtime import settings settings = Settings() @@ -91,6 +138,9 @@ def setup() -> None: "Secret", "SetupError", "__version__", + "checkout_id", + "checkout_state_path", + "find_project_root", "settings", "setup", ]