Skip to content

server: relocate .server.pid to $XDG_RUNTIME_DIR#413

Merged
memtomem merged 2 commits intomainfrom
feat/xdg-runtime-pidfile-412
Apr 23, 2026
Merged

server: relocate .server.pid to $XDG_RUNTIME_DIR#413
memtomem merged 2 commits intomainfrom
feat/xdg-runtime-pidfile-412

Conversation

@memtomem
Copy link
Copy Markdown
Owner

Summary

Details

New helper memtomem._runtime_paths:

  • runtime_dir() — pure path resolver (no mkdir). Checks
    $XDG_RUNTIME_DIR; falls back to tempfile.gettempdir()/memtomem-$UID.
  • ensure_runtime_dir() — explicit opt-in that creates the dir with
    mode=0o700. Never re-chmods an existing dir (a root-owned
    leftover surfaces via the caller's open() rather than silently
    adjusted).
  • server_pid_path() / legacy_server_pid_path() — typed handles for
    the two probe targets.

server/__init__.py:main now calls ensure_runtime_dir() / "server.pid"
instead of Path("~/.memtomem").expanduser().mkdir(...) / ".server.pid".

cli/uninstall_cmd.py:

  • _check_server_liveness walks (server_pid_path(), legacy_server_pid_path())
    and returns alive=True for the first flock holder.
  • The new runtime pid file is included in the "Other" inventory group
    so it is cleaned alongside .current_session + legacy .server.pid.
  • If the cleanup empties the runtime subdir, it is rmdir'd (we own
    the subdir; the XDG parent is kernel-managed and never touched).

Non-goals

Test plan

  • uv run pytest packages/memtomem/tests -m "not ollama" — 2191
    passed, 0 regressions.
  • New test_runtime_paths.py covers XDG set / unset / stale,
    0o700 creation, idempotency, uid suffix on fallback.
  • test_uninstall_cmd.py now covers refusal at both the new and
    legacy flock locations, PID recycling at both locations, and
    runtime-subdir cleanup.
  • test_server_sigterm.py integration test spawns a real
    memtomem-server with isolated $HOME + $XDG_RUNTIME_DIR and
    verifies SIGTERM cleanup at the new location.
  • uv run ruff check + uv run ruff format --check — clean.
  • uv run mypy advisory on the three changed source files — clean.
  • Manual smoke: memtomem-server writes and cleans up
    server.pid at the new location on macOS; ~/.memtomem/ is not
    created.

Closes #412.

🤖 Generated with Claude Code

memtomem pushed a commit that referenced this pull request Apr 23, 2026
Addresses the PR #413 review pass:

B1 (BLOCKER) — the new server acquired its flock only at the new
location, so an upgrade with a pre-#412 server still running at
~/.memtomem/.server.pid could put two writers on the same DB. main()
now holds a lifetime flock on the legacy path too: detect + exit 1
if an old server holds it, and keep our own hold so a future old
server also detects us. Subprocess test pins the refusal.

M1/M2/M3 — runtime dir now validated for symlink / owner / mode in
both the XDG-gate (fall through silently) and the ensure path (raise
PermissionError with a rm -rf hint). The previous "never re-chmod
existing dir" docstring was at odds with the test that locked in
0o755 silently; that test is flipped to pytest.raises, and a new
test covers each refusal case (symlink, loose mode, wrong owner,
non-directory). XDG=/tmp or a user-symlinked XDG now transparently
falls through to the per-user tempdir form instead of placing the
pid file in a world-readable location.

MINOR — umask belt-and-suspenders explicit chmod after mkdir;
pin test for the rmdir non-empty guard (unrelated files must
survive, runtime subdir must not prune); subprocess coverage for
the tempdir fallback path (previously only XDG was exercised); the
~/.memtomem/-untouched headline assertion now lives directly in
the sigterm integration test instead of being implied.

NIT — module-level runtime_dir import in uninstall_cmd (no more
inline shadowing); unused _state_dir param dropped from
_check_server_liveness.

Tests: 2201 pass, 0 regressions. Lint + mypy clean.
@memtomem
Copy link
Copy Markdown
Owner Author

Thanks for the thorough pass — pushed cdb1423 addressing every item
above B1 through NIT.

B1 (BLOCKER) — legacy flock in main()

New _try_hold_legacy_flock acquires a lifetime flock on
~/.memtomem/.server.pid:

  • Old server holds it → stderr error: another memtomem-server holds a lock at … + sys.exit(1) (matches reviewer's recommended refuse
    path — warn+continue is the wrong policy when the two writers are
    different versions hitting the same DB).
  • Legacy path missing → skip entirely (fresh install, no upgrade
    history; touching ~/.memtomem/ would re-pollute the dir server: relocate .server.pid to $XDG_RUNTIME_DIR so ~/.memtomem/ stays lazy #412
    specifically keeps out of handshake).
  • Acquired → hold for process lifetime via atexit, so a future old
    server starting after us also hits this lock via its own
    concurrent-detection path. Bidirectional mutex during the transition
    window.

Subprocess test test_server_refuses_when_legacy_lock_held pins it:
test holds fcntl.flock on the legacy file, spawns the real server
binary, asserts non-zero exit with the legacy pid path in stderr.

M2 — XDG sanity tightened

runtime_dir() now gates on _is_safe_dir(Path(xdg)) which lstats
(never follows the symlink), checks st.st_uid == os.geteuid(), and
requires mode & 0o077 == 0. Any failure silently falls through to
the tempdir form so a misconfigured XDG_RUNTIME_DIR=/tmp or
symlinked export never places the pid file in a world-readable
location. Three dedicated tests: symlink, loose mode, wrong owner.

M3 — docstring and enforcement now agree

test_does_not_chmod_existing_dir (which pinned the loose behavior)
is flipped to test_refuses_existing_loose_mode
pytest.raises(PermissionError, match="unsafe permissions"). Full
matrix on ensure_runtime_dir refusal paths:

  • symlink → PermissionError("... is a symlink; refusing to follow")
  • wrong owner → PermissionError("... owned by uid N (expected M)")
  • group/world bits → PermissionError("... unsafe permissions 0o755")
  • non-directory → PermissionError("... is not a directory")

All error messages end with a rm -f <path> / rm -rf <path> hint so
the user can unstick themselves without reading the source.

M1 — symlink path covered by the same matrix

The _is_safe_dir gate already rejects a symlinked XDG base (M2's
test suite proves it); ensure_runtime_dir rejects a symlinked
memtomem subdir (dedicated test). The tempdir fallback case is
structurally equivalent once the gate falls through to it: the same
validator runs. Umask belt-and-suspenders: explicit os.chmod(target, 0o700) after mkdir with its own test (umask 0o177 scenario).

Minor

  • m1 umask: covered by new test_explicit_chmod_survives_wild_umask.
  • m2 non-empty rmdir pin:
    test_runtime_subdir_preserved_when_unrelated_files_present
    pre-seeds someone-elses.pid in the runtime subdir, runs mm uninstall, asserts sibling survives and runtime subdir stays.
    Invert regression of the not any(iterdir()) guard would break
    this.
  • m3 fallback subprocess: new test_server_uses_tempdir_fallback_ when_xdg_unset spawns the server with XDG_RUNTIME_DIR unset +
    isolated TMPDIR, asserts the pid file lands on the expected
    per-uid path at mode 0o700. The ~/.memtomem/ must-not-exist
    assertion is now directly in
    test_sigterm_unlinks_pid_file_end_to_end too.
  • m4: test_falls_back_when_xdg_points_at_nonexistent_dir renamed
    to test_falls_back_when_xdg_path_is_missing_or_stale with both
    semantic cases (never-materialized + reaped) noted in the docstring.

Nit

n1 left for a separate pass

Declining the sys.platform == "win32" raise in _runtime_paths.py
the module is imported by uninstall_cmd.py which also needs to load
cleanly on Windows (for the detect-and-report paths that don't touch
flock). The POSIX-only gate already lives on fcntl imports inside
both callers; adding it to _runtime_paths would surface a different
error than the one tests exercise. Happy to reconsider if this comes
up again.

CI

Local: 2201 passed, 0 regressions. ruff check + ruff format --check

  • mypy on the three changed source files all clean. Pushing and
    watching CI.

pandas-studio and others added 2 commits April 23, 2026 16:22
Pre-#412 the MCP server wrote ~/.memtomem/.server.pid at startup, which
forced ~/.memtomem/ into existence on every handshake (claude mcp list,
Cursor / Windsurf probes) even for clients that connected but never
called a tool. Combined with the lazy DB initialization shipped in #399
Phase 3, this was the last write keeping that directory from being
truly on-demand.

Runtime files (pid / flock) now live under $XDG_RUNTIME_DIR/memtomem/
when the platform provides it (Linux + systemd), or a per-user temp
subdir otherwise ($TMPDIR/memtomem-$UID/ on macOS / BSD, which
tempfile.gettempdir() already resolves to a per-user path on macOS).
Runtime state and persistent user data are now cleanly separated: a
handshake that never reaches the first tool call leaves ~/.memtomem/
alone.

mm uninstall probes both the new and legacy locations during the
transition window so a mixed-version upgrade (pre-#412 server still
running + new CLI) still refuses correctly. The new runtime subdir is
rmdir'd after cleanup if empty; the XDG parent is kernel-managed and
never touched.

Non-goals:
- Does not extend the liveness probe to other DB writers (mm web /
  mm watchdog), tracked separately in #384. Placing runtime files in a
  dedicated per-user dir is the right substrate for that expansion,
  but registering per-process locks is its own PR.
- .current_session stays in ~/.memtomem/ — that is persistent session
  state, not runtime ephemera.

Closes #412.

Co-Authored-By: Claude <[email protected]>
Addresses the PR #413 review pass:

B1 (BLOCKER) — the new server acquired its flock only at the new
location, so an upgrade with a pre-#412 server still running at
~/.memtomem/.server.pid could put two writers on the same DB. main()
now holds a lifetime flock on the legacy path too: detect + exit 1
if an old server holds it, and keep our own hold so a future old
server also detects us. Subprocess test pins the refusal.

M1/M2/M3 — runtime dir now validated for symlink / owner / mode in
both the XDG-gate (fall through silently) and the ensure path (raise
PermissionError with a rm -rf hint). The previous "never re-chmod
existing dir" docstring was at odds with the test that locked in
0o755 silently; that test is flipped to pytest.raises, and a new
test covers each refusal case (symlink, loose mode, wrong owner,
non-directory). XDG=/tmp or a user-symlinked XDG now transparently
falls through to the per-user tempdir form instead of placing the
pid file in a world-readable location.

MINOR — umask belt-and-suspenders explicit chmod after mkdir;
pin test for the rmdir non-empty guard (unrelated files must
survive, runtime subdir must not prune); subprocess coverage for
the tempdir fallback path (previously only XDG was exercised); the
~/.memtomem/-untouched headline assertion now lives directly in
the sigterm integration test instead of being implied.

NIT — module-level runtime_dir import in uninstall_cmd (no more
inline shadowing); unused _state_dir param dropped from
_check_server_liveness.

Tests: 2201 pass, 0 regressions. Lint + mypy clean.
@memtomem memtomem force-pushed the feat/xdg-runtime-pidfile-412 branch from cdb1423 to c6fb0a0 Compare April 23, 2026 07:23
@memtomem memtomem merged commit b2cab58 into main Apr 23, 2026
7 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 23, 2026
@memtomem memtomem deleted the feat/xdg-runtime-pidfile-412 branch April 27, 2026 14:56
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

server: relocate .server.pid to $XDG_RUNTIME_DIR so ~/.memtomem/ stays lazy

2 participants