Skip to content

TCP server - socket file descriptor is corrupted when the GC is usedΒ #18263

Closed

Description

@ghostiam

Describe the bug

The pointer to an open file descriptor is corrupted when the GC is used. When compiling v -gc none -d trace_tcp . there is no such error.

A pointer to a file descriptor is always corrupted at a random location. Sometimes before reading, sometimes after, sometimes it happens that it gets corrupted immediately after accept().

I tested on ubuntu arm64 and on amd64 both systems have the issue.
I was able to repeat the bug consistently by running a load test with 50 threads and 15,000 requests.

Due to the fact that the connection cannot be closed, it hangs and is visible through htop (select process and press 'L') as in the screenshot.

In my code, I save the handle to the handle_store variable, and if uncomment the code on lines 47-50), then the connection can be closed successfully if the handle is corrupted after reading or writing.

With GC disabled, I don't get file descriptor corruption and timeouts, but I do get a memory leak... Although I free the buffer after use.

Expected Behavior

Same behavior as when we compile without GC :v -gc none -d trace_tcp .

Current Behavior

File descriptor is corrupted:

Reproduction Steps

./ab-proxy -c 50 -n 15000 -s 5 http://127.0.0.1:1080 (https://github.com/maurice2k/ab-proxy)
or
wrk -c 50 -t 50 -d 3s http://127.0.0.1:1080 (https://github.com/wg/wrk)

And try searching for error: close(): in the output

Server code:

module main
import net
import time

fn main() {
	serve('127.0.0.1:1080', 5 * time.second)!
}

fn serve(addr string, timeout time.Duration) ! {
	mut l := net.listen_tcp(.ip, addr) or { return error('Listening on ${addr}: ${err}') }
	// l.set_accept_timeout(timeout)

	eprintln('Listening on ${addr}')

	mut count := 0
	for {
		mut conn := l.accept() or {
			if err.code() == net.err_timed_out_code {
				// just skip network timeouts, they are normal
				continue
			}
			eprintln('error: accept(), reason: ${err}; skipping')
			continue
		}

		count += 1
		eprintln('${count} ${conn.sock.handle}(${conn.sock.handle}) accept()')

		// eprintln('${count} Connected from ${conn.peer_addr()!}')
		spawn handle_connection(mut conn, timeout, count)
	}
}

fn handle_connection(mut conn net.TcpConn, timeout time.Duration, count int) {
	handle_store := conn.sock.handle

	defer {
		// eprintln('${count} Disconnecting from ${conn.peer_addr() or { net.Addr{} }}')

		eprintln('${count} ${conn.sock.handle}(${handle_store}) close()')

		conn.close() or {
			eprintln('${count} ${conn.sock.handle}(${handle_store}) error: close(): ${err}')
			// os.print_c_errno()

			// try close stored handle
			// net.shutdown(handle_store)
			// net.close(handle_store) or {
			// 	eprintln('${count} ${handle_store} error: close() stored handle: ${err}')
			// }
		}

		// eprintln('${count} Disconnected')
	}

	conn.set_read_timeout(timeout)
	conn.set_write_timeout(timeout)

	mut buffer := []u8{len: 512}
	defer {
		unsafe {
			buffer.free()
		}
	}

	eprintln('${count} ${conn.sock.handle}(${handle_store}) read()')

	l := conn.read(mut buffer) or {
		eprintln('${count} ${conn.sock.handle}(${handle_store})  error: read(): ${err}')
		return
	}

	// eprintln('Read from ${conn.peer_addr() or {}}, length: ${l}')
	// eprintln(buffer[..l].bytestr())

	eprintln('${count} ${conn.sock.handle}(${handle_store}) write()')

	resp := 'Test OK'
	conn.write_string('HTTP/1.0 200 OK\r\nContent-Length: ${resp.len}\r\n\r\n${resp}\r\n') or {
		eprintln('${count} ${conn.sock.handle}(${handle_store})  error: write(): ${err}')
		return
	}
}

Possible Solution

No response

Additional Information/Context

https://discord.com/channels/592103645835821068/592114487759470596/1111237138688782338

V version

V 0.3.4 c45c36c

Environment details (OS name and version, etc.)

V full version: V 0.3.4 c45c36c
OS: linux, Linux version 5.15.49-linuxkit (root@buildkitsandbox) (gcc (Alpine 10.2.1_pre1) 10.2.1 20201203, GNU ld (GNU Binutils) 2.35.2) #1 SMP PREEMPT Tue Sep 13 07:51:32 UTC 2022
Processor: 6 cpus, 64bit, little endian, aarch64

getwd: /mount
vexe: /opt/vlang/v
vexe mtime: 2023-05-24 19:31:10

vroot: OK, value: /opt/vlang
VMODULES: OK, value: /root/.vmodules
VTMP: OK, value: /tmp/v_0

Git version: git version 2.20.1
Git vroot status: weekly.2023.21-7-gc45c36cc (9 commit(s) behind V master)
.git/config present: true

CC version: cc (Debian 8.3.0-6) 8.3.0
thirdparty/tcc status: thirdparty-linux-aarch64 dd547903

Activity

  1. added
    BugThis tag is applied to issues which reports bugs.
    on May 25, 2023
  2. ghostiam commented on May 25, 2023

    @ghostiam
    Author

    Did a little research:

    • If remove spawn then there will be no corruption.
    • if remove the calls to read() and write(), leave only close(), then file descriptor corruption will still occur.
    • Explored the generated C code, removed the call to free() in the function generator (with compiler rebuild) main__handle_connection_thread_wrapper , but nothing changed.

    I think the GC can't keep track of what is being launched via pthread...

    Old updates

    UPD:
    If replace memdup in the generated C code with memdup_uncollectable in the return method accept_only(), then the error disappears (but, yes, a memory leak is expected).

    _result_net__TcpConn_ptr net__TcpListener_accept_only(net__TcpListener* l) {
    ...
    	}
    	_result_net__TcpConn_ptr _t4;
    -	_result_ok(&(net__TcpConn*[]) { ((net__TcpConn*)memdup(&(net__TcpConn){.sock = (net__TcpSocket){.Socket = (net__Socket){.handle = 0,},},.handle = new_handle,.write_deadline = (time__Time){.year = 0,.month = 0,.day = 0,.hour = 0,.minute = 0,.second = 0,.microsecond = 0,._v_unix = 0,.is_local = 0,},.read_deadline = (time__Time){.year = 0,.month = 0,.day = 0,.hour = 0,.minute = 0,.second = 0,.microsecond = 0,._v_unix = 0,.is_local = 0,},.read_timeout = _const_net__tcp_default_read_timeout,.write_timeout = _const_net__tcp_default_write_timeout,.is_blocking = 0,}, sizeof(net__TcpConn))) }, (_result*)(&_t4), sizeof(net__TcpConn*));
    +	_result_ok(&(net__TcpConn*[]) { ((net__TcpConn*)memdup_uncollectable(&(net__TcpConn){.sock = (net__TcpSocket){.Socket = (net__Socket){.handle = 0,},},.handle = new_handle,.write_deadline = (time__Time){.year = 0,.month = 0,.day = 0,.hour = 0,.minute = 0,.second = 0,.microsecond = 0,._v_unix = 0,.is_local = 0,},.read_deadline = (time__Time){.year = 0,.month = 0,.day = 0,.hour = 0,.minute = 0,.second = 0,.microsecond = 0,._v_unix = 0,.is_local = 0,},.read_timeout = _const_net__tcp_default_read_timeout,.write_timeout = _const_net__tcp_default_write_timeout,.is_blocking = 0,}, sizeof(net__TcpConn))) }, (_result*)(&_t4), sizeof(net__TcpConn*));
    	return _t4;
    }
    UPD2: In UPD3 the way is easier.

    I was able to solve the problem by corrupting the FD with only `V` (but yes, there was a memory leak). To do this, I had to remove `mut` from all `TcpConn` methods and replace

    pub fn (mut l TcpListener) accept() !&TcpConn {
    pub fn (mut l TcpListener) accept_only() !&TcpConn {
    fn (mut s TcpSocket) close() ! {

    with

    pub fn (mut l TcpListener) accept() !TcpConn {
    pub fn (mut l TcpListener) accept_only() !TcpConn {
    fn (s TcpSocket) close() ! {

    UPD3:
    It turned out that it is possible to solve the problem easier and with less memory leak.

    Apparently, it is impossible to transfer reference to the coroutine, the GC is clearly not ready for it.

    Diffs

    /vlib/net/tcp.v 
    
    98c98
    < pub fn (mut c TcpConn) close() ! {
    ---
    > pub fn ( c TcpConn) close() ! {
    156c156
    < pub fn (mut c TcpConn) write_ptr(b &u8, len int) !int {
    ---
    > pub fn ( c TcpConn) write_ptr(b &u8, len int) !int {
    201c201
    < pub fn (mut c TcpConn) write_string(s string) !int {
    ---
    > pub fn ( c TcpConn) write_string(s string) !int {
    280c280
    < pub mut:
    ---
    >       pub mut:
    282c282
    < mut:
    ---
    >       mut:
    485c485
    < fn (mut s TcpSocket) close() ! {
    ---
    > fn ( s TcpSocket) close() ! {
    main.v
    -		spawn handle_connection(mut conn, timeout, count)
    +		spawn handle_connection(conn, timeout, count)
    
    - fn handle_connection(mut conn net.TcpConn, timeout time.Duration, count int) {
    + fn handle_connection(conn net.TcpConn, timeout time.Duration, count int) {

    UPD 4:
    And an even easier way... Wrap conn in a structure...

    pub struct Test {
    mut:
    	conn net.TcpConn
    }

    We get a memory leak, but got rid of the corrupting to the FD.

    I made a mistake, there is no memory leak, it's because of my experiments that I forgot to roll back the compiler with my breaking changes.
    That is, wrapping TcpConn in a struct solves the problem without any disadvantages.

    Full code

    module main
    
    import net
    import time
    
    fn main() {
    	serve('127.0.0.1:1080', 5 * time.second)!
    }
    
    pub struct Test {
    mut:
    	conn net.TcpConn
    }
    
    fn serve(addr string, timeout time.Duration) ! {
    	mut l := net.listen_tcp(.ip, addr) or { return error('Listening on ${addr}: ${err}') }
    	// l.set_accept_timeout(timeout)
    
    	eprintln('Listening on ${addr}')
    
    	mut count := 0
    	for {
    		mut conn := l.accept() or {
    			if err.code() == net.err_timed_out_code {
    				// just skip network timeouts, they are normal
    				continue
    			}
    			eprintln('error: accept(), reason: ${err}; skipping')
    			continue
    		}
    
    		count += 1
    		eprintln('${count} ${conn.sock.handle}(${conn.sock.handle}) accept()')
    
    		// eprintln('${count} Connected from ${conn.peer_addr()!}')
    
    		// spawn handle_connection(mut conn, timeout, count)
    		spawn handle_connection(Test{conn}, timeout, count)
    	}
    }
    
    // fn handle_connection(mut conn net.TcpConn, timeout time.Duration, count int) {
    fn handle_connection(c Test, timeout time.Duration, count int) {
    	mut conn := c.conn
    
    	handle_store := conn.sock.handle
    
    	time.sleep(1 * time.millisecond)
    
    	defer {
    		// eprintln('${count} Disconnecting from ${conn.peer_addr() or { net.Addr{} }}')
    
    		eprintln('${count} ${conn.sock.handle}(${handle_store}) close()')
    
    		conn.close() or {
    			eprintln('${count} ${conn.sock.handle}(${handle_store}) error: close(): ${err}')
    		}
    
    		// eprintln('${count} Disconnected')
    	}
    
    	conn.set_read_timeout(timeout)
    	conn.set_write_timeout(timeout)
    
    	mut buffer := []u8{len: 512}
    	defer {
    		unsafe {
    			buffer.free()
    		}
    	}
    
    	eprintln('${count} ${conn.sock.handle}(${handle_store}) read()')
    
    	l := conn.read(mut buffer) or {
    		eprintln('${count} ${conn.sock.handle}(${handle_store})  error: read(): ${err}')
    		return
    	}
    
    	// eprintln('Read from ${conn.peer_addr() or {}}, length: ${l}')
    	// eprintln(buffer[..l].bytestr())
    
    	eprintln('${count} ${conn.sock.handle}(${handle_store}) write()')
    
    	resp := 'Test OK'
    	conn.write_string('HTTP/1.0 200 OK\r\nContent-Length: ${resp.len}\r\n\r\n${resp}\r\n') or {
    		eprintln('${count} ${conn.sock.handle}(${handle_store})  error: write(): ${err}')
    		return
    	}
    }

    UPD5:
    upd:And as it turns out, the most significant difference is in how I pass TcpConn...

    No bug

    pub struct Test {
    mut:
        conn net.TcpConn
    }

    Has bug

    pub struct Test {
    mut:
        conn &net.TcpConn
    }

    The difference in C code is how the data is passed to the function.
    mut conn := c.conn

    No bug:
    conn = HEAP(net__TcpConn, (c->conn));
    +
    handle_store = (*(conn)).sock.Socket.handle;

    Has bug
    conn = c->conn;
    or
    conn = &c->conn;
    +
    handle_store = conn->sock.Socket.handle;

    UPD6:
    The easiest fix? But once I caught file descriptor corruption even with this fix...

    		count += 1
    		eprintln('${count} ${conn.sock.handle}(${conn.sock.handle}) accept()')
    
    		// eprintln('${count} Connected from ${conn.peer_addr()!}')
    
    -		spawn handle_connection(mut conn, timeout, count)
    +		spawn handle_connection(conn, timeout, count)
    
    -fn handle_connection(conn net.TcpConn, timeout time.Duration, count int) {
    +fn handle_connection(c net.TcpConn, timeout time.Duration, count int) {
    +	mut conn := c
    
    	handle_store := conn.sock.handle
  3. kbkpbot commented on Jun 6, 2023

    @kbkpbot
    Contributor

    It seems there are some bug in the thread usage.
    I create a more simpler test case :

    module main
    
    import time
    
    struct Socket {
            conn int
    }
    
    fn thread_fn(t &Socket) {
            t1 := t.conn
            time.sleep(10)
            t2 := t.conn
            if t1 != t2 {
                    println('Error! t.conn=${t.conn} t1=${t1} t2=${t2}')
            }
    }
    
    fn main() {
            mut conn := 1
            for {
                    xx := Socket{ conn: conn++ }
                    spawn thread_fn(&xx)
                    time.sleep(10)
            }
    }

    It will produce a lot of error messages.

  4. pithing commented on Jul 12, 2023

    @pithing

    change:

            xx := &Socket{ conn: conn++ }
            spawn thread_fn(xx)
    

    It seems to be a bug derived from a function reference passed before.

  5. added 2 commits that reference this issue on Apr 15, 2026
    5244be9
    c7327d4
  6. added a commit that references this issue on Apr 17, 2026
    fc6d5cd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    BugThis tag is applied to issues which reports bugs.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions