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
@@ -0,0 +1,91 @@
-- Migration 0003 — tasks.start_mode is rebuilt to the contract's StartMode values.
--
-- 0001's CHECK read `start_mode IN ('auto','now','queue','manual')`. That is not
-- StartMode.schema.json's enum at all: the contract is ['now','later','queue']. The
-- practical effect: `download.add` with `startMode: "later"` — a real, documented value
-- (the File Info dialog's Download Later button) — hit the CHECK constraint on insert
-- and surfaced as an unhandled -32603, every time. 'auto' and 'manual' were never
-- contract values; they were this table's own invention and nothing on the wire ever
-- sends them.
--
-- SQLite cannot ALTER a CHECK constraint in place, so this rebuilds the table (same
-- pattern as 0002: create the new shape, copy with the value mapped, drop, rename).
-- Existing rows are remapped by what they actually meant: 'auto' was "eligible for the
-- scheduler the moment it's added", i.e. 'now'; 'manual' was "parked, wait for the user",
-- which is what 'later' means on the wire (StartMode's own description: "lands the task
-- in paused"). Any row already spelled 'now' or 'queue' passes through unchanged.
--
-- This does NOT touch `state` or `pause_reason` — a row that was start_mode='manual' and
-- (per the dispatcher's now-dead branch) state='new' keeps state='new'; the daemon-side
-- fix to actually land a 'later' task in 'paused' going forward lives in dispatcher.cpp,
-- not in this migration. Historical rows are not replayed through the scheduler.
CREATE TABLE tasks_new (
task_id TEXT PRIMARY KEY,
url TEXT NOT NULL,
effective_url TEXT,
filename TEXT NOT NULL DEFAULT '',
save_dir TEXT NOT NULL,
category_id TEXT REFERENCES categories(category_id) ON DELETE SET NULL,
queue_id TEXT REFERENCES queues(queue_id) ON DELETE SET NULL,
queue_position INTEGER,
state TEXT NOT NULL DEFAULT 'new'
CHECK (state IN ('new','probing','queued','connecting','downloading','paused',
'retry_wait','assembling','verifying','complete','failed','cancelled')),
pause_reason TEXT CHECK (pause_reason IN
('user','schedule','queue_stopped','admission_reconcile','auto')),
size_bytes INTEGER,
downloaded_bytes INTEGER NOT NULL DEFAULT 0,
resumable INTEGER NOT NULL DEFAULT 0,
req_segments INTEGER,
eff_segments INTEGER NOT NULL DEFAULT 0,
req_buffer_bytes INTEGER,
eff_buffer_bytes INTEGER,
-- The contract's StartMode (StartMode.schema.json): 'now' | 'later' | 'queue'.
start_mode TEXT NOT NULL DEFAULT 'now'
CHECK (start_mode IN ('now','later','queue')),
description TEXT,
etag TEXT,
last_modified TEXT,
content_type TEXT,
checksum_algo TEXT CHECK (checksum_algo IN ('md5','sha1','sha256','sha512')),
checksum_value TEXT,
error_code TEXT,
error_message TEXT,
error_http_status INTEGER,
error_retryable INTEGER,
error_attempt INTEGER,
error_next_retry_at TEXT,
speed_bps INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
last_try_at TEXT,
completed_at TEXT
) STRICT;
INSERT INTO tasks_new
SELECT task_id, url, effective_url, filename, save_dir, category_id, queue_id,
queue_position, state, pause_reason, size_bytes, downloaded_bytes, resumable,
req_segments, eff_segments, req_buffer_bytes, eff_buffer_bytes,
CASE start_mode WHEN 'auto' THEN 'now' WHEN 'manual' THEN 'later' ELSE start_mode END,
description, etag, last_modified, content_type, checksum_algo, checksum_value,
error_code, error_message, error_http_status, error_retryable, error_attempt,
error_next_retry_at, speed_bps, created_at, last_try_at, completed_at
FROM tasks;
DROP TABLE tasks;
ALTER TABLE tasks_new RENAME TO tasks;
CREATE INDEX idx_tasks_state ON tasks(state);
CREATE INDEX idx_tasks_category ON tasks(category_id);
CREATE INDEX idx_tasks_queue_order ON tasks(queue_id, queue_position);
CREATE INDEX idx_tasks_created ON tasks(created_at);
CREATE INDEX idx_tasks_completed ON tasks(completed_at);
+1 -1
View File
@@ -21,7 +21,7 @@ struct TaskRow {
std::string save_dir;
std::string filename;
std::string state = "new";
std::string start_mode = "auto";
std::string start_mode = "now"; // contract StartMode: 'now'|'later'|'queue'
std::string created_at;
std::optional<std::string> effective_url;