Something went wrong. Try again.
tool for detachable ptys with optional tailscale discovery
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409package 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 := f.Feed([]byte(tc.in)).shared; string(got) != tc.want { t.Fatalf("Feed(%q) = %q, want %q", tc.in, got, tc.want) } }}
func TestQueryFilterRoutesClipboardTraffic(t *testing.T) { var f queryFilter req := "\x1b]5522;type=read:id=a;aW1hZ2UvcG5n\x1b\\" for _, tc := range []struct{ in, emulator, shared, routed string }{ {"hi\x1b]5522;type=re", "hi", "hi", ""}, {"ad:id=a;aW1hZ2UvcG5n\x1b\\there", "there", "there", req}, {"\x1b]52;c;?\a", "\x1b]52;c;?\a", "\x1b]52;c;?\a", ""}, {"\x1b[?5522$p", "", "", "\x1b[?5522$p"}, {"\x1b[?2026$p", "\x1b[?2026$p", "", ""}, } { got := f.Feed([]byte(tc.in)) if string(got.emulator) != tc.emulator || string(got.shared) != tc.shared || string(got.routed) != tc.routed { t.Fatalf("Feed(%q) = %q; want emulator %q, shared %q, routed %q", tc.in, got, tc.emulator, tc.shared, tc.routed) } }}
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 TestHopRetiresOwnerOnceViewersLeave(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) sp := startOwner(t, "sh") v := attach(t, sp) if err := SendHop(sp, "remote.123456"); err != nil { t.Fatal(err) } expectFrame(t, v, protocol.TypeHop, "remote.123456") if !answers(sp, 100*time.Millisecond) { t.Fatal("owner went away while a viewer was still attached") } _ = v.Close() waitGone(t, sp)}
func TestHopToSelfKeepsOwner(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) sp := startOwner(t, "sh") self := ShortHostname() + "." + strings.TrimSuffix(filepath.Base(sp), ".sock") v := attach(t, sp) if err := SendHop(sp, self); err != nil { t.Fatal(err) } expectFrame(t, v, protocol.TypeHop, self) _ = v.Close() time.Sleep(200 * time.Millisecond) if !answers(sp, 100*time.Millisecond) { t.Fatal("hop to the same session retired it") }}
func TestHopWithoutViewersKeepsOwner(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) sp := startOwner(t, "sh") if err := SendHop(sp, "remote.123456"); err != nil { t.Fatal(err) } v := attach(t, sp) _ = v.Close() time.Sleep(200 * time.Millisecond) if !answers(sp, 100*time.Millisecond) { t.Fatal("a hop nobody saw retired the owner") }}
func waitGone(t *testing.T, sp string) { t.Helper() for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) { if !answers(sp, 50*time.Millisecond) { return } } t.Fatalf("owner still answering: %s", sp)}
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")}
// every terminal that sees a clipboard read request prompts and answers it into// the same pty, so only the viewer that last typed (the one who pressed paste)// may see itfunc TestClipboardRequestReachesOnlyTheTypingViewer(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) sp := startOwner(t, "sh", "-c", "stty raw -echo; cat") time.Sleep(200 * time.Millisecond) // until the tty stops echoing, which would show v2 the request v1, v2 := attach(t, sp), attach(t, sp) defer v1.Close() defer v2.Close()
_ = protocol.WriteFrame(v1, protocol.TypeInput, []byte("\x1b]5522;type=read:id=x;aW1hZ2UvcG5n\x1b\\")) expectFrame(t, v1, protocol.TypeOutput, "\x1b]5522;type=read:id=x;") expectNoOutput(t, v2, "5522")
// the reply goes back through the same viewer and reaches the app, which cat echoes _ = protocol.WriteFrame(v1, protocol.TypeInput, []byte("replydone\n")) expectFrame(t, v1, protocol.TypeOutput, "replydone") expectFrame(t, v2, protocol.TypeOutput, "replydone")
_ = protocol.WriteFrame(v2, protocol.TypeInput, []byte("\x1b]5522;type=read:id=y;aW1hZ2UvcG5n\x1b\\")) expectFrame(t, v2, protocol.TypeOutput, "\x1b]5522;type=read:id=y;") expectNoOutput(t, v1, "id=y")}
// the owner's emulator would answer the capability probe with "mode unknown"// before the real terminal could, so the probe must reach only that terminalfunc TestClipboardProbeIsLeftToTheTypingViewersTerminal(t *testing.T) { t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t)) sp := startOwner(t, "sh", "-c", "stty raw -echo; cat") time.Sleep(200 * time.Millisecond) v1, v2 := attach(t, sp), attach(t, sp) defer v1.Close() defer v2.Close()
_ = protocol.WriteFrame(v1, protocol.TypeInput, []byte("\x1b[?5522$p")) expectFrame(t, v1, protocol.TypeOutput, "\x1b[?5522$p") expectNoOutput(t, v1, "5522;") expectNoOutput(t, v2, "5522")}
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) }}