CtrlK
BlogDocsLog inGet started
Tessl Logo

jbaruch/nanoclaw-conferences

Finds open conference CFPs relevant to the user across Java/AI/developer conferences, with persistent sent/dismissed/remind state and source-aware Sessionize verification. NanoClaw per-chat overlay, loaded via containerConfig.additionalTiles.

74

Quality

93%

Does it follow best practices?

Impact

No eval scenarios have been run

SecuritybySnyk

Advisory

Suggest reviewing before use

Overview
Quality
Evals
Security
Files

state_lock.pyskills/check-cfps/scripts/

"""Advisory file lock for cfp-state.json read-modify-write sections.

Every cfp-state.json mutator reads the whole file, mutates in memory, and
writes back via temp file + os.replace. The atomic replace prevents
truncation/partial files, but not lost updates: two writers that read the
same old state each write a complete file, and whichever lands second
silently discards the other's changes (jbaruch/nanoclaw-conferences#35).
Main groups run default and maintenance containers concurrently against
the same /workspace/group/ volume, so this is a real interleaving, not a
theoretical one.

This module is the shared write discipline: every cfp-state.json writer
in this plugin runs its read-modify-write inside `locked(state_path)` —
the maintenance scripts (backfill-source, backfill-name, dedup-by-url,
expire-cfps, stamp-schema-version, stamp-last-checked) and the Step 8
committer (commit-state.py), which replaces direct agent-side writes.
The lock is an advisory `fcntl.flock` on a sibling `<name>.lock` file
(never on the state file itself — os.replace swaps the state file's
inode, which would drop the lock mid-write). Readers that only snapshot
the file (check-cfps-fetch) do not need the lock: os.replace guarantees
they see a complete old or complete new file. The one writer outside
this plugin, morning-brief's `--mark-shown` (nanoclaw-admin), must adopt
the same lock file to be fully covered — tracked as a follow-up issue
in this repo.

The lock file is created on first use and intentionally never unlinked —
removing a lock file while another process holds/awaits its fd reopens
the race the lock exists to close.

Acquisition blocks up to `timeout` seconds (default DEFAULT_TIMEOUT,
overridable per-call or via the CFP_STATE_LOCK_TIMEOUT env var), then
raises LockTimeout. A lock file that cannot be created (missing state
directory, non-writable filesystem) raises LockError with an actionable
message. LockTimeout subclasses LockError, so callers catch LockError
once and translate it into their script's exit-1 stderr diagnostic
contract — no lock failure ever escapes as a traceback.

Not a standalone script: importable module only, no entry point. Writer
scripts load it from the sibling file via importlib
(`spec_from_file_location`), the same pattern backfill-name.py uses for
its dedup-by-url helpers.
"""

import fcntl
import os
import time
from contextlib import contextmanager
from pathlib import Path

DEFAULT_TIMEOUT = 30.0
_POLL_INTERVAL = 0.05
LOCK_SUFFIX = ".lock"


class LockError(Exception):
    """Raised when the state lock cannot be obtained — creation failure
    or timeout. Catch this to honor the exit-1 diagnostic contract."""


class LockTimeout(LockError):
    """Raised when the state lock cannot be acquired within the timeout."""


def lock_path_for(state_path: Path) -> Path:
    return state_path.with_name(state_path.name + LOCK_SUFFIX)


def _effective_timeout(timeout):
    if timeout is not None:
        try:
            value = float(timeout)
        except (TypeError, ValueError) as exc:
            raise LockError(
                f"invalid lock timeout {timeout!r}: expected non-negative "
                f"numeric seconds — fix the caller's timeout argument"
            ) from exc
        if value < 0:
            raise LockError(
                f"invalid lock timeout {timeout!r}: expected non-negative "
                f"numeric seconds — fix the caller's timeout argument"
            )
        return value
    env = os.environ.get("CFP_STATE_LOCK_TIMEOUT")
    if not env:
        return DEFAULT_TIMEOUT
    try:
        value = float(env)
    except ValueError as exc:
        raise LockError(
            f"invalid CFP_STATE_LOCK_TIMEOUT value {env!r}: expected "
            f"non-negative numeric seconds (e.g. 30) — fix or unset the "
            f"environment variable"
        ) from exc
    if value < 0:
        raise LockError(
            f"invalid CFP_STATE_LOCK_TIMEOUT value {env!r}: expected "
            f"non-negative numeric seconds (e.g. 30) — fix or unset the "
            f"environment variable"
        )
    return value


@contextmanager
def locked(state_path: Path, timeout: float | None = None):
    """Hold the advisory write lock for `state_path`'s RMW section.

    Blocks up to `timeout` seconds (None → CFP_STATE_LOCK_TIMEOUT env var
    or DEFAULT_TIMEOUT), polling a non-blocking flock. Raises LockTimeout
    on expiry and LockError when the lock file cannot be created, so
    callers handle every lock failure through one except clause."""
    effective = _effective_timeout(timeout)
    deadline = time.monotonic() + effective
    try:
        fd = os.open(lock_path_for(state_path), os.O_CREAT | os.O_RDWR, 0o644)
    except OSError as exc:
        raise LockError(
            f"cannot create lock file {lock_path_for(state_path)}: "
            f"{type(exc).__name__}: {exc} — check that the state directory "
            f"exists and is writable, then rerun"
        ) from exc
    try:
        while True:
            try:
                fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
                break
            except BlockingIOError:
                if time.monotonic() >= deadline:
                    raise LockTimeout(
                        f"could not acquire {lock_path_for(state_path)} within "
                        f"{effective:g}s — another cfp-state writer "
                        f"holds it; retry once it finishes"
                    ) from None
                time.sleep(_POLL_INTERVAL)
            except OSError as exc:
                # e.g. flock unsupported on the underlying filesystem —
                # contention is retried above, everything else is terminal.
                raise LockError(
                    f"cannot lock {lock_path_for(state_path)}: "
                    f"{type(exc).__name__}: {exc} — the filesystem may not "
                    f"support flock; move the state file to one that does"
                ) from exc
        yield
    finally:
        os.close(fd)

README.md

tile.json