Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions internal/session/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,22 @@ func (r *Registry) start(opts SpawnOpts, id string, preload []byte, restore Info
argv = []string{shell, "-l"}
}

// An empty Cwd is a caller with no preference, and it must not fall
// through to the daemon's own directory: a service-started daemon runs at
// / — neither unit in internal/service sets a working directory — and a
// hand-started one runs wherever it happened to be launched from, so
// every default session would open somewhere nobody chose. Home is what
// a terminal emulator or sshd would have picked, and it is where Revive
// already lands a session whose directory has vanished. When even home is
// unknowable the empty string stands and the shell inherits — the old
// behaviour, kept as the floor rather than the default.
cwd := opts.Cwd
if cwd == "" {
cwd, _ = os.UserHomeDir()
}

cmd := exec.Command(argv[0], argv[1:]...)
cmd.Dir = opts.Cwd
cmd.Dir = cwd
cmd.Env = sessionEnv(os.Environ(), shell)

cols, rows := opts.Cols, opts.Rows
Expand All @@ -184,7 +198,10 @@ func (r *Registry) start(opts SpawnOpts, id string, preload []byte, restore Info
size = DefaultRingSize
}

cwd := opts.Cwd
// The recorded cwd is the value resolved above, so what Info reports is
// where the shell actually started rather than a second opinion. It is
// only still empty when home was unresolvable, and then the child
// inherited the daemon's directory — so that is what gets recorded.
if cwd == "" {
cwd, _ = os.Getwd()
}
Expand Down
79 changes: 79 additions & 0 deletions internal/session/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package session
import (
"strings"
"testing"
"time"
)

func TestLoginShellPrefersSHELL(t *testing.T) {
Expand Down Expand Up @@ -82,6 +83,84 @@ func TestSessionEnvKeepsAnExistingSHELL(t *testing.T) {
}
}

// TestSpawnDefaultsCwdToHome pins where a session with no stated directory
// opens: the user's home, like any terminal emulator or sshd. The alternative
// — inheriting the daemon's own directory — is the launchd/systemd bug: a
// service-started daemon runs at /, so every plain new session opened there.
// os.UserHomeDir answers from $HOME on every platform flue targets, so
// setting it is setting the expectation. Both the recorded cwd and the
// child's real one are checked, because the fix is one resolution feeding
// both — a seed that disagreed with the process would be the old split back
// again. The poll and the symlink resolution are cwd_test.go's, for
// cwd_test.go's reasons.
func TestSpawnDefaultsCwdToHome(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
want := resolved(t, home)

r := NewRegistry(nil)
s, err := r.Spawn(SpawnOpts{Cmd: []string{"sleep", "5"}, Cols: 80, Rows: 24})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
t.Cleanup(func() { _ = s.Close() })

s.mu.Lock()
seeded := s.info.Cwd
s.mu.Unlock()
if seeded != home {
t.Fatalf("recorded Cwd = %q, want the home %q", seeded, home)
}

deadline := time.Now().Add(5 * time.Second)
for {
got, err := processCwd(s.pid)
if err == nil && resolved(t, got) == want {
return
}
if time.Now().After(deadline) {
t.Fatalf("processCwd(%d) = %q, %v; want %q", s.pid, got, err, want)
}
time.Sleep(5 * time.Millisecond)
}
}

// TestSpawnKeepsExplicitCwd is the other half of the default: a caller that
// states a directory gets that directory, and home never competes with it.
// The stated one is deliberately not $HOME, so a spawn that reached for home
// anyway could not pass by coincidence.
func TestSpawnKeepsExplicitCwd(t *testing.T) {
t.Setenv("HOME", t.TempDir())
dir := t.TempDir()
want := resolved(t, dir)

r := NewRegistry(nil)
s, err := r.Spawn(SpawnOpts{Cwd: dir, Cmd: []string{"sleep", "5"}, Cols: 80, Rows: 24})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
t.Cleanup(func() { _ = s.Close() })

s.mu.Lock()
seeded := s.info.Cwd
s.mu.Unlock()
if seeded != dir {
t.Fatalf("recorded Cwd = %q, want the stated %q", seeded, dir)
}

deadline := time.Now().Add(5 * time.Second)
for {
got, err := processCwd(s.pid)
if err == nil && resolved(t, got) == want {
return
}
if time.Now().After(deadline) {
t.Fatalf("processCwd(%d) = %q, %v; want %q", s.pid, got, err, want)
}
time.Sleep(5 * time.Millisecond)
}
}

func contains(env []string, kv string) bool {
for _, e := range env {
if e == kv {
Expand Down