Skip to content

nodeFileSystem labels write-phase ENOSPC failures as open #127

Open

Description

@soarkit
  • Repository: pingdotgg/ts-rust
  • Target commit: 0ffd46271f41f0c448b10ee154339b88cd34ee8a
  • File: npm/wasm/node-run.js:67

Summary

nodeFileSystem().writeFile first tries the write; when the parent is missing it creates the directory and retries. The error path that formats the final failure always uses the literal operation open, regardless of whether the failure happened in the open/parent-creation phase or in the actual write syscall. An ENOSPC during the write therefore surfaces as open /dev/full: no space left on device, while the Go/Rust contract for that failure names write, so callers cannot distinguish a path problem from a full device.

Affected code

npm/wasm/node-run.js:

   59  function statOrUndefined(stat, path) {
   60      try {
   61          return stat(path);
   62      } catch {
   63          return undefined;
   64      }
   65  }
   66  
   67  /** The real file system through node:fs, for `runTsc`. */
   68  export function nodeFileSystem() {
   69      const kind = entry =>
   70          entry.isDirectory() ? "directory" : entry.isFile() ? "file" : entry.isSymbolicLink() ? "symlink" : "other";
   71      return {
   72          readFile(path) {
   73              try {
   74                  return fs.readFileSync(path);
   75              } catch {
   76                  return undefined;
   77              }
   78          },
   79          stat(path) {
   80              try {
   81                  const stat = fs.statSync(path, { bigint: true });
   82                  return {
   83                      isDirectory: stat.isDirectory(),
   84                      isFile: stat.isFile(),
   85                      size: Number(stat.size),
   86                      mtimeNs: stat.mtimeNs,
   87                  };
   88              } catch {
   89                  return undefined;
   90              }
   91          },
   92          readDirectory(path) {
   93              try {
   94                  return fs.readdirSync(path, { withFileTypes: true }).map(entry => ({ name: entry.name, kind: kind(entry) }));
   95              } catch {
   96                  return undefined;
   97              }
   98          },
   99          realpath(path) {
  100              try {
  101                  return fs.realpathSync.native(path).replaceAll("\\", "/");
  102              } catch {
  103                  return undefined;
  104              }
  105          },
  106          // As Go's osvfs writeFileEnsuringDir (internal/vfs/osvfs/os.go:163):
  107          // write, and when that fails, make the directory and write again.
  108          writeFile(path, data, append) {
  109              const write = () => (append ? fs.appendFileSync : fs.writeFileSync)(path, data);
  110              try {
  111                  return write();
  112              } catch {
  113                  // Make the directory below.
  114              }
  115              const mkdirError = mkdirAll(dirname(path));
  116              if (mkdirError) return mkdirError;
  117              try {
  118                  return write();
  119              } catch (error) {
  120                  return goError("open", path, error);
  121              }
  122          },
  123          remove(path) {
  124              try {
  125                  fs.rmSync(path, { recursive: true, force: true });
  126              } catch {
  127                  return false;
  128              }
  129          },
  130          chtimes(path, atimeNs, mtimeNs) {
  131              try {
  132                  const stat = fs.statSync(path, { bigint: true });
  133                  const seconds = ns => Number(ns) / 1e9;
  134                  fs.utimesSync(path, seconds(atimeNs ?? stat.atimeNs), seconds(mtimeNs ?? stat.mtimeNs));
  135              } catch (error) {
  136                  return goError("chtimes", path, error);
  137              }
  138          },
  139      };
  140  }
  141  
  142  /** Go's osvfs check: the file system ignores case when the executable is also found with its case swapped. */
  143  function caseInsensitive() {
  144      const swapped = process.execPath.replace(/\w/g, c => (c === c.toUpperCase() ? c.toLowerCase() : c.toUpperCase()));
  145      return swapped !== process.execPath && fs.existsSync(swapped);
  146  }

Expected behavior

Failed host writes must report Go's operation-specific error text, using 'open' for open failures and 'write' for write failures, so the diagnostic consumed by tsc matches Go's error text.

Actual behavior

When a host write reaches the write syscall and fails there, e.g. ENOSPC during fs.writeFileSync or appendFileSync after the parent directory exists, nodeFileSystem.writeFile catches the error and unconditionally emits 'open : '. The supplied reference Go implementation would emit 'write : ' for that failure. core.d.ts requires Go's error text from writeFile, and writeResult, host_error, fs_error_text, and emitter-js transfer the already-mislabeled text to the TS5033 diagnostic, producing a producer/consumer contract mismatch.

How to reproduce

Call nodeFileSystem().writeFile("/dev/full","hello",false) from Node; the error text starts with "open" rather than "write".

Root cause

nodeFileSystem().writeFile first tries the write; when the parent is missing it creates the directory and retries. The error path that formats the final failure always uses the literal operation open, regardless of whether the failure happened in the open/parent-creation phase or in the actual write syscall. An ENOSPC during the write therefore surfaces as open /dev/full: no space left on device, while the Go/Rust contract for that failure names write, so callers cannot distinguish a path problem from a full device.

Suggested fix

Keep the failing operation in nodeFileSystem().writeFile. When the write syscall fails, format the error as write <path>: <errno>; retain open only for failures during the open/parent-creation phase. Add a test writing to /dev/full that asserts the prefix.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions