Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 13 additions & 43 deletions plain-dev/plain/dev/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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))}"
)
2 changes: 1 addition & 1 deletion plain-dev/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion plain-dev/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
30 changes: 27 additions & 3 deletions plain-portal/plain/portal/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion plain-portal/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
46 changes: 45 additions & 1 deletion plain-portal/tests/public/test_portal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
52 changes: 51 additions & 1 deletion plain/plain/runtime/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import hashlib
import importlib.metadata
import os
import sys
Expand All @@ -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.
Expand All @@ -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()

Expand Down Expand Up @@ -91,6 +138,9 @@ def setup() -> None:
"Secret",
"SetupError",
"__version__",
"checkout_id",
"checkout_state_path",
"find_project_root",
"settings",
"setup",
]
Loading