@volter/tabnode 0.3.0 → 0.4.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.
@@ -45,7 +45,7 @@ export declare class FSEvent {
45
45
  #private;
46
46
  onchange: ((status: number, event: string, filename: string) => void) | null;
47
47
  initialized: boolean;
48
- start(path: unknown, _persistent?: boolean, recursive?: boolean, _encoding?: string): number;
48
+ start(path: unknown, persistent?: boolean, recursive?: boolean, _encoding?: string): number;
49
49
  close(): void;
50
50
  ref(): void;
51
51
  unref(): void;
@@ -95,11 +95,11 @@ export declare const fsBinding: {
95
95
  read(fd: number, buffer: Uint8Array, offset: number, length: number, position: number, req?: FSReq): number | undefined;
96
96
  readBuffers(fd: number, buffers: Uint8Array[], position: number, req?: FSReq): number | undefined;
97
97
  /** Node's fast path for `readFile` with an encoding it can decode itself. */
98
- readFileUtf8(path: unknown, _flags: number): string;
98
+ readFileUtf8(path: unknown, flags: number): string;
99
99
  writeBuffer(fd: number, buffer: Uint8Array, offset: number, length: number, position: number | null, req?: FSReq): number | undefined;
100
100
  writeBuffers(fd: number, buffers: Uint8Array[], position: number | null, req?: FSReq): number | undefined;
101
101
  writeString(fd: number, value: string, position: number | null, encoding: string | undefined, req?: FSReq): number | undefined;
102
- writeFileUtf8(path: unknown, data: string, flags: number, _mode: number): undefined;
102
+ writeFileUtf8(path: unknown, data: string, flags: number, mode: number): undefined;
103
103
  stat(path: unknown, bigint: boolean, req?: FSReq, throwIfNoEntry?: boolean): Float64Array | BigInt64Array | undefined;
104
104
  lstat(path: unknown, bigint: boolean, req?: FSReq, throwIfNoEntry?: boolean): Float64Array | BigInt64Array | undefined;
105
105
  fstat(fd: number, bigint: boolean, req?: FSReq): Float64Array | BigInt64Array | undefined;
@@ -109,7 +109,7 @@ export declare const fsBinding: {
109
109
  * a disk: a block size, and a count no program can exhaust.
110
110
  */
111
111
  statfs(_path: unknown, bigint: boolean, req?: FSReq): Float64Array | BigInt64Array | undefined;
112
- access(path: unknown, _mode: number, req?: FSReq): undefined;
112
+ access(path: unknown, mode: number, req?: FSReq): undefined;
113
113
  existsSync(path: unknown): boolean;
114
114
  /**
115
115
  * Node's `cpSync` asks this before it copies: a directory is not a file,
@@ -143,12 +143,12 @@ export declare const fsBinding: {
143
143
  mkdtemp(prefix: unknown, encoding: string, req?: FSReq): string | Uint8Array | undefined;
144
144
  chmod(path: unknown, mode: number, req?: FSReq): undefined;
145
145
  fchmod(fd: number, mode: number, req?: FSReq): undefined;
146
- chown(path: unknown, _uid: number, _gid: number, req?: FSReq): undefined;
146
+ chown(_path: unknown, _uid: number, _gid: number, req?: FSReq): undefined;
147
147
  fchown(_fd: number, _uid: number, _gid: number, req?: FSReq): undefined;
148
148
  lchown(_path: unknown, _uid: number, _gid: number, req?: FSReq): undefined;
149
- utimes(_path: unknown, _atime: number, _mtime: number, req?: FSReq): undefined;
150
- futimes(_fd: number, _atime: number, _mtime: number, req?: FSReq): undefined;
151
- lutimes(_path: unknown, _atime: number, _mtime: number, req?: FSReq): undefined;
149
+ utimes(path: unknown, atime: number, mtime: number, req?: FSReq): undefined;
150
+ futimes(fd: number, atime: number, mtime: number, req?: FSReq): undefined;
151
+ lutimes(path: unknown, atime: number, mtime: number, req?: FSReq): undefined;
152
152
  fsync(_fd: number, req?: FSReq): undefined;
153
153
  fdatasync(_fd: number, req?: FSReq): undefined;
154
154
  ftruncate(fd: number, length: number, req?: FSReq): undefined;
@@ -65,6 +65,7 @@ export declare class ServerBridge extends EventEmitter {
65
65
  * news comes from the net binding, because that is where a port is taken;
66
66
  * the engine's `http` is Node's own file and knows nothing of a page.
67
67
  */
68
+ private readonly guestServers;
68
69
  private watchGuestPorts;
69
70
  close(): void;
70
71
  /**
@@ -32,6 +32,12 @@ type RunStdin = {
32
32
  * output went down the child's channel.
33
33
  */
34
34
  export interface RunStreams {
35
+ stdinStream?: AsyncIterable<Uint8Array>;
36
+ terminal?: {
37
+ columns: number;
38
+ rows: number;
39
+ onResize?: (listener: (columns: number, rows: number) => void) => () => void;
40
+ };
35
41
  onStdout?: (data: string) => void;
36
42
  onStderr?: (data: string) => void;
37
43
  signal?: AbortSignal;
@@ -28,6 +28,7 @@ export interface FSNode {
28
28
  target?: string;
29
29
  children?: Map<string, FSNode>;
30
30
  mtime: number;
31
+ mode?: number;
31
32
  /**
32
33
  * When the file was last read. Node reports it apart from the write time
33
34
  * and `utimes` sets the two separately; this filesystem reported the write
@@ -154,6 +155,8 @@ export declare class VirtualFS {
154
155
  /**
155
156
  * Get stats for path
156
157
  */
158
+ chmodSync(path: string, mode: number): void;
159
+ utimesSync(path: string, atime: number | Date, mtime: number | Date): void;
157
160
  statSync(path: string): Stats;
158
161
  /**
159
162
  * lstatSync - same as statSync for our virtual FS (no symlinks)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/tabnode",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Node's own library, in a browser tab.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -68,6 +68,8 @@ export interface RunOptions {
68
68
  env?: Record<string, string>;
69
69
  /** What the shell reads on stdin, so a builtin reads what was piped to it. */
70
70
  stdin?: string;
71
+ stdinStream?: AsyncIterable<Uint8Array>;
72
+ terminal?: { columns: number; rows: number; onResize?: (listener: (columns: number, rows: number) => void) => () => void };
71
73
  /** Callback for streaming stdout chunks as they arrive (for long-running commands like vitest watch) */
72
74
  onStdout?: (data: string) => void;
73
75
  /** Callback for streaming stderr chunks as they arrive */
@@ -168,6 +170,9 @@ export function createContainer(options?: ContainerOptions): {
168
170
  onStderr: runOptions?.onStderr,
169
171
  signal: runOptions?.signal,
170
172
  held: runOptions?.held === true,
173
+ stdinStream: runOptions?.stdinStream,
174
+ stdinOpen: runOptions?.stdinStream !== undefined,
175
+ terminal: runOptions?.terminal,
171
176
  });
172
177
 
173
178
  return new Promise((resolve) => {
@@ -180,7 +185,7 @@ export function createContainer(options?: ContainerOptions): {
180
185
  resolve({
181
186
  stdout: String(stdout),
182
187
  stderr: String(stderr),
183
- exitCode: error ? (error.code ?? 1) : 0,
188
+ exitCode: runOptions?.signal?.aborted ? 143 : error ? (error.code ?? 1) : 0,
184
189
  });
185
190
  });
186
191
  });
@@ -28,6 +28,8 @@
28
28
  * own tree.
29
29
  */
30
30
  import { createNodeError as vfsError } from '../../virtual-fs';
31
+ import { registerHandle, releaseHandle, refHandle, unrefHandle, handleHasRef, currentOwner } from './handles';
32
+ import { enterRun } from '../../process-tokens';
31
33
 
32
34
  /**
33
35
  * An errno error, as the filesystem makes one. The tree's own maker knows the
@@ -51,8 +53,10 @@ function refuseLinks(syscall: string, path: string): never {
51
53
  );
52
54
  }
53
55
 
54
- /** Modes `chmod` has set, keyed by the path Node handed the binding. */
55
- const pathModes = new Map<string, number>();
56
+ function unsupportedMetadata(syscall: string): never {
57
+ throw Object.assign(new Error(`ENOTSUP: filesystem does not support ${syscall}`), { code: 'ENOTSUP', syscall });
58
+ }
59
+
56
60
 
57
61
  function treeHoldsLinks(): boolean {
58
62
  return (vfs() as VirtualFS & { holdsLinks?: boolean }).holdsLinks === true;
@@ -173,11 +177,10 @@ function statArray(stats: VfsStats, bigint: boolean, path?: string): Float64Arra
173
177
  // The tree keeps permissions but not the type bits; `Stats.isFile()` is
174
178
  // `mode & S_IFMT`, so the type the tree does know is put where Node looks.
175
179
  // `chmod` stores what it set; a path it has not touched keeps the tree's.
176
- const stored = path !== undefined ? pathModes.get(path) : undefined;
177
- const permissions = (stored !== undefined ? stored : Number(stats.mode ?? 0)) & 0o7777;
180
+ const permissions = Number(stats.mode ?? (stats.isDirectory?.() ? 0o755 : 0o644)) & 0o7777;
178
181
  const type = stats.isDirectory?.() ? S_IFDIR : stats.isSymbolicLink?.() ? S_IFLNK : S_IFREG;
179
182
  const values = [
180
- Number(stats.dev ?? 0), type | (permissions || (type === S_IFDIR ? 0o755 : 0o644)), Number(stats.nlink ?? 1),
183
+ Number(stats.dev ?? 0), type | permissions, Number(stats.nlink ?? 1),
181
184
  Number(stats.uid ?? 0), Number(stats.gid ?? 0), Number(stats.rdev ?? 0),
182
185
  Number(stats.blksize ?? 4096), Number(stats.ino ?? 0), Number(stats.size ?? 0),
183
186
  Number(stats.blocks ?? Math.ceil(Number(stats.size ?? 0) / 512)),
@@ -366,16 +369,23 @@ export class FSEvent {
366
369
  #watcher: { close(): void } | null = null;
367
370
  initialized = false;
368
371
 
369
- start(path: unknown, _persistent?: boolean, recursive?: boolean, _encoding?: string): number {
372
+ start(path: unknown, persistent?: boolean, recursive?: boolean, _encoding?: string): number {
370
373
  const name = asPath(path);
371
374
  const tree = vfs();
375
+ const cwd = callingCwd();
376
+ const owner = currentOwner();
372
377
  try {
373
378
  this.#watcher = tree.watch(name, { recursive: Boolean(recursive) }, ((event: string, filename: string | null) => {
374
379
  // libuv names a change `change` and a create or a remove `rename`,
375
380
  // which is what Node's `FSWatcher` turns into its own two events.
376
- this.onchange?.(0, event === 'rename' ? 'rename' : 'change', filename ?? '');
381
+ const notify = () => withFilesystem(tree, () => withCwd(cwd, () =>
382
+ this.onchange?.(0, event === 'rename' ? 'rename' : 'change', filename ?? '')));
383
+ if (owner === null) notify();
384
+ else enterRun(owner, notify);
377
385
  }) as never) as unknown as { close(): void };
378
386
  this.initialized = true;
387
+ registerHandle(this);
388
+ if (persistent === false) unrefHandle(this);
379
389
  return 0;
380
390
  } catch (error) {
381
391
  const code = (error as { code?: string }).code;
@@ -384,15 +394,16 @@ export class FSEvent {
384
394
  }
385
395
 
386
396
  close(): void {
397
+ releaseHandle(this);
387
398
  this.initialized = false;
388
399
  try { this.#watcher?.close(); } catch { /* a watcher already closed is closed */ }
389
400
  this.#watcher = null;
390
401
  }
391
402
 
392
- ref(): void {}
393
- unref(): void {}
403
+ ref(): void { if (this.initialized) refHandle(this); }
404
+ unref(): void { unrefHandle(this); }
394
405
  getAsyncId(): number { return 0; }
395
- hasRef(): boolean { return this.initialized; }
406
+ hasRef(): boolean { return this.initialized && handleHasRef(this); }
396
407
  }
397
408
 
398
409
  /**
@@ -466,9 +477,15 @@ export const fsBinding = {
466
477
  const tree = vfs();
467
478
  const bits = flagBits();
468
479
  const exists = tree.existsSync(name);
480
+ if (exists && ((flags & 3) !== 0 || (flags & (bits.create | bits.truncate | bits.append)) !== 0)) {
481
+ tree.accessSync(name, 2);
482
+ }
469
483
  if ((flags & bits.excl) !== 0 && (flags & bits.create) !== 0 && exists) throw createNodeError('EEXIST', 'open', name);
470
484
  if (!exists) {
471
485
  if ((flags & bits.create) === 0) throw createNodeError('ENOENT', 'open', name);
486
+ // VirtualFS writes create parents for image loading; open(2) does not.
487
+ const parent = name.slice(0, name.lastIndexOf('/')) || '/';
488
+ if (!tree.statSync(parent).isDirectory()) throw createNodeError('ENOTDIR', 'open', name);
472
489
  tree.writeFileSync(name, '');
473
490
  } else if ((flags & bits.truncate) !== 0) {
474
491
  tree.writeFileSync(name, '');
@@ -481,13 +498,14 @@ export const fsBinding = {
481
498
  },
482
499
 
483
500
  close(fd: number, req?: FSReq): undefined {
484
- return answer(req, () => { openFiles.delete(fd); return undefined; });
501
+ return answer(req, () => { fileFor(fd); openFiles.delete(fd); return undefined; });
485
502
  },
486
503
 
487
504
  // ---- reading ------------------------------------------------------------
488
505
  read(fd: number, buffer: Uint8Array, offset: number, length: number, position: number, req?: FSReq): number | undefined {
489
506
  return answer(req, () => {
490
507
  const file = fileFor(fd);
508
+ if ((file.flags & 3) === 1) throw createNodeError('EBADF', 'read', file.path);
491
509
  if (file.cached === undefined) file.cached = file.tree.readFileSync(file.path) as Uint8Array;
492
510
  const bytes = file.cached;
493
511
  const from = position === null || position === undefined || position < 0 ? file.position : position;
@@ -512,18 +530,27 @@ export const fsBinding = {
512
530
  },
513
531
 
514
532
  /** Node's fast path for `readFile` with an encoding it can decode itself. */
515
- readFileUtf8(path: unknown, _flags: number): string {
516
- return vfs().readFileSync(asPath(path), 'utf8') as string;
533
+ readFileUtf8(path: unknown, flags: number): string {
534
+ const owned = typeof path !== 'number';
535
+ const fd = owned ? fsBinding.open(path, flags, 0o666) as number : path as number;
536
+ try {
537
+ const file = fileFor(fd);
538
+ const bytes = new Uint8Array(file.tree.statSync(file.path).size);
539
+ const length = fsBinding.read(fd, bytes, 0, bytes.length, -1) as number;
540
+ return new TextDecoder().decode(bytes.subarray(0, length));
541
+ } finally { if (owned) fsBinding.close(fd); }
517
542
  },
518
543
 
519
544
  // ---- writing ------------------------------------------------------------
520
545
  writeBuffer(fd: number, buffer: Uint8Array, offset: number, length: number, position: number | null, req?: FSReq): number | undefined {
521
546
  return answer(req, () => {
522
547
  const file = fileFor(fd);
548
+ if ((file.flags & 3) === 0) throw createNodeError('EBADF', 'write', file.path);
523
549
  const tree = file.tree;
524
550
  const slice = buffer.subarray(offset, offset + length);
525
551
  const existing = file.cached ?? (tree.existsSync(file.path) ? tree.readFileSync(file.path) as Uint8Array : new Uint8Array(0));
526
- const at = position === null || position === undefined || position < 0 ? file.position : position;
552
+ const at = (file.flags & flagBits().append) !== 0 ? existing.length
553
+ : position === null || position === undefined || position < 0 ? file.position : position;
527
554
  const size = Math.max(existing.length, at + slice.length);
528
555
  const next = new Uint8Array(size);
529
556
  next.set(existing, 0);
@@ -553,15 +580,13 @@ export const fsBinding = {
553
580
  return fsBinding.writeBuffer(fd, bytes, 0, bytes.length, position ?? null, req);
554
581
  },
555
582
 
556
- writeFileUtf8(path: unknown, data: string, flags: number, _mode: number): undefined {
557
- const tree = vfs();
558
- const name = asPath(path);
559
- if ((flags & flagBits().append) !== 0 && tree.existsSync(name)) {
560
- const existing = tree.readFileSync(name, 'utf8') as string;
561
- tree.writeFileSync(name, existing + data);
562
- return undefined;
563
- }
564
- tree.writeFileSync(name, data);
583
+ writeFileUtf8(path: unknown, data: string, flags: number, mode: number): undefined {
584
+ // Node's UTF-8 fast path accepts a path OR an already-open descriptor.
585
+ // Reuse open/write so flags, offsets and errors match the buffer path.
586
+ const owned = typeof path !== 'number';
587
+ const fd = owned ? fsBinding.open(path, flags, mode) as number : path as number;
588
+ try { fsBinding.writeString(fd, data, null, 'utf8'); }
589
+ finally { if (owned) fsBinding.close(fd); }
565
590
  return undefined;
566
591
  },
567
592
 
@@ -612,10 +637,10 @@ export const fsBinding = {
612
637
  });
613
638
  },
614
639
 
615
- access(path: unknown, _mode: number, req?: FSReq): undefined {
640
+ access(path: unknown, mode: number, req?: FSReq): undefined {
616
641
  return answer(req, () => {
617
642
  const name = asPath(path);
618
- if (!vfs().existsSync(name)) throw createNodeError('ENOENT', 'access', name);
643
+ vfs().accessSync(name, mode);
619
644
  return undefined;
620
645
  });
621
646
  },
@@ -726,13 +751,10 @@ export const fsBinding = {
726
751
  if (tree.existsSync(name)) {
727
752
  const stats = tree.statSync(name);
728
753
  if (!recursive || !stats.isDirectory()) throw createNodeError('EEXIST', 'mkdir', name);
729
- const stored = pathModes.get(name);
730
- if (stored !== undefined && (stored & 0o777) !== wanted) throw createNodeError('EEXIST', 'mkdir', name);
731
- pathModes.set(name, wanted);
732
754
  return undefined;
733
755
  }
734
756
  tree.mkdirSync(name, { recursive });
735
- pathModes.set(name, wanted);
757
+ tree.chmodSync(name, wanted);
736
758
  return undefined;
737
759
  });
738
760
  },
@@ -806,43 +828,39 @@ export const fsBinding = {
806
828
  });
807
829
  },
808
830
 
809
- // ---- the calls a page's filesystem has nothing to do -------------------
810
- // Ownership and times are a kernel's bookkeeping. Permissions `chmod` sets
811
- // are kept here, because a program that sets a mode and stats it back must
812
- // see what it set, and a missing path is ENOENT as Node's is.
831
+ // Metadata belongs to the filesystem, including a host's permission view.
832
+ // Never keep a realm-global mode table or bypass that view for descriptors.
813
833
  chmod(path: unknown, mode: number, req?: FSReq): undefined {
814
834
  return answer(req, () => {
815
835
  const name = asPath(path);
816
- if (!vfs().existsSync(name)) throw createNodeError('ENOENT', 'chmod', name);
817
- pathModes.set(name, mode & 0o7777);
836
+ vfs().chmodSync(name, mode);
818
837
  return undefined;
819
838
  });
820
839
  },
821
840
  fchmod(fd: number, mode: number, req?: FSReq): undefined {
822
841
  return answer(req, () => {
823
842
  const file = fileFor(fd);
824
- pathModes.set(file.path, mode & 0o7777);
825
- return undefined;
826
- });
827
- },
828
- chown(path: unknown, _uid: number, _gid: number, req?: FSReq): undefined {
829
- return answer(req, () => {
830
- const name = asPath(path);
831
- if (!vfs().existsSync(name)) throw createNodeError('ENOENT', 'chown', name);
843
+ file.tree.chmodSync(file.path, mode);
832
844
  return undefined;
833
845
  });
834
846
  },
835
- fchown(_fd: number, _uid: number, _gid: number, req?: FSReq): undefined { return answer(req, () => undefined); },
836
- lchown(_path: unknown, _uid: number, _gid: number, req?: FSReq): undefined { return answer(req, () => undefined); },
837
- utimes(_path: unknown, _atime: number, _mtime: number, req?: FSReq): undefined { return answer(req, () => undefined); },
838
- futimes(_fd: number, _atime: number, _mtime: number, req?: FSReq): undefined { return answer(req, () => undefined); },
839
- lutimes(_path: unknown, _atime: number, _mtime: number, req?: FSReq): undefined { return answer(req, () => undefined); },
847
+ chown(_path: unknown, _uid: number, _gid: number, req?: FSReq): undefined { return answer(req, () => unsupportedMetadata('chown')); },
848
+ fchown(_fd: number, _uid: number, _gid: number, req?: FSReq): undefined { return answer(req, () => unsupportedMetadata('fchown')); },
849
+ lchown(_path: unknown, _uid: number, _gid: number, req?: FSReq): undefined { return answer(req, () => unsupportedMetadata('lchown')); },
850
+ utimes(path: unknown, atime: number, mtime: number, req?: FSReq): undefined { return answer(req, () => { vfs().utimesSync(asPath(path), new Date(atime * 1000), new Date(mtime * 1000)); return undefined; }); },
851
+ futimes(fd: number, atime: number, mtime: number, req?: FSReq): undefined { return answer(req, () => { const file = fileFor(fd); file.tree.utimesSync(file.path, new Date(atime * 1000), new Date(mtime * 1000)); return undefined; }); },
852
+ lutimes(path: unknown, atime: number, mtime: number, req?: FSReq): undefined { return answer(req, () => {
853
+ if (treeHoldsLinks()) return unsupportedMetadata('lutimes');
854
+ vfs().utimesSync(asPath(path), new Date(atime * 1000), new Date(mtime * 1000));
855
+ return undefined;
856
+ }); },
840
857
  fsync(_fd: number, req?: FSReq): undefined { return answer(req, () => undefined); },
841
858
  fdatasync(_fd: number, req?: FSReq): undefined { return answer(req, () => undefined); },
842
859
 
843
860
  ftruncate(fd: number, length: number, req?: FSReq): undefined {
844
861
  return answer(req, () => {
845
862
  const file = fileFor(fd);
863
+ if ((file.flags & 3) === 0) throw createNodeError('EBADF', 'ftruncate', file.path);
846
864
  const tree = file.tree;
847
865
  const bytes = file.cached ?? (tree.readFileSync(file.path) as Uint8Array);
848
866
  const next = new Uint8Array(length);
@@ -23,6 +23,8 @@ export interface OwnedHandle {
23
23
  }
24
24
 
25
25
  const owners = new Map<ProcessToken, Set<OwnedHandle>>();
26
+ // Unref affects liveness, not ownership: exit must close unreferenced handles too.
27
+ const allOwned = new Map<ProcessToken, Set<OwnedHandle>>();
26
28
  const heldBy = new WeakMap<OwnedHandle, ProcessToken | null>();
27
29
  const refHeld = new WeakSet<OwnedHandle>();
28
30
  /**
@@ -68,6 +70,12 @@ export function ownerOf(handle: OwnedHandle): ProcessToken | null {
68
70
  /** A new handle is the current run's, ref'd and active, as libuv's start. */
69
71
  export function registerHandle(handle: OwnedHandle): void {
70
72
  heldBy.set(handle, currentOwner());
73
+ const token = heldBy.get(handle);
74
+ if (token != null) {
75
+ let owned = allOwned.get(token);
76
+ if (!owned) { owned = new Set(); allOwned.set(token, owned); }
77
+ owned.add(handle);
78
+ }
71
79
  refHeld.add(handle);
72
80
  activeHandles.add(handle);
73
81
  hold(handle);
@@ -103,6 +111,12 @@ export function handleHasRef(handle: OwnedHandle): boolean {
103
111
  /** A closed handle holds nothing. */
104
112
  export function releaseHandle(handle: OwnedHandle): void {
105
113
  drop(handle);
114
+ const token = heldBy.get(handle);
115
+ if (token != null) {
116
+ const owned = allOwned.get(token);
117
+ owned?.delete(handle);
118
+ if (owned?.size === 0) allOwned.delete(token);
119
+ }
106
120
  heldBy.delete(handle);
107
121
  refHeld.delete(handle);
108
122
  activeHandles.delete(handle);
@@ -161,8 +175,9 @@ export function __releaseOwnedServers(token: ProcessToken, stopTimers = true, st
161
175
  * Called where the run's servers and timers are released.
162
176
  */
163
177
  export function __releaseOwnedHandles(token: ProcessToken): void {
164
- const held = owners.get(token);
178
+ const held = allOwned.get(token);
165
179
  if (!held) return;
180
+ allOwned.delete(token);
166
181
  owners.delete(token);
167
182
  for (const handle of [...held]) {
168
183
  try { handle.close(); } catch { /* a handle already closed is already released */ }
@@ -182,6 +197,17 @@ export function __releaseOwnedHandles(token: ProcessToken): void {
182
197
  export function __adoptHandle(handle: OwnedHandle | null | undefined, token: ProcessToken | null): void {
183
198
  if (!handle) return;
184
199
  drop(handle);
200
+ const previous = heldBy.get(handle);
201
+ if (previous != null) {
202
+ const owned = allOwned.get(previous);
203
+ owned?.delete(handle);
204
+ if (owned?.size === 0) allOwned.delete(previous);
205
+ }
185
206
  heldBy.set(handle, token);
207
+ if (token !== null) {
208
+ let owned = allOwned.get(token);
209
+ if (!owned) { owned = new Set(); allOwned.set(token, owned); }
210
+ owned.add(handle);
211
+ }
186
212
  hold(handle);
187
213
  }
@@ -417,16 +417,26 @@ export class ServerBridge extends EventEmitter {
417
417
  * news comes from the net binding, because that is where a port is taken;
418
418
  * the engine's `http` is Node's own file and knows nothing of a page.
419
419
  */
420
+ private readonly guestServers = new WeakSet<IVirtualServer>();
421
+
420
422
  private watchGuestPorts(): void {
421
423
  setPortWatchers(
422
424
  (port, address) => {
423
425
  if (this.servers.has(port)) return;
424
426
  const hostname = address || '0.0.0.0';
425
- this.registerServer(guestListeningServer(port, hostname), port, hostname);
427
+ const server = guestListeningServer(port, hostname);
428
+ this.guestServers.add(server);
429
+ this.registerServer(server, port, hostname);
426
430
  },
427
431
  (port) => {
428
432
  const entry = this.servers.get(port);
429
- if (entry && entry.server === null) this.unregisterServer(port);
433
+ // Closing a guest's listener releases its bridge port too. Guest
434
+ // registrations carry a request adapter now, not the old null sentinel;
435
+ // a host replacement on that port must still be left alone.
436
+ if (entry && (entry.server === null || this.guestServers.has(entry.server))) {
437
+ if (entry.server) entry.server.listening = false;
438
+ this.unregisterServer(port);
439
+ }
430
440
  },
431
441
  );
432
442
  }
@@ -127,6 +127,8 @@ type RunStdin = { emit: (event: string, ...args: unknown[]) => void; push: (chun
127
127
  * output went down the child's channel.
128
128
  */
129
129
  export interface RunStreams {
130
+ stdinStream?: AsyncIterable<Uint8Array>;
131
+ terminal?: { columns: number; rows: number; onResize?: (listener: (columns: number, rows: number) => void) => () => void };
130
132
  onStdout?: (data: string) => void;
131
133
  onStderr?: (data: string) => void;
132
134
  signal?: AbortSignal;
@@ -470,7 +472,7 @@ export function initChildProcess(vfs: VirtualFS): void {
470
472
  // open, as a pipe whose writer has not closed.
471
473
  stdin: typeof ctx.stdin === 'string' ? ctx.stdin : '',
472
474
  ...(streams?.held || streams?.stdinOpen ? { stdinHeld: true } : {}),
473
- ...(streams?.held ? { tty: true } : {}),
475
+ ...(streams?.held || streams?.terminal ? { tty: true } : {}),
474
476
  // The numbers this run was started with, so the guest's `process.pid`
475
477
  // is the one its parent's handle carries.
476
478
  ...(runPid(runToken) ? { pid: runPid(runToken)!.pid, ppid: runPid(runToken)!.ppid } : {}),
@@ -541,12 +543,26 @@ export function initChildProcess(vfs: VirtualFS): void {
541
543
  // For long-running commands (watch mode), report as TTY so tools like
542
544
  // vitest set up interactive features (file watching, stdin commands). A
543
545
  // spawned child's stdio is a pipe, as Node's is, and gets none of this.
544
- if (streams?.held) {
546
+ if (streams?.held || streams?.terminal) {
545
547
  proc.stdout.isTTY = true;
546
548
  proc.stderr.isTTY = true;
547
549
  proc.stdin.isTTY = true;
548
550
  proc.stdin.setRawMode = () => proc.stdin;
549
551
  }
552
+ const terminal = streams?.terminal;
553
+ const resize = (columns: number, rows: number): void => {
554
+ for (const stream of [proc.stdout, proc.stderr]) {
555
+ stream.columns = columns;
556
+ stream.rows = rows;
557
+ stream.emit('resize');
558
+ }
559
+ };
560
+ let stopResize: (() => void) | undefined;
561
+ let inputActive = true;
562
+ let inputIterator: AsyncIterator<Uint8Array> | undefined;
563
+ const readableInput = proc.stdin as unknown as { listenerCount(event: string): number; readableLength: number; readableHighWaterMark: number; readableFlowing: boolean | null; _readableState?: { reading?: boolean; needReadable?: boolean } };
564
+ const inputReading = () => readableInput.readableFlowing === true || readableInput._readableState?.reading === true
565
+ || readableInput._readableState?.needReadable === true || readableInput.listenerCount('data') > 0 || readableInput.listenerCount('readable') > 0;
550
566
 
551
567
  // A child started with a channel wires its own end of it before its
552
568
  // module runs, which is what `lib/internal/process/pre_execution.js` does
@@ -606,6 +622,41 @@ export function initChildProcess(vfs: VirtualFS): void {
606
622
  };
607
623
  const detachUncaught = __onUncaughtException(proc, onUncaughtException);
608
624
  try {
625
+ // The host's pipe delivers bytes after the entry has attached its reader.
626
+ // EOF closes that same run's stream, never the most recently started run.
627
+ if (streams?.stdinStream) {
628
+ inputIterator = streams.stdinStream[Symbol.asyncIterator]();
629
+ const input = inputIterator;
630
+ void (async () => {
631
+ const push = (chunk: Uint8Array | null) => {
632
+ if (runToken === null) proc.stdin.push(chunk);
633
+ else enterRun(runToken, () => proc.stdin.push(chunk));
634
+ };
635
+ try {
636
+ while (inputActive && !streams.signal?.aborted) {
637
+ // A pipe is read on demand. Bound queued bytes by the Readable's
638
+ // high-water mark and do not pull a producer nobody consumes.
639
+ while (inputActive && !streams.signal?.aborted && (!inputReading()
640
+ || readableInput.readableLength >= readableInput.readableHighWaterMark)) {
641
+ await new Promise(resolve => (globalThis.__browserRuntimeNativeSetTimeout ?? setTimeout).call(globalThis, resolve, 10));
642
+ }
643
+ if (!inputActive || streams.signal?.aborted) break;
644
+ const next = await input.next();
645
+ if (next.done) break;
646
+ if (!inputActive || streams.signal?.aborted) break;
647
+ push(next.value);
648
+ }
649
+ if (inputActive && !streams.signal?.aborted) push(null);
650
+ } catch (error) {
651
+ if (inputActive) onUncaughtException(error);
652
+ } finally { streams.stdinOpen = false; }
653
+ })();
654
+ }
655
+ if (terminal) resize(terminal.columns, terminal.rows);
656
+ stopResize = terminal?.onResize?.((columns, rows) => {
657
+ if (runToken === null) resize(columns, rows);
658
+ else enterRun(runToken, () => resize(columns, rows));
659
+ });
609
660
 
610
661
  let entrySettling: Promise<unknown> | undefined;
611
662
  try {
@@ -676,7 +727,7 @@ export function initChildProcess(vfs: VirtualFS): void {
676
727
  // handle is one socket back to the server that forked it and which sets no
677
728
  // timer — as idle, with exit 0, three times over.
678
729
  const __ownsHandles = () => runToken !== null && (__ownedServerPorts(runToken).length > 0 || __ownedHandleCount(runToken) > 0);
679
- const __printedThenWorking = () => pendingGuestTimers(proc) > 0 || heldWork().count > 0 || __ownsHandles();
730
+ const __printedThenWorking = () => (streams?.stdinOpen === true && inputReading()) || pendingGuestTimers(proc) > 0 || heldWork().count > 0 || __ownsHandles();
680
731
  if ((stdout.length > 0 || stderr.length > 0) && !__printedThenWorking()) {
681
732
  // Settling the command is host work. Killing guest timers must not
682
733
  // cancel this continuation and leave the command's promise unresolved.
@@ -725,7 +776,7 @@ export function initChildProcess(vfs: VirtualFS): void {
725
776
  let idleMs = 0;
726
777
  // A timer the guest still holds is work in Node's loop, whether or not
727
778
  // the program has printed; so is a handle it has open.
728
- const stillWorking = (): boolean => pendingGuestTimers(proc) > 0 || __ownsHandles();
779
+ const stillWorking = (): boolean => (streams?.stdinOpen === true && inputReading()) || pendingGuestTimers(proc) > 0 || __ownsHandles();
729
780
 
730
781
  // When an abort signal is present (e.g. watch mode), don't apply idle timeout —
731
782
  // only exit when aborted or process.exit is called.
@@ -787,6 +838,9 @@ export function initChildProcess(vfs: VirtualFS): void {
787
838
  } finally {
788
839
  detachRejections();
789
840
  detachUncaught();
841
+ inputActive = false;
842
+ try { void Promise.resolve(inputIterator?.return?.()).catch(() => {}); } catch { /* producer cleanup cannot prevent process cleanup */ }
843
+ try { stopResize?.(); } catch { /* host cleanup cannot prevent handle release */ }
790
844
  // A process that ends releases its listening sockets, whatever ended it:
791
845
  // a return from the entry, a throw, an exit. Only `process.exit` released
792
846
  // them here, so `vite --host` — which opened 5173 and then died at startup