tool for detachable ptys with optional tailscale discovery
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295package session
import ( "fmt" "net" "os" "os/exec" "path/filepath" "strings" "testing" "time"
"tobi/internal/protocol" "tobi/internal/termcolor")
// t.TempDir() blows past the ~104 byte cap on a unix socket path on macosfunc shortTempDir(t *testing.T) string { dir, err := os.MkdirTemp("", "tb") if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = os.RemoveAll(dir) }) return dir}
func startOwner(t *testing.T, cmd ...string) string { id, sp := GenerateID(), "" sp = SocketPath(id) go func() { _ = RunOwner(id, cmd) }() for i := 0; i < 50; i++ { if c, err := net.Dial("unix", sp); err == nil { _ = c.Close() return sp } time.Sleep(20 * time.Millisecond) } t.Fatalf("owner did not start: %s", sp) return ""}
func attach(t *testing.T, sp string) net.Conn { c, err := net.Dial("unix", sp) if err != nil { t.Fatal(err) } _ = protocol.WriteFrame(c, protocol.TypeAttach, protocol.EncodeSize(80, 24)) if ft, _, err := protocol.ReadFrame(c); err != nil || ft != protocol.TypeSnapshot { t.Fatalf("snapshot: %v", err) } return c}
func expectFrame(t *testing.T, c net.Conn, want protocol.FrameType, contains string) { _ = c.SetReadDeadline(time.Now().Add(time.Second)) for { ft, p, err := protocol.ReadFrame(c) if err != nil { t.Fatalf("frame %v: %v", want, err) } if ft == want && (contains == "" || strings.Contains(string(p), contains)) { return } }}
func TestKill(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) sp := startOwner(t, "sleep", "60") id := strings.TrimSuffix(filepath.Base(sp), ".sock") if err := Kill(id); err != nil { t.Fatal(err) } if list := ListLocal(); len(list) != 0 { t.Fatalf("session survived: %v", list) } if err := Kill(id); err == nil { t.Fatal("killing a dead session succeeded") }}
func TestSnapshotKeepsMouseEncoding(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) sp := startOwner(t, "sh") v := attach(t, sp) defer v.Close() // the tty echoes the typed line, so the marker is split by quotes to // match only once the command has run _ = protocol.WriteFrame(v, protocol.TypeInput, []byte("printf '\\033[?1002h\\033[?1006h'; echo do''ne\n")) expectFrame(t, v, protocol.TypeOutput, "done") c, err := net.Dial("unix", sp) if err != nil { t.Fatal(err) } defer c.Close() _ = protocol.WriteFrame(c, protocol.TypeAttach, protocol.EncodeSize(80, 24)) if _, snap, err := protocol.ReadFrame(c); err != nil || !strings.Contains(string(snap), "\x1b[?1006h") { t.Fatalf("snapshot lost sgr mouse: %v %q", err, snap) }}
func TestKillSignalsAnOwnerThatPredatesIt(t *testing.T) { if sock := os.Getenv("TOBI_TEST_DEAF_OWNER"); sock != "" { l, err := net.Listen("unix", sock) if err != nil { os.Exit(1) } for { c, err := l.Accept() if err != nil { os.Exit(1) } _, _, _ = protocol.ReadFrame(c) _ = c.Close() } } t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) id := GenerateID() owner := exec.Command(os.Args[0], "-test.run=^TestKillSignalsAnOwnerThatPredatesIt$") owner.Env = append(os.Environ(), "TOBI_TEST_DEAF_OWNER="+SocketPath(id)) if err := owner.Start(); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = owner.Process.Kill() }) for i := 0; i < 100 && !answers(SocketPath(id), 250*time.Millisecond); i++ { time.Sleep(20 * time.Millisecond) } if err := Kill(id); err != nil { t.Fatal(err) } if err := owner.Wait(); err == nil || !strings.Contains(err.Error(), "terminated") { t.Fatalf("owner exit: %v", err) }}
func TestQueryFilter(t *testing.T) { var f queryFilter for _, tc := range []struct{ in, want string }{ {"\x1b]10", ""}, {";?\x1b\\", ""}, {"\x1b]4;1;?;2", ""}, {";?\a", ""}, {"hi\x1b[?6nthere\x1b[2", "hithere"}, {"Jok", "\x1b[2Jok"}, {"ok\x1b", "ok"}, {"[31mred", "\x1b[31mred"}, } { if got := string(f.Feed([]byte(tc.in))); got != tc.want { t.Fatalf("Feed(%q) = %q, want %q", tc.in, got, tc.want) } }}
func TestStaleSocketCleanup(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) sp := filepath.Join(RuntimeDir(), "stale123456.sock") _ = os.WriteFile(sp, []byte("stale"), 0600) _ = ListLocal() if _, err := os.Stat(sp); !os.IsNotExist(err) { t.Fatal("stale socket not removed") }}
func TestOwnerMultiViewerHopAndOSC(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) sp := startOwner(t, "sh") v1, v2 := attach(t, sp), attach(t, sp) defer v1.Close() defer v2.Close()
cmd := "printf '\\033]2;hx main.go\\a\\033]7;file://localhost/custom/dir\\a'; echo sha''red\n" // quoted apart from the tty's echo _ = protocol.WriteFrame(v1, protocol.TypeInput, []byte(cmd)) expectFrame(t, v1, protocol.TypeOutput, "shared") expectFrame(t, v2, protocol.TypeOutput, "shared") if err := SendHop(sp, "remote.123456"); err != nil { t.Fatal(err) } expectFrame(t, v1, protocol.TypeHop, "remote.123456") expectFrame(t, v2, protocol.TypeHop, "remote.123456") if list := ListLocal(); len(list) == 0 || list[0].Command != "hx main.go" || list[0].Cwd != "/custom/dir" { t.Fatalf("metadata mismatch: %v", list) }}
func TestSlowViewerDoesNotBlockOwner(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) sp := startOwner(t, "sh", "-c", "dd if=/dev/zero bs=1024 count=2000; sleep 10") slow, _ := net.Dial("unix", sp) defer slow.Close() _ = protocol.WriteFrame(slow, protocol.TypeAttach, protocol.EncodeSize(80, 24)) time.Sleep(300 * time.Millisecond)
conn, err := net.DialTimeout("unix", sp, 500*time.Millisecond) if err != nil { t.Fatal(err) } defer conn.Close() go func() { _ = SendHop(sp, "remote.blocked") }() _ = protocol.WriteFrame(conn, protocol.TypeInfo, nil) if ft, _, err := protocol.ReadFrame(conn); err != nil || ft != protocol.TypeInfoResp { t.Fatalf("owner blocked: %v", err) }}
func TestDetachedIdleTTL(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) defer detachedTTL.Store(detachedTTL.Swap(int64(80 * time.Millisecond))) // the swap runs now, the restore on return sp := startOwner(t, "sh", "-c", "sleep 10") v := attach(t, sp) _ = v.Close() time.Sleep(150 * time.Millisecond) if _, err := os.Stat(sp); !os.IsNotExist(err) { t.Fatalf("socket not reaped: %v", err) }}
func TestEnsureOwnerNamesAMissingCommand(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) err := EnsureOwner(GenerateID(), []string{"tobi-no-such-command-xyz"}) if err == nil || !strings.Contains(err.Error(), "tobi-no-such-command-xyz") { t.Fatalf("EnsureOwner = %v, want the missing command named", err) } if strings.Contains(err.Error(), "did not start") { t.Fatalf("EnsureOwner = %v, want the real reason rather than a timeout", err) }}func attachColors(t *testing.T, sp string, colors map[int]termcolor.Color) net.Conn { c, err := net.Dial("unix", sp) if err != nil { t.Fatal(err) } _ = protocol.WriteFrame(c, protocol.TypeAttach, append(protocol.EncodeSize(80, 24), termcolor.EncodeColors(colors)...)) if ft, _, err := protocol.ReadFrame(c); err != nil || ft != protocol.TypeSnapshot { t.Fatalf("snapshot: %v", err) } return c}
func expectNoOutput(t *testing.T, c net.Conn, contains string) { t.Helper() _ = c.SetReadDeadline(time.Now().Add(300 * time.Millisecond)) defer c.SetReadDeadline(time.Time{}) for { ft, p, err := protocol.ReadFrame(c) if err != nil { if ne, ok := err.(net.Error); ok && ne.Timeout() { return } t.Fatalf("frame %q: %v", contains, err) } if ft == protocol.TypeOutput && strings.Contains(string(p), contains) { t.Fatalf("unexpected %q", contains) } }}
func TestOwnerAnswersAgreedColorsOnly(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) sp := startOwner(t, "sh", "-c", "stty raw -echo; cat") ask := func(v net.Conn, i int) { _ = protocol.WriteFrame(v, protocol.TypeInput, []byte(fmt.Sprintf("\x1b]%d;?\x1b\\", i))) }
fg := termcolor.Color{R: 0xffff, G: 0xffff, B: 0xffff} v1 := attachColors(t, sp, map[int]termcolor.Color{termcolor.Foreground: fg, termcolor.Background: {R: 0, G: 0, B: 0}}) defer v1.Close() v2 := attachColors(t, sp, map[int]termcolor.Color{termcolor.Foreground: fg, termcolor.Background: {R: 0, G: 0, B: 0xffff}}) defer v2.Close()
ask(v1, 10) expectFrame(t, v1, protocol.TypeOutput, "\x1b]10;rgb:ffff/ffff/ffff") ask(v1, 11) expectNoOutput(t, v1, "\x1b]11;rgb:")
// a viewer without colors silences even agreed colors v3 := attach(t, sp) defer v3.Close() ask(v1, 10) expectNoOutput(t, v1, "\x1b]10;rgb:")
// once the disagreeing and unknown viewers detach the answer appears v2.Close() v3.Close() time.Sleep(100 * time.Millisecond) ask(v1, 11) expectFrame(t, v1, protocol.TypeOutput, "\x1b]11;rgb:0000/0000/0000")}func TestAttachPayloadKeepsLegacySize(t *testing.T) { colors := map[int]termcolor.Color{termcolor.Foreground: {R: 1, G: 2, B: 3}} payload := append(protocol.EncodeSize(120, 40), termcolor.EncodeColors(colors)...) c, r, err := protocol.DecodeSize(payload) if err != nil || c != 120 || r != 40 { t.Fatalf("DecodeSize on a longer payload = %d %d %v, want the size bytes only", c, r, err) } if colors2, ok := termcolor.DecodeColors(payload[4:]); !ok || colors2[termcolor.Foreground] != (termcolor.Color{R: 1, G: 2, B: 3}) { t.Fatalf("DecodeColors on the tail = %v %v", colors2, ok) }}