Documentation
¶
Overview ¶
Package sessiondir owns the on-disk identity of a session: which name it holds, where its sockets live, whether the process that claimed it is still alive, and how it ended.
A name is owned on both roots, not just the runtime one. The two roots move independently: on Linux a login session and a cron job get different XDG_RUNTIME_DIRs and the same state directory, so a name guarded only under RuntimeRoot would let two hosts each hold "their" lock and publish into one results/<name>/session.json — and once the first ended, its exit code is what a reader finds for the second, still running.
So there are two registry locks, and exactly one order between them: the sessions registry (RuntimeRoot/sessions/.registry.lock) is taken before the results registry (StateRoot/results/.registry.lock), and no function takes the sessions registry while holding the results registry. Claim takes both, in that order; Release and Reap take only the sessions registry; Inspect and Prune take only the results registry, which is why neither is given a runtime root at all. A per-name lock is only ever acquired under the registry that guards the directory it lives in, because a lock inside a directory cannot protect that directory from removal.
Index ¶
- Constants
- Variables
- func AdminSocketPath(runtimeRoot, name string) (string, error)
- func CheckSocketPath(runtimeRoot, name string) error
- func GenerateName(command []string) string
- func Prune(ctx context.Context, stateRoot string, olderThan time.Duration) error
- func Reap(ctx context.Context, runtimeRoot string) error
- func ResultsRoot(stateRoot string) string
- func SessionsRoot(runtimeRoot string) string
- func ValidateName(name string) error
- type ClaimOptions
- type Dir
- func (d *Dir) AdminSocket() string
- func (d *Dir) AttachSocket() string
- func (d *Dir) LaunchID() string
- func (d *Dir) LogPath() string
- func (d *Dir) Name() string
- func (d *Dir) Record() Record
- func (d *Dir) RecordPath() string
- func (d *Dir) Release(ctx context.Context) error
- func (d *Dir) StartedAt() time.Time
- func (d *Dir) Update(mutate func(*Record)) error
- type Record
Constants ¶
const ( StatusStarting = "starting" StatusReady = "ready" StatusDisconnected = "disconnected" StatusEnding = "ending" )
Session status, published in the single session record.
A held lock says a process owns the name; it says nothing about whether that process got anywhere, which is why this exists separately.
const ( ReasonUnknown = "unknown" ReasonExited = "exited" ReasonSignaled = "signaled" ReasonStopped = "stopped" ReasonCanceled = "canceled" // ReasonJoinTimeout means no guest joined before the deadline; this is a successful session end. ReasonJoinTimeout = "join_timeout" ReasonStartupFailed = "startup_failed" ReasonStartupAbandoned = "startup_abandoned" )
Outcome reasons, published in the single session record.
ReasonUnknown is both the value written when a name is claimed and the value that survives a process which never replaced it. It is what lets a caller tell "we do not know how this ended" from "no such session", which is an absent record — and an absent record means only "not found within retained history", since Prune removes anything unheld and older than RecordRetention.
const RecordRetention = 7 * 24 * time.Hour
RecordRetention is how long a record outlives the session that wrote it. Nothing sweeps in the background, so this is only as true as the next call to Prune.
Variables ¶
var ( // ErrNameInUse is returned when a name is held by a live session. ErrNameInUse = errors.New("sessiondir: name is in use by a running session") // ErrInvalidName is returned for a name that is not a single safe path // component. ErrInvalidName = errors.New("sessiondir: invalid session name") // ErrSocketPathTooLong is returned when a name is legal but would put the // session's sockets past what a unix socket address can hold. ErrSocketPathTooLong = errors.New("sessiondir: session socket path is too long") )
Functions ¶
func AdminSocketPath ¶
AdminSocketPath returns a named session's admin socket without claiming it, so a caller can look up a session it does not own.
func CheckSocketPath ¶
CheckSocketPath reports whether a name's sockets would fit in a unix socket address under runtimeRoot. The name is expected to have passed ValidateName already.
It measures attach.sock, the longest name a session puts in its directory, rather than admin.sock, the one bound first. Both hang off the same parent, so the shorter fitting says nothing about the longer: a name checked against admin.sock could be accepted here and still be one byte too long to bind, with the name already claimed and its directory already made. One byte of name is the whole cost, and the error quotes the path it measured so that byte is visible to whoever has to shorten something.
Length is a property of the name *and* where it lives, so it cannot be folded into ValidateName: the same name is fine under /run/user/1000 and impossible under ~/Library/Application Support. It is exported so the CLI can refuse an over-long --name at flag validation, because the alternative is a bind failure minutes later, after the tunnel is already up.
func GenerateName ¶
GenerateName derives a readable, collision-resistant name from a command. It always returns something ValidateName accepts.
func Prune ¶
Prune removes records that nobody holds and that nothing has written to for olderThan.
Under the results registry, so a directory cannot be removed out from under a Claim that is midway through taking it — the same reason Reap runs under the sessions registry. A held name is skipped whatever its record says: a session that has been up longer than the retention window is still publishing into that directory.
One bad entry is not a failed prune. A record that cannot be read or parsed is left alone rather than dated by guesswork, and a removal that fails is left for the next call; only failing to take the registry or to list the directory at all is an error, since neither says anything about one entry.
func ResultsRoot ¶
ResultsRoot returns the directory session records live in, which is where ListLive finds the sessions that exist. Exported for the reason SessionsRoot is: `session list` says in its help where the names it prints came from, and that is this directory rather than any one runtime root.
func SessionsRoot ¶
SessionsRoot returns the directory session runtime directories live in.
func ValidateName ¶
ValidateName reports whether name is a single safe path component.
Types ¶
type ClaimOptions ¶
type ClaimOptions struct {
RuntimeRoot string
StateRoot string
Name string
Command []string
ForceCommand []string
}
ClaimOptions configures a claim.
type Dir ¶
type Dir struct {
// contains filtered or unexported fields
}
Dir is a claimed session name and the paths that belong to it. The claim lasts as long as its two lock files stay open: one per root, because a name is owned on both. See the package comment for the order they are taken in.
func Claim ¶
func Claim(ctx context.Context, opts ClaimOptions) (*Dir, error)
Claim takes ownership of a name and publishes its initial state and outcome.
Everything here happens inside one critical section held on a registry lock in the parent directory. Two reasons, both learned the hard way:
A lock inside the session directory cannot protect the session directory. A reaper can remove the directory after the claimer has locked a file within it, leaving the claimer holding a valid lock on an unreachable inode and the name free for a third process.
And the initial result must be published before the lock is released. If it were written afterwards, a process that claimed the name and then died would leave the previous run's exit code sitting there looking current — reporting success for a run that never started.
The same two reasons apply to the results side, which is claimed the same way under its own registry: the name is not ours until both roots say so, and the record is published before either registry is given back.
func (*Dir) AdminSocket ¶
func (*Dir) AttachSocket ¶
func (*Dir) RecordPath ¶
func (*Dir) Release ¶
Release gives the name back on both roots and removes the runtime directory. The results directory is deliberately left behind, only its lock dropped: it holds the outcome, which has to outlive the session that produced it, and it goes when Prune finds it old enough rather than when the session ends.
Call it at most once, and call nothing else on the Dir afterwards. Both rules exist because the name is free the instant this returns and a successor may already own it: a second Release would RemoveAll the successor's directory, and an Update after Release would overwrite the successor's record with this run's outcome. Neither is detectable from here — the successor's files are at the same paths, which is the point of a name — so the ordering is the caller's to keep.
The lock is always dropped, even when the registry lock cannot be had. A teardown that can block forever is worse than a directory left behind: the directory is reapable by anyone later, whereas a Host.Run that never returns is not recoverable from outside. ctx bounds the wait; the caller may pass a shorter one.
type Record ¶
type Record struct {
Name string `json:"name"`
LaunchID string `json:"launch_id"`
// AdminSocket is where the session's admin socket is bound, under the
// runtime root it claimed its name with. A reader under another runtime
// root — a login shell asking about a cron job's session — shares only
// the record with it, and a path rebuilt under the reader's own root
// names a socket nothing answers at. Forced on every publish, like Name
// and LaunchID, so no caller can leave it out.
AdminSocket string `json:"admin_socket,omitempty"`
// AttachSocket is where the session's local terminal door is bound, beside
// the admin socket and under the same runtime root, for the same reason
// AdminSocket is recorded: `upterm attach` from a shell under a different
// XDG_RUNTIME_DIR must dial the socket that exists, not one rebuilt under
// its own root. Forced on every publish.
AttachSocket string `json:"attach_socket,omitempty"`
// HostKeys is the public half of the key the attach door presents, in
// authorized_keys form. One entry: the session host key the daemon
// generated for this run. `upterm attach` pins it: the socket's
// permissions are what keep another process from binding that path, and
// this is what catches one that managed to. A slice because the record's
// shape predates the single key and older attach clients read it.
// Unlike AdminSocket and AttachSocket it is not forced on every publish —
// Dir has no key of its own — so the daemon supplies it once it has one.
HostKeys []string `json:"host_keys,omitempty"`
SessionID string `json:"session_id,omitempty"`
Command []string `json:"command,omitempty"`
ForceCommand []string `json:"force_command,omitempty"`
StartedAt time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
// omitzero, not omitempty: omitempty does nothing for a struct, so a
// record that has not finished carried finished_at: "0001-01-01T00:00:00Z"
// — a date, and one a reader could easily take for a real one.
FinishedAt time.Time `json:"finished_at,omitzero"`
// FirstGuestJoinedAt is when a guest first joined this session, or the
// zero time when none ever has. It latches: the first join writes it and
// no later join moves it.
//
// Published because "has anyone ever joined?" cannot be answered from
// connected_clients, which is a current list. A reader that polls it
// misses a guest who joins and leaves between two ticks -- the bug
// action-upterm has today, latching in a local variable of its own
// poller. One sticky fact with one owner replaces every consumer's
// private latch.
//
// Guests only: the host's own attached terminal is a client of its own
// session, so counting clients would make every foreground session look
// joined from the moment it starts.
//
// omitzero for the reason FinishedAt is: omitempty does nothing for a
// struct, and a zero time serialises to a date a reader could take for a
// real one.
FirstGuestJoinedAt time.Time `json:"first_guest_joined_at,omitzero"`
Status string `json:"status"`
Reason string `json:"reason"`
ExitCode *int `json:"exit_code,omitempty"`
Signal string `json:"signal,omitempty"`
SignalNumber *int `json:"signal_number,omitempty"`
// Pid is the process that claimed the name. Set by Claim and never by a
// caller: the claimer is the owner by definition, and a reader who finds
// the name held but its socket silent needs a process to name.
Pid int `json:"pid,omitempty"`
// LogPath is where the session's process writes its log, published by
// the process that knows, for the reason AdminSocket is: the reader's
// state root need not be the daemon's.
LogPath string `json:"log_path,omitempty"`
}
Record is a session's status and outcome, in one file.
One file, not two, because a reader takes no lock. Status and outcome in separate files could be read across a launch boundary — new status, previous run's exit code — and the combination "ready, exited 0" is both plausible and completely wrong. A single atomic rename makes that impossible instead of unlikely. LaunchID is what lets a caller confirm which run it is looking at.
func Inspect ¶
Inspect reads a name's record and ownership as one observation.
Both under the results registry lock, because they are facts about one thing and a caller that reads them separately can mix generations: read A's record, then observe B's ownership. Under that lock a name is either unclaimed or fully published, since Claim holds the results registry across the results lock and the initial publication — so held implies rec != nil, and a caller need not defend against a record that vanished between the two reads.
Ownership is the lock beside the record and nothing else. That lock is held by whoever is publishing into this record, whatever runtime root they claimed under, which is why a holder this caller cannot otherwise see is still reported. The runtime lock cannot stand in for it and is not consulted: it says only that *someone* holds this name under this runtime root, and that someone may be a different session with a different state root. Taking the disjunction of the two let a fresh claim under (R, stateA) lend its liveness to a SIGKILLed session's record under (R, stateB), which reported a dead session as ready and its name as held by it.
So there is no runtime root in this signature. One used to be taken, for symmetry with Claim, and read by nothing: a parameter this function may not consult is an invitation to go back to consulting it.
func ListLive ¶
ListLive returns the record of every session that exists right now, in name order.
The set is the names whose results lock is held, because that lock is the one thing every live session has wherever it was started: a host under a different XDG_RUNTIME_DIR — which Linux gives a login session and denies a cron job — publishes into the same results root and leaves nothing under this one. A caller that walked a runtime root instead would answer "no sessions" for a session it could then look up by name.
Held names only, and ownership comes from the lock for the reason Inspect gives: a record says what its owner last published, which after a SIGKILL is frequently "ready".
One bad entry is not a failed listing, the same way it is not a failed Prune: a record that cannot be read or parsed is skipped, since a listing that fails entirely over one unreadable name tells the user less than one that is short by a row.
func ReadRecord ¶
ReadRecord reads a session's record by name. It works after the session is gone, which is the point: an admin socket dies with its process, so polling it can never answer "did it finish".
A record that exists says nothing about liveness — normal shutdown leaves "ending" behind and a SIGKILL leaves whatever was last written. Use Inspect, which reads the record and ownership together, before trusting Status.