From 57873719c03273ae8e42a3ebe24c8acba0c77d20 Mon Sep 17 00:00:00 2001 From: Nick Nassiri Date: Sun, 28 Jun 2026 16:10:10 -0700 Subject: [PATCH] =?UTF-8?q?feat(fs):=20long-tail=20ops=20=E2=80=94=20fsync?= =?UTF-8?q?/fdatasync,=20f*/l*=20metadata,=20readv/writev,=20statfs,=20glo?= =?UTF-8?q?b=20(#976)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the fs long tail across sync/callback/promise in both modes (epic #968): - Durability: fsync/fsyncSync, fdatasync/fdatasyncSync. - fd-variant metadata: fchmod/fchown/futimes (routed through a new fdPath primitive to the path ops) + Sync forms. - symlink metadata: lchmod (BSD/macOS-only -> ENOSYS elsewhere), lutimes. - vectored I/O: readv/writev + Sync forms (loops over read/writeSync). - statfs/statfsSync/promises.statfs (+ {bigint}), from DriveInfo. - a minimal glob/globSync/promises.glob: `*`/`?`/`**` matcher over a recursive readdir walk; the promise form is an async iterator (Node 22+). - exists (deprecated, non-err-first callback). Approach B: only the genuinely native ops need primitives — fsyncSync, fdPath, and statfsRaw — added in interp (FsModuleInterpreter) and as BCL-only emitted IL (RuntimeEmitter.FsLongTail.cs) so compiled output stays standalone. Everything else is one TS implementation in fs.ts/fs/promises.ts shared by both modes. FileHandle.sync/datasync now do a real fd flush. Deferred (issue-optional): realpathSync.native (function-property expando is unsafe in compiled mode) and openAsBlob (needs Blob). Gotcha: __globWalk's `catch { return }` tripped the compiled InvalidProgramException (the #973 return-in-catch pattern); restructured to an assignment-only catch. Verified byte-identical interp==compiled (scratch smoke), standalone preserved, 5 dual-mode tests per family (10 cases). Full suite 14472/0; TS conformance unchanged. --- Compilation/EmittedRuntime.cs | 4 + .../Emitters/Modules/FsModuleEmitter.cs | 43 ++++ Compilation/RuntimeEmitter.FsHelpers.cs | 3 + Compilation/RuntimeEmitter.FsLongTail.cs | 198 ++++++++++++++++ .../Interpreter/FsModuleInterpreter.cs | 64 +++++ .../BuiltInModules/FsModuleTests.cs | 167 +++++++++++++ TypeSystem/BuiltInModuleTypes.cs | 8 + stdlib/node/fs.ts | 224 +++++++++++++++++- stdlib/node/fs/promises.ts | 9 +- 9 files changed, 714 insertions(+), 6 deletions(-) create mode 100644 Compilation/RuntimeEmitter.FsLongTail.cs diff --git a/Compilation/EmittedRuntime.cs b/Compilation/EmittedRuntime.cs index c2d4d187..90f26ee3 100644 --- a/Compilation/EmittedRuntime.cs +++ b/Compilation/EmittedRuntime.cs @@ -1537,6 +1537,10 @@ public class EmittedRuntime public MethodBuilder FsWriteSyncString { get; set; } = null!; public MethodBuilder FsFstatSync { get; set; } = null!; public MethodBuilder FsFtruncateSync { get; set; } = null!; + // Long-tail fd primitives (#976): fsync, fd→path, and statfs. + public MethodBuilder FsFsyncSync { get; set; } = null!; + public MethodBuilder FsFdPath { get; set; } = null!; + public MethodBuilder FsStatfsRaw { get; set; } = null!; public FieldBuilder FsFileDescriptorTable { get; set; } = null!; // File descriptor low-level helpers (reflection-based for standalone DLLs) diff --git a/Compilation/Emitters/Modules/FsModuleEmitter.cs b/Compilation/Emitters/Modules/FsModuleEmitter.cs index e8380f76..cb39eb2e 100644 --- a/Compilation/Emitters/Modules/FsModuleEmitter.cs +++ b/Compilation/Emitters/Modules/FsModuleEmitter.cs @@ -24,6 +24,8 @@ public sealed class FsModuleEmitter : IBuiltInModuleEmitter "symlinkSync", "readlinkSync", "realpathSync", "utimesSync", // File descriptor APIs "openSync", "closeSync", "readSync", "writeSync", "fstatSync", "ftruncateSync", + // Long-tail fd primitives (#976) + "fsyncSync", "fdPath", "statfsRaw", // Directory utilities "mkdtempSync", "opendirSync", // Hard links @@ -74,6 +76,10 @@ public bool TryEmitMethodCall(IEmitterContext emitter, string methodName, List EmitWriteSync(emitter, arguments), "fstatSync" => EmitFstatSync(emitter, arguments), "ftruncateSync" => EmitFtruncateSync(emitter, arguments), + // Long-tail fd primitives (#976) + "fsyncSync" => EmitFsyncSync(emitter, arguments), + "fdPath" => EmitFdPath(emitter, arguments), + "statfsRaw" => EmitStatfsRaw(emitter, arguments), // Directory utilities "mkdtempSync" => EmitMkdtempSync(emitter, arguments), "opendirSync" => EmitOpendirSync(emitter, arguments), @@ -925,6 +931,43 @@ private static bool EmitFtruncateSync(IEmitterContext emitter, List argume return true; } + // Long-tail fd primitives (#976): fsyncSync (flush), fdPath (fd → path), + // statfsRaw (filesystem stats). Each emits its single arg and calls the helper. + + private static bool EmitFsyncSync(IEmitterContext emitter, List arguments) + { + var ctx = emitter.Context; + var il = ctx.IL; + if (arguments.Count == 0) { il.Emit(OpCodes.Ldnull); return true; } + emitter.EmitExpression(arguments[0]); + emitter.EmitBoxIfNeeded(arguments[0]); + il.Emit(OpCodes.Call, ctx.Runtime!.FsFsyncSync); + il.Emit(OpCodes.Ldnull); // undefined return + return true; + } + + private static bool EmitFdPath(IEmitterContext emitter, List arguments) + { + var ctx = emitter.Context; + var il = ctx.IL; + if (arguments.Count == 0) { il.Emit(OpCodes.Ldnull); return true; } + emitter.EmitExpression(arguments[0]); + emitter.EmitBoxIfNeeded(arguments[0]); + il.Emit(OpCodes.Call, ctx.Runtime!.FsFdPath); + return true; + } + + private static bool EmitStatfsRaw(IEmitterContext emitter, List arguments) + { + var ctx = emitter.Context; + var il = ctx.IL; + if (arguments.Count == 0) { il.Emit(OpCodes.Ldnull); return true; } + emitter.EmitExpression(arguments[0]); + emitter.EmitBoxIfNeeded(arguments[0]); + il.Emit(OpCodes.Call, ctx.Runtime!.FsStatfsRaw); + return true; + } + #endregion #region Directory Utilities diff --git a/Compilation/RuntimeEmitter.FsHelpers.cs b/Compilation/RuntimeEmitter.FsHelpers.cs index df0bb0ef..3a63a58d 100644 --- a/Compilation/RuntimeEmitter.FsHelpers.cs +++ b/Compilation/RuntimeEmitter.FsHelpers.cs @@ -80,6 +80,9 @@ private void EmitFsModuleMethods(TypeBuilder typeBuilder, EmittedRuntime runtime EmitFsFstatSync(typeBuilder, runtime); EmitFsFtruncateSync(typeBuilder, runtime); + // Long-tail fd primitives (#976): fsync, fd→path, statfs + EmitFsLongTail(typeBuilder, runtime); + // Directory utilities EmitFsMkdtempSync(typeBuilder, runtime); EmitFsOpendirSync(typeBuilder, runtime); diff --git a/Compilation/RuntimeEmitter.FsLongTail.cs b/Compilation/RuntimeEmitter.FsLongTail.cs new file mode 100644 index 00000000..d0522ced --- /dev/null +++ b/Compilation/RuntimeEmitter.FsLongTail.cs @@ -0,0 +1,198 @@ +using System.IO; +using System.Reflection.Emit; + +namespace SharpTS.Compilation; + +public partial class RuntimeEmitter +{ + /// + /// Emits the long-tail fd primitives (#976): FsFsyncSync (flush an fd), + /// FsFdPath (resolve an fd to its path so the TS facade can route fchmod/ + /// fchown/futimes through the path ops), and FsStatfsRaw (filesystem stats + /// from DriveInfo). Mirrors FsModuleInterpreter so both modes agree. BCL-only + /// (standalone): FileStream/DriveInfo/Path/Math are all in the BCL. + /// + private void EmitFsLongTail(TypeBuilder typeBuilder, EmittedRuntime runtime) + { + EmitFsFsyncSync(typeBuilder, runtime); + EmitFsFdPath(typeBuilder, runtime); + EmitFsStatfsRaw(typeBuilder, runtime); + } + + /// void FsFsyncSync(object fd) — flush the fd's buffered writes to disk. + private void EmitFsFsyncSync(TypeBuilder typeBuilder, EmittedRuntime runtime) + { + var method = typeBuilder.DefineMethod("FsFsyncSync", + System.Reflection.MethodAttributes.Public | System.Reflection.MethodAttributes.Static, + _types.Void, [_types.Object]); + runtime.FsFsyncSync = method; + + var il = method.GetILGenerator(); + + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Call, runtime.ToNumber); + il.Emit(OpCodes.Conv_I4); + var fdLocal = il.DeclareLocal(_types.Int32); + il.Emit(OpCodes.Stloc, fdLocal); + + il.Emit(OpCodes.Ldnull); + var pathLocal = il.DeclareLocal(_types.String); + il.Emit(OpCodes.Stloc, pathLocal); + + EmitWithFsErrorHandling(il, runtime, pathLocal, "fsync", afterTry => + { + var flushMethod = typeof(FileStream).GetMethod("Flush", [typeof(bool)])!; + + il.Emit(OpCodes.Ldsfld, runtime.FileDescriptorTableInstance); + il.Emit(OpCodes.Ldloc, fdLocal); + il.Emit(OpCodes.Callvirt, runtime.FileDescriptorTableGet); + il.Emit(OpCodes.Ldc_I4_1); // flushToDisk = true + il.Emit(OpCodes.Callvirt, flushMethod); + il.Emit(OpCodes.Leave, afterTry); + }); + il.Emit(OpCodes.Ret); + } + + /// object FsFdPath(object fd) — the open fd's file path (FileStream.Name). + private void EmitFsFdPath(TypeBuilder typeBuilder, EmittedRuntime runtime) + { + var method = typeBuilder.DefineMethod("FsFdPath", + System.Reflection.MethodAttributes.Public | System.Reflection.MethodAttributes.Static, + _types.Object, [_types.Object]); + runtime.FsFdPath = method; + + var il = method.GetILGenerator(); + var resultLocal = il.DeclareLocal(_types.Object); + + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Call, runtime.ToNumber); + il.Emit(OpCodes.Conv_I4); + var fdLocal = il.DeclareLocal(_types.Int32); + il.Emit(OpCodes.Stloc, fdLocal); + + il.Emit(OpCodes.Ldnull); + var pathLocal = il.DeclareLocal(_types.String); + il.Emit(OpCodes.Stloc, pathLocal); + + EmitWithFsErrorHandling(il, runtime, pathLocal, "fstat", afterTry => + { + var nameGetter = typeof(FileStream).GetProperty("Name")!.GetMethod!; + + il.Emit(OpCodes.Ldsfld, runtime.FileDescriptorTableInstance); + il.Emit(OpCodes.Ldloc, fdLocal); + il.Emit(OpCodes.Callvirt, runtime.FileDescriptorTableGet); + il.Emit(OpCodes.Callvirt, nameGetter); // string is already an object + il.Emit(OpCodes.Stloc, resultLocal); + il.Emit(OpCodes.Leave, afterTry); + }); + il.Emit(OpCodes.Ldloc, resultLocal); + il.Emit(OpCodes.Ret); + } + + /// + /// object FsStatfsRaw(object path) — flat statfs record {type,bsize,blocks, + /// bfree,bavail,files,ffree}. Mirrors FsModuleInterpreter.BuildStatfsRecord: + /// bsize fixed at 4096; block counts from DriveInfo; inode counts reported 0; + /// any DriveInfo failure (virtual/unknown filesystem) yields zeros, never throws. + /// + private void EmitFsStatfsRaw(TypeBuilder typeBuilder, EmittedRuntime runtime) + { + var method = typeBuilder.DefineMethod("FsStatfsRaw", + System.Reflection.MethodAttributes.Public | System.Reflection.MethodAttributes.Static, + _types.Object, [_types.Object]); + runtime.FsStatfsRaw = method; + + var il = method.GetILGenerator(); + + // path = Stringify(arg0) + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Call, runtime.Stringify); + var pathLocal = il.DeclareLocal(_types.String); + il.Emit(OpCodes.Stloc, pathLocal); + + // blocks / bfree / bavail default to 0.0 + var blocksL = il.DeclareLocal(_types.Double); + var bfreeL = il.DeclareLocal(_types.Double); + var bavailL = il.DeclareLocal(_types.Double); + + var getFullPath = typeof(Path).GetMethod("GetFullPath", [typeof(string)])!; + var getPathRoot = typeof(Path).GetMethod("GetPathRoot", [typeof(string)])!; + var isNullOrEmpty = typeof(string).GetMethod("IsNullOrEmpty", [typeof(string)])!; + var driveCtor = typeof(DriveInfo).GetConstructor([typeof(string)])!; + var totalSizeGet = typeof(DriveInfo).GetProperty("TotalSize")!.GetMethod!; + var totalFreeGet = typeof(DriveInfo).GetProperty("TotalFreeSpace")!.GetMethod!; + var availFreeGet = typeof(DriveInfo).GetProperty("AvailableFreeSpace")!.GetMethod!; + var floor = typeof(Math).GetMethod("Floor", [typeof(double)])!; + + var driveLocal = il.DeclareLocal(typeof(DriveInfo)); + + // try { root = GetPathRoot(GetFullPath(path)); if (!IsNullOrEmpty(root)) { drive=...; blocks/bfree/bavail } } catch { /* zeros */ } + il.BeginExceptionBlock(); + { + il.Emit(OpCodes.Ldloc, pathLocal); + il.Emit(OpCodes.Call, getFullPath); + il.Emit(OpCodes.Call, getPathRoot); + var rootLocal = il.DeclareLocal(_types.String); + il.Emit(OpCodes.Stloc, rootLocal); + + var skipDrive = il.DefineLabel(); + il.Emit(OpCodes.Ldloc, rootLocal); + il.Emit(OpCodes.Call, isNullOrEmpty); + il.Emit(OpCodes.Brtrue, skipDrive); + + il.Emit(OpCodes.Ldloc, rootLocal); + il.Emit(OpCodes.Newobj, driveCtor); + il.Emit(OpCodes.Stloc, driveLocal); + + void Blocks(System.Reflection.MethodInfo getter, LocalBuilder dest) + { + il.Emit(OpCodes.Ldloc, driveLocal); + il.Emit(OpCodes.Callvirt, getter); + il.Emit(OpCodes.Conv_R8); + il.Emit(OpCodes.Ldc_R8, 4096.0); + il.Emit(OpCodes.Div); + il.Emit(OpCodes.Call, floor); + il.Emit(OpCodes.Stloc, dest); + } + Blocks(totalSizeGet, blocksL); + Blocks(totalFreeGet, bfreeL); + Blocks(availFreeGet, bavailL); + + il.MarkLabel(skipDrive); + // No manual Leave: BeginCatchBlock emits the try's exit leave, and + // EndExceptionBlock emits the catch's — both to the block's end. + } + il.BeginCatchBlock(_types.Exception); + il.Emit(OpCodes.Pop); // swallow → zeros + il.EndExceptionBlock(); + + // Build the record dictionary. + var dictType = _types.DictionaryStringObject; + var dictCtor = _types.GetDefaultConstructor(dictType); + var addMethod = _types.GetMethod(dictType, "Add", _types.String, _types.Object); + il.Emit(OpCodes.Newobj, dictCtor); + var dict = il.DeclareLocal(dictType); + il.Emit(OpCodes.Stloc, dict); + + void AddEntry(string key, Action loadDouble) + { + il.Emit(OpCodes.Ldloc, dict); + il.Emit(OpCodes.Ldstr, key); + loadDouble(); + il.Emit(OpCodes.Box, _types.Double); + il.Emit(OpCodes.Call, addMethod); + } + + AddEntry("type", () => il.Emit(OpCodes.Ldc_R8, 0.0)); + AddEntry("bsize", () => il.Emit(OpCodes.Ldc_R8, 4096.0)); + AddEntry("blocks", () => il.Emit(OpCodes.Ldloc, blocksL)); + AddEntry("bfree", () => il.Emit(OpCodes.Ldloc, bfreeL)); + AddEntry("bavail", () => il.Emit(OpCodes.Ldloc, bavailL)); + AddEntry("files", () => il.Emit(OpCodes.Ldc_R8, 0.0)); + AddEntry("ffree", () => il.Emit(OpCodes.Ldc_R8, 0.0)); + + il.Emit(OpCodes.Ldloc, dict); + il.Emit(OpCodes.Call, runtime.CreateObject); + il.Emit(OpCodes.Ret); + } +} diff --git a/Runtime/BuiltIns/Modules/Interpreter/FsModuleInterpreter.cs b/Runtime/BuiltIns/Modules/Interpreter/FsModuleInterpreter.cs index aee70b1c..63307fd1 100644 --- a/Runtime/BuiltIns/Modules/Interpreter/FsModuleInterpreter.cs +++ b/Runtime/BuiltIns/Modules/Interpreter/FsModuleInterpreter.cs @@ -95,6 +95,11 @@ private static void WrapFsOperation(string syscall, string? path, Action operati ["writeSync"] = BuiltInMethod.CreateV2("writeSync", 2, 5, WriteSync), ["fstatSync"] = BuiltInMethod.CreateV2("fstatSync", 1, 1, FstatSync), ["ftruncateSync"] = BuiltInMethod.CreateV2("ftruncateSync", 1, 2, FtruncateSync), + // Long-tail fd primitives (#976) — the TS facade derives fsync/fdatasync, + // fchmod/fchown/futimes (via fdPath), and statfs from these. + ["fsyncSync"] = BuiltInMethod.CreateV2("fsyncSync", 1, 1, FsyncSync), + ["fdPath"] = BuiltInMethod.CreateV2("fdPath", 1, 1, FdPath), + ["statfsRaw"] = BuiltInMethod.CreateV2("statfsRaw", 1, 1, StatfsRaw), // Directory utilities ["mkdtempSync"] = BuiltInMethod.CreateV2("mkdtempSync", 1, 1, MkdtempSync), ["opendirSync"] = BuiltInMethod.CreateV2("opendirSync", 1, 1, OpendirSync), @@ -958,6 +963,65 @@ private static RuntimeValue FtruncateSync(Interp interpreter, RuntimeValue recei return RuntimeValue.Null; } + // #976 long-tail fd primitives. The TS facade builds fsync/fdatasync and the + // fd-variant metadata ops (fchmod/fchown/futimes) on top of these so both + // execution modes share one Node-shape implementation. + + /// fsync/fdatasync: flush an open fd's buffered writes to disk. + private static RuntimeValue FsyncSync(Interp interpreter, RuntimeValue receiver, ReadOnlySpan args) + { + var fd = Convert.ToInt32(args[0].ToObject()); + WrapFsOperation("fsync", null, () => _fdTable.Get(fd).Flush(true)); + return RuntimeValue.Null; + } + + /// Resolves an open fd to its file path — lets the facade route the + /// fd-variant metadata ops (fchmod/fchown/futimes) through the path ops. + private static RuntimeValue FdPath(Interp interpreter, RuntimeValue receiver, ReadOnlySpan args) + { + var fd = Convert.ToInt32(args[0].ToObject()); + return RuntimeValue.FromBoxed(WrapFsOperation("fstat", null, () => _fdTable.Get(fd).Name)); + } + + /// statfs: flat filesystem-stats record (type/bsize/blocks/bfree/ + /// bavail/files/ffree) the TS StatFs class shapes. Derived from DriveInfo. + private static RuntimeValue StatfsRaw(Interp interpreter, RuntimeValue receiver, ReadOnlySpan args) + { + var path = args[0].ToObject()?.ToString() ?? ""; + return RuntimeValue.FromBoxed(WrapFsOperation("statfs", path, () => BuildStatfsRecord(path))); + } + + /// Builds the statfs record from DriveInfo (bsize fixed at 4096; the + /// inode counts are unavailable through the BCL, reported as 0 like libuv on + /// filesystems that don't expose them). + internal static SharpTSObject BuildStatfsRecord(string path) + { + const double bsize = 4096.0; + double blocks = 0, bfree = 0, bavail = 0; + try + { + var root = Path.GetPathRoot(Path.GetFullPath(path)); + if (!string.IsNullOrEmpty(root)) + { + var drive = new DriveInfo(root); + blocks = Math.Floor(drive.TotalSize / bsize); + bfree = Math.Floor(drive.TotalFreeSpace / bsize); + bavail = Math.Floor(drive.AvailableFreeSpace / bsize); + } + } + catch { /* unknown/virtual filesystem → zeros */ } + return new SharpTSObject(new Dictionary + { + ["type"] = 0.0, + ["bsize"] = bsize, + ["blocks"] = blocks, + ["bfree"] = bfree, + ["bavail"] = bavail, + ["files"] = 0.0, + ["ffree"] = 0.0, + }); + } + #endregion #region Directory Utilities diff --git a/SharpTS.Tests/SharedTests/BuiltInModules/FsModuleTests.cs b/SharpTS.Tests/SharedTests/BuiltInModules/FsModuleTests.cs index f12eac05..7c5b8e5c 100644 --- a/SharpTS.Tests/SharedTests/BuiltInModules/FsModuleTests.cs +++ b/SharpTS.Tests/SharedTests/BuiltInModules/FsModuleTests.cs @@ -1860,4 +1860,171 @@ async function main() { } #endregion + + #region long-tail ops (#976) + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Fs_LongTail_DurabilityAndFdMetadata(ExecutionMode mode) + { + var uid = Uid(); + var files = new Dictionary + { + ["main.ts"] = $$""" + import * as fs from 'fs'; + import * as os from 'os'; + async function main() { + const f = os.tmpdir() + '/lt976a_{{uid}}.txt'; + const fd = fs.openSync(f, 'w+'); + fs.writeSync(fd, 'data'); + fs.fsyncSync(fd); + fs.fdatasyncSync(fd); + fs.futimesSync(fd, 1700000000, 1700000000); + fs.fchmodSync(fd, 0o644); + fs.closeSync(fd); + console.log('rt:' + (fs.readFileSync(f, 'utf8') === 'data')); // true + // fchown's callback fires (platform/perm behavior aside). + const ofd = fs.openSync(f, 'r'); + const fchown: any = await new Promise((res: any) => fs.fchown(ofd, 0, 0, () => res(true))); + console.log('fchown:' + fchown); // true + // callback fsync round-trip. + await new Promise((res: any, rej: any) => fs.fsync(ofd, (e: any) => e ? rej(e) : res(0))); + fs.closeSync(ofd); + console.log('cbfsync:ok'); + // Bad fd surfaces an error with a code (exact code differs by mode). + const bad: any = await new Promise((res: any) => fs.fsync(999999, (e: any) => res(!!(e && typeof e.code === 'string' && e.code.length > 0)))); + console.log('badfd:' + bad); // true + fs.unlinkSync(f); + } + main(); + """ + }; + Assert.Equal("rt:true\nfchown:true\ncbfsync:ok\nbadfd:true\n", TestHarness.RunModules(files, "main.ts", mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Fs_LongTail_VectoredIo(ExecutionMode mode) + { + var uid = Uid(); + var files = new Dictionary + { + ["main.ts"] = $$""" + import * as fs from 'fs'; + import * as os from 'os'; + import { Buffer } from 'buffer'; + async function main() { + const f = os.tmpdir() + '/lt976v_{{uid}}.txt'; + const wfd = fs.openSync(f, 'w'); + const wn = fs.writevSync(wfd, [Buffer.from('AB'), Buffer.from('CD')]); + fs.closeSync(wfd); + const rfd = fs.openSync(f, 'r'); + const b1 = Buffer.alloc(2), b2 = Buffer.alloc(2); + const rn: any = await new Promise((res: any, rej: any) => fs.readv(rfd, [b1, b2], (e: any, n: any) => e ? rej(e) : res(n))); + fs.closeSync(rfd); + console.log(wn + ':' + rn + ':' + b1.toString() + b2.toString()); // 4:4:ABCD + fs.unlinkSync(f); + } + main(); + """ + }; + Assert.Equal("4:4:ABCD\n", TestHarness.RunModules(files, "main.ts", mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Fs_LongTail_Statfs(ExecutionMode mode) + { + var uid = Uid(); + var files = new Dictionary + { + ["main.ts"] = $$""" + import * as fs from 'fs'; + import * as fsp from 'fs/promises'; + import * as os from 'os'; + async function main() { + const d = os.tmpdir(); + const s: any = fs.statfsSync(d); + console.log('sync:' + (s.bsize === 4096 && typeof s.blocks === 'number' && typeof s.bavail === 'number')); + const sb: any = fs.statfsSync(d, { bigint: true }); + console.log('bigint:' + (typeof sb.bsize === 'bigint')); + const ps: any = await fsp.statfs(d); + console.log('promise:' + (ps.bsize === 4096)); + const cb: any = await new Promise((res: any, rej: any) => fs.statfs(d, (e: any, x: any) => e ? rej(e) : res(x.bsize))); + console.log('cb:' + cb); + } + main(); + """ + }; + Assert.Equal("sync:true\nbigint:true\npromise:true\ncb:4096\n", TestHarness.RunModules(files, "main.ts", mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Fs_LongTail_Glob(ExecutionMode mode) + { + var uid = Uid(); + var files = new Dictionary + { + ["main.ts"] = $$""" + import * as fs from 'fs'; + import * as fsp from 'fs/promises'; + import * as os from 'os'; + async function main() { + const root = os.tmpdir() + '/lt976g_{{uid}}'; + fs.rmSync(root, { recursive: true, force: true }); + fs.mkdirSync(root + '/sub', { recursive: true }); + fs.writeFileSync(root + '/a.txt', ''); + fs.writeFileSync(root + '/b.log', ''); + fs.writeFileSync(root + '/sub/c.txt', ''); + console.log('star:' + fs.globSync('*.txt', { cwd: root }).sort().join(',')); // a.txt + console.log('ss:' + fs.globSync('**/*.txt', { cwd: root }).sort().join(',')); // a.txt,sub/c.txt + console.log('q:' + fs.globSync('?.log', { cwd: root }).join(',')); // b.log + console.log('none:' + fs.globSync('*.md', { cwd: root }).length); // 0 + const cb: any = await new Promise((res: any, rej: any) => fs.glob('*.txt', { cwd: root }, (e: any, m: any) => e ? rej(e) : res(m.sort().join(',')))); + console.log('cb:' + cb); // a.txt + const it: string[] = []; + for await (const m of fsp.glob('*.txt', { cwd: root })) { it.push(m); } + console.log('iter:' + it.sort().join(',')); // a.txt + fs.rmSync(root, { recursive: true }); + } + main(); + """ + }; + Assert.Equal("star:a.txt\nss:a.txt,sub/c.txt\nq:b.log\nnone:0\ncb:a.txt\niter:a.txt\n", TestHarness.RunModules(files, "main.ts", mode)); + } + + [Theory] + [MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))] + public void Fs_LongTail_LchmodEnosys_Lutimes_Exists(ExecutionMode mode) + { + var uid = Uid(); + var files = new Dictionary + { + ["main.ts"] = $$""" + import * as fs from 'fs'; + import * as os from 'os'; + async function main() { + const f = os.tmpdir() + '/lt976l_{{uid}}.txt'; + fs.writeFileSync(f, 'x'); + // lchmod is BSD/macOS-only; elsewhere Node fails ENOSYS (we surface it consistently). + let lc = 'none'; try { fs.lchmodSync(f, 0o600); } catch (e: any) { lc = e.code; } + console.log('lchmod:' + lc); // ENOSYS + const lcb: any = await new Promise((res: any) => fs.lchmod(f, 0o600, (e: any) => res(e ? e.code : 'ok'))); + console.log('lchmodcb:' + lcb); // ENOSYS + fs.lutimesSync(f, 1700000000, 1700000000); + console.log('lutimes:ok'); + const ex: any = await new Promise((res: any) => fs.exists(f, (b: any) => res(b))); + console.log('exists:' + ex); // true + const nex: any = await new Promise((res: any) => fs.exists(f + '.nope', (b: any) => res(b))); + console.log('nexists:' + nex); // false + fs.unlinkSync(f); + } + main(); + """ + }; + Assert.Equal("lchmod:ENOSYS\nlchmodcb:ENOSYS\nlutimes:ok\nexists:true\nnexists:false\n", TestHarness.RunModules(files, "main.ts", mode)); + } + + #endregion } diff --git a/TypeSystem/BuiltInModuleTypes.cs b/TypeSystem/BuiltInModuleTypes.cs index 2bfd70ce..ac33a493 100644 --- a/TypeSystem/BuiltInModuleTypes.cs +++ b/TypeSystem/BuiltInModuleTypes.cs @@ -267,6 +267,14 @@ public static Dictionary GetFsModuleTypes() voidType, RequiredParams: 1 ), + // Long-tail fd primitives (#976): the TS facade derives fsync/fdatasync, + // fchmod/fchown/futimes (via fdPath), and statfs from these. + // fsyncSync(fd) -> void + ["fsyncSync"] = new TypeInfo.Function([numberType], voidType), + // fdPath(fd) -> string (the open fd's file path) + ["fdPath"] = new TypeInfo.Function([numberType], stringType), + // statfsRaw(path) -> flat record the TS StatFs shapes + ["statfsRaw"] = new TypeInfo.Function([stringType], anyType), // Directory utilities // mkdtempSync(prefix) -> string diff --git a/stdlib/node/fs.ts b/stdlib/node/fs.ts index 5f4a8313..aba2d80e 100644 --- a/stdlib/node/fs.ts +++ b/stdlib/node/fs.ts @@ -48,6 +48,10 @@ import { writeSync as __writeSync, fstatRaw as __fstatRaw, ftruncateSync as __ftruncateSync, + // Long-tail fd primitives (#976) + fsyncSync as __fsyncSync, + fdPath as __fdPath, + statfsRaw as __statfsRaw, // Directory utilities / hard links mkdtempSync as __mkdtempSync, linkSync as __linkSync, @@ -225,7 +229,7 @@ function __parentDir(p: string): string { return p.slice(0, i); } -const __errnoFor: any = { ENOENT: -2, EEXIST: -17, EACCES: -13 }; +const __errnoFor: any = { ENOENT: -2, EEXIST: -17, EACCES: -13, EBADF: -9, ENOSYS: -38, EISDIR: -21 }; /** Builds a Node-shaped fs error (code/errno/syscall/path) thrown from the facade. */ function __fsError(code: string, message: string, syscall: string, path: string): any { @@ -464,6 +468,154 @@ export function ftruncateSync(fd: number, len?: number): void { __ftruncateSync(fd, len); } +// =========================================================================== +// Long-tail sync ops (#976). Durability (fsync/fdatasync), fd-variant metadata +// (fchmod/fchown/futimes — routed through fdPath to the path ops), symlink +// metadata (lchmod/lutimes), vectored I/O (readv/writev), filesystem stats +// (statfs), and glob. All TS over primitive:fs, so both modes share one impl. +// =========================================================================== + +/** Synchronously flushes an fd's buffered writes to disk. */ +export function fsyncSync(fd: number): void { __fsyncSync(fd); } + +/** Synchronously flushes an fd's data writes. A full flush is a correct superset. */ +export function fdatasyncSync(fd: number): void { __fsyncSync(fd); } + +/** Synchronously changes the permissions of an open fd's file. */ +export function fchmodSync(fd: number, mode: number): void { __chmodSync(__fdPath(fd), mode); } + +/** Synchronously changes the owner and group of an open fd's file. */ +export function fchownSync(fd: number, uid: number, gid: number): void { __chownSync(__fdPath(fd), uid, gid); } + +/** Synchronously changes the file-system timestamps of an open fd's file. */ +export function futimesSync(fd: number, atime: any, mtime: any): void { __utimesSync(__fdPath(fd), atime, mtime); } + +/** Synchronously changes the permissions of a symbolic link. Only BSD/macOS support + * this; elsewhere Node fails with ENOSYS, which we surface consistently. */ +export function lchmodSync(path: string, mode: number): void { + throw __fsError('ENOSYS', 'function not implemented', 'lchmod', path); +} + +/** Synchronously changes the timestamps of a symbolic link. The BCL has no + * no-follow timestamp API, so this approximates by setting the target's times. */ +export function lutimesSync(path: string, atime: any, mtime: any): void { __utimesSync(path, atime, mtime); } + +/** Synchronously reads sequentially into an array of buffers; returns total bytes read. */ +export function readvSync(fd: number, buffers: any[], position?: any): number { + let pos = (position === undefined || position === null) ? null : position; + let total = 0; + for (const b of buffers) { + const n = readSync(fd, b, 0, b.length, pos); + total += n; + if (pos !== null) pos += n; + if (n < b.length) break; // short read => EOF + } + return total; +} + +/** Synchronously writes an array of buffers sequentially; returns total bytes written. */ +export function writevSync(fd: number, buffers: any[], position?: any): number { + let pos = (position === undefined || position === null) ? null : position; + let total = 0; + for (const b of buffers) { + const n = writeSync(fd, b, 0, b.length, pos); + total += n; + if (pos !== null) pos += n; + } + return total; +} + +/** Shapes the flat statfs record, converting fields to BigInt when requested. */ +function __shapeStatfs(raw: any, options: any): any { + const big = typeof options === 'object' && options !== null && !!options.bigint; + if (!big) return raw; + const b = (x: number): any => BigInt(Math.trunc(x)); + return { + type: b(raw.type), bsize: b(raw.bsize), blocks: b(raw.blocks), bfree: b(raw.bfree), + bavail: b(raw.bavail), files: b(raw.files), ffree: b(raw.ffree), + }; +} + +/** Synchronously retrieves filesystem statistics for the path. */ +export function statfsSync(path: string, options?: any): any { return __shapeStatfs(__statfsRaw(path), options); } + +// --- Minimal glob (Node 22+): supports `*` and `?` within a path segment and +// `**` across segments. Matching walks the directory tree under cwd. --- + +/** Compiles one glob path-segment (`*`/`?`/literals) to an anchored RegExp. */ +function __globSegRegex(seg: string): RegExp { + let re = '^'; + for (const ch of seg) { + if (ch === '*') re += '[^/]*'; + else if (ch === '?') re += '[^/]'; + else if ('.+^${}()|[]\\/'.indexOf(ch) >= 0) re += '\\' + ch; + else re += ch; + } + return new RegExp(re + '$'); +} + +/** Lists a directory, returning [] when it can't be read (not a dir / missing). + * The catch only assigns — a `return` inside a compiled catch can miscompile (#973). */ +function __globReaddir(dir: string): string[] { + let entries: string[] = []; + try { entries = __readdirSync(dir); } catch (e) { entries = []; } + return entries; +} + +/** Recursive glob walk: matches `segs[i]` against entries of `base/rel`, collecting + * matched relative paths into `out`. `**` matches zero or more directory levels. */ +function __globWalk(base: string, rel: string, segs: string[], i: number, out: string[]): void { + if (i >= segs.length) { if (rel.length > 0) out.push(rel); return; } + const seg = segs[i]; + const dir = rel.length > 0 ? base + '/' + rel : base; + const entries = __globReaddir(dir); + if (seg === '**') { + __globWalk(base, rel, segs, i + 1, out); // ** consumes zero levels + for (const e of entries) { + const childRel = rel.length > 0 ? rel + '/' + e : e; + let isDir = false; + try { isDir = statSync(base + '/' + childRel).isDirectory(); } catch (er) { isDir = false; } + if (isDir) __globWalk(base, childRel, segs, i, out); // ...or one+ levels + } + return; + } + const re = __globSegRegex(seg); + for (const e of entries) { + if (re.test(e)) { + const childRel = rel.length > 0 ? rel + '/' + e : e; + __globWalk(base, childRel, segs, i + 1, out); + } + } +} + +/** Synchronously returns paths matching a glob pattern (or array of patterns), + * relative to `options.cwd` (default `.`). */ +export function globSync(pattern: any, options?: any): string[] { + const pats: any[] = Array.isArray(pattern) ? pattern : [pattern]; + const cwd = (options && typeof options === 'object' && options.cwd) ? options.cwd : '.'; + const out: string[] = []; + const seen: any = {}; + for (const pat of pats) { + const segs = ('' + pat).split('/').filter((s: string) => s.length > 0); + const local: string[] = []; + __globWalk(cwd, '', segs, 0, local); + for (const m of local) { if (!seen[m]) { seen[m] = true; out.push(m); } } + } + return out; +} + +/** Wraps an array as a one-shot async iterable (for fsPromises.glob, Node 22+). */ +function __arrayAsyncIterator(items: any[]): any { + let i = 0; + return { + [Symbol.asyncIterator](): any { return this; }, + next(): any { + if (i < items.length) return Promise.resolve({ value: items[i++], done: false }); + return Promise.resolve({ value: undefined, done: true }); + } + }; +} + /** Synchronously creates a unique temporary directory and returns its path. */ export function mkdtempSync(prefix: string): string { return __mkdtempSync(prefix); } @@ -790,6 +942,62 @@ export function ftruncate(fd: number, len?: any, callback?: any): void { __cbVoid(__promisifyVoid(() => ftruncateSync(fd, len)), callback); } +// --- Long-tail callback ops (#976), derived from the sync forms above. --- + +/** Asynchronously flushes an fd's buffered writes; callback receives (err). */ +export function fsync(fd: number, callback: any): void { __cbVoid(__promisifyVoid(() => fsyncSync(fd)), callback); } + +/** Asynchronously flushes an fd's data writes; callback receives (err). */ +export function fdatasync(fd: number, callback: any): void { __cbVoid(__promisifyVoid(() => fdatasyncSync(fd)), callback); } + +/** Asynchronously changes the permissions of an open fd's file; callback receives (err). */ +export function fchmod(fd: number, mode: number, callback: any): void { __cbVoid(__promisifyVoid(() => fchmodSync(fd, mode)), callback); } + +/** Asynchronously changes the owner and group of an open fd's file; callback receives (err). */ +export function fchown(fd: number, uid: number, gid: number, callback: any): void { __cbVoid(__promisifyVoid(() => fchownSync(fd, uid, gid)), callback); } + +/** Asynchronously changes the timestamps of an open fd's file; callback receives (err). */ +export function futimes(fd: number, atime: any, mtime: any, callback: any): void { __cbVoid(__promisifyVoid(() => futimesSync(fd, atime, mtime)), callback); } + +/** Asynchronously changes the permissions of a symbolic link; callback receives (err). */ +export function lchmod(path: string, mode: number, callback: any): void { __cbVoid(__promisifyVoid(() => lchmodSync(path, mode)), callback); } + +/** Asynchronously changes the timestamps of a symbolic link; callback receives (err). */ +export function lutimes(path: string, atime: any, mtime: any, callback: any): void { __cbVoid(__promisifyVoid(() => lutimesSync(path, atime, mtime)), callback); } + +/** Asynchronously reads into an array of buffers; callback receives (err, bytesRead, buffers). */ +export function readv(fd: number, buffers: any[], position?: any, callback?: any): void { + let cb = callback; + if (typeof position === 'function') { cb = position; position = undefined; } + __promisifyValue(() => readvSync(fd, buffers, position)).then( + (n: any) => { cb(null, n, buffers); }, (e: any) => { cb(e, 0, buffers); }); +} + +/** Asynchronously writes an array of buffers; callback receives (err, bytesWritten, buffers). */ +export function writev(fd: number, buffers: any[], position?: any, callback?: any): void { + let cb = callback; + if (typeof position === 'function') { cb = position; position = undefined; } + __promisifyValue(() => writevSync(fd, buffers, position)).then( + (n: any) => { cb(null, n, buffers); }, (e: any) => { cb(e, 0, buffers); }); +} + +/** Asynchronously retrieves filesystem statistics; callback receives (err, stats). */ +export function statfs(path: string, options?: any, callback?: any): void { + if (typeof options === 'function') { callback = options; options = undefined; } + __cbData(__promisifyValue(() => statfsSync(path, options)), callback); +} + +/** Asynchronously returns paths matching a glob pattern; callback receives (err, matches). */ +export function glob(pattern: any, options?: any, callback?: any): void { + if (typeof options === 'function') { callback = options; options = undefined; } + __cbData(__promisifyValue(() => globSync(pattern, options)), callback); +} + +/** Deprecated. Tests existence; the callback receives a single boolean (not err-first). */ +export function exists(path: string, callback: any): void { + __promisifyValue(() => existsSync(path)).then((b: any) => { callback(b); }, () => { callback(false); }); +} + // =========================================================================== // FileHandle (#972) — the promise-based file-descriptor workflow. // @@ -921,11 +1129,11 @@ class FileHandle { /** Changes the file-system timestamps of the underlying file. */ utimes(atime: number, mtime: number): Promise { return __pUtimes(this.__path, atime, mtime); } - /** Flushes pending writes. SharpTS sync I/O is already durable, so this resolves. */ - sync(): Promise { return Promise.resolve(); } + /** Flushes the fd's buffered writes to disk (#976). */ + sync(): Promise { return __promisifyVoid(() => __fsyncSync(this.fd)); } - /** Flushes pending data writes. As with sync(), a resolved no-op here. */ - datasync(): Promise { return Promise.resolve(); } + /** Flushes the fd's data writes to disk (#976). */ + datasync(): Promise { return __promisifyVoid(() => __fsyncSync(this.fd)); } /** Returns a ReadStream over the underlying file. */ createReadStream(options?: any): any { return createReadStream(this.__path, options); } @@ -989,6 +1197,8 @@ export const promises = { open: (path: string, flags?: any, mode?: any): Promise => __openHandle(path, flags, mode), opendir: (path: string, options?: any): Promise => Promise.resolve(opendirSync(path, options)), watch: (filename: string, options?: any): any => __watchAsync(filename, options), + statfs: (path: string, options?: any): Promise => __promisifyValue(() => statfsSync(path, options)), + glob: (pattern: any, options?: any): any => __arrayAsyncIterator(globSync(pattern, options)), constants, }; @@ -1001,6 +1211,8 @@ export default { chmodSync, chownSync, lchownSync, truncateSync, symlinkSync, readlinkSync, realpathSync, utimesSync, openSync, closeSync, readSync, writeSync, fstatSync, ftruncateSync, + fsyncSync, fdatasyncSync, fchmodSync, fchownSync, futimesSync, + lchmodSync, lutimesSync, readvSync, writevSync, statfsSync, globSync, mkdtempSync, opendirSync, linkSync, createReadStream, createWriteStream, watch, watchFile, unwatchFile, @@ -1009,5 +1221,7 @@ export default { rm, cp, readdir, rename, copyFile, access, chmod, truncate, utimes, readlink, realpath, symlink, link, mkdtemp, chown, lchown, open, close, read, write, fstat, ftruncate, + fsync, fdatasync, fchmod, fchown, futimes, lchmod, lutimes, + readv, writev, statfs, glob, exists, promises, }; diff --git a/stdlib/node/fs/promises.ts b/stdlib/node/fs/promises.ts index b3916d9f..2fa5a2a3 100644 --- a/stdlib/node/fs/promises.ts +++ b/stdlib/node/fs/promises.ts @@ -109,11 +109,18 @@ export function opendir(path: string, options?: any): Promise { return __fs /** Returns an async iterator of `{ eventType, filename }` change events for a path. */ export function watch(filename: string, options?: any): any { return __fsp.watch(filename, options); } +/** Asynchronously retrieves filesystem statistics for the path. */ +export function statfs(path: string, options?: any): Promise { return __fsp.statfs(path, options); } + +/** Returns an async iterator of paths matching a glob pattern (Node 22+). */ +export function glob(pattern: any, options?: any): any { return __fsp.glob(pattern, options); } + /** File-system constants — re-exported from 'fs' so both share one table. */ export { constants }; export default { readFile, writeFile, appendFile, stat, lstat, unlink, mkdir, rmdir, rm, cp, readdir, rename, copyFile, access, chmod, chown, lchown, truncate, utimes, - readlink, realpath, symlink, link, mkdtemp, open, opendir, watch, constants, + readlink, realpath, symlink, link, mkdtemp, open, opendir, watch, + statfs, glob, constants, };