Skip to content

std.socket API proposal #405

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open

std.socket API proposal #405

wants to merge 2 commits into from

Conversation

yne
Copy link

@yne yne commented May 8, 2025

This PR aim to start a discussion about the futur socket API.

My goal is to use QuickJS as sing portable (via cosmo) graphic app via webapp+CGI server.

I took inspiration from other std syscall wrapper to do this proposal

  • fd = std.socket(domain=AF_INET, type=SOCK_STREAM, protocol=0)
  • err = std.bind(sock, {addr:XXX,port:XXX})
  • err = std.listen(sock)
  • fd = std.accept(sock)
  • err = std.connect(sock, {addr:XXX,port:XXX})

I'm confident about the return value API, but I'm open to feedback about:

  • allowing implicit args (socket() currently return a TCP fd, listen() default to a backlog of 10)
  • shall we use a specific SockAddr() constructor (with .parse() and .toString()) instead of the current plain {address:number,port:number} object used in bind() and connect()
  • are socket more os related than std
  • remove the SO_REUSEADDR I added, and add a setsockopt wrapper (but just for this usecase ?)

Once agreed, I'll add required unit tests/docs ...

@yne yne marked this pull request as draft May 8, 2025 18:26
@yne yne changed the title [draft] std.socket API std.socket API proposa' May 9, 2025
@yne yne changed the title std.socket API proposa' std.socket API proposal May 9, 2025
@yne yne marked this pull request as ready for review May 11, 2025 04:25
@bellard
Copy link
Owner

bellard commented May 11, 2025

I am interested by the feature. Here are some suggestions:

  • the functions should be in "os" instead of "std"
  • no implicit args for socket()
  • implicit arg for listen is acceptable
  • use a plain object for sockaddr. The "family" field is optional (AF_INET by default). "address" should be renamed to "addr" and should be a string containing the IPv4 or IPv6 address. AF_INET6 should be supported too
  • remove SO_REUSEADDR and add setsockopt() and getsockopt()
  • For better portability it is useful to add recv, recvfrom, send, sendto and shutdown
  • In your example, it is better to avoid using fdopen() on sockets ('FILE *' are designed to work on files)
  • a wrapper to getaddrinfo() would be needed (take a hostname as input, returns a sockaddr object)
  • for win32 compilation without cosmolibc some specific support might be needed if you want os.write/os.read/os.close to work with socket handles

@yne yne force-pushed the patch-1 branch 2 times, most recently from 90f098c to 15a2ff3 Compare May 23, 2025 15:59
@yne
Copy link
Author

yne commented May 23, 2025

Okay here is an update.

  • socket are now part of os
  • socket() now use 2 args (family, type)
  • backlog=10 is kept as optional arg for listen
  • sockaddr now use { family: number, port: number, addr: string | null }
  • setsockopt + getsockopt now exist, see example/http_server.js
os.setsockopt(sock_srv, os.SO_REUSEADDR, new Uint32Array([1]).buffer)
  • added following functions
  ret         = send    (sockfd, buf, len?)
  ret         = sendto  (sockfd, buf, len,saddr)
  ret         = recv    (sockfd, buf, len?)
  [ret,saddr] = recvfrom(sockfd, buf, len?)
  • http_sever.js now only use os.* api (no more std.fdopen). But I've seen cases on other project where fscanf/fprintf where used on socket (e.g. for an IRC client)
    So out of curiosity, would this usage fail on a non-UNIX kernel ?
  • getaddrinfo(host,port) now exist, but an additional hint args might be needed since current implementation return too much results (See: example/http_client.js that need a .filter() to work)
  • I'll try on windows once I get back on my enterprise machine

In addition to the given http_server example, what client demo would be nice ?
I've made an HTTP client, and could add a websocket server (to use it as REPL) but I could replace with an NTP or DNS or IRC client

@ceedriic
Copy link

Having a socket API for quickjs is great, however it won't be much useful (for me at least) if only blocking functions are available.
I want to run other code in my app between accept() or connect() or read() and write() calls.
The most useful socket api would be promised-based (accept/connect then read then write then close...)
However, if you want to keep the socket api low-level, then it would be great to provide the options to make the sockets non-blocking:

    int flags = fcntl(sockfd, F_GETFL, 0);
    fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

And a wrapper for the select() function.
With both of theses, it would be possible to design an asynchronous API on top of this one.
Thanks

@yne
Copy link
Author

yne commented May 24, 2025

if you want to keep the socket api low-level

For now yes, because all os IO function are made that way (synchronous).

Note that existing os.setReadHandler(), os.setWriteHandler() offer an callback API for send/recv.

Adding *Async variant to all os IO function could be done in a separate PR.

then it would be great to provide the options to make the sockets non-blocking:

    fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFL, 0) | O_NONBLOCK);

agree, we could either go for:

  • a plain API: add fcntl() + EWOULDBLOCK/EAGAIN support
  • a linux-ish API : allow SOCK_NONBLOCK flag at socket() creation which will do the fcntl() under the hood to work on all OS.

I have a preference for the last one but I'll let @bellard have the last word.

I'll also add a poll() wrapper

poll({fd:number,events:number}[], timeoutMs=-1): number[] // returned events masks
SOCK_NONBLOCK http_client example
#!/usr/bin/env qjs
///@ts-check
/// <reference path="../doc/globals.d.ts" />
/// <reference path="../doc/os.d.ts" />
/// <reference path="../doc/std.d.ts" />

import * as os from "os";
import * as std from "std";

/** @template T @param {os.Result<T>} result @returns {T} */
function must(result) {
    if (typeof result === "number" && result < 0) throw result;
    return /** @type {T} */ (result)
}
/** @param {os.FileDescriptor} fd @param {string[]} lines */
function sendLines(fd, lines) {
    const buf = Uint8Array.from(lines.join('\r\n'), c => c.charCodeAt(0));
    const written = os.send(fd, buf.buffer, buf.byteLength);
    if (written != buf.byteLength) throw `send:${written} : ${std.strerror(-written)}`;
}
const [host = "example.com", port = "80"] = scriptArgs.slice(1);
const ai = os.getaddrinfo(host, port).filter(ai => ai.family == os.AF_INET && ai.port); // TODO too much/invalid result
if (!ai.length) throw `Unable to getaddrinfo(${host}, ${port})`;
const sockfd = must(os.socket(os.AF_INET, os.SOCK_STREAM | os.SOCK_NONBLOCK));
must(os.connect(sockfd, ai[0]) == -std.Error.EINPROGRESS);
must(os.poll([{ fd: sockfd, events: os.POLLOUT }])?.[0] == os.POLLOUT);
sendLines(sockfd, ["GET / HTTP/1.0", `Host: ${host}`, "Connection: close", "", ""]);

const chunk = new Uint8Array(4096);
while (os.poll([{ fd: sockfd, events: os.POLLIN }])?.[0] == os.POLLIN) {
    const got = os.recv(sockfd, chunk.buffer, chunk.byteLength);
    if (got <= 0) break;
    console.log(String.fromCharCode(...chunk));
}

os.close(sockfd);

@ceedriic
Copy link

ceedriic commented May 25, 2025

if you want to keep the socket api low-level

For now yes, because all os IO function are made that way (synchronous).

Kind of true, but filesystem functions are perfectly usable in a synchronous way, whereas a socket API with only synchronous functions is little more than a toy API.

Also look at os.sleepAsync()

Note that existing os.setReadHandler(), os.setWriteHandler() offer an callback API for send/recv.

Oh, sorry, missed that. Well, then I think it covers all my use cases (together with your proposed SOCK_NONBLOCK).

If you can also use os.setReadHandler() after listen() and os.setWriteHandler() after a connect() call, then I think everything is covered. Have you tested that?

Adding *Async variant to all os IO function could be done in a separate PR.

then it would be great to provide the options to make the sockets non-blocking:

    fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFL, 0) | O_NONBLOCK);

agree, we could either go for:

  • a plain API: add fcntl() + EWOULDBLOCK/EAGAIN support
  • a linux-ish API : allow SOCK_NONBLOCK flag at socket() creation which will do the fcntl() under the hood to work on all OS.

I have a preference for the last one but I'll let @bellard have the last word.

I also like SOCK_NONBLOCK fwiw. It seems supported by all the BSDs as well as Linux.

I'll also add a poll() wrapper

I don't think it's the right approach, because once again your poll() function will be synchronous and block other events.
It needs to be integrated in the main quicks_libc.c select() loop.

But in any case, this is moot because if SOCK_NONBLOCK can be set to a socket FD and os.setReadHandler() and os.setWriteHandler() can be attached to sockets FDs, then I believe everything can be built from these primitives.

A connectAsync() that returns a Promise like sleepAsync() would certainly be nice, but as you said, that can be added later.
Same for acceptAsync() BTW.

@yne
Copy link
Author

yne commented May 25, 2025

If you can also use os.setReadHandler() after listen() and os.setWriteHandler() after a connect() call, then I think everything is covered. Have you tested that?

I tested it (both on sync/async socket), It kinda works, but for some reason it also call the handler when there is nothing to recv/send.

[...] poll() function will be synchronous and block other events.
It needs to be integrated in the main quicks_libc.c select() loop.

Yes, we either

  • move all socket API to async (I've looked at os.sleepAsync(), I understood the gist of it event loop registration but i'm still unsure about how to translate it to sockets)
  • just move the poll() one to async.

@ceedriic
Copy link

If you can also use os.setReadHandler() after listen() and os.setWriteHandler() after a connect() call, then I think everything is covered. Have you tested that?

I tested it (both on sync/async socket), It kinda works, but for some reason it also call the handler when there is nothing to recv/send.

If the handler is called when there is "nothing to recv", then I don't understand, unless the socket is in error (if I remember correctly, a socket in error or has reached EOF will be reported as "readable" because a read() call will not block).

However, I don't know what you mean when you say the handler is called when there is nothing to send: a write handler should be called when there is room in the socket buffer for a write, in other words when a write() call will not block. so after a successful connect, it is normal that the write handler is called.

In the case of an async socket connection, the connect() call will return EINPROGRESS and operate in the background, then you can register the FD for select(write), and the write handler will be notified when the connection is done (or has failed) which is the time a write call to the socket will succeeds.

[...] poll() function will be synchronous and block other events.
It needs to be integrated in the main quicks_libc.c select() loop.

Yes, we either

  • move all socket API to async (I've looked at os.sleepAsync(), I understood the gist of it event loop registration but i'm still unsure about how to translate it to sockets)

It probably translate differently for the different functions, but the easiest and most important to start with are probably connect and accept. For connect, I guess we want to be able to write something like:

await os.connectAsync(sockfd, address)

To do that, you can follow the general sequence described in the first answer here:

https://stackoverflow.com/questions/17769964/linux-sockets-non-blocking-connect

So:

  1. make sure the socket is SOCK_NONBLOCK (or put it in that state temporarily with fcntl())
  2. call connect(fd, address), then there is 3 possible return values:
    2.1) if 0 (which means the connect is already successful likely because it was done on the localhost) then call the promise success handler.
    2.2) if value is an error other then EINPROGRESS then call the promise error handler, the connect() call has already failed.
    2.3) if it is EINPROGRESS, it means the connection is in progress (and address is not in localhost). In this case, you need to register the socket for write (like os.setWriteHandler) on the quickjs event loop.
  3. when the quickjs event loop reports that the socket is signaled for write, call getsockopt(fd, SOL_SOCKET, SO_ERROR, ...) which will tell you if the connection succeeded or not. you can then call the promise success or error handler.

Does that make sense?

@ceedriic
Copy link

ceedriic commented May 25, 2025

Similarly, I guess we want to be able to write something like

newSocket = await os.acceptAsync(sockfd);

or

os.acceptAsync(sockfd).then((newSocket) => {
    // do something with newSocket
})

So we need something like:

  1. make sure the socket is SOCK_NONBLOCK (or put it in that state temporarily with fcntl())
  2. call accept(fd, address), then there is 3 possible return values:
    2.1) if 0 or positive, a new socket is already available and the promise can be successfully resolved.
    2.2) if value is an error other then EWOULDBLOCK/EAGAIN then call the promise error handler, the accept() call has already failed.
    2.3) if it is EWOULDBLOCK/EAGAIN, it means we need to wait for a new connection to be accepted. In this case, you need to register the listening socket for read (like os.setReadHandler) on the quickjs event loop.
    when the quickjs event loop reports that the socket is signaled for read, call again accept() and call the promise success or error handler based on accept() return value.

@ceedriic
Copy link

Finally, for the send/recv calls, I think the os.setReadHandler and os.setWriteHandler functionality is enough for real world applications. Or maybe sendAsync() and recvAsync() could be added exactly like accept(): first trying a normal send / recv with the socket in NONBLOCK mode, and repeat in the quickjs event loop as long as EWOULDBLOCK/EAGAIN is received.

In any case I think a good high level test that the API is good enough would be to rewrite your two nice http client + server examples in such a way that they work both together in the same quickjs instance (start the server on localhost 8080, then connect to the same port with the client) :)

I will stop here but if you want I could try to write one of these functions.

Thanks you.

@yne
Copy link
Author

yne commented May 25, 2025

  • I meant it also call the handler when there is nothing to recv. => indeed because the fd was closed. So a correct handler would need to unregister itself:
const chunk = new Uint8Array(16);
os.setReadHandler(sockfd, ()=> {
    const ret = os.recv(sockfd, chunk.buffer);
    if(!ret) os.setReadHandler(sockfd, null);
    else console.log(String.fromCharCode(...chunk.slice(0, ret)));
})

All your JS async connect/listen/accept suggestions makes sens, but I'm just unsure of the intricacies with the event loop. For example what will happen with

acceptAsync(); // no await
await acceptAsync()
  • The first acceptAsync will do it job in background
  • before reaching the event loop select() we call acceptAsync() again
  • event loop select() is called to wait for acceptAsync() completion => (it accept both ?)
  • event loop select() is called to wait for acceptAsync() completion => will wait forever since all has been settled already

I gave you access to my branch if you want to push an eventloop-based accept/listen/connect prototype

Since my initial goal was to create an light and portable GUI-based JS webapp (cilent+server) I'll

  • wait for feedback on the "sync" part of this socket API
  • continue to play with it possibilities (e.g: using Worker, or while(poll([fd],1))await os.sleepAsync() promise wrapper )

@ceedriic
Copy link

All your JS async connect/listen/accept suggestions makes sens, but I'm just unsure of the intricacies with the event loop. For example what will happen with

acceptAsync(); // no await
await acceptAsync()
  • The first acceptAsync will do it job in background
  • before reaching the event loop select() we call acceptAsync() again
  • event loop select() is called to wait for acceptAsync() completion => (it accept both ?)
  • event loop select() is called to wait for acceptAsync() completion => will wait forever since all has been settled already

Good questions.

I gave you access to my branch if you want to push an eventloop-based accept/listen/connect prototype

Ok, thank you, I will try to see if I can do something. I hope I won't mess anything with the branch because I'm not very good with git...

@bellard
Copy link
Owner

bellard commented May 26, 2025

Here some remarks:

  • js_os_poll() should not be renamed.
  • I don't think that adding poll() is relevant to this patch. setReadHandler() and setWriteHandler() should suffice.
  • async functions can be implemented in Javascript with setReadHandler() and set setWriteHandler(). If not, then there is a problem in the API. You can provide examples in your HTTP client and server.
  • If JS_IsException() returns TRUE, then the calling function must return an exception. This is not the case e.g. in JS_toSockaddrStruct. Moreover, all return values of JS API must be tested and handled.
  • using SOCK_NONBLOCK in second argument of socket() is a good idea to set the O_NONBLOCK flag. Another possibility is to open the socket in non blocking mode by default and to add a SOCK_BLOCK flag to make it non blocking.
  • only os.send() and os.recv() seem necessary with optional flags and sockaddr parameters.
  • implementing IPv6 is necessary in order to have an example of another sockaddr type.

@ceedriic
Copy link

Here some remarks:

  • async functions can be implemented in Javascript with setReadHandler() and set setWriteHandler(). If not, then there is a problem in the API. You can provide examples in your HTTP client and server.

@yne: if you want, after you provide the nonblocking sockets, I can write the async wrappers in Javascript, let me know.

@yne
Copy link
Author

yne commented May 31, 2025

  • poll() API removed
  • reverted js_os_poll to be the event loop pooling
  • more JS_*() catched (sorry I'll do a global pass at the end)
  • IPv6 shall now work thanks to struct sockaddr_storage
  • socket is now non blocking mode by default, unless SOCK_BLOCK flag is given
  • Fixed os.send() and os.recv() optional parameters
  • async functions can be implemented in Javascript with setReadHandler()/setWriteHandler().

I'm now more confident with the internal poll loop of quickjs so I would prefer to offer native async socket API than forcing everybody to wrap with setReadHandler / setWriteHandler.

I'm planning on offering this API (os.connect is now async):

bind    (sockfd: FileDescriptor, addr: SocketAddr   ): Result<Success>;
listen  (sockfd: FileDescriptor, backlog?: number   ): Result<Success>;
shutdown(sockfd: FileDescriptor, type: SocketShutOpt): Result<Success>;
connect (sockfd: FileDescriptor, addr: SocketAddr   ): Promise<Result<Success>>;
accept  (sockfd: FileDescriptor                     ): Promise<[remotefd: FileDescriptor, remoteaddr: SocketAddr]>;
recv    (sockfd: FileDescriptor, buffer: ArrayBuffer, length?: number                  ): Promise<Result<number>>;
send    (sockfd: FileDescriptor, buffer: ArrayBuffer, length?: number                  ): Promise<Result<number>>;
recvfrom(sockfd: FileDescriptor, buffer: ArrayBuffer, length?: number                  ): Promise<[total: Result<number>, from: SocketAddr]>;
sendto  (sockfd: FileDescriptor, buffer: ArrayBuffer, length : number, addr: SocketAddr): Promise<Result<number>>;
//with type Result<T> = T | NegativeErrno;

The changes to provide a native async API (as I plan it):

  • js_os_connect/accept/recv/send shall:
    • if magic==connect: call connect() as it's the only one that need initiation call
    • list_add_tail(os_sock_handlers) with magic, and sockfd put as readfds for accept/recv and writefds for connect/send
    • return the JS_NewPromiseCapability to JS-land
  • js_os_poll shall:
    • include os_sock_handlers's sockfd in it select() readfds/writefds
    • if FD_ISSET(readfds|writefds) on a socket: handle_socket_message according to it magic:
      • connect: if getsockopt() is SO_ERROR => reject(), else resolve()
      • accept/recv/send: if accept/recv/send() is EAGAIN or EWOULDBLOCK => continue polling; else reject() if <0 else resolve()

NB: each reject()/resolve() also list_del()

@ceedriic non-blocking socket (via O_NONBLOCK flag) are available since c087718 if you want to try

@ceedriic
Copy link

ceedriic commented Jun 3, 2025

bind    (sockfd: FileDescriptor, addr: SocketAddr   ): Result<Success>;
listen  (sockfd: FileDescriptor, backlog?: number   ): Result<Success>;
shutdown(sockfd: FileDescriptor, type: SocketShutOpt): Result<Success>;
connect (sockfd: FileDescriptor, addr: SocketAddr   ): Promise<Result<Success>>;
accept  (sockfd: FileDescriptor                     ): Promise<[remotefd: FileDescriptor, remoteaddr: SocketAddr]>;
recv    (sockfd: FileDescriptor, buffer: ArrayBuffer, length?: number                  ): Promise<Result<number>>;
send    (sockfd: FileDescriptor, buffer: ArrayBuffer, length?: number                  ): Promise<Result<number>>;
recvfrom(sockfd: FileDescriptor, buffer: ArrayBuffer, length?: number                  ): Promise<[total: Result<number>, from: SocketAddr]>;
sendto  (sockfd: FileDescriptor, buffer: ArrayBuffer, length : number, addr: SocketAddr): Promise<Result<number>>;
//with type Result<T> = T | NegativeErrno;

As far as I'm concerned, an API like that would be fantastic.

* connect: if getsockopt() is SO_ERROR => reject(), else resolve()
* accept/recv/send: if accept/recv/send() is EAGAIN or EWOULDBLOCK => continue polling; else reject() if <0 else resolve()

Great (you also need to poll for write for the connect+EAGAIN case, but I suppose it's your plan)

@ceedriic non-blocking socket (via O_NONBLOCK flag) are available since c087718 if you want to try

Well, If you can deliver the above API and it is accepted by the project, I don't think I need to spend any time on a javascript implementation, this will be better 😄. When you've something ready to test, let me know and I'll try your code.

The only possible improvement I see (which again can - and should - be done later or in javascript) that would be nice to have would be to provide an optional fully boolean to ask the function to loop on short reads / short writes. Something like (not sure it's valid TypeScript):

recv    (sockfd: FileDescriptor, buffer: ArrayBuffer, length?: number, boolean?: fully): Promise<Result<number>>;
send    (sockfd: FileDescriptor, buffer: ArrayBuffer, length?: number, boolean?: fully): Promise<Result<number>>;

Because sometimes you want short reads or short writes, but often (in the case of send for example) you would prefer to have all the bytes written on the sockets buffer before the promise is resolved (same for read if you're implementing a binary protocol and know in advance the exact number of bytes you want to read)

So it would simplify user code if you can write something as simple as:

    const fdSa = await os.accept();
    await os.send(fdSa[0], "Hello World\n", true));   /* fully = true */
    os.close(fdSa[0]);

Without having to loop on short writes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants