diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index cf23204b..c1e5fc3a 100644 --- a/docs/src/generated/node-v24-compatibility-meta.json +++ b/docs/src/generated/node-v24-compatibility-meta.json @@ -2,6 +2,6 @@ "schemaVersion": 3, "nodeVersion": "24.15.0", "nodeCommit": "848430679556aed0bd073f2bc263331ad84fa119", - "artifactVersion": "13aa4b44face2c6c630d", + "artifactVersion": "748941357fe7bf09817d", "rowCount": 3662 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 187bb101..60b51616 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -218,9 +218,9 @@ "apiEntries": 39, "static": { "supported": 0, - "partial": 6, + "partial": 7, "refused": 1, - "not-implemented": 32, + "not-implemented": 31, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -986,9 +986,9 @@ "apiEntries": 137, "static": { "supported": 0, - "partial": 33, + "partial": 36, "refused": 0, - "not-implemented": 104, + "not-implemented": 101, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -12261,9 +12261,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -72438,9 +72438,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented static async-iteration subset.", + "verification": "test-backed" }, "dynamic": { "status": "partial", @@ -72662,9 +72662,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented static async-iteration subset.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -72970,9 +72970,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented static async-iteration subset.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 43314324..425a0b9b 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -8,8 +8,8 @@ "tiers": { "static": { "replace-refusal": 59, - "verify-gap": 2746, - "audit-partial": 415, + "verify-gap": 2742, + "audit-partial": 419, "classify": 2, "implement": 81 }, @@ -6612,12 +6612,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:child_process", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:child_process.stdin", + "tests": [ + "tests/corpus/2893-child-stdin-duplex.ts", + "tests/corpus/2894-child-stdin-backpressure.ts", + "tests/corpus/2895-child-stdin-write-after-end.ts", + "tests/corpus/2896-child-stdin-write-after-finish.ts", + "tests/corpus/2897-child-stdin-destroy.ts", + "tests/corpus/2898-child-stdin-spawn-failure.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -78843,12 +78850,14 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:stream", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:stream.writable.destroy", + "tests": [ + "tests/corpus/2897-child-stdin-destroy.ts" + ] }, "dynamic": { "status": "partial", @@ -79133,12 +79142,15 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:stream", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:stream.writable", + "tests": [ + "tests/corpus/2893-child-stdin-duplex.ts", + "tests/corpus/2895-child-stdin-write-after-end.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -79496,12 +79508,14 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:stream", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:stream.'drain'", + "tests": [ + "tests/corpus/2894-child-stdin-backpressure.ts" + ] }, "dynamic": { "status": "not-implemented", diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 7d532fcd..ea6e24b5 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -195,9 +195,9 @@ "apiEntries": 39, "static": { "supported": 0, - "partial": 6, + "partial": 7, "refused": 1, - "not-implemented": 32, + "not-implemented": 31, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -1107,9 +1107,9 @@ "apiEntries": 137, "static": { "supported": 0, - "partial": 33, + "partial": 36, "refused": 0, - "not-implemented": 104, + "not-implemented": 101, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -13988,7 +13988,13 @@ "tests": [ "tests/corpus/1361-spawn-events.ts", "tests/corpus/1565-spawn-pipe-streams.ts", - "tests/corpus/2844-node-disposable-resources.ts" + "tests/corpus/2844-node-disposable-resources.ts", + "tests/corpus/2893-child-stdin-duplex.ts", + "tests/corpus/2894-child-stdin-backpressure.ts", + "tests/corpus/2895-child-stdin-write-after-end.ts", + "tests/corpus/2896-child-stdin-write-after-finish.ts", + "tests/corpus/2897-child-stdin-destroy.ts", + "tests/corpus/2898-child-stdin-spawn-failure.ts" ] }, "dynamic": { @@ -14952,8 +14958,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:child_process" + "status": "partial", + "evidence": "compiler-feature:child_process.stdin", + "tests": [ + "tests/corpus/2893-child-stdin-duplex.ts", + "tests/corpus/2894-child-stdin-backpressure.ts", + "tests/corpus/2895-child-stdin-write-after-end.ts", + "tests/corpus/2896-child-stdin-write-after-finish.ts", + "tests/corpus/2897-child-stdin-destroy.ts", + "tests/corpus/2898-child-stdin-spawn-failure.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -93993,7 +94007,11 @@ "tests/corpus/1688-stream-writable-basics.ts", "tests/corpus/1746-stream-for-await.ts", "tests/corpus/1814-stream-pipeline.cjs", - "tests/corpus/2861-readable-iterator-close.ts" + "tests/corpus/2861-readable-iterator-close.ts", + "tests/corpus/2893-child-stdin-duplex.ts", + "tests/corpus/2894-child-stdin-backpressure.ts", + "tests/corpus/2895-child-stdin-write-after-end.ts", + "tests/corpus/2897-child-stdin-destroy.ts" ] }, "dynamic": { @@ -95566,8 +95584,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:stream" + "status": "partial", + "evidence": "compiler-feature:stream.writable.destroy", + "tests": [ + "tests/corpus/2897-child-stdin-destroy.ts" + ] }, "dynamic": { "status": "partial", @@ -95817,8 +95838,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:stream" + "status": "partial", + "evidence": "compiler-feature:stream.writable", + "tests": [ + "tests/corpus/2893-child-stdin-duplex.ts", + "tests/corpus/2895-child-stdin-write-after-end.ts" + ] }, "dynamic": { "status": "not-implemented", @@ -96114,8 +96139,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:stream" + "status": "partial", + "evidence": "compiler-feature:stream.'drain'", + "tests": [ + "tests/corpus/2894-child-stdin-backpressure.ts" + ] }, "dynamic": { "status": "not-implemented", diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index bc260e00..3589f238 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -88,10 +88,14 @@ { "chapter": "packages", "symbols": ["package.json", "\"name\"", "\"main\"", "\"type\"", "\"exports\"", "\"imports\""], "status": "partial", "evidence": ["tests/corpus/2092-package-imports/main.ts", "tests/corpus/2120-package-self-import/main.ts", "tests/corpus/2124-imports-field-wildcard/main.ts", "tests/corpus/2390-dot-requires/main.cjs", "tests/corpus/2830-cjs-require-self-name/main.cjs"] }, { "chapter": "packages", "symbols": ["--input-type"], "status": "not-applicable" }, { "chapter": "child_process", "signatures": ["`subprocess[Symbol.dispose]()`"], "status": "partial", "evidence": ["tests/corpus/2844-node-disposable-resources.ts"] }, + { "chapter": "child_process", "signatures": ["`stdin` Type: {stream.Writable|null|undefined}"], "status": "partial", "evidence": ["tests/corpus/2893-child-stdin-duplex.ts", "tests/corpus/2894-child-stdin-backpressure.ts", "tests/corpus/2895-child-stdin-write-after-end.ts", "tests/corpus/2896-child-stdin-write-after-finish.ts", "tests/corpus/2897-child-stdin-destroy.ts", "tests/corpus/2898-child-stdin-spawn-failure.ts"] }, { "chapter": "fs", "signatures": ["`filehandle[Symbol.asyncDispose]()`"], "status": "partial", "evidence": ["tests/corpus/2844-node-disposable-resources.ts"] }, { "chapter": "readline", "signatures": ["`rl[Symbol.dispose]()`"], "status": "partial", "evidence": ["tests/corpus/2844-node-disposable-resources.ts"] }, { "chapter": "timers", "signatures": ["`immediate[Symbol.dispose]()`", "`timeout[Symbol.dispose]()`"], "status": "partial", "evidence": ["tests/corpus/2844-node-disposable-resources.ts"] }, { "chapter": "stream", "signatures": ["`readable[Symbol.asyncIterator]()`", "`readable.iterator([options])`"], "status": "partial", "evidence": ["tests/corpus/1746-stream-for-await.ts", "tests/corpus/2861-readable-iterator-close.ts"] }, + { "chapter": "stream", "signatures": ["`writable.destroy([error])`"], "status": "partial", "evidence": ["tests/corpus/2897-child-stdin-destroy.ts"] }, + { "chapter": "stream", "signatures": ["`writable` Type: {boolean}"], "status": "partial", "evidence": ["tests/corpus/2893-child-stdin-duplex.ts", "tests/corpus/2895-child-stdin-write-after-end.ts"] }, + { "chapter": "stream", "signatures": ["Event: `'drain'`"], "status": "partial", "evidence": ["tests/corpus/2894-child-stdin-backpressure.ts"] }, { "chapter": "webstreams", "signatures": ["`readableStream.values([options])`"], "status": "partial", "evidence": ["tests/corpus/2862-web-readable-async-iteration.ts"] }, { "chapter": "typescript", "symbols": ["type"], "status": "supported", "evidence": ["tests/corpus/2702-type-import-link-static/main.ts", "tests/corpus/2703-type-import-link-static-reexport/main.ts", "tests/corpus/2704-type-import-link-static-success/main.ts", "tests/corpus/2707-type-import-link-static-default-reexport/main.ts", "tests/harness/errors.test.ts"] } ], diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 5a08227e..1ff0b887 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1533,7 +1533,8 @@ declare module "child_process" { ): SpawnSyncReturns; /* The asynchronous slice: spawn with stdio "ignore"/"inherit"/fd/"pipe" - * tuples (piped stdout/stderr deliver through child.stdout/stderr) and + * tuples (piped stdin is writable through child.stdin; piped stdout/stderr + * deliver through child.stdout/stderr) and * the two terminal events. "exit" fires once with the exit code, or * null when the child died to a signal; "error" fires ONLY when the * child could not be spawned at all (Node's split: a spawn failure @@ -1545,6 +1546,17 @@ declare module "child_process" { * quiescence (SEMANTICS.md documents the divergence). An "error" * event with no registered listener prints the error and exits 1, * exactly the unhandled-'error' EventEmitter behavior. */ + export interface ChildStdin { + readonly writable: boolean; + write(chunk: string | Uint8Array): boolean; + end(): void; + destroy(): void; + on(event: "drain" | "finish", listener: () => void): void; + on(event: "error", listener: (err: Error) => void): void; + once(event: "drain" | "finish", listener: () => void): void; + once(event: "error", listener: (err: Error) => void): void; + } + export interface ChildProcess extends Disposable { /* The exit listener may also take Node's second parameter — the * terminating signal's name, null for a normal exit. */ @@ -1566,8 +1578,9 @@ declare module "child_process" { kill(signal?: string | number): boolean; unref(): void; [Symbol.dispose](): void; - /* The piped-output streams — non-null exactly when the matching - * stdio slot was "pipe" (Node's shape). */ + /* The piped streams — non-null exactly when the matching stdio slot + * was "pipe" (Node's shape). */ + readonly stdin: ChildStdin | null; readonly stdout: NodeJS.ReadableStream | null; readonly stderr: NodeJS.ReadableStream | null; } @@ -1578,7 +1591,8 @@ declare module "child_process" { /* The 3-tuple form admits number fds in the stdout/stderr slots — * openSync results dup2'd into the child (the daemon-log idiom * ["ignore", logFd, logFd]) — and "pipe" there too (child.stdout/ - * child.stderr streams); piped STDIN stays a compile fence. */ + * child.stderr streams); "pipe" in the stdin slot exposes + * child.stdin. */ stdio: "ignore" | "inherit" | "pipe" | ("ignore" | "inherit" | "pipe" | number)[]; /* detached gives the child its own session and process group * (POSIX_SPAWN_SETSID); env REPLACES the child environment; cwd diff --git a/packages/compiler/src/backend/c/exprs.ts b/packages/compiler/src/backend/c/exprs.ts index a3f4d63a..3219ea6b 100644 --- a/packages/compiler/src/backend/c/exprs.ts +++ b/packages/compiler/src/backend/c/exprs.ts @@ -3,7 +3,7 @@ import { InternalCompilerError } from "../../errors.js"; * expression lands in a fresh C temp, with RC ownership tracked on the * emitter's frames (see the discipline comment in emitter core). */ import type { CEmitter, Temp } from "./c-emitter.js"; -import { arrayOf, BOOL, BYTES_U8, bytesOf, canMarshalFuncIntoIsland, CHILDSTREAM_T, DYN, F64, IrExpr, IrLibFn, IrRecordShape, IrType, islandPromisePayloadTag, isClassOwnEnumerableFieldName, isDynTypedRefType, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isRefCounted, isUnitType, MAY_THROW_LIB_FNS, RUNTIME_ERROR_CLASSES, STRING, typeEquals, typeKey } from "../../ir/ir.js"; +import { arrayOf, BOOL, BYTES_U8, bytesOf, canMarshalFuncIntoIsland, CHILDSTREAM_T, CHILDWRITER_T, DYN, F64, IrExpr, IrLibFn, IrRecordShape, IrType, islandPromisePayloadTag, isClassOwnEnumerableFieldName, isDynTypedRefType, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isRefCounted, isUnitType, MAY_THROW_LIB_FNS, RUNTIME_ERROR_CLASSES, STRING, typeEquals, typeKey } from "../../ir/ir.js"; import { boxAccess, BYTES_NUM_KIND_C, BYTES_NUM_VAR_C, bytesElemKindC, cDecl, cFnPtrCast, cNumberLiteral, cStringLiteral, cType, DV_GET_KIND_C, DV_SET_KIND_C, elemAccess, mapKeyAccess, mapKeyKindC, mapValKindC, releaseCallC, retainCallC, vAdapters } from "./types.js"; import { mangleClassNew, mangleClassRetain, mangleClassStruct, mangleField, mangleFnClosure, mangleFunction, mangleGlobal, mangleLocal, mangleRecordClone, mangleRecordNew, mangleRecordStruct, mangleVtStruct } from "../mangle.js"; import { OVERFLOW_MEMBER } from "./shapes.js"; @@ -5483,26 +5483,58 @@ function emitChildProcessLibCall(state: LibCallState): Temp { const absent = emitter.unitInstanceRef(e.type.unionId, unitTag); return emitter.newTemp(e.type, `${has}(${arg(0)}) ? ${present} : ${absent}`); } + case "child.stdin": case "child.stdout": case "child.stderr": { - // `Readable | null` — the child.pid pattern with a REF arm: - // the runtime answers a +1 stream handle or NULL (not piped). + // `Writable | null` / `Readable | null` — the child.pid + // pattern with a REF arm: the runtime answers a +1 stream + // handle or NULL (not piped). if (e.type.kind !== "union") { throw new InternalCompilerError(`emitter bug: ${e.fn} result is not a union`); } const def = emitter.unionsById.get(e.type.unionId); - const streamTag = def ? def.arms.findIndex((a) => a.kind === "childStream") : -1; + const writer = e.fn === "child.stdin"; + const streamTag = def ? def.arms.findIndex((a) => a.kind === (writer ? "childWriter" : "childStream")) : -1; const nullTag = def ? def.arms.findIndex((a) => a.kind === "nullT") : -1; if (streamTag < 0 || nullTag < 0) { throw new InternalCompilerError(`emitter bug: ${e.fn} union lacks its arms`); } - const get = e.fn === "child.stdout" ? "scr_child_stdout" : "scr_child_stderr"; - const raw = emitter.newTemp(CHILDSTREAM_T, `${get}(${arg(0)})`); + const get = writer ? "scr_child_stdin" : e.fn === "child.stdout" ? "scr_child_stdout" : "scr_child_stderr"; + const rawType = writer ? CHILDWRITER_T : CHILDSTREAM_T; + const raw = emitter.newTemp(rawType, `${get}(${arg(0)})`); emitter.moveTemp(raw); // ownership passes into the union arm below - const present = `scr_union_new_ref(${streamTag}, ${raw.name}, &scr_child_stream_retain_v, &scr_child_stream_release_v, NULL)`; + const stem = writer ? "scr_child_writer" : "scr_child_stream"; + const present = `scr_union_new_ref(${streamTag}, ${raw.name}, &${stem}_retain_v, &${stem}_release_v, NULL)`; const absent = emitter.unitInstanceRef(e.type.unionId, nullTag); return emitter.newTemp(e.type, `${raw.name} != NULL ? ${present} : ${absent}`); } + case "writer.writeString": + return finish(`scr_child_writer_write_string(${arg(0)}, ${arg(1)})`); + case "writer.writeBytes": + return finish(`scr_child_writer_write_bytes(${arg(0)}, ${arg(1)})`); + case "writer.end": + return finish(`scr_child_writer_end(${arg(0)})`); + case "writer.destroy": + return finish(`scr_child_writer_destroy(${arg(0)})`); + case "writer.writable": + return finish(`scr_child_writer_writable(${arg(0)})`); + case "writer.onDrain": + case "writer.onFinish": { + const cb = args[1]!; + emitter.moveTemp(cb); + const fnName = fn === "writer.onDrain" ? "scr_child_writer_on_drain" : "scr_child_writer_on_finish"; + emitter.line(`${fnName}(${arg(0)}, ${cb.name}, ${arg(2)});${emitter.srcComment(e.loc)}`); + return { name: "", type: e.type }; + } + case "writer.onError": { + const cbT = e.args[1]!.type; + if (cbT.kind !== "func") throw new InternalCompilerError("emitter bug: writer.onError callback not a func"); + const cb = args[1]!; + emitter.moveTemp(cb); + const adapter = cbT.params.length === 0 ? "scr_child_err_thunk0" : "scr_child_err_thunk_error"; + emitter.line(`scr_child_writer_on_error(${arg(0)}, ${cb.name}, &${adapter}, ${arg(2)});${emitter.srcComment(e.loc)}`); + return { name: "", type: e.type }; + } case "procStream.write": // The receiver IS the fd scalar; dispatches onto the exact // promptly-submitted stdout/stderr paths (ordering identical). @@ -8437,6 +8469,7 @@ function emitLibCallExpr(emitter: CEmitter, e: LibCallExpr): Temp { case "cp": case "spawnRes": case "child": + case "writer": case "procStream": return emitChildProcessLibCall(state); case "net": diff --git a/packages/compiler/src/backend/c/shapes.ts b/packages/compiler/src/backend/c/shapes.ts index 598f7d8e..94f1a68f 100644 --- a/packages/compiler/src/backend/c/shapes.ts +++ b/packages/compiler/src/backend/c/shapes.ts @@ -805,7 +805,7 @@ function emitRecordCloneC( t.kind === "httpReq" || t.kind === "httpRes" || t.kind === "httpClientReq" || t.kind === "secureCtx" || t.kind === "cryptoHash" || t.kind === "cryptoHmac" || - t.kind === "fsWatcher" || t.kind === "childStream" || + t.kind === "fsWatcher" || t.kind === "childStream" || t.kind === "childWriter" || // Island handles: the box carries scr_jsval_retain_v/release_v and // no trace — the same stance as jsval array elements. t.kind === "jsval" || diff --git a/packages/compiler/src/backend/c/types.ts b/packages/compiler/src/backend/c/types.ts index 0e9d086e..7f86c358 100644 --- a/packages/compiler/src/backend/c/types.ts +++ b/packages/compiler/src/backend/c/types.ts @@ -85,6 +85,8 @@ export function cType(t: IrType): string { return "ScrWatcher *"; case "childStream": return "ScrChildStream *"; + case "childWriter": + return "ScrChildWriter *"; case "procStream": // A SCALAR kind: the stream value IS its fd (1 = stdout, 2 = // stderr) — no heap, no refcount. diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 659a4758..80327870 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -2480,7 +2480,7 @@ class LlEmitter { case "array": case "record": case "object": case "classval": case "func": case "map": case "set": case "symbol": case "regex": case "promise": case "bytes": case "url": case "searchParams": case "stats": case "fileHandle": case "spawnRes": - case "child": case "childStream": case "generator": case "fsWatcher": + case "child": case "childStream": case "childWriter": case "generator": case "fsWatcher": case "cryptoHash": case "cryptoHmac": { if (unionArm) return "true"; const truthy = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index 193e8c3a..ef674307 100644 --- a/packages/compiler/src/backend/llvm/lib-dispatch.ts +++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts @@ -226,6 +226,7 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { case "cp": case "spawnRes": case "child": + case "writer": case "procStream": return host.emitChildProcessLibCall(e); case "tp": diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index 66994172..a4471e42 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -137,21 +137,64 @@ export function emitChildProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr B.line(`${raw} = call ptr @scr_spawn_res_error(ptr ${recv.name}) ; +1 or NULL`); return host.wrapNullable(raw, raw, { kind: "object", className: "%Error" }, errTag, e.type, undefTag); } - if (e.fn === "child.stdout" || e.fn === "child.stderr") { - // `Readable | null` — the child.pid pattern with a REF arm: the - // runtime answers a +1 stream handle or NULL (not piped). + if (e.fn === "child.stdin" || e.fn === "child.stdout" || e.fn === "child.stderr") { + // `Writable | null` / `Readable | null` — the child.pid pattern + // with a REF arm: the runtime answers a +1 handle or NULL. if (e.type.kind !== "union") throw new InternalCompilerError(`llvm emitter bug: ${e.fn} result is not a union`); const def = host.unionsById.get(e.type.unionId); - const streamTag = def ? def.arms.findIndex((a) => a.kind === "childStream") : -1; + const writer = e.fn === "child.stdin"; + const streamTag = def ? def.arms.findIndex((a) => a.kind === (writer ? "childWriter" : "childStream")) : -1; const nullTag = def ? def.arms.findIndex((a) => a.kind === "nullT") : -1; if (streamTag < 0 || nullTag < 0) throw new InternalCompilerError(`llvm emitter bug: ${e.fn} union lacks its arms`); - const get = e.fn === "child.stdout" ? "scr_child_stdout" : "scr_child_stderr"; + const get = writer ? "scr_child_stdin" : e.fn === "child.stdout" ? "scr_child_stdout" : "scr_child_stderr"; const recv = host.emitExpr(e.args[0]!); host.declare(`declare ptr @${get}(ptr)`); const raw = B.tmp(); B.line(`${raw} = call ptr @${get}(ptr ${recv.name}) ; +1 or NULL`); return host.wrapNullable(raw, raw, def!.arms[streamTag]!, streamTag, e.type, nullTag); } + if (e.fn === "writer.writeString" || e.fn === "writer.writeBytes") { + const writer = host.emitExpr(e.args[0]!); + const data = host.emitExpr(e.args[1]!); + const sym = e.fn === "writer.writeString" ? "scr_child_writer_write_string" : "scr_child_writer_write_bytes"; + host.declare(`declare zeroext i1 @${sym}(ptr, ptr)`); + const t = B.tmp(); + B.line(`${t} = call zeroext i1 @${sym}(ptr ${writer.name}, ptr ${data.name})`); + return { name: t, type: e.type }; + } + if (e.fn === "writer.end" || e.fn === "writer.destroy") { + const writer = host.emitExpr(e.args[0]!); + const sym = e.fn === "writer.end" ? "scr_child_writer_end" : "scr_child_writer_destroy"; + host.declare(`declare void @${sym}(ptr)`); + B.line(`call void @${sym}(ptr ${writer.name})`); + return { name: "", type: e.type }; + } + if (e.fn === "writer.writable") { + const writer = host.emitExpr(e.args[0]!); + host.declare(`declare zeroext i1 @scr_child_writer_writable(ptr)`); + const t = B.tmp(); + B.line(`${t} = call zeroext i1 @scr_child_writer_writable(ptr ${writer.name})`); + return { name: t, type: e.type }; + } + if (e.fn === "writer.onDrain" || e.fn === "writer.onFinish" || e.fn === "writer.onError") { + const writer = host.emitExpr(e.args[0]!); + const cbT = e.args[1]!.type; + if (cbT.kind !== "func") throw new InternalCompilerError(`llvm emitter bug: ${e.fn} callback not a func`); + const cb = host.emitExpr(e.args[1]!); + host.moveTemp(cb); + const once = host.emitExpr(e.args[2]!); + if (e.fn === "writer.onError") { + const adapter = cbT.params.length === 0 ? "scr_child_err_thunk0" : "scr_child_err_thunk_error"; + host.declare(`declare void @${adapter}(ptr, ptr)`); + host.declare(`declare void @scr_child_writer_on_error(ptr, ptr, ptr, i1 zeroext)`); + B.line(`call void @scr_child_writer_on_error(ptr ${writer.name}, ptr ${cb.name}, ptr @${adapter}, i1 ${once.name})`); + } else { + const sym = e.fn === "writer.onDrain" ? "scr_child_writer_on_drain" : "scr_child_writer_on_finish"; + host.declare(`declare void @${sym}(ptr, ptr, i1 zeroext)`); + B.line(`call void @${sym}(ptr ${writer.name}, ptr ${cb.name}, i1 ${once.name})`); + } + return { name: "", type: e.type }; + } return host.emitGenericLibCall(e); } diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 6418e13d..a640d81d 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -333,7 +333,7 @@ export function boxNewCall(host: ShapeHost, t: IrType): string { t.kind === "record" || t.kind === "object" || t.kind === "classval" || t.kind === "union" || t.kind === "array" || t.kind === "map" || t.kind === "set" || t.kind === "symbol" || t.kind === "regex" || t.kind === "promise" || t.kind === "bytes" || t.kind === "url" || t.kind === "searchParams" || - t.kind === "stats" || t.kind === "fileHandle" || t.kind === "spawnRes" || t.kind === "child" || t.kind === "childStream" || + t.kind === "stats" || t.kind === "fileHandle" || t.kind === "spawnRes" || t.kind === "child" || t.kind === "childStream" || t.kind === "childWriter" || t.kind === "generator" || t.kind === "netServer" || t.kind === "netSocket" || t.kind === "dgramSocket" || t.kind === "httpReq" || t.kind === "httpRes" || t.kind === "httpClientReq" || diff --git a/packages/compiler/src/frontend/lowering/lower-builtins.ts b/packages/compiler/src/frontend/lowering/lower-builtins.ts index c55cd39e..01ad1053 100644 --- a/packages/compiler/src/frontend/lowering/lower-builtins.ts +++ b/packages/compiler/src/frontend/lowering/lower-builtins.ts @@ -37,7 +37,7 @@ import { CRYPTO_CIPHERS, CRYPTO_CONSTANTS, CRYPTO_CURVES, CRYPTO_HASHES } from " import { generatorMeta, timerStyleCallback } from "./lower-calls.js"; import { registerHttpClientFnBinding, voidizedCallback } from "./lower-server.js"; import { pairsSnapshotHelper } from "./pairs-snapshot.js"; -import { BOOL, BYTES_U8, CHILD_T, CHILDSTREAM_T, CRYPTOHASH_T, CRYPTOHMAC_T, DYN, F64, FILEHANDLE_T, FSWATCHER_T, PROCSTREAM_T, IrExpr, IrFunction, IrLibFn, IrLocal, IrStmt, IrType, JSVAL, NULL_T, SEARCH_PARAMS_T, SPAWNRES_T, STRING, SrcLoc, UNDEFINED_T, VOID, arrayOf, canBoxFuncIntoDyn, canConvertToDyn, funcOf, isUnitType, typeEquals, typeKey } from "../../ir/ir.js"; +import { BOOL, BYTES_U8, CHILD_T, CHILDSTREAM_T, CHILDWRITER_T, CRYPTOHASH_T, CRYPTOHMAC_T, DYN, F64, FILEHANDLE_T, FSWATCHER_T, PROCSTREAM_T, IrExpr, IrFunction, IrLibFn, IrLocal, IrStmt, IrType, JSVAL, NULL_T, SEARCH_PARAMS_T, SPAWNRES_T, STRING, SrcLoc, UNDEFINED_T, VOID, arrayOf, canBoxFuncIntoDyn, canConvertToDyn, funcOf, isUnitType, typeEquals, typeKey } from "../../ir/ir.js"; import { boolLit, countedFor, numLit, strLit, varRef } from "../../ir/build.js"; function optionalStringTags(lowerer: Lowerer, type: IrType): { stringTag: number; undefinedTag: number } | null { @@ -2287,13 +2287,12 @@ function optionMember(p: ts.ObjectLiteralElementLike): { name: string; value: ts } /** `spawn(command, args?, options)` → one cp.spawn / cp.spawnOpts - * libCall. The options argument must be an object LITERAL with an - * EXPLICIT stdio — "ignore" or "inherit" as the scalar, or the 3-tuple - * whose stdout/stderr slots may also be "pipe" (the child.stdout/ - * child.stderr streams) or number fds; piped STDIN fences, and - * OMITTING the options means Node's default stdio, "pipe" on all - * three — fenced too, so a program never silently loses its child's - * output. The other lowered + * libCall. The options argument, when present, must be an object + * LITERAL. Stdio accepts "ignore", "inherit", or "pipe" as the scalar, + * or the 3-tuple whose stdin pipe becomes child.stdin, stdout/stderr + * pipes become child.stdout/child.stderr, and output slots may also be + * number fds. Omitting stdio uses Node's default: all three piped. The + * other lowered * members: `detached` (a boolean literal, inline or carried by the * conditional spread `...(c ? { detached: true } : {})` in either * orientation — POSIX_SPAWN_SETSID, the child gets its own session and @@ -2306,12 +2305,30 @@ function optionMember(p: ts.ObjectLiteralElementLike): { name: string; value: ts lowerer.noLowering( "spawn with this argument shape", expr, - 'the supported form is spawn(command, args?, { stdio: "ignore" | "inherit", detached?, env?, cwd? })', + 'the supported form is spawn(command, args?, { stdio: "pipe" | "ignore" | "inherit", detached?, env?, cwd? })', ); } const cmd = lowerer.lowerExprExpecting(expr.arguments[0]!, STRING); - const argsNode = expr.arguments.length === 3 ? expr.arguments[1] : undefined; - const optsNode = expr.arguments[expr.arguments.length - 1]; + let argsNode: ts.Expression | undefined; + let optsNode: ts.Expression | undefined; + if (expr.arguments.length === 3) { + argsNode = expr.arguments[1]; + optsNode = expr.arguments[2]; + } else if (expr.arguments.length === 2) { + const second = expr.arguments[1]!; + const secondType = lowerer.typeOf(second); + const mapped = lowerer.mapTypeOf(secondType); + if (lowerer.checker.isArrayType(secondType) || lowerer.checker.isTupleType(secondType) || + (mapped?.kind === "array" && mapped.elem.kind === "string")) argsNode = second; + else optsNode = second; + } + if (optsNode !== undefined && !ts.isObjectLiteralExpression(optsNode)) { + lowerer.noLowering( + "spawn with a non-literal options argument", + optsNode, + "pass stdio, detached, env, cwd, and windowsHide in an inline object literal", + ); + } const emptyStr: IrExpr = { kind: "strLit", value: "", type: STRING, loc }; // Per-slot stdio modes (scr_child.c: 0 ignore, 1 inherit, 2 fd) and @@ -2327,13 +2344,7 @@ function optionMember(p: ts.ObjectLiteralElementLike): { name: string; value: ts let cwd: IrExpr = emptyStr; let plain = true; // exactly { stdio: "ignore" }: the historical libCall - const pipeFence = (node: ts.Node): never => - lowerer.noLowering( - 'spawn with stdio: "pipe"', - node, - 'piped STDIN has no lowering — pipe stdout/stderr with the tuple form (stdio: ["ignore", "pipe", "pipe"]), or capture with spawnSync', - ); - if (optsNode && ts.isObjectLiteralExpression(optsNode) && expr.arguments.length >= 2) { + if (optsNode && ts.isObjectLiteralExpression(optsNode)) { for (const p of optsNode.properties) { // The conditional-spread idiom `...(isWindows ? {} : { detached: // true })` (either orientation): the one carried member supported @@ -2378,20 +2389,18 @@ function optionMember(p: ts.ObjectLiteralElementLike): { name: string; value: ts } switch (m.name) { case "stdio": { - // The 3-tuple form: stdin a "ignore"/"inherit" literal, - // stdout/stderr each a literal (including "pipe" — the - // child.stdout/stderr stream slots) OR a number-typed fd (an - // openSync result — dup2'd into the child, Node's fd slots). + // The 3-tuple form: every slot accepts a literal, including + // "pipe" (child.stdin/stdout/stderr); stdout/stderr also accept + // a number-typed fd (an openSync result — dup2'd into the child). if (ts.isArrayLiteralExpression(m.value) && m.value.elements.length === 3) { const slot = (el: ts.Expression, which: 0 | 1 | 2): void => { const t = lowerer.typeOf(el); if (t.isStringLiteralType()) { - if (t.value === "pipe" && which === 0) pipeFence(el); if (t.value !== "ignore" && t.value !== "inherit" && t.value !== "pipe") { lowerer.noLowering( `spawn with stdio "${t.value}"`, el, - '"ignore", "inherit", "pipe" (stdout/stderr), and number fds are the supported stdio slots', + '"ignore", "inherit", "pipe", and number output fds are the supported stdio slots', ); } const mode = t.value === "inherit" ? 1 : t.value === "pipe" ? 3 : 0; @@ -2405,7 +2414,7 @@ function optionMember(p: ts.ObjectLiteralElementLike): { name: string; value: ts lowerer.noLowering( "spawn with this stdin slot", el, - 'stdin takes "ignore" or "inherit" (fd stdin has no lowering)', + 'stdin takes "pipe", "ignore", or "inherit" (fd stdin has no lowering)', ); } if (lowerer.mapTypeOf(t)?.kind !== "f64") { @@ -2426,16 +2435,15 @@ function optionMember(p: ts.ObjectLiteralElementLike): { name: string; value: ts } const t = lowerer.typeOf(m.value); const v = t.isStringLiteralType() ? t.value : null; - if (v === "pipe") pipeFence(m.value); - if (v !== "ignore" && v !== "inherit") { + if (v !== "pipe" && v !== "ignore" && v !== "inherit") { lowerer.noLowering( "spawn with this stdio option", m.value, - '"ignore" and "inherit" are the supported stdio literals ' + - '(or a 3-tuple of those and number fds; "pipe" has no lowering)', + '"pipe", "ignore", and "inherit" are the supported stdio literals ' + + "(or a 3-tuple of those and number output fds)", ); } - const mode = v === "inherit" ? 1 : 0; + const mode = v === "inherit" ? 1 : v === "pipe" ? 3 : 0; inMode = outMode = errMode = mode; sawStdio = true; if (v !== "ignore") plain = false; @@ -2478,11 +2486,8 @@ function optionMember(p: ts.ObjectLiteralElementLike): { name: string; value: ts } } if (!sawStdio) { - lowerer.noLowering( - "spawn without { stdio: \"ignore\" }", - expr, - 'Node\'s default stdio is "pipe" (streams, no lowering) — pass { stdio: "ignore" } or { stdio: "inherit" } explicitly, or capture with spawnSync', - ); + inMode = outMode = errMode = 3; + plain = false; } const argv = lowerer.lowerChildArgsArg(argsNode, loc); if (plain) { @@ -5306,7 +5311,7 @@ function lowerOptionalStringSearchParams(lowerer: Lowerer, init: IrExpr, loc: Sr return { kind: "recordGet", obj: receiver, shapeId: receiver.type.shapeId, field: "%enc", type: STRING, loc: locOf(expr) }; } const kind = lowerer.mapTypeOf(lowerer.typeOf(expr.expression))?.kind; - if (kind !== "stats" && kind !== "fileHandle" && kind !== "spawnRes" && kind !== "child") return null; + if (kind !== "stats" && kind !== "fileHandle" && kind !== "spawnRes" && kind !== "child" && kind !== "childWriter") return null; if (kind === "child" ? !isChildSurfaceMember(lowerer, expr) : !lowerer.isStdlibMember(expr)) return null; const name = expr.name.text; const loc = locOf(expr); @@ -5326,19 +5331,20 @@ function lowerOptionalStringSearchParams(lowerer: Lowerer, init: IrExpr, loc: Sr lowerer.checker.getSymbolAtLocation(expr.name), ); } - // child.stdout / child.stderr — the piped-output streams: the - // checker's `Readable | null` (null exactly when the slot was not - // piped), constructed type-directedly in the backend over the + // child.stdin/stdout/stderr — the piped streams: the checker's + // `Writable | null` / `Readable | null` (null exactly when the slot + // was not piped), constructed type-directedly in the backend over the // +1-or-NULL runtime pair. - if (kind === "child" && (name === "stdout" || name === "stderr")) { + if (kind === "child" && (name === "stdin" || name === "stdout" || name === "stderr")) { const receiver = lowerer.lowerExpr(expr.expression); + const streamType = name === "stdin" ? CHILDWRITER_T : CHILDSTREAM_T; const type: IrType = { kind: "union", - unionId: lowerer.unions.intern([CHILDSTREAM_T, { kind: "nullT" }]), + unionId: lowerer.unions.intern([streamType, { kind: "nullT" }]), }; const read: IrExpr = { kind: "libCall", - fn: name === "stdout" ? "child.stdout" : "child.stderr", + fn: name === "stdin" ? "child.stdin" : name === "stdout" ? "child.stdout" : "child.stderr", args: [receiver], type, loc, @@ -5346,6 +5352,21 @@ function lowerOptionalStringSearchParams(lowerer: Lowerer, init: IrExpr, loc: Sr return lowerer.maybeNarrow(read, expr); } if (kind === "child") return null; // pid/exitCode/killed live in lowerIntrinsicProperty + if (kind === "childWriter") { + if (name === "writable") { + const receiver = lowerer.lowerExprExpecting(expr.expression, CHILDWRITER_T); + return { kind: "libCall", fn: "writer.writable", args: [receiver], type: BOOL, loc }; + } + if (name === "write" || name === "end" || name === "destroy" || name === "on" || name === "once") { + lowerer.unsupported("SC1090", expr, `child stdin methods as values (call '${name}' directly)`); + } + lowerer.noLowering( + `Writable.${name}`, + expr, + 'write(string | Uint8Array), end(), destroy(), writable, and on/once("drain" | "finish" | "error", cb) are supported', + lowerer.checker.getSymbolAtLocation(expr.name), + ); + } if ( kind === "stats" && (name === "blocks" || name === "nlink" || name === "atimeMs" || name === "mtimeMs") @@ -5597,6 +5618,91 @@ function lowerOptionalStringSearchParams(lowerer: Lowerer, init: IrExpr, loc: Sr ); } +/** Method calls on a piped child stdin writer. Writes copy one string or + * Uint8Array into the nonblocking runtime queue and return the backpressure + * signal. end()/destroy() are statement-only, as are drain/finish/error + * listener registrations; unsupported callbacks/encodings stay fenced. */ + export function lowerChildWriterMethodCall(lowerer: Lowerer, call: ts.CallExpression, + access: ts.PropertyAccessExpression,): IrExpr | null { + if (lowerer.chainBlocked(call, access)) return null; + if (lowerer.mapTypeOf(lowerer.typeOf(access.expression))?.kind !== "childWriter") return null; + if (!lowerer.isStdlibMember(access)) return null; + const name = access.name.text; + const loc = locOf(call); + + if (name === "write") { + if (call.arguments.length !== 1 || call.arguments.some(ts.isSpreadElement)) { + lowerer.noLowering( + `child stdin write with ${call.arguments.length} arguments`, + call, + "write(string | Uint8Array) with no encoding or callback is supported", + ); + } + const receiver = lowerer.lowerExprExpecting(access.expression, CHILDWRITER_T); + const data = lowerer.lowerExpr(call.arguments[0]!); + if (data.type.kind === "string") { + return { kind: "libCall", fn: "writer.writeString", args: [receiver, data], type: BOOL, loc }; + } + if (data.type.kind === "bytes" && data.type.elem === "u8") { + return { kind: "libCall", fn: "writer.writeBytes", args: [receiver, data], type: BOOL, loc }; + } + lowerer.noLowering( + `child stdin write of '${lowerer.fmt(data.type)}'`, + call.arguments[0]!, + "write one string, Buffer, or Uint8Array value (narrow unions first)", + ); + } + + if ((name === "end" || name === "destroy") && call.arguments.length === 0) { + if (!ts.isExpressionStatement(call.parent)) { + lowerer.unsupported("SC1090", call, `chaining child.stdin.${name}() (call it as its own statement)`); + } + const receiver = lowerer.lowerExprExpecting(access.expression, CHILDWRITER_T); + return { kind: "libCall", fn: name === "end" ? "writer.end" : "writer.destroy", args: [receiver], type: VOID, loc }; + } + + if ((name === "on" || name === "once") && call.arguments.length === 2) { + const eventType = lowerer.typeOf(call.arguments[0]!); + const event = eventType.isStringLiteralType() ? eventType.value : null; + if (event !== "drain" && event !== "finish" && event !== "error") { + lowerer.noLowering( + `child stdin ${name}(${event === null ? "non-literal event" : `"${event}"`}, ...)`, + call.arguments[0]!, + '"drain", "finish", and "error" are the supported child stdin events', + ); + } + if (!ts.isExpressionStatement(call.parent)) { + lowerer.unsupported("SC1090", call, "chaining child stdin listener registration"); + } + const receiver = lowerer.lowerExprExpecting(access.expression, CHILDWRITER_T); + const cb = lowerer.lowerExpr(call.arguments[1]!); + const maxParams = event === "error" ? 1 : 0; + if (cb.type.kind !== "func" || cb.type.ret.kind !== "void" || cb.type.params.length > maxParams) { + lowerer.unsupported( + "SC1090", + call.arguments[1]!, + event === "error" + ? "error listeners with at most one Error parameter and no return value" + : `${event} listeners with no parameters or return value`, + ); + } + if (event === "error" && cb.type.params[0] !== undefined && + !(cb.type.params[0]!.kind === "object" && cb.type.params[0]!.className === "%Error")) { + lowerer.unsupported("SC1090", call.arguments[1]!, "child stdin error listeners whose parameter is Error"); + } + const once = boolLit(name === "once", loc); + const fn = event === "drain" ? "writer.onDrain" : event === "finish" ? "writer.onFinish" : "writer.onError"; + return { kind: "libCall", fn, args: [receiver, cb, once], type: VOID, loc }; + } + + lowerer.noLowering( + `Writable.${name}`, + call, + 'write(string | Uint8Array), end(), destroy(), and on/once("drain" | "finish" | "error", cb) are supported', + lowerer.checker.getSymbolAtLocation(access.name), + ); + } + /** Method calls on first-class process-stream receivers (procStream — * a WritableStream-typed value like prefixStream's `output` param): * write(data) with one string, dispatched at runtime onto the exact diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 902795c2..4c9a55e3 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -19,7 +19,7 @@ import { mixinFnShapeOf } from "./lower-mixins.js"; import { dynStringReceiver, lowerArrayFromCall, lowerDynArrayFilterCall, lowerDynArrayFlatMapCall, lowerGroupByStaticCall, lowerIteratorHelperCall, lowerObjectAssignIndexShape, lowerObjectFromEntriesCall, lowerObjectIterOverIndexShape, lowerTupleReadMethodCall } from "./lower-containers.js"; import { bufEncoding } from "./containers/bytes.js"; import { lowerRegexMethodCall, lowerStringMethodCall } from "./containers/string-and-regexp.js"; -import { lowerChildStreamMethodCall, lowerCreateRequireCall, lowerCryptoHashMethodCall, lowerDirentMethodCall, lowerFileHandleMethodCall, lowerImportMetaResolveCall, lowerPerfHooksCall, lowerProcStreamMethodCall, lowerReflectApplyCall, lowerRequireResolveCall, lowerWatcherMethodCall } from "./lower-builtins.js"; +import { lowerChildStreamMethodCall, lowerChildWriterMethodCall, lowerCreateRequireCall, lowerCryptoHashMethodCall, lowerDirentMethodCall, lowerFileHandleMethodCall, lowerImportMetaResolveCall, lowerPerfHooksCall, lowerProcStreamMethodCall, lowerReflectApplyCall, lowerRequireResolveCall, lowerWatcherMethodCall } from "./lower-builtins.js"; import { lowerAbsenceProbe, lowerPromiseAllTupleCall, lowerPromiseRejectCall, templateRawTextOf } from "./lower-exprs.js"; import { isSafeToDiscard } from "./expressions/evaluation-safety.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; @@ -4341,6 +4341,7 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { lowerer.lowerChildMethodCall(expr, expr.expression) ?? // Piped child-output stream receivers — on/once("data" | "end"). lowerChildStreamMethodCall(lowerer, expr, expr.expression) ?? + lowerChildWriterMethodCall(lowerer, expr, expr.expression) ?? // First-class process-stream receivers — write(data). lowerProcStreamMethodCall(lowerer, expr, expr.expression) ?? // FSWatcher receivers — close() (fs.watch's handle). diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 61aed1ec..76381ceb 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -6824,7 +6824,7 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr case "netServer": case "netSocket": case "httpReq": case "httpRes": case "httpClientReq": case "secureCtx": case "fsWatcher": case "cryptoHash": case "cryptoHmac": - case "childStream": case "procStream": + case "childStream": case "childWriter": case "procStream": case "object": case "record": case "promise": return "object"; default: diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 6608a957..7028106b 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -513,7 +513,7 @@ export function provenanceElidedConstDecl(lowerer: Lowerer, decl: ts.VariableDec // shape is the const's own initializer capturing the const: // `const server = http.createServer(h).listen(0, done)` with `done` // reading `server` — a TDZ read until the assign completes, JS exactly. - "child", "childStream", "netServer", "netSocket", "dgramSocket", "testCtx", + "child", "childStream", "childWriter", "netServer", "netSocket", "dgramSocket", "testCtx", "httpReq", "httpRes", "httpClientReq", // The h2 handles — `const client = http2.connect(url, mustCall(() => // ...client...))` is the suite's canonical self-capturing const. diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 97aac1e3..3f878322 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -533,6 +533,8 @@ export function formatIrType(t: IrType, shapes: ShapeRegistry, unions: UnionRegi return "FSWatcher"; case "childStream": return "Readable"; + case "childWriter": + return "Writable"; case "procStream": return "WriteStream"; case "promise": @@ -1225,7 +1227,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // no class identity of its own. if (widened.isIntersectionType()) { const HANDLE_KINDS = new Set([ - "netServer", "netSocket", "httpReq", "httpRes", "httpClientReq", "dgramSocket", + "netServer", "netSocket", "httpReq", "httpRes", "httpClientReq", "dgramSocket", "childWriter", // process.stdout's own type IS the refined intersection // `WriteStream & { fd: 1 }` — the scalar stream kind rides the same // refinement rule. @@ -1805,14 +1807,19 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ) { return { kind: "spawnRes" }; } - // child_process.ChildProcess: @types/node's class or the fallback - // declarations' interface. Provenance-checked like Stats. + // child_process.ChildProcess and @types/node's stdio-refined return + // interfaces: all carry the same runtime child handle; the generic + // refinements only tell the checker which stdio properties are null. if ( - psym?.name === "ChildProcess" && + psym !== undefined && + (psym.name === "ChildProcess" || + psym.name === "ChildProcessWithoutNullStreams" || + psym.name === "ChildProcessByStdio") && checker.declarationsOf(psym).some( (d) => (ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)) && - ctx.isStdlibFile(d.getSourceFile()), + ctx.isStdlibFile(d.getSourceFile()) && + isDeclaredInAmbientModule(d, "child_process"), ) ) { return { kind: "child" }; @@ -1925,6 +1932,29 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ) { return PROCSTREAM_T; } + // A piped child stdin writer. The fallback declarations give it the + // dedicated ChildStdin name; @types/node spells the same value as + // stream.Writable. As with stream.Readable below, unsupported producers + // of the broad @types class fence where they are constructed. + if ( + (psym?.name === "ChildStdin" && + checker.declarationsOf(psym).some( + (d) => + ts.isInterfaceDeclaration(d) && + ctx.isStdlibFile(d.getSourceFile()) && + isDeclaredInAmbientModule(d, "child_process"), + )) || + (psym?.name === "Writable" && + checker.declarationsOf(psym).some( + (d) => + (ts.isInterfaceDeclaration(d) || ts.isClassDeclaration(d)) && + ctx.isStdlibFile(d.getSourceFile()) && + isNodeTypesPath(d.getSourceFile().fileName) && + isDeclaredInAmbientModule(d, "stream"), + )) + ) { + return { kind: "childWriter" }; + } // The static node:stream classes (the shipped fallback declarations — // NOT @types/node, whose stream.Readable also types child stdio; under // @types/node the childStream mapping below keeps priority and the @@ -2589,7 +2619,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // NgrokChildProcess idiom — `spawn(...) as NgrokChildProcess`, declared // so tests can hand in mocks): every member is named on the lowered // ChildProcess surface AND at least two are the handle-defining ones - // (kill/on/stdout/stderr/unref/exitCode — a data record like + // (kill/on/stdin/stdout/stderr/unref/exitCode — a data record like // `{ pid: number }` never qualifies). The TYPE maps to the child // handle: in a compiled program the only VALUE producer is spawn // itself (a mock object literal in this slot fences at its @@ -2607,9 +2637,9 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ) { const CHILD_SURFACE = new Set([ "pid", "exitCode", "killed", "kill", "on", "once", "off", - "removeListener", "unref", "ref", "stdout", "stderr", + "removeListener", "unref", "ref", "stdin", "stdout", "stderr", ]); - const CHILD_CORE = new Set(["kill", "on", "stdout", "stderr", "unref", "exitCode"]); + const CHILD_CORE = new Set(["kill", "on", "stdin", "stdout", "stderr", "unref", "exitCode"]); const props = checker.getPropertiesOfType(widened); let core = 0; const childShaped = diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index b0b7cd51..4a3522f1 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -383,6 +383,7 @@ function moduleWasiUnavailableSurface(mod: IrModule): { surface: string; loc: Sr ["child", "child processes (WASI Preview 1 has no process-spawning API)"], ["spawnRes", "child processes (WASI Preview 1 has no process-spawning API)"], ["childStream", "child processes (WASI Preview 1 has no process-spawning API)"], + ["childWriter", "child processes (WASI Preview 1 has no process-spawning API)"], ["netServer", "network sockets (WASI Preview 1 has no socket API)"], ["netSocket", "network sockets (WASI Preview 1 has no socket API)"], ["http2Session", "network sockets (WASI Preview 1 has no socket API)"], diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 9578e302..7a9b3caf 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -186,6 +186,11 @@ export type IrType = * rules as child: union arms fine (the checker's `Readable | null`), * arrays/maps/JSON fenced. */ | { kind: "childStream" } + /** A piped child-input stream (child.stdin — spawn with a piped stdin + * slot; scr_child.c). Heap, refcounted, and mutable: writes queue into + * the platform pipe without blocking the JavaScript thread, and + * drain/finish/error listeners drop when the writer settles. */ + | { kind: "childWriter" } /** A process output stream as a FIRST-CLASS value (process.stdout / * process.stderr flowing into a `NodeJS.WritableStream` slot — the * prefixStream idiom). Representation is the raw FD as a double (1 or @@ -338,6 +343,7 @@ const POINTER_HANDLE_KINDS = [ "cryptoHmac", "fsWatcher", "childStream", + "childWriter", ] as const satisfies readonly IrType["kind"][]; interface IrKindSet extends ReadonlySet { @@ -415,6 +421,7 @@ export const RUNTIME_RC_STEMS: Record = { httpRes: "scr_http_res", httpClientReq: "scr_http_client", childStream: "scr_child_stream", + childWriter: "scr_child_writer", procStream: "", fsWatcher: "scr_watcher", secureCtx: "scr_secure_ctx", @@ -458,7 +465,7 @@ export const REF_TRUTHY_KINDS: ReadonlySet = new Set([ "symbol", "date", "array", "map", "set", "regex", "url", "searchParams", "stats", "fileHandle", "spawnRes", "child", "netServer", "netSocket", "http2Session", "http2Stream", "dgramSocket", "testCtx", "httpReq", "httpRes", "httpClientReq", - "secureCtx", "cryptoHash", "cryptoHmac", "fsWatcher", "childStream", "procStream", "bytes", "func", "object", "record", "promise", + "secureCtx", "cryptoHash", "cryptoHmac", "fsWatcher", "childStream", "childWriter", "procStream", "bytes", "func", "object", "record", "promise", // A generator object is a JS object: always truthy. "generator", // A class object is a JS object (constructors are functions): always truthy. @@ -492,6 +499,7 @@ export const CRYPTOHASH_T: IrType = { kind: "cryptoHash" }; export const CRYPTOHMAC_T: IrType = { kind: "cryptoHmac" }; export const FSWATCHER_T: IrType = { kind: "fsWatcher" }; export const CHILDSTREAM_T: IrType = { kind: "childStream" }; +export const CHILDWRITER_T: IrType = { kind: "childWriter" }; export const PROCSTREAM_T: IrType = { kind: "procStream" }; export const VOID: IrType = { kind: "void" }; export const DYN: IrType = { kind: "dyn" }; @@ -2341,10 +2349,9 @@ export type IrLibFn = * the backend (the envGet convention). Never throws. */ | "spawnRes.error" /** child_process.spawn (scr_child.c + the scr_async.c loop): posix_spawnp - * with stdio "ignore" (all three fds on /dev/null — the only supported - * stdio; "pipe"/"inherit" are frontend-fenced), the child registered - * with the event loop, which polls waitpid(WNOHANG) at quiescence like - * timers (kqueue is the follow-up; SEMANTICS.md documents the polling). + * with per-slot ignore/inherit/pipe modes (and number output fds), the + * all-piped Node default, and the child registered with the event loop, + * which polls waitpid(WNOHANG) at quiescence like timers. * NEVER throws: spawn failure defers to the "error" event, Node-exact * (the error message is Node's "spawn "; an "error" * event with no listener prints it and exits 1 like an EventEmitter). @@ -2402,6 +2409,19 @@ export type IrLibFn = | "child.stderr" | "stream.onData" | "stream.onEnd" + /** The piped-input writer (stdio mode 3 on fd 0). child.stdin answers + * `Writable | null`; writes copy borrowed data into the nonblocking + * queue, end/destroy settle it, writable is a pure state read, and + * listener callbacks move into the writer registry. */ + | "child.stdin" + | "writer.writeString" + | "writer.writeBytes" + | "writer.end" + | "writer.destroy" + | "writer.writable" + | "writer.onDrain" + | "writer.onFinish" + | "writer.onError" /** The first-class WritableStream write (`output.write(line)` — the * prefixStream idiom): the receiver IS the fd scalar (process.stdout/ * stderr reads mint 1/2), dispatched onto the exact stdoutWrite/ @@ -6450,12 +6470,12 @@ export function moduleUsesChildProcess(mod: IrModule): boolean { if ( node.kind === "libCall" && typeof node.fn === "string" && - (node.fn.startsWith("cp.") || node.fn.startsWith("child.") || node.fn.startsWith("spawnRes.")) + (node.fn.startsWith("cp.") || node.fn.startsWith("child.") || node.fn.startsWith("writer.") || node.fn.startsWith("spawnRes.")) ) { found = true; return; } - if (node.kind === "child" || node.kind === "childStream" || node.kind === "spawnRes") { + if (node.kind === "child" || node.kind === "childStream" || node.kind === "childWriter" || node.kind === "spawnRes") { found = true; return; } @@ -6903,6 +6923,7 @@ const LIB_MODE_REFUSED_KINDS: ReadonlyMap = new Map([ ["child", "the child_process surface"], ["spawnRes", "the child_process surface"], ["childStream", "the child_process surface"], + ["childWriter", "the child_process surface"], ["netServer", "the node:net surface"], ["netSocket", "the node:net surface"], ["http2Session", "the node:http2 surface"], diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 307cad98..452691cf 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -18,7 +18,7 @@ import type { IrUnionDef, SrcLoc, } from "./ir.js"; -import { arrayOf, BOOL, BYTES_U8, bytesOf, canAdaptDynFuncTo, canConvertToDyn, canExitIslandToType, canMarshalIntoIsland, canMarshalTypedFuncIntoIsland, CHILD_T, CHILDSTREAM_T, CRYPTOHASH_T, CRYPTOHMAC_T, DATE_T, DGRAMSOCK_T, DYN, DYN_HANDLE_KINDS, F64, ffiClassType, ffiSourceParamTypes, FILEHANDLE_T, FSWATCHER_T, HTTP2SESSION_T, HTTP2STREAM_T, HTTPCLIENTREQ_T, HTTPREQ_T, HTTPRES_T, islandPromisePayloadTag, isDynTypedRefType, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isJsonSafeType, isJsonStringifySafeType, isRefCounted, isSupportedArrayElem, isSupportedIndexValue, isSupportedMapKey, isSupportedMapValue, isSupportedSetElem, isUnitType, jsOpResultKind, JSVAL, NETSERVER_T, NETSOCKET_T, PROCSTREAM_T, REF_TRUTHY_KINDS, REGEX, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, SEARCH_PARAMS_T, SECURECTX_T, shapeHasAccessorSlots, SPAWNRES_T, STATS_T, STRING, SYMBOL_T, TESTCTX_T, typeEquals, typeKey, unionFuncSetArmsOk, URL_T, VOID } from "./ir.js"; +import { arrayOf, BOOL, BYTES_U8, bytesOf, canAdaptDynFuncTo, canConvertToDyn, canExitIslandToType, canMarshalIntoIsland, canMarshalTypedFuncIntoIsland, CHILD_T, CHILDSTREAM_T, CHILDWRITER_T, CRYPTOHASH_T, CRYPTOHMAC_T, DATE_T, DGRAMSOCK_T, DYN, DYN_HANDLE_KINDS, F64, ffiClassType, ffiSourceParamTypes, FILEHANDLE_T, FSWATCHER_T, HTTP2SESSION_T, HTTP2STREAM_T, HTTPCLIENTREQ_T, HTTPREQ_T, HTTPRES_T, islandPromisePayloadTag, isDynTypedRefType, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isJsonSafeType, isJsonStringifySafeType, isRefCounted, isSupportedArrayElem, isSupportedIndexValue, isSupportedMapKey, isSupportedMapValue, isSupportedSetElem, isUnitType, jsOpResultKind, JSVAL, NETSERVER_T, NETSOCKET_T, PROCSTREAM_T, REF_TRUTHY_KINDS, REGEX, RUNTIME_EMITTER_CLASS, RUNTIME_ERROR_CLASSES, RUNTIME_STREAM_CLASSES, SEARCH_PARAMS_T, SECURECTX_T, shapeHasAccessorSlots, SPAWNRES_T, STATS_T, STRING, SYMBOL_T, TESTCTX_T, typeEquals, typeKey, unionFuncSetArmsOk, URL_T, VOID } from "./ir.js"; /** Per-method signature for strIntrinsic: `argTypes` lists every argument * position (optional ones included); `minArgs` is how many may be omitted @@ -729,11 +729,20 @@ export const LIB_FN_SIGS: Record a.kind === "childStream") && + def.arms.some((a) => a.kind === handleKind) && def.arms.some((a) => a.kind === "nullT"); if (!ok) { - err(`libCall ${e.fn} must return the 'Readable | null' union`, e.loc); + err(`libCall ${e.fn} must return the '${e.fn === "child.stdin" ? "Writable" : "Readable"} | null' union`, e.loc); } break; } diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index c5336f11..78b381cd 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -6211,6 +6211,48 @@ ], "diags": [] }, + "/tests/corpus/2892-windows-console-unicode.ts": { + "order": [ + "/tests/corpus/2892-windows-console-unicode.ts" + ], + "diags": [] + }, + "/tests/corpus/2893-child-stdin-duplex.ts": { + "order": [ + "/tests/corpus/2893-child-stdin-duplex.ts" + ], + "diags": [] + }, + "/tests/corpus/2894-child-stdin-backpressure.ts": { + "order": [ + "/tests/corpus/2894-child-stdin-backpressure.ts" + ], + "diags": [] + }, + "/tests/corpus/2895-child-stdin-write-after-end.ts": { + "order": [ + "/tests/corpus/2895-child-stdin-write-after-end.ts" + ], + "diags": [] + }, + "/tests/corpus/2896-child-stdin-write-after-finish.ts": { + "order": [ + "/tests/corpus/2896-child-stdin-write-after-finish.ts" + ], + "diags": [] + }, + "/tests/corpus/2897-child-stdin-destroy.ts": { + "order": [ + "/tests/corpus/2897-child-stdin-destroy.ts" + ], + "diags": [] + }, + "/tests/corpus/2898-child-stdin-spawn-failure.ts": { + "order": [ + "/tests/corpus/2898-child-stdin-spawn-failure.ts" + ], + "diags": [] + }, "/tests/corpus/300-if-else.ts": { "order": [ "/tests/corpus/300-if-else.ts" @@ -8937,6 +8979,18 @@ ], "diags": [] }, + "/tests/fixtures/node-types/child-stdin-fences.ts": { + "order": [ + "/tests/fixtures/node-types/child-stdin-fences.ts" + ], + "diags": [] + }, + "/tests/fixtures/node-types/child-stdin.ts": { + "order": [ + "/tests/fixtures/node-types/child-stdin.ts" + ], + "diags": [] + }, "/tests/fixtures/node-types/fenced.ts": { "order": [ "/tests/fixtures/node-types/fenced.ts" diff --git a/packages/runtime/src/scr_child.c b/packages/runtime/src/scr_child.c index 8d4a1638..8369d802 100644 --- a/packages/runtime/src/scr_child.c +++ b/packages/runtime/src/scr_child.c @@ -46,11 +46,11 @@ static size_t scr_child_arg_start(ScrStr *cmd, ScrArr *args) { * parent when missing, so winsock and PATH-dependent children keep * working; the child's PATH (its own, or the merged parent's) is what * the executable search walks, like uv_spawn. - * - stdio is HANDLE inheritance: anonymous pipes for capture and the - * child.stdout/stderr streams (PeekNamedPipe polls readiness — - * anonymous pipes cannot overlap), NUL for ignore, duplicated std - * handles for inherit, _get_osfhandle for the fd form, PIPE_NOWAIT on - * the input pipe's write end so the sync feed never blocks. + * - stdio is HANDLE inheritance: anonymous pipes for capture and + * child.stdout/stderr (PeekNamedPipe polls readiness), an overlapped + * local named pipe for writable child.stdin, NUL for ignore, duplicated + * std handles for inherit, and _get_osfhandle for the fd form. The sync + * input feed retains its separate PIPE_NOWAIT path. * - There are no signals: kill/killSignal for SIGTERM/SIGKILL/SIGINT/ * SIGQUIT/SIGHUP is TerminateProcess(handle, 1) with the signal NAME * reported on the result/exit event, libuv's uv_kill emulation (a @@ -573,6 +573,43 @@ static bool scr_win_pipe(HANDLE *parent_end, HANDLE *child_end, bool child_write return true; } +/* CreatePipe cannot make the parent end overlapped. A local one-instance + * named pipe gives the child an ordinary inheritable read handle and the + * parent a non-inheritable FILE_FLAG_OVERLAPPED write handle, so a full + * stdin pipe never blocks the JavaScript thread. */ +static LONG scr_win_stdin_pipe_seq = 0; +static bool scr_win_stdin_pipe(HANDLE *parent_end, HANDLE *child_end) { + WCHAR name[128]; + LONG seq = InterlockedIncrement(&scr_win_stdin_pipe_seq); + _snwprintf(name, sizeof name / sizeof name[0], + L"\\\\.\\pipe\\scriptc-stdin-%lu-%ld", + (unsigned long)GetCurrentProcessId(), (long)seq); + name[sizeof name / sizeof name[0] - 1] = L'\0'; + + SECURITY_ATTRIBUTES child_sa = {sizeof(SECURITY_ATTRIBUTES), NULL, TRUE}; + HANDLE rd = CreateNamedPipeW( + name, PIPE_ACCESS_INBOUND, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, + 1, 65536, 65536, 0, &child_sa); + if (rd == INVALID_HANDLE_VALUE) return false; + + HANDLE wr = CreateFileW(name, GENERIC_WRITE, 0, NULL, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, NULL); + if (wr == INVALID_HANDLE_VALUE) { + CloseHandle(rd); + return false; + } + if (!ConnectNamedPipe(rd, NULL) && GetLastError() != ERROR_PIPE_CONNECTED) { + CloseHandle(wr); + CloseHandle(rd); + return false; + } + (void)SetHandleInformation(wr, HANDLE_FLAG_INHERIT, 0); + *parent_end = wr; + *child_end = rd; + return true; +} + /* ── capture buffers (the POSIX arm's shape) ─────────────────────────── */ typedef struct { @@ -1085,8 +1122,40 @@ struct ScrChildStream { struct ScrChildStream *next; /* the service registry (+1) */ }; +struct ScrChildWriter { + size_t rc; + HANDLE h; /* overlapped parent write end; NULL once settled */ + HANDLE event; + OVERLAPPED ov; + bool write_pending; + DWORD pending_len; + bool writable; + bool ending; + bool finished; + bool destroyed; + bool need_drain; + bool drain_pending; + bool finish_pending; + bool error_pending; + uint8_t *buf; + size_t off, len, cap; + ScrStr *err_msg; + const char *err_code; /* static storage */ + ScrChildStreamEndL *drain_ls; + size_t n_drain, cap_drain; + ScrChildStreamEndL *finish_ls; + size_t n_finish, cap_finish; + ScrChildErrEntry *err_ls; + size_t n_write_err, cap_write_err; + struct ScrChildWriter *next; /* the service registry (+1) */ +}; + static ScrChildStream *scr_child_streams = NULL; static size_t scr_child_streams_watching = 0; +static ScrChildWriter *scr_child_writers = NULL; +static bool scr_child_writers_pending(void); +static void scr_child_writers_service(void); +static void scr_child_writer_child_settled(ScrChildWriter *w); struct ScrChild { size_t rc; @@ -1109,6 +1178,7 @@ struct ScrChild { size_t n_err, cap_err; ScrChildStream *out_stream; ScrChildStream *err_stream; + ScrChildWriter *in_stream; struct ScrChild *next; /* the pending registry */ }; @@ -1142,6 +1212,7 @@ void scr_child_release(ScrChild *c) { scr_str_release(c->err_msg); scr_child_stream_release(c->out_stream); scr_child_stream_release(c->err_stream); + scr_child_writer_release(c->in_stream); if (c->proc != NULL) CloseHandle(c->proc); free(c); } @@ -1359,6 +1430,420 @@ void scr_child_stream_thunk_bytes(ScrClosure *cb, ScrBytes *chunk) { ((void (*)(ScrClosure *, ScrBytes *))cb->fn)(cb, scr_bytes_retain(chunk)); } +/* ── the piped-input writer (overlapped named-pipe writes) ─────────── */ + +#define SCR_CHILD_WRITER_HWM 16384 + +static const char *scr_child_writer_err_code = NULL; + +static void scr_child_writer_drop_zero_listeners(ScrChildWriter *w) { + for (size_t i = 0; i < w->n_drain; i++) scr_closure_release(w->drain_ls[i].cb); + for (size_t i = 0; i < w->n_finish; i++) scr_closure_release(w->finish_ls[i].cb); + free(w->drain_ls); + free(w->finish_ls); + w->drain_ls = NULL; + w->finish_ls = NULL; + w->n_drain = w->cap_drain = 0; + w->n_finish = w->cap_finish = 0; +} + +static void scr_child_writer_drop_listeners(ScrChildWriter *w) { + scr_child_writer_drop_zero_listeners(w); + for (size_t i = 0; i < w->n_write_err; i++) scr_closure_release(w->err_ls[i].cb); + free(w->err_ls); + w->err_ls = NULL; + w->n_write_err = w->cap_write_err = 0; +} + +ScrChildWriter *scr_child_writer_retain(ScrChildWriter *w) { + if (w->rc != SIZE_MAX) w->rc++; + return w; +} + +static void scr_child_writer_cancel(ScrChildWriter *w) { + if (!w->write_pending || w->h == NULL) return; + (void)CancelIoEx(w->h, &w->ov); + (void)GetOverlappedResult(w->h, &w->ov, &w->pending_len, TRUE); + w->write_pending = false; +} + +void scr_child_writer_release(ScrChildWriter *w) { + if (!w || w->rc == SIZE_MAX) return; + if (--w->rc == 0) { + scr_child_writer_cancel(w); + if (w->h != NULL) CloseHandle(w->h); + if (w->event != NULL) CloseHandle(w->event); + free(w->buf); + scr_str_release(w->err_msg); + scr_child_writer_drop_listeners(w); + free(w); + } +} + +void *scr_child_writer_retain_v(void *p) { + return scr_child_writer_retain((ScrChildWriter *)p); +} +void scr_child_writer_release_v(void *p) { + scr_child_writer_release((ScrChildWriter *)p); +} + +static ScrChildWriter *scr_child_writer_new(HANDLE h) { + ScrChildWriter *w = calloc(1, sizeof *w); + if (!w) scr_child_oom(); + w->rc = 1; + w->h = h; + w->writable = h != NULL; + w->destroyed = h == NULL; + if (h != NULL) { + w->event = CreateEventW(NULL, TRUE, FALSE, NULL); + if (w->event == NULL) { + CloseHandle(h); + w->h = NULL; + w->writable = false; + w->destroyed = true; + return w; + } + w->ov.hEvent = w->event; + w->next = scr_child_writers; + scr_child_writers = scr_child_writer_retain(w); + } + return w; +} + +static void scr_child_writer_close(ScrChildWriter *w) { + scr_child_writer_cancel(w); + if (w->h != NULL) { + CloseHandle(w->h); + w->h = NULL; + } + w->writable = false; +} + +static void scr_child_writer_clear_queue(ScrChildWriter *w) { + free(w->buf); + w->buf = NULL; + w->off = w->len = w->cap = 0; +} + +static size_t scr_child_writer_queued(const ScrChildWriter *w) { + return w->len - w->off; +} + +static void scr_child_writer_append(ScrChildWriter *w, const void *data, size_t len) { + if (len == 0) return; + size_t queued = scr_child_writer_queued(w); + if (w->off > 0 && w->cap - w->len < len) { + memmove(w->buf, w->buf + w->off, queued); + w->off = 0; + w->len = queued; + } + if (w->cap - w->len < len) { + size_t cap = w->cap ? w->cap : 16384; + while (cap - w->len < len) { + if (cap > SIZE_MAX / 2) scr_child_oom(); + cap *= 2; + } + uint8_t *grown = realloc(w->buf, cap); + if (!grown) scr_child_oom(); + w->buf = grown; + w->cap = cap; + } + memcpy(w->buf + w->len, data, len); + w->len += len; +} + +static void scr_child_writer_fail(ScrChildWriter *w, const char *message, + const char *code) { + if (w->error_pending || (w->err_msg != NULL && w->destroyed)) return; + scr_child_writer_close(w); + scr_child_writer_clear_queue(w); + w->ending = false; + w->destroyed = true; + w->need_drain = false; + w->drain_pending = false; + w->finish_pending = false; + scr_str_release(w->err_msg); + w->err_msg = scr_str_new(message, strlen(message)); + w->err_code = code; + w->error_pending = true; +} + +static void scr_child_writer_complete(ScrChildWriter *w) { + scr_child_writer_close(w); + w->ending = false; + w->finish_pending = true; +} + +static bool scr_child_writer_collect_write(ScrChildWriter *w) { + if (!w->write_pending) return true; + DWORD wrote = 0; + if (!GetOverlappedResult(w->h, &w->ov, &wrote, FALSE)) { + DWORD err = GetLastError(); + if (err == ERROR_IO_INCOMPLETE) return false; + w->write_pending = false; + scr_child_writer_fail( + w, (err == ERROR_BROKEN_PIPE || err == ERROR_NO_DATA) ? "write EPIPE" : "write EIO", + (err == ERROR_BROKEN_PIPE || err == ERROR_NO_DATA) ? "EPIPE" : "EIO"); + return false; + } + w->write_pending = false; + if (wrote == 0) { + scr_child_writer_fail(w, "write EPIPE", "EPIPE"); + return false; + } + w->off += (size_t)wrote; + return true; +} + +static void scr_child_writer_pump(ScrChildWriter *w) { + if (!scr_child_writer_collect_write(w)) return; + while (w->h != NULL && scr_child_writer_queued(w) > 0) { + DWORD amount = scr_child_writer_queued(w) > 65536 + ? 65536 + : (DWORD)scr_child_writer_queued(w); + DWORD wrote = 0; + ResetEvent(w->event); + w->ov.Internal = 0; + w->ov.InternalHigh = 0; + BOOL ok = WriteFile(w->h, w->buf + w->off, amount, &wrote, &w->ov); + if (ok) { + if (wrote == 0) { + scr_child_writer_fail(w, "write EPIPE", "EPIPE"); + return; + } + w->off += (size_t)wrote; + continue; + } + DWORD err = GetLastError(); + if (err == ERROR_IO_PENDING) { + w->write_pending = true; + w->pending_len = amount; + return; + } + scr_child_writer_fail( + w, (err == ERROR_BROKEN_PIPE || err == ERROR_NO_DATA) ? "write EPIPE" : "write EIO", + (err == ERROR_BROKEN_PIPE || err == ERROR_NO_DATA) ? "EPIPE" : "EIO"); + return; + } + if (scr_child_writer_queued(w) == 0 && !w->write_pending) { + w->off = w->len = 0; + if (w->need_drain) { + w->need_drain = false; + w->drain_pending = true; + } + if (w->ending) scr_child_writer_complete(w); + } +} + +static bool scr_child_writer_write(ScrChildWriter *w, const void *data, + size_t len) { + if (!w->writable) { + scr_child_writer_fail(w, "write after end", "ERR_STREAM_WRITE_AFTER_END"); + return false; + } + scr_child_writer_append(w, data, len); + /* libuv's overlapped pipe reports backpressure from the queued amount + * before submission. WriteFile may complete eagerly; keep Node's 16 KiB + * decision and schedule drain once the runtime queue empties. */ + bool ok = scr_child_writer_queued(w) < SCR_CHILD_WRITER_HWM; + if (!ok) w->need_drain = true; + scr_child_writer_pump(w); + if (w->error_pending) return false; + return ok; +} + +bool scr_child_writer_write_string(ScrChildWriter *w, const ScrStr *data) { + return scr_child_writer_write(w, data->data, data->len); +} + +bool scr_child_writer_write_bytes(ScrChildWriter *w, const ScrBytes *data) { + return scr_child_writer_write(w, data->data, data->len); +} + +void scr_child_writer_end(ScrChildWriter *w) { + if (w->ending || w->finish_pending || w->finished || w->destroyed) return; + w->writable = false; + w->ending = true; + scr_child_writer_pump(w); +} + +void scr_child_writer_destroy(ScrChildWriter *w) { + if (w->finished || (w->destroyed && !w->error_pending)) return; + scr_child_writer_close(w); + scr_child_writer_clear_queue(w); + w->ending = false; + w->destroyed = true; + w->need_drain = false; + w->drain_pending = false; + w->finish_pending = false; + w->error_pending = false; + scr_str_release(w->err_msg); + w->err_msg = NULL; + scr_child_writer_drop_listeners(w); +} + +bool scr_child_writer_writable(ScrChildWriter *w) { return w->writable; } + +static void scr_child_writer_add_zero(ScrChildStreamEndL **ls, size_t *n, + size_t *cap, ScrClosure *cb, bool once) { + if (*n == *cap) { + *cap = *cap ? *cap * 2 : 2; + *ls = realloc(*ls, *cap * sizeof **ls); + if (!*ls) scr_child_oom(); + } + (*ls)[*n] = (ScrChildStreamEndL){cb, once}; + (*n)++; +} + +void scr_child_writer_on_drain(ScrChildWriter *w, ScrClosure *cb /*moves*/, + bool once) { + if (w->finished || (w->destroyed && !w->drain_pending)) { + scr_closure_release(cb); + return; + } + scr_child_writer_add_zero(&w->drain_ls, &w->n_drain, &w->cap_drain, cb, once); +} + +void scr_child_writer_on_finish(ScrChildWriter *w, ScrClosure *cb /*moves*/, + bool once) { + if (w->finished || (w->destroyed && !w->finish_pending)) { + scr_closure_release(cb); + return; + } + scr_child_writer_add_zero(&w->finish_ls, &w->n_finish, &w->cap_finish, cb, once); +} + +void scr_child_writer_on_error(ScrChildWriter *w, ScrClosure *cb /*moves*/, + ScrChildErrFn fn, bool once) { + (void)once; + if ((w->destroyed && !w->finished && !w->error_pending) || + (w->err_msg != NULL && !w->error_pending)) { + scr_closure_release(cb); + return; + } + if (w->n_write_err == w->cap_write_err) { + w->cap_write_err = w->cap_write_err ? w->cap_write_err * 2 : 2; + w->err_ls = realloc(w->err_ls, w->cap_write_err * sizeof *w->err_ls); + if (!w->err_ls) scr_child_oom(); + } + w->err_ls[w->n_write_err++] = (ScrChildErrEntry){cb, fn}; +} + +static void scr_child_writer_fire_drain(ScrChildWriter *w) { + w->drain_pending = false; + size_t n = w->n_drain; + if (n == 0) return; + ScrChildStreamEndL *snap = malloc(n * sizeof *snap); + if (!snap) scr_child_oom(); + for (size_t i = 0; i < n; i++) { + snap[i] = w->drain_ls[i]; + scr_closure_retain(snap[i].cb); + } + for (size_t i = 0; i < n; i++) { + if (snap[i].once) { + for (size_t j = 0; j < w->n_drain; j++) { + if (w->drain_ls[j].cb != snap[i].cb) continue; + scr_closure_release(w->drain_ls[j].cb); + memmove(w->drain_ls + j, w->drain_ls + j + 1, + (w->n_drain - j - 1) * sizeof *w->drain_ls); + w->n_drain--; + break; + } + } + if (!scr_exc_pending()) ((void (*)(ScrClosure *))snap[i].cb->fn)(snap[i].cb); + scr_closure_release(snap[i].cb); + } + free(snap); +} + +static void scr_child_writer_fire_finish(ScrChildWriter *w) { + w->finish_pending = false; + w->finished = true; + w->destroyed = true; + size_t n = w->n_finish; + ScrChildStreamEndL *snap = n ? malloc(n * sizeof *snap) : NULL; + if (n && !snap) scr_child_oom(); + for (size_t i = 0; i < n; i++) { + snap[i] = w->finish_ls[i]; + scr_closure_retain(snap[i].cb); + } + for (size_t i = 0; i < n; i++) { + if (!scr_exc_pending()) ((void (*)(ScrClosure *))snap[i].cb->fn)(snap[i].cb); + scr_closure_release(snap[i].cb); + } + free(snap); + scr_child_writer_drop_zero_listeners(w); +} + +static void scr_child_writer_fire_error(ScrChildWriter *w) { + w->error_pending = false; + if (w->n_write_err == 0) { + fflush(stdout); + fprintf(stderr, "Unhandled 'error' event: Error: %s\n", w->err_msg->data); + _Exit(1); + } + size_t n = w->n_write_err; + ScrChildErrEntry *snap = malloc(n * sizeof *snap); + if (!snap) scr_child_oom(); + for (size_t i = 0; i < n; i++) { + snap[i] = w->err_ls[i]; + scr_closure_retain(snap[i].cb); + } + scr_child_writer_err_code = w->err_code; + for (size_t i = 0; i < n; i++) { + if (!scr_exc_pending()) snap[i].fn(snap[i].cb, w->err_msg); + scr_closure_release(snap[i].cb); + } + scr_child_writer_err_code = NULL; + free(snap); + scr_child_writer_drop_listeners(w); +} + +static bool scr_child_writer_pending(const ScrChildWriter *w) { + return w->write_pending || scr_child_writer_queued(w) > 0 || + w->drain_pending || w->finish_pending || w->error_pending; +} + +static bool scr_child_writers_pending(void) { + for (ScrChildWriter *w = scr_child_writers; w; w = w->next) { + if (scr_child_writer_pending(w)) return true; + } + return false; +} + +static void scr_child_writers_service(void) { + ScrChildWriter **link = &scr_child_writers; + while (*link) { + ScrChildWriter *w = *link; + if (!w->destroyed && !w->finished) scr_child_writer_pump(w); + if (w->error_pending) scr_child_writer_fire_error(w); + if (!scr_exc_pending() && w->drain_pending) scr_child_writer_fire_drain(w); + if (!scr_exc_pending() && w->finish_pending) scr_child_writer_fire_finish(w); + bool done = (w->finished || w->destroyed) && !w->error_pending; + if (done) { + *link = w->next; + w->next = NULL; + scr_child_writer_release(w); + } else { + link = &w->next; + } + if (scr_exc_pending()) return; + } +} + +static void scr_child_writer_child_settled(ScrChildWriter *w) { + if (!w || w->finished || w->finish_pending || w->destroyed) return; + if (scr_child_writer_queued(w) > 0 || w->write_pending || w->ending) { + scr_child_writer_fail(w, "write EPIPE", "EPIPE"); + } else { + scr_child_writer_destroy(w); + } +} + +ScrChildWriter *scr_child_stdin(ScrChild *c) { + return c->in_stream ? scr_child_writer_retain(c->in_stream) : NULL; +} + /* ── spawn ───────────────────────────────────────────────────────────── */ ScrChild *scr_spawn(ScrStr *cmd, ScrArr *args) { @@ -1366,7 +1851,8 @@ ScrChild *scr_spawn(ScrStr *cmd, ScrArr *args) { } /* PER-SLOT stdio modes (the POSIX arm's numbering): in 0 = ignore (NUL), - * 1 = inherit; out/err 0 = ignore, 1 = inherit, 2 = fd, 3 = pipe. */ + * 1 = inherit, 3 = pipe; out/err 0 = ignore, 1 = inherit, 2 = fd, + * 3 = pipe. */ ScrChild *scr_spawn_opts(ScrStr *cmd, ScrArr *args, double in_mode, double out_mode, double err_mode, double out_fd, double err_fd, bool detached, bool has_env, @@ -1381,16 +1867,18 @@ ScrChild *scr_spawn_opts(ScrStr *cmd, ScrArr *args, double in_mode, /* Piped slots: the pipe NOW; a pipe failure degrades the slot to NUL * and the stream husk answers eof (fd exhaustion — nothing real). */ + HANDLE in_parent = NULL, in_child = NULL; HANDLE out_parent = NULL, out_child = NULL; HANDLE err_parent = NULL, err_child = NULL; - int out_m = (int)out_mode, err_m = (int)err_mode; + int in_m = (int)in_mode, out_m = (int)out_mode, err_m = (int)err_mode; + if (in_m == 3 && !scr_win_stdin_pipe(&in_parent, &in_child)) in_m = 0; if (out_m == 3 && !scr_win_pipe(&out_parent, &out_child, true)) out_m = 0; if (err_m == 3 && !scr_win_pipe(&err_parent, &err_child, true)) err_m = 0; - HANDLE in_child = (int)in_mode == 1 - ? scr_win_dup_inherit(GetStdHandle(STD_INPUT_HANDLE)) - : NULL; - if (in_child == NULL) in_child = scr_win_nul(false); + if (in_child == NULL) { + if (in_m == 1) in_child = scr_win_dup_inherit(GetStdHandle(STD_INPUT_HANDLE)); + if (in_child == NULL) in_child = scr_win_nul(false); + } if (out_child == NULL) { if (out_m == 1) out_child = scr_win_dup_inherit(GetStdHandle(STD_OUTPUT_HANDLE)); else if (out_m == 2) out_child = scr_win_dup_inherit((HANDLE)_get_osfhandle((int)out_fd)); @@ -1415,10 +1903,9 @@ ScrChild *scr_spawn_opts(ScrStr *cmd, ScrArr *args, double in_mode, if (out_child != NULL) CloseHandle(out_child); if (err_child != NULL) CloseHandle(err_child); - /* The parent's pipe read ends become the streams regardless of the - * spawn's outcome — a FAILED spawn keeps them with no writer at all, - * so a consumer sees immediate EOF and 'end' fires on the turn after - * the 'error' event, Node's exact order (the POSIX arm's stance). */ + /* Parent keeps only its side of each pipe. A failed spawn still exposes + * the stream husks until the deferred child error settles them. */ + if (in_m == 3) c->in_stream = scr_child_writer_new(in_parent); if (out_m == 3) c->out_stream = scr_child_stream_new(out_parent); if (err_m == 3) c->err_stream = scr_child_stream_new(err_parent); @@ -1513,7 +2000,8 @@ void scr_child_unref(ScrChild *c) { } bool scr_children_reffed_pending(void) { - return scr_children_reffed_n > 0 || scr_child_streams_watching > 0; + return scr_children_reffed_n > 0 || scr_child_streams_watching > 0 || + scr_child_writers_pending(); } void scr_children_teardown(void) { @@ -1524,6 +2012,13 @@ void scr_children_teardown(void) { scr_child_stream_finish(s, false); scr_child_stream_release(s); } + while (scr_child_writers != NULL) { + ScrChildWriter *w = scr_child_writers; + scr_child_writers = w->next; + w->next = NULL; + scr_child_writer_destroy(w); + scr_child_writer_release(w); + } while (scr_children != NULL) { ScrChild *c = scr_children; scr_children = c->next; @@ -1610,6 +2105,8 @@ void scr_child_err_thunk_error(ScrClosure *cb, ScrStr *msg) { ScrError *e = scr_error_new(0 /* Error */, msg); if (scr_child_err_code != NULL) { scr_error_set_code(e, scr_child_err_code); + } else if (scr_child_writer_err_code != NULL) { + scr_error_set_code(e, scr_child_writer_err_code); } else { const char *code = scr_err_msg_code(msg); if (code != NULL) scr_error_set_code(e, code); @@ -1620,7 +2117,8 @@ void scr_child_err_thunk_error(ScrClosure *cb, ScrStr *msg) { /* ── the loop's half (called from scr_async.c) ───────────────────────── */ bool scr_children_pending(void) { - return scr_children != NULL || scr_child_streams_watching > 0; + return scr_children != NULL || scr_child_streams_watching > 0 || + scr_child_writers_pending(); } bool scr_children_failed_pending(void) { @@ -1642,7 +2140,8 @@ int scr_children_wake_fd(void) { return -1; } * stream needs pumping (pipe readability is poll-only), or the handle * table outgrows WaitForMultipleObjects. */ bool scr_children_wait(double max_wait_ms) { - if (scr_children_unwatched > 0 || scr_child_streams_watching > 0) return false; + if (scr_children_unwatched > 0 || scr_child_streams_watching > 0 || + scr_child_writers_pending()) return false; HANDLE hs[MAXIMUM_WAIT_OBJECTS]; DWORD n = 0; for (ScrChild *c = scr_children; c; c = c->next) { @@ -1669,6 +2168,10 @@ static void scr_child_settle(ScrChild *c) { scr_child_stream_drain_now(c->out_stream); if (!scr_exc_pending()) scr_child_stream_drain_now(c->err_stream); } + if (!scr_exc_pending()) { + scr_child_writer_child_settled(c->in_stream); + scr_child_writers_service(); + } c->settled = true; if (scr_exc_pending()) { scr_child_drop_listeners(c); @@ -1701,6 +2204,8 @@ static void scr_child_settle(ScrChild *c) { * ordering), then every running child answers WaitForSingleObject(h, 0) * — the WNOHANG analogue; spawn failures settle on their first pass. */ void scr_children_poll(void) { + scr_child_writers_service(); + if (scr_exc_pending()) return; scr_child_streams_service(); if (scr_exc_pending()) return; ScrChild **link = &scr_children; @@ -1751,7 +2256,7 @@ void scr_children_poll(void) { } } -#else /* !_WIN32 — the POSIX implementation, untouched below */ +#else /* !_WIN32 — the POSIX implementation */ #include #include @@ -2597,10 +3102,38 @@ struct ScrChildStream { struct ScrChildStream *next; /* the service registry (+1) */ }; +struct ScrChildWriter { + size_t rc; + int fd; /* the pipe's nonblocking write end; -1 once settled */ + bool writable; + bool ending; + bool finished; + bool destroyed; + bool need_drain; + bool drain_pending; + bool finish_pending; + bool error_pending; + uint8_t *buf; + size_t off, len, cap; + ScrStr *err_msg; + const char *err_code; /* static storage */ + ScrChildStreamEndL *drain_ls; + size_t n_drain, cap_drain; + ScrChildStreamEndL *finish_ls; + size_t n_finish, cap_finish; + ScrChildErrEntry *err_ls; + size_t n_write_err, cap_write_err; + struct ScrChildWriter *next; /* the service registry (+1) */ +}; + static ScrChildStream *scr_child_streams = NULL; /* Streams with a live consumer (data listener, not yet EOF): the loop's * keep-alive and service predicate. */ static size_t scr_child_streams_watching = 0; +static ScrChildWriter *scr_child_writers = NULL; +static bool scr_child_writers_pending(void); +static void scr_child_writers_service(void); +static void scr_child_writer_child_settled(ScrChildWriter *w); struct ScrChild { size_t rc; @@ -2625,6 +3158,7 @@ struct ScrChild { * child.stderr answer — owned (+1 each), NULL when not piped. */ ScrChildStream *out_stream; ScrChildStream *err_stream; + ScrChildWriter *in_stream; struct ScrChild *next; /* the pending registry */ }; @@ -2735,6 +3269,7 @@ static void scr_child_unwatch(pid_t pid) { (void)pid; } * can wake for EVERY pending child — same condition as scr_children_wait. * -1 tells the caller to keep the ~1ms reap cap instead. */ int scr_children_wake_fd(void) { + if (scr_child_writers_pending()) return -1; #ifdef SCR_HAVE_KQUEUE if (scr_child_kq < 0 || scr_children_unwatched > 0) return -1; return scr_child_kq; @@ -2747,6 +3282,7 @@ int scr_children_wake_fd(void) { } bool scr_children_wait(double max_wait_ms) { + if (scr_child_writers_pending()) return false; #ifdef SCR_HAVE_KQUEUE if (scr_child_kq < 0 || scr_children_unwatched > 0) return false; if (!(max_wait_ms > 0)) max_wait_ms = 0; @@ -2793,6 +3329,7 @@ void scr_child_release(ScrChild *c) { scr_str_release(c->err_msg); scr_child_stream_release(c->out_stream); scr_child_stream_release(c->err_stream); + scr_child_writer_release(c->in_stream); free(c); } } @@ -3119,6 +3656,404 @@ void scr_child_stream_thunk_bytes(ScrClosure *cb, ScrBytes *chunk) { ((void (*)(ScrClosure *, ScrBytes *))cb->fn)(cb, scr_bytes_retain(chunk)); } +/* ── the piped-input writer ─────────────────────────────────────────── */ + +#define SCR_CHILD_WRITER_HWM 65536 + +static const char *scr_child_writer_err_code = NULL; + +static void scr_child_writer_drop_zero_listeners(ScrChildWriter *w) { + for (size_t i = 0; i < w->n_drain; i++) scr_closure_release(w->drain_ls[i].cb); + for (size_t i = 0; i < w->n_finish; i++) scr_closure_release(w->finish_ls[i].cb); + free(w->drain_ls); + free(w->finish_ls); + w->drain_ls = NULL; + w->finish_ls = NULL; + w->n_drain = w->cap_drain = 0; + w->n_finish = w->cap_finish = 0; +} + +static void scr_child_writer_drop_listeners(ScrChildWriter *w) { + scr_child_writer_drop_zero_listeners(w); + for (size_t i = 0; i < w->n_write_err; i++) scr_closure_release(w->err_ls[i].cb); + free(w->err_ls); + w->err_ls = NULL; + w->n_write_err = w->cap_write_err = 0; +} + +ScrChildWriter *scr_child_writer_retain(ScrChildWriter *w) { + if (w->rc != SIZE_MAX) w->rc++; + return w; +} + +void scr_child_writer_release(ScrChildWriter *w) { + if (!w || w->rc == SIZE_MAX) return; + if (--w->rc == 0) { + if (w->fd >= 0) close(w->fd); + free(w->buf); + scr_str_release(w->err_msg); + scr_child_writer_drop_listeners(w); + free(w); + } +} + +void *scr_child_writer_retain_v(void *p) { + return scr_child_writer_retain((ScrChildWriter *)p); +} +void scr_child_writer_release_v(void *p) { + scr_child_writer_release((ScrChildWriter *)p); +} + +static ScrChildWriter *scr_child_writer_new(int fd) { + ScrChildWriter *w = calloc(1, sizeof *w); + if (!w) scr_child_oom(); + w->rc = 1; + w->fd = fd; + w->writable = fd >= 0; + w->destroyed = fd < 0; + if (fd >= 0) { + fcntl(fd, F_SETFL, O_NONBLOCK); + fcntl(fd, F_SETFD, FD_CLOEXEC); + w->next = scr_child_writers; + scr_child_writers = scr_child_writer_retain(w); + } + return w; +} + +static void scr_child_writer_close(ScrChildWriter *w) { + if (w->fd >= 0) { + close(w->fd); + w->fd = -1; + } + w->writable = false; +} + +static void scr_child_writer_clear_queue(ScrChildWriter *w) { + free(w->buf); + w->buf = NULL; + w->off = w->len = w->cap = 0; +} + +static size_t scr_child_writer_queued(const ScrChildWriter *w) { + return w->len - w->off; +} + +static void scr_child_writer_append(ScrChildWriter *w, const void *data, size_t len) { + if (len == 0) return; + size_t queued = scr_child_writer_queued(w); + if (w->off > 0 && w->cap - w->len < len) { + memmove(w->buf, w->buf + w->off, queued); + w->off = 0; + w->len = queued; + } + if (w->cap - w->len < len) { + size_t cap = w->cap ? w->cap : 16384; + while (cap - w->len < len) { + if (cap > SIZE_MAX / 2) scr_child_oom(); + cap *= 2; + } + uint8_t *grown = realloc(w->buf, cap); + if (!grown) scr_child_oom(); + w->buf = grown; + w->cap = cap; + } + memcpy(w->buf + w->len, data, len); + w->len += len; +} + +/* A pipe write must return EPIPE to the stream instead of terminating the + * process with SIGPIPE. Block it only around this call and consume only a + * signal generated by this write, preserving an already-pending signal. */ +static ssize_t scr_child_writer_write_once(int fd, const void *data, size_t len) { +#ifdef SIGPIPE + sigset_t set, old, pending; + sigemptyset(&set); + sigaddset(&set, SIGPIPE); + if (pthread_sigmask(SIG_BLOCK, &set, &old) == 0) { + bool known = sigpending(&pending) == 0; + bool had = known && sigismember(&pending, SIGPIPE) == 1; + ssize_t n = write(fd, data, len); + int write_errno = errno; + if (n < 0 && write_errno == EPIPE && known && !had && + sigpending(&pending) == 0 && sigismember(&pending, SIGPIPE) == 1) { + int caught = 0; + int wait_err; + do { + wait_err = sigwait(&set, &caught); + } while (wait_err == EINTR); + } + (void)pthread_sigmask(SIG_SETMASK, &old, NULL); + errno = write_errno; + return n; + } +#endif + return write(fd, data, len); +} + +static void scr_child_writer_fail(ScrChildWriter *w, const char *message, + const char *code) { + if (w->error_pending || (w->err_msg != NULL && w->destroyed)) return; + scr_child_writer_close(w); + scr_child_writer_clear_queue(w); + w->ending = false; + w->destroyed = true; + w->need_drain = false; + w->drain_pending = false; + w->finish_pending = false; + scr_str_release(w->err_msg); + w->err_msg = scr_str_new(message, strlen(message)); + w->err_code = code; + w->error_pending = true; +} + +static void scr_child_writer_complete(ScrChildWriter *w) { + scr_child_writer_close(w); + w->ending = false; + w->finish_pending = true; +} + +static void scr_child_writer_pump(ScrChildWriter *w) { + while (w->fd >= 0 && scr_child_writer_queued(w) > 0) { + ssize_t n = scr_child_writer_write_once( + w->fd, w->buf + w->off, scr_child_writer_queued(w)); + if (n > 0) { + w->off += (size_t)n; + continue; + } + if (n < 0 && errno == EINTR) continue; + if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) return; + if (n < 0 && errno == EPIPE) { + scr_child_writer_fail(w, "write EPIPE", "EPIPE"); + } else { + scr_child_writer_fail(w, "write EIO", "EIO"); + } + return; + } + if (scr_child_writer_queued(w) == 0) { + w->off = w->len = 0; + if (w->need_drain) { + w->need_drain = false; + w->drain_pending = true; + } + if (w->ending) scr_child_writer_complete(w); + } +} + +static bool scr_child_writer_write(ScrChildWriter *w, const void *data, + size_t len) { + if (!w->writable) { + scr_child_writer_fail(w, "write after end", "ERR_STREAM_WRITE_AFTER_END"); + return false; + } + scr_child_writer_append(w, data, len); +#ifdef __APPLE__ + /* libuv does not partially try-write an oversized Darwin pipe chunk, + * so Node decides backpressure from the queued chunk before submitting + * it. The kernel write below is more eager; preserve Node's decision. */ + bool ok = scr_child_writer_queued(w) <= SCR_CHILD_WRITER_HWM; + if (!ok) w->need_drain = true; +#endif + scr_child_writer_pump(w); + if (w->error_pending) return false; +#ifndef __APPLE__ + bool ok = scr_child_writer_queued(w) < SCR_CHILD_WRITER_HWM; + if (!ok) w->need_drain = true; +#endif + return ok; +} + +bool scr_child_writer_write_string(ScrChildWriter *w, const ScrStr *data) { + return scr_child_writer_write(w, data->data, data->len); +} + +bool scr_child_writer_write_bytes(ScrChildWriter *w, const ScrBytes *data) { + return scr_child_writer_write(w, data->data, data->len); +} + +void scr_child_writer_end(ScrChildWriter *w) { + if (w->ending || w->finish_pending || w->finished || w->destroyed) return; + w->writable = false; + w->ending = true; + scr_child_writer_pump(w); +} + +void scr_child_writer_destroy(ScrChildWriter *w) { + if (w->finished || (w->destroyed && !w->error_pending)) return; + scr_child_writer_close(w); + scr_child_writer_clear_queue(w); + w->ending = false; + w->destroyed = true; + w->need_drain = false; + w->drain_pending = false; + w->finish_pending = false; + w->error_pending = false; + scr_str_release(w->err_msg); + w->err_msg = NULL; + scr_child_writer_drop_listeners(w); +} + +bool scr_child_writer_writable(ScrChildWriter *w) { return w->writable; } + +static void scr_child_writer_add_zero(ScrChildStreamEndL **ls, size_t *n, + size_t *cap, ScrClosure *cb, bool once) { + if (*n == *cap) { + *cap = *cap ? *cap * 2 : 2; + *ls = realloc(*ls, *cap * sizeof **ls); + if (!*ls) scr_child_oom(); + } + (*ls)[*n] = (ScrChildStreamEndL){cb, once}; + (*n)++; +} + +void scr_child_writer_on_drain(ScrChildWriter *w, ScrClosure *cb /*moves*/, + bool once) { + if (w->finished || (w->destroyed && !w->drain_pending)) { + scr_closure_release(cb); + return; + } + scr_child_writer_add_zero(&w->drain_ls, &w->n_drain, &w->cap_drain, cb, once); +} + +void scr_child_writer_on_finish(ScrChildWriter *w, ScrClosure *cb /*moves*/, + bool once) { + if (w->finished || (w->destroyed && !w->finish_pending)) { + scr_closure_release(cb); + return; + } + scr_child_writer_add_zero(&w->finish_ls, &w->n_finish, &w->cap_finish, cb, once); +} + +void scr_child_writer_on_error(ScrChildWriter *w, ScrClosure *cb /*moves*/, + ScrChildErrFn fn, bool once) { + (void)once; /* a writer emits at most one terminal error */ + if ((w->destroyed && !w->finished && !w->error_pending) || + (w->err_msg != NULL && !w->error_pending)) { + scr_closure_release(cb); + return; + } + if (w->n_write_err == w->cap_write_err) { + w->cap_write_err = w->cap_write_err ? w->cap_write_err * 2 : 2; + w->err_ls = realloc(w->err_ls, w->cap_write_err * sizeof *w->err_ls); + if (!w->err_ls) scr_child_oom(); + } + w->err_ls[w->n_write_err++] = (ScrChildErrEntry){cb, fn}; +} + +static void scr_child_writer_fire_drain(ScrChildWriter *w) { + w->drain_pending = false; + size_t n = w->n_drain; + if (n == 0) return; + ScrChildStreamEndL *snap = malloc(n * sizeof *snap); + if (!snap) scr_child_oom(); + for (size_t i = 0; i < n; i++) { + snap[i] = w->drain_ls[i]; + scr_closure_retain(snap[i].cb); + } + for (size_t i = 0; i < n; i++) { + if (snap[i].once) { + for (size_t j = 0; j < w->n_drain; j++) { + if (w->drain_ls[j].cb != snap[i].cb) continue; + scr_closure_release(w->drain_ls[j].cb); + memmove(w->drain_ls + j, w->drain_ls + j + 1, + (w->n_drain - j - 1) * sizeof *w->drain_ls); + w->n_drain--; + break; + } + } + if (!scr_exc_pending()) ((void (*)(ScrClosure *))snap[i].cb->fn)(snap[i].cb); + scr_closure_release(snap[i].cb); + } + free(snap); +} + +static void scr_child_writer_fire_finish(ScrChildWriter *w) { + w->finish_pending = false; + w->finished = true; + w->destroyed = true; + size_t n = w->n_finish; + ScrChildStreamEndL *snap = n ? malloc(n * sizeof *snap) : NULL; + if (n && !snap) scr_child_oom(); + for (size_t i = 0; i < n; i++) { + snap[i] = w->finish_ls[i]; + scr_closure_retain(snap[i].cb); + } + for (size_t i = 0; i < n; i++) { + if (!scr_exc_pending()) ((void (*)(ScrClosure *))snap[i].cb->fn)(snap[i].cb); + scr_closure_release(snap[i].cb); + } + free(snap); + scr_child_writer_drop_zero_listeners(w); +} + +static void scr_child_writer_fire_error(ScrChildWriter *w) { + w->error_pending = false; + if (w->n_write_err == 0) { + fflush(stdout); + fprintf(stderr, "Unhandled 'error' event: Error: %s\n", w->err_msg->data); + _Exit(1); + } + size_t n = w->n_write_err; + ScrChildErrEntry *snap = malloc(n * sizeof *snap); + if (!snap) scr_child_oom(); + for (size_t i = 0; i < n; i++) { + snap[i] = w->err_ls[i]; + scr_closure_retain(snap[i].cb); + } + scr_child_writer_err_code = w->err_code; + for (size_t i = 0; i < n; i++) { + if (!scr_exc_pending()) snap[i].fn(snap[i].cb, w->err_msg); + scr_closure_release(snap[i].cb); + } + scr_child_writer_err_code = NULL; + free(snap); + scr_child_writer_drop_listeners(w); +} + +static bool scr_child_writer_pending(const ScrChildWriter *w) { + return scr_child_writer_queued(w) > 0 || w->drain_pending || + w->finish_pending || w->error_pending; +} + +static bool scr_child_writers_pending(void) { + for (ScrChildWriter *w = scr_child_writers; w; w = w->next) { + if (scr_child_writer_pending(w)) return true; + } + return false; +} + +static void scr_child_writers_service(void) { + ScrChildWriter **link = &scr_child_writers; + while (*link) { + ScrChildWriter *w = *link; + if (!w->destroyed && !w->finished) scr_child_writer_pump(w); + if (w->error_pending) scr_child_writer_fire_error(w); + if (!scr_exc_pending() && w->drain_pending) scr_child_writer_fire_drain(w); + if (!scr_exc_pending() && w->finish_pending) scr_child_writer_fire_finish(w); + bool done = (w->finished || w->destroyed) && !w->error_pending; + if (done) { + *link = w->next; + w->next = NULL; + scr_child_writer_release(w); /* the registry's reference */ + } else { + link = &w->next; + } + if (scr_exc_pending()) return; + } +} + +static void scr_child_writer_child_settled(ScrChildWriter *w) { + if (!w || w->finished || w->finish_pending || w->destroyed) return; + if (scr_child_writer_queued(w) > 0 || w->ending) { + scr_child_writer_fail(w, "write EPIPE", "EPIPE"); + } else { + scr_child_writer_destroy(w); + } +} + +ScrChildWriter *scr_child_stdin(ScrChild *c) { + return c->in_stream ? scr_child_writer_retain(c->in_stream) : NULL; +} + ScrChild *scr_spawn(ScrStr *cmd, ScrArr *args) { return scr_spawn_opts(cmd, args, 0, 0, 0, 0, 0, false, false, NULL, NULL); } @@ -3126,9 +4061,9 @@ ScrChild *scr_spawn(ScrStr *cmd, ScrArr *args) { /* The options core behind cp.spawn/cp.spawnOpts. PER-SLOT stdio modes: * 0 = ignore (/dev/null), 1 = inherit (the parent's fd), 2 = fd (out/err * only — out_fd/err_fd dup2 into the child's slot, Node's stdio fd form: - * the daemon-log idiom ["ignore", logFd, logFd]), 3 = pipe (out/err only - * — a fresh pipe whose read end becomes the child.stdout/child.stderr - * stream; see the stream slice's design note). detached gives the + * the daemon-log idiom ["ignore", logFd, logFd]), 3 = pipe (fd 0's write + * end becomes child.stdin; fd 1/2 read ends become child.stdout/stderr). + * detached gives the * child its own session (POSIX_SPAWN_SETSID → setsid(2), the libuv * implementation of Node's flag). env_pairs, when has_env, REPLACES the * child environment ([k,v,...] like the exec core's); cwd (NULL/"" = @@ -3154,8 +4089,13 @@ ScrChild *scr_spawn_opts(ScrStr *cmd, ScrArr *args, double in_mode, * clears the flag on the child's copy — every other descriptor closes * at exec). A pipe failure degrades the slot to /dev/null — nothing * real hits it (fd exhaustion), and the stream husk answers eof. */ + int in_pipe[2] = {-1, -1}; int out_pipe[2] = {-1, -1}; int err_pipe[2] = {-1, -1}; + if ((int)in_mode == 3 && pipe(in_pipe) != 0) { + in_pipe[0] = in_pipe[1] = -1; + in_mode = 0; + } if ((int)out_mode == 3 && pipe(out_pipe) != 0) { out_pipe[0] = out_pipe[1] = -1; out_mode = 0; @@ -3165,6 +4105,7 @@ ScrChild *scr_spawn_opts(ScrStr *cmd, ScrArr *args, double in_mode, err_mode = 0; } for (int i = 0; i < 2; i++) { + if (in_pipe[i] >= 0) fcntl(in_pipe[i], F_SETFD, FD_CLOEXEC); if (out_pipe[i] >= 0) fcntl(out_pipe[i], F_SETFD, FD_CLOEXEC); if (err_pipe[i] >= 0) fcntl(err_pipe[i], F_SETFD, FD_CLOEXEC); } @@ -3173,6 +4114,8 @@ ScrChild *scr_spawn_opts(ScrStr *cmd, ScrArr *args, double in_mode, posix_spawn_file_actions_init(&fa); if ((int)in_mode == 0) { posix_spawn_file_actions_addopen(&fa, 0, "/dev/null", O_RDONLY, 0); + } else if ((int)in_mode == 3) { + posix_spawn_file_actions_adddup2(&fa, in_pipe[0], 0); } /* 1 = inherit: no redirection */ if ((int)out_mode == 0) { posix_spawn_file_actions_addopen(&fa, 1, "/dev/null", O_WRONLY, 0); @@ -3210,14 +4153,12 @@ ScrChild *scr_spawn_opts(ScrStr *cmd, ScrArr *args, double in_mode, free(argv); scr_exec_envp_free(envp); - /* The parent's copies of the write ends close regardless of outcome — - * the child (when it spawned) holds the only writer, so EOF arrives - * exactly when it exits (or its last inheritor does). A FAILED spawn - * keeps its streams too, with no writer at all: a consumer sees - * immediate EOF, so 'end' fires on the turn after the 'error' event — - * Node's exact order, pinned by corpus. */ + /* Parent keeps only its side of each pipe. A failed spawn still exposes + * the stream husks until the deferred child error settles them. */ + if (in_pipe[0] >= 0) close(in_pipe[0]); if (out_pipe[1] >= 0) close(out_pipe[1]); if (err_pipe[1] >= 0) close(err_pipe[1]); + if ((int)in_mode == 3) c->in_stream = scr_child_writer_new(in_pipe[1]); if ((int)out_mode == 3) c->out_stream = scr_child_stream_new(out_pipe[0]); if ((int)err_mode == 3) c->err_stream = scr_child_stream_new(err_pipe[0]); @@ -3329,7 +4270,8 @@ void scr_child_unref(ScrChild *c) { * it may outlive the child's own settle when a grandchild holds the * write end). */ bool scr_children_reffed_pending(void) { - return scr_children_reffed_n > 0 || scr_child_streams_watching > 0; + return scr_children_reffed_n > 0 || scr_child_streams_watching > 0 || + scr_child_writers_pending(); } /* Loop-exhaustion teardown (the scr_timers_teardown twin): the loop may @@ -3347,6 +4289,13 @@ void scr_children_teardown(void) { scr_child_stream_finish(s, false); scr_child_stream_release(s); } + while (scr_child_writers != NULL) { + ScrChildWriter *w = scr_child_writers; + scr_child_writers = w->next; + w->next = NULL; + scr_child_writer_destroy(w); + scr_child_writer_release(w); + } while (scr_children != NULL) { ScrChild *c = scr_children; scr_children = c->next; @@ -3458,6 +4407,8 @@ void scr_child_err_thunk_error(ScrClosure *cb, ScrStr *msg) { ScrError *e = scr_error_new(0 /* Error */, msg); if (scr_child_err_code != NULL) { scr_error_set_code(e, scr_child_err_code); + } else if (scr_child_writer_err_code != NULL) { + scr_error_set_code(e, scr_child_writer_err_code); } else { const char *code = scr_err_msg_code(msg); if (code != NULL) scr_error_set_code(e, code); @@ -3468,7 +4419,8 @@ void scr_child_err_thunk_error(ScrClosure *cb, ScrStr *msg) { /* ── the loop's half (called from scr_async.c) ───────────────────────── */ bool scr_children_pending(void) { - return scr_children != NULL || scr_child_streams_watching > 0; + return scr_children != NULL || scr_child_streams_watching > 0 || + scr_child_writers_pending(); } /* True while an UNSETTLED spawn failure sits in the registry: its 'error' @@ -3504,6 +4456,10 @@ static void scr_child_settle(ScrChild *c) { scr_child_stream_drain_now(c->out_stream); if (!scr_exc_pending()) scr_child_stream_drain_now(c->err_stream); } + if (!scr_exc_pending()) { + scr_child_writer_child_settled(c->in_stream); + scr_child_writers_service(); + } c->settled = true; if (scr_exc_pending()) { /* A data/end listener threw: the loop surfaces it (the exit event is @@ -3548,6 +4504,8 @@ static void scr_child_settle(ScrChild *c) { * callbacks — a throw stops the pass and leaves the exception pending * for the loop. */ void scr_children_poll(void) { + scr_child_writers_service(); + if (scr_exc_pending()) return; scr_child_streams_service(); if (scr_exc_pending()) return; ScrChild **link = &scr_children; diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 8ba457e5..3a3b0ed5 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -2624,6 +2624,13 @@ typedef struct ScrChild ScrChild; * own 'exit' (Node's pinned ordering; the settle path drains first). A * flowing stream (data consumer, not yet EOF) keeps the loop alive. */ typedef struct ScrChildStream ScrChildStream; +/* A piped child-input writer (child.stdin — stdio mode 3 on fd 0). + * write copies borrowed string/bytes data into a nonblocking queue and + * returns false once buffered data reaches the stream high-water mark; + * drain fires when that queue empties. end closes after queued data and + * then fires finish; destroy closes immediately without finish. Errors + * are asynchronous and use the child error adapters. */ +typedef struct ScrChildWriter ScrChildWriter; /* The data adapter: the chunk arrives BORROWED (multiple listeners see * the same chunk); adapters retain what they keep. */ typedef void (*ScrChildStreamDataFn)(ScrClosure *cb, ScrBytes *chunk); @@ -2638,7 +2645,8 @@ ScrChild *scr_spawn(ScrStr *cmd, ScrArr *args); /* +1, never throws */ /* The options form (cp.spawnOpts): PER-SLOT stdio modes — 0 = ignore * (/dev/null), 1 = inherit, 2 = fd (out/err only: out_fd/err_fd dup2 * into the child's slot, the openSync daemon-log idiom), 3 = pipe - * (out/err only: the read end becomes the child.stdout/stderr stream); + * (stdin's write end becomes child.stdin; out/err read ends become the + * child.stdout/stderr streams); * detached = POSIX_SPAWN_SETSID (own session/process group); env * REPLACES the child environment when has_env ([k,v,...] pairs); cwd "" * = inherit. */ @@ -2673,6 +2681,20 @@ void scr_child_stream_on_data(ScrChildStream *s, ScrClosure *cb /*moves*/, void scr_child_stream_on_end(ScrChildStream *s, ScrClosure *cb /*moves*/, bool once); void scr_child_stream_thunk0(ScrClosure *cb, ScrBytes *chunk); void scr_child_stream_thunk_bytes(ScrClosure *cb, ScrBytes *chunk); +ScrChildWriter *scr_child_writer_retain(ScrChildWriter *w); +void scr_child_writer_release(ScrChildWriter *w); +void *scr_child_writer_retain_v(void *p); +void scr_child_writer_release_v(void *p); +ScrChildWriter *scr_child_stdin(ScrChild *c); /* +1, or NULL */ +bool scr_child_writer_write_string(ScrChildWriter *w, const ScrStr *data); +bool scr_child_writer_write_bytes(ScrChildWriter *w, const ScrBytes *data); +void scr_child_writer_end(ScrChildWriter *w); +void scr_child_writer_destroy(ScrChildWriter *w); +bool scr_child_writer_writable(ScrChildWriter *w); +void scr_child_writer_on_drain(ScrChildWriter *w, ScrClosure *cb /*moves*/, bool once); +void scr_child_writer_on_finish(ScrChildWriter *w, ScrClosure *cb /*moves*/, bool once); +void scr_child_writer_on_error(ScrChildWriter *w, ScrClosure *cb /*moves*/, + ScrChildErrFn fn, bool once); /* The lifecycle members, Node's exact shapes (pinned by the corpus): * pid is undefined (has_pid false) exactly on spawn failure; exitCode is * null while running / after a signal death, the code after a normal diff --git a/tests/corpus/2893-child-stdin-duplex.ts b/tests/corpus/2893-child-stdin-duplex.ts new file mode 100644 index 00000000..765da3c4 --- /dev/null +++ b/tests/corpus/2893-child-stdin-duplex.ts @@ -0,0 +1,19 @@ +import { spawn } from "node:child_process"; + +const child = spawn("node", ["-e", "process.stdin.on('data', b => process.stdout.write(b));"]); +const input = child.stdin; +const output = child.stdout; +const errors = child.stderr; +if (input === null || output === null || errors === null) throw new Error("missing pipe"); + +let echoed = ""; +output.on("data", (chunk) => { echoed += chunk.toString(); }); +output.on("end", () => { console.log("echo", JSON.stringify(echoed)); }); +errors.on("data", (chunk) => { process.stderr.write(chunk); }); +input.on("finish", () => { console.log("finish", input.writable); }); +input.on("error", (err) => { console.log("error", err.message); }); +console.log("initial", input.writable); +console.log("writes", input.write("hé"), input.write(new Uint8Array([108, 108, 111, 10]))); +input.end(); +console.log("ended", input.writable); +child.on("exit", (code) => { console.log("exit", code); }); diff --git a/tests/corpus/2894-child-stdin-backpressure.ts b/tests/corpus/2894-child-stdin-backpressure.ts new file mode 100644 index 00000000..f1ceab21 --- /dev/null +++ b/tests/corpus/2894-child-stdin-backpressure.ts @@ -0,0 +1,21 @@ +import { spawn } from "node:child_process"; + +const child = spawn("node", ["-e", "let n=0;setTimeout(()=>{process.stdin.on('data',b=>n+=b.length);process.stdin.on('end',()=>console.log(n));process.stdin.resume()},50)"], { + stdio: ["pipe", "pipe", "pipe"], +}); +const input = child.stdin; +const output = child.stdout; +if (input === null || output === null) throw new Error("missing pipe"); + +let count = ""; +output.on("data", (chunk) => { count += chunk.toString(); }); +output.on("end", () => { console.log("count", count.trim()); }); +input.on("drain", () => { + console.log("drain", input.writable); + input.end(); +}); +input.on("finish", () => { console.log("finish", input.writable); }); +input.on("error", (err) => { console.log("error", err.message); }); +console.log("boundary", input.write(Buffer.alloc(64 * 1024 + 1, 66))); +console.log("write", input.write(Buffer.alloc(1024 * 1024, 65))); +child.on("exit", (code) => { console.log("exit", code); }); diff --git a/tests/corpus/2895-child-stdin-write-after-end.ts b/tests/corpus/2895-child-stdin-write-after-end.ts new file mode 100644 index 00000000..8d23d0fa --- /dev/null +++ b/tests/corpus/2895-child-stdin-write-after-end.ts @@ -0,0 +1,11 @@ +import { spawn } from "node:child_process"; + +const child = spawn("node", ["-e", "process.stdin.resume()"], { stdio: ["pipe", "ignore", "ignore"] }); +const input = child.stdin; +if (input === null) throw new Error("missing pipe"); + +input.on("finish", () => { console.log("finish"); }); +input.on("error", (err) => { console.log("error", (err as NodeJS.ErrnoException).code, err.message, input.writable); }); +input.end(); +console.log("write", input.write("x"), input.writable); +child.on("exit", (code) => { console.log("exit", code); }); diff --git a/tests/corpus/2896-child-stdin-write-after-finish.ts b/tests/corpus/2896-child-stdin-write-after-finish.ts new file mode 100644 index 00000000..7216fa82 --- /dev/null +++ b/tests/corpus/2896-child-stdin-write-after-finish.ts @@ -0,0 +1,13 @@ +import { spawn } from "node:child_process"; + +const child = spawn("node", ["-e", "process.stdin.resume()"], { stdio: ["pipe", "ignore", "ignore"] }); +const input = child.stdin; +if (input === null) throw new Error("missing pipe"); + +input.on("error", (err) => { console.log("error", (err as NodeJS.ErrnoException).code, err.message, input.writable); }); +input.on("finish", () => { + console.log("finish", input.writable); + console.log("write", input.write("x"), input.writable); +}); +input.end(); +child.on("exit", (code) => { console.log("exit", code); }); diff --git a/tests/corpus/2897-child-stdin-destroy.ts b/tests/corpus/2897-child-stdin-destroy.ts new file mode 100644 index 00000000..47a37501 --- /dev/null +++ b/tests/corpus/2897-child-stdin-destroy.ts @@ -0,0 +1,12 @@ +import { spawn } from "node:child_process"; + +const child = spawn("node", ["-e", "setTimeout(()=>{},25)"], { stdio: ["pipe", "ignore", "ignore"] }); +const input = child.stdin; +if (input === null) throw new Error("missing pipe"); + +input.on("finish", () => { console.log("finish"); }); +input.on("error", (err) => { console.log("error", err.message); }); +console.log("before", input.writable); +input.destroy(); +console.log("after", input.writable); +child.on("exit", (code) => { console.log("exit", code); }); diff --git a/tests/corpus/2898-child-stdin-spawn-failure.ts b/tests/corpus/2898-child-stdin-spawn-failure.ts new file mode 100644 index 00000000..8f7f32c5 --- /dev/null +++ b/tests/corpus/2898-child-stdin-spawn-failure.ts @@ -0,0 +1,10 @@ +import { spawn } from "node:child_process"; + +const child = spawn("definitely-not-a-binary-child-stdin", []); +const input = child.stdin; +if (input === null) throw new Error("missing default stdin pipe"); + +console.log("initial", input.writable); +child.on("error", (err) => { + console.log("error", (err as NodeJS.ErrnoException).code, input.writable); +}); diff --git a/tests/diagnostics/child-process.ts b/tests/diagnostics/child-process.ts index 52b73b70..89e4b5b9 100644 --- a/tests/diagnostics/child-process.ts +++ b/tests/diagnostics/child-process.ts @@ -5,15 +5,18 @@ import { spawn } from "node:child_process"; -// Node's default stdio is "pipe" (streams — no lowering): omitting the -// options never silently loses the child's output. +// The default and explicit "pipe" forms use piped streams. +// Keep both adjacent to the remaining rejection cases. const noOpts = spawn("/bin/echo"); -// "pipe" and "inherit" typecheck against the declared union but have no -// lowering — each names its gap. +// "inherit" and "ignore" remain process-only forms without child streams. +// All four supported modes must coexist with the fences below. const piped = spawn("/bin/echo", [], { stdio: "pipe" }); const inherited = spawn("/bin/echo", [], { stdio: "inherit" }); +// A variable options value must fence instead of being dropped. +const options: { stdio: "ignore"; detached: boolean } = { stdio: "ignore", detached: true }; +spawn("true", [], options); const c = spawn("true", [], { stdio: "ignore" }); // `() => 5` IS assignable to a void-returning listener slot and now ADOPTS @@ -21,6 +24,6 @@ const c = spawn("true", [], { stdio: "ignore" }); // listener keeps its word and stays fenced — the registry calls listeners // as void. c.on("exit", (): number => 5); - // Methods have no bound-value form — call on directly. const f = c.on; +// Keep each fence on its own statement so diagnostics remain site-specific. diff --git a/tests/fixtures/node-types/child-stdin-fences.ts b/tests/fixtures/node-types/child-stdin-fences.ts new file mode 100644 index 00000000..eeabaac6 --- /dev/null +++ b/tests/fixtures/node-types/child-stdin-fences.ts @@ -0,0 +1,10 @@ +import { spawn } from "node:child_process"; + +const options = { stdio: "pipe" as const, detached: true }; +spawn("node", options); +spawn("node", [], options); + +const child = spawn("node", []); +child.stdin.write("x", "utf8"); +child.stdin.end("x"); +child.stdin.destroy(new Error("stop")); diff --git a/tests/fixtures/node-types/child-stdin.ts b/tests/fixtures/node-types/child-stdin.ts new file mode 100644 index 00000000..85e75e61 --- /dev/null +++ b/tests/fixtures/node-types/child-stdin.ts @@ -0,0 +1,8 @@ +import { spawn } from "node:child_process"; + +const child = spawn("node", ["-e", "process.stdin.pipe(process.stdout)"]); +let text = ""; +child.stdout.on("data", (chunk: Buffer) => { text += chunk.toString(); }); +child.stdout.on("end", () => { console.log(text); }); +child.stdin.write("typed child stdin"); +child.stdin.end(); diff --git a/tests/harness/__snapshots__/child-process.ts.txt b/tests/harness/__snapshots__/child-process.ts.txt index f22be074..44b928ca 100644 --- a/tests/harness/__snapshots__/child-process.ts.txt +++ b/tests/harness/__snapshots__/child-process.ts.txt @@ -1,40 +1,31 @@ -child-process.ts:10:16 - error SC2020: 'spawn without { stdio: "ignore" }' is part of the standard library types but has no scriptc lowering yet +child-process.ts:19:19 - error SC2020: 'spawn with a non-literal options argument' is part of the standard library types but has no scriptc lowering yet - 9 | // options never silently loses the child's output. - 10 | const noOpts = spawn("/bin/echo"); - | ^~~~~~~~~~~~~~~~~~ - 11 | + 18 | const options: { stdio: "ignore"; detached: boolean } = { stdio: "ignore", detached: true }; + 19 | spawn("true", [], options); + | ^~~~~~~ + 20 | const c = spawn("true", [], { stdio: "ignore" }); - hint: Node's default stdio is "pipe" (streams, no lowering) — pass { stdio: "ignore" } or { stdio: "inherit" } explicitly, or capture with spawnSync + hint: pass stdio, detached, env, cwd, and windowsHide in an inline object literal -child-process.ts:14:47 - error SC2020: 'spawn with stdio: "pipe"' is part of the standard library types but has no scriptc lowering yet +child-process.ts:26:14 - error SC1090: listeners returning a value (make the callback body a block, or return nothing) is not supported yet - 13 | // lowering — each names its gap. - 14 | const piped = spawn("/bin/echo", [], { stdio: "pipe" }); - | ^~~~~~ - 15 | const inherited = spawn("/bin/echo", [], { stdio: "inherit" }); - - hint: piped STDIN has no lowering — pipe stdout/stderr with the tuple form (stdio: ["ignore", "pipe", "pipe"]), or capture with spawnSync - -child-process.ts:23:14 - error SC1090: listeners returning a value (make the callback body a block, or return nothing) is not supported yet - - 22 | // as void. - 23 | c.on("exit", (): number => 5); + 25 | // as void. + 26 | c.on("exit", (): number => 5); | ^~~~~~~~~~~~~~~ - 24 | + 27 | // Methods have no bound-value form — call on directly. -child-process.ts:26:7 - error SC2007: values of type '{ (event: "exit", listener: (code: number | null, signal: string | null) => void): void; (event: "error", listener: (err: Error) => void): void; }' cannot be compiled: the type declares multiple call signatures (overloads), and a compiled function value is always one concrete signature +child-process.ts:28:7 - error SC2007: values of type '{ (event: "exit", listener: (code: number | null, signal: string | null) => void): void; (event: "error", listener: (err: Error) => void): void; }' cannot be compiled: the type declares multiple call signatures (overloads), and a compiled function value is always one concrete signature - 25 | // Methods have no bound-value form — call on directly. - 26 | const f = c.on; + 27 | // Methods have no bound-value form — call on directly. + 28 | const f = c.on; | ^ - 27 | + 29 | // Keep each fence on its own statement so diagnostics remain site-specific. hint: annotate the slot with the ONE signature the program actually calls (e.g. '(x: number) => string'), or wrap the overloaded function in a single-signature arrow -child-process.ts:26:11 - error SC1090: child methods as values (call 'on' directly) are not supported yet +child-process.ts:28:11 - error SC1090: child methods as values (call 'on' directly) are not supported yet - 25 | // Methods have no bound-value form — call on directly. - 26 | const f = c.on; + 27 | // Methods have no bound-value form — call on directly. + 28 | const f = c.on; | ^~~~ - 27 | \ No newline at end of file + 29 | // Keep each fence on its own statement so diagnostics remain site-specific. \ No newline at end of file diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index 921b57b3..ea82854b 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -102,6 +102,40 @@ test("node-types: captured NodeJS.WritableStream values write through the procSt expect(stderr).toBe("[err] line\n"); }); +test("node-types: refined spawn returns expose writable child stdin", async () => { + const dir = outDirFor("node-child-stdin"); + const entry = join(nodeTypesDir, "child-stdin.ts"); + const result = await compile(entry, { + outPath: join(dir, "child-stdin"), + outDir: dir, + sanitize, + }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const { stdout } = await execFileAsync(result.binaryPath); + expect(stdout).toBe("typed child stdin\n"); +}); + +test("node-types: unsupported child stdin and spawn options forms remain named fences", async () => { + const dir = outDirFor("node-child-stdin-fences"); + const entry = join(nodeTypesDir, "child-stdin-fences.ts"); + const result = await compile(entry, { + outPath: join(dir, "child-stdin-fences"), + outDir: dir, + sanitize, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toEqual(["SC2020", "SC2020", "SC2020", "SC2020", "SC2020"]); + expect(result.diagnostics.map((diagnostic) => diagnostic.message)).toEqual([ + expect.stringContaining("spawn with a non-literal options argument"), + expect.stringContaining("spawn with a non-literal options argument"), + expect.stringContaining("child stdin write with 2 arguments"), + expect.stringContaining("Writable.end"), + expect.stringContaining("Writable.destroy"), + ]); +}); + test("node-types: path and os lower statically under @types/node's shapes", async () => { const outDir = outDirFor("node-path-os"); const result = await compile(join(nodeTypesDir, "path-os.ts"), {