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.
76
95%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
"""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).tessl-plugin
skills
check-cfps
references
scripts
nightly-cfp-sync