Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/source-lints.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ on:
- "src/**/*.classes.ts"
- "src/codegen/class-definitions.ts"
- "src/jsc/bindings/**"
- "packages/bun-types/bun.d.ts"
- "packages/bun-types/redis.d.ts"
- "scripts/build/**"
- "scripts/glob-sources.ts"
Expand All @@ -34,6 +35,7 @@ on:
- "src/**/*.classes.ts"
- "src/codegen/class-definitions.ts"
- "src/jsc/bindings/**"
- "packages/bun-types/bun.d.ts"
- "packages/bun-types/redis.d.ts"
- "scripts/build/**"
- "scripts/glob-sources.ts"
Expand Down
3 changes: 2 additions & 1 deletion docs/runtime/child-process.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,8 @@ interface Subprocess extends AsyncDisposable {
readonly stdin: FileSink | number | undefined | null;
readonly stdout: ReadableStream<Uint8Array<ArrayBuffer>> | number | undefined | null;
readonly stderr: ReadableStream<Uint8Array<ArrayBuffer>> | number | undefined | null;
readonly readable: ReadableStream<Uint8Array<ArrayBuffer>> | number | undefined | null;
readonly writable: Subprocess["stdin"]; // the same value as stdin
readonly readable: Subprocess["stdout"]; // the same value as stdout
readonly terminal: Terminal | undefined;
readonly pid: number;
readonly exited: Promise<number>;
Expand Down
8 changes: 8 additions & 0 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7578,6 +7578,14 @@ declare module "bun" {
*/
readonly stdio: [null, null, null, ...(number | null)[]];

/**
* The same value as {@link Subprocess.stdin}
*
* The counterpart of {@link Subprocess.readable}. With `stdin: "pipe"` this is
* the {@link FileSink} for the process's stdin, not a `WritableStream`.
*/
readonly writable: SpawnOptions.WritableToIO<In>;

/**
* The same value as {@link Subprocess.stdout}
*
Expand Down
2 changes: 2 additions & 0 deletions src/runtime/api/BunObject.classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export default [
memoryCost: true,
klass: {},
JSType: "0b11101110",
// Every member here needs a declaration in `interface Subprocess` in packages/bun-types/bun.d.ts;
// test/internal/source-lints/subprocess-types.test.ts checks the two match.
Comment thread
robobun marked this conversation as resolved.
Outdated
proto: {
pid: {
getter: "getPid",
Expand Down
26 changes: 26 additions & 0 deletions test/integration/bun-types/fixture/spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,32 @@ tsd.expectAssignable<NullSubprocess>(Bun.spawn([], { stdio: [null, null, null] }

tsd.expectAssignable<SyncSubprocess<Bun.SpawnOptions.Readable, Bun.SpawnOptions.Readable>>(Bun.spawnSync([], {}));

// `writable` and `readable` return the same values as `stdin` and `stdout`, so they have the
// same types as `stdin` and `stdout` whatever the process was spawned with.
{
function aliases<
In extends Bun.SpawnOptions.Writable,
Out extends Bun.SpawnOptions.Readable,
Err extends Bun.SpawnOptions.Readable,
>(proc: Bun.Subprocess<In, Out, Err>) {
tsd.expectType(proc.writable).is<typeof proc.stdin>();
tsd.expectType(proc.readable).is<typeof proc.stdout>();
}
aliases(Bun.spawn(["cat"]));
}
{
const proc = Bun.spawn(["cat"], { stdin: "pipe" });
tsd.expectType(proc.writable).is<FileSink>();
proc.writable.write("hello");

// @ts-expect-error writable is a read-only getter, like stdin
proc.writable = proc.stdin;
}
tsd.expectType<PipedSubprocess["writable"]>().is<FileSink>();
tsd.expectType<WritableSubprocess["writable"]>().is<FileSink>();
tsd.expectType<NullSubprocess["writable"]>().is<undefined>();
tsd.expectType<ReadableSubprocess["readable"]>().is<ReadableStream<Uint8Array<ArrayBuffer>>>();

// Lazy option types (async only)
{
// valid: lazy usable with async spawn
Expand Down
117 changes: 117 additions & 0 deletions test/internal/source-lints/subprocess-types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import path from "node:path";
import bunClasses from "../../../src/runtime/api/BunObject.classes.ts";

// `interface Subprocess` in packages/bun-types/bun.d.ts is a hand-written mirror
// of the Subprocess `proto` table in src/runtime/api/BunObject.classes.ts, which
// is what the class codegen installs on Subprocess.prototype. Nothing else
// compares the two: `writable` was registered next to `readable` in 2022 and
// stayed undeclared until 2026, so `proc.writable` worked at runtime and failed
// to type-check. Same check as redis-client-types.test.ts, reading an interface
// body instead of a class body.

// Registered members that are not declared yet, each with the open PR that
// declares it. Delete the entry when that PR lands: the lint fails while a name
// listed here is declared (or is no longer registered).
const pendingDeclarations: Record<string, string> = {
connected: "#38677",
};

// Registered members the interface declares through its `extends` clause rather
// than in its body, keyed to the interface that declares them.
const declaredByExtends: Record<string, string> = {
"@@asyncDispose": "AsyncDisposable",
};

const classesFile = "src/runtime/api/BunObject.classes.ts";
const dtsFile = "packages/bun-types/bun.d.ts";
const root = path.resolve(import.meta.dir, "..", "..", "..");

const definition = bunClasses.find(c => c.name === "Subprocess");
if (definition === undefined) throw new Error(`${classesFile} no longer defines Subprocess`);
// An interface body can only mirror prototype members. A constructor or statics
// would need a value declaration this lint does not read.
if (!definition.noConstructor || Object.keys(definition.klass).length !== 0) {
throw new Error(
`Subprocess in ${classesFile} has a constructor or statics, which \`interface Subprocess\` cannot declare`,
);
}

// Every member the codegen installs on the prototype. A well-known symbol stays
// in the table's `@@x` spelling; parseInterfaceBody maps `[Symbol.x]` onto it.
const registered = new Set<string>();
for (const [name, field] of Object.entries(definition.proto)) {
// Installed under a private name or a Symbol.for() symbol, or (internal) not
// installed at all; none of these has a declaration to mirror.
if ("internal" in field || "privateSymbol" in field || "publicSymbol" in field) continue;
registered.add(name);
}

const { extended, declared, unrecognized } = parseInterfaceBody(readFileSync(path.join(root, dtsFile), "utf8"));
for (const [name, base] of Object.entries(declaredByExtends)) {
if (extended.includes(base)) declared.add(name);
}

function parseInterfaceBody(dts: string): { extended: string[]; declared: Set<string>; unrecognized: string[] } {
// The header spans several lines because of the type parameter list.
const open = /^ interface Subprocess<\n(?: {4}.*\n)* >(?: extends (.+))? \{$/m.exec(dts);
if (open === null) throw new Error(`${dtsFile} no longer declares \`interface Subprocess<...> {\``);
const extended = open[1] === undefined ? [] : open[1].split(",").map(base => base.trim());
const bodyStart = open.index + open[0].length;
const bodyEnd = dts.indexOf("\n }\n", bodyStart);
if (bodyEnd === -1) throw new Error(`${dtsFile}: unterminated interface Subprocess body`);

const body = dts
.slice(bodyStart, bodyEnd)
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^[ \t]*\/\/.*$/gm, "");

const declared = new Set<string>();
const unrecognized: string[] = [];
// Members start at the body's four-space indent. The only other lines at that
// indent are the `): ...;` closers of multi-line signatures, which start with
// punctuation, so everything else at that indent is a member and has to
// parse: a declaration shape this does not know is reported rather than
// skipped.
const member = /^(?:readonly )?(?:\[Symbol\.(\w+)\]|([A-Za-z_$][\w$]*))\s*\??\s*[(:<]/;
for (const [line] of body.matchAll(/^ [^\s)\]}>].*$/gm)) {
const m = member.exec(line.slice(4));
if (m === null) {
unrecognized.push(line.trim());
continue;
}
declared.add(m[1] !== undefined ? `@@${m[1]}` : m[2]!);
}
return { extended, declared, unrecognized };
}

test(`every member of interface Subprocess in ${dtsFile} has a shape this lint can read`, () => {
expect(unrecognized).toEqual([]);
});

test(`${dtsFile} declares every Subprocess member ${classesFile} installs`, () => {
const undeclared = [...registered]
.filter(name => !declared.has(name) && !Object.hasOwn(pendingDeclarations, name))
.sort();
expect(undeclared).toEqual([]);
});

test(`${dtsFile} declares no Subprocess member ${classesFile} does not install`, () => {
const phantom = [...declared].filter(name => !registered.has(name)).sort();
expect(phantom).toEqual([]);
});

test("pendingDeclarations and declaredByExtends describe the current files", () => {
const stale = [
...Object.entries(pendingDeclarations).flatMap(([name, pr]) => {
if (!registered.has(name)) return [`${name} (${pr}) is no longer registered in ${classesFile}`];
if (declared.has(name)) return [`${name} (${pr}) is declared in ${dtsFile} now; delete its entry`];
return [];
}),
...Object.entries(declaredByExtends).flatMap(([name, base]) =>
extended.includes(base) ? [] : [`interface Subprocess no longer extends ${base}, which declared ${name}`],
),
].sort();
expect(stale).toEqual([]);
});
Loading