daemon: fix startMode 'later' -32603 and isolate the single-instance lock by runtime dir

Two bugs blocking PROTO's live-veloxd conformance check.

1. tasks.start_mode's CHECK was ('auto','now','queue','manual') — not the
   contract's StartMode enum (['now','later','queue']) at all. 'later', a real
   documented value (the File Info dialog's Download Later button), hit the CHECK
   on every insert and surfaced as an unhandled -32603; 'auto'/'manual' were
   never contract values to begin with.

   Migration 0003 rebuilds tasks (SQLite can't ALTER a CHECK) with the contract's
   values, remapping existing rows by what they actually meant: 'auto' -> 'now'
   (eligible for the scheduler immediately), 'manual' -> 'later' (parked, matching
   StartMode's own "lands the task in paused" description). store_migrations_test
   covers the remap and that 'later' inserts clean while the retired spellings
   are rejected.

   dispatcher.cpp's on_download_add matched: default (absent startMode) is now
   'now' instead of the invented 'auto'; 'later' actually lands the task in
   `paused` (pause_reason 'user') instead of a dead 'manual' -> `new` branch that
   spec.startMode (typed as the 3-value enum) could never even reach.
   TaskRow::start_mode's in-memory default followed suit ('now').

2. main.cpp's single-instance guard bound an abstract socket named
   "velox-daemon-<euid>" — one name per user, system-wide. XDG_RUNTIME_DIR
   isolation never reached it: a leaked test veloxd held the lock for 4h40m and
   locked out every other isolated instance with the same euid (PROTO, EXT, the
   orchestrator), real daemon included.

   Extracted rpc/single_instance.{hpp,cpp} (was a static in main.cpp, untestable)
   and derived the abstract-socket name from a hash of the resolved runtime dir
   path instead of euid alone. The real per-user daemon is still unique (its
   runtime dir is unique to it); isolated instances pointed at their own runtime
   dirs now coexist. main() resolves the runtime dir before acquiring the lock
   (was the other way around). New single_instance_test covers same-dir refusal,
   different-dir coexistence, and release-on-close.

Verified against real veloxd binaries, not just unit tests: startMode: "later"
via a live download.add lands in `paused`; two veloxd with different runtime
dirs run concurrently, two with the same one and the second refuses with the
runtime dir named in the error. Full ctest: 39/39.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GRDjHGgpYmMoPE2UFbe7pP
This commit is contained in:
2026-09-11 17:04:02 +04:00
co-authored by Claude Sonnet 5
parent a967eca669
commit de748cc2fc
10 changed files with 270 additions and 35 deletions
+13 -4
View File
@@ -152,10 +152,19 @@ VeloxDispatcher::on_download_add(const proto::DownloadSpec& spec) {
row.save_dir = target->dir;
row.filename = target->leaf;
row.created_at = velox::daemon::now_iso();
row.start_mode = spec.startMode ? std::string(proto::to_string(*spec.startMode)) : "auto";
// startMode 'manual' parks the task in `new`; anything else makes it eligible for the
// scheduler (`queued`); on_mutation_ nudges it.
row.state = row.start_mode == "manual" ? "new" : "queued";
// Contract values only (StartMode.schema.json: 'now'|'later'|'queue'); absent means
// "start it", same as an explicit 'now'.
row.start_mode =
spec.startMode ? std::string(proto::to_string(*spec.startMode)) : "now";
// 'later' — the File Info dialog's Download Later button — lands the task in `paused`
// per StartMode's own description; anything else ('now' or 'queue') is eligible for
// the scheduler immediately (`queued`); on_mutation_ nudges it.
if (row.start_mode == "later") {
row.state = "paused";
row.pause_reason = "user";
} else {
row.state = "queued";
}
row.category_id = spec.categoryId;
row.queue_id = spec.queueId;
row.description = spec.description;
+38
View File
@@ -0,0 +1,38 @@
#include "rpc/single_instance.hpp"
#include <cstddef>
#include <cstring>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#include "util/crypto.hpp"
namespace velox::daemon::rpc {
int acquire_single_instance_lock(const std::string& runtime_dir) {
const int fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (fd < 0) return -1;
// Truncated to 16 hex chars (64 bits): a collision would need two distinct runtime
// dirs to hash together, which is not a security boundary here — the socket itself is
// still 0600 same-UID-checked; this is only "don't let two daemons stomp each other".
const std::string name =
"velox-daemon-" + velox::daemon::crypto::sha256_hex(runtime_dir).substr(0, 16);
sockaddr_un addr{};
addr.sun_family = AF_UNIX;
// Leading NUL selects the abstract namespace; the name follows, not NUL-terminated.
addr.sun_path[0] = '\0';
std::memcpy(addr.sun_path + 1, name.c_str(), name.size());
const socklen_t len =
static_cast<socklen_t>(offsetof(sockaddr_un, sun_path) + 1 + name.size());
if (::bind(fd, reinterpret_cast<sockaddr*>(&addr), len) != 0) {
::close(fd);
return -1;
}
return fd;
}
} // namespace velox::daemon::rpc
+27
View File
@@ -0,0 +1,27 @@
#pragma once
// Single-instance guard: bind an abstract-namespace Unix socket whose name is derived
// from the canonical runtime directory (resolve_runtime_dir's result — already the
// per-user default, /run/user/<uid>/velox, unless XDG_RUNTIME_DIR says otherwise). A
// second daemon pointed at the same runtime dir gets EADDRINUSE and exits; one pointed at
// a different (isolated / test) runtime dir gets its own lock and starts fine. The kernel
// reclaims an abstract-namespace address when the holding process dies, so a crash never
// wedges it (docs/01 §2).
//
// Naming this "velox-daemon-<euid>" alone (the old scheme) meant exactly one name per
// user system-wide, so XDG_RUNTIME_DIR isolation never reached it: a leaked test veloxd
// with the same euid held the lock for every isolated instance too, real or test, until
// it was killed. Hashing the resolved runtime dir path instead keeps the real per-user
// daemon unique (its runtime dir is unique to it) while letting isolated instances that
// each point at their own runtime dir coexist.
#include <string>
namespace velox::daemon::rpc {
// Returns the held fd (kept open for the process lifetime; closing it releases the lock)
// or -1 if another process already holds the lock for this exact `runtime_dir`, or on any
// other socket error.
int acquire_single_instance_lock(const std::string& runtime_dir);
} // namespace velox::daemon::rpc