Lock Daemon: Parent/Child State Divergence

See parent: dev | See also: story/04-lockd

After fork(), parent and child have independent copies of all in-process state. Most state is safe; one piece was not. This document analyses each piece of state.

s_lock_tokens (WebDAV plugin)

Populated when child calls t->lock(). Parent's copy remains permanently empty.

Risk: if parent calls t->store() directly, it checks its own empty map, omits If: (<token>) header, gets HTTP 423 Locked. The in-memory changes are silently lost even though pwsafe thinks the save succeeded.

Fix: FClose tests pws_has_lock(url). If true, calls pws_lockd_store(cache, url) — which sends CMD_STORE to the child. The child's t->store() sees the correct token.

Status: FIXED. Regression test: lifecycle test §F demonstrates the bug and fix back-to-back.

s_active_locks (lock registry, transport.cpp)

Populated when parent calls pws_lock_register(url). Child's copy is a snapshot at fork time — it reflects any locks acquired before the first lock (i.e., none in practice, since the fork happens on the first lock).

Risk: none. The registry is read only in the parent (pws_has_lock, pws_lock_has_lock). The child doesn't need it — the child uses s_lock_tokens from the plugin directly.

Status: safe.

s_file_map (FILE* cache map, transport.cpp)

Populated in parent on FOpen. Child inherits a snapshot but closes all fds >= 3 immediately after fork, so the inherited FILE* values are dangling pointers in the child.

Risk: none. The child never calls pws_cache_lookup() or pws_cache_remove(). The child only handles IPC commands (lock/unlock/store) and the map is irrelevant there.

Status: safe.

s_transports / s_handles (plugin map, transport.cpp)

Contains loaded PWSTransport pointers and dlopen handles. Child inherits them — the loaded .so is still mapped into the child's address space.

Risk: safe today under the single-database constraint (one plugin per session). If multi-DB use were allowed, parent could unload a plugin (dlclose) while child was using it. Not currently possible.

Status: safe in practice, latent risk if multi-DB loading added later.

curl global state (libcurl)

curl_global_init() is called in the parent before fork. The child inherits the global state.

Risk: per libcurl docs, curl state after fork() is technically undefined. However: (a) the parent never calls curl after fork, (b) the child was designed to call curl from a clean state. In practice this is safe because libcurl's global state is just memory (no thread-local, no OS handles) and the child reinitialises per-handle state with curl_easy_init().

Status: safe in practice, technically undefined per libcurl docs. Documented as known limitation.

Shutdown scenarios

• Normal UNLOCK — parent sends CMD_UNLOCK; child calls t->unlock() immediately; token removed from s_lock_tokens

• Clean exit — LockdGuard static destructor → pws_lockd_shutdown() → CMD_QUIT → child calls child_unlock_all() → _exit(0) → parent waitpid()

• SIGKILL / crash — parent fd closed by kernel; child reads EOF → child_unlock_all() → _exit(0). Server locks released.

• Child killed — parent gets EIO on next read(). No recovery mechanism. User must reopen the application. The server lock remains held until it expires (WebDAV timeout, typically 5 minutes).

Protocol ordering guarantee

The parent–child protocol is strictly synchronous: the parent sends one command and blocks on read() for the response before sending the next. No pipelining. This guarantees that from the child's perspective, commands for a given URL are totally ordered — no race between concurrent lock/unlock/store for the same URL is possible from the parent side.

The SOCK_CLOEXEC flag on the socketpair ensures no other process (browser spawned for help, file manager) inherits the parent's socket fd and delays the child's EOF detection.

version1
created2026-02-27