daemon: a relay without XDG_RUNTIME_DIR autospawns a second daemon and splits the roster #275
Labels
No labels
priority/P0
priority/P1
priority/P2
release/v0.1.0
status/blocked
status/planned
type/bug
type/design
type/test-gap
type/tracker
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
natsukium/felis#275
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
felis-daemon relayresolves its socket in its own environment (crates/felis-daemon/src/main.rs:104→
felis_transport::socket::default_socket_path). On LinuxSocketPath::resolve(
crates/felis-transport/src/socket.rs:28-37) takes$XDG_RUNTIME_DIR/feliswhen the runtime dir isthere and otherwise falls back to
${TMPDIR:-/tmp}/felis.<uid>. An SSH server that does not runpam_systemd hands the relay an environment with no
XDG_RUNTIME_DIR, so the relay resolves thefallback path, finds it cold, and autospawns a second daemon there
(
crates/felis-daemon/src/relay.rs:72-87). The retargeted window lands on that daemon with an emptyroster while every existing session stays on the first one, and both keep running.
Observed
NixOS host reached over Tailscale SSH, 2026-09-13. Two daemons of the same build:
The second one was spawned by a relay under Tailscale SSH:
/proc/<pid>/environis the proof: the relay and the daemon it spawned carry noXDG_RUNTIME_DIR,while the first daemon carries
XDG_RUNTIME_DIR=/run/user/1000. An OpenSSH login to the same hostin the same minute does get
/run/user/1000from pam_systemd, so the discriminator is the SSHserver, not the host.
Why the recorded triage does not cover it
The triage on #41 already corrected the issue body's Linux claim and confirmed this exact path, but
concluded it "rarely manifests because pam_systemd sets
XDG_RUNTIME_DIRfor SSH logins on systemdhosts", and its round-3 amendment withdrew the proposed
/run/user/<uid>probe because the Linuxendpoint rule is frozen at v0.1.0 (an upgraded client must not miss a running daemon). Tailscale SSH
falsifies the "rarely" premise: it is an ordinary way to reach a host, it never creates the
pam_systemd session environment, and nothing in felis reports what happened. The freeze conclusion
still stands — this issue asks for detection and an escape hatch, not a different default.
Options
terminal, which
relay.rs:18-27already uses for the--no-spawndiagnosis. WhenXDG_RUNTIME_DIRis unset and the relay is about to autospawn on the${TMPDIR:-/tmp}fallback,say so and name the path. Cheapest, no contract change, and it turns a silent split into a visible
one.
--socketislocal-only and mutually exclusive with
--host, and the relay deliberately ignoresFELIS_SOCKET(crates/felis-client-core/src/local_socket.rs:1-4: a daemon that reads the stampmakes an autospawned child bind an address already in use).
felis-daemon relayalready takes--socket(main.rs:55-58), so the missing piece is a client-side way to append it to the remoteargv — a flag paired with
--host. CLI plus carrier surface, soextend-ipcgoverns it.doctorsee the sibling. The Linux half of #41's option C: when the resolved endpointdiffers from
${TMPDIR:-/tmp}/felis.<uid>/daemon.sock, connect-probe the other path and report"a second daemon is serving
<path>" as awarnrow. Read-only, never unlink a live socket(
docs/explanation/security-model.md"Daemon IPC").docs/how-to/attach-over-ssh.mdcarries the macOSTMPDIRrecipe; the Linux counterpart is exporting
XDG_RUNTIME_DIRfor non-login shells on the remotehost, which is what a Tailscale SSH user needs today.
Reproduce
$XDG_RUNTIME_DIR/felis.felis window retarget --to-host <tailscale-destination>.ps -eo args | grep felis-daemonshows twoserveprocesses on different sockets,and
felis --socket /tmp/felis.<uid>/daemon.sock sessions listshows the new window's sessionalone.
Acceptance
doctoron thehost reports the second daemon instead of showing only the resolved one.
Related: #41 (the macOS twin of this split,
TMPDIRinstead ofXDG_RUNTIME_DIR).Plan (2026-09-14, rev 12)
Source-grounded against
mainat3664c6cb, reviewed through twelve rounds of two independent reviewers (pisol and luna) until both passed with no findings.Issue: #275 (body reproduced at the end).
Related: #41 (macOS TMPDIR twin; its triage plan and round-3 amendment froze the Linux default
endpoint rule at v0.1.0 and withdrew a
/run/user/<uid>probe in the default rule).Decision
Ship E + C + A + D in one PR. Reject B.
Shared vocabulary
Endpoint topology (felis-transport::socket, pure functions of uid, no environment)
Per OS, the two canonical places a per-user daemon socket can be:
LM)FB)/run/user/<uid>/felis/daemon.socktmpdir_fallback(uid, None)=/tmp/felis.<uid>/daemon.sock<confstr(_CS_DARWIN_USER_TEMP_DIR)>/felis.<uid>/daemon.socktmpdir_fallback(uid, None)=/tmp/felis.<uid>/daemon.sockLMon macOS needs one libcconfstrcall infelis-transport, beside the existing auditedgetpeereidsite, with a// SAFETY:comment (the shape #41's triage plan already sanctioned).Windows has no topology: the SID-derived pipe name is environment-independent, so nothing below
applies there.
The resolver (
SocketPath::resolve, the frozen default rule) is untouched except for onenormalization: an empty
TMPDIR(and, on Linux, an emptyXDG_RUNTIME_DIR, whichdirectoriesalready treats as unset) is treated as unset instead of failing as "not anabsolute path". Today
TMPDIR=makes resolution error out before anything can run; nothingcould have been listening at a path that never resolved, so no running daemon is missed.
Test:
TMPDIR=resolves like unset on both platforms.Given a resolved default
R, exactly one of three holds (path comparison is lexical, on thepaths the resolver produced):
R == LM: the normal login-session shell.R == FB: the environment lacks the login manager's variable (Tailscale SSH on Linux, any SSHlogin on macOS).
Ris neither: the user setXDG_RUNTIME_DIRorTMPDIRto a custom directory. This is anexplicit opt-out: no probing anywhere, no doctor row; the environment is authoritative,
exactly as today. Documented as such.
The probe (felis-transport::preface, shared by relay and doctor)
probe(path, deadline) -> Absent | Indeterminate { err } | ConnectedUnverified { why } | Live { detail },one connection, run to a terminal outcome under one deadline (2 s, a constant):
ENOENT/ECONNREFUSED->Absent. Any other error (EACCES,EMFILE,ENFILE, ...) ->Indeterminate.ClientPreface::CURRENT; read the daemon preface.DaemonPreface::RefuseorDaemonPreface::Unknown(a validFLISpreface with a status this build does not know isnormatively a refusal from a live, newer daemon;
docs/reference/ipc.mdpreface status table)->
Live { detail: the status and words }.Acceptnaming a major this build did not offer->
Live { detail: major mismatch }(what the primary doctor row already reports as a runningdaemon).
Accepton an offered major -> step 3.Hello; read the reply.Welcome->Live.ConnMsg::Refusedof anyreason (
AtCapacityincluded: a full daemon is a live daemon) ->Live { detail: reason }.->
ConnectedUnverified. This includes a full daemon whose refusal slots are all taken andwhich accepts and drops without a preface (
serve.rsrefuse_over_capacity), a listener thatsends
Acceptand then goes silent, and any non-felis process on the path.Only
Absentmeans "nothing is there".IndeterminateandConnectedUnverifiedmean "somethingmay be there and could not be verified"; no caller may treat them as cold. The probe never
unlinks or binds (security-model "Binding never steals a live daemon's socket"). The connection is
closed after the terminal outcome; a daemon sees an Observer that left before attaching, which
the pool already tolerates.
Security: a client-side connect does not verify the server's uid; the trust in
LMis the sametrust the resolver already places in
$XDG_RUNTIME_DIR/ launchd'sTMPDIR(the loginmanager's 0700 per-user directory), and the probe adds no symlink check the default path does not
have either. Stated in security-model.md "Daemon IPC".
E (primary): the relay selects its endpoint before failing or spawning
Applies to every remote dial, spawning and
--no-spawnalike: window launches,sessions spawn, read/drive verbs,doctor --host,version --host, notifications.felis-daemon relayreceives a relay-only endpoint type frommain.rs, which stops collapsingNone/Some(path)into onePathBuf:Explicit(PathBuf)for--socket,Default(R)otherwise.
Explicit(path): exact single endpoint, today's behavior unchanged (bare connect; on failure,--no-spawnfails, else spawn onpath).Default(R): bare connect toR(today's connect; the client handshakes end-to-end throughthe pump, so the frozen path's behavior is unchanged when a daemon is there). Classify the
connect result with the probe's step-1 rule: connected -> bridge;
Absent-> step 3;Indeterminate-> exit with a diagnostic namingRand the error, no probe, no spawn. (Thistightens today's relay, which spawns on any connect error;
EACCESon the resolved path is notevidence of a cold socket.)
R == FB(and only then;R == LMhas no sibling to prefer, and "neither" is the opt-out):probe(LM).Live-> step 4.Absent-> step 5.Indeterminate/ConnectedUnverified->exit with a diagnostic naming
LMand the outcome ("something listens at but did notanswer as a felis daemon; not starting a second daemon"), no spawn, for spawning and
--no-spawndials alike.LM: open a fresh connection toLMand pump it exactly as theRpath is pumpedtoday (bare connect, byte-transparent, unbounded). Log at info which endpoint won and why.
The probe proved that a felis daemon answered at
LMat selection time; the fresh connectionis not re-verified, and no relay-side gate is attempted: a partial gate (first byte, first
frame) cannot bound a peer that stops after a valid
Accept, and a full one would make therelay parse the client's handshake, which
relay.rsforbids by design (byte-transparent). Aclient-side handshake deadline is likewise rejected: the client's preface read sits behind
SSH's own password and host-key prompts, and a felis timer there would fire under a prompt
(explanation/architecture/ipc.md, the "no deadline on the ssh child" rule). What remains is
the exposure the frozen
Rpath already has: a same-user process that takes over the socketpath in the interval between probe and connect, and then does not speak felis, hangs the
client until the user closes it. This is stated as a documented boundary beside the
probe-to-spawn race, not claimed closed.
RandLMbothAbsent(orR == LM, no sibling):--no-spawn-> today's "no daemon onthis host" error; otherwise spawn on
Ras today, plus the A warning.Freeze argument: the frozen rule still resolves
Rfirst, and a live daemon onRis alwaystaken (step 2 bridges before any probe). E adds a candidate only when
Ris cold, which isexactly the case that today produces a split (spawn) or a false "no daemon" (
--no-spawn). Anupgraded client never misses a daemon it would have found before. Connect only, never bind or
unlink, so #41 round-3 ("must probe the legacy path and never bind over a live socket") holds.
The macOS
LMprobe is connect-only and leaves the macOS default where it is, so it complements#41 rather than pre-empting it; a comment on #41 records that.
Cross-platform shape (non-goals.md "Cross-platform constraints"): the intent is uniform, "a
process whose environment lacks the login manager's per-user variable still finds the per-user
daemon"; only the mechanism (
LM) differs per OS, and Windows needs none. Recorded as the fifthplatform shim in non-goals.md. macOS has no CI runtime: the topology function has
cfg(target_os = "macos")unit tests and the end-to-end path is hand-verified(felis-macos-gui-debug) and recorded in the PR.
Residual races, stated rather than hidden. First, the probe-to-connect swap above (a hang the
frozen path shares). Second, between
probe(LM) == Absentand the daemon spawned onRbinding, another process can start a daemon onLM(a GUI or OpenSSH login autospawning atthe same moment).
serve's startupflockis per socket path and does not coordinate twoendpoints; E adds no cross-endpoint lock. The guarantee is "a relay never spawns beside a daemon
it can observe", not "two daemons can never coexist"; the outcome of the race is the pre-existing
split, now detectable by C. Recorded in the decision record with a Revisit-if trigger (the race is
observed in practice, or a cross-endpoint lock becomes cheap).
--helpforrelay --socketno longer says "same default as serve"; it says that without--socketthe relay also probes the login manager's location when the environment resolved thefallback.
Tests (spawn seam injected; the test binary is never exec'd;
LM/FBinjected as paths):LM/FBper OS; classification ofRintoLM/FB/ neither for: variableunset, empty, equal to
LM's dir, custom;cfg(target_os = "macos")for the confstr cases.Rcold,LMlive: bridges toLM, spawn seam not called.--no-spawn: bridges, no error.Explicit(R)withLMlive: no probe;--no-spawnfails NotFound.Absent, spawn allowed: spawn seam called withR.R;LMnever dialed; spawn seam not called (the freeze invariant).LM,Rcold: no spawn, exit with the diagnostic, no hang.Acceptthen silent: same as (f).Refused AtCapacityonLM: selected as live.LMwith connection admission and refusal slots both zero(
ConnectionAdmission::with_refusal_slots(0, 0): accept-and-drop; admission zero alone stillanswers
Refused AtCapacity): no spawn, diagnostic.LMconnect fails withEACCES(unreadable dir): no spawn, diagnostic namesLM.DaemonPreface::UnknownonLM: selected as live.Rconnect fails withEACCES,LMcold: no probe, no spawn, diagnostic namesR.R == LM(Linux withXDG_RUNTIME_DIR),Rcold: no probe, spawn onRas today.Rneither (custom variable),Rcold,LMlive: no probe, spawn onR(opt-out).C:
doctorreports the other canonical endpointProvenance first.
Reconnectorcarries onlyCarrier::Local(path), so--socket, a non-emptyFELIS_SOCKETstamp (every shell inside a felis session has one:felis doctortyped in asession deliberately targets that daemon), and the platform default are indistinguishable there,
and path equality is not a substitute (an explicit or stamped path can equal the default).
resolve_local_socket_fromtherefore returns the provenance (Explicit | Stamped | Default)alongside the path,
conn::resolvekeeps it, anddoctor::runruns the sibling check only forDefaulton a local carrier. Under--host,--socket, or a stampedFELIS_SOCKETthere is nosibling row (skipped, not "absent"). Tests: all four sources, including an explicit and a stamped
path equal to the computed default.
Selection: with
Rthe default, the other endpointSisFBwhenR == LM,LMwhenR == FB, and none (no row) whenRis neither (the opt-out). Windows: no row ever. Alias: ifcanonicalize(R) == canonicalize(S)(a symlinked directory), no row: the two names are onesocket.
Primary row: today
daemon_checkfolds every non-specializedConnectErrorinto "not running",which would let a silent or malformed listener on
Rread as cold while the same peer onSreads as "unverified". Two separate changes to the primary row, with different scopes:
(1) Dial behavior: the deadline and the connect-error partition (
Absent/Indeterminate/ConnectedUnverified) apply only forDefaultprovenance on the local Unix carrier.Explicit
--socket, stampedFELIS_SOCKET, SSH (--host), and Windows targets keep today'sunbounded dial and today's failure renderings; tests pin the boundary (an explicit
--sockettoa silent listener is not subject to the deadline).
(2) Rendering of completed handshakes: the two
Liveoutcomes that today fall into the "notrunning" catch-all get their own arms on every provenance and carrier, because they are
answers from a daemon, not dial failures. A test on a non-default target (explicit
--sockettoa fake daemon answering
Refused) pins that the arms apply there too.With the probe deadline, the default-local partition renders:
Absent->warn"notrunning" (today's rendering and status, pinned by
crates/felis-cli/tests/cli_doctor.rs, kept);Indeterminate->warn"could not dial : ";ConnectedUnverified->warn"somethinglistens at but did not answer as a felis daemon"; the existing
Liverenderings (running,major skew, not-felis-negotiation
fail) are unchanged. The new rendering arms of (2):DaemonPreface::Unknown->warn"running, but answered preface status () this build does not know: upgradethis build";
ConnMsg::Refused { reason }for every reason ->warn"running, but refused thisconnection: " (
AtCapacity,Role, and any later reason render through the samearm). Tests on a default-local target: unknown preface status and each
Refusedreason render"running, but ...", never "not running". A silent listener on a local
Rnolonger hangs
doctor. The SSH carrier (doctor --host) keeps today's unbounded dial andrenderings: its preface read sits behind SSH's prompts, and a deadline there would fire under a
password or host-key prompt (the same rule that rejects a client-side handshake deadline in E).
Over SSH the relay's diagnostics (indeterminate, unverified) are stderr-only, printed beside the
row; the client sees the relay close its pipe and the row renders "not running" as today. That is
the honest reading of what the client can observe, and the plan adds no machine-readable remote
probe (extend-ipc scope, not needed for this issue's acceptance, which is local
doctoron thehost). Tests: silent listener on a local
R,EACCESon a localR, the existing cold-sockettest unchanged.
Sibling row:
probe(S)with the shared helper.Live-> awarnrow with tokendaemon-sibling; everyother variant -> no row, rendered as such without renaming (
IndeterminateandConnectedUnverifiedare "no row", never "absent"). Wording states only what was observed:nothing on the wire identifies a daemon instance (
DaemonStatuscarries version and protocol,no pid or endpoint), so
doctorcannot prove two answering paths are two daemons and must notsay so or recommend stopping one.
Slive: "a felis daemon also answers at; if it is a different daemon, itssessions are not visible from . Inspect with
felis --socket <S> sessions list."Livedetail (major skew, capacity) appended when present.
Slive: "no daemon at ; a felis daemon answers at". Factual; no claimabout why.
Read-only; never unlink. The operator recipe (confirm with
sessions liston both, retire withfelis --socket <path> daemon stop --when-empty) lives in the how-to (D), which the row does notrepeat.
Machine output:
daemon-siblingextends the stablechecktoken vocabulary, soCheckObject'sdoc comment in
cli_output.rslists it,just schemaregeneratescrates/felis-cli/schemas/felis-cli-v1.schema.json, and the regenerated file is committed (theschema-freshness test fails otherwise).
docs/reference/cli.md"Doctor" lists the token.Tests: both-live, primary-only (no row), sibling-only, alias (symlinked dir, no row),
refuse-major, unknown-status, accept-and-drop (
ConnectionAdmission::with_refusal_slots(0, 0):ConnectedUnverified, no row) andRefused AtCapacity(admission zero with refusal slots: rowwith detail), silent listener, accept-then-silent, indeterminate connect
error (no row),
--socketset,FELIS_SOCKETstamped,Rneither (no row), LinuxR == LM->probes
FB, LinuxR == FB-> probesLM, macOSR == LM-> probesFB(
cfg(target_os = "macos")), silent listener does not hangdoctor.A: make the fallback spawn audible where it can be
In step 5 of E, when spawning on
R == FB:warn!"the environment resolved the fallbackendpoint ( is unset, empty, or names the fallback directory); spawning a daemon there. If
this host runs a login manager, export for non-login shells so remote and local felis share
one daemon" with
<var>the OS's variable (XDG_RUNTIME_DIRon Linux,TMPDIRon macOS).Eligibility is endpoint-based, so an explicit
TMPDIR=/tmpon macOS isR == FBand is probedand warned like an unset one; the text makes no claim about how the variable got there. The
opt-out case (
Rneither) gets an info line naming the variable and the path, no warning: theenvironment was set on purpose. Topology tests include
TMPDIR=/tmpexplicitly set.Honest scope: relay stderr is inherited by the
sshchild (felis-transport/src/stdio.rs), soit reaches the terminal for CLI
--hostdials only. For a GUI-initiated retarget the felis-client'sstderr is not a terminal and the tracing subscriber does not capture the child's stderr, so
nothing is shown or logged there. A is a diagnostic, not the mechanism that satisfies the
acceptance; E (prevention) and C (detection) are.
D: document the remote-side situation
docs/how-to/attach-over-ssh.md"Operational notes":TMPDIRnote: current builds find the GUI daemon automatically (the relayprobes the login manager's location when the SSH login lacks the variable); the export recipe
stays as the remedy for older builds on the remote host or when the relay's stderr diagnostic
reports that the login manager's endpoint could not be verified.
hands the relay no
XDG_RUNTIME_DIR; current builds probe/run/user/<uid>/felisautomatically when the environment resolves the fallback
/tmp/felis.<uid>(noXDG_RUNTIME_DIR, andTMPDIRunset, empty, or/tmp); a customTMPDIRorXDG_RUNTIME_DIRis an opt-out and is used as given. On a host where/run/user/<uid>exists (systemd-logind created it, 0700, ownedby you), exporting
XDG_RUNTIME_DIR=/run/user/$(id -u)for non-login shells is still worthdoing because the relay's environment is carried into the sessions it creates (carrier block),
so the missing variable otherwise reaches the user's shells. Not presented as a non-systemd
remedy: a host without that directory has nothing to export and the fallback path is correct
there.
doctorpoints at: confirm which endpoint holds which sessions withfelis --socket <path> sessions liston both, then retire the unwanted one withfelis --socket <path> daemon stop --when-empty.B: remote-side socket flag paired with
--host-> rejectedPermanent CLI + carrier surface (extend-ipc) for a hatch the user must remember on every
invocation. E removes the common case; the custom-variable opt-out and D cover the rest.
Recorded as a rejected alternative in the carrier decision record (explanation/architecture/
ipc.md), not in non-goals.md (it does not pass the permanent-rejection gate; revisit if a
concrete host layout neither E nor the opt-out handles is reported).
Doc cascade
docs/reference/ipc.md"Cross-host carrier: SSH stdio": the relay's endpoint selection(explicit
--socket; elseR; else, whenR == FB,LM; the probe's outcome classes andwhat each leads to; the fresh, unverified, byte-transparent connection the relay then pumps and
the accepted probe-to-connect exposure). Normative facts only.
docs/explanation/architecture/ipc.mdcross-host carrier section: the decision record inline(why probe-only and relay-only, why not the default rule (freeze, #41 round 3), why no reverse
probe in the relay, why the custom-variable case is an opt-out, the residual race; rejected:
remote
--socketflag (B), changing the default, a cross-endpoint lock; Revisit if: the race isobserved, a non-systemd Linux runtime layout shows up, or #41 moves the macOS endpoint).
docs/explanation/architecture/control-surfaces.md"A session verb never spawns a daemonimplicitly": "a cold remote socket fails like a cold local one" becomes: "no daemon" is
reported only after every eligible endpoint (
R, andLMwhenR == FB) isAbsent; anIndeterminateendpoint or an unverifiedLMfails the dial without spawning, with the path inthe diagnostic; both linked to the normative rule in reference/ipc.md.
docs/reference/workspace.mdruntime-dir bullet: "Two processes whose environments disagreehere resolve different sockets and therefore different daemons" gets the relay/doctor exception,
the Linux SSH case beside the macOS one, and the empty-variable normalization.
docs/reference/spec.md: new REQ (next free id near REQ-009a) for the relay's endpointselection, sourced to
docs/reference/ipc.md"Cross-host carrier: SSH stdio" (the normativelane), with the explanation ipc.md section as the rationale link; REQ-009a wording unchanged (a
cold daemon still spawns on the dialing process's own resolved path).
docs/explanation/non-goals.md"Cross-platform constraints": the fifth platform shim, "theper-user daemon endpoint when the login manager's variable is missing", with the per-OS
LMand the Windows no-op; "None of the four" becomes "None of the five".
docs/reference/cli.md: "Carrier resolution order" paragraph gains the relay sentence and theempty-variable normalization; the
doctorsection documents thedaemon-siblingrow andtoken; the auto-spawn matrix notes that
relay --no-spawnprobesLMbefore reporting "nodaemon".
docs/explanation/security-model.md"Daemon IPC": one bullet stating the trust theLMproberelies on; nothing about scope.
docs/reference/security-audits.md(socket location claim, ~line 19-21): sockets live at theresolved default (
$XDG_RUNTIME_DIR/felis, the macOS confstr dir, or the/tmp/felis.<uid>fallback), each a 0700 per-user directory; the relay/doctor probe reads the other canonical
location under the same ownership assumption.
docs/how-to/attach-over-ssh.md: D above.skills/felis/SKILL.mdremote-verb matrix (line ~212-214): read/drive verbs exit 2 only whenneither
Rnor, when eligible,LMhas a daemon; and the "outside this skill" list(~line 241-244) that excludes
doctorgains the one exception an agent needs: when a remoteor local roster looks empty,
felis doctor --format jsonon the host and itsdaemon-siblingrow tell whether a second endpoint holds the sessions.
CHANGELOG.md: relay endpoint selection (user-affecting), the tightened indeterminate-errorhandling, empty
TMPDIRnormalization, doctor row, how-to note.felis-daemon --helptext forrelay --socket.LMprobe landed here and that #41'sdefault move remains its own decision.
XDG_RUNTIME_DIR,TMPDIR,felis.<uid>,no daemon on this host,--no-spawn,daemon-sibling.Acceptance
daemon at
LMwhen one is running there, for spawning and--no-spawndials alike, and spawnsnothing.
RandLMbothAbsent, it spawns (or fails under--no-spawn) onRas before, andthe spawn warns with the path on the relay's stderr.
ENOENT/ECONNREFUSEDonRorLM, or a listener onLMthatdoes not complete the felis handshake at probe time, ends the dial with a diagnostic naming the
path and never spawns or hangs. A peer swapped in after the probe has the frozen path's
existing exposure, documented, not claimed closed.
--socketon the relay and on the client stays an exact single endpoint; a customXDG_RUNTIME_DIR/TMPDIRis an opt-out (no probe, no row).doctordeadline and connect-error partition apply to the local Unix carrier's defaultendpoint;
doctor --host,--socket, stamped, and Windows targets keep today's dial anddial-failure renderings, with the relay's diagnostic on stderr over SSH. The "running, but ..."
arms for an unknown preface status and for
Refusedapply on every target.felis doctorwith daemons at bothRandSreportsSas awarnrow with the both-livewording; with
Rcold andSlive it reportsSwithout claiming two daemons; with--socket,--host, a stampedFELIS_SOCKET, orRneither it emits no sibling row; an alias, a silentlistener, an accept-and-drop daemon, or an indeterminate connect error yields no row and no
hang; a major-skewed or
Refused AtCapacitydaemon onSis reported with its detail; theprimary row reports an unverified or undialable
Raswarn, never as "not running".LM(unit-tested undercfg(target_os = "macos"), hand-verified end to end, recorded in the PR); on Windows therelay, topology, sibling row, deadline, and dial behavior are unchanged, and only the two new
rendering arms apply.
serveand local clients is unchanged except that an emptyTMPDIRresolves like an unset one (#12 freeze boundary).decision record, not claimed closed.
just schemais clean after thedaemon-siblingtoken is added;just checkpasses; the newtests exercise the spawn seam, not the test binary.
Plan v3 (supersedes comment 2794 / rev 12; PR #281 is reworked to this). Reviewed by pi (sol PASS at rev 5, luna PASS at rev 2-4). Daemon lifetime after endpoint loss is split out to #282.
Issue: #275. Supersedes the plan in comment 2794 (rev 12) and
the relay-probe implementation on PR #281. Daemon lifetime after endpoint loss (logout removing the runtime
directory under a running daemon, replacement sockets, exit cleanup, the agent link, stale stamps) is out of
scope here and tracked as #282; this plan only names the exposure it leaves.
Why the premise changed (issue-comment context only; none of this goes into docs)
Rev 12 kept the Linux default endpoint rule fixed because #41's triage (round-3 amendment) declared it frozen at
v0.1.0, and built a relay-side probe of the login manager's location around that. The premise does not hold: no
v0.1.0 tag exists (
git ls-remote --tagsis empty), #12's "Contract freeze boundary" lists environmentvariables and config defaults but never the endpoint derivation, and the freeze was our own triage decision, not
a shipped promise. Changing the derivation is the smaller and better fix: every process, relay included, lands on
the same socket by construction, and the probe, topology classification, endpoint pair, and most of the doctor
provenance plumbing disappear.
Decision
The endpoint is a function of the uid first, the environment second (
SocketPath::resolve, felis-transport;shared by
serve, local clients, and the relay; no caller-specific logic):$XDG_RUNTIME_DIR/feliswhen the variable is set and non-empty (XDG stays authoritative) →/run/user/<uid>/feliswhen that directory exists, is owned by the uid and is mode0700→${TMPDIR:-/tmp}/felis.<uid>$TMPDIR/felis.<uid>when the variable is set and non-empty (the GUI session's launchdTMPDIRis the confstr directory, so the GUI daemon does not move; a customTMPDIRstays honored as today) →<confstr(_CS_DARWIN_USER_TEMP_DIR)>/felis.<uid>(launchd's per-user0700directory) →/tmp/felis.<uid>when confstr failsResolution is lexical plus one
lstat(the/run/user/<uid>check); it never probes for a daemon and neverchecks whether an environment-selected directory exists (
ensure_dir_0700creates a missing one withcreate_dir_alltoday, and that stays).Empty
TMPDIR/XDG_RUNTIME_DIRnormalize to unset (todayTMPDIR=fails resolution outright). A relativeXDG_RUNTIME_DIRis also treated as unset, which is today's behavior kept on purpose (directoriesdrops anon-absolute value and the current resolver falls through to
${TMPDIR:-/tmp}; making it an error would stranda daemon at that fallback while every upgraded default command,
doctorincluded, failed before dialing); theresolver logs it at
warnonce per process. A relativeTMPDIRkeeps today's hard error ("TMPDIR is not anabsolute path"), raised only when that candidate is consulted: Linux consults
TMPDIRonly when thefallback is reached, so
TMPDIR=relativewith a usable/run/user/<uid>resolves the canonical path and is notan error; macOS consults
TMPDIRfirst and errors as today. Nothing is ever anchored to the workingdirectory (a daemon and a client would anchor against different directories and split). Tests: relative
XDG_RUNTIME_DIRresolves as if unset on a logind host (canonical) and on a non-logind host (/tmp), and theLinux
TMPDIR=relative+ usable/run/user/<uid>cell resolves without error;TMPDIR=relativein theconsulted position still errors on both OSes.
The rule, stated precisely: the platform's runtime-directory variable stays authoritative when set
(
XDG_RUNTIME_DIRon Linux,TMPDIRon macOS, where launchd uses it as the per-user runtime location); when itis absent, the canonical per-uid location replaces
/tmp. On LinuxTMPDIRis not a runtime-directory contractand only shapes the last fallback, so it loses to a usable
/run/user/<uid>. That is the one intentional breakbeyond "absent → canonical": a Linux daemon an older build started under
TMPDIR=/customwith noXDG_RUNTIME_DIRon a logind host is stranded at/custom/felis.<uid>; doctor's$TMPDIRcandidate (below)finds it from a shell carrying that
TMPDIR, and the CHANGELOG names the case. Preferring/run/user/<uid>over a custom
TMPDIRis deliberate: a login shell that exportsTMPDIRglobally would otherwise split fromthe logind daemon in exactly the way this issue describes.
TMPDIR=/tmpon Linux behaves as unset (it onlynames the fallback base, which is
/tmpanyway). The resolver tests enumerate, on both a logind host (usable/run/user/<uid>) and a non-logind host (absent), each ofXDG_RUNTIME_DIRset / unset / empty crossed withTMPDIRunset / empty //tmp/ custom, and assert the selected path for every cell; macOS the same withTMPDIRcrossed with confstr success / failure. Transition tests:/run/user/<uid>absent → usable (aprocess resolving after the directory appears selects it) and usable → invalid (resolution fails with the
recovery message, nothing falls back).
/run/user/<uid>validation, non-following and at resolution time only (the path can still disappear or bereplaced before connect or bind; that lifecycle race is #282):
lstatthe path (a symlink is rejected outright), itmust be a directory,
st_uidmust equal the uid, and the mode bits must be exactly0700(any group/other bitrejects). A
/run/user/<uid>that exists but fails validation is a hard resolution error naming the reason and therecovery ("
/run/user/<uid>exists but is not a0700directory owned by you; fix it, or setXDG_RUNTIME_DIR"): falling through to/tmpwould let an environment-less process autospawn a second daemonbeside a live one on
/run, the split this change removes. Only an absent/run/user/<uid>continues to${TMPDIR:-/tmp}. Selection is a resolver decision only: iffelis/inside the selected directory cannot be createdor bound at
servetime, that is the same hard bind error it is today (no second fallback at bind time, whichwould silently reintroduce the split); a client that dials a selected path that vanished sees
ENOENTand theordinary no-daemon / autospawn behavior.
Why not drop
XDG_RUNTIME_DIRentirely and use/run/user/<uid>only: XDG is the documented contract on Linuxand a user who points it elsewhere (a non-logind host, a test harness) expects felis to follow. The split this
issue is about comes from the variable being absent, not from it being set differently; keeping "set wins"
costs nothing for that case.
Why
/run/user/<uid>and not$HOME-rooted: sockets belong in a runtime location, not a persistent, backed-updirectory (#41 triage, same reasoning), and
/run/user/<uid>is what logind exports asXDG_RUNTIME_DIRonevery systemd host, so the common case resolves to the byte-identical path an upgraded daemon already listens on.
What this trades away, stated as a regression (#282):
/run/user/<uid>lives as long as the uid's logindsessions (or linger), and a daemon forked under it is unreachable once logind removes the directory. That is
already true of every
XDG_RUNTIME_DIRdaemon today. It is new for a daemon the relay spawns itself:today a relay without
XDG_RUNTIME_DIRspawns on/tmp, and that daemon stays reachable across logouts;after this change the same relay spawns under
/run/user/<uid>whenever the directory exists at that moment(a desktop or sshd session of the same uid, or linger), and if that directory is later removed by the last
logout the daemon keeps its sessions (REQ-008) and its attached windows but no new process can reach it, and
the next process starts a replacement at whatever the resolver selects at that moment (a
/run/user/<uid>that a later PAM-backed login recreated, else
/tmp). So the change replaces "the relay never sees the loginddaemon" (this issue) with "a relay-spawned daemon shares the logind daemon's lifetime exposure". That trade is
accepted here because the reachable-forever
/tmpdaemon was the split itself, and because the exposure hasan operator answer today (
loginctl enable-linger, which the how-to and the CHANGELOG state as the setting forhosts that serve persistent daemons over SSH) and a daemon-side answer in #282 (drain on loss,
replacement-safe cleanup, stale stamps). On a host with no logind session and no linger nothing changes: the
relay resolves
/tmpas today.The relay keeps its single-endpoint shape, with the connect-error hardening from PR #281.
felis-daemon relayresolves the default (or takes--socket) and connects. A raw connect that fails withio::ErrorKind::NotFoundorConnectionRefused(Unix:ENOENT/ECONNREFUSED; Windows: an absent pipeis
ERROR_FILE_NOT_FOUND, which maps toNotFound) is a cold socket:--no-spawnfails, otherwise spawn. Anyother raw-connect error (
PermissionDenied, resource exhaustion, ...) is not evidence of a cold socket: therelay exits with a diagnostic naming the path and the error and never spawns (
connect_error_is_absent, keptfrom #281, already written on
ErrorKind, applied to the single path; tests for a permission and aresource-exhaustion failure). The local client's
connect_or_spawn_daemongets the same rule for the rawconnect phase only. Today
ConnectError::Iois the?-converted error oflocal::connecton the localcarrier and of spawning the stdio child on the SSH carrier, while preface I/O is already
ConnectError::Preface(PrefaceExchangeError::Io);is_transientsays yes to all of them, andmay_be_a_cold_socketinherits that. The classification is applied by kind on the raw-connect error where itoccurs (the
open_carriersplit that #281'sdial_boundedalready introduced), and that arm is kept distinctfrom the stdio child's spawn failure (a missing
sshbinary isNotFoundtoo and must not read as a coldsocket; a dedicated
ConnectError::Connect(io::Error)arm is the straightforward way):ConnectwithNotFound/ConnectionRefused→ spawnable;Connectwith anything else → not spawnable, the dial ends withthe error; every post-connect failure (
Preface(Io),EofBeforeWelcome, ...) keeps today's spawnableverdict. The
systemd hand-off path (
spawn.rs,connect_managed) carries the same classified error instead of collapsingit to
None: a non-cold raw connect failure after the transient unit started is "must not spawn" and ends thedial with that error, never "hand-off failed, fork instead". Tests:
EACCESandEMFILE-class raw connect(no spawn) on the fork path and on the managed path (the fork callback is not invoked); an end-to-end
regression where a fake daemon accepts and exits mid-preface, and another that exits after
Acceptbut beforeWelcome, both still reaching spawn-and-retry. No probe, no sibling, no endpoint pair: the relay's environmentlacking
XDG_RUNTIME_DIRno longer matters because step 1 lands on/run/user/<uid>/felisanyway. A listeneron the resolved path that is not a felis daemon hangs the client's handshake as it does today on every
carrier; this is pre-existing (rev 12 also left the resolved path ungated) and stays out of scope, recorded as
such. The daemon's own startup probe (
socket_is_live, which today unlinks on any connect error) is #282.doctorkeeps a sibling row for the migration (the surviving part of C): on the local Unix carrier, afterthe
daemonrow, probe each sibling candidate that differs from the resolved endpoint (deduplicated aftercanonicalize): the endpoints the resolver can produce on this host from the current environment plus theenvironment-free ones, so a daemon reachable through another environment is found: the canonical runtime location (
/run/user/<uid>/felis/daemon.sockon Linux when thatdirectory exists; the confstr
felis.<uid>on macOS), always/tmp/felis.<uid>/daemon.sock, plus$TMPDIR/felis.<uid>/daemon.sockwhenTMPDIRis set, non-empty, and absolute (the old resolver's own validityrule; an empty or relative value is not a path an older build ever bound, so probing it could only report an
unrelated cwd-relative socket). Each uses the bounded four-outcome probe already on PR #281; a felis daemon
answering yields one
warndaemon-siblingrow per candidate with the observable-fact wording ("a felis daemonalso answers at ; if it is a different daemon, its sessions are not visible from . Inspect with
felis --socket <path> sessions list"). This finds the daemon an older build's Tailscale relay spawned on/tmpon Linux, on macOS the one every older SSH login spawned there (where the GUI'sTMPDIRequals the newresolved path, so only the always-included
/tmpcandidate catches it), and a daemon on the canonical pathfrom a shell whose environment now resolves a custom directory. Provenance, with
Dthe freshly resolveddefault and
Lthe sibling candidates: the targetTisDefault(T == D), aFELIS_SOCKETstamp (everyshell inside a felis session carries one, so this is the ordinary place to type
felis doctor), or--socket. Provenance is checked first: an explicit--sockettarget never gets sibling reasoning (no note,no row), even when it equals
D. ForDefaultandStampedtargets:T == D→ probeL \ {D};T ∈ L(the stamp points at a stranded location, e.g.
/tmp/felis.<uid>) → thedaemonrow's detail notes "thisshell targets , which is not the endpoint processes without
FELIS_SOCKETresolve (); commands typed inthis shell keep targeting unless run with
env -u FELIS_SOCKETor--socket <D>" (the note states thetarget, not that a daemon listens there; whether one does is the
daemonrow's own outcome), andDisprobed; a live daemon at
Dis reported in thedaemon-siblingrow with wording keyed to the primaryoutcome: "a felis daemon also answers at ; if it is a different daemon, ..." only when the
daemonrowfound
Tlive, otherwise "this shell targets , where no daemon answered (); a felis daemonanswers at , the endpoint processes without
FELIS_SOCKETresolve"; a stamp that is neitherDnor inL→ no note and no row. WhenTis stamped or explicit and the fresh default resolution fails (an invalid/run/user/<uid>, a relativeTMPDIRin the consulted position), doctor keeps its primary row onT, skipsthe sibling probes, and adds the resolution error to the
daemonrow's detail ("the default endpoint cannotbe resolved: "); the exit status stays what the primary row dictates. Test: stamped
Tlive plus aninjected default-resolution failure. Documented limit: a daemon started under a custom
TMPDIRor a customXDG_RUNTIME_DIRthat the current shell does not carry is not discoverable (its path is not derivable withoutthe variable); the how-to says to run
felis doctorfrom a shell that carries that variable (withenv -u FELIS_SOCKETwhen typed inside another felis session, so the stamp does not select a different daemon), orfelis --socket <path> sessions listdirectly. The "unnameableendpoint" row, the reverse (runtime-dir) probe, and the custom-variable opt-out logic go away.
Keep from PR #281, because they are correct on their own: the bounded probe helper and its
Absent | Indeterminate | ConnectedUnverified | Liveresult (used by doctor only),dial_boundedand thephase-aware doctor timeout, the
Refused/DaemonPreface::Unknown"running, but ..." doctor arms, thedaemon-siblingschema token, the macOSconfstrFFI (now used by the resolver), the empty-variablenormalization, the how-to's drain recipe (
sessions liston both, thendaemon stop --when-empty).Drop from PR #281:
EndpointTopologyand the LM/FB/custom classification,RelayEndpoint, the relay'ssibling selection and its diagnostics (the connect-error classification stays, see 2), the "fifth platform
shim" in non-goals.md, REQ-009b's relay rule, the relay-selection text in reference/ipc.md,
control-surfaces.md, cli.md, skills/felis, CHANGELOG; the A warning on fallback spawn (the fallback is now the
rare non-logind case, and the resolver's
warn!on a skipped/run/user/<uid>covers the one surprisingpath); option B stays rejected (recorded in the explanation as before).
Compatibility
XDG_RUNTIME_DIRset → unchanged path; unset →/run/user/<uid>/felis, thesame path the logind-started daemon already binds. An upgraded client never misses a daemon a logind session
started.
/run/user/<uid>):/tmp/felis.<uid>as today.TMPDIRis the confstr dir). macOS SSH: moves from/tmp/felis.<uid>to theGUI's daemon, which is the fix #41 asked for; #41 is closed by this change (its option A, macOS half).
/tmp/felis.<uid>under an envwithout
XDG_RUNTIME_DIR(the Tailscale case) or withoutTMPDIR(every older macOS SSH login): doctor'salways-included
/tmpcandidate finds it; (b) Linux only: one an older build spawned under a customTMPDIRwith no
XDG_RUNTIME_DIRon a host that has a usable/run/user/<uid>(the intentional break above): foundwhen
doctorruns with thatTMPDIR, otherwise byfelis --socket <path> sessions list; the CHANGELOG namesthis case. macOS honors a set
TMPDIR, so it has no case (b). (c) Linux without linger: a daemon started on/tmp/felis.<uid>while no logind session existed is stranded once a login creates/run/user/<uid>; doctor's/tmpcandidate finds it. (d) Linux without linger, the reverse: a forked daemon under/run/user/<uid>whosedirectory logind removed at last logout keeps its sessions for the windows still attached and is not
reachable by any new process. For a logind-started daemon this is today's behavior; for a relay-spawned daemon
it is a regression against today's
/tmpfallback (Decision 1 states the trade), and daemon-side handling is#282. The how-to's SSH section and the CHANGELOG tell hosts that serve persistent daemons to
loginctl enable-linger, which removes both (c) and (d). (a)–(c) are drained with the how-to recipe; (d) is notdrainable (no path reaches it) and is why the linger note is not optional.
the first tag rather than inferred later.
Security
The check (
lstat, no symlink, a directory, uid matches, mode exactly0700) protects the environment-free/run/user/<uid>candidate, the one felis picks on its own; an explicitly suppliedXDG_RUNTIME_DIRkeeps theexisting environment-authoritative trust model (the login manager set it, and
ensure_dir_0700createsfelis/inside it as today). The confstr directory is launchd's per-user
0700directory, the same trust the GUI'sTMPDIRalready carries.security-model.md"Daemon IPC" states the check;security-audits.mdupdates thesocket-location claim.
Docs cascade
docs/reference/cli.md"Carrier resolution order" and--socketdefault: the new per-OS order; "Doctor": thedaemon-siblingrow and token (the sibling candidate list).docs/reference/workspace.mdruntime-dir bullet: rewritten to the new order; the "different environments →different daemons" sentence becomes "different
XDG_RUNTIME_DIRvalues → different daemons; an absent variableno longer does".
docs/reference/ipc.md"Cross-host carrier: SSH stdio": remove the probe text; one sentence that the relayresolves the same endpoint as every local process that uses the same effective default (a process whose
XDG_RUNTIME_DIR/TMPDIRnames a different directory still selects a different daemon, as today).docs/how-to/attach-over-ssh.md"Single daemon per user" ("all--hostconnections to a given user accountshare a single daemon instance") is qualified the same way: connections that resolve the same default share
one daemon; a custom runtime variable on the remote login is the exception.
docs/explanation/architecture/ipc.md(orsecurity-model.md"Daemon IPC", whichever owns the endpointrationale today): the decision record, in present tense and without revision history: an absent runtime variable
resolves to the canonical per-uid location; rejected alternatives: probing the login manager's location from the
relay while leaving the derivation alone (a second mechanism to reach one daemon, needing a multi-outcome
classification and leaving two races open),
$HOME-rooted directories (persistent, backed up), a remote--socketflag, warn-only; Revisit if: logind stops exporting/run/user/<uid>, or macOS drops_CS_DARWIN_USER_TEMP_DIR. The same record states the runtime directory's lifetime as a known limit (a daemonunder it outlives its reachability; linger is the operator's answer) without promising daemon-side handling.
docs/reference/spec.md: replace REQ-009b with the derivation rule, sourced to cli.md / workspace.md; theraw-connect classification becomes a numbered requirement (an amendment to REQ-009a or a new REQ next to it),
stated in two clauses so it does not contradict Decision 2: (i) when the raw connect to the resolved endpoint
fails, only
io::ErrorKind::NotFoundorConnectionRefused(UnixENOENT/ECONNREFUSED, WindowsERROR_FILE_NOT_FOUNDfor an absent pipe) licenses a spawn; any other raw-connect error ends the dial with thaterror, on the fork path, the systemd hand-off path, and the relay; (ii) a failure after the raw connect
succeeded (preface I/O, EOF before
Welcome) keeps the existing spawn-and-retry behavior. Sourced todocs/reference/cli.md"Auto-spawning", which defines "cold socket" in those terms. The daemon's own startupprobe is not covered by this requirement (#282).
docs/reference/ipc.md"Cross-host carrier" reconnect guarantee ("the per-UID daemon keeps every PTY running,and a reconnecting client reattaches to the still-live session") and
docs/explanation/architecture/session-lifecycle.md(forking "keeps the remote-attach host working: ... theSSH relay's forked daemon survives" the last logout): both are qualified. The process and its sessions survive;
a new connection needs the daemon's runtime directory to still exist, which for
/run/user/<uid>means alogind session or linger; hosts that serve persistent daemons enable linger. The explanation names the forked
daemon under
/run/user/<uid>as the case the manager-started daemon and the forked one now share, instead ofcontrasting them. The same qualification lands on every other unconditional reconnect or agent-forwarding
promise:
docs/how-to/attach-over-ssh.md("leaves remote sessions intact ... To reattach afterwards" and"On reconnect, agent access resumes automatically"),
docs/explanation/non-goals.md(the mosh-style entry's"reconnecting over SSH and reattaching to it already recovers the work"),
skills/felis/SKILL.md("agentforwarding survives the SSH connection that opened it"): each gains the runtime-directory condition in one
clause and, in the how-to, the linger instruction for persistent Linux SSH hosts.
docs/explanation/non-goals.md: revert the fifth-shim entry ("None of the four" stays).docs/how-to/update-felis.md: the update sequence currently installs the new binary before listing anddraining, which under the new resolver can point
sessions listanddaemon stop --when-emptyat the newcanonical endpoint while the old daemon still holds the sessions. Rewrite the sequence for this upgrade: after
installing, before any window or spawning command, run
env -u FELIS_SOCKET felis doctor(the unset matters:every felis shell carries a stamp, and inside a legacy daemon's shell that daemon would be the primary row, or
no row at all if the new default is cold); act on both signals, a
daemon-siblingrow and a note on thedaemonrow: for each old endpoint named, inspect withfelis --socket <path> sessions listand drain withfelis --socket <path> daemon stop --when-empty(or keep working against it with--socketuntil it isempty); only then launch. The remote sequence runs the same commands on the host itself, because
--hostcannot name a remote socket (
--socketis local-only and mutually exclusive with--host):ssh <h> env -u FELIS_SOCKET felis doctor, thenssh <h> felis --socket <path> sessions listandssh <h> felis --socket <path> daemon stop --when-empty. A short "why" ties it to the endpoint change in this release.docs/how-to/attach-over-ssh.md: delete the macOSTMPDIRrecipe and the LinuxXDG_RUNTIME_DIRrecipe asrequirements; keep one "older builds on the remote host" note, the drain recipe (with the
env -u FELIS_SOCKET/
--socket <D>distinction for commands typed inside a legacy daemon's shell), and a "persistent daemon on aserver:
loginctl enable-linger" note tied to the runtime directory's lifetime.docs/reference/security-audits.md,docs/explanation/security-model.md: as above.skills/felis/SKILL.md: remote-verb matrix back to its pre-#281 wording plus the doctor sibling row.--helpstrings:felis-daemon(serve/relay --socket, crates/felis-daemon/src/main.rs) andfelis-client(
--socket, crates/felis-client/src/main.rs) describe the new default order; help snapshots/tests updated.CHANGELOG.md: "daemon endpoint no longer depends onXDG_RUNTIME_DIR/TMPDIRbeing set; a daemon an olderbuild started on
/tmp/felis.<uid>keeps running:felis doctorreports it, drain with ..."; doctor row; emptyvariable normalization; autospawn hardening (a raw connect failing with anything but not-found / refused no
longer starts a daemon, on the fork path, the systemd hand-off, and the relay); the linger note for persistent
SSH hosts.
its option A; option C's observability is the doctor row).
XDG_RUNTIME_DIR,TMPDIR,felis.<uid>,/run/user,daemon-sibling,login manager,LM,FB,topology,REQ-009b,reattaches,survives,last logout,linger,single daemon,share a single daemon,same daemon.Tests
socket.rs: resolver order per OS with injected env and an injectedstatresult for/run/user/<uid>(exists+ours+0700 → used; exists but wrong owner / wrong mode / not a directory / a symlink → hard resolution
error carrying the recovery guidance, no fallback, and the autospawn sites are shown never to spawn on it;
absent → tmp fallback; XDG set → wins; empty → unset; relative XDG → unset, as today);
cfg(target_os = "macos")for the confstr success andfailure paths (pure judgment function already extracted on #281).
relay.rs: the pre-#281 tests plus one: a relay whose environment lacksXDG_RUNTIME_DIRresolves the samepath as one that has it, given an injected
/run/user/<uid>(compareSocketPath::resolveoutputs; no daemonneeded).
cli_doctor.rs: sibling row for a live daemon on/tmp/felis.<uid>, on a$TMPDIRcandidate, and on thecanonical path when the environment resolves a custom directory; no
$TMPDIRcandidate for an empty orrelative
TMPDIR; dedupe when a candidate equals the resolved path; row for a stamp equal to the default; noteplus a probe of
Dfor a stamp on/tmpand for a stamp on a custom-TMPDIRpath; none for--socketor astamp elsewhere; none for cold / silent listener / indeterminate.
relay.rsandconnect_or_spawn_daemon:EACCESandEMFILE-class connect errors do not spawn, on the forkand the managed path.
just check,just check-windows.Acceptance
felis ssh <tailscale host>from a window andfelis --host <tailscale host> sessions listreach the daemonthe logind session started, without any
XDG_RUNTIME_DIRin the relay's environment.TMPDIR(unit-tested via confstr; handverification recorded in the PR if a Mac is available, otherwise stated as not done).
felis doctoron a host with a stranded/tmp/felis.<uid>daemon reports it; nothing is unlinked./run/user/<uid>becomes unreachable when logind removesthe directory (Compatibility (d)); the how-to's linger note and the CHANGELOG carry it; #282 owns the fix.
just checkandjust check-windowspass.