-
Notifications
You must be signed in to change notification settings - Fork 999
os.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
base: master
Are you sure you want to change the base?
Conversation
I am interested by the feature. Here are some suggestions:
|
90f098c
to
15a2ff3
Compare
Okay here is an update.
os.setsockopt(sock_srv, os.SO_REUSEADDR, new Uint32Array([1]).buffer)
ret = send (sockfd, buf, len?)
ret = sendto (sockfd, buf, len,saddr)
ret = recv (sockfd, buf, len?)
[ret,saddr] = recvfrom(sockfd, buf, len?)
In addition to the given http_server example, what client demo would be nice ? |
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.
And a wrapper for the select() function. |
For now yes, because all Note that existing Adding *Async variant to all
agree, we could either go for:
I have a preference for the last one but I'll let @bellard have the last word. I'll also add a 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); |
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()
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?
I also like SOCK_NONBLOCK fwiw. It seems supported by all the BSDs as well as Linux.
I don't think it's the right approach, because once again your poll() function will be synchronous and block other events. 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. |
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.
Yes, we either
|
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.
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:
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:
Does that make sense? |
Similarly, I guess we want to be able to write something like
or
So we need something like:
|
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. |
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 acceptAsync(); // no await
await acceptAsync()
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
|
Good questions.
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... |
Here some remarks:
|
@yne: if you want, after you provide the nonblocking sockets, I can write the async wrappers in Javascript, let me know. |
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):
NB: each reject()/resolve() also list_del() @ceedriic non-blocking socket (via O_NONBLOCK flag) are available since c087718 if you want to try |
2617f22
to
08f1155
Compare
As far as I'm concerned, an API like that would be fantastic.
Great (you also need to poll for write for the connect+EAGAIN case, but I suppose it's your plan)
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
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:
Without having to loop on short writes. |
I've finished the api and it's a joy to play with so @ceedriic don't hésitante to play with CHANGES:
//sync calls
getaddrinfo(node: string, service: string ): Result<Array<SocketAddr>>;
socket (family: SocketFamily, type: SocketType ): Result<FileDescriptor>;
getsockname(sockfd: FileDescriptor ): Result<SocketAddr>;
getsockopt (sockfd: FileDescriptor, name: SocketOpt, data: ArrayBuffer): Result<Success>;
setsockopt (sockfd: FileDescriptor, name: SocketOpt, data: ArrayBuffer): Result<Success>;
bind (sockfd: FileDescriptor, addr: SocketAddr ): Result<Success>;
listen (sockfd: FileDescriptor, backlog?: number ): Result<Success>;
shutdown (sockfd: FileDescriptor, type: SocketShutOpt ): Result<Success>;
//async calls
accept (sockfd: FileDescriptor ): Promise<[remotefd: FileDescriptor, remoteaddr: SocketAddr]>;
connect (sockfd: FileDescriptor, addr: SocketAddr ): Promise<Result<Success>>;
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, addr: SocketAddr, buffer: ArrayBuffer, length?: number): Promise<Result<number>>; functional tests done on Cosmo+Linux env (TODO: w10):
const sockfd = must(os.socket(os.AF_INET, os.SOCK_STREAM));
await os.connect(sockfd, { addr: "51.15.168.198", port: 80}); // bellard.org
const httpReq = ["GET / HTTP/1.0", "", ""].join('\r\n')
await os.send(sockfd, Uint8Array.from(httpReq, c => c.charCodeAt(0)).buffer);
const chunk = new Uint8Array(512);
const recvd = await os.recv(sockfd, chunk.buffer);
console.log([...chunk.slice(0,recvd)].map(c => String.fromCharCode(c)).join(''));
const sockfd = must(os.socket(os.AF_INET6, os.SOCK_DGRAM));
await os.sendto(sockfd, { addr: "2610:20:6f97:97::6", port: 37 }, new ArrayBuffer());
const u32 = new Uint32Array(1);
await os.recvfrom(sockfd, u32.buffer);
const seconds1900 = new DataView(u32.buffer).getUint32(0)
console.log(`${seconds1900} have passed since 1900 (${seconds1900/60/60/24/365} years)`); What's bugging me:
const str = (new std.TextDecoder()).decode // TODO in a later PR
console.log(str(await os.recv(sockfd, new ArrayBuffer(1024))))
to keep things simple I did not included the websocket handling in the HTTP server example. const WSGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
await sendLines(sock_cli, [
"HTTP/1.1 101 Switching Protocols", "Upgrade: websocket", "Connection: Upgrade",
`Sec-WebSocket-Accept: ${sha1base64(headers.get("Sec-WebSocket-Key") + WSGUID)}`, '', ''
]);
const sendStr = (str = "") => os.send(sock_cli, Uint8Array.from([0x81, str.length, ...str.split("").map(x => x.charCodeAt(0))]).buffer);
await sendStr("welcome to quickjs websocket REPL");
const hdr = new DataView(new Uint8Array(2 + 4).buffer);
const payload = new Uint8Array(127);
while (await os.recv(sock_cli, hdr.buffer) == hdr.byteLength) {
if ((hdr.getUint8(0) & 0x0F) == 8) break;
const got = await os.recv(sock_cli, payload.buffer, hdr.getUint8(1) & 0x7F);
const unmasked = String.fromCharCode(...payload.slice(0, got).map((b, i) => b ^ hdr.getUint8(2 + (i % 4))));
await sendStr(await (async (x = "") => `${eval(x)}`)(unmasked).catch(e => `${e}`)).catch(e => -1);
}
return os.close(sock_cli); web client: <form autocomplete=off>
<pre><output name=out></output></pre>
<input hidden name=q placeholder=$>
<input name=o type=button value=open onclick=document.body.onload()>
</form>
<script type=module>
let ws,form = document.forms[0];
form.o.onclick=function(){
ws = new WebSocket(location.href.replace(/^http/,'ws'));
ws.onerror = (error) => console.error('WebSocket error:', error);
ws.onopen = ws.onclose = (ev) => form.o.hidden=!(form.q.hidden=ws.readyState!=1)
ws.onmessage = function(ev) {
form.out.innerHTML+=ev.data+'\n'
window.scrollTo(0,document.body.scrollHeight)
}
}
form.onsubmit = function() {
form.out.innerHTML += '$ '+this.q.value+'\n';
ws.send(this.q.value);
this.q.value='';
return false;
}
</script> |
@bellard : JS sockets are working with winsock2 API (tested HTTP client + HTTP server via wine/mingw32_64) In addition to my previous suggestions, I would like to point that getaddrinfo often return INET6 addr as [0] const [ai] = os.getaddrinfo("bellard.org",'80'); while socket is on INET this make Proposed solutions:
const ai = os.getaddrinfo("bellard.org",'80').find(a => a.family == os.AF_INET);
os.getaddrinfo("bellard.org",'80', { family: os.AF_INET } )
os.getaddrinfo("bellard.org",'80',os.AF_INET) // or maybe
await os.connect(sockfd, os.getaddrinfo("bellard.org",'80')); I would vote for the |
Great! |
@yne Adding a parameter to getaddrinfo() is the best solution. The "hints" parameter of getaddrinfo is made for that. It should be possible to specify at least hints.ai_family and hints.ai_socktype. Note that the "service" parameter is also optional and less important than the "hints" parameter. The API could be like: getaddrinfo(host, { service: "80", family: os.AF_INET, socktype: os.SOCK_STREAM }) |
@bellard done ✔️ Also, returned const [addr] = must(os.getaddrinfo("bellard.org", { service: 80 }));
const sockfd = must(os.socket(addr.family, addr.socktype));
await os.connect(sockfd, addr); And here is the std.encode/decode draft (pretty sure it also add an UAF vulnerability, so I pushed it on a separate branch) Next goal: qjs debugging///@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";
//import { sha1base64 } from "./sha1.js"
/** @template T @param {os.Result<T>} result @returns {T} */
function must(result) {
if (typeof result === "number" && result < 0) throw result;
return /** @type {T} */ (result)
}
const [addrinfo] = must(os.getaddrinfo("localhost", { service: 9229, socktype: os.SOCK_STREAM }));
const sock_srv = must(os.socket(addrinfo.family, addrinfo.socktype));
must(os.setsockopt(sock_srv, os.SO_REUSEADDR, new Uint32Array([1]).buffer));
must(os.bind(sock_srv, addrinfo))
must(os.listen(sock_srv))
const profileId = "98bf008b-13be-4d6d-b479-bc67bc20f729";
console.log(`Debugger listening on ws://${addrinfo.addr}:${addrinfo.port}/${profileId} ...`)
//https://learn.microsoft.com/en-us/microsoft-edge/devtools/protocol/
const GET = {
'^/$': () => ['HTTP/1.1 200 OK', 'Content-Type: text/html', '', `use a websocket profiler`],
'^/json/version$': () => ['HTTP/1.1 200 OK', 'Content-Type: application/json', '', JSON.stringify({})],
'^/json/list$': ({ addrinfo }) => ['HTTP/1.1 200 OK', 'Content-Type: application/json', '', JSON.stringify([{
"type": "node", "title": import.meta.url, "url": import.meta.url, "webSocketDebuggerUrl": `ws://${addrinfo.addr}:${addrinfo.port}/${profileId}`
}])]
}
/** @param {os.FileDescriptor} fd @param {string[]} lines */
function sendLines(fd, lines) {
const buf = Uint8Array.from(lines.join('\r\n'), c => c.charCodeAt(0));
return os.send(fd, buf.buffer);
}
/** @param {os.FileDescriptor} fd @param {number} len*/
async function recv(fd, len = 1) {
const buf = new Uint8Array(len);
must(await os.recv(fd, buf.buffer) == buf.byteLength);
return new DataView(buf.buffer);
}
while (true) {
const [sock_cli] = await os.accept(sock_srv); // we don't care about sockaddr
const sock_cli_r = std.fdopen(sock_cli, "r");
if (sock_cli_r == null) throw "";
const [method, path, http_ver] = sock_cli_r.getline()?.trimEnd().split(' ');
const headers = new Map();
for (let line; line = sock_cli_r.getline()?.trimEnd();) {
const index = line.indexOf(': ');
headers.set(line.slice(0, index), line.slice(index + 2));
}
// Sec-WebSocket-Key often set to "dGhlIHNhbXBsZSBub25jZQ==" => return Sec-WebSocket-Accept = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
// otherwise would require SHA1 compute: ${sha1base64(headers.get("Sec-WebSocket-Key") + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")}
if (headers.has("Sec-WebSocket-Key")) {
await sendLines(sock_cli, ["HTTP/1.1 101 Switching Protocols", "Upgrade: websocket", "Connection: Upgrade", `Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=`, '', '']);
const sendStr = (str = "") => os.send(sock_cli, Uint8Array.from([0x81, str.length, ...str.split("").map(x => x.charCodeAt(0))]).buffer);
while (true) {
const fin1_res3_op4 = (await recv(sock_cli)).getUint8(0);
if (!fin1_res3_op4) { os.sleep(500); continue }
if ((fin1_res3_op4 & 0x0F) == 8) break;// only support TEXT opcode
const len7 = (await recv(sock_cli)).getUint8(0) & 0x7F;
const len16 = len7 == 126 ? (await recv(sock_cli, 2)).getUint16(0) : len7;
const mask32 = await recv(sock_cli, 4);
const payload = new Uint8Array(len16);
await os.recv(sock_cli, payload.buffer);
const jsonPayload = String.fromCharCode(...payload.map((b, i) => b ^ mask32.getUint8(i % 4)));
try { JSON.parse(jsonPayload) } catch (e) { console.log("invalidJSON:", len7, len16, jsonPayload); continue }
const { id, method, ...args } = JSON.parse(jsonPayload);
const debuggerId = "30.-19"
const replies = {
"Runtime.enable": () => [{ id, result: {} }, { method: "Runtime.executionContextCreated", params: { context: { id: 1, origin: "", name: "quickjs[65405]", uniqueId: debuggerId, auxData: { isDefault: true } } } }],
"Runtime.runIfWaitingForDebugger": () => [{ id, result: {} },{"method":"Debugger.scriptParsed","params":{"scriptId":"1","url":"node:internal/bootstrap/realm","startLine":0,"startColumn":0,"endLine":458,"endColumn":0,"executionContextId":1,"hash":"0bf9dbe21ca95845538b501033e894566bfbba1d","executionContextAuxData":{"isDefault":true},"isLiveEdit":false,"sourceMapURL":"","hasSourceURL":false,"isModule":false,"length":14583,"scriptLanguage":"JavaScript","embedderName":"node:internal/bootstrap/realm"}}],
"Debugger.enable": () => [{ id, result: { debuggerId } }],
"Debugger.setPauseOnExceptions": () => [{ id, result: { state: "all" } }],
"Debugger.setAsyncCallStackDepth": () => [{ id, result: {} }],
"Debugger.setBlackboxPatterns": () => [{ id, result: {} }],
"Debugger.stepOver": () => [{ method: "Debugger.resumed", params: {} }, { id, result: {} }],
"Debugger.getScriptSource": () => [{ id, result: { scriptSource: "this is a fake debug from QuickJS" } }],
"Profiler.enable": () => [{ id, result: {} }],
"Runtime.compileScript": () => [{ id, result: {} }],
"": () => [{ id, error: { code: -1, message: `Unsupported ${method}` } }]
}
const reply = (replies[method] || replies[''])();
console.log({ id, method, ...args }, reply);
for (const r of reply)
await sendStr(JSON.stringify(r));
}
os.close(sock_cli);
} else {
const sock_cli_w = std.fdopen(sock_cli, "w");
if (sock_cli_w == null) throw "";
console.log(method, path, http_ver);
const endpoint = Object.entries(GET).find(([re]) => path.match(new RegExp(re)))?.[1];
const notFound = (_) => ['HTTP/1.1 404', '', `No route to ${path}`];
const lines = (endpoint || notFound)({ addrinfo });
sock_cli_w.puts(lines.join('\r\n'));
sock_cli_w.close();
}
}
|
This PR aim to start a discussion about the future socket API.
My goal is use cosmo+QuickJS/HTTP server to offer a portable cross-platform GUI.
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:
socket()
currently return a TCP fd,listen()
default to a backlog of 10)SockAddr()
constructor (with .parse() and .toString()) instead of the current plain{address:number,port:number}
object used inbind()
andconnect()
os
related thanstd
SO_REUSEADDR
I added, and add a setsockopt wrapper (but just for this usecase ?)Once agreed, I'll add required unit tests/docs ...