Skip to content
Open
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
14 changes: 8 additions & 6 deletions ruby/lib/tailscale.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ module Libtailscale
attach_function :TsnetSetLogFD, [:int, :int], :int
attach_function :TsnetDial, [:int, :string, :string, :pointer], :int, blocking: true
attach_function :TsnetListen, [:int, :string, :string, :pointer], :int
attach_function :close, [:int], :int
attach_function :tailscale_accept, [:int, :pointer], :int, blocking: true
attach_function :TsnetAccept, [:int, :pointer], :int, blocking: true
attach_function :TsnetErrmsg, [:int, :pointer, :size_t], :int
attach_function :TsnetLoopback, [:int, :pointer, :size_t, :pointer, :pointer], :int
end
Expand Down Expand Up @@ -86,15 +85,19 @@ def initialize(ts, listener)
# write.
def accept
@ts.assert_open
lio = IO.for_fd(@listener)
until IO.select([lio]).first.any?
@ts.assert_open
Comment on lines +88 to +90
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IO.for_fd defaults to autoclose=true; the temporary lio created here can be garbage-collected after accept returns, which would close the underlying listener FD unexpectedly. Use IO.for_fd(@Listener, autoclose: false) (or store a persistent IO wrapper on the Listener instance and close it in #close) so GC can’t close the listener.

Copilot uses AI. Check for mistakes.
Comment on lines +89 to +90
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This until IO.select([lio]).first.any? loop is effectively a single blocking call because IO.select has no timeout; the body won’t re-run to re-check assert_open. Either pass a small timeout to IO.select and loop, or remove the loop and just do a single select (or go back to a fully blocking accept call) to avoid misleading control flow.

Suggested change
until IO.select([lio]).first.any?
@ts.assert_open
loop do
# Wait briefly for the listener to become readable so we can
# periodically re-check that the server is still open.
readable, = IO.select([lio], nil, nil, 0.1)
@ts.assert_open
break if readable && !readable.empty?

Copilot uses AI. Check for mistakes.
end
conn = FFI::MemoryPointer.new(:int)
Error.check(@ts, Libtailscale::tailscale_accept(@listener, conn))
Error.check(@ts, Libtailscale::TsnetAccept(@listener, conn))
IO::new(conn.read_int)
end

# Close the listener.
def close
@ts.assert_open
Error.check(@ts, Libtailscale::close(@listener))
IO.for_fd(@listener).close
end
end

Expand Down Expand Up @@ -229,8 +232,7 @@ def set_log_fd(log_fd)
end

# Dial a network address. +network+ is one of "tcp" or "udp". +addr+ is the
# remote address to connect to. This method blocks until the connection is
# established.
# remote address to connect to. This method blocks until the connection is established.
def dial(network, addr)
assert_open
conn = FFI::MemoryPointer.new(:int)
Expand Down
26 changes: 25 additions & 1 deletion ruby/test/tailscale/test_tailscale.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def test_that_it_has_a_version_number
def test_listen_sorta_works
ts = newts
ts.up
wait_status_running ts
s = ts.listen("tcp", ":1999")
s.close
ts.close
Expand All @@ -31,18 +32,41 @@ def test_listen_sorta_works
def test_dial_sorta_works
ts = newts
ts.up
wait_status_running ts
c = ts.dial("udp", "100.100.100.100:53")
c.close
ts.close
end

def test_listen_accept_dial_close
ts = newts
ts.up
wait_status_running ts
hn = ts.local_api.status["Self"]["TailscaleIPs"][0]
s = ts.listen "tcp", "#{hn}:1999"
c = ts.dial "tcp", "#{hn}:1999"
ss = s.accept
c.write "hello"
assert_equal "hello", ss.read(5)
ss.write "world"
assert_equal "world", c.read(5)
ss.close
c.close
ts.close
end
Comment on lines +41 to +56
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indentation in this new test method is inconsistent with the rest of the file (2-space indentation). Reformat the body to match the surrounding style to keep the test file readable.

Copilot uses AI. Check for mistakes.

def wait_status_running ts
while ts.local_api.status["BackendState"] != "Running"
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wait_status_running can loop forever if the backend never reaches "Running", which can hang the test suite. Add a timeout/deadline (and possibly a short backoff) and fail the test with a helpful message when exceeded.

Suggested change
while ts.local_api.status["BackendState"] != "Running"
timeout_seconds = 30
deadline = Time.now + timeout_seconds
loop do
status = ts.local_api.status
return if status["BackendState"] == "Running"
if Time.now > deadline
flunk "BackendState did not reach 'Running' within #{timeout_seconds} seconds. Last status: #{status.inspect}"
end

Copilot uses AI. Check for mistakes.
sleep 0.01
end
end

def newts
t = Tailscale::new
unless ENV["VERBOSE"]
logfd = IO.sysopen("/dev/null", "w+")
t.set_log_fd(logfd)
end

t.set_ephemeral(1)
t.set_dir(@tmpdir)
t.set_control_url($testcontrol_url)
Expand Down
23 changes: 2 additions & 21 deletions tailscale.c
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ extern int TsnetSetLogFD(int sd, int fd);
extern int TsnetGetIps(int sd, char *buf, size_t buflen);
extern int TsnetGetRemoteAddr(int listener, int conn, char *buf, size_t buflen);
extern int TsnetListen(int sd, char* net, char* addr, int* listenerOut);
extern int TsnetAccept(int ld, int* connOut);
extern int TsnetLoopback(int sd, char* addrOut, size_t addrLen, char* proxyOut, char* localOut);
extern int TsnetEnableFunnelToLocalhostPlaintextHttp1(int sd, int localhostPort);

Expand Down Expand Up @@ -50,27 +51,7 @@ int tailscale_listen(tailscale sd, const char* network, const char* addr, tailsc
}

int tailscale_accept(tailscale_listener ld, tailscale_conn* conn_out) {
struct msghdr msg = {0};

char mbuf[256];
struct iovec io = { .iov_base = mbuf, .iov_len = sizeof(mbuf) };
msg.msg_iov = &io;
msg.msg_iovlen = 1;

char cbuf[256];
msg.msg_control = cbuf;
msg.msg_controllen = sizeof(cbuf);

if (recvmsg(ld, &msg, 0) == -1) {
return -1;
}

struct cmsghdr* cmsg = CMSG_FIRSTHDR(&msg);
unsigned char* data = CMSG_DATA(cmsg);

int fd = *(int*)data;
*conn_out = fd;
return 0;
return TsnetAccept(ld, (int*)conn_out);
}

int tailscale_getremoteaddr(tailscale_listener l, tailscale_conn conn, char* buf, size_t buflen) {
Expand Down
36 changes: 36 additions & 0 deletions tailscale.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"syscall"
"unsafe"

"golang.org/x/sys/unix"
"tailscale.com/hostinfo"
"tailscale.com/ipn"
"tailscale.com/tsnet"
Expand Down Expand Up @@ -292,6 +293,41 @@ func TsnetListen(sd C.int, network, addr *C.char, listenerOut *C.int) C.int {
return 0
}

//export TsnetAccept
func TsnetAccept(listenerFd C.int, connOut *C.int) C.int {
listeners.mu.Lock()
ln := listeners.m[listenerFd]
listeners.mu.Unlock()

if ln == nil {
return C.EBADF
}

buf := make([]byte, unix.CmsgLen(int(unsafe.Sizeof((C.int)(0)))))
_, oobn, _, _, err := syscall.Recvmsg(int(listenerFd), nil, buf, 0)
if err != nil {
return ln.s.recErr(err)
}

scms, err := syscall.ParseSocketControlMessage(buf[:oobn])
if err != nil {
return ln.s.recErr(err)
}
if len(scms) != 1 {
return ln.s.recErr(fmt.Errorf("libtailscale: got %d control messages, want 1", len(scms)))
}
fds, err := syscall.ParseUnixRights(&scms[0])
Comment on lines +306 to +319
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The control-message buffer is sized with unix.CmsgLen, which is the length value for a cmsghdr, not the space needed for receiving ancillary data. This can lead to truncated SCM_RIGHTS data and failed accepts on some platforms. Allocate the OOB buffer with unix.CmsgSpace(sizeof(int)) (or syscall.CmsgSpace) and check recvmsg flags for MSG_CTRUNC/MSG_TRUNC before parsing.

Copilot uses AI. Check for mistakes.
if err != nil {
return ln.s.recErr(err)
}
if len(fds) != 1 {
return ln.s.recErr(fmt.Errorf("libtailscale: got %d FDs, want 1", len(fds)))
}
*connOut = (C.int)(fds[0])

return 0
}

func newConn(s *server, netConn net.Conn, connOut *C.int) error {
fds, err := syscall.Socketpair(syscall.AF_LOCAL, syscall.SOCK_STREAM, 0)
if err != nil {
Expand Down
Loading