@amritk/nish-x86_64-linux 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/nish CHANGED
Binary file
package/docs/AI.md CHANGED
@@ -2137,11 +2137,18 @@ a `\u` escape `nish/json` decodes) is one unit per byte, and counts its bytes.
2137
2137
  it is; `i32`: the length, or a negative errno), `renameSync(from, to)` (`0` or
2138
2138
  a negative errno; atomic on one filesystem, `-18` across two, so write the
2139
2139
  temporary beside the target), `unlinkSync(path)` (`0` or a negative errno; a
2140
- directory is refused), `writeBytes(bytes, off, len)` (a `u8[]` range to stdout
2140
+ directory is refused), `writeFileSyncMode(path, data, mode)` (the file left
2141
+ at exactly `mode` (`0..0o777`), set before the first byte, written in place; a symbolic link refused; the
2142
+ length or a negative errno), `createFileExclusiveSync(path, data, mode)` (the
2143
+ same, but `-17` when the name exists: a lock file), `mkdirSyncMode(path, mode)`
2144
+ (`0`, `-17` when it exists, the umask applies), `writeBytes(bytes, off, len)` (a `u8[]` range to stdout
2141
2145
  as it is, the length or a negative errno; the range panics like
2142
2146
  `netWrite`'s), `mkdirSync(path)` (one level, `boolean`),
2143
2147
  `isDirectorySync(path)`, `readdirSync(path)` (`string[] | null`, sorted by
2144
2148
  bytes, no `.`/`..`), `spawnSync(argv)`, `spawnSyncTo(argv, outPath, errPath)`,
2149
+ `spawnPiped(argv, stdin, stdout, stderr)` (stdin from a `u8[]`, both outputs
2150
+ appended to `u8[]`s, the status; `-2` past 16 MiB; the way to hand a child a
2151
+ secret, never argv or the environment),
2145
2152
  `getenv(name)` (`string | null` — unset and empty are different answers),
2146
2153
  `realpathSync(path)` (`string | null`; symbolic links resolved, absolute, and
2147
2154
  `null` when it does not resolve),
@@ -2625,6 +2632,20 @@ the array open: end it and send the rest in the next statement. Every other
2625
2632
  guide's [PostgreSQL page](guide/using-nish/postgres.md#array-parameters) its
2626
2633
  walk-through.)
2627
2634
 
2635
+ **Read PostgreSQL ids, counts and times without parsing them** — there is no
2636
+ driver option for binary results: in `nish/net/postgres`, open the bind with
2637
+ `bindStartBinary(name)` instead of `bindStart(name)`, and every result column
2638
+ of that execution comes in binary. Read an `int2`/`int4`/`int8` with
2639
+ `binaryInt(c)`, a `bool` with `binaryBool(c)`, a `float8` with
2640
+ `binaryFloat(c)`, a `timestamptz` with `conn.binary.timestamp(conn.inputBuffer(),
2641
+ conn.colStart(c), conn.colLength(c), out, at)` (or its microseconds since 2000
2642
+ with `binaryInt(c)`, written out by `conn.binary.iso(micros, out, at)`), and a
2643
+ `text` column with `columnText(c)` as before. Cast anything else (`numeric`,
2644
+ `text[]`) to `text` in the SQL. (No block, as for `nish/text`;
2645
+ `tests/postgres/live`'s `binary` scenario runs it against a server, and the
2646
+ user guide's [PostgreSQL page](guide/using-nish/postgres.md#binary-results)
2647
+ its walk-through.)
2648
+
2628
2649
  **Batch a server's keyed reads into one PostgreSQL query** — there is no
2629
2650
  DataLoader and no `setTimeout` to collect a tick's keys: in `nish/net/postgres`,
2630
2651
  a `PgBatcher` built from a `PgBatchConfig` (`name`, `select` reading the key as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/nish-x86_64-linux",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "The nish native compiler for Linux on x86_64",
5
5
  "license": "MIT",
6
6
  "os": [
@@ -47,6 +47,9 @@ export declare function appendFileSync(path: string, data: string): void;
47
47
  export declare function writeFileBytesSync(path: string, bytes: readonly u8[]): i32;
48
48
  export declare function renameSync(from: string, to: string): i32;
49
49
  export declare function unlinkSync(path: string): i32;
50
+ export declare function writeFileSyncMode(path: string, data: string, mode: i32): i32;
51
+ export declare function createFileExclusiveSync(path: string, data: string, mode: i32): i32;
52
+ export declare function mkdirSyncMode(path: string, mode: i32): i32;
50
53
  export declare function mkdirSync(path: string): boolean;
51
54
  export declare function isDirectorySync(path: string): boolean;
52
55
  export declare function readdirSync(path: string): string[] | null;
@@ -56,6 +59,7 @@ export declare function arch(): string;
56
59
  export declare function getenv(name: string): string | null;
57
60
  export declare function spawnSync(argv: string[]): number;
58
61
  export declare function spawnSyncTo(argv: string[], stdoutPath: string, stderrPath: string): number;
62
+ export declare function spawnPiped(argv: string[], stdin: readonly u8[], stdout: u8[], stderr: u8[]): i32;
59
63
  export declare function kill(pid: i32, sig: i32): i32;
60
64
  export declare function availableParallelism(): i32;
61
65
  export declare function totalMemory(): i64;
@@ -157,6 +157,18 @@ export function unlinkSync(path) {
157
157
  return host.call("unlinkSync", [path]);
158
158
  }
159
159
 
160
+ export function writeFileSyncMode(path, data, mode) {
161
+ return host.call("writeFileSyncMode", [path, data, mode]);
162
+ }
163
+
164
+ export function createFileExclusiveSync(path, data, mode) {
165
+ return host.call("createFileExclusiveSync", [path, data, mode]);
166
+ }
167
+
168
+ export function mkdirSyncMode(path, mode) {
169
+ return host.call("mkdirSyncMode", [path, mode]);
170
+ }
171
+
160
172
  export function mkdirSync(path) {
161
173
  return host.call("mkdirSync", [path]);
162
174
  }
@@ -193,6 +205,18 @@ export function spawnSyncTo(argv, out, err) {
193
205
  return host.call("spawnSyncTo", [argv, out, err]);
194
206
  }
195
207
 
208
+ /**
209
+ * `spawnPiped(argv, stdin, stdout, stderr)`: the host runs the child and hands
210
+ * back its status and both streams' bytes, and they are appended here, as the
211
+ * runtime appends them to the caller's arrays.
212
+ */
213
+ export function spawnPiped(argv, input, out, err) {
214
+ const [status, outBytes, errBytes] = host.call("spawnPiped", [argv, input]);
215
+ for (const b of outBytes) out.push(b);
216
+ for (const b of errBytes) err.push(b);
217
+ return status;
218
+ }
219
+
196
220
  export function kill(pid, sig) {
197
221
  return host.call("kill", [pid, sig]);
198
222
  }
package/runtime/nish.d.ts CHANGED
@@ -276,6 +276,33 @@ declare function writeFileBytesSync(path: string, bytes: readonly u8[]): i32;
276
276
  declare function renameSync(from: string, to: string): i32;
277
277
  /** The name `path` removed: 0, or a negative errno. A directory is refused, not removed. */
278
278
  declare function unlinkSync(path: string): i32;
279
+ /**
280
+ * `writeFileSync` with an explicit `mode`: the file is exactly `mode`
281
+ * (`0o600` for a secret) before a byte of `data` is in it, whatever the umask
282
+ * or a file already there had, and a symbolic link at the path is refused
283
+ * (-40). The byte count, or a negative errno: -22 for a mode outside
284
+ * 0..0o777, the setuid, setgid and sticky bits included. The file is
285
+ * truncated and written in place, so a crash or a failed write leaves it
286
+ * empty or partial, and a file this process may write but does not own is
287
+ * emptied before the mode change answers -1 (`EPERM`). For a file that must
288
+ * be the old one or the new one, write a temporary name with this and move it
289
+ * with `renameSync`. Never an exit.
290
+ */
291
+ declare function writeFileSyncMode(path: string, data: string, mode: i32): i32;
292
+ /**
293
+ * `data` in a new file of exactly `mode`, created only if nothing is at `path`:
294
+ * -17 when something is, a dangling symbolic link included. Of several
295
+ * processes racing for one path exactly one succeeds, so it takes a lock. The
296
+ * byte count, or a negative errno; a write that fails after the create
297
+ * removes the file, so the lock is not left taken. Never an exit.
298
+ */
299
+ declare function createFileExclusiveSync(path: string, data: string, mode: i32): i32;
300
+ /**
301
+ * One directory made with `mode` less the umask (`0o700` is exact under any
302
+ * umask that leaves the owner's bits): 0, or a negative errno. A path already
303
+ * there is -17 and its mode is not changed; -22 for a mode outside 0..0o777.
304
+ */
305
+ declare function mkdirSyncMode(path: string, mode: i32): i32;
279
306
  /** One directory, not recursive; whether a directory is there afterwards. */
280
307
  declare function mkdirSync(path: string): boolean;
281
308
  /** Whether a directory is at `path` right now. One `stat`, and never an exit. */
@@ -299,6 +326,13 @@ declare function spawnSync(argv: string[]): number;
299
326
  * either path is refused, as `writeFileSync` refuses one, and the answer is -1.
300
327
  */
301
328
  declare function spawnSyncTo(argv: string[], stdoutPath: string, stderrPath: string): number;
329
+ /**
330
+ * The same run with all three streams on pipes: `stdin` is written to the
331
+ * child and closed, and what it prints is appended to `stdout` and `stderr`.
332
+ * The status as `spawnSync` answers it, or -2 when a stream passed 16 MiB:
333
+ * the child is then killed and each array keeps at most the first 16 MiB.
334
+ */
335
+ declare function spawnPiped(argv: string[], stdin: readonly u8[], stdout: u8[], stderr: u8[]): i32;
302
336
  /**
303
337
  * Start `argv[0]` through `PATH` without waiting: the child's pid, or a
304
338
  * negative errno. The child is the program's until `waitPid` reaps it. No
@@ -492,6 +526,9 @@ declare var crypto: {
492
526
  //
493
527
  // `docs/LANGUAGE.md` -> Builtins -> Builtin modules is the normative list.
494
528
 
529
+ // Every negative errno a file call answers is the platform's own, untranslated:
530
+ // the numbers shown here are Linux's (-2 ENOENT, -17 EEXIST, -18 EXDEV, -22
531
+ // EINVAL, -40 ELOOP), and Darwin's differ (ELOOP is -62 there).
495
532
  declare module "nish:fs" {
496
533
  export function readFileSync(path: string): string;
497
534
  export function readFileSyncOrNull(path: string): string | null;
@@ -506,6 +543,12 @@ declare module "nish:fs" {
506
543
  export function renameSync(from: string, to: string): i32;
507
544
  /** 0, or a negative errno: a directory is refused, not removed. */
508
545
  export function unlinkSync(path: string): i32;
546
+ /** The byte count, or a negative errno; the file is exactly `mode` (0..0o777) before it holds a byte, a link refused, written in place. */
547
+ export function writeFileSyncMode(path: string, data: string, mode: i32): i32;
548
+ /** As `writeFileSyncMode`, but -17 when anything is at `path`: one racer wins; a failed write removes the file. */
549
+ export function createFileExclusiveSync(path: string, data: string, mode: i32): i32;
550
+ /** 0, or a negative errno: -17 for a path already there, whose mode is left alone. */
551
+ export function mkdirSyncMode(path: string, mode: i32): i32;
509
552
  /** `true` when the directory was created, `false` when it already existed. */
510
553
  export function mkdirSync(path: string): boolean;
511
554
  export function isDirectorySync(path: string): boolean;
@@ -530,6 +573,11 @@ declare module "nish:process" {
530
573
  * at either path is refused, and the answer is -1.
531
574
  */
532
575
  export function spawnSyncTo(argv: string[], stdoutPath: string, stderrPath: string): number;
576
+ /**
577
+ * The same run with `stdin` written to the child and its stdout and stderr
578
+ * appended to the two arrays: the status, or -2 past 16 MiB on a stream.
579
+ */
580
+ export function spawnPiped(argv: string[], stdin: readonly u8[], stdout: u8[], stderr: u8[]): i32;
533
581
  /** Start a child without waiting: its pid, or a negative errno. */
534
582
  export function spawn(argv: string[]): i32;
535
583
  /** Send 0, 2, 9 or 15 to `pid`: 0, or a negative errno; any other signal is -22. */
package/runtime/nish.h CHANGED
@@ -403,10 +403,24 @@ nish_array *nish_read_file_bytes(const nish_str *path);
403
403
  * errno — -EXDEV for a rename across file systems, which moves nothing, and
404
404
  * -EISDIR or -EPERM for a directory handed to `nish_unlink`, which never calls
405
405
  * rmdir. A path holding a NUL answers -EINVAL, not -ENOENT, so a program can
406
- * tell a malformed path from a missing file. */
406
+ * tell a malformed path from a missing file. The numbers are the platform's
407
+ * errno values, untranslated: -ELOOP is -40 on Linux and -62 on Darwin. */
407
408
  int32_t nish_rename(const nish_str *from, const nish_str *to);
408
409
  int32_t nish_unlink(const nish_str *path);
409
410
  int32_t nish_write_file_bytes(const nish_str *path, const nish_array *bytes);
411
+ /* `writeFileSyncMode(path, data, mode)`, `createFileExclusiveSync(path, data,
412
+ * mode)` and `mkdirSyncMode(path, mode)`: a file written, or created only if
413
+ * nothing is at the path (`O_EXCL`), then `fchmod`ed to exactly `mode` before a
414
+ * byte is written; a directory made with `mode & ~umask`. A link at a file's
415
+ * path is refused. The file writers answer the byte count and `nish_mkdir_mode`
416
+ * 0, or a negative platform errno: -EEXIST for an exclusive create or a mkdir
417
+ * that finds the path taken (a directory's mode is then left alone), -EINVAL
418
+ * for a mode outside 0..0777 (the setuid, setgid and sticky bits included) or
419
+ * a path holding a NUL, -ENOSYS under WASI. A failed exclusive create leaves
420
+ * no file behind. */
421
+ int32_t nish_write_file_mode(const nish_str *path, const nish_str *data, int32_t mode);
422
+ int32_t nish_create_file_exclusive(const nish_str *path, const nish_str *data, int32_t mode);
423
+ int32_t nish_mkdir_mode(const nish_str *path, int32_t mode);
410
424
  /* `writeBytes(bytes, off, len)` (nish:io), in runtime-os.c: the bytes
411
425
  * `[off, off + len)` of a `u8[]` on stdout, a short write finished and `EINTR`
412
426
  * retried. The caller has range-checked `off` and `len`. Answers `len`, or the
@@ -438,6 +452,17 @@ int32_t nish_spawn_to(const nish_array *argv, const nish_str *out, const nish_st
438
452
  * own error otherwise, and -ENOSYS under WASI. The child is reaped by
439
453
  * `nish_wait_pid`. */
440
454
  int32_t nish_spawn_child(const nish_array *argv);
455
+ /* `spawnPiped(argv, stdin, stdout, stderr)`: start element 0 of `argv` as
456
+ * `nish_spawn` does, with the bytes of `stdin` (a `u8[]`) on its standard input
457
+ * and its standard output and error appended to `stdout` and `stderr`, wait for
458
+ * it, and answer `nish_spawn`'s status. -1 is `nish_spawn`'s -1, a pipe or
459
+ * `poll` that fails included, and always under WASI. -2 is a stream that passed
460
+ * 16 MiB: the child is killed with SIGKILL and reaped, and that array holds the
461
+ * stream's first 16 MiB. The two output arrays grow as `push` grows them, so
462
+ * the compiler treats both as pushed. `stdin` is read in place and never
463
+ * copied; SIGPIPE is blocked on the calling thread for the call, a SIGPIPE the
464
+ * call raised is consumed, and the thread's mask is restored after it. */
465
+ int32_t nish_spawn_piped(const nish_array *argv, const nish_array *in, nish_array *out, nish_array *err);
441
466
  /* `kill(pid, sig)`: send `sig` to `pid` and answer 0, or a negative errno. Only
442
467
  * 0, 2 (SIGINT), 9 (SIGKILL) and 15 (SIGTERM) are sent; any other signal, and
443
468
  * a pid of 0 or below, is -EINVAL. -ESRCH when no process has the pid, -EPERM
package/runtime/nish.mjs CHANGED
@@ -94,12 +94,16 @@ provide("appendFileSync", shim.appendFileSync);
94
94
  provide("writeFileBytesSync", shim.writeFileBytesSync);
95
95
  provide("renameSync", shim.renameSync);
96
96
  provide("unlinkSync", shim.unlinkSync);
97
+ provide("writeFileSyncMode", shim.writeFileSyncMode);
98
+ provide("createFileExclusiveSync", shim.createFileExclusiveSync);
99
+ provide("mkdirSyncMode", shim.mkdirSyncMode);
97
100
  provide("mkdirSync", shim.mkdirSync);
98
101
  provide("isDirectorySync", shim.isDirectorySync);
99
102
  provide("readdirSync", shim.readdirSync);
100
103
  provide("realpathSync", shim.realpathSync);
101
104
  provide("spawnSync", shim.spawnSync);
102
105
  provide("spawnSyncTo", shim.spawnSyncTo);
106
+ provide("spawnPiped", shim.spawnPiped);
103
107
  // WP34 N12. `spawn` and `waitPid` throw, naming themselves: a child is reaped
104
108
  // by Node's event loop. `kill`, `availableParallelism` and `totalMemory` answer.
105
109
  provide("spawn", shim.spawn);
package/runtime/node.mjs CHANGED
@@ -102,6 +102,46 @@ const unlinkSync = (path) => errnoOr(0, () => fs.unlinkSync(path));
102
102
  const writeFileBytesSync = (path, bytes) =>
103
103
  errnoOr(bytes.length, () => fs.writeFileSync(path, Uint8Array.from(bytes), { flag: WRITE_FLAGS, mode: 0o644 }));
104
104
 
105
+ /**
106
+ * `writeFileSyncMode` and `createFileExclusiveSync`'s shared body, as
107
+ * `runtime-os.c`'s: the file opened with `flag`, `fchmod`ed to exactly `mode`
108
+ * before a byte is written, then every byte of `data`, then the count, or a
109
+ * negative errno. A mode outside 0..0o777 is -22 before the file is touched.
110
+ * After an exclusive create (`"wx"`), any failure unlinks the file it made.
111
+ */
112
+ function putFileMode(path, data, flag, mode) {
113
+ if (!(mode >= 0 && mode <= 0o777)) return -22;
114
+ let fd;
115
+ try {
116
+ fd = fs.openSync(path, flag, mode);
117
+ } catch (e) {
118
+ return negErrno(e);
119
+ }
120
+ let r;
121
+ try {
122
+ fs.fchmodSync(fd, mode);
123
+ const buf = Buffer.from(data, "utf8");
124
+ for (let done = 0; done < buf.length; ) done += fs.writeSync(fd, buf, done);
125
+ r = buf.length;
126
+ } catch (e) {
127
+ r = negErrno(e);
128
+ }
129
+ const closed = errnoOr(0, () => fs.closeSync(fd));
130
+ const answer = r >= 0 && closed < 0 ? closed : r;
131
+ if (answer < 0 && flag === "wx") errnoOr(0, () => fs.unlinkSync(path));
132
+ return answer;
133
+ }
134
+
135
+ /** `writeFileSyncMode(path, data, mode)`: `WRITE_FLAGS`, so a link at the path is refused. */
136
+ const writeFileSyncMode = (path, data, mode) => putFileMode(path, data, WRITE_FLAGS, mode);
137
+
138
+ /** `createFileExclusiveSync(path, data, mode)`: `"wx"`, -17 when anything is at the path. */
139
+ const createFileExclusiveSync = (path, data, mode) => putFileMode(path, data, "wx", mode);
140
+
141
+ /** `mkdirSyncMode(path, mode)`: 0, or a negative errno; -17 for a path already there. */
142
+ const mkdirSyncMode = (path, mode) =>
143
+ mode >= 0 && mode <= 0o777 ? errnoOr(0, () => fs.mkdirSync(path, { mode })) : -22;
144
+
105
145
  /**
106
146
  * `writeBytes`'s write, after the core checked the range `[from, to)`: every
107
147
  * byte to stdout, then the count, or a negative errno. `fs.writeSync` may
@@ -268,6 +308,41 @@ function spawnSyncTo(argv, out, err) {
268
308
  return spawnImpl(argv, out, err);
269
309
  }
270
310
 
311
+ /** The bytes `spawnPiped` keeps of one stream, as `runtime-os.c`'s `NISH_PIPED_CAP`. */
312
+ const PIPED_CAP = 16 * 1024 * 1024;
313
+
314
+ /**
315
+ * `spawnPiped(argv, stdin)`'s run, after the core took it apart: the status
316
+ * and the two streams' bytes, which the core appends to the caller's arrays.
317
+ * The status is `spawnImpl`'s, and -2 when a stream passed `PIPED_CAP`: Node
318
+ * then kills the child with `SIGKILL`, as the runtime does, and reports
319
+ * `ENOBUFS`, and what it read is cut to the cap here as the runtime keeps it.
320
+ * A child that exits without reading its stdin is not an error on either side.
321
+ */
322
+ function spawnPiped(argv, input) {
323
+ if (argv.length === 0) return [-1, [], []];
324
+ let r;
325
+ // Node throws, rather than reporting, for an empty command or a NUL in any
326
+ // argument, which the runtime answers -1 for without starting a child.
327
+ try {
328
+ r = child_process.spawnSync(argv[0], argv.slice(1), {
329
+ input: Uint8Array.from(input),
330
+ maxBuffer: PIPED_CAP,
331
+ killSignal: "SIGKILL",
332
+ });
333
+ } catch {
334
+ return [-1, [], []];
335
+ }
336
+ const bytes = (b) => (b === null || b === undefined ? [] : Array.from(b.subarray(0, PIPED_CAP)));
337
+ const out = bytes(r.stdout);
338
+ const err = bytes(r.stderr);
339
+ // `EPIPE` is the child closing its stdin unread, which Node reports with
340
+ // the status it still waited for; the runtime ignores it the same way.
341
+ if (r.error !== undefined && r.error.code !== "EPIPE") return [r.error.code === "ENOBUFS" ? -2 : -1, out, err];
342
+ if (r.signal !== null && r.signal !== undefined) return [128 + (os.constants.signals[r.signal] ?? 0), out, err];
343
+ return [r.status === null ? -1 : r.status, out, err];
344
+ }
345
+
271
346
  /**
272
347
  * `kill(pid, sig)`: 0, or a negative errno, as `runtime-os.c`'s `nish_kill`
273
348
  * answers. Only 0, 2, 9 and 15 are sent and a pid of 0 or below is refused,
@@ -393,6 +468,9 @@ const CALLS = {
393
468
  writeFileBytesSync,
394
469
  renameSync,
395
470
  unlinkSync,
471
+ writeFileSyncMode,
472
+ createFileExclusiveSync,
473
+ mkdirSyncMode,
396
474
  writeBytes,
397
475
  mkdirSync,
398
476
  isDirectorySync,
@@ -403,6 +481,7 @@ const CALLS = {
403
481
  getenv,
404
482
  spawnSync,
405
483
  spawnSyncTo,
484
+ spawnPiped,
406
485
  kill,
407
486
  availableParallelism,
408
487
  totalMemory,
@@ -49,6 +49,7 @@
49
49
  #include <unistd.h>
50
50
  #ifndef __wasi__
51
51
  /* WASI has no processes, so `spawn.h` and `wait.h` do not exist there. */
52
+ #include <poll.h>
52
53
  #include <signal.h>
53
54
  #include <spawn.h>
54
55
  #include <sys/resource.h>
@@ -170,20 +171,9 @@ nish_str *nish_read_file(const nish_str *path) {
170
171
  return s;
171
172
  }
172
173
 
173
- /* `O_NOFOLLOW` (docs/security/runtime.md, RT-4): a symbolic link as the last
174
- component of the path is refused, as an unwritable file is, rather than
175
- followed to whatever file it names. `nish file.ts` writes `file.ll` beside
176
- its source, so a link planted at that name in a checkout would otherwise
177
- truncate any file the user can write. A link among the directories on the
178
- way is still followed.
179
-
180
- `O_CLOEXEC` for the reason the read has it (RT-12), and `EINTR` is retried
181
- as the read retries it (RT-13): a write cut short is finished, and one that
182
- fails is `cannot write`, never a silently short file.
183
-
184
- The loop is `nish_put_all`, which `writeFileBytesSync` and `writeBytes`
185
- share: all `n` bytes of `p` to `fd`, answering 0 or the negative errno of
186
- the write that failed (-EIO for one that wrote nothing). */
174
+ /* All `n` bytes of `p` to `fd`, answering 0 or the negative errno of the
175
+ write that failed (-EIO for one that wrote nothing). A write cut short is
176
+ finished and `EINTR` retried (RT-13). Every writer below shares it. */
187
177
  static int32_t nish_put_all(int fd, const char *p, uint64_t n) {
188
178
  for (uint64_t done = 0; done < n;) {
189
179
  ssize_t k = write(fd, p + done, n - done);
@@ -193,6 +183,17 @@ static int32_t nish_put_all(int fd, const char *p, uint64_t n) {
193
183
  }
194
184
  return 0;
195
185
  }
186
+
187
+ /* `O_NOFOLLOW` (docs/security/runtime.md, RT-4): a symbolic link as the last
188
+ component of the path is refused, as an unwritable file is, rather than
189
+ followed to whatever file it names. `nish file.ts` writes `file.ll` beside
190
+ its source, so a link planted at that name in a checkout would otherwise
191
+ truncate any file the user can write. A link among the directories on the
192
+ way is still followed.
193
+
194
+ `O_CLOEXEC` for the reason the read has it (RT-12), and the bytes go out
195
+ through `nish_put_all`, so a write that fails is `cannot write`, never a
196
+ silently short file. */
196
197
  static void nish_put_file(const nish_str *path, const nish_str *data, int flags) {
197
198
  int fd = open(nish_cpath(path), O_WRONLY | O_CREAT | O_NOFOLLOW | O_CLOEXEC | flags, 0644);
198
199
  if (fd < 0 || nish_put_all(fd, data->data, data->len) < 0) nish_io_fail("write ", path);
@@ -248,6 +249,73 @@ int32_t nish_write_file_bytes(const nish_str *path, const nish_array *bytes) {
248
249
  return r < 0 ? r : (int32_t)bytes->len;
249
250
  }
250
251
 
252
+ /* ---- Files and directories with an explicit mode (docs/security/runtime.md,
253
+ RT-24 to RT-26): `writeFileSyncMode`, `createFileExclusiveSync` and
254
+ `mkdirSyncMode`. Each answers a value and never exits, as the writers just
255
+ above do. A mode outside 0..0777 is -EINVAL before any system call: the
256
+ setuid, setgid and sticky bits are refused, not applied, so the mode asked
257
+ for is the mode the file has. WASI
258
+ has no file modes to apply, so there each answers -ENOSYS and creates
259
+ nothing rather than a file whose mode was not set. */
260
+
261
+ /* The two file writers' shared body: `open(p, O_WRONLY | O_CREAT |
262
+ O_NOFOLLOW | O_CLOEXEC | flags, mode)`, then `fchmod(fd, mode)` before a
263
+ byte is written, so the file is exactly `mode` whatever the umask and
264
+ whatever mode a file already there had: no reader that opens it while it is
265
+ written finds it wider (RT-24). Then the data through `nish_put_all` and a
266
+ checked `close`, as `nish_write_file_bytes` does. The open truncates
267
+ before the `fchmod`, so a file there that this process may write but does
268
+ not own is emptied and then answers -EPERM. When `flags` holds `O_EXCL`,
269
+ any failure after the create unlinks the file this call made, so a lock
270
+ whose write failed does not stay taken. */
271
+ static int32_t nish_put_file_mode(const nish_str *path, const nish_str *data, int flags, int32_t mode) {
272
+ const char *p = nish_cpath(path);
273
+ if (p != path->data || mode < 0 || mode > 0777) return -EINVAL;
274
+ if (data->len > NISH_LENGTH_MAX) return -EFBIG;
275
+ #ifdef __wasi__
276
+ (void)flags;
277
+ return -ENOSYS;
278
+ #else
279
+ int fd = open(p, O_WRONLY | O_CREAT | O_NOFOLLOW | O_CLOEXEC | flags, (mode_t)mode);
280
+ if (fd < 0) return -errno;
281
+ int32_t r = fchmod(fd, (mode_t)mode) == 0 ? 0 : -errno;
282
+ if (r == 0) r = nish_put_all(fd, data->data, data->len);
283
+ if (close(fd) != 0 && r == 0 && errno != EINTR) r = -errno;
284
+ if (r < 0 && (flags & O_EXCL)) unlink(p);
285
+ return r < 0 ? r : (int32_t)data->len;
286
+ #endif
287
+ }
288
+
289
+ /* `writeFileSyncMode(path, data, mode)`: `writeFileSync` with the mode given
290
+ and an answer instead of an exit. A link at the path is refused (-ELOOP on
291
+ Linux) and the file it names is untouched (RT-25). */
292
+ int32_t nish_write_file_mode(const nish_str *path, const nish_str *data, int32_t mode) {
293
+ return nish_put_file_mode(path, data, O_TRUNC, mode);
294
+ }
295
+
296
+ /* `createFileExclusiveSync(path, data, mode)`: `O_EXCL`, so the path is
297
+ created by this call or the call answers -EEXIST, a link there included,
298
+ dangling or not. Of N processes racing for one path exactly one wins, which
299
+ is what a lock file needs; a winner whose write then fails removes the file
300
+ and answers the error. */
301
+ int32_t nish_create_file_exclusive(const nish_str *path, const nish_str *data, int32_t mode) {
302
+ return nish_put_file_mode(path, data, O_EXCL, mode);
303
+ }
304
+
305
+ /* `mkdirSyncMode(path, mode)`: mkdir(2) with `mode`, one directory, not
306
+ recursive. A path already there is -EEXIST and its mode is left as it was.
307
+ The kernel applies the umask, so the directory is `mode & ~umask`: 0700 is
308
+ exact under any umask that leaves the owner's bits (RT-26). */
309
+ int32_t nish_mkdir_mode(const nish_str *path, int32_t mode) {
310
+ const char *p = nish_cpath(path);
311
+ if (p != path->data || mode < 0 || mode > 0777) return -EINVAL;
312
+ #ifdef __wasi__
313
+ return -ENOSYS;
314
+ #else
315
+ return mkdir(p, (mode_t)mode) == 0 ? 0 : -errno;
316
+ #endif
317
+ }
318
+
251
319
  /* `writeBytes(bytes, off, len)` (nish:io): the bytes `[off, off + len)` of a
252
320
  `u8[]` on stdout, through `nish_put_all`, so a short write is finished and `EINTR` retried. The
253
321
  emitter has range-checked `off` and `len` against `bytes->len`
@@ -290,17 +358,19 @@ _Bool nish_mkdir(const nish_str *path) {
290
358
  value rather than through a child that has already run.
291
359
 
292
360
  `out` and `err` are paths for the child's stdout and stderr, or NULL to let
293
- it inherit this process's. Every spawn builtin starts its child here, so the
361
+ it inherit this process's; `dup`, when not NULL, is three descriptors the
362
+ child takes as its stdin, stdout and stderr instead (`spawnPiped`'s pipes). Every spawn builtin starts its child here, so the
294
363
  argv vector and the redirects are written once; it is `static`, so a program
295
364
  that spawns nothing loses it to `--gc-sections`. The answer is the child's
296
365
  pid, or a negative errno: -EINVAL for an empty argv or an argument holding a
297
366
  NUL, and `posix_spawnp`'s own error otherwise (-ENOENT for a command that is
298
367
  not on PATH, on glibc). */
299
- static int32_t nish_spawn_start(const nish_array *argv, const nish_str *out, const nish_str *err) {
368
+ static int32_t nish_spawn_start(const nish_array *argv, const nish_str *out, const nish_str *err, const int *dup) {
300
369
  #ifdef __wasi__
301
370
  (void)argv;
302
371
  (void)out;
303
372
  (void)err;
373
+ (void)dup;
304
374
  return -ENOSYS;
305
375
  #else
306
376
  if (!argv->len) return -EINVAL; /* argv[0] would read past an empty array */
@@ -323,6 +393,13 @@ static int32_t nish_spawn_start(const nish_array *argv, const nish_str *out, con
323
393
  posix_spawn_file_actions_t fa;
324
394
  int failed = posix_spawn_file_actions_init(&fa);
325
395
  if (failed) return -failed;
396
+ /* A `dup2` action that could not be recorded would start the child on this
397
+ process's own stream in its place, so it fails the spawn. */
398
+ for (int k = 0; dup && k < 3 && !failed; k++) failed = posix_spawn_file_actions_adddup2(&fa, dup[k], k);
399
+ if (failed) {
400
+ posix_spawn_file_actions_destroy(&fa);
401
+ return -failed;
402
+ }
326
403
  const nish_str *to[2] = { out, err };
327
404
  for (int k = 0; k < 2; k++) {
328
405
  /* The child opens the file, not this process: a redirect that this process
@@ -348,7 +425,7 @@ static int32_t nish_exit_code(int status) {
348
425
  /* The two blocking builtins: start, then wait for that one child. A failed
349
426
  start is -1, the answer they have always given. */
350
427
  static int32_t nish_spawn_impl(const nish_array *argv, const nish_str *out, const nish_str *err) {
351
- int32_t pid = nish_spawn_start(argv, out, err);
428
+ int32_t pid = nish_spawn_start(argv, out, err, 0);
352
429
  if (pid < 0) return -1;
353
430
  #ifdef __wasi__
354
431
  return -1;
@@ -379,7 +456,160 @@ int32_t nish_spawn_to(const nish_array *argv, const nish_str *out, const nish_st
379
456
  /* `spawn(argv)`: the child's pid, or a negative errno, without waiting. The
380
457
  child is this process's until a `waitPid` reaps it; one never reaped stays
381
458
  a zombie until the program exits, as it would in C. */
382
- int32_t nish_spawn_child(const nish_array *argv) { return nish_spawn_start(argv, 0, 0); }
459
+ int32_t nish_spawn_child(const nish_array *argv) { return nish_spawn_start(argv, 0, 0, 0); }
460
+
461
+ /* `spawnPiped(argv, stdin, stdout, stderr)`: run the child as `nish_spawn`
462
+ does, with `stdin`'s bytes on its standard input and its standard output
463
+ and error appended to `stdout` and `stderr`, and answer its status. -1 is
464
+ `nish_spawn`'s failure (an empty argv, a NUL, a child that cannot start or
465
+ be waited for, a pipe or `poll` that fails), and -2 is a stream that passed
466
+ NISH_PIPED_CAP bytes: the child is killed with SIGKILL and reaped, and the
467
+ array holds the first NISH_PIPED_CAP bytes of that stream.
468
+
469
+ The three pipes are close-on-exec, so no other child this process starts
470
+ inherits an end, and each end is moved off descriptors 0 to 2, where a
471
+ `dup2` onto itself would leave close-on-exec set and the child without
472
+ that stream. Darwin has no `pipe2`, so there an end is close-on-exec only
473
+ once `F_DUPFD_CLOEXEC` has moved it: a child another thread starts in
474
+ that window inherits it (docs/security/runtime.md, RT-30).
475
+
476
+ One `poll` loop over non-blocking ends writes stdin and reads both outputs
477
+ as each is ready, so a child that fills its stdout before it reads all its
478
+ stdin, or the reverse, cannot deadlock against this process. A child that
479
+ exits, or closes its stdin, before reading everything ends the writing
480
+ with EPIPE rather than SIGPIPE: the signal is blocked on this thread once
481
+ the child has started, the one a write raised is taken off the pending
482
+ set, and the mask is put back before the answer (docs/security/runtime.md,
483
+ RT-29). Ignoring it instead would be process-wide, and every child
484
+ started meanwhile, this one included, would inherit SIGPIPE ignored and
485
+ never die of a reader that has gone.
486
+
487
+ The bytes are written from the caller's array as they stand, and nothing
488
+ copies them: the only copies are the kernel's pipe buffer and the child.
489
+ The length is read once, so `stdout` being the same array as `stdin`
490
+ appends to it without feeding the output back in, and the pointer at each
491
+ write, because that array moves when it grows and its stdin bytes stay
492
+ its first `n`. */
493
+ #define NISH_PIPED_CAP (16u << 20)
494
+ #ifndef __wasi__
495
+ static int nish_pipe(int *p) {
496
+ #ifdef __linux__
497
+ if (pipe2(p, O_CLOEXEC)) return -1;
498
+ #else
499
+ if (pipe(p)) return -1;
500
+ #endif
501
+ for (int k = 0; k < 2; k++) {
502
+ #ifdef __linux__
503
+ if (p[k] > 2) continue;
504
+ #endif
505
+ int moved = fcntl(p[k], F_DUPFD_CLOEXEC, 3);
506
+ close(p[k]);
507
+ p[k] = moved;
508
+ if (moved < 0) return -1;
509
+ }
510
+ return 0;
511
+ }
512
+
513
+ #endif
514
+
515
+ int32_t nish_spawn_piped(const nish_array *argv, const nish_array *in, nish_array *out, nish_array *err) {
516
+ #ifdef __wasi__
517
+ (void)argv;
518
+ (void)in;
519
+ (void)out;
520
+ (void)err;
521
+ return -1;
522
+ #else
523
+ uint64_t n = in->len, sent = 0, got[3] = { 0, 0, 0 };
524
+ int p[6] = { -1, -1, -1, -1, -1, -1 };
525
+ int32_t rc = -1;
526
+ if (nish_pipe(p) || nish_pipe(p + 2) || nish_pipe(p + 4)) goto done;
527
+ int child[3] = { p[0], p[3], p[5] };
528
+ int32_t pid = nish_spawn_start(argv, 0, 0, child);
529
+ /* The child holds its ends now; this process keeps only its own. */
530
+ for (int k = 0; k < 3; k++) {
531
+ close(child[k]);
532
+ p[k ? 2 * k + 1 : 0] = -1;
533
+ }
534
+ if (pid < 0) goto done;
535
+ sigset_t pipe_sig, mask, pending;
536
+ sigemptyset(&pipe_sig);
537
+ sigaddset(&pipe_sig, SIGPIPE);
538
+ pthread_sigmask(SIG_BLOCK, &pipe_sig, &mask);
539
+ sigpending(&pending);
540
+ int queued = sigismember(&pending, SIGPIPE);
541
+ struct pollfd f[3] = { { p[1], POLLOUT, 0 }, { p[2], POLLIN, 0 }, { p[4], POLLIN, 0 } };
542
+ p[1] = p[2] = p[4] = -1;
543
+ /* A blocking end could stall the loop on one stream while the child fills
544
+ another, so an end that stays blocking fails the run: the loop is skipped
545
+ and the child killed. */
546
+ rc = 0;
547
+ for (int k = 0; k < 3; k++)
548
+ if (fcntl(f[k].fd, F_SETFL, O_NONBLOCK) < 0) rc = -1;
549
+ if (!n) {
550
+ close(f[0].fd);
551
+ f[0].fd = -1;
552
+ }
553
+ nish_array *to[3] = { 0, out, err };
554
+ while (!rc && (f[0].fd >= 0 || f[1].fd >= 0 || f[2].fd >= 0)) {
555
+ if (poll(f, 3, -1) < 0) {
556
+ if (errno != EINTR) rc = -1;
557
+ continue;
558
+ }
559
+ if (f[0].revents) {
560
+ ssize_t k = write(f[0].fd, in->data + sent, n - sent);
561
+ if (k > 0) sent += k;
562
+ if (sent == n || (k < 0 && errno != EINTR && errno != EAGAIN)) {
563
+ close(f[0].fd);
564
+ f[0].fd = -1;
565
+ }
566
+ }
567
+ for (int s = 1; s < 3 && !rc; s++) {
568
+ if (!f[s].revents) continue;
569
+ nish_array *a = to[s];
570
+ /* Grown as `push` grows it, so the array keeps the shape every other
571
+ array has, and an ASan build tracks the block the same way. */
572
+ if (a->len == a->cap) nish_array_grow(a, 1);
573
+ /* One byte past the cap at most, which is how a stream that ends at
574
+ exactly the cap is told from one that goes on. */
575
+ uint64_t room = a->cap - a->len, left = NISH_PIPED_CAP + 1 - got[s];
576
+ ssize_t k = read(f[s].fd, a->data + a->len, room < left ? room : left);
577
+ if (k > 0) {
578
+ a->len += k;
579
+ got[s] += k;
580
+ if (got[s] > NISH_PIPED_CAP) {
581
+ a->len--;
582
+ rc = -2;
583
+ }
584
+ } else if (!k || (errno != EINTR && errno != EAGAIN)) {
585
+ close(f[s].fd);
586
+ f[s].fd = -1;
587
+ }
588
+ }
589
+ }
590
+ sigpending(&pending);
591
+ if (!queued && sigismember(&pending, SIGPIPE)) {
592
+ int sig;
593
+ sigwait(&pipe_sig, &sig);
594
+ }
595
+ pthread_sigmask(SIG_SETMASK, &mask, 0);
596
+ if (rc) kill(pid, SIGKILL);
597
+ for (int k = 0; k < 3; k++)
598
+ if (f[k].fd >= 0) close(f[k].fd);
599
+ int status;
600
+ while (waitpid(pid, &status, 0) < 0) {
601
+ if (errno != EINTR) {
602
+ rc = -1;
603
+ break;
604
+ }
605
+ }
606
+ if (!rc) rc = nish_exit_code(status);
607
+ done:
608
+ for (int k = 0; k < 6; k++)
609
+ if (p[k] >= 0) close(p[k]);
610
+ return rc;
611
+ #endif
612
+ }
383
613
 
384
614
  /* `kill(pid, sig)`: 0, or a negative errno. Only the four signals a program
385
615
  asks a child to stop or report with are sent -- 0 (is it there), SIGINT,
package/std/README.md CHANGED
@@ -239,7 +239,7 @@ reads one, and it still takes its randomness as an argument.
239
239
  | [`net/webtransport.ts`](./net/webtransport.ts) | `WebTransport`, the server side of WebTransport over HTTP/3 as draft-ietf-webtrans-http3-02 has it and Chrome and `wtransport` 0.7 negotiate it, a layer over one `Http3Connection` whose `Http3Config.webtransportSessions` turns it on (extended CONNECT, SETTINGS_H3_DATAGRAM and the draft's two WebTransport settings): `next()` answers `WT_SESSION` (the program `accept`s or `refuse`s it), `WT_DATAGRAM`, `WT_STREAM`, `WT_STREAM_DATA`, `WT_STREAM_END`, `WT_STREAM_RESET`, `WT_STREAM_STOPPED`, `WT_WRITABLE`, `WT_DRAIN` and `WT_CLOSED`, and passes a plain request's `H3_*` events through; `sendDatagram` (the quarter stream ID, then the payload), `openStream`, `write`, `resetStream`, `stopSending`, `close` (CLOSE_WEBTRANSPORT_SESSION) and `drain`. Streams that arrive before their session wait, bounded; every cap is fixed at start-up, and a warm session allocates nothing per datagram or stream (`docs/security/webtransport.md`) | RFC 9220, RFC 9297 and draft-02: a Nish client over QUIC — sessions, datagrams at the size limits, streams both ways, close and drain from either side, every refusal — and one exchange recorded from a `wtransport` 0.7 client replayed byte for byte (`tests/link/net_webtransport`) |
240
240
  | [`net/http-compress.ts`](./net/http-compress.ts) | Content coding for HTTP responses: `httpAcceptEncoding(buf, start, end, offered)` reads an `Accept-Encoding` value (q-values, `*`, `identity`, `x-gzip`) into `HTTP_CODING_GZIP`, `HTTP_CODING_DEFLATE`, `HTTP_CODING_IDENTITY` or `HTTP_CODING_NONE`, allocating nothing; `HttpCompressor(level, minLength, maxLength)` gzips a body into its own buffer when it is worth it (-1 to send it as it is) and builds the response fields (`addFields`: `Content-Encoding: gzip` and, while `vary`, `Vary: Accept-Encoding`). On HTTP/1.1, `Http1Connection.acceptEncoding()` and `respondEncoded(status, names, values, body, off, len, enc, coding)` answer a request in one call with the compressed `Content-Length`, or nothing at all (`H1_AGAIN`, `H1_INVALID`). Opt-in; BREACH is the program's to avoid (`docs/security/compress.md`) | RFC 9110 §8.4 and §12.5.3: the negotiation table, and sans-IO responses gunzipped back (`tests/link/std_http_compress`) |
241
241
  | [`net/router.ts`](./net/router.ts) | `Router`, a method and path router that knows no carrier and allocates nothing per match: `add(method, pattern)` answers the route's index (0, 1, 2 in the order added) or a refusal — `ROUTER_BAD_PATTERN`, `ROUTER_BAD_METHOD`, `ROUTER_TOO_LONG`, `ROUTER_AMBIGUOUS` (`*` not last), `ROUTER_DUPLICATE` (the same shape and method, whatever the parameter names) or `ROUTER_FULL`, against `RouterLimits`; `match(method, mStart, mEnd, target, tStart, tEnd, params)` answers the index, `ROUTER_NOT_FOUND` or `ROUTER_METHOD_NOT_ALLOWED`, writing each `:name` and a final `*` as a `[start, end)` pair into the caller's `params` (`ROUTER_TOO_MANY_PARAMS` when it is short). Literal before parameter before `*`, segment by segment, backtracking; a lookup enters each node at most once. The query is left unparsed and nothing is decoded: `routerDecode` decodes one parameter (`ROUTER_BAD_ESCAPE`, `ROUTER_NO_ROOM`) (`docs/security/router.md`) | Every refusal code, precedence and backtracking, 405 against 404, `%2F` kept in one segment, the query, `*` and authority-form targets, a target deeper than any route, the lookup bound, params past storage, and the arena flat over matches (`tests/link/net_router`, `net_router_f64`, five `net_router_negative_*`) |
242
- | [`net/postgres.ts`](./net/postgres.ts) | `PgConnection`, a PostgreSQL client that reads no socket: the program fills `inputBuffer()` and sends `outputBuffer()`, and `next()` answers `PG_NEED_MORE`, `PG_READY`, `PG_DESCRIBED`, `PG_ROW`, `PG_COMPLETE`, `PG_ERROR`, `PG_NOTICE`, `PG_GROUP_DONE` or the final `PG_FAILED` (`failure`, one of the `PG_ERR_*` codes). Protocol 3.0: startup, `trust` and SCRAM-SHA-256 with the server's signature checked (cleartext and MD5 refused), the extended query protocol — `prepare`, `bindStart`, `paramInt` / `paramText` / `paramBytes` / `paramTextArray` / `paramIntArray` / `paramNull`, an array built element by element into the Bind (`arrayStart`, `arrayElement`, `arrayInt`, `arrayNull`, `arrayEnd`), `bindEnd`, `bindCancel` to drop a Bind not yet ended, `closeStatement`, `sync` — with up to `maxInFlight` groups pipelined. `PgBatcher` queues keyed reads of one statement shape (`addInt`, `addText`) and `take(conn)` binds up to `maxRequests` of them, each key once, as one execution over `unnest($1) with ordinality`; `row`, `end` and `fail` route its rows by their server-supplied ordinal, checked first, to `next(run)`'s per-request `PG_BATCH_ROW` and `PG_BATCH_DONE` / `PG_BATCH_MISSING` / `PG_BATCH_FAILED`; `take` answers a fresh handle each time, so a server's late or repeated event on a spent one is counted in `stray`, never routed or panicked on. It reads no clock and allocates nothing per request or row. A row's columns are windows onto the receive buffer (`colStart`, `colLength`), read in place by `int`, `bool`, `isNull` and `PgText`'s decoders (`timestamptz`, `PgArrayWalker` for `text[]`); no per-row or per-column allocation. The password is a `Secret<u8[]>`, wiped once the proof is made. Caps on message size, columns, groups in flight and SCRAM iterations (`docs/security/postgres.md`) | PostgreSQL 18's frontend/backend protocol chapter, RFC 5802 and RFC 7677: every builder, event and refusal (`tests/link/std_postgres`), and exchanges recorded from PostgreSQL 18 by `tests/postgres/record.mjs` replayed byte for byte (`tests/link/std_postgres_replay`); the batcher's queueing, caps and ordinal refusals (`tests/link/std_postgres_batch`) |
242
+ | [`net/postgres.ts`](./net/postgres.ts) | `PgConnection`, a PostgreSQL client that reads no socket: the program fills `inputBuffer()` and sends `outputBuffer()`, and `next()` answers `PG_NEED_MORE`, `PG_READY`, `PG_DESCRIBED`, `PG_ROW`, `PG_COMPLETE`, `PG_ERROR`, `PG_NOTICE`, `PG_GROUP_DONE` or the final `PG_FAILED` (`failure`, one of the `PG_ERR_*` codes). Protocol 3.0: startup, `trust` and SCRAM-SHA-256 with the server's signature checked (cleartext and MD5 refused), the extended query protocol — `prepare`, `bindStart`, `paramInt` / `paramText` / `paramBytes` / `paramTextArray` / `paramIntArray` / `paramNull`, an array built element by element into the Bind (`arrayStart`, `arrayElement`, `arrayInt`, `arrayNull`, `arrayEnd`), `bindEnd`, `bindStartBinary` for a Bind whose result columns come in binary (read in place by `binaryInt`, `binaryBool`, `binaryFloat` and `PgBinary`'s `timestamp` and `iso`), `bindCancel` to drop a Bind not yet ended, `closeStatement`, `sync` — with up to `maxInFlight` groups pipelined. `PgBatcher` queues keyed reads of one statement shape (`addInt`, `addText`) and `take(conn)` binds up to `maxRequests` of them, each key once, as one execution over `unnest($1) with ordinality`; `row`, `end` and `fail` route its rows by their server-supplied ordinal, checked first, to `next(run)`'s per-request `PG_BATCH_ROW` and `PG_BATCH_DONE` / `PG_BATCH_MISSING` / `PG_BATCH_FAILED`; `take` answers a fresh handle each time, so a server's late or repeated event on a spent one is counted in `stray`, never routed or panicked on. It reads no clock and allocates nothing per request or row. A row's columns are windows onto the receive buffer (`colStart`, `colLength`), read in place by `int`, `bool`, `isNull` and `PgText`'s decoders (`timestamptz`, `PgArrayWalker` for `text[]`); no per-row or per-column allocation. The password is a `Secret<u8[]>`, wiped once the proof is made. Caps on message size, columns, groups in flight and SCRAM iterations (`docs/security/postgres.md`) | PostgreSQL 18's frontend/backend protocol chapter, RFC 5802 and RFC 7677: every builder, event and refusal (`tests/link/std_postgres`), and exchanges recorded from PostgreSQL 18 by `tests/postgres/record.mjs` replayed byte for byte (`tests/link/std_postgres_replay`); the batcher's queueing, caps and ordinal refusals (`tests/link/std_postgres_batch`) |
243
243
 
244
244
  **How a carrier drives `TlsServer`.** The server speaks in handshake
245
245
  *messages*, tagged by level: `TLS_LEVEL_INITIAL` (cleartext — ClientHello,
@@ -496,7 +496,7 @@ have, and all three have since landed as builtins:
496
496
  | Was missing | Now |
497
497
  | --- | --- |
498
498
  | a directory listing | `readdirSync(path): string[] \| null`, sorted by bytes, because there is no `sort` for a caller to reach for |
499
- | a child's **output** — `spawnSync` answers a status and nothing else | `spawnSyncTo(argv, stdoutPath, stderrPath)`, each stream to a file, an empty path inheriting |
499
+ | a child's **output** — `spawnSync` answers a status and nothing else | `spawnSyncTo(argv, stdoutPath, stderrPath)`, each stream to a file, an empty path inheriting; `spawnPiped(argv, stdin, stdout, stderr)`, stdin from a `u8[]` and both outputs into `u8[]`s, for a secret the child reads on stdin |
500
500
  | a clock | `monotonicNanos(): i64` |
501
501
 
502
502
  So the driver exists, in the language, and it is
@@ -17,7 +17,9 @@
17
17
  * // PG_READY: authenticated; prepare statements and send queries
18
18
  * // conn.prepare("by_id", "select id, name from users where id = $1", [23]); conn.sync()
19
19
  * // conn.bindStart("by_id"); conn.paramInt(7); conn.bindEnd(); conn.sync()
20
+ * // or conn.bindStartBinary("by_id") for binary result columns
20
21
  * // PG_ROW: conn.int(0), conn.colStart(1) .. + conn.colLength(1) in conn.inputBuffer()
22
+ * // after bindStartBinary: conn.binaryInt(0), the text column as before
21
23
  * // PG_COMPLETE: conn.tagRows(); PG_ERROR: conn.lastError.code
22
24
  * // PG_GROUP_DONE: one sync()'s group has been answered
23
25
  * event = conn.next();
@@ -57,18 +59,29 @@
57
59
  * does not need: a program prepares a statement and executes it in the same
58
60
  * flight.
59
61
  *
60
- * **Text results, no copies.** Every parameter and every result column is in
61
- * text format. A row is read where it lies in the input buffer: `next()` frames
62
- * each message once, validates its layout, and records each column's start and
63
- * length in two reused arrays, so a row costs no allocation. `int`, `bool`,
64
- * `isNull` and `PgText.timestamp` decode a column in place; `PgArrayWalker`
65
- * walks a one-dimensional `text[]` in place. `columnText(c)` is the one that
66
- * allocates, a string. An array parameter is a text array literal too:
67
- * `paramIntArray` writes an `int8[]` from an `i64[]` window, and `arrayStart`,
68
- * `arrayElement`, `arrayInt`, `arrayNull` and `arrayEnd` build an array one
69
- * element at a time into the Bind, so a list needs no string built first. A
70
- * program whose element was refused for room can drop the whole bind,
71
- * unsent, with `bindCancel`.
62
+ * **Text results, no copies.** Every parameter is in text format, and so is
63
+ * every result column unless the bind asks otherwise. A row is read where it
64
+ * lies in the input buffer: `next()` frames each message once, validates its
65
+ * layout, and records each column's start and length in two reused arrays, so
66
+ * a row costs no allocation. `int`, `bool`, `isNull` and `PgText.timestamp`
67
+ * decode a column in place; `PgArrayWalker` walks a one-dimensional `text[]`
68
+ * in place. `columnText(c)` is the one that allocates, a string. An array
69
+ * parameter is a text array literal too: `paramIntArray` writes an `int8[]`
70
+ * from an `i64[]` window, and `arrayStart`, `arrayElement`, `arrayInt`,
71
+ * `arrayNull` and `arrayEnd` build an array one element at a time into the
72
+ * Bind, so a list needs no string built first. A program whose element was
73
+ * refused for room can drop the whole bind, unsent, with `bindCancel`.
74
+ *
75
+ * **Binary results.** A bind opened with `bindStartBinary` instead of
76
+ * `bindStart` asks for every result column in binary format: an `int8` is
77
+ * eight bytes, a `timestamptz` eight bytes of microseconds since 2000, a
78
+ * `bool` one byte, so there is nothing to parse and less on the wire.
79
+ * `binaryInt`, `binaryBool` and `binaryFloat` read them in place, and
80
+ * `PgBinary`'s `timestamp` and `iso` write a time as `PgText.timestamp` does.
81
+ * A `text` column is the same bytes in either format, so `columnText` and the
82
+ * column windows read it unchanged. The program knows which of its binds
83
+ * asked for which, and reads each execution's rows with the matching
84
+ * decoders.
72
85
  *
73
86
  * **Authentication.** SCRAM-SHA-256 (RFC 5802, RFC 7677) or none (`trust`).
74
87
  * The password arrives as a `Secret<u8[]>`; the connection keeps a copy in a
@@ -100,8 +113,9 @@
100
113
  * that needs a socket, `connect(slot, nonce)` starts its connection, and the
101
114
  * program moves its bytes as above through `connection(slot)`. A request is a
102
115
  * ticket from `request()`; `dispatch()` gives the oldest to the ready
103
- * connection with the fewest groups in flight, the program builds its group and
104
- * ends it with `send()`, and `next(slot)` reads that connection's events with
116
+ * connection with the fewest groups in flight (or, with `PgConfig.pack`, to the
117
+ * first ready one with room, so a burst fills few connections in fuller
118
+ * writes), the program builds its group and ends it with `send()`, and `next(slot)` reads that connection's events with
105
119
  * `ticket` naming the request. Statements given to `register` are prepared on
106
120
  * each connection ahead of its first request. A connection that fails reports
107
121
  * `PG_LOST` for each request it took, then `PG_FAILED`; the program closes the
@@ -127,7 +141,7 @@
127
141
  * Written from PostgreSQL's "Frontend/Backend Protocol" chapter (version 18)
128
142
  * and RFC 5802 and RFC 7677; not ported from another implementation.
129
143
  */
130
- import { copyBytes, getI32BE, getU16BE, setI32BE, setU16BE } from "nish/bytes"
144
+ import { copyBytes, getF64BE, getI32BE, getI64BE, getU16BE, setI32BE, setU16BE } from "nish/bytes"
131
145
  import { base64StdPadDecode, base64StdPadEncode } from "nish/crypto/base64url"
132
146
  import { timingSafeEqual } from "nish/crypto/ct"
133
147
  import { hmacSha256, pbkdf2HmacSha256 } from "nish/crypto/hmac"
@@ -218,6 +232,11 @@ const PG_NONCE_BYTES: i32 = 18
218
232
  // take: kept free under `maxOutput` by every other builder call, so that
219
233
  // `bindEnd()` and `sync()` are never refused for room.
220
234
  const PG_TAIL: i32 = 17
235
+ // What a binary bind's end takes beyond `PG_TAIL`: its one result format code,
236
+ // kept free from `bindStartBinary` on.
237
+ const PG_BINARY_TAIL: i32 = 2
238
+ // The result format code for binary.
239
+ const PG_FORMAT_BINARY: i32 = 1
221
240
  // The largest `maxMessage` and `maxOutput`. The input buffer doubles up to one
222
241
  // past them; the output buffer doubles only while twice its size fits in an i32.
223
242
  const PG_MAX_CAP: i32 = 1073741823
@@ -238,6 +257,15 @@ export class PgConfig {
238
257
  maxMessage: i32 = 1048576
239
258
  /** The most groups waiting for their answers at once. */
240
259
  maxInFlight: i32 = 16
260
+ /**
261
+ * Read by `PgPool` only: whether `dispatch()` packs requests onto the
262
+ * first ready connection with room, filling it to `maxInFlight` before the
263
+ * next, rather than spreading them over every connection, fewest in flight
264
+ * first. Fewer connections in use send fuller writes, fewer packets, which
265
+ * pays on a lossy link to the server; a connection is still wanted only once
266
+ * every ready one is full.
267
+ */
268
+ pack: boolean = false
241
269
  /** The input buffer's first size, in bytes; it grows to a message's length, up to `maxMessage` + 1. */
242
270
  inputSize: i32 = 8192
243
271
  /** The widest row; past it, `PG_ERR_TOO_BIG`. PostgreSQL's own limit is 1664. */
@@ -677,6 +705,159 @@ export class PgText {
677
705
  }
678
706
  }
679
707
 
708
+ // The days from 0000-03-01, where the civil calendar's year starts for the
709
+ // conversion below, to 2000-01-01, where PostgreSQL's binary times count from.
710
+ const PG_EPOCH_DAYS: i64 = 730425
711
+ // The first and last days `PgBinary.iso` writes, counted from 2000-01-01:
712
+ // 0001-01-01 and 9999-12-31, the years four digits spell.
713
+ const PG_FIRST_DAY: i64 = -730119
714
+ const PG_LAST_DAY: i64 = 2921939
715
+
716
+ /** `value`'s last `width` decimal digits into `out[at, at + width)`. */
717
+ const pgDigits = (out: u8[], at: i32, value: i32, width: i32): void => {
718
+ let v: i32 = value
719
+ for (let i: i32 = width - 1; i >= 0; i--) {
720
+ out[at + i] = toU8((v % 10) + 48)
721
+ v = v / 10
722
+ }
723
+ }
724
+
725
+ /**
726
+ * Decoders for PostgreSQL's binary format, in place, for the rows of a bind
727
+ * that `bindStartBinary` opened; a bad input sets `failed`, which stays set.
728
+ * A `text` or `varchar` column's bytes are the same in either format, so
729
+ * `columnText`, `colStart` and `colLength` read it as they read a text one.
730
+ */
731
+ export class PgBinary {
732
+ /** Whether a decode since the last `reset()` was given something it could not read. */
733
+ failed: boolean = false
734
+
735
+ /** Clears `failed`. */
736
+ reset(): void {
737
+ this.failed = false
738
+ }
739
+
740
+ // Whether `[start, start + length)` is inside `b`; sets `failed` when not.
741
+ private inside(b: u8[], start: i32, length: i32): boolean {
742
+ if (start < 0 || length < 0 || start > toI32(b.length) - length) {
743
+ this.failed = true
744
+ return false
745
+ }
746
+ return true
747
+ }
748
+
749
+ /**
750
+ * An `int2`, `int4` or `int8` column, told apart by its length (2, 4 or 8
751
+ * bytes, big-endian two's complement); 0 and `failed` for any other length,
752
+ * NULL's -1 among them. A `timestamptz` or `timestamp` reads as an `int8`:
753
+ * microseconds since 2000-01-01 00:00:00 UTC, which `iso` writes out.
754
+ */
755
+ int(b: u8[], start: i32, length: i32): i64 {
756
+ if ((length !== 2 && length !== 4 && length !== 8) || !this.inside(b, start, length)) {
757
+ this.failed = true
758
+ return 0
759
+ }
760
+ if (length === 8) {
761
+ return getI64BE(b, start)
762
+ }
763
+ if (length === 4) {
764
+ return toI64(getI32BE(b, start))
765
+ }
766
+ const v: i32 = toI32(getU16BE(b, start))
767
+ return toI64(v >= 32768 ? v - 65536 : v)
768
+ }
769
+
770
+ /** A `bool` column: one byte, 1 or 0; false and `failed` otherwise. */
771
+ bool(b: u8[], start: i32, length: i32): boolean {
772
+ if (length === 1 && this.inside(b, start, length)) {
773
+ const c: i32 = toI32(b[start])
774
+ if (c === 1) {
775
+ return true
776
+ }
777
+ if (c === 0) {
778
+ return false
779
+ }
780
+ }
781
+ this.failed = true
782
+ return false
783
+ }
784
+
785
+ /** A `float8` column: eight bytes, IEEE 754 big-endian; 0 and `failed` otherwise. */
786
+ float(b: u8[], start: i32, length: i32): f64 {
787
+ if (length !== 8 || !this.inside(b, start, length)) {
788
+ this.failed = true
789
+ return 0
790
+ }
791
+ return getF64BE(b, start)
792
+ }
793
+
794
+ /**
795
+ * A `timestamptz` or `timestamp` column, written to `out[at, at + 24)` as
796
+ * `iso` writes it. Answers 24, or -1 and `failed` for a column that is not
797
+ * eight bytes or a time `iso` refuses.
798
+ */
799
+ timestamp(b: u8[], start: i32, length: i32, out: u8[], at: i32): i32 {
800
+ if (length !== 8 || !this.inside(b, start, length)) {
801
+ this.failed = true
802
+ return -1
803
+ }
804
+ return this.iso(getI64BE(b, start), out, at)
805
+ }
806
+
807
+ /**
808
+ * `micros`, microseconds since 2000-01-01 00:00:00 UTC, written to
809
+ * `out[at, at + 24)` as `2024-01-02T03:04:05.123Z`: the microseconds cut,
810
+ * not rounded, to milliseconds, as `PgText.timestamp` cuts the same time.
811
+ * Answers 24, or -1 and `failed` with `out` untouched for a year outside
812
+ * 0001–9999 (`infinity` and `-infinity` among them) or no room in `out`.
813
+ */
814
+ iso(micros: i64, out: u8[], at: i32): i32 {
815
+ if (at < 0 || at > toI32(out.length) - 24) {
816
+ this.failed = true
817
+ return -1
818
+ }
819
+ // Floored, so that a time before 2000 keeps its day and is cut toward the past.
820
+ const dayMicros: i64 = toI64(86400000) * 1000
821
+ let days: i64 = micros / dayMicros
822
+ let rest: i64 = micros % dayMicros
823
+ if (rest < 0) {
824
+ days -= 1
825
+ rest += dayMicros
826
+ }
827
+ if (days < PG_FIRST_DAY || days > PG_LAST_DAY) {
828
+ this.failed = true
829
+ return -1
830
+ }
831
+ // The civil date of a day count: eras of 400 years (146097 days), each
832
+ // year starting on 1 March so that a leap day is its last.
833
+ const z: i64 = days + PG_EPOCH_DAYS
834
+ const era: i64 = z / 146097
835
+ const doe: i64 = z - era * 146097
836
+ const yoe: i64 = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365
837
+ const doy: i64 = doe - (365 * yoe + yoe / 4 - yoe / 100)
838
+ const mp: i64 = (5 * doy + 2) / 153
839
+ const day: i64 = doy - (153 * mp + 2) / 5 + 1
840
+ const month: i64 = mp < 10 ? mp + 3 : mp - 9
841
+ const year: i64 = yoe + era * 400 + (month <= 2 ? 1 : 0)
842
+ const millis: i32 = toI32(rest / 1000)
843
+ pgDigits(out, at, toI32(year), 4)
844
+ out[at + 4] = toU8(45)
845
+ pgDigits(out, at + 5, toI32(month), 2)
846
+ out[at + 7] = toU8(45)
847
+ pgDigits(out, at + 8, toI32(day), 2)
848
+ out[at + 10] = toU8(84)
849
+ pgDigits(out, at + 11, millis / 3600000, 2)
850
+ out[at + 13] = toU8(58)
851
+ pgDigits(out, at + 14, (millis / 60000) % 60, 2)
852
+ out[at + 16] = toU8(58)
853
+ pgDigits(out, at + 17, (millis / 1000) % 60, 2)
854
+ out[at + 19] = toU8(46)
855
+ pgDigits(out, at + 20, millis % 1000, 3)
856
+ out[at + 23] = toU8(90)
857
+ return 24
858
+ }
859
+ }
860
+
680
861
  /**
681
862
  * A walk over a one-dimensional `text[]` column in place: `start` the
682
863
  * column, then `next()` until `PG_ARRAY_END` or `PG_ARRAY_BAD`. Each element
@@ -842,10 +1023,11 @@ export class PgConnection {
842
1023
  lastNotice: PgError | null = null
843
1024
  /** The decoders `int`, `bool` and `isNull` use; `text.failed` says whether one was given a column it could not read. */
844
1025
  readonly text: PgText
1026
+ /** The decoders `binaryInt`, `binaryBool` and `binaryFloat` use, for a bind `bindStartBinary` opened; `binary.failed` as `text.failed`. */
1027
+ readonly binary: PgBinary
845
1028
 
846
1029
  private readonly config: PgConfig
847
1030
  private readonly password: PgPassword
848
- private state: i32 = 0
849
1031
  private input: u8[]
850
1032
  private inStart: i32 = 0
851
1033
  private inEnd: i32 = 0
@@ -863,12 +1045,15 @@ export class PgConnection {
863
1045
  groupFailed: boolean = false
864
1046
  // Whether the open bind opened its group, which `bindCancel` closes again.
865
1047
  private bindOpened: boolean = false
1048
+ // Whether the open bind asks for its result columns in binary.
1049
+ private bindBinary: boolean = false
866
1050
  private bindAt: i32 = -1
867
1051
  private paramCountAt: i32 = 0
868
1052
  private paramCount: i32 = 0
869
1053
  // The open array parameter's length word, -1 when none is open, and its elements so far.
870
1054
  private arrayAt: i32 = -1
871
1055
  private arrayCount: i32 = 0
1056
+ private state: i32 = 0
872
1057
  private clientNonce: string = ""
873
1058
  private serverSignature: u8[]
874
1059
 
@@ -885,6 +1070,7 @@ export class PgConnection {
885
1070
  pgCheckConfig(config)
886
1071
  this.config = config
887
1072
  this.text = new PgText()
1073
+ this.binary = new PgBinary()
888
1074
  this.password = new PgPassword(password)
889
1075
  const inputSize: i32 = config.inputSize < 64 ? 64 : config.inputSize
890
1076
  this.input = new Array<u8>(inputSize)
@@ -1574,7 +1760,8 @@ export class PgConnection {
1574
1760
  */
1575
1761
  private room(size: i32): boolean {
1576
1762
  const queued: i32 = this.outEnd - this.outStart
1577
- if (size < 0 || size > this.config.maxOutput - PG_TAIL - queued) {
1763
+ const tail: i32 = this.bindAt >= 0 && this.bindBinary ? PG_TAIL + PG_BINARY_TAIL : PG_TAIL
1764
+ if (size < 0 || size > this.config.maxOutput - tail - queued) {
1578
1765
  return false
1579
1766
  }
1580
1767
  if (this.outStart > 0 && this.outEnd + size > toI32(this.out.length)) {
@@ -1740,11 +1927,31 @@ export class PgConnection {
1740
1927
  * order, then `bindEnd()`. False as `prepare` is.
1741
1928
  */
1742
1929
  bindStart(name: string): boolean {
1930
+ return this.openBind(name, false)
1931
+ }
1932
+
1933
+ /**
1934
+ * `bindStart`, asking for every result column of the execution in binary
1935
+ * format rather than text: fixed-size integers, booleans, floats and
1936
+ * times, read in place by `binaryInt`, `binaryBool`, `binaryFloat` and
1937
+ * `binary.timestamp`. A `text` column's bytes are the same in either
1938
+ * format, so `columnText` and the column windows read it as before; a
1939
+ * `text[]` or `numeric` column comes in PostgreSQL's binary layout, which
1940
+ * no decoder here reads. Its `bindEnd()` writes two bytes more, kept free
1941
+ * under `maxOutput` from here on. False as `prepare` is.
1942
+ */
1943
+ bindStartBinary(name: string): boolean {
1944
+ return this.openBind(name, true)
1945
+ }
1946
+
1947
+ private openBind(name: string, binary: boolean): boolean {
1743
1948
  const opens: boolean = !this.groupOpen
1744
- if (pgHasNul(name) || toI32(name.length) > PG_MAX_CAP || !this.canOpen(11 + toI32(name.length))) {
1949
+ const extra: i32 = binary ? PG_BINARY_TAIL : 0
1950
+ if (pgHasNul(name) || toI32(name.length) > PG_MAX_CAP || !this.canOpen(11 + extra + toI32(name.length))) {
1745
1951
  return false
1746
1952
  }
1747
1953
  this.bindOpened = opens
1954
+ this.bindBinary = binary
1748
1955
  this.bindAt = this.startMessage(PG_FE_BIND)
1749
1956
  this.putByte(PG_ZERO)
1750
1957
  this.putCString(name)
@@ -2058,9 +2265,10 @@ export class PgConnection {
2058
2265
  }
2059
2266
 
2060
2267
  /**
2061
- * Ends the Bind (every result column in text) and queues its Execute, for
2062
- * every row. False, and nothing written, when no bind is open; never for
2063
- * room, which the calls before it kept.
2268
+ * Ends the Bind (every result column in text, or in binary after
2269
+ * `bindStartBinary`) and queues its Execute, for every row. False, and
2270
+ * nothing written, when no bind is open; never for room, which the calls
2271
+ * before it kept.
2064
2272
  */
2065
2273
  bindEnd(): boolean {
2066
2274
  if (this.bindAt < 0) {
@@ -2069,10 +2277,17 @@ export class PgConnection {
2069
2277
  if (this.arrayAt >= 0) {
2070
2278
  panic("nish/net/postgres: bindEnd() while an array is open")
2071
2279
  }
2072
- this.tailRoom(PG_TAIL)
2280
+ const binary: boolean = this.bindBinary
2281
+ this.tailRoom(binary ? PG_TAIL + PG_BINARY_TAIL : PG_TAIL)
2073
2282
  const at: i32 = this.bindAt
2074
2283
  setU16BE(this.out, this.paramCountAt, toU16(this.paramCount))
2075
- this.putI16(PG_ZERO)
2284
+ if (binary) {
2285
+ // One format code, which the server applies to every column.
2286
+ this.putI16(toI32(1))
2287
+ this.putI16(PG_FORMAT_BINARY)
2288
+ } else {
2289
+ this.putI16(PG_ZERO)
2290
+ }
2076
2291
  this.endMessage(at)
2077
2292
  this.bindAt = -1
2078
2293
  const execute: i32 = this.startMessage(PG_FE_EXECUTE)
@@ -2170,6 +2385,21 @@ export class PgConnection {
2170
2385
  return this.text.bool(this.input, this.colStarts[c], this.colLengths[c])
2171
2386
  }
2172
2387
 
2388
+ /** Binary column `c`, an `int2`, `int4`, `int8` or a time's microseconds, as an integer; NULL or another length sets `binary.failed`. */
2389
+ binaryInt(c: i32): i64 {
2390
+ return this.binary.int(this.input, this.colStarts[c], this.colLengths[c])
2391
+ }
2392
+
2393
+ /** Binary column `c` as a boolean; NULL or a bad value sets `binary.failed`. */
2394
+ binaryBool(c: i32): boolean {
2395
+ return this.binary.bool(this.input, this.colStarts[c], this.colLengths[c])
2396
+ }
2397
+
2398
+ /** Binary column `c`, a `float8`, as an f64; NULL or another length sets `binary.failed`. */
2399
+ binaryFloat(c: i32): f64 {
2400
+ return this.binary.float(this.input, this.colStarts[c], this.colLengths[c])
2401
+ }
2402
+
2173
2403
  /** Whether the tag of the execution just completed starts with the word `word` (`INSERT`, `SELECT`, ...). */
2174
2404
  tagIs(word: string): boolean {
2175
2405
  const n: i32 = toI32(word.length)
@@ -2266,7 +2496,8 @@ class PgPoolSlot {
2266
2496
  *
2267
2497
  * A request is a ticket from `request()`, queued in FIFO order. `dispatch()`
2268
2498
  * hands the queue's head to the ready connection with the fewest groups in
2269
- * flight, never past `maxInFlight` on one; the program builds the request's
2499
+ * flight, or under `PgConfig.pack` the lowest-numbered ready one with room,
2500
+ * never past `maxInFlight` on one; the program builds the request's
2270
2501
  * group on `connection(slot)` and ends it with `send()`, not `sync()`.
2271
2502
  * `next(slot)` then reads that connection's events, `ticket` naming the
2272
2503
  * request each is about. A connection that fails reports `PG_LOST` for each
@@ -2367,7 +2598,8 @@ export class PgPool {
2367
2598
  }
2368
2599
 
2369
2600
  /**
2370
- * Hands the oldest waiting request to a ready connection with room, and
2601
+ * Hands the oldest waiting request to a ready connection with room (the
2602
+ * one with the fewest in flight, or under `PgConfig.pack` the first), and
2371
2603
  * answers its slot, `ticket` set to the request: build its group on
2372
2604
  * `connection(slot)`, then `send()`. `PG_POOL_NONE` when nothing waits, a
2373
2605
  * request is still being built, or no ready connection has room; then a
@@ -2381,6 +2613,7 @@ export class PgPool {
2381
2613
  return PG_POOL_NONE
2382
2614
  }
2383
2615
  const cap: i32 = this.config.maxInFlight
2616
+ const pack: boolean = this.config.pack
2384
2617
  let best: i32 = -1
2385
2618
  let load: i32 = 0
2386
2619
  let starting: i32 = 0
@@ -2389,7 +2622,7 @@ export class PgPool {
2389
2622
  const slot: PgPoolSlot = this.slots[s]
2390
2623
  const conn: PgConnection | null = slot.conn
2391
2624
  if (slot.state === PG_SLOT_READY && conn !== null) {
2392
- if (conn.inFlight < cap && (best < 0 || conn.inFlight < load)) {
2625
+ if (conn.inFlight < cap && (best < 0 || (!pack && conn.inFlight < load))) {
2393
2626
  best = s
2394
2627
  load = conn.inFlight
2395
2628
  }