diff --git a/packages/cli/test/native-link-info.test.ts b/packages/cli/test/native-link-info.test.ts index b4b11fa95..ef07c838d 100644 --- a/packages/cli/test/native-link-info.test.ts +++ b/packages/cli/test/native-link-info.test.ts @@ -72,7 +72,7 @@ describe.runIf(supported)("macOS arm64 native link info", () => { minimum_os: "14.0", }, program: { object, entry_symbol: "main" }, - runtime_abi: { version: 43, marker: "scr_runtime_abi_v43" }, + runtime_abi: { version: 44, marker: "scr_runtime_abi_v44" }, runtime_pack: { kind: "precompiled", package: "@scriptc/runtime-darwin-arm64", @@ -165,6 +165,6 @@ describe.runIf(supported)("macOS arm64 native link info", () => { () => null, (failure: { stderr?: string }) => failure, ); - expect(error?.stderr).toContain("scr_runtime_abi_v43"); + expect(error?.stderr).toContain("scr_runtime_abi_v44"); }); }); diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 1924baece..463808a1a 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -5322,10 +5322,10 @@ declare module "node:zlib" { * with the real port discoverable as address().port (the composed read * is the lowered form — bare address() has no lowering); close([cb]) * firing once connections drain; socket write/end/destroy/pipe and the - * data/end/close/error/connect events (on and once — listeners take at - * most the declared parameter). write/end return void here (Node returns - * the backpressure boolean / the socket); on/once return void (chaining - * is fenced). connect's host is a numeric IP or "localhost" (pinned to + * data/end/close/error/connect/drain events (on and once — listeners + * take at most the declared parameter). write returns Node's + * backpressure boolean; end returns void here (Node returns the socket); + * on/once return void (chaining is fenced). connect's host is a numeric IP or "localhost" (pinned to * 127.0.0.1 — SEMANTICS.md); every failure is the async 'error' event, * and an 'error' with no listener exits 1 like an unhandled EventEmitter * 'error'. */ @@ -5357,7 +5357,13 @@ declare module "net" { readonly bytesWritten: number; /* write's callback runs once the data is flushed, with null; end's * once the socket finishes. */ - write(data: string | Uint8Array, callback?: (err?: Error | null) => void): void; + /* false when the bytes this write left unsent reach the 65536-byte + * high-water mark, as in Node; 'drain' fires when they are all sent. */ + write(data: string | Uint8Array, callback?: (err?: Error | null) => void): boolean; + /* the bytes written and not yet sent */ + readonly writableLength: number; + /* a write answered false and 'drain' has not fired yet */ + readonly writableNeedDrain: boolean; end(callback?: () => void): void; end(data: string | Uint8Array, callback?: () => void): void; destroy(): void; @@ -5405,6 +5411,7 @@ declare module "net" { | "connect" | "ready" | "timeout" + | "drain" | "readable" | "finish" | "secureConnect" @@ -5429,6 +5436,7 @@ declare module "net" { | "connect" | "ready" | "timeout" + | "drain" | "readable" | "finish" | "secureConnect" @@ -5452,6 +5460,7 @@ declare module "net" { | "connect" | "ready" | "timeout" + | "drain" | "readable" | "finish" | "secureConnect" diff --git a/packages/compiler/src/backend/llvm/lib-network.ts b/packages/compiler/src/backend/llvm/lib-network.ts index d49bbde14..35eb885cd 100644 --- a/packages/compiler/src/backend/llvm/lib-network.ts +++ b/packages/compiler/src/backend/llvm/lib-network.ts @@ -309,6 +309,7 @@ export function emitNetworkHttpLibCall(host: LlvmEmitterContext, e: LibCallExpr) e.fn === "net.sockOnClose" || e.fn === "net.sockOnConnect" || e.fn === "net.sockOnTimeout" || + e.fn === "net.sockOnDrain" || e.fn === "net.sockOnReadable" || e.fn === "net.sockOnReady" || e.fn === "http.reqOnEnd" || @@ -329,6 +330,7 @@ export function emitNetworkHttpLibCall(host: LlvmEmitterContext, e: LibCallExpr) "net.sockOnClose": "scr_net_sock_on_close", "net.sockOnConnect": "scr_net_sock_on_connect", "net.sockOnTimeout": "scr_net_sock_on_timeout", + "net.sockOnDrain": "scr_net_sock_on_drain", "net.sockOnReadable": "scr_net_sock_on_readable", "net.sockOnReady": "scr_net_sock_on_ready", "http.reqOnEnd": "scr_http_req_on_end", diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index def2dd6bf..2548b75d5 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1337,6 +1337,8 @@ export const LIB_FN_SYMS: Record = { "net.sockResetAndDestroy": "scr_net_sock_reset_and_destroy", "net.sockAddress": "scr_net_sock_address", "net.sockBytesWritten": "scr_net_sock_bytes_written", + "net.sockWritableLength": "scr_net_sock_writable_length", + "net.sockWritableNeedDrain": "scr_net_sock_writable_need_drain", "net.sockAddrField": "scr_net_sock_addr_field", "net.sockBytesRead": "scr_net_sock_bytes_read", "net.sockConnecting": "scr_net_sock_connecting", diff --git a/packages/compiler/src/backend/runtime-abi.ts b/packages/compiler/src/backend/runtime-abi.ts index d8e256349..7ac0877eb 100644 --- a/packages/compiler/src/backend/runtime-abi.ts +++ b/packages/compiler/src/backend/runtime-abi.ts @@ -2,8 +2,8 @@ * of the link contract: a program object keeps this symbol undefined and the * matching runtime defines it, so incompatible manual links fail before the * program can start. */ -export const RUNTIME_ABI_VERSION = 43 as const; -export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v43" as const; +export const RUNTIME_ABI_VERSION = 44 as const; +export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v44" as const; /** Object consumption is useful today, but the complete scr_* surface may * still change before 1.0. The versioned marker prevents accidental mixing; diff --git a/packages/compiler/src/backend/runtime-pack-targets.test.ts b/packages/compiler/src/backend/runtime-pack-targets.test.ts index 5da2b8e59..650e7c543 100644 --- a/packages/compiler/src/backend/runtime-pack-targets.test.ts +++ b/packages/compiler/src/backend/runtime-pack-targets.test.ts @@ -52,7 +52,7 @@ async function fixture(target: NativeTargetSpec, workers = false) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 43, marker: "scr_runtime_abi_v43" }, + runtime_abi: { version: 44, marker: "scr_runtime_abi_v44" }, compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple }, macros: { executable: [], excluded: ["SCR_LIB"], sanitizer: "external-toolchain-required" }, flavors: { diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts index a87fabd2e..443c22d9e 100644 --- a/packages/compiler/src/backend/runtime-pack.test.ts +++ b/packages/compiler/src/backend/runtime-pack.test.ts @@ -129,7 +129,7 @@ async function fixture(target: NativeTargetSpec = MACOS_ARM64_TARGET) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 43, marker: "scr_runtime_abi_v43" }, + runtime_abi: { version: 44, marker: "scr_runtime_abi_v44" }, compiler: { command: "clang", identity: "fixture clang", diff --git a/packages/compiler/src/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index 7359b56a8..614d6a206 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -2425,7 +2425,9 @@ function lowerNetSocketMethodCall( }; } if (name === "write" || name === "end") { - requireStatementPosition(lowerer, call, `socket.${name}(...)`); + // write() answers Node's backpressure boolean; end() stays a statement + if (name === "end") requireStatementPosition(lowerer, call, `socket.${name}(...)`); + const resultT = name === "write" ? BOOL : VOID; // write(chunk, encoding) — the two-argument encoding form: 'buffer' // beside a string chunk is Writable's encoding check (a synchronous // ERR_UNKNOWN_ENCODING TypeError, "Unknown encoding: buffer" — the @@ -2451,7 +2453,7 @@ function lowerNetSocketMethodCall( const socket = coerceToHandle(lowerer, access.expression, NETSOCKET_T); const chunk = lowerer.lowerExpr(args[0]!); const fn: IrLibFn = chunk.type.kind === "string" ? "net.sockWrite" : "net.sockWriteBytes"; - return { kind: "libCall", fn, args: [socket, chunk], type: VOID, loc }; + return { kind: "libCall", fn, args: [socket, chunk], type: BOOL, loc }; } } } @@ -2510,7 +2512,7 @@ function lowerNetSocketMethodCall( NARROW_DATA_HINT, ); } - main = { kind: "libCall", fn, args: [receiver(), data], type: VOID, loc }; + main = { kind: "libCall", fn, args: [receiver(), data], type: resultT, loc }; } const cb = lowerer.lowerExpr(last); let register: IrExpr; @@ -2531,8 +2533,24 @@ function lowerNetSocketMethodCall( loc, }; } - stmts.push({ kind: "exprStmt", expr: main, loc }); - return { kind: "seqExpr", stmts, result: register, type: VOID, loc }; + if (resultT.kind === "void") { + stmts.push({ kind: "exprStmt", expr: main, loc }); + return { kind: "seqExpr", stmts, result: register, type: VOID, loc }; + } + // write(data, cb): the data is written, the callback registered, and + // the write's answer is the value + const okLocal = lowerer.declareHiddenLocal("%sockWriteOk", BOOL); + stmts.push( + { kind: "varDecl", localId: okLocal.id, init: main, loc }, + { kind: "exprStmt", expr: register, loc }, + ); + return { + kind: "seqExpr", + stmts, + result: { kind: "varRef", localId: okLocal.id, type: BOOL, loc }, + type: BOOL, + loc, + }; } const maxArgs = name === "write" ? 1 : 1; const minArgs = name === "write" ? 1 : 0; @@ -2552,17 +2570,17 @@ function lowerNetSocketMethodCall( const data = lowerer.lowerExpr(args[0]!); if (data.type.kind === "string") { const fn: IrLibFn = name === "write" ? "net.sockWrite" : "net.sockEndStr"; - return { kind: "libCall", fn, args: [receiver, data], type: VOID, loc }; + return { kind: "libCall", fn, args: [receiver, data], type: resultT, loc }; } if (data.type.kind === "bytes" && data.type.elem === "u8") { const fn: IrLibFn = name === "write" ? "net.sockWriteBytes" : "net.sockEndBytes"; - return { kind: "libCall", fn, args: [receiver, data], type: VOID, loc }; + return { kind: "libCall", fn, args: [receiver, data], type: resultT, loc }; } if (data.type.kind === "dyn") { // An untyped JS payload (a helper's parameter): the runtime // dispatches STR/BYTES and throws Node's chunk TypeError otherwise. const fn: IrLibFn = name === "write" ? "net.sockWriteDyn" : "net.sockEndDyn"; - return { kind: "libCall", fn, args: [receiver, data], type: VOID, loc }; + return { kind: "libCall", fn, args: [receiver, data], type: resultT, loc }; } lowerer.noLowering( `${name} of '${lowerer.fmt(data.type)}' data`, @@ -2944,6 +2962,7 @@ function lowerNetSocketMethodCall( event === "connect" || event === "ready" || event === "timeout" || + event === "drain" || event === "readable" ) { const { cb } = lowerCallbackArg( @@ -2962,11 +2981,13 @@ function lowerNetSocketMethodCall( ? "net.sockOnClose" : event === "timeout" ? "net.sockOnTimeout" - : event === "readable" - ? "net.sockOnReadable" - : event === "ready" - ? "net.sockOnReady" - : "net.sockOnConnect"; + : event === "drain" + ? "net.sockOnDrain" + : event === "readable" + ? "net.sockOnReadable" + : event === "ready" + ? "net.sockOnReady" + : "net.sockOnConnect"; return { kind: "libCall", fn, args: [receiver, cb, once], type: VOID, loc }; } if (event === "lookup") { @@ -3957,6 +3978,18 @@ export function lowerServerProperty( const receiver = coerceToHandle(lowerer, expr.expression, NETSOCKET_T); return { kind: "libCall", fn: "net.sockBytesWritten", args: [receiver], type: F64, loc }; } + if ( + recvKind === "netSocket" && + lowerer.isStdlibMember(expr) && + (expr.name.text === "writableLength" || expr.name.text === "writableNeedDrain") + ) { + // The write buffer's unsent bytes, and whether a write answered false + // and 'drain' has not fired yet. + const receiver = coerceToHandle(lowerer, expr.expression, NETSOCKET_T); + return expr.name.text === "writableLength" + ? { kind: "libCall", fn: "net.sockWritableLength", args: [receiver], type: F64, loc } + : { kind: "libCall", fn: "net.sockWritableNeedDrain", args: [receiver], type: BOOL, loc }; + } if (recvKind === "netSocket" && lowerer.isStdlibMember(expr) && expr.name.text === "readable") { // true until the read half is done (peer FIN / destroy). const receiver = coerceToHandle(lowerer, expr.expression, NETSOCKET_T); diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index e768f257f..7183b72a1 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1543,15 +1543,18 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * true } and a callback taking an address string or list, then dials * the answers in order. MAY THROW (host validation, a lookup throw). */ "net.connectLookupDyn": { argTypes: [F64, STRING, DYN], result: NETSOCKET_T }, - "net.sockWrite": { argTypes: [NETSOCKET_T, STRING], result: VOID }, - "net.sockWriteBytes": { argTypes: [NETSOCKET_T, BYTES_U8], result: VOID }, + /** socket.write: Node's Writable answer — false when the bytes the write + * left unsent reach the 65536-byte high-water mark ('drain' follows), + * and on an ended or closed socket. */ + "net.sockWrite": { argTypes: [NETSOCKET_T, STRING], result: BOOL }, + "net.sockWriteBytes": { argTypes: [NETSOCKET_T, BYTES_U8], result: BOOL }, "net.sockEnd": { argTypes: [NETSOCKET_T], result: VOID }, "net.sockEndStr": { argTypes: [NETSOCKET_T, STRING], result: VOID }, "net.sockEndBytes": { argTypes: [NETSOCKET_T, BYTES_U8], result: VOID }, /** write/end with a CHECKED-DYNAMIC chunk (an untyped JS payload into a * typed socket): the runtime dispatches STR/BYTES and throws Node's * ERR_INVALID_ARG_TYPE chunk TypeError on any other kind. */ - "net.sockWriteDyn": { argTypes: [NETSOCKET_T, DYN], result: VOID }, + "net.sockWriteDyn": { argTypes: [NETSOCKET_T, DYN], result: BOOL }, "net.sockEndDyn": { argTypes: [NETSOCKET_T, DYN], result: VOID }, "net.sockDestroy": { argTypes: [NETSOCKET_T], result: VOID }, "net.sockPipe": { argTypes: [NETSOCKET_T, NETSOCKET_T], result: VOID }, @@ -2009,6 +2012,12 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ argTypes: [NETSOCKET_T, { kind: "func", params: [], ret: VOID }, BOOL], result: VOID, }, + /** on('drain') / once('drain'): fires after a write answered false, once + * the write buffer empties. */ + "net.sockOnDrain": { + argTypes: [NETSOCKET_T, { kind: "func", params: [], ret: VOID }, BOOL], + result: VOID, + }, "net.sockRemoteAddress": { argTypes: [NETSOCKET_T], result: VOID }, /** socket.encrypted — `boolean | undefined`: the true arm iff the * socket carries a TLS transport (Node types `encrypted: true` on @@ -2135,6 +2144,11 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ /** socket.address(): { address, family, port } or {} as a dyn. */ "net.sockAddress": { argTypes: [NETSOCKET_T], result: DYN }, "net.sockBytesWritten": { argTypes: [NETSOCKET_T], result: F64 }, + /** socket.writableLength: the bytes written and not yet sent. */ + "net.sockWritableLength": { argTypes: [NETSOCKET_T], result: F64 }, + /** socket.writableNeedDrain: a write answered false and 'drain' has not + * fired, with the write half open. */ + "net.sockWritableNeedDrain": { argTypes: [NETSOCKET_T], result: BOOL }, /** remotePort (0), remoteFamily (1), localAddress (2), localPort (3), * localFamily (4): dyn values, undefined when unknown. */ "net.sockAddrField": { argTypes: [NETSOCKET_T, F64], result: DYN }, diff --git a/packages/compiler/test/native-codegen-integration.test.ts b/packages/compiler/test/native-codegen-integration.test.ts index 8e8439749..dc3f94e6c 100644 --- a/packages/compiler/test/native-codegen-integration.test.ts +++ b/packages/compiler/test/native-codegen-integration.test.ts @@ -378,7 +378,7 @@ done: expect(await symbols(helperObject, ["-u"])).toEqual(await symbols(clangObject, ["-u"])); expect(await symbols(helperObject, ["-gU"])).toEqual(await symbols(clangObject, ["-gU"])); expect(await symbols(helperObject, ["-gU"])).toEqual(["0000000000000000 T _main"]); - expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v43"); + expect(await symbols(helperObject, ["-u"])).toContain("_scr_runtime_abi_v44"); const clangExe = join(dir, "clang-program"); const linkDriver = join(dir, "clang-driver.c"); @@ -712,7 +712,7 @@ done: .split("\n"); expect(undefinedSymbols).toContain("_sf_scale"); expect(undefinedSymbols).toContain("_sf_callback_mix"); - expect(undefinedSymbols).toContain("_scr_runtime_abi_v43"); + expect(undefinedSymbols).toContain("_scr_runtime_abi_v44"); }); test("helper and clang object paths retain the same outbound FFI C ABI", async () => { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7515-net-socket-backpressure.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7515-net-socket-backpressure.ts.json new file mode 100644 index 000000000..16f6edb8d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7515-net-socket-backpressure.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7515-net-socket-backpressure.ts" + ], + "diags": [] +} diff --git a/packages/runtime-darwin-arm64/scripts/build.mjs b/packages/runtime-darwin-arm64/scripts/build.mjs index 84c3b83b6..7858c9782 100644 --- a/packages/runtime-darwin-arm64/scripts/build.mjs +++ b/packages/runtime-darwin-arm64/scripts/build.mjs @@ -366,7 +366,7 @@ async function build() { package: packageManifest.name, version: packageManifest.version, target: RUNTIME_PACK_MATRIX.target, - runtime_abi: { version: 43, marker: "scr_runtime_abi_v43" }, + runtime_abi: { version: 44, marker: "scr_runtime_abi_v44" }, compiler: { command: compiler, identity: compilerVersion, diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index 545c6651b..b770d81b6 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -469,7 +469,7 @@ async function build() { package: packageManifest.name, version: packageManifest.version, target: matrix.target, - runtime_abi: { version: 43, marker: "scr_runtime_abi_v43" }, + runtime_abi: { version: 44, marker: "scr_runtime_abi_v44" }, compiler: { command: compiler, identity: compilerVersion, diff --git a/packages/runtime/src/scr_console.c b/packages/runtime/src/scr_console.c index 2f7c91b7d..ad49e4f6f 100644 --- a/packages/runtime/src/scr_console.c +++ b/packages/runtime/src/scr_console.c @@ -148,7 +148,7 @@ static void scr_enable_utf8_console_output(void) { } #endif -void scr_runtime_abi_v43(void) {} +void scr_runtime_abi_v44(void) {} void scr_init(void) { #ifdef SCR_WORKERS diff --git a/packages/runtime/src/scr_core.h b/packages/runtime/src/scr_core.h index 5d6d80778..8e50ec0ca 100644 --- a/packages/runtime/src/scr_core.h +++ b/packages/runtime/src/scr_core.h @@ -81,7 +81,7 @@ void scr_init(void); /* Program objects emitted by the bundled LLVM helper reference this symbol. * Its versioned spelling makes a mismatched manual runtime link fail before * the program can start. */ -void scr_runtime_abi_v43(void); +void scr_runtime_abi_v44(void); void scr_runtime_workers_v8(void); /* ── the trap funnel (scr_console.c; scr_library.c under -DSCR_LIB) ────── diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index bd1233978..91e8461b7 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -565,6 +565,11 @@ struct ScrNetSocket { ScrNetLs lookup_ls; struct ScrNetLookupEv *lookup_evs; ScrNetLs data_ls, end_ls, close_ls, err_ls, conn_ls, timeout_ls, readable_ls; + /* Writable backpressure: a user write answered false (the bytes it left + * unsent reached SCR_NET_WRITE_HWM), so 'drain' fires once the write + * buffer empties */ + ScrNetLs drain_ls; + bool need_drain; /* Flow control (pause()/resume()): user_paused holds reads OFF even * with consumers (kernel/TCP backpressure is the buffer); flowing * counts as a consumer even with no 'data' listener @@ -747,6 +752,7 @@ void scr_net_sock_release(ScrNetSocket *s) { scr_net_ls_drop(&s->conn_ls); scr_net_ls_drop(&s->timeout_ls); scr_net_ls_drop(&s->readable_ls); + scr_net_ls_drop(&s->drain_ls); scr_net_ls_drop(&s->finish_ls); scr_net_ls_drop(&s->wcb_ls); scr_net_ls_drop(&s->ready_ls); @@ -1103,6 +1109,40 @@ static void scr_net_sock_buffer(ScrNetSocket *s, const char *data, size_t len) { s->wlen += len; } +/* Node's net.Socket writableHighWaterMark (the Writable default, + * getDefaultHighWaterMark()). */ +#define SCR_NET_WRITE_HWM 65536 + +/* 'drain' once a false write's bytes are all out: Node's afterWrite emits + * it when needDrain is set and nothing is pending, unless the stream is + * ending or destroyed. */ +static void scr_net_sock_maybe_drain(ScrNetSocket *s) { + if (!s->need_drain || s->wlen > s->whead) return; + if (s->wr_ending || s->close_emitted || s->fd < 0) return; + s->need_drain = false; + scr_net_fire0_this(&s->drain_ls, s, SCR_DYNH_NET_SOCKET); +} + +static void scr_net_sock_write_raw(ScrNetSocket *s, const char *data, size_t len); + +/* A user write: Writable.write's answer, as Node 24's writeOrBuffer + * computes it after the socket writes — the bytes the write path left + * unsent below the high-water mark — with needDrain set when it is false. + * A write the kernel takes at once answers true whatever its size; the + * buffered bytes of a false one drain from the flush. A write to an ended + * or closed socket answers false. */ +static bool scr_net_sock_user_write(ScrNetSocket *s, const char *data, size_t len) { + ScrNetSocket *w = s->adopted_by != NULL ? s->adopted_by : s; + bool pre_dial = w->fd < 0 && (w->dial_deferred || w->lookup_wait) && !w->wr_ending && + !w->close_emitted; + bool live = pre_dial || !(w->fd < 0 || w->wr_ending || w->close_emitted); + scr_net_sock_write_raw(s, data, len); + if (!live) return false; + bool ok = w->wlen - w->whead < SCR_NET_WRITE_HWM; + if (!ok) w->need_drain = true; + return ok; +} + /* The write path shared by write()/end(data)/pipe delivery. Ignored on a * dead or ended write half (Node fires 'error' there; this slice drops — * SEMANTICS.md documents the bound). */ @@ -1252,6 +1292,9 @@ static void scr_net_sock_flush(ScrNetSocket *s) { s->whead = s->wlen = 0; scr_net_sock_update_write(s); scr_net_sock_maybe_finish_write(s); + /* Node's afterWrite for a write the socket completed later: 'drain' + * now, unless the write half is ending or the socket is gone */ + scr_net_sock_maybe_drain(s); } } @@ -3150,16 +3193,40 @@ void scr_net_lookup_answer(ScrClosure *self, bool has_err, ScrStr *msg /*borrowe /* ── the socket surface ──────────────────────────────────────────────── */ -void scr_net_sock_write_str(ScrNetSocket *s, ScrStr *data /*borrowed*/) { +bool scr_net_sock_write_str(ScrNetSocket *s, ScrStr *data /*borrowed*/) { char *owned; const char *bytes = scr_str_utf8(data, &owned); - scr_net_sock_write_raw(s, bytes, data->len); + bool ok = scr_net_sock_user_write(s, bytes, data->len); scr_str_utf8_end(owned); + return ok; } -void scr_net_sock_write_bytes(ScrNetSocket *s, ScrBytes *data /*borrowed*/) { +bool scr_net_sock_write_bytes(ScrNetSocket *s, ScrBytes *data /*borrowed*/) { SCR_BYTES_SNAPSHOT(data); - scr_net_sock_write_raw(s, (const char *)data->data, (data->len * scr_bytes_elem_size(data->elem))); + return scr_net_sock_user_write(s, (const char *)data->data, + (data->len * scr_bytes_elem_size(data->elem))); +} + +/* socket.writableLength: the bytes written and not yet sent. */ +double scr_net_sock_writable_length(ScrNetSocket *s) { + ScrNetSocket *w = s->adopted_by != NULL ? s->adopted_by : s; + return (double)(w->wlen - w->whead); +} + +/* socket.writableNeedDrain: a write answered false and 'drain' has not + * fired, on a socket whose write half is open. */ +bool scr_net_sock_writable_need_drain(ScrNetSocket *s) { + ScrNetSocket *w = s->adopted_by != NULL ? s->adopted_by : s; + return w->need_drain && !w->wr_ending && !w->close_emitted && w->fd >= 0; +} + +/* on('drain') / once('drain'). */ +void scr_net_sock_on_drain(ScrNetSocket *s, ScrClosure *cb /*moves*/, bool once) { + if (s->close_emitted) { + scr_closure_release(cb); + return; + } + scr_net_ls_add(&s->drain_ls, cb, NULL, once); } void scr_net_sock_end(ScrNetSocket *s) { @@ -5860,6 +5927,7 @@ static void scr_net_cleanup_atexit(void) { scr_net_ls_drop(&s->conn_ls); scr_net_ls_drop(&s->timeout_ls); scr_net_ls_drop(&s->readable_ls); + scr_net_ls_drop(&s->drain_ls); scr_net_ls_drop(&s->lookup_ls); /* Tear down the protocol layer's parser NOW: an in-flight http * exchange (a handler that threw before res.end()) holds req/res, @@ -5984,8 +6052,7 @@ static ScrDyn *scr_net_dynh_sock_invoke(void *h, ScrDyn *self, const char *metho /* fires once when the FIN goes out — once either way */ scr_net_sock_on_finish(s, scr_dyn_listener_closure0(cb)); } else if (scr_net_dynh_name_is(name, "drain")) { - /* This surface never backpressures (write answers true) — an - * accepted, never-fired registration is the consistent answer. */ + scr_net_sock_on_drain(s, scr_dyn_listener_closure0(cb), once); } else if (scr_net_dynh_tls != NULL && name->kind == SCR_DYN_STR && scr_net_dynh_tls->on(s, name->v.str->data, cb, once)) { /* a TLS event name ('secureConnect'), handled by the TLS unit */ @@ -6008,13 +6075,13 @@ static ScrDyn *scr_net_dynh_sock_invoke(void *h, ScrDyn *self, const char *metho scr_dyn_arg_type_fail("chunk", "of type string or an instance of Buffer or Uint8Array", chunk); return NULL; } - if (chunk->kind == SCR_DYN_STR) scr_net_sock_write_str(s, chunk->v.str); - else scr_net_sock_write_bytes(s, chunk->v.bytes); + bool ok = chunk->kind == SCR_DYN_STR ? scr_net_sock_write_str(s, chunk->v.str) + : scr_net_sock_write_bytes(s, chunk->v.bytes); if (argc > 1 && args[1]->kind == SCR_DYN_FUNC) { /* write(chunk, cb): fires when the buffer drains (sweep-deferred) */ scr_net_sock_on_write_flush(s, scr_dyn_listener_closure0(args[1])); } - return scr_dyn_new_bool(true); /* backpressure is not modeled (SEMANTICS.md) */ + return scr_dyn_new_bool(ok); } if (strcmp(method, "end") == 0) { const ScrDyn *chunk = argc > 0 ? args[0] : scr_dyn_undefined(); @@ -6500,10 +6567,11 @@ void scr_net_dyn_install(void) { * STR/BYTES dispatch onto the typed entries; undefined/null end()s plain * (Node accepts them); anything else throws Node's ERR_INVALID_ARG_TYPE * chunk TypeError. Both borrowed. */ -void scr_net_sock_write_dynv(ScrNetSocket *s, const ScrDyn *d) { - if (d->kind == SCR_DYN_STR) scr_net_sock_write_str(s, d->v.str); - else if (d->kind == SCR_DYN_BYTES) scr_net_sock_write_bytes(s, d->v.bytes); - else scr_dyn_arg_type_fail("chunk", "of type string or an instance of Buffer or Uint8Array", d); +bool scr_net_sock_write_dynv(ScrNetSocket *s, const ScrDyn *d) { + if (d->kind == SCR_DYN_STR) return scr_net_sock_write_str(s, d->v.str); + if (d->kind == SCR_DYN_BYTES) return scr_net_sock_write_bytes(s, d->v.bytes); + scr_dyn_arg_type_fail("chunk", "of type string or an instance of Buffer or Uint8Array", d); + return false; } void scr_net_sock_end_dynv(ScrNetSocket *s, const ScrDyn *d) { diff --git a/packages/runtime/src/scr_net.h b/packages/runtime/src/scr_net.h index 95490aaa5..6a41e26d8 100644 --- a/packages/runtime/src/scr_net.h +++ b/packages/runtime/src/scr_net.h @@ -227,15 +227,21 @@ ScrNetSocket *scr_net_connect_lookup(double port, ScrStr *host /*borrowed*/, ScrClosure *lookup /*moves*/, void *answer_fn); /* +1 */ void scr_net_lookup_answer(ScrClosure *self, bool has_err, ScrStr *msg /*borrowed, nullable*/, ScrArr *ips /*borrowed, nullable*/); -void scr_net_sock_write_str(ScrNetSocket *s, ScrStr *data /*borrowed*/); -void scr_net_sock_write_bytes(ScrNetSocket *s, ScrBytes *data /*borrowed*/); +/* write(): Node's Writable answer — false when the bytes the write left + * unsent reach the 65536-byte high-water mark, after which 'drain' fires + * when the write buffer empties; false on an ended or closed socket. */ +bool scr_net_sock_write_str(ScrNetSocket *s, ScrStr *data /*borrowed*/); +bool scr_net_sock_write_bytes(ScrNetSocket *s, ScrBytes *data /*borrowed*/); +double scr_net_sock_writable_length(ScrNetSocket *s); +bool scr_net_sock_writable_need_drain(ScrNetSocket *s); +void scr_net_sock_on_drain(ScrNetSocket *s, ScrClosure *cb /*moves*/, bool once); void scr_net_sock_end(ScrNetSocket *s); void scr_net_sock_end_str(ScrNetSocket *s, ScrStr *data /*borrowed*/); void scr_net_sock_end_bytes(ScrNetSocket *s, ScrBytes *data /*borrowed*/); /* Checked-dynamic chunk forms (an untyped JS payload into a typed * socket): STR/BYTES dispatch; end() takes undefined/null as no chunk; * anything else throws Node's ERR_INVALID_ARG_TYPE chunk TypeError. */ -void scr_net_sock_write_dynv(ScrNetSocket *s, const ScrDyn *d /*borrowed*/); +bool scr_net_sock_write_dynv(ScrNetSocket *s, const ScrDyn *d /*borrowed*/); void scr_net_sock_end_dynv(ScrNetSocket *s, const ScrDyn *d /*borrowed*/); void scr_net_sock_destroy(ScrNetSocket *s); /* Flow control (pause/resume — see the struct's flag comments), the diff --git a/tests/corpus/7515-net-socket-backpressure.ts b/tests/corpus/7515-net-socket-backpressure.ts new file mode 100644 index 000000000..a265d3f77 --- /dev/null +++ b/tests/corpus/7515-net-socket-backpressure.ts @@ -0,0 +1,47 @@ +// net.Socket write() backpressure, as in Node 24: write answers false when +// the bytes it leaves unsent reach the 65536-byte high-water mark, which a +// write made before the connection does, writableNeedDrain stays true +// until 'drain', and 'drain' fires once the buffered bytes are sent. An +// echo client that waits for 'drain' when write answers false moves every +// byte. +import { connect, createServer, Socket } from "node:net"; + +const CHUNK = 65536; +const TOTAL = 64 * CHUNK; +const chunk = Buffer.alloc(CHUNK, 120); + +const server = createServer((socket: Socket) => { + socket.pipe(socket); +}); + +server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + const client = connect(port, "127.0.0.1"); + console.log("small write before connect", client.write("hi"), client.writableLength); + console.log("large write before connect", client.write(Buffer.alloc(70000, 121)), client.writableLength); + console.log("need drain", client.writableNeedDrain); + let sent = 70002; + let received = 0; + const pump = (): void => { + while (sent < TOTAL) { + sent += CHUNK; + if (!client.write(chunk)) { + client.once("drain", pump); + return; + } + } + client.end(); + }; + client.once("drain", () => { + console.log("drain fired, need drain", client.writableNeedDrain, "pending", client.writableLength); + pump(); + }); + client.on("data", (data: Buffer) => { + received += data.length; + }); + client.on("end", () => { + console.log("received", received, "of", sent); + server.close(); + }); +}); diff --git a/tests/harness/corpus-costs/7515-net-socket-backpressure.ts.json b/tests/harness/corpus-costs/7515-net-socket-backpressure.ts.json new file mode 100644 index 000000000..feabbe802 --- /dev/null +++ b/tests/harness/corpus-costs/7515-net-socket-backpressure.ts.json @@ -0,0 +1 @@ +{ "plain": 2800, "san": 4200 } diff --git a/tests/harness/self-hosting-native-toolchain.test.ts b/tests/harness/self-hosting-native-toolchain.test.ts index 8470ce04e..f8d901ee8 100644 --- a/tests/harness/self-hosting-native-toolchain.test.ts +++ b/tests/harness/self-hosting-native-toolchain.test.ts @@ -110,7 +110,7 @@ for (const backend of ["llvm"] as const) { object_format: target.objectFormat, minimum_os: target.minimumOs, }, - runtime_abi: { version: 43, marker: "scr_runtime_abi_v43" }, + runtime_abi: { version: 44, marker: "scr_runtime_abi_v44" }, compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple }, macros: { executable: [], excluded: [], sanitizer: "external-toolchain-required" }, flavors: {