diff --git a/src/bun.js/event_loop.zig b/src/bun.js/event_loop.zig index dc12ab3266beaa..e790e899366258 100644 --- a/src/bun.js/event_loop.zig +++ b/src/bun.js/event_loop.zig @@ -226,13 +226,14 @@ pub const AnyTaskWithExtraContext = struct { callback: *const (fn (*anyopaque, *anyopaque) void) = undefined, next: ?*AnyTaskWithExtraContext = null, + pub fn fromCallbackAutoDeinit(of: anytype, comptime callback: anytype) *AnyTaskWithExtraContext { + const TheTask = NewManaged(std.meta.Child(@TypeOf(of)), void, @field(std.meta.Child(@TypeOf(of)), callback)); + const task = bun.default_allocator.create(AnyTaskWithExtraContext) catch bun.outOfMemory(); + task.* = TheTask.init(of); + return task; + } + pub fn from(this: *@This(), of: anytype, comptime field: []const u8) *@This() { - // this.* = .{ - // .ctx = of, - // .callback = @field(std.meta.Child(@TypeOf(of)), field), - // .next = null, - // }; - // return this; const TheTask = New(std.meta.Child(@TypeOf(of)), void, @field(std.meta.Child(@TypeOf(of)), field)); this.* = TheTask.init(of); return this; @@ -266,6 +267,30 @@ pub const AnyTaskWithExtraContext = struct { } }; } + + pub fn NewManaged(comptime Type: type, comptime ContextType: type, comptime Callback: anytype) type { + return struct { + pub fn init(ctx: *Type) AnyTaskWithExtraContext { + return AnyTaskWithExtraContext{ + .callback = wrap, + .ctx = ctx, + }; + } + + pub fn wrap(this: ?*anyopaque, extra: ?*anyopaque) void { + @call( + .always_inline, + Callback, + .{ + @as(*Type, @ptrCast(@alignCast(this.?))), + @as(*ContextType, @ptrCast(@alignCast(extra.?))), + }, + ); + const anytask: *AnyTaskWithExtraContext = @fieldParentPtr(AnyTaskWithExtraContext, "ctx", @as(*?*anyopaque, @ptrCast(@alignCast(this.?)))); + bun.default_allocator.destroy(anytask); + } + }; + } }; pub const CppTask = opaque { @@ -363,6 +388,7 @@ const ShellMvCheckTargetTask = bun.shell.Interpreter.Builtin.Mv.ShellMvCheckTarg const ShellMvBatchedTask = bun.shell.Interpreter.Builtin.Mv.ShellMvBatchedTask; const ShellMkdirTask = bun.shell.Interpreter.Builtin.Mkdir.ShellMkdirTask; const ShellTouchTask = bun.shell.Interpreter.Builtin.Touch.ShellTouchTask; +const ShellCpTask = bun.shell.Interpreter.Builtin.Cp.ShellCpTask; const ShellCondExprStatTask = bun.shell.Interpreter.CondExpr.ShellCondExprStatTask; const ShellAsync = bun.shell.Interpreter.Async; // const ShellIOReaderAsyncDeinit = bun.shell.Interpreter.IOReader.AsyncDeinit; @@ -443,6 +469,7 @@ pub const Task = TaggedPointerUnion(.{ ShellLsTask, ShellMkdirTask, ShellTouchTask, + ShellCpTask, ShellCondExprStatTask, ShellAsync, ShellAsyncSubprocessDone, @@ -918,6 +945,10 @@ pub const EventLoop = struct { var shell_ls_task: *ShellCondExprStatTask = task.get(ShellCondExprStatTask).?; shell_ls_task.task.runFromMainThread(); }, + @field(Task.Tag, typeBaseName(@typeName(ShellCpTask))) => { + var shell_ls_task: *ShellCpTask = task.get(ShellCpTask).?; + shell_ls_task.runFromMainThread(); + }, @field(Task.Tag, typeBaseName(@typeName(ShellTouchTask))) => { var shell_ls_task: *ShellTouchTask = task.get(ShellTouchTask).?; shell_ls_task.runFromMainThread(); @@ -2094,6 +2125,13 @@ pub const EventLoopHandle = union(enum) { js: *JSC.EventLoop, mini: *MiniEventLoop, + pub fn globalObject(this: EventLoopHandle) ?*JSC.JSGlobalObject { + return switch (this) { + .js => this.js.global, + .mini => null, + }; + } + pub fn stdout(this: EventLoopHandle) *JSC.WebCore.Blob.Store { return switch (this) { .js => this.js.virtual_machine.rareData().stdout(), diff --git a/src/bun.js/node/node_fs.zig b/src/bun.js/node/node_fs.zig index 9e48f4fd22efa6..b7894ce876e498 100644 --- a/src/bun.js/node/node_fs.zig +++ b/src/bun.js/node/node_fs.zig @@ -242,111 +242,491 @@ pub const Async = struct { } }; -pub const AsyncCpTask = struct { - promise: JSC.JSPromise.Strong, - args: Arguments.Cp, - globalObject: *JSC.JSGlobalObject, - task: JSC.WorkPoolTask = .{ .callback = &workPoolCallback }, - result: JSC.Maybe(Return.Cp), - ref: bun.Async.KeepAlive = .{}, - arena: bun.ArenaAllocator, - tracker: JSC.AsyncTaskTracker, - has_result: std.atomic.Value(bool), - /// On each creation of a `AsyncCpSingleFileTask`, this is incremented. - /// When each task is finished, decrement. - /// The maintask thread starts this at 1 and decrements it at the end, to avoid the promise being resolved while new tasks may be added. - subtask_count: std.atomic.Value(usize), - - pub fn create( - globalObject: *JSC.JSGlobalObject, - cp_args: Arguments.Cp, - vm: *JSC.VirtualMachine, +pub const AsyncCpTask = NewAsyncCpTask(false); +pub const ShellAsyncCpTask = NewAsyncCpTask(true); + +pub fn NewAsyncCpTask(comptime is_shell: bool) type { + const ShellTask = bun.shell.Interpreter.Builtin.Cp.ShellCpTask; + const ShellTaskT = if (is_shell) *ShellTask else u0; + return struct { + promise: JSC.JSPromise.Strong = .{}, + args: Arguments.Cp, + evtloop: JSC.EventLoopHandle, + task: JSC.WorkPoolTask = .{ .callback = &workPoolCallback }, + result: JSC.Maybe(Return.Cp), + /// If this task is called by the shell then we shouldn't call this as + /// it is not threadsafe and is unnecessary as the process will be kept + /// alive by the shell instance + ref: if (!is_shell) bun.Async.KeepAlive else struct {} = .{}, arena: bun.ArenaAllocator, - ) JSC.JSValue { - var task = bun.new( - AsyncCpTask, - AsyncCpTask{ - .promise = JSC.JSPromise.Strong.init(globalObject), - .args = cp_args, - .has_result = .{ .raw = false }, - .result = undefined, - .globalObject = globalObject, - .tracker = JSC.AsyncTaskTracker.init(vm), - .arena = arena, - .subtask_count = .{ .raw = 1 }, - }, - ); - task.ref.ref(vm); - task.args.src.toThreadSafe(); - task.args.dest.toThreadSafe(); - task.tracker.didSchedule(globalObject); + tracker: JSC.AsyncTaskTracker, + has_result: std.atomic.Value(bool), + /// On each creation of a `AsyncCpSingleFileTask`, this is incremented. + /// When each task is finished, decrement. + /// The maintask thread starts this at 1 and decrements it at the end, to avoid the promise being resolved while new tasks may be added. + subtask_count: std.atomic.Value(usize), + deinitialized: bool = false, - JSC.WorkPool.schedule(&task.task); + shelltask: ShellTaskT, - return task.promise.value(); - } + const ThisAsyncCpTask = @This(); - fn workPoolCallback(task: *JSC.WorkPoolTask) void { - const this: *AsyncCpTask = @fieldParentPtr(AsyncCpTask, "task", task); + /// This task is used by `AsyncCpTask/fs.promises.cp` to copy a single file. + /// When clonefile cannot be used, this task is started once per file. + pub const SingleTask = struct { + cp_task: *ThisAsyncCpTask, + src: bun.OSPathSliceZ, + dest: bun.OSPathSliceZ, + task: JSC.WorkPoolTask = .{ .callback = &SingleTask.workPoolCallback }, - var node_fs = NodeFS{}; - node_fs.cpAsync(this); - } + const ThisSingleTask = @This(); - /// May be called from any thread (the subtasks) - fn finishConcurrently(this: *AsyncCpTask, result: Maybe(Return.Cp)) void { - if (this.has_result.cmpxchgStrong(false, true, .Monotonic, .Monotonic)) |_| { - return; + pub fn create( + parent: *ThisAsyncCpTask, + src: bun.OSPathSliceZ, + dest: bun.OSPathSliceZ, + ) void { + var task = bun.new(ThisSingleTask, .{ + .cp_task = parent, + .src = src, + .dest = dest, + }); + + JSC.WorkPool.schedule(&task.task); + } + + fn workPoolCallback(task: *JSC.WorkPoolTask) void { + var this: *ThisSingleTask = @fieldParentPtr(ThisSingleTask, "task", task); + + // TODO: error strings on node_fs will die + var node_fs = NodeFS{}; + + const args = this.cp_task.args; + const result = node_fs._copySingleFileSync( + this.src, + this.dest, + @enumFromInt((if (args.flags.errorOnExist or !args.flags.force) Constants.COPYFILE_EXCL else @as(u8, 0))), + null, + this.cp_task.args, + ); + + brk: { + switch (result) { + .err => |err| { + if (err.errno == @intFromEnum(E.EXIST) and !args.flags.errorOnExist) { + break :brk; + } + this.cp_task.finishConcurrently(result); + this.deinit(); + return; + }, + .result => { + this.cp_task.onCopy(this.src, this.dest); + }, + } + } + + const old_count = this.cp_task.subtask_count.fetchSub(1, .Monotonic); + if (old_count == 1) { + this.cp_task.finishConcurrently(Maybe(Return.Cp).success); + } + + this.deinit(); + } + + pub fn deinit(this: *ThisSingleTask) void { + // There is only one path buffer for both paths. 2 extra bytes are the nulls at the end of each + bun.default_allocator.free(this.src.ptr[0 .. this.src.len + this.dest.len + 2]); + + bun.destroy(this); + } + }; + + pub fn onCopy(this: *ThisAsyncCpTask, src: anytype, dest: anytype) void { + if (comptime !is_shell) return; + const task = this.shelltask; + task.cpOnCopy(src, dest); } - this.result = result; + pub fn onFinish(this: *ThisAsyncCpTask, result: Maybe(void)) void { + if (comptime !is_shell) return; + const task = this.shelltask; + task.cpOnFinish(result); + } - if (this.result == .err) { - this.result.err.path = bun.default_allocator.dupe(u8, this.result.err.path) catch ""; + pub fn create( + globalObject: *JSC.JSGlobalObject, + cp_args: Arguments.Cp, + vm: *JSC.VirtualMachine, + arena: bun.ArenaAllocator, + ) JSC.JSValue { + const task = createWithShellTask(globalObject, cp_args, vm, arena, 0, true); + return task.promise.value(); } - this.globalObject.bunVMConcurrently().eventLoop().enqueueTaskConcurrent(JSC.ConcurrentTask.fromCallback(this, runFromJSThread)); - } + pub fn createWithShellTask( + globalObject: *JSC.JSGlobalObject, + cp_args: Arguments.Cp, + vm: *JSC.VirtualMachine, + arena: bun.ArenaAllocator, + shelltask: ShellTaskT, + comptime enable_promise: bool, + ) *ThisAsyncCpTask { + var task = bun.new( + ThisAsyncCpTask, + ThisAsyncCpTask{ + .promise = if (comptime enable_promise) JSC.JSPromise.Strong.init(globalObject) else .{}, + .args = cp_args, + .has_result = .{ .raw = false }, + .result = undefined, + .evtloop = .{ .js = vm.event_loop }, + .tracker = JSC.AsyncTaskTracker.init(vm), + .arena = arena, + .subtask_count = .{ .raw = 1 }, + .shelltask = shelltask, + }, + ); + if (comptime !is_shell) task.ref.ref(vm); + task.args.src.toThreadSafe(); + task.args.dest.toThreadSafe(); + task.tracker.didSchedule(globalObject); + + JSC.WorkPool.schedule(&task.task); + + return task; + } + + pub fn createMini( + cp_args: Arguments.Cp, + mini: *JSC.MiniEventLoop, + arena: bun.ArenaAllocator, + shelltask: *ShellTask, + ) *ThisAsyncCpTask { + var task = bun.new( + ThisAsyncCpTask, + ThisAsyncCpTask{ + .args = cp_args, + .has_result = .{ .raw = false }, + .result = undefined, + .evtloop = .{ .mini = mini }, + .tracker = JSC.AsyncTaskTracker{ .id = 0 }, + .arena = arena, + .subtask_count = .{ .raw = 1 }, + .shelltask = shelltask, + }, + ); + if (comptime !is_shell) task.ref.ref(mini); + task.args.src.toThreadSafe(); + task.args.dest.toThreadSafe(); - fn runFromJSThread(this: *AsyncCpTask) void { - const globalObject = this.globalObject; - var success = @as(JSC.Maybe(Return.Cp).Tag, this.result) == .result; - const result = switch (this.result) { - .err => |err| err.toJSC(globalObject), - .result => |*res| brk: { - const out = globalObject.toJS(res, .temporary); - success = out != .zero; - - break :brk out; - }, - }; - var promise_value = this.promise.value(); - var promise = this.promise.get(); - promise_value.ensureStillAlive(); + JSC.WorkPool.schedule(&task.task); - const tracker = this.tracker; - tracker.willDispatch(globalObject); - defer tracker.didDispatch(globalObject); + return task; + } - this.deinit(); - switch (success) { - false => { - promise.reject(globalObject, result); - }, - true => { - promise.resolve(globalObject, result); - }, + fn workPoolCallback(task: *JSC.WorkPoolTask) void { + const this: *ThisAsyncCpTask = @fieldParentPtr(ThisAsyncCpTask, "task", task); + + var node_fs = NodeFS{}; + ThisAsyncCpTask.cpAsync(&node_fs, this); } - } - pub fn deinit(this: *AsyncCpTask) void { - this.ref.unref(this.globalObject.bunVM()); - this.args.deinit(); - this.promise.strong.deinit(); - this.arena.deinit(); - bun.destroy(this); - } -}; + /// May be called from any thread (the subtasks) + fn finishConcurrently(this: *ThisAsyncCpTask, result: Maybe(Return.Cp)) void { + if (this.has_result.cmpxchgStrong(false, true, .Monotonic, .Monotonic)) |_| { + return; + } + + this.result = result; + + if (this.result == .err) { + this.result.err.path = bun.default_allocator.dupe(u8, this.result.err.path) catch ""; + } + + if (this.evtloop == .js) { + this.evtloop.enqueueTaskConcurrent(.{ .js = JSC.ConcurrentTask.fromCallback(this, runFromJSThread) }); + } else { + this.evtloop.enqueueTaskConcurrent(.{ .mini = JSC.AnyTaskWithExtraContext.fromCallbackAutoDeinit(this, "runFromJSThreadMini") }); + } + } + + pub fn runFromJSThreadMini(this: *ThisAsyncCpTask, _: *void) void { + this.runFromJSThread(); + } + + fn runFromJSThread(this: *ThisAsyncCpTask) void { + if (comptime is_shell) { + this.shelltask.cpOnFinish(this.result); + this.deinit(); + return; + } + const globalObject = this.evtloop.globalObject() orelse { + @panic("No global object, this indicates a bug in Bun. Please file a GitHub issue."); + }; + var success = @as(JSC.Maybe(Return.Cp).Tag, this.result) == .result; + const result = switch (this.result) { + .err => |err| err.toJSC(globalObject), + .result => |*res| brk: { + const out = globalObject.toJS(res, .temporary); + success = out != .zero; + + break :brk out; + }, + }; + var promise_value = this.promise.value(); + var promise = this.promise.get(); + promise_value.ensureStillAlive(); + + const tracker = this.tracker; + tracker.willDispatch(globalObject); + defer tracker.didDispatch(globalObject); + + this.deinit(); + switch (success) { + false => { + promise.reject(globalObject, result); + }, + true => { + promise.resolve(globalObject, result); + }, + } + } + + pub fn deinit(this: *ThisAsyncCpTask) void { + bun.assert(!this.deinitialized); + this.deinitialized = true; + if (comptime !is_shell) this.ref.unref(this.evtloop); + this.args.deinit(); + this.promise.strong.deinit(); + this.arena.deinit(); + bun.destroy(this); + } + + /// Directory scanning + clonefile will block this thread, then each individual file copy (what the sync version + /// calls "_copySingleFileSync") will be dispatched as a separate task. + pub fn cpAsync( + nodefs: *NodeFS, + this: *ThisAsyncCpTask, + ) void { + const args = this.args; + var src_buf: bun.OSPathBuffer = undefined; + var dest_buf: bun.OSPathBuffer = undefined; + const src = args.src.osPath(@ptrCast(&src_buf)); + const dest = args.dest.osPath(@ptrCast(&dest_buf)); + + if (Environment.isWindows) { + const attributes = windows.GetFileAttributesW(src); + if (attributes == windows.INVALID_FILE_ATTRIBUTES) { + this.finishConcurrently(.{ .err = .{ + .errno = @intFromEnum(C.SystemErrno.ENOENT), + .syscall = .copyfile, + .path = nodefs.osPathIntoSyncErrorBuf(src), + } }); + return; + } + const file_or_symlink = (attributes & windows.FILE_ATTRIBUTE_DIRECTORY) == 0 or (attributes & windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0; + if (file_or_symlink) { + const r = nodefs._copySingleFileSync( + src, + dest, + if (comptime is_shell) + // Shell always forces copy + @enumFromInt(Constants.Copyfile.force) + else + @enumFromInt((if (args.flags.errorOnExist or !args.flags.force) Constants.COPYFILE_EXCL else @as(u8, 0))), + attributes, + this.args, + ); + if (r == .err and r.err.errno == @intFromEnum(E.EXIST) and !args.flags.errorOnExist) { + this.finishConcurrently(Maybe(Return.Cp).success); + return; + } + this.onCopy(src, dest); + this.finishConcurrently(r); + return; + } + } else { + const stat_ = switch (Syscall.lstat(src)) { + .result => |result| result, + .err => |err| { + @memcpy(nodefs.sync_error_buf[0..src.len], src); + this.finishConcurrently(.{ .err = err.withPath(nodefs.sync_error_buf[0..src.len]) }); + return; + }, + }; + + if (!os.S.ISDIR(stat_.mode)) { + // This is the only file, there is no point in dispatching subtasks + const r = nodefs._copySingleFileSync( + src, + dest, + @enumFromInt((if (args.flags.errorOnExist or !args.flags.force) Constants.COPYFILE_EXCL else @as(u8, 0))), + stat_, + this.args, + ); + if (r == .err and r.err.errno == @intFromEnum(E.EXIST) and !args.flags.errorOnExist) { + this.onCopy(src, dest); + this.finishConcurrently(Maybe(Return.Cp).success); + return; + } + this.onCopy(src, dest); + this.finishConcurrently(r); + return; + } + } + if (!args.flags.recursive) { + this.finishConcurrently(.{ .err = .{ + .errno = @intFromEnum(E.ISDIR), + .syscall = .copyfile, + .path = nodefs.osPathIntoSyncErrorBuf(src), + } }); + return; + } + + const success = ThisAsyncCpTask._cpAsyncDirectory(nodefs, args.flags, this, &src_buf, @intCast(src.len), &dest_buf, @intCast(dest.len)); + const old_count = this.subtask_count.fetchSub(1, .Monotonic); + if (success and old_count == 1) { + this.finishConcurrently(Maybe(Return.Cp).success); + } + } + + // returns boolean `should_continue` + fn _cpAsyncDirectory( + nodefs: *NodeFS, + args: Arguments.Cp.Flags, + this: *ThisAsyncCpTask, + src_buf: *bun.OSPathBuffer, + src_dir_len: PathString.PathInt, + dest_buf: *bun.OSPathBuffer, + dest_dir_len: PathString.PathInt, + ) bool { + const src = src_buf[0..src_dir_len :0]; + const dest = dest_buf[0..dest_dir_len :0]; + + if (comptime Environment.isMac) { + if (Maybe(Return.Cp).errnoSysP(C.clonefile(src, dest, 0), .clonefile, src)) |err| { + switch (err.getErrno()) { + .ACCES, + .NAMETOOLONG, + .ROFS, + .PERM, + .INVAL, + => { + @memcpy(nodefs.sync_error_buf[0..src.len], src); + this.finishConcurrently(.{ .err = err.err.withPath(nodefs.sync_error_buf[0..src.len]) }); + return false; + }, + // Other errors may be due to clonefile() not being supported + // We'll fall back to other implementations + else => {}, + } + } else { + return true; + } + } + + const open_flags = os.O.DIRECTORY | os.O.RDONLY; + const fd = switch (Syscall.openatOSPath(bun.FD.cwd(), src, open_flags, 0)) { + .err => |err| { + this.finishConcurrently(.{ .err = err.withPath(nodefs.osPathIntoSyncErrorBuf(src)) }); + return false; + }, + .result => |fd_| fd_, + }; + defer _ = Syscall.close(fd); + + var buf: bun.OSPathBuffer = undefined; + + // const normdest = bun.path.normalizeStringGenericTZ(bun.OSPathChar, dest, &buf, true, std.fs.path.sep, bun.path.isSepAnyT, false, true); + const normdest: bun.OSPathSliceZ = if (Environment.isWindows) + switch (bun.sys.normalizePathWindows(u16, bun.invalid_fd, dest, &buf)) { + .err => |err| { + this.finishConcurrently(.{ .err = err }); + return false; + }, + .result => |normdest| normdest, + } + else + dest; + + const mkdir_ = nodefs.mkdirRecursiveOSPath(normdest, Arguments.Mkdir.DefaultMode, false); + switch (mkdir_) { + .err => |err| { + this.finishConcurrently(.{ .err = err }); + return false; + }, + .result => { + this.onCopy(src, normdest); + }, + } + + const dir = fd.asDir(); + var iterator = DirIterator.iterate(dir, if (Environment.isWindows) .u16 else .u8); + var entry = iterator.next(); + while (switch (entry) { + .err => |err| { + // @memcpy(this.sync_error_buf[0..src.len], src); + this.finishConcurrently(.{ .err = err.withPath(nodefs.osPathIntoSyncErrorBuf(src)) }); + return false; + }, + .result => |ent| ent, + }) |current| : (entry = iterator.next()) { + switch (current.kind) { + .directory => { + const cname = current.name.slice(); + @memcpy(src_buf[src_dir_len + 1 .. src_dir_len + 1 + cname.len], cname); + src_buf[src_dir_len] = std.fs.path.sep; + src_buf[src_dir_len + 1 + cname.len] = 0; + + @memcpy(dest_buf[dest_dir_len + 1 .. dest_dir_len + 1 + cname.len], cname); + dest_buf[dest_dir_len] = std.fs.path.sep; + dest_buf[dest_dir_len + 1 + cname.len] = 0; + + const should_continue = ThisAsyncCpTask._cpAsyncDirectory( + nodefs, + args, + this, + src_buf, + @truncate(src_dir_len + 1 + cname.len), + dest_buf, + @truncate(dest_dir_len + 1 + cname.len), + ); + if (!should_continue) return false; + }, + else => { + _ = this.subtask_count.fetchAdd(1, .Monotonic); + + const cname = current.name.slice(); + + // Allocate a path buffer for the path data + var path_buf = bun.default_allocator.alloc( + bun.OSPathChar, + src_dir_len + 1 + cname.len + 1 + dest_dir_len + 1 + cname.len + 1, + ) catch bun.outOfMemory(); + + @memcpy(path_buf[0..src_dir_len], src_buf[0..src_dir_len]); + path_buf[src_dir_len] = std.fs.path.sep; + @memcpy(path_buf[src_dir_len + 1 .. src_dir_len + 1 + cname.len], cname); + path_buf[src_dir_len + 1 + cname.len] = 0; + + @memcpy(path_buf[src_dir_len + 1 + cname.len + 1 .. src_dir_len + 1 + cname.len + 1 + dest_dir_len], dest_buf[0..dest_dir_len]); + path_buf[src_dir_len + 1 + cname.len + 1 + dest_dir_len] = std.fs.path.sep; + @memcpy(path_buf[src_dir_len + 1 + cname.len + 1 + dest_dir_len + 1 .. src_dir_len + 1 + cname.len + 1 + dest_dir_len + 1 + cname.len], cname); + path_buf[src_dir_len + 1 + cname.len + 1 + dest_dir_len + 1 + cname.len] = 0; + + SingleTask.create( + this, + path_buf[0 .. src_dir_len + 1 + cname.len :0], + path_buf[src_dir_len + 1 + cname.len + 1 .. src_dir_len + 1 + cname.len + 1 + dest_dir_len + 1 + cname.len :0], + ); + }, + } + } + + return true; + } + }; +} pub const AsyncReaddirRecursiveTask = struct { promise: JSC.JSPromise.Strong, @@ -686,72 +1066,6 @@ pub const AsyncReaddirRecursiveTask = struct { } }; -/// This task is used by `AsyncCpTask/fs.promises.cp` to copy a single file. -/// When clonefile cannot be used, this task is started once per file. -pub const AsyncCpSingleFileTask = struct { - cp_task: *AsyncCpTask, - src: bun.OSPathSliceZ, - dest: bun.OSPathSliceZ, - task: JSC.WorkPoolTask = .{ .callback = &workPoolCallback }, - - pub fn create( - parent: *AsyncCpTask, - src: bun.OSPathSliceZ, - dest: bun.OSPathSliceZ, - ) void { - var task = bun.new(AsyncCpSingleFileTask, .{ - .cp_task = parent, - .src = src, - .dest = dest, - }); - - JSC.WorkPool.schedule(&task.task); - } - - fn workPoolCallback(task: *JSC.WorkPoolTask) void { - var this: *AsyncCpSingleFileTask = @fieldParentPtr(AsyncCpSingleFileTask, "task", task); - - // TODO: error strings on node_fs will die - var node_fs = NodeFS{}; - - const args = this.cp_task.args; - const result = node_fs._copySingleFileSync( - this.src, - this.dest, - @enumFromInt((if (args.flags.errorOnExist or !args.flags.force) Constants.COPYFILE_EXCL else @as(u8, 0))), - null, - ); - - brk: { - switch (result) { - .err => |err| { - if (err.errno == @intFromEnum(E.EXIST) and !args.flags.errorOnExist) { - break :brk; - } - this.cp_task.finishConcurrently(result); - this.deinit(); - return; - }, - .result => {}, - } - } - - const old_count = this.cp_task.subtask_count.fetchSub(1, .Monotonic); - if (old_count == 1) { - this.cp_task.finishConcurrently(Maybe(Return.Cp).success); - } - - this.deinit(); - } - - pub fn deinit(this: *AsyncCpSingleFileTask) void { - // There is only one path buffer for both paths. 2 extra bytes are the nulls at the end of each - bun.default_allocator.free(this.src.ptr[0 .. this.src.len + this.dest.len + 2]); - - bun.destroy(this); - } -}; - // TODO: to improve performance for all of these // The tagged unions for each type should become regular unions // and the tags should be passed in as comptime arguments to the functions performing the syscalls @@ -3426,11 +3740,14 @@ pub const Arguments = struct { recursive: bool, errorOnExist: bool, force: bool, + deinit_paths: bool = true, }; - fn deinit(this: Cp) void { - this.src.deinit(); - this.dest.deinit(); + fn deinit(this: *Cp) void { + if (this.flags.deinit_paths) { + this.src.deinit(); + this.dest.deinit(); + } } pub fn fromJS(ctx: JSC.C.JSContextRef, arguments: *ArgumentsSlice, exception: JSC.C.ExceptionRef) ?Cp { @@ -4113,7 +4430,7 @@ pub const NodeFS = struct { const dest = strings.toWPathNormalizeAutoExtend(&dest_buf, args.dest.sliceZ(&this.sync_error_buf)); if (windows.CopyFileW(src.ptr, dest.ptr, if (args.mode.shouldntOverwrite()) 1 else 0) == windows.FALSE) { if (ret.errnoSysP(0, .copyfile, args.src.slice())) |rest| { - return rest; + return shouldIgnoreEbusy(args.src, args.dest, rest); } } @@ -5937,7 +6254,7 @@ pub const NodeFS = struct { @intCast(src.len), @as(*bun.OSPathBuffer, @alignCast(@ptrCast(&dest_buf))), @intCast(dest.len), - args.flags, + args, ); } @@ -5964,8 +6281,9 @@ pub const NodeFS = struct { src_dir_len: PathString.PathInt, dest_buf: *bun.OSPathBuffer, dest_dir_len: PathString.PathInt, - args: Arguments.Cp.Flags, + args: Arguments.Cp, ) Maybe(Return.Cp) { + const cp_flags = args.flags; const src = src_buf[0..src_dir_len :0]; const dest = dest_buf[0..dest_dir_len :0]; @@ -5983,10 +6301,11 @@ pub const NodeFS = struct { const r = this._copySingleFileSync( src, dest, - @enumFromInt((if (args.errorOnExist or !args.force) Constants.COPYFILE_EXCL else @as(u8, 0))), + @enumFromInt((if (cp_flags.errorOnExist or !cp_flags.force) Constants.COPYFILE_EXCL else @as(u8, 0))), attributes, + args, ); - if (r == .err and r.err.errno == @intFromEnum(E.EXIST) and !args.errorOnExist) { + if (r == .err and r.err.errno == @intFromEnum(E.EXIST) and !cp_flags.errorOnExist) { return Maybe(Return.Cp).success; } return r; @@ -6004,17 +6323,18 @@ pub const NodeFS = struct { const r = this._copySingleFileSync( src, dest, - @enumFromInt((if (args.errorOnExist or !args.force) Constants.COPYFILE_EXCL else @as(u8, 0))), + @enumFromInt((if (cp_flags.errorOnExist or !cp_flags.force) Constants.COPYFILE_EXCL else @as(u8, 0))), stat_, + args, ); - if (r == .err and r.err.errno == @intFromEnum(E.EXIST) and !args.errorOnExist) { + if (r == .err and r.err.errno == @intFromEnum(E.EXIST) and !cp_flags.errorOnExist) { return Maybe(Return.Cp).success; } return r; } } - if (!args.recursive) { + if (!cp_flags.recursive) { return .{ .err = .{ .errno = @intFromEnum(E.ISDIR), @@ -6045,11 +6365,10 @@ pub const NodeFS = struct { } } - const flags = os.O.DIRECTORY | os.O.RDONLY; const fd = switch (Syscall.openatOSPath( bun.toFD((std.fs.cwd().fd)), src, - flags, + os.O.DIRECTORY | os.O.RDONLY, 0, )) { .err => |err| { @@ -6103,12 +6422,13 @@ pub const NodeFS = struct { const r = this._copySingleFileSync( src_buf[0 .. src_dir_len + 1 + name_slice.len :0], dest_buf[0 .. dest_dir_len + 1 + name_slice.len :0], - @enumFromInt((if (args.errorOnExist or !args.force) Constants.COPYFILE_EXCL else @as(u8, 0))), + @enumFromInt((if (cp_flags.errorOnExist or !cp_flags.force) Constants.COPYFILE_EXCL else @as(u8, 0))), null, + args, ); switch (r) { .err => { - if (r.err.errno == @intFromEnum(E.EXIST) and !args.errorOnExist) { + if (r.err.errno == @intFromEnum(E.EXIST) and !cp_flags.errorOnExist) { continue; } return r; @@ -6121,6 +6441,40 @@ pub const NodeFS = struct { return Maybe(Return.Cp).success; } + /// On Windows, copying a file onto itself will return EBUSY, which is an + /// unintuitive and cryptic error to return to the user for an operation + /// that should seemingly be a no-op. + /// + /// So we check if the source and destination are the same file, and if they + /// are, we return success. + /// + /// This is copied directly from libuv's implementation of `uv_fs_copyfile` + /// for Windows: + /// + /// https://github.com/libuv/libuv/blob/497f3168d13ea9a92ad18c28e8282777ec2acf73/src/win/fs.c#L2069 + /// + /// **This function does nothing on non-Windows platforms**. + fn shouldIgnoreEbusy(src: PathLike, dest: PathLike, result: Maybe(Return.CopyFile)) Maybe(Return.CopyFile) { + if (comptime !Environment.isWindows) return result; + if (result != .err or result.err.getErrno() != .BUSY) return result; + + var buf: bun.PathBuffer = undefined; + const statbuf = switch (Syscall.stat(src.sliceZ(&buf))) { + .result => |b| b, + .err => return result, + }; + const new_statbuf = switch (Syscall.stat(dest.sliceZ(&buf))) { + .result => |b| b, + .err => return result, + }; + + if (statbuf.dev == new_statbuf.dev and statbuf.ino == new_statbuf.ino) { + return Maybe(Return.CopyFile).success; + } + + return result; + } + /// This is `copyFile`, but it copies symlinks as-is pub fn _copySingleFileSync( this: *NodeFS, @@ -6129,6 +6483,7 @@ pub const NodeFS = struct { mode: Constants.Copyfile, /// Stat on posix, file attributes on windows reuse_stat: ?if (Environment.isWindows) windows.DWORD else std.os.Stat, + args: Arguments.Cp, ) Maybe(Return.CopyFile) { const ret = Maybe(Return.CopyFile); @@ -6402,11 +6757,15 @@ pub const NodeFS = struct { .FILE_EXISTS, .ALREADY_EXISTS => dest, else => src, }; - return Maybe(Return.CopyFile).errnoSysP(0, .copyfile, this.osPathIntoSyncErrorBuf(errpath)) orelse .{ .err = .{ - .errno = @intFromEnum(C.SystemErrno.ENOENT), - .syscall = .copyfile, - .path = this.osPathIntoSyncErrorBuf(src), - } }; + return shouldIgnoreEbusy( + args.src, + args.dest, + Maybe(Return.CopyFile).errnoSysP(0, .copyfile, this.osPathIntoSyncErrorBuf(errpath)) orelse .{ .err = .{ + .errno = @intFromEnum(C.SystemErrno.ENOENT), + .syscall = .copyfile, + .path = this.osPathIntoSyncErrorBuf(src), + } }, + ); } return ret.success; } else { @@ -6446,77 +6805,7 @@ pub const NodeFS = struct { /// Directory scanning + clonefile will block this thread, then each individual file copy (what the sync version /// calls "_copySingleFileSync") will be dispatched as a separate task. pub fn cpAsync(this: *NodeFS, task: *AsyncCpTask) void { - const args = task.args; - var src_buf: bun.OSPathBuffer = undefined; - var dest_buf: bun.OSPathBuffer = undefined; - const src = args.src.osPath(@ptrCast(&src_buf)); - const dest = args.dest.osPath(@ptrCast(&dest_buf)); - - if (Environment.isWindows) { - const attributes = windows.GetFileAttributesW(src); - if (attributes == windows.INVALID_FILE_ATTRIBUTES) { - task.finishConcurrently(.{ .err = .{ - .errno = @intFromEnum(C.SystemErrno.ENOENT), - .syscall = .copyfile, - .path = this.osPathIntoSyncErrorBuf(src), - } }); - return; - } - const file_or_symlink = (attributes & windows.FILE_ATTRIBUTE_DIRECTORY) == 0 or (attributes & windows.FILE_ATTRIBUTE_REPARSE_POINT) != 0; - if (file_or_symlink) { - const r = this._copySingleFileSync( - src, - dest, - @enumFromInt((if (args.flags.errorOnExist or !args.flags.force) Constants.COPYFILE_EXCL else @as(u8, 0))), - attributes, - ); - if (r == .err and r.err.errno == @intFromEnum(E.EXIST) and !args.flags.errorOnExist) { - task.finishConcurrently(Maybe(Return.Cp).success); - return; - } - task.finishConcurrently(r); - return; - } - } else { - const stat_ = switch (Syscall.lstat(src)) { - .result => |result| result, - .err => |err| { - @memcpy(this.sync_error_buf[0..src.len], src); - task.finishConcurrently(.{ .err = err.withPath(this.sync_error_buf[0..src.len]) }); - return; - }, - }; - - if (!os.S.ISDIR(stat_.mode)) { - // This is the only file, there is no point in dispatching subtasks - const r = this._copySingleFileSync( - src, - dest, - @enumFromInt((if (args.flags.errorOnExist or !args.flags.force) Constants.COPYFILE_EXCL else @as(u8, 0))), - stat_, - ); - if (r == .err and r.err.errno == @intFromEnum(E.EXIST) and !args.flags.errorOnExist) { - task.finishConcurrently(Maybe(Return.Cp).success); - return; - } - task.finishConcurrently(r); - return; - } - } - if (!args.flags.recursive) { - task.finishConcurrently(.{ .err = .{ - .errno = @intFromEnum(E.ISDIR), - .syscall = .copyfile, - .path = this.osPathIntoSyncErrorBuf(src), - } }); - return; - } - - const success = this._cpAsyncDirectory(args.flags, task, &src_buf, @intCast(src.len), &dest_buf, @intCast(dest.len)); - const old_count = task.subtask_count.fetchSub(1, .Monotonic); - if (success and old_count == 1) { - task.finishConcurrently(Maybe(Return.Cp).success); - } + AsyncCpTask.cpAsync(this, task); } // returns boolean `should_continue` @@ -6529,127 +6818,7 @@ pub const NodeFS = struct { dest_buf: *bun.OSPathBuffer, dest_dir_len: PathString.PathInt, ) bool { - const src = src_buf[0..src_dir_len :0]; - const dest = dest_buf[0..dest_dir_len :0]; - - if (comptime Environment.isMac) { - if (Maybe(Return.Cp).errnoSysP(C.clonefile(src, dest, 0), .clonefile, src)) |err| { - switch (err.getErrno()) { - .ACCES, - .NAMETOOLONG, - .ROFS, - .PERM, - .INVAL, - => { - @memcpy(this.sync_error_buf[0..src.len], src); - task.finishConcurrently(.{ .err = err.err.withPath(this.sync_error_buf[0..src.len]) }); - return false; - }, - // Other errors may be due to clonefile() not being supported - // We'll fall back to other implementations - else => {}, - } - } else { - return true; - } - } - - const open_flags = os.O.DIRECTORY | os.O.RDONLY; - const fd = switch (Syscall.openatOSPath(bun.FD.cwd(), src, open_flags, 0)) { - .err => |err| { - task.finishConcurrently(.{ .err = err.withPath(this.osPathIntoSyncErrorBuf(src)) }); - return false; - }, - .result => |fd_| fd_, - }; - defer _ = Syscall.close(fd); - - var buf: bun.OSPathBuffer = undefined; - - // const normdest = bun.path.normalizeStringGenericTZ(bun.OSPathChar, dest, &buf, true, std.fs.path.sep, bun.path.isSepAnyT, false, true); - const normdest: bun.OSPathSliceZ = if (Environment.isWindows) - switch (bun.sys.normalizePathWindows(u16, bun.invalid_fd, dest, &buf)) { - .err => |err| { - task.finishConcurrently(.{ .err = err }); - return false; - }, - .result => |normdest| normdest, - } - else - dest; - - const mkdir_ = this.mkdirRecursiveOSPath(normdest, Arguments.Mkdir.DefaultMode, false); - switch (mkdir_) { - .err => |err| { - task.finishConcurrently(.{ .err = err }); - return false; - }, - .result => {}, - } - - const dir = fd.asDir(); - var iterator = DirIterator.iterate(dir, if (Environment.isWindows) .u16 else .u8); - var entry = iterator.next(); - while (switch (entry) { - .err => |err| { - // @memcpy(this.sync_error_buf[0..src.len], src); - task.finishConcurrently(.{ .err = err.withPath(this.osPathIntoSyncErrorBuf(src)) }); - return false; - }, - .result => |ent| ent, - }) |current| : (entry = iterator.next()) { - switch (current.kind) { - .directory => { - const cname = current.name.slice(); - @memcpy(src_buf[src_dir_len + 1 .. src_dir_len + 1 + cname.len], cname); - src_buf[src_dir_len] = std.fs.path.sep; - src_buf[src_dir_len + 1 + cname.len] = 0; - - @memcpy(dest_buf[dest_dir_len + 1 .. dest_dir_len + 1 + cname.len], cname); - dest_buf[dest_dir_len] = std.fs.path.sep; - dest_buf[dest_dir_len + 1 + cname.len] = 0; - - const should_continue = this._cpAsyncDirectory( - args, - task, - src_buf, - @truncate(src_dir_len + 1 + cname.len), - dest_buf, - @truncate(dest_dir_len + 1 + cname.len), - ); - if (!should_continue) return false; - }, - else => { - _ = task.subtask_count.fetchAdd(1, .Monotonic); - - const cname = current.name.slice(); - - // Allocate a path buffer for the path data - var path_buf = bun.default_allocator.alloc( - bun.OSPathChar, - src_dir_len + 1 + cname.len + 1 + dest_dir_len + 1 + cname.len + 1, - ) catch bun.outOfMemory(); - - @memcpy(path_buf[0..src_dir_len], src_buf[0..src_dir_len]); - path_buf[src_dir_len] = std.fs.path.sep; - @memcpy(path_buf[src_dir_len + 1 .. src_dir_len + 1 + cname.len], cname); - path_buf[src_dir_len + 1 + cname.len] = 0; - - @memcpy(path_buf[src_dir_len + 1 + cname.len + 1 .. src_dir_len + 1 + cname.len + 1 + dest_dir_len], dest_buf[0..dest_dir_len]); - path_buf[src_dir_len + 1 + cname.len + 1 + dest_dir_len] = std.fs.path.sep; - @memcpy(path_buf[src_dir_len + 1 + cname.len + 1 + dest_dir_len + 1 .. src_dir_len + 1 + cname.len + 1 + dest_dir_len + 1 + cname.len], cname); - path_buf[src_dir_len + 1 + cname.len + 1 + dest_dir_len + 1 + cname.len] = 0; - - AsyncCpSingleFileTask.create( - task, - path_buf[0 .. src_dir_len + 1 + cname.len :0], - path_buf[src_dir_len + 1 + cname.len + 1 .. src_dir_len + 1 + cname.len + 1 + dest_dir_len + 1 + cname.len :0], - ); - }, - } - } - - return true; + return AsyncCpTask._cpAsyncDirectory(this, args, task, src_buf, src_dir_len, dest_buf, dest_dir_len); } }; diff --git a/src/bun.zig b/src/bun.zig index 536512da2a8e36..1a3f9721ae7da2 100644 --- a/src/bun.zig +++ b/src/bun.zig @@ -759,6 +759,24 @@ pub fn openDirAbsolute(path_: []const u8) !std.fs.Dir { } } pub const MimallocArena = @import("./mimalloc_arena.zig").Arena; +pub fn getRuntimeFeatureFlag(comptime flag: [:0]const u8) bool { + return struct { + const flag_ = flag; + const state = enum(u8) { idk, disabled, enabled }; + var is_enabled: std.atomic.Value(state) = std.atomic.Value(state).init(.idk); + pub fn get() bool { + return switch (is_enabled.load(.SeqCst)) { + .enabled => true, + .disabled => false, + .idk => { + const enabled = if (getenvZ(flag_)) |val| strings.eqlComptime(val, "1") or strings.eqlComptime(val, "true") else false; + is_enabled.store(if (enabled) .enabled else .disabled, .SeqCst); + return enabled; + }, + }; + } + }.get(); +} /// This wrapper exists to avoid the call to sliceTo(0) /// Zig's sliceTo(0) is scalar diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index 69cd88e897d3fd..77f755987f2955 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -20,6 +20,15 @@ export const SQL = $cpp("JSSQLStatement.cpp", "createJSSQLStatementConstructor") export const shellInternals = { lex: $newZigFunction("shell.zig", "TestingAPIs.shellLex", 1), parse: $newZigFunction("shell.zig", "TestingAPIs.shellParse", 1), + /** + * Checks if the given builtin is disabled on the current platform + * + * @example + * ```typescript + * const isDisabled = builtinDisabled("cp") + * ``` + */ + builtinDisabled: $newZigFunction("shell.zig", "TestingAPIs.disabledOnThisPlatform", 1), }; export const crash_handler = $zig("crash_handler.zig", "js_bindings.generate") as { diff --git a/src/shell/interpreter.zig b/src/shell/interpreter.zig index 3c2565c8df508b..7f0efeb656c459 100644 --- a/src/shell/interpreter.zig +++ b/src/shell/interpreter.zig @@ -151,14 +151,14 @@ const CowFd = struct { refcount: u32 = 1, being_used: bool = false, - const print = bun.Output.scoped(.CowFd, true); + const debug = bun.Output.scoped(.CowFd, true); pub fn init(fd: bun.FileDescriptor) *CowFd { const this = bun.default_allocator.create(CowFd) catch bun.outOfMemory(); this.* = .{ .__fd = fd, }; - print("init(0x{x}, fd={})", .{ @intFromPtr(this), fd }); + debug("init(0x{x}, fd={})", .{ @intFromPtr(this), fd }); return this; } @@ -167,7 +167,7 @@ const CowFd = struct { .fd = bun.sys.dup(this.fd), .writercount = 1, }); - print("dup(0x{x}, fd={}) = (0x{x}, fd={})", .{ @intFromPtr(this), this.fd, new, new.fd }); + debug("dup(0x{x}, fd={}) = (0x{x}, fd={})", .{ @intFromPtr(this), this.fd, new, new.fd }); return new; } @@ -359,7 +359,7 @@ pub const EnvStr = packed struct { tag: Tag = .empty, len: usize = 0, - const print = bun.Output.scoped(.EnvStr, true); + const debug = bun.Output.scoped(.EnvStr, true); const Tag = enum(u16) { /// no value @@ -438,10 +438,10 @@ pub const RefCountedStr = struct { len: u32 = 0, ptr: [*]const u8 = undefined, - const print = bun.Output.scoped(.RefCountedEnvStr, true); + const debug = bun.Output.scoped(.RefCountedEnvStr, true); fn init(slice: []const u8) *RefCountedStr { - print("init: {s}", .{slice}); + debug("init: {s}", .{slice}); const this = bun.default_allocator.create(RefCountedStr) catch bun.outOfMemory(); this.* = .{ .refcount = 1, @@ -468,7 +468,7 @@ pub const RefCountedStr = struct { } fn deinit(this: *RefCountedStr) void { - print("deinit: {s}", .{this.byteSlice()}); + debug("deinit: {s}", .{this.byteSlice()}); this.freeStr(); bun.default_allocator.destroy(this); } @@ -1060,7 +1060,7 @@ pub const Interpreter = struct { } if (comptime bun.Environment.allow_assert) { - const print = bun.Output.scoped(.ShellTokens, true); + const debug = bun.Output.scoped(.ShellTokens, true); var test_tokens = std.ArrayList(shell.Test.TestToken).initCapacity(arena.allocator(), lex_result.tokens.len) catch @panic("OOPS"); defer test_tokens.deinit(); for (lex_result.tokens) |tok| { @@ -1070,7 +1070,7 @@ pub const Interpreter = struct { const str = std.json.stringifyAlloc(bun.default_allocator, test_tokens.items[0..], .{}) catch @panic("OOPS"); defer bun.default_allocator.free(str); - print("Tokens: {s}", .{str}); + debug("Tokens: {s}", .{str}); } out_parser.* = try bun.shell.Parser.new(arena.allocator(), lex_result, jsobjs); @@ -2336,7 +2336,7 @@ pub const Interpreter = struct { } pub const ShellGlobTask = struct { - const print = bun.Output.scoped(.ShellGlobTask, true); + const debug = bun.Output.scoped(.ShellGlobTask, true); task: WorkPoolTask = .{ .callback = &runFromThreadPool }, @@ -2368,7 +2368,7 @@ pub const Interpreter = struct { }; pub fn createOnMainThread(allocator: Allocator, walker: *GlobWalker, expansion: *Expansion) *This { - print("createOnMainThread", .{}); + debug("createOnMainThread", .{}); var this = allocator.create(This) catch bun.outOfMemory(); this.* = .{ .event_loop = expansion.base.eventLoop(), @@ -2385,7 +2385,7 @@ pub const Interpreter = struct { } pub fn runFromThreadPool(task: *WorkPoolTask) void { - print("runFromThreadPool", .{}); + debug("runFromThreadPool", .{}); var this = @fieldParentPtr(This, "task", task); switch (this.walkImpl()) { .result => {}, @@ -2397,7 +2397,7 @@ pub const Interpreter = struct { } fn walkImpl(this: *This) Maybe(void) { - print("walkImpl", .{}); + debug("walkImpl", .{}); var iter = GlobWalker.Iterator{ .walker = this.walker }; defer iter.deinit(); @@ -2417,7 +2417,7 @@ pub const Interpreter = struct { } pub fn runFromMainThread(this: *This) void { - print("runFromJS", .{}); + debug("runFromJS", .{}); this.expansion.onGlobWalkDone(this); this.ref.unref(this.event_loop); } @@ -2427,12 +2427,12 @@ pub const Interpreter = struct { } pub fn schedule(this: *This) void { - print("schedule", .{}); + debug("schedule", .{}); WorkPool.schedule(&this.task); } pub fn onFinish(this: *This) void { - print("onFinish", .{}); + debug("onFinish", .{}); if (this.event_loop == .js) { this.event_loop.js.enqueueTaskConcurrent(this.concurrent_task.js.from(this, .manual_deinit)); } else { @@ -2441,7 +2441,7 @@ pub const Interpreter = struct { } pub fn deinit(this: *This) void { - print("deinit", .{}); + debug("deinit", .{}); this.result.deinit(); this.allocator.destroy(this); } @@ -4935,6 +4935,7 @@ pub const Interpreter = struct { seq: Seq, dirname: Dirname, basename: Basename, + cp: Cp, }; const Result = @import("../result.zig").Result; @@ -4959,6 +4960,9 @@ pub const Interpreter = struct { seq, dirname, basename, + cp, + + pub const DISABLED_ON_POSIX: []const Kind = &.{ .cat, .cp }; pub fn parentType(this: Kind) type { _ = this; @@ -4984,17 +4988,25 @@ pub const Interpreter = struct { .seq => "usage: seq [-w] [-f format] [-s string] [-t string] [first [incr]] last\n", .dirname => "usage: dirname string\n", .basename => "usage: basename string\n", + .cp => "usage: cp [-R [-H | -L | -P]] [-fi | -n] [-aclpsvXx] source_file target_file\n cp [-R [-H | -L | -P]] [-fi | -n] [-aclpsvXx] source_file ... target_directory\n", }; } + fn forceEnableOnPosix() bool { + return bun.getRuntimeFeatureFlag("BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS"); + } + pub fn fromStr(str: []const u8) ?Builtin.Kind { - if (!bun.Environment.isWindows) { - if (bun.strings.eqlComptime(str, "cat")) { - log("Cat builtin disabled on posix for now", .{}); + const result = std.meta.stringToEnum(Builtin.Kind, str) orelse return null; + if (bun.Environment.isWindows) return result; + if (forceEnableOnPosix()) return result; + inline for (Builtin.Kind.DISABLED_ON_POSIX) |disabled| { + if (disabled == result) { + log("{s} builtin disabled on posix for now", .{@tagName(disabled)}); return null; } } - return std.meta.stringToEnum(Builtin.Kind, str); + return result; } }; @@ -5145,6 +5157,7 @@ pub const Interpreter = struct { .seq => this.callImplWithType(Seq, Ret, "seq", field, args_), .dirname => this.callImplWithType(Dirname, Ret, "dirname", field, args_), .basename => this.callImplWithType(Basename, Ret, "basename", field, args_), + .cp => this.callImplWithType(Cp, Ret, "cp", field, args_), }; } @@ -5540,7 +5553,7 @@ pub const Interpreter = struct { } pub const Cat = struct { - const print = bun.Output.scoped(.ShellCat, true); + const debug = bun.Output.scoped(.ShellCat, true); bltn: *Builtin, opts: Opts = .{}, @@ -5670,7 +5683,7 @@ pub const Interpreter = struct { } pub fn onIOWriterChunk(this: *Cat, _: usize, err: ?JSC.SystemError) void { - print("onIOWriterChunk(0x{x}, {s}, had_err={any})", .{ @intFromPtr(this), @tagName(this.state), err != null }); + debug("onIOWriterChunk(0x{x}, {s}, had_err={any})", .{ @intFromPtr(this), @tagName(this.state), err != null }); const errno: ExitCode = if (err) |e| brk: { defer e.deref(); break :brk @as(ExitCode, @intCast(@intFromEnum(e.getErrno()))); @@ -5731,7 +5744,7 @@ pub const Interpreter = struct { } pub fn onIOReaderChunk(this: *Cat, chunk: []const u8) ReadChunkAction { - print("onIOReaderChunk(0x{x}, {s}, chunk_len={d})", .{ @intFromPtr(this), @tagName(this.state), chunk.len }); + debug("onIOReaderChunk(0x{x}, {s}, chunk_len={d})", .{ @intFromPtr(this), @tagName(this.state), chunk.len }); switch (this.state) { .exec_stdin => { if (this.bltn.stdout.needsIO()) { @@ -5759,7 +5772,7 @@ pub const Interpreter = struct { defer e.deref(); break :brk @as(ExitCode, @intCast(@intFromEnum(e.getErrno()))); } else 0; - print("onIOReaderDone(0x{x}, {s}, errno={d})", .{ @intFromPtr(this), @tagName(this.state), errno }); + debug("onIOReaderDone(0x{x}, {s}, errno={d})", .{ @intFromPtr(this), @tagName(this.state), errno }); switch (this.state) { .exec_stdin => { @@ -6076,7 +6089,7 @@ pub const Interpreter = struct { try writer.print("ShellTouchTask(0x{x}, filepath={s})", .{ @intFromPtr(this), this.filepath }); } - const print = bun.Output.scoped(.ShellTouchTask, true); + const debug = bun.Output.scoped(.ShellTouchTask, true); pub fn deinit(this: *ShellTouchTask) void { if (this.err) |e| { @@ -6099,12 +6112,12 @@ pub const Interpreter = struct { } pub fn schedule(this: *@This()) void { - print("{} schedule", .{this}); + debug("{} schedule", .{this}); WorkPool.schedule(&this.task); } pub fn runFromMainThread(this: *@This()) void { - print("{} runFromJS", .{this}); + debug("{} runFromJS", .{this}); this.touch.onShellTouchTaskDone(this); } @@ -6114,7 +6127,7 @@ pub const Interpreter = struct { fn runFromThreadPool(task: *JSC.WorkPoolTask) void { var this: *ShellTouchTask = @fieldParentPtr(ShellTouchTask, "task", task); - print("{} runFromThreadPool", .{this}); + debug("{} runFromThreadPool", .{this}); // We have to give an absolute path const filepath: [:0]const u8 = brk: { @@ -6460,7 +6473,7 @@ pub const Interpreter = struct { event_loop: JSC.EventLoopHandle, concurrent_task: JSC.EventLoopTask, - const print = bun.Output.scoped(.ShellMkdirTask, true); + const debug = bun.Output.scoped(.ShellMkdirTask, true); fn takeOutput(this: *ShellMkdirTask) ArrayList(u8) { const out = this.created_directories; @@ -6495,12 +6508,12 @@ pub const Interpreter = struct { } pub fn schedule(this: *@This()) void { - print("{} schedule", .{this}); + debug("{} schedule", .{this}); WorkPool.schedule(&this.task); } pub fn runFromMainThread(this: *@This()) void { - print("{} runFromJS", .{this}); + debug("{} runFromJS", .{this}); this.mkdir.onShellMkdirTaskDone(this); } @@ -6510,7 +6523,7 @@ pub const Interpreter = struct { fn runFromThreadPool(task: *JSC.WorkPoolTask) void { var this: *ShellMkdirTask = @fieldParentPtr(ShellMkdirTask, "task", task); - print("{} runFromThreadPool", .{this}); + debug("{} runFromThreadPool", .{this}); // We have to give an absolute path to our mkdir // implementation for it to work with cwd @@ -7336,7 +7349,7 @@ pub const Interpreter = struct { }; pub const ShellLsTask = struct { - const print = bun.Output.scoped(.ShellLsTask, true); + const debug = bun.Output.scoped(.ShellLsTask, true); ls: *Ls, opts: Opts, @@ -7378,7 +7391,7 @@ pub const Interpreter = struct { } pub fn enqueue(this: *@This(), path: [:0]const u8) void { - print("enqueue: {s}", .{path}); + debug("enqueue: {s}", .{path}); const new_path = this.join( bun.default_allocator, &[_][]const u8{ @@ -7429,7 +7442,7 @@ pub const Interpreter = struct { defer { _ = Syscall.close(fd); - print("run done", .{}); + debug("run done", .{}); } if (!this.opts.list_directories) { @@ -7473,7 +7486,7 @@ pub const Interpreter = struct { // TODO more complex output like multi-column fn addEntry(this: *@This(), name: [:0]const u8) void { const skip = this.shouldSkipEntry(name); - print("Entry: (skip={}) {s} :: {s}", .{ skip, this.path, name }); + debug("Entry: (skip={}) {s} :: {s}", .{ skip, this.path, name }); if (skip) return; this.output.ensureUnusedCapacity(name.len + 1) catch bun.outOfMemory(); this.output.appendSlice(name) catch bun.outOfMemory(); @@ -7492,7 +7505,7 @@ pub const Interpreter = struct { } fn doneLogic(this: *@This()) void { - print("Done", .{}); + debug("Done", .{}); if (this.event_loop == .js) { this.event_loop.js.enqueueTaskConcurrent(this.concurrent_task.js.from(this, .manual_deinit)); } else { @@ -7507,7 +7520,7 @@ pub const Interpreter = struct { } pub fn runFromMainThread(this: *@This()) void { - print("runFromMainThread", .{}); + debug("runFromMainThread", .{}); this.ls.onShellLsTaskDone(this); } @@ -7516,7 +7529,7 @@ pub const Interpreter = struct { } pub fn deinit(this: *@This(), comptime free_this: bool) void { - print("deinit {s}", .{if (free_this) "free_this=true" else "free_this=false"}); + debug("deinit {s}", .{if (free_this) "free_this=true" else "free_this=false"}); bun.default_allocator.free(this.path); this.output.deinit(); if (comptime free_this) bun.default_allocator.destroy(this); @@ -7969,14 +7982,14 @@ pub const Interpreter = struct { } = .idle, pub const ShellMvCheckTargetTask = struct { - const print = bun.Output.scoped(.MvCheckTargetTask, true); + const debug = bun.Output.scoped(.MvCheckTargetTask, true); mv: *Mv, cwd: bun.FileDescriptor, target: [:0]const u8, result: ?Maybe(?bun.FileDescriptor) = null, - task: ShellTask(@This(), runFromThreadPool, runFromMainThread, print), + task: ShellTask(@This(), runFromThreadPool, runFromMainThread, debug), pub fn runFromThreadPool(this: *@This()) void { const fd = switch (ShellSyscall.openat(this.cwd, this.target, os.O.RDONLY | os.O.DIRECTORY, 0)) { @@ -8007,7 +8020,7 @@ pub const Interpreter = struct { pub const ShellMvBatchedTask = struct { const BATCH_SIZE = 5; - const print = bun.Output.scoped(.MvBatchedTask, true); + const debug = bun.Output.scoped(.MvBatchedTask, true); mv: *Mv, sources: []const [*:0]const u8, @@ -8018,7 +8031,7 @@ pub const Interpreter = struct { err: ?Syscall.Error = null, - task: ShellTask(@This(), runFromThreadPool, runFromMainThread, print), + task: ShellTask(@This(), runFromThreadPool, runFromMainThread, debug), event_loop: JSC.EventLoopHandle, pub fn runFromThreadPool(this: *@This()) void { @@ -8914,7 +8927,7 @@ pub const Interpreter = struct { } pub const ShellRmTask = struct { - const print = bun.Output.scoped(.AsyncRmTask, true); + const debug = bun.Output.scoped(.AsyncRmTask, true); rm: *Rm, opts: Opts, @@ -8977,14 +8990,14 @@ pub const Interpreter = struct { const EntryKindHint = enum { idk, dir, file }; pub fn takeDeletedEntries(this: *DirTask) std.ArrayList(u8) { - print("DirTask(0x{x} path={s}) takeDeletedEntries", .{ @intFromPtr(this), this.path }); + debug("DirTask(0x{x} path={s}) takeDeletedEntries", .{ @intFromPtr(this), this.path }); const ret = this.deleted_entries; this.deleted_entries = std.ArrayList(u8).init(ret.allocator); return ret; } pub fn runFromMainThread(this: *DirTask) void { - print("DirTask(0x{x}, path={s}) runFromMainThread", .{ @intFromPtr(this), this.path }); + debug("DirTask(0x{x}, path={s}) runFromMainThread", .{ @intFromPtr(this), this.path }); this.task_manager.rm.writeVerbose(this); } @@ -9011,7 +9024,7 @@ pub const Interpreter = struct { const cwd_path = switch (Syscall.getFdPath(this.task_manager.cwd, &buf)) { .result => |p| bun.default_allocator.dupeZ(u8, p) catch bun.outOfMemory(), .err => |err| { - print("[runFromThreadPoolImpl:getcwd] DirTask({x}) failed: {s}: {s}", .{ @intFromPtr(this), @tagName(err.getErrno()), err.path }); + debug("[runFromThreadPoolImpl:getcwd] DirTask({x}) failed: {s}: {s}", .{ @intFromPtr(this), @tagName(err.getErrno()), err.path }); this.task_manager.err_mutex.lock(); defer this.task_manager.err_mutex.unlock(); if (this.task_manager.err == null) { @@ -9025,11 +9038,11 @@ pub const Interpreter = struct { } } - print("DirTask: {s}", .{this.path}); + debug("DirTask: {s}", .{this.path}); this.is_absolute = ResolvePath.Platform.auto.isAbsolute(this.path[0..this.path.len]); switch (this.task_manager.removeEntry(this, this.is_absolute)) { .err => |err| { - print("[runFromThreadPoolImpl] DirTask({x}) failed: {s}: {s}", .{ @intFromPtr(this), @tagName(err.getErrno()), err.path }); + debug("[runFromThreadPoolImpl] DirTask({x}) failed: {s}: {s}", .{ @intFromPtr(this), @tagName(err.getErrno()), err.path }); this.task_manager.err_mutex.lock(); defer this.task_manager.err_mutex.unlock(); if (this.task_manager.err == null) { @@ -9044,7 +9057,7 @@ pub const Interpreter = struct { } fn handleErr(this: *DirTask, err: Syscall.Error) void { - print("[handleErr] DirTask({x}) failed: {s}: {s}", .{ @intFromPtr(this), @tagName(err.getErrno()), err.path }); + debug("[handleErr] DirTask({x}) failed: {s}: {s}", .{ @intFromPtr(this), @tagName(err.getErrno()), err.path }); this.task_manager.err_mutex.lock(); defer this.task_manager.err_mutex.unlock(); if (this.task_manager.err == null) { @@ -9056,7 +9069,7 @@ pub const Interpreter = struct { } pub fn postRun(this: *DirTask) void { - print("DirTask(0x{x}, path={s}) postRun", .{ @intFromPtr(this), this.path }); + debug("DirTask(0x{x}, path={s}) postRun", .{ @intFromPtr(this), this.path }); // // This is true if the directory has subdirectories // // that need to be deleted if (this.need_to_wait.load(.SeqCst)) return; @@ -9090,7 +9103,7 @@ pub const Interpreter = struct { } pub fn deleteAfterWaitingForChildren(this: *DirTask) void { - print("DirTask(0x{x}, path={s}) deleteAfterWaitingForChildren", .{ @intFromPtr(this), this.path }); + debug("DirTask(0x{x}, path={s}) deleteAfterWaitingForChildren", .{ @intFromPtr(this), this.path }); // `runFromMainThreadImpl` has a `defer this.postRun()` so need to set this to true to skip that this.deleting_after_waiting_for_children.store(true, .SeqCst); this.need_to_wait.store(false, .SeqCst); @@ -9104,7 +9117,7 @@ pub const Interpreter = struct { switch (this.task_manager.removeEntryDirAfterChildren(this)) { .err => |e| { - print("[deleteAfterWaitingForChildren] DirTask({x}) failed: {s}: {s}", .{ @intFromPtr(this), @tagName(e.getErrno()), e.path }); + debug("[deleteAfterWaitingForChildren] DirTask({x}) failed: {s}: {s}", .{ @intFromPtr(this), @tagName(e.getErrno()), e.path }); this.task_manager.err_mutex.lock(); defer this.task_manager.err_mutex.unlock(); if (this.task_manager.err == null) { @@ -9187,7 +9200,7 @@ pub const Interpreter = struct { } pub fn enqueueNoJoin(this: *ShellRmTask, parent_task: *DirTask, path: [:0]const u8, kind_hint: DirTask.EntryKindHint) void { - defer print("enqueue: {s} {s}", .{ path, @tagName(kind_hint) }); + defer debug("enqueue: {s} {s}", .{ path, @tagName(kind_hint) }); if (this.error_signal.load(.SeqCst)) { return; @@ -9213,10 +9226,10 @@ pub const Interpreter = struct { } pub fn verboseDeleted(this: *@This(), dir_task: *DirTask, path: [:0]const u8) Maybe(void) { - print("deleted: {s}", .{path[0..path.len]}); + debug("deleted: {s}", .{path[0..path.len]}); if (!this.opts.verbose) return Maybe(void).success; if (dir_task.deleted_entries.items.len == 0) { - print("DirTask(0x{x}, {s}) Incrementing output count (deleted={s})", .{ @intFromPtr(dir_task), dir_task.path, path }); + debug("DirTask(0x{x}, {s}) Incrementing output count (deleted={s})", .{ @intFromPtr(dir_task), dir_task.path, path }); _ = this.rm.state.exec.incrementOutputCount(.output_count); } dir_task.deleted_entries.appendSlice(path[0..path.len]) catch bun.outOfMemory(); @@ -9225,7 +9238,7 @@ pub const Interpreter = struct { } pub fn finishConcurrently(this: *ShellRmTask) void { - print("finishConcurrently", .{}); + debug("finishConcurrently", .{}); if (this.event_loop == .js) { this.event_loop.js.enqueueTaskConcurrent(this.concurrent_task.js.from(this, .manual_deinit)); } else { @@ -9256,7 +9269,7 @@ pub const Interpreter = struct { fn removeEntryDir(this: *ShellRmTask, dir_task: *DirTask, is_absolute: bool, buf: *[bun.MAX_PATH_BYTES]u8) Maybe(void) { const path = dir_task.path; const dirfd = this.cwd; - print("removeEntryDir({s})", .{path}); + debug("removeEntryDir({s})", .{path}); // If `-d` is specified without `-r` then we can just use `rmdirat` if (this.opts.remove_empty_dirs and !this.opts.recursive) out_to_iter: { @@ -9338,7 +9351,7 @@ pub const Interpreter = struct { }, .result => |ent| ent, }) |current| : (entry = iterator.next()) { - print("dir({s}) entry({s}, {s})", .{ path, current.name.slice(), @tagName(current.kind) }); + debug("dir({s}) entry({s}, {s})", .{ path, current.name.slice(), @tagName(current.kind) }); // TODO this seems bad maybe better to listen to kqueue/epoll event if (fastMod(i, 4) == 0 and this.error_signal.load(.SeqCst)) return Maybe(void).success; @@ -9383,7 +9396,7 @@ pub const Interpreter = struct { _ = Syscall.close(fd); } - print("[removeEntryDir] remove after children {s}", .{path}); + debug("[removeEntryDir] remove after children {s}", .{path}); switch (ShellSyscall.unlinkatWithFlags(this.getcwd(), path, std.os.AT.REMOVEDIR)) { .result => { switch (this.verboseDeleted(dir_task, path)) { @@ -9483,7 +9496,7 @@ pub const Interpreter = struct { }; fn removeEntryDirAfterChildren(this: *ShellRmTask, dir_task: *DirTask) Maybe(bool) { - print("remove entry after children: {s}", .{dir_task.path}); + debug("remove entry after children: {s}", .{dir_task.path}); const dirfd = bun.toFD(this.cwd); var state = RemoveFileParent{ .task = this, @@ -9547,7 +9560,7 @@ pub const Interpreter = struct { switch (ShellSyscall.unlinkatWithFlags(dirfd, path, 0)) { .result => return this.verboseDeleted(parent_dir_task, path), .err => |e| { - print("unlinkatWithFlags({s}) = {s}", .{ path, @tagName(e.getErrno()) }); + debug("unlinkatWithFlags({s}) = {s}", .{ path, @tagName(e.getErrno()) }); switch (e.getErrno()) { bun.C.E.NOENT => { if (this.opts.force) @@ -10107,6 +10120,745 @@ pub const Interpreter = struct { } } }; + + pub const Cp = struct { + bltn: *Builtin, + opts: Opts = .{}, + state: union(enum) { + idle, + exec: struct { + target_path: [:0]const u8, + paths_to_copy: []const [*:0]const u8, + started: bool = false, + /// this is thread safe as it is only incremented + /// and decremented on the main thread by this struct + tasks_count: u32 = 0, + output_waiting: u32 = 0, + output_done: u32 = 0, + err: ?bun.shell.ShellErr = null, + + ebusy: if (bun.Environment.isWindows) EbusyState else struct {} = .{}, + }, + ebusy: struct { + state: EbusyState, + idx: usize = 0, + main_exit_code: ExitCode = 0, + }, + waiting_write_err, + done, + } = .idle, + + pub fn format(this: *const Cp, comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void { + try writer.print("Cp(0x{x})", .{@intFromPtr(this)}); + } + + /// On Windows it is possible to get an EBUSY error very simply + /// by running the following command: + /// + /// `cp myfile.txt myfile.txt mydir/` + /// + /// Bearing in mind that the shell cp implementation creates a + /// ShellCpTask for each source file, it's possible for one of the + /// tasks to get EBUSY while trying to access the source file or the + /// destination file. + /// + /// But it's fine to ignore the EBUSY error since at + /// least one of them will succeed anyway. + /// + /// We handle this _after_ all the tasks have been + /// executed, to avoid complicated synchronization on multiple + /// threads, because the precise src or dest for each argument is + /// not known until its corresponding ShellCpTask is executed by the + /// threadpool. + const EbusyState = struct { + tasks: std.ArrayListUnmanaged(*ShellCpTask) = .{}, + absolute_targets: std.StringArrayHashMapUnmanaged(void) = .{}, + absolute_srcs: std.StringArrayHashMapUnmanaged(void) = .{}, + + pub fn deinit(this: *EbusyState) void { + // The tasks themselves are freed in `ignoreEbusyErrorIfPossible()` + this.tasks.deinit(bun.default_allocator); + for (this.absolute_targets.keys()) |tgt| { + bun.default_allocator.free(tgt); + } + this.absolute_targets.deinit(bun.default_allocator); + for (this.absolute_srcs.keys()) |tgt| { + bun.default_allocator.free(tgt); + } + this.absolute_srcs.deinit(bun.default_allocator); + } + }; + + pub fn start(this: *Cp) Maybe(void) { + const maybe_filepath_args = switch (this.opts.parse(this.bltn.argsSlice())) { + .ok => |args| args, + .err => |e| { + const buf = switch (e) { + .illegal_option => |opt_str| this.bltn.fmtErrorArena(.cp, "illegal option -- {s}\n", .{opt_str}), + .show_usage => Builtin.Kind.cp.usageString(), + .unsupported => |unsupported| this.bltn.fmtErrorArena(.cp, "unsupported option, please open a GitHub issue -- {s}\n", .{unsupported}), + }; + + _ = this.writeFailingError(buf, 1); + return Maybe(void).success; + }, + }; + + if (maybe_filepath_args == null or maybe_filepath_args.?.len <= 1) { + _ = this.writeFailingError(Builtin.Kind.cp.usageString(), 1); + return Maybe(void).success; + } + + const args = maybe_filepath_args orelse unreachable; + const paths_to_copy = args[0 .. args.len - 1]; + const tgt_path = std.mem.span(args[args.len - 1]); + + this.state = .{ .exec = .{ + .target_path = tgt_path, + .paths_to_copy = paths_to_copy, + } }; + + this.next(); + + return Maybe(void).success; + } + + pub fn ignoreEbusyErrorIfPossible(this: *Cp) void { + if (!bun.Environment.isWindows) @compileError("dont call this plz"); + + if (this.state.ebusy.idx < this.state.ebusy.state.tasks.items.len) { + outer_loop: for (this.state.ebusy.state.tasks.items[this.state.ebusy.idx..], 0..) |task_, i| { + const task: *ShellCpTask = task_; + const failure_src = task.src_absolute.?; + const failure_tgt = task.tgt_absolute.?; + if (this.state.ebusy.state.absolute_targets.get(failure_tgt)) |_| { + task.deinit(); + continue :outer_loop; + } + if (this.state.ebusy.state.absolute_srcs.get(failure_src)) |_| { + task.deinit(); + continue :outer_loop; + } + this.state.ebusy.idx += i + 1; + this.printShellCpTask(task); + return; + } + } + + this.state.ebusy.state.deinit(); + const exit_code = this.state.ebusy.main_exit_code; + this.state = .done; + this.bltn.done(exit_code); + } + + pub fn next(this: *Cp) void { + while (this.state != .done) { + switch (this.state) { + .idle => @panic("Invalid state for \"Cp\": idle, this indicates a bug in Bun. Please file a GitHub issue"), + .exec => { + var exec = &this.state.exec; + if (exec.started) { + if (this.state.exec.tasks_count <= 0 and this.state.exec.output_done >= this.state.exec.output_waiting) { + const exit_code: ExitCode = if (this.state.exec.err != null) 1 else 0; + if (this.state.exec.err != null) { + this.state.exec.err.?.deinit(bun.default_allocator); + } + if (comptime bun.Environment.isWindows) { + if (exec.ebusy.tasks.items.len > 0) { + this.state = .{ .ebusy = .{ .state = this.state.exec.ebusy, .main_exit_code = exit_code } }; + continue; + } + exec.ebusy.deinit(); + } + this.state = .done; + this.bltn.done(exit_code); + return; + } + return; + } + + exec.started = true; + exec.tasks_count = @intCast(exec.paths_to_copy.len); + + const cwd_path = this.bltn.parentCmd().base.shell.cwdZ(); + + // Launch a task for each argument + for (exec.paths_to_copy) |path_raw| { + const path = std.mem.span(path_raw); + const cp_task = ShellCpTask.create(this, this.bltn.eventLoop(), this.opts, 1 + exec.paths_to_copy.len, path, exec.target_path, cwd_path); + cp_task.schedule(); + } + return; + }, + .ebusy => { + if (comptime bun.Environment.isWindows) { + this.ignoreEbusyErrorIfPossible(); + return; + } else @panic("Should only be called on Windows"); + }, + .waiting_write_err => return, + .done => unreachable, + } + } + + this.bltn.done(0); + } + + pub fn deinit(cp: *Cp) void { + assert(cp.state == .done or cp.state == .waiting_write_err); + } + + pub fn writeFailingError(this: *Cp, buf: []const u8, exit_code: ExitCode) Maybe(void) { + if (this.bltn.stderr.needsIO()) { + this.state = .waiting_write_err; + this.bltn.stderr.enqueue(this, buf); + return Maybe(void).success; + } + + _ = this.bltn.writeNoIO(.stderr, buf); + + this.bltn.done(exit_code); + return Maybe(void).success; + } + + pub fn onIOWriterChunk(this: *Cp, _: usize, e: ?JSC.SystemError) void { + if (e) |err| err.deref(); + if (this.state == .waiting_write_err) { + return this.bltn.done(1); + } + this.state.exec.output_done += 1; + this.next(); + } + + pub fn onShellCpTaskDone(this: *Cp, task: *ShellCpTask) void { + assert(this.state == .exec); + log("task done: 0x{x} {d}", .{ @intFromPtr(task), this.state.exec.tasks_count }); + this.state.exec.tasks_count -= 1; + + const err_ = task.err; + + if (comptime bun.Environment.isWindows) { + if (err_) |err| { + if (err == .sys and + err.sys.getErrno() == .BUSY and + (task.tgt_absolute != null and + err.sys.path.eqlUTF8(task.tgt_absolute.?)) or + (task.src_absolute != null and + err.sys.path.eqlUTF8(task.src_absolute.?))) + { + log("{} got ebusy {d} {d}", .{ this, this.state.exec.ebusy.tasks.items.len, this.state.exec.paths_to_copy.len }); + this.state.exec.ebusy.tasks.append(bun.default_allocator, task) catch bun.outOfMemory(); + this.next(); + return; + } + } else { + const tgt_absolute = task.tgt_absolute; + task.tgt_absolute = null; + if (tgt_absolute) |tgt| this.state.exec.ebusy.absolute_targets.put(bun.default_allocator, tgt, {}) catch bun.outOfMemory(); + const src_absolute = task.src_absolute; + task.src_absolute = null; + if (src_absolute) |tgt| this.state.exec.ebusy.absolute_srcs.put(bun.default_allocator, tgt, {}) catch bun.outOfMemory(); + } + } + + this.printShellCpTask(task); + } + + pub fn printShellCpTask(this: *Cp, task: *ShellCpTask) void { + // Deinitialize this task as we are starting a new one + defer task.deinit(); + + const err_ = task.err; + var output = task.takeOutput(); + + const output_task: *ShellCpOutputTask = bun.new(ShellCpOutputTask, .{ + .parent = this, + .output = .{ .arrlist = output.moveToUnmanaged() }, + .state = .waiting_write_err, + }); + if (err_) |err| { + this.state.exec.err = err; + const error_string = this.bltn.taskErrorToString(.cp, err); + output_task.start(error_string); + return; + } + output_task.start(null); + } + + pub const ShellCpOutputTask = OutputTask(Cp, .{ + .writeErr = ShellCpOutputTaskVTable.writeErr, + .onWriteErr = ShellCpOutputTaskVTable.onWriteErr, + .writeOut = ShellCpOutputTaskVTable.writeOut, + .onWriteOut = ShellCpOutputTaskVTable.onWriteOut, + .onDone = ShellCpOutputTaskVTable.onDone, + }); + + const ShellCpOutputTaskVTable = struct { + pub fn writeErr(this: *Cp, childptr: anytype, errbuf: []const u8) CoroutineResult { + if (this.bltn.stderr.needsIO()) { + this.state.exec.output_waiting += 1; + this.bltn.stderr.enqueue(childptr, errbuf); + return .yield; + } + _ = this.bltn.writeNoIO(.stderr, errbuf); + return .cont; + } + + pub fn onWriteErr(this: *Cp) void { + this.state.exec.output_done += 1; + } + + pub fn writeOut(this: *Cp, childptr: anytype, output: *OutputSrc) CoroutineResult { + if (this.bltn.stdout.needsIO()) { + this.state.exec.output_waiting += 1; + this.bltn.stdout.enqueue(childptr, output.slice()); + return .yield; + } + _ = this.bltn.writeNoIO(.stdout, output.slice()); + return .cont; + } + + pub fn onWriteOut(this: *Cp) void { + this.state.exec.output_done += 1; + } + + pub fn onDone(this: *Cp) void { + this.next(); + } + }; + + pub const ShellCpTask = struct { + cp: *Cp, + + opts: Opts, + operands: usize = 0, + src: [:0]const u8, + tgt: [:0]const u8, + src_absolute: ?[:0]const u8 = null, + tgt_absolute: ?[:0]const u8 = null, + cwd_path: [:0]const u8, + verbose_output_lock: std.Thread.Mutex = .{}, + verbose_output: ArrayList(u8) = ArrayList(u8).init(bun.default_allocator), + + task: JSC.WorkPoolTask = .{ .callback = &runFromThreadPool }, + event_loop: JSC.EventLoopHandle, + concurrent_task: JSC.EventLoopTask, + err: ?bun.shell.ShellErr = null, + + const debug = bun.Output.scoped(.ShellCpTask, false); + + fn deinit(this: *ShellCpTask) void { + debug("deinit", .{}); + this.verbose_output.deinit(); + if (this.err) |e| { + e.deinit(bun.default_allocator); + } + if (this.src_absolute) |sc| { + bun.default_allocator.free(sc); + } + if (this.tgt_absolute) |tc| { + bun.default_allocator.free(tc); + } + bun.destroy(this); + } + + pub fn schedule(this: *@This()) void { + debug("schedule", .{}); + WorkPool.schedule(&this.task); + } + + pub fn create( + cp: *Cp, + evtloop: JSC.EventLoopHandle, + opts: Opts, + operands: usize, + src: [:0]const u8, + tgt: [:0]const u8, + cwd_path: [:0]const u8, + ) *ShellCpTask { + return bun.new(ShellCpTask, ShellCpTask{ + .cp = cp, + .operands = operands, + .opts = opts, + .src = src, + .tgt = tgt, + .cwd_path = cwd_path, + .event_loop = evtloop, + .concurrent_task = JSC.EventLoopTask.fromEventLoop(evtloop), + }); + } + + fn takeOutput(this: *ShellCpTask) ArrayList(u8) { + const out = this.verbose_output; + this.verbose_output = ArrayList(u8).init(bun.default_allocator); + return out; + } + + pub fn ensureDest(nodefs: *JSC.Node.NodeFS, dest: bun.OSPathSliceZ) Maybe(void) { + return switch (nodefs.mkdirRecursiveOSPath(dest, JSC.Node.Arguments.Mkdir.DefaultMode, false)) { + .err => |err| Maybe(void){ .err = err }, + .result => Maybe(void).success, + }; + } + + pub fn hasTrailingSep(path: [:0]const u8) bool { + if (path.len == 0) return false; + return ResolvePath.Platform.auto.isSeparator(path[path.len - 1]); + } + + const Kind = enum { + file, + dir, + }; + + pub fn isDir(_: *ShellCpTask, path: [:0]const u8) Maybe(bool) { + if (bun.Environment.isWindows) { + var wpath: bun.OSPathBuffer = undefined; + const attributes = windows.GetFileAttributesW(bun.strings.toWPath(wpath[0..], path[0..path.len])); + if (attributes == windows.INVALID_FILE_ATTRIBUTES) { + const err: Syscall.Error = .{ + .errno = @intFromEnum(bun.C.SystemErrno.ENOENT), + .syscall = .copyfile, + .path = path, + }; + return .{ .err = err }; + } + return .{ .result = (attributes & windows.FILE_ATTRIBUTE_DIRECTORY) != 0 }; + } + const stat = switch (Syscall.lstat(path)) { + .result => |x| x, + .err => |e| { + return .{ .err = e }; + }, + }; + return .{ .result = os.S.ISDIR(stat.mode) }; + } + + fn enqueueToEventLoop(this: *ShellCpTask) void { + if (this.event_loop == .js) { + this.event_loop.js.enqueueTaskConcurrent(this.concurrent_task.js.from(this, .manual_deinit)); + } else { + this.event_loop.mini.enqueueTaskConcurrent(this.concurrent_task.mini.from(this, "runFromMainThreadMini")); + } + } + + pub fn runFromMainThread(this: *ShellCpTask) void { + debug("runFromMainThread", .{}); + this.cp.onShellCpTaskDone(this); + } + + pub fn runFromMainThreadMini(this: *ShellCpTask, _: *void) void { + this.runFromMainThread(); + } + + pub fn runFromThreadPool(task: *WorkPoolTask) void { + debug("runFromThreadPool", .{}); + var this = @fieldParentPtr(@This(), "task", task); + if (this.runFromThreadPoolImpl()) |e| { + this.err = e; + this.enqueueToEventLoop(); + return; + } + } + + fn runFromThreadPoolImpl(this: *ShellCpTask) ?bun.shell.ShellErr { + var buf2: [bun.MAX_PATH_BYTES]u8 = undefined; + var buf3: [bun.MAX_PATH_BYTES]u8 = undefined; + // We have to give an absolute path to our cp + // implementation for it to work with cwd + const src: [:0]const u8 = brk: { + if (ResolvePath.Platform.auto.isAbsolute(this.src)) break :brk this.src; + const parts: []const []const u8 = &.{ + this.cwd_path[0..], + this.src[0..], + }; + break :brk ResolvePath.joinZ(parts, .auto); + }; + var tgt: [:0]const u8 = brk: { + if (ResolvePath.Platform.auto.isAbsolute(this.tgt)) break :brk this.tgt; + const parts: []const []const u8 = &.{ + this.cwd_path[0..], + this.tgt[0..], + }; + break :brk ResolvePath.joinZBuf(buf2[0..bun.MAX_PATH_BYTES], parts, .auto); + }; + + // Cases: + // SRC DEST + // ---------------- + // file -> file + // file -> folder + // folder -> folder + // ---------------- + // We need to check dest to see what it is + // If it doesn't exist we need to create it + const src_is_dir = switch (this.isDir(src)) { + .result => |x| x, + .err => |e| return bun.shell.ShellErr.newSys(e), + }; + + // Any source directory without -R is an error + if (src_is_dir and !this.opts.recursive) { + const errmsg = std.fmt.allocPrint(bun.default_allocator, "{s} is a directory (not copied)", .{this.src}) catch bun.outOfMemory(); + return .{ .custom = errmsg }; + } + + if (!src_is_dir and bun.strings.eql(src, tgt)) { + const errmsg = std.fmt.allocPrint(bun.default_allocator, "{s} and {s} are identical (not copied)", .{ this.src, this.src }) catch bun.outOfMemory(); + return .{ .custom = errmsg }; + } + + const tgt_is_dir: bool, const tgt_exists: bool = switch (this.isDir(tgt)) { + .result => |is_dir| .{ is_dir, true }, + .err => |e| brk: { + if (e.getErrno() == bun.C.E.NOENT) { + // If it has a trailing directory separator, its a directory + const is_dir = hasTrailingSep(tgt); + break :brk .{ is_dir, false }; + } + return bun.shell.ShellErr.newSys(e); + }, + }; + + var copying_many = false; + + // Note: + // The following logic is based on the POSIX spec: + // https://man7.org/linux/man-pages/man1/cp.1p.html + + // Handle the "1st synopsis": source_file -> target_file + if (!src_is_dir and !tgt_is_dir and this.operands == 2) { + // Don't need to do anything here + } + // Handle the "2nd synopsis": -R source_files... -> target + else if (this.opts.recursive) { + if (tgt_exists) { + const basename = ResolvePath.basename(src[0..src.len]); + const parts: []const []const u8 = &.{ + tgt[0..tgt.len], + basename, + }; + tgt = ResolvePath.joinZBuf(buf3[0..bun.MAX_PATH_BYTES], parts, .auto); + } else if (this.operands == 2) { + // source_dir -> new_target_dir + } else { + const errmsg = std.fmt.allocPrint(bun.default_allocator, "directory {s} does not exist", .{this.tgt}) catch bun.outOfMemory(); + return .{ .custom = errmsg }; + } + copying_many = true; + } + // Handle the "3rd synopsis": source_files... -> target + else { + if (src_is_dir) return .{ .custom = std.fmt.allocPrint(bun.default_allocator, "{s} is a directory (not copied)", .{this.src}) catch bun.outOfMemory() }; + if (!tgt_exists or !tgt_is_dir) return .{ .custom = std.fmt.allocPrint(bun.default_allocator, "{s} is not a directory", .{this.tgt}) catch bun.outOfMemory() }; + const basename = ResolvePath.basename(src[0..src.len]); + const parts: []const []const u8 = &.{ + tgt[0..tgt.len], + basename, + }; + tgt = ResolvePath.joinZBuf(buf3[0..bun.MAX_PATH_BYTES], parts, .auto); + copying_many = true; + } + + this.src_absolute = bun.default_allocator.dupeZ(u8, src[0..src.len]) catch bun.outOfMemory(); + this.tgt_absolute = bun.default_allocator.dupeZ(u8, tgt[0..tgt.len]) catch bun.outOfMemory(); + + const args = JSC.Node.Arguments.Cp{ + .src = JSC.Node.PathLike{ .string = bun.PathString.init(this.src_absolute.?) }, + .dest = JSC.Node.PathLike{ .string = bun.PathString.init(this.tgt_absolute.?) }, + .flags = .{ + .mode = @enumFromInt(0), + .recursive = this.opts.recursive, + .force = true, + .errorOnExist = false, + .deinit_paths = false, + }, + }; + + debug("Scheduling {s} -> {s}", .{ this.src_absolute.?, this.tgt_absolute.? }); + if (this.event_loop == .js) { + const vm: *JSC.VirtualMachine = this.event_loop.js.getVmImpl(); + debug("Yoops", .{}); + _ = JSC.Node.ShellAsyncCpTask.createWithShellTask( + vm.global, + args, + vm, + bun.ArenaAllocator.init(bun.default_allocator), + this, + false, + ); + } else { + _ = JSC.Node.ShellAsyncCpTask.createMini( + args, + this.event_loop.mini, + bun.ArenaAllocator.init(bun.default_allocator), + this, + ); + } + + return null; + } + + fn onSubtaskFinish(this: *ShellCpTask, err: Maybe(void)) void { + debug("onSubtaskFinish", .{}); + if (err.asErr()) |e| { + this.err = bun.shell.ShellErr.newSys(e); + } + this.enqueueToEventLoop(); + } + + pub fn onCopyImpl(this: *ShellCpTask, src: [:0]const u8, dest: [:0]const u8) void { + this.verbose_output_lock.lock(); + log("onCopy: {s} -> {s}\n", .{ src, dest }); + defer this.verbose_output_lock.unlock(); + var writer = this.verbose_output.writer(); + writer.print("{s} -> {s}\n", .{ src, dest }) catch bun.outOfMemory(); + } + + pub fn cpOnCopy(this: *ShellCpTask, src_: anytype, dest_: anytype) void { + if (!this.opts.verbose) return; + if (comptime bun.Environment.isPosix) return this.onCopyImpl(src_, dest_); + + var buf: bun.PathBuffer = undefined; + var buf2: bun.PathBuffer = undefined; + const src: [:0]const u8 = switch (@TypeOf(src_)) { + [:0]const u8, [:0]u8 => src_, + [:0]const u16, [:0]u16 => bun.strings.fromWPath(buf[0..], src_), + else => @compileError("Invalid type: " ++ @typeName(@TypeOf(src_))), + }; + const dest: [:0]const u8 = switch (@TypeOf(dest_)) { + [:0]const u8, [:0]u8 => src_, + [:0]const u16, [:0]u16 => bun.strings.fromWPath(buf2[0..], dest_), + else => @compileError("Invalid type: " ++ @typeName(@TypeOf(dest_))), + }; + this.onCopyImpl(src, dest); + } + + pub fn cpOnFinish(this: *ShellCpTask, result: Maybe(void)) void { + this.onSubtaskFinish(result); + } + }; + + const Opts = packed struct { + /// -f + /// + /// If the destination file cannot be opened, remove it and create a + /// new file, without prompting for confirmation regardless of its + /// permissions. (The -f option overrides any previous -n option.) The + /// target file is not unlinked before the copy. Thus, any existing access + /// rights will be retained. + remove_and_create_new_file_if_not_found: bool = false, + + /// -H + /// + /// Take actions based on the type and contents of the file + /// referenced by any symbolic link specified as a + /// source_file operand. + dereference_command_line_symlinks: bool = false, + + /// -i + /// + /// Write a prompt to standard error before copying to any + /// existing non-directory destination file. If the + /// response from the standard input is affirmative, the + /// copy shall be attempted; otherwise, it shall not. + interactive: bool = false, + + /// -L + /// + /// Take actions based on the type and contents of the file + /// referenced by any symbolic link specified as a + /// source_file operand or any symbolic links encountered + /// during traversal of a file hierarchy. + dereference_all_symlinks: bool = false, + + /// -P + /// + /// Take actions on any symbolic link specified as a + /// source_file operand or any symbolic link encountered + /// during traversal of a file hierarchy. + preserve_symlinks: bool = false, + + /// -p + /// + /// Duplicate the following characteristics of each source + /// file in the corresponding destination file: + /// 1. The time of last data modification and time of last + /// access. + /// 2. The user ID and group ID. + /// 3. The file permission bits and the S_ISUID and + /// S_ISGID bits. + preserve_file_attributes: bool = false, + + /// -R + /// + /// Copy file hierarchies. + recursive: bool = false, + + /// -v + /// + /// Cause cp to be verbose, showing files as they are copied. + verbose: bool = false, + + /// -n + /// + /// Do not overwrite an existing file. (The -n option overrides any previous -f or -i options.) + overwrite_existing_file: bool = true, + + const Parse = FlagParser(*@This()); + + pub fn parse(opts: *Opts, args: []const [*:0]const u8) Result(?[]const [*:0]const u8, ParseError) { + return Parse.parseFlags(opts, args); + } + + pub fn parseLong(this: *Opts, flag: []const u8) ?ParseFlagResult { + _ = this; + _ = flag; + return null; + } + + fn parseShort(this: *Opts, char: u8, smallflags: []const u8, i: usize) ?ParseFlagResult { + switch (char) { + 'f' => { + return .{ .unsupported = unsupportedFlag("-f") }; + }, + 'H' => { + return .{ .unsupported = unsupportedFlag("-H") }; + }, + 'i' => { + return .{ .unsupported = unsupportedFlag("-i") }; + }, + 'L' => { + return .{ .unsupported = unsupportedFlag("-L") }; + }, + 'P' => { + return .{ .unsupported = unsupportedFlag("-P") }; + }, + 'p' => { + return .{ .unsupported = unsupportedFlag("-P") }; + }, + 'R' => { + this.recursive = true; + return .continue_parsing; + }, + 'v' => { + this.verbose = true; + return .continue_parsing; + }, + 'n' => { + this.overwrite_existing_file = true; + this.remove_and_create_new_file_if_not_found = false; + return .continue_parsing; + }, + else => { + return .{ .illegal_option = smallflags[i..] }; + }, + } + + return null; + } + }; + }; }; /// This type is reference counted, but deinitialization is queued onto the event loop @@ -10396,7 +11148,7 @@ pub const Interpreter = struct { pub const DEBUG_REFCOUNT_NAME: []const u8 = "IOWriterRefCount"; - const print = bun.Output.scoped(.IOWriter, true); + const debug = bun.Output.scoped(.IOWriter, true); const ChildPtr = IOWriterChildPtr; @@ -10444,13 +11196,13 @@ pub const Interpreter = struct { this.writer.parent = this; this.flags = flags; - print("IOWriter(0x{x}, fd={}) init flags={any}", .{ @intFromPtr(this), fd, flags }); + debug("IOWriter(0x{x}, fd={}) init flags={any}", .{ @intFromPtr(this), fd, flags }); return this; } pub fn __start(this: *This) Maybe(void) { - print("IOWriter(0x{x}, fd={}) __start()", .{ @intFromPtr(this), this.fd }); + debug("IOWriter(0x{x}, fd={}) __start()", .{ @intFromPtr(this), this.fd }); if (this.writer.start(this.fd, this.flags.pollable).asErr()) |e_| { const e: bun.sys.Error = e_; if (bun.Environment.isPosix) { @@ -10463,7 +11215,7 @@ pub const Interpreter = struct { // same file descriptor. The shell code here makes sure to // _not_ run into that case, but it is possible. if (e.getErrno() == .INVAL) { - print("IOWriter(0x{x}, fd={}) got EINVAL", .{ @intFromPtr(this), this.fd }); + debug("IOWriter(0x{x}, fd={}) got EINVAL", .{ @intFromPtr(this), this.fd }); this.flags.pollable = false; this.flags.nonblocking = false; this.flags.is_socket = false; @@ -10609,7 +11361,7 @@ pub const Interpreter = struct { pub fn onWrite(this: *This, amount: usize, status: bun.io.WriteStatus) void { this.setWriting(false); - print("IOWriter(0x{x}, fd={}) onWrite({d}, {})", .{ @intFromPtr(this), this.fd, amount, status }); + debug("IOWriter(0x{x}, fd={}) onWrite({d}, {})", .{ @intFromPtr(this), this.fd, amount, status }); if (this.__idx >= this.writers.len()) return; const child = this.writers.get(this.__idx); if (child.isDead()) { @@ -10638,7 +11390,7 @@ pub const Interpreter = struct { log("IOWriter(0x{x}, fd={}) wrote_everything={}, idx={d} writers={d} next_len={d}", .{ @intFromPtr(this), this.fd, wrote_everything, this.__idx, this.writers.len(), if (this.writers.len() >= 1) this.writers.get(0).len else 0 }); if (!wrote_everything and this.__idx < this.writers.len()) { - print("IOWriter(0x{x}, fd={}) poll again", .{ @intFromPtr(this), this.fd }); + debug("IOWriter(0x{x}, fd={}) poll again", .{ @intFromPtr(this), this.fd }); if (comptime bun.Environment.isWindows) { this.setWriting(true); this.writer.write(); @@ -10823,12 +11575,12 @@ pub const Interpreter = struct { } pub fn asyncDeinit(this: *@This()) void { - print("IOWriter(0x{x}, fd={}) asyncDeinit", .{ @intFromPtr(this), this.fd }); + debug("IOWriter(0x{x}, fd={}) asyncDeinit", .{ @intFromPtr(this), this.fd }); this.async_deinit.enqueue(); } pub fn __deinit(this: *This) void { - print("IOWriter(0x{x}, fd={}) deinit", .{ @intFromPtr(this), this.fd }); + debug("IOWriter(0x{x}, fd={}) deinit", .{ @intFromPtr(this), this.fd }); if (bun.Environment.allow_assert) assert(this.ref_count == 0); this.buf.deinit(bun.default_allocator); if (comptime bun.Environment.isPosix) { @@ -11022,7 +11774,7 @@ pub fn ShellTask( /// Function that is called on the main thread, once the event loop /// processes that the task is done comptime runFromMainThread_: fn (*Ctx) void, - comptime print: fn (comptime fmt: []const u8, args: anytype) void, + comptime debug: fn (comptime fmt: []const u8, args: anytype) void, ) type { return struct { task: WorkPoolTask = .{ .callback = &runFromThreadPool }, @@ -11034,14 +11786,14 @@ pub fn ShellTask( pub const InnerShellTask = @This(); pub fn schedule(this: *@This()) void { - print("schedule", .{}); + debug("schedule", .{}); this.ref.ref(this.event_loop); WorkPool.schedule(&this.task); } pub fn onFinish(this: *@This()) void { - print("onFinish", .{}); + debug("onFinish", .{}); if (this.event_loop == .js) { const ctx = @fieldParentPtr(Ctx, "task", this); this.event_loop.js.enqueueTaskConcurrent(this.concurrent_task.js.from(ctx, .manual_deinit)); @@ -11052,7 +11804,7 @@ pub fn ShellTask( } pub fn runFromThreadPool(task: *WorkPoolTask) void { - print("runFromThreadPool", .{}); + debug("runFromThreadPool", .{}); var this = @fieldParentPtr(@This(), "task", task); const ctx = @fieldParentPtr(Ctx, "task", this); runFromThreadPool_(ctx); @@ -11060,7 +11812,7 @@ pub fn ShellTask( } pub fn runFromMainThread(this: *@This()) void { - print("runFromJS", .{}); + debug("runFromJS", .{}); const ctx = @fieldParentPtr(Ctx, "task", this); this.ref.unref(this.event_loop); runFromMainThread_(ctx); @@ -11152,6 +11904,8 @@ pub const IOWriterChildPtr = struct { Interpreter.Builtin.Seq, Interpreter.Builtin.Dirname, Interpreter.Builtin.Basename, + Interpreter.Builtin.Cp, + Interpreter.Builtin.Cp.ShellCpOutputTask, shell.subproc.PipeReader.CapturedWriter, }); diff --git a/src/shell/shell.zig b/src/shell/shell.zig index a9027a1cc86cf3..fbb62f1a17cdc7 100644 --- a/src/shell/shell.zig +++ b/src/shell/shell.zig @@ -4336,6 +4336,29 @@ pub fn SmolList(comptime T: type, comptime INLINED_MAX: comptime_int) type { /// Used in JS tests, see `internal-for-testing.ts` and shell tests. pub const TestingAPIs = struct { + pub fn disabledOnThisPlatform(globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSC.JSValue { + if (comptime bun.Environment.isWindows) return JSValue.false; + + const arguments_ = callframe.arguments(1); + var arguments = JSC.Node.ArgumentsSlice.init(globalThis.bunVM(), arguments_.slice()); + const string = arguments.nextEat() orelse { + globalThis.throw("shellInternals.disabledOnPosix: expected 1 arguments, got 0", .{}); + return JSC.JSValue.jsUndefined(); + }; + + const bunstr = string.toBunString(globalThis); + defer bunstr.deref(); + const utf8str = bunstr.toUTF8(bun.default_allocator); + defer utf8str.deinit(); + + inline for (Interpreter.Builtin.Kind.DISABLED_ON_POSIX) |disabled| { + if (bun.strings.eqlComptime(utf8str.byteSlice(), @tagName(disabled))) { + return JSValue.true; + } + } + return JSValue.false; + } + pub fn shellLex( globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame, diff --git a/src/shell/subproc.zig b/src/shell/subproc.zig index 1957587d5d4ac2..bcf0fdfd49291c 100644 --- a/src/shell/subproc.zig +++ b/src/shell/subproc.zig @@ -491,22 +491,12 @@ pub const ShellSubprocess = struct { } /// This disables the keeping process alive flag on the poll and also in the stdin, stdout, and stderr - pub fn unref(this: *@This(), comptime deactivate_poll_ref: bool) void { - _ = deactivate_poll_ref; // autofix - // const vm = this.globalThis.bunVM(); - + pub fn unref(this: *@This(), comptime _: bool) void { this.process.disableKeepingEventLoopAlive(); - // if (!this.hasCalledGetter(.stdin)) { - // this.stdin.unref(); - // } - // if (!this.hasCalledGetter(.stdout)) { this.stdout.unref(); - // } - // if (!this.hasCalledGetter(.stderr)) { - this.stdout.unref(); - // } + this.stderr.unref(); } pub fn hasKilled(this: *const @This()) bool { diff --git a/test/js/bun/shell/bunshell-default.test.ts b/test/js/bun/shell/bunshell-default.test.ts index be46c48976525b..6266b603e86c3f 100644 --- a/test/js/bun/shell/bunshell-default.test.ts +++ b/test/js/bun/shell/bunshell-default.test.ts @@ -1,25 +1,54 @@ import { $ } from "bun"; +import { bunExe, createTestBuilder } from "./test_builder"; +import { bunEnv } from "harness"; +const TestBuilder = createTestBuilder(import.meta.path); test("default throw on command failure", async () => { - try { - await $`echo hi; ls oogabooga`.quiet(); - expect.unreachable(); - } catch (e: any) { - expect(e).toBeInstanceOf(Error); - expect(e.exitCode).toBe(1); - expect(e.message).toBe("Failed with exit code 1"); - expect(e.stdout.toString("utf-8")).toBe("hi\n"); - expect(e.stderr.toString("utf-8")).toBe("ls: oogabooga: No such file or directory\n"); - } + // Run in a subproc because other tests may change the value of $.throws + const code = /* ts */ ` + import { $ } from "bun"; + import { afterAll, beforeAll, describe, expect, test } from "bun:test"; + test('test', async () => { + try { + await $\`echo hi; ls oogabooga\`.quiet(); + expect.unreachable(); + } catch (e: any) { + expect(e).toBeInstanceOf(Error); + expect(e.exitCode).toBe(1); + expect(e.message).toBe("Failed with exit code 1"); + expect(e.stdout.toString("utf-8")).toBe("hi\\n"); + expect(e.stderr.toString("utf-8")).toBe("ls: oogabooga: No such file or directory\\n"); + } + }) + `; + + await TestBuilder.command`echo ${code} > index.test.ts; ${bunExe()} test index.test.ts` + .ensureTempDir() + .stderr(s => s.includes("1 pass")) + .env(bunEnv) + .run(); }); test("ShellError has .text()", async () => { - try { - await $`ls oogabooga`.quiet(); - expect.unreachable(); - } catch (e: any) { - expect(e).toBeInstanceOf(Error); - expect(e.exitCode).toBe(1); - expect(e.stderr.toString("utf-8")).toBe("ls: oogabooga: No such file or directory\n"); - } + // Run in a subproc because other tests may change the value of $.throws + const code = /* ts */ ` + import { $ } from "bun"; + import { afterAll, beforeAll, describe, expect, test } from "bun:test"; + test('test', async () => { + try { + await $\`ls oogabooga\`.quiet(); + expect.unreachable(); + } catch (e: any) { + expect(e).toBeInstanceOf(Error); + expect(e.exitCode).toBe(1); + expect(e.stderr.toString("utf-8")).toBe("ls: oogabooga: No such file or directory\\n"); + } + }) + `; + + await TestBuilder.command`echo ${code} > index.test.ts; ${bunExe()} test index.test.ts` + .ensureTempDir() + .stderr(s => s.includes("1 pass")) + .env(bunEnv) + .run(); }); diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index 3fbe3da4b27106..3a21c892d7126a 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -101,8 +101,8 @@ describe("bunshell", () => { const buf = new Uint8Array(1); expect(async () => { - await TestBuilder.command`echo hi > \\${buf}`.run(); - }).toThrow("Redirection with no file"); + await TestBuilder.command`echo hi > \\${buf}`.error("Redirection with no file").run(); + }); }); test("in command position", async () => { diff --git a/test/js/bun/shell/commands/cp.test.ts b/test/js/bun/shell/commands/cp.test.ts new file mode 100644 index 00000000000000..2c4a3e8429f6fc --- /dev/null +++ b/test/js/bun/shell/commands/cp.test.ts @@ -0,0 +1,182 @@ +import { $ } from "bun"; +import { bunExe, createTestBuilder } from "../test_builder"; +import { beforeAll, describe, test, expect, beforeEach } from "bun:test"; +import { sortedShellOutput } from "../util"; +import { tempDirWithFiles } from "harness"; +import fs from "fs"; +import { shellInternals } from "bun:internal-for-testing"; +const { builtinDisabled } = shellInternals; + +const TestBuilder = createTestBuilder(import.meta.path); + +const p = process.platform === "win32" ? (s: string) => s.replaceAll("/", "\\") : (s: string) => s; + +$.nothrow(); + +describe.if(!builtinDisabled("cp"))("bunshell cp", async () => { + TestBuilder.command`cat ${import.meta.filename} > lmao.txt; cp -v lmao.txt lmao2.txt` + .stdout(p("$TEMP_DIR/lmao.txt -> $TEMP_DIR/lmao2.txt\n")) + .ensureTempDir() + .testMini() + .fileEquals("lmao2.txt", () => $`cat ${import.meta.filename}`.text()) + .runAsTest("file -> file"); + + TestBuilder.command`cat ${import.meta.filename} > lmao.txt; touch lmao2.txt; cp -v lmao.txt lmao2.txt` + .stdout(p("$TEMP_DIR/lmao.txt -> $TEMP_DIR/lmao2.txt\n")) + .ensureTempDir() + .testMini() + .fileEquals("lmao2.txt", () => $`cat ${import.meta.filename}`.text()) + .runAsTest("file -> existing file replaces contents"); + + TestBuilder.command`cat ${import.meta.filename} > lmao.txt; mkdir lmao2; cp -v lmao.txt lmao2` + .ensureTempDir() + .stdout(p("$TEMP_DIR/lmao.txt -> $TEMP_DIR/lmao2/lmao.txt\n")) + .fileEquals("lmao2/lmao.txt", () => $`cat ${import.meta.filename}`.text()) + .testMini() + .runAsTest("file -> dir"); + + TestBuilder.command`cat ${import.meta.filename} > lmao.txt; cp -v lmao.txt lmao2/` + .ensureTempDir() + .stderr("cp: lmao2/ is not a directory\n") + .exitCode(1) + .testMini() + .runAsTest("file -> non-existent dir fails"); + + TestBuilder.command`cat ${import.meta.filename} > lmao.txt; cat ${import.meta.filename} > lmao2.txt; mkdir lmao3; cp -v lmao.txt lmao2.txt lmao3` + .ensureTempDir() + .stdout( + expectSortedOutput( + p("$TEMP_DIR/lmao.txt -> $TEMP_DIR/lmao3/lmao.txt\n$TEMP_DIR/lmao2.txt -> $TEMP_DIR/lmao3/lmao2.txt\n"), + ), + ) + .fileEquals("lmao3/lmao.txt", () => $`cat ${import.meta.filename}`.text()) + .fileEquals("lmao3/lmao2.txt", () => $`cat ${import.meta.filename}`.text()) + .testMini() + .runAsTest("file+ -> dir"); + + TestBuilder.command`mkdir lmao; mkdir lmao2; cp -v lmao lmao2 lmao3` + .ensureTempDir() + .stderr(expectSortedOutput("cp: lmao is a directory (not copied)\ncp: lmao2 is a directory (not copied)\n")) + .exitCode(1) + .testMini() + .runAsTest("dir -> ? fails without -R"); + + describe("EBUSY windows", () => { + TestBuilder.command/* sh */ ` + echo hi! > hello.txt + mkdir somedir + cp ${{ raw: Array(50).fill("hello.txt").join(" ") }} somedir + ` + .ensureTempDir() + .exitCode(0) + .fileEquals("somedir/hello.txt", "hi!\n") + .runAsTest("doesn't fail on EBUSY when copying multiple files that are the same"); + }); + + describe("uutils ported", () => { + const TEST_EXISTING_FILE: string = "existing_file.txt"; + const TEST_HELLO_WORLD_SOURCE: string = "hello_world.txt"; + const TEST_HELLO_WORLD_SOURCE_SYMLINK: string = "hello_world.txt.link"; + const TEST_HELLO_WORLD_DEST: string = "copy_of_hello_world.txt"; + const TEST_HELLO_WORLD_DEST_SYMLINK: string = "copy_of_hello_world.txt.link"; + const TEST_HOW_ARE_YOU_SOURCE: string = "how_are_you.txt"; + const TEST_HOW_ARE_YOU_DEST: string = "hello_dir/how_are_you.txt"; + const TEST_COPY_TO_FOLDER: string = "hello_dir/"; + const TEST_COPY_TO_FOLDER_FILE: string = "hello_dir/hello_world.txt"; + const TEST_COPY_FROM_FOLDER: string = "hello_dir_with_file/"; + const TEST_COPY_FROM_FOLDER_FILE: string = "hello_dir_with_file/hello_world.txt"; + const TEST_COPY_TO_FOLDER_NEW: string = "hello_dir_new"; + const TEST_COPY_TO_FOLDER_NEW_FILE: string = "hello_dir_new/hello_world.txt"; + + // beforeAll doesn't work beacuse of the way TestBuilder is setup + const tempFiles = { + "hello_world.txt": "Hello, World!", + "existing_file.txt": "Cogito ergo sum.", + "how_are_you.txt": "How are you?", + "hello_dir": { + "hello.txt": "", + }, + "hello_dir_with_file": { + "hello_world.txt": "Hello, World!", + }, + "dir_with_10_files": { + "0": "", + "1": "", + "2": "", + "3": "", + "4": "", + "5": "", + "6": "", + "7": "", + "8": "", + "9": "", + }, + }; + const tmpdir: string = tempDirWithFiles("cp-uutils", tempFiles); + const mini_tmpdir: string = tempDirWithFiles("cp-uutils-mini", tempFiles); + + TestBuilder.command`cp ${TEST_HELLO_WORLD_SOURCE} ${TEST_HELLO_WORLD_DEST}` + .ensureTempDir(tmpdir) + .fileEquals(TEST_HELLO_WORLD_DEST, "Hello, World!") + .testMini({ cwd: mini_tmpdir }) + .runAsTest("cp_cp"); + + TestBuilder.command`cp ${TEST_HELLO_WORLD_SOURCE} ${TEST_EXISTING_FILE}` + .ensureTempDir(tmpdir) + .fileEquals(TEST_EXISTING_FILE, "Hello, World!") + .testMini({ cwd: mini_tmpdir }) + .runAsTest("cp_existing_target"); + + TestBuilder.command`cp ${TEST_HELLO_WORLD_SOURCE} ${TEST_HELLO_WORLD_SOURCE} ${TEST_COPY_TO_FOLDER}` + .ensureTempDir(tmpdir) + .file(TEST_EXISTING_FILE, "Hello, World!\n") + .testMini({ cwd: mini_tmpdir }) + .runAsTest("cp_duplicate_files"); + + TestBuilder.command`touch a; cp a a` + .ensureTempDir(tmpdir) + .stderr_contains("cp: a and a are identical (not copied)\n") + .exitCode(1) + .testMini({ cwd: mini_tmpdir }) + .runAsTest("cp_same_file"); + + TestBuilder.command`cp ${TEST_HELLO_WORLD_SOURCE} ${TEST_HELLO_WORLD_SOURCE} ${TEST_EXISTING_FILE}` + .ensureTempDir(tmpdir) + .stderr_contains(`cp: ${TEST_EXISTING_FILE} is not a directory\n`) + .exitCode(1) + .testMini({ cwd: mini_tmpdir }) + .runAsTest("cp_multiple_files_target_is_file"); + + TestBuilder.command`cp ${TEST_COPY_TO_FOLDER} ${TEST_HELLO_WORLD_DEST}` + .ensureTempDir(tmpdir) + .stderr_contains(`cp: ${TEST_COPY_TO_FOLDER} is a directory (not copied)\n`) + .exitCode(1) + .testMini({ cwd: mini_tmpdir }) + .runAsTest("cp_directory_not_recursive"); + + TestBuilder.command`cp ${TEST_HELLO_WORLD_SOURCE} ${TEST_HOW_ARE_YOU_SOURCE} ${TEST_COPY_TO_FOLDER}` + .ensureTempDir(tmpdir) + .fileEquals(TEST_COPY_TO_FOLDER_FILE, "Hello, World!") + .fileEquals(TEST_HOW_ARE_YOU_DEST, "How are you?") + .testMini({ cwd: mini_tmpdir }) + .runAsTest("cp_multiple_files"); + + TestBuilder.command`cp ${TEST_HELLO_WORLD_SOURCE} ${TEST_HOW_ARE_YOU_SOURCE} ${TEST_COPY_TO_FOLDER} && ${bunExe()} -e ${'console.log("HI")'}` + .ensureTempDir(tmpdir) + .stdout("HI\n") + .runAsTest("cp_multiple_files"); + + TestBuilder.command`cp -R ${TEST_COPY_FROM_FOLDER} ${TEST_COPY_TO_FOLDER_NEW}` + .ensureTempDir(tmpdir) + .fileEquals(TEST_COPY_TO_FOLDER_NEW_FILE, "Hello, World!") + .testMini({ cwd: mini_tmpdir }) + .runAsTest("cp_recurse"); + }); +}); + +function expectSortedOutput(expected: string) { + return (stdout: string, tempdir: string) => + expect(sortedShellOutput(stdout).join("\n")).toEqual( + sortedShellOutput(expected).join("\n").replaceAll("$TEMP_DIR", tempdir), + ); +} diff --git a/test/js/bun/shell/env.positionals.test.ts b/test/js/bun/shell/env.positionals.test.ts index 407c54b1842cc1..2a87c303612066 100644 --- a/test/js/bun/shell/env.positionals.test.ts +++ b/test/js/bun/shell/env.positionals.test.ts @@ -5,6 +5,7 @@ const TestBuilder = createTestBuilder(import.meta.path); import { bunEnv, bunExe } from "harness"; import * as path from "node:path"; +$.nothrow(); describe("$ argv", async () => { for (let i = 0; i < process.argv.length; i++) { const element = process.argv[i]; diff --git a/test/js/bun/shell/exec.test.ts b/test/js/bun/shell/exec.test.ts index 498add1fff59d9..2bf67e29fad19f 100644 --- a/test/js/bun/shell/exec.test.ts +++ b/test/js/bun/shell/exec.test.ts @@ -6,6 +6,7 @@ import { bunEnv } from "harness"; const BUN = process.argv0; +$.nothrow(); describe("bun exec", () => { TestBuilder.command`${BUN} exec ${"echo hi!"}`.env(bunEnv).stdout("hi!\n").runAsTest("it works"); TestBuilder.command`${BUN} exec sldkfjslkdjflksdjflj` diff --git a/test/js/bun/shell/test_builder.ts b/test/js/bun/shell/test_builder.ts index a43aa762a681d7..97c0bb3a856b38 100644 --- a/test/js/bun/shell/test_builder.ts +++ b/test/js/bun/shell/test_builder.ts @@ -1,9 +1,10 @@ import { ShellError, ShellOutput } from "bun"; -import { ShellPromise } from "bun"; +import { ShellPromise, ShellExpression } from "bun"; // import { tempDirWithFiles } from "harness"; import { join } from "node:path"; import * as os from "node:os"; import * as fs from "node:fs"; +// import { bunExe } from "harness"; export function createTestBuilder(path: string) { var { describe, test, afterAll, beforeAll, expect, beforeEach, afterEach } = Bun.jest(path); @@ -17,32 +18,41 @@ export function createTestBuilder(path: string) { }); class TestBuilder { - promise: { type: "ok"; val: ShellPromise } | { type: "err"; val: Error }; _testName: string | undefined = undefined; expected_stdout: string | ((stdout: string, tempdir: string) => void) = ""; - expected_stderr: string | ((stderr: string, tempdir: string) => void) = ""; + expected_stderr: string | ((stderr: string, tempdir: string) => void) | { contains: string } = ""; expected_exit_code: number = 0; expected_error: ShellError | string | boolean | undefined = undefined; - file_equals: { [filename: string]: string } = {}; + file_equals: { [filename: string]: string | (() => string | Promise) } = {}; _doesNotExist: string[] = []; _timeout: number | undefined = undefined; tempdir: string | undefined = undefined; _env: { [key: string]: string } | undefined = undefined; + _cwd: string | undefined = undefined; + + _miniCwd: string | undefined = undefined; + _quiet: boolean = false; + + _testMini: boolean = false; + _onlyMini: boolean = false; + __insideExec: boolean = false; + _scriptStr: TemplateStringsArray; + _expresssions: ShellExpression[]; + + _skipExecOnUnknownType: boolean = false; __todo: boolean | string = false; - UNEXPECTED_SUBSHELL_ERROR_OPEN = - "Unexpected `(`, subshells are currently not supported right now. Escape the `(` or open a GitHub issue."; + constructor(_scriptStr: TemplateStringsArray, _expressions: any[]) { + this._scriptStr = _scriptStr; + this._expresssions = _expressions; + } UNEXPECTED_SUBSHELL_ERROR_CLOSE = "Unexpected `)`, subshells are currently not supported right now. Escape the `)` or open a GitHub issue."; - public constructor(promise: TestBuilder["promise"]) { - this.promise = promise; - } - /** * Start the test builder with a command: * @@ -53,19 +63,16 @@ export function createTestBuilder(path: string) { * TestBuilder.command`echo hi!`.stdout('hi!\n').runAsTest('echo works') * ``` */ - public static command(strings: TemplateStringsArray, ...expressions: any[]): TestBuilder { - try { - if (process.env.BUN_DEBUG_SHELL_LOG_CMD === "1") console.info("[ShellTestBuilder] Cmd", strings.join("")); - const promise = Bun.$(strings, ...expressions).nothrow(); - const This = new this({ type: "ok", val: promise }); - This._testName = strings.join(""); - return This; - } catch (err) { - return new this({ type: "err", val: err as Error }); - } + static command(strings: TemplateStringsArray, ...expressions: any[]): TestBuilder { + return new TestBuilder(strings, expressions); + } + + cwd(path: string): this { + this._cwd = path; + return this; } - public directory(path: string): this { + directory(path: string): this { const tempdir = this.getTempDir(); fs.mkdirSync(join(tempdir, path), { recursive: true }); return this; @@ -76,6 +83,18 @@ export function createTestBuilder(path: string) { return this; } + /** + * @param opts + * @returns + */ + testMini(opts?: { errorOnSupportedTemplate?: boolean; onlyMini?: boolean; cwd?: string }): this { + this._testMini = true; + this._skipExecOnUnknownType = opts?.errorOnSupportedTemplate ?? false; + this._onlyMini = opts?.onlyMini ?? false; + this._miniCwd = opts?.cwd; + return this; + } + /** * Create a file in a temp directory * @param path Path to the new file, this will be inside the TestBuilder's temp directory @@ -102,9 +121,7 @@ export function createTestBuilder(path: string) { } quiet(): this { - if (this.promise.type === "ok") { - this.promise.val.quiet(); - } + this._quiet = true; return this; } @@ -128,14 +145,22 @@ export function createTestBuilder(path: string) { return this; } + stderr_contains(expected: string): this { + this.expected_stderr = { contains: expected }; + return this; + } + /** * Makes this test use a temp directory: * - The shell's cwd will be set to the temp directory * - All FS functions on the `TestBuilder` will use this temp directory. * @returns */ - ensureTempDir(): this { - this.getTempDir(); + ensureTempDir(str?: string): this { + if (str !== undefined) { + this.setTempdir(str); + } else this.getTempDir(); + return this; } @@ -155,7 +180,7 @@ export function createTestBuilder(path: string) { return this; } - fileEquals(filename: string, expected: string): this { + fileEquals(filename: string, expected: string | (() => string | Promise)): this { this.getTempDir(); this.file_equals[filename] = expected; return this; @@ -168,18 +193,17 @@ export function createTestBuilder(path: string) { setTempdir(tempdir: string): this { this.tempdir = tempdir; - if (this.promise.type === "ok") { - this.promise.val.cwd(this.tempdir!); - } return this; } + newTempdir(): string { + this.tempdir = undefined; + return this.getTempDir(); + } + getTempDir(): string { if (this.tempdir === undefined) { this.tempdir = TestBuilder.tmpdir(); - if (this.promise.type === "ok") { - this.promise.val.cwd(this.tempdir!); - } return this.tempdir!; } return this.tempdir; @@ -190,35 +214,7 @@ export function createTestBuilder(path: string) { return this; } - async run(): Promise { - if (!insideTestScope) { - const err = new Error("TestBuilder.run() must be called inside a test scope"); - test("TestBuilder.run() must be called inside a test scope", () => { - throw err; - }); - return Promise.resolve(undefined); - } - - if (this.promise.type === "err") { - const err = this.promise.val; - if (this.expected_error === undefined) throw err; - if (this.expected_error === true) return undefined; - if (this.expected_error === false) expect(err).toBeUndefined(); - if (typeof this.expected_error === "string") { - expect(err.message).toEqual(this.expected_error); - } else if (this.expected_error instanceof ShellError) { - expect(err).toBeInstanceOf(ShellError); - const e = err as ShellError; - expect(e.exitCode).toEqual(this.expected_error.exitCode); - expect(e.stdout.toString()).toEqual(this.expected_error.stdout.toString()); - expect(e.stderr.toString()).toEqual(this.expected_error.stderr.toString()); - } - return undefined; - } - - const output = await (this._env !== undefined ? this.promise.val.env(this._env) : this.promise.val); - - const { stdout, stderr, exitCode } = output!; + async doChecks(stdout: Buffer, stderr: Buffer, exitCode: number): Promise { const tempdir = this.tempdir || "NO_TEMP_DIR"; if (this.expected_stdout !== undefined) { if (typeof this.expected_stdout === "string") { @@ -230,13 +226,16 @@ export function createTestBuilder(path: string) { if (this.expected_stderr !== undefined) { if (typeof this.expected_stderr === "string") { expect(stderr.toString()).toEqual(this.expected_stderr.replaceAll("$TEMP_DIR", tempdir)); - } else { + } else if (typeof this.expected_stderr === "function") { this.expected_stderr(stderr.toString(), tempdir); + } else { + expect(stderr.toString()).toContain(this.expected_stderr.contains); } } if (this.expected_exit_code !== undefined) expect(exitCode).toEqual(this.expected_exit_code); - for (const [filename, expected] of Object.entries(this.file_equals)) { + for (const [filename, expected_raw] of Object.entries(this.file_equals)) { + const expected = typeof expected_raw === "string" ? expected_raw : await expected_raw(); const actual = await Bun.file(join(this.tempdir!, filename)).text(); expect(actual).toEqual(expected); } @@ -244,6 +243,50 @@ export function createTestBuilder(path: string) { for (const fsname of this._doesNotExist) { expect(fs.existsSync(join(this.tempdir!, fsname))).toBeFalsy(); } + } + + async run(): Promise { + if (!insideTestScope) { + const err = new Error("TestBuilder.run() must be called inside a test scope"); + test("TestBuilder.run() must be called inside a test scope", () => { + throw err; + }); + return Promise.resolve(undefined); + } + + try { + let finalPromise = Bun.$(this._scriptStr, ...this._expresssions); + if (this.tempdir) finalPromise = finalPromise.cwd(this.tempdir); + if (this._cwd) finalPromise = finalPromise.cwd(this._cwd); + if (this._env) finalPromise = finalPromise.env(this._env); + if (this._quiet) finalPromise = finalPromise.quiet(); + const output = await finalPromise; + + const { stdout, stderr, exitCode } = output; + await this.doChecks(stdout, stderr, exitCode); + } catch (err_) { + const err: ShellError = err_ as any; + const { stdout, stderr, exitCode } = err; + if (this.expected_error === undefined) { + if (stdout === undefined || stderr === undefined || exitCode === undefined) { + throw err_; + } + this.doChecks(stdout, stderr, exitCode); + return; + } + if (this.expected_error === true) return undefined; + if (this.expected_error === false) expect(err).toBeUndefined(); + if (typeof this.expected_error === "string") { + expect(err.message).toEqual(this.expected_error); + } else if (this.expected_error instanceof ShellError) { + expect(err).toBeInstanceOf(ShellError); + const e = err as ShellError; + expect(e.exitCode).toEqual(this.expected_error.exitCode); + expect(e.stdout.toString()).toEqual(this.expected_error.stdout.toString()); + expect(e.stderr.toString()).toEqual(this.expected_error.stderr.toString()); + } + return undefined; + } // return output; } @@ -260,63 +303,104 @@ export function createTestBuilder(path: string) { test.todo(typeof this.__todo === "string" ? `${name} skipped: ${this.__todo}` : name, async () => { await tb.run(); }); + return; } else { - test( - name, - async () => { - await tb.run(); - }, - this._timeout, - ); + if (!this._onlyMini) { + test( + name, + async () => { + await tb.run(); + }, + this._timeout, + ); + } + + if (this._testMini) { + test( + name + " (exec)", + async () => { + let cwd: string = ""; + if (tb._miniCwd === undefined) { + cwd = tb.newTempdir(); + } else { + tb.tempdir = tb._miniCwd; + tb._cwd = tb._miniCwd; + cwd = tb._cwd; + } + const joinedstr = tb.joinTemplate(); + await Bun.$`echo ${joinedstr} > script.bun.sh`.cwd(cwd); + ((script: TemplateStringsArray, ...exprs: any[]) => { + tb._scriptStr = script; + tb._expresssions = exprs; + })`${bunExe()} run script.bun.sh`; + await tb.run(); + }, + this._timeout, + ); + } } } - // async run(): Promise { - // async function doTest(tb: TestBuilder) { - // if (tb.promise.type === "err") { - // const err = tb.promise.val; - // if (tb.expected_error === undefined) throw err; - // if (tb.expected_error === true) return undefined; - // if (tb.expected_error === false) expect(err).toBeUndefined(); - // if (typeof tb.expected_error === "string") { - // expect(err.message).toEqual(tb.expected_error); - // } - // return undefined; - // } - - // const output = await tb.promise.val; - - // const { stdout, stderr, exitCode } = output!; - // if (tb.expected_stdout !== undefined) expect(stdout.toString()).toEqual(tb.expected_stdout); - // if (tb.expected_stderr !== undefined) expect(stderr.toString()).toEqual(tb.expected_stderr); - // if (tb.expected_exit_code !== undefined) expect(exitCode).toEqual(tb.expected_exit_code); - - // for (const [filename, expected] of Object.entries(tb.file_equals)) { - // const actual = await Bun.file(filename).text(); - // expect(actual).toEqual(expected); - // } - // return output; - // } - - // if (this._testName !== undefined) { - // test(this._testName, async () => { - // await doTest(this); - // }); - // } - // await doTest(this); - // } - } - function generateRandomString(length: number): string { - const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - let result = ""; - const charactersLength = characters.length; + generateRandomString(length: number): string { + const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let result = ""; + const charactersLength = characters.length; - for (let i = 0; i < length; i++) { - result += characters.charAt(Math.floor(Math.random() * charactersLength)); + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + + return result; } - return result; + joinTemplate(): string { + let buf = []; + for (let i = 0; i < this._scriptStr.length; i++) { + buf.push(this._scriptStr[i]); + if (this._expresssions[i] !== undefined) { + const expr = this._expresssions[i]; + this.processShellExpr(buf, expr); + } + } + + return buf.join(""); + } + + processShellExpr(buf: string[], expr: ShellExpression) { + if (typeof expr === "string") { + buf.push(Bun.$.escape(expr)); + } else if (typeof expr === "number") { + buf.push(expr.toString()); + } else if (typeof expr?.raw === "string") { + buf.push(Bun.$.escape(expr.raw)); + } else if (Array.isArray(expr)) { + expr.forEach(e => this.processShellExpr(buf, e)); + } else { + if (this._skipExecOnUnknownType) { + console.warn(`Unexpected expression type: ${expr}\nSkipping.`); + return; + } + throw new Error(`Unexpected expression type ${expr}`); + } + } } return TestBuilder; } + +function generateRandomString(length: number): string { + const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let result = ""; + const charactersLength = characters.length; + + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + + return result; +} + +export function bunExe() { + if (process.platform === "win32") return process.execPath.replaceAll("\\", "/"); + return process.execPath; +} diff --git a/test/js/node/fs/cp.test.ts b/test/js/node/fs/cp.test.ts index 19e7b47d5b4ed0..c827fcfbb10e51 100644 --- a/test/js/node/fs/cp.test.ts +++ b/test/js/node/fs/cp.test.ts @@ -306,6 +306,14 @@ for (const [name, copy] of impls) { assertContent(basename + "/result/a.dir/c.txt", "c"); }); + + test.if(process.platform === "win32")("should not throw EBUSY when copying the same file on windows", async () => { + const basename = tempDirWithFiles("cp", { + "hey": "hi", + }); + + await copy(basename + "/hey", basename + "/hey"); + }); }); }