@volter/tabnode 0.3.1 → 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.
@@ -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,6 +477,9 @@ 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);
@@ -623,10 +637,10 @@ export const fsBinding = {
623
637
  });
624
638
  },
625
639
 
626
- access(path: unknown, _mode: number, req?: FSReq): undefined {
640
+ access(path: unknown, mode: number, req?: FSReq): undefined {
627
641
  return answer(req, () => {
628
642
  const name = asPath(path);
629
- if (!vfs().existsSync(name)) throw createNodeError('ENOENT', 'access', name);
643
+ vfs().accessSync(name, mode);
630
644
  return undefined;
631
645
  });
632
646
  },
@@ -737,13 +751,10 @@ export const fsBinding = {
737
751
  if (tree.existsSync(name)) {
738
752
  const stats = tree.statSync(name);
739
753
  if (!recursive || !stats.isDirectory()) throw createNodeError('EEXIST', 'mkdir', name);
740
- const stored = pathModes.get(name);
741
- if (stored !== undefined && (stored & 0o777) !== wanted) throw createNodeError('EEXIST', 'mkdir', name);
742
- pathModes.set(name, wanted);
743
754
  return undefined;
744
755
  }
745
756
  tree.mkdirSync(name, { recursive });
746
- pathModes.set(name, wanted);
757
+ tree.chmodSync(name, wanted);
747
758
  return undefined;
748
759
  });
749
760
  },
@@ -817,37 +828,32 @@ export const fsBinding = {
817
828
  });
818
829
  },
819
830
 
820
- // ---- the calls a page's filesystem has nothing to do -------------------
821
- // Ownership and times are a kernel's bookkeeping. Permissions `chmod` sets
822
- // are kept here, because a program that sets a mode and stats it back must
823
- // 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.
824
833
  chmod(path: unknown, mode: number, req?: FSReq): undefined {
825
834
  return answer(req, () => {
826
835
  const name = asPath(path);
827
- if (!vfs().existsSync(name)) throw createNodeError('ENOENT', 'chmod', name);
828
- pathModes.set(name, mode & 0o7777);
836
+ vfs().chmodSync(name, mode);
829
837
  return undefined;
830
838
  });
831
839
  },
832
840
  fchmod(fd: number, mode: number, req?: FSReq): undefined {
833
841
  return answer(req, () => {
834
842
  const file = fileFor(fd);
835
- pathModes.set(file.path, mode & 0o7777);
843
+ file.tree.chmodSync(file.path, mode);
836
844
  return undefined;
837
845
  });
838
846
  },
839
- chown(path: unknown, _uid: number, _gid: number, req?: FSReq): undefined {
840
- return answer(req, () => {
841
- const name = asPath(path);
842
- if (!vfs().existsSync(name)) throw createNodeError('ENOENT', 'chown', name);
843
- return undefined;
844
- });
845
- },
846
- fchown(_fd: number, _uid: number, _gid: number, req?: FSReq): undefined { return answer(req, () => undefined); },
847
- lchown(_path: unknown, _uid: number, _gid: number, req?: FSReq): undefined { return answer(req, () => undefined); },
848
- utimes(_path: unknown, _atime: number, _mtime: number, req?: FSReq): undefined { return answer(req, () => undefined); },
849
- futimes(_fd: number, _atime: number, _mtime: number, req?: FSReq): undefined { return answer(req, () => undefined); },
850
- 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
+ }); },
851
857
  fsync(_fd: number, req?: FSReq): undefined { return answer(req, () => undefined); },
852
858
  fdatasync(_fd: number, req?: FSReq): undefined { return answer(req, () => undefined); },
853
859
 
@@ -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
package/src/virtual-fs.ts CHANGED
@@ -35,6 +35,7 @@ export interface FSNode {
35
35
  target?: string;
36
36
  children?: Map<string, FSNode>;
37
37
  mtime: number;
38
+ mode?: number;
38
39
  /**
39
40
  * When the file was last read. Node reports it apart from the write time
40
41
  * and `utimes` sets the two separately; this filesystem reported the write
@@ -470,6 +471,19 @@ export class VirtualFS {
470
471
  /**
471
472
  * Get stats for path
472
473
  */
474
+ chmodSync(path: string, mode: number): void {
475
+ const node = this.getNode(path);
476
+ if (!node) throw createNodeError('ENOENT', 'chmod', path);
477
+ node.mode = mode & 0o7777;
478
+ }
479
+
480
+ utimesSync(path: string, atime: number | Date, mtime: number | Date): void {
481
+ const node = this.getNode(path);
482
+ if (!node) throw createNodeError('ENOENT', 'utimes', path);
483
+ node.atime = atime instanceof Date ? atime.getTime() : atime * 1000;
484
+ node.mtime = mtime instanceof Date ? mtime.getTime() : mtime * 1000;
485
+ }
486
+
473
487
  statSync(path: string): Stats {
474
488
  const node = this.getNode(path);
475
489
  if (!node) {
@@ -489,7 +503,7 @@ export class VirtualFS {
489
503
  isFIFO: () => false,
490
504
  isSocket: () => false,
491
505
  size,
492
- mode: node.type === 'directory' ? 0o755 : 0o644,
506
+ mode: node.mode ?? (node.type === 'directory' ? 0o755 : 0o644),
493
507
  mtime: new Date(mtime),
494
508
  atime: new Date(atime),
495
509
  ctime: new Date(mtime),