close
tool for detachable ptys with optional tailscale discovery
28

Configure Feed

Select the types of activity you want to include in your feed.

autocreate sessions on hosts with no sessions + surface errors on failed session creation #1

Merged opened by okami.mom targeting main from [deleted fork]: main

on the picker the cursor only indexed sessions, and the filter only searched them, so a machine with nothing running could neither be arrowed to nor searchable by name. so now

  • hosts are selectable rows while unreachable/offline ones are drawn but skipped
  • enter on a bare host starts a login shell there (${SHELL:-/bin/sh}, resolved on the target)

also print stderr when a session is created

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3rwz3xfw2crswgifqgc3g7zh/sh.tangled.repo.pull/3mvze5pbbl722
+255 -56
Diff #0
+49
internal/app/app_test.go
··· 9 9 10 10 "github.com/charmbracelet/bubbles/spinner" 11 11 "github.com/charmbracelet/bubbles/textinput" 12 + tea "github.com/charmbracelet/bubbletea" 12 13 "tobi/internal/session" 13 14 ) 14 15 ··· 17 18 func TestViewNeverExceedsWidth(t *testing.T) { 18 19 for _, width := range []int{0, 30, 40, 60, 76, 100, 300} { 19 20 f := textinput.New() 21 + f.Width = max(0, min(width, maxUIWidth)-8) 22 + f.SetValue(strings.Repeat("nomatch", 20)) // a filter nothing matches, to cover the empty state 20 23 m := pickerModel{width: width, filter: f, spinner: spinner.New(spinner.WithSpinner(spinner.Dot)), rows: []hostRow{ 21 24 {name: "selfish", local: true, sessions: []session.Session{{ID: "a", Command: "hx", Cwd: "/tmp", StartedAt: time.Now()}}}, 22 25 {name: "caladon", sessions: []session.Session{{ID: "b", Command: "nu", Cwd: "/x", StartedAt: time.Now()}}}, ··· 29 32 } 30 33 } 31 34 } 35 + 36 + func TestHostsAreSelectableAndSearchable(t *testing.T) { 37 + newModel := func(filter string) pickerModel { 38 + f := textinput.New() 39 + f.SetValue(filter) 40 + return pickerModel{width: 76, filter: f, spinner: spinner.New(spinner.WithSpinner(spinner.Dot)), rows: []hostRow{ 41 + {name: "astaroth", local: true}, 42 + {name: "baal", unreachable: true}, 43 + {name: "valefar", sessions: []session.Session{{ID: "abc", Host: "valefar", Command: "hx", StartedAt: time.Now()}}}, 44 + {name: "homura", peerOffline: true}, 45 + }} 46 + } 47 + 48 + m := newModel("") 49 + want := []pickEntry{{host: "astaroth"}, {host: "valefar"}, {host: "valefar", sess: &session.Session{ID: "abc"}}} 50 + got := m.entries() 51 + if len(got) != len(want) { 52 + t.Fatalf("entries = %d, want %d", len(got), len(want)) 53 + } 54 + for i := range want { 55 + if got[i].host != want[i].host || (got[i].sess == nil) != (want[i].sess == nil) { 56 + t.Fatalf("entry %d = %+v, want host %q sess %v", i, got[i], want[i].host, want[i].sess != nil) 57 + } 58 + } 59 + 60 + for range len(want) + 3 { 61 + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) 62 + m = next.(pickerModel) 63 + } 64 + if m.cursor != len(want)-1 { 65 + t.Fatalf("cursor = %d after walking down, want %d", m.cursor, len(want)-1) 66 + } 67 + if e := m.entries(); e[m.cursor].sess == nil { 68 + t.Fatal("last entry should be the session row") 69 + } 70 + 71 + if e := newModel("valef").entries(); len(e) != 2 || e[0].host != "valefar" || e[0].sess != nil { 72 + t.Fatalf("searching a host name gave %+v", e) 73 + } 74 + if e := newModel("astar").entries(); len(e) != 1 || e[0].host != "astaroth" { 75 + t.Fatalf("searching a session-less host gave %+v", e) 76 + } 77 + if e := newModel("hx").entries(); len(e) != 2 || e[1].sess == nil { 78 + t.Fatalf("searching a session command gave %+v", e) 79 + } 80 + }
+124 -46
internal/app/ui.go
··· 14 14 "github.com/charmbracelet/bubbles/textinput" 15 15 tea "github.com/charmbracelet/bubbletea" 16 16 "github.com/charmbracelet/lipgloss" 17 + "github.com/sahilm/fuzzy" 17 18 "github.com/samber/lo" 18 19 "golang.org/x/term" 19 20 ··· 45 46 borderStyle = lipgloss.NewStyle().Foreground(overlay) 46 47 ) 47 48 48 - const maxUIWidth = 76 49 + const ( 50 + maxUIWidth = 76 51 + startWidth = 80 // assumed until the first WindowSizeMsg lands 52 + ) 49 53 50 54 type hostRow struct { 51 55 name string ··· 79 83 holdTicks int // how long the gaze-hold lasts after a blink 80 84 probes chan probeResult 81 85 pending int 82 - selected *session.Session 86 + picked *pickEntry 87 + } 88 + 89 + type pickEntry struct { 90 + host string // ssh destination of the host the line belongs to 91 + sess *session.Session // nil when the line is the host row itself 83 92 } 84 93 85 94 func (m pickerModel) Init() tea.Cmd { return tea.Batch(m.spinner.Tick, waitForProbe(m.probes)) } ··· 91 100 case tea.WindowSizeMsg: 92 101 m.width = msg.Width 93 102 m.filter.Width = min(msg.Width, maxUIWidth) - 8 103 + m.filter.SetCursor(m.filter.Position()) // recompute the scroll offset for the new width 94 104 95 105 case probeResult: 96 106 r := &m.rows[msg.row] ··· 100 110 byNewest(msg.sessions) 101 111 r.sessions = msg.sessions 102 112 m.pending-- 113 + m.clamp() 103 114 if m.pending > 0 { 104 115 return m, waitForProbe(m.probes) 105 116 } ··· 132 143 m.cursor = max(0, m.cursor-1) 133 144 return m, nil 134 145 case tea.KeyDown: 135 - m.cursor++ 146 + m.cursor = min(len(m.entries())-1, m.cursor+1) 136 147 return m, nil 137 148 case tea.KeyEnter: 138 - if v := m.visible(); m.cursor < len(v) { 139 - m.selected = &v[m.cursor] 149 + if e := m.entries(); m.cursor < len(e) { 150 + m.picked = &e[m.cursor] 140 151 return m, tea.Quit 141 152 } 142 153 return m, nil ··· 158 169 return m, nil 159 170 } 160 171 161 - func (m pickerModel) visible() []session.Session { 162 - return rankSessions(lo.FlatMap(m.rows, func(r hostRow, _ int) []session.Session { return r.sessions }), m.filter.Value(), false) 172 + func (m pickerModel) rowsFor() []hostRow { 173 + q := strings.ToLower(strings.TrimSpace(m.filter.Value())) 174 + if q == "" { 175 + return m.rows 176 + } 177 + var out []hostRow 178 + for _, r := range m.rows { 179 + if len(fuzzy.Find(q, []string{strings.ToLower(r.name)})) > 0 { 180 + out = append(out, r) 181 + continue 182 + } 183 + if hits := rankSessions(r.sessions, q, false); len(hits) > 0 { 184 + r.sessions = hits 185 + out = append(out, r) 186 + } 187 + } 188 + return out 189 + } 190 + 191 + // in draw order, so the cursor and the table cannot disagree; hosts we cannot 192 + // reach are drawn but not selectable 193 + func (m pickerModel) entries() []pickEntry { 194 + var out []pickEntry 195 + for _, r := range m.rowsFor() { 196 + if selectableHost(r) { 197 + out = append(out, pickEntry{host: r.name}) 198 + } 199 + for _, s := range r.sessions { 200 + out = append(out, pickEntry{host: r.name, sess: &s}) 201 + } 202 + } 203 + return out 163 204 } 164 205 206 + func selectableHost(r hostRow) bool { return !r.peerOffline && !r.unreachable } 207 + 208 + func (m *pickerModel) clamp() { m.cursor = max(0, min(len(m.entries())-1, m.cursor)) } 209 + 165 210 var kittyFace = [...]string{"(^% % ^)", "(^ % % ^)", "(^ % %^)"} 166 211 167 212 const eyeDozing, eyeOpen, eyeLooking = "-", "•", "." ··· 197 242 b.WriteString(borderStyle.Render("│") + " " + filter + strings.Repeat(" ", max(1, w-4-lipgloss.Width(filter))) + borderStyle.Render(" │") + "\n") 198 243 b.WriteString(borderStyle.Render("╰"+strings.Repeat("─", w-2)+"╯") + "\n") 199 244 200 - cursor := 0 201 - if q := strings.TrimSpace(m.filter.Value()); q != "" { 202 - for _, s := range m.visible() { 203 - sessionRow(&b, w, cursor, m.cursor, s, s.Canonical(), " ", "› ") 204 - cursor++ 205 - } 206 - } else { 207 - cursor = m.drawHostTable(&b, w, cursor) 245 + rows := m.rowsFor() 246 + if len(rows) == 0 { 247 + b.WriteString(" " + faintStyle.MaxWidth(w-1).Render("nothing matches "+strings.TrimSpace(m.filter.Value())) + "\n") 208 248 } 249 + m.drawHostTable(&b, w, rows) 209 250 b.WriteString(borderStyle.Render(strings.Repeat("─", w)) + "\n") 210 251 hint := func(k, act string) string { 211 252 return dimStyle.Render("[") + textStyle.Render(k) + dimStyle.Render("] "+act) 212 253 } 254 + hs := m.hints() 213 255 total := 0 214 - for _, h := range pickerHints { 256 + for _, h := range hs { 215 257 total += h.w 216 258 } 217 - hs := pickerHints 218 259 for len(hs) > 1 && total+3*(len(hs)-1) > w { 219 260 total -= hs[len(hs)-1].w 220 261 hs = hs[:len(hs)-1] ··· 228 269 } 229 270 b.WriteString(strings.Repeat(" ", max(0, next-x))) 230 271 b.WriteString(hint(h.k, h.act)) 231 - x = next + h.w 272 + x = max(next, x) + h.w // a hint that overran its column pushes the rest right 232 273 } 233 274 return b.String() 234 275 } 235 276 236 - var pickerHints = []struct { 277 + type pickerHint struct { 237 278 k, act string 238 279 w int 239 - }{ 240 - {"enter", "attach", 14}, 241 - {"↑↓", "move", 9}, 242 - {"esc", "clear", 11}, 243 - {"ctrl+c", "quit", 13}, 280 + } 281 + 282 + func (m pickerModel) hints() []pickerHint { 283 + act := "attach" 284 + if e := m.entries(); m.cursor < len(e) && e[m.cursor].sess == nil { 285 + act = "new shell" 286 + } 287 + hs := []pickerHint{{k: "enter", act: act}, {k: "↑↓", act: "move"}, {k: "esc", act: "clear"}, {k: "ctrl+c", act: "quit"}} 288 + for i := range hs { 289 + hs[i].w = utf8.RuneCountInString(hs[i].k) + utf8.RuneCountInString(hs[i].act) + 3 290 + } 291 + return hs 292 + } 293 + 294 + // the wrapper we start bare hosts with is plumbing, not something worth 295 + // reading in the command column 296 + func commandLabel(s session.Session) string { 297 + return lo.Ternary(s.Command == strings.Join(loginShell, " "), "shell", s.Command) 244 298 } 245 299 246 300 func sessionRow(b *strings.Builder, w, cursor, sel int, s session.Session, label, pad, selPad string) { 247 - age := relTime(s.StartedAt) 248 - rest := label + " " + s.Command + " " + s.Cwd 301 + age, cmd := relTime(s.StartedAt), commandLabel(s) 302 + rest := label + " " + cmd + " " + s.Cwd 249 303 if cursor == sel { 250 304 b.WriteString(pickStyle.Render(selPad+rest+strings.Repeat(" ", max(0, w-1-utf8.RuneCountInString(age)-utf8.RuneCountInString(selPad+rest)))+" "+age) + "\n") 251 305 return 252 306 } 253 - left := pad + mauveStyle.Render(label) + " " + lavenderStyle.Render(s.Command) + " " + yellowStyle.Render(s.Cwd) 307 + left := pad + mauveStyle.Render(label) + " " + lavenderStyle.Render(cmd) + " " + yellowStyle.Render(s.Cwd) 254 308 fill := max(0, w-utf8.RuneCountInString(age)-lipgloss.Width(left)) 255 309 b.WriteString(left + strings.Repeat(" ", fill) + age + "\n") 256 310 } 257 311 258 - func (m pickerModel) drawHostTable(b *strings.Builder, w, cursor int) int { 312 + func (m pickerModel) drawHostTable(b *strings.Builder, w int, rows []hostRow) { 259 313 nameW := 0 260 - for _, r := range m.rows { 314 + for _, r := range rows { 261 315 nameW = max(nameW, utf8.RuneCountInString(r.name)) 262 316 } 263 317 nameW = min(nameW, 22) 264 - for _, r := range m.rows { 318 + cursor := 0 319 + for _, r := range rows { 265 320 glyph, sess := "○", "no sessions" 266 321 glyphSt, sessSt, nameSt := dimStyle, dimStyle, textStyle 267 - ping := dimStyle.Render(fmt.Sprintf("%.1fs", r.duration.Seconds())) 322 + pingSt := dimStyle 323 + ping := fmt.Sprintf("%.1fs", r.duration.Seconds()) 268 324 switch { 269 325 case r.probing: 270 326 sess = "searching" 271 - ping = dimStyle.Render(fmt.Sprintf("%s %.1fs", m.spinner.View(), probeClock(time.Since(r.started)).Seconds())) 327 + ping = fmt.Sprintf("%s %.1fs", m.spinner.View(), probeClock(time.Since(r.started)).Seconds()) 272 328 case r.unreachable: 273 329 glyph, sess = "!", "unreachable" 274 330 glyphSt, sessSt, nameSt = salmonStyle, salmonStyle.Faint(true), faintStyle 275 - ping = salmonStyle.Faint(true).Render(fmt.Sprintf("%.1fs", r.duration.Seconds())) 331 + pingSt = salmonStyle.Faint(true) 276 332 case r.peerOffline: 277 333 glyph, sess, ping = "×", "offline", "" 278 334 glyphSt, sessSt, nameSt = faintStyle, faintStyle, faintStyle ··· 285 341 reserve += 8 // the local pill and its gap 286 342 } 287 343 sessW := min(12, max(0, w-3-2-nameW-3-reserve)) 288 - left := " " + glyphSt.Width(2).Render(glyph) + nameSt.Width(nameW).Render(r.name) + " " + sessSt.Width(12).MaxWidth(sessW).Render(sess) 289 - right := ping 290 - if r.local { 291 - right = selfPill.Render("local") 344 + selected := selectableHost(r) && cursor == m.cursor 345 + if selectableHost(r) { 346 + cursor++ 347 + } 348 + // the highlighted row is rendered unstyled inside one background run, 349 + // so nested colour resets cannot punch holes in it 350 + if selected { 351 + plain := lipgloss.NewStyle() 352 + left := " › " + plain.Width(nameW).Render(r.name) + " " + plain.Width(12).MaxWidth(sessW).Render(sess) 353 + right := lo.Ternary(r.local, " local ", ping) 354 + fill := max(0, w-lipgloss.Width(left)-lipgloss.Width(right)) 355 + b.WriteString(pickStyle.Render(left+strings.Repeat(" ", fill)+right) + "\n") 356 + } else { 357 + left := " " + glyphSt.Width(2).Render(glyph) + nameSt.Width(nameW).Render(r.name) + " " + sessSt.Width(12).MaxWidth(sessW).Render(sess) 358 + right := pingSt.Render(ping) 359 + if r.local { 360 + right = selfPill.Render("local") 361 + } 362 + fill := max(0, w-lipgloss.Width(left)-lipgloss.Width(right)) 363 + b.WriteString(left + strings.Repeat(" ", fill) + right + "\n") 292 364 } 293 - fill := max(0, w-lipgloss.Width(left)-lipgloss.Width(right)) 294 - b.WriteString(left + strings.Repeat(" ", fill) + right + "\n") 295 365 296 366 for _, s := range r.sessions { 297 367 sessionRow(b, w, cursor, m.cursor, s, s.ID, " ", " › ") 298 368 cursor++ 299 369 } 300 370 } 301 - 302 - return cursor 303 371 } 304 372 305 373 func runPicker(local []session.Session, initial string) error { ··· 314 382 filter.Placeholder = "search sessions…" 315 383 filter.PlaceholderStyle = faintStyle 316 384 filter.Focus() 385 + filter.Width = startWidth - 8 // before the value, so a long initial query scrolls instead of overflowing 317 386 318 387 if initial = strings.TrimSpace(initial); initial != "" { 319 388 filter.SetValue(initial) 320 389 } 321 390 m := pickerModel{ 322 391 filter: filter, 323 - width: 80, 392 + width: startWidth, 324 393 probes: probes, 325 394 spinner: spinner.New(spinner.WithSpinner(spinner.Dot)), 326 395 rows: []hostRow{{name: self, local: true, sessions: local}}, 327 396 } 328 - m.filter.Width = m.width - 8 329 397 m.lookDir, m.nextBlink = []int{-1, 1}[rand.IntN(2)], 30+rand.IntN(31) 330 398 online := lo.Filter(peers, func(p remote.Peer, _ int) bool { return p.Online && !strings.EqualFold(p.Name, self) }) 331 399 var offline []remote.Peer ··· 354 422 }(i, m.rows[i].name) 355 423 } 356 424 425 + m.clamp() 357 426 model, err := tea.NewProgram(m).Run() 358 427 if err != nil { 359 428 return err 360 429 } 361 - if final := model.(pickerModel); final.selected != nil { 362 - return connectOrHop(*final.selected) 430 + picked := model.(pickerModel).picked 431 + switch { 432 + case picked == nil: 433 + return nil 434 + case picked.sess != nil: 435 + return connectOrHop(*picked.sess) 363 436 } 364 - return nil 437 + fmt.Fprintln(os.Stderr, dimStyle.Render("starting a shell on "+picked.host+"…")) 438 + return Spawn(picked.host, loginShell) 365 439 } 366 440 441 + // ask the target for its own login shell rather than carrying ours across, 442 + // since the path we run under need not exist on the other host 443 + var loginShell = []string{"sh", "-lc", `exec "${SHELL:-/bin/sh}"`} 444 + 367 445 func relTime(t time.Time) string { 368 446 d := time.Since(t) 369 447 switch {
+13 -3
internal/remote/hosts_test.go
··· 11 11 dir := t.TempDir() 12 12 t.Setenv("XDG_CONFIG_HOME", dir) 13 13 t.Setenv("HOME", dir) 14 - if content != "" { 15 - _ = os.MkdirAll(filepath.Join(dir, "tobi"), 0700) 16 - _ = os.WriteFile(filepath.Join(dir, "tobi", "hosts.json"), []byte(content), 0600) 14 + if content == "" { 15 + return 16 + } 17 + // not the xdg layout: macos keeps its config under ~/Library 18 + p := hostMapPath() 19 + if p == "" { 20 + t.Fatal("no config dir") 21 + } 22 + if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil { 23 + t.Fatal(err) 24 + } 25 + if err := os.WriteFile(p, []byte(content), 0600); err != nil { 26 + t.Fatal(err) 17 27 } 18 28 } 19 29
+1 -1
internal/remote/remote.go
··· 161 161 remoteInstalled sync.Map 162 162 // the declared release; bump it (or append "+n" for a same-day 163 163 // re-release) to ship, remotes refuse older builds 164 - Version = "2026.09.21" 164 + Version = "2026.09.21+1" 165 165 ) 166 166 167 167 // verTuple parses a declared version - "2026.09.21" or "2026.09.21+2", the
+43 -2
internal/session/session.go
··· 99 99 _ = conn.Close() 100 100 return nil 101 101 } 102 + if _, err := exec.LookPath(argv[0]); err != nil { 103 + return err 104 + } 102 105 self, _ := os.Executable() 103 106 if self == "" { 104 107 self = os.Args[0] ··· 106 109 cmd := exec.Command(self, argv...) 107 110 cmd.Env = append(os.Environ(), "TOBI_OWNER=1", "TOBI_ID="+id) 108 111 cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} 112 + // the owner is detached, so its stderr is the only way it can explain itself 113 + var stderr syncBuffer 114 + cmd.Stderr = &stderr 109 115 if err := cmd.Start(); err != nil { 110 116 return fmt.Errorf("start owner: %w", err) 111 117 } 112 - go func() { _ = cmd.Wait() }() 118 + exited := make(chan struct{}) 119 + go func() { _ = cmd.Wait(); close(exited) }() 120 + 121 + fail := func(reason string) error { 122 + if msg := stderr.lastLine(); msg != "" { 123 + return fmt.Errorf("owner for session %q %s: %s", id, reason, msg) 124 + } 125 + return fmt.Errorf("owner for session %q %s", id, reason) 126 + } 113 127 for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) { 114 128 if conn, err := net.DialTimeout("unix", sockPath, 20*time.Millisecond); err == nil { 115 129 _ = conn.Close() 116 130 return nil 117 131 } 132 + select { 133 + case <-exited: // no point waiting out the deadline for a process that is gone 134 + return fail("exited before it was ready") 135 + default: 136 + } 137 + } 138 + return fail("did not start") 139 + } 140 + 141 + // read while the child may still be writing, so the copier must not be raced 142 + type syncBuffer struct { 143 + mu sync.Mutex 144 + buf bytes.Buffer 145 + } 146 + 147 + func (b *syncBuffer) Write(p []byte) (int, error) { 148 + b.mu.Lock() 149 + defer b.mu.Unlock() 150 + if b.buf.Len() > 8<<10 { 151 + return len(p), nil 118 152 } 119 - return fmt.Errorf("owner for session %q did not start", id) 153 + return b.buf.Write(p) 154 + } 155 + 156 + func (b *syncBuffer) lastLine() string { 157 + b.mu.Lock() 158 + defer b.mu.Unlock() 159 + lines := strings.Split(strings.TrimSpace(b.buf.String()), "\n") 160 + return strings.TrimSpace(lines[len(lines)-1]) 120 161 } 121 162 122 163 func ListLocal() []Session {
+25 -4
internal/session/session_test.go
··· 11 11 "tobi/internal/protocol" 12 12 ) 13 13 14 + // t.TempDir() blows past the ~104 byte cap on a unix socket path on macos 15 + func shortTempDir(t *testing.T) string { 16 + dir, err := os.MkdirTemp("", "tb") 17 + if err != nil { 18 + t.Fatal(err) 19 + } 20 + t.Cleanup(func() { _ = os.RemoveAll(dir) }) 21 + return dir 22 + } 23 + 14 24 func startOwner(t *testing.T, cmd ...string) string { 15 25 id, sp := GenerateID(), "" 16 26 sp = SocketPath(id) ··· 66 76 } 67 77 68 78 func TestStaleSocketCleanup(t *testing.T) { 69 - t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) 79 + t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) 70 80 sp := filepath.Join(RuntimeDir(), "stale123456.sock") 71 81 _ = os.WriteFile(sp, []byte("stale"), 0600) 72 82 _ = ListLocal() ··· 76 86 } 77 87 78 88 func TestOwnerMultiViewerHopAndOSC(t *testing.T) { 79 - t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) 89 + t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) 80 90 sp := startOwner(t, "sh") 81 91 v1, v2 := attach(t, sp), attach(t, sp) 82 92 defer v1.Close() ··· 97 107 } 98 108 99 109 func TestSlowViewerDoesNotBlockOwner(t *testing.T) { 100 - t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) 110 + t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) 101 111 sp := startOwner(t, "sh", "-c", "dd if=/dev/zero bs=1024 count=2000; sleep 10") 102 112 slow, _ := net.Dial("unix", sp) 103 113 defer slow.Close() ··· 117 127 } 118 128 119 129 func TestDetachedIdleTTL(t *testing.T) { 120 - t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) 130 + t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) 121 131 orig := detachedTTL 122 132 detachedTTL = 80 * time.Millisecond 123 133 defer func() { detachedTTL = orig }() ··· 129 139 t.Fatalf("socket not reaped: %v", err) 130 140 } 131 141 } 142 + 143 + func TestEnsureOwnerNamesAMissingCommand(t *testing.T) { 144 + t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) 145 + err := EnsureOwner(GenerateID(), []string{"tobi-no-such-command-xyz"}) 146 + if err == nil || !strings.Contains(err.Error(), "tobi-no-such-command-xyz") { 147 + t.Fatalf("EnsureOwner = %v, want the missing command named", err) 148 + } 149 + if strings.Contains(err.Error(), "did not start") { 150 + t.Fatalf("EnsureOwner = %v, want the real reason rather than a timeout", err) 151 + } 152 + }

History

1 round 0 comments
Sign up or Login to add to the discussion
okami.mom submitted #0
1 commit
Expand
13f3b98b
autocreate sessions on hosts with no sessions + surface errors on failed session creation
Expand 0 comments
Pull request successfully merged